diff --git a/src/assets/index.ts b/src/assets/index.ts index 4ca00b96..89f74f11 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -151,6 +151,8 @@ export { default as LogoMedium } from './svg/logo-medium.svg'; export { default as DefaultPerson } from './svg/default-person.svg'; export { default as MobileDefaultPerson } from './svg/mobile-default-person.svg'; export { default as MobilePlus } from './svg/mobile-plus.svg'; +export { default as MobileReport } from './svg/mobile-report.svg'; +export { default as PickIcon } from './svg/pick-icon.svg'; // TODO: 이전 SVG export { default as Email } from './svg/email.svg'; diff --git a/src/assets/svg/angle-small-down.svg b/src/assets/svg/angle-small-down.svg index 2b064507..d5231249 100644 --- a/src/assets/svg/angle-small-down.svg +++ b/src/assets/svg/angle-small-down.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/svg/angle-small-up.svg b/src/assets/svg/angle-small-up.svg index 9adf7499..82979ba4 100644 --- a/src/assets/svg/angle-small-up.svg +++ b/src/assets/svg/angle-small-up.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/svg/mobile-report.svg b/src/assets/svg/mobile-report.svg new file mode 100644 index 00000000..95dbf862 --- /dev/null +++ b/src/assets/svg/mobile-report.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/svg/pick-icon.svg b/src/assets/svg/pick-icon.svg new file mode 100644 index 00000000..df7e7437 --- /dev/null +++ b/src/assets/svg/pick-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/atoms/ProfileIcon/ProfileIcon.style.ts b/src/components/atoms/ProfileIcon/ProfileIcon.style.ts index ee8ff2f3..26280777 100644 --- a/src/components/atoms/ProfileIcon/ProfileIcon.style.ts +++ b/src/components/atoms/ProfileIcon/ProfileIcon.style.ts @@ -1,19 +1,36 @@ import { css } from '@emotion/react'; -export const profileWrapper = (size: 'small' | 'large') => - css({ - all: 'unset', - width: size === 'small' ? '40px' : '142px', - height: size === 'small' ? '40px' : '142px', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - borderRadius: '50%', - overflow: 'hidden', - backgroundSize: 'cover', - backgroundPosition: 'center', - cursor: 'pointer', - }); +export const profileWrapper = css({ + all: 'unset', + display: 'flex', + flexShrink: 0, + alignItems: 'center', + justifyContent: 'center', + borderRadius: '50%', + overflow: 'hidden', + backgroundSize: 'cover', + backgroundPosition: 'center', + cursor: 'pointer', +}); + +export const getProfileSize = (size: 'extraSmall' | 'small' | 'large') => { + const style = { + large: css({ + width: '142px', + height: '142px', + }), + small: css({ + width: '40px', + height: '40px', + }), + extraSmall: css({ + width: '24px', + height: '24px', + }), + }; + + return style[size]; +}; export const profileImage = css({ width: '100%', diff --git a/src/components/atoms/ProfileIcon/ProfileIcon.tsx b/src/components/atoms/ProfileIcon/ProfileIcon.tsx index 54d0ae37..7ce63ce4 100644 --- a/src/components/atoms/ProfileIcon/ProfileIcon.tsx +++ b/src/components/atoms/ProfileIcon/ProfileIcon.tsx @@ -1,17 +1,21 @@ import React, { ComponentPropsWithRef, ForwardedRef, forwardRef } from 'react'; import { NormalProfile } from '@/assets'; -import { profileWrapper, profileImage } from './ProfileIcon.style'; +import { + profileWrapper, + profileImage, + getProfileSize, +} from './ProfileIcon.style'; export interface ProfileProps extends ComponentPropsWithRef<'button'> { interaction: 'default' | 'custom'; imgUrl?: string; - size?: 'small' | 'large'; + size?: 'large' | 'small' | 'extraSmall'; } interface ProfilePropsWithImage extends ComponentPropsWithRef<'button'> { interaction: 'custom'; imgUrl: string; - size?: 'small' | 'large'; + size?: 'large' | 'small' | 'extraSmall'; } const ProfileIcon = ( @@ -25,12 +29,22 @@ const ProfileIcon = ( ) => { const profileComponents = { normal: ( - profile ), diff --git a/src/components/mobile/atoms/Button/Button.style.ts b/src/components/mobile/atoms/Button/Button.style.ts index 64841939..6569a846 100644 --- a/src/components/mobile/atoms/Button/Button.style.ts +++ b/src/components/mobile/atoms/Button/Button.style.ts @@ -24,6 +24,18 @@ export const getVariantStyling = ( backgroundColor: color.GY[5], color: color.GY[1], }), + outlineHighlightR: css({ + border: `2px solid ${color.PINK}`, + borderRadius: '10px', + backgroundColor: 'transparent', + color: color.RED, + }), + outlineHighlightB: css({ + border: `2px solid ${color.SKYBLUE}`, + borderRadius: '10px', + backgroundColor: 'transparent', + color: color.BLUE, + }), }; return style[variant]; @@ -58,6 +70,22 @@ export const getSizeByVariantStyling = ( height: '34px', }), }, + outlineHighlightR: { + large: css({}), + medium: css(typo.Comment.SemiBold, { + width: '134px', + height: '72px', + padding: '0 21px', + }), + }, + outlineHighlightB: { + large: css({}), + medium: css(typo.Comment.SemiBold, { + width: '134px', + height: '72px', + padding: '0 21px', + }), + }, }; return style[variant][size]; @@ -68,6 +96,6 @@ export const buttonStyling = css({ justifyContent: 'center', alignItems: 'center', border: 'none', - whiteSpace: 'nowrap', + whiteSpace: 'normal', cursor: 'pointer', }); diff --git a/src/components/mobile/atoms/Button/Button.tsx b/src/components/mobile/atoms/Button/Button.tsx index fe2b09a2..44804307 100644 --- a/src/components/mobile/atoms/Button/Button.tsx +++ b/src/components/mobile/atoms/Button/Button.tsx @@ -4,7 +4,12 @@ import * as S from './Button.style'; export interface ButtonProps extends ComponentPropsWithRef<'button'> { size?: 'large' | 'medium'; - variant?: 'primary' | 'roundPrimary' | 'outlineShadow'; + variant?: + | 'primary' + | 'roundPrimary' + | 'outlineShadow' + | 'outlineHighlightR' + | 'outlineHighlightB'; active?: boolean; } diff --git a/src/components/mobile/atoms/SummaryItem/SummaryItem.style.ts b/src/components/mobile/atoms/SummaryItem/SummaryItem.style.ts new file mode 100644 index 00000000..6120f042 --- /dev/null +++ b/src/components/mobile/atoms/SummaryItem/SummaryItem.style.ts @@ -0,0 +1,26 @@ +import { css } from '@emotion/react'; +import color from '@/styles/color'; +import typo from '@/styles/typo'; + +export const summaryItemStyling = css(typo.Mobile.Text.Medium_12, { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + padding: '4px 20px 4px 5px', + gap: '13px', + backgroundColor: color.WT_VIOLET, + color: color.BK, + borderRadius: '30px', +}); + +export const numberItemStyling = css(typo.Mobile.Text.Medium_12, { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + flexShrink: 0, + width: '18px', + height: '18px', + backgroundColor: color.MAIN, + color: color.WT, + borderRadius: '50%', +}); diff --git a/src/components/mobile/atoms/SummaryItem/SummaryItem.tsx b/src/components/mobile/atoms/SummaryItem/SummaryItem.tsx new file mode 100644 index 00000000..6309c3f4 --- /dev/null +++ b/src/components/mobile/atoms/SummaryItem/SummaryItem.tsx @@ -0,0 +1,16 @@ +import React, { ReactNode } from 'react'; +import { numberItemStyling, summaryItemStyling } from './SummaryItem.style'; + +export interface SummaryItemProps { + itemNumber?: '1' | '2' | '3'; + children?: ReactNode; +} + +const SummaryItem = ({ itemNumber = '1', children }: SummaryItemProps) => ( +
+
{itemNumber}
+ {children} +
+); + +export default SummaryItem; diff --git a/src/components/mobile/molecules/ReportModal/ReportModal.style.ts b/src/components/mobile/molecules/ReportModal/ReportModal.style.ts new file mode 100644 index 00000000..9cca1ebc --- /dev/null +++ b/src/components/mobile/molecules/ReportModal/ReportModal.style.ts @@ -0,0 +1,76 @@ +import { css } from '@emotion/react'; +import color from '@/styles/color'; +import typo from '@/styles/typo'; + +export const reportModalStyling = css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '20px', +}); + +export const reportTextWrapper = css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '6px', +}); + +export const reportTextStyling = css(typo.Main.SemiBold, { + color: color.BK, +}); + +export const buttonWrapperStyling = css({ + display: 'grid', + gridTemplateColumns: 'repeat(2, 1fr)', + gridGap: '7px', +}); + +export const reportBtnWrapperStyling = css({ + display: 'flex', + justifyContent: 'space-between', + width: '100%', +}); + +export const buttonStyling = css(typo.Mobile.Text.Medium_14, { + display: 'flex', + width: '143px', + height: '50px', + alignItems: 'center', + padding: '0 16px', + borderRadius: '8px', + backgroundColor: color.GY[5], + color: color.GY[6], + cursor: 'pointer', +}); + +export const selectedButtonStyling = css({ + backgroundColor: color.WT_VIOLET, + color: color.MAIN, + outline: `2px solid ${color.MAIN}`, +}); + +export const getButtonStyling = (selected: boolean) => + css({ + width: '293px', + height: '44px', + borderRadius: '12px', + backgroundColor: selected ? color.MAIN : color.GY[2], + }); + +export const reportInputStyling = css(typo.Mobile.Text.SemiBold_14, { + width: '100%', + padding: '6px 0', + outline: '0', + border: 'none', + borderBottom: `1px solid ${color.GY[3]}`, + color: color.BK, + + ':-webkit-autofill': { + boxShadow: '0 0 0px 1000px white inset', + }, + + '&::placeholder': { + color: color.GY[1], + }, +}); diff --git a/src/components/mobile/molecules/ReportModal/ReportModal.tsx b/src/components/mobile/molecules/ReportModal/ReportModal.tsx new file mode 100644 index 00000000..84b01956 --- /dev/null +++ b/src/components/mobile/molecules/ReportModal/ReportModal.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import { MobileReport } from '@/assets'; +import Modal from '@/components/mobile/atoms/Modal/Modal'; +import Button from '@/components/mobile/atoms/Button/Button'; +import { reportOptions } from '@/constants/reportOption'; +import * as S from './ReportModal.style'; + +export interface ReportModalProps { + isOpen?: boolean; + onConfirm?: (reason: string) => void; + onClose?: () => void; +} + +const ReportModal = ({ isOpen, onConfirm, onClose }: ReportModalProps) => { + const [reportReason, setReportReason] = useState(''); + const [otherReason, setOtherReason] = useState(''); + const finalReportReason: string = + reportReason === '기타' ? otherReason : reportReason; + + const handleOtherReportReason = (e: React.ChangeEvent) => { + setOtherReason(e.target.value); + }; + + const handleConfirm = () => { + if (!finalReportReason.trim()) return; + onConfirm?.(finalReportReason); + }; + + return ( + +
+
+ +
신고사유 선택
+
+
+ {reportOptions.map((option) => ( + + ))} +
+ {reportReason === '기타' && ( + + )} + +
+
+ ); +}; + +export default ReportModal; diff --git a/src/components/mobile/molecules/SummaryBox/SummaryBox.style.ts b/src/components/mobile/molecules/SummaryBox/SummaryBox.style.ts new file mode 100644 index 00000000..c0cd9103 --- /dev/null +++ b/src/components/mobile/molecules/SummaryBox/SummaryBox.style.ts @@ -0,0 +1,63 @@ +import { css } from '@emotion/react'; +import color from '@/styles/color'; +import typo from '@/styles/typo'; +import { rotate } from '@/styles/keyframes'; + +export const summaryBoxStyling = css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', +}); + +export const summaryTextStyling = css(typo.Mobile.Text.SemiBold_14, { + color: color.MAIN, +}); + +export const summaryWrapper = css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '6px', +}); + +export const summarySpinnerWrapper = css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', +}); + +export const summarySpinnerStyling = css({ + animation: `${rotate} 2s infinite linear`, +}); + +export const summarySpinnerText = css(typo.Mobile.Main.Regular_12, { + color: color.GY[1], +}); + +export const summaryTextWrapper = css(typo.Mobile.Main.Regular_12, { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + color: color.GY[1], +}); + +export const summaryStatusWrapper = css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', +}); + +export const summaryText = css({ + display: 'flex', + alignItems: 'center', + gap: '4px', +}); + +export const iconStyling = css({ + transform: 'scale(0.75)', +}); + +export const spinnerStyling = css({ + transform: 'scale(0.75)', + margin: '10px', +}); diff --git a/src/components/mobile/molecules/SummaryBox/SummaryBox.tsx b/src/components/mobile/molecules/SummaryBox/SummaryBox.tsx new file mode 100644 index 00000000..908129ca --- /dev/null +++ b/src/components/mobile/molecules/SummaryBox/SummaryBox.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { TalkPickSummary } from '@/types/talk-pick'; +import { SadEmoji, Spinner, StatusFail, StatusNotRequired } from '@/assets'; +import SummaryItem from '@/components/mobile/atoms/SummaryItem/SummaryItem'; +import { SUMMARY } from '@/constants/message'; +import * as S from './SummaryBox.style'; + +export interface SummaryBoxProps { + summary?: TalkPickSummary; + summaryStatus?: 'PENDING' | 'SUCCESS' | 'FAIL' | 'NOT_REQUIRED'; +} + +const SummaryBox = ({ + summary, + summaryStatus = 'PENDING', +}: SummaryBoxProps) => { + const contentMap: Record< + 'PENDING' | 'SUCCESS' | 'FAIL' | 'NOT_REQUIRED', + React.ReactNode + > = { + PENDING: ( +
+
{SUMMARY.TITLE}
+
+ +
+

