diff --git a/client/src/apis/request/event.ts b/client/src/apis/request/event.ts index 71bec397..be767bb5 100644 --- a/client/src/apis/request/event.ts +++ b/client/src/apis/request/event.ts @@ -1,8 +1,8 @@ import {BankAccount, CreatedEvents, Event, EventCreationData, EventName} from 'types/serviceType'; import {WithErrorHandlingStrategy} from '@errors/RequestGetError'; -import {ADMIN_API_PREFIX, MEMBER_API_PREFIX} from '@apis/endpointPrefix'; -import {requestGet, requestPatchWithoutResponse, requestPostWithResponse} from '@apis/request'; +import {ADMIN_API_PREFIX, MEMBER_API_PREFIX, USER_API_PREFIX} from '@apis/endpointPrefix'; +import {requestDelete, requestGet, requestPatchWithoutResponse, requestPostWithResponse} from '@apis/request'; import {WithEventId} from '@apis/withId.type'; export const requestPostGuestEvent = async (postEventArgs: EventCreationData) => { @@ -52,3 +52,14 @@ export const requestGetCreatedEvents = async () => { endpoint: `${MEMBER_API_PREFIX}/mine`, }); }; + +type DeleteEvents = { + eventIds: string[]; +}; + +export const requestDeleteEvents = async (args: DeleteEvents) => { + await requestDelete({ + endpoint: MEMBER_API_PREFIX, + body: args, + }); +}; diff --git a/client/src/components/CreatedEventList/index.tsx b/client/src/components/CreatedEventList/index.tsx new file mode 100644 index 00000000..08a4fe96 --- /dev/null +++ b/client/src/components/CreatedEventList/index.tsx @@ -0,0 +1,59 @@ +import useRequestDeleteEvents from '@hooks/queries/event/useRequestDeleteEvents'; +import {useCreatedEventsPageContext} from '@pages/mypage/events/CreatedEvent.context'; +import {CreatedEvent} from 'types/serviceType'; +import {CreatedEventItem} from '@components/Design/components/CreatedEvent/CreatedEvent'; + +import {FixedButton, Flex, Input} from '@components/Design'; + +type CreatedEventListProps = { + eventName: string; + onSearch: ({target}: React.ChangeEvent) => void; + placeholder: string; + createdEvents: CreatedEvent[]; +}; + +export const CreatedEventList = ({createdEvents, eventName, onSearch, placeholder}: CreatedEventListProps) => { + const {mode, handleMode, selectedEvents, isAlreadySelected, handleSelectedEvents} = useCreatedEventsPageContext(); + const setViewMode = () => handleMode('view'); + + const {deleteEvents} = useRequestDeleteEvents(); + + const onDeleteClick = async () => { + const selectedEventsId = selectedEvents.map(event => event.eventId); + await deleteEvents({eventIds: selectedEventsId}); + handleMode('view'); + }; + + return ( + <> + + + {createdEvents.length !== 0 && + createdEvents.map(createdEvent => ( + handleMode('edit')} + isChecked={isAlreadySelected(createdEvent)} + onChange={handleSelectedEvents} + createdEvent={createdEvent} + /> + ))} + + {mode === 'edit' && ( + + 편집완료 + + )} + + ); +}; diff --git a/client/src/components/Design/components/Checkbox/Checkbox.tsx b/client/src/components/Design/components/Checkbox/Checkbox.tsx index 4395891d..62b81ddd 100644 --- a/client/src/components/Design/components/Checkbox/Checkbox.tsx +++ b/client/src/components/Design/components/Checkbox/Checkbox.tsx @@ -7,7 +7,7 @@ import Icon from '../Icon/Icon'; import {checkboxStyle, inputGroupStyle} from './Checkbox.style'; interface Props { - labelText: string; + labelText?: string; isChecked: boolean; onChange: () => void; } @@ -20,7 +20,7 @@ const Checkbox = ({labelText, isChecked = false, onChange}: Props) => { {isChecked ? : null} - {labelText} + {labelText && {labelText}} ); }; diff --git a/client/src/components/Design/components/CreatedEvent/CreatedEvent.style.ts b/client/src/components/Design/components/CreatedEvent/CreatedEvent.style.ts index 716aba32..d14dcae5 100644 --- a/client/src/components/Design/components/CreatedEvent/CreatedEvent.style.ts +++ b/client/src/components/Design/components/CreatedEvent/CreatedEvent.style.ts @@ -17,3 +17,10 @@ export const inProgressCheckStyle = ({inProgress, theme}: WithTheme<{inProgress: paddingTop: '0.0625rem', }, }); + +export const touchAreaStyle = css({ + position: 'relative', + overflow: 'hidden', + + width: '100%', +}); diff --git a/client/src/components/Design/components/CreatedEvent/CreatedEvent.tsx b/client/src/components/Design/components/CreatedEvent/CreatedEvent.tsx index 9454fc63..f038f4fe 100644 --- a/client/src/components/Design/components/CreatedEvent/CreatedEvent.tsx +++ b/client/src/components/Design/components/CreatedEvent/CreatedEvent.tsx @@ -2,14 +2,15 @@ import {useNavigate} from 'react-router-dom'; import Text from '@HDcomponents/Text/Text'; +import {CreatedEvent} from 'types/serviceType'; -import {useTheme} from '@components/Design'; +import useLongPressAnimation from '@hooks/useLongPressAnimation'; + +import {Checkbox, useTheme} from '@components/Design'; import Flex from '../Flex/Flex'; -import Input from '../Input/Input'; -import {CreatedEventItemProps, CreatedEventListProps} from './CreatedEvent.type'; -import {inProgressCheckStyle} from './CreatedEvent.style'; +import {inProgressCheckStyle, touchAreaStyle} from './CreatedEvent.style'; function InProgressCheck({inProgress}: {inProgress: boolean}) { const {theme} = useTheme(); @@ -23,48 +24,52 @@ function InProgressCheck({inProgress}: {inProgress: boolean}) { ); } -function CreatedEventItem({createdEvent}: CreatedEventItemProps) { +export interface CreatedEventItemProps { + setEditMode: () => void; + isEditMode: boolean; + isChecked: boolean; + onChange: (event: CreatedEvent) => void; + createdEvent: CreatedEvent; +} + +export function CreatedEventItem({isEditMode, setEditMode, isChecked, onChange, createdEvent}: CreatedEventItemProps) { const navigate = useNavigate(); + + const onLongPress = () => { + setEditMode(); + if (!isChecked) onChange(createdEvent); + }; + + const {handleTouchStart, handleTouchEnd, handleTouchMove} = useLongPressAnimation(onLongPress, { + disabled: isEditMode, + }); + const onClick = () => { - navigate(`/event/${createdEvent.eventId}/admin`); + isEditMode ? onChange(createdEvent) : navigate(`/event/${createdEvent.eventId}/admin`); }; return ( - - - - - {createdEvent.eventName} - + + {isEditMode && onChange(createdEvent)} />} + + + + + {createdEvent.eventName} + + ); } - -function CreatedEventList({createdEvents, eventName, onSearch, placeholder}: CreatedEventListProps) { - return ( - - - {createdEvents.length !== 0 && - createdEvents.map(createdEvent => )} - - ); -} - -export default CreatedEventList; diff --git a/client/src/components/Design/components/CreatedEvent/CreatedEvent.type.ts b/client/src/components/Design/components/CreatedEvent/CreatedEvent.type.ts deleted file mode 100644 index 2102fbd8..00000000 --- a/client/src/components/Design/components/CreatedEvent/CreatedEvent.type.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {CreatedEvent} from './../../../../types/serviceType'; - -export interface CreatedEventItemProps { - createdEvent: CreatedEvent; -} - -export interface CreatedEventListProps { - eventName: string; - onSearch: ({target}: React.ChangeEvent) => void; - placeholder: string; - createdEvents: CreatedEvent[]; -} diff --git a/client/src/hooks/queries/event/useRequestDeleteEvents.ts b/client/src/hooks/queries/event/useRequestDeleteEvents.ts new file mode 100644 index 00000000..4efa7381 --- /dev/null +++ b/client/src/hooks/queries/event/useRequestDeleteEvents.ts @@ -0,0 +1,24 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; + +import {requestDeleteEvents} from '@apis/request/event'; +import toast from '@hooks/useToast/toast'; + +import QUERY_KEYS from '@constants/queryKeys'; + +const useRequestDeleteEvents = () => { + const queryClient = useQueryClient(); + + const {mutateAsync} = useMutation({ + mutationFn: requestDeleteEvents, + onSuccess: () => { + toast.confirm('행사가 정상적으로 삭제되었습니다'); + queryClient.invalidateQueries({queryKey: [QUERY_KEYS.createdEvents]}); + }, + }); + + return { + deleteEvents: mutateAsync, + }; +}; + +export default useRequestDeleteEvents; diff --git a/client/src/hooks/queries/event/useRequestGetCreatedEvents.ts b/client/src/hooks/queries/event/useRequestGetCreatedEvents.ts index f32a24ed..6f5c1e56 100644 --- a/client/src/hooks/queries/event/useRequestGetCreatedEvents.ts +++ b/client/src/hooks/queries/event/useRequestGetCreatedEvents.ts @@ -1,16 +1,25 @@ import {useQuery} from '@tanstack/react-query'; import {requestGetCreatedEvents} from '@apis/request/event'; +import {CreatedEvent} from 'types/serviceType'; import QUERY_KEYS from '@constants/queryKeys'; const useRequestGetCreatedEvents = () => { + const sortByCreatedAndFinishedStatus = (a: CreatedEvent, b: CreatedEvent) => { + if (a.isFinished !== b.isFinished) { + return a.isFinished ? 1 : -1; + } + + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }; + const {data, ...rest} = useQuery({ queryKey: [QUERY_KEYS.createdEvents], queryFn: () => requestGetCreatedEvents(), select: data => ({ ...data, - events: data.events.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()), + events: data.events.sort(sortByCreatedAndFinishedStatus), }), }); diff --git a/client/src/hooks/useAdminPage.ts b/client/src/hooks/useAdminPage.ts index 1fd1e7ba..db9165d1 100644 --- a/client/src/hooks/useAdminPage.ts +++ b/client/src/hooks/useAdminPage.ts @@ -8,7 +8,7 @@ import useEventDataContext from './useEventDataContext'; const useAdminPage = () => { const eventId = getEventIdByUrl(); - const {isAdmin, bankName, accountNumber, eventName} = useEventDataContext(); + const {isAdmin, bankName, accountNumber, eventName, createdByGuest} = useEventDataContext(); const {totalExpenseAmount} = useTotalExpenseAmountStore(); @@ -26,6 +26,7 @@ const useAdminPage = () => { isAdmin, eventName, bankName, + createdByGuest, accountNumber, totalExpenseAmount, isShowAccountBanner, diff --git a/client/src/hooks/useLongPressAnimation.tsx b/client/src/hooks/useLongPressAnimation.tsx new file mode 100644 index 00000000..fbf35fb1 --- /dev/null +++ b/client/src/hooks/useLongPressAnimation.tsx @@ -0,0 +1,105 @@ +import {css, keyframes} from '@emotion/react'; +import {useMemo, useRef, useState} from 'react'; + +import {WithTheme} from '@components/Design/type/withTheme'; +import {ColorKeys} from '@components/Design/token/colors'; + +import {useTheme} from '@components/Design'; + +export type AnimationCoordinate = { + x: number; + y: number; + size: number; +}; + +const animationFrame = keyframes` + from { + transform: scale(0); + } + to { + transform: scale(1); + opacity: 0; + } +`; + +const defaultAnimationColor: ColorKeys = 'grayContainer'; +const defaultLongPressTime = 500; + +const animationStyle = ({ + theme, + x, + y, + size, + animationColor = defaultAnimationColor, + longProgressTime = defaultLongPressTime, +}: WithTheme) => { + const remSize = size / 16; + const remX = x / 16; + const remY = y / 16; + const animationTime = longProgressTime / 1000; + + return css({ + position: 'absolute', + top: `${remY - remSize / 2}rem`, + left: `${remX - remSize / 2}rem`, + width: `${remSize}rem`, + height: `${remSize}rem`, + background: theme.colors[animationColor], + borderRadius: `50%`, + transform: 'scale(1)', + animation: `${animationFrame} ${animationTime}s ease-out forwards`, + }); +}; + +type UseLongPressAnimationOptions = { + longProgressTime?: number; + animationColor?: ColorKeys; + disabled?: boolean; +}; + +const useLongPressAnimation = (onLongPress: () => void, options?: UseLongPressAnimationOptions) => { + const {theme} = useTheme(); + const timeoutRef = useRef(null); + const [coordinate, setCoordinate] = useState(null); + + const handleTouchStart = (event: React.TouchEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + const touch = event.touches[0]; + const x = touch.clientX - rect.left; + const y = touch.clientY - rect.top; + + setCoordinate({x, y, size: 0}); + + timeoutRef.current = setTimeout(() => { + onLongPress(); + }, options?.longProgressTime ?? defaultLongPressTime); + }; + + const handleTouchEnd = () => { + clearTimeout(timeoutRef.current!); + setCoordinate(null); + timeoutRef.current = null; + }; + + const LongPressAnimation = useMemo(() => { + return coordinate && !options?.disabled ? ( +
setCoordinate(prev => prev && {...prev, size: 800})} + /> + ) : null; + }, [coordinate]); + + return { + handleTouchStart, + handleTouchMove: handleTouchEnd, + handleTouchEnd, + LongPressAnimation, + }; +}; + +export default useLongPressAnimation; diff --git a/client/src/pages/event/[eventId]/admin/AdminPage.tsx b/client/src/pages/event/[eventId]/admin/AdminPage.tsx index 8318baac..ecfe336e 100644 --- a/client/src/pages/event/[eventId]/admin/AdminPage.tsx +++ b/client/src/pages/event/[eventId]/admin/AdminPage.tsx @@ -2,17 +2,21 @@ import {useNavigate} from 'react-router-dom'; import StepList from '@components/StepList/Steps'; import {Banner} from '@components/Design/components/Banner'; +import useRequestDeleteEvents from '@hooks/queries/event/useRequestDeleteEvents'; import useAdminPage from '@hooks/useAdminPage'; import useAmplitude from '@hooks/useAmplitude'; import {Title, Button, Dropdown, DropdownButton} from '@HDesign/index'; +import {ROUTER_URLS} from '@constants/routerUrls'; + import {receiptStyle} from './AdminPage.style'; const AdminPage = () => { const navigate = useNavigate(); const {trackAddBillStart} = useAmplitude(); + const {deleteEvents} = useRequestDeleteEvents(); const { eventId, @@ -20,6 +24,7 @@ const AdminPage = () => { eventName, bankName, accountNumber, + createdByGuest, totalExpenseAmount, isShowAccountBanner, onDeleteAccount, @@ -49,6 +54,16 @@ const AdminPage = () => { navigate(`/event/${eventId}/admin/add-bill`); }; + const deleteEventAndNavigateByUser = async () => { + if (createdByGuest) { + navigate(ROUTER_URLS.main, {replace: true}); + } else { + navigate(ROUTER_URLS.createdEvents, {replace: true}); + } + + await deleteEvents({eventIds: [eventId]}); + }; + return (
{ <DropdownButton text="전체 참여자 관리" onClick={navigateEventMemberManage} /> <DropdownButton text="계좌번호 입력하기" onClick={navigateAccountInputPage} /> <DropdownButton text="사진 첨부하기" onClick={navigateAddImages} /> + <DropdownButton text="행사 삭제하기" onClick={deleteEventAndNavigateByUser} /> </Dropdown> } /> diff --git a/client/src/pages/event/[eventId]/admin/edit-event-name/EditEventNamePage.tsx b/client/src/pages/event/[eventId]/admin/edit-event-name/EditEventNamePage.tsx index 716c024c..93e96467 100644 --- a/client/src/pages/event/[eventId]/admin/edit-event-name/EditEventNamePage.tsx +++ b/client/src/pages/event/[eventId]/admin/edit-event-name/EditEventNamePage.tsx @@ -12,7 +12,6 @@ import getEventBaseUrl from '@utils/getEventBaseUrl'; const EditEventNamePage = () => { const location = useLocation(); const locationState = location.state as EventName | null; - console.log(locationState); const navigate = useNavigate(); diff --git a/client/src/pages/mypage/events/CreatedEvent.context.tsx b/client/src/pages/mypage/events/CreatedEvent.context.tsx new file mode 100644 index 00000000..24e3c0c6 --- /dev/null +++ b/client/src/pages/mypage/events/CreatedEvent.context.tsx @@ -0,0 +1,50 @@ +import {createContext, PropsWithChildren, useContext, useState} from 'react'; + +import {CreatedEvent} from 'types/serviceType'; + +type Mode = 'view' | 'edit'; + +interface SelectedEventContextProps { + mode: Mode; + handleMode: (mode: Mode) => void; + selectedEvents: CreatedEvent[]; + isAlreadySelected: (event: CreatedEvent) => boolean; + handleSelectedEvents: (event: CreatedEvent) => void; +} + +const CreatedEventsPageContext = createContext<SelectedEventContextProps | undefined>(undefined); + +export const useCreatedEventsPageContext = () => { + const context = useContext(CreatedEventsPageContext); + if (!context) { + throw new Error('useCreatedEventsPageContext must be used within an CreatedEventsPageContextProvider'); + } + return context; +}; + +export const CreatedEventsPageContextProvider: React.FC<PropsWithChildren> = ({children}: React.PropsWithChildren) => { + const [mode, setMode] = useState<Mode>('view'); + const [selectedEvents, setSelectedEvents] = useState<CreatedEvent[]>([]); + + const handleMode = (mode: Mode) => setMode(mode); + + const isAlreadySelected = (event: CreatedEvent) => { + return selectedEvents.map(event => event.eventId).includes(event.eventId); + }; + + const handleSelectedEvents = (event: CreatedEvent) => { + if (isAlreadySelected(event)) { + setSelectedEvents(prev => prev.filter(prevEvent => prevEvent.eventId !== event.eventId)); + } else { + setSelectedEvents(prev => [...prev, event]); + } + }; + + return ( + <CreatedEventsPageContext.Provider + value={{mode, handleMode, selectedEvents, isAlreadySelected, handleSelectedEvents}} + > + {children} + </CreatedEventsPageContext.Provider> + ); +}; diff --git a/client/src/pages/mypage/events/CreatedEventsPage.tsx b/client/src/pages/mypage/events/CreatedEventsPage.tsx index aa86c3ee..ff603181 100644 --- a/client/src/pages/mypage/events/CreatedEventsPage.tsx +++ b/client/src/pages/mypage/events/CreatedEventsPage.tsx @@ -1,15 +1,18 @@ import {css} from '@emotion/react'; import {useEffect, useState} from 'react'; -import CreatedEventList from '@components/Design/components/CreatedEvent/CreatedEvent'; import useRequestGetCreatedEvents from '@hooks/queries/event/useRequestGetCreatedEvents'; -import {MainLayout, Top, TopNav} from '@components/Design'; +import {FunnelLayout, MainLayout, TextButton, Top, TopNav} from '@components/Design'; +import {CreatedEventList} from '@components/CreatedEventList'; -export default function CreatedEventsPage() { +import {useCreatedEventsPageContext, CreatedEventsPageContextProvider} from './CreatedEvent.context'; + +const PageInner = () => { const [eventName, setEventName] = useState(''); const {events} = useRequestGetCreatedEvents(); const [matchedEvents, setMatchedEvents] = useState(events); + const {mode, handleMode} = useCreatedEventsPageContext(); useEffect(() => { setMatchedEvents(events?.filter(event => event.eventName.includes(eventName))); @@ -23,20 +26,18 @@ export default function CreatedEventsPage() { <MainLayout backgroundColor="white"> <TopNav> <TopNav.Item displayName="뒤로가기" noEmphasis routePath="-1" /> + {mode === 'view' && ( + <TextButton textColor="gray" textSize="bodyBold" onClick={() => handleMode('edit')}> + 편집하기 + </TextButton> + )} </TopNav> - <div - css={css` - display: flex; - flex-direction: column; - gap: 1rem; - padding: 1rem; - `} - > + <FunnelLayout> <Top> <Top.Line text="지금까지 주최했던 행사를" emphasize={['주최했던 행사']} /> <Top.Line text="확인해 보세요" /> </Top> - </div> + </FunnelLayout> <div css={css` display: flex; @@ -52,4 +53,14 @@ export default function CreatedEventsPage() { </div> </MainLayout> ); +}; + +export default function CreatedEventsPage() { + return ( + <MainLayout backgroundColor="white"> + <CreatedEventsPageContextProvider> + <PageInner /> + </CreatedEventsPageContextProvider> + </MainLayout> + ); }