@growthub/cli 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,134 +1,105 @@
1
+ import { readAdapterConfig } from "@/lib/adapters/env";
1
2
  import { describeIntegrationAdapter, listAgencyPortalIntegrations } from "@/lib/adapters/integrations";
2
- import { portalCapabilities } from "@/lib/domain/portal";
3
3
  import { groupIntegrationsByLane } from "@/lib/domain/integrations";
4
+ import { buildPortalWorkspace } from "@/lib/domain/portal";
5
+ import { describeAuthAdapter } from "@/lib/adapters/auth";
6
+ import { describePaymentAdapter } from "@/lib/adapters/payments";
7
+ import { describePersistenceAdapter } from "@/lib/adapters/persistence";
4
8
  import Link from "next/link";
5
- const nav = [
6
- ...portalCapabilities.map((item) => ({ href: `/#${item.id}`, label: item.label })),
7
- { href: "/settings/integrations", label: "Integrations" }
8
- ];
9
9
  async function IntegrationsSettingsPage() {
10
+ const config = readAdapterConfig();
10
11
  const adapter = describeIntegrationAdapter();
11
12
  const grouped = groupIntegrationsByLane(await listAgencyPortalIntegrations());
12
- const allIntegrations = [...grouped.dataSources, ...grouped.workspaceIntegrations];
13
- const connectedCount = allIntegrations.filter((item) => item.isConnected).length;
13
+ const workspace = buildPortalWorkspace({
14
+ config,
15
+ integrations: grouped,
16
+ adapters: {
17
+ persistence: describePersistenceAdapter(),
18
+ auth: describeAuthAdapter(),
19
+ payments: describePaymentAdapter(),
20
+ integrations: adapter
21
+ }
22
+ });
23
+ const rows = [
24
+ ...grouped.dataSources.map((item) => ({ ...item, primitiveGroup: "data-source" })),
25
+ ...grouped.workspaceIntegrations.map((item) => ({ ...item, primitiveGroup: "workspace-integration" }))
26
+ ];
14
27
  return <main className="shell">
15
28
  <aside className="sidebar">
16
29
  <div className="brand">
17
- <span className="brand-mark">GH</span>
18
- <span>Agency Portal</span>
30
+ <span className="brand-mark">{workspace.identity.mark}</span>
31
+ <span>{workspace.identity.label}</span>
19
32
  </div>
20
33
  <nav className="nav">
21
- {nav.map((item) => <Link className={item.href === "/settings/integrations" ? "active" : ""} key={item.href} href={item.href}>
34
+ {workspace.navigation.map((item) => <Link className={item.href === "/settings/integrations" ? "active" : ""} key={item.href} href={item.href.startsWith("#") ? `/${item.href}` : item.href}>
22
35
  {item.label}
23
36
  </Link>)}
24
37
  </nav>
25
38
  <div className="sidebar-footer">
26
39
  <span className="status-dot" />
27
- {connectedCount} connected
40
+ {adapter.authority}
28
41
  </div>
29
42
  </aside>
30
-
31
43
  <section className="main">
32
44
  <div className="utility-bar">
33
45
  <div>
34
- <strong>Integration setup</strong>
35
- <span>Growthub bridge, BYO API keys, and Windsor data pipelines normalize into one worker-kit object model.</span>
46
+ <strong>{adapter.label}</strong>
47
+ <span>{workspace.identity.primitiveContract}</span>
36
48
  </div>
37
49
  <div className="utility-actions">
50
+ <span className="pill">{adapter.id}</span>
38
51
  <span className="pill">{adapter.authority}</span>
39
- <span className="pill">{adapter.source}</span>
40
52
  </div>
41
53
  </div>
42
-
43
- <div className="page-heading">
44
- <span className="eyebrow">Settings</span>
45
- <h1>Integrations</h1>
46
- <p>
47
- Configure the portal through the hosted Growthub auth bridge or through explicit bring-your-own credentials.
48
- Data pipeline objects stay separate from workspace integrations while sharing one normalized surface.
49
- </p>
50
- <span className="badge">{adapter.label}</span>
51
- </div>
52
-
53
- <section className="setup-grid" aria-label="Integration setup paths">
54
- <article className="setup-card">
55
- <span>01</span>
56
- <strong>Growthub Bridge</strong>
57
- <p>Uses the authenticated Growthub account to resolve already-connected MCP accounts for this portal.</p>
58
- <code>GROWTHUB_BRIDGE_ACCESS_TOKEN</code>
54
+ <section className="primitive-grid summary" aria-label="Integration adapter primitives">
55
+ <article className="primitive-card">
56
+ <div className="primitive-card-top">
57
+ <p className="card-label">Authority</p>
58
+ <span className="status runtime-derived">{adapter.authority}</span>
59
+ </div>
60
+ <strong>{adapter.id}</strong>
61
+ <div className="primitive-meta">
62
+ {adapter.requiredEnv.map((key) => <code key={key}>{key}</code>)}
63
+ </div>
59
64
  </article>
60
- <article className="setup-card">
61
- <span>02</span>
62
- <strong>BYO API Key</strong>
63
- <p>Supports direct provider keys without binding the kit to a database, vendor, or hosted account.</p>
64
- <code>AGENCY_PORTAL_BYO_CONNECTIONS_JSON</code>
65
+ <article className="primitive-card">
66
+ <div className="primitive-card-top">
67
+ <p className="card-label">Data-source primitives</p>
68
+ <span className="status runtime-derived">{grouped.dataSources.length}</span>
69
+ </div>
70
+ <strong>{grouped.dataSources.filter((item) => item.isConnected).length}/{grouped.dataSources.length}</strong>
71
+ <div className="primitive-meta"><span>{config.reportingAdapter || "reporting-adapter"}</span></div>
65
72
  </article>
66
- <article className="setup-card">
67
- <span>03</span>
68
- <strong>Windsor Data</strong>
69
- <p>First-class reporting pipeline for blended Meta, Shopify, GA4, and Google Sheets data sources.</p>
70
- <code>WINDSOR_API_KEY</code>
73
+ <article className="primitive-card">
74
+ <div className="primitive-card-top">
75
+ <p className="card-label">Workspace primitives</p>
76
+ <span className="status runtime-derived">{grouped.workspaceIntegrations.length}</span>
77
+ </div>
78
+ <strong>{grouped.workspaceIntegrations.filter((item) => item.isConnected).length}/{grouped.workspaceIntegrations.length}</strong>
79
+ <div className="primitive-meta"><span>{config.integrationAdapter}</span></div>
71
80
  </article>
72
81
  </section>
73
-
74
82
  <section className="integration-board">
75
- <div className="integration-toolbar">
76
- <div>
77
- <strong>Connection authority</strong>
78
- <p>{adapter.description}</p>
79
- </div>
80
- <code>GET /api/settings/integrations</code>
81
- </div>
82
-
83
- <IntegrationPanel
84
- title="Data pipeline objects"
85
- intro="Meta, Shopify, GA4, Windsor AI, and Google Sheets blended data feed reporting and analytics workflows."
86
- items={grouped.dataSources}
87
- />
88
- <IntegrationPanel
89
- title="MCP connection integrations"
90
- intro="Asana, Slack, GHL, Google Drive, and Notion are operational integrations resolved through MCP connection authority or explicit BYO setup."
91
- items={grouped.workspaceIntegrations}
92
- />
83
+ {rows.map((item) => <article className="integration-card" key={item.id}>
84
+ <div className="integration-card-top">
85
+ <div className="provider-mark">{item.icon || item.label.slice(0, 1)}</div>
86
+ <div>
87
+ <strong>{item.label}</strong>
88
+ <p>{item.provider} / {item.objectType} / {item.primitiveGroup}</p>
89
+ </div>
90
+ <span className={`status ${item.status}`}>{item.status}</span>
91
+ </div>
92
+ <div className="integration-card-meta">
93
+ <span>{item.authPath}</span>
94
+ <span>{item.setupMode}</span>
95
+ <span>{item.authType}</span>
96
+ {item.secretEnvName ? <span>{item.secretEnvName}</span> : null}
97
+ </div>
98
+ </article>)}
93
99
  </section>
94
100
  </section>
95
101
  </main>;
96
102
  }
97
- function IntegrationPanel({
98
- title,
99
- intro,
100
- items
101
- }) {
102
- const connected = items.filter((item) => item.isConnected).length;
103
- return <article className="integration-section">
104
- <div className="section-heading">
105
- <div>
106
- <h2>{title}</h2>
107
- <p className="panel-copy">{intro}</p>
108
- </div>
109
- <span className="badge">{connected}/{items.length} connected</span>
110
- </div>
111
- <div className="integration-list">
112
- {items.map((item) => <article className="integration-card" key={item.id}>
113
- <div className="integration-card-top">
114
- <div className="provider-mark">{item.label.slice(0, 1)}</div>
115
- <div>
116
- <strong>{item.label}</strong>
117
- <p>{item.description}</p>
118
- </div>
119
- <span className={`status ${item.status}`}>{item.status}</span>
120
- </div>
121
- <div className="integration-card-meta">
122
- <span>{item.provider}</span>
123
- <span>{item.objectType}</span>
124
- <span>{item.authPath}</span>
125
- <span>{item.setupMode}</span>
126
- {item.secretEnvName ? <span>{item.secretEnvName}</span> : null}
127
- </div>
128
- </article>)}
129
- </div>
130
- </article>;
131
- }
132
103
  export {
133
104
  IntegrationsSettingsPage as default
134
105
  };
@@ -0,0 +1,8 @@
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": ".",
4
+ "paths": {
5
+ "@/*": ["./*"]
6
+ }
7
+ }
8
+ }
@@ -57,7 +57,27 @@ async function listAgencyPortalIntegrations() {
57
57
  return agencyPortalIntegrationCatalog;
58
58
  }
