diff --git a/src/App.tsx b/src/App.tsx index 75bbc5999..8420c735a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,18 @@ +/* eslint-disable */ import { useEffect, ReactNode, Suspense } from 'react'; import { Routes, Route, useLocation, + Navigate, } from 'react-router-dom'; import AuthPage from 'pages/Auth/AuthPage'; import LoginPage from 'pages/Auth/LoginPage'; import BoardPage from 'pages/BoardPage'; import StorePage from 'pages/Store/StorePage'; -import NoticePage from 'pages/Notice/NoticePage'; -import NoticeListPage from 'pages/Notice/NoticeListPage'; -import NoticeDetailPage from 'pages/Notice/NoticeDetailPage'; +import ArticlesPage from 'pages/Articles/ArticlesPage'; +import ArticleListPage from 'pages/Articles/ArticleListPage'; +import ArticlesDetailPage from 'pages/Articles/ArticlesDetailPage'; import Toast from 'components/common/Toast'; import LogPage from 'components/common/LogPage'; import SignupPage from 'pages/Auth/SignupPage'; @@ -27,7 +29,6 @@ import TimetablePage from 'pages/TimetablePage/MainTimetablePage'; import CafeteriaPage from 'pages/Cafeteria'; import MetaHelmet from 'components/common/MetaHelmet'; import ModifyInfoPage from 'pages/Auth/ModifyInfoPage'; -import PrivateRoute from 'components/common/PrivateRoute'; import AddReviewPage from 'pages/StoreReviewPage/AddReviewPage'; import EditReviewPage from 'pages/StoreReviewPage/EditReviewPage'; import ReviewReportingPage from 'pages/Store/StoreDetailPage/Review/components/ReviewReporting'; @@ -35,19 +36,32 @@ import ModifyTimetablePage from 'pages/TimetablePage/ModifyTimetablePage'; import PageNotFound from 'pages/Error/PageNotFound'; import PolicyPage from 'pages/PolicyPage'; import ROUTES from 'static/routes'; +import LostItemWritePage from 'pages/Articles/LostItemWritePage'; +import LostItemDetailPage from 'pages/Articles/LostItemDetailPage'; +import useTokenState from 'utils/hooks/state/useTokenState'; -interface HelmetWrapperProps { +interface WrapperProps { title: string; element: ReactNode; + needAuth?: boolean; } -function HelmetWrapper({ title, element }: HelmetWrapperProps) { +function Wrapper({ + title, + element, + needAuth = false, // 로그인이 필요한 라우트 +}: WrapperProps) { const location = useLocation(); + const token = useTokenState(); useEffect(() => { - document.title = title; + document.title = `코인 - ${title}`; }, [title, location]); + if (needAuth && !token) { + return ; + } + return ( <> @@ -64,47 +78,43 @@ function App() { <> }> - } />} /> - } />} /> - } />} /> - } />} /> - } />} /> - } />} /> - } />} /> - } />} /> - } />} /> - } />} /> - } />} /> - } />}> - } /> - } />} /> + } />} /> + } />} /> + + } />} /> + } />} /> + } />} /> + + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> + + } />} /> + } />} /> + } />} /> + } />}> + } /> + } />} /> + } />} /> - } />} /> - } />} /> - } />} /> + } />} /> + } />} /> + } />} /> - } />} - > - } />} /> - } />} /> - } />} /> - - }> - } />} />} /> - } />} />} /> - } />} />} /> - }> - } />} />} /> + } />} /> + } />} /> + } />} /> + } />} /> - - } />} /> - - } />} /> + } />} /> diff --git a/src/api/articles/APIDetail.ts b/src/api/articles/APIDetail.ts new file mode 100644 index 000000000..69d34e030 --- /dev/null +++ b/src/api/articles/APIDetail.ts @@ -0,0 +1,89 @@ +import { APIRequest, HTTP_METHOD } from 'interfaces/APIRequest'; +import { + ArticlesResponse, + ArticleResponse, + HotArticlesResponse, + SingleLostItemArticleResponseDTO, + LostItemArticlesResponseDTO, + LostItemResponse, + LostItemArticlesRequestDTO, +} from './entity'; + +export class GetArticles implements APIRequest { + method = HTTP_METHOD.GET; + + path: string; + + response!: R; + + constructor(page: string | undefined) { + this.path = `/articles?page=${page}&limit=10`; + } +} + +export class GetArticle implements APIRequest { + method = HTTP_METHOD.GET; + + path: string; + + response!: R; + + constructor(id: string | undefined) { + this.path = `/articles/${id}`; + } +} + +export class GetHotArticles implements APIRequest { + method = HTTP_METHOD.GET; + + path = '/articles/hot'; + + response!: R; +} + +export class GetLostItemArticles implements APIRequest { + method = HTTP_METHOD.GET; + + path = '/articles/lost-item'; + + response!: R; +} + +export class GetSingleLostItemArticle implements APIRequest { + method = HTTP_METHOD.GET; + + path: string; + + response!: R; + + constructor(id: number) { + this.path = `/articles/lost-item/${id}`; + } +} + +export class PostLostItemArticles implements APIRequest { + method = HTTP_METHOD.POST; + + path = '/articles/lost-item'; + + response!: R; + + auth = true; + + constructor(public authorization: string, public data: LostItemArticlesRequestDTO) { } +} + +export class DeleteLostItemArticle implements APIRequest { + method = HTTP_METHOD.DELETE; + + path: string; + + response!: R; + + auth = true; + + constructor(public authorization: string, id: number) { + this.path = `/articles/lost-item/${id}`; + } +} diff --git a/src/api/articles/entity.ts b/src/api/articles/entity.ts new file mode 100644 index 000000000..a1fe8f8cd --- /dev/null +++ b/src/api/articles/entity.ts @@ -0,0 +1,118 @@ +import { APIResponse } from 'interfaces/APIResponse'; + +export interface GetArticlesRequest { + boardId: string + page: string +} + +export interface Article { + id: number + board_id: number + title: string + author: string + hit: number + registered_at: string // yyyy-MM-dd 아우누리에 게시판에 등록된 날짜 + updated_at: string // yyyy-MM-dd HH:mm:ss 이하 형식 동일 +} + +export interface Attachment { + id: 1, + name: string, + url: string, + created_at: string, + updated_at: string, +} + +export interface PaginationInfo { + total_count: number + current_count: number + total_page: number + current_page: number +} + +export interface PaginatedResponse extends PaginationInfo, APIResponse { + articles: T[]; +} + +export type ArticlesResponse = PaginatedResponse
; +export type ArticlesSearchResponse = PaginatedResponse
; + +export interface ArticleResponse extends Article, APIResponse { + content: string; + attachments: Attachment[]; + prev_id: number; + next_id: number; +} + +export interface HotArticle extends Article { } + +export type HotArticlesResponse = HotArticle[]; + +// GET /articles/lost-item +interface LostItemArticleForGetDTO { + id: number; + board_id: number; + category: string; + found_place: string; + found_date: string; + content: string; + author: string; + registered_at: string; + updated_at: string; +} + +export interface LostItemArticlesResponseDTO extends APIResponse { + articles: LostItemArticleForGetDTO[]; + total_count: number; + current_count: number; + total_page: number; + current_page: number; +} + +interface ImageDTO { + id: number; + image_url: string; +} + +export interface SingleLostItemArticleResponseDTO extends APIResponse { + id: number; + board_id: number; + category: string; + found_place: string; + found_date: string; + content: string; + author: string; + images: ImageDTO[]; + prev_id: number | null; + next_id: number | null; + registered_at: string; // yyyy-MM-dd + updated_at: string; // yyyy-MM-dd HH:mm:ss +} + +export interface LostItemResponse extends APIResponse { + id: number; + board_id: number; + category: string; + found_place: string; + found_date: string; + content: string; + author: string; + images: ImageDTO[]; + prev_id: number | null; + next_id: number | null; + registered_at: string; + updated_at: string; +} + +// POST /articles/lost-item +interface LostItemArticleForPostDTO { + category: string; + found_place: string; + found_date: string; // yyyy-MM-dd + content: string; + images: string[]; +} + +export interface LostItemArticlesRequestDTO { + articles: Array; +} diff --git a/src/api/articles/index.ts b/src/api/articles/index.ts new file mode 100644 index 000000000..b51eee672 --- /dev/null +++ b/src/api/articles/index.ts @@ -0,0 +1,24 @@ +import APIClient from 'utils/ts/apiClient'; +import { + GetArticles, + GetHotArticles, + GetArticle, + GetLostItemArticles, + GetSingleLostItemArticle, + DeleteLostItemArticle, + PostLostItemArticles, +} from './APIDetail'; + +export const getArticles = APIClient.of(GetArticles); + +export const getArticle = APIClient.of(GetArticle); + +export const getHotArticles = APIClient.of(GetHotArticles); + +export const getLostItemArticles = APIClient.of(GetLostItemArticles); + +export const getSingleLostItemArticle = APIClient.of(GetSingleLostItemArticle); + +export const postLostItemArticle = APIClient.of(PostLostItemArticles); + +export const deleteLostItemArticle = APIClient.of(DeleteLostItemArticle); diff --git a/src/api/index.ts b/src/api/index.ts index fa99db9f9..f4727eefd 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,11 +1,12 @@ +export * as abTest from './abTest'; export * as auth from './auth'; -export * as store from './store'; -export * as dept from './dept'; -export * as timetable from './timetable'; export * as bus from './bus'; -export * as notice from './notice'; -export * as room from './room'; export * as cafeteria from './cafeteria'; export * as coopshop from './coopshop'; +export * as dept from './dept'; +export * as articles from './articles'; export * as review from './review'; -export * as abTest from './abTest'; +export * as room from './room'; +export * as store from './store'; +export * as timetable from './timetable'; +export * as uploadFile from './uploadFile'; diff --git a/src/api/notice/APIDetail.ts b/src/api/notice/APIDetail.ts deleted file mode 100644 index a1db5dc38..000000000 --- a/src/api/notice/APIDetail.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { APIRequest, HTTP_METHOD } from 'interfaces/APIRequest'; -import { - ArticlesResponse, - ArticleResponse, - HotArticlesResponse, -} from './entity'; - -const BOARD_IDS = { - 자유게시판: 1, - 취업게시판: 2, - 익명게시판: 3, - 공지사항: 4, // 전체 공지 조회 시 사용 - 일반공지: 5, - 장학공지: 6, - 학사공지: 7, - 취업공지: 8, - 코인공지: 9, - 질문게시판: 10, - 홍보게시판: 11, - 현장실습: 12, - 학생생활: 13, -} as const; - -export class GetArticles implements APIRequest { - method = HTTP_METHOD.GET; - - path: string; - - response!: R; - - constructor(page: string | undefined) { - this.path = `/articles?boardId=${BOARD_IDS.공지사항}&page=${page}&limit=10`; - } -} - -export class GetArticle implements APIRequest { - method = HTTP_METHOD.GET; - - path: string; - - response!: R; - - constructor(id: string | undefined) { - this.path = `/articles/${id}`; - } -} - -export class GetHotArticles implements APIRequest { - method = HTTP_METHOD.GET; - - path = '/articles/hot'; - - response!: R; -} diff --git a/src/api/notice/entity.ts b/src/api/notice/entity.ts deleted file mode 100644 index 2a019459d..000000000 --- a/src/api/notice/entity.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { APIResponse } from 'interfaces/APIResponse'; - -export interface GetNoticeRequest { - boardId: string - page: string -} - -export interface Article { - id: number - board_id: number - title: string - author: string - hit: number - registered_at: string // yyyy-MM-dd 아우누리에 게시판에 등록된 날짜 - updated_at: string // yyyy-MM-dd HH:mm:ss 이하 형식 동일 -} - -export interface Attachment { - id: 1, - name: string, - url: string, - created_at: string, - updated_at: string, -} - -export interface PaginationInfo { - total_count: number - current_count: number - total_page: number - current_page: number -} - -export interface PaginatedResponse extends PaginationInfo, APIResponse { - articles: T[]; -} - -export type ArticlesResponse = PaginatedResponse
; -export type ArticlesSearchResponse = PaginatedResponse
; - -export interface ArticleResponse extends Article, APIResponse { - content: string; - attachments: Attachment[]; - prev_id: number; - next_id: number; -} - -export interface HotArticle extends Article { } - -export type HotArticlesResponse = HotArticle[]; diff --git a/src/api/notice/index.ts b/src/api/notice/index.ts deleted file mode 100644 index 1ea30c54c..000000000 --- a/src/api/notice/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import APIClient from 'utils/ts/apiClient'; -import { - GetArticles, - GetHotArticles, - GetArticle, -} from './APIDetail'; - -export const getArticles = APIClient.of(GetArticles); - -export const getArticle = APIClient.of(GetArticle); - -export const getHotArticles = APIClient.of(GetHotArticles); diff --git a/src/api/review/APIDetail.ts b/src/api/review/APIDetail.ts index cdabc36f3..c180d4df5 100644 --- a/src/api/review/APIDetail.ts +++ b/src/api/review/APIDetail.ts @@ -1,5 +1,5 @@ import { APIRequest, HTTP_METHOD } from 'interfaces/APIRequest'; -import { ReviewRequest, ReviewResponse, UploadImage } from './entity'; +import { ReviewRequest, ReviewResponse } from './entity'; export class GetStoreReview implements APIRequest { method = HTTP_METHOD.GET; @@ -48,19 +48,3 @@ export class EditStoreReview implements APIRequest { this.data = data; } } - -export class UploadFile implements APIRequest { - method = HTTP_METHOD.POST; - - response!: R; - - path = 'SHOPS/upload/file'; - - auth = true; - - data: FormData; - - constructor(public authorization: string, formData: FormData) { - this.data = formData; - } -} diff --git a/src/api/review/entity.ts b/src/api/review/entity.ts index 7ada414c2..582258356 100644 --- a/src/api/review/entity.ts +++ b/src/api/review/entity.ts @@ -11,7 +11,3 @@ export interface ReviewRequest extends APIResponse { image_urls: string[]; menu_names: string[]; } - -export interface UploadImage extends APIResponse { - file_url: string; -} diff --git a/src/api/review/index.ts b/src/api/review/index.ts index 0f7243001..5ece07d5c 100644 --- a/src/api/review/index.ts +++ b/src/api/review/index.ts @@ -1,12 +1,8 @@ import APIClient from 'utils/ts/apiClient'; -import { - GetStoreReview, AddStoreReview, EditStoreReview, UploadFile, -} from './APIDetail'; +import { GetStoreReview, AddStoreReview, EditStoreReview } from './APIDetail'; export const getStoreReview = APIClient.of(GetStoreReview); export const postStoreReview = APIClient.of(AddStoreReview); export const putStoreReview = APIClient.of(EditStoreReview); - -export const uploadFile = APIClient.of(UploadFile); diff --git a/src/api/uploadFile/APIDetail.ts b/src/api/uploadFile/APIDetail.ts new file mode 100644 index 000000000..128db69be --- /dev/null +++ b/src/api/uploadFile/APIDetail.ts @@ -0,0 +1,32 @@ +import { APIRequest, HTTP_METHOD } from 'interfaces/APIRequest'; +import { UploadImage } from './entity'; + +export class BaseUploadFile implements APIRequest { + method = HTTP_METHOD.POST; + + response!: R; + + auth = true; + + data: FormData; + + constructor( + public authorization: string, + formData: FormData, + public path: string, + ) { + this.data = formData; + } +} + +export class ShopUploadFile extends BaseUploadFile { + constructor(authorization: string, formData: FormData) { + super(authorization, formData, 'SHOPS/upload/file'); + } +} + +export class LostItemUploadFile extends BaseUploadFile { + constructor(authorization: string, formData: FormData) { + super(authorization, formData, 'LOST_ITEMS/upload/file'); + } +} diff --git a/src/api/uploadFile/entity.ts b/src/api/uploadFile/entity.ts new file mode 100644 index 000000000..ec7e86f4f --- /dev/null +++ b/src/api/uploadFile/entity.ts @@ -0,0 +1,5 @@ +import { APIResponse } from 'interfaces/APIResponse'; + +export interface UploadImage extends APIResponse { + file_url: string; +} diff --git a/src/api/uploadFile/index.ts b/src/api/uploadFile/index.ts new file mode 100644 index 000000000..b18b6cb01 --- /dev/null +++ b/src/api/uploadFile/index.ts @@ -0,0 +1,5 @@ +import APIClient from 'utils/ts/apiClient'; +import { ShopUploadFile, LostItemUploadFile } from './APIDetail'; + +export const uploadShopFile = APIClient.of(ShopUploadFile); +export const uploadLostItemFile = APIClient.of(LostItemUploadFile); diff --git a/src/assets/svg/Articles/add.svg b/src/assets/svg/Articles/add.svg new file mode 100644 index 000000000..1bf151eea --- /dev/null +++ b/src/assets/svg/Articles/add.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/svg/Articles/chevron-down.svg b/src/assets/svg/Articles/chevron-down.svg new file mode 100644 index 000000000..81a1eb0d4 --- /dev/null +++ b/src/assets/svg/Articles/chevron-down.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/svg/Articles/chevron-left-circle.svg b/src/assets/svg/Articles/chevron-left-circle.svg new file mode 100644 index 000000000..a2b46082d --- /dev/null +++ b/src/assets/svg/Articles/chevron-left-circle.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/svg/Articles/chevron-left.svg b/src/assets/svg/Articles/chevron-left.svg new file mode 100644 index 000000000..79a541114 --- /dev/null +++ b/src/assets/svg/Articles/chevron-left.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/svg/Articles/chevron-right-circle.svg b/src/assets/svg/Articles/chevron-right-circle.svg new file mode 100644 index 000000000..ae2ea66c1 --- /dev/null +++ b/src/assets/svg/Articles/chevron-right-circle.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/svg/Articles/chevron-right.svg b/src/assets/svg/Articles/chevron-right.svg new file mode 100644 index 000000000..28c8e13cf --- /dev/null +++ b/src/assets/svg/Articles/chevron-right.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/svg/Articles/close.svg b/src/assets/svg/Articles/close.svg new file mode 100644 index 000000000..8b8c680db --- /dev/null +++ b/src/assets/svg/Articles/close.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/svg/Articles/ellipse-blue.svg b/src/assets/svg/Articles/ellipse-blue.svg new file mode 100644 index 000000000..48d7f8830 --- /dev/null +++ b/src/assets/svg/Articles/ellipse-blue.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svg/Articles/ellipse-grey.svg b/src/assets/svg/Articles/ellipse-grey.svg new file mode 100644 index 000000000..e2a116c01 --- /dev/null +++ b/src/assets/svg/Articles/ellipse-grey.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svg/Articles/found.svg b/src/assets/svg/Articles/found.svg new file mode 100644 index 000000000..653b2a2a8 --- /dev/null +++ b/src/assets/svg/Articles/found.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/svg/Articles/garbage-can.svg b/src/assets/svg/Articles/garbage-can.svg new file mode 100644 index 000000000..0edd8e00b --- /dev/null +++ b/src/assets/svg/Articles/garbage-can.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/svg/Articles/lost.svg b/src/assets/svg/Articles/lost.svg new file mode 100644 index 000000000..3d75144d0 --- /dev/null +++ b/src/assets/svg/Articles/lost.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/assets/svg/Articles/pencil.svg b/src/assets/svg/Articles/pencil.svg new file mode 100644 index 000000000..8f1953786 --- /dev/null +++ b/src/assets/svg/Articles/pencil.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/svg/Articles/photo.svg b/src/assets/svg/Articles/photo.svg new file mode 100644 index 000000000..f07dd9a7c --- /dev/null +++ b/src/assets/svg/Articles/photo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/svg/Articles/remove-image.svg b/src/assets/svg/Articles/remove-image.svg new file mode 100644 index 000000000..ba4b3922e --- /dev/null +++ b/src/assets/svg/Articles/remove-image.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/svg/Articles/warn.svg b/src/assets/svg/Articles/warn.svg new file mode 100644 index 000000000..2f2d82271 --- /dev/null +++ b/src/assets/svg/Articles/warn.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/common/Footer/index.tsx b/src/components/common/Footer/index.tsx index 45a80a5f7..546728b88 100644 --- a/src/components/common/Footer/index.tsx +++ b/src/components/common/Footer/index.tsx @@ -13,13 +13,13 @@ function Footer(): JSX.Element { const isStage = import.meta.env.VITE_API_PATH?.includes('stage'); const logShortcut = (title: string) => { - if (title === '식단') logger.actionEventClick({ actionTitle: 'CAMPUS', title: 'footer', value: '식단' }); - if (title === '버스/교통') logger.actionEventClick({ actionTitle: 'CAMPUS', title: 'footer', value: '버스/교통' }); - if (title === '공지사항') logger.actionEventClick({ actionTitle: 'CAMPUS', title: 'footer', value: '공지사항' }); - if (title === '주변상점') logger.actionEventClick({ actionTitle: 'BUSINESS', title: 'footer', value: '주변상점' }); - if (title === '복덕방') logger.actionEventClick({ actionTitle: 'BUSINESS', title: 'footer', value: '복덕방' }); - if (title === '시간표') logger.actionEventClick({ actionTitle: 'USER', title: 'footer', value: '시간표' }); - if (title === '교내 시설물 정보') logger.actionEventClick({ actionTitle: 'CAMPUS', title: 'footer', value: '교내 시설물 정보' }); + if (title === '식단') logger.actionEventClick({ actionTitle: 'CAMPUS', event_label: 'footer', value: '식단' }); + if (title === '버스/교통') logger.actionEventClick({ actionTitle: 'CAMPUS', event_label: 'footer', value: '버스/교통' }); + if (title === '공지사항') logger.actionEventClick({ actionTitle: 'CAMPUS', event_label: 'footer', value: '공지사항' }); + if (title === '주변상점') logger.actionEventClick({ actionTitle: 'BUSINESS', event_label: 'footer', value: '주변상점' }); + if (title === '복덕방') logger.actionEventClick({ actionTitle: 'BUSINESS', event_label: 'footer', value: '복덕방' }); + if (title === '시간표') logger.actionEventClick({ actionTitle: 'USER', event_label: 'footer', value: '시간표' }); + if (title === '교내 시설물 정보') logger.actionEventClick({ actionTitle: 'CAMPUS', event_label: 'footer', value: '교내 시설물 정보' }); }; return ( @@ -44,7 +44,7 @@ function Footer(): JSX.Element {
facebook - + home
diff --git a/src/components/common/Header/MobileHeader/Panel/index.tsx b/src/components/common/Header/MobileHeader/Panel/index.tsx index 563b3ad90..2441b4ac2 100644 --- a/src/components/common/Header/MobileHeader/Panel/index.tsx +++ b/src/components/common/Header/MobileHeader/Panel/index.tsx @@ -28,13 +28,13 @@ export default function Panel({ openModal }: PanelProps) { const isStage = import.meta.env.VITE_API_PATH?.includes('stage'); const logShortcut = (title: string) => { - if (title === '식단') logger.actionEventClick({ actionTitle: 'CAMPUS', title: 'hamburger', value: '식단' }); - if (title === '버스/교통') logger.actionEventClick({ actionTitle: 'CAMPUS', title: 'hamburger', value: '버스' }); - if (title === '공지사항') logger.actionEventClick({ actionTitle: 'CAMPUS', title: 'hamburger', value: '공지사항' }); - if (title === '주변상점') logger.actionEventClick({ actionTitle: 'BUSINESS', title: 'hamburger_shop', value: '주변상점' }); - if (title === '복덕방') logger.actionEventClick({ actionTitle: 'BUSINESS', title: 'hamburger', value: '복덕방' }); - if (title === '시간표') logger.actionEventClick({ actionTitle: 'USER', title: 'hamburger', value: '시간표' }); - if (title === '교내 시설물 정보') logger.actionEventClick({ actionTitle: 'CAMPUS', title: 'hamburger', value: '교내 시설물 정보' }); + if (title === '식단') logger.actionEventClick({ actionTitle: 'CAMPUS', event_label: 'hamburger', value: '식단' }); + if (title === '버스/교통') logger.actionEventClick({ actionTitle: 'CAMPUS', event_label: 'hamburger', value: '버스' }); + if (title === '공지사항') logger.actionEventClick({ actionTitle: 'CAMPUS', event_label: 'hamburger', value: '공지사항' }); + if (title === '주변상점') logger.actionEventClick({ actionTitle: 'BUSINESS', event_label: 'hamburger_shop', value: '주변상점' }); + if (title === '복덕방') logger.actionEventClick({ actionTitle: 'BUSINESS', event_label: 'hamburger', value: '복덕방' }); + if (title === '시간표') logger.actionEventClick({ actionTitle: 'USER', event_label: 'hamburger', value: '시간표' }); + if (title === '교내 시설물 정보') logger.actionEventClick({ actionTitle: 'CAMPUS', event_label: 'hamburger', value: '교내 시설물 정보' }); }; // 기존 페이지에서 햄버거를 통해 다른 페이지로 이동할 때의 로그입니다. @@ -42,7 +42,7 @@ export default function Panel({ openModal }: PanelProps) { if (pathname === '/timetable') { logger.actionEventClick({ actionTitle: 'USER', - title: 'timetable_back', + event_label: 'timetable_back', value: '햄버거', previous_page: '시간표', current_page: title, @@ -55,7 +55,7 @@ export default function Panel({ openModal }: PanelProps) { if (userInfo) { logger.actionEventClick({ actionTitle: 'USER', - title: 'hamburger', + event_label: 'hamburger', value: '내정보', }); closeSidebar(); @@ -127,7 +127,7 @@ export default function Panel({ openModal }: PanelProps) { logout(); logger.actionEventClick({ actionTitle: 'USER', - title: 'hamburger', + event_label: 'hamburger', value: '로그아웃', }); } @@ -135,7 +135,7 @@ export default function Panel({ openModal }: PanelProps) { navigate(ROUTES.Auth()); logger.actionEventClick({ actionTitle: 'USER', - title: 'hamburger', + event_label: 'hamburger', value: '로그인', }); } diff --git a/src/components/common/Header/MobileHeader/index.tsx b/src/components/common/Header/MobileHeader/index.tsx index 80eb4e721..ebdf0c362 100644 --- a/src/components/common/Header/MobileHeader/index.tsx +++ b/src/components/common/Header/MobileHeader/index.tsx @@ -34,7 +34,7 @@ export default function MobileHeader({ openModal }: MobileHeaderProps) { const response = await api.store.getStoreDetailInfo(id!); logger.actionEventClick({ actionTitle: 'BUSINESS', - title: 'shop_detail_view_back', + event_label: 'shop_detail_view_back', value: response.name, event_category: 'click', current_page: sessionStorage.getItem('cameFrom') || '', @@ -46,7 +46,7 @@ export default function MobileHeader({ openModal }: MobileHeaderProps) { if (pathname === '/timetable') { logger.actionEventClick({ actionTitle: 'USER', - title: 'timetable_back', + event_label: 'timetable_back', value: '뒤로가기버튼', previous_page: '시간표', current_page: '메인', diff --git a/src/components/common/Header/PCHeader/index.tsx b/src/components/common/Header/PCHeader/index.tsx index ea6c9be72..d556f0d6e 100644 --- a/src/components/common/Header/PCHeader/index.tsx +++ b/src/components/common/Header/PCHeader/index.tsx @@ -69,19 +69,19 @@ export default function PCHeader({ openModal }: PCHeaderProps) { const isLoggedin = !!token; const logShortcut = (title: string) => { - if (title === '식단') logger.actionEventClick({ actionTitle: 'CAMPUS', title: 'header', value: '식단' }); - if (title === '버스/교통') logger.actionEventClick({ actionTitle: 'CAMPUS', title: 'header', value: '버스/교통' }); - if (title === '공지사항') logger.actionEventClick({ actionTitle: 'CAMPUS', title: 'header', value: '공지사항' }); + if (title === '식단') logger.actionEventClick({ actionTitle: 'CAMPUS', event_label: 'header', value: '식단' }); + if (title === '버스/교통') logger.actionEventClick({ actionTitle: 'CAMPUS', event_label: 'header', value: '버스/교통' }); + if (title === '공지사항') logger.actionEventClick({ actionTitle: 'CAMPUS', event_label: 'header', value: '공지사항' }); if (title === '주변상점') { logger.actionEventClick({ - actionTitle: 'BUSINESS', title: 'header', value: '주변상점', event_category: 'click', + actionTitle: 'BUSINESS', event_label: 'header', value: '주변상점', event_category: 'click', }); } - if (title === '복덕방') logger.actionEventClick({ actionTitle: 'BUSINESS', title: 'header', value: '복덕방' }); - if (title === '시간표') logger.actionEventClick({ actionTitle: 'USER', title: 'header', value: '시간표' }); + if (title === '복덕방') logger.actionEventClick({ actionTitle: 'BUSINESS', event_label: 'header', value: '복덕방' }); + if (title === '시간표') logger.actionEventClick({ actionTitle: 'USER', event_label: 'header', value: '시간표' }); if (title === '교내 시설물 정보') { logger.actionEventClick({ - actionTitle: 'CAMPUS', title: 'header', value: '교내 시설물 정보', event_category: 'click', + actionTitle: 'CAMPUS', event_label: 'header', value: '교내 시설물 정보', event_category: 'click', }); } }; @@ -90,7 +90,7 @@ export default function PCHeader({ openModal }: PCHeaderProps) { if (pathname === ROUTES.Timetable()) { logger.actionEventClick({ actionTitle: 'USER', - title: 'timetable_back', + event_label: 'timetable_back', value: '로고', previous_page: '시간표', current_page: '메인', @@ -102,7 +102,7 @@ export default function PCHeader({ openModal }: PCHeaderProps) { const shopName = await api.store.getStoreDetailInfo(shopId); logger.actionEventClick({ actionTitle: 'BUSINESS', - title: 'shop_detail_view_back', + event_label: 'shop_detail_view_back', value: shopName.name, event_category: 'logo', current_page: sessionStorage.getItem('cameFrom') || '전체보기', @@ -188,7 +188,7 @@ export default function PCHeader({ openModal }: PCHeaderProps) { onClick={() => { logger.actionEventClick({ actionTitle: 'USER', - title: 'header', + event_label: 'header', value: '회원가입', }); }} @@ -202,7 +202,7 @@ export default function PCHeader({ openModal }: PCHeaderProps) { onClick={() => { logger.actionEventClick({ actionTitle: 'USER', - title: 'header', + event_label: 'header', value: '로그인', }); }} @@ -221,7 +221,7 @@ export default function PCHeader({ openModal }: PCHeaderProps) { openModal(); logger.actionEventClick({ actionTitle: 'USER', - title: 'header', + event_label: 'header', value: '정보수정', }); }} @@ -235,7 +235,7 @@ export default function PCHeader({ openModal }: PCHeaderProps) { logout(); logger.actionEventClick({ actionTitle: 'USER', - title: 'header', + event_label: 'header', value: '로그아웃', }); }} diff --git a/src/components/common/LoadingSpinner/LoadingSpinner.module.scss b/src/components/common/LoadingSpinner/LoadingSpinner.module.scss index eb654e704..b0ca5c02a 100644 --- a/src/components/common/LoadingSpinner/LoadingSpinner.module.scss +++ b/src/components/common/LoadingSpinner/LoadingSpinner.module.scss @@ -1,8 +1,10 @@ .loading-wrapper { + width: 100%; display: inline-block; position: relative; justify-content: center; align-items: center; + margin-top: 80px; } .loading-div { diff --git a/src/components/common/LoginRequiredModal/index.tsx b/src/components/common/LoginRequiredModal/index.tsx index cf066976c..aae156480 100644 --- a/src/components/common/LoginRequiredModal/index.tsx +++ b/src/components/common/LoginRequiredModal/index.tsx @@ -23,7 +23,7 @@ export default function LoginRequiredModal({ if (shopName) { logger.actionEventClick({ actionTitle: 'BUSINESS', - title: `shop_detail_view_review_${type}_login`, + event_label: `shop_detail_view_review_${type}_login`, value: shopName, event_category: 'click', }); @@ -34,7 +34,7 @@ export default function LoginRequiredModal({ if (shopName) { logger.actionEventClick({ actionTitle: 'BUSINESS', - title: `shop_detail_view_review_${type}_cancel`, + event_label: `shop_detail_view_review_${type}_cancel`, value: shopName, event_category: 'click', }); diff --git a/src/components/common/PrivateRoute/index.tsx b/src/components/common/PrivateRoute/index.tsx deleted file mode 100644 index 561e4f0a5..000000000 --- a/src/components/common/PrivateRoute/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { ReactNode } from 'react'; -import useTokenState from 'utils/hooks/state/useTokenState'; -// import { useAuthentication } from 'utils/zustand/authentication'; -import { Navigate } from 'react-router-dom'; -import ROUTES from 'static/routes'; - -interface PirvateRouteProps { - requireAuthentication: boolean; - element: ReactNode; -} - -export default function PrivateRoute({ element, requireAuthentication }: PirvateRouteProps) { - const token = useTokenState(); - // const isAuthenticated = useAuthentication(); - - if (requireAuthentication && !token) { - return ; - } - - // if (requireAuthentication && !isAuthenticated) { - // return ; - // } - - return
{element}
; -} diff --git a/src/index.tsx b/src/index.tsx deleted file mode 100644 index a4898ebd3..000000000 --- a/src/index.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import './index.scss'; -import { BrowserRouter } from 'react-router-dom'; -import PortalProvider from 'components/common/Modal/PortalProvider'; -import { RecoilRoot } from 'recoil'; -import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { sendClientError } from '@bcsdlab/koin'; -import App from './App'; -import reportWebVitals from './reportWebVitals'; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnReconnect: true, - retry: false, - }, - }, - queryCache: new QueryCache({ - onError: (error) => sendClientError(error), - }), -}); - -const root = ReactDOM.createRoot( - document.getElementById('root') as HTMLElement, -); -root.render( - - - - - - - - - - - , -); - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); diff --git a/src/lib/gtag.ts b/src/lib/gtag.ts index 0fc6e008e..2c3074163 100644 --- a/src/lib/gtag.ts +++ b/src/lib/gtag.ts @@ -1,7 +1,7 @@ type GTagEvent = { action: string; category: string; - label: string; + event_label: string; value: string; duration_time?: number; previous_page?: string; @@ -21,12 +21,12 @@ export const pageView = (url: string, userId?: string) => { // https://developers.google.com/analytics/devguides/collection/gtagjs/events export const event = ({ - action, category, label, value, duration_time, previous_page, current_page, + action, category, event_label, value, duration_time, previous_page, current_page, }: GTagEvent) => { if (typeof window.gtag === 'undefined') return; window.gtag('event', action, { event_category: category, - event_label: label, + event_label, value, duration_time, previous_page, @@ -38,7 +38,7 @@ export const event = ({ console.table({ 팀: action, '이벤트 Category': category, - '이벤트 Title': label, + '이벤트 Title': event_label, 값: value, '체류 시간': duration_time, '이전 카테고리': previous_page, diff --git a/src/main.tsx b/src/main.tsx index a4ddb7651..a4898ebd3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,7 +4,8 @@ import './index.scss'; import { BrowserRouter } from 'react-router-dom'; import PortalProvider from 'components/common/Modal/PortalProvider'; import { RecoilRoot } from 'recoil'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { sendClientError } from '@bcsdlab/koin'; import App from './App'; import reportWebVitals from './reportWebVitals'; @@ -15,6 +16,9 @@ const queryClient = new QueryClient({ retry: false, }, }, + queryCache: new QueryCache({ + onError: (error) => sendClientError(error), + }), }); const root = ReactDOM.createRoot( diff --git a/src/pages/Articles/ArticleListPage/index.tsx b/src/pages/Articles/ArticleListPage/index.tsx new file mode 100644 index 000000000..7c24f166f --- /dev/null +++ b/src/pages/Articles/ArticleListPage/index.tsx @@ -0,0 +1,21 @@ +import Pagination from 'pages/Articles/components/Pagination'; +import ArticlesHeader from 'pages/Articles/components/ArticlesHeader'; +import ArticleList from 'pages/Articles/components/ArticleList'; +import usePageParams from 'pages/Articles/hooks/usePageParams'; +import useArticles from 'pages/Articles/hooks/useArticles'; +import { Suspense } from 'react'; + +export default function ArticleListPage() { + const paramsPage = usePageParams(); + const { articles, paginationInfo } = useArticles(paramsPage); + + return ( + <> + + }> + + + + + ); +} diff --git a/src/pages/Notice/NoticeDetailPage/index.tsx b/src/pages/Articles/ArticlesDetailPage/index.tsx similarity index 65% rename from src/pages/Notice/NoticeDetailPage/index.tsx rename to src/pages/Articles/ArticlesDetailPage/index.tsx index 484bb8235..c597cfd10 100644 --- a/src/pages/Notice/NoticeDetailPage/index.tsx +++ b/src/pages/Articles/ArticlesDetailPage/index.tsx @@ -1,10 +1,10 @@ import { Suspense } from 'react'; import { useParams } from 'react-router-dom'; -import ArticleHeader from 'pages/Notice/components/ArticleHeader'; -import ArticleContent from 'pages/Notice/components/ArticleContent'; -import useArticle from 'pages/Notice/hooks/useArticle'; +import ArticleHeader from 'pages/Articles/components/ArticleHeader'; +import ArticleContent from 'pages/Articles/components/ArticleContent'; +import useArticle from 'pages/Articles/hooks/useArticle'; -export default function NoticeDetailPage() { +export default function ArticlesDetailPage() { const params = useParams(); const { article } = useArticle(params.id!); diff --git a/src/pages/Notice/NoticePage/NoticePage.module.scss b/src/pages/Articles/ArticlesPage/ArticlesPage.module.scss similarity index 68% rename from src/pages/Notice/NoticePage/NoticePage.module.scss rename to src/pages/Articles/ArticlesPage/ArticlesPage.module.scss index 6f515d56c..e76395e4d 100644 --- a/src/pages/Notice/NoticePage/NoticePage.module.scss +++ b/src/pages/Articles/ArticlesPage/ArticlesPage.module.scss @@ -17,6 +17,7 @@ margin-bottom: 60px; display: flex; flex-direction: column; + align-items: center; @include media.media-breakpoint(mobile) { width: 100%; @@ -27,22 +28,22 @@ .header { width: 100%; - height: 40px; - margin-bottom: 20px; + margin-bottom: 8px; position: relative; - - @include media.media-breakpoint(mobile) { - display: none; - } + display: flex; + justify-content: space-between; + align-items: center; &__title { - float: left; - font-family: NanumSquare, serif; - font-size: 30px; - font-weight: 800; - letter-spacing: -1.5px; + font-family: Pretendard, sans-serif; + font-size: 32px; + font-weight: 500; color: #175c8e; margin: 0; + + @include media.media-breakpoint(mobile) { + display: none; + } } } diff --git a/src/pages/Articles/ArticlesPage/index.tsx b/src/pages/Articles/ArticlesPage/index.tsx new file mode 100644 index 000000000..dcca2fcb4 --- /dev/null +++ b/src/pages/Articles/ArticlesPage/index.tsx @@ -0,0 +1,38 @@ +import LoadingSpinner from 'components/common/LoadingSpinner'; +import HotArticles from 'pages/Articles/components/HotArticle'; +import { Suspense } from 'react'; +import { Link, Outlet, useLocation } from 'react-router-dom'; +import useScrollToTop from 'utils/hooks/ui/useScrollToTop'; +import { useUser } from 'utils/hooks/state/useUser'; +import LostItemRouteButton from 'pages/Articles/components/LostItemRouteButton'; +import ROUTES from 'static/routes'; +import styles from './ArticlesPage.module.scss'; + +export default function ArticlesPage() { + useScrollToTop(); + const { pathname } = useLocation(); + const isBoard = pathname.endsWith(ROUTES.Articles()); + const { data: userInfo } = useUser(); + const isCouncil = userInfo && userInfo.student_number === '2022136000'; + + return ( +
+
+
+ +

공지사항

+ + {isBoard && isCouncil && } +
+ }> + + +
+
+ }> + + +
+
+ ); +} diff --git a/src/pages/Articles/LostItemDetailPage/LostItemDetailPage.module.scss b/src/pages/Articles/LostItemDetailPage/LostItemDetailPage.module.scss new file mode 100644 index 000000000..48e8d7103 --- /dev/null +++ b/src/pages/Articles/LostItemDetailPage/LostItemDetailPage.module.scss @@ -0,0 +1,213 @@ +@use "src/utils/scss/media" as media; + +.container { + @include media.media-breakpoint(mobile) { + width: calc(100vw - 24px); + } +} + +.header { + border-top: 2px solid #175c8e; + border-bottom: 1px solid #175c8e; + width: 834px; + height: 100%; + display: flex; + flex-direction: column; + gap: 8px; + padding: 26px 0 29px; + font-family: Pretendard, sans-serif; + font-size: 32px; + font-weight: 500; + line-height: 1.2; + + @include media.media-breakpoint(mobile) { + width: 100%; + box-sizing: border-box; + border-top: 0; + padding: 15px 24px; + border-bottom: 1px solid #ececec; + } +} + +.title { + width: 794px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + font-family: Pretendard, sans-serif; + font-size: 32px; + font-weight: 500; + line-height: 1.2; + + @include media.media-breakpoint(mobile) { + gap: 2px; + width: 100%; + padding: 0; + font-size: 14px; + line-height: 1.6; + } + + &__category { + display: flex; + align-items: center; + height: 46px; + box-sizing: border-box; + padding: 2px 12px 0; + border-radius: 23px; + background-color: #175c8e; + color: #fff; + + @include media.media-breakpoint(mobile) { + height: 22px; + padding: 1px 8px 0; + margin-right: 6px; + } + } + + &__board-id { + font-weight: 500; + + @include media.media-breakpoint(mobile) { + width: 100%; + color: #10477a; + font-weight: 600; + } + } + + &__content { + word-break: break-all; + } +} + +.info { + display: flex; + align-items: center; + gap: 12px; + font-family: Pretendard, sans-serif; + font-size: 18px; + font-weight: 400; + line-height: 1.6; + + @include media.media-breakpoint(mobile) { + font-size: 12px; + } + + &__author { + color: #175c8e; + + @include media.media-breakpoint(mobile) { + color: #727272; + } + } + + &__registered-at { + color: #727272; + } +} + +.contents { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 80px; + box-sizing: border-box; + + @include media.media-breakpoint(mobile) { + width: 100%; + padding: 0 24px; + } + + &__content { + width: 794px; + margin-top: 64px; + white-space: pre-wrap; + word-break: break-all; + font-family: Pretendard, sans-serif; + font-size: 20px; + line-height: 1.6; + + @include media.media-breakpoint(mobile) { + width: 100%; + margin-top: 16px; + font-size: 14px; + } + } + + &__guide { + width: 592px; + height: 119px; + margin-top: 75px; + border-radius: 12px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: #f5f5f5; + color: #727272; + font-family: Pretendard, sans-serif; + font-size: 18px; + line-height: 1.6; + text-align: center; + + & strong { + color: #1f1f1f; + font-weight: 500; + } + + @include media.media-breakpoint(mobile) { + width: 100%; + height: 89px; + margin-top: 32px; + font-size: 12px; + line-height: 1.6; + } + } + + &__buttons { + width: 100%; + display: flex; + margin-top: 64px; + justify-content: flex-end; + + @include media.media-breakpoint(mobile) { + width: 100%; + margin-top: 40px; + justify-content: space-between; + } + } + + &__button { + display: flex; + justify-content: center; + align-items: center; + border-radius: 4px; + gap: 1px; + padding: 10px 16px; + background-color: #e1e1e1; + color: #4b4b4b; + font-family: Pretendard, sans-serif; + font-size: 16px; + font-weight: 500; + line-height: 1.6; + + & path { + fill: #4b4b4b; + } + + & svg { + width: 24px; + height: 24px; + } + + @include media.media-breakpoint(mobile) { + font-size: 12px; + padding: 6px 12px; + + & svg { + width: 16px; + height: 16px; + } + } + } +} diff --git a/src/pages/Articles/LostItemDetailPage/components/DeleteModal/DeleteModal.module.scss b/src/pages/Articles/LostItemDetailPage/components/DeleteModal/DeleteModal.module.scss new file mode 100644 index 000000000..eda2a088e --- /dev/null +++ b/src/pages/Articles/LostItemDetailPage/components/DeleteModal/DeleteModal.module.scss @@ -0,0 +1,136 @@ +@use "src/utils/scss/media" as media; + +.background { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + background: rgba(23 23 23 / 30%); +} + +.modal { + width: 431px; + height: 256px; + margin-bottom: 100px; + border-radius: 16px; + background-color: #fff; + + @include media.media-breakpoint(mobile) { + width: 301px; + height: 140px; + padding: 24px 32px; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 24px; + } + + &__close { + height: 56px; + display: flex; + justify-content: flex-end; + align-items: center; + padding: 24px; + box-sizing: border-box; + border-bottom: 1px solid #f5f5f5; + + & svg { + width: 24px; + height: 24px; + } + + & path { + fill: #727272; + } + } + + &__title { + height: 95px; + display: flex; + justify-content: center; + align-items: center; + font-family: Pretendard, sans-serif; + font-size: 26px; + font-weight: 700; + line-height: 1.2; + text-align: center; + + @include media.media-breakpoint(mobile) { + height: auto; + font-size: 14px; + font-weight: 500; + line-height: 1.6; + } + } + + &__buttons { + height: 105px; + display: flex; + flex-direction: column; + align-items: center; + + @include media.media-breakpoint(mobile) { + height: auto; + flex-direction: row-reverse; + gap: 12px; + } + + & button { + width: 383px; + height: 50px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 4px; + padding-top: 3px; + gap: 8px; + font-family: Pretendard, sans-serif; + line-height: 1.6; + + @include media.media-breakpoint(mobile) { + width: 105px; + height: 46px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + } + } + } +} + +.buttons { + &__delete { + background-color: #175c8e; + font-size: 16px; + font-weight: 500; + color: #fff; + + & svg { + padding-bottom: 2px; + width: 24px; + height: 24px; + } + + & path { + fill: #d9d9d9; + } + } + + &__cancel { + background-color: #fff; + font-size: 14px; + font-weight: 400; + color: #000; + + @include media.media-breakpoint(mobile) { + border: 1px solid #cacaca; + color: #4b4b4b; + } + } +} diff --git a/src/pages/Articles/LostItemDetailPage/components/DeleteModal/index.tsx b/src/pages/Articles/LostItemDetailPage/components/DeleteModal/index.tsx new file mode 100644 index 000000000..3dcdbfc41 --- /dev/null +++ b/src/pages/Articles/LostItemDetailPage/components/DeleteModal/index.tsx @@ -0,0 +1,73 @@ +import useDeleteLostItemArticle from 'pages/Articles/hooks/useDeleteLostItemArticles'; +import { useEscapeKeyDown } from 'utils/hooks/ui/useEscapeKeyDown'; +import { useOutsideClick } from 'utils/hooks/ui/useOutsideClick'; +import GarbageCanIcon from 'assets/svg/Articles/garbage-can.svg'; +import CloseIcon from 'assets/svg/Articles/close.svg'; +import { useBodyScrollLock } from 'utils/hooks/ui/useBodyScrollLock'; +import useMediaQuery from 'utils/hooks/layout/useMediaQuery'; +import { useArticlesLogger } from 'pages/Articles/hooks/useArticlesLogger'; +import { useNavigate } from 'react-router-dom'; +import ROUTES from 'static/routes'; +import styles from './DeleteModal.module.scss'; + +interface DeleteModalProps { + articleId: number; + closeDeleteModal: () => void; +} + +export default function DeleteModal( + { articleId, closeDeleteModal }: DeleteModalProps, +) { + const isMobile = useMediaQuery(); + const navigate = useNavigate(); + const { logFindUserDeleteConfirmClick } = useArticlesLogger(); + const { mutate: deleteArticle } = useDeleteLostItemArticle({ + onSuccess: () => navigate(ROUTES.Articles(), { replace: true }), + }); + + useEscapeKeyDown({ onEscape: closeDeleteModal }); + useBodyScrollLock(); + const { backgroundRef } = useOutsideClick({ onOutsideClick: closeDeleteModal }); + + const handleConfirmDeleteClick = () => { + logFindUserDeleteConfirmClick(); + deleteArticle(articleId); + closeDeleteModal(); + }; + + return ( +
+
+ {!isMobile && ( +
+ +
+ )} +
게시글을 삭제하시겠습니까?
+
+ + +
+
+
+ ); +} diff --git a/src/pages/Articles/LostItemDetailPage/components/DisplayImage/DisplayImage.module.scss b/src/pages/Articles/LostItemDetailPage/components/DisplayImage/DisplayImage.module.scss new file mode 100644 index 000000000..c32b14b17 --- /dev/null +++ b/src/pages/Articles/LostItemDetailPage/components/DisplayImage/DisplayImage.module.scss @@ -0,0 +1,79 @@ +@use "src/utils/scss/media" as media; + +.container { + margin-top: 40px; + + @include media.media-breakpoint(mobile) { + margin-top: 20px; + } +} + +.images { + position: relative; + width: 586px; + height: 527px; + + @include media.media-breakpoint(mobile) { + width: 100%; + height: auto; + display: flex; + flex-direction: column; + align-items: center; + } + + &__image { + width: 586px; + height: 527px; + object-fit: contain; + + @include media.media-breakpoint(mobile) { + width: 100%; + height: auto; + } + } + + &__button { + position: absolute; + top: 50%; + transform: translateY(-50%); + + &--left { + left: 17px; + } + + &--right { + right: 17px; + } + + &--hidden { + display: none; + } + + @include media.media-breakpoint(mobile) { + & svg { + width: 24px; + height: 24px; + } + } + } +} + +.navigation { + height: 30px; + display: flex; + align-items: center; + justify-content: center; + margin-top: 20px; + box-sizing: border-box; + gap: 8px; + + & button { + display: flex; + justify-content: center; + align-items: center; + } + + @include media.media-breakpoint(mobile) { + margin-top: 16px; + } +} diff --git a/src/pages/Articles/LostItemDetailPage/components/DisplayImage/index.tsx b/src/pages/Articles/LostItemDetailPage/components/DisplayImage/index.tsx new file mode 100644 index 000000000..e1b57f66a --- /dev/null +++ b/src/pages/Articles/LostItemDetailPage/components/DisplayImage/index.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { cn } from '@bcsdlab/utils'; +import SelectedDotIcon from 'assets/svg/Articles/ellipse-blue.svg'; +import NotSelectedDotIcon from 'assets/svg/Articles/ellipse-grey.svg'; +import ChevronRight from 'assets/svg/Articles/chevron-right-circle.svg'; +import ChevronLeft from 'assets/svg/Articles/chevron-left-circle.svg'; +import { Image } from 'pages/Articles/ts/types'; +import styles from './DisplayImage.module.scss'; + +interface DisplayImageProps { + images: Image[]; +} + +export default function DisplayImage({ + images, +}: DisplayImageProps) { + const [image, setImage] = useState(images[0]); + const imageIndex = images.findIndex((img) => img.id === image.id); + + const handleArrowButtonClick = (diff: 1 | -1) => { + setImage(images[(imageIndex + diff) % images.length]); + }; + + return ( +
+ {images.length > 0 && ( +
+ 분실물 이미지 + + +
+ )} +
+ {images.length > 1 && images.map((currentImage, index) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/Articles/LostItemDetailPage/index.tsx b/src/pages/Articles/LostItemDetailPage/index.tsx new file mode 100644 index 000000000..128af7a93 --- /dev/null +++ b/src/pages/Articles/LostItemDetailPage/index.tsx @@ -0,0 +1,101 @@ +import { Suspense } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { convertArticlesTag } from 'utils/ts/convertArticlesTag'; +import GarbageCanIcon from 'assets/svg/Articles/garbage-can.svg'; +import useSingleLostItemArticle from 'pages/Articles/hooks/useSingleLostItemArticle'; +import DeleteModal from 'pages/Articles/LostItemDetailPage/components/DeleteModal'; +import useBooleanState from 'utils/hooks/state/useBooleanState'; +import useMediaQuery from 'utils/hooks/layout/useMediaQuery'; +import ROUTES from 'static/routes'; +import { useUser } from 'utils/hooks/state/useUser'; +import { useArticlesLogger } from 'pages/Articles/hooks/useArticlesLogger'; +import DisplayImage from 'pages/Articles/LostItemDetailPage/components/DisplayImage'; +import styles from './LostItemDetailPage.module.scss'; + +export default function LostItemDetailPage() { + const isMobile = useMediaQuery(); + const navigate = useNavigate(); + const params = useParams(); + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useBooleanState(false); + const { data: userInfo } = useUser(); + const isCouncil = userInfo && userInfo.student_number === '2022136000'; + + const { article } = useSingleLostItemArticle(Number(params.id)); + const articleId = Number(params.id); + const { + boardId, + category, + foundPlace, + foundDate, + content, + author, + images, + registeredAt, + } = article; + + const { logFindUserDeleteClick } = useArticlesLogger(); + + const handleDeleteButtonClick = () => { + logFindUserDeleteClick(); + openDeleteModal(); + }; + + return ( + +
+
+
+ {convertArticlesTag(boardId)} + {category} + {[foundPlace, foundDate].join(' | ')} +
+
+
{author}
+
{registeredAt}
+
+
+
+ +
{content}
+
+

분실물 수령을 희망하시는 분은 재실 시간 내에

+

+ 학생회관 320호 총학생회 사무실 + 로 방문해 주시기 바랍니다. +

+

재실 시간은 공지 사항을 참고해 주시기 바랍니다.

+
+
+ {isMobile && ( + + )} + {isCouncil && ( + + )} +
+
+ {isDeleteModalOpen && ( + + )} +
+
+ ); +} diff --git a/src/pages/Articles/LostItemWritePage/LostItemWritePage.module.scss b/src/pages/Articles/LostItemWritePage/LostItemWritePage.module.scss new file mode 100644 index 000000000..e2e4bb78f --- /dev/null +++ b/src/pages/Articles/LostItemWritePage/LostItemWritePage.module.scss @@ -0,0 +1,147 @@ +@use "src/utils/scss/media" as media; + +.container { + min-height: calc(100vh - 60px); + padding-top: 64px; + background-color: #fafafa; + display: flex; + flex-direction: column; + align-items: center; + + @include media.media-breakpoint(mobile) { + width: 100%; + padding-top: 0; + background-color: #fff; + } +} + +.wrapper { + width: 1136px; + + @include media.media-breakpoint(mobile) { + width: 100%; + } +} + +.header { + width: 100%; + display: flex; + flex-direction: column; + margin-bottom: 76px; + gap: 8px; + + @include media.media-breakpoint(mobile) { + width: calc(100% - 48px); + padding: 32px 24px 12px; + margin-bottom: 0; + gap: 0; + } + + &__title { + font-family: Pretendard, sans-serif; + font-size: 32px; + font-weight: 500; + color: #175c8e; + line-height: 1.2; + + @include media.media-breakpoint(mobile) { + color: #000; + font-size: 18px; + line-height: 1.6; + display: flex; + gap: 8px; + } + } + + &__description { + font-family: Pretendard, sans-serif; + font-size: 18px; + line-height: 1.6; + color: #666; + + @include media.media-breakpoint(mobile) { + font-size: 12px; + line-height: 1.6; + color: #727272; + } + } +} + +.forms { + display: flex; + flex-direction: column; + gap: 53px; + + @include media.media-breakpoint(mobile) { + gap: 24px; + } +} + +.add { + width: 100%; + margin: 32px 0 20px; + display: flex; + justify-content: flex-end; + box-sizing: border-box; + + @include media.media-breakpoint(mobile) { + margin: 0; + padding: 16px 24px; + } + + &__button { + width: 127px; + height: 48px; + display: flex; + justify-content: center; + align-items: center; + gap: 4px; + border-radius: 8px; + background-color: #e4f2ff; + color: #10477a; + font-family: Pretendard, sans-serif; + font-size: 18px; + font-weight: 500; + line-height: 1.2; + box-shadow: + 0 2px 4px rgba(0 0 0 / 4%), + 0 1px 1px rgba(0 0 0 / 2%); + + @include media.media-breakpoint(mobile) { + height: 40px; + font-size: 14px; + } + } +} + +.complete { + margin-top: auto; + display: flex; + justify-content: center; + margin-bottom: 100px; + + @include media.media-breakpoint(mobile) { + margin-top: 24px; + } + + &__button { + width: 240px; + height: 53px; + border: none; + border-radius: 8px; + background-color: #175c8e; + color: white; + font-family: Pretendard, sans-serif; + font-size: 18px; + font-weight: 700; + line-height: 1.6; + box-shadow: + 0 2px 4px rgba(0 0 0 / 10%), + 0 1px 1px rgba(0 0 0 / 5%); + + @include media.media-breakpoint(mobile) { + height: 44px; + font-size: 14px; + } + } +} diff --git a/src/pages/Articles/LostItemWritePage/components/Calendar/Calendar.module.scss b/src/pages/Articles/LostItemWritePage/components/Calendar/Calendar.module.scss new file mode 100644 index 000000000..f6c70260b --- /dev/null +++ b/src/pages/Articles/LostItemWritePage/components/Calendar/Calendar.module.scss @@ -0,0 +1,113 @@ +.box { + width: 332px; + height: 302px; + padding: 16px 20px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 16px; + background-color: #fff; + border-radius: 8px; + box-shadow: + 0 4px 10px rgba(0 0 0 / 8%), + 0 1px 4px rgba(0 0 0 / 4%); +} + +.year { + display: flex; + align-items: center; + gap: 12px; + + & > button { + height: 24px; + } + + &__text { + padding-top: 4px; + color: #000; + font-family: Pretendard, sans-serif; + font-size: 18px; + font-weight: 500; + line-height: 1.6; + } +} + +.calendar { + display: flex; + flex-direction: column; + gap: 16px; + + &__week { + display: flex; + justify-content: space-between; + } + + &__day-of-week { + width: 28px; + height: 28px; + display: flex; + justify-content: center; + align-items: center; + color: #4b4b4b; + font-family: Pretendard, sans-serif; + font-size: 18px; + font-weight: 400; + line-height: 1.6; + text-align: center; + } + + &__days { + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-template-rows: repeat(5, 1fr); + column-gap: 16px; + row-gap: 10px; + } + + &__day-of-month { + position: relative; + width: 24px; + height: 24px; + display: flex; + justify-content: center; + align-items: center; + padding-top: 2px; + color: #000; + font-family: Pretendard, sans-serif; + font-size: 18px; + font-weight: 500; + line-height: 1.6; + text-align: center; + cursor: pointer; + + &--other-month { + color: #8e8e8e; + } + + &--future { + color: #e1e1e1; + cursor: default; + } + + &--today { + &::before { + position: absolute; + bottom: -5.5px; + content: ""; + width: 11.5px; + height: 0; + border-bottom: 2px solid #175c8e; + border-radius: 1px; + } + } + + &--selected { + color: #fff; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + background-color: #175c8e; + } + } +} diff --git a/src/pages/Articles/LostItemWritePage/components/Calendar/index.tsx b/src/pages/Articles/LostItemWritePage/components/Calendar/index.tsx new file mode 100644 index 000000000..9afc5282c --- /dev/null +++ b/src/pages/Articles/LostItemWritePage/components/Calendar/index.tsx @@ -0,0 +1,95 @@ +import { cn } from '@bcsdlab/utils'; +import ChevronLeftIcon from 'assets/svg/Articles/chevron-left.svg'; +import ChevronRightIcon from 'assets/svg/Articles/chevron-right.svg'; +import { useMemo, useState } from 'react'; +import styles from './Calendar.module.scss'; + +const getyyyyMM = (date: Date) => { + const yyyy = date.getFullYear(); + const MM = (date.getMonth() + 1).toString().padStart(2, '0'); + return `${yyyy}.${MM}`; +}; + +const isSameDate = (date1: Date, date2: Date) => ( + date1.getFullYear() === date2.getFullYear() + && date1.getMonth() === date2.getMonth() + && date1.getDate() === date2.getDate() +); + +const WEEK = ['일', '월', '화', '수', '목', '금', '토']; + +interface CalendarProps { + selectedDate: Date; + setSelectedDate: (date: Date) => void; +} + +export default function Calendar({ selectedDate, setSelectedDate }: CalendarProps) { + const today = new Date(); + const [currentMonthDate, setCurrentMonthDate] = useState(selectedDate); + const days = useMemo(() => Array.from({ length: 35 }, (_, i) => { + const startDate = new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth(), 1); + startDate.setDate(startDate.getDate() - startDate.getDay()); + return new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate() + i, + ); + }), [currentMonthDate]); + + const handleMonthChevronClick = (diff: number) => { + setCurrentMonthDate((prev) => new Date(prev.getFullYear(), prev.getMonth() + diff, 1)); + }; + + return ( +
+
+ + {getyyyyMM(currentMonthDate)} + +
+
+
+ {WEEK.map((day) => ( + + {day} + + ))} +
+
+ {days.map((date) => ( + + ))} +
+
+
+ ); +} diff --git a/src/pages/Articles/LostItemWritePage/components/FormCategory/FormCategory.module.scss b/src/pages/Articles/LostItemWritePage/components/FormCategory/FormCategory.module.scss new file mode 100644 index 000000000..e82685f44 --- /dev/null +++ b/src/pages/Articles/LostItemWritePage/components/FormCategory/FormCategory.module.scss @@ -0,0 +1,96 @@ +@use "src/utils/scss/media" as media; + +.category { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + + @include media.media-breakpoint(mobile) { + flex-direction: column; + } + + &__wrapper { + position: relative; + display: flex; + flex-direction: column; + gap: 13px; + } + + &__text { + display: flex; + flex-direction: column; + } + + &__buttons { + display: flex; + gap: 10px; + flex-flow: row wrap; + } + + &__button { + height: 38px; + padding: 8px 12px; + display: flex; + justify-content: center; + align-items: center; + white-space: nowrap; + border-radius: 20px; + background-color: #fff; + border: 1px solid #175c8e; + color: #175c8e; + font-family: Pretendard, sans-serif; + font-size: 14px; + font-weight: 500; + + &--selected { + background-color: #175c8e; + color: #fff; + } + } +} + +.title { + font-family: Pretendard, sans-serif; + font-size: 20px; + font-weight: 500; + line-height: 1.2; + + @include media.media-breakpoint(mobile) { + font-size: 14px; + line-height: 1.6; + } + + &__description { + color: #727272; + font-family: Pretendard, sans-serif; + font-size: 14px; + line-height: 1.6; + + @include media.media-breakpoint(mobile) { + margin-bottom: 12px; + font-size: 12px; + line-height: 1.6; + } + } +} + +.warning { + position: absolute; + left: 0; + bottom: -32px; + display: flex; + align-items: center; + gap: 6px; + color: #f7941e; + font-family: Pretendard, sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 1.6; + + @include media.media-breakpoint(mobile) { + left: initial; + bottom: initial; + right: 0; + top: -53px; + } +} diff --git a/src/pages/Articles/LostItemWritePage/components/FormCategory/index.tsx b/src/pages/Articles/LostItemWritePage/components/FormCategory/index.tsx new file mode 100644 index 000000000..0c1e08a2a --- /dev/null +++ b/src/pages/Articles/LostItemWritePage/components/FormCategory/index.tsx @@ -0,0 +1,55 @@ +import { cn } from '@bcsdlab/utils'; +import WarnIcon from 'assets/svg/Articles/warn.svg'; +import { FindUserCategory, useArticlesLogger } from 'pages/Articles/hooks/useArticlesLogger'; +import styles from './FormCategory.module.scss'; + +const CATEGORIES: FindUserCategory[] = ['카드', '신분증', '지갑', '전자제품', '그 외']; + +interface FormCategoryProps { + category: FindUserCategory | ''; + setCategory: (category: FindUserCategory) => void; + isCategorySelected: boolean; +} + +export default function FormCategory({ + category, setCategory, isCategorySelected, +}: FormCategoryProps) { + const { logFindUserCategory } = useArticlesLogger(); + + const handleCategoryClick = (item: FindUserCategory) => { + logFindUserCategory(item); + setCategory(item); + }; + + return ( +
+
+ 품목 + 품목을 선택해주세요. +
+
+
+ {CATEGORIES.map((item) => ( + + ))} +
+ {!isCategorySelected && ( + + + 품목이 선택되지 않았습니다. + + )} +
+
+ ); +} diff --git a/src/pages/Articles/LostItemWritePage/components/FormContent/FormContent.module.scss b/src/pages/Articles/LostItemWritePage/components/FormContent/FormContent.module.scss new file mode 100644 index 000000000..f2c65e118 --- /dev/null +++ b/src/pages/Articles/LostItemWritePage/components/FormContent/FormContent.module.scss @@ -0,0 +1,75 @@ +@use "src/utils/scss/media" as media; + +.content { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 12px; + + @include media.media-breakpoint(mobile) { + width: 100%; + } + + &__input { + width: 960px; + height: 115px; + display: flex; + justify-content: space-between; + align-items: center; + box-sizing: border-box; + padding: 8px 16px; + border-radius: 8px; + resize: none; + background-color: #f5f5f5; + border: none; + font-family: Pretendard, sans-serif; + font-size: 16px; + font-weight: 400; + line-height: 1.6; + + @include media.media-breakpoint(mobile) { + width: 100%; + font-size: 12px; + } + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__counter { + color: #727272; + font-family: Pretendard, sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 1.6; + text-align: center; + } +} + +.title { + font-family: Pretendard, sans-serif; + font-size: 20px; + font-weight: 500; + line-height: 1.2; + + @include media.media-breakpoint(mobile) { + font-size: 14px; + line-height: 1.6; + } + + &__description { + color: #727272; + font-family: Pretendard, sans-serif; + font-size: 14px; + line-height: 1.6; + + @include media.media-breakpoint(mobile) { + margin-bottom: 12px; + font-size: 12px; + line-height: 1.6; + } + } +} diff --git a/src/pages/Articles/LostItemWritePage/components/FormContent/index.tsx b/src/pages/Articles/LostItemWritePage/components/FormContent/index.tsx new file mode 100644 index 000000000..132cd010b --- /dev/null +++ b/src/pages/Articles/LostItemWritePage/components/FormContent/index.tsx @@ -0,0 +1,40 @@ +import { useState } from 'react'; +import showToast from 'utils/ts/showToast'; +import styles from './FormContent.module.scss'; + +const MAX_CONTENT_LENGTH = 1000; + +interface FormContentProps { + content: string; + setContent: (content: string) => void; +} + +export default function FormContent({ content, setContent }: FormContentProps) { + const [localContent, setLocalContent] = useState(content); + const contentCounter = `${localContent.length}/${MAX_CONTENT_LENGTH}`; + + const handleContentChange = (value: string) => { + if (value.length <= MAX_CONTENT_LENGTH) { + setLocalContent(value); + } else { + showToast('error', `최대 ${MAX_CONTENT_LENGTH}자까지 입력 가능합니다.`); + } + }; + + return ( +
+
+ 내용 + {contentCounter} +
+