diff --git a/@types/env.d.ts b/@types/env.d.ts new file mode 100644 index 0000000..05f193d --- /dev/null +++ b/@types/env.d.ts @@ -0,0 +1,4 @@ +declare module '@env' { + export const SUPABASE_URL: string; + export const SUPABASE_KEY: string; +} diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..4b81210 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,17 @@ +module.exports = { + presets: ['module:metro-react-native-babel-preset'], + plugins: [ + [ + 'module:react-native-dotenv', + { + moduleName: '@env', + path: '.env', + blocklist: null, + allowlist: null, + safe: false, + allowUndefined: true, + verbose: false, + }, + ], + ], +}; diff --git a/package.json b/package.json index 9533c5b..b1642a1 100644 --- a/package.json +++ b/package.json @@ -10,20 +10,26 @@ "dependencies": { "@expo/vector-icons": "14.1.0", "@react-native-community/datetimepicker": "8.2.0", + "@reduxjs/toolkit": "^2.7.0", + "@supabase/supabase-js": "^2.49.4", "expo": "52.0.46", "expo-fast-image": "1.1.3", + "expo-file-system": "~18.0.12", "expo-image-picker": "16.0.6", "expo-linking": "7.0.5", "expo-router": "4.0.20", "expo-splash-screen": "0.29.24", "expo-status-bar": "2.0.1", + "metro-react-native-babel-preset": "^0.77.0", "react": "18.3.1", "react-dom": "18.3.1", "react-native": "0.76.9", + "react-native-dotenv": "^3.4.11", "react-native-modal": "^14.0.0-rc.1", "react-native-reanimated": "3.16.1", "react-native-safe-area-context": "4.12.0", - "react-native-screens": "4.4.0" + "react-native-screens": "4.4.0", + "react-redux": "^9.2.0" }, "devDependencies": { "@babel/core": "7.26.10", diff --git a/src/app/(gifts)/add-gift.tsx b/src/app/(gifts)/add-gift.tsx index e392b53..7fb99cc 100644 --- a/src/app/(gifts)/add-gift.tsx +++ b/src/app/(gifts)/add-gift.tsx @@ -1,264 +1,465 @@ -import React, { useState } from 'react'; -import { View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView, Image } from 'react-native'; -import * as ImagePicker from 'expo-image-picker'; -import Modal from 'react-native-modal'; import { MaterialIcons } from '@expo/vector-icons'; import DateTimePicker from '@react-native-community/datetimepicker'; +import * as ImagePicker from 'expo-image-picker'; +import { useRouter } from 'expo-router'; +import React, { useEffect, useState } from 'react'; +import { + Image, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import Modal from 'react-native-modal'; + +import Loading from '@/components/Loading'; +import { addGift } from '@/features/gifts/giftSlice'; +import { + fetchRecipients, + updateRecipient, +} from '@/features/recipients/recipientService'; +import { useAppDispatch, useAppSelector } from '@/redux/hooks'; +import { uploadGiftThumbnail } from '@/services/uploadImage'; +import { formatPrice } from '@/utils/priceUtils'; const AddGiftScreen = () => { - const [image, setImage] = useState(null); - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [price, setPrice] = useState(''); - const [recipient, setRecipient] = useState(''); - const [selectedDate, setSelectedDate] = useState(null); - - const [showRecipientModal, setShowRecipientModal] = useState(false); - const [showDatePicker, setShowDatePicker] = useState(false); - - const recipients = ['Alex', 'Emily', 'Michael', 'Malow']; - - const pickImage = async () => { - // Request permission - const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (status !== 'granted') { - alert('Sorry, we need camera roll permissions!'); - return; - } - - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - allowsEditing: true, - aspect: [4, 3], - quality: 1, - }); - - if (!result.canceled) { - setImage(result.assets[0].uri); - } - }; - - const handleSave = () => { - const giftData = { - image: image || '', - title, - description, - price: parseFloat(price), - recipient, - selectedDate: selectedDate ? selectedDate.toISOString().split('T')[0] : '', - }; - console.log(giftData); - // TODO: gửi giftData lên server hoặc lưu local - }; - - return ( - - {/* Upload Image */} - - Image - - {image ? ( - - ) : ( - Pick an Image - )} - - - - {/* Title */} - - Title - - - - {/* Description */} - - Description - - - - {/* Price */} - - Price - - - - {/* Recipient */} - - Recipient - setShowRecipientModal(true)}> - - {recipient ? recipient : 'Choose Recipient'} - - - - - - {/* Selected Date */} - - Selected Date - setShowDatePicker(true)}> - - {selectedDate ? selectedDate.toDateString() : 'Pick a date'} - - - - - - {/* Save Button */} - - Save Gift - - - {/* Recipient Modal */} - setShowRecipientModal(false)} - onBackButtonPress={() => setShowRecipientModal(false)} - > - - {recipients.map((item, index) => ( - { - setRecipient(item); - setShowRecipientModal(false); - }} - > - - - {item} - - - ))} - - - - {/* Date Picker */} - {showDatePicker && ( - { - setShowDatePicker(false); - if (date) setSelectedDate(date); - }} - /> - )} - - ); + const dispatch = useAppDispatch(); + const router = useRouter(); + const { loading } = useAppSelector((state) => state.gifts); + + const [image, setImage] = useState(null); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [price, setPrice] = useState(''); + const [recipient, setRecipient] = useState(null); + const [selectedDate, setSelectedDate] = useState(null); + + const [recipients, setRecipients] = useState< + { id: string; name: string; budget: number; spent: number }[] + >([]); + const [showRecipientModal, setShowRecipientModal] = useState(false); + const [showDatePicker, setShowDatePicker] = useState(false); + + const [errors, setErrors] = useState({ + title: '', + price: '', + recipient: '', + selectedDate: '', + }); + + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + const loadRecipients = async () => { + try { + const data = await fetchRecipients(); + setRecipients(data); + } catch (error) { + alert('Failed to load recipients'); + } + }; + + loadRecipients(); + }, []); + + const pickImage = async () => { + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== 'granted') { + alert('Sorry, we need camera roll permissions!'); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [4, 3], + quality: 1, + }); + + if (!result.canceled) { + setImage(result.assets[0].uri); + } + }; + + const validateFields = () => { + const selectedRecipient = recipients.find((r) => r.id === recipient); + + if (!selectedRecipient) { + console.error('Selected recipient not found'); + setErrors({ ...errors, recipient: 'Recipient is required' }); + return false; + } + + const newErrors = { + title: title ? '' : 'Title is required', + price: price + ? isNaN(Number(price)) + ? 'Price must be a valid number' + : parseFloat(price) > selectedRecipient.budget + ? 'Price exceeds recipient budget' + : parseFloat(price) + selectedRecipient.spent > + selectedRecipient.budget + ? 'Total spent exceeds recipient budget' + : '' + : 'Price is required', + recipient: recipient ? '' : 'Recipient is required', + selectedDate: selectedDate ? '' : 'Date is required', + }; + + setErrors(newErrors); + + return !Object.values(newErrors).some((error) => error !== ''); + }; + + const handleSave = async () => { + if (!validateFields()) { + return; + } + + setIsSaving(true); + + try { + let uploadedImageUrl = image; + + if (image) { + uploadedImageUrl = await uploadGiftThumbnail(image); + } + + const giftData = { + image: uploadedImageUrl || 'https://placeholder.com/default-image.png', + title, + description, + price: parseFloat(price), + recipient: recipient || '', + selectedDate: selectedDate ? selectedDate.toISOString() : '', + }; + + await dispatch(addGift(giftData)).unwrap(); + + const selectedRecipient = recipients.find((r) => r.id === recipient); + if (selectedRecipient) { + const updatedSpent = selectedRecipient.spent + parseFloat(price); + await updateRecipient(recipient!, { spent: updatedSpent }); + } + + alert('Gift added successfully!'); + router.push('/(gifts)'); + } catch (error) { + console.error('Error saving gift:', error); + const errorMessage = + error instanceof Error ? error.message : 'An unknown error occurred'; + alert(`Failed to add gift: ${errorMessage}`); + } finally { + setIsSaving(false); + } + }; + + if (loading) { + return ; + } + + return ( + + {/* Upload Image */} + + Image + + {image ? ( + + ) : ( + Pick an Image + )} + + + + {/* Title */} + + + Title * + + { + setTitle(text); + if (errors.title) setErrors({ ...errors, title: '' }); + }} + /> + {errors.title ? ( + {errors.title} + ) : null} + + + {/* Description */} + + Description + + + + {/* Price */} + + + Price * + + { + setPrice(text); + if (errors.price) setErrors({ ...errors, price: '' }); + }} + keyboardType="numeric" + /> + {errors.price ? ( + {errors.price} + ) : null} + + + {/* Recipient */} + + + Recipient * + + setShowRecipientModal(true)} + > + + {recipient + ? recipients.find((r) => r.id === recipient)?.name + : 'Choose Recipient'} + + + + {errors.recipient ? ( + {errors.recipient} + ) : null} + + {/* Hiển thị Budget và Spent */} + {recipient && ( + + + Budget: $ + {formatPrice( + recipients.find((r) => r.id === recipient)?.budget || 0, + )} + + + Spent: $ + {formatPrice( + recipients.find((r) => r.id === recipient)?.spent || 0, + )} + + + )} + + + {/* Selected Date */} + + + Selected Date * + + setShowDatePicker(true)} + > + + {selectedDate ? selectedDate.toDateString() : 'Pick a date'} + + + + {errors.selectedDate ? ( + {errors.selectedDate} + ) : null} + + + {/* Save Button */} + + + {isSaving ? 'Saving...' : 'Save Gift'} + + + + {/* Recipient Modal */} + setShowRecipientModal(false)} + onBackButtonPress={() => setShowRecipientModal(false)} + > + + {recipients.map((item) => ( + { + setRecipient(item.id); + setErrors({ ...errors, recipient: '' }); + setShowRecipientModal(false); + }} + > + + + {item.name} + + + ))} + + + + {/* Date Picker */} + {showDatePicker && ( + { + setShowDatePicker(false); + if (event.type === 'set' && date) { + setSelectedDate(date); + setErrors({ ...errors, selectedDate: '' }); + } + }} + /> + )} + + ); }; const styles = StyleSheet.create({ - container: { - padding: 16, - backgroundColor: '#F5F5F5', - flexGrow: 1, - }, - inputGroup: { - marginBottom: 16, - }, - label: { - fontSize: 14, - color: '#666', - marginBottom: 8, - }, - input: { - borderWidth: 1, - borderColor: '#E0E0E0', - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 10, - fontSize: 15, - backgroundColor: 'white', - color: '#333', - }, - imagePicker: { - borderWidth: 1, - borderColor: '#E0E0E0', - borderRadius: 8, - height: 180, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'white', - }, - pickImageText: { - color: '#666', - fontSize: 16, - }, - previewImage: { - width: '100%', - height: '100%', - borderRadius: 8, - }, - dropdown: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - borderWidth: 1, - borderColor: '#E0E0E0', - borderRadius: 8, - padding: 12, - backgroundColor: 'white', - }, - dropdownText: { - fontSize: 15, - color: '#333', - }, - modalContent: { - backgroundColor: 'white', - borderRadius: 8, - padding: 16, - }, - option: { - paddingVertical: 12, - flexDirection: 'row', - alignItems: 'center', - }, - radioButtonContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - optionText: { - fontSize: 16, - color: '#333', - marginLeft: 8, - }, - saveButton: { - backgroundColor: '#007AFF', - padding: 16, - borderRadius: 8, - alignItems: 'center', - marginTop: 24, - }, - saveButtonText: { - color: 'white', - fontSize: 16, - fontWeight: '600', - }, + container: { + padding: 16, + backgroundColor: '#F5F5F5', + flexGrow: 1, + }, + inputGroup: { + marginBottom: 16, + }, + label: { + fontSize: 14, + color: '#666', + marginBottom: 8, + }, + required: { + color: 'red', + }, + input: { + borderWidth: 1, + borderColor: '#E0E0E0', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + backgroundColor: 'white', + color: '#333', + }, + inputError: { + borderColor: 'red', + }, + errorText: { + color: 'red', + fontSize: 12, + marginTop: 4, + }, + imagePicker: { + borderWidth: 1, + borderColor: '#E0E0E0', + borderRadius: 8, + height: 180, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'white', + }, + pickImageText: { + color: '#666', + fontSize: 16, + }, + previewImage: { + width: '100%', + height: '100%', + borderRadius: 8, + }, + dropdown: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderWidth: 1, + borderColor: '#E0E0E0', + borderRadius: 8, + padding: 12, + backgroundColor: 'white', + }, + dropdownText: { + fontSize: 15, + color: '#333', + }, + modalContent: { + backgroundColor: 'white', + borderRadius: 8, + padding: 16, + }, + option: { + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + }, + radioButtonContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + optionText: { + fontSize: 16, + color: '#333', + marginLeft: 8, + }, + saveButton: { + backgroundColor: '#007AFF', + padding: 16, + borderRadius: 8, + alignItems: 'center', + marginTop: 24, + }, + saveButtonDisabled: { + backgroundColor: '#A0A0A0', + }, + saveButtonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + }, + recipientInfo: { + marginTop: 8, + padding: 8, + backgroundColor: '#F9F9F9', + borderRadius: 8, + borderWidth: 1, + borderColor: '#E0E0E0', + }, + infoText: { + fontSize: 14, + color: '#333', + }, }); export default AddGiftScreen; diff --git a/src/app/(gifts)/detail-gift.tsx b/src/app/(gifts)/detail-gift.tsx index 0292d78..4047d48 100644 --- a/src/app/(gifts)/detail-gift.tsx +++ b/src/app/(gifts)/detail-gift.tsx @@ -1,12 +1,49 @@ -import { useLocalSearchParams, useRouter } from 'expo-router'; -import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'; import FastImage from 'expo-fast-image'; -import { GiftIdea } from '@/models/GiftIdea'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; + +import Loading from '@/components/Loading'; +import { deleteGift, fetchGifts } from '@/features/gifts/giftSlice'; +import { findRecipientById } from '@/features/recipients/recipientService'; +import { useAppDispatch } from '@/redux/hooks'; +import { deleteGiftThumbnail } from '@/services/deleteImage'; +import { formatDate } from '@/utils/dateUtils'; +import { formatPrice } from '@/utils/priceUtils'; const DetailGiftScreen = () => { const router = useRouter(); + const dispatch = useAppDispatch(); const { id, image, title, description, price, recipient, selectedDate } = - useLocalSearchParams() as unknown as GiftIdea; + useLocalSearchParams() as unknown as { + id: string; + image: string; + title: string; + description?: string; + price: number; + recipient: string; + selectedDate: string; + }; + + const [recipientName, setRecipientName] = useState(null); + const [isImageLoading, setIsImageLoading] = useState(true); + const [isDeleting, setIsDeleting] = useState(false); + + useEffect(() => { + const fetchRecipientName = async () => { + const name = await findRecipientById(Number(recipient)); + setRecipientName(name); + }; + + fetchRecipientName(); + }, [recipient]); const handleEdit = () => { router.push({ @@ -32,31 +69,67 @@ const DetailGiftScreen = () => { { text: 'Delete', style: 'destructive', - onPress: () => console.log(`Delete action triggered for ID: ${id}`), + onPress: async () => { + try { + setIsDeleting(true); + + await deleteGiftThumbnail(image); + + await dispatch(deleteGift(id)); + + await dispatch(fetchGifts()); + + router.push('/(gifts)'); + + setTimeout(() => { + Alert.alert('Success', 'Gift deleted successfully.'); + }, 500); + } catch (error) { + console.error('Failed to delete gift or thumbnail:', error); + Alert.alert( + 'Error', + 'Failed to delete the gift. Please try again.', + ); + } finally { + setIsDeleting(false); + } + }, }, ], ); }; + const formattedDate = formatDate(selectedDate); + return ( - + + {isImageLoading && } + setIsImageLoading(true)} + onLoad={() => setIsImageLoading(false)} + /> + {title} {description} - ${price} - - Happening on {new Date(selectedDate).toLocaleDateString()} - - for {recipient} + {`$${formatPrice(price)}`} + Happening on {formattedDate} + for {recipientName || 'Loading...'} - - Delete + + {isDeleting ? ( + + ) : ( + Delete + )} Edit @@ -72,13 +145,21 @@ const styles = StyleSheet.create({ backgroundColor: '#FFFFFF', padding: 16, }, - image: { + imageContainer: { width: '100%', height: 250, - borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', marginBottom: 16, + position: 'relative', + }, + image: { + width: '100%', + height: '100%', + borderRadius: 16, borderWidth: 1, borderColor: '#f0f0f0', + position: 'absolute', }, title: { fontSize: 25, @@ -139,6 +220,9 @@ const styles = StyleSheet.create({ shadowRadius: 6, elevation: 5, }, + disabledButton: { + backgroundColor: '#FFB3B3', + }, buttonText: { color: '#FFFFFF', fontSize: 16, diff --git a/src/app/(gifts)/edit-gift.tsx b/src/app/(gifts)/edit-gift.tsx index ac7eb86..1b71aa3 100644 --- a/src/app/(gifts)/edit-gift.tsx +++ b/src/app/(gifts)/edit-gift.tsx @@ -1,44 +1,97 @@ -import { Ionicons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons'; import DateTimePicker from '@react-native-community/datetimepicker'; -import { - useNavigation, - useRoute as useRouteAlias, -} from '@react-navigation/native'; import * as ImagePicker from 'expo-image-picker'; -import { useState } from 'react'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; import { - Alert, Image, - Pressable, ScrollView, StyleSheet, Text, TextInput, + TouchableOpacity, View, } from 'react-native'; import Modal from 'react-native-modal'; -import { GiftIdea } from '@/models/GiftIdea'; +import { updateGift } from '@/features/gifts/giftSlice'; +import { + fetchRecipients, + findRecipientById, +} from '@/features/recipients/recipientService'; +import { useAppDispatch } from '@/redux/hooks'; +import { updateGiftThumbnail } from '@/services/updateImage'; +import { formatPrice } from '@/utils/priceUtils'; const EditGiftScreen = () => { - const navigation = useNavigation(); - const route = useRouteAlias(); - const params = route.params as GiftIdea; + const dispatch = useAppDispatch(); + const router = useRouter(); + const params = useLocalSearchParams() as unknown as { + id: string; + image: string; + title: string; + description?: string; + price: number; + recipient: string; + selectedDate: string; + }; const [title, setTitle] = useState(params?.title || ''); const [description, setDescription] = useState(params?.description || ''); - const [price, setPrice] = useState(params?.price || 0); - const [recipient, setRecipient] = useState(params?.recipient || ''); - const [selectedDate, setSelectedDate] = useState( - params?.selectedDate ? new Date(params.selectedDate) : new Date(), + const [recipient, setRecipient] = useState( + params?.recipient || null, ); - const [image, setImage] = useState(params?.image || ''); + const [recipientName, setRecipientName] = useState(null); + const [selectedDate, setSelectedDate] = useState( + params?.selectedDate ? new Date(params.selectedDate) : null, + ); + const [image, setImage] = useState(params?.image || null); + const [recipients, setRecipients] = useState< + { id: string; name: string; budget: number; spent: number }[] + >([]); const [showRecipientModal, setShowRecipientModal] = useState(false); const [showDatePicker, setShowDatePicker] = useState(false); - const recipients = ['Alex', 'Emily', 'Michael', 'Malow']; + const [errors, setErrors] = useState({ + title: '', + recipient: '', + selectedDate: '', + }); + + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + const loadRecipients = async () => { + try { + const data = await fetchRecipients(); + setRecipients(data); + } catch (error) { + alert('Failed to load recipients'); + } + }; + + const loadRecipientName = async () => { + if (recipient) { + try { + const recipientData = recipients.find((r) => r.id === recipient); + if (recipientData) { + setRecipientName(recipientData.name); + } else { + const fetchedRecipientName = await findRecipientById( + Number(recipient), + ); + setRecipientName(fetchedRecipientName); + } + } catch (error) { + console.error('Failed to fetch recipient name:', error); + } + } + }; + + loadRecipients(); + loadRecipientName(); + }, [recipient, recipients]); const pickImage = async () => { const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); @@ -59,105 +112,159 @@ const EditGiftScreen = () => { } }; - const handleEditGift = () => { - if (!title || !description || !recipient || !image) { - Alert.alert('Error', 'Please fill in all fields.'); + const validateFields = () => { + const newErrors = { + title: title ? '' : 'Title is required', + recipient: recipient ? '' : 'Recipient is required', + selectedDate: selectedDate ? '' : 'Date is required', + }; + + setErrors(newErrors); + + return !Object.values(newErrors).some((error) => error !== ''); + }; + + const handleSave = async () => { + if (!validateFields()) { return; } - const updatedGift: GiftIdea = { - id: params.id, - title, - description, - price, - recipient, - selectedDate: selectedDate.toISOString(), - image, - }; + if (!recipient) { + alert('Please select a recipient.'); + return; + } - console.log('Updated Gift Idea:', updatedGift); - Alert.alert('Success', 'Gift idea updated successfully!'); - navigation.goBack(); - }; + setIsSaving(true); - return ( - - - - Title - - + try { + let updatedImageUrl = params.image; - - Description - - + if (image && image !== params.image) { + updatedImageUrl = await updateGiftThumbnail(params.image, image); + } + + const giftData = { + id: params.id, + image: updatedImageUrl || 'https://placeholder.com/default-image.png', + title, + description, + recipient: recipient, + selectedDate: selectedDate ? selectedDate.toISOString() : '', + }; - - Image + await dispatch(updateGift(giftData)).unwrap(); + + alert('Gift updated successfully!'); + router.push('/(gifts)'); + } catch (error) { + console.error('Error updating gift:', error); + const errorMessage = + error instanceof Error ? error.message : 'An unknown error occurred'; + alert(`Failed to update gift: ${errorMessage}`); + } finally { + setIsSaving(false); + } + }; + + return ( + + {/* Upload Image */} + + Image + {image ? ( - <> - - - Change Image - - + ) : ( - - Pick an Image - + Pick an Image )} - + + - - Choose Recipient - setShowRecipientModal(true)} - > - - {recipient || 'Choose Recipient'} - - - - + {/* Title */} + + + Title * + + { + setTitle(text); + if (errors.title) setErrors({ ...errors, title: '' }); + }} + /> + {errors.title ? ( + {errors.title} + ) : null} + - - Choose Time Event - setShowDatePicker(true)} - > - - {selectedDate.toISOString().split('T')[0]} - - - - + {/* Description */} + + Description + + - - Save Changes - + {/* Price (Read-only) */} + + Price + ${formatPrice(params.price)} + - navigation.goBack()} + {/* Recipient */} + + + Recipient * + + setShowRecipientModal(true)} > - Cancel - - + + {recipientName || 'Choose Recipient'} + + + + {errors.recipient ? ( + {errors.recipient} + ) : null} + + + {/* Selected Date */} + + + Selected Date * + + setShowDatePicker(true)} + > + + {selectedDate ? selectedDate.toDateString() : 'Pick a date'} + + + + {errors.selectedDate ? ( + {errors.selectedDate} + ) : null} + + + {/* Save Button */} + + + {isSaving ? 'Saving...' : 'Save Changes'} + + {/* Recipient Modal */} { onBackButtonPress={() => setShowRecipientModal(false)} > - {recipients.map((item, index) => ( - ( + { - setRecipient(item); + setRecipient(item.id); + setRecipientName(item.name); + setErrors({ ...errors, recipient: '' }); setShowRecipientModal(false); }} > - {item} + {item.name} - + ))} + + {/* Date Picker */} {showDatePicker && ( { + if (event.type === 'set' && date) { + setSelectedDate(date); + setErrors({ ...errors, selectedDate: '' }); + } setShowDatePicker(false); - if (date) setSelectedDate(date); }} /> )} - + ); }; const styles = StyleSheet.create({ container: { - flex: 1, - backgroundColor: '#F8F9FA', - }, - scrollView: { - flex: 1, padding: 16, + backgroundColor: '#F5F5F5', + flexGrow: 1, }, inputGroup: { - marginBottom: 20, + marginBottom: 16, }, label: { - fontSize: 16, - color: '#666666', + fontSize: 14, + color: '#666', marginBottom: 8, }, + required: { + color: 'red', + }, input: { - backgroundColor: 'white', - borderRadius: 8, - padding: 12, - fontSize: 16, borderWidth: 1, - borderColor: '#DDDDDD', + borderColor: '#E0E0E0', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + backgroundColor: 'white', + color: '#333', }, - textArea: { - height: 100, - textAlignVertical: 'top', + inputError: { + borderColor: 'red', }, - imagePreview: { - width: '100%', - height: 200, - borderRadius: 8, - marginBottom: 12, + errorText: { + color: 'red', + fontSize: 12, + marginTop: 4, }, - changeImageButton: { - backgroundColor: '#007BFF', + imagePicker: { + borderWidth: 1, + borderColor: '#E0E0E0', borderRadius: 8, - padding: 12, + height: 180, alignItems: 'center', - marginTop: 8, + justifyContent: 'center', + backgroundColor: 'white', }, - changeImageText: { - color: 'white', + pickImageText: { + color: '#666', fontSize: 16, - fontWeight: '600', }, - select: { - backgroundColor: 'white', + previewImage: { + width: '100%', + height: '100%', borderRadius: 8, - padding: 12, + }, + dropdown: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderWidth: 1, - borderColor: '#DDDDDD', - }, - selectText: { - fontSize: 16, - color: '#666666', - }, - addButton: { - backgroundColor: '#4B6BFB', + borderColor: '#E0E0E0', borderRadius: 8, - padding: 16, - alignItems: 'center', - marginTop: 24, - }, - addButtonText: { - color: 'white', - fontSize: 16, - fontWeight: '600', - }, - cancelButton: { - backgroundColor: '#F8F9FA', - borderRadius: 8, - padding: 16, - alignItems: 'center', - marginTop: 12, + padding: 12, + backgroundColor: 'white', }, - cancelButtonText: { - color: '#666666', - fontSize: 16, - marginBottom: 20, + dropdownText: { + fontSize: 15, + color: '#333', }, modalContent: { backgroundColor: 'white', @@ -310,6 +406,25 @@ const styles = StyleSheet.create({ color: '#333', marginLeft: 8, }, + saveButton: { + backgroundColor: '#007AFF', + padding: 16, + borderRadius: 8, + alignItems: 'center', + marginTop: 24, + }, + saveButtonDisabled: { + backgroundColor: '#A0A0A0', + }, + saveButtonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + }, + infoText: { + fontSize: 16, + color: '#333', + }, }); export default EditGiftScreen; diff --git a/src/app/(gifts)/index.tsx b/src/app/(gifts)/index.tsx index 116940b..f5a3751 100644 --- a/src/app/(gifts)/index.tsx +++ b/src/app/(gifts)/index.tsx @@ -1,91 +1,227 @@ +import { MaterialIcons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; -import { useState } from 'react'; -import { Pressable, ScrollView, StyleSheet, Text } from 'react-native'; +import { useEffect, useState } from 'react'; +import { + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import Loading from '@/components/Loading'; import FilterTabs from '@/components/utils/FilterTabs'; import GiftCard from '@/components/utils/GiftCard'; -import { GiftIdea } from '@/models/GiftIdea'; +import { fetchGifts } from '@/features/gifts/giftSlice'; +import { fetchRecipients } from '@/features/recipients/recipientService'; +import { useAppDispatch, useAppSelector } from '@/redux/hooks'; const HomeScreen = () => { const router = useRouter(); + const dispatch = useAppDispatch(); + const { gifts, loading, error } = useAppSelector((state) => state.gifts); + + const [recipients, setRecipients] = useState<{ id: string; name: string }[]>( + [], + ); const [selectedTab, setSelectedTab] = useState('All'); + const [searchQuery, setSearchQuery] = useState(''); + const [isTitleSortActive, setIsTitleSortActive] = useState(false); + const [isDateSortActive, setIsDateSortActive] = useState(false); + const [titleSortDirection, setTitleSortDirection] = useState<'asc' | 'desc'>( + 'asc', + ); + const [dateSortDirection, setDateSortDirection] = useState<'asc' | 'desc'>( + 'asc', + ); + const [recipientFilter, setRecipientFilter] = useState(null); + + useEffect(() => { + dispatch(fetchGifts()); + + const loadRecipients = async () => { + try { + const data = await fetchRecipients(); + setRecipients(data); + } catch (error) { + console.error('Failed to fetch recipients:', error); + } + }; + + loadRecipients(); + }, [dispatch]); + + const filteredGifts = gifts + .filter((gift) => gift && gift.id) + .filter((gift) => + selectedTab === 'All' ? true : gift.recipient === selectedTab, + ) + .filter((gift) => + recipientFilter ? gift.recipient === recipientFilter : true, + ) + .filter((gift) => + gift.title.toLowerCase().includes(searchQuery.toLowerCase()), + ) + .sort((a, b) => { + if (isTitleSortActive) { + const direction = titleSortDirection === 'asc' ? 1 : -1; + const titleComparison = direction * a.title.localeCompare(b.title); + if (titleComparison !== 0) { + return titleComparison; + } + } - const giftIdeas: GiftIdea[] = [ - { - id: '1', - image: - 'https://api.a0.dev/assets/image?text=gift%20box%20with%20art%20supplies%20and%20golden%20bow', - title: 'Creative Art Set', - description: 'Perfect for budding artists', - price: 29.99, - recipient: 'Emily', - selectedDate: new Date('2023-12-25').toISOString(), - }, - { - id: '2', - image: - 'https://api.a0.dev/assets/image?text=luxury%20chocolate%20box%20assortment', - title: 'Gourmet Chocolate Basket', - description: 'Indulgent treat for any occasion', - price: 49.99, - recipient: 'Michael', - selectedDate: new Date('2023-11-15').toISOString(), - }, - { - id: '3', - image: - 'https://api.a0.dev/assets/image?text=elegant%20stainless%20steel%20watch', - title: 'Stainless Steel Watch', - description: 'Timeless elegance for him', - price: 199.99, - recipient: 'Alex', - selectedDate: new Date('2024-01-01').toISOString(), - }, - { - id: '4', - image: - 'https://api.a0.dev/assets/image?text=stylish%20handbag%20for%20women', - title: 'Stylish Handbag', - description: 'Fashionable accessory for her', - price: 89.99, - recipient: 'Sophia', - selectedDate: new Date('2023-10-31').toISOString(), - }, - { - id: '5', - image: - 'https://api.a0.dev/assets/image?text=high-tech%20wireless%20earbuds', - title: 'Wireless Earbuds', - description: 'High-quality sound on the go', - price: 79.99, - recipient: 'John', - selectedDate: new Date('2023-11-20').toISOString(), - }, - ]; + if (isDateSortActive) { + const direction = dateSortDirection === 'asc' ? 1 : -1; + return ( + direction * + (new Date(a.selectedDate).getTime() - + new Date(b.selectedDate).getTime()) + ); + } + + return 0; + }); + + const handleSort = (option: 'title' | 'date') => { + if (option === 'title') { + setIsTitleSortActive(true); + setIsDateSortActive(false); + setTitleSortDirection( + isTitleSortActive && titleSortDirection === 'asc' ? 'desc' : 'asc', + ); + } else if (option === 'date') { + setIsDateSortActive(true); + setIsTitleSortActive(false); + setDateSortDirection( + isDateSortActive && dateSortDirection === 'asc' ? 'desc' : 'asc', + ); + } + }; const handleAddGift = () => { router.push('/add-gift'); }; + if (loading) { + return ; + } + + if (error) { + return ( + + Error: {error} + + ); + } + return ( Your Gift Ideas - - - {giftIdeas.map((gift) => ( - + + {/* Thanh tìm kiếm */} + + + + - ))} + + + + {/* Dropdown sắp xếp */} + + handleSort('title')} + > + + Sort by Title + + {isTitleSortActive && ( + + )} + + handleSort('date')} + > + + Sort by Date + + {isDateSortActive && ( + + )} + + + + + {filteredGifts.map((gift) => { + if (!gift || !gift.id) { + console.error('Invalid gift data:', gift); + return null; + } + + return ( + + ); + })} + @@ -105,6 +241,55 @@ const styles = StyleSheet.create({ padding: 16, color: '#333333', }, + searchContainer: { + paddingHorizontal: 16, + marginBottom: 8, + }, + searchBox: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderColor: '#E0E0E0', + borderRadius: 50, + paddingHorizontal: 12, + backgroundColor: 'white', + }, + searchIcon: { + marginRight: 8, + }, + searchInput: { + flex: 1, + fontSize: 16, + color: '#333', + }, + sortContainer: { + flexDirection: 'row', + justifyContent: 'space-around', + paddingHorizontal: 16, + marginBottom: 8, + }, + sortButton: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 12, + borderWidth: 1, + borderColor: '#E0E0E0', + borderRadius: 8, + backgroundColor: 'white', + }, + activeSortButton: { + backgroundColor: '#e8eeff', + borderColor: '#4B6BFB', + }, + sortButtonText: { + fontSize: 16, + color: '#333', + marginRight: 4, + }, + activeSortButtonText: { + color: '#4B6BFB', + }, scrollView: { flex: 1, }, diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index c52371c..9035db0 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,53 +1,57 @@ +import store from '../redux/store'; import AntDesign from '@expo/vector-icons/AntDesign'; import Ionicons from '@expo/vector-icons/Ionicons'; import { Tabs } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import React from 'react'; import { View } from 'react-native'; +import { Provider } from 'react-redux'; import BottomTabBar from '@/components/tabbar/BottomTabBar'; const RootLayout = () => { return ( - - - } - screenOptions={{ - headerShown: false, - tabBarActiveTintColor: '#4B6BFB', - tabBarInactiveTintColor: '#666666', - }} - > - ( - - ), + + + + } + screenOptions={{ + headerShown: false, + tabBarActiveTintColor: '#4B6BFB', + tabBarInactiveTintColor: '#666666', }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - - + > + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + + ); }; diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx new file mode 100644 index 0000000..fb66164 --- /dev/null +++ b/src/components/Loading.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; + +const Loading = () => { + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#FFFFFF', + }, +}); + +export default Loading; diff --git a/src/components/utils/FilterTabs.tsx b/src/components/utils/FilterTabs.tsx index 413832d..8b14012 100644 --- a/src/components/utils/FilterTabs.tsx +++ b/src/components/utils/FilterTabs.tsx @@ -1,34 +1,122 @@ -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import React, { useState } from 'react'; +import { + FlatList, + Modal, + StyleSheet, + Text, + TouchableOpacity, + TouchableWithoutFeedback, + View, +} from 'react-native'; interface FilterTabsProps { selectedTab: string; onSelectTab: (tab: string) => void; + recipients: { id: string; name: string }[]; + selectedRecipient: string | null; + onSelectRecipient: (recipientId: string | null) => void; } export default function FilterTabs({ selectedTab, onSelectTab, + recipients, + selectedRecipient, + onSelectRecipient, }: FilterTabsProps) { - const tabs = ['All', 'Recipient', 'Occasion', 'Tag']; + const tabs = ['All', 'Recipients']; + const [showRecipientModal, setShowRecipientModal] = useState(false); + + const selectedRecipientName = + recipients.find((r) => r.id === selectedRecipient)?.name || 'Recipients'; return ( {tabs.map((tab) => ( onSelectTab(tab)} + style={[ + styles.tab, + tab === 'All' && styles.selectedTab, + tab === 'Recipients' && + selectedRecipient && + styles.activeRecipientTab, + ]} + onPress={() => { + if (tab === 'Recipients') { + setShowRecipientModal(true); + } + }} > - {tab} + {tab === 'Recipients' ? selectedRecipientName : tab} + {tab === 'Recipients' && ( + + )} ))} + + {/* Dialog chọn recipient */} + setShowRecipientModal(false)} + > + setShowRecipientModal(false)}> + + + + Select Recipient + item.id} + renderItem={({ item }) => ( + { + onSelectRecipient(item.id); + setShowRecipientModal(false); + }} + > + + {item.name} + + + )} + /> + { + onSelectRecipient(null); + setShowRecipientModal(false); + }} + > + Clear Selection + + + + + + ); } @@ -41,6 +129,8 @@ const styles = StyleSheet.create({ gap: 8, }, tab: { + flexDirection: 'row', + alignItems: 'center', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, @@ -49,6 +139,10 @@ const styles = StyleSheet.create({ selectedTab: { backgroundColor: '#e8eeff', }, + activeRecipientTab: { + borderColor: '#4B6BFB', + borderWidth: 1, + }, tabText: { color: '#666', fontSize: 14, @@ -56,4 +150,41 @@ const styles = StyleSheet.create({ selectedTabText: { color: '#4B6BFB', }, + filterIcon: { + marginLeft: 4, + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + }, + modalContent: { + width: '80%', + backgroundColor: 'white', + borderRadius: 8, + padding: 16, + }, + modalTitle: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 16, + textAlign: 'center', + }, + option: { + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#E0E0E0', + }, + activeOption: { + backgroundColor: '#e8eeff', + }, + optionText: { + fontSize: 16, + color: '#333', + textAlign: 'center', + }, + activeOptionText: { + color: '#4B6BFB', + }, }); diff --git a/src/components/utils/GiftCard.tsx b/src/components/utils/GiftCard.tsx index 65ba506..394c354 100644 --- a/src/components/utils/GiftCard.tsx +++ b/src/components/utils/GiftCard.tsx @@ -1,12 +1,13 @@ -import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; -import { Alert, Image, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useEffect, useState } from 'react'; +import { Image, Pressable, StyleSheet, Text, View } from 'react-native'; -import { GiftIdea } from '@/models/GiftIdea'; +import { GiftIdea } from '@/features/gifts/types'; +import { findRecipientById } from '@/features/recipients/recipientService'; +import { formatDate } from '@/utils/dateUtils'; interface GiftCardProps extends GiftIdea { onEdit?: () => void; - onDelete?: () => void; } export default function GiftCard({ @@ -19,6 +20,18 @@ export default function GiftCard({ selectedDate, }: GiftCardProps) { const router = useRouter(); + const [recipientName, setRecipientName] = useState(null); + + useEffect(() => { + const fetchRecipientName = async () => { + const name = await findRecipientById(Number(recipient)); + setRecipientName(name); + }; + + fetchRecipientName(); + }, [recipient]); + + const formattedDate = formatDate(selectedDate); const handlePress = () => { router.push({ @@ -32,10 +45,10 @@ export default function GiftCard({ {title} - for {recipient} - - Happening on {new Date(selectedDate).toLocaleDateString()} + + for {recipientName || 'Loading...'} + Happening on {formattedDate} ); @@ -70,11 +83,6 @@ const styles = StyleSheet.create({ fontWeight: '600', color: '#333333', }, - description: { - fontSize: 14, - color: '#666666', - marginTop: 4, - }, recipient: { fontSize: 14, color: '#666666', @@ -85,12 +93,4 @@ const styles = StyleSheet.create({ color: '#666666', marginTop: 4, }, - actions: { - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'space-evenly', - }, - actionButton: { - marginVertical: 8, - }, }); diff --git a/src/features/gifts/giftService.ts b/src/features/gifts/giftService.ts new file mode 100644 index 0000000..04d8861 --- /dev/null +++ b/src/features/gifts/giftService.ts @@ -0,0 +1,38 @@ +import { CreateGiftDTO, GiftIdea } from '@/features/gifts/types'; +import supabase from '@/services/supabaseClient'; + +export const fetchGifts = async (): Promise => { + const { data, error } = await supabase.from('gifts').select('*'); + if (error) throw error; + return data as GiftIdea[]; +}; + +export const addGift = async (gift: CreateGiftDTO): Promise => { + const { data, error } = await supabase.from('gifts').insert([gift]).single(); + if (error) throw error; + return data as GiftIdea; +}; + +export const deleteGift = async (id: string): Promise => { + const { error } = await supabase.from('gifts').delete().eq('id', id); + if (error) throw error; +}; + +export const updateGift = async ( + gift: Partial, +): Promise => { + const { data, error } = await supabase + .from('gifts') + .update(gift) + .eq('id', gift.id) + .select('*') + .single(); + + if (error) { + console.error('Supabase updateGift error:', error); + throw error; + } + + console.log('Supabase updateGift response:', data); + return data as GiftIdea; +}; diff --git a/src/features/gifts/giftSlice.ts b/src/features/gifts/giftSlice.ts new file mode 100644 index 0000000..15b058b --- /dev/null +++ b/src/features/gifts/giftSlice.ts @@ -0,0 +1,133 @@ +import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'; + +import * as giftService from '@/features/gifts/giftService'; +import { CreateGiftDTO, GiftIdea, GiftState } from '@/features/gifts/types'; + +const initialState: GiftState = { + gifts: [], + loading: false, + error: null, +}; + +export const fetchGifts = createAsyncThunk( + 'gifts/fetchGifts', + async (_, { rejectWithValue }) => { + try { + return await giftService.fetchGifts(); + } catch (error) { + return rejectWithValue((error as Error).message); + } + }, +); + +export const addGift = createAsyncThunk( + 'gifts/addGift', + async (gift: CreateGiftDTO, { rejectWithValue }) => { + try { + return await giftService.addGift(gift); + } catch (error) { + return rejectWithValue((error as Error).message); + } + }, +); + +export const deleteGift = createAsyncThunk( + 'gifts/deleteGift', + async (id: string, { rejectWithValue }) => { + try { + await giftService.deleteGift(id); + return id; + } catch (error) { + return rejectWithValue((error as Error).message); + } + }, +); + +// Thêm action updateGift +export const updateGift = createAsyncThunk( + 'gifts/updateGift', + async (gift: Partial, { rejectWithValue }) => { + try { + return await giftService.updateGift(gift); + } catch (error) { + return rejectWithValue((error as Error).message); + } + }, +); + +const giftSlice = createSlice({ + name: 'gifts', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + // Fetch Gifts + .addCase(fetchGifts.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase( + fetchGifts.fulfilled, + (state, action: PayloadAction) => { + state.loading = false; + state.gifts = action.payload; + }, + ) + .addCase(fetchGifts.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // Add Gift + .addCase(addGift.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(addGift.fulfilled, (state, action: PayloadAction) => { + state.loading = false; + state.gifts.push(action.payload); + }) + .addCase(addGift.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // Delete Gift + .addCase(deleteGift.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(deleteGift.fulfilled, (state, action: PayloadAction) => { + state.loading = false; + state.gifts = state.gifts.filter((gift) => gift.id !== action.payload); + }) + .addCase(deleteGift.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // Update Gift + .addCase(updateGift.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase( + updateGift.fulfilled, + (state, action: PayloadAction) => { + state.loading = false; + const index = state.gifts.findIndex( + (gift) => gift.id === action.payload.id, + ); + if (index !== -1) { + state.gifts[index] = action.payload; + } + }, + ) + .addCase(updateGift.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }); + }, +}); + +export default giftSlice.reducer; diff --git a/src/features/gifts/types.ts b/src/features/gifts/types.ts new file mode 100644 index 0000000..6e66688 --- /dev/null +++ b/src/features/gifts/types.ts @@ -0,0 +1,25 @@ +export interface GiftIdea { + id: string; + title: string; + description?: string; + image: string; + price: number; + recipient: string; + selectedDate: string; + createdAt?: string; +} + +export interface GiftState { + gifts: GiftIdea[]; + loading: boolean; + error: string | null; +} + +export interface CreateGiftDTO { + title: string; + description?: string; + image: string; + price: number; + recipient: string; + selectedDate: string; +} diff --git a/src/features/recipients/recipientService.ts b/src/features/recipients/recipientService.ts new file mode 100644 index 0000000..41492e1 --- /dev/null +++ b/src/features/recipients/recipientService.ts @@ -0,0 +1,54 @@ +import { CreateRecipientDTO, Recipient } from '@/features/recipients/types'; +import supabase from '@/services/supabaseClient'; + +export const fetchRecipients = async (): Promise => { + const { data, error } = await supabase.from('recipients').select('*'); + if (error) throw error; + return data as Recipient[]; +}; + +export const addRecipient = async ( + recipient: CreateRecipientDTO, +): Promise => { + const { data, error } = await supabase + .from('recipients') + .insert([recipient]) + .single(); + if (error) throw error; + return data as Recipient; +}; + +export const deleteRecipient = async (id: string): Promise => { + const { error } = await supabase.from('recipients').delete().eq('id', id); + if (error) throw error; +}; + +export const updateRecipient = async ( + id: string, + updates: Partial, +): Promise => { + const { data, error } = await supabase + .from('recipients') + .update(updates) + .eq('id', id) + .single(); + + if (error) throw error; + + return data as Recipient; +}; + +export const findRecipientById = async (id: number): Promise => { + const { data, error } = await supabase + .from('recipients') + .select('name') + .eq('id', id) + .single(); + + if (error) { + console.error('Error fetching recipient:', error); + return null; + } + + return data?.name || null; +}; diff --git a/src/features/recipients/recipientSlice.ts b/src/features/recipients/recipientSlice.ts new file mode 100644 index 0000000..5934210 --- /dev/null +++ b/src/features/recipients/recipientSlice.ts @@ -0,0 +1,122 @@ +import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'; + +import * as recipientService from '@/features/recipients/recipientService'; +import { + CreateRecipientDTO, + Recipient, + RecipientState, +} from '@/features/recipients/types'; + +const initialState: RecipientState = { + recipients: [], + loading: false, + error: null, +}; + +export const fetchRecipients = createAsyncThunk( + 'recipients/fetchRecipients', + async (_, { rejectWithValue }) => { + try { + return await recipientService.fetchRecipients(); + } catch (error) { + return rejectWithValue((error as Error).message); + } + }, +); + +export const addRecipient = createAsyncThunk( + 'recipients/addRecipient', + async (recipient: CreateRecipientDTO, { rejectWithValue }) => { + try { + return await recipientService.addRecipient(recipient); + } catch (error) { + return rejectWithValue((error as Error).message); + } + }, +); + +export const deleteRecipient = createAsyncThunk( + 'recipients/deleteRecipient', + async (id: string, { rejectWithValue }) => { + try { + await recipientService.deleteRecipient(id); + return id; + } catch (error) { + return rejectWithValue((error as Error).message); + } + }, +); + +export const updateRecipient = createAsyncThunk( + 'recipients/updateRecipient', + async ( + { id, updates }: { id: string; updates: Partial }, + { rejectWithValue }, + ) => { + try { + return await recipientService.updateRecipient(id, updates); + } catch (error) { + return rejectWithValue((error as Error).message); + } + }, +); + +const recipientSlice = createSlice({ + name: 'recipients', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(fetchRecipients.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase( + fetchRecipients.fulfilled, + (state, action: PayloadAction) => { + state.loading = false; + state.recipients = action.payload; + }, + ) + .addCase(fetchRecipients.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + .addCase( + addRecipient.fulfilled, + (state, action: PayloadAction) => { + state.recipients.push(action.payload); + }, + ) + .addCase( + deleteRecipient.fulfilled, + (state, action: PayloadAction) => { + state.recipients = state.recipients.filter( + (recipient) => recipient.id !== action.payload, + ); + }, + ) + .addCase(updateRecipient.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase( + updateRecipient.fulfilled, + (state, action: PayloadAction) => { + state.loading = false; + const index = state.recipients.findIndex( + (recipient) => recipient.id === action.payload.id, + ); + if (index !== -1) { + state.recipients[index] = action.payload; + } + }, + ) + .addCase(updateRecipient.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }); + }, +}); + +export default recipientSlice.reducer; diff --git a/src/features/recipients/types.ts b/src/features/recipients/types.ts new file mode 100644 index 0000000..5a66d0a --- /dev/null +++ b/src/features/recipients/types.ts @@ -0,0 +1,23 @@ +export interface Recipient { + id: string; + image: string; + name: string; + description?: string; + budget: number; + spent: number; + createdAt?: string; +} + +export interface RecipientState { + recipients: Recipient[]; + loading: boolean; + error: string | null; +} + +export interface CreateRecipientDTO { + image: string; + name: string; + description?: string; + budget: number; + spent: number; +} diff --git a/src/models/GiftIdea.ts b/src/models/GiftIdea.ts deleted file mode 100644 index 239b51b..0000000 --- a/src/models/GiftIdea.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface GiftIdea { - id: string; - image: string; - title: string; - description: string; - price: number; - recipient: string; - selectedDate: string; -} diff --git a/src/models/Recipient.ts b/src/models/Recipient.ts deleted file mode 100644 index e8c02ec..0000000 --- a/src/models/Recipient.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface Recipient { - id: string; - image: string; - name: string; - description?: string; - budget: number; - spent: number; -} diff --git a/src/redux/hooks.ts b/src/redux/hooks.ts new file mode 100644 index 0000000..f62e922 --- /dev/null +++ b/src/redux/hooks.ts @@ -0,0 +1,7 @@ +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; + +import type { AppDispatch, RootState } from './store'; + +export const useAppDispatch: () => AppDispatch = useDispatch; + +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/redux/rootReducer.ts b/src/redux/rootReducer.ts new file mode 100644 index 0000000..e181fbc --- /dev/null +++ b/src/redux/rootReducer.ts @@ -0,0 +1,10 @@ +import giftReducer from '../features/gifts/giftSlice'; +import recipientReducer from '../features/recipients/recipientSlice'; +import { combineReducers } from '@reduxjs/toolkit'; + +const rootReducer = combineReducers({ + gifts: giftReducer, + recipients: recipientReducer, +}); + +export default rootReducer; diff --git a/src/redux/store.ts b/src/redux/store.ts new file mode 100644 index 0000000..37b9eff --- /dev/null +++ b/src/redux/store.ts @@ -0,0 +1,12 @@ +import { configureStore } from '@reduxjs/toolkit'; + +import rootReducer from './rootReducer'; + +const store = configureStore({ + reducer: rootReducer, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; + +export default store; diff --git a/src/services/deleteImage.ts b/src/services/deleteImage.ts new file mode 100644 index 0000000..8249c22 --- /dev/null +++ b/src/services/deleteImage.ts @@ -0,0 +1,32 @@ +import supabase from '@/services/supabaseClient'; + +/** + * Deletes an image from Supabase storage based on its public URL. + * @param imageUrl - The public URL of the image to delete. + * @param bucketName - The name of the Supabase storage bucket (default: 'gift-thumbnail'). + * @throws Will throw an error if the deletion fails. + */ +export const deleteGiftThumbnail = async ( + imageUrl: string, + bucketName: string = 'gift-thumbnail', +): Promise => { + try { + const fileName = imageUrl.split('/').pop(); + if (!fileName) { + console.error('Invalid image URL'); + throw new Error('Invalid image URL'); + } + + const { error } = await supabase.storage + .from(bucketName) + .remove([fileName]); + + if (error) { + console.error('Error deleting file from Supabase:', error.message); + throw error; + } + } catch (error) { + console.error('Error deleting image:', error); + throw new Error('Failed to delete image'); + } +}; diff --git a/src/services/supabaseClient.ts b/src/services/supabaseClient.ts new file mode 100644 index 0000000..74b4244 --- /dev/null +++ b/src/services/supabaseClient.ts @@ -0,0 +1,10 @@ +import { SUPABASE_KEY, SUPABASE_URL } from '@env'; +import { createClient } from '@supabase/supabase-js'; + +if (!SUPABASE_URL || !SUPABASE_KEY) { + throw new Error('Missing Supabase environment variables'); +} + +const supabase = createClient(SUPABASE_URL, SUPABASE_KEY); + +export default supabase; diff --git a/src/services/updateImage.ts b/src/services/updateImage.ts new file mode 100644 index 0000000..c644a7b --- /dev/null +++ b/src/services/updateImage.ts @@ -0,0 +1,28 @@ +import { deleteGiftThumbnail } from '@/services/deleteImage'; +import { uploadGiftThumbnail } from '@/services/uploadImage'; + +/** + * Updates an image in Supabase storage by deleting the old image and uploading a new one. + * @param oldImageUrl - The public URL of the old image to delete. + * @param newImageUri - The URI of the new image to upload. + * @param bucketName - The name of the Supabase storage bucket (default: 'gift-thumbnail'). + * @returns The public URL of the new image. + */ +export const updateGiftThumbnail = async ( + oldImageUrl: string, + newImageUri: string, + bucketName: string = 'gift-thumbnail', +): Promise => { + try { + if (oldImageUrl) { + await deleteGiftThumbnail(oldImageUrl, bucketName); + } + + const newImageUrl = await uploadGiftThumbnail(newImageUri, bucketName); + + return newImageUrl; + } catch (error) { + console.error('Error updating image:', error); + throw new Error('Failed to update image'); + } +}; diff --git a/src/services/uploadImage.ts b/src/services/uploadImage.ts new file mode 100644 index 0000000..b7ad73e --- /dev/null +++ b/src/services/uploadImage.ts @@ -0,0 +1,51 @@ +import * as FileSystem from 'expo-file-system'; + +import supabase from '@/services/supabaseClient'; + +export const uploadGiftThumbnail = async ( + uri: string, + bucketName: string = 'gift-thumbnail', +): Promise => { + try { + const fileName = uri.split('/').pop(); + if (!fileName) { + console.error('Invalid file name'); + throw new Error('Invalid file name'); + } + + const fileInfo = await FileSystem.readAsStringAsync(uri, { + encoding: FileSystem.EncodingType.Base64, + }); + + const arrayBuffer = Uint8Array.from(atob(fileInfo), (c) => + c.charCodeAt(0), + ).buffer; + + const { data, error } = await supabase.storage + .from(bucketName) + .upload(fileName, arrayBuffer, { + contentType: 'image/jpeg', + cacheControl: '3600', + upsert: true, + }); + + if (error) { + console.error('Error uploading file to Supabase:', error.message); + throw error; + } + + const { data: publicUrlData } = supabase.storage + .from(bucketName) + .getPublicUrl(fileName); + + if (!publicUrlData?.publicUrl) { + console.error('Failed to retrieve public URL'); + throw new Error('Failed to retrieve public URL'); + } + + return publicUrlData.publicUrl; + } catch (error) { + console.error('Error uploading image:', error); + throw new Error('Failed to upload image'); + } +}; diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts new file mode 100644 index 0000000..211dd92 --- /dev/null +++ b/src/utils/dateUtils.ts @@ -0,0 +1,12 @@ +export const formatDate = (dateString: string): string => { + if (!dateString) return 'Invalid Date'; + + const date = new Date(dateString); + if (isNaN(date.getTime())) return 'Invalid Date'; + + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); +}; diff --git a/src/utils/priceUtils.ts b/src/utils/priceUtils.ts new file mode 100644 index 0000000..e5d5f21 --- /dev/null +++ b/src/utils/priceUtils.ts @@ -0,0 +1,11 @@ +/** + * Formats a number into a price string with commas as thousand separators. + * @param value - The number to format. + * @returns The formatted price string. + */ +export const formatPrice = (value: number): string => { + return value.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +}; diff --git a/tsconfig.json b/tsconfig.json index 62a95f7..e4c8a7c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,8 @@ "strict": true, "paths": { "@/*": ["./src/*"] - } + }, + "typeRoots": ["./node_modules/@types", "./@types"] }, "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] }