Arslan1997 commited on
Commit
79ca2b4
·
1 Parent(s): d149013

launching

Browse files
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: int
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=25,
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=49,
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 = round(plan.price_monthly_usd * (1 - ANNUAL_DISCOUNT), 2)
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 | $25/mo ($18.75/mo billed annually) | $49/mo ($36.75/mo billed annually) |
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: 25,
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: 49,
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 ?? Math.round(plan.price_monthly_usd * (1 - ANNUAL_DISCOUNT) * 100) / 100;
 
 
 
 
 
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 25%</span>
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 sounds, synthesized with Web Audio (no assets to load):
2
- * - tap: a soft sonar ping (sine with a shimmering detuned partner) that fades into a faint echo
3
- * - primary: a short rising "launch" chirp with a breath of filtered noise, also echoing
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 whoosh(ac: AudioContext, out: AudioNode, start: number, dur: number, peak: number): void {
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
- if (kind === "primary") {
95
- // Launch: two detuned voices sweep up an octave and a half, a thin noise trail rises with them.
96
- voice(ctx, out, "sine", 330, 880, now, 0.28, 0.035);
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(el.classList.contains("btn-primary") ? "primary" : "tap");
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("tap");
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);