59
59
  const payload = await response.json();
60
- return mergeBridgeRows(normalizeGrowthubBridgePayload(payload));
60
+ const merged = mergeBridgeRows(normalizeGrowthubBridgePayload(payload));
61
+ return applyApiKeyOverlays(merged, config);
62
+ }
63
+ function applyApiKeyOverlays(integrations, config) {
64
+ if (!config.dataSources.hasWindsorApiKey) return integrations;
65
+ const windsorOverlay = {
66
+ status: "connected",
67
+ isConnected: true,
68
+ isActive: true,
69
+ authPath: "byo-api-key",
70
+ setupMode: "bring-your-own-key",
71
+ authType: "api_token",
72
+ category: "api_key",
73
+ secretEnvName: "WINDSOR_API_KEY",
74
+ connectionMetadata: { source: "workspace-env", secretEnvName: "WINDSOR_API_KEY" }
75
+ };
76
+ return integrations.map((item) => {
77
+ if (item.provider === "windsor-ai") return { ...item, ...windsorOverlay };
78
+ if (item.provider === "google-sheets") return { ...item, ...windsorOverlay, secretEnvName: undefined, connectionMetadata: { source: "windsor-blended-data" } };
79
+ return item;
80
+ });
61
81
  }
62
82
  function readBringYourOwnRows() {
63
83
  const raw = process.env.AGENCY_PORTAL_BYO_CONNECTIONS_JSON;
@@ -1,16 +1,150 @@
1
- const portalCapabilities = [
2
- { id: "dashboard", label: "Dashboard", metric: "Live agency snapshot", description: "Revenue, client health, overdue work, and next actions." },
3
- { id: "clients", label: "Clients", metric: "Profiles and onboarding", description: "Client records, notes, KPIs, lifecycle state, and contacts." },
4
- { id: "pipeline", label: "Pipeline", metric: "Opportunities", description: "Lead stages, potential value, won/lost state, and follow-up ownership." },
5
- { id: "content", label: "Content", metric: "Calendar", description: "Client content plans by channel, due date, owner, and status." },
6
- { id: "tasks", label: "Tasks", metric: "Execution queue", description: "Priorities, recurring templates, due dates, and completion state." },
7
- { id: "finance", label: "Finance", metric: "Invoices and expenses", description: "Billing state, expenses, payment status, and retainer visibility." },
8
- { id: "reports", label: "Reports", metric: "Performance reviews", description: "Ad and campaign reporting through a pluggable reporting adapter." },
9
- { id: "metrics", label: "Metrics", metric: "Agency health", description: "Period-over-period MRR, churn, pipeline, and workload indicators." },
10
- { id: "client-results", label: "Client Results", metric: "Windsor reporting", description: "Blended data pipelines for Meta, Shopify, GA4, and Google Sheets-backed reports." },
11
- { id: "operations", label: "Operations", metric: "SOP library", description: "Internal documentation, quick links, workflows, and process memory." },
12
- { id: "settings", label: "Settings", metric: "Workspace control", description: "Branding, adapter selections, deployment metadata, and user preferences." }
1
+ const fieldTypes = [
2
+ "text",
3
+ "long_text",
4
+ "number",
5
+ "currency",
6
+ "percentage",
7
+ "date",
8
+ "datetime",
9
+ "select",
10
+ "multi_select",
11
+ "boolean",
12
+ "relation",
13
+ "multi_relation",
14
+ "formula",
15
+ "rollup",
16
+ "url",
17
+ "email",
18
+ "phone",
19
+ "json",
20
+ "file",
21
+ "rating",
22
+ "user"
13
23
  ];
24
+ const capabilitySpecs = [
25
+ tab("dashboard", "Dashboard", "workspace-summary", ["agencyAccount", "client", "task"], ["pipelineValue", "clientHealth", "openTasks", "reportingReadiness"], ["data", "auth", "integrations"]),
26
+ tab("clients", "Clients", "client-records", ["client", "contact", "clientKpi"], ["activeClients", "retainerValue", "healthScore", "renewalWindow"], ["data", "auth"]),
27
+ tab("pipeline", "Pipeline", "opportunity-flow", ["opportunity", "client", "contact"], ["stageValue", "winRate", "nextFollowUp", "ownerLoad"], ["data", "integrations"]),
28
+ tab("content", "Content", "publishing-workflow", ["contentPlan", "client", "task"], ["scheduledPosts", "channelMix", "approvalQueue", "overdueAssets"], ["data", "integrations"]),
29
+ tab("tasks", "Tasks", "execution-queue", ["task", "client", "workflowRun"], ["openTasks", "slaRisk", "ownerLoad", "recurringTemplates"], ["data", "integrations"]),
30
+ tab("finance", "Finance", "billing-control", ["invoice", "expense", "client"], ["mrr", "outstandingBalance", "margin", "paymentState"], ["data", "payments"]),
31
+ tab("reports", "Reports", "reporting-adapter", ["report", "clientKpi", "dataConnector"], ["reportQueue", "connectedSources", "periodDelta", "deliveryState"], ["reporting", "integrations"]),
32
+ tab("metrics", "Metrics", "agency-health", ["agencyMetric", "clientKpi", "opportunity"], ["mrrTrend", "churnRisk", "pipelineCoverage", "capacity"], ["data", "reporting"]),
33
+ tab("client-results", "Client Results", "client-performance", ["clientKpi", "report", "dataConnector"], ["roas", "cac", "revenue", "sourceFreshness"], ["reporting", "data-sources"]),
34
+ tab("operations", "Operations", "process-memory", ["sop", "workflowRun", "task"], ["sopCoverage", "runHistory", "handoffHealth", "blockedWork"], ["data", "auth"]),
35
+ tab("settings", "Settings", "workspace-control", ["workspaceSetting", "dataConnector", "userPreference"], ["adapterState", "permissionCoverage", "auditTrail", "deploymentState"], ["auth", "payments", "integrations"])
36
+ ];
37
+ const objectDefinitions = {
38
+ agencyAccount: object("agencyAccount", "Agency account", [["name", "text"], ["domain", "url"], ["owner", "user"], ["settings", "json"], ["createdAt", "datetime"]]),
39
+ client: object("client", "Client", [["name", "text"], ["retainer", "currency"], ["healthScore", "rating"], ["stage", "select"], ["primaryContact", "relation"], ["renewalDate", "date"], ["monthlyRevenue", "currency"], ["notes", "long_text"]]),
40
+ contact: object("contact", "Contact", [["fullName", "text"], ["email", "email"], ["phone", "phone"], ["client", "relation"], ["role", "select"], ["lastTouchAt", "datetime"]]),
41
+ clientKpi: object("clientKpi", "Client KPI", [["client", "relation"], ["source", "select"], ["period", "date"], ["metric", "text"], ["value", "number"], ["delta", "percentage"], ["formulaValue", "formula"]]),
42
+ opportunity: object("opportunity", "Opportunity", [["client", "relation"], ["name", "text"], ["stage", "select"], ["value", "currency"], ["probability", "percentage"], ["owner", "user"], ["nextFollowUp", "datetime"]]),
43
+ contentPlan: object("contentPlan", "Content plan", [["client", "relation"], ["channel", "multi_select"], ["publishAt", "datetime"], ["asset", "file"], ["status", "select"], ["approvers", "multi_relation"]]),
44
+ task: object("task", "Task", [["title", "text"], ["client", "relation"], ["owner", "user"], ["priority", "select"], ["dueAt", "datetime"], ["done", "boolean"], ["sourcePayload", "json"]]),
45
+ workflowRun: object("workflowRun", "Workflow run", [["name", "text"], ["status", "select"], ["startedAt", "datetime"], ["completedAt", "datetime"], ["relatedTasks", "multi_relation"], ["trace", "json"]]),
46
+ invoice: object("invoice", "Invoice", [["client", "relation"], ["amount", "currency"], ["status", "select"], ["dueDate", "date"], ["paidAt", "datetime"], ["lineItems", "json"]]),
47
+ expense: object("expense", "Expense", [["client", "relation"], ["amount", "currency"], ["category", "select"], ["receipt", "file"], ["billable", "boolean"]]),
48
+ report: object("report", "Report", [["client", "relation"], ["period", "date"], ["status", "select"], ["sourceConnectors", "multi_relation"], ["snapshot", "json"], ["publishedUrl", "url"]]),
49
+ dataConnector: object("dataConnector", "Data connector", [["provider", "text"], ["status", "select"], ["authPath", "text"], ["lastSyncedAt", "datetime"], ["metadata", "json"]]),
50
+ agencyMetric: object("agencyMetric", "Agency metric", [["period", "date"], ["mrr", "currency"], ["churn", "percentage"], ["utilization", "percentage"], ["pipeline", "currency"], ["score", "formula"]]),
51
+ sop: object("sop", "SOP", [["title", "text"], ["area", "select"], ["owner", "user"], ["body", "long_text"], ["relatedObjects", "multi_relation"], ["version", "number"]]),
52
+ workspaceSetting: object("workspaceSetting", "Workspace setting", [["key", "text"], ["value", "json"], ["adapter", "select"], ["permission", "select"], ["updatedAt", "datetime"]]),
53
+ userPreference: object("userPreference", "User preference", [["user", "user"], ["theme", "select"], ["defaultView", "text"], ["notifications", "json"]])
54
+ };
55
+ const portalCapabilities = capabilitySpecs.map(({ id, label, objectType, bindings }) => ({ id, label, objectType, bindings }));
56
+ function tab(id, label, objectType, objects, widgets, bindings) {
57
+ return { id, label, objectType, objects, widgets, bindings };
58
+ }
59
+ function object(id, label, fields) {
60
+ return {
61
+ id,
62
+ label,
63
+ fields: fields.map(([name, type]) => ({ name, type })),
64
+ views: ["table", "kanban", "record", "dashboard"],
65
+ contract: "twenty-sdk/define"
66
+ };
67
+ }
68
+ function buildPortalWorkspace({ config, adapters, integrations }) {
69
+ const integrationRows = [...integrations.dataSources, ...integrations.workspaceIntegrations];
70
+ const connectedRows = integrationRows.filter((item) => item.isConnected);
71
+ const connectedDataSources = integrations.dataSources.filter((item) => item.isConnected);
72
+ const adapterRows = [
73
+ primitive("data", "Data", config.dataAdapter, adapters.persistence.mode, adapters.persistence.requiredEnv),
74
+ primitive("auth", "Auth", config.authAdapter, adapters.auth.id, adapters.auth.requiredEnv),
75
+ primitive("payments", "Payments", config.paymentAdapter, adapters.payments.enabled ? "enabled" : "disabled", adapters.payments.requiredEnv),
76
+ primitive("integrations", "Integrations", config.integrationAdapter, adapters.integrations.authority, adapters.integrations.requiredEnv),
77
+ primitive("reporting", "Reporting", config.reportingAdapter || "not-configured", config.dataSources.hasWindsorApiKey ? "windsor-key-present" : "adapter-selected", []),
78
+ primitive("data-sources", "Data sources", `${connectedDataSources.length}/${integrations.dataSources.length}`, config.dataSources.hasWindsorApiKey ? "windsor-overlay" : "integration-state", [])
79
+ ];
80
+ return {
81
+ identity: {
82
+ label: "Agency Portal",
83
+ mark: "GH",
84
+ mode: "governed-worker-kit",
85
+ deployTarget: config.deployTarget,
86
+ primitiveContract: "twenty-sdk/define objects, fields, views, dashboards, widgets, permissions, CRUD, API, webhooks, and audit logs",
87
+ fieldTypes
88
+ },
89
+ navigation: [
90
+ ...portalCapabilities.map((item) => ({ href: `#${item.id}`, label: item.label })),
91
+ { href: "/settings/integrations", label: "Integrations" }
92
+ ],
93
+ summary: [
94
+ primitive("object-schema", "Object schema", `${Object.keys(objectDefinitions).length} objects`, "twenty-sdk/define", fieldTypes),
95
+ primitive("dashboard-widgets", "Dashboard widgets", `${capabilitySpecs.reduce((total, item) => total + item.widgets.length, 0)} widgets`, "capability dashboards", ["bar", "line", "pie", "number"]),
96
+ primitive("connection-state", "Connection state", `${connectedRows.length}/${integrationRows.length}`, config.integrationAdapter, adapters.integrations.requiredEnv)
97
+ ],
98
+ adapters: adapterRows,
99
+ capabilities: capabilitySpecs.map((item) => buildCapabilityPrimitive(item, adapterRows, integrations)),
100
+ actions: capabilitySpecs.slice(0, 5).map((item) => ({ href: `#${item.id}`, label: item.label, objectType: item.objectType })),
101
+ api: [
102
+ { label: "Workspace contract", href: "/api/workspace", method: "GET" },
103
+ { label: "Integration contract", href: "/api/settings/integrations", method: "GET" }
104
+ ]
105
+ };
106
+ }
107
+ function primitive(id, label, value, source, env) {
108
+ return {
109
+ id,
110
+ label,
111
+ value,
112
+ source,
113
+ env,
114
+ status: env.length ? "configured-by-env" : "runtime-derived"
115
+ };
116
+ }
117
+ function buildCapabilityPrimitive(capability, adapterRows, integrations) {
118
+ const bindings = adapterRows.filter((item) => capability.bindings.includes(item.id));
119
+ const relatedObjects = capability.objects.map((id) => objectDefinitions[id]);
120
+ const fieldCount = relatedObjects.reduce((count, item) => count + item.fields.length, 0);
121
+ const dataSources = integrations.dataSources.filter((item) => capability.bindings.includes("data-sources") || capability.bindings.includes("reporting"));
122
+ const workspaceIntegrations = integrations.workspaceIntegrations.filter((item) => capability.bindings.includes("integrations"));
123
+ return {
124
+ ...capability,
125
+ status: bindings.some((item) => item.value === "not-configured") ? "needs-runtime-config" : "runtime-ready",
126
+ fields: fieldCount,
127
+ objects: relatedObjects,
128
+ views: Array.from(new Set(relatedObjects.flatMap((item) => item.views))),
129
+ widgets: capability.widgets.map((id, index) => ({
130
+ id,
131
+ chart: ["number", "bar", "line", "pie"][index % 4],
132
+ sourceObject: relatedObjects[index % relatedObjects.length].id,
133
+ filters: ["client", "period", "owner", "status"].slice(0, 2 + index % 3),
134
+ realtime: true
135
+ })),
136
+ bindings,
137
+ integrations: [...dataSources, ...workspaceIntegrations].map((item) => ({
138
+ id: item.id,
139
+ label: item.label,
140
+ provider: item.provider,
141
+ objectType: item.objectType,
142
+ status: item.status,
143
+ source: item.authPath
144
+ }))
145
+ };
146
+ }
14
147
  export {
148
+ buildPortalWorkspace,
15
149
  portalCapabilities
16
150
  };
@@ -77,3 +77,10 @@ It also accepts the GH app MCP accounts shape returned by `/api/mcp/accounts`:
77
77
  Both shapes normalize into the same `AgencyPortalIntegration` object used by the BYO path. Unknown connected providers are preserved as discovered workspace integrations instead of being dropped.
78
78
 
79
79
  The BYO path uses the same normalized object shape, but expects the workspace operator to provide the connection metadata and secret env names explicitly. Windsor is first-class: when `WINDSOR_API_KEY` is set with `AGENCY_PORTAL_INTEGRATION_ADAPTER=byo-api-key`, the app marks the Windsor AI data pipeline object as connected without requiring a larger JSON payload. The hosted Growthub bridge remains the lower-friction first-party path for user-owned MCP connections.
80
+
81
+ The starter also supports a safe hybrid mode for new users: keep `AGENCY_PORTAL_INTEGRATION_ADAPTER=growthub-bridge` for hosted account authority, and set `WINDSOR_API_KEY` locally when Windsor reporting is being bootstrapped before the hosted bridge has returned a Windsor account row. In that mode the app overlays a connected Windsor state onto:
82
+
83
+ - `windsor-ai`
84
+ - `google-sheets` blended data
85
+
86
+ This preserves the bridge as the source of truth for the rest of the integration catalog while letting the portal surface Windsor-backed reporting setup end to end from first boot.
@@ -1,22 +1,19 @@
1
1
  import React from "react";
2
+ import { agencyPortalIntegrationCatalog, groupIntegrationsByLane } from "../../apps/agency-portal/lib/domain/integrations.js";
3
+ import { portalCapabilities } from "../../apps/agency-portal/lib/domain/portal.js";
2
4
 
3
- const capabilities = [
4
- ["Dashboard", "Live agency snapshot", "Revenue, client health, overdue work, and next actions."],
5
- ["Clients", "Profiles and onboarding", "Client records, notes, KPIs, lifecycle state, and contacts."],
6
- ["Pipeline", "Opportunities", "Lead stages, potential value, won/lost state, and follow-up ownership."],
7
- ["Reports", "Performance reviews", "Ad and campaign reporting through pluggable reporting adapters."],
8
- ["Client Results", "Windsor reporting", "Blended Meta, Shopify, GA4, and Google Sheets-backed results."],
9
- ["Settings", "Workspace control", "Branding, adapter selections, deployment metadata, and user preferences."],
10
- ];
11
-
5
+ const grouped = groupIntegrationsByLane(agencyPortalIntegrationCatalog);
6
+ const integrations = [...grouped.dataSources, ...grouped.workspaceIntegrations];
12
7
  const adapters = [
13
- ["Persistence", "Postgres-compatible adapter boundary, not bound to one vendor."],
14
- ["Auth", "Local workspace auth or Growthub hosted bridge authority."],
15
- ["Payments", "Replaceable billing adapter for Stripe or explicit project needs."],
16
- ["Integrations", "Growthub MCP bridge, BYO API tokens, and Windsor data pipelines."],
8
+ ["Persistence", "Provider-managed", "Postgres, Qstash KV, or provider-managed adapter boundary."],
9
+ ["Auth", "Provider-managed", "OIDC, Clerk, Auth.js, or provider-managed auth selector."],
10
+ ["Payments", "None", "Stripe, Polar, or disabled payment adapter selector."],
11
+ ["Integrations", `${integrations.filter((item) => item.isConnected).length}/${integrations.length} connected`, "Growthub MCP bridge, BYO API tokens, and Windsor data pipelines."],
12
+ ];
13
+ const quickActions = [
14
+ ...portalCapabilities.slice(0, 4).map((item) => ({ href: `#${item.id}`, label: item.label })),
15
+ { href: "#integrations", label: "Integrations" },
17
16
  ];
18
-
19
- const quickActions = ["Client onboarding", "Publish report", "Sync Windsor data", "Review open tasks"];
20
17
 
21
18
  export default function App() {
22
19
  return (
@@ -27,8 +24,8 @@ export default function App() {
27
24
  <span>Agency Portal</span>
28
25
  </div>
29
26
  <nav className="nav">
30
- {capabilities.map(([label]) => (
31
- <a key={label} href={`#${label.toLowerCase().replaceAll(" ", "-")}`}>{label}</a>
27
+ {portalCapabilities.map((capability) => (
28
+ <a key={capability.id} href={`#${capability.id}`}>{capability.label}</a>
32
29
  ))}
33
30
  <a href="#integrations">Integrations</a>
34
31
  </nav>
@@ -46,7 +43,7 @@ export default function App() {
46
43
  </div>
47
44
  <div className="utility-actions">
48
45
  <a href="#integrations">Open integrations</a>
49
- <span className="pill">worker kit v1</span>
46
+ <span className="pill">deploy: local-preview</span>
50
47
  </div>
51
48
  </div>
52
49
 
@@ -54,26 +51,25 @@ export default function App() {
54
51
  <span className="eyebrow">Agency operating system</span>
55
52
  <h1>Client work, reporting, and integrations in one governed shell.</h1>
56
53
  <p>
57
- This starter keeps the copied prototype as product direction while the worker kit owns the
58
- composable adapter model, local Vite workflow, and clean Vercel deployment path.
54
+ The local shell previews the same capability and integration objects that the deployable portal exposes.
59
55
  </p>
60
56
  </header>
61
57
 
62
58
  <section className="hero-grid">
63
59
  <article className="hero-card primary">
64
- <span>Monthly revenue</span>
65
- <strong>$84.2k</strong>
66
- <p>MRR, retainers, won pipeline, and invoice state are ready to bind to any supported database adapter.</p>
60
+ <span>Runtime adapters</span>
61
+ <strong>provider-managed</strong>
62
+ <p>Persistence, auth, and payment selectors stay replaceable at runtime.</p>
67
63
  </article>
68
64
  <article className="hero-card">
69
65
  <span>MCP connections</span>
70
- <strong>9 active</strong>
71
- <p>Growthub bridge connections can hydrate user-linked providers without hardcoded API sprawl.</p>
66
+ <strong>{integrations.filter((item) => item.isConnected).length}/{integrations.length}</strong>
67
+ <p>Bridge, BYO, and catalog rows normalize into one integration object model.</p>
72
68
  </article>
73
69
  <article className="hero-card">
74
70
  <span>Windsor data</span>
75
- <strong>4 sources</strong>
76
- <p>Meta, Shopify, GA4, and Sheets-backed blended data are modeled as data pipeline objects.</p>
71
+ <strong>{grouped.dataSources.filter((item) => item.isConnected).length}/{grouped.dataSources.length}</strong>
72
+ <p>Data pipeline readiness comes from integration state, not seeded reporting metrics.</p>
77
73
  </article>
78
74
  </section>
79
75
 
@@ -101,19 +97,20 @@ export default function App() {
101
97
  </section>
102
98
 
103
99
  <section className="grid">
104
- {capabilities.map(([label, metric, description]) => (
105
- <article className="card" id={label.toLowerCase().replaceAll(" ", "-")} key={label}>
106
- <span>{metric}</span>
107
- <h3>{label}</h3>
108
- <p>{description}</p>
100
+ {portalCapabilities.map((capability) => (
101
+ <article className="card" id={capability.id} key={capability.id}>
102
+ <span>{capability.metric}</span>
103
+ <h3>{capability.label}</h3>
104
+ <p>{capability.description}</p>
109
105
  </article>
110
106
  ))}
111
107
  </section>
112
108
 
113
109
  <section className="adapter">
114
- {adapters.map(([title, body]) => (
110
+ {adapters.map(([title, value, body]) => (
115
111
  <article className="card" key={title}>
116
112
  <h3>{title}</h3>
113
+ <p><strong>{value}</strong></p>
117
114
  <p>{body}</p>
118
115
  </article>
119
116
  ))}
@@ -147,15 +144,15 @@ export default function App() {
147
144
  </p>
148
145
  </div>
149
146
  <div className="results-metrics">
150
- <div><strong>Meta</strong><span>Ads and social source</span></div>
151
- <div><strong>Shopify</strong><span>Commerce source</span></div>
152
- <div><strong>GA4</strong><span>Analytics source</span></div>
147
+ {grouped.dataSources.map((source) => (
148
+ <div key={source.id}><strong>{source.label}</strong><span>{source.status}</span></div>
149
+ ))}
153
150
  </div>
154
151
  </section>
155
152
  </section>
156
153
 
157
154
  <div className="quick-actions" aria-label="Quick actions">
158
- {quickActions.map((action) => <button key={action}>{action}</button>)}
155
+ {quickActions.map((action) => <a key={action.href} href={action.href}>{action.label}</a>)}
159
156
  </div>
160
157
  </main>
161
158
  );
@@ -114,7 +114,7 @@ a { color: inherit; }
114
114
  .results-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
115
115
  .results-metrics span { display: block; color: var(--muted); font-size: 12px; }
116
116
  .quick-actions { position: fixed; right: 20px; bottom: 20px; display: grid; gap: 8px; width: 190px; }
117
- .quick-actions button {
117
+ .quick-actions button, .quick-actions a {
118
118
  border: 1px solid var(--line);
119
119
  border-radius: 8px;
120
120
  padding: 10px 12px;
@@ -122,6 +122,7 @@ a { color: inherit; }
122
122
  color: var(--ink);
123
123
  text-align: left;
124
124
  font: inherit;
125
+ text-decoration: none;
125
126
  }
126
127
 
127
128
  @media (max-width: 1020px) {