@growth-operations/ui-shared 1.0.0

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.
package/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # @growth-operations/ui-shared
2
+
3
+ Shared HubSpot UI-extension components for Growth Operations apps. One component
4
+ library serves all three app archetypes — `big_app` (toast, sparkfly), `ats`,
5
+ and `credit` — by rendering a single payload contract and branching on
6
+ `entitlement.mode`.
7
+
8
+ The components are **presentational**: every app-specific decision (onboarding
9
+ steps, nudges, health verdict, credit thresholds) is computed server-side and
10
+ delivered in the payload. The package never hardcodes app logic, an `app_key`,
11
+ or an app name.
12
+
13
+ ## The /v1/home contract
14
+
15
+ Every app exposes `GET /api/v1/home?portalId={id}` (verify_hubspot) returning the
16
+ single payload these components render. The authoritative shape lives in
17
+ [CONTRACT.md](./CONTRACT.md) — read it first.
18
+
19
+ The one field every consumer gates on is the discriminated `entitlement` union:
20
+
21
+ ```jsonc
22
+ // Arm A — big_app + ATS (maps onto common.entitlements.is_entitled)
23
+ { "mode": "trial_subscription", "entitled": true, "status": "trialing", ... }
24
+
25
+ // Arm B — credit apps (net-new balance model)
26
+ { "mode": "credits", "entitled": true, "granted": 100, "used": 37, "remaining": 63, ... }
27
+ ```
28
+
29
+ `entitlement.entitled` is the only gate the UI/features read. Optional blocks
30
+ (`health`, `onboarding`, `nudges`, `sync_pulse`, `credit_meter`, `pipeline`,
31
+ `activation`) may be absent — each component renders nothing when its block is
32
+ missing.
33
+
34
+ ## Consuming the package
35
+
36
+ Add the dependency (pin the exact version — see Versioning):
37
+
38
+ ```jsonc
39
+ // project/app/app.functions/package.json (or the extension's package.json)
40
+ { "dependencies": { "@growth-operations/ui-shared": "1.0.0" } }
41
+ ```
42
+
43
+ Install into the HubSpot project so the bundler picks it up:
44
+
45
+ ```bash
46
+ hs project install-deps
47
+ ```
48
+
49
+ Render Home with the single opinionated component:
50
+
51
+ ```jsx
52
+ import { AppHome } from "@growth-operations/ui-shared";
53
+
54
+ function Home({ context }) {
55
+ const [state, setState] = useState(null); // GET /api/v1/home
56
+ return (
57
+ <AppHome
58
+ state={state}
59
+ appName="Sparkfly Sync"
60
+ onNavigate={(route) => actions.openIframeModal(/* or your router */)}
61
+ />
62
+ );
63
+ }
64
+ ```
65
+
66
+ `AppHome` composes the lifecycle: loading spinner → `InstallProgress` (until
67
+ activation completes) → the activated cockpit (`HealthHero` + onboarding +
68
+ the archetype tile + nudges).
69
+
70
+ Render the shared Billing tab (pass the Stripe `app_key` as a prop — never
71
+ hardcoded in the package):
72
+
73
+ ```jsx
74
+ import { BillingTab } from "@growth-operations/ui-shared";
75
+
76
+ <BillingTab context={context} state={state} appKey="hubspot_sparkfly" />;
77
+ ```
78
+
79
+ `BillingTab` branches on `entitlement.mode`: `trial_subscription` shows the
80
+ Stripe Customer Portal link + color-coded status panel; `credits` shows the
81
+ `CreditMeter` + a "pick a plan / buy credits" CTA.
82
+
83
+ ### Individual exports
84
+
85
+ `AppHome`, `InstallProgress`, `HealthHero`, `OnboardingChecklist`, `SyncPulse`,
86
+ `FeatureNudges`, `CreditMeter`, `BillingTab`, plus the lib helpers `fmtDate`,
87
+ `daysUntil`, `humanizeStatus`, `statusVariant`, and `useStrictModeEffect`.
88
+
89
+ ## SDK
90
+
91
+ `src/sdk/` is the Growth Ops HubSpot app SDK: the token + API utility layer that
92
+ every token-using app used to reimplement. Use it instead of hand-rolling
93
+ `hubspot.fetch` calls in each app. Everything is re-exported from the package
94
+ root, so `import { callAppApi } from "@growth-operations/ui-shared"` works.
95
+
96
+ ### `hubspot.fetch` rules (why this SDK exists)
97
+
98
+ These two rules have repeatedly cost real debugging time; the SDK enforces both
99
+ so callers never have to remember them:
100
+
101
+ 1. **Body is a plain object — never `JSON.stringify` it.** `hubspot.fetch`
102
+ serializes the body for you; a pre-stringified body is rejected/mangled. Pass
103
+ `{ foo: "bar" }`, not `'{"foo":"bar"}'`.
104
+ 2. **No custom headers.** The platform injects `Authorization` for calls to your
105
+ own backend, so `callAppApi` sends no headers at all. For direct HubSpot API
106
+ calls, `Authorization: Bearer <token>` is the *one* permitted client header —
107
+ `callHubSpotApi` sends only that.
108
+
109
+ ### App backend — `callAppApi`
110
+
111
+ Calls your app's own Cloud Run backend at `context.variables.BASE_URL`:
112
+
113
+ ```js
114
+ import { callAppApi, AppApiError } from "@growth-operations/ui-shared";
115
+
116
+ // body is a plain object (NOT JSON.stringify'd)
117
+ const result = await callAppApi(context, "/api/v1/settings", "PUT", { foo: 1 });
118
+ // throws AppApiError(message, statusCode, responseText) on non-2xx;
119
+ // FastAPI {"detail": "..."} bodies are unwrapped into the message.
120
+ ```
121
+
122
+ Built on top of `callAppApi`:
123
+
124
+ - `getSettings(context, { force })` / `updateSettings(context, body)` — module-cached
125
+ settings client (`force: true` bypasses the cache, e.g. when polling). Use
126
+ `makeSettingsApi({ path })` for a client with its own cache or a different path.
127
+ - `getAlerts(context, params)` / `dismissAlert(context, alertId)` — the generic
128
+ `/api/v1/alerts` surface (`offset`, `limit`, `orderBy`, `orderDirection`,
129
+ `filterField`, `filterValue`).
130
+
131
+ ### HubSpot public API — `callHubSpotApi`
132
+
133
+ Direct calls to `api.hubapi.com` with a short-lived OAuth token:
134
+
135
+ ```js
136
+ import { callHubSpotApi, buildHubSpotUrl, HubSpotApiError } from "@growth-operations/ui-shared";
137
+
138
+ const url = buildHubSpotUrl("crm/v3/properties/contacts");
139
+ const data = await callHubSpotApi(url, token); // GET
140
+ // POST/PATCH bodies are plain objects too:
141
+ await callHubSpotApi(url, token, "POST", { query: "x", count: 100 });
142
+ ```
143
+
144
+ Resource fetchers built on it (all take `(context, token, ...)`):
145
+ `getObjectProperties(context, token, objectType)` (picker-shaped, filtered),
146
+ `getProperties` (raw), `updateProperties(context, token, properties)`,
147
+ `getForms`, `getLists(context, token, query)`,
148
+ `getPipelines` / `getPipelineStages` / `computeStageChanges`.
149
+
150
+ ### Token lifecycle — `getShortLivedToken` / `TokenProvider`
151
+
152
+ `getShortLivedToken(context)` fetches a short-lived HubSpot OAuth token from the
153
+ app backend (`GET /api/v1/tokens/short-lived?portalId=...`) and returns the token
154
+ string. For React trees, wrap with `TokenProvider` and read the token via
155
+ `useToken()`, which caches by `expires_at` at module level (survives remounts, no
156
+ re-renders):
157
+
158
+ ```jsx
159
+ import { TokenProvider, useToken, callHubSpotApi, buildHubSpotUrl } from "@growth-operations/ui-shared";
160
+
161
+ function Root({ context }) {
162
+ return (
163
+ <TokenProvider context={context}>
164
+ <Mapper />
165
+ </TokenProvider>
166
+ );
167
+ }
168
+
169
+ function Mapper() {
170
+ const { ensureValidToken, invalidateToken } = useToken();
171
+ async function load() {
172
+ const token = await ensureValidToken(); // fetches/refreshes as needed
173
+ return callHubSpotApi(buildHubSpotUrl("marketing/v3/forms"), token);
174
+ }
175
+ // ...
176
+ }
177
+ ```
178
+
179
+ ### CRM-card encryption
180
+
181
+ `encrypt(data, context)` / `decrypt(ciphertext, context)` plus the primitives
182
+ `strToBase64`, `base64ToStr`, `getKeyFromContext`. AES-GCM keyed to the current
183
+ CRM record (`context.extension.objectTypeId` + `context.crm.objectId`), so usable
184
+ only in a CRM-card context.
185
+
186
+ ### SDK exports
187
+
188
+ `callAppApi`, `AppApiError`, `getSettings`, `updateSettings`, `makeSettingsApi`,
189
+ `getAlerts`, `dismissAlert`, `callHubSpotApi`, `buildHubSpotUrl`,
190
+ `HubSpotApiError`, `getObjectProperties`, `getProperties`, `updateProperties`,
191
+ `getForms`, `getLists`, `getPipelines`, `getPipelineStages`,
192
+ `computeStageChanges`, `getShortLivedToken`, `isTokenExpired`, `TokenProvider`,
193
+ `useToken`, `TokenContext`, `encrypt`, `decrypt`, `strToBase64`, `base64ToStr`,
194
+ `getKeyFromContext`.
195
+
196
+ ## Peer dependencies
197
+
198
+ `react` and `@hubspot/ui-extensions` are **peer dependencies** — they are
199
+ provided by the consuming HubSpot app, not bundled here. The package imports
200
+ from them but never ships its own copy, so a single React/UI-extensions runtime
201
+ is shared.
202
+
203
+ ## Versioning
204
+
205
+ Pin **exact** versions in consuming apps (`"1.0.0"`, not `"^1.0.0"`). These
206
+ components render a shared contract across multiple apps; an unexpected minor
207
+ bump should be an explicit, reviewed change, not an automatic upgrade. Publishing
208
+ is automated: pushing a `v*` tag runs `.github/workflows/publish.yml`, which
209
+ `npm publish --access public`.
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@growth-operations/ui-shared",
3
+ "version": "1.0.0",
4
+ "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
+ "license": "MIT",
6
+ "private": false,
7
+ "main": "src/index.js",
8
+ "module": "src/index.js",
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/growth-operations/ui-shared.git"
18
+ },
19
+ "peerDependencies": {
20
+ "@hubspot/ui-extensions": ">=0.13.0",
21
+ "react": ">=18.0.0"
22
+ }
23
+ }
@@ -0,0 +1,246 @@
1
+ import React, { useState } from "react";
2
+ import {
3
+ hubspot,
4
+ Flex,
5
+ Heading,
6
+ Text,
7
+ Tile,
8
+ Link,
9
+ LoadingSpinner,
10
+ Alert,
11
+ } from "@hubspot/ui-extensions";
12
+ import { useStrictModeEffect } from "../lib/useStrictModeEffect";
13
+ import { fmtDate, daysUntil } from "../lib/format";
14
+ import { CreditMeter } from "../home/CreditMeter";
15
+
16
+ // The shared Billing tab for all Growth Operations apps. Branches on the
17
+ // entitlement union (CONTRACT.md):
18
+ // trial_subscription -> the toast/sparkfly-proven Stripe Customer Portal
19
+ // pattern: pre-create a portal session on mount, a
20
+ // primary "Manage billing in Stripe →" link at the top,
21
+ // and a color-coded status panel (per Andrew's feedback;
22
+ // no invoice LineChart for flat-rate plans).
23
+ // credits -> the CreditMeter + a "Pick a plan" / "Buy credits" CTA.
24
+ //
25
+ // Props:
26
+ // context — the UI-extension serverless context (context.portal.id).
27
+ // state — the /v1/home payload (entitlement union, billing_base_url, app_id).
28
+ // appKey — the Stripe/billing app_key for THIS app (e.g. "hubspot_toast").
29
+ // Always a prop — never hardcoded in the shared package.
30
+
31
+ // Color-coded account-status panel (per Andrew's billing feedback): trial =>
32
+ // yellow + days left + end date; active => green + next renewal; past_due =>
33
+ // red + warning. Replaces the low-value flat-rate invoice chart.
34
+ function StatusPanel({ entitlement }) {
35
+ if (!entitlement) return null;
36
+ const {
37
+ status,
38
+ current_period_end,
39
+ trial_ends_at,
40
+ sandbox_trial_expires_at,
41
+ cancel_at_period_end,
42
+ } = entitlement;
43
+
44
+ if (status === "trialing") {
45
+ const trialEnd = trial_ends_at || current_period_end || sandbox_trial_expires_at;
46
+ const left = daysUntil(trialEnd);
47
+ const leftLabel =
48
+ left == null
49
+ ? "Free trial active"
50
+ : left <= 0
51
+ ? "Your free trial ends today"
52
+ : `${left} day${left === 1 ? "" : "s"} left in your free trial`;
53
+ return (
54
+ <Alert title={leftLabel} variant="warning">
55
+ <Text>
56
+ Trial ends {fmtDate(trialEnd)}. Add a plan in Stripe before then to
57
+ keep syncing.
58
+ </Text>
59
+ </Alert>
60
+ );
61
+ }
62
+
63
+ if (status === "active") {
64
+ return (
65
+ <Alert title="You're all set" variant="success">
66
+ <Flex direction="column" gap="extra-small">
67
+ <Text>Your subscription is active.</Text>
68
+ <Text>Next renewal: {fmtDate(current_period_end)}</Text>
69
+ {cancel_at_period_end && (
70
+ <Text format={{ fontStyle: "italic" }}>
71
+ Cancels at the end of the current period.
72
+ </Text>
73
+ )}
74
+ </Flex>
75
+ </Alert>
76
+ );
77
+ }
78
+
79
+ if (status === "past_due") {
80
+ return (
81
+ <Alert title="Your subscription is past due" variant="error">
82
+ <Text>
83
+ We couldn't process your latest payment. Update your payment method in
84
+ Stripe to avoid losing access.
85
+ </Text>
86
+ </Alert>
87
+ );
88
+ }
89
+
90
+ if (status === "pending_purchase") {
91
+ return (
92
+ <Alert title="Your free trial has ended" variant="warning">
93
+ <Text>Choose a plan in Stripe to resume syncing your data.</Text>
94
+ </Alert>
95
+ );
96
+ }
97
+
98
+ return (
99
+ <Tile>
100
+ <Text format={{ fontWeight: "bold" }}>Status: {status ?? "Unknown"}</Text>
101
+ </Tile>
102
+ );
103
+ }
104
+
105
+ // Pre-creates a Stripe billing/checkout portal session on mount and returns
106
+ // { portalUrl, loading, error }. Shared by both arms — the credits arm reuses
107
+ // the same portal endpoint for v1 (see note in CreditsBilling below).
108
+ function usePortalSession({ context, state, appKey }) {
109
+ const [loading, setLoading] = useState(true);
110
+ const [error, setError] = useState(null);
111
+ const [portalUrl, setPortalUrl] = useState(null);
112
+
113
+ // Pre-create on mount so the link is one real click (Stripe can't be iframed;
114
+ // UI Extensions have no open-URL action — only Link, whose href must exist
115
+ // before render).
116
+ useStrictModeEffect(
117
+ async ({ mounted }) => {
118
+ try {
119
+ const base = state?.billing_base_url;
120
+ if (!base) throw new Error("Billing service not configured");
121
+ if (!appKey) throw new Error("Missing appKey for billing portal");
122
+ const portalId = context?.portal?.id;
123
+
124
+ // Return to THIS app's Billing page after Stripe (named app-page deep
125
+ // link /app/{portalId}/{appId}/{pagePath}); fall back to HubSpot home.
126
+ const returnUrl = state?.app_id
127
+ ? `https://app.hubspot.com/app/${portalId}/${state.app_id}/billing`
128
+ : "https://app.hubspot.com/";
129
+
130
+ // hubspot.fetch: body must be a plain OBJECT (HubSpot serializes it),
131
+ // NOT a JSON string, and only Authorization survives as a header —
132
+ // do not set Content-Type or any other header.
133
+ const res = await hubspot.fetch(`${base}/v1/billing/portal`, {
134
+ method: "POST",
135
+ body: {
136
+ app_key: appKey,
137
+ portal_id: String(portalId),
138
+ return_url: returnUrl,
139
+ },
140
+ });
141
+ if (!res.ok) {
142
+ const text = await res.text();
143
+ throw new Error(`Billing portal request failed (${res.status}): ${text}`);
144
+ }
145
+ const { url } = await res.json();
146
+ if (!url) throw new Error("No billing portal URL returned");
147
+ if (mounted.current) setPortalUrl(url);
148
+ } catch (perr) {
149
+ if (mounted.current) setError(String(perr));
150
+ } finally {
151
+ if (mounted.current) setLoading(false);
152
+ }
153
+ },
154
+ [context, appKey, state?.billing_base_url, state?.app_id]
155
+ );
156
+
157
+ return { portalUrl, loading, error };
158
+ }
159
+
160
+ function TrialSubscriptionBilling({ context, state, appKey }) {
161
+ const { portalUrl, loading, error } = usePortalSession({ context, state, appKey });
162
+
163
+ return (
164
+ <Flex direction="column" gap="medium">
165
+ {/* PRIMARY CTA at the very top, large and obvious (per billing feedback). */}
166
+ {portalUrl ? (
167
+ <Link href={{ url: portalUrl, external: true }} variant="primary">
168
+ <Text format={{ fontWeight: "bold", fontSize: "lg" }}>
169
+ Manage billing in Stripe →
170
+ </Text>
171
+ </Link>
172
+ ) : loading ? (
173
+ <LoadingSpinner showLabel label="Preparing billing…" />
174
+ ) : (
175
+ error && (
176
+ <Alert title="Couldn't open billing" variant="danger">
177
+ <Text>{error}</Text>
178
+ </Alert>
179
+ )
180
+ )}
181
+
182
+ <StatusPanel entitlement={state?.entitlement} />
183
+
184
+ <Text format={{ fontStyle: "italic" }}>
185
+ Billing is managed in Stripe across all Growth Operations apps. Use the
186
+ link above to update your plan, payment method, or view invoices.
187
+ </Text>
188
+ </Flex>
189
+ );
190
+ }
191
+
192
+ function CreditsBilling({ context, state, appKey }) {
193
+ // NOTE: the credit-pack / paid-allowance checkout backend is net-new and
194
+ // pending. For v1 the "Pick a plan" / "Buy credits" CTA reuses the same
195
+ // Stripe portal session pattern (routes to the portal); swap the endpoint to
196
+ // the credit checkout once that backend lands.
197
+ const { portalUrl, loading, error } = usePortalSession({ context, state, appKey });
198
+
199
+ return (
200
+ <Flex direction="column" gap="medium">
201
+ <CreditMeter
202
+ entitlement={state?.entitlement}
203
+ creditMeter={state?.credit_meter}
204
+ />
205
+
206
+ {/* PRIMARY CTA at top: pick a plan / buy credits. */}
207
+ {portalUrl ? (
208
+ <Link href={{ url: portalUrl, external: true }} variant="primary">
209
+ <Text format={{ fontWeight: "bold", fontSize: "lg" }}>
210
+ Pick a plan / Buy credits →
211
+ </Text>
212
+ </Link>
213
+ ) : loading ? (
214
+ <LoadingSpinner showLabel label="Preparing checkout…" />
215
+ ) : (
216
+ error && (
217
+ <Alert title="Couldn't open checkout" variant="danger">
218
+ <Text>{error}</Text>
219
+ </Alert>
220
+ )
221
+ )}
222
+
223
+ <Text format={{ fontStyle: "italic" }}>
224
+ Top up or move to a paid monthly allowance to keep going. Billing is
225
+ managed in Stripe across all Growth Operations apps.
226
+ </Text>
227
+ </Flex>
228
+ );
229
+ }
230
+
231
+ export function BillingTab({ context, state, appKey }) {
232
+ const mode = state?.entitlement?.mode;
233
+
234
+ return (
235
+ <Flex direction="column" gap="medium">
236
+ <Heading>Billing</Heading>
237
+ {mode === "credits" ? (
238
+ <CreditsBilling context={context} state={state} appKey={appKey} />
239
+ ) : (
240
+ <TrialSubscriptionBilling context={context} state={state} appKey={appKey} />
241
+ )}
242
+ </Flex>
243
+ );
244
+ }
245
+
246
+ export default BillingTab;
@@ -0,0 +1,115 @@
1
+ import React from "react";
2
+ import { Flex, Tile, Text, LoadingSpinner } from "@hubspot/ui-extensions";
3
+ import { InstallProgress } from "./InstallProgress";
4
+ import { HealthHero } from "./HealthHero";
5
+ import { OnboardingChecklist } from "./OnboardingChecklist";
6
+ import { SyncPulse } from "./SyncPulse";
7
+ import { CreditMeter } from "./CreditMeter";
8
+ import { FeatureNudges } from "./FeatureNudges";
9
+
10
+ // Minimal inline ATS tile (the `pipeline` block). Kept tiny here rather than a
11
+ // separate file — it's the simplest of the three archetype tiles. Renders
12
+ // nothing when there's no pipeline block.
13
+ function PipelineTile({ pipeline }) {
14
+ if (!pipeline) return null;
15
+ const byStage = pipeline.applications_by_stage ?? {};
16
+ return (
17
+ <Tile>
18
+ <Flex direction="column" gap="small">
19
+ <Text format={{ fontWeight: "bold" }}>Pipeline</Text>
20
+ <Flex direction="row" gap="large">
21
+ <Flex direction="column" gap="extra-small">
22
+ <Text format={{ fontWeight: "demibold" }}>Open jobs</Text>
23
+ <Text>{pipeline.open_jobs ?? 0}</Text>
24
+ </Flex>
25
+ <Flex direction="column" gap="extra-small">
26
+ <Text format={{ fontWeight: "demibold" }}>Outstanding scorecards</Text>
27
+ <Text>{pipeline.outstanding_scorecards ?? 0}</Text>
28
+ </Flex>
29
+ </Flex>
30
+ {Object.keys(byStage).length > 0 && (
31
+ <Flex direction="row" gap="medium">
32
+ {Object.entries(byStage).map(([stage, count]) => (
33
+ <Flex key={stage} direction="column" gap="extra-small">
34
+ <Text format={{ fontWeight: "demibold" }}>{stage}</Text>
35
+ <Text>{count}</Text>
36
+ </Flex>
37
+ ))}
38
+ </Flex>
39
+ )}
40
+ </Flex>
41
+ </Tile>
42
+ );
43
+ }
44
+
45
+ // Picks the single archetype activity tile (CONTRACT: at most ONE present).
46
+ // credit_meter / credits mode -> CreditMeter; sync_pulse -> SyncPulse;
47
+ // pipeline -> PipelineTile. Renders nothing when none apply.
48
+ function ArchetypeTile({ state }) {
49
+ if (state.credit_meter || state.entitlement?.mode === "credits") {
50
+ return (
51
+ <CreditMeter
52
+ entitlement={state.entitlement}
53
+ creditMeter={state.credit_meter}
54
+ />
55
+ );
56
+ }
57
+ if (state.sync_pulse) return <SyncPulse syncPulse={state.sync_pulse} />;
58
+ if (state.pipeline) return <PipelineTile pipeline={state.pipeline} />;
59
+ return null;
60
+ }
61
+
62
+ // The opinionated one-line Home integration. An app's Pages.jsx Home route can
63
+ // render just <AppHome state={homePayload} onNavigate={fn} appName={...} />.
64
+ //
65
+ // Lifecycle composition:
66
+ // no state -> LoadingSpinner (still fetching /v1/home)
67
+ // activation pending -> <InstallProgress> (install isn't finished yet)
68
+ // activated cockpit -> <HealthHero>
69
+ // + <OnboardingChecklist> (only if not complete)
70
+ // + the archetype activity tile (sync/credit/pipeline)
71
+ // + <FeatureNudges>
72
+ //
73
+ // Props:
74
+ // state — the /v1/home payload (null while loading).
75
+ // onNavigate — (route) => void, threaded to onboarding + nudges for in-app
76
+ // PageLink navigation (the host owns the router).
77
+ // appName — display name override; defaults to state.app.name.
78
+ // onRetry — optional, passed to InstallProgress for the failed state.
79
+ export function AppHome({ state, onNavigate, appName, onRetry }) {
80
+ if (!state) {
81
+ return <LoadingSpinner showLabel label="Loading…" />;
82
+ }
83
+
84
+ const activation = state.activation;
85
+ const activationComplete =
86
+ !activation || activation.complete === true || activation.status === "completed";
87
+
88
+ // Install isn't finished (running / failed / completed-with-warnings) — show
89
+ // the install experience instead of the cockpit.
90
+ if (!activationComplete) {
91
+ return <InstallProgress state={state} onRetry={onRetry} />;
92
+ }
93
+
94
+ const onboardingComplete = state.onboarding?.complete === true;
95
+
96
+ return (
97
+ <Flex direction="column" gap="medium">
98
+ {/* A completed-with-warnings activation still surfaces its warnings
99
+ callout above the cockpit; InstallProgress renders null when clean. */}
100
+ <InstallProgress state={state} onRetry={onRetry} />
101
+
102
+ <HealthHero appName={appName} state={state} />
103
+
104
+ {state.onboarding && !onboardingComplete && (
105
+ <OnboardingChecklist onboarding={state.onboarding} onNavigate={onNavigate} />
106
+ )}
107
+
108
+ <ArchetypeTile state={state} />
109
+
110
+ <FeatureNudges nudges={state.nudges} onNavigate={onNavigate} />
111
+ </Flex>
112
+ );
113
+ }
114
+
115
+ export default AppHome;
@@ -0,0 +1,85 @@
1
+ import React from "react";
2
+ import { Flex, Tile, Text, ProgressBar, StatusTag, Alert } from "@hubspot/ui-extensions";
3
+ import { daysUntil } from "../lib/format";
4
+
5
+ // THE credit-archetype activity tile + hero signal. For credit apps, "credits
6
+ // remaining" is the primary glanceable signal the way "trial days left" is for
7
+ // big apps. Driven by the entitlement union (mode === "credits") and/or the
8
+ // `credit_meter` block (CONTRACT.md). Renders nothing when there is no credit
9
+ // data to show.
10
+ //
11
+ // Numbers resolve from the credit_meter block first (richer: carries `recent`),
12
+ // then fall back to the entitlement arm. Thresholds (low_threshold, depleted,
13
+ // grant_expires_at) only live on the entitlement arm.
14
+ //
15
+ // Props:
16
+ // entitlement — the /v1/home entitlement union (uses the credits arm).
17
+ // creditMeter — the /v1/home credit_meter block (optional).
18
+ export function CreditMeter({ entitlement, creditMeter }) {
19
+ const ent = entitlement?.mode === "credits" ? entitlement : null;
20
+ const meter = creditMeter ?? null;
21
+ if (!ent && !meter) return null;
22
+
23
+ const granted = meter?.granted ?? ent?.granted ?? 0;
24
+ const used = meter?.used ?? ent?.used ?? 0;
25
+ const remaining = meter?.remaining ?? ent?.remaining ?? Math.max(granted - used, 0);
26
+
27
+ const lowThreshold = ent?.low_threshold ?? 0;
28
+ const depleted = ent?.depleted === true || remaining <= 0;
29
+ const low = !depleted && remaining <= lowThreshold;
30
+
31
+ // healthy green / low yellow / depleted red.
32
+ const variant = depleted ? "error" : low ? "warning" : "success";
33
+ const tagVariant = depleted ? "danger" : low ? "warning" : "success";
34
+ const tagLabel = depleted ? "Depleted" : low ? "Running low" : "Healthy";
35
+
36
+ const grantDaysLeft = ent?.grant_expires_at ? daysUntil(ent.grant_expires_at) : null;
37
+
38
+ return (
39
+ <Tile>
40
+ <Flex direction="column" gap="small">
41
+ <Flex direction="row" gap="small" align="center">
42
+ <Text format={{ fontWeight: "bold" }}>Credits</Text>
43
+ <StatusTag variant={tagVariant}>{tagLabel}</StatusTag>
44
+ </Flex>
45
+
46
+ {/* PRIMARY signal — large and obvious. */}
47
+ <Text format={{ fontWeight: "bold", fontSize: "lg" }}>
48
+ {remaining} of {granted} credits left
49
+ </Text>
50
+
51
+ <ProgressBar
52
+ title={`${used} used`}
53
+ value={used}
54
+ maxValue={granted > 0 ? granted : 1}
55
+ showPercentage={true}
56
+ />
57
+
58
+ {/* Free-grant countdown: "100 free credits — N days left". */}
59
+ {ent?.grant_expires_at && grantDaysLeft != null && (
60
+ <Text format={{ fontStyle: "italic" }}>
61
+ {granted} free credits —{" "}
62
+ {grantDaysLeft <= 0
63
+ ? "grant expired"
64
+ : `${grantDaysLeft} day${grantDaysLeft === 1 ? "" : "s"} left`}
65
+ </Text>
66
+ )}
67
+
68
+ {(depleted || low) && (
69
+ <Alert
70
+ title={depleted ? "You're out of credits" : "Running low on credits"}
71
+ variant={variant}
72
+ >
73
+ <Text>
74
+ {depleted
75
+ ? "Pick a plan or buy more credits to keep going."
76
+ : "You're getting close to your limit — pick a plan to top up."}
77
+ </Text>
78
+ </Alert>
79
+ )}
80
+ </Flex>
81
+ </Tile>
82
+ );
83
+ }
84
+
85
+ export default CreditMeter;