@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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Flex, Tile, Text, Link, Button } from "@hubspot/ui-extensions";
|
|
3
|
+
|
|
4
|
+
// Mode B: "get more from <app>" — surfaces unused/under-used capabilities the
|
|
5
|
+
// backend detected (/v1/home nudges block). Each nudge is app-specific copy
|
|
6
|
+
// computed server-side (e.g. sparkfly: "turn on bidirectional sync",
|
|
7
|
+
// "map more fields"). Renders nothing when there are no nudges.
|
|
8
|
+
//
|
|
9
|
+
// `onNavigate(route)` lets a nudge deep-link to an in-app route; cta_url is an
|
|
10
|
+
// external/settings deep link.
|
|
11
|
+
export function FeatureNudges({ nudges, onNavigate, title = "Get more from this app" }) {
|
|
12
|
+
if (!Array.isArray(nudges) || nudges.length === 0) return null;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<Tile>
|
|
16
|
+
<Flex direction="column" gap="small">
|
|
17
|
+
<Text format={{ fontWeight: "bold" }}>{title}</Text>
|
|
18
|
+
{nudges.map((n) => (
|
|
19
|
+
<Flex key={n.key} direction="column" gap="extra-small">
|
|
20
|
+
<Text format={{ fontWeight: "demibold" }}>{n.title}</Text>
|
|
21
|
+
<Text>{n.message}</Text>
|
|
22
|
+
{n.cta_url ? (
|
|
23
|
+
<Link href={{ url: n.cta_url, external: true }}>
|
|
24
|
+
{n.cta_label ?? "Set it up"} →
|
|
25
|
+
</Link>
|
|
26
|
+
) : n.cta_route && onNavigate ? (
|
|
27
|
+
<Button variant="secondary" onClick={() => onNavigate(n.cta_route)}>
|
|
28
|
+
{n.cta_label ?? "Set it up"}
|
|
29
|
+
</Button>
|
|
30
|
+
) : null}
|
|
31
|
+
</Flex>
|
|
32
|
+
))}
|
|
33
|
+
</Flex>
|
|
34
|
+
</Tile>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default FeatureNudges;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Flex, Heading, Alert, Text } from "@hubspot/ui-extensions";
|
|
3
|
+
import { humanizeStatus, statusVariant } from "../lib/format";
|
|
4
|
+
|
|
5
|
+
const HEALTH_VARIANT = { healthy: "success", attention: "warning", critical: "error" };
|
|
6
|
+
const HEALTH_TITLE = {
|
|
7
|
+
healthy: "Everything looks healthy",
|
|
8
|
+
attention: "Needs attention",
|
|
9
|
+
critical: "Action required",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// The activated-state hero at the top of Home: a glanceable, color-coded health
|
|
13
|
+
// verdict (computed server-side in /v1/home, the `health` block). The
|
|
14
|
+
// install-time activation progress is handled separately by InstallProgress, so
|
|
15
|
+
// this focuses purely on steady-state health. When no health block is present it
|
|
16
|
+
// falls back to a color-coded verdict derived from the entitlement union
|
|
17
|
+
// (CONTRACT.md): trial_subscription -> the AppInstallStatus; credits -> a
|
|
18
|
+
// remaining/depleted verdict.
|
|
19
|
+
export function HealthHero({ appName, state }) {
|
|
20
|
+
if (!state) return null;
|
|
21
|
+
|
|
22
|
+
const health = state.health;
|
|
23
|
+
const name = appName ?? state.app?.name;
|
|
24
|
+
|
|
25
|
+
if (health?.level) {
|
|
26
|
+
return (
|
|
27
|
+
<Flex direction="column" gap="small">
|
|
28
|
+
{name && <Heading>{name}</Heading>}
|
|
29
|
+
<Alert
|
|
30
|
+
title={HEALTH_TITLE[health.level] ?? "Status"}
|
|
31
|
+
variant={HEALTH_VARIANT[health.level] ?? "info"}
|
|
32
|
+
>
|
|
33
|
+
<Flex direction="column" gap="extra-small">
|
|
34
|
+
{(health.reasons ?? []).map((r, i) => (
|
|
35
|
+
<Text key={i}>{r}</Text>
|
|
36
|
+
))}
|
|
37
|
+
</Flex>
|
|
38
|
+
</Alert>
|
|
39
|
+
</Flex>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// No health block — fall back to an entitlement-derived verdict.
|
|
44
|
+
const ent = state.entitlement;
|
|
45
|
+
if (!ent) {
|
|
46
|
+
return name ? (
|
|
47
|
+
<Flex direction="column" gap="small">
|
|
48
|
+
<Heading>{name}</Heading>
|
|
49
|
+
</Flex>
|
|
50
|
+
) : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (ent.mode === "credits") {
|
|
54
|
+
const remaining = ent.remaining ?? 0;
|
|
55
|
+
const depleted = ent.depleted === true || remaining <= 0;
|
|
56
|
+
const low = !depleted && remaining <= (ent.low_threshold ?? 0);
|
|
57
|
+
const variant = depleted ? "error" : low ? "warning" : "success";
|
|
58
|
+
const title = depleted
|
|
59
|
+
? "You're out of credits"
|
|
60
|
+
: low
|
|
61
|
+
? "Running low on credits"
|
|
62
|
+
: `${remaining} credits remaining`;
|
|
63
|
+
return (
|
|
64
|
+
<Flex direction="column" gap="small">
|
|
65
|
+
{name && <Heading>{name}</Heading>}
|
|
66
|
+
<Alert title={title} variant={variant}>
|
|
67
|
+
{(depleted || low) && (
|
|
68
|
+
<Text>
|
|
69
|
+
{depleted
|
|
70
|
+
? "Pick a plan in Billing to keep going."
|
|
71
|
+
: "You're getting close to your limit — consider a plan in Billing."}
|
|
72
|
+
</Text>
|
|
73
|
+
)}
|
|
74
|
+
</Alert>
|
|
75
|
+
</Flex>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// trial_subscription (and any other status-bearing arm).
|
|
80
|
+
return (
|
|
81
|
+
<Flex direction="column" gap="small">
|
|
82
|
+
{name && <Heading>{name}</Heading>}
|
|
83
|
+
<Alert title={humanizeStatus(ent.status)} variant={statusVariant(ent.status)}>
|
|
84
|
+
{ent.entitled === false && (
|
|
85
|
+
<Text>
|
|
86
|
+
This install isn't on an active plan. Open Billing to choose a plan
|
|
87
|
+
and resume syncing.
|
|
88
|
+
</Text>
|
|
89
|
+
)}
|
|
90
|
+
</Alert>
|
|
91
|
+
</Flex>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default HealthHero;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
Flex,
|
|
4
|
+
Tile,
|
|
5
|
+
Text,
|
|
6
|
+
ProgressBar,
|
|
7
|
+
Alert,
|
|
8
|
+
Button,
|
|
9
|
+
LoadingSpinner,
|
|
10
|
+
} from "@hubspot/ui-extensions";
|
|
11
|
+
|
|
12
|
+
// THE standard install-status module — every Growth Operations app uses this
|
|
13
|
+
// for the post-install activation experience. Baselined on the Anvil
|
|
14
|
+
// Installation page (client_code/Pages/Installation) which handled: a step
|
|
15
|
+
// progress bar, a success/final-output state, a non-fatal WARNINGS panel, and a
|
|
16
|
+
// FAILED + retry panel. We carry all of those into the new (Stripe/Firestore)
|
|
17
|
+
// world, driven by the /v1/home `activation` block (CONTRACT.md).
|
|
18
|
+
//
|
|
19
|
+
// State machine (from activation.status + activation.step):
|
|
20
|
+
// RUNNING / PENDING -> determinate progress bar (step / total) + label
|
|
21
|
+
// FAILED -> error alert + Retry button (onRetry)
|
|
22
|
+
// COMPLETED w/ warnings -> success + a warnings callout
|
|
23
|
+
// COMPLETED clean -> render nothing (install is done; Home shows health)
|
|
24
|
+
//
|
|
25
|
+
// Props:
|
|
26
|
+
// state — the /v1/home payload. Reads the nested `activation` block:
|
|
27
|
+
// activation.status, activation.step, activation.total,
|
|
28
|
+
// activation.label, activation.complete, activation.warnings.
|
|
29
|
+
// onRetry — optional () => void to re-trigger activation (re-publishes the
|
|
30
|
+
// activation Pub/Sub message via an app endpoint). If omitted, the
|
|
31
|
+
// FAILED state still renders guidance, just no button.
|
|
32
|
+
export function InstallProgress({ state, onRetry }) {
|
|
33
|
+
const activation = state?.activation;
|
|
34
|
+
// No activation block => nothing to show (Home's health hero takes over).
|
|
35
|
+
if (!activation) return null;
|
|
36
|
+
|
|
37
|
+
const status = activation.status;
|
|
38
|
+
const step = activation.step ?? 0;
|
|
39
|
+
const total = activation.total ?? 5;
|
|
40
|
+
const warnings = activation.warnings ?? [];
|
|
41
|
+
const complete = activation.complete === true || status === "completed";
|
|
42
|
+
|
|
43
|
+
// FAILED — surface the error and let the user retry (Pub/Sub also retries
|
|
44
|
+
// server-side, but an explicit retry beats a silent stall).
|
|
45
|
+
if (status === "failed") {
|
|
46
|
+
return (
|
|
47
|
+
<Alert title="Setup didn't finish" variant="error">
|
|
48
|
+
<Flex direction="column" gap="small">
|
|
49
|
+
<Text>
|
|
50
|
+
We hit a problem finishing your install. This often clears itself on
|
|
51
|
+
the next automatic retry; if it persists, retry now or contact
|
|
52
|
+
support.
|
|
53
|
+
</Text>
|
|
54
|
+
{warnings.length > 0 &&
|
|
55
|
+
warnings.map((w, i) => <Text key={i}>• {w}</Text>)}
|
|
56
|
+
{onRetry && (
|
|
57
|
+
<Button variant="primary" onClick={onRetry}>
|
|
58
|
+
Retry setup
|
|
59
|
+
</Button>
|
|
60
|
+
)}
|
|
61
|
+
</Flex>
|
|
62
|
+
</Alert>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// RUNNING / PENDING — determinate progress while activation provisions assets.
|
|
67
|
+
if (!complete) {
|
|
68
|
+
return (
|
|
69
|
+
<Tile>
|
|
70
|
+
<Flex direction="column" gap="small">
|
|
71
|
+
<Text format={{ fontWeight: "bold" }}>Setting up your install…</Text>
|
|
72
|
+
<ProgressBar
|
|
73
|
+
title={activation.label ?? "Finishing up…"}
|
|
74
|
+
value={step}
|
|
75
|
+
maxValue={total}
|
|
76
|
+
showPercentage={true}
|
|
77
|
+
/>
|
|
78
|
+
<LoadingSpinner label="This usually takes under a minute." showLabel />
|
|
79
|
+
</Flex>
|
|
80
|
+
</Tile>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// COMPLETED with non-fatal warnings — let the user know setup finished but
|
|
85
|
+
// something needs a look (mirrors the Anvil warnings panel).
|
|
86
|
+
if (complete && warnings.length > 0) {
|
|
87
|
+
return (
|
|
88
|
+
<Alert title="Setup finished with warnings" variant="warning">
|
|
89
|
+
<Flex direction="column" gap="extra-small">
|
|
90
|
+
{warnings.map((w, i) => (
|
|
91
|
+
<Text key={i}>• {w}</Text>
|
|
92
|
+
))}
|
|
93
|
+
</Flex>
|
|
94
|
+
</Alert>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// COMPLETED clean — nothing to show; Home's health hero takes over.
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default InstallProgress;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Flex, Tile, Text, ProgressBar, Link, Button } from "@hubspot/ui-extensions";
|
|
3
|
+
|
|
4
|
+
// Lifecycle Mode A: the setup checklist for new/not-yet-activated installs.
|
|
5
|
+
// Each step's `done` is computed server-side (/v1/home onboarding block) from
|
|
6
|
+
// the account doc, so the list self-checks. A single "next best action" button
|
|
7
|
+
// points at the first incomplete step. Steps carry either a cta_route (an
|
|
8
|
+
// in-app PageLink target) or a cta_url (external/deep link).
|
|
9
|
+
//
|
|
10
|
+
// `onNavigate(route)` is supplied by the host app (PageLink can't be created
|
|
11
|
+
// here without the router); cta_url renders as an external Link.
|
|
12
|
+
export function OnboardingChecklist({ onboarding, onNavigate }) {
|
|
13
|
+
if (!onboarding || !Array.isArray(onboarding.steps) || onboarding.steps.length === 0) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const steps = onboarding.steps;
|
|
18
|
+
const doneCount = steps.filter((s) => s.done).length;
|
|
19
|
+
const total = steps.length;
|
|
20
|
+
const next = steps.find((s) => !s.done);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Tile>
|
|
24
|
+
<Flex direction="column" gap="small">
|
|
25
|
+
<Text format={{ fontWeight: "bold", fontSize: "lg" }}>
|
|
26
|
+
Get started ({doneCount} of {total})
|
|
27
|
+
</Text>
|
|
28
|
+
<ProgressBar value={doneCount} maxValue={total} showPercentage={true} />
|
|
29
|
+
|
|
30
|
+
<Flex direction="column" gap="extra-small">
|
|
31
|
+
{steps.map((s) => (
|
|
32
|
+
<Text key={s.key} format={{ fontWeight: s.done ? "regular" : "demibold" }}>
|
|
33
|
+
{s.done ? "✓" : "○"} {s.label}
|
|
34
|
+
</Text>
|
|
35
|
+
))}
|
|
36
|
+
</Flex>
|
|
37
|
+
|
|
38
|
+
{next && (
|
|
39
|
+
<Flex direction="row" gap="small" align="center">
|
|
40
|
+
{next.cta_url ? (
|
|
41
|
+
<Link href={{ url: next.cta_url, external: true }} variant="primary">
|
|
42
|
+
{next.cta_label ?? `Next: ${next.label}`} →
|
|
43
|
+
</Link>
|
|
44
|
+
) : next.cta_route && onNavigate ? (
|
|
45
|
+
<Button variant="primary" onClick={() => onNavigate(next.cta_route)}>
|
|
46
|
+
{next.cta_label ?? `Next: ${next.label}`}
|
|
47
|
+
</Button>
|
|
48
|
+
) : (
|
|
49
|
+
<Text format={{ fontWeight: "demibold" }}>Next: {next.label}</Text>
|
|
50
|
+
)}
|
|
51
|
+
</Flex>
|
|
52
|
+
)}
|
|
53
|
+
</Flex>
|
|
54
|
+
</Tile>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default OnboardingChecklist;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Flex, Tile, Text, StatusTag } from "@hubspot/ui-extensions";
|
|
3
|
+
import { fmtDate } from "../lib/format";
|
|
4
|
+
|
|
5
|
+
// Mode B health tile: the most recent sync at a glance. Driven by the
|
|
6
|
+
// /v1/home sync_pulse block (app-specific: sparkfly = contact-sync/member
|
|
7
|
+
// import counters; toast = order/menu sync). Renders nothing when the app
|
|
8
|
+
// has no sync pulse to report.
|
|
9
|
+
export function SyncPulse({ syncPulse }) {
|
|
10
|
+
if (!syncPulse) return null;
|
|
11
|
+
|
|
12
|
+
const { last_sync_at, records_synced, records_failed, label } = syncPulse;
|
|
13
|
+
const failed = records_failed ?? 0;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Tile>
|
|
17
|
+
<Flex direction="column" gap="small">
|
|
18
|
+
<Flex direction="row" gap="small" align="center">
|
|
19
|
+
<Text format={{ fontWeight: "bold" }}>{label ?? "Sync activity"}</Text>
|
|
20
|
+
{failed > 0 ? (
|
|
21
|
+
<StatusTag variant="warning">{failed} failed</StatusTag>
|
|
22
|
+
) : (
|
|
23
|
+
<StatusTag variant="success">Healthy</StatusTag>
|
|
24
|
+
)}
|
|
25
|
+
</Flex>
|
|
26
|
+
<Flex direction="row" gap="large">
|
|
27
|
+
<Flex direction="column" gap="extra-small">
|
|
28
|
+
<Text format={{ fontWeight: "demibold" }}>Records synced</Text>
|
|
29
|
+
<Text>{records_synced ?? 0}</Text>
|
|
30
|
+
</Flex>
|
|
31
|
+
<Flex direction="column" gap="extra-small">
|
|
32
|
+
<Text format={{ fontWeight: "demibold" }}>Failed</Text>
|
|
33
|
+
<Text>{failed}</Text>
|
|
34
|
+
</Flex>
|
|
35
|
+
<Flex direction="column" gap="extra-small">
|
|
36
|
+
<Text format={{ fontWeight: "demibold" }}>Last sync</Text>
|
|
37
|
+
<Text>{last_sync_at ? fmtDate(last_sync_at) : "Never"}</Text>
|
|
38
|
+
</Flex>
|
|
39
|
+
</Flex>
|
|
40
|
+
</Flex>
|
|
41
|
+
</Tile>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default SyncPulse;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @growth-operations/ui-shared — shared HubSpot UI-extension modules.
|
|
2
|
+
// Presentational components that render the /v1/home contract (see CONTRACT.md),
|
|
3
|
+
// branching on entitlement.mode ("trial_subscription" | "credits").
|
|
4
|
+
|
|
5
|
+
// The opinionated one-line Home integration.
|
|
6
|
+
export { AppHome, default as AppHomeDefault } from "./home/AppHome";
|
|
7
|
+
|
|
8
|
+
// Home building blocks.
|
|
9
|
+
export { InstallProgress } from "./home/InstallProgress";
|
|
10
|
+
export { HealthHero } from "./home/HealthHero";
|
|
11
|
+
export { OnboardingChecklist } from "./home/OnboardingChecklist";
|
|
12
|
+
export { SyncPulse } from "./home/SyncPulse";
|
|
13
|
+
export { FeatureNudges } from "./home/FeatureNudges";
|
|
14
|
+
export { CreditMeter } from "./home/CreditMeter";
|
|
15
|
+
|
|
16
|
+
// Billing.
|
|
17
|
+
export { BillingTab } from "./billing/BillingTab";
|
|
18
|
+
|
|
19
|
+
// Lib helpers.
|
|
20
|
+
export {
|
|
21
|
+
fmtDate,
|
|
22
|
+
daysUntil,
|
|
23
|
+
humanizeStatus,
|
|
24
|
+
statusVariant,
|
|
25
|
+
STATUS_LABELS,
|
|
26
|
+
STATUS_VARIANT,
|
|
27
|
+
} from "./lib/format";
|
|
28
|
+
export { useStrictModeEffect } from "./lib/useStrictModeEffect";
|
|
29
|
+
|
|
30
|
+
// SDK — the token + API utility layer (see README "SDK" section and src/sdk/).
|
|
31
|
+
export {
|
|
32
|
+
// App backend
|
|
33
|
+
callAppApi,
|
|
34
|
+
AppApiError,
|
|
35
|
+
getAlerts,
|
|
36
|
+
dismissAlert,
|
|
37
|
+
getSettings,
|
|
38
|
+
updateSettings,
|
|
39
|
+
makeSettingsApi,
|
|
40
|
+
// Encryption
|
|
41
|
+
strToBase64,
|
|
42
|
+
base64ToStr,
|
|
43
|
+
getKeyFromContext,
|
|
44
|
+
encrypt,
|
|
45
|
+
decrypt,
|
|
46
|
+
// HubSpot public API
|
|
47
|
+
callHubSpotApi,
|
|
48
|
+
buildHubSpotUrl,
|
|
49
|
+
HubSpotApiError,
|
|
50
|
+
getObjectProperties,
|
|
51
|
+
getProperties,
|
|
52
|
+
updateProperties,
|
|
53
|
+
getForms,
|
|
54
|
+
getLists,
|
|
55
|
+
getPipelines,
|
|
56
|
+
getPipelineStages,
|
|
57
|
+
computeStageChanges,
|
|
58
|
+
// Token lifecycle
|
|
59
|
+
getShortLivedToken,
|
|
60
|
+
isTokenExpired,
|
|
61
|
+
TokenProvider,
|
|
62
|
+
useToken,
|
|
63
|
+
TokenContext,
|
|
64
|
+
} from "./sdk/index";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Shared formatting + status helpers for Growth Operations app UIs.
|
|
2
|
+
// Framework-agnostic (no React) so they can be unit-reasoned and reused.
|
|
3
|
+
|
|
4
|
+
// ISO datetime string (or ms epoch) -> "Mon DD, YYYY", null-safe.
|
|
5
|
+
// new Date(NaN).toLocaleDateString() would throw, so guard it.
|
|
6
|
+
export function fmtDate(value) {
|
|
7
|
+
if (value == null) return "—";
|
|
8
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
9
|
+
if (Number.isNaN(d.getTime())) return "—";
|
|
10
|
+
return d.toLocaleDateString(undefined, {
|
|
11
|
+
year: "numeric",
|
|
12
|
+
month: "short",
|
|
13
|
+
day: "numeric",
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Whole days from now until `value` (negative = past). null if unparseable.
|
|
18
|
+
export function daysUntil(value) {
|
|
19
|
+
if (value == null) return null;
|
|
20
|
+
const ms = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
21
|
+
if (Number.isNaN(ms)) return null;
|
|
22
|
+
return Math.ceil((ms - Date.now()) / (1000 * 60 * 60 * 24));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Friendly labels for the AppInstallStatus enum (shared across apps).
|
|
26
|
+
export const STATUS_LABELS = {
|
|
27
|
+
trialing: "Free trial",
|
|
28
|
+
active: "Active",
|
|
29
|
+
pending_purchase: "Trial ended — choose a plan",
|
|
30
|
+
past_due: "Payment past due",
|
|
31
|
+
paused: "Paused",
|
|
32
|
+
canceled: "Canceled",
|
|
33
|
+
uninstalled: "Uninstalled",
|
|
34
|
+
sandbox_trial: "Sandbox trial",
|
|
35
|
+
sandbox_linked: "Sandbox",
|
|
36
|
+
not_installed: "Not installed",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function humanizeStatus(status) {
|
|
40
|
+
return STATUS_LABELS[status] ?? (status ? String(status) : "Unknown");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Color-code status so it reads at a glance: green = good standing,
|
|
44
|
+
// yellow = needs attention soon, red = action required.
|
|
45
|
+
export const STATUS_VARIANT = {
|
|
46
|
+
active: "success",
|
|
47
|
+
trialing: "warning",
|
|
48
|
+
sandbox_trial: "warning",
|
|
49
|
+
sandbox_linked: "success",
|
|
50
|
+
pending_purchase: "warning",
|
|
51
|
+
past_due: "error",
|
|
52
|
+
paused: "warning",
|
|
53
|
+
canceled: "error",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function statusVariant(status) {
|
|
57
|
+
return STATUS_VARIANT[status] ?? "info";
|
|
58
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A custom hook that handles React Strict Mode's double-mount pattern for async effects.
|
|
5
|
+
*
|
|
6
|
+
* This hook prevents duplicate API calls in development by:
|
|
7
|
+
* 1. Using a ref to track if the effect has already run
|
|
8
|
+
* 2. Properly resetting state when the component unmounts during async operations
|
|
9
|
+
* 3. Allowing the effect to run again when the component remounts
|
|
10
|
+
*
|
|
11
|
+
* @param {Function} effect - Async function to run. Will receive a cleanup object with:
|
|
12
|
+
* - mounted: ref to check if component is still mounted
|
|
13
|
+
* - resetOnUnmount: function to reset loading state and ref when unmounted
|
|
14
|
+
* @param {Array} dependencies - Dependency array for the effect
|
|
15
|
+
* @param {Object} options - Optional configuration
|
|
16
|
+
* @param {Function} options.setLoading - Function to set loading state (if applicable)
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* useStrictModeEffect(
|
|
20
|
+
* async ({ mounted, resetOnUnmount }) => {
|
|
21
|
+
* const token = await getToken();
|
|
22
|
+
* if (!mounted.current) {
|
|
23
|
+
* resetOnUnmount();
|
|
24
|
+
* return;
|
|
25
|
+
* }
|
|
26
|
+
* const data = await fetchData(token);
|
|
27
|
+
* if (!mounted.current) {
|
|
28
|
+
* resetOnUnmount();
|
|
29
|
+
* return;
|
|
30
|
+
* }
|
|
31
|
+
* setData(data);
|
|
32
|
+
* },
|
|
33
|
+
* [dependency1, dependency2],
|
|
34
|
+
* { setLoading }
|
|
35
|
+
* );
|
|
36
|
+
*/
|
|
37
|
+
export function useStrictModeEffect(effect, dependencies = [], options = {}) {
|
|
38
|
+
const fetchedRef = useRef(false);
|
|
39
|
+
const { setLoading } = options;
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
// Prevent duplicate fetches in React Strict Mode
|
|
43
|
+
if (fetchedRef.current) return;
|
|
44
|
+
|
|
45
|
+
const mounted = { current: true };
|
|
46
|
+
|
|
47
|
+
const resetOnUnmount = () => {
|
|
48
|
+
fetchedRef.current = false;
|
|
49
|
+
if (setLoading) {
|
|
50
|
+
setLoading(false);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const runEffect = async () => {
|
|
55
|
+
// Mark as fetched BEFORE starting work to prevent race conditions
|
|
56
|
+
fetchedRef.current = true;
|
|
57
|
+
|
|
58
|
+
if (setLoading) {
|
|
59
|
+
setLoading(true);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await effect({ mounted, resetOnUnmount });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
// If there's an error, reset the ref so it can be retried
|
|
66
|
+
if (mounted.current) {
|
|
67
|
+
fetchedRef.current = false;
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
} finally {
|
|
71
|
+
if (mounted.current && setLoading) {
|
|
72
|
+
setLoading(false);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
runEffect();
|
|
78
|
+
|
|
79
|
+
return () => {
|
|
80
|
+
mounted.current = false;
|
|
81
|
+
};
|
|
82
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
83
|
+
}, dependencies);
|
|
84
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// getAlerts / dismissAlert — generic alerts API client.
|
|
2
|
+
//
|
|
3
|
+
// Every Growth Ops app exposes the same /api/v1/alerts surface (paginated,
|
|
4
|
+
// orderable, filterable). This is already app-agnostic, so it's ported verbatim.
|
|
5
|
+
// Goes through callAppApi, so it inherits the hubspot.fetch rules (object body,
|
|
6
|
+
// no custom headers).
|
|
7
|
+
import { callAppApi } from "./base";
|
|
8
|
+
|
|
9
|
+
// context: extension context. params: { offset, limit, orderBy, orderDirection,
|
|
10
|
+
// filterField, filterValue }. Returns the backend's alerts response.
|
|
11
|
+
export async function getAlerts(context, params = {}) {
|
|
12
|
+
const {
|
|
13
|
+
offset = 0,
|
|
14
|
+
limit = 10,
|
|
15
|
+
orderBy,
|
|
16
|
+
orderDirection,
|
|
17
|
+
filterField,
|
|
18
|
+
filterValue,
|
|
19
|
+
} = params;
|
|
20
|
+
const queryParams = new URLSearchParams();
|
|
21
|
+
queryParams.append("offset", offset);
|
|
22
|
+
queryParams.append("limit", limit);
|
|
23
|
+
|
|
24
|
+
if (orderBy) {
|
|
25
|
+
queryParams.append("order_by_field", orderBy);
|
|
26
|
+
}
|
|
27
|
+
if (orderDirection) {
|
|
28
|
+
queryParams.append("order_by_direction", orderDirection);
|
|
29
|
+
}
|
|
30
|
+
if (filterField && filterValue) {
|
|
31
|
+
queryParams.append("filter_field", filterField);
|
|
32
|
+
queryParams.append("filter_value", filterValue);
|
|
33
|
+
}
|
|
34
|
+
const path = `/api/v1/alerts?${queryParams.toString()}`;
|
|
35
|
+
return callAppApi(context, path, "GET");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function dismissAlert(context, alertId) {
|
|
39
|
+
const path = `/api/v1/alerts/${alertId}/dismiss`;
|
|
40
|
+
return callAppApi(context, path, "POST");
|
|
41
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// callAppApi — the single most-reused SDK helper.
|
|
2
|
+
//
|
|
3
|
+
// Wraps hubspot.fetch for calls to the APP's OWN backend (the Cloud Run service
|
|
4
|
+
// behind context.variables.BASE_URL). Use this instead of reimplementing the
|
|
5
|
+
// fetch dance in every app.
|
|
6
|
+
//
|
|
7
|
+
// hubspot.fetch rules enforced here (these bugs have cost real debugging time):
|
|
8
|
+
// - `body` is passed as a plain OBJECT. NEVER JSON.stringify it — hubspot.fetch
|
|
9
|
+
// serializes it for you, and a pre-stringified body is rejected/mangled.
|
|
10
|
+
// - NO custom headers. The platform injects Authorization automatically; adding
|
|
11
|
+
// any other header (Content-Type, etc.) causes hubspot.fetch to reject the
|
|
12
|
+
// request. So this helper sends none.
|
|
13
|
+
import { hubspot } from "@hubspot/ui-extensions";
|
|
14
|
+
|
|
15
|
+
export class AppApiError extends Error {
|
|
16
|
+
constructor(message, statusCode, responseText) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.statusCode = statusCode;
|
|
19
|
+
this.responseText = responseText;
|
|
20
|
+
this.name = "AppApiError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// context: the HubSpot extension context (must expose context.variables.BASE_URL)
|
|
25
|
+
// path: request path appended to BASE_URL, e.g. "/api/v1/settings"
|
|
26
|
+
// method: HTTP method (default "GET")
|
|
27
|
+
// body: plain object — NOT a JSON string (see header)
|
|
28
|
+
export async function callAppApi(context, path, method = "GET", body = null) {
|
|
29
|
+
const url = `${context.variables.BASE_URL}${path}`;
|
|
30
|
+
|
|
31
|
+
const response = await hubspot.fetch(url, {
|
|
32
|
+
timeout: 30000,
|
|
33
|
+
method,
|
|
34
|
+
// body is a plain object; hubspot.fetch serializes it. No custom headers —
|
|
35
|
+
// Authorization is injected by the platform.
|
|
36
|
+
...(body && { body }),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
const errorText = await response.text();
|
|
41
|
+
// FastAPI errors come back as {"detail": "..."} — surface just the detail
|
|
42
|
+
// string so callers can show it directly in the UI without dragging the
|
|
43
|
+
// JSON wrapper into the message.
|
|
44
|
+
let message = errorText;
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(errorText);
|
|
47
|
+
if (parsed && typeof parsed.detail === "string") {
|
|
48
|
+
message = parsed.detail;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// body wasn't JSON; keep the raw text
|
|
52
|
+
}
|
|
53
|
+
throw new AppApiError(message, response.status, errorText);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return response.json();
|
|
57
|
+
}
|