React를 활용한 여행 일정 계획 애플리케이션 개발

hyuko·2023년 5월 15일
0

팀 프로젝트

목록 보기
5/8

이번 블로그에서는 React를 사용하여 여행 일정 계획을 작성하고 관리하는
웹 애플리케이션을 개발하는 방법에 대해 알아보겠습니다.
이 애플리케이션은 여행 날짜와 장소 정보를 선택하여 일정을 작성하고 저장하는 기능을 제공합니다.

1. 개발 환경 설정

먼저, 개발을 시작하기 전에 필요한 환경 설정을 완료해야 합니다.
아래는 개발 환경 설정에 필요한 내용입니다.

1.1 Node.js와 npm(Node Package Manager) 설치

React 프로젝트 생성
필요한 라이브러리와 의존성 설치
자세한 환경 설정 방법은 본 블로그에서 다루지 않으므로,
이미 환경이 설정되어 있다고 가정하고 진행하겠습니다.

1.2. 컴포넌트 개발

여행 일정 계획 애플리케이션을 구현하기 위해 다양한 컴포넌트를 개발해야 합니다.
여기서는 Calendar, Map, TabPanel, VerticalTabs, Contents 등의 컴포넌트가 사용됩니다.


export default function Calendar(props) {
  const { startDay, endDay, totalDate, onStartDayChange, onEndDayChange, markerData } = props;
  const [scheduleData, setScheduleData] = useState([]);

  const resetDay = () => {
    onStartDayChange(startDay);
    onEndDayChange(endDay);
  }

  const startDayHandle = (newValue) => {
    onStartDayChange(newValue);
  }

  const endDayHandle = (newValue) => {
    onEndDayChange(newValue);
  }


  useEffect(() => {
    const totalDate = endDay.diff(startDay, 'day') + 1;

    const generatedData = Array.from({ length: totalDate }, (_, i) => {
      const date = startDay.clone().add(i, 'day').format('YYYY-MM-DD');
      const id = i+1;

      const markerItem = markerData.find((item) => item.id === id);
      const location = markerItem ? markerItem.location : [{ addr: '', lat: null, lng: null }];

      return {
        id: id,
        date: date,
        location: location,
      };
    });

    setScheduleData(generatedData);
  }, [startDay, endDay, markerData]);

  useEffect(() => {
    const updatedData = scheduleData.map((schedule) => {
      const markerItem = markerData.find((item) => item.id === schedule.id);
      const location = markerItem ? markerItem.location : [{ addr: '', lat: null, lng: null }];

      if (markerItem) {
        return {
          ...schedule,
          location: location,
        };
      }

      return schedule;
    });

    setScheduleData(updatedData);
  }, [markerData]);
  localStorage.setItem("scheduleData", JSON.stringify(scheduleData));

  const requestData = useMutation(async (scheduleData) => {

    const option = {
      headers: {
        'Content-Type': 'application/json',
        Authorization: `${localStorage.getItem("accessToken")}`
      }
    }
    try {
      const response = await axios.post("http://localhost:8080/api/v1/travel/plan", scheduleData, option)
      console.log(response.status);

      window.location.replace("/")
      return response;
    }catch (error) {

    }

  }, {
    onSuccess: (response) => {
      localStorage.removeItem("scheduleData");
    }
  })

  const submitPlanHandler = () => {
    requestData.mutate(localStorage.getItem("scheduleData"));
  }

  return (
    <LocalizationProvider dateAdapter={AdapterDayjs}>
      <div id='calendar'>
        <div css={Total}>Total days: {totalDate}</div>
        <div css={calendarContainer}>
          <DemoContainer components={['DatePicker', 'DatePicker']}>
            <DatePicker
              label="start"
              value={startDay}
              onMonthChange={false}
              onChange={startDayHandle}
              minDate={dayjs()}
              maxDate={dayjs().add(3, 'month')}
              />
            <DatePicker
              label="end"
              value={endDay}
              onMonthChange={false}
              onChange={endDayHandle}
              minDate={startDay}
              maxDate={startDay.add(1, 'month')}
              />
          </DemoContainer>
          <VerticalTabs 
            // scheduleDays={Array.from({ length: totalDate }, (_, i) => startDay.add(i, 'day'))}
            // coordinates={paths}
            scheduleData={scheduleData}
            />
          <button onClick={submitPlanHandler}>일정 확정하기 </button>
        </div>
      </div>
    </LocalizationProvider>
  );
  
}
const { kakao } = window;

