@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
@@ -0,0 +1,343 @@
1
+ "use strict";
2
+
3
+ import { Button, Card, Input, Spinner, StatusBadge, Text, useTheme } from '@webority/mobile-ui';
4
+ import { useMemo, useState } from 'react';
5
+ import { ScrollView, StyleSheet, View } from 'react-native';
6
+ import { formatWhen } from "../format.js";
7
+ import { useSupportConfig, useSupportTicket } from "../SupportProvider.js";
8
+ import { validateBody, validateFiles } from "../validation.js";
9
+ import { AttachmentList, ImageViewer } from "./Attachments.js";
10
+ import { PhotoTray } from "./PhotoTray.js";
11
+ import { STATUS_TONES } from "./SupportTicketList.js";
12
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
13
+ const MessageBubble = ({
14
+ message,
15
+ styles,
16
+ onOpenImage
17
+ }) => {
18
+ if (message.authorKind === 'System') {
19
+ return /*#__PURE__*/_jsx(View, {
20
+ style: styles.system,
21
+ testID: `support-message-${message.id}`,
22
+ children: /*#__PURE__*/_jsx(Text, {
23
+ variant: "caption",
24
+ color: "tertiary",
25
+ align: "center",
26
+ children: `${message.body}, ${formatWhen(message.createdDateTimeUtc)}`
27
+ })
28
+ });
29
+ }
30
+ const mine = message.authorKind === 'Customer';
31
+ return /*#__PURE__*/_jsx(View, {
32
+ style: [styles.bubbleRow, mine ? styles.mineRow : styles.theirsRow],
33
+ testID: `support-message-${message.id}`,
34
+ children: /*#__PURE__*/_jsxs(View, {
35
+ style: [styles.bubble, mine ? styles.mine : styles.theirs],
36
+ children: [/*#__PURE__*/_jsx(Text, {
37
+ variant: "label",
38
+ color: mine ? 'primary' : 'secondary',
39
+ children: message.authorName ?? '-'
40
+ }), /*#__PURE__*/_jsx(Text, {
41
+ variant: "body",
42
+ selectable: true,
43
+ children: message.body
44
+ }), /*#__PURE__*/_jsx(AttachmentList, {
45
+ attachments: message.attachments,
46
+ onOpenImage: onOpenImage
47
+ }), /*#__PURE__*/_jsx(Text, {
48
+ variant: "caption",
49
+ color: "tertiary",
50
+ children: formatWhen(message.createdDateTimeUtc)
51
+ })]
52
+ })
53
+ });
54
+ };
55
+ const SATISFACTION_TEXT = {
56
+ Satisfied: 'You said we handled this well.',
57
+ Unsatisfied: 'You said we did not handle this well.'
58
+ };
59
+ export const SupportTicketDetail = ({
60
+ ticketId,
61
+ testID = 'support-ticket-detail'
62
+ }) => {
63
+ const theme = useTheme();
64
+ const styles = useMemo(() => buildStyles(theme), [theme]);
65
+ const {
66
+ config
67
+ } = useSupportConfig();
68
+ const {
69
+ ticket,
70
+ loading,
71
+ error,
72
+ actionError,
73
+ busy,
74
+ reload,
75
+ reply,
76
+ reopen,
77
+ rate
78
+ } = useSupportTicket(ticketId);
79
+ const [viewing, setViewing] = useState(null);
80
+ const [draft, setDraft] = useState('');
81
+ const [photos, setPhotos] = useState([]);
82
+ const [replyError, setReplyError] = useState(null);
83
+ const [choice, setChoice] = useState(null);
84
+ const [comment, setComment] = useState('');
85
+ if (!ticket) {
86
+ return /*#__PURE__*/_jsx(View, {
87
+ style: styles.center,
88
+ testID: testID,
89
+ children: loading ? /*#__PURE__*/_jsx(Spinner, {
90
+ accessibilityLabel: "Loading the ticket"
91
+ }) : /*#__PURE__*/_jsxs(_Fragment, {
92
+ children: [/*#__PURE__*/_jsx(Text, {
93
+ variant: "body",
94
+ color: "error",
95
+ align: "center",
96
+ children: error?.message ?? 'That ticket could not be found.'
97
+ }), /*#__PURE__*/_jsx(Button, {
98
+ title: "Try again",
99
+ variant: "ghost",
100
+ onPress: () => void reload()
101
+ })]
102
+ })
103
+ });
104
+ }
105
+ const sendReply = async () => {
106
+ const body = draft.trim();
107
+ // Without the config the server still enforces its limits; only emptiness is checked here.
108
+ const problem = config ? validateBody(body, config) ?? validateFiles(photos, config) : body ? undefined : 'Write a message.';
109
+ if (problem) {
110
+ setReplyError(problem);
111
+ return;
112
+ }
113
+ setReplyError(null);
114
+ if (await reply({
115
+ body,
116
+ files: photos
117
+ })) {
118
+ setDraft('');
119
+ setPhotos([]);
120
+ }
121
+ };
122
+ const sendRating = async () => {
123
+ if (!choice) {
124
+ return;
125
+ }
126
+ if (await rate({
127
+ satisfaction: choice,
128
+ comment
129
+ })) {
130
+ setChoice(null);
131
+ setComment('');
132
+ }
133
+ };
134
+ const visible = ticket.messages.filter(m => !m.isInternal);
135
+ return /*#__PURE__*/_jsxs(View, {
136
+ style: styles.root,
137
+ testID: testID,
138
+ children: [/*#__PURE__*/_jsxs(ScrollView, {
139
+ contentContainerStyle: styles.content,
140
+ keyboardShouldPersistTaps: "handled",
141
+ children: [/*#__PURE__*/_jsxs(View, {
142
+ style: styles.head,
143
+ children: [/*#__PURE__*/_jsx(Text, {
144
+ variant: "h3",
145
+ accessibilityRole: "header",
146
+ children: ticket.subject
147
+ }), /*#__PURE__*/_jsx(Text, {
148
+ variant: "bodySmall",
149
+ color: "secondary",
150
+ children: `${ticket.number}, ${ticket.category}`
151
+ }), /*#__PURE__*/_jsxs(View, {
152
+ style: styles.headRow,
153
+ children: [/*#__PURE__*/_jsx(StatusBadge, {
154
+ label: ticket.statusLabel,
155
+ tone: STATUS_TONES[ticket.status] ?? 'neutral',
156
+ accessibilityLabel: `Status: ${ticket.statusLabel}`
157
+ }), /*#__PURE__*/_jsx(Text, {
158
+ variant: "caption",
159
+ color: "tertiary",
160
+ children: `Opened ${formatWhen(ticket.createdDateTimeUtc)}`
161
+ })]
162
+ })]
163
+ }), ticket.canReopen && /*#__PURE__*/_jsxs(Card, {
164
+ variant: "outlined",
165
+ bodyStyle: styles.section,
166
+ children: [/*#__PURE__*/_jsx(Text, {
167
+ variant: "bodySmall",
168
+ children: `This ticket is ${ticket.statusLabel}. Reopen it if you still need help.`
169
+ }), /*#__PURE__*/_jsx(Button, {
170
+ title: "Reopen ticket",
171
+ variant: "outline",
172
+ size: "sm",
173
+ loading: busy,
174
+ disabled: busy,
175
+ onPress: () => void reopen(),
176
+ testID: "support-reopen"
177
+ })]
178
+ }), ticket.canRate && /*#__PURE__*/_jsxs(Card, {
179
+ variant: "outlined",
180
+ bodyStyle: styles.section,
181
+ testID: "support-rating",
182
+ children: [/*#__PURE__*/_jsx(Text, {
183
+ variant: "label",
184
+ children: "How did we do?"
185
+ }), ticket.satisfaction && /*#__PURE__*/_jsx(Text, {
186
+ variant: "bodySmall",
187
+ color: "secondary",
188
+ children: SATISFACTION_TEXT[ticket.satisfaction]
189
+ }), /*#__PURE__*/_jsxs(View, {
190
+ style: styles.ratingRow,
191
+ children: [/*#__PURE__*/_jsx(Button, {
192
+ title: "Satisfied",
193
+ size: "sm",
194
+ leftIcon: "like",
195
+ variant: choice === 'Satisfied' ? 'solid' : 'outline',
196
+ tone: "success",
197
+ accessibilityState: {
198
+ selected: choice === 'Satisfied'
199
+ },
200
+ onPress: () => setChoice('Satisfied'),
201
+ testID: "support-rate-satisfied"
202
+ }), /*#__PURE__*/_jsx(Button, {
203
+ title: "Not satisfied",
204
+ size: "sm",
205
+ leftIcon: "dislike",
206
+ variant: choice === 'Unsatisfied' ? 'solid' : 'outline',
207
+ tone: "error",
208
+ accessibilityState: {
209
+ selected: choice === 'Unsatisfied'
210
+ },
211
+ onPress: () => setChoice('Unsatisfied'),
212
+ testID: "support-rate-unsatisfied"
213
+ })]
214
+ }), choice && /*#__PURE__*/_jsxs(_Fragment, {
215
+ children: [/*#__PURE__*/_jsx(Input, {
216
+ label: "Anything to add? (optional)",
217
+ value: comment,
218
+ onChangeText: setComment,
219
+ multiline: true,
220
+ maxLength: config?.satisfactionCommentMaxLength,
221
+ testID: "support-rating-comment"
222
+ }), /*#__PURE__*/_jsx(Button, {
223
+ title: "Send rating",
224
+ size: "sm",
225
+ loading: busy,
226
+ disabled: busy,
227
+ onPress: () => void sendRating(),
228
+ testID: "support-rating-send"
229
+ })]
230
+ })]
231
+ }), /*#__PURE__*/_jsx(View, {
232
+ style: styles.thread,
233
+ accessibilityLabel: "Conversation",
234
+ children: visible.map(m => /*#__PURE__*/_jsx(MessageBubble, {
235
+ message: m,
236
+ styles: styles,
237
+ onOpenImage: (uri, name) => setViewing({
238
+ uri,
239
+ name
240
+ })
241
+ }, m.id))
242
+ }), /*#__PURE__*/_jsxs(View, {
243
+ style: styles.section,
244
+ children: [/*#__PURE__*/_jsx(Input, {
245
+ label: "Your reply",
246
+ placeholder: "Write a reply to the support team",
247
+ value: draft,
248
+ onChangeText: setDraft,
249
+ multiline: true,
250
+ maxLength: config?.bodyMaxLength,
251
+ error: replyError ?? undefined,
252
+ testID: "support-reply-input"
253
+ }), config && /*#__PURE__*/_jsx(PhotoTray, {
254
+ photos: photos,
255
+ onChange: setPhotos,
256
+ remaining: config.maxAttachmentsPerMessage - photos.length,
257
+ maxBytes: config.maxAttachmentBytes,
258
+ disabled: busy
259
+ }), ticket.canReopen && /*#__PURE__*/_jsx(Text, {
260
+ variant: "caption",
261
+ color: "secondary",
262
+ children: "Replying reopens this ticket."
263
+ }), actionError && /*#__PURE__*/_jsx(Text, {
264
+ variant: "bodySmall",
265
+ color: "error",
266
+ accessibilityLiveRegion: "polite",
267
+ children: actionError.message
268
+ }), /*#__PURE__*/_jsx(Button, {
269
+ title: "Send reply",
270
+ leftIcon: "send",
271
+ loading: busy,
272
+ disabled: busy,
273
+ onPress: () => void sendReply(),
274
+ testID: "support-reply-send"
275
+ })]
276
+ })]
277
+ }), /*#__PURE__*/_jsx(ImageViewer, {
278
+ image: viewing,
279
+ onClose: () => setViewing(null)
280
+ })]
281
+ });
282
+ };
283
+ const buildStyles = theme => StyleSheet.create({
284
+ root: {
285
+ flex: 1
286
+ },
287
+ content: {
288
+ padding: theme.spacing.md,
289
+ gap: theme.spacing.md
290
+ },
291
+ center: {
292
+ flex: 1,
293
+ alignItems: 'center',
294
+ justifyContent: 'center',
295
+ padding: theme.spacing.xl,
296
+ gap: theme.spacing.sm
297
+ },
298
+ head: {
299
+ gap: theme.spacing.xxs
300
+ },
301
+ headRow: {
302
+ flexDirection: 'row',
303
+ alignItems: 'center',
304
+ gap: theme.spacing.sm,
305
+ marginTop: theme.spacing.xs
306
+ },
307
+ section: {
308
+ gap: theme.spacing.sm
309
+ },
310
+ ratingRow: {
311
+ flexDirection: 'row',
312
+ gap: theme.spacing.sm
313
+ },
314
+ thread: {
315
+ gap: theme.spacing.sm
316
+ },
317
+ system: {
318
+ paddingHorizontal: theme.spacing.lg,
319
+ paddingVertical: theme.spacing.xxs
320
+ },
321
+ bubbleRow: {
322
+ flexDirection: 'row'
323
+ },
324
+ mineRow: {
325
+ justifyContent: 'flex-end'
326
+ },
327
+ theirsRow: {
328
+ justifyContent: 'flex-start'
329
+ },
330
+ bubble: {
331
+ maxWidth: '85%',
332
+ borderRadius: theme.radius.lg,
333
+ padding: theme.spacing.sm,
334
+ gap: theme.spacing.xxs
335
+ },
336
+ mine: {
337
+ backgroundColor: theme.colors.primaryMuted
338
+ },
339
+ theirs: {
340
+ backgroundColor: theme.colors.background.secondary
341
+ }
342
+ });
343
+ //# sourceMappingURL=SupportTicketDetail.js.map
@@ -0,0 +1,273 @@
1
+ "use strict";
2
+
3
+ import { Button, Card, Chip, EmptyState, SearchBar, Spinner, StatusBadge, Text, useTheme } from '@webority/mobile-ui';
4
+ import { useMemo } from 'react';
5
+ import { FlatList, RefreshControl, ScrollView, StyleSheet, View } from 'react-native';
6
+ import { formatWhen } from "../format.js";
7
+ import { useSupportTickets } from "../SupportProvider.js";
8
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
9
+ export const STATUS_TONES = {
10
+ Open: 'warning',
11
+ InProgress: 'info',
12
+ Resolved: 'success',
13
+ Closed: 'neutral'
14
+ };
15
+
16
+ // The counts endpoint carries numbers only, so the chip wording lives here. It
17
+ // matches the server's own labels (Webority.Support SupportDisplay).
18
+ const STATUS_FILTERS = [{
19
+ value: 'Open',
20
+ label: 'Open',
21
+ count: 'open'
22
+ }, {
23
+ value: 'InProgress',
24
+ label: 'In progress',
25
+ count: 'inProgress'
26
+ }, {
27
+ value: 'Resolved',
28
+ label: 'Resolved',
29
+ count: 'resolved'
30
+ }, {
31
+ value: 'Closed',
32
+ label: 'Closed',
33
+ count: 'closed'
34
+ }];
35
+ const ALL = 'all';
36
+ /**
37
+ * All, then each status that has tickets. The selected status stays even at
38
+ * zero, so the chip the person is on never vanishes under them.
39
+ */
40
+ export const statusFilterOptions = (counts, selected) => [{
41
+ value: ALL,
42
+ label: 'All',
43
+ count: counts.total
44
+ }, ...STATUS_FILTERS.filter(s => counts[s.count] > 0 || s.value === selected).map(s => ({
45
+ value: s.value,
46
+ label: s.label,
47
+ count: counts[s.count]
48
+ }))];
49
+ const SEARCH_DEBOUNCE_MS = 400;
50
+ export const SupportTicketList = ({
51
+ onOpenTicket,
52
+ onNewTicket,
53
+ header,
54
+ pageSize,
55
+ testID = 'support-ticket-list'
56
+ }) => {
57
+ const theme = useTheme();
58
+ const styles = useMemo(() => buildStyles(theme), [theme]);
59
+ const {
60
+ tickets,
61
+ counts,
62
+ status,
63
+ search,
64
+ setStatus,
65
+ setSearch,
66
+ loading,
67
+ refreshing,
68
+ loadingMore,
69
+ error,
70
+ loadMoreError,
71
+ refresh,
72
+ loadMore
73
+ } = useSupportTickets({
74
+ pageSize
75
+ });
76
+ const hasAnyTicket = (counts?.total ?? 0) > 0;
77
+ const filtered = status !== null || search.trim() !== '';
78
+ const listHeader = /*#__PURE__*/_jsxs(View, {
79
+ style: styles.header,
80
+ children: [header, onNewTicket && /*#__PURE__*/_jsx(Button, {
81
+ title: "New ticket",
82
+ leftIcon: "add",
83
+ onPress: onNewTicket,
84
+ fullWidth: true
85
+ }), error && /*#__PURE__*/_jsxs(View, {
86
+ style: styles.error,
87
+ accessibilityLiveRegion: "polite",
88
+ children: [/*#__PURE__*/_jsx(Text, {
89
+ variant: "bodySmall",
90
+ color: "error",
91
+ children: error.message
92
+ }), /*#__PURE__*/_jsx(Button, {
93
+ title: "Try again",
94
+ size: "sm",
95
+ variant: "ghost",
96
+ onPress: () => void refresh()
97
+ })]
98
+ }), counts && hasAnyTicket && /*#__PURE__*/_jsxs(_Fragment, {
99
+ children: [/*#__PURE__*/_jsx(SearchBar, {
100
+ value: search,
101
+ onChangeText: setSearch,
102
+ debounceMs: SEARCH_DEBOUNCE_MS,
103
+ placeholder: "Search by subject or ticket number",
104
+ accessibilityLabel: "Search tickets",
105
+ testID: "support-ticket-search"
106
+ }), /*#__PURE__*/_jsx(ScrollView, {
107
+ horizontal: true,
108
+ showsHorizontalScrollIndicator: false,
109
+ contentContainerStyle: styles.filters,
110
+ accessibilityRole: "tablist",
111
+ accessibilityLabel: "Filter by status",
112
+ children: statusFilterOptions(counts, status).map(o => /*#__PURE__*/_jsx(Chip, {
113
+ label: `${o.label} (${o.count})`,
114
+ selected: (status ?? ALL) === o.value,
115
+ onPress: () => setStatus(o.value === ALL ? null : o.value),
116
+ accessibilityLabel: `${o.label}, ${o.count} tickets`,
117
+ testID: `support-filter-${o.value}`
118
+ }, o.value))
119
+ })]
120
+ })]
121
+ });
122
+ if (loading && counts === null && !error) {
123
+ return /*#__PURE__*/_jsx(View, {
124
+ style: styles.center,
125
+ testID: testID,
126
+ children: /*#__PURE__*/_jsx(Spinner, {
127
+ accessibilityLabel: "Loading your tickets"
128
+ })
129
+ });
130
+ }
131
+ const empty = () => {
132
+ if (loading) {
133
+ return /*#__PURE__*/_jsx(View, {
134
+ style: styles.footer,
135
+ children: /*#__PURE__*/_jsx(Spinner, {
136
+ accessibilityLabel: "Loading your tickets"
137
+ })
138
+ });
139
+ }
140
+ if (error) {
141
+ return null;
142
+ }
143
+ if (!hasAnyTicket) {
144
+ return /*#__PURE__*/_jsx(EmptyState, {
145
+ title: "No tickets yet",
146
+ description: "Raise a ticket and it will show here with its status.",
147
+ action: onNewTicket ? {
148
+ label: 'New ticket',
149
+ onPress: onNewTicket
150
+ } : undefined
151
+ });
152
+ }
153
+ return /*#__PURE__*/_jsx(EmptyState, {
154
+ title: "No tickets match",
155
+ description: filtered ? 'Try another status or search.' : undefined
156
+ });
157
+ };
158
+ const footer = loadingMore ? /*#__PURE__*/_jsx(View, {
159
+ style: styles.footer,
160
+ children: /*#__PURE__*/_jsx(Spinner, {
161
+ size: "sm",
162
+ accessibilityLabel: "Loading more tickets"
163
+ })
164
+ }) : loadMoreError ? /*#__PURE__*/_jsxs(View, {
165
+ style: [styles.footer, styles.error],
166
+ accessibilityLiveRegion: "polite",
167
+ children: [/*#__PURE__*/_jsx(Text, {
168
+ variant: "bodySmall",
169
+ color: "error",
170
+ children: loadMoreError.message
171
+ }), /*#__PURE__*/_jsx(Button, {
172
+ title: "Load more",
173
+ size: "sm",
174
+ variant: "ghost",
175
+ onPress: () => void loadMore()
176
+ })]
177
+ }) : null;
178
+ return /*#__PURE__*/_jsx(FlatList, {
179
+ testID: testID,
180
+ data: tickets,
181
+ keyExtractor: t => t.id,
182
+ contentContainerStyle: styles.content,
183
+ ListHeaderComponent: listHeader,
184
+ ListFooterComponent: footer,
185
+ onEndReached: () => void loadMore(),
186
+ onEndReachedThreshold: 0.5,
187
+ refreshControl: /*#__PURE__*/_jsx(RefreshControl, {
188
+ refreshing: refreshing,
189
+ onRefresh: () => void refresh()
190
+ }),
191
+ ListEmptyComponent: empty(),
192
+ renderItem: ({
193
+ item
194
+ }) => /*#__PURE__*/_jsxs(Card, {
195
+ onPress: () => onOpenTicket(item),
196
+ accessibilityLabel: `${item.subject}, ${item.statusLabel}, ticket ${item.number}`,
197
+ bodyStyle: styles.card,
198
+ testID: `support-ticket-${item.id}`,
199
+ children: [/*#__PURE__*/_jsxs(View, {
200
+ style: styles.cardTop,
201
+ children: [/*#__PURE__*/_jsx(Text, {
202
+ variant: "caption",
203
+ color: "tertiary",
204
+ children: item.number
205
+ }), /*#__PURE__*/_jsx(StatusBadge, {
206
+ label: item.statusLabel,
207
+ tone: STATUS_TONES[item.status] ?? 'neutral',
208
+ size: "sm"
209
+ })]
210
+ }), /*#__PURE__*/_jsx(Text, {
211
+ variant: "title",
212
+ numberOfLines: 2,
213
+ children: item.subject
214
+ }), /*#__PURE__*/_jsx(Text, {
215
+ variant: "bodySmall",
216
+ color: "secondary",
217
+ children: item.category
218
+ }), /*#__PURE__*/_jsxs(View, {
219
+ style: styles.cardBottom,
220
+ children: [/*#__PURE__*/_jsx(Text, {
221
+ variant: "caption",
222
+ color: "tertiary",
223
+ children: `Updated ${formatWhen(item.updatedDateTimeUtc)}`
224
+ }), item.awaitingCustomer && /*#__PURE__*/_jsx(Text, {
225
+ variant: "caption",
226
+ color: "warning",
227
+ children: "Waiting for your reply"
228
+ })]
229
+ })]
230
+ })
231
+ });
232
+ };
233
+ const buildStyles = theme => StyleSheet.create({
234
+ content: {
235
+ padding: theme.spacing.md,
236
+ gap: theme.spacing.sm
237
+ },
238
+ header: {
239
+ gap: theme.spacing.sm,
240
+ marginBottom: theme.spacing.xs
241
+ },
242
+ filters: {
243
+ gap: theme.spacing.xs
244
+ },
245
+ error: {
246
+ gap: theme.spacing.xs,
247
+ alignItems: 'flex-start'
248
+ },
249
+ center: {
250
+ flex: 1,
251
+ alignItems: 'center',
252
+ justifyContent: 'center',
253
+ padding: theme.spacing.xl
254
+ },
255
+ footer: {
256
+ paddingVertical: theme.spacing.md,
257
+ alignItems: 'center'
258
+ },
259
+ card: {
260
+ gap: theme.spacing.xxs
261
+ },
262
+ cardTop: {
263
+ flexDirection: 'row',
264
+ justifyContent: 'space-between',
265
+ alignItems: 'center'
266
+ },
267
+ cardBottom: {
268
+ flexDirection: 'row',
269
+ justifyContent: 'space-between',
270
+ marginTop: theme.spacing.xxs
271
+ }
272
+ });
273
+ //# sourceMappingURL=SupportTicketList.js.map
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+
3
+ import { getDeviceContext } from '@webority/mobile-core';
4
+ import { Platform } from 'react-native';
5
+ const PLATFORM_NAMES = {
6
+ ios: 'iOS',
7
+ android: 'Android'
8
+ };
9
+
10
+ /**
11
+ * The device line a ticket carries as its user agent: platform, OS version and,
12
+ * when the app registered one with mobile-core's `setDeviceContext`, the model.
13
+ */
14
+ export const describeDevice = () => {
15
+ const context = getDeviceContext();
16
+ const platform = PLATFORM_NAMES[Platform.OS] ?? Platform.OS;
17
+ const osVersion = context?.osVersion ?? String(Platform.Version ?? '');
18
+ const parts = [`${platform} ${osVersion}`.trim()];
19
+ if (context?.deviceModel) {
20
+ parts.push(context.deviceModel);
21
+ }
22
+ return parts.join('; ');
23
+ };
24
+ //# sourceMappingURL=device.js.map
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+
3
+ import { File, Paths } from 'expo-file-system';
4
+ const FOLDER = 'support-attachments';
5
+
6
+ /** A server-supplied name reduced to one path segment, so it cannot leave the cache folder. */
7
+ export const safeFileName = name => {
8
+ const last = name.split(/[/\\]/).pop() ?? '';
9
+ const cleaned = Array.from(last).filter(char => (char.codePointAt(0) ?? 0) > 0x1f).join('').trim();
10
+ return cleaned === '' || cleaned === '.' || cleaned === '..' ? 'attachment' : cleaned;
11
+ };
12
+
13
+ /**
14
+ * Writes an attachment to the app's cache and returns its `file://` URI. The id
15
+ * prefix keeps two tickets' `report.pdf` apart. Attachments never change once
16
+ * stored, so a file already on disk is reused rather than fetched again.
17
+ */
18
+ export const saveAttachmentToCache = async (attachment, loadBytes) => {
19
+ const file = new File(Paths.cache, FOLDER, `${attachment.id}-${safeFileName(attachment.fileName)}`);
20
+ if (file.exists && file.size > 0) {
21
+ return file.uri;
22
+ }
23
+ const bytes = await loadBytes();
24
+ file.create({
25
+ intermediates: true,
26
+ overwrite: true
27
+ });
28
+ file.write(new Uint8Array(bytes));
29
+ return file.uri;
30
+ };
31
+ //# sourceMappingURL=files.js.map
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+
3
+ import { formatDateTime } from '@webority/mobile-core';
4
+
5
+ /**
6
+ * A timestamp in the device's own time zone. The backend sends UTC; mobile-core's
7
+ * formatter goes through Intl without pinning a zone, so the reader sees their
8
+ * own clock.
9
+ */
10
+ export const formatWhen = utc => utc ? formatDateTime(utc) || '-' : '-';
11
+
12
+ /** A byte count as a person reads it. */
13
+ export const formatBytes = bytes => {
14
+ if (bytes >= 1024 * 1024) {
15
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
16
+ }
17
+ if (bytes >= 1024) {
18
+ return `${Math.round(bytes / 1024)} KB`;
19
+ }
20
+ return `${bytes} bytes`;
21
+ };
22
+ export const isImage = contentType => contentType.startsWith('image/');
23
+ //# sourceMappingURL=format.js.map