Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion design-system/ui/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ interface ButtonProps {
onClick: () => void; // 클릭 핸들러
disabled?: boolean; // 비활성화 여부
className?: string; // 추가 스타일링 클래스
type?: 'button' | 'submit' | 'reset';
}

const Button = ({ label, onClick, disabled = false, className = '' }: ButtonProps) => {
const Button = ({ label, onClick, disabled = false, className = '', type = 'submit' }: ButtonProps) => {
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={`py-2 px-4 text-white font-semibold transition text-base sm:text-xs md:text-sm lg:text-base
Expand Down
35 changes: 35 additions & 0 deletions src/features/dashboard/api/participants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,38 @@ export const approveParticipants = async ({ orderId }: { orderId: number }) => {
console.log(error);
}
};

export const downloadExcel = async (eventId:number): Promise<void> => {
const response = await axiosClient.get(
'/host-channels/dashboard/participant-management/excel',
{
responseType: 'blob',
params: { eventId },
}
);
console.log(response.headers)

//파일이름 추출
const disposition = response.headers['content-disposition'];
let filename = '기본값.xlsx';
if (disposition) {
const match = disposition.match(/filename\*=UTF-8''(.+)/);
if (match && match[1]) {
filename = decodeURIComponent(match[1]);
}
}
console.log(disposition)

//저장
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
};
2 changes: 1 addition & 1 deletion src/features/dashboard/ui/PariticipantsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const ParticipantsList = ({ listType, selectedFilter = [], participants }: Parti
</div>
</div>
<div className="flex items-center gap-4">
<p>참여자 정보</p>
<p>옵션 응답</p>
<p>체크인</p>
<p className="mr-1 md:mr-2">승인</p>
</div>
Expand Down
4 changes: 3 additions & 1 deletion src/features/dashboard/ui/ParticipantCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ const ParticipantCard = ({ participant, onCheckClick }: ParticipantCardProps) =>
<div>
구매 일자: {formatDate(participant.purchaseDate)} {formatTime(participant.purchaseDate)}
</div>
<p>티켓 이름: {participant.ticketName}</p>
<p className="max-w-[120px] truncate text-ellipsis whitespace-nowrap">
티켓 이름: {participant.ticketName}
</p>
</div>
</div>
</div>
Expand Down
8 changes: 8 additions & 0 deletions src/features/event/ui/EventFunnel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ const EventFunnel = ({ onNext, Funnel, Step, currentStep }: EventFunnelInterface
const location = useLocation();
const [backPath] = useState(location.state?.backPath ?? '/');

//중복 클릭 방지
const [isSubmittingEvent, setIsSubmittingEvent] = useState(false);
const [isSubmittingHost, setIsSubmittingHost] = useState(false);

const stepOrder = [
StepNames.HostSelection,
StepNames.HostCreation,
Expand All @@ -44,6 +48,8 @@ const EventFunnel = ({ onNext, Funnel, Step, currentStep }: EventFunnelInterface
onNext(nextStep);
};
const handleCreateEvent = () => {
if (isSubmittingEvent) return;
setIsSubmittingEvent(true);
createEvent(eventState, {
onSuccess: () => {
navigate('/menu/myHost');
Expand All @@ -61,6 +67,8 @@ const EventFunnel = ({ onNext, Funnel, Step, currentStep }: EventFunnelInterface
};

const handleHostCreation = () => {
if (isSubmittingHost) return;
setIsSubmittingHost(true);
createHost(hostState, {
onSuccess: () => {
setHostState(initialHostState);
Expand Down
10 changes: 10 additions & 0 deletions src/features/join/api/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,13 @@ export const agreeTerms = async (data: TermsAgreementRequest) => {
});
return response.data;
};

//인증번호 발급
export const sendCertificationCode = async (phoneNum: string): Promise<void> => {
await axiosClient.post('/sms/send', { phoneNum });
};

//인증번호 검증
export const verifyCertificationCode = async (phoneNum: string, certificationCode: string): Promise<void> => {
await axiosClient.post('/sms/verify', { phoneNum, certificationCode });
};
32 changes: 30 additions & 2 deletions src/features/join/hooks/useUserHook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { agreeTerms, readUser, updateUser } from '../api/user';
import { agreeTerms, readUser, sendCertificationCode, updateUser, verifyCertificationCode } from '../api/user';
import { UserInfoRequest, UserInfoResponse } from '../model/userInformation';

export const useUserInfo = (enabled: boolean = true) => {
Expand All @@ -26,4 +26,32 @@ export const useAgreeTerms = () => {
alert('약관 동의 처리에 실패했습니다.');
}
});
};
};


// 인증번호 발급
export const useSendCertificationCode = () => {
return useMutation({
mutationFn: (phoneNum: string) => sendCertificationCode(phoneNum),
onSuccess: () => {
alert('인증번호를 발송했습니다.');
},
onError: () => {
alert('인증번호 전송에 실패했습니다.');
},
});
};

// 인증번호 확인
export const useVerifyCertificationCode = () => {
return useMutation({
mutationFn: (params: { phoneNum: string; certificationCode: string }) =>
verifyCertificationCode(params.phoneNum, params.certificationCode),
onSuccess: () => {
alert('인증에 성공했습니다.');
},
onError: () => {
alert('인증번호가 일치하지 않습니다.');
},
});
}
31 changes: 25 additions & 6 deletions src/pages/dashboard/ui/ParticipantsMangementPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import ParticipantsFilterBar from '../../../widgets/dashboard/ui/ParticipantsFil
import EmailModal from '../../../widgets/dashboard/ui/email/EmailModal';
import SelectTicketModal from '../../../widgets/dashboard/ui/email/SelectTicketModal';
import { useParticipants } from '../../../features/dashboard/hook/useParticipants';
import SecondaryButton from '../../../../design-system/ui/buttons/SecondaryButton';
import { useParams } from 'react-router-dom';
import { downloadExcel } from '../../../features/dashboard/api/participants';

const ParticipantsManagementPage = () => {
const [filterModalOpen, setfilterModalOpen] = useState(false);
Expand All @@ -15,6 +18,8 @@ const ParticipantsManagementPage = () => {
const [listType, setListType] = useState<'all' | 'approved' | 'pending'>('all');
const [filter, setFilter] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const { id } = useParams();
const eventId = Number(id);

const { participants } = useParticipants();
const checkedInCount = participants.filter((p: { checkedIn: boolean; }) => p.checkedIn).length;
Expand All @@ -29,18 +34,32 @@ const ParticipantsManagementPage = () => {
String(p.ticketId).includes(lowerSearch)
);
});
const exportToExcel = () => {
try {
downloadExcel(eventId);
} catch (error) {
alert('엑셀 다운로드에 실패했습니다.');
console.error(error);
}
}

return (
<DashboardLayout centerContent="DASHBOARD" pinkBg={true}>
<div className="flex flex-col px-2 md:px-4">
<h1 className="text-center font-bold text-xl py-4 md:py-6">구매/참가자 관리</h1>
<div className="flex justify-end gap-2 md:gap-3 px-4">
<h3 className="text-placeholderText text-sm md:text-base">체크인</h3>
<span className="text-sm md:text-base">{checkedInCount}/{participants.length}</span>
<span className="text-sm md:text-base">|</span>
<h3 className="text-placeholderText text-sm md:text-base">미승인</h3>
<span className="text-sm md:text-base">{unapprovedCount}</span>

<div className="flex justify-between items-center px-2">
{<SecondaryButton label="Excel" color="pink" size="small" onClick={exportToExcel} />}

<div className="flex items-center gap-2 md:gap-3">
<h3 className="text-placeholderText text-sm md:text-base">체크인</h3>
<span className="text-sm md:text-base">{checkedInCount}/{participants.length}</span>
<span className="text-sm md:text-base">|</span>
<h3 className="text-placeholderText text-sm md:text-base">미승인</h3>
<span className="text-sm md:text-base">{unapprovedCount}</span>
</div>
</div>

<SearchBar
placeholder="이름, 이메일, 전화번호, 티켓ID로 검색"
className="py-5"
Expand Down
17 changes: 15 additions & 2 deletions src/pages/dashboard/ui/ticket/TicketCreatePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const TicketCreatePage = () => {
const { mutate: createTicket } = useCreateTicket();
const { id } = useParams();
const eventId = Number(id);
const [isSubmitting, setIsSubmitting] = useState(false); // 중복 클릭 방지
const [ticketData, setTicketData] = useState<CreateTicketRequest>({
eventId: eventId,
ticketType: 'FIRST_COME',
Expand Down Expand Up @@ -63,6 +64,7 @@ const TicketCreatePage = () => {

// API 호출
const handleSaveClick = async () => {
if (isSubmitting) return;
if (
!ticketData.ticketName ||
!ticketData.ticketDescription ||
Expand All @@ -72,8 +74,19 @@ const TicketCreatePage = () => {
alert('모든 필수 입력 항목을 작성해주세요.');
return;
}
createTicket(ticketData);
navigate(`/dashboard/${id}/ticket`);
try {
setIsSubmitting(true);
createTicket(ticketData, {
onSuccess: () => {
navigate(`/dashboard/${id}/ticket`);
},
onSettled: () => {
setIsSubmitting(false);
}
});
} catch (e) {
setIsSubmitting(false);
}
};

return (
Expand Down
45 changes: 26 additions & 19 deletions src/pages/join/AuthCallback.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,40 @@
import { useNavigate, useSearchParams } from 'react-router-dom';
import useAuthStore from '../../app/provider/authStore';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useUserInfo } from '../../features/join/hooks/useUserHook';

const AuthCallback = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const status = searchParams.get('status'); // 'new' or 'existing'
const status = searchParams.get('status'); // 'new', 'existing', 'duplicatedEmail'
const { login, setName, closeModal } = useAuthStore();
const { data } = useUserInfo();
const alreadyHandled = useRef(false);

useEffect(() => {
const handleAuth = async () => {
if (!data) return;
try {
closeModal();
if (status === 'new') {
navigate('/join/agreement');
} else {
login();
setName(data?.name || '사용자');
navigate('/');
}
} catch {
navigate('/');
}
};
handleAuth();
}, [data, navigate, login, status, setName, closeModal]);
if (alreadyHandled.current || !status) return;

if (status === 'duplicatedEmail') {
alreadyHandled.current = true;
alert('이미 가입된 이메일입니다. 다른 이메일을 사용해주세요.');
navigate('/');
return;
}
if (status === 'new') {
alreadyHandled.current = true;
closeModal();
navigate('/join/agreement');
return;
}
if (status === 'existing' && data) {
alreadyHandled.current = true;
closeModal();
login();
setName(data.name || '사용자');
navigate('/');
return;
}
}, [status, data, closeModal, login, setName, navigate]);

return <div className="text-center mt-32 text-lg font-bold">로그인 중입니다...</div>;
};
Expand Down
27 changes: 15 additions & 12 deletions src/pages/join/InfoInputPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect} from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm, SubmitHandler } from 'react-hook-form';
import Header from '../../../design-system/ui/Header';
Expand All @@ -14,6 +14,7 @@ const InfoInputPage = () => {
const { data, isLoading } = useUserInfo();
const { login, setName } = useAuthStore();
const { mutate: updateUser } = useUserUpdate();

const {
register,
handleSubmit,
Expand All @@ -40,7 +41,6 @@ const InfoInputPage = () => {
const formatted = formatPhoneNumber(e.target.value);
setValue('phone', formatted, { shouldValidate: true });
};

const onSubmit: SubmitHandler<FormData> = formData => {
const agreementStates = getAgreementStates();
const updatedData = {
Expand Down Expand Up @@ -104,16 +104,19 @@ const InfoInputPage = () => {
/>

{/* 연락처 필드 */}
<UnderlineTextField
label="연락처"
placeholder={'연락처'}
type="tel"
errorMessage={errors.phone?.message}
className="text-xl"
value={phoneValue}
onChange={handlePhoneChange}
/>

<div className="flex items-end gap-3 pb-8">
<div className="flex-1">
<UnderlineTextField
label="연락처"
placeholder="연락처"
type="tel"
errorMessage={errors.phone?.message}
className="text-xl"
value={phoneValue}
onChange={handlePhoneChange}
/>
</div>
</div>
{/* 이메일 필드 */}
<UnderlineTextField
label="이메일"
Expand Down
2 changes: 1 addition & 1 deletion src/shared/lib/formValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const formSchema = z.object({
.regex(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/, '올바른 이메일 형식이어야 합니다.'),
phone: z
.string()
.regex(/^[0-9]{3}-[0-9]{3,4}-[0-9]{4}$/, '연락처는 휴대전화 번호 형식(예: 010-1234-5678)이어야 합니다.'),
.regex(/^[0-9]{3}-[0-9]{3,4}-[0-9]{4}$/, '000-0000-0000 형식으로 입력해주세요.'),
});
export const organizerFormSchema = formSchema.pick({ email: true, phone: true });
export const eventTitleSchema = z.object({
Expand Down
Loading