const Map = ({ destinationTitle, paths, setPaths }) => {
  const linePath = [];
  const markerId = useRef(1);
  const mapRef = useRef(null);
  const [editMode, setEditMode] = useState(false);
  const [markers, setMarkers] = useState([]);
  const [markerPositions, setMarkerPositions] = useState([]);
  const [address, setAddress] = useState([]);
  

  useEffect(() => { //지도의 시작 좌표,확대 단계 조절
    const mapOption = {
      center: new kakao.maps.LatLng(35.152380, 129.059647),
      level: 9,
    };
    const map = new kakao.maps.Map(mapRef.current, mapOption);
    const geocoder = new kakao.maps.services.Geocoder();

    geocoder.addressSearch(destinationTitle, function(result, status) {     //geocoder 사용으로 주소로 장소표시
      if (status === kakao.maps.services.Status.OK) {
        const latitude = result[0].y;
        const longitude = result[0].x;
        const coords = new kakao.maps.LatLng(result[0].y, result[0].x);
        // 검색결과위치로 맵을 이동
        map.setCenter(coords);
        localStorage.setItem('firstLatitude', latitude);
        localStorage.setItem('firstLongitude', longitude);        
      }
    });
  
     kakao.maps.event.addListener(map, 'click', function(mouseEvent) {  //지도 클릭시 이벤트
        const position = mouseEvent.latLng; //지도에서 클릭한 위치
        geocoder.coord2Address(position.getLng(), position.getLat(), function(result, status) { //coord2Address 좌표 값에 해당하는 구 주소와 도로명 주소 정보를 요청
          if (status === kakao.maps.services.Status.OK) {
            const address = result[0].address.address_name;
            setAddress(addr => [...addr, address]);
            
          }
        });
        const marker = new kakao.maps.Marker({ position }); //마커 객체 생성
        marker.setMap(map); //마커 지도에 보여줌
        setMarkers(prevMarkers => [...prevMarkers, marker]);  //새로 생성된 마커 저장
        setMarkerPositions(prevPositions => [...prevPositions, position]);  //마커와 연결된 좌표 저장
      
    kakao.maps.event.addListener(marker, 'click', function() {  //마커 클릭시 이벤트
        setMarkers(prevMarkers => prevMarkers.filter(prevMarker => prevMarker !== marker)); //클릭한 마커 배열에서 제거
        marker.setMap(null);  //클릭한 마커 지도에서 제거
        });
    });

    return () => {
        kakao.maps.event.removeListener(map, 'click');
    };
  }, [editMode, destinationTitle]);

function handleSavePath() { //로컬저장소에 마커 위도,경도,주소 정보 저장
  if (markerPositions.length === 0) {
    alert('경로를 지정해주세요.');
    return;
  }

  const markerData = markerPositions.map((position, index) => {
    const locations = [
      {
        addr: address[index],
        lat: position.getLat(),
        lng: position.getLng(),
      },
    ];
  
    return {
      id: markerId.current,
      location: locations,
    };
  });
  
  const groupedMarkerData = markerData.reduce((result, current) => {
    const existingItem = result.find((item) => item.id === current.id);
  
    if (existingItem) {
      existingItem.location.push(...current.location);
    } else {
      result.push(current);
    }
  
    return result;
  }, []);
  
  markerId.current += 1; 
  setPaths(groupedMarkerData);
  setMarkerPositions([]);
  setMarkers([]);
  setAddress([]);
  markers.forEach(marker => marker.setMap(null));
}

  return (
    <div css={map} ref={mapRef}>
      <div css={guideBox}>
            <button css={guideButton} onClick={handleSavePath}>경로 저장</button> 
            <button css={guideButton} onClick={handleSavePath}>경로 수정</button> 
      </div>
     <MapSearch  map={map} />
    </div>
  );
  
};

export default Map;

function a11yProps(index) {
  return {
    id: `vertical-tab-${index}`,
    'aria-controls': `vertical-tabpanel-${index}`,
  };
}
/*
  TabPanel은 Tab 내용 함수
*/

function TabPanel({ children, value, index, scheduleData, ...other }) {
  const scheduleDay = scheduleData.find(day => day.id === index +1);
  if (!scheduleDay) {
    return null;
  }

  return (
    <div
      role="tabpanel"
      hidden={value !== index}
      id={`vertical-tabpanel-${index}`}
      aria-labelledby={`vertical-tab-${index}`}
      {...other}
    >
      {value === index && (
        <Box sx={{ display: 'flex', flexDirection: 'column' }}>
          <Typography sx={{ px: 2, py: 1 }}>
            {scheduleDay.date}
            {scheduleDay.location.map((loc, locIndex) => (
              <div css={route} key={locIndex}>
                place {locIndex} : {loc.addr}
              </div>
            ))}
          </Typography>
          <Box sx={{ flexGrow: 1, px: 2 }}>{children}</Box>
        </Box>
      )}
    </div>
  );
}

