feat(web): pricing page + plan section in settings (Phase 3)

- web/src/pages/Pricing.tsx: tier comparison, monthly/yearly toggle,
  stripe checkout CTA
- web/src/pages/Settings.tsx: Plan section shows Free/Pro + Upgrade or
  Manage Subscription (opens Stripe Customer Portal)
- web/src/lib/api.ts: startCheckout(), openBillingPortal() + extended ApiUser
- footer on Home gets a discreet "Pricing →" link
- k8s/secrets.example.yml: documents STRIPE_* env vars
- .gitignore: exclude .env files to prevent leaking credentials

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-20 13:28:18 +02:00
parent dc184c4608
commit 3aaa319264
6 changed files with 368 additions and 11 deletions

View File

@ -14,7 +14,11 @@
# DATABASE_URL="postgres://anydrop:${POSTGRES_PASSWORD}@postgres.anydrop.svc.cluster.local:5432/anydrop"
# kubectl -n anydrop create secret generic anydrop-app-secrets \
# --from-literal=SESSION_SECRET="$SESSION_SECRET" \
# --from-literal=DATABASE_URL="$DATABASE_URL"
# --from-literal=DATABASE_URL="$DATABASE_URL" \
# --from-literal=STRIPE_SECRET_KEY="sk_live_…" \
# --from-literal=STRIPE_WEBHOOK_SECRET="whsec_…" \
# --from-literal=STRIPE_PRICE_MONTHLY="price_…" \
# --from-literal=STRIPE_PRICE_YEARLY="price_…"
#
# # MinIO — reuses the shared cluster MinIO in the `minio` namespace.
# # Create a scoped user + policy on MinIO (one-shot), then store its
@ -53,6 +57,13 @@ type: Opaque
stringData:
SESSION_SECRET: CHANGE_ME_64_BYTE_RANDOM_STRING
DATABASE_URL: postgres://anydrop:CHANGE_ME@postgres.anydrop.svc.cluster.local:5432/anydrop
# Phase 3 — Stripe billing. Create the product + recurring prices in the
# Stripe dashboard, then fill these in. Leaving them unset disables the
# /api/billing/* and webhook routes gracefully (503).
STRIPE_SECRET_KEY: CHANGE_ME_sk_live_xxx
STRIPE_WEBHOOK_SECRET: CHANGE_ME_whsec_xxx
STRIPE_PRICE_MONTHLY: CHANGE_ME_price_xxx
STRIPE_PRICE_YEARLY: CHANGE_ME_price_xxx
---
apiVersion: v1

View File

@ -3,6 +3,7 @@ import Home from "./pages/Home";
import JoinRoom from "./pages/JoinRoom";
import Share from "./pages/Share";
import Settings from "./pages/Settings";
import Pricing from "./pages/Pricing";
import Receive from "./pages/Receive";
import Inbox from "./pages/Inbox";
@ -12,6 +13,7 @@ export default function App() {
<Route path="/" element={<Home />} />
<Route path="/share" element={<Share />} />
<Route path="/settings" element={<Settings />} />
<Route path="/pricing" element={<Pricing />} />
<Route path="/inbox" element={<Inbox />} />
<Route path="/r/:id" element={<Receive />} />
<Route path="/:code" element={<JoinRoom />} />

View File

@ -1,7 +1,10 @@
export interface ApiUser {
id: string;
email: string;
plan: string;
plan: "free" | "pro";
planStatus: string | null;
planExpiresAt: string | null;
hasStripeCustomer: boolean;
createdAt: string;
}
@ -162,3 +165,26 @@ export async function claimTransfer(id: string): Promise<boolean> {
const body = (await res.json()) as { claimed: boolean };
return body.claimed;
}
export async function startCheckout(interval: "monthly" | "yearly"): Promise<string> {
const res = await call("/api/billing/checkout", {
method: "POST",
body: JSON.stringify({ interval }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as { error?: string }).error ?? `checkout failed: ${res.status}`);
}
const { url } = (await res.json()) as { url: string };
return url;
}
export async function openBillingPortal(): Promise<string> {
const res = await call("/api/billing/portal", { method: "POST" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as { error?: string }).error ?? `portal failed: ${res.status}`);
}
const { url } = (await res.json()) as { url: string };
return url;
}

View File

