diff --git a/src/WebSocketContext.tsx b/src/WebSocketContext.tsx index c637ab81..cf3834bc 100644 --- a/src/WebSocketContext.tsx +++ b/src/WebSocketContext.tsx @@ -73,7 +73,12 @@ export const WebSocketProvider = ({ children }: { children: React.ReactNode }) = ) } -export const useWebSocket = () => { +export const useWebSocket = (): { + client: Client | null + isConnected: boolean + subscribe: (destination: string, callback: (message: any) => void) => void + publish: (destination: string, body: any) => void +} => { const context = useContext(WebSocketContext) if (!context) { throw new Error('useWebSocket must be used within a WebSocketProvider') diff --git a/src/apis/walk/fetchWalkComplete.ts b/src/apis/walk/fetchWalkComplete.ts new file mode 100644 index 00000000..b8b47397 --- /dev/null +++ b/src/apis/walk/fetchWalkComplete.ts @@ -0,0 +1,80 @@ +import { AxiosError } from 'axios' +import { APIResponse, ErrorResponse } from '~types/api' +import { axiosInstance } from '~apis/axiosInstance' +import { useQuery } from '@tanstack/react-query' + +export const WALK_COMPLETE_QUERY_KEY = 'walkComplete' as const + +export type FetchWalkCompleteRequest = { + walkImgFile: File + totalDistanceMeter: number + totalWalkTimeSecond: number +} + +export type TimeDuration = { + hours: number + minutes: number + seconds: number +} + +export type WalkWithDogInfo = { + otherDogId: number + otherDogProfileImg: string + otherDogName: string + otherDogBreed: string + otherDogAge: number + otherDogGender: 'MALE' | 'FEMALE' + memberId: number +} + +export type FetchWalkCompleteResponse = { + date: string + memberName: string + dogName: string + totalDistanceMeter: number + timeDuration: TimeDuration + totalCalorie: number + walkImg: string + walkWithDogInfo: WalkWithDogInfo +} + +export const fetchWalkComplete = async (formData: FormData): Promise> => { + try { + const { data } = await axiosInstance.post>(`/walk/complete`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + return data + } catch (error) { + if (error instanceof AxiosError) { + const { response } = error as AxiosError + + if (response) { + const { code, message } = response.data + switch (code) { + case 400: + throw new Error(message || '잘못된 요청입니다.') + case 401: + throw new Error(message || '인증에 실패했습니다.') + case 500: + throw new Error(message || '서버 오류가 발생했습니다.') + default: + throw new Error(message || '알 수 없는 오류가 발생했습니다.') + } + } + throw new Error('네트워크 연결을 확인해주세요') + } + + console.error('예상치 못한 에러:', error) + throw new Error('다시 시도해주세요') + } +} + +export const useWalkComplete = (formData: FormData) => { + return useQuery({ + queryKey: [WALK_COMPLETE_QUERY_KEY, formData], + queryFn: () => fetchWalkComplete(formData), + enabled: false, + }) +} diff --git a/src/assets/map_pin_1.svg b/src/assets/map_pin_1.svg new file mode 100644 index 00000000..47291d34 --- /dev/null +++ b/src/assets/map_pin_1.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/map_pin_2.svg b/src/assets/map_pin_2.svg new file mode 100644 index 00000000..63437afd --- /dev/null +++ b/src/assets/map_pin_2.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/pages/WalkCompletePage/index.tsx b/src/pages/WalkCompletePage/index.tsx index dd4ea443..58edf93c 100644 --- a/src/pages/WalkCompletePage/index.tsx +++ b/src/pages/WalkCompletePage/index.tsx @@ -2,8 +2,10 @@ import { useLocation } from 'react-router-dom' import * as S from './styles' interface WalkCompleteData { + dogName: string + memberName: string date: string - time: string + timeDuration: { hours: number; minutes: number; seconds: number } distance: string calories: string mapImage: string @@ -11,7 +13,10 @@ interface WalkCompleteData { export default function WalkCompletePage() { const location = useLocation() + console.log(location.state) const walkData: WalkCompleteData = { + dogName: location.state?.dogName || '', + memberName: location.state?.memberName || '', date: new Date() .toLocaleDateString('ko-KR', { year: 'numeric', @@ -19,27 +24,27 @@ export default function WalkCompletePage() { day: '2-digit', }) .replace(/\. /g, '.'), - time: location.state?.time || '00:00:00', - distance: location.state?.distance || '0m', - calories: '200kcal', - mapImage: location.state?.mapImage || '', + timeDuration: location.state?.timeDuration, + distance: `${location.state?.totalDistanceMeter}m` || '0m', + calories: `${location.state?.totalCalorie}kcal` || '0kcal', + mapImage: location.state?.walkImg || '', } console.log(walkData) - const getMinutesFromTime = (time: string) => { - const [hours, minutes] = time.split(':').map(Number) + const getMinutesFromTime = (time: { hours: number; minutes: number; seconds?: number }) => { + const { hours, minutes } = time return hours * 60 + minutes } - const walkTimeInMinutes = getMinutesFromTime(walkData.time) + const walkTimeInMinutes = getMinutesFromTime(walkData.timeDuration) return ( {walkData.date} - 견주닉넴과 밤톨이가 + {walkData.memberName}와(과) {walkData.dogName}가
{walkTimeInMinutes}분동안 산책했어요.
@@ -49,7 +54,7 @@ export default function WalkCompletePage() { - {walkData.time} + {`${walkData.timeDuration.hours} : ${walkData.timeDuration.minutes} : ${walkData.timeDuration.seconds}`} 산책 시간 diff --git a/src/pages/WalkPage/components/MapComponent/index.tsx b/src/pages/WalkPage/components/MapComponent/index.tsx index 63980def..e31f1457 100644 --- a/src/pages/WalkPage/components/MapComponent/index.tsx +++ b/src/pages/WalkPage/components/MapComponent/index.tsx @@ -18,6 +18,9 @@ import * as S from './styles' import { MIN_ACCURACY, MIN_DISTANCE, MIN_TIME_INTERVAL } from '~types/map' import { useNavigate } from 'react-router-dom' import { useWebSocket } from '~/WebSocketContext' +import { useMutation } from '@tanstack/react-query' +import { fetchWalkComplete } from '~apis/walk/fetchWalkComplete' +import WalkModal from '~pages/WalkPage/components/WalkModal' const ORS_API_URL = '/ors/v2/directions/foot-walking/geojson' @@ -32,16 +35,60 @@ export const getMarkerIconString = () => { return svgString } -type MapComponentProps = { - isModalOpen?: boolean +export const getMarkerIconString2 = () => { + const svgString = ReactDOMServer.renderToString() + return svgString } -// DeviceOrientationEvent 타입 확장 interface ExtendedDeviceOrientationEvent extends DeviceOrientationEvent { webkitCompassHeading?: number } -export default function MapComponent({ isModalOpen = false }: MapComponentProps) { +export type NearbyWalker = { + dogId: number + dogProfileImg: string + dogName: string + breed: string + dogWalkCount: number + dogAge: number + dogGender: 'MALE' | 'FEMALE' + memberId: number + memberEmail: string +} + +export type ProposalResponse = { + code: number + message: string + data: { + dogId: number + dogName: string + dogBreed: string + dogProfileImg: string + comment: string + dogGender: string + dogAge: number + email: string + type: 'PROPOSAL' + } +} + +export type DecisionResponse = { + code: number + message: string + data: { + decision: 'ACCEPT' | 'DENY' + otherMemberName: string + otherMemberProfileImg: string + type: 'DECISION' + } +} + +type MapComponentProps = { + isModalOpen?: boolean + setNearbyWalkers: React.Dispatch> +} + +export default function MapComponent({ isModalOpen = false, setNearbyWalkers }: MapComponentProps) { const mapRef = useRef(null) const currentLocationMarkerRef = useRef | null>(null) const watchPositionIdRef = useRef(null) @@ -66,21 +113,102 @@ export default function MapComponent({ isModalOpen = false }: MapComponentProps) const [autoRotate, setAutoRotate] = useState(false) const lastHeadingRef = useRef(0) + const [showProposalModal, setShowProposalModal] = useState(false) + const [showDecisionModal, setShowDecisionModal] = useState(false) + const [proposalInfo, setProposalInfo] = useState({ + dogId: 1, + dogName: '몽이', + dogBreed: '골든 리트리버', + dogProfileImg: 'https://placedog.net/200/200?id=1', + comment: '같이 산책 해요 :)', + dogGender: 'MALE', + dogAge: 5, + email: 'example@example.com', + type: 'PROPOSAL', + }) + + const [decisionInfo, setDecisionInfo] = useState({ + decision: 'ACCEPT', + otherMemberName: '홍길동', + otherMemberProfileImg: 'https://placedog.net/200/200?id=2', + type: 'DECISION', + }) + const [currentAccuracy, setCurrentAccuracy] = useState(null) + const [isWalkingTogether, setIsWalkingTogether] = useState(false) + const [, setWalkPartnerEmail] = useState(null) + const navigate = useNavigate() const { subscribe, publish, isConnected } = useWebSocket() - const memberEmail = 'mkh6793@naver.com' + const memberEmail = localStorage.getItem('email') + + // const { data } = useQuery({ + // queryKey: ['walkPage'], + // queryFn: fetchMypage, + // }) + + const walkCompleteMutation = useMutation({ + mutationFn: (walkData: FormData) => fetchWalkComplete(walkData), + }) + + const partnerMarkerRef = useRef(null) useEffect(() => { + console.log('isConnected', isConnected) if (isConnected) { const handleMessage = (message: { body: string }) => { - console.log('수신된 메시지:', message.body) + try { + const response = JSON.parse(message.body) + console.log('response', response) + const type = Array.isArray(response.data) ? response.data[0].type : response.data.type + + if (type === 'WALK_ALONE') { + const nearbyWalkersData = response.data.map((walker: NearbyWalker) => ({ + dogId: walker.dogId, + dogProfileImg: walker.dogProfileImg, + dogName: walker.dogName, + breed: walker.breed, + dogWalkCount: walker.dogWalkCount, + dogAge: walker.dogAge, + dogGender: walker.dogGender, + memberId: walker.memberId, + memberEmail: walker.memberEmail, + })) + + setNearbyWalkers(nearbyWalkersData) + } else if (type === 'PROPOSAL') { + const proposalData = response.data as ProposalResponse['data'] + setProposalInfo(proposalData) + setShowProposalModal(true) + setWalkPartnerEmail(proposalData.email) + } else if (type === 'DECISION') { + const decisionData = response.data as DecisionResponse['data'] + + if (decisionData.decision === 'DENY') { + setDecisionInfo(decisionData) + setShowDecisionModal(true) + } else { + setIsWalkingTogether(true) + } + } else if (type === 'WALK_WITH') { + setIsWalkingTogether(true) + setWalkPartnerEmail(response.data.email) + + updatePartnerLocation(response.data.latitude, response.data.longitude) + } + } catch (error) { + console.error('메시지 파싱 실패:', error) + } } + const handleError = (message: { body: string }) => { + console.error('WebSocket 오류:', message.body) + } subscribe(`/sub/walk/${memberEmail}`, handleMessage) + subscribe('/user/queue/errors', handleError) } }, [isConnected, subscribe, memberEmail]) @@ -446,8 +574,7 @@ export default function MapComponent({ isModalOpen = false }: MapComponentProps) const filterPosition = (position: GeolocationPosition): boolean => { const isAccurate = position.coords.accuracy <= MIN_ACCURACY - const accuracy = position.coords.accuracy - setCurrentAccuracy(accuracy) + setCurrentAccuracy(position.coords.accuracy) return isAccurate } @@ -623,86 +750,117 @@ export default function MapComponent({ isModalOpen = false }: MapComponentProps) } } - const handleWalkToggle = async () => { - if (!isWalking) { - handleCompassPermission() + const base64ToFile = (base64String: string): File => { + // base64 문자열에서 데이터 URI 스키마 제거 + const base64Content = base64String.split(',')[1] + // base64를 바이너리 데이터로 변환 + const binaryData = atob(base64Content) - setIsWalking(true) - setAutoRotate(true) - setWalkDistance(0) - setEstimatedDistance(0) - accumulatedPositionsRef.current = [] + // 바이너리 데이터를 Uint8Array로 변환 + const bytes = new Uint8Array(binaryData.length) + for (let i = 0; i < binaryData.length; i++) { + bytes[i] = binaryData.charCodeAt(i) + } - routeSourceRef.current.clear() - vectorSourceRef.current.clear() + // Blob 생성 + const blob = new Blob([bytes], { type: 'image/png' }) - rotateMap(lastHeadingRef.current) - watchPositionIdRef.current = navigator.geolocation.watchPosition( - (position: GeolocationPosition) => { - if (!filterPosition(position)) return + // File 객체 생성 + const fileName = `walk-map-${new Date().getTime()}.png` + return new File([blob], fileName, { type: 'image/png' }) + } - const newPosition = { - lat: position.coords.latitude, - lng: position.coords.longitude, - } + const startWatchingPosition = () => { + return navigator.geolocation.watchPosition( + (position: GeolocationPosition) => { + if (!filterPosition(position)) return - const coordinates = fromLonLat([newPosition.lng, newPosition.lat]) + const newPosition = { + lat: position.coords.latitude, + lng: position.coords.longitude, + } - if (currentLocationMarkerRef.current) { - const point = currentLocationMarkerRef.current.getGeometry() as Point - point.setCoordinates(coordinates) - vectorSourceRef.current.changed() - } else { - currentLocationMarkerRef.current = new Feature({ - geometry: new Point(coordinates), - }) - const markerStyle = new Style({ - image: new Icon({ - anchor: [0.5, 1], - anchorXUnits: 'fraction', - anchorYUnits: 'fraction', - src: `data:image/svg+xml;utf8,${encodeURIComponent(getMarkerIconString())}`, - scale: 1, - }), - }) - currentLocationMarkerRef.current.setStyle(markerStyle) - vectorSourceRef.current.addFeature(currentLocationMarkerRef.current) + const coordinates = fromLonLat([newPosition.lng, newPosition.lat]) + + if (currentLocationMarkerRef.current) { + const point = currentLocationMarkerRef.current.getGeometry() as Point + point.setCoordinates(coordinates) + vectorSourceRef.current.changed() + } else { + currentLocationMarkerRef.current = new Feature({ + geometry: new Point(coordinates), + }) + const markerStyle = new Style({ + image: new Icon({ + anchor: [0.5, 1], + anchorXUnits: 'fraction', + anchorYUnits: 'fraction', + src: `data:image/svg+xml;utf8,${encodeURIComponent(getMarkerIconString())}`, + scale: 1, + }), + }) + currentLocationMarkerRef.current.setStyle(markerStyle) + vectorSourceRef.current.addFeature(currentLocationMarkerRef.current) + } + + if (mapRef.current) { + mapRef.current.getView().animate({ + center: coordinates, + duration: 500, + }) + } + + if (shouldCallApi(newPosition)) { + accumulatedPositionsRef.current.push(newPosition) + addWalkLocationMarker(coordinates) + + const walkData = { + latitude: newPosition.lat.toFixed(6), + longitude: newPosition.lng.toFixed(6), } - if (mapRef.current) { - mapRef.current.getView().animate({ - center: coordinates, - duration: 500, - }) + const endpoint = isWalkingTogether ? '/pub/api/v1/walk-with' : '/pub/api/v1/walk-alone' + publish(endpoint, walkData) + + if (accumulatedPositionsRef.current.length >= 2) { + const lastTwoPositions = accumulatedPositionsRef.current.slice(-2) + calculateWalkingDistance(lastTwoPositions) } - if (shouldCallApi(newPosition)) { - accumulatedPositionsRef.current.push(newPosition) - addWalkLocationMarker(coordinates) + lastApiCallTimeRef.current = Date.now() + } - try { - publish('/pub/api/v1/walk-alone', { - latitude: newPosition.lat, - longitude: newPosition.lng, - }) - console.log('웹소켓 발행 성공') - } catch (error) { - console.error('웹소켓 발행 실패:', error) - } + updateEstimatedDistance() + }, + handleLocationError, + getGeoOptions() + ) + } - if (accumulatedPositionsRef.current.length >= 2) { - const lastTwoPositions = accumulatedPositionsRef.current.slice(-2) - calculateWalkingDistance(lastTwoPositions) - } + useEffect(() => { + if (isWalking) { + if (watchPositionIdRef.current !== null) { + navigator.geolocation.clearWatch(watchPositionIdRef.current) + } + watchPositionIdRef.current = startWatchingPosition() + } + }, [isWalkingTogether, isWalking]) - lastApiCallTimeRef.current = Date.now() - } + const handleWalkToggle = async () => { + if (!isWalking) { + handleCompassPermission() - updateEstimatedDistance() - }, - handleLocationError, - getGeoOptions() - ) + setIsWalking(true) + setAutoRotate(true) + setWalkDistance(0) + setEstimatedDistance(0) + accumulatedPositionsRef.current = [] + + routeSourceRef.current.clear() + vectorSourceRef.current.clear() + + rotateMap(lastHeadingRef.current) + watchPositionIdRef.current = startWatchingPosition() walkIntervalRef.current = window.setInterval(() => { setWalkTime(prev => prev + 1) @@ -723,15 +881,35 @@ export default function MapComponent({ isModalOpen = false }: MapComponentProps) const mapImage = await captureMap() - const walkCompleteData = { - time: formatTime(walkTime), - distance: formatDistance(walkDistance || estimatedDistance), - mapImage: mapImage, - } + try { + const mapImageFile = base64ToFile(mapImage) + + // FormData 객체 생성 + const formData = new FormData() + formData.append('walkImgFile', mapImageFile) + formData.append( + 'request', + new Blob( + [ + JSON.stringify({ + totalDistanceMeter: walkDistance || estimatedDistance, + totalWalkTimeSecond: walkTime, + }), + ], + { type: 'application/json' } + ) + ) - navigate('/walk-complete', { - state: walkCompleteData, - }) + const response = await walkCompleteMutation.mutateAsync(formData) + + console.log(response) + + navigate('/walk-complete', { + state: response.data, + }) + } catch (error) { + console.error('산책 완료 데이터 전송 실패:', error) + } } } @@ -779,8 +957,8 @@ export default function MapComponent({ isModalOpen = false }: MapComponentProps) const drawRoute = (coordinates: number[][], append: boolean = false) => { const routeStyle = new Style({ stroke: new Stroke({ - color: '#FF6B6B', - width: 5, + color: '#ECB99A', + width: 10, lineCap: 'round', lineJoin: 'round', }), @@ -841,6 +1019,65 @@ export default function MapComponent({ isModalOpen = false }: MapComponentProps) } }, [isWalking]) + // 파트너 위치 마커 스타일 정의 + const getPartnerMarkerStyle = new Style({ + image: new Icon({ + anchor: [0.5, 1], + anchorXUnits: 'fraction', + anchorYUnits: 'fraction', + src: `data:image/svg+xml;utf8,${encodeURIComponent(getMarkerIconString2())}`, + + scale: 0.75, + }), + }) + + // 파트너 위치 업데이트 함수 + const updatePartnerLocation = (latitude: number, longitude: number) => { + const coordinates = fromLonLat([longitude, latitude]) + + if (partnerMarkerRef.current) { + // 기존 마커 위치 업데이트 + const point = partnerMarkerRef.current.getGeometry() as Point + const currentCoords = point.getCoordinates() + + // 부드러운 애니메이션으로 위치 이동 + const duration = 2000 + const start = Date.now() + const animate = () => { + const elapsed = Date.now() - start + const progress = Math.min(elapsed / duration, 1) + const easeProgress = easeOut(progress) + + const x = currentCoords[0] + (coordinates[0] - currentCoords[0]) * easeProgress + const y = currentCoords[1] + (coordinates[1] - currentCoords[1]) * easeProgress + + point.setCoordinates([x, y]) + + if (progress < 1) { + requestAnimationFrame(animate) + } + } + + animate() + } else { + // 새로운 마커 생성 + partnerMarkerRef.current = new Feature({ + geometry: new Point(coordinates), + }) + partnerMarkerRef.current.setStyle(getPartnerMarkerStyle) + vectorSourceRef.current.addFeature(partnerMarkerRef.current) + } + } + + // 컴포넌트 정리 시 파트너 마커 제거 + useEffect(() => { + return () => { + if (partnerMarkerRef.current) { + vectorSourceRef.current.removeFeature(partnerMarkerRef.current) + } + } + }, []) + return ( @@ -928,6 +1165,47 @@ export default function MapComponent({ isModalOpen = false }: MapComponentProps) )} + + {showProposalModal && proposalInfo && ( + setShowProposalModal(false)} + onConfirm={() => { + // TODO: 수락 로직 구현 + setShowProposalModal(false) + }} + onCancel={() => { + // TODO: 거절 로직 구현 + setShowProposalModal(false) + }} + /> + )} + {showDecisionModal && decisionInfo && ( + setShowDecisionModal(false)} + onConfirm={() => { + // TODO: 다시 시도 구현 + setShowDecisionModal(false) + }} + onCancel={() => { + setShowDecisionModal(false) + }} + /> + )} ) } diff --git a/src/pages/WalkPage/components/MapComponent/styles.ts b/src/pages/WalkPage/components/MapComponent/styles.ts index 4ce1eec6..46e96497 100644 --- a/src/pages/WalkPage/components/MapComponent/styles.ts +++ b/src/pages/WalkPage/components/MapComponent/styles.ts @@ -1,7 +1,8 @@ import { styled } from 'styled-components' import { FOOTER_HEIGHT } from '~constants/layout' import DogProfile from '~assets/walk_dog.svg?react' -import MapPin from '~assets/walk_pin.svg?react' +import MapPin from '~assets/map_pin_1.svg?react' +import MapPin2 from '~assets/map_pin_2.svg?react' import { ActionButton } from '~components/Button/ActionButton' import Center from '~assets/center.svg?react' import { Box } from '~components/Box' @@ -71,6 +72,11 @@ export const MapPinIcon = styled(MapPin)` height: 100%; ` +export const MapPinIcon2 = styled(MapPin2)` + width: 100%; + height: 100%; +` + export const WalkControlContainer = styled.div` position: absolute; bottom: 20px; diff --git a/src/pages/WalkPage/components/WalkModal/index.tsx b/src/pages/WalkPage/components/WalkModal/index.tsx index 252114ff..aee79813 100644 --- a/src/pages/WalkPage/components/WalkModal/index.tsx +++ b/src/pages/WalkPage/components/WalkModal/index.tsx @@ -1,7 +1,8 @@ import { SetStateAction, useState, useEffect } from 'react' import * as S from './styles' import Select from '~components/Select' -import { WalkModalProps, RequestUserInfo, OtherUserInfo } from '~types/modal' +import { WalkModalProps, RequestUserInfo } from '~types/modal' +import { useWebSocket } from '~/WebSocketContext' const reportOptions = [ { value: 'dog', label: '강아지가 사나워요.' }, @@ -9,6 +10,44 @@ const reportOptions = [ ] const WalkModal = ({ type, userInfo, onClose, onConfirm, onCancel }: WalkModalProps) => { + const [message, setMessage] = useState('') + const { publish } = useWebSocket() + + const handleConfirm = () => { + if (type === 'request') { + const proposalData = { + otherMemberEmail: (userInfo as RequestUserInfo).memberEmail, + comment: message, + } + console.log(proposalData) + publish('/pub/api/v1/proposal', proposalData) + onClose() + } else if (type === 'accept') { + const decisionData = { + otherEmail: (userInfo as RequestUserInfo).memberEmail, + decision: 'ACCEPT', + } + console.log(decisionData) + publish('/pub/api/v1/decision', decisionData) + onClose() + } else { + onConfirm() + } + } + + const handleCancel = () => { + if (type === 'accept') { + const decisionData = { + otherEmail: (userInfo as RequestUserInfo).memberEmail, + decision: 'DENY', + } + publish('/pub/api/v1/decision', decisionData) + onClose() + } else { + onCancel?.() + } + } + useEffect(() => { document.body.style.overflow = 'hidden' @@ -84,26 +123,24 @@ const WalkModal = ({ type, userInfo, onClose, onConfirm, onCancel }: WalkModalPr {type !== 'accept' && type === 'friend' &&
2024.12.14
} {type !== 'progress' && type !== 'report' && type !== 'reportComplete' && ( - - - -

{userInfo.name}

- {type === 'request' || type === 'accept' || type === 'walkRequest' ? ( - <> -

- {(userInfo as RequestUserInfo).breed} {' '} - {(userInfo as RequestUserInfo).age} {' '} - {(userInfo as RequestUserInfo).gender} -

- - ) : ( - <> - {(userInfo as OtherUserInfo).location &&

{(userInfo as OtherUserInfo).location}

} - {(userInfo as OtherUserInfo).time &&

{(userInfo as OtherUserInfo).time}

} - - )} -
-
+ <> + + + +

{userInfo.name}

+ {(type === 'request' || type === 'accept' || type === 'walkRequest') && ( + <> +

+ {(userInfo as RequestUserInfo).breed} {' '} + {(userInfo as RequestUserInfo).age} {' '} + {(userInfo as RequestUserInfo).gender} +

+ + )} +
+
+ {type === 'accept' && {(userInfo as RequestUserInfo).comment}} + )} {type === 'report' && ( @@ -121,7 +158,14 @@ const WalkModal = ({ type, userInfo, onClose, onConfirm, onCancel }: WalkModalPr )} {type === 'request' ? ( - + setMessage(e.target.value)} + /> ) : type === 'progress' ? ( {modalContent?.message} @@ -137,11 +181,11 @@ const WalkModal = ({ type, userInfo, onClose, onConfirm, onCancel }: WalkModalPr {modalContent?.cancelText && ( - + {modalContent.cancelText} )} - + {modalContent?.confirmText} diff --git a/src/pages/WalkPage/components/WalkModal/styles.ts b/src/pages/WalkPage/components/WalkModal/styles.ts index 0a719198..b9e8ab8a 100644 --- a/src/pages/WalkPage/components/WalkModal/styles.ts +++ b/src/pages/WalkPage/components/WalkModal/styles.ts @@ -144,7 +144,7 @@ export const UserInfo = styled.div` }} ` -export const Avatar = styled.div` +export const Avatar = styled.img` width: 48px; height: 48px; border-radius: 50%; @@ -225,6 +225,18 @@ export const Info = styled.div` }} ` +export const ProposalMessage = styled.div` + background-color: ${({ theme }) => theme.colors.grayscale.gc_3}; + border-radius: 16px; + padding: 20px; + font-size: 14px; + font-weight: 500; + color: ${({ theme }) => theme.colors.grayscale.font_3}; + width: 100%; + text-align: left; + line-height: 1.5; +` + export const Message = styled.p` margin: 0; ${({ type }) => { diff --git a/src/pages/WalkPage/components/WalkerListModal/index.tsx b/src/pages/WalkPage/components/WalkerListModal/index.tsx index b792afa3..031fed59 100644 --- a/src/pages/WalkPage/components/WalkerListModal/index.tsx +++ b/src/pages/WalkPage/components/WalkerListModal/index.tsx @@ -1,12 +1,15 @@ +import { NearbyWalker } from '~pages/WalkPage/components/MapComponent' import * as S from './styles' -interface WalkerListModalProps { +type WalkerListModalProps = { isOpen: boolean onClose: () => void isClosing: boolean + walkers: NearbyWalker[] + onWalkRequest: (walker: NearbyWalker) => void } -export default function WalkerListModal({ isOpen, onClose, isClosing }: WalkerListModalProps) { +export default function WalkerListModal({ isOpen, onClose, isClosing, walkers, onWalkRequest }: WalkerListModalProps) { if (!isOpen) return null const handleBackgroundClick = (e: React.MouseEvent) => { @@ -20,33 +23,42 @@ export default function WalkerListModal({ isOpen, onClose, isClosing }: WalkerLi 강번따 리스트 - - {Array(10) - .fill(0) - .map((_, i) => ( - + {walkers.length > 0 ? ( + + {walkers.map(walker => ( + - + + {walker.dogName} + - 밤돌이 + {walker.dogName} - 포메라니안 + {walker.breed} - 4살 + {walker.dogAge}살 - + {walker.dogGender === 'MALE' ? '남' : '여'} - 산책 횟수

 4회

+ 산책 횟수

 {walker.dogWalkCount}회

- + onWalkRequest(walker)} + > 강번따
))} -
+
+ ) : ( + 주변에 산책 가능한 유저가 없습니다. + )}
diff --git a/src/pages/WalkPage/components/WalkerListModal/styles.ts b/src/pages/WalkPage/components/WalkerListModal/styles.ts index d60438f5..1a2407dc 100644 --- a/src/pages/WalkPage/components/WalkerListModal/styles.ts +++ b/src/pages/WalkPage/components/WalkerListModal/styles.ts @@ -111,6 +111,13 @@ export const ProfileCircle = styled.div` border-radius: 50%; background-color: #ffe4d6; margin-right: 12px; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } ` export const InfoArea = styled.div` @@ -158,3 +165,11 @@ export const WalkBtn = styled(ActionButton)` export const WalkListSeparator = styled(Separator)` margin: 0 4px; ` + +export const NoWalkersMessage = styled.div` + padding: 20px; + text-align: center; + color: ${({ theme }) => theme.colors.grayscale.font_3}; + font-size: ${({ theme }) => theme.typography._17}; + font-weight: 500; +` diff --git a/src/pages/WalkPage/index.tsx b/src/pages/WalkPage/index.tsx index 6abadde9..b2278f86 100644 --- a/src/pages/WalkPage/index.tsx +++ b/src/pages/WalkPage/index.tsx @@ -1,19 +1,28 @@ import { IoChevronBack } from 'react-icons/io5' -import MapComponent from './components/MapComponent' +import MapComponent, { NearbyWalker } from './components/MapComponent' import * as S from './styles' import { Helmet } from 'react-helmet-async' import { useNavigate } from 'react-router-dom' import { useState } from 'react' import WalkerListModal from '~pages/WalkPage/components/WalkerListModal' +import WalkModal from '~pages/WalkPage/components/WalkModal' export default function WalkPage() { const navigate = useNavigate() - // const [_modalType, _setModalType] = useState<'request' | 'accept' | 'complete' | 'progress' | 'friend' | null>(null) - // const [isModalOpen, _setIsModalOpen] = useState(false) const [isModalOpen] = useState(false) const [isWalkerListOpen, setIsWalkerListOpen] = useState(false) const [isClosing, setIsClosing] = useState(false) + const [nearbyWalkers, setNearbyWalkers] = useState([]) + + const [isWalkModalOpen, setIsWalkModalOpen] = useState(false) + const [selectedWalker, setSelectedWalker] = useState(null) + + const handleWalkRequest = (walker: NearbyWalker) => { + setSelectedWalker(walker) + setIsWalkModalOpen(true) + } + const handleWalkerListClose = () => { setIsClosing(true) setTimeout(() => { @@ -39,8 +48,33 @@ export default function WalkPage() { - - + + + + {isWalkModalOpen && selectedWalker && ( + setIsWalkModalOpen(false)} + onConfirm={() => { + // 산책 요청 처리 로직 + setIsWalkModalOpen(false) + }} + /> + )} ) } diff --git a/src/types/api.ts b/src/types/api.ts index 3912c63d..313d4f57 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -55,6 +55,8 @@ export type OtherDog = { otherDogAge: number /** 다른 강아지 성별 @example "MALE" */ otherDogGender: Gender + /** 다른 강아지 산책 횟수 @example 4 */ + otherDogWalkCount?: number /** 다른 강아지 주인의 유저 ID @example 2 */ memberId: number } diff --git a/src/types/modal.ts b/src/types/modal.ts index 62460a03..a2691742 100644 --- a/src/types/modal.ts +++ b/src/types/modal.ts @@ -7,14 +7,16 @@ export type BaseModalProps = { export type RequestUserInfo = { name: string breed: string - age: string + age: number gender: string + profileImg?: string + memberEmail?: string + comment?: string } export type OtherUserInfo = { name: string - location?: string - time?: string + profileImg: string } export type ModalContentType = {