giftcon_dev/resources/views/web/auth/find_id.blade.php
2026-01-15 11:15:26 +09:00

391 lines
15 KiB
PHP

@extends('web.layouts.auth')
@section('title', '아이디 찾기 | PIN FOR YOU')
@section('meta_description', 'PIN FOR YOU 아이디(이메일) 찾기 페이지입니다.')
@section('canonical', url('/auth/find-id'))
@section('h1', '아이디 찾기')
@section('desc', '가입 시 등록한 휴대폰 번호로 아이디를 확인할 수 있어요.')
@section('card_aria', '아이디 찾기 폼')
@section('show_cs_links', true)
@section('auth_content')
<form class="auth-form" id="findIdForm" onsubmit="return false;">
{{-- STEP 1 --}}
<div class="auth-panel is-active" data-step="1">
<div class="auth-field">
<label class="auth-label" for="fi_phone">휴대폰 번호</label>
<input class="auth-input" id="fi_phone" type="tel" placeholder="010-0000-0000" autocomplete="tel" value="{{ $phone ? preg_replace('/(\d{3})(\d{4})(\d{4})/', '$1-$2-$3', $phone) : '' }}">
<div class="auth-help">본인 인증 , 가입된 아이디(이메일) 안내합니다.</div>
</div>
<div class="auth-actions">
<button class="auth-btn auth-btn--primary" type="button" data-next>인증번호 받기</button>
<a class="auth-btn auth-btn--ghost" href="{{ route('web.auth.login') }}">로그인으로 돌아가기</a>
</div>
</div>
{{-- STEP 2 --}}
<div class="auth-panel" data-step="2">
<div class="auth-field">
<label class="auth-label" for="fi_code">인증번호</label>
<input class="auth-input" id="fi_code" type="text" placeholder="6자리 인증번호" inputmode="numeric">
<div class="auth-help"> 현재는 UI만 구성되어 있어 실제 발송/검증은 동작하지 않습니다.</div>
</div>
<div class="auth-actions">
<button class="auth-btn auth-btn--primary" type="button" data-next>확인</button>
<button class="auth-btn auth-btn--ghost" type="button" data-prev>이전</button>
</div>
</div>
{{-- STEP 3 --}}
<div class="auth-panel" data-step="3">
<div class="auth-field">
<label class="auth-label">확인 결과</label>
<div class="auth-help" id="findIdResult" style="font-weight:850;">
가입된 아이디는 <b>example@domain.com</b> 형태로 안내됩니다. (샘플)
</div>
</div>
<div class="auth-actions">
<a class="auth-btn auth-btn--primary" href="{{ route('web.auth.login') }}">로그인 하기</a>
<a class="auth-btn auth-btn--ghost" href="{{ route('web.auth.find_password') }}">비밀번호 찾기</a>
</div>
</div>
</form>
@endsection
@section('auth_bottom')
{{-- 필요 하단에 추가 문구/링크를 넣고 싶으면 여기 --}}
@endsection
@push('scripts')
<script>
(function(){
const root = document.getElementById('findIdForm');
if(!root) return;
const panels = root.querySelectorAll('.auth-panel');
const stepInd = document.querySelectorAll('.auth-step');
let step = Number(@json($initialStep ?? 1));
const $phone = document.getElementById('fi_phone');
const $code = document.getElementById('fi_code');
// ✅ 결과 박스는 id로 고정 (절대 흔들리지 않음)
const resultBox = document.getElementById('findIdResult');
// 메시지 영역: 항상 "현재 활성 패널"의 actions 위로 이동/생성
const mkMsg = () => {
let el = root.querySelector('.auth-msg');
if(!el){
el = document.createElement('div');
el.className = 'auth-msg';
el.style.marginTop = '10px';
el.style.fontSize = '13px';
el.style.lineHeight = '1.4';
}
const activeActions = root.querySelector('.auth-panel.is-active .auth-actions');
if(activeActions && el.parentNode !== activeActions){
activeActions.prepend(el);
}
return el;
};
const setMsg = (text, type='info') => {
const el = mkMsg();
el.textContent = text || '';
el.style.color =
(type === 'error') ? '#ff6b6b' :
(type === 'success') ? '#2ecc71' :
'#c7c7c7';
};
const render = () => {
// ✅ 1) 전환 전에 현재 포커스 제거 (경고 원인 제거)
const activeEl = document.activeElement;
if (activeEl && root.contains(activeEl)) {
activeEl.blur();
}
panels.forEach(p => {
const on = Number(p.dataset.step) === step;
p.classList.toggle('is-active', on);
p.style.display = on ? 'block' : 'none';
// ✅ 2) aria-hidden은 유지하되, 포커스/클릭 차단은 inert로 처리
// on=false인 패널은 inert 적용(포커스 못 감)
if (!on) {
p.setAttribute('aria-hidden', 'true');
p.setAttribute('inert', '');
} else {
p.setAttribute('aria-hidden', 'false');
p.removeAttribute('inert');
}
});
stepInd.forEach(s => {
const on = Number(s.dataset.stepInd) === step;
s.classList.toggle('is-active', on);
});
mkMsg();
// ✅ 3) 전환 후 포커스 이동(접근성/UX)
// 현재 step 패널의 첫 input 또는 버튼으로 포커스
const target = root.querySelector(`.auth-panel[data-step="${step}"] input, .auth-panel[data-step="${step}"] button, .auth-panel[data-step="${step}"] a`);
target?.focus?.();
};
// -------- helpers ----------
const csrf = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const postJson = async (url, data) => {
const res = await fetch(url, {
method: 'POST',
credentials: 'same-origin', // ✅ include 대신 same-origin 권장(같은 도메인일 때)
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrf(),
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
},
body: JSON.stringify(data || {})
});
const ct = res.headers.get('content-type') || '';
const raw = await res.text(); // ✅ 먼저 text로 받는다
let json = null;
if (ct.includes('application/json')) {
try { json = JSON.parse(raw); } catch (e) {}
}
if (!res.ok) {
const msg = json?.message || `요청 실패 (${res.status})`;
const err = new Error(msg);
err.status = res.status;
err.payload = json;
err.raw = raw;
throw err;
}
return json ?? { ok: true };
};
const normalizePhone = (v) => (v || '').replace(/[^\d]/g,'');
const formatPhone = (digits) => {
if(!digits) return '';
if(digits.length === 11) return digits.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3');
if(digits.length === 10) return digits.replace(/(\d{3})(\d{3,4})(\d{4})/, '$1-$2-$3');
return digits;
};
// -------- timer ----------
let timerId = null;
let remain = 0;
const ensureTimerUI = () => {
let wrap = root.querySelector('.fi-timer');
if(!wrap){
wrap = document.createElement('div');
wrap.className = 'fi-timer';
wrap.style.marginTop = '8px';
wrap.style.display = 'flex';
wrap.style.gap = '10px';
wrap.style.alignItems = 'center';
const t = document.createElement('span');
t.className = 'fi-timer__text';
t.style.fontSize = '13px';
t.style.color = '#c7c7c7';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'auth-btn auth-btn--ghost fi-resend';
btn.textContent = '재전송';
btn.style.padding = '10px 12px';
wrap.appendChild(t);
wrap.appendChild(btn);
const step2Field = root.querySelector('[data-step="2"] .auth-field');
step2Field?.appendChild(wrap);
}
return wrap;
};
const tick = () => {
const wrap = ensureTimerUI();
const t = wrap.querySelector('.fi-timer__text');
const btn = wrap.querySelector('.fi-resend');
const mm = String(Math.floor(remain/60)).padStart(2,'0');
const ss = String(remain%60).padStart(2,'0');
t.textContent = remain > 0 ? `유효시간 ${mm}:${ss}` : '인증번호가 만료되었습니다. 재전송 해주세요.';
btn.disabled = remain > 0;
if(remain <= 0){
clearInterval(timerId);
timerId = null;
return;
}
remain -= 1;
};
const startTimer = (sec) => {
remain = Number(sec || 180);
if(timerId) clearInterval(timerId);
timerId = setInterval(tick, 1000);
tick();
};
// -------- actions ----------
const sendCode = async () => {
const raw = $phone?.value || '';
const digits = normalizePhone(raw);
if(!digits){
setMsg('휴대폰 번호를 입력해 주세요.', 'error');
return;
}
setMsg('확인 중입니다...', 'info');
try {
const json = await postJson(@json(route('web.auth.find_id.send_code')), { phone: raw });
// ✅ 성공 (ok true)
setMsg(json.message || '인증번호를 발송했습니다.', 'success');
step = 2;
render();
startTimer(json.expires_in || 180);
if(json.dev_code){
setMsg(`(개발용) 인증번호: ${json.dev_code}`, 'info');
}
} catch (err) {
// ✅ 여기서 404(PHONE_NOT_FOUND)도 UX로 처리
const p = err.payload || {};
if (err.status === 404 && p.code === 'PHONE_NOT_FOUND') {
// step 1 유지
step = 1;
render();
setMsg(p.message || '해당 번호로 가입된 계정을 찾을 수 없습니다.', 'error');
return;
}
// 기타 에러(429/422/500 등)
setMsg(err.message || '오류가 발생했습니다.', 'error');
}
};
const verifyCode = async () => {
const code = ($code?.value || '').trim();
if(!/^\d{6}$/.test(code)){
setMsg('인증번호 6자리를 입력해 주세요.', 'error');
return;
}
setMsg('인증 확인 중입니다...', 'info');
const json = await postJson(@json(route('web.auth.find_id.verify')), { code });
// ✅ 먼저 step 이동 + 렌더 (패널 표시 보장)
step = 3;
render();
// ✅ 결과 반영은 렌더 후
const maskedList = Array.isArray(json.masked_emails) ? json.masked_emails : [];
if (resultBox) {
if (maskedList.length > 0) {
// 여러 개 출력 (줄바꿈)
const html = maskedList.map(e => `<div>• <b>${e}</b></div>`).join('');
resultBox.innerHTML = `가입된 아이디는 아래와 같습니다.${html}`;
} else {
resultBox.innerHTML = `해당 번호로 가입된 계정을 찾을 수 없습니다.`;
}
}
setMsg(json.message || '인증이 완료되었습니다.', 'success');
};
// -------- events ----------
root.addEventListener('click', async (e) => {
const resend = e.target.closest('.fi-resend');
const next = e.target.closest('[data-next]');
const prev = e.target.closest('[data-prev]');
try{
if(resend){
await sendCode();
return;
}
if(next){
if(step === 1) await sendCode();
else if(step === 2) await verifyCode();
return;
}
if(prev){
step = Math.max(1, step - 1);
render();
return;
}
}catch(err){
const stepFromServer = err?.payload?.step;
if(stepFromServer){
step = Number(stepFromServer);
render();
}
setMsg(err.message || '오류가 발생했습니다.', 'error');
}
});
// input UX
if($phone){
$phone.addEventListener('input', () => {
const digits = normalizePhone($phone.value);
$phone.value = formatPhone(digits);
});
$phone.addEventListener('keydown', (e) => {
if(e.key === 'Enter'){
e.preventDefault();
root.querySelector('[data-step="1"] [data-next]')?.click();
}
});
}
if($code){
$code.addEventListener('keydown', (e) => {
if(e.key === 'Enter'){
e.preventDefault();
root.querySelector('[data-step="2"] [data-next]')?.click();
}
});
}
render();
})();
</script>
@endpush