'use client' import React, { useState, useEffect, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Progress } from '@/components/ui/progress'; import { Check, ChevronRight, User, Building, FileText, Plus, X, ChevronsUpDown, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useRouter, useParams, useSearchParams } from 'next/navigation'; import { useTranslation } from '@/i18n/client'; import { toast } from '@/hooks/use-toast'; import { Popover, PopoverTrigger, PopoverContent, } from '@/components/ui/popover'; import { Command, CommandList, CommandInput, CommandEmpty, CommandGroup, CommandItem, } from '@/components/ui/command'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Dropzone, DropzoneZone, DropzoneInput, DropzoneUploadIcon, DropzoneTitle, DropzoneDescription, } from '@/components/ui/dropzone'; import { FileList, FileListItem, FileListHeader, FileListIcon, FileListInfo, FileListName, FileListDescription, FileListAction, } from '@/components/ui/file-list'; import { ScrollArea } from '@/components/ui/scroll-area'; import prettyBytes from 'pretty-bytes'; import { useToast } from "@/hooks/use-toast"; // libphonenumber-js 관련 imports import { parsePhoneNumberFromString, isPossiblePhoneNumber, isValidPhoneNumber, validatePhoneNumberLength, getExampleNumber, AsYouType, getCountries, getCountryCallingCode } from 'libphonenumber-js' import examples from 'libphonenumber-js/examples.mobile.json' // 기존 imports... import i18nIsoCountries from "i18n-iso-countries"; import enLocale from "i18n-iso-countries/langs/en.json"; import koLocale from "i18n-iso-countries/langs/ko.json"; import { getVendorTypes } from '@/lib/vendors/service'; import ConsentStep from './conset-step'; import { checkEmailExists } from '@/lib/vendor-users/service'; i18nIsoCountries.registerLocale(enLocale); i18nIsoCountries.registerLocale(koLocale); const locale = "ko"; const countryMap = i18nIsoCountries.getNames(locale, { select: "official" }); const countryArray = Object.entries(countryMap).map(([code, label]) => ({ code, label, })); const sortedCountryArray = [...countryArray].sort((a, b) => { if (a.code === "KR") return -1; if (b.code === "KR") return 1; return a.label.localeCompare(b.label); }); const enhancedCountryArray = sortedCountryArray.map(country => ({ ...country, label: locale === "ko" && country.code === "KR" ? "대한민국 (South Korea)" : country.label })); const contactTaskOptions = [ { value: "PRESIDENT_DIRECTOR", label: "회사대표 President/Director" }, { value: "SALES_MANAGEMENT", label: "영업관리 Sales Management" }, { value: "ENGINEERING_DESIGN", label: "설계/기술 Engineering/Design" }, { value: "PROCUREMENT", label: "구매 Procurement" }, { value: "DELIVERY_CONTROL", label: "납기/출하/운송 Delivery Control" }, { value: "PM_MANUFACTURING", label: "PM/생산관리 PM/Manufacturing" }, { value: "QUALITY_MANAGEMENT", label: "품질관리 Quality Management" }, { value: "SHIPPING_DOC_MANAGEMENT", label: "세금계산서/납품서관리 Shipping Doc. Management" }, { value: "AS_MANAGEMENT", label: "A/S 관리 A/S Management" }, { value: "FIELD_SERVICE_ENGINEER", label: "FSE(야드작업자) Field Service Engineer" } ]; const MAX_FILE_SIZE = 3e9; // 스텝 정의 const STEPS = [ { id: 1, title: '약관 동의', description: '서비스 이용 약관 동의', icon: FileText }, { id: 2, title: '계정 생성', description: '개인 계정 정보 입력', icon: User }, { id: 3, title: '업체 등록', description: '업체 정보 및 서류 제출', icon: Building } ]; // ========== 전화번호 처리 유틸리티 함수들 ========== /** * 국가별 전화번호 예시를 가져오는 함수 */ function getPhoneExample(countryCode) { if (!countryCode) return null; try { return getExampleNumber(countryCode, examples); } catch { return null; } } /** * 국가별 전화번호 플레이스홀더를 생성하는 함수 */ function getPhonePlaceholder(countryCode) { if (!countryCode) return "국가를 먼저 선택해주세요"; const example = getPhoneExample(countryCode); if (example) { // 국내 형식으로 표시 (더 친숙함) return example.formatNational(); } // 예시가 없는 경우 국가 코드 기반 플레이스홀더 try { const callingCode = getCountryCallingCode(countryCode); return `+${callingCode} ...`; } catch { return "전화번호를 입력하세요"; } } /** * 국가별 전화번호 설명을 생성하는 함수 */ function getPhoneDescription(countryCode) { if (!countryCode) return "국가를 먼저 선택하여 올바른 전화번호 형식을 확인하세요."; const example = getPhoneExample(countryCode); if (example) { return `예시: ${example.formatNational()} 또는 ${example.formatInternational()}`; } try { const callingCode = getCountryCallingCode(countryCode); return `국가 코드: +${callingCode}. 국내 형식 또는 국제 형식으로 입력 가능합니다.`; } catch { return "올바른 전화번호 형식으로 입력해주세요."; } } /** * 전화번호 검증 결과를 반환하는 함수 */ function validatePhoneNumber(phoneNumber, countryCode) { if (!phoneNumber || !countryCode) { return { isValid: false, error: null, formatted: phoneNumber }; } try { // 1. 기본 파싱 시도 const parsed = parsePhoneNumberFromString(phoneNumber, countryCode); if (!parsed) { return { isValid: false, error: "올바른 전화번호 형식이 아닙니다.", formatted: phoneNumber }; } // 2. 길이 검증 const lengthValidation = validatePhoneNumberLength(phoneNumber, countryCode); if (lengthValidation !== undefined) { const lengthErrors = { 'TOO_SHORT': '전화번호가 너무 짧습니다.', 'TOO_LONG': '전화번호가 너무 깁니다.', 'INVALID_LENGTH': '전화번호 길이가 올바르지 않습니다.' }; return { isValid: false, error: lengthErrors[lengthValidation] || '전화번호 길이가 올바르지 않습니다.', formatted: phoneNumber }; } // 3. 가능성 검증 if (!isPossiblePhoneNumber(phoneNumber, countryCode)) { return { isValid: false, error: "이 국가에서 가능하지 않은 전화번호 형식입니다.", formatted: phoneNumber }; } // 4. 유효성 검증 if (!isValidPhoneNumber(phoneNumber, countryCode)) { return { isValid: false, error: "유효하지 않은 전화번호입니다.", formatted: phoneNumber }; } // 모든 검증 통과 return { isValid: true, error: null, formatted: parsed.formatNational(), international: parsed.formatInternational() }; } catch (error) { return { isValid: false, error: "전화번호 형식을 확인해주세요.", formatted: phoneNumber }; } } /** * 실시간 전화번호 포맷팅을 위한 커스텀 훅 */ function usePhoneFormatter(countryCode) { const [formatter, setFormatter] = useState(null); useEffect(() => { if (countryCode) { setFormatter(new AsYouType(countryCode)); } else { setFormatter(null); } }, [countryCode]); const formatPhone = useCallback((value) => { if (!formatter) return value; // AsYouType은 매번 새로운 인스턴스를 사용해야 함 const newFormatter = new AsYouType(countryCode); return newFormatter.input(value); }, [countryCode, formatter]); return formatPhone; } // ========== 전화번호 입력 컴포넌트 ========== function PhoneInput({ value, onChange, countryCode, placeholder, disabled = false, onBlur = null, className = "", showValidation = true }) { const [touched, setTouched] = useState(false); const [localValue, setLocalValue] = useState(value || ''); const formatPhone = usePhoneFormatter(countryCode); // value prop이 변경될 때 localValue 동기화 useEffect(() => { setLocalValue(value || ''); }, [value]); const validation = validatePhoneNumber(localValue, countryCode); const handleInputChange = (e) => { const inputValue = e.target.value; // 실시간 포맷팅 적용 const formattedValue = countryCode ? formatPhone(inputValue) : inputValue; setLocalValue(formattedValue); onChange(formattedValue); }; const handleBlur = () => { setTouched(true); if (onBlur) onBlur(); }; const showError = showValidation && touched && localValue && !validation.isValid; const showSuccess = showValidation && touched && localValue && validation.isValid; return (
{showValidation && (
{/* 설명 텍스트 */}

{getPhoneDescription(countryCode)}

{/* 오류 메시지 */} {showError && (

{validation.error}

)} {/* 성공 메시지 */} {showSuccess && (

✓ 올바른 전화번호입니다. {validation.international && ` (${validation.international})`}

)}
)}
); } // ========== 메인 컴포넌트 ========== export default function JoinForm() { const params = useParams() || {}; const lng = params.lng ? String(params.lng) : "ko"; const { t } = useTranslation(lng, "translation"); const router = useRouter(); const searchParams = useSearchParams() || new URLSearchParams(); const defaultTaxId = searchParams.get("taxID") ?? ""; const { toast } = useToast(); const [currentStep, setCurrentStep] = useState(1); const [completedSteps, setCompletedSteps] = useState(new Set()); // 각 스텝별 데이터 const [consentData, setConsentData] = useState({ privacy: false, terms: false, marketing: false }); const [accountData, setAccountData] = useState({ name: '', email: '', phone: '', country: 'KR' // 기본값을 한국으로 설정 }); const [vendorData, setVendorData] = useState({ vendorName: "", vendorTypeId: undefined, items: "", taxId: defaultTaxId, address: "", email: "", phone: "", country: "", // 기본값을 빈 문자열로 설정하여 계정 데이터에서 가져오도록 함 website: "", representativeName: "", representativeBirth: "", representativeEmail: "", representativePhone: "", corporateRegistrationNumber: "", representativeWorkExpirence: false, contacts: [ { contactName: "", contactPosition: "", contactDepartment: "", contactTask: "", contactEmail: "", contactPhone: "", }, ], }); // 업체 타입 및 파일 상태 const [vendorTypes, setVendorTypes] = useState([]); const [isLoadingVendorTypes, setIsLoadingVendorTypes] = useState(true); const [businessRegistrationFiles, setBusinessRegistrationFiles] = useState([]); const [isoCertificationFiles, setIsoCertificationFiles] = useState([]); const [creditReportFiles, setCreditReportFiles] = useState([]); const [bankAccountFiles, setBankAccountFiles] = useState([]); const [policyVersions, setPolicyVersions] = useState({ privacy_policy: '1.0', terms_of_service: '1.0' }); const progress = ((currentStep - 1) / (STEPS.length - 1)) * 100; // 정책 버전 및 업체 타입 로드 useEffect(() => { loadVendorTypes(); }, []); const loadVendorTypes = async () => { setIsLoadingVendorTypes(true); try { const result = await getVendorTypes(); if (result.data) { setVendorTypes(result.data); } } catch (error) { console.error("Failed to load vendor types:", error); toast({ variant: "destructive", title: "Error", description: "Failed to load vendor types", }); } finally { setIsLoadingVendorTypes(false); } }; const handleStepComplete = (step) => { setCompletedSteps(prev => new Set([...prev, step])); if (step < STEPS.length) { setCurrentStep(step + 1); } }; const handleStepClick = (stepId) => { if (stepId <= Math.max(...completedSteps) + 1) { setCurrentStep(stepId); } }; return (
{/* 진행률 표시 */}

