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"
|
# DATABASE_URL="postgres://anydrop:${POSTGRES_PASSWORD}@postgres.anydrop.svc.cluster.local:5432/anydrop"
|
||||||
# kubectl -n anydrop create secret generic anydrop-app-secrets \
|
# kubectl -n anydrop create secret generic anydrop-app-secrets \
|
||||||
# --from-literal=SESSION_SECRET="$SESSION_SECRET" \
|
# --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.
|
# # MinIO — reuses the shared cluster MinIO in the `minio` namespace.
|
||||||
# # Create a scoped user + policy on MinIO (one-shot), then store its
|
# # Create a scoped user + policy on MinIO (one-shot), then store its
|
||||||
@ -53,6 +57,13 @@ type: Opaque
|
|||||||
stringData:
|
stringData:
|
||||||
SESSION_SECRET: CHANGE_ME_64_BYTE_RANDOM_STRING
|
SESSION_SECRET: CHANGE_ME_64_BYTE_RANDOM_STRING
|
||||||
DATABASE_URL: postgres://anydrop:CHANGE_ME@postgres.anydrop.svc.cluster.local:5432/anydrop
|
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
|
apiVersion: v1
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import Home from "./pages/Home";
|
|||||||
import JoinRoom from "./pages/JoinRoom";
|
import JoinRoom from "./pages/JoinRoom";
|
||||||
import Share from "./pages/Share";
|
import Share from "./pages/Share";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
|
import Pricing from "./pages/Pricing";
|
||||||
import Receive from "./pages/Receive";
|
import Receive from "./pages/Receive";
|
||||||
import Inbox from "./pages/Inbox";
|
import Inbox from "./pages/Inbox";
|
||||||
|
|
||||||
@ -12,6 +13,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/share" element={<Share />} />
|
<Route path="/share" element={<Share />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/pricing" element={<Pricing />} />
|
||||||
<Route path="/inbox" element={<Inbox />} />
|
<Route path="/inbox" element={<Inbox />} />
|
||||||
<Route path="/r/:id" element={<Receive />} />
|
<Route path="/r/:id" element={<Receive />} />
|
||||||
<Route path="/:code" element={<JoinRoom />} />
|
<Route path="/:code" element={<JoinRoom />} />
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
export interface ApiUser {
|
export interface ApiUser {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
plan: string;
|
plan: "free" | "pro";
|
||||||
|
planStatus: string | null;
|
||||||
|
planExpiresAt: string | null;
|
||||||
|
hasStripeCustomer: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,3 +165,26 @@ export async function claimTransfer(id: string): Promise<boolean> {
|
|||||||
const body = (await res.json()) as { claimed: boolean };
|
const body = (await res.json()) as { claimed: boolean };
|
||||||
return body.claimed;
|
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">
|
<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>
|
<span>Nothing transits the server</span>
|
||||||
<Link to="/settings" className="text-ink hover:text-signal transition-colors duration-fast">
|
<div className="flex items-center gap-4">
|
||||||
Account →
|
<Link to="/pricing" className="text-ink-muted hover:text-ink transition-colors duration-fast">
|
||||||
</Link>
|
Pricing →
|
||||||
|
</Link>
|
||||||
|
<Link to="/settings" className="text-ink hover:text-signal transition-colors duration-fast">
|
||||||
|
Account →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</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 {
|
import {
|
||||||
deleteTransfer,
|
deleteTransfer,
|
||||||
listInboxTransfers,
|
listInboxTransfers,
|
||||||
|
openBillingPortal,
|
||||||
registerDevice,
|
registerDevice,
|
||||||
requestMagicLink,
|
requestMagicLink,
|
||||||
|
startCheckout,
|
||||||
unlinkDevice,
|
unlinkDevice,
|
||||||
type InboxTransfer,
|
type InboxTransfer,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
@ -80,15 +82,11 @@ export default function Settings() {
|
|||||||
<div className="paper-panel px-5 py-4">
|
<div className="paper-panel px-5 py-4">
|
||||||
<div className="text-xs uppercase tracking-[0.15em] text-ink-muted">Email</div>
|
<div className="text-xs uppercase tracking-[0.15em] text-ink-muted">Email</div>
|
||||||
<div className="text-ink mt-1">{user.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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<PlanSection />
|
||||||
|
|
||||||
<ReceivedSection />
|
<ReceivedSection />
|
||||||
|
|
||||||
<SharedLinksSection />
|
<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 & {
|
type LinkItem = InboxTransfer & {
|
||||||
filename: string | null;
|
filename: string | null;
|
||||||
keyFrag: string | null;
|
keyFrag: string | null;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user