Spaces:
Runtime error
Runtime error
File size: 5,679 Bytes
00a4363 d149013 79ca2b4 00a4363 d149013 00a4363 66de9c8 00a4363 66de9c8 00a4363 79ca2b4 00a4363 66de9c8 070c931 66de9c8 00a4363 79ca2b4 00a4363 66de9c8 070c931 66de9c8 00a4363 aeea10c d149013 79ca2b4 aeea10c 79ca2b4 d149013 aeea10c d149013 00a4363 66de9c8 79ca2b4 00a4363 c0b9f7d 00a4363 d149013 79ca2b4 d149013 00a4363 d149013 00a4363 d149013 00a4363 d149013 00a4363 d149013 00a4363 aeea10c d149013 00a4363 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 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 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api/client";
import { CheckIcon } from "./icons/Icons";
type Plan = {
id: string;
name: string;
tagline: string;
price_monthly_usd: number;
price_annual_monthly_usd?: number;
price_annual_usd?: number;
annual_savings_pct?: number;
features: string[];
};
type Cycle = "monthly" | "annual";
const ANNUAL_DISCOUNT = 0.25;
// Mirrors backend app/services/plans.py so the page renders before (or without) the API.
const FALLBACK: Plan[] = [
{
id: "free",
name: "Free",
tagline: "For trying Notestack on your archive",
price_monthly_usd: 0,
features: [
"Up to 5 posts indexed, from any source",
"Grounded research chat with citations",
"1 audio overview, any length",
"1 video",
"2 Launch Kits",
"2 infographics",
"2 reports a month",
],
},
{
id: "writer",
name: "Writer",
tagline: "For writers publishing every week",
price_monthly_usd: 24.99,
features: [
"Up to 100 posts indexed, from any source",
"10x more audio overviews than Free (60 min a month)",
"10 videos a month",
"20 Launch Kits a month",
"30 reports a month",
"30 infographics a month",
"Voice cloning with consent",
],
},
{
id: "studio",
name: "Studio",
tagline: "For publications and power users",
price_monthly_usd: 48.99,
features: [
"Up to 250 posts indexed, from any source",
"40x more audio overviews than Free (240 min a month)",
"20 videos a month",
"50 Launch Kits a month",
"Unlimited reports",
"Unlimited infographics",
"Voice cloning with consent",
],
},
];
export function money(n: number): string {
return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
}
/** Nearest .99 price, mirroring plans.to_99 on the backend. */
function to99(amount: number): number {
return amount <= 0 ? 0 : Math.max(0.99, Math.round(amount + 0.01) - 0.01);
}
export function annualMonthly(plan: Plan): number {
return plan.price_annual_monthly_usd ?? to99(plan.price_monthly_usd * (1 - ANNUAL_DISCOUNT));
}
function savingsPct(plan: Plan): number {
if (!plan.price_monthly_usd) return 0;
return plan.annual_savings_pct ?? Math.round((1 - annualMonthly(plan) / plan.price_monthly_usd) * 100);
}
export function annualTotal(plan: Plan): number {
return plan.price_annual_usd ?? Math.round(annualMonthly(plan) * 12 * 100) / 100;
}
export default function PricingTiers() {
const [plans, setPlans] = useState<Plan[]>(FALLBACK);
const [billingEnabled, setBillingEnabled] = useState(false);
const [cycle, setCycle] = useState<Cycle>("annual");
const bestSaving = Math.max(0, ...plans.map(savingsPct));
useEffect(() => {
api<{ billing_enabled: boolean; plans: Plan[] }>("/api/billing/plans")
.then((r) => {
if (Array.isArray(r.plans) && r.plans.length > 0) setPlans(r.plans);
setBillingEnabled(r.billing_enabled);
})
.catch(() => {
/* keep fallback */
});
}, []);
return (
<div className="pricing">
<div className="pricing-top">
<div className="cycle-toggle" role="radiogroup" aria-label="Billing cycle">
<button type="button" role="radio" aria-checked={cycle === "monthly"} className={cycle === "monthly" ? "on" : ""} onClick={() => setCycle("monthly")}>
Monthly
</button>
<button type="button" role="radio" aria-checked={cycle === "annual"} className={cycle === "annual" ? "on" : ""} onClick={() => setCycle("annual")}>
Annual {bestSaving > 0 && <span className="save mono">Save {bestSaving}%</span>}
</button>
</div>
</div>
<div className="pricing-grid">
{plans.map((plan) => {
const featured = plan.id === "writer";
const free = plan.price_monthly_usd === 0;
const annual = cycle === "annual" && !free;
return (
<article key={plan.id} className={`card tier${featured ? " tier-featured active" : ""}`}>
{featured && <span className="badge tier-flag">Most popular</span>}
<h3>{plan.name}</h3>
<p className="muted tier-tagline">{plan.tagline}</p>
<p className="tier-price">
{free ? (
<span className="tier-amount">Free</span>
) : (
<>
{annual && <span className="tier-was muted">{money(plan.price_monthly_usd)}</span>}
<span className="tier-amount">{money(annual ? annualMonthly(plan) : plan.price_monthly_usd)}</span>
<span className="muted">/month</span>
</>
)}
</p>
<p className="mono muted tier-billing">
{free ? "Free forever" : annual ? `${money(annualTotal(plan))} billed yearly` : "Billed monthly"}
</p>
<ul className="tier-features">
{plan.features.map((f) => (
<li key={f}>
<CheckIcon size={16} />
<span>{f}</span>
</li>
))}
</ul>
<Link
to={billingEnabled && !free ? `/app?upgrade=${plan.id}` : "/auth?mode=signup"}
className={`btn ${featured ? "btn-primary" : ""} tier-cta`}
>
{billingEnabled && !free ? `Choose ${plan.name}` : "Start free"}
</Link>
</article>
);
})}
</div>
</div>
);
}
|