diff --git a/.nvmrc 2 b/.nvmrc 2 deleted file mode 100644 index 9d13aae7..00000000 --- a/.nvmrc 2 +++ /dev/null @@ -1 +0,0 @@ -20.0.0 \ No newline at end of file diff --git a/.storybook/main.ts b/.storybook/main.ts index dad99ec2..b9a537d3 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,6 +1,7 @@ import type { StorybookConfig } from '@storybook/react-vite'; import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; import svgr from 'vite-plugin-svgr'; +import path from 'node:path'; const config: StorybookConfig = { stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], @@ -22,6 +23,21 @@ const config: StorybookConfig = { ...(config.optimizeDeps || {}), include: ['@vanilla-extract/css'], }; + config.resolve = { + ...(config.resolve || {}), + alias: { + ...(config.resolve?.alias || {}), + '@': path.resolve(__dirname, '../src'), + '@api': path.resolve(__dirname, '../src/api'), + '@assets': path.resolve(__dirname, '../src/assets'), + '@common': path.resolve(__dirname, '../src/common'), + '@page': path.resolve(__dirname, '../src/page'), + '@route': path.resolve(__dirname, '../src/route'), + '@shared': path.resolve(__dirname, '../src/shared'), + '@style': path.resolve(__dirname, '../src/style'), + '@type': path.resolve(__dirname, '../src/type'), + }, + }; return config; }, }; diff --git a/.storybook/preview.ts b/.storybook/preview.ts index cbd88a98..c3c58e58 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,6 +1,11 @@ import type { Preview } from '@storybook/react'; -import '../src/style/reset.css'; -import '../src/style/global.css'; +import React from 'react'; +import { OverlayProvider } from 'overlay-kit'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import '../src/style/reset.css.ts'; +import '../src/style/global.css.ts'; + +const queryClient = new QueryClient(); const preview: Preview = { parameters: { @@ -31,6 +36,14 @@ const preview: Preview = { test: 'todo', }, }, + decorators: [ + (Story) => + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(OverlayProvider, null, React.createElement(Story)), + ), + ], }; export default preview; diff --git a/package.json b/package.json index 48a59157..e75a1701 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "clsx": "^2.1.1", "lottie-react": "^2.4.1", "openapi-typescript": "^7.8.0", + "overlay-kit": "^1.8.5", "react": "^19.1.0", "react-dom": "^19.1.0", "react-redux": "^9.2.0", @@ -43,6 +44,7 @@ "@storybook/preview-api": "^8.6.14", "@storybook/react": "^9.0.15", "@storybook/react-vite": "^9.0.15", + "@storybook/test": "8.6.14", "@storybook/types": "^8.6.14", "@tanstack/react-query-devtools": "^5.81.5", "@types/react": "^19.1.2", @@ -75,4 +77,4 @@ "vitest": "^3.2.4" }, "packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184" -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73b90ac9..9caf4fb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: openapi-typescript: specifier: ^7.8.0 version: 7.8.0(typescript@5.8.3) + overlay-kit: + specifier: ^1.8.5 + version: 1.8.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: ^19.1.0 version: 19.1.0 @@ -75,6 +78,9 @@ importers: '@storybook/react-vite': specifier: ^9.0.15 version: 9.0.16(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.45.0)(storybook@9.0.16(@testing-library/dom@10.4.0)(prettier@3.6.2))(typescript@5.8.3)(vite@4.5.0(@types/node@24.0.13)) + '@storybook/test': + specifier: 8.6.14 + version: 8.6.14(storybook@9.0.16(@testing-library/dom@10.4.0)(prettier@3.6.2)) '@storybook/types': specifier: ^8.6.14 version: 8.6.14(storybook@9.0.16(@testing-library/dom@10.4.0)(prettier@3.6.2)) @@ -937,6 +943,11 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + '@storybook/instrumenter@8.6.14': + resolution: {integrity: sha512-iG4MlWCcz1L7Yu8AwgsnfVAmMbvyRSk700Mfy2g4c8y5O+Cv1ejshE1LBBsCwHgkuqU0H4R0qu4g23+6UnUemQ==} + peerDependencies: + storybook: ^8.6.14 + '@storybook/preview-api@8.6.14': resolution: {integrity: sha512-2GhcCd4dNMrnD7eooEfvbfL4I83qAqEyO0CO7JQAmIO6Rxb9BsOLLI/GD5HkvQB73ArTJ+PT50rfaO820IExOQ==} peerDependencies: @@ -977,6 +988,11 @@ packages: typescript: optional: true + '@storybook/test@8.6.14': + resolution: {integrity: sha512-GkPNBbbZmz+XRdrhMtkxPotCLOQ1BaGNp/gFZYdGDk2KmUWBKmvc5JxxOhtoXM2703IzNFlQHSSNnhrDZYuLlw==} + peerDependencies: + storybook: ^8.6.14 + '@storybook/types@8.6.14': resolution: {integrity: sha512-33kzHZa7h6/EygeLZDcm1PNRTlybokz8dzAh2JYjpETf77pG8jhPmEfrI2oHSAdgNeK7A3OMcGA/EwEN7EJdzw==} peerDependencies: @@ -1146,10 +1162,20 @@ packages: resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} + '@testing-library/jest-dom@6.5.0': + resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/jest-dom@6.6.3': resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/user-event@14.5.2': + resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@testing-library/user-event@14.6.1': resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} engines: {node: '>=12', npm: '>=6'} @@ -1325,6 +1351,9 @@ packages: '@vitest/browser': optional: true + '@vitest/expect@2.0.5': + resolution: {integrity: sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -1339,6 +1368,12 @@ packages: vite: optional: true + '@vitest/pretty-format@2.0.5': + resolution: {integrity: sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==} + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} @@ -1348,9 +1383,18 @@ packages: '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/spy@2.0.5': + resolution: {integrity: sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/utils@2.0.5': + resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} @@ -2561,6 +2605,12 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + overlay-kit@1.8.5: + resolution: {integrity: sha512-BNtfOsfMNzo22x+uMNtV8nix53VTbA0XMENtwdvaEUmtXCjUE6go2xK4XSlNnSGyQfmB6c04uUCuFQtS5ZAAEQ==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 + react-dom: ^16.8 || ^17 || ^18 || ^19 + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -3034,10 +3084,18 @@ packages: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.3: resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} engines: {node: '>=14.0.0'} @@ -3982,6 +4040,12 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + '@storybook/instrumenter@8.6.14(storybook@9.0.16(@testing-library/dom@10.4.0)(prettier@3.6.2))': + dependencies: + '@storybook/global': 5.0.0 + '@vitest/utils': 2.1.9 + storybook: 9.0.16(@testing-library/dom@10.4.0)(prettier@3.6.2) + '@storybook/preview-api@8.6.14(storybook@9.0.16(@testing-library/dom@10.4.0)(prettier@3.6.2))': dependencies: storybook: 9.0.16(@testing-library/dom@10.4.0)(prettier@3.6.2) @@ -4028,6 +4092,17 @@ snapshots: optionalDependencies: typescript: 5.8.3 + '@storybook/test@8.6.14(storybook@9.0.16(@testing-library/dom@10.4.0)(prettier@3.6.2))': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/instrumenter': 8.6.14(storybook@9.0.16(@testing-library/dom@10.4.0)(prettier@3.6.2)) + '@testing-library/dom': 10.4.0 + '@testing-library/jest-dom': 6.5.0 + '@testing-library/user-event': 14.5.2(@testing-library/dom@10.4.0) + '@vitest/expect': 2.0.5 + '@vitest/spy': 2.0.5 + storybook: 9.0.16(@testing-library/dom@10.4.0)(prettier@3.6.2) + '@storybook/types@8.6.14(storybook@9.0.16(@testing-library/dom@10.4.0)(prettier@3.6.2))': dependencies: storybook: 9.0.16(@testing-library/dom@10.4.0)(prettier@3.6.2) @@ -4180,6 +4255,16 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 + '@testing-library/jest-dom@6.5.0': + dependencies: + '@adobe/css-tools': 4.4.3 + aria-query: 5.3.2 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + redent: 3.0.0 + '@testing-library/jest-dom@6.6.3': dependencies: '@adobe/css-tools': 4.4.3 @@ -4190,6 +4275,10 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 + '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': + dependencies: + '@testing-library/dom': 10.4.0 + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)': dependencies: '@testing-library/dom': 10.4.0 @@ -4477,6 +4566,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@2.0.5': + dependencies: + '@vitest/spy': 2.0.5 + '@vitest/utils': 2.0.5 + chai: 5.2.1 + tinyrainbow: 1.2.0 + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.2 @@ -4501,6 +4597,14 @@ snapshots: optionalDependencies: vite: 6.3.5(@types/node@24.0.13) + '@vitest/pretty-format@2.0.5': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -4517,10 +4621,27 @@ snapshots: magic-string: 0.30.17 pathe: 2.0.3 + '@vitest/spy@2.0.5': + dependencies: + tinyspy: 3.0.2 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.3 + '@vitest/utils@2.0.5': + dependencies: + '@vitest/pretty-format': 2.0.5 + estree-walker: 3.0.3 + loupe: 3.1.4 + tinyrainbow: 1.2.0 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.1.4 + tinyrainbow: 1.2.0 + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 @@ -5873,6 +5994,11 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + overlay-kit@1.8.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -6414,8 +6540,12 @@ snapshots: tinypool@1.1.1: {} + tinyrainbow@1.2.0: {} + tinyrainbow@2.0.0: {} + tinyspy@3.0.2: {} + tinyspy@4.0.3: {} to-regex-range@5.0.1: diff --git a/src/common/component/AiFailModal/AiFailModal.css.ts b/src/common/component/AiFailModal/AiFailModal.css.ts deleted file mode 100644 index 24dbc004..00000000 --- a/src/common/component/AiFailModal/AiFailModal.css.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { style } from '@vanilla-extract/css'; - -import { colors } from '@/style/token/color.css'; -import { fonts } from '@/style/token/typography.css'; - -export const modalContainer = style({ - display: 'inline-flex', - padding: '4rem', - flexDirection: 'column', - alignItems: 'center', - borderRadius: '16px', - background: colors.grey3, -}); - -export const contentWrapper = style({ - width: '55.6rem', - display: 'flex', - flexDirection: 'column', -}); - -export const iconWrapper = style({ - marginLeft: 'auto', -}); - -export const closeIcon = style({ - width: '3.2rem', - height: '3.2rem', - cursor: 'pointer', -}); - -export const textWrapper = style({ - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: '1.6rem', -}); - -export const title = style({ - color: colors.grey11, - textAlign: 'center', - ...fonts.display02, -}); - -export const description = style({ - color: colors.grey7, - textAlign: 'center', - ...fonts.subtitle06, -}); - -export const buttonWrapper = style({ - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - marginTop: '5rem', -}); diff --git a/src/common/component/AiFailModal/AiFailModal.stories.tsx b/src/common/component/AiFailModal/AiFailModal.stories.tsx index c9c0d414..d502c75d 100644 --- a/src/common/component/AiFailModal/AiFailModal.stories.tsx +++ b/src/common/component/AiFailModal/AiFailModal.stories.tsx @@ -2,6 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import AiFailModal from './AiFailModal'; +import { useOverlayModal } from '@/common/hook/useOverlayModal'; + const meta = { title: 'Common/AiFailModal', component: AiFailModal, @@ -9,6 +11,9 @@ const meta = { layout: 'centered', }, tags: ['autodocs'], + argTypes: { + onClose: { action: 'closed' }, + }, } satisfies Meta; export default meta; @@ -17,5 +22,28 @@ type Story = StoryObj; export const Default: Story = { args: { onClose: () => {}, + message: '다시 한 번 시도해주세요.', + }, +}; + +export const InOverlay: Story = { + render: (args) => { + const Demo = () => { + const { openModal } = useOverlayModal(); + return ( + + ); + }; + return ; + }, + args: { + onClose: () => {}, + message: '다시 한 번 시도해주세요.', }, }; diff --git a/src/common/component/AiFailModal/AiFailModal.tsx b/src/common/component/AiFailModal/AiFailModal.tsx index fbdbfee2..8cd3aa4e 100644 --- a/src/common/component/AiFailModal/AiFailModal.tsx +++ b/src/common/component/AiFailModal/AiFailModal.tsx @@ -1,17 +1,27 @@ +import AiModalBase from '@/common/component/AiModalBase/AiModalBase'; +import * as modalStyles from '@/common/component/AiModalBase/AiModalBase.css'; import Button from '@/common/component/Button/Button'; -import Modal from '@/common/component/Modal/Modal'; interface AiFailModalProps { onClose: () => void; message?: string; } -const AiFailModal = ({ onClose, message = '다시 한 번 시도해주세요.' }: AiFailModalProps) => ( - -

