diff --git a/src/app/(public)/apply/page.tsx b/src/app/(public)/apply/page.tsx
new file mode 100644
index 0000000..47c69d1
--- /dev/null
+++ b/src/app/(public)/apply/page.tsx
@@ -0,0 +1,18 @@
+import type { Metadata } from "next";
+import { SubmitApplicationForm } from "@/features/submit-application";
+import { CenteredCard } from "@/shared/ui/centered-card";
+
+export const metadata: Metadata = { title: "가입 신청" };
+
+export default function ApplyPage() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/entities/application/index.ts b/src/entities/application/index.ts
new file mode 100644
index 0000000..554bafd
--- /dev/null
+++ b/src/entities/application/index.ts
@@ -0,0 +1,20 @@
+export {
+ checkStudentIdAvailableAction,
+ submitApplicationAction,
+} from "./model/actions";
+export type {
+ ApplicationInput,
+ ApplicationRole,
+ ApplicationType,
+ TeamMemberInput,
+} from "./model/application";
+export {
+ COLLEGE_DEPARTMENTS,
+ type College,
+ departmentsFor,
+} from "./model/college-department";
+export {
+ type ApplicationFormInput,
+ applicationSchema,
+ ROLE_OPTIONS,
+} from "./model/schema";
diff --git a/src/entities/application/model/actions.ts b/src/entities/application/model/actions.ts
new file mode 100644
index 0000000..169351c
--- /dev/null
+++ b/src/entities/application/model/actions.ts
@@ -0,0 +1,59 @@
+"use server";
+
+import type { ActionResult } from "@/entities/session";
+import {
+ type ApplicationInput,
+ createApplication,
+ findApplicationByStudentId,
+} from "./application";
+import { applicationSchema } from "./schema";
+
+const STUDENT_ID_REGEX = /^\d{8}$/;
+
+// Non-blocking early warning (called on studentId blur) — the authoritative
+// check is the unique constraint hit inside submitApplicationAction, so a
+// failure here should never stop the applicant from continuing to fill out
+// the form.
+//
+// This is also a public existence-check ("has this student ID already
+// applied?"), so it doubles as an enumeration oracle — rejecting anything
+// that isn't a well-formed student ID at least keeps it from being a free
+// probe for arbitrary input, though it doesn't fully close off enumerating
+// real 8-digit IDs. There's no rate limiting here (none exists anywhere
+// else in this app's login actions either); revisit if abuse shows up.
+export async function checkStudentIdAvailableAction(
+ studentId: string,
+): Promise {
+ if (!STUDENT_ID_REGEX.test(studentId)) return true;
+ try {
+ return !(await findApplicationByStudentId(studentId));
+ } catch (error) {
+ console.error(error);
+ return true;
+ }
+}
+
+// A Server Action is a callable HTTP endpoint, not just a function the form
+// happens to call — anything reaching this point over the network may not
+// have gone through the client's zodResolver at all, so the same schema is
+// re-applied here before touching the database.
+export async function submitApplicationAction(
+ input: ApplicationInput,
+): Promise {
+ const parsed = applicationSchema.safeParse(input);
+ if (!parsed.success) {
+ return { ok: false, message: "입력값을 다시 확인해주세요" };
+ }
+
+ try {
+ await createApplication(parsed.data);
+ return { ok: true };
+ } catch (error) {
+ console.error(error);
+ const message =
+ error instanceof Error && error.message.includes("duplicate key")
+ ? "이미 지원 내역이 있어요"
+ : "제출에 실패했어요. 잠시 후 다시 시도해주세요";
+ return { ok: false, message };
+ }
+}
diff --git a/src/entities/application/model/application.ts b/src/entities/application/model/application.ts
new file mode 100644
index 0000000..99eba59
--- /dev/null
+++ b/src/entities/application/model/application.ts
@@ -0,0 +1,75 @@
+import { createAdminClient } from "@/shared/lib/supabase/admin";
+import { throwIfError } from "@/shared/lib/supabase/query";
+
+export type ApplicationRole = "pm" | "design" | "developer";
+export type ApplicationType = "individual" | "team";
+
+export interface TeamMemberInput {
+ name: string;
+ college: string;
+ department: string;
+}
+
+export interface ApplicationInput {
+ name: string;
+ studentId: string;
+ college: string;
+ department: string;
+ phone: string;
+ birthDate: string;
+ role: ApplicationRole;
+ applicationType: ApplicationType;
+ teamMembers: TeamMemberInput[];
+}
+
+// applications has no public SELECT policy (contains PII) — reads go
+// through the service-role client, same as participants.
+export async function findApplicationByStudentId(
+ studentId: string,
+): Promise {
+ const supabase = createAdminClient();
+ const { data, error } = await supabase
+ .from("applications")
+ .select("id")
+ .eq("student_id", studentId)
+ .maybeSingle();
+ throwIfError(error);
+ return !!data;
+}
+
+export async function createApplication(
+ input: ApplicationInput,
+): Promise {
+ const supabase = createAdminClient();
+ const { data, error } = await supabase
+ .from("applications")
+ .insert({
+ name: input.name,
+ student_id: input.studentId,
+ college: input.college,
+ department: input.department,
+ phone: input.phone,
+ birth_date: input.birthDate,
+ role: input.role,
+ application_type: input.applicationType,
+ })
+ .select("id")
+ .single();
+ throwIfError(error);
+ if (!data) throw new Error("지원서 생성에 실패했어요");
+
+ if (input.applicationType === "team" && input.teamMembers.length > 0) {
+ const { error: membersError } = await supabase
+ .from("application_team_members")
+ .insert(
+ input.teamMembers.map((member, index) => ({
+ application_id: data.id,
+ position: index,
+ name: member.name,
+ college: member.college,
+ department: member.department,
+ })),
+ );
+ throwIfError(membersError);
+ }
+}
diff --git a/src/entities/application/model/college-department.ts b/src/entities/application/model/college-department.ts
new file mode 100644
index 0000000..fc3abd9
--- /dev/null
+++ b/src/entities/application/model/college-department.ts
@@ -0,0 +1,82 @@
+// Best-effort snapshot of 숭실대학교's 단과대/학과 structure, compiled from
+// public sources (not scraped from an official, always-current listing) —
+// verify against https://iphak.ssu.ac.kr before relying on this for a real
+// application cycle, and update this single array if departments changed.
+export const COLLEGE_DEPARTMENTS = [
+ {
+ college: "IT대학",
+ departments: [
+ "글로벌미디어학부",
+ "컴퓨터학부",
+ "전자정보공학부",
+ "디지털미디어학과",
+ ],
+ },
+ { college: "AI대학", departments: ["AI소프트웨어학부", "정보보호학과"] },
+ {
+ college: "인문대학",
+ departments: [
+ "기독교학과",
+ "국어국문학과",
+ "영어영문학과",
+ "독어독문학과",
+ "불어불문학과",
+ "중어중문학과",
+ "일어일문학과",
+ "철학과",
+ "사학과",
+ "예술창작학부",
+ "스포츠학부",
+ ],
+ },
+ {
+ college: "자연과학대학",
+ departments: [
+ "수학과",
+ "물리학과",
+ "화학과",
+ "정보통계·보험수리학과",
+ "의생명시스템학부",
+ ],
+ },
+ { college: "법과대학", departments: ["법학과", "국제법무학과"] },
+ {
+ college: "사회과학대학",
+ departments: [
+ "사회복지학부",
+ "행정학부",
+ "정치외교학과",
+ "정보사회학과",
+ "언론홍보학과",
+ "평생교육학과",
+ ],
+ },
+ {
+ college: "경제통상대학",
+ departments: ["경제학과", "글로벌통상학과", "금융경제학과", "국제무역학과"],
+ },
+ {
+ college: "경영대학",
+ departments: ["경영학부", "벤처중소기업학과", "회계학과", "금융학부"],
+ },
+ {
+ college: "공과대학",
+ departments: [
+ "화학공학과",
+ "신소재공학과",
+ "전기공학부",
+ "기계공학부",
+ "산업정보시스템공학과",
+ "건축학부",
+ ],
+ },
+ { college: "베어드학부대학", departments: ["자유전공학부"] },
+] as const;
+
+export type College = (typeof COLLEGE_DEPARTMENTS)[number]["college"];
+
+export function departmentsFor(college: string): readonly string[] {
+ return (
+ COLLEGE_DEPARTMENTS.find((c) => c.college === college)?.departments ?? []
+ );
+}
diff --git a/src/entities/application/model/schema.ts b/src/entities/application/model/schema.ts
new file mode 100644
index 0000000..f4701bc
--- /dev/null
+++ b/src/entities/application/model/schema.ts
@@ -0,0 +1,86 @@
+import { z } from "zod";
+
+export const ROLE_OPTIONS = [
+ { value: "pm", label: "PM" },
+ { value: "design", label: "Design" },
+ { value: "developer", label: "Developer" },
+] as const;
+
+const PHONE_REGEX = /^\d{2,3}-\d{3,4}-\d{4}$/;
+const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
+
+function isRealDate(value: string): boolean {
+ if (!DATE_REGEX.test(value)) return false;
+ const [year, month, day] = value.split("-").map(Number);
+ const date = new Date(year, month - 1, day);
+ return (
+ date.getFullYear() === year &&
+ date.getMonth() === month - 1 &&
+ date.getDate() === day
+ );
+}
+
+// No .min(1) here on purpose — item validity is only enforced in
+// superRefine below, gated on applicationType === "team". A plain
+// z.array(schema-with-min) would validate leftover rows even after
+// switching back to "individual" (the section holding the error is
+// unmounted, so submit would silently fail with no visible feedback).
+const teamMemberSchema = z.object({
+ name: z.string().trim(),
+ college: z.string(),
+ department: z.string(),
+});
+
+// Shared between the client form (react-hook-form resolver) and the server
+// action (re-validated there since a Server Action is a callable endpoint —
+// nothing stops a request from bypassing the form entirely).
+export const applicationSchema = z
+ .object({
+ name: z.string().trim().min(1, "이름을 입력해주세요"),
+ studentId: z.string().regex(/^\d{8}$/, "학번 8자리를 입력해주세요"),
+ college: z.string().min(1, "단과대를 선택해주세요"),
+ department: z.string().min(1, "학과를 선택해주세요"),
+ phone: z.string().regex(PHONE_REGEX, "전화번호를 정확히 입력해주세요"),
+ birthDate: z.string().refine(isRealDate, "생년월일을 정확히 입력해주세요"),
+ role: z.enum(["pm", "design", "developer"], {
+ message: "역할을 선택해주세요",
+ }),
+ applicationType: z.enum(["individual", "team"]),
+ teamMembers: z.array(teamMemberSchema).max(4),
+ })
+ .superRefine((data, ctx) => {
+ if (data.applicationType !== "team") return;
+ if (data.teamMembers.length < 1) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["teamMembers"],
+ message: "팀원을 1명 이상 추가해주세요 (본인 포함 2~5명)",
+ });
+ return;
+ }
+ data.teamMembers.forEach((member, index) => {
+ if (!member.name) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["teamMembers", index, "name"],
+ message: "이름을 입력해주세요",
+ });
+ }
+ if (!member.college) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["teamMembers", index, "college"],
+ message: "단과대를 선택해주세요",
+ });
+ }
+ if (!member.department) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["teamMembers", index, "department"],
+ message: "학과를 선택해주세요",
+ });
+ }
+ });
+ });
+
+export type ApplicationFormInput = z.infer;
diff --git a/src/features/submit-application/index.ts b/src/features/submit-application/index.ts
new file mode 100644
index 0000000..83ea27a
--- /dev/null
+++ b/src/features/submit-application/index.ts
@@ -0,0 +1 @@
+export { SubmitApplicationForm } from "./ui/submit-application-form";
diff --git a/src/features/submit-application/model/schema.ts b/src/features/submit-application/model/schema.ts
new file mode 100644
index 0000000..52d3eda
--- /dev/null
+++ b/src/features/submit-application/model/schema.ts
@@ -0,0 +1,8 @@
+// Re-exported from the entity layer so the server action can validate
+// against the exact same schema the client form uses — see
+// src/entities/application/model/schema.ts for the source of truth.
+export {
+ type ApplicationFormInput,
+ applicationSchema,
+ ROLE_OPTIONS,
+} from "@/entities/application";
diff --git a/src/features/submit-application/ui/apply-form-draft.ts b/src/features/submit-application/ui/apply-form-draft.ts
new file mode 100644
index 0000000..f3566ec
--- /dev/null
+++ b/src/features/submit-application/ui/apply-form-draft.ts
@@ -0,0 +1,45 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import type { UseFormWatch } from "react-hook-form";
+import type { ApplicationFormInput } from "../model/schema";
+
+const DRAFT_KEY = "submit-application-draft";
+
+export function loadApplicationDraft(): Partial | null {
+ if (typeof window === "undefined") return null;
+ try {
+ const raw = window.localStorage.getItem(DRAFT_KEY);
+ return raw ? JSON.parse(raw) : null;
+ } catch {
+ return null;
+ }
+}
+
+export function clearApplicationDraft(): void {
+ window.localStorage.removeItem(DRAFT_KEY);
+}
+
+// Losing a half-filled form to an accidental refresh/back-navigation is one
+// of the biggest blockers for a form this long, so every change gets
+// persisted to localStorage without the user doing anything.
+export function useApplicationDraftAutosave(
+ watch: UseFormWatch,
+): void {
+ const timeoutRef = useRef | undefined>(
+ undefined,
+ );
+
+ useEffect(() => {
+ const subscription = watch((value) => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => {
+ window.localStorage.setItem(DRAFT_KEY, JSON.stringify(value));
+ }, 500);
+ });
+ return () => {
+ subscription.unsubscribe();
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ };
+ }, [watch]);
+}
diff --git a/src/features/submit-application/ui/college-department-select.tsx b/src/features/submit-application/ui/college-department-select.tsx
new file mode 100644
index 0000000..f899f03
--- /dev/null
+++ b/src/features/submit-application/ui/college-department-select.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import {
+ type Control,
+ Controller,
+ type UseFormSetValue,
+ useWatch,
+} from "react-hook-form";
+import {
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectRoot,
+ SelectTrigger,
+} from "seed-design/ui/select";
+import { COLLEGE_DEPARTMENTS, departmentsFor } from "@/entities/application";
+import type { ApplicationFormInput } from "../model/schema";
+
+type CollegePath = "college" | `teamMembers.${number}.college`;
+type DepartmentPath = "department" | `teamMembers.${number}.department`;
+
+// Reused for both the applicant's own 단과대/학과 and each team member row —
+// the department list depends on the selected college, so picking a college
+// resets whatever department was previously selected.
+export function CollegeDepartmentFields({
+ control,
+ setValue,
+ collegeName,
+ departmentName,
+ collegeErrorMessage,
+ departmentErrorMessage,
+}: {
+ control: Control;
+ setValue: UseFormSetValue;
+ collegeName: CollegePath;
+ departmentName: DepartmentPath;
+ collegeErrorMessage?: string;
+ departmentErrorMessage?: string;
+}) {
+ const college = useWatch({ control, name: collegeName }) || "";
+
+ return (
+ <>
+ (
+ {
+ field.onChange(values[0] ?? "");
+ // Not shouldValidate: true — that would immediately flash a
+ // "select a department" error the instant the college
+ // changes, before the applicant has had a chance to open the
+ // now-enabled department dropdown.
+ setValue(departmentName, "");
+ }}
+ >
+
+
+
+ {COLLEGE_DEPARTMENTS.map((c) => (
+
+ ))}
+
+
+
+ )}
+ />
+ (
+ field.onChange(values[0] ?? "")}
+ >
+
+
+
+ {departmentsFor(college).map((d) => (
+
+ ))}
+
+
+
+ )}
+ />
+ >
+ );
+}
diff --git a/src/features/submit-application/ui/formatted-fields.tsx b/src/features/submit-application/ui/formatted-fields.tsx
new file mode 100644
index 0000000..63b9ec0
--- /dev/null
+++ b/src/features/submit-application/ui/formatted-fields.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import { IconExclamationmarkCircleFill } from "@karrotmarket/react-monochrome-icon";
+import { VStack } from "@seed-design/react";
+import { useState } from "react";
+import { type Control, Controller } from "react-hook-form";
+import { Callout } from "seed-design/ui/callout";
+import { TextField, TextFieldInput } from "seed-design/ui/text-field";
+import { checkStudentIdAvailableAction } from "@/entities/application";
+import type { ApplicationFormInput } from "../model/schema";
+
+// "Custom Input" per SEED Design's TextField docs: TextFieldInput takes a
+// plain controlled value/onChange, so formatting is just intercepting
+// onChange before it reaches react-hook-form — no masking library needed.
+function formatPhone(raw: string): string {
+ const digits = raw.replace(/\D/g, "").slice(0, 11);
+ // Seoul's area code is 2 digits (02-XXXX-XXXX); every other area code and
+ // every mobile prefix is 3 digits — grouping everything as 3 would print
+ // "021-234-5678" for a Seoul landline instead of "02-1234-5678".
+ const areaLength = digits.startsWith("02") ? 2 : 3;
+ if (digits.length <= areaLength) return digits;
+
+ const area = digits.slice(0, areaLength);
+ const rest = digits.slice(areaLength);
+ if (rest.length <= 4) return `${area}-${rest}`;
+
+ const last = rest.slice(-4);
+ const middle = rest.slice(0, rest.length - 4);
+ return `${area}-${middle}-${last}`;
+}
+
+function formatDate(raw: string): string {
+ const digits = raw.replace(/\D/g, "").slice(0, 8);
+ if (digits.length < 5) return digits;
+ if (digits.length < 7) return `${digits.slice(0, 4)}-${digits.slice(4)}`;
+ return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6)}`;
+}
+
+export function PhoneField({
+ control,
+ name,
+ label,
+ description,
+}: {
+ control: Control;
+ name: "phone";
+ label: string;
+ description?: string;
+}) {
+ return (
+ (
+
+
+ field.onChange(formatPhone(event.target.value))
+ }
+ onBlur={field.onBlur}
+ />
+
+ )}
+ />
+ );
+}
+
+export function BirthDateField({
+ control,
+}: {
+ control: Control;
+}) {
+ return (
+ (
+
+ field.onChange(formatDate(event.target.value))}
+ onBlur={field.onBlur}
+ />
+
+ )}
+ />
+ );
+}
+
+export function StudentIdField({
+ control,
+}: {
+ control: Control;
+}) {
+ const [duplicate, setDuplicate] = useState(false);
+
+ return (
+ (
+
+
+ {
+ setDuplicate(false);
+ field.onChange(
+ event.target.value.replace(/\D/g, "").slice(0, 8),
+ );
+ }}
+ onBlur={async () => {
+ field.onBlur();
+ if (/^\d{8}$/.test(field.value)) {
+ const available = await checkStudentIdAvailableAction(
+ field.value,
+ );
+ setDuplicate(!available);
+ }
+ }}
+ />
+
+ {duplicate && (
+ }
+ description="이미 이 학번으로 지원 내역이 있어요. 다시 제출해도 반영되지 않아요."
+ />
+ )}
+
+ )}
+ />
+ );
+}
diff --git a/src/features/submit-application/ui/submit-application-form.tsx b/src/features/submit-application/ui/submit-application-form.tsx
new file mode 100644
index 0000000..387ab43
--- /dev/null
+++ b/src/features/submit-application/ui/submit-application-form.tsx
@@ -0,0 +1,204 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Field, Text, VStack } from "@seed-design/react";
+import { useEffect, useState } from "react";
+import { Controller, useForm } from "react-hook-form";
+import { ActionButton } from "seed-design/ui/action-button";
+import {
+ SegmentedControl,
+ SegmentedControlItem,
+} from "seed-design/ui/segmented-control";
+import { Snackbar, useSnackbarAdapter } from "seed-design/ui/snackbar";
+import { TextField, TextFieldInput } from "seed-design/ui/text-field";
+import { submitApplicationAction } from "@/entities/application";
+import {
+ type ApplicationFormInput,
+ applicationSchema,
+ ROLE_OPTIONS,
+} from "../model/schema";
+import {
+ clearApplicationDraft,
+ loadApplicationDraft,
+ useApplicationDraftAutosave,
+} from "./apply-form-draft";
+import { CollegeDepartmentFields } from "./college-department-select";
+import { BirthDateField, PhoneField, StudentIdField } from "./formatted-fields";
+import { SubmitApplicationResult } from "./submit-application-result";
+import { TeamMembersField } from "./team-members-field";
+
+const DEFAULT_VALUES: Partial = {
+ name: "",
+ studentId: "",
+ college: "",
+ department: "",
+ phone: "",
+ birthDate: "",
+ role: "pm",
+ applicationType: "individual",
+ teamMembers: [],
+};
+
+export function SubmitApplicationForm() {
+ const adapter = useSnackbarAdapter();
+ const [submitted, setSubmitted] = useState(false);
+ const {
+ register,
+ control,
+ handleSubmit,
+ watch,
+ reset,
+ setValue,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(applicationSchema),
+ defaultValues: DEFAULT_VALUES,
+ });
+
+ // Runs client-side only, after the SSR-matching empty form has hydrated —
+ // avoids a hydration mismatch between the server render and a
+ // localStorage-backed draft that only exists in the browser.
+ useEffect(() => {
+ const draft = loadApplicationDraft();
+ if (draft) reset({ ...DEFAULT_VALUES, ...draft });
+ }, [reset]);
+
+ useApplicationDraftAutosave(watch);
+
+ const applicationType = watch("applicationType");
+
+ const onSubmit = handleSubmit(async (data) => {
+ const result = await submitApplicationAction({
+ ...data,
+ teamMembers: data.applicationType === "team" ? data.teamMembers : [],
+ });
+ if (!result.ok) {
+ adapter.create({
+ onClose: () => {},
+ render: () => ,
+ });
+ return;
+ }
+ clearApplicationDraft();
+ setSubmitted(true);
+ });
+
+ if (submitted) {
+ return ;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/features/submit-application/ui/submit-application-result.tsx b/src/features/submit-application/ui/submit-application-result.tsx
new file mode 100644
index 0000000..7a781c8
--- /dev/null
+++ b/src/features/submit-application/ui/submit-application-result.tsx
@@ -0,0 +1,23 @@
+import IconCheckmarkCircleFill from "@karrotmarket/react-monochrome-icon/IconCheckmarkCircleFill";
+import { Box, Icon, Text, VStack } from "@seed-design/react";
+
+export function SubmitApplicationResult() {
+ return (
+
+
+ }
+ color="fg.positive"
+ />
+ 지원이 접수됐어요
+
+ 검토 후 등록하신 연락처로 개별 안내드릴게요
+
+
+
+ );
+}
diff --git a/src/features/submit-application/ui/team-members-field.tsx b/src/features/submit-application/ui/team-members-field.tsx
new file mode 100644
index 0000000..b5e76c7
--- /dev/null
+++ b/src/features/submit-application/ui/team-members-field.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import IconQuestionmarkCircleLine from "@karrotmarket/react-monochrome-icon/IconQuestionmarkCircleLine";
+import IconXmarkLine from "@karrotmarket/react-monochrome-icon/IconXmarkLine";
+import { Box, HStack, Text, VStack } from "@seed-design/react";
+import { useState } from "react";
+import {
+ type Control,
+ type FieldErrors,
+ type UseFormRegister,
+ type UseFormSetValue,
+ useFieldArray,
+} from "react-hook-form";
+import { ActionButton } from "seed-design/ui/action-button";
+import { HelpBubbleTrigger } from "seed-design/ui/help-bubble";
+import { TextField, TextFieldInput } from "seed-design/ui/text-field";
+import type { ApplicationFormInput } from "../model/schema";
+import { CollegeDepartmentFields } from "./college-department-select";
+
+const MAX_TEAM_MEMBERS = 4; // 본인 포함 최대 5명
+
+export function TeamMembersField({
+ control,
+ register,
+ setValue,
+ errors,
+}: {
+ control: Control;
+ register: UseFormRegister;
+ setValue: UseFormSetValue;
+ errors: FieldErrors;
+}) {
+ const { fields, append, remove } = useFieldArray({
+ control,
+ name: "teamMembers",
+ });
+ // Opens the moment "팀 지원" is selected (this component only mounts
+ // then) instead of waiting for the "?" to be tapped — the whole point is
+ // that applicants easily miss this rule, so surface it immediately.
+ const [helpOpen, setHelpOpen] = useState(true);
+
+ return (
+
+
+
+
+ 팀원 정보
+
+
+
+
+
+
+ 본인 포함 {fields.length + 1}/5명
+
+
+
+ {errors.teamMembers?.message && (
+
+ {errors.teamMembers.message}
+
+ )}
+
+ {fields.map((field, index) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ = MAX_TEAM_MEMBERS}
+ onClick={() => append({ name: "", college: "", department: "" })}
+ >
+ + 팀원 추가
+
+ {fields.length >= MAX_TEAM_MEMBERS && (
+
+ 최대 5명(본인 포함)까지 지원할 수 있어요
+
+ )}
+
+ );
+}
diff --git a/src/shared/lib/supabase/database.types.ts b/src/shared/lib/supabase/database.types.ts
index e51f20d..911d711 100644
--- a/src/shared/lib/supabase/database.types.ts
+++ b/src/shared/lib/supabase/database.types.ts
@@ -60,6 +60,83 @@ export type Database = {
};
Relationships: [];
};
+ application_team_members: {
+ Row: {
+ application_id: string;
+ college: string;
+ department: string;
+ id: string;
+ name: string;
+ position: number;
+ };
+ Insert: {
+ application_id: string;
+ college: string;
+ department: string;
+ id?: string;
+ name: string;
+ position: number;
+ };
+ Update: {
+ application_id?: string;
+ college?: string;
+ department?: string;
+ id?: string;
+ name?: string;
+ position?: number;
+ };
+ Relationships: [
+ {
+ foreignKeyName: "application_team_members_application_id_fkey";
+ columns: ["application_id"];
+ isOneToOne: false;
+ referencedRelation: "applications";
+ referencedColumns: ["id"];
+ },
+ ];
+ };
+ applications: {
+ Row: {
+ application_type: string;
+ birth_date: string;
+ college: string;
+ created_at: string;
+ department: string;
+ id: string;
+ name: string;
+ phone: string;
+ role: string;
+ status: string;
+ student_id: string;
+ };
+ Insert: {
+ application_type: string;
+ birth_date: string;
+ college: string;
+ created_at?: string;
+ department: string;
+ id?: string;
+ name: string;
+ phone: string;
+ role: string;
+ status?: string;
+ student_id: string;
+ };
+ Update: {
+ application_type?: string;
+ birth_date?: string;
+ college?: string;
+ created_at?: string;
+ department?: string;
+ id?: string;
+ name?: string;
+ phone?: string;
+ role?: string;
+ status?: string;
+ student_id?: string;
+ };
+ Relationships: [];
+ };
booth_markers: {
Row: {
kind: string;
diff --git a/supabase/migrations/20260904120000_applications.sql b/supabase/migrations/20260904120000_applications.sql
new file mode 100644
index 0000000..bef2568
--- /dev/null
+++ b/supabase/migrations/20260904120000_applications.sql
@@ -0,0 +1,38 @@
+-- applications: public recruitment intake form. Deliberately separate from
+-- participants/teams (the currently-running demo-day roster) — this is
+-- reviewed manually by admins and successful applicants get seeded into
+-- participants/teams afterwards, same as today's manual onboarding.
+create table applications (
+ id uuid primary key default gen_random_uuid(),
+ name text not null,
+ student_id text not null,
+ college text not null,
+ department text not null,
+ phone text not null,
+ birth_date date not null,
+ role text not null check (role in ('pm', 'design', 'developer')),
+ application_type text not null check (application_type in ('individual', 'team')),
+ status text not null default 'submitted' check (status in ('submitted', 'reviewing', 'accepted', 'rejected')),
+ created_at timestamptz not null default now()
+);
+create unique index applications_student_id_key on applications (student_id);
+
+-- Reference-only roster of teammates the applicant named for matching —
+-- each teammate is expected to submit their own application separately, so
+-- these rows carry no contact info, just enough to identify/cross-check them.
+create table application_team_members (
+ id uuid primary key default gen_random_uuid(),
+ application_id uuid not null references applications(id) on delete cascade,
+ position int not null,
+ name text not null,
+ college text not null,
+ department text not null
+);
+create index application_team_members_application_id_idx on application_team_members (application_id);
+
+alter table applications enable row level security;
+alter table application_team_members enable row level security;
+
+-- No policies by design — every read/write goes through Server Actions using
+-- createAdminClient() (service role, bypasses RLS), same as
+-- participants/investors (see 20260814055714_tighten_rls_and_trade_lock.sql).