@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,61 @@
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
+ export interface SupportUser {
6
+ /** Display name shown to the support team. */
7
+ name: string;
8
+ /** Whether the user is on a paid plan. */
9
+ isPaid: boolean;
10
+ /** RevenueCat app user id — identifies the user's threads. */
11
+ revenuecatId: string;
12
+ /** Host app version, e.g. "2.4.1". */
13
+ appVersion: string;
14
+ }
15
+ export interface SupportButtonProps {
16
+ /** App name as registered in the admin dashboard. Required. */
17
+ appName: string;
18
+ /** Per-app API key (sk_support_…). Required. Keep it in secret config. */
19
+ apiKey: string;
20
+ /** The current end user. Required. */
21
+ user: SupportUser;
22
+ /**
23
+ * Base URL of the Supabase project hosting the support backend,
24
+ * e.g. "https://abcd1234.supabase.co". Required — the SDK talks to its
25
+ * Edge Functions.
26
+ */
27
+ supabaseUrl: string;
28
+ /** Visual theme. Matches the admin dashboard tokens. Default "dark". */
29
+ theme?: ThemeName;
30
+ }
31
+ export interface ThreadMessage {
32
+ id: string;
33
+ sender: MessageSender;
34
+ body: string;
35
+ created_at: string;
36
+ admin_name: string | null;
37
+ }
38
+ export interface Thread {
39
+ id: string;
40
+ type: QueryType;
41
+ status: QueryStatus;
42
+ created_at: string;
43
+ resolved_at: string | null;
44
+ resolved_by_name: string | null;
45
+ app_version: string;
46
+ unread_count: number;
47
+ last_message: {
48
+ body: string;
49
+ sender: MessageSender;
50
+ created_at: string;
51
+ } | null;
52
+ messages: ThreadMessage[];
53
+ }
54
+ export interface ThreadsResponse {
55
+ threads: Thread[];
56
+ total_unread: number;
57
+ }
58
+ export declare const QUERY_TYPE_LABEL: Record<QueryType, string>;
59
+ export declare const USER_MESSAGE_MIN = 10;
60
+ export declare const USER_MESSAGE_MAX = 300;
61
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,SAAS,GAAG,UAAU,GAAG,iBAAiB,GAAG,YAAY,CAAC;AACtE,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,UAAU,CAAC;AAC9C,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,CAAC;AAC7C,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,OAAO,CAAC;AAEzC,MAAM,WAAW,WAAW;IAC1B,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,0CAA0C;IAC1C,MAAM,EAAE,OAAO,CAAC;IAChB,8DAA8D;IAC9D,YAAY,EAAE,MAAM,CAAC;IACrB,sCAAsC;IACtC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,kBAAkB;IACjC,+DAA+D;IAC/D,OAAO,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,IAAI,EAAE,WAAW,CAAC;IAClB;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,wEAAwE;IACxE,KAAK,CAAC,EAAE,SAAS,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,aAAa,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,MAAM;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,WAAW,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE;QACZ,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,aAAa,CAAC;QACtB,UAAU,EAAE,MAAM,CAAC;KACpB,GAAG,IAAI,CAAC;IACT,QAAQ,EAAE,aAAa,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,SAAS,EAAE,MAAM,CAItD,CAAC;AAEF,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,eAAO,MAAM,gBAAgB,MAAM,CAAC"}
@@ -0,0 +1,11 @@
1
+ /** "2h ago" relative time — mirrors the web dashboard's formatting. */
2
+ export declare function relativeTime(iso: string): string;
3
+ export declare function formatDate(iso: string): string;
4
+ /** Dev-mode prop validation with actionable messages. */
5
+ export declare function validateProps(props: {
6
+ appName?: unknown;
7
+ apiKey?: unknown;
8
+ supabaseUrl?: unknown;
9
+ user?: unknown;
10
+ }): string | null;
11
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/utils.ts"],"names":[],"mappings":"AAAA,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAehD;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAM9C;AAED,yDAAyD;AACzD,wBAAgB,aAAa,CAAC,KAAK,EAAE;IACnC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB,GAAG,MAAM,GAAG,IAAI,CA8BhB"}
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@appsupportkit/support-sdk",
3
+ "version": "0.1.0",
4
+ "description": "In-app customer support for React Native: feedback, feature requests, and bug reports with two-way threads.",
5
+ "main": "./lib/commonjs/index.js",
6
+ "module": "./lib/module/index.js",
7
+ "types": "./lib/typescript/index.d.ts",
8
+ "react-native": "./src/index.tsx",
9
+ "source": "./src/index.tsx",
10
+ "files": [
11
+ "src",
12
+ "lib"
13
+ ],
14
+ "keywords": [
15
+ "react-native",
16
+ "support",
17
+ "feedback",
18
+ "in-app"
19
+ ],
20
+ "license": "MIT",
21
+ "dependencies": {
22
+ "@appsupportkit/design-tokens": "^0.1.0"
23
+ },
24
+ "peerDependencies": {
25
+ "react": ">=18.0.0",
26
+ "react-native": ">=0.73.0",
27
+ "react-native-safe-area-context": ">=4.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/react": "^19.0.2",
31
+ "react": "19.0.0",
32
+ "react-native": "0.79.5",
33
+ "react-native-builder-bob": "^0.35.2",
34
+ "react-native-safe-area-context": "^5.4.0",
35
+ "typescript": "^5.7.2"
36
+ },
37
+ "react-native-builder-bob": {
38
+ "source": "src",
39
+ "output": "lib",
40
+ "targets": [
41
+ "commonjs",
42
+ "module",
43
+ "typescript"
44
+ ]
45
+ },
46
+ "scripts": {
47
+ "build": "bob build",
48
+ "typecheck": "tsc --noEmit",
49
+ "lint": "eslint src"
50
+ }
51
+ }
package/src/api.ts ADDED
@@ -0,0 +1,125 @@
1
+ // Thin client for the Supabase Edge Functions. Every call authenticates with
2
+ // the per-app x-api-key header; no Supabase JS client is needed on-device.
3
+ import type { QueryType, ThreadsResponse } from './types';
4
+
5
+ export interface ApiConfig {
6
+ supabaseUrl: string;
7
+ apiKey: string;
8
+ appName: string;
9
+ }
10
+
11
+ export class SupportApiError extends Error {
12
+ status: number | null;
13
+ /** True for network-level failures (likely offline). */
14
+ offline: boolean;
15
+
16
+ constructor(message: string, status: number | null, offline = false) {
17
+ super(message);
18
+ this.name = 'SupportApiError';
19
+ this.status = status;
20
+ this.offline = offline;
21
+ }
22
+ }
23
+
24
+ async function request<T>(
25
+ config: ApiConfig,
26
+ path: string,
27
+ init: RequestInit,
28
+ ): Promise<T> {
29
+ const url = `${config.supabaseUrl.replace(/\/$/, '')}/functions/v1/${path}`;
30
+ let response: Response;
31
+ try {
32
+ response = await fetch(url, {
33
+ ...init,
34
+ headers: {
35
+ 'Content-Type': 'application/json',
36
+ 'x-api-key': config.apiKey,
37
+ ...init.headers,
38
+ },
39
+ });
40
+ } catch {
41
+ throw new SupportApiError('Network request failed', null, true);
42
+ }
43
+
44
+ let payload: unknown = null;
45
+ try {
46
+ payload = await response.json();
47
+ } catch {
48
+ // Non-JSON error body — fall through with status only.
49
+ }
50
+
51
+ if (!response.ok) {
52
+ const message =
53
+ payload !== null &&
54
+ typeof payload === 'object' &&
55
+ 'error' in payload &&
56
+ typeof (payload as { error: unknown }).error === 'string'
57
+ ? (payload as { error: string }).error
58
+ : `Request failed (${response.status})`;
59
+ throw new SupportApiError(message, response.status);
60
+ }
61
+
62
+ return payload as T;
63
+ }
64
+
65
+ export function getThreads(
66
+ config: ApiConfig,
67
+ revenuecatId: string,
68
+ ): Promise<ThreadsResponse> {
69
+ const params = new URLSearchParams({ revenuecat_id: revenuecatId });
70
+ return request<ThreadsResponse>(config, `get-threads?${params.toString()}`, {
71
+ method: 'GET',
72
+ });
73
+ }
74
+
75
+ export function submitQuery(
76
+ config: ApiConfig,
77
+ input: {
78
+ queryType: QueryType;
79
+ message: string;
80
+ userName: string;
81
+ isPaid: boolean;
82
+ revenuecatId: string;
83
+ appVersion: string;
84
+ },
85
+ ): Promise<{ query_id: string }> {
86
+ return request(config, 'submit-query', {
87
+ method: 'POST',
88
+ body: JSON.stringify({
89
+ app_name: config.appName,
90
+ query_type: input.queryType,
91
+ message: input.message,
92
+ user_name: input.userName,
93
+ is_paid: input.isPaid,
94
+ revenuecat_id: input.revenuecatId,
95
+ app_version: input.appVersion,
96
+ }),
97
+ });
98
+ }
99
+
100
+ export function postMessage(
101
+ config: ApiConfig,
102
+ input: { queryId: string; revenuecatId: string; message: string },
103
+ ): Promise<{ message_id: string }> {
104
+ return request(config, 'post-message', {
105
+ method: 'POST',
106
+ body: JSON.stringify({
107
+ query_id: input.queryId,
108
+ revenuecat_id: input.revenuecatId,
109
+ message: input.message,
110
+ }),
111
+ });
112
+ }
113
+
114
+ export function markRead(
115
+ config: ApiConfig,
116
+ input: { queryId: string; revenuecatId: string },
117
+ ): Promise<{ ok: boolean }> {
118
+ return request(config, 'mark-read', {
119
+ method: 'POST',
120
+ body: JSON.stringify({
121
+ query_id: input.queryId,
122
+ revenuecat_id: input.revenuecatId,
123
+ }),
124
+ });
125
+ }
@@ -0,0 +1,173 @@
1
+ // Bottom sheet built on the built-in Modal + Animated APIs.
2
+ //
3
+ // Why no sheet library: the SDK needs exactly one detent, a drag handle, a
4
+ // backdrop fade, and a 180ms ease-out slide — all achievable with core APIs.
5
+ // Skipping reanimated/gesture-handler keeps the package free of native
6
+ // dependencies and install friction for consumers.
7
+ import { useCallback, useEffect, useRef, type ReactNode } from 'react';
8
+ import {
9
+ Animated,
10
+ Easing,
11
+ Modal,
12
+ PanResponder,
13
+ Pressable,
14
+ useWindowDimensions,
15
+ View,
16
+ } from 'react-native';
17
+ import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
18
+ import type { SupportTheme } from '../theme';
19
+
20
+ const SLIDE_MS = 180; // motion.slow — ease-out, no springs, no bounce.
21
+
22
+ export function BottomSheet({
23
+ visible,
24
+ onClose,
25
+ theme,
26
+ children,
27
+ }: {
28
+ visible: boolean;
29
+ onClose: () => void;
30
+ theme: SupportTheme;
31
+ children: ReactNode;
32
+ }) {
33
+ const { height: windowHeight } = useWindowDimensions();
34
+ const sheetHeight = Math.min(windowHeight * 0.82, 640);
35
+
36
+ const translateY = useRef(new Animated.Value(sheetHeight)).current;
37
+ const backdrop = useRef(new Animated.Value(0)).current;
38
+
39
+ const animateIn = useCallback(() => {
40
+ Animated.parallel([
41
+ Animated.timing(translateY, {
42
+ toValue: 0,
43
+ duration: SLIDE_MS,
44
+ easing: Easing.out(Easing.cubic),
45
+ useNativeDriver: true,
46
+ }),
47
+ Animated.timing(backdrop, {
48
+ toValue: 1,
49
+ duration: SLIDE_MS,
50
+ easing: Easing.out(Easing.cubic),
51
+ useNativeDriver: true,
52
+ }),
53
+ ]).start();
54
+ }, [translateY, backdrop]);
55
+
56
+ const animateOut = useCallback(
57
+ (done: () => void) => {
58
+ Animated.parallel([
59
+ Animated.timing(translateY, {
60
+ toValue: sheetHeight,
61
+ duration: SLIDE_MS,
62
+ easing: Easing.out(Easing.cubic),
63
+ useNativeDriver: true,
64
+ }),
65
+ Animated.timing(backdrop, {
66
+ toValue: 0,
67
+ duration: SLIDE_MS,
68
+ easing: Easing.out(Easing.cubic),
69
+ useNativeDriver: true,
70
+ }),
71
+ ]).start(({ finished }) => {
72
+ if (finished) done();
73
+ });
74
+ },
75
+ [translateY, backdrop, sheetHeight],
76
+ );
77
+
78
+ useEffect(() => {
79
+ if (visible) animateIn();
80
+ }, [visible, animateIn]);
81
+
82
+ const requestClose = useCallback(() => {
83
+ animateOut(onClose);
84
+ }, [animateOut, onClose]);
85
+
86
+ // Drag handle: follow the finger downward; past 25% (or a flick), dismiss.
87
+ const panResponder = useRef(
88
+ PanResponder.create({
89
+ onStartShouldSetPanResponder: () => true,
90
+ onMoveShouldSetPanResponder: (_e, g) => Math.abs(g.dy) > 4,
91
+ onPanResponderMove: (_e, g) => {
92
+ if (g.dy > 0) translateY.setValue(g.dy);
93
+ },
94
+ onPanResponderRelease: (_e, g) => {
95
+ if (g.dy > sheetHeight * 0.25 || g.vy > 1.2) {
96
+ requestClose();
97
+ } else {
98
+ Animated.timing(translateY, {
99
+ toValue: 0,
100
+ duration: SLIDE_MS,
101
+ easing: Easing.out(Easing.cubic),
102
+ useNativeDriver: true,
103
+ }).start();
104
+ }
105
+ },
106
+ }),
107
+ ).current;
108
+
109
+ if (!visible) return null;
110
+
111
+ return (
112
+ <Modal
113
+ visible
114
+ transparent
115
+ animationType="none"
116
+ onRequestClose={requestClose}
117
+ statusBarTranslucent
118
+ >
119
+ <SafeAreaProvider>
120
+ <View style={{ flex: 1, justifyContent: 'flex-end' }}>
121
+ <Animated.View
122
+ style={{
123
+ position: 'absolute',
124
+ top: 0,
125
+ right: 0,
126
+ bottom: 0,
127
+ left: 0,
128
+ backgroundColor: 'rgba(0,0,0,0.5)',
129
+ opacity: backdrop,
130
+ }}
131
+ >
132
+ <Pressable
133
+ style={{ flex: 1 }}
134
+ onPress={requestClose}
135
+ accessibilityLabel="Close support"
136
+ />
137
+ </Animated.View>
138
+
139
+ <Animated.View
140
+ style={{
141
+ height: sheetHeight,
142
+ backgroundColor: theme.colors.overlay,
143
+ borderTopLeftRadius: theme.radii.sheet,
144
+ borderTopRightRadius: theme.radii.sheet,
145
+ borderWidth: 1,
146
+ borderBottomWidth: 0,
147
+ borderColor: theme.colors.border,
148
+ transform: [{ translateY }],
149
+ }}
150
+ >
151
+ {/* Drag handle */}
152
+ <View
153
+ {...panResponder.panHandlers}
154
+ style={{ alignItems: 'center', paddingVertical: 10 }}
155
+ >
156
+ <View
157
+ style={{
158
+ width: 32,
159
+ height: 4,
160
+ borderRadius: 2,
161
+ backgroundColor: theme.colors.borderStrong,
162
+ }}
163
+ />
164
+ </View>
165
+ <SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
166
+ {children}
167
+ </SafeAreaView>
168
+ </Animated.View>
169
+ </View>
170
+ </SafeAreaProvider>
171
+ </Modal>
172
+ );
173
+ }
@@ -0,0 +1,265 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Pressable, Text, View } from 'react-native';
3
+ import { getThreads, markRead, postMessage, submitQuery, SupportApiError, type ApiConfig } from '../api';
4
+ import { resolveTheme } from '../theme';
5
+ import type { QueryType, SupportButtonProps, Thread } from '../types';
6
+ import { validateProps } from '../utils';
7
+ import { BottomSheet } from './BottomSheet';
8
+ import { BubbleMark } from './icons';
9
+ import { ComposeScreen } from './screens/ComposeScreen';
10
+ import { HomeScreen } from './screens/HomeScreen';
11
+ import { ThreadScreen } from './screens/ThreadScreen';
12
+
13
+ type Screen =
14
+ | { name: 'home' }
15
+ | { name: 'compose'; type: QueryType }
16
+ | { name: 'thread'; threadId: string };
17
+
18
+ /**
19
+ * The single entry point of the SDK: a button with an unread badge that
20
+ * opens the support bottom sheet. Polls get-threads on mount and whenever
21
+ * the sheet opens (no websockets in v1).
22
+ */
23
+ export function SupportButton(props: SupportButtonProps) {
24
+ const validationError = validateProps(props);
25
+ const theme = resolveTheme(props.theme);
26
+
27
+ const config: ApiConfig = useMemo(
28
+ () => ({
29
+ supabaseUrl: props.supabaseUrl,
30
+ apiKey: props.apiKey,
31
+ appName: props.appName,
32
+ }),
33
+ [props.supabaseUrl, props.apiKey, props.appName],
34
+ );
35
+
36
+ const [sheetOpen, setSheetOpen] = useState(false);
37
+ const [screen, setScreen] = useState<Screen>({ name: 'home' });
38
+ const [threads, setThreads] = useState<Thread[] | null>(null);
39
+ const [totalUnread, setTotalUnread] = useState(0);
40
+ const [loading, setLoading] = useState(false);
41
+ const [error, setError] = useState<string | null>(null);
42
+ const mounted = useRef(true);
43
+
44
+ useEffect(() => {
45
+ mounted.current = true;
46
+ return () => {
47
+ mounted.current = false;
48
+ };
49
+ }, []);
50
+
51
+ const refresh = useCallback(async () => {
52
+ if (validationError) return;
53
+ setLoading(true);
54
+ setError(null);
55
+ try {
56
+ const data = await getThreads(config, props.user.revenuecatId);
57
+ if (!mounted.current) return;
58
+ setThreads(data.threads);
59
+ setTotalUnread(data.total_unread);
60
+ } catch (e) {
61
+ if (!mounted.current) return;
62
+ setError(
63
+ e instanceof SupportApiError && e.offline
64
+ ? "You're offline — check your connection"
65
+ : "Couldn't load threads",
66
+ );
67
+ } finally {
68
+ if (mounted.current) setLoading(false);
69
+ }
70
+ }, [config, props.user.revenuecatId, validationError]);
71
+
72
+ // Poll on mount…
73
+ useEffect(() => {
74
+ void refresh();
75
+ }, [refresh]);
76
+
77
+ // …and on sheet open.
78
+ function openSheet() {
79
+ setScreen({ name: 'home' });
80
+ setSheetOpen(true);
81
+ void refresh();
82
+ }
83
+
84
+ function closeSheet() {
85
+ setSheetOpen(false);
86
+ setScreen({ name: 'home' });
87
+ }
88
+
89
+ if (validationError) {
90
+ // Dev-mode: loud and actionable. Production: render nothing rather than
91
+ // a broken button.
92
+ if (__DEV__) {
93
+ console.error(validationError);
94
+ return (
95
+ <View
96
+ style={{
97
+ padding: 8,
98
+ borderWidth: 1,
99
+ borderColor: theme.colors.statusDanger,
100
+ borderRadius: theme.radii.control,
101
+ }}
102
+ >
103
+ <Text style={{ fontSize: 11, color: theme.colors.statusDanger }}>
104
+ {validationError}
105
+ </Text>
106
+ </View>
107
+ );
108
+ }
109
+ return null;
110
+ }
111
+
112
+ function openThread(thread: Thread) {
113
+ setScreen({ name: 'thread', threadId: thread.id });
114
+ if (thread.unread_count > 0) {
115
+ // Optimistically clear the badge; persist via mark-read.
116
+ setThreads(
117
+ (prev) =>
118
+ prev?.map((t) =>
119
+ t.id === thread.id ? { ...t, unread_count: 0 } : t,
120
+ ) ?? null,
121
+ );
122
+ setTotalUnread((n) => Math.max(0, n - thread.unread_count));
123
+ void markRead(config, {
124
+ queryId: thread.id,
125
+ revenuecatId: props.user.revenuecatId,
126
+ }).catch(() => {
127
+ // Non-fatal: the next get-threads reconciles the badge.
128
+ });
129
+ }
130
+ }
131
+
132
+ async function handleSubmit(type: QueryType, message: string) {
133
+ try {
134
+ await submitQuery(config, {
135
+ queryType: type,
136
+ message,
137
+ userName: props.user.name,
138
+ isPaid: props.user.isPaid,
139
+ revenuecatId: props.user.revenuecatId,
140
+ appVersion: props.user.appVersion,
141
+ });
142
+ void refresh();
143
+ return true;
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ async function handleSend(threadId: string, message: string) {
150
+ try {
151
+ await postMessage(config, {
152
+ queryId: threadId,
153
+ revenuecatId: props.user.revenuecatId,
154
+ message,
155
+ });
156
+ await refresh();
157
+ return true;
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ const activeThread =
164
+ screen.name === 'thread'
165
+ ? (threads ?? []).find((t) => t.id === screen.threadId) ?? null
166
+ : null;
167
+
168
+ return (
169
+ <>
170
+ <Pressable
171
+ onPress={openSheet}
172
+ accessibilityRole="button"
173
+ accessibilityLabel={
174
+ totalUnread > 0
175
+ ? `Support, ${totalUnread} unread ${totalUnread === 1 ? 'reply' : 'replies'}`
176
+ : 'Support'
177
+ }
178
+ style={({ pressed }) => ({
179
+ width: 44,
180
+ height: 44,
181
+ borderRadius: 22,
182
+ borderWidth: 1,
183
+ borderColor: theme.colors.border,
184
+ backgroundColor: pressed
185
+ ? theme.colors.surfaceHover
186
+ : theme.colors.surface,
187
+ alignItems: 'center',
188
+ justifyContent: 'center',
189
+ })}
190
+ >
191
+ <BubbleMark color={theme.colors.textPrimary} size={18} />
192
+ {totalUnread > 0 && (
193
+ <View
194
+ style={{
195
+ position: 'absolute',
196
+ top: -3,
197
+ right: -3,
198
+ minWidth: 16,
199
+ height: 16,
200
+ borderRadius: 8,
201
+ paddingHorizontal: 4,
202
+ backgroundColor: theme.colors.accent,
203
+ alignItems: 'center',
204
+ justifyContent: 'center',
205
+ }}
206
+ >
207
+ <Text
208
+ style={{
209
+ fontSize: 10,
210
+ fontWeight: '600',
211
+ color: theme.colors.accentForeground,
212
+ fontVariant: ['tabular-nums'],
213
+ }}
214
+ >
215
+ {totalUnread > 9 ? '9+' : totalUnread}
216
+ </Text>
217
+ </View>
218
+ )}
219
+ </Pressable>
220
+
221
+ <BottomSheet visible={sheetOpen} onClose={closeSheet} theme={theme}>
222
+ {screen.name === 'home' && (
223
+ <HomeScreen
224
+ theme={theme}
225
+ threads={threads}
226
+ loading={loading}
227
+ error={error}
228
+ onRetry={() => void refresh()}
229
+ onCompose={(type) => setScreen({ name: 'compose', type })}
230
+ onOpenThread={openThread}
231
+ onClose={closeSheet}
232
+ />
233
+ )}
234
+ {screen.name === 'compose' && (
235
+ <ComposeScreen
236
+ theme={theme}
237
+ type={screen.type}
238
+ onBack={() => setScreen({ name: 'home' })}
239
+ onSubmit={(message) => handleSubmit(screen.type, message)}
240
+ />
241
+ )}
242
+ {screen.name === 'thread' &&
243
+ (activeThread ? (
244
+ <ThreadScreen
245
+ theme={theme}
246
+ thread={activeThread}
247
+ onBack={() => setScreen({ name: 'home' })}
248
+ onSend={(message) => handleSend(activeThread.id, message)}
249
+ />
250
+ ) : (
251
+ <HomeScreen
252
+ theme={theme}
253
+ threads={threads}
254
+ loading={loading}
255
+ error={error}
256
+ onRetry={() => void refresh()}
257
+ onCompose={(type) => setScreen({ name: 'compose', type })}
258
+ onOpenThread={openThread}
259
+ onClose={closeSheet}
260
+ />
261
+ ))}
262
+ </BottomSheet>
263
+ </>
264
+ );
265
+ }