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
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import styled from 'styled-components';

export const TextAreaContainer = styled.div<{ width: string }>`
width: ${(props) => props.width};
min-width: 385px;
min-width: 300px;
display: flex;
flex-direction: column;
`;
Expand Down Expand Up @@ -38,7 +38,7 @@ export const TextArea = styled.textarea<{ hasError?: boolean }>`
border-color: ${({ hasError }) => (hasError ? 'red' : '#007bff')};
box-shadow: 0 0 3px
${({ hasError }) =>
hasError ? 'rgba(255, 0, 0, 0.5)' : 'rgba(0, 123, 255, 0.5)'};
hasError ? 'rgba(255, 0, 0, 0.5)' : 'rgba(0, 123, 255, 0.5)'};
}

&:disabled {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import styled from 'styled-components';

export const InputContainer = styled.div<{ width: string; readOnly?: boolean }>`
width: ${(props) => props.width};
min-width: 385px;
min-width: 300px;
display: flex;
flex-direction: column;

Expand Down
14 changes: 14 additions & 0 deletions frontend/src/hooks/useValidateAnswers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Question } from '@/types/application';

export const validateAnswers = (
questions: Question[],
getAnswersById: (id: number) => string[]
): number[] => {
return questions
.filter(q => q.options.required)
.filter(q => {
const answers = getAnswersById(q.id);
return answers.length === 0 || answers.every(s => s.trim() === '');
})
.map(q => q.id);
};
Original file line number Diff line number Diff line change
@@ -1,21 +1,39 @@
import styled from 'styled-components';
import { media } from '@/styles/mediaQuery';

export const FormTitle = styled.h1`
font-size: 2.5rem;
font-size: 2.2rem;
font-weight: 700;
border: none;
outline: none;
margin-top: 20px;
margin-bottom: 46px;
margin-top: 30px;
margin-bottom: 30px;
padding: 0 15px;
`;

export const FormDescription = styled.div`
white-space: pre-line;
font-size: 1rem;
line-height: 1.6;
color: #444;
margin-top: -20px;
margin-bottom: 48px;
padding: 0 15px;

${media.mobile} {
font-size: 0.95rem;
line-height: 1.5;
}
`;


export const QuestionsWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 50px;
gap: 20px;

