diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index a6c23385..f545190d 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -75,7 +75,7 @@ const Footer = () => { ))}
+ className="cursor-pointer py-[12px] custom-h4 text-gray700 max-lg:text-[16px] max-phone:text-[14px] max-lg:px-[0px]"> 고객센터
diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index 529a0347..687def25 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -139,8 +139,8 @@ const Navbar = () => { src={data?.data.has_unread_messages ? MessageIcon : MessageIcon} alt="메세지 알림" className="self-center max-phone:w-[16px] max-phone:h-[16px]" - // onClick={() => navigate('/chat')} - onClick={() => setIsMessageModalShow((prev) => !prev)} + onClick={() => navigate('/chat')} + // onClick={() => setIsMessageModalShow((prev) => !prev)} /> { const [selectedRoomId, setSelectedRoomId] = useState(null); const isTablet = window.innerWidth < 1024; + const { data } = useGetInfiniteChatRooms({ limit: 20 }); // 채팅 목록 조회 + + useEffect(() => { + const latestRoomId = data?.pages?.[0]?.data?.rooms?.[0]?.room_id; + + if (latestRoomId) { + setSelectedRoomId(latestRoomId); + } + }, [data]); + return (
{(!isTablet || selectedRoomId === null) && ( @@ -13,7 +24,9 @@ const ChatPage = () => { )}
- {(!isTablet || selectedRoomId === null) && } + {(!isTablet || selectedRoomId === null) && ( + + )} {(!isTablet || selectedRoomId !== null) && }
diff --git a/src/pages/ChatPage/components/ChatBubble.tsx b/src/pages/ChatPage/components/ChatBubble.tsx index e757242f..8736fa3c 100644 --- a/src/pages/ChatPage/components/ChatBubble.tsx +++ b/src/pages/ChatPage/components/ChatBubble.tsx @@ -12,21 +12,36 @@ interface ChatBubbleProps { files: Attachment[]; isMine: boolean; popup?: boolean; + date: string; + showTime?: boolean; } -const ChatBubble = ({ text, files, isMine, popup }: ChatBubbleProps) => { +const ChatBubble = ({ text, files, isMine, popup, date, showTime }: ChatBubbleProps) => { const [preview, setPreview] = useState(null); const [selected, setSelected] = useState(null); const hasFile = files.some((file) => file.type === 'FILE'); + const formatTime = (date: string) => { + const d = new Date(date); + + return new Intl.DateTimeFormat('ko-KR', { + hour: '2-digit', + minute: '2-digit', + hour12: true, + }).format(d); + }; + return ( -
+
+ {showTime &&
{formatTime(date)}
} +
{text} diff --git a/src/pages/ChatPage/components/ChatList.tsx b/src/pages/ChatPage/components/ChatList.tsx index e7c7e80d..2eaea13c 100644 --- a/src/pages/ChatPage/components/ChatList.tsx +++ b/src/pages/ChatPage/components/ChatList.tsx @@ -7,6 +7,7 @@ import { useNavigate } from 'react-router-dom'; interface ChatListProps { setSelectedRoomId: (roomId: number) => void; + selectedRoomId: number | null; } type ActiveButton = { @@ -21,7 +22,7 @@ const BUTTONS: ActiveButton[] = [ { id: 3, label: '고정된 메시지', filter: 'pinned' }, ]; -const ChatList = ({ setSelectedRoomId }: ChatListProps) => { +const ChatList = ({ setSelectedRoomId, selectedRoomId }: ChatListProps) => { const [search, setSearch] = useState(''); const [activeButton, setActiveButton] = useState(BUTTONS[0]); const isTablet = window.innerWidth < 1024; @@ -81,6 +82,7 @@ const ChatList = ({ setSelectedRoomId }: ChatListProps) => { last_message={list.last_message} unread_count={list.unread_count} is_pinned={list.is_pinned} + is_clicked={list.room_id === selectedRoomId} onClick={() => { if (isTablet) { navigate(`/chat/${list.room_id}`); diff --git a/src/pages/ChatPage/components/ChatListItem.tsx b/src/pages/ChatPage/components/ChatListItem.tsx index 2e80c9c4..5774bd29 100644 --- a/src/pages/ChatPage/components/ChatListItem.tsx +++ b/src/pages/ChatPage/components/ChatListItem.tsx @@ -1,5 +1,6 @@ import formatDate from '@/utils/formatDate'; import Default from '@assets/icon-profile-image-default.svg'; +import clsx from 'clsx'; interface ChatListItemProps { partner: { @@ -13,16 +14,20 @@ interface ChatListItemProps { }; unread_count: number; is_pinned: boolean; + is_clicked: boolean; onClick: () => void; } -const ChatListItem = ({ partner, last_message, unread_count, onClick }: ChatListItemProps) => { +const ChatListItem = ({ partner, last_message, unread_count, is_clicked, onClick }: ChatListItemProps) => { const { month, day } = formatDate(last_message.sent_at); return (
+ className={clsx( + 'p-[16px] flex gap-[16px] items-start lg:max-w-[317px] w-full cursor-pointer hover:bg-background rounded-[8px]', + is_clicked && 'bg-background', + )}>
(false); // 메뉴 클릭 여부 const [showDownload, setShowDownload] = useState(false); // 내가 다운받은 프롬프트 const [showDownloadAll, setShowDownloadAll] = useState(false); // 내가 다운받은 프롬프트 더 보기 + const [fileError, setFileError] = useState(''); + + const MAX_FILE_COUNT = 3; + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + const menuRef = useRef(null); const isFirstLoad = useRef(true); // 처음 입장했는지 const prevHeightRef = useRef(0); @@ -135,7 +140,7 @@ const ChattingRoom = ({ selectedRoomId, className, popup }: ChattingRoomProps) = }); }; - const handleEnter = (e: React.KeyboardEvent) => { + const handleEnter = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { handleSubmit(); } @@ -157,11 +162,20 @@ const ChattingRoom = ({ selectedRoomId, className, popup }: ChattingRoomProps) = const handleFileSelect = (e: React.ChangeEvent) => { const selected = e.target.files; if (!selected) return; - if (files.length === 3) return; - if (previews.length === 3) return; + + setFileError(''); const fileArr = Array.from(selected); + const countError = files.length + fileArr.length > MAX_FILE_COUNT; + const sizeError = fileArr.find((file) => file.size > MAX_FILE_SIZE); + + if (countError || sizeError) { + setFileError('이미지, 파일은 최대 3개, 10mb까지 업로드할 수 있어요!'); + e.target.value = ''; + return; + } + setFiles((prev) => [...prev, ...fileArr]); const previewUrl = fileArr.map((file) => URL.createObjectURL(file)); @@ -176,6 +190,8 @@ const ChattingRoom = ({ selectedRoomId, className, popup }: ChattingRoomProps) = setPreviews((prev) => prev.filter((_, i) => i !== idx)); setFiles((prev) => prev.filter((_, i) => i !== idx)); + + setFileError(''); }; const isNearBottom = () => { @@ -428,7 +444,7 @@ const ChattingRoom = ({ selectedRoomId, className, popup }: ChattingRoomProps) = ) : ( mutatePatchPinChat(selectedRoomId)} className="cursor-pointer" /> )} - setShowMenu((prev) => !prev)} /> + setShowMenu((prev) => !prev)} /> {/* 메뉴 */} {showMenu && ( @@ -443,7 +459,7 @@ const ChattingRoom = ({ selectedRoomId, className, popup }: ChattingRoomProps) =
+ className="flex flex-col gap-[20px] flex-1 min-h-0 overflow-auto pr-[16px]">
{/* 사용자 정보 부분 */} @@ -487,22 +503,39 @@ const ChattingRoom = ({ selectedRoomId, className, popup }: ChattingRoomProps) = {/* 날짜 */}
-
{`${year}.${month}.${day}(${dayOfWeek})`}
+
{`${year}.${month}.${day}(${dayOfWeek})`}
{/* 메시지 */} {messages && (
- {messages.map((msg) => ( - - ))} + {messages.map((msg, idx) => { + const nextMessage = messages[idx + 1]; + + const isSameSender = + (msg.sender_id ?? msg?.sender?.user_id) === + (nextMessage?.sender_id ?? nextMessage?.sender?.user_id); + + const isSameMinute = + nextMessage && + new Date(msg.sent_at).getHours() === new Date(nextMessage.sent_at).getHours() && + new Date(msg.sent_at).getMinutes() === new Date(nextMessage.sent_at).getMinutes(); + + const showTime = !(isSameSender && isSameMinute); + + return ( + + ); + })}
)} @@ -510,8 +543,8 @@ const ChattingRoom = ({ selectedRoomId, className, popup }: ChattingRoomProps) =
{/* 입력창 */} -
-
+
+
{/* 파일 선택 */}
-
+
{/* 채팅 입력 */} - setInput(e.target.value)} + onInput={(e) => { + const target = e.currentTarget; + + target.style.height = 'auto'; + target.style.height = `${Math.min(target.scrollHeight, 102)}px`; + + if (target.scrollHeight > 102) { + target.style.overflowY = 'auto'; + } else { + target.style.overflowY = 'hidden'; + } + }} onKeyDown={handleEnter} placeholder="메시지를 입력해주세요." - className="flex-1 cursor-pointer custom-body1 plcaeholder:font-['SCoreDream'] placeholder:custom-body1 placeholder:text-gray500" + rows={1} + className="w-full min-h-[24px] max-h-[102px] resize-none cursor-pointer custom-body1 placeholder:font-['SCoreDream'] placeholder:custom-body1 placeholder:text-gray500" /> + {fileError &&
{fileError}
} +
{previews.length > 0 && previews.map((item, idx) => ( diff --git a/src/pages/ChatPage/components/PreviewItem.tsx b/src/pages/ChatPage/components/PreviewItem.tsx index 581ce53b..5e474ebe 100644 --- a/src/pages/ChatPage/components/PreviewItem.tsx +++ b/src/pages/ChatPage/components/PreviewItem.tsx @@ -16,7 +16,7 @@ const PreviewItem = ({ imageURL, fileSize, type, name, handleDeleteImage }: Prev return (
{/* 이미지 */} @@ -28,19 +28,25 @@ const PreviewItem = ({ imageURL, fileSize, type, name, handleDeleteImage }: Prev {/* 파일 */} {!type.startsWith('image/') && ( -
+
-

{name}

+

+ {name} +

)} {handleDeleteImage && (
{/* 파일 및 이미지 선택 시 파일 사이즈 */} -

{fileSize}

+

{fileSize}

{/* 파일 및 이미지 삭제 */} - +
)} diff --git a/src/pages/ProfilePage/components/ProfileCard.tsx b/src/pages/ProfilePage/components/ProfileCard.tsx index 9234a1c5..8f9c4aa7 100644 --- a/src/pages/ProfilePage/components/ProfileCard.tsx +++ b/src/pages/ProfilePage/components/ProfileCard.tsx @@ -94,6 +94,13 @@ const ProfileCard = ({ mypage }: ProfileCardProps) => { }); }; + // 채팅하기 로그인 관련 + const handleChatting = () => { + handleShowLoginModal(() => { + openChatRoom(member_id); + }); + }; + const normalizedFollowerList: FollowerWithStatus[] = followerData?.data.map((f) => ({ ...f, @@ -215,7 +222,7 @@ const ProfileCard = ({ mypage }: ProfileCardProps) => { {!isMyProfile && !isAdmin && (
- openChatRoom(member_id)} /> + - - + + + diff --git a/src/pages/PromptDetailPage/components/PromptActions.tsx b/src/pages/PromptDetailPage/components/PromptActions.tsx index 0ec4caa2..94b71ef3 100644 --- a/src/pages/PromptDetailPage/components/PromptActions.tsx +++ b/src/pages/PromptDetailPage/components/PromptActions.tsx @@ -17,6 +17,7 @@ import ReviewList from './ReviewList'; import ReportModal from '../components/ReportModal'; import DownloadModal from '../components/DownloadModal'; import CreateModal from '../components/CreateModal'; +import ShareModal from '../components/ShareModal'; import usePromptDownload from '@/hooks/mutations/PromptDetailPage/usePromptDownload'; import usePromptLike from '@/hooks/mutations/PromptDetailPage/usePromptLike'; @@ -109,6 +110,7 @@ const PromptActions = ({ const [isReportModalOpen, setIsReportModalOpen] = useState(false); const [isDownloadModalOpen, setIsDownloadModalOpen] = useState(false); const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false); + const [isShareModalOpen, setIsShareModalOpen] = useState(false); const handlePaid = () => { setIsPaid(true); @@ -350,9 +352,6 @@ const PromptActions = ({ {/* 제목 */}
[{truncateTitle(title)}]
- {/* 가격 */} -
{isFree ? '무료' : `${price.toLocaleString()}원`}
- {/* 버튼 영역 */}
{isAdmin ? ( @@ -368,7 +367,15 @@ const PromptActions = ({ buttonType="squareBig" style="fill" imgType="download" - text={isDownloading ? '불러오는 중…' : '다운로드'} + text={ + isDownloading + ? '불러오는 중…' + : isPaid && !isFree + ? '구매완료' + : isFree + ? '다운로드' + : `₩${price.toLocaleString()}` + } onClick={() => handleShowLoginModal(handleDownloadClick)} /> {isPaymentModalOpen && ( //유료프롬프트 & 미결제 시 PaymentModal 열기 @@ -402,6 +409,22 @@ const PromptActions = ({ className="ml-[34px] w-[28px] h-[25px] cursor-pointer" onClick={handleToggleLike} /> + + {/* 공유 버튼 (찜하기 버튼 UI 재사용) */} + + + setIsShareModalOpen(false)} title={title} />
{/* 별점 및 리뷰보기 */} diff --git a/src/pages/PromptDetailPage/components/PromptAuthorAndReview.tsx b/src/pages/PromptDetailPage/components/PromptAuthorAndReview.tsx index ae3a1390..4158a4ec 100644 --- a/src/pages/PromptDetailPage/components/PromptAuthorAndReview.tsx +++ b/src/pages/PromptDetailPage/components/PromptAuthorAndReview.tsx @@ -20,14 +20,14 @@ import { useQueryClient } from '@tanstack/react-query'; import useGetPromptReviews from '@/hooks/queries/PromptDetailPage/useGetAllPromptReviews'; import useUpdateReview from '@/hooks/mutations/PromptDetailPage/useUpdateReview'; import { useLocation } from 'react-router-dom'; +import { useOpenChatRoom } from '@/hooks/useOpenChatRoom'; +import SocialLoginModal from '@/components/Modal/SocialLoginModal'; import InstaIcon from '@assets/icon-instagram-logo.svg'; import YoutubeIcon from '@assets/icon-youtube-logo.svg'; import XIcon from '@assets/icon-x-logo.svg'; import TextModal from '@components/Modal/TextModal'; import star from '../assets/star.png'; -import usePostChatRooms from '@/hooks/mutations/ChatPage/usePostChatRooms'; -import { useOpenChatRoom } from '@/hooks/useOpenChatRoom'; interface Review { review_id: number; @@ -93,7 +93,7 @@ const PromptAuthorAndReview = ({ }); const { mutate: mutateFollow } = usePatchFollow({ member_id }); const { mutate: mutateUnFollow } = useDeleteFollow({ member_id }); - const { handleShowLoginModal } = useShowLoginModal(); + const { loginModalShow, setLoginModalShow, handleShowLoginModal } = useShowLoginModal(); const [rating, setRating] = useState(0); const [reviewText, setReviewText] = useState(''); @@ -282,6 +282,13 @@ const PromptAuthorAndReview = ({ } }; + // 채팅하기 로그인 관련 + const handleChatting = () => { + handleShowLoginModal(() => { + openChatRoom(member_id); + }); + }; + return (
{/* 작성자 프로필 카드 */} @@ -318,8 +325,8 @@ const PromptAuthorAndReview = ({
@@ -413,6 +420,9 @@ const PromptAuthorAndReview = ({
{showModal && setShowModal(false)} size="lg" />} + {loginModalShow && ( + setLoginModalShow(false)} onClick={() => {}} /> + )}
); }; diff --git a/src/pages/PromptDetailPage/components/PromptDetailCard.tsx b/src/pages/PromptDetailPage/components/PromptDetailCard.tsx index 5f645cd6..83516e35 100644 --- a/src/pages/PromptDetailPage/components/PromptDetailCard.tsx +++ b/src/pages/PromptDetailPage/components/PromptDetailCard.tsx @@ -24,11 +24,8 @@ import reportIcon from '../assets/report.svg'; import star from '../assets/star.png'; import ReportModal from '../components/ReportModal'; import arrowRightBlack from '../assets/arrow_right.svg'; -import XIcon from '@assets/icon-x-logo.svg'; -import KakaoIcon from '../assets/kakaotalk-logo.svg'; -import LinkIcon from '../assets/link-logo.svg'; -import FacebookIcon from '../assets/facebook-logo.svg'; import usePayment from '@/hooks/mutations/MainPage/usePostRequestPayment'; +import ShareModal from './ShareModal'; interface Props { title: string; @@ -126,6 +123,7 @@ const PromptDetailCard = ({ const displayMain = normalizedImages[activeIdx] ?? ''; const [liked, setLiked] = useState(false); + const [isShareModalOpen, setIsShareModalOpen] = useState(false); const { data: likedSet } = useMyLikedPrompts(); @@ -222,73 +220,10 @@ const PromptDetailCard = ({ // downloadPrompt(promptId); // }; - const currentUrl = window.location.href; - - // Kakao SDK 초기화 - useEffect(() => { - if (!window.Kakao) return; - if (!window.Kakao.isInitialized()) { - window.Kakao.init(import.meta.env.VITE_KAKAO_JAVASCRIPT_KEY); - } - }, []); - - // Facebook 공유 - const handleFacebookShare = () => { - const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(currentUrl)}`; - window.open(url, '_blank', 'width=600,height=400'); - }; - - // X (트위터) - const handleXShare = () => { - const text = encodeURIComponent(`${title} - PromptPlace`); - const url = `https://twitter.com/intent/tweet?text=${text}&url=${encodeURIComponent(currentUrl)}`; - window.open(url, '_blank', 'width=600,height=400'); - }; - - // Kakao 공유 - const handleKakaoShare = () => { - if (!window.Kakao) { - alert('카카오 SDK를 불러오지 못했습니다.'); - return; - } - - window.Kakao.Share.sendDefault({ - objectType: 'feed', - content: { - title: title, - description: 'PromptPlace에서 확인해보세요!', - imageUrl: displayMain || 'https://your-default-image-url.com/logo.png', - link: { - mobileWebUrl: currentUrl, - webUrl: currentUrl, - }, - }, - buttons: [ - { - title: '프롬프트 보러가기', - link: { - mobileWebUrl: currentUrl, - webUrl: currentUrl, - }, - }, - ], - }); - }; - - // 링크 복사 - const handleCopyLink = async () => { - try { - await navigator.clipboard.writeText(currentUrl); - alert('링크가 복사되었습니다!'); - } catch { - alert('복사에 실패했습니다.'); - } - }; - const isPaidPrompt = !isFree; // 유료 프롬프트 여부 const hasPurchased = isPaidPrompt && isPaid; // 구매 완료 여부(유료일 때만 의미 있음) - const actionLabel = hasPurchased || isFree ? '프롬프트 다운로드' : '프롬프트 구매하기'; + const actionLabel = isFree ? '다운로드' : hasPurchased ? '구매완료' : `₩${price.toLocaleString()}`; const guideText = hasPurchased || isFree @@ -457,27 +392,21 @@ const PromptDetailCard = ({
{/* 왼쪽 영역 */} -
- 업로드   {uploadedAt} - - -
- - {/* 오른쪽 공유 */} -
- 공유 - - - - +
+
+ 업로드   {uploadedAt} + + +
+
+ +
@@ -564,28 +493,33 @@ const PromptDetailCard = ({ 태그가 없습니다. )}
- {/* 가격 + 버튼 */} + {/* 찜하기 + 공유 버튼 */}
-

{isFree ? '무료' : `${price.toLocaleString()}원`}

- {isPaid && !isFree && 구매 완료} - {guideText} - -
- - +
+ {/* 찜하기 버튼 */} + + {/* 공유 버튼 */} +
+ + setIsShareModalOpen(false)} title={title} />
diff --git a/src/pages/PromptDetailPage/components/ShareModal.tsx b/src/pages/PromptDetailPage/components/ShareModal.tsx new file mode 100644 index 00000000..f9d99ea0 --- /dev/null +++ b/src/pages/PromptDetailPage/components/ShareModal.tsx @@ -0,0 +1,123 @@ +import useMediaQuery from '@/hooks/queries/PromptDetailPage/useMediaQuery'; +import FacebookIcon from '../assets/facebook-logo.svg'; +import KakaoIcon from '../assets/kakaotalk-logo.svg'; +import LinkIcon from '../assets/link-logo.svg'; +import XIcon from '@assets/icon-x-logo.svg'; + +interface ShareModalProps { + isOpen: boolean; + onClose: () => void; + title: string; +} + +const ShareModal = ({ isOpen, onClose, title }: ShareModalProps) => { + const isMobile = useMediaQuery('(max-width: 1024px)'); + + if (!isOpen) return null; + + const currentUrl = window.location.href; + + const handleFacebookShare = () => { + window.open( + `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(currentUrl)}`, + '_blank', + 'width=600,height=400', + ); + onClose(); + }; + + const handleXShare = () => { + const text = encodeURIComponent(`${title} - PromptPlace`); + window.open( + `https://twitter.com/intent/tweet?text=${text}&url=${encodeURIComponent(currentUrl)}`, + '_blank', + 'width=600,height=400', + ); + onClose(); + }; + + const handleKakaoShare = () => { + if (!window.Kakao) { + alert('카카오 SDK를 불러오지 못했습니다.'); + return; + } + window.Kakao.Share.sendDefault({ + objectType: 'feed', + content: { + title, + description: 'PromptPlace에서 확인해보세요!', + imageUrl: 'https://promptplace.co.kr/favicon-96x96.png', + link: { mobileWebUrl: currentUrl, webUrl: currentUrl }, + }, + buttons: [ + { + title: '프롬프트 보러가기', + link: { mobileWebUrl: currentUrl, webUrl: currentUrl }, + }, + ], + }); + onClose(); + }; + + const handleCopyLink = async () => { + try { + await navigator.clipboard.writeText(currentUrl); + alert('링크가 복사되었습니다!'); + } catch { + alert('복사에 실패했습니다.'); + } + onClose(); + }; + + // 데스크탑: 버튼 64×64 / 모바일: 56×56 + const btnSize = isMobile ? 'w-[56px] h-[56px]' : 'w-[64px] h-[64px]'; + // 아이콘은 버튼보다 살짝 작게 (여백감 유지) + const iconSize = isMobile ? 'w-[36px] h-[36px]' : 'w-[44px] h-[44px]'; + const labelSize = isMobile ? 'text-[10px]' : 'text-[12px]'; + + const items = [ + { label: 'Facebook', icon: FacebookIcon, onClick: handleFacebookShare }, + { label: '카카오톡', icon: KakaoIcon, onClick: handleKakaoShare }, + { label: 'X', icon: XIcon, onClick: handleXShare }, + { label: '링크복사', icon: LinkIcon, onClick: handleCopyLink }, + ]; + + return ( +
+
e.stopPropagation()}> + {/* 헤더 */} +
+

공유

+ +
+ + {/* 아이콘 4열 가로 배열 */} +
+ {items.map(({ label, icon, onClick }) => ( + + ))} +
+
+
+ ); +}; + +export default ShareModal;