Spaces:
Runtime error
Runtime error
Commit ·
79ca2b4
1
Parent(s): d149013
launching
Browse files- backend/app/services/plans.py +18 -5
- docs/DESIGN.md +1 -1
- frontend/src/components/PricingTiers.tsx +16 -4
- frontend/src/lib/sound.ts +8 -38
- frontend/src/pages/TopicMap.tsx +1 -1
backend/app/services/plans.py
CHANGED
|
@@ -15,7 +15,7 @@ class Plan:
|
|
| 15 |
id: str
|
| 16 |
name: str
|
| 17 |
tagline: str
|
| 18 |
-
price_monthly_usd:
|
| 19 |
sources: int
|
| 20 |
indexed_posts: int
|
| 21 |
audio_minutes: int
|
|
@@ -51,7 +51,7 @@ PLANS: dict[str, Plan] = {
|
|
| 51 |
id="writer",
|
| 52 |
name="Writer",
|
| 53 |
tagline="For writers publishing every week",
|
| 54 |
-
price_monthly_usd=
|
| 55 |
sources=3,
|
| 56 |
indexed_posts=500,
|
| 57 |
audio_minutes=60,
|
|
@@ -72,7 +72,7 @@ PLANS: dict[str, Plan] = {
|
|
| 72 |
id="studio",
|
| 73 |
name="Studio",
|
| 74 |
tagline="For publications and power users",
|
| 75 |
-
price_monthly_usd=
|
| 76 |
sources=10,
|
| 77 |
indexed_posts=5000,
|
| 78 |
audio_minutes=240,
|
|
@@ -92,17 +92,30 @@ PLANS: dict[str, Plan] = {
|
|
| 92 |
}
|
| 93 |
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
def annual_prices(plan: Plan) -> tuple[float, float]:
|
| 96 |
-
"""(effective monthly price, total billed per year) on annual billing."""
|
| 97 |
-
per_month =
|
| 98 |
return per_month, round(per_month * 12, 2)
|
| 99 |
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
def plan_dict(plan: Plan) -> dict:
|
| 102 |
data = asdict(plan)
|
| 103 |
data["features"] = list(plan.features)
|
| 104 |
data["price_annual_monthly_usd"], data["price_annual_usd"] = annual_prices(plan)
|
| 105 |
data["annual_discount"] = ANNUAL_DISCOUNT
|
|
|
|
| 106 |
return data
|
| 107 |
|
| 108 |
|
|
|
|
| 15 |
id: str
|
| 16 |
name: str
|
| 17 |
tagline: str
|
| 18 |
+
price_monthly_usd: float
|
| 19 |
sources: int
|
| 20 |
indexed_posts: int
|
| 21 |
audio_minutes: int
|
|
|
|
| 51 |
id="writer",
|
| 52 |
name="Writer",
|
| 53 |
tagline="For writers publishing every week",
|
| 54 |
+
price_monthly_usd=24.99,
|
| 55 |
sources=3,
|
| 56 |
indexed_posts=500,
|
| 57 |
audio_minutes=60,
|
|
|
|
| 72 |
id="studio",
|
| 73 |
name="Studio",
|
| 74 |
tagline="For publications and power users",
|
| 75 |
+
price_monthly_usd=48.99,
|
| 76 |
sources=10,
|
| 77 |
indexed_posts=5000,
|
| 78 |
audio_minutes=240,
|
|
|
|
| 92 |
}
|
| 93 |
|
| 94 |
|
| 95 |
+
def to_99(amount: float) -> float:
|
| 96 |
+
"""Nearest price ending in .99 (18.74 -> 18.99, 36.74 -> 36.99)."""
|
| 97 |
+
return 0.0 if amount <= 0 else round(max(0.99, round(amount + 0.01) - 0.01), 2)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
def annual_prices(plan: Plan) -> tuple[float, float]:
|
| 101 |
+
"""(effective monthly price, total billed per year) on annual billing, shown as .99 prices."""
|
| 102 |
+
per_month = to_99(plan.price_monthly_usd * (1 - ANNUAL_DISCOUNT))
|
| 103 |
return per_month, round(per_month * 12, 2)
|
| 104 |
|
| 105 |
|
| 106 |
+
def annual_savings_pct(plan: Plan) -> int:
|
| 107 |
+
if not plan.price_monthly_usd:
|
| 108 |
+
return 0
|
| 109 |
+
per_month, _ = annual_prices(plan)
|
| 110 |
+
return round((1 - per_month / plan.price_monthly_usd) * 100)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
def plan_dict(plan: Plan) -> dict:
|
| 114 |
data = asdict(plan)
|
| 115 |
data["features"] = list(plan.features)
|
| 116 |
data["price_annual_monthly_usd"], data["price_annual_usd"] = annual_prices(plan)
|
| 117 |
data["annual_discount"] = ANNUAL_DISCOUNT
|
| 118 |
+
data["annual_savings_pct"] = annual_savings_pct(plan)
|
| 119 |
return data
|
| 120 |
|
| 121 |
|
docs/DESIGN.md
CHANGED
|
@@ -205,7 +205,7 @@ signatures so the schema contract is fixed early.
|
|
| 205 |
|
| 206 |
| | Free | Writer | Studio |
|
| 207 |
| --- | --- | --- | --- |
|
| 208 |
-
| Price (when billing turns on) | $0 | $
|
| 209 |
| Sources | 1 | 3 | 10 |
|
| 210 |
| Indexed posts | 5 | 500 | 5,000 |
|
| 211 |
| Audio overview minutes / mo | 3 | 60 | 240 |
|
|
|
|
| 205 |
|
| 206 |
| | Free | Writer | Studio |
|
| 207 |
| --- | --- | --- | --- |
|
| 208 |
+
| Price (when billing turns on) | $0 | $24.99/mo ($18.99/mo billed annually) | $48.99/mo ($36.99/mo billed annually) |
|
| 209 |
| Sources | 1 | 3 | 10 |
|
| 210 |
| Indexed posts | 5 | 500 | 5,000 |
|
| 211 |
| Audio overview minutes / mo | 3 | 60 | 240 |
|
frontend/src/components/PricingTiers.tsx
CHANGED
|
@@ -10,6 +10,7 @@ type Plan = {
|
|
| 10 |
price_monthly_usd: number;
|
| 11 |
price_annual_monthly_usd?: number;
|
| 12 |
price_annual_usd?: number;
|
|
|
|
| 13 |
features: string[];
|
| 14 |
};
|
| 15 |
|
|
@@ -36,7 +37,7 @@ const FALLBACK: Plan[] = [
|
|
| 36 |
id: "writer",
|
| 37 |
name: "Writer",
|
| 38 |
tagline: "For writers publishing every week",
|
| 39 |
-
price_monthly_usd:
|
| 40 |
features: [
|
| 41 |
"3 sources, 500 indexed posts",
|
| 42 |
"60 min of audio overviews a month",
|
|
@@ -50,7 +51,7 @@ const FALLBACK: Plan[] = [
|
|
| 50 |
id: "studio",
|
| 51 |
name: "Studio",
|
| 52 |
tagline: "For publications and power users",
|
| 53 |
-
price_monthly_usd:
|
| 54 |
features: [
|
| 55 |
"10 sources, 5,000 indexed posts",
|
| 56 |
"240 min of audio overviews a month",
|
|
@@ -66,8 +67,18 @@ function money(n: number): string {
|
|
| 66 |
return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
|
| 67 |
}
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
function annualMonthly(plan: Plan): number {
|
| 70 |
-
return plan.price_annual_monthly_usd ??
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
}
|
| 72 |
|
| 73 |
function annualTotal(plan: Plan): number {
|
|
@@ -78,6 +89,7 @@ export default function PricingTiers() {
|
|
| 78 |
const [plans, setPlans] = useState<Plan[]>(FALLBACK);
|
| 79 |
const [billingEnabled, setBillingEnabled] = useState(false);
|
| 80 |
const [cycle, setCycle] = useState<Cycle>("monthly");
|
|
|
|
| 81 |
|
| 82 |
useEffect(() => {
|
| 83 |
api<{ billing_enabled: boolean; plans: Plan[] }>("/api/billing/plans")
|
|
@@ -104,7 +116,7 @@ export default function PricingTiers() {
|
|
| 104 |
Monthly
|
| 105 |
</button>
|
| 106 |
<button type="button" role="radio" aria-checked={cycle === "annual"} className={cycle === "annual" ? "on" : ""} onClick={() => setCycle("annual")}>
|
| 107 |
-
Annual <span className="save mono">Save
|
| 108 |
</button>
|
| 109 |
</div>
|
| 110 |
</div>
|
|
|
|
| 10 |
price_monthly_usd: number;
|
| 11 |
price_annual_monthly_usd?: number;
|
| 12 |
price_annual_usd?: number;
|
| 13 |
+
annual_savings_pct?: number;
|
| 14 |
features: string[];
|
| 15 |
};
|
| 16 |
|
|
|
|
| 37 |
id: "writer",
|
| 38 |
name: "Writer",
|
| 39 |
tagline: "For writers publishing every week",
|
| 40 |
+
price_monthly_usd: 24.99,
|
| 41 |
features: [
|
| 42 |
"3 sources, 500 indexed posts",
|
| 43 |
"60 min of audio overviews a month",
|
|
|
|
| 51 |
id: "studio",
|
| 52 |
name: "Studio",
|
| 53 |
tagline: "For publications and power users",
|
| 54 |
+
price_monthly_usd: 48.99,
|
| 55 |
features: [
|
| 56 |
"10 sources, 5,000 indexed posts",
|
| 57 |
"240 min of audio overviews a month",
|
|
|
|
| 67 |
return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
|
| 68 |
}
|
| 69 |
|
| 70 |
+
/** Nearest .99 price, mirroring plans.to_99 on the backend. */
|
| 71 |
+
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 |
+
|
| 79 |
+
function savingsPct(plan: Plan): number {
|
| 80 |
+
if (!plan.price_monthly_usd) return 0;
|
| 81 |
+
return plan.annual_savings_pct ?? Math.round((1 - annualMonthly(plan) / plan.price_monthly_usd) * 100);
|
| 82 |
}
|
| 83 |
|
| 84 |
function annualTotal(plan: Plan): number {
|
|
|
|
| 89 |
const [plans, setPlans] = useState<Plan[]>(FALLBACK);
|
| 90 |
const [billingEnabled, setBillingEnabled] = useState(false);
|
| 91 |
const [cycle, setCycle] = useState<Cycle>("monthly");
|
| 92 |
+
const bestSaving = Math.max(0, ...plans.map(savingsPct));
|
| 93 |
|
| 94 |
useEffect(() => {
|
| 95 |
api<{ billing_enabled: boolean; plans: Plan[] }>("/api/billing/plans")
|
|
|
|
| 116 |
Monthly
|
| 117 |
</button>
|
| 118 |
<button type="button" role="radio" aria-checked={cycle === "annual"} className={cycle === "annual" ? "on" : ""} onClick={() => setCycle("annual")}>
|
| 119 |
+
Annual {bestSaving > 0 && <span className="save mono">Save {bestSaving}%</span>}
|
| 120 |
</button>
|
| 121 |
</div>
|
| 122 |
</div>
|
frontend/src/lib/sound.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
-
/** Space-flavored UI
|
| 2 |
-
*
|
| 3 |
-
*
|
| 4 |
-
* Everything is very quiet. Viewers can turn sounds off in Settings (stored per browser). */
|
| 5 |
|
| 6 |
const KEY = "ns_click_sounds";
|
| 7 |
|
|
@@ -61,28 +60,7 @@ function voice(ac: AudioContext, out: AudioNode, type: OscillatorType, from: num
|
|
| 61 |
osc.stop(start + dur + 0.02);
|
| 62 |
}
|
| 63 |
|
| 64 |
-
function
|
| 65 |
-
const len = Math.floor(ac.sampleRate * dur);
|
| 66 |
-
const buffer = ac.createBuffer(1, len, ac.sampleRate);
|
| 67 |
-
const data = buffer.getChannelData(0);
|
| 68 |
-
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
|
| 69 |
-
const src = ac.createBufferSource();
|
| 70 |
-
src.buffer = buffer;
|
| 71 |
-
const band = ac.createBiquadFilter();
|
| 72 |
-
band.type = "bandpass";
|
| 73 |
-
band.Q.value = 6;
|
| 74 |
-
band.frequency.setValueAtTime(600, start);
|
| 75 |
-
band.frequency.exponentialRampToValueAtTime(3200, start + dur);
|
| 76 |
-
const gain = ac.createGain();
|
| 77 |
-
gain.gain.setValueAtTime(0.0001, start);
|
| 78 |
-
gain.gain.exponentialRampToValueAtTime(peak, start + dur * 0.3);
|
| 79 |
-
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
|
| 80 |
-
src.connect(band).connect(gain).connect(out);
|
| 81 |
-
src.start(start);
|
| 82 |
-
src.stop(start + dur);
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
export function playClick(kind: "tap" | "primary" = "tap"): void {
|
| 86 |
if (!soundsEnabled()) return;
|
| 87 |
try {
|
| 88 |
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
@@ -91,17 +69,9 @@ export function playClick(kind: "tap" | "primary" = "tap"): void {
|
|
| 91 |
if (ctx.state === "suspended") void ctx.resume();
|
| 92 |
const now = ctx.currentTime + 0.005;
|
| 93 |
const out = output(ctx);
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
voice(ctx, out, "triangle", 330, 880, now, 0.28, 0.012, 9);
|
| 98 |
-
voice(ctx, out, "sine", 1320, 1760, now + 0.09, 0.2, 0.01);
|
| 99 |
-
whoosh(ctx, out, now, 0.26, 0.012);
|
| 100 |
-
} else {
|
| 101 |
-
// Sonar ping: a clean tone with a slightly detuned shimmer, dropping a touch as it fades.
|
| 102 |
-
voice(ctx, out, "sine", 1046, 988, now, 0.22, 0.025);
|
| 103 |
-
voice(ctx, out, "sine", 1568, 1480, now, 0.16, 0.006, 7);
|
| 104 |
-
}
|
| 105 |
} catch {
|
| 106 |
/* audio is decoration only */
|
| 107 |
}
|
|
@@ -115,7 +85,7 @@ export function installClickSounds(): void {
|
|
| 115 |
if (e.button !== 0) return;
|
| 116 |
const el = (e.target as Element | null)?.closest?.("button, a.btn, [role='tab'], .cal-item, .agenda-row");
|
| 117 |
if (!el || (el as HTMLButtonElement).disabled || el.getAttribute("aria-disabled") === "true") return;
|
| 118 |
-
playClick(
|
| 119 |
},
|
| 120 |
{ capture: true, passive: true },
|
| 121 |
);
|
|
|
|
| 1 |
+
/** Space-flavored UI sound, synthesized with Web Audio (no assets to load): one soft sonar ping
|
| 2 |
+
* (a sine with a shimmering detuned partner) that fades into a faint echo, used for every click.
|
| 3 |
+
* Very quiet. Viewers can turn sounds off in Settings (stored per browser). */
|
|
|
|
| 4 |
|
| 5 |
const KEY = "ns_click_sounds";
|
| 6 |
|
|
|
|
| 60 |
osc.stop(start + dur + 0.02);
|
| 61 |
}
|
| 62 |
|
| 63 |
+
export function playClick(): void {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
if (!soundsEnabled()) return;
|
| 65 |
try {
|
| 66 |
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
|
|
| 69 |
if (ctx.state === "suspended") void ctx.resume();
|
| 70 |
const now = ctx.currentTime + 0.005;
|
| 71 |
const out = output(ctx);
|
| 72 |
+
// Sonar ping: a clean tone with a slightly detuned shimmer, dropping a touch as it fades.
|
| 73 |
+
voice(ctx, out, "sine", 1046, 988, now, 0.22, 0.025);
|
| 74 |
+
voice(ctx, out, "sine", 1568, 1480, now, 0.16, 0.006, 7);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
} catch {
|
| 76 |
/* audio is decoration only */
|
| 77 |
}
|
|
|
|
| 85 |
if (e.button !== 0) return;
|
| 86 |
const el = (e.target as Element | null)?.closest?.("button, a.btn, [role='tab'], .cal-item, .agenda-row");
|
| 87 |
if (!el || (el as HTMLButtonElement).disabled || el.getAttribute("aria-disabled") === "true") return;
|
| 88 |
+
playClick();
|
| 89 |
},
|
| 90 |
{ capture: true, passive: true },
|
| 91 |
);
|
frontend/src/pages/TopicMap.tsx
CHANGED
|
@@ -204,7 +204,7 @@ export default function TopicMap() {
|
|
| 204 |
};
|
| 205 |
|
| 206 |
const open = async (id: string, center = false) => {
|
| 207 |
-
playClick(
|
| 208 |
if (center) {
|
| 209 |
const p = positions.get(id);
|
| 210 |
if (p) focusOn(p.x, p.y);
|
|
|
|
| 204 |
};
|
| 205 |
|
| 206 |
const open = async (id: string, center = false) => {
|
| 207 |
+
playClick();
|
| 208 |
if (center) {
|
| 209 |
const p = positions.get(id);
|
| 210 |
if (p) focusOn(p.x, p.y);
|