파트너 등록

{currentStep} / {STEPS.length}
{/* 스텝 네비게이션 */}
{STEPS.map((step, index) => { const Icon = step.icon; const isCompleted = completedSteps.has(step.id); const isCurrent = currentStep === step.id; const isAccessible = step.id <= Math.max(...completedSteps) + 1; return (
handleStepClick(step.id)} >
{isCompleted ? ( ) : ( )}
{step.title}
{step.description}
{index < STEPS.length - 1 && ( )}
); })}
{/* 스텝 콘텐츠 */}
{currentStep === 1 && ( handleStepComplete(1)} /> )} {currentStep === 2 && ( handleStepComplete(2)} onBack={() => setCurrentStep(1)} enhancedCountryArray={enhancedCountryArray} /> )} {currentStep === 3 && ( setCurrentStep(2)} onComplete={() => { handleStepComplete(3); router.push(`/${lng}/partners/dashboard`); }} accountData={accountData} consentData={consentData} vendorTypes={vendorTypes} isLoadingVendorTypes={isLoadingVendorTypes} businessRegistrationFiles={businessRegistrationFiles} setBusinessRegistrationFiles={setBusinessRegistrationFiles} isoCertificationFiles={isoCertificationFiles} setIsoCertificationFiles={setIsoCertificationFiles} creditReportFiles={creditReportFiles} setCreditReportFiles={setCreditReportFiles} bankAccountFiles={bankAccountFiles} setBankAccountFiles={setBankAccountFiles} enhancedCountryArray={enhancedCountryArray} contactTaskOptions={contactTaskOptions} lng={lng} policyVersions={policyVersions} /> )}
); } // Step 2: 계정 생성 function AccountStep({ data, onChange, onNext, onBack, enhancedCountryArray }) { const [isLoading, setIsLoading] = useState(false); const [emailCheckError, setEmailCheckError] = useState(''); // 입력 핸들러 const handleInputChange = (field, value) => { onChange(prev => ({ ...prev, [field]: value })); }; // 전화번호 validation const phoneValidation = validatePhoneNumber(data.phone, data.country); // 전체 입력 유효성 const isValid = data.name && data.email && data.country && data.phone && phoneValidation.isValid; // 이메일 중복체크 + 다음단계 const handleNext = async () => { if (!isValid) return; setIsLoading(true); setEmailCheckError(''); try { const isUsed = await checkEmailExists(data.email); if (isUsed) { setEmailCheckError('이미 사용 중인 이메일입니다.'); return; } onNext(); } catch (error) { setEmailCheckError('이메일 확인 중 오류가 발생했습니다.'); } finally { setIsLoading(false); } }; return (

