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

launching

Browse files
backend/app/services/plans.py CHANGED
@@ -7,6 +7,8 @@ from sqlalchemy.orm import Session
7
  from app.config import settings
8
  from app.models import Subscription, Workspace
9
 
 
 
10
 
11
  @dataclass(frozen=True)
12
  class Plan:
@@ -31,24 +33,25 @@ PLANS: dict[str, Plan] = {
31
  tagline="For trying Notestack on your archive",
32
  price_monthly_usd=0,
33
  sources=1,
34
- indexed_posts=50,
35
- audio_minutes=10,
36
- video_minutes=3,
37
- launch_kits=5,
38
  voice_cloning=False,
39
  brand_kit=False,
40
  features=(
41
- "1 source, 50 indexed posts",
42
  "Grounded research chat with citations",
43
- "10 min of audio overviews a month",
44
- "5 Launch Kits a month",
 
45
  ),
46
  ),
47
  "writer": Plan(
48
  id="writer",
49
  name="Writer",
50
  tagline="For writers publishing every week",
51
- price_monthly_usd=19,
52
  sources=3,
53
  indexed_posts=500,
54
  audio_minutes=60,
@@ -89,9 +92,17 @@ PLANS: dict[str, Plan] = {
89
  }
90
 
91
 
 
 
 
 
 
 
92
  def plan_dict(plan: Plan) -> dict:
93
  data = asdict(plan)
94
  data["features"] = list(plan.features)
 
 
95
  return data
96
 
97
 
 
7
  from app.config import settings
8
  from app.models import Subscription, Workspace
9
 
10
+ ANNUAL_DISCOUNT = 0.25 # annual billing: 25% off the monthly price
11
+
12
 
13
  @dataclass(frozen=True)
14
  class Plan:
 
33
  tagline="For trying Notestack on your archive",
34
  price_monthly_usd=0,
35
  sources=1,
36
+ indexed_posts=5,
37
+ audio_minutes=3,
38
+ video_minutes=1,
39
+ launch_kits=2,
40
  voice_cloning=False,
41
  brand_kit=False,
42
  features=(
43
+ "1 source, your 5 latest posts",
44
  "Grounded research chat with citations",
45
+ "3 min of audio overviews a month",
46
+ "1 min of video a month",
47
+ "2 Launch Kits a month",
48
  ),
49
  ),
50
  "writer": 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,
 
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
 
docs/DESIGN.md CHANGED
@@ -205,12 +205,12 @@ signatures so the schema contract is fixed early.
205
 
206
  | | Free | Writer | Studio |
207
  | --- | --- | --- | --- |
208
- | Price (when billing turns on) | $0 | $19/mo | $49/mo |
209
  | Sources | 1 | 3 | 10 |
210
- | Indexed posts | 50 | 500 | 5,000 |
211
- | Audio overview minutes / mo | 10 | 60 | 240 |
212
- | Video render minutes / mo | 3 | 30 | 120 |
213
- | Launch Kits / mo | 5 | 50 | unlimited |
214
  | Voice profile | yes | yes | yes |
215
  | Voice cloning (with consent) | no | yes | yes |
216
  | Brand kit, no watermark | no | yes | yes |
 
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 |
212
+ | Video render minutes / mo | 1 | 30 | 120 |
213
+ | Launch Kits / mo | 2 | 50 | unlimited |
214
  | Voice profile | yes | yes | yes |
215
  | Voice cloning (with consent) | no | yes | yes |
216
  | Brand kit, no watermark | no | yes | yes |
frontend/src/components/PricingTiers.tsx CHANGED
@@ -8,9 +8,15 @@ type Plan = {
8
  name: string;
9
  tagline: string;
10
  price_monthly_usd: number;
 
 
11
  features: string[];
12
  };
13
 
 
 
 
 
14
  // Mirrors backend app/services/plans.py so the page renders before (or without) the API.
