diff --git a/.eslintrc.js b/.eslintrc.js index 85f2fe8f..d1eac194 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -38,6 +38,7 @@ module.exports = { 'unused-imports/no-unused-imports': 'error', // emotion css props 'react/no-unknown-property': ['error', { ignore: ['css'] }], + 'react/prop-types': 'off', }, overrides: [ { diff --git a/Dockerfile b/Dockerfile index 7d225865..33519059 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,8 +16,6 @@ COPY .nx ./ RUN pnpm install -RUN pnpm add -w sharp - ENV NEXT_TELEMETRY_DISABLED=1 RUN pnpm nextjs:build diff --git a/services/ahhachul.com/src/apis/request/complaint.ts b/services/ahhachul.com/src/apis/request/complaint.ts index cb0ff5c3..5fce786a 100644 --- a/services/ahhachul.com/src/apis/request/complaint.ts +++ b/services/ahhachul.com/src/apis/request/complaint.ts @@ -1 +1,49 @@ -export {}; +import { appendFilesToFormData, createJsonBlob, extractFormData } from '@ahhachul/utils'; + +import axiosInstance from '@/apis/fetcher'; +import type { ApiResponse, CommentList, PaginatedList, WithPostId } from '@/types'; +import type { + ComplaintForm, + ComplaintListParams, + ComplaintPost, + ComplaintPostDetail, +} from '@/types/complaint'; + +export const fetchComplaintList = async (req: ComplaintListParams) => { + const { data } = await axiosInstance.get>>( + '/complaint-posts', + { + params: { + ...req, + pageSize: 10, + }, + }, + ); + return data; +}; + +export const createComplaint = async (req: ComplaintForm) => { + const formData = new FormData(); + const formDataWithoutImages = extractFormData(req, 'images'); + const jsonBlob = createJsonBlob(formDataWithoutImages); + + formData.append('content', jsonBlob); + + if (req.images?.length) { + appendFilesToFormData(formData, req.images); + } + + const { data } = await axiosInstance.post>('/complaint-posts', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return data; +}; + +export const fetchComplaintDetail = (id: number) => + axiosInstance.get>(`/complaint-posts/${id}`); + +export const fetchComplaintCommentList = (id: number) => + axiosInstance.get>(`/complaint-posts/${id}/comments`); diff --git a/services/ahhachul.com/src/apis/request/index.ts b/services/ahhachul.com/src/apis/request/index.ts index 26375013..70ef3645 100644 --- a/services/ahhachul.com/src/apis/request/index.ts +++ b/services/ahhachul.com/src/apis/request/index.ts @@ -3,3 +3,4 @@ export * from './user'; export * from './token'; export * from './lostFound'; export * from './community'; +export * from './complaint'; diff --git a/services/ahhachul.com/src/assets/icons/complaint/ic_arrow.svg b/services/ahhachul.com/src/assets/icons/complaint/ic_arrow.svg new file mode 100644 index 00000000..4b36c9a1 --- /dev/null +++ b/services/ahhachul.com/src/assets/icons/complaint/ic_arrow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/services/ahhachul.com/src/assets/icons/complaint/ic_emergency.svg b/services/ahhachul.com/src/assets/icons/complaint/ic_emergency.svg new file mode 100644 index 00000000..1361c181 --- /dev/null +++ b/services/ahhachul.com/src/assets/icons/complaint/ic_emergency.svg @@ -0,0 +1,4 @@ + + + + diff --git a/services/ahhachul.com/src/assets/icons/complaint/ic_hit.svg b/services/ahhachul.com/src/assets/icons/complaint/ic_hit.svg new file mode 100644 index 00000000..ba7ff16c --- /dev/null +++ b/services/ahhachul.com/src/assets/icons/complaint/ic_hit.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/services/ahhachul.com/src/assets/icons/complaint/ic_metro.svg b/services/ahhachul.com/src/assets/icons/complaint/ic_metro.svg new file mode 100644 index 00000000..16073904 --- /dev/null +++ b/services/ahhachul.com/src/assets/icons/complaint/ic_metro.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/services/ahhachul.com/src/assets/icons/complaint/ic_tree.svg b/services/ahhachul.com/src/assets/icons/complaint/ic_tree.svg new file mode 100644 index 00000000..d70bafcd --- /dev/null +++ b/services/ahhachul.com/src/assets/icons/complaint/ic_tree.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/services/ahhachul.com/src/assets/icons/complaint/index.ts b/services/ahhachul.com/src/assets/icons/complaint/index.ts new file mode 100644 index 00000000..fbc5befb --- /dev/null +++ b/services/ahhachul.com/src/assets/icons/complaint/index.ts @@ -0,0 +1,5 @@ +export { default as ArrowMiniIcon } from './ic_arrow.svg?react'; +export { default as EmergencyIcon } from './ic_emergency.svg?react'; +export { default as HitIcon } from './ic_hit.svg?react'; +export { default as MetroIcon } from './ic_metro.svg?react'; +export { default as TreeIcon } from './ic_tree.svg?react'; diff --git a/services/ahhachul.com/src/assets/icons/system/ic_list.svg b/services/ahhachul.com/src/assets/icons/system/ic_list.svg new file mode 100644 index 00000000..37d025c7 --- /dev/null +++ b/services/ahhachul.com/src/assets/icons/system/ic_list.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/services/ahhachul.com/src/assets/icons/system/index.ts b/services/ahhachul.com/src/assets/icons/system/index.ts index 60f5930c..2bc7e9a2 100644 --- a/services/ahhachul.com/src/assets/icons/system/index.ts +++ b/services/ahhachul.com/src/assets/icons/system/index.ts @@ -1,6 +1,7 @@ export { default as DotIcon } from './ic_dot.svg?react'; export { default as MicIcon } from './ic_mic.svg?react'; export { default as BellIcon } from './ic_bell.svg?react'; +export { default as ListIcon } from './ic_list.svg?react'; export { default as TalkIcon } from './ic_talk.svg?react'; export { default as InfoIcon } from './ic_info.svg?react'; export { default as LogoIcon } from './ic_logo.svg?react'; diff --git a/services/ahhachul.com/src/components/common/float/atoms/floatBtn/FloatButton.styled.tsx b/services/ahhachul.com/src/components/common/float/atoms/floatBtn/FloatButton.styled.tsx index e81d88c3..0aa31c26 100644 --- a/services/ahhachul.com/src/components/common/float/atoms/floatBtn/FloatButton.styled.tsx +++ b/services/ahhachul.com/src/components/common/float/atoms/floatBtn/FloatButton.styled.tsx @@ -1,13 +1,15 @@ import { css } from '@emotion/react'; import styled from '@emotion/styled'; +import { isWebView } from '@/constants'; + export const FloatButton = styled.button` ${({ theme }) => css` ${theme.fonts.bodyLarge}; position: fixed; right: 20px; - bottom: 80px; + bottom: ${isWebView() ? '112px' : '80px'}; width: max-content; height: 44px; diff --git a/services/ahhachul.com/src/components/common/float/molecules/newBtn/newBtn.component.tsx b/services/ahhachul.com/src/components/common/float/molecules/newBtn/newBtn.component.tsx index d3a4809c..09158c05 100644 --- a/services/ahhachul.com/src/components/common/float/molecules/newBtn/newBtn.component.tsx +++ b/services/ahhachul.com/src/components/common/float/molecules/newBtn/newBtn.component.tsx @@ -1,25 +1,37 @@ -import { PlusIcon } from '@/assets/icons/system'; +import { ListIcon, PlusIcon } from '@/assets/icons/system'; import { UiComponent } from '@/components'; import { useAuth } from '@/contexts'; import { type TypeActivities, useFlow } from '@/stackflow'; import type { KeyOf } from '@/types'; +type NewBtnType = 'new' | 'list'; + interface NewBtnProps { activityName: KeyOf; + label?: string; + type?: NewBtnType; + replace?: boolean; } -const NewBtn = ({ activityName }: NewBtnProps) => { - const { push } = useFlow(); +const NewBtn = ({ activityName, label = '글쓰기', type = 'new', replace = false }: NewBtnProps) => { + const { push, replace: replacePage } = useFlow(); const { authService } = useAuth(); const onClick = () => { - push(authService.isAuthenticated ? activityName : 'SignInPage', {}); + const action = replace ? replacePage : push; + action( + authService.isAuthenticated ? activityName : 'SignInPage', + {}, + { + animate: !replace, + }, + ); }; return ( - - 글쓰기 + {type === 'new' ? : } + {label} ); }; diff --git a/services/ahhachul.com/src/components/common/form/molecules/error/FormErrorMessage.styled.tsx b/services/ahhachul.com/src/components/common/form/molecules/error/FormErrorMessage.styled.tsx index 0e15ca6f..7eb63627 100644 --- a/services/ahhachul.com/src/components/common/form/molecules/error/FormErrorMessage.styled.tsx +++ b/services/ahhachul.com/src/components/common/form/molecules/error/FormErrorMessage.styled.tsx @@ -8,7 +8,7 @@ export const ErrorMessage = styled.div` color: ${({ theme }) => theme.colors.red}; gap: 6px; - & > div > svg > path { + & > svg > path { fill: #e02020; stroke: #ffffff; diff --git a/services/ahhachul.com/src/components/common/form/organisms/select/Select.component.tsx b/services/ahhachul.com/src/components/common/form/organisms/select/Select.component.tsx index 193c6f5c..bd4d02b9 100644 --- a/services/ahhachul.com/src/components/common/form/organisms/select/Select.component.tsx +++ b/services/ahhachul.com/src/components/common/form/organisms/select/Select.component.tsx @@ -2,6 +2,8 @@ import { Controller, Path, RegisterOptions, FieldValues, useFormContext } from ' import { FormComponent } from '@/components'; +import * as S from './Select.styled'; + import { SelectMolecules } from '../../molecules'; interface SelectFieldProps { @@ -43,7 +45,7 @@ const SelectField = ({ /> )} /> - + ); }; diff --git a/services/ahhachul.com/src/components/common/form/organisms/select/Select.styled.tsx b/services/ahhachul.com/src/components/common/form/organisms/select/Select.styled.tsx new file mode 100644 index 00000000..6caeb071 --- /dev/null +++ b/services/ahhachul.com/src/components/common/form/organisms/select/Select.styled.tsx @@ -0,0 +1,6 @@ +import { css } from '@emotion/react'; + +export const errorStyle = css` + margin-top: 12px; + padding-left: 20px; +`; diff --git a/services/ahhachul.com/src/components/domain/community/edit/template/EditCommunity.component.tsx b/services/ahhachul.com/src/components/domain/community/edit/template/EditCommunity.component.tsx index 9c498beb..bf0ebeab 100644 --- a/services/ahhachul.com/src/components/domain/community/edit/template/EditCommunity.component.tsx +++ b/services/ahhachul.com/src/components/domain/community/edit/template/EditCommunity.component.tsx @@ -1,7 +1,7 @@ import { FormProvider } from 'react-hook-form'; import { FormComponent } from '@/components'; -import { lostFoundTypeOptions } from '@/constants'; +import { communityTypeFormOptions } from '@/constants'; import { useEditCommunityForm } from '@/hooks/domain/community'; import { useFetchCommunityDetail } from '@/services/community'; import { useActivity } from '@/stackflow'; @@ -41,7 +41,7 @@ const EditCommunity = ({ id }: WithPostId) => { onDeleteImg={handleImageDelete} onImgChange={handleImageUpload} /> - + diff --git a/services/ahhachul.com/src/components/domain/community/postDetail/headerActions/CommunityHeaderActions.component.tsx b/services/ahhachul.com/src/components/domain/community/postDetail/headerActions/CommunityHeaderActions.component.tsx index bf237885..a24a8e07 100644 --- a/services/ahhachul.com/src/components/domain/community/postDetail/headerActions/CommunityHeaderActions.component.tsx +++ b/services/ahhachul.com/src/components/domain/community/postDetail/headerActions/CommunityHeaderActions.component.tsx @@ -37,7 +37,7 @@ const CommunityHeaderActions = ({ id, createdBy }: CommunityHeaderActionsProps) ? [ { label: '수정하기', - onClick: () => push('EditLostFoundPage', { id }), + onClick: () => push('EditCommunityPage', { id }), }, { label: '삭제하기', diff --git a/services/ahhachul.com/src/components/domain/complaint/index.ts b/services/ahhachul.com/src/components/domain/complaint/index.ts new file mode 100644 index 00000000..ea26fb32 --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/index.ts @@ -0,0 +1,3 @@ +export { default as ComplaintPanel } from './panel/ComplaintPanel.component'; +export * from './postDetail'; +export * from './searchResults'; diff --git a/services/ahhachul.com/src/components/domain/complaint/panel/ComplaintPanel.component.tsx b/services/ahhachul.com/src/components/domain/complaint/panel/ComplaintPanel.component.tsx index 88263072..213fbe64 100644 --- a/services/ahhachul.com/src/components/domain/complaint/panel/ComplaintPanel.component.tsx +++ b/services/ahhachul.com/src/components/domain/complaint/panel/ComplaintPanel.component.tsx @@ -8,11 +8,7 @@ import { KeyOf, ValueOf } from '@/types'; import * as S from './ComplaintPanel.styled'; -interface ComplaintPanelProps { - layoutCss: ReturnType; -} - -const ComplaintPanel = ({ layoutCss }: ComplaintPanelProps) => { +const ComplaintPanel = () => { const topCards = objectEntries(complaintsContentList).slice(0, 4); const bottomCards = objectEntries(complaintsContentList).slice(4, 7); @@ -22,39 +18,30 @@ const ComplaintPanel = ({ layoutCss }: ComplaintPanelProps) => { styleCss: ReturnType, ) => (
    - {items.map( - ([ - key, - { - // icon, - label, - desc, - }, - ]) => ( -
  • - - {/* */} + {items.map(([key, { icon, label, desc }]) => ( +
  • + + {label}

    {desc}

    - {/* {icon} */} - {/*
    */} -
    -
  • - ), - )} + {icon} + + + + ))}
); return ( - -
- {/* 지하철 환경 */} + + + 지하철 환경 {renderComplaintCards(topCards, S.topSection)} -
-
- {/* 긴급민원 요청 */} + + + 긴급민원 요청 {renderComplaintCards(bottomCards, S.bottomSection)} -
+
); }; diff --git a/services/ahhachul.com/src/components/domain/complaint/panel/ComplaintPanel.styled.tsx b/services/ahhachul.com/src/components/domain/complaint/panel/ComplaintPanel.styled.tsx index bef7d9e1..c8b311ae 100644 --- a/services/ahhachul.com/src/components/domain/complaint/panel/ComplaintPanel.styled.tsx +++ b/services/ahhachul.com/src/components/domain/complaint/panel/ComplaintPanel.styled.tsx @@ -4,73 +4,59 @@ import styled from '@emotion/styled'; import mixins from '@/styles/mixins'; export const Panel = styled.div` - ${mixins.fullWidth} - ${mixins.flexColumn} - ${mixins.sideGutter} + ${mixins.animatedLayout(false)}; + ${mixins.fullWidth}; + ${mixins.flexColumn}; gap: 36px; - background-color: ${({ theme }) => theme.colors.white}; + padding-top: 16px; `; -// export const Label = styled.span` -// color: ${({ theme }) => theme.color.text[50]}; -// font-size: ${({ theme }) => theme.typography.fontSize[16]}px; -// font-weight: ${({ theme }) => theme.typography.fontWeight[600]}; -// margin-bottom: 16px; -// `; - -// export const Card = styled.div` -// display: flex; -// flex-direction: column; -// width: 100%; -// height: 100%; -// position: relative; -// padding: 16px; -// border-radius: 8px; -// background-color: ${({ theme }) => theme.color.black[500]}; - -// span { -// color: ${({ theme }) => theme.color.text[50]}; -// font-size: ${({ theme }) => theme.typography.fontSize[16]}px; -// font-weight: ${({ theme }) => theme.typography.fontWeight[600]}; -// margin-bottom: 8px; -// } - -// & > p { -// color: ${({ theme }) => theme.color.blueDarkGray[500]}; -// font-size: ${({ theme }) => theme.typography.fontSize[12]}px; -// } - -// & > div { -// position: absolute; -// right: 16px; -// bottom: 10px; -// } -// `; - -// export const CardStats = styled.article` -// position: absolute; -// right: 16px; -// bottom: 10px; -// text-align: right; - -// span { -// color: ${({ theme }) => theme.color.blueDarkGray[500]}; -// font-size: ${({ theme }) => theme.typography.fontSize[12]}px; -// } - -// p { -// color: ${({ theme }) => theme.color.white[700]}; -// font-size: ${({ theme }) => theme.typography.fontSize[12]}px; -// margin-top: 4px; -// margin-bottom: 6px; -// } - -// div { -// color: ${({ theme }) => theme.color.skyBlue[500]}; -// font-size: ${({ theme }) => theme.typography.fontSize[48]}px; -// font-weight: ${({ theme }) => theme.typography.fontWeight[600]}; -// } -// `; +export const Cell = styled.div` + ${mixins.flexColumn}; + gap: 12px; +`; + +export const Label = styled.span` + ${({ theme }) => css` + color: ${theme.colors.black}; + ${theme.fonts.titleLarge}; + `} +`; + +export const Card = styled.div` + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + position: relative; + padding: 16px; + border-radius: 8px; + background-color: #eafcf1; + + span { + color: ${({ theme }) => theme.colors.black}; + font-size: 16px; + font-weight: 600; + margin-bottom: 8px; + } + + & > p { + color: #67696f; + font-size: 12px; + } + + & > div { + position: absolute; + right: 16px; + bottom: 10px; + } + + & > svg { + position: absolute; + right: 16px; + bottom: 10px; + } +`; const grid = css` display: grid; diff --git a/services/ahhachul.com/src/components/domain/complaint/postDetail/categoryBadge/ComplaintCategoryBadge.component.tsx b/services/ahhachul.com/src/components/domain/complaint/postDetail/categoryBadge/ComplaintCategoryBadge.component.tsx new file mode 100644 index 00000000..f305ccaf --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/postDetail/categoryBadge/ComplaintCategoryBadge.component.tsx @@ -0,0 +1,14 @@ +import { complaintTypeOptions } from '@/constants'; +import type { ComplaintType } from '@/types/complaint'; + +import * as S from './ComplaintCategoryBadge.styled'; + +interface ComplaintCategoryBadgeProps { + complaintType: ComplaintType; +} + +const ComplaintCategoryBadge = ({ complaintType }: ComplaintCategoryBadgeProps) => { + return {complaintTypeOptions[complaintType]}; +}; + +export default ComplaintCategoryBadge; diff --git a/services/ahhachul.com/src/components/domain/complaint/postDetail/categoryBadge/ComplaintCategoryBadge.styled.tsx b/services/ahhachul.com/src/components/domain/complaint/postDetail/categoryBadge/ComplaintCategoryBadge.styled.tsx new file mode 100644 index 00000000..010833bc --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/postDetail/categoryBadge/ComplaintCategoryBadge.styled.tsx @@ -0,0 +1,15 @@ +import styled from '@emotion/styled'; + +export const Badge = styled.div` + height: 28px; + font-size: 12px; + line-height: 18px; + color: #ffffff; + padding: 0 10px; + display: flex; + align-items: center; + justify-content: center; + background-color: #407ad6; + border-radius: 100px; + width: max-content; +`; diff --git a/services/ahhachul.com/src/components/domain/complaint/postDetail/commentList/ComplaintCommentList.component.tsx b/services/ahhachul.com/src/components/domain/complaint/postDetail/commentList/ComplaintCommentList.component.tsx new file mode 100644 index 00000000..6924d4d2 --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/postDetail/commentList/ComplaintCommentList.component.tsx @@ -0,0 +1,39 @@ +import { BookmarkIcon } from '@/assets/icons/system'; +import { UiComponent } from '@/components'; +import { useFetchComplaintCommentList } from '@/services/complaint'; + +import * as S from './ComplaintCommentList.styled'; + +interface ComplaintCommentListProps { + id: number; + commentCnt: number; +} + +const ComplaintCommentList = ({ commentCnt, id }: ComplaintCommentListProps) => { + return ( + + + + 댓글 + {commentCnt ?? 0} + + + + } + suspenseFallback={} + > + + + + ); +}; + +const CommentListInner = ({ id }: Pick) => { + const { data } = useFetchComplaintCommentList(id); + + return ; +}; + +export default ComplaintCommentList; diff --git a/services/ahhachul.com/src/components/domain/complaint/postDetail/commentList/ComplaintCommentList.styled.tsx b/services/ahhachul.com/src/components/domain/complaint/postDetail/commentList/ComplaintCommentList.styled.tsx new file mode 100644 index 00000000..d6c40b4a --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/postDetail/commentList/ComplaintCommentList.styled.tsx @@ -0,0 +1,23 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +export const Section = styled.section``; + +export const HeaderWrapper = styled.div` + height: 50px; + border-top: 1px solid ${({ theme }) => theme.colors.gray[30]}; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; +`; + +export const CommentCountWrapper = styled.div` + ${({ theme }) => css` + ${theme.fonts.labelMedium}; + color: ${theme.colors.gray[80]}; + display: flex; + align-items: center; + gap: 4px; + `} +`; diff --git a/services/ahhachul.com/src/components/domain/complaint/postDetail/headerActions/ComplaintHeaderActions.component.tsx b/services/ahhachul.com/src/components/domain/complaint/postDetail/headerActions/ComplaintHeaderActions.component.tsx new file mode 100644 index 00000000..a48ee30a --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/postDetail/headerActions/ComplaintHeaderActions.component.tsx @@ -0,0 +1,80 @@ +import { useReducer } from 'react'; + +import { useActivity } from '@stackflow/react'; + +import { MoreVerticalIcon, ShareIcon } from '@/assets/icons/system'; +import { UiComponent } from '@/components'; +import { useNativeBridge } from '@/contexts'; +import { useUser } from '@/hooks/domain'; +import { useFlow } from '@/stackflow'; +import { getSharePageURL } from '@/utils/share'; + +import * as S from './ComplaintHeaderActions.styled'; + +interface ComplaintHeaderActionsProps { + id: number; + createdBy: number; +} + +const ComplaintHeaderActions = ({ id, createdBy }: ComplaintHeaderActionsProps) => { + const { push } = useFlow(); + const { user } = useUser(); + const { isActive } = useActivity(); + const { bridge, isBridgeInitialized } = useNativeBridge(); + + const handleClickShare = () => { + if (!isBridgeInitialized) return; + + const targetUrl = getSharePageURL('ComplaintDetailPage'); + bridge.send.share(`${targetUrl}/${id}`); + }; + + const [isOpen, toggle] = useReducer(open => !open, false); + + const isAuthor = user?.memberId === createdBy; + + const actions = isAuthor + ? [ + { + label: '수정하기', + onClick: () => push('EditComplaintPage', { id }), + }, + { + label: '삭제하기', + onClick: () => console.log('삭제하기'), + }, + ] + : [ + { + label: '신고하기', + onClick: () => console.log('신고하기'), + }, + ]; + + if (!isActive) return null; + + return ( + <> + +
+ + + + + + + + +
+
+ + +
+ +
+
+ + ); +}; + +export default ComplaintHeaderActions; diff --git a/services/ahhachul.com/src/components/domain/complaint/postDetail/headerActions/ComplaintHeaderActions.styled.tsx b/services/ahhachul.com/src/components/domain/complaint/postDetail/headerActions/ComplaintHeaderActions.styled.tsx new file mode 100644 index 00000000..a5fce08d --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/postDetail/headerActions/ComplaintHeaderActions.styled.tsx @@ -0,0 +1,24 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +export const Container = styled.button` + ${({ theme }) => css` + position: fixed; + top: 0; + right: 16px; + background: ${theme.colors.white}; + z-index: ${theme.zIndex.header}; + height: 58px; + display: flex; + align-items: center; + justify-content: center; + gap: 15px; + `} +`; + +export const ActionButton = styled.button` + width: max-content; + display: flex; + align-items: center; + justify-content: center; +`; diff --git a/services/ahhachul.com/src/components/domain/complaint/postDetail/index.ts b/services/ahhachul.com/src/components/domain/complaint/postDetail/index.ts new file mode 100644 index 00000000..03131c4c --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/postDetail/index.ts @@ -0,0 +1,4 @@ +export { default as ComplaintDetail } from './template/ComplaintDetail.component'; +export { default as ComplaintCategoryBadge } from './categoryBadge/ComplaintCategoryBadge.component'; +export { default as ComplaintCommentList } from './commentList/ComplaintCommentList.component'; +export { default as ComplaintDetailHeaderActions } from './headerActions/ComplaintHeaderActions.component'; diff --git a/services/ahhachul.com/src/components/domain/complaint/postDetail/template/ComplaintDetail.component.tsx b/services/ahhachul.com/src/components/domain/complaint/postDetail/template/ComplaintDetail.component.tsx new file mode 100644 index 00000000..f6bea1b8 --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/postDetail/template/ComplaintDetail.component.tsx @@ -0,0 +1,51 @@ +import { formatDateTime } from '@ahhachul/utils'; + +import { ComplaintComponent, UiComponent } from '@/components'; +import { subwayIconMap } from '@/constants'; +import { useFetchComplaintDetail } from '@/services/complaint'; +import { isLexicalContent } from '@/utils/lexical'; + +import * as S from './ComplaintDetail.styled'; + +interface ComplaintDetailProps { + id: number; +} + +const ComplaintDetail = ({ id }: ComplaintDetailProps) => { + const { data: post } = useFetchComplaintDetail(id); + + return ( + <> + + + + + + + {post.title} + + + {post.writer} + {formatDateTime(post.createdAt, { format: 'short' })} + + {subwayIconMap.get(post.subwayLineId)} + + + + + {!isLexicalContent(post.content) ? ( + {post.content} + ) : ( + + + + )} + + + + + + ); +}; + +export default ComplaintDetail; diff --git a/services/ahhachul.com/src/components/domain/complaint/postDetail/template/ComplaintDetail.styled.tsx b/services/ahhachul.com/src/components/domain/complaint/postDetail/template/ComplaintDetail.styled.tsx new file mode 100644 index 00000000..8761537c --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/postDetail/template/ComplaintDetail.styled.tsx @@ -0,0 +1,98 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +export const ArticleWrapper = styled.article``; + +export const ContentWrapper = styled.div` + padding: 20px 20px 24px; +`; + +export const TitleWrapper = styled.div` + ${({ theme }) => css` + ${theme.fonts.titleLarge}; + color: ${theme.colors.gray[90]}; + padding-top: 13px; + padding-bottom: 16px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + `} +`; + +export const MetaInfoWrapper = styled.div` + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + padding-bottom: 16px; + border-bottom: 1px solid ${({ theme }) => theme.colors.gray[20]}; +`; + +export const AuthorDateWrapper = styled.div` + ${({ theme }) => css` + ${theme.fonts.bodyMedium}; + display: flex; + align-items: center; + gap: 4px; + `} +`; + +export const AuthorText = styled.span` + color: ${({ theme }) => theme.colors.gray[80]}; +`; + +export const DateText = styled.span` + color: ${({ theme }) => theme.colors.gray[70]}; +`; + +export const SubwayLineWrapper = styled.div` + ${({ theme }) => css` + ${theme.fonts.labelMedium}; + display: flex; + align-items: center; + color: ${theme.colors.gray[90]}; + font-weight: 400; + `} +`; + +export const Lost112Wrapper = styled.div` + padding: 0 20px; + display: flex; + align-items: center; + height: 56px; + width: 100%; + gap: 8px; +`; + +export const Lost112Text = styled.span` + ${({ theme }) => css` + ${theme.fonts.labelMedium}; + color: ${theme.colors.gray[90]}; + `} +`; + +export const ContentContainer = styled.div` + padding: 0 20px; +`; + +export const TextContent = styled.p` + ${({ theme }) => css` + ${theme.fonts.bodyLargeSemi}; + color: ${theme.colors.gray[90]}; + padding: 0 0 24px; + margin-bottom: 12px; + `} +`; + +export const LexicalContent = styled.div` + padding: 0 0 24px; + + & > div { + padding: 0; + & > div > div { + padding: 0; + border: none; + } + } +`; diff --git a/services/ahhachul.com/src/components/domain/complaint/searchResults/filters/ComplaintFilters.component.tsx b/services/ahhachul.com/src/components/domain/complaint/searchResults/filters/ComplaintFilters.component.tsx new file mode 100644 index 00000000..02c81181 --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/searchResults/filters/ComplaintFilters.component.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +import { UiComponent } from '@/components'; +import { compalintFilterKeys, subwayLineFilterOptions } from '@/constants'; +import { useActivity } from '@/stackflow'; +import type { IFilterState } from '@/stores/filter'; +import type { ComplaintFilters as TypeComplaintFilters } from '@/types/complaint'; + +import * as S from './ComplaintFilters.styled'; + +interface ComplaintFilterListProps extends Omit, 'loaded'> { + isScale: boolean; + toggleScale: () => void; +} + +const ComplaintFilters: React.FC = ({ + isScale, + toggleScale, + filters, + activatedCount, + handleSelect, + handleReset, +}) => { + const { isActive } = useActivity(); + + return ( + <> + + + + + + + + + + ); +}; + +export default ComplaintFilters; diff --git a/services/ahhachul.com/src/components/domain/complaint/searchResults/filters/ComplaintFilters.styled.tsx b/services/ahhachul.com/src/components/domain/complaint/searchResults/filters/ComplaintFilters.styled.tsx new file mode 100644 index 00000000..fe2c8c19 --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/searchResults/filters/ComplaintFilters.styled.tsx @@ -0,0 +1,48 @@ +import styled from '@emotion/styled'; + +import mixins from '@/styles/mixins'; + +interface MotionProps { + isScale: boolean; +} + +export const Motion = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: ${({ isScale }) => (isScale ? '58px' : 0)}; + z-index: ${({ isScale }) => (isScale ? 8 : 0)}; + background-color: ${({ theme, isScale }) => (isScale ? theme.colors.white : 'rgba(0,0,0,0)')}; + transition: background-color 0.15s ease; +`; + +interface FilterGroupProps { + isScale: boolean; + isActive: boolean; +} + +export const FilterGroup = styled.div` + position: fixed; + top: 58px; + left: 0; + flex-direction: column; + width: 100%; + gap: ${({ isScale }) => (isScale ? '9px' : '16px')}; + transform: ${({ isScale }) => (isScale ? 'translateY(-42px)' : 'translateY(0)')}; + transition: all 0.4s ease; + border-bottom: 1px solid ${({ theme }) => theme.colors.gray[20]}; + background-color: ${({ theme }) => theme.colors.white}; + padding-bottom: 16px; + z-index: ${({ isActive, isScale }) => (isActive || isScale ? 9 : 0)}; + display: ${({ isActive }) => (isActive ? 'flex' : 'none')}; +`; + +export const FilterListWrap = styled.div` + ${mixins.fullWidth} + ${mixins.flexAlignCenter} + ${mixins.overflowXScroll} + padding-left: 20px; + padding-right: 20px; + gap: 8px; +`; diff --git a/services/ahhachul.com/src/components/domain/complaint/searchResults/index.ts b/services/ahhachul.com/src/components/domain/complaint/searchResults/index.ts new file mode 100644 index 00000000..775f5a19 --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/searchResults/index.ts @@ -0,0 +1,3 @@ +export { default as FilterList } from './filters/ComplaintFilters.component'; +export { default as SearchedList } from './searchedList/SearchedList.component'; +export { default as SearchedListSkeleton } from './skeleton/SearchedList.skeleton'; diff --git a/services/ahhachul.com/src/components/domain/complaint/searchResults/searchedList/SearchedList.component.tsx b/services/ahhachul.com/src/components/domain/complaint/searchResults/searchedList/SearchedList.component.tsx new file mode 100644 index 00000000..49ec60b0 --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/searchResults/searchedList/SearchedList.component.tsx @@ -0,0 +1,56 @@ +import { UiComponent } from '@/components'; +import { useIntersectionObserver, useThrottle } from '@/hooks'; +import { useFetchComplaintList } from '@/services/complaint'; +import { StackFlow } from '@/stackflow'; +import { ComplaintFilters } from '@/types/complaint'; +import { extractInfinitePageData } from '@/utils'; + +import * as S from './SearchedList.styled'; + +interface ComplaintSearchedListProps { + filters: ComplaintFilters; + keyword?: string; + isScale?: boolean; +} + +const ComplaintSearchedList = ({ + keyword, + filters: { subwayLineId }, + isScale, +}: ComplaintSearchedListProps) => { + const { data, hasNextPage, isFetchingNextPage, fetchNextPage } = useFetchComplaintList({ + keyword, + subwayLineId, + }); + + const lostArticles = extractInfinitePageData(data); + + const throttledFetchNextPage = useThrottle(() => { + if (!isFetchingNextPage) { + fetchNextPage(); + } + }, 500); + + const { ref: observer } = useIntersectionObserver({ + callback: throttledFetchNextPage, + }); + + if (!lostArticles.length) return ; + + return ( + + {lostArticles.map((post, idx) => ( + + + + ))} + {hasNextPage && 더 보기} + + ); +}; + +export default ComplaintSearchedList; diff --git a/services/ahhachul.com/src/components/domain/complaint/searchResults/searchedList/SearchedList.styled.tsx b/services/ahhachul.com/src/components/domain/complaint/searchResults/searchedList/SearchedList.styled.tsx new file mode 100644 index 00000000..0f50f40b --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/searchResults/searchedList/SearchedList.styled.tsx @@ -0,0 +1,16 @@ +import styled from '@emotion/styled'; + +import { mixins } from '@/styles'; + +interface SectionProps { + isScale?: boolean; +} + +export const Section = styled.section` + ${({ isScale }) => mixins.animatedLayout(isScale)}; +`; + +export const ViewMore = styled.span` + opacity: 0; + height: 1px; +`; diff --git a/services/ahhachul.com/src/components/domain/complaint/searchResults/skeleton/SearchedList.skeleton.styled.tsx b/services/ahhachul.com/src/components/domain/complaint/searchResults/skeleton/SearchedList.skeleton.styled.tsx new file mode 100644 index 00000000..e6106fdf --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/searchResults/skeleton/SearchedList.skeleton.styled.tsx @@ -0,0 +1,90 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { DotIcon } from '@/assets/icons/system'; + +interface SectionWrapperProps { + isScale: boolean; +} +export const SectionWrapper = styled.section` + padding-top: 99px; + transform: ${({ isScale }) => (isScale ? 'translateY(-50px)' : 'translateY(0)')}; + + opacity: 0; + animation: fadeIn 0.5s ease-in-out forwards; + @media (prefers-reduced-motion: reduce) { + animation: none; + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +`; + +export const ArticleItem = styled.article<{ delay: number }>` + padding: 20px; + opacity: 0; + animation: fadeIn 0.5s ease-in-out forwards; + border-bottom: 1px solid ${({ theme }) => theme.colors.gray[20]}; +`; + +export const ContentWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +export const TopSection = styled.div` + display: flex; + gap: 6px; +`; + +export const TextSection = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 6px; +`; + +export const ImageSection = styled.div` + display: flex; + align-items: center; + justify-content: center; + position: relative; + width: 66px; + min-width: 66px; + aspect-ratio: 1; +`; + +export const BottomSection = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +export const MetaInfoSection = styled.div` + ${({ theme }) => css` + ${theme.fonts.bodyMedium}; + color: ${theme.colors.gray[80]}; + display: flex; + align-items: center; + gap: 4px; + `} +`; + +export const StyledDotIcon = styled(DotIcon)` + position: relative; + top: 1px; +`; + +export const CountSection = styled.div` + display: flex; + align-items: center; + gap: 2px; + color: ${({ theme }) => theme.colors.gray[80]}; +`; diff --git a/services/ahhachul.com/src/components/domain/complaint/searchResults/skeleton/SearchedList.skeleton.tsx b/services/ahhachul.com/src/components/domain/complaint/searchResults/skeleton/SearchedList.skeleton.tsx new file mode 100644 index 00000000..b194ffe9 --- /dev/null +++ b/services/ahhachul.com/src/components/domain/complaint/searchResults/skeleton/SearchedList.skeleton.tsx @@ -0,0 +1,44 @@ +import { BaseSkeleton } from '@/components/common'; + +import * as S from './SearchedList.skeleton.styled'; + +interface SearchedListSkeletonProps { + isScale: boolean; +} + +const SearchedListSkeleton = ({ isScale }: SearchedListSkeletonProps) => { + return ( + + {new Array(5).fill('').map((_, idx) => ( + + + + + + + + {/* 이미지 스켈레톤 */} + + + + + + + + + + + + + + + + + + + ))} + + ); +}; + +export default SearchedListSkeleton; diff --git a/services/ahhachul.com/src/components/domain/index.ts b/services/ahhachul.com/src/components/domain/index.ts index f90fbb34..35134ca6 100644 --- a/services/ahhachul.com/src/components/domain/index.ts +++ b/services/ahhachul.com/src/components/domain/index.ts @@ -2,3 +2,4 @@ export * as AuthComponent from './auth'; export * as HomeComponent from './home'; export * as LostFoundComponent from './lostFound'; export * as CommunityComponent from './community'; +export * as ComplaintComponent from './complaint'; diff --git a/services/ahhachul.com/src/components/domain/lostFound/searchResults/skeleton/SearchedList.skeleton.styled.tsx b/services/ahhachul.com/src/components/domain/lostFound/searchResults/skeleton/SearchedList.skeleton.styled.tsx index 6f557d4c..e6106fdf 100644 --- a/services/ahhachul.com/src/components/domain/lostFound/searchResults/skeleton/SearchedList.skeleton.styled.tsx +++ b/services/ahhachul.com/src/components/domain/lostFound/searchResults/skeleton/SearchedList.skeleton.styled.tsx @@ -8,7 +8,7 @@ interface SectionWrapperProps { } export const SectionWrapper = styled.section` padding-top: 99px; - transform: ${({ isScale }) => (isScale ? 'translateY(-50px)' : 'translateY(0)')} + transform: ${({ isScale }) => (isScale ? 'translateY(-50px)' : 'translateY(0)')}; opacity: 0; animation: fadeIn 0.5s ease-in-out forwards; diff --git a/services/ahhachul.com/src/constants/community.ts b/services/ahhachul.com/src/constants/community.ts index cea5ac59..bc12444a 100644 --- a/services/ahhachul.com/src/constants/community.ts +++ b/services/ahhachul.com/src/constants/community.ts @@ -13,12 +13,12 @@ export const defaultCommunityFilterValues = { export const communityTypeOptions = { [CommunityType.HOT]: '인기', [CommunityType.FREE]: '자유', - [CommunityType.HUMOR]: '유머', + [CommunityType.ISSUE]: '이슈', [CommunityType.INSIGHT]: '정보', } as const; export const communityTypeFormOptions = { [CommunityType.FREE]: '자유', - [CommunityType.HUMOR]: '유머', + [CommunityType.ISSUE]: '이슈', [CommunityType.INSIGHT]: '정보', } as const; diff --git a/services/ahhachul.com/src/constants/complaint.tsx b/services/ahhachul.com/src/constants/complaint.tsx index 5e71b01f..10c6f287 100644 --- a/services/ahhachul.com/src/constants/complaint.tsx +++ b/services/ahhachul.com/src/constants/complaint.tsx @@ -1,8 +1,18 @@ +import { + ArrowMiniIcon, + EmergencyIcon, + HitIcon, + MetroIcon, + TreeIcon, +} from '@/assets/icons/complaint'; +import { SubwayLineFilterOptions } from '@/types'; +import type { ComplaintType } from '@/types/complaint'; + export const complaintsContentList = { ENVIRONMENTAL_COMPLAINT: { label: '환경민원', desc: '토사물, 오물, 환기', - // icon: <환경Icon />, + icon: , }, TEMPERATURE_CONTROL: { label: '온도조절', @@ -12,26 +22,100 @@ export const complaintsContentList = { DISORDER: { label: '질서저해', desc: '취객, 노숙, 구걸 등', - // icon: <질서Icon />, + icon: , }, ANNOUNCEMENT: { label: '안내방송', desc: '방송불량, 음량 조절까지 한 번에', - // icon: <화살표Icon />, + icon: , }, EMERGENCY_PATIENT: { label: '응급환자', desc: '환자 긴급 신고', - // icon: <응급Icon />, + icon: , }, VIOLENCE: { label: '폭력', desc: '열차 내 폭행신고', - // icon: <폭력Icon />, + icon: , }, SEXUAL_HARASSMENT: { label: '성추행', desc: '열차 내 성폭력, 몰래카메라', - // icon: <화살표Icon />, + icon: , }, }; + +export const complaintsContentDetail = { + ENVIRONMENTAL_COMPLAINT: { + title: '민원유형 선택', + selectList: { + WASTE: '오물', + VOMIT: '토사물', + VENTILATION_REQUEST: '환기요청', + }, + }, + TEMPERATURE_CONTROL: { + title: '온도조절', + selectList: { + TOO_HOT: '더워요', + TOO_COLD: '추워요', + }, + }, + DISORDER: { + title: '민원유형 선택', + selectList: { + MOBILE_VENDOR: '이동상인', + DRUNK: '취객', + HOMELESS: '노숙', + BEGGING: '구걸', + RELIGIOUS_ACTIVITY: '종교행위', + }, + }, + ANNOUNCEMENT: { + title: '안내방송', + selectList: { + NOISY: '시끄러워요', + NOT_HEARD: '안들려요', + }, + }, + EMERGENCY_PATIENT: { + title: '응급환자와 어떤 관계이신가요?', + selectList: { + SELF: '본인', + WITNESS: '목격자', + }, + }, + VIOLENCE: { + title: '폭력', + selectList: { + VICTIM: '피해자', + WITNESS: '목격자', + }, + }, + SEXUAL_HARASSMENT: { + title: '성추행', + selectList: { + VICTIM: '피해자', + WITNESS: '목격자', + }, + }, +} as const; + +export const compalintFilterKeys = { + subwayLineId: 'subwayLineId', +} as const; + +export const defaultComplaintFilterValues = { + subwayLineId: SubwayLineFilterOptions.ALL_LINES, +} as const; + +export const complaintTypeOptions: Record = { + ENVIRONMENTAL_COMPLAINT: '환경민원', + TEMPERATURE_CONTROL: '온도조절', + DISORDER: '질서저해', + ANNOUNCEMENT: '안내방송', + EMERGENCY_PATIENT: '응급환자', + VIOLENCE: '폭력', + SEXUAL_HARASSMENT: '성추행', +}; diff --git a/services/ahhachul.com/src/constants/filter.ts b/services/ahhachul.com/src/constants/filter.ts index 4517a1ed..964d9ec8 100644 --- a/services/ahhachul.com/src/constants/filter.ts +++ b/services/ahhachul.com/src/constants/filter.ts @@ -1,6 +1,7 @@ -import { AppUniqueFilterId } from '@/types/filter'; +import type { AppUniqueFilterId } from '@/types/filter'; export const APP_UNIQUE_FILTER_ID_LIST: Record = { CommunityPage: 'CommunityPage', LostFoundPage: 'LostFoundPage', + ComplaintListPage: 'ComplaintListPage', } as const; diff --git a/services/ahhachul.com/src/hooks/domain/complaint/index.ts b/services/ahhachul.com/src/hooks/domain/complaint/index.ts new file mode 100644 index 00000000..79489241 --- /dev/null +++ b/services/ahhachul.com/src/hooks/domain/complaint/index.ts @@ -0,0 +1,2 @@ +export { default as useComplaintForm } from './useComplaintForm'; +export { default as useComplaintFilters } from './useComplintFilterStore'; diff --git a/services/ahhachul.com/src/hooks/domain/complaint/useComplaintForm.ts b/services/ahhachul.com/src/hooks/domain/complaint/useComplaintForm.ts new file mode 100644 index 00000000..0a6bb9b1 --- /dev/null +++ b/services/ahhachul.com/src/hooks/domain/complaint/useComplaintForm.ts @@ -0,0 +1,78 @@ +import { useCallback } from 'react'; +import { useForm } from 'react-hook-form'; + +import { complaintsContentDetail } from '@/constants'; +import { useCreateComplaint } from '@/services/complaint'; +import { KeyOf } from '@/types'; +import type { ComplaintForm } from '@/types/complaint'; +import { validateLexicalContent } from '@/utils/lexical'; + +const useComplaintForm = (slug: KeyOf) => { + const { mutate: createComplaintArticle, isPending } = useCreateComplaint(); + + const methods = useForm({ + mode: 'onBlur', + defaultValues: { + title: '', + content: '', + images: [], + subwayLineId: 1, + complaintType: slug, + }, + }); + + const images = methods.watch('images'); + + const validateContent = useCallback( + (content: string) => validateLexicalContent(content, methods.setError), + [methods.setError], + ); + + const handleImageUpload = useCallback( + (files: File[]) => { + const fileBlob = files[0]; + if (!fileBlob) return; + + const newImages = [...images, ...files].slice(0, 5); + + methods.setValue('images', newImages, { shouldDirty: true }); + }, + [methods.setValue, images], + ); + + const handleImageDelete = useCallback( + (index: number) => { + const targetImage = images[index]; + if (!targetImage) return; + + methods.setValue( + 'images', + images.filter((_, i) => i !== index), + { shouldDirty: true }, + ); + }, + [images, methods.setValue], + ); + + const onSubmit = useCallback( + (data: ComplaintForm) => { + if (!validateContent(data.content)) return; + createComplaintArticle(data); + }, + [createComplaintArticle, validateContent], + ); + + const onError = useCallback(() => { + validateContent(methods.getValues('content')); + }, [methods.getValues, validateContent]); + + return { + methods, + isPending, + handleImageUpload, + handleImageDelete, + submit: methods.handleSubmit(onSubmit, onError), + }; +}; + +export default useComplaintForm; diff --git a/services/ahhachul.com/src/hooks/domain/complaint/useComplintFilterStore.ts b/services/ahhachul.com/src/hooks/domain/complaint/useComplintFilterStore.ts new file mode 100644 index 00000000..c9e65b33 --- /dev/null +++ b/services/ahhachul.com/src/hooks/domain/complaint/useComplintFilterStore.ts @@ -0,0 +1,37 @@ +import { defaultComplaintFilterValues } from '@/constants'; +import { APP_UNIQUE_FILTER_ID_LIST } from '@/constants/filter'; +import { useActivity } from '@/stackflow'; +import { filterStore } from '@/stores'; +import type { IFilterState } from '@/stores/filter'; +import type { ComplaintFilters } from '@/types/complaint'; + +const useComplintFilters = () => { + const { + params: { keyword = '' }, + } = useActivity(); + + const { filters, loaded, activatedCount, handleSelect, handleReset } = + filterStore( + defaultComplaintFilterValues, + APP_UNIQUE_FILTER_ID_LIST.ComplaintListPage, + )(); + + const boundaryKeys = [...Object.values(filters), keyword]; + + const getFilterProps = (): Omit, 'loaded'> => ({ + filters, + activatedCount, + handleSelect, + handleReset, + }); + + return { + loaded, + filters, + keyword, + boundaryKeys, + getFilterProps, + }; +}; + +export default useComplintFilters; diff --git a/services/ahhachul.com/src/hooks/domain/index.ts b/services/ahhachul.com/src/hooks/domain/index.ts index e13cab9c..20d8a4a6 100644 --- a/services/ahhachul.com/src/hooks/domain/index.ts +++ b/services/ahhachul.com/src/hooks/domain/index.ts @@ -1,2 +1,4 @@ export * from './my'; export * from './lostFound'; +export * from './community'; +export * from './complaint'; diff --git a/services/ahhachul.com/src/pages/auth/callback.tsx b/services/ahhachul.com/src/pages/auth/callback.tsx index 29fc7623..2f42b7b2 100644 --- a/services/ahhachul.com/src/pages/auth/callback.tsx +++ b/services/ahhachul.com/src/pages/auth/callback.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/prop-types */ import { useEffect } from 'react'; import type { ActivityComponentType } from '@stackflow/react'; diff --git a/services/ahhachul.com/src/pages/community/[id]/edit.tsx b/services/ahhachul.com/src/pages/community/[id]/edit.tsx index 6ba32fb3..6bfd7c33 100644 --- a/services/ahhachul.com/src/pages/community/[id]/edit.tsx +++ b/services/ahhachul.com/src/pages/community/[id]/edit.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/prop-types */ import type { ActivityComponentType } from '@stackflow/react'; import { LayoutComponent, CommunityComponent, UiComponent } from '@/components'; diff --git a/services/ahhachul.com/src/pages/community/[id]/page.tsx b/services/ahhachul.com/src/pages/community/[id]/page.tsx index 63485929..7bc10c22 100644 --- a/services/ahhachul.com/src/pages/community/[id]/page.tsx +++ b/services/ahhachul.com/src/pages/community/[id]/page.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/prop-types */ import { type ActivityComponentType } from '@stackflow/react'; import { LayoutComponent, UiComponent, CommunityComponent } from '@/components'; diff --git a/services/ahhachul.com/src/pages/complaint/[id]/page.tsx b/services/ahhachul.com/src/pages/complaint/[id]/page.tsx index ac2b4120..1d9202c5 100644 --- a/services/ahhachul.com/src/pages/complaint/[id]/page.tsx +++ b/services/ahhachul.com/src/pages/complaint/[id]/page.tsx @@ -1,7 +1,20 @@ -import { LayoutComponent } from '@/components'; +import { type ActivityComponentType } from '@stackflow/react'; -const ComplaintDetailPage = () => { - return ComplaintDetailPage; +import { LayoutComponent, LostFoundComponent, UiComponent } from '@/components'; +import type { WithPostId } from '@/types'; + +const ComplaintDetailPage: ActivityComponentType = ({ params: { id } }) => { + return ( + + } + errorFallback={props => } + > + + + + ); }; export default ComplaintDetailPage; diff --git a/services/ahhachul.com/src/pages/complaint/list.tsx b/services/ahhachul.com/src/pages/complaint/list.tsx index b7e14c28..51c9b5fa 100644 --- a/services/ahhachul.com/src/pages/complaint/list.tsx +++ b/services/ahhachul.com/src/pages/complaint/list.tsx @@ -1,7 +1,45 @@ -import { LayoutComponent } from '@/components'; +import { useReducer } from 'react'; -const ComplaintListPage = () => { - return ComplaintListPage; +import type { ActivityComponentType } from '@stackflow/react'; + +import { HeaderComponent, LayoutComponent, UiComponent } from '@/components'; +import { ComplaintComponent } from '@/components/domain'; +import { useComplaintFilters } from '@/hooks/domain'; + +const ComplaintListPage: ActivityComponentType = () => { + const [isScale, toggleScale] = useReducer(scale => !scale, false); + + const { loaded, keyword, filters, boundaryKeys, getFilterProps } = useComplaintFilters(); + + if (!loaded) { + return ; + } + + return ( + + } + > + } + suspenseFallback={} + > + + + + + + ); }; export default ComplaintListPage; diff --git a/services/ahhachul.com/src/pages/complaint/new.tsx b/services/ahhachul.com/src/pages/complaint/new.tsx index 8c59390d..91b92095 100644 --- a/services/ahhachul.com/src/pages/complaint/new.tsx +++ b/services/ahhachul.com/src/pages/complaint/new.tsx @@ -1,7 +1,62 @@ -import { LayoutComponent } from '@/components'; +import { FormProvider } from 'react-hook-form'; -const NewComplaintPage = () => { - return NewComplaintPage; +import styled from '@emotion/styled'; +import type { ActivityComponentType } from '@stackflow/react'; + +import { LayoutComponent, FormComponent } from '@/components'; +import { complaintsContentDetail } from '@/constants'; +import useComplaintForm from '@/hooks/domain/complaint/useComplaintForm'; +import { useActivity } from '@/stackflow'; +import { mixins } from '@/styles'; +import { KeyOf } from '@/types'; + +interface ComplaintFormProps { + slug: KeyOf; +} + +const NewComplaintPage: ActivityComponentType = ({ params: { slug } }) => { + const information = complaintsContentDetail[slug]; + + const { isActive } = useActivity(); + + const { methods, isPending, handleImageUpload, handleImageDelete, submit } = + useComplaintForm(slug); + + return ( + + + + + + + + + + + + + ); +}; + +const S = { + FormContainer: styled.div` + ${mixins.fullWidth}; + ${mixins.flexColumn}; + ${mixins.pagePaddingTop}; + ${mixins.pagePaddingBottom}; + `, }; export default NewComplaintPage; diff --git a/services/ahhachul.com/src/pages/complaint/page.tsx b/services/ahhachul.com/src/pages/complaint/page.tsx index 0950ed0a..30d33e6e 100644 --- a/services/ahhachul.com/src/pages/complaint/page.tsx +++ b/services/ahhachul.com/src/pages/complaint/page.tsx @@ -1,6 +1,7 @@ import { ActivityComponentType } from '@stackflow/react'; -import { HeaderComponent, LayoutComponent } from '@/components'; +import { HeaderComponent, LayoutComponent, UiComponent } from '@/components'; +import { ComplaintPanel } from '@/components/domain/complaint'; const ComplaintPage: ActivityComponentType = () => { return ( @@ -11,8 +12,13 @@ const ComplaintPage: ActivityComponentType = () => { renderRight: HeaderComponent.HeaderActions, }} > - {/* */} - ComplaintPage + + ); }; diff --git a/services/ahhachul.com/src/pages/lostFound/[id]/edit.tsx b/services/ahhachul.com/src/pages/lostFound/[id]/edit.tsx index 3f6e66ea..6eaaba07 100644 --- a/services/ahhachul.com/src/pages/lostFound/[id]/edit.tsx +++ b/services/ahhachul.com/src/pages/lostFound/[id]/edit.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/prop-types */ import type { ActivityComponentType } from '@stackflow/react'; import { LayoutComponent, LostFoundComponent, UiComponent } from '@/components'; diff --git a/services/ahhachul.com/src/pages/lostFound/[id]/page.tsx b/services/ahhachul.com/src/pages/lostFound/[id]/page.tsx index 6ef1cbeb..ad138e17 100644 --- a/services/ahhachul.com/src/pages/lostFound/[id]/page.tsx +++ b/services/ahhachul.com/src/pages/lostFound/[id]/page.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/prop-types */ import { type ActivityComponentType } from '@stackflow/react'; import { LayoutComponent, LostFoundComponent, UiComponent } from '@/components'; diff --git a/services/ahhachul.com/src/services/complaint.ts b/services/ahhachul.com/src/services/complaint.ts index cb0ff5c3..67cd12be 100644 --- a/services/ahhachul.com/src/services/complaint.ts +++ b/services/ahhachul.com/src/services/complaint.ts @@ -1 +1,90 @@ -export {}; +import { + useMutation, + useQueryClient, + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from '@tanstack/react-query'; + +import { removeFalsyValues } from '@ahhachul/utils'; + +import * as api from '@/apis/request'; +import { TIMESTAMP } from '@/constants'; +import { useFlow } from '@/stackflow'; +import { SubwayLineFilterOptions } from '@/types'; +import type { ComplaintForm, ComplaintListParams } from '@/types/complaint'; +import { formatSubwayFilterOption } from '@/utils'; + +export const complaintKeys = { + all: ['complaint'] as const, + lists: () => [...complaintKeys.all, 'list'] as const, + list: (filters: (string | number)[]) => [...complaintKeys.lists(), ...filters] as const, + details: () => [...complaintKeys.all, 'detail'] as const, + detail: (id: number) => [...complaintKeys.details(), id] as const, + comments(id: number) { + return [...this.detail(id), 'comment-list'] as const; + }, +}; + +export const useFetchComplaintList = (filters: ComplaintListParams) => { + const favoriteLine = 3; + const req = removeFalsyValues( + { + keyword: filters.keyword, + subwayLineId: formatSubwayFilterOption(filters.subwayLineId, favoriteLine), + }, + { removeZero: true, removeEmptyStrings: true }, + ) as ComplaintListParams; + + return useSuspenseInfiniteQuery({ + initialPageParam: '', + queryKey: complaintKeys.list(Object.values(req)), + queryFn: ({ pageParam = filters.pageToken }) => + api.fetchComplaintList({ + ...req, + ...(pageParam && { pageToken: pageParam }), + }), + getNextPageParam: lastPage => lastPage.result.pageToken, + }); +}; + +export const useCreateComplaint = () => { + const { pop, push } = useFlow(); + // const { addToast } = useToast(); + + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (req: ComplaintForm) => api.createComplaint(req), + onSuccess: res => { + pop(); + + queryClient.invalidateQueries({ + queryKey: complaintKeys.lists(), + }); + setTimeout(() => { + push('ComplaintDetailPage', { + id: res.result.id, + }); + }, 500); + }, + onError: () => { + // addToast(TOAST_MSG.WARNING.CREATE_FAIL); + }, + }); +}; + +export const useFetchComplaintDetail = (id: number) => + useSuspenseQuery({ + queryKey: complaintKeys.detail(id), + queryFn: () => api.fetchComplaintDetail(id), + staleTime: 5 * TIMESTAMP.MINUTE, // 5분 + select: res => res.data.result, + }); + +export const useFetchComplaintCommentList = (id: number) => + useSuspenseQuery({ + queryKey: complaintKeys.comments(id), + queryFn: () => api.fetchComplaintCommentList(id), + staleTime: 5 * TIMESTAMP.MINUTE, //5분 + select: res => res.data.result, + }); diff --git a/services/ahhachul.com/src/types/community.ts b/services/ahhachul.com/src/types/community.ts index 2b6d8fc9..eba96d13 100644 --- a/services/ahhachul.com/src/types/community.ts +++ b/services/ahhachul.com/src/types/community.ts @@ -11,7 +11,7 @@ import type { export enum CommunityType { HOT = 'HOT', FREE = 'FREE', - HUMOR = 'HUMOR', + ISSUE = 'ISSUE', INSIGHT = 'INSIGHT', } diff --git a/services/ahhachul.com/src/types/complaint.ts b/services/ahhachul.com/src/types/complaint.ts new file mode 100644 index 00000000..99cf19f8 --- /dev/null +++ b/services/ahhachul.com/src/types/complaint.ts @@ -0,0 +1,66 @@ +import type { Post, CursorPagination, PostImage, SubwayLineFilterOptions } from './common'; + +export type ComplaintType = + | 'ENVIRONMENTAL_COMPLAINT' + | 'TEMPERATURE_CONTROL' + | 'DISORDER' + | 'ANNOUNCEMENT' + | 'EMERGENCY_PATIENT' + | 'VIOLENCE' + | 'SEXUAL_HARASSMENT'; + +export type ShortComplaintType = + | 'WASTE' + | 'VOMIT' + | 'VENTILATION_REQUEST' + | 'NOISY' + | 'NOT_HEARD' + | 'TOO_HOT' + | 'TOO_COLD' + | 'MOBILE_VENDOR' + | 'DRUNK' + | 'HOMELESS' + | 'BEGGING' + | 'RELIGIOUS_ACTIVITY' + | 'SELF' + | 'WITNESS' + | 'VICTIM'; + +export type ComplaintStatus = 'CREATED' | 'DONE'; + +export interface ComplaintPost extends Post { + complaintType: ComplaintType; + shortContentType: ShortComplaintType; + trainNo: string; + phoneNumber: string; + location: number; + status: ComplaintStatus; +} + +export interface ComplaintPostDetail extends ComplaintPost { + images: PostImage[]; +} + +export interface ComplaintListParams extends Partial { + subwayLineId: TSubwayLine; + keyword?: string; +} + +export interface ComplaintForm { + title: string; + content: string; + subwayLineId: number; + complaintType: ComplaintType; + shortContentType: ShortComplaintType; + images: File[]; +} + +export type ComplaintFilterKeys = 'subwayLineId'; + +export type ComplaintFilterValues = { + subwayLineId: SubwayLineFilterOptions; +}; + +export type ComplaintFilters = { + [K in ComplaintFilterKeys]: ComplaintFilterValues[K]; +}; diff --git a/services/ahhachul.com/src/types/filter.ts b/services/ahhachul.com/src/types/filter.ts index 07e4c2be..70440340 100644 --- a/services/ahhachul.com/src/types/filter.ts +++ b/services/ahhachul.com/src/types/filter.ts @@ -2,4 +2,7 @@ import type { TypeActivities } from '@/stackflow'; import type { KeyOf } from './common'; -export type AppUniqueFilterId = Extract, 'CommunityPage' | 'LostFoundPage'>; +export type AppUniqueFilterId = Extract< + KeyOf, + 'CommunityPage' | 'LostFoundPage' | 'ComplaintListPage' +>; diff --git a/services/ahhachul.com/vite.config.ts b/services/ahhachul.com/vite.config.ts index bb5256de..abecf36f 100644 --- a/services/ahhachul.com/vite.config.ts +++ b/services/ahhachul.com/vite.config.ts @@ -34,7 +34,6 @@ export default defineConfig({ 'sortAttrs', 'removeXMLProcInst', 'removeXMLNS', - 'removeDimensions', 'minifyStyles', 'removeComments', 'removeHiddenElems', @@ -43,12 +42,6 @@ export default defineConfig({ 'removeEmptyContainers', 'collapseGroups', 'removeMetadata', - { - name: 'convertPathData', - params: { - floatPrecision: 2, - }, - }, { name: 'addAttributesToSVGElement', params: { diff --git a/services/one-app/src/app/(main-service)/lost-found/[id]/_lib/getDetailPostServer.ts b/services/one-app/src/app/(main-service)/lost-found/[id]/_lib/getDetailPostServer.ts index 12d6da0a..eb86b64b 100644 --- a/services/one-app/src/app/(main-service)/lost-found/[id]/_lib/getDetailPostServer.ts +++ b/services/one-app/src/app/(main-service)/lost-found/[id]/_lib/getDetailPostServer.ts @@ -12,6 +12,7 @@ export const getLostFoundDetailPostServer = async ({ revalidate: 3600, tags: ['lost-found-post', id.toString()], }, + cache: 'force-cache', credentials: 'include', headers: { Cookie: (await cookies()).toString() }, });