diff --git a/app/components/OutsourcingRequestForm.tsx b/app/components/OutsourcingRequestForm.tsx new file mode 100644 index 0000000..ee149aa --- /dev/null +++ b/app/components/OutsourcingRequestForm.tsx @@ -0,0 +1,632 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import Link from 'next/link'; +import { createClient } from '@/lib/supabase/client'; +import { trackEvent } from '@/lib/gtag'; + +// 외주 의뢰용 4단계 폼. +// ① 프로젝트 유형 → ② 예산·일정 → ③ 상세 내용 → ④ 연락처 +// 각 단계 검증을 통과해야 다음으로 진행한다. 마지막에 POST /api/contact. +// 마운트 시 로그인 사용자면 이메일을 자동 채운다(수정 가능). +// 기존 ContactForm.tsx는 보존하고, 이 폼이 /outsourcing #contact에서 대체한다. +// 디자인: --jsm-* 토큰만 사용. gradient/blur/보라/이모지 금지. + +const KOR_TIGHT = { letterSpacing: '-0.02em' } as const; +const KOR_BODY = { letterSpacing: '-0.01em' } as const; + +const PROJECT_TYPES = [ + '웹 서비스', + '웹사이트', + '업무 자동화', + 'API·백엔드', + '봇 개발', + 'AI 연동', + '기타', +] as const; + +const BUDGETS = [ + '100만원 미만', + '100~300만원', + '300~1,000만원', + '1,000만원 이상', + '미정', +] as const; + +const TIMELINES = ['1개월 내', '1~3개월', '3개월 이상', '미정'] as const; + +const STEPS = [ + { n: 1, label: '프로젝트 유형' }, + { n: 2, label: '예산·일정' }, + { n: 3, label: '상세 내용' }, + { n: 4, label: '연락처' }, +] as const; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +interface SuccessInfo { + trackUrl: string | null; +} + +export default function OutsourcingRequestForm() { + const [step, setStep] = useState(1); + const [projectType, setProjectType] = useState(''); + const [budget, setBudget] = useState(''); + const [timeline, setTimeline] = useState(''); + const [message, setMessage] = useState(''); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [phone, setPhone] = useState(''); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(null); + + const headingRef = useRef(null); + const setHeadingRef = useCallback((el: HTMLElement | null) => { + headingRef.current = el; + }, []); + const firstRender = useRef(true); + + // 로그인 사용자 이메일 자동 채움 (BankTransferModal 세션 확인 패턴) + useEffect(() => { + let mounted = true; + const supabase = createClient(); + supabase.auth + .getUser() + .then(({ data }) => { + const userEmail = data?.user?.email; + if (mounted && userEmail) { + setEmail((prev) => (prev ? prev : userEmail)); + } + }) + .catch(() => { + /* 비로그인 — 무시 */ + }); + return () => { + mounted = false; + }; + }, []); + + // 단계 전환 시 헤딩으로 포커스 이동 (초기 마운트는 제외) + useEffect(() => { + if (firstRender.current) { + firstRender.current = false; + return; + } + headingRef.current?.focus(); + }, [step, success]); + + const trimmedMessage = message.trim(); + const trimmedName = name.trim(); + const trimmedEmail = email.trim(); + + const stepValid = (s: number): boolean => { + switch (s) { + case 1: + return projectType !== ''; + case 2: + return budget !== '' && timeline !== ''; + case 3: + return trimmedMessage.length >= 10; + case 4: + return trimmedName !== '' && EMAIL_RE.test(trimmedEmail); + default: + return false; + } + }; + + const goNext = useCallback(() => { + setError(''); + setStep((s) => Math.min(s + 1, STEPS.length)); + }, []); + + const goPrev = useCallback(() => { + setError(''); + setStep((s) => Math.max(s - 1, 1)); + }, []); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!stepValid(4) || submitting) return; + setSubmitting(true); + setError(''); + try { + const res = await fetch('/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: trimmedName, + phone: phone.trim(), + email: trimmedEmail, + service: `외주 개발 문의 — ${projectType}`, + message: trimmedMessage, + projectType, + budget, + timeline, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError( + data?.error || '의뢰 전송 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + ); + setSubmitting(false); + return; + } + trackEvent('generate_lead', { + event_category: 'contact', + event_label: `외주 개발 문의 — ${projectType}`, + }); + setSuccess({ trackUrl: typeof data?.trackUrl === 'string' ? data.trackUrl : null }); + } catch { + setError('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); + setSubmitting(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + submitting, + trimmedName, + trimmedEmail, + trimmedMessage, + phone, + projectType, + budget, + timeline, + ] + ); + + // ── 완료 화면 ────────────────────────────────────────────── + if (success) { + return ( +
+

+ 의뢰가 접수되었습니다 +

+

+ 영업일 2일 내 회신드립니다. +

+ + {success.trackUrl ? ( +
+ + 진행 상태 확인하기 + + +

+ 추적 링크를 이메일로도 보내드렸습니다. +

+
+ ) : null} +
+ ); + } + + const isLast = step === STEPS.length; + const canAdvance = stepValid(step); + + return ( +
+ {/* 진행 표시기 */} +
    + {STEPS.map((s, i) => { + const state = + s.n < step ? 'done' : s.n === step ? 'current' : 'upcoming'; + return ( +
  1. + + {s.n} + + + {s.label} + + {i < STEPS.length - 1 && ( + + )} +
  2. + ); + })} +
+ +
+ {/* ── 단계 ① 프로젝트 유형 ── */} + {step === 1 && ( +
+ + 어떤 프로젝트인가요? + +

+ 가장 가까운 유형을 하나 선택해주세요. +

+
+ {PROJECT_TYPES.map((t) => { + const selected = projectType === t; + return ( + + ); + })} +
+
+ )} + + {/* ── 단계 ② 예산·일정 ── */} + {step === 2 && ( +
+

+ 예산과 일정을 알려주세요 +

+

+ 대략적인 범위면 충분합니다. 정해지지 않았다면 미정을 선택하세요. +

+ +
+ + 예산 + +
+ {BUDGETS.map((b) => ( + setBudget(b)} + /> + ))} +
+
+ +
+ + 희망 일정 + +
+ {TIMELINES.map((t) => ( + setTimeline(t)} + /> + ))} +
+
+
+ )} + + {/* ── 단계 ③ 상세 내용 ── */} + {step === 3 && ( +
+

+ 자세히 들려주세요 +

+

+ 구체적일수록 정확한 견적이 가능합니다. 최소 10자 이상 작성해주세요. +

+ +