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
3 changes: 3 additions & 0 deletions apps/land/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# This workspace is managed with the root pnpm lockfile.
/package-lock.json

# env files (can opt-in for committing if needed)
.env*

Expand Down
25 changes: 18 additions & 7 deletions apps/land/app/apply/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
'use client';

import { ApplyHeroSection, FAQSection, RecruitmentTable } from '@/components/apply';
import { motion, useReducedMotion } from 'motion/react';

export default function ApplyPage() {
const prefersReducedMotion = useReducedMotion();

return (
<div className="flex min-h-screen flex-col bg-black pt-header-height">
<div className="sticky top-16">
<ApplyHeroSection />
</div>
<div className="relative z-10 bg-gray-50">
<RecruitmentTable />
<FAQSection />
<div className="flex min-h-screen flex-col bg-black">
<div className="bg-primary">
<div className="sticky top-0">
<ApplyHeroSection />
</div>
<motion.div
data-header-cover-panel
className="relative z-10 bg-gray-50"
initial={{ y: prefersReducedMotion ? 0 : 240 }}
animate={{ y: 0 }}
transition={{ duration: 0.84, ease: [0.22, 1, 0.36, 1] }}
>
<RecruitmentTable />
<FAQSection />
</motion.div>
</div>
</div>
);
Expand Down
10 changes: 10 additions & 0 deletions apps/land/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ body {
scrollbar-width: none;
}

html {
scroll-behavior: smooth;
}

@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}

