diff --git a/package.json b/package.json index f6474bc7..95e080ba 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "axios": "^1.6.2", "date-fns": "^3.1.0", "dotenv": "^16.3.1", + "firebase": "^9.23.0", "framer-motion": "^10.16.16", "immutability-helper": "^3.1.1", "jwt-decode": "^4.0.0", diff --git a/public/firebase-messaging-sw.js b/public/firebase-messaging-sw.js new file mode 100644 index 00000000..16740290 --- /dev/null +++ b/public/firebase-messaging-sw.js @@ -0,0 +1,28 @@ +self.addEventListener('install', function () { + console.log('[FCM SW] 설치중..'); + self.skipWaiting(); +}); + +self.addEventListener('activate', function () { + console.log('[FCM SW] 실행중..'); +}); + +self.addEventListener('push', function (e) { + if (!e.data.json()) return; + + const resultData = e.data.json().notification; + // 필수! 알람 제목 + const notificationTitle = resultData.title; + const notificationOptions = { + //필수! 알람 설명 + body: resultData.body, + //필수! 알람 이미지(프로필, 로고) + icon: resultData.image, + //필수! 알람 분류태그 (전체, 투표, 나가기...) + tag: resultData.tag, + ...resultData, + }; + console.log('출력'); + + self.registration.showNotification(notificationTitle, notificationOptions); +}); diff --git a/src/App.tsx b/src/App.tsx index 8ff9a987..0fa8770b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,10 +4,12 @@ import {CookiesProvider} from 'react-cookie'; import {DndProvider} from 'react-dnd'; import {HTML5Backend} from 'react-dnd-html5-backend'; import {BrowserRouter} from 'react-router-dom'; +import './firebase/messaging-init-in-sw'; import './sass/index.scss'; import MainRouter from './routes/MainRouter/MainRouter'; +window.Kakao.init(import.meta.env.VITE_KAKAO_KEY); const queryClient = new QueryClient(); function App() { diff --git a/src/api/invite.ts b/src/api/invite.ts new file mode 100644 index 00000000..d98e1ba0 --- /dev/null +++ b/src/api/invite.ts @@ -0,0 +1,11 @@ +import axios from 'axios'; + +import {InviteCodeRequestParams} from '@/types/Invitation'; + +export const postJoin = async (spaceId: number) => + await axios.post(`/api/auth/join/spaces/${spaceId}`, {withCredentials: true}); +export const postJoinSpaces = async (params: InviteCodeRequestParams) => { + const {spaceId} = params; + const response = await axios.post(`/api/auth/join/spaces/${spaceId}/code`, {withCredentials: true}); + return response.data.data; +}; diff --git a/src/api/notification.ts b/src/api/notification.ts new file mode 100644 index 00000000..a29c93a6 --- /dev/null +++ b/src/api/notification.ts @@ -0,0 +1,21 @@ +import axios from 'axios'; + +import {Token} from '@/types/notification'; + +export const sendNotificationToken = async (token: Token) => { + try { + const response = await axios.post('/api/notifications/token', token, {withCredentials: true}); + return response.data; + } catch (error) { + console.error('[notification]토큰 전송 요청에 실패했습니다', error); + } +}; + +export const GetNotification = async () => { + try { + const response = await axios.get('/api/notifications'); + return response.data.data; + } catch (error) { + console.log('[notification]알림내용을 가져오지 못했습니다'); + } +}; diff --git a/src/api/spaces.ts b/src/api/spaces.ts new file mode 100644 index 00000000..3b60f1af --- /dev/null +++ b/src/api/spaces.ts @@ -0,0 +1,6 @@ +import axios from 'axios'; + +export const spacesRequest = { + getSpaces: () => axios.get('/api/spaces').then((response) => response.data.data.spaces), + postSpaces: () => axios.post('/api/spaces'), +}; diff --git a/src/api/user.ts b/src/api/user.ts new file mode 100644 index 00000000..d52df65b --- /dev/null +++ b/src/api/user.ts @@ -0,0 +1,5 @@ +import axios from 'axios'; + +export const memberRequest = { + getMyInfo: () => axios.get('/api/members/my-info').then((response) => response.data.data), +}; diff --git a/src/assets/alarm/alarmBlackBell.png b/src/assets/alarm/alarmBlackBell.png new file mode 100644 index 00000000..b02e0d46 Binary files /dev/null and b/src/assets/alarm/alarmBlackBell.png differ diff --git a/src/assets/alarm/alarmWhiteBell.png b/src/assets/alarm/alarmWhiteBell.png new file mode 100644 index 00000000..7b76106f Binary files /dev/null and b/src/assets/alarm/alarmWhiteBell.png differ diff --git a/src/assets/InviteLogo.svg b/src/assets/invite/InviteLogo.svg similarity index 100% rename from src/assets/InviteLogo.svg rename to src/assets/invite/InviteLogo.svg diff --git a/src/assets/inviteKakao.svg b/src/assets/invite/inviteKakao.svg similarity index 100% rename from src/assets/inviteKakao.svg rename to src/assets/invite/inviteKakao.svg diff --git a/src/assets/inviteLink.svg b/src/assets/invite/inviteLink.svg similarity index 100% rename from src/assets/inviteLink.svg rename to src/assets/invite/inviteLink.svg diff --git a/src/components/Alarm/Alarm.tsx b/src/components/Alarm/Alarm.tsx index c7c8d0f3..85c8ccf3 100644 --- a/src/components/Alarm/Alarm.tsx +++ b/src/components/Alarm/Alarm.tsx @@ -1,16 +1,16 @@ -import { Slide } from "@chakra-ui/react"; -import { useEffect, useState } from "react"; +import {Slide} from '@chakra-ui/react'; +import {useEffect, useState} from 'react'; -import styles from "./Alarm.module.scss"; +import styles from './Alarm.module.scss'; -import useLockBodyScroll from "@/hooks/useLockBodyScroll"; +import useLockBodyScroll from '@/hooks/useLockBodyScroll'; -import Back from "@/components/Alarm/Back/Back"; -import TabCapsule from "@/components/Alarm/TabCapsule/TabCapsule"; +import Back from '@/components/Alarm/Back/Back'; +import TabCapsule from '@/components/Alarm/TabCapsule/TabCapsule'; -import { AlarmProps } from "@/types/alarm"; +import {AlarmProps} from '@/types/alarm'; -function Alarm({ isAlarmOpen, alarmClose }: AlarmProps) { +function Alarm({isAlarmOpen, alarmClose}: AlarmProps) { // 알림 스타일링 const [isVisible, setIsVisible] = useState(isAlarmOpen); const [windowWidth, setWindowWidth] = useState(window.innerWidth); @@ -30,34 +30,42 @@ function Alarm({ isAlarmOpen, alarmClose }: AlarmProps) { }; }, [isAlarmOpen]); const containerStyle = { - display: isVisible ? "block" : "none", + display: isVisible ? 'block' : 'none', }; useEffect(() => { const handleResize = () => { setWindowWidth(window.innerWidth); }; - window.addEventListener("resize", handleResize); + window.addEventListener('resize', handleResize); handleResize(); return () => { - window.removeEventListener("resize", handleResize); + window.removeEventListener('resize', handleResize); }; }, []); useLockBodyScroll(isAlarmOpen); + // 새로운 알림 상태관리 + useEffect(() => { + if (isAlarmOpen === true) { + localStorage.removeItem('news'); + console.log('비움'); + } + }, [isAlarmOpen]); + return (
diff --git a/src/components/Alarm/TabCapsule/TabCapsule.tsx b/src/components/Alarm/TabCapsule/TabCapsule.tsx index 7a2bab07..be2feeee 100644 --- a/src/components/Alarm/TabCapsule/TabCapsule.tsx +++ b/src/components/Alarm/TabCapsule/TabCapsule.tsx @@ -1,91 +1,67 @@ -import { - Tab, - TabIndicator, - TabList, - TabPanel, - TabPanels, - Tabs, -} from "@chakra-ui/react"; +import {Tab, TabIndicator, TabList, TabPanel, TabPanels, Tabs} from '@chakra-ui/react'; -import styles from "./TabCapsule.module.scss"; +import styles from './TabCapsule.module.scss'; -import Content from "./Content/Content"; +import Content from './Content/Content'; const AllContents = [ { - url: "https://m.eejmall.com/web/product/big/201708/211_shop1_627935.jpg", + url: 'https://m.eejmall.com/web/product/big/201708/211_shop1_627935.jpg', title: `[강릉 여행]강자밭님이 "밥집 어디갈꺼야" 투표를 만들었어요.`, - time: "2024-01-11T23:09:00", + time: '2024-01-11T23:09:00', }, { - url: "https://private-user-images.githubusercontent.com/120024673/294744934-6e80734e-61fc-46e5-abb4-3d26bad5f1ea.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MDQ2MTQzNDYsIm5iZiI6MTcwNDYxNDA0NiwicGF0aCI6Ii8xMjAwMjQ2NzMvMjk0NzQ0OTM0LTZlODA3MzRlLTYxZmMtNDZlNS1hYmI0LTNkMjZiYWQ1ZjFlYS5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjQwMTA3JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI0MDEwN1QwNzU0MDZaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT0yMGRlZGI3ZjRiZTRkZDY4YTJjNTQxMWNkNmI5YWI5ZDExNzU3OWEwNjJhNTA1NTgzNDQxYWY3MDMyYmVkYmIyJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCZhY3Rvcl9pZD0wJmtleV9pZD0wJnJlcG9faWQ9MCJ9.QMy2zh7OIc5uzN7W7qhAr540FtZN7YbaZ4k0z9Ajmws", - title: "개인정보 보호정책이 변경되었습니다.", - time: "2024-01-11T20:00:00", + url: 'https://github.com/Strong-Potato/TripVote-FE/assets/120024673/089a8673-405e-4a06-b173-4cf3e985b466', + title: '개인정보 보호정책이 변경되었습니다.', + time: '2024-01-11T20:00:00', }, { - url: "https://private-user-images.githubusercontent.com/120024673/294744934-6e80734e-61fc-46e5-abb4-3d26bad5f1ea.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MDQ2MTQzNDYsIm5iZiI6MTcwNDYxNDA0NiwicGF0aCI6Ii8xMjAwMjQ2NzMvMjk0NzQ0OTM0LTZlODA3MzRlLTYxZmMtNDZlNS1hYmI0LTNkMjZiYWQ1ZjFlYS5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjQwMTA3JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI0MDEwN1QwNzU0MDZaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT0yMGRlZGI3ZjRiZTRkZDY4YTJjNTQxMWNkNmI5YWI5ZDExNzU3OWEwNjJhNTA1NTgzNDQxYWY3MDMyYmVkYmIyJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCZhY3Rvcl9pZD0wJmtleV9pZD0wJnJlcG9faWQ9MCJ9.QMy2zh7OIc5uzN7W7qhAr540FtZN7YbaZ4k0z9Ajmws", - title: "개인정보 보호정책이 변경되었습니다.", - time: "2024-01-10T10:00:00", + url: 'https://github.com/Strong-Potato/TripVote-FE/assets/120024673/089a8673-405e-4a06-b173-4cf3e985b466', + title: '개인정보 보호정책이 변경되었습니다.', + time: '2024-01-10T10:00:00', }, { - url: "https://m.eejmall.com/web/product/big/201708/211_shop1_627935.jpg", + url: 'https://m.eejmall.com/web/product/big/201708/211_shop1_627935.jpg', title: `[강릉 여행]강자밭님이 "카페 어디갈꺼야" 투표를 만들었어요.`, - time: "2024-01-03T12:00:00", + time: '2024-01-03T12:00:00', }, { - url: "https://m.eejmall.com/web/product/big/201708/211_shop1_627935.jpg", + url: 'https://m.eejmall.com/web/product/big/201708/211_shop1_627935.jpg', title: `[강릉 여행]강자밭님이 "숙소 어디갈꺼야" 투표를 만들었어요.`, - time: "2023-12-31T12:00:00", + time: '2023-12-31T12:00:00', }, ]; const SpaceTravelContents = [ { - url: "https://m.eejmall.com/web/product/big/201708/211_shop1_627935.jpg", + url: 'https://m.eejmall.com/web/product/big/201708/211_shop1_627935.jpg', title: `[강릉 여행]강자밭님이 "밥집 어디갈꺼야" 투표를 만들었어요.`, - time: "2024-01-11T23:05:00", + time: '2024-01-11T23:05:00', }, { - url: "https://m.eejmall.com/web/product/big/201708/211_shop1_627935.jpg", + url: 'https://m.eejmall.com/web/product/big/201708/211_shop1_627935.jpg', title: `[강릉 여행]강자밭님이 "카페 어디갈꺼야" 투표를 만들었어요.`, - time: "2024-01-03T12:00:00", + time: '2024-01-03T12:00:00', }, { - url: "https://m.eejmall.com/web/product/big/201708/211_shop1_627935.jpg", + url: 'https://m.eejmall.com/web/product/big/201708/211_shop1_627935.jpg', title: `[강릉 여행]강자밭님이 "숙소 어디갈꺼야" 투표를 만들었어요.`, - time: "2023-12-31T12:00:00", + time: '2023-12-31T12:00:00', }, ]; function TabCapsule() { return ( - + - + 전체 - + 여행스페이스 - + diff --git a/src/components/Home/TabBar/TabBar.module.scss b/src/components/Home/TabBar/TabBar.module.scss index f4f0b0be..da8b2013 100644 --- a/src/components/Home/TabBar/TabBar.module.scss +++ b/src/components/Home/TabBar/TabBar.module.scss @@ -1,4 +1,4 @@ -@use "@/sass" as *; +@use '@/sass' as *; .container { position: fixed; @@ -20,5 +20,19 @@ gap: 12px; font-size: 2.4rem; + + &__wrapper { + position: relative; + + &__eclips { + width: 0.8rem; + height: 0.8rem; + border-radius: 50%; + position: absolute; + top: 3px; + right: 3px; + background: $primary400; + } + } } } diff --git a/src/components/Home/TabBar/TabBar.tsx b/src/components/Home/TabBar/TabBar.tsx index c28f647b..1d5e9255 100644 --- a/src/components/Home/TabBar/TabBar.tsx +++ b/src/components/Home/TabBar/TabBar.tsx @@ -1,18 +1,21 @@ -import { AiOutlineBell } from "react-icons/ai"; -import { IoSearchSharp } from "react-icons/io5"; -import { Link } from "react-router-dom"; +import {AiOutlineBell} from 'react-icons/ai'; +import {IoSearchSharp} from 'react-icons/io5'; +import {Link} from 'react-router-dom'; -import styles from "./TabBar.module.scss"; +import styles from './TabBar.module.scss'; function TabBar() { + const news = localStorage.getItem('news'); + return (
- + - + + {news &&
}
diff --git a/src/components/Modal/Invitation/Invitation.tsx b/src/components/Modal/Invitation/Invitation.tsx index 0ffa212f..3b35a229 100644 --- a/src/components/Modal/Invitation/Invitation.tsx +++ b/src/components/Modal/Invitation/Invitation.tsx @@ -1,19 +1,17 @@ -import { useCookies } from "react-cookie"; -import { useNavigate } from "react-router-dom"; +import {useCookies} from 'react-cookie'; +import {useNavigate} from 'react-router-dom'; -import styles from "./Invitation.module.scss"; +import styles from './Invitation.module.scss'; -import { inviteCodeJoin } from "@/hooks/Invite/useInviteCode"; +import {postJoin} from '@/api/invite'; +import {parseInviteCode} from '@/utils/parseInviteCode'; -import { parseInviteCode } from "@/utils/parseInviteCode"; +import {InvitationProps} from '@/types/Invitation'; -import { InvitationProps } from "@/types/Invitation"; - -function Invitation({ inviteCode, isLogin, modal }: InvitationProps) { - const [, , removeCookie] = useCookies(["inviteCode"]); +function Invitation({inviteCode, isLogin, modal}: InvitationProps) { + const [, , removeCookie] = useCookies(['join_space_token']); const navigate = useNavigate(); const parsedInviteCode = parseInviteCode(inviteCode); - console.log(parseInviteCode); const handleModalClick = (e: React.MouseEvent) => { e.stopPropagation(); @@ -25,26 +23,24 @@ function Invitation({ inviteCode, isLogin, modal }: InvitationProps) { }; const handleJoin = async () => { - const response = await inviteCodeJoin(); + const response = await postJoin(parsedInviteCode!.space_id); if (response.status === 200) { - removeCookie("inviteCode", { path: "/" }); - navigate(`/trip/${parsedInviteCode!.ID}`); + removeCookie('join_space_token', {path: '/'}); + navigate(`/trip/${parsedInviteCode!.space_id}`); } else { - removeCookie("inviteCode", { path: "/" }); + removeCookie('join_space_token', {path: '/'}); modal(false); } }; return ( <> - {parsedInviteCode!.RESULT === "false" ? ( + {parsedInviteCode!.exp < Math.floor(new Date().getTime() / 1000) === true ? ( <>
-

- 초대 코드가 만료되었어요. -

+

초대 코드가 만료되었어요.

초대 코드는 7일 간 유효합니다.
@@ -56,7 +52,7 @@ function Invitation({ inviteCode, isLogin, modal }: InvitationProps) { className={styles.wrapperInvalidButton__confirm} onClick={() => { modal(false); - removeCookie("inviteCode", { path: "/" }); + removeCookie('join_space_token', {path: '/'}); }} > 확인 @@ -69,9 +65,7 @@ function Invitation({ inviteCode, isLogin, modal }: InvitationProps) {

-

- {parsedInviteCode!.PUBLISHER} 님이 여행에 초대했어요. -

+

{parsedInviteCode!.iss} 님이 여행에 초대했어요.

초대를 수락하고 함께 즐거운 여행을
@@ -83,15 +77,12 @@ function Invitation({ inviteCode, isLogin, modal }: InvitationProps) { className={styles.wrapperButton__cancel} onClick={() => { modal(false); - removeCookie("inviteCode", { path: "/" }); + removeCookie('join_space_token', {path: '/'}); }} > 취소 -

@@ -113,15 +104,12 @@ function Invitation({ inviteCode, isLogin, modal }: InvitationProps) { className={styles.wrapperButton__cancel} onClick={() => { modal(false); - removeCookie("inviteCode", { path: "/" }); + removeCookie('join_space_token', {path: '/'}); }} > 취소 -
diff --git a/src/components/SideBar/TravelList/TravelList.module.scss b/src/components/SideBar/TravelList/TravelList.module.scss index 9c94acd9..6b761df7 100644 --- a/src/components/SideBar/TravelList/TravelList.module.scss +++ b/src/components/SideBar/TravelList/TravelList.module.scss @@ -1,4 +1,4 @@ -@use "@/sass" as *; +@use '@/sass' as *; .travelSpaceList { display: flex; @@ -14,8 +14,12 @@ padding: 24px 16px; align-items: center; border-radius: 16px; - border: 1px solid $primary200; - background: $primary200; + background: linear-gradient( + 129deg, + #267dff 0.29%, + rgba(98, 170, 255, 0.95) 50.64%, + rgba(142, 255, 144, 0.88) 169.29% + ); &__icon { color: $primary200; @@ -47,9 +51,9 @@ &__item { display: flex; flex-direction: column; + gap: 2px; width: 24.3rem; - height: 7.8rem; - padding: 16px 0px 16px 16px; + padding: 12px 16px; border-radius: 16px; border: 1px solid $primary200; background: $neutral0; @@ -61,9 +65,14 @@ } &__date { + color: $neutral700; + letter-spacing: -0.12px; + @include typography(captionSmall); + } + + &__members { color: $neutral400; - font-style: normal; - letter-spacing: -0.012rem; + letter-spacing: -0.12px; @include typography(captionSmall); } } diff --git a/src/components/SideBar/TravelList/TravelList.tsx b/src/components/SideBar/TravelList/TravelList.tsx index 34aa7f23..01c12d7f 100644 --- a/src/components/SideBar/TravelList/TravelList.tsx +++ b/src/components/SideBar/TravelList/TravelList.tsx @@ -1,25 +1,23 @@ -import { GoPlus } from "react-icons/go"; -import { useNavigate } from "react-router-dom"; +import {GoPlus} from 'react-icons/go'; +import {useNavigate} from 'react-router-dom'; -import styles from "./TravelList.module.scss"; +import styles from './TravelList.module.scss'; -import { useGetSpaces, usePostSpace } from "@/hooks/Spaces/useSpaces"; +import {useGetSpaces, usePostSpace} from '@/hooks/Spaces/useSpaces'; -interface TravelListProps { - isSideOpen: boolean; -} +import {TravelListProp} from '@/types/sidebar'; -function TravelList({ isSideOpen }: TravelListProps) { - const { mutate } = usePostSpace(); +function TravelList({isSideOpen}: TravelListProp) { + const {mutate} = usePostSpace(); const navigate = useNavigate(); - const { data: spaces } = useGetSpaces(isSideOpen); + const {data: spaces} = useGetSpaces(isSideOpen); const handlePostSpace = (e: React.MouseEvent) => { e.preventDefault(); if (spaces!.length >= 15) { - navigate("/mypage?full=true"); + navigate('/user?full=true'); } else { mutate(); } @@ -28,32 +26,22 @@ function TravelList({ isSideOpen }: TravelListProps) { if (!spaces) { return
로딩 중...
; } + return (
-
    {spaces.map((item, index) => (
  • -
  • ))} diff --git a/src/components/TripSpace/FriendList/FriendList.tsx b/src/components/TripSpace/FriendList/FriendList.tsx index 604b2461..843c1de1 100644 --- a/src/components/TripSpace/FriendList/FriendList.tsx +++ b/src/components/TripSpace/FriendList/FriendList.tsx @@ -1,18 +1,16 @@ -import { Avatar } from "@chakra-ui/react"; +import {Avatar} from '@chakra-ui/react'; -import styles from "./FriendList.module.scss"; +import styles from './FriendList.module.scss'; -import { FriendListProps } from "@/types/FriendList"; -function FriendList({ users }: FriendListProps) { +import {FriendListProps} from '@/types/friendList'; +function FriendList({users}: FriendListProps) { return (
    -

    - 여행을 함께하는 친구 {users.length}명 -

    +

    여행을 함께하는 친구 {users.length}명

    {users.map((user, index) => (
    - +

    {user.name}

    ))} diff --git a/src/components/TripSpace/InviteFriends/InviteFriends.tsx b/src/components/TripSpace/InviteFriends/InviteFriends.tsx index 0d6c5c15..b53ae67a 100644 --- a/src/components/TripSpace/InviteFriends/InviteFriends.tsx +++ b/src/components/TripSpace/InviteFriends/InviteFriends.tsx @@ -1,30 +1,28 @@ -import { useEffect, useState } from "react"; +import {useEffect, useState} from 'react'; -import styles from "./InviteFriends.module.scss"; +import styles from './InviteFriends.module.scss'; -import { inviteCodeRequest } from "@/hooks/Invite/useInviteCode"; -import useKakaoShareButton from "@/hooks/useKakaoShareButton"; -import { useGetMyInfo } from "@/hooks/User/useUser"; +import useKakaoShareButton from '@/hooks/useKakaoShareButton'; -import CopyClipboard from "@/components/Modal/CopyClipboard/CopyClipboard"; +import CopyClipboard from '@/components/Modal/CopyClipboard/CopyClipboard'; -import InviteKakao from "@/assets/inviteKakao.svg?react"; -import InviteLink from "@/assets/inviteLink.svg?react"; -import InviteLogo from "@/assets/InviteLogo.svg?react"; +import {postJoinSpaces} from '@/api/invite'; +import InviteKakao from '@/assets/invite/inviteKakao.svg?react'; +import InviteLink from '@/assets/invite/inviteLink.svg?react'; +import InviteLogo from '@/assets/invite/InviteLogo.svg?react'; -import { InviteFriendsProps } from "@/types/inviteFriends"; +import {InviteFriendsProps} from '@/types/inviteFriends'; -function InviteFriends({ isOpen }: InviteFriendsProps) { +function InviteFriends({isOpen}: InviteFriendsProps) { const [isModalVisible, setIsModalVisible] = useState(false); - const [code, setCode] = useState(""); - const spaceId = window.location.pathname.split("/"); - const { data: myInfo } = useGetMyInfo(isOpen); + const [code, setCode] = useState(''); + const spaceId = window.location.pathname.split('/'); useEffect(() => { const fetchInviteCode = async () => { - if (myInfo && isOpen) { + if (isOpen) { try { - const response = await inviteCodeRequest(myInfo.nickname, spaceId[2]); + const response = await postJoinSpaces({spaceId: spaceId[2]}); setCode(response.code); } catch (error) { console.error(error); @@ -33,12 +31,10 @@ function InviteFriends({ isOpen }: InviteFriendsProps) { }; fetchInviteCode(); - }, [myInfo, isOpen]); + }, [isOpen]); const handleCopyClick = (code: string) => () => { - navigator.clipboard.writeText( - `https://api.tripvote.site/members/join/spaces/${code}`, - ); + navigator.clipboard.writeText(`https://api.tripvote.site/auth/join/spaces/${spaceId[2]}/token?&code=${code}`); setIsModalVisible(true); setTimeout(() => { setIsModalVisible(false); @@ -50,9 +46,7 @@ function InviteFriends({ isOpen }: InviteFriendsProps) {
    -

    - 친구를 초대해보세요 -

    +

    친구를 초대해보세요

    함께 여행 갈 친구나 가족을 초대해보세요
    @@ -60,7 +54,7 @@ function InviteFriends({ isOpen }: InviteFriendsProps) {

    - diff --git a/src/firebase/messaging-init-in-sw.ts b/src/firebase/messaging-init-in-sw.ts new file mode 100644 index 00000000..2ceb8a83 --- /dev/null +++ b/src/firebase/messaging-init-in-sw.ts @@ -0,0 +1,47 @@ +import axios from 'axios'; +import {initializeApp} from 'firebase/app'; +import {getMessaging, getToken, onMessage} from 'firebase/messaging'; + +import {sendNotificationToken} from '@/api/notification'; + +export const firebaseConfig = initializeApp({ + apiKey: import.meta.env.VITE_API_KEY, + authDomain: import.meta.env.VITE_AUTH_DOMAIN, + projectId: import.meta.env.VITE_PROJECT_ID, + storageBucket: import.meta.env.VITE_STORAGE_BUCKET, + messagingSenderId: import.meta.env.VITE_MESSAGING_SENDER_ID, + appId: import.meta.env.VITE_APP_ID, + measurementId: import.meta.env.VITE_MEASUREMENT_ID, +}); +export const messaging = getMessaging(firebaseConfig); + +async function requestPermission() { + console.log('[FCM]알림 권한 요청 중...'); + const permission = await Notification.requestPermission(); + if (permission === 'denied') { + console.log('[FCM]알림 권한 허용 안됨'); + return; + } + console.log('[FCM]알림 권한 허용'); + + const token = await getToken(messaging, { + vapidKey: import.meta.env.VITE_VAPID_KEY, + }); + if (token) { + await sendNotificationToken({token}); + await axios.post( + '/api/notifications/subscribe', + { + spaceId: 1, + isGlobal: false, + }, + {withCredentials: true}, + ); + console.log('[FCM]알림 토큰을 전송했습니다'); + } else console.log('[FCM]알림 토큰을 얻지 못했습니다'); + onMessage(messaging, (payload) => { + console.log('푸시 알람 메세지 출력', payload); + }); +} + +requestPermission(); diff --git a/src/firebase/messaging-on-background-message.js b/src/firebase/messaging-on-background-message.js new file mode 100644 index 00000000..e5c74c9e --- /dev/null +++ b/src/firebase/messaging-on-background-message.js @@ -0,0 +1,20 @@ +// import {getMessaging, onBackgroundMessage} from 'firebase/messaging/sw'; + +// import {firebaseConfig} from './messaging-init-in-sw'; + +// const messaging = getMessaging(firebaseConfig); + +// async function background() { +// onBackgroundMessage(messaging, (payload) => { +// console.log('[firebase-messaging-sw.js] Received background message ', payload); +// const notificationTitle = 'Background Message Title'; +// const notificationOptions = { +// body: 'Background Message body.', +// icon: '/firebase-logo.png', +// }; + +// self.registration.showNotification(notificationTitle, notificationOptions); +// }); +// } + +// background(); diff --git a/src/firebase/messaging-receive-message.ts b/src/firebase/messaging-receive-message.ts new file mode 100644 index 00000000..944c768f --- /dev/null +++ b/src/firebase/messaging-receive-message.ts @@ -0,0 +1,11 @@ +// import {getMessaging, onMessage} from 'firebase/messaging'; + +// const messaging = getMessaging(); + +// async function receiveMessage() { +// onMessage(messaging, (payload) => { +// console.log('Message received. ', payload); +// }); +// } + +// receiveMessage(); diff --git a/src/hooks/Invite/useInviteCode.ts b/src/hooks/Invite/useInviteCode.ts index 30e1f615..84ebdb2d 100644 --- a/src/hooks/Invite/useInviteCode.ts +++ b/src/hooks/Invite/useInviteCode.ts @@ -1,14 +1,21 @@ -import axios from "axios"; - -export const inviteCodeRequest = async (nickname: string, spaceId: string) => { - const response = await axios.post("/api/members/join/spaces", { - nickname, - spaceId, - }); - return response.data.data; -}; - -export const inviteCodeJoin = async () => { - const response = await axios.post("/api/members/join"); - return response; -}; +// import {useMutation} from '@tanstack/react-query'; + +// import {postJoin} from '@/api/invite'; + +// const handleMutationError = (error: Error) => { +// console.error('[inviteCode]에러가 발생했습니다', error); +// }; + +// export const useInviteCodeJoin = () => { +// return useMutation({ +// mutationFn: postJoin, +// onError: handleMutationError, +// }); +// }; + +// export const useInviteCodeRequest = ({nickname, spaceId}: InviteCodeRequestParams) => { +// return useMutation({ +// mutationFn: () => postJoinSpaces({nickname, spaceId}), +// onError: handleMutationError, +// }); +// }; diff --git a/src/hooks/Spaces/useSpaces.ts b/src/hooks/Spaces/useSpaces.ts index 13b283c0..0a0c89b2 100644 --- a/src/hooks/Spaces/useSpaces.ts +++ b/src/hooks/Spaces/useSpaces.ts @@ -1,26 +1,12 @@ -import { - useMutation, - useQuery, - useQueryClient, - UseQueryResult, -} from "@tanstack/react-query"; -import axios from "axios"; +import {useMutation, useQuery, useQueryClient, UseQueryResult} from '@tanstack/react-query'; -import { TravelListItem } from "@/types/sidebar"; +import {spacesRequest} from '@/api/spaces'; -const spacesRequest = { - getSpaces: () => - axios - .get("/api/spaces", { params: {} }) - .then((response) => response.data.data), - postSpaces: () => axios.post("/api/spaces"), -}; +import {TravelListItem} from '@/types/sidebar'; -function useGetSpaces( - isSideOpen: boolean, -): UseQueryResult { +function useGetSpaces(isSideOpen: boolean): UseQueryResult { return useQuery({ - queryKey: ["spaces"], + queryKey: ['spaces'], queryFn: spacesRequest.getSpaces, enabled: isSideOpen, }); @@ -31,17 +17,9 @@ function usePostSpace() { return useMutation({ mutationFn: spacesRequest.postSpaces, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["spaces"] }); + queryClient.invalidateQueries({queryKey: ['spaces']}); }, }); } -// function useInviteCode() { -// const queryClient = useQueryClient(); - -// return useMutation({ -// mutationFn: inviteCodeRequest.getInviteCode, -// onSuccess: (data) => {}, -// }); -// } -export { useGetSpaces, usePostSpace }; +export {useGetSpaces, usePostSpace}; diff --git a/src/hooks/User/useUser.ts b/src/hooks/User/useUser.ts index c9f9c997..752be7b4 100644 --- a/src/hooks/User/useUser.ts +++ b/src/hooks/User/useUser.ts @@ -1,18 +1,15 @@ -import { useQuery, UseQueryResult } from "@tanstack/react-query"; -import axios from "axios"; +import {useQuery, UseQueryResult} from '@tanstack/react-query'; -import { User } from "@/types/sidebar"; +import {memberRequest} from '@/api/user'; + +import {User} from '@/types/sidebar'; -const memberRequest = { - getMyInfo: () => - axios.get("/api/members/my-info").then((response) => response.data.data), -}; function useGetMyInfo(isOpen: boolean): UseQueryResult { return useQuery({ - queryKey: ["myInfo"], + queryKey: ['myInfo'], queryFn: memberRequest.getMyInfo, enabled: isOpen, }); } -export { useGetMyInfo }; +export {useGetMyInfo}; diff --git a/src/hooks/useKakaoShareButton.ts b/src/hooks/useKakaoShareButton.ts index a0c95197..1b223a58 100644 --- a/src/hooks/useKakaoShareButton.ts +++ b/src/hooks/useKakaoShareButton.ts @@ -1,26 +1,24 @@ -function useKakaoShareButton(code: string) { +function useKakaoShareButton(code: string, spaceId: string) { const handleKakaoClick = () => { - window.Kakao.init(import.meta.env.VITE_KAKAO_KEY); if (window.Kakao && window.Kakao.isInitialized()) { - window.Kakao.Share.createDefaultButton({ - objectType: "feed", - container: "#kakaoShareButton", + window.Kakao.Share.sendDefault({ + objectType: 'feed', content: { title: `닉네임님의 '여행스페이스' 초대장`, - description: "흰 돛과 일정만 있으면 어디든 갈 수 있어!", + description: '흰 돛과 일정만 있으면 어디든 갈 수 있어!', imageUrl: - "https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmefPK%2FbtsDhzDhoRo%2FjNgK9lkghZsrtwaB510jo1%2Fimg.png", + 'https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmefPK%2FbtsDhzDhoRo%2FjNgK9lkghZsrtwaB510jo1%2Fimg.png', link: { - mobileWebUrl: "https://tripvote.site", - webUrl: "https://tripvote.site", + mobileWebUrl: 'https://tripvote.site', + webUrl: 'https://tripvote.site', }, }, buttons: [ { - title: "같이 여행하기", + title: '같이 여행하기', link: { - mobileWebUrl: `https://api.tripvote.site/members/spaces/join/${code}`, - webUrl: `https://api.tripvote.site/members/spaces/join/${code}`, + mobileWebUrl: `https://api.tripvote.site/auth/join/spaces/${spaceId}/token?&code=${code}`, + webUrl: `https://api.tripvote.site/auth/join/spaces/${spaceId}/token?&code=${code}`, }, }, ], diff --git a/src/mocks/handlers/Invite.ts b/src/mocks/handlers/Invite.ts index 75def02b..fedddb5b 100644 --- a/src/mocks/handlers/Invite.ts +++ b/src/mocks/handlers/Invite.ts @@ -1,37 +1,31 @@ -import { http, HttpResponse } from "msw"; - -import { spaceInfo } from "@/types/sidebar"; +import {http, HttpResponse} from 'msw'; const inviteCode = { status: 200, - message: "members/join/spaces", + message: 'SUCCESS', data: { - code: "qwerasdfqerasdfqewrasdfqwer", + code: 'NkpWoMi2', }, }; export const invite = [ - http.post("/api/members/join/spaces", async ({ request }) => { - const { nickname, spaceId } = (await request.json()) as spaceInfo; + http.post('/api/auth/join/spaces/:spaceId/code', async ({request, cookies}) => { + const url = String(new URL(request.url)); + const spaceId = url.split('/')[7]; + console.log(cookies); - if (nickname && spaceId) { - return HttpResponse.json(inviteCode, { status: 200 }); + if (cookies && spaceId) { + return HttpResponse.json(inviteCode, {status: 200}); } else { - return HttpResponse.json( - { message: "nickname과 spaceId가 필요합니다." }, - { status: 400 }, - ); + return HttpResponse.json({message: 'nickname과 spaceId가 필요합니다.'}, {status: 400}); } }), - http.post("/api/members/join", async ({ cookies }) => { - if (cookies.inviteCode) { - return HttpResponse.json({ message: "가입 완료" }, { status: 200 }); + http.post('/api/auth/join/spaces/:spaceId', async ({cookies}) => { + if (cookies.join_space_token) { + return HttpResponse.json({message: 'SUCCESS'}, {status: 200}); } else { - return HttpResponse.json( - { message: "필요한 쿠키가 없습니다." }, - { status: 400 }, - ); + return HttpResponse.json({message: '필요한 쿠키가 없습니다.'}, {status: 400}); } }), ]; diff --git a/src/mocks/handlers/index.ts b/src/mocks/handlers/index.ts index 2f6f1049..b9bca975 100644 --- a/src/mocks/handlers/index.ts +++ b/src/mocks/handlers/index.ts @@ -1,7 +1,8 @@ -import { auth } from "./auth"; -import { home } from "./home"; -import { invite } from "./Invite"; -import { sidebar } from "./sidebar"; -import { vote } from "./vote"; +import {auth} from './auth'; +import {home} from './home'; +import {invite} from './Invite'; +import {notification} from './notification'; +import {sidebar} from './sidebar'; +import {vote} from './vote'; -export const handlers = [...auth, ...home, ...vote, ...sidebar, ...invite]; +export const handlers = [...auth, ...home, ...vote, ...sidebar, ...invite, ...notification]; diff --git a/src/mocks/handlers/notification.ts b/src/mocks/handlers/notification.ts new file mode 100644 index 00000000..3e5e74d6 --- /dev/null +++ b/src/mocks/handlers/notification.ts @@ -0,0 +1,58 @@ +import {http, HttpResponse} from 'msw'; + +import {Token} from '@/types/notification'; + +const alarmData = { + status: 200, + message: 'SUCCESS', + data: { + elements: [ + { + id: 3, + notificationType: 'NEW_CANDIDATE_ADDED', + memberProfile: { + id: 1, + nickname: '강자밭', + profileImageUrl: 'http://image.com/test', + }, + voteInfo: { + id: 1, + title: '카페 어디갈래', + }, + createdAt: '2024-01-01 14:00:00', // 발급일자(yyyy-MM-dd HH:mm:ss) + }, + { + id: 2, + notificationType: 'MEMBER_EXIT_ENTER', + memberProfile: { + id: 1, + nickname: '강자밭', + profileImageUrl: 'http://image.com/test', + }, + spaceInfo: { + id: 1, + title: '강릉 여행', + }, + createdAt: '2024-01-01 14:00:00 ', + }, + { + id: 1, + notificationType: 'NOTICE', + createdAt: '2024-01-01 14:00:00', + }, + ], + }, +}; + +export const notification = [ + http.post('/api/notifications/token', async ({request}) => { + const {token} = (await request.json()) as Token; + if (token) { + return HttpResponse.json({message: 'SUCCESS'}, {status: 200}); + } + }), + + http.get('/api/notifications', () => { + return HttpResponse.json(alarmData, {status: 200}); + }), +]; diff --git a/src/mocks/handlers/sidebar.ts b/src/mocks/handlers/sidebar.ts index 1d001c8a..081f8900 100644 --- a/src/mocks/handlers/sidebar.ts +++ b/src/mocks/handlers/sidebar.ts @@ -1,230 +1,71 @@ -import { http, HttpResponse } from "msw"; +import {http, HttpResponse} from 'msw'; const mySpaces = { status: 200, - message: "member/my-spaces", - data: [ - { - title: "서울 여행", - startDate: "2024.1.17", - endDate: "2024.1.19", - id: 1234, - members: [ - { - id: 1234, - nickname: "lalala", - profile: "laalal", - }, - ], - }, - { - title: "강릉, 여수, 전주, 부산 여행", - startDate: "2024.1.17", - endDate: "2024.1.19", - id: 1234, - members: [ - { - id: 1234, - nickname: "lalala", - profile: "laalal", - }, - ], - }, - { - title: "여행 스페이스 3 (여행지 미정)", - startDate: "", - endDate: "", - id: "1234", - members: [ - { - id: 1234, - nickname: "lalala", - profile: "laalal", - }, - ], - }, - { - title: "여행 스페이스 4 (여행지 미정)", - startDate: "", - endDate: "", - id: "1234", - members: [ - { - id: 1234, - nickname: "lalala", - profile: "laalal", - }, - ], - }, - { - title: "강릉, 여수, 전주, 여행", - startDate: "2024.1.17", - endDate: "2024.1.19", - id: 1234, - members: [ - { - id: 1234, - nickname: "lalala", - profile: "laalal", - }, - ], - }, - { - title: "여행 스페이스 6 (여행지 미정)", - startDate: "", - endDate: "", - id: 1234, - members: [ - { - id: 1234, - nickname: "lalala", - profile: "laalal", - }, - ], - }, - { - title: "여행 스페이스 7 (여행지 미정)", - startDate: "", - endDate: "", - id: 1234, - members: [ - { - id: 1234, - nickname: "lalala", - profile: "laalal", - }, - ], - }, - { - title: "여행 스페이스 8 (여행지 미정)", - startDate: "2024.1.17", - endDate: "2024.1.19", - id: 1234, - members: [ - { - id: 1234, - nickname: "lalala", - profile: "laalal", - }, - ], - }, - { - title: "여행 스페이스 9 (여행지 미정)", - startDate: "", - endDate: "", - id: 1234, - members: [ - { - id: 1234, - nickname: "lalala", - profile: "laalal", - }, - ], - }, - { - title: "여행 스페이스 10 (여행지 미정)", - startDate: "", - endDate: "", - id: 1234, - members: [ - { - id: 1234, - nickname: "lalala", - profile: "laalal", - }, - ], - }, - { - title: "여행 스페이스 11 (여행지 미정)", - startDate: "", - endDate: "", - id: 1234, - members: [ - { - id: 1234, - nickname: "lalala", - profile: "laalal", - }, - ], - }, - { - title: "여행 스페이스 12 (여행지 미정)", - startDate: "", - endDate: "", - id: 1234, - members: [ - { - id: 1234, - nickname: "lalala", - profile: "laalal", - }, - ], - }, - { - title: "여행 스페이스 13 (여행지 미정)", - startDate: "", - endDate: "", - id: 1234, - members: [ - { - id: 1234, - nickname: "lalala", - profile: "laalal", - }, - ], - }, - { - title: "여행 스페이스 14 (여행지 미정)", - startDate: "", - endDate: "", - id: 1234, - members: [ - { - id: 1234, - nickname: "lalala", - profile: "laalal", - }, - ], - }, - { - title: "여행 스페이스 15 (여행지 미정)", - startDate: "", - endDate: "", - id: 1234, - members: [ - { - id: 1234, - nickname: "lalala", - profile: "laalal", - }, - ], - }, - ], + message: 'SUCCESS', + data: { + spaces: [ + { + id: 1, + title: '여행 스페이스 1 (여행스페이스 미정)', + startDate: '', + endDate: '', + members: '감자깡 외 3명의 여행', + }, + { + id: 2, + title: '강릉, 속초, 전주, 여수 여행', + startDate: '2024.1.17', + endDate: '2024.1.19', + members: '새우깡 외 2명의 여행', + }, + { + id: 3, + title: '강릉, 속초, 전주, 여수 여행', + startDate: '2024.1.17', + endDate: '2024.1.19', + members: '새우깡 외 1명의 여행', + }, + { + id: 4, + title: '강릉, 속초, 전주, 여수 여행', + startDate: '2024.1.17', + endDate: '2024.1.19', + members: '옥수수깡의 여행', + }, + ], + pageNumber: 0, + pageSize: 20, + totalPages: 0, + totalResult: 0, + first: true, + last: true, + }, }; const myInfo = { status: 200, - message: "member/my-info", + message: 'member/my-info', data: { - nickname: "김철수", - profile: "", + nickname: '김철수', + profile: '', }, }; export const sidebar = [ - http.get("/api/spaces", () => { + http.get('/api/spaces', () => { return HttpResponse.json(mySpaces, { status: 200, }); }), - http.post("/api/spaces", () => { + http.post('/api/spaces', () => { return HttpResponse.json({ status: 200, }); }), - http.get("/api/members/my-info", () => { + http.get('/api/members/my-info', () => { return HttpResponse.json(myInfo, { status: 200, }); diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index bf9d0bb6..1297d0a0 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -16,7 +16,7 @@ import Invitation from '@/components/Modal/Invitation/Invitation'; function Home() { const [onboarding, setOnboarding] = useState(true); - const [cookies] = useCookies(['inviteCode', 'isLogin']); + const [cookies] = useCookies(['join_space_token', 'isLogin']); const [modal, setModal] = useState(false); useEffect(() => { @@ -29,10 +29,10 @@ function Home() { useLockBodyScroll(!onboarding); useEffect(() => { - if (cookies.inviteCode) { + if (cookies.join_space_token) { setModal(true); } - }, [cookies.inviteCode]); + }, [cookies.join_space_token]); return (
    @@ -67,7 +67,7 @@ function Home() {
    {!onboarding && } - {modal && } + {modal && }
    ); } diff --git a/src/pages/Trip/Trip.module.scss b/src/pages/Trip/Trip.module.scss index 0705882a..8162e693 100644 --- a/src/pages/Trip/Trip.module.scss +++ b/src/pages/Trip/Trip.module.scss @@ -1,4 +1,4 @@ -@use "@/sass" as *; +@use '@/sass' as *; .container { margin-top: 56px; @@ -37,6 +37,20 @@ position: fixed; top: 0; z-index: 4; + + &__wrapper { + position: relative; + + &__eclips { + width: 0.8rem; + height: 0.8rem; + border-radius: 50%; + position: absolute; + top: 3px; + right: 3px; + background: $primary400; + } + } } .header { diff --git a/src/pages/Trip/Trip.tsx b/src/pages/Trip/Trip.tsx index d65f5316..d2ec8fc0 100644 --- a/src/pages/Trip/Trip.tsx +++ b/src/pages/Trip/Trip.tsx @@ -28,6 +28,7 @@ import VoteTabPanel from '@/components/VoteTabPanel/VoteTabPanel'; import {LatLng} from '@/types/route'; function Trip() { + const news = localStorage.getItem('news'); // 임시 데이터 const users = [ {name: '김철수', src: ''}, @@ -63,8 +64,9 @@ function Trip() { <>
    -