Skip to content

Commit

Permalink
feat: 회원 행사 생성, 비회원 행사 생성 구현 (#835)
Browse files Browse the repository at this point in the history
* feat: 이벤트 생성에서 반복적으로 사용되는 타입 선언

* refactor: 반복되는 path를 상수로 만들고 이를 재사용함

* feat: 게스트 행사, 회원 행사 생성을 위한 api 연결 함수 구현

* feat: 유저 이름이 올바르지 않을 때 보여줄 에러 메세지 수정

* refactor: 이미 제작된 이름 유효성 검증 로직을 재사용하도록 수정

* feat: 행사 생성 시 관리자 이름을 입력받는 페이지 구현

* feat: 회원 이벤트 생성 과정 퍼널 구현

* refactor: 불필요한 조건문 제거

* feat: 비회원 행사 생성 페이지, 회원 행사 생성 페이지를 라우터에 연결

* refactor: 행사 생성시 사용되는 타입으로 재사용하도록 수정

* feat: 회원 이벤트 생성을 트래킹할 amplitude 함수 구현

* feat: 회원 이벤트 생성 시 행사명만 받으면 바로 행사를 생성하는 플로우로 스텝 페이지 구현

* chore: 행사 생성 과정이 회원 여부로 분기됨에 따라 위치와 파일명 수정

* feat: 행사 생성 시 행사 관리자의 이름을 입력받는 스텝 페이지에서 사용하는 이름 검증 훅 구현

* chore: 행사 생성에 대한 path 경로를 수정

* chore: 사용하지 않게된 파일 제거

* test: 분리 개발로 인해 테스트 수행 불가하므로 테스트코드 주석처리

* rename: nickName -> nickname으로 수정

* feat: 사용하지 않는 return 함수 제거

* feat: 회원 이벤트 생성 완료 버튼을 누를 때 로딩이 되도록 함

* rename: submit->onSubmit으로 form태그에서 사용되는 핸들러이름과 일치시킴

* feat: 행사 생성 시 입력받는 관리자 이름, 비밀번호 스텝 페이지의 문구 변경

* rename: Page -> Step으로 이름 변경

* feat: api에서 사용되는 타입을 serviceType으로 이동

* rename: NickName -> Nickname으로 수정
  • Loading branch information
pakxe authored and jinhokim98 committed Dec 11, 2024
1 parent 839fe4e commit 07401c0
Show file tree
Hide file tree
Showing 25 changed files with 415 additions and 174 deletions.
126 changes: 56 additions & 70 deletions client/cypress/e2e/createEvent.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,74 +8,60 @@ beforeEach(() => {
});

describe('Flow: 랜딩 페이지에서부터 이벤트를 생성 완료하는 flow', () => {
it('랜딩페이지에서 "정산 시작하기" 버튼을 눌러 행사 이름 입력 페이지로 이동해야 한다.', () => {
cy.visit('/');
cy.get('button').contains('정산 시작하기').click();
cy.url().should('include', ROUTER_URLS.login);
cy.get('button').contains('비회원으로 진행하기').click();
cy.url().should('include', ROUTER_URLS.createEvent);
});

context('행사 이름 입력 페이지', () => {
beforeEach(() => {
cy.visit(ROUTER_URLS.createEvent);
});

it('행사 이름 입력 페이지에서 input이 포커싱 되어 있고, "다음" 버튼이 비활성화 되어 있어야 한다.', () => {
cy.get('input').focused();
cy.get('button').contains('다음').should('have.attr', 'disabled');
});

it('행사 이름이 1자 이상 입력된 경우 "다음" 버튼이 활성화 되고, 값이 없는 경우 "다음" 버튼이 비활성화 되어야 한다.', () => {
cy.get('input').type(CONSTANTS.eventName);
cy.get('button').contains('다음').should('not.have.attr', 'disabled');
cy.get('input').clear();
cy.get('input').should('have.value', '');
cy.get('button').contains('다음').should('have.attr', 'disabled');
});

it('행사 이름을 입력한 후 "다음" 버튼을 누르면 행사 비밀번호 설정 화면으로 이동해야 한다.', () => {
cy.get('input').type(CONSTANTS.eventName);
cy.get('button').contains('다음').click();

// 다음 버튼을 클릭하면 /create/event 경로가 아니라 /create/event/?로 가네요.. 그래서 일단 제거함.
cy.contains('비밀번호').should('exist');
});
});

context('행사 비밀번호 입력 페이지', () => {
beforeEach(() => {
cy.createEventName(CONSTANTS.eventName);
});

it('행사 비밀번호 입력 페이지에서 input이 포커싱 되어 있고, "행동 개시!" 버튼이 비활성화 되어 있어야 한다.', () => {
cy.get('input').focused();
cy.get('button').contains('행동 개시!').should('have.attr', 'disabled');
});

it('행사 비밀번호에 숫자가 아닌 입력을 할 경우 값이 입력되지 않아야 한다.', () => {
cy.get('input').type('테스트');
cy.get('input').should('have.value', '');
});

it(`행사 비밀번호에 ${RULE.maxEventPasswordLength}자리 이상 입력을 할 경우 처음 ${RULE.maxEventPasswordLength}자리만 입력되어야 한다.`, () => {
cy.get('input').type('12345');
cy.get('input').should('have.value', CONSTANTS.eventPassword);
});

it('행사 비밀번호이 1자 이상 입력된 경우 "행동 개시!" 버튼이 활성화 되고, 값이 없는 경우 "행동 개시!" 버튼이 비활성화 되어야 한다.', () => {
cy.get('input').type(CONSTANTS.eventPassword);
cy.get('button').contains('행동 개시!').should('not.have.attr', 'disabled');
cy.get('input').clear();
cy.get('input').should('have.value', '');
cy.get('button').contains('행동 개시!').should('have.attr', 'disabled');
});

it('행사 비밀번호을 입력한 후 "행동 개시!" 버튼을 누르면 행사 생성 완료 화면으로 이동해야 한다.', () => {
cy.interceptAPI({type: 'postEvent', statusCode: 200});
cy.interceptAPI({type: 'getEventName', statusCode: 200});
cy.get('input').type(CONSTANTS.eventPassword);
cy.get('button').contains('행동 개시!').click();
});
});
// it('랜딩페이지에서 "정산 시작하기" 버튼을 눌러 행사 이름 입력 페이지로 이동해야 한다.', () => {
// cy.visit('/');
// cy.get('button').contains('정산 시작하기').click();
// cy.url().should('include', ROUTER_URLS.createGuestEvent);
// });
// context('행사 이름 입력 페이지', () => {
// beforeEach(() => {
// cy.visit(ROUTER_URLS.createGuestEvent);
// });
// it('행사 이름 입력 페이지에서 input이 포커싱 되어 있고, "다음" 버튼이 비활성화 되어 있어야 한다.', () => {
// cy.get('input').focused();
// cy.get('button').contains('다음').should('have.attr', 'disabled');
// });
// it('행사 이름이 1자 이상 입력된 경우 "다음" 버튼이 활성화 되고, 값이 없는 경우 "다음" 버튼이 비활성화 되어야 한다.', () => {
// cy.get('input').type(CONSTANTS.eventName);
// cy.get('button').contains('다음').should('not.have.attr', 'disabled');
// cy.get('input').clear();
// cy.get('input').should('have.value', '');
// cy.get('button').contains('다음').should('have.attr', 'disabled');
// });
// it('행사 이름을 입력한 후 "다음" 버튼을 누르면 행사 관리자 이름 입력 화면으로 이동해야 한다.', () => {
// cy.get('input').type(CONSTANTS.eventName);
// cy.get('button').contains('다음').click();
// cy.contains('관리자의 이름').should('exist');
// });
// });
// context('행사 비밀번호 입력 페이지', () => {
// beforeEach(() => {
// cy.createEventName(CONSTANTS.eventName);
// });
// it('행사 비밀번호 입력 페이지에서 input이 포커싱 되어 있고, "행동 개시!" 버튼이 비활성화 되어 있어야 한다.', () => {
// cy.get('input').focused();
// cy.get('button').contains('행동 개시!').should('have.attr', 'disabled');
// });
// it('행사 비밀번호에 숫자가 아닌 입력을 할 경우 값이 입력되지 않아야 한다.', () => {
// cy.get('input').type('테스트');
// cy.get('input').should('have.value', '');
// });
// it(`행사 비밀번호에 ${RULE.maxEventPasswordLength}자리 이상 입력을 할 경우 처음 ${RULE.maxEventPasswordLength}자리만 입력되어야 한다.`, () => {
// cy.get('input').type('12345');
// cy.get('input').should('have.value', CONSTANTS.eventPassword);
// });
// it('행사 비밀번호이 1자 이상 입력된 경우 "행동 개시!" 버튼이 활성화 되고, 값이 없는 경우 "행동 개시!" 버튼이 비활성화 되어야 한다.', () => {
// cy.get('input').type(CONSTANTS.eventPassword);
// cy.get('button').contains('행동 개시!').should('not.have.attr', 'disabled');
// cy.get('input').clear();
// cy.get('input').should('have.value', '');
// cy.get('button').contains('행동 개시!').should('have.attr', 'disabled');
// });
// it('행사 비밀번호을 입력한 후 "행동 개시!" 버튼을 누르면 행사 생성 완료 화면으로 이동해야 한다.', () => {
// cy.interceptAPI({type: 'postEvent', statusCode: 200});
// cy.interceptAPI({type: 'getEventName', statusCode: 200});
// cy.get('input').type(CONSTANTS.eventPassword);
// cy.get('button').contains('행동 개시!').click();
// });
// });
});
2 changes: 1 addition & 1 deletion client/cypress/support/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Cypress.Commands.add('interceptAPI', ({type, delay = 0, statusCode = 200}: Inter
});

Cypress.Commands.add('createEventName', (eventName: string) => {
cy.visit(ROUTER_URLS.createEvent);
cy.visit(ROUTER_URLS.createMemberEvent);
cy.get('input').type(eventName);
cy.get('button').contains('다음').click();
});
Expand Down
21 changes: 12 additions & 9 deletions client/src/apis/request/event.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import {Event, EventId} from 'types/serviceType';
import {Event, EventCreationData, EventId, EventName} from 'types/serviceType';
import {WithErrorHandlingStrategy} from '@errors/RequestGetError';

import {ADMIN_API_PREFIX, USER_API_PREFIX} from '@apis/endpointPrefix';
import {requestGet, requestPatch, requestPostWithResponse, requestPut} from '@apis/fetcher';
import {requestGet, requestPatch, requestPostWithResponse} from '@apis/fetcher';
import {WithEventId} from '@apis/withId.type';

export interface RequestPostEvent {
eventName: string;
password: string;
}
export const requestPostGuestEvent = async (postEventArgs: EventCreationData) => {
return await requestPostWithResponse<EventId>({
endpoint: `${USER_API_PREFIX}/guest`,
body: {
...postEventArgs,
},
});
};

export const requestPostEvent = async ({eventName, password}: RequestPostEvent) => {
export const requestPostMemberEvent = async (eventName: EventName) => {
return await requestPostWithResponse<EventId>({
endpoint: USER_API_PREFIX,
endpoint: `${USER_API_PREFIX}/events`,
body: {
eventName,
password,
},
});
};
Expand Down
2 changes: 1 addition & 1 deletion client/src/constants/errorMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const SERVER_ERROR_MESSAGES: ErrorMessage = {
export const ERROR_MESSAGE = {
eventName: SERVER_ERROR_MESSAGES.EVENT_NAME_LENGTH_INVALID,
eventPasswordType: SERVER_ERROR_MESSAGES.EVENT_PASSWORD_FORMAT_INVALID,
memberName: SERVER_ERROR_MESSAGES.MEMBER_NAME_LENGTH_INVALID,
memberName: `이름은 ${RULE.maxMemberNameLength}자까지 입력 가능해요.`,
purchasePrice: `${RULE.maxPrice.toLocaleString('ko-kr')}원 이하의 숫자만 입력이 가능해요`,
purchaseTitle: `지출 이름은 ${RULE.maxBillNameLength}자 이하의 한글, 영어, 숫자만 가능해요`,
preventEmpty: '값은 비어있을 수 없어요',
Expand Down
28 changes: 16 additions & 12 deletions client/src/constants/routerUrls.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
const EVENT = '/event';
const EVENT_WITH_EVENT_ID = `${EVENT}/:eventId`;

export const ROUTER_URLS = {
main: '/',
createEvent: '/event/create',
event: '/event',
eventManage: '/event/:eventId/admin',
home: '/event/:eventId/home',
member: '/event/:eventId/admin/member',
addBill: '/event/:eventId/admin/add-bill',
editBill: '/event/:eventId/admin/edit-bill',
eventEdit: 'event/:eventId/admin/edit',
images: '/event/:eventId/images',
addImages: '/event/:eventId/admin/add-images',
send: 'event/:eventId/:memberId/send',
qrCode: 'event/:eventId/qrcode',
createGuestEvent: `${EVENT}/create/guest`,
createMemberEvent: `${EVENT}/create/member`,
eventManage: `${EVENT_WITH_EVENT_ID}/admin`,
home: `${EVENT_WITH_EVENT_ID}/home`,
member: `${EVENT_WITH_EVENT_ID}/admin/member`,
addBill: `${EVENT_WITH_EVENT_ID}/admin/add-bill`,
editBill: `${EVENT_WITH_EVENT_ID}/admin/edit-bill`,
eventEdit: `${EVENT_WITH_EVENT_ID}/admin/edit`,
images: `${EVENT_WITH_EVENT_ID}/images`,
addImages: `${EVENT_WITH_EVENT_ID}/admin/add-images`,
send: `${EVENT_WITH_EVENT_ID}/:memberId/send`,
qrCode: `${EVENT_WITH_EVENT_ID}/qrcode`,
event: EVENT,
login: '/login',
};
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import {useState} from 'react';

import useSetEventNameStep from './useSetEventNameStep';
import {useSetNickNameStep} from './useSetNicknameStep';

// 행사 생성 페이지에서 여러 스텝에 걸쳐 사용되는 상태를 선언해 내려주는 용도의 훅입니다.
const useCreateEventData = () => {
const useCreateGuestEventData = () => {
const eventNameProps = useSetEventNameStep();
const nickNameProps = useSetNickNameStep();
const [eventToken, setEventToken] = useState('');

return {
eventNameProps,
nickNameProps,
eventToken,
setEventToken,
};
};

export default useCreateEventData;
export default useCreateGuestEventData;
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,52 +1,46 @@
import {useState} from 'react';

import validateEventPassword from '@utils/validate/validateEventPassword';
import {EventCreationData} from 'types/serviceType';

import RULE from '@constants/rule';

import useRequestPostEvent from './queries/event/useRequestPostEvent';
import useAmplitude from './useAmplitude';
import useRequestPostGuestEvent from '../queries/event/useRequestPostGuestEvent';
import useAmplitude from '../useAmplitude';

export type UseSetEventPasswordStepReturnType = ReturnType<typeof useSetEventPasswordStep>;

type SubmitDataForPostEventArgs = Omit<EventCreationData, 'password'> & {
event: React.FormEvent<HTMLFormElement>;
setEventToken: (eventToken: string) => void;
};

const useSetEventPasswordStep = () => {
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [canSubmit, setCanSubmit] = useState(false);
const {postEvent: requestPostEvent, isPostEventPending} = useRequestPostEvent();
const {postEvent: requestPostEvent, isPostEventPending} = useRequestPostGuestEvent();

const {trackCompleteCreateEvent} = useAmplitude();

const submitDataForPostEvent = async ({
event,
eventName,
setEventToken,
}: {
event: React.FormEvent<HTMLFormElement>;
eventName: string;
setEventToken: (eventToken: string) => void;
}) => {
const submitDataForPostEvent = async ({event, nickname, eventName, setEventToken}: SubmitDataForPostEventArgs) => {
event.preventDefault();

await postEvent(eventName, setEventToken);
};

const getPasswordWithPad = () => {
return String(password).padStart(4, '0');
};

const postEvent = async (eventName: string, updateEventToken: (eventToken: string) => void) => {
await requestPostEvent(
{eventName, password: getPasswordWithPad()},
{eventName, nickname, password: getPasswordWithPad()},
{
onSuccess: ({eventId}) => {
trackCompleteCreateEvent({eventName, eventToken: eventId});
updateEventToken(eventId);
setEventToken(eventId);
},
},
);
};

const getPasswordWithPad = () => {
return String(password).padStart(4, '0');
};

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
const validation = validateEventPassword(newValue);
Expand Down
29 changes: 29 additions & 0 deletions client/src/hooks/createEvent/useSetNicknameStep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {useState} from 'react';

import validateMemberName from '@utils/validate/validateMemberName';
import {Nickname} from 'types/serviceType';

type UseSetNicknameStepProps = ReturnType<typeof useSetNicknameStep>;

const useSetNicknameStep = () => {
const [nickname, setNickname] = useState<Nickname>('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [canSubmit, setCanSubmit] = useState(false);

const handleNicknameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const name = event.target.value;
const {isValid, errorMessage: errorMessageResult} = validateMemberName(name);

setErrorMessage(errorMessageResult);

if (isValid) {
setNickname(name);
}

setCanSubmit(name.length !== 0);
};

return {handleNicknameChange, canSubmit, nickname, errorMessage};
};

export {useSetNicknameStep, type UseSetNicknameStepProps};
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import {useMutation, useQueryClient} from '@tanstack/react-query';

import {RequestPostEvent, requestPostEvent} from '@apis/request/event';
import {requestPostGuestEvent} from '@apis/request/event';
import {EventCreationData} from 'types/serviceType';

import QUERY_KEYS from '@constants/queryKeys';

const useRequestPostEvent = () => {
const useRequestPostGuestEvent = () => {
const queryClient = useQueryClient();

const {mutate, mutateAsync, ...rest} = useMutation({
mutationFn: ({eventName, password}: RequestPostEvent) => requestPostEvent({eventName, password}),
mutationFn: ({eventName, nickname, password}: EventCreationData) =>
requestPostGuestEvent({eventName, nickname, password}),
onSuccess: () => {
queryClient.removeQueries();
},
Expand All @@ -22,4 +22,4 @@ const useRequestPostEvent = () => {
};
};

export default useRequestPostEvent;
export default useRequestPostGuestEvent;
24 changes: 24 additions & 0 deletions client/src/hooks/queries/event/useRequestPostMemberEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {useMutation, useQueryClient} from '@tanstack/react-query';

import {requestPostMemberEvent} from '@apis/request/event';
import {EventName} from 'types/serviceType';

const useRequestPostMemberEvent = () => {
const queryClient = useQueryClient();

const {mutateAsync, ...rest} = useMutation({
mutationFn: (eventName: EventName) => requestPostMemberEvent(eventName),
onSuccess: () => {
queryClient.removeQueries();
},
});

// 실행 순서를 await으로 보장하기 위해 mutateAsync 사용
return {
postEvent: mutateAsync,
isPostEventPending: rest.isPending,
...rest,
};
};

export default useRequestPostMemberEvent;
2 changes: 0 additions & 2 deletions client/src/hooks/useAccount.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type {Event} from 'types/serviceType';

import {useEffect, useState} from 'react';

import validateAccountNumber from '@utils/validate/validateAccountNumber';
Expand Down
Loading

0 comments on commit 07401c0

Please sign in to comment.