@media (max-width: 500px) {
gap: 30px;
${media.mobile} {
gap: 10px;
}

`;
Expand Down
68 changes: 36 additions & 32 deletions frontend/src/pages/ApplicationFormPage/ApplicationFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@ import { PageContainer } from '@/styles/PageContainer.styles';
import Header from '@/components/common/Header/Header';
import { useNavigate, useParams } from 'react-router-dom';
import { useGetClubDetail } from '@/hooks/queries/club/useGetClubDetail';
import ClubProfile from '@/pages/ClubDetailPage/components/ClubProfile/ClubProfile';
//import ClubProfile from '@/pages/ClubDetailPage/components/ClubProfile/ClubProfile';
import { useAnswers } from '@/hooks/useAnswers';
import QuestionAnswerer from '@/pages/ApplicationFormPage/components/QuestionAnswerer/QuestionAnswerer';
import { useGetApplication } from '@/hooks/queries/application/useGetApplication';
import { Question } from '@/types/application';
import Spinner from '@/components/common/Spinner/Spinner';
import applyToClub from '@/apis/application/applyToClub';
import QuestionContainer from '@/pages/ApplicationFormPage/components/QuestionContainer/QuestionContainer';
import { parseDescriptionWithLinks } from '@/utils/parseDescriptionWithLinks';
import { validateAnswers } from '@/hooks/useValidateAnswers';
import * as Styled from './ApplicationFormPage.styles';



const AnswerApplicationForm = () => {
const { clubId } = useParams<{ clubId: string }>();
const navigate = useNavigate();
Expand Down Expand Up @@ -48,61 +52,61 @@ const AnswerApplicationForm = () => {
}
};

const handleScrollToInvalid = (invalidIds: number[]) => {
const firstInvalidIndex = formData?.questions.findIndex((q: Question) => invalidIds.includes(q.id));
const targetEl = firstInvalidIndex !== undefined ? questionRefs.current[firstInvalidIndex] : null;
targetEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};

if (isError || clubError) {
alert(applicationError?.message || '문제가 발생했어요.');
navigate(`/club/${clubId}`);
return <div>문제가 발생했어요. 잠시 후 다시 시도해 주세요.</div>;
}

if (!formData || !clubDetail) {
return (
<div>
지원서 정보를 불러오지 못했어요. 새로고침하거나 잠시 후 다시 시도해 주세요.
</div>
);
}

const handleSubmit = async () => {
const invalidIds: number[] = formData.questions
.filter((q: Question) => q.options.required)
.filter((q: Question) => {
const a = getAnswersById(q.id);
return a.length === 0 || a.every((s) => s.trim() === '');
})
.map((q: Question) => q.id);
if (!formData) return;

const invalidIds = validateAnswers(formData.questions, getAnswersById);

if (invalidIds.length > 0) {
setInvalidQuestionIds(invalidIds);

const firstInvalidIndex = formData.questions.findIndex((q: Question) =>
invalidIds.includes(q.id),
);
const targetEl = questionRefs.current[firstInvalidIndex];
targetEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
handleScrollToInvalid(invalidIds);
return;
}

try {
await applyToClub(clubId, answers);
await applyToClub(clubId!, answers);
alert('답변이 성공적으로 제출되었습니다.');
} catch (e) {
} catch {
alert('답변 제출에 실패했습니다. 잠시 후 다시 시도해 주세요.');
}
};

if (!clubId) return null;
if (isLoading) return <Spinner />;
if (isError || clubError) {
alert(applicationError?.message || '문제가 발생했어요.');
navigate(`/club/${clubId}`);
return <div>문제가 발생했어요. 잠시 후 다시 시도해 주세요.</div>;
}
if (!formData || !clubDetail) {
return <div>지원서 정보를 불러오지 못했어요. 새로고침하거나 잠시 후 다시 시도해 주세요.</div>;
}

return (
<>
<Header />
<PageContainer style={{ paddingTop: '172px' }}>
<ClubProfile
<PageContainer style={{ paddingTop: '80px' }}>
{/* <ClubProfile
name={clubDetail.name}
logo={clubDetail.logo}
division={clubDetail.division}
category={clubDetail.category}
tags={clubDetail.tags}
/>
/> */}
<Styled.FormTitle>{formData.title}</Styled.FormTitle>
{formData.description && (
<Styled.FormDescription>
{parseDescriptionWithLinks(formData.description)}
</Styled.FormDescription>

)}
<Styled.QuestionsWrapper>
{formData.questions.map((q: Question, i: number) => (
<QuestionContainer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { forwardRef } from 'react';
const Container = styled.div<{ hasError?: boolean }>`
border: ${({ hasError }) => (hasError ? '1px solid #FF5414' : 'transparent')};
border-radius: 16px;
padding: 24px;
padding: 26px 15px;
position: relative;
scroll-margin-top: 120px;
transition: border 0.2s ease;
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/utils/parseDescriptionWithLinks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Fragment } from 'react';

export const parseDescriptionWithLinks = (text: string): React.ReactNode => {
const urlRegex = /(https?:\/\/[^\s]+)/g;
Copy link

Copilot AI Jul 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The URL regex pattern is too simplistic and may not handle edge cases properly. It doesn't account for URLs ending with punctuation or parentheses that shouldn't be part of the link. Consider using a more robust URL detection pattern or a dedicated library.

Copilot uses AI. Check for mistakes.

return text.split(urlRegex).map((part, index) => {
const isUrl = /^https?:\/\/[^\s]+$/.test(part);
return isUrl ? (
<a
key={`${part}-${index}`}
href={part}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#0077cc', textDecoration: 'underline' }}
>
{part}
</a>
) : (
<Fragment key={`${part}-${index}`}>{part}</Fragment>
);
});
};