@harkenapp/sdk-react-native 0.0.1-alpha.1

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 (235) hide show
  1. package/README.md +67 -0
  2. package/app.plugin.cjs +135 -0
  3. package/app.plugin.js +1 -0
  4. package/dist/api/client.d.ts +67 -0
  5. package/dist/api/client.d.ts.map +1 -0
  6. package/dist/api/client.js +163 -0
  7. package/dist/api/client.js.map +1 -0
  8. package/dist/api/errors.d.ts +46 -0
  9. package/dist/api/errors.d.ts.map +1 -0
  10. package/dist/api/errors.js +72 -0
  11. package/dist/api/errors.js.map +1 -0
  12. package/dist/api/index.d.ts +7 -0
  13. package/dist/api/index.d.ts.map +1 -0
  14. package/dist/api/index.js +20 -0
  15. package/dist/api/index.js.map +1 -0
  16. package/dist/api/retry.d.ts +29 -0
  17. package/dist/api/retry.d.ts.map +1 -0
  18. package/dist/api/retry.js +74 -0
  19. package/dist/api/retry.js.map +1 -0
  20. package/dist/attachments/FeedbackSheet.d.ts +88 -0
  21. package/dist/attachments/FeedbackSheet.d.ts.map +1 -0
  22. package/dist/attachments/FeedbackSheet.js +250 -0
  23. package/dist/attachments/FeedbackSheet.js.map +1 -0
  24. package/dist/attachments/index.d.ts +20 -0
  25. package/dist/attachments/index.d.ts.map +1 -0
  26. package/dist/attachments/index.js +40 -0
  27. package/dist/attachments/index.js.map +1 -0
  28. package/dist/components/AttachmentGrid.d.ts +94 -0
  29. package/dist/components/AttachmentGrid.d.ts.map +1 -0
  30. package/dist/components/AttachmentGrid.js +132 -0
  31. package/dist/components/AttachmentGrid.js.map +1 -0
  32. package/dist/components/AttachmentPicker.d.ts +98 -0
  33. package/dist/components/AttachmentPicker.d.ts.map +1 -0
  34. package/dist/components/AttachmentPicker.js +297 -0
  35. package/dist/components/AttachmentPicker.js.map +1 -0
  36. package/dist/components/AttachmentPreview.d.ts +78 -0
  37. package/dist/components/AttachmentPreview.d.ts.map +1 -0
  38. package/dist/components/AttachmentPreview.js +133 -0
  39. package/dist/components/AttachmentPreview.js.map +1 -0
  40. package/dist/components/CategorySelector.d.ts +77 -0
  41. package/dist/components/CategorySelector.d.ts.map +1 -0
  42. package/dist/components/CategorySelector.js +117 -0
  43. package/dist/components/CategorySelector.js.map +1 -0
  44. package/dist/components/FeedbackForm.d.ts +50 -0
  45. package/dist/components/FeedbackForm.d.ts.map +1 -0
  46. package/dist/components/FeedbackForm.js +141 -0
  47. package/dist/components/FeedbackForm.js.map +1 -0
  48. package/dist/components/FeedbackSheet.d.ts +75 -0
  49. package/dist/components/FeedbackSheet.d.ts.map +1 -0
  50. package/dist/components/FeedbackSheet.js +215 -0
  51. package/dist/components/FeedbackSheet.js.map +1 -0
  52. package/dist/components/ThemedButton.d.ts +23 -0
  53. package/dist/components/ThemedButton.d.ts.map +1 -0
  54. package/dist/components/ThemedButton.js +77 -0
  55. package/dist/components/ThemedButton.js.map +1 -0
  56. package/dist/components/ThemedText.d.ts +16 -0
  57. package/dist/components/ThemedText.d.ts.map +1 -0
  58. package/dist/components/ThemedText.js +44 -0
  59. package/dist/components/ThemedText.js.map +1 -0
  60. package/dist/components/ThemedTextInput.d.ts +13 -0
  61. package/dist/components/ThemedTextInput.d.ts.map +1 -0
  62. package/dist/components/ThemedTextInput.js +76 -0
  63. package/dist/components/ThemedTextInput.js.map +1 -0
  64. package/dist/components/UploadStatusOverlay.d.ts +82 -0
  65. package/dist/components/UploadStatusOverlay.d.ts.map +1 -0
  66. package/dist/components/UploadStatusOverlay.js +319 -0
  67. package/dist/components/UploadStatusOverlay.js.map +1 -0
  68. package/dist/components/index.d.ts +19 -0
  69. package/dist/components/index.d.ts.map +1 -0
  70. package/dist/components/index.js +28 -0
  71. package/dist/components/index.js.map +1 -0
  72. package/dist/context/HarkenContext.d.ts +62 -0
  73. package/dist/context/HarkenContext.d.ts.map +1 -0
  74. package/dist/context/HarkenContext.js +128 -0
  75. package/dist/context/HarkenContext.js.map +1 -0
  76. package/dist/context/index.d.ts +3 -0
  77. package/dist/context/index.d.ts.map +1 -0
  78. package/dist/context/index.js +7 -0
  79. package/dist/context/index.js.map +1 -0
  80. package/dist/domain/index.d.ts +3 -0
  81. package/dist/domain/index.d.ts.map +1 -0
  82. package/dist/domain/index.js +7 -0
  83. package/dist/domain/index.js.map +1 -0
  84. package/dist/domain/upload-queue.d.ts +116 -0
  85. package/dist/domain/upload-queue.d.ts.map +1 -0
  86. package/dist/domain/upload-queue.js +34 -0
  87. package/dist/domain/upload-queue.js.map +1 -0
  88. package/dist/hooks/index.d.ts +6 -0
  89. package/dist/hooks/index.d.ts.map +1 -0
  90. package/dist/hooks/index.js +16 -0
  91. package/dist/hooks/index.js.map +1 -0
  92. package/dist/hooks/useAnonymousId.d.ts +28 -0
  93. package/dist/hooks/useAnonymousId.d.ts.map +1 -0
  94. package/dist/hooks/useAnonymousId.js +59 -0
  95. package/dist/hooks/useAnonymousId.js.map +1 -0
  96. package/dist/hooks/useAttachmentPicker.d.ts +84 -0
  97. package/dist/hooks/useAttachmentPicker.d.ts.map +1 -0
  98. package/dist/hooks/useAttachmentPicker.js +181 -0
  99. package/dist/hooks/useAttachmentPicker.js.map +1 -0
  100. package/dist/hooks/useAttachmentStatus.d.ts +51 -0
  101. package/dist/hooks/useAttachmentStatus.d.ts.map +1 -0
  102. package/dist/hooks/useAttachmentStatus.js +69 -0
  103. package/dist/hooks/useAttachmentStatus.js.map +1 -0
  104. package/dist/hooks/useAttachmentUpload.d.ts +101 -0
  105. package/dist/hooks/useAttachmentUpload.d.ts.map +1 -0
  106. package/dist/hooks/useAttachmentUpload.js +293 -0
  107. package/dist/hooks/useAttachmentUpload.js.map +1 -0
  108. package/dist/hooks/useFeedback.d.ts +55 -0
  109. package/dist/hooks/useFeedback.d.ts.map +1 -0
  110. package/dist/hooks/useFeedback.js +96 -0
  111. package/dist/hooks/useFeedback.js.map +1 -0
  112. package/dist/hooks/useHarkenContext.d.ts +25 -0
  113. package/dist/hooks/useHarkenContext.d.ts.map +1 -0
  114. package/dist/hooks/useHarkenContext.js +35 -0
  115. package/dist/hooks/useHarkenContext.js.map +1 -0
  116. package/dist/hooks/useHarkenTheme.d.ts +26 -0
  117. package/dist/hooks/useHarkenTheme.d.ts.map +1 -0
  118. package/dist/hooks/useHarkenTheme.js +36 -0
  119. package/dist/hooks/useHarkenTheme.js.map +1 -0
  120. package/dist/index.d.ts +49 -0
  121. package/dist/index.d.ts.map +1 -0
  122. package/dist/index.js +91 -0
  123. package/dist/index.js.map +1 -0
  124. package/dist/services/index.d.ts +4 -0
  125. package/dist/services/index.d.ts.map +1 -0
  126. package/dist/services/index.js +9 -0
  127. package/dist/services/index.js.map +1 -0
  128. package/dist/services/uploadQueueService.d.ts +193 -0
  129. package/dist/services/uploadQueueService.d.ts.map +1 -0
  130. package/dist/services/uploadQueueService.js +623 -0
  131. package/dist/services/uploadQueueService.js.map +1 -0
  132. package/dist/services/uploadQueueStorage.d.ts +30 -0
  133. package/dist/services/uploadQueueStorage.d.ts.map +1 -0
  134. package/dist/services/uploadQueueStorage.js +77 -0
  135. package/dist/services/uploadQueueStorage.js.map +1 -0
  136. package/dist/storage/IdentityStore.d.ts +38 -0
  137. package/dist/storage/IdentityStore.d.ts.map +1 -0
  138. package/dist/storage/IdentityStore.js +83 -0
  139. package/dist/storage/IdentityStore.js.map +1 -0
  140. package/dist/storage/SecureStoreAdapter.d.ts +28 -0
  141. package/dist/storage/SecureStoreAdapter.d.ts.map +1 -0
  142. package/dist/storage/SecureStoreAdapter.js +52 -0
  143. package/dist/storage/SecureStoreAdapter.js.map +1 -0
  144. package/dist/storage/defaultStorage.d.ts +20 -0
  145. package/dist/storage/defaultStorage.d.ts.map +1 -0
  146. package/dist/storage/defaultStorage.js +131 -0
  147. package/dist/storage/defaultStorage.js.map +1 -0
  148. package/dist/storage/index.d.ts +6 -0
  149. package/dist/storage/index.d.ts.map +1 -0
  150. package/dist/storage/index.js +13 -0
  151. package/dist/storage/index.js.map +1 -0
  152. package/dist/storage/types.d.ts +32 -0
  153. package/dist/storage/types.d.ts.map +1 -0
  154. package/dist/storage/types.js +11 -0
  155. package/dist/storage/types.js.map +1 -0
  156. package/dist/theme/defaults.d.ts +43 -0
  157. package/dist/theme/defaults.d.ts.map +1 -0
  158. package/dist/theme/defaults.js +128 -0
  159. package/dist/theme/defaults.js.map +1 -0
  160. package/dist/theme/index.d.ts +3 -0
  161. package/dist/theme/index.d.ts.map +1 -0
  162. package/dist/theme/index.js +14 -0
  163. package/dist/theme/index.js.map +1 -0
  164. package/dist/theme/types.d.ts +136 -0
  165. package/dist/theme/types.d.ts.map +1 -0
  166. package/dist/theme/types.js +3 -0
  167. package/dist/theme/types.js.map +1 -0
  168. package/dist/types/config.d.ts +100 -0
  169. package/dist/types/config.d.ts.map +1 -0
  170. package/dist/types/config.js +3 -0
  171. package/dist/types/config.js.map +1 -0
  172. package/dist/types/index.d.ts +3 -0
  173. package/dist/types/index.d.ts.map +1 -0
  174. package/dist/types/index.js +3 -0
  175. package/dist/types/index.js.map +1 -0
  176. package/dist/types/openapi.d.ts +601 -0
  177. package/dist/types/openapi.d.ts.map +1 -0
  178. package/dist/types/openapi.js +7 -0
  179. package/dist/types/openapi.js.map +1 -0
  180. package/dist/utils/index.d.ts +2 -0
  181. package/dist/utils/index.d.ts.map +1 -0
  182. package/dist/utils/index.js +6 -0
  183. package/dist/utils/index.js.map +1 -0
  184. package/dist/utils/uuid.d.ts +10 -0
  185. package/dist/utils/uuid.d.ts.map +1 -0
  186. package/dist/utils/uuid.js +60 -0
  187. package/dist/utils/uuid.js.map +1 -0
  188. package/package.json +124 -0
  189. package/src/@types/expo-file-system-legacy.d.ts +13 -0
  190. package/src/api/client.ts +250 -0
  191. package/src/api/errors.ts +84 -0
  192. package/src/api/index.ts +15 -0
  193. package/src/api/retry.ts +99 -0
  194. package/src/attachments/FeedbackSheet.tsx +400 -0
  195. package/src/attachments/index.ts +70 -0
  196. package/src/components/AttachmentGrid.tsx +247 -0
  197. package/src/components/AttachmentPicker.tsx +391 -0
  198. package/src/components/AttachmentPreview.tsx +210 -0
  199. package/src/components/CategorySelector.tsx +174 -0
  200. package/src/components/FeedbackForm.tsx +216 -0
  201. package/src/components/FeedbackSheet.tsx +321 -0
  202. package/src/components/ThemedButton.tsx +127 -0
  203. package/src/components/ThemedText.tsx +65 -0
  204. package/src/components/ThemedTextInput.tsx +65 -0
  205. package/src/components/UploadStatusOverlay.tsx +440 -0
  206. package/src/components/index.ts +39 -0
  207. package/src/context/HarkenContext.tsx +129 -0
  208. package/src/context/index.ts +2 -0
  209. package/src/domain/index.ts +12 -0
  210. package/src/domain/upload-queue.ts +131 -0
  211. package/src/hooks/index.ts +10 -0
  212. package/src/hooks/useAnonymousId.ts +68 -0
  213. package/src/hooks/useAttachmentPicker.ts +243 -0
  214. package/src/hooks/useAttachmentStatus.ts +86 -0
  215. package/src/hooks/useAttachmentUpload.ts +370 -0
  216. package/src/hooks/useFeedback.ts +139 -0
  217. package/src/hooks/useHarkenContext.ts +35 -0
  218. package/src/hooks/useHarkenTheme.ts +36 -0
  219. package/src/index.ts +168 -0
  220. package/src/services/index.ts +11 -0
  221. package/src/services/uploadQueueService.ts +727 -0
  222. package/src/services/uploadQueueStorage.ts +78 -0
  223. package/src/storage/IdentityStore.ts +89 -0
  224. package/src/storage/SecureStoreAdapter.ts +59 -0
  225. package/src/storage/defaultStorage.ts +109 -0
  226. package/src/storage/index.ts +5 -0
  227. package/src/storage/types.ts +34 -0
  228. package/src/theme/defaults.ts +151 -0
  229. package/src/theme/index.ts +23 -0
  230. package/src/theme/types.ts +157 -0
  231. package/src/types/config.ts +112 -0
  232. package/src/types/index.ts +10 -0
  233. package/src/types/openapi.ts +601 -0
  234. package/src/utils/index.ts +1 -0
  235. package/src/utils/uuid.ts +77 -0
