diff --git a/.pnp.cjs b/.pnp.cjs index 51a533b1..1e84a0e8 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -17,6 +17,10 @@ const RAW_RUNTIME_STATE = "name": "admin",\ "reference": "workspace:apps/admin"\ },\ + {\ + "name": "place",\ + "reference": "workspace:apps/place"\ + },\ {\ "name": "preview",\ "reference": "workspace:apps/preview"\ @@ -69,6 +73,7 @@ const RAW_RUNTIME_STATE = ["@boolti/ui", ["workspace:packages/ui"]],\ ["admin", ["workspace:apps/admin"]],\ ["boolti-web", ["workspace:."]],\ + ["place", ["workspace:apps/place"]],\ ["preview", ["workspace:apps/preview"]],\ ["profile", ["workspace:apps/profile"]],\ ["stroybook", ["workspace:apps/storybook"]],\ @@ -662,6 +667,13 @@ const RAW_RUNTIME_STATE = ["@babel/helper-string-parser", "npm:7.23.4"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:7.29.7", {\ + "packageLocation": "./.yarn/cache/@babel-helper-string-parser-npm-7.29.7-87998d618e-194bc0f171.zip/node_modules/@babel/helper-string-parser/",\ + "packageDependencies": [\ + ["@babel/helper-string-parser", "npm:7.29.7"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@babel/helper-validator-identifier", [\ @@ -671,6 +683,13 @@ const RAW_RUNTIME_STATE = ["@babel/helper-validator-identifier", "npm:7.22.20"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:7.29.7", {\ + "packageLocation": "./.yarn/cache/@babel-helper-validator-identifier-npm-7.29.7-9939aac13d-4795354e7a.zip/node_modules/@babel/helper-validator-identifier/",\ + "packageDependencies": [\ + ["@babel/helper-validator-identifier", "npm:7.29.7"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@babel/helper-validator-option", [\ @@ -2970,6 +2989,15 @@ const RAW_RUNTIME_STATE = ["to-fast-properties", "npm:2.0.0"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:7.29.7", {\ + "packageLocation": "./.yarn/cache/@babel-types-npm-7.29.7-8e5b8d613f-b6623994c6.zip/node_modules/@babel/types/",\ + "packageDependencies": [\ + ["@babel/types", "npm:7.29.7"],\ + ["@babel/helper-string-parser", "npm:7.29.7"],\ + ["@babel/helper-validator-identifier", "npm:7.29.7"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@base2/pretty-print-object", [\ @@ -10370,6 +10398,16 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["babel-plugin-react-compiler", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/babel-plugin-react-compiler-npm-1.0.0-5beba4221c-9406267ada.zip/node_modules/babel-plugin-react-compiler/",\ + "packageDependencies": [\ + ["babel-plugin-react-compiler", "npm:1.0.0"],\ + ["@babel/types", "npm:7.29.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["bail", [\ ["npm:2.0.2", {\ "packageLocation": "./.yarn/cache/bail-npm-2.0.2-42130cb251-25cbea309e.zip/node_modules/bail/",\ @@ -17264,6 +17302,35 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["place", [\ + ["workspace:apps/place", {\ + "packageLocation": "./apps/place/",\ + "packageDependencies": [\ + ["place", "workspace:apps/place"],\ + ["@boolti/api", "workspace:packages/api"],\ + ["@boolti/bridge", "workspace:packages/bridge"],\ + ["@boolti/eslint-config", "workspace:packages/config-eslint"],\ + ["@boolti/icon", "workspace:packages/icon"],\ + ["@boolti/typescript-config", "workspace:packages/config-typescript"],\ + ["@boolti/ui", "workspace:packages/ui"],\ + ["@emotion/babel-plugin", "npm:11.11.0"],\ + ["@emotion/react", "virtual:de80dc576383b2386358abc0e9fe49c00e3397fe355a0337462b73ab3115c2e557eb85784ee0fe776394cc11dd020b4e84dbbd75acf72ee6d54415d82d21f5c5#npm:11.11.3"],\ + ["@emotion/styled", "virtual:85869d3eba7afdb6f94c001c9503942ddc4354e881daf63c24e9d58366ea9f25c6bac2df65ae0f5266c54cd36fe68f0d9568da3a1ab62446405c98ac852f4431#npm:11.11.0"],\ + ["@types/react", "npm:18.2.48"],\ + ["@types/react-dom", "npm:18.2.18"],\ + ["@vitejs/plugin-react", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:4.2.1"],\ + ["babel-plugin-react-compiler", "npm:1.0.0"],\ + ["react", "npm:18.2.0"],\ + ["react-compiler-runtime", "virtual:4b8cd713b6ca70968e9777b77e1b63c4c6f6e4e1535a442d176636f573cd7f58aa5dd6ea3b60cbdc4a697fc2c843fbb7dd80f0170fe8b132fb8ca7c72cb888c1#npm:1.0.0"],\ + ["react-dom", "virtual:de80dc576383b2386358abc0e9fe49c00e3397fe355a0337462b73ab3115c2e557eb85784ee0fe776394cc11dd020b4e84dbbd75acf72ee6d54415d82d21f5c5#npm:18.2.0"],\ + ["react-router-dom", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:6.21.3"],\ + ["the-new-css-reset", "npm:1.11.2"],\ + ["typescript", "patch:typescript@npm%3A5.3.3#optional!builtin::version=5.3.3&hash=e012d7"],\ + ["vite", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:5.0.11"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["playwright", [\ ["npm:1.59.1", {\ "packageLocation": "./.yarn/cache/playwright-npm-1.59.1-8e8808a3f1-dfe38396e6.zip/node_modules/playwright/",\ @@ -18913,6 +18980,28 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["react-compiler-runtime", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/react-compiler-runtime-npm-1.0.0-2873ae96e7-e081192380.zip/node_modules/react-compiler-runtime/",\ + "packageDependencies": [\ + ["react-compiler-runtime", "npm:1.0.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:4b8cd713b6ca70968e9777b77e1b63c4c6f6e4e1535a442d176636f573cd7f58aa5dd6ea3b60cbdc4a697fc2c843fbb7dd80f0170fe8b132fb8ca7c72cb888c1#npm:1.0.0", {\ + "packageLocation": "./.yarn/__virtual__/react-compiler-runtime-virtual-b0c8441cd2/0/cache/react-compiler-runtime-npm-1.0.0-2873ae96e7-e081192380.zip/node_modules/react-compiler-runtime/",\ + "packageDependencies": [\ + ["react-compiler-runtime", "virtual:4b8cd713b6ca70968e9777b77e1b63c4c6f6e4e1535a442d176636f573cd7f58aa5dd6ea3b60cbdc4a697fc2c843fbb7dd80f0170fe8b132fb8ca7c72cb888c1#npm:1.0.0"],\ + ["@types/react", "npm:18.2.48"],\ + ["react", "npm:18.2.0"]\ + ],\ + "packagePeers": [\ + "@types/react",\ + "react"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["react-confetti", [\ ["npm:6.1.0", {\ "packageLocation": "./.yarn/cache/react-confetti-npm-6.1.0-9b9e19a3c8-5b4eb23eef.zip/node_modules/react-confetti/",\ diff --git a/apps/place/.gitignore b/apps/place/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/apps/place/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/place/index.html b/apps/place/index.html new file mode 100644 index 00000000..12f6e019 --- /dev/null +++ b/apps/place/index.html @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + 핫한 공연 예매의 시작, 불티 + + +
+ + + diff --git a/apps/place/package.json b/apps/place/package.json new file mode 100644 index 00000000..0cf2eb1f --- /dev/null +++ b/apps/place/package.json @@ -0,0 +1,38 @@ +{ + "name": "place", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "TIMING=1 eslint . --ext ts,tsx --report-unused-disable-directives", + "lint:fix": "TIMING=1 eslint . --ext ts,tsx --fix", + "type-check": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@boolti/api": "*", + "@boolti/bridge": "*", + "@boolti/icon": "*", + "@boolti/ui": "*", + "@emotion/react": "^11.11.3", + "@emotion/styled": "^11.11.0", + "react": "^18.2.0", + "react-compiler-runtime": "^1.0.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.3", + "the-new-css-reset": "^1.11.2" + }, + "devDependencies": { + "@boolti/eslint-config": "*", + "@boolti/typescript-config": "*", + "@emotion/babel-plugin": "^11.11.0", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "babel-plugin-react-compiler": "^1.0.0", + "typescript": "^5.2.2", + "vite": "^5.0.8" + } +} diff --git a/apps/place/public/_redirects b/apps/place/public/_redirects new file mode 100644 index 00000000..7797f7c6 --- /dev/null +++ b/apps/place/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/apps/place/src/App.tsx b/apps/place/src/App.tsx new file mode 100644 index 00000000..23f28d51 --- /dev/null +++ b/apps/place/src/App.tsx @@ -0,0 +1,34 @@ +import 'the-new-css-reset/css/reset.css'; +import './index.css'; + +import { QueryClientProvider } from '@boolti/api'; +import { BooltiUIProvider } from '@boolti/ui'; +import { Suspense } from 'react'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; + +import ConcertHallPage from './pages/ConcertHallPage'; +import ErrorPage from './pages/ErrorPage'; + +const router = createBrowserRouter([ + { + path: '/:concertHallId', + element: , + errorElement: , + }, + { + path: '*', + element: , + }, +]); + +const App = () => ( + + + + + + + +); + +export default App; diff --git a/apps/place/src/assets/images/default-hall.png b/apps/place/src/assets/images/default-hall.png new file mode 100644 index 00000000..10b26be6 Binary files /dev/null and b/apps/place/src/assets/images/default-hall.png differ diff --git a/apps/place/src/components/ComingSoon/index.tsx b/apps/place/src/components/ComingSoon/index.tsx new file mode 100644 index 00000000..04ba70f6 --- /dev/null +++ b/apps/place/src/components/ComingSoon/index.tsx @@ -0,0 +1,34 @@ +import styled from '@emotion/styled'; + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + height: 290px; + width: 100%; +`; + +const Title = styled.span` + font-family: 'SB Aggro'; + font-size: 20px; + line-height: 30px; + letter-spacing: -0.6px; + color: ${({ theme }) => theme.palette.mobile.grey.g20}; +`; + +const Description = styled.span` + font-size: 16px; + line-height: 24px; + color: ${({ theme }) => theme.palette.mobile.grey.g30}; +`; + +const ComingSoon = () => ( + + COMING SOON + 조금만 기다려주세요! + +); + +export default ComingSoon; diff --git a/apps/place/src/components/GalleryModal/GalleryModal.styles.ts b/apps/place/src/components/GalleryModal/GalleryModal.styles.ts new file mode 100644 index 00000000..966fac6b --- /dev/null +++ b/apps/place/src/components/GalleryModal/GalleryModal.styles.ts @@ -0,0 +1,145 @@ +import styled from '@emotion/styled'; + +const Overlay = styled.div` + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + justify-content: center; + background-color: ${({ theme }) => theme.palette.mobile.grey.g95}; +`; + +const Inner = styled.div` + display: flex; + flex-direction: column; + width: 100%; + max-width: 680px; + height: 100dvh; +`; + +const Header = styled.header` + display: flex; + align-items: center; + height: 56px; + padding: 0 8px; + flex-shrink: 0; +`; + +const HeaderButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + color: ${({ theme }) => theme.palette.mobile.grey.g10}; + cursor: pointer; +`; + +const HeaderTitle = styled.h1` + margin-left: 4px; + font-size: 18px; + font-weight: 600; + line-height: 26px; + color: ${({ theme }) => theme.palette.mobile.grey.g10}; +`; + +const HeaderSpacer = styled.div` + flex: 1; +`; + +// 사진 목록 (3열 그리드) +const Grid = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2px; + flex: 1; + align-content: start; + overflow-y: auto; + padding-bottom: 20px; +`; + +const GridItem = styled.button` + position: relative; + aspect-ratio: 1 / 1; + overflow: hidden; + background-color: ${({ theme }) => theme.palette.mobile.grey.g85}; + cursor: pointer; +`; + +const GridImage = styled.img` + width: 100%; + height: 100%; + object-fit: cover; +`; + +// 사진 크게 보기 (가로 스크롤 캐러셀) +const ViewerBody = styled.div` + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +`; + +const Carousel = styled.div` + flex: 1; + display: flex; + overflow-x: auto; + scroll-snap-type: x mandatory; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } +`; + +const Slide = styled.div` + flex: 0 0 100%; + scroll-snap-align: center; + display: flex; + align-items: center; + justify-content: center; + padding: 0 16px; +`; + +const SlideImage = styled.img` + max-width: 100%; + max-height: 100%; + object-fit: contain; +`; + +const Dots = styled.div` + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 48px; + flex-shrink: 0; +`; + +const Dot = styled.span<{ active: boolean }>` + width: 6px; + height: 6px; + border-radius: 50%; + background-color: ${({ theme, active }) => + active ? theme.palette.mobile.grey.w : theme.palette.mobile.grey.g60}; + transition: background-color 0.2s ease; +`; + +export default { + Overlay, + Inner, + Header, + HeaderButton, + HeaderTitle, + HeaderSpacer, + Grid, + GridItem, + GridImage, + ViewerBody, + Carousel, + Slide, + SlideImage, + Dots, + Dot, +}; diff --git a/apps/place/src/components/GalleryModal/index.tsx b/apps/place/src/components/GalleryModal/index.tsx new file mode 100644 index 00000000..c919f11c --- /dev/null +++ b/apps/place/src/components/GalleryModal/index.tsx @@ -0,0 +1,145 @@ +import { useConcertHallImages } from '@boolti/api'; +import { ArrowLeftIcon, CloseIcon } from '@boolti/icon'; +import { useEffect, useRef, useState } from 'react'; + +import Styled from './GalleryModal.styles'; + +export type GalleryMode = 'list' | 'viewer'; + +interface Props { + concertHallId: number; + hallName: string; + open: boolean; + /** 'viewer'면 바로 크게 보기, 'list'면 사진 목록부터 */ + initialMode: GalleryMode; + initialIndex?: number; + onClose: () => void; +} + +const GalleryModal = ({ + concertHallId, + hallName, + open, + initialMode, + initialIndex = 0, + onClose, +}: Props) => { + const { data } = useConcertHallImages(concertHallId, open); + const images = data?.items ?? []; + + const [viewerIndex, setViewerIndex] = useState(null); + const [activeIndex, setActiveIndex] = useState(0); + const carouselRef = useRef(null); + + // 모달이 열릴 때 초기 모드/인덱스 설정 + useEffect(() => { + if (open) { + setViewerIndex(initialMode === 'viewer' ? initialIndex : null); + setActiveIndex(initialIndex); + } + }, [open, initialMode, initialIndex]); + + // 뷰어 진입 시 해당 인덱스로 스크롤 위치 이동 + useEffect(() => { + if (viewerIndex != null && carouselRef.current) { + const el = carouselRef.current; + el.scrollLeft = el.clientWidth * viewerIndex; + setActiveIndex(viewerIndex); + } + }, [viewerIndex]); + + // 모달이 열려 있는 동안 배경 스크롤 잠금 + useEffect(() => { + if (!open) { + return; + } + const original = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = original; + }; + }, [open]); + + if (!open) { + return null; + } + + const isViewer = viewerIndex != null; + + // 목록에서 진입한 뷰어는 목록으로, 바로 뷰어로 진입한 경우는 모달 닫기 + const handleCloseViewer = () => { + if (initialMode === 'viewer') { + onClose(); + } else { + setViewerIndex(null); + } + }; + + const handleScroll = () => { + const el = carouselRef.current; + if (el && el.clientWidth > 0) { + setActiveIndex(Math.round(el.scrollLeft / el.clientWidth)); + } + }; + + return ( + + + {isViewer ? ( + <> + + + + + + + + + {images.map((image, index) => ( + + + + ))} + + {images.length > 1 && ( + + {images.map((image, index) => ( + + ))} + + )} + + + ) : ( + <> + + + + + 사진 + + + {images.map((image, index) => ( + setViewerIndex(index)} + > + + + ))} + + + )} + + + ); +}; + +export default GalleryModal; diff --git a/apps/place/src/components/HallHead/HallHead.styles.ts b/apps/place/src/components/HallHead/HallHead.styles.ts new file mode 100644 index 00000000..dd816350 --- /dev/null +++ b/apps/place/src/components/HallHead/HallHead.styles.ts @@ -0,0 +1,163 @@ +import styled from '@emotion/styled'; + +const Container = styled.section` + display: flex; + flex-direction: column; + width: 100%; + padding-bottom: 32px; + background-color: ${({ theme }) => theme.palette.mobile.grey.g90}; + border-radius: 0 0 20px 20px; + overflow: hidden; +`; + +const ImageArea = styled.div` + position: relative; + display: flex; + flex-direction: column; + width: 100%; + aspect-ratio: 1 / 1; +`; + +const BackgroundImage = styled.img` + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +`; + +const BackgroundDim = styled.div` + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(18, 19, 24, 0.2) 0%, #121318 100%); +`; + +const AppBar = styled.div` + position: relative; + display: flex; + justify-content: flex-end; + align-items: center; + padding: 10px 20px; +`; + +const ShareButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +`; + +const HallNameArea = styled.div` + position: relative; + display: flex; + flex: 1; + align-items: flex-end; + padding: 0 20px; +`; + +const HallName = styled.h1` + font-family: 'SB Aggro'; + font-size: 24px; + line-height: 34px; + letter-spacing: -0.72px; + color: ${({ theme }) => theme.palette.mobile.grey.g10}; + word-break: break-word; +`; + +const SummaryArea = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + padding: 16px 20px 24px; +`; + +const SummaryRow = styled.div` + display: flex; + align-items: flex-start; + gap: 8px; + width: 100%; + font-size: 15px; + line-height: 23px; +`; + +const SummaryLabel = styled.span` + flex-shrink: 0; + width: 88px; + color: ${({ theme }) => theme.palette.mobile.grey.g50}; +`; + +const SummaryValue = styled.span` + flex: 1; + color: ${({ theme }) => theme.palette.mobile.grey.g30}; + word-break: break-word; +`; + +const SubwayStationList = styled.div` + display: flex; + flex: 1; + flex-direction: column; + gap: 8px; +`; + +const SubwayStationRow = styled.div` + display: flex; + align-items: center; + gap: 4px; +`; + +const SubwayStationName = styled.span` + font-size: 15px; + line-height: 23px; + color: ${({ theme }) => theme.palette.mobile.grey.g30}; + white-space: nowrap; +`; + +const ContactButtonArea = styled.div` + display: flex; + gap: 12px; + width: 100%; + padding: 0 20px; +`; + +// 데이터가 없어도 버튼은 노출하되 색상만 어둡게 처리한다 (클릭 시 토스트 안내) +const ContactButton = styled.button<{ isActive: boolean }>` + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 12px 16px; + border-radius: 8px; + background-color: ${({ theme }) => theme.palette.mobile.grey.g85}; + color: ${({ theme, isActive }) => + isActive ? theme.palette.mobile.grey.g30 : theme.palette.mobile.grey.g70}; + cursor: pointer; +`; + +const ContactButtonLabel = styled.span` + font-size: 14px; + line-height: 22px; +`; + +export default { + Container, + ImageArea, + BackgroundImage, + BackgroundDim, + AppBar, + ShareButton, + HallNameArea, + HallName, + SummaryArea, + SummaryRow, + SummaryLabel, + SummaryValue, + SubwayStationList, + SubwayStationRow, + SubwayStationName, + ContactButtonArea, + ContactButton, + ContactButtonLabel, +}; diff --git a/apps/place/src/components/HallHead/index.tsx b/apps/place/src/components/HallHead/index.tsx new file mode 100644 index 00000000..e8976aab --- /dev/null +++ b/apps/place/src/components/HallHead/index.tsx @@ -0,0 +1,147 @@ +import type { ConcertHallProfileResponse } from '@boolti/api'; +import { ShareIcon } from '@boolti/icon'; +import { SubwayLineBadge, useToast } from '@boolti/ui'; + +import defaultHallImage from '~/assets/images/default-hall.png'; +import { CallIcon, MailIcon, WebsiteIcon } from '~/components/icons'; +import { formatAddress, formatCapacity, normalizeWebsiteUrl } from '~/utils/format'; + +import Styled from './HallHead.styles'; + +interface Props { + profile: ConcertHallProfileResponse; + onShare: () => void; +} + +const HallHead = ({ profile, onShare }: Props) => { + const toast = useToast(); + const { name, representativeImageUrl, head } = profile; + + const capacityText = formatCapacity(head?.capacity); + const addressText = formatAddress(head?.location); + const subwayStations = head?.subwayStations ?? []; + const contact = head?.contact; + const hasContact = Boolean(contact?.websiteUrl || contact?.phoneNumber || contact?.email); + + const hasSummary = + Boolean(head?.rentalFeeSummary) || + Boolean(capacityText) || + Boolean(addressText) || + subwayStations.length > 0; + + // 문의처는 1개라도 있으면 버튼 3개를 모두 노출하고, + // 데이터가 없는 항목은 비활성 스타일 + 클릭 시 준비 중 토스트를 띄운다 + const contactButtons = [ + { + key: 'website', + label: '웹사이트', + icon: , + value: contact?.websiteUrl, + emptyMessage: '웹사이트를 준비 중이에요.', + action: (websiteUrl: string) => { + window.open(normalizeWebsiteUrl(websiteUrl), '_blank', 'noopener,noreferrer'); + }, + }, + { + key: 'phone', + label: '전화', + icon: , + value: contact?.phoneNumber, + emptyMessage: '전화 정보를 준비 중이에요.', + action: (phoneNumber: string) => { + window.location.href = `tel:${phoneNumber}`; + }, + }, + { + key: 'email', + label: '메일', + icon: , + value: contact?.email, + emptyMessage: '메일 정보를 준비 중이에요.', + action: (email: string) => { + window.location.href = `mailto:${email}`; + }, + }, + ]; + + return ( + + + + + + + + + + + {name} + + + {hasSummary && ( + + {head?.rentalFeeSummary && ( + + 대관료 + {head.rentalFeeSummary} + + )} + {capacityText && ( + + 수용 인원 + {capacityText} + + )} + {addressText && ( + + 위치 + {addressText} + + )} + {subwayStations.length > 0 && ( + + 지하철역 + + {subwayStations.map((station) => ( + + {station.lines.map((line) => ( + + ))} + {station.stationName} + + ))} + + + )} + + )} + {hasContact && ( + + {contactButtons.map(({ key, label, icon, value, emptyMessage, action }) => ( + { + if (value) { + action(value); + } else { + toast.info(emptyMessage); + } + }} + > + {icon} + {label} + + ))} + + )} + + ); +}; + +export default HallHead; diff --git a/apps/place/src/components/HomeTab/HomeTab.styles.ts b/apps/place/src/components/HomeTab/HomeTab.styles.ts new file mode 100644 index 00000000..dfe291a2 --- /dev/null +++ b/apps/place/src/components/HomeTab/HomeTab.styles.ts @@ -0,0 +1,164 @@ +import styled from '@emotion/styled'; + +const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; + padding: 12px 0; +`; + +const Section = styled.section` + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + padding: 32px 20px; +`; + +const SectionTitle = styled.h2` + font-size: 18px; + font-weight: 600; + line-height: 26px; + color: ${({ theme }) => theme.palette.mobile.grey.g10}; +`; + +const IntroductionWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +`; + +const IntroductionText = styled.div<{ isCollapsed: boolean }>` + position: relative; + width: 100%; + max-height: ${({ isCollapsed }) => (isCollapsed ? '280px' : 'none')}; + overflow: hidden; +`; + +const IntroductionParagraph = styled.p` + font-size: 15px; + line-height: 23px; + color: ${({ theme }) => theme.palette.mobile.grey.g30}; + word-break: break-word; + white-space: pre-wrap; +`; + +const IntroductionDim = styled.div` + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 80px; + background: linear-gradient(180deg, rgba(9, 10, 11, 0) 0%, #090a0b 100%); +`; + +const MoreButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 40px; + padding: 0 4px; + font-size: 16px; + font-weight: 600; + line-height: 22px; + color: ${({ theme }) => theme.palette.mobile.grey.g10}; + cursor: pointer; +`; + +const PhotoGrid = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; + width: 100%; +`; + +const PhotoItem = styled.button` + position: relative; + aspect-ratio: 1 / 1; + border: 1px solid ${({ theme }) => theme.palette.mobile.grey.g85}; + border-radius: 8px; + overflow: hidden; + padding: 0; + cursor: pointer; +`; + +const PhotoImage = styled.img` + width: 100%; + height: 100%; + object-fit: cover; +`; + +const PhotoMoreOverlay = styled.div` + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.45); + color: ${({ theme }) => theme.palette.mobile.grey.g10}; +`; + +const PhotoMoreCount = styled.span` + font-size: 15px; + line-height: 23px; +`; + +const AmenityGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + column-gap: 8px; + row-gap: 4px; + width: 100%; +`; + +const AmenityItem = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + color: ${({ theme }) => theme.palette.mobile.grey.g30}; +`; + +const AmenityLabel = styled.span` + font-size: 15px; + line-height: 23px; + word-break: keep-all; +`; + +const AddressLine = styled.p` + font-size: 16px; + line-height: 24px; + color: ${({ theme }) => theme.palette.mobile.grey.g30}; + word-break: break-word; +`; + +const CopyButton = styled.button` + font-size: 16px; + line-height: 24px; + color: ${({ theme }) => theme.palette.mobile.status.link}; + cursor: pointer; +`; + +export default { + Container, + Section, + SectionTitle, + IntroductionWrapper, + IntroductionText, + IntroductionParagraph, + IntroductionDim, + MoreButton, + PhotoGrid, + PhotoItem, + PhotoImage, + PhotoMoreOverlay, + PhotoMoreCount, + AmenityGrid, + AmenityItem, + AmenityLabel, + AddressLine, + CopyButton, +}; diff --git a/apps/place/src/components/HomeTab/index.tsx b/apps/place/src/components/HomeTab/index.tsx new file mode 100644 index 00000000..03ac94e3 --- /dev/null +++ b/apps/place/src/components/HomeTab/index.tsx @@ -0,0 +1,188 @@ +import type { ConcertHallProfileResponse } from '@boolti/api'; +import { checkIsWebView } from '@boolti/bridge'; +import { ChevronDownIcon, ChevronUpIcon } from '@boolti/icon'; +import { PreviewMapWithProvider, useToast } from '@boolti/ui'; +import { useLayoutEffect, useRef, useState } from 'react'; + +import GalleryModal, { type GalleryMode } from '~/components/GalleryModal'; +import { + AlcoholIcon, + CabinetIcon, + CameraIcon, + ParkingIcon, + RestroomIcon, + SecondFloorIcon, + WaitingRoomIcon, +} from '~/components/icons'; +import { X_NCP_APIGW_API_KEY_ID } from '~/constants/ncp'; +import { formatAddress, formatAmenityLabel } from '~/utils/format'; + +import Styled from './HomeTab.styles'; + +const INTRODUCTION_COLLAPSED_HEIGHT = 280; + +const AMENITY_ICONS: Record = { + WAITING_ROOM: , + SECOND_FLOOR_SEATING: , + INDOOR_RESTROOM: , + ALCOHOL_SALES: , + PARKING: , + CABINET: , +}; + +interface IntroductionSectionProps { + introduction: string; +} + +const IntroductionSection = ({ introduction }: IntroductionSectionProps) => { + const textRef = useRef(null); + const [isExpanded, setIsExpanded] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + + useLayoutEffect(() => { + if (textRef.current) { + setIsOverflowing(textRef.current.scrollHeight > INTRODUCTION_COLLAPSED_HEIGHT); + } + }, [introduction]); + + const isCollapsed = isOverflowing && !isExpanded; + + return ( + + 소개 + + + {introduction} + {isCollapsed && } + + {isOverflowing && ( + setIsExpanded((prev) => !prev)}> + {isExpanded ? '내용 접기' : '내용 더 보기'} + {isExpanded ? : } + + )} + + + ); +}; + +interface Props { + profile: ConcertHallProfileResponse; +} + +const HomeTab = ({ profile }: Props) => { + const toast = useToast(); + const home = profile.home; + const [gallery, setGallery] = useState<{ mode: GalleryMode; index: number } | null>(null); + + // 미리보기 장수는 백엔드가 제어(최대 5장)하고, 전체는 갤러리 모달에서 별도 조회한다. + const visibleImages = home?.images ?? []; + const totalImageCount = home?.totalImageCount ?? visibleImages.length; + const hiddenImageCount = totalImageCount - visibleImages.length; + + const amenities = home?.amenities ?? []; + const location = home?.location; + const addressText = formatAddress(location); + const hasMap = + location?.latitude != null && location?.longitude != null && Boolean(X_NCP_APIGW_API_KEY_ID); + + const handleCopyAddress = async () => { + if (!addressText) { + return; + } + + try { + await navigator.clipboard.writeText(addressText); + toast.success('주소를 복사했어요.'); + } catch { + toast.error('주소 복사에 실패했어요.'); + } + }; + + return ( + <> + + {home?.introduction && } + {visibleImages.length > 0 && ( + + 사진 + + {visibleImages.map((image, index) => { + const isLastVisible = index === visibleImages.length - 1; + const showMoreOverlay = isLastVisible && hiddenImageCount > 0; + + return ( + + setGallery( + showMoreOverlay ? { mode: 'list', index: 0 } : { mode: 'viewer', index }, + ) + } + > + + {showMoreOverlay && ( + + + {totalImageCount} + + )} + + ); + })} + + + )} + {amenities.length > 0 && ( + + 편의 시설 및 서비스 + + {amenities.map((amenity) => ( + + {AMENITY_ICONS[amenity.type]} + {formatAmenityLabel(amenity)} + + ))} + + + )} + {addressText && ( + + 위치 + + {addressText}・ + + 복사 + + + {hasMap && ( + + )} + + )} + + {gallery && ( + setGallery(null)} + /> + )} + + ); +}; + +export default HomeTab; diff --git a/apps/place/src/components/Layout/index.tsx b/apps/place/src/components/Layout/index.tsx new file mode 100644 index 00000000..9ada6dbe --- /dev/null +++ b/apps/place/src/components/Layout/index.tsx @@ -0,0 +1,30 @@ +import styled from '@emotion/styled'; + +// 모바일 웹뷰 기준 화면. 데스크탑에서는 최대 너비를 고정하고 가운데 정렬한다. +const Container = styled.div` + display: flex; + justify-content: center; + width: 100%; + min-height: 100dvh; + background-color: ${({ theme }) => theme.palette.mobile.grey.g95}; +`; + +const ContentWrapper = styled.div` + position: relative; + width: 100%; + max-width: 680px; + min-height: 100dvh; + background-color: ${({ theme }) => theme.palette.mobile.grey.g95}; +`; + +interface Props { + children: React.ReactNode; +} + +const Layout = ({ children }: Props) => ( + + {children} + +); + +export default Layout; diff --git a/apps/place/src/components/RentalTab/RentalTab.styles.ts b/apps/place/src/components/RentalTab/RentalTab.styles.ts new file mode 100644 index 00000000..d878f64f --- /dev/null +++ b/apps/place/src/components/RentalTab/RentalTab.styles.ts @@ -0,0 +1,179 @@ +import styled from '@emotion/styled'; + +const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; + padding: 12px 0; +`; + +const Section = styled.section` + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + padding: 32px 20px; +`; + +const SectionTitle = styled.h2` + font-size: 18px; + font-weight: 600; + line-height: 26px; + color: ${({ theme }) => theme.palette.mobile.grey.g10}; +`; + +const SectionDescription = styled.p` + margin-top: -4px; + font-size: 14px; + line-height: 20px; + color: ${({ theme }) => theme.palette.mobile.grey.g40}; + word-break: keep-all; +`; + +// 대관 방법 — 회색 박스, 줄바꿈 보존 +const MethodBox = styled.div` + padding: 16px; + border-radius: 8px; + background-color: ${({ theme }) => theme.palette.mobile.grey.g85}; + font-size: 15px; + line-height: 23px; + color: ${({ theme }) => theme.palette.mobile.grey.g20}; + white-space: pre-wrap; + word-break: break-word; +`; + +// 대관 시간 값 박스 +const TimeBox = styled.div` + display: flex; + align-items: center; + height: 48px; + padding: 0 16px; + border-radius: 8px; + background-color: ${({ theme }) => theme.palette.mobile.grey.g85}; + font-size: 15px; + line-height: 23px; + color: ${({ theme }) => theme.palette.mobile.grey.g10}; +`; + +// 금액 행 (요일/옵션명 좌측, 금액 우측) +const FeeList = styled.div` + display: flex; + flex-direction: column; +`; + +const FeeRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 0; + + & + & { + border-top: 1px solid ${({ theme }) => theme.palette.mobile.grey.g85}; + } +`; + +const FeeLabel = styled.span` + font-size: 15px; + line-height: 23px; + color: ${({ theme }) => theme.palette.mobile.grey.g30}; + word-break: keep-all; +`; + +const FeeValue = styled.span` + font-size: 15px; + font-weight: 600; + line-height: 23px; + color: ${({ theme }) => theme.palette.mobile.grey.g10}; + text-align: right; + white-space: nowrap; +`; + +// 보유 악기 — 더보기 토글 (HomeTab 소개 패턴과 동일) +const InstrumentsWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +`; + +const InstrumentsText = styled.div<{ isCollapsed: boolean }>` + position: relative; + width: 100%; + max-height: ${({ isCollapsed }) => (isCollapsed ? '280px' : 'none')}; + overflow: hidden; +`; + +const InstrumentsParagraph = styled.p` + font-size: 15px; + line-height: 23px; + color: ${({ theme }) => theme.palette.mobile.grey.g30}; + word-break: break-word; + white-space: pre-wrap; +`; + +const InstrumentsDim = styled.div` + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 80px; + background: linear-gradient(180deg, rgba(9, 10, 11, 0) 0%, #090a0b 100%); +`; + +const MoreButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 40px; + padding: 0 4px; + font-size: 16px; + font-weight: 600; + line-height: 22px; + color: ${({ theme }) => theme.palette.mobile.grey.g10}; + cursor: pointer; +`; + +// 특이사항 — 목록 +const NoteList = styled.ul` + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +`; + +const NoteItem = styled.li` + display: flex; + gap: 8px; + font-size: 15px; + line-height: 23px; + color: ${({ theme }) => theme.palette.mobile.grey.g30}; + word-break: break-word; + + &::before { + content: '•'; + flex-shrink: 0; + color: ${({ theme }) => theme.palette.mobile.grey.g40}; + } +`; + +export default { + Container, + Section, + SectionTitle, + SectionDescription, + MethodBox, + TimeBox, + FeeList, + FeeRow, + FeeLabel, + FeeValue, + InstrumentsWrapper, + InstrumentsText, + InstrumentsParagraph, + InstrumentsDim, + MoreButton, + NoteList, + NoteItem, +}; diff --git a/apps/place/src/components/RentalTab/index.tsx b/apps/place/src/components/RentalTab/index.tsx new file mode 100644 index 00000000..16811449 --- /dev/null +++ b/apps/place/src/components/RentalTab/index.tsx @@ -0,0 +1,157 @@ +import type { ConcertHallProfileResponse } from '@boolti/api'; +import { ChevronDownIcon } from '@boolti/icon'; +import { useLayoutEffect, useRef, useState } from 'react'; + +import { formatFee } from '~/utils/format'; + +import Styled from './RentalTab.styles'; + +const INSTRUMENTS_COLLAPSED_HEIGHT = 280; + +interface InstrumentsSectionProps { + instrumentsText: string; +} + +const InstrumentsSection = ({ instrumentsText }: InstrumentsSectionProps) => { + const textRef = useRef(null); + const [isExpanded, setIsExpanded] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + + useLayoutEffect(() => { + if (textRef.current) { + setIsOverflowing(textRef.current.scrollHeight > INSTRUMENTS_COLLAPSED_HEIGHT); + } + }, [instrumentsText]); + + const isCollapsed = isOverflowing && !isExpanded; + + return ( + + 보유 악기 + + + {instrumentsText} + {isCollapsed && } + + {isCollapsed && ( + setIsExpanded(true)}> + 내용 더 보기 + + + )} + + + ); +}; + +interface Props { + profile: ConcertHallProfileResponse; +} + +const RentalTab = ({ profile }: Props) => { + const rental = profile.rental; + + if (!rental) { + return null; + } + + const { + rentalMethod, + rentalTime, + rentalFees = [], + vat, + additionalFees = [], + instrumentsText, + paidOptions = [], + specialNotes = [], + } = rental; + + // 대관 시간 부가 설명: 백엔드 설명 우선, 없고 휴식 포함이면 안내 문구 + const rentalTimeDescription = + rentalTime?.rentalTimeDescription || + (rentalTime?.isEngineerBreakIncluded ? '엔지니어 휴식 1시간이 포함된 시간입니다.' : null); + + return ( + + {rentalMethod && ( + + 대관 방법 + {rentalMethod} + + )} + + {rentalTime?.rentalTimeHours != null && ( + + 대관 시간 + {rentalTimeDescription && ( + {rentalTimeDescription} + )} + {rentalTime.rentalTimeHours}시간 + + )} + + {rentalFees.length > 0 && ( + + 대관료 + {vat?.description && ( + {vat.description} + )} + + {rentalFees.map((fee) => ( + + {fee.dayTypeName} + {formatFee(fee.fee)} + + ))} + + + )} + + {additionalFees.length > 0 && ( + + 시간당 추가 요금 + + 대관 시간 외 별도 시간 추가 시 발생하는 비용입니다. + + + {additionalFees.map((fee) => ( + + {fee.dayTypeName} + {formatFee(fee.fee)} / 1시간 + + ))} + + + )} + + {instrumentsText && } + + {paidOptions.length > 0 && ( + + 유료 옵션 + + {paidOptions.map((option) => ( + + {option.name} + {formatFee(option.price)} + + ))} + + + )} + + {specialNotes.length > 0 && ( + + 특이사항 + + {specialNotes.map((note, index) => ( + {note} + ))} + + + )} + + ); +}; + +export default RentalTab; diff --git a/apps/place/src/components/icons/index.tsx b/apps/place/src/components/icons/index.tsx new file mode 100644 index 00000000..7a6b4f8d --- /dev/null +++ b/apps/place/src/components/icons/index.tsx @@ -0,0 +1,68 @@ +// Figma Boolti Vol.2 공연장 프로필 디자인에서 추출한 아이콘 모음 + +export const WebsiteIcon = ({ size = 16 }: { size?: number }) => ( + + + +); + +export const CallIcon = ({ size = 16 }: { size?: number }) => ( + + + +); + +export const MailIcon = ({ size = 16 }: { size?: number }) => ( + + + +); + +export const CameraIcon = ({ size = 20 }: { size?: number }) => ( + + + +); + +export const MapIcon = ({ size = 24 }: { size?: number }) => ( + + + + +); + +export const WaitingRoomIcon = ({ size = 20 }: { size?: number }) => ( + + + +); + +export const SecondFloorIcon = ({ size = 20 }: { size?: number }) => ( + + + +); + +export const RestroomIcon = ({ size = 20 }: { size?: number }) => ( + + + +); + +export const AlcoholIcon = ({ size = 20 }: { size?: number }) => ( + + + +); + +export const ParkingIcon = ({ size = 20 }: { size?: number }) => ( + + + +); + +export const CabinetIcon = ({ size = 20 }: { size?: number }) => ( + + + +); diff --git a/apps/place/src/constants/ncp.ts b/apps/place/src/constants/ncp.ts new file mode 100644 index 00000000..062ebf64 --- /dev/null +++ b/apps/place/src/constants/ncp.ts @@ -0,0 +1 @@ +export const X_NCP_APIGW_API_KEY_ID = import.meta.env.VITE_X_NCP_APIGW_API_KEY_ID; diff --git a/apps/place/src/emotion.d.ts b/apps/place/src/emotion.d.ts new file mode 100644 index 00000000..7f14b3a9 --- /dev/null +++ b/apps/place/src/emotion.d.ts @@ -0,0 +1,11 @@ +import '@emotion/react'; + +import { breakpoint, palette, typo } from '@boolti/ui'; + +declare module '@emotion/react' { + export interface Theme { + palette: typeof palette; + typo: typeof typo; + breakpoint: typeof breakpoint; + } +} diff --git a/apps/place/src/index.css b/apps/place/src/index.css new file mode 100644 index 00000000..6f09007c --- /dev/null +++ b/apps/place/src/index.css @@ -0,0 +1,11 @@ +@import url("https://cdnjs.cloudflare.com/ajax/libs/pretendard/1.3.9/static/pretendard-dynamic-subset.min.css"); + + + +* { + font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, "Helvetica Neue", "Segoe UI", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif; +} + +body { + background-color: #090a0b; +} diff --git a/apps/place/src/main.tsx b/apps/place/src/main.tsx new file mode 100644 index 00000000..028ca158 --- /dev/null +++ b/apps/place/src/main.tsx @@ -0,0 +1,5 @@ +import ReactDOM from 'react-dom/client'; + +import App from './App.tsx'; + +ReactDOM.createRoot(document.getElementById('root')!).render(); diff --git a/apps/place/src/pages/ConcertHallPage/ConcertHallPage.styles.ts b/apps/place/src/pages/ConcertHallPage/ConcertHallPage.styles.ts new file mode 100644 index 00000000..7f2402eb --- /dev/null +++ b/apps/place/src/pages/ConcertHallPage/ConcertHallPage.styles.ts @@ -0,0 +1,45 @@ +import styled from '@emotion/styled'; + +const TabBar = styled.div` + display: flex; + align-items: center; + width: 100%; + padding: 20px 20px 0; + border-bottom: 1px solid ${({ theme }) => theme.palette.mobile.grey.g85}; +`; + +const TabItem = styled.button<{ isActive: boolean }>` + flex: 1; + padding: 13px 0; + margin-bottom: -1px; + font-size: 16px; + font-weight: 600; + line-height: 22px; + text-align: center; + cursor: pointer; + color: ${({ theme, isActive }) => + isActive ? theme.palette.mobile.grey.g10 : theme.palette.mobile.grey.g70}; + border-bottom: 2px solid + ${({ theme, isActive }) => (isActive ? theme.palette.mobile.grey.g10 : 'transparent')}; +`; + +const Bottom = styled.div` + display: flex; + flex-direction: column; + width: 100%; + padding: 12px 20px 20px; +`; + +const BottomText = styled.p` + font-size: 12px; + line-height: 18px; + color: ${({ theme }) => theme.palette.mobile.grey.g70}; + word-break: break-word; +`; + +export default { + TabBar, + TabItem, + Bottom, + BottomText, +}; diff --git a/apps/place/src/pages/ConcertHallPage/index.tsx b/apps/place/src/pages/ConcertHallPage/index.tsx new file mode 100644 index 00000000..22854eaf --- /dev/null +++ b/apps/place/src/pages/ConcertHallPage/index.tsx @@ -0,0 +1,101 @@ +import { useConcertHallProfile } from '@boolti/api'; +import { useToast } from '@boolti/ui'; +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import ComingSoon from '~/components/ComingSoon'; +import HallHead from '~/components/HallHead'; +import HomeTab from '~/components/HomeTab'; +import Layout from '~/components/Layout'; +import RentalTab from '~/components/RentalTab'; +import { formatUpdatedAt } from '~/utils/format'; + +import Styled from './ConcertHallPage.styles'; + +type TabKey = 'home' | 'rental'; + +const TABS: Array<{ key: TabKey; label: string }> = [ + { key: 'home', label: '홈' }, + { key: 'rental', label: '대관 정보' }, +]; + +const ConcertHallPage = () => { + const toast = useToast(); + const { concertHallId: idParam } = useParams<{ concertHallId: string }>(); + const concertHallId = idParam && /^\d+$/.test(idParam) ? Number(idParam) : null; + + const { data: profile } = useConcertHallProfile(concertHallId); + const [activeTab, setActiveTab] = useState('home'); + + useEffect(() => { + if (profile?.name) { + document.title = profile.share?.title ?? profile.name; + } + }, [profile?.name, profile?.share?.title]); + + if (!profile) { + return {null}; + } + + const handleShare = async () => { + const shareData = { + title: profile.share?.title ?? profile.name, + url: window.location.href, + }; + + if (navigator.share) { + try { + await navigator.share(shareData); + } catch { + // 사용자가 공유를 취소한 경우 + } + + return; + } + + try { + await navigator.clipboard.writeText(shareData.url); + toast.success('링크를 복사했어요.'); + } catch { + toast.error('링크 복사에 실패했어요.'); + } + }; + + const updatedAtText = formatUpdatedAt(profile.informationUpdatedAt); + + return ( + + + + {TABS.map((tab) => ( + setActiveTab(tab.key)} + > + {tab.label} + + ))} + + {activeTab === 'home' && + (profile.hasHomeTabData ? : )} + {activeTab === 'rental' && + (profile.hasRentalTabData ? : )} + + + 이 페이지의 정보는 불티에서 수집한 것으로, 실제 시설 및 장비와 다를 수 있습니다. 정확한 + 정보는 대관 시 공연장에 직접 확인해 주세요. + {updatedAtText && ( + <> +
+ *정보 업데이트: {updatedAtText} + + )} +
+
+
+ ); +}; + +export default ConcertHallPage; diff --git a/apps/place/src/pages/ErrorPage/index.tsx b/apps/place/src/pages/ErrorPage/index.tsx new file mode 100644 index 00000000..20caa0fb --- /dev/null +++ b/apps/place/src/pages/ErrorPage/index.tsx @@ -0,0 +1,21 @@ +import styled from '@emotion/styled'; + +import ComingSoon from '~/components/ComingSoon'; +import Layout from '~/components/Layout'; + +const Center = styled.div` + display: flex; + align-items: center; + justify-content: center; + min-height: 100dvh; +`; + +const ErrorPage = () => ( + +
+ +
+
+); + +export default ErrorPage; diff --git a/apps/place/src/utils/format.ts b/apps/place/src/utils/format.ts new file mode 100644 index 00000000..60d584ff --- /dev/null +++ b/apps/place/src/utils/format.ts @@ -0,0 +1,55 @@ +import type { ConcertHallAmenity, ConcertHallCapacity, ConcertHallLocation } from '@boolti/api'; + +export const formatUpdatedAt = (iso?: string) => { + if (!iso) { + return null; + } + + const date = new Date(iso); + + if (Number.isNaN(date.getTime())) { + return null; + } + + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${date.getFullYear()}.${month}.${day}`; +}; + +export const formatCapacity = (capacity?: ConcertHallCapacity) => { + const parts: string[] = []; + + if (capacity?.seatedCapacity != null) { + parts.push(`좌석 ${capacity.seatedCapacity.toLocaleString()}석`); + } + + if (capacity?.standingCapacity != null) { + parts.push(`스탠딩 ${capacity.standingCapacity.toLocaleString()}명`); + } + + return parts.length > 0 ? parts.join(' / ') : null; +}; + +export const formatAddress = (location?: ConcertHallLocation) => { + const parts = [location?.streetAddress, location?.detailAddress].filter(Boolean); + + return parts.length > 0 ? parts.join(' ') : null; +}; + +export const formatAmenityLabel = ({ type, name, count }: ConcertHallAmenity) => { + if (count == null) { + return name; + } + + if (type === 'PARKING') { + return `${name} ${count.toLocaleString()}대 가능`; + } + + return `${name} ${count.toLocaleString()}개`; +}; + +export const formatFee = (fee: number) => `${fee.toLocaleString()}원`; + +export const normalizeWebsiteUrl = (url: string) => + /^https?:\/\//i.test(url) ? url : `https://${url}`; diff --git a/apps/place/src/vite-env.d.ts b/apps/place/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/apps/place/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/place/tsconfig.json b/apps/place/tsconfig.json new file mode 100644 index 00000000..fdc1ef34 --- /dev/null +++ b/apps/place/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@boolti/typescript-config/vite.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["src/*"] + } + }, + "include": [ + "src" + ] +} diff --git a/apps/place/vite.config.ts b/apps/place/vite.config.ts new file mode 100644 index 00000000..7d7b6147 --- /dev/null +++ b/apps/place/vite.config.ts @@ -0,0 +1,37 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// https://vitejs.dev/config/ +export default defineConfig({ + resolve: { + alias: [{ find: '~', replacement: '/src' }], + }, + server: { + port: 8083, + // 로컬 개발 시 dev API의 CORS 제한을 우회하기 위한 프록시. + // .env.local에서 VITE_BASE_API_URL을 비워두면 요청이 프록시를 타게 된다. + proxy: { + '/web': { + target: 'https://dev.api.boolti.in', + changeOrigin: true, + }, + }, + }, + plugins: [ + react({ + jsxImportSource: '@emotion/react', + // react-compiler는 place 소스에만 적용한다. + // 워크스페이스 패키지(@boolti/*) 소스까지 변환하면 해당 패키지에 + // 선언되지 않은 react-compiler-runtime import가 주입되어 빌드가 깨진다. + babel: (id) => ({ + plugins: id.includes('/apps/place/src') + ? [ + // react-compiler must run before any other babel plugin + ['babel-plugin-react-compiler', { target: '18' }], + '@emotion/babel-plugin', + ] + : ['@emotion/babel-plugin'], + }), + }), + ], +}); diff --git a/apps/super-admin/src/pages/ConcertHallInfoPage/AddressSearchModal.tsx b/apps/super-admin/src/pages/ConcertHallInfoPage/AddressSearchModal.tsx index 75e2125e..d21d3e79 100644 --- a/apps/super-admin/src/pages/ConcertHallInfoPage/AddressSearchModal.tsx +++ b/apps/super-admin/src/pages/ConcertHallInfoPage/AddressSearchModal.tsx @@ -1,6 +1,9 @@ +import { NaverGeocodeProvider, useNaverGeocode, type GeocodeCoordinates } from '@boolti/ui'; import { Modal } from 'antd'; import { useEffect, useRef } from 'react'; +const NCP_KEY_ID = import.meta.env.VITE_X_NCP_APIGW_API_KEY_ID; + const POSTCODE_SCRIPT_URL = '//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js'; interface DaumPostcodeData { @@ -36,7 +39,7 @@ const loadPostcodeScript = () => { script.onload = () => resolve(); script.onerror = () => { scriptPromise = null; - reject(new Error('카카오 우편번호 스크립트 로드 실패')); + reject(new Error('우편번호 스크립트 로드 실패')); }; document.head.appendChild(script); }); @@ -44,17 +47,25 @@ const loadPostcodeScript = () => { return scriptPromise; }; +type Geocode = (address: string) => Promise; + interface AddressSearchModalProps { open: boolean; onClose: () => void; - /** 도로명주소 선택 시 호출. 호출 측에서 모달을 닫는다. */ - onComplete: (roadAddress: string) => void; + /** 도로명주소 선택 시 호출. 좌표는 지오코딩 실패 시 null. 호출 측에서 모달을 닫는다. */ + onComplete: (roadAddress: string, coordinates: GeocodeCoordinates | null) => void; /** 닫힘 애니메이션 완료 후 호출 — 상세주소 포커스는 이 시점에 해야 antd의 포커스 복원에 덮이지 않는다. */ afterClose?: () => void; } -// 카카오(다음) 우편번호 서비스를 embed한 주소 찾기 모달 -const AddressSearchModal = ({ open, onClose, onComplete, afterClose }: AddressSearchModalProps) => { +// 우편번호 서비스 embed + 선택 주소를 네이버 geocode로 좌표 변환 +const AddressSearchModalView = ({ + open, + onClose, + onComplete, + afterClose, + geocode, +}: AddressSearchModalProps & { geocode: Geocode | null }) => { const containerRef = useRef(null); useEffect(() => { @@ -70,15 +81,18 @@ const AddressSearchModal = ({ open, onClose, onComplete, afterClose }: AddressSe new window.daum.Postcode({ width: '100%', height: '100%', - oncomplete: (data) => { - onComplete(data.roadAddress || data.address); + oncomplete: async (data) => { + const roadAddress = data.roadAddress || data.address; + // 우편번호 서비스는 좌표를 주지 않으므로 선택 주소를 네이버 geocode로 변환한다. + const coordinates = geocode ? await geocode(roadAddress) : null; + onComplete(roadAddress, coordinates); }, }).embed(containerRef.current); }); return () => { cancelled = true; }; - }, [open, onComplete]); + }, [open, onComplete, geocode]); return ( { + const geocode = useNaverGeocode(); + return ; +}; + +// NCP 키가 있으면 네이버 geocode를 붙이고, 없으면 좌표 없이 동작한다. +// 키 유무는 런타임 고정값이라 트리 구조가 바뀌지 않는다(모달 애니메이션 보존). +const AddressSearchModal = (props: AddressSearchModalProps) => { + if (NCP_KEY_ID) { + return ( + + + + ); + } + return ; +}; + export default AddressSearchModal; diff --git a/apps/super-admin/src/pages/ConcertHallInfoPage/SubwayLineBadge.tsx b/apps/super-admin/src/pages/ConcertHallInfoPage/SubwayLineBadge.tsx deleted file mode 100644 index 99d65c63..00000000 --- a/apps/super-admin/src/pages/ConcertHallInfoPage/SubwayLineBadge.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { SuperAdminSubwayLine } from '@boolti/api/src/types/superAdminConcertHall'; - -// '수도권 2호선' → '2', '인천 1호선' → '인천1', '분당선' → '분당', 'GTX-A' → 'GTX-A' -const getLineBadgeLabel = (lineName: string) => { - const name = lineName.replace(/^(수도권|서울)\s*/, '').trim(); - const numberMatch = name.match(/^(\d+)호선$/); - if (numberMatch) { - return numberMatch[1]; - } - return name.replace(/호선$/, '').replace(/선$/, '').replace(/\s+/g, ''); -}; - -const SubwayLineBadge = ({ line }: { line: SuperAdminSubwayLine }) => { - const label = getLineBadgeLabel(line.lineName); - const isCircle = label.length === 1; - - return ( - - {label} - - ); -}; - -export default SubwayLineBadge; diff --git a/apps/super-admin/src/pages/ConcertHallInfoPage/SubwayStationSearchModal.tsx b/apps/super-admin/src/pages/ConcertHallInfoPage/SubwayStationSearchModal.tsx index 273e640c..65d1e861 100644 --- a/apps/super-admin/src/pages/ConcertHallInfoPage/SubwayStationSearchModal.tsx +++ b/apps/super-admin/src/pages/ConcertHallInfoPage/SubwayStationSearchModal.tsx @@ -4,7 +4,7 @@ import { useTheme } from '@emotion/react'; import { Flex, Input, Modal, Typography } from 'antd'; import { useEffect, useState } from 'react'; -import SubwayLineBadge from './SubwayLineBadge'; +import { SubwayLineBadge } from '@boolti/ui'; const { Search } = Input; @@ -74,7 +74,12 @@ const SubwayStationSearchModal = ({ open, onClose, onSelect }: SubwayStationSear {station.stationName} {station.lines.map((line) => ( - + ))} diff --git a/apps/super-admin/src/pages/ConcertHallInfoPage/index.tsx b/apps/super-admin/src/pages/ConcertHallInfoPage/index.tsx index 575fce07..cb4a4176 100644 --- a/apps/super-admin/src/pages/ConcertHallInfoPage/index.tsx +++ b/apps/super-admin/src/pages/ConcertHallInfoPage/index.tsx @@ -10,7 +10,12 @@ import { SubwayStationSearchItem, SuperAdminSubwayLine, } from '@boolti/api/src/types/superAdminConcertHall'; -import { Button as BooltiButton, useToast } from '@boolti/ui'; +import { + Button as BooltiButton, + SubwayLineBadge, + useToast, + type GeocodeCoordinates, +} from '@boolti/ui'; import { useTheme } from '@emotion/react'; import { Button, Card, Checkbox, Flex, Input, InputNumber, Typography } from 'antd'; import type { InputRef } from 'antd'; @@ -26,7 +31,6 @@ import secondFloorIcon from '~/assets/amenities/second-floor.svg'; import waitingRoomIcon from '~/assets/amenities/waiting-room.svg'; import AddressSearchModal from './AddressSearchModal'; import ImageUploadBox from './ImageUploadBox'; -import SubwayLineBadge from './SubwayLineBadge'; import SubwayStationSearchModal from './SubwayStationSearchModal'; const { TextArea } = Input; @@ -122,6 +126,8 @@ const ConcertHallInfoPage = () => { const [name, setName] = useState(''); const [streetAddress, setStreetAddress] = useState(''); const [detailAddress, setDetailAddress] = useState(''); + const [latitude, setLatitude] = useState(undefined); + const [longitude, setLongitude] = useState(undefined); const [stations, setStations] = useState([]); const [representativeImage, setRepresentativeImage] = useState(null); @@ -145,6 +151,8 @@ const ConcertHallInfoPage = () => { setName(detail.name ?? ''); setStreetAddress(detail.location?.streetAddress ?? ''); setDetailAddress(detail.location?.detailAddress ?? ''); + setLatitude(detail.location?.latitude); + setLongitude(detail.location?.longitude); setRepresentativeImage(detail.representativeImageUrl ?? null); setWebsiteUrl(detail.contact?.websiteUrl ?? ''); setPhoneNumber(detail.contact?.phoneNumber ?? ''); @@ -190,8 +198,13 @@ const ConcertHallInfoPage = () => { // 주소 선택으로 닫힌 경우에만 닫힘 애니메이션 완료 후 상세주소에 포커스한다 (디자인 정책) const shouldFocusDetailAddressRef = useRef(false); - const onCompleteAddress = (roadAddress: string) => { + const onCompleteAddress = (roadAddress: string, coordinates: GeocodeCoordinates | null) => { setStreetAddress(roadAddress); + // 지오코딩 성공 시에만 좌표를 갱신한다 (실패 시 기존 좌표 유지) + if (coordinates) { + setLatitude(coordinates.latitude); + setLongitude(coordinates.longitude); + } shouldFocusDetailAddressRef.current = true; setIsAddressModalOpen(false); }; @@ -251,9 +264,9 @@ const ConcertHallInfoPage = () => { location: { streetAddress: streetAddress.trim() || undefined, detailAddress: detailAddress.trim() || undefined, - // Daum 우편번호는 좌표를 주지 않으므로 기존 좌표를 보존한다. - latitude: detail?.location?.latitude, - longitude: detail?.location?.longitude, + // 주소 찾기 시 카카오 지오코딩으로 취합한 좌표 (없으면 기존 값 유지) + latitude, + longitude, }, contact: { websiteUrl: websiteUrl.trim() || undefined, @@ -403,7 +416,12 @@ const ConcertHallInfoPage = () => { }} > {station.lines.map((line) => ( - + ))} {station.stationName} + useQuery({ + ...queryKeys.concertHall.images(concertHallId ?? 0), + enabled: concertHallId != null && enabled, + }); + +export default useConcertHallImages; diff --git a/packages/api/src/queryKey.ts b/packages/api/src/queryKey.ts index 7467bd13..71accaf7 100644 --- a/packages/api/src/queryKey.ts +++ b/packages/api/src/queryKey.ts @@ -43,6 +43,7 @@ import { } from './types/adminShow'; import { SuperAdminUserResponse } from './types/superAdminUser'; import { + ConcertHallImageListResponse, ConcertHallProfileResponse, WebHostConcertHallListResponse, } from './types/concertHall'; @@ -515,6 +516,13 @@ export const concertHallQueryKeys = createQueryKeys('concertHall', { queryFn: () => fetcher.get(`web/papi/v1/concert-halls/${concertHallId}`), }), + images: (concertHallId: number) => ({ + queryKey: [concertHallId], + queryFn: () => + fetcher.get( + `web/papi/v1/concert-halls/${concertHallId}/images`, + ), + }), }); export const preQuestionQueryKeys = createQueryKeys('preQuestion', { diff --git a/packages/api/src/types/concertHall.ts b/packages/api/src/types/concertHall.ts index fbf5cc41..c47c2165 100644 --- a/packages/api/src/types/concertHall.ts +++ b/packages/api/src/types/concertHall.ts @@ -21,16 +21,141 @@ export interface ConcertHallLocation { longitude?: number; } +export interface ConcertHallCapacity { + seatedCapacity?: number; + standingCapacity?: number; +} + +export interface ConcertHallSubwayLine { + id: number; + lineKey?: string; + lineName: string; + colorHex: string; +} + +export interface ConcertHallSubwayStation { + id: number; + stationName: string; + region?: string; + lines: ConcertHallSubwayLine[]; +} + +export interface ConcertHallContact { + websiteUrl?: string; + phoneNumber?: string; + email?: string; +} + export interface ConcertHallProfileHead { rentalFeeSummary?: string; + capacity?: ConcertHallCapacity; + location?: ConcertHallLocation; + subwayStations?: ConcertHallSubwayStation[]; + contact?: ConcertHallContact; +} + +export interface ConcertHallImage { + id: number; + imageUrl: string; + thumbnailUrl?: string; + sequence?: number; +} + +export interface ConcertHallAmenity { + type: string; + name: string; + count?: number | null; +} + +export interface ConcertHallImageListResponse { + items: ConcertHallImage[]; +} + +export interface ConcertHallProfileHome { + introduction?: string; + images?: ConcertHallImage[]; + totalImageCount?: number; + amenities?: ConcertHallAmenity[]; location?: ConcertHallLocation; } +export interface ConcertHallShare { + shareCode?: string; + title?: string; + imageUrl?: string; +} + +export type ConcertHallProfileDayType = + | 'ANYTIME' + | 'MON_TO_THU' + | 'WEEKDAY' + | 'FRIDAY' + | 'SATURDAY' + | 'SUNDAY' + | 'FRI_TO_SUN' + | 'WEEKEND' + | 'HOLIDAY' + | 'PRE_HOLIDAY_WEEKDAY'; + +export interface ConcertHallProfileRentalTime { + /** 대관 시간 (시간 단위) */ + rentalTimeHours?: number; + /** 대관 시간 설명 */ + rentalTimeDescription?: string; + isEngineerBreakIncluded?: boolean; +} + +export interface ConcertHallProfileFee { + id: number; + dayType: ConcertHallProfileDayType; + /** 요일 유형 이름 (예: 평일) */ + dayTypeName: string; + /** 금액 (원) */ + fee: number; + /** 노출 순서 (0부터 시작) */ + sequence: number; +} + +export interface ConcertHallProfileVat { + type: 'NONE' | 'VAT_INCLUDED' | 'VAT_EXCLUDED'; + /** 부가세 설명 (예: 부가세 별도) */ + description?: string; +} + +export interface ConcertHallProfilePaidOption { + id: number; + name: string; + /** 옵션 가격 (원) */ + price: number; +} + +export interface ConcertHallProfileRental { + /** 대관 방법 */ + rentalMethod?: string; + rentalTime?: ConcertHallProfileRentalTime; + /** 기본 대관료 목록 */ + rentalFees?: ConcertHallProfileFee[]; + vat?: ConcertHallProfileVat; + /** 추가 비용 목록 */ + additionalFees?: ConcertHallProfileFee[]; + /** 악기 목록 텍스트 */ + instrumentsText?: string; + /** 유상 옵션 목록 */ + paidOptions?: ConcertHallProfilePaidOption[]; + /** 특이사항 목록 */ + specialNotes?: string[]; +} + export interface ConcertHallProfileResponse { id: number; name: string; shareCode?: string; representativeImageUrl?: string; + share?: ConcertHallShare; + hasHomeTabData?: boolean; + hasRentalTabData?: boolean; head?: ConcertHallProfileHead; + home?: ConcertHallProfileHome; + rental?: ConcertHallProfileRental; informationUpdatedAt?: string; } diff --git a/packages/ui/src/components/NaverGeocodeProvider/index.tsx b/packages/ui/src/components/NaverGeocodeProvider/index.tsx new file mode 100644 index 00000000..69e69441 --- /dev/null +++ b/packages/ui/src/components/NaverGeocodeProvider/index.tsx @@ -0,0 +1,15 @@ +import { NavermapsProvider } from 'react-naver-maps'; + +interface Props { + ncpKeyId: string; + children: React.ReactNode; +} + +// useNaverGeocode를 쓰기 위한 컨텍스트. geocoder submodule을 로드한다. +const NaverGeocodeProvider = ({ ncpKeyId, children }: Props) => ( + + {children} + +); + +export default NaverGeocodeProvider; diff --git a/packages/ui/src/components/SubwayLineBadge/SubwayLineBadge.styles.ts b/packages/ui/src/components/SubwayLineBadge/SubwayLineBadge.styles.ts new file mode 100644 index 00000000..1ff0cd15 --- /dev/null +++ b/packages/ui/src/components/SubwayLineBadge/SubwayLineBadge.styles.ts @@ -0,0 +1,35 @@ +import styled from '@emotion/styled'; + +import { SubwayLineBadgeSize } from './index'; + +interface ContainerProps { + backgroundColor: string; + textColor: string; + size: SubwayLineBadgeSize; + isCircle: boolean; +} + +const FONT_BY_SIZE: Record = { + small: { fontSize: 11, fontWeight: 700 }, + medium: { fontSize: 14, fontWeight: 600 }, +}; + +const Container = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + height: 20px; + min-width: 20px; + padding: ${({ isCircle }) => (isCircle ? '0' : '0 7px')}; + border-radius: 100px; + background-color: ${({ backgroundColor }) => backgroundColor}; + color: ${({ textColor }) => textColor}; + font-size: ${({ size }) => FONT_BY_SIZE[size].fontSize}px; + font-weight: ${({ size }) => FONT_BY_SIZE[size].fontWeight}; + line-height: 1; + white-space: nowrap; +`; + +export default { + Container, +}; diff --git a/packages/ui/src/components/SubwayLineBadge/index.tsx b/packages/ui/src/components/SubwayLineBadge/index.tsx new file mode 100644 index 00000000..729fa0d5 --- /dev/null +++ b/packages/ui/src/components/SubwayLineBadge/index.tsx @@ -0,0 +1,89 @@ +import Styled from './SubwayLineBadge.styles'; + +export type SubwayLineBadgeSize = 'small' | 'medium'; + +// 비숫자 노선의 뱃지 라벨 매핑 (디자인 기준). 키는 지역 접두사 제거 후의 노선명. +const LINE_LABEL_MAP: Record = { + 신분당선: '신분당', + 분당선: '분당', + 수인분당선: '분당', + '경의·중앙선': '경의', + 경의중앙선: '경의', + 경춘선: '경춘', + 공항철도: '공항', + 의정부경전철: '의정', + 용인경전철: '용인', + 용인에버라인: '용인', + 경강선: '경강', + 우이신설선: '우이', + 서해선: '서해', + 김포골드라인: '김포', + 신림선: '신림', +}; + +// 노선 이름 -> 뱃지 라벨. +// "수도권 2호선" -> "2", "인천 1호선" -> "인천 1", "신분당선" -> "신분당", "GTX-A" -> "GTX-A" +export const getSubwayLineLabel = (lineName: string) => { + const name = lineName.replace(/^(수도권|서울)\s*/, '').trim(); + + const incheonLine = name.match(/^인천\s*(\d+)호선$/); + if (incheonLine) { + return `인천 ${incheonLine[1]}`; + } + + const numberLine = name.match(/^(\d+)호선$/); + if (numberLine) { + return numberLine[1]; + } + + if (LINE_LABEL_MAP[name]) { + return LINE_LABEL_MAP[name]; + } + + if (/^GTX/i.test(name)) { + return name; + } + + // 매핑에 없으면 잘라내지 않고 '선' 접미사만 제거해 그대로 노출한다. + return name.replace(/선$/, ''); +}; + +// 밝은 노선 색상(분당선 등) 위에는 어두운 텍스트를 써서 가독성을 확보한다. +const isLightColor = (colorHex: string) => { + const hex = colorHex.replace('#', ''); + + if (hex.length !== 6) { + return false; + } + + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + + return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.64; +}; + +interface Props { + /** 노선 이름 (예: "수도권 2호선", "신분당선") */ + lineName: string; + /** 노선 색상 (#rrggbb) */ + colorHex: string; + size?: SubwayLineBadgeSize; +} + +const SubwayLineBadge = ({ lineName, colorHex, size = 'medium' }: Props) => { + const label = getSubwayLineLabel(lineName); + + return ( + + {label} + + ); +}; + +export default SubwayLineBadge; diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 2ca58cd7..35ef81f3 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -19,6 +19,9 @@ import Checkbox from './Checkbox'; import RadioButton from './RadioButton'; import StepDialog from './Dialog/StepDialog'; import ShowInfoDetail from './ShowPreview/ShowInfoDetail'; +import PreviewMapWithProvider from './PreviewMap/PreviewMapWithProvider'; +import SubwayLineBadge from './SubwayLineBadge'; +import NaverGeocodeProvider from './NaverGeocodeProvider'; export { AgreeCheck, @@ -42,4 +45,7 @@ export { RadioButton, StepDialog, ShowInfoDetail, + PreviewMapWithProvider, + SubwayLineBadge, + NaverGeocodeProvider, }; diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 54944748..63b1a794 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -5,5 +5,16 @@ import useToast from './useToast'; import useAlert from './useAlert'; import useStepDialog from './useStepDialog'; import useDeviceByWidth from './useDeviceByWidth'; +import useNaverGeocode from './useNaverGeocode'; -export { useConfirm, useDialog, useDropdown, useToast, useAlert, useStepDialog, useDeviceByWidth }; +export { + useConfirm, + useDialog, + useDropdown, + useToast, + useAlert, + useStepDialog, + useDeviceByWidth, + useNaverGeocode, +}; +export type { GeocodeCoordinates } from './useNaverGeocode'; diff --git a/packages/ui/src/hooks/useNaverGeocode.ts b/packages/ui/src/hooks/useNaverGeocode.ts new file mode 100644 index 00000000..fd90c59d --- /dev/null +++ b/packages/ui/src/hooks/useNaverGeocode.ts @@ -0,0 +1,56 @@ +import { useCallback } from 'react'; +import { useNavermaps } from 'react-naver-maps'; + +export interface GeocodeCoordinates { + latitude: number; + longitude: number; +} + +// geocode에 필요한 최소 인터페이스. @types/navermaps 유무와 무관하게 +// 동일하게 동작하도록 useNavermaps 결과를 이 형태로 캐스팅해 사용한다. +interface NaverGeocodeService { + Service: { + Status: { OK: string }; + geocode: ( + options: { query: string }, + callback: ( + status: string, + response: { v2: { addresses: Array<{ x: string; y: string }> } }, + ) => void, + ) => void; + }; +} + +// 네이버 지도 geocoder로 주소를 좌표로 변환한다. +// NaverGeocodeProvider(submodules: geocoder) 컨텍스트 안에서만 사용 가능. +const useNaverGeocode = () => { + const navermaps = useNavermaps() as unknown as NaverGeocodeService; + + return useCallback( + (address: string) => + new Promise((resolve) => { + if (!address.trim()) { + resolve(null); + return; + } + + navermaps.Service.geocode({ query: address }, (status, response) => { + if (status !== navermaps.Service.Status.OK) { + resolve(null); + return; + } + + const result = response.v2.addresses[0]; + if (!result) { + resolve(null); + return; + } + + resolve({ latitude: Number(result.y), longitude: Number(result.x) }); + }); + }), + [navermaps], + ); +}; + +export default useNaverGeocode; diff --git a/yarn.lock b/yarn.lock index f250e87e..c7a2e140 100644 --- a/yarn.lock +++ b/yarn.lock @@ -406,6 +406,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-string-parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-string-parser@npm:7.29.7" + checksum: 10c0/194bc0f1716e396d5ffde56ad6119745fb9557662c98611590e5e454906783a4ccb21ce93056b8eb69a4909044834e45d96e50ac695bbe9e3221648fe033c06c + languageName: node + linkType: hard + "@babel/helper-validator-identifier@npm:^7.22.20": version: 7.22.20 resolution: "@babel/helper-validator-identifier@npm:7.22.20" @@ -413,6 +420,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-identifier@npm:7.29.7" + checksum: 10c0/4795354e7ae0dcafa72de1cd04ec51252dc1498517170beaf019e03effc5b7bf13c6b21a3949a77e07b8125be7f106ed1131350d8ebd4566ae874094a726d62b + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.22.15, @babel/helper-validator-option@npm:^7.23.5": version: 7.23.5 resolution: "@babel/helper-validator-option@npm:7.23.5" @@ -1699,6 +1713,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.26.0": + version: 7.29.7 + resolution: "@babel/types@npm:7.29.7" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10c0/b6623994c69717fa27294f5fa46d59140338e2d86c6c1c13085c84ef7d53086ee357fbf4fe9abe3dd3da75734dc77c4c0df2f90fb29e667558bb3b3fb705e88f + languageName: node + linkType: hard + "@base2/pretty-print-object@npm:1.0.1": version: 1.0.1 resolution: "@base2/pretty-print-object@npm:1.0.1" @@ -6857,6 +6881,15 @@ __metadata: languageName: node linkType: hard +"babel-plugin-react-compiler@npm:^1.0.0": + version: 1.0.0 + resolution: "babel-plugin-react-compiler@npm:1.0.0" + dependencies: + "@babel/types": "npm:^7.26.0" + checksum: 10c0/9406267ada8d7dbdfe8906b40ecadb816a5f4cee2922bee23f7729293b369624ee135b5a9b0f263851c263c9787522ac5d97016c9a2b82d1668300e42b18aff8 + languageName: node + linkType: hard + "bail@npm:^2.0.0": version: 2.0.2 resolution: "bail@npm:2.0.2" @@ -12952,6 +12985,33 @@ __metadata: languageName: node linkType: hard +"place@workspace:apps/place": + version: 0.0.0-use.local + resolution: "place@workspace:apps/place" + dependencies: + "@boolti/api": "npm:*" + "@boolti/bridge": "npm:*" + "@boolti/eslint-config": "npm:*" + "@boolti/icon": "npm:*" + "@boolti/typescript-config": "npm:*" + "@boolti/ui": "npm:*" + "@emotion/babel-plugin": "npm:^11.11.0" + "@emotion/react": "npm:^11.11.3" + "@emotion/styled": "npm:^11.11.0" + "@types/react": "npm:^18.2.43" + "@types/react-dom": "npm:^18.2.17" + "@vitejs/plugin-react": "npm:^4.2.1" + babel-plugin-react-compiler: "npm:^1.0.0" + react: "npm:^18.2.0" + react-compiler-runtime: "npm:^1.0.0" + react-dom: "npm:^18.2.0" + react-router-dom: "npm:^6.21.3" + the-new-css-reset: "npm:^1.11.2" + typescript: "npm:^5.2.2" + vite: "npm:^5.0.8" + languageName: unknown + linkType: soft + "playwright-core@npm:1.59.1": version: 1.59.1 resolution: "playwright-core@npm:1.59.1" @@ -13966,6 +14026,15 @@ __metadata: languageName: node linkType: hard +"react-compiler-runtime@npm:^1.0.0": + version: 1.0.0 + resolution: "react-compiler-runtime@npm:1.0.0" + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental + checksum: 10c0/e081192380ae32d18bebaf341071ae8aecf80f4401b188f80e4b77e8d8995bb4c7175b8c3f75e8307019eac2015edf45c7999f788dcfcd99fdbe2ba9a2d465a4 + languageName: node + linkType: hard + "react-confetti@npm:^6.1.0": version: 6.1.0 resolution: "react-confetti@npm:6.1.0"