diff --git a/package.json b/package.json index a5dc725..27926ca 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" @@ -18,6 +18,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router": "^7.1.5", + "swiper": "^11.2.4", "tailwind-merge": "^3.0.1", "tailwindcss": "^4.0.6", "zustand": "^5.0.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 329dd62..8732bcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: react-router: specifier: ^7.1.5 version: 7.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + swiper: + specifier: ^11.2.4 + version: 11.2.4 tailwind-merge: specifier: ^3.0.1 version: 3.0.1 @@ -2105,6 +2108,10 @@ packages: svg-parser@2.0.4: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + swiper@11.2.4: + resolution: {integrity: sha512-DTtglrsFfMYytid+oNy4QI3t2N2+XhhwSYbnyOhlwBmvY8Bkoj3ombK1/b80w8vDpQ+Lqlnbm+0737+i32MrcA==} + engines: {node: '>= 4.7.0'} + tailwind-merge@3.0.1: resolution: {integrity: sha512-AvzE8FmSoXC7nC+oU5GlQJbip2UO7tmOhOfQyOmPhrStOGXHU08j8mZEHZ4BmCqY5dWTCo4ClWkNyRNx1wpT0g==} @@ -4304,6 +4311,8 @@ snapshots: svg-parser@2.0.4: {} + swiper@11.2.4: {} + tailwind-merge@3.0.1: {} tailwindcss@4.0.6: {} diff --git a/src/assets/icons/cloud.svg b/src/assets/icons/cloud.svg new file mode 100644 index 0000000..ac0df8d --- /dev/null +++ b/src/assets/icons/cloud.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/color-siren.svg b/src/assets/icons/color-siren.svg new file mode 100644 index 0000000..139be74 --- /dev/null +++ b/src/assets/icons/color-siren.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index e14f8ea..7983ffa 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -1,6 +1,8 @@ import AlarmIcon from './alarm.svg?react'; import ArrowLeftIcon from './arrow-left.svg?react'; import BoardIcon from './board.svg?react'; +import CloudIcon from './cloud.svg?react'; +import ColorSirenIcon from './color-siren.svg?react'; import DeleteIcon from './delete.svg?react'; import EnvelopeIcon from './envelope.svg?react'; import InformationIcon from './infromation.svg?react'; @@ -8,18 +10,28 @@ import LikeFilledIcon from './like-filled.svg?react'; import LikeOutlinedIcon from './like-outlined.svg?react'; import NoticeIcon from './notice.svg?react'; import PersonIcon from './person.svg?react'; +import RestartIcon from './restart.svg'; import SirenFilledIcon from './siren-filled.svg?react'; import SirenOutlinedIcon from './siren-outlined.svg?react'; +import SnowIcon from './snow.svg?react'; +import ThermostatIcon from './thermostat.svg?react'; +import WarmIcon from './warm.svg?react'; export { AlarmIcon, PersonIcon, ArrowLeftIcon, InformationIcon, - SirenFilledIcon, - SirenOutlinedIcon, EnvelopeIcon, BoardIcon, + RestartIcon, + CloudIcon, + SnowIcon, + ThermostatIcon, + WarmIcon, + ColorSirenIcon, + SirenFilledIcon, + SirenOutlinedIcon, NoticeIcon, LikeFilledIcon, LikeOutlinedIcon, diff --git a/src/assets/icons/restart.svg b/src/assets/icons/restart.svg new file mode 100644 index 0000000..b228ae2 --- /dev/null +++ b/src/assets/icons/restart.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/snow.svg b/src/assets/icons/snow.svg new file mode 100644 index 0000000..fe6746b --- /dev/null +++ b/src/assets/icons/snow.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/thermostat.svg b/src/assets/icons/thermostat.svg new file mode 100644 index 0000000..89d2b9e --- /dev/null +++ b/src/assets/icons/thermostat.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/warm.svg b/src/assets/icons/warm.svg new file mode 100644 index 0000000..5070cc4 --- /dev/null +++ b/src/assets/icons/warm.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/closed-letter.png b/src/assets/images/closed-letter.png index 63fea89..d787dca 100644 Binary files a/src/assets/images/closed-letter.png and b/src/assets/images/closed-letter.png differ diff --git a/src/assets/images/envelope-pink-back-top.png b/src/assets/images/envelope-pink-back-top.png new file mode 100644 index 0000000..1188f10 Binary files /dev/null and b/src/assets/images/envelope-pink-back-top.png differ diff --git a/src/assets/images/field-4.png b/src/assets/images/field-4.png index 8844d58..d920f40 100644 Binary files a/src/assets/images/field-4.png and b/src/assets/images/field-4.png differ diff --git a/src/assets/images/opened-letter-front.png b/src/assets/images/opened-letter-front.png index bc39e8d..c326013 100644 Binary files a/src/assets/images/opened-letter-front.png and b/src/assets/images/opened-letter-front.png differ diff --git a/src/pages/LetterDetail/index.tsx b/src/pages/LetterDetail/index.tsx index 98ef5b0..ac0f42c 100644 --- a/src/pages/LetterDetail/index.tsx +++ b/src/pages/LetterDetail/index.tsx @@ -1,5 +1,94 @@ +import { useEffect, useRef, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; + +import { CloudIcon, ColorSirenIcon, SnowIcon, ThermostatIcon, WarmIcon } from '@/assets/icons'; +import ReportModal from '@/components/ReportModal'; + const LetterDetailPage = () => { - return
LetterDetailPage
; + const DUMMY = { + title: '나에게 햄버거 햄부기우기우가 햄북스따스 함부르크 햄버거링고를 대령하거라 ', + text: '이 편지는 영국에서 최초로 시작되어 일년에 한바퀴를 돌면서 받는 사람에게 행운을 주었고 지금은 당신에게로 옮겨진 이 편지는 4일 안에 당신 곁을 떠나야 합니다. 이 편지를 포함해서 7통을 행운이 필요한 사람에게 보내 주셔야 합니다. 복사를 해도 좋습니다. 혹 미신이라 하실지 모르지만 사실입니다.영국에서 HGXWCH이라는 사람은 1930년에 이 편지를 받았습니다. 그는 비서에게 복사해서 보내라고 했습니다. 며칠 뒤에 복권이 당첨되어 20억을 받았습니다. 어떤 이는 이 편지를 받았으나 96시간 이내 자신의 손에서 떠나야 한다는 사실을 잊었습니다. 그는 곧 사직되었습니다. 나중에야 이 사실을 알고 7통의 편지를 보냈는데 다시 좋은 직장을 얻었습니다. 미국의 케네디 대통령은 이 편지를 받았지만 그냥 버렸습니다. 결국 9일 후 그는 암살당했습니다. 기억해 주세요. 이 편지를 보내면 7년의 행운이 있을 것이고 그렇지 않으면 3년의 불행이 있을 것입니다. ', + }; + const FONT = 'kobyo'; + const THEME = 'celebrate'; + const DEGREES = [ + { icon: , title: '따뜻해요' }, + { icon: , title: '그럭저럭' }, + { icon: , title: '앗! 차가워' }, + ]; + const [degreeModalOpen, setDegreeModalOpen] = useState(false); + const [reportModalOpen, setReportModalOpen] = useState(false); + + const degreeButtonRef = useRef(null); + const handleOutsideClick = (event: MouseEvent) => { + const target = event.target as Node; + if (!target || degreeButtonRef.current?.contains(target)) { + return; + } + setDegreeModalOpen(false); + }; + useEffect(() => { + document.body.addEventListener('click', handleOutsideClick); + + return () => { + document.body.removeEventListener('click', handleOutsideClick); + }; + }, []); + return ( + <> + {reportModalOpen && setReportModalOpen(false)} />} +
+
+ + + {degreeModalOpen && ( +
+ {DEGREES.map((degree, idx) => { + return ( + + ); + })} +
+ )} +
+
+ TO. 따숨이 + {DUMMY.title} +
+ + FROM. {'12E12'} + +
+ + ); }; export default LetterDetailPage; diff --git a/src/pages/Onboarding/SetZipCode.tsx b/src/pages/Onboarding/SetZipCode.tsx new file mode 100644 index 0000000..e71b876 --- /dev/null +++ b/src/pages/Onboarding/SetZipCode.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import Spinner from './components/Spinner'; + +const SetZipCode = ({ + setIsZipCodeSet, +}: { + setIsZipCodeSet: React.Dispatch>; +}) => { + const DUMMY_ZIPCODE = '122A2'; + + return ( + <> +
+

우편번호를 설정하고 있습니다.

+

우편번호란?

+

사용자님이 편지를 주고 받는 주소입니다.

+
+
+ {DUMMY_ZIPCODE.split('').map((char, index) => ( + + ))} +
+ + + ); +}; + +export default SetZipCode; diff --git a/src/pages/Onboarding/UserInteraction.tsx b/src/pages/Onboarding/UserInteraction.tsx new file mode 100644 index 0000000..6ab062e --- /dev/null +++ b/src/pages/Onboarding/UserInteraction.tsx @@ -0,0 +1,146 @@ +import envelope from '@/assets/images/closed-letter.png'; +import envelopeFront from '@/assets/images/opened-letter-front.png'; +import envelopeTop from '@/assets/images/envelope-pink-back-top.png'; + +import { useState, useRef, useEffect } from 'react'; +import { twMerge } from 'tailwind-merge'; +import { Link } from 'react-router'; + +export default function UserInteraction() { + const imgRef = useRef(null); + const [imgPos, setImgPos] = useState<{ top: number; width: number }>({ top: 0, width: 0 }); + const [imgToBottom, setImgToBottom] = useState(false); + + const [startAnimation, setStartAnimation] = useState(false); + const [openAnimation, setOpenAnimation] = useState(false); + const [letterOutAnimation, setLetterOutAnimation] = useState(false); + const [envelopeOut, setEnvelopeOut] = useState(false); + const [finishAnimation, setFinishAnimation] = useState(false); + + const handleLetterClick = () => { + if (imgRef.current) { + const rect = imgRef.current.getBoundingClientRect(); + setImgPos({ top: rect.top, width: rect.width }); + } + setStartAnimation(true); + setTimeout(() => { + setImgToBottom(true); + }, 1000); + }; + + useEffect(() => { + if (imgToBottom) { + setTimeout(() => { + setOpenAnimation(true); + }, 1000); + } + }, [imgToBottom]); + + useEffect(() => { + if (openAnimation) { + setTimeout(() => { + setLetterOutAnimation(true); + }, 2000); + } + }, [openAnimation]); + + useEffect(() => { + if (letterOutAnimation) { + setTimeout(() => { + setEnvelopeOut(true); + }, 2000); + } + }, [letterOutAnimation]); + + useEffect(() => { + if (envelopeOut) { + setTimeout(() => { + setFinishAnimation(true); + }, 2000); + } + }, [envelopeOut]); + if (startAnimation === false) { + return ( + <> +
+

이제 편지를 보내러 가볼까요?

+
+
+

편지를 눌러보세요!

+ 분홍색 편지지 +
+ + ); + } else { + return ( + <> + 분홍색 편지지 + {letterOutAnimation && ( +
+ )} + {openAnimation && ( + + )} + 분홍색 편지지 + {/* TODO: 편지지 링크 */} + {finishAnimation && } + + ); + } +} diff --git a/src/pages/Onboarding/components/Spinner.tsx b/src/pages/Onboarding/components/Spinner.tsx new file mode 100644 index 0000000..d100bf4 --- /dev/null +++ b/src/pages/Onboarding/components/Spinner.tsx @@ -0,0 +1,90 @@ +import { useEffect, useRef, useState } from 'react'; +import { ELEMENTS } from '../constants/index'; + +interface SpinnerProps { + target: string; + index: number; +} + +const Spinner = ({ target, index }: SpinnerProps) => { + const newArr = ELEMENTS.filter((item) => item !== target); + const TARGET_ARR = [target, ...newArr.sort(() => Math.random() - 0.5), target]; + const SPEED = 100 + 10 * index; + const [position, setPosition] = useState(0); + const [isRunning, setIsRunning] = useState(true); + let LETTER_HEIGHT = 40; + const animationFrameRef = useRef(null); + + //TODO: 여러 기기에서 실효성 확인 + // 웹에서는 없어도 될 것 같음 + // calculate full height of the cycle + const containerRef = useRef(null); + useEffect(() => { + if (containerRef.current) { + console.log(LETTER_HEIGHT); + const letter = containerRef.current.querySelector('p'); + if (letter) { + LETTER_HEIGHT = letter.getBoundingClientRect().height; + } + } + console.log(LETTER_HEIGHT); + }, []); + const FULL_ROTATION = -TARGET_ARR.length * LETTER_HEIGHT; + + useEffect(() => { + if (!isRunning) return; + + let lastTime = performance.now(); + const frameRate = 1000 / 60; + + const animate = (time: number) => { + const deltaTime = time - lastTime; + if (deltaTime >= frameRate) { + setPosition((prev) => { + let newPos = prev - LETTER_HEIGHT * (deltaTime / SPEED); + + if (newPos < FULL_ROTATION) { + newPos = 0; + setIsRunning(false); + return newPos; + } + return newPos; + }); + lastTime = time; + } + + animationFrameRef.current = requestAnimationFrame(animate); + }; + + animationFrameRef.current = requestAnimationFrame(animate); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [isRunning]); + + return ( +
+
+ {TARGET_ARR.map((item, index) => { + return ( +

+ {item} +

+ ); + })} +
+
+ ); +}; + +export default Spinner; diff --git a/src/pages/Onboarding/constants/index.ts b/src/pages/Onboarding/constants/index.ts new file mode 100644 index 0000000..345f5d1 --- /dev/null +++ b/src/pages/Onboarding/constants/index.ts @@ -0,0 +1,38 @@ +export const ELEMENTS = [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 0, + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', +]; diff --git a/src/pages/Onboarding/index.tsx b/src/pages/Onboarding/index.tsx index 5be8052..1bc4afe 100644 --- a/src/pages/Onboarding/index.tsx +++ b/src/pages/Onboarding/index.tsx @@ -1,5 +1,15 @@ +import { useState } from 'react'; +import SetZipCode from './SetZipCode'; +import UserInteraction from './UserInteraction'; + const OnboardingPage = () => { - return
OnboardingPage
; + const [isZipCodeSet, setIsZipCodeSet] = useState(false); + + return ( +
+ {isZipCodeSet ? : } +
+ ); }; export default OnboardingPage; diff --git a/src/pages/RandomLetters/Matched.tsx b/src/pages/RandomLetters/Matched.tsx new file mode 100644 index 0000000..6d504fc --- /dev/null +++ b/src/pages/RandomLetters/Matched.tsx @@ -0,0 +1,20 @@ +import ResultLetter from '@/components/ResultLetter'; + +export default function Matched() { + return ( +
+
+

답장까지 남은 시간

+

+ {'00'} : {'00'} : {'00'} +

+
+ +
+ +
+
+ ); +} diff --git a/src/pages/RandomLetters/MatchingSelect.tsx b/src/pages/RandomLetters/MatchingSelect.tsx new file mode 100644 index 0000000..1410935 --- /dev/null +++ b/src/pages/RandomLetters/MatchingSelect.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { EffectCards } from 'swiper/modules'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import { twMerge } from 'tailwind-merge'; + +import 'swiper/swiper-bundle.css'; + +import { RestartIcon } from '@/assets/icons'; +import ResultLetter from '@/components/ResultLetter'; + +export default function MatchingSelect({ + setOpenModal, + setSelectedLetter, +}: { + setOpenModal: React.Dispatch>; + setSelectedLetter: React.Dispatch>; +}) { + const [selectedCategory, setSelectedCategory] = useState('전체'); + + const CATEGORY_LIST = ['전체', '위로와 공감', '축하와 응원', '고민 상담', '기타']; + const DUMMY_LIST: { stampName: Stamp; title: string }[] = [ + { stampName: '위로와 공감', title: '위로가 필요해요' }, + { stampName: '축하와 응원', title: '저에게 미움받을 용기를 주세요' }, + { stampName: '고민 상담', title: '삶이 무료해서 고민이에요' }, + { stampName: '기타', title: '어제 꾼 꿈이 신기했어요' }, + { stampName: '고민 상담', title: '삶이 유료해서 고민이에요' }, + { stampName: '축하와 응원', title: '어제 취업했어요!!!!' }, + { stampName: '축하와 응원', title: '어제 게임 신기록 세웠어요!!!!!' }, + { stampName: '기타', title: '기타는 핑거스타일이 멋있는거 같아요' }, + { stampName: '위로와 공감', title: '10년지기 친구가 이사를 가요' }, + { + stampName: '기타', + title: + '햄부기햄북 햄북어 햄북스딱스 함부르크햄부가우가 햄비기햄부거 햄부가티햄부기온앤 온 을 차려오거라.', + }, + ]; + + return ( + <> +
+ +
+ + {DUMMY_LIST.map((list, idx) => { + return ( + +
{ + setOpenModal(true); + setSelectedLetter(() => ({ stampName: list.stampName, title: list.title })); + }} + > + +
+
+ ); + })} +
+
+
+ +
+ {CATEGORY_LIST.map((category, idx) => { + return ( + + ); + })} +
+ + ); +} diff --git a/src/pages/RandomLetters/MatchingSelectModal.tsx b/src/pages/RandomLetters/MatchingSelectModal.tsx new file mode 100644 index 0000000..2fc083d --- /dev/null +++ b/src/pages/RandomLetters/MatchingSelectModal.tsx @@ -0,0 +1,47 @@ +import { useNavigate } from 'react-router'; + +import ModalOverlay from '@/components/ModalOverlay'; +import ResultLetter from '@/components/ResultLetter'; + +function MatchingSelectModal({ + setOpenModal, + selectedLetter, +}: { + setOpenModal: React.Dispatch>; + selectedLetter: SelectedLetter; +}) { + const navigate = useNavigate(); + return ( + +
+
+ 이 편지에 답장 하시겠어요? + 수락한 편지는 5분이 지나면 취소할 수 없습니다. +
+
+ +
+
+ + +
+
+
+ ); +} +export default MatchingSelectModal; diff --git a/src/pages/RandomLetters/index.tsx b/src/pages/RandomLetters/index.tsx index 90088e1..7f32d4f 100644 --- a/src/pages/RandomLetters/index.tsx +++ b/src/pages/RandomLetters/index.tsx @@ -1,5 +1,38 @@ +import { useState } from 'react'; + +import BackgroundBottom from '@/components/BackgroundBottom'; +import PageTitle from '@/components/PageTitle'; + +import Matched from './Matched'; +import MatchingSelect from './MatchingSelect'; +import MatchingSelectModal from './MatchingSelectModal'; + const RandomLettersPage = () => { - return
RandomLettersPage
; + const [openModal, setOpenModal] = useState(false); + const [matched, setMatched] = useState(false); + const [selectedLetter, setSelectedLetter] = useState({ + stampName: '기타', + title: 'error', + }); + + return ( + <> +
+ + {!matched ? '답장하고 싶은 편지를 선택해주세요!' : '이미 답장 중인 편지가 있어요!'} + + {!matched ? ( + + ) : ( + + )} + + {openModal && ( + + )} +
+ + ); }; export default RandomLettersPage; diff --git a/src/pages/Write/CategorySelect.tsx b/src/pages/Write/CategorySelect.tsx index a8a1855..9e80bd2 100644 --- a/src/pages/Write/CategorySelect.tsx +++ b/src/pages/Write/CategorySelect.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { Link } from 'react-router'; +import PageTitle from '@/components/PageTitle'; import useWrite from '@/stores/writeStore'; import CategoryList from './components/CategoryList'; @@ -19,7 +20,7 @@ export default function CategorySelect({ const stamp = useWrite((state) => state.stamp); return ( <> -
+
{!send && !prevLetter && ( - + {send || prevLetter ? '편지 작성이 완료 되었어요!' : '어떤 답장을 받고 싶나요?'} - + {/* 카테고리 선택 컴포넌트 */} {!send && !prevLetter && } @@ -65,13 +66,13 @@ export default function CategorySelect({ {send || prevLetter ? ( 홈으로 돌아가기 ) : (