diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-06 04:23:40 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-06 04:23:40 +0000 |
| commit | de2ac5a2860bc25180971e7a11f852d9d44675b7 (patch) | |
| tree | b931c363f2cb19e177a0a7b17190d5de2a82d709 /components/signup/conset-step.tsx | |
| parent | 6c549b0f264e9be4d60af38f9efc05b189d6849f (diff) | |
(대표님) 정기평가, 법적검토, 정책, 가입관련 처리 및 관련 컴포넌트 추가, 메뉴 변경
Diffstat (limited to 'components/signup/conset-step.tsx')
| -rw-r--r-- | components/signup/conset-step.tsx | 415 |
1 files changed, 415 insertions, 0 deletions
diff --git a/components/signup/conset-step.tsx b/components/signup/conset-step.tsx new file mode 100644 index 00000000..3260a7b7 --- /dev/null +++ b/components/signup/conset-step.tsx @@ -0,0 +1,415 @@ +import React, { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { ChevronDown, ChevronUp, FileText, Shield, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { getCurrentPolicies } from '@/lib/polices/service'; + +// ✅ 정책 데이터 타입 개선 +interface PolicyData { + id: number; + policyType: 'privacy_policy' | 'terms_of_service'; + version: string; + content: string; // HTML 형태의 리치텍스트 + effectiveDate: string; + isCurrent: boolean; + createdAt: string; +} + +interface PolicyVersions { + privacy_policy?: PolicyData; + terms_of_service?: PolicyData; +} + +interface ConsentStepProps { + data: { + privacy: boolean; + terms: boolean; + marketing: boolean; + }; + onChange: (updater: (prev: any) => any) => void; + onNext: () => void; +} + +export default function ConsentStep({ data, onChange, onNext }: ConsentStepProps) { + const [policyData, setPolicyData] = useState<PolicyVersions | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [showPrivacyModal, setShowPrivacyModal] = useState(false); + const [showTermsModal, setShowTermsModal] = useState(false); + const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({}); + + const isValid = data.privacy && data.terms; + + // 정책 데이터 로드 + useEffect(() => { + fetchPolicyData(); + }, []); + + const fetchPolicyData = async () => { + try { + setLoading(true); + setError(null); + + const result = await getCurrentPolicies(); + + if (result.success) { + console.log('Policy data loaded:', result.data); + setPolicyData(result.data); + } else { + setError(result.error || '정책 데이터를 불러올 수 없습니다.'); + console.error('Failed to fetch policy data:', result.error); + } + } catch (error) { + const errorMessage = '정책 데이터를 불러오는 중 오류가 발생했습니다.'; + setError(errorMessage); + console.error('Failed to fetch policy data:', error); + } finally { + setLoading(false); + } + }; + + const handleConsentChange = (type: string, checked: boolean) => { + onChange(prev => ({ ...prev, [type]: checked })); + }; + + const toggleSection = (section: string) => { + setExpandedSections(prev => ({ + ...prev, + [section]: !prev[section] + })); + }; + + // ✅ HTML에서 텍스트만 추출하는 함수 + const stripHtmlTags = (html: string): string => { + if (!html) return ''; + + // HTML 태그 제거 + return html + .replace(/<[^>]*>/g, '') // 모든 HTML 태그 제거 + .replace(/ /g, ' ') // non-breaking space 처리 + .replace(/&/g, '&') // HTML entities 처리 + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') // 연속된 공백을 하나로 + .trim(); + }; + + // ✅ 정책 미리보기 텍스트 생성 + const renderPolicyPreview = (policy: PolicyData, maxLength = 200): string => { + if (!policy?.content) return '내용 없음'; + + const textContent = stripHtmlTags(policy.content); + return textContent.length > maxLength + ? textContent.substring(0, maxLength) + '...' + : textContent; + }; + + // ✅ 로딩 상태 + if (loading) { + return ( + <div className="flex items-center justify-center p-8"> + <div className="flex items-center gap-2 text-gray-600"> + <Loader2 className="h-4 w-4 animate-spin" /> + 정책 내용을 불러오는 중... + </div> + </div> + ); + } + + // ✅ 에러 상태 + if (error || !policyData) { + return ( + <div className="text-center p-8"> + <div className="text-red-600 mb-4"> + {error || '정책 내용을 불러올 수 없습니다.'} + </div> + <Button onClick={fetchPolicyData} variant="outline"> + 다시 시도 + </Button> + </div> + ); + } + + // ✅ 필수 정책이 없는 경우 + if (!policyData.privacy_policy || !policyData.terms_of_service) { + return ( + <div className="text-center p-8"> + <div className="text-amber-600 mb-4"> + 일부 정책이 설정되지 않았습니다. 관리자에게 문의해주세요. + </div> + <div className="text-sm text-gray-500"> + {!policyData.privacy_policy && '개인정보 처리방침이 없습니다.'}<br /> + {!policyData.terms_of_service && '이용약관이 없습니다.'} + </div> + </div> + ); + } + + return ( + <div className="space-y-6"> + <div> + <h2 className="text-xl font-semibold mb-2">서비스 이용 약관 동의</h2> + <p className="text-gray-600 text-sm"> + 서비스 이용을 위해 다음 약관에 동의해주세요. 각 항목을 클릭하여 상세 내용을 확인할 수 있습니다. + </p> + </div> + + <div className="space-y-4"> + {/* ✅ 개인정보 처리방침 */} + {policyData.privacy_policy && ( + <PolicyConsentSection + id="privacy-consent" + type="privacy" + checked={data.privacy} + onChange={handleConsentChange} + policy={policyData.privacy_policy} + isRequired={true} + icon={<Shield className="w-4 h-4" />} + title="개인정보 처리방침" + description="개인정보 수집, 이용, 보관 및 파기에 관한 정책입니다." + expanded={expandedSections.privacy} + onToggleExpand={() => toggleSection('privacy')} + onShowModal={() => setShowPrivacyModal(true)} + /> + )} + + <Separator /> + + {/* ✅ 이용약관 */} + {policyData.terms_of_service && ( + <PolicyConsentSection + id="terms-consent" + type="terms" + checked={data.terms} + onChange={handleConsentChange} + policy={policyData.terms_of_service} + isRequired={true} + icon={<FileText className="w-4 h-4" />} + title="이용약관" + description="서비스 이용 시 준수해야 할 규칙과 조건입니다." + expanded={expandedSections.terms} + onToggleExpand={() => toggleSection('terms')} + onShowModal={() => setShowTermsModal(true)} + /> + )} + + + {/* ✅ 전체 동의 */} + <div className="pt-4 border-t bg-gray-50 p-4 rounded-lg"> + <div className="flex items-center space-x-3"> + <input + type="checkbox" + id="all-consent" + checked={data.privacy && data.terms && data.marketing} + onChange={(e) => { + const checked = e.target.checked; + onChange(() => ({ + privacy: checked, + terms: checked, + marketing: checked + })); + }} + className="h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + /> + <label htmlFor="all-consent" className="text-base font-medium cursor-pointer"> + 위 내용에 모두 동의합니다 + </label> + </div> + </div> + </div> + + <div className="flex justify-end"> + <Button onClick={onNext} disabled={!isValid} size="lg"> + 다음 단계로 + </Button> + </div> + + {/* ✅ 개인정보 처리방침 상세 모달 */} + {showPrivacyModal && policyData.privacy_policy && ( + <PolicyModal + policy={policyData.privacy_policy} + onClose={() => setShowPrivacyModal(false)} + onAgree={() => { + onChange(prev => ({ ...prev, privacy: true })); + setShowPrivacyModal(false); + }} + /> + )} + + {/* ✅ 이용약관 상세 모달 */} + {showTermsModal && policyData.terms_of_service && ( + <PolicyModal + policy={policyData.terms_of_service} + onClose={() => setShowTermsModal(false)} + onAgree={() => { + onChange(prev => ({ ...prev, terms: true })); + setShowTermsModal(false); + }} + /> + )} + </div> + ); +} + +// ✅ 개별 정책 동의 섹션 컴포넌트 +interface PolicyConsentSectionProps { + id: string; + type: string; + checked: boolean; + onChange: (type: string, checked: boolean) => void; + policy: PolicyData; + isRequired: boolean; + icon: React.ReactNode; + title: string; + description: string; + expanded: boolean; + onToggleExpand: () => void; + onShowModal: () => void; +} + +function PolicyConsentSection({ + id, type, checked, onChange, policy, isRequired, icon, title, description, + expanded, onToggleExpand, onShowModal +}: PolicyConsentSectionProps) { + // ✅ HTML에서 텍스트 추출 + const renderPolicyPreview = (content: string, maxLength = 300): string => { + if (!content) return '내용 없음'; + + const textContent = content + .replace(/<[^>]*>/g, '') // HTML 태그 제거 + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); + + return textContent.length > maxLength + ? textContent.substring(0, maxLength) + '...' + : textContent; + }; + + return ( + <div className="border rounded-lg p-4 hover:bg-gray-50 transition-colors"> + {/* 체크박스와 기본 정보 */} + <div className="flex items-start space-x-3"> + <input + type="checkbox" + id={id} + checked={checked} + onChange={(e) => onChange(type, e.target.checked)} + className="mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + /> + <div className="flex-1"> + <div className="flex items-center space-x-2 mb-1"> + {icon} + <label htmlFor={id} className="text-sm font-medium cursor-pointer"> + <span className={isRequired ? "text-red-500" : "text-blue-500"}> + [{isRequired ? "필수" : "선택"}] + </span> {title} (v{policy.version}) + </label> + </div> + + <p className="text-xs text-gray-600 mb-2">{description}</p> + + {/* ✅ 정책 미리보기 - HTML 내용 표시 */} + <div className="bg-gray-50 p-3 rounded text-xs text-gray-700 mb-2"> + {renderPolicyPreview(policy.content, expanded ? 1000 : 200)} + </div> + + {/* 액션 버튼들 */} + <div className="flex items-center space-x-3 text-xs"> + <button + type="button" + onClick={onToggleExpand} + className="flex items-center space-x-1 text-blue-600 hover:underline" + > + {expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />} + <span>{expanded ? '간략히' : '더보기'}</span> + </button> + <span className="text-gray-400">|</span> + <button + type="button" + onClick={onShowModal} + className="text-blue-600 hover:underline" + > + 전문보기 + </button> + <span className="text-gray-400">|</span> + <span className="text-gray-500"> + 시행일: {new Date(policy.effectiveDate).toLocaleDateString('ko-KR')} + </span> + </div> + </div> + </div> + </div> + ); +} + +// ✅ 정책 상세 모달 컴포넌트 +interface PolicyModalProps { + policy: PolicyData; + onClose: () => void; + onAgree: () => void; +} + +function PolicyModal({ policy, onClose, onAgree }: PolicyModalProps) { + const getPolicyTitle = (policyType: string): string => { + return policyType === 'privacy_policy' ? '개인정보 처리방침' : '이용약관'; + }; + + return ( + <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> + <div className="bg-white rounded-lg w-full max-w-4xl max-h-[90vh] flex flex-col"> + {/* 헤더 */} + <div className="flex items-center justify-between p-6 border-b"> + <div> + <h3 className="text-xl font-semibold"> + {getPolicyTitle(policy.policyType)} + </h3> + <p className="text-sm text-gray-600 mt-1"> + 버전 {policy.version} | 시행일: {new Date(policy.effectiveDate).toLocaleDateString('ko-KR')} + </p> + </div> + <button + onClick={onClose} + className="text-gray-400 hover:text-gray-600 text-2xl leading-none" + > + × + </button> + </div> + + {/* ✅ 내용 - HTML 직접 렌더링 */} + <ScrollArea className="flex-1 p-6"> + <div + className="prose prose-sm max-w-none text-gray-700 leading-relaxed" + dangerouslySetInnerHTML={{ __html: policy.content }} + /> + </ScrollArea> + + {/* 푸터 */} + <div className="flex gap-3 p-6 border-t bg-gray-50"> + <Button + variant="outline" + onClick={onClose} + className="flex-1" + > + 닫기 + </Button> + <Button + onClick={onAgree} + className="flex-1" + > + 동의하고 닫기 + </Button> + </div> + </div> + </div> + ); +}
\ No newline at end of file |