TabPanel.propTypes = {
  children: PropTypes.node,
  index: PropTypes.number.isRequired,
  value: PropTypes.number.isRequired,
  scheduleData: PropTypes.arrayOf(
    PropTypes.shape({
      id: PropTypes.number.isRequired,
      date: PropTypes.string.isRequired,
      location: PropTypes.arrayOf(
        PropTypes.shape({
          addr: PropTypes.string.isRequired,
          lat: PropTypes.number.isRequired,
          lng: PropTypes.number.isRequired,
        })
      ).isRequired,
    })
  ).isRequired,
};

export default function VerticalTabs({ scheduleData }) {
  const storedTab = localStorage.getItem('selectedTab');
  const initialTab = storedTab ? scheduleData.findIndex((day) => day.date === storedTab) : 0;
  const [value, setValue] = useState(initialTab >= 0 ? initialTab : 0);
  const [paths, setPaths] = useState([]);

  useEffect(() => {
    if (scheduleData && scheduleData.length > 0) {
      const selectedTabDate = scheduleData[value].date;
      const selectedTabPath = scheduleData[value].location;

      if (selectedTabPath) {
        setPaths(selectedTabPath);
      } else {
        const selectedSchedule = scheduleData[value];
        const selectedSchedulelocation = selectedSchedule.location; 

        localStorage.setItem('selectedTab', selectedTabDate);
        localStorage.setItem('selectedSchedule', JSON.stringify(selectedSchedule));
        localStorage.setItem('markers', JSON.stringify(selectedSchedulelocation));
        setPaths(selectedSchedulelocation);

        const updatedDataStructor = scheduleData.map((day) => {
          if (day.date === selectedTabDate) {
            return {
              ...day,
              location: selectedSchedulelocation,
            };
          } else {
            return day;
          }
        });
        localStorage.setItem("dataStructor", JSON.stringify(updatedDataStructor));
      }
    }
  }, [value, scheduleData]);
  
  const handleChange = (event, newValue) => {
    // const previousTabDate = scheduleData[value].date;
    // const previousTabPath = JSON.stringify(scheduleData[value].location);
    // localStorage.setItem(previousTabDate, previousTabPath);
    localStorage.removeItem('markers');
  
    setValue(newValue);
  
    const selectedTabDate = scheduleData[newValue].date;
    const selectedTabPath = localStorage.getItem(selectedTabDate);
  
    if (selectedTabPath) {
      const parsedTabPath = JSON.parse(selectedTabPath);
      setPaths(parsedTabPath || []);
    } else {
      setPaths([]);
    }
  };
 

  if (!scheduleData || scheduleData.length === 0) {
    return <div>No schedule data available.</div>; 
  }

  return (
    <Box sx={{ flexGrow: 1, bgcolor: 'background.paper', display: 'flex', height: 224 }}>
      <Tabs
        orientation="vertical"
        variant="scrollable"
        value={value}
        onChange={handleChange}
        aria-label="Vertical tabs example"
        sx={{ borderRight: 1, borderColor: 'divider' }}
      >
        {scheduleData.map((day, index) => (
          <Tab label={day.date} {...a11yProps(index+1)} key={day.date} />
        ))}
      </Tabs>
      {scheduleData.map((day, index) => (
        <TabPanel value={value} index={index} key={day.date} scheduleData={scheduleData} />
      ))}
      {/* <button onClick={pathSaveClickHandle}>확인DB에 저장하는것.</button> */}
    </Box>
  );
}

VerticalTabs.propTypes = {
  scheduleData: PropTypes.arrayOf(
    PropTypes.shape({
      id: PropTypes.number.isRequired,
      date: PropTypes.string.isRequired,
      location: PropTypes.arrayOf(
        PropTypes.shape({
          addr: PropTypes.string.isRequired,
          lat: PropTypes.number.isRequired,
          lng: PropTypes.number.isRequired,
        })
      ).isRequired,
    })
  ).isRequired,
};