계정 정보 입력

서비스 이용을 위한 개인 계정을 생성합니다.

handleInputChange('name', e.target.value)} />
handleInputChange('email', e.target.value)} /> {emailCheckError && (

{emailCheckError}

)}
No country found. {enhancedCountryArray.map((country) => ( handleInputChange('country', country.code)} > {country.label} ))}
handleInputChange('phone', value)} countryCode={data.country} showValidation={true} />
); } // Step 3: 업체 등록 function VendorStep(props) { return ; } // 나머지 CompeleteVendorForm과 FileUploadSection은 기존과 동일하되, // PhoneInput 컴포넌트를 사용하도록 수정 function CompleteVendorForm({ data, onChange, onBack, onComplete, accountData, consentData, vendorTypes, isLoadingVendorTypes, businessRegistrationFiles, setBusinessRegistrationFiles, isoCertificationFiles, setIsoCertificationFiles, creditReportFiles, setCreditReportFiles, bankAccountFiles, setBankAccountFiles, enhancedCountryArray, contactTaskOptions, lng, policyVersions }) { const [isSubmitting, setIsSubmitting] = useState(false); // 담당자 관리 함수들 const addContact = () => { onChange(prev => ({ ...prev, contacts: [...prev.contacts, { contactName: "", contactPosition: "", contactDepartment: "", contactTask: "", contactEmail: "", contactPhone: "", }] })); }; const removeContact = (index) => { onChange(prev => ({ ...prev, contacts: prev.contacts.filter((_, i) => i !== index) })); }; const updateContact = (index, field, value) => { onChange(prev => ({ ...prev, contacts: prev.contacts.map((contact, i) => i === index ? { ...contact, [field]: value } : contact ) })); }; // 폼 입력 변경 핸들러 const handleInputChange = (field, value) => { onChange(prev => ({ ...prev, [field]: value })); }; // 파일 업로드 핸들러들 const createFileUploadHandler = (setFiles, currentFiles) => ({ onDropAccepted: (acceptedFiles) => { const newFiles = [...currentFiles, ...acceptedFiles]; setFiles(newFiles); }, onDropRejected: (fileRejections) => { fileRejections.forEach((rej) => { toast({ variant: "destructive", title: "File Error", description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`, }); }); }, removeFile: (index) => { const updated = [...currentFiles]; updated.splice(index, 1); setFiles(updated); } }); const businessRegistrationHandler = createFileUploadHandler(setBusinessRegistrationFiles, businessRegistrationFiles); const isoCertificationHandler = createFileUploadHandler(setIsoCertificationFiles, isoCertificationFiles); const creditReportHandler = createFileUploadHandler(setCreditReportFiles, creditReportFiles); const bankAccountHandler = createFileUploadHandler(setBankAccountFiles, bankAccountFiles); // 유효성 검사 const validateRequiredFiles = () => { const errors = []; if (businessRegistrationFiles.length === 0) { errors.push("사업자등록증을 업로드해주세요."); } if (isoCertificationFiles.length === 0) { errors.push("ISO 인증서를 업로드해주세요."); } if (creditReportFiles.length === 0) { errors.push("신용평가보고서를 업로드해주세요."); } if (data.country !== "KR" && bankAccountFiles.length === 0) { errors.push("대금지급 통장사본을 업로드해주세요."); } return errors; }; // 전화번호 검증 const vendorPhoneValidation = validatePhoneNumber(data.phone, data.country); const contactsValid = data.contacts.length > 0 && data.contacts[0].contactName && data.contacts.every(contact => contact.contactPhone ? validatePhoneNumber(contact.contactPhone, data.country).isValid : true ); const isFormValid = data.vendorName && data.vendorTypeId && data.items && data.country && data.phone && vendorPhoneValidation.isValid && data.email && contactsValid && validateRequiredFiles().length === 0; // 최종 제출 (기존과 동일) const handleSubmit = async () => { const fileErrors = validateRequiredFiles(); if (fileErrors.length > 0) { toast({ variant: "destructive", title: "파일 업로드 필수", description: fileErrors.join("\n"), }); return; } setIsSubmitting(true); try { const formData = new FormData(); const completeData = { account: accountData, vendor: { ...data, email: data.email || accountData.email, }, consents: { privacy_policy: { agreed: consentData.privacy, version: policyVersions.privacy_policy }, terms_of_service: { agreed: consentData.terms, version: policyVersions.terms_of_service }, marketing: { agreed: consentData.marketing, version: policyVersions.privacy_policy } } }; formData.append('completeData', JSON.stringify(completeData)); businessRegistrationFiles.forEach(file => { formData.append('businessRegistration', file); }); isoCertificationFiles.forEach(file => { formData.append('isoCertification', file); }); creditReportFiles.forEach(file => { formData.append('creditReport', file); }); if (data.country !== "KR") { bankAccountFiles.forEach(file => { formData.append('bankAccountCopy', file); }); } const response = await fetch('/api/auth/signup-with-vendor', { method: 'POST', body: formData, }); const result = await response.json(); if (response.ok) { toast({ title: "등록 완료", description: "회원가입 및 업체 등록이 완료되었습니다. 관리자 승인 후 서비스를 이용하실 수 있습니다.", }); onComplete(); } else { toast({ variant: "destructive", title: "오류", description: result.error || "등록에 실패했습니다.", }); } } catch (error) { console.error(error); toast({ variant: "destructive", title: "서버 에러", description: error.message || "에러가 발생했습니다.", }); } finally { setIsSubmitting(false); } }; return (

