@growth-operations/ui-shared 1.33.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 +1 -1
- package/src/index.js +4 -0
- package/src/support/SupportTab.jsx +198 -0
- package/src/support/supportLogic.js +50 -0
package/package.json
CHANGED
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
|
|
|
@@ -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
|
+
}
|