diff --git a/.pnp.cjs b/.pnp.cjs index ac7f3e3d..12aa6e0c 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -33,6 +33,10 @@ const RAW_RUNTIME_STATE = "name": "@boolti/api",\ "reference": "workspace:packages/api"\ },\ + {\ + "name": "@boolti/bridge",\ + "reference": "workspace:packages/bridge"\ + },\ {\ "name": "@boolti/eslint-config",\ "reference": "workspace:packages/config-eslint"\ @@ -54,6 +58,7 @@ const RAW_RUNTIME_STATE = "ignorePatternData": "(^(?:\\\\.yarn\\\\/sdks(?:\\\\/(?!\\\\.{1,2}(?:\\\\/|$))(?:(?:(?!(?:^|\\\\/)\\\\.{1,2}(?:\\\\/|$)).)*?)|$))$)",\ "fallbackExclusionList": [\ ["@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"]],\ @@ -2832,6 +2837,14 @@ const RAW_RUNTIME_STATE = ["regenerator-runtime", "npm:0.14.1"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:7.26.0", {\ + "packageLocation": "./.yarn/cache/@babel-runtime-npm-7.26.0-9afa3c4ef6-12c01357e0.zip/node_modules/@babel/runtime/",\ + "packageDependencies": [\ + ["@babel/runtime", "npm:7.26.0"],\ + ["regenerator-runtime", "npm:0.14.1"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@babel/template", [\ @@ -2928,6 +2941,7 @@ const RAW_RUNTIME_STATE = "packageLocation": "./packages/api/",\ "packageDependencies": [\ ["@boolti/api", "workspace:packages/api"],\ + ["@boolti/bridge", "workspace:packages/bridge"],\ ["@boolti/eslint-config", "workspace:packages/config-eslint"],\ ["@boolti/typescript-config", "workspace:packages/config-typescript"],\ ["@emotion/react", "virtual:de80dc576383b2386358abc0e9fe49c00e3397fe355a0337462b73ab3115c2e557eb85784ee0fe776394cc11dd020b4e84dbbd75acf72ee6d54415d82d21f5c5#npm:11.11.3"],\ @@ -2936,6 +2950,7 @@ const RAW_RUNTIME_STATE = ["@tanstack/react-query", "virtual:de80dc576383b2386358abc0e9fe49c00e3397fe355a0337462b73ab3115c2e557eb85784ee0fe776394cc11dd020b4e84dbbd75acf72ee6d54415d82d21f5c5#npm:4.36.1"],\ ["@types/react", "npm:18.2.48"],\ ["@types/react-dom", "npm:18.2.18"],\ + ["async-mutex", "npm:0.5.0"],\ ["image-resize", "npm:1.3.2"],\ ["ky", "npm:1.2.0"],\ ["react", "npm:18.2.0"],\ @@ -2945,6 +2960,21 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@boolti/bridge", [\ + ["workspace:packages/bridge", {\ + "packageLocation": "./packages/bridge/",\ + "packageDependencies": [\ + ["@boolti/bridge", "workspace:packages/bridge"],\ + ["@boolti/eslint-config", "workspace:packages/config-eslint"],\ + ["@boolti/typescript-config", "workspace:packages/config-typescript"],\ + ["@types/react", "npm:18.2.48"],\ + ["@types/react-dom", "npm:18.2.18"],\ + ["typescript", "patch:typescript@npm%3A5.3.3#optional!builtin::version=5.3.3&hash=e012d7"],\ + ["uuid", "npm:11.0.3"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@boolti/eslint-config", [\ ["workspace:packages/config-eslint", {\ "packageLocation": "./packages/config-eslint/",\ @@ -8687,6 +8717,7 @@ const RAW_RUNTIME_STATE = "packageDependencies": [\ ["admin", "workspace:apps/admin"],\ ["@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"],\ @@ -8708,7 +8739,6 @@ const RAW_RUNTIME_STATE = ["date-fns", "npm:3.3.1"],\ ["framer-motion", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:11.2.10"],\ ["jotai", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:2.8.3"],\ - ["js-cookie", "npm:3.0.5"],\ ["jwt-decode", "npm:4.0.0"],\ ["lodash.debounce", "npm:4.0.8"],\ ["qrcode.react", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:3.1.0"],\ @@ -8716,6 +8746,7 @@ const RAW_RUNTIME_STATE = ["react-daum-postcode", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:3.1.3"],\ ["react-dom", "virtual:de80dc576383b2386358abc0e9fe49c00e3397fe355a0337462b73ab3115c2e557eb85784ee0fe776394cc11dd020b4e84dbbd75acf72ee6d54415d82d21f5c5#npm:18.2.0"],\ ["react-dropzone", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:14.2.3"],\ + ["react-error-boundary", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:4.1.2"],\ ["react-hook-form", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:7.50.0"],\ ["react-intersection-observer", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:9.8.0"],\ ["react-pdf", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:9.0.0"],\ @@ -8724,6 +8755,7 @@ const RAW_RUNTIME_STATE = ["react-tooltip", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:5.26.3"],\ ["the-new-css-reset", "npm:1.11.2"],\ ["typescript", "patch:typescript@npm%3A5.3.3#optional!builtin::version=5.3.3&hash=e012d7"],\ + ["vconsole", "npm:3.15.1"],\ ["vite", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:5.0.11"]\ ],\ "linkType": "SOFT"\ @@ -9144,6 +9176,16 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["async-mutex", [\ + ["npm:0.5.0", {\ + "packageLocation": "./.yarn/cache/async-mutex-npm-0.5.0-cc288ce63d-9096e6ad6b.zip/node_modules/async-mutex/",\ + "packageDependencies": [\ + ["async-mutex", "npm:0.5.0"],\ + ["tslib", "npm:2.6.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["async-validator", [\ ["npm:4.2.5", {\ "packageLocation": "./.yarn/cache/async-validator-npm-4.2.5-4d61110c66-0ec09ee388.zip/node_modules/async-validator/",\ @@ -10032,6 +10074,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["copy-text-to-clipboard", [\ + ["npm:3.2.0", {\ + "packageLocation": "./.yarn/cache/copy-text-to-clipboard-npm-3.2.0-46c47374b9-d60fdadc59.zip/node_modules/copy-text-to-clipboard/",\ + "packageDependencies": [\ + ["copy-text-to-clipboard", "npm:3.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["copy-to-clipboard", [\ ["npm:3.3.3", {\ "packageLocation": "./.yarn/cache/copy-to-clipboard-npm-3.3.3-6964e6cfad-3ebf5e8ee0.zip/node_modules/copy-to-clipboard/",\ @@ -10042,6 +10093,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["core-js", [\ + ["npm:3.39.0", {\ + "packageLocation": "./.yarn/unplugged/core-js-npm-3.39.0-4c420e59a7/node_modules/core-js/",\ + "packageDependencies": [\ + ["core-js", "npm:3.39.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["core-js-compat", [\ ["npm:3.35.1", {\ "packageLocation": "./.yarn/cache/core-js-compat-npm-3.35.1-1088e0320e-c3b872e1f9.zip/node_modules/core-js-compat/",\ @@ -13199,15 +13259,6 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ - ["js-cookie", [\ - ["npm:3.0.5", {\ - "packageLocation": "./.yarn/cache/js-cookie-npm-3.0.5-8fc8fcc9b4-04a0e56040.zip/node_modules/js-cookie/",\ - "packageDependencies": [\ - ["js-cookie", "npm:3.0.5"]\ - ],\ - "linkType": "HARD"\ - }]\ - ]],\ ["js-tokens", [\ ["npm:4.0.0", {\ "packageLocation": "./.yarn/cache/js-tokens-npm-4.0.0-0ac852e9e2-e248708d37.zip/node_modules/js-tokens/",\ @@ -14102,6 +14153,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["mutation-observer", [\ + ["npm:1.0.3", {\ + "packageLocation": "./.yarn/cache/mutation-observer-npm-1.0.3-fa3b236d74-2f010fdec4.zip/node_modules/mutation-observer/",\ + "packageDependencies": [\ + ["mutation-observer", "npm:1.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["nan", [\ ["npm:2.20.0", {\ "packageLocation": "./.yarn/unplugged/nan-npm-2.20.0-5b5be83e88/node_modules/nan/",\ @@ -16574,6 +16634,29 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["react-error-boundary", [\ + ["npm:4.1.2", {\ + "packageLocation": "./.yarn/cache/react-error-boundary-npm-4.1.2-7591172537-0737e5259b.zip/node_modules/react-error-boundary/",\ + "packageDependencies": [\ + ["react-error-boundary", "npm:4.1.2"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:4.1.2", {\ + "packageLocation": "./.yarn/__virtual__/react-error-boundary-virtual-f9f7566544/0/cache/react-error-boundary-npm-4.1.2-7591172537-0737e5259b.zip/node_modules/react-error-boundary/",\ + "packageDependencies": [\ + ["react-error-boundary", "virtual:9845906954fdbefbb879db24fa8772d77a945dca59f459806df47a5b67245d4bc6502880b373cca7201062c81bea9f13f699f52de2004c037e79dbdbd5d97fb3#npm:4.1.2"],\ + ["@babel/runtime", "npm:7.23.8"],\ + ["@types/react", "npm:18.2.48"],\ + ["react", "npm:18.2.0"]\ + ],\ + "packagePeers": [\ + "@types/react",\ + "react"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["react-fast-compare", [\ ["npm:3.2.2", {\ "packageLocation": "./.yarn/cache/react-fast-compare-npm-3.2.2-45b585a872-0bbd2f3eb4.zip/node_modules/react-fast-compare/",\ @@ -19233,6 +19316,13 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["uuid", [\ + ["npm:11.0.3", {\ + "packageLocation": "./.yarn/cache/uuid-npm-11.0.3-abcb5b16c0-cee762fc76.zip/node_modules/uuid/",\ + "packageDependencies": [\ + ["uuid", "npm:11.0.3"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:9.0.1", {\ "packageLocation": "./.yarn/cache/uuid-npm-9.0.1-39a8442bc6-1607dd32ac.zip/node_modules/uuid/",\ "packageDependencies": [\ @@ -19261,6 +19351,19 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["vconsole", [\ + ["npm:3.15.1", {\ + "packageLocation": "./.yarn/cache/vconsole-npm-3.15.1-329eac4d95-1e62132b71.zip/node_modules/vconsole/",\ + "packageDependencies": [\ + ["vconsole", "npm:3.15.1"],\ + ["@babel/runtime", "npm:7.26.0"],\ + ["copy-text-to-clipboard", "npm:3.2.0"],\ + ["core-js", "npm:3.39.0"],\ + ["mutation-observer", "npm:1.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["vite", [\ ["npm:5.0.11", {\ "packageLocation": "./.yarn/cache/vite-npm-5.0.11-d5457a8b86-74a3ddc6d4.zip/node_modules/vite/",\ diff --git a/README.md b/README.md index 9a331bbd..f8f7f0a7 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,12 @@ ## 패키지 설명 - `apps/admin`: 불티에서 공연을 생성하고 관리하는 사용자들을 위한 서비스입니다. -- `apps/preview`: 공연 예매 페이지를 공유했을 때 랜딩될 페이지입니다. (WIP) -- `apps/super-admin`: 불티 팀원이 사용할 슈퍼 어드민 페이지입니다. (WIP) +- `apps/preview`: 공연 예매 페이지를 공유했을 때 랜딩될 페이지입니다. +- `apps/super-admin`: 불티 팀원이 사용할 슈퍼 어드민 페이지입니다. - `apps/storybook`: 불티에서 공통적으로 사용될 디자인 컴포넌트를 확인할 수 있는 Storybook 페이지입니다. - `packages/api`: 웹 클라이언트에서 사용되는 서버 API 호출 관련 로직이 포함된 패키지입니다. - `packages/config-eslint`: 각 패키지에서 공통적으로 사용될 ESLint 관련 설정이 포함된 패키지입니다. - `packages/config-typescript`: 각 패키지에서 공통적으로 사용될 TypeScript 관련 설정이 포함된 패키지입니다. - `packages/icon`: 공통적으로 사용될 아이콘 컴포넌트가 포함된 패키지입니다. - `packages/ui`: 공통적으로 사용될 디자인 컴포넌트가 포함된 패키지입니다. +- `packages/ui`: 공통적으로 사용될 웹뷰 브릿지가 포함된 패키지입니다. diff --git a/apps/admin/package.json b/apps/admin/package.json index cc43cfd1..dffe1138 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -6,13 +6,14 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "lint": "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": "*", "@dnd-kit/core": "^6.1.0", @@ -27,7 +28,6 @@ "date-fns": "^3.3.1", "framer-motion": "^11.2.10", "jotai": "^2.8.3", - "js-cookie": "^3.0.5", "jwt-decode": "^4.0.0", "lodash.debounce": "^4.0.8", "qrcode.react": "^3.1.0", @@ -35,13 +35,15 @@ "react-daum-postcode": "^3.1.3", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", + "react-error-boundary": "^4.1.2", "react-hook-form": "^7.50.0", "react-intersection-observer": "^9.8.0", "react-pdf": "^9.0.0", "react-router-dom": "^6.21.3", "react-select": "^5.8.0", "react-tooltip": "^5.26.3", - "the-new-css-reset": "^1.11.2" + "the-new-css-reset": "^1.11.2", + "vconsole": "^3.15.1" }, "devDependencies": { "@boolti/eslint-config": "*", diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 2ed23ccf..5f5c045a 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -24,7 +24,6 @@ import { QRPage, OAuthKakaoPage, HomePage, - ShowAddCompletePage, SignUpCompletePage, SitePolicyPage, GiftRegisterPage, @@ -40,9 +39,13 @@ import ShowTicketPage from './pages/ShowTicketPage'; import ShowReservationPage from './pages/ShowReservationPage'; import ShowSettlementPage from './pages/ShowSettlementPage'; import ShowEnterancePage from './pages/ShowEnterancePage'; +import { initVConsole } from './utils/vConsole'; +import { checkIsWebView } from '@boolti/bridge'; setDefaultOptions({ locale: ko }); +initVConsole(); + const publicRoutes = [ { element: ( @@ -97,7 +100,7 @@ const publicRoutes = [ const PrivateRoute = () => { const { isLogin } = useAuthAtom(); - if (!isLogin()) { + if (!isLogin() && !checkIsWebView()) { return ; } @@ -124,12 +127,9 @@ const privateRoutes = [ element: , }, { path: PATH.HOME, element: }, - { path: PATH.SHOW_ADD, element: }, - { path: PATH.SHOW_ADD_TICKET, element: }, - { - path: PATH.SHOW_ADD_COMPLETE, - element: , - }, + { path: PATH.SHOW_ADD, element: }, + { path: PATH.SHOW_ADD_DETAIL, element: }, + { path: PATH.SHOW_ADD_SALES, element: }, { path: '/', element: ( diff --git a/apps/admin/src/atoms/useAuthAtom.ts b/apps/admin/src/atoms/useAuthAtom.ts index dce328cf..f8abd18b 100644 --- a/apps/admin/src/atoms/useAuthAtom.ts +++ b/apps/admin/src/atoms/useAuthAtom.ts @@ -1,7 +1,5 @@ -import Cookies from 'js-cookie'; -import { LOCAL_STORAGE, COOKIES } from '@boolti/api'; +import { LOCAL_STORAGE } from '@boolti/api'; import { atom, useAtom } from 'jotai'; -import { useEffect } from 'react'; const storageMethod = { getItem: (key: string, initialValue: string | null) => { @@ -16,39 +14,11 @@ const storageMethod = { }; const accessTokenAtom = atom( - (() => { - const accessTokenFromCookie = Cookies.get(COOKIES.ACCESS_TOKEN); - const accessTokenFromStorage = storageMethod.getItem(LOCAL_STORAGE.ACCESS_TOKEN, null); - - if (accessTokenFromCookie) { - localStorage.setItem(LOCAL_STORAGE.ACCESS_TOKEN, accessTokenFromCookie); - return accessTokenFromCookie; - } - - if (accessTokenFromStorage) { - return accessTokenFromStorage; - } - - return null; - })(), + storageMethod.getItem(LOCAL_STORAGE.ACCESS_TOKEN, null), ); const refreshTokenAtom = atom( - (() => { - const refreshTokenFromCookie = Cookies.get(COOKIES.ACCESS_TOKEN); - const refreshTokenFromStorage = storageMethod.getItem(LOCAL_STORAGE.REFRESH_TOKEN, null); - - if (refreshTokenFromCookie) { - localStorage.setItem(LOCAL_STORAGE.REFRESH_TOKEN, refreshTokenFromCookie); - return refreshTokenFromCookie; - } - - if (refreshTokenFromStorage) { - return refreshTokenFromStorage; - } - - return null; - })(), + storageMethod.getItem(LOCAL_STORAGE.REFRESH_TOKEN, null), ); export const useAuthAtom = () => { @@ -65,40 +35,12 @@ export const useAuthAtom = () => { const removeToken = () => { storageMethod.removeItem(LOCAL_STORAGE.ACCESS_TOKEN); storageMethod.removeItem(LOCAL_STORAGE.REFRESH_TOKEN); - Cookies.remove(COOKIES.ACCESS_TOKEN); - Cookies.remove(COOKIES.REFRESH_TOKEN); setAccessToken(null); setRefreshToken(null); }; const isLogin = () => !!accessToken && !!refreshToken; - useEffect(() => { - const handler = ({ key, newValue }: StorageEvent) => { - switch (key) { - case LOCAL_STORAGE.ACCESS_TOKEN: { - setAccessToken(newValue); - newValue - ? Cookies.set(COOKIES.ACCESS_TOKEN, newValue) - : Cookies.remove(COOKIES.ACCESS_TOKEN); - return; - } - case LOCAL_STORAGE.REFRESH_TOKEN: { - setRefreshToken(newValue); - newValue - ? Cookies.set(COOKIES.REFRESH_TOKEN, newValue) - : Cookies.remove(COOKIES.REFRESH_TOKEN); - return; - } - } - }; - window.addEventListener('storage', handler); - - return () => { - window.removeEventListener('storage', handler); - }; - }, [setAccessToken, setRefreshToken]); - return { setToken, removeToken, diff --git a/apps/admin/src/components/AuthoritySettingDialogContent/AuthoritySettingDialogContent.styles.ts b/apps/admin/src/components/AuthoritySettingDialogContent/AuthoritySettingDialogContent.styles.ts deleted file mode 100644 index c134d195..00000000 --- a/apps/admin/src/components/AuthoritySettingDialogContent/AuthoritySettingDialogContent.styles.ts +++ /dev/null @@ -1,7 +0,0 @@ -import styled from '@emotion/styled'; - -const Container = styled.div``; - -export default { - Container, -}; diff --git a/apps/admin/src/components/AuthoritySettingDialogContent/index.tsx b/apps/admin/src/components/AuthoritySettingDialogContent/index.tsx deleted file mode 100644 index 50ea1cb2..00000000 --- a/apps/admin/src/components/AuthoritySettingDialogContent/index.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import Styled from './AuthoritySettingDialogContent.styles'; -import { useHostList } from '@boolti/api'; -import HostInputForm from './components/HostInputForm'; -import HostList from './components/HostList'; - -interface AuthoritySettingDialogContentProps { - showId: number; - onClose: () => void; -} - -const AuthoritySettingDialogContent = ({ showId, onClose }: AuthoritySettingDialogContentProps) => { - const { data: hosts } = useHostList(showId); - - return ( - - - - - ); -}; - -export default AuthoritySettingDialogContent; diff --git a/apps/admin/src/components/ErrorBoundary/AuthErrorBoundary.tsx b/apps/admin/src/components/ErrorBoundary/AuthErrorBoundary.tsx index 7dd711e4..f2adf7aa 100644 --- a/apps/admin/src/components/ErrorBoundary/AuthErrorBoundary.tsx +++ b/apps/admin/src/components/ErrorBoundary/AuthErrorBoundary.tsx @@ -1,48 +1,62 @@ -import { BooltiHTTPError, LOCAL_STORAGE } from '@boolti/api'; -import React from 'react'; -import { Navigate } from 'react-router-dom'; +import { + CustomHttpError, + CustomHttpErrorParams, + LOCAL_STORAGE, + checkIsAuthError, + checkIsHttpError, +} from '@boolti/api'; +import { useNavigate } from 'react-router-dom'; import { PATH } from '../../constants/routes'; -interface AuthErrorBoundaryProps { - children?: React.ReactNode; -} - -interface AuthErrorBoundaryState { - status: BooltiHTTPError['status'] | null; -} - -const initialState: AuthErrorBoundaryState = { - status: null, -}; - -class AuthErrorBoundary extends React.Component { - public state: AuthErrorBoundaryState = initialState; - - public static getDerivedStateFromError(error: Error): AuthErrorBoundaryState { - if (error instanceof BooltiHTTPError) { - return { - status: error.status, - }; - } - - return { - status: null, +import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; +import { checkIsWebView, isWebViewBridgeAvailable, requestToken } from '@boolti/bridge'; +import { useEffect } from 'react'; + +const AuthErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => { + const navigate = useNavigate(); + + useEffect(() => { + const reset = async () => { + if (checkIsAuthError(error)) { + if (checkIsWebView() && isWebViewBridgeAvailable()) { + const token = (await requestToken()).data.token; + localStorage.setItem(LOCAL_STORAGE.ACCESS_TOKEN, token); + resetErrorBoundary(); + } else { + navigate(PATH.LOGIN, { replace: true }); + } + } else { + if (checkIsHttpError(error)) { + let customOptions: CustomHttpErrorParams['customOptions']; + try { + const body = await error.response.json(); + customOptions = { + errorTraceId: body.errorTraceId, + type: body.type, + detail: body.detail, + }; + } catch { + throw new CustomHttpError({ + request: error.request, + response: error.response, + options: error.options, + customOptions, + }); + } + } + navigate(PATH.HOME, { replace: true }); + } }; - } - - public render() { - if (this.state.status !== null) { - this.setState(initialState); - window.localStorage.removeItem(LOCAL_STORAGE.ACCESS_TOKEN); - window.localStorage.removeItem(LOCAL_STORAGE.REFRESH_TOKEN); + reset(); + }, []); - return ; - } + return null; +}; - return this.props.children; - } -} +const AuthErrorBoundary = ({ children }: React.PropsWithChildren) => { + return {children}; +}; export default AuthErrorBoundary; diff --git a/apps/admin/src/components/ErrorBoundary/GlobalErrorBoundary.tsx b/apps/admin/src/components/ErrorBoundary/GlobalErrorBoundary.tsx index 9bce1ccc..f19ae8b3 100644 --- a/apps/admin/src/components/ErrorBoundary/GlobalErrorBoundary.tsx +++ b/apps/admin/src/components/ErrorBoundary/GlobalErrorBoundary.tsx @@ -1,4 +1,4 @@ -import { isBooltiHTTPError } from '@boolti/api/src/BooltiHTTPError'; +import { checkIsCustomHttpError } from '@boolti/api'; import { useEffect } from 'react'; import { Navigate, useRouteError } from 'react-router-dom'; import { PATH } from '~/constants/routes'; @@ -7,10 +7,10 @@ const GlobalErrorBoundary = () => { const error = useRouteError(); useEffect(() => { - if (error instanceof Error && isBooltiHTTPError(error)) { - const errorMessage = '[BooltiHTTPError] errorTraceId:' + error.errorTraceId + '\n'; - '[BooltiHTTPError] type' + error.type + '\n'; - '[BooltiHTTPError] detail' + error.detail; + if (error instanceof Error && checkIsCustomHttpError(error)) { + const errorMessage = '[CustomHttpError] errorTraceId:' + error.errorTraceId + '\n'; + '[CustomHttpError] type' + error.type + '\n'; + '[CustomHttpError] detail' + error.detail; console.error(errorMessage); return; } diff --git a/apps/admin/src/components/EventPopupContent/EventPopupContent.styles.ts b/apps/admin/src/components/EventPopupContent/EventPopupContent.styles.ts new file mode 100644 index 00000000..a2ca7c10 --- /dev/null +++ b/apps/admin/src/components/EventPopupContent/EventPopupContent.styles.ts @@ -0,0 +1,54 @@ +import styled from '@emotion/styled'; +import { mq_lg } from '@boolti/ui'; + +interface PopupImageProps { + hasDetail: boolean; +} + +const HomePopupContent = styled.div` + margin: 0 -24px; + ${mq_lg} { + margin: 0; + width: 420px; + } +`; + +const PopupImage = styled.img` + border-top-left-radius: 8px; + border-top-right-radius: 8px; + cursor: ${({ hasDetail }) => (hasDetail ? 'pointer' : 'default')}; + + ${mq_lg} { + width: 420px; + height: 486px; + } +`; + +const PopupFooter = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const CheckLabel = styled.label` + display: flex; + align-items: center; + gap: 8px; + padding: 16px 20px; + color: ${({ theme }) => theme.palette.grey.g60}; + cursor: pointer; +`; + +const CloseButton = styled.button` + padding: 16px 20px; + color: ${({ theme }) => theme.palette.grey.g100}; + cursor: pointer; +`; + +export default { + HomePopupContent, + CheckLabel, + PopupImage, + PopupFooter, + CloseButton, +}; diff --git a/apps/admin/src/components/EventPopupContent/index.tsx b/apps/admin/src/components/EventPopupContent/index.tsx new file mode 100644 index 00000000..a581800a --- /dev/null +++ b/apps/admin/src/components/EventPopupContent/index.tsx @@ -0,0 +1,55 @@ +import { Checkbox } from '@boolti/ui'; +import Styled from './EventPopupContent.styles'; +import { useState } from 'react'; +import { useBodyScrollLock } from '~/hooks/useBodyScrollLock'; +import { useNavigate } from 'react-router-dom'; +import useCookie from '~/hooks/useCookie'; + +interface HomePopupContentProps { + id: number; + imagePath: string; + detailPath: string | null; + onClose: () => void; +} +const EventPopupContent = ({ id, imagePath, detailPath, onClose }: HomePopupContentProps) => { + const [checked, setChecked] = useState(false); + const navigate = useNavigate(); + useBodyScrollLock(); + const { setCookie } = useCookie(); + + const onChange = () => { + setChecked((checked) => !checked); + }; + + const closeDialog = () => { + if (checked) { + const midnight = new Date(); + midnight.setHours(24, 0, 0, 0); + setCookie('popup', `${id}`, { expires: midnight.toUTCString() }); + } + onClose(); + }; + + const onClickImage = () => { + if (!detailPath) { + return; + } + navigate(detailPath); + onClose(); + }; + + return ( + + + + + + 오늘 하루 그만보기 + + 닫기 + + + ); +}; + +export default EventPopupContent; diff --git a/apps/admin/src/components/LinkFormDialogContent/LinkFormDialogContent.styles.ts b/apps/admin/src/components/LinkFormDialogContent/LinkFormDialogContent.styles.ts index e0234826..50518249 100644 --- a/apps/admin/src/components/LinkFormDialogContent/LinkFormDialogContent.styles.ts +++ b/apps/admin/src/components/LinkFormDialogContent/LinkFormDialogContent.styles.ts @@ -54,7 +54,7 @@ const Label = styled.label` &::after { content: ${({ required }) => (required ? "'*'" : 'none')}; - color: ${({ theme }) => theme.palette.status.error}; + color: ${({ theme }) => theme.palette.status.error1}; ${({ theme }) => theme.typo.b1}; line-height: 22px; margin-left: 2px; diff --git a/apps/admin/src/components/NoticePopupContent/NoticePopupContent.styles.ts b/apps/admin/src/components/NoticePopupContent/NoticePopupContent.styles.ts new file mode 100644 index 00000000..ac2460bd --- /dev/null +++ b/apps/admin/src/components/NoticePopupContent/NoticePopupContent.styles.ts @@ -0,0 +1,73 @@ +import styled from '@emotion/styled'; +import { Button, mq_lg } from '@boolti/ui'; + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + + ${mq_lg} { + width: 410px; + padding: 0 20px 20px 20px; + } +`; + +const Header = styled.div` + width: 100%; + display: flex; + justify-content: flex-end; + align-items: center; + padding: 12px 0; +`; + +const CloseButton = styled.button` + width: 24px; + height: 24px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + color: ${({ theme }) => theme.palette.grey.g70}; + + svg { + width: 24px; + height: 24px; + } +`; + +const Title = styled.h1` + font-size: 18px; + color: ${({ theme }) => theme.palette.grey.g70}; + margin-bottom: 24px; +`; + +const Emphasized = styled.div` + width: 100%; + border-radius: 4px; + padding: 16px; + background-color: ${({ theme }) => theme.palette.grey.g00}; + color: ${({ theme }) => theme.palette.red.main}; + font-size: 15px; + text-align: center; + margin-bottom: 16px; +`; + +const Description = styled.p` + color: ${({ theme }) => theme.palette.grey.g60}; + font-size: 14px; + margin-bottom: 28px; +`; + +const ConfirmButton = styled(Button)` + width: 100%; +`; + +export default { + Container, + Header, + CloseButton, + Title, + Emphasized, + Description, + ConfirmButton, +}; diff --git a/apps/admin/src/components/NoticePopupContent/index.tsx b/apps/admin/src/components/NoticePopupContent/index.tsx new file mode 100644 index 00000000..5a3431d6 --- /dev/null +++ b/apps/admin/src/components/NoticePopupContent/index.tsx @@ -0,0 +1,50 @@ +import { CloseIcon } from '@boolti/icon'; +import Styled from './NoticePopupContent.styles'; +import { useMemo } from 'react'; + +interface NoticePopupContentProps { + title: string; + description: string; + onClose: () => void; +} + +const NoticePopupContent = ({ title, description, onClose }: NoticePopupContentProps) => { + const dividedDescription = useMemo(() => { + const regex = /`([^`]*)`|([^`]+)/g; + let match; + const result: { emphasized: string[]; normal: string[] } = { + emphasized: [], + normal: [], + }; + + while ((match = regex.exec(description)) !== null) { + if (match[1] !== undefined) { + result.emphasized.push(match[1]); + } else if (match[2] !== undefined) { + result.normal.push(match[2].trim()); + } + } + + return result; + }, [description]); + + return ( + + + + + + + {title} + {dividedDescription.emphasized.length ? ( + {dividedDescription.emphasized} + ) : null} + {dividedDescription.normal} + + 확인 + + + ); +}; + +export default NoticePopupContent; diff --git a/apps/admin/src/components/SettingDialogContent/SettingDialogContent.styles.ts b/apps/admin/src/components/SettingDialogContent/SettingDialogContent.styles.ts index 6fe6a179..09edae28 100644 --- a/apps/admin/src/components/SettingDialogContent/SettingDialogContent.styles.ts +++ b/apps/admin/src/components/SettingDialogContent/SettingDialogContent.styles.ts @@ -211,7 +211,7 @@ const Label = styled.label` &::after { content: ${({ required }) => (required ? "'*'" : 'none')}; - color: ${({ theme }) => theme.palette.status.error}; + color: ${({ theme }) => theme.palette.status.error1}; ${({ theme }) => theme.typo.b1}; line-height: 22px; margin-left: 2px; @@ -319,7 +319,7 @@ const TextAreaWrapper = styled.div` const TextAreaBox = styled.div` border: 1px solid ${({ theme, hasError }) => - hasError ? `${theme.palette.status.error} !important` : theme.palette.grey.g20}; + hasError ? `${theme.palette.status.error1} !important` : theme.palette.grey.g20}; border-radius: 4px; background-color: ${({ theme }) => theme.palette.grey.w}; position: absolute; diff --git a/apps/admin/src/components/SettingDialogContent/index.tsx b/apps/admin/src/components/SettingDialogContent/index.tsx index 12b1c0ff..a2fd021a 100644 --- a/apps/admin/src/components/SettingDialogContent/index.tsx +++ b/apps/admin/src/components/SettingDialogContent/index.tsx @@ -394,7 +394,7 @@ const SettingDialogContent = ({ onDeleteAccount }: SettingDialogContentProps) => diff --git a/apps/admin/src/components/ShowDetailLayout/ShowDetailLayout.styles.ts b/apps/admin/src/components/ShowDetailLayout/ShowDetailLayout.styles.ts index 345f97c7..06e066b9 100644 --- a/apps/admin/src/components/ShowDetailLayout/ShowDetailLayout.styles.ts +++ b/apps/admin/src/components/ShowDetailLayout/ShowDetailLayout.styles.ts @@ -85,6 +85,24 @@ const ShowName = styled.h2` } `; +const ShowSettingButtonContainer = styled.div` + margin-right: 20px; + + ${mq_lg} { + margin-right: 0; + } +`; + +const HostListContainer = styled.div` + margin: 16px 0; + height: calc(100dvh - 148px); + + ${mq_lg} { + margin: 0; + height: auto; + } +`; + const TabContainer = styled.div` padding: 0 20px; white-space: nowrap; @@ -177,6 +195,8 @@ export default { HeaderContent, ShowNameWrapper, ShowName, + ShowSettingButtonContainer, + HostListContainer, TabContainer, Tab, TabItem, diff --git a/apps/admin/src/components/ShowDetailLayout/index.tsx b/apps/admin/src/components/ShowDetailLayout/index.tsx index f3b99cb5..699fe7b2 100644 --- a/apps/admin/src/components/ShowDetailLayout/index.tsx +++ b/apps/admin/src/components/ShowDetailLayout/index.tsx @@ -6,7 +6,7 @@ import { } from '@boolti/api'; import { ArrowLeftIcon } from '@boolti/icon'; import { Setting } from '@boolti/icon/src/components/Setting.tsx'; -import { palette, useDialog } from '@boolti/ui'; +import { Button, palette, useStepDialog } from '@boolti/ui'; import { useTheme } from '@emotion/react'; import { useInView } from 'react-intersection-observer'; import { useMatch, useNavigate, useParams } from 'react-router-dom'; @@ -17,12 +17,14 @@ import { HREF, PATH } from '~/constants/routes'; import Header from '../Header/index.tsx'; import Layout from '../Layout/index.tsx'; import Styled from './ShowDetailLayout.styles.ts'; -import AuthoritySettingDialogContent from '../AuthoritySettingDialogContent'; +import ShowSettingDialogContent from '../ShowSettingDialogContent/index.tsx'; import { HostListItem, HostType } from '@boolti/api/src/types/host.ts'; import { atom, useAtom, useAtomValue } from 'jotai'; import { useEffect } from 'react'; -import { useDeviceWidth } from '~/hooks/useDeviceWidth.ts'; import ProfileDropdown from '../ProfileDropdown/index.tsx'; +import HostList from '../ShowSettingDialogContent/components/HostList/index.tsx'; +import { useDeviceWidth } from '~/hooks/useDeviceWidth.ts'; +import ShowDeleteDialogContent from '../ShowDeleteDialogContent/index.tsx'; const settlementTooltipText = { SEND: '내역서 확인 및 정산 요청을 진행해 주세요', @@ -60,8 +62,8 @@ const toTargets = { } as const; const label = { - INFO: '공연 기본 정보', - TICKET: '티켓 관리', + INFO: '공연 정보', + TICKET: '판매 정보', RESERVATION: '결제 관리', ENTRANCE: '방문자 관리', SETTLEMENT: '정산 관리', @@ -83,6 +85,7 @@ const tooltipStyle = { const TabItem = ({ type }: TabItemProps) => { const params = useParams<{ showId: string }>(); const showId = Number(params!.showId); + const [myHostInfo] = useAtom(myHostInfoAtom); const { data: settlementInfo } = useShowSettlementInfo(showId); const { data: lastSettlementEvent } = useShowLastSettlementEvent(showId); @@ -97,6 +100,8 @@ const TabItem = ({ type }: TabItemProps) => { settlementInfo?.settlementBankAccountPhotoFile === null; const isTooltipVisible = (() => { + if (myHostInfo?.type !== HostType.MAIN) return false; + if ( lastSettlementEvent?.settlementEventType === 'REQUEST' || lastSettlementEvent?.settlementEventType === 'DONE' || @@ -154,17 +159,17 @@ const ShowDetailLayout = ({ children }: ShowDetailLayoutProps) => { initialInView: true, }); const theme = useTheme(); + const deviceWidth = useDeviceWidth(); + const isMobile = deviceWidth < parseInt(theme.breakpoint.mobile, 10); + const navigate = useNavigate(); const params = useParams<{ showId: string }>(); - const authoritySettingDialog = useDialog(); + const showSettingDialog = useStepDialog<'main' | 'hostList' | 'deleteShow'>(); const showId = Number(params!.showId); const [, setMyHostInfo] = useAtom(myHostInfoAtom); - const deviceWidth = useDeviceWidth(); - const isMobile = deviceWidth < parseInt(theme.breakpoint.mobile, 10); - const { data: show } = useShowDetail(showId); const { data: myHostInfoData } = useMyHostInfo(showId); @@ -212,26 +217,53 @@ const ShowDetailLayout = ({ children }: ShowDetailLayoutProps) => { {show?.name} {myHostInfoData?.type !== HostType.SUPPORTER && ( - { - authoritySettingDialog.open({ - title: '권한 설정', - width: '600px', - content: ( - - ), - }); - }} - > - - {!isMobile && 권한 설정} - + + + )} diff --git a/apps/admin/src/components/ShowDetailUnauthorized/index.tsx b/apps/admin/src/components/ShowDetailUnauthorized/index.tsx index f015e607..58302465 100644 --- a/apps/admin/src/components/ShowDetailUnauthorized/index.tsx +++ b/apps/admin/src/components/ShowDetailUnauthorized/index.tsx @@ -2,30 +2,40 @@ import { HostType } from '@boolti/api/src/types/host'; import Styled from './ShowDetailUnauthorized.styles'; import { BooltiGreyIcon } from '@boolti/icon/src/components/BooltiGreyIcon'; +type ShowDetailPageName = '공연 정보' | '판매 정보' | '결제 관리' | '방문자 관리' | '정산 관리'; + +export const PAGE_PERMISSION: Record = { + '공연 정보': [HostType.MAIN, HostType.MANAGER], + '판매 정보': [HostType.MAIN, HostType.MANAGER], + '결제 관리': [HostType.MAIN, HostType.MANAGER], + '방문자 관리': [HostType.MAIN, HostType.MANAGER, HostType.SUPPORTER], + '정산 관리': [HostType.MAIN], +}; + +const HOST_TYPE_NAME: Record = { + [HostType.MAIN]: '주최자', + [HostType.MANAGER]: '관리자', + [HostType.SUPPORTER]: '도우미', +}; + interface ShowDetailUnauthorizedProps { - pageName: string; + pageName: ShowDetailPageName; name: string; type: HostType; } const ShowDetailUnauthorized = ({ pageName, name, type }: ShowDetailUnauthorizedProps) => { - const isSupporter = type === HostType.SUPPORTER; - const hostTypeName = isSupporter ? '도우미' : '관리자'; - const descriptionText = isSupporter - ? '주최자, 관리자만 접근 가능합니다.' - : '주최자만 접근 가능합니다.'; + const descriptionText = `${PAGE_PERMISSION[pageName].map((permission) => HOST_TYPE_NAME[permission]).join(', ')}만 접근 가능합니다.`; + return ( - - {pageName} 페이지에 대한{'\n'}접근 권한이 없어요 - + 페이지에 접근 권한이 없어요 - 현재 {name} 님의 권한은 {hostTypeName} 입니다. + 현재 {name} 님의 권한은 {HOST_TYPE_NAME[type]} 입니다. {'\n'} {pageName}는 {descriptionText} - {'\n'} - {isSupporter && '이 페이지를 보시려면 주최자에게 권한을 요청해 주세요.'} + {'\n'}이 페이지를 보시려면 주최자에게 권한을 요청해 주세요. ); diff --git a/apps/admin/src/components/ShowInfoFormContent/ShowBasicInfoFormContent.tsx b/apps/admin/src/components/ShowInfoFormContent/ShowBasicInfoFormContent.tsx index 03f5653d..2b2e8825 100644 --- a/apps/admin/src/components/ShowInfoFormContent/ShowBasicInfoFormContent.tsx +++ b/apps/admin/src/components/ShowInfoFormContent/ShowBasicInfoFormContent.tsx @@ -8,14 +8,14 @@ import { Controller, UseFormReturn } from 'react-hook-form'; import DaumPostcode from 'react-daum-postcode'; import Styled from './ShowInfoFormContent.styles'; -import { ShowInfoFormInputs } from './types'; +import { ShowBasicInfoFormInputs } from './types'; import { useBodyScrollLock } from '~/hooks/useBodyScrollLock'; const MAX_IMAGE_COUNT = 3; -const MIN_DATE = format(add(new Date(), { days: 1 }), 'yyyy-MM-dd') +const MIN_DATE = format(add(new Date(), { days: 1 }), 'yyyy-MM-dd'); interface ShowBasicInfoFormContentProps { - form: UseFormReturn; + form: UseFormReturn; imageFiles: ImageFile[]; disabled?: boolean; onDropImage: (acceptedFiles: File[]) => void; @@ -32,7 +32,13 @@ const ShowBasicInfoFormContent = ({ const { open, close, isOpen } = useDialog(); const detailAddressInputRef = useRef(null); - const { control, setValue, formState: { errors }, setError, clearErrors } = form; + const { + control, + setValue, + formState: { errors }, + setError, + clearErrors, + } = form; const { getRootProps, getInputProps } = useDropzone({ accept: { @@ -76,8 +82,11 @@ const ShowBasicInfoFormContent = ({ 공연 포스터 - 원하시는 노출 순서대로 이미지를 업로드해주세요.  - 표준 종이규격(A, B)의 이미지를 권장합니다.
+ + 원하시는 노출 순서대로 이미지를 업로드해주세요.  + + 표준 종이규격(A, B)의 이미지를 권장합니다. +
(최소 1장, 최대 {MAX_IMAGE_COUNT}장 업로드 가능 / jpg, png 형식)
@@ -142,7 +151,7 @@ const ShowBasicInfoFormContent = ({ if (!value) { setError('name', { type: 'required', message: '필수 입력사항입니다.' }); - return + return; } }} value={value ?? ''} @@ -174,7 +183,7 @@ const ShowBasicInfoFormContent = ({ if (new Date(event.target.value) < new Date(MIN_DATE)) { setError('date', { type: 'min', message: '오늘 이후부터 선택 가능합니다.' }); - return + return; } }} onBlur={() => { @@ -182,7 +191,7 @@ const ShowBasicInfoFormContent = ({ if (!value) { setError('date', { type: 'required', message: '필수 입력사항입니다.' }); - return + return; } }} placeholder={value} @@ -219,8 +228,11 @@ const ShowBasicInfoFormContent = ({ onBlur(); if (!value) { - setError('startTime', { type: 'required', message: '필수 입력사항입니다.' }); - return + setError('startTime', { + type: 'required', + message: '필수 입력사항입니다.', + }); + return; } }} value={value} @@ -255,8 +267,11 @@ const ShowBasicInfoFormContent = ({ onBlur(); if (!value) { - setError('runningTime', { type: 'required', message: '필수 입력사항입니다.' }); - return + setError('runningTime', { + type: 'required', + message: '필수 입력사항입니다.', + }); + return; } }} value={value ?? ''} @@ -294,7 +309,7 @@ const ShowBasicInfoFormContent = ({ if (!value) { setError('placeName', { type: 'required', message: '필수 입력사항입니다.' }); - return + return; } }} value={value ?? ''} @@ -356,8 +371,11 @@ const ShowBasicInfoFormContent = ({ onBlur(); if (!value) { - setError('placeDetailAddress', { type: 'required', message: '필수 입력사항입니다.' }); - return + setError('placeDetailAddress', { + type: 'required', + message: '필수 입력사항입니다.', + }); + return; } }} value={value ?? ''} diff --git a/apps/admin/src/components/ShowInfoFormContent/ShowCastInfoFormContent.tsx b/apps/admin/src/components/ShowInfoFormContent/ShowCastInfoFormContent.tsx index eeae5a87..2d86e8e0 100644 --- a/apps/admin/src/components/ShowInfoFormContent/ShowCastInfoFormContent.tsx +++ b/apps/admin/src/components/ShowInfoFormContent/ShowCastInfoFormContent.tsx @@ -5,58 +5,150 @@ import { PlusIcon } from '@boolti/icon'; import ShowCastInfoFormDialogContent, { TempShowCastInfoFormInput, } from '../ShowCastInfoFormDialogContent'; +import { + DndContext, + DragOverEvent, + KeyboardSensor, + MouseSensor, + TouchSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import ShowCastInfo from '~/components/ShowCastInfo'; +import { useCallback, useRef, useState } from 'react'; +import { useEffect } from 'react'; -interface Props { - onSave: (value: TempShowCastInfoFormInput) => Promise; +interface ShowCastInfoFormContentProps { + initialCastTeamList?: TempShowCastInfoFormInput[]; + onChange: (value: TempShowCastInfoFormInput[]) => void; } -const ShowCastInfoFormContent = ({ onSave }: Props) => { +const ShowCastInfoFormContent = ({ + initialCastTeamList, + onChange, +}: ShowCastInfoFormContentProps) => { const dialog = useDialog(); - const onClick = () => { + const [castTeamList, setCastTeamList] = useState( + initialCastTeamList ?? [], + ); + const prevCastTeamList = useRef(JSON.stringify(castTeamList)); + + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint: { + distance: 10, + }, + }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 0, + tolerance: 5, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const castTeamDragEndHandler = useCallback((event: DragOverEvent) => { + const { active, over } = event; + + if (active && over && over.id !== active.id) { + setCastTeamList((prev) => { + const oldIndex = prev.findIndex(({ id }) => id === active.id); + const newIndex = prev.findIndex(({ id }) => id === over.id); + + return arrayMove(prev, oldIndex, newIndex); + }); + } + }, []); + + const castAddButtonClickHandler = () => { dialog.open({ isAuto: true, title: '출연진 정보 등록', content: ( { - try { - await onSave(value); - dialog.close(); - } catch { - return new Promise((_, reject) => reject('저장 중 오류가 발생하였습니다.')); - } + onSave={(castInfo) => { + setCastTeamList((prev) => [...prev, castInfo]); + dialog.close(); }} /> ), }); }; + useEffect(() => { + const stringifiedCastTeamList = JSON.stringify(castTeamList); + + if (prevCastTeamList.current !== stringifiedCastTeamList) { + prevCastTeamList.current = stringifiedCastTeamList; + onChange?.(castTeamList); + } + }, [castTeamList, onChange]); + return ( - - + + 출연진 정보 - + 등록하기 - - + + 출연진 정보를 팀 단위로 등록해 주세요. - + } - onClick={onClick} + onClick={castAddButtonClickHandler} > 등록하기 + + info.id)} + strategy={verticalListSortingStrategy} + > + {castTeamList.map((info) => ( + { + setCastTeamList((prev) => + prev.map((item) => (item.id === info.id ? showCastInfoFormInput : item)), + ); + return new Promise((resolve) => resolve()); + }} + onDelete={() => { + setCastTeamList((prev) => prev.filter((item) => item.id !== info.id)); + return new Promise((resolve) => resolve()); + }} + /> + ))} + + ); }; diff --git a/apps/admin/src/components/ShowInfoFormContent/ShowDetailInfoFormContent.tsx b/apps/admin/src/components/ShowInfoFormContent/ShowDetailInfoFormContent.tsx index 584a9c6d..499c2caa 100644 --- a/apps/admin/src/components/ShowInfoFormContent/ShowDetailInfoFormContent.tsx +++ b/apps/admin/src/components/ShowInfoFormContent/ShowDetailInfoFormContent.tsx @@ -2,17 +2,22 @@ import { TextField } from '@boolti/ui'; import { Controller, UseFormReturn } from 'react-hook-form'; import Styled from './ShowInfoFormContent.styles'; -import { ShowInfoFormInputs } from './types'; +import { ShowDetailInfoFormInputs } from './types'; interface ShowDetailInfoFormContentProps { - form: UseFormReturn; + form: UseFormReturn; disabled?: boolean; } -const phoneNumberRegExp = /^\d{3}-\d{3,4}-\d{4}$/ +const phoneNumberRegExp = /^\d{3}-\d{3,4}-\d{4}$/; const ShowDetailInfoFormContent = ({ form, disabled }: ShowDetailInfoFormContentProps) => { - const { control, formState: { errors }, setError, clearErrors } = form; + const { + control, + formState: { errors }, + setError, + clearErrors, + } = form; return ( @@ -63,28 +68,28 @@ const ShowDetailInfoFormContent = ({ form, disabled }: ShowDetailInfoFormContent - 대표자명 + 주최자명 { - if (!fieldValue) return '필수 입력사항입니다.' + if (!fieldValue) return '필수 입력사항입니다.'; - return true - } + return true; + }, }} render={({ field: { onChange, onBlur, value } }) => ( { onChange(event); - clearErrors('hostName') + clearErrors('hostName'); }} onBlur={() => { onBlur(); @@ -101,7 +106,7 @@ const ShowDetailInfoFormContent = ({ form, disabled }: ShowDetailInfoFormContent - 대표자 연락처 + 주최자 연락처 { - if (event.target.value.length > 13) return + if (event.target.value.length > 13) return; event.target.value = event.target.value .replace(/[^0-9]/g, '') - .replace(/^(\d{0,3})(\d{0,4})(\d{0,4})$/g, '$1-$2-$3').replace(/(-{1,2})$/g, '') + .replace(/^(\d{0,3})(\d{0,4})(\d{0,4})$/g, '$1-$2-$3') + .replace(/(-{1,2})$/g, ''); onChange(event); - clearErrors('hostPhoneNumber') + clearErrors('hostPhoneNumber'); }} onBlur={() => { onBlur(); if (!value) { - setError('hostPhoneNumber', { type: 'required', message: '필수 입력사항입니다.' }); - return + setError('hostPhoneNumber', { + type: 'required', + message: '필수 입력사항입니다.', + }); + return; } if (!phoneNumberRegExp.test(value)) { - setError('hostPhoneNumber', { type: 'pattern', message: '유효한 전화번호 형식이 아닙니다.' }); - return + setError('hostPhoneNumber', { + type: 'pattern', + message: '유효한 전화번호 형식이 아닙니다.', + }); + return; } }} value={value ?? ''} diff --git a/apps/admin/src/components/ShowInfoFormContent/ShowInfoFormContent.styles.ts b/apps/admin/src/components/ShowInfoFormContent/ShowInfoFormContent.styles.ts index d50ac25a..c5f9477c 100644 --- a/apps/admin/src/components/ShowInfoFormContent/ShowInfoFormContent.styles.ts +++ b/apps/admin/src/components/ShowInfoFormContent/ShowInfoFormContent.styles.ts @@ -89,21 +89,22 @@ const ShowInfoFormResponsiveRowColumn = styled.div` &:last-of-type { margin-bottom: 0; } -` +`; const ShowInfoFormContent = styled.div` flex: 1; `; const ShowInfoFormLabel = styled.label` - display: block; + display: flex; + align-items: flex-end; ${({ theme }) => theme.typo.b3}; color: ${({ theme }) => theme.palette.grey.g90}; &::after { content: '*'; ${({ theme }) => theme.typo.b3}; - color: ${({ theme }) => theme.palette.status.error}; + color: ${({ theme }) => theme.palette.status.error1}; display: ${({ required }) => (required ? 'inline' : 'none')}; margin-left: 2px; } @@ -304,13 +305,14 @@ const TextArea = styled.textarea` padding: 12px; border: 1px solid ${({ theme, hasError }) => - hasError ? `${theme.palette.status.error} !important` : theme.palette.grey.g20}; + hasError ? `${theme.palette.status.error1} !important` : theme.palette.grey.g20}; border-radius: 4px; background-color: ${({ theme }) => theme.palette.grey.w}; color: ${({ theme }) => theme.palette.grey.g90}; ${({ theme }) => theme.typo.b3}; &:placeholder-shown { + ${({ theme }) => theme.typo.b3}; border: 1px solid ${({ theme }) => theme.palette.grey.g20}; color: ${({ theme }) => theme.palette.grey.g30}; } @@ -333,7 +335,7 @@ const TextArea = styled.textarea` const TextAreaErrorMessage = styled.p` margin-top: 4px; ${({ theme }) => theme.typo.b1}; - color: ${({ theme }) => theme.palette.status.error}; + color: ${({ theme }) => theme.palette.status.error1}; `; const TicketGroup = styled.div` @@ -358,19 +360,18 @@ const TicketGroupInfo = styled.div` const TicketGroupTitle = styled.h3` display: flex; - ${({ theme }) => theme.typo.sh2}; + ${({ theme }) => theme.typo.b3}; color: ${({ theme }) => theme.palette.grey.g90}; &::after { content: '*'; ${({ theme }) => theme.typo.b1}; - color: ${({ theme }) => theme.palette.status.error}; + color: ${({ theme }) => theme.palette.status.error1}; display: ${({ required }) => (required ? 'inline' : 'none')}; margin-left: 2px; } ${mq_lg} { - ${({ theme }) => theme.typo.h1}; margin-bottom: 2px; } `; @@ -448,6 +449,11 @@ const TicketAction = styled.div` ${mq_lg} { display: flex; + + svg { + width: 20px; + height: 20px; + } } `; diff --git a/apps/admin/src/components/ShowInfoFormContent/ShowInvitationTicketFormContent.tsx b/apps/admin/src/components/ShowInfoFormContent/ShowInvitationTicketFormContent.tsx index 3f409067..b3cac52e 100644 --- a/apps/admin/src/components/ShowInfoFormContent/ShowInvitationTicketFormContent.tsx +++ b/apps/admin/src/components/ShowInfoFormContent/ShowInvitationTicketFormContent.tsx @@ -53,7 +53,7 @@ const ShowInvitationTicketFormContent = ({ + ; + form: UseFormReturn; showDate: string; showCreatedAt?: string; salesStartTime?: string; @@ -21,69 +21,82 @@ const ShowTicketInfoFormContent = ({ salesStartTime, disabled, }: ShowTicketInfoFormContentProps) => { - const { watch, control, formState: { errors }, setError, clearErrors } = form; - - const minStartDate = format(showCreatedAt ?? new Date(), 'yyyy-MM-dd') + const { + watch, + control, + formState: { errors }, + setError, + clearErrors, + } = form; + + const minStartDate = format(showCreatedAt ?? new Date(), 'yyyy-MM-dd'); const minEndDate = format( - watch('startDate') || - (salesStartTime ? new Date(salesStartTime) : new Date()), + watch('startDate') || (salesStartTime ? new Date(salesStartTime) : new Date()), 'yyyy-MM-dd', - ) + ); const maxDate = format( sub(showDate ? new Date(showDate) : new Date(), { days: 1 }), 'yyyy-MM-dd', - ) - - const validateStartDate = useCallback((value: string) => { - if (!value) { - setError('startDate', { type: 'required', message: '필수 입력사항입니다.' }); - return - } - - if (new Date(value) > new Date(maxDate)) { - setError('startDate', { type: 'max', message: '공연일 이전까지 선택 가능합니다.' }); - return - } - - if (new Date(value) < new Date(minStartDate)) { - const message = showCreatedAt ? `공연 등록일부터 선택 가능합니다. (${format(showCreatedAt, 'yy.MM.dd')})` : '오늘부터 선택 가능합니다.'; - setError('startDate', { type: 'min', message }); - return - } - - clearErrors('startDate') - }, [clearErrors, maxDate, minStartDate, setError, showCreatedAt]) - - const validateEndDate = useCallback((value: string) => { - if (!value) { - setError('endDate', { type: 'required', message: '필수 입력사항입니다.' }); - return - } - - if (new Date(value) > new Date(maxDate)) { - setError('endDate', { type: 'max', message: '공연일 이전까지 선택 가능합니다.' }); - return - } - - if (new Date(value) < new Date(minEndDate)) { - setError('endDate', { type: 'min', message: '시작일부터 선택 가능합니다.' }); - return - } - - clearErrors('endDate') - }, [clearErrors, maxDate, minEndDate, setError]) + ); + + const validateStartDate = useCallback( + (value: string) => { + if (!value) { + setError('startDate', { type: 'required', message: '필수 입력사항입니다.' }); + return; + } + + if (new Date(value) > new Date(maxDate)) { + setError('startDate', { type: 'max', message: '공연일 이전까지 선택 가능합니다.' }); + return; + } + + if (new Date(value) < new Date(minStartDate)) { + const message = showCreatedAt + ? `공연 등록일부터 선택 가능합니다. (${format(showCreatedAt, 'yy.MM.dd')})` + : '오늘부터 선택 가능합니다.'; + setError('startDate', { type: 'min', message }); + return; + } + + clearErrors('startDate'); + }, + [clearErrors, maxDate, minStartDate, setError, showCreatedAt], + ); + + const validateEndDate = useCallback( + (value: string) => { + if (!value) { + setError('endDate', { type: 'required', message: '필수 입력사항입니다.' }); + return; + } + + if (new Date(value) > new Date(maxDate)) { + setError('endDate', { type: 'max', message: '공연일 이전까지 선택 가능합니다.' }); + return; + } + + if (new Date(value) < new Date(minEndDate)) { + setError('endDate', { type: 'min', message: '시작일부터 선택 가능합니다.' }); + return; + } + + clearErrors('endDate'); + }, + [clearErrors, maxDate, minEndDate, setError], + ); useEffect(() => { if (!watch('startDate') || !watch('endDate')) return; validateStartDate(watch('startDate')); validateEndDate(watch('endDate')); - }, [validateEndDate, validateStartDate, watch]) + }, [validateEndDate, validateStartDate, watch]); return ( - 티켓 판매 + 판매 정보 @@ -159,7 +172,7 @@ const ShowTicketInfoFormContent = ({ - 티켓 구매 시 안내사항 + 구매 시 안내사항 방문자에게 안내할 사항이 있다면 작성해 주세요. 작성한 내용은 티켓 상세 화면에 노출됩니다. @@ -172,7 +185,7 @@ const ShowTicketInfoFormContent = ({ }} render={({ field: { onChange, onBlur, value } }) => ( theme.typo.b3}; + color: ${({ theme }) => theme.palette.grey.g90}; +`; + +const SectionDescription = styled.p` + ${({ theme }) => theme.typo.b1}; + color: ${({ theme }) => theme.palette.grey.g60}; +`; + +const SectionDivider = styled.hr` + height: 1px; + background-color: ${({ theme }) => theme.palette.grey.g20}; + margin: 32px 0; +`; + +const HostInputFormContainer = styled.div` + margin-top: 12px; +`; + +const HostListButton = styled.button` + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 20px; + cursor: pointer; +`; + +const HostPreview = styled.div` + display: flex; + align-items: center; + gap: 6px; +`; + +const HostProfileImage = styled.img` + width: 36px; + height: 36px; + border-radius: 999px; + object-fit: cover; +`; + +const HostDefaultProfileImage = styled(DefaultUserProfileIcon)` + border-radius: 999px; + + svg { + width: 36px; + height: 36px; + } +`; + +const HostName = styled.span` + ${({ theme }) => theme.typo.b3}; + display: inline-flex; +`; + +const DeleteButtonContainer = styled.div` + display: flex; + margin-top: 16px; +`; + +export default { + Container, + Section, + SectionHeader, + SectionTitle, + SectionDescription, + SectionDivider, + HostInputFormContainer, + HostListButton, + HostPreview, + HostProfileImage, + HostDefaultProfileImage, + HostName, + DeleteButtonContainer, +}; diff --git a/apps/admin/src/components/AuthoritySettingDialogContent/components/HostInputForm/HostInputForm.styles.ts b/apps/admin/src/components/ShowSettingDialogContent/components/HostInputForm/HostInputForm.styles.ts similarity index 98% rename from apps/admin/src/components/AuthoritySettingDialogContent/components/HostInputForm/HostInputForm.styles.ts rename to apps/admin/src/components/ShowSettingDialogContent/components/HostInputForm/HostInputForm.styles.ts index 08c22296..575cdd69 100644 --- a/apps/admin/src/components/AuthoritySettingDialogContent/components/HostInputForm/HostInputForm.styles.ts +++ b/apps/admin/src/components/ShowSettingDialogContent/components/HostInputForm/HostInputForm.styles.ts @@ -12,8 +12,6 @@ interface InputProps { const Form = styled.form` display: flex; align-items: center; - margin-bottom: 28px; - margin-top: 20px; ${mq_lg} { margin-top: 0; diff --git a/apps/admin/src/components/AuthoritySettingDialogContent/components/HostInputForm/index.tsx b/apps/admin/src/components/ShowSettingDialogContent/components/HostInputForm/index.tsx similarity index 95% rename from apps/admin/src/components/AuthoritySettingDialogContent/components/HostInputForm/index.tsx rename to apps/admin/src/components/ShowSettingDialogContent/components/HostInputForm/index.tsx index 77c7c66c..80de9f59 100644 --- a/apps/admin/src/components/AuthoritySettingDialogContent/components/HostInputForm/index.tsx +++ b/apps/admin/src/components/ShowSettingDialogContent/components/HostInputForm/index.tsx @@ -53,7 +53,7 @@ const HostInputForm = ({ showId }: HostInputFormProps) => { toast.error('이미 초대된 회원입니다.'); } else { toast.error( - '불티에 회원으로 등록된 식별 코드로만 초대가 가능합니다. 식별 코드를 확인 후 다시 시도해 주세요.', + '불티에 회원으로 등록된 식별 코드로만 초대가 가능합니다.\n식별 코드를 확인 후 다시 시도해 주세요.', ); } } @@ -74,7 +74,7 @@ const HostInputForm = ({ showId }: HostInputFormProps) => { # diff --git a/apps/admin/src/components/AuthoritySettingDialogContent/components/HostList/HostList.styles.ts b/apps/admin/src/components/ShowSettingDialogContent/components/HostList/HostListDialogContent.ts similarity index 72% rename from apps/admin/src/components/AuthoritySettingDialogContent/components/HostList/HostList.styles.ts rename to apps/admin/src/components/ShowSettingDialogContent/components/HostList/HostListDialogContent.ts index d4cb0742..3a5bac34 100644 --- a/apps/admin/src/components/AuthoritySettingDialogContent/components/HostList/HostList.styles.ts +++ b/apps/admin/src/components/ShowSettingDialogContent/components/HostList/HostListDialogContent.ts @@ -1,13 +1,6 @@ -import { mq_lg } from '@boolti/ui'; import styled from '@emotion/styled'; -const HostListWrapper = styled.div` - height: calc(100vh - 215px); - - ${mq_lg} { - height: auto; - } -`; +const HostListWrapper = styled.div``; const HostListTitle = styled.h3` ${({ theme }) => theme.typo.b3}; diff --git a/apps/admin/src/components/AuthoritySettingDialogContent/components/HostList/index.tsx b/apps/admin/src/components/ShowSettingDialogContent/components/HostList/index.tsx similarity index 84% rename from apps/admin/src/components/AuthoritySettingDialogContent/components/HostList/index.tsx rename to apps/admin/src/components/ShowSettingDialogContent/components/HostList/index.tsx index cce2447f..573d7417 100644 --- a/apps/admin/src/components/AuthoritySettingDialogContent/components/HostList/index.tsx +++ b/apps/admin/src/components/ShowSettingDialogContent/components/HostList/index.tsx @@ -1,23 +1,19 @@ -import Styled from './HostList.styles'; -import { - HostListItem as IHostListItem, - HostListResponse, - HostType, -} from '@boolti/api/src/types/host'; +import Styled from './HostListDialogContent'; +import { HostListItem as IHostListItem, HostType } from '@boolti/api/src/types/host'; import HostListItem from '../HostListItem'; import { useConfirm, useToast } from '@boolti/ui'; -import { useDeleteHost, useEditHost } from '@boolti/api'; +import { useDeleteHost, useEditHost, useHostList } from '@boolti/api'; import { HREF, PATH } from '~/constants/routes'; import { useNavigate } from 'react-router-dom'; import { useBodyScrollLock } from '~/hooks/useBodyScrollLock'; interface HostListProps { - hosts: HostListResponse; showId: number; onCloseDialog: () => void; } -const HostList = ({ hosts, showId, onCloseDialog }: HostListProps) => { +const HostList = ({ showId, onCloseDialog }: HostListProps) => { + const { data: hosts } = useHostList(showId); const editHostMutation = useEditHost(showId); const deleteHostMutation = useDeleteHost(showId); const navigate = useNavigate(); @@ -50,7 +46,7 @@ const HostList = ({ hosts, showId, onCloseDialog }: HostListProps) => { const confirmText = type === HostType.MANAGER ? `${hostName}의 권한을 관리자로 수정하시겠어요?${'\n'}관리자는 권한 편집이 가능하며, 정산 관리 페이지 이외의 모든 페이지 접근이 가능합니다.` - : `${hostName}의 권한을 도우미로 수정하시겠어요?${'\n'}도우미는 권한 편집이 불가하며, 방문자/입장 관리 페이지만 접근이 가능합니다.`; + : `${hostName}의 권한을 도우미로 수정하시겠어요?${'\n'}도우미는 권한 편집이 불가하며, 방문자 관리 페이지만 접근이 가능합니다.`; const result = await confirm(confirmText, { cancel: '취소하기', confirm: '수정하기', @@ -72,7 +68,6 @@ const HostList = ({ hosts, showId, onCloseDialog }: HostListProps) => { return ( - 팀원 {hosts && hosts.map((host) => ( diff --git a/apps/admin/src/components/AuthoritySettingDialogContent/components/HostListItem/HostListItem.styles.ts b/apps/admin/src/components/ShowSettingDialogContent/components/HostListItem/HostListItem.styles.ts similarity index 94% rename from apps/admin/src/components/AuthoritySettingDialogContent/components/HostListItem/HostListItem.styles.ts rename to apps/admin/src/components/ShowSettingDialogContent/components/HostListItem/HostListItem.styles.ts index c764021d..ed11173e 100644 --- a/apps/admin/src/components/AuthoritySettingDialogContent/components/HostListItem/HostListItem.styles.ts +++ b/apps/admin/src/components/ShowSettingDialogContent/components/HostListItem/HostListItem.styles.ts @@ -81,10 +81,10 @@ const DropdownListItem = styled.li` padding: 7px 12px; ${({ theme }) => theme.typo.b1}; color: ${({ isDelete, theme }) => - isDelete ? theme.palette.status.error : theme.palette.grey.g70}; + isDelete ? theme.palette.status.error1 : theme.palette.grey.g70}; background-color: ${({ theme }) => theme.palette.grey.w}; cursor: pointer; - margin-top: ${({ isDelete }) => (isDelete ? '4px' : '0')}; + margin-top: 0; &:hover { background-color: ${({ theme }) => theme.palette.grey.g10}; diff --git a/apps/admin/src/components/AuthoritySettingDialogContent/components/HostListItem/index.tsx b/apps/admin/src/components/ShowSettingDialogContent/components/HostListItem/index.tsx similarity index 96% rename from apps/admin/src/components/AuthoritySettingDialogContent/components/HostListItem/index.tsx rename to apps/admin/src/components/ShowSettingDialogContent/components/HostListItem/index.tsx index 00d278f9..5d5a9489 100644 --- a/apps/admin/src/components/AuthoritySettingDialogContent/components/HostListItem/index.tsx +++ b/apps/admin/src/components/ShowSettingDialogContent/components/HostListItem/index.tsx @@ -23,7 +23,7 @@ const dropdownItems: HostTypeInfo[] = [ ]; const HostListItem = ({ host, onEdit, onDelete }: HostListItemProps) => { - const { isOpen, toggleDropdown } = useDropdown(); + const { isOpen, dropdownRef, toggleDropdown } = useDropdown(); const [myHostInfo] = useAtom(myHostInfoAtom); const getHostTypeName = (type: HostType) => { @@ -65,7 +65,7 @@ const HostListItem = ({ host, onEdit, onDelete }: HostListItemProps) => { {host.hostName} {host.self && (나)} - + { if (host.type === HostType.MAIN || myHostInfo?.type === HostType.SUPPORTER) return; diff --git a/apps/admin/src/components/ShowSettingDialogContent/index.tsx b/apps/admin/src/components/ShowSettingDialogContent/index.tsx new file mode 100644 index 00000000..be4a39e3 --- /dev/null +++ b/apps/admin/src/components/ShowSettingDialogContent/index.tsx @@ -0,0 +1,93 @@ +import Styled from './ShowSettingDialogContent.styles'; +import { useHostList, useInvitationTicketList, useSalesTicketList } from '@boolti/api'; +import HostInputForm from './components/HostInputForm'; +import { Button } from '@boolti/ui'; +import { ChevronRightIcon } from '@boolti/icon'; +import { myHostInfoAtom } from '../ShowDetailLayout'; +import { useAtom } from 'jotai'; +import { HostType } from '@boolti/api/src/types/host'; +import { useBodyScrollLock } from '~/hooks/useBodyScrollLock'; + +interface ShowSettingDialogContentProps { + showId: number; + onClickHostList: () => void; + onClickDeleteButton: () => void; +} + +const ShowSettingDialogContent = ({ + showId, + onClickHostList, + onClickDeleteButton, +}: ShowSettingDialogContentProps) => { + const { data: hosts } = useHostList(showId); + const { data: salesTicketList } = useSalesTicketList(showId); + const { data: invitationTicketList } = useInvitationTicketList(showId); + + const hasSoldSalesTicketAtLeastOnce = salesTicketList?.some(({ soldAtLeastOnce }) => soldAtLeastOnce) + const hasSoldInvitationTicket = invitationTicketList?.some(({ totalForSale, quantity }) => totalForSale > quantity) + const isShowDeletable = !hasSoldSalesTicketAtLeastOnce && !hasSoldInvitationTicket + + const [firstHost, ...restHosts] = hosts ?? []; + + const [myHostInfo] = useAtom(myHostInfoAtom); + + useBodyScrollLock(); + + return ( + + + + 관리 그룹 + + + + + + {hosts && ( + <> + + {firstHost?.imagePath ? ( + + ) : ( + + )} + + {firstHost?.hostName} + {restHosts.length > 0 ? ` 외 ${restHosts.length}명` : ''} + + + + + )} + + + {myHostInfo?.type === HostType.MAIN && ( + <> + + + + 공연 삭제 + + * 1매 이상 티켓이 판매된 공연은 삭제할 수 없어요. +
* 삭제 시 작성했던 공연 정보는 전부 사라지며 복구할 수 없어요. +
+
+ + + +
+ + )} +
+ ); +}; + +export default ShowSettingDialogContent; diff --git a/apps/admin/src/constants/routes.ts b/apps/admin/src/constants/routes.ts index 65e8e328..13d1159b 100644 --- a/apps/admin/src/constants/routes.ts +++ b/apps/admin/src/constants/routes.ts @@ -9,8 +9,8 @@ export const PATH = { SIGNUP_COMPLETE: '/signup/complete', SHOW_ADD: '/show/add', - SHOW_ADD_TICKET: '/show/add/ticket', - SHOW_ADD_COMPLETE: '/show/add/complete', + SHOW_ADD_DETAIL: '/show/add/detail', + SHOW_ADD_SALES: '/show/add/sales', SHOW_INFO: '/show/:showId/info', SHOW_TICKET: '/show/:showId/ticket', diff --git a/apps/admin/src/hooks/useCastTeamListOrder.ts b/apps/admin/src/hooks/useCastTeamListOrder.ts deleted file mode 100644 index 121c6cd4..00000000 --- a/apps/admin/src/hooks/useCastTeamListOrder.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { useChangeCastTeamOrder } from "@boolti/api"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { DragOverEvent, KeyboardSensor, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core"; -import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable'; - -import { TempShowCastInfoFormInput } from "~/components/ShowCastInfoFormDialogContent"; - -interface UseCastTeamListOrderParams { - showId?: number; - castTeamList?: TempShowCastInfoFormInput[]; - onChange?: () => void; -} - -const useCastTeamListOrder = (params?: UseCastTeamListOrderParams) => { - const showId = params?.showId; - const castTeamList = params?.castTeamList; - - const [castTeamListDraft, setCastTeamListDraft] = useState([]); - const currentCastTeamIds = useRef(null) - - const changeCastTeamOrder = useChangeCastTeamOrder(); - - const castTeamDragEndHandler = useCallback((event: DragOverEvent) => { - const { active, over } = event; - - if (active && over && over.id !== active.id) { - setCastTeamListDraft((prev) => { - const oldIndex = prev.findIndex(({ id }) => id === active.id); - const newIndex = prev.findIndex(({ id }) => id === over.id); - - return arrayMove(prev, oldIndex, newIndex); - }); - } - }, []); - - const fetchCastTeamOrder = useCallback(async (castTeamIds: number[]) => { - if (showId === undefined || castTeamIds.length === 0) return; - - await changeCastTeamOrder.mutateAsync({ - showId, - body: { - castTeamIds, - }, - }); - }, [changeCastTeamOrder, showId]); - - const sensors = useSensors( - useSensor(MouseSensor, { - activationConstraint: { - distance: 10, - }, - }), - useSensor(TouchSensor, { - activationConstraint: { - delay: 0, - tolerance: 5, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); - - useEffect(() => { - if (!castTeamList) return; - - setCastTeamListDraft(castTeamList); - }, [castTeamList]); - - useEffect(() => { - const castTeamIds = castTeamListDraft.map(({ id }) => id) - const stringifiedCastTeamIds = JSON.stringify(castTeamIds); - - if (stringifiedCastTeamIds === currentCastTeamIds.current) return; - - currentCastTeamIds.current = stringifiedCastTeamIds; - fetchCastTeamOrder(castTeamIds); - }, [castTeamListDraft, fetchCastTeamOrder]); - - return { - castTeamListDraft, - sensors, - setCastTeamListDraft, - castTeamDragEndHandler, - } -} - -export default useCastTeamListOrder diff --git a/apps/admin/src/hooks/useCookie.ts b/apps/admin/src/hooks/useCookie.ts new file mode 100644 index 00000000..4c3e5193 --- /dev/null +++ b/apps/admin/src/hooks/useCookie.ts @@ -0,0 +1,54 @@ +interface CookieOptions { + path?: string; + 'max-age'?: number; + domain?: string; + secure?: boolean; + [key: string]: unknown; +} + +const uscCookie = () => { + const setCookie = (name: string, value: string, options: CookieOptions = {}) => { + options.path = options.path || '/'; + + let updatedCookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`; + + for (const [key, optionValue] of Object.entries(options)) { + updatedCookie += `; ${key}`; + if (optionValue !== true) { + updatedCookie += `=${optionValue}`; + } + } + + console.log(`updatedCookie: ${updatedCookie}`); + + document.cookie = updatedCookie; + }; + + const getCookie = (name: string) => { + const cookieString = document.cookie; + const cookies = cookieString.split('; '); + + for (const cookie of cookies) { + const [key, value] = cookie.split('='); + if (decodeURIComponent(key) === name) { + return decodeURIComponent(value); + } + } + + return null; + }; + + const deleteCookie = (name: string) => { + setCookie(name, '', { + 'max-age': -1, + }); + }; + + return { + setCookie, + getCookie, + deleteCookie, + }; +}; + +export default uscCookie; diff --git a/apps/admin/src/hooks/useIsMobile.ts b/apps/admin/src/hooks/useIsMobile.ts new file mode 100644 index 00000000..46fe8191 --- /dev/null +++ b/apps/admin/src/hooks/useIsMobile.ts @@ -0,0 +1,10 @@ +import { useTheme } from '@emotion/react'; +import { useDeviceWidth } from './useDeviceWidth'; + +export function useIsMobile() { + const deviceWidth = useDeviceWidth(); + const theme = useTheme(); + const isMobile = deviceWidth < parseInt(theme.breakpoint.mobile, 10); + + return isMobile; +} diff --git a/apps/admin/src/hooks/usePopupDialog.tsx b/apps/admin/src/hooks/usePopupDialog.tsx new file mode 100644 index 00000000..957c5d6f --- /dev/null +++ b/apps/admin/src/hooks/usePopupDialog.tsx @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import useCookie from './useCookie'; +import { useDialog } from '@boolti/ui'; +import { Popup } from '@boolti/api'; +import NoticePopupContent from '~/components/NoticePopupContent'; +import EventPopupContent from '~/components/EventPopupContent'; + +const usePopupDialog = (popupData?: Popup) => { + const eventPopupDialog = useDialog(); + const noticePopupDialog = useDialog(); + const { getCookie } = useCookie(); + + useEffect(() => { + if (!popupData) { + return; + } + const today = new Date(); + const startDate = new Date(popupData.startDate); + const endDate = new Date(popupData.endDate); + if (!(startDate <= today && today <= endDate)) { + return; + } + const hasCookie = !!getCookie('popup'); + + switch (popupData.type) { + case 'EVENT': + if (hasCookie) { + return; + } + eventPopupDialog.open({ + content: ( + + ), + mobileType: 'centerPopup', + isAuto: true, + contentPadding: '0', + }); + return; + case 'NOTICE': + noticePopupDialog.open({ + content: ( + + ), + mobileType: 'centerPopup', + isAuto: true, + contentPadding: '0', + }); + return; + } + }, [popupData]); +}; + +export default usePopupDialog; diff --git a/apps/admin/src/pages/HomePage/HomePage.styles.ts b/apps/admin/src/pages/HomePage/HomePage.styles.ts index 27500ca6..d340bca8 100644 --- a/apps/admin/src/pages/HomePage/HomePage.styles.ts +++ b/apps/admin/src/pages/HomePage/HomePage.styles.ts @@ -49,8 +49,8 @@ const Container = styled.main` `; const BannerContainer = styled.div` - border-bottom: 1px solid #C5E1FF; -` + border-bottom: 1px solid #c5e1ff; +`; const Banner = styled.div` max-width: ${({ theme }) => theme.breakpoint.desktop}; diff --git a/apps/admin/src/pages/HomePage/index.tsx b/apps/admin/src/pages/HomePage/index.tsx index 7a33bda9..6c7e60d4 100644 --- a/apps/admin/src/pages/HomePage/index.tsx +++ b/apps/admin/src/pages/HomePage/index.tsx @@ -1,6 +1,7 @@ import { queryKeys, useLogout, + usePopup, useQueryClient, useSettlementBanners, useShowList, @@ -21,6 +22,7 @@ import { useAuthAtom } from '~/atoms/useAuthAtom'; import SettingDialogContent from '~/components/SettingDialogContent'; import { useNavigate } from 'react-router-dom'; import { useState } from 'react'; +import usePopupDialog from '~/hooks/usePopupDialog'; const bannerDescription = { REQUIRED: '공연의 정산 내역서가 도착했어요. 내역을 확인한 후 정산을 요청해 주세요.', @@ -42,6 +44,8 @@ const HomePage = () => { const { data: userProfileData, isLoading: isUserProfileLoading } = useUserProfile(); const { data: showList = [], isLoading: isShowListLoading } = useShowList(); const { data: settlementBanners } = useSettlementBanners(); + const { data: popupData } = usePopup(); + usePopupDialog(popupData); const { imgPath, nickname = '', userCode } = userProfileData ?? {}; diff --git a/apps/admin/src/pages/ShowAddCompletePage/ShowAddCompletePage.styles.ts b/apps/admin/src/pages/ShowAddCompletePage/ShowAddCompletePage.styles.ts deleted file mode 100644 index 27951246..00000000 --- a/apps/admin/src/pages/ShowAddCompletePage/ShowAddCompletePage.styles.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { mq_lg } from '@boolti/ui'; -import styled from '@emotion/styled'; - -const ShowAddCompletePage = styled.div` - display: none; - background-color: ${({ theme }) => theme.palette.grey.g00}; - - ${mq_lg} { - display: block; - } -`; - -const HeaderContainer = styled.div``; - -const Header = styled.header` - display: flex; - align-items: center; - max-width: ${({ theme }) => theme.breakpoint.desktop}; - height: 68px; - padding: 0 20px; - margin: 0 auto; -`; - -const BackButton = styled.button` - display: inline-flex; - justify-content: center; - align-items: center; - background: none; - border: none; - cursor: pointer; -`; - -const HeaderText = styled.span` - ${({ theme }) => theme.typo.sh1}; - color: ${({ theme }) => theme.palette.grey.g90}; - margin-left: 8px; -`; - -const CardContainer = styled.div` - height: calc(100vh - 68px); - padding: 40px 20px 68px; - display: flex; - justify-content: center; - align-items: center; -`; - -const Card = styled.div` - width: 100%; - max-width: 760px; - margin: 0 auto; - background-color: ${({ theme }) => theme.palette.grey.w}; - box-shadow: 0px 0px 20px 0px ${({ theme }) => theme.palette.shadow}; - border-radius: 8px; -`; - -const CardHeader = styled.div` - padding: 16px 0; - border-bottom: 1px solid ${({ theme }) => theme.palette.grey.g20}; -`; - -const CardHeaderText = styled.h2` - ${({ theme }) => theme.typo.h2_m}; - color: ${({ theme }) => theme.palette.grey.g70}; - text-align: center; -`; - -const CardContent = styled.div` - padding: 40px 20px; - display: flex; - flex-direction: column; - align-items: center; -`; - -const CardContentImage = styled.img` - margin-bottom: 28px; -`; - -const CardContentTitle = styled.h3` - ${({ theme }) => theme.typo.h2}; - color: ${({ theme }) => theme.palette.grey.g80}; - margin-bottom: 4px; -`; - -const CardContentDescription = styled.p` - ${({ theme }) => theme.typo.b1}; - color: ${({ theme }) => theme.palette.grey.g50}; - margin-bottom: 40px; -`; - -const CardContentButtonContainer = styled.div` - margin-bottom: 28px; - - button { - width: 195px; - } -`; - -const MobileShowAddCompletePage = styled.div` - width: 100%; - height: 100vh; - - ${mq_lg} { - display: none; - } -`; - -const MobileHeader = styled.header` - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - height: 52px; - padding: 0 20px; - background-color: ${({ theme }) => theme.palette.grey.w}; - border-bottom: 1px solid ${({ theme }) => theme.palette.grey.g10}; -`; - -const MobileHeaderText = styled.div` - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - justify-content: center; - align-items: center; - ${({ theme }) => theme.typo.sh1}; - color: ${({ theme }) => theme.palette.grey.g90}; -`; - -const MobileContent = styled.div` - height: calc(100% - 52px - 80px); - padding: 32px 20px; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -`; - -const MobileContentImage = styled.img` - margin-bottom: 28px; - - ${mq_lg} { - margin-bottom: 0; - } -`; - -const MobileContentTitle = styled.h3` - ${({ theme }) => theme.typo.sh2}; - color: ${({ theme }) => theme.palette.grey.g80}; - text-align: center; - margin-bottom: 4px; - - ${mq_lg} { - ${({ theme }) => theme.typo.h2}; - } -`; - -const MobileContentDescription = styled.p` - ${({ theme }) => theme.typo.b1}; - color: ${({ theme }) => theme.palette.grey.g60}; - text-align: center; - margin-bottom: 32px; -`; - -const MobileContentButtonContainer = styled.div` - position: absolute; - bottom: 0; - width: 100%; - height: 80px; - padding: 16px 20px; - - button { - width: 100%; - } -`; - -export default { - ShowAddCompletePage, - HeaderContainer, - Header, - BackButton, - HeaderText, - CardContainer, - Card, - CardHeader, - CardHeaderText, - CardContent, - CardContentImage, - CardContentTitle, - CardContentDescription, - CardContentButtonContainer, - MobileShowAddCompletePage, - MobileHeader, - MobileHeaderText, - MobileContent, - MobileContentImage, - MobileContentTitle, - MobileContentDescription, - MobileContentButtonContainer, -}; diff --git a/apps/admin/src/pages/ShowAddCompletePage/index.tsx b/apps/admin/src/pages/ShowAddCompletePage/index.tsx deleted file mode 100644 index c4887f74..00000000 --- a/apps/admin/src/pages/ShowAddCompletePage/index.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { ArrowLeftIcon } from '@boolti/icon'; -import { Button } from '@boolti/ui'; -import { useNavigate } from 'react-router-dom'; - -import congratulationSvgUrl from '~/assets/svg/congratulation.svg'; -import { PATH } from '~/constants/routes'; - -import Styled from './ShowAddCompletePage.styles'; -import { checkIsWebView } from '~/utils/webview'; - -const ShowAddCompletePage = () => { - const navigate = useNavigate(); - const isWebView = checkIsWebView(window.navigator.userAgent); - - return ( - <> - - {!isWebView && ( - - - { - navigate(PATH.HOME); - }} - > - - - - - - )} - - - - 공연 등록 - - - - 공연 등록이 완료되었습니다. - - 내가 주최한 공연을 관리하고 불티나게 티켓을 팔아보세요! - - - - - - - - - - {!isWebView && ( - - { - navigate(PATH.HOME); - }} - > - - - 공연 등록 - - )} - - - 공연 등록이 완료되었습니다. - - 내가 주최한 공연을 관리하고 불티나게 티켓을 팔아보세요! - - - - - - - - ); -}; - -export default ShowAddCompletePage; diff --git a/apps/admin/src/pages/ShowAddPage/ShowAddPage.styles.ts b/apps/admin/src/pages/ShowAddPage/ShowAddPage.styles.ts index f84cb37a..180d5f0c 100644 --- a/apps/admin/src/pages/ShowAddPage/ShowAddPage.styles.ts +++ b/apps/admin/src/pages/ShowAddPage/ShowAddPage.styles.ts @@ -1,11 +1,6 @@ -import { Button, mq_lg } from '@boolti/ui'; +import { Button, Checkbox, mq_lg } from '@boolti/ui'; import styled from '@emotion/styled'; -interface ProcessIndicatorItemProps { - active?: boolean; - currentStep?: boolean; -} - interface ShowAddFormLabelProps { required?: boolean; } @@ -15,7 +10,7 @@ interface TextFieldProps { } interface ShowAddFormButtonProps { - width?: string; + variant: 'prev' | 'next'; } interface FileUploadAreaProps { @@ -23,7 +18,14 @@ interface FileUploadAreaProps { } const ShowAddPage = styled.div` - background-color: ${({ theme }) => theme.palette.grey.g00}; + background-color: ${({ theme }) => theme.palette.grey.w}; + + ${mq_lg} { + background-color: ${({ theme }) => theme.palette.grey.g00}; + } +`; + +const HeaderContainer = styled.div` display: none; ${mq_lg} { @@ -31,8 +33,6 @@ const ShowAddPage = styled.div` } `; -const HeaderContainer = styled.div``; - const Header = styled.header` display: flex; align-items: center; @@ -58,8 +58,13 @@ const HeaderText = styled.span` margin-left: 8px; `; -const CardContainer = styled.div` +const Content = styled.div` + display: none; padding: 40px 20px 68px; + + ${mq_lg} { + display: block; + } `; const Card = styled.div` @@ -92,55 +97,22 @@ const ProcessIndicator = styled.div` display: flex; justify-content: center; align-items: center; - gap: 46px; - margin-bottom: 40px; -`; - -const ProcessIndicatorItem = styled.div` - display: inline-flex; - flex-direction: column; - justify-content: center; - align-items: center; - gap: 5px; - position: relative; - - &::before { - content: ''; - width: 72px; - height: 1px; - background-color: ${({ theme, active }) => - active ? theme.palette.primary.o1 : theme.palette.grey.g20}; - position: absolute; - left: -59px; - top: 5px; - } + margin-bottom: 16px; - &:first-of-type::before { - content: none; + ${mq_lg} { + margin-bottom: 28px; } `; -const ProcessIndicatorDot = styled.div` - width: 10px; - height: 10px; - border-radius: 10px; - background-color: ${({ theme, active, currentStep }) => - active && currentStep ? theme.palette.primary.o1 : 'none'}; - border: 2px solid - ${({ theme, active }) => (active ? theme.palette.primary.o1 : theme.palette.grey.g20)}; -`; - -const ProcessIndicatorText = styled.span` - ${({ theme }) => theme.typo.c1}; - font-weight: ${({ active }) => (active ? '600' : '400')}; - color: ${({ theme, active }) => (active ? theme.palette.primary.o1 : theme.palette.grey.g30)}; -`; - const CardDescription = styled.p` ${({ theme }) => theme.typo.b1}; color: ${({ theme }) => theme.palette.grey.g60}; text-align: center; - margin-bottom: 40px; + margin-bottom: 32px; + + ${mq_lg} { + margin-bottom: 40px; + } `; const ShowAddForm = styled.form` @@ -148,7 +120,7 @@ const ShowAddForm = styled.form` max-width: 100%; display: flex; flex-direction: column; - gap: 68px; + gap: 40px; `; const ShowInfoFormContent = styled.div``; @@ -168,7 +140,9 @@ const ShowInfoFormFooter = styled.div` } `; -const ShowAddFormGroup = styled.div``; +const ShowAddFormGroup = styled.div` + margin-bottom: 28px; +`; const ShowAddFormTitle = styled.h3` ${({ theme }) => theme.typo.h1}; @@ -198,7 +172,7 @@ const ShowAddFormLabel = styled.label` &::after { content: '*'; ${({ theme }) => theme.typo.b3}; - color: ${({ theme }) => theme.palette.status.error}; + color: ${({ theme }) => theme.palette.status.error1}; display: ${({ required }) => (required ? 'inline' : 'none')}; margin-left: 2px; } @@ -219,9 +193,49 @@ const ShowAddFormButtonContainer = styled.div` gap: 8px; `; -const ShowAddFormButton = styled(Button)` - width: ${({ width }) => width}; +const ShowAddFormButton = styled(Button) ` + ${({ variant }) => { + switch (variant) { + case 'next': { + return ` + width: calc(100% - 100px); + `; + } + case 'prev': { + return ` + width: 100px; + `; + } + default: { + return ` + width: 100%; + `; + } + } + }}; padding: 0 8px; + + ${mq_lg} { + ${({ variant }) => { + switch (variant) { + case 'next': { + return ` + width: calc(100% - 152px); + `; + } + case 'prev': { + return ` + width: 152px; + `; + } + default: { + return ` + width: 100%; + `; + } + } + }}; + } `; const PreviewImageContainer = styled.div` @@ -355,21 +369,69 @@ const TextArea = styled.textarea` } `; -const TicketGroupContainer = styled.div` +const TicketFormContainer = styled.div` display: flex; flex-direction: column; - gap: 68px; + gap: 16px; `; -const MobileShowAddPage = styled.div` - background-color: ${({ theme }) => theme.palette.grey.w}; - display: block; +const TicketForm = styled.div` + display: flex; + flex-direction: column; + gap: 60px; + margin-bottom: 32px; +`; + +const TicketFormTitle = styled.h3` + display: flex; + justify-content: space-between; + align-items: center; + ${({ theme }) => theme.typo.sh2}; + color: ${({ theme }) => theme.palette.grey.g90}; ${mq_lg} { - display: none; + ${({ theme }) => theme.typo.h1}; } `; +const TermGroupContainer = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + background-color: ${({ theme }) => theme.palette.grey.g00}; + padding: 20px; + border-radius: 8px; +`; + +const TermGroup = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const Term = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const TermLabel = styled(Checkbox.Label) <{ main?: boolean }>` + display: flex; + align-items: center; + gap: 8px; + ${({ theme, main }) => (main ? theme.typo.sh1 : theme.typo.b1)}; + color: ${({ theme, main }) => (main ? theme.palette.grey.g90 : theme.palette.grey.g60)}; + font-weight: ${({ main }) => (main ? '600' : '400')}; + user-select: none; + cursor: ${({ main }) => (main ? 'pointer' : 'default')}; +`; + +const TermLink = styled.a` + ${({ theme }) => theme.typo.b1}; + color: ${({ theme }) => theme.palette.grey.g60}; + text-decoration: underline; +`; + const MobileHeader = styled.header` position: fixed; top: 0; @@ -383,6 +445,10 @@ const MobileHeader = styled.header` padding: 0 20px; background-color: ${({ theme }) => theme.palette.grey.w}; border-bottom: 1px solid ${({ theme }) => theme.palette.grey.g10}; + + ${mq_lg} { + display: none; + } `; const MobileHeaderText = styled.div` @@ -398,16 +464,13 @@ const MobileHeaderText = styled.div` color: ${({ theme }) => theme.palette.grey.g90}; `; -const MobileContent = styled.div` - margin-top: 52px; +const MobileContent = styled.div<{ isWebView: boolean }>` + margin-top: ${({ isWebView }) => (isWebView ? '0' : '52px')}; padding: 32px 20px; -`; -const MobileDescription = styled.p` - ${({ theme }) => theme.typo.b1}; - color: ${({ theme }) => theme.palette.grey.g60}; - text-align: center; - margin-bottom: 32px; + ${mq_lg} { + display: none; + } `; export default { @@ -416,15 +479,12 @@ export default { Header, BackButton, HeaderText, - CardContainer, + Content, Card, CardHeader, CardHeaderText, CardContent, ProcessIndicator, - ProcessIndicatorItem, - ProcessIndicatorDot, - ProcessIndicatorText, CardDescription, ShowAddForm, ShowInfoFormContent, @@ -447,10 +507,15 @@ export default { TextFieldSuffix, TextFieldRow, TextArea, - TicketGroupContainer, - MobileShowAddPage, + TicketFormContainer, + TicketForm, + TicketFormTitle, + TermGroupContainer, + TermGroup, + Term, + TermLabel, + TermLink, MobileHeader, MobileHeaderText, MobileContent, - MobileDescription, }; diff --git a/apps/admin/src/pages/ShowAddPage/index.tsx b/apps/admin/src/pages/ShowAddPage/index.tsx index f8495a78..57799e02 100644 --- a/apps/admin/src/pages/ShowAddPage/index.tsx +++ b/apps/admin/src/pages/ShowAddPage/index.tsx @@ -5,13 +5,10 @@ import { useUploadShowImage, } from '@boolti/api'; import { ArrowLeftIcon } from '@boolti/icon'; -import { Button, useToast } from '@boolti/ui'; +import { Button, Checkbox, StepProgressBar, useToast } from '@boolti/ui'; import { useState } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; import { Navigate, useNavigate } from 'react-router-dom'; -import { DndContext, closestCenter } from '@dnd-kit/core'; -import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; import ShowBasicInfoFormContent from '~/components/ShowInfoFormContent/ShowBasicInfoFormContent'; import ShowDetailInfoFormContent from '~/components/ShowInfoFormContent/ShowDetailInfoFormContent'; @@ -22,42 +19,65 @@ import ShowSalesTicketFormContent, { SalesTicket, } from '~/components/ShowInfoFormContent/ShowSalesTicketFormContent'; import ShowTicketInfoFormContent from '~/components/ShowInfoFormContent/ShowTicketInfoFormContent'; -import { ShowInfoFormInputs, ShowTicketFormInputs } from '~/components/ShowInfoFormContent/types'; -import { PATH } from '~/constants/routes'; +import { + ShowBasicInfoFormInputs, + ShowDetailInfoFormInputs, + ShowSalesInfoFormInputs, +} from '~/components/ShowInfoFormContent/types'; +import { HREF, PATH } from '~/constants/routes'; import Styled from './ShowAddPage.styles'; import ShowCastInfoFormContent from '~/components/ShowInfoFormContent/ShowCastInfoFormContent'; -import ShowCastInfo from '~/components/ShowCastInfo'; import { TempShowCastInfoFormInput } from '~/components/ShowCastInfoFormDialogContent'; -import { checkIsWebView } from '~/utils/webview'; -import useCastTeamListOrder from '~/hooks/useCastTeamListOrder'; +import { + TOAST_DURATIONS, + checkIsWebView, + isWebViewBridgeAvailable, + navigateToShowDetail, + showToast, +} from '@boolti/bridge'; + +const stepItems = [ + { key: 'basic', title: '기본 정보' }, + { key: 'detail', title: '상세 정보' }, + { key: 'sales', title: '판매 정보' }, +]; + +const SHOW_ADD_SUCCESS_MESSAGE = '공연 등록을 완료했습니다'; interface ShowAddPageProps { - step: 'info' | 'ticket'; + step: 'basic' | 'detail' | 'sales'; } const ShowAddPage = ({ step }: ShowAddPageProps) => { const navigate = useNavigate(); - const isWebView = checkIsWebView(window.navigator.userAgent); + const isWebView = checkIsWebView(); const [imageFiles, setImageFiles] = useState([]); + const [castTeamList, setCastTeamList] = useState([]); const [salesTicketList, setSalesTicketList] = useState([]); const [invitationTicketList, setInvitationTicketList] = useState([]); - const showInfoForm = useForm(); - const showTicketForm = useForm(); + const [isTermsAccepted, setIsTermsAccepted] = useState(false); + + const showBasicInfoForm = useForm(); + const showDetailInfoForm = useForm(); + const showSalesInfoForm = useForm(); const uploadShowImageMutation = useUploadShowImage(); const addShowMutation = useAddShow(); - const { castTeamListDraft, sensors, setCastTeamListDraft, castTeamDragEndHandler } = useCastTeamListOrder(); const toast = useToast(); - const onSubmitInfoForm: SubmitHandler = async () => { - navigate(PATH.SHOW_ADD_TICKET); + const onSubmitBasicInfoForm: SubmitHandler = async () => { + navigate(PATH.SHOW_ADD_DETAIL); + }; + + const onSubmitDetailInfoForm: SubmitHandler = async () => { + navigate(PATH.SHOW_ADD_SALES); }; - const onSubmitTicketForm: SubmitHandler = async () => { + const onSubmitSalesInfoForm: SubmitHandler = async () => { if (uploadShowImageMutation.status === 'loading' || addShowMutation.status === 'loading') return; @@ -65,24 +85,24 @@ const ShowAddPage = ({ step }: ShowAddPageProps) => { const showImageInfo = await uploadShowImageMutation.mutateAsync(imageFiles); // 공연 생성 - await addShowMutation.mutateAsync({ - name: showInfoForm.getValues('name'), + const showId = await addShowMutation.mutateAsync({ + name: showBasicInfoForm.getValues('name'), images: showImageInfo, - date: `${showInfoForm.getValues('date')}T${showInfoForm.getValues('startTime')}:00.000Z`, - runningTime: Number(showInfoForm.getValues('runningTime')), + date: `${showBasicInfoForm.getValues('date')}T${showBasicInfoForm.getValues('startTime')}:00.000Z`, + runningTime: Number(showBasicInfoForm.getValues('runningTime')), place: { - name: showInfoForm.getValues('placeName'), - streetAddress: showInfoForm.getValues('placeStreetAddress'), - detailAddress: showInfoForm.getValues('placeDetailAddress'), + name: showBasicInfoForm.getValues('placeName'), + streetAddress: showBasicInfoForm.getValues('placeStreetAddress'), + detailAddress: showBasicInfoForm.getValues('placeDetailAddress'), }, - notice: showInfoForm.getValues('notice'), + notice: showDetailInfoForm.getValues('notice'), host: { - name: showInfoForm.getValues('hostName'), - phoneNumber: showInfoForm.getValues('hostPhoneNumber'), + name: showDetailInfoForm.getValues('hostName'), + phoneNumber: showDetailInfoForm.getValues('hostPhoneNumber'), }, - salesStartTime: `${showTicketForm.getValues('startDate')}T00:00:00.000Z`, - salesEndTime: `${showTicketForm.getValues('endDate')}T23:59:59.000Z`, - ticketNotice: `${showTicketForm.getValues('ticketNotice') ?? ''}`, + salesStartTime: `${showSalesInfoForm.getValues('startDate')}T00:00:00.000Z`, + salesEndTime: `${showSalesInfoForm.getValues('endDate')}T23:59:59.000Z`, + ticketNotice: `${showSalesInfoForm.getValues('ticketNotice') ?? ''}`, salesTickets: salesTicketList.map((ticket) => ({ ticketName: ticket.name, price: ticket.price, @@ -92,7 +112,7 @@ const ShowAddPage = ({ step }: ShowAddPageProps) => { ticketName: ticket.name, totalForSale: ticket.quantity, })), - castTeams: castTeamListDraft.map(({ name, members }) => ({ + castTeams: castTeamList.map(({ name, members }) => ({ name, members: members ?.filter(({ userCode, roleName }) => userCode && roleName) @@ -103,13 +123,284 @@ const ShowAddPage = ({ step }: ShowAddPageProps) => { })) as ShowCastTeamCreateOrUpdateRequest[], }); - navigate(PATH.SHOW_ADD_COMPLETE); + if (isWebView && isWebViewBridgeAvailable()) { + showToast({ message: SHOW_ADD_SUCCESS_MESSAGE, duration: TOAST_DURATIONS.SHORT }); + navigateToShowDetail({ showId }); + return; + } + + toast.success(SHOW_ADD_SUCCESS_MESSAGE); + navigate(HREF.SHOW_INFO(showId)); }; - return ( + const basicStepContent = ( + <> + + 공연의 기본 정보를 입력해 주세요. +
+ 입력한 정보는 등록 후에도 수정할 수 있어요. +
+ + + { + setImageFiles((prevImageFiles) => [ + ...prevImageFiles, + ...acceptedFiles.map((file) => ({ + ...file, + preview: URL.createObjectURL(file), + })), + ]); + }} + onDeleteImage={(file) => { + setImageFiles((prevImageFiles) => + prevImageFiles.filter((prevFile) => prevFile !== file), + ); + }} + /> + + + + + ); + + const detailStepContent = ( + <> + {(!showBasicInfoForm.formState.isDirty || !showBasicInfoForm.formState.isValid) && ( + + )} + + 공연의 상세 정보를 입력해 주세요. +
+ 입력한 정보는 등록 후에도 수정할 수 있어요. +
+ + + + + + { + setCastTeamList(data); + }} + /> + + + { + navigate(-1); + }} + > + 이전으로 + + + 다음으로 + + + + + ); + + const salesStepContent = ( + <> + {(!showBasicInfoForm.formState.isDirty || !showBasicInfoForm.formState.isValid) && + (!showDetailInfoForm.formState.isDirty || !showDetailInfoForm.formState.isValid) && ( + + )} + + 판매할 티켓의 정보를 입력하고 약관에 동의해 주세요. +
+ 티켓은 판매 종료일까지 추가할 수 있어요. +
+ + + + + + 판매 티켓 + + { + setSalesTicketList((prevList) => + [...prevList, ticket].map((ticket) => ({ + name: ticket.name, + price: Number(ticket.price), + quantity: Number(ticket.totalForSale), + totalForSale: Number(ticket.totalForSale), + })), + ); + toast.success('일반 티켓을 생성했습니다.'); + }} + onDeleteTicket={(ticket) => { + setSalesTicketList((prevList) => + prevList.filter((prevTicket) => prevTicket.name !== ticket.name), + ); + toast.success('티켓을 삭제했습니다.'); + }} + /> + + 초청 티켓 이용을 원하시면 티켓을 생성해주세요. +
* 초청 코드는 공연 등록 후 공연 관리 > 티켓 관리 + 에서 확인할 수 있습니다. + + } + onSubmitTicket={(ticket) => { + setInvitationTicketList((prevList) => + [...prevList, ticket].map((ticket) => ({ + name: ticket.name, + quantity: Number(ticket.totalForSale), + totalForSale: Number(ticket.totalForSale), + })), + ); + toast.success('초청 티켓을 생성했습니다.'); + }} + onDeleteTicket={(ticket) => { + setInvitationTicketList((prevList) => + prevList.filter((prevTicket) => prevTicket.name !== ticket.name), + ); + toast.success('티켓을 삭제했습니다.'); + }} + /> +
+
+ + + + + { + setIsTermsAccepted(event.target.checked); + }} + /> + 정책 확인 및 약관 동의 + + + + + + + + [필수] 공연 등록 및 관리 이용 약관 + + + 보기 + + + + + + [필수] 수수료 정책 + + + 보기 + + + + + + [필수] 환불 정책 + + + 보기 + + + + + + { + navigate(-1); + }} + > + 이전으로 + + + 공연 등록 완료하기 + + +
+ + ); + + const showAddPageContent = ( <> - - {!isWebView && ( + + + + {step === 'basic' && basicStepContent} + {step === 'detail' && detailStepContent} + {step === 'sales' && salesStepContent} + + ); + + return ( + + {!isWebView && ( + <> { - )} - - - {step === 'info' && ( - <> - - 공연 등록 - - - - - - 정보 입력 - - - - 티켓 생성 - - - - 등록하려는 공연의 정보를 입력해 주세요. -
- 입력한 정보는 등록 후에도 수정할 수 있어요. -
- - - { - setImageFiles((prevImageFiles) => [ - ...prevImageFiles, - ...acceptedFiles.map((file) => ({ - ...file, - preview: URL.createObjectURL(file), - })), - ]); - }} - onDeleteImage={(file) => { - setImageFiles((prevImageFiles) => - prevImageFiles.filter((prevFile) => prevFile !== file), - ); - }} - /> - - - - - - { - setCastTeamListDraft((prev) => [...prev, showCastInfoFormInput]); - return new Promise((reslve) => reslve()); - }} - /> - - info.id)} strategy={verticalListSortingStrategy}> - {castTeamListDraft.map((info) => ( - { - setCastTeamListDraft((prev) => - prev.map((item) => - item.id === info.id ? showCastInfoFormInput : item, - ) - ); - return new Promise((reslve) => reslve()); - }} - onDelete={() => { - setCastTeamListDraft((prev) => - prev.filter((item) => item.id !== info.id) - ); - return new Promise((reslve) => reslve()); - }} - /> - ))} - - - - - -
- - )} - {step === 'ticket' && ( - <> - {(!showInfoForm.formState.isDirty || !showInfoForm.formState.isValid) && ( - - )} - - 티켓 생성 - - - - - - 정보 입력 - - - - 티켓 생성 - - - - 티켓 판매 기간을 설정하고 티켓을 생성해 주세요. -
- 티켓은 판매 종료일까지 추가할 수 있어요. -
- - - - { - setSalesTicketList((prevList) => - [...prevList, ticket].map((ticket) => ({ - name: ticket.name, - price: Number(ticket.price), - quantity: Number(ticket.totalForSale), - totalForSale: Number(ticket.totalForSale), - })), - ); - toast.success('일반 티켓을 생성했습니다.'); - }} - onDeleteTicket={(ticket) => { - setSalesTicketList((prevList) => - prevList.filter((prevTicket) => prevTicket.name !== ticket.name), - ); - toast.success('티켓을 삭제했습니다.'); - }} - /> - - 초청 티켓 이용을 원하시면 티켓을 생성해주세요. -
* 초청 코드는 공연 등록 후{' '} - 공연 관리 > 티켓 관리 - 에서 확인할 수 있습니다. - - } - onSubmitTicket={(ticket) => { - setInvitationTicketList((prevList) => - [...prevList, ticket].map((ticket) => ({ - name: ticket.name, - quantity: Number(ticket.totalForSale), - totalForSale: Number(ticket.totalForSale), - })), - ); - toast.success('초청 티켓을 생성했습니다.'); - }} - onDeleteTicket={(ticket) => { - setInvitationTicketList((prevList) => - prevList.filter((prevTicket) => prevTicket.name !== ticket.name), - ); - toast.success('티켓을 삭제했습니다.'); - }} - /> -
- - { - navigate(-1); - }} - > - 이전으로 - - - 공연 등록 완료하기 - - -
-
- - )} -
-
-
- - {!isWebView && ( { 공연 등록 - )} - {step === 'info' && ( - - - - - 정보 입력 - - - - 티켓 생성 - - - - 등록하려는 공연의 정보를 입력해 주세요. -
- 입력한 정보는 등록 후에도 수정할 수 있어요. -
- - - { - setImageFiles((prevImageFiles) => [ - ...prevImageFiles, - ...acceptedFiles.map((file) => ({ - ...file, - preview: URL.createObjectURL(file), - })), - ]); - }} - onDeleteImage={(file) => { - setImageFiles((prevImageFiles) => - prevImageFiles.filter((prevFile) => prevFile !== file), - ); - }} - /> - - - - - - { - setCastTeamListDraft((prev) => [...prev, showCastInfoFormInput]); - return new Promise((reslve) => reslve()); - }} - /> - - info.id)} strategy={verticalListSortingStrategy}> - {castTeamListDraft.map((info) => ( - { - setCastTeamListDraft((prev) => - prev.map((item) => - item.id === info.id ? showCastInfoFormInput : item, - ) - ); - return new Promise((reslve) => reslve()); - }} - onDelete={() => { - setCastTeamListDraft((prev) => - prev.filter((item) => item.id !== info.id) - ); - return new Promise((reslve) => reslve()); - }} - /> - ))} - - - - - -
- )} - {step === 'ticket' && ( - - - - - 정보 입력 - - - - 티켓 생성 - - - - 티켓 판매 기간을 설정하고 티켓을 생성해 주세요. -
- 티켓은 판매 종료일까지 추가할 수 있어요. -
- - - - { - setSalesTicketList((prevList) => - [...prevList, ticket].map((ticket) => ({ - name: ticket.name, - price: Number(ticket.price), - quantity: Number(ticket.totalForSale), - totalForSale: Number(ticket.totalForSale), - })), - ); - toast.success('일반 티켓을 생성했습니다.'); - }} - onDeleteTicket={(ticket) => { - setSalesTicketList((prevList) => - prevList.filter((prevTicket) => prevTicket.name !== ticket.name), - ); - toast.success('티켓을 삭제했습니다.'); - }} - /> - - 초청 티켓 이용을 원하시면 티켓을 생성해주세요. -
* 초청 코드는 공연 등록 후 공연 관리 > 티켓 관리 - 에서 확인할 수 있습니다. - - } - onSubmitTicket={(ticket) => { - setInvitationTicketList((prevList) => - [...prevList, ticket].map((ticket) => ({ - name: ticket.name, - quantity: Number(ticket.totalForSale), - totalForSale: Number(ticket.totalForSale), - })), - ); - toast.success('초청 티켓을 생성했습니다.'); - }} - onDeleteTicket={(ticket) => { - setInvitationTicketList((prevList) => - prevList.filter((prevTicket) => prevTicket.name !== ticket.name), - ); - toast.success('티켓을 삭제했습니다.'); - }} - /> -
- - { - navigate(-1); - }} - > - 이전으로 - - - 공연 등록 완료하기 - - -
-
- )} -
- + + )} + + + + 공연 등록 + + {showAddPageContent} + + + {showAddPageContent} +
); }; diff --git a/apps/admin/src/pages/ShowEnterancePage/index.tsx b/apps/admin/src/pages/ShowEnterancePage/index.tsx index e7138939..58b72db7 100644 --- a/apps/admin/src/pages/ShowEnterancePage/index.tsx +++ b/apps/admin/src/pages/ShowEnterancePage/index.tsx @@ -19,11 +19,15 @@ import { useTheme } from '@emotion/react'; import { BooltiGreyIcon } from '@boolti/icon/src/components/BooltiGreyIcon'; import TicketNameFilter from '~/components/TicketNameFilter'; import { format } from 'date-fns'; +import ShowDetailUnauthorized, { PAGE_PERMISSION } from '~/components/ShowDetailUnauthorized'; +import { useAtom } from 'jotai'; +import { myHostInfoAtom } from '~/components/ShowDetailLayout'; type TicketType = 'ALL' | 'USED' | 'UNUSED'; const ShowEnterancePage = () => { const params = useParams<{ showId: string }>(); + const [myHostInfo] = useAtom(myHostInfoAtom); const [enteranceTicketType, setEnteranceTicetType] = useState('ALL'); const [searchText, setSearchText] = useState(''); @@ -78,7 +82,17 @@ const ShowEnterancePage = () => { setCurrentPage(0); }, [selectedTicketId, useTicketUsedFilter, debouncedSearchText]); - if (!show || !entranceSummary || !enteranceInfo || !ticketList) return null; + if (!show || !entranceSummary || !enteranceInfo || !ticketList || !myHostInfo) return null; + + if (!PAGE_PERMISSION['방문자 관리'].includes(myHostInfo.type)) { + return ( + + ); + } const { totalTicketCount = 0, diff --git a/apps/admin/src/pages/ShowInfoPage/ShowInfoPage.styles.ts b/apps/admin/src/pages/ShowInfoPage/ShowInfoPage.styles.ts index c04e54a6..76c401e8 100644 --- a/apps/admin/src/pages/ShowInfoPage/ShowInfoPage.styles.ts +++ b/apps/admin/src/pages/ShowInfoPage/ShowInfoPage.styles.ts @@ -28,7 +28,7 @@ const ShowInfoFormDivider = styled.hr` const ShowInfoFormFooter = styled.div` display: flex; justify-content: space-between; - margin-top: 52px; + margin-top: 48px; button { width: 100%; @@ -185,6 +185,22 @@ const ShowInfoPreviewSubmitButton = styled.button` } `; +const ConfirmMessageContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const ConfirmMessage = styled.p` + ${({ theme }) => theme.typo.b3}; + color: ${({ theme }) => theme.palette.grey.g90}; +`; + +const ConfirmSubMessage = styled.p` + ${({ theme }) => theme.typo.b1}; + color: ${({ theme }) => theme.palette.grey.g60}; +`; + export default { ShowInfoPage, ShowInfoForm, @@ -203,4 +219,7 @@ export default { ShowInfoPreviewFooter, ShowInfoPreviewCloseButton, ShowInfoPreviewSubmitButton, + ConfirmMessageContainer, + ConfirmMessage, + ConfirmSubMessage, }; diff --git a/apps/admin/src/pages/ShowInfoPage/index.tsx b/apps/admin/src/pages/ShowInfoPage/index.tsx index 71b8a5ee..c5d99e92 100644 --- a/apps/admin/src/pages/ShowInfoPage/index.tsx +++ b/apps/admin/src/pages/ShowInfoPage/index.tsx @@ -1,77 +1,78 @@ import { ImageFile, - ShowCastTeamCreateOrUpdateRequest, ShowImage, - queryKeys, useCastTeamList, - useDeleteCastTeams, - useDeleteShow, useEditShowInfo, - usePostCastTeams, - usePutCastTeams, - useQueryClient, useShowDetail, useShowSalesInfo, useUploadShowImage, } from '@boolti/api'; -import { Button, Drawer, ShowPreview, useConfirm, useDialog, useToast } from '@boolti/ui'; -import { compareAsc, format } from 'date-fns'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { SubmitHandler, useForm } from 'react-hook-form'; -import { useNavigate, useParams } from 'react-router-dom'; -import { DndContext, closestCenter } from '@dnd-kit/core'; -import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; - -import ShowDeleteForm from '~/components/ShowDeleteForm'; +import { Button, Drawer, ShowPreview, useConfirm, useToast } from '@boolti/ui'; +import { format } from 'date-fns'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useParams } from 'react-router-dom'; + import { middlewareAtom, myHostInfoAtom } from '~/components/ShowDetailLayout'; import ShowBasicInfoFormContent from '~/components/ShowInfoFormContent/ShowBasicInfoFormContent'; import ShowDetailInfoFormContent from '~/components/ShowInfoFormContent/ShowDetailInfoFormContent'; -import { ShowInfoFormInputs } from '~/components/ShowInfoFormContent/types'; -import { PATH } from '~/constants/routes'; +import { + ShowBasicInfoFormInputs, + ShowDetailInfoFormInputs, +} from '~/components/ShowInfoFormContent/types'; import PreviewFrame from './PreviewFrame'; import Styled from './ShowInfoPage.styles'; import { useAtom, useSetAtom } from 'jotai'; import { HostType } from '@boolti/api/src/types/host'; -import ShowDetailUnauthorized from '~/components/ShowDetailUnauthorized'; +import ShowDetailUnauthorized, { PAGE_PERMISSION } from '~/components/ShowDetailUnauthorized'; import Portal from '@boolti/ui/src/components/Portal'; import ShowCastInfoFormContent from '~/components/ShowInfoFormContent/ShowCastInfoFormContent'; -import ShowCastInfo from '~/components/ShowCastInfo'; -import { TempShowCastInfoFormInput } from '~/components/ShowCastInfoFormDialogContent'; import { useBodyScrollLock } from '~/hooks/useBodyScrollLock'; -import useCastTeamListOrder from '~/hooks/useCastTeamListOrder'; - +import { TempShowCastInfoFormInput } from '~/components/ShowCastInfoFormDialogContent'; const ShowInfoPage = () => { - const queryClient = useQueryClient(); const params = useParams<{ showId: string }>(); - const navigate = useNavigate(); const [myHostInfo] = useAtom(myHostInfoAtom); const showPreviewRef = useRef(null); const showPreviewMobileRef = useRef(null); + const initialCastTeamListRef = useRef(null); const [imageFiles, setImageFiles] = useState([]); const [showImages, setShowImages] = useState([]); + const [castTeamListDraft, setCastTeamListDraft] = useState( + null, + ); const isImageFilesDirty = imageFiles.some((file) => file.preview.startsWith('blob:')); - const showInfoForm = useForm(); + const isCastTeamListDraftDirty = + initialCastTeamListRef.current !== JSON.stringify(castTeamListDraft); + const showBasicInfoForm = useForm(); + const showDetailInfoForm = useForm(); const showId = Number(params!.showId); - const { data: show } = useShowDetail(showId); - const { data: showSalesInfo } = useShowSalesInfo(showId); + const { data: show, refetch: refetchShowDetail } = useShowDetail(showId); + const { data: showSalesInfo, refetch: refetchShowSalesInfo } = useShowSalesInfo(showId); const { data: castTeamList, refetch: refetchCastTeamList } = useCastTeamList(showId); - const { castTeamListDraft, sensors, castTeamDragEndHandler } = useCastTeamListOrder({ showId, castTeamList, onChange: refetchCastTeamList }); const editShowInfoMutation = useEditShowInfo(); const uploadShowImageMutation = useUploadShowImage(); - const deleteShowMutation = useDeleteShow(); - const putCastTeams = usePutCastTeams(); - const postCastTeams = usePostCastTeams(); - const deleteCastTeams = useDeleteCastTeams(); + + const isSaveButtonDisabled = useMemo( + () => + !showBasicInfoForm.formState.isValid || + !showDetailInfoForm.formState.isValid || + imageFiles.length === 0 || + show?.isEnded, + [ + imageFiles.length, + show?.isEnded, + showBasicInfoForm.formState.isValid, + showDetailInfoForm.formState.isValid, + ], + ); const toast = useToast(); const confirm = useConfirm(); - const deleteShowDialog = useDialog(); const setMiddleware = useSetAtom(middlewareAtom); @@ -79,71 +80,118 @@ const ShowInfoPage = () => { useBodyScrollLock(previewDrawerOpen); - const onSubmit: SubmitHandler = useCallback( - async (data) => { - if (!show) return; + const submitHandler = useCallback(async () => { + if (!show) return; + + const newImageFiles = imageFiles.filter((file) => file.preview.startsWith('blob:')); + const newShowImages = await (async () => { + if (newImageFiles.length === 0) return []; + + return await uploadShowImageMutation.mutateAsync(newImageFiles); + })(); - const newImageFiles = imageFiles.filter((file) => file.preview.startsWith('blob:')); - const newShowImages = await (async () => { - if (newImageFiles.length === 0) return []; + const [isValidShowBasicInfoFormInputs, isValidShowDetailInfoFormInputs] = await Promise.all([ + showBasicInfoForm.trigger(), + showDetailInfoForm.trigger(), + ]); - return await uploadShowImageMutation.mutateAsync(newImageFiles); - })(); + if (!isValidShowBasicInfoFormInputs || !isValidShowDetailInfoFormInputs) return; - await editShowInfoMutation.mutateAsync({ - showId: show.id, + const showBasicInfoFormInputs = showBasicInfoForm.getValues(); + const showDetailInfoFormInputs = showDetailInfoForm.getValues(); + const castTeams = + castTeamListDraft?.map((team) => ({ + id: team.id >= 0 ? team.id : undefined, + name: team.name, + members: team.members?.map((member) => ({ + id: member.id >= 0 ? member.id : undefined, + roleName: member.roleName ?? '', + userCode: member.userCode ?? '', + })), + })) ?? []; + + await editShowInfoMutation.mutateAsync( + { + showId, body: { - name: data.name, + name: showBasicInfoFormInputs.name, images: [...showImages, ...newShowImages].map((image, index) => ({ sequence: index + 1, thumbnailPath: image.thumbnailPath, path: image.path, })), - date: `${data.date}T${data.startTime}:00.000Z`, - runningTime: Number(data.runningTime), + date: `${showBasicInfoFormInputs.date}T${showBasicInfoFormInputs.startTime}:00.000Z`, + runningTime: +showBasicInfoFormInputs.runningTime, place: { - name: data.placeName, - streetAddress: data.placeStreetAddress, - detailAddress: data.placeDetailAddress, + name: showBasicInfoFormInputs.placeName, + streetAddress: showBasicInfoFormInputs.placeStreetAddress, + detailAddress: showBasicInfoFormInputs.placeDetailAddress, }, - notice: data.notice, + notice: showDetailInfoFormInputs.notice, host: { - name: data.hostName, - phoneNumber: data.hostPhoneNumber, + name: showDetailInfoFormInputs.hostName, + phoneNumber: showDetailInfoFormInputs.hostPhoneNumber, }, + castTeams, }, - }); + }, + { + onSuccess: () => { + refetchShowDetail(); + refetchShowSalesInfo(); + refetchCastTeamList(); - toast.success('공연 정보를 저장했습니다.'); - setPreviewDrawerOpen(false); - }, - [editShowInfoMutation, imageFiles, show, showImages, toast, uploadShowImageMutation], - ); + toast.success('공연 정보를 저장했습니다.'); + setPreviewDrawerOpen(false); + }, + }, + ); + }, [ + castTeamListDraft, + editShowInfoMutation, + imageFiles, + refetchCastTeamList, + refetchShowDetail, + refetchShowSalesInfo, + show, + showBasicInfoForm, + showDetailInfoForm, + showId, + showImages, + toast, + uploadShowImageMutation, + ]); const confirmSaveShowInfo = useCallback(async () => { - if (!showInfoForm.formState.isDirty && !isImageFilesDirty) { - return true; - } + const isDirty = Object.values(showBasicInfoForm.formState.dirtyFields).some((value) => value) || + Object.values(showDetailInfoForm.formState.dirtyFields).some((value) => value) || + isImageFilesDirty || + isCastTeamListDraftDirty + + if (!isDirty) return true const result = await confirm( - '저장하지 않고 이 페이지를 나가면 작성한 정보가 손실됩니다.\n변경된 정보를 저장할까요?', + + + 저장하지 않고 이 페이지를 나가면 작성한 정보가 손실됩니다.
이 페이지를 나갈까요? +
+ + *페이지 하단의 [저장하기] 버튼을 눌러 정보를 저장할 수 있습니다. + +
, { - cancel: '취소하기', - confirm: '저장하기', + cancel: '나가기', + confirm: '머무르기', }, ); - if (result) { - showInfoForm.handleSubmit(onSubmit)(); - } - - return true; - }, [confirm, isImageFilesDirty, onSubmit, showInfoForm]); + return !result; + }, [showBasicInfoForm, showDetailInfoForm, isImageFilesDirty, isCastTeamListDraftDirty, confirm]); useEffect(() => { if (!show) return; - showInfoForm.reset({ + showBasicInfoForm.reset({ name: show.name, date: format(show.date, 'yyyy-MM-dd'), startTime: format(show.date, 'HH:mm'), @@ -151,6 +199,9 @@ const ShowInfoPage = () => { placeName: show.place.name, placeStreetAddress: show.place.streetAddress, placeDetailAddress: show.place.detailAddress, + }); + + showDetailInfoForm.reset({ notice: show.notice, hostName: show.host.name, hostPhoneNumber: show.host.phoneNumber, @@ -158,7 +209,20 @@ const ShowInfoPage = () => { setImageFiles(show.images.map((image) => ({ preview: image.thumbnailPath }))); setShowImages(show.images); - }, [show, showInfoForm]); + }, [show, showBasicInfoForm, showDetailInfoForm]); + + useEffect(() => { + if (!castTeamList) return; + + const initialCastTeamList = castTeamList.map((team) => ({ + id: team.id, + name: team.name, + members: team.members, + })); + + setCastTeamListDraft(initialCastTeamList); + initialCastTeamListRef.current = JSON.stringify(initialCastTeamList); + }, [castTeamList]); useEffect(() => { setMiddleware(() => confirmSaveShowInfo); @@ -167,284 +231,202 @@ const ShowInfoPage = () => { }; }, [confirmSaveShowInfo, setMiddleware]); - if (!show || !showSalesInfo || !castTeamList) { + if (!show || !showSalesInfo || !castTeamList || !myHostInfo) { return null; } - const salesStarted = compareAsc(new Date(showSalesInfo.salesStartTime), new Date()) === -1; + if (!PAGE_PERMISSION['공연 정보'].includes(myHostInfo.type)) { + return ( + + ); + } return ( - <> - {myHostInfo?.type === HostType.SUPPORTER ? ( - - ) : ( - - - - { - setImageFiles((prevImageFiles) => [ - ...prevImageFiles, - ...acceptedFiles.map((file) => ({ - ...file, - preview: URL.createObjectURL(file), - })), - ]); - }} - onDeleteImage={(file) => { - setImageFiles((prevImageFiles) => - prevImageFiles.filter((prevFile) => prevFile !== file), - ); - setShowImages((prevShowImages) => - prevShowImages.filter((prevImage) => prevImage.thumbnailPath !== file.preview), - ); - }} - /> - - - - - - - - - - - - - + + + + { + setImageFiles((prevImageFiles) => [ + ...prevImageFiles, + ...acceptedFiles.map((file) => ({ + ...file, + preview: URL.createObjectURL(file), + })), + ]); + }} + onDeleteImage={(file) => { + setImageFiles((prevImageFiles) => + prevImageFiles.filter((prevFile) => prevFile !== file), + ); + setShowImages((prevShowImages) => + prevShowImages.filter((prevImage) => prevImage.thumbnailPath !== file.preview), + ); + }} + /> + + + + + + + {castTeamListDraft && ( { - await postCastTeams.mutateAsync( - { - showId, - name, - members: members - ?.filter(({ userCode, roleName }) => userCode && roleName) - .map(({ id, userCode, roleName }) => ({ - id: id < 0 ? undefined : id, - userCode, - roleName, - })) as ShowCastTeamCreateOrUpdateRequest['members'], - }, - { - onSuccess: () => { - queryClient.invalidateQueries(queryKeys.castTeams.list(showId)); - }, - }, - ); + initialCastTeamList={castTeamListDraft} + onChange={(data) => { + setCastTeamListDraft(data); }} /> - - info.id)} strategy={verticalListSortingStrategy}> - {castTeamListDraft?.map((info) => ( - { - if (info.id === undefined) return; - - await putCastTeams.mutateAsync( - { - name, - members: members - ?.filter(({ userCode, roleName }) => userCode && roleName) - .map(({ id, userCode, roleName }) => ({ - id: id < 0 ? undefined : id, - userCode, - roleName, - })) as ShowCastTeamCreateOrUpdateRequest['members'], - castTeamId: info.id, - }, - { - onSuccess: () => { - queryClient.invalidateQueries(queryKeys.castTeams.list(showId)); - }, - }, - ); - }} - onDelete={async () => { - if (info.id === undefined) return; - - await deleteCastTeams.mutateAsync(info.id, { - onSuccess: () => { - queryClient.invalidateQueries(queryKeys.castTeams.list(showId)); - }, - }); - }} - /> - ))} - - - - - - - - - { - setPreviewDrawerOpen(false); - }} - > - - - - - - - - - file.preview), - name: showInfoForm.watch('name') ? showInfoForm.watch('name') : '', - date: showInfoForm.watch('date') - ? format(showInfoForm.watch('date'), 'yyyy.MM.dd (E)') - : '', - startTime: showInfoForm.watch('startTime'), - runningTime: showInfoForm.watch('runningTime'), - salesStartTime: showSalesInfo - ? format(showSalesInfo.salesStartTime, 'yyyy.MM.dd (E)') - : '', - salesEndTime: showSalesInfo - ? format(showSalesInfo.salesEndTime, 'yyyy.MM.dd (E)') - : '', - placeName: showInfoForm.watch('placeName'), - placeStreetAddress: showInfoForm.watch('placeStreetAddress'), - placeDetailAddress: showInfoForm.watch('placeDetailAddress'), - notice: showInfoForm.watch('notice'), - hostName: showInfoForm.watch('hostName'), - hostPhoneNumber: showInfoForm.watch('hostPhoneNumber'), - }} - showCastTeams={castTeamList} - hasNoticePage - containerRef={showPreviewRef} - /> - - - - - - { - setPreviewDrawerOpen(false); - }} - > - 닫기 - - { - showInfoForm.handleSubmit(onSubmit)(); - }} - > - 저장하기 - - - - - {previewDrawerOpen && ( - - - + )} + + + + + + + + { + setPreviewDrawerOpen(false); + }} + > + + + + + + + + file.preview), - name: showInfoForm.watch('name') ? showInfoForm.watch('name') : '', - date: showInfoForm.watch('date') - ? format(showInfoForm.watch('date'), 'yyyy.MM.dd (E)') + name: showBasicInfoForm.watch('name') ? showBasicInfoForm.watch('name') : '', + date: showBasicInfoForm.watch('date') + ? format(showBasicInfoForm.watch('date'), 'yyyy.MM.dd (E)') : '', - startTime: showInfoForm.watch('startTime'), - runningTime: showInfoForm.watch('runningTime'), + startTime: showBasicInfoForm.watch('startTime'), + runningTime: showBasicInfoForm.watch('runningTime'), salesStartTime: showSalesInfo ? format(showSalesInfo.salesStartTime, 'yyyy.MM.dd (E)') : '', salesEndTime: showSalesInfo ? format(showSalesInfo.salesEndTime, 'yyyy.MM.dd (E)') : '', - placeName: showInfoForm.watch('placeName'), - placeStreetAddress: showInfoForm.watch('placeStreetAddress'), - placeDetailAddress: showInfoForm.watch('placeDetailAddress'), - notice: showInfoForm.watch('notice'), - hostName: showInfoForm.watch('hostName'), - hostPhoneNumber: showInfoForm.watch('hostPhoneNumber'), + placeName: showBasicInfoForm.watch('placeName'), + placeStreetAddress: showBasicInfoForm.watch('placeStreetAddress'), + placeDetailAddress: showBasicInfoForm.watch('placeDetailAddress'), + notice: showDetailInfoForm.watch('notice'), + hostName: showDetailInfoForm.watch('hostName'), + hostPhoneNumber: showDetailInfoForm.watch('hostPhoneNumber'), }} - showCastTeams={castTeamList} + showCastTeams={ + castTeamListDraft?.map((team) => ({ + name: team.name, + members: team.members?.map((member) => ({ + roleName: member.roleName ?? '', + userNickname: member.userNickname ?? '', + userImgPath: member.userImgPath ?? '', + })), + })) ?? [] + } hasNoticePage - containerRef={showPreviewMobileRef} + containerRef={showPreviewRef} /> - - - { - setPreviewDrawerOpen(false); - }} - > - 닫기 - - { - showInfoForm.handleSubmit(onSubmit)(); - }} - > - 저장하기 - - - - - )} - + + + + + + { + setPreviewDrawerOpen(false); + }} + > + 닫기 + + + 저장하기 + + + + + {previewDrawerOpen && ( + + + + file.preview), + name: showBasicInfoForm.watch('name') ? showBasicInfoForm.watch('name') : '', + date: showBasicInfoForm.watch('date') + ? format(showBasicInfoForm.watch('date'), 'yyyy.MM.dd (E)') + : '', + startTime: showBasicInfoForm.watch('startTime'), + runningTime: showBasicInfoForm.watch('runningTime'), + salesStartTime: showSalesInfo + ? format(showSalesInfo.salesStartTime, 'yyyy.MM.dd (E)') + : '', + salesEndTime: showSalesInfo + ? format(showSalesInfo.salesEndTime, 'yyyy.MM.dd (E)') + : '', + placeName: showBasicInfoForm.watch('placeName'), + placeStreetAddress: showBasicInfoForm.watch('placeStreetAddress'), + placeDetailAddress: showBasicInfoForm.watch('placeDetailAddress'), + notice: showDetailInfoForm.watch('notice'), + hostName: showDetailInfoForm.watch('hostName'), + hostPhoneNumber: showDetailInfoForm.watch('hostPhoneNumber'), + }} + showCastTeams={ + castTeamListDraft?.map((team) => ({ + name: team.name, + members: team.members?.map((member) => ({ + roleName: member.roleName ?? '', + userNickname: member.userNickname ?? '', + userImgPath: member.userImgPath ?? '', + })), + })) ?? []} + hasNoticePage + containerRef={showPreviewMobileRef} + /> + + + { + setPreviewDrawerOpen(false); + }} + > + 닫기 + + + 저장하기 + + + + )} - + ); }; diff --git a/apps/admin/src/pages/ShowReservationPage/index.tsx b/apps/admin/src/pages/ShowReservationPage/index.tsx index 7cbd8ca2..d771d38d 100644 --- a/apps/admin/src/pages/ShowReservationPage/index.tsx +++ b/apps/admin/src/pages/ShowReservationPage/index.tsx @@ -17,6 +17,9 @@ import Styled from './ShowReservationPage.styles'; import { useDeviceWidth } from '~/hooks/useDeviceWidth'; import { useTheme } from '@emotion/react'; import { BooltiGreyIcon } from '@boolti/icon/src/components/BooltiGreyIcon'; +import { myHostInfoAtom } from '~/components/ShowDetailLayout'; +import { useAtom } from 'jotai'; +import ShowDetailUnauthorized, { PAGE_PERMISSION } from '~/components/ShowDetailUnauthorized'; const emptyLabel: Record = { COMPLETE: '결제 완료된 티켓이 없어요.', @@ -26,6 +29,8 @@ const emptyLabel: Record = { const ShowReservationPage = () => { const params = useParams<{ showId: string }>(); + const [myHostInfo] = useAtom(myHostInfoAtom); + const [selectedTicketType, setSelectedTicketType] = useState< React.ComponentProps['value'] >({ value: 'ALL', label: '티켓 전체' }); @@ -74,7 +79,17 @@ const ShowReservationPage = () => { setCurrentPage(0); }, [selectedTicketType, selectedTicketStatus, debouncedSearchText]); - if (!show || !reservationSummary) return null; + if (!show || !reservationSummary || !myHostInfo) return null; + + if (!PAGE_PERMISSION['결제 관리'].includes(myHostInfo.type)) { + return ( + + ); + } const { totalPaymentAmount, diff --git a/apps/admin/src/pages/ShowSettlementPage/ShowSettlementPage.styles.ts b/apps/admin/src/pages/ShowSettlementPage/ShowSettlementPage.styles.ts index 6e306148..ba93b604 100644 --- a/apps/admin/src/pages/ShowSettlementPage/ShowSettlementPage.styles.ts +++ b/apps/admin/src/pages/ShowSettlementPage/ShowSettlementPage.styles.ts @@ -12,14 +12,17 @@ const ShowSettlementPage = styled.div` const Notice = styled.div` padding: 20px 24px; + margin-bottom: 20px; border: 1px solid ${({ theme }) => theme.palette.grey.g20}; border-radius: 4px; - background-color: ${({ theme }) => theme.palette.grey.g00}; + background-color: ${({ theme }) => theme.palette.grey.w}; color: ${({ theme }) => theme.palette.grey.g60}; ${({ theme }) => theme.typo.b1}; + word-break: keep-all; `; const Link = styled.a` + display: inline-block; color: ${({ theme }) => theme.palette.grey.g90}; cursor: pointer; font-weight: 600; @@ -27,7 +30,7 @@ const Link = styled.a` `; const PageSection = styled.div` - margin: 52px 0; + margin: 0 0 52px; `; const PageSectionHeader = styled.div` @@ -51,12 +54,27 @@ const PageDescription = styled.p` `; const DocumentContainer = styled.div` + height: 240px; + display: flex; + justify-content: center; + align-items: center; width: 100%; white-space: nowrap; overflow-x: auto; border: 1px solid ${({ theme }) => theme.palette.grey.g20}; border-radius: 8px; box-shadow: 0 8px 14px 0 ${({ theme }) => theme.palette.shadow}; + padding: 0 20px; + + ${mq_lg} { + height: auto; + padding: 0; + } +`; + +const DocumentMobileText = styled.p` + ${({ theme }) => theme.typo.b3}; + color: ${({ theme }) => theme.palette.grey.g40}; `; const DocumentFooter = styled.div` @@ -125,6 +143,76 @@ const SettlementDoneDescription = styled.p` margin-top: 52px; `; +const SummaryContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 32px; + ${mq_lg} { + gap: 12px; + margin-bottom: 40px; + } +`; + +const Summary = styled.div<{ colorTheme: 'grey' | 'primary' }>` + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-radius: 8px; + flex: 1 0 auto; + height: 50px; + width: 100%; + ${({ colorTheme, theme }) => { + switch (colorTheme) { + case 'grey': + return ` + border: 1px solid ${theme.palette.grey.g00}; + background-color: ${theme.palette.grey.g00}; + color: ${theme.palette.grey.g60}; + & > b { + color: ${theme.palette.grey.g90}; + } + `; + case 'primary': + return ` + border: 1px solid ${theme.palette.primary.o0}; + background-color: ${theme.palette.primary.o0}; + color: ${theme.palette.primary.o2}; + `; + } + }} + ${mq_lg} { + flex: 1 0 30%; + height: 58px; + padding: 16px 20px; + min-width: 230px; + align-items: center; + } +`; + +const SumamryLabel = styled.span<{ bold?: boolean }>` + display: flex; + justify-content: center; + align-items: center; + ${({ theme }) => theme.typo.b1}; + white-space: nowrap; + + & > svg { + margin-left: 4px; + } + + ${mq_lg} { + ${({ theme }) => theme.typo.b2}; + } +`; + +const SumamryValue = styled.b` + white-space: nowrap; + margin-left: 4px; + ${({ theme }) => theme.typo.sh2}; +`; + export default { ShowSettlementPage, Notice, @@ -144,4 +232,9 @@ export default { AccountAddButton, PageSectionDivider, SettlementDoneDescription, + SummaryContainer, + Summary, + SumamryLabel, + SumamryValue, + DocumentMobileText, }; diff --git a/apps/admin/src/pages/ShowSettlementPage/index.tsx b/apps/admin/src/pages/ShowSettlementPage/index.tsx index 63bf7a30..2ab7ac15 100644 --- a/apps/admin/src/pages/ShowSettlementPage/index.tsx +++ b/apps/admin/src/pages/ShowSettlementPage/index.tsx @@ -12,10 +12,11 @@ import { useShowLastSettlementEvent, useShowSettlementInfo, useShowSettlementStatement, + useShowSettlementSummary, useUploadBankAccountCopyPhoto, useUploadIDCardPhotoFile, } from '@boolti/api'; -import { DownloadIcon } from '@boolti/icon'; +import { DownloadIcon, QuestionIcon } from '@boolti/icon'; import { AgreeCheck, Button, TextButton, useToast } from '@boolti/ui'; import { format } from 'date-fns'; import { useEffect, useMemo, useState } from 'react'; @@ -27,8 +28,9 @@ import { myHostInfoAtom } from '~/components/ShowDetailLayout'; import Styled from './ShowSettlementPage.styles'; import { useAtom } from 'jotai'; -import { HostType } from '@boolti/api/src/types/host'; -import ShowDetailUnauthorized from '~/components/ShowDetailUnauthorized'; +import ShowDetailUnauthorized, { PAGE_PERMISSION } from '~/components/ShowDetailUnauthorized'; +import { Tooltip } from 'react-tooltip'; +import { useIsMobile } from '~/hooks/useIsMobile'; pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; @@ -44,6 +46,7 @@ const ShowSettlementPage = () => { const [numPages, setNumPages] = useState(0); const toast = useToast(); + const isMobile = useIsMobile(); const showId = Number(params!.showId); const { data: show } = useShowDetail(showId); @@ -53,6 +56,7 @@ const ShowSettlementPage = () => { const { data: settlementStatementBlob } = useShowSettlementStatement(showId, { enabled: lastSettlementEvent?.settlementEventType != null, }); + const { data: settlementSummary } = useShowSettlementSummary(showId); const { data: settlementBanners } = useSettlementBanners(); const uploadIDCardPhotoFileMutation = useUploadIDCardPhotoFile(showId); @@ -61,6 +65,7 @@ const ShowSettlementPage = () => { const deleteBankAccountCopyPhotoMutation = useDeleteBankAccountCopyPhoto(showId); const requestSettlementMutation = useRequestSettlement(showId); const readSettlementBanner = useReadSettlementBanner(); + const hasActualSettlementSummary = !!settlementSummary?.actual; const settlementStatementFile = useMemo(() => { if (settlementStatementBlob) { @@ -83,190 +88,288 @@ const ShowSettlementPage = () => { }); }, [params.showId, readSettlementBanner, settlementBanners]); - if (!show) return null; + if (!show || !myHostInfo || !settlementSummary) return null; + + if (!PAGE_PERMISSION['정산 관리'].includes(myHostInfo.type)) { + return ( + + ); + } return ( - <> - {myHostInfo?.type !== HostType.MAIN ? ( - - ) : ( - - - 개인정보 처리방침을 확인 후 정산에 필요한 정보를 업로드해 주세요.{' '} - - 개인정보 처리방침 - -
- 업로드 시 불티의 개인정보 처리방침에 동의한 것으로 간주하며, 정보는 정산 및 현금영수증 - 발급에 사용됩니다. -
- {settlementInfo && ( - <> - - - 정산 정보 - - - - - - 신분증 또는 사업자등록증 사본 - - - (개인 - 신분증 / 사업자 - 사업자등록증) - - - { - if (event.target.files?.[0]) { - await uploadIDCardPhotoFileMutation.mutateAsync(event.target.files[0]); - await refetchSettlementInfo(); - } - }} - onClear={async () => { - await deleteIDCardPhotoFileMutation.mutateAsync(); - await refetchSettlementInfo(); - }} - /> - - - - 통장 사본 - - { - if (event.target.files?.[0]) { - await uploadBankAccountCopyPhotoMutation.mutateAsync( - event.target.files[0], - ); - await refetchSettlementInfo(); - } - }} - onClear={async () => { - await deleteBankAccountCopyPhotoMutation.mutateAsync(); - await refetchSettlementInfo(); - }} - /> - - - - - - - 정산 내역서 - {settlementStatementFile && ( - } - onClick={() => { - if (!settlementStatementBlob) return; + + + 개인정보 처리방침을 확인 후 정산에 필요한 정보를 업로드해 주세요.{' '} + + 개인정보 처리방침 + +
+ 업로드 시 불티의 개인정보 처리방침에 동의한 것으로 간주하며, 정보는 정산 및 현금영수증 + 발급에 사용됩니다. +
+ 정산 프로세스 및 관련 안내는 이{' '} + + 링크 + + 를 참고해 주세요. 개인정보 처리방침을 확인 후 정산에 필요한 정보를 업로드해 주세요. +
+ + + 결제 금액 + + {settlementSummary.salesAmount.toLocaleString()}원 + + + + + {!hasActualSettlementSummary ? `예상 ` : ''}수수료 + {!hasActualSettlementSummary && ( + <> + + + + )} + + + {( + settlementSummary.actual?.fee ?? + settlementSummary.expected?.fee ?? + 0 + ).toLocaleString()} + 원 + + + + + {!hasActualSettlementSummary ? `예상 ` : ''}정산 금액 + {!hasActualSettlementSummary && ( + <> + + + + )} + + + {( + settlementSummary.actual?.settlementAmount ?? + settlementSummary.expected?.settlementAmount ?? + 0 + ).toLocaleString()} + 원 + + + + {settlementInfo && ( + <> + + + 정산 정보 + + + + + + 신분증 또는 사업자등록증 사본 + + + (개인 - 신분증 / 사업자 - 사업자등록증) + + + { + if (event.target.files?.[0]) { + await uploadIDCardPhotoFileMutation.mutateAsync(event.target.files[0]); + await refetchSettlementInfo(); + } + }} + onClear={async () => { + await deleteIDCardPhotoFileMutation.mutateAsync(); + await refetchSettlementInfo(); + }} + /> + + + + 통장 사본 + + { + if (event.target.files?.[0]) { + await uploadBankAccountCopyPhotoMutation.mutateAsync(event.target.files[0]); + await refetchSettlementInfo(); + } + }} + onClear={async () => { + await deleteBankAccountCopyPhotoMutation.mutateAsync(); + await refetchSettlementInfo(); + }} + /> + + + + + + + 정산 내역서 + {settlementStatementFile && ( + } + onClick={() => { + if (!settlementStatementBlob) return; - const downloadUrl = URL.createObjectURL(settlementStatementBlob); + const downloadUrl = URL.createObjectURL(settlementStatementBlob); - const anchorElement = document.createElement('a'); - anchorElement.href = downloadUrl; - anchorElement.download = `불티 정산 내역서 - ${show.name}.pdf`; - anchorElement.click(); + const anchorElement = document.createElement('a'); + anchorElement.href = downloadUrl; + anchorElement.download = `불티 정산 내역서 - ${show.name}.pdf`; + anchorElement.click(); - URL.revokeObjectURL(downloadUrl); - }} - > - 다운로드 - - )} - - {lastSettlementEvent?.settlementEventType !== null && - settlementStatementFile !== null && ( - <> - - { - setNumPages(data.numPages); - }} - > - {Array.from(new Array(numPages), (_, index) => ( - - ))} - - - {lastSettlementEvent?.settlementEventType === 'SEND' && ( - - { - setAgreeChecked(event.target.checked); - }} - /> - - - )} - {lastSettlementEvent?.settlementEventType === 'REQUEST' && ( - - - - )} - + toast.success('정산을 요청했습니다'); + } catch (error) { + toast.error('정산 요청에 실패했습니다. 잠시 후에 다시 시도해주세요.'); + } + }} + > + 정산 요청하기 + + )} - {lastSettlementEvent?.settlementEventType === 'DONE' && - lastSettlementEvent.triggeredAt && ( - - {format(new Date(lastSettlementEvent.triggeredAt), 'yyyy년 MM월 dd일')}자로 - 정산이 완료된 공연입니다. - + {lastSettlementEvent?.settlementEventType === 'REQUEST' && ( + + + )} - {!lastSettlementEvent?.settlementEventType && ( - - 정산 내역서는 티켓 판매종료 후 생성돼요 - - )} - - - )} -
+ + )} + {lastSettlementEvent?.settlementEventType === 'DONE' && + lastSettlementEvent.triggeredAt && ( + + {format(new Date(lastSettlementEvent.triggeredAt), 'yyyy년 MM월 dd일')}자로 정산이 + 완료된 공연입니다. + + )} + {!lastSettlementEvent?.settlementEventType && ( + + 정산 내역서는 티켓 판매종료 후 생성돼요 + + )} +
+ )} - +
); }; diff --git a/apps/admin/src/pages/ShowTicketPage/ShowTicketPage.styles.ts b/apps/admin/src/pages/ShowTicketPage/ShowTicketPage.styles.ts index 7c9540c8..962152d6 100644 --- a/apps/admin/src/pages/ShowTicketPage/ShowTicketPage.styles.ts +++ b/apps/admin/src/pages/ShowTicketPage/ShowTicketPage.styles.ts @@ -12,10 +12,29 @@ const ShowTicketPage = styled.div` const ShowTicketForm = styled.form``; -const ShowTicketFormContent = styled.div` +const ShowTicketFormTitle = styled.h3` + display: flex; + justify-content: space-between; + align-items: center; + ${({ theme }) => theme.typo.sh2}; + color: ${({ theme }) => theme.palette.grey.g90}; + margin-bottom: 16px; + + ${mq_lg} { + ${({ theme }) => theme.typo.h1}; + } +`; + +const ShowTicketFormContentContainer = styled.div` max-width: 600px; `; +const ShowTicketFormContent = styled.div` + display: flex; + flex-direction: column; + gap: 48px; +`; + const ShowTicketSubmitContainer = styled.div` margin-top: 32px; @@ -36,6 +55,8 @@ const ShowTicketFormDivider = styled.hr` export default { ShowTicketPage, ShowTicketForm, + ShowTicketFormTitle, + ShowTicketFormContentContainer, ShowTicketFormContent, ShowTicketSubmitContainer, ShowTicketFormDivider, diff --git a/apps/admin/src/pages/ShowTicketPage/index.tsx b/apps/admin/src/pages/ShowTicketPage/index.tsx index 911af03e..9fbedd21 100644 --- a/apps/admin/src/pages/ShowTicketPage/index.tsx +++ b/apps/admin/src/pages/ShowTicketPage/index.tsx @@ -19,18 +19,17 @@ import { myHostInfoAtom } from '~/components/ShowDetailLayout'; import ShowInvitationTicketFormContent from '~/components/ShowInfoFormContent/ShowInvitationTicketFormContent'; import ShowSalesTicketFormContent from '~/components/ShowInfoFormContent/ShowSalesTicketFormContent'; import ShowTicketInfoFormContent from '~/components/ShowInfoFormContent/ShowTicketInfoFormContent'; -import { ShowTicketFormInputs } from '~/components/ShowInfoFormContent/types'; +import { ShowSalesInfoFormInputs } from '~/components/ShowInfoFormContent/types'; import Styled from './ShowTicketPage.styles'; import { useAtom } from 'jotai'; -import { HostType } from '@boolti/api/src/types/host'; -import ShowDetailUnauthorized from '~/components/ShowDetailUnauthorized'; +import ShowDetailUnauthorized, { PAGE_PERMISSION } from '~/components/ShowDetailUnauthorized'; const ShowTicketPage = () => { const params = useParams<{ showId: string }>(); const [myHostInfo] = useAtom(myHostInfoAtom); - const showTicketForm = useForm(); + const showTicketForm = useForm(); const showId = Number(params!.showId); const { data: show } = useShowDetail(showId); @@ -47,7 +46,7 @@ const ShowTicketPage = () => { const toast = useToast(); const confirm = useConfirm(); - const onSubmitShowTicketForm: SubmitHandler = async (data) => { + const onSubmitShowTicketForm: SubmitHandler = async (data) => { if (!show) return; await editSalesTicketInfoMutation.mutateAsync({ @@ -71,131 +70,131 @@ const ShowTicketPage = () => { }); }, [showSalesInfo, showTicketForm]); - if (!show || !showSalesInfo) return null; + if (!show || !showSalesInfo || !myHostInfo) return null; + + if (!PAGE_PERMISSION['판매 정보'].includes(myHostInfo.type)) { + return ( + + ); + } return ( - <> - {myHostInfo?.type === HostType.SUPPORTER ? ( - - ) : ( - - - - - - - - - - - - {salesTicketList && ( - ({ - id: ticket.id, - name: ticket.ticketName, - price: ticket.price, - quantity: ticket.quantity, - totalForSale: ticket.totalForSale, - }))} - disabled={show.isEnded} - onSubmitTicket={async (ticket) => { - await createSalesTicketMutation.mutateAsync({ - showId: show.id, - ticketName: ticket.name, - price: Number(ticket.price), - totalForSale: Number(ticket.totalForSale), - }); - - await refetchSalesTicketList(); - toast.success('일반 티켓을 생성했습니다.'); - }} - onDeleteTicket={async (ticket) => { - if (ticket.id === undefined) return; - - const result = await confirm( - '삭제한 티켓은 다시 생성할 수 없어요. 삭제하시겠어요?', - { - cancel: '취소하기', - confirm: '삭제하기', - }, - ); - - if (!result) return; - - await deleteSalesTicketMutation.mutateAsync(ticket.id); - await refetchSalesTicketList(); - toast.success('티켓을 삭제했습니다.'); - }} - /> - )} - - - - {invitationTicketList && ( - ({ - id: ticket.id, - name: ticket.ticketName, - quantity: ticket.quantity, - totalForSale: ticket.totalForSale, - }))} - description={ - <> - 초청 티켓 이용을 원하시면 티켓을 생성해주세요. -
* 사용 완료 처리된 코드는 재사용할 수 없습니다. - - } - isShowEnded={show.isEnded} - onSubmitTicket={async (ticket) => { - await createInvitationTicketMutation.mutateAsync({ - showId: show.id, - ticketName: ticket.name, - totalForSale: Number(ticket.totalForSale), - }); - await refetchInvitationTicketList(); - toast.success('초청 티켓을 생성했습니다.'); - }} - onDeleteTicket={async (ticket) => { - if (ticket.id === undefined) return; - - const result = await confirm( - '삭제한 티켓은 다시 생성할 수 없어요. 삭제하시겠어요?', - { - cancel: '취소하기', - confirm: '삭제하기', - }, - ); - - if (!result) return; - - await deleteInvitationTicketMutation.mutateAsync(ticket.id); - await refetchInvitationTicketList(); - toast.success('티켓을 삭제했습니다.'); - }} - /> - )} -
-
- )} - + + + + + + + + + + + + 판매 티켓 + + {salesTicketList && ( + ({ + id: ticket.id, + name: ticket.ticketName, + price: ticket.price, + quantity: ticket.quantity, + totalForSale: ticket.totalForSale, + }))} + disabled={show.isEnded} + onSubmitTicket={async (ticket) => { + await createSalesTicketMutation.mutateAsync({ + showId: show.id, + ticketName: ticket.name, + price: Number(ticket.price), + totalForSale: Number(ticket.totalForSale), + }); + + await refetchSalesTicketList(); + toast.success('일반 티켓을 생성했습니다.'); + }} + onDeleteTicket={async (ticket) => { + if (ticket.id === undefined) return; + + const result = await confirm( + '삭제한 티켓은 다시 생성할 수 없어요. 삭제하시겠어요?', + { + cancel: '취소하기', + confirm: '삭제하기', + }, + ); + + if (!result) return; + + await deleteSalesTicketMutation.mutateAsync(ticket.id); + await refetchSalesTicketList(); + toast.success('티켓을 삭제했습니다.'); + }} + /> + )} + {invitationTicketList && ( + ({ + id: ticket.id, + name: ticket.ticketName, + quantity: ticket.quantity, + totalForSale: ticket.totalForSale, + }))} + description={ + <> + 초청 티켓 이용을 원하시면 티켓을 생성해주세요. +
* 사용 완료 처리된 코드는 재사용할 수 없습니다. + + } + isShowEnded={show.isEnded} + onSubmitTicket={async (ticket) => { + await createInvitationTicketMutation.mutateAsync({ + showId: show.id, + ticketName: ticket.name, + totalForSale: Number(ticket.totalForSale), + }); + await refetchInvitationTicketList(); + toast.success('초청 티켓을 생성했습니다.'); + }} + onDeleteTicket={async (ticket) => { + if (ticket.id === undefined) return; + + const result = await confirm( + '삭제한 티켓은 다시 생성할 수 없어요. 삭제하시겠어요?', + { + cancel: '취소하기', + confirm: '삭제하기', + }, + ); + + if (!result) return; + + await deleteInvitationTicketMutation.mutateAsync(ticket.id); + await refetchInvitationTicketList(); + toast.success('티켓을 삭제했습니다.'); + }} + /> + )} +
+
+
); }; diff --git a/apps/admin/src/pages/index.ts b/apps/admin/src/pages/index.ts index e64dc3c3..acd40a47 100644 --- a/apps/admin/src/pages/index.ts +++ b/apps/admin/src/pages/index.ts @@ -12,8 +12,6 @@ export const OAuthApplePage = lazy(() => import('./OAuth/OAuthApplePage')); export const HomePage = lazy(() => import('./HomePage')); -export const ShowAddCompletePage = lazy(() => import('./ShowAddCompletePage')); - export const ShowEnterancePage = lazy(() => import('./ShowEnterancePage')); export const ShowInfoPage = lazy(() => import('./ShowInfoPage')); diff --git a/apps/admin/src/utils/vConsole.ts b/apps/admin/src/utils/vConsole.ts new file mode 100644 index 00000000..3991c965 --- /dev/null +++ b/apps/admin/src/utils/vConsole.ts @@ -0,0 +1,17 @@ +import { checkIsWebView } from '@boolti/bridge'; +import type vConsole from 'vconsole'; + +let vConsoleObject: vConsole | undefined; + +export const initVConsole = async () => { + if (checkIsWebView()) { + const { default: vConsole } = await import('vconsole'); + vConsoleObject = new vConsole({}); + } +}; + +export const destroyVConsole = () => { + if (vConsoleObject) { + vConsoleObject.destroy(); + } +}; diff --git a/apps/admin/src/utils/webview.ts b/apps/admin/src/utils/webview.ts deleted file mode 100644 index 917e7f86..00000000 --- a/apps/admin/src/utils/webview.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const WEBVIEW_REGEX = /BOOLTI\/(ANDROID|IOS)/; -export const OS_REGEX = /(?<=BOOLTI\/).*/; - -export const checkIsWebView = (userAgent: string) => WEBVIEW_REGEX.test(userAgent); - -export const getWebViewOS = (userAgent: string) => { - const regexResult = OS_REGEX.exec(userAgent); - return regexResult === null ? undefined : regexResult[0]; -}; - -export const checkIsAndroid = (userAgent: string) => { - if (!checkIsWebView(userAgent)) return false; - return getWebViewOS(userAgent) === 'ANDROID'; -}; - -export const checkIsIOS = (userAgent: string) => { - if (!checkIsWebView(userAgent)) return false; - return getWebViewOS(userAgent) === 'IOS'; -}; diff --git a/apps/preview/package.json b/apps/preview/package.json index 7d75aed5..a2b993c6 100644 --- a/apps/preview/package.json +++ b/apps/preview/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "lint": "TIMING=1 eslint . --ext ts,tsx --max-warnings 0", + "lint": "TIMING=1 eslint . --ext ts,tsx", "lint:fix": "TIMING=1 eslint . --ext ts,tsx --fix", "type-check": "tsc --noEmit", "preview": "vite preview" diff --git a/apps/preview/src/pages/ShowPreviewPage/index.tsx b/apps/preview/src/pages/ShowPreviewPage/index.tsx index b9d069be..869bdc1f 100644 --- a/apps/preview/src/pages/ShowPreviewPage/index.tsx +++ b/apps/preview/src/pages/ShowPreviewPage/index.tsx @@ -13,22 +13,31 @@ setDefaultOptions({ locale: ko }); const getDynamicLink = (showId: number) => { return `https://boolti.page.link/?link=https://preview.boolti.in/show/${showId}&apn=com.nexters.boolti&ibi=com.nexters.boolti&isi=6476589322`; -} +}; const getPreviewLink = (showId: number) => { - return `${window.location.origin}/show/${showId}` -} - -const getShareText = (show: { id: number, title: string, date: Date, placeName: string, streetAddress: string, detailAddress: string }) => { - return `공연 정보를 공유드려요! - -- 공연명 : ${show.title} -- 일시 : ${format(show.date, 'yyyy.MM.dd (E) / HH:mm -', { locale: ko })} -- 장소 : ${show.placeName} / ${show.streetAddress}, ${show.detailAddress} + return `${window.location.origin}/show/${showId}`; +}; -공연 상세 정보 ▼ -${getPreviewLink(show.id)}` -} +const getShareText = (show: { + id: number; + title: string; + date: Date; + placeName: string; + streetAddress: string; + detailAddress: string; +}) => { + return ( + '공연 정보를 공유드려요!\n' + + '\n' + + `- 공연명 : ${show.title}\n` + + `- 일시 : ${format(show.date, 'yyyy.MM.dd (E) / HH:mm -', { locale: ko })}\n` + + `- 장소 : ${show.placeName} / ${show.streetAddress}, ${show.detailAddress}\n` + + '\n' + + '공연 상세 정보 ▼\n' + + `${getPreviewLink(show.id)}` + ); +}; const ShowPreviewPage = () => { const loaderData = useLoaderData() as @@ -62,7 +71,14 @@ const ShowPreviewPage = () => { const shareButtonClickHandler = async () => { if (navigator.share) { await navigator.share({ - text: getShareText({ id, title, date: new Date(date), placeName, streetAddress, detailAddress }), + text: getShareText({ + id, + title, + date: new Date(date), + placeName, + streetAddress, + detailAddress, + }), }); } else { await navigator.clipboard.writeText(getPreviewLink(id)); diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 244cb0c4..61355e13 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -6,7 +6,7 @@ "main": ".storybook/main.ts", "types": ".storybook/main.ts", "scripts": { - "lint": "TIMING=1 eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "lint": "TIMING=1 eslint . --ext ts,tsx --report-unused-disable-directives", "lint:fix": "TIMING=1 eslint . --ext ts,tsx --fix", "type-check": "tsc --noEmit", "storybook": "storybook dev -p 6006", diff --git a/apps/super-admin/package.json b/apps/super-admin/package.json index 0a2abdf5..6faadd0a 100644 --- a/apps/super-admin/package.json +++ b/apps/super-admin/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "lint": "TIMING=1 eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "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" diff --git a/apps/super-admin/src/components/EntranceTable/EntranceTable.styles.ts b/apps/super-admin/src/components/EntranceTable/EntranceTable.styles.ts index 3d1b0446..63676ce0 100644 --- a/apps/super-admin/src/components/EntranceTable/EntranceTable.styles.ts +++ b/apps/super-admin/src/components/EntranceTable/EntranceTable.styles.ts @@ -66,7 +66,7 @@ const TableItem = styled.div` const EntranceStateText = styled.p` color: ${({ complete, theme }) => - complete ? theme.palette.status.success : theme.palette.status.error}; + complete ? theme.palette.status.success : theme.palette.status.error1}; `; const Empty = styled.div` diff --git a/apps/super-admin/src/components/ErrorBoundary/AuthErrorBoundary.tsx b/apps/super-admin/src/components/ErrorBoundary/AuthErrorBoundary.tsx index 7dd711e4..56800a1b 100644 --- a/apps/super-admin/src/components/ErrorBoundary/AuthErrorBoundary.tsx +++ b/apps/super-admin/src/components/ErrorBoundary/AuthErrorBoundary.tsx @@ -1,4 +1,4 @@ -import { BooltiHTTPError, LOCAL_STORAGE } from '@boolti/api'; +import { CustomHttpError, LOCAL_STORAGE } from '@boolti/api'; import React from 'react'; import { Navigate } from 'react-router-dom'; @@ -9,7 +9,7 @@ interface AuthErrorBoundaryProps { } interface AuthErrorBoundaryState { - status: BooltiHTTPError['status'] | null; + status: CustomHttpError['status'] | null; } const initialState: AuthErrorBoundaryState = { @@ -20,7 +20,7 @@ class AuthErrorBoundary extends React.Component` const EntranceStateText = styled.p` color: ${({ complete, theme }) => - complete ? theme.palette.status.success : theme.palette.status.error}; + complete ? theme.palette.status.success : theme.palette.status.error1}; `; const Empty = styled.div` diff --git a/apps/super-admin/src/components/SettlementStatement/SettlementStatement.styles.ts b/apps/super-admin/src/components/SettlementStatement/SettlementStatement.styles.ts index 035d7081..7dac5488 100644 --- a/apps/super-admin/src/components/SettlementStatement/SettlementStatement.styles.ts +++ b/apps/super-admin/src/components/SettlementStatement/SettlementStatement.styles.ts @@ -226,6 +226,15 @@ const SettlementStatementPreviewContainer = styled.div` height: 600px; `; +const PreviewMessage = styled.div` + background-color: #FACBCF; + border-radius: 8px; + padding: 16px 20px; + ${({ theme }) => theme.typo.b3}; + color: ${({ theme }) => theme.palette.grey.g90}; + margin-bottom: 32px; +`; + const TextFieldRow = styled.div` display: flex; gap: 8px; @@ -267,6 +276,7 @@ export default { SettlementStatementForm, SettlementStatementFooter, SettlementStatementPreviewContainer, + PreviewMessage, TextFieldRow, TextField, Select, diff --git a/apps/super-admin/src/components/SettlementStatement/SettlementStatementFormDialog.tsx b/apps/super-admin/src/components/SettlementStatement/SettlementStatementFormDialog.tsx index fe5f8204..ab444e4b 100644 --- a/apps/super-admin/src/components/SettlementStatement/SettlementStatementFormDialog.tsx +++ b/apps/super-admin/src/components/SettlementStatement/SettlementStatementFormDialog.tsx @@ -354,6 +354,9 @@ const SettlementStatementFormDialog = ({ )} {step === 2 && ( + + 발송 전 내역서를 다시 한 번 확인해 주세요! + { return { label: '정산 중', color: theme.palette.red.sub, - fontColor: theme.palette.status.error, + fontColor: theme.palette.status.error1, }; case 'SETTLEMENT_DONE': return { diff --git a/apps/super-admin/src/constants/settlement.ts b/apps/super-admin/src/constants/settlement.ts new file mode 100644 index 00000000..75a44e41 --- /dev/null +++ b/apps/super-admin/src/constants/settlement.ts @@ -0,0 +1 @@ +export const BOOLTI_FEE_RATE = 0.01 diff --git a/apps/super-admin/src/pages/SettlementPage/SettlementPage.styles.ts b/apps/super-admin/src/pages/SettlementPage/SettlementPage.styles.ts index 8543f521..1dacc422 100644 --- a/apps/super-admin/src/pages/SettlementPage/SettlementPage.styles.ts +++ b/apps/super-admin/src/pages/SettlementPage/SettlementPage.styles.ts @@ -72,7 +72,7 @@ const ProgressItem = styled.li` bottom: -36px; left: 12px; background-color: ${({ theme, active }) => - active ? theme.palette.primary.o1 : theme.palette.grey.g20}; + active ? theme.palette.primary.o1 : theme.palette.grey.g20}; width: 1px; height: 30px; } @@ -105,6 +105,20 @@ const ProgressItemDescription = styled.span` color: ${({ theme }) => theme.palette.grey.g70}; `; +const ProgressItemButton = styled.button` + font-size: 15px; + font-weight: 700; + line-height: 23px; + text-decoration: underline; + color: ${({ theme }) => theme.palette.grey.g70}; + cursor: pointer; + + &:hover, &:active { + text-decoration: underline; + color: ${({ theme }) => theme.palette.grey.g70}; + } +`; + const UserInfo = styled.div` display: flex; flex-direction: column; @@ -217,6 +231,7 @@ export default { ProgressItemNumber, ProgressItemTitle, ProgressItemDescription, + ProgressItemButton, UserInfo, UserInfoItem, UserInfoTitle, diff --git a/apps/super-admin/src/pages/SettlementPage/index.tsx b/apps/super-admin/src/pages/SettlementPage/index.tsx index b23845fd..971e011c 100644 --- a/apps/super-admin/src/pages/SettlementPage/index.tsx +++ b/apps/super-admin/src/pages/SettlementPage/index.tsx @@ -1,10 +1,12 @@ import { + checkIsHttpError, useAdminCreateSettlementStatement, useAdminSettlementDone, useAdminSettlementEvent, useAdminSettlementInfo, useAdminShowDetail, useAdminTicketSalesInfo, + useSuperAdminShowSettlementStatement, } from '@boolti/api'; import { PlusIcon } from '@boolti/icon'; import { Button, useConfirm, useDialog, useToast } from '@boolti/ui'; @@ -15,6 +17,7 @@ import SettlementStatementFormDialog from '~/components/SettlementStatement/Sett import Styled from './SettlementPage.styles'; import PageLayout from '~/components/PageLayout/PageLayout'; +import { BOOLTI_FEE_RATE } from '~/constants/settlement'; const SettlementPage = () => { const params = useParams<{ showId: string }>(); @@ -28,10 +31,92 @@ const SettlementPage = () => { useAdminSettlementEvent(Number(params!.showId)); const { data: adminSettlementInfo } = useAdminSettlementInfo(Number(params!.showId)); const { data: adminTicketSalesInfo } = useAdminTicketSalesInfo(Number(params!.showId)); - + const { data: superAdminShowSettlementStatementBlob } = useSuperAdminShowSettlementStatement(Number(params!.showId), { + enabled: !!adminSettlementEvent && adminSettlementEvent?.SEND !== null + }); const createSettlementStatementMutation = useAdminCreateSettlementStatement(); const settlementDoneMutation = useAdminSettlementDone(); + const dialogContent = ( + { + try { + if (createSettlementStatementMutation.isLoading) return; + + const body = { + showName: data.showName, + hostName: data.hostName, + settlementBankInfo: { + bankCode: data.bankCode, + bankAccountNumber: data.accountNumber, + bankAccountHolder: data.accountHolder, + }, + businessLicenseNumber: data.businessNumber, + salesAmount: parseInt(data.salesAmount.replace(/,/g, '')), + salesItems: data.salesItems.reduce< + { + salesTicketTypeId: number; + amount: number; + }[] + >((acc, item) => { + acc.push({ + salesTicketTypeId: parseInt(item.salesTicketId.replace(/,/g, '')), + amount: parseInt(item.amount.replace(/,/g, '')), + }); + + return acc; + }, []), + fee: parseInt(data.fee.replace(/,/g, '')), + feeItems: [ + { + feeType: 'BROKERAGE_FEE' as 'BROKERAGE_FEE' | 'PAYMENT_AGENCY_FEE', + amount: parseInt(data.brokerageFee.replace(/,/g, '')), + }, + { + feeType: 'PAYMENT_AGENCY_FEE' as + | 'BROKERAGE_FEE' + | 'PAYMENT_AGENCY_FEE', + amount: parseInt(data.paymentAgencyFee.replace(/,/g, '')), + }, + ], + vat: parseInt(data.vat.replace(/,/g, '')), + roundAmount: parseInt(data.adjustmentAmount.replace(/,/g, '')), + roundReason: data.adjustmentReason, + }; + + await createSettlementStatementMutation.mutateAsync({ + showId: Number(params.showId), + body, + }); + await refetchAdminSettlementEvent(); + + toast.success('정산 내역서를 발송했어요.'); + dialog.close(); + } catch (error) { + if (error instanceof Error && checkIsHttpError(error)) { + const json: { + type: string; detail: string + } = await error.response.json(); + + if (json.type === 'SETTLEMENT_STATEMENT_CREATION_ALREADY_IN_PROGRESS') { + toast.error(json.detail) + } else { + toast.error('정산 내역서를 생성하지 못했어요. 개발자에게 문의해주세요.'); + } + } + } + }} + /> + ) + return ( { description={`공연 종료 후 수익이 있을 때만 생성하는 내역서 입니다.\n신분증과 정산 계좌 정보, 통장 사본을 꼼꼼히 확인한 후 발송을 진행해 주세요.`} > {adminSettlementEvent && - (adminSettlementEvent?.SEND !== null || + (adminSettlementEvent?.SEND !== null !== null || adminSettlementEvent?.REQUEST !== null || adminSettlementEvent?.DONE !== null) && ( <> @@ -58,6 +143,22 @@ const SettlementPage = () => { {format(adminSettlementEvent.SEND, 'yyyy-MM-dd HH:mm')} )} + {adminSettlementEvent?.SEND && superAdminShowSettlementStatementBlob && ( + { + if (!adminShowDetail) return; + + const downloadUrl = URL.createObjectURL(superAdminShowSettlementStatementBlob); + + const anchorElement = document.createElement('a'); + anchorElement.href = downloadUrl; + anchorElement.download = `불티 정산 내역서 - ${adminShowDetail.name}.pdf`; + anchorElement.click(); + + URL.revokeObjectURL(downloadUrl); + }}> + 전송한 내역서 보기 + + )} @@ -78,11 +179,11 @@ const SettlementPage = () => { {adminSettlementEvent?.SEND && - adminSettlementEvent?.REQUEST && - !adminSettlementEvent?.DONE ? ( + adminSettlementEvent?.REQUEST && + !adminSettlementEvent?.DONE ? ( - - ) : ( - - - )} - - 통장 사본 @@ -248,6 +319,18 @@ const SettlementPage = () => { + + 불티 수수료 + + + + + {((adminTicketSalesInfo ?? []).reduce((acc, cur) => acc + cur.amount, 0) * BOOLTI_FEE_RATE) + .toLocaleString()} + 원 + + + @@ -257,81 +340,12 @@ const SettlementPage = () => { )} + {adminSettlementEvent?.SEND && + !adminSettlementEvent?.REQUEST && + !adminSettlementEvent?.DONE && ( + + + + )} ); }; diff --git a/packages/api/package.json b/packages/api/package.json index 6d95959a..d4c4cc5f 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -6,13 +6,15 @@ "main": "src/index.ts", "types": "src/index.ts", "scripts": { - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives" }, "dependencies": { + "@boolti/bridge": "*", "@emotion/react": "^11.11.3", "@lukemorales/query-key-factory": "^1.3.2", "@tanstack/query-core": "^4.32.6", "@tanstack/react-query": "^4.32.6", + "async-mutex": "^0.5.0", "image-resize": "^1.3.2", "ky": "^1.2.0", "react": "^18.2.0", diff --git a/packages/api/src/BooltiHTTPError.ts b/packages/api/src/CustomHttpError.ts similarity index 56% rename from packages/api/src/BooltiHTTPError.ts rename to packages/api/src/CustomHttpError.ts index dea710dc..1f001f37 100644 --- a/packages/api/src/BooltiHTTPError.ts +++ b/packages/api/src/CustomHttpError.ts @@ -3,36 +3,32 @@ import { HTTPError } from 'ky'; import { ERROR_CODE } from './constants'; -interface BooltiHTTPErrorOptions { +interface CustomHttpErrorOptions { errorTraceId: string; type: keyof typeof ERROR_CODE; detail: string; } -class BooltiHTTPError extends HTTPError { +export interface CustomHttpErrorParams { + response: Response; + request: Request; + options: NormalizedOptions; + customOptions?: CustomHttpErrorOptions; +} + +export class CustomHttpError extends HTTPError { public errorTraceId?: string; public type?: keyof typeof ERROR_CODE; public detail?: string; public status: number; - constructor( - response: Response, - request: Request, - options: NormalizedOptions, - customOptions?: BooltiHTTPErrorOptions, - ) { + constructor({ request, response, options, customOptions }: CustomHttpErrorParams) { super(response, request, options); - this.name = 'BooltiHTTPError'; + this.name = 'CustomHttpError'; this.errorTraceId = customOptions?.errorTraceId; this.type = customOptions?.type; this.detail = customOptions?.detail; this.status = response.status; } } - -export function isBooltiHTTPError(error: Error): error is BooltiHTTPError { - return error.name === 'BooltiHTTPError'; -} - -export default BooltiHTTPError; diff --git a/packages/api/src/QueryClientProvider.tsx b/packages/api/src/QueryClientProvider.tsx index cb4a6c5a..fa9eaf9a 100644 --- a/packages/api/src/QueryClientProvider.tsx +++ b/packages/api/src/QueryClientProvider.tsx @@ -1,8 +1,6 @@ import { QueryClient, QueryClientProvider as BaseQueryClientProvider } from '@tanstack/react-query'; import { useState } from 'react'; -import BooltiHTTPError from './BooltiHTTPError'; - export function QueryClientProvider({ children }: React.PropsWithChildren) { const [queryClient] = useState( () => @@ -12,12 +10,7 @@ export function QueryClientProvider({ children }: React.PropsWithChildren) { refetchOnWindowFocus: false, retry: false, staleTime: 5000, - useErrorBoundary: (error) => { - // 인증 관련 에러일 때만 ErrorBoundary를 사용한다. - return ( - error instanceof BooltiHTTPError && (error.status === 401 || error.status === 403) - ); - }, + useErrorBoundary: true, }, }, }), diff --git a/packages/api/src/constants/errorCode.ts b/packages/api/src/constants/errorCode.ts index 8e84f5ac..49e0bd67 100644 --- a/packages/api/src/constants/errorCode.ts +++ b/packages/api/src/constants/errorCode.ts @@ -11,4 +11,12 @@ export const ERROR_CODE = { type: 'TOKEN_REFRESH_FAILED', status: 400, }, + UNAUTHROIZED: { + type: 'UNAUTHROIZED', + status: 401, + }, + FORBIDDEN: { + type: 'FORBIDDEN', + status: 403, + }, }; diff --git a/packages/api/src/constants/index.ts b/packages/api/src/constants/index.ts index b8e54998..fbcfa995 100644 --- a/packages/api/src/constants/index.ts +++ b/packages/api/src/constants/index.ts @@ -1,4 +1,7 @@ import { ERROR_CODE } from './errorCode'; -import { LOCAL_STORAGE, COOKIES } from './storages'; +import { LOCAL_STORAGE } from './storages'; -export { ERROR_CODE, LOCAL_STORAGE, COOKIES }; +export { ERROR_CODE, LOCAL_STORAGE }; + +export const API_URL = import.meta.env.VITE_BASE_API_URL; +export const IS_SUPER_ADMIN = import.meta.env.VITE_IS_SUPER_ADMIN === 'true'; diff --git a/packages/api/src/constants/storages.ts b/packages/api/src/constants/storages.ts index 7f234569..be4ab2e3 100644 --- a/packages/api/src/constants/storages.ts +++ b/packages/api/src/constants/storages.ts @@ -2,8 +2,3 @@ export const LOCAL_STORAGE = { ACCESS_TOKEN: 'accessToken', REFRESH_TOKEN: 'refreshToken', }; - -export const COOKIES = { - ACCESS_TOKEN: 'x-access-token', - REFRESH_TOKEN: 'x-refresh-token', -}; diff --git a/packages/api/src/fetcher.ts b/packages/api/src/fetcher.ts index 9793e6d3..d760ad64 100644 --- a/packages/api/src/fetcher.ts +++ b/packages/api/src/fetcher.ts @@ -1,38 +1,17 @@ import type { Options, ResponsePromise } from 'ky'; import ky, { HTTPError } from 'ky'; -import { isBooltiHTTPError } from './BooltiHTTPError'; -import { LOCAL_STORAGE } from './constants'; - -const API_URL = import.meta.env.VITE_BASE_API_URL; -const IS_SUPER_ADMIN = import.meta.env.VITE_IS_SUPER_ADMIN === 'true'; - -interface PostRefreshTokenResponse { - accessToken: string; - refreshToken: string; -} - -const postRefreshToken = async () => { - const refreshToken = window.localStorage.getItem(LOCAL_STORAGE.REFRESH_TOKEN); - - if (refreshToken) { - const response = await ky.post( - `${API_URL}/${IS_SUPER_ADMIN ? 'sa-api' : 'web'}/papi/v1/login/refresh`, - { - json: { - refreshToken, - }, - }, - ); - return await response.json(); - } -}; +import { API_URL, LOCAL_STORAGE } from './constants'; +import { Mutex } from 'async-mutex'; +import { refreshAccessToken } from './refreshAccessToken'; const defaultOption: Options = { retry: 0, timeout: 30_000, }; +const tokenRefreshMutex = new Mutex(); + export const instance = ky.create({ prefixUrl: API_URL, headers: { @@ -50,35 +29,36 @@ export const instance = ky.create({ ], afterResponse: [ async (request, options, response) => { - // access token이 만료되었을 때, refresh token으로 새로운 access token을 발급받는다. if (!response.ok && response.status === 401 && !request.url.includes('logout')) { try { - const { accessToken, refreshToken } = (await postRefreshToken()) ?? {}; - if (accessToken && refreshToken) { - window.localStorage.setItem(LOCAL_STORAGE.ACCESS_TOKEN, accessToken); - window.localStorage.setItem(LOCAL_STORAGE.REFRESH_TOKEN, refreshToken); + let accessToken: string | undefined; + + if (tokenRefreshMutex.isLocked()) { + await tokenRefreshMutex.waitForUnlock(); - request.headers.set('Authorization', `Bearer ${accessToken}`); + const newAccessToken = window.localStorage.getItem(LOCAL_STORAGE.ACCESS_TOKEN); - return ky(request, options); + if (newAccessToken) { + accessToken = newAccessToken; + } + } else { + await tokenRefreshMutex.acquire(); + accessToken = await refreshAccessToken(); } + + request.headers.set('Authorization', `Bearer ${accessToken}`); + return ky(request, options); } catch (e) { if (e instanceof HTTPError && e.response.url.includes('/login/refresh')) { window.localStorage.removeItem(LOCAL_STORAGE.ACCESS_TOKEN); window.localStorage.removeItem(LOCAL_STORAGE.REFRESH_TOKEN); - window.dispatchEvent( - new StorageEvent('storage', { - key: LOCAL_STORAGE.REFRESH_TOKEN, - newValue: undefined, - }), - ); - window.dispatchEvent( - new StorageEvent('storage', { - key: LOCAL_STORAGE.ACCESS_TOKEN, - newValue: undefined, - }), - ); } + + if (e instanceof Error) { + console.warn(`[fether.ts] ${e.name} (${e.message})`); + } + } finally { + tokenRefreshMutex.release(); } } return response; @@ -89,16 +69,7 @@ export const instance = ky.create({ }); export async function resultify(response: ResponsePromise) { - try { - return await response.json(); - } catch (error) { - if (error instanceof Error && isBooltiHTTPError(error)) { - console.error('[BooltiHTTPError] errorTraceId:', error.errorTraceId); - console.error('[BooltiHTTPError] type', error.type); - console.error('[BooltiHTTPError] detail', error.detail); - } - throw error; - } + return await response.json(); } export const fetcher = { diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 9e3c40d3..0d61d32d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,14 +1,12 @@ import { useQueryClient } from '@tanstack/react-query'; - -import BooltiHTTPError from './BooltiHTTPError'; +export * from './CustomHttpError'; export { QueryClientProvider } from './QueryClientProvider'; -export { BooltiHTTPError }; - export * from './constants'; export * from './mutations'; export * from './queries'; -export { queryKeys } from './queryKey'; +export * from './utils'; export type * from './types'; +export { queryKeys } from './queryKey'; export { useQueryClient }; diff --git a/packages/api/src/mutations/index.ts b/packages/api/src/mutations/index.ts index 830ff458..3d173804 100644 --- a/packages/api/src/mutations/index.ts +++ b/packages/api/src/mutations/index.ts @@ -87,7 +87,7 @@ export { useSuperAdminCreateSalesTicket, useSuperAdminCreateInvitationTicket, useSuperAdminEditSalesInfo, - useChangeCastTeamOrder + useChangeCastTeamOrder, }; export type { ImageFile }; diff --git a/packages/api/src/mutations/useChangeCastTeamOrder.ts b/packages/api/src/mutations/useChangeCastTeamOrder.ts index cd64e5ee..a3c27d29 100644 --- a/packages/api/src/mutations/useChangeCastTeamOrder.ts +++ b/packages/api/src/mutations/useChangeCastTeamOrder.ts @@ -10,6 +10,8 @@ const postChangeCastTeamOrder = (showId: number, body: PostChangeCastTeamOrderRe fetcher.post(`web/v1/shows/${showId}/cast-teams/change-sequence`, { json: body }); const useChangeCastTeamOrder = () => - useMutation(({ showId, body }: { showId: number, body: PostChangeCastTeamOrderRequest }) => postChangeCastTeamOrder(showId, body)); + useMutation(({ showId, body }: { showId: number; body: PostChangeCastTeamOrderRequest }) => + postChangeCastTeamOrder(showId, body), + ); export default useChangeCastTeamOrder; diff --git a/packages/api/src/mutations/useEditShowInfo.ts b/packages/api/src/mutations/useEditShowInfo.ts index 04b6ef99..b85d614f 100644 --- a/packages/api/src/mutations/useEditShowInfo.ts +++ b/packages/api/src/mutations/useEditShowInfo.ts @@ -21,10 +21,19 @@ interface PutShowInfoRequest { name: string; phoneNumber: string; }; + castTeams?: { + id?: number; + name: string; + members?: { + id?: number; + userCode: string; + roleName: string; + }[]; + }[]; } const putShowInfo = (showId: number, body: PutShowInfoRequest) => - fetcher.put(`web/v1/host/shows/${showId}`, { json: body }); + fetcher.put(`web/v2/shows/${showId}`, { json: body }); const useEditShowInfo = () => useMutation(({ showId, body }: { showId: number; body: PutShowInfoRequest }) => diff --git a/packages/api/src/queries/index.ts b/packages/api/src/queries/index.ts index 018fadcd..fa0ace76 100644 --- a/packages/api/src/queries/index.ts +++ b/packages/api/src/queries/index.ts @@ -15,6 +15,7 @@ import useShowEnteranceSummary from './useShowEnteranceSummary'; import useShowLastSettlementEvent from './useShowLastSettlementEvent'; import useShowList from './useShowList'; import useShowPreview from './useShowPreview'; +import useShowSettlementSummary from './useShowSettlementSummary'; import useShowReservations from './useShowReservations'; import useShowReservationSummary from './useShowReservationSummary'; import useShowSalesInfo from './useShowSalesInfo'; @@ -40,6 +41,8 @@ import useCastTeamList from './useCastTeamList'; import useAdminTicketList from './useAdminTicketList'; import useAdminSalesTicketList from './useAdminSalesTicketList'; import useAdminReservationSummaryV2 from './useAdminReservationSummaryV2'; +import usePopup from './usePopup'; +import useSuperAdminShowSettlementStatement from './useSuperAdminShowSettlementStatement'; export { useCastTeamList, @@ -53,6 +56,7 @@ export { useAdminShowDetail, useAdminShowList, useAdminTicketSalesInfo, + useShowSettlementSummary, useBankAccountList, useInvitationCodeList, useInvitationTicketList, @@ -84,4 +88,6 @@ export { useSuperAdminSalesTicketList, useSuperAdminInvitationTicketList, useSuperAdminInvitationCodeList, + usePopup, + useSuperAdminShowSettlementStatement, }; diff --git a/packages/api/src/queries/usePopup.ts b/packages/api/src/queries/usePopup.ts new file mode 100644 index 00000000..eb770ed9 --- /dev/null +++ b/packages/api/src/queries/usePopup.ts @@ -0,0 +1,7 @@ +import { useQuery } from '@tanstack/react-query'; + +import { queryKeys } from '../queryKey'; + +const usePopup = () => useQuery(queryKeys.popup.info); + +export default usePopup; diff --git a/packages/api/src/queries/useShowSettlementSummary.ts b/packages/api/src/queries/useShowSettlementSummary.ts new file mode 100644 index 00000000..3946cfd2 --- /dev/null +++ b/packages/api/src/queries/useShowSettlementSummary.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; + +import { queryKeys } from '../queryKey'; + +const useShowSettlementSummary = (showId: number) => + useQuery({ + ...queryKeys.show.settlementSummary(showId), + }); + +export default useShowSettlementSummary; diff --git a/packages/api/src/queries/useSuperAdminShowSettlementStatement.ts b/packages/api/src/queries/useSuperAdminShowSettlementStatement.ts new file mode 100644 index 00000000..94187a5d --- /dev/null +++ b/packages/api/src/queries/useSuperAdminShowSettlementStatement.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; + +import { queryKeys } from '../queryKey'; + +const useSuperAdminShowSettlementStatement = (showId: number, options?: { enabled?: boolean }) => + useQuery({ + ...queryKeys.adminShow.settlementStatement(showId), + enabled: options?.enabled, + }); + +export default useSuperAdminShowSettlementStatement; diff --git a/packages/api/src/queryKey.ts b/packages/api/src/queryKey.ts index c051d5fc..c0e302d9 100644 --- a/packages/api/src/queryKey.ts +++ b/packages/api/src/queryKey.ts @@ -20,9 +20,11 @@ import { ShowSalesTicketResponse, ShowSettlementEventResponse, ShowSettlementInfoResponse, + ShowSettlementSummaryResponse, ShowSummaryResponse, TicketStatus, TicketType, + Popup, } from './types'; import { AdminShowDetailResponse, @@ -130,6 +132,11 @@ export const adminShowQueryKeys = createQueryKeys('adminShow', { queryFn: () => fetcher.get(`sa-api/v1/shows/${showId}/settlement-events/each-last`), }), + settlementStatement: (showId: number) => ({ + queryKey: [showId], + queryFn: () => + instance.get(`sa-api/v1/shows/${showId}/settlement-statements/last/file`).blob(), + }), ticketSalesInfo: (showId: number) => ({ queryKey: [showId], queryFn: () => @@ -393,6 +400,11 @@ export const showQueryKeys = createQueryKeys('show', { queryKey: null, queryFn: () => fetcher.get(`web/v1/host/settlement-banners`), }, + settlementSummary: (showId: number) => ({ + queryKey: [showId], + queryFn: () => + fetcher.get(`web/v1/shows/${showId}/settlement-summaries`), + }), }); export const userQueryKeys = createQueryKeys('user', { @@ -440,6 +452,13 @@ export const castTeamQueryKeys = createQueryKeys('castTeams', { }), }); +export const popupQueryKeys = createQueryKeys('popup', { + info: { + queryKey: null, + queryFn: () => fetcher.get('web/papi/v1/popup'), + }, +}); + export const queryKeys = mergeQueryKeys( adminShowQueryKeys, adminEntranceQueryKeys, @@ -451,4 +470,5 @@ export const queryKeys = mergeQueryKeys( giftQueryKeys, hostQueryKeys, castTeamQueryKeys, + popupQueryKeys, ); diff --git a/packages/api/src/refreshAccessToken.ts b/packages/api/src/refreshAccessToken.ts new file mode 100644 index 00000000..18978e55 --- /dev/null +++ b/packages/api/src/refreshAccessToken.ts @@ -0,0 +1,47 @@ +import ky from 'ky'; +import { API_URL, IS_SUPER_ADMIN, LOCAL_STORAGE } from './constants'; +import { checkIsWebView, isWebViewBridgeAvailable, requestToken } from '@boolti/bridge'; + +interface PostRefreshTokenResponse { + accessToken: string; + refreshToken: string; +} + +const postRefreshToken = async () => { + const refreshToken = window.localStorage.getItem(LOCAL_STORAGE.REFRESH_TOKEN); + + if (refreshToken) { + const response = await ky.post( + `${API_URL}/${IS_SUPER_ADMIN ? 'sa-api' : 'web'}/papi/v1/login/refresh`, + { + json: { + refreshToken, + }, + }, + ); + return await response.json(); + } +}; + +export async function refreshAccessToken() { + let newAccessToken: string | undefined = undefined, + newRefreshToken: string | undefined = undefined; + + if (checkIsWebView() && isWebViewBridgeAvailable()) { + newAccessToken = (await requestToken()).data.token; + } else { + const { accessToken, refreshToken } = (await postRefreshToken()) ?? {}; + newAccessToken = accessToken; + newRefreshToken = refreshToken; + } + + if (newAccessToken) { + window.localStorage.setItem(LOCAL_STORAGE.ACCESS_TOKEN, newAccessToken); + + if (newRefreshToken) { + window.localStorage.setItem(LOCAL_STORAGE.REFRESH_TOKEN, newRefreshToken); + } + } + + return newAccessToken; +} diff --git a/packages/api/src/types/index.ts b/packages/api/src/types/index.ts index d4c560d2..c2af1956 100644 --- a/packages/api/src/types/index.ts +++ b/packages/api/src/types/index.ts @@ -3,3 +3,4 @@ export * from './entrance'; export * from './show'; export * from './users'; export * from './cast'; +export * from './popup'; diff --git a/packages/api/src/types/popup.ts b/packages/api/src/types/popup.ts new file mode 100644 index 00000000..a0d11683 --- /dev/null +++ b/packages/api/src/types/popup.ts @@ -0,0 +1,10 @@ +export interface Popup { + id: number; + type: 'EVENT' | 'NOTICE'; + eventUrl: string | null; + view: 'Home'; + noticeTitle: string | null; + description: string; + startDate: string; + endDate: string; +} diff --git a/packages/api/src/types/show.ts b/packages/api/src/types/show.ts index def1f19d..85b11174 100644 --- a/packages/api/src/types/show.ts +++ b/packages/api/src/types/show.ts @@ -349,3 +349,20 @@ export interface ShowCreateRequest { /** 출연진 팀 */ castTeams?: Array; } + +export interface SummaryItem { + /** 수수료 */ + fee: number; + + /** 정산 금액 */ + settlementAmount: number; +} + +export interface ShowSettlementSummaryResponse { + /** 결제 금액 */ + salesAmount: number; + /** 최종. 정산 내역서가 발행되지 않았으면 null. */ + expected?: SummaryItem; + /** 최종. 정산 내역서가 발행되지 않았으면 null. */ + actual?: SummaryItem; +} diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts new file mode 100644 index 00000000..3b41e73e --- /dev/null +++ b/packages/api/src/utils/index.ts @@ -0,0 +1,13 @@ +import { HTTPError } from 'ky'; +import { ERROR_CODE } from '../constants'; +import { CustomHttpError } from '../CustomHttpError'; + +export const checkIsHttpError = (error: Error): error is HTTPError => error instanceof HTTPError; + +export const checkIsAuthError = (error: HTTPError) => + error.response.status === ERROR_CODE.UNAUTHROIZED.status || + error.response.status === ERROR_CODE.FORBIDDEN.status; + +export function checkIsCustomHttpError(error: Error): error is CustomHttpError { + return error.name === 'CustomHttpError'; +} diff --git a/packages/bridge/package.json b/packages/bridge/package.json new file mode 100644 index 00000000..7271e5ba --- /dev/null +++ b/packages/bridge/package.json @@ -0,0 +1,23 @@ +{ + "name": "@boolti/bridge", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives", + "lint:fix": "TIMING=1 eslint . --ext ts,tsx --fix", + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "@boolti/eslint-config": "*", + "@boolti/typescript-config": "*", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "typescript": "^5.2.2" + }, + "dependencies": { + "uuid": "^11.0.3" + } +} diff --git a/packages/bridge/src/commands/index.ts b/packages/bridge/src/commands/index.ts new file mode 100644 index 00000000..cacd83d3 --- /dev/null +++ b/packages/bridge/src/commands/index.ts @@ -0,0 +1,4 @@ +export * from './navigateBack'; +export * from './navigateToShowDetail'; +export * from './requestToken'; +export * from './showToast'; diff --git a/packages/bridge/src/commands/messageListeners.ts b/packages/bridge/src/commands/messageListeners.ts new file mode 100644 index 00000000..3a1096db --- /dev/null +++ b/packages/bridge/src/commands/messageListeners.ts @@ -0,0 +1,12 @@ +import { ResponseListener } from '../types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const messageListeners: Map> = new Map(); + +export const subscribe = (id: string, listener: ResponseListener) => { + messageListeners.set(id, listener); +}; + +export const unsubscribe = (id: string) => { + messageListeners.delete(id); +}; diff --git a/packages/bridge/src/commands/navigateBack.ts b/packages/bridge/src/commands/navigateBack.ts new file mode 100644 index 00000000..f66ca412 --- /dev/null +++ b/packages/bridge/src/commands/navigateBack.ts @@ -0,0 +1,5 @@ +import { sendCommand } from './sendCommand'; + +export const navigateBack = () => { + return sendCommand({ command: 'NAVIGATE_BACK' }); +}; diff --git a/packages/bridge/src/commands/navigateToShowDetail.ts b/packages/bridge/src/commands/navigateToShowDetail.ts new file mode 100644 index 00000000..97721164 --- /dev/null +++ b/packages/bridge/src/commands/navigateToShowDetail.ts @@ -0,0 +1,7 @@ +import { sendCommand } from './sendCommand'; + +export type NavigateToShowDetailRequestData = { showId: number }; + +export const navigateToShowDetail = (data: NavigateToShowDetailRequestData) => { + return sendCommand({ command: 'NAVIGATE_TO_SHOW_DETAIL', data }); +}; diff --git a/packages/bridge/src/commands/requestToken.ts b/packages/bridge/src/commands/requestToken.ts new file mode 100644 index 00000000..52d1fdf1 --- /dev/null +++ b/packages/bridge/src/commands/requestToken.ts @@ -0,0 +1,7 @@ +import { sendCommand } from './sendCommand'; + +export type RequestTokenResponseData = { token: string }; + +export const requestToken = () => { + return sendCommand({ command: 'REQUEST_TOKEN' }); +}; diff --git a/packages/bridge/src/commands/sendCommand.ts b/packages/bridge/src/commands/sendCommand.ts new file mode 100644 index 00000000..30397231 --- /dev/null +++ b/packages/bridge/src/commands/sendCommand.ts @@ -0,0 +1,87 @@ +import { BRIDGE } from '../constants'; +import { Command, PostMessageFn, ResponseListener, WebviewCommand } from '../types'; +import { messageListeners, subscribe, unsubscribe } from './messageListeners'; +import { getTimeStamp, getUuid, hasAndroidPostMessage, hasWebkitPostMessage } from '../utils'; + +const getPostMessageFn = (): PostMessageFn | null => { + if (hasAndroidPostMessage()) { + return (jsonMessage) => { + window.boolti?.postMessage?.(jsonMessage); + }; + } + + if (hasWebkitPostMessage()) { + return (jsonMessage) => { + window.webkit?.messageHandlers?.boolti?.postMessage?.(jsonMessage); + }; + } + + return null; +}; + +export const sendCommand = ( + request: { + command: WebviewCommand; + data?: RequestData; + }, + timeout: number = 1_000, +): Promise> => { + const postMessage = getPostMessageFn(); + const id = getUuid(); + const timestamp = getTimeStamp(); + const command = { id, timestamp, ...request }; + const message = JSON.stringify(command, undefined, 2); + + console.log('[sendCommand.ts] SEND:', message); + + if (!postMessage) { + console.warn('[sendCommand.ts] NOT WEBVIEW:', command); + return Promise.reject(command); + } + + setTimeout(() => { + postMessage(message); + }, 0); + + return new Promise((resolve, reject) => { + const listener: ResponseListener = (response) => { + if (response.id === command.id) { + resolve(response); + unsubscribe(id); + } + }; + + subscribe(id, listener); + + if (timeout) { + setTimeout(() => { + console.warn('[sendCommand.ts] TIMEOUT:', command); + unsubscribe(id); + reject(command); + }, timeout); + } + }); +}; + +window[BRIDGE] = window[BRIDGE] || { + postMessage: (command: Command) => { + const message = JSON.stringify(command); + + console.log('[sendCommand.ts] RCVD:', message); + + try { + const messageListener = messageListeners.get(command.id); + + if (messageListener) { + messageListener(command); + } + } catch (error) { + console.warn(`[sendCommand.ts] NOT RCVD: ${message}`); + if (error instanceof Error) { + console.warn( + `[sendCommand.ts] NOT RCVD: ${JSON.stringify({ name: error.name, message: error.message })}`, + ); + } + } + }, +}; diff --git a/packages/bridge/src/commands/showToast.ts b/packages/bridge/src/commands/showToast.ts new file mode 100644 index 00000000..1526325a --- /dev/null +++ b/packages/bridge/src/commands/showToast.ts @@ -0,0 +1,21 @@ +import { sendCommand } from './sendCommand'; + +export enum TOAST_DURATIONS { + /** + * 10초 + */ + SHORT = 'SHORT', + /** + * 4초 + */ + LONG = 'LONG', +} + +export type ShowToastRequestData = { + message: string; + duration: TOAST_DURATIONS; +}; + +export const showToast = (data: ShowToastRequestData) => { + return sendCommand({ command: 'SHOW_TOAST', data }); +}; diff --git a/packages/bridge/src/constants.ts b/packages/bridge/src/constants.ts new file mode 100644 index 00000000..668210c9 --- /dev/null +++ b/packages/bridge/src/constants.ts @@ -0,0 +1,3 @@ +export const BRIDGE = '__boolti__webview__bridge__'; +export const WEBVIEW_REGEX = /BOOLTI\/(ANDROID|IOS)/; +export const OS_REGEX = /(?<=BOOLTI\/).*/; diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts new file mode 100644 index 00000000..011fc090 --- /dev/null +++ b/packages/bridge/src/index.ts @@ -0,0 +1,3 @@ +export * from './commands'; +export * from './types'; +export * from './utils'; diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts new file mode 100644 index 00000000..2d4d41f9 --- /dev/null +++ b/packages/bridge/src/types.ts @@ -0,0 +1,38 @@ +export type WebviewCommand = + | 'NAVIGATE_TO_SHOW_DETAIL' + | 'NAVIGATE_BACK' + | 'REQUEST_TOKEN' + | 'SHOW_TOAST'; + +export type BaseCommand = { + id: string; + timestamp: string; +}; + +export type Command = Data extends undefined + ? BaseCommand & { command: WebviewCommand } + : BaseCommand & { command: WebviewCommand; data: Data }; + +export type ResponseListener = (message: Command) => void; + +export type PostMessageFn = (message: string) => void; + +declare global { + interface Window { + webkit?: { + messageHandlers?: { + boolti: { + postMessage?: PostMessageFn; + }; + }; + }; + + boolti?: { + postMessage?: PostMessageFn; + }; + + __boolti__webview__bridge__: { + postMessage: PostMessageFn; + }; + } +} diff --git a/packages/bridge/src/utils.ts b/packages/bridge/src/utils.ts new file mode 100644 index 00000000..25f479b0 --- /dev/null +++ b/packages/bridge/src/utils.ts @@ -0,0 +1,40 @@ +import { OS_REGEX, WEBVIEW_REGEX } from './constants'; +import { v4 as uuidv4 } from 'uuid'; + +export const getUserAgent = () => window.navigator.userAgent; + +export const checkIsWebView = (userAgent: string = window.navigator.userAgent) => + WEBVIEW_REGEX.test(userAgent); + +export const getWebViewOS = (userAgent: string = window.navigator.userAgent) => { + const regexResult = OS_REGEX.exec(userAgent); + return regexResult === null ? undefined : regexResult[0]; +}; + +export const checkIsAndroid = (userAgent: string = window.navigator.userAgent) => { + if (!checkIsWebView(userAgent)) return false; + return getWebViewOS(userAgent) === 'ANDROID'; +}; + +export const checkIsIOS = (userAgent: string = window.navigator.userAgent) => { + if (!checkIsWebView(userAgent)) return false; + return getWebViewOS(userAgent) === 'IOS'; +}; + +export const getTimeStamp = () => new Date().valueOf().toString(); + +export const getUuid = () => uuidv4(); + +export const hasAndroidPostMessage = () => + !!(typeof window !== 'undefined' && window.boolti && window.boolti.postMessage); + +export const hasWebkitPostMessage = () => + !!( + typeof window !== 'undefined' && + window.webkit && + window.webkit.messageHandlers && + window.webkit.messageHandlers.boolti && + window.webkit.messageHandlers.boolti.postMessage + ); + +export const isWebViewBridgeAvailable = () => hasAndroidPostMessage() || hasWebkitPostMessage(); diff --git a/packages/bridge/tsconfig.json b/packages/bridge/tsconfig.json new file mode 100644 index 00000000..aa3c4850 --- /dev/null +++ b/packages/bridge/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@boolti/typescript-config/vite.json", + "compilerOptions": { + "moduleResolution": "node", + "jsxImportSource": "@emotion/react", + }, + "include": ["src"], +} diff --git a/packages/icon/package.json b/packages/icon/package.json index 0842ebfe..bd080f1e 100644 --- a/packages/icon/package.json +++ b/packages/icon/package.json @@ -6,8 +6,9 @@ "main": "src/index.ts", "types": "src/index.ts", "scripts": { - "lint": "TIMING=1 eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "lint:fix": "TIMING=1 eslint . --ext ts,tsx --fix" + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives", + "lint:fix": "TIMING=1 eslint . --ext ts,tsx --fix", + "type-check": "tsc --noEmit" }, "dependencies": { "@emotion/react": "^11.11.3", diff --git a/packages/icon/src/components/ChevronDown.tsx b/packages/icon/src/components/ChevronDown.tsx index 50bdfb9f..671210c2 100644 --- a/packages/icon/src/components/ChevronDown.tsx +++ b/packages/icon/src/components/ChevronDown.tsx @@ -1,5 +1,5 @@ export const ChevronDown = () => ( - + ( - + ( - + - + @@ -9,14 +24,26 @@ export const Discord = () => ( - - + + - + - -) +); diff --git a/packages/icon/src/components/Question.tsx b/packages/icon/src/components/Question.tsx new file mode 100644 index 00000000..6e2fbcc8 --- /dev/null +++ b/packages/icon/src/components/Question.tsx @@ -0,0 +1,41 @@ +export const Question = (props: React.HTMLAttributes) => { + return ( + + + + + + + + + + + + + ); +}; diff --git a/packages/icon/src/components/index.ts b/packages/icon/src/components/index.ts index 95b483ad..268a2c84 100644 --- a/packages/icon/src/components/index.ts +++ b/packages/icon/src/components/index.ts @@ -71,11 +71,13 @@ import { Logout } from './Logout'; import { Call } from './Call'; import { Message } from './Message'; import { Discord } from './Discord'; +import { Question } from './Question'; export { Apple as AppleIcon, ArrowLeft as ArrowLeftIcon, ArrowRight as ArrowRightIcon, + Question as QuestionIcon, BNK, BNP, BoA, diff --git a/packages/ui/package.json b/packages/ui/package.json index 07a9198e..af5f4a03 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -6,8 +6,9 @@ "main": "src/index.ts", "types": "src/index.ts", "scripts": { - "lint": "TIMING=1 eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "lint:fix": "TIMING=1 eslint . --ext ts,tsx --fix" + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives", + "lint:fix": "TIMING=1 eslint . --ext ts,tsx --fix", + "type-check": "tsc --noEmit" }, "dependencies": { "@boolti/icon": "*", diff --git a/packages/ui/src/components/BooltiUIProvider/index.tsx b/packages/ui/src/components/BooltiUIProvider/index.tsx index 28972924..06c5b9c5 100644 --- a/packages/ui/src/components/BooltiUIProvider/index.tsx +++ b/packages/ui/src/components/BooltiUIProvider/index.tsx @@ -14,9 +14,7 @@ const BooltiUIProvider = ({ children }: BooltiUIProviderProps) => { - - {children} - + {children} diff --git a/packages/ui/src/components/Button/Button.styles.ts b/packages/ui/src/components/Button/Button.styles.ts index a604d5b2..cc3083f8 100644 --- a/packages/ui/src/components/Button/Button.styles.ts +++ b/packages/ui/src/components/Button/Button.styles.ts @@ -1,10 +1,10 @@ import styled from '@emotion/styled'; -type colorTheme = 'primary' | 'netural' | 'line'; +type ColorTheme = 'primary' | 'netural' | 'line' | 'secondary' | 'danger'; type Size = 'bold' | 'medium' | 'regular' | 'small' | 'x-small'; export interface ButtonProps { - colorTheme: colorTheme; + colorTheme: ColorTheme; size: Size; disabled?: boolean; icon?: React.ReactNode; @@ -114,14 +114,60 @@ const Container = styled.button` background-color: ${theme.palette.grey.g10}; } `; + case 'secondary': + return ` + color: ${theme.palette.grey.g90}; + border: 0; + background-color: ${theme.palette.grey.g10}; + &:hover:not(:disabled) { + background-color: ${theme.palette.grey.g20}; + } + &:active:not(:disabled) { + background-color: ${theme.palette.grey.g30}; + } + &:disabled { + color: ${theme.palette.grey.g40}; + background-color: ${theme.palette.grey.g00}; + } + `; + case 'danger': + return ` + color: ${theme.palette.grey.w}; + border: 0; + background-color: ${theme.palette.status.error1}; + &:hover:not(:disabled) { + background-color: ${theme.palette.status.error2}; + } + &:active:not(:disabled) { + background-color: ${theme.palette.status.error3}; + } + &:disabled { + color: ${theme.palette.grey.g40}; + background-color: ${theme.palette.grey.g20}; + } + ` } }} `; -const Icon = styled.div` +const Icon = styled.div & { hasChildren: boolean }>` width: 20px; height: 20px; - margin-right: 8px; + + ${({ size, hasChildren }) => { + if (!hasChildren) return null + + switch (size) { + case 'x-small': + return ` + margin-right: 6px; + ` + default: + return ` + margin-right: 8px; + ` + } + }} `; export default { diff --git a/packages/ui/src/components/Button/index.tsx b/packages/ui/src/components/Button/index.tsx index 193762e4..79121e3e 100644 --- a/packages/ui/src/components/Button/index.tsx +++ b/packages/ui/src/components/Button/index.tsx @@ -3,9 +3,11 @@ import Styled, { ButtonProps } from './Button.styles'; type Props = React.ComponentProps<'button'> & ButtonProps; const Button = ({ children, colorTheme, size, icon, ...rest }: Props) => { + const hasChildren = !!children + return ( - {icon && {icon}} + {icon && {icon}} {children} ); diff --git a/packages/ui/src/components/Checkbox/Checkbox.styles.ts b/packages/ui/src/components/Checkbox/Checkbox.styles.ts new file mode 100644 index 00000000..6914694b --- /dev/null +++ b/packages/ui/src/components/Checkbox/Checkbox.styles.ts @@ -0,0 +1,44 @@ +import styled from '@emotion/styled'; + +export const CheckboxContainer = styled.div<{ checked?: boolean; variant: 'main' | 'sub' }>` + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + + ${({ variant, checked, theme }) => { + switch (variant) { + case 'main': { + return ` + border: 1px solid ${checked ? theme.palette.grey.g90 : theme.palette.grey.g30}; + border-radius: 4px; + background-color: ${checked ? theme.palette.grey.g90 : 'transparent'}; + color: ${checked ? theme.palette.grey.w : theme.palette.grey.g30}; + + &:hover { + border-color: ${theme.palette.grey.g90}; + } + `; + } + case 'sub': { + return ` + color: ${checked ? theme.palette.grey.g90 : theme.palette.grey.g20}; + `; + } + } + }} +`; + +export const CheckboxInput = styled.input` + appearance: none; + width: 0; + height: 0; + position: absolute; + opacity: 0; +`; + +export default { + CheckboxContainer, + CheckboxInput, +}; diff --git a/packages/ui/src/components/Checkbox/index.tsx b/packages/ui/src/components/Checkbox/index.tsx new file mode 100644 index 00000000..bb48f815 --- /dev/null +++ b/packages/ui/src/components/Checkbox/index.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import Styled from './Checkbox.styles'; +import styled from '@emotion/styled'; + +const CheckIcon: React.FC = () => ( + + + +); + +interface CheckboxProps extends Omit, 'type'> { + variant: 'main' | 'sub'; +} + +const Checkbox: React.FC = ({ variant, ...props }) => { + const [uncontrolledChecked, setUncontrolledChecked] = useState(props.checked); + + const checked = props.checked ?? uncontrolledChecked; + + const changeHandler = (event: React.ChangeEvent) => { + setUncontrolledChecked((prev) => !prev); + props.onChange?.(event); + }; + + return ( + + + + + ); +}; + +const CheckboxLabel = styled.label` + &:hover ${Styled.CheckboxContainer} { + border-color: ${({ theme }) => theme.palette.grey.g90}; + } +`; + +const CheckboxComponent = Checkbox as React.FC & { + Label: typeof CheckboxLabel; +}; + +CheckboxComponent.Label = CheckboxLabel; + +export default CheckboxComponent; diff --git a/packages/ui/src/components/Dialog/Dialog.styles.ts b/packages/ui/src/components/Dialog/Dialog.styles.ts index 8785e666..e3c2088f 100644 --- a/packages/ui/src/components/Dialog/Dialog.styles.ts +++ b/packages/ui/src/components/Dialog/Dialog.styles.ts @@ -98,6 +98,12 @@ const DialogHeader = styled.div<{ mobileType: 'bottomSheet' | 'fullPage' | 'cent } `; +const DialogTitleContainer = styled.div` + display: flex; + align-items: center; + gap: 12px; +` + const DialogTitle = styled.h2` ${({ theme }) => theme.typo.sh1}; color: ${({ theme }) => theme.palette.grey.g70}; @@ -143,6 +149,39 @@ const DialogCloseButton = styled.button<{ mobileType: 'bottomSheet' | 'fullPage' } `; +const DialogBackButton = styled.button<{ mobileType: 'bottomSheet' | 'fullPage' | 'centerPopup' }>` + display: inline-flex; + justify-content: center; + align-items: center; + cursor: pointer; + color: ${({ theme }) => theme.palette.grey.g70}; + + svg { + width: 24px; + height: 24px; + } + + ${({ mobileType }) => + mobileType === 'bottomSheet' && + ` + top: 16px; + right: 24px; + `} + + ${({ mobileType }) => + mobileType === 'fullPage' && + ` + top: 20px; + left: 20px; + `} + + ${mq_lg} { + top: 17px; + right: 32px; + left: initial; + } +`; + const DialogContent = styled.div` padding: 0 24px; overflow-y: auto; @@ -156,7 +195,9 @@ export default { DimmedArea, Dialog, DialogHeader, + DialogTitleContainer, DialogTitle, DialogCloseButton, + DialogBackButton, DialogContent, }; diff --git a/packages/ui/src/components/Dialog/DialogBase.tsx b/packages/ui/src/components/Dialog/DialogBase.tsx new file mode 100644 index 00000000..eef06392 --- /dev/null +++ b/packages/ui/src/components/Dialog/DialogBase.tsx @@ -0,0 +1,23 @@ +import Portal from '../Portal'; +import { DialogProps } from './types'; +import Styled from './Dialog.styles'; + +type DialogBaseProps = Pick + +const DialogBase: React.FC = ({ children, onClose }) => { + return ( + + { + if (event.target === event.currentTarget) { + onClose?.(); + } + }} + > + {children} + + + ) +} + +export default DialogBase diff --git a/packages/ui/src/components/Dialog/DialogContent.tsx b/packages/ui/src/components/Dialog/DialogContent.tsx new file mode 100644 index 00000000..0fcdb8f3 --- /dev/null +++ b/packages/ui/src/components/Dialog/DialogContent.tsx @@ -0,0 +1,40 @@ +import { ChevronLeftIcon, CloseIcon } from '@boolti/icon'; + +import { DialogContentProps } from './types'; +import Styled from './Dialog.styles'; + +const DialogContent: React.FC = ({ + title, + isAuto = false, + width, + mobileType = 'bottomSheet', + contentPadding, + children, + onClose, + onClickBackButton +}) => { + return ( + + {title && onClose && ( + + + {onClickBackButton && ( + + + + )} + {title && {title}} + + + + + + )} + + {children} + + + ) +} + +export default DialogContent; diff --git a/packages/ui/src/components/Dialog/StepDialog.tsx b/packages/ui/src/components/Dialog/StepDialog.tsx new file mode 100644 index 00000000..d3e1bc52 --- /dev/null +++ b/packages/ui/src/components/Dialog/StepDialog.tsx @@ -0,0 +1,35 @@ +import { useState, useCallback } from 'react'; +import DialogBase from './DialogBase'; +import DialogContent from './DialogContent'; +import { StepDialogProps } from './types'; + +const StepDialog: React.FC = ({ initialHistory, ...props }) => { + const [history, setHistory] = useState(initialHistory); + const currentHistory = history.length > 0 ? history[history.length - 1] : null; + + const push = useCallback((nextStep: string) => { + setHistory((prev) => [...prev, nextStep]); + }, []); + + const back = useCallback(() => { + setHistory((prev) => { + if (prev.length <= 1) return prev; + return prev.slice(0, -1); + }); + }, []); + + if (!props.open || currentHistory === null) return null; + + const children = props.content[currentHistory].children; + const title = props.content[currentHistory].title; + + return ( + + 1 ? back : undefined}> + {children({ push, back })} + + + ); +}; + +export default StepDialog; diff --git a/packages/ui/src/components/Dialog/index.tsx b/packages/ui/src/components/Dialog/index.tsx index 80261df1..2f978596 100644 --- a/packages/ui/src/components/Dialog/index.tsx +++ b/packages/ui/src/components/Dialog/index.tsx @@ -1,55 +1,14 @@ -import { CloseIcon } from '@boolti/icon'; +import DialogBase from './DialogBase'; +import DialogContent from './DialogContent'; +import { DialogProps } from './types'; -import Portal from '../Portal'; -import Styled from './Dialog.styles'; - -interface DialogProps { - open: boolean; - children: React.ReactNode; - isAuto?: boolean; - width?: string; - title?: string; - contentPadding?: string; - mobileType?: 'bottomSheet' | 'fullPage' | 'centerPopup'; - onClose?: () => void; -} - -const Dialog = ({ - open, - children, - isAuto = false, - width, - title, - contentPadding, - mobileType = 'bottomSheet', - onClose, -}: DialogProps) => { - if (!open) return null; +const Dialog = (props: DialogProps) => { + if (!props.open) return null; return ( - - { - if (e.target === e.currentTarget) { - onClose?.(); - } - }} - > - - {title && onClose && ( - - {title && {title}} - - - - - )} - - {children} - - - - + + + ); }; diff --git a/packages/ui/src/components/Dialog/types.ts b/packages/ui/src/components/Dialog/types.ts new file mode 100644 index 00000000..80f325d7 --- /dev/null +++ b/packages/ui/src/components/Dialog/types.ts @@ -0,0 +1,35 @@ +interface DialogPropsBase { + open: boolean; + isAuto?: boolean; + width?: string; + title?: string; + contentPadding?: string; + mobileType?: 'bottomSheet' | 'fullPage' | 'centerPopup'; + onClose?: () => void; +} + +export interface DialogProps extends DialogPropsBase { + children: React.ReactNode; +} + +export interface StepDialogProps { + initialHistory: string[]; + content: Record void; + back: () => void; + }) => React.ReactNode + title?: string; + }>; + open: boolean; + isAuto?: boolean; + width?: string; + contentPadding?: string; + mobileType?: 'bottomSheet' | 'fullPage' | 'centerPopup'; + onClose?: () => void; +} + +export interface DialogContentProps extends DialogPropsBase { + children: React.ReactNode; + onClickBackButton?: () => void; +} diff --git a/packages/ui/src/components/DialogProvider/index.tsx b/packages/ui/src/components/DialogProvider/index.tsx index 4b7ed0d5..2cf4e061 100644 --- a/packages/ui/src/components/DialogProvider/index.tsx +++ b/packages/ui/src/components/DialogProvider/index.tsx @@ -1,35 +1,61 @@ import { useState } from 'react'; -import dialogContext, { IDialog } from '../../contexts/dialogContext'; +import dialogContext, { DialogListItem } from '../../contexts/dialogContext'; import Dialog from '../Dialog'; +import StepDialog from '../Dialog/StepDialog'; interface DialogProviderProps { children: React.ReactNode; } const DialogProvider = ({ children }: DialogProviderProps) => { - const [dialogList, setDialogList] = useState([]); + const [dialogList, setDialogList] = useState([]); return ( {children} - {dialogList.map((dialog) => ( - dialog.id === id)} - title={dialog.title} - isAuto={dialog.isAuto} - width={dialog.width} - contentPadding={dialog.contentPadding} - mobileType={dialog.mobileType} - onClose={() => { - dialog.onClose?.(); - setDialogList((prev) => prev.filter(({ id }) => dialog.id !== id)); - }} - > - {dialog.content} - - ))} + {dialogList.map((dialog) => { + switch (dialog.type) { + case 'default': { + return ( + dialog.id === id)} + title={dialog.title} + isAuto={dialog.isAuto} + width={dialog.width} + contentPadding={dialog.contentPadding} + mobileType={dialog.mobileType} + onClose={() => { + dialog.onClose?.(); + setDialogList((prev) => prev.filter(({ id }) => dialog.id !== id)); + }} + > + {dialog.content} + + ) + } + case 'funnel': { + return ( + dialog.id === id)} + isAuto={dialog.isAuto} + width={dialog.width} + contentPadding={dialog.contentPadding} + mobileType={dialog.mobileType} + onClose={() => { + dialog.onClose?.(); + setDialogList((prev) => prev.filter(({ id }) => dialog.id !== id)); + }} + /> + ) + } + } + } + )} ); }; diff --git a/packages/ui/src/components/ShowPreview/ShowInfoDetail.tsx b/packages/ui/src/components/ShowPreview/ShowInfoDetail.tsx index 1857a574..30b858f5 100644 --- a/packages/ui/src/components/ShowPreview/ShowInfoDetail.tsx +++ b/packages/ui/src/components/ShowPreview/ShowInfoDetail.tsx @@ -57,7 +57,10 @@ const ShowInfoDetail = ({ 일시 - {date} / {startTime}{runningTime}분 + + {date} / {startTime} + + {runningTime}분 @@ -111,10 +114,7 @@ const ShowInfoDetail = ({ 내용 {hasNoticePage && ( - + 전체보기 )} diff --git a/packages/ui/src/components/ShowPreview/ShowPreview.styles.ts b/packages/ui/src/components/ShowPreview/ShowPreview.styles.ts index 995c3499..55c89ccb 100644 --- a/packages/ui/src/components/ShowPreview/ShowPreview.styles.ts +++ b/packages/ui/src/components/ShowPreview/ShowPreview.styles.ts @@ -50,7 +50,7 @@ const ShowPreviewNavbar = styled.div` const LogoLink = styled.a` display: flex; align-items: center; - cursor: ${({ href }) => href ? 'pointer' : 'default'}; + cursor: ${({ href }) => (href ? 'pointer' : 'default')}; svg { width: 53px; @@ -243,7 +243,7 @@ const ShowInfoDescriptionBadge = styled.div` border-radius: 999px; position: relative; top: -1.5px; -` +`; const ShowInfoBox = styled.div` height: 56px; diff --git a/packages/ui/src/components/ShowPreview/index.tsx b/packages/ui/src/components/ShowPreview/index.tsx index 0e5a6549..b520ce19 100644 --- a/packages/ui/src/components/ShowPreview/index.tsx +++ b/packages/ui/src/components/ShowPreview/index.tsx @@ -53,7 +53,7 @@ const ShowPreview = ({ containerRef, onClickLink, onClickLinkMobile, - onClickShareButton + onClickShareButton, }: ShowPreviewProps) => { const { images, name } = show; @@ -119,7 +119,7 @@ const ShowPreview = ({ onClickLink={onClickLink} onClickLinkMobile={onClickLinkMobile} onClickViewNotice={() => { - containerScrollTop.current = containerRef?.current?.scrollTop ?? null + containerScrollTop.current = containerRef?.current?.scrollTop ?? null; containerRef?.current?.scrollTo(0, 0); setNoticeOpen(true); }} diff --git a/packages/ui/src/components/StepProgressBar/StepProgressBar.styles.ts b/packages/ui/src/components/StepProgressBar/StepProgressBar.styles.ts new file mode 100644 index 00000000..cc1066e9 --- /dev/null +++ b/packages/ui/src/components/StepProgressBar/StepProgressBar.styles.ts @@ -0,0 +1,60 @@ +import styled from '@emotion/styled'; + +const StepProgressBar = styled.div<{ width?: string }>` + display: flex; + flex-direction: column; + gap: 8px; + width: ${({ width }) => width || '100%'}; +`; + +const StepProgressBarLine = styled.div<{ step: number; maxStep: number }>` + position: relative; + width: 100%; + height: 4px; + flex: 1; + border-radius: 4px; + background-color: ${({ theme }) => theme.palette.grey.g10}; + overflow: hidden; + + &::after { + content: ''; + position: relative; + display: block; + top: 0; + left: 0; + width: calc(${({ step, maxStep }) => (step / maxStep) * 100}%); + height: 4px; + background-color: ${({ theme }) => theme.palette.grey.g90}; + transition: width 0.2s ease-in-out; + } +`; + +const StepProgressBarItemList = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const StepProgressBarItem = styled.div<{ active: boolean }>` + display: flex; + flex-direction: column; + align-items: center; + flex-wrap: nowrap; + gap: 4px; + ${({ theme }) => theme.typo.c1}; + color: ${({ theme }) => theme.palette.grey.g30}; + + ${({ theme, active }) => + active && + ` + color: ${theme.palette.grey.g90}; + font-weight: 600; + `} +`; + +export default { + StepProgressBar, + StepProgressBarLine, + StepProgressBarItemList, + StepProgressBarItem, +}; diff --git a/packages/ui/src/components/StepProgressBar/index.tsx b/packages/ui/src/components/StepProgressBar/index.tsx new file mode 100644 index 00000000..2789f769 --- /dev/null +++ b/packages/ui/src/components/StepProgressBar/index.tsx @@ -0,0 +1,31 @@ +import Styled from './StepProgressBar.styles'; + +interface StepProgressBarItem { + key: string; + title: string; +} + +interface StepProgressBarProps { + activeKey: string; + items: StepProgressBarItem[]; + width?: string; +} + +const StepProgressBar: React.FC = ({ activeKey, items, width }) => { + const step = items.findIndex((item) => item.key === activeKey) + 1; + + return ( + + + + {items.map((item, index) => ( + = index + 1}> + {item.title} + + ))} + + + ); +}; + +export default StepProgressBar; diff --git a/packages/ui/src/components/TextField/TextField.styles.ts b/packages/ui/src/components/TextField/TextField.styles.ts index 9d292144..5222dca8 100644 --- a/packages/ui/src/components/TextField/TextField.styles.ts +++ b/packages/ui/src/components/TextField/TextField.styles.ts @@ -47,7 +47,7 @@ const InputLabel = styled.label<{ hasError?: boolean; disabled?: boolean; hasVal padding: 12px 13px; color: ${({ theme }) => theme.palette.grey.g90}; border: 1px solid - ${({ hasError, theme }) => (hasError ? theme.palette.status.error : theme.palette.grey.g20)}; + ${({ hasError, theme }) => (hasError ? theme.palette.status.error1 : theme.palette.grey.g20)}; background: ${({ theme }) => theme.palette.grey.w}; ${({ theme }) => theme.typo.b3}; @@ -72,7 +72,7 @@ const Input = styled.input<{ hasError?: boolean }>` color: ${({ theme }) => theme.palette.grey.g90}; border: 1px solid ${({ hasError, theme }) => - hasError ? `${theme.palette.status.error} !important` : theme.palette.grey.g20}; + hasError ? `${theme.palette.status.error1} !important` : theme.palette.grey.g20}; background: ${({ theme }) => theme.palette.grey.w}; ${({ theme }) => theme.typo.b3}; &:placeholder-shown { @@ -136,7 +136,7 @@ const ButtonContainer = styled.div` const ErrorMessage = styled.span` margin-top: 4px; ${({ theme }) => theme.typo.b1}; - color: ${({ theme }) => theme.palette.status.error}; + color: ${({ theme }) => theme.palette.status.error1}; `; export default { diff --git a/packages/ui/src/components/TimePicker/TimePicker.styles.ts b/packages/ui/src/components/TimePicker/TimePicker.styles.ts index 8e5d63ae..880f3cdc 100644 --- a/packages/ui/src/components/TimePicker/TimePicker.styles.ts +++ b/packages/ui/src/components/TimePicker/TimePicker.styles.ts @@ -16,21 +16,21 @@ const Text = styled(TextField)` const Dimmed = styled.div` position: fixed; - z-index: 1; + z-index: 999; bottom: 0; left: 0; width: 100vw !important; height: 100vh; background-color: rgba(0, 0, 0, 0.25); ${mq_lg} { - display: none; + background-color: transparent; + position: absolute; } `; const Control = styled.div` position: fixed; width: 100vw !important; - z-index: 2; left: 0; bottom: 0; border-radius: 12px 12px 0px 0px; diff --git a/packages/ui/src/components/TimePicker/index.tsx b/packages/ui/src/components/TimePicker/index.tsx index 878f170e..70c1d6ec 100644 --- a/packages/ui/src/components/TimePicker/index.tsx +++ b/packages/ui/src/components/TimePicker/index.tsx @@ -89,7 +89,7 @@ function TimePicker({ disabled, errorMessage, value, onChange, onBlur }: Props) /> {open && ( - <> + 시간 선택하기 @@ -157,8 +157,7 @@ function TimePicker({ disabled, errorMessage, value, onChange, onBlur }: Props) - - + )} diff --git a/packages/ui/src/components/Toast/Toast.styles.ts b/packages/ui/src/components/Toast/Toast.styles.ts index e3926eb7..9e56b19b 100644 --- a/packages/ui/src/components/Toast/Toast.styles.ts +++ b/packages/ui/src/components/Toast/Toast.styles.ts @@ -3,14 +3,13 @@ import styled from '@emotion/styled'; const Toast = styled.div` display: flex; justify-content: center; - align-items: center; gap: 8px; padding: 16px; `; const Icon = styled.div` display: inline-flex; - align-items: center; + margin-top: 1px; `; const Message = styled.div` diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 1204437c..f0e04946 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -12,6 +12,9 @@ import ShowPreview from './ShowPreview'; import TextButton from './TextButton'; import TextField from './TextField'; import TimePicker from './TimePicker'; +import StepProgressBar from './StepProgressBar'; +import Checkbox from './Checkbox'; +import StepDialog from './Dialog/StepDialog'; export { AgreeCheck, @@ -28,4 +31,7 @@ export { TextButton, TextField, TimePicker, + StepProgressBar, + Checkbox, + StepDialog }; diff --git a/packages/ui/src/contexts/dialogContext.ts b/packages/ui/src/contexts/dialogContext.ts index 769dc224..c0c57d96 100644 --- a/packages/ui/src/contexts/dialogContext.ts +++ b/packages/ui/src/contexts/dialogContext.ts @@ -1,19 +1,37 @@ import { createContext } from 'react'; -export interface IDialog { +interface DialogListItemBase { id: string; - content: React.ReactNode; isAuto?: boolean; - title?: string; width?: string; contentPadding?: string; mobileType?: 'bottomSheet' | 'fullPage' | 'centerPopup'; onClose?: () => void; } +interface DefaultDialogListItem extends DialogListItemBase { + type: 'default' + content: React.ReactNode; + title?: string +} + +interface StepDialogListItem extends DialogListItemBase { + type: 'funnel' + content: Record void; + back: () => void; + }) => React.ReactNode + title?: string + }> + initialHistory: string[] +} + +export type DialogListItem = DefaultDialogListItem | StepDialogListItem + interface DialogContext { - dialogList: IDialog[]; - setDialogList: React.Dispatch>; + dialogList: DialogListItem[]; + setDialogList: React.Dispatch>; } const dialogContext = createContext(null); diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 801fa1d5..62996cad 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -3,5 +3,6 @@ import useDialog from './useDialog'; import useDropdown from './useDropdown'; import useToast from './useToast'; import useAlert from './useAlert'; +import useStepDialog from './useStepDialog'; -export { useConfirm, useDialog, useDropdown, useToast, useAlert }; +export { useConfirm, useDialog, useDropdown, useToast, useAlert, useStepDialog }; diff --git a/packages/ui/src/hooks/useDialog.ts b/packages/ui/src/hooks/useDialog.ts index 3411ec92..4e2afc53 100644 --- a/packages/ui/src/hooks/useDialog.ts +++ b/packages/ui/src/hooks/useDialog.ts @@ -1,7 +1,7 @@ import { nanoid } from 'nanoid'; import { useCallback, useContext, useRef, useState } from 'react'; -import dialogContext, { IDialog } from '../contexts/dialogContext'; +import dialogContext, { DialogListItem } from '../contexts/dialogContext'; const useDialog = () => { const id = useRef(nanoid(6)); @@ -27,7 +27,8 @@ const useDialog = () => { mobileType?: 'bottomSheet' | 'fullPage' | 'centerPopup'; onClose?: () => void; }) => { - const newDialog: IDialog = { + const newDialog: DialogListItem = { + type: 'default', id: id.current, content, title, diff --git a/packages/ui/src/hooks/useStepDialog.ts b/packages/ui/src/hooks/useStepDialog.ts new file mode 100644 index 00000000..93d7f511 --- /dev/null +++ b/packages/ui/src/hooks/useStepDialog.ts @@ -0,0 +1,65 @@ +import { nanoid } from 'nanoid'; +import { useCallback, useContext, useRef, useState } from 'react'; +import dialogContext, { DialogListItem } from '../contexts/dialogContext'; + +const useStepDialog = () => { + const id = useRef(nanoid(6)); + const [isOpen, setIsOpen] = useState(false); + + const context = useContext(dialogContext); + + const open = useCallback( + ({ + initialHistory, + content, + isAuto, + width, + contentPadding, + mobileType, + onClose, + }: { + initialHistory: T[]; + content: Record void; + back: () => void; + }) => React.ReactNode; + title?: string + }>; + isAuto?: boolean; + width?: string; + contentPadding?: string; + mobileType?: 'bottomSheet' | 'fullPage' | 'centerPopup'; + onClose?: () => void; + }) => { + const newDialog: DialogListItem = { + type: 'funnel', + initialHistory, + id: id.current, + content, + isAuto, + width, + contentPadding, + mobileType, + onClose, + }; + setIsOpen(true); + context?.setDialogList((prev) => [...prev, newDialog]); + }, + [context, id], + ); + + const close = useCallback(() => { + setIsOpen(false); + context?.setDialogList((prev) => prev.filter((dialog) => dialog.id !== id.current)); + }, [context, id]); + + return { + id: id.current, + isOpen, + open, + close, + }; +}; + +export default useStepDialog; diff --git a/packages/ui/src/systems/palette.ts b/packages/ui/src/systems/palette.ts index 32609f04..cbd9392d 100644 --- a/packages/ui/src/systems/palette.ts +++ b/packages/ui/src/systems/palette.ts @@ -6,7 +6,9 @@ const palette = { o3: '#C93E02', }, status: { - error: '#FF4D4F', + error1: '#FF4D4F', + error2: '#EC2C2E', + error3: '#DE0609', warning: '#FAAD14', success: '#52C41A', link: '#1890FF', diff --git a/yarn.lock b/yarn.lock index 3ab0c8b9..6577db4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1554,6 +1554,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.17.2": + version: 7.26.0 + resolution: "@babel/runtime@npm:7.26.0" + dependencies: + regenerator-runtime: "npm:^0.14.0" + checksum: 10c0/12c01357e0345f89f4f7e8c0e81921f2a3e3e101f06e8eaa18a382b517376520cd2fa8c237726eb094dab25532855df28a7baaf1c26342b52782f6936b07c287 + languageName: node + linkType: hard + "@babel/runtime@npm:^7.20.13": version: 7.24.7 resolution: "@babel/runtime@npm:7.24.7" @@ -1663,6 +1672,7 @@ __metadata: version: 0.0.0-use.local resolution: "@boolti/api@workspace:packages/api" dependencies: + "@boolti/bridge": "npm:*" "@boolti/eslint-config": "npm:*" "@boolti/typescript-config": "npm:*" "@emotion/react": "npm:^11.11.3" @@ -1671,6 +1681,7 @@ __metadata: "@tanstack/react-query": "npm:^4.32.6" "@types/react": "npm:^18.2.43" "@types/react-dom": "npm:^18.2.17" + async-mutex: "npm:^0.5.0" image-resize: "npm:^1.3.2" ky: "npm:^1.2.0" react: "npm:^18.2.0" @@ -1679,6 +1690,19 @@ __metadata: languageName: unknown linkType: soft +"@boolti/bridge@npm:*, @boolti/bridge@workspace:packages/bridge": + version: 0.0.0-use.local + resolution: "@boolti/bridge@workspace:packages/bridge" + dependencies: + "@boolti/eslint-config": "npm:*" + "@boolti/typescript-config": "npm:*" + "@types/react": "npm:^18.2.43" + "@types/react-dom": "npm:^18.2.17" + typescript: "npm:^5.2.2" + uuid: "npm:^11.0.3" + languageName: unknown + linkType: soft + "@boolti/eslint-config@npm:*, @boolti/eslint-config@workspace:packages/config-eslint": version: 0.0.0-use.local resolution: "@boolti/eslint-config@workspace:packages/config-eslint" @@ -5441,6 +5465,7 @@ __metadata: resolution: "admin@workspace:apps/admin" dependencies: "@boolti/api": "npm:*" + "@boolti/bridge": "npm:*" "@boolti/eslint-config": "npm:*" "@boolti/icon": "npm:*" "@boolti/typescript-config": "npm:*" @@ -5462,7 +5487,6 @@ __metadata: date-fns: "npm:^3.3.1" framer-motion: "npm:^11.2.10" jotai: "npm:^2.8.3" - js-cookie: "npm:^3.0.5" jwt-decode: "npm:^4.0.0" lodash.debounce: "npm:^4.0.8" qrcode.react: "npm:^3.1.0" @@ -5470,6 +5494,7 @@ __metadata: react-daum-postcode: "npm:^3.1.3" react-dom: "npm:^18.2.0" react-dropzone: "npm:^14.2.3" + react-error-boundary: "npm:^4.1.2" react-hook-form: "npm:^7.50.0" react-intersection-observer: "npm:^9.8.0" react-pdf: "npm:^9.0.0" @@ -5478,6 +5503,7 @@ __metadata: react-tooltip: "npm:^5.26.3" the-new-css-reset: "npm:^1.11.2" typescript: "npm:^5.2.2" + vconsole: "npm:^3.15.1" vite: "npm:^5.0.8" languageName: unknown linkType: soft @@ -5843,6 +5869,15 @@ __metadata: languageName: node linkType: hard +"async-mutex@npm:^0.5.0": + version: 0.5.0 + resolution: "async-mutex@npm:0.5.0" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/9096e6ad6b674c894d8ddd5aa4c512b09bb05931b8746ebd634952b05685608b2b0820ed5c406e6569919ff5fe237ab3c491e6f2887d6da6b6ba906db3ee9c32 + languageName: node + linkType: hard + "async-validator@npm:^4.1.0": version: 4.2.5 resolution: "async-validator@npm:4.2.5" @@ -6602,6 +6637,13 @@ __metadata: languageName: node linkType: hard +"copy-text-to-clipboard@npm:^3.0.1": + version: 3.2.0 + resolution: "copy-text-to-clipboard@npm:3.2.0" + checksum: 10c0/d60fdadc59d526e19d56ad23cec2b292d33c771a5091621bd322d138804edd3c10eb2367d46ec71b39f5f7f7116a2910b332281aeb36a5b679199d746a8a5381 + languageName: node + linkType: hard + "copy-to-clipboard@npm:^3.3.3": version: 3.3.3 resolution: "copy-to-clipboard@npm:3.3.3" @@ -6620,6 +6662,13 @@ __metadata: languageName: node linkType: hard +"core-js@npm:^3.11.0": + version: 3.39.0 + resolution: "core-js@npm:3.39.0" + checksum: 10c0/f7602069b6afb2e3298eec612a5c1e0c3e6a458930fbfc7a4c5f9ac03426507f49ce395eecdd2d9bae9024f820e44582b67ffe16f2272395af26964f174eeb6b + languageName: node + linkType: hard + "core-util-is@npm:~1.0.0": version: 1.0.3 resolution: "core-util-is@npm:1.0.3" @@ -9367,13 +9416,6 @@ __metadata: languageName: node linkType: hard -"js-cookie@npm:^3.0.5": - version: 3.0.5 - resolution: "js-cookie@npm:3.0.5" - checksum: 10c0/04a0e560407b4489daac3a63e231d35f4e86f78bff9d792011391b49c59f721b513411cd75714c418049c8dc9750b20fcddad1ca5a2ca616c3aca4874cce5b3a - languageName: node - linkType: hard - "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -10157,6 +10199,13 @@ __metadata: languageName: node linkType: hard +"mutation-observer@npm:^1.0.3": + version: 1.0.3 + resolution: "mutation-observer@npm:1.0.3" + checksum: 10c0/2f010fdec4b860a6576558013bcaa691c4912e287ea1dc99ea3b9360b52586267b291e7a2a88c0f2a9b399b4ef1e116ce8c0f839f88d2d7c9b4323fb0badc321 + languageName: node + linkType: hard + "nan@npm:^2.17.0": version: 2.20.0 resolution: "nan@npm:2.20.0" @@ -11840,6 +11889,17 @@ __metadata: languageName: node linkType: hard +"react-error-boundary@npm:^4.1.2": + version: 4.1.2 + resolution: "react-error-boundary@npm:4.1.2" + dependencies: + "@babel/runtime": "npm:^7.12.5" + peerDependencies: + react: ">=16.13.1" + checksum: 10c0/0737e5259bed40ce14eb0823b3c7b152171921f2179e604f48f3913490cdc594d6c22d43d7abb4ffb1512c832850228db07aa69d3b941db324953a5e393cb399 + languageName: node + linkType: hard + "react-fast-compare@npm:^3.2.2": version: 3.2.2 resolution: "react-fast-compare@npm:3.2.2" @@ -13987,6 +14047,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^11.0.3": + version: 11.0.3 + resolution: "uuid@npm:11.0.3" + bin: + uuid: dist/esm/bin/uuid + checksum: 10c0/cee762fc76d949a2ff9205770334699e0043d52bb766472593a25f150077c9deed821230251ea3d6ab3943a5ea137d2826678797f1d5f6754c7ce5ce27e9f7a6 + languageName: node + linkType: hard + "uuid@npm:^9.0.0": version: 9.0.1 resolution: "uuid@npm:9.0.1" @@ -14013,6 +14082,18 @@ __metadata: languageName: node linkType: hard +"vconsole@npm:^3.15.1": + version: 3.15.1 + resolution: "vconsole@npm:3.15.1" + dependencies: + "@babel/runtime": "npm:^7.17.2" + copy-text-to-clipboard: "npm:^3.0.1" + core-js: "npm:^3.11.0" + mutation-observer: "npm:^1.0.3" + checksum: 10c0/1e62132b719e324eb7d533c94f38e9db288a9d0c9e85c8752ba742adee4e5925df10d4b43d05ba0cd264d99c32817f9f9c8f24fe391a7d8837469bd318d1b2ac + languageName: node + linkType: hard + "vite-compatible-readable-stream@npm:^3.6.1": version: 3.6.1 resolution: "vite-compatible-readable-stream@npm:3.6.1"