@growth-operations/ui-shared 1.32.0 → 1.34.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-operations/ui-shared",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -103,7 +103,7 @@ export function AutoProvisionToggle({
103
103
  <Alert variant="warning" title="Disable automatic credit provisioning?">
104
104
  <Box flex="auto">
105
105
  <Flex direction="column" gap="small">
106
- <Text>{autoProvisionConfirmBody(appName)}</Text>
106
+ <Text>{autoProvisionConfirmBody(appName, appKey)}</Text>
107
107
  <Flex direction="row" gap="small">
108
108
  <Button
109
109
  variant="destructive"
@@ -22,7 +22,7 @@ import { useStrictModeEffect } from "../lib/useStrictModeEffect";
22
22
  import { CreditMeter } from "../home/CreditMeter";
23
23
  import { PlanGrid } from "./PlanGrid";
24
24
  import { AutoProvisionToggle } from "./AutoProvisionToggle";
25
- import { resolveAutoProvision } from "../lib/autoProvision";
25
+ import { autoProvisionBlockOf, resolveAutoProvision } from "../lib/autoProvision";
26
26
  import { refreshBillingActionTokens } from "../sdk/billing";
27
27
  import { getInvoices } from "../sdk/app/topUps";
28
28
 
@@ -691,7 +691,12 @@ export function CreditsBilling({ context, state, appKey, openIframe = null }) {
691
691
  <AutoProvisionToggle
692
692
  context={context}
693
693
  appKey={appKey}
694
- enabled={resolveAutoProvision(state?.auto_provision).enabled}
694
+ // Dual-read: the backends nest auto_provision under
695
+ // payload.state — a top-level-only read would snap a disabled
696
+ // toggle back to ON on every refetch.
697
+ enabled={
698
+ resolveAutoProvision(autoProvisionBlockOf(state)).enabled
699
+ }
695
700
  appName={state?.app?.name ?? "this app"}
696
701
  // The POST's second auth dep (see setAutoProvision). `state`
697
702
  // here is BillingTab's interval-refreshed copy, so this token is
package/src/index.js CHANGED
@@ -43,6 +43,10 @@ export {
43
43
  // Sandbox — the shared "Install on a sandbox" action (mints a signed link).
44
44
  export { SandboxInstall } from "./sandbox/SandboxInstall";
45
45
 
46
+ // Support — the shared in-app support request form (posts to the app's
47
+ // support route; see common.support).
48
+ export { SupportTab } from "./support/SupportTab";
49
+
46
50
  // Forms — shared HubSpot-options dropdown (forms/lists/pipeline-stages picker).
47
51
  export { OptionSelect } from "./forms/OptionSelect";
48
52
 
@@ -14,8 +14,19 @@ export function autoProvisionSubtext(appName) {
14
14
  return `If monthly credits are used, this will automatically purchase another pack of credits so use of ${appName} is not impacted.`;
15
15
  }
16
16
 
17
- export function autoProvisionConfirmBody(appName) {
18
- return `Are you sure you want to disable automatic credit provisioning? If monthly credits are used, ${appName} will no longer function and workflow actions will fail.`;
17
+ // The ON→OFF confirmation's ending, per app. The default names the generic
18
+ // consequence (workflow actions fail); ATS has no workflow actions — its
19
+ // credit-gated features just stop working — so Andrew's 2026-10-19 copy names
20
+ // the user-visible consequence instead.
21
+ const CONFIRM_BODY_ENDINGS = {
22
+ applicant_tracking_system:
23
+ "and features like resume parsing will stop functioning until credit renew at the start of the next bill period.",
24
+ };
25
+ const DEFAULT_CONFIRM_BODY_ENDING = "and workflow actions will fail.";
26
+
27
+ export function autoProvisionConfirmBody(appName, appKey) {
28
+ const ending = CONFIRM_BODY_ENDINGS[appKey] ?? DEFAULT_CONFIRM_BODY_ENDING;
29
+ return `Are you sure you want to disable automatic credit provisioning? If monthly credits are used, ${appName} will no longer function ${ending}`;
19
30
  }
20
31
 
21
32
  export const AUTO_PROVISION_CONFIRM_YES = "Yes, disable this feature.";
@@ -28,6 +39,15 @@ export function resolveAutoProvision(autoProvision) {
28
39
  return { enabled: autoProvision?.enabled !== false };
29
40
  }
30
41
 
42
+ // The auto_provision block off a /v1/home PAYLOAD, dual-read like
43
+ // planFitOf/periodComparisonOf (lib/homeBlocks): base-hosted and ATS backends
44
+ // nest it under payload.state; a top-level read alone silently resolves to
45
+ // the default ON for a disabled account after every refetch/remount.
46
+ export function autoProvisionBlockOf(state) {
47
+ if (!state || typeof state !== "object") return undefined;
48
+ return state.state?.auto_provision ?? state.auto_provision;
49
+ }
50
+
31
51
  // The toggle's interaction state. `enabled` itself lives in the component
32
52
  // (seeded from the payload, updated on save); this machine owns everything
33
53
  // around it: the ON→OFF confirmation and the in-flight POST.
@@ -0,0 +1,198 @@
1
+ // SupportTab — the shared in-app support request form.
2
+ //
3
+ // Renders a native Form mirroring the "Growth Ops Apps - Support Form" on the
4
+ // website (First name*, Last name*, Email*, "What can we help you with
5
+ // today?"*, optional screenshot) and POSTs it to the app's own support route
6
+ // (common.support), which forwards it into the real form via the Forms API.
7
+ // Name/email prefill from the signed-in HubSpot user and stay editable.
8
+ //
9
+ // Props:
10
+ // context — the HubSpot extension context (user prefill, portal.id,
11
+ // variables.BASE_URL).
12
+ // supportPath — the app's support route path, e.g.
13
+ // "/v1/hubspot/app_pages/<app_key>/support" (base-hosted) or
14
+ // "/api/v1/support" (self-hosted).
15
+ // appName — display name, used in headings/success copy.
16
+ // supportUrl — external support link shown as a fallback on error.
17
+ import React, { useState } from "react";
18
+ import {
19
+ Alert,
20
+ Button,
21
+ Divider,
22
+ Flex,
23
+ Form,
24
+ Heading,
25
+ Input,
26
+ Link,
27
+ Text,
28
+ TextArea,
29
+ } from "@hubspot/ui-extensions";
30
+ import { FileInput } from "@hubspot/ui-extensions/experimental";
31
+
32
+ import { callAppApi } from "../sdk/app/base";
33
+ import {
34
+ buildSupportBody,
35
+ readFileAsBase64,
36
+ unwrapFileEvent,
37
+ validateSupportFields,
38
+ } from "./supportLogic";
39
+
40
+ export function SupportTab({ context, supportPath, appName = "the app", supportUrl }) {
41
+ const [fields, setFields] = useState({
42
+ firstname: context.user?.firstName ?? "",
43
+ lastname: context.user?.lastName ?? "",
44
+ email: context.user?.email ?? "",
45
+ content: "",
46
+ });
47
+ const [file, setFile] = useState(null); // { name, contentBase64 }
48
+ const [submitting, setSubmitting] = useState(false);
49
+ const [submitted, setSubmitted] = useState(false);
50
+ const [error, setError] = useState(null);
51
+
52
+ const set = (key) => (value) =>
53
+ setFields((prev) => ({ ...prev, [key]: value }));
54
+
55
+ const handleFileChange = async (event) => {
56
+ const picked = unwrapFileEvent(event);
57
+ if (!picked) return;
58
+ try {
59
+ const contentBase64 = await readFileAsBase64(picked);
60
+ setFile({ name: picked.name, contentBase64 });
61
+ } catch {
62
+ setError("Couldn't read that file — try a different one.");
63
+ }
64
+ };
65
+
66
+ const handleSubmit = async () => {
67
+ const validationError = validateSupportFields(fields);
68
+ if (validationError) {
69
+ setError(validationError);
70
+ return;
71
+ }
72
+ setSubmitting(true);
73
+ setError(null);
74
+ try {
75
+ await callAppApi(
76
+ context,
77
+ `${supportPath}?portalId=${context.portal.id}`,
78
+ "POST",
79
+ buildSupportBody(fields, file)
80
+ );
81
+ setSubmitted(true);
82
+ } catch (err) {
83
+ setError(
84
+ err?.message ||
85
+ "Something went wrong sending your request. Please try again."
86
+ );
87
+ } finally {
88
+ setSubmitting(false);
89
+ }
90
+ };
91
+
92
+ if (submitted) {
93
+ return (
94
+ <Flex direction="column" gap="medium">
95
+ <Alert title="Request received" variant="success">
96
+ <Text>
97
+ Thanks — we've received your request and will be in touch soon
98
+ (typical response time is under 1–2 hours). You can reply to the
99
+ confirmation email with anything else.
100
+ </Text>
101
+ </Alert>
102
+ <Button
103
+ onClick={() => {
104
+ setSubmitted(false);
105
+ setFields((prev) => ({ ...prev, content: "" }));
106
+ setFile(null);
107
+ }}
108
+ >
109
+ Submit another request
110
+ </Button>
111
+ </Flex>
112
+ );
113
+ }
114
+
115
+ return (
116
+ <Flex direction="column" gap="medium">
117
+ <Flex direction="column" gap="small">
118
+ <Heading>Contact {appName} support</Heading>
119
+ <Text>
120
+ Send us a message and we'll get back to you. For urgent issues you
121
+ can also{" "}
122
+ {supportUrl ? (
123
+ <Link href={{ url: supportUrl, external: true }}>
124
+ use our contact page
125
+ </Link>
126
+ ) : (
127
+ "use our contact page"
128
+ )}
129
+ .
130
+ </Text>
131
+ </Flex>
132
+ <Divider />
133
+ {error && (
134
+ <Alert title="Couldn't send your request" variant="error">
135
+ <Text>{error}</Text>
136
+ {supportUrl ? (
137
+ <Text>
138
+ You can also reach us via{" "}
139
+ <Link href={{ url: supportUrl, external: true }}>
140
+ our contact page
141
+ </Link>
142
+ .
143
+ </Text>
144
+ ) : null}
145
+ </Alert>
146
+ )}
147
+ <Form onSubmit={handleSubmit}>
148
+ <Flex direction="column" gap="medium">
149
+ <Input
150
+ label="First name"
151
+ name="firstname"
152
+ required={true}
153
+ value={fields.firstname}
154
+ onChange={set("firstname")}
155
+ />
156
+ <Input
157
+ label="Last name"
158
+ name="lastname"
159
+ required={true}
160
+ value={fields.lastname}
161
+ onChange={set("lastname")}
162
+ />
163
+ <Input
164
+ label="Email"
165
+ name="email"
166
+ required={true}
167
+ value={fields.email}
168
+ onChange={set("email")}
169
+ />
170
+ <TextArea
171
+ label="What can we help you with today?"
172
+ name="content"
173
+ required={true}
174
+ value={fields.content}
175
+ onChange={set("content")}
176
+ />
177
+ <Flex direction="column" gap="extra-small">
178
+ <Text format={{ fontWeight: "bold" }}>Screenshot (optional)</Text>
179
+ <FileInput name="file" value={null} onChange={handleFileChange} />
180
+ {file && (
181
+ <Flex direction="row" gap="small" align="center">
182
+ <Text>Attached: {file.name}</Text>
183
+ <Button variant="secondary" size="xs" onClick={() => setFile(null)}>
184
+ Remove
185
+ </Button>
186
+ </Flex>
187
+ )}
188
+ </Flex>
189
+ <Button variant="primary" type="submit" disabled={submitting}>
190
+ {submitting ? "Sending…" : "Send"}
191
+ </Button>
192
+ </Flex>
193
+ </Form>
194
+ </Flex>
195
+ );
196
+ }
197
+
198
+ export default SupportTab;
@@ -0,0 +1,50 @@
1
+ // supportLogic — pure helpers for SupportTab, kept separate so the validation
2
+ // and payload shaping are unit-testable without rendering (see
3
+ // tests/supportTab.test.js).
4
+
5
+ // Mirrors common.support's contract: contact fields + the ticket `content`,
6
+ // optional file as base64 (hubspot.fetch carries JSON only, no multipart).
7
+ export function validateSupportFields({ firstname, lastname, email, content }) {
8
+ if (!firstname?.trim()) return "First name is required.";
9
+ if (!lastname?.trim()) return "Last name is required.";
10
+ const e = email?.trim() ?? "";
11
+ if (!e || !e.includes("@") || e.startsWith("@") || e.endsWith("@")) {
12
+ return "A valid email is required.";
13
+ }
14
+ if (!content?.trim()) return "Tell us what we can help with.";
15
+ return null;
16
+ }
17
+
18
+ // Body for POST {supportPath}?portalId=N — plain object (callAppApi
19
+ // serializes; NEVER pre-stringify for hubspot.fetch).
20
+ export function buildSupportBody({ firstname, lastname, email, content }, file) {
21
+ const body = {
22
+ firstname: firstname.trim(),
23
+ lastname: lastname.trim(),
24
+ email: email.trim(),
25
+ content: content.trim(),
26
+ };
27
+ if (file) {
28
+ body.file = { filename: file.name, contentBase64: file.contentBase64 };
29
+ }
30
+ return body;
31
+ }
32
+
33
+ // FileInput (@hubspot/ui-extensions/experimental) has an ambiguous event
34
+ // shape per HubSpot's own types (`any`) — defensively unwrap to a real File.
35
+ // Same empirically-verified pattern as ATS AddCandidate's resume picker.
36
+ export function unwrapFileEvent(event) {
37
+ const file = event?.target?.files?.[0] || event;
38
+ return file instanceof File ? file : null;
39
+ }
40
+
41
+ // FileReader → raw base64 (strips the "data:<mime>;base64," prefix).
42
+ export function readFileAsBase64(file) {
43
+ return new Promise((resolve, reject) => {
44
+ const reader = new FileReader();
45
+ reader.onload = () => resolve(String(reader.result).split(",")[1] || "");
46
+ reader.onerror = () =>
47
+ reject(reader.error || new Error("Failed to read file"));
48
+ reader.readAsDataURL(file);
49
+ });
50
+ }