@ -147,9 +147,14 @@ function HomeConnected() {
<footer className="pt-6 mt-10 sm:pt-8 sm:mt-14 rule flex items-center justify-between text-xs text-ink-muted">
<span>Nothing transits the server</span>
<div className="flex items-center gap-4">
<Link to="/pricing" className="text-ink-muted hover:text-ink transition-colors duration-fast">
Pricing
</Link>
<Link to="/settings" className="text-ink hover:text-signal transition-colors duration-fast">
Account
</Link>
</div>
</footer>
</div>

206
web/src/pages/Pricing.tsx Normal file
View File

@ -0,0 +1,206 @@
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useAuthStore } from "../stores/useAuthStore";
import { startCheckout } from "../lib/api";
const FREE_FEATURES = [
"Peer-to-peer transfers, LAN or link",
"Cloud relay up to 2 GB",
"Links expire within 7 days",
"1 download per link",
"Password protection",
];
const PRO_FEATURES = [
"Everything in Free",
"Cloud relay up to 20 GB",
"Links last up to 90 days",
"Up to 100 downloads per link",
"Priority support",
];
export default function Pricing() {
const user = useAuthStore((s) => s.user);
const navigate = useNavigate();
const [params] = useSearchParams();
const canceled = params.get("checkout") === "canceled";
const [interval, setInterval] = useState<"monthly" | "yearly">("monthly");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleUpgrade = async () => {
if (!user) {
navigate("/settings");
return;
}
setError(null);
setLoading(true);
try {
const url = await startCheckout(interval);
window.location.assign(url);
} catch (err) {
setLoading(false);
setError(err instanceof Error ? err.message : "unknown_error");
}
};
const isPro = user?.plan === "pro";
return (
<div className="min-h-screen">
<div className="max-w-2xl mx-auto px-5 sm:px-8 pt-10 pb-24">
<header className="flex items-center justify-between pb-8 mb-10 rule">
<Link
to="/"
className="text-sm text-ink-muted hover:text-ink transition-colors duration-fast"
>
Back
</Link>
<h1 className="font-display text-xl text-ink tracking-tight">Pricing</h1>
<div className="w-10" />
</header>
{canceled && (
<div className="mb-8 px-4 py-3 border border-paper-edge bg-paper-deep rounded-sm text-sm text-ink-muted">
Checkout canceled. No charge was made.
</div>
)}
<div className="mb-10">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Plans
</div>
<h2 className="font-display text-3xl text-ink mt-2 tracking-tight">
Simple pricing, fair defaults
</h2>
<p className="text-sm text-ink-muted mt-3 leading-relaxed max-w-md">
Free forever for peer-to-peer and small cloud transfers. Pro unlocks
larger files, longer expiry, and multiple downloads.
</p>
</div>
<div className="inline-flex border border-paper-edge rounded-sm overflow-hidden mb-8">
<button
onClick={() => setInterval("monthly")}
className={`px-4 py-2 text-xs uppercase tracking-[0.15em] transition-colors
${interval === "monthly" ? "bg-ink text-paper" : "bg-paper text-ink-muted hover:text-ink"}`}
>
Monthly
</button>
<button
onClick={() => setInterval("yearly")}
className={`px-4 py-2 text-xs uppercase tracking-[0.15em] transition-colors
${interval === "yearly" ? "bg-ink text-paper" : "bg-paper text-ink-muted hover:text-ink"}`}
>
Yearly <span className="text-signal ml-1">30%</span>
</button>
</div>
<div className="grid sm:grid-cols-2 gap-5">
<PlanCard
name="Free"
priceLabel="€0"
tagline="For every device, always"
features={FREE_FEATURES}
action={
<div className="text-xs text-ink-muted font-mono uppercase tracking-widest py-3">
Current plan if not upgraded
</div>
}
/>
<PlanCard
highlighted
name="Pro"
priceLabel={interval === "monthly" ? "€6" : "€50"}
priceSuffix={interval === "monthly" ? "/ month" : "/ year"}
tagline="When 2 GB isn't enough"
features={PRO_FEATURES}
action={
isPro ? (
<Link
to="/settings"
className="block w-full text-center py-3 border border-paper-edge bg-paper
hover:border-ink text-sm text-ink rounded-sm
transition-colors duration-fast ease-crisp"
>
Manage subscription
</Link>
) : (
<button
onClick={handleUpgrade}
disabled={loading}
className="w-full py-3 bg-ink text-paper text-sm font-medium rounded-sm
hover:bg-signal transition-colors duration-fast ease-crisp
disabled:opacity-40"
>
{loading ? "Opening checkout…" : user ? "Upgrade to Pro →" : "Sign in to upgrade →"}
</button>
)
}
/>
</div>
{error && (
<p className="mt-5 text-xs text-fail font-mono">
{error === "billing_not_configured"
? "Billing is not configured on this instance yet."
: error}
</p>
)}
<p className="mt-10 text-xs text-ink-faint leading-relaxed">
Payments are handled by Stripe. Cancel anytime from the customer portal.
Your peer-to-peer transfers never transit our servers your plan affects
the optional cloud relay only.
</p>
</div>
</div>
);
}
function PlanCard({
name,
priceLabel,
priceSuffix,
tagline,
features,
action,
highlighted,
}: {
name: string;
priceLabel: string;
priceSuffix?: string;
tagline: string;
features: string[];
action: React.ReactNode;
highlighted?: boolean;
}) {
return (
<div
className={`paper-panel p-5 sm:p-6 flex flex-col ${
highlighted ? "shadow-lift border-ink" : ""
}`}
>
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">{name}</div>
<div className="flex items-baseline gap-2 mt-3">
<span className="font-display text-4xl text-ink tracking-tight">{priceLabel}</span>
{priceSuffix && (
<span className="text-xs text-ink-muted font-mono">{priceSuffix}</span>
)}
</div>
<p className="mt-2 text-sm text-ink-muted">{tagline}</p>
<ul className="mt-6 space-y-2.5 flex-1">
{features.map((f) => (
<li key={f} className="text-sm text-ink flex items-start gap-2">
<span className="text-signal mt-1">·</span>
<span>{f}</span>
</li>
))}
</ul>
<div className="mt-6">{action}</div>
</div>
);
}

