├── .all-contributorsrc ├── .czrc ├── .eslintignore ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── pull_request_template.md ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc ├── .npmrc ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── apps └── web │ ├── .eslintrc.js │ ├── README.md │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── public │ ├── assets │ │ ├── basketball │ │ │ ├── animation_off_400.png │ │ │ ├── animation_off_favorited.png │ │ │ ├── fire_off_400.gif │ │ │ ├── fire_off_all_tagged.gif │ │ │ ├── fire_off_favorited.gif │ │ │ ├── fire_off_reservated.gif │ │ │ ├── fire_on_400.gif │ │ │ ├── fire_on_all_tagged.gif │ │ │ ├── fire_on_favorited.gif │ │ │ ├── fire_on_reservated.gif │ │ │ ├── only_ball_500.gif │ │ │ └── only_ball_500.png │ │ ├── default_profile.svg │ │ ├── error.svg │ │ ├── favorited.svg │ │ ├── fonts │ │ │ └── Righteous-Regular.ttf │ │ ├── icon-kakao.svg │ │ ├── lottie │ │ │ └── basketball.json │ │ ├── pond.svg │ │ ├── preview_map.png │ │ └── reservated.svg │ ├── favicon.ico │ └── link_image.png │ ├── src │ ├── apis │ │ ├── core │ │ │ └── index.ts │ │ ├── courts │ │ │ └── index.ts │ │ ├── favorites │ │ │ └── index.ts │ │ ├── follows │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── notifications │ │ │ └── index.ts │ │ ├── reservations │ │ │ └── index.ts │ │ └── users │ │ │ └── index.ts │ ├── components │ │ ├── common │ │ │ ├── OpenGraph │ │ │ │ └── index.tsx │ │ │ ├── Spinner │ │ │ │ └── index.tsx │ │ │ └── index.ts │ │ ├── domains │ │ │ ├── CourtItem │ │ │ │ └── index.tsx │ │ │ ├── DatePicker │ │ │ │ └── index.tsx │ │ │ ├── ErrorMessage │ │ │ │ └── index.tsx │ │ │ ├── EssentialImagePreload │ │ │ │ └── index.tsx │ │ │ ├── FollowListItem │ │ │ │ └── index.tsx │ │ │ ├── Logo │ │ │ │ └── index.tsx │ │ │ ├── NoItemMessage │ │ │ │ └── index.tsx │ │ │ ├── ProfileAvatar │ │ │ │ └── index.tsx │ │ │ ├── ReservationItem │ │ │ │ └── index.tsx │ │ │ ├── ReservationTable │ │ │ │ └── index.tsx │ │ │ └── index.ts │ │ ├── kakaos │ │ │ ├── Map │ │ │ │ ├── Button │ │ │ │ │ ├── CurrentLocation │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── ZoomInOut │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Container │ │ │ │ │ └── index.tsx │ │ │ │ ├── LoadingIndicator │ │ │ │ │ └── index.tsx │ │ │ │ ├── Marker │ │ │ │ │ ├── CustomMarkerOverlay.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── context │ │ │ │ │ ├── Provider.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── hooks │ │ │ │ │ ├── index.ts │ │ │ │ │ └── useMapEvent.ts │ │ │ │ └── index.tsx │ │ │ └── index.ts │ │ └── uis │ │ │ ├── Badge │ │ │ └── index.tsx │ │ │ ├── BottomModal │ │ │ └── index.tsx │ │ │ ├── Button │ │ │ └── index.tsx │ │ │ ├── FullHeight │ │ │ └── index.tsx │ │ │ ├── Icon │ │ │ └── index.tsx │ │ │ ├── IconButton │ │ │ └── index.tsx │ │ │ ├── InfiniteScrollSensor │ │ │ └── index.tsx │ │ │ ├── LayerOver │ │ │ ├── hooks │ │ │ │ ├── index.ts │ │ │ │ └── useDisclosure │ │ │ │ │ └── index.ts │ │ │ └── index.tsx │ │ │ ├── Skeleton │ │ │ └── index.tsx │ │ │ ├── Tab │ │ │ └── index.tsx │ │ │ ├── Toast │ │ │ └── index.tsx │ │ │ ├── Upload │ │ │ └── index.tsx │ │ │ └── index.ts │ ├── constants │ │ └── index.ts │ ├── contexts │ │ ├── AnalyticsProvider │ │ │ └── index.tsx │ │ └── index.ts │ ├── features │ │ ├── QueryClientProvider.tsx │ │ ├── addresses │ │ │ ├── index.ts │ │ │ ├── key.ts │ │ │ └── useAddressQuery │ │ │ │ └── index.ts │ │ ├── courts │ │ │ ├── index.ts │ │ │ ├── key.ts │ │ │ ├── useCourtCreateMutation │ │ │ │ └── index.ts │ │ │ ├── useCourtQuery │ │ │ │ └── index.ts │ │ │ └── useCourtsQuery │ │ │ │ └── index.ts │ │ ├── favorites │ │ │ ├── index.ts │ │ │ ├── key.ts │ │ │ ├── useCancelFavoriteMutation │ │ │ │ └── index.ts │ │ │ ├── useCreateFavoriteMutation │ │ │ │ └── index.ts │ │ │ └── useGetFavoritesQuery │ │ │ │ └── index.ts │ │ ├── follows │ │ │ └── key.ts │ │ ├── index.ts │ │ ├── key.ts │ │ ├── notifications │ │ │ ├── index.ts │ │ │ ├── key.ts │ │ │ ├── useFollowCancelMutation │ │ │ │ └── index.ts │ │ │ ├── useFollowCreateMutation │ │ │ │ └── index.ts │ │ │ ├── useGetInfiniteNotificationsQuery │ │ │ │ └── index.ts │ │ │ └── useGetNotificationsQuery │ │ │ │ └── index.ts │ │ ├── reservations │ │ │ ├── index.ts │ │ │ ├── key.ts │ │ │ ├── useCreateReservationMutation │ │ │ │ └── index.ts │ │ │ ├── useDeleteReservationMutation │ │ │ │ └── index.ts │ │ │ ├── useGetExpiredReservationsInfiniteQuery │ │ │ │ └── index.ts │ │ │ ├── useGetReservationsInfiniteQuery │ │ │ │ └── index.ts │ │ │ └── useGetUpcomingReservationsQuery │ │ │ │ └── index.ts │ │ └── users │ │ │ ├── index.ts │ │ │ ├── key.ts │ │ │ ├── useCurrentUserQuery │ │ │ └── index.ts │ │ │ ├── useMyProfileMutation │ │ │ └── index.ts │ │ │ ├── useMyProfileQuery │ │ │ └── index.ts │ │ │ ├── useUpdateMyProfileImageMutation │ │ │ └── index.ts │ │ │ ├── useUserFollowerInfiniteQuery │ │ │ └── index.ts │ │ │ ├── useUserFollowingInfiniteQuery │ │ │ └── index.ts │ │ │ └── useUserProfileQuery │ │ │ └── index.ts │ ├── hocs │ │ ├── index.ts │ │ └── withShareClick │ │ │ ├── index.tsx │ │ │ ├── sendKakaoLink.ts │ │ │ └── types.ts │ ├── hooks │ │ ├── index.ts │ │ └── useSentry.ts │ ├── layouts │ │ ├── BottomFixedGradient │ │ │ └── index.tsx │ │ ├── Layout │ │ │ ├── components │ │ │ │ ├── PageLoader │ │ │ │ │ └── index.tsx │ │ │ │ ├── ScrollContainer │ │ │ │ │ └── index.tsx │ │ │ │ └── index.ts │ │ │ ├── index.tsx │ │ │ └── navigations │ │ │ │ ├── BottomNavigation │ │ │ │ ├── NavIcon │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ │ ├── Navigation │ │ │ │ └── index.tsx │ │ │ │ ├── TopNavigation │ │ │ │ └── index.tsx │ │ │ │ ├── atoms │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ └── index.ts │ ├── libs │ │ └── Toast │ │ │ ├── helpers │ │ │ ├── index.ts │ │ │ └── iterateCallWithDelay.ts │ │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── useIsMounted │ │ │ │ └── index.ts │ │ │ ├── useTimeout │ │ │ │ └── index.ts │ │ │ └── useTimeoutFn │ │ │ │ └── index.ts │ │ │ └── index.tsx │ ├── middleware.ts │ ├── middlewares │ │ ├── cookies │ │ │ ├── deleteCookie │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ └── jwt │ │ │ ├── index.ts │ │ │ └── verify │ │ │ └── index.ts │ ├── pages │ │ ├── 404.tsx │ │ ├── 500.tsx │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── activity │ │ │ └── index.tsx │ │ ├── chat │ │ │ ├── [chatroomId] │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── list │ │ │ │ └── index.tsx │ │ ├── clubs │ │ │ └── index.tsx │ │ ├── courts │ │ │ └── create.tsx │ │ ├── credits │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── licenses │ │ │ └── index.tsx │ │ ├── login │ │ │ ├── index.tsx │ │ │ └── redirect │ │ │ │ └── index.tsx │ │ ├── map │ │ │ └── index.tsx │ │ ├── notifications │ │ │ └── index.tsx │ │ ├── policy │ │ │ ├── privacy │ │ │ │ └── index.tsx │ │ │ └── service │ │ │ │ └── index.tsx │ │ ├── reservations │ │ │ ├── courts │ │ │ │ └── [courtId] │ │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── test │ │ │ ├── form.tsx │ │ │ └── index.tsx │ │ └── user │ │ │ ├── [userId] │ │ │ ├── follower.tsx │ │ │ ├── following.tsx │ │ │ └── index.tsx │ │ │ ├── edit │ │ │ └── index.tsx │ │ │ └── menu │ │ │ └── index.tsx │ └── styles │ │ ├── GlobalCSS │ │ └── index.tsx │ │ ├── chakraTheme │ │ └── index.ts │ │ ├── emotionTheme │ │ ├── colors │ │ │ └── index.ts │ │ ├── emotion.d.ts │ │ ├── gaps │ │ │ └── index.ts │ │ ├── index.ts │ │ └── sizes │ │ │ └── index.ts │ │ └── index.ts │ └── tsconfig.json ├── commitlint.config.js ├── configs ├── eslint │ ├── common.js │ ├── next.js │ ├── package.json │ └── react.js └── tsconfig │ ├── README.md │ ├── base.json │ ├── nextjs.json │ ├── package.json │ └── react-library.json ├── dependencies-graph.html ├── package.json ├── packages ├── hooks │ ├── .eslintrc.js │ ├── package.json │ ├── src │ │ ├── EventKeyValueType.ts │ │ ├── index.ts │ │ ├── useAsync.ts │ │ ├── useAsyncFn.ts │ │ ├── useClickAway.ts │ │ ├── useDebounce.ts │ │ ├── useHotKey.ts │ │ ├── useHover.ts │ │ ├── useIntersectionObserver.ts │ │ ├── useInterval.ts │ │ ├── useIntervalFn.ts │ │ ├── useIsMounted.ts │ │ ├── useIsomorphicLayoutEffect.ts │ │ ├── useKey.ts │ │ ├── useKeyPress.ts │ │ ├── useLocalStorage.ts │ │ ├── useResize.ts │ │ ├── useSessionStorage.ts │ │ ├── useTimeout.ts │ │ ├── useTimeoutFn.ts │ │ └── useToggle.ts │ └── tsconfig.json ├── types │ ├── .eslintrc.js │ ├── package.json │ ├── src │ │ ├── abstracts │ │ │ └── index.ts │ │ ├── index.ts │ │ └── objects │ │ │ ├── chat.ts │ │ │ ├── chatroom.ts │ │ │ ├── court.ts │ │ │ ├── favorite.ts │ │ │ ├── follow.ts │ │ │ ├── loudspeaker.ts │ │ │ ├── newCourt.ts │ │ │ ├── notification.ts │ │ │ ├── reservation.ts │ │ │ └── user.ts │ └── tsconfig.json └── utility-types │ ├── .eslintrc.js │ ├── package.json │ ├── src │ └── index.ts │ └── tsconfig.json ├── packlint.config.mjs ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "frontend", 3 | "projectOwner": "slamapp", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 80, 10 | "commit": false, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "manudeli", 15 | "name": "Jonghyeon Ko", 16 | "avatar_url": "https://avatars.githubusercontent.com/u/61593290?v=4", 17 | "profile": "http://bit.ly/jonghyeon", 18 | "contributions": [ 19 | "bug", 20 | "code", 21 | "design", 22 | "ideas", 23 | "maintenance", 24 | "review", 25 | "video", 26 | "infra", 27 | "doc" 28 | ] 29 | }, 30 | { 31 | "login": "grighth12", 32 | "name": "grighth12", 33 | "avatar_url": "https://avatars.githubusercontent.com/u/68159627?v=4", 34 | "profile": "https://github.com/grighth12", 35 | "contributions": [ 36 | "bug", 37 | "code", 38 | "doc", 39 | "design", 40 | "ideas", 41 | "review", 42 | "test" 43 | ] 44 | }, 45 | { 46 | "login": "limkhl", 47 | "name": "limkhl", 48 | "avatar_url": "https://avatars.githubusercontent.com/u/84858773?v=4", 49 | "profile": "https://github.com/limkhl", 50 | "contributions": [ 51 | "code", 52 | "doc", 53 | "design", 54 | "ideas", 55 | "review" 56 | ] 57 | }, 58 | { 59 | "login": "Parkserim", 60 | "name": "Parkserim", 61 | "avatar_url": "https://avatars.githubusercontent.com/u/33405125?v=4", 62 | "profile": "https://github.com/Parkserim", 63 | "contributions": [ 64 | "code", 65 | "doc", 66 | "design", 67 | "ideas", 68 | "review" 69 | ] 70 | }, 71 | { 72 | "login": "locodingve", 73 | "name": "yun", 74 | "avatar_url": "https://avatars.githubusercontent.com/u/88185304?v=4", 75 | "profile": "https://github.com/locodingve", 76 | "contributions": [ 77 | "code", 78 | "maintenance", 79 | "infra" 80 | ] 81 | }, 82 | { 83 | "login": "KwonYeKyeong", 84 | "name": "KwonYeKyeong", 85 | "avatar_url": "https://avatars.githubusercontent.com/u/65434196?v=4", 86 | "profile": "https://github.com/KwonYeKyeong", 87 | "contributions": [ 88 | "code", 89 | "maintenance", 90 | "infra" 91 | ] 92 | }, 93 | { 94 | "login": "sds1vrk", 95 | "name": "SEO DONGSUNG", 96 | "avatar_url": "https://avatars.githubusercontent.com/u/51287886?v=4", 97 | "profile": "https://velog.io/@sds1vrk", 98 | "contributions": [ 99 | "code", 100 | "infra" 101 | ] 102 | }, 103 | { 104 | "login": "tooooo1", 105 | "name": "퉁이리", 106 | "avatar_url": "https://avatars.githubusercontent.com/u/77133565?v=4", 107 | "profile": "https://www.linkedin.com/in/tooo1", 108 | "contributions": [ 109 | "bug", 110 | "example" 111 | ] 112 | }, 113 | { 114 | "login": "ChanhyukPark-Tech", 115 | "name": "박찬혁", 116 | "avatar_url": "https://avatars.githubusercontent.com/u/69495129?v=4", 117 | "profile": "http://chanhyuk.com", 118 | "contributions": [ 119 | "bug" 120 | ] 121 | } 122 | ], 123 | "contributorsPerLine": 7, 124 | "linkToUsage": true 125 | } 126 | -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "cz-conventional-changelog" 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | dist 3 | esm 4 | .eslintrc.js 5 | packlint.config.mjs 6 | node_modules -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @manudeli 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: 'Report a bug ' 4 | title: '[BUG]:' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | ### **Package Scope** 10 | 11 | 12 | 13 | Package name: 14 | 15 | ### **Describe the bug** 16 | 17 | 18 | 19 | ### **Expected behavior** 20 | 21 | 22 | 23 | ### **To Reproduce** 24 | 25 | 30 | 31 | ### **Possible Solution** 32 | 33 | 34 | 35 | ### **Additional context** 36 | 37 | 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[Feature]:' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | ### **Package Scope** 10 | 11 | 15 | 16 | - [ ] Add to an existing package 17 | - [ ] New package 18 | 19 | 20 | 21 | Package name: 22 | 23 | ### **Overview** 24 | 25 | 26 | 27 | ### **Describe the solution you'd like** 28 | 29 | 30 | 31 | ### **Additional context** 32 | 33 | 34 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | .pnpm-debug.log* 23 | 24 | # local env files 25 | .env.local 26 | .env.development.local 27 | .env.test.local 28 | .env.production.local 29 | 30 | # turbo 31 | .turbo 32 | 33 | node_modules -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm lint 5 | pnpm lint-staged 6 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,tsx}": ["prettier --write"] 3 | } 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers = true 2 | node-linker = isolated 3 | hoist = true 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": false, 4 | "bracketSpacing": true, 5 | "endOfLine": "lf", 6 | "jsxSingleQuote": false, 7 | "printWidth": 120, 8 | "proseWrap": "preserve", 9 | "quoteProps": "as-needed", 10 | "semi": false, 11 | "singleQuote": true, 12 | "tabWidth": 2, 13 | "trailingComma": "es5" 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "eslint.packageManager": "pnpm", 5 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], 6 | "typescript.enablePromptUseWorkspaceTsdk": true, 7 | 8 | "[typescript]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode", 10 | "editor.formatOnSave": false 11 | }, 12 | "[typescriptreact]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode", 14 | "editor.formatOnSave": false 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('@slam/eslint/next.js'), 3 | parserOptions: { 4 | tsconfigRootDir: __dirname, 5 | project: './tsconfig.json', 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/README.md: -------------------------------------------------------------------------------- 1 | ## Getting Started 2 | 3 | First, run the development server: 4 | 5 | ```bash 6 | yarn dev 7 | ``` 8 | 9 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 10 | 11 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 12 | 13 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. 14 | 15 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 16 | 17 | ## Learn More 18 | 19 | To learn more about Next.js, take a look at the following resources: 20 | 21 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 22 | - [Learn Next.js](https://nextjs.org/learn/foundations/about-nextjs) - an interactive Next.js tutorial. 23 | 24 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 25 | 26 | ## Deploy on Vercel 27 | 28 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js. 29 | 30 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 31 | -------------------------------------------------------------------------------- /apps/web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/web/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | transpilePackages: ['@slam/ui'], 5 | compiler: { 6 | emotion: true, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@slam/web", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "lint": "next lint", 9 | "start": "next start" 10 | }, 11 | "dependencies": { 12 | "@chakra-ui/react": "^2.3.6", 13 | "@emotion/react": "^11.10.4", 14 | "@emotion/styled": "^11.10.4", 15 | "@hookform/devtools": "^4.2.2", 16 | "@hookform/error-message": "^2.0.1", 17 | "@hookform/resolvers": "^2.9.8", 18 | "@jsxcss/emotion": "^1.3.6", 19 | "@sentry/nextjs": "^7.15.0", 20 | "@slam/hooks": "workspace:*", 21 | "@slam/types": "workspace:*", 22 | "@slam/utility-types": "workspace:*", 23 | "@suspensive/react": "^1.9.2", 24 | "@suspensive/react-query": "^1.9.2", 25 | "@tanstack/react-query": "^4.13.0", 26 | "@tanstack/react-query-devtools": "^4.13.0", 27 | "axios": "^1.1.3", 28 | "copy-to-clipboard": "^3.3.2", 29 | "dayjs": "^1.11.6", 30 | "feather-icons": "^4.29.0", 31 | "framer-motion": "^7.6.1", 32 | "jose": "^4.10.0", 33 | "lottie-react": "^2.3.1", 34 | "next": "^13.0.0", 35 | "react": "18.2.0", 36 | "react-dom": "18.2.0", 37 | "react-ga4": "^1.4.1", 38 | "react-hook-form": "^7.36.1", 39 | "recoil": "^0.7.5", 40 | "uuid": "^9.0.0", 41 | "zod": "^3.19.1" 42 | }, 43 | "devDependencies": { 44 | "@babel/core": "^7.0.0", 45 | "@slam/eslint": "workspace:*", 46 | "@slam/tsconfig": "workspace:*", 47 | "@types/feather-icons": "^4.7.0", 48 | "@types/node": "18.7.18", 49 | "@types/react": "^18.0.22", 50 | "@types/react-dom": "^18.0.7", 51 | "@types/uuid": "^8.3.4", 52 | "eslint": "8.23.1", 53 | "eslint-config-next": "12.3.0", 54 | "kakao.maps.d.ts": "^0.1.33", 55 | "typescript": "4.8.3" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /apps/web/public/assets/basketball/animation_off_400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/assets/basketball/animation_off_400.png -------------------------------------------------------------------------------- /apps/web/public/assets/basketball/animation_off_favorited.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/assets/basketball/animation_off_favorited.png -------------------------------------------------------------------------------- /apps/web/public/assets/basketball/fire_off_400.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/assets/basketball/fire_off_400.gif -------------------------------------------------------------------------------- /apps/web/public/assets/basketball/fire_off_all_tagged.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/assets/basketball/fire_off_all_tagged.gif -------------------------------------------------------------------------------- /apps/web/public/assets/basketball/fire_off_favorited.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/assets/basketball/fire_off_favorited.gif -------------------------------------------------------------------------------- /apps/web/public/assets/basketball/fire_off_reservated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/assets/basketball/fire_off_reservated.gif -------------------------------------------------------------------------------- /apps/web/public/assets/basketball/fire_on_400.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/assets/basketball/fire_on_400.gif -------------------------------------------------------------------------------- /apps/web/public/assets/basketball/fire_on_all_tagged.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/assets/basketball/fire_on_all_tagged.gif -------------------------------------------------------------------------------- /apps/web/public/assets/basketball/fire_on_favorited.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/assets/basketball/fire_on_favorited.gif -------------------------------------------------------------------------------- /apps/web/public/assets/basketball/fire_on_reservated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/assets/basketball/fire_on_reservated.gif -------------------------------------------------------------------------------- /apps/web/public/assets/basketball/only_ball_500.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/assets/basketball/only_ball_500.gif -------------------------------------------------------------------------------- /apps/web/public/assets/basketball/only_ball_500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/assets/basketball/only_ball_500.png -------------------------------------------------------------------------------- /apps/web/public/assets/default_profile.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /apps/web/public/assets/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /apps/web/public/assets/favorited.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/web/public/assets/fonts/Righteous-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/assets/fonts/Righteous-Regular.ttf -------------------------------------------------------------------------------- /apps/web/public/assets/icon-kakao.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/web/public/assets/pond.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /apps/web/public/assets/preview_map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/assets/preview_map.png -------------------------------------------------------------------------------- /apps/web/public/assets/reservated.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/favicon.ico -------------------------------------------------------------------------------- /apps/web/public/link_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slamapp/frontend/7e6e3674f8d58173b37b2f89190ace2d3b29a6cd/apps/web/public/link_image.png -------------------------------------------------------------------------------- /apps/web/src/apis/core/index.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosInstance, AxiosPromise, AxiosRequestConfig } from 'axios' 2 | import axios from 'axios' 3 | import { PROXY_PRE_FIX, env } from '~/constants' 4 | 5 | type RequestType = 'DEFAULT' | 'FILE' 6 | const getInterceptedInstance = (requestType: RequestType) => 7 | setInterceptors( 8 | axios.create({ 9 | baseURL: `${PROXY_PRE_FIX}${env.SERVICE_API_SUB_FIX}`, 10 | }), 11 | requestType 12 | ) 13 | 14 | const setInterceptors = (instance: AxiosInstance, requestType: RequestType) => { 15 | instance.interceptors.request.use((config) => { 16 | if (requestType === 'FILE') { 17 | config.headers['Content-Type'] = 'multipart/form-data' 18 | } 19 | 20 | return config 21 | }) 22 | 23 | return instance 24 | } 25 | 26 | type SelectedMethod = 'get' | 'post' | 'patch' | 'put' | 'delete' 27 | const attachMethod = 28 | (method: SelectedMethod) => 29 | (axiosInstance: AxiosInstance) => 30 | (url: string, config?: Omit): AxiosPromise => 31 | axiosInstance(url, { method, ...config }) 32 | 33 | const instance = { 34 | default: getInterceptedInstance('DEFAULT'), 35 | file: getInterceptedInstance('FILE'), 36 | } 37 | 38 | export const http = { 39 | get: attachMethod('get')(instance.default), 40 | post: attachMethod('post')(instance.default), 41 | patch: attachMethod('patch')(instance.default), 42 | put: attachMethod('put')(instance.default), 43 | delete: attachMethod('delete')(instance.default), 44 | file: { 45 | get: attachMethod('get')(instance.file), 46 | post: attachMethod('post')(instance.file), 47 | patch: attachMethod('patch')(instance.file), 48 | put: attachMethod('put')(instance.file), 49 | delete: attachMethod('delete')(instance.file), 50 | }, 51 | } as const 52 | -------------------------------------------------------------------------------- /apps/web/src/apis/courts/index.ts: -------------------------------------------------------------------------------- 1 | import type { APICourt, APINewCourt, APIReservation, APIUser } from '@slam/types' 2 | import { http } from '~/apis/core' 3 | 4 | const courtApi = { 5 | getCourtsByCoordsAndDate: ({ 6 | date, 7 | time, 8 | startLatitude, 9 | endLatitude, 10 | startLongitude, 11 | endLongitude, 12 | }: { 13 | date: string 14 | startLatitude: APICourt['latitude'] 15 | startLongitude: APICourt['longitude'] 16 | endLatitude: APICourt['latitude'] 17 | endLongitude: APICourt['longitude'] 18 | time: 'dawn' | 'morning' | 'afternoon' | 'night' 19 | }) => 20 | http.get<{ court: APICourt; reservationMaxCount: number }[]>(`/courts`, { 21 | params: { 22 | date, 23 | latitude: `${startLatitude},${endLatitude}`, 24 | longitude: `${startLongitude},${endLongitude}`, 25 | time, 26 | }, 27 | }), 28 | 29 | createNewCourt: (data: Pick) => 30 | http.post(`/courts/new`, { data }), 31 | 32 | getCourtDetail: ( 33 | courtId: APICourt['id'], 34 | { 35 | date, 36 | time, 37 | }: { 38 | date: string 39 | time: 'dawn' | 'morning' | 'afternoon' | 'night' 40 | } 41 | ) => 42 | http.get< 43 | Pick< 44 | APICourt, 45 | 'basketCount' | 'createdAt' | 'id' | 'image' | 'latitude' | 'longitude' | 'name' | 'texture' | 'updatedAt' 46 | > & { 47 | reservationMaxCount: number 48 | } 49 | >(`/courts/${courtId}/detail`, { 50 | params: { 51 | date, 52 | time, 53 | }, 54 | }), 55 | 56 | getAllCourtReservationsByDate: (courtId: APICourt['id'], date: string) => 57 | http.get<{ 58 | courtId: number 59 | date: string 60 | reservations: { 61 | userId: number 62 | avatarImgSrc: APIUser['profileImage'] 63 | courtId: number 64 | reservationId: number 65 | startTime: APIReservation['startTime'] 66 | endTime: APIReservation['endTime'] 67 | }[] 68 | }>(`/courts/${courtId}/reservations/${date}`), 69 | } 70 | 71 | export default courtApi 72 | -------------------------------------------------------------------------------- /apps/web/src/apis/favorites/index.ts: -------------------------------------------------------------------------------- 1 | import type { APICourt, APIFavorite, List } from '@slam/types' 2 | import { http } from '~/apis/core' 3 | 4 | export default { 5 | getMyFavorites: () => http.get>('/favorites'), 6 | 7 | createFavorite: ({ courtId }: { courtId: APICourt['id'] }) => 8 | http.post>('/favorites', { 9 | data: { courtId }, 10 | }), 11 | 12 | deleteFavorite: ({ favoriteId }: { favoriteId: APIFavorite['id'] }) => http.delete(`/favorites/${favoriteId}`), 13 | } as const 14 | -------------------------------------------------------------------------------- /apps/web/src/apis/follows/index.ts: -------------------------------------------------------------------------------- 1 | import type { APIFollower, APIFollowing, APIUser, CursorList, CursorListRequestOption } from '@slam/types' 2 | import { http } from '~/apis/core' 3 | 4 | export default { 5 | getUserFollowings: ( 6 | userId: APIUser['id'], 7 | { isFirst = false, lastId = null, size = 4 }: CursorListRequestOption 8 | ) => 9 | http.get>(`/follow/${userId}/followings`, { 10 | params: { isFirst, lastId, size }, 11 | }), 12 | 13 | getUserFollowers: ( 14 | userId: APIUser['id'], 15 | { isFirst = false, lastId = null, size = 4 }: CursorListRequestOption 16 | ) => 17 | http.get>(`/follow/${userId}/followers`, { 18 | params: { isFirst, lastId, size }, 19 | }), 20 | 21 | postFollow: ({ receiverId }: { receiverId: APIUser['id'] }) => 22 | http.post(`/notifications/follow`, { 23 | params: { receiverId }, 24 | }), 25 | 26 | deleteFollow: ({ receiverId }: { receiverId: APIUser['id'] }) => 27 | http.delete(`/notifications/follow`, { params: { receiverId } }), 28 | } as const 29 | -------------------------------------------------------------------------------- /apps/web/src/apis/index.ts: -------------------------------------------------------------------------------- 1 | import courts from './courts' 2 | import favorites from './favorites' 3 | import follows from './follows' 4 | import notifications from './notifications' 5 | import reservations from './reservations' 6 | import users from './users' 7 | 8 | export const api = { 9 | courts, 10 | favorites, 11 | follows, 12 | notifications, 13 | reservations, 14 | users, 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/src/apis/notifications/index.ts: -------------------------------------------------------------------------------- 1 | import type { APINotification, CursorListRequestOption } from '@slam/types' 2 | import type { AxiosPromise } from 'axios' 3 | import { http } from '~/apis/core' 4 | 5 | export default { 6 | getNotifications: ({ size = 3, lastId, isFirst = false }: CursorListRequestOption) => 7 | http.get<{ 8 | contents: APINotification[] 9 | lastId: APINotification['id'] | null 10 | }>('/notifications', { 11 | params: { 12 | size, 13 | lastId: lastId || 0, 14 | isFirst, 15 | }, 16 | }), 17 | 18 | readAllNotifications: () => http.put('/notifications/read'), 19 | } as const 20 | -------------------------------------------------------------------------------- /apps/web/src/apis/reservations/index.ts: -------------------------------------------------------------------------------- 1 | import type { APICourt, APIReservation, APIUser, CursorList, CursorListRequestOption, List } from '@slam/types' 2 | import { http } from '~/apis/core' 3 | 4 | export default { 5 | getMyUpcomingReservations: () => http.get>('/reservations/upcoming'), 6 | 7 | getMyExpiredReservations: ({ isFirst, lastId = null, size = 5 }: CursorListRequestOption) => 8 | http.get>('/reservations/expired', { 9 | params: { isFirst, lastId, size }, 10 | }), 11 | 12 | getReservationsAtDate: (params: { courtId: APICourt['id']; date: string }) => 13 | http.get>('/reservations', { params }), 14 | 15 | getMyReservationParticipants: ({ 16 | courtId, 17 | startTime, 18 | endTime, 19 | }: Pick & { 20 | courtId: APICourt['id'] 21 | }) => 22 | http.get<{ 23 | participants: (Pick & { 24 | userId: APIUser['id'] 25 | isFollowed: boolean 26 | })[] 27 | }>(`/reservations/${courtId}/${startTime}/${endTime}`), 28 | 29 | createReservation: (courtId: APICourt['id'], data: Pick) => 30 | http.post< 31 | Pick & { 32 | reservationId: APIReservation['id'] 33 | courtId: APIReservation['court']['id'] 34 | userId: APIReservation['creator']['id'] 35 | } 36 | >('/reservations', { data: { courtId, ...data } }), 37 | 38 | deleteReservation: (reservationId: APIReservation['id']) => http.delete(`/reservations/${reservationId}`), 39 | } as const 40 | -------------------------------------------------------------------------------- /apps/web/src/apis/users/index.ts: -------------------------------------------------------------------------------- 1 | import type { APICourt, APINotification, APIUser } from '@slam/types' 2 | import { http } from '~/apis/core' 3 | 4 | export default { 5 | getUserData: () => http.get('/users/me'), 6 | 7 | getMyProfile: () => 8 | http.get< 9 | APIUser & { 10 | followerCount: number 11 | followingCount: number 12 | } 13 | >('/users/myprofile'), 14 | 15 | getUserProfile: ({ id }: { id: APIUser['id'] }) => 16 | http.get< 17 | APIUser & { 18 | favoriteCourts: Pick[] 19 | followerCount: number 20 | followingCount: number 21 | isFollowing: boolean 22 | } 23 | >(`/users/${id}`), 24 | 25 | updateMyProfile: (data: Pick) => 26 | http.put('/users/myprofile', { 27 | data, 28 | }), 29 | 30 | updateMyProfileImage: (imageFile: File) => { 31 | const formData = new FormData() 32 | formData.append('profileImage', imageFile) 33 | 34 | return http.file.put<{ profileImage: APIUser['profileImage'] }>('/users/myprofile/image', { data: formData }) 35 | }, 36 | } as const 37 | -------------------------------------------------------------------------------- /apps/web/src/components/common/OpenGraph/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentType } from 'react' 2 | import { Fragment } from 'react' 3 | 4 | interface Props { 5 | title?: string 6 | description?: string 7 | imageUrl?: string 8 | container?: ComponentType 9 | } 10 | 11 | /** 12 | * @name OpenGraph 13 | * @description 14 | * 현재 페이지에 [OpenGraph](https://nowonbun.tistory.com/517) (공유 시 타이틀, 설명, 이미지) 를 적용할 수 있도록 하는 컴포넌트입니다. 15 | * @example 16 | * 21 | */ 22 | const OpenGraph = ({ title, description, imageUrl, container: Container = Fragment }: Props) => { 23 | return ( 24 | 25 | {title !== undefined && ( 26 | <> 27 | 28 | 29 | )} 30 | {description !== undefined && ( 31 | <> 32 | 33 | 34 | )} 35 | {imageUrl !== undefined && ( 36 | <> 37 | 38 | 39 | )} 40 | 41 | ) 42 | } 43 | 44 | export default OpenGraph 45 | -------------------------------------------------------------------------------- /apps/web/src/components/common/Spinner/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react' 2 | import { Box, Flex } from '@jsxcss/emotion' 3 | 4 | export const Spinner = () => ( 5 | 6 | 32 | 33 | ) 34 | -------------------------------------------------------------------------------- /apps/web/src/components/common/index.ts: -------------------------------------------------------------------------------- 1 | export { default as OpenGraph } from './OpenGraph' 2 | export { Spinner } from './Spinner' 3 | -------------------------------------------------------------------------------- /apps/web/src/components/domains/CourtItem/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | import Link from 'next/link' 3 | import { css, useTheme } from '@emotion/react' 4 | import { Box, Stack } from '@jsxcss/emotion' 5 | import type { APIChatRoom, APICourt, APIFavorite } from '@slam/types' 6 | import { Icon, IconButton } from '~/components/uis' 7 | import { useCancelFavoriteMutation, useCreateFavoriteMutation } from '~/features/favorites' 8 | import { withShareClick } from '~/hocs' 9 | 10 | const CourtItem = { 11 | Share: ({ court }: { court: Pick }) => 12 | withShareClick('court', { court })(({ onClick }) => ), 13 | FavoritesToggle: ({ courtId, favoriteId }: { courtId: APICourt['id']; favoriteId: APIFavorite['id'] | null }) => { 14 | const createFavoriteMutation = useCreateFavoriteMutation() 15 | const cancelFavoriteMutation = useCancelFavoriteMutation() 16 | 17 | return ( 18 | { 21 | if (!favoriteId) { 22 | createFavoriteMutation.mutate({ courtId }) 23 | } else { 24 | cancelFavoriteMutation.mutate({ favoriteId }) 25 | } 26 | }} 27 | /> 28 | ) 29 | }, 30 | 31 | ChatLink: ({ chatroom }: { chatroom: Pick }) => ( 32 | 33 | 34 | 35 | ), 36 | 37 | Map: ({ 38 | court, 39 | type = 'information', 40 | }: { 41 | court: Pick 42 | type?: 'information' | 'findRoad' 43 | }) => ( 44 | 54 | 55 | 56 | ), 57 | 58 | Header: ({ children }: { children: ReactNode }) => { 59 | return ( 60 | 61 | 62 | 63 | {children} 64 | 65 | 66 | ) 67 | }, 68 | Address: ({ children }: { children: ReactNode }) => { 69 | const theme = useTheme() 70 | 71 | return ( 72 | 73 | 84 | {children} 85 | 86 | 87 | ) 88 | }, 89 | } 90 | 91 | export default CourtItem 92 | -------------------------------------------------------------------------------- /apps/web/src/components/domains/DatePicker/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef, useState } from 'react' 2 | import { useTheme } from '@emotion/react' 3 | import { Box, Stack } from '@jsxcss/emotion' 4 | import type { Dayjs } from 'dayjs' 5 | import dayjs from 'dayjs' 6 | import { motion } from 'framer-motion' 7 | 8 | const week = ['일', '월', '화', '수', '목', '금', '토'] as const 9 | 10 | const DAY_RANGE = 14 11 | 12 | const DATE_ITEM_GAP = 16 13 | const DATE_ITEM_WIDTH = 58 14 | 15 | const SUNDAY_INDEX = 0 16 | const SATURDAY_INDEX = 6 17 | 18 | interface Props { 19 | initialValue?: Dayjs 20 | onChange: (date: Dayjs) => void 21 | } 22 | 23 | const DatePicker = ({ initialValue, onChange }: Props) => { 24 | const theme = useTheme() 25 | const [selectedDate, setSelectedDate] = useState( 26 | initialValue || (() => dayjs().tz().hour(0).minute(0).second(0).millisecond(0)) 27 | ) 28 | 29 | const twoWeekDates = useMemo( 30 | () => Array.from({ length: DAY_RANGE }, (_, index) => selectedDate.add(index, 'day')), 31 | [] 32 | ) 33 | 34 | const ref = useRef(null) 35 | 36 | return ( 37 | 38 | 48 | {twoWeekDates.map((date, index) => { 49 | const selected = date.isSame(selectedDate) 50 | const dayOfWeekIndex = date.day() 51 | 52 | return ( 53 | { 65 | setSelectedDate(date) 66 | onChange(date) 67 | }} 68 | > 69 | 70 | 83 | {week[dayOfWeekIndex]} 84 | 85 | 86 | {date.date()} 87 | 88 | 89 | 90 | ) 91 | })} 92 | 93 | 94 | 95 | 96 | ) 97 | } 98 | 99 | export default DatePicker 100 | 101 | const GradientCover = ({ position }: { position: 'left' | 'right' }) => { 102 | const theme = useTheme() 103 | 104 | return ( 105 | 120 | ) 121 | } 122 | -------------------------------------------------------------------------------- /apps/web/src/components/domains/ErrorMessage/index.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import Link from 'next/link' 3 | import { Box, Stack } from '@jsxcss/emotion' 4 | import { Button, Icon } from '~/components/uis' 5 | 6 | interface Props { 7 | title: string 8 | } 9 | 10 | const ErrorMessage = ({ title }: Props) => ( 11 | 12 | 13 | error 14 | {title} 15 | 16 | 17 | 18 | 22 | 23 | 내 주변 농구장을 찾으러 가보는 건 어떨까요? 24 | 25 | ) 26 | 27 | export default ErrorMessage 28 | -------------------------------------------------------------------------------- /apps/web/src/components/domains/EssentialImagePreload/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { css } from '@emotion/react' 3 | import { useTimeout } from '@slam/hooks' 4 | 5 | type Props = { 6 | lazyLoadTime: number 7 | } 8 | 9 | const EssentialImagePreload = ({ lazyLoadTime = 5000 }: Props) => { 10 | const [isNeedToLoad, setIsNeedToLoad] = useState(false) 11 | 12 | useTimeout(() => { 13 | setIsNeedToLoad(true) 14 | }, lazyLoadTime) 15 | 16 | return isNeedToLoad ? ( 17 |
27 | {[ 28 | '/assets/basketball/only_ball_500.png', 29 | '/assets/basketball/animation_off_400.png', 30 | '/assets/basketball/animation_off_favorited.png', 31 | '/assets/basketball/fire_off_400.gif', 32 | '/assets/basketball/fire_off_all_tagged.gif', 33 | '/assets/basketball/fire_off_favorited.gif', 34 | '/assets/basketball/fire_off_reservated.gif', 35 | '/assets/basketball/fire_on_400.gif', 36 | '/assets/basketball/fire_on_all_tagged.gif', 37 | '/assets/basketball/fire_on_favorited.gif', 38 | '/assets/basketball/fire_on_reservated.gif', 39 | '/assets/basketball/only_ball_500.gif', 40 | ].map((url) => ( 41 | {url} 42 | ))} 43 |
44 | ) : null 45 | } 46 | 47 | export default EssentialImagePreload 48 | -------------------------------------------------------------------------------- /apps/web/src/components/domains/FollowListItem/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Stack } from '@jsxcss/emotion' 2 | import type { APIUser } from '@slam/types' 3 | import { Button } from '~/components/uis' 4 | import ProfileAvatar from '../ProfileAvatar' 5 | 6 | const FollowListItem = ({ 7 | user, 8 | isFollowed, 9 | }: { 10 | isFollowed: boolean 11 | user: Pick 12 | }) => ( 13 | 14 | 15 | {user.nickname} 16 |
{isFollowed === undefined ? <> : isFollowed ? : }
17 |
18 | ) 19 | 20 | export default FollowListItem 21 | -------------------------------------------------------------------------------- /apps/web/src/components/domains/NoItemMessage/index.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import Link from 'next/link' 3 | import { useTheme } from '@emotion/react' 4 | import { Box, Stack } from '@jsxcss/emotion' 5 | import { Button, Icon } from '~/components/uis' 6 | 7 | type Props = { 8 | title: string 9 | description: string 10 | buttonTitle: string 11 | type: 'reservation' | 'favorite' | 'notification' | 'follow' 12 | } 13 | 14 | const NoItemMessage = ({ title, description, buttonTitle, type }: Props) => { 15 | const theme = useTheme() 16 | 17 | const src = 18 | type === 'favorite' 19 | ? '/assets/basketball/fire_off_favorited.gif' 20 | : type === 'reservation' 21 | ? '/assets/basketball/fire_off_reservated.gif' 22 | : type === 'notification' 23 | ? '/assets/basketball/animation_off_400.png' 24 | : '/assets/basketball/fire_off_400.gif' 25 | 26 | return ( 27 | 28 | basketball 29 | 30 | 31 | {title} 32 | 33 | 34 | {description} 35 | 36 | 37 | 38 | 42 | 43 | 44 | 45 | ) 46 | } 47 | 48 | export default NoItemMessage 49 | -------------------------------------------------------------------------------- /apps/web/src/components/domains/ProfileAvatar/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { Avatar } from '@chakra-ui/react' 3 | import type { APIUser } from '@slam/types' 4 | import { DEFAULT_PROFILE_IMAGE_URL } from '~/constants' 5 | 6 | const ProfileAvatar = ({ user }: { user: Pick }) => { 7 | return ( 8 | 17 | 18 | 19 | ) 20 | } 21 | 22 | export default ProfileAvatar 23 | -------------------------------------------------------------------------------- /apps/web/src/components/domains/index.ts: -------------------------------------------------------------------------------- 1 | export { default as DatePicker } from './DatePicker' 2 | export { default as Logo } from './Logo' 3 | export { default as ErrorMessage } from './ErrorMessage' 4 | export { default as NoItemMessage } from './NoItemMessage' 5 | export { default as ProfileAvatar } from './ProfileAvatar' 6 | export { default as ReservationTable } from './ReservationTable' 7 | export { default as CourtItem } from './CourtItem' 8 | export { default as EssentialImagePreload } from './EssentialImagePreload' 9 | export { default as FollowListItem } from './FollowListItem' 10 | /* eslint-disable import/no-cycle */ 11 | export { default as ReservationItem } from './ReservationItem' 12 | -------------------------------------------------------------------------------- /apps/web/src/components/kakaos/Map/Button/CurrentLocation/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@jsxcss/emotion' 2 | import type { Coord } from '@slam/types' 3 | import { motion } from 'framer-motion' 4 | import { IconButton } from '~/components/uis' 5 | import { useMap } from '../../context' 6 | 7 | // 서울의 경도, 위도 8 | export const DEFAULT_POSITION: Coord = [37.5665, 126.978] 9 | 10 | const getCurrentLocation = (callback: (coord: Coord) => void) => { 11 | const options = { 12 | // 아래 옵션을 켤 경우 느려짐 13 | // enableHighAccuracy: true, 14 | timeout: 5000, 15 | maximumAge: 0, 16 | } 17 | 18 | const successCallback: PositionCallback = (position) => { 19 | const { latitude, longitude } = position.coords 20 | callback([latitude, longitude]) 21 | } 22 | 23 | const failCallback: PositionErrorCallback = (error) => { 24 | console.warn(`에러 ${error.code}: ${error.message}`) 25 | callback(DEFAULT_POSITION) 26 | } 27 | 28 | if (navigator) { 29 | navigator.geolocation.getCurrentPosition(successCallback, failCallback, options) 30 | } 31 | } 32 | 33 | const CurrentLocationButton = () => { 34 | const { map, render } = useMap() 35 | const handleClick = () => { 36 | getCurrentLocation(([latitude, longitude]) => { 37 | map?.panTo(new kakao.maps.LatLng(latitude, longitude)) 38 | setTimeout(() => { 39 | render() 40 | }, 400) 41 | }) 42 | } 43 | 44 | return ( 45 | 56 | 57 | 58 | ) 59 | } 60 | 61 | export default CurrentLocationButton 62 | -------------------------------------------------------------------------------- /apps/web/src/components/kakaos/Map/Button/ZoomInOut/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps, ReactNode } from 'react' 2 | import { css } from '@emotion/react' 3 | import { motion } from 'framer-motion' 4 | import { IconButton } from '~/components/uis' 5 | import { useMap } from '../../context' 6 | 7 | const ZoomInOut = () => { 8 | const { map } = useMap() 9 | 10 | const handleClickZoomIn = () => { 11 | if (map) { 12 | if (map.getLevel() || 0 > 1) { 13 | map.setLevel(map.getLevel() - 1, { animate: true }) 14 | } 15 | } 16 | } 17 | const handleClickZoomOut = () => { 18 | if (map) { 19 | if (map.getLevel() || 0 < 7) { 20 | map.setLevel(map.getLevel() + 1, { animate: true }) 21 | } 22 | } 23 | } 24 | 25 | return ( 26 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ) 48 | } 49 | 50 | export default ZoomInOut 51 | 52 | const TapAnimate = ({ 53 | onTapStart, 54 | children, 55 | }: { 56 | onTapStart: ComponentProps['onTapStart'] 57 | children: ReactNode 58 | }) => { 59 | return ( 60 | 61 | {children} 62 | 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /apps/web/src/components/kakaos/Map/Button/index.ts: -------------------------------------------------------------------------------- 1 | import CurrentLocation from './CurrentLocation' 2 | import ZoomInOut from './ZoomInOut' 3 | 4 | const Button = { 5 | CurrentLocation, 6 | ZoomInOut, 7 | } 8 | 9 | export default Button 10 | -------------------------------------------------------------------------------- /apps/web/src/components/kakaos/Map/Container/index.tsx: -------------------------------------------------------------------------------- 1 | import type { CSSProperties, ReactNode } from 'react' 2 | import { useMap } from '../context' 3 | import useMapEvent from '../hooks/useMapEvent' 4 | 5 | type Props = { 6 | onClick?: (map: kakao.maps.Map, e: kakao.maps.event.MouseEvent) => void 7 | onDragStart?: (map: kakao.maps.Map, e: kakao.maps.event.MouseEvent) => void 8 | onDragEnd?: (map: kakao.maps.Map, e: kakao.maps.event.MouseEvent) => void 9 | onZoomChanged?: (map: kakao.maps.Map) => void 10 | style?: CSSProperties 11 | children?: ReactNode 12 | } 13 | 14 | const Container = ({ onClick, onDragStart, onDragEnd, onZoomChanged, style, children }: Props) => { 15 | const { map, mapRef } = useMap() 16 | 17 | useMapEvent(map, 'click', onClick) 18 | useMapEvent(map, 'dragstart', onDragStart) 19 | useMapEvent(map, 'dragend', onDragEnd) 20 | useMapEvent(map, 'zoom_changed', onZoomChanged) 21 | 22 | return ( 23 |
24 | {map ? children : null} 25 |
26 | ) 27 | } 28 | 29 | export default Container 30 | -------------------------------------------------------------------------------- /apps/web/src/components/kakaos/Map/LoadingIndicator/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react' 2 | import { motion } from 'framer-motion' 3 | import { Spinner } from '~/components/common' 4 | 5 | const LoadingIndicator = () => ( 6 | 20 | 21 | 22 | ) 23 | 24 | export default LoadingIndicator 25 | -------------------------------------------------------------------------------- /apps/web/src/components/kakaos/Map/Marker/CustomMarkerOverlay.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react' 3 | import { createPortal } from 'react-dom' 4 | import { useMap } from '../context' 5 | 6 | type Props = { 7 | position: { latitude: number; longitude: number } 8 | children: ReactNode 9 | } 10 | 11 | const CustomMarkerOverlay = forwardRef(function CustomMarkerOverlay( 12 | { position, children }, 13 | ref 14 | ) { 15 | const { map } = useMap() 16 | 17 | const container = useRef(document.createElement('div')) 18 | 19 | const overlayPosition = useMemo(() => { 20 | return new kakao.maps.LatLng(position.latitude, position.longitude) 21 | }, [position.latitude, position.longitude]) 22 | 23 | const overlay = useMemo(() => { 24 | const kakaoCustomOverlay = new kakao.maps.CustomOverlay({ 25 | clickable: true, 26 | position: overlayPosition, 27 | content: container.current, 28 | }) 29 | container.current.style.display = 'none' 30 | 31 | return kakaoCustomOverlay 32 | }, [overlayPosition]) 33 | 34 | useImperativeHandle(ref, () => overlay, [overlay]) 35 | 36 | useEffect(() => { 37 | overlay.setMap(map) 38 | 39 | return () => overlay.setMap(null) 40 | }, [overlay, map]) 41 | 42 | return container.current.parentElement && createPortal(children, container.current.parentElement) 43 | }) 44 | 45 | export default CustomMarkerOverlay 46 | -------------------------------------------------------------------------------- /apps/web/src/components/kakaos/Map/Marker/index.ts: -------------------------------------------------------------------------------- 1 | import CustomMarkerOverlay from './CustomMarkerOverlay' 2 | 3 | const Marker = { CustomMarkerOverlay } 4 | 5 | export default Marker 6 | -------------------------------------------------------------------------------- /apps/web/src/components/kakaos/Map/context/Provider.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | import { useEffect, useMemo, useRef, useState } from 'react' 3 | import { useDebounce } from '@slam/hooks' 4 | import { Context } from '.' 5 | 6 | type Props = { 7 | center: { latitude: number; longitude: number } 8 | level: number 9 | draggable: boolean 10 | zoomable: boolean 11 | maxLevel: number 12 | minLevel: number 13 | onLoaded?: (map: kakao.maps.Map) => void 14 | onBoundChange?: (map: kakao.maps.Map) => void 15 | debounceDelay?: number 16 | children: ReactNode 17 | } 18 | 19 | export const Provider = ({ 20 | center, 21 | level, 22 | draggable, 23 | zoomable, 24 | maxLevel, 25 | minLevel, 26 | onLoaded, 27 | onBoundChange, 28 | debounceDelay = 200, 29 | children, 30 | }: Props) => { 31 | const [, setRender] = useState({}) 32 | const [map, setMap] = useState(null) 33 | const mapRef = useRef(null) 34 | 35 | const render = () => setRender(() => {}) 36 | 37 | useEffect(() => { 38 | kakao.maps.load(() => { 39 | if (mapRef.current) { 40 | const newMap = new window.kakao.maps.Map(mapRef.current, { 41 | draggable: true, 42 | center: new kakao.maps.LatLng(center.latitude, center.longitude), 43 | level, 44 | }) 45 | if (maxLevel) { 46 | newMap.setMaxLevel(maxLevel) 47 | } 48 | 49 | setMap(newMap) 50 | onLoaded?.(newMap) 51 | } 52 | }) 53 | }, []) 54 | 55 | useEffect(() => { 56 | map?.setZoomable(zoomable) 57 | 58 | if (!zoomable) { 59 | map?.setLevel(map?.getLevel()) 60 | map?.setMaxLevel(map?.getLevel()) 61 | map?.setMinLevel(map?.getLevel()) 62 | } 63 | 64 | if (zoomable) { 65 | map?.setMaxLevel(maxLevel) 66 | map?.setMinLevel(minLevel) 67 | } 68 | }, [map, zoomable, level, maxLevel, minLevel]) 69 | 70 | useEffect(() => { 71 | map?.setDraggable(draggable) 72 | }, [map, draggable]) 73 | 74 | useEffect(() => { 75 | map?.setLevel(level) 76 | }, [map, level]) 77 | 78 | useEffect(() => { 79 | if (center) { 80 | map?.panTo(new kakao.maps.LatLng(center.latitude, center.longitude)) 81 | } 82 | 83 | map?.relayout() 84 | }, [map, center]) 85 | 86 | const bounds = map?.getBounds() 87 | const northEast = bounds?.getNorthEast() 88 | const southWest = bounds?.getSouthWest() 89 | 90 | const debouncedNorthEastLat = useDebounce(northEast?.getLat(), debounceDelay) 91 | const debouncedNorthEastLng = useDebounce(northEast?.getLng(), debounceDelay) 92 | const debouncedSouthWestLat = useDebounce(southWest?.getLat(), debounceDelay) 93 | const debouncedSouthWestLng = useDebounce(southWest?.getLng(), debounceDelay) 94 | 95 | useEffect(() => { 96 | if (map && debouncedNorthEastLat && debouncedNorthEastLng && debouncedSouthWestLat && debouncedSouthWestLng) { 97 | requestAnimationFrame(() => { 98 | onBoundChange?.(map) 99 | }) 100 | } 101 | }, [debouncedNorthEastLat, debouncedNorthEastLng, debouncedSouthWestLat, debouncedSouthWestLng]) 102 | 103 | const value = useMemo(() => ({ map, mapRef, render }), [map, mapRef, render]) 104 | 105 | return {children} 106 | } 107 | -------------------------------------------------------------------------------- /apps/web/src/components/kakaos/Map/context/index.ts: -------------------------------------------------------------------------------- 1 | import type { RefObject } from 'react' 2 | import { createContext, useContext } from 'react' 3 | 4 | export const Context = createContext({ 5 | map: null, 6 | mapRef: { current: null }, 7 | } as { 8 | map: kakao.maps.Map | null 9 | mapRef: RefObject 10 | render: () => void 11 | }) 12 | 13 | export const useMap = () => useContext(Context) 14 | -------------------------------------------------------------------------------- /apps/web/src/components/kakaos/Map/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useMapEvent } from './useMapEvent' 2 | -------------------------------------------------------------------------------- /apps/web/src/components/kakaos/Map/hooks/useMapEvent.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useMap } from '../context' 3 | 4 | const useMapEvent = ( 5 | target: kakao.maps.event.EventTarget | null, 6 | type: string, 7 | callback?: (map: kakao.maps.Map, e: kakao.maps.event.MouseEvent) => void 8 | ) => { 9 | const { map, render } = useMap() 10 | const handler = (e: kakao.maps.event.MouseEvent) => { 11 | if (map) { 12 | callback?.(map, e) 13 | } 14 | 15 | render() 16 | } 17 | 18 | useEffect(() => { 19 | if (target) { 20 | kakao.maps.event.addListener(target, type, handler) 21 | 22 | return () => kakao.maps.event.removeListener(target, type, handler) 23 | } 24 | }, [handler, target, type]) 25 | } 26 | 27 | export default useMapEvent 28 | -------------------------------------------------------------------------------- /apps/web/src/components/kakaos/Map/index.tsx: -------------------------------------------------------------------------------- 1 | import type { CSSProperties, ReactNode } from 'react' 2 | import Button from './Button' 3 | import Container from './Container' 4 | import { Provider } from './context/Provider' 5 | import LoadingIndicator from './LoadingIndicator' 6 | import Marker from './Marker' 7 | 8 | // 서울의 경도, 위도 9 | export const DEFAULT_INITIAL_CENTER = { latitude: 37.5665, longitude: 126.978 } 10 | 11 | type Props = { 12 | center?: { latitude: number; longitude: number } 13 | level?: number 14 | minLevel?: number 15 | maxLevel?: number 16 | draggable?: boolean 17 | zoomable?: boolean 18 | onClick?: (map: kakao.maps.Map, e: kakao.maps.event.MouseEvent) => void 19 | onDragStart?: (map: kakao.maps.Map, e: kakao.maps.event.MouseEvent) => void 20 | onDragEnd?: (map: kakao.maps.Map, e: kakao.maps.event.MouseEvent) => void 21 | onZoomChanged?: (map: kakao.maps.Map) => void 22 | onLoaded?: (map: kakao.maps.Map) => void 23 | onBoundChange?: (map: kakao.maps.Map) => void 24 | style?: CSSProperties 25 | children?: ReactNode 26 | } 27 | 28 | const Map = ({ 29 | center = DEFAULT_INITIAL_CENTER, 30 | level = 6, 31 | minLevel = 1, 32 | maxLevel = 8, 33 | draggable = true, 34 | zoomable = true, 35 | onClick, 36 | onDragStart, 37 | onDragEnd, 38 | onZoomChanged, 39 | style, 40 | onLoaded, 41 | onBoundChange, 42 | children, 43 | }: Props) => { 44 | return ( 45 | 55 | 62 | {children} 63 | 64 | 65 | ) 66 | } 67 | 68 | Map.Button = Button 69 | Map.Marker = Marker 70 | Map.LoadingIndicator = LoadingIndicator 71 | 72 | export default Map 73 | -------------------------------------------------------------------------------- /apps/web/src/components/kakaos/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Map } from './Map' 2 | -------------------------------------------------------------------------------- /apps/web/src/components/uis/Badge/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | import { css } from '@emotion/react' 3 | import styled from '@emotion/styled' 4 | 5 | interface Props { 6 | children: ReactNode 7 | count: number 8 | maxCount?: number 9 | showZero?: boolean 10 | dot?: boolean 11 | backgroundColor?: string 12 | textColor?: string 13 | } 14 | 15 | const Badge = ({ 16 | children, 17 | count, 18 | maxCount = Infinity, 19 | showZero = false, 20 | dot = true, 21 | backgroundColor, 22 | textColor, 23 | }: Props) => { 24 | const colorStyle = { 25 | backgroundColor, 26 | color: textColor, 27 | } 28 | 29 | let badge = null 30 | if (count > 0) { 31 | if (dot) { 32 | badge = 33 | } else { 34 | badge = {maxCount && count > maxCount ? `${maxCount}+` : count} 35 | } 36 | } else if (count === 0) { 37 | if (dot) { 38 | badge = showZero ? : null 39 | } else { 40 | badge = showZero ? 0 : null 41 | } 42 | } 43 | 44 | return ( 45 |
51 | {children} 52 | {badge} 53 |
54 | ) 55 | } 56 | 57 | export default Badge 58 | 59 | const Super = styled.sup` 60 | position: absolute; 61 | top: 0; 62 | right: 0; 63 | display: inline-flex; 64 | align-items: center; 65 | height: 20px; 66 | padding: 0 8px; 67 | color: white; 68 | font-size: 12px; 69 | background-color: #f44; 70 | border-radius: 20px; 71 | transform: translate(50%, -50%); 72 | 73 | &.dot { 74 | width: 6px; 75 | height: 6px; 76 | padding: 0; 77 | border-radius: 50%; 78 | } 79 | ` 80 | -------------------------------------------------------------------------------- /apps/web/src/components/uis/BottomModal/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | import { Box } from '@jsxcss/emotion' 3 | 4 | type Props = { 5 | isOpen?: boolean 6 | children: ReactNode 7 | } 8 | 9 | const BottomModal = ({ isOpen = true, children }: Props) => 10 | isOpen ? ( 11 | 21 | {children} 22 | 23 | ) : null 24 | 25 | export default BottomModal 26 | -------------------------------------------------------------------------------- /apps/web/src/components/uis/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithoutRef, ReactNode } from 'react' 2 | import { useTheme } from '@emotion/react' 3 | import { Flex } from '@jsxcss/emotion' 4 | import { motion } from 'framer-motion' 5 | import { Spinner } from '~/components/common' 6 | import type { EmotionTheme } from '~/styles/emotionTheme' 7 | 8 | interface Props 9 | extends Partial, 'onClick' | 'style'>>, 10 | Pick, 'initial' | 'animate' | 'disabled' | 'type'> { 11 | size?: keyof EmotionTheme['sizes']['buttonHeight'] | number 12 | scheme?: keyof EmotionTheme['scheme']['buttons'] 13 | loading?: boolean 14 | fullWidth?: boolean 15 | children?: ReactNode 16 | } 17 | 18 | const Button = ({ 19 | size = 'md', 20 | scheme = 'black', 21 | loading = false, 22 | disabled = false, 23 | fullWidth = false, 24 | ...props 25 | }: Props) => { 26 | const theme = useTheme() 27 | 28 | const height = typeof size === 'string' ? theme.sizes.buttonHeight[size] : size 29 | 30 | const selectedScheme = theme.scheme.buttons[scheme] 31 | 32 | return ( 33 | 50 | {loading && } {props.children} 51 | 52 | ) 53 | } 54 | 55 | export default Button 56 | -------------------------------------------------------------------------------- /apps/web/src/components/uis/FullHeight/index.tsx: -------------------------------------------------------------------------------- 1 | import type { HTMLAttributes, ReactNode } from 'react' 2 | import { useState } from 'react' 3 | import { css } from '@emotion/react' 4 | import { useIsomorphicLayoutEffect } from '@slam/hooks' 5 | 6 | const FullHeight = ({ children, ...props }: { children: ReactNode } & HTMLAttributes) => { 7 | const [height, setHeight] = useState(0) 8 | 9 | useIsomorphicLayoutEffect(() => { 10 | setHeight(window.innerHeight) 11 | }, []) 12 | 13 | return ( 14 |
21 | {children} 22 |
23 | ) 24 | } 25 | 26 | export default FullHeight 27 | -------------------------------------------------------------------------------- /apps/web/src/components/uis/IconButton/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps, MouseEventHandler } from 'react' 2 | import { useTheme } from '@emotion/react' 3 | import { Flex } from '@jsxcss/emotion' 4 | import { motion } from 'framer-motion' 5 | import { Icon } from '~/components/uis' 6 | 7 | interface Props extends ComponentProps { 8 | icon: Pick, 'name' | 'size' | 'color' | 'fill'> 9 | size?: 'sm' | 'md' | 'lg' 10 | type?: ComponentProps['type'] 11 | onClick?: MouseEventHandler & MouseEventHandler 12 | } 13 | 14 | const IconButton = ({ icon, size = 'lg', type = 'button', onClick, ...props }: Props) => { 15 | const theme = useTheme() 16 | 17 | return ( 18 | 30 | 31 | 32 | ) 33 | } 34 | 35 | export default IconButton 36 | -------------------------------------------------------------------------------- /apps/web/src/components/uis/InfiniteScrollSensor/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactElement, RefObject } from 'react' 2 | import { useEffect, useRef } from 'react' 3 | import { useIntersectionObserver } from '@slam/hooks' 4 | 5 | const InfiniteScrollSensor = ({ 6 | onIntersected, 7 | render, 8 | }: { 9 | onIntersected: (entry?: IntersectionObserverEntry) => void 10 | render: (ref: RefObject) => ReactElement 11 | }) => { 12 | const ref = useRef(null) 13 | const entry = useIntersectionObserver(ref, { threshold: 0.1 }) 14 | 15 | useEffect(() => { 16 | if (entry?.isIntersecting) { 17 | onIntersected(entry) 18 | } 19 | }, [entry?.isIntersecting, onIntersected, entry]) 20 | 21 | return render(ref) 22 | } 23 | 24 | export default InfiniteScrollSensor 25 | -------------------------------------------------------------------------------- /apps/web/src/components/uis/LayerOver/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useDisclosure } from './useDisclosure' 2 | -------------------------------------------------------------------------------- /apps/web/src/components/uis/LayerOver/hooks/useDisclosure/index.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | type Options = { 4 | initialState?: boolean 5 | onOpen?: () => void 6 | onClose?: () => void 7 | } 8 | 9 | const useDisclosure = (options: Options) => { 10 | const { initialState = false, onOpen, onClose } = options 11 | 12 | const [isOpen, setIsOpen] = useState(initialState) 13 | 14 | const open = async () => { 15 | setIsOpen(true) 16 | 17 | return onOpen?.() 18 | } 19 | 20 | const close = async () => { 21 | setIsOpen(false) 22 | 23 | return onClose?.() 24 | } 25 | 26 | const toggle = () => (isOpen ? close() : open()) 27 | 28 | return { isOpen, open, close, toggle } 29 | } 30 | 31 | export default useDisclosure 32 | -------------------------------------------------------------------------------- /apps/web/src/components/uis/LayerOver/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | import { createContext, useMemo } from 'react' 3 | import { createPortal } from 'react-dom' 4 | import { useDisclosure } from './hooks' 5 | 6 | const Context = createContext({} as ReturnType) 7 | 8 | type Props = { 9 | portalId?: string 10 | trigger: ReactNode | ((args: Pick, 'open' | 'isOpen' | 'toggle'>) => ReactNode) 11 | layer: ReactNode | ((args: Pick, 'close' | 'isOpen'>) => ReactNode) 12 | options?: Parameters[0] 13 | } 14 | 15 | const LayerOver = ({ portalId, trigger, options, layer }: Props) => { 16 | const layoverId = portalId || 'layover-portal' 17 | 18 | const { isOpen, close, open, toggle } = useDisclosure({ 19 | ...options, 20 | onClose: () => { 21 | options?.onClose?.() 22 | }, 23 | }) 24 | 25 | const portal = useMemo(() => { 26 | if (typeof document !== 'undefined') { 27 | const portalEl = document.getElementById(layoverId) 28 | 29 | if (portalEl) { 30 | return portalEl 31 | } 32 | const newPortalEl = document.createElement('div') 33 | newPortalEl.id = layoverId 34 | newPortalEl.style.cssText = `left: 0; top: 0; position: fixed; z-index:9999;` 35 | document.body.appendChild(newPortalEl) 36 | 37 | return newPortalEl 38 | } 39 | 40 | return null 41 | }, [layoverId]) 42 | 43 | const value = useMemo(() => ({ isOpen, close, open, toggle }), [isOpen, close, open, toggle]) 44 | 45 | return ( 46 | 47 | {typeof trigger === 'function' ? trigger({ isOpen, open, toggle }) : trigger} 48 | {portal && createPortal(typeof layer === 'function' ? layer({ isOpen, close }) : layer, portal)} 49 | 50 | ) 51 | } 52 | 53 | export default LayerOver 54 | -------------------------------------------------------------------------------- /apps/web/src/components/uis/Skeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useState } from 'react' 2 | import { css } from '@emotion/react' 3 | import styled from '@emotion/styled' 4 | 5 | const Base = styled.div` 6 | ${({ theme }) => css` 7 | background: ${theme.colors.gray0100}; 8 | `} 9 | 10 | display: inline-block; 11 | background-position: 0 center; 12 | background-size: 200% 200%; 13 | border-radius: 4px; 14 | animation: skeleton--zoom-in 0.2s ease-out, skeleton--loading 1s infinite; 15 | 16 | @keyframes skeleton--zoom-in { 17 | 0% { 18 | transform: scale(0.95); 19 | opacity: 0; 20 | } 21 | 100% { 22 | transform: scale(1); 23 | opacity: 1; 24 | } 25 | } 26 | 27 | @keyframes skeleton--loading { 28 | 0% { 29 | opacity: 0.1; 30 | } 31 | 50% { 32 | opacity: 1; 33 | } 34 | 100% { 35 | opacity: 0.1; 36 | } 37 | } 38 | ` 39 | 40 | interface BoxProps { 41 | width?: number | string 42 | height?: number | string 43 | } 44 | 45 | const Box = styled(Base)` 46 | width: ${({ width }) => (typeof width === 'number' ? `${width}px` : width)}; 47 | height: ${({ height }) => (typeof height === 'number' ? `${height}px` : height)}; 48 | ` 49 | 50 | interface CircleProps { 51 | size: number | string 52 | } 53 | 54 | const Circle = styled(Base)` 55 | width: ${({ size }) => (typeof size === 'number' ? `${size}px` : size)}; 56 | height: ${({ size }) => (typeof size === 'number' ? `${size}px` : size)}; 57 | border-radius: 50%; 58 | ` 59 | 60 | interface ParagraphProps { 61 | line?: number 62 | fontSize?: number 63 | lineHeight?: number 64 | stepPercentage?: number 65 | lineBreak?: number 66 | } 67 | 68 | export const Paragraph = ({ 69 | line = 3, 70 | fontSize = 16, 71 | lineHeight = 1.6, 72 | stepPercentage = 10, 73 | lineBreak = 4, 74 | }: ParagraphProps) => { 75 | const [randomForMiddle, setRandomForMiddle] = useState(0) 76 | const [randomForLast, setRandomForLast] = useState(0) 77 | 78 | useEffect(() => { 79 | setRandomForMiddle(Math.random()) 80 | setRandomForLast(Math.random()) 81 | }, []) 82 | 83 | const stepWidth = useCallback( 84 | (ratio: number) => Math.floor(ratio / stepPercentage) * stepPercentage, 85 | [stepPercentage] 86 | ) 87 | 88 | // 정갈한 Paragraph 모양을 위한 Step Percentage 89 | const middleLineWidthRandomRatio = useMemo( 90 | () => stepWidth(80 + Math.floor(randomForMiddle * 20)), 91 | [stepWidth, randomForMiddle] 92 | ) 93 | const lastLineWidthRandomRatio = useMemo( 94 | () => stepWidth(20 + Math.floor(randomForLast * 80)), 95 | [stepWidth, randomForLast] 96 | ) 97 | 98 | return ( 99 |
100 | {Array.from(Array(line), (_, index) => 101 | index === line - 1 ? ( 102 | 103 | ) : (index + 1) % lineBreak === 0 ? ( 104 | 105 | ) : ( 106 | 107 | ) 108 | )} 109 |
110 | ) 111 | } 112 | 113 | const Skeleton = { 114 | Base, 115 | Box, 116 | Circle, 117 | Paragraph, 118 | } 119 | 120 | export default Skeleton 121 | -------------------------------------------------------------------------------- /apps/web/src/components/uis/Tab/index.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react' 2 | import { Children, createContext, isValidElement, useContext, useMemo, useState } from 'react' 3 | import { css } from '@emotion/react' 4 | import { Box, Stack } from '@jsxcss/emotion' 5 | import { motion } from 'framer-motion' 6 | 7 | const TabContext = createContext({ tabName: '' }) 8 | TabContext.displayName = 'TabContext' 9 | const useTab = () => useContext(TabContext) 10 | 11 | const Tab = ({ children, defaultTabName }: PropsWithChildren<{ defaultTabName: string }>) => { 12 | const [tabName, setTabName] = useState(defaultTabName) 13 | 14 | const value = useMemo(() => ({ tabName }), [tabName]) 15 | 16 | return ( 17 | 18 | 19 | {Children.map(children, (child) => { 20 | if (!isValidElement(child)) { 21 | return null 22 | } 23 | 24 | const style = tabName === child.props.tabName ? { backgroundColor: '#000000', color: '#ffffff' } : {} 25 | 26 | return ( 27 | setTabName(child.props.tabName)} 33 | textAlign="center" 34 | fontWeight="bold" 35 | cursor="pointer" 36 | {...style} 37 | css={css` 38 | transition: 200ms; 39 | `} 40 | > 41 | {child.props.tabName} 42 | 43 | ) 44 | })} 45 | 46 |
{children}
47 |
48 | ) 49 | } 50 | 51 | type PanelProps = PropsWithChildren<{ tabName: string }> 52 | const Panel = ({ tabName: panelTabName, children }: PanelProps) => { 53 | const { tabName } = useTab() 54 | 55 | return tabName === panelTabName ? <>{children} : null 56 | } 57 | 58 | export default Tab 59 | Tab.Panel = Panel 60 | -------------------------------------------------------------------------------- /apps/web/src/components/uis/Toast/index.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider, css } from '@emotion/react' 2 | import styled from '@emotion/styled' 3 | import { AnimatePresence, LayoutGroup, motion } from 'framer-motion' 4 | import Toast from '~/libs/Toast' 5 | import emotionTheme from '~/styles/emotionTheme' 6 | 7 | const marginBottoms = { 8 | bottomNavigation: 52, 9 | bottomFixedGradient: 74, 10 | } as const 11 | 12 | const badge = { 13 | success: '✅', 14 | error: '⛔️', 15 | warning: '🚧', 16 | info: 'ⓘ', 17 | } as const 18 | 19 | interface ExtraOptions { 20 | isShowProgressBar?: boolean 21 | isShowClose?: boolean 22 | marginBottom?: 'bottomNavigation' | 'bottomFixedGradient' | number 23 | } 24 | 25 | export default new Toast({ 26 | defaultOptions: { 27 | duration: 4000, 28 | delay: 100, 29 | status: 'info', 30 | marginBottom: 0, 31 | }, 32 | Adapter: ({ children }) => ( 33 | 34 | {children} 35 | 36 | ), 37 | List: ({ templates, options: { marginBottom } }) => { 38 | return ( 39 | 44 | {templates} 45 | 46 | ) 47 | }, 48 | Template: ({ 49 | content, 50 | isShow, 51 | options: { delay, duration, status, isShowClose = true, isShowProgressBar = false }, 52 | close, 53 | }) => { 54 | return ( 55 | 56 | {isShow && ( 57 | 76 | 77 | {isShowProgressBar && } 78 |
85 | {status && badge[status]} 86 |
92 | {content} 93 |
94 | {isShowClose && ( 95 | 105 | )} 106 |
107 |
108 |
109 | )} 110 |
111 | ) 112 | }, 113 | }) 114 | 115 | const Container = styled(motion.div)` 116 | overflow: hidden; 117 | color: black; 118 | background-color: rgba(255, 255, 255, 0.5); 119 | border-radius: 16px; 120 | box-shadow: 0 16px 32px -16px rgba(255, 255, 255, 0.4); 121 | box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1); 122 | backdrop-filter: blur(10px); 123 | ` 124 | 125 | const ProgressBar = styled(motion.div)` 126 | position: fixed; 127 | top: 0; 128 | height: 4px; 129 | background-color: ${({ theme }) => theme.colors.blue0500}; 130 | animation-name: progress; 131 | animation-timing-function: linear; 132 | animation-fill-mode: forwards; 133 | @keyframes progress { 134 | 0% { 135 | width: 0; 136 | } 137 | 100% { 138 | width: 100%; 139 | } 140 | } 141 | ` 142 | -------------------------------------------------------------------------------- /apps/web/src/components/uis/Upload/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ChangeEvent, DragEvent, InputHTMLAttributes, ReactNode } from 'react' 2 | import { useEffect, useRef, useState } from 'react' 3 | import { css } from '@emotion/react' 4 | 5 | interface Props extends Omit, 'value' | 'onChange' | 'children'> { 6 | children?: ((props: { file?: File; dragging: boolean }) => ReactNode) | ReactNode 7 | droppable?: boolean 8 | value?: File 9 | onChange?: (file: File) => void 10 | } 11 | 12 | const Upload = ({ children, droppable = false, name, accept, value, onChange, className, ...props }: Props) => { 13 | const [file, setFile] = useState(value) 14 | const [dragging, setDragging] = useState(false) 15 | const ref = useRef(null) 16 | 17 | const handleFileChange = (e: ChangeEvent) => { 18 | const changedFile = (e.target.files as FileList)[0] 19 | setFile(changedFile) 20 | } 21 | 22 | const handleDragEnter = (e: DragEvent) => { 23 | if (!droppable) { 24 | return 25 | } 26 | 27 | e.preventDefault() // 브라우저 기본 이벤트를 막는다. 28 | e.stopPropagation() // 부모나 자식 컴포넌트로 이벤트가 전파되는 것을 막는다. 29 | 30 | if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { 31 | setDragging(true) 32 | } 33 | } 34 | const handleDragLeave = (e: DragEvent) => { 35 | if (!droppable) { 36 | return 37 | } 38 | 39 | e.preventDefault() 40 | e.stopPropagation() 41 | 42 | setDragging(false) 43 | } 44 | const handleDragOver = (e: DragEvent) => { 45 | if (!droppable) { 46 | return 47 | } 48 | 49 | e.preventDefault() 50 | e.stopPropagation() 51 | } 52 | const handleFileDrop = (e: DragEvent) => { 53 | if (!droppable) { 54 | return 55 | } 56 | 57 | e.preventDefault() 58 | e.stopPropagation() 59 | 60 | const changedFile = e.dataTransfer.files[0] 61 | setFile(changedFile) 62 | onChange?.(changedFile) 63 | setDragging(false) 64 | } 65 | 66 | useEffect(() => { 67 | if (file) { 68 | onChange?.(file) 69 | } 70 | }, [file]) 71 | 72 | return ( 73 |
ref.current?.click()} 80 | onDrop={handleFileDrop} 81 | onDragEnter={handleDragEnter} 82 | onDragLeave={handleDragLeave} 83 | onDragOver={handleDragOver} 84 | {...props} 85 | > 86 | 96 | {typeof children === 'function' ? children({ file, dragging }) : children} 97 |
98 | ) 99 | } 100 | 101 | export default Upload 102 | -------------------------------------------------------------------------------- /apps/web/src/components/uis/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Upload } from './Upload' 2 | export { default as Toast } from './Toast' 3 | export { default as Badge } from './Badge' 4 | export { default as Button } from './Button' 5 | export { default as Icon } from './Icon' 6 | export { default as Skeleton } from './Skeleton' 7 | export { default as LayerOver } from './LayerOver' 8 | export { default as InfiniteScrollSensor } from './InfiniteScrollSensor' 9 | export { default as BottomModal } from './BottomModal' 10 | export { default as Tab } from './Tab' 11 | export { default as FullHeight } from './FullHeight' 12 | export { default as IconButton } from './IconButton' 13 | -------------------------------------------------------------------------------- /apps/web/src/constants/index.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | 3 | export const DEFAULT_PROFILE_IMAGE_URL = '/assets/default_profile.svg' 4 | 5 | export const PROXY_PRE_FIX = '/proxy' 6 | 7 | export const COOKIE_TOKEN_EXPIRES = () => dayjs().add(365, 'day').toDate() 8 | 9 | export const env = { 10 | IS_PRODUCTION_MODE: process.env.NODE_ENV === 'production', 11 | 12 | SLAM_TOKEN_KEY: process.env.NEXT_PUBLIC_SLAM_TOKEN_KEY as string, 13 | 14 | SERVICE_API_END_POINT: process.env.NEXT_PUBLIC_SERVICE_API_END_POINT as string, 15 | 16 | SERVICE_API_SUB_FIX: process.env.NEXT_PUBLIC_SERVICE_API_SUB_FIX as string, 17 | 18 | GOOGLE_ANALYTICS_TRACKING_ID: process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_TRACKING_ID as string, 19 | 20 | KAKAO_JAVASCRIPT_KEY: process.env.NEXT_PUBLIC_KAKAO_JAVASCRIPT_KEY as string, 21 | 22 | REDIRECT_URI: process.env.NEXT_PUBLIC_REDIRECT_URI as string, 23 | 24 | SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN as string, 25 | } as const 26 | -------------------------------------------------------------------------------- /apps/web/src/contexts/AnalyticsProvider/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | import { createContext, useEffect, useMemo } from 'react' 3 | import { useRouter } from 'next/router' 4 | import GA from 'react-ga4' 5 | import { env } from '~/constants' 6 | 7 | interface ContextProps { 8 | sendPageview: (pathname: string) => void 9 | } 10 | const Context = createContext({} as ContextProps) 11 | 12 | interface Props { 13 | children: ReactNode 14 | } 15 | 16 | const sendPageview = (pathname: string) => { 17 | GA.send({ hitType: 'pageview', page: pathname }) 18 | } 19 | 20 | const AnalyticsProvider = ({ children }: Props) => { 21 | const router = useRouter() 22 | 23 | useEffect(() => GA.initialize([{ trackingId: env.GOOGLE_ANALYTICS_TRACKING_ID }]), []) 24 | 25 | useEffect(() => { 26 | sendPageview(router.pathname) 27 | }, [router.pathname]) 28 | 29 | const value = useMemo(() => ({ sendPageview }), [sendPageview]) 30 | 31 | return {children} 32 | } 33 | 34 | export default AnalyticsProvider 35 | -------------------------------------------------------------------------------- /apps/web/src/contexts/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AnalyticsProvider } from './AnalyticsProvider' 2 | -------------------------------------------------------------------------------- /apps/web/src/features/QueryClientProvider.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | import { useState } from 'react' 3 | import * as Sentry from '@sentry/nextjs' 4 | import { QueryClient, QueryClientProvider as TanStackQueryClientProvider } from '@tanstack/react-query' 5 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools' 6 | 7 | type Props = { 8 | children: ReactNode 9 | } 10 | const QueryClientProvider = ({ children }: Props) => { 11 | const [queryClient] = useState( 12 | () => 13 | new QueryClient({ 14 | defaultOptions: { 15 | queries: { 16 | retry: 0, 17 | }, 18 | }, 19 | logger: { 20 | log: (message) => { 21 | Sentry.captureMessage(message) 22 | }, 23 | warn: (message) => { 24 | Sentry.captureMessage(message) 25 | }, 26 | error: (error) => { 27 | Sentry.captureException(error) 28 | }, 29 | }, 30 | }) 31 | ) 32 | 33 | return ( 34 | 35 | {children} 36 | 37 | 38 | ) 39 | } 40 | 41 | export default QueryClientProvider 42 | -------------------------------------------------------------------------------- /apps/web/src/features/addresses/index.ts: -------------------------------------------------------------------------------- 1 | export { useAddressQuery } from './useAddressQuery' 2 | -------------------------------------------------------------------------------- /apps/web/src/features/addresses/key.ts: -------------------------------------------------------------------------------- 1 | import type { APICourt } from '@slam/types' 2 | 3 | const key = { 4 | all: ['address'] as const, 5 | byPosition: (position: Pick) => [...key.all, position] as const, 6 | } as const 7 | 8 | export default key 9 | -------------------------------------------------------------------------------- /apps/web/src/features/addresses/useAddressQuery/index.ts: -------------------------------------------------------------------------------- 1 | import { useSuspenseQuery } from '@suspensive/react-query' 2 | import key from '~/features/key' 3 | 4 | export const useAddressQuery = (position: Parameters[0]) => 5 | useSuspenseQuery( 6 | key.addresses.byPosition(position), 7 | () => 8 | new Promise((resolve) => { 9 | const latLng = new kakao.maps.LatLng(position.latitude, position.longitude) 10 | new kakao.maps.services.Geocoder().coord2RegionCode(latLng.getLng(), latLng.getLat(), (result, status) => { 11 | if (status === kakao.maps.services.Status.OK) { 12 | // 도로명 주소 13 | if (result[0]) { 14 | resolve(result[0].address_name) 15 | } 16 | // 법정 주소 17 | else if (result[1]) { 18 | resolve(result[1].address_name) 19 | } 20 | } else { 21 | resolve('주소가 존재하지 않습니다.') 22 | } 23 | }) 24 | }) 25 | ) 26 | -------------------------------------------------------------------------------- /apps/web/src/features/courts/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useCourtsQuery } from './useCourtsQuery' 2 | export { default as useCourtQuery } from './useCourtQuery' 3 | export { default as useCourtCreateMutation } from './useCourtCreateMutation' 4 | -------------------------------------------------------------------------------- /apps/web/src/features/courts/key.ts: -------------------------------------------------------------------------------- 1 | import type { APICourt } from '@slam/types' 2 | import type { api } from '~/apis' 3 | 4 | const key = { 5 | all: ['courts'] as const, 6 | one: (courtId: APICourt['id']) => [...key.all, courtId] as const, 7 | oneFilter: (courtId: APICourt['id'], filter: Parameters[1]) => 8 | [...key.one(courtId), filter] as const, 9 | } as const 10 | 11 | export default key 12 | -------------------------------------------------------------------------------- /apps/web/src/features/courts/useCourtCreateMutation/index.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query' 2 | import { api } from '~/apis' 3 | 4 | const useCourtCreateMutation = () => { 5 | return useMutation(api.courts.createNewCourt) 6 | } 7 | 8 | export default useCourtCreateMutation 9 | -------------------------------------------------------------------------------- /apps/web/src/features/courts/useCourtQuery/index.ts: -------------------------------------------------------------------------------- 1 | import type { APICourt } from '@slam/types' 2 | import type { UseSuspenseQueryOptions } from '@suspensive/react-query' 3 | import { useSuspenseQuery } from '@suspensive/react-query' 4 | import { api } from '~/apis' 5 | import { key } from '~/features' 6 | 7 | const useCourtQuery = ( 8 | courtId: APICourt['id'], 9 | filter: Parameters[1], 10 | options: Pick< 11 | UseSuspenseQueryOptions>['data']>, 12 | 'onSuccess' | 'enabled' 13 | > 14 | ) => 15 | useSuspenseQuery( 16 | key.courts.oneFilter(courtId, filter), 17 | () => api.courts.getCourtDetail(courtId, filter).then(({ data }) => data), 18 | options 19 | ) 20 | 21 | export default useCourtQuery 22 | -------------------------------------------------------------------------------- /apps/web/src/features/courts/useCourtsQuery/index.ts: -------------------------------------------------------------------------------- 1 | import { useSuspenseQuery } from '@suspensive/react-query' 2 | import { api } from '~/apis' 3 | import key from '~/features/key' 4 | 5 | const useCourtsQuery = (filter: Parameters[0]) => 6 | useSuspenseQuery(key.courts.all, () => api.courts.getCourtsByCoordsAndDate(filter).then(({ data }) => data)) 7 | 8 | export default useCourtsQuery 9 | -------------------------------------------------------------------------------- /apps/web/src/features/favorites/index.ts: -------------------------------------------------------------------------------- 1 | export { useGetFavoritesQuery } from './useGetFavoritesQuery' 2 | export { default as useCreateFavoriteMutation } from './useCreateFavoriteMutation' 3 | export { default as useCancelFavoriteMutation } from './useCancelFavoriteMutation' 4 | -------------------------------------------------------------------------------- /apps/web/src/features/favorites/key.ts: -------------------------------------------------------------------------------- 1 | const key = { 2 | all: ['favorites'] as const, 3 | } as const 4 | 5 | export default key 6 | -------------------------------------------------------------------------------- /apps/web/src/features/favorites/useCancelFavoriteMutation/index.ts: -------------------------------------------------------------------------------- 1 | import type { APIFavorite } from '@slam/types' 2 | import { useMutation, useQueryClient } from '@tanstack/react-query' 3 | import { api } from '~/apis' 4 | import key from '~/features/key' 5 | 6 | const useCancelFavoriteMutation = () => { 7 | const queryClient = useQueryClient() 8 | 9 | return useMutation( 10 | ({ favoriteId }: { favoriteId: APIFavorite['id'] }) => 11 | api.favorites.deleteFavorite({ favoriteId }).then(({ data }) => data), 12 | { 13 | onSuccess: () => queryClient.invalidateQueries(key.favorites.all), 14 | } 15 | ) 16 | } 17 | 18 | export default useCancelFavoriteMutation 19 | -------------------------------------------------------------------------------- /apps/web/src/features/favorites/useCreateFavoriteMutation/index.ts: -------------------------------------------------------------------------------- 1 | import type { APICourt } from '@slam/types' 2 | import { useMutation, useQueryClient } from '@tanstack/react-query' 3 | import { api } from '~/apis' 4 | import key from '~/features/key' 5 | 6 | const useCreateFavoriteMutation = () => { 7 | const queryClient = useQueryClient() 8 | 9 | return useMutation( 10 | ({ courtId }: { courtId: APICourt['id'] }) => api.favorites.createFavorite({ courtId }).then(({ data }) => data), 11 | { 12 | onSuccess: () => queryClient.invalidateQueries(key.favorites.all), 13 | } 14 | ) 15 | } 16 | 17 | export default useCreateFavoriteMutation 18 | -------------------------------------------------------------------------------- /apps/web/src/features/favorites/useGetFavoritesQuery/index.ts: -------------------------------------------------------------------------------- 1 | import type { UseSuspenseQueryResultOnLoading, UseSuspenseQueryResultOnSuccess } from '@suspensive/react-query' 2 | import { useSuspenseQuery } from '@suspensive/react-query' 3 | import { api } from '~/apis' 4 | import key from '~/features/key' 5 | 6 | const queryFn = () => api.favorites.getMyFavorites().then(({ data }) => data) 7 | 8 | type ResultSuccess = UseSuspenseQueryResultOnSuccess>> 9 | export function useGetFavoritesQuery(): ResultSuccess 10 | export function useGetFavoritesQuery(options: { enabled: true }): ResultSuccess 11 | export function useGetFavoritesQuery(options: { enabled: false }): UseSuspenseQueryResultOnLoading 12 | export function useGetFavoritesQuery(options: { enabled: boolean }): UseSuspenseQueryResultOnLoading | ResultSuccess 13 | export function useGetFavoritesQuery(options?: { enabled?: boolean }) { 14 | return useSuspenseQuery(key.favorites.all, queryFn, { ...options }) 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/src/features/follows/key.ts: -------------------------------------------------------------------------------- 1 | const key = { 2 | all: ['follows'] as const, 3 | } as const 4 | 5 | export default key 6 | -------------------------------------------------------------------------------- /apps/web/src/features/index.ts: -------------------------------------------------------------------------------- 1 | export { default as key } from './key' 2 | export { default as QueryClientProvider } from './QueryClientProvider' 3 | -------------------------------------------------------------------------------- /apps/web/src/features/key.ts: -------------------------------------------------------------------------------- 1 | import addresses from './addresses/key' 2 | import courts from './courts/key' 3 | import favorites from './favorites/key' 4 | import follows from './follows/key' 5 | import notifications from './notifications/key' 6 | import reservations from './reservations/key' 7 | import users from './users/key' 8 | 9 | const key = { 10 | courts, 11 | favorites, 12 | follows, 13 | notifications, 14 | reservations, 15 | users, 16 | addresses, 17 | } as const 18 | 19 | export default key 20 | -------------------------------------------------------------------------------- /apps/web/src/features/notifications/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useFollowCreateMutation } from './useFollowCreateMutation' 2 | export { default as useFollowCancelMutation } from './useFollowCancelMutation' 3 | export { default as useGetInfiniteNotificationsQuery } from './useGetInfiniteNotificationsQuery' 4 | export { default as useGetNotificationsQuery } from './useGetNotificationsQuery' 5 | -------------------------------------------------------------------------------- /apps/web/src/features/notifications/key.ts: -------------------------------------------------------------------------------- 1 | const key = { 2 | all: ['notifications'] as const, 3 | forCount: () => [...key.all, 'forCount'] as const, 4 | } as const 5 | 6 | export default key 7 | -------------------------------------------------------------------------------- /apps/web/src/features/notifications/useFollowCancelMutation/index.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query' 2 | import { api } from '~/apis' 3 | 4 | const useFollowCancelMutation = () => { 5 | return useMutation(api.follows.deleteFollow) 6 | } 7 | 8 | export default useFollowCancelMutation 9 | -------------------------------------------------------------------------------- /apps/web/src/features/notifications/useFollowCreateMutation/index.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query' 2 | import { api } from '~/apis' 3 | 4 | const useFollowCreateMutation = () => { 5 | return useMutation(api.follows.postFollow) 6 | } 7 | 8 | export default useFollowCreateMutation 9 | -------------------------------------------------------------------------------- /apps/web/src/features/notifications/useGetInfiniteNotificationsQuery/index.ts: -------------------------------------------------------------------------------- 1 | import { useSuspenseInfiniteQuery } from '@suspensive/react-query' 2 | import { api } from '~/apis' 3 | import key from '~/features/key' 4 | 5 | const useGetInfiniteNotificationsQuery = () => 6 | useSuspenseInfiniteQuery( 7 | key.notifications.all, 8 | ({ pageParam = { isFirst: true, lastId: null } }) => 9 | api.notifications.getNotifications({ ...pageParam }).then(({ data }) => data), 10 | { 11 | getNextPageParam: (lastPage) => ({ 12 | isFirst: false, 13 | lastId: lastPage.lastId, 14 | }), 15 | } 16 | ) 17 | 18 | export default useGetInfiniteNotificationsQuery 19 | -------------------------------------------------------------------------------- /apps/web/src/features/notifications/useGetNotificationsQuery/index.ts: -------------------------------------------------------------------------------- 1 | import { useSuspenseQuery } from '@suspensive/react-query' 2 | import { api } from '~/apis' 3 | import key from '~/features/key' 4 | 5 | const useGetNotificationsQuery = () => 6 | useSuspenseQuery(key.notifications.forCount(), () => 7 | api.notifications.getNotifications({ isFirst: true, size: 10, lastId: null }).then(({ data }) => data) 8 | ) 9 | 10 | export default useGetNotificationsQuery 11 | -------------------------------------------------------------------------------- /apps/web/src/features/reservations/index.ts: -------------------------------------------------------------------------------- 1 | export { useGetUpcomingReservationsQuery } from './useGetUpcomingReservationsQuery' 2 | export { default as useGetReservationsInfiniteQuery } from './useGetReservationsInfiniteQuery' 3 | export { default as useCreateReservationMutation } from './useCreateReservationMutation' 4 | export { default as useDeleteReservationMutation } from './useDeleteReservationMutation' 5 | export { default as useGetExpiredReservationsInfiniteQuery } from './useGetExpiredReservationsInfiniteQuery' 6 | -------------------------------------------------------------------------------- /apps/web/src/features/reservations/key.ts: -------------------------------------------------------------------------------- 1 | import type { APICourt } from '@slam/types' 2 | 3 | const key = { 4 | all: ['reservations'] as const, 5 | 6 | upcoming: () => [...key.all, 'upcoming'] as const, 7 | 8 | expired: () => [...key.all, 'expired'] as const, 9 | 10 | court: (courtId: APICourt['id']) => [...key.all, 'courts', courtId] as const, 11 | } as const 12 | 13 | export default key 14 | -------------------------------------------------------------------------------- /apps/web/src/features/reservations/useCreateReservationMutation/index.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query' 2 | import { api } from '~/apis' 3 | 4 | const useCreateReservationMutation = (courtId: Parameters[0]) => 5 | useMutation((data: Parameters[1]) => 6 | api.reservations.createReservation(courtId, data).then(({ data }) => data) 7 | ) 8 | 9 | export default useCreateReservationMutation 10 | -------------------------------------------------------------------------------- /apps/web/src/features/reservations/useDeleteReservationMutation/index.ts: -------------------------------------------------------------------------------- 1 | import type { APIReservation } from '@slam/types' 2 | import { useMutation } from '@tanstack/react-query' 3 | import { api } from '~/apis' 4 | 5 | const useDeleteReservationMutation = () => 6 | useMutation((reservationId: APIReservation['id']) => api.reservations.deleteReservation(reservationId)) 7 | 8 | export default useDeleteReservationMutation 9 | -------------------------------------------------------------------------------- /apps/web/src/features/reservations/useGetExpiredReservationsInfiniteQuery/index.ts: -------------------------------------------------------------------------------- 1 | import { useSuspenseInfiniteQuery } from '@suspensive/react-query' 2 | import { api } from '~/apis' 3 | import key from '~/features/key' 4 | 5 | const useGetExpiredReservationsInfiniteQuery = () => 6 | useSuspenseInfiniteQuery( 7 | key.reservations.expired(), 8 | ({ pageParam = { isFirst: true, lastId: null } }) => 9 | api.reservations.getMyExpiredReservations({ ...pageParam, size: 4 }).then(({ data }) => data), 10 | { 11 | getNextPageParam: (lastPage) => ({ 12 | isFirst: false, 13 | lastId: lastPage.lastId, 14 | }), 15 | } 16 | ) 17 | 18 | export default useGetExpiredReservationsInfiniteQuery 19 | -------------------------------------------------------------------------------- /apps/web/src/features/reservations/useGetReservationsInfiniteQuery/index.ts: -------------------------------------------------------------------------------- 1 | import type { APICourt } from '@slam/types' 2 | import { useSuspenseInfiniteQuery } from '@suspensive/react-query' 3 | import dayjs from 'dayjs' 4 | import { api } from '~/apis' 5 | import key from '~/features/key' 6 | 7 | const useGetReservationsInfiniteQuery = ({ courtId, initialDate }: { courtId: APICourt['id']; initialDate: string }) => 8 | useSuspenseInfiniteQuery(key.reservations.court(courtId), ({ pageParam: date = dayjs(initialDate).toISOString() }) => 9 | api.reservations.getReservationsAtDate({ courtId, date }).then(({ data }) => ({ ...data, date })) 10 | ) 11 | 12 | export default useGetReservationsInfiniteQuery 13 | -------------------------------------------------------------------------------- /apps/web/src/features/reservations/useGetUpcomingReservationsQuery/index.ts: -------------------------------------------------------------------------------- 1 | import type { UseSuspenseQueryResultOnLoading, UseSuspenseQueryResultOnSuccess } from '@suspensive/react-query' 2 | import { useSuspenseQuery } from '@suspensive/react-query' 3 | import { api } from '~/apis' 4 | import key from '~/features/key' 5 | 6 | const queryFn = () => api.reservations.getMyUpcomingReservations().then(({ data }) => data) 7 | 8 | type ResultSuccess = UseSuspenseQueryResultOnSuccess>> 9 | 10 | export function useGetUpcomingReservationsQuery(): ResultSuccess 11 | export function useGetUpcomingReservationsQuery(options: { enabled: true }): ResultSuccess 12 | export function useGetUpcomingReservationsQuery(options: { enabled: false }): UseSuspenseQueryResultOnLoading 13 | export function useGetUpcomingReservationsQuery(options: { 14 | enabled: boolean 15 | }): UseSuspenseQueryResultOnLoading | ResultSuccess 16 | export function useGetUpcomingReservationsQuery(options?: { enabled?: boolean }) { 17 | return useSuspenseQuery(key.reservations.upcoming(), queryFn, { ...options }) 18 | } 19 | -------------------------------------------------------------------------------- /apps/web/src/features/users/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useMyProfileQuery } from './useMyProfileQuery' 2 | export { default as useUserProfileQuery } from './useUserProfileQuery' 3 | export { default as useUserFollowerInfiniteQuery } from './useUserFollowerInfiniteQuery' 4 | export { default as useUserFollowingInfiniteQuery } from './useUserFollowingInfiniteQuery' 5 | export { default as useMyProfileMutation } from './useMyProfileMutation' 6 | export { default as useUpdateMyProfileImageMutation } from './useUpdateMyProfileImageMutation' 7 | export { default as useCurrentUserQuery } from './useCurrentUserQuery' 8 | -------------------------------------------------------------------------------- /apps/web/src/features/users/key.ts: -------------------------------------------------------------------------------- 1 | import type { APIUser } from '@slam/types' 2 | 3 | const key = { 4 | all: ['users'] as const, 5 | 6 | currentUser: () => [...key.all, 'currentUser'] as const, 7 | 8 | myProfile: () => [...key.all, 'myProfile'] as const, 9 | 10 | one: (userId: APIUser['id']) => [...key.all, userId] as const, 11 | 12 | oneFollowings: (userId: APIUser['id']) => [...key.one(userId), 'followings'] as const, 13 | 14 | oneFollowers: (userId: APIUser['id']) => [...key.one(userId), 'followers'] as const, 15 | 16 | otherProfile: (userId: APIUser['id']) => [...key.one(userId), 'otherProfile'] as const, 17 | } as const 18 | 19 | export default key 20 | -------------------------------------------------------------------------------- /apps/web/src/features/users/useCurrentUserQuery/index.ts: -------------------------------------------------------------------------------- 1 | import type { UseQueryOptions } from '@tanstack/react-query' 2 | import { useQuery } from '@tanstack/react-query' 3 | import { api } from '~/apis' 4 | import { key } from '~/features' 5 | 6 | const useCurrentUserQuery = ( 7 | options?: Pick>['data']>, 'onSuccess'> 8 | ) => { 9 | return useQuery(key.users.currentUser(), () => api.users.getUserData().then(({ data }) => data), { 10 | ...options, 11 | }) 12 | } 13 | 14 | export default useCurrentUserQuery 15 | -------------------------------------------------------------------------------- /apps/web/src/features/users/useMyProfileMutation/index.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query' 2 | import { api } from '~/apis' 3 | import key from '~/features/key' 4 | 5 | const useMyProfileMutation = () => { 6 | const queryClient = useQueryClient() 7 | 8 | return useMutation( 9 | (data: Parameters[0]) => api.users.updateMyProfile(data).then(({ data }) => data), 10 | { 11 | onSuccess: () => { 12 | return queryClient.invalidateQueries(key.users.myProfile()) 13 | }, 14 | } 15 | ) 16 | } 17 | 18 | export default useMyProfileMutation 19 | -------------------------------------------------------------------------------- /apps/web/src/features/users/useMyProfileQuery/index.ts: -------------------------------------------------------------------------------- 1 | import { useSuspenseQuery } from '@suspensive/react-query' 2 | import { api } from '~/apis' 3 | import { key } from '~/features' 4 | 5 | const useMyProfileQuery = () => 6 | useSuspenseQuery(key.users.myProfile(), () => api.users.getMyProfile().then(({ data }) => data)) 7 | 8 | export default useMyProfileQuery 9 | -------------------------------------------------------------------------------- /apps/web/src/features/users/useUpdateMyProfileImageMutation/index.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query' 2 | import { api } from '~/apis' 3 | import key from '~/features/key' 4 | 5 | const useUpdateMyProfileImageMutation = () => { 6 | const queryClient = useQueryClient() 7 | 8 | return useMutation(api.users.updateMyProfileImage, { 9 | onSuccess: () => { 10 | return queryClient.invalidateQueries(key.users.myProfile()) 11 | }, 12 | }) 13 | } 14 | 15 | export default useUpdateMyProfileImageMutation 16 | -------------------------------------------------------------------------------- /apps/web/src/features/users/useUserFollowerInfiniteQuery/index.ts: -------------------------------------------------------------------------------- 1 | import type { APIUser } from '@slam/types' 2 | import { useSuspenseInfiniteQuery } from '@suspensive/react-query' 3 | import { api } from '~/apis' 4 | import key from '~/features/key' 5 | 6 | const useUserFollowerInfiniteQuery = (userId: APIUser['id']) => 7 | useSuspenseInfiniteQuery( 8 | key.users.oneFollowers(userId), 9 | ({ pageParam = { isFirst: true, lastId: null } }) => 10 | api.follows.getUserFollowers(userId, pageParam).then(({ data }) => data), 11 | { 12 | getNextPageParam: (lastPage) => ({ 13 | isFirst: false, 14 | lastId: lastPage.lastId, 15 | }), 16 | } 17 | ) 18 | 19 | export default useUserFollowerInfiniteQuery 20 | -------------------------------------------------------------------------------- /apps/web/src/features/users/useUserFollowingInfiniteQuery/index.ts: -------------------------------------------------------------------------------- 1 | import type { APIUser } from '@slam/types' 2 | import { useSuspenseInfiniteQuery } from '@suspensive/react-query' 3 | import { api } from '~/apis' 4 | import key from '~/features/key' 5 | 6 | const useUserFollowingInfiniteQuery = (userId: APIUser['id']) => 7 | useSuspenseInfiniteQuery( 8 | key.users.oneFollowings(userId), 9 | ({ pageParam = { isFirst: true, lastId: null } }) => 10 | api.follows.getUserFollowings(userId, pageParam).then(({ data }) => data), 11 | { 12 | getNextPageParam: (lastPage) => ({ 13 | isFirst: false, 14 | lastId: lastPage.lastId, 15 | }), 16 | } 17 | ) 18 | 19 | export default useUserFollowingInfiniteQuery 20 | -------------------------------------------------------------------------------- /apps/web/src/features/users/useUserProfileQuery/index.ts: -------------------------------------------------------------------------------- 1 | import type { APIUser } from '@slam/types' 2 | import { useSuspenseQuery } from '@suspensive/react-query' 3 | import { api } from '~/apis' 4 | import { key } from '~/features' 5 | 6 | const useUserProfileQuery = ({ userId }: { userId: APIUser['id'] }) => 7 | useSuspenseQuery(key.users.otherProfile(userId), () => 8 | api.users.getUserProfile({ id: userId }).then(({ data }) => data) 9 | ) 10 | 11 | export default useUserProfileQuery 12 | -------------------------------------------------------------------------------- /apps/web/src/hocs/index.ts: -------------------------------------------------------------------------------- 1 | export { default as withShareClick } from './withShareClick' 2 | -------------------------------------------------------------------------------- /apps/web/src/hocs/withShareClick/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentType, UIEvent } from 'react' 2 | import { positionType, proficiencyType } from '@slam/types' 3 | import copy from 'copy-to-clipboard' 4 | import { Toast } from '~/components/uis' 5 | import { sendKakaoLink } from './sendKakaoLink' 6 | import type { ShareArgs } from './types' 7 | 8 | const handleShareClick = (isKakaoInitialized: boolean, options: Parameters[0]) => { 9 | if (isKakaoInitialized) { 10 | sendKakaoLink(options) 11 | } else { 12 | const copyText = options.requestUrl + options.templateArgs.path 13 | 14 | copy(copyText) 15 | Toast.show(`🔗 공유하실 링크를 복사했습니다 (${copyText})`) 16 | } 17 | } 18 | 19 | const withShareClick = (...args: ShareArgs) => { 20 | return (WrappedComponent: ComponentType<{ onClick?: (e?: UIEvent) => void }>) => { 21 | const defaultOptions = { 22 | requestUrl: 23 | window.location.hostname === 'localhost' ? `http://${window.location.host}` : `https://${window.location.host}`, 24 | templateArgs: { 25 | title: '슬램', 26 | subtitle: '같이 농구할 사람이 없다고?', 27 | path: '', 28 | buttonText: '슬램에서 보기', 29 | }, 30 | callback: () => 31 | Toast.show('성공적으로 공유했어요', { 32 | status: 'success', 33 | }), 34 | } 35 | 36 | let options: Parameters[0] = { 37 | ...defaultOptions, 38 | } 39 | 40 | switch (args[0]) { 41 | case 'court': { 42 | const { id, name } = args[1].court 43 | options = { 44 | ...defaultOptions, 45 | templateArgs: { 46 | title: `${name}`, 47 | subtitle: `${name}에서 농구 한판 어때요?`, 48 | path: `/map?courtId=${id}`, 49 | buttonText: `${name} 놀러가기`, 50 | }, 51 | callback: () => 52 | Toast.show(`농구장 공유에 성공했어요🥳`, { 53 | status: 'success', 54 | }), 55 | } 56 | 57 | break 58 | } 59 | 60 | case 'courtChatroom': { 61 | const { id, court } = args[1].courtChatroom 62 | options = { 63 | ...defaultOptions, 64 | templateArgs: { 65 | title: `${court.name}`, 66 | subtitle: `우리 ${court.name} 채팅방으로 놀러오세요`, 67 | path: `/chat/${id}`, 68 | buttonText: `${court.name} 놀러가기`, 69 | }, 70 | callback: () => 71 | Toast.show(`농구장 채팅방 공유에 성공했어요🥳`, { 72 | status: 'success', 73 | }), 74 | } 75 | 76 | break 77 | } 78 | 79 | case 'user': { 80 | const { id, nickname, positions, proficiency } = args[1].user 81 | options = { 82 | ...defaultOptions, 83 | templateArgs: { 84 | title: `${nickname}`, 85 | subtitle: `${nickname}를 소개합니다 86 | 포지션: ${positions.map((position) => positionType[position]).join(', ')}${ 87 | proficiency 88 | ? ` 89 | 실력: ${proficiencyType[proficiency]}` 90 | : '' 91 | }`, 92 | path: `/user/${id}`, 93 | buttonText: `${nickname}를 만나러 가기`, 94 | }, 95 | callback: () => 96 | Toast.show(`사용자 공유에 성공했어요🥳`, { 97 | status: 'success', 98 | }), 99 | } 100 | 101 | break 102 | } 103 | 104 | default: { 105 | throw new Error('지정된 type이 아니면 withShareClick는 eventHandler를 바인딩 할 수 없습니다.') 106 | } 107 | } 108 | 109 | return ( 110 | // handleShareClick(isKakaoInitialized, options)} 112 | // /> 113 | null 114 | ) 115 | } 116 | } 117 | 118 | export default withShareClick 119 | -------------------------------------------------------------------------------- /apps/web/src/hocs/withShareClick/sendKakaoLink.ts: -------------------------------------------------------------------------------- 1 | const DEFAULT_MESSAGE_TEMPLATE_ID = 69947 2 | 3 | type Options = { 4 | requestUrl: string 5 | templateArgs: { 6 | title: string 7 | subtitle: string 8 | path: string 9 | buttonText: string 10 | } 11 | callback?: () => void 12 | } 13 | 14 | export const sendKakaoLink = ({ requestUrl, templateArgs }: Options) => { 15 | // window.Kakao.Link.sendScrap({ 16 | // templateId: DEFAULT_MESSAGE_TEMPLATE_ID, 17 | // requestUrl, 18 | // installTalk: true, 19 | // templateArgs, 20 | // }) 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/src/hocs/withShareClick/types.ts: -------------------------------------------------------------------------------- 1 | import type { APICourt, APICourtChatroom, APIUser, OmitAt } from '@slam/types' 2 | 3 | export type ShareArgs = CourtArgs | CourtChatroomArgs | UserArgs 4 | 5 | type CourtArgs = Args<{ 6 | court: Pick 7 | }> 8 | type CourtChatroomArgs = Args<{ 9 | courtChatroom: OmitAt 10 | }> 11 | type UserArgs = Args<{ 12 | user: OmitAt 13 | }> 14 | 15 | type Args = { 16 | [Key in keyof Obj]: [Key, Obj] 17 | }[keyof Obj] 18 | -------------------------------------------------------------------------------- /apps/web/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useSentry } from './useSentry' 2 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useSentry.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import * as Sentry from '@sentry/nextjs' 3 | 4 | interface Options { 5 | dsn: string 6 | allowUrls: string[] 7 | } 8 | 9 | const useSentry = ({ dsn, allowUrls }: Options) => { 10 | useEffect(() => { 11 | Sentry.init({ 12 | dsn, 13 | enabled: process.env.STAGE === 'production', 14 | allowUrls, 15 | }) 16 | }, [allowUrls, dsn]) 17 | } 18 | 19 | export default useSentry 20 | -------------------------------------------------------------------------------- /apps/web/src/layouts/BottomFixedGradient/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithoutRef, ReactPortal } from 'react' 2 | import { useEffect, useState } from 'react' 3 | import { useTheme } from '@emotion/react' 4 | import type { Flex } from '@jsxcss/emotion' 5 | import { Box } from '@jsxcss/emotion' 6 | import { motion } from 'framer-motion' 7 | import ReactDOM from 'react-dom' 8 | import { useScrollContainer } from '~/layouts' 9 | 10 | const BottomFixedGradient = ({ children }: ComponentPropsWithoutRef) => { 11 | const theme = useTheme() 12 | const scrollContainer = useScrollContainer() 13 | 14 | const [portal, setPortal] = useState(null) 15 | 16 | useEffect(() => { 17 | setPortal( 18 | ReactDOM.createPortal( 19 | <> 20 | 36 | 46 | {children} 47 | 48 | , 49 | scrollContainer.ref.current! 50 | ) 51 | ) 52 | }, [children, scrollContainer.width, scrollContainer.ref, theme.colors.white]) 53 | 54 | return portal 55 | } 56 | 57 | export default BottomFixedGradient 58 | -------------------------------------------------------------------------------- /apps/web/src/layouts/Layout/components/PageLoader/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | import Router, { useRouter } from 'next/router' 3 | import { css, useTheme } from '@emotion/react' 4 | import { Box } from '@jsxcss/emotion' 5 | import { AnimatePresence, motion } from 'framer-motion' 6 | import { useScrollContainer } from '~/layouts' 7 | import { useSetNavigation } from '../../navigations' 8 | 9 | const PageLoader = () => { 10 | const theme = useTheme() 11 | const router = useRouter() 12 | 13 | const visiteds = useRef<{ [pathname: string]: true }>({ 14 | [router.pathname]: true, 15 | }) 16 | const [isProgressBar, setIsProgressBar] = useState(false) 17 | 18 | const scrollContainer = useScrollContainer() 19 | const setNavigation = useSetNavigation() 20 | 21 | useEffect(() => { 22 | const progressBarOn = (url: string) => { 23 | const nextPathname = url.split('?')[0] 24 | if (!visiteds.current[nextPathname]) { 25 | setIsProgressBar(true) 26 | setNavigation.all((prev) => ({ 27 | ...prev, 28 | })) 29 | visiteds.current[nextPathname] = true 30 | } 31 | } 32 | 33 | const progressBarOff = () => { 34 | setIsProgressBar(false) 35 | setNavigation.all((prev) => ({ 36 | ...prev, 37 | })) 38 | } 39 | 40 | Router.events.on('routeChangeStart', progressBarOn) 41 | Router.events.on('routeChangeComplete', progressBarOff) 42 | Router.events.on('routeChangeError', progressBarOff) 43 | 44 | return () => { 45 | Router.events.off('routeChangeStart', progressBarOn) 46 | Router.events.off('routeChangeComplete', progressBarOff) 47 | Router.events.off('routeChangeError', progressBarOff) 48 | } 49 | }, [router.pathname]) 50 | 51 | return ( 52 | 53 | {isProgressBar && ( 54 | 63 | 92 | 93 | )} 94 | 95 | ) 96 | } 97 | 98 | export default PageLoader 99 | -------------------------------------------------------------------------------- /apps/web/src/layouts/Layout/components/ScrollContainer/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode, RefObject } from 'react' 2 | import { createContext, useContext, useMemo, useRef } from 'react' 3 | import { css, useTheme } from '@emotion/react' 4 | 5 | type Value = { 6 | to: (top: number) => void 7 | toTop: () => void 8 | ref: RefObject 9 | height: number 10 | width: number 11 | } 12 | 13 | const Context = createContext({} as Value) 14 | 15 | export const useScrollContainer = () => useContext(Context) 16 | 17 | type Props = { 18 | children: ReactNode 19 | } 20 | 21 | const ScrollContainer = ({ children }: Props) => { 22 | const ref = useRef(null) 23 | const theme = useTheme() 24 | const to = (top: number) => ref.current?.scrollTo({ top, behavior: 'smooth' }) 25 | const toTop = () => to(0) 26 | const height = ref.current?.getClientRects()[0].height ?? 0 27 | const width = ref.current?.getClientRects()[0].width ?? 0 28 | 29 | const value = useMemo(() => ({ to, toTop, ref, height, width }), [to, toTop, ref, height, width]) 30 | 31 | return ( 32 | 33 |
48 | {children} 49 |
50 |
51 | ) 52 | } 53 | 54 | export default ScrollContainer 55 | -------------------------------------------------------------------------------- /apps/web/src/layouts/Layout/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ScrollContainer } from './ScrollContainer' 2 | export { default as PageLoader } from './PageLoader' 3 | -------------------------------------------------------------------------------- /apps/web/src/layouts/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import Image from 'next/image' 3 | import { useRouter } from 'next/router' 4 | import { css } from '@emotion/react' 5 | import { Box, Flex, Stack } from '@jsxcss/emotion' 6 | import { useIntersectionObserver } from '@slam/hooks' 7 | import { QueryErrorBoundary } from '@suspensive/react-query' 8 | import { Button } from '~/components/uis' 9 | import { PageLoader, ScrollContainer } from './components' 10 | import { BottomNavigation, TopNavigation, useNavigationValue } from './navigations' 11 | 12 | interface Props { 13 | children: React.ReactNode 14 | } 15 | 16 | const Layout = ({ children }: Props) => { 17 | const navigation = useNavigationValue() 18 | const topIntersectionObserverRef = useRef(null) 19 | const router = useRouter() 20 | 21 | const topIntersectionObserverEntry = useIntersectionObserver(topIntersectionObserverRef, {}) 22 | 23 | return ( 24 | 25 | 26 | { 28 | console.error(queryError.error) 29 | 30 | return ( 31 | 32 | 33 | basketball 44 | 오류가 발생했습니다. 45 | 53 | 54 | 55 | ) 56 | }} 57 | > 58 | 59 | {navigation.top && } 60 | {children} 61 | 62 | 63 | 64 | {navigation.bottom && } 65 | 66 | ) 67 | } 68 | 69 | export default Layout 70 | -------------------------------------------------------------------------------- /apps/web/src/layouts/Layout/navigations/BottomNavigation/NavIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps } from 'react' 2 | import { useRouter } from 'next/router' 3 | import { css, useTheme } from '@emotion/react' 4 | import { Stack } from '@jsxcss/emotion' 5 | import { motion } from 'framer-motion' 6 | import { Icon } from '~/components/uis' 7 | 8 | const tap = { scale: 0.7 } 9 | 10 | interface Props { 11 | href: string 12 | iconName: ComponentProps['name'] 13 | label: string 14 | isActive?: boolean 15 | onTap?: (href: string) => void 16 | } 17 | 18 | const NavIcon = ({ href, iconName, label = '이름', isActive, onTap }: Props) => { 19 | const router = useRouter() 20 | const theme = useTheme() 21 | const color = isActive ? theme.colors.black : theme.colors.gray0500 22 | 23 | const handleTap = async () => { 24 | onTap?.(href) 25 | await router.push(href) 26 | } 27 | 28 | return ( 29 | 40 | 41 | 42 | 49 | {label} 50 | 51 | 52 | 53 | ) 54 | } 55 | 56 | export default NavIcon 57 | -------------------------------------------------------------------------------- /apps/web/src/layouts/Layout/navigations/BottomNavigation/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps } from 'react' 2 | import { useEffect, useState } from 'react' 3 | import { useRouter } from 'next/router' 4 | import { Flex } from '@jsxcss/emotion' 5 | import { useCurrentUserQuery } from '~/features/users' 6 | import NavIcon from './NavIcon' 7 | 8 | const navIconPropsList: ComponentProps[] = [ 9 | { 10 | label: '즐겨찾기', 11 | href: '/', 12 | iconName: 'star', 13 | }, 14 | { 15 | label: '지도', 16 | href: '/map', 17 | iconName: 'map', 18 | }, 19 | // { 20 | // label: "채팅", 21 | // href: "/chat/list", 22 | // iconName: "message-circle", 23 | // }, 24 | { 25 | label: '예약', 26 | href: '/reservations', 27 | iconName: 'calendar', 28 | }, 29 | ] 30 | 31 | const BottomNavigation = () => { 32 | const router = useRouter() 33 | const [activePathname, setActivePathname] = useState(() => router.pathname) 34 | const currentUserQuery = useCurrentUserQuery() 35 | 36 | useEffect(() => { 37 | setActivePathname(router.pathname) 38 | }, [router.pathname]) 39 | 40 | return ( 41 | 42 | 43 | {navIconPropsList.map(({ href, iconName, label }) => ( 44 | setActivePathname(href)} 51 | /> 52 | ))} 53 | 54 | 55 | ) 56 | } 57 | 58 | export default BottomNavigation 59 | -------------------------------------------------------------------------------- /apps/web/src/layouts/Layout/navigations/Navigation/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | import { useEffect } from 'react' 3 | import type { useNavigationValue } from '../atoms' 4 | import { useSetNavigation } from '../atoms' 5 | 6 | type Props = Pick, 'top' | 'bottom'> & { 7 | children: ReactNode 8 | } 9 | 10 | const Navigation = ({ top = null, bottom = false, children }: Props) => { 11 | const set = useSetNavigation() 12 | 13 | useEffect(() => { 14 | set.all({ 15 | top, 16 | bottom, 17 | }) 18 | 19 | return () => 20 | set.all((prev) => ({ 21 | ...prev, 22 | top: null, 23 | })) 24 | }, [...Object.values(top ?? {}), bottom]) 25 | 26 | return <>{children} 27 | } 28 | 29 | export default Navigation 30 | -------------------------------------------------------------------------------- /apps/web/src/layouts/Layout/navigations/atoms/index.ts: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { atom, useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' 3 | 4 | const navigationState = atom<{ 5 | top?: { 6 | title?: string 7 | isBack?: boolean 8 | isNotification?: boolean 9 | isMenu?: boolean 10 | isProfile?: boolean 11 | Custom?: FC 12 | } | null 13 | bottom?: boolean 14 | }>({ 15 | key: 'navigation', 16 | default: { top: null, bottom: undefined }, 17 | }) 18 | 19 | const useNavigation = () => useRecoilState(navigationState) 20 | const useNavigationValue = () => useRecoilValue(navigationState) 21 | const useSetNavigation = () => { 22 | const set = useSetRecoilState(navigationState) 23 | 24 | return { 25 | all: set, 26 | title: (title: string) => 27 | set((prev) => ({ 28 | ...prev, 29 | top: { ...prev.top, title }, 30 | })), 31 | custom: (Custom: FC) => 32 | set((prev) => ({ 33 | ...prev, 34 | top: { ...prev.top, Custom }, 35 | })), 36 | } 37 | } 38 | 39 | export { useNavigation, useNavigationValue, useSetNavigation } 40 | -------------------------------------------------------------------------------- /apps/web/src/layouts/Layout/navigations/index.ts: -------------------------------------------------------------------------------- 1 | export { default as TopNavigation } from './TopNavigation' 2 | export { default as BottomNavigation } from './BottomNavigation' 3 | export { default as Navigation } from './Navigation' 4 | export * from './atoms' 5 | -------------------------------------------------------------------------------- /apps/web/src/layouts/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Layout } from './Layout' 2 | export { default as BottomFixedGradient } from './BottomFixedGradient' 3 | export { useScrollContainer } from './Layout/components/ScrollContainer' 4 | -------------------------------------------------------------------------------- /apps/web/src/libs/Toast/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as iterateCallWithDelay } from './iterateCallWithDelay' 2 | -------------------------------------------------------------------------------- /apps/web/src/libs/Toast/helpers/iterateCallWithDelay.ts: -------------------------------------------------------------------------------- 1 | const iterateCallWithDelay = (arr: ((...props: any[]) => any)[], delay: number) => { 2 | const newArr = [...arr] 3 | 4 | function next() { 5 | // protect against empty array 6 | if (!newArr.length) { 7 | return 8 | } 9 | 10 | newArr.shift()?.() 11 | 12 | // schedule next iteration 13 | setTimeout(next, delay) 14 | } 15 | // start the iteration 16 | next() 17 | } 18 | 19 | export default iterateCallWithDelay 20 | -------------------------------------------------------------------------------- /apps/web/src/libs/Toast/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useIsMounted } from './useIsMounted' 2 | export { default as useTimeout } from './useTimeout' 3 | export { default as useTimeoutFn } from './useTimeoutFn' 4 | -------------------------------------------------------------------------------- /apps/web/src/libs/Toast/hooks/useIsMounted/index.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | const useIsMounted = () => { 4 | const [isMounted, setIsMounted] = useState(false) 5 | 6 | useEffect(() => { 7 | setIsMounted(true) 8 | }, []) 9 | 10 | return isMounted 11 | } 12 | 13 | export default useIsMounted 14 | -------------------------------------------------------------------------------- /apps/web/src/libs/Toast/hooks/useTimeout/index.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import useTimeoutFn from '../useTimeoutFn' 3 | 4 | const useTimeout = (fn: () => void, ms: number) => { 5 | const [run, clear] = useTimeoutFn(fn, ms) 6 | 7 | useEffect(() => { 8 | run() 9 | 10 | return clear 11 | }, [run, clear]) 12 | 13 | return clear 14 | } 15 | 16 | export default useTimeout 17 | -------------------------------------------------------------------------------- /apps/web/src/libs/Toast/hooks/useTimeoutFn/index.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react' 2 | 3 | const useTimeoutFn = (fn: () => void, ms: number) => { 4 | const timeoutId = useRef() 5 | const callback = useRef(fn) 6 | 7 | useEffect(() => { 8 | callback.current = fn 9 | }, [fn]) 10 | 11 | const run = useCallback(() => { 12 | if (timeoutId.current) { 13 | clearTimeout(timeoutId.current) 14 | } 15 | 16 | timeoutId.current = setTimeout(() => { 17 | callback.current() 18 | }, ms) 19 | }, [ms]) 20 | 21 | const clear = useCallback(() => { 22 | if (timeoutId.current) { 23 | clearTimeout(timeoutId.current) 24 | } 25 | }, []) 26 | 27 | useEffect(() => clear, [clear]) 28 | 29 | return [run, clear] 30 | } 31 | 32 | export default useTimeoutFn 33 | -------------------------------------------------------------------------------- /apps/web/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextMiddleware } from 'next/server' 2 | import { NextResponse } from 'next/server' 3 | import { PROXY_PRE_FIX, env } from '~/constants' 4 | import { deleteCookie } from '~/middlewares/cookies' 5 | import { verify } from '~/middlewares/jwt' 6 | 7 | const jwtSecretKey = process.env.NEXT_PUBLIC_JWT_SECRET_KEY as string 8 | const PRIVATE_PATHS = ['/user', '/courts/create', '/chat/list', '/reservations'] 9 | const PRIVATE_REDIRECT_PATH = '/map' 10 | 11 | const PREVENTED_PATHS = ['/login'] 12 | const PREVENTED_REDIRECT_PATH = '/' 13 | 14 | const middleware: NextMiddleware = async (request) => { 15 | const slamToken = request.cookies.get(env.SLAM_TOKEN_KEY) 16 | const isProxy = request.nextUrl.pathname.startsWith(PROXY_PRE_FIX) 17 | 18 | // 프록시 19 | if (isProxy) { 20 | const { search } = request.nextUrl 21 | const pathname = request.nextUrl.pathname.substring(PROXY_PRE_FIX.length) 22 | const destination = `${env.SERVICE_API_END_POINT}${pathname}${search}` 23 | const headers = new Headers(request.headers) 24 | if (slamToken) { 25 | headers.set('Authorization', `Bearer ${slamToken.value}`) 26 | } 27 | 28 | return NextResponse.rewrite(destination, { request: { headers } }) 29 | } 30 | 31 | // 로그인 리다이렉트 32 | if (request.nextUrl.pathname.startsWith('/login/redirect')) { 33 | const token = request.nextUrl.searchParams.get('token') ?? '' 34 | const isJwtVerified = await verify(token, jwtSecretKey) 35 | 36 | const nextUrl = request.nextUrl.clone() 37 | nextUrl.pathname = isJwtVerified ? PREVENTED_REDIRECT_PATH : PRIVATE_REDIRECT_PATH 38 | nextUrl.search = '' 39 | 40 | const response = NextResponse.redirect(nextUrl) 41 | 42 | if (isJwtVerified) { 43 | response.cookies.set(env.SLAM_TOKEN_KEY, token, { 44 | httpOnly: true, 45 | sameSite: 'strict', 46 | path: '/', 47 | secure: env.IS_PRODUCTION_MODE, 48 | }) 49 | } 50 | 51 | return response 52 | } 53 | 54 | // 로그아웃 55 | if (request.nextUrl.pathname.startsWith('/logout')) { 56 | console.log('logout', env.SLAM_TOKEN_KEY) 57 | 58 | const nextUrl = request.nextUrl.clone() 59 | nextUrl.pathname = '/login' 60 | nextUrl.search = '' 61 | 62 | const response = NextResponse.redirect(nextUrl) 63 | response.cookies.set(env.SLAM_TOKEN_KEY, '', { 64 | expires: new Date('Thu, 01 Jan 1999 00:00:10 GMT'), 65 | }) 66 | 67 | return response 68 | } 69 | 70 | // 토큰이 있는 경우 71 | if (slamToken) { 72 | const isJwtVerified = await verify(slamToken.value, jwtSecretKey) 73 | 74 | if (PREVENTED_PATHS.reduce((acc, path) => acc || request.nextUrl.pathname.includes(path), false)) { 75 | if (isJwtVerified) { 76 | const nextUrl = request.nextUrl.clone() 77 | nextUrl.pathname = PREVENTED_REDIRECT_PATH 78 | nextUrl.search = '' 79 | 80 | return NextResponse.redirect(nextUrl) 81 | } 82 | } 83 | } 84 | 85 | const isPrivatePath = 86 | !!PRIVATE_PATHS.find((path) => request.nextUrl.pathname.includes(path)) || request.nextUrl.pathname === '/' 87 | 88 | if (isPrivatePath) { 89 | if (slamToken) { 90 | const isJwtVerified = await verify(slamToken.value, jwtSecretKey) 91 | 92 | if (!isJwtVerified) { 93 | const nextUrl = request.nextUrl.clone() 94 | nextUrl.pathname = PRIVATE_REDIRECT_PATH 95 | nextUrl.search = '' 96 | 97 | const response = deleteCookie(request, NextResponse.redirect(nextUrl), env.SLAM_TOKEN_KEY) 98 | 99 | return response 100 | } 101 | } else { 102 | const nextUrl = request.nextUrl.clone() 103 | nextUrl.pathname = PRIVATE_REDIRECT_PATH 104 | nextUrl.search = '' 105 | 106 | return NextResponse.redirect(nextUrl) 107 | } 108 | } 109 | } 110 | 111 | export default middleware 112 | -------------------------------------------------------------------------------- /apps/web/src/middlewares/cookies/deleteCookie/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest, NextResponse } from 'next/server' 2 | 3 | const deleteCookie = (request: NextRequest, response: NextResponse, cookie: string) => { 4 | const cookieValue = request.cookies.get(cookie) 5 | if (cookieValue?.value) { 6 | response.cookies.delete(cookie) 7 | } 8 | 9 | return response 10 | } 11 | 12 | export default deleteCookie 13 | -------------------------------------------------------------------------------- /apps/web/src/middlewares/cookies/index.ts: -------------------------------------------------------------------------------- 1 | export { default as deleteCookie } from './deleteCookie' 2 | -------------------------------------------------------------------------------- /apps/web/src/middlewares/jwt/index.ts: -------------------------------------------------------------------------------- 1 | export { default as verify } from './verify' 2 | -------------------------------------------------------------------------------- /apps/web/src/middlewares/jwt/verify/index.ts: -------------------------------------------------------------------------------- 1 | import type { JWTPayload } from 'jose' 2 | import { jwtVerify } from 'jose' 3 | 4 | async function verify(token: string, secret: string): Promise { 5 | try { 6 | const { payload } = await jwtVerify(token, new TextEncoder().encode(secret)) 7 | 8 | return payload 9 | } catch (error) { 10 | return false 11 | } 12 | } 13 | 14 | export default verify 15 | -------------------------------------------------------------------------------- /apps/web/src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { css, useTheme } from '@emotion/react' 2 | import { ErrorMessage } from '~/components/domains' 3 | 4 | const Custom404 = () => { 5 | const theme = useTheme() 6 | 7 | return ( 8 |
18 | 19 |
20 | ) 21 | } 22 | 23 | export default Custom404 24 | -------------------------------------------------------------------------------- /apps/web/src/pages/500.tsx: -------------------------------------------------------------------------------- 1 | import { css, useTheme } from '@emotion/react' 2 | import { ErrorMessage } from '~/components/domains' 3 | 4 | const Custom500 = () => { 5 | const theme = useTheme() 6 | 7 | return ( 8 |
18 | 19 |
20 | ) 21 | } 22 | 23 | export default Custom500 24 | -------------------------------------------------------------------------------- /apps/web/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from 'next/app' 2 | import Head from 'next/head' 3 | import { ChakraProvider } from '@chakra-ui/react' 4 | import { ThemeProvider, css } from '@emotion/react' 5 | import { MediaQueryProvider } from '@jsxcss/emotion' 6 | import { Delay, SuspensiveConfigs, SuspensiveProvider } from '@suspensive/react' 7 | import dayjs from 'dayjs' 8 | import relativeTime from 'dayjs/plugin/relativeTime' 9 | import timezone from 'dayjs/plugin/timezone' 10 | import utc from 'dayjs/plugin/utc' 11 | import { RecoilRoot } from 'recoil' 12 | import { OpenGraph, Spinner } from '~/components/common' 13 | import { FullHeight } from '~/components/uis' 14 | import { env } from '~/constants' 15 | import { AnalyticsProvider } from '~/contexts' 16 | import { QueryClientProvider } from '~/features' 17 | import { useSentry } from '~/hooks' 18 | import { Layout } from '~/layouts' 19 | import { GlobalCSS, chakraTheme, emotionTheme } from '~/styles' 20 | 21 | dayjs.extend(utc) 22 | dayjs.extend(timezone) 23 | dayjs.extend(relativeTime) 24 | dayjs.tz.setDefault('Asia/Seoul') 25 | 26 | const suspensiveConfigs = new SuspensiveConfigs({ 27 | defaultOptions: { 28 | delay: { ms: 200 }, 29 | suspense: { 30 | fallback: ( 31 | 32 | 39 | 40 | 41 | 42 | ), 43 | }, 44 | }, 45 | }) 46 | 47 | const App = ({ Component, pageProps }: AppProps) => { 48 | useSentry({ 49 | dsn: env.SENTRY_DSN, 50 | allowUrls: ['https://slams.app', 'https://dev.slams.app'], 51 | }) 52 | 53 | return ( 54 | <> 55 | 56 | 함께 농구할 사람을 가장 쉽게 찾아보세요 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | ) 79 | } 80 | 81 | export default App 82 | -------------------------------------------------------------------------------- /apps/web/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from 'react' 2 | import type { DocumentContext, DocumentInitialProps } from 'next/document' 3 | import Document, { Head, Html, Main, NextScript } from 'next/document' 4 | import { env } from '~/constants' 5 | import { chakraTheme } from '~/styles' 6 | 7 | class MyDocument extends Document { 8 | static async getInitialProps(ctx: DocumentContext): Promise { 9 | const initialProps = await Document.getInitialProps(ctx) 10 | 11 | return { ...initialProps } 12 | } 13 | 14 | render(): ReactElement { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 27 | 9 | 10 | 33 | 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "packageManager": "pnpm@8.1.1", 5 | "workspaces": [ 6 | "apps/*", 7 | "configs/*", 8 | "packages/*" 9 | ], 10 | "scripts": { 11 | "build": "turbo run build", 12 | "commit": "cz", 13 | "contributors:add": "all-contributors add", 14 | "contributors:generate": "all-contributors generate", 15 | "dev": "turbo run dev", 16 | "graph": "turbo run build --graph=dependencies-graph.html", 17 | "lint": "turbo run lint", 18 | "lint:pack": "packlint sort -R", 19 | "postinstall": "husky install", 20 | "type:check": "turbo run type:check --parallel" 21 | }, 22 | "dependencies": {}, 23 | "devDependencies": { 24 | "@commitlint/cli": "^17.4.0", 25 | "@commitlint/config-conventional": "^17.4.0", 26 | "all-contributors-cli": "^6.24.0", 27 | "commitizen": "^4.2.6", 28 | "cz-conventional-changelog": "^3.3.0", 29 | "husky": "^8.0.3", 30 | "lint-staged": "^13.1.0", 31 | "packlint": "^0.2.4", 32 | "prettier": "^2.8.2", 33 | "turbo": "latest", 34 | "typescript": "^4.9.4" 35 | }, 36 | "engines": { 37 | "node": ">=14.0.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/hooks/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('@slam/eslint/common.js'), 3 | parserOptions: { 4 | tsconfigRootDir: __dirname, 5 | project: './tsconfig.json', 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /packages/hooks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@slam/hooks", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "main": "./src/index.ts", 7 | "types": "./src/index.ts", 8 | "scripts": { 9 | "lint": "eslint src/*" 10 | }, 11 | "dependencies": { 12 | "react": "18.2.0" 13 | }, 14 | "devDependencies": { 15 | "@slam/eslint": "workspace:*", 16 | "@slam/tsconfig": "workspace:*", 17 | "@types/node": "18.7.18", 18 | "@types/react": "^18.0.22", 19 | "typescript": "^4.5.2" 20 | }, 21 | "peerDependencies": { 22 | "react": "^16.8 || ^17 || ^18" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/hooks/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useAsync } from './useAsync' 2 | export { default as useAsyncFn } from './useAsyncFn' 3 | export { default as useClickAway } from './useClickAway' 4 | export { default as useDebounce } from './useDebounce' 5 | export { default as useHotKey } from './useHotKey' 6 | export { default as useHover } from './useHover' 7 | export { default as useInterval } from './useInterval' 8 | export { default as useIntervalFn } from './useIntervalFn' 9 | export { default as useKey } from './useKey' 10 | export { default as useKeyPress } from './useKeyPress' 11 | export { default as useLocalStorage } from './useLocalStorage' 12 | export { default as useResize } from './useResize' 13 | export { default as useSessionStorage } from './useSessionStorage' 14 | export { default as useTimeout } from './useTimeout' 15 | export { default as useTimeoutFn } from './useTimeoutFn' 16 | export { default as useToggle } from './useToggle' 17 | export { default as useIntersectionObserver } from './useIntersectionObserver' 18 | export { default as useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' 19 | export { default as useIsMounted } from './useIsMounted' 20 | -------------------------------------------------------------------------------- /packages/hooks/src/useAsync.ts: -------------------------------------------------------------------------------- 1 | import type { DependencyList } from 'react' 2 | import { useEffect } from 'react' 3 | import type { AsyncFn } from './useAsyncFn' 4 | import useAsyncFn from './useAsyncFn' 5 | 6 | interface StateProps { 7 | isLoading: boolean 8 | value?: any 9 | } 10 | 11 | const useAsync = (fn: AsyncFn, deps: DependencyList): StateProps => { 12 | const [state, callback] = useAsyncFn(fn, deps) 13 | 14 | useEffect(() => { 15 | callback() 16 | }, [callback]) 17 | 18 | return state 19 | } 20 | 21 | export default useAsync 22 | -------------------------------------------------------------------------------- /packages/hooks/src/useAsyncFn.ts: -------------------------------------------------------------------------------- 1 | import type { DependencyList } from 'react' 2 | import { useCallback, useRef, useState } from 'react' 3 | 4 | export type AsyncFn = (...args: any[]) => Promise 5 | 6 | interface StateProps { 7 | isLoading: boolean 8 | value?: T 9 | error?: unknown 10 | } 11 | 12 | const useAsyncFn = ( 13 | fn: (...args: Args) => Promise, 14 | deps: DependencyList 15 | ): [state: StateProps, callback: typeof fn] => { 16 | const lastCallId = useRef(0) 17 | const [state, setState] = useState>({ 18 | isLoading: false, 19 | value: undefined, 20 | error: undefined, 21 | }) 22 | 23 | const callback = useCallback((...args: Parameters) => { 24 | const callId = lastCallId.current + 1 25 | 26 | if (!state.isLoading) { 27 | setState({ ...state, isLoading: true }) 28 | } 29 | 30 | return fn(...args).then( 31 | (value) => { 32 | if (callId === lastCallId.current) { 33 | setState({ value, isLoading: false }) 34 | } 35 | 36 | return value 37 | }, 38 | (error) => { 39 | if (callId === lastCallId.current) { 40 | setState({ error, isLoading: false }) 41 | } 42 | 43 | return error 44 | } 45 | ) 46 | }, deps) 47 | 48 | return [state, callback] 49 | } 50 | 51 | export default useAsyncFn 52 | -------------------------------------------------------------------------------- /packages/hooks/src/useClickAway.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | type Handler = (e: Event) => void 4 | 5 | const events = ['mousedown', 'touchstart'] 6 | 7 | const useClickAway = (handler: Handler) => { 8 | const ref = useRef(null) 9 | const savedHandler = useRef(handler) 10 | 11 | useEffect(() => { 12 | savedHandler.current = handler 13 | }, [handler]) 14 | 15 | useEffect(() => { 16 | const element = ref.current 17 | if (!element) { 18 | return 19 | } 20 | 21 | const handleEvent = (e: Event) => { 22 | if (!element.contains(e.target as Node)) { 23 | savedHandler.current(e) 24 | } 25 | } 26 | 27 | events.forEach((eventName) => document.addEventListener(eventName, handleEvent)) 28 | 29 | return () => events.forEach((eventName) => document.removeEventListener(eventName, handleEvent)) 30 | }, [ref]) 31 | 32 | return ref 33 | } 34 | 35 | export default useClickAway 36 | -------------------------------------------------------------------------------- /packages/hooks/src/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | function useDebounce(value: T, delay?: number): T { 4 | const [debouncedValue, setDebouncedValue] = useState(value) 5 | 6 | useEffect(() => { 7 | const timer = setTimeout(() => setDebouncedValue(value), delay || 500) 8 | 9 | return () => { 10 | clearTimeout(timer) 11 | } 12 | }, [value, delay]) 13 | 14 | return debouncedValue 15 | } 16 | 17 | export default useDebounce 18 | -------------------------------------------------------------------------------- /packages/hooks/src/useHover.ts: -------------------------------------------------------------------------------- 1 | import type { RefObject } from 'react' 2 | import { useCallback, useEffect, useRef, useState } from 'react' 3 | 4 | const useHover = (): [ref: RefObject, state: boolean] => { 5 | const [state, setState] = useState(false) 6 | const ref = useRef(null) 7 | 8 | const handleMouseOver = useCallback(() => setState(true), []) 9 | const handleMouseOut = useCallback(() => setState(false), []) 10 | 11 | useEffect(() => { 12 | const element = ref.current 13 | if (!element) { 14 | return 15 | } 16 | 17 | element.addEventListener('mouseover', handleMouseOver) 18 | element.addEventListener('mouseout', handleMouseOut) 19 | 20 | return () => { 21 | element.removeEventListener('mouseover', handleMouseOver) 22 | element.removeEventListener('mouseout', handleMouseOut) 23 | } 24 | }, [ref, handleMouseOver, handleMouseOut]) 25 | 26 | return [ref, state] 27 | } 28 | 29 | export default useHover 30 | -------------------------------------------------------------------------------- /packages/hooks/src/useIntersectionObserver.ts: -------------------------------------------------------------------------------- 1 | import type { RefObject } from 'react' 2 | import { useEffect, useState } from 'react' 3 | 4 | interface Args extends IntersectionObserverInit { 5 | freezeOnceVisible?: boolean 6 | } 7 | 8 | function useIntersectionObserver( 9 | elementRef: RefObject, 10 | { threshold = 0, root = null, rootMargin = '0%', freezeOnceVisible = false }: Args 11 | ): IntersectionObserverEntry | undefined { 12 | const [entry, setEntry] = useState() 13 | 14 | const frozen = entry?.isIntersecting && freezeOnceVisible 15 | 16 | const updateEntry = ([entry]: IntersectionObserverEntry[]): void => { 17 | setEntry(entry) 18 | } 19 | 20 | useEffect(() => { 21 | const node = elementRef?.current // DOM Ref 22 | const hasIOSupport = !!window.IntersectionObserver 23 | 24 | if (!hasIOSupport || frozen || !node) { 25 | return 26 | } 27 | 28 | const observerParams = { threshold, root, rootMargin } 29 | const observer = new IntersectionObserver(updateEntry, observerParams) 30 | 31 | observer.observe(node) 32 | 33 | return () => observer.disconnect() 34 | }, [elementRef, JSON.stringify(threshold), root, rootMargin, frozen]) 35 | 36 | return entry 37 | } 38 | 39 | export default useIntersectionObserver 40 | -------------------------------------------------------------------------------- /packages/hooks/src/useInterval.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import useIntervalFn from './useIntervalFn' 3 | 4 | const useInterval = (fn: () => void, ms: number) => { 5 | const [run, clear] = useIntervalFn(fn, ms) 6 | 7 | useEffect(() => { 8 | run() 9 | 10 | return clear 11 | }, [run, clear]) 12 | 13 | return clear 14 | } 15 | 16 | export default useInterval 17 | -------------------------------------------------------------------------------- /packages/hooks/src/useIntervalFn.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react' 2 | 3 | const useIntervalFn = (fn: () => void, ms: number) => { 4 | const intervalId = useRef() 5 | const callback = useRef(fn) 6 | 7 | useEffect(() => { 8 | callback.current = fn 9 | }, [fn]) 10 | 11 | const run = useCallback(() => { 12 | if (intervalId.current) { 13 | clearInterval(intervalId.current) 14 | } 15 | 16 | intervalId.current = setInterval(() => { 17 | callback.current() 18 | }, ms) 19 | }, [ms]) 20 | 21 | const clear = useCallback(() => { 22 | if (intervalId.current) { 23 | clearInterval(intervalId.current) 24 | } 25 | }, []) 26 | 27 | useEffect(() => clear, [clear]) 28 | 29 | return [run, clear] 30 | } 31 | 32 | export default useIntervalFn 33 | -------------------------------------------------------------------------------- /packages/hooks/src/useIsMounted.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | const useIsMounted = () => { 4 | const [isMounted, setIsMounted] = useState(false) 5 | 6 | useEffect(() => { 7 | setIsMounted(true) 8 | }, []) 9 | 10 | return isMounted 11 | } 12 | 13 | export default useIsMounted 14 | -------------------------------------------------------------------------------- /packages/hooks/src/useIsomorphicLayoutEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect } from 'react' 2 | 3 | export default typeof document !== 'undefined' ? useLayoutEffect : useEffect 4 | -------------------------------------------------------------------------------- /packages/hooks/src/useKey.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react' 2 | import type { EventKeyValue } from './EventKeyValueType' 3 | 4 | type Event = 'keydown' | 'keyup' 5 | type Handler = () => void 6 | 7 | const useKey = (targetKey: EventKeyValue, handler: Handler, event: Event = 'keydown') => { 8 | const handleKey = useCallback( 9 | ({ key }: KeyboardEvent) => { 10 | if (key === targetKey) { 11 | handler() 12 | } 13 | }, 14 | [targetKey, handler] 15 | ) 16 | 17 | useEffect(() => { 18 | window.addEventListener(event, handleKey) 19 | 20 | return () => { 21 | window.removeEventListener(event, handleKey) 22 | } 23 | }, [event, targetKey, handleKey]) 24 | } 25 | 26 | export default useKey 27 | -------------------------------------------------------------------------------- /packages/hooks/src/useKeyPress.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react' 2 | import type { EventKeyValue } from './EventKeyValueType' 3 | 4 | const useKeyPress = (targetKey: EventKeyValue) => { 5 | const [keyPressed, setKeyPressed] = useState(false) 6 | 7 | const handleKeyDown = useCallback( 8 | ({ key }: KeyboardEvent) => { 9 | if (key === targetKey) { 10 | setKeyPressed(true) 11 | } 12 | }, 13 | [targetKey] 14 | ) 15 | 16 | const handleKeyUp = useCallback( 17 | ({ key }: KeyboardEvent) => { 18 | if (key === targetKey) { 19 | setKeyPressed(false) 20 | } 21 | }, 22 | [targetKey] 23 | ) 24 | 25 | useEffect(() => { 26 | window.addEventListener('keydown', handleKeyDown) 27 | window.addEventListener('keyup', handleKeyUp) 28 | 29 | return () => { 30 | window.removeEventListener('keydown', handleKeyDown) 31 | window.removeEventListener('keyup', handleKeyUp) 32 | } 33 | }) 34 | 35 | return keyPressed 36 | } 37 | 38 | export default useKeyPress 39 | -------------------------------------------------------------------------------- /packages/hooks/src/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | const useLocalStorage = (key: string, initialValue: T): [storedValue: T, setValue: (value: T) => void] => { 4 | const [storedValue, setStoredValue] = useState( 5 | typeof document !== 'undefined' 6 | ? () => { 7 | try { 8 | const item = localStorage.getItem(key) 9 | 10 | return item ? JSON.parse(item) : initialValue 11 | } catch (error) { 12 | console.error(error) 13 | 14 | return initialValue 15 | } 16 | } 17 | : initialValue 18 | ) 19 | 20 | const setValue = (value: T) => { 21 | try { 22 | const valueToStore = typeof value === 'function' ? value(storedValue) : value 23 | 24 | setStoredValue(valueToStore) 25 | localStorage.setItem(key, JSON.stringify(value)) 26 | } catch (error) { 27 | console.error(error) 28 | } 29 | } 30 | 31 | return [storedValue, setValue] 32 | } 33 | 34 | export default useLocalStorage 35 | -------------------------------------------------------------------------------- /packages/hooks/src/useResize.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | type Handler = (contentRect: DOMRectReadOnly) => void 4 | 5 | const useResize = (handler: Handler) => { 6 | const savedHandler = useRef(handler) 7 | const ref = useRef(null) 8 | 9 | useEffect(() => { 10 | savedHandler.current = handler 11 | }, [handler]) 12 | 13 | useEffect(() => { 14 | const element = ref.current 15 | if (!element) { 16 | return 17 | } 18 | 19 | const observer = new ResizeObserver((entries) => { 20 | savedHandler.current(entries[0].contentRect) 21 | }) 22 | 23 | observer.observe(element) 24 | 25 | return () => { 26 | observer.disconnect() 27 | } 28 | }, [ref]) 29 | 30 | return ref 31 | } 32 | 33 | export default useResize 34 | -------------------------------------------------------------------------------- /packages/hooks/src/useSessionStorage.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | const useLocalStorage = (key: string, initialValue: T): [storedValue: T, setValue: (value: T) => void] => { 4 | const [storedValue, setStoredValue] = useState(() => { 5 | try { 6 | const item = sessionStorage.getItem(key) 7 | 8 | return item ? JSON.parse(item) : initialValue 9 | } catch (error) { 10 | // 서버사이드에서 실행되지 않도록 처리 11 | if (typeof document !== 'undefined') { 12 | console.error(error) 13 | } 14 | 15 | return initialValue 16 | } 17 | }) 18 | 19 | const setValue = (value: T) => { 20 | try { 21 | const valueToStore = typeof value === 'function' ? value(storedValue) : value 22 | 23 | setStoredValue(valueToStore) 24 | sessionStorage.setItem(key, JSON.stringify(value)) 25 | } catch (error) { 26 | console.error(error) 27 | } 28 | } 29 | 30 | return [storedValue, setValue] 31 | } 32 | 33 | export default useLocalStorage 34 | -------------------------------------------------------------------------------- /packages/hooks/src/useTimeout.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import useTimeoutFn from './useTimeoutFn' 3 | 4 | const useTimeout = (fn: () => void, ms: number) => { 5 | const [run, clear] = useTimeoutFn(fn, ms) 6 | 7 | useEffect(() => { 8 | run() 9 | 10 | return clear 11 | }, [run, clear]) 12 | 13 | return clear 14 | } 15 | 16 | export default useTimeout 17 | -------------------------------------------------------------------------------- /packages/hooks/src/useTimeoutFn.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react' 2 | 3 | const useTimeoutFn = (fn: () => void, ms: number) => { 4 | const timeoutId = useRef() 5 | const callback = useRef(fn) 6 | 7 | useEffect(() => { 8 | callback.current = fn 9 | }, [fn]) 10 | 11 | const run = useCallback(() => { 12 | if (timeoutId.current) { 13 | clearTimeout(timeoutId.current) 14 | } 15 | 16 | timeoutId.current = setTimeout(() => { 17 | callback.current() 18 | }, ms) 19 | }, [ms]) 20 | 21 | const clear = useCallback(() => { 22 | if (timeoutId.current) { 23 | clearTimeout(timeoutId.current) 24 | } 25 | }, []) 26 | 27 | useEffect(() => clear, [clear]) 28 | 29 | return [run, clear] 30 | } 31 | 32 | export default useTimeoutFn 33 | -------------------------------------------------------------------------------- /packages/hooks/src/useToggle.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react' 2 | 3 | const useToggle = (initialState = false): [boolean, () => void] => { 4 | const [state, setState] = useState(initialState) 5 | const toggle = useCallback(() => setState((state) => !state), []) 6 | 7 | return [state, toggle] 8 | } 9 | 10 | export default useToggle 11 | -------------------------------------------------------------------------------- /packages/hooks/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@slam/tsconfig/react-library.json", 3 | "compilerOptions": { 4 | "baseUrl": "./src" 5 | }, 6 | "include": ["./src"], 7 | "exclude": ["dist", "build", "node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/types/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('@slam/eslint/common.js'), 3 | parserOptions: { 4 | tsconfigRootDir: __dirname, 5 | project: './tsconfig.json', 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /packages/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@slam/types", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "main": "./src/index.ts", 7 | "types": "./src/index.ts", 8 | "scripts": { 9 | "lint": "eslint src/*" 10 | }, 11 | "devDependencies": { 12 | "@slam/eslint": "workspace:*", 13 | "@slam/tsconfig": "workspace:*", 14 | "@slam/utility-types": "workspace:*", 15 | "typescript": "^4.5.2" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/types/src/abstracts/index.ts: -------------------------------------------------------------------------------- 1 | import type { APIUser } from '../objects/user' 2 | 3 | export interface Id { 4 | id: string 5 | } 6 | 7 | export interface Default extends Id { 8 | createdAt: string 9 | updatedAt: string 10 | } 11 | 12 | export interface Send extends Default { 13 | sender: Pick 14 | } 15 | 16 | export interface Receive extends Default { 17 | receiver: Pick 18 | } 19 | 20 | export interface Create extends Default { 21 | creator: Pick 22 | } 23 | 24 | export type OmitAt = Omit 25 | 26 | export interface List { 27 | contents: T[] 28 | } 29 | 30 | export interface CursorList extends List { 31 | lastId: T['id'] | null 32 | } 33 | 34 | export type CursorListRequestOption = 35 | | { isFirst: true; lastId: null; size?: number } 36 | | { isFirst: false; lastId: T['id']; size?: number } 37 | -------------------------------------------------------------------------------- /packages/types/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { APIChat } from './objects/chat' 2 | export type { APIChatRoom, APICourtChatroom, APIUsersChatroom } from './objects/chatroom' 3 | export type { APICourt, Coord } from './objects/court' 4 | export type { APIFavorite } from './objects/favorite' 5 | export type { APIFollow, APIFollower, APIFollowing } from './objects/follow' 6 | export type { APILoudspeaker } from './objects/loudspeaker' 7 | export type { APINewCourt } from './objects/newCourt' 8 | export type { APINotification } from './objects/notification' 9 | export type { APIReservation } from './objects/reservation' 10 | export type { APIUser } from './objects/user' 11 | 12 | export { chatType } from './objects/chat' 13 | export { chatroomAdminType, chatroomType } from './objects/chatroom' 14 | export { textureType } from './objects/court' 15 | export { statusType } from './objects/newCourt' 16 | export { notificationType } from './objects/notification' 17 | export { positionType, proficiencyType, roleType } from './objects/user' 18 | 19 | export type { Create, CursorList, CursorListRequestOption, Default, Id, OmitAt, Receive, Send, List } from './abstracts' 20 | -------------------------------------------------------------------------------- /packages/types/src/objects/chat.ts: -------------------------------------------------------------------------------- 1 | import type { ValueOf } from '@slam/utility-types' 2 | import type { APICourtChatroom, APIUsersChatroom } from './chatroom' 3 | import type { APILoudspeaker } from './loudspeaker' 4 | import type { OmitAt, Send } from '../abstracts' 5 | 6 | export interface APIChat extends Send { 7 | text: string 8 | type: ValueOf 9 | loudspeaker?: OmitAt 10 | chatroom: OmitAt | OmitAt 11 | } 12 | 13 | export const chatType = { 14 | DEFAULT: 'DEFAULT', 15 | LOUDSPEAKER: 'LOUDSPEAKER', 16 | } as const 17 | -------------------------------------------------------------------------------- /packages/types/src/objects/chatroom.ts: -------------------------------------------------------------------------------- 1 | import type { Keyof } from '@slam/utility-types' 2 | import type { APIChat } from './chat' 3 | import type { APICourt } from './court' 4 | import type { APIUser } from './user' 5 | import type { Default } from '../abstracts' 6 | 7 | export interface APIChatRoom extends Default { 8 | admins: Admin[] 9 | type: Keyof 10 | participants: APIUser[] 11 | lastChat: APIChat 12 | } 13 | 14 | export interface APICourtChatroom extends APIChatRoom { 15 | court: APICourt 16 | } 17 | 18 | export type APIUsersChatroom = APIChatRoom 19 | 20 | type Admin = { 21 | id: APIUser['id'] 22 | type: Keyof 23 | } 24 | 25 | export const chatroomType = { 26 | USER: 'USER', 27 | GROUP: 'GROUP', 28 | COURT: 'COURT', 29 | } as const 30 | 31 | export const chatroomAdminType = { 32 | OWNER: 'OWNER', 33 | MAINTAINER: 'MAINTAINER', 34 | } as const 35 | -------------------------------------------------------------------------------- /packages/types/src/objects/court.ts: -------------------------------------------------------------------------------- 1 | import type { Keyof } from '@slam/utility-types' 2 | import type { Default } from '../abstracts' 3 | 4 | export interface APICourt extends Default { 5 | name: string 6 | latitude: number 7 | longitude: number 8 | image: string | null 9 | basketCount: number 10 | texture: Keyof 11 | } 12 | 13 | export const textureType = { 14 | RUBBER: '고무', 15 | URETHANE: '우레탄', 16 | ASPHALT: '아스팔트', 17 | SOIL: '흙', 18 | CONCRETE: '콘크리트', 19 | ETC: '기타', 20 | } as const 21 | 22 | export type Coord = [APICourt['latitude'], APICourt['longitude']] 23 | -------------------------------------------------------------------------------- /packages/types/src/objects/favorite.ts: -------------------------------------------------------------------------------- 1 | import type { APICourt } from './court' 2 | import type { Default } from '../abstracts' 3 | 4 | export interface APIFavorite extends Default { 5 | court: APICourt 6 | } 7 | -------------------------------------------------------------------------------- /packages/types/src/objects/follow.ts: -------------------------------------------------------------------------------- 1 | import type { APIUser } from './user' 2 | import type { Create, OmitAt, Receive, Send } from '../abstracts' 3 | 4 | export interface APIFollow extends Send { 5 | receiver: OmitAt 6 | } 7 | 8 | export type APIFollowing = Receive 9 | export type APIFollower = Create 10 | -------------------------------------------------------------------------------- /packages/types/src/objects/loudspeaker.ts: -------------------------------------------------------------------------------- 1 | import type { APICourt } from './court' 2 | import type { Default, OmitAt } from '../abstracts' 3 | 4 | export interface APILoudspeaker extends Default { 5 | startTime: string 6 | court: OmitAt 7 | } 8 | -------------------------------------------------------------------------------- /packages/types/src/objects/newCourt.ts: -------------------------------------------------------------------------------- 1 | import type { Keyof } from '@slam/utility-types' 2 | import type { APICourt } from './court' 3 | import type { Create } from '../abstracts' 4 | 5 | export type APINewCourt = Create & 6 | Pick & { 7 | status: Keyof 8 | } 9 | 10 | export const statusType = { 11 | ACCEPT: '승인', 12 | DENY: '거절', 13 | READY: '대기', 14 | } as const 15 | -------------------------------------------------------------------------------- /packages/types/src/objects/notification.ts: -------------------------------------------------------------------------------- 1 | import type { Keyof } from '@slam/utility-types' 2 | import type { APIFollow } from './follow' 3 | import type { APILoudspeaker } from './loudspeaker' 4 | import type { Default, OmitAt } from '../abstracts' 5 | 6 | export type APINotification = APINotificationFollow | APINotificationLoudspeaker 7 | 8 | interface APINotificationFollow extends DefaultNotification { 9 | type: 'FOLLOW' 10 | follow: OmitAt 11 | } 12 | 13 | interface APINotificationLoudspeaker extends DefaultNotification { 14 | type: 'LOUDSPEAKER' 15 | loudspeaker: OmitAt 16 | } 17 | interface DefaultNotification extends Default { 18 | type: Keyof 19 | isRead: boolean 20 | isClicked: boolean 21 | } 22 | 23 | export const notificationType = { 24 | FOLLOW: 'FOLLOW', 25 | LOUDSPEAKER: 'LOUDSPEAKER', 26 | } as const 27 | -------------------------------------------------------------------------------- /packages/types/src/objects/reservation.ts: -------------------------------------------------------------------------------- 1 | import type { APICourt } from './court' 2 | import type { APIUser } from './user' 3 | import type { Default, OmitAt } from '../abstracts' 4 | 5 | export interface APIReservation extends Default { 6 | numberOfReservations: number 7 | startTime: string 8 | endTime: string 9 | hasBall: boolean 10 | court: OmitAt 11 | creator: OmitAt 12 | } 13 | -------------------------------------------------------------------------------- /packages/types/src/objects/user.ts: -------------------------------------------------------------------------------- 1 | import type { Keyof } from '@slam/utility-types' 2 | import type { Default } from '../abstracts' 3 | 4 | export interface APIUser extends Default { 5 | description: string | null 6 | email: string | null 7 | profileImage: string | null 8 | role: Keyof 9 | positions: Keyof[] 10 | proficiency: Keyof | null 11 | nickname: string 12 | } 13 | 14 | export const roleType = { 15 | USER: 'USER', 16 | ADMIN: 'ADMIN', 17 | } as const 18 | 19 | export const proficiencyType = { 20 | BEGINNER: '뉴비', 21 | INTERMEDIATE: '중수', 22 | MASTER: '고수', 23 | } as const 24 | 25 | export const positionType = { 26 | PF: '파워포워드', 27 | SF: '스몰포워드', 28 | SG: '슈팅가드', 29 | PG: '포인트가드', 30 | C: '센터', 31 | TBD: '미정', 32 | } as const 33 | -------------------------------------------------------------------------------- /packages/types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@slam/tsconfig/react-library.json", 3 | "compilerOptions": { 4 | "baseUrl": "./src" 5 | }, 6 | "include": ["./src"], 7 | "exclude": ["dist", "build", "node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/utility-types/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('@slam/eslint/common.js'), 3 | parserOptions: { 4 | tsconfigRootDir: __dirname, 5 | project: './tsconfig.json', 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /packages/utility-types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@slam/utility-types", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "main": "./src/index.ts", 7 | "types": "./src/index.ts", 8 | "scripts": { 9 | "lint": "eslint src/*" 10 | }, 11 | "devDependencies": { 12 | "@slam/eslint": "workspace:*", 13 | "@slam/tsconfig": "workspace:*", 14 | "typescript": "^4.5.2" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/utility-types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@slam/tsconfig/react-library.json", 3 | "compilerOptions": { 4 | "baseUrl": "./src" 5 | }, 6 | "include": ["./src"], 7 | "exclude": ["dist", "build", "node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /packlint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | files: ['./package.json', './packages/*/package.json', './apps/*/package.json', './configs/*/package.json'], 3 | } 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "configs/*" 4 | - "packages/*" 5 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": ["**/.env.*local"], 4 | "pipeline": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "outputs": ["dist/**", ".next/**"] 8 | }, 9 | "lint": { 10 | "outputs": [] 11 | }, 12 | "dev": { 13 | "cache": false 14 | } 15 | }, 16 | "globalEnv": [ 17 | "NODE_ENV", 18 | "NEXT_PUBLIC_SLAM_TOKEN_KEY", 19 | "NEXT_PUBLIC_JWT_SECRET_KEY", 20 | "NEXT_PUBLIC_SERVICE_API_END_POINT", 21 | "NEXT_PUBLIC_SERVICE_API_SUB_FIX", 22 | "NEXT_PUBLIC_GOOGLE_ANALYTICS_TRACKING_ID", 23 | "NEXT_PUBLIC_KAKAO_JAVASCRIPT_KEY", 24 | "NEXT_PUBLIC_REDIRECT_URI", 25 | "NEXT_PUBLIC_SENTRY_DSN" 26 | ] 27 | } 28 | --------------------------------------------------------------------------------