업체 정보 등록

업체 정보와 필요한 서류를 등록해주세요. 모든 정보는 관리자 검토 후 승인됩니다.

{/* 기본 정보 */}

기본 정보

{/* 업체 유형 */}
No vendor type found. {vendorTypes.map((type) => ( handleInputChange('vendorTypeId', type.id)} > {lng === "ko" ? type.nameKo : type.nameEn} ))}
{/* 업체명 */}
handleInputChange('vendorName', e.target.value)} disabled={isSubmitting} />

{(data.country || accountData.country) === "KR" ? "사업자 등록증에 표기된 정확한 회사명을 입력하세요." : "해외 업체의 경우 영문 회사명을 입력하세요."}

{/* 공급품목 */}
handleInputChange('items', e.target.value)} disabled={isSubmitting} />

공급 가능한 제품/서비스를 입력하세요

{/* 사업자등록번호 */}
handleInputChange('taxId', e.target.value)} disabled={isSubmitting} placeholder="123-45-67890" />
{/* 주소 */}
handleInputChange('address', e.target.value)} disabled={isSubmitting} />
{/* 국가 */}
No country found. {enhancedCountryArray.map((country) => ( handleInputChange('country', country.code)} > {country.label} ))}
{/* 대표 전화 - PhoneInput 사용 */}
handleInputChange('phone', value)} countryCode={data.country} disabled={isSubmitting} showValidation={true} />
{/* 대표 이메일 */}
handleInputChange('email', e.target.value)} disabled={isSubmitting} placeholder={accountData.email} />

