@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.
Files changed (87) hide show
  1. package/lib/commonjs/api.js +93 -0
  2. package/lib/commonjs/api.js.map +1 -0
  3. package/lib/commonjs/components/BottomSheet.js +158 -0
  4. package/lib/commonjs/components/BottomSheet.js.map +1 -0
  5. package/lib/commonjs/components/SupportButton.js +244 -0
  6. package/lib/commonjs/components/SupportButton.js.map +1 -0
  7. package/lib/commonjs/components/icons.js +236 -0
  8. package/lib/commonjs/components/icons.js.map +1 -0
  9. package/lib/commonjs/components/primitives.js +284 -0
  10. package/lib/commonjs/components/primitives.js.map +1 -0
  11. package/lib/commonjs/components/screens/ComposeScreen.js +171 -0
  12. package/lib/commonjs/components/screens/ComposeScreen.js.map +1 -0
  13. package/lib/commonjs/components/screens/HomeScreen.js +227 -0
  14. package/lib/commonjs/components/screens/HomeScreen.js.map +1 -0
  15. package/lib/commonjs/components/screens/ThreadScreen.js +213 -0
  16. package/lib/commonjs/components/screens/ThreadScreen.js.map +1 -0
  17. package/lib/commonjs/index.js +58 -0
  18. package/lib/commonjs/index.js.map +1 -0
  19. package/lib/commonjs/package.json +1 -0
  20. package/lib/commonjs/theme.js +29 -0
  21. package/lib/commonjs/theme.js.map +1 -0
  22. package/lib/commonjs/types.js +14 -0
  23. package/lib/commonjs/types.js.map +1 -0
  24. package/lib/commonjs/utils.js +63 -0
  25. package/lib/commonjs/utils.js.map +1 -0
  26. package/lib/module/api.js +84 -0
  27. package/lib/module/api.js.map +1 -0
  28. package/lib/module/components/BottomSheet.js +153 -0
  29. package/lib/module/components/BottomSheet.js.map +1 -0
  30. package/lib/module/components/SupportButton.js +240 -0
  31. package/lib/module/components/SupportButton.js.map +1 -0
  32. package/lib/module/components/icons.js +225 -0
  33. package/lib/module/components/icons.js.map +1 -0
  34. package/lib/module/components/primitives.js +270 -0
  35. package/lib/module/components/primitives.js.map +1 -0
  36. package/lib/module/components/screens/ComposeScreen.js +167 -0
  37. package/lib/module/components/screens/ComposeScreen.js.map +1 -0
  38. package/lib/module/components/screens/HomeScreen.js +223 -0
  39. package/lib/module/components/screens/HomeScreen.js.map +1 -0
  40. package/lib/module/components/screens/ThreadScreen.js +209 -0
  41. package/lib/module/components/screens/ThreadScreen.js.map +1 -0
  42. package/lib/module/index.js +7 -0
  43. package/lib/module/index.js.map +1 -0
  44. package/lib/module/package.json +1 -0
  45. package/lib/module/theme.js +23 -0
  46. package/lib/module/theme.js.map +1 -0
  47. package/lib/module/types.js +10 -0
  48. package/lib/module/types.js.map +1 -0
  49. package/lib/module/utils.js +57 -0
  50. package/lib/module/utils.js.map +1 -0
  51. package/lib/typescript/api.d.ts +37 -0
  52. package/lib/typescript/api.d.ts.map +1 -0
  53. package/lib/typescript/components/BottomSheet.d.ts +9 -0
  54. package/lib/typescript/components/BottomSheet.d.ts.map +1 -0
  55. package/lib/typescript/components/SupportButton.d.ts +8 -0
  56. package/lib/typescript/components/SupportButton.d.ts.map +1 -0
  57. package/lib/typescript/components/icons.d.ts +32 -0
  58. package/lib/typescript/components/icons.d.ts.map +1 -0
  59. package/lib/typescript/components/primitives.d.ts +60 -0
  60. package/lib/typescript/components/primitives.d.ts.map +1 -0
  61. package/lib/typescript/components/screens/ComposeScreen.d.ts +10 -0
  62. package/lib/typescript/components/screens/ComposeScreen.d.ts.map +1 -0
  63. package/lib/typescript/components/screens/HomeScreen.d.ts +13 -0
  64. package/lib/typescript/components/screens/HomeScreen.d.ts.map +1 -0
  65. package/lib/typescript/components/screens/ThreadScreen.d.ts +10 -0
  66. package/lib/typescript/components/screens/ThreadScreen.d.ts.map +1 -0
  67. package/lib/typescript/index.d.ts +6 -0
  68. package/lib/typescript/index.d.ts.map +1 -0
  69. package/lib/typescript/theme.d.ts +13 -0
  70. package/lib/typescript/theme.d.ts.map +1 -0
  71. package/lib/typescript/types.d.ts +61 -0
  72. package/lib/typescript/types.d.ts.map +1 -0
  73. package/lib/typescript/utils.d.ts +11 -0
  74. package/lib/typescript/utils.d.ts.map +1 -0
  75. package/package.json +51 -0
  76. package/src/api.ts +125 -0
  77. package/src/components/BottomSheet.tsx +173 -0
  78. package/src/components/SupportButton.tsx +265 -0
  79. package/src/components/icons.tsx +189 -0
  80. package/src/components/primitives.tsx +298 -0
  81. package/src/components/screens/ComposeScreen.tsx +163 -0
  82. package/src/components/screens/HomeScreen.tsx +234 -0
  83. package/src/components/screens/ThreadScreen.tsx +227 -0
  84. package/src/index.tsx +24 -0
  85. package/src/theme.ts +39 -0
  86. package/src/types.ts +71 -0
  87. 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&apos;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
+ }