Files
Sauci/apps/mobile/src/components/feedback/FeedbackModal.tsx
T
jjbruntonandClaude Fable 5 6bc4e31719 chore(mobile): upgrade to Expo SDK 56
Bump expo, react, react-native, and all expo-* / react-native-* peers to
the SDK 56 line via expo install --fix, adding expo-splash-screen and
expo-system-ui for config plugins the SDK split out of core. Bump
@bacons/apple-targets, react-native-view-shot, and other native modules
to versions compatible with React Native 0.85 and the new architecture.

Remove @react-navigation/* direct dependencies and route their hooks
through expo-router, which is no longer compatible with react-navigation
as of SDK 56. Fix RN 0.85's removed StyleSheet.absoluteFillObject (use
absoluteFill) and react-native-view-shot 5.x's ViewShotRef type across
call sites. Downgrade eslint-plugin-react-hooks v7's new React Compiler
era rules to warnings pending a dedicated refactor, and repair Jest
fallout from the jest-expo/reanimated/worklets/expo-media-library bumps.

Regenerate the committed ios/ and android/ native projects with
`expo prebuild --clean` (required for @bacons/apple-targets 5.x) against
the production release channel, and document that requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:11:37 +01:00

521 lines
19 KiB
TypeScript

import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
TextInput,
ScrollView,
Alert,
ActivityIndicator,
Platform,
Image,
KeyboardAvoidingView,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import * as ImagePicker from 'expo-image-picker';
import { uploadMedia } from '../../lib/mediaApi';
import { profileSettingsApi } from '../../lib/profileSettingsApi';
import { getDeviceInfo } from '../../lib/deviceInfo';
import { useAuthStore } from '../../store';
import { colors, gradients, spacing, radius, typography } from '../../theme';
import type { FeedbackType } from '../../types';
interface FeedbackModalProps {
visible: boolean;
onClose: () => void;
}
const FEEDBACK_OPTIONS: { type: FeedbackType; label: string; icon: keyof typeof Ionicons.glyphMap }[] = [
{ type: 'bug', label: 'Bug', icon: 'bug' },
{ type: 'feature_request', label: 'Feature', icon: 'bulb' },
{ type: 'general', label: 'General', icon: 'chatbubble-ellipses' },
];
export function FeedbackModal({ visible, onClose }: FeedbackModalProps) {
const { user } = useAuthStore();
const [feedbackType, setFeedbackType] = useState<FeedbackType>('bug');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [screenshotUri, setScreenshotUri] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<{ title?: string; description?: string }>({});
if (!visible) return null;
const resetForm = () => {
setFeedbackType('bug');
setTitle('');
setDescription('');
setScreenshotUri(null);
setErrors({});
};
const handleClose = () => {
if (!isSubmitting) {
resetForm();
onClose();
}
};
const handlePickScreenshot = async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: false,
quality: 0.7,
});
if (!result.canceled) {
setScreenshotUri(result.assets[0].uri);
}
};
const uploadScreenshot = async (uri: string, _userId: string): Promise<string> => {
let ext = 'jpg';
if (Platform.OS === 'web') {
const response = await fetch(uri);
const blob = await response.blob();
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/jpeg' || blob.type === 'image/jpg') ext = 'jpg';
else if (blob.type === 'image/gif') ext = 'gif';
else if (blob.type === 'image/webp') ext = 'webp';
} else {
ext = uri.split('.').pop()?.toLowerCase() || 'jpg';
}
return (await uploadMedia(uri, { kind: 'feedback', mimeType: `image/${ext === 'jpg' ? 'jpeg' : ext}` })).media.id;
};
const handleSubmit = async () => {
// Validate
const newErrors: typeof errors = {};
if (!title.trim()) newErrors.title = 'Title is required';
if (!description.trim()) newErrors.description = 'Description is required';
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
if (!user) {
Alert.alert('Error', 'You must be logged in to submit feedback.');
return;
}
setIsSubmitting(true);
try {
const deviceInfo = getDeviceInfo();
let screenshotMediaId: string | null = null;
// Upload screenshot if provided
if (screenshotUri) {
screenshotMediaId = await uploadScreenshot(screenshotUri, user.id);
}
await profileSettingsApi.submitFeedback({
type: feedbackType,
title: title.trim(),
description: description.trim(),
device_info: deviceInfo,
...(screenshotMediaId ? { screenshot_media_id: screenshotMediaId } : {}),
});
Alert.alert('Thank you!', 'Your feedback has been submitted successfully.', [
{ text: 'OK', onPress: handleClose },
]);
resetForm();
} catch (error) {
Alert.alert('Error', 'Failed to submit feedback. Please try again.');
console.error(error);
} finally {
setIsSubmitting(false);
}
};
const selectedOption = FEEDBACK_OPTIONS.find((o) => o.type === feedbackType)!;
return (
<View style={styles.fullScreen}>
<TouchableOpacity
style={styles.backdrop}
activeOpacity={1}
onPress={handleClose}
/>
<KeyboardAvoidingView
style={styles.keyboardView}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.content}>
{/* Close Button */}
<TouchableOpacity
style={styles.closeButton}
onPress={handleClose}
disabled={isSubmitting}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={styles.scrollContent}
>
{/* Header */}
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.headerIcon}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name={selectedOption.icon} size={28} color={colors.text} />
</LinearGradient>
<Text style={styles.headerTitle}>Send Feedback</Text>
<Text style={styles.headerSubtitle}>
Help us improve Sauci
</Text>
</View>
{/* Type Selector */}
<View style={styles.typeSelector}>
{FEEDBACK_OPTIONS.map((option) => (
<TouchableOpacity
key={option.type}
style={[
styles.typeButton,
feedbackType === option.type && styles.typeButtonActive,
]}
onPress={() => setFeedbackType(option.type)}
activeOpacity={0.7}
>
<Ionicons
name={option.icon}
size={18}
color={
feedbackType === option.type
? colors.text
: colors.textSecondary
}
/>
<Text
style={[
styles.typeButtonText,
feedbackType === option.type && styles.typeButtonTextActive,
]}
>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* Title Input */}
<View style={styles.inputGroup}>
<Text style={styles.label}>Title</Text>
<TextInput
style={[styles.input, errors.title && styles.inputError]}
value={title}
onChangeText={(text) => {
setTitle(text);
if (errors.title) setErrors((e) => ({ ...e, title: undefined }));
}}
placeholder="Brief summary of your feedback"
placeholderTextColor={colors.textTertiary}
editable={!isSubmitting}
/>
{errors.title && <Text style={styles.errorText}>{errors.title}</Text>}
</View>
{/* Description Input */}
<View style={styles.inputGroup}>
<Text style={styles.label}>Description</Text>
<TextInput
style={[
styles.input,
styles.textArea,
errors.description && styles.inputError,
]}
value={description}
onChangeText={(text) => {
setDescription(text);
if (errors.description)
setErrors((e) => ({ ...e, description: undefined }));
}}
placeholder="Please provide details about the issue or suggestion..."
placeholderTextColor={colors.textTertiary}
multiline
numberOfLines={4}
textAlignVertical="top"
editable={!isSubmitting}
/>
{errors.description && (
<Text style={styles.errorText}>{errors.description}</Text>
)}
</View>
{/* Screenshot Picker */}
<View style={styles.inputGroup}>
<Text style={styles.label}>Screenshot (optional)</Text>
{screenshotUri ? (
<View style={styles.screenshotPreview}>
<Image
source={{ uri: screenshotUri }}
style={styles.screenshotImage}
/>
<TouchableOpacity
style={styles.removeScreenshot}
onPress={() => setScreenshotUri(null)}
disabled={isSubmitting}
>
<Ionicons name="close-circle" size={24} color={colors.error} />
</TouchableOpacity>
</View>
) : (
<TouchableOpacity
style={styles.screenshotPicker}
onPress={handlePickScreenshot}
disabled={isSubmitting}
activeOpacity={0.7}
>
<Ionicons
name="image-outline"
size={24}
color={colors.textTertiary}
/>
<Text style={styles.screenshotPickerText}>
Tap to add a screenshot
</Text>
</TouchableOpacity>
)}
</View>
{/* Buttons */}
<View style={styles.buttons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={handleClose}
disabled={isSubmitting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.submitButton}
onPress={handleSubmit}
disabled={isSubmitting}
activeOpacity={0.7}
>
{isSubmitting ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="send" size={16} color={colors.text} />
<Text style={styles.submitButtonText}>Submit</Text>
</>
)}
</TouchableOpacity>
</View>
</ScrollView>
</View>
</KeyboardAvoidingView>
</View>
);
}
const styles = StyleSheet.create({
fullScreen: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 1000,
},
backdrop: {
...StyleSheet.absoluteFill,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
},
keyboardView: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
content: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
padding: spacing.lg,
width: '100%',
maxWidth: 400,
maxHeight: '80%',
borderWidth: 1,
borderColor: colors.glass.border,
},
closeButton: {
position: 'absolute',
top: spacing.md,
right: spacing.md,
zIndex: 1,
padding: spacing.xs,
},
scrollContent: {
flexGrow: 1,
},
header: {
alignItems: 'center',
marginBottom: spacing.lg,
},
headerIcon: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.md,
},
headerTitle: {
...typography.title2,
color: colors.text,
textAlign: 'center',
},
headerSubtitle: {
...typography.subhead,
color: colors.textSecondary,
textAlign: 'center',
marginTop: spacing.xs,
},
typeSelector: {
flexDirection: 'row',
gap: spacing.sm,
marginBottom: spacing.lg,
},
typeButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
backgroundColor: colors.glass.background,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
},
typeButtonActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
typeButtonText: {
...typography.caption1,
color: colors.textSecondary,
fontWeight: '600',
},
typeButtonTextActive: {
color: colors.text,
},
inputGroup: {
marginBottom: spacing.md,
},
label: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: spacing.sm,
marginLeft: spacing.xs,
},
input: {
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
padding: spacing.md,
...typography.body,
color: colors.text,
},
inputError: {
borderColor: colors.error,
},
textArea: {
minHeight: 100,
paddingTop: spacing.md,
},
errorText: {
...typography.caption1,
color: colors.error,
marginTop: spacing.xs,
marginLeft: spacing.xs,
},
screenshotPicker: {
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
borderStyle: 'dashed',
padding: spacing.lg,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
},
screenshotPickerText: {
...typography.subhead,
color: colors.textTertiary,
},
screenshotPreview: {
position: 'relative',
alignSelf: 'flex-start',
},
screenshotImage: {
width: 120,
height: 120,
borderRadius: radius.md,
backgroundColor: colors.glass.background,
},
removeScreenshot: {
position: 'absolute',
top: -8,
right: -8,
backgroundColor: colors.backgroundLight,
borderRadius: 12,
},
buttons: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.lg,
},
cancelButton: {
flex: 1,
backgroundColor: colors.glass.backgroundLight,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.glass.borderLight,
},
cancelButtonText: {
...typography.body,
color: colors.textSecondary,
fontWeight: '600',
},
submitButton: {
flex: 1,
backgroundColor: colors.primary,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.xs,
},
submitButtonText: {
...typography.body,
color: colors.text,
fontWeight: '600',
},
});
export default FeedbackModal;