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:
parent
dc184c4608
commit
3aaa319264
@ -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
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
<Link to="/settings" className="text-ink hover:text-signal transition-colors duration-fast">
|
||||
Account →
|
||||
</Link>
|
||||
<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
206
web/src/pages/Pricing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user