View File

@ -5,8 +5,10 @@ import { useProfileStore } from "../stores/useProfileStore";
import {
deleteTransfer,
listInboxTransfers,
openBillingPortal,
registerDevice,
requestMagicLink,
startCheckout,
unlinkDevice,
type InboxTransfer,
} from "../lib/api";
@ -80,15 +82,11 @@ export default function Settings() {
<div className="paper-panel px-5 py-4">
<div className="text-xs uppercase tracking-[0.15em] text-ink-muted">Email</div>
<div className="text-ink mt-1">{user.email}</div>
<div className="mt-4 pt-4 border-t border-paper-edge flex items-baseline justify-between">
<div className="text-xs uppercase tracking-[0.15em] text-ink-muted">Plan</div>
<div className="font-mono text-xs text-ink uppercase tracking-widest">
{user.plan}
</div>
</div>
</div>
</section>
<PlanSection />
<ReceivedSection />
<SharedLinksSection />
@ -229,6 +227,115 @@ function SignInForm({ initialError }: { initialError: string | null }) {
);
}
function PlanSection() {
const user = useAuthStore((s) => s.user);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!user) return null;
const isPro = user.plan === "pro";
const renewLabel = user.planExpiresAt
? new Date(user.planExpiresAt).toLocaleDateString()
: null;
const statusLabel = user.planStatus === "canceled"
? "ends"
: "renews";
const handleUpgrade = async () => {
setBusy(true);
setError(null);
try {
const url = await startCheckout("monthly");
window.location.assign(url);
} catch (err) {
setBusy(false);
setError(err instanceof Error ? err.message : "unknown");
}
};
const handlePortal = async () => {
setBusy(true);
setError(null);
try {
const url = await openBillingPortal();
window.location.assign(url);
} catch (err) {
setBusy(false);
setError(err instanceof Error ? err.message : "unknown");
}
};
return (
<section className="mb-12">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Plan
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
{isPro ? "Pro" : "Free"}
</h2>
<div className="paper-panel px-5 py-4">
{isPro ? (
<>
<p className="text-sm text-ink">
20 GB per transfer · up to 90 days · 100 downloads per link.
</p>
{renewLabel && (
<p className="mt-1 text-xs text-ink-muted font-mono">
{statusLabel} {renewLabel}
</p>
)}
<button
onClick={handlePortal}
disabled={busy}
className="mt-4 w-full py-2.5 border border-paper-edge hover:border-ink
text-sm text-ink rounded-sm transition-colors duration-fast ease-crisp
disabled:opacity-40"
>
{busy ? "Opening portal…" : "Manage subscription →"}
</button>
</>
) : (
<>
<p className="text-sm text-ink">
2 GB per transfer · 7-day links · 1 download each.
</p>
<p className="mt-1 text-xs text-ink-muted">
Upgrade to send larger files and keep links alive longer.
</p>
<div className="mt-4 flex gap-2">
<button
onClick={handleUpgrade}
disabled={busy}
className="flex-1 py-2.5 bg-ink text-paper text-sm font-medium rounded-sm
hover:bg-signal transition-colors duration-fast ease-crisp
disabled:opacity-40"
>
{busy ? "Opening checkout…" : "Upgrade to Pro →"}
</button>
<Link
to="/pricing"
className="px-4 py-2.5 border border-paper-edge hover:border-ink
text-sm text-ink rounded-sm transition-colors duration-fast ease-crisp
flex items-center"
>
Compare
</Link>
</div>
</>
)}
{error && (
<p className="mt-3 text-xs text-fail font-mono">
{error === "billing_not_configured"
? "Billing is not configured on this instance yet."
: error}
</p>
)}
</div>
</section>
);
}
type LinkItem = InboxTransfer & {
filename: string | null;
keyFrag: string | null;