@@ -0,0 +1,174 @@
1
+ import React from 'react';
2
+ import { View, Pressable } from 'react-native';
3
+ import type { ViewStyle, StyleProp } from 'react-native';
4
+ import { useHarkenTheme } from '../hooks';
5
+ import { ThemedText } from './ThemedText';
6
+ import type { FeedbackCategory } from '../types';
7
+
8
+ export interface CategoryOption {
9
+ value: FeedbackCategory;
10
+ label: string;
11
+ emoji?: string;
12
+ /** Custom icon element (replaces emoji) */
13
+ icon?: React.ReactNode;
14
+ }
15
+
16
+ export const DEFAULT_CATEGORIES: CategoryOption[] = [
17
+ { value: 'bug', label: 'Bug', emoji: '🐛' },
18
+ { value: 'idea', label: 'Idea', emoji: '💡' },
19
+ { value: 'ux', label: 'UX', emoji: '✨' },
20
+ { value: 'other', label: 'Other', emoji: '💬' },
21
+ ];
22
+
23
+ export interface CategorySelectorProps {
24
+ /** Currently selected category */
25
+ value: FeedbackCategory | null;
26
+ /** Callback when category is selected */
27
+ onChange: (category: FeedbackCategory) => void;
28
+ /** Custom categories (defaults to bug, idea, ux, other) */
29
+ categories?: CategoryOption[];
30
+ /** Disable interaction */
31
+ disabled?: boolean;
32
+ /** Custom renderer for category chips */
33
+ renderCategory?: (
34
+ option: CategoryOption,
35
+ isSelected: boolean,
36
+ onSelect: () => void
37
+ ) => React.ReactNode;
38
+ /** Style for the container */
39
+ style?: StyleProp<ViewStyle>;
40
+ /** Style for unselected chips */
41
+ chipStyle?: StyleProp<ViewStyle>;
42
+ /** Style for selected chips */
43
+ selectedChipStyle?: StyleProp<ViewStyle>;
44
+ }
45
+
46
+ /**
47
+ * Category selector for feedback type.
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * // Basic usage
52
+ * <CategorySelector
53
+ * value={category}
54
+ * onChange={setCategory}
55
+ * />
56
+ *
57
+ * // Custom categories without emojis
58
+ * <CategorySelector
59
+ * value={category}
60
+ * onChange={setCategory}
61
+ * categories={[
62
+ * { value: 'bug', label: 'Report Bug' },
63
+ * { value: 'idea', label: 'Feature Request' },
64
+ * ]}
65
+ * />
66
+ *
67
+ * // With custom icons
68
+ * <CategorySelector
69
+ * value={category}
70
+ * onChange={setCategory}
71
+ * categories={[
72
+ * { value: 'bug', label: 'Bug', icon: <BugIcon /> },
73
+ * { value: 'idea', label: 'Idea', icon: <LightbulbIcon /> },
74
+ * ]}
75
+ * />
76
+ *
77
+ * // Fully custom rendering
78
+ * <CategorySelector
79
+ * value={category}
80
+ * onChange={setCategory}
81
+ * renderCategory={(option, isSelected, onSelect) => (
82
+ * <MyCustomChip
83
+ * key={option.value}
84
+ * selected={isSelected}
85
+ * onPress={onSelect}
86
+ * label={option.label}
87
+ * />
88
+ * )}
89
+ * />
90
+ * ```
91
+ */
92
+ export function CategorySelector({
93
+ value,
94
+ onChange,
95
+ categories = DEFAULT_CATEGORIES,
96
+ disabled = false,
97
+ renderCategory,
98
+ style,
99
+ chipStyle,
100
+ selectedChipStyle,
101
+ }: CategorySelectorProps): React.JSX.Element {
102
+ const theme = useHarkenTheme();
103
+
104
+ const containerStyle: ViewStyle = {
105
+ flexDirection: 'row',
106
+ flexWrap: 'wrap',
107
+ gap: theme.spacing.sm,
108
+ };
109
+
110
+ return (
111
+ <View style={[containerStyle, style]}>
112
+ {categories.map((category) => {
113
+ const isSelected = value === category.value;
114
+ const onSelect = () => onChange(category.value);
115
+
116
+ // Use custom renderer if provided
117
+ if (renderCategory) {
118
+ return (
119
+ <React.Fragment key={category.value}>
120
+ {renderCategory(category, isSelected, onSelect)}
121
+ </React.Fragment>
122
+ );
123
+ }
124
+
125
+ const baseChipStyle: ViewStyle = {
126
+ flexDirection: 'row',
127
+ alignItems: 'center',
128
+ paddingVertical: theme.spacing.sm,
129
+ paddingHorizontal: theme.spacing.md,
130
+ borderRadius: theme.radii.full,
131
+ borderWidth: 1,
132
+ borderColor: isSelected ? theme.colors.primary : theme.colors.border,
133
+ backgroundColor: isSelected
134
+ ? theme.colors.primary
135
+ : theme.colors.backgroundSecondary,
136
+ opacity: disabled ? 0.6 : 1,
137
+ };
138
+
139
+ const textColor = isSelected
140
+ ? theme.colors.textOnPrimary
141
+ : theme.colors.text;
142
+
143
+ return (
144
+ <Pressable
145
+ key={category.value}
146
+ onPress={onSelect}
147
+ disabled={disabled}
148
+ style={({ pressed }) => [
149
+ baseChipStyle,
150
+ chipStyle,
151
+ isSelected && selectedChipStyle,
152
+ pressed && !disabled && {
153
+ opacity: 0.8,
154
+ },
155
+ ]}
156
+ >
157
+ {category.icon ? (
158
+ <View style={{ marginRight: theme.spacing.xs }}>
159
+ {category.icon}
160
+ </View>
161
+ ) : category.emoji ? (
162
+ <ThemedText style={{ marginRight: theme.spacing.xs }}>
163
+ {category.emoji}
164
+ </ThemedText>
165
+ ) : null}
166
+ <ThemedText variant="label" color={textColor}>
167
+ {category.label}
168
+ </ThemedText>
169
+ </Pressable>
170
+ );
171
+ })}
172
+ </View>
173
+ );
174
+ }
@@ -0,0 +1,216 @@
1
+ import React, { useState, useCallback } from 'react';
2
+ import { View, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
3
+ import type { ViewStyle } from 'react-native';
4
+ import { useHarkenTheme } from '../hooks';
5
+ import { ThemedText } from './ThemedText';
6
+ import { ThemedTextInput } from './ThemedTextInput';
7
+ import { ThemedButton } from './ThemedButton';
8
+ import { CategorySelector } from './CategorySelector';
9
+ import type { CategoryOption } from './CategorySelector';
10
+ import type { FeedbackCategory } from '../types';
11
+
12
+ export interface FeedbackFormData {
13
+ message: string;
14
+ category: FeedbackCategory | null;
15
+ }
16
+
17
+ export interface FeedbackFormProps {
18
+ /** Called when form is submitted */
19
+ onSubmit: (data: FeedbackFormData) => void | Promise<void>;
20
+ /** Called when form is cancelled/dismissed */
21
+ onCancel?: () => void;
22
+ /** Title text */
23
+ title?: string;
24
+ /** Placeholder text for message input */
25
+ placeholder?: string;
26
+ /** Submit button text */
27
+ submitLabel?: string;
28
+ /** Cancel button text */
29
+ cancelLabel?: string;
30
+ /** Custom categories */
31
+ categories?: CategoryOption[];
32
+ /** Whether category selection is required */
33
+ requireCategory?: boolean;
34
+ /** Minimum message length */
35
+ minMessageLength?: number;
36
+ /** Maximum message length */
37
+ maxMessageLength?: number;
38
+ /** Loading state (disables form) */
39
+ loading?: boolean;
40
+ /** Initial form values */
41
+ initialValues?: Partial<FeedbackFormData>;
42
+ }
43
+
44
+ /**
45
+ * Feedback composer form component.
46
+ *
47
+ * A minimal, themed form for collecting user feedback.
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * <FeedbackForm
52
+ * onSubmit={async (data) => {
53
+ * await submitFeedback(data);
54
+ * }}
55
+ * onCancel={() => setVisible(false)}
56
+ * />
57
+ * ```
58
+ */
59
+ export function FeedbackForm({
60
+ onSubmit,
61
+ onCancel,
62
+ title = 'Send Feedback',
63
+ placeholder = 'What would you like to share?',
64
+ submitLabel = 'Submit',
65
+ cancelLabel = 'Cancel',
66
+ categories,
67
+ requireCategory = false,
68
+ minMessageLength = 1,
69
+ maxMessageLength = 5000,
70
+ loading = false,
71
+ initialValues,
72
+ }: FeedbackFormProps): React.JSX.Element {
73
+ const theme = useHarkenTheme();
74
+
75
+ const [message, setMessage] = useState(initialValues?.message ?? '');
76
+ const [category, setCategory] = useState<FeedbackCategory | null>(
77
+ initialValues?.category ?? null
78
+ );
79
+ const [isSubmitting, setIsSubmitting] = useState(false);
80
+
81
+ const trimmedMessage = message.trim();
82
+ const isMessageValid =
83
+ trimmedMessage.length >= minMessageLength &&
84
+ trimmedMessage.length <= maxMessageLength;
85
+ const isCategoryValid = !requireCategory || category !== null;
86
+ const canSubmit = isMessageValid && isCategoryValid && !loading && !isSubmitting;
87
+
88
+ const handleSubmit = useCallback(async () => {
89
+ if (!canSubmit) return;
90
+
91
+ setIsSubmitting(true);
92
+ try {
93
+ await onSubmit({
94
+ message: trimmedMessage,
95
+ category,
96
+ });
97
+ } finally {
98
+ setIsSubmitting(false);
99
+ }
100
+ }, [canSubmit, onSubmit, trimmedMessage, category]);
101
+
102
+ const containerStyle: ViewStyle = {
103
+ backgroundColor: theme.colors.background,
104
+ padding: theme.spacing.lg,
105
+ borderRadius: theme.radii.lg,
106
+ };
107
+
108
+ const sectionStyle: ViewStyle = {
109
+ marginBottom: theme.spacing.lg,
110
+ };
111
+
112
+ const buttonRowStyle: ViewStyle = {
113
+ flexDirection: 'row',
114
+ gap: theme.spacing.sm,
115
+ marginTop: theme.spacing.md,
116
+ };
117
+
118
+ const characterCount = trimmedMessage.length;
119
+ const showCharacterWarning = characterCount > maxMessageLength * 0.9;
120
+
121
+ return (
122
+ <KeyboardAvoidingView
123
+ behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
124
+ style={{ flex: 1 }}
125
+ >
126
+ <ScrollView
127
+ contentContainerStyle={{ flexGrow: 1 }}
128
+ keyboardShouldPersistTaps="handled"
129
+ >
130
+ <View style={containerStyle}>
131
+ {/* Title */}
132
+ <View style={sectionStyle}>
133
+ <ThemedText variant="title">{title}</ThemedText>
134
+ </View>
135
+
136
+ {/* Category selector */}
137
+ <View style={sectionStyle}>
138
+ <ThemedText
139
+ variant="label"
140
+ secondary
141
+ style={{ marginBottom: theme.spacing.sm }}
142
+ >
143
+ Category{requireCategory ? '' : ' (optional)'}
144
+ </ThemedText>
145
+ <CategorySelector
146
+ value={category}
147
+ onChange={setCategory}
148
+ categories={categories}
149
+ disabled={loading || isSubmitting}
150
+ />
151
+ </View>
152
+
153
+ {/* Message input */}
154
+ <View style={sectionStyle}>
155
+ <ThemedText
156
+ variant="label"
157
+ secondary
158
+ style={{ marginBottom: theme.spacing.sm }}
159
+ >
160
+ Message
161
+ </ThemedText>
162
+ <ThemedTextInput
163
+ value={message}
164
+ onChangeText={setMessage}
165
+ placeholder={placeholder}
166
+ multiline
167
+ numberOfLines={4}
168
+ textAlignVertical="top"
169
+ editable={!loading && !isSubmitting}
170
+ style={{ minHeight: 120 }}
171
+ maxLength={maxMessageLength + 100} // Allow slight overflow to show warning
172
+ />
173
+ {showCharacterWarning && (
174
+ <ThemedText
175
+ variant="caption"
176
+ color={
177
+ characterCount > maxMessageLength
178
+ ? theme.colors.error
179
+ : theme.colors.textSecondary
180
+ }
181
+ style={{ marginTop: theme.spacing.xs, textAlign: 'right' }}
182
+ >
183
+ {characterCount}/{maxMessageLength}
184
+ </ThemedText>
185
+ )}
186
+ </View>
187
+
188
+ {/* Buttons */}
189
+ <View style={buttonRowStyle}>
190
+ {onCancel && (
191
+ <View style={{ flex: 1 }}>
192
+ <ThemedButton
193
+ title={cancelLabel}
194
+ variant="secondary"
195
+ onPress={onCancel}
196
+ disabled={isSubmitting}
197
+ fullWidth
198
+ />
199
+ </View>
200
+ )}
201
+ <View style={{ flex: onCancel ? 1 : undefined }}>
202
+ <ThemedButton
203
+ title={submitLabel}
204
+ variant="primary"
205
+ onPress={handleSubmit}
206
+ disabled={!canSubmit}
207
+ loading={isSubmitting || loading}
208
+ fullWidth={!!onCancel}
209
+ />
210
+ </View>
211
+ </View>
212
+ </View>
213
+ </ScrollView>
214
+ </KeyboardAvoidingView>
215
+ );
216
+ }
@@ -0,0 +1,321 @@
1
+ import React, { useState, useCallback } from 'react';
2
+ import {
3
+ View,
4
+ KeyboardAvoidingView,
5
+ Platform,
6
+ ScrollView,
7
+ Alert,
8
+ } from 'react-native';
9
+ import type { ViewStyle } from 'react-native';
10
+ import type { components } from '../types/index.js';
11
+ import { useHarkenTheme, useFeedback } from '../hooks';
12
+ import { ThemedText } from './ThemedText';
13
+ import { ThemedTextInput } from './ThemedTextInput';
14
+ import { ThemedButton } from './ThemedButton';
15
+ import { CategorySelector, DEFAULT_CATEGORIES } from './CategorySelector';
16
+ import type { CategoryOption } from './CategorySelector';
17
+ import type { FeedbackCategory } from '../types';
18
+
19
+ type FeedbackSubmissionResponse = components['schemas']['FeedbackSubmissionResponse'];
20
+
21
+ export interface FeedbackSheetProps {
22
+ /** Called when feedback is successfully submitted */
23
+ onSuccess?: (result: FeedbackSubmissionResponse) => void;
24
+ /** Called when submission fails */
25
+ onError?: (error: Error) => void;
26
+ /** Called when user cancels/dismisses the form */
27
+ onCancel?: () => void;
28
+
29
+ /** Title text */
30
+ title?: string;
31
+ /** Placeholder text for message input */
32
+ placeholder?: string;
33
+ /** Submit button text */
34
+ submitLabel?: string;
35
+ /** Cancel button text */
36
+ cancelLabel?: string;
37
+
38
+ /** Custom categories */
39
+ categories?: CategoryOption[];
40
+ /** Whether category selection is required */
41
+ requireCategory?: boolean;
42
+
43
+ /** Minimum message length */
44
+ minMessageLength?: number;
45
+ /** Maximum message length */
46
+ maxMessageLength?: number;
47
+
48
+ /** Message shown in success alert. Set to null to disable alert. */
49
+ successMessage?: string | null;
50
+ /** Whether to show success alert. @default true */
51
+ showSuccessAlert?: boolean;
52
+ /** Whether to clear form on success. @default true */
53
+ clearOnSuccess?: boolean;
54
+
55
+ /** Container style override */
56
+ containerStyle?: ViewStyle;
57
+ /** Form content style override */
58
+ formStyle?: ViewStyle;
59
+ }
60
+
61
+ /**
62
+ * A batteries-included feedback form component.
63
+ *
64
+ * Unlike `FeedbackForm` which is a "dumb" UI component requiring manual
65
+ * wiring, `FeedbackSheet` handles everything internally:
66
+ * - API submission via `useFeedback` hook
67
+ * - Success/error alerts
68
+ * - Form state management
69
+ * - Keyboard handling
70
+ *
71
+ * For attachment support, import from '@harkenapp/sdk-react-native/attachments'.
72
+ *
73
+ * @example
74
+ * ```tsx
75
+ * // Minimal usage
76
+ * <FeedbackSheet onSuccess={() => navigation.goBack()} />
77
+ *
78
+ * // With customization
79
+ * <FeedbackSheet
80
+ * title="Report a Bug"
81
+ * requireCategory
82
+ * categories={[
83
+ * { value: 'crash', label: 'App Crash', icon: '💥' },
84
+ * { value: 'visual', label: 'Visual Bug', icon: '👁️' },
85
+ * ]}
86
+ * onSuccess={(result) => {
87
+ * analytics.track('feedback_submitted');
88
+ * navigation.goBack();
89
+ * }}
90
+ * onCancel={() => navigation.goBack()}
91
+ * />
92
+ * ```
93
+ */
94
+ export function FeedbackSheet({
95
+ onSuccess,
96
+ onError,
97
+ onCancel,
98
+ title = 'Send Feedback',
99
+ placeholder = 'What would you like to share?',
100
+ submitLabel = 'Submit',
101
+ cancelLabel = 'Cancel',
102
+ categories = DEFAULT_CATEGORIES,
103
+ requireCategory = false,
104
+ minMessageLength = 1,
105
+ maxMessageLength = 5000,
106
+ successMessage = 'Thank you for your feedback!',
107
+ showSuccessAlert = true,
108
+ clearOnSuccess = true,
109
+ containerStyle,
110
+ formStyle,
111
+ }: FeedbackSheetProps): React.JSX.Element {
112
+ const theme = useHarkenTheme();
113
+ const { submitFeedback, isSubmitting, error, clearError, isInitializing } =
114
+ useFeedback();
115
+
116
+ const [message, setMessage] = useState('');
117
+ const [category, setCategory] = useState<FeedbackCategory | null>(null);
118
+
119
+ const trimmedMessage = message.trim();
120
+ const isMessageValid =
121
+ trimmedMessage.length >= minMessageLength &&
122
+ trimmedMessage.length <= maxMessageLength;
123
+ const isCategoryValid = !requireCategory || category !== null;
124
+ const canSubmit = isMessageValid && isCategoryValid && !isSubmitting && !isInitializing;
125
+
126
+ const resetForm = useCallback(() => {
127
+ setMessage('');
128
+ setCategory(null);
129
+ clearError();
130
+ }, [clearError]);
131
+
132
+ const handleSubmit = useCallback(async () => {
133
+ if (!canSubmit) return;
134
+
135
+ clearError();
136
+
137
+ try {
138
+ const result = await submitFeedback({
139
+ message: trimmedMessage,
140
+ category: category ?? 'other',
141
+ });
142
+
143
+ if (showSuccessAlert && successMessage) {
144
+ Alert.alert('Success', successMessage, [
145
+ {
146
+ text: 'OK',
147
+ onPress: () => {
148
+ if (clearOnSuccess) {
149
+ resetForm();
150
+ }
151
+ onSuccess?.(result);
152
+ },
153
+ },
154
+ ]);
155
+ } else {
156
+ if (clearOnSuccess) {
157
+ resetForm();
158
+ }
159
+ onSuccess?.(result);
160
+ }
161
+ } catch (e) {
162
+ const errorMessage =
163
+ e instanceof Error ? e.message : 'Failed to submit feedback. Please try again.';
164
+ Alert.alert('Submission Failed', errorMessage);
165
+ onError?.(e instanceof Error ? e : new Error(errorMessage));
166
+ }
167
+ }, [
168
+ canSubmit,
169
+ clearError,
170
+ submitFeedback,
171
+ trimmedMessage,
172
+ category,
173
+ showSuccessAlert,
174
+ successMessage,
175
+ clearOnSuccess,
176
+ resetForm,
177
+ onSuccess,
178
+ onError,
179
+ ]);
180
+
181
+ const handleCancel = useCallback(() => {
182
+ resetForm();
183
+ onCancel?.();
184
+ }, [resetForm, onCancel]);
185
+
186
+ const baseContainerStyle: ViewStyle = {
187
+ flex: 1,
188
+ backgroundColor: theme.colors.background,
189
+ };
190
+
191
+ const contentStyle: ViewStyle = {
192
+ flexGrow: 1,
193
+ padding: theme.spacing.lg,
194
+ };
195
+
196
+ const sectionStyle: ViewStyle = {
197
+ marginBottom: theme.spacing.lg,
198
+ };
199
+
200
+ const buttonRowStyle: ViewStyle = {
201
+ flexDirection: 'row',
202
+ gap: theme.spacing.sm,
203
+ marginTop: theme.spacing.md,
204
+ };
205
+
206
+ const characterCount = trimmedMessage.length;
207
+ const showCharacterWarning = characterCount > maxMessageLength * 0.9;
208
+
209
+ if (isInitializing) {
210
+ return (
211
+ <View style={[baseContainerStyle, containerStyle, { justifyContent: 'center', alignItems: 'center' }]}>
212
+ <ThemedText variant="body" secondary>
213
+ Initializing...
214
+ </ThemedText>
215
+ </View>
216
+ );
217
+ }
218
+
219
+ return (
220
+ <KeyboardAvoidingView
221
+ behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
222
+ style={[baseContainerStyle, containerStyle]}
223
+ >
224
+ <ScrollView
225
+ contentContainerStyle={[contentStyle, formStyle]}
226
+ keyboardShouldPersistTaps="handled"
227
+ >
228
+ {/* Title */}
229
+ <View style={sectionStyle}>
230
+ <ThemedText variant="title">{title}</ThemedText>
231
+ </View>
232
+
233
+ {/* Category selector */}
234
+ <View style={sectionStyle}>
235
+ <ThemedText
236
+ variant="label"
237
+ secondary
238
+ style={{ marginBottom: theme.spacing.sm }}
239
+ >
240
+ Category{requireCategory ? '' : ' (optional)'}
241
+ </ThemedText>
242
+ <CategorySelector
243
+ value={category}
244
+ onChange={setCategory}
245
+ categories={categories}
246
+ disabled={isSubmitting}
247
+ />
248
+ </View>
249
+
250
+ {/* Message input */}
251
+ <View style={sectionStyle}>
252
+ <ThemedText
253
+ variant="label"
254
+ secondary
255
+ style={{ marginBottom: theme.spacing.sm }}
256
+ >
257
+ Message
258
+ </ThemedText>
259
+ <ThemedTextInput
260
+ value={message}
261
+ onChangeText={setMessage}
262
+ placeholder={placeholder}
263
+ multiline
264
+ numberOfLines={4}
265
+ textAlignVertical="top"
266
+ editable={!isSubmitting}
267
+ style={{ minHeight: 120 }}
268
+ maxLength={maxMessageLength + 100}
269
+ />
270
+ {showCharacterWarning && (
271
+ <ThemedText
272
+ variant="caption"
273
+ color={
274
+ characterCount > maxMessageLength
275
+ ? theme.colors.error
276
+ : theme.colors.textSecondary
277
+ }
278
+ style={{ marginTop: theme.spacing.xs, textAlign: 'right' }}
279
+ >
280
+ {characterCount}/{maxMessageLength}
281
+ </ThemedText>
282
+ )}
283
+ </View>
284
+
285
+ {/* Error display */}
286
+ {error && (
287
+ <View style={{ marginBottom: theme.spacing.md }}>
288
+ <ThemedText variant="caption" color={theme.colors.error}>
289
+ {error.message}
290
+ </ThemedText>
291
+ </View>
292
+ )}
293
+
294
+ {/* Buttons */}
295
+ <View style={buttonRowStyle}>
296
+ {onCancel && (
297
+ <View style={{ flex: 1 }}>
298
+ <ThemedButton
299
+ title={cancelLabel}
300
+ variant="secondary"
301
+ onPress={handleCancel}
302
+ disabled={isSubmitting}
303
+ fullWidth
304
+ />
305
+ </View>
306
+ )}
307
+ <View style={{ flex: onCancel ? 1 : undefined }}>
308
+ <ThemedButton
309
+ title={submitLabel}
310
+ variant="primary"
311
+ onPress={handleSubmit}
312
+ disabled={!canSubmit}
313
+ loading={isSubmitting}
314
+ fullWidth={!!onCancel}
315
+ />
316
+ </View>
317
+ </View>
318
+ </ScrollView>
319
+ </KeyboardAvoidingView>
320
+ );
321
+ }