diff --git a/README.md b/README.md new file mode 100644 index 00000000..3d48a2ef --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +MAKE-A-WISH CLIENT diff --git a/api/auth.ts b/api/auth.ts new file mode 100644 index 00000000..99107af8 --- /dev/null +++ b/api/auth.ts @@ -0,0 +1,17 @@ +import { API_VERSION_01, PATH_AUTH } from './path'; +import { client } from './common/axios'; + +export const postAuthKakao = async (code: string) => { + const data = await client.post( + `${API_VERSION_01}${PATH_AUTH.KAKAO}?redirectUri=${process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI}`, + {}, + { + headers: { + 'Content-Type': 'application/json', + code: `${code}`, + }, + }, + ); + + return data.data.data; +}; diff --git a/api/cakes.ts b/api/cakes.ts new file mode 100644 index 00000000..0aef6936 --- /dev/null +++ b/api/cakes.ts @@ -0,0 +1,35 @@ +import { getAccessToken } from '@/utils/common/token'; +import { client } from './common/axios'; +import { API_VERSION_01, PATH_CAKES } from './path'; + +const ACCESS_TOKEN = getAccessToken(); + +/** + * 해당 소원에 대한 케이크 조회 + */ +export const getCakesInfo = async ( + wishId: string | string[] | undefined, + cakeId: string | string[] | undefined, +) => { + const data = await client.get(`${API_VERSION_01}${PATH_CAKES.GET_CAKES_INFO(wishId, cakeId)}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + }, + }); + + return data.data.data; +}; + +/** + * 해당 소원에 대한 모든 케이크 리스트 결과 조회 + */ +export const getCakesResult = async (wishId: string | string[] | undefined) => { + const data = await client.get(`${API_VERSION_01}${PATH_CAKES.GET_CAKES_RESULT(wishId)}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + }, + }); + return data.data.data; +}; diff --git a/api/common/axios.ts b/api/common/axios.ts new file mode 100644 index 00000000..594e4019 --- /dev/null +++ b/api/common/axios.ts @@ -0,0 +1,44 @@ +import axios from 'axios'; + +//서버통신 함수 +export const client = axios.create({ + baseURL: process.env.NEXT_PUBLIC_BASE_URL, +}); + +client.interceptors.request.use(function (config: any) { + const token = localStorage.getItem('accessToken'); + + if (!token) { + config.headers['accessToken'] = null; + return config; + } + + if (config.headers && token) { + config.headers['Authorization'] = `Bearer ${token}`; + return config; + } + return config; +}); + +client.interceptors.response.use( + function (response) { + return response; + }, + async function (error) { + if (error.response) { + if (error.response.data.message === '유효하지 않은 토큰입니다.') { + // alert('로그인 상태를 확인해주세요!'); + // window.location.replace('/'); + } else if (error.response.data.message === '유효하지 않은 소원 링크입니다.') { + // alert(error.response.data.message); + // window.location.replace('/'); + } else if ( + error.response?.data?.message === '이미 진행 중인 소원 링크가 있습니다.' || + error.response?.data?.message === '주간이 끝난 소원 링크입니다.' + ) { + // alert(error.response?.data?.message); + // window.location.replace('/main'); + } + } + }, +); diff --git a/api/file.ts b/api/file.ts new file mode 100644 index 00000000..f326622a --- /dev/null +++ b/api/file.ts @@ -0,0 +1,25 @@ +import axios from 'axios'; +import { client } from './common/axios'; +import { API_VERSION_01 } from './path'; +import { getAccessToken } from '@/utils/common/token'; + +export const uploadPresignedURL = async (signedURL: string, file: File | Blob | null) => { + const data = await axios.put(signedURL, file, { + headers: { + 'Content-Type': file?.type, + }, + }); + + return data; +}; + +export const getPresignedURL = async (fileName: string | undefined) => { + const data = await client.get(`${API_VERSION_01}/file?fileName=${fileName}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${getAccessToken()}`, + }, + }); + + return data; +}; diff --git a/api/path.ts b/api/path.ts new file mode 100644 index 00000000..6d63b9d2 --- /dev/null +++ b/api/path.ts @@ -0,0 +1,41 @@ +export const API_VERSION_01 = '/api/v1'; + +const PATH = { + auth: '/auth', + user: '/user', + wishes: '/wishes', + public: '/public', + cakes: '/cakes', +}; + +export const PATH_AUTH = { + TOKEN: `${PATH.auth}/token`, + KAKAO: `${PATH.auth}/kakao/callback`, +}; + +export const PATH_USER = { + DEFAULT: PATH.user, + ACCOUNT: `${PATH.user}/account`, + ACCOUNT_VERIFY: `${PATH.user}/verify-account`, + ABUSE: `${PATH.user}/abuse`, +}; + +export const PATH_WISHES = { + DEFAULT: PATH.wishes, + PROGRESS: `${PATH.wishes}/progress`, + GET_SINGLE_WISH_INFO: (wishId: string | string[] | undefined) => `${PATH.wishes}/${wishId}`, + PRESENT_LINK_INFO: `${PATH.wishes}/present/info`, + MAIN: `${PATH.wishes}/main`, +}; + +export const PATH_PUBLIC = { + CAKES: `${PATH.public}/cakes`, + GET_WISHES_INFO: (wishId: string | string[] | undefined) => + `${PATH.public}${PATH.wishes}/${wishId}`, +}; + +export const PATH_CAKES = { + GET_CAKES_RESULT: (wishId: string | string[] | undefined) => `${PATH.cakes}/${wishId}`, + GET_CAKES_INFO: (wishId: string | string[] | undefined, cakeId: string | string[] | undefined) => + `${PATH.cakes}/${wishId}/${cakeId}`, +}; diff --git a/api/public.ts b/api/public.ts new file mode 100644 index 00000000..75382648 --- /dev/null +++ b/api/public.ts @@ -0,0 +1,30 @@ +import { PostPublicCakesResponseType, PublicWishesDataResponseType } from '@/types/api/response'; +import { client } from './common/axios'; +import { API_VERSION_01, PATH_PUBLIC } from './path'; +import { PostPublicCakesRequestType } from '@/types/api/request'; +import { getAccessToken } from '@/utils/common/token'; + +const ACCESS_TOKEN = getAccessToken(); + +export const getPublicWishes = async (wishId: string | string[] | undefined) => { + const data = await client.get( + `${API_VERSION_01}${PATH_PUBLIC.GET_WISHES_INFO(wishId)}`, + ); + + return data.data.data; +}; + +export const postPublicCakes = async (parameter: PostPublicCakesRequestType) => { + const data = await client.post( + `${API_VERSION_01}${PATH_PUBLIC.CAKES}`, + parameter, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + }, + }, + ); + + return data.data.data; +}; diff --git a/api/user.ts b/api/user.ts new file mode 100644 index 00000000..44284bac --- /dev/null +++ b/api/user.ts @@ -0,0 +1,55 @@ +import { getAccessToken } from '@/utils/common/token'; +import { client } from './common/axios'; +import { API_VERSION_01, PATH_USER } from './path'; +import { UseFormReturn } from 'react-hook-form'; + +import { UserAccountDataResponseType } from '@/types/api/response'; +import { WishesDataInputType } from '@/types/wishesType'; + +const ACCESS_TOKEN = getAccessToken(); + +export const patchUserAccount = async ( + methods: UseFormReturn, +) => { + const data = await client.put( + `${API_VERSION_01}${PATH_USER.ACCOUNT}`, + { + accountInfo: { + name: methods.getValues('name'), + bank: methods.getValues('bank'), + account: methods.getValues('account'), + }, + }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + }, + }, + ); + return data; +}; + +export const getUserAccount = async () => { + const data = await client.get( + `${API_VERSION_01}${PATH_USER.ACCOUNT}`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + }, + }, + ); + return data?.data.data; +}; + +export const deleteUserInfo = async () => { + const data = await client.delete(`${API_VERSION_01}${PATH_USER.DEFAULT}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + }, + }); + + return data; +}; diff --git a/api/wishes.ts b/api/wishes.ts new file mode 100644 index 00000000..ed14732d --- /dev/null +++ b/api/wishes.ts @@ -0,0 +1,176 @@ +import { MainProgressDataResponseType, WishesProgressDataResponseType } from '@/types/api/response'; +import { client } from './common/axios'; +import { getAccessToken } from '@/utils/common/token'; +import { API_VERSION_01, PATH_WISHES } from './path'; + +import { UseFormReturn } from 'react-hook-form'; +import { SiteDataType } from '@/types/siteDataType'; +import { WishesDataInputType } from '@/types/wishesType'; + +const ACCESS_TOKEN = getAccessToken(); + +/** + * 진행중인 소원 조회 + */ +export const getMainProgressData = async () => { + const data = await client.get( + `${API_VERSION_01}${PATH_WISHES.MAIN}`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + }, + }, + ); + + return data.data.data; +}; + +/** + * 모든 소원리스트 조회 + */ +export const getWishes = async () => { + const data = await client.get(`${API_VERSION_01}${PATH_WISHES.DEFAULT}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + }, + }); + + return data.data.data.wishes; +}; + +/** + * 소원링크 생성 + */ +export const postWishes = async (methods: UseFormReturn) => { + const data = await client.post( + `${API_VERSION_01}${PATH_WISHES.DEFAULT}`, + { + imageUrl: methods.getValues('imageUrl'), + price: methods.getValues('price'), + title: methods.getValues('title'), + hint: methods.getValues('hint'), + initial: methods.getValues('initial'), + phone: methods.getValues('phone'), + startDate: methods.getValues('startDate'), + endDate: methods.getValues('endDate'), + }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + }, + }, + ); + return data; +}; + +/** + * 소원링크 삭제 + */ +export const deleteWishes = async (wishesData: number[]) => { + const data = await client.delete(`${API_VERSION_01}${PATH_WISHES.DEFAULT}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + }, + data: { + wishes: wishesData, + }, + }); + + return data; +}; + +/** + * 29cm에서 파싱한 데이터 + */ +export const getPresentLinkInfo = async (link: string, siteData: SiteDataType | undefined) => { + const imageTag = + siteData && + (await client.get( + `${API_VERSION_01}${PATH_WISHES.PRESENT_LINK_INFO}?url=${link}&tag=${siteData.IMAGE_TAG}`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + }, + }, + )); + + const priceTag = + siteData && + (await client.get( + `${API_VERSION_01}${PATH_WISHES.PRESENT_LINK_INFO}?url=${link}&tag=${siteData.PRICE_TAG}`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + }, + }, + )); + return { imageTag, priceTag }; +}; + +/** + * 진행중인 소원 정보 조회 + */ +export const getProgressWishInfo = async () => { + const data = await client.get( + `${API_VERSION_01}${PATH_WISHES.PROGRESS}`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + }, + }, + ); + + return data.data.data; +}; + +/** + * 진행중인 소원 정보 수정 + */ +export const patchProgressWishInfo = async ( + methods: UseFormReturn, +) => { + const data = await client.put( + `${API_VERSION_01}${PATH_WISHES.PROGRESS}`, + { + title: methods.getValues('title'), + hint: methods.getValues('hint'), + initial: methods.getValues('initial'), + imageUrl: methods.getValues('imageUrl'), + phone: methods.getValues('phone'), + account: methods.getValues('account'), + bank: methods.getValues('bank'), + name: methods.getValues('name'), + startDate: methods.getValues('startDate'), + endDate: methods.getValues('endDate'), + }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + }, + }, + ); + + return data.data.data; +}; + +/** + * 소원 단건 조회 + */ +export const getSingleWishInfo = async (wishId: string | string[] | undefined) => { + const data = await client.get(`${API_VERSION_01}${PATH_WISHES.GET_SINGLE_WISH_INFO(wishId)}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + }, + }); + + return data.data.data; +}; diff --git a/components/Cakes/CakesForm.tsx b/components/Cakes/CakesForm.tsx new file mode 100644 index 00000000..01dc15fc --- /dev/null +++ b/components/Cakes/CakesForm.tsx @@ -0,0 +1,120 @@ +import TextareaBox from '../Common/Input/TextareaBox'; +import styled from 'styled-components'; +import { LIMIT_TEXT } from '@/constant/limitText'; +import SelectCakes from './SelectCakes'; +import Button from '../Common/Button'; +import Input from '../Common/Input/Input'; +import { UseFormReturn } from 'react-hook-form'; +import InputContainer from '../Common/Input/InputContainer'; +import { CakesDataInputType } from '@/types/common/input/cakesInput'; +import { StyledBox } from '../Common/Box'; +import BackBtn from '../Common/Button/BackBtn'; +import { CakeListType } from '@/types/cakes/cakeListType'; +import { useGetPublicWishes } from '@/hooks/queries/public'; +import { UseMutateFunction } from 'react-query'; +import theme from '@/styles/theme'; + +interface CakesFormProps { + methods: UseFormReturn; + selectedCake: CakeListType; + selectedIndex: number; + selectCake: (index: number) => void; + wishesId: string | string[] | undefined; + postPublicCakesData: UseMutateFunction< + { + cakeId: number; + imageUrl: string; + hint: string; + initial: string; + contribute: string; + wisher: string; + }, + unknown, + void, + unknown + >; +} + +export default function CakesForm(props: CakesFormProps) { + const { methods, selectedCake, selectedIndex, selectCake, wishesId, postPublicCakesData } = props; + + const { publicWishesData } = useGetPublicWishes(wishesId); + + const handleClickFn = () => { + postPublicCakesData(); + }; + + return ( + <> + + + {`D-${publicWishesData?.dayCount}`} + + + {publicWishesData?.title} + + + {publicWishesData?.hint} + + + + + + + + + + + + + + + + + ); +} + +const Styled = { + HeaderWrapper: styled.div` + display: flex; + justify-content: space-between; + + width: 100%; + + color: ${theme.colors.main_blue}; + ${theme.fonts.headline20}; + `, + + Title: styled.h1` + ${theme.fonts.headline24_100}; + color: ${theme.colors.main_blue}; + margin: 2.4rem 0 3rem; + `, + + HintBox: styled(StyledBox)` + width: 100%; + height: 12.6rem; + + ${theme.fonts.body14}; + + padding: 1.2rem 1rem 1.2rem 1.2rem; + `, + + ButtonWrapper: styled.div` + padding-bottom: 4.6rem; + `, +}; diff --git a/components/Cakes/CakesPay.tsx b/components/Cakes/CakesPay.tsx new file mode 100644 index 00000000..7e49f339 --- /dev/null +++ b/components/Cakes/CakesPay.tsx @@ -0,0 +1,150 @@ +import styled from 'styled-components'; +import theme from '@/styles/theme'; +import { BackBtnIc } from '@/public/assets/icons'; +import Image from 'next/image'; +import { CakeListType } from '@/types/cakes/cakeListType'; +import { convertMoneyText } from '@/utils/common/convertMoneyText'; +import PaymentItemSelect from '../Common/Select/PaymentItemSelect'; +import { BANK_LIST } from '@/constant/bankList'; +import { BankListType } from '@/types/bankListType'; +import Button from '../Common/Button'; +import { useState } from 'react'; +import { useGetPublicWishes } from '@/hooks/queries/public'; + +interface CakesPayProps { + handlePrevStep: () => void; + handleNextStep: () => void; + selectedCake: CakeListType; + wishesId: string | string[] | undefined; +} + +export default function CakesPay(props: CakesPayProps) { + const { handlePrevStep, handleNextStep, selectedCake, wishesId } = props; + + const { publicWishesData } = useGetPublicWishes(wishesId); + + const PAYMENTS: BankListType[] = [BANK_LIST[5], BANK_LIST[1]]; + const [selectedPayment, setSelected] = useState(); + + const handlePaymentSelect = (payment: BankListType) => { + setSelected(payment); + }; + + const handleDeepLink = (payment: BankListType | undefined) => { + const ua = navigator.userAgent.toLowerCase(); + + if (!selectedPayment) { + alert('결제수단을 선택해주세요!'); + return; + } + + if (window.confirm(`${payment?.name}(으)로 이동할까요?`)) { + if (payment?.name === '토스뱅크') { + window.open( + ua.indexOf('android') > -1 + ? 'https://play.google.com/store/apps/details?id=viva.republica.toss' + : 'https://apps.apple.com/app/id839333328', + ); + } + + if (payment?.name === '카카오뱅크') { + window.open( + ua.indexOf('android') > -1 + ? 'https://play.google.com/store/apps/details?id=com.kakaobank.channel' + : 'https://apps.apple.com/app/id1258016944', + ); + } + handleNextStep(); + } + }; + + return ( + <> + + 뒤로가기 + + + 주문 확인 내역 + {`${selectedCake.name} ${convertMoneyText( + String(selectedCake.price), + )}원을\n${publicWishesData?.name}님께 보내시겠습니까?`} + + + 케이크 이미지 + + + 결제수단 선택 + + + {PAYMENTS.map((payment: BankListType) => ( + + ))} + + + + + + + + ); +} + +const Styled = { + Header: styled.header` + display: flex; + justify-content: space-between; + align-items: center; + + width: 100%; + height: 3rem; + `, + + Container: styled.section` + width: 100%; + height: 100%; + + margin: 2.4rem 0 2rem; + `, + + TitleText: styled.h1` + ${theme.fonts.headline24_130}; + color: ${theme.colors.main_blue}; + `, + + TextWrapper: styled.div` + ${theme.fonts.headline24_130}; + color: ${theme.colors.black}; + + margin-top: 0.7rem; + + white-space: pre-line; + `, + + ImageWrapper: styled.div` + margin: 1.5rem 0 2rem; + `, + + PaymentWrapper: styled.ul` + display: flex; + flex-direction: column; + + gap: 1.4rem; + + margin-top: 2rem; + `, + + ButtonWrapper: styled.div` + padding-bottom: 4.6rem; + `, +}; diff --git a/components/Cakes/CakesResult.tsx b/components/Cakes/CakesResult.tsx new file mode 100644 index 00000000..61310b83 --- /dev/null +++ b/components/Cakes/CakesResult.tsx @@ -0,0 +1,142 @@ +import theme from '@/styles/theme'; +import styled from 'styled-components'; +import ItemImageBox from '@/components/Common/Box/ItemImageBox'; +import Contribution from './Result/Contribution'; +import { CakeListType } from '@/types/cakes/cakeListType'; +import Image from 'next/image'; +import Button from '../Common/Button'; +import router from 'next/router'; + +interface CakesResultProps { + cakesResultData: + | { + cakeId: number; + imageUrl: string; + hint: string; + initial: string; + contribute: string; + wisher: string; + } + | undefined; + selectedCake: CakeListType; +} + +export default function CakesResult(props: CakesResultProps) { + const { cakesResultData, selectedCake } = props; + + const handleMoveHome = () => { + router.push('/'); + }; + + return ( + <> + + + {cakesResultData?.wisher}님께 + + 케이크 감사 이미지 + + {selectedCake.name} + 선물이 완료 되었어요! + + {cakesResultData?.cakeId === 1 ? ( + <> + + + ~선물 초성힌트~ + {cakesResultData?.initial} + + + 사실 내가 갖고 싶었던 건...비밀이야❤ + + ) : ( + <> + + 사실 내가 갖고 싶었던 건...이거야❤ + + )} + {cakesResultData && ( + + )} + + + + + + ); +} + +const Styled = { + Container: styled.section` + display: flex; + flex-direction: column; + align-items: center; + + margin-top: 5rem; + `, + WishText: styled.div` + color: ${theme.colors.main_blue}; + ${theme.fonts.body16}; + + margin-top: 1.4rem; + `, + + HintWrapper: styled.div` + display: flex; + flex-direction: column; + align-items: center; + + color: ${theme.colors.main_blue}; + ${theme.fonts.body16}; + + margin-top: 1.4rem; + `, + + HintText: styled.div` + ${theme.fonts.headline30}; + margin-top: 3.4rem; + `, + + CakeText: styled.span` + ${theme.fonts.headline30}; + color: ${theme.colors.main_blue}; + `, + + ImageWrapper: styled.div` + margin: 1.3rem 0 2.1rem; + `, + + TextWrapper: styled.div` + display: flex; + flex-direction: column; + align-items: center; + + ${theme.fonts.headline30}; + + margin-bottom: 3.6rem; + `, + + ButtonWrapper: styled.div` + padding-bottom: 4.6rem; + `, + + HintBox: styled.div` + display: flex; + justify-content: center; + + width: 100%; + height: 16rem; + + border-radius: 1.6rem; + + border: 1px solid ${theme.colors.main_blue}; + background-color: ${theme.colors.pastel_blue}; + `, +}; diff --git a/components/Cakes/Result/Contribution.tsx b/components/Cakes/Result/Contribution.tsx new file mode 100644 index 00000000..acf5e1b9 --- /dev/null +++ b/components/Cakes/Result/Contribution.tsx @@ -0,0 +1,62 @@ +import styled, { css } from 'styled-components'; +import ProgressBar from '../../Common/ProgressBar'; +import theme from '@/styles/theme'; + +interface ContributionProps { + percent: number; + vertical: boolean; +} + +export default function Contribution(props: ContributionProps) { + const { percent, vertical } = props; + return ( + + 당신의 기여도는...? + + + + {percent}% + + + ); +} + +const Styled = { + Container: styled.div` + display: flex; + flex-direction: column; + align-items: center; + + margin: 5rem 0 7rem; + `, + + Text: styled.div` + ${theme.fonts.body16}; + color: ${theme.colors.main_blue}; + + margin-bottom: 0.5rem; + `, + + ProgressBox: styled.div` + display: flex; + + width: 100%; + + margin-top: 0.5rem; + `, + + PercentWrapper: styled.div<{ percent: number }>` + width: ${(props) => props.percent}%; + + ${(props) => + props.percent > 3 && + css` + margin-left: -1.7rem; + `} + `, + + Percent: styled.div` + ${theme.fonts.button18}; + color: ${theme.colors.main_blue}; + `, +}; diff --git a/components/Cakes/SelectCakes.tsx b/components/Cakes/SelectCakes.tsx new file mode 100644 index 00000000..67f05f99 --- /dev/null +++ b/components/Cakes/SelectCakes.tsx @@ -0,0 +1,93 @@ +import { CAKE_LIST } from '@/constant/cakeList'; +import { CakeListType } from '@/types/cakes/cakeListType'; +import InputContainer from '../Common/Input/InputContainer'; +import styled from 'styled-components'; +import Image from 'next/image'; +import theme from '@/styles/theme'; +import { convertMoneyText } from '@/utils/common/convertMoneyText'; +import ImageBox from '../Common/Box/ImageBox'; +import { StyledBox } from '../Common/Box'; + +interface SelectCakesProps { + selectedCake: CakeListType; + selectedIndex: number; + selectCake: (index: number) => void; +} + +export default function SelectCakes(props: SelectCakesProps) { + const { selectedCake, selectedIndex, selectCake } = props; + + return ( + + + {CAKE_LIST.map((cake, index) => ( + selectCake(index)} + index={index} + selectedIndex={selectedIndex} + key={cake.name} + > + {`${cake.name}이미지`} + + ))} + + + + 케이크 상세 이미지 + + + + {selectedCake.name} {convertMoneyText(String(selectedCake.price))}원 + + + ); +} + +const Styled = { + CakeWrapper: styled.ul` + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr; + grid-column-gap: 1.2rem; + grid-row-gap: 1rem; + + width: 100%; + + margin-bottom: 1.2rem; + `, + + CakeBox: styled(StyledBox)<{ index: number; selectedIndex: number }>` + width: 7.4rem; + height: 4.6rem; + + display: flex; + justify-content: center; + align-items: center; + + padding: 0.8rem 1.4rem; + background-color: ${(props) => + props.index === props.selectedIndex ? theme.colors.main_blue : theme.colors.pastel_blue}; + border-radius: 0.6rem; + + cursor: pointer; + `, + + CakeInfo: styled.span` + ${theme.fonts.button18}; + color: ${theme.colors.main_blue}; + + display: flex; + justify-content: center; + + margin-top: 1rem; + `, + + CakesImageWrapper: styled.div` + height: 100%; + + display: flex; + justify-content: center; + align-items: center; + `, +}; diff --git a/components/Cakes/index.tsx b/components/Cakes/index.tsx new file mode 100644 index 00000000..f0da3349 --- /dev/null +++ b/components/Cakes/index.tsx @@ -0,0 +1,95 @@ +import useWishesStep from '@/hooks/wishes/useWisehsStep'; +import CakesForm from './CakesForm'; +import styled from 'styled-components'; +import theme from '@/styles/theme'; +import CakesPay from './CakesPay'; +import { useForm } from 'react-hook-form'; +import { CakesDataInputType } from '@/types/common/input/cakesInput'; +import useSelectCakes from '@/hooks/cakes/useSelectCakes'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import CakesResult from './CakesResult'; +import { usePostPublicCakes } from '@/hooks/queries/public'; + +export default function CakesContainer() { + const wishesStep = { ...useWishesStep() }; + const { selectedCake, selectedIndex, selectCake } = useSelectCakes(); + const [wishesId, setWishesId] = useState(''); + + const router = useRouter(); + + const methods = useForm({ + defaultValues: { + giverName: '', + letter: '', + }, + }); + + const { postPublicCakesData, cakesResultData, isSuccess } = usePostPublicCakes({ + name: methods.getValues('giverName'), + wishId: wishesId, + cakeId: selectedCake.cakeNumber, + message: methods.getValues('letter'), + }); + + useEffect(() => { + isSuccess && wishesStep.handleNextStep(); + }, [isSuccess]); + + useEffect(() => { + if (!router.isReady) return; + setWishesId(router.query.id); + }, [router.isReady]); + + return ( + + { + { + 1: ( + + ), + 2: ( + + ), + 3: , + }[wishesStep.stepIndex] + } + + ); +} + +const Styled = { + Container: styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + + height: 100svh; + `, + + Header: styled.header` + display: flex; + justify-content: space-between; + + width: 100%; + + color: ${theme.colors.main_blue}; + ${theme.fonts.headline20}; + `, + + ButtonWrapper: styled.div` + padding-bottom: 4.6rem; + `, +}; diff --git a/components/Common/AlertTextBox.tsx b/components/Common/AlertTextBox.tsx new file mode 100644 index 00000000..39d2612f --- /dev/null +++ b/components/Common/AlertTextBox.tsx @@ -0,0 +1,34 @@ +import { ReactNode } from 'react'; +import { AlertSuccessIc, AlertWarningIc } from '@/public/assets/icons'; +import theme from '@/styles/theme'; +import styled from 'styled-components'; +import Image from 'next/image'; + +interface AlertTextBoxProps { + alertSuccess?: boolean; + children: ReactNode; +} + +export default function AlertTextBox(props: AlertTextBoxProps) { + const { alertSuccess, children } = props; + + return ( + + 알림 아이콘 + {children} + + ); +} + +const Styled = { + Container: styled.div` + display: flex; + align-items: center; + margin-top: 1rem; + `, + Text: styled.span<{ alertSuccess?: boolean }>` + margin-left: 0.6rem; + ${theme.fonts.body12}; + color: ${(props) => (props.alertSuccess ? theme.colors.dark_blue : theme.colors.warning_red)}; + `, +}; diff --git a/components/Common/Box/ImageBox.tsx b/components/Common/Box/ImageBox.tsx new file mode 100644 index 00000000..65a20dd6 --- /dev/null +++ b/components/Common/Box/ImageBox.tsx @@ -0,0 +1,47 @@ +import styled from 'styled-components'; +import { StyledBox } from './index'; +import theme from '@/styles/theme'; +import { ColorSystemType, ImageBoxTypes } from '@/types/common/box/boxStyleType'; +import { PropsWithChildren } from 'react'; + +interface ImageBoxProps { + boxType?: ImageBoxTypes; + colorSystem: ColorSystemType; +} + +export default function ImageBox(props: PropsWithChildren) { + const { boxType, colorSystem, children } = props; + + return {children}; +} + +const StyledImageBox = styled(StyledBox)` + width: 100%; + height: 15rem; + + padding: 1rem 1rem 1rem 1.2rem; + border: 0.1rem solid ${theme.colors.main_blue}; + + //ImageBox Style System + &.imageBox--textarea { + height: 16rem; + + border-radius: 1rem; + padding: 1.4rem 1.2rem; + } + + &.imageBox--image { + position: relative; + + display: flex; + justify-content: center; + align-items: center; + + padding: 0; + + border-radius: 1.6rem; + background-color: ${theme.colors.pastel_blue}; + + overflow: hidden; + } +`; diff --git a/components/Common/Box/InputBox.tsx b/components/Common/Box/InputBox.tsx new file mode 100644 index 00000000..c1f03364 --- /dev/null +++ b/components/Common/Box/InputBox.tsx @@ -0,0 +1,56 @@ +import styled from 'styled-components'; +import { StyledBox } from './index'; +import theme from '@/styles/theme'; +import { ColorSystemType, InputBoxTypes } from '@/types/common/box/boxStyleType'; +import { PropsWithChildren } from 'react'; + +interface InputBoxProps { + width?: string; + boxType: InputBoxTypes; + colorSystem: ColorSystemType; +} + +export default function InputBox(props: PropsWithChildren) { + const { width, boxType, colorSystem, children } = props; + + return ( + + {children} + + ); +} + +const StyledInputBox = styled(StyledBox)<{ width?: string }>` + height: 5rem; + + padding: 1rem 1rem 1rem 1.2rem; + border: 0.1rem solid ${theme.colors.main_blue}; + border-radius: 1rem; + + //InputBox Style System + &.inputBox--half { + width: calc(100% / 2); + } + + &.inputBox--large { + width: 100%; + + display: flex; + align-items: center; + } + + &.inputBox--calendar { + width: 16rem; + + display: flex; + justify-content: space-between; + align-items: center; + + ${theme.fonts.body14}; + } + + &.inputBox--custom { + width: ${(props) => props.width}; + height: 5rem; + } +`; diff --git a/components/Common/Box/ItemImageBox.tsx b/components/Common/Box/ItemImageBox.tsx new file mode 100644 index 00000000..2074ddda --- /dev/null +++ b/components/Common/Box/ItemImageBox.tsx @@ -0,0 +1,17 @@ +import Image from 'next/image'; +import ImageBox from './ImageBox'; + +interface ItemImageBoxProps { + src: string; + alt: string; +} + +export default function ItemImageBox(props: ItemImageBoxProps) { + const { src, alt } = props; + + return ( + + {alt} + + ); +} diff --git a/components/Common/Box/PresentBox.tsx b/components/Common/Box/PresentBox.tsx new file mode 100644 index 00000000..5c50fd16 --- /dev/null +++ b/components/Common/Box/PresentBox.tsx @@ -0,0 +1,23 @@ +import theme from '@/styles/theme'; +import { PropsWithChildren } from 'react'; +import styled from 'styled-components'; + +export default function PresentBox(props: PropsWithChildren) { + const { children } = props; + return {children}; +} + +const Styled = { + Container: styled.div` + display: flex; + justify-content: center; + align-items: center; + + width: 33.1rem; + height: 16rem; + + border: 0.1rem solid ${theme.colors.main_blue}; + background-color: ${theme.colors.white}; + border-radius: 1.6rem; + `, +}; diff --git a/components/Common/Box/index.tsx b/components/Common/Box/index.tsx new file mode 100644 index 00000000..6975afd9 --- /dev/null +++ b/components/Common/Box/index.tsx @@ -0,0 +1,95 @@ +import theme from '@/styles/theme'; +import { BoxTypes, ColorSystemType } from '@/types/common/box/boxStyleType'; +import styled from 'styled-components'; + +interface BoxProps { + width?: number; + boxType?: BoxTypes; + colorSystem: ColorSystemType; + children: React.ReactNode; +} + +export default function Box(props: BoxProps) { + const { boxType, colorSystem, children } = props; + + return {children}; +} + +export const StyledBox = styled.div` + border-radius: 1rem; + + .small { + width: 7.4rem; + height: 4.6rem; + } + + .half { + width: calc(100% / 2); + height: 5rem; + } + + .large { + width: 100%; + height: 5rem; + } + + .image { + width: 100%; + height: 15rem; + } + + // Color System + &.gray1_white { + background-color: ${theme.colors.gray1}; + color: ${theme.colors.white}; + } + + &.mainBlue_white { + background-color: ${theme.colors.main_blue}; + color: ${theme.colors.white}; + } + + &.yellow_black { + background-color: ${theme.colors.yellow}; + color: ${theme.colors.black}; + } + + &.pastelBlue_white { + background-color: ${theme.colors.pastel_blue}; + color: ${theme.colors.white}; + } + + &.pastelBlue_mainBlue { + background-color: ${theme.colors.pastel_blue}; + color: ${theme.colors.main_blue}; + } + + &.pastelBlue_gray2 { + background-color: ${theme.colors.pastel_blue}; + color: ${theme.colors.gray2}; + } + + &.pastelBlue_darkBlue { + background-color: ${theme.colors.pastel_blue}; + color: ${theme.colors.dark_blue}; + } + + &.white_mainBlue { + background-color: ${theme.colors.white}; + color: ${theme.colors.main_blue}; + } + + &.gray1_gray2 { + background-color: ${theme.colors.gray1}; + color: ${theme.colors.gray2}; + } + + &.lightRed_warningRed { + background: rgba(190, 33, 33, 0.1); + color: ${theme.colors.warning_red}; + } +`; + +export const EmptyBox = styled.div` + width: 100%; +`; diff --git a/components/Common/Button/BackBtn.tsx b/components/Common/Button/BackBtn.tsx new file mode 100644 index 00000000..48d96ddd --- /dev/null +++ b/components/Common/Button/BackBtn.tsx @@ -0,0 +1,16 @@ +import { BackBtnIc } from '@/public/assets/icons'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; + +export default function BackBtn() { + const router = useRouter(); + + return ( + 뒤로가기 router.back()} + /> + ); +} diff --git a/components/Common/Button/SiteBox.tsx b/components/Common/Button/SiteBox.tsx new file mode 100644 index 00000000..b74fdfea --- /dev/null +++ b/components/Common/Button/SiteBox.tsx @@ -0,0 +1,23 @@ +import theme from '@/styles/theme'; +import styled from 'styled-components'; + +interface SiteBoxProps { + children: React.ReactNode; +} + +export default function SiteBox(props: SiteBoxProps) { + const { children } = props; + + return {children}; +} + +const Styled = { + Container: styled.button` + display: inline-block; + width: 6rem; + height: 6rem; + background-color: ${theme.colors.white}; + cursor: pointer; + margin: 0 1rem 1rem 0; + `, +}; diff --git a/components/Common/Button/SnsBox.tsx b/components/Common/Button/SnsBox.tsx new file mode 100644 index 00000000..ebd387b2 --- /dev/null +++ b/components/Common/Button/SnsBox.tsx @@ -0,0 +1,19 @@ +import styled from 'styled-components'; + +interface SNSBoxProps { + children: React.ReactNode; + handleClick?: () => void; +} + +export default function SNSBox(props: SNSBoxProps) { + const { children, handleClick } = props; + + return {children}; +} + +const Styled = { + Box: styled.div` + margin: 0 0.5rem 0; + cursor: pointer; + `, +}; diff --git a/components/Common/Button/index.tsx b/components/Common/Button/index.tsx new file mode 100644 index 00000000..d4f1cb9e --- /dev/null +++ b/components/Common/Button/index.tsx @@ -0,0 +1,33 @@ +import { ReactNode } from 'react'; +import { BoxTypes, ColorSystemType } from '@/types/common/box/boxStyleType'; +import styled from 'styled-components'; +import { StyledBox } from '../Box'; +import theme from '@/styles/theme'; + +interface ButtonProps { + boxType: BoxTypes; + colorSystem: ColorSystemType; + handleClickFn?: (parameter?: unknown) => void | unknown; + children: ReactNode; +} + +export default function Button(props: ButtonProps) { + const { boxType, colorSystem, handleClickFn, children } = props; + + return ( + + {children} + + ); +} + +const StyledBtnBox = styled(StyledBox)<{ width?: number }>` + display: flex; + justify-content: center; + align-items: center; + + width: 100%; + height: 5rem; + + ${theme.fonts.button18}; +`; diff --git a/components/Common/Calendar/Calendar.tsx b/components/Common/Calendar/Calendar.tsx new file mode 100644 index 00000000..4fa22a43 --- /dev/null +++ b/components/Common/Calendar/Calendar.tsx @@ -0,0 +1,65 @@ +import Image from 'next/image'; +import { ko } from 'date-fns/locale'; +import styled from 'styled-components'; +import theme from '@/styles/theme'; +import { UseFormReturn } from 'react-hook-form'; +import { WishesDataInputType } from '@/types/wishesType'; +import { CalendarGreyIc, CalendarIc } from '@/public/assets/icons'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import CalendarHeader from './CalendarHeader'; +import { getDate } from '@/utils/common/getDate'; +import InputBox from '../Box/InputBox'; + +interface CalendarProps { + date: Date; + methods: UseFormReturn; + readOnly?: boolean; +} + +export default function Calendar(props: CalendarProps) { + const { date, methods, readOnly } = props; + + const handleChangeDate = (selectedDate: Date) => { + methods.setValue('startDate', selectedDate); + methods.setValue('endDate', getDate(selectedDate, 7)); + }; + + return ( + + + ( + + )} + locale={ko} + dateFormat="yyyy.MM.dd" + selected={new Date(date)} + onChange={handleChangeDate} + minDate={new Date()} + selectsEnd + readOnly={readOnly} + className="react-datepicker__input-container" + /> + {/* */} + + 캘린더 아이콘 + + ); +} + +const Styled = { + Wrapper: styled.div` + display: flex; + justify-content: space-between; + align-items: center; + + width: 100%; + height: 100%; + + ${theme.fonts.body12}; + `, +}; diff --git a/components/Common/Calendar/CalendarHeader.tsx b/components/Common/Calendar/CalendarHeader.tsx new file mode 100644 index 00000000..87c878b9 --- /dev/null +++ b/components/Common/Calendar/CalendarHeader.tsx @@ -0,0 +1,50 @@ +import styled from 'styled-components'; +import { getMonth, getYear } from 'date-fns'; +import { MONTHS } from '@/constant/dateList'; + + +interface CalendarHeaderProps { + date: Date; + changeYear: (year: number) => void; + changeMonth: (month: number) => void; +} + +export default function CalendarHeader(props: CalendarHeaderProps) { + const { date, changeYear, changeMonth } = props; + + // 연도 range + const years = Array.from( + { length: getYear(new Date()) + 6 - getYear(new Date()) }, + (_, index) => getYear(new Date()) + index, + ); + + return ( + + + 년   + + 월 + + ); +} + +const Styled = { + CalendarHeader: styled.div` + width: 100%; + `, +}; diff --git a/components/Common/CheckBox.tsx b/components/Common/CheckBox.tsx new file mode 100644 index 00000000..422e3e4d --- /dev/null +++ b/components/Common/CheckBox.tsx @@ -0,0 +1,41 @@ +import { CheckedBoxIc, UnCheckedBoxIc } from '@/public/assets/icons'; +import theme from '@/styles/theme'; +import Image from 'next/image'; +import { PropsWithChildren } from 'react'; +import styled from 'styled-components'; + +interface CheckBoxProps { + checkBoxState: boolean; + checkBoxText: string; + handleClickFn: () => void; +} + +export default function CheckBox(props: PropsWithChildren) { + const { checkBoxState, checkBoxText, handleClickFn } = props; + return ( + + {checkBoxState ? ( + 체크박스 아이콘 + ) : ( + 체크박스 아이콘 + )} + {checkBoxText} + + ); +} + +const Styled = { + CheckBoxWrapper: styled.div` + display: flex; + align-items: center; + + height: 100%; + `, + + CheckBoxText: styled.p` + color: ${theme.colors.main_blue}; + ${theme.fonts.body14}; + + margin-left: 0.8rem; + `, +}; diff --git a/components/Common/Input/Input.tsx b/components/Common/Input/Input.tsx new file mode 100644 index 00000000..8c0d7f87 --- /dev/null +++ b/components/Common/Input/Input.tsx @@ -0,0 +1,45 @@ +import theme from '@/styles/theme'; +import { WishesDataInputType } from '@/types/wishesType'; +import { PropsWithChildren } from 'react'; +import { FieldError, UseFormRegisterReturn } from 'react-hook-form'; +import styled from 'styled-components'; + +import { InputBoxTypes } from '@/types/common/box/boxStyleType'; +import { CakesDataInputType } from '@/types/common/input/cakesInput'; +import AlertTextBox from '../AlertTextBox'; +import InputBox from '../Box/InputBox'; + +interface InputProps { + width?: string; + boxType?: InputBoxTypes; + placeholder?: string; + readOnly?: boolean; + register: UseFormRegisterReturn; + errors?: FieldError; +} + +export default function Input(props: PropsWithChildren) { + const { width, boxType, placeholder, readOnly, register, errors, children } = props; + + return ( + <> + + + {children} + + {errors?.message && {errors.message}} + + ); +} + +export const StyledInput = styled.input` + width: 100%; + height: 100%; + + ${theme.fonts.body12}; + color: ${theme.colors.dark_blue}; +`; diff --git a/components/Common/Input/InputContainer.tsx b/components/Common/Input/InputContainer.tsx new file mode 100644 index 00000000..b872c1d3 --- /dev/null +++ b/components/Common/Input/InputContainer.tsx @@ -0,0 +1,31 @@ +import { ReactNode } from 'react'; +import styled from 'styled-components'; +import theme from '@/styles/theme'; + +interface InputContainerProps { + title?: string; + children: ReactNode; +} + +export default function InputContainer(props: InputContainerProps) { + const { title, children } = props; + return ( + + {title && {title}} + {children} + + ); +} + +const Styled = { + Container: styled.section` + margin-bottom: 2.4rem; + `, + + InputTitle: styled.h4` + ${theme.fonts.body16}; + color: ${theme.colors.main_blue}; + + margin-bottom: 1.2rem; + `, +}; diff --git a/components/Common/Input/InputLength.tsx b/components/Common/Input/InputLength.tsx new file mode 100644 index 00000000..978d8b16 --- /dev/null +++ b/components/Common/Input/InputLength.tsx @@ -0,0 +1,26 @@ +import theme from '@/styles/theme'; +import styled from 'styled-components'; + +interface InputLengthProps { + inputLength: number; + limitLength: number; +} + +export default function InputLength(props: InputLengthProps) { + const { inputLength, limitLength } = props; + + return ( + + {inputLength}/{limitLength} + + ); +} + +const Styled = { + Container: styled.div` + color: ${theme.colors.gray2}; + ${theme.fonts.body12}; + + padding-left: 1rem; + `, +}; diff --git a/components/Common/Input/InputLink.tsx b/components/Common/Input/InputLink.tsx new file mode 100644 index 00000000..76c9d044 --- /dev/null +++ b/components/Common/Input/InputLink.tsx @@ -0,0 +1,26 @@ +import theme from '@/styles/theme'; +import styled from 'styled-components'; + +interface InputLinkProps { + children: React.ReactNode; +} + +export default function InputLink(props: InputLinkProps) { + const { children } = props; + + return {children}; +} + +const Styled = { + Container: styled.div` + width: 100%; + height: 4.8rem; + + display: flex; + align-items: center; + + padding: 1.4rem 1.2rem; + background-color: ${theme.colors.main_blue}; + border-radius: 1rem; + `, +}; diff --git a/components/Common/Input/TextareaBox.tsx b/components/Common/Input/TextareaBox.tsx new file mode 100644 index 00000000..7f3d16e6 --- /dev/null +++ b/components/Common/Input/TextareaBox.tsx @@ -0,0 +1,46 @@ +import theme from '@/styles/theme'; +import styled from 'styled-components'; +import InputLength from './InputLength'; +import { EmptyBox } from '../Box'; +import { UseFormRegisterReturn } from 'react-hook-form'; +import { WishesDataInputType } from '@/types/wishesType'; +import ImageBox from '../Box/ImageBox'; +import { CakesDataInputType } from '@/types/common/input/cakesInput'; + +interface TextareaBoxProps { + placeholder?: string; + inputLength?: number; + limitLength?: number; + readOnly?: boolean; + register: UseFormRegisterReturn; +} + +export default function TextareaBox(props: TextareaBoxProps) { + const { placeholder, inputLength, limitLength, readOnly, register } = props; + + return ( + + + + + {limitLength && } + + + ); +} + +const Styled = { + InputLengthWrapper: styled.div` + display: flex; + justify-content: space-between; + `, + + Textarea: styled.textarea` + width: 100%; + height: 10.5rem; + + ${theme.fonts.body12}; + + resize: none; + `, +}; diff --git a/components/Common/Loading/Loading.tsx b/components/Common/Loading/Loading.tsx new file mode 100644 index 00000000..55d020d7 --- /dev/null +++ b/components/Common/Loading/Loading.tsx @@ -0,0 +1,30 @@ +import theme from '@/styles/theme'; +import ClipLoader from 'react-spinners/ClipLoader'; +import styled from 'styled-components'; + +export default function Loading() { + + return ( + + + 로딩중입니다 + + ); +} + +const Styled = { + Container: styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + `, + + Title: styled.h1` + ${theme.fonts.headline24_100}; + color: ${theme.colors.main_blue}; + margin: 2.4rem 0 3rem; + `, +}; + diff --git a/components/Common/Modal/BankInput.tsx b/components/Common/Modal/BankInput.tsx new file mode 100644 index 00000000..61b051d3 --- /dev/null +++ b/components/Common/Modal/BankInput.tsx @@ -0,0 +1,95 @@ +import useModal from '@/hooks/common/useModal'; +import Modal from '.'; +import BankModal from './BankModal'; +import styled from 'styled-components'; + +import { UseFormReturn } from 'react-hook-form'; + +import { WishesDataInputType } from '@/types/wishesType'; +import { ArrowDownIc } from '@/public/assets/icons'; +import Image from 'next/image'; + +import theme from '@/styles/theme'; +import Input from '../Input/Input'; +import Button from '../Button'; +import { StyledBox } from '../Box'; +import AlertTextBox from '../AlertTextBox'; + +interface BankInputProps { + methods: UseFormReturn; +} + +export default function BankInput(props: BankInputProps) { + const { methods } = props; + const { isOpen, handleToggle } = useModal(); + + return ( + + + ※ 4회 이상 틀리면, 서비스 이용이 제한됩니다 + + + + + + + + + 더 보기 + + + + + + + + + + {/* 조건 기능 추가 */} + {/* {{true ? '정상 계좌입니다' : '존재하지 않는 계좌번호입니다'}} */} + + + {isOpen && ( + + + + )} + + ); +} + +const Styled = { + Container: styled.div` + margin-bottom: 2.4rem; + `, + + ItemWrapper: styled.div` + margin-top: 1.2rem; + `, + + InputWrapper: styled.div` + display: flex; + justify-content: space-between; + + gap: 0.6rem; + + width: 100%; + `, + + GuideBox: styled(StyledBox)` + width: 100%; + height: 4.4rem; + + padding: 1.2rem; + + ${theme.fonts.body14}; + line-height: 140%; + `, +}; diff --git a/components/Common/Modal/BankModal.tsx b/components/Common/Modal/BankModal.tsx new file mode 100644 index 00000000..c8c1f306 --- /dev/null +++ b/components/Common/Modal/BankModal.tsx @@ -0,0 +1,109 @@ +import styled from 'styled-components'; +import Image from 'next/image'; +import theme from '@/styles/theme'; +import { BANK_LIST } from '@/constant/bankList'; +import { UseFormReturn } from 'react-hook-form'; +import { WishesDataInputType } from '@/types/wishesType'; + +interface BankModalProps { + handleToggle: () => void; + + methods: UseFormReturn; +} + +export default function BankModal(props: BankModalProps) { + const { handleToggle, methods } = props; + + const handleChangeBank = (input: string) => { + methods.setValue('bank', input); + handleToggle(); + }; + + return ( + + 은행을 선택해주세요. + + + {BANK_LIST.map((bank) => ( + handleChangeBank(bank.name)}> + + + {`${bank.name} + + + {bank.name} + + + ))} + + + ); +} + +const Styled = { + Modal: styled.section` + width: 33.1rem; + height: 60rem; + + background-color: ${theme.colors.pastel_blue}; + padding: 2.2rem 2.2rem 0 2.2rem; + border: 0.1rem solid ${theme.colors.main_blue}; + + border-radius: 2rem; + `, + + Title: styled.h2` + width: 100%; + ${theme.fonts.body16}; + `, + + BankContainer: styled.ul` + height: 91.4%; + overflow: auto; + ::-webkit-scrollbar { + display: none; + } + margin: 2rem 0 0; + display: grid; + grid-template-columns: 1fr 1fr 1fr; + grid-column-gap: 0.8rem; + grid-row-gap: 0.8rem; + `, + + BankItem: styled.div` + width: 9rem; + height: 6.6rem; + + padding: 1rem 0 0.5rem; + + background-color: ${theme.colors.white}; + border-radius: 1rem; + + cursor: pointer; + `, + + BanksWrapper: styled.li` + display: flex; + flex-direction: column; + + align-items: center; + `, + + BankName: styled.div` + ${theme.fonts.body12}; + text-align: center; + + margin: 0.5rem 0 0; + `, + + BankLogo: styled.div` + display: flex; + justify-content: center; + align-items: center; + + width: 2.6rem; + height: 2.6rem; + + margin: 0 auto; + `, +}; diff --git a/components/Common/Modal/DeleteModal.tsx b/components/Common/Modal/DeleteModal.tsx new file mode 100644 index 00000000..6753bc4a --- /dev/null +++ b/components/Common/Modal/DeleteModal.tsx @@ -0,0 +1,92 @@ +import styled from 'styled-components'; +import Image from 'next/image'; + +import theme from '@/styles/theme'; +import { CloseSmallIc } from '@/public/assets/icons'; +import { MainCakeImg } from '@/public/assets/images'; +import Button from '../Button'; + +interface DeleteModalProps { + clickModal: () => void; + handleDelete: () => void; + linksCount: number; +} + +export default function DeleteModal(props: DeleteModalProps) { + const { clickModal, handleDelete, linksCount } = props; + + const handleDeleteConfirm = () => { + handleDelete(); + clickModal(); + }; + + return ( + + + 닫기 + + + + {'케이크'} + 총 {linksCount}개의 소원링크를 삭제합니다. + + + + + + + + ); +} + +const Styled = { + Container: styled.div` + width: 31.6rem; + height: 21.2rem; + background-color: ${theme.colors.pastel_blue}; + padding: 2rem; + border-radius: 1.6rem; + margin: 0 1rem 0; + + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + `, + + IconContainer: styled.header` + position: absolute; + top: 20%; + right: 5%; + transform: translate(-50%, -50%); + `, + + ContentContainer: styled.div` + margin: 1.5rem 0 1.5rem; + display: flex; + flex-direction: column; + align-items: center; + `, + + DeleteText: styled.div` + ${theme.fonts.body14}; + color: ${theme.colors.dark_blue}; + margin: 0.7rem 0 1rem; + `, + + ButtonContainer: styled.div` + display: flex; + align-items: center; + & > :not(:last-child) { + margin-right: 1rem; + } + `, +}; diff --git a/components/Common/Modal/GuideModal.tsx b/components/Common/Modal/GuideModal.tsx new file mode 100644 index 00000000..6a9f4305 --- /dev/null +++ b/components/Common/Modal/GuideModal.tsx @@ -0,0 +1,60 @@ +import styled from 'styled-components'; +import Image from 'next/image'; +import { CloseWhiteIc } from '@/public/assets/icons'; +import { GuideContentImg, GuideBoxImg } from '@/public/assets/images'; + +interface GuideModalProps { + handleToggle: () => void; +} + +export default function GuideModal(props: GuideModalProps) { + const { handleToggle } = props; + + return ( + <> + + 닫기 + + + + + 서비스 가이드 설명 + + + + ); +} + +const Styled = { + Content: styled.div` + display: flex; + flex-direction: column; + + width: 33rem; + height: 61.4rem; + + border-radius: 1.6rem; + + padding: 4rem 1.5rem 2rem; + + background-image: url(${GuideBoxImg.src}); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + `, + + ScrollContent: styled.div` + width: 100%; + height: 100%; + + overflow: scroll; + `, + + ButtonContainer: styled.div` + display: flex; + flex-direction: row-reverse; + + position: relative; + margin: 2.3rem 0rem 2.9rem; + `, +}; diff --git a/components/Common/Modal/MainShareModal.tsx b/components/Common/Modal/MainShareModal.tsx new file mode 100644 index 00000000..a018e47f --- /dev/null +++ b/components/Common/Modal/MainShareModal.tsx @@ -0,0 +1,106 @@ +import theme from '@/styles/theme'; +import styled from 'styled-components'; +import ShareContent from './ShareContent'; +import { useGetMainProgressData } from '@/hooks/queries/wishes'; +import Image from 'next/image'; +import { MainCakeImg, MainWishChatImg } from '@/public/assets/images'; +import { CloseWhiteIc } from '@/public/assets/icons'; + +interface MainShareModalProps { + handleToggle: () => void; +} + +export default function MainShareModal(props: MainShareModalProps) { + const { handleToggle } = props; + const { progressData } = useGetMainProgressData(); + + return ( + <> + + 닫기 + + + + {/* {progressData.title} */} + {'화정이의 앙큼 벌스데이'} + 말풍선 + 메인 케이크 이미지 + + {'예상 케이크 금액\n'} + {`총 ${progressData?.price}원`} + + + + {'조물주보다\n생일선물주'} + + + + ); +} + +const Styled = { + ButtonContainer: styled.div` + display: flex; + flex-direction: row-reverse; + + position: relative; + margin: 2.3rem 0rem 2.9rem; + `, + + Container: styled.div` + display: flex; + flex-direction: column; + align-items: center; + + width: 31.6rem; + + background-color: ${theme.colors.pastel_blue}; + + border-radius: 1.6rem; + `, + + IconContainer: styled.div` + position: absolute; + top: 20%; + right: 5%; + transform: translate(-50%, -50%); + `, + + DivisionLine: styled.hr` + width: 31.2rem; + height: 0.1rem; + border-top: 0.3rem dashed ${theme.colors.main_blue}; + + margin: 2.2rem 0 2.7rem; + `, + + Title: styled.h2` + ${theme.fonts.headline20}; + color: ${theme.colors.main_blue}; + + margin: 3rem 0 1.6rem; + `, + + PriceTextWrapper: styled.div` + ${theme.fonts.headline24_130}; + color: ${theme.colors.main_blue}; + + white-space: pre-line; + text-align: center; + + margin-top: -0.4rem; + `, + PriceText: styled.span` + color: ${theme.colors.black}; + `, + + LogoText: styled.span` + ${theme.fonts.headline20}; + color: ${theme.colors.main_blue}; + + line-height: 100%; + + white-space: pre-line; + text-align: center; + `, +}; diff --git a/components/Common/Modal/ShareContent.tsx b/components/Common/Modal/ShareContent.tsx new file mode 100644 index 00000000..a1deae9f --- /dev/null +++ b/components/Common/Modal/ShareContent.tsx @@ -0,0 +1,100 @@ +import { SNS_LIST } from '@/constant/snsList'; +import { LinkCopyIc } from '@/public/assets/icons'; +import theme from '@/styles/theme'; +import Image from 'next/image'; +import styled from 'styled-components'; + +import { useEffect, useState } from 'react'; +import useKakaoShare from '@/hooks/common/useKakaoShare'; +import { useRecoilValue } from 'recoil'; +import { LoginUserInfo } from '@/recoil/auth/loginUserInfo'; +import SNSBox from '../Button/SnsBox'; +import InputLink from '../Input/InputLink'; + +export default function ShareContent() { + const [wishesLink, setWishesLink] = useState(''); + const loginUserInfo = useRecoilValue(LoginUserInfo); + + useEffect(() => { + setWishesLink(`https://sunmulzu.store/wishes/${loginUserInfo.wishesId}`); + }, []); + + const handleShareSNS = (name: string) => { + const link = encodeURIComponent(wishesLink); + const text = encodeURIComponent( + `${loginUserInfo.nickName}님의 생일선물을 고민하고 있다면?\n고민할 필요없이 이 귀여운 케이크를 선물해 ${loginUserInfo.nickName}님의 생일 펀딩에 참여해보세요! \n`, + ); + const hashtag = encodeURIComponent(`#조물주보다생일선물주`); + + if (name === 'KakaoTalk') { + useKakaoShare(loginUserInfo.nickName, wishesLink); + } else if (name === 'FaceBook') { + if (name === 'FaceBook') { + window.open(`http://www.facebook.com/sharer/sharer.php?u=${link}&hashtag=${hashtag}`); + } + } else if (name === 'Twitter') { + window.open(`https://twitter.com/intent/tweet?text=${text + link}`); + } + }; + + const handleTextCopy = async (text: string) => { + const isClipboardSupported = () => navigator?.clipboard != null; + + try { + if (isClipboardSupported()) { + await navigator.clipboard.writeText(text); + } else { + const textArea = document.createElement('textarea'); + textArea.value = text; + document.body.appendChild(textArea); + textArea.select(); + + document.execCommand('copy'); + document.body.removeChild(textArea); + } + alert('링크가 복사되었습니다.'); + } catch (error) { + alert('공유하기가 지원되지 않는 환경입니다.'); + } + }; + + return ( + + + {SNS_LIST.map((sns) => ( + handleShareSNS(sns.name)}> + {`${sns.name}`} + + ))} + + + + + 링크 복사 handleTextCopy(wishesLink)} /> + + + ); +} + +const Styled = { + ContentWrapper: styled.div` + width: 100%; + height: 100%; + + padding: 2.2rem 1.5rem 1.6rem; + `, + + SNSContainer: styled.div` + margin: 0 0 1.5rem; + display: flex; + justify-content: center; + `, + + InputText: styled.input` + ${theme.fonts.body12}; + color: ${theme.colors.white}; + width: 100%; + `, + + +}; diff --git a/components/Common/Modal/ShareModal.tsx b/components/Common/Modal/ShareModal.tsx new file mode 100644 index 00000000..9bf58fb1 --- /dev/null +++ b/components/Common/Modal/ShareModal.tsx @@ -0,0 +1,45 @@ +import styled from 'styled-components'; +import Image from 'next/image'; +import theme from '@/styles/theme'; +import { CloseSmallIc } from '@/public/assets/icons'; +import ShareContent from './ShareContent'; + +interface ShareModalProps { + handleToggle: () => void; +} + +export default function ShareModal(props: ShareModalProps) { + const { handleToggle } = props; + + return ( + + + 닫기 + + + + + ); +} + +const Styled = { + Container: styled.div` + width: 31.6rem; + height: 14.3rem; + background-color: ${theme.colors.pastel_blue}; + padding: 2.2rem 1.5rem 1.6rem; + border-radius: 1.6rem; + + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + `, + + IconContainer: styled.div` + position: absolute; + top: 20%; + right: 5%; + transform: translate(-50%, -50%); + `, +}; diff --git a/components/Common/Modal/index.tsx b/components/Common/Modal/index.tsx new file mode 100644 index 00000000..f7968f63 --- /dev/null +++ b/components/Common/Modal/index.tsx @@ -0,0 +1,45 @@ +import { PropsWithChildren } from 'react'; +import styled from 'styled-components'; + +interface ModalProps { + isOpen: boolean; + handleToggle: () => void; + bgNone?: boolean; +} + +export default function Modal(props: PropsWithChildren) { + const { isOpen, handleToggle, bgNone, children } = props; + return ( + <> + {isOpen && ( + + e.stopPropagation()}> + {children} + + + )} + + ); +} + +const Styled = { + ModalOverlay: styled.div<{ bgNone?: boolean }>` + display: flex; + justify-content: center; + align-items: center; + + z-index: 9999; + width: 100%; + height: 100svh; + + position: fixed; + top: 0; + left: 0; + background: ${(props) => (props.bgNone ? 'transparent' : 'rgba(0, 0, 0, 0.7)')}; + display: flex; + justify-content: center; + align-items: center; + `, + + ModalContainer: styled.div``, +}; diff --git a/components/Common/ProgressBar.tsx b/components/Common/ProgressBar.tsx new file mode 100644 index 00000000..6504d737 --- /dev/null +++ b/components/Common/ProgressBar.tsx @@ -0,0 +1,52 @@ +import theme from '@/styles/theme'; +import styled, { css } from 'styled-components'; + +interface ProgressBarProps { + percent: number; + vertical: boolean; +} + +export default function ProgressBar(props: ProgressBarProps) { + const { percent, vertical } = props; + + return ( + <> + + + + + ); +} + +const Styled = { + Container: styled.div<{ vertical: boolean }>` + width: 27rem; + height: 1rem; + + background-color: ${theme.colors.pastel_blue}; + + border-bottom-right-radius: 5rem; + border-bottom-left-radius: 5rem; + border-top-right-radius: 5rem; + border-top-left-radius: 5rem; + + ${(props) => + props.vertical && + css` + -ms-transform: rotate(-90deg); /* IE 9 */ + -webkit-transform: rotate(-90deg); /* Chrome, Safari, Opera */ + transform: rotate(-90deg); + `} + `, + Progress: styled.div<{ percent: number }>` + width: ${(props) => (props.percent > 100 ? 100 : props.percent)}%; + height: 100%; + + background-color: ${theme.colors.main_blue}; + + border-bottom-right-radius: 5rem; + border-bottom-left-radius: 5rem; + border-top-right-radius: 5rem; + border-top-left-radius: 5rem; + `, +}; diff --git a/components/Common/Select/PaymentItemSelect.tsx b/components/Common/Select/PaymentItemSelect.tsx new file mode 100644 index 00000000..3dd8aafc --- /dev/null +++ b/components/Common/Select/PaymentItemSelect.tsx @@ -0,0 +1,91 @@ +import styled from 'styled-components'; +import { StyledBox } from '../Box'; +import theme from '@/styles/theme'; +import Image from 'next/image'; +import { BankListType } from '@/types/bankListType'; + +interface PaymentItemSelectProps { + payment: BankListType; + handleClickFn: (payment: BankListType) => void; + selectedPayment: BankListType | undefined; +} + +export default function PaymentItemSelect(props: PaymentItemSelectProps) { + const { payment, handleClickFn, selectedPayment } = props; + + const isSelected = (): boolean => { + return payment === selectedPayment; + }; + + return ( + + handleClickFn(payment)} isSelected={isSelected()}> + {isSelected() && } + + + + + 결제 수단 이미지 + + {payment.name} + + + ); +} + +const Styled = { + PaymentItem: styled(StyledBox)` + display: flex; + align-items: center; + gap: 0.8rem; + + width: 100%; + height: 6rem; + + background-color: ${theme.colors.pastel_blue}; + + border-radius: 1rem; + padding: 1rem; + `, + + SelectIcon: styled.div<{ isSelected: boolean }>` + display: flex; + justify-content: center; + align-items: center; + + width: 1.6rem; + height: 1.6rem; + + border-radius: 50%; + background-color: ${theme.colors.white}; + border: 1px solid ${(props) => (props.isSelected ? theme.colors.main_blue : theme.colors.gray2)}; + `, + + SelectInnerIcon: styled.div` + width: 0.9rem; + height: 0.9rem; + + border-radius: 50%; + background-color: ${theme.colors.main_blue}; + `, + + BankItem: styled.div` + width: 3rem; + height: 3rem; + + background-color: ${theme.colors.white}; + border-radius: 0.4rem; + + overflow: hidden; + `, + + BankItemWrapper: styled.div` + display: flex; + align-items: center; + + gap: 1.2rem; + + ${theme.fonts.body14}; + color: ${theme.colors.dark_blue}; + `, +}; diff --git a/components/Common/Select/index.tsx b/components/Common/Select/index.tsx new file mode 100644 index 00000000..9aeca538 --- /dev/null +++ b/components/Common/Select/index.tsx @@ -0,0 +1,3 @@ +export default function Select() { + return <>; +} diff --git a/components/Common/UploadTypeToggleBtn.tsx b/components/Common/UploadTypeToggleBtn.tsx new file mode 100644 index 00000000..7e7bdd00 --- /dev/null +++ b/components/Common/UploadTypeToggleBtn.tsx @@ -0,0 +1,64 @@ +import theme, { ColorsTypes } from '@/styles/theme'; +import styled from 'styled-components'; + +interface UploadTypeToggleBtnProps { + isLinkLoadType: boolean; + handleLoadTypeToggle: (state: boolean) => void; +} + +export default function UploadTypeToggleBtn(props: UploadTypeToggleBtnProps) { + const { isLinkLoadType, handleLoadTypeToggle } = props; + + return ( + + handleLoadTypeToggle(true)} + fontColor={isLinkLoadType ? 'white' : 'main_blue'} + bgColor={isLinkLoadType ? 'main_blue' : 'pastel_blue'} + > + 선물 링크 불러오기 + + handleLoadTypeToggle(false)} + fontColor={isLinkLoadType ? 'main_blue' : 'white'} + bgColor={isLinkLoadType ? 'pastel_blue' : 'main_blue'} + > + 선물 직접 등록하기 + + + ); +} + +const Styled = { + ButtonContainer: styled.div` + display: flex; + justify-content: space-between; + align-items: center; + + width: 100%; + height: 5.8rem; + + margin-bottom: 2rem; + + border-radius: 4.9rem; + background-color: ${theme.colors.pastel_blue}; + padding: 0.5rem; + `, + + ToggleButton: styled.div<{ fontColor: keyof ColorsTypes; bgColor: keyof ColorsTypes }>` + display: flex; + justify-content: center; + align-items: center; + + ${theme.fonts.body16}; + color: ${(props) => theme.colors[props.fontColor]}; + + width: calc(100% / 2); + height: 4.8rem; + + border-radius: 4rem; + background-color: ${(props) => theme.colors[props.bgColor]}; + + cursor: pointer; + `, +}; diff --git a/components/Common/VerticalProgressBar.tsx b/components/Common/VerticalProgressBar.tsx new file mode 100644 index 00000000..3c7d1f01 --- /dev/null +++ b/components/Common/VerticalProgressBar.tsx @@ -0,0 +1,78 @@ +import theme from '@/styles/theme'; +import styled, { css } from 'styled-components'; + +interface ProgressBarProps { + percent: number | undefined; +} + +export default function VerticalProgressBar(props: ProgressBarProps) { + const { percent } = props; + + return ( + <> + + {percent}% + + + + + + + + ); +} + +const Styled = { + ProgressBox: styled.div` + height: 27rem; + display: flex; + flex-direction: column; + justify-content: right; + `, + + PercentWrapper: styled.div<{ percent: number }>` + height: ${(props) => props.percent}%; + + ${(props) => + props.percent > 3 && + css` + margin-top: -2rem; + `} + `, + + Percent: styled.div` + ${theme.fonts.button16}; + color: ${theme.colors.main_blue}; + margin-top: auto; + margin-right: 0.5rem; + `, + + BarContainer: styled.div` + width: 1rem; + height: 27rem; + + background-color: ${theme.colors.pastel_blue}; + + border-bottom-right-radius: 5rem; + border-bottom-left-radius: 5rem; + border-top-right-radius: 5rem; + border-top-left-radius: 5rem; + + -ms-transform: rotate(180deg); /* IE 9 */ + -webkit-transform: rotate(180deg); /* Chrome, Safari, Opera */ + transform: rotate(180deg); + `, + + Progress: styled.div<{ percent: number }>` + height: ${(props) => props.percent}%; + max-height: 100%; + width: 100%; + + background-color: ${theme.colors.main_blue}; + + border-bottom-right-radius: 5rem; + border-bottom-left-radius: 5rem; + border-top-right-radius: 5rem; + border-top-left-radius: 5rem; + `, +}; diff --git a/components/Common/mainHeader.tsx b/components/Common/mainHeader.tsx new file mode 100644 index 00000000..3613b52c --- /dev/null +++ b/components/Common/mainHeader.tsx @@ -0,0 +1,33 @@ +import styled from 'styled-components'; + +interface MainHeaderProps { + title: React.ReactNode; + side?: React.ReactNode; + children?: React.ReactNode; +} + +export default function MainHeader(props: MainHeaderProps) { + const { title, side } = props; + + + return ( + <> + + {title} + {side} + + + + ); +} + +const Styled = { + Container: styled.div` + display: flex; + margin: 2rem 0 0rem; + `, + + SideContainer: styled.div` + margin-left: auto; +`, +}; diff --git a/components/Common/mainView.tsx b/components/Common/mainView.tsx new file mode 100644 index 00000000..639a4b3e --- /dev/null +++ b/components/Common/mainView.tsx @@ -0,0 +1,89 @@ +import styled from 'styled-components'; +import Image from 'next/image'; +import theme from '@/styles/theme'; +import { KakaoLoginIc, WideArrowDownIc } from '@/public/assets/icons'; +import { MainLoginImg } from '@/public/assets/images'; +import GuideModal from '@/components/Common/Modal/GuideModal'; +import Modal from '@/components/Common/Modal'; +import useModal from '@/hooks/common/useModal'; + +interface MainViewProps { + text: string; +} + +export default function MainView(props: MainViewProps) { + const { text } = props; + const { isOpen, handleToggle } = useModal(); + + return ( + <> + {isOpen && ( + + + + )} + + + + 조물주보다 +
+ 생일선물주 +
+ + 사용 설명 케이크 이미지 +
+ {text} + + + + ); +} + +const Styled = { + Title: styled.div` + ${theme.fonts.title56}; + color: ${theme.colors.main_blue}; + margin-bottom: 2.8rem; + display: flex; + justify-content: center; + white-space: pre-line; + `, + + ImageContainer: styled.div` + display: flex; + flex-direction: column; + align-items: center; + + width: 100%; + + margin-top: 8.1rem; + `, + + About: styled.div` + display: flex; + justify-content: center; + + margin: 1rem 0 2.1rem; + + ${theme.fonts.headline24_100}; + color: ${theme.colors.main_blue}; + + text-align: center; + white-space: pre-line; + `, + + WideArrowDownIcon: styled((props) => 아래화살표)` + margin-bottom: 2.4rem; + `, + + KakaoLoginIcon: styled((props) => ( + 카카오로그인아이콘 + ))` + margin-right: 1.3rem; + `, +}; diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 00000000..fa21afdf --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,33 @@ +import { ReactNode } from 'react'; +import styled from 'styled-components'; +import BackBtn from './Common/Button/BackBtn'; + +interface HeaderProps { + width: string; + children?: ReactNode; +} + +export default function Header(props: HeaderProps) { + const { width, children } = props; + + return ( + + + {children} + + ); +} + +const Styled = { + Container: styled.header<{ width: string }>` + display: flex; + justify-content: space-between; + align-items: center; + + width: ${(props) => props.width}; + height: 3rem; + + padding: 1rem 1.5rem; + margin-top: 1.6rem; + `, +}; diff --git a/components/Layout/EmptyLayout.tsx b/components/Layout/EmptyLayout.tsx new file mode 100644 index 00000000..c1d11a63 --- /dev/null +++ b/components/Layout/EmptyLayout.tsx @@ -0,0 +1,14 @@ +import { PropsWithChildren } from 'react'; +import styled from 'styled-components'; + +export default function EmptyLayout(props: PropsWithChildren) { + const { children } = props; + return {children}; +} + +const Container = styled.main` + width: 37.5rem; + height: 100%; + + padding: 2.2rem 2.2rem 0 2.2rem; +`; diff --git a/components/Layout/HeaderLayout.tsx b/components/Layout/HeaderLayout.tsx new file mode 100644 index 00000000..54e840d6 --- /dev/null +++ b/components/Layout/HeaderLayout.tsx @@ -0,0 +1,23 @@ +import { PropsWithChildren } from 'react'; + +import styled from 'styled-components'; +import Header from '../Header'; + +export default function HeaderLayout(props: PropsWithChildren) { + const { children } = props; + const WIDTH = '37.5rem'; + + return ( + <> +
+ {children} + + ); +} + +const Container = styled.main<{ width: string }>` + width: ${(props) => props.width}; + height: 100%; + + padding: 0 2.2rem; +`; diff --git a/components/Layout/index.tsx b/components/Layout/index.tsx new file mode 100644 index 00000000..7c5523b3 --- /dev/null +++ b/components/Layout/index.tsx @@ -0,0 +1,20 @@ +import { PropsWithChildren } from 'react'; +import EmptyLayout from './EmptyLayout'; +import HeaderLayout from './HeaderLayout'; + +const layouts = { + header: HeaderLayout, + empty: EmptyLayout, +}; + +interface LayoutProps { + layoutKey: keyof typeof layouts; +} + +export default function Layout(props: PropsWithChildren) { + const { layoutKey, children } = props; + + const LayoutContainer = layouts[layoutKey]; + + return {children}; +} diff --git a/components/Login/Rredirect.tsx b/components/Login/Rredirect.tsx new file mode 100644 index 00000000..dea878e5 --- /dev/null +++ b/components/Login/Rredirect.tsx @@ -0,0 +1,18 @@ +import { useRouter } from 'next/router'; +import { useEffect } from 'react'; +import Loading from '../Common/Loading/Loading'; +import { useAuthKakao } from '@/hooks/queries/auth'; + +export default function Redirect() { + const router = useRouter(); + + const { accessToken, refreshToken, nickName } = useAuthKakao(); + + useEffect(() => { + if (accessToken && refreshToken && nickName) { + router.push('/main'); + } + }, [accessToken, refreshToken, nickName]); + + return ; +} diff --git a/components/Login/index.tsx b/components/Login/index.tsx new file mode 100644 index 00000000..dc975bbf --- /dev/null +++ b/components/Login/index.tsx @@ -0,0 +1,59 @@ +import styled from 'styled-components'; +import Image from 'next/image'; +import { KakaoLoginIc } from '@/public/assets/icons'; +import MainView from '../Common/mainView'; +import Button from '../Common/Button'; + +export default function LoginContainer() { + const KAKAO_AUTH_URL = `https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${process.env.NEXT_PUBLIC_KAKAO_RESTAPI_KEY}&redirect_uri=${process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI}`; + const handleKaKaoLogin = () => { + window.location.href = KAKAO_AUTH_URL; + }; + return ( + + + + + + + + ); +} + +const Styled = { + Container: styled.div` + display: flex; + flex-direction: column; + align-items: center; + `, + + ButtonWrapper: styled.div` + display: flex; + justify-content: center; + + width: 100%; + + margin-bottom: 10.4rem; + `, + + ButtonContentWrapper: styled.div` + display: flex; + justify-content: center; + align-items: center; + + width: 100%; + + cursor: pointer; + `, + + KakaoLoginIcon: styled((props) => ( + 카카오로그인아이콘 + ))` + margin-right: 1.3rem; + `, +}; diff --git a/components/Main/MainBtn.tsx b/components/Main/MainBtn.tsx new file mode 100644 index 00000000..cee73259 --- /dev/null +++ b/components/Main/MainBtn.tsx @@ -0,0 +1,50 @@ +import router from 'next/router'; +import Button from '../Common/Button'; +import styled from 'styled-components'; +import useModal from '@/hooks/common/useModal'; +import Modal from '../Common/Modal'; +import { useGetMainProgressData } from '@/hooks/queries/wishes'; +import MainShareModal from '../Common/Modal/MainShareModal'; + +export default function MainBtn() { + const { progressData } = useGetMainProgressData(); + const progressStatus = progressData?.status; + + const { isOpen, handleToggle } = useModal(); + + const handleMoveWishesPage = () => { + router.push('/wishes'); + }; + + return ( + + {progressStatus ? ( + progressStatus !== 'END' && ( + + ) + ) : ( + + )} + {isOpen && ( + + + + )} + + ); +} + +const Styled = { + ButtonWrapper: styled.div` + margin-top: 7.1rem; + margin: 7.1rem 0 10.4rem; + `, +}; diff --git a/components/Main/MainCenterContent.tsx b/components/Main/MainCenterContent.tsx new file mode 100644 index 00000000..379dee26 --- /dev/null +++ b/components/Main/MainCenterContent.tsx @@ -0,0 +1,107 @@ +import Image from 'next/image'; +import styled from 'styled-components'; +import VerticalProgressBar from '../Common/VerticalProgressBar'; +import { MainCakeImg, MainChatImg, MainEndChatImg, MainWishChatImg } from '@/public/assets/images'; +import theme from '@/styles/theme'; +import { useGetMainProgressData } from '@/hooks/queries/wishes'; + +export default function MainCenterContent() { + const { progressData } = useGetMainProgressData(); + + const ChatImg = () => { + if (!progressData) { + return MainChatImg; + } + + if (progressData?.status === 'BEFORE' || progressData?.status === 'WHILE') { + return MainWishChatImg; + } + + if (progressData?.status === 'END') { + return MainEndChatImg; + } + }; + + return ( + + + + 말풍선 + + 메인 케이크 이미지 + + + + + + + + {progressData === undefined || progressData.status === 'BEFORE' ? ( + <> + {'모인 케이크\n'} + 총 ???개 + + ) : ( + <> + {'예상 케이크 금액 >\n'} + {`총 ${progressData.price}원`} + + )} + + + ); +} + +const Styled = { + Container: styled.div` + display: flex; + flex-direction: column; + align-items: center; + `, + + ImageWrapper: styled.div` + display: flex; + flex-direction: column; + + width: 100%; + `, + + CakeImageWrapper: styled.div` + display: flex; + justify-content: center; + width: 100%; + margin-top: -1rem; + margin-left: 2rem; + `, + + CenterContentWrapper: styled.div` + display: flex; + justify-content: space-between; + + width: 100%; + + margin-top: 10rem; + padding-right: 2.2rem; + `, + + CakeTextWrapper: styled.div` + text-align: center; + + ${theme.fonts.headline24_130}; + color: ${theme.colors.main_blue}; + + margin-top: -5.8rem; + + white-space: pre-line; + `, + + CakeText: styled.span` + color: ${theme.colors.black}; + `, + + ProgressBarWrapper: styled.div` + display: flex; + + margin-top: 3.4rem; + `, +}; diff --git a/components/Main/MainTopContent.tsx b/components/Main/MainTopContent.tsx new file mode 100644 index 00000000..03d6474c --- /dev/null +++ b/components/Main/MainTopContent.tsx @@ -0,0 +1,106 @@ +import { useGetMainProgressData } from '@/hooks/queries/wishes'; +import { SideBarIc } from '@/public/assets/icons'; +import { LoginUserInfo } from '@/recoil/auth/loginUserInfo'; +import theme from '@/styles/theme'; +import Image from 'next/image'; +import router from 'next/router'; +import { useEffect, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import styled from 'styled-components'; + +export default function MainTopContent() { + const { progressData } = useGetMainProgressData(); + const loginUserInfo = useRecoilValue(LoginUserInfo); + const [nickName, setNickName] = useState(''); + + const handleMoveToMypage = () => { + router.push('/mypage'); + }; + + const getDayText = () => { + if (!progressData) return 'D-?'; + + if (progressData?.dayCount === 0) { + return 'D-Day'; + } else { + return `D-${progressData?.dayCount}`; + } + }; + + useEffect(() => { + setNickName(loginUserInfo.nickName); + }, [loginUserInfo]); + + return ( + + + {progressData ? ( + progressData.status === 'BEFORE' ? ( + <> + {`${nickName}님\n`} + {`${progressData.dayCount}일 뒤 `} + {`부터 소원링크를\n공유할 수 있어요!`} + + ) : ( + //소원 진행 중 + <> + {`${nickName}님에게\n`} + {`${progressData.cakeCount}개 `} + {`의 조각 케이크가\n도착했어요!`} + + ) + ) : ( + <> + {`${nickName}님\n`} + {'소원 링크 '} + {`를 생성하고\n케이크를 모아봐요!`} + + )} + + + + 설정 + {getDayText()} + + + ); +} + +const Styled = { + Container: styled.header` + display: flex; + justify-content: space-between; + + margin-top: 2rem; + `, + + TextWrapper: styled.div` + ${theme.fonts.headline24_130}; + color: ${theme.colors.black}; + white-space: pre-line; + `, + + Text: styled.span` + color: ${theme.colors.main_blue}; + `, + + RigthSideWrapper: styled.div` + display: flex; + flex-direction: column; + + gap: 2.3rem; + `, + + DayText: styled.span` + ${theme.fonts.headline20}; + color: ${theme.colors.main_blue}; + + &.BEFORE { + color: ${theme.colors.gray2}; + } + + &.END { + color: ${theme.colors.warning_red}; + } + `, +}; diff --git a/components/Main/index.tsx b/components/Main/index.tsx new file mode 100644 index 00000000..f8d5c5c2 --- /dev/null +++ b/components/Main/index.tsx @@ -0,0 +1,14 @@ +import MainTopContent from './MainTopContent'; + +import MainBtn from './MainBtn'; +import MainCenterContent from './MainCenterContent'; + +export default function MainContainer() { + return ( + <> + + + + + ); +} diff --git a/components/Mypage/EditWishes/index.tsx b/components/Mypage/EditWishes/index.tsx new file mode 100644 index 00000000..8dcd4b62 --- /dev/null +++ b/components/Mypage/EditWishes/index.tsx @@ -0,0 +1,206 @@ +import Button from '@/components/Common/Button'; +import Calendar from '@/components/Common/Calendar/Calendar'; +import InputContainer from '@/components/Common/Input/InputContainer'; +import TextareaBox from '@/components/Common/Input/TextareaBox'; +import UploadTypeToggleBtn from '@/components/Common/UploadTypeToggleBtn'; +import ItemLink from '@/components/Wishes/WishesForm/ItemLink'; +import theme from '@/styles/theme'; +import styled from 'styled-components'; +import { useEffect, useState } from 'react'; +import { convertMoneyText } from '@/utils/common/convertMoneyText'; +import UploadPresent from '@/components/Wishes/WishesForm/UploadPresent'; +import Input from '@/components/Common/Input/Input'; +import useUploadItemInfo from '@/hooks/wishes/useUploadItemInfo'; +import { useForm } from 'react-hook-form'; +import { WishesDataInputType } from '@/types/wishesType'; +import BankInput from '@/components/Common/Modal/BankInput'; +import { + useGetMainProgressData, + useGetWishesProgress, + usePatchWishes, +} from '@/hooks/queries/wishes'; +import SiteList from '@/components/Wishes/WishesForm/SiteList'; +import { usePatchUserAccount } from '@/hooks/queries/user'; +import { LIMIT_TEXT } from '@/constant/limitText'; +import { getDate } from '@/utils/common/getDate'; + +export default function EditWishesContainer() { + const { imageFile, preSignedImageUrl, uploadImageFile } = useUploadItemInfo(); + const [isLinkLoadType, setIsLinkLoadType] = useState(true); //false : 링크 불러오기 true : 직접 + + const methods = useForm({ + defaultValues: { + linkURL: '', + imageUrl: '', + price: '', + initial: '', + title: '', + hint: '', + startDate: new Date(), + endDate: getDate(new Date(), 7), + phone: '', + name: '', + bank: '', + account: '', + }, + }); + + const { wishesProgressData } = useGetWishesProgress(); + const { progressData } = useGetMainProgressData(); + + const { patchUserAccountData } = usePatchUserAccount(methods); + const { patchWishesData } = usePatchWishes(methods); + + useEffect(() => { + if (wishesProgressData) { + methods.setValue('account', wishesProgressData?.accountInfo.account); + methods.setValue('name', wishesProgressData?.accountInfo.name); + methods.setValue('bank', wishesProgressData?.accountInfo.bank); + methods.setValue('phone', wishesProgressData.phone); + + methods.setValue('title', wishesProgressData.title); + methods.setValue('imageUrl', wishesProgressData.imageUrl); + methods.setValue('hint', wishesProgressData.hint); + methods.setValue('price', wishesProgressData.price); + methods.setValue('initial', wishesProgressData.initial); + + methods.setValue('startDate', new Date(wishesProgressData.startDate)); + methods.setValue('endDate', new Date(wishesProgressData.endDate)); + } + }, [wishesProgressData]); + + const handleLoadTypeToggle = (state: boolean) => { + setIsLinkLoadType(state); + }; + + return ( + <> + + 소원링크 정보 수정하기 + + + + + {isLinkLoadType ? ( + + + + + ) : ( + <> + + + )} + + + + + + + + + + + + {/* 시작일 */} + + {/* 종료일 */} + + + + + {/* BankInfo */} + + + + + + + + + + + + + + + + + ); +} + +const Styled = { + Title: styled.h1` + ${theme.fonts.headline24_100}; + color: ${theme.colors.black}; + + margin-left: 1rem; + `, + + TitleWrapper: styled.div` + display: flex; + + height: 2.4rem; + + margin: 2.4rem 0 2rem; + `, + + CalendarWrapper: styled.div` + display: flex; + justify-content: space-between; + `, + + UploadImageBox: styled.div` + display: flex; + justify-content: center; + align-items: center; + + width: 100%; + height: 100%; + + cursor: pointer; + `, + + Lable: styled.label` + cursor: pointer; + `, + + FileInput: styled.input` + display: none; + `, + + ButtonWrapper: styled.div` + display: flex; + justify-content: space-between; + + width: 100%; + + margin-bottom: 4.6rem; + `, +}; diff --git a/components/Mypage/ItemBox.tsx b/components/Mypage/ItemBox.tsx new file mode 100644 index 00000000..a9b279d9 --- /dev/null +++ b/components/Mypage/ItemBox.tsx @@ -0,0 +1,36 @@ +import theme from '@/styles/theme'; +import styled from 'styled-components'; +import { PropsWithChildren } from 'react'; +import { StyledBox } from '../Common/Box'; +import { ColorSystemType } from '@/types/common/box/boxStyleType'; + +interface ItemBoxProps { + handleClickFn?: () => void; + colorSystem: ColorSystemType; +} + +export default function ItemBox(props: PropsWithChildren) { + const { handleClickFn, colorSystem, children } = props; + + return ( +
  • + + {children} + {'>'} + +
  • + ); +} + +const MypageItemBox = styled(StyledBox)` + display: flex; + justify-content: space-between; + align-items: center; + + width: 100%; + height: 6rem; + + ${theme.fonts.button18}; + + padding: 0 2rem; +`; diff --git a/components/Mypage/Letters/CakeListButton.tsx b/components/Mypage/Letters/CakeListButton.tsx new file mode 100644 index 00000000..05f7d60a --- /dev/null +++ b/components/Mypage/Letters/CakeListButton.tsx @@ -0,0 +1,59 @@ +import theme from '@/styles/theme'; +import styled from 'styled-components'; +import Image, { StaticImageData } from 'next/image'; + +interface CakeListButtonProps { + image?: string | StaticImageData; + backgroundColor: string; + fontColor: string; + fonts: string; + handleClick?: () => void; + cakeName?: string; + cakeNum?: number; +} + +export default function CakeListButton(props: CakeListButtonProps) { + const { image, backgroundColor, fonts, fontColor, handleClick, cakeName, cakeNum } = props; + return ( + + {image && 케이크 이미지} + {/* */} + + {cakeName} X {cakeNum}개 + + + ); +} + +const Styled = { + Container: styled.button<{ backgroundColor: string }>` + width: 100%; + height: 6rem; + + display: flex; + justify-content: left; + align-items: center; + + padding: 0 2rem 0; + border-radius: 1rem; + + color: ${theme.colors.gray4}; + background-color: ${(props) => props.backgroundColor}; + border-color: transparent; + margin: 0 0 1rem; + `, + + // TextContainer: styled.div<{ fonts: string; fontColor: string }>` + // padding: 0.2rem 0.5rem 0 1rem; + // ${(props) => props.fonts} + + // `, + + TextContainer: styled.div<{ fontColor: string }>` + padding: 0.2rem 0.5rem 0 1rem; + `, + + NumText: styled.span` + color: ${theme.colors.main_blue}; + `, +}; diff --git a/components/Mypage/Letters/LettersMain.tsx b/components/Mypage/Letters/LettersMain.tsx new file mode 100644 index 00000000..b0912ca5 --- /dev/null +++ b/components/Mypage/Letters/LettersMain.tsx @@ -0,0 +1,110 @@ +import theme from '@/styles/theme'; +import styled from 'styled-components'; +import { useRouter } from 'next/router'; +import CakeListButton from './CakeListButton'; +import { useRecoilValue } from 'recoil'; +import { LoginUserInfo } from '@/recoil/auth/loginUserInfo'; +import { useEffect, useState } from 'react'; +import MainHeader from '@/components/Common/mainHeader'; +import { CAKE_LIST } from '@/constant/cakeList'; +import { useGetCakesResult } from '@/hooks/queries/cakes'; + +export default function LettersMainContainer() { + const [wishId, setWishId] = useState(''); + const router = useRouter(); + + useEffect(() => { + if (!router.isReady) return; + setWishId(router.query.id); + }, [router.isReady]); + + // nickname + const [nickName, setNicknameState] = useState(''); + const loginUserInfo = useRecoilValue(LoginUserInfo); + + useEffect(() => { + setNicknameState(loginUserInfo.nickName); + }, [loginUserInfo]); + + // cake 개수, 합 + const { cakesCount, total } = useGetCakesResult(wishId); + + const getCakeNum = (cakeId: number, cakesCount: any[]): number => { + if (!cakesCount) { + return 0; + } + + const cake = cakesCount.find((cake) => cake.cakeId === cakeId); + return cake ? cake.count : 0; + }; + + const handleMoveToLetters = (cakeId: number) => { + const cake = CAKE_LIST.find((cake) => cake.cakeNumber === cakeId); + + if (cake) { + router.push({ + pathname: `/mypage/letters/${wishId}/${cakeId}`, + query: { + cake: getCakeNum(cake.cakeNumber, cakesCount), + }, + }); + } + }; + + const title = ( + + {nickName}님에게 도착한 +
    + {total}개의 조각 케이크 +
    + 편지 열어보기! +
    + ); + + return ( + <> + + + + + {CAKE_LIST?.map((cake) => ( + handleMoveToLetters(cake.cakeNumber) + : undefined + } + backgroundColor={theme.colors.pastel_blue} + fontColor={theme.colors.gray4} + fonts={theme.fonts.button18} + image={cake.smallImage} + cakeName={cake.name} + cakeNum={getCakeNum(cake.cakeNumber, cakesCount)} + /> + ))} + + + + ); +} + +const Styled = { + Container: styled.div` + margin: 0 1rem 0; + `, + + Title: styled.h1` + margin: 0 0 3rem; + ${theme.fonts.headline24_130}; + color: ${theme.colors.gray4}; + `, + + TitleColor: styled.span` + color: ${theme.colors.main_blue}; + `, + + ListButton: styled.div` + ${theme.fonts.button18}; + `, +}; diff --git a/components/Mypage/Letters/[id].tsx b/components/Mypage/Letters/[id].tsx new file mode 100644 index 00000000..8a065bb6 --- /dev/null +++ b/components/Mypage/Letters/[id].tsx @@ -0,0 +1,158 @@ +import theme from '@/styles/theme'; +import styled from 'styled-components'; +import CakeListButton from './CakeListButton'; +import Image from 'next/image'; +import { BorderImg } from '@/public/assets/images'; +import { useEffect, useState } from 'react'; +import { ArrowLeftIc, ArrowRightIc, BackBtnIc } from '@/public/assets/icons'; +import { useRouter } from 'next/router'; +import { CAKE_LIST } from '@/constant/cakeList'; +import { useGetCakesInfo } from '@/hooks/queries/cakes'; + +export default function LettersContainer() { + const [wishId, setWishId] = useState(''); + const [cakeId, setCakeId] = useState(''); + const [clickedBox, setClickedBox] = useState(0); + const router = useRouter(); + + useEffect(() => { + if (!router.isReady) return; + setWishId(router.query.id); + setCakeId(router.query.cakeId); + }, [router.isReady]); + + // 케이크 정보, 개수 + const { cake } = router.query; + const cakeData = CAKE_LIST.find((cake) => cake.cakeNumber === Number(cakeId)); + + // 편지 + const { lettersData, lettersSum } = useGetCakesInfo(wishId, cakeId); + + const handleNameBoxClick = (index: number) => { + setClickedBox(index); + }; + + const handleArrowClick = (direction: string) => { + let movedBox = clickedBox; + + if (direction === 'left') { + movedBox = (movedBox - 1 + lettersSum) % lettersSum; + } else if (direction === 'right') { + movedBox = (movedBox + 1) % lettersSum; + } + + setClickedBox(movedBox); + }; + + const handleMoveBack = () => { + window.history.back(); + }; + + return ( + <> + + + + {cakeData?.name}를 보낸 선물주님들이 +
    + 남긴 편지를 읽어보세요 +
    + + {`'${lettersData[clickedBox]?.name}' 선물주님`} + + handleArrowClick('left')}> + 왼쪽 화살표 + + + + + handleArrowClick('right')}> + 오른쪽 화살표 + + + + 구분선 + + + {lettersData.map((letters, index) => ( + handleNameBoxClick(index)} + > + {letters.name} + + ))} + + + ); +} + +const Styled = { + Title: styled.div` + ${theme.fonts.body16}; + color: ${theme.colors.main_blue}; + margin: 0 0 2rem 1rem; + `, + + Text: styled.div` + ${theme.fonts.body16}; + color: ${theme.colors.dark_blue}; + margin: 1rem 1rem 2rem; + `, + + LetterContainer: styled.div` + margin: 0 0 1rem; + display: flex; + align-items: center; + justify-content: space-between; + `, + + ArrowButton: styled.button``, + + TextareaText: styled.textarea` + width: 100%; + height: 15rem; + color: ${theme.colors.dark_blue}; + ${theme.fonts.body14}; + resize: none; + background-color: ${theme.colors.pastel_blue}; + border: 0.1rem solid ${theme.colors.main_blue}; + border-radius: 1rem; + padding: 1.2rem 1rem 1.2rem 1.2rem; + `, + + NameContainer: styled.div` + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr; + grid-column-gap: 1.2rem; + grid-row-gap: 1rem; + margin: 1rem 0 2rem; + color: ${theme.colors.white}; + ${theme.fonts.body14}; + overflow: auto; + max-height: 45vh; + `, + + NameBox: styled.div<{ active: boolean }>` + width: 7.4rem; + height: 4.6rem; + display: flex; + justify-content: center; + align-items: center; + padding: 0.8rem; + border-radius: 0.6rem; + background-color: ${(props) => + props.active ? theme.colors.main_blue : theme.colors.pastel_blue}; + color: ${(props) => (props.active ? theme.colors.white : theme.colors.main_blue)}; + cursor: pointer; + `, +}; diff --git a/components/Mypage/Links/LinksBox.tsx b/components/Mypage/Links/LinksBox.tsx new file mode 100644 index 00000000..5e400485 --- /dev/null +++ b/components/Mypage/Links/LinksBox.tsx @@ -0,0 +1,85 @@ +import theme from '@/styles/theme'; +import styled from 'styled-components'; + +interface LinksBoxProps { + title: string; + date: string; + handleCheckbox: () => void; + isChecked?: boolean; + handleMovePage: () => void; +} + +export default function LinksBox(props: LinksBoxProps) { + const { title, date, handleCheckbox, isChecked, handleMovePage } = props; + + + return ( + + + + {title} + {date} + + + + {'>'} + + + + ); +} + +const Styled = { + Container: styled.div` + width: 100%; + height: 7rem; + display: flex; + justify-content: left; + align-items: center; + padding: 0rem 1.5rem; + border-radius: 1rem; + background-color: ${theme.colors.pastel_blue}; + margin: 0 0 1rem; +`, + + TextContainer: styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 0 0 0 1rem; + `, + + Title: styled.div` + margin: 0 0 0.5rem; + ${theme.fonts.button16_2}; + color: ${theme.colors.main_blue}; +`, + + Date: styled.div` + ${theme.fonts.body12_2}; + color: ${theme.colors.main_blue}; +`, + + ButtonContainer: styled.button` + margin-left: auto; + color: ${theme.colors.main_blue}; + ${theme.fonts.button16_2}; +`, + + Checkbox: styled.input.attrs({ type: 'checkbox' })` + width: 1.7rem; + height: 1.7rem; + border: 0.4px solid ${theme.colors.main_blue}; + border-radius: 20%; + background-color: ${theme.colors.white}; + cursor: pointer; + &:checked { + background-image: url('/assets/icons/checkIc.svg'); + background-repeat: no-repeat; + background-position: center; + background-color: ${theme.colors.main_blue}; +} +appearance: none; +`, + +}; \ No newline at end of file diff --git a/components/Mypage/Links/NoWishLists.tsx b/components/Mypage/Links/NoWishLists.tsx new file mode 100644 index 00000000..d283024b --- /dev/null +++ b/components/Mypage/Links/NoWishLists.tsx @@ -0,0 +1,39 @@ +import styled from 'styled-components'; +import Image from 'next/image'; +import { LinksPageChatImg, MainCakeImg } from '@/public/assets/images'; +import router from 'next/router'; +import Button from '@/components/Common/Button'; + +export default function NoWishLists() { + const handleMoveToMain = () => { + router.push('/main'); + }; + + return ( + <> + + + 말풍선 + 케이크 + + + + + + ); +} + +const Styled = { + Container: styled.div` + margin: 6.7rem 0 3.7rem; + `, + + ImageContainer: styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + `, +}; diff --git a/components/Mypage/Links/WishLists.tsx b/components/Mypage/Links/WishLists.tsx new file mode 100644 index 00000000..d1038dd9 --- /dev/null +++ b/components/Mypage/Links/WishLists.tsx @@ -0,0 +1,33 @@ +import LinksBox from './LinksBox'; +import { WishLinksType } from '@/types/links/wishLinksType'; +import { convertDateFormat } from '@/hooks/common/useDate'; +import router from 'next/router'; + +interface WishListsProps { + linksData: WishLinksType[]; + selectedLinks: number[]; + handleCheckbox: (wishId: number) => void; +} + +export default function WishLists(props: WishListsProps) { + const { linksData, selectedLinks, handleCheckbox } = props; + + const handleMovePage = (wishId: number) => { + router.push(`/mypage/links/${wishId}`); + }; + + return ( + <> + {linksData.map((link) => ( + handleMovePage(link.wishId)} + handleCheckbox={() => handleCheckbox(link.wishId)} + title={link.title} + date={`${convertDateFormat(link.startAt)} ~ ${convertDateFormat(link.endAt)}`} + isChecked={selectedLinks.includes(link.wishId)} + /> + ))} + + ); +} diff --git a/components/Mypage/Links/[id].tsx b/components/Mypage/Links/[id].tsx new file mode 100644 index 00000000..2c0ae8f8 --- /dev/null +++ b/components/Mypage/Links/[id].tsx @@ -0,0 +1,98 @@ +import styled from 'styled-components'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import theme from '@/styles/theme'; +import { LinkBeefCakeImg } from '@/public/assets/images'; +import { useEffect, useState } from 'react'; +import { convertDateFormat } from '@/hooks/common/useDate'; +import VerticalProgressBar from '@/components/Common/VerticalProgressBar'; +import { useGetSingleWishInfo } from '@/hooks/queries/wishes'; + +export default function LinksContainer() { + const [wishId, setWishId] = useState(''); + const router = useRouter(); + + useEffect(() => { + if (!router.isReady) return; + setWishId(router.query.id); + }, [router.isReady]); + + const { wishData } = useGetSingleWishInfo(wishId); + + const handleMovePage = () => { + router.push(`/mypage/letters/${wishId}`); + }; + + return ( + <> + {wishData?.title} + {`${convertDateFormat(wishData?.startAt)} ~ ${convertDateFormat( + wishData?.endAt, + )}`} + + + + + 케이크 + + 모인 케이크 보러가기 {'>'} + 총 {wishData?.price}원 + + + + + + ); +} + +const Styled = { + Title: styled.div` + ${theme.fonts.headline24_130}; + color: ${theme.colors.gray4}; + margin: 2rem 0 0.5rem; + `, + + Date: styled.div` + ${theme.fonts.body16}; + color: ${theme.colors.gray4}; + `, + + CenterContainer: styled.div` + margin: 9rem 0 15.5rem; + width: 100%; + display: flex; + justify-content: right; + align-items: center; + `, + + ContentContainer: styled.div` + display: flex; + flex-direction: column; + justify-content: center; + `, + + BarContainer: styled.div` + float: right; + margin: 0rem 1.5rem 0 0; + `, + + ImageContainer: styled.div` + width: 100%; + text-align: center; + padding: 4rem 0 0; + `, + + About: styled.div` + display: flex; + justify-content: center; + margin: 0 0 1rem; + ${theme.fonts.headline24_100}; + color: ${theme.colors.main_blue}; + `, + + AboutSmall: styled.div` + display: flex; + justify-content: center; + ${theme.fonts.headline24_100}; + `, +}; diff --git a/components/Mypage/Links/index.tsx b/components/Mypage/Links/index.tsx new file mode 100644 index 00000000..ef676d7d --- /dev/null +++ b/components/Mypage/Links/index.tsx @@ -0,0 +1,80 @@ +import theme from '@/styles/theme'; +import styled from 'styled-components'; +import useModal from '@/hooks/common/useModal'; +import Modal from '@/components/Common/Modal'; +import DeleteModal from '@/components/Common/Modal/DeleteModal'; +import { useState } from 'react'; +import WishLists from './WishLists'; +import NoWishLists from './NoWishLists'; +import { useQueryClient } from 'react-query'; +import { QUERY_KEY } from '@/constant/queryKey'; +import { useDeleteWishes, useGetWishLinks } from '@/hooks/queries/wishes'; + +export default function LinksMainContainer() { + const [selectedLinks, setSelectedLinks] = useState([]); + const { isOpen, handleToggle } = useModal(); + const queryClient = useQueryClient(); + + const deleteWishesMutation = useDeleteWishes(); + + const { wishLinks, noWishes } = useGetWishLinks(); + + const handleCheckbox = (wishId: number) => { + if (selectedLinks.includes(wishId)) { + setSelectedLinks((prev) => prev.filter((item) => item !== wishId)); + } else { + setSelectedLinks((prev) => [...prev, wishId]); + } + }; + + const handleDeleteConfirm = () => { + if (selectedLinks.length > 0) { + deleteWishesMutation.mutate(selectedLinks, { + onSuccess: () => { + queryClient.invalidateQueries(QUERY_KEY.WISH_LINKS); + }, + }); + } + }; + + return ( + <> + {isOpen && ( + + + + )} + + 지난 소원 링크 모음 + + {noWishes ? ( + + ) : ( + + )} + + + ); +} + +const Styled = { + Container: styled.div` + margin: 0 1rem 0; + overflow: auto; + max-height: 80vh; + `, + + Title: styled.h1` + ${theme.fonts.headline24_130}; + color: ${theme.colors.gray4}; + margin: 2rem 1rem 2rem; + `, +}; diff --git a/components/Mypage/index.tsx b/components/Mypage/index.tsx new file mode 100644 index 00000000..ef5ffb39 --- /dev/null +++ b/components/Mypage/index.tsx @@ -0,0 +1,175 @@ +import theme from '@/styles/theme'; +import styled from 'styled-components'; +import router from 'next/router'; +import Image from 'next/image'; +import ItemBox from './ItemBox'; +import { useRecoilValue, useResetRecoilState } from 'recoil'; +import { LoginUserInfo } from '@/recoil/auth/loginUserInfo'; +import GuideModal from '@/components/Common/Modal/GuideModal'; +import Modal from '@/components/Common/Modal'; +import useModal from '@/hooks/common/useModal'; +import { MypageCakeImg } from '@/public/assets/images'; +import { useEffect, useState } from 'react'; +import { deleteUserInfo } from '@/api/user'; +import { useGetMainProgressData } from '@/hooks/queries/wishes'; + +export default function MyPageContainer() { + const { isOpen, handleToggle } = useModal(); + const [nickName, setNicknameState] = useState(''); + const loginUserInfo = useRecoilValue(LoginUserInfo); + const { progressData } = useGetMainProgressData(); + + useEffect(() => { + setNicknameState(loginUserInfo.nickName); + }, [loginUserInfo]); + + const handleEditWish = () => { + if (progressData?.status === 'END' || progressData === undefined) return; + + router.push('/mypage/editWishes'); + }; + + const handleWishLinks = () => { + router.push('/mypage/links'); + }; + + const handleCustomerService = () => { + if (progressData?.status === 'END' || progressData === undefined) return; + + if (window.Kakao && window.Kakao.Channel) { + window.Kakao.Channel.chat({ + channelPublicId: process.env.NEXT_PUBLIC_KAKAO_CHANNEL_ID, + }); + } else { + alert( + "채널 연결에 문제가 발생했습니다. 카카오톡에서 '조물주보다생일선물주'를 검색하여 문의해주세요.", + ); + } + }; + + const handleLogOut = () => { + window.location.href = `https://kauth.kakao.com/oauth/logout?client_id=${process.env.NEXT_PUBLIC_KAKAO_RESTAPI_KEY}&logout_redirect_uri=${process.env.NEXT_PUBLIC_KAKAO_LOGOUT_REDIRECT_URI}`; + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('UserInfo'); + useResetRecoilState(LoginUserInfo); + }; + + const handleWithdrawal = () => { + if (window.confirm('탈퇴를 진행하시겠습니까?')) { + deleteUserInfo(); + } + }; + + return ( + <> + {isOpen && ( + + + + )} + + + + + 케이크 프로필 + + {nickName} 님 + + + + + 진행중인 소원 링크 정보 수정하기 + + + 진행중인 펀딩 중단하기 + + + 지난 소원 링크 모음 + + + 사용설명서 보기 + + + 고객센터 문의하기 + + + + + 로그아웃 + 회원탈퇴 + + + + ); +} + +const Styled = { + Container: styled.div` + margin: 0 1rem 0; + `, + + TitleContainer: styled.div` + display: flex; + margin: 2rem 0 2rem; + `, + + ItemBoxWrapper: styled.ul` + display: flex; + flex-direction: column; + + gap: 1.2rem; + `, + + Title: styled.h1` + ${theme.fonts.headline24_130}; + color: ${theme.colors.gray4}; + display: flex; + align-items: center; + margin: 0 0 0 1.5rem; + `, + + ProfileImgContainer: styled.div` + width: 5rem; + height: 5rem; + border-radius: 30rem; + background-color: ${theme.colors.pastel_blue}; + display: flex; + justify-content: center; + padding-top: 0.5rem; + `, + + TextButtonWrapper: styled.ul` + display: flex; + flex-direction: column; + align-items: left; + + width: 100%; + + ${theme.fonts.button18}; + color: ${theme.colors.main_blue}; + text-decoration: underline; + + margin-top: 3rem; + `, + + TextButton: styled.li` + line-height: 30px; + + cursor: pointer; + `, +}; diff --git a/components/Wishes/Common/WishesStepBtn.tsx b/components/Wishes/Common/WishesStepBtn.tsx new file mode 100644 index 00000000..636a2b31 --- /dev/null +++ b/components/Wishes/Common/WishesStepBtn.tsx @@ -0,0 +1,57 @@ +import styled from 'styled-components'; +import Button from '../../Common/Button'; +import { ColorSystemType } from '@/types/common/box/boxStyleType'; + +interface WishesStepBtnProps { + wishesStep: { + stepIndex: number; + prevState: boolean; + nextState: boolean; + changePrevState: (state: boolean) => void; + changeNextState: (state: boolean) => void; + handleNextStep: () => void; + handlePrevStep: () => void; + getNextBtnColor: (state: boolean) => ColorSystemType; + getPrevBtnColor: (state: boolean) => ColorSystemType; + }; + + handleClickFn?: () => void; +} + +export default function WishesStepBtn(props: WishesStepBtnProps) { + const { wishesStep, handleClickFn } = props; + + const handleNextClickFn = () => { + if (handleClickFn) handleClickFn(); + wishesStep.handleNextStep(); + }; + + return ( + + + + + ); +} + +const Styled = { + ButtonWrapper: styled.div` + display: flex; + justify-content: space-between; + gap: 0.6rem; + + margin-bottom: 4.6rem; + `, +}; diff --git a/components/Wishes/Common/WishesStepTitle.tsx b/components/Wishes/Common/WishesStepTitle.tsx new file mode 100644 index 00000000..93ba8ce6 --- /dev/null +++ b/components/Wishes/Common/WishesStepTitle.tsx @@ -0,0 +1,35 @@ +import { WishesFormPresentIc } from '@/public/assets/icons'; +import theme from '@/styles/theme'; +import Image from 'next/image'; +import styled from 'styled-components'; + +interface WishesStepTitleProps { + title: string; +} + +export default function WishesStepTitle(props: WishesStepTitleProps) { + const { title } = props; + return ( + + 선물 이미지 + {title}, + + ); +} + +const Styled = { + Title: styled.h1` + ${theme.fonts.headline24_100}; + color: ${theme.colors.main_blue}; + + margin-left: 1rem; + `, + + TitleWrapper: styled.div` + display: flex; + + height: 2.4rem; + + margin: 2.4rem 0 2rem; + `, +}; diff --git a/components/Wishes/Share/index.tsx b/components/Wishes/Share/index.tsx new file mode 100644 index 00000000..4b9bdb51 --- /dev/null +++ b/components/Wishes/Share/index.tsx @@ -0,0 +1,103 @@ +import styled from 'styled-components'; +import Image from 'next/image'; +import { useRecoilValue } from 'recoil'; +import router from 'next/router'; +import theme from '@/styles/theme'; +import { CloseBlueIc } from '@/public/assets/icons'; +import { ShareChatImg, MainCakeImg } from '@/public/assets/images'; +import { LoginUserInfo } from '@/recoil/auth/loginUserInfo'; +import Button from '@/components/Common/Button'; +import useModal from '@/hooks/common/useModal'; +import { useGetMainProgressData } from '@/hooks/queries/wishes'; +import ShareModal from '@/components/Common/Modal/ShareModal'; +import Modal from '@/components/Common/Modal'; + +export default function ShareContainer() { + const { isOpen, handleToggle } = useModal(); + + const { progressData } = useGetMainProgressData(); + + const loginUserInfo = useRecoilValue(LoginUserInfo); + + const handleMoveToMain = () => { + router.push('/main'); + }; + + return ( + <> + + 닫기 + + + {`${loginUserInfo.nickName}님의\n 소원 생성 완료!`} + {progressData?.status === 'WHILE' ? ( + 선물주들에게 생일 축하 받으러 가볼까요? + ) : ( + + {progressData ? progressData?.dayCount : '?'}일 뒤부터 링크를 공유할 수 있어요 + + )} + + + 말풍선 + 케이크 + + + + {isOpen && ( + + + + )} + + + + + ); +} + +const Styled = { + TopButtonWrapper: styled.div` + display: flex; + flex-direction: row-reverse; + `, + + Title: styled.div` + display: flex; + justify-content: center; + margin: 0 0 2rem; + ${theme.fonts.headline30}; + color: ${theme.colors.main_blue}; + text-align: center; + white-space: pre-line; + `, + + Container: styled.div` + margin: 6.7rem 0 12.2rem; + `, + + ImageContainer: styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + `, + + About: styled.div` + display: flex; + justify-content: center; + margin: 0 0 4.3rem; + ${theme.fonts.body16}; + color: ${theme.colors.main_blue}; + `, + + ButtonWrapper: styled.div` + margin-bottom: 10.4rem; + `, +}; diff --git a/components/Wishes/WishesForm/BankInfo.tsx b/components/Wishes/WishesForm/BankInfo.tsx new file mode 100644 index 00000000..337d2582 --- /dev/null +++ b/components/Wishes/WishesForm/BankInfo.tsx @@ -0,0 +1,155 @@ +import { UseFormReturn } from 'react-hook-form'; +import InputContainer from '@/components/Common/Input/InputContainer'; +import BankInput from '@/components/Common/Modal/BankInput'; +import { WishesDataInputType } from '@/types/wishesType'; +import styled from 'styled-components'; +import CheckBox from '@/components/Common/CheckBox'; +import useCheckBox from '@/hooks/common/useCheckBox'; +import Input from '@/components/Common/Input/Input'; +import { StyledBox } from '@/components/Common/Box'; +import WishesStepTitle from '../Common/WishesStepTitle'; +import WishesStepBtn from '../Common/WishesStepBtn'; +import { ColorSystemType } from '@/types/common/box/boxStyleType'; +import theme from '@/styles/theme'; +import { useEffect } from 'react'; +import { validation } from '@/validation/input'; +import AlertTextBox from '@/components/Common/AlertTextBox'; +import { useGetUserAccount, usePatchUserAccount } from '@/hooks/queries/user'; +import { usePostWishes } from '@/hooks/queries/wishes'; + +interface BankInfoProps { + methods: UseFormReturn; + wishesStep: { + stepIndex: number; + prevState: boolean; + nextState: boolean; + changePrevState: (state: boolean) => void; + changeNextState: (state: boolean) => void; + handleNextStep: () => void; + handlePrevStep: () => void; + getNextBtnColor: (state: boolean) => ColorSystemType; + getPrevBtnColor: (state: boolean) => ColorSystemType; + }; +} + +export default function BankInfo(props: BankInfoProps) { + const { methods, wishesStep } = props; + + const { userAccountData } = useGetUserAccount(); + + const { checkBoxState, handleChangeCheckBoxState } = useCheckBox(); + + const { postWishesData } = usePostWishes(methods); + const { patchUserAccountData } = usePatchUserAccount(methods); + + useEffect(() => { + if ( + checkBoxState && + methods.getValues('name') && + methods.getValues('bank') && + methods.getValues('account') && + methods.getValues('phone') && + validation.isCorrectPhoneNumber(methods.getValues('phone')) + ) { + wishesStep.changeNextState(true); + } else { + wishesStep.changeNextState(false); + } + }, [methods.watch()]); + + useEffect(() => { + if (userAccountData) { + methods.setValue('name', userAccountData.accountInfo.name); + methods.setValue('bank', userAccountData.accountInfo.bank); + methods.setValue('account', userAccountData.accountInfo.account); + methods.setValue('phone', userAccountData.phone); + } + }, [userAccountData]); + + return ( + <> + + +
    + + {'※ 계좌번호, 연락처에 대한 허위기재와 오기로 인해 발생되는 문제는 책임지지 않습니다.'} + + + + + + + + + + + + {!validation.isIncludeHyphen(methods.watch('phone')) || + (!validation.isCorrectPhoneNumber(methods.watch('phone')) && + methods.watch('phone') !== '' && ( + {'올바른 연락처를 입력해주세요'} + ))} + + +
    + + { + postWishesData(); + patchUserAccountData(); + }} + /> +
    + + ); +} + +const GuideBox = styled(StyledBox)` + display: flex; + flex-direction: column; + justify-content: space-between; + + width: 100%; + height: 9.8rem; + + ${theme.fonts.body14}; + text-align: left; + + margin-bottom: 2.4rem; + padding: 1.2rem; +`; + +const Styled = { + Container: styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + + height: 100svh; + `, + + GuideCheckBoxWrapper: styled.div` + display: flex; + justify-content: right; + + width: 100%; + height: 2rem; + `, + + ButtonWrapper: styled.div` + display: flex; + justify-content: space-between; + + width: 100%; + + margin-bottom: 4.6rem; + `, +}; diff --git a/components/Wishes/WishesForm/ItemLink.tsx b/components/Wishes/WishesForm/ItemLink.tsx new file mode 100644 index 00000000..32fb6d1e --- /dev/null +++ b/components/Wishes/WishesForm/ItemLink.tsx @@ -0,0 +1,94 @@ +import theme from '@/styles/theme'; +import styled from 'styled-components'; +import { validation } from '@/validation/input'; +import { useState } from 'react'; +import { convertMoneyText } from '@/utils/common/convertMoneyText'; +import { QUERY_KEY } from '@/constant/queryKey'; +import { extractImageSrc, extractPrice } from '@/utils/common/extractItem'; +import { useQuery } from 'react-query'; +import { getSiteData } from '@/utils/common/getSiteData'; +import Input from '@/components/Common/Input/Input'; +import { UseFormReturn } from 'react-hook-form'; +import AlertTextBox from '@/components/Common/AlertTextBox'; +import ItemImageBox from '@/components/Common/Box/ItemImageBox'; +import { WishesDataInputType } from '@/types/wishesType'; +import { getPresentLinkInfo } from '@/api/wishes'; + +interface ItemLinkProps { + methods: UseFormReturn; +} + +export default function ItemLink(props: ItemLinkProps) { + const { methods } = props; + const [isCorrectLinkURL, setIsCorrectLinkURL] = useState(false); + + const linkURL = methods.watch('linkURL'); + const imageUrl = methods.getValues('imageUrl'); + const price = methods.getValues('price'); + + const parseImage = () => { + isSuccess && refetch(); + if (validation.isCorrectSite(linkURL)) { + setIsCorrectLinkURL(true); + } else { + setIsCorrectLinkURL(false); + } + }; + + const { refetch, isSuccess } = useQuery( + QUERY_KEY.ITEM_DATA, + () => getPresentLinkInfo(linkURL, getSiteData(linkURL)), + { + onSuccess: (data) => { + const imageData = extractImageSrc(data?.imageTag?.data?.data); + const priceData = extractPrice(data?.priceTag?.data?.data, linkURL); + + if (imageData) { + methods.setValue('imageUrl', imageData); + methods.setValue('price', priceData); + } + }, + enabled: isCorrectLinkURL && validation.isCorrectSite(linkURL), + }, + ); + + return ( + + + {linkURL && linkURL.length > 0 && !validation.isCorrectSite(linkURL) && ( + 정해진 사이트에서 링크를 가져와주세요! + )} + + {imageUrl && linkURL && isCorrectLinkURL && ( + + + {`가격 : ${convertMoneyText(price.toString())}원`} + + )} + + ); +} + +const Styled = { + Container: styled.div` + margin-bottom: 2.4rem; + `, + + SiteBox: styled.div` + display: inline-block; + width: 6rem; + height: 6rem; + background-color: ${theme.colors.white}; + cursor: pointer; + margin: 0 1rem 1rem 0; + `, + + ItemImageWrapper: styled.div` + margin-top: 1.2rem; + `, +}; diff --git a/components/Wishes/WishesForm/Preview.tsx b/components/Wishes/WishesForm/Preview.tsx new file mode 100644 index 00000000..5dab05dc --- /dev/null +++ b/components/Wishes/WishesForm/Preview.tsx @@ -0,0 +1,96 @@ +import InputContainer from '@/components/Common/Input/InputContainer'; +import TextareaBox from '@/components/Common/Input/TextareaBox'; +import styled from 'styled-components'; +import theme from '@/styles/theme'; +import { convertMoneyText } from '@/utils/common/convertMoneyText'; +import { convertDateToString } from '@/utils/common/getDate'; +import Input from '@/components/Common/Input/Input'; +import { UseFormReturn } from 'react-hook-form'; +import { WishesDataInputType } from '@/types/wishesType'; +import ItemImageBox from '@/components/Common/Box/ItemImageBox'; +import WishesStepTitle from '../Common/WishesStepTitle'; +import WishesStepBtn from '../Common/WishesStepBtn'; +import { ColorSystemType } from '@/types/common/box/boxStyleType'; +import { useEffect } from 'react'; + +interface PreviewProps { + methods: UseFormReturn; + wishesStep: { + stepIndex: number; + prevState: boolean; + nextState: boolean; + changePrevState: (state: boolean) => void; + changeNextState: (state: boolean) => void; + handleNextStep: () => void; + handlePrevStep: () => void; + getNextBtnColor: (state: boolean) => ColorSystemType; + getPrevBtnColor: (state: boolean) => ColorSystemType; + }; +} + +export default function Preview(props: PreviewProps) { + const { methods, wishesStep } = props; + + useEffect(() => { + wishesStep.changeNextState(true); + }, []); + + return ( + <> + + +
    + + {convertDateToString(methods.getValues('startDate'))}~ + {convertDateToString(methods.getValues('endDate'))} + + + + + + 가격 : {convertMoneyText(methods.getValues('price').toString())}원 + + + + + + + + + + +
    + + +
    + + ); +} + +const Styled = { + Container: styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + + height: 100%; + `, + + Period: styled.p` + ${theme.fonts.body16}; + color: ${theme.colors.main_blue}; + margin: 0 0 1rem; + `, + + PresentPrice: styled.p` + ${theme.fonts.button18}; + color: ${theme.colors.main_blue}; + text-align: center; + + margin-top: 1rem; + `, + + ButtonWrapper: styled.div` + padding-bottom: 4.6rem; + `, +}; diff --git a/components/Wishes/WishesForm/SiteList.tsx b/components/Wishes/WishesForm/SiteList.tsx new file mode 100644 index 00000000..66cd663d --- /dev/null +++ b/components/Wishes/WishesForm/SiteList.tsx @@ -0,0 +1,35 @@ +import { SITE_LIST } from '@/constant/siteList'; +import theme from '@/styles/theme'; +import Image from 'next/image'; +import styled from 'styled-components'; + +export default function SiteList() { + return ( + + {Object.values(SITE_LIST).map((siteData) => ( + + + {`${siteData.NAME} + + + ))} + + ); +} + +const Styled = { + SiteItemWrapper: styled.ul` + display: flex; + + width: 100%; + `, + + SiteItem: styled.li` + display: inline-block; + width: 6rem; + height: 6rem; + background-color: ${theme.colors.white}; + cursor: pointer; + margin: 0 1rem 1rem 0; + `, +}; diff --git a/components/Wishes/WishesForm/UploadPresent.tsx b/components/Wishes/WishesForm/UploadPresent.tsx new file mode 100644 index 00000000..114db1f9 --- /dev/null +++ b/components/Wishes/WishesForm/UploadPresent.tsx @@ -0,0 +1,92 @@ +import InputContainer from '@/components/Common/Input/InputContainer'; +import styled from 'styled-components'; +import { ImageUploadIc } from '@/public/assets/icons'; +import Image from 'next/image'; +import InputLength from '@/components/Common/Input/InputLength'; +import Input from '@/components/Common/Input/Input'; +import { LIMIT_TEXT } from '@/constant/limitText'; +import { ChangeEvent, useEffect } from 'react'; +import { validation } from '@/validation/input'; +import AlertTextBox from '@/components/Common/AlertTextBox'; +import { UseFormReturn } from 'react-hook-form'; +import ImageBox from '@/components/Common/Box/ImageBox'; +import ItemImageBox from '@/components/Common/Box/ItemImageBox'; +import { WishesDataInputType } from '@/types/wishesType'; + +interface UploadPresentProps { + imageFile: File | Blob | null; + preSignedImageUrl: string; + uploadImageFile: (e: ChangeEvent) => void; + methods: UseFormReturn; +} + +export default function UploadPresent(props: UploadPresentProps) { + const { imageFile, preSignedImageUrl, uploadImageFile, methods } = props; + + useEffect(() => { + if (preSignedImageUrl) methods.setValue('imageUrl', preSignedImageUrl); + }, [preSignedImageUrl]); + + return ( + <> + + + {methods.watch('imageUrl') ? ( + + ) : ( + + + 업로드 아이콘 + + + )} + + + {imageFile && !validation.checkImageFileSize(imageFile.size) && ( + 사진은 10MB 이하로 업로드해주세요! + )} + + + + + + + + + ); +} + +const Styled = { + UploadImageBox: styled.div` + display: flex; + justify-content: center; + align-items: center; + + margin-top: 1.3rem; + + cursor: pointer; + `, + + Label: styled.label` + cursor: pointer; + `, + + FileInput: styled.input` + display: none; + `, + + ButtonWrapper: styled.div` + margin-bottom: 4.6rem; + `, +}; diff --git a/components/Wishes/WishesForm/WisehsStep2.tsx b/components/Wishes/WishesForm/WisehsStep2.tsx new file mode 100644 index 00000000..dd2ffaa4 --- /dev/null +++ b/components/Wishes/WishesForm/WisehsStep2.tsx @@ -0,0 +1,99 @@ +import Calendar from '@/components/Common/Calendar/Calendar'; +import Input from '@/components/Common/Input/Input'; +import InputContainer from '@/components/Common/Input/InputContainer'; +import TextareaBox from '@/components/Common/Input/TextareaBox'; +import { LIMIT_TEXT } from '@/constant/limitText'; +import { WishesDataInputType } from '@/types/wishesType'; +import { useEffect } from 'react'; +import { UseFormReturn } from 'react-hook-form'; + +import styled from 'styled-components'; +import WishesStepTitle from '../Common/WishesStepTitle'; +import WishesStepBtn from '../Common/WishesStepBtn'; +import { ColorSystemType } from '@/types/common/box/boxStyleType'; + +interface WishesStep2Props { + methods: UseFormReturn; + wishesStep: { + stepIndex: number; + prevState: boolean; + nextState: boolean; + changePrevState: (state: boolean) => void; + changeNextState: (state: boolean) => void; + handleNextStep: () => void; + handlePrevStep: () => void; + getNextBtnColor: (state: boolean) => ColorSystemType; + getPrevBtnColor: (state: boolean) => ColorSystemType; + }; +} + +export default function WishesStep2(props: WishesStep2Props) { + const { methods, wishesStep } = props; + + useEffect(() => { + if ( + methods.getValues('title') && + methods.getValues('hint').length !== 0 && + methods.getValues('hint').length <= 300 + ) { + wishesStep.changeNextState(true); + } else { + wishesStep.changeNextState(false); + } + }, [methods.watch()]); + + return ( +
    + + +
    + + + + + + + + + + + {/* 시작일 */} + + {/* 종료일 */} + + + +
    + + +
    + + ); +} + +const Styled = { + Container: styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + + height: 100svh; + `, + + CalendarWrapper: styled.div` + display: flex; + justify-content: space-between; + gap: 0.6rem; + + width: 100%; + `, + + ButtonWrapper: styled.div` + margin-bottom: 4.6rem; + `, +}; diff --git a/components/Wishes/WishesForm/WishesStep1.tsx b/components/Wishes/WishesForm/WishesStep1.tsx new file mode 100644 index 00000000..50c92eba --- /dev/null +++ b/components/Wishes/WishesForm/WishesStep1.tsx @@ -0,0 +1,142 @@ +import InputContainer from '@/components/Common/Input/InputContainer'; +import ItemLink from './ItemLink'; +import { LIMIT_TEXT } from '@/constant/limitText'; +import { ChangeEvent, PropsWithChildren, useEffect, useState } from 'react'; +import styled from 'styled-components'; +import UploadTypeToggleBtn from '@/components/Common/UploadTypeToggleBtn'; +import { WishesDataInputType } from '@/types/wishesType'; +import { UseFormReturn } from 'react-hook-form'; +import Input from '@/components/Common/Input/Input'; +import InputLength from '@/components/Common/Input/InputLength'; +import UploadGift from './UploadPresent'; +import SiteList from './SiteList'; +import { validation } from '@/validation/input'; +import { ColorSystemType } from '@/types/common/box/boxStyleType'; +import WishesStepTitle from '../Common/WishesStepTitle'; +import WishesStepBtn from '../Common/WishesStepBtn'; +import { rules_initial } from '@/validation/rules'; + +interface WishesStep1Props { + methods: UseFormReturn; + wishesStep: { + stepIndex: number; + prevState: boolean; + nextState: boolean; + changePrevState: (state: boolean) => void; + changeNextState: (state: boolean) => void; + handleNextStep: () => void; + handlePrevStep: () => void; + getNextBtnColor: (state: boolean) => ColorSystemType; + getPrevBtnColor: (state: boolean) => ColorSystemType; + }; + imageFile: File | Blob | null; + preSignedImageUrl: string; + uploadImageFile: (e: ChangeEvent) => void; +} + +export default function WishesStep1(props: PropsWithChildren) { + const { methods, wishesStep, imageFile, preSignedImageUrl, uploadImageFile } = props; + const [isLinkLoadType, setIsLinkLoadType] = useState(true); //false : 링크 불러오기 true : 직접 불러오기 + + const handleLoadTypeToggle = (state: boolean) => { + setIsLinkLoadType(state); + }; + + useEffect(() => { + wishesStep.changeNextState(false); + + if (isLinkLoadType) { + if ( + validation.isCorrectSite(methods.getValues('linkURL')) && + methods.getValues('initial').length !== 0 && + methods.getValues('initial').length <= 15 + ) { + wishesStep.changeNextState(true); + } + } else { + if ( + methods.getValues('initial').length !== 0 && + methods.getValues('initial').length <= 15 && + imageFile && + methods.getValues('price') !== '' && + Number(methods.getValues('price')) <= 12000000 + ) { + wishesStep.changeNextState(true); + } + } + }, [methods.watch()]); + + return ( +
    + + +
    + + + {isLinkLoadType ? ( + <> + + + + ) : ( + + )} + + + + + + +
    + + +
    + + ); +} + +const Styled = { + Container: styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + + height: 100svh; + `, + + UploadImageBox: styled.div` + display: flex; + justify-content: center; + align-items: center; + + margin-top: 1.3rem; + + cursor: pointer; + `, + + Lable: styled.label` + cursor: pointer; + `, + + FileInput: styled.input` + display: none; + `, +}; diff --git a/components/Wishes/WishesForm/index.tsx b/components/Wishes/WishesForm/index.tsx new file mode 100644 index 00000000..3dc9d709 --- /dev/null +++ b/components/Wishes/WishesForm/index.tsx @@ -0,0 +1,81 @@ +import WishesStep1 from './WishesStep1'; +import styled from 'styled-components'; +import theme from '@/styles/theme'; +import useWishesStep from '@/hooks/wishes/useWisehsStep'; +import WishesStep2 from './WisehsStep2'; +import Preview from './Preview'; +import BankInfo from './BankInfo'; + +import { useForm } from 'react-hook-form'; +import { WishesDataInputType } from '@/types/wishesType'; +import { getDate } from '@/utils/common/getDate'; +import useUploadItemInfo from '@/hooks/wishes/useUploadItemInfo'; + +export default function WishesFormContainer() { + const wishesStep = { ...useWishesStep() }; + const { imageFile, preSignedImageUrl, uploadImageFile } = useUploadItemInfo(); + + const methods = useForm({ + defaultValues: { + linkURL: '', + imageUrl: '', + price: '', + initial: '', + title: '', + hint: '', + startDate: new Date(), + endDate: getDate(new Date(), 7), + phone: '', + name: '', + bank: '', + account: '', + }, + }); + + return ( + + { + { + 1: ( + + ), + 2: , + 3: , + 4: , + }[wishesStep.stepIndex] + } + + ); +} + +const Styled = { + Container: styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + + width: 100%; + height: 100%; + `, + + Title: styled.h1` + ${theme.fonts.headline24_100}; + color: ${theme.colors.main_blue}; + + margin-left: 1rem; + `, + + TitleWrapper: styled.div` + display: flex; + + height: 2.4rem; + + margin: 2.4rem 0 2rem; + `, +}; diff --git a/components/Wishes/[id].tsx b/components/Wishes/[id].tsx new file mode 100644 index 00000000..c1f9770c --- /dev/null +++ b/components/Wishes/[id].tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { useRouter } from 'next/router'; +import Button from '../Common/Button'; +import MainView from '../Common/mainView'; +import { useGetSingleWishInfo } from '@/hooks/queries/wishes'; +import { getPublicWishes } from '@/api/public'; +import { useGetPublicWishes } from '@/hooks/queries/public'; + +export default function WishesContainer() { + const [wishesId, setWishesId] = useState(''); + const router = useRouter(); + + useEffect(() => { + if (!router.isReady) return; + setWishesId(router.query.id); + }, [router.isReady]); + + const { publicWishesData } = useGetPublicWishes(wishesId); + + const handleMoveToCakes = () => { + router.push(`/cakes/${wishesId}`); + }; + + const handleMoveToHome = () => { + router.push('/'); + }; + + return ( + + + + + + + + + ); +} + +const Styled = { + Container: styled.div` + display: flex; + flex-direction: column; + align-items: center; + `, + + ButtonWrapper: styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + + width: 100%; + height: 11rem; + + margin-bottom: 4.6rem; + `, +}; diff --git a/constant/alertMessage.ts b/constant/alertMessage.ts new file mode 100644 index 00000000..7b74bdf0 --- /dev/null +++ b/constant/alertMessage.ts @@ -0,0 +1,3 @@ +export const ALERT_ACCOUNT_HYPEN_MESSAGE = '계좌번호는 (-)없이 입력해주세요'; +export const ALERT_ACCOUNT_LENGTH = '10~14자리의 계좌번호를 입력해주세요'; +export const ALERT_PHONE_FORM = '10~14자리의 계좌번호를 입력해주세요'; diff --git a/constant/bankList.ts b/constant/bankList.ts new file mode 100644 index 00000000..096acf32 --- /dev/null +++ b/constant/bankList.ts @@ -0,0 +1,16 @@ +import bankImgs from '@/public/assets/images'; +import { BankListType } from '@/types/bankListType'; + +export const BANK_LIST: BankListType[] = []; + +for (let i = 0; i <= 32; i++) { + BANK_LIST.push({ + name: + ['NH농협', '카카오뱅크', 'KB국민', '신한', '우리', '토스뱅크', 'IBK기업', '하나', '새마을', + '부산', '대구', '케이뱅크', '신협', '우체국', 'SC제일', "경남", "광주", "수협", "전북", + "저축은행", "제주", "씨티", "KDB산업", "산림조합", "SBI저축은행", "BOA", "중국", "HSBC", + "중국공상", "도이치", "JP모건", "BNP파리바", "중국건설"][i], + bankNumber: i + 1, + logo: bankImgs[`bank${i + 1}Img`], + }); +} \ No newline at end of file diff --git a/constant/cakeList.ts b/constant/cakeList.ts new file mode 100644 index 00000000..1454613c --- /dev/null +++ b/constant/cakeList.ts @@ -0,0 +1,110 @@ +import { + BeefCakeImg, + ChickenCakeImg, + CoffeeCakeImg, + PoopCakeImg, + SushiCakeImg, + FlowerCakeImg, + PerfumeCakeImg, + VitaminCakeImg, + BeefCakeDetailImg, + ChickenCakeDetailImg, + CoffeeCakeDetailImg, + PoopCakeDetailImg, + SushiCakeDetailImg, + FlowerCakeDetailImg, + PerfumeCakeDetailImg, + VitaminCakeDetailImg, + BeefCakeThanksImg, + ChickenCakeThanksImg, + CoffeeCakeThanksImg, + PoopCakeThanksImg, + SushiCakeThanksImg, + FlowerCakeThanksImg, + PerfumeCakeThanksImg, + VitaminCakeThanksImg, + BeefCakeSmallImg, + VitaminCakeSmallImg, + ChickenCakeSmallImg, + SushiCakeSmallImg, + FlowerCakeSmallImg, + PerfumeCakeSmallImg, + PoopCakeSmallImg, + CoffeeCakeSmallImg +} from '@/public/assets/images'; +import { CakeListType } from '@/types/cakes/cakeListType'; + +export const CAKE_LIST: CakeListType[] = [ + { + name: '달콤 커피 케이크', + price: 4900, + cakeImage: CoffeeCakeImg, + detailImage: CoffeeCakeDetailImg, + thanksImage: CoffeeCakeThanksImg, + smallImage: CoffeeCakeSmallImg, + cakeNumber: 2, + }, + { + name: '상큼 비타민 케이크', + price: 9900, + cakeImage: VitaminCakeImg, + detailImage: VitaminCakeDetailImg, + thanksImage: VitaminCakeThanksImg, + smallImage: VitaminCakeSmallImg, + cakeNumber: 3, + }, + { + name: '바사삭 치킨 케이크', + price: 17900, + cakeImage: ChickenCakeImg, + detailImage: ChickenCakeDetailImg, + thanksImage: ChickenCakeThanksImg, + smallImage: ChickenCakeSmallImg, + cakeNumber: 4, + }, + { + name: '장인 초밥 케이크', + price: 25900, + cakeImage: SushiCakeImg, + detailImage: SushiCakeDetailImg, + thanksImage: SushiCakeThanksImg, + smallImage: SushiCakeSmallImg, + cakeNumber: 5, + }, + { + name: '샤랄라 꽃 케이크', + price: 38900, + cakeImage: FlowerCakeImg, + detailImage: FlowerCakeDetailImg, + thanksImage: FlowerCakeThanksImg, + smallImage: FlowerCakeSmallImg, + cakeNumber: 6, + }, + { + name: '몸보신 한우 케이크', + price: 46900, + cakeImage: BeefCakeImg, + detailImage: BeefCakeDetailImg, + thanksImage: BeefCakeThanksImg, + smallImage: BeefCakeSmallImg, + cakeNumber: 7, + }, + { + name: '킁킁 향수 케이크', + price: 55900, + cakeImage: PerfumeCakeImg, + detailImage: PerfumeCakeDetailImg, + thanksImage: PerfumeCakeThanksImg, + smallImage: PerfumeCakeSmallImg, + cakeNumber: 8, + }, + { + name: '구리구리 똥 케이크', + price: 0, + cakeImage: PoopCakeImg, + detailImage: PoopCakeDetailImg, + thanksImage: PoopCakeThanksImg, + smallImage: PoopCakeSmallImg, + cakeNumber: 1, + }, +]; diff --git a/constant/dateList.ts b/constant/dateList.ts new file mode 100644 index 00000000..70370fbc --- /dev/null +++ b/constant/dateList.ts @@ -0,0 +1,2 @@ +export const MONTHS = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']; +export const TODAY = new Date(); diff --git a/constant/imageFileSize.ts b/constant/imageFileSize.ts new file mode 100644 index 00000000..b250c04b --- /dev/null +++ b/constant/imageFileSize.ts @@ -0,0 +1 @@ +export const IMAGE_FILE_SIZE = 10 * 1024 * 1024; diff --git a/constant/limitText.ts b/constant/limitText.ts new file mode 100644 index 00000000..277f2e34 --- /dev/null +++ b/constant/limitText.ts @@ -0,0 +1,7 @@ +import { LimitTextType } from '@/types/limitTestType'; + +export const LIMIT_TEXT: LimitTextType = { + '15': 15, + '20': 20, + DESCRIPTION: 300, +}; diff --git a/constant/path.ts b/constant/path.ts new file mode 100644 index 00000000..66309c64 --- /dev/null +++ b/constant/path.ts @@ -0,0 +1,27 @@ +const PATH = { + API: 'api', + V1: 'v1', + CAKES: 'cakes', + PAY: 'pay', + AUTH: 'auth', + WISHES: 'wishes', + MAIN: 'main', + APPROVE: 'approve', + READY: 'ready', + PG_TOKEN: 'pg_token', + PRESENT: 'present', + INFO: 'info', + KAKAO: 'kakao', + CALLBACK: 'callback', + USER: 'user', + ACCOUNT: 'account', + PROGRESS: 'progress', + FILE: 'file', + FILE_NAME: 'fileName', + PUBLIC: 'public', + REDIRECT_URI: 'redirectUri', +}; + +export default PATH; + +//페이지 경로 diff --git a/constant/queryKey.ts b/constant/queryKey.ts new file mode 100644 index 00000000..baf3f1ed --- /dev/null +++ b/constant/queryKey.ts @@ -0,0 +1,14 @@ +export const QUERY_KEY = { + ITEM_DATA: 'itemData', + WISHES_DATA: 'wishesData', + PAYREADY: 'payReady', + PG_TOKEN: 'pgToken', + USER: 'user', + ACCOUNT: 'account', + PROGRESS: 'progress', + CAKES_COUNT: 'cakesCount', + CAKE_LETTERS: 'cakeLetters', + WISH_LINKS: 'wishLinks', + ONE_WISH: 'oneWish', + PRE_SIGNED_URL: 'preSignedURL', +}; diff --git a/constant/siteList.ts b/constant/siteList.ts new file mode 100644 index 00000000..3b37ffe9 --- /dev/null +++ b/constant/siteList.ts @@ -0,0 +1,12 @@ +import { TwentynineLogoImg } from '@/public/assets/images'; +import { SiteDataType } from '@/types/siteDataType'; + +export const SITE_LIST = { + TWENTY_NINE: { + NAME: 'twentynine', + LINK: 'https://www.29cm.co.kr/', + LOGO: TwentynineLogoImg, + IMAGE_TAG: 'ewptmlp5', + PRICE_TAG: 'ent7twr4', + }, +}; diff --git a/constant/snsList.ts b/constant/snsList.ts new file mode 100644 index 00000000..c34e8162 --- /dev/null +++ b/constant/snsList.ts @@ -0,0 +1,23 @@ + +import { KaKaoLogoImg, InstaLogoImg, FacebookLogoImg, TwitterLogoImg } from '@/public/assets/images'; +import { SNSListType } from '@/types/snsListType'; + + +export const SNS_LIST: SNSListType[] = [ + { + name: 'KakaoTalk', + logo: KaKaoLogoImg, + }, + // { + // name: 'Instagram', + // logo: InstaLogoImg, + // }, + { + name: 'FaceBook', + logo: FacebookLogoImg, + }, + { + name: 'Twitter', + logo: TwitterLogoImg, + }, +]; \ No newline at end of file diff --git a/constant/wishesStatus.ts b/constant/wishesStatus.ts new file mode 100644 index 00000000..acba9385 --- /dev/null +++ b/constant/wishesStatus.ts @@ -0,0 +1,5 @@ +export const WISHES_STATUS = { + BEFORE: 'BEFORE', + WHILE: 'WHILE', + END: 'END', +}; diff --git a/hooks/cakes/useSelectCakes.ts b/hooks/cakes/useSelectCakes.ts new file mode 100644 index 00000000..a73aeae8 --- /dev/null +++ b/hooks/cakes/useSelectCakes.ts @@ -0,0 +1,15 @@ +import { CAKE_LIST } from "@/constant/cakeList"; +import { CakeListType } from "@/types/cakes/cakeListType"; +import { useState } from "react"; + +export default function useSelectCakes() { + const [selectedCake, setSelectedCake] = useState(CAKE_LIST[0]); + const [selectedIndex, setSelectedIndex] = useState(0); + + const selectCake = (index: number) => { + setSelectedCake(CAKE_LIST[index]); + setSelectedIndex(index); + }; + + return {selectedCake,selectedIndex,selectCake} +} diff --git a/hooks/common/useCheckBox.tsx b/hooks/common/useCheckBox.tsx new file mode 100644 index 00000000..4e29f02e --- /dev/null +++ b/hooks/common/useCheckBox.tsx @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react'; + +export default function useCheckBox() { + const [checkBoxState, setCheckBoxState] = useState(false); + + useEffect(() => { + setCheckBoxState(false); + }, []); + + const handleChangeCheckBoxState = () => { + setCheckBoxState(!checkBoxState); + }; + + return { checkBoxState, handleChangeCheckBoxState }; +} diff --git a/hooks/common/useDate.ts b/hooks/common/useDate.ts new file mode 100644 index 00000000..c33198d4 --- /dev/null +++ b/hooks/common/useDate.ts @@ -0,0 +1,25 @@ +export default function useDate(date: Date | null): string { + // 선택한 날짜가 없을 시 현재 날짜로 설정 + if (!date) { + date = new Date(); + } + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; +} + +export const convertDateFormat = (data: string | undefined): string => { + if (!data) { + return ''; + } + + const date = new Date(data); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}.${month}.${day}`; +}; diff --git a/hooks/common/useInput.tsx b/hooks/common/useInput.tsx new file mode 100644 index 00000000..6b17427f --- /dev/null +++ b/hooks/common/useInput.tsx @@ -0,0 +1,19 @@ +import { useState, useCallback } from 'react'; + +export default function useInput(initValue: string, inputLimit?: number) { + const [value, setValue] = useState(initValue); + + const handleChangeInput = useCallback( + (e: React.ChangeEvent | React.ChangeEvent) => { + if (inputLimit) { + if (e.currentTarget.value.length <= inputLimit) { + setValue(e.currentTarget.value); + } + } else { + setValue(e.currentTarget.value); + } + }, + [], + ); + return [value, handleChangeInput, setValue] as const; +} diff --git a/hooks/common/useKakaoShare.ts b/hooks/common/useKakaoShare.ts new file mode 100644 index 00000000..e7961c57 --- /dev/null +++ b/hooks/common/useKakaoShare.ts @@ -0,0 +1,27 @@ +export default function useKakaoShare(nickname: string, link: string) { + // if (window.Kakao) { + window.Kakao.Share.sendDefault({ + objectType: 'feed', + content: { + title: `${nickname}님의 생일선물을 고민하고 있다면?`, + description: + `고민할 필요없이 이 귀여운 케이크를 선물해 ${nickname}님의 생일 펀딩에 참여해보세요!`, + imageUrl: + 'https://ifh.cc/g/wWJNBF.jpg', + link: { + mobileWebUrl: link, + webUrl: link, + }, + }, + buttons: [ + { + title: '자세히 보기', + link: { + mobileWebUrl: link, + webUrl: link, + }, + }, + ], + }); + // } +} diff --git a/hooks/common/useModal.tsx b/hooks/common/useModal.tsx new file mode 100644 index 00000000..60b60093 --- /dev/null +++ b/hooks/common/useModal.tsx @@ -0,0 +1,14 @@ +import { useState } from 'react'; + +export default function useModal() { + const [isOpen, setIsOpen] = useState(false); + + const handleToggle = () => { + setIsOpen(!isOpen); + }; + + return { + isOpen, + handleToggle, + }; +} diff --git a/hooks/common/useSelect.ts b/hooks/common/useSelect.ts new file mode 100644 index 00000000..17530410 --- /dev/null +++ b/hooks/common/useSelect.ts @@ -0,0 +1,5 @@ +import { useState } from 'react'; + +export default function useSelect() { + const [selectedItem, setSelectedItem] = useState(); +} diff --git a/hooks/queries/auth.ts b/hooks/queries/auth.ts new file mode 100644 index 00000000..0425339d --- /dev/null +++ b/hooks/queries/auth.ts @@ -0,0 +1,48 @@ +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import { useMutation } from 'react-query'; +import { useSetRecoilState } from 'recoil'; +import { LoginUserInfo } from '@/recoil/auth/loginUserInfo'; +import { postAuthKakao } from '@/api/auth'; + +export function useAuthKakao() { + const [accessToken, setAccessToken] = useState(''); + const [refreshToken, setRefreshToken] = useState(''); + const [nickName, setNickname] = useState(''); + + const setLoginUserInfo = useSetRecoilState(LoginUserInfo); + + const router = useRouter(); + const { code: authCode } = router.query; + + const { mutate: kakaoLoginMutate } = useMutation(() => postAuthKakao(authCode as string), { + onSuccess: (data) => { + const { nickName, accessToken, refreshToken } = data; + + setLoginUserInfo((prev) => ({ + ...prev, + nickName: nickName, + accessToken: accessToken, + refreshToken: refreshToken, + })); + + setAccessToken(accessToken); + setRefreshToken(refreshToken); + setNickname(nickName); + + localStorage.setItem('accessToken', accessToken); + }, + onError: (error) => { + alert('카카오 로그인에 실패하셨습니다. : ' + error); + router.back(); + }, + }); + + useEffect(() => { + if (authCode) { + kakaoLoginMutate(); + } + }, [authCode, kakaoLoginMutate]); + + return { accessToken, refreshToken, nickName }; +} diff --git a/hooks/queries/cakes.ts b/hooks/queries/cakes.ts new file mode 100644 index 00000000..bda08be4 --- /dev/null +++ b/hooks/queries/cakes.ts @@ -0,0 +1,53 @@ +import { getCakesInfo, getCakesResult } from '@/api/cakes'; +import { QUERY_KEY } from '@/constant/queryKey'; +import { CakeLettersType } from '@/types/letters/cakeLettersType'; +import { useState } from 'react'; +import { useQuery } from 'react-query'; + +/** + * 해당 소원에 대한 케이크 조회 + */ +export function useGetCakesInfo( + wishId: string | string[] | undefined, + cakeId: string | string[] | undefined, +) { + const [lettersData, setLettersData] = useState([]); + + const { data } = useQuery(QUERY_KEY.CAKE_LETTERS, async () => getCakesInfo(wishId, cakeId), { + onSuccess: (data) => { + setLettersData(data); + }, + enabled: wishId !== '' && cakeId !== '', + }); + + const lettersSum = lettersData ? lettersData.length : 0; + + return { data, lettersData, lettersSum }; +} + +/** + * 해당 소원에 대한 모든 케이크 리스트 결과 조회 + */ + +export function useGetCakesResult(wishId: string | string[] | undefined) { + const [total, setTotal] = useState(0); + // const setCakesCountData = useSetRecoilState(CakesCountData); + + const { data: cakesCount } = useQuery(QUERY_KEY.CAKES_COUNT, async () => getCakesResult(wishId), { + onSuccess: (data) => { + // setCakesCountData(data); + if (Array.isArray(data)) { + const cakesTotal = calculateTotal(data.map((cake: { count: number }) => cake.count)); + setTotal(cakesTotal); + } + }, + enabled: wishId !== '', + }); + + const calculateTotal = (cakeCounts: number[]): number => { + const total = cakeCounts.reduce((sum, count) => sum + count, 0); + return total; + }; + + return { cakesCount, total }; +} diff --git a/hooks/queries/public.ts b/hooks/queries/public.ts new file mode 100644 index 00000000..c7dce5bb --- /dev/null +++ b/hooks/queries/public.ts @@ -0,0 +1,21 @@ +import { getPublicWishes, postPublicCakes } from '@/api/public'; +import { PostPublicCakesRequestType } from '@/types/api/request'; +import { useMutation, useQuery } from 'react-query'; + +export function useGetPublicWishes(wishId: string | string[] | undefined) { + const { data: publicWishesData } = useQuery('publicWishes', () => getPublicWishes(wishId), { + enabled: wishId !== '', + }); + + return { publicWishesData }; +} + +export function usePostPublicCakes(parameter: PostPublicCakesRequestType) { + const { + mutate: postPublicCakesData, + data: cakesResultData, + ...restProps + } = useMutation(() => postPublicCakes(parameter)); + + return { postPublicCakesData, cakesResultData, ...restProps }; +} diff --git a/hooks/queries/user.ts b/hooks/queries/user.ts new file mode 100644 index 00000000..342effa4 --- /dev/null +++ b/hooks/queries/user.ts @@ -0,0 +1,22 @@ +import { getUserAccount, patchUserAccount } from '@/api/user'; +import { QUERY_KEY } from '@/constant/queryKey'; +import { WishesDataInputType } from '@/types/wishesType'; +import router from 'next/router'; +import { UseFormReturn } from 'react-hook-form'; +import { useMutation, useQuery } from 'react-query'; + +export function usePatchUserAccount(methods: UseFormReturn) { + const { mutate: patchUserAccountData } = useMutation(() => patchUserAccount(methods), { + onSuccess: () => { + router.push('/wishes/share'); + }, + }); + + return { patchUserAccountData }; +} + +export function useGetUserAccount() { + const { data: userAccountData } = useQuery(QUERY_KEY.ITEM_DATA, getUserAccount); + + return { userAccountData }; +} diff --git a/hooks/queries/wishes.ts b/hooks/queries/wishes.ts new file mode 100644 index 00000000..ee1dcc0f --- /dev/null +++ b/hooks/queries/wishes.ts @@ -0,0 +1,127 @@ +import { + deleteWishes, + getMainProgressData, + getProgressWishInfo, + getSingleWishInfo, + getWishes, + patchProgressWishInfo, + postWishes, +} from '@/api/wishes'; +import { QUERY_KEY } from '@/constant/queryKey'; +import { LoginUserInfo } from '@/recoil/auth/loginUserInfo'; +import { WishesDataInputType } from '@/types/wishesType'; +import { WishLinksType } from '@/types/links/wishLinksType'; +import router from 'next/router'; +import { useState } from 'react'; +import { UseFormReturn } from 'react-hook-form'; +import { useMutation, useQuery } from 'react-query'; +import { useSetRecoilState } from 'recoil'; + +/** + * 모든 소원리스트 조회 + */ +export function useGetWishes() { + const { data } = useQuery(QUERY_KEY.WISHES_DATA, getWishes); + + return data; +} + +/** + * 진행중인 소원 정보 수정 + */ +export function usePatchWishes(methods: UseFormReturn) { + const { mutate: patchWishesData } = useMutation(() => patchProgressWishInfo(methods), { + onSuccess: () => { + alert('수정성공'); + router.back(); + }, + }); + + return { patchWishesData }; +} + +/** + * 소원링크 생성 + */ +export function usePostWishes(methods: UseFormReturn) { + const setLoginUserInfo = useSetRecoilState(LoginUserInfo); + + const { mutate: postWishesData } = useMutation(() => postWishes(methods), { + onSuccess: (data) => { + setLoginUserInfo((prevData) => ({ + ...prevData, + wishesId: data.data.data, + })); + }, + }); + + return { postWishesData }; +} + +/** + * 소원링크 삭제 + */ +export function useDeleteWishes() { + const mutation = useMutation((wishesData: number[]) => deleteWishes(wishesData)); + + return mutation; +} + +/** + * 진행중인 소원 정보 조회 + */ +export function useGetWishesProgress() { + const { data: wishesProgressData, ...restProps } = useQuery( + QUERY_KEY.PROGRESS, + getProgressWishInfo, + ); + + return { wishesProgressData, ...restProps }; +} + +/** + * 진행중인 소원 조회(메인화면) + */ +export function useGetMainProgressData() { + const { data: progressData, ...restProps } = useQuery(QUERY_KEY.PROGRESS, getMainProgressData); + + return { progressData, ...restProps }; +} + +/** + * 소원 단건 조회 + */ +export function useGetSingleWishInfo(wishId: string | string[] | undefined) { + const { data: wishData } = useQuery(QUERY_KEY.ONE_WISH, async () => getSingleWishInfo(wishId), { + onError: (error: any) => { + if (error.response && error.response.status === 403) { + alert('해당 소원에 접근할 수 없습니다.'); + router.back(); + } + }, + enabled: wishId !== '', + }); + + return { wishData }; +} + +export function useGetWishLinks() { + const [noWishes, setNoWishes] = useState(true); + const [wishLinks, setWishLinks] = useState([]); + + const { data, isSuccess } = useQuery( + QUERY_KEY.WISH_LINKS, + async () => getWishes(), + { + onSuccess: (wishLinks) => { + if (wishLinks.length > 0) { + setNoWishes(false); + } + + setWishLinks(wishLinks); + }, + }, + ); + + return { wishLinks, isSuccess, noWishes }; +} diff --git a/hooks/wishes/useUploadItemInfo.ts b/hooks/wishes/useUploadItemInfo.ts new file mode 100644 index 00000000..100dfe73 --- /dev/null +++ b/hooks/wishes/useUploadItemInfo.ts @@ -0,0 +1,43 @@ +import { getPresignedURL, uploadPresignedURL } from '@/api/file'; +import { QUERY_KEY } from '@/constant/queryKey'; +import { validation } from '@/validation/input'; +import { useEffect, useState } from 'react'; +import { useMutation, useQuery } from 'react-query'; + +export default function useUploadItemInfo() { + const [imageFile, setImageFile] = useState(null); + const [preSignedImageUrl, setPreSignedImageUrl] = useState(''); + + const { data, refetch } = useQuery( + QUERY_KEY.PRE_SIGNED_URL, + () => getPresignedURL(imageFile?.name), + { + enabled: + imageFile !== null && + validation.checkImageFileSize(imageFile.size) && + imageFile?.name !== '', + }, + ); + + const { mutate } = useMutation(() => uploadPresignedURL(data?.data?.data?.signedUrl, imageFile), { + onSuccess: () => { + const S3_URL = `https://wish-image-bucket.s3.ap-northeast-2.amazonaws.com/${data?.data?.data.filename}`; + setPreSignedImageUrl(S3_URL); + }, + }); + + useEffect(() => { + data?.data?.success && mutate(); + }, [data]); + + useEffect(() => { + imageFile && validation.checkImageFileSize(imageFile.size) && refetch(); + }, [imageFile]); + + function uploadImageFile(e: React.ChangeEvent) { + const imageFile = e.target.files && e.target.files[0]; + imageFile && setImageFile(imageFile); + } + + return { imageFile, preSignedImageUrl, setPreSignedImageUrl, uploadImageFile }; +} diff --git a/hooks/wishes/useWisehsStep.ts b/hooks/wishes/useWisehsStep.ts new file mode 100644 index 00000000..432936c0 --- /dev/null +++ b/hooks/wishes/useWisehsStep.ts @@ -0,0 +1,60 @@ +import { ColorSystemType } from '@/types/common/box/boxStyleType'; +import { useEffect, useState } from 'react'; + +export default function useWishesStep() { + const [stepIndex, setStepIndex] = useState(1); + const [prevState, setPrevState] = useState(false); + const [nextState, setNextState] = useState(false); + + useEffect(() => { + if (stepIndex === 1) { + setPrevState(false); + } else { + setPrevState(true); + } + }, [stepIndex]); + + const handleNextStep = () => { + if (stepIndex < 4) { + setStepIndex((prev) => (prev += 1)); + } + }; + + const handlePrevStep = () => { + if (stepIndex > 1) { + setStepIndex((prev) => (prev -= 1)); + } + + if (stepIndex === 2) { + setPrevState(false); + } + }; + + const changePrevState = (state: boolean) => { + setPrevState(state); + }; + + const changeNextState = (state: boolean) => { + setNextState(state); + }; + + const getNextBtnColor = (state: boolean): ColorSystemType => { + return state ? 'mainBlue_white' : 'gray1_white'; + }; + + const getPrevBtnColor = (state: boolean): ColorSystemType => { + return state ? 'pastelBlue_mainBlue' : 'gray1_white'; + }; + + return { + stepIndex, + prevState, + nextState, + changePrevState, + changeNextState, + handleNextStep, + handlePrevStep, + getNextBtnColor, + getPrevBtnColor, + }; +} diff --git a/next.config.js b/next.config.js new file mode 100644 index 00000000..201c01e2 --- /dev/null +++ b/next.config.js @@ -0,0 +1,48 @@ +/** @type {import('next').NextConfig} */ + +const withBundleAnalyzer = require('@next/bundle-analyzer')({ + enabled: process.env.ANALYZE === 'true', +}); + +module.exports = withBundleAnalyzer({ + compress: true, + webpack(config, { webpack }) { + const prod = process.env.NODE_ENV === 'production'; + const plugins = [...config.plugins]; + return { + ...config, + mode: prod ? 'producton' : 'development', + devtool: prod ? 'hidden-source-map' : 'eval', + plugins, + }; + }, +}); + +const nextConfig = { + reactStrictMode: true, + swcMinify: true, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'img.29cm.co.kr', + port: '', + pathname: '/**', + }, + ], + domains: [ + 'img.29cm.co.kr', + 'img2.29cm.co.kr', + 'product.29cm.co.kr', + 'localhost', + 'wish-image-bucket.s3.ap-northeast-2.amazonaws.com', + 'shopping-phinf.pstatic.net', + ], + }, + rules: { + test: /\.svg$/, + use: ['@svgr/webpack'], + }, +}; + +module.exports = nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 00000000..814cd898 --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "test", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "export PORT=8080 && next dev", + "build": "ANALYZE=true NODE_ENV=production next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@next/bundle-analyzer": "^13.4.19", + "@types/node": "18.15.11", + "@types/react": "^18.2.45", + "@types/react-dom": "18.0.11", + "autoprefixer": "10.4.14", + "axios": "^1.3.5", + "cheerio": "^1.0.0-rc.12", + "eslint": "^8.38.0", + "eslint-config-next": "13.3.0", + "next": "13.3.0", + "next-pwa": "^5.6.0", + "postcss": "8.4.22", + "qs": "^6.11.2", + "react": "18.2.0", + "react-datepicker": "^4.12.0", + "react-dom": "18.2.0", + "react-hook-form": "^7.47.0", + "react-query": "^3.39.3", + "react-responsive": "^9.0.2", + "react-spinners": "^0.13.8", + "recoil": "^0.7.7", + "recoil-persist": "^4.2.0", + "serverless": "^2.59.0", + "styled-component": "^2.8.0", + "styled-components": "^5.3.9", + "styled-reset": "^4.4.6", + "tailwindcss": "3.3.1", + "typescript": "5.0.4" + }, + "devDependencies": { + "@babel/eslint-parser": "^7.21.3", + "@svgr/webpack": "^8.0.1", + "@types/qs": "^6.9.7", + "@types/react-datepicker": "^4.11.2", + "@types/styled-components": "^5.1.26", + "@typescript-eslint/eslint-plugin": "^5.59.0", + "@typescript-eslint/parser": "^5.59.0", + "babel-plugin-styled-components": "^2.1.1", + "eslint-plugin-react": "^7.32.2" + }, + "resolutions": { + "@types/react": "^18.2.45" + } +} diff --git a/pages/_app.tsx b/pages/_app.tsx new file mode 100644 index 00000000..c8d7d061 --- /dev/null +++ b/pages/_app.tsx @@ -0,0 +1,101 @@ +import { QueryClient, QueryClientProvider } from 'react-query'; +import type { AppProps } from 'next/app'; +import Head from 'next/head'; +import { RecoilRoot } from 'recoil'; +import GlobalStyle from '../styles/GlobalStyle'; +import styled, { ThemeProvider } from 'styled-components'; +import theme from '@/styles/theme'; +import { useEffect } from 'react'; +import Script from 'next/script'; +import { useRouter } from 'next/router'; + +declare global { + interface Window { + Kakao: any; + } +} + +export default function App({ Component, pageProps }: AppProps) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 0, + refetchOnWindowFocus: false, + }, + }, + }); + + const router = useRouter(); + + // const setVh = () => { + // document.documentElement.style.setProperty('--vh', `${window.innerHeight}px`); + // }; + + useEffect(() => { + if (window.Kakao && !window.Kakao.isInitialized()) { + window.Kakao.init(process.env.NEXT_PUBLIC_KAKAO_JAVASCRIPT_KEY); + } + //높이 resize + // window.addEventListener('resize', setVh); + // setVh(); + }, []); + + // useEffect(() => { + // const handleRouteChange = (url: string) => { + // gtag.pageview(url); + // }; + // router.events.on('routeChangeComplete', handleRouteChange); + // router.events.on('hashChangeComplete', handleRouteChange); + // return () => { + // router.events.off('routeChangeComplete', handleRouteChange); + // router.events.off('hashChangeComplete', handleRouteChange); + // }; + // }, [router.events]); + + return ( + <> + {/* Global Site Tag (gtag.js) - Google Analytics */} + + + + + + + + + {/* */} +