@webority/mobile-support 0.0.9

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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/lib/commonjs/SupportProvider.js +335 -0
  4. package/lib/commonjs/client.js +164 -0
  5. package/lib/commonjs/clipboard.js +10 -0
  6. package/lib/commonjs/components/Attachments.js +246 -0
  7. package/lib/commonjs/components/NewSupportTicket.js +210 -0
  8. package/lib/commonjs/components/PhotoTray.js +141 -0
  9. package/lib/commonjs/components/SupportContact.js +134 -0
  10. package/lib/commonjs/components/SupportTicketDetail.js +348 -0
  11. package/lib/commonjs/components/SupportTicketList.js +279 -0
  12. package/lib/commonjs/device.js +29 -0
  13. package/lib/commonjs/files.js +37 -0
  14. package/lib/commonjs/format.js +29 -0
  15. package/lib/commonjs/index.js +97 -0
  16. package/lib/commonjs/package.json +1 -0
  17. package/lib/commonjs/photos.js +67 -0
  18. package/lib/commonjs/report/ReportIssueButton.js +57 -0
  19. package/lib/commonjs/report/captureAndReport.js +34 -0
  20. package/lib/commonjs/report/index.js +20 -0
  21. package/lib/commonjs/share/index.js +44 -0
  22. package/lib/commonjs/types.js +2 -0
  23. package/lib/commonjs/validation.js +45 -0
  24. package/lib/module/SupportProvider.js +323 -0
  25. package/lib/module/client.js +156 -0
  26. package/lib/module/clipboard.js +5 -0
  27. package/lib/module/components/Attachments.js +240 -0
  28. package/lib/module/components/NewSupportTicket.js +205 -0
  29. package/lib/module/components/PhotoTray.js +136 -0
  30. package/lib/module/components/SupportContact.js +129 -0
  31. package/lib/module/components/SupportTicketDetail.js +343 -0
  32. package/lib/module/components/SupportTicketList.js +273 -0
  33. package/lib/module/device.js +24 -0
  34. package/lib/module/files.js +31 -0
  35. package/lib/module/format.js +23 -0
  36. package/lib/module/index.js +10 -0
  37. package/lib/module/package.json +1 -0
  38. package/lib/module/photos.js +61 -0
  39. package/lib/module/report/ReportIssueButton.js +52 -0
  40. package/lib/module/report/captureAndReport.js +29 -0
  41. package/lib/module/report/index.js +12 -0
  42. package/lib/module/share/index.js +37 -0
  43. package/lib/module/types.js +2 -0
  44. package/lib/module/validation.js +38 -0
  45. package/lib/typescript/commonjs/SupportProvider.d.ts +82 -0
  46. package/lib/typescript/commonjs/client.d.ts +63 -0
  47. package/lib/typescript/commonjs/clipboard.d.ts +3 -0
  48. package/lib/typescript/commonjs/components/Attachments.d.ts +16 -0
  49. package/lib/typescript/commonjs/components/NewSupportTicket.d.ts +12 -0
  50. package/lib/typescript/commonjs/components/PhotoTray.d.ts +17 -0
  51. package/lib/typescript/commonjs/components/SupportContact.d.ts +7 -0
  52. package/lib/typescript/commonjs/components/SupportTicketDetail.d.ts +7 -0
  53. package/lib/typescript/commonjs/components/SupportTicketList.d.ts +28 -0
  54. package/lib/typescript/commonjs/device.d.ts +6 -0
  55. package/lib/typescript/commonjs/files.d.ts +10 -0
  56. package/lib/typescript/commonjs/format.d.ts +10 -0
  57. package/lib/typescript/commonjs/index.d.ts +15 -0
  58. package/lib/typescript/commonjs/package.json +1 -0
  59. package/lib/typescript/commonjs/photos.d.ts +16 -0
  60. package/lib/typescript/commonjs/report/ReportIssueButton.d.ts +14 -0
  61. package/lib/typescript/commonjs/report/captureAndReport.d.ts +15 -0
  62. package/lib/typescript/commonjs/report/index.d.ts +11 -0
  63. package/lib/typescript/commonjs/share/index.d.ts +10 -0
  64. package/lib/typescript/commonjs/types.d.ts +152 -0
  65. package/lib/typescript/commonjs/validation.d.ts +10 -0
  66. package/lib/typescript/module/SupportProvider.d.ts +82 -0
  67. package/lib/typescript/module/client.d.ts +63 -0
  68. package/lib/typescript/module/clipboard.d.ts +3 -0
  69. package/lib/typescript/module/components/Attachments.d.ts +16 -0
  70. package/lib/typescript/module/components/NewSupportTicket.d.ts +12 -0
  71. package/lib/typescript/module/components/PhotoTray.d.ts +17 -0
  72. package/lib/typescript/module/components/SupportContact.d.ts +7 -0
  73. package/lib/typescript/module/components/SupportTicketDetail.d.ts +7 -0
  74. package/lib/typescript/module/components/SupportTicketList.d.ts +28 -0
  75. package/lib/typescript/module/device.d.ts +6 -0
  76. package/lib/typescript/module/files.d.ts +10 -0
  77. package/lib/typescript/module/format.d.ts +10 -0
  78. package/lib/typescript/module/index.d.ts +15 -0
  79. package/lib/typescript/module/package.json +1 -0
  80. package/lib/typescript/module/photos.d.ts +16 -0
  81. package/lib/typescript/module/report/ReportIssueButton.d.ts +14 -0
  82. package/lib/typescript/module/report/captureAndReport.d.ts +15 -0
  83. package/lib/typescript/module/report/index.d.ts +11 -0
  84. package/lib/typescript/module/share/index.d.ts +10 -0
  85. package/lib/typescript/module/types.d.ts +152 -0
  86. package/lib/typescript/module/validation.d.ts +10 -0
  87. package/package.json +124 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Webority Technologies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # @webority/mobile-support
