diff --git a/client/.eslintrc.json b/client/.eslintrc.json index 4474ccb..2061f22 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -5,12 +5,9 @@ "plugin:prettier/recommended", "plugin:storybook/recommended" ], - "plugins": [ - "@typescript-eslint", - "prettier" - ], + "plugins": ["@typescript-eslint", "prettier"], "rules": { - "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/explicit-module-boundary-types": "warn", "prettier/prettier": "error", "react/react-in-jsx-scope": "off" }, diff --git a/client/next.config.js b/client/next.config.js index f9fcecc..1fa9fda 100644 --- a/client/next.config.js +++ b/client/next.config.js @@ -1,5 +1,15 @@ /** @type {import('next').NextConfig} */ -const nextConfig = { +const runtimeCaching = require('next-pwa/cache'); +const withPWA = require('next-pwa')({ + dest: 'public', + disable: process.env.NODE_ENV === 'development', + register: true, + skipWaiting: true, + runtimeCaching, +}); + +const nextConfig = withPWA({ + reactStrictMode: false, compiler: { emotion: true, }, @@ -14,6 +24,6 @@ const nextConfig = { return config; }, -}; +}); module.exports = nextConfig; diff --git a/client/package.json b/client/package.json index 335ee00..e647339 100644 --- a/client/package.json +++ b/client/package.json @@ -8,7 +8,8 @@ "start": "next start", "lint": "next lint", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "export": "next export" }, "dependencies": { "@emotion/react": "^11.11.1", @@ -19,12 +20,16 @@ "@tanstack/react-query": "^4.36.1", "@types/lodash": "^4.14.199", "axios": "^1.5.1", + "embla-carousel-react": "^8.0.0-rc14", + "framer-motion": "^10.16.4", "jotai": "^2.4.3", "lodash": "^4.17.21", "lottie-react": "^2.4.0", "next": "^13.5.4", + "next-pwa": "^5.6.0", "react": "latest", - "react-dom": "latest" + "react-dom": "latest", + "uuid": "^9.0.1" }, "devDependencies": { "@babel/preset-env": "^7.22.20", @@ -45,6 +50,7 @@ "@types/node": "20.7.2", "@types/react": "^18.2.27", "@types/react-dom": "^18.2.12", + "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.7.3", "@typescript-eslint/parser": "^6.7.3", "chromatic": "^7.4.0", @@ -53,6 +59,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "eslint-plugin-storybook": "^0.6.14", + "next-compose-plugins": "^2.2.1", "prettier": "^3.0.3", "storybook": "^7.5.0-alpha.3", "typescript": "latest" diff --git a/client/public/favicon.png b/client/public/favicon.png new file mode 100644 index 0000000..a5626f5 Binary files /dev/null and b/client/public/favicon.png differ diff --git a/client/public/icons/icon-192x192.png b/client/public/icons/icon-192x192.png new file mode 100644 index 0000000..26bb0fd Binary files /dev/null and b/client/public/icons/icon-192x192.png differ diff --git a/client/public/icons/icon-384x384.png b/client/public/icons/icon-384x384.png new file mode 100644 index 0000000..107923c Binary files /dev/null and b/client/public/icons/icon-384x384.png differ diff --git a/client/public/icons/icon-512x512.png b/client/public/icons/icon-512x512.png new file mode 100644 index 0000000..d959a1c Binary files /dev/null and b/client/public/icons/icon-512x512.png differ diff --git a/client/public/manifest.json b/client/public/manifest.json new file mode 100644 index 0000000..8ac6a9d --- /dev/null +++ b/client/public/manifest.json @@ -0,0 +1,27 @@ +{ + "theme_color": "#1d9bf0", + "background_color": "#1d9bf0", + "display": "fullscreen", + "scope": "/", + "start_url": "/", + "name": "\uc288\ub3c4\ube44", + "short_name": "\uc288\ub3c4\ube44", + "description": "\uc22d\uc2e4\ub300\ud559\uad50 \ub3c4\uc11c\uad00 \ube44\ub300\uba74 \uc608\uc57d \uc2dc\uc2a4\ud15c", + "icons": [ + { + "src": "icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/icon-384x384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/client/public/og.jpg b/client/public/og.jpg new file mode 100644 index 0000000..5f47d4d Binary files /dev/null and b/client/public/og.jpg differ diff --git a/client/public/splash/ipad_splash.png b/client/public/splash/ipad_splash.png new file mode 100644 index 0000000..fabd31d Binary files /dev/null and b/client/public/splash/ipad_splash.png differ diff --git a/client/public/splash/ipadpro1_splash.png b/client/public/splash/ipadpro1_splash.png new file mode 100644 index 0000000..dfe2df7 Binary files /dev/null and b/client/public/splash/ipadpro1_splash.png differ diff --git a/client/public/splash/ipadpro2_splash.png b/client/public/splash/ipadpro2_splash.png new file mode 100644 index 0000000..26e659a Binary files /dev/null and b/client/public/splash/ipadpro2_splash.png differ diff --git a/client/public/splash/ipadpro3_splash.png b/client/public/splash/ipadpro3_splash.png new file mode 100644 index 0000000..d9bb066 Binary files /dev/null and b/client/public/splash/ipadpro3_splash.png differ diff --git a/client/public/splash/iphone5_splash.png b/client/public/splash/iphone5_splash.png new file mode 100644 index 0000000..b9b3d6f Binary files /dev/null and b/client/public/splash/iphone5_splash.png differ diff --git a/client/public/splash/iphone6_splash.png b/client/public/splash/iphone6_splash.png new file mode 100644 index 0000000..c0b7580 Binary files /dev/null and b/client/public/splash/iphone6_splash.png differ diff --git a/client/public/splash/iphoneplus_splash.png b/client/public/splash/iphoneplus_splash.png new file mode 100644 index 0000000..f7b5bab Binary files /dev/null and b/client/public/splash/iphoneplus_splash.png differ diff --git a/client/public/splash/iphonex_splash.png b/client/public/splash/iphonex_splash.png new file mode 100644 index 0000000..288cdae Binary files /dev/null and b/client/public/splash/iphonex_splash.png differ diff --git a/client/public/splash/iphonexr_splash.png b/client/public/splash/iphonexr_splash.png new file mode 100644 index 0000000..ee31551 Binary files /dev/null and b/client/public/splash/iphonexr_splash.png differ diff --git a/client/public/splash/iphonexsmax_splash.png b/client/public/splash/iphonexsmax_splash.png new file mode 100644 index 0000000..528d3fd Binary files /dev/null and b/client/public/splash/iphonexsmax_splash.png differ diff --git a/client/src/@types/Mate.d.ts b/client/src/@types/Mate.d.ts new file mode 100644 index 0000000..3a6d323 --- /dev/null +++ b/client/src/@types/Mate.d.ts @@ -0,0 +1,19 @@ +declare module 'Mate' { + export type StudentIdResponse = { + id: number; + name: string; + memberNo: string; + alternativeId: string; + }; + + export type MateItemType = { + name?: string; + memberNo?: string; + info: { + name: string; + sId: string; + alternativeId: string; + }; + id: number; + }; +} diff --git a/client/src/@types/MyTemplate.ts b/client/src/@types/MyTemplate.ts new file mode 100644 index 0000000..cca2873 --- /dev/null +++ b/client/src/@types/MyTemplate.ts @@ -0,0 +1,19 @@ +import { MateItemType } from 'Mate'; + +export type UsageType = '학습' | '회의' | '기타' | '수업'; + +export type Seminartype = '개방형 세미나실' | '세미나실'; + +export interface MyTemplate { + uuid: string; + title: string; + day: string; + time: number; + usePerson: number; + startTime: string; + finishTime: string; + people: Array; + seminarType: Seminartype; + semina: number[]; + type: UsageType; +} diff --git a/client/src/@types/ReservationList.ts b/client/src/@types/ReservationList.ts new file mode 100644 index 0000000..4d6018b --- /dev/null +++ b/client/src/@types/ReservationList.ts @@ -0,0 +1,82 @@ +interface Room { + id: number; + name: string; + useBeacon: boolean; + checkKiosk: boolean; + useDoorLock: boolean; + checkAccessGate: boolean; + checkAccessGPS: boolean; + roomGeoLocationInfo: string | null; + branchGroup: { + id: number; + name: string; + }; + floor: number; +} + +interface RoomType { + id: number; + code: string; + name: string; + order: number; +} + +interface Patron { + name: string; +} + +interface RoomReservationState { + id: number; + code: string; + name: string; +} + +export interface PatronInfo { + name?: string; + memberNo?: string; + sId?: string; + info?: { + name: string; + sId: string; + }; +} + +interface RoomUseSection { + id: number; + code: string; + name: string; +} + +export interface ReservationData { + success: boolean; + code: string; + message: string; + data: { + totalCount: number; + list: ReservationInfo[]; + }; +} + +interface ReservationInfo { + id: number; + room: Room; + roomType: RoomType; + patron: Patron; + isMyReservation: boolean; + beginTime: string; + endTime: string; + patronCount: number; + patrons: PatronInfo[]; + roomReservationState: RoomReservationState; + equipments: Array; // 데이터가 없어서 빈 배열로 설정 + dateCreated: string; + isEditable: boolean; + renewableRemainingTime: number; + renewalLimitCount: number; + renewalTime: number; + availableRenewal: boolean; + isCheckInTime: boolean; + checkInDateTime: string; + roomUseSection: RoomUseSection; + isAllDayOpen: boolean; +} diff --git a/client/src/@types/Template.d.ts b/client/src/@types/Template.d.ts new file mode 100644 index 0000000..c1f9b75 --- /dev/null +++ b/client/src/@types/Template.d.ts @@ -0,0 +1,34 @@ +declare module 'Template' { + export type WeekdayShort = + | 'Sun' + | 'Mon' + | 'Tue' + | 'Wed' + | 'Thu' + | 'Fri' + | 'Sat'; + export type Patron = { + name: string; + sId: string; + id?: string; + memberNo?: string; + info?: { + name: string; + sId: string; + }; + }; + export type TemplateInfo = { + // template 제목 + title: string; + day: WeekdayShort; + // template 시작 시간, 끝나는 시간 + beginTime: string; + endTime: string; + // template 장소 + place: number; + // template 메모 + memo: string; + // template 동반이용자 + friends: Array; + }; +} diff --git a/client/src/apis/ReserveData.ts b/client/src/apis/ReserveData.ts new file mode 100644 index 0000000..f4b9736 --- /dev/null +++ b/client/src/apis/ReserveData.ts @@ -0,0 +1,40 @@ +import { ReservationData } from '@/@types/ReservationList'; +import axios from 'axios'; + +export const getReservationData = async (AccessToken?: string) => { + const apiUrl = `${process.env.NEXT_PUBLIC_RESERVATION_CHECK}`; + const headers = { + Accept: 'application/json, text/plain, */*', + 'pyxis-auth-token': `${AccessToken}`, //로그인 후 발급받은 토큰 + }; + + const res = await axios.get(apiUrl, { headers }); + + return res.data; +}; + +export const postReservationCancel = async ( + reserveId?: number, + AccessToken?: string, +) => { + try { + const apiUrl = `${process.env.NEXT_PUBLIC_RESERVATION_CHECK}/${reserveId}/cancel?_method=delete`; + const headers = { + Accept: 'application/json;charset=UTF-8', + 'pyxis-auth-token': `${AccessToken}`, //로그인 후 발급받은 토큰 + }; + + // API 호출 + await axios + .post(apiUrl, null, { headers }) + .then((res) => { + console.log('res', res.data); + }) + .catch((error) => { + console.error('Error fetching data:', error); + }); + } catch (error) { + console.error('로그인 오류: ', error); + } finally { + } +}; diff --git a/client/src/apis/auth.ts b/client/src/apis/auth.ts index 3fb8796..22a014f 100644 --- a/client/src/apis/auth.ts +++ b/client/src/apis/auth.ts @@ -1,3 +1,4 @@ +import { getAccessToken } from '@/utils/lib/tokenHandler'; import { AuthData, AuthResponse } from 'Auth'; import axios, { AxiosResponse } from 'axios'; @@ -18,6 +19,34 @@ class AuthApi { return data.data.data; }; + reservation = async ( + room: string, + roomUseSection: number, + beginTime: string, + endTime: string, + patronIds: string[], + ) => { + const requestBody = { + room: room /*세미나실 번호*/, + roomUseSection: + roomUseSection /*세미나실 사용 용도 1: 학습, 2:회의, 3:수업, 4:기타*/, + beginTime: beginTime, //'YYYY-MM-DD HH:MM' + endTime: endTime, + patronCount: patronIds.length /*동반 사용자 수 */, + alternativeIds: patronIds /*동반 사용자의 도서관 id 리스트*/, + }; + + const accessToken = getAccessToken(); + + const headers = { + Accept: 'application/json, text/plain, */*', + 'pyxis-auth-token': `${accessToken}`, + }; + const apiUrl = `${process.env.NEXT_PUBLIC_BASE_URL}pc/rooms/${room}/reserve`; + const response = await axios.post(apiUrl, requestBody, { headers }); + const { data } = response; + return data; + }; } export default AuthApi; diff --git a/client/src/apis/student.ts b/client/src/apis/student.ts new file mode 100644 index 0000000..f55b63c --- /dev/null +++ b/client/src/apis/student.ts @@ -0,0 +1,14 @@ +import { Patron } from 'Template'; +import { get } from './AxiosCreate'; +import { StudentIdResponse } from 'Mate'; + +class StudentApi { + getStudentId = async (info: Patron): Promise => { + const res = await get( + `/patrons/${info.name}/${info.sId}`, + ); + return res; + }; +} + +export default StudentApi; diff --git a/client/src/assets/json/magnifying.json b/client/src/assets/json/magnifying.json new file mode 100644 index 0000000..0803bc6 --- /dev/null +++ b/client/src/assets/json/magnifying.json @@ -0,0 +1 @@ +{"nm":"MagnifyingGlass_Preview","ddd":0,"h":800,"w":800,"meta":{"g":"@lottiefiles/toolkit-js 0.33.2"},"layers":[{"ty":0,"nm":"MagnifyingGlass_AnimOff","sr":1,"st":84,"op":127,"ip":84,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[75,75,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[400,400,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"w":150,"h":150,"refId":"comp_45","ind":1},{"ty":0,"nm":"MagnifyingGlass_AnimOn","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[75,75,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[400,400,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"w":150,"h":150,"refId":"comp_46","ind":2}],"v":"4.10.1","fr":60,"op":127,"ip":0,"assets":[{"nm":"","id":"comp_45","layers":[{"ty":4,"nm":"Streak","sr":1,"st":-139,"op":34,"ip":23,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[-14,-51.889,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[8.25,4.967,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":1,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[-42.25,-2.444],[1.992,-2.149],[0,0]],"o":[[42.25,2.444],[-36.238,39.086],[0,0]],"v":[[59,-11.3],[82.481,25.395],[15.2,23.2]]},"ix":2}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"tm","bm":0,"hd":false,"mn":"ADBE Vector Filter - Trim","nm":"Trim Paths 1","ix":2,"e":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.833,"y":0.833},"s":[83],"t":23},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[68.4],"t":28},{"s":[10],"t":33}],"ix":2},"o":{"a":0,"k":0,"ix":3},"s":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.833,"y":0.833},"s":[83.1],"t":23},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[44.1],"t":28},{"s":[10],"t":33}],"ix":1},"m":1},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5},"c":{"a":0,"k":[0.1137,0.6078,0.9412],"ix":3}}],"ind":1},{"ty":4,"nm":"Streak","sr":1,"st":-139,"op":34,"ip":23,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[-14,-51.889,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[8.25,4.967,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":1,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[-53.75,-0.944],[2.217,-2.448],[0,0]],"o":[[53.75,0.944],[-40.539,44.757],[0,0]],"v":[[57,-12.8],[89.884,29.907],[5.7,25.9]]},"ix":2}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"tm","bm":0,"hd":false,"mn":"ADBE Vector Filter - Trim","nm":"Trim Paths 1","ix":2,"e":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.833,"y":0.833},"s":[83],"t":23},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[68.4],"t":28},{"s":[10],"t":33}],"ix":2},"o":{"a":0,"k":0,"ix":3},"s":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.833,"y":0.833},"s":[83.1],"t":23},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[44.1],"t":28},{"s":[10],"t":33}],"ix":1},"m":1},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5},"c":{"a":0,"k":[0.1137,0.6078,0.9412],"ix":3}}],"ind":2},{"ty":4,"nm":"MagnifyingGlass","sr":1,"st":-129,"op":43,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[84.242,60.334,0],"ix":1},"s":{"a":0,"k":[0,0,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[84.242,60.334,0],"ix":2},"r":{"a":0,"k":-120,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":3,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[-4.55,0.391],[0.91,9.88],[9.36,-0.91],[-0.779,-9.491],[-3.77,-3.12]],"o":[[9.49,-0.909],[-0.78,-9.49],[-9.75,0.78],[0.391,4.679],[3.9,3.12]],"v":[[10.661,5.2],[26.65,-14.301],[7.54,-30.29],[-8.711,-11.18],[-2.47,1.041]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.1137,0.6078,0.9412],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[75.262,72.885],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"tm","bm":0,"hd":false,"mn":"ADBE Vector Filter - Trim","nm":"Trim Paths 1","ix":2,"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":-50,"ix":3},"s":{"a":0,"k":0,"ix":1},"m":1}],"ind":3,"parent":5},{"ty":4,"nm":"MagnifyingGlass","sr":1,"st":-129,"op":43,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[69.323,77.987,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[69.323,77.987,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":3,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 2","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[3.442,-7.412],[0,0],[0,0],[1.69,-0.13],[0.389,4.031],[-1.04,1.299],[0,0],[0,0]],"o":[[0,0],[0,0],[-1.17,1.431],[-4.16,0.39],[-0.131,-1.689],[0,0],[0,0],[5.523,-4.101]],"v":[[-7.799,11.051],[-11.96,24.441],[-25.351,40.43],[-29.9,42.382],[-38.351,35.23],[-37.18,30.422],[-23.921,14.43],[-11.309,7.93]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.1137,0.6078,0.9412],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[75.262,72.885],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"tm","bm":0,"hd":false,"mn":"ADBE Vector Filter - Trim","nm":"Trim Paths 1","ix":2,"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":-50,"ix":3},"s":{"a":0,"k":0,"ix":1},"m":1}],"ind":4,"parent":5},{"ty":4,"nm":"MagnifyingGlass","sr":1,"st":-129,"op":43,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[84.242,60.334,0],"ix":1},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100,100,100],"t":21},{"s":[0,0,100],"t":41}],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[84.242,60.334,0],"ix":2},"r":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":1},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[20.4],"t":21},{"s":[-369],"t":41}],"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[-5.975,0.513],[1.195,12.974],[12.291,-1.195],[-1.041,-12.462],[-4.951,-4.097]],"o":[[12.462,-1.194],[-1.024,-12.462],[-12.803,1.024],[0.513,6.144],[5.121,4.097]],"v":[[11.17,10.838],[32.168,-14.769],[7.073,-35.767],[-14.268,-10.67],[-6.072,5.377]]},"ix":2}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[75.262,72.885],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":1,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":12,"ix":5},"c":{"a":0,"k":[0.1137,0.6078,0.9412],"ix":3}}],"ind":5}]},{"nm":"","id":"comp_46","layers":[{"ty":4,"nm":"Streak","sr":1,"st":-114,"op":64,"ip":49,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[-14,-51.889,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[8.25,4.967,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":1,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[-41.538,-37.469],[-21.446,8.704],[-2.05,39.056]],"o":[[24.762,22.336],[22.993,-9.332],[2.05,-39.056]],"v":[[3.5,49.7],[94.196,69.44],[121.7,10.2]]},"ix":2}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"tm","bm":0,"hd":false,"mn":"ADBE Vector Filter - Trim","nm":"Trim Paths 1","ix":2,"e":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.833,"y":0.833},"s":[83],"t":48},{"o":{"x":0.167,"y":0.167},"i":{"x":0.667,"y":1},"s":[68.4],"t":52},{"s":[10],"t":61}],"ix":2},"o":{"a":0,"k":0,"ix":3},"s":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.833,"y":0.833},"s":[83.1],"t":48},{"o":{"x":0.167,"y":0.167},"i":{"x":0.667,"y":1},"s":[44.1],"t":52},{"s":[10],"t":61}],"ix":1},"m":1},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5},"c":{"a":0,"k":[0.1137,0.6078,0.9412],"ix":3}}],"ind":1},{"ty":4,"nm":"Streak","sr":1,"st":-114,"op":63,"ip":49,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[-14,-51.889,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[8.25,4.967,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":1,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[-40.612,-38.471],[-22.44,12.46],[-2.95,56.244]],"o":[[25.886,24.521],[15.312,-8.502],[2.479,-47.26]],"v":[[4.5,43.2],[94.19,59.185],[120.7,-6.6]]},"ix":2}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"tm","bm":0,"hd":false,"mn":"ADBE Vector Filter - Trim","nm":"Trim Paths 1","ix":2,"e":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.833,"y":0.833},"s":[83],"t":48},{"o":{"x":0.167,"y":0.167},"i":{"x":0.667,"y":1},"s":[68.4],"t":52},{"s":[10],"t":61}],"ix":2},"o":{"a":0,"k":0,"ix":3},"s":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.833,"y":0.833},"s":[83.1],"t":48},{"o":{"x":0.167,"y":0.167},"i":{"x":0.667,"y":1},"s":[44.1],"t":52},{"s":[10],"t":61}],"ix":1},"m":1},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5},"c":{"a":0,"k":[0.1137,0.6078,0.9412],"ix":3}}],"ind":2},{"ty":4,"nm":"Dot","sr":1,"st":0,"op":16,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[84.242,60.334,0],"ix":1},"s":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.833,"y":0.833},"s":[0,0,100],"t":0},{"s":[30,30,100],"t":4}],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.1,"y":1},"s":[139.742,9.334,0],"t":0,"ti":[0,0,0],"to":[0,0,0]},{"s":[77.242,75.834,0],"t":32}],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":3,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[-4.55,0.391],[0.91,9.88],[9.36,-0.91],[-0.779,-9.491],[-3.77,-3.12]],"o":[[9.49,-0.909],[-0.78,-9.49],[-9.75,0.78],[0.391,4.679],[3.9,3.12]],"v":[[10.661,5.2],[26.65,-14.301],[7.54,-30.29],[-8.711,-11.18],[-2.47,1.041]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.1137,0.6078,0.9412],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[75.262,72.885],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"tm","bm":0,"hd":false,"mn":"ADBE Vector Filter - Trim","nm":"Trim Paths 1","ix":2,"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":-50,"ix":3},"s":{"a":0,"k":0,"ix":1},"m":1}],"ind":3},{"ty":4,"nm":"Dot","sr":1,"st":0,"op":84,"ip":44,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[84.242,60.334,0],"ix":1},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[71.681,71.681,100],"t":44},{"s":[0,0,100],"t":58}],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.1,"y":1},"s":[44.932,51.096,0],"t":0,"ti":[0,0,0],"to":[0,0,0]},{"s":[84.242,60.334,0],"t":32}],"ix":2},"r":{"a":0,"k":-120,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":3,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[-4.55,0.391],[0.91,9.88],[9.36,-0.91],[-0.779,-9.491],[-3.77,-3.12]],"o":[[9.49,-0.909],[-0.78,-9.49],[-9.75,0.78],[0.391,4.679],[3.9,3.12]],"v":[[10.661,5.2],[26.65,-14.301],[7.54,-30.29],[-8.711,-11.18],[-2.47,1.041]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.1137,0.6078,0.9412],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[75.262,72.885],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"tm","bm":0,"hd":false,"mn":"ADBE Vector Filter - Trim","nm":"Trim Paths 1","ix":2,"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":-50,"ix":3},"s":{"a":0,"k":0,"ix":1},"m":1}],"ind":4,"parent":8},{"ty":4,"nm":"Matte","sr":1,"st":0,"op":44,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"td":1,"ao":0,"ks":{"a":{"a":0,"k":[84.242,60.334,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[84.242,60.334,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[-5.975,0.513],[1.195,12.974],[12.291,-1.195],[-1.041,-12.462],[-4.951,-4.097]],"o":[[12.462,-1.194],[-1.024,-12.462],[-12.803,1.024],[0.513,6.144],[5.121,4.097]],"v":[[11.17,10.838],[32.168,-14.769],[7.073,-35.767],[-14.268,-10.67],[-6.072,5.377]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.1137,0.6078,0.9412],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[75.262,72.885],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":5,"parent":8},{"ty":4,"nm":"Dot","sr":1,"st":0,"op":44,"ip":0,"hd":false,"ddd":0,"bm":0,"tt":1,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[84.242,60.334,0],"ix":1},"s":{"a":0,"k":[162,162,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.1,"y":1},"s":[139.742,9.334,0],"t":4,"ti":[0,0,0],"to":[0,0,0]},{"s":[77.242,75.834,0],"t":32}],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":3,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[-4.55,0.391],[0.91,9.88],[9.36,-0.91],[-0.779,-9.491],[-3.77,-3.12]],"o":[[9.49,-0.909],[-0.78,-9.49],[-9.75,0.78],[0.391,4.679],[3.9,3.12]],"v":[[10.661,5.2],[26.65,-14.301],[7.54,-30.29],[-8.711,-11.18],[-2.47,1.041]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.1137,0.6078,0.9412],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[75.262,72.885],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"tm","bm":0,"hd":false,"mn":"ADBE Vector Filter - Trim","nm":"Trim Paths 1","ix":2,"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":-50,"ix":3},"s":{"a":0,"k":0,"ix":1},"m":1}],"ind":6},{"ty":4,"nm":"Handle","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[69.323,77.987,0],"ix":1},"s":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.1,"y":1},"s":[0,0,100],"t":44},{"s":[100,100,100],"t":63}],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[69.323,77.987,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":3,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 2","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[3.442,-7.412],[0,0],[0,0],[1.69,-0.13],[0.389,4.031],[-1.04,1.299],[0,0],[0,0]],"o":[[0,0],[0,0],[-1.17,1.431],[-4.16,0.39],[-0.131,-1.689],[0,0],[0,0],[5.523,-4.101]],"v":[[-7.799,11.051],[-11.96,24.441],[-25.351,40.43],[-29.9,42.382],[-38.351,35.23],[-37.18,30.422],[-23.921,14.43],[-11.309,7.93]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.1137,0.6078,0.9412],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[75.262,72.885],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"tm","bm":0,"hd":false,"mn":"ADBE Vector Filter - Trim","nm":"Trim Paths 1","ix":2,"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":-50,"ix":3},"s":{"a":0,"k":0,"ix":1},"m":1}],"ind":7,"parent":8},{"ty":4,"nm":"MagnifyingGlass","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[84.242,60.334,0],"ix":1},"s":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.1,"y":1},"s":[0,0,100],"t":0},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[226,226,100],"t":32},{"o":{"x":0.333,"y":0},"i":{"x":0.1,"y":1},"s":[226,226,100],"t":44},{"s":[100,100,100],"t":63}],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.1,"y":1},"s":[6.242,143.834,0],"t":0,"ti":[0,0,0],"to":[0,0,0]},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[77.242,75.834,0],"t":32,"ti":[0,0,0],"to":[0,0,0]},{"o":{"x":0.333,"y":0},"i":{"x":0.1,"y":1},"s":[77.242,75.834,0],"t":44,"ti":[0,0,0],"to":[0,0,0]},{"s":[84.242,60.334,0],"t":63}],"ix":2},"r":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.1,"y":1},"s":[-240],"t":44},{"o":{"x":0.189,"y":0},"i":{"x":0.562,"y":1},"s":[13.3],"t":63},{"s":[0],"t":82}],"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":1,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[-5.975,0.513],[1.195,12.974],[12.291,-1.195],[-1.041,-12.462],[-4.951,-4.097]],"o":[[12.462,-1.194],[-1.024,-12.462],[-12.803,1.024],[0.513,6.144],[5.121,4.097]],"v":[[11.17,10.838],[32.168,-14.769],[7.073,-35.767],[-14.268,-10.67],[-6.072,5.377]]},"ix":2}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[75.262,72.885],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":1,"lj":1,"ml":4,"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.1,"y":1},"s":[7],"t":44},{"s":[12],"t":63}],"ix":5},"c":{"a":0,"k":[0.1137,0.6078,0.9412],"ix":3}}],"ind":8}]}]} \ No newline at end of file diff --git a/client/src/assets/seos.ts b/client/src/assets/seos.ts new file mode 100644 index 0000000..de9208c --- /dev/null +++ b/client/src/assets/seos.ts @@ -0,0 +1,35 @@ +export const seos = { + index: { + title: '홈', + desc: '숭실대학교 도서관의 시설을 간편하게 예약해보세요.', + }, + landing: { + title: '슈도비', + desc: '숭실대학교 도서관의 시설을 간편하게 예약해보세요.', + }, + login: { + title: '로그인', + desc: '로그인하고, 숭실대학교 도서관의 시설을 간편하게 예약해보세요.', + }, + mate: { + title: '메이트', + desc: '함께하는 친구를 메이트로 등록하고, 간편하게 예약해보세요.', + }, + mypage: { + title: '프로필', + desc: '내 프로필을 확인해보세요.', + ogDesc: '내 프로필을 확인해보세요.', + }, + schedule: { + title: '스케줄', + desc: '현재 예약한 나의 스케줄을 확인해보세요.', + }, + template: { + title: '템플릿', + desc: '템플릿을 만들고, 간편하게 예약해보세요.', + }, + reserve: { + title: '예약하기', + desc: '슈도비에서 메이트와 함께 세미나실을 예약해보세요.', + }, +}; diff --git a/client/src/assets/svg/Edit.svg b/client/src/assets/svg/Edit.svg new file mode 100644 index 0000000..423c0fc --- /dev/null +++ b/client/src/assets/svg/Edit.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/client/src/assets/svg/Remove.svg b/client/src/assets/svg/Remove.svg new file mode 100644 index 0000000..d6ba901 --- /dev/null +++ b/client/src/assets/svg/Remove.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/assets/svg/checkBox-checked.svg b/client/src/assets/svg/checkBox-checked.svg new file mode 100644 index 0000000..2439b06 --- /dev/null +++ b/client/src/assets/svg/checkBox-checked.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/src/assets/svg/checkBox-unChecked.svg b/client/src/assets/svg/checkBox-unChecked.svg new file mode 100644 index 0000000..f81576f --- /dev/null +++ b/client/src/assets/svg/checkBox-unChecked.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/src/assets/svg/leftArrow.svg b/client/src/assets/svg/leftArrow.svg new file mode 100644 index 0000000..8e0d503 --- /dev/null +++ b/client/src/assets/svg/leftArrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/assets/svg/loading.svg b/client/src/assets/svg/loading.svg new file mode 100644 index 0000000..8acb317 --- /dev/null +++ b/client/src/assets/svg/loading.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/assets/svg/plus-circle.svg b/client/src/assets/svg/plus-circle.svg new file mode 100644 index 0000000..1e110f9 --- /dev/null +++ b/client/src/assets/svg/plus-circle.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/client/src/assets/svg/rightArrow.svg b/client/src/assets/svg/rightArrow.svg new file mode 100644 index 0000000..7f31419 --- /dev/null +++ b/client/src/assets/svg/rightArrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/atoms/editingState.ts b/client/src/atoms/editingState.ts new file mode 100644 index 0000000..59d6c32 --- /dev/null +++ b/client/src/atoms/editingState.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const editingState = atom(false); diff --git a/client/src/atoms/fullpageState.ts b/client/src/atoms/fullpageState.ts new file mode 100644 index 0000000..0f30574 --- /dev/null +++ b/client/src/atoms/fullpageState.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const fullpageState = atom(false); diff --git a/client/src/atoms/templateState.ts b/client/src/atoms/templateState.ts new file mode 100644 index 0000000..0fc0153 --- /dev/null +++ b/client/src/atoms/templateState.ts @@ -0,0 +1,19 @@ +import { MyTemplate } from '@/@types/MyTemplate'; +import { atom } from 'jotai'; + +export const initTemplateState: MyTemplate = { + uuid: '', + title: '', + day: '', + time: 0, + usePerson: 0, + startTime: '', + finishTime: '', + people: [], + semina: [], + type: '회의', + seminarType: '세미나실', +}; + +export const templateState = atom(initTemplateState); +export const myTemplateListState = atom([]); diff --git a/client/src/atoms/toastState.ts b/client/src/atoms/toastState.ts new file mode 100644 index 0000000..55af8ad --- /dev/null +++ b/client/src/atoms/toastState.ts @@ -0,0 +1,17 @@ +import { atom } from "jotai"; + +export type ToastTheme = "negative" | "positive"; + +interface ToastType { + isOpen: boolean; + theme: ToastTheme; + content: string; +} + +export const toastState = atom({ + isOpen: false, + theme: "positive", + content: "", +}); + +export const toastTransitionState = atom(false); diff --git a/client/src/components/AddTemplate/CompanionsSelect.tsx b/client/src/components/AddTemplate/CompanionsSelect.tsx new file mode 100644 index 0000000..d0c1f71 --- /dev/null +++ b/client/src/components/AddTemplate/CompanionsSelect.tsx @@ -0,0 +1,95 @@ +import { MateManageKit } from '@/components/Mate'; +import { useMate, useTemplate } from '@/hooks'; +import styled from '@emotion/styled'; +import { Title } from '../Layouts'; +import { PageContainer, flex } from '@/styles/tokens'; +import RoundButton from '../Buttons/Round'; +import { TYPO } from '@/styles/typo'; +import { COLORS } from '@/styles/colors'; +import { css } from '@emotion/react'; +import { injectAnimation } from '@/styles/animations'; +import { useLayoutEffect } from 'react'; + +const CompanionsSelect = () => { + const { selectedList, handleSelect } = useMate(); + const { settingHeader, settingCompanion, handleNextStage, template } = + useTemplate(); + + const handleRoute = () => { + settingCompanion(selectedList); + handleNextStage('companion'); + }; + + useLayoutEffect(() => { + settingHeader(); + }, []); + + return ( + + + + </TitleBox> + <MateManageKit + kitType="selectable" + handleSelect={handleSelect} + selectedList={selectedList} + /> + <NextBox css={paddingStyle}> + {template.seminarType === '개방형 세미나실' ? ( + <WarningBox display={true}>최대 2명까지 선택 가능합니다.</WarningBox> + ) : ( + <WarningBox display={selectedList.length >= 8}> + {selectedList.length >= 8 ? '최대 8명까지 선택 가능합니다.' : '.'} + </WarningBox> + )} + <ButtonWrapper> + <RoundButton + title="예약 가능 시간 탐색하기" + theme="primary" + disabled={selectedList.length < 2} + onClick={handleRoute} + /> + </ButtonWrapper> + </NextBox> + </PageContainer> + ); +}; + +const TitleBox = styled.div``; + +const NextBox = styled.div` + width: 100%; + ${flex('column', 'end', 'center', 1)} +`; + +const WarningBox = styled.div<{ display: boolean }>` + ${TYPO.text2.Reg}; + color: ${(props) => (props.display ? COLORS.tomato : COLORS.white)}; + display: flex; + justify-content: center; + margin-bottom: 13px; +`; + +const pageStyle = css` + width: 100%; + padding: 3rem 0rem; + padding-bottom: 10rem; + position: relative; + ${flex('column', 'start', 'start', 3)}; + ${injectAnimation('fadeInTopDown', '0.5s', 'ease')}; +`; + +const paddingStyle = css` + padding: 0rem 2.7rem; +`; + +const ButtonWrapper = styled.div` + width: 95%; + margin-top: 4rem; +`; + +export default CompanionsSelect; diff --git a/client/src/components/AddTemplate/NameTimeType.tsx b/client/src/components/AddTemplate/NameTimeType.tsx new file mode 100644 index 0000000..257313c --- /dev/null +++ b/client/src/components/AddTemplate/NameTimeType.tsx @@ -0,0 +1,181 @@ +import styled from '@emotion/styled'; +import { useTemplate } from '@/hooks'; +import { PageContainer, flex } from '@/styles/tokens'; +import { useLayoutEffect } from 'react'; +import { Title } from '../Layouts'; +import { MenuTitle } from './common'; +import { TextInput } from '../Field'; +import { ItemButton } from '../Buttons'; +import RoundButton from '../Buttons/Round'; +import { Seminartype, UsageType } from '@/@types/MyTemplate'; +import { TYPO } from '@/styles/typo'; +import { COLORS } from '@/styles/colors'; +import Usage from '../Buttons/Usage'; +import { css } from '@emotion/react'; +import { injectAnimation } from '@/styles/animations'; + +const TIMES: number[] = [1, 2, 3]; +const USAGES: UsageType[] = ['학습', '회의', '수업', '기타']; +const ROOMS: Seminartype[] = ['세미나실', '개방형 세미나실']; + +const NameTimeType = () => { + const { + settingHeader, + template, + settingTitle, + settingSeminarType, + settingTime, + settingUsage, + handleNextStage, + editing, + } = useTemplate(); + + const getTitle = () => { + if (editing) return '템플릿을 수정할 거예요'; + else return '템플릿을 추가할 거예요'; + }; + + useLayoutEffect(() => { + settingHeader(); + }, []); + + return ( + <PageContainer css={pageStyle}> + <TitleBox> + <Title + title={getTitle()} + subtitle="자주 만나는 모임의 정보를 입력해 주세요!" + animated={false} + /> + </TitleBox> + <MenuBox> + <MenuTitle>템플릿 이름</MenuTitle> + <TextInput + value={template.title} + onChange={settingTitle} + placeholder="ex. 슈도비 프로젝트 회의" + /> + </MenuBox> + <MenuArea + css={css` + margin-top: 0.6rem; + `} + > + <MenuTitle>사용 시간과 용도를 선택해 주세요.</MenuTitle> + <MenuBox> + <SmallTitleBox>사용 시간</SmallTitleBox> + <TimesBox> + {TIMES.map((item) => ( + <ItemButton + disabled={false} + title={`${item}시간`} + checked={template.time === item} + onClick={() => settingTime(item)} + key={item} + /> + ))} + </TimesBox> + </MenuBox> + <MenuBox> + <SmallTitleBox>사용 용도를 선택해 주세요.</SmallTitleBox> + <UsageWrapper> + {USAGES.map((item) => ( + <Usage + title={item} + checked={template.type === item} + onClick={() => settingUsage(item)} + key={item} + /> + ))} + </UsageWrapper> + </MenuBox> + </MenuArea> + <MenuBox> + <DescriptionWrapper> + <MenuTitle>장소 종류</MenuTitle> + <DescriptionBox> + 개방형 세미나실은 3명만 이용할 수 있어요. + </DescriptionBox> + </DescriptionWrapper> + <TypeBox> + {ROOMS.map((item) => ( + <ItemButton + title={item} + disabled={false} + checked={template.seminarType === item} + onClick={() => settingSeminarType(item)} + key={item} + /> + ))} + </TypeBox> + </MenuBox> + <RoundButton + title="다음 단계로" + theme="primary" + onClick={() => handleNextStage('name')} + css={buttonStyle} + /> + </PageContainer> + ); +}; + +const TitleBox = styled.div``; + +const SmallTitleBox = styled.div` + ${TYPO.text1.Reg}; +`; + +const MenuArea = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 2)}; +`; + +const MenuBox = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 1.5)}; +`; + +const TimesBox = styled.div` + width: 70%; + ${flex('row', 'start', 'center', 0.8)} +`; + +const TypeBox = styled.div` + width: 100%; + ${flex('row', 'start', 'center', 0.8)} +`; + +const DescriptionWrapper = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 0.5)} +`; + +const DescriptionBox = styled.div` + ${TYPO.text2.Reg}; + color: ${COLORS.grey3}; +`; + +const UsageWrapper = styled.div` + width: 100%; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + grid-gap: 0.6rem; +`; + +const pageStyle = css` + width: 100%; + padding: 3rem 2.7rem; + padding-bottom: 10rem; + position: relative; + ${flex('column', 'start', 'start', 3)}; + ${injectAnimation('fadeInTopDown', '0.5s', 'ease')}; +`; + +const buttonStyle = css` + width: 95%; + margin-top: 4rem; + align-self: center; +`; + +export default NameTimeType; diff --git a/client/src/components/AddTemplate/TemplateTimeTable.tsx b/client/src/components/AddTemplate/TemplateTimeTable.tsx new file mode 100644 index 0000000..730e2ac --- /dev/null +++ b/client/src/components/AddTemplate/TemplateTimeTable.tsx @@ -0,0 +1,196 @@ +import { useTemplate } from '@/hooks'; +import { PageContainer } from '@/styles/tokens'; +import { useEffect, useLayoutEffect, useState } from 'react'; +import Schedule from '../Timetable'; +import { WeeklyData } from '../Timetable/getTimeTable'; +import { useRouter } from 'next/router'; +import { EmptyDate } from '@/utils/EmptyDate'; +import styled from '@emotion/styled'; +import { COLORS } from '@/styles/colors'; +import { TYPO } from '@/styles/typo'; +import ReserveConfirmBottomModal from '../BottomModal/ReserveConfirm'; +import { ROOM_USE_SECTION } from '@/constants/roomUseSection'; +import { CompanionProps } from '@/utils/types/Companion'; +import ConfirmModal from '../Modal/Confrim'; +import { ReserveError } from '@/utils/types/ReserveError'; + +const TemplateTimeTable = () => { + const { settingHeader, template, handleNextStage, editing } = useTemplate(); + const [companions, setCompanions] = useState<CompanionProps[]>([ + { + name: '', + memberNo: '', + id: '', + alternativeId: '', + }, + ]); + + const route = useRouter(); + const [processData, setProcessData] = useState<WeeklyData[]>(EmptyDate); + const [isSelected, setIsSelected] = useState<boolean>(false); + const [selectedSlots, setSelectedSlots] = useState<string[]>([]); + const [isSuccess, setIsSuccess] = useState<boolean>(false); + const [isError, setIsError] = useState<ReserveError>({ + isError: false, + errorMessage: '', + }); + const [dates, setDates] = useState<string[]>([]); + + const roomMapping: { [key: number]: string[] } = { + 3: ['1', '2', '3', '4', '5', '6', '7'], + 4: ['1', '2', '3', '4', '5', '6', '7', '9'], + 5: ['1', '3', '4', '5', '6', '7', '9'], + 6: ['1', '3', '5', '6', '7', '9'], + 7: ['1', '7', '9'], + 8: ['1', '7', '9'], + }; + + const handleTemplateError = () => { + setIsError({ isError: false, errorMessage: '' }); + setIsSelected(false); + }; + + const handleTemplateSuccess = () => { + setIsSuccess(false); + route.push('/template'); + }; + + const getTitle = () => { + if (editing) return '템플릿이 수정되었습니다.'; + else return '템플릿이 추가되었습니다.'; + }; + + useEffect(() => { + if (isSuccess) handleNextStage('time'); + }, [isSuccess]); + + useEffect(() => { + setCompanions( + template.people.map((item) => { + const { id, info } = item; + if (!info || !id) { + return { + name: '', + memberNo: '', + id: '', + alternativeId: '', + }; + } + + return { + name: info.name, + memberNo: info.sId, + id: id.toString(), + alternativeId: info.sId, + }; + }), + ); + }, [template.people]); + + useLayoutEffect(() => { + settingHeader(); + }, []); + + return ( + <> + <PageContainer> + <HeaderDiv> + <HeaderEachDiv> + <HeaderDivBoldText>사용시간</HeaderDivBoldText> + <HeaderDivText>{template.time * 60}분</HeaderDivText> + </HeaderEachDiv> + <HeaderEachDiv> + <HeaderDivBoldText>인원</HeaderDivBoldText> + <HeaderDivText>{template.usePerson + 1}명</HeaderDivText> + </HeaderEachDiv> + <HeaderEachDiv> + <HeaderDivBoldText>장소종류</HeaderDivBoldText> + <HeaderDivText>{template.seminarType}</HeaderDivText> + </HeaderEachDiv> + </HeaderDiv> + <CenterBox> + <TableContainBox> + <Schedule + processData={processData} + isOpenSeminar={template.seminarType === '개방형 세미나실'} + curProcessDataIdx={0} + isSelected={isSelected} + setIsSelected={setIsSelected} + selectedSlots={selectedSlots} + setSelectedSlots={setSelectedSlots} + curTime={String(template.time * 60)} + dates={dates} + /> + </TableContainBox> + </CenterBox> + </PageContainer> + {isSelected && ( + <ReserveConfirmBottomModal + type={ROOM_USE_SECTION[template.type]} + setIsSuccess={setIsSuccess} + setIsError={setIsError} + semina={roomMapping[template.usePerson + 1]} + setIsOpen={setIsSelected} + selectedSlots={selectedSlots} + companions={companions} + /> + )} + {isSuccess && ( + <ConfirmModal + onClick={handleTemplateSuccess} + title={getTitle()} + message="템플릿 정보는 템플릿 탭에서 확인하세요!" + /> + )} + {isError.isError && ( + <ConfirmModal + onClick={handleTemplateError} + title="예약에 실패하였습니다." + message={isError.errorMessage} + /> + )} + </> + ); +}; + +const HeaderDiv = styled.div` + width: 100%; + padding: 15px 36px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #ececec; + background: #feffff; +`; + +const HeaderEachDiv = styled.div` + display: flex; + /* justify-content: ; */ + align-items: center; +`; + +const HeaderDivBoldText = styled.div` + ${TYPO.text1.Lg} + ${COLORS.grey0} +`; + +const HeaderDivText = styled.div` + ${TYPO.text1.Lg} + color:#ccc; + margin-left: 10px; +`; + +const CenterBox = styled.div` + display: flex; + justify-content: center; + margin-top: 2.7rem; +`; + +const TableContainBox = styled.div` + display: flex; + justify-content: center; + align-items: center; + max-width: 36rem; +`; + +export default TemplateTimeTable; diff --git a/client/src/components/AddTemplate/common.tsx b/client/src/components/AddTemplate/common.tsx new file mode 100644 index 0000000..7d7dc27 --- /dev/null +++ b/client/src/components/AddTemplate/common.tsx @@ -0,0 +1,6 @@ +import styled from '@emotion/styled'; +import { TYPO } from '@/styles/typo'; + +export const MenuTitle = styled.div` + ${TYPO.title3.Sb}; +`; diff --git a/client/src/components/AddTemplate/index.tsx b/client/src/components/AddTemplate/index.tsx new file mode 100644 index 0000000..89bea8b --- /dev/null +++ b/client/src/components/AddTemplate/index.tsx @@ -0,0 +1,100 @@ +import styled from '@emotion/styled'; +import { useAtomValue } from 'jotai'; +import { authInfoState } from '@/atoms/authInfoState'; +import { Title } from '../Layouts'; +import { SquareButton } from '../Buttons'; +import Template from '../TemplateList/TemplatePage/Template'; +import Link from 'next/link'; +import { flex } from '@/styles/tokens'; +import { css } from '@emotion/react'; +import { injectAnimation } from '@/styles/animations'; +import { useTemplate } from '@/hooks'; +import { useEffect } from 'react'; + +const AddTemplate = () => { + const authInfo = useAtomValue(authInfoState); + const { templateList, getMyTemplateList, handleRouteTemplate } = + useTemplate(); + + useEffect(() => { + getMyTemplateList(); + }, []); + + return ( + <Container> + <TitleWrapper css={paddingStyle}> + <Title + title={`${authInfo?.name}님, \n템플릿을 만들고 선택해 보세요.`} + subtitle="정기적으로 진행되는 미팅을 빠르게 신청할 수 있어요" + animated={true} + /> + </TitleWrapper> + <UnderWrapper css={paddingStyle}> + <ButtonBox onClick={() => handleRouteTemplate('create')}> + <SquareButton title="템플릿 추가하기" theme="primary" /> + </ButtonBox> + <ReservationListsBox> + {templateList !== undefined + ? templateList.map((el, idx) => ( + <ListBox key={idx}> + <Template + selectedTemplate={el} + uuid={el.uuid} + title={el.title} + day={el.day} + beginTime={el.startTime} + endTime={el.finishTime} + friends={el.people} + place={el.seminarType + ' ' + el.semina} + idx={idx} + semina={el.semina} + onClick={() => {}} + /> + </ListBox> + )) + : '템플릿 없음'} + </ReservationListsBox> + </UnderWrapper> + </Container> + ); +}; + +const Container = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 3)}; +`; + +const TitleWrapper = styled.div` + width: 100%; +`; + +const ButtonBox = styled.div` + width: 100%; + position: relative; + ${injectAnimation('fadeInTopDown', '0.5s', 'ease')}; +`; + +const ReservationListsBox = styled.div` + display: flex; + flex-direction: column; + width: 100%; + margin-top: 10px; +`; + +const ListBox = styled.div` + width: 100%; + display: flex; + justify-content: center; + margin: 0 auto 15px; +`; + +const UnderWrapper = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 1.5)}; +`; + +const paddingStyle = css` + padding: 0rem 2.7rem; +`; + +export default AddTemplate; diff --git a/client/src/components/BottomModal/ConfirmReservationModal/ConfirmReservationModal.stories.tsx b/client/src/components/BottomModal/ConfirmReservationModal/ConfirmReservationModal.stories.tsx new file mode 100644 index 0000000..da67236 --- /dev/null +++ b/client/src/components/BottomModal/ConfirmReservationModal/ConfirmReservationModal.stories.tsx @@ -0,0 +1,39 @@ +import type { StoryObj } from '@storybook/react'; +import ConfirmReservationModal from '.'; + +const meta = { + title: 'BottomModal/ConfirmReservation', + component: ConfirmReservationModal, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Default: Story = { + args: { + slotDay: '11-09', + day: '목요일', + startTime: '12:30', + endTime: '14:30', + companions: [ + { + alternativeId: 'c002d415-f0f4-4111-90f8-a329cc9b31fe', + id: '227974', + memberNo: '20180806', + name: '정명진', + }, + { + alternativeId: '4adcb026-ab3f-49d0-bfbc-81d1327a49ad', + id: '240341', + memberNo: '20182665', + name: '김수진', + }, + ], + seminaRoom: ['3번'], + type: 1, + date: '2023-11-09', + setIsSuccess: () => {}, + }, +}; diff --git a/client/src/components/BottomModal/ConfirmReservationModal/index.tsx b/client/src/components/BottomModal/ConfirmReservationModal/index.tsx new file mode 100644 index 0000000..023e852 --- /dev/null +++ b/client/src/components/BottomModal/ConfirmReservationModal/index.tsx @@ -0,0 +1,270 @@ +import { Dispatch, SetStateAction, useEffect } from 'react'; +import styled from '@emotion/styled'; +import { COLORS } from '@/styles/colors'; +import { TYPO } from '@/styles/typo'; +import { calculateEndTimeWithMinutes } from '@/utils/func/calculateEndTimeWithMinutes'; +import Companion from '@/components/CompanionsList/Companion'; +import AuthApi from '@/apis/auth'; +import { CompanionProps } from '@/utils/types/Companion'; +import { ReserveError } from '@/utils/types/ReserveError'; +import { useTemplate } from '@/hooks'; + +interface ConfirmReservationModalProps { + /** + * 선택된 시간들 (30분 단위) + */ + slotDay: string; + /** + * 요일 + */ + day: string; + /** + * 시작시간 + */ + startTime: string; + /** + * 종료시간 + */ + endTime: string; + /** + * 동반이용자들 + */ + companions: CompanionProps[]; + /** + * 세미나실 + */ + seminaRoom: string[]; + /** + * 사용 용도 + */ + type: number; + /** + * 날짜 + */ + date: string; + /** + * 성공 시 플래그 상태변환 함수 + */ + setIsSuccess: Dispatch<SetStateAction<boolean>>; + /** + * 실패 시 플래그 상태변환 함수 + */ + setIsError: Dispatch<SetStateAction<ReserveError>>; + // 템플릿용인지 예약용인지 + createType: 'template' | 'reserve'; + handleClose?: () => void; +} + +const ConfirmReservationModal = ({ + slotDay, + day, + startTime, + endTime, + companions, + seminaRoom, + type, + date, + setIsSuccess, + setIsError, + createType, + handleClose, +}: ConfirmReservationModalProps) => { + const { template, settingReservationInfo } = useTemplate(); + + useEffect(() => { + if (createType === 'template') + settingReservationInfo( + slotDay, + startTime, + endTime, + seminaRoom.map(Number), + ); + }, [seminaRoom]); + + return ( + <> + <ModalMainStyle> + <ModalHeader> + 세미나룸 {createType === 'reserve' ? ' 예약을 ' : ' 템플릿 저장을 '} + 확정하시겠어요? + </ModalHeader> + {createType === 'reserve' && ( + <> + <BodyText>슈도비에서 언제든지 예약을 취소할 수 있어요.</BodyText> + <BodyText>만약 방문이 어렵다면, 꼭 예약을 취소해주세요!</BodyText> + </> + )} + <ReservationBox> + <ReservationDay> + {slotDay} {createType === 'reserve' && day} + <div + style={{ + width: '80%', + height: '2px', + backgroundColor: '#1D9BF0', + marginTop: '10px', + }} + ></div> + </ReservationDay> + <ReservationRightText> + {calculateEndTimeWithMinutes( + startTime.slice(0, 2) + startTime.slice(3), + 30, + )}{' '} + 입실 마감 + </ReservationRightText> + <ReservationTime> + {startTime} ~ {endTime} + </ReservationTime> + <ReservationRightText> + {calculateEndTimeWithMinutes( + endTime.slice(0, 2) + endTime.slice(3), + -30, + )} + 퇴실 권장 + </ReservationRightText> + </ReservationBox> + </ModalMainStyle> + <CompanionsListContainer> + {companions.map((res) => { + return ( + <Companion + key={res.id} + id={res.id} + name={res.name} + isSelected={false} + memberNo={res.memberNo} + onClick={() => {}} + /> + ); + })} + </CompanionsListContainer> + <ReservationButton + curScreen="confirm" + onClick={() => { + if (createType === 'reserve') { + const authApi = new AuthApi(); + authApi + .reservation( + seminaRoom[0].replace(/[^0-9]/g, ''), + type, + `${date} ${startTime}`, + `${date} ${endTime}`, + companions.map((res) => res.alternativeId), + ) + .then((res) => { + if (res.success) { + setIsSuccess(true); + if (handleClose) handleClose(); + } else { + if (handleClose) handleClose(); + setIsError({ isError: true, errorMessage: res.message }); + } + }); + } else { + setIsSuccess(true); + if (handleClose) handleClose(); + } + }} + > + {createType === 'reserve' ? '예약하기' : ' 템플릿 저장하기'} + </ReservationButton> + </> + ); +}; + +export default ConfirmReservationModal; + +const ModalMainStyle = styled.div` + padding: 3rem; +`; + +const ModalHeader = styled.div` + text-align: left; + margin-bottom: 10px; + ${TYPO.title1.Sb}; +`; + +interface BodyTextProps { + color?: string; +} + +const BodyText = styled.div<BodyTextProps>` + color: ${(props) => + props.color === 'red' + ? COLORS.negative + : props.color === 'blue' + ? COLORS.primary + : '#444'}; + text-align: left; + ${TYPO.text1.Reg}; +`; + +const ReservationBox = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + + margin-top: 20px; +`; + +const ReservationDay = styled.div` + color: #000; + font-family: Pretendard; + font-size: 16px; + font-style: normal; + font-weight: 500; + text-align: left; + /* padding-bottom: 10px; */ + border-right: 1px solid #cccccc; +`; + +const ReservationTime = styled.div` + color: #000; + text-align: left; + font-family: Pretendard; + font-size: 22px; + font-style: normal; + font-weight: 600; + padding-top: 10px; + border-right: 1px solid #cccccc; +`; + +const ReservationRightText = styled.div` + color: #999; + display: flex; + justify-content: left; + align-items: center; + padding-left: 20px; + font-family: Pretendard; + font-size: 14px; + font-style: normal; + font-weight: 500; + border-left: 1px solid #cccccc; +`; + +interface ReservationButtonProps { + isChecked?: boolean; + curScreen?: string; +} + +const ReservationButton = styled.div<ReservationButtonProps>` + bottom: 0; + height: 53px; + background-color: ${(props) => + props.curScreen === 'confirm' + ? COLORS.primary + : props.isChecked + ? COLORS.primary + : COLORS.grey3}; + color: #fff; + display: flex; + justify-content: center; + align-items: center; + ${TYPO.title3.Sb}; +`; + +const CompanionsListContainer = styled.div` + flex: 1; + overflow-y: auto; + background-color: ${COLORS.grey8}; +`; diff --git a/client/src/components/BottomModal/ReserveConfirm.tsx b/client/src/components/BottomModal/ReserveConfirm.tsx new file mode 100644 index 0000000..6721a2c --- /dev/null +++ b/client/src/components/BottomModal/ReserveConfirm.tsx @@ -0,0 +1,259 @@ +// import { useRouter } from 'next/router'; +import styled from '@emotion/styled'; +import { + ComponentProps, + Dispatch, + SetStateAction, + useCallback, + useEffect, + useState, +} from 'react'; +import SelectSeminarRoomModal from './SeletSeminarRoomModal'; +import { calculateEndTimeWithMinutes } from '@/utils/func/calculateEndTimeWithMinutes'; +import ConfirmReservationModal from './ConfirmReservationModal'; +import { CompanionProps } from '@/utils/types/Companion'; +import { ReserveError } from '@/utils/types/ReserveError'; +import { injectAnimation } from '@/styles/animations'; + +interface BProps extends ComponentProps<'div'> { + setIsOpen: (isOpen: boolean) => void; + selectedSlots: string[]; + semina: string[]; + type: number; + companions: CompanionProps[]; + setIsSuccess: Dispatch<SetStateAction<boolean>>; + setIsError: Dispatch<SetStateAction<ReserveError>>; +} + +const ReserveConfirmBottomModal = ({ + setIsOpen, + selectedSlots, + semina, + type, + companions, + setIsSuccess, + setIsError, + ...props +}: BProps) => { + let timeoutId: NodeJS.Timeout | null = null; + const date = selectedSlots[0].slice(0, 10); + + const [isSeminaRoomSelected, setIsSeminaRoomSelected] = + useState<boolean>(false); + + const [seminaRoom, setSeminaRoom] = useState<string[]>([]); + const [isTransition, setIsTransition] = useState<boolean>(false); + + const seminaRoomContents = useCallback(() => { + if (semina) + return semina.map((res) => { + return { disabled: false, title: res }; + }); + else return []; + }, [semina]); + + const handleClose = () => { + if (isTransition) return; + + setIsTransition(true); + timeoutId = setTimeout(() => { + setIsTransition(false); + setIsOpen(false); + }, 400); + }; + + const day = getDayOfWeek( + selectedSlots[0].split('-')[0] + + '-' + + selectedSlots[0].split('-')[1] + + '-' + + selectedSlots[0].split('-')[2], + ); + + const slotDay = + selectedSlots[0].split('-')[1] + '-' + selectedSlots[0].split('-')[2]; + + const startTime = + selectedSlots[0].split('-')[3].slice(0, 2) + + ':' + + selectedSlots[0].split('-')[3].slice(2); + const endTime = calculateEndTimeWithMinutes( + selectedSlots[selectedSlots.length - 1].split('-')[3], + 30, + ); + function getDayOfWeek(dateString: string) { + const days = [ + '일요일', + '월요일', + '화요일', + '수요일', + '목요일', + '금요일', + '토요일', + ]; + const date = new Date(dateString); + return days[date.getUTCDay()]; + } + const getTemplateDayOfWeek = (number: string): string => { + switch (number) { + case '1': + return '월요일'; + case '2': + return '화요일'; + case '3': + return '수요일'; + case '4': + return '목요일'; + case '5': + return '금요일'; + case '6': + return '토요일'; + default: + throw new Error('Invalid day number.'); + } + }; + + useEffect(() => { + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, []); + + if (!isSeminaRoomSelected) { + return ( + <Modal + css={ + isTransition && + injectAnimation('modalBackgroundDisappear', '0.4s', 'ease') + } + onClick={handleClose} + > + <ModalView + height="400px" + onClick={(e) => { + e.stopPropagation(); + }} + css={ + isTransition && injectAnimation('modalDisappear', '0.4s', 'ease') + } + {...props} + > + {date.slice(8, 10) === '00' ? ( + <SelectSeminarRoomModal + slotDay={getTemplateDayOfWeek(date.slice(6, 7))} + day={day} + startTime={startTime} + endTime={endTime} + seminaRoom={seminaRoom} + seminaRoomContents={seminaRoomContents()} + setSeminaRoom={setSeminaRoom} + setIsSeminaRoomSelected={setIsSeminaRoomSelected} + type="template" + /> + ) : ( + <SelectSeminarRoomModal + slotDay={slotDay} + day={day} + startTime={startTime} + endTime={endTime} + seminaRoom={seminaRoom} + seminaRoomContents={seminaRoomContents()} + setSeminaRoom={setSeminaRoom} + setIsSeminaRoomSelected={setIsSeminaRoomSelected} + type="reserve" + /> + )} + </ModalView> + </Modal> + ); + } else { + return ( + <Modal + css={ + isTransition && + injectAnimation('modalBackgroundDisappear', '0.4s', 'ease') + } + onClick={handleClose} + > + <ModalView + height="500px" + css={ + isTransition && injectAnimation('modalDisappear', '0.4s', 'ease') + } + onClick={(e) => { + e.stopPropagation(); + }} + > + {date.slice(8, 10) === '00' ? ( + <ConfirmReservationModal + slotDay={getTemplateDayOfWeek(date.slice(6, 7))} + day={day} + startTime={startTime} + endTime={endTime} + seminaRoom={seminaRoom} + companions={companions} + type={type} + date={date} + setIsSuccess={setIsSuccess} + setIsError={setIsError} + createType="template" + /> + ) : ( + <ConfirmReservationModal + slotDay={slotDay} + day={day} + startTime={startTime} + endTime={endTime} + seminaRoom={seminaRoom} + companions={companions} + type={type} + date={date} + setIsSuccess={setIsSuccess} + setIsError={setIsError} + createType="reserve" + /> + )} + </ModalView> + </Modal> + ); + } +}; + +export default ReserveConfirmBottomModal; + +const Backdrop = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 10; + ${injectAnimation('modalBackgroundAppear', '0.4s', 'ease')}; +`; + +export const Modal = styled(Backdrop)` + display: flex; + text-align: center; + flex-direction: column-reverse; +`; + +interface ModalViewProps { + height: string; +} + +export const ModalView = styled.div<ModalViewProps>` + margin: 0 auto; + display: flex; + flex-direction: column; + justify-content: space-between; + background-color: white; + border-radius: 15px 15px 0 0; + max-width: 50rem; + width: 100%; + height: ${(props) => props.height}; + transition: height 0.5s ease; + ${injectAnimation('modalAppear', '0.5s', 'ease')}; +`; diff --git a/client/src/components/BottomModal/SeletSeminarRoomModal/SelectSeminarRoomModal.stories.tsx b/client/src/components/BottomModal/SeletSeminarRoomModal/SelectSeminarRoomModal.stories.tsx new file mode 100644 index 0000000..4eb5401 --- /dev/null +++ b/client/src/components/BottomModal/SeletSeminarRoomModal/SelectSeminarRoomModal.stories.tsx @@ -0,0 +1,47 @@ +import type { StoryObj } from '@storybook/react'; +import SelectSeminarRoomModal from '.'; + +const meta = { + title: 'BottomModal/SelectSeminarRoom', + component: SelectSeminarRoomModal, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Default: Story = { + args: { + slotDay: '11-09', + day: '목요일', + startTime: '12:30', + endTime: '14:30', + seminaRoomContents: [ + { + disabled: false, + title: '1번', + }, + { + disabled: false, + title: '2번', + }, + { + disabled: false, + title: '3번', + }, + { + disabled: false, + title: '4번', + }, + { + disabled: false, + title: '5번', + }, + ], + + seminaRoom: [], + setSeminaRoom: () => {}, + setIsSeminaRoomSelected: () => {}, + }, +}; diff --git a/client/src/components/BottomModal/SeletSeminarRoomModal/index.tsx b/client/src/components/BottomModal/SeletSeminarRoomModal/index.tsx new file mode 100644 index 0000000..bd90031 --- /dev/null +++ b/client/src/components/BottomModal/SeletSeminarRoomModal/index.tsx @@ -0,0 +1,205 @@ +import { Dispatch, SetStateAction } from 'react'; +import styled from '@emotion/styled'; +import Picker from '@/components/Layouts/Picker'; +import { COLORS } from '@/styles/colors'; +import { TYPO } from '@/styles/typo'; +import { calculateEndTimeWithMinutes } from '@/utils/func/calculateEndTimeWithMinutes'; +import { useTemplate } from '@/hooks'; + +interface SelectSeminarRoomModalProps { + /** + * 선택된 시간들 (30분 단위) + */ + slotDay: string; + /** + * 요일 + */ + day: string; + /** + * 시작시간 + */ + startTime: string; + /** + * 종료시간 + */ + endTime: string; + /** + * 예약가능한 세미나실 + */ + seminaRoomContents: { disabled: boolean; title: string }[]; + /** + * 선택한 세미나실 + */ + seminaRoom: string[]; + /** + * 세미나실 변경 + */ + setSeminaRoom: Dispatch<SetStateAction<string[]>>; + /** + * 다음 페이지로 넘어가기 위한 플래그 함수 + */ + setIsSeminaRoomSelected: Dispatch<SetStateAction<boolean>>; + // 템플릿용인지 예약용인지 + type: 'template' | 'reserve'; +} +const SelectSeminarRoomModal = ({ + slotDay, + day, + startTime, + endTime, + seminaRoomContents, + seminaRoom, + setSeminaRoom, + setIsSeminaRoomSelected, + type, +}: SelectSeminarRoomModalProps) => { + const { editing } = useTemplate(); + + const getTitle = () => { + if (type === 'reserve') return '아래 시간으로 예약을 진행할게요'; + else { + if (editing) return `아래 시간으로 템플릿 수정을 진행할게요`; + else return `아래 시간으로 템플릿 저장을 진행할게요`; + } + }; + + return ( + <> + <ModalMainStyle> + <ModalHeader>{getTitle()}</ModalHeader> + <ReservationBox> + <ReservationDay> + {slotDay} {type === 'reserve' && day} + <div + style={{ + width: '80%', + height: '2px', + backgroundColor: '#1D9BF0', + marginTop: '10px', + }} + ></div> + </ReservationDay> + <ReservationRightText> + {calculateEndTimeWithMinutes( + startTime.slice(0, 2) + startTime.slice(3), + 30, + )}{' '} + 입실 마감 + </ReservationRightText> + <ReservationTime> + {startTime} ~ {endTime} + </ReservationTime> + <ReservationRightText> + {calculateEndTimeWithMinutes( + endTime.slice(0, 2) + endTime.slice(3), + -30, + )}{' '} + 퇴실 권장 + </ReservationRightText> + </ReservationBox> + <div style={{ height: '30px' }}></div> + <Picker + itemType="Info" + title="사용할 세미나실 고르기" + isMultiple={false} + contents={seminaRoomContents} + itemSetter={setSeminaRoom} + /> + <div style={{ height: '15px' }}></div> + <Text2Reg> + 인원 수와 시간에 따라 사용 가능한 세미나실이 다를 수 있어요. + </Text2Reg> + </ModalMainStyle> + + <ReservationButton + isChecked={!(seminaRoom.length === 0)} + onClick={() => { + if (seminaRoom.length === 0) { + return; + } + setIsSeminaRoomSelected(true); + }} + > + 세미나실 선택완료 + </ReservationButton> + </> + ); +}; + +export default SelectSeminarRoomModal; + +const ModalMainStyle = styled.div` + padding: 3rem; +`; + +const ModalHeader = styled.div` + text-align: left; + margin-bottom: 10px; + ${TYPO.title1.Sb}; +`; + +const ReservationBox = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + + margin-top: 20px; +`; + +const ReservationDay = styled.div` + color: #000; + font-family: Pretendard; + font-size: 16px; + font-style: normal; + font-weight: 500; + text-align: left; + /* padding-bottom: 10px; */ + border-right: 1px solid #cccccc; +`; + +const ReservationTime = styled.div` + color: #000; + text-align: left; + font-family: Pretendard; + ${TYPO.caption.Md} + border-right: 1px solid #cccccc; +`; + +const ReservationRightText = styled.div` + color: #999; + display: flex; + justify-content: left; + align-items: center; + padding-left: 20px; + font-family: Pretendard; + font-size: 14px; + font-style: normal; + font-weight: 500; + border-left: 1px solid #cccccc; +`; + +interface ReservationButtonProps { + isChecked?: boolean; + curScreen?: string; +} + +const ReservationButton = styled.div<ReservationButtonProps>` + bottom: 0; + height: 53px; + background-color: ${(props) => + props.curScreen === 'confirm' + ? COLORS.primary + : props.isChecked + ? COLORS.primary + : COLORS.grey3}; + color: #fff; + display: flex; + justify-content: center; + align-items: center; + ${TYPO.title3.Sb}; +`; + +const Text2Reg = styled.div` + ${TYPO.text2.Reg}; + color: ${COLORS.grey3}; + text-align: start; +`; diff --git a/client/src/components/Buttons/Usage/Usage.stories.tsx b/client/src/components/Buttons/Usage/Usage.stories.tsx new file mode 100644 index 0000000..f7b403e --- /dev/null +++ b/client/src/components/Buttons/Usage/Usage.stories.tsx @@ -0,0 +1,26 @@ +import type { StoryObj } from '@storybook/react'; +import Usage from '.'; + +const meta = { + title: 'Buttons/Usage', + component: Usage, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Default: Story = { + args: { + title: '수업', + checked: false, + }, +}; + +export const Checked: Story = { + args: { + title: '수업', + checked: true, + }, +}; diff --git a/client/src/components/Buttons/Usage/index.tsx b/client/src/components/Buttons/Usage/index.tsx new file mode 100644 index 0000000..9ddaf88 --- /dev/null +++ b/client/src/components/Buttons/Usage/index.tsx @@ -0,0 +1,115 @@ +import { injectAnimation } from '@/styles/animations'; +import { COLORS } from '@/styles/colors'; +import { flex, transition } from '@/styles/tokens'; +import { TYPO } from '@/styles/typo'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { ComponentProps } from 'react'; + +interface Props extends ComponentProps<'input'> { + /** + * 버튼에 들어갈 내용 + */ + title: string; + /** + * 버튼 체크 여부 + */ + checked: boolean; +} + +const Usage = ({ title, checked, ...props }: Props) => { + const getButtonStyle = (checked: boolean) => { + if (checked) return buttonStyles.checked; + else return buttonStyles.default; + }; + + return ( + <UsageButton htmlFor={title} css={getButtonStyle(checked)}> + <UsageInput checked={checked} id={title} {...props} /> + <span>{title}</span> + {checked && ( + <> + <Circle css={circleStyles[0]} /> + <Circle css={circleStyles[1]} /> + </> + )} + </UsageButton> + ); +}; + +const Circle = (props: ComponentProps<'svg'>) => { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="81" + height="81" + viewBox="0 0 81 81" + fill="none" + {...props} + > + <circle + cx="40.5" + cy="40.5" + r="40.5" + fill="url(#paint0_linear_1437_3598)" + /> + <defs> + <linearGradient + id="paint0_linear_1437_3598" + x1="40.5" + y1="0" + x2="40.5" + y2="81" + gradientUnits="userSpaceOnUse" + > + <stop stop-color="#aeddfd50" /> + <stop offset="1" stop-color="#1D9BF0" stop-opacity="0" /> + </linearGradient> + </defs> + </svg> + ); +}; + +const circleStyles = [ + css` + position: absolute; + top: 50%; + left: 10%; + ${injectAnimation('usageMovingBottom')}; + `, + css` + position: absolute; + bottom: 50%; + right: 10%; + ${injectAnimation('usageMovingTop')}; + `, +]; + +const buttonStyles = { + checked: css` + background-color: ${COLORS.primary}; + color: white; + `, + default: css` + background-color: ${COLORS.grey6}; + color: ${COLORS.grey4}; + `, +}; + +const UsageButton = styled.label` + width: 100%; + height: 6.8rem; + border-radius: 1rem; + ${flex('row', 'center', 'center', 0)}; + position: relative; + overflow: hidden; + ${TYPO.title2.Sb}; + ${buttonStyles.default}; + ${transition('0.3s', 'linear')}; +`; + +const UsageInput = styled.input` + display: none; +`; + +export default Usage; diff --git a/client/src/components/CompanionsList/Companion/index.tsx b/client/src/components/CompanionsList/Companion/index.tsx index 85817a9..86542ed 100644 --- a/client/src/components/CompanionsList/Companion/index.tsx +++ b/client/src/components/CompanionsList/Companion/index.tsx @@ -28,6 +28,10 @@ interface ModalProps extends ComponentProps<'div'> { * 제거 가능 여부 */ isRemovable?: boolean; + /** + * 클릭 시 해당 요소 상태 변경 함수 + */ + onClick: () => void; } const Companion = ({ @@ -36,6 +40,7 @@ const Companion = ({ memberNo, isSelected, isRemovable, + onClick, ...props }: ModalProps) => { const ProfileIcon = companionIconGetter(); @@ -46,7 +51,12 @@ const Companion = ({ }; return ( - <Container isSelected={isSelected} css={getContainerStyle} {...props}> + <Container + onClick={onClick} + isSelected={isSelected} + css={getContainerStyle} + {...props} + > <ProfileWrapper> <ProfileIcon css={logoStyle} /> <Profile> diff --git a/client/src/components/CompanionsList/index.tsx b/client/src/components/CompanionsList/index.tsx index c6bcb78..30cfdcd 100644 --- a/client/src/components/CompanionsList/index.tsx +++ b/client/src/components/CompanionsList/index.tsx @@ -1,13 +1,43 @@ import styled from '@emotion/styled'; -import Plus from '../../../public/assets/companions/puls.svg'; -import Image from 'next/image'; +import Plus from '@/assets/svg/puls.svg'; import Companion from './Companion'; +import { Dispatch, SetStateAction, useState } from 'react'; +import { CompanionProps } from '@/utils/types/Companion'; -const CompnaionsList = () => { - const exData = [ - { name: '정명진', memberNo: '20180806', id: '111' }, - { name: '최상원', memberNo: '20180399', id: '112' }, - ]; +interface CompanionsListProps { + companions: CompanionProps[]; + curCompanions: CompanionProps[]; + setCurCompanions: Dispatch<SetStateAction<CompanionProps[]>>; +} + +const CompanionsList = ({ + companions, + curCompanions, + setCurCompanions, +}: CompanionsListProps) => { + const [selectedStates, setSelectedStates] = useState( + companions.map(() => false), + ); + + const handleCompanionClick = (index: number, companion: CompanionProps) => { + const isSelected = !selectedStates[index]; + + const newSelectedStates = [...selectedStates]; + newSelectedStates[index] = !newSelectedStates[index]; + setSelectedStates(newSelectedStates); + + if (isSelected) { + if (!curCompanions.some((comp) => comp.id === companion.id)) { + setCurCompanions((prev) => [...prev, companion]); + } + } else { + if (curCompanions.some((comp) => comp.id === companion.id)) { + setCurCompanions((prev) => + prev.filter((comp) => comp.id !== companion.id), + ); + } + } + }; return ( <Container> @@ -16,17 +46,22 @@ const CompnaionsList = () => { // TODO : 동반이용자 추가페이지로 이동해야함 }} > - <Image src={Plus} alt="plus-button" /> + <PlusButton> + <Plus /> + </PlusButton> <AddMateText>메이트 추가하기</AddMateText> </AddMate> - {exData.map((res, idx) => { + {companions.map((res, idx) => { return ( <Companion key={idx} name={res.name} id={res.id} memberNo={res.memberNo} - isSelected={false} + isSelected={selectedStates[idx]} + onClick={() => { + handleCompanionClick(idx, res); + }} /> ); })} @@ -34,17 +69,18 @@ const CompnaionsList = () => { ); }; -export default CompnaionsList; +export default CompanionsList; const Container = styled.div` display: flex; + min-height: 200px; flex-direction: column; border-bottom: 1px solid #e1e1e1; background: #f7f7f7; `; const AddMate = styled.div` - padding: 15px 30px; + padding: 1.5rem 3rem; display: flex; align-items: center; `; @@ -58,3 +94,11 @@ const AddMateText = styled.div` font-weight: 500; line-height: normal; `; + +const PlusButton = styled.div` + width: 3.5rem; + height: 3.5rem; + display: flex; + justify-content: center; + align-items: center; +`; diff --git a/client/src/components/Friends/FriendCircle.tsx b/client/src/components/Friends/FriendCircle.tsx index 40490bc..b3e5813 100644 --- a/client/src/components/Friends/FriendCircle.tsx +++ b/client/src/components/Friends/FriendCircle.tsx @@ -1,6 +1,8 @@ +import { ComponentProps } from 'react'; import * as styles from './Circle.styles'; +import { css } from '@emotion/react'; -interface FCProps { +interface FCProps extends ComponentProps<'div'> { /** * 이름 */ @@ -11,12 +13,16 @@ interface FCProps { type: 'friend' | 'plus'; } -const FriendCircle = ({ name, type }: FCProps) => { +const FriendCircle = ({ name, type, ...props }: FCProps) => { return ( - <styles.CircleContainer style={{ marginLeft: '-12px' }} type={type}> + <styles.CircleContainer css={leftStyle} type={type} {...props}> {name} </styles.CircleContainer> ); }; +const leftStyle = css` + margin-left: -1.2rem; +`; + export default FriendCircle; diff --git a/client/src/components/Home/MateList/index.tsx b/client/src/components/Home/MateList/index.tsx new file mode 100644 index 0000000..827aede --- /dev/null +++ b/client/src/components/Home/MateList/index.tsx @@ -0,0 +1,33 @@ +import FriendCircle from '@/components/Friends/FriendCircle'; +import { flex } from '@/styles/tokens'; +import styled from '@emotion/styled'; +import { useRouter } from 'next/router'; +import { ComponentProps } from 'react'; + +interface Props extends ComponentProps<'div'> { + mates: string[]; +} + +const MateList = ({ mates, ...props }: Props) => { + const router = useRouter(); + + const handleRoute = () => { + router.push('/mate'); + }; + return ( + <MateWrapper {...props}> + {mates.map((mate, idx) => ( + <FriendCircle name={mate} type={'friend'} key={mate} /> + ))} + <FriendCircle type="plus" name="+" onClick={handleRoute} /> + </MateWrapper> + ); +}; + +const MateWrapper = styled.div` + width: 100%; + ${flex('row', 'start', 'center', 0)}; + padding-left: 1.2rem; +`; + +export default MateList; diff --git a/client/src/components/Home/ReserveButtons/index.tsx b/client/src/components/Home/ReserveButtons/index.tsx new file mode 100644 index 0000000..ec69982 --- /dev/null +++ b/client/src/components/Home/ReserveButtons/index.tsx @@ -0,0 +1,48 @@ +import ReservationButton from '@/components/Buttons/Reservation'; +import { flex } from '@/styles/tokens'; +import styled from '@emotion/styled'; +import bubble1 from '@/assets/svg/bubble1.svg'; +import bubble2 from '@/assets/svg/bubble2.svg'; +import bubble3 from '@/assets/svg/bubble3.svg'; +import cloud from '@/assets/svg/cloud.svg'; +import lock1 from '@/assets/svg/lock1.svg'; +import talk from '@/assets/svg/talk.svg'; +import { injectAnimation } from '@/styles/animations'; +import { useRouter } from 'next/router'; + +const assetArray = [ + [bubble3, bubble2, lock1], + [talk, bubble1, cloud], +]; + +const ReserveButtons = () => { + const router = useRouter(); + + const configs = [ + { + title: '세미나실', + subtitle: '#회의 #미팅 #강의 #세션', + assets: assetArray[0], + buttonStyleType: 'blue', + onClick: () => router.push('/create/reserve'), + }, + { + title: '개방형 세미나실', + subtitle: '#학습 #쾌적', + assets: assetArray[1], + buttonStyleType: 'skyblue', + onClick: () => router.push('/'), + }, + ]; + + //@ts-ignore + return <ButtonsWrapper>{configs.map(ReservationButton)}</ButtonsWrapper>; +}; + +const ButtonsWrapper = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 1.2)}; + ${injectAnimation('fadeInTopDownTranslate', '0.5s', 'ease')}; +`; + +export default ReserveButtons; diff --git a/client/src/components/Home/TemplateList/Carousel.tsx b/client/src/components/Home/TemplateList/Carousel.tsx new file mode 100644 index 0000000..10b2445 --- /dev/null +++ b/client/src/components/Home/TemplateList/Carousel.tsx @@ -0,0 +1,131 @@ +import { MyTemplate } from '@/@types/MyTemplate'; +import HomeTemplate from '@/components/TemplateList/HomeTemplate/HomeTemplate'; +import { useTemplate } from '@/hooks'; +import { COLORS } from '@/styles/colors'; +import { flex } from '@/styles/tokens'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { faAnglesRight, faPlusCircle } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { TemplateInfo } from 'Template'; +import useEmblaCarousel, { EmblaOptionsType } from 'embla-carousel-react'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; + +type PropType = { + templates: MyTemplate[]; + options?: EmblaOptionsType; +}; + +const Carousel = ({ templates, options }: PropType) => { + const [isMax, setIsMax] = useState(false); + const [slides, setSlides] = useState<MyTemplate[]>([]); + const [emblaRef] = useEmblaCarousel(options); + const router = useRouter(); + const { handleRouteTemplate } = useTemplate(); + + const calculTemplates = () => { + if (templates.length > 5) { + setSlides(templates.slice(0, 5)); + setIsMax(true); + } else { + setSlides(templates); + setIsMax(false); + } + }; + + useEffect(() => { + calculTemplates(); + }, []); + + return ( + <Embla> + <Viewport ref={emblaRef}> + <Container> + {slides.map((slide, idx) => ( + <Slide idx={idx} key={idx}> + <HomeTemplate {...slide} /> + </Slide> + ))} + <ButtonsWrapper> + {isMax && ( + <Box + css={boxStyles.more} + onClick={() => router.push('/template')} + > + <FontAwesomeIcon icon={faAnglesRight} /> + </Box> + )} + <Box + css={boxStyles.plus} + onClick={() => handleRouteTemplate('create')} + > + <FontAwesomeIcon icon={faPlusCircle} /> + </Box> + </ButtonsWrapper> + </Container> + </Viewport> + </Embla> + ); +}; + +const Embla = styled.div` + --slide-spacing: 1rem; +`; + +const Viewport = styled.div` + overflow: hidden; +`; + +const Container = styled.div` + backface-visibility: hidden; + display: flex; + touch-action: pan-y; + margin-left: calc(var(--slide-spacing) * -1); +`; + +const paddingStyle = { + default: css` + padding-left: var(--slide-spacing); + `, + first: css` + padding-left: calc(var(--slide-spacing)); + margin-left: 2.7rem; + `, +}; + +const ButtonsWrapper = styled.div` + ${flex('row', 'start', 'center', 1)}; + margin-left: 1rem; + flex: 0 0 20rem; +`; + +const Slide = styled.div<{ idx: number }>` + min-width: 0; + position: relative; + flex: 0 0 28rem; + ${paddingStyle.default}; + ${(props) => props.idx === 0 && paddingStyle.first}; +`; + +const Box = styled.div` + width: 7rem; + height: 100%; + border-radius: 1rem; + box-shadow: rgba(0, 0, 0, 0.05) 0px 0px 0px 1px; + cursor: pointer; + ${flex('column', 'center', 'center', 0)}; + font-size: 2.4rem; + color: ${COLORS.primaryWhite}; +`; + +const boxStyles = { + plus: css` + background-color: ${COLORS.primary}; + `, + more: css` + background-color: ${COLORS.grey3}; + `, +}; + +export default Carousel; diff --git a/client/src/components/Home/TemplateList/Empty.tsx b/client/src/components/Home/TemplateList/Empty.tsx new file mode 100644 index 0000000..6e3081c --- /dev/null +++ b/client/src/components/Home/TemplateList/Empty.tsx @@ -0,0 +1,44 @@ +import { useTemplate } from '@/hooks'; +import { COLORS } from '@/styles/colors'; +import { flex } from '@/styles/tokens'; +import { TYPO } from '@/styles/typo'; +import styled from '@emotion/styled'; +import { faMarker } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +const Empty = () => { + const { handleRouteTemplate } = useTemplate(); + + return ( + <Container> + <Button onClick={() => handleRouteTemplate('create')}> + <Content>템플릿 추가하기</Content> + <FontAwesomeIcon icon={faMarker} /> + </Button> + </Container> + ); +}; + +const Container = styled.div` + width: 100%; + padding: 0rem 2.7rem; +`; + +const Button = styled.button` + width: 100%; + height: 6rem; + background-color: ${COLORS.primary}; + color: ${COLORS.white}; + border: none; + outline: none; + border-radius: 1rem; + cursor: pointer; + ${flex('row', 'center', 'center', 1)}; +`; + +const Content = styled.span` + ${TYPO.text2.Sb}; + color: ${COLORS.white}; +`; + +export default Empty; diff --git a/client/src/components/Home/TemplateList/dummy.ts b/client/src/components/Home/TemplateList/dummy.ts new file mode 100644 index 0000000..3fa1c6c --- /dev/null +++ b/client/src/components/Home/TemplateList/dummy.ts @@ -0,0 +1,215 @@ +import { TemplateInfo } from 'Template'; + +export const emptyTemplates = []; + +export const basicTemplates: TemplateInfo[] = [ + { + // template 제목 + title: '더미', + day: 'Thu', + beginTime: '18:00', + endTime: '19:00', + place: 1, + memo: '템플릿 메모', + friends: [ + { + name: '홍길동', + sId: '20111111', + }, + { + name: '홍길동', + sId: '20111112', + }, + ], + }, + { + // template 제목 + title: '더미', + day: 'Thu', + beginTime: '18:00', + endTime: '19:00', + place: 1, + memo: '템플릿 메모', + friends: [ + { + name: '홍길동', + sId: '20111111', + }, + { + name: '홍길동', + sId: '20111112', + }, + ], + }, +]; + +export const maxTemplates: TemplateInfo[] = [ + { + // template 제목 + title: '더미', + day: 'Thu', + beginTime: '18:00', + endTime: '19:00', + place: 1, + memo: '템플릿 메모', + friends: [ + { + name: '홍길동', + sId: '20111111', + }, + { + name: '홍길동', + sId: '20111112', + }, + ], + }, + { + // template 제목 + title: '더미', + day: 'Thu', + beginTime: '18:00', + endTime: '19:00', + place: 1, + memo: '템플릿 메모', + friends: [ + { + name: '홍길동', + sId: '20111111', + }, + { + name: '홍길동', + sId: '20111112', + }, + ], + }, + { + // template 제목 + title: '더미', + day: 'Thu', + beginTime: '18:00', + endTime: '19:00', + place: 1, + memo: '템플릿 메모', + friends: [ + { + name: '홍길동', + sId: '20111111', + }, + { + name: '홍길동', + sId: '20111112', + }, + { + name: '홍길동', + sId: '20111112', + }, + { + name: '홍길동', + sId: '20111112', + }, + { + name: '홍길동', + sId: '20111112', + }, + { + name: '홍길동', + sId: '20111112', + }, + ], + }, + { + // template 제목 + title: '더미', + day: 'Thu', + beginTime: '18:00', + endTime: '19:00', + place: 1, + memo: '템플릿 메모', + friends: [ + { + name: '홍길동', + sId: '20111111', + }, + { + name: '홍길동', + sId: '20111112', + }, + ], + }, + { + // template 제목 + title: '더미', + day: 'Thu', + beginTime: '18:00', + endTime: '19:00', + place: 1, + memo: '템플릿 메모', + friends: [ + { + name: '홍길동', + sId: '20111111', + }, + { + name: '홍길동', + sId: '20111112', + }, + ], + }, + { + // template 제목 + title: '더미', + day: 'Thu', + beginTime: '18:00', + endTime: '19:00', + place: 1, + memo: '템플릿 메모', + friends: [ + { + name: '홍길동', + sId: '20111111', + }, + { + name: '홍길동', + sId: '20111112', + }, + ], + }, + { + // template 제목 + title: '더미', + day: 'Thu', + beginTime: '18:00', + endTime: '19:00', + place: 1, + memo: '템플릿 메모', + friends: [ + { + name: '홍길동', + sId: '20111111', + }, + { + name: '홍길동', + sId: '20111112', + }, + ], + }, + { + // template 제목 + title: '더미', + day: 'Thu', + beginTime: '18:00', + endTime: '19:00', + place: 1, + memo: '템플릿 메모', + friends: [ + { + name: '홍길동', + sId: '20111111', + }, + { + name: '홍길동', + sId: '20111112', + }, + ], + }, +]; diff --git a/client/src/components/Home/TemplateList/index.tsx b/client/src/components/Home/TemplateList/index.tsx new file mode 100644 index 0000000..0fe6a59 --- /dev/null +++ b/client/src/components/Home/TemplateList/index.tsx @@ -0,0 +1,36 @@ +import Carousel from './Carousel'; +import { EmblaOptionsType } from 'embla-carousel-react'; +import styled from '@emotion/styled'; +import { injectAnimation } from '@/styles/animations'; +import Empty from './Empty'; +import { useTemplate } from '@/hooks'; +import { useEffect } from 'react'; + +const TemplateList = () => { + const { templateList, getMyTemplateList } = useTemplate(); + const OPTIONS: EmblaOptionsType = { + dragFree: true, + containScroll: 'trimSnaps', + }; + + useEffect(() => { + getMyTemplateList(); + }, []); + + return ( + <Container css={injectAnimation('fadeInTopDownTranslate', '0.5s', 'ease')}> + {templateList.length > 0 ? ( + <Carousel templates={templateList} options={OPTIONS} /> + ) : ( + <Empty /> + )} + </Container> + ); +}; + +const Container = styled.div` + width: 100%; + position: relative; +`; + +export default TemplateList; diff --git a/client/src/components/Home/index.tsx b/client/src/components/Home/index.tsx new file mode 100644 index 0000000..d5470fc --- /dev/null +++ b/client/src/components/Home/index.tsx @@ -0,0 +1,3 @@ +export { default as ReserveButtons } from './ReserveButtons'; +export { default as MateList } from './MateList'; +export { default as TemplateList } from './TemplateList'; diff --git a/client/src/components/Layouts/Frame/index.tsx b/client/src/components/Layouts/Frame/index.tsx index b637ab6..2db5616 100644 --- a/client/src/components/Layouts/Frame/index.tsx +++ b/client/src/components/Layouts/Frame/index.tsx @@ -7,6 +7,7 @@ import styled from '@emotion/styled'; import FrameNavigator from './FrameNavigator'; import { COLORS } from '@/styles/colors'; import { useRouter } from 'next/router'; +import { containerStyle } from '@/styles/tokens'; interface FrameProps extends ComponentProps<'div'> { children: React.ReactNode; @@ -42,10 +43,24 @@ const Frame = ({ children, ...props }: FrameProps) => { } }; + const getPaddingStyle = (pathname: string) => { + switch (pathname) { + case '/landing': + return containerStyle.skinight; + case '/': + case '/template': + case '/mate': + case '/schedule': + return containerStyle.navigator; + default: + return containerStyle.header; + } + }; + return ( <div css={[backgroundStyle, getBgColor(router.pathname)]} {...props}> <FrameHeader /> - <Container>{children}</Container> + <Container css={[getPaddingStyle(router.pathname)]}>{children}</Container> <FrameNavigator /> </div> ); diff --git a/client/src/components/Layouts/Header/NavHeader.tsx b/client/src/components/Layouts/Header/NavHeader.tsx index cab7b8e..58901b8 100644 --- a/client/src/components/Layouts/Header/NavHeader.tsx +++ b/client/src/components/Layouts/Header/NavHeader.tsx @@ -1,6 +1,6 @@ import { mq } from '@/styles/breakpoints'; import { COLORS } from '@/styles/colors'; -import { flex, transform } from '@/styles/tokens'; +import { HEADER_HEIGHT, flex, transform } from '@/styles/tokens'; import { TYPO } from '@/styles/typo'; import styled from '@emotion/styled'; import { faChevronLeft } from '@fortawesome/free-solid-svg-icons'; @@ -28,17 +28,15 @@ const NavHeader = ({ title, onBack, ...props }: Props) => { const HeaderWrapper = styled.div` width: 100%; min-width: 32rem; - height: 6rem; + height: ${HEADER_HEIGHT}rem; position: fixed; top: 0px; left: 50%; ${transform('translate(-50%, 0%)')}; + background-color: ${COLORS.white}; + z-index: 10; box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.1); - - ${mq[4]} { - height: 5rem; - } `; const HeaderInner = styled.div` diff --git a/client/src/components/Layouts/Header/TitleHeader.tsx b/client/src/components/Layouts/Header/TitleHeader.tsx index 18815b3..a525356 100644 --- a/client/src/components/Layouts/Header/TitleHeader.tsx +++ b/client/src/components/Layouts/Header/TitleHeader.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; import { TYPO } from '@/styles/typo'; import { COLORS } from '@/styles/colors'; -import { flex, transform } from '@/styles/tokens'; +import { HEADER_HEIGHT, flex, transform } from '@/styles/tokens'; import HumanIcon from '@/assets/svg/human.svg'; import { css } from '@emotion/react'; import { mq } from '@/styles/breakpoints'; @@ -35,19 +35,17 @@ const hovering = css` const HeaderWrapper = styled.div` width: 100%; min-width: 32rem; - height: 6rem; + height: ${HEADER_HEIGHT}rem; ${flex('row', 'between', 'center', 0)}; position: fixed; top: 0px; left: 50%; ${transform('translate(-50%, 0%)')}; + background-color: ${COLORS.white}; + z-index: 10; box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.1); padding: 0rem 2.7rem; - - ${mq[4]} { - height: 5rem; - } `; const Logo = styled.span` diff --git a/client/src/components/Layouts/Navigator/index.tsx b/client/src/components/Layouts/Navigator/index.tsx index 46af120..1631ccc 100644 --- a/client/src/components/Layouts/Navigator/index.tsx +++ b/client/src/components/Layouts/Navigator/index.tsx @@ -1,5 +1,5 @@ import { COLORS } from '@/styles/colors'; -import { flex, transform } from '@/styles/tokens'; +import { BOTTOM_HEIGHT, flex, transform } from '@/styles/tokens'; import { TYPO } from '@/styles/typo'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; @@ -64,8 +64,10 @@ const Navigator = ({ curRoute, handleRoute }: Props) => { const NavigatorWrapper = styled.div` width: 100%; min-width: 25rem; - height: 7rem; + height: ${BOTTOM_HEIGHT}rem; ${flex('row', 'center', 'center', 0)}; + background-color: ${COLORS.white}; + z-index: 10; position: fixed; bottom: 0px; diff --git a/client/src/components/Layouts/Picker/index.tsx b/client/src/components/Layouts/Picker/index.tsx index 1ddcee7..b01bd9f 100644 --- a/client/src/components/Layouts/Picker/index.tsx +++ b/client/src/components/Layouts/Picker/index.tsx @@ -35,6 +35,10 @@ interface Props extends ComponentProps<'div'> { * 선택 완료시 set 해줄 함수 */ itemSetter: (item: string[]) => void; + /** + * 초기 선택된 아이템들 + */ + initialSelectedItems?: string[]; } const DatePicker = ({ @@ -43,9 +47,12 @@ const DatePicker = ({ contents, isMultiple, itemSetter, + initialSelectedItems, ...props }: Props) => { - const [selectedItem, setSelectedItem] = useState<string[]>([]); + const [selectedItem, setSelectedItem] = useState<string[]>( + initialSelectedItems ? initialSelectedItems : [], + ); /** 존재하는지 찾ㅈ기 */ const isHavingItem = (item: string) => { diff --git a/client/src/components/Layouts/Title/index.tsx b/client/src/components/Layouts/Title/index.tsx index 82c33e0..5635b6a 100644 --- a/client/src/components/Layouts/Title/index.tsx +++ b/client/src/components/Layouts/Title/index.tsx @@ -24,7 +24,7 @@ interface Props extends ComponentProps<'div'> { const Title = ({ title, subtitle, animated, ...props }: Props) => { return ( <TitleWrapper - css={animated && injectAnimation('fadeInTopDown', '0.5s')} + css={animated && injectAnimation('fadeInTopDown', '0.5s', 'ease')} {...props} > <span css={typo.title}>{title}</span> @@ -44,10 +44,12 @@ const typo = { title: css` ${TYPO.title1.Sb}; color: ${COLORS.grey1}; + line-height: 148%; `, subtitle: css` ${TYPO.text1.Reg}; color: ${COLORS.grey1}; + line-height: 155%; `, }; diff --git a/client/src/components/Layouts/ToastProvider/Toast.stories.tsx b/client/src/components/Layouts/ToastProvider/Toast.stories.tsx new file mode 100644 index 0000000..0b71813 --- /dev/null +++ b/client/src/components/Layouts/ToastProvider/Toast.stories.tsx @@ -0,0 +1,33 @@ +import type { StoryObj } from '@storybook/react'; +import ToastBox from './ToastBox'; + +const meta = { + title: 'Layout/ToastBox', + component: ToastBox, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Default: Story = { + args: { + theme: 'positive', + content: '임시 토스트 메시지입니다.', + }, +}; + +export const Positive: Story = { + args: { + theme: 'positive', + content: '임시 토스트 메시지입니다.', + }, +}; + +export const Negative: Story = { + args: { + theme: 'negative', + content: '임시 토스트 메시지입니다.', + }, +}; diff --git a/client/src/components/Layouts/ToastProvider/ToastBox.tsx b/client/src/components/Layouts/ToastProvider/ToastBox.tsx new file mode 100644 index 0000000..8df685b --- /dev/null +++ b/client/src/components/Layouts/ToastProvider/ToastBox.tsx @@ -0,0 +1,69 @@ +import { ToastTheme } from '@/atoms/toastState'; +import { COLORS } from '@/styles/colors'; +import { TYPO } from '@/styles/typo'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +interface Props { + /** + * 토스트 테마 + */ + theme: ToastTheme; + /** + * 글 내용 + */ + content: string; +} + +/** + * 커스텀 Alert입니다. + */ +const ToastBox = ({ theme, content }: Props) => { + return ( + <ToastInnerWrapper> + <ToastContent>{content}</ToastContent> + <ToastThemeLine css={toastStyles[theme]} /> + </ToastInnerWrapper> + ); +}; + +const ToastInnerWrapper = styled.div` + width: 32rem; + padding: 1.5rem 2rem; + border-radius: 0.5rem; + text-align: start; + white-space: pre-line; + + background-color: ${COLORS.white}; + + position: relative; + overflow: hidden; + box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px; +`; + +const ToastContent = styled.span` + ${TYPO.text2.Reg}; + color: ${COLORS.grey2}; + white-space: pre-line; + text-align: start; + line-height: 150%; +`; + +const ToastThemeLine = styled.div` + width: 0.7rem; + height: 100%; + position: absolute; + top: 0px; + right: 0px; +`; + +const toastStyles = { + positive: css` + background-color: ${COLORS.primaryDeep}; + `, + negative: css` + background-color: ${COLORS.negative}; + `, +}; + +export default ToastBox; diff --git a/client/src/components/Layouts/ToastProvider/index.tsx b/client/src/components/Layouts/ToastProvider/index.tsx new file mode 100644 index 0000000..06115cd --- /dev/null +++ b/client/src/components/Layouts/ToastProvider/index.tsx @@ -0,0 +1,32 @@ +import styled from '@emotion/styled'; +import { transform } from '@/styles/tokens'; +import { useToast } from '@/hooks'; +import { injectAnimation } from '@/styles/animations'; +import ToastBox from './ToastBox'; + +const Toast = () => { + const { isMount, theme, content, isTransition } = useToast(); + + if (isMount) { + return ( + <ToastContainer + css={isTransition && injectAnimation('toastClose', '1s', 'ease')} + > + <ToastBox theme={theme} content={content} /> + </ToastContainer> + ); + } + return <></>; +}; + +const ToastContainer = styled.div` + position: fixed; + z-index: 99; + top: 2rem; + left: 50%; + opacity: 0; + ${transform('translate(-50%, 0%)')}; + ${injectAnimation('toastOpen', '1s', 'ease')}; +`; + +export default Toast; diff --git a/client/src/components/Layouts/index.tsx b/client/src/components/Layouts/index.tsx index f97fcf7..6ec0b7e 100644 --- a/client/src/components/Layouts/index.tsx +++ b/client/src/components/Layouts/index.tsx @@ -2,3 +2,4 @@ export { default as Navigator } from './Navigator'; export { default as Picker } from './Picker'; export { default as Title } from './Title'; export { default as Frame } from './Frame'; +export { default as ToastProvider } from './ToastProvider'; diff --git a/client/src/components/Mate/MateManageKit/AddMateBox.tsx b/client/src/components/Mate/MateManageKit/AddMateBox.tsx new file mode 100644 index 0000000..1a73d74 --- /dev/null +++ b/client/src/components/Mate/MateManageKit/AddMateBox.tsx @@ -0,0 +1,179 @@ +import styled from '@emotion/styled'; +import { MateBox, lineStyles } from './common'; +import { flex, transition } from '@/styles/tokens'; +import { FormEvent, useState } from 'react'; +import { useInput, useTransition } from '@/hooks'; +import PlusIcon from '@/assets/svg/plus-circle.svg'; +import { TYPO } from '@/styles/typo'; +import { COLORS } from '@/styles/colors'; +import { css } from '@emotion/react'; +import { TextInput } from '@/components/Field'; +import { motion } from 'framer-motion'; +import { SquareButton } from '@/components/Buttons'; +import Modal from '@/components/Modal'; + +type FormData = { + name: string; + sId: string; +}; + +interface Props { + saveMateList: (name: string, sId: string) => Promise<boolean>; + isErr: boolean; +} + +const ANIMATION_STYLES = { + hidden: { + opacity: 0, + scaleY: 0, + }, + visible: { + opacity: 1, + scaleY: 1, + }, +}; + +const AddMateBox = ({ saveMateList, isErr }: Props) => { + const [toggleOpen, setToggleOpen] = useState(false); + const { values, handleChange, setValue } = useInput<FormData>({ + name: '', + sId: '', + }); + const { isMount, isTransition, handleOpen, handleClose } = useTransition(); + + const modalConfig = { + title: '메이트 등록에 실패하였습니다.', + message: '이미 등록되었거나, 유효하지 않은 정보입니다.', + handleClose, + onClick: handleClose, + }; + + const handleToggle = () => { + setToggleOpen((prev) => !prev); + }; + + const clearInput = () => { + setValue('name', ''); + setValue('sId', ''); + setToggleOpen(false); + }; + + const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { + e.preventDefault(); + if (values.name === '' || values.sId === '') return; + + const res = await saveMateList(values.name, values.sId); + if (res) clearInput(); + else handleOpen(); + }; + + return ( + <Container> + <MateBox onClick={handleToggle} css={lineStyles.bottom}> + <PlusIcon /> + <PlusContent>메이트 추가하기</PlusContent> + </MateBox> + {toggleOpen && ( + <InputForm + onSubmit={handleSubmit} + initial={ANIMATION_STYLES.hidden} + animate={ + toggleOpen ? ANIMATION_STYLES.visible : ANIMATION_STYLES.hidden + } + transition={{ + type: 'spring', + stiffness: 260, + damping: 20, + }} + > + <InputWrapper> + <InputCaption> + <span css={textStyles.caption}>이름</span> + <span css={textStyles.noti}> + 메이트는 숭실대학교 학생만 가능합니다. + </span> + </InputCaption> + <TextInput + placeholder="홍길동" + value={values.name} + name="name" + onChange={handleChange} + warning={isErr} + /> + </InputWrapper> + <InputWrapper> + <InputCaption> + <span css={textStyles.caption}>학번</span> + </InputCaption> + <TextInput + placeholder="20230000" + value={values.sId} + name="sId" + onChange={handleChange} + warning={isErr} + /> + </InputWrapper> + <SquareButton + title="메이트 등록하기" + theme="primary" + disabled={values.name === '' || values.sId === ''} + css={buttonStyle} + /> + </InputForm> + )} + {isMount && ( + <Modal + {...modalConfig} + modalType="confirm" + isTransition={isTransition} + /> + )} + </Container> + ); +}; + +const Container = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 0)}; +`; + +const PlusContent = styled.span` + ${TYPO.caption.Reg}; + color: ${COLORS.grey3}; +`; + +const InputForm = styled(motion.form)` + width: 100%; + ${flex('column', 'start', 'center', 2.6)}; + padding: 2.7rem 3rem 4.1rem 3rem; + transform-origin: top; + overflow: hidden; +`; + +const InputWrapper = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 1.5)}; +`; + +const InputCaption = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 0.5)}; +`; + +const textStyles = { + caption: css` + ${TYPO.title3.Md}; + color: ${COLORS.grey0}; + `, + noti: css` + ${TYPO.text2.Reg}; + color: ${COLORS.primary}; + `, +}; + +const buttonStyle = css` + margin-top: 1.9rem; + ${transition('0.3s')}; +`; + +export default AddMateBox; diff --git a/client/src/components/Mate/MateManageKit/MateLine.tsx b/client/src/components/Mate/MateManageKit/MateLine.tsx new file mode 100644 index 0000000..8a7f287 --- /dev/null +++ b/client/src/components/Mate/MateManageKit/MateLine.tsx @@ -0,0 +1,87 @@ +import { MateItemType } from 'Mate'; +import { ComponentProps, useMemo } from 'react'; +import { MateBox, boxStyle, lineStyles, logoStyle } from './common'; +import { companionIconGetter } from '@/utils/func/companionIconGetter'; +import styled from '@emotion/styled'; +import { flex } from '@/styles/tokens'; +import { css } from '@emotion/react'; +import { TYPO } from '@/styles/typo'; +import { COLORS } from '@/styles/colors'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faX } from '@fortawesome/free-solid-svg-icons'; +import { KitType } from '.'; +import { useTransition } from '@/hooks'; +import Modal from '@/components/Modal'; + +interface Props extends ComponentProps<'div'> { + info: MateItemType; + kitType: KitType; + removeMate: (info: MateItemType) => void; + selected?: boolean; +} + +const MateLine = ({ info, kitType, removeMate, selected, ...props }: Props) => { + const ProfileIcon = useMemo(() => { + return companionIconGetter(); + }, []); + const { isMount, isTransition, handleOpen, handleClose } = useTransition(300); + + const modalConfig = { + title: '메이트를 정말 삭제하시겠습니까?', + message: '삭제한 메이트는 복구할 수 없습니다.', + onClick: () => removeMate(info), + handleClose, + }; + + return ( + <MateBox + css={[ + lineStyles.both, + boxStyle.mate, + boxStyle[kitType], + selected && boxStyle.selected, + ]} + {...props} + > + <ProfileIcon css={logoStyle} /> + <InfoWrapper> + <span css={textStyles.name}>{info.info.name}</span> + <span css={textStyles.id}>{info.info.sId}</span> + </InfoWrapper> + {kitType === 'removable' && ( + <FontAwesomeIcon icon={faX} css={textStyles.x} onClick={handleOpen} /> + )} + {isMount && ( + <Modal + {...modalConfig} + modalType="decision" + isTransition={isTransition} + /> + )} + </MateBox> + ); +}; + +const InfoWrapper = styled.div` + flex: 1; + ${flex('column', 'center', 'start', 0.2)}; +`; + +const textStyles = { + name: css` + ${TYPO.caption.Reg}; + color: ${COLORS.grey0}; + `, + id: css` + ${TYPO.text2.Reg}; + color: ${COLORS.grey1}; + `, + x: css` + color: ${COLORS.grey3}; + cursor: pointer; + width: 1.8rem; + height: 1.8rem; + `, +}; + +export default MateLine; diff --git a/client/src/components/Mate/MateManageKit/common.tsx b/client/src/components/Mate/MateManageKit/common.tsx new file mode 100644 index 0000000..ceb6acc --- /dev/null +++ b/client/src/components/Mate/MateManageKit/common.tsx @@ -0,0 +1,42 @@ +import { COLORS } from '@/styles/colors'; +import { flex, transition } from '@/styles/tokens'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +export const MateBox = styled.div` + width: 100%; + padding: 1.5rem 3rem; + ${flex('row', 'start', 'center', 1.5)}; + cursor: pointer; + ${transition('0.3s', 'ease')}; +`; + +export const boxStyle = { + mate: css` + background-color: ${COLORS.grey8}; + `, + selectable: css` + cursor: pointer; + `, + removable: css` + cursor: default; + `, + selected: css` + background-color: ${COLORS.primaryWeak}; + `, +}; + +export const lineStyles = { + bottom: css` + border-bottom: 1px solid ${COLORS.grey45}; + `, + both: css` + border-top: 1px solid ${COLORS.grey45}; + border-bottom: 1px solid ${COLORS.grey45}; + `, +}; + +export const logoStyle = css` + width: 4rem; + height: 4rem; +`; diff --git a/client/src/components/Mate/MateManageKit/index.tsx b/client/src/components/Mate/MateManageKit/index.tsx new file mode 100644 index 0000000..83c6fb5 --- /dev/null +++ b/client/src/components/Mate/MateManageKit/index.tsx @@ -0,0 +1,66 @@ +import { COLORS } from '@/styles/colors'; +import { flex } from '@/styles/tokens'; +import styled from '@emotion/styled'; +import { HTMLMotionProps, motion } from 'framer-motion'; +import AddMateBox from './AddMateBox'; +import { useMate } from '@/hooks'; +import MateLine from './MateLine'; +import { MateItemType } from 'Mate'; + +export type KitType = 'selectable' | 'removable'; + +interface Props extends HTMLMotionProps<'ul'> { + kitType: KitType; + selectedList?: MateItemType[]; + handleSelect?: (idx: MateItemType) => void; +} + +/** + * 메이트 관련 기능을 편리하게 사용할 수 있는 키트 + * + * useMate와 함께 이용하세요 + * + * @param kitType 선택형 UI (selectable) / 삭제형 UI (removable) + * @param selectedList 선택한 메이트의 학생 ID가 담길 배열 + * @param handleSelect 인덱스 값에 따라 메이트를 추가하는 함수 + */ +const MateManageKit = ({ + kitType, + selectedList, + handleSelect, + ...props +}: Props) => { + const { mateList, isErr, saveMateList, removeMate, isSelected } = useMate(); + + const handleClick = (info: MateItemType) => { + if (kitType === 'removable' || !handleSelect) return; + handleSelect(info); + }; + + return ( + <Container layout {...props}> + <AddMateBox isErr={isErr} saveMateList={saveMateList} /> + {mateList.map((info) => ( + <MateLine + info={info} + key={info.id} + kitType={kitType} + removeMate={removeMate} + onClick={() => handleClick(info)} + selected={isSelected(selectedList, info)} + /> + ))} + </Container> + ); +}; + +const Container = styled(motion.ul)` + width: 100%; + ${flex('column', 'start', 'center', 0)}; + background-color: ${COLORS.grey55}; + padding: 0px; + margin: 0px; + list-style-type: none; +`; + +export default MateManageKit; diff --git a/client/src/components/Mate/index.tsx b/client/src/components/Mate/index.tsx new file mode 100644 index 0000000..0534f37 --- /dev/null +++ b/client/src/components/Mate/index.tsx @@ -0,0 +1 @@ +export { default as MateManageKit } from './MateManageKit'; diff --git a/client/src/components/Modal/Confrim/Confirm.stories.tsx b/client/src/components/Modal/Confrim/Confirm.stories.tsx index 62003c1..f677e7b 100644 --- a/client/src/components/Modal/Confrim/Confirm.stories.tsx +++ b/client/src/components/Modal/Confrim/Confirm.stories.tsx @@ -15,5 +15,6 @@ export const Default: Story = { args: { title: '모달 제목입니다', message: '모달 내용입니다', + onClick: () => {}, }, }; diff --git a/client/src/components/Modal/Confrim/index.tsx b/client/src/components/Modal/Confrim/index.tsx index 25fa6b8..06c2da9 100644 --- a/client/src/components/Modal/Confrim/index.tsx +++ b/client/src/components/Modal/Confrim/index.tsx @@ -1,6 +1,14 @@ import { ComponentProps } from 'react'; -import { ModalButton, Message, ModalContent, Title } from '../common'; +import { + ModalButton, + Message, + ModalContent, + Title, + Modal, + ModalView, +} from '../common'; import { css } from '@emotion/react'; +import { injectAnimation } from '@/styles/animations'; interface ModalProps extends ComponentProps<'button'> { /** @@ -11,17 +19,38 @@ interface ModalProps extends ComponentProps<'button'> { * 모달에 표시할 메시지 */ message: string; + /** + * 모달 꺼질 때 + */ + isTransition?: boolean; } -const ConfirmModal = ({ title, message, ...props }: ModalProps) => { +const ConfirmModal = ({ + title, + message, + isTransition, + ...props +}: ModalProps) => { return ( - <ModalContent> - <Title>{title} - {message} - - 확인 - - + + { + e.stopPropagation(); + }} + > + + {title} + {message} + + 확인 + + + + ); }; diff --git a/client/src/components/Modal/Decision/index.tsx b/client/src/components/Modal/Decision/index.tsx index 8ae78f8..cbfee65 100644 --- a/client/src/components/Modal/Decision/index.tsx +++ b/client/src/components/Modal/Decision/index.tsx @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import { flex } from '@/styles/tokens'; import { css } from '@emotion/react'; import { COLORS } from '@/styles/colors'; +import { injectAnimation } from '@/styles/animations'; interface ModalProps extends ComponentProps<'button'> { /** @@ -18,11 +19,23 @@ interface ModalProps extends ComponentProps<'button'> { * 취소 버튼 눌렀을 때 실행되는 함수 */ onCancle: () => void; + /** + * 모달 꺼질 때 + */ + isTransition?: boolean; } -const DecisionModal = ({ title, message, onCancle, ...props }: ModalProps) => { +const DecisionModal = ({ + title, + message, + onCancle, + isTransition, + ...props +}: ModalProps) => { return ( - + {title} {message} diff --git a/client/src/components/Modal/Portal/index.tsx b/client/src/components/Modal/Portal/index.tsx new file mode 100644 index 0000000..b2f331c --- /dev/null +++ b/client/src/components/Modal/Portal/index.tsx @@ -0,0 +1,35 @@ +import type React from "react"; +import { useLayoutEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +const createWrapperAndAppendToBody = (wrapperId: string) => { + if (document.getElementById(wrapperId)) + return document.getElementById(wrapperId) as HTMLDivElement; + else { + const wrapperElement = document.createElement("div"); + wrapperElement.setAttribute("id", wrapperId); + document.body.appendChild(wrapperElement); + return wrapperElement; + } +}; + +const ReactPortal = ({ + children, + wrapperId = "react-portal-wrapper", +}: { + children: React.ReactNode; + wrapperId: string; +}) => { + const [wrapperElement, setWrapperElement] = useState( + null + ); + useLayoutEffect(() => { + setWrapperElement(createWrapperAndAppendToBody(wrapperId)); + return () => { + createWrapperAndAppendToBody(wrapperId)?.remove(); + }; + }, [wrapperId]); + return wrapperElement ? createPortal(children, wrapperElement) : null; +}; + +export default ReactPortal; diff --git a/client/src/components/Modal/common.tsx b/client/src/components/Modal/common.tsx index 3ae2020..df09df2 100644 --- a/client/src/components/Modal/common.tsx +++ b/client/src/components/Modal/common.tsx @@ -1,8 +1,42 @@ +import { injectAnimation } from '@/styles/animations'; import { COLORS } from '@/styles/colors'; import { flex, transform, transition } from '@/styles/tokens'; import { TYPO } from '@/styles/typo'; import styled from '@emotion/styled'; +export const Backdrop = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.2); + display: flex; + justify-content: center; + align-items: center; + background-color: rgba(0, 0, 0, 0.5); + z-index: 10; +`; + +export const Modal = styled(Backdrop)` + display: flex; + justify-content: center; + align-items: center; + text-align: center; +`; + +export const ModalView = styled.div` + display: flex; + flex-direction: column; + + background-color: white; + border-radius: 10px; + max-width: 50rem; + + transition: height 0.5s ease; + ${injectAnimation('modalBackgroundAppear', '0.5s', 'ease')}; +`; + export const ModalContent = styled.div` ${flex('column', 'center', 'center', 0)} diff --git a/client/src/components/Modal/index.tsx b/client/src/components/Modal/index.tsx index e69de29..3608a97 100644 --- a/client/src/components/Modal/index.tsx +++ b/client/src/components/Modal/index.tsx @@ -0,0 +1,73 @@ +import { ComponentProps } from 'react'; +import ConfirmModal from './Confrim'; +import DecisionModal from './Decision'; +import { injectAnimation } from '@/styles/animations'; +import ReactPortal from './Portal'; +import { useVh } from '@/hooks'; +import styled from '@emotion/styled'; +import { flex } from '@/styles/tokens'; + +type ModalType = 'confirm' | 'decision'; +interface Props extends ComponentProps<'button'> { + modalType: ModalType; + isTransition: boolean; + handleClose: () => void; + /** + * 모달에 표시할 타이틀 + */ + title: string; + /** + * 모달에 표시할 메시지 + */ + message: string; +} + +const Modal = ({ + isTransition, + modalType, + handleClose, + title, + message, + ...props +}: Props) => { + const { fullPageStyle } = useVh(); + + return ( + + + {modalType === 'confirm' && ( + + )} + {modalType === 'decision' && ( + + )} + + + ); +}; + +const Overlay = styled.div` + position: fixed; + top: 0px; + left: 0px; + background: rgba(0, 0, 0, 0.4); + box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); + backdrop-filter: blur(5.5px); + -webkit-backdrop-filter: blur(5.5px); + z-index: 999; + ${flex('row', 'center', 'center', 0)}; + ${injectAnimation('modalBackgroundAppear', '0.3s', 'ease')} +`; + +export default Modal; diff --git a/client/src/components/Profile/MenuBox/index.tsx b/client/src/components/Profile/MenuBox/index.tsx new file mode 100644 index 0000000..2e3a225 --- /dev/null +++ b/client/src/components/Profile/MenuBox/index.tsx @@ -0,0 +1,36 @@ +import { COLORS } from '@/styles/colors'; +import { transition } from '@/styles/tokens'; +import { TYPO } from '@/styles/typo'; +import styled from '@emotion/styled'; +import { ComponentProps } from 'react'; + +interface Props extends ComponentProps<'div'> { + title: string; +} + +const MenuBox = ({ title, ...props }: Props) => { + return ( + + {title} + + ); +}; + +const Container = styled.div` + width: 100%; + padding: 3rem 3rem; + cursor: pointer; + ${transition('0.2s', 'linear')}; + + &:hover { + background-color: ${COLORS.grey8}; + } +`; + +const Title = styled.span` + ${TYPO.title3.Reg}; + color: ${COLORS.grey1}; + text-align: start; +`; + +export default MenuBox; diff --git a/client/src/components/Profile/Profile.stories.tsx b/client/src/components/Profile/ProfileBox/Profile.stories.tsx similarity index 100% rename from client/src/components/Profile/Profile.stories.tsx rename to client/src/components/Profile/ProfileBox/Profile.stories.tsx diff --git a/client/src/components/Profile/ProfileBox/index.tsx b/client/src/components/Profile/ProfileBox/index.tsx new file mode 100644 index 0000000..38a8d28 --- /dev/null +++ b/client/src/components/Profile/ProfileBox/index.tsx @@ -0,0 +1,51 @@ +import styled from '@emotion/styled'; +import { TYPO } from '@/styles/typo'; +import { companionIconGetter } from '@/utils/func/companionIconGetter'; +import { css } from '@emotion/react'; +import { flex } from '@/styles/tokens'; +import { ComponentProps, useMemo } from 'react'; + +interface ModalProps extends ComponentProps<'div'> { + /** + * 이름 + */ + name: string; + /** + * 학번 + */ + memberNo: string; +} + +const Profile = ({ name, memberNo, ...props }: ModalProps) => { + const ProfileIcon = useMemo(() => { + return companionIconGetter(); + }, []); + + return ( + + + + {name}님, 반가워요! + {memberNo} + + + ); +}; + +const Container = styled.div` + ${flex('row', 'center', 'center', 0)}; + min-height: 6.6rem; +`; + +const InfoDiv = styled.div` + margin-left: 1.5rem; + + ${flex('column', 'center', 'start', 0.3)}; +`; + +const logoStyle = css` + width: 3.5rem; + height: 3.5rem; +`; + +export default Profile; diff --git a/client/src/components/Profile/index.tsx b/client/src/components/Profile/index.tsx index 7f7ebb0..34cae75 100644 --- a/client/src/components/Profile/index.tsx +++ b/client/src/components/Profile/index.tsx @@ -1,53 +1,2 @@ -import styled from '@emotion/styled'; -import { TYPO } from '@/styles/typo'; -import { companionIconGetter } from '@/utils/func/companionIconGetter'; -import { css } from '@emotion/react'; -import { flex } from '@/styles/tokens'; -import { ComponentProps } from 'react'; - -interface ModalProps extends ComponentProps<'div'> { - /** - * 이름 - */ - name: string; - /** - * 학번 - */ - memberNo: string; - /** - * 도서관 id - */ - id: string; -} - -const Profile = ({ name, id, memberNo, ...props }: ModalProps) => { - const ProfileIcon = companionIconGetter(); - - return ( - - - - {name}님, 반가워요! - {memberNo} - - - ); -}; - -const Container = styled.div` - ${flex('row', 'center', 'center', 0)}; - height: 6.6rem; -`; - -const InfoDiv = styled.div` - margin-left: 1.5rem; - - ${flex('column', 'center', 'start', 0.3)}; -`; - -const logoStyle = css` - width: 3.5rem; - height: 3.5rem; -`; - -export default Profile; +export { default as ProfileBox } from './ProfileBox'; +export { default as MenuBox } from './MenuBox'; diff --git a/client/src/components/ReservationCheck/ReservationItem.tsx b/client/src/components/ReservationCheck/ReservationItem.tsx new file mode 100644 index 0000000..031f960 --- /dev/null +++ b/client/src/components/ReservationCheck/ReservationItem.tsx @@ -0,0 +1,169 @@ +import { postReservationCancel } from '@/apis/ReserveData'; +import { useToast, useTransition } from '@/hooks'; +import { COLORS } from '@/styles/colors'; +import { flex } from '@/styles/tokens'; +import { TYPO } from '@/styles/typo'; +import { getAccessToken } from '@/utils/lib/tokenHandler'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import Modal from '../Modal'; +import { PatronInfo, ReservationData } from '@/@types/ReservationList'; +import { formatDateRange } from '@/utils/func/templateTimeConverter'; + +interface Props { + title: string; + beginTime: string; + endTime: string; + room: string; + patrons: PatronInfo[]; + reserveId: number; +} + +const ReservationItem = ({ + title = '외부 예약', + beginTime, + endTime, + room, + patrons, + reserveId, +}: Props) => { + const { isMount, handleOpen, handleClose, isTransition } = useTransition(); + const { showToast } = useToast(); + const AccessToken = getAccessToken(); + const queryClient = useQueryClient(); + + const CancelReserve = useMutation( + () => postReservationCancel(reserveId, AccessToken), + { + onSuccess: () => { + showToast('positive', '예약이 취소되었습니다.'); + queryClient.setQueryData( + ['reserveData', AccessToken], + (prevData: ReservationData | undefined) => { + if (!prevData) return prevData; + return { + ...prevData, + reservations: prevData.data.list.filter( + (reservation) => reservation.id !== reserveId, + ), + }; + }, + ); + + queryClient.invalidateQueries(['reserveData']); + }, + }, + ); + + const handleRemove = () => { + CancelReserve.mutate(); + handleClose(); + }; + + return ( + + + +
{title}
+ + + +
+
+ {formatDateRange(beginTime, endTime)} + {room} + + {patrons.map((el, idx) => { + return ( + + {el.name} / {el.memberNo} + + ); + })} + +
+
+ + {isMount && ( + + )} +
+ ); +}; + +const Container = styled.div` + width: 100%; + display: flex; + position: relative; +`; + +const InfoBox = styled.div` + width: 100%; + background-color: ${COLORS.grey7}; + padding: 1.8rem 1.8rem 1.8rem 2.2rem; + border-radius: 10px 0 0 10px; +`; + +const TitleBox = styled.div` + display: flex; + color: ${COLORS.primary}; + ${TYPO.text2.Sb}; +`; + +const RemoveBox = styled.div` + margin-left: auto; + ${flex('row', 'end', 'center', 1)} + font-size: 2rem; + gap: 0.6rem; +`; + +const DateBox = styled.div` + margin-top: 5px; + ${TYPO.text3.Reg}; + white-space: pre-line; + line-height: 155%; +`; + +const PlaceBox = styled.div` + margin-top: 5px; + ${TYPO.text3.Reg}; +`; + +const PeopleBox = styled.div` + display: flex; + ${flex('row', 'start', 'end', 0.5)}; + flex-wrap: wrap; + margin-top: 1.3rem; +`; + +const PersonInfo = styled.div` + ${TYPO.label.Md}; + border-radius: 3px; + color: ${COLORS.grey3}; + background-color: #ececec; + padding: 2px 5px; +`; + +const SideLine = styled.div` + background-color: ${COLORS.primary}; + border-radius: 0 10px 10px 0; + width: 5px; +`; + +export default ReservationItem; diff --git a/client/src/components/ReservationCheck/index.tsx b/client/src/components/ReservationCheck/index.tsx new file mode 100644 index 0000000..d7c9a03 --- /dev/null +++ b/client/src/components/ReservationCheck/index.tsx @@ -0,0 +1,97 @@ +import styled from '@emotion/styled'; +import { Title } from '../Layouts'; +import { useAtomValue } from 'jotai'; +import { authInfoState } from '@/atoms/authInfoState'; +import { useQuery } from '@tanstack/react-query'; +import { getReservationData } from '@/apis/ReserveData'; +import { ReservationData } from '@/@types/ReservationList'; +import { useAuth } from '@/hooks'; +import ReservationItem from './ReservationItem'; +import { css } from '@emotion/react'; +import { PageContainer, flex } from '@/styles/tokens'; +import { injectAnimation } from '@/styles/animations'; +import { TYPO } from '@/styles/typo'; +import { COLORS } from '@/styles/colors'; + +const ReservationCheck = () => { + const authInfo = useAtomValue(authInfoState); + const { token } = useAuth(); + + const { data: reservationData } = useQuery( + ['reserveData', token], + () => getReservationData(token), + { enabled: token !== undefined }, + ); + + return ( + + + + </TitleWrapper> + <ReservationListsBox + css={[paddingStyle, injectAnimation('fadeInTopDown', '0.5s', 'ease')]} + > + {reservationData?.data?.totalCount !== undefined ? ( + reservationData?.data?.list.map((el, idx) => ( + <ListBox key={idx}> + <ReservationItem + title="예약 완료" + beginTime={el.beginTime} + endTime={el.endTime} + room={el.room.name} + patrons={el.patrons} + reserveId={el.id} + /> + </ListBox> + )) + ) : ( + <EmptyBox> + <span>아직 등록된 예약이 없어요.</span> + </EmptyBox> + )} + </ReservationListsBox> + </PageContainer> + ); +}; + +const pageStyle = css` + width: 100%; + ${flex('column', 'start', 'start', 3)}; + padding: 3rem 0rem; +`; + +const paddingStyle = css` + padding: 0rem 2.7rem; +`; + +const TitleWrapper = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 3)}; +`; + +const ReservationListsBox = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 1)}; + position: relative; +`; + +const ListBox = styled.div` + width: 100%; + display: flex; + justify-content: center; +`; + +const EmptyBox = styled.div` + width: 100%; + height: 40vh; + ${flex('column', 'center', 'center', 0)}; + ${TYPO.text2.Reg}; + color: ${COLORS.grey3}; +`; + +export default ReservationCheck; diff --git a/client/src/components/Seo/index.tsx b/client/src/components/Seo/index.tsx new file mode 100644 index 0000000..c910b52 --- /dev/null +++ b/client/src/components/Seo/index.tsx @@ -0,0 +1,37 @@ +import Head from 'next/head'; +import { ReactNode } from 'react'; + +export interface headProps { + title: string; + desc: string; + children?: ReactNode; +} + +/** + * 검색 최적화를 위한 Seo 컴포넌트 + * 페이지 최 상단에 선언하면 됩니다. + */ +const Seo = ({ title, desc, children }: headProps) => { + return ( + <Head> + <title>{`${title} | SSUDOBI : 숭실대학교 도서관 비대면 예약 시스템`} + + + + + + + + + {children} + + ); +}; + +export default Seo; diff --git a/client/src/components/TableFilter/Filter.styles.tsx b/client/src/components/TableFilter/Filter.styles.tsx index 8fa9691..7113e67 100644 --- a/client/src/components/TableFilter/Filter.styles.tsx +++ b/client/src/components/TableFilter/Filter.styles.tsx @@ -6,12 +6,14 @@ interface FilterBoxProps { } export const FilterBox = styled.div` + position: absolute; display: flex; flex-direction: column; background-color: white; - height: ${(props) => (props.expanded ? '342px' : '46px')}; - width: 100vw; - transition: height 0.5s ease; + height: ${(props) => (props.expanded ? '200px' : '46px')}; + width: 100%; + transition: height 0.1s ease; + border-bottom: ${(props) => (props.expanded ? 'solid 1px #ececec' : 'none')}; `; @@ -22,8 +24,8 @@ export const FilterFlexBox = styled.div` export const FilterButton = styled.div` margin: 0 auto; - margin-top: ${(props) => (props.expanded ? '290px' : '-12px')}; - transition: margin 0.5s ease; + margin-top: ${(props) => (props.expanded ? '-10px' : '-12px')}; + transition: margin 0.1s ease; cursor: pointer; `; diff --git a/client/src/components/TableFilter/Filter.tsx b/client/src/components/TableFilter/Filter.tsx index 3da7eca..8fef634 100644 --- a/client/src/components/TableFilter/Filter.tsx +++ b/client/src/components/TableFilter/Filter.tsx @@ -1,5 +1,5 @@ import * as styles from './Filter.styles'; -import { useState } from 'react'; +import { ReactNode, useState } from 'react'; import Image from 'next/image'; import SlideDown from '@/assets/svg/SlideDown.svg'; import SlideTop from '@/assets/svg/SlideTop.svg'; @@ -7,11 +7,11 @@ import SlideTop from '@/assets/svg/SlideTop.svg'; interface FProps { time: string; place: string; + child: ReactNode; } -export default function Filter({ time, place }: FProps) { +export default function Filter({ time, place, child }: FProps) { const [isExpanded, setIsExpanded] = useState(false); - const handleButtonClick = () => { setIsExpanded(!isExpanded); }; @@ -32,16 +32,16 @@ export default function Filter({ time, place }: FProps) { {isExpanded && ( <> - + {child} )} - ArrowButton + {isExpanded ? ( + + ) : ( + + )} diff --git a/client/src/components/TemplateList/HomeTemplate/HomeTemplate.styles.ts b/client/src/components/TemplateList/Common.styles.tsx similarity index 57% rename from client/src/components/TemplateList/HomeTemplate/HomeTemplate.styles.ts rename to client/src/components/TemplateList/Common.styles.tsx index 44da2f3..deff70e 100644 --- a/client/src/components/TemplateList/HomeTemplate/HomeTemplate.styles.ts +++ b/client/src/components/TemplateList/Common.styles.tsx @@ -1,59 +1,48 @@ import { COLORS } from '@/styles/colors'; +import { flex } from '@/styles/tokens'; +import { TYPO } from '@/styles/typo'; import styled from '@emotion/styled'; export const Container = styled.div` + width: 100%; display: flex; -`; - -// export const SideLine = styled.div` -// background-color: ${COLORS.grey7}; -// border-radius: 0 10px 10px 0; -// `; - -export const InfoBox = styled.div` - width: 250px; - background-color: ${COLORS.grey7}; - padding: 15px 20px; - border-radius: 10px; - margin-right: 12px; + position: relative; `; export const TitleBox = styled.div` + display: flex; color: ${COLORS.primary}; - font-size: 1.2rem; + ${TYPO.text2.Sb}; `; export const DateBox = styled.div` margin-top: 5px; - - font-size: 1rem; - font-weight: 400; + ${TYPO.text3.Reg}; `; export const PlaceBox = styled.div` - margin-top: 3px; - font-size: 1rem; - font-weight: 400; + margin-top: 5px; + ${TYPO.text3.Reg}; `; export const NoteBox = styled.div` - font-weight: 300; - font-size: 1rem; - margin-top: 10px; + ${TYPO.text3.Lg}; + margin-top: 1rem; `; export const PeopleBox = styled.div` display: flex; + ${flex('row', 'start', 'end', 0.5)}; + flex-wrap: wrap; + margin-top: 1.3rem; `; export const PersonInfo = styled.div` - font-size: 0.8rem; + ${TYPO.label.Md}; border-radius: 3px; color: ${COLORS.grey3}; background-color: #ececec; padding: 2px 5px; - margin-right: 5px; - margin-top: 10px; `; export const PlusBox = styled.button` diff --git a/client/src/components/TemplateList/HomeTemplate/HomeTemplate.stories.tsx b/client/src/components/TemplateList/HomeTemplate/HomeTemplate.stories.tsx new file mode 100644 index 0000000..c3e3610 --- /dev/null +++ b/client/src/components/TemplateList/HomeTemplate/HomeTemplate.stories.tsx @@ -0,0 +1,23 @@ +import type { StoryObj } from '@storybook/react'; +import HomeTemplate from './HomeTemplate'; + +const meta = { + title: 'TemplatesList/HomeTemplate', + component: HomeTemplate, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Reserve: Story = { + args: { + title: '템플릿 제목', + beginTime: '목요일 13시', + endTime: '15시 30분', + place: '세미나룸 102호', + memo: '슈도비 회의 잊지말기', + friends: ['정명진 / 20181234', '이준규 / 20181234'], + }, +}; diff --git a/client/src/components/TemplateList/HomeTemplate/HomeTemplate.tsx b/client/src/components/TemplateList/HomeTemplate/HomeTemplate.tsx index 44ebbff..dcdcede 100644 --- a/client/src/components/TemplateList/HomeTemplate/HomeTemplate.tsx +++ b/client/src/components/TemplateList/HomeTemplate/HomeTemplate.tsx @@ -1,23 +1,233 @@ -import * as styles from './HomeTemplate.styles'; +import * as styles from '../Common.styles'; +import styled from '@emotion/styled'; +import { COLORS } from '@/styles/colors'; +import { Patron, TemplateInfo, WeekdayShort } from 'Template'; +import { + formatNextOccurrence, + formatOnlyDate, + formatSchedule, +} from '@/utils/func/templateTimeConverter'; +import { useEffect, useState } from 'react'; +import { useTransition } from '@/hooks'; + +import { MyTemplate } from '@/@types/MyTemplate'; +import { MateItemType } from 'Mate'; +import { CompanionProps } from '@/utils/types/Companion'; +import ConfirmReservationModal from '@/components/BottomModal/ConfirmReservationModal'; +import * as bottomStyles from '@/components/BottomModal/ReserveConfirm'; +import { injectAnimation } from '@/styles/animations'; +import { ReserveError } from '@/utils/types/ReserveError'; +import ConfirmModal from '@/components/Modal/Confrim'; +import { useRouter } from 'next/router'; +import ReactPortal from '@/components/Modal/Portal'; + +type ModalType = 'remove' | 'bottom' | 'confirm'; + +const HomeTemplate = ({ + title, + day, + startTime, + finishTime, + seminarType, + semina, + people, +}: MyTemplate) => { + const { pathname } = useRouter(); + const [isMax, setIsMax] = useState(false); + const [patrons, setPatrons] = useState([]); + const [companions, setCompanions] = useState([ + { + name: '', + memberNo: '', + id: '', + alternativeId: '', + }, + ]); + const [modalType, setModalType] = useState('bottom'); + const { isTransition, isMount, handleOpen, handleClose } = useTransition(400); + const [isSuccess, setIsSuccess] = useState(false); + const [isError, setIsError] = useState({ + isError: false, + errorMessage: '', + }); + + const handleModalOpen = (e: React.MouseEvent, type: ModalType) => { + e.stopPropagation(); + setModalType(type); + handleOpen(); + }; + + const organizePatron = (patron: MateItemType) => { + return `${patron.info.name} / ${patron.info.sId}`; + }; + + const calculPatrons = () => { + if (people.length > 2) { + setIsMax(true); + setPatrons(people.slice(0, 2).map((el) => organizePatron(el))); + } else { + setIsMax(false); + setPatrons(people.map((el) => organizePatron(el))); + } + }; + + const getRestPatrons = (patrons: MateItemType[]) => { + return `외 ${patrons.length - 2}명`; + }; + + const [date, setDate] = useState(''); + const korDay = [ + '일요일', + '월요일', + '화요일', + '수요일', + '목요일', + '금요일', + '토요일', + ]; + const engDay = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const typeNumber = ['학습', '회의', '수업', '기타']; + const handleReserveError = () => { + setIsError({ isError: false, errorMessage: '' }); + handleClose(); + }; + + const route = useRouter(); + const handleReserveSuccess = () => { + route.replace('/schedule'); + setIsSuccess(false); + }; + + useEffect(() => { + calculPatrons(); + setCompanions( + people.map((item) => { + const { id, info } = item; + if (!info || !id) { + return { + name: '', + memberNo: '', + id: '', + alternativeId: '', + }; + } + + return { + name: info.name, + memberNo: info.sId, + id: id.toString(), + alternativeId: info.alternativeId, + }; + }), + ); + }, []); + + const handleOnClickBottomModalOpen = (e: React.MouseEvent) => { + e.stopPropagation(); + console.log('isMoutnsaf', isMount, modalType); + const { beginTime, endTime } = formatNextOccurrence( + engDay[korDay.indexOf(day)] as WeekdayShort, + startTime, + finishTime, + ); + setDate(beginTime); + }; + + function convertNumberArrayToStringArray(numbers: number[]): string[] { + return numbers.map(String); + } -const HomeTemplate = () => { return ( - - - 캡스톤 정기 회의 - 목요일 13시 - 15시 30분 - 세미나룸 102호 - - 캡스톤 정기 회의 잊지말자 제발!! 준비물 챙기기 - + <> + { + handleModalOpen(e, 'bottom'); + handleOnClickBottomModalOpen(e); + }} + > + {title} + {`${day} ${startTime}-${finishTime}`} + {`${seminarType}`} - 최상원 / 20181234 - 정명진 / 20181234 + {patrons.map((el) => { + return {el}; + })} + {isMax && ( + {getRestPatrons(people)} + )} - - + - + + {isMount && modalType === 'bottom' && ( + + + { + e.stopPropagation(); + }} + > + + + + {isSuccess && ( + + )} + {isError.isError && ( + + )} + + )} + ); }; +const InfoBox = styled.div` + width: 100%; + background-color: ${COLORS.grey7}; + padding: 1.5rem 2rem; + border-radius: 1rem; + box-shadow: rgba(0, 0, 0, 0.05) 0px 0px 0px 1px; + cursor: pointer; +`; + +export const PlusBox = styled.button` + width: 5rem; + background-color: ${COLORS.primary}; + border-radius: 1rem; + color: white; + font-size: 3rem; + border: none; +`; + export default HomeTemplate; diff --git a/client/src/components/TemplateList/TemplatePage/Template.stories.tsx b/client/src/components/TemplateList/TemplatePage/Template.stories.tsx new file mode 100644 index 0000000..c223a45 --- /dev/null +++ b/client/src/components/TemplateList/TemplatePage/Template.stories.tsx @@ -0,0 +1,23 @@ +import type { StoryObj } from '@storybook/react'; +import Template from './Template'; + +const meta = { + title: 'TemplatesList/Template', + component: Template, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const TemplateComponent: Story = { + args: { + title: '템플릿 제목', + beginTime: '목요일 12시', + endTime: '15시', + place: '세미나룸 102호', + memo: '슈도비 회의 잊지말기', + friends: ['김수진 / 20191234', '이준규 / 20181234'], + }, +}; diff --git a/client/src/components/TemplateList/TemplatePage/Template.styles.tsx b/client/src/components/TemplateList/TemplatePage/Template.styles.tsx deleted file mode 100644 index 44e33ec..0000000 --- a/client/src/components/TemplateList/TemplatePage/Template.styles.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { COLORS } from '@/styles/colors'; -import styled from '@emotion/styled'; - -export const Container = styled.div` - display: flex; -`; - -export const SideLine = styled.div` - background-color: ${COLORS.primary}; - border-radius: 0 10px 10px 0; - width: 5px; -`; - -export const InfoBox = styled.div` - width: 329px; - background-color: ${COLORS.grey7}; - padding: 15px 20px; - border-radius: 10px 0 0 10px; -`; - -export const TitleBox = styled.div` - color: ${COLORS.primary}; - font-size: 1.2rem; -`; - -export const DateBox = styled.div` - margin-top: 5px; - - font-size: 1rem; - font-weight: 400; -`; - -export const PlaceBox = styled.div` - margin-top: 3px; - font-size: 1rem; - font-weight: 400; -`; - -export const NoteBox = styled.div` - font-weight: 300; - font-size: 1rem; - margin-top: 10px; -`; - -export const PeopleBox = styled.div` - display: flex; -`; - -export const PersonInfo = styled.div` - font-size: 0.8rem; - border-radius: 3px; - color: ${COLORS.grey3}; - background-color: #ececec; - padding: 2px 5px; - margin-right: 5px; - margin-top: 10px; -`; diff --git a/client/src/components/TemplateList/TemplatePage/Template.tsx b/client/src/components/TemplateList/TemplatePage/Template.tsx index 41ec60b..26cd1f9 100644 --- a/client/src/components/TemplateList/TemplatePage/Template.tsx +++ b/client/src/components/TemplateList/TemplatePage/Template.tsx @@ -1,23 +1,282 @@ -import * as styles from './Template.styles'; +import { TemplateProps } from '../TemplateProps'; +import * as styles from '../Common.styles'; +import { COLORS } from '@/styles/colors'; +import styled from '@emotion/styled'; +import { useEffect, useState } from 'react'; +import { ReserveError } from '@/utils/types/ReserveError'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faGear, faXmark } from '@fortawesome/free-solid-svg-icons'; +import { css } from '@emotion/react'; +import { flex } from '@/styles/tokens'; +import { injectAnimation } from '@/styles/animations'; +import { useTemplate, useTransition } from '@/hooks'; +import Modal from '@/components/Modal'; +import { CompanionProps } from '@/utils/types/Companion'; +import ConfirmReservationModal from '@/components/BottomModal/ConfirmReservationModal'; +import { MyTemplate } from '@/@types/MyTemplate'; +import { + formatNextOccurrence, + formatOnlyDate, +} from '@/utils/func/templateTimeConverter'; +import { WeekdayShort } from 'Template'; +import ConfirmModal from '@/components/Modal/Confrim'; +import { useRouter } from 'next/router'; +import * as bottomStyles from '@/components/BottomModal/ReserveConfirm'; +import ReactPortal from '@/components/Modal/Portal'; + +type ModalType = 'remove' | 'bottom' | 'confirm'; + +const Template = ({ + selectedTemplate, + uuid, + title, + day, + beginTime, + endTime, + place, + friends, +}: TemplateProps) => { + const [templateArr, setTemplateArr] = useState([]); + const { removeTemplate, handleRouteTemplate, handleReserveTemplate } = + useTemplate(); + const { isTransition, isMount, handleOpen, handleClose } = useTransition(400); + const [modalType, setModalType] = useState('bottom'); + const [date, setDate] = useState(''); + + const handleModalOpen = (e: React.MouseEvent, type: ModalType) => { + e.stopPropagation(); + setModalType(type); + handleOpen(); + }; + + const handleOpenRemoveModal = (e: React.MouseEvent) => { + e.stopPropagation(); + removeTemplate(uuid); + handleClose(); + }; + + const [isSuccess, setIsSuccess] = useState(false); + const [isError, setIsError] = useState({ + isError: false, + errorMessage: '', + }); + const [companions, setCompanions] = useState([ + { + name: '', + memberNo: '', + id: '', + alternativeId: '', + }, + ]); + const typeNumber = ['학습', '회의', '수업', '기타']; + + useEffect(() => { + const storedCompanionMember = localStorage.getItem('templateArr'); + if (storedCompanionMember) { + setTemplateArr(JSON.parse(storedCompanionMember)); + } + }, []); + const [selectTemplate, setSelectTemplate] = useState(); + + const handleOnClickBottomModalOpen = (e: React.MouseEvent) => { + e.stopPropagation(); + const ReserveArr: MyTemplate[] = templateArr.filter( + (e) => e.title == title, + ); + const korDay = [ + '일요일', + '월요일', + '화요일', + '수요일', + '목요일', + '금요일', + '토요일', + ]; + const engDay = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const { beginTime, endTime } = formatNextOccurrence( + engDay[korDay.indexOf(ReserveArr[0].day)] as WeekdayShort, + ReserveArr[0].startTime, + ReserveArr[0].finishTime, + ); + + setDate(beginTime); + setSelectTemplate(ReserveArr); + }; + + const route = useRouter(); + const handleReserveSuccess = () => { + route.replace('/schedule'); + setIsSuccess(false); + }; + const handleReserveError = () => { + setIsError({ isError: false, errorMessage: '' }); + }; + + useEffect(() => { + if (selectedTemplate) + setCompanions( + selectedTemplate.people.map((item) => { + const { id, info } = item; + if (!info || !id) { + return { + name: '', + memberNo: '', + id: '', + alternativeId: '', + }; + } + + return { + name: info.name, + memberNo: info.sId, + id: id.toString(), + alternativeId: info.alternativeId, + }; + }), + ); + }, [friends]); -const Template = () => { return ( - - - 캡스톤 정기 회의 - 목요일 13시 - 15시 30분 - 세미나룸 102호 - - 캡스톤 정기 회의 잊지말자 제발!! 준비물 챙기기 - - - 최상원 / 20181234 - 정명진 / 20181234 - - - - + <> + + + +
{ + handleModalOpen(e, 'bottom'); + handleOnClickBottomModalOpen(e); + }} + > + {title} +
+ + handleRouteTemplate('edit', selectedTemplate)} + icon={faGear} + css={css` + font-size: 1.6rem; + cursor: pointer; + `} + /> + handleModalOpen(e, 'remove')} + css={css` + cursor: pointer; + `} + /> + +
+
{ + handleModalOpen(e, 'bottom'); + handleOnClickBottomModalOpen(e); + }} + > + + {day + ' ' + beginTime + ' ~ ' + endTime} + + {place} + + {friends.map((el, idx) => { + return ( + + {el.info?.name} / {el.info?.sId} + + ); + })} + +
+
+ +
+ {isMount && modalType === 'remove' && ( + { + handleOpenRemoveModal(e); + }} + /> + )} + {isMount && modalType === 'bottom' && ( + + + { + e.stopPropagation(); + }} + > + `${item}`)} + type={typeNumber.indexOf(selectTemplate![0].type)} + setIsSuccess={setIsSuccess} + setIsError={setIsError} + createType="reserve" + handleClose={handleClose} + /> + + + + )} + {isSuccess && ( + + + + )} + {isError.isError && ( + + )} + ); }; +const RemoveBox = styled.div` + margin-left: auto; + ${flex('row', 'end', 'center', 1)} + font-size: 2rem; + gap: 0.6rem; +`; + +const InfoBox = styled.div` + width: 100%; + background-color: ${COLORS.grey7}; + padding: 1.8rem 1.8rem 1.8rem 2.2rem; + border-radius: 10px 0 0 10px; +`; + +const SideLine = styled.div` + background-color: ${COLORS.primary}; + border-radius: 0 10px 10px 0; + width: 5px; +`; + export default Template; diff --git a/client/src/components/TemplateList/TemplateProps.ts b/client/src/components/TemplateList/TemplateProps.ts new file mode 100644 index 0000000..519ff30 --- /dev/null +++ b/client/src/components/TemplateList/TemplateProps.ts @@ -0,0 +1,25 @@ +import { MyTemplate } from '@/@types/MyTemplate'; +import { PatronInfo } from '@/@types/ReservationList'; +import { MateItemType } from 'Mate'; +import { Patron } from 'Template'; + +export interface TemplateProps { + selectedTemplate: MyTemplate; + uuid: string; + // template 제목 + title: string; + // template 시작 시간, 끝나는 시간 + beginTime: string; + endTime: string; + // template 장소 + place?: string; + // template 동반이용자 + friends: PatronInfo[] | Array | Patron[]; + day?: string; + // 예약 번호 + reserveId?: number; + idx: number; + // onClick 함수 + onClick: (idx: number) => void; + semina?: number[]; +} diff --git a/client/src/components/Timetable/getTimeTable.tsx b/client/src/components/Timetable/getTimeTable.tsx new file mode 100644 index 0000000..9ee6c6f --- /dev/null +++ b/client/src/components/Timetable/getTimeTable.tsx @@ -0,0 +1,233 @@ +import axios from 'axios'; + +const falseData = { + '0900': false, + '0930': false, + '1000': false, + '1030': false, + '1100': false, + '1130': false, + '1200': false, + '1230': false, + '1300': false, + '1330': false, + '1400': false, + '1430': false, + '1500': false, + '1530': false, + '1600': false, + '1630': false, + '1700': false, + '1730': false, + '1800': false, + '1830': false, + '1900': false, + '1930': false, + '2000': false, + '2030': false, +}; + +export type TimeSlot = { + [time: string]: boolean; +}; + +export type WeeklyData = { + [date: string]: TimeSlot; +}; + +export type RoomData = { + [date: string]: { + [room: string]: number[][] | null; + }; +}; + +export function processAvailabilityData( + roomData: RoomData, + targetRooms: string[], +): WeeklyData[] { + const weekData: WeeklyData = {}; + const now = new Date(); + const currentHour = now.getHours().toString().padStart(2, '0'); + const currentMinute = now.getMinutes().toString().padStart(2, '0'); + const currentTime = currentHour + currentMinute; + const currentDate = now.toISOString().split('T')[0]; // YYYY-MM-DD 형식 + // roomData 기준으로 예약 가능한 시간을 초기화한다. + Object.keys(roomData).forEach((date) => { + weekData[date] = {}; + for (let i = 9; i <= 20; i++) { + for (let j = 0; j < 60; j += 30) { + const time = + i.toString().padStart(2, '0') + j.toString().padStart(2, '0'); + weekData[date][time] = true; + + // 현재 날짜와 시간을 체크 + if ( + (date === currentDate && parseInt(time) <= parseInt(currentTime)) || + date < currentDate + ) { + weekData[date][time] = false; + } + } + } + }); + + Object.keys(roomData).forEach((date) => { + if (!weekData[date]) return; + + const roomsToCheck = + targetRooms.length !== 0 ? targetRooms : Object.keys(roomData[date]); // targetRooms가 있으면 그것을 사용, 없으면 모든 방을 사용 + const totalRooms = roomsToCheck.length; + + Object.keys(weekData[date]).forEach((time) => { + let bookedRooms = 0; + + roomsToCheck.forEach((room) => { + const roomReservations = roomData[date][room]; + + if (roomReservations) { + roomReservations.forEach(([start, end]) => { + for (let t = start; t <= end; t++) { + const timeStr = t.toString().padEnd(4, '0'); + if (timeStr === time) { + bookedRooms++; + } + } + }); + } + }); + + // 모든 세미나실이 예약되어 있는 경우에만 false로 설정 + if (bookedRooms >= totalRooms) { + weekData[date][time] = false; + const curHour = time.slice(0, 2); + const curMin = time.slice(2); + const nextTime = + curMin === '00' + ? curHour + `${parseInt(curMin) + 30}` + : `${parseInt(curHour) + 1}` + `${parseInt(curMin) + 30}`; + if (weekData[date][nextTime] !== undefined) { + weekData[date][nextTime] = false; + } + } + // 현재 시간에서 30분 더한 시간을 계산 + }); + }); + + const sortedDates = Object.keys(weekData).sort(); + const weekArray = []; + + let startIndex = 0; + let firstWeekProcessed = false; + + for (let i = 0; i < sortedDates.length; ) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const singleWeek: any = {}; + + if (!firstWeekProcessed) { + const firstDate = new Date(sortedDates[i]); + const firstDay = firstDate.getDay(); + + if (firstDay > 1 && firstDay <= 6) { + for (let j = 1; j < firstDay; j++) { + const prevDate = new Date(firstDate); + prevDate.setDate(firstDate.getDate() - (firstDay - j)); + const prevDateStr = prevDate.toISOString().split('T')[0]; + singleWeek[prevDateStr] = Object.fromEntries( + Object.keys(weekData[sortedDates[i]]).map((time) => [time, false]), + ); + } + + for (let j = 0; j < 6 - firstDay + 1; j++) { + const date = sortedDates[i + j]; + if (date) { + singleWeek[date] = weekData[date]; + } + } + + startIndex = i + (6 - firstDay + 1); + } else { + for (let j = 0; j < 6; j++) { + const date = sortedDates[i + j]; + if (date) { + singleWeek[date] = weekData[date]; + } + } + + startIndex = i + 6; + } + + weekArray.push(singleWeek); + firstWeekProcessed = true; + i = startIndex; + } else { + for (let j = 0; j < 6; j++) { + const date = sortedDates[i + j]; + if (date) { + singleWeek[date] = weekData[date]; + } + } + + weekArray.push(singleWeek); + i += 6; + } + } + const lastArrayDaysLength = Object.keys( + weekArray[weekArray.length - 1], + ).length; + + if (lastArrayDaysLength !== 6) { + for (let i = 0; i < 6 - lastArrayDaysLength; i++) { + const lastWeekDayKeys = Object.keys(weekArray[weekArray.length - 1]); + const lastDate = lastWeekDayKeys[lastWeekDayKeys.length - 1]; + const emptyData = JSON.parse(JSON.stringify(falseData)); + const newEmptyDate = addOneDay(lastDate); + weekArray[weekArray.length - 1][newEmptyDate] = emptyData; + } + } + + return weekArray; +} + +function addOneDay(inputDate: string): string { + // "YYYY-MM-DD" 형태의 문자열을 Date 객체로 변환 + const dateParts = inputDate.split('-'); + const year = parseInt(dateParts[0], 10); + const month = parseInt(dateParts[1], 10) - 1; // 월은 0부터 시작하기 때문에 1을 빼기 + const day = parseInt(dateParts[2], 10); + + const date = new Date(year, month, day); + + // 하루를 더함 + date.setDate(date.getDate() + 1); + + // Date 객체를 "YYYY-MM-DD" 형태의 문자열로 변환 + const nextYear = date.getFullYear(); + const nextMonth = String(date.getMonth() + 1).padStart(2, '0'); // 월은 0부터 시작하기 때문에 1을 더함 + const nextDay = String(date.getDate()).padStart(2, '0'); + + return `${nextYear}-${nextMonth}-${nextDay}`; +} + +export const getTimeTable = async () => { + try { + const apiUrl = `${process.env.NEXT_PUBLIC_API_URL}test/read`; + const headers = { + Accept: 'application/json, text/plain, */*', + }; + // API 호출 + const response = await axios.get(apiUrl, { headers }); + + // 응답 데이터 확인 + const { data } = response; + const { status } = response; + + if (status === 200) { + console.log(data.data); + return data.data as RoomData; + } else { + // 로그인 실패 + } + } catch (error) { + console.error('오류:', error); + } +}; diff --git a/client/src/components/Timetable/index.tsx b/client/src/components/Timetable/index.tsx new file mode 100644 index 0000000..192ae5f --- /dev/null +++ b/client/src/components/Timetable/index.tsx @@ -0,0 +1,346 @@ +import styled from '@emotion/styled'; +import { useState } from 'react'; +import Lottie from 'lottie-react'; +import loading from '@/assets/json/magnifying.json'; +import { TYPO } from '@/styles/typo'; + +interface TimeSlot { + [time: string]: boolean; +} +interface WeeklyData { + [date: string]: TimeSlot; +} +interface TProps { + processData: WeeklyData[]; + curProcessDataIdx: number; + isOpenSeminar: boolean; + isSelected: boolean; + setIsSelected: React.Dispatch>; + selectedSlots: string[]; + + setSelectedSlots: React.Dispatch>; + curTime: string; + dates: string[]; +} + +//[2-1] 시간표 불러오기 및 출력 +const Schedule: React.FC = ({ + processData, + isOpenSeminar, + isSelected, + setIsSelected, + selectedSlots, + setSelectedSlots, + curProcessDataIdx, + curTime, + dates, +}) => { + const days = ['', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일']; + + const [isDataAllFalse, setIsDataAllFalse] = useState(false); + + const SeminarTimes = [ + '10', + ' ', + '11', + ' ', + '12', + ' ', + '13', + ' ', + '14', + ' ', + '15', + ' ', + '16', + ' ', + '17', + ' ', + '18', + ' ', + '19', + ' ', + '20', + ' ', + ]; + + const OpenSeminarTimes = [ + '06', + '07', + '08', + '09', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '19', + '20', + '21', + '22', + '23', + ]; + + if (processData.length === 0) { + return ( + + +
+ 세미나룸 예약 가능 시간을 + 찾고있어요! +
+ ); + } else { + return ( + + + + + {days.map((day, idx) => { + return {day}; + })} + + + {dates.map((day, idx) => { + return {day}; + })} + + + + {isOpenSeminar ? ( + OpenSeminarTimes.map((time, idx) => { + return ( + + {time}  + {} + {} + {} + {} + {} + + ); + }) + ) : ( + + + {SeminarTimes.map((time) => { + return {time}; + })} + + {isDataAllFalse ? ( + <> + + + + + + + 예약이 불가능해요 + + ) : ( + <> + {Object.entries(processData[curProcessDataIdx]).map( + ([date, timeSlots], idx) => ( + + {Object.entries(timeSlots) + .sort(([timeA], [timeB]) => + timeA.localeCompare(timeB), + ) + .map(([time, value], idx) => { + const slotKey = `${date}-${time}`; + const isSlotSelected = + selectedSlots.includes(slotKey); + const nextSlots = Object.entries(timeSlots) + .sort(([timeA], [timeB]) => + timeA.localeCompare(timeB), + ) + .slice( + idx + 1, + idx + Math.floor(parseInt(curTime) / 30), + ) + .map(([time, nextValue]) => ({ + slot: `${date}-${time}`, + value: nextValue, + })); + + const isSelectable = nextSlots.every( + (slot) => + !selectedSlots.includes(slot.slot) && + slot.value, + ); + + return ( + { + if (!value) return; + + if (isSlotSelected) { + setSelectedSlots([]); + } else if (isSelectable) { + if (isSelected) return; + const newSelectedSlots = [ + slotKey, + ...nextSlots.map((slot) => slot.slot), + ]; + if ( + newSelectedSlots.length !== + Math.floor(parseInt(curTime) / 30) + ) { + return; + } + setSelectedSlots(newSelectedSlots); + setIsSelected((res: boolean) => !res); + } + }} + > +   + + ); + })} + + ), + )} + + )} + + )} + +
+ 예약할 시간을 선택해주세요 +
+ ); + } +}; + +export default Schedule; + +const Container = styled.div` + display: flex; + flex-direction: column; +`; + +const DetailTextBox = styled.div` + color: #1d9bf0; + display: flex; + margin: auto; + align-items: center; + justify-content: center; + height: 5rem; +`; + +const Table = styled.table` + border-spacing: 0; + max-width: 36rem; + width: 100%; +`; + +const Thead = styled.thead``; + +const TableTr = styled.tr``; + +const TableTh = styled.th` + font-size: 1.1rem; + vertical-align: top; +`; + +const DisplayBox = styled.div<{ + value: boolean; + type: number; + selected: boolean; +}>` + height: 24px; + border-bottom: ${(props) => + props.type % 2 === 1 + ? props.type === 23 + ? 'none' + : '1px solid #b5b5b5 ' + : '1px dotted #b5b5b5'}; + background-color: ${(props) => + props.selected ? '#6ABCF5' : props.value ? '#D7EAFC' : 'white'}; +`; + +const TableTd = styled.div<{ + isEmpty: boolean; +}>` + display: table-cell; + background-color: ${(props) => (props.isEmpty ? '#D7EAFC' : 'white')}; + width: 6rem; + border: 1px solid #b5b5b5; +`; + +const TableTimeBox = styled.div` + height: 24px; + margin-top: -0.25px; +`; + +const BodyTable = styled.tbody``; + +const EmptyTimeTableText = styled.div` + position: absolute; + bottom: 50%; + left: 40%; + color: white; + color: #fff; + font-family: Pretendard; + font-size: 16px; + font-style: normal; + font-weight: 700; + line-height: normal; + letter-spacing: -0.8px; +`; + +const LoadingContainer = styled.div` + display: flex; + flex-direction: column; + width: 100%; + justify-content: center; + align-items: center; +`; + +const LoadingText = styled.div` + ${TYPO.text1.Md}; + margin: 0 auto; +`; diff --git a/client/src/constants/reserveTime.ts b/client/src/constants/reserveTime.ts new file mode 100644 index 0000000..7442529 --- /dev/null +++ b/client/src/constants/reserveTime.ts @@ -0,0 +1,8 @@ +export const RESERVE_TIME = { + '30분': '30', + '1시간': '60', + '1시간 30분': '90', + '2시간': '120', + '2시간 30분': '150', + '3시간': '180', +}; diff --git a/client/src/constants/roomUseSection.ts b/client/src/constants/roomUseSection.ts new file mode 100644 index 0000000..6c283f2 --- /dev/null +++ b/client/src/constants/roomUseSection.ts @@ -0,0 +1,6 @@ +export const ROOM_USE_SECTION: { [key: string]: number } = { + 학습: 1, + 회의: 2, + 수업: 3, + 기타: 4, +}; diff --git a/client/src/constants/seminaAvailablePeople.ts b/client/src/constants/seminaAvailablePeople.ts new file mode 100644 index 0000000..f1a49fb --- /dev/null +++ b/client/src/constants/seminaAvailablePeople.ts @@ -0,0 +1,10 @@ +export const SEMINA_AVAILABLE_PEOPLE: { [key: number]: number[] } = { + 1: [3, 4, 5, 6, 7, 8], + 2: [3, 4], + 3: [3, 4, 5, 6], + 4: [3, 4, 5], + 5: [3, 4, 5, 6], + 6: [3, 4, 5, 6], + 7: [3, 4, 5, 6, 7, 8], + 9: [4, 5, 6, 7, 8], +}; diff --git a/client/src/hooks/index.tsx b/client/src/hooks/index.tsx index f6a181b..eecbcff 100644 --- a/client/src/hooks/index.tsx +++ b/client/src/hooks/index.tsx @@ -2,3 +2,7 @@ export { default as useVh } from './useVh'; export { default as useHeader } from './useHeader'; export { default as useInput } from './useInput'; export { default as useAuth } from './useAuth'; +export { default as useToast } from './useToast'; +export { default as useTransition } from './useTransition'; +export { default as useMate } from './useMate'; +export { default as useTemplate } from './useTemplate'; diff --git a/client/src/hooks/useAuth.tsx b/client/src/hooks/useAuth.tsx index 2361d2b..4519c99 100644 --- a/client/src/hooks/useAuth.tsx +++ b/client/src/hooks/useAuth.tsx @@ -1,15 +1,28 @@ import AuthApi from '@/apis/auth'; import { authInfoState } from '@/atoms/authInfoState'; -import { getUserInfo, updateUserInfo } from '@/utils/lib/infoHandler'; -import { updateAccessToken } from '@/utils/lib/tokenHandler'; +import { + getUserInfo, + removeUserInfo, + updateUserInfo, +} from '@/utils/lib/infoHandler'; +import { + getAccessToken, + removeAccessToken, + updateAccessToken, +} from '@/utils/lib/tokenHandler'; import { useAtom } from 'jotai'; import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import { useToast } from '.'; const useAuth = () => { const authApi = new AuthApi(); + const { showToast } = useToast(); const router = useRouter(); const [authInfo, setAuthInfo] = useAtom(authInfoState); + const [isWarn, setIsWarn] = useState(false); + const [token, setToken] = useState(''); /** * 로그인 함수 @@ -24,8 +37,12 @@ const useAuth = () => { }); updateAccessToken(data.accessToken); updateUserInfo(data.name, data.printMemberNo, id, password); + setIsWarn(false); + showToast('positive', '로그인에 성공하였습니다.'); router.replace('/'); } catch (err) { + setIsWarn(true); + showToast('negative', '로그인에 실패하였습니다.\n다시 시도해주세요!'); console.log(err); } }; @@ -38,7 +55,7 @@ const useAuth = () => { const userInfo = getUserInfo(); if (!userInfo) throw new Error('Empty user info'); const data = await authApi.login(userInfo.loginId, userInfo.password); - console.log(data); + setAuthInfo({ name: data.name, sId: data.printMemberNo, @@ -56,7 +73,27 @@ const useAuth = () => { } }; - return { authInfo, autoLogin, handleLogin }; + /** + * 로그인 함수 + */ + const handleLogout = async () => { + try { + setAuthInfo({ name: '', sId: '' }); + removeAccessToken(); + removeUserInfo(); + showToast('positive', '로그아웃에 성공하였습니다.'); + router.replace('/landing'); + } catch (err) { + showToast('negative', '로그아웃에 실패하였습니다.\n다시 시도해주세요!'); + console.log(err); + } + }; + + useEffect(() => { + if (typeof window !== undefined) setToken(getAccessToken()); + }, []); + + return { authInfo, autoLogin, handleLogin, isWarn, handleLogout, token }; }; export default useAuth; diff --git a/client/src/hooks/useMate.tsx b/client/src/hooks/useMate.tsx new file mode 100644 index 0000000..7fed0e4 --- /dev/null +++ b/client/src/hooks/useMate.tsx @@ -0,0 +1,102 @@ +import StudentApi from '@/apis/student'; +import { MateItemType } from 'Mate'; +import { useEffect, useState } from 'react'; +import { useToast } from '.'; + +const MATE_KEY = 'MATE_KEY'; + +const useMate = () => { + const studentApi = new StudentApi(); + const { showToast } = useToast(); + const [mateList, setMateList] = useState([]); + const [isErr, setIsErr] = useState(false); + const [selectedList, setSelectedList] = useState([]); + + /** + * 메이트 정보 받아오기 & 갱신 + */ + const getMateList = () => { + const savedMates = localStorage.getItem(MATE_KEY); + if (savedMates) setMateList(JSON.parse(savedMates)); + }; + + /** + * 새 메이트 추가하기 + */ + const saveMateList = async (name: string, sId: string): Promise => { + const finding = mateList.some((mate) => mate.info.sId === sId); + if (finding) { + setIsErr(true); + return false; + } + + try { + const newMateId = await studentApi.getStudentId({ name, sId }); + setIsErr(false); + const newMateList = [ + ...mateList, + { + info: { + name, + sId, + alternativeId: newMateId.alternativeId, + }, + id: newMateId.id, + }, + ]; + localStorage.setItem(MATE_KEY, JSON.stringify(newMateList)); + setMateList(newMateList); + showToast('positive', '메이트 추가가 완료되었습니다.'); + return true; + } catch (err) { + console.log(err); + setIsErr(true); + return false; + } + }; + + /** + * 특정 메이트 제거 + */ + const removeMate = (info: MateItemType) => { + const newMateList = mateList.filter((mate) => mate.id !== info.id); + localStorage.setItem(MATE_KEY, JSON.stringify(newMateList)); + setMateList(newMateList); + }; + + /** + * 선택된 아이템인지 체크 + */ + const isSelected = (list: MateItemType[] | undefined, item: MateItemType) => { + if (!list) return false; + const res = list.some((el) => el.id === item.id); + return res; + }; + + /** + * selectable) 메이트 선택 + */ + const handleSelect = (info: MateItemType) => { + const selected = isSelected(selectedList, info); + if (selected) + setSelectedList((prev) => prev.filter((el) => el.id !== info.id)); + else setSelectedList((prev) => [...prev, info]); + }; + + useEffect(() => { + getMateList(); + }, []); + + return { + mateList, + getMateList, + saveMateList, + removeMate, + isErr, + selectedList, + handleSelect, + isSelected, + }; +}; + +export default useMate; diff --git a/client/src/hooks/useTemplate.tsx b/client/src/hooks/useTemplate.tsx new file mode 100644 index 0000000..cdc86e6 --- /dev/null +++ b/client/src/hooks/useTemplate.tsx @@ -0,0 +1,249 @@ +import { MyTemplate, Seminartype, UsageType } from '@/@types/MyTemplate'; +import { useAtom } from 'jotai'; +import { useHeader } from '.'; +import { editingState } from '@/atoms/editingState'; +import { useRouter } from 'next/router'; +import { + initTemplateState, + myTemplateListState, + templateState, +} from '@/atoms/templateState'; +import { ChangeEvent } from 'react'; +import { MateItemType } from 'Mate'; +import { v4 as uuidv4 } from 'uuid'; +import AuthApi from '@/apis/auth'; +import { formatNextOccurrence } from '@/utils/func/templateTimeConverter'; +import { WeekdayShort } from 'Template'; + +export type RouteType = 'create' | 'edit'; +export type StageType = 'name' | 'companion' | 'time'; + +const useTemplate = () => { + /**---------- state, atom, custom hook ----------**/ + const [template, setTemplate] = useAtom(templateState); + const [editing, setEditing] = useAtom(editingState); + const [templateList, setTemplateList] = useAtom(myTemplateListState); + const router = useRouter(); + const { setHeader } = useHeader(); + /**---------- 템플릿 정보 관리 관련 ----------**/ + const getMyTemplateList = () => { + const prevTemplates = localStorage.getItem('templateArr'); + if (prevTemplates) setTemplateList(JSON.parse(prevTemplates)); + else setTemplateList([]); + }; + + /**---------- 템플릿 페이지 세팅 관련 함수 ----------**/ + /** + * 페이지 헤더 세팅 + */ + const settingHeader = () => { + if (editing) setHeader('템플릿 수정하기'); + else setHeader('템플릿 추가하기'); + }; + + /** + * 템플릿 페이지 이동 (생성 / 수정 선택) + */ + const handleRouteTemplate = ( + type: RouteType, + selectedTemplate?: MyTemplate, + ) => { + switch (type) { + case 'create': + setEditing(false); + setTemplate(initTemplateState); + break; + case 'edit': + setEditing(true); + setTemplate(selectedTemplate!); + break; + } + router.push('/template/1'); + }; + + /** + * 단계 이동 관련 함수 + */ + const handleNextStage = (stage: StageType) => { + switch (stage) { + case 'name': + router.push('/template/2'); + break; + case 'companion': + router.push('/template/3'); + break; + case 'time': + saveTemplate(editing ? 'edit' : 'create'); + break; + } + }; + + /**---------- 템플릿 정보 세팅 관련 함수 ----------**/ + /** + * 템플릿 이름 설정 + */ + const settingTitle = (e: ChangeEvent) => { + setTemplate((prev) => { + return { + ...prev, + title: e.target.value, + }; + }); + }; + + /** + * 템플릿 사용 시간 설정 + */ + const settingTime = (time: number) => { + setTemplate((prev) => { + return { + ...prev, + time: time, + }; + }); + }; + + /** + * 템플릿 사용 용도 설정 + */ + const settingUsage = (usage: UsageType) => { + setTemplate((prev) => { + return { + ...prev, + type: usage, + }; + }); + }; + + /** + * 세미나룸 선택 + */ + const settingSeminarType = (type: Seminartype) => { + setTemplate((prev) => { + return { + ...prev, + seminarType: type, + }; + }); + }; + + /** + * 세미나룸 선택 + */ + const settingCompanion = (selectedList: MateItemType[]) => { + setTemplate((prev) => { + return { + ...prev, + people: selectedList, + usePerson: selectedList.length, + }; + }); + }; + + /** + * 세미나룸 예약 정보 선택 + */ + const settingReservationInfo = ( + day: string, + startTime: string, + finishTime: string, + semina: number[], + ) => { + setTemplate((prev) => { + return { + ...prev, + day, + startTime, + finishTime, + semina, + }; + }); + }; + + /** + * 템플릿 저장 / 갱신 + */ + const saveTemplate = (type: RouteType) => { + switch (type) { + case 'create': + const newTemplate: MyTemplate = { + ...template, + uuid: uuidv4(), + }; + localStorage.setItem( + 'templateArr', + JSON.stringify([...templateList, newTemplate]), + ); + getMyTemplateList(); + break; + case 'edit': + const newTemplates: MyTemplate[] = [ + ...templateList.filter((item) => item.uuid !== template.uuid), + template, + ]; + localStorage.setItem('templateArr', JSON.stringify(newTemplates)); + break; + } + setTemplate(initTemplateState); + }; + + /** + * 템플릿 제거 + */ + const removeTemplate = (uuid: string) => { + localStorage.setItem( + 'templateArr', + JSON.stringify(templateList.filter((item) => item.uuid !== uuid)), + ); + getMyTemplateList(); + }; + + /**---------- 템플릿으로 예약하기 관련 ----------**/ + const handleReserveTemplate = async (selectedTemplate: MyTemplate) => { + const authApi = new AuthApi(); + const korDay = [ + '일요일', + '월요일', + '화요일', + '수요일', + '목요일', + '금요일', + '토요일', + ]; + const engDay = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const typeNumber = ['학습', '회의', '수업', '기타']; + const { beginTime, endTime } = formatNextOccurrence( + engDay[korDay.indexOf(selectedTemplate.day)] as WeekdayShort, + selectedTemplate.startTime, + selectedTemplate.finishTime, + ); + const res = await authApi.reservation( + String(selectedTemplate.semina[0]), + typeNumber.indexOf(selectedTemplate.type), + beginTime, + endTime, + selectedTemplate.people.map((res) => res.info.alternativeId), + ); + return res; + }; + + return { + settingHeader, + settingTitle, + settingUsage, + settingSeminarType, + settingCompanion, + settingTime, + settingReservationInfo, + handleRouteTemplate, + handleNextStage, + getMyTemplateList, + removeTemplate, + handleReserveTemplate, + template, + editing, + templateList, + }; +}; + +export default useTemplate; diff --git a/client/src/hooks/useToast.tsx b/client/src/hooks/useToast.tsx new file mode 100644 index 0000000..dc542d2 --- /dev/null +++ b/client/src/hooks/useToast.tsx @@ -0,0 +1,59 @@ +import { + ToastTheme, + toastState, + toastTransitionState, +} from '@/atoms/toastState'; +import { useAtom } from 'jotai'; +import { useEffect } from 'react'; + +const useToast = () => { + const [toast, setToast] = useAtom(toastState); + const [isTransition, setIsTransition] = useAtom(toastTransitionState); + let timeoutId: NodeJS.Timeout | null = null; + + const showToast = (theme: ToastTheme, content: string, delay = 3000) => { + if (toast.isOpen) return; + setToast({ isOpen: true, theme, content }); + setTimeout(() => { + closeToast(); + }, delay); + }; + + const handleUnmount = () => { + setToast((prev) => { + return { + ...prev, + isOpen: false, + }; + }); + }; + + const closeToast = (delay = 1000) => { + if (isTransition) return; + + setIsTransition(true); + timeoutId = setTimeout(() => { + setIsTransition(false); + handleUnmount(); + }, delay); + }; + + useEffect(() => { + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, []); + + return { + showToast, + closeToast, + isMount: toast.isOpen, + theme: toast.theme, + content: toast.content, + isTransition, + }; +}; + +export default useToast; diff --git a/client/src/hooks/useTransition.tsx b/client/src/hooks/useTransition.tsx new file mode 100644 index 0000000..47528f7 --- /dev/null +++ b/client/src/hooks/useTransition.tsx @@ -0,0 +1,56 @@ +import { useState, useEffect } from "react"; + +/** transition이 true가 되면 닫는 애니메이션 실행 */ +const useTransition = (delay = 1000) => { + const [isMount, setIsMount] = useState(false); + const [isTransition, setIsTransition] = useState(false); + const [isActive, setIsActive] = useState(false); + let timeoutId: NodeJS.Timeout | null = null; + + const handleOpen = () => { + setIsMount(true); + setIsActive(true); + }; + + const handleUnmount = () => { + setIsMount(false); + }; + + const handleClose = () => { + if (isTransition) return; + + setIsTransition(true); + setIsActive(false); + timeoutId = setTimeout(() => { + setIsTransition(false); + handleUnmount(); + }, delay); + }; + + const handleToggle = () => { + if (isMount) { + handleClose(); + } else { + handleOpen(); + } + }; + + useEffect(() => { + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, []); + + return { + isMount, + isTransition, + handleClose, + handleOpen, + handleToggle, + isActive, + }; +}; + +export default useTransition; diff --git a/client/src/hooks/useVh.tsx b/client/src/hooks/useVh.tsx index 6bd0728..51337f5 100644 --- a/client/src/hooks/useVh.tsx +++ b/client/src/hooks/useVh.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/react'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; /** 리사이징 이벤트에 따라 변하는 vh 가져오는 훅 (불필요한 스크롤 생기는 이슈 방지) */ const useVh = () => { @@ -10,12 +10,31 @@ const useVh = () => { setVh(vh); }; - const fullPageStyle = useMemo( - () => css` + const remToPx = (rem: number) => { + if (typeof window !== 'undefined') { + return ( + parseFloat(getComputedStyle(document.documentElement).fontSize) * rem + ); + } else { + return 10 * rem; + } + }; + + const fullPageHeight = useCallback( + (headerHeight = 0, navigatorHeight = 0) => { + const headerHeightPx = remToPx(headerHeight); + const navigatorHeightPx = remToPx(navigatorHeight); + return `calc(${vh}px * 100 - ${headerHeightPx}px - ${navigatorHeightPx}px)`; + }, + [vh], + ); + + const fullPageStyle = useCallback( + (headerHeight = 0, navigatorHeight = 0) => css` width: 100%; - height: calc(${vh}px * 100); + height: ${fullPageHeight(headerHeight, navigatorHeight)}; `, - [vh], + [fullPageHeight], ); useEffect(() => { diff --git a/client/src/pages/_app.tsx b/client/src/pages/_app.tsx index ce88842..e56ecac 100644 --- a/client/src/pages/_app.tsx +++ b/client/src/pages/_app.tsx @@ -1,6 +1,6 @@ import type { AppProps } from 'next/app'; import '@fortawesome/fontawesome-svg-core/styles.css'; -import { Frame } from '@/components/Layouts'; +import { Frame, ToastProvider } from '@/components/Layouts'; import useAuth from '@/hooks/useAuth'; import { useEffect } from 'react'; import { Global, css } from '@emotion/react'; @@ -31,6 +31,7 @@ export default function App({ Component, pageProps }: AppProps) { return ( + diff --git a/client/src/pages/_document.tsx b/client/src/pages/_document.tsx index 136d9d9..5f368df 100644 --- a/client/src/pages/_document.tsx +++ b/client/src/pages/_document.tsx @@ -3,7 +3,52 @@ import { Html, Head, Main, NextScript } from 'next/document'; export default function Document() { return ( - + + + + + + + + + + + + + + +
diff --git a/client/src/pages/create/companions.tsx b/client/src/pages/create/companions.tsx new file mode 100644 index 0000000..65510ff --- /dev/null +++ b/client/src/pages/create/companions.tsx @@ -0,0 +1,121 @@ +import { RoundButton } from '@/components/Buttons'; +import { useHeader, useMate } from '@/hooks'; +import { COLORS } from '@/styles/colors'; +import { TYPO } from '@/styles/typo'; +import styled from '@emotion/styled'; +import { useRouter } from 'next/router'; +import { useEffect, useLayoutEffect, useState } from 'react'; +import { MateManageKit } from '@/components/Mate'; +import { injectAnimation } from '@/styles/animations'; +import { CompanionProps } from '@/utils/types/Companion'; +import { PageContainer, flex } from '@/styles/tokens'; +import { css } from '@emotion/react'; +import { Title } from '@/components/Layouts'; +import Seo from '@/components/Seo'; +import { seos } from '@/assets/seos'; + +/** + * 예약하기 페이지 + */ +const Reserve = () => { + const { setHeader } = useHeader(); + const route = useRouter(); + + const time = route.query.time as string; + const useCase = route.query.useCase as string; + + const { selectedList, handleSelect } = useMate(); + + const [companions, setCompnaions] = useState([]); + + const [checkedButton, setCheckedButton] = useState(false); + + useLayoutEffect(() => { + setHeader('예약하기'); + }, []); + + useEffect(() => { + const curComapnions: CompanionProps[] = selectedList.map((res) => { + return { + name: res.info.name, + memberNo: res.info.sId, + id: `${res.id}`, + alternativeId: res.info.alternativeId, + }; + }); + setCompnaions(curComapnions); + }, [selectedList]); + + useEffect(() => { + console.log(companions); + if (companions.length >= 2 && companions.length <= 8) { + setCheckedButton(false); + } else { + setCheckedButton(true); + } + }, [companions]); + + return ( + + + + <CompanionListContainer> + <MateManageKit + kitType="selectable" + selectedList={selectedList} + handleSelect={handleSelect} + css={injectAnimation('fadeInTopDown', '0.5s', 'ease')} + /> + </CompanionListContainer> + <ButtonWrapper> + <RoundButton + title="예약 가능 시간 탐색하기" + theme="primary" + disabled={checkedButton} + onClick={() => { + if (checkedButton) return; + const companionsJson = JSON.stringify(companions); + const encodedCompanions = encodeURIComponent(companionsJson); + + route.push({ + pathname: '/create/timetable', + query: { + time: time, + useCase: useCase, + companions: encodedCompanions, + }, + }); + }} + /> + </ButtonWrapper> + </PageContainer> + ); +}; + +export default Reserve; + +const pageStyle = css` + width: 100%; + ${flex('column', 'start', 'start', 3)}; + padding: 3rem 0rem; +`; + +const paddingStyle = css` + padding: 0rem 2.7rem; +`; + +const CompanionListContainer = styled.div` + width: 100%; + overflow-y: auto; + max-height: 50vh; +`; + +const ButtonWrapper = styled.div` + width: 100%; + padding: 5rem 2.7rem 0rem 2.7rem; +`; diff --git a/client/src/pages/create/reserve.tsx b/client/src/pages/create/reserve.tsx index d9557d9..3676d96 100644 --- a/client/src/pages/create/reserve.tsx +++ b/client/src/pages/create/reserve.tsx @@ -1,18 +1,191 @@ +import { RoundButton } from '@/components/Buttons'; +import Usage from '@/components/Buttons/Usage'; +import { Picker } from '@/components/Layouts'; import { useHeader } from '@/hooks'; -import { PageContainer } from '@/styles/tokens'; -import { useLayoutEffect } from 'react'; +import { COLORS } from '@/styles/colors'; +import { PageContainer, flex } from '@/styles/tokens'; +import { TYPO } from '@/styles/typo'; +import styled from '@emotion/styled'; +import { useRouter } from 'next/router'; +import CheckedBox from '@/assets/svg/checkBox-checked.svg'; +import UnCheckedBox from '@/assets/svg/checkBox-unChecked.svg'; +import { useEffect, useLayoutEffect, useState } from 'react'; +import { css } from '@emotion/react'; +import { Title } from '@/components/Layouts'; +import { injectAnimation } from '@/styles/animations'; +import Seo from '@/components/Seo'; +import { seos } from '@/assets/seos'; /** * 예약하기 페이지 */ const Reserve = () => { const { setHeader } = useHeader(); + const route = useRouter(); + + const [checkedButton, setCheckedButton] = useState<boolean>(true); + const [checkBox, setCheckBox] = useState<boolean>(false); + const [selectedUsage, setSelectedUsage] = useState('학습'); + + const handleUsageClick = (title: string) => { + setSelectedUsage(title); + }; + const [time, setTime] = useState<string[]>([]); useLayoutEffect(() => { setHeader('예약하기'); }, []); - return <PageContainer></PageContainer>; + useEffect(() => { + if (time.length && selectedUsage !== '') { + setCheckedButton(false); + } else { + setCheckedButton(true); + } + }, [time, selectedUsage]); + + return ( + <PageContainer css={pageStyle}> + <Seo {...seos.reserve} /> + <Title + title="세미나실을 예약할 거예요." + subtitle="간단한 예약 정보를 입력해 주세요!" + animated + /> + <OptionSelectWrapper + css={injectAnimation('fadeInTopDown', '0.5s', 'ease')} + > + <SubTitle>사용시간과 용도를 선택해주세요.</SubTitle> + <Picker + title="사용 시간" + itemType="Info" + isMultiple={false} + itemSetter={setTime} + contents={[ + { + disabled: false, + title: '1시간', + }, + { + disabled: false, + title: '2시간', + }, + { + disabled: false, + title: '3시간', + }, + ]} + /> + <UsageContainer> + <Caption>사용 용도를 선택해 주세요</Caption> + <UsageDiv> + <Usage + title="학습" + checked={selectedUsage === '학습'} + onClick={() => handleUsageClick('학습')} + /> + <Usage + title="회의" + checked={selectedUsage === '회의'} + onClick={() => handleUsageClick('회의')} + /> + <Usage + title="수업" + checked={selectedUsage === '수업'} + onClick={() => handleUsageClick('수업')} + /> + <Usage + title="기타" + checked={selectedUsage === '기타'} + onClick={() => handleUsageClick('기타')} + /> + </UsageDiv> + </UsageContainer> + </OptionSelectWrapper> + <CheckWrapper css={injectAnimation('fadeInTopDown', '0.5s', 'ease')}> + <CheckBoxDiv> + <div + onClick={() => { + setCheckBox((res) => !res); + }} + > + {checkBox ? <CheckedBox /> : <UnCheckedBox />} + </div> + <CheckBoxText>현재 예약 정보를 템플릿으로 추가하기</CheckBoxText> + </CheckBoxDiv> + <Caption>{`자주하는 예약의 경우 탬플릿으로 추가하면\n다음번에 간편하게 예약할 수 있어요`}</Caption> + </CheckWrapper> + <RoundButton + css={buttonStyle} + title="예약 가능 시간 탐색하기" + theme="primary" + disabled={checkedButton} + onClick={() => { + route.push({ + pathname: '/create/companions', + query: { time: time, useCase: selectedUsage }, + }); + }} + /> + </PageContainer> + ); }; export default Reserve; + +const pageStyle = css` + width: 100%; + ${flex('column', 'start', 'start', 3)}; + padding: 3rem 2.7rem; +`; + +const SubTitle = styled.div` + color: ${COLORS.grey0}; + ${TYPO.title3.Sb}; +`; + +const Caption = styled.div` + ${TYPO.text1.Reg}; + white-space: pre-line; + line-height: 155%; +`; + +const OptionSelectWrapper = styled.div` + width: 100%; + position: relative; + ${flex('column', 'start', 'start', 2.5)}; +`; + +const UsageContainer = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 1)}; +`; + +const UsageDiv = styled.div` + width: 100%; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + grid-column-gap: 0.6rem; + grid-row-gap: 0.6rem; +`; + +const CheckWrapper = styled.div` + width: 100%; + position: relative; + ${flex('column', 'start', 'start', 1)} +`; + +const CheckBoxDiv = styled.div` + width: 100%; + ${flex('row', 'start', 'center', 1)} +`; + +const CheckBoxText = styled.div` + color: ${COLORS.grey0}; + ${TYPO.title3.Sb}; +`; + +const buttonStyle = css` + margin-top: 5rem; +`; diff --git a/client/src/pages/create/template.tsx b/client/src/pages/create/template.tsx deleted file mode 100644 index 0b48331..0000000 --- a/client/src/pages/create/template.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useHeader } from '@/hooks'; -import { PageContainer } from '@/styles/tokens'; -import { useLayoutEffect } from 'react'; - -/** - * 템플릿 추가하기 페이지 - */ -const Template = () => { - const { setHeader } = useHeader(); - - useLayoutEffect(() => { - setHeader('템플릿 추가하기'); - }, []); - - return <PageContainer></PageContainer>; -}; - -export default Template; diff --git a/client/src/pages/create/timetable.tsx b/client/src/pages/create/timetable.tsx new file mode 100644 index 0000000..b9e7daf --- /dev/null +++ b/client/src/pages/create/timetable.tsx @@ -0,0 +1,342 @@ +import styled from '@emotion/styled'; +import { useRouter } from 'next/router'; +import { useState, useEffect } from 'react'; +import { + processAvailabilityData, + getTimeTable, + WeeklyData, + RoomData, +} from '@/components/Timetable/getTimeTable'; +import Schedule from '@/components/Timetable'; +import ReserveConfirmBottomModal from '@/components/BottomModal/ReserveConfirm'; +import LeftArrow from '@/assets/svg/leftArrow.svg'; +import RightArrow from '@/assets/svg/rightArrow.svg'; +import { RESERVE_TIME } from '@/constants/reserveTime'; +import { ROOM_USE_SECTION } from '@/constants/roomUseSection'; +import { SEMINA_AVAILABLE_PEOPLE } from '@/constants/seminaAvailablePeople'; +import { ReserveError } from '@/utils/types/ReserveError'; +import ConfirmModal from '@/components/Modal/Confrim'; +import { TYPO } from '@/styles/typo'; +import { COLORS } from '@/styles/colors'; +import { CompanionProps } from '@/utils/types/Companion'; +import Seo from '@/components/Seo'; +import { seos } from '@/assets/seos'; + +const Timetable = () => { + const isKeyOfReserveTime = ( + key: string, + ): key is keyof typeof RESERVE_TIME => { + return key in RESERVE_TIME; + }; + + const route = useRouter(); + const timeQuery = route.query.time as string; + const useCaseQuery = route.query.useCase as string; + const { companions } = route.query; + const [curCompanions, setCurCompanions] = useState<CompanionProps[]>([]); + const [personCount, setPersonCount] = useState<number>(0); + // URI 디코딩 후 JSON 파싱 + + const time = isKeyOfReserveTime(timeQuery) + ? RESERVE_TIME[timeQuery] + : undefined; + + const [isOpenSeminar, setIsOpenSeminar] = useState<boolean>(false); + const [isSelected, setIsSelected] = useState<boolean>(false); + const [roomData, setRoomData] = useState<RoomData>(); + const [isSuccess, setIsSuccess] = useState<boolean>(false); + const [isError, setIsError] = useState<ReserveError>({ + isError: false, + errorMessage: '', + }); + + const [curProcessDataIdx, setCurProcessDataIdx] = useState<number>(0); + + const [processData, setProcessData] = useState<WeeklyData[]>([]); + const [selectedSlots, setSelectedSlots] = useState<string[]>([]); + const [dates, setDates] = useState<string[]>([]); + + const [seminaRoom, setSeminaRoom] = useState<string[]>([]); + + async function getScheduleData(curSemina: string[]) { + const roomdata = await getTimeTable(); + if (roomdata === undefined) return; + setRoomData(roomdata as RoomData); + const pr = processAvailabilityData(roomdata, curSemina); + setProcessData(pr); + } + + const getDays = () => { + if ( + processData === null || + processData === undefined || + processData.length <= curProcessDataIdx + ) + return; + if (processData && processData[curProcessDataIdx]) { + const curDataKeys = Object.keys(processData[curProcessDataIdx]); + const newDates = curDataKeys.map((res) => { + const curdates = new Date(res); + return `${curdates.getDate()}`; + }); + newDates.unshift(''); + setDates(newDates); + } + }; + function getAvailableSeminarRooms( + data: RoomData, + date: string, + startTime: number, + endTime: number, + numberOfPeople: number, + isOpenSeminaRoom: boolean, + ): string[] { + const availableRooms = []; + console.log('data', data); + for (const [room, timeRanges] of Object.entries(data[date])) { + console.log('aaa', room, timeRanges); + // 세미나실이 예약되어 있지 않은 경우 + if (isOpenSeminaRoom && parseInt(room) < 10) { + continue; + } + if (!isOpenSeminaRoom && parseInt(room) > 10) { + continue; + } + + // 해당 세미나실의 사용 가능 인원 확인 + const availablePeople = SEMINA_AVAILABLE_PEOPLE[parseInt(room)]; + if (!availablePeople || !availablePeople.includes(numberOfPeople)) { + continue; + } + + if (timeRanges === null) { + availableRooms.push(room + '번'); + } else { + // 예약된 시간대와 겹치지 않는지 확인 + let isAvailable = true; + for (const [reservedStartTime, reservedEndTime] of timeRanges) { + console.log(timeRanges); + console.log(startTime, reservedStartTime, endTime, reservedEndTime); + if ( + (startTime >= reservedStartTime && startTime < reservedEndTime) || + (endTime > reservedStartTime && endTime <= reservedEndTime) || + (startTime <= reservedStartTime && endTime >= reservedEndTime) + ) { + isAvailable = false; + break; + } + } + if (isAvailable) { + availableRooms.push(room + '번'); + } + } + } + return availableRooms; + } + + useEffect(() => { + getDays(); + }, [processData, curProcessDataIdx]); + + useEffect(() => { + if (!curCompanions) return; + setPersonCount(curCompanions.length); + }, [curCompanions]); + + useEffect(() => { + getScheduleData([]); + }, [selectedSlots]); + + const handleReserveError = () => { + setIsError({ isError: false, errorMessage: '' }); + setIsSelected(false); + }; + + const handleReserveSuccess = () => { + route.replace('/schedule'); + setIsSuccess(false); + }; + + useEffect(() => { + if (roomData === undefined) return; + if (selectedSlots.length === 0) return; + const availableRooms = getAvailableSeminarRooms( + roomData, + selectedSlots[0].slice(0, 10), + parseInt(selectedSlots[0].split('-')[3].slice(0, 2)), + parseInt( + selectedSlots[selectedSlots.length - 1].split('-')[3].slice(0, 2), + ), + personCount + 1, + false, + ); + setSeminaRoom(availableRooms); + }, [roomData]); + + useEffect(() => { + if (companions) { + const decodedCompanions = decodeURIComponent(companions as string); + const parseCompanions = JSON.parse(decodedCompanions); + setCurCompanions(parseCompanions); + } + }, []); + + const clickRightArrowBtn = () => { + if (processData.length - 1 === curProcessDataIdx) return; + setCurProcessDataIdx((res) => res + 1); + }; + const clickLeftArrowBtn = () => { + if (processData.length === 0) return; + setCurProcessDataIdx((res) => res - 1); + }; + + return ( + <Container> + <Seo {...seos.reserve} /> + <HeaderDiv> + <HeaderEachDiv> + <HeaderDivBoldText>사용시간</HeaderDivBoldText> + <HeaderDivText>{time}분</HeaderDivText> + </HeaderEachDiv> + <HeaderEachDiv> + <HeaderDivBoldText>인원</HeaderDivBoldText> + <HeaderDivText>{personCount}명</HeaderDivText> + </HeaderEachDiv> + <HeaderEachDiv> + <HeaderDivBoldText>장소종류</HeaderDivBoldText> + <HeaderDivText>세미나실</HeaderDivText> + </HeaderEachDiv> + </HeaderDiv> + <Main> + <CenterBox> + <LeftBox> + {curProcessDataIdx === 0 ? ( + <div style={{ width: '30px' }}></div> + ) : ( + <ArrowBox> + <LeftArrow onClick={clickLeftArrowBtn} /> + </ArrowBox> + )} + </LeftBox> + <TableContainBox> + <Schedule + processData={processData} + isOpenSeminar={isOpenSeminar} + curProcessDataIdx={curProcessDataIdx} + isSelected={isSelected} + setIsSelected={setIsSelected} + selectedSlots={selectedSlots} + setSelectedSlots={setSelectedSlots} + curTime={time as string} + dates={dates} + /> + </TableContainBox> + <RightBox> + {processData.length === 0 || + curProcessDataIdx === processData.length - 1 ? ( + <div style={{ width: '30px' }}></div> + ) : ( + <ArrowBox> + <RightArrow onClick={clickRightArrowBtn} /> + </ArrowBox> + )} + </RightBox> + </CenterBox> + </Main> + {isSelected && !isSuccess && !isError.isError && ( + <ReserveConfirmBottomModal + type={ROOM_USE_SECTION[useCaseQuery]} + setIsSuccess={setIsSuccess} + setIsError={setIsError} + semina={seminaRoom} + setIsOpen={setIsSelected} + selectedSlots={selectedSlots} + companions={curCompanions} + /> + )} + {isSuccess && ( + <ConfirmModal + onClick={handleReserveSuccess} + title="예약이 완료되었습니다." + message="예약 정보는 스케줄 탭에서 확인하세요!" + /> + )} + {isError.isError && ( + <ConfirmModal + onClick={handleReserveError} + title="예약에 실패하였습니다." + message={isError.errorMessage} + /> + )} + </Container> + ); +}; + +export default Timetable; + +const Container = styled.div` + position: relative; +`; + +const Main = styled.main` + width: 100%; + padding: 1rem 0 0 0; + height: 100%; +`; + +const CenterBox = styled.div` + display: flex; + justify-content: center; + margin-top: 2.7rem; +`; + +const LeftBox = styled.div` + display: flex; + margin: auto 0.5rem auto 0; +`; + +const RightBox = styled.div` + display: flex; + margin: auto 0 auto 0.5rem; +`; + +const TableContainBox = styled.div` + display: flex; + justify-content: center; + align-items: center; + max-width: 36rem; +`; + +const ArrowBox = styled.div` + width: 30px; + display: flex; + justify-content: center; + align-items: center; +`; + +const HeaderDiv = styled.div` + width: 100%; + padding: 15px 36px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #ececec; + background: #feffff; +`; + +const HeaderEachDiv = styled.div` + display: flex; + /* justify-content: ; */ + align-items: center; +`; + +const HeaderDivBoldText = styled.div` + ${TYPO.text1.Lg} + ${COLORS.grey0} +`; + +const HeaderDivText = styled.div` + ${TYPO.text1.Lg} + color:#ccc; + margin-left: 10px; +`; diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx index 5b19e64..514c4ca 100644 --- a/client/src/pages/index.tsx +++ b/client/src/pages/index.tsx @@ -1,5 +1,67 @@ +import { seos } from '@/assets/seos'; +import { MateList, ReserveButtons, TemplateList } from '@/components/Home'; +import { Title } from '@/components/Layouts'; +import Seo from '@/components/Seo'; +import { useAuth, useMate } from '@/hooks'; +import { PageContainer, flex } from '@/styles/tokens'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + const Home = () => { - return <></>; + const { authInfo } = useAuth(); + const { mateList } = useMate(); + const configs = { + reserve: { + title: `${authInfo?.name || ''}님,\n지금 바로 예약해보세요.`, + subtitle: '세미나실과 개방형 세미나실을 간편하게 예약해보세요!', + animated: true, + }, + template: { + title: `템플릿으로\n간편하게 예약해요.`, + subtitle: '미리 템플릿을 만들어두면,\n빠르고 쉽게 다음 예약이 가능해요.', + animated: true, + }, + mate: { + title: '내 슈도비 메이트와 함께!', + subtitle: '항상 함께 가는 친구를 등록하고,\n빠르게 예약해보세요!', + animated: true, + }, + }; + + return ( + <PageContainer css={pageStyle}> + <Seo {...seos.index} /> + <TitleWrapper css={paddingStyle}> + <Title {...configs.reserve} /> + <ReserveButtons /> + </TitleWrapper> + <TitleWrapper> + <div css={paddingStyle}> + <Title {...configs.template} /> + </div> + <TemplateList /> + </TitleWrapper> + <TitleWrapper css={paddingStyle}> + <Title {...configs.mate} /> + <MateList mates={mateList.map((item) => item.info.name)} /> + </TitleWrapper> + </PageContainer> + ); }; +const pageStyle = css` + width: 100%; + ${flex('column', 'start', 'start', 6)}; + padding: 3rem 0rem; +`; + +const paddingStyle = css` + padding: 0rem 2.7rem; +`; + +const TitleWrapper = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 3)}; +`; + export default Home; diff --git a/client/src/pages/landing.tsx b/client/src/pages/landing.tsx index afb208a..5d91e3e 100644 --- a/client/src/pages/landing.tsx +++ b/client/src/pages/landing.tsx @@ -1,5 +1,7 @@ +import { seos } from '@/assets/seos'; import { RoundButton } from '@/components/Buttons'; import { Circle } from '@/components/Login'; +import Seo from '@/components/Seo'; import { useVh } from '@/hooks'; import { injectAnimation } from '@/styles/animations'; import { COLORS } from '@/styles/colors'; @@ -21,7 +23,10 @@ const Landing = () => { }; return ( - <Container css={fullPageStyle}> + <Container css={fullPageStyle()}> + <Seo {...seos.landing}> + <meta name="msapplication-TileColor" content="#1D9BF0"></meta> + </Seo> <TitleWrapper> <span css={TYPO.title1.Eb}>SSUDOBI</span> <span css={TYPO.title2.Reg}>숭실대학교 도서관 비대면 예약 시스템</span> diff --git a/client/src/pages/login.tsx b/client/src/pages/login.tsx index 687ef06..096c0ef 100644 --- a/client/src/pages/login.tsx +++ b/client/src/pages/login.tsx @@ -1,8 +1,10 @@ +import { seos } from '@/assets/seos'; import { RoundButton } from '@/components/Buttons'; import { TextInput } from '@/components/Field'; +import Seo from '@/components/Seo'; import { useAuth, useHeader, useInput, useVh } from '@/hooks'; import { COLORS } from '@/styles/colors'; -import { flex } from '@/styles/tokens'; +import { HEADER_HEIGHT, flex } from '@/styles/tokens'; import { TYPO } from '@/styles/typo'; import styled from '@emotion/styled'; import { useLayoutEffect } from 'react'; @@ -18,7 +20,7 @@ type FormData = { const Login = () => { const { setHeader } = useHeader(); const { fullPageStyle } = useVh(); - const { handleLogin } = useAuth(); + const { handleLogin, isWarn } = useAuth(); const { values, handleChange } = useInput<FormData>({ id: '', password: '' }); useLayoutEffect(() => { @@ -26,7 +28,8 @@ const Login = () => { }, []); return ( - <Container css={fullPageStyle}> + <Container css={fullPageStyle(HEADER_HEIGHT)}> + <Seo {...seos.login} /> <InputWrapper> <InputBox> <Caption>학번</Caption> @@ -35,6 +38,7 @@ const Login = () => { value={values.id} name="id" onChange={handleChange} + warning={isWarn} /> </InputBox> <InputBox> @@ -45,6 +49,8 @@ const Login = () => { value={values.password} name="password" onChange={handleChange} + warning={isWarn} + warningCaption="학번 또는 비밀번호가 일치하지 않습니다." /> </InputBox> </InputWrapper> @@ -62,7 +68,7 @@ const Login = () => { const Container = styled.div` width: 100%; - padding: 12rem 4.5rem; + padding: 9rem 4.5rem; ${flex('column', 'between', 'center', 5)}; `; diff --git a/client/src/pages/mate.tsx b/client/src/pages/mate.tsx index 8faf483..05e2399 100644 --- a/client/src/pages/mate.tsx +++ b/client/src/pages/mate.tsx @@ -1,5 +1,48 @@ +import { seos } from '@/assets/seos'; +import { Title } from '@/components/Layouts'; +import { MateManageKit } from '@/components/Mate'; +import Seo from '@/components/Seo'; +import { useAuth } from '@/hooks'; +import { injectAnimation } from '@/styles/animations'; +import { PageContainer, flex } from '@/styles/tokens'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + const Mate = () => { - return <></>; + const { authInfo } = useAuth(); + const config = { + title: `${authInfo?.name || ''}님,\n슈도비 메이트를 추가해보세요.`, + subtitle: '함께 공부하는 친구를 등록하고, 편하게 예약해요.', + animated: true, + }; + + return ( + <PageContainer css={pageStyle}> + <Seo {...seos.mate} /> + <TitleWrapper css={paddingStyle}> + <Title {...config} /> + </TitleWrapper> + <MateManageKit + kitType="removable" + css={injectAnimation('fadeInTopDown', '0.5s', 'ease')} + /> + </PageContainer> + ); }; +const pageStyle = css` + width: 100%; + ${flex('column', 'start', 'start', 6)}; + padding: 3rem 0rem; +`; + +const paddingStyle = css` + padding: 0rem 2.7rem; +`; + +const TitleWrapper = styled.div` + width: 100%; + ${flex('column', 'start', 'start', 3)}; +`; + export default Mate; diff --git a/client/src/pages/mypage.tsx b/client/src/pages/mypage.tsx index b02c7f6..b3ac545 100644 --- a/client/src/pages/mypage.tsx +++ b/client/src/pages/mypage.tsx @@ -1,18 +1,113 @@ -import { useHeader } from '@/hooks'; -import { PageContainer } from '@/styles/tokens'; -import { useLayoutEffect } from 'react'; +import { seos } from '@/assets/seos'; +import Modal from '@/components/Modal'; +import { MenuBox, ProfileBox } from '@/components/Profile'; +import Seo from '@/components/Seo'; +import { useAuth, useHeader, useTransition } from '@/hooks'; +import { COLORS } from '@/styles/colors'; +import { PageContainer, flex } from '@/styles/tokens'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { useLayoutEffect, useState } from 'react'; + +const config = { + title: '', + message: '', + onClick: () => {}, + handleClose: () => {}, +}; /** * 마이페이지 */ const Mypage = () => { const { setHeader } = useHeader(); + const { authInfo, handleLogout } = useAuth(); + const { handleOpen, handleClose, isTransition, isMount } = useTransition(300); + const [modalConfig, setModalCofig] = useState(config); + + const policyMenus = [ + { + title: '서비스 이용 약관', + onClick: () => {}, + }, + { + title: '개인정보 처리 방침', + onClick: () => {}, + }, + ]; + + const infoMenus = [ + { + title: '버전 정보', + onClick: () => {}, + }, + { + title: '개발자 정보', + onClick: () => {}, + }, + ]; + + const authMenus = [ + { + title: '문의하기', + onClick: () => {}, + }, + { + title: '로그아웃', + onClick: () => { + setModalCofig({ + title: '로그아웃 하시겠습니까?', + message: '언제든 다시 돌아와주세요!', + onClick: handleLogout, + handleClose, + }); + handleOpen(); + }, + }, + ]; useLayoutEffect(() => { setHeader('마이페이지'); }, []); - return <PageContainer></PageContainer>; + return ( + <PageContainer css={pageStyle}> + <Seo {...seos.mypage} /> + <ProfileBox + name={authInfo?.name || ''} + memberNo={authInfo?.sId || ''} + css={paddingStyle} + /> + <Line /> + {policyMenus.map(MenuBox)} + <Line /> + {infoMenus.map(MenuBox)} + <Line /> + {authMenus.map(MenuBox)} + {isMount && ( + <Modal + {...modalConfig} + modalType="decision" + isTransition={isTransition} + /> + )} + </PageContainer> + ); }; +const pageStyle = css` + width: 100%; + ${flex('column', 'start', 'start', 0)}; +`; + +const paddingStyle = css` + padding: 3rem; +`; + +const Line = styled.div` + width: 100%; + height: 1px; + background-color: ${COLORS.grey65}; +`; + export default Mypage; diff --git a/client/src/pages/schedule.tsx b/client/src/pages/schedule.tsx index 6ba8391..a950c6f 100644 --- a/client/src/pages/schedule.tsx +++ b/client/src/pages/schedule.tsx @@ -1,5 +1,14 @@ +import { seos } from '@/assets/seos'; +import ReservationCheck from '@/components/ReservationCheck'; +import Seo from '@/components/Seo'; + const Schedule = () => { - return <></>; + return ( + <> + <Seo {...seos.schedule} /> + <ReservationCheck /> + </> + ); }; export default Schedule; diff --git a/client/src/pages/template.tsx b/client/src/pages/template.tsx index 068f1a8..4a8d539 100644 --- a/client/src/pages/template.tsx +++ b/client/src/pages/template.tsx @@ -1,5 +1,21 @@ +import { seos } from '@/assets/seos'; +import AddTemplate from '@/components/AddTemplate'; +import Seo from '@/components/Seo'; +import { PageContainer } from '@/styles/tokens'; +import { css } from '@emotion/react'; + const Template = () => { - return <></>; + return ( + <PageContainer css={pageStyle}> + <Seo {...seos.template} /> + <AddTemplate /> + </PageContainer> + ); }; +const pageStyle = css` + width: 100%; + padding: 3rem 0rem; +`; + export default Template; diff --git a/client/src/pages/template/1.tsx b/client/src/pages/template/1.tsx new file mode 100644 index 0000000..120c615 --- /dev/null +++ b/client/src/pages/template/1.tsx @@ -0,0 +1,14 @@ +import { seos } from '@/assets/seos'; +import NameTimeType from '@/components/AddTemplate/NameTimeType'; +import Seo from '@/components/Seo'; + +const First = () => { + return ( + <> + <Seo {...seos.template} /> + <NameTimeType /> + </> + ); +}; + +export default First; diff --git a/client/src/pages/template/2.tsx b/client/src/pages/template/2.tsx new file mode 100644 index 0000000..82dbc93 --- /dev/null +++ b/client/src/pages/template/2.tsx @@ -0,0 +1,14 @@ +import { seos } from '@/assets/seos'; +import CompanionsSelect from '@/components/AddTemplate/CompanionsSelect'; +import Seo from '@/components/Seo'; + +const Second = () => { + return ( + <> + <Seo {...seos.template} /> + <CompanionsSelect /> + </> + ); +}; + +export default Second; diff --git a/client/src/pages/template/3.tsx b/client/src/pages/template/3.tsx new file mode 100644 index 0000000..e35da2e --- /dev/null +++ b/client/src/pages/template/3.tsx @@ -0,0 +1,14 @@ +import { seos } from '@/assets/seos'; +import TemplateTimeTable from '@/components/AddTemplate/TemplateTimeTable'; +import Seo from '@/components/Seo'; + +const Third = () => { + return ( + <> + <Seo {...seos.template} /> + <TemplateTimeTable /> + </> + ); +}; + +export default Third; diff --git a/client/src/styles/animations.tsx b/client/src/styles/animations.tsx index 60e64c4..0c719e3 100644 --- a/client/src/styles/animations.tsx +++ b/client/src/styles/animations.tsx @@ -71,6 +71,29 @@ const circleMoving = { `, }; +const usageMoving = { + top: keyframes` + 0%{ + opacity: 0; + ${transform('translate(0%, 20%) rotate(-90deg)')}; + } + 100%{ + opacity: 1; + ${transform('translate(0%, 0%) rotate(-90deg)')}; + } + `, + bottom: keyframes` + 0%{ + opacity: 0; + ${transform('translate(0%, -20%) rotate(90deg)')}; + } + 100%{ + opacity: 1; + ${transform('translate(0%, 0%) rotate(90deg)')}; + } + `, +}; + const loginTitlePopup = keyframes` 0%{ opacity: 0; @@ -100,6 +123,68 @@ const loginButtonPopup = keyframes` } `; +const toastAnimations = { + open: keyframes` + 0%{ + opacity: 0; + ${transform('translate(-50%, -20%)')}; + } + 100%{ + opacity: 1; + ${transform('translate(-50%, 0%)')}; + } + `, + close: keyframes` + 0%{ + opacity: 1; + ${transform('translate(-50%, 0%)')}; + } + 100%{ + opacity: 0; + ${transform('translate(-50%, -20%)')}; + } + `, +}; + +const modalAnimations = { + appearBg: keyframes` + 0%{ + opacity: 0; + } + 100%{ + opacity: 1; + } + `, + disappearBg: keyframes` + 0%{ + opacity: 1; + } + 100%{ + opacity: 0; + } + `, + appearModal: keyframes` + 0%{ + opacity: 0; + ${transform('translate(0px, 1rem)')} + } + 100%{ + opacity: 1; + ${transform('translate(0px, 0rem)')} + } + `, + disappearModal: keyframes` + 0%{ + opacity: 1; + ${transform('translate(0px, 0rem)')} + } + 100%{ + opacity: 0; + ${transform('translate(0px, 1rem)')} + } + `, +}; + const animations = { fadeInTopDown, fadeInTopDownTranslate, @@ -108,6 +193,14 @@ const animations = { circleMovingBottom: circleMoving.bottom, loginTitlePopup, loginButtonPopup, + toastOpen: toastAnimations.open, + toastClose: toastAnimations.close, + usageMovingTop: usageMoving.top, + usageMovingBottom: usageMoving.bottom, + modalBackgroundAppear: modalAnimations.appearBg, + modalBackgroundDisappear: modalAnimations.disappearBg, + modalAppear: modalAnimations.appearModal, + modalDisappear: modalAnimations.disappearModal, }; export const injectAnimation = ( diff --git a/client/src/styles/colors.tsx b/client/src/styles/colors.tsx index 62c6dee..ac2d073 100644 --- a/client/src/styles/colors.tsx +++ b/client/src/styles/colors.tsx @@ -5,14 +5,19 @@ export const COLORS = { grey0: '#0C0C0C', grey1: '#161616', grey2: '#444444', + grey25: '#6D6A64', grey3: '#999999', grey4: '#CCCCCC', + grey45: '#E1E1E1', grey5: '#D7D7D7', + grey55: '#F7F7F7', grey6: '#F3F5F8', + grey65: '#F2F2F2', grey7: '#F8FAFC', grey8: '#F5F7FA', white: '#FEFFFF', primaryWhite: '#fcfdff', primaryWeak: '#D7EAFC', + primaryDeep: '#1a80c5', disabled: '#B5B5B5', }; diff --git a/client/src/styles/tokens.tsx b/client/src/styles/tokens.tsx index 5a150e0..5782b19 100644 --- a/client/src/styles/tokens.tsx +++ b/client/src/styles/tokens.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/react'; import styled from '@emotion/styled'; +import { mq } from './breakpoints'; type Direction = 'row' | 'column'; type JustifyContent = @@ -59,7 +60,7 @@ export const transform = (transformVal: string) => { export const transition = (duration: string, animationType = 'linear') => { return css` -o-transition: all ${duration} ${animationType}; - -webkit-transition: -webkit-transform ${duration}; + -webkit-transition: all ${duration}; -ms-transition: all ${duration}; -moz-transition: all ${duration}; transition: all ${duration}; @@ -68,5 +69,23 @@ export const transition = (duration: string, animationType = 'linear') => { export const PageContainer = styled.div` width: 100%; - padding: 1rem 2.7rem; + min-height: 100%; `; + +/** 공통 헤더 높이 */ +export const HEADER_HEIGHT = 6; +/** 공통 바텀 네비게이션바 높이 */ +export const BOTTOM_HEIGHT = 7; + +export const containerStyle = { + navigator: css` + padding-top: ${HEADER_HEIGHT}rem; + padding-bottom: ${BOTTOM_HEIGHT}rem; + `, + header: css` + padding-top: ${HEADER_HEIGHT}rem; + `, + skinight: css` + padding: 0rem; + `, +}; diff --git a/client/src/utils/EmptyDate.ts b/client/src/utils/EmptyDate.ts new file mode 100644 index 0000000..b90987a --- /dev/null +++ b/client/src/utils/EmptyDate.ts @@ -0,0 +1,148 @@ +export const EmptyDate = [ + { + '2023-01-00': { + '1000': true, + '1030': true, + '1100': true, + '1130': true, + '1200': true, + '1230': true, + '1300': true, + '1330': true, + '1400': true, + '1430': true, + '1500': true, + '1530': true, + '1600': true, + '1630': true, + '1700': true, + '1730': true, + '1800': true, + '1830': true, + '1900': true, + '1930': true, + '2000': true, + '2030': true, + }, + '2023-02-00': { + '1000': true, + '1030': true, + '1100': true, + '1130': true, + '1200': true, + '1230': true, + '1300': true, + '1330': true, + '1400': true, + '1430': true, + '1500': true, + '1530': true, + '1600': true, + '1630': true, + '1700': true, + '1730': true, + '1800': true, + '1830': true, + '1900': true, + '1930': true, + '2000': true, + '2030': true, + }, + '2023-03-00': { + '1000': true, + '1030': true, + '1100': true, + '1130': true, + '1200': true, + '1230': true, + '1300': true, + '1330': true, + '1400': true, + '1430': true, + '1500': true, + '1530': true, + '1600': true, + '1630': true, + '1700': true, + '1730': true, + '1800': true, + '1830': true, + '1900': true, + '1930': true, + '2000': true, + '2030': true, + }, + '2023-04-00': { + '1000': true, + '1030': true, + '1100': true, + '1130': true, + '1200': true, + '1230': true, + '1300': true, + '1330': true, + '1400': true, + '1430': true, + '1500': true, + '1530': true, + '1600': true, + '1630': true, + '1700': true, + '1730': true, + '1800': true, + '1830': true, + '1900': true, + '1930': true, + '2000': true, + '2030': true, + }, + '2023-05-00': { + '1000': true, + '1030': true, + '1100': true, + '1130': true, + '1200': true, + '1230': true, + '1300': true, + '1330': true, + '1400': true, + '1430': true, + '1500': true, + '1530': true, + '1600': true, + '1630': true, + '1700': true, + '1730': true, + '1800': true, + '1830': true, + '1900': true, + '1930': true, + '2000': true, + '2030': true, + }, + '2023-06-00': { + '1000': true, + '1030': true, + '1100': true, + '1130': true, + '1200': true, + '1230': true, + '1300': true, + '1330': true, + '1400': true, + '1430': true, + '1500': true, + '1530': true, + '1600': true, + '1630': true, + '1700': true, + '1730': true, + '1800': true, + '1830': true, + '1900': true, + '1930': true, + '2000': true, + '2030': true, + }, + }, +]; diff --git a/client/src/utils/func/calculateEndTimeWithMinutes.tsx b/client/src/utils/func/calculateEndTimeWithMinutes.tsx new file mode 100644 index 0000000..9a125cd --- /dev/null +++ b/client/src/utils/func/calculateEndTimeWithMinutes.tsx @@ -0,0 +1,12 @@ +export const calculateEndTimeWithMinutes = (time: string, minutes: number) => { + const hours = parseInt(time.slice(0, 2)); + const originalMinutes = parseInt(time.slice(2)); + const totalMinutes = hours * 60 + originalMinutes + minutes; + const newHours = Math.floor(totalMinutes / 60); + const newMinutes = totalMinutes % 60; + + return `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart( + 2, + '0', + )}`; +}; diff --git a/client/src/utils/func/getDayOfWeek.ts b/client/src/utils/func/getDayOfWeek.ts new file mode 100644 index 0000000..a735d77 --- /dev/null +++ b/client/src/utils/func/getDayOfWeek.ts @@ -0,0 +1,14 @@ +export function getDayOfWeek(dateString: string): string { + const daysOfWeek = [ + '일요일', + '월요일', + '화요일', + '수요일', + '목요일', + '금요일', + '토요일', + ]; + const date = new Date(dateString.split(' ')[0]); + const dayOfWeek = date.getDay(); + return daysOfWeek[dayOfWeek]; +} diff --git a/client/src/utils/func/templateTimeConverter.ts b/client/src/utils/func/templateTimeConverter.ts new file mode 100644 index 0000000..bf81375 --- /dev/null +++ b/client/src/utils/func/templateTimeConverter.ts @@ -0,0 +1,109 @@ +import { WeekdayShort } from 'Template'; + +/** + * 템플릿 요일과, 시작 끝 시간을 넣으면 UI 문자열 반환 + */ +export const formatSchedule = ( + day: WeekdayShort, + startTime: string, + endTime: string, +): string => { + const daysMap: Record<WeekdayShort, string> = { + Sun: '일요일', + Mon: '월요일', + Tue: '화요일', + Wed: '수요일', + Thu: '목요일', + Fri: '금요일', + Sat: '토요일', + }; + + const startHour = parseInt(startTime.split(':')[0]); + const startMinute = startTime.split(':')[1]; + + const endHour = parseInt(endTime.split(':')[0]); + const endMinute = endTime.split(':')[1]; + + return `${daysMap[day]} ${startHour}시 ${startMinute}분 - ${endHour}시 ${endMinute}분`; +}; + +const getNextWeekdayDate = ( + day: WeekdayShort, + currentTime: Date = new Date(), +): Date => { + const dayOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf( + day, + ); + let date = new Date(currentTime.getTime()); + date.setDate(date.getDate() + ((7 - date.getDay() + dayOfWeek) % 7 || 7)); + if (date <= currentTime) { + // 이미 해당 요일이 지났다면 다음 주로 설정 + date.setDate(date.getDate() + 7); + } + return date; +}; + +/** + * 템플릿에 저장된 요일 시작시간, 끝시간 넣으면 예약할 때 사용할 수 있는 시간 반환 + */ +export const formatNextOccurrence = ( + day: WeekdayShort, + startDate: string, + endDate: string, +): { beginTime: string; endTime: string } => { + const nextDate = getNextWeekdayDate(day); + const year = nextDate.getFullYear(); + const month = nextDate.getMonth() + 1; + const date = nextDate.getDate(); + + const beginTime = `${year}-${month.toString().padStart(2, '0')}-${date + .toString() + .padStart(2, '0')} ${startDate}`; + const endTime = `${year}-${month.toString().padStart(2, '0')}-${date + .toString() + .padStart(2, '0')} ${endDate}`; + + return { beginTime, endTime }; +}; + +export const formatDateRange = (dateStart: string, dateEnd: string): string => { + const startDate = new Date(dateStart); + const endDate = new Date(dateEnd); + + const daysOfWeek = ['일', '월', '화', '수', '목', '금', '토']; + + const startYear = startDate.getFullYear(); + const startMonth = startDate.getMonth() + 1; + const startDay = startDate.getDate(); + const startDayOfWeek = daysOfWeek[startDate.getDay()]; + const startHour = startDate.getHours(); + const startMinute = startDate.getMinutes(); + + const endHour = endDate.getHours(); + const endMinute = endDate.getMinutes(); + + const startTime = `${startHour.toString().padStart(2, '0')}시 ${startMinute + .toString() + .padStart(2, '0')}분`; + const endTime = `${endHour.toString().padStart(2, '0')}시 ${endMinute + .toString() + .padStart(2, '0')}분`; + + const formattedString = `${startYear}년 ${startMonth}월 ${startDay}일 ${startDayOfWeek}요일`; + const timeRange = `${startTime} - ${endTime}`; + + return `${formattedString} ${timeRange}`; +}; + +export const formatOnlyDate = (date: string) => { + const startDate = new Date(date); + const year = startDate.getFullYear(); + const month = startDate.getMonth() + 1; + const day = startDate.getDate(); + + const beginTime = `${year}-${month.toString().padStart(2, '0')}-${day + .toString() + .padStart(2, '0')}`; + + return beginTime; +}; diff --git a/client/src/utils/lib/tokenHandler.ts b/client/src/utils/lib/tokenHandler.ts index c26034d..037f922 100644 --- a/client/src/utils/lib/tokenHandler.ts +++ b/client/src/utils/lib/tokenHandler.ts @@ -2,7 +2,7 @@ * 액세스 토큰 가져오기 */ export const getAccessToken = () => { - return localStorage.getItem('ACESS_TOKEN'); + return localStorage.getItem('ACCESS_TOKEN') || undefined; }; /** @@ -16,5 +16,5 @@ export const updateAccessToken = (token: string) => { * 토큰 삭제 (로그아웃) */ export const removeAccessToken = () => { - localStorage.removeItem('ACESS_TOKEN'); + localStorage.removeItem('ACCESS_TOKEN'); }; diff --git a/client/src/utils/types/Companion.ts b/client/src/utils/types/Companion.ts new file mode 100644 index 0000000..b128842 --- /dev/null +++ b/client/src/utils/types/Companion.ts @@ -0,0 +1,6 @@ +export interface CompanionProps { + name: string; + memberNo: string; + id: string; + alternativeId: string; +} diff --git a/client/src/utils/types/ReserveError.ts b/client/src/utils/types/ReserveError.ts new file mode 100644 index 0000000..1282473 --- /dev/null +++ b/client/src/utils/types/ReserveError.ts @@ -0,0 +1,4 @@ +export interface ReserveError { + isError: boolean; + errorMessage: string; +} diff --git a/client/tsconfig.json b/client/tsconfig.json index 630ce47..645ca9d 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -4,7 +4,7 @@ "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, - "strict": true, + "strict": false, "noEmit": true, "esModuleInterop": true, "module": "esnext",