@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 +209 -0
- package/package.json +23 -0
- package/src/billing/BillingTab.jsx +246 -0
- package/src/home/AppHome.jsx +115 -0
- package/src/home/CreditMeter.jsx +85 -0
- package/src/home/FeatureNudges.jsx +38 -0
- package/src/home/HealthHero.jsx +95 -0
- package/src/home/InstallProgress.jsx +102 -0
- package/src/home/OnboardingChecklist.jsx +58 -0
- package/src/home/SyncPulse.jsx +45 -0
- package/src/index.js +64 -0
- package/src/lib/format.js +58 -0
- package/src/lib/useStrictModeEffect.js +84 -0
- package/src/sdk/app/alerts.js +41 -0
- package/src/sdk/app/base.js +57 -0
- package/src/sdk/app/encryption.js +98 -0
- package/src/sdk/app/settings.js +65 -0
- package/src/sdk/hubspot/base.js +67 -0
- package/src/sdk/hubspot/forms.js +12 -0
- package/src/sdk/hubspot/lists.js +31 -0
- package/src/sdk/hubspot/pipeline.js +103 -0
- package/src/sdk/hubspot/properties.js +50 -0
- package/src/sdk/index.js +33 -0
- package/src/sdk/token/TokenContext.jsx +89 -0
- package/src/sdk/token/getShortLivedToken.js +24 -0
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;
|