AI 추천 실패

-

{message}

- + +
+
+

+ {title} +

+ {description ? ( +

{description}

+ ) : null} +
+ {children} + {footer ?
{footer}
: null} +
+ + ); +}; + +export default AiModalBase; diff --git a/src/common/component/AiRecommendModal/AiRecommendModal.css.ts b/src/common/component/AiRecommendModal/AiRecommendModal.css.ts index 840b6548..90a57860 100644 --- a/src/common/component/AiRecommendModal/AiRecommendModal.css.ts +++ b/src/common/component/AiRecommendModal/AiRecommendModal.css.ts @@ -1,84 +1,18 @@ import { style } from '@vanilla-extract/css'; -import { colors, fonts, zIndex } from '@/style/token'; - -export const container = style({ - display: 'inline-flex', - padding: '4rem', - flexDirection: 'column', - alignItems: 'center', - borderRadius: '16px', - background: colors.grey3, - zIndex: zIndex.modal, -}); - -export const contentWrapper = style({ - width: '55.6rem', - display: 'flex', - flexDirection: 'column', - padding: '0 9.5rem', -}); - -export const iconWrapper = style({ - display: 'flex', - justifyContent: 'flex-end', - alignItems: 'center', - alignSelf: 'flex-end', -}); - -export const closeIcon = style({ - width: '3.2rem', - height: '3.2rem', - cursor: 'pointer', -}); - -export const title = style({ - color: colors.white01, - textAlign: 'center', - ...fonts.display02, -}); - -export const subtitle = style({ - color: colors.grey6, - textAlign: 'center', - ...fonts.subtitle06, - marginTop: '0.9rem', -}); +import { colors, layout } from '@/style/token'; export const highlight = style({ color: colors.white01, }); -export const listWrapper = style({ - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - gap: '2rem', - marginTop: '3.9rem', - marginBottom: '5rem', -}); - -export const listItem = style({ - display: 'flex', - alignItems: 'center', - gap: '0.6rem', - color: colors.white01, - ...fonts.body02, - cursor: 'pointer', - whiteSpace: 'nowrap', -}); +export const listWrapper = style([ + layout.flexColumn, + { + alignItems: 'flex-start', + gap: '2rem', + marginTop: '3.9rem', + }, +]); -export const listItemDisabled = style({ - cursor: 'not-allowed', -}); - -export const checkboxIcon = style({ - width: '2.4rem', - height: '2.4rem', -}); - -export const buttonWrapper = style({ - display: 'flex', - justifyContent: 'center', - alignItems: 'center', -}); +export const buttonWrapper = style([layout.flexCenter]); diff --git a/src/common/component/AiRecommendModal/AiRecommendModal.stories.tsx b/src/common/component/AiRecommendModal/AiRecommendModal.stories.tsx index 9c851010..b3b3a25c 100644 --- a/src/common/component/AiRecommendModal/AiRecommendModal.stories.tsx +++ b/src/common/component/AiRecommendModal/AiRecommendModal.stories.tsx @@ -3,6 +3,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import AiRecommendModal from './AiRecommendModal'; +import { useOverlayModal } from '@/common/hook/useOverlayModal'; + const queryClient = new QueryClient(); const meta = { @@ -28,9 +30,32 @@ type Story = StoryObj; export const Default: Story = { args: { onClose: () => {}, - onSubmit: (selected) => console.log('Selected options:', selected), - values: [], - options: ['옵션1', '옵션2'], - mandalartId: 1, + onSubmit: (goals) => console.log('Selected goals:', goals), + values: ['', '', '', '', '', '', '', ''], + options: ['옵션1', '옵션2', '옵션3', '옵션4'], + }, +}; + +export const InOverlay: Story = { + render: (args) => { + const Demo = () => { + const { openModal } = useOverlayModal(); + return ( + + ); + }; + return ; + }, + args: { + onSubmit: (goals) => console.log('Selected:', goals), + values: ['', '', '', '', '', '', '', ''], + options: ['추천1', '추천2', '추천3', '추천4'], + onClose: () => {}, }, }; diff --git a/src/common/component/AiRecommendModal/AiRecommendModal.tsx b/src/common/component/AiRecommendModal/AiRecommendModal.tsx index 785a5a31..19f6320e 100644 --- a/src/common/component/AiRecommendModal/AiRecommendModal.tsx +++ b/src/common/component/AiRecommendModal/AiRecommendModal.tsx @@ -2,46 +2,31 @@ import { useState } from 'react'; import * as styles from './AiRecommendModal.css'; import Button from '../Button/Button'; +import SelectableOption from '../SelectableOption/SelectableOption'; -import { usePostAiRecommendToCoreGoals } from '@/api/domain/upperTodo/hook'; -import { IcModalDelete, IcCheckboxDefault, IcCheckboxChecked } from '@/assets/svg'; +import AiModalBase from '@/common/component/AiModalBase/AiModalBase'; interface AiRecommendModalProps { onClose: () => void; - onSubmit: (aiResponseData: { id: number; position: number; title: string }[]) => void; - values: string[]; - options?: string[]; - mandalartId?: number; + onSubmit: (goals: { title: string }[]) => void; + values: readonly string[]; + options?: readonly string[]; } -const AiRecommendModal = ({ - onClose, - onSubmit, - values, - options, - mandalartId = 0, // 기본값 설정 -}: AiRecommendModalProps) => { +const TEXT = { + title: 'AI가 추천해 준 할 일이에요!', + subtitlePre: '앞으로 ', + subtitleSuf: '를 더 선택할 수 있어요', + confirmButton: '내 만다라트에 넣기', +} as const; + +const AiRecommendModal = ({ onClose, onSubmit, values, options }: AiRecommendModalProps) => { const [selectedOptions, setSelectedOptions] = useState([]); - const postRecommend = usePostAiRecommendToCoreGoals(); const emptyCount = values.filter((v) => v.trim() === '').length; const remainingSelections = emptyCount - selectedOptions.length; - const displayOptions = - options && options.length > 0 - ? options - : options === undefined - ? [ - '와 이거 진짜같은데 와이거 진짜같은데 와 이거 진짜1', - '와 이거 진짜같은데 와이거 진짜같은데 와 이거 진짜2', - '와 이거 진짜같은데 와이거 진짜같은데 와 이거 진짜3', - '와 이거 진짜같은데 와이거 진짜같은데 와 이거 진짜4', - '와 이거 진짜같은데 와이거 진짜같은데 와 이거 진짜5', - '와 이거 진짜같은데 와이거 진짜같은데 와 이거 진짜6', - '와 이거 진짜같은데 와이거 진짜같은데 와 이거 진짜7', - '와 이거 진짜같은데 와이거 진짜같은데 와 이거 진짜8', - ] - : []; + const displayOptions = Array.isArray(options) && options.length > 0 ? options : []; const toggleOption = (option: string) => { setSelectedOptions((prev) => @@ -50,75 +35,42 @@ const AiRecommendModal = ({ }; const handleClick = () => { - const goals = selectedOptions.slice(0, emptyCount); - - if (mandalartId && mandalartId > 0) { - postRecommend.mutate( - { mandalartId, goals }, - { - onSuccess: (response) => { - const aiResponseData = response.coreGoals; - onSubmit(aiResponseData); - onClose(); - }, - onError: (error) => { - console.error('AI 추천 목표 저장 실패:', error); - }, - }, - ); - } else { - const mockAiResponseData = goals.map((title, index) => ({ - id: Date.now() + index, // 임시 ID - position: index + 1, - title, - })); - onSubmit(mockAiResponseData); - onClose(); - } + const titles = selectedOptions.slice(0, emptyCount); + const goals = titles.map((title) => ({ title })); + onSubmit(goals); + onClose(); }; return ( -
-
- -
-
- -

- 앞으로 {remainingSelections}개를 더 선택할 수 - 있어요 -

-
- {displayOptions.map((option) => { - const isChecked = selectedOptions.includes(option); - const isDisabled = !isChecked && selectedOptions.length >= emptyCount; - const CheckIcon = isChecked ? IcCheckboxChecked : IcCheckboxDefault; - - return ( -
{ - if (!isDisabled) { - toggleOption(option); - } - }} - > - - {option} -
- ); - })} -
-
-
+ + {TEXT.subtitlePre} + {remainingSelections}개 + {TEXT.subtitleSuf} + + } + titleId="modal-title" + footer={
+ ); }; diff --git a/src/common/component/Button/Button 2.tsx b/src/common/component/Button/Button 2.tsx deleted file mode 100644 index 8248a4da..00000000 --- a/src/common/component/Button/Button 2.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import * as styles from './Button.css'; - -type MandalButtonProps = { - text: string; -}; - -const MandalButton = ({ text }: MandalButtonProps) => { - return ; -}; - -export default MandalButton; diff --git a/src/common/component/Modal/Modal.css.ts b/src/common/component/Modal/Modal.css.ts index ea982226..8ec4a9d1 100644 --- a/src/common/component/Modal/Modal.css.ts +++ b/src/common/component/Modal/Modal.css.ts @@ -1,17 +1,17 @@ import { style } from '@vanilla-extract/css'; -import { zIndex } from '@/style/token'; +import { zIndex, layout } from '@/style/token'; -export const dimmed = style({ - position: 'fixed', - top: 0, - left: 0, - width: '100vw', - height: '100vh', - background: 'rgba(18, 18, 18, 0.70)', - backdropFilter: 'blur(2px)', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - zIndex: zIndex.modal, -}); +export const dimmed = style([ + layout.flexCenter, + { + position: 'fixed', + top: 0, + left: 0, + width: '100vw', + height: '100vh', + background: 'rgba(18, 18, 18, 0.70)', + backdropFilter: 'blur(2px)', + zIndex: zIndex.modal, + }, +]); diff --git a/src/common/component/SelectableOption/SelectableOption.css.ts b/src/common/component/SelectableOption/SelectableOption.css.ts new file mode 100644 index 00000000..a6a23a8e --- /dev/null +++ b/src/common/component/SelectableOption/SelectableOption.css.ts @@ -0,0 +1,23 @@ +import { style } from '@vanilla-extract/css'; + +import { colors, fonts } from '@/style/token'; + +export const optionItem = style({ + display: 'flex', + alignItems: 'center', + gap: '0.6rem', + color: colors.white01, + ...fonts.body02, + cursor: 'pointer', + whiteSpace: 'nowrap', +}); + +export const optionItemDisabled = style({ + cursor: 'not-allowed', + opacity: 0.7, +}); + +export const checkboxIcon = style({ + width: '2.4rem', + height: '2.4rem', +}); diff --git a/src/common/component/SelectableOption/SelectableOption.tsx b/src/common/component/SelectableOption/SelectableOption.tsx new file mode 100644 index 00000000..acb3515f --- /dev/null +++ b/src/common/component/SelectableOption/SelectableOption.tsx @@ -0,0 +1,51 @@ +import clsx from 'clsx'; + +import * as styles from './SelectableOption.css'; + +import { IcCheckboxChecked, IcCheckboxDefault } from '@/assets/svg'; + +type SelectableOptionProps = { + label: string; + checked: boolean; + disabled?: boolean; + onToggle: () => void; + className?: string; +}; + +const SelectableOption = ({ + label, + checked, + disabled, + onToggle, + className, +}: SelectableOptionProps) => { + const CheckIcon = checked ? IcCheckboxChecked : IcCheckboxDefault; + + const handleActivate = () => { + if (!disabled) { + onToggle(); + } + }; + + return ( +
{ + if ((e.key === 'Enter' || e.key === ' ') && !disabled) { + e.preventDefault(); + onToggle(); + } + }} + > + + {label} +
+ ); +}; + +export default SelectableOption; diff --git a/src/common/hook/useModal.tsx b/src/common/hook/useModal.tsx deleted file mode 100644 index aeb6bf22..00000000 --- a/src/common/hook/useModal.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useCallback, useState } from 'react'; - -import Modal from '../component/Modal/Modal'; - -export const useModal = () => { - const [isOpen, setIsOpen] = useState(false); - const [content, setContent] = useState(null); - - const openModal = useCallback((modalContent: React.ReactNode) => { - setContent(modalContent); - setIsOpen(true); - }, []); - - const closeModal = useCallback(() => { - setIsOpen(false); - setContent(null); - }, []); - - const ModalWrapper = isOpen ? {content} : null; - - return { openModal, closeModal, ModalWrapper }; -}; diff --git a/src/common/hook/useOverlayModal.tsx b/src/common/hook/useOverlayModal.tsx new file mode 100644 index 00000000..49ce43b1 --- /dev/null +++ b/src/common/hook/useOverlayModal.tsx @@ -0,0 +1,65 @@ +import React, { useRef } from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { overlay } from 'overlay-kit'; + +import Modal from '@/common/component/Modal/Modal'; + +type Closeable = { onClose: () => void }; +type OpenOptions = { withWrapper?: boolean }; + +const hasOnCloseProp = (props: unknown): props is { onClose: unknown } => { + return ( + typeof props === 'object' && props !== null && 'onClose' in (props as Record) + ); +}; + +const isCloseable = (element: ReactNode): element is ReactElement => { + return ( + React.isValidElement(element) && + hasOnCloseProp(element.props) && + typeof element.props.onClose === 'function' + ); +}; + +export const useOverlayModal = () => { + const lastIdRef = useRef(null); + + const removeLastId = (id: string) => { + if (lastIdRef.current === id) { + lastIdRef.current = null; + } + }; + + const openModal = (node: ReactNode, options: OpenOptions = {}) => { + const { withWrapper = true } = options; + + const id = overlay.open(({ unmount }) => { + const handleClose = () => { + unmount(); + removeLastId(id); + }; + const content = isCloseable(node) ? React.cloneElement(node, { onClose: handleClose }) : node; + return withWrapper ? {content} : <>{content}; + }); + + lastIdRef.current = id; + return { + id, + close: () => { + overlay.unmount(id); + removeLastId(id); + }, + }; + }; + + const closeModal = () => { + const id = lastIdRef.current; + if (!id) { + return; + } + overlay.unmount(id); + removeLastId(id); + }; + + return { openModal, closeModal }; +}; diff --git a/src/main.tsx b/src/main.tsx index da800e2c..f6512184 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,16 +2,18 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { OverlayProvider } from 'overlay-kit'; import App from './App.tsx'; import { queryClient } from './common/util/queryClient.ts'; - import './style/global.css.ts'; createRoot(document.getElementById('root')!).render( - + + + {import.meta.env.DEV && } , diff --git a/src/page/home/Home.tsx b/src/page/home/Home.tsx index 6b307c52..6ee0a780 100644 --- a/src/page/home/Home.tsx +++ b/src/page/home/Home.tsx @@ -9,7 +9,7 @@ import { useMultipleFadeInOnView } from '@/page/home/hook/useMultipleFadeInOnVie import mandalAnimation from '@/assets/lottie/mandalart.json'; import aiAnimation from '@/assets/lottie/ai.json'; import todoAnimation from '@/assets/lottie/todo.json'; -import { useModal } from '@/common/hook/useModal'; +import { useOverlayModal } from '@/common/hook/useOverlayModal'; import LoginModal from '@/common/component/LoginModal/LoginModal'; const animationDataArray = [mandalAnimation, aiAnimation, todoAnimation]; @@ -19,7 +19,7 @@ const Home = () => { const scrolls = useMultipleFadeInOnView(); const end = useFadeInOnView(); - const { openModal, closeModal, ModalWrapper } = useModal(); + const { openModal, closeModal } = useOverlayModal(); const handleOpenLogin = () => { openModal(); @@ -44,7 +44,6 @@ const Home = () => { })} - {ModalWrapper}
); }; diff --git a/src/page/todo/lowerTodo/LowerTodo.tsx b/src/page/todo/lowerTodo/LowerTodo.tsx index dd776c67..0b84e53a 100644 --- a/src/page/todo/lowerTodo/LowerTodo.tsx +++ b/src/page/todo/lowerTodo/LowerTodo.tsx @@ -10,7 +10,7 @@ import { PATH } from '@/route'; import { IcSmallNext } from '@/assets/svg'; import GradientBackground from '@/common/component/Background/GradientBackground'; import Tooltip from '@/common/component/Tooltip/Tooltip'; -import { useModal } from '@/common/hook/useModal'; +import { useOverlayModal } from '@/common/hook/useOverlayModal'; import AiRecommendModal from '@/common/component/AiRecommendModal/AiRecommendModal'; import AiFailModal from '@/common/component/AiFailModal/AiFailModal'; import Mandalart from '@/common/component/Mandalart/Mandalart'; @@ -36,7 +36,7 @@ interface TodoItem { const LowerTodo = ({ userName = '김도트' }: LowerTodoProps) => { const navigate = useNavigate(); - const { openModal, ModalWrapper, closeModal } = useModal(); + const { openModal, closeModal } = useOverlayModal(); const [selectedGoalIndex, setSelectedGoalIndex] = useState(0); const [allTodos, setAllTodos] = useState( Array(8) @@ -351,12 +351,10 @@ const LowerTodo = ({ userName = '김도트' }: LowerTodoProps) => { console.log('subGoals[newIndex]:', subGoals[newIndex]); }; - const handleApplyAiRecommendedGoals = async ( - selected: { id: number; position: number; title: string; cycle?: string }[], - ) => { + const handleApplyAiRecommendedGoals = async (selected: { title: string }[]) => { const goals = selected.map((item) => ({ title: item.title, - cycle: item.cycle || 'DAILY', + cycle: 'DAILY', })); try { if (!selectedCoreGoalId) { @@ -538,7 +536,6 @@ const LowerTodo = ({ userName = '김도트' }: LowerTodoProps) => { } /> - {ModalWrapper} ); diff --git a/src/page/todo/upperTodo/UpperTodo.tsx b/src/page/todo/upperTodo/UpperTodo.tsx index eb1eef42..59302f82 100644 --- a/src/page/todo/upperTodo/UpperTodo.tsx +++ b/src/page/todo/upperTodo/UpperTodo.tsx @@ -10,9 +10,12 @@ import { useUpperTodoState } from './hook/useUpperTodoState'; import AiRecommendModal from '@/common/component/AiRecommendModal/AiRecommendModal'; import GradientBackground from '@/common/component/Background/GradientBackground'; -import { useModal } from '@/common/hook/useModal'; +import { useOverlayModal } from '@/common/hook/useOverlayModal'; import { PATH } from '@/route'; -import { usePostAiRecommendCoreGoal } from '@/api/domain/upperTodo/hook'; +import { + usePostAiRecommendCoreGoal, + usePostAiRecommendToCoreGoals, +} from '@/api/domain/upperTodo/hook'; interface UpperTodoProps { userName?: string; @@ -35,7 +38,7 @@ const extractTitles = (goals: { title: string }[]) => goals.map((item) => item.t const UpperTodo = ({ userName = '김도트' }: UpperTodoProps) => { const mandalartId = 1; - const { openModal, ModalWrapper, closeModal } = useModal(); + const { openModal, closeModal } = useOverlayModal(); const navigate = useNavigate(); const { @@ -53,6 +56,7 @@ const UpperTodo = ({ userName = '김도트' }: UpperTodoProps) => { } = useUpperTodoState(mandalartId); const postAiRecommend = usePostAiRecommendCoreGoal(); + const postRecommendToCore = usePostAiRecommendToCoreGoals(); const mainGoal = data?.title || '사용자가 작성한 대목표'; @@ -60,12 +64,27 @@ const UpperTodo = ({ userName = '김도트' }: UpperTodoProps) => { navigate(PATH.TODO_LOWER); }; - const handleAiSubmit = (responseData: { id: number; position: number; title: string }[]) => { - setAiResponseData(responseData); - const updatedSubGoals = updateSubGoalsWithAiResponse(subGoals, responseData); - setSubGoals(updatedSubGoals); - refetchCoreGoalIds(); - refetch(); + const handleAiSubmit = (goals: { title: string }[]) => { + postRecommendToCore.mutate( + { mandalartId, goals: goals.map((g) => g.title) }, + { + onSuccess: (response) => { + const responseData = response.coreGoals as { + id: number; + position: number; + title: string; + }[]; + setAiResponseData(responseData); + const updatedSubGoals = updateSubGoalsWithAiResponse(subGoals, responseData); + setSubGoals(updatedSubGoals); + refetchCoreGoalIds(); + refetch(); + }, + onError: (error) => { + console.error('AI 추천 목표 저장 실패:', error); + }, + }, + ); }; const handleOpenAiModal = async () => { @@ -98,7 +117,6 @@ const UpperTodo = ({ userName = '김도트' }: UpperTodoProps) => { onSubmit={handleAiSubmit} values={subGoals} options={titles} - mandalartId={mandalartId} /> ); @@ -161,7 +179,6 @@ const UpperTodo = ({ userName = '김도트' }: UpperTodoProps) => { hasFilledSubGoals={hasFilledSubGoals} handleNavigateLower={handleNavigateLower} /> - {ModalWrapper} ); diff --git a/src/shared/component/Layout/layoutHeader/Header.tsx b/src/shared/component/Layout/layoutHeader/Header.tsx index c0dfb6d8..6bdf21a9 100644 --- a/src/shared/component/Layout/layoutHeader/Header.tsx +++ b/src/shared/component/Layout/layoutHeader/Header.tsx @@ -6,7 +6,7 @@ import * as styles from './Header.css'; import { PATH } from '@/route/path'; import IcLogo from '@/assets/svg/IcLogo'; import LoginModal from '@/common/component/LoginModal/LoginModal'; -import { useModal } from '@/common/hook/useModal'; +import { useOverlayModal } from '@/common/hook/useOverlayModal'; import { useGetUser } from '@/api/domain/signup/hook/useGetUser'; import UserModal from '@/common/component/UserModal/UserModal'; @@ -28,7 +28,7 @@ const Header = () => { const [activeMenu, setActiveMenu] = useState(initialMenu); const [openProfile, setOpenProfile] = useState(false); - const { openModal, closeModal, ModalWrapper } = useModal(); + const { openModal, closeModal } = useOverlayModal(); const handleLogin = () => { openModal(); @@ -91,7 +91,6 @@ const Header = () => { )} - {ModalWrapper} ); }; diff --git a/src/style/token/layout.css.ts b/src/style/token/layout.css.ts index 803146da..a6da3efc 100644 --- a/src/style/token/layout.css.ts +++ b/src/style/token/layout.css.ts @@ -1,4 +1,14 @@ export const layout = { + flexColumn: { + display: 'flex', + flexDirection: 'column', + }, + rowBetween: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, flexCenter: { display: 'flex', alignItems: 'center',