2
+
3
+ Customer support tickets for Webority React Native apps. It talks to the Webority Support
4
+ endpoints a product hosts (`MapWeboritySupportEndpoints("/api/support")`) and gives the app
5
+ ready screens: the ticket list, a ticket's thread, a new ticket form, a contact card and a
6
+ report-an-issue button that attaches a screenshot of the current screen.
7
+
8
+ ```bash
9
+ npm install @webority/mobile-support
10
+ ```
11
+
12
+ Builds on `@webority/mobile-core` (the HTTP client, device context, image
13
+ compression) and `@webority/mobile-ui` (theme, components, form engine).
14
+
15
+ ## Peers
16
+
17
+ | Package | Why | Required |
18
+ |---|---|---|
19
+ | `expo-image-picker` | photos from the library and the camera | yes |
20
+ | `expo-image-manipulator`, `expo-file-system` | photos are bounded and compressed before upload (mobile-core's compressor); attachments are saved to the cache before opening | yes |
21
+ | `expo-clipboard` | copy the support email | yes |
22
+ | `react-native-gesture-handler` | pinch to zoom in the full-screen image view | yes (mobile-ui needs it too) |
23
+ | `react-native-view-shot` | the screen capture behind `ReportIssueButton` | only if you import `/report` |
24
+ | `expo-sharing` | opens a PDF or text attachment through the share sheet | only if you import `/share` |
25
+
26
+ `react-native-view-shot` is named only under the `/report` import path and `expo-sharing` only
27
+ under `/share`, so an app that never imports them does not need the packages installed.
28
+
29
+ ## Wire it up
30
+
31
+ Create the client over the app's own signed-in HTTP client, so every support call carries the
32
+ same bearer token, tenant header and refresh handling as the rest of the app. Pass the prefix the
33
+ product mapped the endpoints on.
34
+
35
+ ```tsx
36
+ import { userClient } from '@webority/mobile-core';
37
+ import { createSupportClient, SupportProvider } from '@webority/mobile-support';
38
+
39
+ const supportClient = createSupportClient(userClient, '/api/support');
40
+
41
+ export const App = () => (
42
+ <ThemeProvider>
43
+ <SupportProvider client={supportClient}>
44
+ <Navigation />
45
+ </SupportProvider>
46
+ </ThemeProvider>
47
+ );
48
+ ```
49
+
50
+ ## Screens
51
+
52
+ The screens know nothing about navigation. Place them in your own navigator and pass callbacks.
53
+
54
+ ```tsx
55
+ import {
56
+ NewSupportTicket,
57
+ SupportContact,
58
+ SupportTicketDetail,
59
+ SupportTicketList
60
+ } from '@webority/mobile-support';
61
+
62
+ const SupportScreen = ({ navigation }) => (
63
+ <SupportTicketList
64
+ header={<SupportContact />}
65
+ onOpenTicket={(t) => navigation.navigate('SupportTicket', { id: t.id })}
66
+ onNewTicket={() => navigation.navigate('NewSupportTicket')}
67
+ />
68
+ );
69
+
70
+ const SupportTicketScreen = ({ route }) => <SupportTicketDetail ticketId={route.params.id} />;
71
+
72
+ const NewSupportTicketScreen = ({ navigation, route }) => (
73
+ <NewSupportTicket
74
+ initialDraft={route.params?.draft}
75
+ onCancel={() => navigation.goBack()}
76
+ onCreated={(t) => navigation.replace('SupportTicket', { id: t.id })}
77
+ />
78
+ );
79
+ ```
80
+
81
+ - **SupportTicketList**: one page at a time (`pageSize`, default 20), the next page loading as
82
+ the person reaches the end. Status chips carry the counts from `GET /tickets/counts`; a chip and
83
+ the search box (subject or ticket number) filter on the server and start again from page 1, as
84
+ does pull to refresh. Empty state, retry on failure, and a Load more retry when a later page
85
+ fails without losing the rows already shown.
86
+ - **SupportTicketDetail**: the thread as bubbles, status events as small centred lines, image
87
+ thumbnails that open full screen, a reply box with photos, Reopen when `canReopen`, and a
88
+ satisfied or not satisfied rating with an optional comment when `canRate`. Replying to a
89
+ resolved or closed ticket reopens it.
90
+ - **NewSupportTicket**: category, subject, message and photos, checked against the limits the
91
+ product sends in `GET /settings` before anything is uploaded.
92
+ - **SupportContact**: the support email with copy and compose, the phone number, and the hours.
93
+
94
+ ## Report an issue
95
+
96
+ ```tsx
97
+ import { ReportIssueButton } from '@webority/mobile-support/report';
98
+
99
+ <ReportIssueButton
100
+ screenName={route.name}
101
+ appVersion={AppSettings.VERSION_DISPLAY}
102
+ onReport={(draft) => navigation.navigate('NewSupportTicket', { draft })}
103
+ />;
104
+ ```
105
+
106
+ The button captures the screen, then hands your app a draft with the image under
107
+ `screenshots`, the screen name as the page, the device line and the app version.
108
+ `captureAndReport(options)` does the same without the button. The device line reads the
109
+ platform and OS version, plus the model when the app has called mobile-core's
110
+ `setDeviceContext`.
111
+
112
+ ## Hooks
113
+
114
+ `useSupportConfig()`, `useSupportTickets({ pageSize })`, `useSupportTicket(id)` and
115
+ `useCreateSupportTicket()` expose the same data for apps that build their own screens.
116
+ `useSupportTickets` returns the pages loaded so far as `tickets`, the `counts`, `status` and
117
+ `search` with their setters, `loadMore` and `hasMore`, and `refresh`. Every
118
+ failure is a `SupportApiError` whose `message` is written for the person and can be shown as is;
119
+ branch on `code`, never show it.
120
+
121
+ `client.confirmAction(token)` sends the person's yes to a support action an assistant prepared
122
+ (raise a ticket, reply on one, reopen one) as `POST {base}/actions/confirm`. Only this call
123
+ completes the action. It resolves to the ticket it acted on; an expired or unknown token rejects
124
+ with the code `action_expired`.
125
+
126
+ ## Attachments
127
+
128
+ Attachment files need the bearer token, so thumbnails are fetched through the client you passed
129
+ in and shown from a `data:` URI, cached for the session.
130
+
131
+ Files that are not images (a PDF, a text file) show their name and size. To let people open
132
+ them, give `SupportProvider` an `openFile`. The file is downloaded through the same client, saved
133
+ to the app's cache, then handed to your opener. `/share` ships one that uses the system share
134
+ sheet (install `expo-sharing`):
135
+
136
+ ```tsx
137
+ import { shareSupportFile } from '@webority/mobile-support/share';
138
+
139
+ <SupportProvider client={supportClient} openFile={shareSupportFile}>
140
+ <Navigation />
141
+ </SupportProvider>;
142
+ ```
143
+
144
+ On both platforms the person picks the app to open or save the file with; on Android this is
145
+ the share chooser rather than an "open with" list. If the file cannot be
146
+ opened, the person sees a short message and can try again.
@@ -0,0 +1,335 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.useSupportTickets = exports.useSupportTicket = exports.useSupportConfig = exports.useSupportClient = exports.useCreateSupportTicket = exports.useAttachmentOpener = exports.useAttachmentLoader = exports.SupportProvider = void 0;
7
+ var _react = require("react");
8
+ var _client = require("./client.js");
9
+ var _files = require("./files.js");
10
+ var _jsxRuntime = require("react/jsx-runtime");
11
+ /** A downloaded attachment, ready for the OS to open. */
12
+
13
+ /** Hands a downloaded file to the OS. `shareSupportFile` from the `/share` entry is one. */
14
+
15
+ const SupportContext = /*#__PURE__*/(0, _react.createContext)(null);
16
+ const SupportProvider = ({
17
+ client,
18
+ openFile,
19
+ children
20
+ }) => {
21
+ const [config, setConfig] = (0, _react.useState)(null);
22
+ const [configError, setConfigError] = (0, _react.useState)(null);
23
+ const [configLoading, setConfigLoading] = (0, _react.useState)(true);
24
+ const [revision, setRevision] = (0, _react.useState)(0);
25
+ // Attachments never change once stored, so each is fetched once per session.
26
+ const attachments = (0, _react.useRef)(new Map());
27
+ const reloadConfig = (0, _react.useCallback)(async () => {
28
+ setConfigLoading(true);
29
+ try {
30
+ setConfig(await client.getConfig());
31
+ setConfigError(null);
32
+ } catch (error) {
33
+ setConfigError((0, _client.toSupportApiError)(error));
34
+ } finally {
35
+ setConfigLoading(false);
36
+ }
37
+ }, [client]);
38
+ (0, _react.useEffect)(() => {
39
+ attachments.current.clear();
40
+ void reloadConfig();
41
+ }, [reloadConfig]);
42
+ const loadAttachment = (0, _react.useCallback)(attachment => {
43
+ const cached = attachments.current.get(attachment.id);
44
+ if (cached) {
45
+ return cached;
46
+ }
47
+ const pending = client.getAttachmentDataUri(attachment);
48
+ attachments.current.set(attachment.id, pending);
49
+ pending.catch(() => attachments.current.delete(attachment.id));
50
+ return pending;
51
+ }, [client]);
52
+ const openAttachment = (0, _react.useMemo)(() => openFile ? async attachment => {
53
+ const uri = await (0, _files.saveAttachmentToCache)(attachment, () => client.getAttachmentBytes(attachment.id));
54
+ await openFile({
55
+ uri,
56
+ name: attachment.fileName,
57
+ contentType: attachment.contentType
58
+ });
59
+ } : null, [client, openFile]);
60
+ const bumpRevision = (0, _react.useCallback)(() => setRevision(r => r + 1), []);
61
+ const value = (0, _react.useMemo)(() => ({
62
+ client,
63
+ config,
64
+ configError,
65
+ configLoading,
66
+ reloadConfig,
67
+ loadAttachment,
68
+ openAttachment,
69
+ revision,
70
+ bumpRevision
71
+ }), [client, config, configError, configLoading, reloadConfig, loadAttachment, openAttachment, revision, bumpRevision]);
72
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(SupportContext.Provider, {
73
+ value: value,
74
+ children: children
75
+ });
76
+ };
77
+ exports.SupportProvider = SupportProvider;
78
+ const useSupportContext = () => {
79
+ const ctx = (0, _react.useContext)(SupportContext);
80
+ if (!ctx) {
81
+ throw new Error('[@webority/mobile-support] Support screens and hooks need a <SupportProvider client={...}> above them.');
82
+ }
83
+ return ctx;
84
+ };
85
+ const useSupportClient = () => useSupportContext().client;
86
+
87
+ /** @internal Used by the attachment thumbnails. */
88
+ exports.useSupportClient = useSupportClient;
89
+ const useAttachmentLoader = () => useSupportContext().loadAttachment;
90
+
91
+ /** @internal Used by the file rows; null when the app gave no `openFile`. */
92
+ exports.useAttachmentLoader = useAttachmentLoader;
93
+ const useAttachmentOpener = () => useSupportContext().openAttachment;
94
+ exports.useAttachmentOpener = useAttachmentOpener;
95
+ const useSupportConfig = () => {
96
+ const {
97
+ config,
98
+ configLoading,
99
+ configError,
100
+ reloadConfig
101
+ } = useSupportContext();
102
+ return {
103
+ config,
104
+ loading: configLoading,
105
+ error: configError,
106
+ reload: reloadConfig
107
+ };
108
+ };
109
+ exports.useSupportConfig = useSupportConfig;
110
+ const DEFAULT_PAGE_SIZE = 20;
111
+ const useSupportTickets = (options = {}) => {
112
+ const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
113
+ const {
114
+ client,
115
+ revision
116
+ } = useSupportContext();
117
+ const [status, setStatus] = (0, _react.useState)(null);
118
+ const [search, setSearch] = (0, _react.useState)('');
119
+ const [tickets, setTickets] = (0, _react.useState)([]);
120
+ const [counts, setCounts] = (0, _react.useState)(null);
121
+ const [page, setPage] = (0, _react.useState)(0);
122
+ const [totalPages, setTotalPages] = (0, _react.useState)(0);
123
+ const [loading, setLoading] = (0, _react.useState)(true);
124
+ const [refreshing, setRefreshing] = (0, _react.useState)(false);
125
+ const [loadingMore, setLoadingMore] = (0, _react.useState)(false);
126
+ const [error, setError] = (0, _react.useState)(null);
127
+ const [loadMoreError, setLoadMoreError] = (0, _react.useState)(null);
128
+ const mounted = (0, _react.useRef)(true);
129
+ // Each first-page load takes a new number; a response carrying an older one
130
+ // belongs to a filter or search the person has already left, and is dropped.
131
+ const sequence = (0, _react.useRef)(0);
132
+ const moreInFlight = (0, _react.useRef)(false);
133
+ // The client whose counts are on screen; null until a load with counts lands.
134
+ const countsFrom = (0, _react.useRef)(null);
135
+ (0, _react.useEffect)(() => {
136
+ mounted.current = true;
137
+ return () => {
138
+ mounted.current = false;
139
+ };
140
+ }, []);
141
+ const query = (0, _react.useCallback)(nextPage => ({
142
+ status: status ?? undefined,
143
+ search: search.trim() || undefined,
144
+ page: nextPage,
145
+ pageSize
146
+ }), [status, search, pageSize]);
147
+ const loadFirstPage = (0, _react.useCallback)(async mode => {
148
+ sequence.current += 1;
149
+ const seq = sequence.current;
150
+ const current = () => mounted.current && seq === sequence.current;
151
+ moreInFlight.current = false;
152
+ setLoadingMore(false);
153
+ if (mode === 'refresh') {
154
+ setRefreshing(true);
155
+ } else if (mode === 'replace') {
156
+ setLoading(true);
157
+ setTickets([]);
158
+ }
159
+ // Counts ignore the status and search, so a filter change keeps the ones it has.
160
+ const withCounts = mode !== 'replace' || countsFrom.current !== client;
161
+ try {
162
+ const [first, nextCounts] = await Promise.all([client.listTickets(query(1)), withCounts ? client.getTicketCounts() : null]);
163
+ if (current()) {
164
+ setTickets(first.items);
165
+ setPage(first.page);
166
+ setTotalPages(first.totalPages);
167
+ if (nextCounts) {
168
+ setCounts(nextCounts);
169
+ countsFrom.current = client;
170
+ }
171
+ setError(null);
172
+ setLoadMoreError(null);
173
+ }
174
+ } catch (e) {
175
+ if (current()) {
176
+ setError((0, _client.toSupportApiError)(e));
177
+ }
178
+ } finally {
179
+ if (current()) {
180
+ setLoading(false);
181
+ setRefreshing(false);
182
+ }
183
+ }
184
+ }, [client, query]);
185
+ const seenRevision = (0, _react.useRef)(revision);
186
+ (0, _react.useEffect)(() => {
187
+ // A write elsewhere refetches without blanking the rows on screen.
188
+ const mode = seenRevision.current === revision ? 'replace' : 'quiet';
189
+ seenRevision.current = revision;
190
+ void loadFirstPage(mode);
191
+ }, [loadFirstPage, revision]);
192
+ const hasMore = page < totalPages;
193
+ const loadMore = (0, _react.useCallback)(async () => {
194
+ if (moreInFlight.current || loading || refreshing || !hasMore) {
195
+ return;
196
+ }
197
+ const seq = sequence.current;
198
+ const current = () => mounted.current && seq === sequence.current;
199
+ moreInFlight.current = true;
200
+ setLoadingMore(true);
201
+ setLoadMoreError(null);
202
+ try {
203
+ const next = await client.listTickets(query(page + 1));
204
+ if (current()) {
205
+ // Order is by last update, so a ticket that changed between two pages
206
+ // can arrive twice.
207
+ setTickets(prev => {
208
+ const ids = new Set(prev.map(t => t.id));
209
+ return [...prev, ...next.items.filter(t => !ids.has(t.id))];
210
+ });
211
+ setPage(next.page);
212
+ setTotalPages(next.totalPages);
213
+ }
214
+ } catch (e) {
215
+ if (current()) {
216
+ setLoadMoreError((0, _client.toSupportApiError)(e));
217
+ }
218
+ } finally {
219
+ if (current()) {
220
+ moreInFlight.current = false;
221
+ setLoadingMore(false);
222
+ }
223
+ }
224
+ }, [client, query, page, hasMore, loading, refreshing]);
225
+ const refresh = (0, _react.useCallback)(() => loadFirstPage('refresh'), [loadFirstPage]);
226
+ const createTicket = useCreateSupportTicket();
227
+ return {
228
+ tickets,
229
+ counts,
230
+ status,
231
+ search,
232
+ setStatus,
233
+ setSearch,
234
+ loading,
235
+ refreshing,
236
+ loadingMore,
237
+ hasMore,
238
+ error,
239
+ loadMoreError,
240
+ refresh,
241
+ loadMore,
242
+ createTicket
243
+ };
244
+ };
245
+
246
+ /** Raises a ticket and tells every mounted ticket list to refetch. */
247
+ exports.useSupportTickets = useSupportTickets;
248
+ const useCreateSupportTicket = () => {
249
+ const {
250
+ client,
251
+ bumpRevision
252
+ } = useSupportContext();
253
+ return (0, _react.useCallback)(async input => {
254
+ const created = await client.createTicket(input);
255
+ bumpRevision();
256
+ return created;
257
+ }, [client, bumpRevision]);
258
+ };
259
+ exports.useCreateSupportTicket = useCreateSupportTicket;
260
+ const useSupportTicket = id => {
261
+ const {
262
+ client,
263
+ bumpRevision
264
+ } = useSupportContext();
265
+ const [ticket, setTicket] = (0, _react.useState)(null);
266
+ const [loading, setLoading] = (0, _react.useState)(true);
267
+ const [error, setError] = (0, _react.useState)(null);
268
+ const [actionError, setActionError] = (0, _react.useState)(null);
269
+ const [busy, setBusy] = (0, _react.useState)(false);
270
+ const mounted = (0, _react.useRef)(true);
271
+ (0, _react.useEffect)(() => {
272
+ mounted.current = true;
273
+ return () => {
274
+ mounted.current = false;
275
+ };
276
+ }, []);
277
+ const reload = (0, _react.useCallback)(async () => {
278
+ setLoading(true);
279
+ try {
280
+ const next = await client.getTicket(id);
281
+ if (mounted.current) {
282
+ setTicket(next);
283
+ setError(null);
284
+ }
285
+ } catch (e) {
286
+ if (mounted.current) {
287
+ setError((0, _client.toSupportApiError)(e));
288
+ }
289
+ } finally {
290
+ if (mounted.current) {
291
+ setLoading(false);
292
+ }
293
+ }
294
+ }, [client, id]);
295
+ (0, _react.useEffect)(() => {
296
+ void reload();
297
+ }, [reload]);
298
+ const act = (0, _react.useCallback)(async run => {
299
+ setBusy(true);
300
+ setActionError(null);
301
+ try {
302
+ const next = await run();
303
+ if (mounted.current) {
304
+ setTicket(next);
305
+ }
306
+ bumpRevision();
307
+ return true;
308
+ } catch (e) {
309
+ if (mounted.current) {
310
+ setActionError((0, _client.toSupportApiError)(e));
311
+ }
312
+ return false;
313
+ } finally {
314
+ if (mounted.current) {
315
+ setBusy(false);
316
+ }
317
+ }
318
+ }, [bumpRevision]);
319
+ const reply = (0, _react.useCallback)(input => act(() => client.reply(id, input)), [act, client, id]);
320
+ const reopen = (0, _react.useCallback)(() => act(() => client.reopen(id)), [act, client, id]);
321
+ const rate = (0, _react.useCallback)(input => act(() => client.rate(id, input)), [act, client, id]);
322
+ return {
323
+ ticket,
324
+ loading,
325
+ error,
326
+ actionError,
327
+ busy,
328
+ reload,
329
+ reply,
330
+ reopen,
331
+ rate
332
+ };
333
+ };
334
+ exports.useSupportTicket = useSupportTicket;
335
+ //# sourceMappingURL=SupportProvider.js.map
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.toSupportApiError = exports.encodeBase64 = exports.createSupportClient = exports.SupportApiError = exports.OFFLINE_MESSAGE = exports.GENERIC_MESSAGE = void 0;
7
+ /**
8
+ * The slice of an HTTP client the support client needs. The app's signed-in
9
+ * axios instance (mobile-core's `userClient`, or its own) satisfies it as is, so
10
+ * whatever it adds to a request (bearer token, tenant header, refresh on 401)
11
+ * applies to support calls too. Declared structurally so no axios type leaks into
12
+ * this package's declarations.
13
+ */
14
+
15
+ const OFFLINE_MESSAGE = exports.OFFLINE_MESSAGE = 'You are offline. Check your connection and try again.';
16
+ const GENERIC_MESSAGE = exports.GENERIC_MESSAGE = 'Something went wrong. Try again in a moment.';
17
+
18
+ /**
19
+ * A failed support call. `message` is written for the person and is shown as is;
20
+ * `code` is the backend's stable code, for branching only.
21
+ */
22
+ class SupportApiError extends Error {
23
+ constructor(code, message, status) {
24
+ super(message);
25
+ this.name = 'SupportApiError';
26
+ this.code = code;
27
+ this.status = status;
28
+ }
29
+ }
30
+ exports.SupportApiError = SupportApiError;
31
+ /** Turns whatever the HTTP client threw into a SupportApiError. */
32
+ const toSupportApiError = error => {
33
+ if (error instanceof SupportApiError) {
34
+ return error;
35
+ }
36
+ const e = error ?? {};
37
+ // By name, not instanceof: a second copy of mobile-core would fail instanceof.
38
+ if (e.name === 'RequestOfflineError') {
39
+ return new SupportApiError('offline', OFFLINE_MESSAGE, null);
40
+ }
41
+ const status = typeof e.response?.status === 'number' ? e.response.status : null;
42
+ const data = e.response?.data;
43
+ if (data && typeof data.message === 'string' && data.message.trim()) {
44
+ const code = typeof data.code === 'string' ? data.code : 'unknown';
45
+ return new SupportApiError(code, data.message, status);
46
+ }
47
+ if (!e.response) {
48
+ return new SupportApiError('network', OFFLINE_MESSAGE, null);
49
+ }
50
+ return new SupportApiError('unknown', GENERIC_MESSAGE, status);
51
+ };
52
+
53
+ // Photo uploads get the fleet's multipart timeout, not the 30 s JSON one.
54
+ exports.toSupportApiError = toSupportApiError;
55
+ const UPLOAD_TIMEOUT_MS = 300000;
56
+ const MULTIPART = {
57
+ 'Content-Type': 'multipart/form-data'
58
+ };
59
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
60
+ const encodeBase64 = buffer => {
61
+ const bytes = new Uint8Array(buffer);
62
+ let out = '';
63
+ for (let i = 0; i < bytes.length; i += 3) {
64
+ const b0 = bytes[i];
65
+ const b1 = i + 1 < bytes.length ? bytes[i + 1] : undefined;
66
+ const b2 = i + 2 < bytes.length ? bytes[i + 2] : undefined;
67
+ out += B64[b0 >> 2];
68
+ out += B64[(b0 & 0x03) << 4 | (b1 ?? 0) >> 4];
69
+ out += b1 === undefined ? '=' : B64[(b1 & 0x0f) << 2 | (b2 ?? 0) >> 6];
70
+ out += b2 === undefined ? '=' : B64[b2 & 0x3f];
71
+ }
72
+ return out;
73
+ };
74
+ exports.encodeBase64 = encodeBase64;
75
+ const appendParts = (form, field, parts) => {
76
+ for (const part of parts ?? []) {
77
+ // React Native's FormData takes a { uri, name, type } object as a file part.
78
+ form.append(field, {
79
+ uri: part.uri,
80
+ name: part.name,
81
+ type: part.type
82
+ });
83
+ }
84
+ };
85
+ const appendText = (form, field, value) => {
86
+ if (value !== undefined && value !== '') {
87
+ form.append(field, value);
88
+ }
89
+ };
90
+ const queryString = query => {
91
+ const pairs = [];
92
+ const add = (key, value) => {
93
+ if (value !== undefined && value !== '') {
94
+ pairs.push(`${key}=${encodeURIComponent(String(value))}`);
95
+ }
96
+ };
97
+ add('status', query.status);
98
+ add('search', query.search?.trim());
99
+ add('page', query.page);
100
+ add('pageSize', query.pageSize);
101
+ return pairs.length > 0 ? `?${pairs.join('&')}` : '';
102
+ };
103
+ const createSupportClient = (http, basePath = '/api/support') => {
104
+ const base = basePath.replace(/\/+$/, '');
105
+ const ticketUrl = id => `${base}/tickets/${encodeURIComponent(id)}`;
106
+ const call = async request => {
107
+ try {
108
+ const response = await request();
109
+ return response.data;
110
+ } catch (error) {
111
+ throw toSupportApiError(error);
112
+ }
113
+ };
114
+ const getBytes = id => call(() => http.get(`${base}/attachments/${encodeURIComponent(id)}`, {
115
+ responseType: 'arraybuffer'
116
+ }));
117
+ return {
118
+ basePath: base,
119
+ getConfig: () => call(() => http.get(`${base}/settings`)),
120
+ listTickets: (query = {}) => call(() => http.get(`${base}/tickets${queryString(query)}`)),
121
+ getTicketCounts: () => call(() => http.get(`${base}/tickets/counts`)),
122
+ getTicket: id => call(() => http.get(ticketUrl(id))),
123
+ createTicket: input => {
124
+ const form = new FormData();
125
+ form.append('category', input.category);
126
+ form.append('subject', input.subject);
127
+ form.append('body', input.body);
128
+ appendText(form, 'pageUrl', input.pageUrl);
129
+ appendText(form, 'userAgent', input.userAgent);
130
+ appendText(form, 'appVersion', input.appVersion);
131
+ appendParts(form, 'files', input.files);
132
+ appendParts(form, 'screenshots', input.screenshots);
133
+ return call(() => http.post(`${base}/tickets`, form, {
134
+ headers: MULTIPART,
135
+ timeout: UPLOAD_TIMEOUT_MS
136
+ }));
137
+ },
138
+ reply: (id, input) => {
139
+ const form = new FormData();
140
+ form.append('body', input.body);
141
+ appendParts(form, 'files', input.files);
142
+ appendParts(form, 'screenshots', input.screenshots);
143
+ return call(() => http.post(`${ticketUrl(id)}/replies`, form, {
144
+ headers: MULTIPART,
145
+ timeout: UPLOAD_TIMEOUT_MS
146
+ }));
147
+ },
148
+ reopen: id => call(() => http.post(`${ticketUrl(id)}/reopen`)),
149
+ rate: (id, input) => call(() => http.post(`${ticketUrl(id)}/rating`, {
150
+ satisfaction: input.satisfaction,
151
+ comment: input.comment?.trim() ? input.comment.trim() : null
152
+ })),
153
+ getAttachmentBytes: getBytes,
154
+ getAttachmentDataUri: async attachment => {
155
+ const bytes = await getBytes(attachment.id);
156
+ return `data:${attachment.contentType};base64,${encodeBase64(bytes)}`;
157
+ },
158
+ confirmAction: token => call(() => http.post(`${base}/actions/confirm`, {
159
+ token
160
+ }))
161
+ };
162
+ };
163
+ exports.createSupportClient = createSupportClient;
164
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.copyText = void 0;
7
+ /** The one place `expo-clipboard` is named. */
8
+ const copyText = text => require('expo-clipboard').setStringAsync(text);
9
+ exports.copyText = copyText;
10
+ //# sourceMappingURL=clipboard.js.map