diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js
index 186ee74d1..42f3db4a3 100644
--- a/src/gui/src/UI/Dashboard/TabTeams.js
+++ b/src/gui/src/UI/Dashboard/TabTeams.js
@@ -19,6 +19,7 @@
import UIAlert from '../UIAlert.js';
import UIPrompt from '../UIPrompt.js';
+import teamPlanHtml from './teamPlan.js';
import {
annotateMembers,
auditActionKey,
@@ -31,7 +32,7 @@ import {
const SECTION = '.dashboard-section-teams';
/** What the console last loaded, so a redraw needs no second round trip. */
-let state = { status: 'loading', teams: [], selected: null, members: [], audit: [] };
+let state = { status: 'loading', teams: [], selected: null, members: [], audit: [], plan: null };
/** In flight, so `init` and the initial-route `onActivate` don't both load. */
let loadPromise = null;
@@ -174,6 +175,12 @@ const renderMemberView = () => {
return h + renderAudit();
};
+const renderPlan = () => teamPlanHtml({
+ plan: state.plan,
+ seats: state.members.filter(m => m.org_owned).length,
+ canBuy: window.team_billing_ui === true,
+});
+
const renderDirectory = () => {
const on = state.selected?.directoryEnabled === true;
let h = '
';
@@ -197,6 +204,7 @@ const renderOwnerView = () => {
h += ``;
h += '
';
+ h += renderPlan();
h += renderDirectory();
h += renderAddAccount();
h += renderMembers();
@@ -245,6 +253,35 @@ const loadSelected = async () => {
state.members = state.selected.isOwner
? await puter.teams.listMembers(state.selected.uid)
: [];
+ state.plan = state.selected.isOwner ? await loadPlan(state.selected.uid) : null;
+};
+
+/** Served by a billing extension; absent is a normal answer. */
+const loadPlan = async (teamUid) => {
+ const get = async (path) => {
+ const resp = await fetch(`${window.api_origin}${path}`, {
+ headers: { Authorization: `Bearer ${puter.authToken}` },
+ });
+ return resp.ok ? resp.json() : null;
+ };
+ try {
+ const [cat, sub] = await Promise.all([
+ get('/marketplace/subscriptions/team-offerings'),
+ get(`/marketplace/teams/${encodeURIComponent(teamUid)}/subscription`),
+ ]);
+ if ( ! cat ) return { status: 'unavailable', offerings: [], current: null };
+ const offerings = Array.isArray(cat.offerings) ? cat.offerings : [];
+ const entry = sub?.subscription ?? null;
+ return {
+ status: 'ready',
+ offerings,
+ current: entry
+ ? { ...entry, name_en: offerings.find(o => o.tier === entry.tier)?.name_en }
+ : null,
+ };
+ } catch {
+ return { status: 'unavailable', offerings: [], current: null };
+ }
};
const load = async ($el_window) => {
@@ -481,6 +518,17 @@ const TabTeams = {
$el_window.on('click', `${SECTION} .teams-create`, () => createTeam($el_window));
$el_window.on('click', `${SECTION} .teams-rename`, () => renameTeam($el_window));
$el_window.on('click', `${SECTION} .teams-delete`, () => deleteTeam($el_window));
+ // Checkout is the billing extension's job; this only says what was asked for.
+ $el_window.on('click', `${SECTION} .teams-plan-buy`, function () {
+ if ( ! state.selected ) return;
+ window.dispatchEvent(new CustomEvent('team-plan-purchase', {
+ detail: {
+ teamUid: state.selected.uid,
+ itemId: $(this).attr('data-item-id'),
+ onDone: () => refresh($el_window),
+ },
+ }));
+ });
$el_window.on('click', `${SECTION} .teams-reset`, function () {
reissueCredential($el_window, $(this).attr('data-username'));
});
diff --git a/src/gui/src/UI/Dashboard/teamPlan.js b/src/gui/src/UI/Dashboard/teamPlan.js
new file mode 100644
index 000000000..d3bc6fa34
--- /dev/null
+++ b/src/gui/src/UI/Dashboard/teamPlan.js
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+/**
+ * The team's plan card, drawn from whatever catalogue the server returned. No
+ * tier, price or payment provider is known here.
+ *
+ * @param {object} args `{ plan, seats, canBuy }`
+ * @returns {string} markup, or '' when there is no catalogue
+ */
+export const teamPlanHtml = ({ plan, seats = 0, canBuy = false } = {}) => {
+ if ( plan?.status !== 'ready' ) return '';
+ const current = plan.current ?? null;
+ const offerings = Array.isArray(plan.offerings) ? plan.offerings : [];
+
+ let h = '
';
+ h += `
${i18n('teams_plan')}
`;
+
+ if ( current ) {
+ const key = seats === 1 ? 'teams_plan_current_one' : 'teams_plan_current';
+ h += `