diff --git a/src/api/alarm/alarm.ts b/src/api/alarm/alarm.ts index f1b9750..49b7ec3 100644 --- a/src/api/alarm/alarm.ts +++ b/src/api/alarm/alarm.ts @@ -1,6 +1,6 @@ import type { TRequestGetAlarm, TRequestPostDeviceToken, TResponseGetAlarm, TResponsePostDeviceToken } from '@/types/alarm/alarm'; -import { axiosInstance } from '../axiosInstance'; +import { axiosInstance } from '@/api/axiosInstance'; export const getAlarm = async ({ size = 5, cursor }: TRequestGetAlarm): Promise => { const { data } = await axiosInstance.get('/api/v1/alarms', { diff --git a/src/api/auth/auth.ts b/src/api/auth/auth.ts index 48f6d9a..40176e7 100644 --- a/src/api/auth/auth.ts +++ b/src/api/auth/auth.ts @@ -13,7 +13,7 @@ import type { TSocialLoginValues, } from '@/types/auth/auth'; -import { axiosInstance } from '../axiosInstance'; +import { axiosInstance } from '@/api/axiosInstance'; export const defaultSignup = async ({ email, password, username, gender, phoneNumber, birth, socialId }: TSignupValues): Promise => { const { data } = await axiosInstance.post('/api/v1/auth/sign-up', { email, password, socialId, username, gender, phoneNumber, birth }); diff --git a/src/api/course/course.ts b/src/api/course/course.ts index fa176f3..c1ec404 100644 --- a/src/api/course/course.ts +++ b/src/api/course/course.ts @@ -1,6 +1,6 @@ import type { TSearchRegionResponse, TSearchRegionValues } from '@/types/dateCourse/dateCourse'; -import { axiosInstance } from '../axiosInstance'; +import { axiosInstance } from '@/api/axiosInstance'; export const searchRegion = async ({ keyword }: TSearchRegionValues): Promise => { const { data } = await axiosInstance.get('/api/v1/regions/search', { diff --git a/src/api/home/dateCourse.ts b/src/api/home/dateCourse.ts new file mode 100644 index 0000000..b1c3a18 --- /dev/null +++ b/src/api/home/dateCourse.ts @@ -0,0 +1,8 @@ +import type { TDateCourseSavedCountResponse } from '@/types/home/dateCourse'; + +import { axiosInstance } from '@/api/axiosInstance'; + +export const getDateCourseSavedCount = async (): Promise => { + const { data } = await axiosInstance.get('/api/v1/logs/datecourses/saved-count'); + return data; +}; diff --git a/src/api/home/dateTimes.ts b/src/api/home/dateTimes.ts new file mode 100644 index 0000000..b03573f --- /dev/null +++ b/src/api/home/dateTimes.ts @@ -0,0 +1,14 @@ +import type { TGetDateTimeStates, TMonthlyDatePlaceResponse } from '../../types/home/datePlace'; + +import { axiosInstance } from '@/api/axiosInstance'; + +// 월별 데이트 장소 수 조회 API +export const getMonthlyDatePlaceStates = async (): Promise => { + const { data } = await axiosInstance.get('/api/v1/logs/dateplaces/monthly'); + return data; +}; + +export const getDateTimeStates = async (): Promise => { + const { data } = await axiosInstance.get('/api/v1/logs/datecourses/average'); + return data; +}; diff --git a/src/api/home/keyword.ts b/src/api/home/keyword.ts new file mode 100644 index 0000000..852d218 --- /dev/null +++ b/src/api/home/keyword.ts @@ -0,0 +1,9 @@ +import type { TWeeklyKeywordResponse } from '@/types/home/keyword'; + +import { axiosInstance } from '@/api/axiosInstance'; + +// 이번 주 인기 키워드 조회 API +export const getWeeklyKeywords = async (): Promise => { + const { data } = await axiosInstance.get('/api/v1/logs/keyword/weekly'); + return data; +}; diff --git a/src/api/home/level.ts b/src/api/home/level.ts new file mode 100644 index 0000000..eb649ac --- /dev/null +++ b/src/api/home/level.ts @@ -0,0 +1,9 @@ +import type { TUserGradeResponse } from '@/types/home/level'; + +import { axiosInstance } from '@/api/axiosInstance'; + +// 사용자 등급 조회 API +export const getUserGrade = async (): Promise => { + const { data } = await axiosInstance.get('/api/v1/members/grade'); + return data; +}; diff --git a/src/api/home/region.ts b/src/api/home/region.ts new file mode 100644 index 0000000..42148ed --- /dev/null +++ b/src/api/home/region.ts @@ -0,0 +1,13 @@ +import type { TGetUserRegionResponse, TPatchUserRegionRequest, TPatchUserRegionResponse } from '@/types/home/region'; + +import { axiosInstance } from '@/api/axiosInstance'; + +export const patchUserRegion = async ({ regionId }: TPatchUserRegionRequest): Promise => { + const { data } = await axiosInstance.patch('/api/v1/regions/users', { regionId }); + return data; +}; + +export const getUserRegion = async (): Promise => { + const { data } = await axiosInstance.get('/api/v1/regions/users/current'); + return data; +}; diff --git a/src/api/home/weather.ts b/src/api/home/weather.ts new file mode 100644 index 0000000..cbca1ff --- /dev/null +++ b/src/api/home/weather.ts @@ -0,0 +1,22 @@ +import type { + TGetPrecipitationRequest, + TGetPrecipitationResponse, + TGetWeeklyWeatherRecommendationRequest, + TGetWeeklyWeatherRecommendationResponse, +} from '@/types/home/weather'; + +import { axiosInstance } from '@/api/axiosInstance'; + +// 주간 날씨 추천 조회 API +export const getWeeklyWeatherRecommendation = async ({ + regionId, + startDate, +}: TGetWeeklyWeatherRecommendationRequest): Promise => { + const { data } = await axiosInstance.get(`/api/v1/weather/${regionId}/weekly`, { params: { startDate } }); + return data; +}; + +export const getPrecipitation = async ({ regionId, startDate }: TGetPrecipitationRequest): Promise => { + const { data } = await axiosInstance.get(`/api/v1/weather/${regionId}/precipitation`, { params: { startDate } }); + return data; +}; diff --git a/src/api/notice/notice.ts b/src/api/notice/notice.ts index 2249296..b2ce1c0 100644 --- a/src/api/notice/notice.ts +++ b/src/api/notice/notice.ts @@ -1,19 +1,11 @@ -import type { TFetchNoticeDetailResponse, TFetchNoticesResponse } from '@/types/notice/notice'; +import type { TFetchNoticeDetailResponse, TFetchNoticesResponse, TRequestGetNoticeRequest } from '@/types/notice/notice'; -import { axiosInstance } from '../axiosInstance'; +import { axiosInstance } from '@/api/axiosInstance'; // 공지사항 전체 조회 API -export const fetchNotices = async ({ - category, - page, - size, -}: { - category: 'SERVICE' | 'SYSTEM'; - page: number; - size: number; -}): Promise => { +export const fetchNotices = async ({ noticeCategory = 'SERVICE', page, size }: TRequestGetNoticeRequest): Promise => { const { data } = await axiosInstance.get('/api/v1/notices', { - params: { noticeCategory: category, page, size }, + params: { noticeCategory: noticeCategory, page, size }, }); return data; }; diff --git a/src/assets/icons/weather/rain.svg b/src/assets/icons/weather/rain.svg index 7d28a95..966e7de 100644 --- a/src/assets/icons/weather/rain.svg +++ b/src/assets/icons/weather/rain.svg @@ -1,12 +1,11 @@ - + - diff --git a/src/components/common/modalProvider.tsx b/src/components/common/modalProvider.tsx index 8b626a8..821efa9 100644 --- a/src/components/common/modalProvider.tsx +++ b/src/components/common/modalProvider.tsx @@ -6,6 +6,8 @@ import DateCourseSearchFilterModal from '@/components/modal/dateCourseSearchFilt import ErrorModal from '@/components/modal/errorModal'; import SettingsModal from '@/components/modal/SettingModal'; +import RegionModal from '../modal/regionModal'; + import useModalStore from '@/store/useModalStore'; // 모달 타입 정의 -> 만약 다른 모달을 추가하고 싶다면 여기에 타입을 추가하고, MODAL_COMPONENTS에 컴포넌트를 추가하면 됩니다. @@ -15,6 +17,7 @@ export const MODAL_TYPES = { DateCourseSearchFilterModal: 'DateCourseSearchFilterModal', SettingsModal: 'SettingsModal', //설정 모달 추가 AlarmModal: 'AlarmModal', + RegionModal: 'RegionModal', }; export const MODAL_COMPONENTS = { @@ -22,6 +25,7 @@ export const MODAL_COMPONENTS = { [MODAL_TYPES.DateCourseSearchFilterModal]: DateCourseSearchFilterModal, [MODAL_TYPES.SettingsModal]: SettingsModal, [MODAL_TYPES.AlarmModal]: AlarmModal, + [MODAL_TYPES.RegionModal]: RegionModal, }; export default function ModalProvider() { diff --git a/src/components/home/banner.tsx b/src/components/home/banner.tsx index 3230142..6e94f3b 100644 --- a/src/components/home/banner.tsx +++ b/src/components/home/banner.tsx @@ -5,28 +5,35 @@ import Button from '../common/Button'; import ChevronBack from '@/assets/icons/default_arrows/chevron_back.svg?react'; import ChevronForward from '@/assets/icons/default_arrows/chevron_forward.svg?react'; -import scroll from '@/images/scroll.png'; +import bicycle from '@/images/banner/bicycle.png'; +import bukchon from '@/images/banner/bukchon.png'; +import itaewon from '@/images/banner/itaewon.png'; +import sungsu from '@/images/banner/sungsu.png'; const slides = [ { title: '서울 성수동 : 옛것과 새로운 것이 교차하는 하루', description: '1960년대부터 조성된 오래된 공장 건물과 최근 벽돌 건물들의 분위기', tags: ['#활발한 활동', '#레트로 감성', '#서울 핫플'], + img: sungsu, }, { title: '한강 자전거 데이트 : 바람 따라 달리는 낭만', description: '도심 속 자연을 만끽하며 힐링 타임', tags: ['#운동 데이트', '#자연과 함께', '#저녁노을'], + img: bicycle, }, { title: '이태원 세계 음식 투어 : 입 안 가득 여행', description: '세계 각국의 맛을 한 자리에서 즐기기', tags: ['#미식가 커플', '#이국적인 분위기', '#도심 속 여행'], + img: itaewon, }, { title: '북촌 한옥마을 산책 : 전통의 미를 따라 걷기', description: '골목골목 숨어있는 사진 명소', tags: ['#한옥', '#조용한 산책', '#전통과 현대'], + img: bukchon, }, ]; @@ -34,16 +41,14 @@ function Banner() { const navigate = useNavigate(); const [currentIndex, setCurrentIndex] = useState(0); - // ⏱️ 자동 슬라이드 타이머 useEffect(() => { const interval = setInterval(() => { setCurrentIndex((prevIndex) => (prevIndex + 1) % slides.length); }, 3000); // 2초 - return () => clearInterval(interval); // 언마운트 시 정리 + return () => clearInterval(interval); }, []); - // ⬅️➡️ 버튼 클릭 핸들러 const goToPrev = () => { setCurrentIndex((prevIndex) => (prevIndex - 1 + slides.length) % slides.length); }; @@ -52,12 +57,12 @@ function Banner() { setCurrentIndex((prevIndex) => (prevIndex + 1) % slides.length); }; - const { title, description, tags } = slides[currentIndex]; + const { title, description, tags, img } = slides[currentIndex]; return (
- 배너 - + 배너 +
{/* 내용 */}
오늘의 데이트 추천
diff --git a/src/components/home/dateCourseStore.tsx b/src/components/home/dateCourseStore.tsx index 819f18d..e969977 100644 --- a/src/components/home/dateCourseStore.tsx +++ b/src/components/home/dateCourseStore.tsx @@ -1,8 +1,16 @@ +import { Navigate } from 'react-router-dom'; + +import { useDateCourseSavedCount } from '@/hooks/home/useDateCourseStates'; + import MainCard from './mainCard'; import ArchiveBlank from '@/assets/icons/Archive_Blank.svg?react'; function DateCourseStore() { + const { data, isLoading, error } = useDateCourseSavedCount(); + if (error) { + return ; + } return (
@@ -11,11 +19,16 @@ function DateCourseStore() {
내 데이트 코스를
-
2,345명
+ {isLoading ? ( +
로딩...
+ ) : ( +
{data?.result.count}명
+ )}
이 저장했어요.
); } + export default DateCourseStore; diff --git a/src/components/home/dateLocation.tsx b/src/components/home/dateLocation.tsx index d402dce..e8487fa 100644 --- a/src/components/home/dateLocation.tsx +++ b/src/components/home/dateLocation.tsx @@ -1,31 +1,45 @@ +import { useMemo } from 'react'; +import { Navigate } from 'react-router-dom'; +import ClipLoader from 'react-spinners/ClipLoader'; + +import { useMonthlyPlaceStates } from '@/hooks/home/useDatePlaceStates'; + import MainCard from '@/components/home/mainCard'; function DateLocation() { + const { data, isLoading, error } = useMonthlyPlaceStates(); + const maxCount = useMemo(() => { + return data?.result?.datePlaceLogList?.reduce((max, cur) => Math.max(max, cur.count), 0) ?? 0; + }, [data]); + if (error) { + return ; + } + if (isLoading) { + return ( + + + + ); + } return (
WithTime에 등록된 데이트 장소 수
-
- 230 -
-
2022
-
-
- 430 -
-
2023
-
-
- 830 -
-
2024
-
-
- 1,230 -
-
2025
-
+ {(data?.result?.datePlaceLogList ?? []).map((graph, idx) => { + const height = maxCount ? Math.max((graph.count / maxCount) * 200, 4) : 4; + return ( +
+ {graph.count} +
+
{graph.month}월
+
+ ); + })}
diff --git a/src/components/home/dateRecommend.tsx b/src/components/home/dateRecommend.tsx index 281d32b..fdb13e5 100644 --- a/src/components/home/dateRecommend.tsx +++ b/src/components/home/dateRecommend.tsx @@ -1,139 +1,163 @@ -import { useState } from 'react'; +// src/components/home/dateRecommend.tsx +import { type ComponentType, type SVGProps, useEffect, useMemo, useState } from 'react'; import { Line } from 'react-chartjs-2'; +import ClipLoader from 'react-spinners/ClipLoader'; import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Title, Tooltip } from 'chart.js'; import ChartDataLabels from 'chartjs-plugin-datalabels'; +import { chartOptions } from '@/constants/chartOptions'; + +import { getNextSevenDay } from '@/utils/getNextSevenDay'; +import { getTodayString } from '@/utils/getTodayString'; +import { normalizeEmojiKey } from '@/utils/normalizeEmojiKey'; +import { getWeatherSentence } from '@/utils/weatherMessage'; + +import { useGetUserRegion } from '@/hooks/home/useUserRegion'; +import { useRainyInfo, useWeatherForecast } from '@/hooks/home/useWeather'; + import Button from '@/components/common/Button'; import MainCard from '@/components/home/mainCard'; +import { MODAL_TYPES } from '../common/modalProvider'; + +import Cloud from '@/assets/icons/weather/cloud.svg?react'; +import Rain from '@/assets/icons/weather/rain.svg?react'; +import Snow from '@/assets/icons/weather/snow.svg?react'; import Sun from '@/assets/icons/weather/sun.svg?react'; +import Shower from '@/assets/icons/weather/sunShower.svg?react'; +import useModalStore from '@/store/useModalStore'; ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, ChartDataLabels); +const EMOJI_KEY_ICON_MAP: Record>> = { + sunny: Sun, + cloudy: Cloud, + rainy: Rain, + shower: Shower, + snowy: Snow, +}; + function DateRecommend() { - const [date, setDate] = useState(0); - const rainData = { - labels: ['7월 9일', '7월 10일', '7월 11일', '7월 12일', '7월 13일', '7월 14일', '7월 15일'], - datasets: [ - { - label: '강수확률', - data: [10, 30, 10, 45, 70, 45, 90], - borderColor: '#3FA495', - borderWidth: 2, - pointRadius: 4, - pointBackgroundColor: '#ffffff', - fill: false, - tension: 0, - datalabels: { - align: 'end' as const, - anchor: 'end' as const, - offset: 2, - color: '#c3c3c3', - font: { - weight: 'bold' as const, - size: 12, - }, - formatter: (value: number) => `${value}%`, - }, - }, - ], - }; - - const chartOptions = { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: false, - }, - tooltip: { - enabled: false, - }, - }, - scales: { - x: { - grid: { - display: false, - }, - ticks: { - color: '#616161', - font: { - size: 12, - weight: 500, + const [dateIdx, setDateIdx] = useState(0); + const { data } = useGetUserRegion(); + // 기준 날짜/지역 + const startDate = getTodayString(); + const regionId = data?.result.regionId; + + const { data: forecastData, isLoading: forecastLoading } = useWeatherForecast({ startDate, regionId: regionId ?? 0 }); + const { data: rainyData, isLoading: rainyLoading } = useRainyInfo({ startDate, regionId: regionId ?? 0 }); + + const safeStartDate = rainyData?.result?.startDate ?? startDate; + const dateList = useMemo(() => getNextSevenDay(safeStartDate), [safeStartDate]); + + // 차트 데이터: 로딩/초기엔 빈 배열 + const rainData = useMemo(() => { + const values = rainyData?.result?.dailyPrecipitations?.map((f) => f.precipitationProbability) ?? []; + return { + labels: dateList, + datasets: [ + { + label: '강수확률', + data: values, + borderColor: '#3FA495', + borderWidth: 2, + pointRadius: 4, + pointBackgroundColor: '#ffffff', + fill: false, + tension: 0, + datalabels: { + align: 'end' as const, + anchor: 'end' as const, + offset: 2, + color: '#c3c3c3', + font: { weight: 'bold' as const, size: 12 }, + formatter: (value: number) => `${value}%`, }, }, - drawBorder: false, - drawOnChartArea: false, - border: { - display: false, - }, - }, - y: { - min: 0, - max: 100, - ticks: { - stepSize: 10, - }, - display: false, - grid: { - display: false, - }, - }, - }, - }; + ], + }; + }, [dateList, rainyData]); + const { openModal } = useModalStore(); + const currentRec = forecastData?.result?.dailyRecommendations?.[dateIdx]; + const rawKey = currentRec?.emoji; + const iconKey = normalizeEmojiKey(rawKey); + const Icon = EMOJI_KEY_ICON_MAP[iconKey] ?? Sun; + + useEffect(() => { + if (dateIdx >= dateList.length) setDateIdx(0); + }, [dateIdx, dateList.length]); + + if (rainyLoading || forecastLoading) { + return ( + + + + ); + } return (
{/* 상단 텍스트 */}
-
이번 주 강남구 데이트 추천
- +
이번 주 {forecastData?.result?.region?.regionName ?? '지역'} 데이트 추천
+
{/* 날짜 버튼 */}
- {rainData.labels.map((data, idx) => ( + {dateList.map((d, idx) => ( ))}
{/* 날씨 설명 */} -
-
-
- - 맑고 무더운 날 +
+
+
+ + + {forecastLoading + ? '날씨 로딩 중...' + : currentRec + ? getWeatherSentence({ + weather: currentRec.weatherType!, + temp: currentRec.tempCategory!, + }) + : '날씨 정보 없음'} +
+
- #실내추천 - - #카페데이트 - - - #시원한하루 - + {(currentRec?.keywords ?? []).map((tag: string, index: number) => ( + + {tag} + + ))}
-
- 맑은 하늘무더운 날씨가 기승을 - 부려요. -
- 우산 없이도 걱정 없어요. -
- 시원한 음료와 함께 실내 데이트가 좋아요. +
+ {currentRec?.message ?? (forecastLoading ? '로드 중...' : '메시지 없음')}
+ + {/* 차트 */}
이번주 강수확률 (%)
diff --git a/src/components/home/dateTimes.tsx b/src/components/home/dateTimes.tsx index 4c92da3..1788307 100644 --- a/src/components/home/dateTimes.tsx +++ b/src/components/home/dateTimes.tsx @@ -1,10 +1,24 @@ +import ClipLoader from 'react-spinners/ClipLoader'; + +import { useDateTimeStates } from '@/hooks/home/useDateTimes'; + import MainCard from './mainCard'; function DateTimes() { + const { data: states, isLoading, error } = useDateTimeStates(); + + const displayStates = states?.result; + if (isLoading) { + return ( +
+ +
+ ); + } return (
- {/* 첫 번째 카드 */} + {/* 첫 번째 카드 - WithTime 이용자 평균 데이트 횟수 */}
최근 1개월
@@ -12,21 +26,27 @@ function DateTimes() {
평균 데이트 횟수
-
4.6회
+ +
+ {displayStates?.averageDateCount != null ? `${displayStates.averageDateCount}회` : '—'} +
+
- {/* 두 번째 카드 */} + + {/* 두 번째 카드 - 나의 데이트 횟수 */}
최근 1개월
-
- WithTime 이용자 -
- 평균 데이트 횟수 -
-
2회
+
나의 데이트 횟수
+ +
{displayStates?.myDateCount != null ? `${displayStates.myDateCount}회` : '—'}
+ + {/* 에러 상태 표시 */} + {error &&
데이터를 불러올 수 없습니다
} ); } + export default DateTimes; diff --git a/src/components/home/info.tsx b/src/components/home/info.tsx index 1ab8083..09a0df9 100644 --- a/src/components/home/info.tsx +++ b/src/components/home/info.tsx @@ -1,11 +1,27 @@ -import { useNavigate } from 'react-router-dom'; +import { Navigate, useNavigate } from 'react-router-dom'; +import ClipLoader from 'react-spinners/ClipLoader'; + +import { useGetNotices } from '@/hooks/notices/useGetNotices'; import MainCard from './mainCard'; import AddCircleBlank from '@/assets/icons/add-circle_Blank.svg?react'; -export default function MainInfo() { +function MainInfo() { const navigate = useNavigate(); + const { data, error, isLoading } = useGetNotices({ size: 3, page: 0, noticeCategory: 'SERVICE' }); + + if (error) { + return ; + } + if (isLoading) { + return ( + + + + ); + } + const notices = data?.pages.flatMap((page) => page.result.noticeList) ?? []; return (
@@ -20,11 +36,30 @@ export default function MainInfo() {
    -
  • 여름 맞이 피서 데이트 코스 추가 업데이트
  • -
  • 슬기로운 데이트를 하고싶은 커플을 위한 이벤트
  • -
  • 위티 사칭 웹사이트 및 보이스피싱 주의 안내
  • + {notices.length === 0 ? ( +
  • 공지사항이 없습니다.
  • + ) : ( + notices.map((notice) => ( +
  • + +
  • + )) + )}
); } + +export default MainInfo; diff --git a/src/components/home/level.tsx b/src/components/home/level.tsx index 69bb720..0ea8d0a 100644 --- a/src/components/home/level.tsx +++ b/src/components/home/level.tsx @@ -1,29 +1,44 @@ +import { useEffect, useState } from 'react'; + +import type { IGradeInfo } from '@/types/home/level'; + import MainCard from '@/components/home/mainCard'; -import ramji from '@/images/animals/ramgi.png'; +import mainCharacter from '@/images/mainCharacter.png'; + +function Level({ grade, nextRequiredPoint }: IGradeInfo) { + const [percentage, setPercentage] = useState(0); + + useEffect(() => { + setPercentage(100 - nextRequiredPoint); + }, [nextRequiredPoint]); -function Level() { return (
캐릭터
-
Flirt
+
{grade}
+
다음 데이트 레벨 성장까지
- 20점 - 의 데이트 활동이 필요합니다 + {`${nextRequiredPoint}점`} + 의 데이트 활동이 필요합니다
+ {/* 진행바 */}
-
+
@@ -31,4 +46,5 @@ function Level() { ); } + export default Level; diff --git a/src/components/home/wordCloud.tsx b/src/components/home/wordCloud.tsx index aad36d3..dabf9dc 100644 --- a/src/components/home/wordCloud.tsx +++ b/src/components/home/wordCloud.tsx @@ -1,34 +1,26 @@ -import { useEffect, useRef } from 'react'; -import type { DebouncedFunc } from 'lodash'; +import { useEffect, useRef, useState } from 'react'; +import { type DebouncedFunc } from 'lodash'; import throttle from 'lodash.throttle'; import type { ListEntry } from 'wordcloud'; import WordCloud from 'wordcloud'; -const keywords = [ - { text: '드라이브', value: 19 }, - { text: '포토존', value: 18 }, - { text: '카페', value: 20 }, - { text: '감성', value: 17 }, - { text: '맛집', value: 19.5 }, - { text: '피크닉', value: 16 }, - { text: '영화관', value: 17.5 }, - { text: '산책', value: 15 }, - { text: '실내', value: 15.5 }, - { text: '레저', value: 14 }, - { text: '홍대', value: 19.2 }, - { text: '전시', value: 14.8 }, - { text: '성수', value: 18.8 }, - { text: '가로수길', value: 15.2 }, - { text: '행궁동', value: 15.3 }, - { text: '레트로', value: 15.6 }, - { text: '쇼핑', value: 15.5 }, -]; +import { useWeeklyKeywords } from '@/hooks/home/useKeywordStates'; -export default function WordCloudCanvas() { +function WordCloudCanvas() { + const { data } = useWeeklyKeywords(); + const [list, setList] = useState([]); const containerRef = useRef(null); const canvasRef = useRef(null); const throttledDrawRef = useRef void> | null>(null); + useEffect(() => { + if (data?.result?.placeCategoryLogList) { + setList(data.result.placeCategoryLogList.map((k) => [String(k.placeCategoryLabel), Number(k.count)]) as ListEntry[]); + } else { + setList([]); + } + }, [data]); + const drawCloud = (width: number, height: number) => { const canvas = canvasRef.current; if (!canvas) return; @@ -36,25 +28,29 @@ export default function WordCloudCanvas() { const dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = height * dpr; - canvas.style.width = `${width}px`; + canvas.style.width = `${width - 10}px`; canvas.style.height = `${height}px`; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - const list: ListEntry[] = keywords.map((k) => [k.text, k.value]); - WordCloud(canvas, { list, gridSize: Math.round(8 * (width / 400)), - weightFactor: (size) => size * (width / 350), + weightFactor: (size) => { + const factor = width / 1500; + const minFactor = 0.3; + const maxFactor = 1.5; + return size * Math.max(minFactor, Math.min(maxFactor, factor)); + }, fontFamily: 'Pretendard, sans-serif', color: (_word, weight) => { - if (Number(weight) > 18) return '#186a6d'; - if (Number(weight) > 17) return '#3fa495'; - if (Number(weight) > 16) return '#7fe4c1'; - if (Number(weight) > 15) return '#b5f7d3'; + if (Number(weight) > 95) return '#186a6d'; + if (Number(weight) > 85) return '#389486'; + if (Number(weight) > 75) return '#6fc9a9'; + if (Number(weight) > 65) return '#99d4b4'; + if (Number(weight) > 55) return '#b5f7d3'; return '#c3c3c3'; }, rotateRatio: 0.4, @@ -79,13 +75,17 @@ export default function WordCloudCanvas() { const { width, height } = container.getBoundingClientRect(); drawCloud(width, height); // 최초 한 번 직접 호출 } - }, []); + return () => { + WordCloud.stop(); + }; + }, [list]); // ResizeObserver 적용 useEffect(() => { if (!containerRef.current || !throttledDrawRef.current) return; const resizeObserver = new ResizeObserver((entries) => { + WordCloud.stop(); for (const entry of entries) { const { width, height } = entry.contentRect; throttledDrawRef.current?.(width, height); @@ -98,14 +98,15 @@ export default function WordCloudCanvas() { resizeObserver.disconnect(); throttledDrawRef.current?.cancel?.(); }; - }, []); + }, [list]); return (
이번주 인기 데이트 키워드 현황
-
- +
+
); } +export default WordCloudCanvas; diff --git a/src/components/modal/regionModal.tsx b/src/components/modal/regionModal.tsx new file mode 100644 index 0000000..cf51cb8 --- /dev/null +++ b/src/components/modal/regionModal.tsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; + +import { useSearchRegion } from '@/hooks/course/useSearchRegion'; +import { useUserRegion } from '@/hooks/home/useUserRegion'; + +import EditableInputBox from '@/components/common/EditableInputBox'; +import Modal from '@/components/common/modal'; + +interface IRegionModalProps { + onClose: () => void; +} + +function RegionModal({ onClose }: IRegionModalProps) { + const [searchQuery, setSearchQuery] = useState(''); // 검색어 + const [showResults, setShowResults] = useState(false); // 검색 결과 표시 여부 + const { mutate: patchUserRegionMutate } = useUserRegion(); + const { data: regionList, refetch } = useSearchRegion({ keyword: searchQuery }, { enabled: false }); + + const handleRegionSelect = (regionId: number) => { + patchUserRegionMutate( + { + regionId: regionId, + }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + }; + + const handleSearch = () => { + const keyword = searchQuery.trim(); + if (!keyword) return; + refetch(); + setShowResults(true); + }; + const handleInputChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + }; + + return ( + +
+ {/* 검색바 */} +
+
+ +
+
+ {/* 검색 결과 리스트 */} + {showResults && ( +
+
    + {regionList?.result.regions.length === 0 &&
    검색 결과가 없습니다
    } + {regionList?.result.regions.map((region, index) => ( +
  • + + {index < regionList.result.regions.length - 1 &&
    } +
  • + ))} +
+
+ )} +
+
+ ); +} + +export default RegionModal; diff --git a/src/constants/chartOptions.ts b/src/constants/chartOptions.ts new file mode 100644 index 0000000..106e699 --- /dev/null +++ b/src/constants/chartOptions.ts @@ -0,0 +1,24 @@ +import type { ChartOptions } from 'chart.js'; + +export const chartOptions: ChartOptions<'line'> = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { enabled: false }, + }, + scales: { + x: { + grid: { display: false, drawOnChartArea: false }, + ticks: { color: '#616161', font: { size: 12, weight: 500 } }, + border: { display: false }, + }, + y: { + min: 0, + max: 100, + ticks: { stepSize: 10 }, + display: false, + grid: { display: false }, + }, + }, +}; diff --git a/src/constants/weather.ts b/src/constants/weather.ts new file mode 100644 index 0000000..19c945b --- /dev/null +++ b/src/constants/weather.ts @@ -0,0 +1,31 @@ +export enum WeatherType { + CLEAR = 'CLEAR', // 맑고 + CLOUDY = 'CLOUDY', // 흐리고 + RAINY = 'RAINY', // 비오고 + SNOWY = 'SNOWY', // 눈오고 + RAIN_SNOW = 'RAIN_SNOW', // 비/눈오는 + SHOWER = 'SHOWER', // 소나기 +} + +export const WeatherTypeLabel: Record = { + [WeatherType.CLEAR]: '맑고', + [WeatherType.CLOUDY]: '흐리고', + [WeatherType.RAINY]: '비오고', + [WeatherType.SNOWY]: '눈오는', + [WeatherType.RAIN_SNOW]: '비/눈오는', + [WeatherType.SHOWER]: '소나기오는', +}; + +export enum TempCategory { + CHILLY = 'CHILLY', // 쌀쌀한 날씨 + COOL = 'COOL', // 선선한 날씨 + MILD = 'MILD', // 무난한 날씨 + HOT = 'HOT', // 무더운 날씨 +} + +export const TempCategoryLabel: Record = { + [TempCategory.CHILLY]: '쌀쌀한 날씨', + [TempCategory.COOL]: '선선한 날씨', + [TempCategory.MILD]: '무난한 날씨', + [TempCategory.HOT]: '무더운 날씨', +}; diff --git a/src/hooks/customQuery.ts b/src/hooks/customQuery.ts index c5b6ace..2ab4371 100644 --- a/src/hooks/customQuery.ts +++ b/src/hooks/customQuery.ts @@ -13,7 +13,7 @@ export function useCoreQuery( queryKey: keyName, queryFn: query, ...options, - staleTime: 1000 * 60 * 5, + staleTime: options?.staleTime ?? 1000 * 60 * 5, }); } diff --git a/src/hooks/home/useDateCourseStates.ts b/src/hooks/home/useDateCourseStates.ts new file mode 100644 index 0000000..39e50c2 --- /dev/null +++ b/src/hooks/home/useDateCourseStates.ts @@ -0,0 +1,12 @@ +import { useCoreQuery } from '@/hooks/customQuery'; + +import { getDateCourseSavedCount } from '@/api/home/dateCourse'; +import { HomeKeys } from '@/queryKey/queryKey'; + +// 데이트 코스 저장 횟수 훅 +export const useDateCourseSavedCount = () => { + return useCoreQuery(HomeKeys.dateCourseSave().queryKey, getDateCourseSavedCount, { + gcTime: 15 * 60 * 1000, // 15분 + retry: 3, + }); +}; diff --git a/src/hooks/home/useDatePlaceStates.ts b/src/hooks/home/useDatePlaceStates.ts new file mode 100644 index 0000000..35c60df --- /dev/null +++ b/src/hooks/home/useDatePlaceStates.ts @@ -0,0 +1,11 @@ +import { useCoreQuery } from '../customQuery'; + +import { getMonthlyDatePlaceStates } from '@/api/home/dateTimes'; +import { HomeKeys } from '@/queryKey/queryKey'; + +export const useMonthlyPlaceStates = () => { + return useCoreQuery(HomeKeys.monthlyPlaceStates().queryKey, () => getMonthlyDatePlaceStates(), { + gcTime: 15 * 60 * 1000, + retry: 3, + }); +}; diff --git a/src/hooks/home/useDateTimes.ts b/src/hooks/home/useDateTimes.ts new file mode 100644 index 0000000..225e46a --- /dev/null +++ b/src/hooks/home/useDateTimes.ts @@ -0,0 +1,8 @@ +import { useCoreQuery } from '@/hooks/customQuery'; + +import { getDateTimeStates } from '@/api/home/dateTimes'; +import { HomeKeys } from '@/queryKey/queryKey'; + +export const useDateTimeStates = () => { + return useCoreQuery(HomeKeys.dateTimes().queryKey, () => getDateTimeStates(), { staleTime: 5 * 60 * 1000, gcTime: 15 * 60 * 1000, retry: 3 }); +}; diff --git a/src/hooks/home/useKeywordStates.ts b/src/hooks/home/useKeywordStates.ts new file mode 100644 index 0000000..3cc4d4d --- /dev/null +++ b/src/hooks/home/useKeywordStates.ts @@ -0,0 +1,12 @@ +import { useCoreQuery } from '@/hooks/customQuery'; + +import { getWeeklyKeywords } from '@/api/home/keyword'; +import { HomeKeys } from '@/queryKey/queryKey'; + +// 이번 주 인기 키워드 훅 +export const useWeeklyKeywords = () => { + return useCoreQuery(HomeKeys.keywords().queryKey, getWeeklyKeywords, { + gcTime: 30 * 60 * 1000, + retry: 3, + }); +}; diff --git a/src/hooks/home/useUserGrade.ts b/src/hooks/home/useUserGrade.ts new file mode 100644 index 0000000..bc79b16 --- /dev/null +++ b/src/hooks/home/useUserGrade.ts @@ -0,0 +1,12 @@ +import { useCoreQuery } from '@/hooks/customQuery'; + +import { getUserGrade } from '@/api/home/level'; +import { HomeKeys } from '@/queryKey/queryKey'; + +// 사용자 등급 정보 훅 +export const useUserGrade = () => { + return useCoreQuery(HomeKeys.getUserGrade().queryKey, () => getUserGrade(), { + gcTime: 15 * 60 * 1000, // 15분 + retry: 3, + }); +}; diff --git a/src/hooks/home/useUserRegion.ts b/src/hooks/home/useUserRegion.ts new file mode 100644 index 0000000..bcb7ac9 --- /dev/null +++ b/src/hooks/home/useUserRegion.ts @@ -0,0 +1,12 @@ +import { useCoreMutation, useCoreQuery } from '../customQuery'; + +import { getUserRegion, patchUserRegion } from '@/api/home/region'; +import { HomeKeys } from '@/queryKey/queryKey'; + +export function useUserRegion() { + return useCoreMutation(patchUserRegion); +} + +export function useGetUserRegion() { + return useCoreQuery(HomeKeys.userRegion().queryKey, getUserRegion); +} diff --git a/src/hooks/home/useWeather.ts b/src/hooks/home/useWeather.ts new file mode 100644 index 0000000..cb9cc3c --- /dev/null +++ b/src/hooks/home/useWeather.ts @@ -0,0 +1,19 @@ +import { getPrecipitation, getWeeklyWeatherRecommendation } from '../../api/home/weather'; +import { useCoreQuery } from '../customQuery'; + +import { HomeKeys } from '@/queryKey/queryKey'; + +// 주간 날씨 추천 훅 +export const useWeatherForecast = ({ startDate, regionId }: { startDate: string; regionId: number }) => { + return useCoreQuery(HomeKeys.weather(startDate, regionId).queryKey, () => getWeeklyWeatherRecommendation({ startDate, regionId: regionId! }), { + staleTime: 1000 * 60 * 30, + enabled: !!startDate && !!regionId, + }); +}; + +export const useRainyInfo = ({ startDate, regionId }: { startDate: string; regionId: number }) => { + return useCoreQuery(HomeKeys.rainyInfo(startDate, regionId).queryKey, () => getPrecipitation({ startDate, regionId: regionId! }), { + staleTime: 1000 * 60 * 30, + enabled: !!startDate && !!regionId, + }); +}; diff --git a/src/hooks/notices/useGetNotices.ts b/src/hooks/notices/useGetNotices.ts new file mode 100644 index 0000000..61c8451 --- /dev/null +++ b/src/hooks/notices/useGetNotices.ts @@ -0,0 +1,15 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import type { TRequestGetNoticeRequest } from '@/types/notice/notice'; + +import { fetchNotices } from '@/api/notice/notice'; +import { NoticeKeys } from '@/queryKey/queryKey'; + +export const useGetNotices = ({ size, page, noticeCategory }: TRequestGetNoticeRequest) => { + return useInfiniteQuery({ + queryKey: NoticeKeys.getAllNotices(page, size ?? 5, noticeCategory).queryKey, + queryFn: ({ pageParam = page }) => fetchNotices({ page: pageParam, size: size ?? 5, noticeCategory }), + initialPageParam: page, + getNextPageParam: (lastPage) => (lastPage.result.hasNextPage ? lastPage.result.currentPage + 1 : undefined), + }); +}; diff --git a/src/images/banner/bicycle.png b/src/images/banner/bicycle.png new file mode 100644 index 0000000..6f0cbfa Binary files /dev/null and b/src/images/banner/bicycle.png differ diff --git a/src/images/banner/bukchon.png b/src/images/banner/bukchon.png new file mode 100644 index 0000000..4a92819 Binary files /dev/null and b/src/images/banner/bukchon.png differ diff --git a/src/images/banner/itaewon.png b/src/images/banner/itaewon.png new file mode 100644 index 0000000..767038f Binary files /dev/null and b/src/images/banner/itaewon.png differ diff --git a/src/images/banner/sungsu.png b/src/images/banner/sungsu.png new file mode 100644 index 0000000..3e15354 Binary files /dev/null and b/src/images/banner/sungsu.png differ diff --git a/src/images/mainCharacter.png b/src/images/mainCharacter.png new file mode 100644 index 0000000..d2221d2 Binary files /dev/null and b/src/images/mainCharacter.png differ diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index 619d3b3..90838f0 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -1,4 +1,8 @@ +import { Navigate } from 'react-router-dom'; +import ClipLoader from 'react-spinners/ClipLoader'; + import { useDeviceToken } from '@/hooks/alarm/useDeviceToken'; +import { useUserGrade } from '@/hooks/home/useUserGrade'; import Banner from '@/components/home/banner'; import DateCourseStore from '@/components/home/dateCourseStore'; @@ -12,18 +16,27 @@ import WordCloudCard from '@/components/home/wordCloud'; function Home() { useDeviceToken(); + const { data: gradeData, isLoading, error } = useUserGrade(); + if (error) return ; + if (isLoading) { + return ( +
+ +
+ ); + } return (
- Madeleine + {gradeData?.result.username} 님의 WithTime
- + {gradeData?.result && }
diff --git a/src/pages/notice/Notice.tsx b/src/pages/notice/Notice.tsx index 884070a..1fe1f4c 100644 --- a/src/pages/notice/Notice.tsx +++ b/src/pages/notice/Notice.tsx @@ -32,7 +32,7 @@ export default function Notice() { try { // 공지사항 목록 요청 const response = await fetchNotices({ - category: categoryKey, + noticeCategory: categoryKey, page: currentPage - 1, size: itemsPerPage, }); diff --git a/src/queryKey/queryKey.ts b/src/queryKey/queryKey.ts index c964258..0b60752 100644 --- a/src/queryKey/queryKey.ts +++ b/src/queryKey/queryKey.ts @@ -7,3 +7,20 @@ export const regionKeys = createQueryKeys('region', { export const alarmKeys = createQueryKeys('alarm', { getAlarm: (size: number, cursor?: number) => [size, cursor], }); + +export const HomeKeys = createQueryKeys('home', { + all: () => ['home'], + getUserGrade: () => ['home', 'user', 'grade'], + dateCourseSave: () => ['home', 'date-courses', 'saved-count'], + weather: (startDate, regionId) => ['home', 'weather', 'forecast', startDate, regionId], + rainyInfo: (startDate, regionId) => ['home', 'rainy', 'forecast', startDate, regionId], + keywords: () => ['home', 'keywords'], + dateTimes: () => ['home', 'dateTimes'], + monthlyPlaceStates: () => ['home', 'monthlyPlaceStates'], + userRegion: () => ['home', 'user', 'region'], +}); + +export const NoticeKeys = createQueryKeys('notice', { + all: () => ['notice'], + getAllNotices: (page: number, size: number, noticeCategory: 'SERVICE' | 'SYSTEM') => ['notice', page, size, noticeCategory], +}); diff --git a/src/types/home/dateCourse.ts b/src/types/home/dateCourse.ts new file mode 100644 index 0000000..3a07367 --- /dev/null +++ b/src/types/home/dateCourse.ts @@ -0,0 +1,6 @@ +import type { TCommonResponse } from '../common/common'; + +// 사용자 등급 응답 타입 (실제 API 응답 구조) +export type TDateCourseSavedCountResponse = TCommonResponse<{ + count: number; +}>; diff --git a/src/types/home/datePlace.ts b/src/types/home/datePlace.ts new file mode 100644 index 0000000..bc267c3 --- /dev/null +++ b/src/types/home/datePlace.ts @@ -0,0 +1,21 @@ +import type { TCommonResponse } from '../common/common'; + +// 월별 데이트 장소 수 응답 타입 +export type TMonthlyDatePlaceResponse = TCommonResponse<{ datePlaceLogList: IMonthlyDatePlaceLog[] }>; + +export type TGetDateTimeStates = TCommonResponse<{ + averageDateCount: number; + myDateCount: number; +}>; +// 월별 데이트 장소 로그 타입 +export interface IMonthlyDatePlaceLog { + year: number; + month: number; + count: number; +} + +// 연도별 통계로 변환된 타입 +export interface IYearlyPlaceStates { + year: number; + placeCount: number; +} diff --git a/src/types/home/keyword.ts b/src/types/home/keyword.ts new file mode 100644 index 0000000..97c673e --- /dev/null +++ b/src/types/home/keyword.ts @@ -0,0 +1,12 @@ +import type { TCommonResponse } from '../common/common'; + +// 이번 주 인기 키워드 응답 타입 +export type TWeeklyKeywordResponse = TCommonResponse<{ + placeCategoryLogList: IPlaceCategoryLog[]; +}>; + +// 장소 카테고리 로그 타입 +export interface IPlaceCategoryLog { + placeCategoryLabel: string; + count: number; +} diff --git a/src/types/home/level.ts b/src/types/home/level.ts new file mode 100644 index 0000000..95d7175 --- /dev/null +++ b/src/types/home/level.ts @@ -0,0 +1,13 @@ +import type { TCommonResponse } from '../common/common'; + +// 사용자 등급 응답 타입 (실제 API 응답 구조) +export type TUserGradeResponse = TCommonResponse; + +// 등급 정보 타입 +export interface IGradeInfo { + username: string; + grade: string; + level: string; + description: string; + nextRequiredPoint: number; +} diff --git a/src/types/home/region.ts b/src/types/home/region.ts new file mode 100644 index 0000000..01613d6 --- /dev/null +++ b/src/types/home/region.ts @@ -0,0 +1,29 @@ +import type { TCommonResponse } from '../common/common'; + +export type TPatchUserRegionRequest = { + regionId: number; +}; + +export type TPatchUserRegionResponse = TCommonResponse<{ + regionId: number; + name: string; + regionCode: TRegionCode; + message: string; +}>; + +type TRegionCode = { + regionCodeId: number; + landRegCode: string; + tempRegCode: string; + name: string; +}; + +export type TGetUserRegionResponse = TCommonResponse<{ + regionId: number; + name: string; + latitude: number; + longitude: number; + gridX: number; + gridY: number; + regionCode: TRegionCode; +}>; diff --git a/src/types/home/weather.ts b/src/types/home/weather.ts new file mode 100644 index 0000000..cf32d36 --- /dev/null +++ b/src/types/home/weather.ts @@ -0,0 +1,50 @@ +import type { TempCategory, WeatherType } from '@/constants/weather'; + +import type { TCommonResponse } from '../common/common'; + +export type TGetWeeklyWeatherRecommendationRequest = { + startDate: string; + regionId: number; +}; +type TDailyRecommendations = { + forecastDate: string; + weatherType: WeatherType; + tempCategory: TempCategory; + precipCategory: string; + message: string; + emoji: string; + keywords: string[]; +}; + +type TWeatherRegion = { + regionId: number; + regionName: string; + landRegCode: string; + tempRegCode: string; +}; +export type TGetWeeklyWeatherRecommendationResponse = TCommonResponse<{ + region: TWeatherRegion; + startDate: string; + endDate: string; + dailyRecommendations: TDailyRecommendations[]; + totalDays: number; + message: string; +}>; + +export type TGetPrecipitationRequest = { + startDate: string; + regionId: number; +}; +export type TGetPrecipitationResponse = TCommonResponse<{ + region: TWeatherRegion; + startDate: string; + endDate: string; + dailyPrecipitations: TDailyPrecipitations[]; + totalDays: number; + message: string; +}>; + +type TDailyPrecipitations = { + forecastDate: string; + precipitationProbability: number; +}; diff --git a/src/types/notice/notice.ts b/src/types/notice/notice.ts index a9d41e9..c52ef05 100644 --- a/src/types/notice/notice.ts +++ b/src/types/notice/notice.ts @@ -8,6 +8,12 @@ export type TNoticeItem = { createdAt: string; }; +export type TRequestGetNoticeRequest = { + size?: number; + noticeCategory: 'SERVICE' | 'SYSTEM'; + page: number; +}; + export type TFetchNoticesResponse = TCommonResponse<{ noticeList: TNoticeItem[]; totalPages: number; diff --git a/src/utils/getNextSevenDay.ts b/src/utils/getNextSevenDay.ts new file mode 100644 index 0000000..0566f52 --- /dev/null +++ b/src/utils/getNextSevenDay.ts @@ -0,0 +1,14 @@ +export function getNextSevenDay(startDate: string): string[] { + const result: string[] = []; + const start = new Date(startDate); + + for (let i = 0; i < 7; i++) { + const date = new Date(start); + date.setDate(start.getDate() + i); + const label = date.toLocaleDateString('ko-KR', { month: 'long', day: 'numeric', timeZone: 'Asia/Seoul' }); + + result.push(label); + } + + return result; +} diff --git a/src/utils/getTodayString.ts b/src/utils/getTodayString.ts new file mode 100644 index 0000000..bf9fad0 --- /dev/null +++ b/src/utils/getTodayString.ts @@ -0,0 +1,7 @@ +export function getTodayString(date: Date = new Date()): string { + const today = date; + const yyyy = today.getFullYear(); + const mm = String(today.getMonth() + 1).padStart(2, '0'); + const dd = String(today.getDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} diff --git a/src/utils/normalizeEmojiKey.ts b/src/utils/normalizeEmojiKey.ts new file mode 100644 index 0000000..ce58631 --- /dev/null +++ b/src/utils/normalizeEmojiKey.ts @@ -0,0 +1,9 @@ +export function normalizeEmojiKey(k?: string): string { + if (!k) return ''; + return k + .normalize('NFKD') + .replace(/[\uFE0E\uFE0F\u200D]/g, '') + .replace(/\p{M}/gu, '') + .trim() + .toLowerCase(); +} diff --git a/src/utils/weatherMessage.ts b/src/utils/weatherMessage.ts new file mode 100644 index 0000000..6704e4c --- /dev/null +++ b/src/utils/weatherMessage.ts @@ -0,0 +1,8 @@ +import type { TempCategory, WeatherType } from '@/constants/weather'; +import { TempCategoryLabel, WeatherTypeLabel } from '@/constants/weather'; + +export function getWeatherSentence({ weather, temp }: { weather: WeatherType; temp: TempCategory }): string { + const weatherLabel = WeatherTypeLabel[weather]; + const tempLabel = TempCategoryLabel[temp]; + return [weatherLabel, tempLabel].filter(Boolean).join(' '); +} diff --git a/tsconfig.json b/tsconfig.json index f6df6c7..0d11e84 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,10 @@ { "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } }