'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 (
계정 정보 입력
서비스 이용을 위한 개인 계정을 생성합니다.
);
}
// 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('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('website', e.target.value)}
disabled={isSubmitting}
/>
{/* 담당자 정보 - PhoneInput 사용 */}
{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" && (
)}
{/* 필수 첨부 서류 */}
필수 첨부 서류
{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)}>
))}
)}
);
}