diff --git a/package.json b/package.json index 59919698..980735c3 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@types/react-datepicker": "^7.0.0", "react": "^19.1.0", + "react-datepicker": "^8.4.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.0", "zustand": "^5.0.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b781d362..9dadad53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,15 @@ importers: '@emotion/styled': specifier: ^11.14.0 version: 11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0) + '@types/react-datepicker': + specifier: ^7.0.0 + version: 7.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: ^19.1.0 version: 19.1.0 + react-datepicker: + specifier: ^8.4.0 + version: 8.4.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) @@ -398,6 +404,27 @@ packages: resolution: {integrity: sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@floating-ui/core@1.7.2': + resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==} + + '@floating-ui/dom@1.7.2': + resolution: {integrity: sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==} + + '@floating-ui/react-dom@2.1.4': + resolution: {integrity: sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.13': + resolution: {integrity: sha512-Qmj6t9TjgWAvbygNEu1hj4dbHI9CY0ziCMIJrmYoDIn9TUAH5lRmiIeZmRd4c6QEZkzdoH7jNnoNyoY1AIESiA==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -576,6 +603,10 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/react-datepicker@7.0.0': + resolution: {integrity: sha512-4tWwOUq589tozyQPBVEqGNng5DaZkomx5IVNuur868yYdgjH6RaL373/HKiVt1IDoNNXYiTGspm1F7kjrarM8Q==} + deprecated: This is a stub types definition. react-datepicker provides its own type definitions, so you do not need this installed. + '@types/react-dom@19.1.3': resolution: {integrity: sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==} peerDependencies: @@ -710,6 +741,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -761,6 +796,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -1280,6 +1318,12 @@ packages: resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} engines: {node: '>= 0.8'} + react-datepicker@8.4.0: + resolution: {integrity: sha512-6nPDnj8vektWCIOy9ArS3avus9Ndsyz5XgFCJ7nBxXASSpBdSL6lG9jzNNmViPOAOPh6T5oJyGaXuMirBLECag==} + peerDependencies: + react: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom@19.1.0: resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} peerDependencies: @@ -1421,6 +1465,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + tinyglobby@0.2.13: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} @@ -1889,6 +1936,31 @@ snapshots: '@eslint/core': 0.13.0 levn: 0.4.1 + '@floating-ui/core@1.7.2': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.2': + dependencies: + '@floating-ui/core': 1.7.2 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/dom': 1.7.2 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@floating-ui/react@0.27.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react-dom': 2.1.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@floating-ui/utils': 0.2.10 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tabbable: 6.2.0 + + '@floating-ui/utils@0.2.10': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -2037,6 +2109,13 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/react-datepicker@7.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + react-datepicker: 8.4.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + transitivePeerDependencies: + - react + - react-dom + '@types/react-dom@19.1.3(@types/react@19.1.3)': dependencies: '@types/react': 19.1.3 @@ -2220,6 +2299,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2265,6 +2346,8 @@ snapshots: csstype@3.1.3: {} + date-fns@4.1.0: {} + debug@4.4.0: dependencies: ms: 2.1.3 @@ -2777,6 +2860,14 @@ snapshots: iconv-lite: 0.6.3 unpipe: 1.0.0 + react-datepicker@8.4.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@floating-ui/react': 0.27.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + clsx: 2.1.1 + date-fns: 4.1.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 @@ -2941,6 +3032,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tabbable@6.2.0: {} + tinyglobby@0.2.13: dependencies: fdir: 6.4.4(picomatch@4.0.2) diff --git a/src/assets/books/deer.svg b/src/assets/books/deer.svg new file mode 100644 index 00000000..cde0bdf0 --- /dev/null +++ b/src/assets/books/deer.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/books/hormone.svg b/src/assets/books/hormone.svg new file mode 100644 index 00000000..ad63bf44 --- /dev/null +++ b/src/assets/books/hormone.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/books/life.svg b/src/assets/books/life.svg new file mode 100644 index 00000000..27ec3503 --- /dev/null +++ b/src/assets/books/life.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/books/tomato.svg b/src/assets/books/tomato.svg new file mode 100644 index 00000000..c5eae1a1 --- /dev/null +++ b/src/assets/books/tomato.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/group/close.svg b/src/assets/group/close.svg new file mode 100644 index 00000000..664c4bc6 --- /dev/null +++ b/src/assets/group/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/group/search.svg b/src/assets/group/search.svg new file mode 100644 index 00000000..286aac04 --- /dev/null +++ b/src/assets/group/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/group/search_white.svg b/src/assets/group/search_white.svg new file mode 100644 index 00000000..1befa622 --- /dev/null +++ b/src/assets/group/search_white.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts new file mode 100644 index 00000000..a75a27f4 --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts @@ -0,0 +1,207 @@ +import styled from '@emotion/styled'; +import { colors, semanticColors, typography } from '../../../styles/global/global'; + +export const Overlay = styled.div<{ isVisible: boolean }>` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + backdrop-filter: blur(2px); + z-index: 1000; + opacity: ${({ isVisible }) => (isVisible ? 1 : 0)}; + visibility: ${({ isVisible }) => (isVisible ? 'visible' : 'hidden')}; + transition: + opacity 0.3s ease, + visibility 0.3s ease; +`; + +export const BottomSheetContainer = styled.div<{ isVisible: boolean }>` + position: fixed; + bottom: 0; + left: 0; + right: 0; + max-width: 767px; + margin: 0 auto; + background-color: ${colors.darkgrey.main}; + border-radius: 20px 20px 0 0; + z-index: 1001; + transform: translateY(${({ isVisible }) => (isVisible ? '0' : '100%')}); + transition: transform 0.3s ease; + max-height: 50vh; + overflow: hidden; + display: flex; + flex-direction: column; +`; + +export const Content = styled.div` + padding: 20px; + display: flex; + flex-direction: column; + height: 50vh; +`; + +export const SearchContainer = styled.div` + display: flex; + align-items: center; + background-color: ${colors.darkgrey.dark}; + border-radius: 12px; + padding: 8px 12px; + gap: 12px; + margin-bottom: 20px; + position: relative; + flex-shrink: 0; +`; + +export const SearchInputWrapper = styled.div` + display: flex; + align-items: center; + flex: 1; + gap: 8px; +`; + +export const SearchInput = styled.input` + background: none; + border: none; + outline: none; + color: ${semanticColors.text.primary}; + font-size: ${typography.fontSize.base}; + flex: 1; + caret-color: ${semanticColors.text.point.green}; + + &::placeholder { + color: ${semanticColors.text.ghost}; + font-size: ${typography.fontSize.base}; + } +`; + +export const ButtonGroup = styled.div` + display: flex; + align-items: center; + gap: 20px; +`; + +export const IconButton = styled.button` + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + padding: 0; + + img { + width: 24px; + height: 24px; + } + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } +`; + +export const TabContainer = styled.div` + display: flex; + gap: 34px; + margin-bottom: 24px; + flex-shrink: 0; +`; + +export const Tab = styled.button<{ active: boolean }>` + background: none; + border: none; + color: ${({ active }) => (active ? semanticColors.text.primary : semanticColors.text.ghost)}; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.semibold}; + padding: 8px 0 8px 0; + cursor: pointer; + position: relative; + + ${({ active }) => + active && + ` + &::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background-color: ${semanticColors.text.primary}; + } + `} +`; + +export const BookListContainer = styled.div` + flex: 1; + overflow-y: auto; + margin-right: -16px; + padding-right: 16px; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: ${colors.grey[400]}; + border-radius: 2px; + margin-right: 14px; + } + + &::-webkit-scrollbar-thumb { + background-color: ${colors.white}; + border-radius: 2px; + } + + &::-webkit-scrollbar-thumb:hover { + background-color: ${colors.grey[200]}; + } + + /* Firefox 스크롤바 */ + scrollbar-width: thin; + scrollbar-color: ${colors.white} ${colors.grey[400]}; +`; + +export const BookList = styled.div` + display: flex; + flex-direction: column; + gap: 0; +`; + +export const BookItem = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 12px 0; + border-bottom: 1px solid ${colors.grey[400]}; + cursor: pointer; +`; + +export const BookCover = styled.div` + width: 45px; + height: 60px; + overflow: hidden; + flex-shrink: 0; + background-color: ${semanticColors.background.card}; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +`; + +export const BookInfo = styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +`; + +export const BookTitle = styled.div` + color: ${semanticColors.text.primary}; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; + line-height: 1.4; +`; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx new file mode 100644 index 00000000..bc3a3c19 --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -0,0 +1,203 @@ +import { useState, useEffect } from 'react'; +import closeIcon from '../../../assets/group/close.svg'; +import whitesearchIcon from '../../../assets/group/search_white.svg'; +import { + Overlay, + BottomSheetContainer, + Content, + SearchContainer, + SearchInputWrapper, + SearchInput, + ButtonGroup, + IconButton, + TabContainer, + Tab, + BookListContainer, + BookList, + BookItem, + BookCover, + BookInfo, + BookTitle, +} from './BookSearchBottomSheet.styled.ts'; + +// Types +interface Book { + id: number; + title: string; + author: string; + cover: string; +} + +interface BookSearchBottomSheetProps { + isOpen: boolean; + onClose: () => void; + onSelectBook: (book: Book) => void; +} + +type TabType = 'saved' | 'group'; + +// Mock Data +const mockBooks: Book[] = [ + { + id: 1, + title: '토마토 컵라면', + author: '작가명', + cover: '/src/assets/books/tomato.svg', + }, + { + id: 2, + title: '사슴', + author: '작가명', + cover: '/src/assets/books/deer.svg', + }, + { + id: 3, + title: '호르몬 체인지', + author: '작가명', + cover: '/src/assets/books/hormone.svg', + }, + { + id: 4, + title: '토마토 컵라면', + author: '작가명', + cover: '/src/assets/books/tomato.svg', + }, + { + id: 5, + title: '사슴', + author: '작가명', + cover: '/src/assets/books/deer.svg', + }, + { + id: 6, + title: '호르몬 체인지', + author: '작가명', + cover: '/src/assets/books/hormone.svg', + }, + { + id: 7, + title: '단 한번의 삶', + author: '작가명', + cover: '/src/assets/books/life.svg', + }, +]; + +const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBottomSheetProps) => { + // State + const [searchQuery, setSearchQuery] = useState(''); + const [filteredBooks, setFilteredBooks] = useState(mockBooks); + const [activeTab, setActiveTab] = useState('saved'); + + // Effects + useEffect(() => { + if (searchQuery.trim() === '') { + setFilteredBooks(mockBooks); + } else { + const filtered = mockBooks.filter( + book => + book.title.toLowerCase().includes(searchQuery.toLowerCase()) || + book.author.toLowerCase().includes(searchQuery.toLowerCase()), + ); + setFilteredBooks(filtered); + } + }, [searchQuery]); + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'unset'; + } + + return () => { + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + // Handlers + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + const handleBookSelect = (book: Book) => { + onSelectBook(book); + onClose(); + }; + + const handleSearch = () => { + // 실제 검색 API 호출 로직 + console.log('검색:', searchQuery); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearch(); + } + }; + + const handleImageError = (e: React.SyntheticEvent) => { + e.currentTarget.style.display = 'none'; + }; + + const handleClearSearch = () => { + setSearchQuery(''); + }; + + return ( + + + + {/* 검색 영역 */} + + + setSearchQuery(e.target.value)} + onKeyPress={handleKeyPress} + /> + + + + 닫기 + + + 검색 + + + + + {/* 탭 영역 */} + + setActiveTab('saved')}> + 저장한 책 + + setActiveTab('group')}> + 모임 책 + + + + {/* 책 목록 영역 */} + + + {filteredBooks.map(book => ( + handleBookSelect(book)}> + + {book.title} + + + {book.title} + + + ))} + + + + + + ); +}; + +export default BookSearchBottomSheet; diff --git a/src/pages/group/CommonSection.styled.ts b/src/pages/group/CommonSection.styled.ts new file mode 100644 index 00000000..a448850c --- /dev/null +++ b/src/pages/group/CommonSection.styled.ts @@ -0,0 +1,18 @@ +import styled from '@emotion/styled'; +import { colors, typography, semanticColors } from '../../styles/global/global'; + +export const Section = styled.div<{ showDivider?: boolean }>` + margin-bottom: 32px; + ${({ showDivider }) => + showDivider && + ` + border-bottom: 1px solid ${colors.darkgrey.dark}; + `} +`; + +export const SectionTitle = styled.div` + color: ${semanticColors.text.primary}; + font-size: ${typography.fontSize.lg}; + font-weight: ${typography.fontWeight.semibold}; + margin-bottom: 12px; +`; diff --git a/src/pages/group/CreateGroup.styled.ts b/src/pages/group/CreateGroup.styled.ts new file mode 100644 index 00000000..ca28490b --- /dev/null +++ b/src/pages/group/CreateGroup.styled.ts @@ -0,0 +1,14 @@ +import styled from '@emotion/styled'; +import { semanticColors } from '../../styles/global/global'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + background-color: ${semanticColors.background.primary}; + min-width: 360px; + max-width: 767px; + min-height: 100vh; + margin: 0 auto; + padding: 96px 20px 100px 20px; + box-sizing: border-box; +`; diff --git a/src/pages/group/CreateGroup.tsx b/src/pages/group/CreateGroup.tsx new file mode 100644 index 00000000..f49f8c6c --- /dev/null +++ b/src/pages/group/CreateGroup.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import TitleHeader from '../../components/common/TitleHeader'; +import BookSearchBottomSheet from '../../components/common/BookSearchBottomSheet/BookSearchBottomSheet'; +import BookSelectionSection from './components/BookSelectionSection'; +import GenreSelectionSection from './components/GenreSelectionSection'; +import RoomInfoSection from './components/RoomInfoSection'; +import ActivityPeriodSection from './components/ActivityPeriodSection/ActivityPeriodSection'; +import MemberLimitSection from './components/MemberLimitSection'; +import PrivacySettingSection from './components/PrivacySettingSection/PrivacySettingSection'; +import leftarrow from '../../assets/leftArrow.svg'; +import { Container } from './CreateGroup.styled'; +import { Section } from './CommonSection.styled'; + +const CreateGroup = () => { + const navigate = useNavigate(); + const [bookTitle, setBookTitle] = useState(''); + const [selectedBook, setSelectedBook] = useState(null); + const [selectedGenre, setSelectedGenre] = useState(''); + const [roomTitle, setRoomTitle] = useState(''); + const [roomDescription, setRoomDescription] = useState(''); + const [startDate, setStartDate] = useState({ year: 2025, month: 1, day: 1 }); + const [endDate, setEndDate] = useState({ year: 2025, month: 1, day: 1 }); + const [memberLimit, setMemberLimit] = useState(1); + const [isPrivate, setIsPrivate] = useState(false); + const [password, setPassword] = useState(''); + const [isBookSearchOpen, setIsBookSearchOpen] = useState(false); + + const handleBackClick = () => { + navigate(-1); + }; + + const handleCompleteClick = () => { + // 완료 로직 추후 구현 + console.log('모임 생성 완료'); + }; + + const handleBookSearchOpen = () => { + setIsBookSearchOpen(true); + }; + + const handleChangeBook = () => { + setIsBookSearchOpen(true); + }; + + const handleBookSearchClose = () => { + setIsBookSearchOpen(false); + }; + + const handleBookSelect = (book: any) => { + setSelectedBook(book); + setBookTitle(book.title); + }; + + const handleGenreSelect = (genre: string) => { + setSelectedGenre(genre); + }; + + const handlePrivacyToggle = () => { + setIsPrivate(!isPrivate); + // 비공개 설정을 끄면 비밀번호도 초기화 + if (isPrivate) { + setPassword(''); + } + }; + + const handlePasswordChange = (newPassword: string) => { + setPassword(newPassword); + }; + + const handlePasswordClose = () => { + setPassword(''); + }; + + const isFormValid = (selectedBook || bookTitle.trim() !== '') && selectedGenre !== ''; + + return ( + <> + } + title="모임 만들기" + rightButton="완료" + onLeftClick={handleBackClick} + onRightClick={handleCompleteClick} + isNextActive={isFormValid} + /> + + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + + + + + ); +}; + +export default CreateGroup; diff --git a/src/pages/group/components/ActivityPeriodSection/ActivityPeriodSection.styled.ts b/src/pages/group/components/ActivityPeriodSection/ActivityPeriodSection.styled.ts new file mode 100644 index 00000000..80fa8440 --- /dev/null +++ b/src/pages/group/components/ActivityPeriodSection/ActivityPeriodSection.styled.ts @@ -0,0 +1,122 @@ +import styled from '@emotion/styled'; +import { typography, semanticColors, colors } from '../../../../styles/global/global'; + +export const DatePickerContainer = styled.div` + color: ${semanticColors.text.primary}; +`; + +export const DateRangeContainer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 30px; + width: 100%; +`; + +export const DateGroup = styled.div` + display: flex; + align-items: center; + gap: 2px; + flex: 1; + justify-content: center; +`; + +export const WheelContainer = styled.div` + position: relative; + display: flex; + align-items: center; + gap: 4px; + height: 150px; +`; + +export const WheelSelector = styled.div` + position: relative; + height: 150px; + overflow: hidden; + touch-action: pan-y; + user-select: none; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + /* 스크롤바 숨기기 */ + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 56px; + height: 46px; + background-color: ${colors.grey[400]}; + border-radius: 8px; + z-index: 1; + pointer-events: none; + } +`; + +export const WheelList = styled.div<{ offset: number }>` + position: relative; + width: 100%; + z-index: 2; +`; + +export const WheelItem = styled.div<{ isSelected: boolean }>` + height: 50px; + display: flex; + align-items: center; + justify-content: center; + font-size: ${({ isSelected }) => (isSelected ? '20px' : '16px')}; + font-weight: ${({ isSelected }) => + isSelected ? typography.fontWeight.bold : typography.fontWeight.medium}; + color: ${({ isSelected }) => + isSelected ? semanticColors.text.primary : semanticColors.text.tertiary}; + transition: all 0.2s ease; + cursor: pointer; + position: relative; + + &:hover { + color: ${semanticColors.text.secondary}; + } +`; + +export const DateUnitText = styled.div` + color: ${semanticColors.text.primary}; + font-size: ${typography.fontSize.xs}; + font-weight: ${typography.fontWeight.regular}; + margin-right: 6px; +`; + +export const SeparatorText = styled.div` + color: ${semanticColors.text.primary}; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; + flex-shrink: 0; + display: flex; + align-items: center; +`; + +export const InfoText = styled.div` + text-align: end; + color: ${semanticColors.text.point.green}; + font-size: ${typography.fontSize.xs}; + font-weight: ${typography.fontWeight.regular}; + margin-top: 20px; +`; + +export const ErrorText = styled.div` + text-align: end; + color: ${semanticColors.text.warning}; + font-size: ${typography.fontSize.xs}; + font-weight: ${typography.fontWeight.regular}; + margin-top: 20px; +`; diff --git a/src/pages/group/components/ActivityPeriodSection/ActivityPeriodSection.tsx b/src/pages/group/components/ActivityPeriodSection/ActivityPeriodSection.tsx new file mode 100644 index 00000000..8ad34dcc --- /dev/null +++ b/src/pages/group/components/ActivityPeriodSection/ActivityPeriodSection.tsx @@ -0,0 +1,260 @@ +import { useEffect, useMemo } from 'react'; +import { Section, SectionTitle } from '../../CommonSection.styled'; +import DateWheel from './DateWheel'; +import { + DatePickerContainer, + DateRangeContainer, + DateGroup, + DateUnitText, + SeparatorText, + InfoText, + ErrorText, +} from './ActivityPeriodSection.styled'; + +interface ActivityPeriodSectionProps { + startDate: { year: number; month: number; day: number }; + endDate: { year: number; month: number; day: number }; + onStartDateChange: (date: { year: number; month: number; day: number }) => void; + onEndDateChange: (date: { year: number; month: number; day: number }) => void; +} + +const ActivityPeriodSection = ({ + startDate, + endDate, + onStartDateChange, + onEndDateChange, +}: ActivityPeriodSectionProps) => { + // 현재 년도와 다음 년도 + const currentYear = new Date().getFullYear(); + const years = [currentYear, currentYear + 1]; + + // 월 배열 (1-12) + const months = Array.from({ length: 12 }, (_, i) => i + 1); + + // 일 배열 계산 (선택된 년/월에 따라 동적으로 변경) + const getDaysInMonth = (year: number, month: number) => { + const daysInMonth = new Date(year, month, 0).getDate(); + return Array.from({ length: daysInMonth }, (_, i) => i + 1); + }; + + const startDays = getDaysInMonth(startDate.year, startDate.month); + const endDays = getDaysInMonth(endDate.year, endDate.month); + + // 날짜 간 일수 계산 + const calculateDaysDifference = (start: Date, end: Date): number => { + const timeDiff = end.getTime() - start.getTime(); + return Math.ceil(timeDiff / (1000 * 3600 * 24)) + 1; // +1은 시작일 포함 + }; + + // 현재 선택된 날짜들로 일수 계산 + const daysDifference = useMemo(() => { + const startDateObj = new Date(startDate.year, startDate.month - 1, startDate.day); + const endDateObj = new Date(endDate.year, endDate.month - 1, endDate.day); + return calculateDaysDifference(startDateObj, endDateObj); + }, [startDate, endDate]); + + // 90일 초과 여부 확인 + const isOverMaxDays = daysDifference > 90; + + // 종료일이 시작일보다 빠른지 확인 (추가) + const isEndDateBeforeStart = useMemo(() => { + const startDateObj = new Date(startDate.year, startDate.month - 1, startDate.day); + const endDateObj = new Date(endDate.year, endDate.month - 1, endDate.day); + return endDateObj < startDateObj; + }, [startDate, endDate]); + + // 오늘 날짜로 초기값 설정 + const getInitialDate = () => { + const today = new Date(); + return { + year: today.getFullYear(), + month: today.getMonth() + 1, + day: today.getDate(), + }; + }; + + const getInitialEndDate = () => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + return { + year: tomorrow.getFullYear(), + month: tomorrow.getMonth() + 1, + day: tomorrow.getDate(), + }; + }; + + // 날짜 유효성 검사 및 조정 + const validateAndAdjustDate = ( + date: { year: number; month: number; day: number }, + isEndDate = false, + ) => { + const today = new Date(); + const selectedDate = new Date(date.year, date.month - 1, date.day); + const daysInSelectedMonth = new Date(date.year, date.month, 0).getDate(); + + let adjustedDate = { ...date }; + + // 일수가 해당 월의 최대 일수를 초과하는 경우 조정 + if (date.day > daysInSelectedMonth) { + adjustedDate.day = daysInSelectedMonth; + } + + // 시작일이 오늘보다 이른 경우 조정 + if (!isEndDate && selectedDate < today) { + adjustedDate = getInitialDate(); + } + + // 종료일 검증 + if (isEndDate) { + const startDateObj = new Date(startDate.year, startDate.month - 1, startDate.day); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + // 종료일이 내일보다 이르거나 시작일보다 이른 경우 조정 + if (selectedDate < tomorrow || selectedDate < startDateObj) { + adjustedDate = getInitialEndDate(); + } + } + + return adjustedDate; + }; + + // 시작일 변경 핸들러 + const handleStartYearChange = (year: number) => { + const newDate = validateAndAdjustDate({ ...startDate, year }); + onStartDateChange(newDate); + + // 종료일도 재검증 + const adjustedEndDate = validateAndAdjustDate(endDate, true); + if (JSON.stringify(adjustedEndDate) !== JSON.stringify(endDate)) { + onEndDateChange(adjustedEndDate); + } + }; + + const handleStartMonthChange = (month: number) => { + const newDate = validateAndAdjustDate({ ...startDate, month }); + onStartDateChange(newDate); + + // 종료일도 재검증 + const adjustedEndDate = validateAndAdjustDate(endDate, true); + if (JSON.stringify(adjustedEndDate) !== JSON.stringify(endDate)) { + onEndDateChange(adjustedEndDate); + } + }; + + const handleStartDayChange = (day: number) => { + const newDate = validateAndAdjustDate({ ...startDate, day }); + onStartDateChange(newDate); + + // 종료일도 재검증 + const adjustedEndDate = validateAndAdjustDate(endDate, true); + if (JSON.stringify(adjustedEndDate) !== JSON.stringify(endDate)) { + onEndDateChange(adjustedEndDate); + } + }; + + // 종료일 변경 핸들러 + const handleEndYearChange = (year: number) => { + const newDate = validateAndAdjustDate({ ...endDate, year }, true); + onEndDateChange(newDate); + }; + + const handleEndMonthChange = (month: number) => { + const newDate = validateAndAdjustDate({ ...endDate, month }, true); + onEndDateChange(newDate); + }; + + const handleEndDayChange = (day: number) => { + const newDate = validateAndAdjustDate({ ...endDate, day }, true); + onEndDateChange(newDate); + }; + + // 컴포넌트 마운트 시 초기 날짜 유효성 검사 + useEffect(() => { + const validatedStartDate = validateAndAdjustDate(startDate); + const validatedEndDate = validateAndAdjustDate(endDate, true); + + if (JSON.stringify(validatedStartDate) !== JSON.stringify(startDate)) { + onStartDateChange(validatedStartDate); + } + + if (JSON.stringify(validatedEndDate) !== JSON.stringify(endDate)) { + onEndDateChange(validatedEndDate); + } + }, []); + + return ( +
+ 모임 활동기간 + + + {/* 시작일 */} + + + + + + + + + + + + ~ + + {/* 종료일 */} + + + + + + + + + + + + + {isEndDateBeforeStart ? ( + 종료일은 시작일보다 빠를 수 없어요. + ) : isOverMaxDays ? ( + 모임 활동기간은 최대 3개월까지 설정가능합니다. + ) : ( + 모임방 활동이 시작되면, 독서메이트 모집이 자동으로 종료돼요. + )} + +
+ ); +}; + +export default ActivityPeriodSection; diff --git a/src/pages/group/components/ActivityPeriodSection/DateWheel.tsx b/src/pages/group/components/ActivityPeriodSection/DateWheel.tsx new file mode 100644 index 00000000..512a297e --- /dev/null +++ b/src/pages/group/components/ActivityPeriodSection/DateWheel.tsx @@ -0,0 +1,205 @@ +import { useEffect, useRef, useState } from 'react'; +import { + WheelContainer, + WheelInner, + WheelSlides, + WheelShadowTop, + WheelShadowBottom, + WheelLabel, + WheelSlide, +} from './Wheel.styled'; + +interface DateWheelProps { + values: number[]; + selectedValue: number; + onChange: (value: number) => void; + label?: string; + width?: number; +} + +interface SliderState { + abs: number; + slides: Array<{ distance: number }>; +} + +const DateWheel = ({ values, selectedValue, onChange, label, width = 50 }: DateWheelProps) => { + const [sliderState, setSliderState] = useState(null); + const [radius, setRadius] = useState(0); + const [currentIndex, setCurrentIndex] = useState(0); + const containerRef = useRef(null); + const isDragging = useRef(false); + const startY = useRef(0); + const startIndex = useRef(0); + + const wheelSize = 15; + const slideDegree = 360 / wheelSize; + const slidesPerView = 2; + const slides = values.length; + + useEffect(() => { + const index = values.indexOf(selectedValue); + if (index !== -1) { + setCurrentIndex(index); + } + }, [selectedValue, values]); + + useEffect(() => { + if (containerRef.current) { + const containerHeight = 150; + setRadius(containerHeight / 2); + + const initialState: SliderState = { + abs: currentIndex, + slides: values.map((_, i) => ({ + distance: i - currentIndex, + })), + }; + setSliderState(initialState); + } + }, [currentIndex, values]); + + const handleInteractionStart = (clientY: number) => { + isDragging.current = true; + startY.current = clientY; + startIndex.current = currentIndex; + }; + + const handleInteractionMove = (clientY: number) => { + if (!isDragging.current) return; + + const deltaY = clientY - startY.current; + const sensitivity = 30; + const deltaIndex = Math.round(-deltaY / sensitivity); + const newIndex = Math.max(0, Math.min(values.length - 1, startIndex.current + deltaIndex)); + + if (newIndex !== currentIndex) { + setCurrentIndex(newIndex); + + const newState: SliderState = { + abs: newIndex, + slides: values.map((_, i) => ({ + distance: i - newIndex, + })), + }; + setSliderState(newState); + } + }; + + const handleInteractionEnd = () => { + if (!isDragging.current) return; + + isDragging.current = false; + onChange(values[currentIndex]); + }; + + const handleTouchStart = (e: React.TouchEvent) => { + e.preventDefault(); + handleInteractionStart(e.touches[0].clientY); + }; + + const handleTouchMove = (e: React.TouchEvent) => { + e.preventDefault(); + if (e.touches.length > 0) { + handleInteractionMove(e.touches[0].clientY); + } + }; + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + handleInteractionStart(e.clientY); + + const handleMouseMove = (moveEvent: MouseEvent) => { + handleInteractionMove(moveEvent.clientY); + }; + + const handleMouseUp = () => { + handleInteractionEnd(); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + const handleWheel = (e: React.WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? 1 : -1; + moveToIndex(delta); + }; + + const moveToIndex = (direction: number) => { + const newIndex = Math.max(0, Math.min(values.length - 1, currentIndex + direction)); + if (newIndex !== currentIndex) { + setCurrentIndex(newIndex); + onChange(values[newIndex]); + + const newState: SliderState = { + abs: newIndex, + slides: values.map((_, i) => ({ + distance: i - newIndex, + })), + }; + setSliderState(newState); + } + }; + + const slideValues = () => { + if (!sliderState || !sliderState.slides) return []; + + const offset = 0; + const valuesArray = []; + + for (let i = 0; i < slides; i++) { + const slideData = sliderState.slides[i]; + if (!slideData) continue; + + const distance = slideData.distance || 0; + const threshold = wheelSize / 2 + 1; + const rotate = Math.abs(distance) > threshold ? 180 : distance * (360 / wheelSize) * -1; + + const isSelected = i === currentIndex; + + const style = { + transform: `rotateX(${rotate}deg) translateZ(${radius}px)`, + WebkitTransform: `rotateX(${rotate}deg) translateZ(${radius}px)`, + }; + + const value = values[i]; + valuesArray.push({ style, value, isSelected }); + } + + return valuesArray; + }; + + return ( + + moveToIndex(-1)} /> + + + + {slideValues().map(({ style, value, isSelected }, idx) => ( + + {value} + + ))} + + + {label && {label}} + + + moveToIndex(1)} /> + + ); +}; + +export default DateWheel; diff --git a/src/pages/group/components/ActivityPeriodSection/Wheel.styled.ts b/src/pages/group/components/ActivityPeriodSection/Wheel.styled.ts new file mode 100644 index 00000000..ac8d015d --- /dev/null +++ b/src/pages/group/components/ActivityPeriodSection/Wheel.styled.ts @@ -0,0 +1,82 @@ +import styled from '@emotion/styled'; +import { colors, semanticColors, typography } from '../../../../styles/global/global'; + +export const WheelContainer = styled.div` + display: block; + height: 100%; + overflow: visible; + + &.wheel--perspective-center .wheel__inner { + perspective-origin: 50% 50%; + } +`; + +export const WheelInner = styled.div` + display: flex; + align-items: center; + justify-content: center; + perspective: 1000px; + transform-style: preserve-3d; + height: 20%; + width: 100%; + position: relative; + z-index: 2; +`; + +export const WheelSlides = styled.div` + height: 100%; + position: relative; + width: 100%; +`; + +export const WheelShadowTop = styled.div<{ radius: number }>` + background: linear-gradient(to bottom, ${colors.black.main} 0%, rgba(18, 18, 18, 0.4) 100%); + left: 0; + height: calc(40% + 2px); + width: 100%; + position: relative; + margin-top: -2px; + z-index: 5; + transform: translateZ(${({ radius }) => radius}px); + cursor: pointer; +`; + +export const WheelShadowBottom = styled.div<{ radius: number }>` + background: linear-gradient(to bottom, rgba(18, 18, 18, 0.4) 0%, ${colors.black.main} 100%); + left: 0; + height: calc(40% + 2px); + width: 100%; + position: relative; + margin-top: 2px; + z-index: 5; + border-bottom: none; + transform: translateZ(${({ radius }) => radius}px); + cursor: pointer; +`; + +export const WheelLabel = styled.div<{ radius: number }>` + font-weight: ${typography.fontWeight.regular}; + font-size: 12px; + line-height: 1; + margin-top: 1px; + margin-left: 5px; + color: ${semanticColors.text.primary}; + transform: translateZ(${({ radius }) => radius}px); +`; + +export const WheelSlide = styled.div<{ isSelected?: boolean }>` + align-items: center; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + display: flex; + font-weight: ${typography.fontWeight.regular}; + height: 32px; + width: 100%; + position: absolute; + justify-content: center; + color: ${semanticColors.text.primary}; + font-size: 12px; + background-color: ${({ isSelected }) => (isSelected ? colors.darkgrey.main : 'transparent')}; + border-radius: ${({ isSelected }) => (isSelected ? '4px' : '0')}; + transition: all 0.2s ease; +`; diff --git a/src/pages/group/components/BookSelectionSection.styled.ts b/src/pages/group/components/BookSelectionSection.styled.ts new file mode 100644 index 00000000..fc4327b6 --- /dev/null +++ b/src/pages/group/components/BookSelectionSection.styled.ts @@ -0,0 +1,84 @@ +import styled from '@emotion/styled'; +import { colors, typography, semanticColors } from '../../../styles/global/global'; + +export const SearchBox = styled.div<{ hasSelectedBook?: boolean }>` + display: flex; + justify-content: center; + background-color: ${semanticColors.background.primary}; + border: ${({ hasSelectedBook }) => (hasSelectedBook ? 'none' : `1px solid ${colors.grey[300]}`)}; + border-radius: 12px; + padding: ${({ hasSelectedBook }) => (hasSelectedBook ? '0' : '12px 16px')}; + gap: 16px; + cursor: ${({ hasSelectedBook }) => (hasSelectedBook ? 'default' : 'pointer')}; + transition: background-color 0.2s; + align-items: ${({ hasSelectedBook }) => (hasSelectedBook ? 'flex-end' : 'center')}; + + span { + font-size: ${typography.fontSize.base}; + font-weight: ${typography.fontWeight.medium}; + } +`; + +export const SearchIcon = styled.div` + display: flex; + align-items: center; + justify-content: center; + + img { + width: 24px; + height: 24px; + } +`; + +export const SelectedBookContainer = styled.div` + display: flex; + align-items: flex-start; + gap: 16px; + flex: 1; +`; + +export const SelectedBookCover = styled.div` + width: 60px; + height: 80px; + overflow: hidden; + flex-shrink: 0; + background-color: ${semanticColors.background.card}; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +`; + +export const SelectedBookInfo = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; +`; + +export const SelectedBookTitle = styled.div` + color: ${semanticColors.text.primary}; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.semibold}; + line-height: 1.4; +`; + +export const SelectedBookAuthor = styled.div` + color: ${semanticColors.text.tertiary}; + font-size: ${typography.fontSize.xs}; + font-weight: ${typography.fontWeight.medium}; +`; + +export const ChangeButton = styled.button` + background-color: ${semanticColors.button.fill.black}; + border: 1px solid ${colors.grey[300]}; + border-radius: 20px; + padding: 8px 12px; + color: ${semanticColors.text.secondary}; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.medium}; + cursor: pointer; + flex-shrink: 0; +`; diff --git a/src/pages/group/components/BookSelectionSection.tsx b/src/pages/group/components/BookSelectionSection.tsx new file mode 100644 index 00000000..b795ddf2 --- /dev/null +++ b/src/pages/group/components/BookSelectionSection.tsx @@ -0,0 +1,59 @@ +import { semanticColors } from '../../../styles/global/global'; +import searchIcon from '../../../assets/group/search.svg'; +import { Section, SectionTitle } from '../CommonSection.styled'; +import { + SearchBox, + SearchIcon, + SelectedBookContainer, + SelectedBookCover, + SelectedBookInfo, + SelectedBookTitle, + SelectedBookAuthor, + ChangeButton, +} from './BookSelectionSection.styled'; + +interface BookSelectionSectionProps { + selectedBook: { cover: string; title: string; author: string } | null; + onSearchClick: () => void; + onChangeClick: () => void; +} + +const BookSelectionSection = ({ + selectedBook, + onSearchClick, + onChangeClick, +}: BookSelectionSectionProps) => { + return ( +
+ 책 선택 + + {selectedBook ? ( + <> + + + {selectedBook.title} + + + {selectedBook.title} + {selectedBook.author} 저 + + + 변경 + + ) : ( + <> + + 검색 + + 검색해서 찾기 + + )} + +
+ ); +}; + +export default BookSelectionSection; diff --git a/src/pages/group/components/GenreSelectionSection.styled.ts b/src/pages/group/components/GenreSelectionSection.styled.ts new file mode 100644 index 00000000..4e94a2a0 --- /dev/null +++ b/src/pages/group/components/GenreSelectionSection.styled.ts @@ -0,0 +1,21 @@ +import styled from '@emotion/styled'; +import { colors, typography, semanticColors } from '../../../styles/global/global'; + +export const GenreButtonGroup = styled.div` + display: flex; + flex-wrap: wrap; + gap: 12px; +`; + +export const GenreButton = styled.button<{ active: boolean }>` + background-color: ${({ active }) => + active ? semanticColors.button.fill.primary : colors.grey[400]}; + border: none; + border-radius: 20px; + padding: 8px 12px; + color: ${semanticColors.text.primary}; + font-size: ${typography.fontSize.xs}; + font-weight: ${typography.fontWeight.regular}; + cursor: pointer; + transition: all 0.2s; +`; diff --git a/src/pages/group/components/GenreSelectionSection.tsx b/src/pages/group/components/GenreSelectionSection.tsx new file mode 100644 index 00000000..b867efe3 --- /dev/null +++ b/src/pages/group/components/GenreSelectionSection.tsx @@ -0,0 +1,41 @@ +import { semanticColors, typography } from '../../../styles/global/global'; +import { Section, SectionTitle } from '../CommonSection.styled'; +import { GenreButtonGroup, GenreButton } from './GenreSelectionSection.styled'; + +interface GenreSelectionSectionProps { + selectedGenre: string; + onGenreSelect: (genre: string) => void; +} + +const GenreSelectionSection = ({ selectedGenre, onGenreSelect }: GenreSelectionSectionProps) => { + const genres = ['문학', '과학·IT', '사회과학', '인문학', '예술']; + + return ( +
+ 책 장르 + + {genres.map(genre => ( + onGenreSelect(genre)} + > + {genre} + + ))} + +
+ {selectedGenre ? '1개만 선택 가능합니다.' : '책을 가장 잘 설명하는 장르를 하나 골라주세요.'} +
+
+ ); +}; + +export default GenreSelectionSection; diff --git a/src/pages/group/components/MemberLimitSection.styled.ts b/src/pages/group/components/MemberLimitSection.styled.ts new file mode 100644 index 00000000..16de1a8f --- /dev/null +++ b/src/pages/group/components/MemberLimitSection.styled.ts @@ -0,0 +1,21 @@ +import styled from '@emotion/styled'; +import { typography, semanticColors } from '../../../styles/global/global'; + +export const MemberLimitContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + gap: 16px; +`; + +export const MemberWheelContainer = styled.div` + display: flex; + align-items: center; + gap: 4px; +`; + +export const MemberText = styled.span` + color: ${semanticColors.text.primary}; + font-size: ${typography.fontSize.xs}; + font-weight: ${typography.fontWeight.regular}; +`; diff --git a/src/pages/group/components/MemberLimitSection.tsx b/src/pages/group/components/MemberLimitSection.tsx new file mode 100644 index 00000000..e07cac19 --- /dev/null +++ b/src/pages/group/components/MemberLimitSection.tsx @@ -0,0 +1,36 @@ +import { Section, SectionTitle } from '../CommonSection.styled'; +import DateWheel from './ActivityPeriodSection/DateWheel'; +import { + MemberLimitContainer, + MemberWheelContainer, + MemberText, +} from './MemberLimitSection.styled'; + +interface MemberLimitSectionProps { + memberLimit: number; + onMemberLimitChange: (limit: number) => void; +} + +const MemberLimitSection = ({ memberLimit, onMemberLimitChange }: MemberLimitSectionProps) => { + // 1부터 30까지의 배열 생성 + const memberNumbers = Array.from({ length: 30 }, (_, i) => i + 1); + + return ( +
+ 인원 제한 + + + + 명의 독서메이트를 모집합니다. + + +
+ ); +}; + +export default MemberLimitSection; diff --git a/src/pages/group/components/PrivacySettingSection/PasswordInputSection.styled.ts b/src/pages/group/components/PrivacySettingSection/PasswordInputSection.styled.ts new file mode 100644 index 00000000..d04b5128 --- /dev/null +++ b/src/pages/group/components/PrivacySettingSection/PasswordInputSection.styled.ts @@ -0,0 +1,62 @@ +import styled from '@emotion/styled'; +import { colors, typography, semanticColors } from '../../../../styles/global/global'; + +export const PasswordInputContainer = styled.div` + margin-top: 16px; +`; + +export const PasswordInputBox = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + background-color: ${semanticColors.background.cardDark}; + width: 100%; + height: 48px; + border-radius: 12px; + padding: 12px 16px; + box-sizing: border-box; +`; + +export const PasswordInput = styled.input` + background: none; + border: none; + outline: none; + color: ${semanticColors.text.primary}; + font-weight: ${typography.fontWeight.regular}; + font-size: ${typography.fontSize.sm}; + flex: 1; + caret-color: ${colors.neongreen}; + + &::placeholder { + color: ${colors.grey[300]}; + } + + /* 숫자만 입력 가능하도록 설정 */ + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &[type='number'] { + -moz-appearance: textfield; + } +`; + +export const CloseButton = styled.button` + background: none; + border: none; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + + img { + width: 20px; + height: 20px; + } +`; diff --git a/src/pages/group/components/PrivacySettingSection/PasswordInputSection.tsx b/src/pages/group/components/PrivacySettingSection/PasswordInputSection.tsx new file mode 100644 index 00000000..a7e59a21 --- /dev/null +++ b/src/pages/group/components/PrivacySettingSection/PasswordInputSection.tsx @@ -0,0 +1,69 @@ +import { + PasswordInputContainer, + PasswordInputBox, + PasswordInput, + CloseButton, +} from './PasswordInputSection.styled'; +import closeIcon from '../../../../assets/group/close.svg'; + +interface PasswordInputSectionProps { + password: string; + onPasswordChange: (password: string) => void; + onClose: () => void; +} + +const PasswordInputSection = ({ + password, + onPasswordChange, + onClose, +}: PasswordInputSectionProps) => { + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + // 숫자만 허용하고 최대 4자리까지만 입력 가능 + const numericValue = value.replace(/[^0-9]/g, ''); + if (numericValue.length <= 4) { + onPasswordChange(numericValue); + } + }; + + const handleClose = () => { + onPasswordChange(''); // 입력된 숫자 전체 삭제 + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // 숫자, 백스페이스, 삭제, 탭, 엔터만 허용 + const allowedKeys = ['Backspace', 'Delete', 'Tab', 'Enter', 'ArrowLeft', 'ArrowRight']; + const isNumber = /^[0-9]$/; + + if (!allowedKeys.includes(e.key) && !isNumber.test(e.key)) { + e.preventDefault(); + } + + // 이미 4자리면 새로운 숫자 입력 방지 + if (password.length >= 4 && isNumber.test(e.key)) { + e.preventDefault(); + } + }; + + return ( + + + + + 닫기 + + + + ); +}; + +export default PasswordInputSection; diff --git a/src/pages/group/components/PrivacySettingSection/PrivacySettingSection.styled.ts b/src/pages/group/components/PrivacySettingSection/PrivacySettingSection.styled.ts new file mode 100644 index 00000000..f83fce9b --- /dev/null +++ b/src/pages/group/components/PrivacySettingSection/PrivacySettingSection.styled.ts @@ -0,0 +1,36 @@ +import styled from '@emotion/styled'; +import { typography, semanticColors } from '../../../../styles/global/global'; + +export const PrivacyToggleContainer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +export const PrivacyLabel = styled.span` + color: ${semanticColors.text.primary}; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; +`; + +export const ToggleSwitch = styled.div<{ active: boolean }>` + width: 48px; + height: 28px; + background-color: ${({ active }) => + active ? semanticColors.button.fill.primary : semanticColors.background.card}; + border-radius: 14px; + position: relative; + cursor: pointer; + transition: background-color 0.3s; +`; + +export const ToggleSlider = styled.div<{ active: boolean }>` + width: 20px; + height: 20px; + background-color: ${semanticColors.text.primary}; + border-radius: 50%; + position: absolute; + top: 4px; + left: ${({ active }) => (active ? '24px' : '4px')}; + transition: left 0.3s; +`; diff --git a/src/pages/group/components/PrivacySettingSection/PrivacySettingSection.tsx b/src/pages/group/components/PrivacySettingSection/PrivacySettingSection.tsx new file mode 100644 index 00000000..70346950 --- /dev/null +++ b/src/pages/group/components/PrivacySettingSection/PrivacySettingSection.tsx @@ -0,0 +1,45 @@ +import { Section, SectionTitle } from '../../CommonSection.styled'; +import { + PrivacyToggleContainer, + PrivacyLabel, + ToggleSwitch, + ToggleSlider, +} from './PrivacySettingSection.styled'; +import PasswordInputSection from './PasswordInputSection'; + +interface PrivacySettingSectionProps { + isPrivate: boolean; + password: string; + onToggle: () => void; + onPasswordChange: (password: string) => void; + onPasswordClose: () => void; +} + +const PrivacySettingSection = ({ + isPrivate, + password, + onToggle, + onPasswordChange, + onPasswordClose, +}: PrivacySettingSectionProps) => { + return ( +
+ 공개 설정 + + 비공개로 설정하기 + + + + + {isPrivate && ( + + )} +
+ ); +}; + +export default PrivacySettingSection; diff --git a/src/pages/group/components/RoomInfoSection.styled.ts b/src/pages/group/components/RoomInfoSection.styled.ts new file mode 100644 index 00000000..e1792380 --- /dev/null +++ b/src/pages/group/components/RoomInfoSection.styled.ts @@ -0,0 +1,35 @@ +import styled from '@emotion/styled'; +import { typography, semanticColors } from '../../../styles/global/global'; + +export const TextAreaBox = styled.div` + position: relative; + background-color: transparent; + border-radius: 0; + padding: 0; +`; + +export const TextArea = styled.textarea` + background: none; + border: none; + outline: none; + color: ${semanticColors.text.primary}; + font-size: ${typography.fontSize.base}; + width: 100%; + resize: none; + font-family: ${typography.fontFamily.primary}; + font-weight: ${typography.fontWeight.regular}; + padding: 0; + margin: 0; + + &::placeholder { + color: ${semanticColors.text.primary}; + font-size: ${typography.fontSize.sm}; + } +`; + +export const CharacterCount = styled.div` + color: ${semanticColors.text.point.green}; + font-size: ${typography.fontSize.xs}; + text-align: right; + margin-top: 8px; +`; diff --git a/src/pages/group/components/RoomInfoSection.tsx b/src/pages/group/components/RoomInfoSection.tsx new file mode 100644 index 00000000..e6bd0364 --- /dev/null +++ b/src/pages/group/components/RoomInfoSection.tsx @@ -0,0 +1,52 @@ +import { Section, SectionTitle } from '../CommonSection.styled'; +import { TextAreaBox, TextArea, CharacterCount } from './RoomInfoSection.styled'; + +interface RoomInfoSectionProps { + roomTitle: string; + roomDescription: string; + onRoomTitleChange: (value: string) => void; + onRoomDescriptionChange: (value: string) => void; +} + +const RoomInfoSection = ({ + roomTitle, + roomDescription, + onRoomTitleChange, + onRoomDescriptionChange, +}: RoomInfoSectionProps) => { + return ( + <> +
+ 방 제목 + +