{SUMMARY.PENDING}

+
+ ), + SUCCESS: ( +
+ {summary?.summaryFirstLine} + {summary?.summarySecondLine} + {summary?.summaryThirdLine} +
+ ), + FAIL: ( +
+
{SUMMARY.TITLE}
+ +
+

{SUMMARY.FAIL.UNKNOWN}

+

{SUMMARY.FAIL.REFRESH}

+
+
+ ), + NOT_REQUIRED: ( +
+
{SUMMARY.TITLE}
+ +
+ + {SUMMARY.NOT_REQUIRED.TEXT_VALIDATION} + + +

{SUMMARY.NOT_REQUIRED.TEXT_CHECK}

+
+
+ ), + }; + + const renderContent = contentMap[summaryStatus]; + + return
{renderContent}
; +}; +export default SummaryBox; diff --git a/src/components/mobile/molecules/VoteToggle/VoteToggle.style.ts b/src/components/mobile/molecules/VoteToggle/VoteToggle.style.ts new file mode 100644 index 00000000..e0fb6a19 --- /dev/null +++ b/src/components/mobile/molecules/VoteToggle/VoteToggle.style.ts @@ -0,0 +1,41 @@ +import { css } from '@emotion/react'; +import { VoteOption, MyVoteOption } from '@/types/vote'; +import typo from '@/styles/typo'; +import color from '@/styles/color'; + +export const voteToggleStyle = css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', +}); + +export const buttonContainerStyle = css({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + width: '306px', +}); + +export const voteTextStyle = css(typo.Mobile.Text.Medium_16, { + color: color.GY[1], +}); + +export const getButtonStyle = ( + side: VoteOption, + selectedButton: MyVoteOption, +) => + css({ + ...(selectedButton === side && { + backgroundColor: side === 'A' ? color.RED : color.BLUE, + color: color.WT, + border: 'none', + }), + }); + +export const toastModalStyling = css({ + position: 'fixed', + top: '110px', + left: '50%', + transform: 'translate(-50%)', + zIndex: '1000', +}); diff --git a/src/components/mobile/molecules/VoteToggle/VoteToggle.tsx b/src/components/mobile/molecules/VoteToggle/VoteToggle.tsx new file mode 100644 index 00000000..fbe828d9 --- /dev/null +++ b/src/components/mobile/molecules/VoteToggle/VoteToggle.tsx @@ -0,0 +1,125 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { NOTICE } from '@/constants/message'; +import { PATH } from '@/constants/path'; +import { VoteOption, MyVoteOption } from '@/types/vote'; +import Button from '@/components/mobile/atoms/Button/Button'; +import ToastModal from '@/components/atoms/ToastModal/ToastModal'; +import { useNewSelector } from '@/store'; +import { selectAccessToken } from '@/store/auth'; +import { useCreateTalkPickVoteMutation } from '@/hooks/api/vote/useCreateTalkPickVoteMutation'; +import { useEditTalkPickVoteMutation } from '@/hooks/api/vote/useEditTalkPickVoteMutation'; +import { useDeleteTalkPickVoteMutation } from '@/hooks/api/vote/useDeleteTalkPickVoteMutation'; +import useToastModal from '@/hooks/modal/useToastModal'; +import * as S from './VoteToggle.style'; + +interface VoteToggleProps { + talkPickId: number; + leftButtonText: string; + rightButtonText: string; + selectedVote: MyVoteOption; +} + +const VoteToggle: React.FC = ({ + talkPickId, + leftButtonText, + rightButtonText, + selectedVote, +}) => { + const navigate = useNavigate(); + const accessToken = useNewSelector(selectAccessToken); + const [loggedOutVoteOption, setLoggedOutVoteOption] = + useState(null); + + const currnetOption: MyVoteOption = accessToken + ? selectedVote + : loggedOutVoteOption; + + const { isVisible, modalText, showToastModal } = useToastModal(); + + const { mutate: createTalkPickVote } = + useCreateTalkPickVoteMutation(talkPickId); + + const { mutate: editTalkPickVote } = useEditTalkPickVoteMutation(talkPickId); + + const { mutate: deleteTalkPickVote } = + useDeleteTalkPickVoteMutation(talkPickId); + + useEffect(() => { + if (!accessToken) { + const savedVote = localStorage.getItem(`talkpick_${talkPickId}`); + + if (savedVote === 'A' || savedVote === 'B') { + setLoggedOutVoteOption(savedVote); + } + } + }, [accessToken, talkPickId]); + + const handleLoggedOutTalkPickVote = (voteOption: VoteOption) => { + if (loggedOutVoteOption === voteOption) { + setLoggedOutVoteOption(null); + localStorage.removeItem(`talkpick_${talkPickId}`); + } else { + setLoggedOutVoteOption(voteOption); + localStorage.setItem(`talkpick_${talkPickId}`, voteOption); + + showToastModal(NOTICE.REQUIRED.LOGIN, () => { + navigate(`/${PATH.LOGIN}`, { state: { talkPickId } }); + }); + } + }; + + const handleTalkPickVote = ( + selectedOption: MyVoteOption, + voteOption: VoteOption, + ) => { + if (selectedOption === null) { + createTalkPickVote(voteOption); + } else if (selectedOption === voteOption) { + deleteTalkPickVote(); + } else { + editTalkPickVote(voteOption); + } + }; + + const handleVoteButtonClick = (voteOption: VoteOption) => { + if (accessToken) { + handleTalkPickVote(selectedVote, voteOption); + } else { + handleLoggedOutTalkPickVote(voteOption); + } + }; + + return ( +
+ {isVisible && ( +
+ {modalText} +
+ )} +
+ +
VS
+ +
+
+ ); +}; + +export default VoteToggle; diff --git a/src/components/mobile/organisms/TalkPickSection/TalkPickSection.style.ts b/src/components/mobile/organisms/TalkPickSection/TalkPickSection.style.ts new file mode 100644 index 00000000..079602aa --- /dev/null +++ b/src/components/mobile/organisms/TalkPickSection/TalkPickSection.style.ts @@ -0,0 +1,158 @@ +import { css } from '@emotion/react'; +import color from '@/styles/color'; +import typo from '@/styles/typo'; + +export const talkPickStyling = css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '10px', +}); + +export const talkPickTitle = css(typo.Main.SemiBold, { + display: 'flex', + alignItems: 'center', + gap: '6px', + width: '100%', + color: color.BK, +}); + +export const buttonWrapper = css({ + display: 'flex', + gap: '10px', +}); + +export const talkPickTopWrapper = css({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + width: '100%', +}); + +export const talkPickWrapper = css({ + display: 'flex', + flexDirection: 'column', + width: '330px', + padding: '10px 0', + borderTop: `1px solid${color.GY[4]}`, +}); + +export const talkPickTopStyling = css({ + display: 'flex', + gap: '10px', +}); + +export const talkPickInfoWrapper = css({ + display: 'flex', + flexDirection: 'column', + width: '100%', + paddingTop: '6px', + gap: '7px', +}); + +export const talkPickInfoTopWrapper = css({ + display: 'flex', + justifyContent: 'space-between', +}); + +export const talkPickInfoBottomWrapper = css({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}); + +export const talkPickWriterInfoWrapper = css({ + display: 'flex', + alignItems: 'center', + gap: '8px', +}); + +export const talkPickWriterWrapper = css({ + display: 'flex', + alignItems: 'center', + gap: '4px', +}); + +export const talkPickWriterStyling = css(typo.Mobile.Text.SemiBold_12, { + color: color.GY[1], +}); + +export const talkPickDateStyling = css(typo.Mobile.Text.Regular_12, { + color: color.GY[2], +}); + +export const talkPickTitleStyling = css(typo.Main.SemiBold, { + width: '300px', + wordBreak: 'break-all', + whiteSpace: 'normal', + color: color.BK, +}); + +export const talkPickViewStyling = css(typo.Mobile.Text.Regular_10, { + color: color.GY[1], + + '& > span': { + color: color.MAIN, + }, +}); + +export const talkPickContentWrapper = css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: '24px 0', + gap: '17px', + borderBottom: `1px solid${color.GY[4]}`, +}); + +export const talkPickContent = css({ + width: '100%', + padding: '20px 8px 0', +}); + +export const talkPickContentTextStyling = css(typo.Mobile.Main.Regular_12, { + width: '100%', + whiteSpace: 'pre-wrap', + color: color.BK, +}); + +export const talkPickImageWrapper = css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + paddingTop: '20px', + gap: '8px', + + '& > img': { + maxWidth: '100%', + width: 'auto', + height: 'auto', + borderRadius: '5px', + }, +}); + +export const voteToggleWrapper = css({ + display: 'flex', + justifyContent: 'center', + paddingTop: '14px', +}); + +export const contentBtnStyling = css({ + width: 'fit-content', +}); + +export const toastModalStyling = css({ + position: 'fixed', + top: '65px', + left: '50%', + transform: 'translate(-50%)', + zIndex: '1000', +}); + +export const centerStyling = css({ + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + zIndex: '1000', +}); diff --git a/src/components/mobile/organisms/TalkPickSection/TalkPickSection.tsx b/src/components/mobile/organisms/TalkPickSection/TalkPickSection.tsx new file mode 100644 index 00000000..c933404b --- /dev/null +++ b/src/components/mobile/organisms/TalkPickSection/TalkPickSection.tsx @@ -0,0 +1,239 @@ +/* eslint-disable jsx-a11y/img-redundant-alt */ +import React, { useState } from 'react'; +import { + AngleSmallUp, + AngleSmallDown, + MobileBookmarkDF, + MobileBookmarkPR, + MobileShare, + PickIcon, +} from '@/assets'; +import { useNavigate } from 'react-router-dom'; +import { TalkPickDetail } from '@/types/talk-pick'; +import { PATH } from '@/constants/path'; +import { ERROR } from '@/constants/message'; +import { formatDate, formatNumber } from '@/utils/formatData'; +import Button from '@/components/atoms/Button/Button'; +import IconButton from '@/components/mobile/atoms/IconButton/IconButton'; +import SummaryBox from '@/components/mobile/molecules/SummaryBox/SummaryBox'; +import ProfileIcon from '@/components/atoms/ProfileIcon/ProfileIcon'; +import ToastModal from '@/components/atoms/ToastModal/ToastModal'; +import VoteToggle from '@/components/mobile/molecules/VoteToggle/VoteToggle'; +import MenuTap, { MenuItem } from '@/components/atoms/MenuTap/MenuTap'; +import TextModal from '@/components/mobile/molecules/TextModal/TextModal'; +import ShareModal from '@/components/mobile/molecules/ShareModal/ShareModal'; +import ReportModal from '@/components/mobile/molecules/ReportModal/ReportModal'; +import { useCreateTalkPickBookmarkMutation } from '@/hooks/api/bookmark/useCreateTalkPickBookmarkMutation'; +import { useDeleteTalkPickBookmarkMutation } from '@/hooks/api/bookmark/useDeleteTalkPickBookmarkMutation'; +import { useDeleteTalkPickMutation } from '@/hooks/api/talk-pick/useDeleteTalkPickMutation'; +import useToastModal from '@/hooks/modal/useToastModal'; +import * as S from './TalkPickSection.style'; + +export interface TalkPickProps { + talkPick: TalkPickDetail; + myTalkPick: boolean; + isTodayTalkPick: boolean; +} + +const TalkPickSection = ({ + talkPick, + myTalkPick, + isTodayTalkPick, +}: TalkPickProps) => { + const navigate = useNavigate(); + + const [isExpanded, setIsExpanded] = useState(false); + const { isVisible, modalText, showToastModal } = useToastModal(); + + const [activeModal, setActiveModal] = useState< + 'reportTalkPick' | 'reportText' | 'deleteText' | 'share' | 'none' + >('none'); + + const onCloseModal = () => { + setActiveModal('none'); + }; + + const { mutate: createBookmark } = useCreateTalkPickBookmarkMutation( + talkPick?.id ?? 0, + ); + + const { mutate: deleteBookmark } = useDeleteTalkPickBookmarkMutation( + talkPick?.id ?? 0, + ); + + const handleBookmarkClick = () => { + if (!talkPick) return; + + if (myTalkPick) { + showToastModal(ERROR.BOOKMARK.MY_TALKPICK); + return; + } + + if (talkPick.myBookmark) { + deleteBookmark(); + } else { + createBookmark(); + } + }; + + const handleContentToggle = () => { + setIsExpanded((prev) => !prev); + }; + + const myTalkPickItem: MenuItem[] = [ + { + label: '수정', + onClick: () => { + navigate(`/${PATH.CREATE.TALK_PICK}`, { state: { talkPick } }); + }, + }, + { + label: '삭제', + onClick: () => { + setActiveModal('deleteText'); + }, + }, + ]; + + const otherTalkPickItem: MenuItem[] = [ + { + label: '신고', + onClick: () => { + setActiveModal('reportText'); + }, + }, + ]; + + const { mutate: deleteTalkPick } = useDeleteTalkPickMutation( + talkPick?.id ?? 0, + ); + + const handleDeleteButton = () => { + deleteTalkPick(); + onCloseModal(); + }; + + return ( +
+ {isVisible && ( +
+ {modalText} +
+ )} +
+ {}} + onClose={onCloseModal} + /> + + { + setActiveModal('reportTalkPick'); + }} + onClose={onCloseModal} + /> + {}} + onClose={onCloseModal} + /> +
+
+
+ {isTodayTalkPick ? '오늘의 톡픽' : '톡픽'} + +
+
+ } + onClick={() => setActiveModal('share')} + /> + : + } + onClick={handleBookmarkClick} + /> +
+
+
+
+
+
{talkPick?.baseFields.title}
+ +
+
+
+ +
+
{talkPick?.writer}
+
+
+ {formatDate(talkPick?.createdAt ?? '')} +
+
+
+
+ 조회 {formatNumber(talkPick?.views ?? '')} +
+
+
+
+ + {isExpanded && ( +
+
+ {talkPick?.baseFields.content} +
+ {talkPick?.imgUrls.length !== 0 && ( +
+ {talkPick?.imgUrls.map((url, idx) => ( + {`image + ))} +
+ )} +
+ )} + +
+
+ +
+
+
+ ); +}; + +export default TalkPickSection; diff --git a/src/components/organisms/TalkPickSection/TalkPickSection.tsx b/src/components/organisms/TalkPickSection/TalkPickSection.tsx index 491b3806..9709cd7c 100644 --- a/src/components/organisms/TalkPickSection/TalkPickSection.tsx +++ b/src/components/organisms/TalkPickSection/TalkPickSection.tsx @@ -220,7 +220,7 @@ const TalkPickSection = ({
(
  • +

    기본 extraSmall

    +

    기본 Small

    기본 Large

    +

    별도 img 있을때 extraSmall

    +

    별도 img 있을때 Small

    medium +

    outlineHighlightR

    + +

    outlineHighlightB

    +
  • disabled

    diff --git a/src/stories/mobile/atoms/SummaryItem.stories.tsx b/src/stories/mobile/atoms/SummaryItem.stories.tsx new file mode 100644 index 00000000..6b99745e --- /dev/null +++ b/src/stories/mobile/atoms/SummaryItem.stories.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import SummaryItem from '@/components/mobile/atoms/SummaryItem/SummaryItem'; +import type { Meta, StoryObj } from '@storybook/react'; +import { storyContainer, storyInnerContainer } from '@/stories/story.styles'; + +const meta = { + title: 'mobile/atoms/SummaryItem', + component: SummaryItem, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + itemNumber: { + options: ['1', '2', '3'], + control: { type: 'radio' }, + }, + children: { control: { type: 'text' } }, + }, + args: { + itemNumber: '1', + children: 'Summary Item', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const All: Story = { + render: () => ( +
      +
    • +

      Summary Item

      + + 남친이 어쩌고 저쩌고 잘못했네 안했네 대충 더미글 + + + 남친이 친구 새우 껍질을 어쩌고 저쩌고 뭐라뭐라 + + + 나 너무 속상한데 이걸 찬성해 말어 + +
    • +
    + ), +}; diff --git a/src/stories/mobile/molecules/ReportModal.stories.tsx b/src/stories/mobile/molecules/ReportModal.stories.tsx new file mode 100644 index 00000000..1137b113 --- /dev/null +++ b/src/stories/mobile/molecules/ReportModal.stories.tsx @@ -0,0 +1,26 @@ +import ReportModal from '@/components/mobile/molecules/ReportModal/ReportModal'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta = { + title: 'mobile/molecules/ReportModal', + component: ReportModal, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + isOpen: { control: { type: 'boolean' } }, + }, + args: { + isOpen: true, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isOpen: true, + }, +}; diff --git a/src/stories/mobile/molecules/SummaryBox.stories.tsx b/src/stories/mobile/molecules/SummaryBox.stories.tsx new file mode 100644 index 00000000..a16715a1 --- /dev/null +++ b/src/stories/mobile/molecules/SummaryBox.stories.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import SummaryBox from '@/components/mobile/molecules/SummaryBox/SummaryBox'; +import { TalkPickSummary } from '@/types/talk-pick'; +import type { Meta, StoryObj } from '@storybook/react'; +import { storyContainer, storyInnerContainer } from '@/stories/story.styles'; + +const defaultSummary: TalkPickSummary = { + summaryFirstLine: 'first summary line', + summarySecondLine: 'second summary line', + summaryThirdLine: 'third summary line', +}; + +const exampleSummary: TalkPickSummary = { + summaryFirstLine: '남친이 어쩌고 저쩌고 잘못했네 안했네 대충 더미글', + summarySecondLine: '남친이 친구 새우 껍질을 어쩌고 저쩌고 뭐라뭐라', + summaryThirdLine: '나 너무 속상한데 이걸 찬성해 말어', +}; + +const meta = { + title: 'mobile/molecules/SummaryBox', + component: SummaryBox, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + args: { + summary: defaultSummary, + summaryStatus: 'SUCCESS', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const All: Story = { + render: () => ( +
      +
    • +

      SUCCESS

      + +
    • +
    • +

      PENDING

      + +
    • +
    • +

      NOT_REQUIRED

      + +
    • +
    • +

      FAIL

      + +
    • +
    + ), +}; diff --git a/src/stories/mobile/molecules/VoteToggle.stories.tsx b/src/stories/mobile/molecules/VoteToggle.stories.tsx new file mode 100644 index 00000000..c867b562 --- /dev/null +++ b/src/stories/mobile/molecules/VoteToggle.stories.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import VoteToggle from '@/components/mobile/molecules/VoteToggle/VoteToggle'; +import store from '@/store'; +import { Provider } from 'react-redux'; +import ReactQueryProvider from '@/providers/ReactQueryProvider'; +import { BrowserRouter as Router } from 'react-router-dom'; +import type { Meta, StoryObj } from '@storybook/react'; +import { setToken } from '@/store/auth'; +import { storyContainer, storyInnerContainer } from '@/stories/story.styles'; + +const meta = { + title: 'mobile/molecules/VoteToggle', + component: VoteToggle, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + talkPickId: { control: 'number' }, + leftButtonText: { control: 'text' }, + rightButtonText: { control: 'text' }, + }, + args: { + talkPickId: 1, + leftButtonText: '상관없다다다다다다', + rightButtonText: '상관 있다', + selectedVote: null, + }, + decorators: [ + (Story) => ( + + + + + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: () => { + store.dispatch(setToken('accessToken')); + }, +}; + +export const All: Story = { + play: () => { + store.dispatch(setToken('accessToken')); + }, + render: (args) => ( +
      +
    • +

      Defult

      + +
    • +
    • +

      A Selected

      + +
    • +
    • +

      B Selected

      + +
    • +
    + ), +}; diff --git a/src/stories/mobile/organisms/TalkPickSection.stories.tsx b/src/stories/mobile/organisms/TalkPickSection.stories.tsx new file mode 100644 index 00000000..93dc9d17 --- /dev/null +++ b/src/stories/mobile/organisms/TalkPickSection.stories.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { TalkPickDetail } from '@/types/talk-pick'; +import store from '@/store'; +import { Provider } from 'react-redux'; +import { BrowserRouter as Router } from 'react-router-dom'; +import ReactQueryProvider from '@/providers/ReactQueryProvider'; +import type { Meta, StoryObj } from '@storybook/react'; +import { setToken } from '@/store/auth'; +import { OctopusProfile, RabbitProfile } from '@/assets'; +import TalkPickSection from '@/components/mobile/organisms/TalkPickSection/TalkPickSection'; + +const defaultTodayTalkPick: TalkPickDetail = { + id: 0, + baseFields: { + title: '새우 껍질 논쟁, 당신의 선택은?', + optionA: '상관없다다다다다다다', + optionB: '상관 있다', + sourceUrl: '출처', + content: + '(더미글이에욤) 건강과 지속 가능성을 추구하는 이들을 위해, 맛과 영양이 가득한 채식 요리 레시피를 소개합니다. 이 글에서는 간단하지만 맛있는 채식 요리 10가지를 선보입니다. 첫 번째 레시피는 아보카도 토스트, 아침 식사로 완벽하며 영양소가 풍부합니다. 두 번째는 콩과 야채를 사용한 푸짐한 채식 칠리, 포만감을 주는 동시에 영양소를 공급합니다. 세 번째는 색다른 맛의 채식 패드타이, 고소한 땅콩 소스로 풍미를 더합니다. 네 번째는 간단하고 건강한 콥 샐러드, 신선한 야채와 단백질이 가득합니다. 다섯 번째로는 향긋한 허브와 함께하는 채식 리조또, 크리미한 맛이 일품입니다. 여섯 번째는 에너지를 주는 채식 스무디 볼, 과일과 견과류의 완벽한 조합입니다. 여덟 번째는 채식 파스타 프리마베라, 신선한 야채와 토마토 소스의 조화가 뛰어납니다. 아홉 번째는 채식 볶음밥, 풍부한 맛과 영양으로 가득 차 있습니다. 마지막으로, 식사 후 달콤한 마무리를 위한 채식 초콜릿 케이크, 건강한 재료로 만들어 죄책감 없는 달콤함을 선사합니다. 이 레시피들은 채식을 선호하는 이들에게 새로운 요리 아이디어를 제공하며, 채식이 얼마나 다채롭고 맛있을 수 있는지 보여줍니다. 건강한 라이프스타일을 추구하는 모든 이들에게 이 레시피들이 영감을 줄 것입니다.', + }, + summary: { + summaryFirstLine: '남친이 어쩌고 저쩌고 잘못했네 안했네 대충 더미글', + summarySecondLine: '남친이 친구 새우 껍질을 어쩌고 저쩌고 뭐라뭐라', + summaryThirdLine: '나 너무 속상한데 이걸 찬성해 말어', + }, + summaryStatus: 'SUCCESS', + imgUrls: [OctopusProfile, RabbitProfile], + fileIds: [], + votesCountOfOptionA: 1963, + votesCountOfOptionB: 2635, + views: 35254, + bookmarks: 234, + myBookmark: false, + votedOption: 'A', + writer: '닉네임593', + writerProfileImgUrl: OctopusProfile, + createdAt: '2024-08-04', + isEdited: false, +}; + +const meta = { + title: 'mobile/organisms/TalkPickSection', + component: TalkPickSection, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + args: { + talkPick: defaultTodayTalkPick, + myTalkPick: false, + isTodayTalkPick: false, + }, + decorators: [ + (Story) => ( + + + + + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: () => { + store.dispatch(setToken('accessToken')); + }, +}; diff --git a/src/stories/molecules/SummaryBox.stories.tsx b/src/stories/molecules/SummaryBox.stories.tsx index ed7f8990..d5692151 100644 --- a/src/stories/molecules/SummaryBox.stories.tsx +++ b/src/stories/molecules/SummaryBox.stories.tsx @@ -10,12 +10,6 @@ const defaultSummary: TalkPickSummary = { summaryThirdLine: 'third summary line', }; -const spinnerSummary: TalkPickSummary = { - summaryFirstLine: 'first summary line', - summarySecondLine: 'second summary line', - summaryThirdLine: null, -}; - const exampleSummary: TalkPickSummary = { summaryFirstLine: '남친이 어쩌고 저쩌고 잘못했네 안했네 대충 더미글', summarySecondLine: '남친이 친구 새우 껍질을 어쩌고 저쩌고 뭐라뭐라', @@ -31,6 +25,7 @@ const meta = { tags: ['autodocs'], args: { summary: defaultSummary, + summaryStatus: 'SUCCESS', }, } satisfies Meta; @@ -43,12 +38,20 @@ export const All: Story = { render: () => (
    • -

      Summary Box

      - +

      SUCCESS

      + +
    • +
    • +

      PENDING

      + +
    • +
    • +

      NOT_REQUIRED

      +
    • -

      Summary Box with Spinner

      - +

      FAIL

      +
    ), diff --git a/src/stories/organisms/TalkPickSection.stories.tsx b/src/stories/organisms/TalkPickSection.stories.tsx index 1f6001a3..0d9a4ff8 100644 --- a/src/stories/organisms/TalkPickSection.stories.tsx +++ b/src/stories/organisms/TalkPickSection.stories.tsx @@ -23,6 +23,7 @@ const defaultTodayTalkPick: TalkPickDetail = { summarySecondLine: '남친이 친구 새우 껍질을 어쩌고 저쩌고 뭐라뭐라', summaryThirdLine: '나 너무 속상한데 이걸 찬성해 말어', }, + summaryStatus: 'SUCCESS', imgUrls: [], fileIds: [], votesCountOfOptionA: 1963, diff --git a/src/styles/color.ts b/src/styles/color.ts index 13800520..2553aa05 100644 --- a/src/styles/color.ts +++ b/src/styles/color.ts @@ -7,6 +7,7 @@ const color = { 3: '#F1F1F1', 4: '#E6E9EF', 5: '#F6F7F9', + 6: '#949DAE', }, WT: '#FFFFFF', WT_VIOLET: '#F2F3FF', diff --git a/src/styles/typo.ts b/src/styles/typo.ts index ead0f7cd..6e07008b 100644 --- a/src/styles/typo.ts +++ b/src/styles/typo.ts @@ -235,6 +235,13 @@ const typo = { lineHeight: '1.6', letterSpacing: `${16 * -0.05}px`, }, + Medium_14: { + fontFamily: 'Pretendard', + fontSize: '14px', + fontWeight: 500, + lineHeight: '1.6', + letterSpacing: `${14 * -0.05}px`, + }, Medium_12: { fontFamily: 'Pretendard', fontSize: '12px', @@ -256,6 +263,20 @@ const typo = { lineHeight: '1.3', letterSpacing: `${8 * -0.05}px`, }, + Regular_12: { + fontFamily: 'Pretendard', + fontSize: '12px', + fontWeight: 400, + lineHeight: '1.6', + letterSpacing: `${12 * -0.05}px`, + }, + Regular_10: { + fontFamily: 'Pretendard', + fontSize: '10px', + fontWeight: 400, + lineHeight: '1.6', + letterSpacing: `${10 * -0.05}px`, + }, }, }, diff --git a/src/types/talk-pick.ts b/src/types/talk-pick.ts index 2d29624c..753f44db 100644 --- a/src/types/talk-pick.ts +++ b/src/types/talk-pick.ts @@ -22,6 +22,7 @@ export type TalkPickDetail = { myBookmark: boolean; votedOption: 'A' | 'B' | null; writer: string; + writerProfileImgUrl: string | null; createdAt: string; isEdited: boolean; };