html::-webkit-scrollbar,
body::-webkit-scrollbar {
display: none;
Expand Down
2 changes: 1 addition & 1 deletion apps/land/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const metadata: Metadata = {

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko" className={`${wantedSans.variable}`} suppressHydrationWarning>
<html lang="ko" className={wantedSans.variable} suppressHydrationWarning>
<body className="antialiased" suppressHydrationWarning>
<Header />
<QueryProvider>{children}</QueryProvider>
Expand Down
31 changes: 25 additions & 6 deletions apps/land/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
'use client';

import {
HeroSection,
StatsSection,
StudySection,
CtaSection,
LandingFooter,
ActivitySection,
} from '@/components/home';
import { motion, useReducedMotion } from 'motion/react';

export default function Home() {
const prefersReducedMotion = useReducedMotion();

return (
<div className="flex min-h-screen flex-col relative pt-header-height">
<HeroSection />
<StatsSection />
<ActivitySection />
<CtaSection />
<LandingFooter />
<div className="flex min-h-screen flex-col overflow-x-clip bg-white">
<div className="bg-white">
<div className="sticky top-0">
<HeroSection />
</div>
<motion.div
data-header-cover-panel
className="relative z-10 -mt-16 bg-white"
initial={{ y: prefersReducedMotion ? 0 : 160 }}
animate={{ y: 0 }}
transition={{ duration: 0.84, ease: [0.22, 1, 0.36, 1] }}
>
<StatsSection />
<StudySection />
<ActivitySection />
<CtaSection />
<LandingFooter />
</motion.div>
</div>
</div>
);
}
182 changes: 182 additions & 0 deletions apps/land/components/apply/ApplicationHistorySection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
'use client';

import { CURRENT_SEMESTER_RECRUITMENT } from '@/constants';
import { useApplyPassed, useRecruitments } from '@/hooks/apply';
import { formatDate, getApplicationHistory } from '@/lib';
import type { ApplicationHistory } from '@/lib';
import type { ApplicationResultResponse, Recruitment } from '@/types';
import { Button } from '@clab/design-system';
import Link from 'next/link';
import { useSyncExternalStore } from 'react';

function getResultMessage(result: ApplicationResultResponse | undefined) {
if (!result) return null;

if (result.success && result.data) {
if (result.data.isPass) {
return {
eyebrow: '최종 결과',
title: '합격을 축하드립니다!',
description: `${result.data.name}님과 함께할 활동을 기대하고 응원합니다. \n자세한 안내는 개별 연락으로 드립니다.`,
tone: 'success' as const,
};
}

if (result.data.name) {
return {
eyebrow: '최종 결과',
title: '지원과 관심에 감사드립니다.',
description: '이번 모집에서는 함께하지 못하게 되었습니다. \n지원해 주셔서 감사합니다.',
tone: 'neutral' as const,
};
}

return {
eyebrow: '지원 내역',
title: '지원 내역을 찾을 수 없습니다.',
description: '모집 공고와 학번 정보를 다시 확인해 주세요.',
tone: 'neutral' as const,
};
}

if (result.errorMessage === 'INVALID_RECRUITMENT_CLOSURE_WINDOW') {
return {
eyebrow: '최종 결과',
title: '결과 확인 가능 기간이 아닙니다.',
description: '모집 종료 후 7일 이내에 확인해 주세요.',
tone: 'neutral' as const,
};
}

if (result.errorMessage === 'NOT_FOUND') {
return {
eyebrow: '지원 내역',
title: '지원 내역을 찾을 수 없습니다.',
description: '모집 공고와 학번 정보를 다시 확인해 주세요.',
tone: 'neutral' as const,
};
}

return {
eyebrow: '최종 결과',
title: '결과를 불러오지 못했습니다.',
description: '잠시 후 다시 시도해 주세요.',
tone: 'neutral' as const,
};
}

const subscribeToApplicationHistory = () => () => undefined;

function isCurrentSemesterApplication(history: ApplicationHistory) {
const submittedAt = new Date(history.submittedAt);
const applicationStartAt = new Date(CURRENT_SEMESTER_RECRUITMENT.applicationStartAt);
const applicationEndAt = new Date(CURRENT_SEMESTER_RECRUITMENT.applicationEndAt);

return (
!Number.isNaN(submittedAt.getTime()) &&
submittedAt >= applicationStartAt &&
submittedAt <= applicationEndAt
);
}

function getResultAvailability(history: ApplicationHistory) {
if (!isCurrentSemesterApplication(history)) {
return { disabled: false, description: null };
}

const finalResultReleaseAt = new Date(CURRENT_SEMESTER_RECRUITMENT.finalResultReleaseAt);
if (new Date() < finalResultReleaseAt) {
return {
disabled: true,
description: `${CURRENT_SEMESTER_RECRUITMENT.finalResultReleaseLabel} 공개 예정`,
};
}

return { disabled: false, description: null };
}

export default function ApplicationHistorySection() {
const history = useSyncExternalStore<ApplicationHistory | null>(
subscribeToApplicationHistory,
getApplicationHistory,
() => null
);
const { data: recruitmentsResponse } = useRecruitments();
const {
data: result,
error,
isFetching,
refetch,
} = useApplyPassed({
recruitmentId: history?.recruitmentId ?? 0,
studentId: history?.studentId ?? '',
});

if (!history) return null;

const recruitments: Recruitment[] = recruitmentsResponse?.data ?? [];
const recruitment = recruitments.find((item) => item.id === history.recruitmentId);
const resultAvailability = getResultAvailability(history);
const hasResolvedResult = Boolean(result?.success && result.data);
const message = error
? {
eyebrow: '최종 결과',
title: '결과를 불러오지 못했습니다.',
description: '잠시 후 다시 시도해 주세요.',
tone: 'neutral' as const,
}
: getResultMessage(result);

return (
<div className="mb-10 rounded-xl border border-gray-200 bg-white p-5 sm:p-6">
<p className="mb-2 text-sm font-bold text-sky-600">지원 내역</p>
<h2 className="text-2xl font-bold text-gray-900">
{recruitment?.title ?? '지원한 모집 공고'}
</h2>
<p className="mt-2 text-sm text-gray-600">지원이 완료되었습니다.</p>
{recruitment && (
<p className="mt-1 text-sm text-gray-500">
모집 종료일 · {formatDate(recruitment.endDate)}
</p>
)}

{!hasResolvedResult && (
<div className="mt-6">
<Button
className="whitespace-nowrap px-4"
onClick={() => void refetch()}
disabled={isFetching || resultAvailability.disabled}
color={isFetching || resultAvailability.disabled ? 'disabled' : 'active'}
>
{isFetching ? '확인 중...' : (resultAvailability.description ?? '최종 결과 확인')}
</Button>
</div>
)}

{message && (
<section
className="mt-8 border-t border-gray-200 px-4 py-10 text-center sm:px-8 sm:py-12"
aria-live="polite"
>
<span className="mt-5 block text-5xl leading-none" aria-hidden="true">
{message.tone === 'success' ? '🎉' : '✦'}
</span>
<h3 className="mt-5 break-keep text-2xl font-bold tracking-tight text-gray-950 sm:text-3xl">
{message.title}
</h3>
<p className="whitespace-pre-line mx-auto mt-4 max-w-128 break-keep text-sm leading-6 text-gray-600 sm:text-base">
{message.description}
</p>
<div className="mt-8 flex justify-center">
<Link
href="/"
className="inline-flex min-h-11 items-center justify-center rounded-full bg-slate-950 px-5 text-sm font-bold text-white transition-colors hover:bg-sky-600"
>
동아리 더 살펴보기
</Link>
</div>
</section>
)}
</div>
);
}
35 changes: 27 additions & 8 deletions apps/land/components/apply/ApplyFormSection.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
'use client';

import { LandingSection } from '@/components/common';
import { Button, Input, Textarea } from '@clab/design-system';
import { cn } from '@clab/design-system/utils';
import Link from 'next/link';
import { useState } from 'react';

import { FORM_FIELD_MAX_LENGTH } from '@/constants/apply';
import { useApplicationMutation } from '@/hooks/apply';
import { formatBirth } from '@/lib';
import { formatBirth, saveApplicationHistory } from '@/lib';
import type { ApplicationForm as ApplicationFormType, ApplicationType } from '@/types';

interface Props {
Expand Down Expand Up @@ -64,7 +65,16 @@ export default function ApplyFormSection({ recruitmentId, applicationType }: Pro
applicationType,
});
const [isApplySuccess, setIsApplySuccess] = useState<boolean | null>(null);
const { applicationMutate, isPending } = useApplicationMutation({ setIsApplySuccess });
const { applicationMutate, isPending } = useApplicationMutation({
setIsApplySuccess,
onApplySuccess: (application) => {
saveApplicationHistory({
recruitmentId: application.recruitmentId,
studentId: application.studentId,
submittedAt: new Date().toISOString(),
});
},
});

const update = <K extends keyof ApplicationFormType>(key: K, value: ApplicationFormType[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
Expand Down Expand Up @@ -93,7 +103,10 @@ export default function ApplyFormSection({ recruitmentId, applicationType }: Pro

if (isApplySuccess === true) {
return (
<section className="px-10 py-16 lg:px-[30%]">
<LandingSection
className="h-auto items-stretch bg-gray-50 px-5 py-16 sm:px-10 sm:py-16 lg:px-[30%] lg:py-16"
innerClassName="max-w-none"
>
<div className="text-center gap-4 flex flex-col items-center">
<h2 className="text-2xl font-bold text-gray-900">지원이 완료되었습니다!</h2>
<p className="mt-2 text-gray-600">결과는 추후 안내드리겠습니다.</p>
Expand All @@ -104,26 +117,32 @@ export default function ApplyFormSection({ recruitmentId, applicationType }: Pro
동아리 더 둘러보기
</Link>
</div>
</section>
</LandingSection>
);
}

if (isApplySuccess === false) {
return (
<section className="px-10 py-16 lg:px-[30%]">
<LandingSection
className="h-auto items-stretch bg-gray-50 px-5 py-16 sm:px-10 sm:py-16 lg:px-[30%] lg:py-16"
innerClassName="max-w-none"
>
<div className="text-center gap-4 flex flex-col items-center">
<h2 className="text-2xl font-bold text-gray-900">지원에 실패했습니다.</h2>
<p className="mt-2 text-gray-600">다시 시도해주세요.</p>
<Button size="small" onClick={() => setIsApplySuccess(null)}>
다시 시도
</Button>
</div>
</section>
</LandingSection>
);
}

return (
<section className="px-10 py-16 lg:px-[30%]">
<LandingSection
className="h-auto items-stretch bg-gray-50 px-5 py-16 sm:px-10 sm:py-16 lg:px-[30%] lg:py-16"
innerClassName="max-w-none"
>
<form onSubmit={handleSubmit} className="flex flex-col gap-8">
<h2 className="font-bold text-2xl">지원서 작성</h2>

Expand Down Expand Up @@ -260,6 +279,6 @@ export default function ApplyFormSection({ recruitmentId, applicationType }: Pro
{isPending ? '제출 중...' : '제출하기'}
</Button>
</form>
</section>
</LandingSection>
);
}
2 changes: 1 addition & 1 deletion apps/land/components/apply/ApplyHeroSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export default function ApplyHeroSection() {
return (
<section className="bg-linear-to-b from-black to-primary py-48 px-10 text-white">
<section className="bg-linear-to-b from-black to-primary px-10 pb-48 pt-64 text-white">
<div className="lg:px-[30%]">
<h1 className="text-3xl font-bold mb-6">C-Lab 지원하기</h1>
<p className="text-lg mb-10 opacity-90">C-Lab과 함께 성장할 열정 있는 분들을 기다립니다.</p>
Expand Down
Loading
Loading