@appsupportkit/support-sdk 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/api.js +93 -0
- package/lib/commonjs/api.js.map +1 -0
- package/lib/commonjs/components/BottomSheet.js +158 -0
- package/lib/commonjs/components/BottomSheet.js.map +1 -0
- package/lib/commonjs/components/SupportButton.js +244 -0
- package/lib/commonjs/components/SupportButton.js.map +1 -0
- package/lib/commonjs/components/icons.js +236 -0
- package/lib/commonjs/components/icons.js.map +1 -0
- package/lib/commonjs/components/primitives.js +284 -0
- package/lib/commonjs/components/primitives.js.map +1 -0
- package/lib/commonjs/components/screens/ComposeScreen.js +171 -0
- package/lib/commonjs/components/screens/ComposeScreen.js.map +1 -0
- package/lib/commonjs/components/screens/HomeScreen.js +227 -0
- package/lib/commonjs/components/screens/HomeScreen.js.map +1 -0
- package/lib/commonjs/components/screens/ThreadScreen.js +213 -0
- package/lib/commonjs/components/screens/ThreadScreen.js.map +1 -0
- package/lib/commonjs/index.js +58 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/theme.js +29 -0
- package/lib/commonjs/theme.js.map +1 -0
- package/lib/commonjs/types.js +14 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/commonjs/utils.js +63 -0
- package/lib/commonjs/utils.js.map +1 -0
- package/lib/module/api.js +84 -0
- package/lib/module/api.js.map +1 -0
- package/lib/module/components/BottomSheet.js +153 -0
- package/lib/module/components/BottomSheet.js.map +1 -0
- package/lib/module/components/SupportButton.js +240 -0
- package/lib/module/components/SupportButton.js.map +1 -0
- package/lib/module/components/icons.js +225 -0
- package/lib/module/components/icons.js.map +1 -0
- package/lib/module/components/primitives.js +270 -0
- package/lib/module/components/primitives.js.map +1 -0
- package/lib/module/components/screens/ComposeScreen.js +167 -0
- package/lib/module/components/screens/ComposeScreen.js.map +1 -0
- package/lib/module/components/screens/HomeScreen.js +223 -0
- package/lib/module/components/screens/HomeScreen.js.map +1 -0
- package/lib/module/components/screens/ThreadScreen.js +209 -0
- package/lib/module/components/screens/ThreadScreen.js.map +1 -0
- package/lib/module/index.js +7 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/theme.js +23 -0
- package/lib/module/theme.js.map +1 -0
- package/lib/module/types.js +10 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utils.js +57 -0
- package/lib/module/utils.js.map +1 -0
- package/lib/typescript/api.d.ts +37 -0
- package/lib/typescript/api.d.ts.map +1 -0
- package/lib/typescript/components/BottomSheet.d.ts +9 -0
- package/lib/typescript/components/BottomSheet.d.ts.map +1 -0
- package/lib/typescript/components/SupportButton.d.ts +8 -0
- package/lib/typescript/components/SupportButton.d.ts.map +1 -0
- package/lib/typescript/components/icons.d.ts +32 -0
- package/lib/typescript/components/icons.d.ts.map +1 -0
- package/lib/typescript/components/primitives.d.ts +60 -0
- package/lib/typescript/components/primitives.d.ts.map +1 -0
- package/lib/typescript/components/screens/ComposeScreen.d.ts +10 -0
- package/lib/typescript/components/screens/ComposeScreen.d.ts.map +1 -0
- package/lib/typescript/components/screens/HomeScreen.d.ts +13 -0
- package/lib/typescript/components/screens/HomeScreen.d.ts.map +1 -0
- package/lib/typescript/components/screens/ThreadScreen.d.ts +10 -0
- package/lib/typescript/components/screens/ThreadScreen.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +6 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/theme.d.ts +13 -0
- package/lib/typescript/theme.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +61 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/lib/typescript/utils.d.ts +11 -0
- package/lib/typescript/utils.d.ts.map +1 -0
- package/package.json +51 -0
- package/src/api.ts +125 -0
- package/src/components/BottomSheet.tsx +173 -0
- package/src/components/SupportButton.tsx +265 -0
- package/src/components/icons.tsx +189 -0
- package/src/components/primitives.tsx +298 -0
- package/src/components/screens/ComposeScreen.tsx +163 -0
- package/src/components/screens/HomeScreen.tsx +234 -0
- package/src/components/screens/ThreadScreen.tsx +227 -0
- package/src/index.tsx +24 -0
- package/src/theme.ts +39 -0
- package/src/types.ts +71 -0
- package/src/utils.ts +63 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { ScrollView, Text, View } from 'react-native';
|
|
2
|
+
import type { SupportTheme } from '../../theme';
|
|
3
|
+
import type { QueryType, Thread } from '../../types';
|
|
4
|
+
import { QUERY_TYPE_LABEL } from '../../types';
|
|
5
|
+
import { relativeTime } from '../../utils';
|
|
6
|
+
import { Chevron, CloseIcon } from '../icons';
|
|
7
|
+
import {
|
|
8
|
+
EmptyState,
|
|
9
|
+
ErrorState,
|
|
10
|
+
PressableRow,
|
|
11
|
+
SkeletonBlock,
|
|
12
|
+
textStyles,
|
|
13
|
+
typeColor,
|
|
14
|
+
typeIcon,
|
|
15
|
+
} from '../primitives';
|
|
16
|
+
|
|
17
|
+
const COMPOSE_TYPES: QueryType[] = ['feedback', 'feature_request', 'bug_report'];
|
|
18
|
+
|
|
19
|
+
function ThreadRow({
|
|
20
|
+
thread,
|
|
21
|
+
theme,
|
|
22
|
+
onPress,
|
|
23
|
+
}: {
|
|
24
|
+
thread: Thread;
|
|
25
|
+
theme: SupportTheme;
|
|
26
|
+
onPress: () => void;
|
|
27
|
+
}) {
|
|
28
|
+
const preview = thread.last_message?.body ?? '';
|
|
29
|
+
return (
|
|
30
|
+
<PressableRow
|
|
31
|
+
onPress={onPress}
|
|
32
|
+
theme={theme}
|
|
33
|
+
accessibilityLabel={`${QUERY_TYPE_LABEL[thread.type]} thread`}
|
|
34
|
+
style={{
|
|
35
|
+
paddingHorizontal: 16,
|
|
36
|
+
paddingVertical: 10,
|
|
37
|
+
borderBottomWidth: 1,
|
|
38
|
+
borderBottomColor: theme.colors.border,
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
|
42
|
+
{thread.unread_count > 0 && (
|
|
43
|
+
<View
|
|
44
|
+
style={{
|
|
45
|
+
width: 6,
|
|
46
|
+
height: 6,
|
|
47
|
+
borderRadius: 3,
|
|
48
|
+
backgroundColor: theme.colors.accent,
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
)}
|
|
52
|
+
<Text style={{ fontSize: 11, fontWeight: '500', color: typeColor(thread.type, theme) }}>
|
|
53
|
+
{QUERY_TYPE_LABEL[thread.type]}
|
|
54
|
+
</Text>
|
|
55
|
+
<Text style={[textStyles.meta(theme), { marginLeft: 'auto' }]}>
|
|
56
|
+
{thread.last_message
|
|
57
|
+
? relativeTime(thread.last_message.created_at)
|
|
58
|
+
: relativeTime(thread.created_at)}
|
|
59
|
+
</Text>
|
|
60
|
+
</View>
|
|
61
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 3 }}>
|
|
62
|
+
<Text
|
|
63
|
+
numberOfLines={1}
|
|
64
|
+
style={{
|
|
65
|
+
flex: 1,
|
|
66
|
+
fontSize: 13,
|
|
67
|
+
color:
|
|
68
|
+
thread.unread_count > 0
|
|
69
|
+
? theme.colors.textPrimary
|
|
70
|
+
: theme.colors.textSecondary,
|
|
71
|
+
fontWeight: thread.unread_count > 0 ? '500' : '400',
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
{preview}
|
|
75
|
+
</Text>
|
|
76
|
+
<Chevron color={theme.colors.textTertiary} />
|
|
77
|
+
</View>
|
|
78
|
+
</PressableRow>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function HomeScreen({
|
|
83
|
+
theme,
|
|
84
|
+
threads,
|
|
85
|
+
loading,
|
|
86
|
+
error,
|
|
87
|
+
onRetry,
|
|
88
|
+
onCompose,
|
|
89
|
+
onOpenThread,
|
|
90
|
+
onClose,
|
|
91
|
+
}: {
|
|
92
|
+
theme: SupportTheme;
|
|
93
|
+
threads: Thread[] | null;
|
|
94
|
+
loading: boolean;
|
|
95
|
+
error: string | null;
|
|
96
|
+
onRetry: () => void;
|
|
97
|
+
onCompose: (type: QueryType) => void;
|
|
98
|
+
onOpenThread: (thread: Thread) => void;
|
|
99
|
+
onClose: () => void;
|
|
100
|
+
}) {
|
|
101
|
+
const open = (threads ?? []).filter((t) => t.status === 'open');
|
|
102
|
+
const resolved = (threads ?? []).filter((t) => t.status === 'resolved');
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<View style={{ flex: 1 }}>
|
|
106
|
+
{/* Header */}
|
|
107
|
+
<View
|
|
108
|
+
style={{
|
|
109
|
+
flexDirection: 'row',
|
|
110
|
+
alignItems: 'center',
|
|
111
|
+
paddingHorizontal: 16,
|
|
112
|
+
paddingBottom: 12,
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<Text style={textStyles.title(theme)}>Support</Text>
|
|
116
|
+
<PressableRow
|
|
117
|
+
onPress={onClose}
|
|
118
|
+
theme={theme}
|
|
119
|
+
accessibilityLabel="Close"
|
|
120
|
+
style={{ marginLeft: 'auto', padding: 6, borderRadius: theme.radii.control }}
|
|
121
|
+
>
|
|
122
|
+
<CloseIcon color={theme.colors.textSecondary} size={13} />
|
|
123
|
+
</PressableRow>
|
|
124
|
+
</View>
|
|
125
|
+
|
|
126
|
+
<ScrollView style={{ flex: 1 }}>
|
|
127
|
+
{/* Compose actions */}
|
|
128
|
+
<View
|
|
129
|
+
style={{
|
|
130
|
+
marginHorizontal: 16,
|
|
131
|
+
borderWidth: 1,
|
|
132
|
+
borderColor: theme.colors.border,
|
|
133
|
+
borderRadius: theme.radii.card,
|
|
134
|
+
backgroundColor: theme.colors.surface,
|
|
135
|
+
overflow: 'hidden',
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
{COMPOSE_TYPES.map((type, i) => (
|
|
139
|
+
<PressableRow
|
|
140
|
+
key={type}
|
|
141
|
+
onPress={() => onCompose(type)}
|
|
142
|
+
theme={theme}
|
|
143
|
+
accessibilityLabel={`New ${QUERY_TYPE_LABEL[type].toLowerCase()}`}
|
|
144
|
+
style={{
|
|
145
|
+
flexDirection: 'row',
|
|
146
|
+
alignItems: 'center',
|
|
147
|
+
gap: 10,
|
|
148
|
+
paddingHorizontal: 14,
|
|
149
|
+
paddingVertical: 12,
|
|
150
|
+
borderTopWidth: i === 0 ? 0 : 1,
|
|
151
|
+
borderTopColor: theme.colors.border,
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
{typeIcon(type, typeColor(type, theme))}
|
|
155
|
+
<Text style={{ fontSize: 14, color: theme.colors.textPrimary, flex: 1 }}>
|
|
156
|
+
{QUERY_TYPE_LABEL[type]}
|
|
157
|
+
</Text>
|
|
158
|
+
<Chevron color={theme.colors.textTertiary} />
|
|
159
|
+
</PressableRow>
|
|
160
|
+
))}
|
|
161
|
+
</View>
|
|
162
|
+
|
|
163
|
+
{/* Threads */}
|
|
164
|
+
{error ? (
|
|
165
|
+
<ErrorState message={error} onRetry={onRetry} theme={theme} />
|
|
166
|
+
) : loading && threads === null ? (
|
|
167
|
+
<View style={{ paddingHorizontal: 16, paddingTop: 24, gap: 14 }}>
|
|
168
|
+
<SkeletonBlock width={80} height={12} theme={theme} />
|
|
169
|
+
<SkeletonBlock width="100%" height={40} theme={theme} />
|
|
170
|
+
<SkeletonBlock width="100%" height={40} theme={theme} />
|
|
171
|
+
</View>
|
|
172
|
+
) : (threads ?? []).length === 0 ? (
|
|
173
|
+
<EmptyState message="No queries yet" theme={theme} />
|
|
174
|
+
) : (
|
|
175
|
+
<>
|
|
176
|
+
{open.length > 0 && (
|
|
177
|
+
<>
|
|
178
|
+
<Text
|
|
179
|
+
style={{
|
|
180
|
+
fontSize: 11,
|
|
181
|
+
fontWeight: '500',
|
|
182
|
+
textTransform: 'uppercase',
|
|
183
|
+
letterSpacing: 0.4,
|
|
184
|
+
color: theme.colors.textTertiary,
|
|
185
|
+
paddingHorizontal: 16,
|
|
186
|
+
paddingTop: 20,
|
|
187
|
+
paddingBottom: 6,
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
Open
|
|
191
|
+
</Text>
|
|
192
|
+
{open.map((t) => (
|
|
193
|
+
<ThreadRow
|
|
194
|
+
key={t.id}
|
|
195
|
+
thread={t}
|
|
196
|
+
theme={theme}
|
|
197
|
+
onPress={() => onOpenThread(t)}
|
|
198
|
+
/>
|
|
199
|
+
))}
|
|
200
|
+
</>
|
|
201
|
+
)}
|
|
202
|
+
{resolved.length > 0 && (
|
|
203
|
+
<>
|
|
204
|
+
<Text
|
|
205
|
+
style={{
|
|
206
|
+
fontSize: 11,
|
|
207
|
+
fontWeight: '500',
|
|
208
|
+
textTransform: 'uppercase',
|
|
209
|
+
letterSpacing: 0.4,
|
|
210
|
+
color: theme.colors.textTertiary,
|
|
211
|
+
paddingHorizontal: 16,
|
|
212
|
+
paddingTop: 20,
|
|
213
|
+
paddingBottom: 6,
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
Resolved
|
|
217
|
+
</Text>
|
|
218
|
+
{resolved.map((t) => (
|
|
219
|
+
<ThreadRow
|
|
220
|
+
key={t.id}
|
|
221
|
+
thread={t}
|
|
222
|
+
theme={theme}
|
|
223
|
+
onPress={() => onOpenThread(t)}
|
|
224
|
+
/>
|
|
225
|
+
))}
|
|
226
|
+
</>
|
|
227
|
+
)}
|
|
228
|
+
<View style={{ height: 16 }} />
|
|
229
|
+
</>
|
|
230
|
+
)}
|
|
231
|
+
</ScrollView>
|
|
232
|
+
</View>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
KeyboardAvoidingView,
|
|
4
|
+
Platform,
|
|
5
|
+
ScrollView,
|
|
6
|
+
Text,
|
|
7
|
+
TextInput,
|
|
8
|
+
View,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import type { SupportTheme } from '../../theme';
|
|
11
|
+
import type { Thread } from '../../types';
|
|
12
|
+
import { USER_MESSAGE_MAX, USER_MESSAGE_MIN } from '../../types';
|
|
13
|
+
import { formatDate, relativeTime } from '../../utils';
|
|
14
|
+
import { Chevron } from '../icons';
|
|
15
|
+
import {
|
|
16
|
+
PressableRow,
|
|
17
|
+
PrimaryButton,
|
|
18
|
+
StatusIndicator,
|
|
19
|
+
textStyles,
|
|
20
|
+
TypeChipView,
|
|
21
|
+
} from '../primitives';
|
|
22
|
+
|
|
23
|
+
export function ThreadScreen({
|
|
24
|
+
theme,
|
|
25
|
+
thread,
|
|
26
|
+
onBack,
|
|
27
|
+
onSend,
|
|
28
|
+
}: {
|
|
29
|
+
theme: SupportTheme;
|
|
30
|
+
thread: Thread;
|
|
31
|
+
onBack: () => void;
|
|
32
|
+
/** Resolves true on success. */
|
|
33
|
+
onSend: (message: string) => Promise<boolean>;
|
|
34
|
+
}) {
|
|
35
|
+
const [draft, setDraft] = useState('');
|
|
36
|
+
const [sending, setSending] = useState(false);
|
|
37
|
+
const [failed, setFailed] = useState(false);
|
|
38
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
39
|
+
|
|
40
|
+
const resolved = thread.status === 'resolved';
|
|
41
|
+
const trimmed = draft.trim();
|
|
42
|
+
const valid =
|
|
43
|
+
trimmed.length >= USER_MESSAGE_MIN && trimmed.length <= USER_MESSAGE_MAX;
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
scrollRef.current?.scrollToEnd({ animated: false });
|
|
47
|
+
}, [thread.messages.length]);
|
|
48
|
+
|
|
49
|
+
async function handleSend() {
|
|
50
|
+
if (!valid || sending || resolved) return;
|
|
51
|
+
setSending(true);
|
|
52
|
+
setFailed(false);
|
|
53
|
+
const ok = await onSend(trimmed);
|
|
54
|
+
setSending(false);
|
|
55
|
+
if (ok) setDraft('');
|
|
56
|
+
else setFailed(true);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<KeyboardAvoidingView
|
|
61
|
+
style={{ flex: 1 }}
|
|
62
|
+
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
63
|
+
>
|
|
64
|
+
{/* Header */}
|
|
65
|
+
<View
|
|
66
|
+
style={{
|
|
67
|
+
flexDirection: 'row',
|
|
68
|
+
alignItems: 'center',
|
|
69
|
+
gap: 8,
|
|
70
|
+
paddingHorizontal: 12,
|
|
71
|
+
paddingBottom: 10,
|
|
72
|
+
borderBottomWidth: 1,
|
|
73
|
+
borderBottomColor: theme.colors.border,
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
<PressableRow
|
|
77
|
+
onPress={onBack}
|
|
78
|
+
theme={theme}
|
|
79
|
+
accessibilityLabel="Back"
|
|
80
|
+
style={{ padding: 6, borderRadius: theme.radii.control }}
|
|
81
|
+
>
|
|
82
|
+
<Chevron color={theme.colors.textSecondary} direction="left" size={12} />
|
|
83
|
+
</PressableRow>
|
|
84
|
+
<TypeChipView type={thread.type} theme={theme} />
|
|
85
|
+
<StatusIndicator status={thread.status} theme={theme} />
|
|
86
|
+
<Text style={[textStyles.meta(theme), { marginLeft: 'auto' }]}>
|
|
87
|
+
{relativeTime(thread.created_at)}
|
|
88
|
+
</Text>
|
|
89
|
+
</View>
|
|
90
|
+
|
|
91
|
+
{/* Messages */}
|
|
92
|
+
<ScrollView
|
|
93
|
+
ref={scrollRef}
|
|
94
|
+
style={{ flex: 1 }}
|
|
95
|
+
contentContainerStyle={{ padding: 16, gap: 10 }}
|
|
96
|
+
>
|
|
97
|
+
{thread.messages.map((m) => {
|
|
98
|
+
const own = m.sender === 'user';
|
|
99
|
+
return (
|
|
100
|
+
<View
|
|
101
|
+
key={m.id}
|
|
102
|
+
style={{
|
|
103
|
+
maxWidth: '85%',
|
|
104
|
+
alignSelf: own ? 'flex-end' : 'flex-start',
|
|
105
|
+
alignItems: own ? 'flex-end' : 'flex-start',
|
|
106
|
+
gap: 3,
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
{!own && (
|
|
110
|
+
<Text style={textStyles.meta(theme)}>
|
|
111
|
+
{m.admin_name ?? 'Support'}
|
|
112
|
+
</Text>
|
|
113
|
+
)}
|
|
114
|
+
<View
|
|
115
|
+
style={{
|
|
116
|
+
borderRadius: theme.radii.card,
|
|
117
|
+
paddingHorizontal: 12,
|
|
118
|
+
paddingVertical: 8,
|
|
119
|
+
backgroundColor: own
|
|
120
|
+
? theme.colors.accentMuted
|
|
121
|
+
: theme.colors.surface,
|
|
122
|
+
borderWidth: own ? 0 : 1,
|
|
123
|
+
borderColor: theme.colors.border,
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
<Text style={textStyles.body(theme)}>{m.body}</Text>
|
|
127
|
+
</View>
|
|
128
|
+
<Text style={textStyles.meta(theme)}>
|
|
129
|
+
{relativeTime(m.created_at)}
|
|
130
|
+
</Text>
|
|
131
|
+
</View>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
134
|
+
|
|
135
|
+
{resolved && thread.resolved_at && (
|
|
136
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginVertical: 6 }}>
|
|
137
|
+
<View style={{ flex: 1, height: 1, backgroundColor: theme.colors.border }} />
|
|
138
|
+
<Text style={textStyles.meta(theme)}>
|
|
139
|
+
Resolved by {thread.resolved_by_name ?? 'support'} ·{' '}
|
|
140
|
+
{formatDate(thread.resolved_at)}
|
|
141
|
+
</Text>
|
|
142
|
+
<View style={{ flex: 1, height: 1, backgroundColor: theme.colors.border }} />
|
|
143
|
+
</View>
|
|
144
|
+
)}
|
|
145
|
+
</ScrollView>
|
|
146
|
+
|
|
147
|
+
{/* Composer */}
|
|
148
|
+
<View
|
|
149
|
+
style={{
|
|
150
|
+
borderTopWidth: 1,
|
|
151
|
+
borderTopColor: theme.colors.border,
|
|
152
|
+
paddingHorizontal: 16,
|
|
153
|
+
paddingTop: 10,
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{resolved ? (
|
|
157
|
+
<Text
|
|
158
|
+
style={[
|
|
159
|
+
textStyles.meta(theme),
|
|
160
|
+
{ textAlign: 'center', paddingVertical: 8 },
|
|
161
|
+
]}
|
|
162
|
+
>
|
|
163
|
+
This thread is resolved and read-only
|
|
164
|
+
</Text>
|
|
165
|
+
) : (
|
|
166
|
+
<>
|
|
167
|
+
<TextInput
|
|
168
|
+
value={draft}
|
|
169
|
+
onChangeText={(v) => {
|
|
170
|
+
setDraft(v);
|
|
171
|
+
setFailed(false);
|
|
172
|
+
}}
|
|
173
|
+
multiline
|
|
174
|
+
placeholder="Reply"
|
|
175
|
+
placeholderTextColor={theme.colors.textTertiary}
|
|
176
|
+
style={{
|
|
177
|
+
minHeight: 60,
|
|
178
|
+
maxHeight: 120,
|
|
179
|
+
borderWidth: 1,
|
|
180
|
+
borderColor: theme.colors.border,
|
|
181
|
+
borderRadius: theme.radii.control,
|
|
182
|
+
backgroundColor: theme.colors.surface,
|
|
183
|
+
color: theme.colors.textPrimary,
|
|
184
|
+
fontSize: 14,
|
|
185
|
+
lineHeight: 20,
|
|
186
|
+
padding: 10,
|
|
187
|
+
textAlignVertical: 'top',
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
<View
|
|
191
|
+
style={{
|
|
192
|
+
flexDirection: 'row',
|
|
193
|
+
alignItems: 'center',
|
|
194
|
+
gap: 12,
|
|
195
|
+
paddingVertical: 8,
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
<Text
|
|
199
|
+
style={[
|
|
200
|
+
textStyles.meta(theme),
|
|
201
|
+
draft.length > USER_MESSAGE_MAX && {
|
|
202
|
+
color: theme.colors.statusDanger,
|
|
203
|
+
},
|
|
204
|
+
]}
|
|
205
|
+
>
|
|
206
|
+
{draft.length}/{USER_MESSAGE_MAX}
|
|
207
|
+
</Text>
|
|
208
|
+
{failed && (
|
|
209
|
+
<Text style={{ fontSize: 11, color: theme.colors.statusDanger }}>
|
|
210
|
+
Couldn't send — try again
|
|
211
|
+
</Text>
|
|
212
|
+
)}
|
|
213
|
+
<View style={{ marginLeft: 'auto' }}>
|
|
214
|
+
<PrimaryButton
|
|
215
|
+
label={sending ? 'Sending…' : 'Send'}
|
|
216
|
+
onPress={handleSend}
|
|
217
|
+
disabled={!valid || sending}
|
|
218
|
+
theme={theme}
|
|
219
|
+
/>
|
|
220
|
+
</View>
|
|
221
|
+
</View>
|
|
222
|
+
</>
|
|
223
|
+
)}
|
|
224
|
+
</View>
|
|
225
|
+
</KeyboardAvoidingView>
|
|
226
|
+
);
|
|
227
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { SupportButton } from './components/SupportButton';
|
|
2
|
+
export {
|
|
3
|
+
supportDarkTheme,
|
|
4
|
+
supportLightTheme,
|
|
5
|
+
resolveTheme,
|
|
6
|
+
type SupportTheme,
|
|
7
|
+
} from './theme';
|
|
8
|
+
export { SupportApiError } from './api';
|
|
9
|
+
export type {
|
|
10
|
+
MessageSender,
|
|
11
|
+
QueryStatus,
|
|
12
|
+
QueryType,
|
|
13
|
+
SupportButtonProps,
|
|
14
|
+
SupportUser,
|
|
15
|
+
Thread,
|
|
16
|
+
ThreadMessage,
|
|
17
|
+
ThreadsResponse,
|
|
18
|
+
ThemeName,
|
|
19
|
+
} from './types';
|
|
20
|
+
export {
|
|
21
|
+
QUERY_TYPE_LABEL,
|
|
22
|
+
USER_MESSAGE_MAX,
|
|
23
|
+
USER_MESSAGE_MIN,
|
|
24
|
+
} from './types';
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The SDK consumes the same design tokens as the admin dashboard, so both
|
|
2
|
+
// surfaces look like one product.
|
|
3
|
+
import {
|
|
4
|
+
darkColors,
|
|
5
|
+
lightColors,
|
|
6
|
+
motion,
|
|
7
|
+
radii,
|
|
8
|
+
spacing,
|
|
9
|
+
type ThemeColors,
|
|
10
|
+
} from '@appsupportkit/design-tokens';
|
|
11
|
+
import type { ThemeName } from './types';
|
|
12
|
+
|
|
13
|
+
export interface SupportTheme {
|
|
14
|
+
name: ThemeName;
|
|
15
|
+
colors: ThemeColors;
|
|
16
|
+
spacing: typeof spacing;
|
|
17
|
+
radii: typeof radii;
|
|
18
|
+
motion: typeof motion;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const supportDarkTheme: SupportTheme = {
|
|
22
|
+
name: 'dark',
|
|
23
|
+
colors: darkColors,
|
|
24
|
+
spacing,
|
|
25
|
+
radii,
|
|
26
|
+
motion,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const supportLightTheme: SupportTheme = {
|
|
30
|
+
name: 'light',
|
|
31
|
+
colors: lightColors,
|
|
32
|
+
spacing,
|
|
33
|
+
radii,
|
|
34
|
+
motion,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function resolveTheme(name: ThemeName | undefined): SupportTheme {
|
|
38
|
+
return name === 'light' ? supportLightTheme : supportDarkTheme;
|
|
39
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type QueryType = 'feedback' | 'feature_request' | 'bug_report';
|
|
2
|
+
export type QueryStatus = 'open' | 'resolved';
|
|
3
|
+
export type MessageSender = 'user' | 'admin';
|
|
4
|
+
export type ThemeName = 'dark' | 'light';
|
|
5
|
+
|
|
6
|
+
export interface SupportUser {
|
|
7
|
+
/** Display name shown to the support team. */
|
|
8
|
+
name: string;
|
|
9
|
+
/** Whether the user is on a paid plan. */
|
|
10
|
+
isPaid: boolean;
|
|
11
|
+
/** RevenueCat app user id — identifies the user's threads. */
|
|
12
|
+
revenuecatId: string;
|
|
13
|
+
/** Host app version, e.g. "2.4.1". */
|
|
14
|
+
appVersion: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SupportButtonProps {
|
|
18
|
+
/** App name as registered in the admin dashboard. Required. */
|
|
19
|
+
appName: string;
|
|
20
|
+
/** Per-app API key (sk_support_…). Required. Keep it in secret config. */
|
|
21
|
+
apiKey: string;
|
|
22
|
+
/** The current end user. Required. */
|
|
23
|
+
user: SupportUser;
|
|
24
|
+
/**
|
|
25
|
+
* Base URL of the Supabase project hosting the support backend,
|
|
26
|
+
* e.g. "https://abcd1234.supabase.co". Required — the SDK talks to its
|
|
27
|
+
* Edge Functions.
|
|
28
|
+
*/
|
|
29
|
+
supabaseUrl: string;
|
|
30
|
+
/** Visual theme. Matches the admin dashboard tokens. Default "dark". */
|
|
31
|
+
theme?: ThemeName;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ThreadMessage {
|
|
35
|
+
id: string;
|
|
36
|
+
sender: MessageSender;
|
|
37
|
+
body: string;
|
|
38
|
+
created_at: string;
|
|
39
|
+
admin_name: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Thread {
|
|
43
|
+
id: string;
|
|
44
|
+
type: QueryType;
|
|
45
|
+
status: QueryStatus;
|
|
46
|
+
created_at: string;
|
|
47
|
+
resolved_at: string | null;
|
|
48
|
+
resolved_by_name: string | null;
|
|
49
|
+
app_version: string;
|
|
50
|
+
unread_count: number;
|
|
51
|
+
last_message: {
|
|
52
|
+
body: string;
|
|
53
|
+
sender: MessageSender;
|
|
54
|
+
created_at: string;
|
|
55
|
+
} | null;
|
|
56
|
+
messages: ThreadMessage[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ThreadsResponse {
|
|
60
|
+
threads: Thread[];
|
|
61
|
+
total_unread: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const QUERY_TYPE_LABEL: Record<QueryType, string> = {
|
|
65
|
+
feedback: 'Feedback',
|
|
66
|
+
feature_request: 'Feature request',
|
|
67
|
+
bug_report: 'Bug report',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const USER_MESSAGE_MIN = 10;
|
|
71
|
+
export const USER_MESSAGE_MAX = 300;
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** "2h ago" relative time — mirrors the web dashboard's formatting. */
|
|
2
|
+
export function relativeTime(iso: string): string {
|
|
3
|
+
const then = new Date(iso).getTime();
|
|
4
|
+
const seconds = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
|
5
|
+
if (seconds < 60) return 'just now';
|
|
6
|
+
const minutes = Math.floor(seconds / 60);
|
|
7
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
8
|
+
const hours = Math.floor(minutes / 60);
|
|
9
|
+
if (hours < 24) return `${hours}h ago`;
|
|
10
|
+
const days = Math.floor(hours / 24);
|
|
11
|
+
if (days < 7) return `${days}d ago`;
|
|
12
|
+
const weeks = Math.floor(days / 7);
|
|
13
|
+
if (weeks < 5) return `${weeks}w ago`;
|
|
14
|
+
const months = Math.floor(days / 30);
|
|
15
|
+
if (months < 12) return `${months}mo ago`;
|
|
16
|
+
return `${Math.floor(days / 365)}y ago`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatDate(iso: string): string {
|
|
20
|
+
return new Date(iso).toLocaleDateString(undefined, {
|
|
21
|
+
year: 'numeric',
|
|
22
|
+
month: 'short',
|
|
23
|
+
day: 'numeric',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Dev-mode prop validation with actionable messages. */
|
|
28
|
+
export function validateProps(props: {
|
|
29
|
+
appName?: unknown;
|
|
30
|
+
apiKey?: unknown;
|
|
31
|
+
supabaseUrl?: unknown;
|
|
32
|
+
user?: unknown;
|
|
33
|
+
}): string | null {
|
|
34
|
+
if (typeof props.appName !== 'string' || props.appName.length === 0) {
|
|
35
|
+
return 'SupportButton: `appName` is required — the app name registered in the admin dashboard.';
|
|
36
|
+
}
|
|
37
|
+
if (typeof props.apiKey !== 'string' || props.apiKey.length === 0) {
|
|
38
|
+
return 'SupportButton: `apiKey` is required — the key shown once when the app was registered.';
|
|
39
|
+
}
|
|
40
|
+
if (
|
|
41
|
+
typeof props.supabaseUrl !== 'string' ||
|
|
42
|
+
!/^https?:\/\//.test(props.supabaseUrl)
|
|
43
|
+
) {
|
|
44
|
+
return 'SupportButton: `supabaseUrl` is required — your Supabase project URL, e.g. "https://abcd1234.supabase.co".';
|
|
45
|
+
}
|
|
46
|
+
const user = props.user as Record<string, unknown> | undefined;
|
|
47
|
+
if (!user || typeof user !== 'object') {
|
|
48
|
+
return 'SupportButton: `user` is required — { name, isPaid, revenuecatId, appVersion }.';
|
|
49
|
+
}
|
|
50
|
+
if (typeof user.name !== 'string' || user.name.length === 0) {
|
|
51
|
+
return 'SupportButton: `user.name` is required (string).';
|
|
52
|
+
}
|
|
53
|
+
if (typeof user.isPaid !== 'boolean') {
|
|
54
|
+
return 'SupportButton: `user.isPaid` is required (boolean).';
|
|
55
|
+
}
|
|
56
|
+
if (typeof user.revenuecatId !== 'string' || user.revenuecatId.length === 0) {
|
|
57
|
+
return 'SupportButton: `user.revenuecatId` is required (string) — the RevenueCat app user id.';
|
|
58
|
+
}
|
|
59
|
+
if (typeof user.appVersion !== 'string' || user.appVersion.length === 0) {
|
|
60
|
+
return 'SupportButton: `user.appVersion` is required (string), e.g. "2.4.1".';
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|