@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.
- package/README.md +67 -0
- package/app.plugin.cjs +135 -0
- package/app.plugin.js +1 -0
- package/dist/api/client.d.ts +67 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +163 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/errors.d.ts +46 -0
- package/dist/api/errors.d.ts.map +1 -0
- package/dist/api/errors.js +72 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/api/index.d.ts +7 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +20 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/retry.d.ts +29 -0
- package/dist/api/retry.d.ts.map +1 -0
- package/dist/api/retry.js +74 -0
- package/dist/api/retry.js.map +1 -0
- package/dist/attachments/FeedbackSheet.d.ts +88 -0
- package/dist/attachments/FeedbackSheet.d.ts.map +1 -0
- package/dist/attachments/FeedbackSheet.js +250 -0
- package/dist/attachments/FeedbackSheet.js.map +1 -0
- package/dist/attachments/index.d.ts +20 -0
- package/dist/attachments/index.d.ts.map +1 -0
- package/dist/attachments/index.js +40 -0
- package/dist/attachments/index.js.map +1 -0
- package/dist/components/AttachmentGrid.d.ts +94 -0
- package/dist/components/AttachmentGrid.d.ts.map +1 -0
- package/dist/components/AttachmentGrid.js +132 -0
- package/dist/components/AttachmentGrid.js.map +1 -0
- package/dist/components/AttachmentPicker.d.ts +98 -0
- package/dist/components/AttachmentPicker.d.ts.map +1 -0
- package/dist/components/AttachmentPicker.js +297 -0
- package/dist/components/AttachmentPicker.js.map +1 -0
- package/dist/components/AttachmentPreview.d.ts +78 -0
- package/dist/components/AttachmentPreview.d.ts.map +1 -0
- package/dist/components/AttachmentPreview.js +133 -0
- package/dist/components/AttachmentPreview.js.map +1 -0
- package/dist/components/CategorySelector.d.ts +77 -0
- package/dist/components/CategorySelector.d.ts.map +1 -0
- package/dist/components/CategorySelector.js +117 -0
- package/dist/components/CategorySelector.js.map +1 -0
- package/dist/components/FeedbackForm.d.ts +50 -0
- package/dist/components/FeedbackForm.d.ts.map +1 -0
- package/dist/components/FeedbackForm.js +141 -0
- package/dist/components/FeedbackForm.js.map +1 -0
- package/dist/components/FeedbackSheet.d.ts +75 -0
- package/dist/components/FeedbackSheet.d.ts.map +1 -0
- package/dist/components/FeedbackSheet.js +215 -0
- package/dist/components/FeedbackSheet.js.map +1 -0
- package/dist/components/ThemedButton.d.ts +23 -0
- package/dist/components/ThemedButton.d.ts.map +1 -0
- package/dist/components/ThemedButton.js +77 -0
- package/dist/components/ThemedButton.js.map +1 -0
- package/dist/components/ThemedText.d.ts +16 -0
- package/dist/components/ThemedText.d.ts.map +1 -0
- package/dist/components/ThemedText.js +44 -0
- package/dist/components/ThemedText.js.map +1 -0
- package/dist/components/ThemedTextInput.d.ts +13 -0
- package/dist/components/ThemedTextInput.d.ts.map +1 -0
- package/dist/components/ThemedTextInput.js +76 -0
- package/dist/components/ThemedTextInput.js.map +1 -0
- package/dist/components/UploadStatusOverlay.d.ts +82 -0
- package/dist/components/UploadStatusOverlay.d.ts.map +1 -0
- package/dist/components/UploadStatusOverlay.js +319 -0
- package/dist/components/UploadStatusOverlay.js.map +1 -0
- package/dist/components/index.d.ts +19 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +28 -0
- package/dist/components/index.js.map +1 -0
- package/dist/context/HarkenContext.d.ts +62 -0
- package/dist/context/HarkenContext.d.ts.map +1 -0
- package/dist/context/HarkenContext.js +128 -0
- package/dist/context/HarkenContext.js.map +1 -0
- package/dist/context/index.d.ts +3 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +7 -0
- package/dist/context/index.js.map +1 -0
- package/dist/domain/index.d.ts +3 -0
- package/dist/domain/index.d.ts.map +1 -0
- package/dist/domain/index.js +7 -0
- package/dist/domain/index.js.map +1 -0
- package/dist/domain/upload-queue.d.ts +116 -0
- package/dist/domain/upload-queue.d.ts.map +1 -0
- package/dist/domain/upload-queue.js +34 -0
- package/dist/domain/upload-queue.js.map +1 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +16 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/useAnonymousId.d.ts +28 -0
- package/dist/hooks/useAnonymousId.d.ts.map +1 -0
- package/dist/hooks/useAnonymousId.js +59 -0
- package/dist/hooks/useAnonymousId.js.map +1 -0
- package/dist/hooks/useAttachmentPicker.d.ts +84 -0
- package/dist/hooks/useAttachmentPicker.d.ts.map +1 -0
- package/dist/hooks/useAttachmentPicker.js +181 -0
- package/dist/hooks/useAttachmentPicker.js.map +1 -0
- package/dist/hooks/useAttachmentStatus.d.ts +51 -0
- package/dist/hooks/useAttachmentStatus.d.ts.map +1 -0
- package/dist/hooks/useAttachmentStatus.js +69 -0
- package/dist/hooks/useAttachmentStatus.js.map +1 -0
- package/dist/hooks/useAttachmentUpload.d.ts +101 -0
- package/dist/hooks/useAttachmentUpload.d.ts.map +1 -0
- package/dist/hooks/useAttachmentUpload.js +293 -0
- package/dist/hooks/useAttachmentUpload.js.map +1 -0
- package/dist/hooks/useFeedback.d.ts +55 -0
- package/dist/hooks/useFeedback.d.ts.map +1 -0
- package/dist/hooks/useFeedback.js +96 -0
- package/dist/hooks/useFeedback.js.map +1 -0
- package/dist/hooks/useHarkenContext.d.ts +25 -0
- package/dist/hooks/useHarkenContext.d.ts.map +1 -0
- package/dist/hooks/useHarkenContext.js +35 -0
- package/dist/hooks/useHarkenContext.js.map +1 -0
- package/dist/hooks/useHarkenTheme.d.ts +26 -0
- package/dist/hooks/useHarkenTheme.d.ts.map +1 -0
- package/dist/hooks/useHarkenTheme.js +36 -0
- package/dist/hooks/useHarkenTheme.js.map +1 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +91 -0
- package/dist/index.js.map +1 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +9 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/uploadQueueService.d.ts +193 -0
- package/dist/services/uploadQueueService.d.ts.map +1 -0
- package/dist/services/uploadQueueService.js +623 -0
- package/dist/services/uploadQueueService.js.map +1 -0
- package/dist/services/uploadQueueStorage.d.ts +30 -0
- package/dist/services/uploadQueueStorage.d.ts.map +1 -0
- package/dist/services/uploadQueueStorage.js +77 -0
- package/dist/services/uploadQueueStorage.js.map +1 -0
- package/dist/storage/IdentityStore.d.ts +38 -0
- package/dist/storage/IdentityStore.d.ts.map +1 -0
- package/dist/storage/IdentityStore.js +83 -0
- package/dist/storage/IdentityStore.js.map +1 -0
- package/dist/storage/SecureStoreAdapter.d.ts +28 -0
- package/dist/storage/SecureStoreAdapter.d.ts.map +1 -0
- package/dist/storage/SecureStoreAdapter.js +52 -0
- package/dist/storage/SecureStoreAdapter.js.map +1 -0
- package/dist/storage/defaultStorage.d.ts +20 -0
- package/dist/storage/defaultStorage.d.ts.map +1 -0
- package/dist/storage/defaultStorage.js +131 -0
- package/dist/storage/defaultStorage.js.map +1 -0
- package/dist/storage/index.d.ts +6 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +13 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/types.d.ts +32 -0
- package/dist/storage/types.d.ts.map +1 -0
- package/dist/storage/types.js +11 -0
- package/dist/storage/types.js.map +1 -0
- package/dist/theme/defaults.d.ts +43 -0
- package/dist/theme/defaults.d.ts.map +1 -0
- package/dist/theme/defaults.js +128 -0
- package/dist/theme/defaults.js.map +1 -0
- package/dist/theme/index.d.ts +3 -0
- package/dist/theme/index.d.ts.map +1 -0
- package/dist/theme/index.js +14 -0
- package/dist/theme/index.js.map +1 -0
- package/dist/theme/types.d.ts +136 -0
- package/dist/theme/types.d.ts.map +1 -0
- package/dist/theme/types.js +3 -0
- package/dist/theme/types.js.map +1 -0
- package/dist/types/config.d.ts +100 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +3 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/openapi.d.ts +601 -0
- package/dist/types/openapi.d.ts.map +1 -0
- package/dist/types/openapi.js +7 -0
- package/dist/types/openapi.js.map +1 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +6 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/uuid.d.ts +10 -0
- package/dist/utils/uuid.d.ts.map +1 -0
- package/dist/utils/uuid.js +60 -0
- package/dist/utils/uuid.js.map +1 -0
- package/package.json +124 -0
- package/src/@types/expo-file-system-legacy.d.ts +13 -0
- package/src/api/client.ts +250 -0
- package/src/api/errors.ts +84 -0
- package/src/api/index.ts +15 -0
- package/src/api/retry.ts +99 -0
- package/src/attachments/FeedbackSheet.tsx +400 -0
- package/src/attachments/index.ts +70 -0
- package/src/components/AttachmentGrid.tsx +247 -0
- package/src/components/AttachmentPicker.tsx +391 -0
- package/src/components/AttachmentPreview.tsx +210 -0
- package/src/components/CategorySelector.tsx +174 -0
- package/src/components/FeedbackForm.tsx +216 -0
- package/src/components/FeedbackSheet.tsx +321 -0
- package/src/components/ThemedButton.tsx +127 -0
- package/src/components/ThemedText.tsx +65 -0
- package/src/components/ThemedTextInput.tsx +65 -0
- package/src/components/UploadStatusOverlay.tsx +440 -0
- package/src/components/index.ts +39 -0
- package/src/context/HarkenContext.tsx +129 -0
- package/src/context/index.ts +2 -0
- package/src/domain/index.ts +12 -0
- package/src/domain/upload-queue.ts +131 -0
- package/src/hooks/index.ts +10 -0
- package/src/hooks/useAnonymousId.ts +68 -0
- package/src/hooks/useAttachmentPicker.ts +243 -0
- package/src/hooks/useAttachmentStatus.ts +86 -0
- package/src/hooks/useAttachmentUpload.ts +370 -0
- package/src/hooks/useFeedback.ts +139 -0
- package/src/hooks/useHarkenContext.ts +35 -0
- package/src/hooks/useHarkenTheme.ts +36 -0
- package/src/index.ts +168 -0
- package/src/services/index.ts +11 -0
- package/src/services/uploadQueueService.ts +727 -0
- package/src/services/uploadQueueStorage.ts +78 -0
- package/src/storage/IdentityStore.ts +89 -0
- package/src/storage/SecureStoreAdapter.ts +59 -0
- package/src/storage/defaultStorage.ts +109 -0
- package/src/storage/index.ts +5 -0
- package/src/storage/types.ts +34 -0
- package/src/theme/defaults.ts +151 -0
- package/src/theme/index.ts +23 -0
- package/src/theme/types.ts +157 -0
- package/src/types/config.ts +112 -0
- package/src/types/index.ts +10 -0
- package/src/types/openapi.ts +601 -0
- package/src/utils/index.ts +1 -0
- 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
|
+
}
|