diff --git a/apps/web/app/blog/page.tsx b/apps/web/app/blog/page.tsx
index 7a8dcd7..153c56a 100644
--- a/apps/web/app/blog/page.tsx
+++ b/apps/web/app/blog/page.tsx
@@ -1,10 +1,24 @@
import type { Metadata } from "next";
+import { Navigation } from "@/components/layout/Navigation";
+import { Footer } from "@/components/layout/Footer";
+import { ArticleListPageSection } from "@/components/sections/ArticleListPageSection";
+import { fetchPublicArticlesPage } from "@/lib/api/blog";
export const metadata: Metadata = {
title: "DDD 블로그 - 사이드 프로젝트 인사이트",
description: "DDD 멤버들의 사이드 프로젝트 경험과 개발, 협업 인사이트를 공유합니다.",
};
-export default function BlogPage() {
- return <>>;
+export default async function BlogPage() {
+ const { items, nextCursor } = await fetchPublicArticlesPage({ limit: 4 });
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index cca57b3..3ed0a0c 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -1,5 +1,12 @@
+@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css');
+
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
+
+body {
+ background-color: #0c0e0f;
+ font-family: 'Pretendard', sans-serif;
+}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 45d8e77..ca7473b 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,33 +1,27 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
-import "./globals.css";
-
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
+import type { Metadata } from 'next';
+import { PreAlertModal } from '@/components/modals/PreAlertModal';
+import { RecruitStatusProvider } from "@/components/providers/RecruitStatusProvider";
+import { fetchRecruitStatus } from "@/lib/api/cohort";
+import './globals.css';
export const metadata: Metadata = {
title: {
- default: "DDD - 사이드 프로젝트로 성장하는 개발자 커뮤니티",
- template: "%s | DDD",
+ default: 'DDD - 사이드 프로젝트로 성장하는 개발자 커뮤니티',
+ template: '%s | DDD',
},
- description: "개발자, 디자이너, 기획자가 함께 사이드 프로젝트를 만들고 성장하는 커뮤니티 DDD. 실전 협업 경험을 쌓아보세요.",
+ description:
+ '개발자, 디자이너, 기획자가 함께 사이드 프로젝트를 만들고 성장하는 커뮤니티 DDD. 실전 협업 경험을 쌓아보세요.',
};
-export default function RootLayout({
- children,
-}: Readonly<{
- children: React.ReactNode;
-}>) {
+export default async function RootLayout({ children }: { children: React.ReactNode }) {
+ const recruitStatus = await fetchRecruitStatus();
+
return (
-
{children}
+
+ {children}
+
+
);
}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 6ff5373..99e4fff 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -1,3 +1,31 @@
-export default function Home() {
- return <>>;
+import { Navigation } from '@/components/layout/Navigation';
+import { Footer } from '@/components/layout/Footer';
+import { HeroSection } from '@/components/sections/HeroSection';
+import { AboutSection } from '@/components/sections/AboutSection';
+import { ProjectsSection } from '@/components/sections/ProjectsSection';
+import { BlogSection } from '@/components/sections/BlogSection';
+import { FaqSection } from '@/components/sections/FaqSection';
+import { SponsorSection } from '@/components/sections/SponsorSection';
+import { CtaSection } from '@/components/sections/CtaSection';
+import { fetchPublicArticles } from '@/lib/api/blog';
+import { fetchPublicProjects } from '@/lib/api/project';
+
+export default async function HomePage() {
+ const [projects, articles] = await Promise.all([fetchPublicProjects(), fetchPublicArticles()]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
}
diff --git a/apps/web/app/project/[id]/page.tsx b/apps/web/app/project/[id]/page.tsx
index f124ce8..10f7ceb 100644
--- a/apps/web/app/project/[id]/page.tsx
+++ b/apps/web/app/project/[id]/page.tsx
@@ -1,4 +1,9 @@
import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { Navigation } from "@/components/layout/Navigation";
+import { Footer } from "@/components/layout/Footer";
+import { ProjectDetailSection } from "@/components/sections/ProjectDetailSection";
+import { fetchPublicProjectById } from "@/lib/api/project";
type Props = {
params: Promise<{ id: string }>;
@@ -6,9 +11,8 @@ type Props = {
export async function generateMetadata({ params }: Props): Promise {
const { id } = await params;
-
- // TODO: fetch project name by id
- const projectName = id;
+ const project = await fetchPublicProjectById(id);
+ const projectName = project?.title ?? id;
return {
title: projectName,
@@ -16,6 +20,21 @@ export async function generateMetadata({ params }: Props): Promise {
};
}
-export default function ProjectDetailPage() {
- return <>>;
+export default async function ProjectDetailPage({ params }: Props) {
+ const { id } = await params;
+ const project = await fetchPublicProjectById(id);
+
+ if (!project) {
+ notFound();
+ }
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
}
diff --git a/apps/web/app/project/page.tsx b/apps/web/app/project/page.tsx
index 57d58dd..fb07eb9 100644
--- a/apps/web/app/project/page.tsx
+++ b/apps/web/app/project/page.tsx
@@ -1,10 +1,24 @@
import type { Metadata } from "next";
+import { Navigation } from "@/components/layout/Navigation";
+import { Footer } from "@/components/layout/Footer";
+import { ProjectListPageSection } from "@/components/sections/ProjectListPageSection";
+import { fetchPublicProjectsPage } from "@/lib/api/project";
export const metadata: Metadata = {
title: "DDD 프로젝트 - 사이드 프로젝트 결과물 모음",
description: "DDD에서 진행된 다양한 사이드 프로젝트 결과물을 확인해보세요.",
};
-export default function ProjectPage() {
- return <>>;
+export default async function ProjectPage() {
+ const { items, nextCursor } = await fetchPublicProjectsPage({ limit: 9 });
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
}
diff --git a/apps/web/app/recruit/apply/page.tsx b/apps/web/app/recruit/apply/page.tsx
new file mode 100644
index 0000000..04410a7
--- /dev/null
+++ b/apps/web/app/recruit/apply/page.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from "next";
+import { Navigation } from "@/components/layout/Navigation";
+import { Footer } from "@/components/layout/Footer";
+import { RecruitApplySection } from "@/components/sections/RecruitApplySection";
+
+export const metadata: Metadata = {
+ title: "DDD 지원서 | DDD",
+ description: "DDD 13기 지원서 페이지입니다.",
+};
+
+export default function RecruitApplyPage() {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/web/app/recruit/page.tsx b/apps/web/app/recruit/page.tsx
index bc083b7..fe1a68e 100644
--- a/apps/web/app/recruit/page.tsx
+++ b/apps/web/app/recruit/page.tsx
@@ -1,10 +1,32 @@
import type { Metadata } from "next";
+import { Navigation } from "@/components/layout/Navigation";
+import { Footer } from "@/components/layout/Footer";
+import { RecruitHeroSection } from "@/components/sections/RecruitHeroSection";
+import { RecruitRolesSection } from "@/components/sections/RecruitRolesSection";
+import { RecruitScheduleSection } from "@/components/sections/RecruitScheduleSection";
+import { RecruitCurriculumSection } from "@/components/sections/RecruitCurriculumSection";
+import { recruitPageMetaDescriptionByStatus } from "@/constants/recruit";
+import { fetchRecruitStatus } from "@/lib/api/cohort";
-export const metadata: Metadata = {
- title: "DDD 모집 - 사이드 프로젝트 멤버 지원",
- description: "DDD에서 함께할 개발자, 디자이너, 기획자를 모집합니다.",
-};
+export async function generateMetadata(): Promise {
+ const recruitStatus = await fetchRecruitStatus();
+ return {
+ title: "DDD 모집 - 사이드 프로젝트 멤버 지원",
+ description: recruitPageMetaDescriptionByStatus[recruitStatus],
+ };
+}
export default function RecruitPage() {
- return <>>;
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
}
diff --git a/apps/web/components/layout/Footer.tsx b/apps/web/components/layout/Footer.tsx
new file mode 100644
index 0000000..f7e403c
--- /dev/null
+++ b/apps/web/components/layout/Footer.tsx
@@ -0,0 +1,262 @@
+"use client";
+
+import styled from "@emotion/styled";
+import { Fragment } from "react";
+import { assets } from "@/constants/assets";
+import { colors, fontSizes, fontWeights, lineHeights } from "@/constants/tokens";
+
+type SocialLinkItem = {
+ label: string;
+ href: string;
+ icon?: string;
+};
+
+const SOCIAL_LINKS: SocialLinkItem[] = [
+ {
+ label: "Instagram",
+ icon: assets.social.instagram,
+ href: "https://www.instagram.com/dynamic_ddd?igsh=MTF1Mm42eW8xZTZ4YQ==",
+ },
+ { label: "Tistory", icon: assets.social.tistory, href: "https://dynamic-ddd.tistory.com/" },
+ { label: "Medium", icon: assets.social.medium, href: "https://dddstudy.medium.com/" },
+ { label: "Brunch", icon: assets.social.brunch, href: "https://brunch.co.kr/@6d3076805b994b9" },
+];
+
+const FooterWrapper = styled.footer({
+ background: colors.background,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "space-between",
+ padding: "80px",
+ minHeight: "317px",
+
+ "@media (max-width: 1024px)": {
+ padding: "100px 80px",
+ },
+ "@media (max-width: 768px)": {
+ padding: "100px 80px",
+ },
+ "@media (max-width: 375px)": {
+ background: "#000000",
+ padding: "48px 16px 40px",
+ minHeight: "unset",
+ },
+});
+
+const FooterInner = styled.div({
+ width: "100%",
+ maxWidth: "1280px",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "space-between",
+});
+
+const FooterTop = styled.div({
+ width: "100%",
+ display: "flex",
+ flexWrap: "wrap",
+ gap: "80px",
+ justifyContent: "center",
+ padding: "0 20px",
+ marginBottom: "80px",
+
+ "@media (max-width: 375px)": {
+ flexDirection: "column",
+ flexWrap: "nowrap",
+ alignItems: "center",
+ gap: "32px",
+ padding: 0,
+ marginBottom: "40px",
+ textAlign: "center",
+ },
+});
+
+const FooterSection = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "8px",
+
+ "@media (max-width: 375px)": {
+ alignItems: "center",
+ width: "100%",
+ },
+});
+
+const FooterLabel = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.small,
+ fontWeight: fontWeights.regular,
+ lineHeight: lineHeights.small,
+ color: colors.textInverse,
+ "@media (max-width: 1024px)": {
+ fontSize: "12px",
+ lineHeight: "16px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "11px",
+ lineHeight: "15px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "10px",
+ lineHeight: "13px",
+ color: colors.slate500,
+ },
+});
+
+const FooterEmail = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.headingLarge,
+ fontWeight: fontWeights.semiBold,
+ lineHeight: lineHeights.headingLarge,
+ color: colors.textInverse,
+ "@media (max-width: 1024px)": {
+ fontSize: "24px",
+ lineHeight: "30px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "20px",
+ lineHeight: "25px",
+ },
+
+ "@media (max-width: 375px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ fontWeight: fontWeights.bold,
+ },
+});
+
+const SocialLinks = styled.div({
+ display: "flex",
+ flexWrap: "wrap",
+ alignItems: "center",
+ gap: "16px",
+
+ "@media (max-width: 375px)": {
+ display: "grid",
+ width: "100%",
+ gridTemplateColumns: "auto 1px auto",
+ gridTemplateRows: "auto auto",
+ justifyContent: "center",
+ justifyItems: "center",
+ alignItems: "center",
+ columnGap: "12px",
+ rowGap: "20px",
+
+ "& > a:nth-of-type(1)": {
+ gridColumn: 1,
+ gridRow: 1,
+ },
+ "& > span:nth-of-type(1)": {
+ gridColumn: 2,
+ gridRow: 1,
+ },
+ "& > a:nth-of-type(2)": {
+ gridColumn: 3,
+ gridRow: 1,
+ },
+ "& > span:nth-of-type(2)": {
+ display: "none",
+ },
+ "& > a:nth-of-type(3)": {
+ gridColumn: 1,
+ gridRow: 2,
+ },
+ "& > span:nth-of-type(3)": {
+ gridColumn: 2,
+ gridRow: 2,
+ },
+ "& > a:nth-of-type(4)": {
+ gridColumn: 3,
+ gridRow: 2,
+ },
+ },
+});
+
+const SocialLink = styled.a({
+ display: "flex",
+ alignItems: "center",
+ gap: "4px",
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.headingLarge,
+ fontWeight: fontWeights.semiBold,
+ lineHeight: lineHeights.headingLarge,
+ color: colors.textInverse,
+ textDecoration: "none",
+ "@media (max-width: 1024px)": {
+ fontSize: "24px",
+ lineHeight: "30px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "20px",
+ lineHeight: "25px",
+ },
+
+ "&:hover": {
+ opacity: 0.8,
+ },
+
+ "@media (max-width: 375px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ fontWeight: fontWeights.bold,
+ },
+});
+
+const Divider = styled.span({
+ width: "1px",
+ height: "14px",
+ background: colors.slate300,
+
+ "@media (max-width: 375px)": {
+ height: "16px",
+ background: colors.textInverse,
+ alignSelf: "center",
+ },
+});
+
+const Copyright = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.small,
+ fontWeight: fontWeights.medium,
+ lineHeight: lineHeights.small,
+ color: colors.textInverse,
+ textAlign: "center",
+
+ "@media (max-width: 375px)": {
+ fontSize: "11px",
+ lineHeight: "15px",
+ fontWeight: fontWeights.regular,
+ },
+});
+
+export const Footer = () => {
+ return (
+
+
+
+
+ Email
+ dddstudy1@gmail.com
+
+
+ Follow us
+
+ {SOCIAL_LINKS.map(({ label, icon, href }, index) => (
+
+ {index > 0 && }
+
+ {icon ?
: null}
+ {label}
+
+
+ ))}
+
+
+
+ ©2026 DDD. All Rights Reserved.
+
+
+ );
+};
diff --git a/apps/web/components/layout/Navigation.tsx b/apps/web/components/layout/Navigation.tsx
new file mode 100644
index 0000000..c90c88b
--- /dev/null
+++ b/apps/web/components/layout/Navigation.tsx
@@ -0,0 +1,287 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import styled from "@emotion/styled";
+import { assets } from "@/constants/assets";
+import { useRecruitStatus } from "@/components/providers/RecruitStatusProvider";
+import { openPreAlertModal } from "@/components/modals/PreAlertModal";
+import { colors, fontSizes, fontWeights, lineHeights } from "@/constants/tokens";
+
+const NAV_LINKS = [
+ { label: "모집 안내", href: "/recruit" },
+ { label: "프로젝트", href: "/project" },
+ { label: "블로그", href: "/blog" },
+] as const;
+
+const Header = styled.header({
+ position: "fixed",
+ top: 0,
+ left: 0,
+ right: 0,
+ zIndex: 100,
+ display: "flex",
+ padding: "32px 80px",
+ pointerEvents: "none",
+
+ "@media (max-width: 768px)": {
+ padding: "16px 40px",
+ },
+ "@media (max-width: 375px)": {
+ padding: "16px 16px",
+ },
+});
+
+const Inner = styled.div({
+ width: "100%",
+ maxWidth: "1280px",
+ margin: "0 auto",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ pointerEvents: "auto",
+ position: "relative",
+});
+
+const LogoLink = styled(Link)({
+ display: "flex",
+ flexShrink: 0,
+});
+
+const DesktopGroup = styled.div({
+ display: "flex",
+ alignItems: "center",
+
+ "@media (max-width: 768px)": {
+ display: "none",
+ },
+});
+
+const NavPill = styled.nav({
+ display: "flex",
+ alignItems: "center",
+ gap: "2px",
+ background: "#FFF",
+ backdropFilter: "blur(14px) saturate(160%)",
+ WebkitBackdropFilter: "blur(14px) saturate(160%)",
+ border: "1px solid rgba(255, 255, 255, 0.35)",
+ boxShadow: "0 10px 30px rgba(0, 0, 0, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.45)",
+ borderRadius: "99px",
+ padding: "4px",
+});
+
+const NavItem = styled(Link)({
+ display: "flex",
+ alignItems: "center",
+ padding: "12px 28px",
+ borderRadius: "99px",
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.headingMedium,
+ fontWeight: fontWeights.medium,
+ lineHeight: lineHeights.headingMedium,
+ color: colors.textPrimary,
+ textDecoration: "none",
+ whiteSpace: "nowrap",
+ transition: "background 0.15s",
+ "@media (max-width: 1024px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "13px",
+ lineHeight: "16px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "12px",
+ lineHeight: "14px",
+ },
+
+ "&:hover": {
+ background: "rgba(255, 255, 255, 0.4)",
+ },
+});
+
+const CtaButton = styled(Link)({
+ display: "flex",
+ alignItems: "center",
+ height: "55px",
+ padding: "12px 28px",
+ background: "rgba(46, 113, 255, 0.85)",
+ backdropFilter: "blur(14px) saturate(160%)",
+ WebkitBackdropFilter: "blur(14px) saturate(160%)",
+ borderRadius: "99px",
+ color: colors.textInverse,
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.headingMedium,
+ fontWeight: fontWeights.medium,
+ lineHeight: lineHeights.headingMedium,
+ textDecoration: "none",
+ whiteSpace: "nowrap",
+ transition: "background 0.15s",
+ "@media (max-width: 1024px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "13px",
+ lineHeight: "16px",
+ display: "none",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "12px",
+ lineHeight: "14px",
+ },
+ "&:hover": {
+ background: "rgba(31, 95, 224, 0.9)",
+ },
+});
+
+const MobileBar = styled.div({
+ display: "none",
+ width: "100%",
+ alignItems: "center",
+ justifyContent: "space-between",
+
+ "@media (max-width: 768px)": {
+ display: "flex",
+ },
+});
+
+const MobileMenuButton = styled.button({
+ borderRadius: "99px",
+ border: "1px solid #CAD5E2",
+ background: "#F1F5F9",
+ display: "flex",
+ padding: "12px",
+ justifyContent: "center",
+ alignItems: "center",
+ gap: "2px",
+});
+
+const MobileDrawer = styled.nav<{ open: boolean }>(({ open }) => ({
+ display: open ? "flex" : "none",
+ position: "absolute",
+ top: "84px",
+ left: "16px",
+ right: "16px",
+ background: "rgba(255, 255, 255, 0.65)",
+ backdropFilter: "blur(18px) saturate(160%)",
+ WebkitBackdropFilter: "blur(18px) saturate(160%)",
+ border: "1px solid rgba(255, 255, 255, 0.35)",
+ borderRadius: "20px",
+ padding: "12px",
+ flexDirection: "column",
+ gap: "8px",
+ boxShadow: "0 18px 40px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.45)",
+ pointerEvents: "auto",
+
+ "@media (max-width: 375px)": {
+ top: "72px",
+ },
+}));
+
+const MobileItem = styled(Link)({
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: "12px 14px",
+ borderRadius: "12px",
+ textDecoration: "none",
+ color: colors.textPrimary,
+ fontSize: "14px",
+ lineHeight: "18px",
+ fontWeight: fontWeights.medium,
+
+ "&:active": {
+ background: "rgba(255, 255, 255, 0.35)",
+ },
+});
+
+const MobileCta = styled(Link)({
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: "12px 14px",
+ borderRadius: "12px",
+ textDecoration: "none",
+ background: colors.primary,
+ color: colors.textInverse,
+ fontSize: "14px",
+ lineHeight: "18px",
+ fontWeight: fontWeights.medium,
+
+ boxShadow: "0 10px 26px rgba(0, 0, 0, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.35)",
+});
+
+export const Navigation = () => {
+ const [open, setOpen] = useState(false);
+ const { isRecruitOpen, recruitButtonLabels } = useRecruitStatus();
+ const recruitActionHref = isRecruitOpen ? "/recruit/apply" : "/recruit";
+
+ return (
+
+
+
+
+
+
+
+ {NAV_LINKS.map(({ label, href }) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+
+ setOpen((prev) => !prev)}
+ >
+
+
+
+ {
+ if (isRecruitOpen) return;
+ event.preventDefault();
+ openPreAlertModal();
+ }}
+ >
+ {recruitButtonLabels.navigation}
+
+
+ {NAV_LINKS.map(({ label, href }) => (
+ setOpen(false)}>
+ {label}
+
+ ))}
+ {
+ setOpen(false);
+ if (isRecruitOpen) return;
+ event.preventDefault();
+ openPreAlertModal();
+ }}
+ >
+ {recruitButtonLabels.navigation}
+
+
+
+
+ );
+};
diff --git a/apps/web/components/modals/PreAlertModal.tsx b/apps/web/components/modals/PreAlertModal.tsx
new file mode 100644
index 0000000..dfe7a94
--- /dev/null
+++ b/apps/web/components/modals/PreAlertModal.tsx
@@ -0,0 +1,737 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import styled from "@emotion/styled";
+import { colors, fontWeights } from "@/constants/tokens";
+import { ApiError } from "@ddd/api";
+import { subscribeEarlyNotificationWithActiveCohort } from "@/lib/api/early-notification";
+import successIcon from "@/public/images/success.png";
+import modalImageIcon from "@/public/images/modal_image.png";
+
+export const PRE_ALERT_MODAL_OPEN_EVENT = "ddd:open-pre-alert-modal";
+
+export const openPreAlertModal = () => {
+ window.dispatchEvent(new Event(PRE_ALERT_MODAL_OPEN_EVENT));
+};
+
+type FormValues = {
+ email: string;
+};
+
+type ModalStep = "form" | "success" | "confirm-close";
+
+const INITIAL_VALUES: FormValues = {
+ email: "",
+};
+
+const Overlay = styled.div<{ open: boolean }>(({ open }) => ({
+ position: "fixed",
+ inset: 0,
+ zIndex: 1200,
+ display: open ? "flex" : "none",
+ alignItems: "flex-start",
+ justifyContent: "center",
+ background: "rgba(12, 14, 15, 0.72)",
+ padding: "200px 24px 24px",
+
+ "@media (max-width: 1024px)": {
+ paddingTop: "304px",
+ },
+ "@media (max-width: 768px)": {
+ paddingTop: "304px",
+ },
+ "@media (max-width: 375px)": {
+ alignItems: "flex-start",
+ padding: "225px 16px 16px",
+ },
+}));
+
+const ModalWrap = styled.div({
+ position: "relative",
+ width: "100%",
+ maxWidth: "846px",
+});
+
+const ModalCard = styled.div({
+ width: "100%",
+ background: "#ffffff",
+ borderRadius: "30px",
+ border: "1px solid rgba(255, 255, 255, 0.12)",
+ boxShadow: "0 24px 80px rgba(0, 0, 0, 0.45)",
+ padding: "120px 80px 80px",
+ color: "#202325",
+ position: "relative",
+
+ "@media (max-width: 768px)": {
+ maxWidth: "643px",
+ borderRadius: "30px",
+ padding: "80px 40px 40px",
+ },
+ "@media (max-width: 375px)": {
+ maxWidth: "343px",
+ borderRadius: "20px",
+ padding: "80px 12px 40px",
+ },
+});
+
+const Header = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "12px",
+ position: "relative",
+});
+
+const Title = styled.h2({
+ margin: 0,
+ color: "#202325",
+ fontSize: "40px",
+ lineHeight: "50px",
+ fontWeight: fontWeights.bold,
+ "@media (max-width: 1024px)": {
+ fontSize: "34px",
+ lineHeight: "45px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "20px",
+ lineHeight: "25px",
+ },
+});
+
+const FloatingCloseArea = styled.div({
+ position: "absolute",
+ right: "20px",
+ top: "20px",
+ zIndex: 5,
+ width: "88px",
+ height: "88px",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "flex-end",
+
+ "@media (max-width: 768px)": {
+ right: "12px",
+ top: "12px",
+ },
+ "@media (max-width: 375px)": {
+ width: "60px",
+ height: "60px",
+ right: "8px",
+ top: "8px",
+ },
+});
+
+const CloseButton = styled.button({
+ width: "48px",
+ height: "48px",
+ border: "none",
+ borderRadius: "999px",
+ background: "#e2e8f0",
+ color: "#0c0e0f",
+ cursor: "pointer",
+ fontSize: "22px",
+ lineHeight: "22px",
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+
+ "@media (max-width: 375px)": {
+ width: "28px",
+ height: "28px",
+ fontSize: "14px",
+ lineHeight: "14px",
+ },
+});
+
+const Description = styled.p({
+ margin: "12px 0 0",
+ color: "#62748e",
+ fontSize: "24px",
+ lineHeight: "30px",
+ fontWeight: fontWeights.medium,
+ maxWidth: "686px",
+
+ "@media (max-width: 1024px)": {
+ fontSize: "20px",
+ lineHeight: "25px",
+ maxWidth: "297px",
+ },
+
+ "@media (max-width: 375px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ maxWidth: "297px",
+ },
+});
+
+const Decoration = styled.img({
+ position: "absolute",
+ right: "0",
+ top: "0",
+ width: "125px",
+ height: "125px",
+ objectFit: "cover",
+ opacity: 0.3,
+ pointerEvents: "none",
+ background: `url(${modalImageIcon.src}) / cover no-repeat`,
+
+ "@media (max-width: 1024px)": {
+ width: "102px",
+ height: "102px",
+ },
+ "@media (max-width: 375px)": {
+ width: "86px",
+ height: "86px",
+ right: "2px",
+ top: "78px",
+ },
+});
+
+const Form = styled.form({
+ marginTop: "80px",
+ display: "flex",
+ flexDirection: "column",
+ gap: "20px",
+
+ "@media (max-width: 375px)": {
+ marginTop: "40px",
+ },
+});
+
+const InputGroup = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "12px",
+});
+
+const InputLabel = styled.div({
+ display: "flex",
+ alignItems: "center",
+ gap: "4px",
+ color: "#525252",
+ fontSize: "14px",
+ lineHeight: "18px",
+ fontWeight: fontWeights.medium,
+});
+
+const RequiredDot = styled.span({
+ color: "#ff3b30",
+});
+
+const InputFieldWrap = styled.div({
+ position: "relative",
+ width: "100%",
+});
+
+const Input = styled.input<{ invalid?: boolean }>(({ invalid }) => ({
+ width: "100%",
+ height: "60px",
+ borderRadius: "30px",
+ border: invalid ? "1.5px solid #ff7c7c" : "1.5px solid #90a1b9",
+ background: "#ffffff",
+ color: "#202325",
+ padding: "0 56px 0 24px",
+ fontSize: "20px",
+ lineHeight: "28px",
+ outline: "none",
+
+ "::placeholder": {
+ color: "#90a1b9",
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ },
+
+ "@media (max-width: 768px)": {
+ "::placeholder": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+ },
+ "@media (max-width: 375px)": {
+ "::placeholder": {
+ fontSize: "10px",
+ lineHeight: "13px",
+ fontWeight: fontWeights.regular,
+ },
+ },
+}));
+
+const InputErrorIcon = styled.span({
+ position: "absolute",
+ right: "20px",
+ top: "50%",
+ transform: "translateY(-50%)",
+ width: "18px",
+ height: "18px",
+ borderRadius: "50%",
+ background: "#ff3b30",
+ color: "#ffffff",
+ fontSize: "12px",
+ fontWeight: fontWeights.bold,
+ lineHeight: "18px",
+ textAlign: "center",
+ pointerEvents: "none",
+});
+
+const ErrorText = styled.p({
+ margin: "4px 0 0",
+ color: "#ff8d8d",
+ fontSize: "14px",
+ lineHeight: "18px",
+ fontWeight: fontWeights.medium,
+});
+
+const ActionRow = styled.div({
+ marginTop: "8px",
+ display: "flex",
+ justifyContent: "flex-end",
+ gap: "12px",
+});
+
+const PrimaryButton = styled.button({
+ height: "65px",
+ padding: "20px 50px",
+ borderRadius: "100px",
+ border: "none",
+ background: colors.primary,
+ color: colors.textInverse,
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ cursor: "pointer",
+
+ "@media (max-width: 1024px)": {
+ fontSize: "19px",
+ lineHeight: "26px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "18px",
+ lineHeight: "23px",
+ },
+ "@media (max-width: 375px)": {
+ height: "56px",
+ padding: "30px 40px",
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+const SuccessWrap = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: "20px",
+ minHeight: "383px",
+
+ "@media (max-width: 375px)": {
+ padding: "0 40px",
+ minHeight: "300px",
+ },
+});
+
+const SuccessImage = styled.img({
+ width: "180px",
+ height: "180px",
+ objectFit: "cover",
+
+ "@media (max-width: 375px)": {
+ width: "150px",
+ height: "150px",
+ },
+});
+
+const SuccessTitle = styled.h3({
+ margin: 0,
+ color: "#1e1e1e",
+ fontSize: "40px",
+ lineHeight: "50px",
+ fontWeight: fontWeights.bold,
+ textAlign: "center",
+ whiteSpace: "pre-line",
+ "@media (max-width: 1024px)": {
+ fontSize: "34px",
+ lineHeight: "45px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "30px",
+ lineHeight: "36px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "20px",
+ lineHeight: "25px",
+ },
+});
+
+const SuccessDescription = styled.p({
+ margin: 0,
+ color: "#525252",
+ fontSize: "16px",
+ lineHeight: "20px",
+ fontWeight: fontWeights.medium,
+ textAlign: "center",
+
+ "@media (max-width: 1024px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "13px",
+ lineHeight: "16px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "12px",
+ lineHeight: "15px",
+ },
+});
+
+const SuccessTimerText = styled.p({
+ margin: 0,
+ color: "#0d82f9",
+ fontSize: "12px",
+ lineHeight: "15px",
+ fontWeight: fontWeights.regular,
+ textAlign: "center",
+
+ "@media (max-width: 1024px)": {
+ fontSize: "11px",
+ lineHeight: "14px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "10px",
+ lineHeight: "14px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "9px",
+ lineHeight: "12px",
+ },
+});
+
+const ConfirmWrap = styled.div({
+ width: "100%",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: "60px",
+ padding: "0 80px",
+ minHeight: "332px",
+
+ "@media (max-width: 375px)": {
+ minHeight: "260px",
+ padding: "0 20px",
+ gap: "40px",
+ },
+});
+
+const ConfirmHeader = styled.div({
+ width: "100%",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "20px",
+ textAlign: "center",
+});
+
+const ConfirmTitle = styled.h3({
+ margin: 0,
+ color: "#202325",
+ fontSize: "40px",
+ lineHeight: "50px",
+ fontWeight: fontWeights.bold,
+ "@media (max-width: 1024px)": {
+ fontSize: "34px",
+ lineHeight: "45px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "20px",
+ lineHeight: "25px",
+ },
+});
+
+const ConfirmDescription = styled.p({
+ margin: 0,
+ color: "#62748e",
+ fontSize: "24px",
+ lineHeight: "30px",
+ fontWeight: fontWeights.medium,
+ whiteSpace: "pre-line",
+
+ "@media (max-width: 1024px)": {
+ fontSize: "25px",
+ lineHeight: "25px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+});
+
+const ConfirmActions = styled.div({
+ width: "430px",
+ display: "flex",
+ flexDirection: "column",
+ gap: "12px",
+
+ "@media (max-width: 375px)": {
+ width: "100%",
+ },
+});
+
+const ConfirmPrimaryButton = styled.button({
+ width: "100%",
+ height: "65px",
+ borderRadius: "100px",
+ border: "none",
+ background: colors.primary,
+ color: colors.textInverse,
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ cursor: "pointer",
+
+ "@media (max-width: 1024px)": {
+ height: "60px",
+ fontSize: "19px",
+ lineHeight: "26px",
+ },
+ "@media (max-width: 375px)": {
+ height: "56px",
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+const ConfirmSecondaryButton = styled.button({
+ width: "100%",
+ height: "65px",
+ borderRadius: "100px",
+ border: "none",
+ background: "#f1f5f9",
+ color: "#202325",
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ cursor: "pointer",
+
+ "@media (max-width: 1024px)": {
+ height: "60px",
+ fontSize: "19px",
+ lineHeight: "26px",
+ },
+ "@media (max-width: 375px)": {
+ height: "56px",
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+export const PreAlertModal = () => {
+ const [open, setOpen] = useState(false);
+ const [step, setStep] = useState("form");
+ const [values, setValues] = useState(INITIAL_VALUES);
+ const [showError, setShowError] = useState(false);
+ const [submitErrorMessage, setSubmitErrorMessage] = useState(null);
+
+ const hasAnyInput = useMemo(
+ () => Object.values(values).some((value) => value.trim().length > 0),
+ [values],
+ );
+
+ useEffect(() => {
+ const openHandler = () => {
+ setOpen(true);
+ setStep("form");
+ setValues(INITIAL_VALUES);
+ setShowError(false);
+ setSubmitErrorMessage(null);
+ };
+
+ const escHandler = (event: KeyboardEvent) => {
+ if (!open || event.key !== "Escape") return;
+ if (step === "success") {
+ setOpen(false);
+ return;
+ }
+ if (hasAnyInput) {
+ setStep("confirm-close");
+ return;
+ }
+ setOpen(false);
+ };
+
+ window.addEventListener(PRE_ALERT_MODAL_OPEN_EVENT, openHandler);
+ window.addEventListener("keydown", escHandler);
+ return () => {
+ window.removeEventListener(PRE_ALERT_MODAL_OPEN_EVENT, openHandler);
+ window.removeEventListener("keydown", escHandler);
+ };
+ }, [hasAnyInput, open, step]);
+
+ useEffect(() => {
+ if (!open || step !== "success") return;
+ const timer = window.setTimeout(() => {
+ setOpen(false);
+ setStep("form");
+ setValues(INITIAL_VALUES);
+ setShowError(false);
+ setSubmitErrorMessage(null);
+ }, 3000);
+ return () => window.clearTimeout(timer);
+ }, [open, step]);
+
+ useEffect(() => {
+ if (!open) return;
+
+ const { body, documentElement } = document;
+ const previousBodyOverflow = body.style.overflow;
+ const previousHtmlOverflow = documentElement.style.overflow;
+
+ body.style.overflow = "hidden";
+ documentElement.style.overflow = "hidden";
+
+ return () => {
+ body.style.overflow = previousBodyOverflow;
+ documentElement.style.overflow = previousHtmlOverflow;
+ };
+ }, [open]);
+
+ const onCloseRequest = () => {
+ if (step === "success") {
+ setOpen(false);
+ return;
+ }
+ if (hasAnyInput) {
+ setStep("confirm-close");
+ return;
+ }
+ setOpen(false);
+ };
+
+ const onBackdropClick = (event: React.MouseEvent) => {
+ if (event.target !== event.currentTarget) return;
+ onCloseRequest();
+ };
+
+ const validate = () => {
+ const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email);
+ return emailOk;
+ };
+
+ const onSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!validate()) {
+ setShowError(true);
+ setSubmitErrorMessage(null);
+ return;
+ }
+ setShowError(false);
+ setSubmitErrorMessage(null);
+
+ try {
+ await subscribeEarlyNotificationWithActiveCohort(values.email);
+ setStep("success");
+ } catch (error) {
+ if (error instanceof ApiError) {
+ setSubmitErrorMessage(error.message);
+ return;
+ }
+ if (error instanceof Error && error.message) {
+ setSubmitErrorMessage(error.message);
+ return;
+ }
+ setSubmitErrorMessage("사전 알림 신청 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
+ }
+ };
+
+ if (!open) return null;
+
+ return (
+
+
+
+
+ ×
+
+
+
+ {step === "form" && (
+ <>
+
+
+
+ >
+ )}
+
+ {step === "success" && (
+ <>
+
+
+ {"14기 모집 알림 신청이\n완료되었어요!"}
+ DDD 크루 모집 시, 이메일로 알려드릴게요.
+ 3초 뒤에 자동으로 화면이 닫힙니다.
+
+ >
+ )}
+
+ {step === "confirm-close" && (
+ <>
+
+
+ 모집 알림을 신청하지 않고 닫으실 건가요?
+
+ {"지금 닫으시면 작성된 내용은 모두 사라집니다.\n그래도 닫으시겠습니까?"}
+
+
+
+ setOpen(false)}>
+ 나가기
+
+ setStep("form")}>
+ 취소
+
+
+
+ >
+ )}
+
+
+
+ );
+};
diff --git a/apps/web/components/providers/RecruitStatusProvider.tsx b/apps/web/components/providers/RecruitStatusProvider.tsx
new file mode 100644
index 0000000..def277f
--- /dev/null
+++ b/apps/web/components/providers/RecruitStatusProvider.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { createContext, useContext } from "react";
+import type { ReactNode } from "react";
+import type { RecruitStatus } from "@/constants/recruit";
+import { recruitButtonLabelsByStatus } from "@/constants/recruit";
+
+type RecruitStatusContextValue = {
+ recruitStatus: RecruitStatus;
+ isRecruitOpen: boolean;
+ recruitButtonLabels: {
+ navigation: string;
+ hero: string;
+ role: string;
+ };
+};
+
+const RecruitStatusContext = createContext({
+ recruitStatus: "closed",
+ isRecruitOpen: false,
+ recruitButtonLabels: recruitButtonLabelsByStatus.closed,
+});
+
+export const RecruitStatusProvider = ({
+ recruitStatus,
+ children,
+}: {
+ recruitStatus: RecruitStatus;
+ children: ReactNode;
+}) => {
+ const value: RecruitStatusContextValue = {
+ recruitStatus,
+ isRecruitOpen: recruitStatus === "open",
+ recruitButtonLabels: recruitButtonLabelsByStatus[recruitStatus],
+ };
+
+ return {children};
+};
+
+export const useRecruitStatus = () => useContext(RecruitStatusContext);
diff --git a/apps/web/components/sections/AboutSection.tsx b/apps/web/components/sections/AboutSection.tsx
new file mode 100644
index 0000000..6bb30ce
--- /dev/null
+++ b/apps/web/components/sections/AboutSection.tsx
@@ -0,0 +1,284 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import styled from "@emotion/styled";
+import { colors, fontWeights } from "@/constants/tokens";
+
+const STATS = [
+ { label: "DDD가 탄생한지", value: "10년" },
+ { label: "누적 멤버 수", value: "470명+" },
+ { label: "런칭 성공률", value: "nn%" },
+] as const;
+
+const Section = styled.section({
+ background: colors.background,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ padding: "120px 80px",
+ gap: "80px",
+
+ "@media (max-width: 1024px)": { padding: "120px 80px" },
+ "@media (max-width: 768px)": { padding: "100px 40px" },
+ "@media (max-width: 375px)": { padding: "80px 16px" },
+});
+
+const Inner = styled.div({
+ width: "100%",
+ maxWidth: "1280px",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "80px",
+
+ "@media (max-width: 768px)": {
+ gap: "56px",
+ },
+ "@media (max-width: 375px)": {
+ gap: "40px",
+ },
+});
+
+const TitleArea = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "24px",
+ textAlign: "center",
+ width: "100%",
+});
+
+const SectionLabel = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontWeight: fontWeights.medium,
+ color: colors.textInverse,
+ fontSize: "20px",
+ lineHeight: "28px",
+ "@media (max-width: 1024px)": {
+ fontSize: "18px",
+ lineHeight: "23px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+const Title = styled.h2({
+ fontFamily: "'Pretendard', sans-serif",
+ fontWeight: fontWeights.bold,
+ color: colors.slate200,
+ whiteSpace: "pre-wrap",
+ fontSize: "64px",
+ lineHeight: "75px",
+ "@media (max-width: 1024px)": {
+ fontSize: "54px",
+ lineHeight: "65px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "42px",
+ lineHeight: "52px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "38px",
+ lineHeight: "48px",
+ },
+});
+
+const TitleHighlight = styled.span({
+ color: colors.textInverse,
+});
+
+const TitleMuted = styled.span({
+ color: colors.slate500,
+});
+
+const StatsGrid = styled.div({
+ display: "flex",
+ gap: "24px",
+ width: "100%",
+
+ "@media (max-width: 768px)": {
+ flexDirection: "column",
+ },
+});
+
+const StatCard = styled.div({
+ flex: "1 0 0",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: "4px",
+ padding: "50px 20px",
+ background: colors.backgroundDark,
+ borderRadius: "20px",
+ boxShadow: "inset 3px 3px 25px 0px rgba(146, 146, 146, 0.25)",
+
+ "@media (max-width: 768px)": {
+ padding: "40px 16px",
+ },
+ "@media (max-width: 375px)": {
+ padding: "28px 16px",
+ borderRadius: "16px",
+ },
+});
+
+const StatLabel = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontWeight: fontWeights.medium,
+ color: colors.slate300,
+ textAlign: "center",
+ fontSize: "20px",
+ lineHeight: "28px",
+ "@media (max-width: 1024px)": {
+ fontSize: "18px",
+ lineHeight: "23px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+const StatValue = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontWeight: fontWeights.bold,
+ color: colors.textInverse,
+ textAlign: "center",
+ fontSize: "80px",
+ lineHeight: "75px",
+ "@media (max-width: 1024px)": {
+ fontSize: "70px",
+ lineHeight: "65px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "60px",
+ lineHeight: "52px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "28px",
+ lineHeight: "35px",
+ },
+});
+
+const useCountUpOnView = (target: number, enabled: boolean) => {
+ const [value, setValue] = useState(0);
+
+ useEffect(() => {
+ if (!enabled) {
+ // 섹션이 화면에서 벗어났다가 다시 들어올 때
+ // 동일한 “지금처럼” 애니메이션을 매번 재생하기 위해 리셋합니다.
+ setValue(0);
+ return;
+ }
+
+ // enabled 전환 시 항상 0부터 카운트업 시작
+ setValue(0);
+
+ const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
+ if (reduceMotion) {
+ let raf = 0;
+ raf = requestAnimationFrame(() => setValue(target));
+ return () => cancelAnimationFrame(raf);
+ }
+
+ let raf = 0;
+ const durationMs = 1200;
+ const start = performance.now();
+
+ const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
+
+ const tick = (now: number) => {
+ const progress = Math.min(1, (now - start) / durationMs);
+ const eased = easeOutCubic(progress);
+ setValue(Math.round(target * eased));
+ if (progress < 1) raf = requestAnimationFrame(tick);
+ };
+
+ raf = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(raf);
+ }, [enabled, target]);
+
+ return value;
+};
+
+const useInViewToggle = () => {
+ const ref = useRef(null);
+ const [inView, setInView] = useState(false);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ // 화면에 들어오면 true, 벗어나면 false로 토글합니다.
+ // enabled가 토글될 때마다 count-up이 재생됩니다.
+ setInView(Boolean(entry?.isIntersecting));
+ },
+ { threshold: 0.35 },
+ );
+
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+
+ return { ref, inView };
+};
+
+const formatWithCommas = (n: number) => n.toLocaleString("ko-KR");
+
+const parseNumericStat = (raw: string) => {
+ const match = raw.match(/(\d[\d,]*)/);
+ if (!match) return null;
+ const numeric = Number(match[1].replaceAll(",", ""));
+ if (!Number.isFinite(numeric)) return null;
+ const suffix = raw.replace(match[0], "");
+ return { numeric, suffix };
+};
+
+const StatValueCountUp = ({ rawValue, enabled }: { rawValue: string; enabled: boolean }) => {
+ const parsed = useMemo(() => parseNumericStat(rawValue), [rawValue]);
+ const animated = useCountUpOnView(parsed?.numeric ?? 0, enabled && Boolean(parsed));
+
+ if (!parsed) return <>{rawValue}>;
+ return <>{`${formatWithCommas(animated)}${parsed.suffix}`}>;
+};
+
+export const AboutSection = () => {
+ const { ref, inView } = useInViewToggle();
+
+ return (
+
+
+
+ About Us
+
+ 함께 성장하고 싶은 {"\n"}
+ PM, 디자이너, 개발자
+ {`가 모여 \nDDD에서 프로젝트를 만들어요.`}
+
+
+
+ {STATS.map(({ label, value }) => (
+
+ {label}
+
+
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/apps/web/components/sections/ArticleListPageSection.tsx b/apps/web/components/sections/ArticleListPageSection.tsx
new file mode 100644
index 0000000..78df16f
--- /dev/null
+++ b/apps/web/components/sections/ArticleListPageSection.tsx
@@ -0,0 +1,266 @@
+"use client";
+
+import { useState } from "react";
+import styled from "@emotion/styled";
+import { fontWeights } from "@/constants/tokens";
+import type { ArticleItem } from "@/constants/articles";
+import { fetchPublicArticlesPage } from "@/lib/api/blog";
+
+const Section = styled.section({
+ background: "#fff",
+});
+
+const Banner = styled.div({
+ padding: "160px 80px",
+ position: "relative",
+ overflow: "hidden",
+ minHeight: "330px",
+ backgroundColor: "#02111f",
+ backgroundImage:
+ "linear-gradient(90deg, #02111f 7.926%, #072d3e 66.31%, #011924 100%), url('https://www.figma.com/api/mcp/asset/6f928e32-36e6-4c5d-886d-63789ff48cea')",
+ backgroundSize: "cover",
+ backgroundPosition: "center",
+
+ "@media (max-width: 1024px)": { padding: "160px 80px 80px", minHeight: "323px" },
+ "@media (max-width: 768px)": { padding: "140px 40px 50px", minHeight: "300px" },
+ "@media (max-width: 375px)": { padding: "160px 16px 20px", minHeight: "300px" },
+});
+
+const Heading = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "8px",
+ maxWidth: "1280px",
+ margin: "0 auto",
+});
+
+const BannerLabel = styled.p({
+ margin: 0,
+ color: "#62748e",
+ fontSize: "28px",
+ lineHeight: "32px",
+ fontWeight: fontWeights.semiBold,
+ "@media (max-width: 1024px)": { fontSize: "24px", lineHeight: "30px" },
+ "@media (max-width: 768px)": { fontSize: "20px", lineHeight: "25px" },
+ "@media (max-width: 375px)": { fontSize: "12px", lineHeight: "15px" },
+});
+
+const BannerTitle = styled.h1({
+ margin: "8px 0 0",
+ color: "#cad5e2",
+ fontSize: "40px",
+ lineHeight: "50px",
+ fontWeight: fontWeights.bold,
+ "@media (max-width: 1024px)": { fontSize: "34px", lineHeight: "45px" },
+ "@media (max-width: 768px)": { fontSize: "30px", lineHeight: "38px" },
+ "@media (max-width: 375px)": { fontSize: "24px", lineHeight: "30px" },
+});
+
+const ContentSection = styled.div({
+ padding: "80px 80px",
+ "@media (max-width: 1024px)": { padding: "80px" },
+ "@media (max-width: 768px)": { padding: "48px 40px" },
+ "@media (max-width: 375px)": { padding: "40px 16px" },
+});
+
+const Body = styled.div({
+ maxWidth: "1280px",
+ margin: "0 auto",
+});
+
+const List = styled.div({
+ display: "flex",
+ flexDirection: "column",
+});
+
+const Row = styled.article({
+ display: "grid",
+ gridTemplateColumns: "410px 1fr",
+ alignItems: "center",
+ gap: "24px",
+ padding: "40px 0",
+ borderBottom: "1px solid #c9c9c9",
+
+ "@media (max-width: 1024px)": {
+ gridTemplateColumns: "340px 1fr",
+ },
+ "@media (max-width: 768px)": {
+ gridTemplateColumns: "1fr",
+ gap: "24px",
+ padding: "20px 0",
+ },
+});
+
+const Thumbnail = styled.img({
+ width: "100%",
+ height: "324px",
+ objectFit: "cover",
+ borderRadius: "30px",
+ display: "block",
+
+ "@media (max-width: 1024px)": {
+ height: "260px",
+ },
+ "@media (max-width: 768px)": {
+ height: "220px",
+ borderRadius: "20px",
+ },
+ "@media (max-width: 375px)": {
+ height: "222px",
+ borderRadius: "25px",
+ },
+});
+
+const TextWrap = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "12px",
+ minWidth: 0,
+});
+
+const Title = styled.h2({
+ margin: 0,
+ color: "#202325",
+ fontSize: "28px",
+ lineHeight: "32px",
+ fontWeight: fontWeights.semiBold,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ "@media (max-width: 1024px)": { fontSize: "24px", lineHeight: "30px" },
+ "@media (max-width: 768px)": { fontSize: "20px", lineHeight: "25px" },
+ "@media (max-width: 375px)": { fontSize: "16px", lineHeight: "20px" },
+});
+
+const Description = styled.p({
+ margin: 0,
+ color: "#525252",
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ display: "-webkit-box",
+ WebkitLineClamp: 3,
+ WebkitBoxOrient: "vertical",
+ overflow: "hidden",
+
+ "@media (max-width: 1024px)": { fontSize: "18px", lineHeight: "23px" },
+ "@media (max-width: 768px)": { fontSize: "16px", lineHeight: "20px" },
+ "@media (max-width: 375px)": { fontSize: "14px", lineHeight: "18px" },
+});
+
+const Pagination = styled.div({
+ marginTop: "80px",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: "40px",
+ color: "#d4d4d4",
+ fontSize: "20px",
+ lineHeight: "25px",
+ fontWeight: fontWeights.medium,
+
+ "@media (max-width: 768px)": {
+ marginTop: "48px",
+ gap: "24px",
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+const Arrow = styled.span({
+ color: "#cad5e2",
+ fontSize: "18px",
+});
+
+type Props = {
+ initialItems?: ArticleItem[];
+ initialNextCursor?: string | null;
+};
+
+const PaginationButton = styled.button<{ disabled?: boolean }>(({ disabled }) => ({
+ border: "none",
+ background: "transparent",
+ color: disabled ? "#9aa8bb" : "#cad5e2",
+ fontSize: "18px",
+ cursor: disabled ? "not-allowed" : "pointer",
+}));
+
+export const ArticleListPageSection = ({
+ initialItems = [],
+ initialNextCursor = null,
+}: Props) => {
+ const [articleItems, setArticleItems] = useState(initialItems);
+ const [nextCursor, setNextCursor] = useState(initialNextCursor);
+ const [cursorHistory, setCursorHistory] = useState>([null]);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const loadNextPage = async () => {
+ if (!nextCursor || isLoading) return;
+ setIsLoading(true);
+ try {
+ const page = await fetchPublicArticlesPage({ cursor: nextCursor, limit: 4 });
+ setArticleItems(page.items);
+ setCursorHistory((prev) => [...prev, nextCursor]);
+ setNextCursor(page.nextCursor);
+ setCurrentPage((prev) => prev + 1);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const loadPrevPage = async () => {
+ if (cursorHistory.length <= 1 || isLoading) return;
+ const prevHistory = [...cursorHistory];
+ prevHistory.pop();
+ const prevCursor = prevHistory[prevHistory.length - 1] ?? null;
+ setIsLoading(true);
+ try {
+ const page = await fetchPublicArticlesPage({ cursor: prevCursor ?? undefined, limit: 4 });
+ setArticleItems(page.items);
+ setNextCursor(page.nextCursor);
+ setCursorHistory(prevHistory);
+ setCurrentPage((prev) => Math.max(1, prev - 1));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Article
+ 일잘러들의 생각, 글로 남겼어요.
+
+
+
+
+
+ {articleItems.map((article) => (
+
+
+
+ {article.title}
+ {article.description}
+
+
+ ))}
+
+
+
+ ‹
+
+ {currentPage}
+
+ ›
+
+
+
+
+
+ );
+};
diff --git a/apps/web/components/sections/BlogSection.tsx b/apps/web/components/sections/BlogSection.tsx
new file mode 100644
index 0000000..2e22654
--- /dev/null
+++ b/apps/web/components/sections/BlogSection.tsx
@@ -0,0 +1,377 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import styled from "@emotion/styled";
+import { colors, fontSizes, fontWeights, lineHeights } from "@/constants/tokens";
+import type { ArticleItem } from "@/constants/articles";
+
+const Section = styled.section({
+ background: colors.background,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "flex-start",
+ padding: "120px 80px",
+
+ "@media (max-width: 1024px)": { padding: "120px 80px" },
+ "@media (max-width: 768px)": { padding: "100px 40px" },
+ "@media (max-width: 375px)": { padding: "80px 16px" },
+});
+
+const Inner = styled.div({
+ width: "100%",
+ maxWidth: "1280px",
+ margin: "0 auto",
+ display: "flex",
+ flexDirection: "column",
+ gap: "40px",
+});
+
+const TitleArea = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "24px",
+
+ "@media (max-width: 375px)": {
+ gap: "16px",
+ },
+});
+
+const SectionLabel = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: "20px",
+ fontWeight: fontWeights.medium,
+ lineHeight: "28px",
+ color: colors.textInverse,
+ "@media (max-width: 1024px)": {
+ fontSize: "18px",
+ lineHeight: "23px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+const TitleGroup = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "8px",
+});
+
+const SectionSubtitle = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: "28px",
+ fontWeight: fontWeights.semiBold,
+ lineHeight: "32px",
+ color: colors.textInverse,
+ "@media (max-width: 1024px)": {
+ fontSize: "24px",
+ lineHeight: "30px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "20px",
+ lineHeight: "25px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+});
+
+const ArticleList = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "24px",
+ width: "100%",
+
+ "@media (max-width: 768px)": {
+ flexDirection: "row",
+ overflowX: "auto",
+ scrollSnapType: "x mandatory",
+ gap: "12px",
+ WebkitOverflowScrolling: "touch",
+ scrollbarWidth: "none",
+ msOverflowStyle: "none",
+
+ "&::-webkit-scrollbar": {
+ display: "none",
+ },
+ },
+});
+
+const ArticleCard = styled.article({
+ display: "flex",
+ gap: "25px",
+ alignItems: "center",
+ background: "white",
+ borderRadius: "30px",
+ overflow: "hidden",
+ height: "324px",
+ paddingRight: "25px",
+
+ "@media (max-width: 768px)": {
+ height: "189px",
+ borderRadius: "20px",
+ padding: "20px",
+ minWidth: "100%",
+ flex: "0 0 100%",
+ scrollSnapAlign: "start",
+ },
+});
+
+const ArticleThumbnail = styled.div({
+ width: "410px",
+ height: "100%",
+ flexShrink: 0,
+
+ "& img": {
+ width: "100%",
+ height: "100%",
+ objectFit: "cover",
+ },
+
+ "@media (max-width: 768px)": {
+ display: "none",
+ },
+});
+
+const ArticleContent = styled.div({
+ flex: "1 0 0",
+ display: "flex",
+ flexDirection: "column",
+ gap: "12px",
+ minWidth: 0,
+
+ "@media (max-width: 768px)": {
+ padding: 0,
+ },
+});
+
+const ArticleTitle = styled.h3({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.headingLarge,
+ fontWeight: fontWeights.semiBold,
+ lineHeight: lineHeights.headingLarge,
+ color: colors.textPrimary,
+
+ "@media (max-width: 1024px)": {
+ fontSize: "24px",
+ lineHeight: "30px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "20px",
+ lineHeight: "25px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+});
+
+const ArticleDescription = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.medium,
+ fontWeight: fontWeights.regular,
+ lineHeight: lineHeights.paragraphMedium,
+ color: colors.textSecondary,
+ overflow: "hidden",
+ display: "-webkit-box",
+ WebkitLineClamp: 3,
+ WebkitBoxOrient: "vertical",
+
+ "@media (max-width: 1024px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ WebkitLineClamp: 2,
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "13px",
+ lineHeight: "18px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "12px",
+ lineHeight: "15px",
+ },
+});
+
+const ContentAndButton = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "40px",
+});
+
+const MobileBulletRow = styled.div({
+ display: "none",
+
+ "@media (max-width: 768px)": {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: "10px",
+ },
+});
+
+const MobileBullet = styled.button<{ active: boolean }>(({ active }) => ({
+ width: "10px",
+ height: "10px",
+ borderRadius: "50%",
+ background: active ? colors.slate500 : colors.slate200,
+ opacity: active ? 1 : 0.9,
+ border: "none",
+ padding: 0,
+ cursor: "pointer",
+}));
+
+const MoreButton = styled(Link)({
+ display: "flex",
+ alignItems: "center",
+ gap: "4px",
+ height: "80px",
+ padding: "20px 50px",
+ background: colors.primary,
+ borderRadius: "100px",
+ color: colors.textInverse,
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.large,
+ fontWeight: fontWeights.medium,
+ lineHeight: lineHeights.paragraphLarge,
+ textDecoration: "none",
+ whiteSpace: "nowrap",
+ flexShrink: 0,
+ transition: "background 0.15s",
+
+ "&:hover": {
+ background: "#1f5fe0",
+ },
+
+ "@media (max-width: 1024px)": {
+ fontSize: "18px",
+ lineHeight: "24px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+type Props = {
+ items: ArticleItem[];
+};
+
+export const BlogSection = ({ items }: Props) => {
+ const [activeSlide, setActiveSlide] = useState(0);
+ const articleListRef = useRef(null);
+ const sourceArticles: readonly ArticleItem[] = items;
+
+ const updateActiveSlide = useCallback(() => {
+ const container = articleListRef.current;
+ if (!container) return;
+
+ const slides = Array.from(container.children) as HTMLElement[];
+ if (slides.length === 0) return;
+
+ const scrollLeft = container.scrollLeft;
+ let nearestIndex = 0;
+ let nearestDistance = Number.POSITIVE_INFINITY;
+
+ slides.forEach((slide, index) => {
+ const distance = Math.abs(slide.offsetLeft - scrollLeft);
+ if (distance < nearestDistance) {
+ nearestDistance = distance;
+ nearestIndex = index;
+ }
+ });
+
+ setActiveSlide(nearestIndex);
+ }, []);
+
+ const handleBulletClick = useCallback((index: number) => {
+ const container = articleListRef.current;
+ if (!container) return;
+
+ const target = container.children[index] as HTMLElement | undefined;
+ if (!target) return;
+
+ container.scrollTo({ left: target.offsetLeft, behavior: "smooth" });
+ setActiveSlide(index);
+ }, []);
+
+ useEffect(() => {
+ const container = articleListRef.current;
+ if (!container) return;
+
+ const onScroll = () => updateActiveSlide();
+ container.addEventListener("scroll", onScroll, { passive: true });
+ let raf = 0;
+ raf = requestAnimationFrame(() => updateActiveSlide());
+
+ return () => {
+ container.removeEventListener("scroll", onScroll);
+ cancelAnimationFrame(raf);
+ };
+ }, [updateActiveSlide, items]);
+
+ return (
+
+
+
+ Article
+
+
+ 트렌드 분석, 실무 인사이트, 커리어 고민까지. DDD 멤버들이 직접 쓴 아티클을 먼저
+ 만나보세요.
+
+
+
+
+
+ {sourceArticles.map(({ id, title, description, thumbnail }) => (
+
+
+
+
+
+ {title}
+ {description}
+
+
+ ))}
+
+
+ {sourceArticles.map(({ id }, index) => (
+ handleBulletClick(index)}
+ />
+ ))}
+
+
+ 더 알아보기
+ {" "}
+
+
+
+
+ );
+};
diff --git a/apps/web/components/sections/CtaSection.tsx b/apps/web/components/sections/CtaSection.tsx
new file mode 100644
index 0000000..d358ac1
--- /dev/null
+++ b/apps/web/components/sections/CtaSection.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import Link from "next/link";
+import styled from "@emotion/styled";
+import { openPreAlertModal } from "@/components/modals/PreAlertModal";
+import { useRecruitStatus } from "@/components/providers/RecruitStatusProvider";
+import { colors, fontSizes, fontWeights, lineHeights } from "@/constants/tokens";
+
+const Section = styled.section({
+ background: colors.background,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: "120px 80px",
+ gap: "24px",
+ textAlign: "center",
+
+ "@media (max-width: 1024px)": { padding: "120px 80px" },
+ "@media (max-width: 768px)": { padding: "100px 40px" },
+ "@media (max-width: 375px)": { padding: "80px 16px" },
+});
+
+const Inner = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "24px",
+
+ "@media (max-width: 375px)": {
+ gap: "16px",
+ },
+});
+
+const Headline = styled.h2({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: "40px",
+ fontWeight: fontWeights.bold,
+ lineHeight: "50px",
+ color: colors.textInverse,
+ "@media (max-width: 1024px)": {
+ fontSize: "34px",
+ lineHeight: "45px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "30px",
+ lineHeight: "38px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "20px",
+ lineHeight: "25px",
+ },
+});
+
+const HeadlineHighlight = styled.span({
+ color: colors.primary,
+});
+
+const CtaButton = styled(Link)({
+ display: "flex",
+ alignItems: "center",
+ gap: "4px",
+ height: "80px",
+ padding: "20px 50px",
+ background: colors.primary,
+ borderRadius: "100px",
+ color: colors.textInverse,
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.large,
+ fontWeight: fontWeights.medium,
+ lineHeight: "28px",
+ textDecoration: "none",
+ whiteSpace: "nowrap",
+ flexShrink: 0,
+ transition: "background 0.15s",
+
+ "&:hover": {
+ background: "#1f5fe0",
+ },
+
+ "@media (max-width: 1024px)": {
+ fontSize: "18px",
+ lineHeight: "24px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+export const CtaSection = () => {
+ const { isRecruitOpen, recruitButtonLabels } = useRecruitStatus();
+
+ return (
+
+
+
+ 성장은 혼자 하는 게 아니에요.
+
+ 함께 만들고, 함께 성장할 동료를 DDD
+ 에서 만나보세요.
+
+ {
+ if (isRecruitOpen) return;
+ event.preventDefault();
+ openPreAlertModal();
+ }}
+ >
+ {recruitButtonLabels.hero}
+ {" "}
+
+
+
+ );
+};
diff --git a/apps/web/components/sections/FaqSection.tsx b/apps/web/components/sections/FaqSection.tsx
new file mode 100644
index 0000000..74035c7
--- /dev/null
+++ b/apps/web/components/sections/FaqSection.tsx
@@ -0,0 +1,253 @@
+"use client";
+
+import { useState } from "react";
+import styled from "@emotion/styled";
+import { colors, fontSizes, fontWeights, lineHeights } from "@/constants/tokens";
+
+interface FaqItem {
+ question: string;
+ answer: string;
+}
+
+const FAQ_ITEMS: FaqItem[] = [
+ {
+ question: "현직자가 아니어도 지원할 수 있나요?",
+ answer: "네, 가능해요. 직군과 무관하게 함께 만들고 성장하려는 열정이 있다면 누구든 환영해요.",
+ },
+ {
+ question: "사이드프로젝트 경험이 없어도 괜찮나요?",
+ answer:
+ "네, 없어도 괜찮아요. DDD는 처음 사이드프로젝트를 경험하는 분들도 함께 성장할 수 있는 환경을 제공해요.",
+ },
+ {
+ question: "팀은 어떻게 구성되나요?",
+ answer:
+ "기수별로 PM, 디자이너, 개발자(iOS, AOS, WEB)가 함께하는 팀으로 구성되며, 모집 과정에서 적절히 배정돼요.",
+ },
+ {
+ question: "정기 모임은 어떻게 진행되나요?",
+ answer:
+ "격주 토요일에 오프라인 정기 모임이 진행되며, 팀 프로젝트 진행 상황 공유 및 네트워킹 시간을 가져요.",
+ },
+ {
+ question: "한 기수 활동 기간은 얼마나 되나요?",
+ answer:
+ "한 기수는 약 6개월로 구성되며, 기수 종료 시 데모데이를 통해 팀별 프로젝트 결과물을 발표해요.",
+ },
+];
+
+const Section = styled.section({
+ width: "100%",
+ padding: "120px 80px",
+ background:
+ "url(\"data:image/svg+xml;utf8,\"), linear-gradient(90deg, rgb(12, 14, 15) 0%, rgb(12, 14, 15) 100%)",
+ backgroundSize: "cover",
+ display: "flex",
+ justifyContent: "center",
+
+ "@media (max-width: 1024px)": { padding: "120px 80px" },
+ "@media (max-width: 768px)": { padding: "100px 40px" },
+ "@media (max-width: 375px)": { padding: "80px 16px" },
+});
+
+const Inner = styled.div({
+ width: "100%",
+ maxWidth: "1280px",
+ display: "flex",
+ flexDirection: "column",
+ gap: "56px",
+});
+
+const TitleArea = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "24px",
+ alignItems: "center",
+});
+
+const SectionLabel = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: "20px",
+ fontWeight: fontWeights.medium,
+ lineHeight: "28px",
+ color: colors.textInverse,
+ "@media (max-width: 1024px)": {
+ fontSize: "18px",
+ lineHeight: "23px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+const SectionTitle = styled.h2({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: "28px",
+ fontWeight: fontWeights.semiBold,
+ lineHeight: "32px",
+ color: colors.textInverse,
+ "@media (max-width: 1024px)": {
+ fontSize: "24px",
+ lineHeight: "30px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "28px",
+ lineHeight: "34px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+});
+
+const AccordionList = styled.dl({
+ display: "flex",
+ flexDirection: "column",
+ width: "100%",
+});
+
+const AccordionItem = styled.div({
+ borderBottom: `1px solid ${colors.border}`,
+ contain: "layout paint",
+});
+
+interface AccordionTriggerProps {
+ isOpen: boolean;
+}
+
+const AccordionTrigger = styled.button({
+ width: "100%",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: "36px",
+ height: "73px",
+ padding: "10px",
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ textAlign: "left",
+});
+
+const AccordionQuestion = styled.dt({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.headingMedium,
+ fontWeight: fontWeights.medium,
+ lineHeight: lineHeights.headingMedium,
+ color: colors.textInverse,
+ flex: "1 0 0",
+ "@media (max-width: 1024px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "13px",
+ lineHeight: "16px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "12px",
+ lineHeight: "15px",
+ },
+});
+
+const ChevronIcon = styled.svg({
+ width: "24px",
+ height: "24px",
+ flexShrink: 0,
+ display: "block",
+});
+
+const AccordionBody = styled.dd<{ isOpen: boolean }>(({ isOpen }) => ({
+ display: "grid",
+ gridTemplateRows: isOpen ? "1fr" : "0fr",
+ opacity: isOpen ? 1 : 0,
+ padding: "0 24px",
+ transition: "grid-template-rows 0.2s ease, opacity 0.15s ease",
+ pointerEvents: isOpen ? "auto" : "none",
+}));
+
+const AccordionBodyInner = styled.div<{ isOpen: boolean }>(({ isOpen }) => ({
+ overflow: "hidden",
+ minHeight: 0,
+ paddingTop: isOpen ? "4px" : "0",
+ paddingBottom: isOpen ? "24px" : "0",
+}));
+
+const AccordionAnswer = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.medium,
+ fontWeight: fontWeights.regular,
+ lineHeight: lineHeights.paragraphMedium,
+ color: colors.slate300,
+ "@media (max-width: 1024px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "13px",
+ lineHeight: "18px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "12px",
+ lineHeight: "15px",
+ },
+});
+
+export const FaqSection = () => {
+ const [openIndex, setOpenIndex] = useState(0);
+
+ const handleToggle = (index: number) => {
+ setOpenIndex(openIndex === index ? null : index);
+ };
+
+ return (
+
+
+
+ FAQ
+ DDD에 대해 궁금한 점이 있으신가요?
+
+
+ {FAQ_ITEMS.map(({ question, answer }, index) => {
+ const isOpen = openIndex === index;
+ return (
+
+ handleToggle(index)}
+ isOpen={isOpen}
+ >
+ Q. {question}
+
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ A. {answer}
+
+
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/apps/web/components/sections/HeroSection.tsx b/apps/web/components/sections/HeroSection.tsx
new file mode 100644
index 0000000..69c0130
--- /dev/null
+++ b/apps/web/components/sections/HeroSection.tsx
@@ -0,0 +1,233 @@
+"use client";
+
+import Link from "next/link";
+import styled from "@emotion/styled";
+import { assets } from "@/constants/assets";
+import { openPreAlertModal } from "@/components/modals/PreAlertModal";
+import { useRecruitStatus } from "@/components/providers/RecruitStatusProvider";
+import { colors, fontSizes, fontWeights, lineHeights } from "@/constants/tokens";
+
+const Section = styled.section({
+ position: "relative",
+ width: "100%",
+ height: "100vh",
+ minHeight: "1080px",
+ overflow: "hidden",
+ background: colors.background,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+
+ "@media (max-width: 768px)": {
+ minHeight: "820px",
+ },
+ "@media (max-width: 375px)": {
+ minHeight: "812px",
+ },
+});
+
+const BgImage = styled.div({
+ position: "absolute",
+ inset: 0,
+
+ "& img": {
+ width: "100%",
+ height: "100%",
+ objectFit: "cover",
+ },
+});
+
+const BgOverlay = styled.div({
+ position: "absolute",
+ inset: 0,
+ backdropFilter: "blur(5px)",
+ background: "rgba(12, 14, 15, 0.7)",
+});
+
+const Hero3D = styled.picture({
+ position: "absolute",
+ left: "50%",
+ top: "50%",
+ transform: "translate(-50%, -55%)",
+ opacity: 0.6,
+ pointerEvents: "none",
+});
+
+const Hero3DImage = styled.img({
+ width: "341.804px",
+ height: "350.535px",
+ flexShrink: 0,
+ aspectRatio: "39/40",
+ opacity: 0.6,
+ background: `url(${assets.hero3d}) no-repeat center center`,
+ backgroundSize: "cover",
+
+ "@media (max-width: 1024px)": {
+ width: "331px",
+ height: "331px",
+ },
+ "@media (max-width: 768px)": {
+ width: "309px",
+ height: "309px",
+ },
+ "@media (max-width: 375px)": {
+ width: "185px",
+ height: "185px",
+ },
+});
+
+const Content = styled.div({
+ position: "relative",
+ zIndex: 1,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "40px",
+ textAlign: "center",
+ width: "100%",
+ maxWidth: "1280px",
+ padding: "0 40px",
+
+ "@media (max-width: 768px)": {
+ gap: "28px",
+ padding: "0 24px",
+ },
+ "@media (max-width: 375px)": {
+ gap: "20px",
+ padding: "0 16px",
+ },
+});
+
+const HeadlineWrapper = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ width: "100%",
+ gap: "40px",
+ alignItems: "center",
+});
+
+const GradientHeadline = styled.h1({
+ fontFamily: "'Pretendard', sans-serif",
+ whiteSpace: "normal",
+ wordBreak: "keep-all",
+ overflowWrap: "anywhere",
+ maxWidth: "100%",
+ width: "100%",
+ fontWeight: fontWeights.bold,
+ background: "linear-gradient(180deg, rgba(255, 255, 255, 0.00) -3.04%, #FFF 95.35%)",
+ backgroundClip: "text",
+ WebkitBackgroundClip: "text",
+ WebkitTextFillColor: "transparent",
+ fontSize: "clamp(45px, 6.92vw + 1px, 130px)",
+ lineHeight: "clamp(50px, 6.41vw + 7px, 130px)",
+ "@media (max-width: 1024px)": {
+ fontSize: "clamp(45px, 8.59vw + 12px, 100px)",
+ lineHeight: "clamp(50px, 9.38vw + 14px, 110px)",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "clamp(45px, 14.29vw - 20px, 90px)",
+ lineHeight: "clamp(50px, 15.87vw - 20px, 100px)",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "45px",
+ lineHeight: "50px",
+ },
+});
+
+const Subtitle = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: "clamp(14px, calc(1.541vw + 8.22px), 28px)",
+ fontWeight: fontWeights.semiBold,
+ lineHeight: "clamp(18px, calc(1.849vw + 11.07px), 32px)",
+ color: colors.textInverse,
+});
+
+const CtaButton = styled(Link)({
+ display: "flex",
+ alignItems: "center",
+ gap: "4px",
+ height: "80px",
+ padding: "20px 50px",
+ background: colors.primary,
+ borderRadius: "100px",
+ color: colors.textInverse,
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.large,
+ fontWeight: fontWeights.medium,
+ lineHeight: lineHeights.paragraphLarge,
+ textDecoration: "none",
+ flexShrink: 0,
+ transition: "background 0.15s",
+
+ "&:hover": {
+ background: "#1f5fe0",
+ },
+
+ "@media (max-width: 1024px)": {
+ fontSize: "18px",
+ lineHeight: "24px",
+ },
+
+ "@media (max-width: 768px)": {
+ height: "68px",
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+ "@media (max-width: 375px)": {
+ height: "56px",
+ width: "100%",
+ maxWidth: "280px",
+ justifyContent: "center",
+ padding: "30px 40px",
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+export const HeroSection = () => {
+ const { isRecruitOpen, recruitButtonLabels } = useRecruitStatus();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ 일 잘하는 사람들은 어디서 성장하는 걸까요?
+
+ 10년간 470명이 선택한 IT 사이드프로젝트 동아리 DDD.
+
+ 퇴근 후에도 성장하고 싶은 사람들이 여기 모입니다.
+
+
+ {
+ if (isRecruitOpen) return;
+ event.preventDefault();
+ openPreAlertModal();
+ }}
+ >
+ {recruitButtonLabels.hero}
+ {" "}
+
+
+
+ );
+};
diff --git a/apps/web/components/sections/ProjectDetailSection.tsx b/apps/web/components/sections/ProjectDetailSection.tsx
new file mode 100644
index 0000000..316cd2d
--- /dev/null
+++ b/apps/web/components/sections/ProjectDetailSection.tsx
@@ -0,0 +1,204 @@
+"use client";
+
+import styled from "@emotion/styled";
+import { colors, fontWeights, fontSizes, lineHeights } from "@/constants/tokens";
+import type { ProjectItem } from "@/constants/projects";
+
+const Section = styled.section({
+ background: "#ffffff",
+});
+
+const ContentSection = styled.div({
+ padding: "80px 80px",
+ "@media (max-width: 1024px)": { padding: "40px 80px" },
+ "@media (max-width: 768px)": { padding: "40px" },
+ "@media (max-width: 375px)": { padding: "40px 16px" },
+});
+
+const Banner = styled.div<{ src: string }>(({ src }) => ({
+ minHeight: "330px",
+ padding: "160px 320px 80px",
+ backgroundColor: "#02111f",
+ backgroundImage: `linear-gradient(90deg, #02111f 7.926%, #072d3e 66.31%, #011924 100%), url('${src}')`,
+ backgroundSize: "cover",
+ backgroundPosition: "center",
+ display: "flex",
+ alignItems: "flex-end",
+
+ "@media (max-width: 1024px)": { padding: "160px 80px 80px" },
+ "@media (max-width: 768px)": { padding: "140px 40px 50px", minHeight: "300px" },
+ "@media (max-width: 375px)": { padding: "160px 16px 20px", minHeight: "300px" },
+}));
+
+const BannerLabel = styled.p({
+ margin: 0,
+ color: "#62748e",
+ fontSize: fontSizes.headingLarge,
+ lineHeight: lineHeights.headingLarge,
+ fontWeight: fontWeights.semiBold,
+
+ "@media (max-width: 768px)": { fontSize: "18px", lineHeight: "20px" },
+ "@media (max-width: 375px)": { fontSize: "12px", lineHeight: "15px" },
+});
+
+const BannerTitle = styled.h1({
+ margin: "8px 0 0",
+ color: "#cad5e2",
+ fontSize: "clamp(24px, calc(3.90625vw - 6px), 40px)",
+ lineHeight: "clamp(30px, calc(5.859375vw - 15px), 50px)",
+ fontWeight: fontWeights.bold,
+ "@media (max-width: 768px)": { fontSize: "24px", lineHeight: "30px" },
+ "@media (max-width: 375px)": { fontSize: "24px", lineHeight: "30px", maxWidth: "265px" },
+});
+
+const Container = styled.div({
+ maxWidth: "1280px",
+ margin: "0 auto",
+});
+
+const BadgeRow = styled.div({
+ display: "flex",
+ flexWrap: "wrap",
+ gap: "12px",
+});
+
+const Badge = styled.span<{ kind: "primary" | "gray" }>(({ kind }) => ({
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: "4px 20px",
+ borderRadius: "30px",
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ color: kind === "primary" ? colors.primary : "#525252",
+ background: kind === "primary" ? colors.mainLight : "#e9e9e9",
+
+ "@media (max-width: 1024px)": { fontSize: "18px", lineHeight: "23px" },
+ "@media (max-width: 768px)": { fontSize: "16px", lineHeight: "20px" },
+ "@media (max-width: 375px)": { fontSize: "14px", lineHeight: "18px" },
+}));
+
+const Title = styled.h2({
+ margin: "20px 0 0",
+ color: "#202325",
+ fontSize: "48px",
+ lineHeight: "55px",
+ fontWeight: fontWeights.bold,
+ "@media (max-width: 1024px)": { fontSize: "38px", lineHeight: "45px" },
+ "@media (max-width: 375px)": { fontSize: "18px", lineHeight: "25.2px" },
+});
+
+const LongDescription = styled.p({
+ margin: "12px 0 0",
+ color: "#202325",
+ whiteSpace: "pre-line",
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+
+ "@media (max-width: 1024px)": { fontSize: "14px", lineHeight: "18px" },
+ "@media (max-width: 375px)": { fontSize: "9px", lineHeight: "12px" },
+});
+
+const TeamTitle = styled.h3({
+ marginTop: "24px",
+ marginBottom: "12px",
+ color: "#202325",
+ fontSize: "40px",
+ lineHeight: "50px",
+ fontWeight: fontWeights.bold,
+ "@media (max-width: 1024px)": { fontSize: "34px", lineHeight: "45px", marginTop: "20px" },
+ "@media (max-width: 768px)": { fontSize: "30px", lineHeight: "38px", marginTop: "16px" },
+ "@media (max-width: 375px)": { fontSize: "20px", lineHeight: "25px", marginTop: "12px" },
+});
+
+const MemberGrid = styled.div({
+ marginTop: "12px",
+ display: "flex",
+ flexWrap: "wrap",
+ gap: "20px",
+});
+
+const Member = styled.div({
+ background: "#f1f5f9",
+ borderRadius: "10px",
+ padding: "12px 20px",
+ display: "inline-flex",
+ alignItems: "flex-end",
+ gap: "14px",
+});
+
+const MemberName = styled.span({
+ color: "#202325",
+ fontSize: "24px",
+ lineHeight: "30px",
+ fontWeight: fontWeights.medium,
+
+ "@media (max-width: 1024px)": { fontSize: "20px", lineHeight: "25px" },
+ "@media (max-width: 768px)": { fontSize: "18px", lineHeight: "24px" },
+ "@media (max-width: 375px)": { fontSize: "16px", lineHeight: "20px" },
+});
+
+const MemberRole = styled.span({
+ color: "#90a1b9",
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+
+ "@media (max-width: 1024px)": { fontSize: "18px", lineHeight: "23px" },
+ "@media (max-width: 768px)": { fontSize: "16px", lineHeight: "20px" },
+ "@media (max-width: 375px)": { fontSize: "14px", lineHeight: "18px" },
+});
+
+const Pdf = styled.img({
+ marginTop: "80px",
+ "@media (max-width: 1024px)": { marginTop: "40px" },
+ "@media (max-width: 768px)": { marginTop: "20px" },
+ width: "100%",
+ height: "auto",
+ display: "block",
+});
+
+type Props = { project: ProjectItem };
+
+function hasNonEmptySrc(src: string): boolean {
+ return src.trim().length > 0;
+}
+
+export const ProjectDetailSection = ({ project }: Props) => {
+ return (
+
+
+
+ Projects
+ DDD 멤버들이 만든 다양한 프로젝트를 확인해보세요.
+
+
+
+
+
+ {project.category}
+ {project.generation}
+
+ {project.detailTitle}
+ {project.longDescription}
+
+ 팀원
+
+ {project.participants.map((member) => (
+
+ {member.name}
+ {member.role}
+
+ ))}
+
+
+ {hasNonEmptySrc(project.pdf) ? (
+
+ ) : null}
+
+
+
+ );
+};
diff --git a/apps/web/components/sections/ProjectListPageSection.tsx b/apps/web/components/sections/ProjectListPageSection.tsx
new file mode 100644
index 0000000..8091385
--- /dev/null
+++ b/apps/web/components/sections/ProjectListPageSection.tsx
@@ -0,0 +1,381 @@
+"use client";
+
+import { useState } from "react";
+import styled from "@emotion/styled";
+import { colors, fontWeights } from "@/constants/tokens";
+import type { ProjectCategory, ProjectItem } from "@/constants/projects";
+import { fetchPublicProjectsPage } from "@/lib/api/project";
+
+const tabs: ProjectCategory[] = ["전체", "iOS", "AOS", "WEB"];
+
+const Section = styled.section({
+ background: "#ffffff",
+});
+
+const Banner = styled.div({
+ padding: "160px 80px",
+ position: "relative",
+ overflow: "hidden",
+ minHeight: "330px",
+ backgroundColor: "#02111f",
+ backgroundImage:
+ "linear-gradient(90deg, #02111f 7.926%, #072d3e 66.31%, #011924 100%), url('https://www.figma.com/api/mcp/asset/6f928e32-36e6-4c5d-886d-63789ff48cea')",
+ backgroundSize: "cover",
+ backgroundPosition: "center",
+
+ "@media (max-width: 1024px)": { padding: "160px 80px 80px", minHeight: "323px" },
+ "@media (max-width: 768px)": { padding: "140px 40px 50px", minHeight: "300px" },
+ "@media (max-width: 375px)": { padding: "160px 16px 20px", minHeight: "300px" },
+});
+
+const Heading = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "8px",
+ maxWidth: "1280px",
+ margin: "0 auto",
+});
+
+const ContentSection = styled.div({
+ padding: "80px",
+ "@media (max-width: 1024px)": { padding: "80px" },
+ "@media (max-width: 768px)": { padding: "40px" },
+ "@media (max-width: 375px)": { padding: "40px 16px" },
+});
+
+const Body = styled.div({
+ maxWidth: "1280px",
+ margin: "0 auto",
+});
+
+const Label = styled.p({
+ margin: 0,
+ fontSize: "28px",
+ lineHeight: "32px",
+ color: "#62748e",
+ fontWeight: fontWeights.semiBold,
+ "@media (max-width: 1024px)": { fontSize: "24px", lineHeight: "30px" },
+ "@media (max-width: 768px)": { fontSize: "20px", lineHeight: "25px" },
+ "@media (max-width: 375px)": { fontSize: "12px", lineHeight: "15px" },
+});
+
+const Title = styled.h1({
+ margin: 0,
+ color: "#cad5e2",
+ fontSize: "40px",
+ lineHeight: "50px",
+ fontWeight: fontWeights.bold,
+
+ "@media (max-width: 1024px)": { fontSize: "34px", lineHeight: "45px" },
+ "@media (max-width: 768px)": { fontSize: "30px", lineHeight: "38px" },
+ "@media (max-width: 375px)": { fontSize: "24px", lineHeight: "30px", width: "265px" },
+});
+
+const TabList = styled.div({
+ display: "flex",
+ gap: "24px",
+ justifyContent: "center",
+ marginBottom: "80px",
+ overflowX: "auto",
+ paddingBottom: "4px",
+ flexWrap: "nowrap",
+ WebkitOverflowScrolling: "touch",
+
+ "&::-webkit-scrollbar": {
+ display: "none",
+ },
+ scrollbarWidth: "none",
+
+ "@media (max-width: 768px)": {
+ justifyContent: "flex-start",
+ marginBottom: "40px",
+ },
+});
+
+const Tab = styled.button<{ active: boolean }>(({ active }) => ({
+ border: "none",
+ background: "transparent",
+ color: active ? colors.primary : "#525252",
+ borderBottom: `2px solid ${active ? colors.primary : "transparent"}`,
+ fontSize: "28px",
+ lineHeight: "32px",
+ fontWeight: fontWeights.semiBold,
+ padding: "8px 20px",
+ cursor: "pointer",
+ whiteSpace: "nowrap",
+
+ "@media (max-width: 1024px)": { fontSize: "24px", lineHeight: "30px" },
+ "@media (max-width: 768px)": { fontSize: "13px", lineHeight: "16px", padding: "6px 10px" },
+ "@media (max-width: 375px)": {
+ fontSize: "12px",
+ lineHeight: "15px",
+ padding: "4px 8px",
+ },
+}));
+
+const Grid = styled.div({
+ display: "grid",
+ gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
+ gap: "24px",
+
+ "@media (max-width: 1024px)": { gridTemplateColumns: "repeat(2, minmax(0, 1fr))" },
+ "@media (max-width: 768px)": { gridTemplateColumns: "1fr" },
+});
+
+const CardLink = styled.a({
+ textDecoration: "none",
+ color: "inherit",
+});
+
+const Card = styled.article({
+ display: "flex",
+ flexDirection: "column",
+});
+
+const CardThumbnail = styled.div({
+ aspectRatio: "1 / 1",
+ borderRadius: "30px",
+ overflow: "hidden",
+ width: "100%",
+
+ "& img": {
+ width: "100%",
+ height: "100%",
+ objectFit: "cover",
+ display: "block",
+ },
+});
+
+const CardBody = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "8px",
+ padding: "10px 20px",
+});
+
+const CardTitle = styled.p({
+ margin: 0,
+ color: "#202325",
+ fontSize: "28px",
+ lineHeight: "32px",
+ fontWeight: fontWeights.semiBold,
+ whiteSpace: "nowrap",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+
+ "@media (max-width: 1024px)": { fontSize: "24px", lineHeight: "30px" },
+ "@media (max-width: 768px)": { fontSize: "20px", lineHeight: "25px" },
+ "@media (max-width: 375px)": { fontSize: "16px", lineHeight: "20px" },
+});
+
+const CardDescription = styled.p({
+ margin: 0,
+ color: "#525252",
+ fontSize: "16px",
+ lineHeight: "20px",
+ fontWeight: fontWeights.regular,
+ display: "-webkit-box",
+ WebkitLineClamp: 2,
+ WebkitBoxOrient: "vertical",
+ overflow: "hidden",
+ minHeight: "40px",
+
+ "@media (max-width: 1024px)": { fontSize: "14px", lineHeight: "18px" },
+ "@media (max-width: 768px)": { fontSize: "13px", lineHeight: "18px" },
+ "@media (max-width: 375px)": { fontSize: "12px", lineHeight: "15px" },
+});
+
+const BadgeRow = styled.div({
+ display: "flex",
+ gap: "8px",
+ padding: "0 20px",
+});
+
+const Badge = styled.span<{ kind: "primary" | "gray" }>(({ kind }) => ({
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: "4px 20px",
+ borderRadius: "30px",
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ background: kind === "primary" ? colors.mainLight : "#e9e9e9",
+ color: kind === "primary" ? colors.primary : "#525252",
+ "@media (max-width: 1024px)": { fontSize: "18px", lineHeight: "23px" },
+ "@media (max-width: 768px)": { fontSize: "16px", lineHeight: "20px" },
+ "@media (max-width: 375px)": { fontSize: "14px", lineHeight: "18px" },
+}));
+
+const Pagination = styled.div({
+ marginTop: "80px",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: "40px",
+ color: "#d4d4d4",
+ fontSize: "20px",
+ lineHeight: "25px",
+ fontWeight: fontWeights.medium,
+
+ "@media (max-width: 768px)": {
+ gap: "24px",
+ marginTop: "48px",
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+const Arrow = styled.span({
+ color: "#cad5e2",
+ fontSize: "18px",
+});
+
+type Props = {
+ initialItems?: ProjectItem[];
+ initialNextCursor?: string | null;
+};
+
+const PaginationButton = styled.button<{ disabled?: boolean }>(({ disabled }) => ({
+ border: "none",
+ background: "transparent",
+ color: disabled ? "#9aa8bb" : "#cad5e2",
+ fontSize: "18px",
+ cursor: disabled ? "not-allowed" : "pointer",
+}));
+
+const toApiPlatform = (tab: ProjectCategory): "IOS" | "AOS" | "WEB" | undefined => {
+ if (tab === "전체") return undefined;
+ if (tab === "iOS") return "IOS";
+ return tab;
+};
+
+export const ProjectListPageSection = ({
+ initialItems = [],
+ initialNextCursor = null,
+}: Props) => {
+ const [activeTab, setActiveTab] = useState("전체");
+ const [projectItems, setProjectItems] = useState(initialItems);
+ const [nextCursor, setNextCursor] = useState(initialNextCursor);
+ const [cursorHistory, setCursorHistory] = useState>([null]);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const loadFirstPage = async (tab: ProjectCategory) => {
+ setIsLoading(true);
+ try {
+ const page = await fetchPublicProjectsPage({
+ platform: toApiPlatform(tab),
+ limit: 9,
+ });
+ setProjectItems(page.items);
+ setNextCursor(page.nextCursor);
+ setCursorHistory([null]);
+ setCurrentPage(1);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const loadNextPage = async () => {
+ if (!nextCursor || isLoading) return;
+ setIsLoading(true);
+ try {
+ const page = await fetchPublicProjectsPage({
+ platform: toApiPlatform(activeTab),
+ limit: 9,
+ cursor: nextCursor,
+ });
+ setProjectItems(page.items);
+ setCursorHistory((prev) => [...prev, nextCursor]);
+ setNextCursor(page.nextCursor);
+ setCurrentPage((prev) => prev + 1);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const loadPrevPage = async () => {
+ if (cursorHistory.length <= 1 || isLoading) return;
+ const prevHistory = [...cursorHistory];
+ prevHistory.pop();
+ const prevCursor = prevHistory[prevHistory.length - 1] ?? null;
+ setIsLoading(true);
+ try {
+ const page = await fetchPublicProjectsPage({
+ platform: toApiPlatform(activeTab),
+ limit: 9,
+ cursor: prevCursor ?? undefined,
+ });
+ setProjectItems(page.items);
+ setNextCursor(page.nextCursor);
+ setCursorHistory(prevHistory);
+ setCurrentPage((prev) => Math.max(1, prev - 1));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ DDD 멤버들이 만든 다양한 프로젝트를 확인해보세요.
+
+
+
+
+
+ {tabs.map((tab) => (
+ {
+ if (activeTab === tab) return;
+ setActiveTab(tab);
+ void loadFirstPage(tab);
+ }}
+ >
+ {tab}
+
+ ))}
+
+
+ {projectItems.map((project) => (
+
+
+
+
+
+
+ {project.title}
+ {project.description}
+
+
+ {project.category}
+ {project.generation}
+
+
+
+ ))}
+
+
+
+ ‹
+
+ {currentPage}
+
+ ›
+
+
+
+
+
+ );
+};
diff --git a/apps/web/components/sections/ProjectsSection.tsx b/apps/web/components/sections/ProjectsSection.tsx
new file mode 100644
index 0000000..2270a02
--- /dev/null
+++ b/apps/web/components/sections/ProjectsSection.tsx
@@ -0,0 +1,358 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import styled from "@emotion/styled";
+import { ProjectCard } from "@/components/ui/ProjectCard";
+import type { ProjectItem } from "@/constants/projects";
+import { colors, fontSizes, fontWeights } from "@/constants/tokens";
+
+type ProjectCategory = "전체" | "iOS" | "AOS" | "WEB";
+
+const TABS: ProjectCategory[] = ["전체", "iOS", "AOS", "WEB"];
+
+const Section = styled.section({
+ background: colors.background,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "flex-start",
+ padding: "120px 80px",
+ gap: "24px",
+
+ "@media (max-width: 1024px)": { padding: "120px 80px" },
+ "@media (max-width: 768px)": { padding: "100px 40px" },
+ "@media (max-width: 375px)": { padding: "80px 16px" },
+});
+
+const Inner = styled.div({
+ width: "100%",
+ maxWidth: "1280px",
+ margin: "0 auto",
+ display: "flex",
+ flexDirection: "column",
+ gap: "24px",
+});
+
+const TitleArea = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "24px",
+});
+
+const SectionLabel = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontWeight: fontWeights.medium,
+ color: colors.textInverse,
+ fontSize: "20px",
+ lineHeight: "28px",
+ "@media (max-width: 1024px)": {
+ fontSize: "18px",
+ lineHeight: "23px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+const SectionTitle = styled.h2({
+ fontFamily: "'Pretendard', sans-serif",
+ fontWeight: fontWeights.semiBold,
+ color: colors.textInverse,
+ fontSize: "28px",
+ lineHeight: "32px",
+ "@media (max-width: 1024px)": {
+ fontSize: "24px",
+ lineHeight: "30px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "20px",
+ lineHeight: "25px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+});
+
+const TabsAndCards = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "40px",
+ width: "100%",
+});
+
+const TabList = styled.div({
+ display: "flex",
+ gap: "40px",
+ justifyContent: "center",
+ width: "100%",
+ flexWrap: "nowrap",
+ role: "tablist",
+ scrollSnapType: "x mandatory",
+ scrollbarWidth: "none",
+ msOverflowStyle: "none",
+
+ "&::-webkit-scrollbar": {
+ display: "none",
+ },
+
+ "@media (max-width: 768px)": {
+ overflowX: "auto",
+ justifyContent: "flex-start",
+ WebkitOverflowScrolling: "touch",
+ },
+ "@media (max-width: 375px)": { gap: "12px" },
+});
+
+interface TabButtonProps {
+ isActive: boolean;
+}
+
+const TabButton = styled.button(({ isActive: active }) => ({
+ border: "none",
+ background: "transparent",
+ color: active ? colors.primary : "#FFF",
+ borderBottom: `2px solid ${active ? colors.primary : "FFF"}`,
+ fontSize: "16px",
+ lineHeight: "20px",
+ fontWeight: fontWeights.semiBold,
+ padding: "8px 20px",
+ cursor: "pointer",
+ whiteSpace: "nowrap",
+
+ "@media (max-width: 1024px)": { fontSize: "14px", lineHeight: "18px" },
+ "@media (max-width: 768px)": { fontSize: "13px", lineHeight: "16px" },
+ "@media (max-width: 375px)": {
+ fontSize: "12px",
+ lineHeight: "15px",
+ },
+}));
+
+const CardGrid = styled.div({
+ display: "grid",
+ gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
+ gap: "24px",
+ width: "100%",
+
+ "@media (max-width: 1024px)": {
+ gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
+ },
+ "@media (max-width: 768px)": {
+ display: "flex",
+ overflowX: "auto",
+ scrollSnapType: "x mandatory",
+ gap: "12px",
+ WebkitOverflowScrolling: "touch",
+ scrollbarWidth: "none",
+ msOverflowStyle: "none",
+
+ "&::-webkit-scrollbar": {
+ display: "none",
+ },
+
+ "& > *": {
+ flex: "0 0 100%",
+ minWidth: "100%",
+ scrollSnapAlign: "start",
+ },
+ },
+});
+
+const MobileBulletRow = styled.div({
+ display: "none",
+
+ "@media (max-width: 768px)": {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: "10px",
+ },
+});
+
+const MobileBullet = styled.button<{ active: boolean }>(({ active }) => ({
+ width: "10px",
+ height: "10px",
+ borderRadius: "50%",
+ background: active ? colors.slate500 : colors.slate200,
+ opacity: active ? 1 : 0.9,
+ border: "none",
+ padding: 0,
+ cursor: "pointer",
+}));
+
+const MoreButton = styled(Link)({
+ display: "flex",
+ alignItems: "center",
+ gap: "4px",
+ height: "80px",
+ padding: "20px 50px",
+ background: colors.primary,
+ borderRadius: "100px",
+ color: colors.textInverse,
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.large,
+ fontWeight: fontWeights.medium,
+ lineHeight: "28px",
+ textDecoration: "none",
+ whiteSpace: "nowrap",
+ flexShrink: 0,
+ transition: "background 0.15s",
+
+ "&:hover": {
+ background: "#1f5fe0",
+ },
+
+ "@media (max-width: 1024px)": {
+ fontSize: "18px",
+ lineHeight: "24px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+type Props = {
+ items: ProjectItem[];
+};
+
+export const ProjectsSection = ({ items }: Props) => {
+ const [activeTab, setActiveTab] = useState("전체");
+ const [activeSlide, setActiveSlide] = useState(0);
+ const cardGridRef = useRef(null);
+ const sourceProjects: readonly ProjectItem[] = items;
+
+ const filteredProjects =
+ activeTab === "전체"
+ ? sourceProjects
+ : sourceProjects.filter((project) => project.category === activeTab);
+
+ const updateActiveSlide = useCallback(() => {
+ const container = cardGridRef.current;
+ if (!container) return;
+
+ const slides = Array.from(container.children) as HTMLElement[];
+ if (slides.length === 0) return;
+
+ const scrollLeft = container.scrollLeft;
+ let nearestIndex = 0;
+ let nearestDistance = Number.POSITIVE_INFINITY;
+
+ slides.forEach((slide, index) => {
+ const distance = Math.abs(slide.offsetLeft - scrollLeft);
+ if (distance < nearestDistance) {
+ nearestDistance = distance;
+ nearestIndex = index;
+ }
+ });
+
+ setActiveSlide(nearestIndex);
+ }, []);
+
+ const handleBulletClick = useCallback((index: number) => {
+ const container = cardGridRef.current;
+ if (!container) return;
+
+ const target = container.children[index] as HTMLElement | undefined;
+ if (!target) return;
+
+ container.scrollTo({ left: target.offsetLeft, behavior: "smooth" });
+ setActiveSlide(index);
+ }, []);
+
+ useEffect(() => {
+ const container = cardGridRef.current;
+ if (!container) return;
+
+ const onScroll = () => updateActiveSlide();
+ container.addEventListener("scroll", onScroll, { passive: true });
+ let raf = 0;
+ raf = requestAnimationFrame(() => updateActiveSlide());
+
+ return () => {
+ container.removeEventListener("scroll", onScroll);
+ cancelAnimationFrame(raf);
+ };
+ }, [updateActiveSlide, filteredProjects]);
+
+ useEffect(() => {
+ const container = cardGridRef.current;
+ if (!container) return;
+ container.scrollTo({ left: 0, behavior: "auto" });
+ let raf = 0;
+ raf = requestAnimationFrame(() => setActiveSlide(0));
+
+ return () => cancelAnimationFrame(raf);
+ }, [activeTab]);
+
+ return (
+
+
+
+ Projects
+ DDD 멤버들이 만든 다양한 프로젝트를 확인해보세요.
+
+
+
+ {TABS.map((tab) => (
+ setActiveTab(tab)}
+ >
+ {tab}
+
+ ))}
+
+
+ {filteredProjects.map((project) => (
+
+ ))}
+
+
+ {filteredProjects.map((project, index) => (
+ handleBulletClick(index)}
+ />
+ ))}
+
+
+ 더 알아보기
+ {" "}
+
+
+
+
+ );
+};
diff --git a/apps/web/components/sections/RecruitApplySection.tsx b/apps/web/components/sections/RecruitApplySection.tsx
new file mode 100644
index 0000000..636c124
--- /dev/null
+++ b/apps/web/components/sections/RecruitApplySection.tsx
@@ -0,0 +1,1357 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import type { FormEvent } from "react";
+import styled from "@emotion/styled";
+import { ApiError } from "@ddd/api";
+import { colors, fontWeights } from "@/constants/tokens";
+import successIcon from "@/public/images/success.png";
+import {
+ APPLY_PART_OPTIONS,
+ fetchApplyPartIdMap,
+ type ApplyPartOption,
+} from "@/lib/api/cohort";
+import {
+ fetchApplicationDraftAnswers,
+ saveRecruitApplicationDraft,
+ submitRecruitApplication,
+} from "@/lib/api/application";
+import { birthInputToApiDate, formatApplicantPhoneKorea } from "@/lib/format";
+
+type Step = 1 | 2 | 3 | 4 | 5;
+type BasicField = "name" | "email" | "phone" | "birth" | "region";
+
+type FormValues = {
+ name: string;
+ email: string;
+ phone: string;
+ birth: string;
+ region: string;
+ agreedToPrivacy: boolean;
+ part: string | null;
+ essay: string;
+ portfolioLink: string;
+ portfolioFile: File | null;
+ channels: string[];
+};
+
+const CHANNEL_OPTIONS = [
+ "지인 추천",
+ "인스타그램",
+ "링크드인",
+ "블로그",
+ "아티클",
+ "이전 기수 활동",
+ "기타",
+] as const;
+
+const PART_DESCRIPTIONS: Record = {
+ iOS: "Apple 생태계에 맞춰 안정적인 앱을 만들어요. 섬세한 디테일로 완성도 높은 경험을 설계해요.",
+ AOS: "다양한 환경에서 안정적으로 동작하는 앱을 만들어요. 지속 성장 가능한 서비스를 함께 개발해요.",
+ FE: "사용자 중심의 직관적이고 빠른 웹 환경을 구축합니다. 최적화된 코드로 끊김 없는 사용자 경험을 제공합니다.",
+ BE: "서버와 데이터의 흐름을 설계해 서비스가 안정적으로 동작하도록 만들어요. 성능과 확장성을 고려해 빠르고 유연한 시스템을 구축해요.",
+ PM: "문제를 정의하고 방향을 제시해 팀이 같은 목표를 향해 나아가도록 이끌어요. 우선순위를 정하고 실행을 조율해 제품 가치를 만듭니다.",
+ PD: "사용자의 니즈를 반영한 최상의 UI/UX를 만들어요. 여러 툴을 활용해 협업하며, 더 나은 사용자 경험을 고민해요.",
+};
+
+function parseDraftToFormValues(draft: Record): Partial {
+ const patch: Partial = {};
+ if (typeof draft.email === "string") patch.email = draft.email;
+ if (typeof draft.name === "string") patch.name = draft.name;
+ if (typeof draft.phone === "string") patch.phone = draft.phone;
+ if (typeof draft.birth === "string") patch.birth = draft.birth;
+ if (typeof draft.region === "string") patch.region = draft.region;
+ if (typeof draft.essay === "string") patch.essay = draft.essay;
+ if (typeof draft.portfolioLink === "string") patch.portfolioLink = draft.portfolioLink;
+ if ("agreedToPrivacy" in draft && typeof draft.agreedToPrivacy === "boolean") {
+ patch.agreedToPrivacy = draft.agreedToPrivacy;
+ }
+ if (Array.isArray(draft.channels)) {
+ patch.channels = draft.channels.filter(
+ (c): c is string =>
+ typeof c === "string" && (CHANNEL_OPTIONS as readonly string[]).includes(c),
+ );
+ }
+ if (
+ typeof draft.part === "string" &&
+ (APPLY_PART_OPTIONS as readonly string[]).includes(draft.part)
+ ) {
+ patch.part = draft.part as FormValues["part"];
+ }
+ return patch;
+}
+
+const BANNER_TEXT = "함께 성장할 PM, 디자이너, 개발자를 기다리고 있어요.";
+
+const initialValues: FormValues = {
+ name: "",
+ email: "",
+ phone: "",
+ birth: "",
+ region: "",
+ agreedToPrivacy: false,
+ part: null,
+ essay: "",
+ portfolioLink: "",
+ portfolioFile: null,
+ channels: [],
+};
+
+const PageSection = styled.section({
+ background: colors.background,
+ color: colors.textInverse,
+});
+
+const Banner = styled.section({
+ minHeight: "400px",
+ padding: "160px 80px",
+ position: "relative",
+ overflow: "hidden",
+ backgroundColor: "#02111f",
+ backgroundImage:
+ "linear-gradient(90deg, #02111f 7.926%, #072d3e 66.31%, #011924 100%), url('https://www.figma.com/api/mcp/asset/6f928e32-36e6-4c5d-886d-63789ff48cea')",
+ backgroundSize: "cover",
+ backgroundPosition: "center",
+ "@media (max-width: 1024px)": { padding: "160px 80px 80px" },
+ "@media (max-width: 768px)": { padding: "140px 40px 50px" },
+ "@media (max-width: 375px)": { padding: "160px 16px 20px" },
+});
+
+const BannerInner = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "8px",
+ maxWidth: "1280px",
+ margin: "0 auto",
+});
+
+const BannerLabel = styled.p({
+ margin: 0,
+ color: "#62748e",
+ fontSize: "28px",
+ lineHeight: "32px",
+ fontWeight: fontWeights.semiBold,
+ "@media (max-width: 1024px)": { fontSize: "24px", lineHeight: "30px" },
+ "@media (max-width: 768px)": { fontSize: "20px", lineHeight: "25px" },
+ "@media (max-width: 375px)": { fontSize: "12px", lineHeight: "15px" },
+});
+
+const BannerTitle = styled.h1({
+ margin: "8px 0 0",
+ color: colors.slate300,
+ fontSize: "40px",
+ lineHeight: "50px",
+ fontWeight: fontWeights.bold,
+ "@media (max-width: 1024px)": { fontSize: "34px", lineHeight: "45px" },
+ "@media (max-width: 768px)": { fontSize: "30px", lineHeight: "38px" },
+ "@media (max-width: 375px)": { fontSize: "24px", lineHeight: "30px", width: "265px" },
+});
+
+const ContainerPadding = styled.div({
+ padding: "80px",
+ "@media (max-width: 768px)": {
+ padding: "16px 40px",
+ },
+ "@media (max-width: 375px)": {
+ padding: "16px 16px",
+ },
+});
+
+const Container = styled.div({
+ width: "100%",
+ maxWidth: "1280px",
+ margin: "0 auto",
+});
+
+const FormTitle = styled.h2({
+ margin: 0,
+ textAlign: "center",
+ color: colors.primary,
+ fontSize: "clamp(28px, calc(1.85vw + 21.07px), 48px)",
+ lineHeight: "clamp(38px, calc(1.85vw + 31.07px), 55px)",
+ fontWeight: fontWeights.bold,
+});
+
+const FormDescription = styled.p({
+ margin: "10px 0 0",
+ textAlign: "center",
+ color: "#d4d4d4",
+ fontSize: "28px",
+ lineHeight: "32px",
+ fontWeight: fontWeights.semiBold,
+ "@media (max-width: 1024px)": { fontSize: "24px", lineHeight: "30px" },
+ "@media (max-width: 768px)": { fontSize: "20px", lineHeight: "25px" },
+ "@media (max-width: 375px)": { fontSize: "16px", lineHeight: "20px" },
+});
+
+const StepWrap = styled.div({
+ position: "relative",
+ display: "grid",
+ gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
+ gap: "20px",
+ marginTop: "50px",
+ paddingTop: "10px",
+ "@media (max-width: 768px)": { gap: "12px", marginTop: "36px" },
+ "@media (max-width: 375px)": { gap: "8px", marginTop: "28px" },
+});
+
+const StepLine = styled.div({
+ position: "absolute",
+ left: "calc(12.5% + 20px)",
+ right: "calc(12.5% + 20px)",
+ top: "50px",
+ height: "2px",
+ background: "#62748e",
+ "@media (max-width: 768px)": {
+ top: "40px",
+ left: "calc(12.5% + 16px)",
+ right: "calc(12.5% + 16px)",
+ },
+ "@media (max-width: 375px)": {
+ top: "34px",
+ left: "calc(12.5% + 12px)",
+ right: "calc(12.5% + 12px)",
+ },
+});
+
+const StepItem = styled.div({
+ position: "relative",
+ zIndex: 1,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "10px",
+ textAlign: "center",
+});
+
+const StepCircle = styled.div<{ active: boolean }>(({ active }) => ({
+ width: "80px",
+ height: "80px",
+ borderRadius: "999px",
+ border: `2px solid ${active ? "#ffffff" : "#62748e"}`,
+ background: colors.backgroundDark,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ fontSize: "40px",
+ lineHeight: "50px",
+ fontWeight: fontWeights.bold,
+ color: active ? "#ffffff" : "#62748e",
+ "@media (max-width: 768px)": {
+ width: "64px",
+ height: "64px",
+ fontSize: "28px",
+ lineHeight: "36px",
+ },
+ "@media (max-width: 375px)": {
+ width: "48px",
+ height: "48px",
+ fontSize: "20px",
+ lineHeight: "24px",
+ },
+}));
+
+const StepLabel = styled.p<{ active: boolean }>(({ active }) => ({
+ margin: 0,
+ fontSize: "24px",
+ lineHeight: "30px",
+ fontWeight: fontWeights.medium,
+ color: active ? "#ffffff" : "#62748e",
+ "@media (max-width: 768px)": { fontSize: "18px", lineHeight: "24px" },
+ "@media (max-width: 375px)": { fontSize: "12px", lineHeight: "16px" },
+}));
+
+const Card = styled.section({
+ marginTop: "56px",
+ borderRadius: "30px",
+ background: colors.backgroundDark,
+ padding: "80px",
+ "@media (max-width: 1024px)": { padding: "56px 44px" },
+ "@media (max-width: 768px)": { padding: "40px 24px" },
+ "@media (max-width: 375px)": { marginTop: "28px", padding: "24px 16px", borderRadius: "20px" },
+});
+
+const CardTitle = styled.h3({
+ margin: 0,
+ fontSize: "24px",
+ lineHeight: "30px",
+ fontWeight: fontWeights.medium,
+ "@media (max-width: 768px)": { fontSize: "20px", lineHeight: "25px" },
+ "@media (max-width: 375px)": { fontSize: "16px", lineHeight: "20px" },
+});
+
+const Fields = styled.div({
+ maxWidth: "640px",
+ margin: "40px auto 0",
+ display: "flex",
+ flexDirection: "column",
+ gap: "20px",
+ "@media (max-width: 375px)": { marginTop: "24px", gap: "14px" },
+});
+
+const Field = styled.label({
+ display: "flex",
+ flexDirection: "column",
+ gap: "8px",
+});
+
+const Label = styled.span({
+ fontSize: "14px",
+ lineHeight: "18px",
+ fontWeight: fontWeights.medium,
+});
+
+const Required = styled.span<{ hasError?: boolean }>(({ hasError }) => ({
+ color: hasError ? "#ff7d7d" : colors.primary,
+}));
+
+const Hint = styled.span({
+ fontSize: "14px",
+ lineHeight: "18px",
+ color: "#d4d4d4",
+ fontWeight: fontWeights.regular,
+});
+
+const Input = styled.input<{ hasError?: boolean; isFocused?: boolean; hasValue?: boolean }>(
+ ({ hasError, isFocused, hasValue }) => ({
+ width: "100%",
+ height: "54px",
+ borderRadius: "10px",
+ border: "2px solid",
+ borderColor: hasError
+ ? "#ff7d7d"
+ : isFocused
+ ? colors.primary
+ : hasValue
+ ? "#d9e2ef"
+ : "#ffffff",
+ background: "#ffffff",
+ color: colors.textPrimary,
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ padding: "0 16px",
+ outline: "none",
+ transition: "border-color 0.15s ease, box-shadow 0.15s ease",
+ boxShadow: isFocused ? "0 0 0 3px rgba(46, 113, 255, 0.15)" : "none",
+ "&::placeholder": { color: colors.slate500 },
+ "&:focus": {
+ borderColor: hasError ? "#ff7d7d" : colors.primary,
+ boxShadow: hasError
+ ? "0 0 0 3px rgba(255, 125, 125, 0.15)"
+ : "0 0 0 3px rgba(46, 113, 255, 0.15)",
+ },
+ "@media (max-width: 375px)": { fontSize: "16px", lineHeight: "20px", height: "48px" },
+ }),
+);
+
+const TextArea = styled.textarea({
+ width: "100%",
+ minHeight: "400px",
+ borderRadius: "10px",
+ border: "none",
+ background: "#ffffff",
+ color: colors.textPrimary,
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ padding: "20px",
+ resize: "vertical",
+ "&::placeholder": { color: colors.textSecondary },
+ "@media (max-width: 375px)": { minHeight: "280px", fontSize: "16px", lineHeight: "24px" },
+});
+
+const PrivacyBox = styled.div({
+ marginTop: "24px",
+ borderRadius: "10px",
+ background: colors.slate300,
+ color: colors.slate500,
+ padding: "20px",
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ whiteSpace: "pre-line",
+ "@media (max-width: 768px)": { fontSize: "16px", lineHeight: "24px" },
+ "@media (max-width: 375px)": {
+ marginTop: "16px",
+ fontSize: "12px",
+ lineHeight: "18px",
+ padding: "14px",
+ },
+});
+
+const Agreement = styled.button<{ checked: boolean }>(({ checked: _checked }) => ({
+ marginTop: "16px",
+ border: "none",
+ background: "transparent",
+ color: "#ffffff",
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "9px",
+ minHeight: "20px",
+ cursor: "pointer",
+ padding: 0,
+ fontSize: "14px",
+ lineHeight: "18px",
+ fontWeight: fontWeights.semiBold,
+ textAlign: "left" as const,
+ "@media (max-width: 375px)": {
+ marginTop: "12px",
+ fontSize: "12px",
+ lineHeight: "16px",
+ gap: "7px",
+ },
+}));
+
+const AgreementCheck = styled.span<{ checked: boolean }>(({ checked }) => ({
+ width: "20px",
+ height: "20px",
+ borderRadius: "3px",
+ border: checked ? "1px solid #2e71ff" : "1px solid #ffffff",
+ background: checked ? colors.primary : "transparent",
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+}));
+
+const AgreementCheckIcon = styled.svg<{ visible: boolean }>(({ visible }) => ({
+ display: "block",
+ width: "11px",
+ height: "8px",
+ flexShrink: 0,
+ opacity: visible ? 1 : 0,
+}));
+
+const AgreementText = styled.span({
+ color: "#ffffff",
+ display: "inline-block",
+ whiteSpace: "nowrap",
+});
+
+const AgreementRequiredDot = styled.span({
+ width: "7px",
+ height: "7px",
+ borderRadius: "999px",
+ background: colors.primary,
+ display: "inline-block",
+ marginLeft: "0px",
+ transform: "translateY(0px)",
+ "@media (max-width: 375px)": {
+ width: "4px",
+ height: "4px",
+ },
+});
+
+const ChipGrid = styled.div({
+ marginTop: "20px",
+ display: "flex",
+ gap: "20px",
+ flexWrap: "wrap",
+ "@media (max-width: 375px)": { gap: "10px" },
+});
+
+const Chip = styled.button<{ selected: boolean }>(({ selected }) => ({
+ border: "none",
+ borderRadius: "20px",
+ background: selected ? colors.primary : colors.slate300,
+ color: selected ? "#ffffff" : "#62748e",
+ fontSize: "28px",
+ lineHeight: "32px",
+ fontWeight: fontWeights.semiBold,
+ padding: "20px 40px",
+ cursor: "pointer",
+ "@media (max-width: 1024px)": { fontSize: "22px", lineHeight: "28px", padding: "16px 26px" },
+ "@media (max-width: 768px)": { fontSize: "18px", lineHeight: "24px", padding: "12px 20px" },
+ "@media (max-width: 375px)": { fontSize: "16px", lineHeight: "20px", padding: "10px 14px" },
+}));
+
+const PartDescription = styled.p({
+ margin: "20px 0 0",
+ color: "#ffffff",
+ fontSize: "24px",
+ lineHeight: "30px",
+ fontWeight: fontWeights.semiBold,
+ "@media (max-width: 1024px)": { fontSize: "20px", lineHeight: "26px" },
+ "@media (max-width: 768px)": { fontSize: "16px", lineHeight: "22px" },
+ "@media (max-width: 375px)": { marginTop: "14px", fontSize: "12px", lineHeight: "18px" },
+});
+
+const AnswerHeader = styled.div({
+ background: colors.backgroundDark,
+ borderRadius: "30px 30px 0 0",
+ padding: "40px 80px",
+ "@media (max-width: 768px)": { padding: "24px 20px" },
+});
+
+const AnswerBody = styled.div({
+ background: colors.slate200,
+ borderRadius: "0 0 30px 30px",
+ padding: "40px 80px",
+ "@media (max-width: 768px)": { padding: "20px" },
+});
+
+const UploadBox = styled.label({
+ width: "100%",
+ borderRadius: "10px",
+ background: "#ffffff",
+ padding: "40px 20px",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flexDirection: "column",
+ gap: "12px",
+ color: colors.textSecondary,
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ cursor: "pointer",
+ textAlign: "center",
+ "@media (max-width: 768px)": { fontSize: "16px", lineHeight: "24px" },
+});
+
+const HiddenFile = styled.input({ display: "none" });
+
+const ButtonRow = styled.div({
+ marginTop: "56px",
+ display: "flex",
+ gap: "20px",
+ "@media (max-width: 768px)": { marginTop: "40px" },
+ "@media (max-width: 375px)": { marginTop: "28px", flexDirection: "column" },
+});
+
+const ActionButton = styled.button<{ primary?: boolean; full?: boolean }>(({ primary, full }) => ({
+ border: "none",
+ height: "80px",
+ borderRadius: "100px",
+ padding: "20px 50px",
+ minWidth: full ? undefined : "200px",
+ width: full ? "100%" : "auto",
+ background: primary ? colors.primary : "#62748e",
+ color: "#ffffff",
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ cursor: "pointer",
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: "4px",
+ "@media (max-width: 768px)": { height: "68px", fontSize: "18px", minWidth: "160px" },
+ "@media (max-width: 375px)": { height: "52px", width: "100%", fontSize: "16px", minWidth: 0 },
+}));
+
+const Arrow = ({ back = false }: { back?: boolean }) => (
+
+);
+
+const SuccessWrap = styled.div({
+ padding: "72px 0 40px",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ textAlign: "center",
+ "@media (max-width: 375px)": { paddingTop: "20px", gap: "24px" },
+});
+
+const SuccessTitle = styled.h3({
+ margin: 0,
+ fontSize: "64px",
+ lineHeight: "75px",
+ fontWeight: fontWeights.bold,
+ marginBottom: "20px",
+ marginTop: "40px",
+ "@media (max-width: 768px)": { fontSize: "44px", lineHeight: "54px" },
+ "@media (max-width: 375px)": { fontSize: "32px", lineHeight: "40px" },
+});
+
+const ErrorText = styled.p({
+ margin: "10px 0 0",
+ color: "#ff7d7d",
+ fontSize: "14px",
+ lineHeight: "18px",
+ fontWeight: fontWeights.medium,
+});
+
+const FieldError = styled.p({
+ margin: "6px 0 0",
+ color: "#ff7d7d",
+ fontSize: "14px",
+ lineHeight: "18px",
+ fontWeight: fontWeights.medium,
+});
+
+const validateBasicField = (field: BasicField, value: string) => {
+ const trimmed = value.trim();
+
+ if (field === "name") {
+ if (!trimmed) return "이름을 입력해주세요.";
+ if (!/^[가-힣a-zA-Z\s]{2,20}$/.test(trimmed)) {
+ return "이름은 2~20자의 한글/영문으로 입력해주세요.";
+ }
+ return null;
+ }
+
+ if (field === "email") {
+ if (!trimmed) return "이메일을 입력해주세요.";
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return "올바른 이메일 형식을 입력해주세요.";
+ return null;
+ }
+
+ if (field === "phone") {
+ if (!trimmed) return "휴대폰 번호를 입력해주세요.";
+ const digits = trimmed.replace(/\D/g, "");
+ if (digits.length < 10 || digits.length > 11) return "올바른 휴대폰 번호를 입력해주세요.";
+ return null;
+ }
+
+ if (field === "region") {
+ if (!trimmed) return "거주지역을 입력해주세요.";
+ return null;
+ }
+
+ const normalized = trimmed.replace(/\s+/g, "");
+ const match = normalized.match(/^(\d{4})[/.-]?(\d{2})[/.-]?(\d{2})$/);
+
+ if (!trimmed) return "생년월일을 입력해주세요.";
+ if (!match) return "생년월일은 YYYY/MM/DD 형식으로 입력해주세요.";
+
+ const year = Number(match[1]);
+ const month = Number(match[2]);
+ const day = Number(match[3]);
+ const date = new Date(year, month - 1, day);
+ const validDate =
+ date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
+
+ if (!validDate) return "유효한 생년월일을 입력해주세요.";
+ return null;
+};
+
+export const RecruitApplySection = () => {
+ const [step, setStep] = useState(1);
+ const [values, setValues] = useState(initialValues);
+ const [error, setError] = useState(null);
+ const [basicErrors, setBasicErrors] = useState>>({});
+ const [basicTouched, setBasicTouched] = useState>>({});
+ const [focusedField, setFocusedField] = useState(null);
+ const [partIdByOption, setPartIdByOption] = useState>>(
+ {},
+ );
+ const [configError, setConfigError] = useState(null);
+ const [isBootstrapLoading, setIsBootstrapLoading] = useState(true);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isSavingDraft, setIsSavingDraft] = useState(false);
+ const [isLoadingDraft, setIsLoadingDraft] = useState(false);
+
+ const stepLabels = useMemo(() => ["기본 정보", "지원 파트", "지원서", "기타 정보"], []);
+
+ useEffect(() => {
+ let mounted = true;
+ (async () => {
+ setIsBootstrapLoading(true);
+ setConfigError(null);
+ try {
+ const map = await fetchApplyPartIdMap();
+ if (!mounted) return;
+ setPartIdByOption(map);
+ if (Object.keys(map).length === 0) {
+ setConfigError(
+ "현재 모집 중인 지원 파트 정보를 불러오지 못했어요. 잠시 후 다시 시도해주세요.",
+ );
+ }
+ } catch (e) {
+ if (!mounted) return;
+ setConfigError(e instanceof ApiError ? e.message : "모집 정보를 불러오지 못했습니다.");
+ } finally {
+ if (mounted) setIsBootstrapLoading(false);
+ }
+ })();
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!values.part) return;
+ const cohortPartId = partIdByOption[values.part as ApplyPartOption];
+ if (typeof cohortPartId !== "number") return;
+
+ let cancelled = false;
+ (async () => {
+ setIsLoadingDraft(true);
+ try {
+ const draft = await fetchApplicationDraftAnswers(cohortPartId);
+ if (cancelled || !draft) return;
+ const patch = parseDraftToFormValues(draft);
+ setValues((prev) => {
+ const next: FormValues = { ...prev };
+ if (patch.email !== undefined) next.email = patch.email;
+ if (patch.name !== undefined) next.name = patch.name;
+ if (patch.phone !== undefined) next.phone = patch.phone;
+ if (patch.birth !== undefined) next.birth = patch.birth;
+ if (patch.region !== undefined) next.region = patch.region;
+ if (patch.agreedToPrivacy !== undefined) next.agreedToPrivacy = patch.agreedToPrivacy;
+ if (patch.part !== undefined) next.part = patch.part;
+ if (patch.essay !== undefined) next.essay = patch.essay;
+ if (patch.portfolioLink !== undefined) next.portfolioLink = patch.portfolioLink;
+ if (patch.channels !== undefined) next.channels = patch.channels;
+ return next;
+ });
+ } finally {
+ if (!cancelled) setIsLoadingDraft(false);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [values.part, partIdByOption]);
+
+ const buildDraftAnswers = useCallback((): Record => {
+ return {
+ email: values.email,
+ name: values.name,
+ phone: formatApplicantPhoneKorea(values.phone),
+ birth: values.birth,
+ region: values.region,
+ agreedToPrivacy: values.agreedToPrivacy,
+ part: values.part,
+ essay: values.essay,
+ portfolioLink: values.portfolioLink,
+ portfolioFileName: values.portfolioFile?.name ?? "",
+ channels: values.channels,
+ };
+ }, [values]);
+
+ const validateCurrentStep = () => {
+ if (step === 1) {
+ const nextBasicErrors: Partial> = {};
+ const nameError = validateBasicField("name", values.name);
+ const emailError = validateBasicField("email", values.email);
+ const phoneError = validateBasicField("phone", values.phone);
+ const birthError = validateBasicField("birth", values.birth);
+ const regionError = validateBasicField("region", values.region);
+ if (nameError) nextBasicErrors.name = nameError;
+ if (emailError) nextBasicErrors.email = emailError;
+ if (phoneError) nextBasicErrors.phone = phoneError;
+ if (birthError) nextBasicErrors.birth = birthError;
+ if (regionError) nextBasicErrors.region = regionError;
+ setBasicErrors(nextBasicErrors);
+ setBasicTouched((prev) => ({
+ ...prev,
+ name: true,
+ email: true,
+ phone: true,
+ birth: true,
+ region: true,
+ }));
+
+ if (nameError || emailError || phoneError || birthError || regionError) {
+ setError("기본 정보를 다시 확인해주세요.");
+ return false;
+ }
+ if (!values.agreedToPrivacy) {
+ setError("개인정보 수집 및 이용에 동의해주세요.");
+ return false;
+ }
+ }
+
+ if (step === 2 && !values.part) {
+ setError("지원 파트를 선택해주세요.");
+ return false;
+ }
+
+ if (step === 3 && !values.essay.trim()) {
+ setError("지원서를 입력해주세요.");
+ return false;
+ }
+
+ if (step === 4 && values.channels.length === 0) {
+ setError("DDD를 알게 된 경로를 1개 이상 선택해주세요.");
+ return false;
+ }
+
+ setError(null);
+ return true;
+ };
+
+ const validateAllStepsForSubmit = (): boolean => {
+ const nextBasicErrors: Partial> = {};
+ const nameError = validateBasicField("name", values.name);
+ const emailError = validateBasicField("email", values.email);
+ const phoneError = validateBasicField("phone", values.phone);
+ const birthError = validateBasicField("birth", values.birth);
+ const regionError = validateBasicField("region", values.region);
+ if (nameError) nextBasicErrors.name = nameError;
+ if (emailError) nextBasicErrors.email = emailError;
+ if (phoneError) nextBasicErrors.phone = phoneError;
+ if (birthError) nextBasicErrors.birth = birthError;
+ if (regionError) nextBasicErrors.region = regionError;
+ setBasicErrors(nextBasicErrors);
+ setBasicTouched((prev) => ({
+ ...prev,
+ name: true,
+ email: true,
+ phone: true,
+ birth: true,
+ region: true,
+ }));
+
+ if (nameError || emailError || phoneError || birthError || regionError) {
+ setError("기본 정보를 다시 확인해주세요.");
+ return false;
+ }
+ if (!values.agreedToPrivacy) {
+ setError("개인정보 수집 및 이용에 동의해주세요.");
+ return false;
+ }
+ if (!values.part) {
+ setError("지원 파트를 선택해주세요.");
+ return false;
+ }
+ if (!values.essay.trim()) {
+ setError("지원서를 입력해주세요.");
+ return false;
+ }
+ if (values.channels.length === 0) {
+ setError("DDD를 알게 된 경로를 1개 이상 선택해주세요.");
+ return false;
+ }
+ setError(null);
+ return true;
+ };
+
+ const handleSaveDraft = useCallback(async () => {
+ if (!values.part) {
+ setError("지원 파트를 선택한 뒤 임시저장할 수 있어요.");
+ return;
+ }
+ const cohortPartId = partIdByOption[values.part as ApplyPartOption];
+ if (typeof cohortPartId !== "number") {
+ setError("선택한 파트의 모집 정보를 찾지 못했어요.");
+ return;
+ }
+ setError(null);
+ setIsSavingDraft(true);
+ try {
+ await saveRecruitApplicationDraft(cohortPartId, buildDraftAnswers());
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "임시저장에 실패했습니다.");
+ } finally {
+ setIsSavingDraft(false);
+ }
+ }, [values.part, partIdByOption, buildDraftAnswers]);
+
+ const handleNext = async (event: FormEvent) => {
+ event.preventDefault();
+ if (isSubmitting || isBootstrapLoading) return;
+ if (!validateCurrentStep()) return;
+
+ if (step === 4) {
+ if (!validateAllStepsForSubmit()) return;
+ const cohortPartId = partIdByOption[values.part as ApplyPartOption];
+ if (typeof cohortPartId !== "number") {
+ setError("지원 파트 정보를 불러오지 못해 제출할 수 없어요.");
+ return;
+ }
+ const birthApi = birthInputToApiDate(values.birth);
+ setIsSubmitting(true);
+ setError(null);
+ try {
+ await submitRecruitApplication({
+ cohortPartId,
+ applicantName: values.name.trim(),
+ applicantPhone: formatApplicantPhoneKorea(values.phone),
+ applicantBirthDate: birthApi,
+ applicantRegion: values.region.trim(),
+ answers: buildDraftAnswers(),
+ privacyAgreed: values.agreedToPrivacy,
+ });
+ setStep(5);
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "제출에 실패했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ return;
+ }
+
+ setStep((prev) => (prev < 5 ? ((prev + 1) as Step) : prev));
+ };
+
+ const handlePrev = () => {
+ setError(null);
+ setStep((prev) => (prev > 1 ? ((prev - 1) as Step) : prev));
+ };
+
+ const toggleChannel = (channel: string) => {
+ setValues((prev) => ({
+ ...prev,
+ channels: prev.channels.includes(channel)
+ ? prev.channels.filter((item) => item !== channel)
+ : [...prev.channels, channel],
+ }));
+ };
+
+ const partTitle = values.part ? `${values.part} 파트 지원서` : "지원서";
+ const handleBasicBlur = (field: BasicField) => {
+ setFocusedField((prev) => (prev === field ? null : prev));
+ setBasicTouched((prev) => ({ ...prev, [field]: true }));
+ const nextError = validateBasicField(field, values[field]);
+ setBasicErrors((prev) => ({ ...prev, [field]: nextError ?? "" }));
+ };
+
+ const handleBasicChange = (field: BasicField, value: string) => {
+ setValues((prev) => ({ ...prev, [field]: value }));
+ if (!basicTouched[field]) return;
+ const nextError = validateBasicField(field, value);
+ setBasicErrors((prev) => ({ ...prev, [field]: nextError ?? "" }));
+ };
+
+ return (
+
+
+
+ Recruitment
+ {BANNER_TEXT}
+
+
+
+
+
+ {step < 5 ? (
+ <>
+ DDD 지원서
+ {BANNER_TEXT}
+ {configError ? (
+ {configError}
+ ) : null}
+ {isBootstrapLoading ? (
+
+ 모집 정보를 불러오는 중이에요...
+
+ ) : null}
+ {isLoadingDraft && values.part ? (
+
+ 저장된 임시 지원서를 불러오는 중이에요...
+
+ ) : null}
+ >
+ ) : null}
+
+ {step < 5 ? (
+ <>
+
+
+ {stepLabels.map((label, index) => {
+ const current = index + 1;
+ const isActive = step === current;
+ return (
+
+ {current}
+ {label}
+
+ );
+ })}
+
+
+
+ >
+ ) : (
+
+
+
+
지원서가 제출됐어요.
+
+ 검토 후 입력하신 이메일로 결과를 안내드릴게요.
+
+ DDD와 함께할 날을 기대하고 있을게요 :)
+
+
+ {
+ setValues(initialValues);
+ setBasicErrors({});
+ setBasicTouched({});
+ setError(null);
+ setStep(1);
+ }}
+ >
+ 완료
+
+
+ )}
+
+
+
+ );
+};
diff --git a/apps/web/components/sections/RecruitCurriculumSection.tsx b/apps/web/components/sections/RecruitCurriculumSection.tsx
new file mode 100644
index 0000000..9e91079
--- /dev/null
+++ b/apps/web/components/sections/RecruitCurriculumSection.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import styled from "@emotion/styled";
+import { assets } from "@/constants/assets";
+import { colors, fontWeights } from "@/constants/tokens";
+import { recruitCurriculum } from "@/constants/recruit";
+
+const Section = styled.section({
+ background: colors.background,
+ padding: "80px 80px",
+ overflow: "hidden",
+
+ "@media (max-width: 1024px)": { padding: "80px 80px" },
+ "@media (max-width: 768px)": { padding: "80px 40px" },
+ "@media (max-width: 375px)": { padding: "40px 16px" },
+});
+
+const Inner = styled.div({
+ position: "relative",
+ width: "100%",
+ maxWidth: "1280px",
+ margin: "0 auto",
+});
+
+const Title = styled.h2({
+ margin: 0,
+ textAlign: "center",
+ color: colors.textInverse,
+ fontSize: "40px",
+ lineHeight: "50px",
+ fontWeight: fontWeights.bold,
+ "@media (max-width: 1024px)": { fontSize: "34px", lineHeight: "45px" },
+ "@media (max-width: 768px)": { fontSize: "30px", lineHeight: "38px" },
+ "@media (max-width: 375px)": { fontSize: "20px", lineHeight: "25px" },
+});
+
+const Grid = styled.div({
+ marginTop: "40px",
+ display: "grid",
+ gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
+ gap: "24px",
+ width: "100%",
+
+ "@media (max-width: 768px)": { gridTemplateColumns: "1fr" },
+ "@media (max-width: 375px)": { marginTop: "20px", gap: "8px" },
+});
+
+const Item = styled.article({
+ borderBottom: "1px solid #90a1b9",
+ minHeight: "268px",
+ padding: "32px 0 62px",
+ display: "grid",
+ // minmax(0, 1fr): 콘텐츠 최소 너비로 그리드가 뷰포트를 밀어내지 않도록 함
+ gridTemplateColumns: "minmax(0, 1fr) 84px",
+ gap: "32px",
+ position: "relative",
+ zIndex: 1,
+ minWidth: 0,
+ width: "100%",
+ maxWidth: "100%",
+
+ "@media (max-width: 375px)": {
+ minHeight: "187px",
+ gridTemplateColumns: "minmax(0, 1fr) 60px",
+ gap: "12px",
+ padding: "20px 0",
+ },
+});
+
+const ItemBody = styled.div({
+ minWidth: 0,
+ overflowWrap: "break-word",
+});
+
+const Week = styled.p({
+ margin: 0,
+ color: "#90a1b9",
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ textAlign: "right",
+
+ "@media (max-width: 1024px)": { fontSize: "18px", lineHeight: "23px" },
+ "@media (max-width: 768px)": { fontSize: "16px", lineHeight: "20px" },
+ "@media (max-width: 375px)": { fontSize: "14px", lineHeight: "18px" },
+});
+
+const DateText = styled.p({
+ margin: 0,
+ color: "#90a1b9",
+ fontSize: "24px",
+ lineHeight: "30px",
+ fontWeight: fontWeights.medium,
+
+ "@media (max-width: 1024px)": { fontSize: "20px", lineHeight: "25px" },
+ "@media (max-width: 768px)": { fontSize: "18px", lineHeight: "24px" },
+ "@media (max-width: 375px)": { fontSize: "16px", lineHeight: "20px" },
+});
+
+const ItemTitle = styled.h3({
+ margin: "16px 0 0",
+ color: colors.textInverse,
+ fontSize: "40px",
+ lineHeight: "50px",
+ fontWeight: fontWeights.bold,
+
+ "@media (max-width: 1024px)": { fontSize: "34px", lineHeight: "45px" },
+ "@media (max-width: 768px)": { fontSize: "30px", lineHeight: "38px" },
+ "@media (max-width: 375px)": { fontSize: "20px", lineHeight: "25px" },
+});
+
+const Description = styled.p({
+ margin: "16px 0 0",
+ color: colors.textInverse,
+ fontSize: "24px",
+ lineHeight: "30px",
+ fontWeight: fontWeights.medium,
+
+ "@media (max-width: 1024px)": { fontSize: "20px", lineHeight: "25px" },
+ "@media (max-width: 768px)": { fontSize: "18px", lineHeight: "24px" },
+ "@media (max-width: 375px)": { fontSize: "16px", lineHeight: "20px" },
+});
+
+const Floating3D = styled.img({
+ position: "absolute",
+ left: "50%",
+ top: "38%",
+ width: "610px",
+ transform: "translateX(-50%) rotate(6deg)",
+ opacity: 0.4,
+ pointerEvents: "none",
+
+ "@media (max-width: 768px)": { width: "420px", top: "46%" },
+ "@media (max-width: 375px)": { width: "220px", top: "56%" },
+});
+
+export const RecruitCurriculumSection = () => {
+ return (
+
+
+ 13기 커리큘럼
+
+
+ {recruitCurriculum.map((item) => (
+ -
+
+ {item.date}
+ {item.title}
+ {item.description}
+
+ {item.week}
+
+ ))}
+
+
+
+ );
+};
diff --git a/apps/web/components/sections/RecruitHeroSection.tsx b/apps/web/components/sections/RecruitHeroSection.tsx
new file mode 100644
index 0000000..17478aa
--- /dev/null
+++ b/apps/web/components/sections/RecruitHeroSection.tsx
@@ -0,0 +1,217 @@
+"use client";
+
+import Link from "next/link";
+import styled from "@emotion/styled";
+import { assets } from "@/constants/assets";
+import { recruitHeroDescriptionByStatus } from "@/constants/recruit";
+import { useRecruitStatus } from "@/components/providers/RecruitStatusProvider";
+import { openPreAlertModal } from "@/components/modals/PreAlertModal";
+import { colors, fontWeights } from "@/constants/tokens";
+
+const Section = styled.section({
+ position: "relative",
+ overflow: "hidden",
+ background: colors.background,
+ minHeight: "1080px",
+ paddingTop: "120px",
+
+ "@media (max-width: 1024px)": {
+ minHeight: "1080px",
+ },
+ "@media (max-width: 768px)": {
+ minHeight: "1000px",
+ paddingTop: "100px",
+ },
+ "@media (max-width: 375px)": {
+ minHeight: "812px",
+ paddingTop: "54px",
+ },
+});
+
+const Bg = styled.div({
+ position: "absolute",
+ inset: 0,
+ backgroundImage: `url('${assets.recruitHeroBg}')`,
+ backgroundSize: "cover",
+ backgroundPosition: "center",
+});
+
+const Overlay = styled.div({
+ position: "absolute",
+ inset: 0,
+ backdropFilter: "blur(5px)",
+ background: "rgba(12, 14, 15, 0.7)",
+});
+
+const Inner = styled.div({
+ position: "relative",
+ zIndex: 1,
+ width: "100%",
+ maxWidth: "1280px",
+ margin: "0 auto",
+ height: "100%",
+ display: "flex",
+ flexDirection: "column",
+ justifyContent: "center",
+ alignItems: "center",
+ gap: "44px",
+ textAlign: "center",
+
+ "@media (max-width: 1024px)": {
+ padding: "0 80px",
+ },
+ "@media (max-width: 768px)": {
+ padding: "0 20px",
+ },
+});
+
+const Label = styled.p({
+ color: colors.textInverse,
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ marginTop: "120px",
+ "@media (max-width: 1024px)": {
+ fontSize: "18px",
+ lineHeight: "23px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+const Title = styled.h1({
+ margin: 0,
+ maxWidth: "100%",
+ whiteSpace: "pre-line",
+ fontSize: "130px",
+ lineHeight: "130px",
+ fontWeight: fontWeights.bold,
+ // Figma 헤드 타이틀 느낌을 이미지가 아닌 타이포/그라데이션으로 구현
+ backgroundImage: "linear-gradient(180deg, rgba(255, 255, 255, 0.00) -3.04%, #FFF 95.35%);",
+ backgroundClip: "text",
+ WebkitBackgroundClip: "text",
+ WebkitTextFillColor: "transparent",
+ letterSpacing: "-0.02em",
+
+ "@media (max-width: 1024px)": {
+ fontSize: "100px",
+ lineHeight: "110px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "clamp(45px, calc(45px + (100vw - 375px) * 45 / 393), 90px)",
+ lineHeight: "clamp(50px, calc(50px + (100vw - 375px) * 50 / 393), 100px)",
+ },
+});
+
+const Description = styled.p({
+ margin: 0,
+ color: colors.textInverse,
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ whiteSpace: "pre-line",
+ "@media (max-width: 1024px)": {
+ fontSize: "18px",
+ lineHeight: "23px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+const CtaButton = styled(Link)({
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: "4px",
+ height: "80px",
+ padding: "20px 50px",
+ background: colors.primary,
+ borderRadius: "100px",
+ color: colors.textInverse,
+ textDecoration: "none",
+ fontSize: "20px",
+ lineHeight: "25px",
+ fontWeight: fontWeights.medium,
+ transition: "background 0.15s ease",
+
+ "&:hover": { background: "#1f5fe0" },
+
+ "@media (max-width: 768px)": {
+ height: "68px",
+ padding: "16px 36px",
+ fontSize: "18px",
+ },
+ "@media (max-width: 375px)": {
+ height: "40px",
+ width: "157px",
+ maxWidth: "157px",
+ padding: "0 16px",
+ fontSize: "12px",
+ lineHeight: "16px",
+ },
+});
+
+const Arrow = styled.span({
+ display: "inline-flex",
+ width: "24px",
+ height: "24px",
+ alignItems: "center",
+ justifyContent: "center",
+ transform: "translateY(-1px)",
+});
+
+export const RecruitHeroSection = () => {
+ const { recruitStatus, isRecruitOpen, recruitButtonLabels } = useRecruitStatus();
+ const heroTitle = recruitStatus === "open" ? "Now\nRecruiting" : "Currently Under\nRenewal";
+ const recruitActionHref = isRecruitOpen ? "/recruit/apply" : "/recruit";
+
+ return (
+
+ {/* background image */}
+
+
+
+
+
{heroTitle}
+ {recruitHeroDescriptionByStatus[recruitStatus]}
+
+ {
+ if (isRecruitOpen) return;
+ event.preventDefault();
+ openPreAlertModal();
+ }}
+ >
+ {recruitButtonLabels.hero}
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/components/sections/RecruitRolesSection.tsx b/apps/web/components/sections/RecruitRolesSection.tsx
new file mode 100644
index 0000000..e8741d5
--- /dev/null
+++ b/apps/web/components/sections/RecruitRolesSection.tsx
@@ -0,0 +1,236 @@
+"use client";
+
+import styled from "@emotion/styled";
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { colors, fontWeights } from "@/constants/tokens";
+import { recruitParts } from "@/constants/recruit";
+import { useRecruitStatus } from "@/components/providers/RecruitStatusProvider";
+import { fetchCohortPartByActiveCohortId } from "@/lib/api/cohort";
+
+const Section = styled.section({
+ background: colors.background,
+ padding: "80px 80px",
+
+ "@media (max-width: 1024px)": { padding: "80px 80px" },
+ "@media (max-width: 768px)": { padding: "80px 40px" },
+ "@media (max-width: 375px)": { padding: "40px 16px" },
+});
+
+const Inner = styled.div({
+ width: "100%",
+ maxWidth: "1280px",
+ margin: "0 auto",
+});
+
+const Title = styled.h2({
+ margin: 0,
+ textAlign: "center",
+ color: colors.textInverse,
+ fontSize: "40px",
+ lineHeight: "50px",
+ fontWeight: fontWeights.bold,
+ "@media (max-width: 1024px)": {
+ fontSize: "34px",
+ lineHeight: "45px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "30px",
+ lineHeight: "38px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "20px",
+ lineHeight: "25px",
+ },
+});
+
+const Grid = styled.div({
+ marginTop: "40px",
+ display: "grid",
+ gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
+ gap: "24px",
+
+ "@media (max-width: 768px)": {
+ gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
+ },
+ "@media (max-width: 525px)": {
+ gridTemplateColumns: "1fr",
+ gap: "12px",
+ },
+});
+
+const Card = styled.article<{ isRecruitOpen: boolean }>(({ isRecruitOpen }) => ({
+ borderRadius: "30px",
+ padding: "40px",
+ background: colors.backgroundDark,
+ display: "flex",
+ flexDirection: "column",
+ justifyContent: "space-between",
+ alignItems: "center",
+ gap: "20px",
+ textAlign: "center",
+ transition: "background 0.2s ease",
+
+ ...(isRecruitOpen
+ ? {
+ "&:hover, &:focus-within": {
+ background: colors.primary,
+ },
+ }
+ : {}),
+
+ "@media (max-width: 375px)": {
+ padding: "20px",
+ borderRadius: "24px",
+ },
+}));
+
+const RoleName = styled.h3({
+ margin: 0,
+ color: colors.textInverse,
+ fontSize: "28px",
+ lineHeight: "32px",
+ fontWeight: fontWeights.semiBold,
+ "@media (max-width: 1024px)": { fontSize: "24px", lineHeight: "30px" },
+ "@media (max-width: 768px)": { fontSize: "20px", lineHeight: "25px" },
+ "@media (max-width: 375px)": { fontSize: "16px", lineHeight: "20px" },
+});
+
+const RoleDescription = styled.p<{ isRecruitOpen: boolean }>(({ isRecruitOpen }) => ({
+ margin: 0,
+ color: colors.mainLight,
+ fontSize: "20px",
+ lineHeight: "28px",
+ fontWeight: fontWeights.medium,
+ maxHeight: 0,
+ opacity: 0,
+ overflow: "hidden",
+ transition: "max-height 0.2s ease, opacity 0.15s ease, margin-top 0.2s ease",
+ marginTop: 0,
+
+ ...(isRecruitOpen
+ ? {
+ ".role-card:hover &, .role-card:focus-within &": {
+ maxHeight: "120px",
+ opacity: 1,
+ marginTop: "4px",
+ },
+ }
+ : {}),
+
+ "@media (max-width: 1024px)": { fontSize: "18px", lineHeight: "23px" },
+ "@media (max-width: 768px)": { fontSize: "16px", lineHeight: "20px" },
+ "@media (max-width: 375px)": { fontSize: "14px", lineHeight: "18px" },
+}));
+
+const ApplyButton = styled.button<{ isRecruitOpen: boolean }>(({ isRecruitOpen }) => ({
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ border: "none",
+ width: "100%",
+ height: "65px",
+ borderRadius: "100px",
+ background: "#62748e",
+ color: colors.textInverse,
+ fontSize: "20px",
+ lineHeight: "25px",
+ fontWeight: fontWeights.medium,
+ cursor: isRecruitOpen ? "pointer" : "default",
+ transition: "background 0.2s ease",
+
+ ...(isRecruitOpen
+ ? {
+ ".role-card:hover &, .role-card:focus-within &": {
+ background: "#0a62bb",
+ },
+
+ "& svg": {
+ width: "24px",
+ height: "24px",
+ flexShrink: 0,
+ },
+ }
+ : {}),
+
+ "@media (max-width: 1024px)": {
+ height: "60px",
+ fontSize: "18px",
+ lineHeight: "23px",
+ },
+ "@media (max-width: 768px)": {
+ height: "52px",
+ fontSize: "16px",
+ lineHeight: "22px",
+ },
+ "@media (max-width: 375px)": {
+ height: "40px",
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+}));
+
+export const RecruitRolesSection = () => {
+ const { isRecruitOpen, recruitButtonLabels } = useRecruitStatus();
+ const router = useRouter();
+
+ useEffect(() => {
+ const printCohortPart = async () => {
+ const response = await fetchCohortPartByActiveCohortId();
+ console.warn("[RecruitRolesSection] cohort part by active cohort id:", response);
+ };
+ void printCohortPart();
+ }, []);
+
+ return (
+
+
+ 6개의 직군을 모집하고있어요.
+
+ {recruitParts.map((part) => {
+ const description = "description" in part ? part.description : undefined;
+
+ return (
+
+
+ {part.name}
+ {description ? (
+ {description}
+ ) : null}
+
+ {
+ if (!isRecruitOpen) return;
+ router.push("/recruit/apply");
+ }}
+ >
+ {recruitButtonLabels.role}
+ {isRecruitOpen ? (
+
+ ) : null}
+
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/apps/web/components/sections/RecruitScheduleSection.tsx b/apps/web/components/sections/RecruitScheduleSection.tsx
new file mode 100644
index 0000000..9b698e9
--- /dev/null
+++ b/apps/web/components/sections/RecruitScheduleSection.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import styled from "@emotion/styled";
+import { colors, fontWeights } from "@/constants/tokens";
+import { recruitSchedules } from "@/constants/recruit";
+
+const Section = styled.section({
+ background: colors.background,
+ padding: "80px 80px",
+
+ "@media (max-width: 1024px)": { padding: "80px 80px" },
+ "@media (max-width: 768px)": { padding: "80px 40px" },
+ "@media (max-width: 375px)": { padding: "40px 16px" },
+});
+
+const Inner = styled.div({
+ width: "100%",
+ maxWidth: "1280px",
+ margin: "0 auto",
+});
+
+const Title = styled.h2({
+ margin: 0,
+ color: colors.textInverse,
+ textAlign: "center",
+ fontSize: "40px",
+ lineHeight: "50px",
+ fontWeight: fontWeights.bold,
+ "@media (max-width: 1024px)": { fontSize: "34px", lineHeight: "45px" },
+ "@media (max-width: 768px)": { fontSize: "30px", lineHeight: "38px" },
+ "@media (max-width: 375px)": { fontSize: "20px", lineHeight: "25px" },
+});
+
+const List = styled.div({
+ marginTop: "40px",
+ display: "flex",
+ flexDirection: "column",
+ gap: "24px",
+
+ "@media (max-width: 375px)": {
+ marginTop: "20px",
+ gap: "12px",
+ },
+});
+
+const Item = styled.article({
+ background: colors.backgroundDark,
+ borderRadius: "30px",
+ boxShadow: "inset 3px 3px 25px 0 rgba(146, 146, 146, 0.25)",
+ padding: "40px",
+ display: "flex",
+ alignItems: "center",
+ gap: "56px",
+
+ "@media (max-width: 768px)": { padding: "40px" },
+ "@media (max-width: 375px)": {
+ padding: "24px",
+ gap: "40px",
+ },
+});
+
+const Step = styled.p({
+ margin: 0,
+ color: "#90a1b9",
+ fontSize: "64px",
+ lineHeight: "75px",
+ fontWeight: fontWeights.bold,
+ "@media (max-width: 1024px)": { fontSize: "54px", lineHeight: "65px" },
+ "@media (max-width: 768px)": { fontSize: "42px", lineHeight: "52px" },
+ "@media (max-width: 375px)": { fontSize: "38px", lineHeight: "48px" },
+});
+
+const Label = styled.p({
+ margin: 0,
+ color: "#90a1b9",
+ fontSize: "28px",
+ lineHeight: "32px",
+ fontWeight: fontWeights.semiBold,
+ "@media (max-width: 1024px)": { fontSize: "24px", lineHeight: "30px" },
+ "@media (max-width: 768px)": { fontSize: "20px", lineHeight: "25px" },
+ "@media (max-width: 375px)": { fontSize: "16px", lineHeight: "20px" },
+});
+
+const DateText = styled.p({
+ margin: 0,
+ marginTop: "10px",
+ color: colors.textInverse,
+ fontSize: "40px ",
+ lineHeight: "50px",
+ fontWeight: fontWeights.bold,
+ "@media (max-width: 1024px)": { fontSize: "34px", lineHeight: "45px" },
+ "@media (max-width: 768px)": { fontSize: "30px", lineHeight: "38px" },
+ "@media (max-width: 575px)": { fontSize: "20px", lineHeight: "25px", marginTop: "6px" },
+});
+
+export const RecruitScheduleSection = () => {
+ return (
+
+
+ 13기 모집 일정
+
+ {recruitSchedules.map((item) => (
+ -
+ {item.step}
+
+
+ {item.date}
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/apps/web/components/sections/SponsorSection.tsx b/apps/web/components/sections/SponsorSection.tsx
new file mode 100644
index 0000000..ab5bb06
--- /dev/null
+++ b/apps/web/components/sections/SponsorSection.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+import Link from "next/link";
+import styled from "@emotion/styled";
+import { assets } from "@/constants/assets";
+import { colors, fontSizes, fontWeights, lineHeights } from "@/constants/tokens";
+
+const SPONSORS = [
+ { name: "Elice", logo: assets.sponsors.elice },
+ { name: "ICTCOC", logo: assets.sponsors.ictcoc },
+ { name: "아산나눔재단", logo: assets.sponsors.asanNanum },
+ { name: "한빛미디어", logo: assets.sponsors.hanbit },
+] as const;
+
+const Section = styled.section({
+ background: colors.background,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ padding: "120px 80px",
+ gap: "24px",
+
+ "@media (max-width: 1024px)": { padding: "120px 80px" },
+ "@media (max-width: 768px)": { padding: "100px 40px" },
+ "@media (max-width: 375px)": { padding: "80px 16px" },
+});
+
+const Inner = styled.div({
+ width: "100%",
+ maxWidth: "1280px",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "24px",
+});
+
+const TitleArea = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "24px",
+ width: "100%",
+ alignItems: "center",
+ textAlign: "center",
+});
+
+const SectionLabel = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: "20px",
+ fontWeight: fontWeights.medium,
+ lineHeight: "28px",
+ color: colors.textInverse,
+ "@media (max-width: 1024px)": {
+ fontSize: "18px",
+ lineHeight: "23px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+const SectionTitle = styled.h2({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: "28px",
+ fontWeight: fontWeights.semiBold,
+ lineHeight: "32px",
+ color: colors.textInverse,
+ "@media (max-width: 1024px)": {
+ fontSize: "24px",
+ lineHeight: "30px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "20px",
+ lineHeight: "25px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+});
+
+const LogoGrid = styled.div({
+ display: "flex",
+ flexWrap: "wrap",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: "40px",
+ paddingTop: "20px",
+ width: "100%",
+});
+
+const SponsorLogo = styled.div({
+ width: "160px",
+ height: "160px",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ overflow: "hidden",
+
+ "& img": {
+ maxWidth: "100%",
+ maxHeight: "100%",
+ objectFit: "contain",
+ },
+});
+
+const ContactButton = styled(Link)({
+ display: "flex",
+ alignItems: "center",
+ gap: "4px",
+ height: "80px",
+ padding: "20px 50px",
+ background: colors.primary,
+ borderRadius: "100px",
+ color: colors.textInverse,
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.large,
+ fontWeight: fontWeights.medium,
+ lineHeight: "28px",
+ textDecoration: "none",
+ whiteSpace: "nowrap",
+ flexShrink: 0,
+ transition: "background 0.15s",
+
+ "&:hover": {
+ background: "#1f5fe0",
+ },
+
+ "@media (max-width: 1024px)": {
+ fontSize: "18px",
+ lineHeight: "24px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+export const SponsorSection = () => {
+ return (
+
+
+
+ Sponsor
+
+ DDD는 다양한 파트너사와 함께 성장하고 있어요.
+
+ 후원과 협력으로 더 나은 IT 커뮤니티를 만들어가고 있습니다.
+
+
+
+ {SPONSORS.map(({ name, logo }) => (
+
+
+
+ ))}
+
+
+ 후원 문의하기
+ {" "}
+
+
+
+ );
+};
diff --git a/apps/web/components/ui/Button.tsx b/apps/web/components/ui/Button.tsx
new file mode 100644
index 0000000..de8bc17
--- /dev/null
+++ b/apps/web/components/ui/Button.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import styled from '@emotion/styled';
+import { colors, fontSizes, fontWeights, lineHeights } from '@/constants/tokens';
+
+interface ButtonProps {
+ children: React.ReactNode;
+ onClick?: () => void;
+ type?: 'button' | 'submit';
+}
+
+const StyledButton = styled.button({
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '4px',
+ height: '80px',
+ padding: '20px 50px',
+ background: colors.primary,
+ border: 'none',
+ borderRadius: '100px',
+ color: colors.textInverse,
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.large,
+ fontWeight: fontWeights.medium,
+ lineHeight: lineHeights.paragraphLarge,
+ cursor: 'pointer',
+ whiteSpace: 'nowrap',
+ flexShrink: 0,
+
+ '&:hover': {
+ background: '#1f5fe0',
+ },
+
+ '@media (max-width: 768px)': {
+ height: '68px',
+ padding: '16px 36px',
+ fontSize: '18px',
+ lineHeight: '24px',
+ },
+ '@media (max-width: 375px)': {
+ height: '56px',
+ padding: '14px 24px',
+ fontSize: '14px',
+ lineHeight: '18px',
+ },
+});
+
+export const Button = ({ children, onClick, type = 'button' }: ButtonProps) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/web/components/ui/ProjectCard.tsx b/apps/web/components/ui/ProjectCard.tsx
new file mode 100644
index 0000000..94526b4
--- /dev/null
+++ b/apps/web/components/ui/ProjectCard.tsx
@@ -0,0 +1,214 @@
+"use client";
+
+import styled from "@emotion/styled";
+import Link from "next/link";
+import { colors, fontSizes, fontWeights, lineHeights } from "@/constants/tokens";
+
+interface ProjectCardProps {
+ title: string;
+ description: string;
+ thumbnail: string;
+ category: string;
+ generation: string;
+ href?: string;
+}
+
+const Card = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ background: "white",
+ borderRadius: "30px",
+ width: "100%",
+ overflow: "hidden",
+});
+
+const Thumbnail = styled.div({
+ position: "relative",
+ width: "100%",
+ aspectRatio: "1 / 1",
+ flexShrink: 0,
+
+ "& img": {
+ width: "100%",
+ height: "100%",
+ objectFit: "cover",
+ },
+});
+
+const CardBody = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "8px",
+ padding: "20px 24px",
+});
+
+const CardTexts = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: "8px",
+});
+
+const CardTitle = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.headingLarge,
+ fontWeight: fontWeights.semiBold,
+ lineHeight: lineHeights.headingLarge,
+ color: colors.textPrimary,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+
+ "@media (max-width: 1024px)": {
+ fontSize: "24px",
+ lineHeight: "30px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "20px",
+ lineHeight: "25px",
+ },
+
+ "@media (max-width: 375px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+});
+
+const CardDescription = styled.p({
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.medium,
+ fontWeight: fontWeights.regular,
+ lineHeight: lineHeights.paragraphMedium,
+ color: colors.textSecondary,
+ overflow: "hidden",
+ display: "-webkit-box",
+ WebkitLineClamp: 2,
+ WebkitBoxOrient: "vertical",
+ "@media (max-width: 1024px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "13px",
+ lineHeight: "18px",
+ },
+
+ "@media (max-width: 375px)": {
+ fontSize: "12px",
+ lineHeight: "15px",
+ },
+});
+
+const BadgeRow = styled.div({
+ display: "flex",
+ gap: "8px",
+ flexWrap: "wrap",
+ marginTop: "2px",
+});
+
+const CategoryBadge = styled.span({
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: "4px 20px",
+ background: colors.mainLight,
+ borderRadius: "30px",
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.large,
+ fontWeight: fontWeights.medium,
+ lineHeight: "28px",
+ color: colors.primary,
+ whiteSpace: "nowrap",
+ "@media (max-width: 1024px)": {
+ fontSize: "18px",
+ lineHeight: "24px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+const GenerationBadge = styled.span({
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: "4px 20px",
+ background: colors.categoryBg,
+ borderRadius: "30px",
+ fontFamily: "'Pretendard', sans-serif",
+ fontSize: fontSizes.large,
+ fontWeight: fontWeights.medium,
+ color: colors.textSecondary,
+ whiteSpace: "nowrap",
+ lineHeight: "28px",
+ "@media (max-width: 1024px)": {
+ fontSize: "18px",
+ lineHeight: "24px",
+ },
+ "@media (max-width: 768px)": {
+ fontSize: "16px",
+ lineHeight: "20px",
+ },
+ "@media (max-width: 375px)": {
+ fontSize: "14px",
+ lineHeight: "18px",
+ },
+});
+
+const CardLink = styled(Link)({
+ textDecoration: "none",
+ color: "inherit",
+});
+
+const ProjectCardBody = ({
+ title,
+ description,
+ thumbnail,
+ category,
+ generation,
+}: Omit) => (
+
+
+
+
+
+
+ {title}
+ {description}
+
+
+ {category}
+ {generation}
+
+
+
+);
+
+export const ProjectCard = ({
+ title,
+ description,
+ thumbnail,
+ category,
+ generation,
+ href,
+}: ProjectCardProps) => {
+ const body = (
+
+ );
+
+ if (href) {
+ return {body};
+ }
+
+ return body;
+};
diff --git a/apps/web/constants/articles.ts b/apps/web/constants/articles.ts
new file mode 100644
index 0000000..1b466a4
--- /dev/null
+++ b/apps/web/constants/articles.ts
@@ -0,0 +1,6 @@
+export type ArticleItem = {
+ id: string;
+ title: string;
+ description: string;
+ thumbnail: string;
+};
diff --git a/apps/web/constants/assets.ts b/apps/web/constants/assets.ts
new file mode 100644
index 0000000..d1fc071
--- /dev/null
+++ b/apps/web/constants/assets.ts
@@ -0,0 +1,27 @@
+export const assets = {
+ logo: "/images/logo.png",
+ heroBg: "https://www.figma.com/api/mcp/asset/368a3885-c833-4a97-bae3-923bf3c60070",
+ heroTextBg: "https://www.figma.com/api/mcp/asset/b0a8d8db-c55b-4d71-9a03-7bba5fbcd1a6",
+ recruitHeroBg: "https://www.figma.com/api/mcp/asset/d893df99-d978-48ed-b6d8-f83490db30b8",
+ recruitHeroTextBg: "https://www.figma.com/api/mcp/asset/e7dc7d16-f496-491c-9ad9-ac2771191474",
+ recruit3d: "https://www.figma.com/api/mcp/asset/e16d4787-f270-41f0-ad5d-0c40683b9c77",
+ hero3d: "/images/hero/main-hero-3d.png",
+ hero3d1024: "https://www.figma.com/api/mcp/asset/baac66b4-230e-4fa2-8529-3fdf3ca3988e",
+ hero3d768: "https://www.figma.com/api/mcp/asset/500cd51c-3def-4ced-bf9a-c3bafa84f13d",
+ hero3d375: "https://www.figma.com/api/mcp/asset/77b87f10-9900-430d-839e-662ba3e26fa7",
+ arrowRight: "https://www.figma.com/api/mcp/asset/962d703d-9e8e-47c9-b2a4-3e9d1bf0edf2",
+ arrowLeft: "https://www.figma.com/api/mcp/asset/475824fe-4859-46e5-8228-9e4a34252ce5",
+ chevronDown: "https://www.figma.com/api/mcp/asset/d136abfc-f9a0-4ac5-91f3-f513323f26cf",
+ sponsors: {
+ elice: "/images/imag_elice.png",
+ ictcoc: "/images/image_ictcoc.png",
+ asanNanum: "/images/image_asan-nanum.png",
+ hanbit: "/images/image_hanbit.png",
+ },
+ social: {
+ tistory: "/images/Tistory.png",
+ medium: "/images/Medium.png",
+ brunch: "/images/Brunch.png",
+ instagram: "/images/Instagram.png",
+ },
+} as const;
diff --git a/apps/web/constants/projects.ts b/apps/web/constants/projects.ts
new file mode 100644
index 0000000..149d69e
--- /dev/null
+++ b/apps/web/constants/projects.ts
@@ -0,0 +1,15 @@
+export type ProjectCategory = "전체" | "iOS" | "AOS" | "WEB";
+
+export type ProjectItem = {
+ id: string;
+ title: string;
+ description: string;
+ thumbnail: string;
+ category: Exclude;
+ generation: string;
+ banner: string;
+ pdf: string;
+ detailTitle: string;
+ longDescription: string;
+ participants: Array<{ name: string; role: string }>;
+};
diff --git a/apps/web/constants/recruit.ts b/apps/web/constants/recruit.ts
new file mode 100644
index 0000000..3a24413
--- /dev/null
+++ b/apps/web/constants/recruit.ts
@@ -0,0 +1,128 @@
+export type RecruitStatus = "open" | "closed";
+
+export const recruitStatus: RecruitStatus = "closed";
+
+export const recruitPageMetaDescriptionByStatus: Record = {
+ open: "지금 DDD 크루원을 모집하고 있어요! 함께 성장할 준비가 되셨다면 지금 바로 지원해보세요.",
+ closed: "DDD에서 함께할 개발자, 디자이너, 기획자를 모집합니다.",
+};
+
+export const recruitHeroDescriptionByStatus: Record = {
+ open: "지금 DDD 크루원을 모집하고 있어요!\n함께 성장할 준비가 되셨다면 지금 바로 지원해보세요.",
+ closed:
+ "다음 크루원 모집을 위해 DDD 운영진들이 열심히 준비 중이에요.\n크루원 모집 준비가 끝나면 그 누구보다 빠르게 연락 드릴게요!",
+};
+
+export const recruitButtonLabelsByStatus: Record<
+ RecruitStatus,
+ { navigation: string; hero: string; role: string }
+> = {
+ open: {
+ navigation: "지원 신청",
+ hero: "지원하기",
+ role: "지원하기",
+ },
+ closed: {
+ navigation: "사전 알림 신청",
+ hero: "사전 알림 신청하기",
+ role: "지원마감",
+ },
+};
+
+export const recruitButtonLabels = recruitButtonLabelsByStatus[recruitStatus];
+
+export const recruitParts = [
+ { name: "Product Manager" },
+ {
+ name: "Product Designer",
+ description:
+ "사용자의 니즈를 반영한 최상의 UI/UX를 만들어요. 여러 툴을 활용해 협업하며, 더 나은 사용자 경험을 고민해요.",
+ },
+ {
+ name: "Back-end",
+ description:
+ "서버와 데이터의 흐름을 설계해 서비스가 안정적으로 동작하도록 만들어요. 성능과 확장성을 고려해 빠르고 유연한 시스템을 구축해요.",
+ },
+ {
+ name: "Front-end",
+ description:
+ "사용자 중심의 직관적이고 빠른 웹 환경을 구축합니다. 최적화된 코드로 끊김 없는 사용자 경험을 제공합니다.",
+ },
+ {
+ name: "iOS",
+ description:
+ "Apple 생태계에 맞춰 안정적인 앱을 만들어요. 섬세한 디테일로 완성도 높은 경험을 설계해요.",
+ },
+ {
+ name: "Android",
+ description:
+ "다양한 환경에서 안정적으로 동작하는 앱을 만들어요. 지속 성장 가능한 서비스를 함께 개발해요.",
+ },
+] as const;
+
+export const recruitSchedules = [
+ { step: "01", label: "서류 접수", date: "2026.02.12 (목) - 02.18 (수)" },
+ { step: "02", label: "서류 발표", date: "2026.02.12" },
+ { step: "03", label: "온라인 인터뷰", date: "2026.02.12 (목) - 02.18 (수)" },
+ { step: "04", label: "최종 발표", date: "2026.02.12 (목)" },
+] as const;
+
+export const recruitCurriculum = [
+ {
+ week: "1주차",
+ date: "03.07",
+ title: "Orientation",
+ description: "크루원들과 처음 만나서 이야기를 나누는 날",
+ },
+ {
+ week: "2주차",
+ date: "03.21",
+ title: "부스팅 데이",
+ description: "팀에서 정한 아이디어를 바탕으로 기획을 구체화하는 날이에요",
+ },
+ {
+ week: "3주차",
+ date: "04.04",
+ title: "직군 세션",
+ description:
+ "같은 직군 멤버들과 모여 각자의 경험과 고민을 나누고, 시야를 넓히는 네트워킹 데이에요",
+ },
+ {
+ week: "4주차",
+ date: "04.18",
+ title: "UT 1차",
+ description:
+ "같은 직군 멤버들과 모여 각자의 경험과 고민을 나누고, 시야를 넓히는 네트워킹 데이에요",
+ },
+ {
+ week: "5주차",
+ date: "05.02",
+ title: "중간 발표",
+ description: "현재까지의 진행 상황과 서비스 방향을 공유하고, 피드백을 통해 방향성을 점검해요",
+ },
+ {
+ week: "6주차",
+ date: "05.16",
+ title: "티키타카",
+ description: "프로젝트를 잠시 벗어나, 전체 멤버들과 자유롭게 소통하며 관계를 다지는 날이에요",
+ },
+ {
+ week: "7주차",
+ date: "05.30",
+ title: "UT 2차",
+ description: "구현된 서비스를 중심으로 다시 한 번 사용성 테스트를 진행하고 완성도를 높여요",
+ },
+ {
+ week: "8주차",
+ date: "06.13",
+ title: "직군 세션",
+ description:
+ "프로젝트를 진행하며 쌓인 인사이트를 바탕으로, 직군별 경험을 공유하는 네트워킹 시간이에요",
+ },
+ {
+ week: "9주차",
+ date: "06.27",
+ title: "최종발표",
+ description: "4개월간의 결과물을 정리해 발표하고, 프로젝트를 하나의 서비스로 마무리해요",
+ },
+] as const;
diff --git a/apps/web/constants/tokens.ts b/apps/web/constants/tokens.ts
new file mode 100644
index 0000000..89f6eaf
--- /dev/null
+++ b/apps/web/constants/tokens.ts
@@ -0,0 +1,44 @@
+export const colors = {
+ primary: "#2e71ff",
+ background: "#0c0e0f",
+ backgroundDark: "#1d212d",
+ textPrimary: "#202325",
+ textSecondary: "#525252",
+ textInverse: "#ffffff",
+ slate200: "#e2e8f0",
+ slate300: "#cad5e2",
+ slate500: "#90a1b9",
+ mainLight: "#e7f3fe",
+ border: "#c9c9c9",
+ categoryBg: "#e9e9e9",
+} as const;
+
+export const fontWeights = {
+ regular: 400,
+ medium: 500,
+ semiBold: 600,
+ bold: 700,
+} as const;
+
+export const fontSizes = {
+ small: "14px",
+ medium: "16px",
+ large: "20px",
+ headingMedium: "16px",
+ headingLarge: "28px",
+ headingXl: "40px",
+ paragraphXxxl: "64px",
+ paragraphXxxxl: "80px",
+ headingXxl: "130px",
+} as const;
+
+export const lineHeights = {
+ small: "18px",
+ paragraphMedium: "20px",
+ headingMedium: "20px",
+ paragraphLarge: "25px",
+ headingLarge: "32px",
+ headingXl: "50px",
+ paragraphXxxl: "75px",
+ headingXxl: "130px",
+} as const;
diff --git a/apps/web/lib/api/application.ts b/apps/web/lib/api/application.ts
new file mode 100644
index 0000000..7285645
--- /dev/null
+++ b/apps/web/lib/api/application.ts
@@ -0,0 +1,44 @@
+import { applicationAPI, type PostSubmitApplicationRequest } from "@ddd/api";
+import { ensureApiConfigured } from "./config";
+
+/**
+ * 임시저장 응답 안의 answers 를 꺼낸다.
+ *
+ * BE OpenAPI 가 GET /applications/draft/{cohortPartId} 의 응답 schema 를
+ * 정의하지 않아 generated 타입이 `void` 다. 실제 응답에는 `answers` 필드가 있어
+ * 여기서 unknown 으로 받아 좁힌다.
+ */
+export async function fetchApplicationDraftAnswers(
+ cohortPartId: number,
+): Promise | null> {
+ ensureApiConfigured();
+ try {
+ const data = (await applicationAPI.getApplicationDraft({
+ params: { cohortPartId },
+ })) as unknown as { answers?: unknown } | null;
+ const answers = data?.answers;
+ if (answers && typeof answers === "object") {
+ return answers as Record;
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+export async function saveRecruitApplicationDraft(
+ cohortPartId: number,
+ answers: Record,
+): Promise {
+ ensureApiConfigured();
+ await applicationAPI.saveApplicationDraft({
+ payload: { cohortPartId, answers },
+ });
+}
+
+export async function submitRecruitApplication(
+ payload: PostSubmitApplicationRequest,
+): Promise {
+ ensureApiConfigured();
+ await applicationAPI.submitApplication({ payload });
+}
diff --git a/apps/web/lib/api/blog.ts b/apps/web/lib/api/blog.ts
new file mode 100644
index 0000000..0a8148d
--- /dev/null
+++ b/apps/web/lib/api/blog.ts
@@ -0,0 +1,36 @@
+import { blogAPI } from "@ddd/api";
+import type { ArticleItem } from "@/constants/articles";
+import { mapArticle } from "@/lib/mappers/article";
+import { ensureApiConfigured } from "./config";
+
+export type ArticleCursorPage = {
+ items: ArticleItem[];
+ nextCursor: string | null;
+};
+
+export async function fetchPublicArticles(): Promise {
+ ensureApiConfigured();
+ const response = await blogAPI.getBlogPosts({ params: { limit: 100 } });
+ return response.items
+ .map(mapArticle)
+ .filter((item): item is ArticleItem => Boolean(item));
+}
+
+export async function fetchPublicArticlesPage(options?: {
+ cursor?: string;
+ limit?: number;
+}): Promise {
+ ensureApiConfigured();
+ const response = await blogAPI.getBlogPosts({
+ params: {
+ cursor: options?.cursor,
+ limit: options?.limit ?? 4,
+ },
+ });
+ return {
+ items: response.items
+ .map(mapArticle)
+ .filter((item): item is ArticleItem => Boolean(item)),
+ nextCursor: response.nextCursor ?? null,
+ };
+}
diff --git a/apps/web/lib/api/cohort.ts b/apps/web/lib/api/cohort.ts
new file mode 100644
index 0000000..ec0614b
--- /dev/null
+++ b/apps/web/lib/api/cohort.ts
@@ -0,0 +1,50 @@
+import { cohortPublicAPI, type CohortPartConfigDto } from "@ddd/api";
+import type { RecruitStatus } from "@/constants/recruit";
+import {
+ buildApplyPartIdMap,
+ getActiveCohortId,
+ getActiveCohortPartId,
+ parseRecruitStatus,
+} from "@/lib/mappers/cohort";
+import { ensureApiConfigured } from "./config";
+
+export const APPLY_PART_OPTIONS = ["iOS", "AOS", "FE", "BE", "PM", "PD"] as const;
+export type ApplyPartOption = (typeof APPLY_PART_OPTIONS)[number];
+
+export async function fetchRecruitStatus(): Promise {
+ try {
+ ensureApiConfigured();
+ const response = await cohortPublicAPI.getActiveCohort();
+ return parseRecruitStatus(response);
+ } catch {
+ return "closed";
+ }
+}
+
+export async function fetchActiveCohortId(): Promise {
+ try {
+ ensureApiConfigured();
+ const response = await cohortPublicAPI.getActiveCohort();
+ return getActiveCohortId(response);
+ } catch {
+ return null;
+ }
+}
+
+export async function fetchCohortPartByActiveCohortId(): Promise {
+ try {
+ ensureApiConfigured();
+ const activeCohort = await cohortPublicAPI.getActiveCohort();
+ const partId = getActiveCohortPartId(activeCohort);
+ if (!partId) return null;
+ return await cohortPublicAPI.getCohortPart({ params: { id: partId } });
+ } catch {
+ return null;
+ }
+}
+
+export async function fetchApplyPartIdMap(): Promise>> {
+ ensureApiConfigured();
+ const active = await cohortPublicAPI.getActiveCohort();
+ return buildApplyPartIdMap(active);
+}
diff --git a/apps/web/lib/api/config.ts b/apps/web/lib/api/config.ts
new file mode 100644
index 0000000..504d0b1
--- /dev/null
+++ b/apps/web/lib/api/config.ts
@@ -0,0 +1,17 @@
+import { configureApi } from "@ddd/api";
+
+/**
+ * @ddd/api 의 ApiClient 를 매 호출 시점에 1회 configure 한다.
+ *
+ * Next.js Server Component / Client Component 양쪽에서 모듈이 캐시되므로
+ * 사실상 idempotent 하다. 부트스트랩 1회 호출로 치환하는 작업은 별도 PR.
+ */
+export function ensureApiConfigured(): void {
+ const baseUrl =
+ process.env.NEXT_PUBLIC_API_URL ??
+ (typeof window !== "undefined" ? window.location.origin : undefined);
+ if (!baseUrl) {
+ throw new Error("NEXT_PUBLIC_API_URL is not set.");
+ }
+ configureApi(baseUrl);
+}
diff --git a/apps/web/lib/api/early-notification.ts b/apps/web/lib/api/early-notification.ts
new file mode 100644
index 0000000..7829ea5
--- /dev/null
+++ b/apps/web/lib/api/early-notification.ts
@@ -0,0 +1,34 @@
+import { earlyNotificationAPI } from "@ddd/api";
+import { ensureApiConfigured } from "./config";
+import { fetchActiveCohortId } from "./cohort";
+
+export async function subscribeEarlyNotification(
+ email: string,
+ cohortId: number,
+): Promise {
+ ensureApiConfigured();
+ await earlyNotificationAPI.subscribeEarlyNotification({
+ payload: { email, cohortId },
+ });
+}
+
+export async function subscribeGeneralEarlyNotification(email: string): Promise {
+ ensureApiConfigured();
+ await earlyNotificationAPI.subscribeGeneralEarlyNotification({
+ payload: { email },
+ });
+}
+
+/**
+ * 활성 기수가 있으면 기수별 사전 알림, 없으면 대기열(general) 사전 알림으로 폴백한다.
+ */
+export async function subscribeEarlyNotificationWithActiveCohort(
+ email: string,
+): Promise {
+ const activeCohortId = await fetchActiveCohortId();
+ if (activeCohortId) {
+ await subscribeEarlyNotification(email, activeCohortId);
+ return;
+ }
+ await subscribeGeneralEarlyNotification(email);
+}
diff --git a/apps/web/lib/api/project.ts b/apps/web/lib/api/project.ts
new file mode 100644
index 0000000..23031e2
--- /dev/null
+++ b/apps/web/lib/api/project.ts
@@ -0,0 +1,40 @@
+import { projectAPI, type ProjectPlatform } from "@ddd/api";
+import type { ProjectItem } from "@/constants/projects";
+import { mapProject } from "@/lib/mappers/project";
+import { ensureApiConfigured } from "./config";
+
+export type ProjectCursorPage = {
+ items: ProjectItem[];
+ nextCursor: string | null;
+};
+
+export async function fetchPublicProjects(): Promise {
+ ensureApiConfigured();
+ const response = await projectAPI.getProjects({ params: { limit: 100 } });
+ return response.items.map(mapProject);
+}
+
+export async function fetchPublicProjectsPage(options?: {
+ cursor?: string;
+ limit?: number;
+ platform?: ProjectPlatform;
+}): Promise {
+ ensureApiConfigured();
+ const response = await projectAPI.getProjects({
+ params: {
+ cursor: options?.cursor,
+ limit: options?.limit ?? 9,
+ platform: options?.platform,
+ },
+ });
+ return {
+ items: response.items.map(mapProject),
+ nextCursor: response.nextCursor ?? null,
+ };
+}
+
+export async function fetchPublicProjectById(id: string): Promise {
+ ensureApiConfigured();
+ const response = await projectAPI.getProject({ params: { id: Number(id) } });
+ return mapProject(response);
+}
diff --git a/apps/web/lib/format.ts b/apps/web/lib/format.ts
new file mode 100644
index 0000000..5a674d1
--- /dev/null
+++ b/apps/web/lib/format.ts
@@ -0,0 +1,18 @@
+export function birthInputToApiDate(birth: string): string | undefined {
+ const match = birth.trim().match(/^(\d{4})[/.-](\d{2})[/.-](\d{2})$/);
+ if (!match) return undefined;
+ return `${match[1]}-${match[2]}-${match[3]}`;
+}
+
+/** OpenAPI `applicantPhone` 패턴(01x-…-xxxx)에 맞게 하이픈을 넣는다. */
+export function formatApplicantPhoneKorea(phone: string): string {
+ const trimmed = phone.trim();
+ const digits = trimmed.replace(/\D/g, "");
+ if (digits.length === 11 && /^01[0-9]/.test(digits)) {
+ return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
+ }
+ if (digits.length === 10 && /^01[0-9]/.test(digits)) {
+ return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6, 10)}`;
+ }
+ return trimmed;
+}
diff --git a/apps/web/lib/mappers/article.ts b/apps/web/lib/mappers/article.ts
new file mode 100644
index 0000000..caf5df2
--- /dev/null
+++ b/apps/web/lib/mappers/article.ts
@@ -0,0 +1,26 @@
+import type { BlogPostDto } from "@ddd/api";
+import type { ArticleItem } from "@/constants/articles";
+import { toStringValue } from "./shared";
+
+export function mapArticle(item: BlogPostDto): ArticleItem | null {
+ const description = toStringValue(
+ item.excerpt,
+ toStringValue((item as BlogPostDto & { description?: unknown }).description),
+ );
+ if (!description) return null;
+
+ const thumbnail = toStringValue(
+ item.thumbnail,
+ toStringValue(
+ (item as BlogPostDto & { thumbnailUrl?: unknown }).thumbnailUrl,
+ toStringValue((item as BlogPostDto & { imageUrl?: unknown }).imageUrl),
+ ),
+ );
+
+ return {
+ id: String(item.id),
+ title: item.title,
+ description,
+ thumbnail,
+ };
+}
diff --git a/apps/web/lib/mappers/cohort.ts b/apps/web/lib/mappers/cohort.ts
new file mode 100644
index 0000000..1fba345
--- /dev/null
+++ b/apps/web/lib/mappers/cohort.ts
@@ -0,0 +1,45 @@
+import type { CohortDto, CohortPartConfigDto, CohortPartName } from "@ddd/api";
+import type { RecruitStatus } from "@/constants/recruit";
+import type { ApplyPartOption } from "@/lib/api/cohort";
+
+export function parseRecruitStatus(cohort: CohortDto | null | undefined): RecruitStatus {
+ if (!cohort) return "closed";
+ return cohort.status === "RECRUITING" ? "open" : "closed";
+}
+
+export function getActiveCohortId(cohort: CohortDto | null | undefined): number | null {
+ return cohort?.id ?? null;
+}
+
+export function getActiveCohortPartId(cohort: CohortDto | null | undefined): number | null {
+ const parts = cohort?.parts ?? [];
+ const opened = parts.find((p) => p.isOpen && typeof p.id === "number");
+ if (opened?.id != null) return opened.id;
+ const first = parts.find((p) => typeof p.id === "number");
+ return first?.id ?? null;
+}
+
+/** BE partName enum → 웹에서 노출하는 ApplyPartOption */
+const PART_NAME_TO_APPLY: Record = {
+ IOS: "iOS",
+ AND: "AOS",
+ FE: "FE",
+ BE: "BE",
+ PM: "PM",
+ PD: "PD",
+};
+
+export function buildApplyPartIdMap(
+ cohort: CohortDto | null | undefined,
+): Partial> {
+ const map: Partial> = {};
+ const parts: CohortPartConfigDto[] = cohort?.parts ?? [];
+ for (const part of parts) {
+ if (!part.isOpen) continue;
+ if (typeof part.id !== "number") continue;
+ const key = PART_NAME_TO_APPLY[part.partName];
+ if (!key) continue;
+ map[key] = part.id;
+ }
+ return map;
+}
diff --git a/apps/web/lib/mappers/project.ts b/apps/web/lib/mappers/project.ts
new file mode 100644
index 0000000..d8cf256
--- /dev/null
+++ b/apps/web/lib/mappers/project.ts
@@ -0,0 +1,36 @@
+import type { ProjectDto, ProjectPlatform } from "@ddd/api";
+import type { ProjectItem } from "@/constants/projects";
+import { toStringValue } from "./shared";
+
+function toProjectCategory(platforms: ProjectPlatform[] | undefined): ProjectItem["category"] {
+ if (!platforms || platforms.length === 0) return "WEB";
+ const raw = String(platforms[0]).toUpperCase();
+ if (raw === "IOS") return "iOS";
+ if (raw === "AOS") return "AOS";
+ return "WEB";
+}
+
+export function mapProject(item: ProjectDto): ProjectItem {
+ const participants = (item.members ?? [])
+ .map((member) => {
+ const name = toStringValue(member.name);
+ const role = toStringValue(member.part);
+ if (!name || !role) return null;
+ return { name, role };
+ })
+ .filter((member): member is { name: string; role: string } => Boolean(member));
+
+ return {
+ id: String(item.id),
+ title: item.name,
+ description: item.description,
+ category: toProjectCategory(item.platforms),
+ generation: toStringValue((item as ProjectDto & { cohortName?: unknown }).cohortName, "DDD"),
+ thumbnail: toStringValue(item.thumbnailUrl),
+ banner: toStringValue(item.thumbnailUrl),
+ pdf: toStringValue(item.pdfUrl),
+ detailTitle: item.name,
+ longDescription: item.description,
+ participants,
+ };
+}
diff --git a/apps/web/lib/mappers/shared.ts b/apps/web/lib/mappers/shared.ts
new file mode 100644
index 0000000..1d87c37
--- /dev/null
+++ b/apps/web/lib/mappers/shared.ts
@@ -0,0 +1,3 @@
+export function toStringValue(value: unknown, fallback = ""): string {
+ return typeof value === "string" ? value : fallback;
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index a3189e9..95b0cce 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -10,6 +10,7 @@
"lint:fix": "eslint . --fix"
},
"dependencies": {
+ "@ddd/api": "workspace:*",
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
diff --git a/apps/web/public/images/Brunch.png b/apps/web/public/images/Brunch.png
new file mode 100644
index 0000000..7e0d06b
Binary files /dev/null and b/apps/web/public/images/Brunch.png differ
diff --git a/apps/web/public/images/Instagram.png b/apps/web/public/images/Instagram.png
new file mode 100644
index 0000000..3b2c2ff
Binary files /dev/null and b/apps/web/public/images/Instagram.png differ
diff --git a/apps/web/public/images/Medium.png b/apps/web/public/images/Medium.png
new file mode 100644
index 0000000..9d725a3
Binary files /dev/null and b/apps/web/public/images/Medium.png differ
diff --git a/apps/web/public/images/Tistory.png b/apps/web/public/images/Tistory.png
new file mode 100644
index 0000000..309354e
Binary files /dev/null and b/apps/web/public/images/Tistory.png differ
diff --git a/apps/web/public/images/hero/main-hero-3d.png b/apps/web/public/images/hero/main-hero-3d.png
new file mode 100644
index 0000000..e99dafb
Binary files /dev/null and b/apps/web/public/images/hero/main-hero-3d.png differ
diff --git a/apps/web/public/images/imag_elice.png b/apps/web/public/images/imag_elice.png
new file mode 100644
index 0000000..4f91587
Binary files /dev/null and b/apps/web/public/images/imag_elice.png differ
diff --git a/apps/web/public/images/image_asan-nanum.png b/apps/web/public/images/image_asan-nanum.png
new file mode 100644
index 0000000..03175ee
Binary files /dev/null and b/apps/web/public/images/image_asan-nanum.png differ
diff --git a/apps/web/public/images/image_hanbit.png b/apps/web/public/images/image_hanbit.png
new file mode 100644
index 0000000..eb56e55
Binary files /dev/null and b/apps/web/public/images/image_hanbit.png differ
diff --git a/apps/web/public/images/image_ictcoc.png b/apps/web/public/images/image_ictcoc.png
new file mode 100644
index 0000000..acefa8b
Binary files /dev/null and b/apps/web/public/images/image_ictcoc.png differ
diff --git a/apps/web/public/images/logo.png b/apps/web/public/images/logo.png
new file mode 100644
index 0000000..8402fc6
Binary files /dev/null and b/apps/web/public/images/logo.png differ
diff --git a/apps/web/public/images/modal_image.png b/apps/web/public/images/modal_image.png
new file mode 100644
index 0000000..893cca1
Binary files /dev/null and b/apps/web/public/images/modal_image.png differ
diff --git a/apps/web/public/images/success.png b/apps/web/public/images/success.png
new file mode 100644
index 0000000..8a6de8b
Binary files /dev/null and b/apps/web/public/images/success.png differ
diff --git a/package.json b/package.json
index f0fdd3e..f12664c 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"install:admin": "pnpm --filter @ddd/admin install",
"install:web": "pnpm --filter @ddd/web install",
"dev:admin": "pnpm --filter @ddd/admin dev",
- "dev:web": "pnpm --filter @ddd/web dev",
+ "dev:web": "pnpm --filter @ddd/web dev --port 5173",
"build:admin": "pnpm --filter @ddd/admin build",
"build:web": "pnpm --filter @ddd/web build",
"gen:api": "pnpm --filter @ddd/api generate",
diff --git a/packages/api/package.json b/packages/api/package.json
index 86b0ec9..53b7176 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -7,7 +7,7 @@
".": "./src/index.ts"
},
"scripts": {
- "generate": "openapi-typescript http://localhost:3000/api-docs-json -o ./src/generated/api.ts",
+ "generate": "openapi-typescript http://localhost:3000/api/api-docs-json -o ./src/generated/api.ts",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
diff --git a/packages/api/src/application/api.ts b/packages/api/src/application/api.ts
index 6a4e2e6..dff20b3 100644
--- a/packages/api/src/application/api.ts
+++ b/packages/api/src/application/api.ts
@@ -44,7 +44,7 @@ export const applicationAPI = {
payload: PostSaveApplicationDraftRequest;
}) =>
api.post("/api/v1/applications/draft", {
- body: payload,
+ body: payload as never,
}) as unknown as Promise,
/** 임시저장 단건 조회 - GET /api/v1/applications/draft/{cohortPartId} */
@@ -59,5 +59,7 @@ export const applicationAPI = {
}: {
payload: PostSubmitApplicationRequest;
}) =>
- api.post("/api/v1/applications", { body: payload }) as unknown as Promise,
+ api.post("/api/v1/applications", {
+ body: payload as never,
+ }) as unknown as Promise,
};
diff --git a/packages/api/src/application/types.ts b/packages/api/src/application/types.ts
index 019dd32..9211025 100644
--- a/packages/api/src/application/types.ts
+++ b/packages/api/src/application/types.ts
@@ -1,12 +1,20 @@
import type { components, paths } from "../generated/api";
// Request DTO 재노출
+//
+// BE OpenAPI 가 `answers` 를 `Record` (빈 객체) 로 정의해두어
+// 실제 응답/요청 (자유 폼) 과 충돌한다. cohort 의 `process` / `curriculum` 패턴
+// 처럼 FE 에서 `Record` 으로 완화한다.
export type UpdateApplicationStatusRequestDto =
components["schemas"]["UpdateApplicationStatusRequestDto"];
-export type SaveApplicationDraftRequestDto =
- components["schemas"]["SaveApplicationDraftRequestDto"];
-export type SubmitApplicationRequestDto =
- components["schemas"]["SubmitApplicationRequestDto"];
+export type SaveApplicationDraftRequestDto = Omit<
+ components["schemas"]["SaveApplicationDraftRequestDto"],
+ "answers"
+> & { answers: Record };
+export type SubmitApplicationRequestDto = Omit<
+ components["schemas"]["SubmitApplicationRequestDto"],
+ "answers"
+> & { answers: Record };
// GET /api/v1/admin/applications - 어드민 지원서 목록 조회
export type ApplicationGetAdminListParams =
diff --git a/packages/api/src/cohort/api.ts b/packages/api/src/cohort/api.ts
index 50c379d..f80d7ce 100644
--- a/packages/api/src/cohort/api.ts
+++ b/packages/api/src/cohort/api.ts
@@ -13,6 +13,8 @@ import type {
PutUpdateCohortPartsRequest,
PutUpdateCohortPartsResponse,
GetActiveCohortResponse,
+ GetCohortPartParams,
+ GetCohortPartResponse,
} from "./types";
/** 어드민 기수 API */
@@ -71,4 +73,10 @@ export const cohortPublicAPI = {
/** 현재 활성 기수 조회 - GET /api/v1/cohorts/active */
getActiveCohort: () =>
api.get("/api/v1/cohorts/active") as unknown as Promise,
+
+ /** 모집 파트 상세 조회 - GET /api/v1/cohorts/parts/{id} */
+ getCohortPart: ({ params }: { params: GetCohortPartParams }) =>
+ api.get("/api/v1/cohorts/parts/{id}", {
+ params: { path: { id: params.id } },
+ }) as unknown as Promise,
};
diff --git a/packages/api/src/cohort/queries.ts b/packages/api/src/cohort/queries.ts
index 147fa2c..ecc2dc4 100644
--- a/packages/api/src/cohort/queries.ts
+++ b/packages/api/src/cohort/queries.ts
@@ -3,6 +3,7 @@ import { cohortAPI, cohortPublicAPI } from "./api";
import { cohortKeys } from "./queryKeys";
import type {
GetCohortParams,
+ GetCohortPartParams,
PatchUpdateCohortParams,
PatchUpdateCohortRequest,
DeleteCohortParams,
@@ -129,4 +130,22 @@ export const cohortPublicQueries = {
queryKey: cohortKeys.active(),
queryFn: () => cohortPublicAPI.getActiveCohort(),
}),
+
+ /**
+ * 모집 파트 상세 조회 쿼리 (GET /api/v1/cohorts/parts/{id})
+ *
+ * @param {GetCohortPartParams} params - 조회 파라미터
+ * @param {number} params.id - 파트 ID
+ *
+ * @returns {QueryOptions} TanStack Query 옵션 객체
+ *
+ * @example
+ * const partQuery = useQuery(cohortPublicQueries.getCohortPart({ params: { id: 1 } }))
+ */
+ getCohortPart: ({ params }: { params: GetCohortPartParams }) =>
+ queryOptions({
+ queryKey: cohortKeys.part(params),
+ queryFn: () => cohortPublicAPI.getCohortPart({ params }),
+ enabled: !!params.id,
+ }),
};
diff --git a/packages/api/src/cohort/queryKeys.ts b/packages/api/src/cohort/queryKeys.ts
index b2247e7..9253594 100644
--- a/packages/api/src/cohort/queryKeys.ts
+++ b/packages/api/src/cohort/queryKeys.ts
@@ -1,4 +1,4 @@
-import type { GetCohortParams } from "./types";
+import type { GetCohortParams, GetCohortPartParams } from "./types";
export const cohortKeys = {
/** 기수 base key */
@@ -18,4 +18,13 @@ export const cohortKeys = {
/** 현재 활성 기수 key (public) */
active: () => [...cohortKeys.all, "active"] as const,
+
+ /**
+ * 모집 파트 상세 key (public)
+ *
+ * @param {GetCohortPartParams} params - 조회 파라미터
+ * @param {number} params.id - 파트 ID
+ */
+ part: (params: GetCohortPartParams) =>
+ [...cohortKeys.all, "part", params] as const,
};
diff --git a/packages/api/src/cohort/types.ts b/packages/api/src/cohort/types.ts
index 1d81d03..0114c48 100644
--- a/packages/api/src/cohort/types.ts
+++ b/packages/api/src/cohort/types.ts
@@ -101,6 +101,10 @@ export type PutUpdateCohortPartsResponse = CohortDto;
// GET /api/v1/cohorts/active - 현재 활성 기수 조회 (public)
export type GetActiveCohortResponse = CohortDto;
+// GET /api/v1/cohorts/parts/{id} - 모집 파트 상세 조회 (public)
+export type GetCohortPartParams = { id: number };
+export type GetCohortPartResponse = CohortPartConfigDto;
+
// ---------- CohortDto 엔티티 (BE 응답 schema 미정의 → 수동 정의) ----------
export interface CohortDto {
id: number;
diff --git a/packages/api/src/early-notification/api.ts b/packages/api/src/early-notification/api.ts
index 5fa756d..effb88f 100644
--- a/packages/api/src/early-notification/api.ts
+++ b/packages/api/src/early-notification/api.ts
@@ -6,6 +6,7 @@ import type {
GetAdminEarlyNotificationsCsvResponse,
PostSendBulkEarlyNotificationRequest,
PostSubscribeEarlyNotificationRequest,
+ PostSubscribeGeneralEarlyNotificationRequest,
} from "./types";
export const earlyNotificationAPI = {
@@ -47,4 +48,14 @@ export const earlyNotificationAPI = {
payload: PostSubscribeEarlyNotificationRequest;
}) =>
api.post("/api/v1/early-notifications", { body: payload }) as unknown as Promise,
+
+ /** 대기열 사전 알림 구독 - POST /api/v1/early-notifications/general */
+ subscribeGeneralEarlyNotification: ({
+ payload,
+ }: {
+ payload: PostSubscribeGeneralEarlyNotificationRequest;
+ }) =>
+ api.post("/api/v1/early-notifications/general", {
+ body: payload,
+ }) as unknown as Promise,
};
diff --git a/packages/api/src/early-notification/queries.ts b/packages/api/src/early-notification/queries.ts
index 0df4917..e707099 100644
--- a/packages/api/src/early-notification/queries.ts
+++ b/packages/api/src/early-notification/queries.ts
@@ -6,6 +6,7 @@ import type {
GetAdminEarlyNotificationsCsvParams,
PostSendBulkEarlyNotificationRequest,
PostSubscribeEarlyNotificationRequest,
+ PostSubscribeGeneralEarlyNotificationRequest,
} from "./types";
export const earlyNotificationQueries = {
@@ -91,4 +92,22 @@ export const earlyNotificationMutations = {
payload: PostSubscribeEarlyNotificationRequest;
}) => earlyNotificationAPI.subscribeEarlyNotification({ payload }),
}),
+
+ /**
+ * 대기열 사전 알림 구독 mutation (cohortId 없이 다음 기수 알림 대기열 등록)
+ *
+ * @returns {MutationOptions} TanStack Query Mutation 옵션 객체
+ *
+ * @example
+ * const mutation = useMutation(earlyNotificationMutations.subscribeGeneralEarlyNotification())
+ * mutation.mutate({ payload: { email: 'user@example.com' } })
+ */
+ subscribeGeneralEarlyNotification: () =>
+ mutationOptions({
+ mutationFn: ({
+ payload,
+ }: {
+ payload: PostSubscribeGeneralEarlyNotificationRequest;
+ }) => earlyNotificationAPI.subscribeGeneralEarlyNotification({ payload }),
+ }),
};
diff --git a/packages/api/src/early-notification/types.ts b/packages/api/src/early-notification/types.ts
index e1d9383..c287be9 100644
--- a/packages/api/src/early-notification/types.ts
+++ b/packages/api/src/early-notification/types.ts
@@ -20,6 +20,11 @@ export type PostSubscribeEarlyNotificationRequest =
components["schemas"]["SubscribeEarlyNotificationRequestDto"];
export type PostSubscribeEarlyNotificationResponse = void;
+// POST /api/v1/early-notifications/general - 대기열 사전 알림 구독 (cohortId 없음)
+export type PostSubscribeGeneralEarlyNotificationRequest =
+ components["schemas"]["SubscribeGeneralEarlyNotificationRequestDto"];
+export type PostSubscribeGeneralEarlyNotificationResponse = void;
+
// 엔티티 타입 (BE OpenAPI 가 응답 schema 미정의 → 수동 정의)
export interface EarlyNotificationDto {
id: number;
diff --git a/packages/api/src/fetchClient.ts b/packages/api/src/fetchClient.ts
index 08e4e09..cc863e0 100644
--- a/packages/api/src/fetchClient.ts
+++ b/packages/api/src/fetchClient.ts
@@ -23,6 +23,16 @@ interface BeWrapper {
code?: string;
message?: string;
data?: unknown;
+ meta?: unknown;
+}
+
+interface CursorMeta {
+ nextCursor?: unknown;
+ hasNext?: unknown;
+}
+
+function isCursorMeta(value: unknown): value is CursorMeta {
+ return typeof value === "object" && value !== null;
}
export type UnwrapData = T extends { data?: infer U }
@@ -37,6 +47,21 @@ function isBeWrapper(value: unknown): value is BeWrapper {
);
}
+/**
+ * BE 응답 envelope `{ data: [...], meta: { nextCursor, hasNext } }` 를
+ * SDK 의 페이지네이션 DTO 형태 `{ items, nextCursor?, hasMore }` 로 정규화한다.
+ * meta 가 없으면 `data` 그대로 반환 (단순 배열 응답 호환).
+ */
+function normalizeCursorListPayload(payload: unknown[], meta: unknown): unknown {
+ if (!isCursorMeta(meta)) return payload;
+ const nextCursor = typeof meta.nextCursor === "string" ? meta.nextCursor : undefined;
+ return {
+ items: payload,
+ nextCursor,
+ hasMore: meta.hasNext === true,
+ };
+}
+
function toErrorCode(code: string | undefined): ErrorMessageKey {
return (code ?? "UNKNOWN_ERROR") as ErrorMessageKey;
}
@@ -163,7 +188,12 @@ class ApiClient {
if (data.code && data.code !== "SUCCESS") {
throw new ApiError(toErrorCode(data.code), data.message ?? "Request failed");
}
- return (data.data ?? null) as R;
+
+ const payload = data.data ?? null;
+ if (Array.isArray(payload) && data.meta !== undefined) {
+ return normalizeCursorListPayload(payload, data.meta) as R;
+ }
+ return payload as R;
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2ad059c..79d31de 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -198,6 +198,9 @@ importers:
apps/web:
dependencies:
+ '@ddd/api':
+ specifier: workspace:*
+ version: link:../../packages/api
'@emotion/cache':
specifier: ^11.14.0
version: 11.14.0
@@ -5297,7 +5300,7 @@ snapshots:
'@next/eslint-plugin-next': 16.1.6
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
@@ -5324,7 +5327,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3(supports-color@10.2.2)
@@ -5339,14 +5342,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
@@ -5361,7 +5364,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
diff --git a/progress.md b/progress.md
index 9600fb3..8a331c4 100644
--- a/progress.md
+++ b/progress.md
@@ -257,7 +257,9 @@ HTML 목업 대비 현재 코드의 **하드코딩 / API 미연동 / 미구현
| ~~**auth**~~ | ✅ 241ae4e 이후 `api` 싱글톤 사용 — 다른 도메인과 패턴 일치 | 갭 해소 |
| ~~**notification-campaign**~~ | ✅ SDK + 어드민 UI(`NotificationCampaignSection` / 편집 Drawer / pause·resume 토글) 연결 완료 | 갭 해소 |
| ~~**interview**~~ | ✅ `cancelInterviewReservation` mutation + 어드민 UI(`ReservationsDrawer` / `CancelReservationDialog`) 연결 완료 | 갭 해소 |
-| **early-notification** | `subscribeGeneral` query 미구현 | 웹앱 대기열 신청 미지원 |
+| ~~**early-notification**~~ | ✅ `subscribeGeneralEarlyNotification` (POST /api/v1/early-notifications/general) SDK·queries 추가, `apps/web/lib/api/early-notification.ts` 에서 활성 기수 없을 때 자동 폴백 | 갭 해소 |
+| ~~**cohort (public)**~~ | ✅ `getCohortPart` (GET /api/v1/cohorts/parts/{id}) SDK·queries 추가 | 갭 해소 |
+| ~~**legacy `webApi` 단일 객체**~~ | ✅ `packages/api/src/web.ts` 폐기. `apps/web/lib/api/{project,blog,cohort,application,early-notification}.ts` 도메인별 파일 + `lib/mappers/*` 매퍼 분리로 정리 | 갭 해소 |
### HTML 목업에는 있는데 미구현인 UI