From 2580f4302d36e40237a097052b909cb84807d1b0 Mon Sep 17 00:00:00 2001 From: MinSeo <138097126+m1nse0kim@users.noreply.github.com> Date: Mon, 27 May 2024 00:54:36 +0900 Subject: [PATCH] Kan 84 (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature: 리뷰 작성 페이지 UI 작업 * feat: 가게 상세 페이지 ui * 가게 상세 페이지 정보(메뉴, 리뷰) 로딩 및 전화 모달 창 추가 * 리뷰 작성 시 사진 최대 3개 추가 및 작성 완료 --------- Co-authored-by: MinseoKim --- src/assets/color.js | 1 + src/assets/svg.js | 42 +- src/screens/detail/ReviewWriteScreen.js | 231 ++++++++-- src/screens/detail/StoreDetailScreen.js | 572 ++++++++++++++++++++++-- 4 files changed, 790 insertions(+), 56 deletions(-) diff --git a/src/assets/color.js b/src/assets/color.js index 95c667e..c1fb22b 100644 --- a/src/assets/color.js +++ b/src/assets/color.js @@ -9,3 +9,4 @@ export const COLOR_TEXT70GRAY = '#4D4D4D'; export const COLOR_TEXT60GRAY = '#666666'; export const COLOR_NAVY = '#003C71'; export const COLOR_LIGHTGRAY = '#dddddd'; +export const COLOR_ORANGE = '#FF6A13'; \ No newline at end of file diff --git a/src/assets/svg.js b/src/assets/svg.js index cbb347c..f68d93f 100644 --- a/src/assets/svg.js +++ b/src/assets/svg.js @@ -196,12 +196,50 @@ export const svgXml = { `, + starGrey: ` + + + + `, + heartGrey: ` + + + + `, + emptyHeartGrey: ` + + + + `, camera: ` - - + + + + `, + phone: ` + + + + `, + pen: ` + + + + + `, + location: ` + + + + + `, + clock: ` + + + `, emptyStar: ` diff --git a/src/screens/detail/ReviewWriteScreen.js b/src/screens/detail/ReviewWriteScreen.js index 39d1634..63e7a27 100644 --- a/src/screens/detail/ReviewWriteScreen.js +++ b/src/screens/detail/ReviewWriteScreen.js @@ -27,6 +27,7 @@ import { COLOR_TEXT70GRAY, COLOR_TEXT_BLACK, COLOR_LIGHTGRAY, + COLOR_ORANGE, } from '../../assets/color'; import AnimatedButton from '../../components/AnimationButton'; import {useNavigation} from '@react-navigation/native'; @@ -35,22 +36,112 @@ import {svgXml} from '../../assets/svg'; import Header from '../../components/Header'; import AppContext from '../../components/AppContext'; import axios from 'axios'; -import {API_URL} from '@env'; +import { API_URL, IMG_URL } from '@env'; import {Dimensions} from 'react-native'; import TodayPick from '../../components/TodayPick'; import FoodCategory from '../../components/FoodCategory'; import KingoPass from '../../components/KingoPass'; +import ImagePicker from 'react-native-image-crop-picker'; +import RNFS from 'react-native-fs' const windowWidth = Dimensions.get('window').width; export default function ReviewWriteScreen(props) { const navigation = useNavigation(); - const {route} = props; + const context = useContext(AppContext); + const { route } = props; const storeData = route.params?.data; const [rating, setRating] = useState(0); + const [reviewContent, setReviewContent] = useState(''); + const [showRatingError, setShowRatingError] = useState(false); + const [showContentError, setShowContentError] = useState(false); + const [showImageError, setShowImageError] = useState(false); + const [reviewImage, setReviewImage] = useState([]); console.log('storeData:', storeData); + const handleReviewSubmit = async () => { + if (rating === 0) { + setShowRatingError(true); + return; + } + if (reviewContent.trim() === '') { + setShowContentError(true); + return; + } + + try { + const response = await axios.post( + `${API_URL}/v1/restaurants/${storeData.id}/reviews`, + { + content: reviewContent, + imageUrls: reviewImage, + rating: rating, + }, + { + headers: { Authorization: `Bearer ${context.accessToken}` }, + }, + ); + console.log('Review submitted successfully:', response.data); + navigation.goBack(); + } catch (error) { + console.error('Error submitting review:', error); + } + }; + + const uploadImage = async (image) => { + if (reviewImage.length >= 3) { + setShowImageError(true); + return; + } + + let imageData = ''; + await RNFS.readFile(image.path, 'base64') + .then((data) => { + console.log('encoded', data); + imageData = data; + }) + .catch((err) => { + console.error(err); + }); + + try { + const response = await axios.post(`${IMG_URL}/v1/upload-image`, { + images: [ + { + imageData: imageData, + location: 'test', + }, + ], + }); + + console.log('response image:', response.data); + + if (response.data.result != 'SUCCESS') { + console.log('Error: No return data'); + return; + } + + setReviewImage((prevImages) => [...prevImages, response.data.data[0].imageUrl]); + } catch (error) { + console.log('Error:', error); + } + }; + + const removeImage = (index) => { + setReviewImage((prevImages) => prevImages.filter((_, i) => i !== index)); + }; + + const DottedLine = () => { + return ( + + {[...Array(20)].map((_, index) => ( + + ))} + + ); + }; + const DottedLine = () => { return ( @@ -64,12 +155,18 @@ export default function ReviewWriteScreen(props) { return ( <>
- - < View style={styles.headerContainer}> + + {storeData.name} {[...Array(5)].map((_, index) => ( - setRating(index + 1)}> + { + setRating(index + 1); + setShowRatingError(false); + }} + > - - 사진 첨부하기 - - - - - 완료 - - + + {showRatingError && 평점을 매겨주세요} + { + console.log('리뷰 사진 추가', reviewImage); + if (reviewImage.length >= 3) { + console.log('Error: Maximum 3 images allowed'); + return; + } + ImagePicker.openPicker({ + width: 400, + height: 400, + cropping: true, + multiple: true, + }).then((images) => { + images.forEach((image) => uploadImage(image)); + }).catch((error) => { + console.error('Image Picker Error:', error); + }); + }}> + + 사진 첨부하기 + + {showImageError && 사진은 최대 3개만 넣어주세요} + { + setReviewContent(text); + setShowContentError(false); + }} + /> + {showContentError && 리뷰 내용을 작성해주세요} + + + + {reviewImage.map((image, index) => ( + + + removeImage(index)}> + X + + + ))} + + + + + 완료 + + + ); } @@ -100,7 +242,6 @@ export default function ReviewWriteScreen(props) { const styles = StyleSheet.create({ entire: { backgroundColor: COLOR_WHITE, - // justifyContent: 'center', alignItems: 'center', height: '100%', }, @@ -110,7 +251,11 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', width: '100%', paddingHorizontal: 16, - marginVertical: 24, + marginVertical: 20, + }, + Container: { + width: '100%', + marginHorizontal: 16, }, storeName: { fontSize: 22, @@ -121,20 +266,50 @@ const styles = StyleSheet.create({ flexDirection: 'row', }, photoButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', backgroundColor: COLOR_WHITE, borderColor: COLOR_PRIMARY, borderWidth: 1, padding: 12, borderRadius: 8, - alignItems: 'center', - marginTop: 8, + marginTop: 6, marginBottom: 16, width: '92%', - height: '', }, photoButtonText: { color: COLOR_PRIMARY, fontSize: 16, + marginLeft: 4, + }, + imageScrollView: { + height: 120, + }, + imageContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + imageWrapper: { + position: 'relative', + marginRight: 8, + }, + image: { + width: 120, + height: 120, + borderRadius: 10, + }, + removeButton: { + position: 'absolute', + top: 5, + right: 5, + backgroundColor: 'rgba(0,0,0,0.5)', + padding: 4, + borderRadius: 5, + }, + removeButtonText: { + color: COLOR_WHITE, + fontSize: 12, }, reviewInput: { backgroundColor: COLOR_WHITE, @@ -143,11 +318,15 @@ const styles = StyleSheet.create({ borderRadius: 8, padding: 12, width: '92%', - height: 150, + height: 160, textAlignVertical: 'top', marginVertical: 16, fontSize: 16, }, + errorText: { + color: COLOR_ORANGE, + fontSize: 10, + }, dottedContainer: { flexDirection: 'row', width: '92%', @@ -167,7 +346,7 @@ const styles = StyleSheet.create({ padding: 16, borderRadius: 32, alignItems: 'center', - width: '90%', + width: '92%', marginBottom: 16, shadowColor: COLOR_TEXT_BLACK, shadowOffset: { width: 0, height: 3 }, @@ -180,4 +359,4 @@ const styles = StyleSheet.create({ fontSize: 18, fontWeight: '600', }, -}); +}); \ No newline at end of file diff --git a/src/screens/detail/StoreDetailScreen.js b/src/screens/detail/StoreDetailScreen.js index d2bce8c..44a14dd 100644 --- a/src/screens/detail/StoreDetailScreen.js +++ b/src/screens/detail/StoreDetailScreen.js @@ -22,8 +22,12 @@ import { COLOR_BACKGROUND, COLOR_GRAY, COLOR_PRIMARY, + COLOR_TEXT_DARKGRAY, COLOR_TEXT70GRAY, + COLOR_TEXT60GRAY, COLOR_TEXT_BLACK, + COLOR_LIGHTGRAY, + COLOR_ORANGE, } from '../../assets/color'; import AnimatedButton from '../../components/AnimationButton'; import {useNavigation} from '@react-navigation/native'; @@ -34,39 +38,551 @@ import AppContext from '../../components/AppContext'; import axios from 'axios'; import {API_URL} from '@env'; import {Dimensions} from 'react-native'; -import TodayPick from '../../components/TodayPick'; -import FoodCategory from '../../components/FoodCategory'; -import KingoPass from '../../components/KingoPass'; +import ImageModal from 'react-native-image-modal'; +import { Modal, TouchableHighlight } from 'react-native'; const windowWidth = Dimensions.get('window').width; export default function StoreDetailScreen(props) { const navigation = useNavigation(); - const {route} = props; - const storeData = route.params?.data; + const context = useContext(AppContext); + const { route } = props; + const restaurantId = route.params?.data?.id || 1; + const [restaurant, setRestaurant] = useState(null); + const [isHearted, setIsHearted] = useState(false); + const [heartCount, setHeartCount] = useState(0); + const [menuList, setMenuList] = useState([]); + const [reviewList, setReviewList] = useState([]); + const [displayedMenuList, setDisplayedMenuList] = useState([]); + const [displayedReviewList, setDisplayedReviewList] = useState([]); + const [menuCount, setMenuCount] = useState(4); + const [reviewCount, setReviewCount] = useState(4); + const [modalVisible, setModalVisible] = useState(false); - console.log('storeData:', storeData); + useEffect(() => { + restaurantDetail(); + handleHeartPress(); + }, []); - return ( - <> -
- - {storeData.name} - { - navigation.navigate('ReviewWrite', {data: storeData}); - }}> - 리뷰쓰기버튼 - - - - ); -} + useEffect(() => { + setDisplayedMenuList(menuList.slice(0, menuCount)); + }, [menuList, menuCount]); -const styles = StyleSheet.create({ - entire: { - backgroundColor: COLOR_BACKGROUND, - // justifyContent: 'center', - alignItems: 'center', - }, -}); + useEffect(() => { + setDisplayedReviewList(reviewList.slice(0, reviewCount)); + }, [reviewList, reviewCount]); + + const restaurantDetail = async () => { + console.log('Id: ', restaurantId); + try { + const response = await axios.get(`${API_URL}/v1/restaurants/${restaurantId}`, { + headers: { Authorization: `Bearer ${context.accessToken}` }, + }); + const responseReview = await axios.get(`${API_URL}/v1/restaurants/${restaurantId}/reviews`, { + headers: { Authorization: `Bearer ${context.accessToken}` }, + }); + const data = response.data.data; + const dataReview = responseReview.data.data; + + console.log('data: ', data.restaurant.isLike); + console.log('review: ', dataReview); + + setRestaurant(data); + setIsHearted(data.restaurant.isLike); + setHeartCount(data.restaurant.likeCount); + setMenuList(data.restaurant.detailInfo.menus); + setReviewList(dataReview.reviews.content); + } catch (error) { + console.error('Error fetching restaurant details:', error); + } + }; + + const handleHeartPress = async () => { + try { + const newHeartedState = !isHearted; + setIsHearted(newHeartedState); + setHeartCount(newHeartedState ? heartCount + 1 : heartCount - 1); + + await axios.post( + `${API_URL}/v1/restaurants/${restaurantId}/like`, + { + isLike: newHeartedState, + }, + { + headers: { Authorization: `Bearer ${context.accessToken}` }, + }, + ); + + console.log('isLike: ', newHeartedState); + } catch (error) { + console.error('Error updating heart count:', error); + setIsHearted(!newHeartedState); + setHeartCount(newHeartedState ? heartCount - 1 : heartCount + 1); + } + }; + + const handleLoadMoreMenus = () => { + setMenuCount(menuCount + 4); + }; + + const handleLoadMoreReviews = () => { + setReviewCount(reviewCount + 4); + }; + + if (!restaurant) { + return Loading...; + } + + const renderMenuItem = ({item}) => ( + <> + + {item.imageUrl ? ( + + ) : ( + + (빈 이미지) + + )} + + {item.name} + {item.description} + {item.price} 원 + + + + + ); + + const renderReviewItem = ({item}) => { + const rating = item.rating; + const likeCount = item.likeCount; + const viewCount = item.viewCount; + + const renderStars = () => { + const stars = []; + for (let i = 0; i < 5; i++) { + stars.push( + + ); + } + return stars; + }; + + return ( + <> + + + + + {item.profileImageUrl ? ( + + ) : ( + + )} + {item.username} + + + + {`좋아요 ${likeCount}`} + {renderStars()} + + + {item.content} + + + ( + + )} + keyExtractor={(image, index) => `${item.id}-${index}`} + /> + + + ); + }; + + const ListHeader = () => ( + <> + + + + + + + + + {restaurant.restaurant.name} + {restaurant.restaurant.categories} + + + 찜 {heartCount} + · + 리뷰 {restaurant.restaurant.reviewCount} + + + + + setModalVisible(true)} + > + + 전화 + + + + + + + + + + {restaurant.restaurant.ratingAvg.toFixed(1)} + + + { + navigation.navigate('ReviewWrite', { data: restaurant.restaurant }); + }}> + + 리뷰 + + + + + + + 위치: {restaurant.restaurant.detailInfo.address} + + + + + + 전화번호: {restaurant.restaurant.detailInfo.contactNumber} + + + + + + 메뉴 + {menuList.length} + + item.name} + /> + {menuCount < menuList.length && ( + + 메뉴 더보기 + + )} + + + + 리뷰 + {reviewList.length} + + item.id} + ListFooterComponent={ + reviewCount < reviewList.length && ( + + 리뷰 더보기 + + ) + } + /> + + + + ); + + return ( + <> +
+ + { + setModalVisible(!modalVisible); + }} + > + + 전화번호: {restaurant.restaurant.detailInfo.contactNumber} + { + setModalVisible(!modalVisible); + }} + > + 닫기 + + + + + ); + } + + const styles = StyleSheet.create({ + entire: { + backgroundColor: COLOR_BACKGROUND, + alignItems: 'center', + marginHorizontal: -3, + }, + storeImageContainer: { + width: '100%', + height: 240, + }, + storeImage: { + width: '100%', + height: '100%', + }, + storeInfo: { + width: '100%', + padding: 16, + backgroundColor: COLOR_WHITE, + elevation: 3, + marginBottom: 16, + }, + storeHeader: { + alignItems: 'right', + }, + storeName: { + fontSize: 24, + color: COLOR_TEXT_BLACK, + fontWeight: 'bold', + marginVertical: 6, + }, + storeCategory: { + fontSize: 16, + color: COLOR_TEXT_DARKGRAY, + marginVertical: 10, + }, + storeReview: { + fontSize: 15, + color: COLOR_TEXT70GRAY, + marginBottom: 16, + marginRight: 6, + }, + contactContainer: { + flexDirection: 'row', + justifyContent: 'space-around', + width: '100%', + marginVertical: 8, + paddingHorizontal: 16, + }, + contactButton: { + alignItems: 'center', + }, + contactButtonIcon: { + marginVertical: 6, + marginHorizontal: 2, + }, + contactButtonText: { + fontSize: 15, + marginTop: 4, + color: COLOR_TEXT70GRAY, + }, + verticalDivider: { + width: 1, + height: '100%', + backgroundColor: COLOR_LIGHTGRAY, + marginHorizontal: 16, + }, + horizontalDivider: { + width: '100%', + height: 1, + backgroundColor: COLOR_LIGHTGRAY, + }, + storeAddress: { + fontSize: 15, + color: COLOR_TEXT_DARKGRAY, + marginVertical: 8, + }, + storeHours: { + fontSize: 15, + color: COLOR_TEXT_DARKGRAY, + marginVertical: 7, + }, + storePhoneNum: { + fontSize: 15, + color: COLOR_TEXT_DARKGRAY, + marginVertical: 7, + }, + section: { + width: '92%', + backgroundColor: COLOR_WHITE, + borderRadius: 10, + padding: 12, + marginBottom: 16, + shadowColor: COLOR_TEXT_BLACK, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 4, + }, + sectionTitle: { + flexDirection: 'row', + }, + sectionTitleText: { + marginTop: 2, + fontSize: 20, + fontWeight: 'bold', + color: COLOR_TEXT_BLACK, + marginRight: 5, + }, + sectionTitleNumText: { + marginTop: 2, + fontSize: 20, + fontWeight: 'bold', + color: COLOR_GRAY, + }, + sectionItem: { + flexDirection: 'row', + }, + menuImage: { + width: 90, + height: 90, + borderRadius: 12, + marginVertical: 12, + }, + menuImagePlaceholder: { + width: 90, + height: 90, + borderRadius: 12, + marginVertical: 12, + alignItems: 'center', + justifyContent: 'center', + }, + menuImagePlaceholderText: { + color: COLOR_LIGHTGRAY, + fontSize: 16, + }, + menuTextContainer: { + marginLeft: 10, + justifyContent: 'center', + }, + menuTitle: { + fontSize: 18, + color: COLOR_TEXT_BLACK, + fontWeight: '500', + }, + menuDescription: { + fontSize: 16, + color: COLOR_TEXT_DARKGRAY, + }, + menuPrice: { + fontSize: 17, + color: COLOR_TEXT_BLACK, + fontWeight: '600', + }, + reviewTextContainer: { + justifyContent: 'center', + marginVertical: 12, + }, + reviewAuthor: { + fontSize: 17, + color: COLOR_TEXT_BLACK, + marginRight: 4, + }, + reviewAuthor2: { + fontSize: 12, + color: COLOR_GRAY, + }, + reviewAuthorImage: { + width: 30, + height: 30, + borderRadius: 25, + }, + reviewAuthorImagePlaceholder: { + width: 30, + height: 30, + borderRadius: 25, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: COLOR_LIGHTGRAY, + marginRight: 6, + }, + reviewDate: { + fontSize: 12, + color: COLOR_TEXT_DARKGRAY, + marginVertical: 4, + }, + reviewStats: { + fontSize: 12, + color: COLOR_TEXT60GRAY, + marginRight: 6, + }, + reviewText: { + fontSize: 15, + color: COLOR_TEXT_BLACK, + marginTop: 6, + }, + reviewImage: { + width: 80, + height: 80, + borderRadius: 12, + margin: 7, + }, + loadMoreText: { + textAlign: 'center', + color: COLOR_PRIMARY, + fontSize: 16, + marginTop: 10, + }, + modalView: { + margin: 20, + backgroundColor: "white", + borderRadius: 20, + padding: 35, + alignItems: "center", + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + closeButton: { + backgroundColor: COLOR_PRIMARY, + borderRadius: 20, + padding: 10, + elevation: 2, + }, + textStyle: { + color: "white", + fontWeight: "bold", + textAlign: "center", + }, + modalText: { + marginBottom: 15, + textAlign: "center", + }, + });