Spaces:
Runtime error
Runtime error
Commit ·
aeea10c
1
Parent(s): 3f79c45
LETS GOOOOOOOOOOOOOOOOOOOOOOOOO
Browse files- backend/app/services/billing.py +7 -5
- backend/app/services/plans.py +1 -1
- frontend/src/api/client.ts +14 -2
- frontend/src/api/endpoints.ts +9 -0
- frontend/src/api/types.ts +35 -0
- frontend/src/components/AppShell.tsx +55 -46
- frontend/src/components/Nudges.tsx +80 -0
- frontend/src/components/PricingTiers.tsx +7 -4
- frontend/src/components/UpgradeModal.tsx +228 -0
- frontend/src/hooks/useUpgrade.tsx +194 -0
- frontend/src/main.tsx +1 -0
- frontend/src/pages/Settings.tsx +20 -0
- frontend/src/styles/upgrade.css +603 -0
- frontend/tsconfig.tsbuildinfo +1 -1
- renderer/public/demo-vo/durations.json +8 -8
- renderer/public/launch-vo/durations.json +11 -11
- renderer/scripts/make_demo_vo.py +14 -10
- renderer/scripts/make_launch_vo.py +13 -13
- renderer/src/compositions/LaunchTrailer.tsx +82 -22
- renderer/src/compositions/Lockup.tsx +23 -0
- renderer/src/compositions/demoVo.ts +2 -0
- renderer/src/compositions/launchTimeline.ts +13 -13
backend/app/services/billing.py
CHANGED
|
@@ -56,14 +56,15 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic
|
|
| 56 |
if m["limit"] < 0 or m["key"] == "sources":
|
| 57 |
continue
|
| 58 |
label = m["label"].lower()
|
|
|
|
| 59 |
if m["pct"] >= 1:
|
| 60 |
out.append(_nudge(f"empty-{m['key']}-{month}", "limit", f"Out of {label} this month",
|
| 61 |
-
f"You have used all {
|
| 62 |
-
|
| 63 |
elif m["pct"] >= LOW_FUEL:
|
| 64 |
-
left =
|
| 65 |
out.append(_nudge(f"low-{m['key']}-{month}", "limit", f"Running low on {label}",
|
| 66 |
-
f"Only {left
|
| 67 |
"See plans", priority=90))
|
| 68 |
if not plan.voice_cloning:
|
| 69 |
out.append(_nudge("perk-voice", "upgrade", "Narrate in your own voice",
|
|
@@ -71,7 +72,8 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic
|
|
| 71 |
"Unlock voice", priority=40))
|
| 72 |
if not plan.brand_kit:
|
| 73 |
out.append(_nudge("perk-brand", "upgrade", "Put your brand on every render",
|
| 74 |
-
"Your colors and logo on videos, quote cards and carousels.", "Unlock brand kit",
|
|
|
|
| 75 |
|
| 76 |
sources = _count(db, Source, ws)
|
| 77 |
if sources == 0:
|
|
|
|
| 56 |
if m["limit"] < 0 or m["key"] == "sources":
|
| 57 |
continue
|
| 58 |
label = m["label"].lower()
|
| 59 |
+
amount = f"{m['limit']:g} min of {label}" if m["unit"] else f"{m['limit']:g} {label}"
|
| 60 |
if m["pct"] >= 1:
|
| 61 |
out.append(_nudge(f"empty-{m['key']}-{month}", "limit", f"Out of {label} this month",
|
| 62 |
+
f"You have used all {amount}. Upgrade to keep launching.", "Refuel now",
|
| 63 |
+
priority=100))
|
| 64 |
elif m["pct"] >= LOW_FUEL:
|
| 65 |
+
left = f"{m['limit'] - m['used']:g}" + (" min" if m["unit"] else "")
|
| 66 |
out.append(_nudge(f"low-{m['key']}-{month}", "limit", f"Running low on {label}",
|
| 67 |
+
f"Only {left} left this month. Upgrade before you stall mid launch.",
|
| 68 |
"See plans", priority=90))
|
| 69 |
if not plan.voice_cloning:
|
| 70 |
out.append(_nudge("perk-voice", "upgrade", "Narrate in your own voice",
|
|
|
|
| 72 |
"Unlock voice", priority=40))
|
| 73 |
if not plan.brand_kit:
|
| 74 |
out.append(_nudge("perk-brand", "upgrade", "Put your brand on every render",
|
| 75 |
+
"Your colors and logo on videos, quote cards and carousels.", "Unlock brand kit",
|
| 76 |
+
priority=30))
|
| 77 |
|
| 78 |
sources = _count(db, Source, ws)
|
| 79 |
if sources == 0:
|
backend/app/services/plans.py
CHANGED
|
@@ -146,7 +146,7 @@ def upgrade_for(plan: Plan, kind: str | None = None) -> str | None:
|
|
| 146 |
if kind == "voice_cloning" and candidate.voice_cloning:
|
| 147 |
return pid
|
| 148 |
current, better = getattr(plan, kind, 0), getattr(candidate, kind, 0)
|
| 149 |
-
if better < 0 or (isinstance(better,
|
| 150 |
return pid
|
| 151 |
return None
|
| 152 |
|
|
|
|
| 146 |
if kind == "voice_cloning" and candidate.voice_cloning:
|
| 147 |
return pid
|
| 148 |
current, better = getattr(plan, kind, 0), getattr(candidate, kind, 0)
|
| 149 |
+
if better < 0 or (isinstance(better, int | float) and better > current):
|
| 150 |
return pid
|
| 151 |
return None
|
| 152 |
|
frontend/src/api/client.ts
CHANGED
|
@@ -5,13 +5,20 @@ const REFRESH = "ns_refresh";
|
|
| 5 |
export class ApiError extends Error {
|
| 6 |
status: number;
|
| 7 |
code?: string;
|
| 8 |
-
|
|
|
|
| 9 |
super(message);
|
| 10 |
this.status = status;
|
| 11 |
this.code = code;
|
|
|
|
| 12 |
}
|
| 13 |
}
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
function read(key: string): string | null {
|
| 16 |
try {
|
| 17 |
return localStorage.getItem(key);
|
|
@@ -48,6 +55,7 @@ export const tokens = {
|
|
| 48 |
async function parseError(res: Response): Promise<ApiError> {
|
| 49 |
let message = res.statusText || "Something went wrong";
|
| 50 |
let code: string | undefined;
|
|
|
|
| 51 |
try {
|
| 52 |
const body = await res.json();
|
| 53 |
const detail = body.detail;
|
|
@@ -55,11 +63,15 @@ async function parseError(res: Response): Promise<ApiError> {
|
|
| 55 |
else if (detail?.message) {
|
| 56 |
message = detail.message;
|
| 57 |
code = detail.code;
|
|
|
|
| 58 |
}
|
| 59 |
} catch {
|
| 60 |
/* non JSON */
|
| 61 |
}
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
| 63 |
}
|
| 64 |
|
| 65 |
async function tryRefresh(): Promise<boolean> {
|
|
|
|
| 5 |
export class ApiError extends Error {
|
| 6 |
status: number;
|
| 7 |
code?: string;
|
| 8 |
+
detail?: Record<string, unknown>;
|
| 9 |
+
constructor(status: number, message: string, code?: string, detail?: Record<string, unknown>) {
|
| 10 |
super(message);
|
| 11 |
this.status = status;
|
| 12 |
this.code = code;
|
| 13 |
+
this.detail = detail;
|
| 14 |
}
|
| 15 |
}
|
| 16 |
|
| 17 |
+
/** Fired on every 402 plan_limit so the upgrade popup opens wherever the request came from. */
|
| 18 |
+
export const PLAN_LIMIT_EVENT = "ns:plan-limit";
|
| 19 |
+
|
| 20 |
+
export type PlanLimitDetail = { message: string; kind?: string; plan?: string; upgrade_to?: string | null };
|
| 21 |
+
|
| 22 |
function read(key: string): string | null {
|
| 23 |
try {
|
| 24 |
return localStorage.getItem(key);
|
|
|
|
| 55 |
async function parseError(res: Response): Promise<ApiError> {
|
| 56 |
let message = res.statusText || "Something went wrong";
|
| 57 |
let code: string | undefined;
|
| 58 |
+
let extra: Record<string, unknown> | undefined;
|
| 59 |
try {
|
| 60 |
const body = await res.json();
|
| 61 |
const detail = body.detail;
|
|
|
|
| 63 |
else if (detail?.message) {
|
| 64 |
message = detail.message;
|
| 65 |
code = detail.code;
|
| 66 |
+
extra = detail;
|
| 67 |
}
|
| 68 |
} catch {
|
| 69 |
/* non JSON */
|
| 70 |
}
|
| 71 |
+
if (res.status === 402 && code === "plan_limit") {
|
| 72 |
+
window.dispatchEvent(new CustomEvent<PlanLimitDetail>(PLAN_LIMIT_EVENT, { detail: { ...extra, message } }));
|
| 73 |
+
}
|
| 74 |
+
return new ApiError(res.status, message, code, extra);
|
| 75 |
}
|
| 76 |
|
| 77 |
async function tryRefresh(): Promise<boolean> {
|
frontend/src/api/endpoints.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { api, del, patch, post, put, qs, uploadFile } from "./client";
|
|
| 2 |
import type {
|
| 3 |
Artifact,
|
| 4 |
ArtifactType,
|
|
|
|
| 5 |
CalendarItem,
|
| 6 |
ChatMessage,
|
| 7 |
ChatSummary,
|
|
@@ -179,3 +180,11 @@ export const settingsApi = {
|
|
| 179 |
update: (body: SettingsPatch) => patch<Settings>("/api/settings", body),
|
| 180 |
usage: () => api<Usage>("/api/settings/usage"),
|
| 181 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import type {
|
| 3 |
Artifact,
|
| 4 |
ArtifactType,
|
| 5 |
+
BillingStatus,
|
| 6 |
CalendarItem,
|
| 7 |
ChatMessage,
|
| 8 |
ChatSummary,
|
|
|
|
| 180 |
update: (body: SettingsPatch) => patch<Settings>("/api/settings", body),
|
| 181 |
usage: () => api<Usage>("/api/settings/usage"),
|
| 182 |
};
|
| 183 |
+
|
| 184 |
+
export type BillingCycle = "monthly" | "annual";
|
| 185 |
+
|
| 186 |
+
export const billingApi = {
|
| 187 |
+
status: () => api<BillingStatus>("/api/billing/status"),
|
| 188 |
+
checkout: (plan: string, cycle: BillingCycle) => post<{ url: string }>("/api/billing/checkout", { plan, cycle }),
|
| 189 |
+
portal: () => post<{ url: string }>("/api/billing/portal"),
|
| 190 |
+
};
|
frontend/src/api/types.ts
CHANGED
|
@@ -310,3 +310,38 @@ export type Settings = {
|
|
| 310 |
usage: Usage;
|
| 311 |
integrations: { llm: boolean; elevenlabs: boolean; x: boolean; linkedin: boolean; storage: string; renderer: string };
|
| 312 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
usage: Usage;
|
| 311 |
integrations: { llm: boolean; elevenlabs: boolean; x: boolean; linkedin: boolean; storage: string; renderer: string };
|
| 312 |
};
|
| 313 |
+
|
| 314 |
+
export type PlanInfo = Plan & {
|
| 315 |
+
tagline: string;
|
| 316 |
+
price_monthly_usd: number;
|
| 317 |
+
price_annual_monthly_usd: number;
|
| 318 |
+
price_annual_usd: number;
|
| 319 |
+
annual_savings_pct: number;
|
| 320 |
+
features: string[];
|
| 321 |
+
};
|
| 322 |
+
|
| 323 |
+
export type Meter = { key: string; label: string; unit: string; used: number; limit: number; pct: number };
|
| 324 |
+
|
| 325 |
+
/** tone: limit (low or out of fuel), upgrade (locked perk), action (use what you have). No `to` opens the upgrade popup. */
|
| 326 |
+
export type Nudge = {
|
| 327 |
+
id: string;
|
| 328 |
+
tone: "limit" | "upgrade" | "action";
|
| 329 |
+
title: string;
|
| 330 |
+
body: string;
|
| 331 |
+
cta: string;
|
| 332 |
+
to: string | null;
|
| 333 |
+
priority: number;
|
| 334 |
+
};
|
| 335 |
+
|
| 336 |
+
export type BillingStatus = {
|
| 337 |
+
billing_enabled: boolean;
|
| 338 |
+
plan: PlanInfo;
|
| 339 |
+
plans: PlanInfo[];
|
| 340 |
+
next_plan: string | null;
|
| 341 |
+
can_upgrade: boolean;
|
| 342 |
+
has_billing_account: boolean;
|
| 343 |
+
period_end: string | null;
|
| 344 |
+
meters: Meter[];
|
| 345 |
+
since: string;
|
| 346 |
+
nudges: Nudge[];
|
| 347 |
+
};
|
frontend/src/components/AppShell.tsx
CHANGED
|
@@ -1,8 +1,11 @@
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { NavLink, Outlet, Navigate, useLocation } from "react-router-dom";
|
| 3 |
import { useAuth } from "../hooks/useAuth";
|
|
|
|
| 4 |
import Logo from "./Logo";
|
|
|
|
| 5 |
import SkyCanvas from "./SkyCanvas";
|
|
|
|
| 6 |
import {
|
| 7 |
AsteroidIcon,
|
| 8 |
CometIcon,
|
|
@@ -62,52 +65,58 @@ export default function AppShell() {
|
|
| 62 |
}
|
| 63 |
|
| 64 |
return (
|
| 65 |
-
<
|
| 66 |
-
<
|
| 67 |
-
|
| 68 |
-
<
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
</button>
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
</
|
| 92 |
-
</
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
</NavLink>
|
| 99 |
-
))}
|
| 100 |
-
</nav>
|
| 101 |
-
<div className="sidebar-user">
|
| 102 |
-
<span className="mono muted side-label">{user.email}</span>
|
| 103 |
-
<button className="link-btn" onClick={logout} title="Sign out">
|
| 104 |
-
Sign out
|
| 105 |
-
</button>
|
| 106 |
-
</div>
|
| 107 |
-
</aside>
|
| 108 |
-
<main className="shell-main">
|
| 109 |
-
<Outlet />
|
| 110 |
-
</main>
|
| 111 |
-
</div>
|
| 112 |
);
|
| 113 |
}
|
|
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { NavLink, Outlet, Navigate, useLocation } from "react-router-dom";
|
| 3 |
import { useAuth } from "../hooks/useAuth";
|
| 4 |
+
import { UpgradeProvider } from "../hooks/useUpgrade";
|
| 5 |
import Logo from "./Logo";
|
| 6 |
+
import { FuelGauge, NudgeDock } from "./Nudges";
|
| 7 |
import SkyCanvas from "./SkyCanvas";
|
| 8 |
+
import UpgradeModal from "./UpgradeModal";
|
| 9 |
import {
|
| 10 |
AsteroidIcon,
|
| 11 |
CometIcon,
|
|
|
|
| 65 |
}
|
| 66 |
|
| 67 |
return (
|
| 68 |
+
<UpgradeProvider>
|
| 69 |
+
<div className={`shell${collapsed ? " collapsed" : ""}`}>
|
| 70 |
+
<SkyCanvas intensity={0.35} />
|
| 71 |
+
<aside className="sidebar">
|
| 72 |
+
<div className="sidebar-top">
|
| 73 |
+
{collapsed ? (
|
| 74 |
+
<button type="button" className="sidebar-brand sidebar-brand-btn" onClick={toggle} aria-label="Expand sidebar" title="Expand sidebar">
|
| 75 |
+
<Logo size={28} />
|
| 76 |
+
</button>
|
| 77 |
+
) : (
|
| 78 |
+
<NavLink to="/" className="sidebar-brand">
|
| 79 |
+
<Logo size={28} />
|
| 80 |
+
</NavLink>
|
| 81 |
+
)}
|
| 82 |
+
<button
|
| 83 |
+
type="button"
|
| 84 |
+
className="icon-btn sidebar-toggle"
|
| 85 |
+
onClick={toggle}
|
| 86 |
+
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
| 87 |
+
aria-expanded={!collapsed}
|
| 88 |
+
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
| 89 |
+
>
|
| 90 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
| 91 |
+
<rect x="3.5" y="4.5" width="17" height="15" rx="2.5" />
|
| 92 |
+
<path d="M9 4.5v15" />
|
| 93 |
+
<path d={collapsed ? "M13 10l2 2-2 2" : "M15 10l-2 2 2 2"} />
|
| 94 |
+
</svg>
|
| 95 |
</button>
|
| 96 |
+
</div>
|
| 97 |
+
<nav aria-label="App">
|
| 98 |
+
{NAV.map(({ to, label, icon: Icon, end }) => (
|
| 99 |
+
<NavLink key={to} to={to} end={end} title={collapsed ? label : undefined} className={({ isActive }) => `side-link${isActive ? " active" : ""}`}>
|
| 100 |
+
<Icon />
|
| 101 |
+
<span className="side-label">{label}</span>
|
| 102 |
+
</NavLink>
|
| 103 |
+
))}
|
| 104 |
+
</nav>
|
| 105 |
+
<FuelGauge collapsed={collapsed} />
|
| 106 |
+
<div className="sidebar-user">
|
| 107 |
+
<span className="mono muted side-label">{user.email}</span>
|
| 108 |
+
<button className="link-btn" onClick={logout} title="Sign out">
|
| 109 |
+
Sign out
|
| 110 |
+
</button>
|
| 111 |
+
</div>
|
| 112 |
+
</aside>
|
| 113 |
+
<main className="shell-main">
|
| 114 |
+
<Outlet />
|
| 115 |
+
</main>
|
| 116 |
+
</div>
|
| 117 |
+
{/* Outside .shell: its children get position: relative, and these are fixed overlays. */}
|
| 118 |
+
<NudgeDock />
|
| 119 |
+
<UpgradeModal />
|
| 120 |
+
</UpgradeProvider>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
);
|
| 122 |
}
|
frontend/src/components/Nudges.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useNavigate } from "react-router-dom";
|
| 2 |
+
import { useUpgrade } from "../hooks/useUpgrade";
|
| 3 |
+
import { CometIcon, RocketIcon, SparkleIcon } from "./icons/Icons";
|
| 4 |
+
|
| 5 |
+
const TONE_ICON = { limit: RocketIcon, upgrade: SparkleIcon, action: CometIcon };
|
| 6 |
+
const TONE_LABEL = { limit: "Fuel check", upgrade: "Unlock", action: "Next mission" };
|
| 7 |
+
|
| 8 |
+
/** One floating card, bottom right: the most useful next step, or a limit warning. */
|
| 9 |
+
export function NudgeDock() {
|
| 10 |
+
const { nudge, dismissNudge, openUpgrade } = useUpgrade();
|
| 11 |
+
const navigate = useNavigate();
|
| 12 |
+
if (!nudge) return null;
|
| 13 |
+
const Icon = TONE_ICON[nudge.tone];
|
| 14 |
+
const go = () => {
|
| 15 |
+
dismissNudge(nudge.id);
|
| 16 |
+
if (nudge.to) navigate(nudge.to);
|
| 17 |
+
else openUpgrade();
|
| 18 |
+
};
|
| 19 |
+
return (
|
| 20 |
+
<aside key={nudge.id} className={`nudge nudge-${nudge.tone}`} role="status" aria-live="polite">
|
| 21 |
+
<span className="nudge-icon">
|
| 22 |
+
<Icon size={20} />
|
| 23 |
+
</span>
|
| 24 |
+
<div className="nudge-body">
|
| 25 |
+
<p className="eyebrow">{TONE_LABEL[nudge.tone]}</p>
|
| 26 |
+
<h3>{nudge.title}</h3>
|
| 27 |
+
<p className="muted small">{nudge.body}</p>
|
| 28 |
+
<div className="row">
|
| 29 |
+
<button className={`btn btn-small ${nudge.tone === "action" ? "" : "btn-primary"}`} onClick={go}>
|
| 30 |
+
{nudge.cta}
|
| 31 |
+
</button>
|
| 32 |
+
<button className="link-btn small" onClick={() => dismissNudge(nudge.id)}>
|
| 33 |
+
Not now
|
| 34 |
+
</button>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
<button className="icon-btn nudge-close" onClick={() => dismissNudge(nudge.id)} aria-label="Dismiss">
|
| 38 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
|
| 39 |
+
<path d="M6 6l12 12M18 6L6 18" />
|
| 40 |
+
</svg>
|
| 41 |
+
</button>
|
| 42 |
+
</aside>
|
| 43 |
+
);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/** Sidebar card: plan, monthly meters and the upgrade button. Collapses to a rocket button. */
|
| 47 |
+
export function FuelGauge({ collapsed }: { collapsed: boolean }) {
|
| 48 |
+
const { status, openUpgrade } = useUpgrade();
|
| 49 |
+
if (!status) return null;
|
| 50 |
+
const meters = status.meters.filter((m) => m.key !== "sources");
|
| 51 |
+
const low = meters.some((m) => m.limit >= 0 && m.pct >= 0.8);
|
| 52 |
+
if (collapsed) {
|
| 53 |
+
return status.can_upgrade ? (
|
| 54 |
+
<button className={`icon-btn fuel-mini${low ? " low" : ""}`} onClick={() => openUpgrade()} title="Upgrade" aria-label="Upgrade">
|
| 55 |
+
<RocketIcon />
|
| 56 |
+
</button>
|
| 57 |
+
) : null;
|
| 58 |
+
}
|
| 59 |
+
return (
|
| 60 |
+
<div className={`fuel${low ? " low" : ""}`}>
|
| 61 |
+
<div className="row between">
|
| 62 |
+
<span className="mono small">{status.plan.name} plan</span>
|
| 63 |
+
{low && status.can_upgrade && <span className="mono small fuel-warn">Low fuel</span>}
|
| 64 |
+
</div>
|
| 65 |
+
{meters.map((m) => (
|
| 66 |
+
<div key={m.key} className="fuel-row" title={`${m.used} / ${m.limit < 0 ? "unlimited" : m.limit} ${m.unit}`}>
|
| 67 |
+
<span className="mono">{m.label}</span>
|
| 68 |
+
<div className="fuel-track">
|
| 69 |
+
<div className={`fuel-fill${m.pct >= 0.8 ? " hot" : ""}`} style={{ width: `${m.limit < 0 ? 4 : Math.max(3, m.pct * 100)}%` }} />
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
))}
|
| 73 |
+
{status.can_upgrade && (
|
| 74 |
+
<button className="btn btn-primary btn-small fuel-cta" onClick={() => openUpgrade()}>
|
| 75 |
+
<RocketIcon size={16} /> Upgrade now
|
| 76 |
+
</button>
|
| 77 |
+
)}
|
| 78 |
+
</div>
|
| 79 |
+
);
|
| 80 |
+
}
|
frontend/src/components/PricingTiers.tsx
CHANGED
|
@@ -63,7 +63,7 @@ const FALLBACK: Plan[] = [
|
|
| 63 |
},
|
| 64 |
];
|
| 65 |
|
| 66 |
-
function money(n: number): string {
|
| 67 |
return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
|
| 68 |
}
|
| 69 |
|
|
@@ -72,7 +72,7 @@ function to99(amount: number): number {
|
|
| 72 |
return amount <= 0 ? 0 : Math.max(0.99, Math.round(amount + 0.01) - 0.01);
|
| 73 |
}
|
| 74 |
|
| 75 |
-
function annualMonthly(plan: Plan): number {
|
| 76 |
return plan.price_annual_monthly_usd ?? to99(plan.price_monthly_usd * (1 - ANNUAL_DISCOUNT));
|
| 77 |
}
|
| 78 |
|
|
@@ -81,7 +81,7 @@ function savingsPct(plan: Plan): number {
|
|
| 81 |
return plan.annual_savings_pct ?? Math.round((1 - annualMonthly(plan) / plan.price_monthly_usd) * 100);
|
| 82 |
}
|
| 83 |
|
| 84 |
-
function annualTotal(plan: Plan): number {
|
| 85 |
return plan.price_annual_usd ?? Math.round(annualMonthly(plan) * 12 * 100) / 100;
|
| 86 |
}
|
| 87 |
|
|
@@ -146,7 +146,10 @@ export default function PricingTiers() {
|
|
| 146 |
</li>
|
| 147 |
))}
|
| 148 |
</ul>
|
| 149 |
-
<Link
|
|
|
|
|
|
|
|
|
|
| 150 |
{billingEnabled && !free ? `Choose ${plan.name}` : "Start free"}
|
| 151 |
</Link>
|
| 152 |
</article>
|
|
|
|
| 63 |
},
|
| 64 |
];
|
| 65 |
|
| 66 |
+
export function money(n: number): string {
|
| 67 |
return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
|
| 68 |
}
|
| 69 |
|
|
|
|
| 72 |
return amount <= 0 ? 0 : Math.max(0.99, Math.round(amount + 0.01) - 0.01);
|
| 73 |
}
|
| 74 |
|
| 75 |
+
export function annualMonthly(plan: Plan): number {
|
| 76 |
return plan.price_annual_monthly_usd ?? to99(plan.price_monthly_usd * (1 - ANNUAL_DISCOUNT));
|
| 77 |
}
|
| 78 |
|
|
|
|
| 81 |
return plan.annual_savings_pct ?? Math.round((1 - annualMonthly(plan) / plan.price_monthly_usd) * 100);
|
| 82 |
}
|
| 83 |
|
| 84 |
+
export function annualTotal(plan: Plan): number {
|
| 85 |
return plan.price_annual_usd ?? Math.round(annualMonthly(plan) * 12 * 100) / 100;
|
| 86 |
}
|
| 87 |
|
|
|
|
| 146 |
</li>
|
| 147 |
))}
|
| 148 |
</ul>
|
| 149 |
+
<Link
|
| 150 |
+
to={billingEnabled && !free ? `/app?upgrade=${plan.id}` : "/auth?mode=signup"}
|
| 151 |
+
className={`btn ${featured ? "btn-primary" : ""} tier-cta`}
|
| 152 |
+
>
|
| 153 |
{billingEnabled && !free ? `Choose ${plan.name}` : "Start free"}
|
| 154 |
</Link>
|
| 155 |
</article>
|
frontend/src/components/UpgradeModal.tsx
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState } from "react";
|
| 2 |
+
import { Link } from "react-router-dom";
|
| 3 |
+
import type { BillingCycle } from "../api/endpoints";
|
| 4 |
+
import type { Meter, PlanInfo } from "../api/types";
|
| 5 |
+
import { useUpgrade } from "../hooks/useUpgrade";
|
| 6 |
+
import { CheckIcon, RocketIcon } from "./icons/Icons";
|
| 7 |
+
import { annualMonthly, annualTotal, money } from "./PricingTiers";
|
| 8 |
+
|
| 9 |
+
const ORDER = ["free", "writer", "studio"];
|
| 10 |
+
|
| 11 |
+
const LIMIT_TITLES: Record<string, string> = {
|
| 12 |
+
audio_minutes: "Out of audio fuel",
|
| 13 |
+
video_minutes: "Out of render fuel",
|
| 14 |
+
launch_kits: "Launch Kits used up",
|
| 15 |
+
sources: "Your station is full",
|
| 16 |
+
voice_cloning: "Voice cloning is locked",
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
/** What the plan adds over the current one, as short boost chips ("20x audio minutes"). */
|
| 20 |
+
function boosts(plan: PlanInfo, current: PlanInfo): string[] {
|
| 21 |
+
const out: string[] = [];
|
| 22 |
+
const times = (a: number, b: number, label: string) => {
|
| 23 |
+
if (a < 0 && b >= 0) out.push(`Unlimited ${label}`);
|
| 24 |
+
else if (b > 0 && a > b) out.push(`${Math.round(a / b)}x ${label}`);
|
| 25 |
+
};
|
| 26 |
+
if (plan.sources > current.sources) out.push(`${plan.sources} sources`);
|
| 27 |
+
times(plan.audio_minutes, current.audio_minutes, "audio minutes");
|
| 28 |
+
times(plan.video_minutes, current.video_minutes, "video minutes");
|
| 29 |
+
times(plan.launch_kits, current.launch_kits, "Launch Kits");
|
| 30 |
+
if (plan.voice_cloning && !current.voice_cloning) out.push("Voice cloning");
|
| 31 |
+
if (plan.brand_kit && !current.brand_kit) out.push("Brand kit");
|
| 32 |
+
return out;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function resetDate(since: string | undefined): string {
|
| 36 |
+
const d = since ? new Date(since) : new Date();
|
| 37 |
+
const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1));
|
| 38 |
+
return next.toLocaleDateString(undefined, { month: "long", day: "numeric" });
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function Scene({ mode }: { mode: "explore" | "limit" | "welcome" }) {
|
| 42 |
+
return (
|
| 43 |
+
<div className={`upg-scene upg-${mode}`} aria-hidden="true">
|
| 44 |
+
<div className="upg-stars l1" />
|
| 45 |
+
<div className="upg-stars l2" />
|
| 46 |
+
<div className="upg-stars l3" />
|
| 47 |
+
{mode === "explore" && <div className="upg-warp">{Array.from({ length: 14 }, (_, i) => <span key={i} />)}</div>}
|
| 48 |
+
{mode === "welcome" && <div className="upg-burst">{Array.from({ length: 16 }, (_, i) => <span key={i} />)}</div>}
|
| 49 |
+
<div className="upg-planet-wrap">
|
| 50 |
+
<svg className="upg-planet" viewBox="0 0 200 200">
|
| 51 |
+
<defs>
|
| 52 |
+
<radialGradient id="upg-body" cx="35%" cy="30%" r="75%">
|
| 53 |
+
<stop offset="0%" stopColor="#217cff" stopOpacity="0.95" />
|
| 54 |
+
<stop offset="55%" stopColor="#217cff" stopOpacity="0.35" />
|
| 55 |
+
<stop offset="100%" stopColor="#000" stopOpacity="1" />
|
| 56 |
+
</radialGradient>
|
| 57 |
+
</defs>
|
| 58 |
+
<ellipse cx="100" cy="104" rx="92" ry="20" className="upg-ring back" />
|
| 59 |
+
<circle cx="100" cy="100" r="52" fill="url(#upg-body)" />
|
| 60 |
+
<circle cx="84" cy="86" r="7" className="upg-crater" />
|
| 61 |
+
<circle cx="118" cy="112" r="4.5" className="upg-crater" />
|
| 62 |
+
<circle cx="106" cy="76" r="3" className="upg-crater" />
|
| 63 |
+
<path d="M8 104a92 20 0 0 0 184 0" className="upg-ring front" />
|
| 64 |
+
</svg>
|
| 65 |
+
<span className="upg-rocket">
|
| 66 |
+
<RocketIcon size={22} />
|
| 67 |
+
</span>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function FuelMeter({ meter }: { meter: Meter }) {
|
| 74 |
+
const unlimited = meter.limit < 0;
|
| 75 |
+
return (
|
| 76 |
+
<div className="upg-fuel">
|
| 77 |
+
<div className="row between mono small">
|
| 78 |
+
<span>{meter.label}</span>
|
| 79 |
+
<span className="upg-empty">{meter.pct >= 1 ? "EMPTY" : `${Math.round(meter.pct * 100)}% used`}</span>
|
| 80 |
+
</div>
|
| 81 |
+
<div className="upg-fuel-track">
|
| 82 |
+
<div className="upg-fuel-fill" style={{ width: `${unlimited ? 4 : Math.max(4, meter.pct * 100)}%` }} />
|
| 83 |
+
</div>
|
| 84 |
+
<p className="mono muted small">
|
| 85 |
+
{meter.used} / {unlimited ? "unlimited" : meter.limit} {meter.unit}
|
| 86 |
+
</p>
|
| 87 |
+
</div>
|
| 88 |
+
);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
export default function UpgradeModal() {
|
| 92 |
+
const { status, modal, close, checkout, busy, error } = useUpgrade();
|
| 93 |
+
const [cycle, setCycle] = useState<BillingCycle>("annual");
|
| 94 |
+
const panel = useRef<HTMLDivElement>(null);
|
| 95 |
+
|
| 96 |
+
useEffect(() => {
|
| 97 |
+
if (!modal) return;
|
| 98 |
+
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
|
| 99 |
+
document.addEventListener("keydown", onKey);
|
| 100 |
+
panel.current?.focus();
|
| 101 |
+
return () => document.removeEventListener("keydown", onKey);
|
| 102 |
+
}, [modal, close]);
|
| 103 |
+
|
| 104 |
+
if (!modal) return null;
|
| 105 |
+
const current = status?.plan;
|
| 106 |
+
const rank = ORDER.indexOf(current?.id ?? "free");
|
| 107 |
+
let offers = (status?.plans ?? []).filter((p) => ORDER.indexOf(p.id) > rank);
|
| 108 |
+
if (!offers.length && status && !status.billing_enabled) offers = status.plans.filter((p) => p.price_monthly_usd > 0);
|
| 109 |
+
const limit = modal.mode === "limit" ? modal.limit : null;
|
| 110 |
+
const recommended =
|
| 111 |
+
(modal.mode === "explore" && modal.highlight) || limit?.upgrade_to || status?.next_plan || offers[0]?.id;
|
| 112 |
+
const meter = limit?.kind ? status?.meters.find((m) => m.key === limit.kind) : undefined;
|
| 113 |
+
const canBuy = modal.mode !== "welcome" && offers.length > 0 && (status?.can_upgrade || !status?.billing_enabled);
|
| 114 |
+
const title =
|
| 115 |
+
modal.mode === "welcome"
|
| 116 |
+
? `Welcome to ${current?.name ?? "your new plan"}`
|
| 117 |
+
: limit
|
| 118 |
+
? LIMIT_TITLES[limit.kind ?? ""] ?? "You are out of fuel"
|
| 119 |
+
: "Explore more with an upgrade";
|
| 120 |
+
|
| 121 |
+
return (
|
| 122 |
+
<div className="upg-backdrop" onMouseDown={(e) => e.target === e.currentTarget && close()}>
|
| 123 |
+
<div className={`upg card${canBuy ? " upg-wide" : ""}`} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1} ref={panel}>
|
| 124 |
+
<button className="icon-btn upg-close" onClick={close} aria-label="Close">
|
| 125 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
|
| 126 |
+
<path d="M6 6l12 12M18 6L6 18" />
|
| 127 |
+
</svg>
|
| 128 |
+
</button>
|
| 129 |
+
<Scene mode={modal.mode} />
|
| 130 |
+
|
| 131 |
+
<header className="upg-head">
|
| 132 |
+
<p className="eyebrow">
|
| 133 |
+
{modal.mode === "welcome" ? "Orbit reached" : limit ? "Low fuel warning" : "Mission upgrade"}
|
| 134 |
+
</p>
|
| 135 |
+
<h2>{title}</h2>
|
| 136 |
+
<p className="muted">
|
| 137 |
+
{modal.mode === "welcome"
|
| 138 |
+
? "Your new limits are live. Every source, minute and Launch Kit below is ready to use."
|
| 139 |
+
: limit
|
| 140 |
+
? `${limit.message} ${canBuy ? "Upgrade and keep flying, or wait" : "Your allowance refills"} on ${resetDate(status?.since)}.`
|
| 141 |
+
: "Your archive has more orbits in it. More sources, more minutes and more Launch Kits, so every post gets a second life."}
|
| 142 |
+
</p>
|
| 143 |
+
{meter && <FuelMeter meter={meter} />}
|
| 144 |
+
</header>
|
| 145 |
+
|
| 146 |
+
{modal.mode === "welcome" && current && (
|
| 147 |
+
<>
|
| 148 |
+
<ul className="upg-features">
|
| 149 |
+
{current.features.map((f) => (
|
| 150 |
+
<li key={f}>
|
| 151 |
+
<CheckIcon size={16} />
|
| 152 |
+
{f}
|
| 153 |
+
</li>
|
| 154 |
+
))}
|
| 155 |
+
</ul>
|
| 156 |
+
<div className="row end">
|
| 157 |
+
<Link to="/app/launch-kit" className="btn btn-primary" onClick={close}>
|
| 158 |
+
Launch something
|
| 159 |
+
</Link>
|
| 160 |
+
</div>
|
| 161 |
+
</>
|
| 162 |
+
)}
|
| 163 |
+
|
| 164 |
+
{canBuy && current && (
|
| 165 |
+
<>
|
| 166 |
+
<div className="upg-cycle">
|
| 167 |
+
<div className="cycle-toggle" role="radiogroup" aria-label="Billing cycle">
|
| 168 |
+
{(["monthly", "annual"] as const).map((c) => (
|
| 169 |
+
<button key={c} type="button" role="radio" aria-checked={cycle === c} className={cycle === c ? "on" : ""} onClick={() => setCycle(c)}>
|
| 170 |
+
{c === "monthly" ? "Monthly" : "Annual"}
|
| 171 |
+
{c === "annual" && offers[0]?.annual_savings_pct > 0 && <span className="save mono">Save {offers[0].annual_savings_pct}%</span>}
|
| 172 |
+
</button>
|
| 173 |
+
))}
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
<div className="upg-plans">
|
| 177 |
+
{offers.map((plan) => {
|
| 178 |
+
const pick = plan.id === recommended;
|
| 179 |
+
const price = cycle === "annual" ? annualMonthly(plan) : plan.price_monthly_usd;
|
| 180 |
+
return (
|
| 181 |
+
<article key={plan.id} className={`upg-plan${pick ? " pick" : ""}`}>
|
| 182 |
+
{pick && <span className="badge upg-flag">{limit ? "Refuels you" : "Recommended"}</span>}
|
| 183 |
+
<h3>{plan.name}</h3>
|
| 184 |
+
<p className="muted small">{plan.tagline}</p>
|
| 185 |
+
<p className="upg-price">
|
| 186 |
+
{cycle === "annual" && <span className="tier-was muted">{money(plan.price_monthly_usd)}</span>}
|
| 187 |
+
<span className="tier-amount">{money(price)}</span>
|
| 188 |
+
<span className="muted">/month</span>
|
| 189 |
+
</p>
|
| 190 |
+
<p className="mono muted small">
|
| 191 |
+
{cycle === "annual" ? `${money(annualTotal(plan))} billed yearly` : "Billed monthly"}
|
| 192 |
+
</p>
|
| 193 |
+
<ul className="upg-boosts">
|
| 194 |
+
{boosts(plan, current).map((b) => (
|
| 195 |
+
<li key={b} className="mono">
|
| 196 |
+
{b}
|
| 197 |
+
</li>
|
| 198 |
+
))}
|
| 199 |
+
</ul>
|
| 200 |
+
<button className={`btn ${pick ? "btn-primary" : ""} upg-cta`} disabled={busy} onClick={() => checkout(plan.id, cycle)}>
|
| 201 |
+
{busy ? "Opening checkout..." : `Launch with ${plan.name}`}
|
| 202 |
+
</button>
|
| 203 |
+
</article>
|
| 204 |
+
);
|
| 205 |
+
})}
|
| 206 |
+
</div>
|
| 207 |
+
</>
|
| 208 |
+
)}
|
| 209 |
+
|
| 210 |
+
{error && <p className="error-text">{error}</p>}
|
| 211 |
+
|
| 212 |
+
{modal.mode !== "welcome" && (
|
| 213 |
+
<footer className="upg-foot mono small muted">
|
| 214 |
+
<span>Cancel anytime. Keep everything you made.</span>
|
| 215 |
+
<span className="row">
|
| 216 |
+
<Link to="/pricing" onClick={close}>
|
| 217 |
+
Compare plans
|
| 218 |
+
</Link>
|
| 219 |
+
<button className="link-btn" onClick={close}>
|
| 220 |
+
{canBuy ? "Maybe later" : "Got it"}
|
| 221 |
+
</button>
|
| 222 |
+
</span>
|
| 223 |
+
</footer>
|
| 224 |
+
)}
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
);
|
| 228 |
+
}
|
frontend/src/hooks/useUpgrade.tsx
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
| 2 |
+
import { useLocation, useNavigate } from "react-router-dom";
|
| 3 |
+
import { PLAN_LIMIT_EVENT, type PlanLimitDetail } from "../api/client";
|
| 4 |
+
import { billingApi, type BillingCycle } from "../api/endpoints";
|
| 5 |
+
import type { BillingStatus, Nudge } from "../api/types";
|
| 6 |
+
import { errorMessage } from "../components/ui";
|
| 7 |
+
import { ONBOARDED_KEY } from "../pages/Welcome";
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* The upgrade system: billing status (plan, meters, nudges), the upgrade popup and nudge dismissals.
|
| 11 |
+
* Popup modes: explore (the opening pitch), limit (a request hit a 402 plan_limit), welcome (back from checkout).
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
export type UpgradeModal =
|
| 15 |
+
| { mode: "explore"; highlight?: string | null }
|
| 16 |
+
| { mode: "limit"; limit: PlanLimitDetail }
|
| 17 |
+
| { mode: "welcome" };
|
| 18 |
+
|
| 19 |
+
type UpgradeState = {
|
| 20 |
+
status: BillingStatus | null;
|
| 21 |
+
modal: UpgradeModal | null;
|
| 22 |
+
nudge: Nudge | null;
|
| 23 |
+
openUpgrade: (highlight?: string | null) => void;
|
| 24 |
+
close: () => void;
|
| 25 |
+
refresh: () => Promise<void>;
|
| 26 |
+
dismissNudge: (id: string) => void;
|
| 27 |
+
checkout: (plan: string, cycle: BillingCycle) => Promise<void>;
|
| 28 |
+
manageBilling: () => Promise<void>;
|
| 29 |
+
busy: boolean;
|
| 30 |
+
error: string | null;
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const SEEN_KEY = "ns_upgrade_seen"; // last time the opening popup showed
|
| 34 |
+
const DISMISSED_KEY = "ns_nudges_dismissed"; // { nudgeId: dismissedAtMs }
|
| 35 |
+
const OPENING_EVERY_MS = 3 * 24 * 3600 * 1000; // opening popup at most every three days
|
| 36 |
+
const NUDGE_SNOOZE_MS = 5 * 24 * 3600 * 1000; // a dismissed nudge stays away for five days
|
| 37 |
+
const OPENING_DELAY_MS = 1400;
|
| 38 |
+
const NUDGE_DELAY_MS = 5000;
|
| 39 |
+
const REFRESH_EVERY_MS = 30_000;
|
| 40 |
+
|
| 41 |
+
function readJson<T>(key: string, fallback: T): T {
|
| 42 |
+
try {
|
| 43 |
+
const raw = localStorage.getItem(key);
|
| 44 |
+
return raw ? (JSON.parse(raw) as T) : fallback;
|
| 45 |
+
} catch {
|
| 46 |
+
return fallback;
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function writeJson(key: string, value: unknown): void {
|
| 51 |
+
try {
|
| 52 |
+
localStorage.setItem(key, JSON.stringify(value));
|
| 53 |
+
} catch {
|
| 54 |
+
/* private mode: remember for this visit only */
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const UpgradeContext = createContext<UpgradeState | null>(null);
|
| 59 |
+
|
| 60 |
+
export function UpgradeProvider({ children }: { children: ReactNode }) {
|
| 61 |
+
const [status, setStatus] = useState<BillingStatus | null>(null);
|
| 62 |
+
const [modal, setModal] = useState<UpgradeModal | null>(null);
|
| 63 |
+
const [dismissed, setDismissed] = useState<Record<string, number>>(() => readJson(DISMISSED_KEY, {}));
|
| 64 |
+
const [nudgeReady, setNudgeReady] = useState(false);
|
| 65 |
+
const [busy, setBusy] = useState(false);
|
| 66 |
+
const [error, setError] = useState<string | null>(null);
|
| 67 |
+
const lastFetch = useRef(0);
|
| 68 |
+
const openingDone = useRef(false);
|
| 69 |
+
const openingTimer = useRef<ReturnType<typeof setTimeout>>();
|
| 70 |
+
const location = useLocation();
|
| 71 |
+
const navigate = useNavigate();
|
| 72 |
+
|
| 73 |
+
const refresh = useCallback(async () => {
|
| 74 |
+
lastFetch.current = Date.now();
|
| 75 |
+
try {
|
| 76 |
+
setStatus(await billingApi.status());
|
| 77 |
+
} catch {
|
| 78 |
+
/* the app works without it; nudges just stay hidden */
|
| 79 |
+
}
|
| 80 |
+
}, []);
|
| 81 |
+
|
| 82 |
+
// Fresh meters on navigation (throttled), so nudges follow what the user just did.
|
| 83 |
+
useEffect(() => {
|
| 84 |
+
if (Date.now() - lastFetch.current > REFRESH_EVERY_MS) refresh();
|
| 85 |
+
setNudgeReady(false);
|
| 86 |
+
const t = setTimeout(() => setNudgeReady(true), NUDGE_DELAY_MS);
|
| 87 |
+
return () => clearTimeout(t);
|
| 88 |
+
}, [location.pathname, refresh]);
|
| 89 |
+
|
| 90 |
+
// Any request that hits a plan limit opens the out of fuel popup.
|
| 91 |
+
useEffect(() => {
|
| 92 |
+
const onLimit = (e: Event) => {
|
| 93 |
+
setError(null);
|
| 94 |
+
setModal({ mode: "limit", limit: (e as CustomEvent<PlanLimitDetail>).detail });
|
| 95 |
+
refresh();
|
| 96 |
+
};
|
| 97 |
+
window.addEventListener(PLAN_LIMIT_EVENT, onLimit);
|
| 98 |
+
return () => window.removeEventListener(PLAN_LIMIT_EVENT, onLimit);
|
| 99 |
+
}, [refresh]);
|
| 100 |
+
|
| 101 |
+
// ?upgrade=1 opens the pitch (links from emails, the pricing page); ?checkout=success celebrates.
|
| 102 |
+
useEffect(() => {
|
| 103 |
+
const params = new URLSearchParams(location.search);
|
| 104 |
+
const upgrade = params.get("upgrade");
|
| 105 |
+
const checkoutResult = params.get("checkout");
|
| 106 |
+
if (!upgrade && !checkoutResult) return;
|
| 107 |
+
if (upgrade) setModal({ mode: "explore", highlight: upgrade === "1" ? null : upgrade });
|
| 108 |
+
if (checkoutResult === "success") {
|
| 109 |
+
setModal({ mode: "welcome" });
|
| 110 |
+
refresh();
|
| 111 |
+
}
|
| 112 |
+
params.delete("upgrade");
|
| 113 |
+
params.delete("checkout");
|
| 114 |
+
const rest = params.toString();
|
| 115 |
+
navigate({ pathname: location.pathname, search: rest ? `?${rest}` : "" }, { replace: true });
|
| 116 |
+
openingDone.current = true;
|
| 117 |
+
}, [location.search, location.pathname, navigate, refresh]);
|
| 118 |
+
|
| 119 |
+
// The opening popup: once per few days for workspaces that can upgrade, after onboarding.
|
| 120 |
+
useEffect(() => {
|
| 121 |
+
if (!status || openingDone.current || !status.can_upgrade) return;
|
| 122 |
+
openingDone.current = true;
|
| 123 |
+
const onboarded = readJson<number | string>(ONBOARDED_KEY, 0) || status.meters.some((m) => m.key === "sources" && m.used > 0);
|
| 124 |
+
const seen = readJson<number>(SEEN_KEY, 0);
|
| 125 |
+
if (!onboarded || Date.now() - seen < OPENING_EVERY_MS) return;
|
| 126 |
+
// Not cleared when status refreshes; only on unmount (below), or it would never fire.
|
| 127 |
+
openingTimer.current = setTimeout(() => {
|
| 128 |
+
setModal((m) => m ?? { mode: "explore" });
|
| 129 |
+
writeJson(SEEN_KEY, Date.now());
|
| 130 |
+
}, OPENING_DELAY_MS);
|
| 131 |
+
}, [status]);
|
| 132 |
+
|
| 133 |
+
useEffect(() => () => clearTimeout(openingTimer.current), []);
|
| 134 |
+
|
| 135 |
+
const dismissNudge = useCallback((id: string) => {
|
| 136 |
+
setDismissed((d) => {
|
| 137 |
+
const next = { ...d, [id]: Date.now() };
|
| 138 |
+
writeJson(DISMISSED_KEY, next);
|
| 139 |
+
return next;
|
| 140 |
+
});
|
| 141 |
+
setNudgeReady(false); // the next one waits for the next page
|
| 142 |
+
}, []);
|
| 143 |
+
|
| 144 |
+
const nudge = useMemo(() => {
|
| 145 |
+
if (!status || !nudgeReady || modal) return null;
|
| 146 |
+
const now = Date.now();
|
| 147 |
+
return (
|
| 148 |
+
status.nudges.find(
|
| 149 |
+
(n) => !(dismissed[n.id] && now - dismissed[n.id] < NUDGE_SNOOZE_MS) && n.to !== location.pathname,
|
| 150 |
+
) ?? null
|
| 151 |
+
);
|
| 152 |
+
}, [status, nudgeReady, modal, dismissed, location.pathname]);
|
| 153 |
+
|
| 154 |
+
const redirectTo = useCallback(async (get: () => Promise<{ url: string }>) => {
|
| 155 |
+
setBusy(true);
|
| 156 |
+
setError(null);
|
| 157 |
+
try {
|
| 158 |
+
window.location.assign((await get()).url);
|
| 159 |
+
} catch (e) {
|
| 160 |
+
setError(errorMessage(e));
|
| 161 |
+
setBusy(false);
|
| 162 |
+
}
|
| 163 |
+
}, []);
|
| 164 |
+
|
| 165 |
+
const value = useMemo<UpgradeState>(
|
| 166 |
+
() => ({
|
| 167 |
+
status,
|
| 168 |
+
modal,
|
| 169 |
+
nudge,
|
| 170 |
+
openUpgrade: (highlight) => {
|
| 171 |
+
setError(null);
|
| 172 |
+
setModal({ mode: "explore", highlight });
|
| 173 |
+
},
|
| 174 |
+
close: () => {
|
| 175 |
+
setModal(null);
|
| 176 |
+
setError(null);
|
| 177 |
+
},
|
| 178 |
+
refresh,
|
| 179 |
+
dismissNudge,
|
| 180 |
+
checkout: (plan, cycle) => redirectTo(() => billingApi.checkout(plan, cycle)),
|
| 181 |
+
manageBilling: () => redirectTo(billingApi.portal),
|
| 182 |
+
busy,
|
| 183 |
+
error,
|
| 184 |
+
}),
|
| 185 |
+
[status, modal, nudge, refresh, dismissNudge, redirectTo, busy, error],
|
| 186 |
+
);
|
| 187 |
+
return <UpgradeContext.Provider value={value}>{children}</UpgradeContext.Provider>;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
export function useUpgrade(): UpgradeState {
|
| 191 |
+
const ctx = useContext(UpgradeContext);
|
| 192 |
+
if (!ctx) throw new Error("useUpgrade must be used inside UpgradeProvider");
|
| 193 |
+
return ctx;
|
| 194 |
+
}
|
frontend/src/main.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { AuthProvider } from "./hooks/useAuth";
|
|
| 6 |
import "./styles/global.css";
|
| 7 |
import "./styles/app.css";
|
| 8 |
import "./styles/pages.css";
|
|
|
|
| 9 |
import { installClickSounds } from "./lib/sound";
|
| 10 |
|
| 11 |
installClickSounds();
|
|
|
|
| 6 |
import "./styles/global.css";
|
| 7 |
import "./styles/app.css";
|
| 8 |
import "./styles/pages.css";
|
| 9 |
+
import "./styles/upgrade.css";
|
| 10 |
import { installClickSounds } from "./lib/sound";
|
| 11 |
|
| 12 |
installClickSounds();
|
frontend/src/pages/Settings.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { settingsApi, type SettingsPatch } from "../api/endpoints";
|
|
| 6 |
import type { Settings as SettingsData } from "../api/types";
|
| 7 |
import { ConfirmButton, errorMessage, Loading, PageHeader } from "../components/ui";
|
| 8 |
import { useAuth } from "../hooks/useAuth";
|
|
|
|
| 9 |
import { setSoundsEnabled, soundsEnabled } from "../lib/sound";
|
| 10 |
|
| 11 |
function Meter({ label, used, limit, unit }: { label: string; used: number; limit: number; unit: string }) {
|
|
@@ -46,6 +47,7 @@ export default function Settings() {
|
|
| 46 |
const logoRef = useRef<HTMLInputElement>(null);
|
| 47 |
const [sounds, setSounds] = useState(soundsEnabled);
|
| 48 |
const { logout } = useAuth();
|
|
|
|
| 49 |
const navigate = useNavigate();
|
| 50 |
|
| 51 |
const hydrate = (d: SettingsData) => {
|
|
@@ -177,6 +179,24 @@ export default function Settings() {
|
|
| 177 |
<Meter label="Audio overviews" used={u.used.audio_minutes} limit={u.limits.audio_minutes} unit="min" />
|
| 178 |
<Meter label="Video renders" used={u.used.video_minutes} limit={u.limits.video_minutes} unit="min" />
|
| 179 |
<Meter label="Launch Kits" used={u.used.launch_kits} limit={u.limits.launch_kits} unit="" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
<p className="mono muted small">
|
| 181 |
{s.plan.sources} sources · {s.plan.indexed_posts.toLocaleString()} indexed posts · {u.used.llm_tokens.toLocaleString()} LLM tokens this month
|
| 182 |
</p>
|
|
|
|
| 6 |
import type { Settings as SettingsData } from "../api/types";
|
| 7 |
import { ConfirmButton, errorMessage, Loading, PageHeader } from "../components/ui";
|
| 8 |
import { useAuth } from "../hooks/useAuth";
|
| 9 |
+
import { useUpgrade } from "../hooks/useUpgrade";
|
| 10 |
import { setSoundsEnabled, soundsEnabled } from "../lib/sound";
|
| 11 |
|
| 12 |
function Meter({ label, used, limit, unit }: { label: string; used: number; limit: number; unit: string }) {
|
|
|
|
| 47 |
const logoRef = useRef<HTMLInputElement>(null);
|
| 48 |
const [sounds, setSounds] = useState(soundsEnabled);
|
| 49 |
const { logout } = useAuth();
|
| 50 |
+
const upgrade = useUpgrade();
|
| 51 |
const navigate = useNavigate();
|
| 52 |
|
| 53 |
const hydrate = (d: SettingsData) => {
|
|
|
|
| 179 |
<Meter label="Audio overviews" used={u.used.audio_minutes} limit={u.limits.audio_minutes} unit="min" />
|
| 180 |
<Meter label="Video renders" used={u.used.video_minutes} limit={u.limits.video_minutes} unit="min" />
|
| 181 |
<Meter label="Launch Kits" used={u.used.launch_kits} limit={u.limits.launch_kits} unit="" />
|
| 182 |
+
<p className="mono muted small">
|
| 183 |
+
Refills on the 1st of every month.
|
| 184 |
+
</p>
|
| 185 |
+
{s.billing_enabled && (
|
| 186 |
+
<div className="row">
|
| 187 |
+
{upgrade.status?.can_upgrade && (
|
| 188 |
+
<button className="btn btn-primary btn-small" onClick={() => upgrade.openUpgrade()}>
|
| 189 |
+
Upgrade now
|
| 190 |
+
</button>
|
| 191 |
+
)}
|
| 192 |
+
{upgrade.status?.has_billing_account && (
|
| 193 |
+
<button className="btn btn-small" disabled={upgrade.busy} onClick={() => upgrade.manageBilling()}>
|
| 194 |
+
Manage billing
|
| 195 |
+
</button>
|
| 196 |
+
)}
|
| 197 |
+
</div>
|
| 198 |
+
)}
|
| 199 |
+
{upgrade.error && !upgrade.modal && <p className="error-text">{upgrade.error}</p>}
|
| 200 |
<p className="mono muted small">
|
| 201 |
{s.plan.sources} sources · {s.plan.indexed_posts.toLocaleString()} indexed posts · {u.used.llm_tokens.toLocaleString()} LLM tokens this month
|
| 202 |
</p>
|
frontend/src/styles/upgrade.css
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Upgrade system: the space popup, the nudge dock and the sidebar fuel gauge.
|
| 2 |
+
Strict palette: black, #217cff, white. */
|
| 3 |
+
|
| 4 |
+
/* ---------- Popup ---------- */
|
| 5 |
+
.upg-backdrop {
|
| 6 |
+
position: fixed;
|
| 7 |
+
inset: 0;
|
| 8 |
+
z-index: 60;
|
| 9 |
+
display: grid;
|
| 10 |
+
place-items: center;
|
| 11 |
+
padding: 16px;
|
| 12 |
+
overflow: auto;
|
| 13 |
+
background: rgba(0, 0, 0, 0.8);
|
| 14 |
+
backdrop-filter: blur(6px);
|
| 15 |
+
animation: upg-fade 0.25s ease both;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
.upg {
|
| 19 |
+
position: relative;
|
| 20 |
+
width: min(540px, 100%);
|
| 21 |
+
max-height: calc(100vh - 32px);
|
| 22 |
+
overflow: auto;
|
| 23 |
+
display: grid;
|
| 24 |
+
gap: 18px;
|
| 25 |
+
padding: 0 22px 20px;
|
| 26 |
+
background: var(--black);
|
| 27 |
+
border-color: var(--blue-35);
|
| 28 |
+
box-shadow: var(--glow-lg), 0 0 0 1px var(--blue-10) inset;
|
| 29 |
+
animation: upg-rise 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.upg:focus {
|
| 33 |
+
outline: none;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.upg-wide {
|
| 37 |
+
width: min(800px, 100%);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.upg-close {
|
| 41 |
+
position: absolute;
|
| 42 |
+
top: 12px;
|
| 43 |
+
right: 12px;
|
| 44 |
+
z-index: 3;
|
| 45 |
+
background: rgba(0, 0, 0, 0.5);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.upg-head {
|
| 49 |
+
display: grid;
|
| 50 |
+
gap: 8px;
|
| 51 |
+
text-align: center;
|
| 52 |
+
justify-items: center;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.upg-head h2 {
|
| 56 |
+
font-family: var(--font-display);
|
| 57 |
+
font-size: clamp(1.5rem, 4vw, 2.1rem);
|
| 58 |
+
line-height: 1.1;
|
| 59 |
+
margin: 0;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.upg-head > p.muted {
|
| 63 |
+
max-width: 52ch;
|
| 64 |
+
margin: 0;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/* Scene: drifting stars, a ringed planet and a rocket on orbit */
|
| 68 |
+
.upg-scene {
|
| 69 |
+
position: relative;
|
| 70 |
+
height: 190px;
|
| 71 |
+
margin: 0 -22px;
|
| 72 |
+
overflow: hidden;
|
| 73 |
+
background:
|
| 74 |
+
radial-gradient(60% 90% at 50% 100%, var(--blue-20), transparent 70%),
|
| 75 |
+
radial-gradient(40% 60% at 50% 60%, var(--blue-10), transparent 70%),
|
| 76 |
+
var(--black);
|
| 77 |
+
border-bottom: 1px solid var(--line);
|
| 78 |
+
mask-image: linear-gradient(to bottom, #000 70%, transparent);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.upg-stars {
|
| 82 |
+
position: absolute;
|
| 83 |
+
inset: -50% 0 0;
|
| 84 |
+
background-repeat: repeat;
|
| 85 |
+
animation: upg-drift linear infinite;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.upg-stars.l1 {
|
| 89 |
+
background-image:
|
| 90 |
+
radial-gradient(1px 1px at 12px 18px, rgba(255, 255, 255, 0.7), transparent),
|
| 91 |
+
radial-gradient(1px 1px at 70px 90px, rgba(255, 255, 255, 0.5), transparent),
|
| 92 |
+
radial-gradient(1px 1px at 130px 40px, rgba(255, 255, 255, 0.6), transparent);
|
| 93 |
+
background-size: 160px 120px;
|
| 94 |
+
animation-duration: 40s;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.upg-stars.l2 {
|
| 98 |
+
background-image:
|
| 99 |
+
radial-gradient(1.5px 1.5px at 40px 60px, rgba(255, 255, 255, 0.85), transparent),
|
| 100 |
+
radial-gradient(1.5px 1.5px at 190px 20px, rgba(33, 124, 255, 0.9), transparent);
|
| 101 |
+
background-size: 240px 150px;
|
| 102 |
+
animation-duration: 24s;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.upg-stars.l3 {
|
| 106 |
+
background-image:
|
| 107 |
+
radial-gradient(2px 2px at 80px 30px, #fff, transparent),
|
| 108 |
+
radial-gradient(2px 2px at 260px 110px, rgba(33, 124, 255, 1), transparent);
|
| 109 |
+
background-size: 340px 180px;
|
| 110 |
+
animation-duration: 14s;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.upg-planet-wrap {
|
| 114 |
+
position: absolute;
|
| 115 |
+
left: 50%;
|
| 116 |
+
top: 54%;
|
| 117 |
+
width: 170px;
|
| 118 |
+
height: 170px;
|
| 119 |
+
transform: translate(-50%, -50%);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.upg-planet {
|
| 123 |
+
width: 100%;
|
| 124 |
+
height: 100%;
|
| 125 |
+
filter: drop-shadow(0 0 24px var(--blue-35));
|
| 126 |
+
animation: upg-bob 6s ease-in-out infinite;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.upg-ring {
|
| 130 |
+
fill: none;
|
| 131 |
+
stroke: rgba(255, 255, 255, 0.55);
|
| 132 |
+
stroke-width: 1.5;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.upg-ring.back {
|
| 136 |
+
stroke: rgba(255, 255, 255, 0.2);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.upg-crater {
|
| 140 |
+
fill: rgba(0, 0, 0, 0.35);
|
| 141 |
+
stroke: rgba(255, 255, 255, 0.15);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/* The rocket flies an ellipse around the planet (the ring's plane). */
|
| 145 |
+
.upg-rocket {
|
| 146 |
+
position: absolute;
|
| 147 |
+
top: 0;
|
| 148 |
+
left: 0;
|
| 149 |
+
display: grid;
|
| 150 |
+
place-items: center;
|
| 151 |
+
width: 26px;
|
| 152 |
+
height: 26px;
|
| 153 |
+
color: var(--white);
|
| 154 |
+
filter: drop-shadow(0 0 6px var(--blue));
|
| 155 |
+
offset-path: path("M -20 92 a 105 32 0 1 0 210 0 a 105 32 0 1 0 -210 0");
|
| 156 |
+
offset-rotate: 0deg;
|
| 157 |
+
animation: upg-fly 9s linear infinite;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/* Explore: warp streaks racing past */
|
| 161 |
+
.upg-warp span {
|
| 162 |
+
position: absolute;
|
| 163 |
+
top: 50%;
|
| 164 |
+
left: 50%;
|
| 165 |
+
width: 90px;
|
| 166 |
+
height: 1px;
|
| 167 |
+
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.8));
|
| 168 |
+
transform-origin: 0 50%;
|
| 169 |
+
opacity: 0;
|
| 170 |
+
animation: upg-warp 2.6s ease-in infinite;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.upg-warp span:nth-child(odd) {
|
| 174 |
+
background: linear-gradient(90deg, transparent, var(--blue));
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.upg-warp span:nth-child(1) { --a: 8deg; animation-delay: 0s; }
|
| 178 |
+
.upg-warp span:nth-child(2) { --a: 34deg; animation-delay: 0.3s; }
|
| 179 |
+
.upg-warp span:nth-child(3) { --a: 61deg; animation-delay: 1.1s; }
|
| 180 |
+
.upg-warp span:nth-child(4) { --a: 97deg; animation-delay: 0.7s; }
|
| 181 |
+
.upg-warp span:nth-child(5) { --a: 128deg; animation-delay: 1.6s; }
|
| 182 |
+
.upg-warp span:nth-child(6) { --a: 152deg; animation-delay: 0.2s; }
|
| 183 |
+
.upg-warp span:nth-child(7) { --a: 181deg; animation-delay: 1.3s; }
|
| 184 |
+
.upg-warp span:nth-child(8) { --a: 206deg; animation-delay: 0.5s; }
|
| 185 |
+
.upg-warp span:nth-child(9) { --a: 233deg; animation-delay: 1.9s; }
|
| 186 |
+
.upg-warp span:nth-child(10) { --a: 262deg; animation-delay: 0.9s; }
|
| 187 |
+
.upg-warp span:nth-child(11) { --a: 289deg; animation-delay: 2.2s; }
|
| 188 |
+
.upg-warp span:nth-child(12) { --a: 312deg; animation-delay: 0.4s; }
|
| 189 |
+
.upg-warp span:nth-child(13) { --a: 337deg; animation-delay: 1.4s; }
|
| 190 |
+
.upg-warp span:nth-child(14) { --a: 355deg; animation-delay: 2s; }
|
| 191 |
+
|
| 192 |
+
/* Limit: the rocket sputters, the planet dims */
|
| 193 |
+
.upg-limit .upg-planet {
|
| 194 |
+
filter: drop-shadow(0 0 10px var(--blue-20)) saturate(0.6);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.upg-limit .upg-rocket {
|
| 198 |
+
animation:
|
| 199 |
+
upg-fly 28s linear infinite,
|
| 200 |
+
upg-sputter 1.4s steps(2, jump-none) infinite;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
/* Welcome: a burst of sparkles */
|
| 204 |
+
.upg-burst span {
|
| 205 |
+
position: absolute;
|
| 206 |
+
top: 54%;
|
| 207 |
+
left: 50%;
|
| 208 |
+
width: 6px;
|
| 209 |
+
height: 6px;
|
| 210 |
+
margin: -3px;
|
| 211 |
+
border-radius: 50%;
|
| 212 |
+
background: var(--white);
|
| 213 |
+
box-shadow: 0 0 10px var(--blue);
|
| 214 |
+
animation: upg-burst 1.6s cubic-bezier(0.1, 0.7, 0.2, 1) both;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.upg-burst span:nth-child(3n) {
|
| 218 |
+
background: var(--blue);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
.upg-burst span:nth-child(1) { --a: 0deg; }
|
| 222 |
+
.upg-burst span:nth-child(2) { --a: 22deg; animation-delay: 0.05s; }
|
| 223 |
+
.upg-burst span:nth-child(3) { --a: 45deg; }
|
| 224 |
+
.upg-burst span:nth-child(4) { --a: 67deg; animation-delay: 0.1s; }
|
| 225 |
+
.upg-burst span:nth-child(5) { --a: 90deg; }
|
| 226 |
+
.upg-burst span:nth-child(6) { --a: 112deg; animation-delay: 0.05s; }
|
| 227 |
+
.upg-burst span:nth-child(7) { --a: 135deg; }
|
| 228 |
+
.upg-burst span:nth-child(8) { --a: 157deg; animation-delay: 0.1s; }
|
| 229 |
+
.upg-burst span:nth-child(9) { --a: 180deg; }
|
| 230 |
+
.upg-burst span:nth-child(10) { --a: 202deg; animation-delay: 0.05s; }
|
| 231 |
+
.upg-burst span:nth-child(11) { --a: 225deg; }
|
| 232 |
+
.upg-burst span:nth-child(12) { --a: 247deg; animation-delay: 0.1s; }
|
| 233 |
+
.upg-burst span:nth-child(13) { --a: 270deg; }
|
| 234 |
+
.upg-burst span:nth-child(14) { --a: 292deg; animation-delay: 0.05s; }
|
| 235 |
+
.upg-burst span:nth-child(15) { --a: 315deg; }
|
| 236 |
+
.upg-burst span:nth-child(16) { --a: 337deg; animation-delay: 0.1s; }
|
| 237 |
+
|
| 238 |
+
/* Fuel meter (limit mode) */
|
| 239 |
+
.upg-fuel {
|
| 240 |
+
width: min(360px, 100%);
|
| 241 |
+
display: grid;
|
| 242 |
+
gap: 6px;
|
| 243 |
+
margin-top: 6px;
|
| 244 |
+
text-align: left;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.upg-fuel p {
|
| 248 |
+
margin: 0;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.upg-empty {
|
| 252 |
+
color: var(--blue);
|
| 253 |
+
animation: upg-blink 1.2s steps(2, jump-none) infinite;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.upg-fuel-track {
|
| 257 |
+
height: 8px;
|
| 258 |
+
border-radius: 999px;
|
| 259 |
+
background: var(--surface-2);
|
| 260 |
+
overflow: hidden;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.upg-fuel-fill {
|
| 264 |
+
height: 100%;
|
| 265 |
+
border-radius: inherit;
|
| 266 |
+
background: linear-gradient(90deg, var(--blue-60), var(--blue));
|
| 267 |
+
box-shadow: var(--glow-sm);
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
/* Plans */
|
| 271 |
+
.upg-cycle {
|
| 272 |
+
display: flex;
|
| 273 |
+
justify-content: center;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.upg-plans {
|
| 277 |
+
display: grid;
|
| 278 |
+
gap: 14px;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
@media (min-width: 640px) {
|
| 282 |
+
.upg-plans {
|
| 283 |
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
.upg-plan {
|
| 288 |
+
position: relative;
|
| 289 |
+
display: grid;
|
| 290 |
+
align-content: start;
|
| 291 |
+
gap: 6px;
|
| 292 |
+
padding: 18px;
|
| 293 |
+
border: 1px solid var(--line-strong);
|
| 294 |
+
border-radius: var(--radius);
|
| 295 |
+
background: var(--surface);
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.upg-plan.pick {
|
| 299 |
+
border-color: var(--blue);
|
| 300 |
+
background: linear-gradient(180deg, var(--blue-10), transparent 60%);
|
| 301 |
+
box-shadow: var(--glow-md);
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.upg-plan h3 {
|
| 305 |
+
margin: 0;
|
| 306 |
+
font-family: var(--font-display);
|
| 307 |
+
font-size: 1.25rem;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.upg-plan p {
|
| 311 |
+
margin: 0;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.upg-flag {
|
| 315 |
+
position: absolute;
|
| 316 |
+
top: -11px;
|
| 317 |
+
right: 14px;
|
| 318 |
+
background: var(--blue);
|
| 319 |
+
border-color: var(--blue);
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
.upg-price {
|
| 323 |
+
display: flex;
|
| 324 |
+
align-items: baseline;
|
| 325 |
+
gap: 4px;
|
| 326 |
+
margin-top: 6px !important;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.upg-price .tier-amount {
|
| 330 |
+
font-family: var(--font-display);
|
| 331 |
+
font-size: 2rem;
|
| 332 |
+
font-weight: 600;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.upg-boosts {
|
| 336 |
+
display: flex;
|
| 337 |
+
flex-wrap: wrap;
|
| 338 |
+
gap: 6px;
|
| 339 |
+
margin: 8px 0 12px;
|
| 340 |
+
padding: 0;
|
| 341 |
+
list-style: none;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
.upg-boosts li {
|
| 345 |
+
padding: 3px 9px;
|
| 346 |
+
border: 1px solid var(--blue-35);
|
| 347 |
+
border-radius: 999px;
|
| 348 |
+
background: var(--blue-05);
|
| 349 |
+
font-size: 0.72rem;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.upg-cta {
|
| 353 |
+
width: 100%;
|
| 354 |
+
justify-content: center;
|
| 355 |
+
margin-top: auto;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.upg-features {
|
| 359 |
+
display: grid;
|
| 360 |
+
gap: 8px;
|
| 361 |
+
margin: 0;
|
| 362 |
+
padding: 0;
|
| 363 |
+
list-style: none;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
.upg-features li {
|
| 367 |
+
display: flex;
|
| 368 |
+
gap: 10px;
|
| 369 |
+
align-items: center;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
.upg-features .icon {
|
| 373 |
+
color: var(--blue);
|
| 374 |
+
flex: none;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.upg-foot {
|
| 378 |
+
display: flex;
|
| 379 |
+
flex-wrap: wrap;
|
| 380 |
+
justify-content: space-between;
|
| 381 |
+
align-items: center;
|
| 382 |
+
gap: 10px;
|
| 383 |
+
padding-top: 12px;
|
| 384 |
+
border-top: 1px solid var(--line);
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.upg-foot .row {
|
| 388 |
+
gap: 16px;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.upg-foot a {
|
| 392 |
+
color: var(--white);
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
/* ---------- Nudge dock ---------- */
|
| 396 |
+
.nudge {
|
| 397 |
+
position: fixed;
|
| 398 |
+
right: 16px;
|
| 399 |
+
bottom: 16px;
|
| 400 |
+
left: 16px;
|
| 401 |
+
z-index: 40;
|
| 402 |
+
display: flex;
|
| 403 |
+
gap: 12px;
|
| 404 |
+
padding: 14px 36px 14px 14px;
|
| 405 |
+
border: 1px solid var(--line-strong);
|
| 406 |
+
border-radius: var(--radius);
|
| 407 |
+
background: rgba(0, 0, 0, 0.92);
|
| 408 |
+
backdrop-filter: blur(10px);
|
| 409 |
+
box-shadow: var(--glow-lg);
|
| 410 |
+
animation: nudge-in 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
@media (min-width: 640px) {
|
| 414 |
+
.nudge {
|
| 415 |
+
left: auto;
|
| 416 |
+
width: 360px;
|
| 417 |
+
right: 24px;
|
| 418 |
+
bottom: 24px;
|
| 419 |
+
}
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
.nudge-limit,
|
| 423 |
+
.nudge-upgrade {
|
| 424 |
+
border-color: var(--blue-60);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
.nudge-icon {
|
| 428 |
+
flex: none;
|
| 429 |
+
display: grid;
|
| 430 |
+
place-items: center;
|
| 431 |
+
width: 38px;
|
| 432 |
+
height: 38px;
|
| 433 |
+
border-radius: 50%;
|
| 434 |
+
border: 1px solid var(--blue-35);
|
| 435 |
+
background: var(--blue-10);
|
| 436 |
+
color: var(--white);
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
.nudge-limit .nudge-icon {
|
| 440 |
+
background: var(--blue);
|
| 441 |
+
box-shadow: var(--glow-sm);
|
| 442 |
+
animation: pulse-ring 2s ease-out infinite;
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
.nudge-body {
|
| 446 |
+
display: grid;
|
| 447 |
+
gap: 6px;
|
| 448 |
+
min-width: 0;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
.nudge-body h3 {
|
| 452 |
+
margin: 0;
|
| 453 |
+
font-size: 1rem;
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
.nudge-body p {
|
| 457 |
+
margin: 0;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
.nudge-body .eyebrow {
|
| 461 |
+
font-size: 0.65rem;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
.nudge-body .row {
|
| 465 |
+
margin-top: 4px;
|
| 466 |
+
gap: 14px;
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
.nudge-close {
|
| 470 |
+
position: absolute;
|
| 471 |
+
top: 8px;
|
| 472 |
+
right: 8px;
|
| 473 |
+
width: 26px;
|
| 474 |
+
height: 26px;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
/* ---------- Sidebar fuel gauge ---------- */
|
| 478 |
+
.fuel,
|
| 479 |
+
.fuel-mini {
|
| 480 |
+
display: none;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
@media (min-width: 900px) {
|
| 484 |
+
.fuel {
|
| 485 |
+
display: grid;
|
| 486 |
+
gap: 8px;
|
| 487 |
+
margin-top: auto;
|
| 488 |
+
padding: 12px;
|
| 489 |
+
border: 1px solid var(--line);
|
| 490 |
+
border-radius: var(--radius);
|
| 491 |
+
background: var(--surface);
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
.fuel + .sidebar-user {
|
| 495 |
+
margin-top: 0;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
.fuel.low {
|
| 499 |
+
border-color: var(--blue-35);
|
| 500 |
+
box-shadow: var(--glow-sm);
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
.fuel-warn {
|
| 504 |
+
color: var(--blue);
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
.fuel-row {
|
| 508 |
+
display: grid;
|
| 509 |
+
gap: 4px;
|
| 510 |
+
font-size: 0.7rem;
|
| 511 |
+
color: var(--text-2);
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
.fuel-track {
|
| 515 |
+
height: 4px;
|
| 516 |
+
border-radius: 999px;
|
| 517 |
+
background: var(--surface-2);
|
| 518 |
+
overflow: hidden;
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
.fuel-fill {
|
| 522 |
+
height: 100%;
|
| 523 |
+
border-radius: inherit;
|
| 524 |
+
background: var(--blue-60);
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
.fuel-fill.hot {
|
| 528 |
+
background: var(--blue);
|
| 529 |
+
box-shadow: var(--glow-sm);
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
.fuel-cta {
|
| 533 |
+
width: 100%;
|
| 534 |
+
justify-content: center;
|
| 535 |
+
gap: 6px;
|
| 536 |
+
margin-top: 4px;
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
.shell.collapsed .fuel {
|
| 540 |
+
display: none;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
.shell.collapsed .fuel-mini {
|
| 544 |
+
display: inline-grid;
|
| 545 |
+
margin-top: auto;
|
| 546 |
+
color: var(--white);
|
| 547 |
+
border-color: var(--blue-35);
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
.fuel-mini.low {
|
| 551 |
+
background: var(--blue);
|
| 552 |
+
box-shadow: var(--glow-sm);
|
| 553 |
+
}
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
/* ---------- Motion ---------- */
|
| 557 |
+
@keyframes upg-fade {
|
| 558 |
+
from { opacity: 0; }
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
@keyframes upg-rise {
|
| 562 |
+
from { opacity: 0; transform: translateY(24px) scale(0.97); }
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
@keyframes upg-drift {
|
| 566 |
+
to { transform: translateY(50%); }
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
@keyframes upg-bob {
|
| 570 |
+
50% { transform: translateY(-5px); }
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
@keyframes upg-fly {
|
| 574 |
+
to { offset-distance: 100%; }
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
@keyframes upg-warp {
|
| 578 |
+
0% { opacity: 0; transform: rotate(var(--a)) translateX(10px) scaleX(0.2); }
|
| 579 |
+
30% { opacity: 1; }
|
| 580 |
+
100% { opacity: 0; transform: rotate(var(--a)) translateX(360px) scaleX(1.4); }
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
@keyframes upg-burst {
|
| 584 |
+
0% { opacity: 1; transform: rotate(var(--a)) translateX(0) scale(1.4); }
|
| 585 |
+
100% { opacity: 0; transform: rotate(var(--a)) translateX(150px) scale(0.3); }
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
@keyframes upg-sputter {
|
| 589 |
+
50% { opacity: 0.35; }
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
@keyframes upg-blink {
|
| 593 |
+
50% { opacity: 0.3; }
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
@keyframes nudge-in {
|
| 597 |
+
from { opacity: 0; transform: translateY(20px); }
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
@keyframes pulse-ring {
|
| 601 |
+
0% { box-shadow: 0 0 0 0 var(--blue-60); }
|
| 602 |
+
100% { box-shadow: 0 0 0 12px transparent; }
|
| 603 |
+
}
|
frontend/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/missionposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebooklmalternative.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}
|
|
|
|
| 1 |
+
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/nudges.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/upgrademodal.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/missionposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/hooks/useupgrade.tsx","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebooklmalternative.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}
|
renderer/public/demo-vo/durations.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
{
|
| 2 |
-
"warp": 3.
|
| 3 |
-
"paste":
|
| 4 |
-
"orbit":
|
| 5 |
-
"research":
|
| 6 |
-
"audio":
|
| 7 |
-
"launchkit":
|
| 8 |
-
"launchpad":
|
| 9 |
-
"outro": 2.
|
| 10 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"warp": 3.92,
|
| 3 |
+
"paste": 6.95,
|
| 4 |
+
"orbit": 6.61,
|
| 5 |
+
"research": 3.74,
|
| 6 |
+
"audio": 2.67,
|
| 7 |
+
"launchkit": 3.32,
|
| 8 |
+
"launchpad": 4.05,
|
| 9 |
+
"outro": 2.48
|
| 10 |
}
|
renderer/public/launch-vo/durations.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
| 1 |
{
|
| 2 |
-
"intro": 5.
|
| 3 |
-
"launch":
|
| 4 |
-
"sources": 6.
|
| 5 |
"research": 4.16,
|
| 6 |
-
"map":
|
| 7 |
-
"voice":
|
| 8 |
-
"audio": 3.
|
| 9 |
-
"video":
|
| 10 |
-
"launchkit":
|
| 11 |
-
"launchpad":
|
| 12 |
-
"resurface": 4.
|
| 13 |
-
"outro":
|
| 14 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"intro": 5.83,
|
| 3 |
+
"launch": 2.9,
|
| 4 |
+
"sources": 6.38,
|
| 5 |
"research": 4.16,
|
| 6 |
+
"map": 4.47,
|
| 7 |
+
"voice": 3.56,
|
| 8 |
+
"audio": 3.19,
|
| 9 |
+
"video": 5.88,
|
| 10 |
+
"launchkit": 3.69,
|
| 11 |
+
"launchpad": 4.94,
|
| 12 |
+
"resurface": 4.44,
|
| 13 |
+
"outro": 6.3
|
| 14 |
}
|
renderer/scripts/make_demo_vo.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
"""Narration for the landing page demo (Nora Vale, Mission Control). Writes public/demo-vo/*.mp3 + durations.json.
|
| 2 |
|
| 3 |
-
The screens show the product; the voice
|
| 4 |
-
|
| 5 |
-
make_demo_music.py
|
| 6 |
|
| 7 |
cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_demo_vo.py
|
| 8 |
"""
|
|
@@ -17,13 +17,13 @@ OUT = Path(__file__).resolve().parents[1] / "public" / "demo-vo"
|
|
| 17 |
OUT.mkdir(parents=True, exist_ok=True)
|
| 18 |
|
| 19 |
LINES = {
|
| 20 |
-
"warp": "Captain's log.
|
| 21 |
-
"paste": "
|
| 22 |
-
"orbit": "
|
| 23 |
-
"research": "
|
| 24 |
-
"audio": "
|
| 25 |
-
"launchkit": "
|
| 26 |
-
"launchpad": "
|
| 27 |
"outro": "Notestack. Clear skies ahead.",
|
| 28 |
}
|
| 29 |
|
|
@@ -39,4 +39,8 @@ for i, key in enumerate(keys):
|
|
| 39 |
(OUT / f"{key}.mp3").write_bytes(clip)
|
| 40 |
durations[key] = round(tts.mp3_seconds(clip), 2)
|
| 41 |
(OUT / "durations.json").write_text(json.dumps(durations, indent=2))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
print(json.dumps(durations))
|
|
|
|
| 1 |
"""Narration for the landing page demo (Nora Vale, Mission Control). Writes public/demo-vo/*.mp3 + durations.json.
|
| 2 |
|
| 3 |
+
The screens show the product; the voice never explains it, it only says how it feels: exploring,
|
| 4 |
+
excited, mesmerized. Also writes src/compositions/demoVo.ts; scenes stretch to fit their lines.
|
| 5 |
+
Then run make_demo_music.py, which reads the same file to size the score.
|
| 6 |
|
| 7 |
cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_demo_vo.py
|
| 8 |
"""
|
|
|
|
| 17 |
OUT.mkdir(parents=True, exist_ok=True)
|
| 18 |
|
| 19 |
LINES = {
|
| 20 |
+
"warp": "Captain's log. Something wonderful is on the horizon.",
|
| 21 |
+
"paste": "Take a breath. That flutter of excitement? That's how every great expedition begins.",
|
| 22 |
+
"orbit": "And there it is. Everything you've made, shimmering... impossible to look away.",
|
| 23 |
+
"research": "A question sparks, and suddenly you're wide awake.",
|
| 24 |
+
"audio": "Lean back. Let it wash over you.",
|
| 25 |
+
"launchkit": "Your pulse quickens. You can't wait to share this.",
|
| 26 |
+
"launchpad": "Three, two, one... and you're flying.",
|
| 27 |
"outro": "Notestack. Clear skies ahead.",
|
| 28 |
}
|
| 29 |
|
|
|
|
| 39 |
(OUT / f"{key}.mp3").write_bytes(clip)
|
| 40 |
durations[key] = round(tts.mp3_seconds(clip), 2)
|
| 41 |
(OUT / "durations.json").write_text(json.dumps(durations, indent=2))
|
| 42 |
+
(Path(__file__).resolve().parents[1] / "src" / "compositions" / "demoVo.ts").write_text(
|
| 43 |
+
"// Generated by scripts/make_demo_vo.py: narration clip lengths in seconds. Do not edit by hand.\n"
|
| 44 |
+
f"export const DEMO_VO_SECONDS = {json.dumps(durations)} as const;\n"
|
| 45 |
+
)
|
| 46 |
print(json.dumps(durations))
|
renderer/scripts/make_launch_vo.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""Narration for the launch trailer (Nora Vale, Mission Control). Writes public/launch-vo/*.mp3 + durations.json.
|
| 2 |
|
| 3 |
-
The
|
| 4 |
|
| 5 |
Run from backend/ (uses its ElevenLabs client and .env), then run make_launch_music.py:
|
| 6 |
cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_launch_vo.py
|
|
@@ -16,18 +16,18 @@ OUT = Path(__file__).resolve().parents[1] / "public" / "launch-vo"
|
|
| 16 |
OUT.mkdir(parents=True, exist_ok=True)
|
| 17 |
|
| 18 |
LINES = {
|
| 19 |
-
"intro": "
|
| 20 |
-
"launch": "
|
| 21 |
-
"sources": "
|
| 22 |
-
"research": "
|
| 23 |
-
"map": "
|
| 24 |
-
"voice": "
|
| 25 |
-
"audio": "
|
| 26 |
-
"video": "
|
| 27 |
-
"launchkit": "
|
| 28 |
-
"launchpad": "
|
| 29 |
-
"resurface": "And
|
| 30 |
-
"outro": "Notestack A.I.
|
| 31 |
}
|
| 32 |
|
| 33 |
voice_id = next(v["voice_id"] for v in tts.list_voices() if v["name"].startswith("Nora Vale"))
|
|
|
|
| 1 |
"""Narration for the launch trailer (Nora Vale, Mission Control). Writes public/launch-vo/*.mp3 + durations.json.
|
| 2 |
|
| 3 |
+
The voice never explains the product. It only says how the journey feels: exploring, excited, mesmerized.
|
| 4 |
|
| 5 |
Run from backend/ (uses its ElevenLabs client and .env), then run make_launch_music.py:
|
| 6 |
cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_launch_vo.py
|
|
|
|
| 16 |
OUT.mkdir(parents=True, exist_ok=True)
|
| 17 |
|
| 18 |
LINES = {
|
| 19 |
+
"intro": "There's a feeling you get at the edge of something vast. Curious. A little breathless.",
|
| 20 |
+
"launch": "Hold on. Here we go.",
|
| 21 |
+
"sources": "Everything you've ever made, glowing back at you. Like seeing Earth from orbit for the first time.",
|
| 22 |
+
"research": "That spark, when a thought you'd forgotten lights up again.",
|
| 23 |
+
"map": "You drift through your own ideas... and you can't look away.",
|
| 24 |
+
"voice": "It feels like you. Only braver.",
|
| 25 |
+
"audio": "Lean back. Let it wash over you.",
|
| 26 |
+
"video": "Color. Motion. Wonder. You'll want to watch it twice.",
|
| 27 |
+
"launchkit": "Your heart races a little. The good kind of nervous.",
|
| 28 |
+
"launchpad": "Three, two, one... and you're flying.",
|
| 29 |
+
"resurface": "And then, out of the dark, an old favorite shines again.",
|
| 30 |
+
"outro": "Notestack A.I. Come explore the galaxies of your untapped potential. Coming soon.",
|
| 31 |
}
|
| 32 |
|
| 33 |
voice_id = next(v["voice_id"] for v in tts.list_voices() if v["name"].startswith("Nora Vale"))
|
renderer/src/compositions/LaunchTrailer.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
import type { CSSProperties, ReactNode } from "react";
|
| 2 |
-
import { AbsoluteFill, Audio as SoundTrack, Easing,
|
| 3 |
import { theme } from "../theme";
|
| 4 |
import { Flare, WarpField } from "./LandingDemo";
|
|
|
|
| 5 |
import { LAUNCH_BPM, LAUNCH_SCENES, LAUNCH_SECONDS, type LaunchSceneId } from "./launchTimeline";
|
| 6 |
|
| 7 |
/** Launch trailer: a journey of knowledge through the galaxies of untapped potential.
|
|
@@ -137,15 +138,15 @@ function typed(text: string, frame: number, start: number, cps = 30): string {
|
|
| 137 |
// ---------------------------------------------------------------- journey HUD
|
| 138 |
|
| 139 |
const STOPS: { id: LaunchSceneId; short: string; name: string; line: string }[] = [
|
| 140 |
-
{ id: "sources", short: "Sources", name: "The Source Nebula", line: "
|
| 141 |
-
{ id: "research", short: "Research", name: "The Research Cluster", line: "
|
| 142 |
-
{ id: "map", short: "Topic map", name: "The Constellation Field", line: "
|
| 143 |
-
{ id: "voice", short: "Voice", name: "The Voice Pulsar", line: "
|
| 144 |
-
{ id: "audio", short: "Audio", name: "The Audio Belt", line: "
|
| 145 |
-
{ id: "video", short: "Studio", name: "The Studio Galaxy", line: "
|
| 146 |
-
{ id: "launchkit", short: "Launch Kit", name: "The Launch Kit Spiral", line: "
|
| 147 |
-
{ id: "launchpad", short: "Launchpad", name: "The Launchpad", line: "
|
| 148 |
-
{ id: "resurface", short: "Resurface", name: "The Evergreen Belt", line: "
|
| 149 |
];
|
| 150 |
|
| 151 |
function Hud({ index }: { index: number }) {
|
|
@@ -158,8 +159,8 @@ function Hud({ index }: { index: number }) {
|
|
| 158 |
const px = (i: number) => x0 + ((x1 - x0) * i) / (STOPS.length - 1);
|
| 159 |
return (
|
| 160 |
<AbsoluteFill style={{ opacity: inn }}>
|
| 161 |
-
<div style={{ position: "absolute", left: 80, top:
|
| 162 |
-
|
| 163 |
</div>
|
| 164 |
<div style={{ position: "absolute", right: 80, top: 56, fontFamily: theme.mono, fontSize: 20, letterSpacing: 5, color: BLUE }}>
|
| 165 |
GALAXY {String(index + 1).padStart(2, "0")} / {String(STOPS.length).padStart(2, "0")}
|
|
@@ -200,8 +201,8 @@ function Stop({ id, children }: { id: LaunchSceneId; children: ReactNode }) {
|
|
| 200 |
const visual = useIn(10, 17);
|
| 201 |
return (
|
| 202 |
<Space drift={1.4}>
|
| 203 |
-
<div style={{ position: "absolute", inset: 0, transform: `scale(${approach})`, transformOrigin: "
|
| 204 |
-
<Galaxy seed={`g-${id}`} x={
|
| 205 |
</div>
|
| 206 |
<AbsoluteFill style={{ background: "linear-gradient(90deg, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.55) 38%, transparent 62%)" }} />
|
| 207 |
<div style={{ position: "absolute", left: 120, top: 250, width: 700 }}>
|
|
@@ -225,6 +226,66 @@ function Stop({ id, children }: { id: LaunchSceneId; children: ReactNode }) {
|
|
| 225 |
|
| 226 |
// ---------------------------------------------------------------- feature visuals (900 x 660 box)
|
| 227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
function SourcesVisual() {
|
| 229 |
const frame = useCurrentFrame();
|
| 230 |
const kinds = ["Blog", "Newsletter", "Website", "Markdown", "PDF", "Substack", "Ghost", "WordPress"];
|
|
@@ -236,9 +297,9 @@ function SourcesVisual() {
|
|
| 236 |
<svg width={900} height={660} style={{ position: "absolute", overflow: "visible" }}>
|
| 237 |
<ellipse cx={cx} cy={cy} rx={330} ry={130} fill="none" stroke={`${BLUE}88`} strokeWidth={2} strokeDasharray="4 10" />
|
| 238 |
<ellipse cx={cx} cy={cy} rx={220} ry={86} fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth={1.5} />
|
| 239 |
-
<circle cx={cx} cy={cy} r={74} fill="#000" stroke={BLUE} strokeWidth={3} style={{ filter: `drop-shadow(0 0 30px ${BLUE})` }} />
|
| 240 |
-
<circle cx={cx} cy={cy} r={40} fill={WHITE} opacity={0.9} style={{ filter: `drop-shadow(0 0 24px ${WHITE})` }} />
|
| 241 |
</svg>
|
|
|
|
|
|
|
| 242 |
{kinds.map((k, i) => {
|
| 243 |
const arrive = interpolate(frame, [12 + i * 9, 50 + i * 9], [0, 1], { ...clamp, easing: ease });
|
| 244 |
const ring = i % 2 ? 220 : 330;
|
|
@@ -545,10 +606,10 @@ function Intro() {
|
|
| 545 |
</div>
|
| 546 |
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center", gap: 28 }}>
|
| 547 |
<h1 style={{ margin: 0, fontFamily: theme.display, fontWeight: 600, fontSize: 80, color: WHITE, opacity: a, transform: `translateY(${(1 - a) * 30}px)` }}>
|
| 548 |
-
|
| 549 |
</h1>
|
| 550 |
<h2 style={{ margin: 0, fontFamily: theme.display, fontWeight: 500, fontSize: 64, color: "rgba(255,255,255,0.75)", opacity: b, transform: `translateY(${(1 - b) * 24}px)` }}>
|
| 551 |
-
|
| 552 |
</h2>
|
| 553 |
<p style={{ margin: "30px 0 0", fontFamily: theme.mono, fontSize: 24, letterSpacing: 8, color: BLUE, opacity: c }}>A JOURNEY OF KNOWLEDGE</p>
|
| 554 |
</AbsoluteFill>
|
|
@@ -628,10 +689,9 @@ function Outro() {
|
|
| 628 |
</div>
|
| 629 |
<AbsoluteFill style={{ background: "radial-gradient(45% 45% at 50% 50%, rgba(0,0,0,0.55), transparent 80%)" }} />
|
| 630 |
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center" }}>
|
| 631 |
-
{
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
</h1>
|
| 635 |
<div style={{ marginTop: 44, display: "flex", gap: 6 }}>
|
| 636 |
{letters.map((ch, i) => {
|
| 637 |
const s = spring({ frame: frame - S(3.2) - i * 2, fps: LAUNCH_FPS, config: { damping: 11 } });
|
|
|
|
| 1 |
import type { CSSProperties, ReactNode } from "react";
|
| 2 |
+
import { AbsoluteFill, Audio as SoundTrack, Easing, interpolate, random, Sequence, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
| 3 |
import { theme } from "../theme";
|
| 4 |
import { Flare, WarpField } from "./LandingDemo";
|
| 5 |
+
import { Lockup } from "./Lockup";
|
| 6 |
import { LAUNCH_BPM, LAUNCH_SCENES, LAUNCH_SECONDS, type LaunchSceneId } from "./launchTimeline";
|
| 7 |
|
| 8 |
/** Launch trailer: a journey of knowledge through the galaxies of untapped potential.
|
|
|
|
| 138 |
// ---------------------------------------------------------------- journey HUD
|
| 139 |
|
| 140 |
const STOPS: { id: LaunchSceneId; short: string; name: string; line: string }[] = [
|
| 141 |
+
{ id: "sources", short: "Sources", name: "The Source Nebula", line: "Everything you made, glowing back at you." },
|
| 142 |
+
{ id: "research", short: "Research", name: "The Research Cluster", line: "The spark of a thought you forgot you had." },
|
| 143 |
+
{ id: "map", short: "Topic map", name: "The Constellation Field", line: "Drift through your own ideas. Stay a while." },
|
| 144 |
+
{ id: "voice", short: "Voice", name: "The Voice Pulsar", line: "It feels like you. Only braver." },
|
| 145 |
+
{ id: "audio", short: "Audio", name: "The Audio Belt", line: "Lean back. Let it wash over you." },
|
| 146 |
+
{ id: "video", short: "Studio", name: "The Studio Galaxy", line: "Color, motion, wonder. Watch it twice." },
|
| 147 |
+
{ id: "launchkit", short: "Launch Kit", name: "The Launch Kit Spiral", line: "Your heart races. The good kind of nervous." },
|
| 148 |
+
{ id: "launchpad", short: "Launchpad", name: "The Launchpad", line: "Three, two, one, and you are flying." },
|
| 149 |
+
{ id: "resurface", short: "Resurface", name: "The Evergreen Belt", line: "An old favorite, shining again." },
|
| 150 |
];
|
| 151 |
|
| 152 |
function Hud({ index }: { index: number }) {
|
|
|
|
| 159 |
const px = (i: number) => x0 + ((x1 - x0) * i) / (STOPS.length - 1);
|
| 160 |
return (
|
| 161 |
<AbsoluteFill style={{ opacity: inn }}>
|
| 162 |
+
<div style={{ position: "absolute", left: 80, top: 44 }}>
|
| 163 |
+
<Lockup size={44} glow={0.6} />
|
| 164 |
</div>
|
| 165 |
<div style={{ position: "absolute", right: 80, top: 56, fontFamily: theme.mono, fontSize: 20, letterSpacing: 5, color: BLUE }}>
|
| 166 |
GALAXY {String(index + 1).padStart(2, "0")} / {String(STOPS.length).padStart(2, "0")}
|
|
|
|
| 201 |
const visual = useIn(10, 17);
|
| 202 |
return (
|
| 203 |
<Space drift={1.4}>
|
| 204 |
+
<div style={{ position: "absolute", inset: 0, transform: `scale(${approach})`, transformOrigin: "1350px 520px" }}>
|
| 205 |
+
<Galaxy seed={`g-${id}`} x={1350} y={520} size={430} tilt={0.42 + random(`t-${id}`) * 0.3} arms={2 + (index % 2)} coreBoost={pulse * 0.25} />
|
| 206 |
</div>
|
| 207 |
<AbsoluteFill style={{ background: "linear-gradient(90deg, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.55) 38%, transparent 62%)" }} />
|
| 208 |
<div style={{ position: "absolute", left: 120, top: 250, width: 700 }}>
|
|
|
|
| 226 |
|
| 227 |
// ---------------------------------------------------------------- feature visuals (900 x 660 box)
|
| 228 |
|
| 229 |
+
/** A ringed planet, like the one in the logo: lit sphere, atmosphere, and a tilted ring whose far
|
| 230 |
+
* side passes behind the body and near side in front. Drawn in its own layer above the orbits. */
|
| 231 |
+
function Planet({ cx, cy, r }: { cx: number; cy: number; r: number }) {
|
| 232 |
+
const frame = useCurrentFrame();
|
| 233 |
+
const tilt = -18;
|
| 234 |
+
const rx = r * 1.75;
|
| 235 |
+
const ry = r * 0.36;
|
| 236 |
+
const shimmer = 0.75 + 0.25 * Math.sin(frame * 0.08);
|
| 237 |
+
const ring = (half: "back" | "front") => (
|
| 238 |
+
<path
|
| 239 |
+
d={half === "back" ? `M ${-rx} 0 A ${rx} ${ry} 0 0 1 ${rx} 0` : `M ${rx} 0 A ${rx} ${ry} 0 0 1 ${-rx} 0`}
|
| 240 |
+
fill="none"
|
| 241 |
+
stroke={half === "back" ? "#9cc4ff" : WHITE}
|
| 242 |
+
strokeOpacity={half === "back" ? 0.45 : 0.95}
|
| 243 |
+
strokeWidth={r * 0.1}
|
| 244 |
+
strokeLinecap="round"
|
| 245 |
+
/>
|
| 246 |
+
);
|
| 247 |
+
return (
|
| 248 |
+
<svg width={900} height={660} style={{ position: "absolute", left: 0, top: 0, overflow: "visible", zIndex: 1 }}>
|
| 249 |
+
<defs>
|
| 250 |
+
<radialGradient id="planet-body" cx="36%" cy="32%" r="75%">
|
| 251 |
+
<stop offset="0%" stopColor="#ffffff" />
|
| 252 |
+
<stop offset="14%" stopColor="#bcd8ff" />
|
| 253 |
+
<stop offset="42%" stopColor={BLUE} />
|
| 254 |
+
<stop offset="78%" stopColor="#0b2f75" />
|
| 255 |
+
<stop offset="100%" stopColor="#020a1c" />
|
| 256 |
+
</radialGradient>
|
| 257 |
+
<radialGradient id="planet-air" cx="50%" cy="50%" r="50%">
|
| 258 |
+
<stop offset="78%" stopColor={BLUE} stopOpacity={0} />
|
| 259 |
+
<stop offset="90%" stopColor={BLUE} stopOpacity={0.55} />
|
| 260 |
+
<stop offset="100%" stopColor={BLUE} stopOpacity={0} />
|
| 261 |
+
</radialGradient>
|
| 262 |
+
<clipPath id="planet-clip">
|
| 263 |
+
<circle r={r} />
|
| 264 |
+
</clipPath>
|
| 265 |
+
</defs>
|
| 266 |
+
<g transform={`translate(${cx} ${cy})`}>
|
| 267 |
+
<circle r={r * 2.2} fill={BLUE} opacity={0.12 * shimmer} />
|
| 268 |
+
<g transform={`rotate(${tilt})`}>{ring("back")}</g>
|
| 269 |
+
<circle r={r} fill="url(#planet-body)" />
|
| 270 |
+
{/* Soft cloud bands, clipped to the sphere, drifting slowly. */}
|
| 271 |
+
<g clipPath="url(#planet-clip)" opacity={0.22}>
|
| 272 |
+
{[-0.45, -0.1, 0.3].map((y, i) => (
|
| 273 |
+
<ellipse key={i} cx={((frame * 0.6 + i * 40) % (r * 2)) - r} cy={y * r} rx={r * 1.1} ry={r * 0.09} fill={WHITE} />
|
| 274 |
+
))}
|
| 275 |
+
</g>
|
| 276 |
+
{/* Night side. */}
|
| 277 |
+
<circle r={r} fill="url(#planet-shadow)" />
|
| 278 |
+
<radialGradient id="planet-shadow" cx="78%" cy="80%" r="70%">
|
| 279 |
+
<stop offset="0%" stopColor="#000" stopOpacity={0.55} />
|
| 280 |
+
<stop offset="100%" stopColor="#000" stopOpacity={0} />
|
| 281 |
+
</radialGradient>
|
| 282 |
+
<circle r={r * 1.14} fill="url(#planet-air)" />
|
| 283 |
+
<g transform={`rotate(${tilt})`}>{ring("front")}</g>
|
| 284 |
+
</g>
|
| 285 |
+
</svg>
|
| 286 |
+
);
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
function SourcesVisual() {
|
| 290 |
const frame = useCurrentFrame();
|
| 291 |
const kinds = ["Blog", "Newsletter", "Website", "Markdown", "PDF", "Substack", "Ghost", "WordPress"];
|
|
|
|
| 297 |
<svg width={900} height={660} style={{ position: "absolute", overflow: "visible" }}>
|
| 298 |
<ellipse cx={cx} cy={cy} rx={330} ry={130} fill="none" stroke={`${BLUE}88`} strokeWidth={2} strokeDasharray="4 10" />
|
| 299 |
<ellipse cx={cx} cy={cy} rx={220} ry={86} fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth={1.5} />
|
|
|
|
|
|
|
| 300 |
</svg>
|
| 301 |
+
<Planet cx={cx} cy={cy} r={82} />
|
| 302 |
+
|
| 303 |
{kinds.map((k, i) => {
|
| 304 |
const arrive = interpolate(frame, [12 + i * 9, 50 + i * 9], [0, 1], { ...clamp, easing: ease });
|
| 305 |
const ring = i % 2 ? 220 : 330;
|
|
|
|
| 606 |
</div>
|
| 607 |
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center", gap: 28 }}>
|
| 608 |
<h1 style={{ margin: 0, fontFamily: theme.display, fontWeight: 600, fontSize: 80, color: WHITE, opacity: a, transform: `translateY(${(1 - a) * 30}px)` }}>
|
| 609 |
+
At the edge of something vast.
|
| 610 |
</h1>
|
| 611 |
<h2 style={{ margin: 0, fontFamily: theme.display, fontWeight: 500, fontSize: 64, color: "rgba(255,255,255,0.75)", opacity: b, transform: `translateY(${(1 - b) * 24}px)` }}>
|
| 612 |
+
Curious. <span style={{ color: WHITE, textShadow: `0 0 30px ${BLUE}, 0 0 70px ${BLUE}` }}>A little breathless.</span>
|
| 613 |
</h2>
|
| 614 |
<p style={{ margin: "30px 0 0", fontFamily: theme.mono, fontSize: 24, letterSpacing: 8, color: BLUE, opacity: c }}>A JOURNEY OF KNOWLEDGE</p>
|
| 615 |
</AbsoluteFill>
|
|
|
|
| 689 |
</div>
|
| 690 |
<AbsoluteFill style={{ background: "radial-gradient(45% 45% at 50% 50%, rgba(0,0,0,0.55), transparent 80%)" }} />
|
| 691 |
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center" }}>
|
| 692 |
+
<div style={{ opacity: Math.min(1, logo), transform: `scale(${0.85 + 0.15 * logo}) translateY(${(1 - name) * 20}px)` }}>
|
| 693 |
+
<Lockup size={176} suffix="AI" glow={1 + pulse * 0.3} />
|
| 694 |
+
</div>
|
|
|
|
| 695 |
<div style={{ marginTop: 44, display: "flex", gap: 6 }}>
|
| 696 |
{letters.map((ch, i) => {
|
| 697 |
const s = spring({ frame: frame - S(3.2) - i * 2, fps: LAUNCH_FPS, config: { damping: 11 } });
|
renderer/src/compositions/Lockup.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Img, staticFile } from "remotion";
|
| 2 |
+
import { theme } from "../theme";
|
| 3 |
+
|
| 4 |
+
/** The navbar logo: the mark (public/logo.svg, copied from frontend/public/logo.svg) with the
|
| 5 |
+
* "Notestack" wordmark beside it, in the same proportions as the site (32 px mark, 10 px gap,
|
| 6 |
+
* Space Grotesk 600 at 18 px). `suffix` adds a word after it in blue, such as "AI". */
|
| 7 |
+
export function Lockup({ size, suffix, glow = 1 }: { size: number; suffix?: string; glow?: number }) {
|
| 8 |
+
const text = (size * 18) / 32;
|
| 9 |
+
return (
|
| 10 |
+
<div style={{ display: "inline-flex", alignItems: "center", gap: (size * 10) / 32 }}>
|
| 11 |
+
<Img
|
| 12 |
+
src={staticFile("logo.svg")}
|
| 13 |
+
width={size}
|
| 14 |
+
height={size}
|
| 15 |
+
style={{ borderRadius: (size * 9) / 32, boxShadow: `0 0 ${size * 0.6 * glow}px ${theme.blue}, 0 0 ${size * 0.2 * glow}px ${theme.white}` }}
|
| 16 |
+
/>
|
| 17 |
+
<span style={{ fontFamily: theme.display, fontWeight: 600, fontSize: text, letterSpacing: "0.01em", lineHeight: 1, color: theme.white, textShadow: glow ? `0 0 ${text * 0.5 * glow}px ${theme.blue}` : undefined }}>
|
| 18 |
+
Notestack
|
| 19 |
+
{suffix && <span style={{ color: theme.blue, marginLeft: text * 0.28 }}>{suffix}</span>}
|
| 20 |
+
</span>
|
| 21 |
+
</div>
|
| 22 |
+
);
|
| 23 |
+
}
|
renderer/src/compositions/demoVo.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Generated by scripts/make_demo_vo.py: narration clip lengths in seconds. Do not edit by hand.
|
| 2 |
+
export const DEMO_VO_SECONDS = {"warp": 3.92, "paste": 6.95, "orbit": 6.61, "research": 3.74, "audio": 2.67, "launchkit": 3.32, "launchpad": 4.05, "outro": 2.48} as const;
|
renderer/src/compositions/launchTimeline.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
| 1 |
// Generated by make_launch_music.py from public/launch-vo/durations.json. Do not edit by hand.
|
| 2 |
// 120 bpm; every scene starts on a bar line so cuts land on the beat.
|
| 3 |
export const LAUNCH_BPM = 120;
|
| 4 |
-
export const LAUNCH_SECONDS =
|
| 5 |
export const LAUNCH_SCENES = [
|
| 6 |
-
{ id: "intro", start: 0.0, len: 8.0, vo: 1.0, voLen: 5.
|
| 7 |
-
{ id: "launch", start: 8.0, len: 4.0, vo: 0.35, voLen:
|
| 8 |
-
{ id: "sources", start: 12.0, len: 8.0, vo: 0.35, voLen: 6.
|
| 9 |
{ id: "research", start: 20.0, len: 6.0, vo: 0.35, voLen: 4.16 },
|
| 10 |
-
{ id: "map", start: 26.0, len:
|
| 11 |
-
{ id: "voice", start:
|
| 12 |
-
{ id: "audio", start:
|
| 13 |
-
{ id: "video", start:
|
| 14 |
-
{ id: "launchkit", start:
|
| 15 |
-
{ id: "launchpad", start:
|
| 16 |
-
{ id: "resurface", start:
|
| 17 |
-
{ id: "montage", start:
|
| 18 |
-
{ id: "outro", start:
|
| 19 |
] as const;
|
| 20 |
export type LaunchSceneId = (typeof LAUNCH_SCENES)[number]["id"];
|
|
|
|
| 1 |
// Generated by make_launch_music.py from public/launch-vo/durations.json. Do not edit by hand.
|
| 2 |
// 120 bpm; every scene starts on a bar line so cuts land on the beat.
|
| 3 |
export const LAUNCH_BPM = 120;
|
| 4 |
+
export const LAUNCH_SECONDS = 82.0;
|
| 5 |
export const LAUNCH_SCENES = [
|
| 6 |
+
{ id: "intro", start: 0.0, len: 8.0, vo: 1.0, voLen: 5.83 },
|
| 7 |
+
{ id: "launch", start: 8.0, len: 4.0, vo: 0.35, voLen: 2.9 },
|
| 8 |
+
{ id: "sources", start: 12.0, len: 8.0, vo: 0.35, voLen: 6.38 },
|
| 9 |
{ id: "research", start: 20.0, len: 6.0, vo: 0.35, voLen: 4.16 },
|
| 10 |
+
{ id: "map", start: 26.0, len: 6.0, vo: 0.35, voLen: 4.47 },
|
| 11 |
+
{ id: "voice", start: 32.0, len: 6.0, vo: 0.35, voLen: 3.56 },
|
| 12 |
+
{ id: "audio", start: 38.0, len: 4.0, vo: 0.35, voLen: 3.19 },
|
| 13 |
+
{ id: "video", start: 42.0, len: 8.0, vo: 0.35, voLen: 5.88 },
|
| 14 |
+
{ id: "launchkit", start: 50.0, len: 6.0, vo: 0.35, voLen: 3.69 },
|
| 15 |
+
{ id: "launchpad", start: 56.0, len: 6.0, vo: 0.35, voLen: 4.94 },
|
| 16 |
+
{ id: "resurface", start: 62.0, len: 6.0, vo: 0.35, voLen: 4.44 },
|
| 17 |
+
{ id: "montage", start: 68.0, len: 4.0, vo: null, voLen: null },
|
| 18 |
+
{ id: "outro", start: 72.0, len: 10.0, vo: 1.2, voLen: 6.3 },
|
| 19 |
] as const;
|
| 20 |
export type LaunchSceneId = (typeof LAUNCH_SCENES)[number]["id"];
|