비워두면 계정 이메일({accountData.email})을 사용합니다.

{/* 웹사이트 */}
handleInputChange('website', e.target.value)} disabled={isSubmitting} />
{/* 담당자 정보 - PhoneInput 사용 */}

담당자 정보 (최소 1명)

{data.contacts.map((contact, index) => (
updateContact(index, 'contactName', e.target.value)} disabled={isSubmitting} />
updateContact(index, 'contactPosition', e.target.value)} disabled={isSubmitting} />
updateContact(index, 'contactDepartment', e.target.value)} disabled={isSubmitting} />
updateContact(index, 'contactEmail', e.target.value)} disabled={isSubmitting} />
updateContact(index, 'contactPhone', value)} countryCode={data.country} disabled={isSubmitting} showValidation={true} />
{data.contacts.length > 1 && (
)}
))}
{/* 한국 사업자 정보 - PhoneInput 사용 */} {data.country === "KR" && (

한국 사업자 정보

handleInputChange('representativeName', e.target.value)} disabled={isSubmitting} />
handleInputChange('representativeBirth', e.target.value)} disabled={isSubmitting} />
handleInputChange('representativeEmail', e.target.value)} disabled={isSubmitting} />
handleInputChange('representativePhone', value)} countryCode="KR" disabled={isSubmitting} showValidation={true} />
handleInputChange('corporateRegistrationNumber', e.target.value)} disabled={isSubmitting} />
handleInputChange('representativeWorkExpirence', e.target.checked)} disabled={isSubmitting} className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" />
)} {/* 필수 첨부 서류 */}

필수 첨부 서류

{data.country !== "KR" && ( )}
); } // 파일 업로드 섹션 컴포넌트 (기존과 동일) function FileUploadSection({ title, description, files, onDropAccepted, onDropRejected, removeFile, isSubmitting, required = true }) { return (
{title} {required && *}

{description}

{({ maxSize }) => (
파일 업로드 드래그 또는 클릭 {maxSize ? ` (최대: ${prettyBytes(maxSize)})` : null}
)}
{files.length > 0 && (
{files.map((file, i) => ( {file.name} {prettyBytes(file.size)} removeFile(i)}> ))}
)}
); }