@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
|
@@ -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
|
-
|
|
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
|
|
package/src/lib/autoProvision.js
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
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
|
+
}
|