@webority/mobile-support 0.0.12 → 0.1.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/lib/commonjs/SupportProvider.js +1 -335
- package/lib/commonjs/client.js +1 -164
- package/lib/commonjs/clipboard.js +1 -10
- package/lib/commonjs/components/Attachments.js +1 -246
- package/lib/commonjs/components/NewSupportTicket.js +1 -210
- package/lib/commonjs/components/PhotoTray.js +1 -141
- package/lib/commonjs/components/SupportContact.js +1 -134
- package/lib/commonjs/components/SupportTicketDetail.js +1 -348
- package/lib/commonjs/components/SupportTicketList.js +1 -279
- package/lib/commonjs/device.js +1 -29
- package/lib/commonjs/files.js +1 -37
- package/lib/commonjs/format.js +1 -29
- package/lib/commonjs/index.js +1 -97
- package/lib/commonjs/photos.js +1 -67
- package/lib/commonjs/report/ReportIssueButton.js +1 -57
- package/lib/commonjs/report/captureAndReport.js +1 -34
- package/lib/commonjs/report/index.js +1 -20
- package/lib/commonjs/share/index.js +1 -44
- package/lib/commonjs/types.js +1 -2
- package/lib/commonjs/validation.js +1 -45
- package/lib/module/SupportProvider.js +1 -323
- package/lib/module/client.js +1 -156
- package/lib/module/clipboard.js +1 -5
- package/lib/module/components/Attachments.js +1 -240
- package/lib/module/components/NewSupportTicket.js +1 -205
- package/lib/module/components/PhotoTray.js +1 -136
- package/lib/module/components/SupportContact.js +1 -129
- package/lib/module/components/SupportTicketDetail.js +1 -343
- package/lib/module/components/SupportTicketList.js +1 -273
- package/lib/module/device.js +1 -24
- package/lib/module/files.js +1 -31
- package/lib/module/format.js +1 -23
- package/lib/module/index.js +1 -10
- package/lib/module/photos.js +1 -61
- package/lib/module/report/ReportIssueButton.js +1 -52
- package/lib/module/report/captureAndReport.js +1 -29
- package/lib/module/report/index.js +1 -12
- package/lib/module/share/index.js +1 -37
- package/lib/module/types.js +0 -2
- package/lib/module/validation.js +1 -38
- package/lib/typescript/commonjs/SupportProvider.d.ts +4 -0
- package/lib/typescript/commonjs/client.d.ts +0 -1
- package/lib/typescript/commonjs/format.d.ts +6 -4
- package/lib/typescript/module/SupportProvider.d.ts +4 -0
- package/lib/typescript/module/client.d.ts +0 -1
- package/lib/typescript/module/format.d.ts +6 -4
- package/package.json +8 -6
|
@@ -1,273 +1 @@
|
|
|
1
|
-
"
|
|
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
|
|
1
|
+
import{Button as e,Card as t,Chip as r,EmptyState as i,SearchBar as o,Spinner as s,StatusBadge as a,Text as n,useTheme as l}from"@webority/mobile-ui";import{useMemo as c}from"react";import{FlatList as d,RefreshControl as p,ScrollView as u,StyleSheet as m,View as g}from"react-native";import{formatWhen as b}from"../format.js";import{useSupportTickets as h,useSupportTimeZone as y}from"../SupportProvider.js";import{jsx as f,jsxs as v,Fragment as S}from"react/jsx-runtime";export const STATUS_TONES={Open:"warning",InProgress:"info",Resolved:"success",Closed:"neutral"};const k=[{value:"Open",label:"Open",count:"open"},{value:"InProgress",label:"In progress",count:"inProgress"},{value:"Resolved",label:"Resolved",count:"resolved"},{value:"Closed",label:"Closed",count:"closed"}],x="all";export const statusFilterOptions=(e,t)=>[{value:x,label:"All",count:e.total},...k.filter(r=>e[r.count]>0||r.value===t).map(t=>({value:t.value,label:t.label,count:e[t.count]}))];export const SupportTicketList=({onOpenTicket:m,onNewTicket:k,header:L,pageSize:T,testID:I="support-ticket-list"})=>{const w=l(),C=c(()=>buildStyles(w),[w]),{tickets:j,counts:P,status:R,search:$,setStatus:D,setSearch:O,loading:z,refreshing:E,loadingMore:N,error:F,loadMoreError:M,refresh:U,loadMore:A}=h({pageSize:T}),B=y(),H=(P?.total??0)>0,W=null!==R||""!==$.trim(),_=v(g,{style:C.header,children:[L,k&&f(e,{title:"New ticket",leftIcon:"add",onPress:k,fullWidth:!0}),F&&v(g,{style:C.error,accessibilityLiveRegion:"polite",children:[f(n,{variant:"bodySmall",color:"error",children:F.message}),f(e,{title:"Try again",size:"sm",variant:"ghost",onPress:()=>{U()}})]}),P&&H&&v(S,{children:[f(o,{value:$,onChangeText:O,debounceMs:400,placeholder:"Search by subject or ticket number",accessibilityLabel:"Search tickets",testID:"support-ticket-search"}),f(u,{horizontal:!0,showsHorizontalScrollIndicator:!1,contentContainerStyle:C.filters,accessibilityRole:"tablist",accessibilityLabel:"Filter by status",children:statusFilterOptions(P,R).map(e=>f(r,{label:`${e.label} (${e.count})`,selected:(R??x)===e.value,onPress:()=>D(e.value===x?null:e.value),accessibilityLabel:`${e.label}, ${e.count} tickets`,testID:`support-filter-${e.value}`},e.value))})]})]});if(z&&null===P&&!F)return f(g,{style:C.center,testID:I,children:f(s,{accessibilityLabel:"Loading your tickets"})});const V=N?f(g,{style:C.footer,children:f(s,{size:"sm",accessibilityLabel:"Loading more tickets"})}):M?v(g,{style:[C.footer,C.error],accessibilityLiveRegion:"polite",children:[f(n,{variant:"bodySmall",color:"error",children:M.message}),f(e,{title:"Load more",size:"sm",variant:"ghost",onPress:()=>{A()}})]}):null;return f(d,{testID:I,data:j,keyExtractor:e=>e.id,contentContainerStyle:C.content,ListHeaderComponent:_,ListFooterComponent:V,onEndReached:()=>{A()},onEndReachedThreshold:.5,refreshControl:f(p,{refreshing:E,onRefresh:()=>{U()}}),ListEmptyComponent:z?f(g,{style:C.footer,children:f(s,{accessibilityLabel:"Loading your tickets"})}):F?null:f(i,H?{title:"No tickets match",description:W?"Try another status or search.":void 0}:{title:"No tickets yet",description:"Raise a ticket and it will show here with its status.",action:k?{label:"New ticket",onPress:k}:void 0}),renderItem:({item:e})=>v(t,{onPress:()=>m(e),accessibilityLabel:`${e.subject}, ${e.statusLabel}, ticket ${e.number}`,bodyStyle:C.card,testID:`support-ticket-${e.id}`,children:[v(g,{style:C.cardTop,children:[f(n,{variant:"caption",color:"tertiary",children:e.number}),f(a,{label:e.statusLabel,tone:STATUS_TONES[e.status]??"neutral",size:"sm"})]}),f(n,{variant:"title",numberOfLines:2,children:e.subject}),f(n,{variant:"bodySmall",color:"secondary",children:e.category}),v(g,{style:C.cardBottom,children:[f(n,{variant:"caption",color:"tertiary",children:`Updated ${b(e.updatedDateTimeUtc,B)}`}),e.awaitingCustomer&&f(n,{variant:"caption",color:"warning",children:"Waiting for your reply"})]})]})})};const buildStyles=e=>m.create({content:{padding:e.spacing.md,gap:e.spacing.sm},header:{gap:e.spacing.sm,marginBottom:e.spacing.xs},filters:{gap:e.spacing.xs},error:{gap:e.spacing.xs,alignItems:"flex-start"},center:{flex:1,alignItems:"center",justifyContent:"center",padding:e.spacing.xl},footer:{paddingVertical:e.spacing.md,alignItems:"center"},card:{gap:e.spacing.xxs},cardTop:{flexDirection:"row",justifyContent:"space-between",alignItems:"center"},cardBottom:{flexDirection:"row",justifyContent:"space-between",marginTop:e.spacing.xxs}});
|
package/lib/module/device.js
CHANGED
|
@@ -1,24 +1 @@
|
|
|
1
|
-
"
|
|
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
|
|
1
|
+
import{getDeviceContext as o}from"@webority/mobile-core";import{Platform as e}from"react-native";const i={ios:"iOS",android:"Android"};export const describeDevice=()=>{const r=o(),t=[`${i[e.OS]??e.OS} ${r?.osVersion??String(e.Version??"")}`.trim()];return r?.deviceModel&&t.push(r.deviceModel),t.join("; ")};
|
package/lib/module/files.js
CHANGED
|
@@ -1,31 +1 @@
|
|
|
1
|
-
"
|
|
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
|
|
1
|
+
import{File as e,Paths as t}from"expo-file-system";export const safeFileName=e=>{const t=e.split(/[/\\]/).pop()??"",r=Array.from(t).filter(e=>(e.codePointAt(0)??0)>31).join("").trim();return""===r||"."===r||".."===r?"attachment":r};export const saveAttachmentToCache=async(r,i)=>{const a=new e(t.cache,"support-attachments",`${r.id}-${safeFileName(r.fileName)}`);if(a.exists&&a.size>0)return a.uri;const n=await i();return a.create({intermediates:!0,overwrite:!0}),a.write(new Uint8Array(n)),a.uri};
|
package/lib/module/format.js
CHANGED
|
@@ -1,23 +1 @@
|
|
|
1
|
-
"
|
|
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
|
|
1
|
+
export const DEFAULT_TIME_ZONE="Asia/Kolkata";export const formatWhen=(t,e="Asia/Kolkata")=>{if(!t)return"-";const o=new Date(t);if(Number.isNaN(o.getTime()))return"-";const r={};for(const t of new Intl.DateTimeFormat("en-US",{timeZone:e,day:"2-digit",month:"short",year:"numeric",hour:"numeric",minute:"2-digit",hour12:!0}).formatToParts(o))r[t.type]=t.value;return`${r.day}-${r.month}-${r.year}, ${r.hour}:${r.minute} ${(r.dayPeriod??"").toUpperCase()}`};export const formatBytes=t=>t>=1048576?`${(t/1048576).toFixed(1)} MB`:t>=1024?`${Math.round(t/1024)} KB`:`${t} bytes`;export const isImage=t=>t.startsWith("image/");
|
package/lib/module/index.js
CHANGED
|
@@ -1,10 +1 @@
|
|
|
1
|
-
"
|
|
2
|
-
|
|
3
|
-
export { createSupportClient, SupportApiError, toSupportApiError } from "./client.js";
|
|
4
|
-
export { NewSupportTicket } from "./components/NewSupportTicket.js";
|
|
5
|
-
export { SupportContact } from "./components/SupportContact.js";
|
|
6
|
-
export { SupportTicketDetail } from "./components/SupportTicketDetail.js";
|
|
7
|
-
export { SupportTicketList } from "./components/SupportTicketList.js";
|
|
8
|
-
export { describeDevice } from "./device.js";
|
|
9
|
-
export { SupportProvider, useCreateSupportTicket, useSupportClient, useSupportConfig, useSupportTicket, useSupportTickets } from "./SupportProvider.js";
|
|
10
|
-
//# sourceMappingURL=index.js.map
|
|
1
|
+
export{createSupportClient,SupportApiError,toSupportApiError}from"./client.js";export{NewSupportTicket}from"./components/NewSupportTicket.js";export{SupportContact}from"./components/SupportContact.js";export{SupportTicketDetail}from"./components/SupportTicketDetail.js";export{SupportTicketList}from"./components/SupportTicketList.js";export{describeDevice}from"./device.js";export{SupportProvider,useCreateSupportTicket,useSupportClient,useSupportConfig,useSupportTicket,useSupportTickets}from"./SupportProvider.js";
|
package/lib/module/photos.js
CHANGED
|
@@ -1,61 +1 @@
|
|
|
1
|
-
"
|
|
2
|
-
|
|
3
|
-
import { compressImage } from '@webority/mobile-core/compressor';
|
|
4
|
-
/** The one place `expo-image-picker` is named. */
|
|
5
|
-
const picker = () => require('expo-image-picker');
|
|
6
|
-
export class PhotoPermissionError extends Error {
|
|
7
|
-
constructor(source) {
|
|
8
|
-
super(source === 'camera' ? 'Allow camera access in Settings to take a photo.' : 'Allow photo access in Settings to attach a photo.');
|
|
9
|
-
this.name = 'PhotoPermissionError';
|
|
10
|
-
this.source = source;
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
// The fleet capture pass: quality 0.8, no editing, then bound to 1920 px.
|
|
15
|
-
const CAPTURE_OPTIONS = {
|
|
16
|
-
mediaTypes: ['images'],
|
|
17
|
-
quality: 0.8,
|
|
18
|
-
allowsEditing: false
|
|
19
|
-
};
|
|
20
|
-
const MAX_SIDE = 1920;
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Lets the person pick or take photos and returns them ready to upload,
|
|
24
|
-
* compressed to fit the product's per-file limit. An empty list means they
|
|
25
|
-
* cancelled.
|
|
26
|
-
*/
|
|
27
|
-
export const pickPhotos = async (source, options) => {
|
|
28
|
-
if (options.maxCount <= 0) {
|
|
29
|
-
return [];
|
|
30
|
-
}
|
|
31
|
-
const m = picker();
|
|
32
|
-
const permission = source === 'camera' ? await m.requestCameraPermissionsAsync() : await m.requestMediaLibraryPermissionsAsync();
|
|
33
|
-
if (!permission.granted) {
|
|
34
|
-
throw new PhotoPermissionError(source);
|
|
35
|
-
}
|
|
36
|
-
const result = source === 'camera' ? await m.launchCameraAsync(CAPTURE_OPTIONS) : await m.launchImageLibraryAsync({
|
|
37
|
-
...CAPTURE_OPTIONS,
|
|
38
|
-
allowsMultipleSelection: options.maxCount > 1,
|
|
39
|
-
selectionLimit: options.maxCount
|
|
40
|
-
});
|
|
41
|
-
if (result.canceled || !result.assets) {
|
|
42
|
-
return [];
|
|
43
|
-
}
|
|
44
|
-
const parts = [];
|
|
45
|
-
for (const asset of result.assets.slice(0, options.maxCount)) {
|
|
46
|
-
const compressed = await compressImage(asset.uri, {
|
|
47
|
-
maxSize: MAX_SIDE,
|
|
48
|
-
maxBytes: options.maxBytes,
|
|
49
|
-
fileSize: asset.fileSize ?? undefined,
|
|
50
|
-
name: asset.fileName ?? `photo-${Date.now()}-${parts.length + 1}`
|
|
51
|
-
});
|
|
52
|
-
parts.push({
|
|
53
|
-
uri: compressed.part.uri,
|
|
54
|
-
name: compressed.part.name,
|
|
55
|
-
type: compressed.part.type,
|
|
56
|
-
size: compressed.size ?? undefined
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
return parts;
|
|
60
|
-
};
|
|
61
|
-
//# sourceMappingURL=photos.js.map
|
|
1
|
+
import{compressImage as e}from"@webority/mobile-core/compressor";export class PhotoPermissionError extends Error{constructor(e){super("camera"===e?"Allow camera access in Settings to take a photo.":"Allow photo access in Settings to attach a photo."),this.name="PhotoPermissionError",this.source=e}}const t={mediaTypes:["images"],quality:.8,allowsEditing:!1};export const pickPhotos=async(a,o)=>{if(o.maxCount<=0)return[];const s=require("expo-image-picker");if(!("camera"===a?await s.requestCameraPermissionsAsync():await s.requestMediaLibraryPermissionsAsync()).granted)throw new PhotoPermissionError(a);const r="camera"===a?await s.launchCameraAsync(t):await s.launchImageLibraryAsync({...t,allowsMultipleSelection:o.maxCount>1,selectionLimit:o.maxCount});if(r.canceled||!r.assets)return[];const i=[];for(const t of r.assets.slice(0,o.maxCount)){const a=await e(t.uri,{maxSize:1920,maxBytes:o.maxBytes,fileSize:t.fileSize??void 0,name:t.fileName??`photo-${Date.now()}-${i.length+1}`});i.push({uri:a.part.uri,name:a.part.name,type:a.part.type,size:a.size??void 0})}return i};
|
|
@@ -1,52 +1 @@
|
|
|
1
|
-
"
|
|
2
|
-
|
|
3
|
-
import { Button } from '@webority/mobile-ui';
|
|
4
|
-
import { useState } from 'react';
|
|
5
|
-
import { captureAndReport } from "./captureAndReport.js";
|
|
6
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
7
|
-
/** A button that screenshots the current screen and hands a prefilled ticket draft to the app. */
|
|
8
|
-
export const ReportIssueButton = ({
|
|
9
|
-
onReport,
|
|
10
|
-
onCaptureError,
|
|
11
|
-
screenName,
|
|
12
|
-
appVersion,
|
|
13
|
-
category,
|
|
14
|
-
subject,
|
|
15
|
-
title = 'Report an issue',
|
|
16
|
-
accessibilityLabel = 'Report an issue with this screen',
|
|
17
|
-
testID = 'support-report-issue',
|
|
18
|
-
...buttonProps
|
|
19
|
-
}) => {
|
|
20
|
-
const [capturing, setCapturing] = useState(false);
|
|
21
|
-
const press = async () => {
|
|
22
|
-
setCapturing(true);
|
|
23
|
-
try {
|
|
24
|
-
onReport(await captureAndReport({
|
|
25
|
-
screenName,
|
|
26
|
-
appVersion,
|
|
27
|
-
category,
|
|
28
|
-
subject
|
|
29
|
-
}));
|
|
30
|
-
} catch (error) {
|
|
31
|
-
onCaptureError?.(error);
|
|
32
|
-
onReport({
|
|
33
|
-
category,
|
|
34
|
-
subject,
|
|
35
|
-
pageUrl: screenName,
|
|
36
|
-
appVersion
|
|
37
|
-
});
|
|
38
|
-
} finally {
|
|
39
|
-
setCapturing(false);
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
return /*#__PURE__*/_jsx(Button, {
|
|
43
|
-
leftIcon: "message-question",
|
|
44
|
-
...buttonProps,
|
|
45
|
-
title: title,
|
|
46
|
-
accessibilityLabel: accessibilityLabel,
|
|
47
|
-
testID: testID,
|
|
48
|
-
loading: capturing,
|
|
49
|
-
onPress: () => void press()
|
|
50
|
-
});
|
|
51
|
-
};
|
|
52
|
-
//# sourceMappingURL=ReportIssueButton.js.map
|
|
1
|
+
import{Button as e}from"@webority/mobile-ui";import{useState as t}from"react";import{captureAndReport as r}from"./captureAndReport.js";import{jsx as s}from"react/jsx-runtime";export const ReportIssueButton=({onReport:o,onCaptureError:i,screenName:a,appVersion:c,category:n,subject:p,title:u="Report an issue",accessibilityLabel:m="Report an issue with this screen",testID:l="support-report-issue",...b})=>{const[y,f]=t(!1);return s(e,{leftIcon:"message-question",...b,title:u,accessibilityLabel:m,testID:l,loading:y,onPress:()=>{(async()=>{f(!0);try{o(await r({screenName:a,appVersion:c,category:n,subject:p}))}catch(e){i?.(e),o({category:n,subject:p,pageUrl:a,appVersion:c})}finally{f(!1)}})()}})};
|
|
@@ -1,29 +1 @@
|
|
|
1
|
-
"
|
|
2
|
-
|
|
3
|
-
import { describeDevice } from "../device.js";
|
|
4
|
-
/** The one place `react-native-view-shot` is named, and only this subpath reaches it. */
|
|
5
|
-
const viewShot = () => require('react-native-view-shot');
|
|
6
|
-
/**
|
|
7
|
-
* Captures what is on screen right now and returns a draft for NewSupportTicket
|
|
8
|
-
* with the image under `screenshots`, plus the page, device and app version.
|
|
9
|
-
*/
|
|
10
|
-
export const captureAndReport = async options => {
|
|
11
|
-
const uri = await viewShot().captureScreen({
|
|
12
|
-
format: 'jpg',
|
|
13
|
-
quality: 0.8,
|
|
14
|
-
result: 'tmpfile'
|
|
15
|
-
});
|
|
16
|
-
return {
|
|
17
|
-
category: options.category,
|
|
18
|
-
subject: options.subject,
|
|
19
|
-
pageUrl: options.screenName,
|
|
20
|
-
userAgent: describeDevice(),
|
|
21
|
-
appVersion: options.appVersion,
|
|
22
|
-
screenshots: [{
|
|
23
|
-
uri,
|
|
24
|
-
name: `screenshot-${Date.now()}.jpg`,
|
|
25
|
-
type: 'image/jpeg'
|
|
26
|
-
}]
|
|
27
|
-
};
|
|
28
|
-
};
|
|
29
|
-
//# sourceMappingURL=captureAndReport.js.map
|
|
1
|
+
import{describeDevice as e}from"../device.js";export const captureAndReport=async t=>{const r=await require("react-native-view-shot").captureScreen({format:"jpg",quality:.8,result:"tmpfile"});return{category:t.category,subject:t.subject,pageUrl:t.screenName,userAgent:e(),appVersion:t.appVersion,screenshots:[{uri:r,name:`screenshot-${Date.now()}.jpg`,type:"image/jpeg"}]}};
|
|
@@ -1,12 +1 @@
|
|
|
1
|
-
"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Report an issue. A separate entry point because it names react-native-view-shot,
|
|
5
|
-
* an optional peer: Metro resolves every require reachable from an import, so
|
|
6
|
-
* keeping it off the main barrel lets an app skip the package when it does not
|
|
7
|
-
* use this button.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export { captureAndReport } from "./captureAndReport.js";
|
|
11
|
-
export { ReportIssueButton } from "./ReportIssueButton.js";
|
|
12
|
-
//# sourceMappingURL=index.js.map
|
|
1
|
+
export{captureAndReport}from"./captureAndReport.js";export{ReportIssueButton}from"./ReportIssueButton.js";
|
|
@@ -1,37 +1 @@
|
|
|
1
|
-
"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Opens a support attachment through the system share sheet, by way of
|
|
5
|
-
* mobile-core's `/share`. A separate entry point because that reaches
|
|
6
|
-
* expo-sharing, an optional peer: Metro resolves every require reachable from an
|
|
7
|
-
* import, so keeping it off the main barrel lets an app skip the package when it
|
|
8
|
-
* does not open files.
|
|
9
|
-
*/
|
|
10
|
-
import { shareFile } from '@webority/mobile-core/share';
|
|
11
|
-
export class SupportShareUnavailableError extends Error {
|
|
12
|
-
constructor() {
|
|
13
|
-
super('Opening files is not available on this device.');
|
|
14
|
-
this.name = 'SupportShareUnavailableError';
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Hands a downloaded attachment to the OS share sheet, where the person can open
|
|
20
|
-
* it in a viewer or save it. Pass it to `SupportProvider` as `openFile`.
|
|
21
|
-
*/
|
|
22
|
-
export const shareSupportFile = async file => {
|
|
23
|
-
try {
|
|
24
|
-
await shareFile(file.uri, {
|
|
25
|
-
mimeType: file.contentType,
|
|
26
|
-
dialogTitle: file.name
|
|
27
|
-
});
|
|
28
|
-
} catch (error) {
|
|
29
|
-
// By name, not instanceof: a second copy of mobile-core would fail instanceof.
|
|
30
|
-
const e = error;
|
|
31
|
-
if (e?.name === 'ShareError' && e.code === 'unavailable') {
|
|
32
|
-
throw new SupportShareUnavailableError();
|
|
33
|
-
}
|
|
34
|
-
throw error;
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
//# sourceMappingURL=index.js.map
|
|
1
|
+
import{shareFile as r}from"@webority/mobile-core/share";export class SupportShareUnavailableError extends Error{constructor(){super("Opening files is not available on this device."),this.name="SupportShareUnavailableError"}}export const shareSupportFile=async e=>{try{await r(e.uri,{mimeType:e.contentType,dialogTitle:e.name})}catch(r){const e=r;if("ShareError"===e?.name&&"unavailable"===e.code)throw new SupportShareUnavailableError;throw r}};
|
package/lib/module/types.js
CHANGED
package/lib/module/validation.js
CHANGED
|
@@ -1,38 +1 @@
|
|
|
1
|
-
"
|
|
2
|
-
|
|
3
|
-
export const validateCategory = (category, config) => config.categories.includes(category) ? undefined : 'Choose one of the listed categories.';
|
|
4
|
-
|
|
5
|
-
/** Limits come from GET /settings, so the product's own values are the only copy. */
|
|
6
|
-
|
|
7
|
-
export const validateSubject = (subject, limits) => {
|
|
8
|
-
const trimmed = subject.trim();
|
|
9
|
-
if (!trimmed) {
|
|
10
|
-
return 'Add a short subject.';
|
|
11
|
-
}
|
|
12
|
-
return trimmed.length > limits.subjectMaxLength ? `Keep the subject under ${limits.subjectMaxLength} characters.` : undefined;
|
|
13
|
-
};
|
|
14
|
-
export const validateBody = (body, limits) => {
|
|
15
|
-
const trimmed = body.trim();
|
|
16
|
-
if (!trimmed) {
|
|
17
|
-
return 'Write a message.';
|
|
18
|
-
}
|
|
19
|
-
return trimmed.length > limits.bodyMaxLength ? `Keep the message under ${limits.bodyMaxLength} characters.` : undefined;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
/** Checks every file on one message against the product's limits. The first problem wins. */
|
|
23
|
-
export const validateFiles = (files, config) => {
|
|
24
|
-
if (files.length > config.maxAttachmentsPerMessage) {
|
|
25
|
-
return `Attach up to ${config.maxAttachmentsPerMessage} files to one message.`;
|
|
26
|
-
}
|
|
27
|
-
for (const file of files) {
|
|
28
|
-
if (config.allowedContentTypes.length > 0 && !config.allowedContentTypes.includes(file.type)) {
|
|
29
|
-
return `${file.name} can't be attached. Use an image, a PDF or a text file.`;
|
|
30
|
-
}
|
|
31
|
-
if (file.size !== undefined && file.size > config.maxAttachmentBytes) {
|
|
32
|
-
const mb = Math.floor(config.maxAttachmentBytes / (1024 * 1024));
|
|
33
|
-
return `${file.name} is too large. The limit is ${mb} MB per file.`;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return undefined;
|
|
37
|
-
};
|
|
38
|
-
//# sourceMappingURL=validation.js.map
|
|
1
|
+
export const validateCategory=(e,t)=>t.categories.includes(e)?void 0:"Choose one of the listed categories.";export const validateSubject=(e,t)=>{const a=e.trim();return a?a.length>t.subjectMaxLength?`Keep the subject under ${t.subjectMaxLength} characters.`:void 0:"Add a short subject."};export const validateBody=(e,t)=>{const a=e.trim();return a?a.length>t.bodyMaxLength?`Keep the message under ${t.bodyMaxLength} characters.`:void 0:"Write a message."};export const validateFiles=(e,t)=>{if(e.length>t.maxAttachmentsPerMessage)return`Attach up to ${t.maxAttachmentsPerMessage} files to one message.`;for(const a of e){if(t.allowedContentTypes.length>0&&!t.allowedContentTypes.includes(a.type))return`${a.name} can't be attached. Use an image, a PDF or a text file.`;if(void 0!==a.size&&a.size>t.maxAttachmentBytes){const e=Math.floor(t.maxAttachmentBytes/1048576);return`${a.name} is too large. The limit is ${e} MB per file.`}}};
|
|
@@ -18,6 +18,8 @@ export interface SupportProviderProps {
|
|
|
18
18
|
* attachments show their name and size only.
|
|
19
19
|
*/
|
|
20
20
|
openFile?: SupportFileOpener;
|
|
21
|
+
/** IANA time zone ticket times show in. Defaults to `Asia/Kolkata` (IST). */
|
|
22
|
+
timeZone?: string;
|
|
21
23
|
children: React.ReactNode;
|
|
22
24
|
}
|
|
23
25
|
export declare const SupportProvider: React.FC<SupportProviderProps>;
|
|
@@ -26,6 +28,8 @@ export declare const useSupportClient: () => SupportClient;
|
|
|
26
28
|
export declare const useAttachmentLoader: () => (attachment: Pick<SupportAttachmentView, "id" | "contentType">) => Promise<string>;
|
|
27
29
|
/** @internal Used by the file rows; null when the app gave no `openFile`. */
|
|
28
30
|
export declare const useAttachmentOpener: () => AttachmentOpener | null;
|
|
31
|
+
/** @internal Used by the ticket screens to format times. */
|
|
32
|
+
export declare const useSupportTimeZone: () => string;
|
|
29
33
|
export interface SupportConfigState {
|
|
30
34
|
config: SupportContactInfo | null;
|
|
31
35
|
loading: boolean;
|
|
@@ -32,7 +32,6 @@ export declare class SupportApiError extends Error {
|
|
|
32
32
|
}
|
|
33
33
|
/** Turns whatever the HTTP client threw into a SupportApiError. */
|
|
34
34
|
export declare const toSupportApiError: (error: unknown) => SupportApiError;
|
|
35
|
-
export declare const encodeBase64: (buffer: ArrayBuffer) => string;
|
|
36
35
|
export interface SupportClient {
|
|
37
36
|
readonly basePath: string;
|
|
38
37
|
getConfig(): Promise<SupportContactInfo>;
|