const Contents = ({ map }) => {
  const [serchParams, setSearchParams] = useSearchParams();
  const [startDay, setStartDay] = useState(dayjs());
  const [endDay, setEndDay] = useState(dayjs().add(1, 'day'));
  const [totalDate, setTotalDate] =useState(1);
  const [paths, setPaths] = useState([]);
  const [authState, setAuthState] = useRecoilState(authenticationState);
  const [isModalOpen, setIsModalOpen] = useState(true);

  const principal = useQuery(["principal"], async () => {
    const accessToken = localStorage.getItem("accessToken");
    const response = await axios.get('http://localhost:8080/api/v1/auth/principal', {params: {accessToken}});
    return response;
  }, {
    enabled: authState,
  });

  const startDayHandle = (newValue) => {
    setStartDay(newValue);
    setTotalDate(endDay.diff(newValue, 'day') + 1);
  }

  const endDayHandle = (newValue) => {
    setEndDay(newValue);
    setTotalDate(newValue.diff(startDay, 'Day') + 1)
  }

  const resetDay = () => {
    setStartDay(dayjs());
    setEndDay(dayjs().add(1,'day'));
    setTotalDate(1);
  }
  
  // const submitPlanHandle = (e)=>{
  //   localStorage.removeItem('scheduleData');
  // }
  const openModal = () => {
    setIsModalOpen(!isModalOpen);
  };
  
  const closeModal = () => {
    setIsModalOpen(!isModalOpen);
  };
  return (
    <>
      <div css={container}>
        <div css={main}>
          <Map destinationTitle={serchParams.get("destinationTitle")} paths={paths} setPaths={setPaths} map={map}/>
          <div css={sidebar}>
              <div css={Title}>{serchParams.get("destinationTitle")}</div>
              <div css={avatarBox}>
                친구아바타
                <button onClick={openModal}>Open Modal</button>
              </div>
              <button css={resetButton} onClick={resetDay}>Reset Start Day</button>
              <Calendar 
                css={calendar}
                startDay={startDay}
                endDay={endDay}
                totalDate={totalDate}
                onStartDayChange={startDayHandle}
                onEndDayChange={endDayHandle}
                markerData={paths}
              />
               {/* <MapSearch map={map} /> */}
          </div>
        </div>
      </div>
      <AddUserModal
      isOpen={isModalOpen}
      onClose={closeModal}
      destination={{ image: 'image-url', title: serchParams.get("destinationTitle"), englishing: 'Englishing' }}
      />
    </>

  );
};

export default Contents;

위의 코드들은 컴포넌트들을 구성하는 코드들입니다.

1.3. 기능 구현

애플리케이션의 기능을 구현하기 위해서는 컴포넌트에서 사용되는 상태 및
이벤트 핸들러를 적절히 구현해야 합니다. 예를 들어, Calendar 컴포넌트에서는
날짜 선택, 일정 저장 등의 기능을 구현해야 합니다.
Map 컴포넌트에서는 지도 초기화, 마커 추가 및 제거 등의 기능을 구현해야 합니다.

상태 관리에는 React의 useState 훅을 사용하며,
상태 변경에 따라 필요한 로직을 useEffect 훅을 사용하여 구현합니다.
이벤트 핸들러 함수를 정의하고 해당 함수를 이벤트 처리 로직에 바인딩하여
원하는 동작을 구현할 수 있습니다.

1.4. 데이터 통신

애플리케이션에서 서버와의 데이터 통신은 axios 또는 fetch API를 활용하여 구현할 수 있습니다.
Calendar 컴포넌트에서는 일정 저장 버튼 클릭 시 서버로 데이터를 전송하는 로직을 구현하였습니다.

const requestData = useMutation(async (scheduleData) => {
  const option = {
    headers: {
      'Content-Type': 'application/json',
      Authorization: `${localStorage.getItem("accessToken")}`
    }
  }
  try {
    const response = await axios.post("http://localhost:8080/api/v1/travel/plan", scheduleData, option);
    console.log(response.status);

    window.location.replace("/");
    return response;
  } catch (error) {
    // 오류 처리 로직
  }
}, {
  onSuccess: (response) => {
    localStorage.removeItem("scheduleData");
  }
});

const submitPlanHandler = () => {
  requestData.mutate(localStorage.getItem("scheduleData"));
}

위 코드는 useMutation 훅을 사용하여 서버와의 통신을 구현한 예시입니다.
데이터를 전송하기 위해 axios를 사용하고,
서버 응답을 처리하기 위해 onSuccess 핸들러를 정의하였습니다.


마무리

이제 React를 활용하여 여행 일정 계획 애플리케이션을 개발하는 방법에 대해 알아보았습니다.
실제 개발에 사용되는 일부 코드이며, 더 많은 코드와 로직이 필요할 수 있습니다.

profile
백엔드 개발자 준비중

0개의 댓글