- Your subscription is set to downgrade to Free on {renewal}. You can
- reactivate from the billing portal before then.
+
+ Your subscription is set to downgrade to Free on {renewal}.
)}
-
{billing.plan_status === "past_due" && (
-
- Your last payment failed. Update your payment method in the billing
- portal to keep Pro features active.
+
+ Your last payment failed. Update your payment method to keep Pro
+ active.
)}
-
-
-
-
-
- Llink Pro
-
-
-
- {cadenceLabel} · {formatCents(perSeatCents)} per seat / month
-
-
+
+ Llink Pro
+
Pro
-
-
-
- Seats
- {billing.seats}
-
- {billing.cancel_at_period_end ? "Ends" : "Renews"}
-
- {renewal ?? "—"}
-
-
-
-
-
- {createPortal.isPending
- ? "Opening Stripe..."
- : "Manage subscription"}
-
-
-
-
-
- Seats are synced automatically when you add or remove members. Changes
- are prorated.
-
-
- );
-}
-
-export default function NetworkBillingPage() {
- const navigate = useNavigate();
- const { networkId } = useParams<{ networkId: string }>();
- const { data: networks } = useNetworks();
- const network = networks?.find((n) => n.id === networkId);
- const currentUser = useAuthStore((s) => s.user);
- const isAdmin = currentUser?.id === network?.admin_human.id;
-
- const { data: billing, isLoading, error } = useNetworkBilling(
- isAdmin ? networkId : undefined,
- );
-
- const networkName = network?.name ?? "Network";
-
- return (
-
-
-
+ }
+ />
+
+
+
+
+ {renewal && (
+ <>
+
+
+ >
+ )}
+
navigate(`/${networkId}/settings`)}
+ variant="outline"
+ className="w-full"
+ onClick={handleManage}
+ disabled={createPortal.isPending}
>
-
+
+ {createPortal.isPending
+ ? "Opening Stripe..."
+ : "Manage subscription"}
-
{networkName} · Billing
-
-
-
- {!isAdmin ? (
-
-
- Only the network admin can manage billing.
-
-
- ) : isLoading || !billing ? (
-
- Loading billing...
-
- ) : error ? (
-
-
- Failed to load billing. Try again later.
-
-
- ) : billing.plan === "pro" ? (
-
- ) : (
-
- )}
-
-
-
-
- Payments are processed securely by Stripe. You can view invoices
- and update payment methods from the subscription portal.
-
-
-
-
+ >
);
}
+
+export function BillingSection({ networkId }: { networkId: string }) {
+ const { data: billing, isLoading, error } = useNetworkBilling(networkId);
+
+ if (isLoading || !billing) {
+ return (
+
+ Loading billing...
+
+ );
+ }
+ if (error) {
+ return (
+
+ Failed to load billing.
+
+ );
+ }
+ if (billing.plan === "pro") {
+ return
;
+ }
+ return
;
+}
diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx
index 82069c9..f008dd5 100644
--- a/js/src/features/network-root.tsx
+++ b/js/src/features/network-root.tsx
@@ -1,16 +1,13 @@
import { useCallback, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
-import { CircleDot, CircleCheckBig, CreditCard } from "lucide-react";
+import { CircleDot, CircleCheckBig } from "lucide-react";
import { particlePath } from "@/lib/particle-path";
import { ParticleListView } from "@/features/particles/particle-list-view";
import { VideoAudioToggle } from "@/components/video-audio-toggle";
import { ComposeOverlay } from "./compose/compose-overlay";
-import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useStreamParticles } from "@/hooks/use-stream-particles";
import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav";
-import { useNetworks } from "@/hooks/use-networks";
-import { useAuthStore } from "@/stores/auth-store";
/**
* Route-level component for /:networkId (index).
@@ -21,11 +18,6 @@ export default function NetworkRoot() {
const navigate = useNavigate();
const path = particlePath(networkId!, []);
- const { data: networks } = useNetworks();
- const network = networks?.find((n) => n.id === networkId);
- const currentUser = useAuthStore((s) => s.user);
- const isAdmin = currentUser?.id === network?.admin_human.id;
-
const [composeActive, setComposeActive] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const statusTab: "open" | "closed" =
@@ -58,7 +50,7 @@ export default function NetworkRoot() {
return (
{/* Top bar — stays in place */}
-
+
setStatusTab(v === "closed" ? "closed" : "open")}
@@ -68,17 +60,6 @@ export default function NetworkRoot() {
Closed
- {isAdmin && (
- navigate(`/${networkId}/settings/billing`)}
- >
-
- Billing
-
- )}
{/* Scrollable content */}
diff --git a/js/src/features/network-settings.tsx b/js/src/features/network-settings.tsx
index b2ca79d..7418f73 100644
--- a/js/src/features/network-settings.tsx
+++ b/js/src/features/network-settings.tsx
@@ -17,6 +17,7 @@ import {
useRevokeInvitation,
} from "@/hooks/use-invitations";
import { useAuthStore } from "@/stores/auth-store";
+import { BillingSection } from "@/features/network-billing";
import type { Human } from "@/api/types";
function MemberRow({
@@ -175,7 +176,7 @@ export default function NetworkSettingsPage() {
-
+
{network?.humans.map((human, index) => (
@@ -219,6 +220,12 @@ export default function NetworkSettingsPage() {
)}
+
+
+
+
+
+
>
)}
diff --git a/js/src/hooks/use-billing.ts b/js/src/hooks/use-billing.ts
index bdaf09f..0ef195a 100644
--- a/js/src/hooks/use-billing.ts
+++ b/js/src/hooks/use-billing.ts
@@ -9,7 +9,9 @@ export function useNetworkBilling(networkId: string | undefined) {
enabled: !!networkId,
// Refetch on window focus so the UI catches up after the user returns
// from Stripe Checkout (webhook may land a second or two later).
+ // FIX: doesn't work with electron
refetchOnWindowFocus: true,
+ refetchInterval: 10000
});
}