15
  const FALLBACK: Plan[] = [
16
  {
@@ -19,17 +25,18 @@ const FALLBACK: Plan[] = [
19
  tagline: "For trying Notestack on your archive",
20
  price_monthly_usd: 0,
21
  features: [
22
- "1 source, 50 indexed posts",
23
  "Grounded research chat with citations",
24
- "10 min of audio overviews a month",
25
- "5 Launch Kits a month",
 
26
  ],
27
  },
28
  {
29
  id: "writer",
30
  name: "Writer",
31
  tagline: "For writers publishing every week",
32
- price_monthly_usd: 19,
33
  features: [
34
  "3 sources, 500 indexed posts",
35
  "60 min of audio overviews a month",
@@ -55,9 +62,22 @@ const FALLBACK: Plan[] = [
55
  },
56
  ];
57
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  export default function PricingTiers() {
59
  const [plans, setPlans] = useState<Plan[]>(FALLBACK);
60
  const [billingEnabled, setBillingEnabled] = useState(false);
 
61
 
62
  useEffect(() => {
63
  api<{ billing_enabled: boolean; plans: Plan[] }>("/api/billing/plans")
@@ -72,33 +92,46 @@ export default function PricingTiers() {
72
 
73
  return (
74
  <div className="pricing">
75
- {!billingEnabled && (
76
- <p className="pricing-note">
77
- <span className="badge">Early access</span>
78
- <span className="muted">Every plan is free while we are in early access. No card needed.</span>
79
- </p>
80
- )}
 
 
 
 
 
 
 
 
 
 
81
  <div className="pricing-grid">
82
  {plans.map((plan) => {
83
  const featured = plan.id === "writer";
 
 
84
  return (
85
  <article key={plan.id} className={`card tier${featured ? " tier-featured active" : ""}`}>
86
  {featured && <span className="badge tier-flag">Most popular</span>}
87
  <h3>{plan.name}</h3>
88
  <p className="muted tier-tagline">{plan.tagline}</p>
89
  <p className="tier-price">
90
- {billingEnabled || plan.price_monthly_usd === 0 ? (
91
- <>
92
- <span className="tier-amount">${plan.price_monthly_usd}</span>
93
- <span className="muted">/month</span>
94
- </>
95
  ) : (
96
  <>
97
- <span className="tier-amount">$0</span>
98
- <span className="muted tier-strike">${plan.price_monthly_usd}/month later</span>
 
99
  </>
100
  )}
101
  </p>
 
 
 
102
  <ul className="tier-features">
103
  {plan.features.map((f) => (
104
  <li key={f}>
@@ -108,7 +141,7 @@ export default function PricingTiers() {
108
  ))}
109
  </ul>
110
  <Link to="/auth?mode=signup" className={`btn ${featured ? "btn-primary" : ""} tier-cta`}>
111
- {billingEnabled && plan.price_monthly_usd > 0 ? `Choose ${plan.name}` : "Start free"}
112
  </Link>
113
  </article>
114
  );
 
8
  name: string;
9
  tagline: string;
10
  price_monthly_usd: number;
11
+ price_annual_monthly_usd?: number;
12
+ price_annual_usd?: number;
13
  features: string[];
14
  };
15
 
16
+ type Cycle = "monthly" | "annual";
17
+
18
+ const ANNUAL_DISCOUNT = 0.25;
19
+
20
  // Mirrors backend app/services/plans.py so the page renders before (or without) the API.
21
  const FALLBACK: Plan[] = [
22
  {
 
25
  tagline: "For trying Notestack on your archive",
26
  price_monthly_usd: 0,
27
  features: [
28
+ "1 source, your 5 latest posts",
29
  "Grounded research chat with citations",
30
+ "3 min of audio overviews a month",
31
+ "1 min of video a month",
32
+ "2 Launch Kits a month",
33
  ],
34
  },
35
  {
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",
 
62
  },
63
  ];
64
 
65
+ 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 {
74
+ return plan.price_annual_usd ?? Math.round(annualMonthly(plan) * 12 * 100) / 100;
75
+ }
76
+
77
  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")
 
92
 
93
  return (
94
  <div className="pricing">
95
+ <div className="pricing-top">
96
+ {!billingEnabled && (
97
+ <p className="pricing-note">
98
+ <span className="badge">Early access</span>
99
+ <span className="muted">Every plan is free while we are in early access. No card needed.</span>
100
+ </p>
101
+ )}
102
+ <div className="cycle-toggle" role="radiogroup" aria-label="Billing cycle">
103
+ <button type="button" role="radio" aria-checked={cycle === "monthly"} className={cycle === "monthly" ? "on" : ""} onClick={() => setCycle("monthly")}>
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>
111
  <div className="pricing-grid">
112
  {plans.map((plan) => {
113
  const featured = plan.id === "writer";
114
+ const free = plan.price_monthly_usd === 0;
115
+ const annual = cycle === "annual" && !free;
116
  return (
117
  <article key={plan.id} className={`card tier${featured ? " tier-featured active" : ""}`}>
118
  {featured && <span className="badge tier-flag">Most popular</span>}
119
  <h3>{plan.name}</h3>
120
  <p className="muted tier-tagline">{plan.tagline}</p>
121
  <p className="tier-price">
122
+ {free ? (
123
+ <span className="tier-amount">Free</span>
 
 
 
124
  ) : (
125
  <>
126
+ {annual && <span className="tier-was muted">{money(plan.price_monthly_usd)}</span>}
127
+ <span className="tier-amount">{money(annual ? annualMonthly(plan) : plan.price_monthly_usd)}</span>
128
+ <span className="muted">/month</span>
129
  </>
130
  )}
131
  </p>
132
+ <p className="mono muted tier-billing">
133
+ {free ? "Free forever" : annual ? `${money(annualTotal(plan))} billed yearly` : "Billed monthly"}
134
+ </p>
135
  <ul className="tier-features">
136
  {plan.features.map((f) => (
137
  <li key={f}>
 
141
  ))}
142
  </ul>
143
  <Link to="/auth?mode=signup" className={`btn ${featured ? "btn-primary" : ""} tier-cta`}>
144
+ {billingEnabled && !free ? `Choose ${plan.name}` : "Start free"}
145
  </Link>
146
  </article>
147
  );
frontend/src/styles/showcase.css CHANGED
@@ -519,3 +519,68 @@
519
  .demo-unmute:hover {
520
  background: var(--blue);
521
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  .demo-unmute:hover {
520
  background: var(--blue);
521
  }
522
+
523
+ /* Pricing: billing cycle toggle */
524
+ .pricing-top {
525
+ display: flex;
526
+ flex-wrap: wrap;
527
+ align-items: center;
528
+ justify-content: space-between;
529
+ gap: 12px;
530
+ margin-bottom: 20px;
531
+ }
532
+
533
+ .pricing-top .pricing-note {
534
+ margin: 0;
535
+ }
536
+
537
+ .cycle-toggle {
538
+ display: inline-flex;
539
+ padding: 4px;
540
+ border: 1px solid var(--line-strong);
541
+ border-radius: 999px;
542
+ background: rgba(0, 0, 0, 0.6);
543
+ }
544
+
545
+ .cycle-toggle button {
546
+ display: inline-flex;
547
+ align-items: center;
548
+ gap: 8px;
549
+ padding: 7px 16px;
550
+ border: none;
551
+ border-radius: 999px;
552
+ background: transparent;
553
+ color: var(--text-2);
554
+ font: 600 0.875rem var(--font-body);
555
+ cursor: pointer;
556
+ }
557
+
558
+ .cycle-toggle button.on {
559
+ background: var(--blue);
560
+ color: var(--white);
561
+ box-shadow: var(--glow-sm);
562
+ }
563
+
564
+ .cycle-toggle .save {
565
+ padding: 1px 8px;
566
+ border-radius: 999px;
567
+ border: 1px solid var(--blue-60);
568
+ font-size: 0.65rem;
569
+ color: var(--white);
570
+ }
571
+
572
+ .cycle-toggle button.on .save {
573
+ border-color: rgba(255, 255, 255, 0.6);
574
+ }
575
+
576
+ .tier-was {
577
+ margin-right: 8px;
578
+ font-size: 1.1rem;
579
+ text-decoration: line-through;
580
+ text-decoration-color: var(--blue);
581
+ }
582
+
583
+ .tier-billing {
584
+ font-size: 0.72rem;
585
+ margin-top: -4px;
586
+ }
renderer/src/compositions/LandingDemo.tsx CHANGED
@@ -23,6 +23,37 @@ const SCENES = [
23
  ] as const;
24
  const XFADE = 0.5; // seconds of overlap between scenes
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  export const DEMO_DURATION = S(SCENES.reduce((n, s) => n + s.len, 0) - XFADE * (SCENES.length - 1));
27
 
28
  const BLUE = theme.blue;
@@ -518,7 +549,7 @@ export function LandingDemo() {
518
  let cursor = 0;
519
  return (
520
  <AbsoluteFill style={{ backgroundColor: theme.black }}>
521
- <SoundTrack src={staticFile("demo-vo/ambience.wav")} volume={0.32} />
522
  {SCENES.map((scene, i) => {
523
  const from = cursor;
524
  cursor += S(scene.len - XFADE);
 
23
  ] as const;
24
  const XFADE = 0.5; // seconds of overlap between scenes
25
 
26
+ // Narration clip lengths (public/demo-vo/durations.json) so the music can duck under the voice.
27
+ const VO_SECONDS: Record<(typeof SCENES)[number]["id"], number> = {
28
+ warp: 3.79, paste: 3.27, orbit: 6.3, research: 4.29, audio: 3.74, launchkit: 6.53, launchpad: 3.22, outro: 2.48,
29
+ };
30
+
31
+ /** Frame ranges where the narrator is speaking, in composition time. */
32
+ const VO_WINDOWS: [number, number][] = (() => {
33
+ let cursor = 0;
34
+ return SCENES.map((scene) => {
35
+ const start = cursor + scene.vo;
36
+ cursor += scene.len - XFADE;
37
+ return [Math.round(start * 30), Math.round((start + VO_SECONDS[scene.id]) * 30)] as [number, number];
38
+ });
39
+ })();
40
+
41
+ const MUSIC = 0.5;
42
+ const MUSIC_DUCKED = 0.17;
43
+
44
+ /** Music level: full between lines, eased down around each line (0.3 s ramps). */
45
+ function musicVolume(frame: number): number {
46
+ const ramp = 9;
47
+ let level = MUSIC;
48
+ for (const [a, b] of VO_WINDOWS) {
49
+ if (frame >= a - ramp && frame <= b + ramp) {
50
+ const into = interpolate(frame, [a - ramp, a, b, b + ramp], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
51
+ level = Math.min(level, MUSIC - (MUSIC - MUSIC_DUCKED) * into);
52
+ }
53
+ }
54
+ return level;
55
+ }
56
+
57
  export const DEMO_DURATION = S(SCENES.reduce((n, s) => n + s.len, 0) - XFADE * (SCENES.length - 1));
58
 
59
  const BLUE = theme.blue;
 
549
  let cursor = 0;
550
  return (
551
  <AbsoluteFill style={{ backgroundColor: theme.black }}>
552
+ <SoundTrack src={staticFile("demo-vo/music.wav")} volume={musicVolume} />
553
  {SCENES.map((scene, i) => {
554
  const from = cursor;
555
  cursor += S(scene.len - XFADE);