diff --git a/ssh/src/pages/Voicephishing/index.tsx b/ssh/src/pages/Voicephishing/index.tsx index 83cacab..41c833b 100644 --- a/ssh/src/pages/Voicephishing/index.tsx +++ b/ssh/src/pages/Voicephishing/index.tsx @@ -1,6 +1,22 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { Phone, CreditCard, ShieldAlert } from 'lucide-react'; +const onlyDigits = (v: string) => v.replace(/\D/g, ''); + +const normalizeForType = (value: string, type: 'phone' | 'account') => { + const digits = onlyDigits(value); + if (type === 'phone') return digits; + return digits; +}; + +const isValid = (value: string, type: 'phone' | 'account') => { + const digits = onlyDigits(value); + if (type === 'phone') { + return /^01[016789]\d{7,8}$/.test(digits); + } + return /^\d{8,20}$/.test(digits); +}; + const VoicephishingPage = () => { const [input, setInput] = useState(''); const [type, setType] = useState<'phone' | 'account'>('phone'); @@ -13,27 +29,185 @@ const VoicephishingPage = () => { type: 'phone' | 'account'; value: string; reports: number; - lastReported?: string; - source?: string; - risk?: 'low' | 'medium' | 'high'; + lastReported: string; + risk: 'low' | 'medium' | 'high'; }> >([]); const [hasSearched, setHasSearched] = useState(false); + const [reportingId, setReportingId] = useState(null); + const [notice, setNotice] = useState(null); + + const [recentReports, setRecentReports] = useState< + Array<{ + id: string; + type: 'phone' | 'account'; + value: string; + lastReported: string; + }> + >([]); + + useEffect(() => { + let aborted = false; + + const load = async () => { + try { + const res = await fetch('/api/reports/recent'); + if (!res.ok) throw new Error(); + if (res.status === 204) { + // No Content + if (!aborted) setRecentReports([]); + return; + } + const data = await res.json(); + let list: any[] = []; + if (Array.isArray(data)) list = data; + else if (Array.isArray((data as any)?.data?.items)) list = (data as any).data.items; + else if (Array.isArray((data as any)?.data)) list = (data as any).data; + else if (Array.isArray((data as any)?.content)) list = (data as any).content; + else if (Array.isArray((data as any)?.items)) list = (data as any).items; + else if (data && typeof data === 'object') list = [data]; + else list = []; + + const mapped = list.map((item: any) => ({ + id: item.id ?? '', + type: item.type === 'phone' || item.type === 'account' ? item.type : 'phone', + value: item.value ?? '', + lastReported: item.lastReported + ? new Date(item.lastReported).toISOString().split('T')[0] + : '-', + })); + if (!aborted) setRecentReports(mapped); + } catch { + if (!aborted) setRecentReports([]); + } + }; + + load(); + const t = setInterval(load, 15000); + return () => { + aborted = true; + clearInterval(t); + }; + }, []); + + const switchType = useCallback((next: 'phone' | 'account') => { + setType(next); + setInput(''); + setResults([]); + setHasSearched(false); + setError(null); + setNotice(null); + setReportingId(null); + }, []); + + const submitReport = useCallback( + async (payload: { type: 'phone' | 'account'; value: string }) => { + const key = `${payload.type}:${payload.value}`; + setReportingId(key); + setError(null); + setNotice(null); + try { + const res = await fetch('/api/reports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(text || '신고에 실패했습니다. 다시 시도해 주세요.'); + } + setNotice('신고가 접수되었습니다. 감사합니다.'); + try { + const recentRes = await fetch('/api/reports/recent'); + if (recentRes.ok && recentRes.status !== 204) { + const data = await recentRes.json(); + let list: any[] = []; + if (Array.isArray(data)) list = data; + else if (Array.isArray((data as any)?.data)) list = (data as any).data; + else if (Array.isArray((data as any)?.content)) list = (data as any).content; + else if (Array.isArray((data as any)?.items)) list = (data as any).items; + else if (data && typeof data === 'object') list = [data]; + const mapped = list.map((item: any) => ({ + id: item.id ?? '', + type: item.type === 'phone' || item.type === 'account' ? item.type : 'phone', + value: item.value ?? '', + lastReported: item.lastReported + ? new Date(item.lastReported).toISOString().split('T')[0] + : '-', + })); + setRecentReports(mapped); + } + } catch {} + } catch (e: any) { + setError(e?.message || '신고 처리 중 오류가 발생했습니다.'); + } finally { + setReportingId(null); + } + }, + [], + ); const handleLookup = useCallback(() => { - const q = input.trim(); + const raw = input.trim(); setHasSearched(true); setError(null); - if (!q) { + if (!raw) { setResults([]); setIsLoading(false); setError(type === 'phone' ? '전화번호를 입력해 주세요.' : '계좌번호를 입력해 주세요.'); return; } + + const q = normalizeForType(raw, type); + if (!isValid(q, type)) { + setResults([]); + setIsLoading(false); + setError( + type === 'phone' ? '전화번호 형식을 확인해 주세요.' : '계좌번호 형식을 확인해 주세요.', + ); + return; + } + setIsLoading(true); - // 백엔드 연동 전이므로 즉시 완료 처리 - setResults([]); - setIsLoading(false); + fetch(`/api/lookup?type=${type}&q=${encodeURIComponent(q)}`) + .then(async (res) => { + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(text || '조회에 실패했습니다. 다시 시도해 주세요.'); + } + const data = await res.json(); + let list: any[] = []; + if (Array.isArray(data)) list = data; + else if (Array.isArray((data as any)?.data?.items)) list = (data as any).data.items; + else if (Array.isArray((data as any)?.data)) list = (data as any).data; + else if (Array.isArray((data as any)?.content)) list = (data as any).content; + else if (Array.isArray((data as any)?.items)) list = (data as any).items; + else if (data && typeof data === 'object') list = [data]; + else list = []; + + const mappedResults = list.map((item: any) => ({ + id: item.id ?? '', + type: item.type === 'phone' || item.type === 'account' ? item.type : type, + value: item.value ?? q, + reports: typeof item.reports === 'number' ? item.reports : 0, + lastReported: item.lastReported + ? new Date(item.lastReported).toISOString().split('T')[0] + : '-', + risk: (['low', 'medium', 'high'] as const).includes(item.risk) + ? item.risk + : typeof item.reports === 'number' && item.reports >= 10 + ? 'high' + : typeof item.reports === 'number' && item.reports >= 3 + ? 'medium' + : 'low', + })); + setResults(mappedResults); + }) + .catch((err) => { + setError(err.message || '조회 중 오류가 발생했습니다.'); + setResults([]); + }) + .finally(() => setIsLoading(false)); }, [input, type]); return ( @@ -73,7 +247,7 @@ const VoicephishingPage = () => { + @@ -133,9 +324,16 @@ const VoicephishingPage = () => { {error} )} + {!isLoading && !error && notice && ( +
+ {notice} +
+ )} {!isLoading && !error && hasSearched && results.length === 0 && (
- 검색 결과가 없습니다. +
+ 검색 결과가 없습니다. +
)} {!isLoading && !error && results.length > 0 && ( @@ -152,13 +350,23 @@ const VoicephishingPage = () => {
신고 횟수
{r.reports}건
+
+ {r.risk === 'high' ? '높음' : r.risk === 'medium' ? '중간' : '낮음'} +
{r.lastReported ? `최근 신고일: ${r.lastReported}` : '최근 신고일: -'} - {r.source ? `출처: ${r.source}` : ''}
))} @@ -167,12 +375,7 @@ const VoicephishingPage = () => {
- } - /> + ; +}) => { + const [index, setIndex] = useState(0); + + useEffect(() => { + if (index >= reports.length) setIndex(0); + }, [reports, index]); + + const hasItems = reports && reports.length > 0; + const current = hasItems ? reports[index] : null; + + return ( +
+
+
+ + + + 최근 신고 데이터 +
+
+ {hasItems ? `${index + 1} / ${Math.min(5, reports.length)}` : '0 / 0'} +
+
+ + {!hasItems ? ( +
최근 신고 데이터가 없습니다.
+ ) : ( +
+ {/* 단일 카드 */} +
+
+ {current!.type === 'phone' ? '전화번호' : '계좌번호'} +
+
{current!.value}
+
+ 최근 신고일: {current!.lastReported || '-'} +
+
+ + {/* 내비게이션 */} +
+ +
+ {reports.slice(0, 5).map((_, i) => ( + + ))} +
+ +
+
+ )} +
+ ); +}; + export default VoicephishingPage;