From 69bd8cd82dade64f9820ebff868c278c203dd663 Mon Sep 17 00:00:00 2001 From: hexdrinker Date: Thu, 11 Jun 2026 15:25:33 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat(api):=20=EA=B3=B5=EC=97=B0=EC=9E=A5?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=91=EB=8B=B5=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9D=84=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/api/src/types/concertHall.ts | 59 +++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/api/src/types/concertHall.ts b/packages/api/src/types/concertHall.ts index fbf5cc41..9d629ccf 100644 --- a/packages/api/src/types/concertHall.ts +++ b/packages/api/src/types/concertHall.ts @@ -21,16 +21,75 @@ 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 ConcertHallProfileHome { + introduction?: string; + images?: ConcertHallImage[]; + totalImageCount?: number; + amenities?: ConcertHallAmenity[]; location?: ConcertHallLocation; } +export interface ConcertHallShare { + shareCode?: string; + title?: string; + imageUrl?: string; +} + export interface ConcertHallProfileResponse { id: number; name: string; shareCode?: string; representativeImageUrl?: string; + share?: ConcertHallShare; + hasHomeTabData?: boolean; + hasRentalTabData?: boolean; head?: ConcertHallProfileHead; + home?: ConcertHallProfileHome; informationUpdatedAt?: string; } From 5c9306ae2ec2c31872aa33be1dae990d45a1378f Mon Sep 17 00:00:00 2001 From: hexdrinker Date: Thu, 11 Jun 2026 15:25:33 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat(ui):=20PreviewMapWithProvider=20expo?= =?UTF-8?q?rt=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/ui/src/components/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 2ca58cd7..589a8873 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -19,6 +19,7 @@ import Checkbox from './Checkbox'; import RadioButton from './RadioButton'; import StepDialog from './Dialog/StepDialog'; import ShowInfoDetail from './ShowPreview/ShowInfoDetail'; +import PreviewMapWithProvider from './PreviewMap/PreviewMapWithProvider'; export { AgreeCheck, @@ -42,4 +43,5 @@ export { RadioButton, StepDialog, ShowInfoDetail, + PreviewMapWithProvider, }; From c3fd793ff0f517c12fd58f9b2097f25d5b2aaeb9 Mon Sep 17 00:00:00 2001 From: hexdrinker Date: Thu, 11 Jun 2026 15:25:43 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat(place):=20=EA=B3=B5=EC=97=B0?= =?UTF-8?q?=EC=9E=A5=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9B=B9=EB=B7=B0=20?= =?UTF-8?q?=EC=95=B1=20=EC=8B=A0=EA=B7=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - React 18 + Vite 5 + React Compiler(target 18) 구성의 place 워크스페이스 추가 - 공연장 홈 화면 구현: Hall Head(요약/문의처), 홈 탭(소개/사진/편의시설/위치), 데이터 없는 경우 COMING SOON - 데스크탑은 max-width 680px 중앙 고정, 모바일 웹뷰 기준 레이아웃 - 로컬 개발용 vite 프록시(/web -> dev.api.boolti.in) 구성 Co-Authored-By: Claude Fable 5 --- .pnp.cjs | 89 +++++++++ apps/place/.gitignore | 24 +++ apps/place/index.html | 29 +++ apps/place/package.json | 38 ++++ apps/place/public/_redirects | 1 + apps/place/src/App.tsx | 30 +++ apps/place/src/assets/images/default-hall.png | Bin 0 -> 13167 bytes .../place/src/components/ComingSoon/index.tsx | 34 ++++ .../components/HallHead/HallHead.styles.ts | 184 ++++++++++++++++++ apps/place/src/components/HallHead/index.tsx | 119 +++++++++++ .../src/components/HomeTab/HomeTab.styles.ts | 162 +++++++++++++++ apps/place/src/components/HomeTab/index.tsx | 167 ++++++++++++++++ apps/place/src/components/Layout/index.tsx | 30 +++ apps/place/src/components/icons/index.tsx | 68 +++++++ apps/place/src/constants/ncp.ts | 1 + apps/place/src/emotion.d.ts | 11 ++ apps/place/src/index.css | 11 ++ apps/place/src/main.tsx | 5 + .../ConcertHallPage/ConcertHallPage.styles.ts | 45 +++++ .../place/src/pages/ConcertHallPage/index.tsx | 101 ++++++++++ apps/place/src/pages/ErrorPage/index.tsx | 21 ++ apps/place/src/utils/format.ts | 76 ++++++++ apps/place/src/vite-env.d.ts | 1 + apps/place/tsconfig.json | 12 ++ apps/place/vite.config.ts | 37 ++++ yarn.lock | 69 +++++++ 26 files changed, 1365 insertions(+) create mode 100644 apps/place/.gitignore create mode 100644 apps/place/index.html create mode 100644 apps/place/package.json create mode 100644 apps/place/public/_redirects create mode 100644 apps/place/src/App.tsx create mode 100644 apps/place/src/assets/images/default-hall.png create mode 100644 apps/place/src/components/ComingSoon/index.tsx create mode 100644 apps/place/src/components/HallHead/HallHead.styles.ts create mode 100644 apps/place/src/components/HallHead/index.tsx create mode 100644 apps/place/src/components/HomeTab/HomeTab.styles.ts create mode 100644 apps/place/src/components/HomeTab/index.tsx create mode 100644 apps/place/src/components/Layout/index.tsx create mode 100644 apps/place/src/components/icons/index.tsx create mode 100644 apps/place/src/constants/ncp.ts create mode 100644 apps/place/src/emotion.d.ts create mode 100644 apps/place/src/index.css create mode 100644 apps/place/src/main.tsx create mode 100644 apps/place/src/pages/ConcertHallPage/ConcertHallPage.styles.ts create mode 100644 apps/place/src/pages/ConcertHallPage/index.tsx create mode 100644 apps/place/src/pages/ErrorPage/index.tsx create mode 100644 apps/place/src/utils/format.ts create mode 100644 apps/place/src/vite-env.d.ts create mode 100644 apps/place/tsconfig.json create mode 100644 apps/place/vite.config.ts 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..9287440d --- /dev/null +++ b/apps/place/src/App.tsx @@ -0,0 +1,30 @@ +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: '/', + element: , + errorElement: , + }, +]); + +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 0000000000000000000000000000000000000000..10b26be6db4ef73758520ad0a693fcdb620662ed GIT binary patch literal 13167 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 C3<($j literal 0 HcmV?d00001 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/HallHead/HallHead.styles.ts b/apps/place/src/components/HallHead/HallHead.styles.ts new file mode 100644 index 00000000..e62ea9a0 --- /dev/null +++ b/apps/place/src/components/HallHead/HallHead.styles.ts @@ -0,0 +1,184 @@ +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 SubwayLineChip = styled.span<{ backgroundColor: string; isLight: boolean }>` + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 100px; + background-color: ${({ backgroundColor }) => backgroundColor}; + font-size: 14px; + font-weight: 600; + line-height: 22px; + white-space: nowrap; + color: ${({ theme, isLight }) => + isLight ? theme.palette.mobile.grey.g90 : theme.palette.mobile.grey.w}; +`; + +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` + 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 }) => theme.palette.mobile.grey.g30}; + cursor: pointer; + + &:disabled { + opacity: 0.4; + cursor: default; + } +`; + +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, + SubwayLineChip, + 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..5195a466 --- /dev/null +++ b/apps/place/src/components/HallHead/index.tsx @@ -0,0 +1,119 @@ +import type { ConcertHallProfileResponse } from '@boolti/api'; +import { ShareIcon } from '@boolti/icon'; + +import defaultHallImage from '~/assets/images/default-hall.png'; +import { CallIcon, MailIcon, WebsiteIcon } from '~/components/icons'; +import { formatAddress, formatCapacity, getSubwayLineShortName, isLightColor } from '~/utils/format'; + +import Styled from './HallHead.styles'; + +interface Props { + profile: ConcertHallProfileResponse; + onShare: () => void; +} + +const HallHead = ({ profile, onShare }: Props) => { + 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; + + return ( + + + + + + + + + + + {name} + + + {hasSummary && ( + + {head?.rentalFeeSummary && ( + + 대관료 + {head.rentalFeeSummary} + + )} + {capacityText && ( + + 수용 인원 + {capacityText} + + )} + {addressText && ( + + 위치 + {addressText} + + )} + {subwayStations.length > 0 && ( + + 지하철역 + + {subwayStations.map((station) => ( + + {station.lines.map((line) => ( + + {getSubwayLineShortName(line.lineName)} + + ))} + {station.stationName} + + ))} + + + )} + + )} + {hasContact && ( + + window.open(contact?.websiteUrl, '_blank', 'noopener,noreferrer')} + > + + 웹사이트 + + (window.location.href = `tel:${contact?.phoneNumber}`)} + > + + 전화 + + (window.location.href = `mailto:${contact?.email}`)} + > + + 메일 + + + )} + + ); +}; + +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..c1d9a7a4 --- /dev/null +++ b/apps/place/src/components/HomeTab/HomeTab.styles.ts @@ -0,0 +1,162 @@ +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.div` + position: relative; + aspect-ratio: 1 / 1; + border: 1px solid ${({ theme }) => theme.palette.mobile.grey.g85}; + border-radius: 8px; + overflow: hidden; +`; + +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..748daa6c --- /dev/null +++ b/apps/place/src/components/HomeTab/index.tsx @@ -0,0 +1,167 @@ +import type { ConcertHallProfileResponse } from '@boolti/api'; +import { checkIsWebView } from '@boolti/bridge'; +import { ChevronDownIcon } from '@boolti/icon'; +import { PreviewMapWithProvider, useToast } from '@boolti/ui'; +import { useLayoutEffect, useRef, useState } from 'react'; + +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 MAX_VISIBLE_PHOTO_COUNT = 6; + +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 && } + + {isCollapsed && ( + setIsExpanded(true)}> + 내용 더 보기 + + + )} + + + ); +}; + +interface Props { + profile: ConcertHallProfileResponse; +} + +const HomeTab = ({ profile }: Props) => { + const toast = useToast(); + const home = profile.home; + + const images = home?.images ?? []; + const totalImageCount = home?.totalImageCount ?? images.length; + const visibleImages = images.slice(0, MAX_VISIBLE_PHOTO_COUNT); + const hiddenImageCount = totalImageCount - MAX_VISIBLE_PHOTO_COUNT; + + 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 === MAX_VISIBLE_PHOTO_COUNT - 1; + const showMoreOverlay = isLastVisible && hiddenImageCount > 0; + + return ( + + + {showMoreOverlay && ( + + + {totalImageCount} + + )} + + ); + })} + + + )} + {amenities.length > 0 && ( + + 편의 시설 및 서비스 + + {amenities.map((amenity) => ( + + {AMENITY_ICONS[amenity.type]} + {formatAmenityLabel(amenity)} + + ))} + + + )} + {addressText && ( + + 위치 + + {addressText}・ + + 복사 + + + {hasMap && ( + + )} + + )} + + ); +}; + +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/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..bdc8616a --- /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 { useSearchParams } 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 { 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 [searchParams] = useSearchParams(); + const idParam = searchParams.get('concertHallId') ?? searchParams.get('id'); + const concertHallId = idParam && /^\d+$/.test(idParam) ? Number(idParam) : null; + + console.log('concertHallId', concertHallId); + 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' && } + + + 이 페이지의 정보는 불티에서 수집한 것으로, 실제 시설 및 장비와 다를 수 있습니다. 정확한 + 정보는 대관 시 공연장에 직접 확인해 주세요. + {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..bc0dc113 --- /dev/null +++ b/apps/place/src/utils/format.ts @@ -0,0 +1,76 @@ +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()}개`; +}; + +// "2호선" -> "2", "경의중앙선" -> "경의", "분당선" -> "분당" +export const getSubwayLineShortName = (lineName: string) => { + const numberLine = lineName.match(/^(\d+)호선$/); + + if (numberLine) { + return numberLine[1]; + } + + return lineName.replace(/선$/, '').slice(0, 2); +}; + +// 밝은 노선 색상(분당선 등) 위에는 어두운 텍스트를 쓴다 +export 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; +}; 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/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" From b33035e46a528242ea99e4113de20371456897d6 Mon Sep 17 00:00:00 2001 From: hexdrinker Date: Mon, 22 Jun 2026 20:48:45 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat(api):=20=EA=B3=B5=EC=97=B0=EC=9E=A5?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=EA=B4=80(rental)=20=EC=A0=95=EB=B3=B4=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConcertHallProfileResponse에 rental 섹션(대관 방법/시간/대관료/추가요금/ 부가세/보유악기/유료옵션/특이사항) 타입을 추가한다. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/api/src/types/concertHall.ts | 62 +++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/api/src/types/concertHall.ts b/packages/api/src/types/concertHall.ts index 9d629ccf..4730d928 100644 --- a/packages/api/src/types/concertHall.ts +++ b/packages/api/src/types/concertHall.ts @@ -81,6 +81,67 @@ export interface ConcertHallShare { 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; @@ -91,5 +152,6 @@ export interface ConcertHallProfileResponse { hasRentalTabData?: boolean; head?: ConcertHallProfileHead; home?: ConcertHallProfileHome; + rental?: ConcertHallProfileRental; informationUpdatedAt?: string; } From 4392734ecc38c7c8f4fc1b59f8816cb4a245df82 Mon Sep 17 00:00:00 2001 From: hexdrinker Date: Mon, 22 Jun 2026 20:48:45 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat(place):=20=EA=B3=B5=EC=97=B0?= =?UTF-8?q?=EC=9E=A5=20=ED=94=84=EB=A1=9C=ED=95=84=20=EB=8C=80=EA=B4=80=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=ED=83=AD=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대관 정보 탭을 신규 구현한다. 대관 방법/시간/대관료/시간당 추가 요금/ 보유 악기(더보기 토글)/유료 옵션/특이사항을 응답 데이터로 렌더하고, hasRentalTabData가 false면 ComingSoon을 노출한다. 금액 포맷 유틸 추가. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/RentalTab/RentalTab.styles.ts | 179 ++++++++++++++++++ apps/place/src/components/RentalTab/index.tsx | 157 +++++++++++++++ .../place/src/pages/ConcertHallPage/index.tsx | 10 +- apps/place/src/utils/format.ts | 5 + 4 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 apps/place/src/components/RentalTab/RentalTab.styles.ts create mode 100644 apps/place/src/components/RentalTab/index.tsx 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/pages/ConcertHallPage/index.tsx b/apps/place/src/pages/ConcertHallPage/index.tsx index bdc8616a..22854eaf 100644 --- a/apps/place/src/pages/ConcertHallPage/index.tsx +++ b/apps/place/src/pages/ConcertHallPage/index.tsx @@ -1,12 +1,13 @@ import { useConcertHallProfile } from '@boolti/api'; import { useToast } from '@boolti/ui'; import { useEffect, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; +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'; @@ -20,11 +21,9 @@ const TABS: Array<{ key: TabKey; label: string }> = [ const ConcertHallPage = () => { const toast = useToast(); - const [searchParams] = useSearchParams(); - const idParam = searchParams.get('concertHallId') ?? searchParams.get('id'); + const { concertHallId: idParam } = useParams<{ concertHallId: string }>(); const concertHallId = idParam && /^\d+$/.test(idParam) ? Number(idParam) : null; - console.log('concertHallId', concertHallId); const { data: profile } = useConcertHallProfile(concertHallId); const [activeTab, setActiveTab] = useState('home'); @@ -81,7 +80,8 @@ const ConcertHallPage = () => { {activeTab === 'home' && (profile.hasHomeTabData ? : )} - {activeTab === 'rental' && } + {activeTab === 'rental' && + (profile.hasRentalTabData ? : )} 이 페이지의 정보는 불티에서 수집한 것으로, 실제 시설 및 장비와 다를 수 있습니다. 정확한 diff --git a/apps/place/src/utils/format.ts b/apps/place/src/utils/format.ts index bc0dc113..e39735b7 100644 --- a/apps/place/src/utils/format.ts +++ b/apps/place/src/utils/format.ts @@ -49,6 +49,11 @@ export const formatAmenityLabel = ({ type, name, count }: ConcertHallAmenity) => return `${name} ${count.toLocaleString()}개`; }; +export const formatFee = (fee: number) => `${fee.toLocaleString()}원`; + +export const normalizeWebsiteUrl = (url: string) => + /^https?:\/\//i.test(url) ? url : `https://${url}`; + // "2호선" -> "2", "경의중앙선" -> "경의", "분당선" -> "분당" export const getSubwayLineShortName = (lineName: string) => { const numberLine = lineName.match(/^(\d+)호선$/); From 4774d246d30866e9a0eda5f38613ad55763bdb35 Mon Sep 17 00:00:00 2001 From: hexdrinker Date: Mon, 22 Jun 2026 21:07:30 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat(api):=20=EA=B3=B5=EC=97=B0=EC=9E=A5?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EC=82=AC=EC=A7=84=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /web/papi/v1/concert-halls/{id}/images 쿼리(useConcertHallImages)와 ConcertHallImageListResponse 타입을 추가한다. 홈 탭은 최대 5장 미리보기만 주므로 사진 목록/크게보기에서 전체 사진을 조회하는 데 사용한다. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/api/src/queries/index.ts | 2 ++ packages/api/src/queries/useConcertHallImages.ts | 12 ++++++++++++ packages/api/src/queryKey.ts | 8 ++++++++ packages/api/src/types/concertHall.ts | 4 ++++ 4 files changed, 26 insertions(+) create mode 100644 packages/api/src/queries/useConcertHallImages.ts diff --git a/packages/api/src/queries/index.ts b/packages/api/src/queries/index.ts index 3f7d6768..fc53ef5c 100644 --- a/packages/api/src/queries/index.ts +++ b/packages/api/src/queries/index.ts @@ -59,6 +59,7 @@ import useSuperAdminUserList from './useSuperAdminUserList'; import useSuperAdminHostList from './useSuperAdminHostList'; import useConcertHallSearch from './useConcertHallSearch'; import useConcertHallProfile from './useConcertHallProfile'; +import useConcertHallImages from './useConcertHallImages'; import useSuperAdminConcertHallList from './useSuperAdminConcertHallList'; import useSuperAdminConcertHallDetail from './useSuperAdminConcertHallDetail'; import useSuperAdminConcertHallRental from './useSuperAdminConcertHallRental'; @@ -130,6 +131,7 @@ export { useSuperAdminHostList, useConcertHallSearch, useConcertHallProfile, + useConcertHallImages, useSuperAdminConcertHallList, useSuperAdminConcertHallDetail, useSuperAdminConcertHallRental, diff --git a/packages/api/src/queries/useConcertHallImages.ts b/packages/api/src/queries/useConcertHallImages.ts new file mode 100644 index 00000000..6bf899df --- /dev/null +++ b/packages/api/src/queries/useConcertHallImages.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; + +import { queryKeys } from '../queryKey'; + +// 사진 목록/크게보기에서 전체 이미지를 조회한다. (홈 탭은 최대 5장 미리보기만 제공) +const useConcertHallImages = (concertHallId: number | null, enabled = true) => + 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 4730d928..c47c2165 100644 --- a/packages/api/src/types/concertHall.ts +++ b/packages/api/src/types/concertHall.ts @@ -67,6 +67,10 @@ export interface ConcertHallAmenity { count?: number | null; } +export interface ConcertHallImageListResponse { + items: ConcertHallImage[]; +} + export interface ConcertHallProfileHome { introduction?: string; images?: ConcertHallImage[]; From 8be6d85781ef7b29314e7506bef9725f76aca985 Mon Sep 17 00:00:00 2001 From: hexdrinker Date: Mon, 22 Jun 2026 21:07:31 +0900 Subject: [PATCH 07/16] =?UTF-8?q?feat(place):=20=ED=99=88=20=ED=83=AD=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EA=B0=A4=EB=9F=AC=EB=A6=AC(=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=C2=B7=ED=81=AC=EA=B2=8C=EB=B3=B4=EA=B8=B0)=20?= =?UTF-8?q?=EB=B0=8F=20=EC=86=8C=EA=B0=9C=20=EC=A0=91=EA=B8=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사진 썸네일 클릭 시 크게보기(가로 스크롤 캐러셀 + dot), 더보기(+N) 클릭 시 사진 목록(3열 그리드) 모달을 띄운다. 전체 사진은 별도 조회. - 미리보기 오버레이를 백엔드 미리보기 장수 기준으로 보정. - 소개 섹션에 '내용 접기' 토글 추가. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../GalleryModal/GalleryModal.styles.ts | 145 ++++++++++++++++++ .../src/components/GalleryModal/index.tsx | 145 ++++++++++++++++++ .../src/components/HomeTab/HomeTab.styles.ts | 4 +- apps/place/src/components/HomeTab/index.tsx | 51 ++++-- 4 files changed, 329 insertions(+), 16 deletions(-) create mode 100644 apps/place/src/components/GalleryModal/GalleryModal.styles.ts create mode 100644 apps/place/src/components/GalleryModal/index.tsx 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/HomeTab/HomeTab.styles.ts b/apps/place/src/components/HomeTab/HomeTab.styles.ts index c1d9a7a4..dfe291a2 100644 --- a/apps/place/src/components/HomeTab/HomeTab.styles.ts +++ b/apps/place/src/components/HomeTab/HomeTab.styles.ts @@ -74,12 +74,14 @@ const PhotoGrid = styled.div` width: 100%; `; -const PhotoItem = styled.div` +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` diff --git a/apps/place/src/components/HomeTab/index.tsx b/apps/place/src/components/HomeTab/index.tsx index 748daa6c..03ac94e3 100644 --- a/apps/place/src/components/HomeTab/index.tsx +++ b/apps/place/src/components/HomeTab/index.tsx @@ -1,9 +1,10 @@ import type { ConcertHallProfileResponse } from '@boolti/api'; import { checkIsWebView } from '@boolti/bridge'; -import { ChevronDownIcon } from '@boolti/icon'; +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, @@ -19,7 +20,6 @@ import { formatAddress, formatAmenityLabel } from '~/utils/format'; import Styled from './HomeTab.styles'; const INTRODUCTION_COLLAPSED_HEIGHT = 280; -const MAX_VISIBLE_PHOTO_COUNT = 6; const AMENITY_ICONS: Record = { WAITING_ROOM: , @@ -55,10 +55,10 @@ const IntroductionSection = ({ introduction }: IntroductionSectionProps) => { {introduction} {isCollapsed && } - {isCollapsed && ( - setIsExpanded(true)}> - 내용 더 보기 - + {isOverflowing && ( + setIsExpanded((prev) => !prev)}> + {isExpanded ? '내용 접기' : '내용 더 보기'} + {isExpanded ? : } )} @@ -73,11 +73,12 @@ interface Props { const HomeTab = ({ profile }: Props) => { const toast = useToast(); const home = profile.home; + const [gallery, setGallery] = useState<{ mode: GalleryMode; index: number } | null>(null); - const images = home?.images ?? []; - const totalImageCount = home?.totalImageCount ?? images.length; - const visibleImages = images.slice(0, MAX_VISIBLE_PHOTO_COUNT); - const hiddenImageCount = totalImageCount - MAX_VISIBLE_PHOTO_COUNT; + // 미리보기 장수는 백엔드가 제어(최대 5장)하고, 전체는 갤러리 모달에서 별도 조회한다. + const visibleImages = home?.images ?? []; + const totalImageCount = home?.totalImageCount ?? visibleImages.length; + const hiddenImageCount = totalImageCount - visibleImages.length; const amenities = home?.amenities ?? []; const location = home?.location; @@ -99,18 +100,27 @@ const HomeTab = ({ profile }: Props) => { }; return ( - - {home?.introduction && } + <> + + {home?.introduction && } {visibleImages.length > 0 && ( 사진 {visibleImages.map((image, index) => { - const isLastVisible = index === MAX_VISIBLE_PHOTO_COUNT - 1; + const isLastVisible = index === visibleImages.length - 1; const showMoreOverlay = isLastVisible && hiddenImageCount > 0; return ( - + + setGallery( + showMoreOverlay ? { mode: 'list', index: 0 } : { mode: 'viewer', index }, + ) + } + > { )} )} - + + {gallery && ( + setGallery(null)} + /> + )} + ); }; From 88387d5b6345cd42a594fc3731f83ec0a5447f96 Mon Sep 17 00:00:00 2001 From: hexdrinker Date: Mon, 22 Jun 2026 21:30:06 +0900 Subject: [PATCH 08/16] =?UTF-8?q?fix(place):=20=EC=A7=80=ED=95=98=EC=B2=A0?= =?UTF-8?q?=20=EB=85=B8=EC=84=A0=20=EB=B1=83=EC=A7=80=20=EB=9D=BC=EB=B2=A8?= =?UTF-8?q?=EC=9D=84=20=EB=85=B8=EC=84=A0=EB=B3=84=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 2글자로 무조건 자르던 로직을 디자인 규칙 기반 매핑으로 교체한다. 숫자 호선은 숫자만, 인천 N호선은 '인천 N', 신분당/경의/공항 등은 노선별 라벨, GTX-A는 원문 유지, 미정의 노선은 자르지 않고 노출한다. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/place/src/utils/format.ts | 42 +++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/place/src/utils/format.ts b/apps/place/src/utils/format.ts index e39735b7..60bb7711 100644 --- a/apps/place/src/utils/format.ts +++ b/apps/place/src/utils/format.ts @@ -54,15 +54,51 @@ export const formatFee = (fee: number) => `${fee.toLocaleString()}원`; export const normalizeWebsiteUrl = (url: string) => /^https?:\/\//i.test(url) ? url : `https://${url}`; -// "2호선" -> "2", "경의중앙선" -> "경의", "분당선" -> "분당" +// 비숫자 노선의 뱃지 라벨 매핑 (디자인 기준). 키는 지역 접두사 제거 후의 노선명. +const SUBWAY_LINE_LABEL_MAP: Record = { + 신분당선: '신분당', + 분당선: '분당', + 수인분당선: '분당', + '경의·중앙선': '경의', + 경의중앙선: '경의', + 경춘선: '경춘', + 공항철도: '공항', + 의정부경전철: '의정', + 용인경전철: '용인', + 용인에버라인: '용인', + 경강선: '경강', + 우이신설선: '우이', + 서해선: '서해', + 김포골드라인: '김포', + 신림선: '신림', +}; + +// 노선 이름 -> 뱃지 라벨. +// "수도권 2호선" -> "2", "인천 1호선" -> "인천 1", "신분당선" -> "신분당", "GTX-A" -> "GTX-A" export const getSubwayLineShortName = (lineName: string) => { - const numberLine = lineName.match(/^(\d+)호선$/); + // "수도권 ", "서울 " 등 지역 접두사 제거 + 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]; } - return lineName.replace(/선$/, '').slice(0, 2); + if (SUBWAY_LINE_LABEL_MAP[name]) { + return SUBWAY_LINE_LABEL_MAP[name]; + } + + if (/^GTX/i.test(name)) { + return name; + } + + // 매핑에 없으면 잘라내지 않고 '선' 접미사만 제거해 그대로 노출한다. + return name.replace(/선$/, ''); }; // 밝은 노선 색상(분당선 등) 위에는 어두운 텍스트를 쓴다 From cb89c2fd96b41b46bec4a83d435652a5792a29d8 Mon Sep 17 00:00:00 2001 From: hexdrinker Date: Mon, 22 Jun 2026 21:38:16 +0900 Subject: [PATCH 09/16] =?UTF-8?q?feat(ui):=20=EC=A7=80=ED=95=98=EC=B2=A0?= =?UTF-8?q?=20=EB=85=B8=EC=84=A0=20=EB=B1=83=EC=A7=80(SubwayLineBadge)=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit place/super-admin에 중복되던 노선 칩을 @boolti/ui로 추출한다. 노선명-> 라벨 매핑(숫자 호선/인천 N/신분당/GTX 등)과 배경 밝기 기반 텍스트 색 보정을 내장하고 size(small/medium)를 지원한다. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SubwayLineBadge/SubwayLineBadge.styles.ts | 35 ++++++++ .../src/components/SubwayLineBadge/index.tsx | 89 +++++++++++++++++++ packages/ui/src/components/index.ts | 2 + 3 files changed, 126 insertions(+) create mode 100644 packages/ui/src/components/SubwayLineBadge/SubwayLineBadge.styles.ts create mode 100644 packages/ui/src/components/SubwayLineBadge/index.tsx 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 589a8873..d1ff0622 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -20,6 +20,7 @@ import RadioButton from './RadioButton'; import StepDialog from './Dialog/StepDialog'; import ShowInfoDetail from './ShowPreview/ShowInfoDetail'; import PreviewMapWithProvider from './PreviewMap/PreviewMapWithProvider'; +import SubwayLineBadge from './SubwayLineBadge'; export { AgreeCheck, @@ -44,4 +45,5 @@ export { StepDialog, ShowInfoDetail, PreviewMapWithProvider, + SubwayLineBadge, }; From eb72beff0c1bb6a35dbe58ea8fae2173c2c44280 Mon Sep 17 00:00:00 2001 From: hexdrinker Date: Mon, 22 Jun 2026 21:38:16 +0900 Subject: [PATCH 10/16] =?UTF-8?q?refactor(super-admin):=20=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EB=B1=83=EC=A7=80=EB=A5=BC=20@boolti/ui=20SubwayLi?= =?UTF-8?q?neBadge=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자체 SubwayLineBadge 컴포넌트를 제거하고 공통 컴포넌트를 사용한다. 밝은 노선의 흰색 고정 텍스트 가독성 문제도 함께 해소된다. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ConcertHallInfoPage/SubwayLineBadge.tsx | 40 ------------------- .../SubwayStationSearchModal.tsx | 9 ++++- .../src/pages/ConcertHallInfoPage/index.tsx | 10 +++-- 3 files changed, 14 insertions(+), 45 deletions(-) delete mode 100644 apps/super-admin/src/pages/ConcertHallInfoPage/SubwayLineBadge.tsx 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..35a31157 100644 --- a/apps/super-admin/src/pages/ConcertHallInfoPage/index.tsx +++ b/apps/super-admin/src/pages/ConcertHallInfoPage/index.tsx @@ -10,7 +10,7 @@ import { SubwayStationSearchItem, SuperAdminSubwayLine, } from '@boolti/api/src/types/superAdminConcertHall'; -import { Button as BooltiButton, useToast } from '@boolti/ui'; +import { Button as BooltiButton, SubwayLineBadge, useToast } 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 +26,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; @@ -403,7 +402,12 @@ const ConcertHallInfoPage = () => { }} > {station.lines.map((line) => ( - + ))} {station.stationName} Date: Mon, 22 Jun 2026 21:42:45 +0900 Subject: [PATCH 11/16] =?UTF-8?q?feat(place):=20=EA=B3=B5=EC=97=B0?= =?UTF-8?q?=EC=9E=A5=20ID=20=EA=B2=BD=EB=A1=9C=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8C=85=20=EB=B0=8F=20=EB=AF=B8=EB=A7=A4=EC=B9=AD=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 루트 경로를 /:concertHallId로 변경하고, 매칭되지 않는 경로는 ErrorPage로 처리한다. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/place/src/App.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/place/src/App.tsx b/apps/place/src/App.tsx index 9287440d..23f28d51 100644 --- a/apps/place/src/App.tsx +++ b/apps/place/src/App.tsx @@ -11,10 +11,14 @@ import ErrorPage from './pages/ErrorPage'; const router = createBrowserRouter([ { - path: '/', + path: '/:concertHallId', element: , errorElement: , }, + { + path: '*', + element: , + }, ]); const App = () => ( From 405511b82ab431f1bd66a997d4c67fbfca81eac5 Mon Sep 17 00:00:00 2001 From: hexdrinker Date: Mon, 22 Jun 2026 21:42:45 +0900 Subject: [PATCH 12/16] =?UTF-8?q?refactor(place):=20=EB=AC=B8=EC=9D=98?= =?UTF-8?q?=EC=B2=98=20=EB=B2=84=ED=8A=BC=203=EC=A2=85=20=EC=83=81?= =?UTF-8?q?=EC=8B=9C=20=EB=85=B8=EC=B6=9C=20=EB=B0=8F=20=EB=B9=88=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=95=88=EB=82=B4=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 웹사이트/전화/메일 버튼을 항상 노출하고, 값이 없는 항목은 비활성 스타일로 표시하며 클릭 시 준비 중 토스트를 띄운다. 웹사이트는 normalizeWebsiteUrl로 보정해 연다. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/HallHead/HallHead.styles.ts | 11 +-- apps/place/src/components/HallHead/index.tsx | 86 +++++++++++++------ 2 files changed, 65 insertions(+), 32 deletions(-) diff --git a/apps/place/src/components/HallHead/HallHead.styles.ts b/apps/place/src/components/HallHead/HallHead.styles.ts index e62ea9a0..f70dcad8 100644 --- a/apps/place/src/components/HallHead/HallHead.styles.ts +++ b/apps/place/src/components/HallHead/HallHead.styles.ts @@ -137,7 +137,8 @@ const ContactButtonArea = styled.div` padding: 0 20px; `; -const ContactButton = styled.button` +// 데이터가 없어도 버튼은 노출하되 색상만 어둡게 처리한다 (클릭 시 토스트 안내) +const ContactButton = styled.button<{ isActive: boolean }>` display: flex; flex: 1; flex-direction: column; @@ -147,13 +148,9 @@ const ContactButton = styled.button` padding: 12px 16px; border-radius: 8px; background-color: ${({ theme }) => theme.palette.mobile.grey.g85}; - color: ${({ theme }) => theme.palette.mobile.grey.g30}; + color: ${({ theme, isActive }) => + isActive ? theme.palette.mobile.grey.g30 : theme.palette.mobile.grey.g70}; cursor: pointer; - - &:disabled { - opacity: 0.4; - cursor: default; - } `; const ContactButtonLabel = styled.span` diff --git a/apps/place/src/components/HallHead/index.tsx b/apps/place/src/components/HallHead/index.tsx index 5195a466..42c27680 100644 --- a/apps/place/src/components/HallHead/index.tsx +++ b/apps/place/src/components/HallHead/index.tsx @@ -1,9 +1,16 @@ import type { ConcertHallProfileResponse } from '@boolti/api'; import { ShareIcon } from '@boolti/icon'; +import { useToast } from '@boolti/ui'; import defaultHallImage from '~/assets/images/default-hall.png'; import { CallIcon, MailIcon, WebsiteIcon } from '~/components/icons'; -import { formatAddress, formatCapacity, getSubwayLineShortName, isLightColor } from '~/utils/format'; +import { + formatAddress, + formatCapacity, + getSubwayLineShortName, + isLightColor, + normalizeWebsiteUrl, +} from '~/utils/format'; import Styled from './HallHead.styles'; @@ -13,6 +20,7 @@ interface Props { } const HallHead = ({ profile, onShare }: Props) => { + const toast = useToast(); const { name, representativeImageUrl, head } = profile; const capacityText = formatCapacity(head?.capacity); @@ -27,6 +35,41 @@ const HallHead = ({ profile, onShare }: Props) => { 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 ( @@ -86,30 +129,23 @@ const HallHead = ({ profile, onShare }: Props) => { )} {hasContact && ( - window.open(contact?.websiteUrl, '_blank', 'noopener,noreferrer')} - > - - 웹사이트 - - (window.location.href = `tel:${contact?.phoneNumber}`)} - > - - 전화 - - (window.location.href = `mailto:${contact?.email}`)} - > - - 메일 - + {contactButtons.map(({ key, label, icon, value, emptyMessage, action }) => ( + { + if (value) { + action(value); + } else { + toast.info(emptyMessage); + } + }} + > + {icon} + {label} + + ))} )} From 55b69afc59ee7f49ffa5275d662157da171ecffb Mon Sep 17 00:00:00 2001 From: hexdrinker Date: Mon, 22 Jun 2026 21:47:29 +0900 Subject: [PATCH 13/16] =?UTF-8?q?feat(super-admin):=20=EA=B3=B5=EC=97=B0?= =?UTF-8?q?=EC=9E=A5=20=EC=A3=BC=EC=86=8C=20=EC=84=A0=ED=83=9D=20=EC=8B=9C?= =?UTF-8?q?=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=A7=80=EC=98=A4=EC=BD=94?= =?UTF-8?q?=EB=94=A9=EC=9C=BC=EB=A1=9C=20=EC=A2=8C=ED=91=9C=20=EC=B7=A8?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주소 찾기로 도로명주소를 선택하면 카카오 주소 검색 API로 위경도를 지오코딩해 공연장 정보 수정 요청(location.latitude/longitude)에 담는다. 기존에는 새 주소를 골라도 좌표가 갱신되지 않고 기존 값만 보존했다. 지오코딩 실패 시에는 기존 좌표를 유지한다. VITE_KAKAO_REST_API_KEY 환경변수가 필요하다. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AddressSearchModal.tsx | 13 ++++-- .../src/pages/ConcertHallInfoPage/index.tsx | 18 +++++++-- apps/super-admin/src/utils/geocode.ts | 40 +++++++++++++++++++ 3 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 apps/super-admin/src/utils/geocode.ts diff --git a/apps/super-admin/src/pages/ConcertHallInfoPage/AddressSearchModal.tsx b/apps/super-admin/src/pages/ConcertHallInfoPage/AddressSearchModal.tsx index 75e2125e..8ba42d1c 100644 --- a/apps/super-admin/src/pages/ConcertHallInfoPage/AddressSearchModal.tsx +++ b/apps/super-admin/src/pages/ConcertHallInfoPage/AddressSearchModal.tsx @@ -1,6 +1,8 @@ import { Modal } from 'antd'; import { useEffect, useRef } from 'react'; +import { Coordinates, geocodeAddress } from '~/utils/geocode'; + const POSTCODE_SCRIPT_URL = '//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js'; interface DaumPostcodeData { @@ -47,8 +49,8 @@ const loadPostcodeScript = () => { interface AddressSearchModalProps { open: boolean; onClose: () => void; - /** 도로명주소 선택 시 호출. 호출 측에서 모달을 닫는다. */ - onComplete: (roadAddress: string) => void; + /** 도로명주소 선택 시 호출. 좌표는 지오코딩 실패 시 null. 호출 측에서 모달을 닫는다. */ + onComplete: (roadAddress: string, coordinates: Coordinates | null) => void; /** 닫힘 애니메이션 완료 후 호출 — 상세주소 포커스는 이 시점에 해야 antd의 포커스 복원에 덮이지 않는다. */ afterClose?: () => void; } @@ -70,8 +72,11 @@ 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; + // 우편번호 서비스는 좌표를 주지 않으므로 선택 주소를 카카오로 지오코딩한다. + const coordinates = await geocodeAddress(roadAddress); + onComplete(roadAddress, coordinates); }, }).embed(containerRef.current); }); diff --git a/apps/super-admin/src/pages/ConcertHallInfoPage/index.tsx b/apps/super-admin/src/pages/ConcertHallInfoPage/index.tsx index 35a31157..1f5bf206 100644 --- a/apps/super-admin/src/pages/ConcertHallInfoPage/index.tsx +++ b/apps/super-admin/src/pages/ConcertHallInfoPage/index.tsx @@ -24,6 +24,7 @@ import parkingIcon from '~/assets/amenities/parking.svg'; import restroomIcon from '~/assets/amenities/restroom.svg'; import secondFloorIcon from '~/assets/amenities/second-floor.svg'; import waitingRoomIcon from '~/assets/amenities/waiting-room.svg'; +import { Coordinates } from '~/utils/geocode'; import AddressSearchModal from './AddressSearchModal'; import ImageUploadBox from './ImageUploadBox'; import SubwayStationSearchModal from './SubwayStationSearchModal'; @@ -121,6 +122,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); @@ -144,6 +147,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 ?? ''); @@ -189,8 +194,13 @@ const ConcertHallInfoPage = () => { // 주소 선택으로 닫힌 경우에만 닫힘 애니메이션 완료 후 상세주소에 포커스한다 (디자인 정책) const shouldFocusDetailAddressRef = useRef(false); - const onCompleteAddress = (roadAddress: string) => { + const onCompleteAddress = (roadAddress: string, coordinates: Coordinates | null) => { setStreetAddress(roadAddress); + // 지오코딩 성공 시에만 좌표를 갱신한다 (실패 시 기존 좌표 유지) + if (coordinates) { + setLatitude(coordinates.latitude); + setLongitude(coordinates.longitude); + } shouldFocusDetailAddressRef.current = true; setIsAddressModalOpen(false); }; @@ -250,9 +260,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, diff --git a/apps/super-admin/src/utils/geocode.ts b/apps/super-admin/src/utils/geocode.ts new file mode 100644 index 00000000..3b5000b4 --- /dev/null +++ b/apps/super-admin/src/utils/geocode.ts @@ -0,0 +1,40 @@ +const KAKAO_REST_API_KEY = import.meta.env.VITE_KAKAO_REST_API_KEY; +const KAKAO_LOCAL_ADDRESS_URL = 'https://dapi.kakao.com/v2/local/search/address.json'; + +export interface Coordinates { + latitude: number; + longitude: number; +} + +// 도로명/지번 주소를 카카오 주소 검색 API로 지오코딩해 좌표를 얻는다. +// 카카오 응답의 x=경도(longitude), y=위도(latitude). 키가 없거나 실패 시 null. +export const geocodeAddress = async (address: string): Promise => { + if (!KAKAO_REST_API_KEY || !address.trim()) { + return null; + } + + try { + const url = new URL(KAKAO_LOCAL_ADDRESS_URL); + url.searchParams.set('query', address); + url.searchParams.set('size', '1'); + + const response = await fetch(url.toString(), { + headers: { Authorization: `KakaoAK ${KAKAO_REST_API_KEY}` }, + }); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as { documents: Array<{ x: string; y: string }> }; + const document = data.documents[0]; + + if (!document) { + return null; + } + + return { latitude: Number(document.y), longitude: Number(document.x) }; + } catch { + return null; + } +}; From 8e7fb172ec7621da86d73510f0cbf799a4b4ca16 Mon Sep 17 00:00:00 2001 From: hexdrinker Date: Mon, 22 Jun 2026 22:05:38 +0900 Subject: [PATCH 14/16] =?UTF-8?q?refactor(place):=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EB=B1=83=EC=A7=80=EB=A5=BC=20@boolti/ui=20SubwayLineBadge?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HallHead의 자체 SubwayLineChip과 format.ts의 getSubwayLineShortName· isLightColor 중복 구현을 제거하고 공통 컴포넌트를 사용한다. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/HallHead/HallHead.styles.ts | 18 ------ apps/place/src/components/HallHead/index.tsx | 20 ++---- apps/place/src/utils/format.ts | 62 ------------------- 3 files changed, 6 insertions(+), 94 deletions(-) diff --git a/apps/place/src/components/HallHead/HallHead.styles.ts b/apps/place/src/components/HallHead/HallHead.styles.ts index f70dcad8..dd816350 100644 --- a/apps/place/src/components/HallHead/HallHead.styles.ts +++ b/apps/place/src/components/HallHead/HallHead.styles.ts @@ -106,23 +106,6 @@ const SubwayStationRow = styled.div` gap: 4px; `; -const SubwayLineChip = styled.span<{ backgroundColor: string; isLight: boolean }>` - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 20px; - height: 20px; - padding: 0 6px; - border-radius: 100px; - background-color: ${({ backgroundColor }) => backgroundColor}; - font-size: 14px; - font-weight: 600; - line-height: 22px; - white-space: nowrap; - color: ${({ theme, isLight }) => - isLight ? theme.palette.mobile.grey.g90 : theme.palette.mobile.grey.w}; -`; - const SubwayStationName = styled.span` font-size: 15px; line-height: 23px; @@ -173,7 +156,6 @@ export default { SummaryValue, SubwayStationList, SubwayStationRow, - SubwayLineChip, SubwayStationName, ContactButtonArea, ContactButton, diff --git a/apps/place/src/components/HallHead/index.tsx b/apps/place/src/components/HallHead/index.tsx index 42c27680..e8976aab 100644 --- a/apps/place/src/components/HallHead/index.tsx +++ b/apps/place/src/components/HallHead/index.tsx @@ -1,16 +1,10 @@ import type { ConcertHallProfileResponse } from '@boolti/api'; import { ShareIcon } from '@boolti/icon'; -import { useToast } from '@boolti/ui'; +import { SubwayLineBadge, useToast } from '@boolti/ui'; import defaultHallImage from '~/assets/images/default-hall.png'; import { CallIcon, MailIcon, WebsiteIcon } from '~/components/icons'; -import { - formatAddress, - formatCapacity, - getSubwayLineShortName, - isLightColor, - normalizeWebsiteUrl, -} from '~/utils/format'; +import { formatAddress, formatCapacity, normalizeWebsiteUrl } from '~/utils/format'; import Styled from './HallHead.styles'; @@ -111,13 +105,11 @@ const HallHead = ({ profile, onShare }: Props) => { {subwayStations.map((station) => ( {station.lines.map((line) => ( - - {getSubwayLineShortName(line.lineName)} - + lineName={line.lineName} + colorHex={line.colorHex} + /> ))} {station.stationName} diff --git a/apps/place/src/utils/format.ts b/apps/place/src/utils/format.ts index 60bb7711..60d584ff 100644 --- a/apps/place/src/utils/format.ts +++ b/apps/place/src/utils/format.ts @@ -53,65 +53,3 @@ export const formatFee = (fee: number) => `${fee.toLocaleString()}원`; export const normalizeWebsiteUrl = (url: string) => /^https?:\/\//i.test(url) ? url : `https://${url}`; - -// 비숫자 노선의 뱃지 라벨 매핑 (디자인 기준). 키는 지역 접두사 제거 후의 노선명. -const SUBWAY_LINE_LABEL_MAP: Record = { - 신분당선: '신분당', - 분당선: '분당', - 수인분당선: '분당', - '경의·중앙선': '경의', - 경의중앙선: '경의', - 경춘선: '경춘', - 공항철도: '공항', - 의정부경전철: '의정', - 용인경전철: '용인', - 용인에버라인: '용인', - 경강선: '경강', - 우이신설선: '우이', - 서해선: '서해', - 김포골드라인: '김포', - 신림선: '신림', -}; - -// 노선 이름 -> 뱃지 라벨. -// "수도권 2호선" -> "2", "인천 1호선" -> "인천 1", "신분당선" -> "신분당", "GTX-A" -> "GTX-A" -export const getSubwayLineShortName = (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 (SUBWAY_LINE_LABEL_MAP[name]) { - return SUBWAY_LINE_LABEL_MAP[name]; - } - - if (/^GTX/i.test(name)) { - return name; - } - - // 매핑에 없으면 잘라내지 않고 '선' 접미사만 제거해 그대로 노출한다. - return name.replace(/선$/, ''); -}; - -// 밝은 노선 색상(분당선 등) 위에는 어두운 텍스트를 쓴다 -export 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; -}; From 0eb441fc1b3313ceb352a322a02b0af8aafe32ee Mon Sep 17 00:00:00 2001 From: hexdrinker Date: Wed, 24 Jun 2026 21:31:00 +0900 Subject: [PATCH 15/16] =?UTF-8?q?feat(ui):=20=EB=84=A4=EC=9D=B4=EB=B2=84?= =?UTF-8?q?=20=EC=A7=80=EB=8F=84=20geocode=20Provider/=ED=9B=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NaverGeocodeProvider(geocoder submodule)와 useNaverGeocode(주소→좌표 Promise 래핑)를 추가한다. @types/navermaps가 없는 앱에서도 동작하도록 필요한 최소 인터페이스로 캐스팅해 사용한다. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/NaverGeocodeProvider/index.tsx | 15 +++++ packages/ui/src/components/index.ts | 2 + packages/ui/src/hooks/index.ts | 13 ++++- packages/ui/src/hooks/useNaverGeocode.ts | 56 +++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/NaverGeocodeProvider/index.tsx create mode 100644 packages/ui/src/hooks/useNaverGeocode.ts 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/index.ts b/packages/ui/src/components/index.ts index d1ff0622..35ef81f3 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -21,6 +21,7 @@ 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, @@ -46,4 +47,5 @@ export { 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; From dc855d168ee26fe161bea46188886686dd282d1b Mon Sep 17 00:00:00 2001 From: hexdrinker Date: Wed, 24 Jun 2026 21:31:00 +0900 Subject: [PATCH 16/16] =?UTF-8?q?refactor(super-admin):=20=EA=B3=B5?= =?UTF-8?q?=EC=97=B0=EC=9E=A5=20=EC=A3=BC=EC=86=8C=20=EC=A2=8C=ED=91=9C=20?= =?UTF-8?q?=EC=B7=A8=ED=95=A9=EC=9D=84=20=EB=84=A4=EC=9D=B4=EB=B2=84=20geo?= =?UTF-8?q?code=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카카오 로컬 API 지오코딩(utils/geocode) 대신 @boolti/ui의 useNaverGeocode를 사용한다. NCP 키가 있으면 NaverGeocodeProvider로 감싸 좌표를 취합하고, 없으면 좌표 없이 동작한다(페이지 안전). VITE_X_NCP_APIGW_API_KEY_ID 환경변수가 필요하다. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AddressSearchModal.tsx | 44 +++++++++++++++---- .../src/pages/ConcertHallInfoPage/index.tsx | 10 +++-- apps/super-admin/src/utils/geocode.ts | 40 ----------------- 3 files changed, 43 insertions(+), 51 deletions(-) delete mode 100644 apps/super-admin/src/utils/geocode.ts diff --git a/apps/super-admin/src/pages/ConcertHallInfoPage/AddressSearchModal.tsx b/apps/super-admin/src/pages/ConcertHallInfoPage/AddressSearchModal.tsx index 8ba42d1c..d21d3e79 100644 --- a/apps/super-admin/src/pages/ConcertHallInfoPage/AddressSearchModal.tsx +++ b/apps/super-admin/src/pages/ConcertHallInfoPage/AddressSearchModal.tsx @@ -1,7 +1,8 @@ +import { NaverGeocodeProvider, useNaverGeocode, type GeocodeCoordinates } from '@boolti/ui'; import { Modal } from 'antd'; import { useEffect, useRef } from 'react'; -import { Coordinates, geocodeAddress } from '~/utils/geocode'; +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'; @@ -38,7 +39,7 @@ const loadPostcodeScript = () => { script.onload = () => resolve(); script.onerror = () => { scriptPromise = null; - reject(new Error('카카오 우편번호 스크립트 로드 실패')); + reject(new Error('우편번호 스크립트 로드 실패')); }; document.head.appendChild(script); }); @@ -46,17 +47,25 @@ const loadPostcodeScript = () => { return scriptPromise; }; +type Geocode = (address: string) => Promise; + interface AddressSearchModalProps { open: boolean; onClose: () => void; /** 도로명주소 선택 시 호출. 좌표는 지오코딩 실패 시 null. 호출 측에서 모달을 닫는다. */ - onComplete: (roadAddress: string, coordinates: Coordinates | null) => void; + 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(() => { @@ -74,8 +83,8 @@ const AddressSearchModal = ({ open, onClose, onComplete, afterClose }: AddressSe height: '100%', oncomplete: async (data) => { const roadAddress = data.roadAddress || data.address; - // 우편번호 서비스는 좌표를 주지 않으므로 선택 주소를 카카오로 지오코딩한다. - const coordinates = await geocodeAddress(roadAddress); + // 우편번호 서비스는 좌표를 주지 않으므로 선택 주소를 네이버 geocode로 변환한다. + const coordinates = geocode ? await geocode(roadAddress) : null; onComplete(roadAddress, coordinates); }, }).embed(containerRef.current); @@ -83,7 +92,7 @@ const AddressSearchModal = ({ open, onClose, onComplete, afterClose }: AddressSe 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/index.tsx b/apps/super-admin/src/pages/ConcertHallInfoPage/index.tsx index 1f5bf206..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, SubwayLineBadge, 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'; @@ -24,7 +29,6 @@ import parkingIcon from '~/assets/amenities/parking.svg'; import restroomIcon from '~/assets/amenities/restroom.svg'; import secondFloorIcon from '~/assets/amenities/second-floor.svg'; import waitingRoomIcon from '~/assets/amenities/waiting-room.svg'; -import { Coordinates } from '~/utils/geocode'; import AddressSearchModal from './AddressSearchModal'; import ImageUploadBox from './ImageUploadBox'; import SubwayStationSearchModal from './SubwayStationSearchModal'; @@ -194,7 +198,7 @@ const ConcertHallInfoPage = () => { // 주소 선택으로 닫힌 경우에만 닫힘 애니메이션 완료 후 상세주소에 포커스한다 (디자인 정책) const shouldFocusDetailAddressRef = useRef(false); - const onCompleteAddress = (roadAddress: string, coordinates: Coordinates | null) => { + const onCompleteAddress = (roadAddress: string, coordinates: GeocodeCoordinates | null) => { setStreetAddress(roadAddress); // 지오코딩 성공 시에만 좌표를 갱신한다 (실패 시 기존 좌표 유지) if (coordinates) { diff --git a/apps/super-admin/src/utils/geocode.ts b/apps/super-admin/src/utils/geocode.ts deleted file mode 100644 index 3b5000b4..00000000 --- a/apps/super-admin/src/utils/geocode.ts +++ /dev/null @@ -1,40 +0,0 @@ -const KAKAO_REST_API_KEY = import.meta.env.VITE_KAKAO_REST_API_KEY; -const KAKAO_LOCAL_ADDRESS_URL = 'https://dapi.kakao.com/v2/local/search/address.json'; - -export interface Coordinates { - latitude: number; - longitude: number; -} - -// 도로명/지번 주소를 카카오 주소 검색 API로 지오코딩해 좌표를 얻는다. -// 카카오 응답의 x=경도(longitude), y=위도(latitude). 키가 없거나 실패 시 null. -export const geocodeAddress = async (address: string): Promise => { - if (!KAKAO_REST_API_KEY || !address.trim()) { - return null; - } - - try { - const url = new URL(KAKAO_LOCAL_ADDRESS_URL); - url.searchParams.set('query', address); - url.searchParams.set('size', '1'); - - const response = await fetch(url.toString(), { - headers: { Authorization: `KakaoAK ${KAKAO_REST_API_KEY}` }, - }); - - if (!response.ok) { - return null; - } - - const data = (await response.json()) as { documents: Array<{ x: string; y: string }> }; - const document = data.documents[0]; - - if (!document) { - return null; - } - - return { latitude: Number(document.y), longitude: Number(document.x) }; - } catch { - return null; - } -};