회원가입 작업 중

This commit is contained in:
sungro815 2026-01-26 12:59:59 +09:00
parent cd9c2bb1f7
commit 28ec93ac1f
28 changed files with 2257 additions and 146 deletions

View File

@ -4,9 +4,11 @@ namespace App\Http\Controllers\Web\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Repositories\Member\MemberAuthRepository; use App\Repositories\Member\MemberAuthRepository;
use App\Services\Danal\DanalAuthtelService;
use App\Rules\RecaptchaV3Rule; use App\Rules\RecaptchaV3Rule;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Log;
class RegisterController extends Controller class RegisterController extends Controller
{ {
@ -43,30 +45,254 @@ class RegisterController extends Controller
$ip4 = $request->ip() ?: ''; $ip4 = $request->ip() ?: '';
$result = $repo->step0PhoneCheck((string)$request->input('phone'), $ip4); $result = $repo->step0PhoneCheck((string)$request->input('phone'), $ip4);
if (!$result['ok']) { if (!($result['ok'] ?? false)) {
$status = ($result['reason'] ?? '') === 'blocked' ? 403 : 422; $reason = $result['reason'] ?? 'error';
return response()->json(['ok' => false, 'message' => $result['message'] ?? '처리 실패'], $status);
// blocked류만 403, 그 외는 422(원하면 already_member는 200으로 바꿔도 됨)
$status = in_array($reason, ['blocked', 'blocked_ip'], true) ? 403 : 422;
return response()->json([
'ok' => false,
'reason' => $reason,
'message' => $result['message'] ?? '처리에 실패했습니다.',
'redirect' => $result['redirect'] ?? null,
], $status);
} }
if (($result['reason'] ?? '') === 'already_member') { $request->session()->put('signup.step', 1);
$request->session()->put('signup.phone', $result['phone']); // 필요하면
$request->session()->save();
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'reason' => 'already_member', 'reason' => $result['reason'] ?? 'ok',
'redirect' => route('web.auth.find_id'), 'message' => $result['message'] ?? '',
'redirect' => $result['redirect'] ?? null,
'phone' => $result['phone'] ?? null,
], 200);
}
public function termsSubmit(Request $request)
{
$v = Validator::make($request->all(), [
'agree_terms' => ['required', 'in:1'],
'agree_privacy' => ['required', 'in:1'],
'agree_age' => ['required', 'in:1'],
'agree_marketing' => ['nullable', 'in:1'],
], [
'agree_terms.required' => '이용약관에 동의해 주세요.',
'agree_privacy.required' => '개인정보 수집·이용에 동의해 주세요.',
'agree_age.required' => '만 14세 이상만 가입할 수 있습니다.',
]);
if ($v->fails()) {
// AJAX면 JSON으로
if ($request->expectsJson()) {
return response()->json([
'ok' => false,
'message' => $v->errors()->first(),
'errors' => $v->errors(),
], 422);
}
// 일반 submit이면 기존처럼
return back()->withErrors($v)->withInput();
}
// 약관 동의값 저장(세션)
$request->session()->put('register.terms', [
'agree_terms' => true,
'agree_privacy' => true,
'agree_age' => true,
'agree_marketing' => (bool)$request->input('agree_marketing'),
'agreed_at' => now()->toDateTimeString(),
'ip' => $request->ip(),
'ua' => substr((string)$request->userAgent(), 0, 500),
]);
$request->session()->save();
// AJAX면: 다날 준비값 생성 후 popup 정보 반환
if ($request->expectsJson()) {
$danal = app(\App\Services\Danal\DanalAuthtelService::class)->prepare([
'targetUrl' => route('web.auth.register.danal.result'),
'backUrl' => route('web.auth.register.terms'),
'cpTitle' => request()->getHost(),
]);
if (!($danal['ok'] ?? false)) {
return response()->json([
'ok' => false,
'message' => $danal['message'] ?? '본인인증 준비에 실패했습니다. 잠시 후 다시 시도해 주세요.',
], 500);
}
// 필요하면 트랜잭션 정보 세션 저장
$request->session()->put('register.danal', [
'txid' => $danal['txid'] ?? null,
'created_at' => now()->toDateTimeString(),
]);
$request->session()->save();
return response()->json([
'ok' => true,
'reason' => 'danal_ready',
'popup' => [
'url' => route('web.auth.register.danal.start'),
'fields' => $danal['fields'], // Start.php로 보낼 hidden inputs
],
]); ]);
} }
$request->session()->put('signup.phone', $result['phone']); return redirect()->route('web.auth.register.terms')
$request->session()->put('signup.step', 1); ->with('ui_dialog', [
$request->session()->put('signup.ip4', $ip4); 'type' => 'alert',
$request->session()->put('signup.ip4_c', $repo->ipToCClass($ip4)); 'title' => '완료',
$request->session()->put('signup.checked_at', now()->toDateTimeString()); 'message' => "약관 동의가 저장되었습니다.\n\n다음 단계(본인인증)를 진행합니다.",
]);
}
return response()->json([
'ok' => true, public function danalStart(Request $request)
'reason' => 'ok', {
// fields는 JSON으로 받을 것
$fieldsJson = (string)$request->input('fields', '');
$fields = json_decode($fieldsJson, true);
if (!is_array($fields) || empty($fields)) {
abort(400, 'Invalid Danal fields');
}
return view('web.auth.danal_autosubmit', [
'action' => 'https://wauth.teledit.com/Danal/WebAuth/Web/Start.php',
'fields' => $fields,
]);
}
public function danalResult(Request $request, DanalAuthtelService $danal)
{
$payload = $request->all();
if (config('app.debug')) {
Log::info('[DANAL][RESULT] payload keys', [
'method' => $request->method(),
'url' => $request->fullUrl(),
'keys' => array_keys($payload),
]);
Log::info('[DANAL][RESULT] payload', $this->maskDanalPayloadForLog($payload));
}
$tid = (string)($payload['TID'] ?? '');
if ($tid === '') {
return response()->view('web.auth.danal_finish_top', [
'ok' => false,
'message' => 'TID가 없습니다.',
'redirect' => route('web.auth.register.terms'), 'redirect' => route('web.auth.register.terms'),
]); ]);
} }
// CI와 동일: TID로 CONFIRM 호출해서 RETURNCODE를 받는다 (dndata 사용 안함)
$res = $danal->confirm($tid, 0, 1);
if (config('app.debug')) {
Log::info('[DANAL][CONFIRM] keys', ['keys' => array_keys($res)]);
Log::info('[DANAL][CONFIRM] res', $this->maskDanalPayloadForLog($res));
} }
$ok = (($res['RETURNCODE'] ?? '') === '0000');
if (!$ok) {
return response()->view('web.auth.danal_finish_top', [
'ok' => false,
'message' => ($res['RETURNMSG'] ?? '본인인증에 실패했습니다.') . ' (' . ($res['RETURNCODE'] ?? 'NO_CODE') . ')',
'redirect' => route('web.auth.register.terms'),
]);
}
/* 다날 통신 성공 후 체크*/
$normPhone = function (?string $p) {
$p = preg_replace('/\D+/', '', (string)$p);
return $p;
};
$signupPhone = $normPhone(data_get($request->session()->get('signup', []), 'phone')); //처음 입력 전화번호
$passPhone = $normPhone($res['PHONE'] ?? null); //인증받은 전화번호
// signup.phone vs PASS PHONE 비교
if ($signupPhone === '' || $passPhone === '' || $signupPhone !== $passPhone) {
$request->session()->flush();
$request->session()->save();
return response()->view('web.auth.danal_finish_top', [
'ok' => false,
'message' => '인증에 사용한 휴대폰 번호가 가입 진행 번호와 일치하지 않습니다. 다시 진행해 주세요.',
'redirect' => route('web.auth.register'),
]);
}
// 통신사 제한
$carrier = (string)($res['CARRIER'] ?? '');
$blockedCarriers = ['SKT_MVNO', 'KT_MVNO', 'LGT_MVNO']; // 알뜰폰
if (in_array($carrier, $blockedCarriers, true)) {
$request->session()->flush();
$request->session()->save();
return response()->view('web.auth.danal_finish_top', [
'ok' => false,
'message' => '죄송합니다. 알뜰폰(또는 일부 통신사) 휴대전화는 가입할 수 없습니다.',
'redirect' => route('web.auth.register'),
]);
}
// 성공: 다음 단계에서 쓸 데이터 세션 저장
$request->session()->put('register.pass_verified', true);
$request->session()->put('register.pass_payload', $res);
$request->session()->save();
return response()->view('web.auth.danal_finish_top', [
'ok' => true,
'message' => '본인인증이 완료되었습니다.',
'redirect' => route('web.auth.register.profile'),
]);
}
public function showProfileForm(Request $request)
{
if (!$request->session()->get('register.pass_verified')) {
return redirect()->route('web.auth.register.terms')
->with('ui_dialog', [
'type'=>'alert',
'title'=>'안내',
'message'=>'본인인증 후 진행해 주세요.',
]);
}
return view('web.auth.profile');
}
private function maskDanalPayloadForLog(array $payload): array
{
$masked = $payload;
$sensitiveKeys = [
'CI','DI','NAME','BIRTH','SEX','GENDER','TEL','PHONE','MOBILE',
'TID','TXID','TOKEN','ENC','CERT','SSN'
];
foreach ($masked as $k => $v) {
$keyUpper = strtoupper((string)$k);
foreach ($sensitiveKeys as $sk) {
if (str_contains($keyUpper, $sk)) {
$masked[$k] = '***';
break;
}
}
}
return $masked;
}
}

View File

@ -8,6 +8,7 @@ use App\Models\Member\MemAuthLog;
use App\Models\Member\MemInfo; use App\Models\Member\MemInfo;
use App\Models\Member\MemJoinFilter; use App\Models\Member\MemJoinFilter;
use App\Models\Member\MemJoinLog; use App\Models\Member\MemJoinLog;
use App\Support\LegacyCrypto\CiSeedCrypto;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@ -107,39 +108,18 @@ class MemberAuthRepository
* Step0: phone check + join_filter + join_log * Step0: phone check + join_filter + join_log
* ========================================================= */ * ========================================================= */
public function normalizeKoreanPhone(string $raw): ?string private function normalizeKoreanPhone(string $raw): ?string
{ {
$digits = preg_replace('/\D+/', '', $raw ?? ''); $digits = preg_replace('/\D+/', '', $raw ?? '');
if (!$digits) return null;
// 82 국제형 → 0 시작으로 변환 if (strlen($digits) !== 11) {
if (str_starts_with($digits, '82')) { return null;
$digits = '0' . substr($digits, 2);
} }
if (substr($digits, 0, 3) !== '010') {
// 010/011/016/017/018/019 + 10~11자리
if (!preg_match('/^01[016789]\d{7,8}$/', $digits)) {
return null; return null;
} }
return $digits; return $digits; // ✅ 무조건 11자리 숫자만 리턴
}
public function ipToCClass(string $ip): string
{
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return '';
}
$p = explode('.', $ip);
return count($p) === 4 ? ($p[0] . '.' . $p[1] . '.' . $p[2] . '.0') : '';
}
public function isAlreadyMemberByPhone(string $phone): bool
{
return MemInfo::query()
->where('cell_phone', $phone)
->where('dt_out', '0000-00-00 00:00:00')
->exists();
} }
/** /**
@ -196,62 +176,169 @@ class MemberAuthRepository
*/ */
public function step0PhoneCheck(string $rawPhone, string $ip4 = ''): array public function step0PhoneCheck(string $rawPhone, string $ip4 = ''): array
{ {
$base = [
'ok' => false,
'reason' => '',
'message' => '',
'redirect' => null,
'phone' => null,
'filter' => null,
'admin_phones' => [],
];
// 0) IP 필터 먼저
$ip4c = $this->ipToCClass($ip4);
$ipHit = $this->checkJoinFilterByIp($ip4, $ip4c);
if ($ipHit) {
$base['filter'] = $ipHit;
$base['admin_phones'] = $ipHit['admin_phones'] ?? [];
}
if ($ipHit && ($ipHit['join_block'] ?? '') === 'A') {
return array_merge($base, [
'ok' => false,
'reason' => 'blocked_ip',
'message' => '현재 가입이 제한된 정보입니다. 고객센터로 문의해 주세요.',
]);
}
// 1) 전화번호 검증
$phone = $this->normalizeKoreanPhone($rawPhone); $phone = $this->normalizeKoreanPhone($rawPhone);
if (!$phone) { if (!$phone) {
return [ return array_merge($base, [
'ok' => false, 'ok' => false,
'reason' => 'invalid_phone', 'reason' => 'invalid_phone',
'message' => '휴대폰 번호 형식이 올바르지 않습니다.', 'message' => '휴대폰 번호 형식이 올바르지 않습니다.',
];
}
$ip4c = $this->ipToCClass($ip4);
// already member
if ($this->isAlreadyMemberByPhone($phone)) {
$this->writeJoinLog([
'gubun' => 'already_member',
'mem_no' => 0,
'cell_phone' => $phone,
'ip4' => $ip4,
'ip4_c' => $ip4c,
'error_code' => 'J2',
]); ]);
}
$base['phone'] = $phone;
return ['ok' => true, 'reason' => 'already_member', 'phone' => $phone]; // 2) 이미 회원인지 체크
$member = $this->findMemberByPhone($phone);
if ($member && !empty($member->mem_no)) {
return array_merge($base, [
'ok' => false,
'reason' => 'already_member',
'message' => "이미 가입된 전화번호 입니다.\n\n아이디 찾기로 이동할까요?",
'redirect' => route('web.auth.find_id'), // ✅ 이미가입이면 아이디 찾기
'matched_mem_no' => (int)$member->mem_no, // 필요하면 유지
]);
} }
// join filter // 3) 기존 phone+ip 필터
$filter = $this->checkJoinFilter($phone, $ip4, $ip4c); $filter = $this->checkJoinFilter($phone, $ip4, $ip4c);
if ($filter && ($filter['block'] ?? false) === true) { if ($filter && ($filter['block'] ?? false) === true) {
$this->writeJoinLog([ return array_merge($base, [
'gubun' => $filter['gubun'] ?? 'filter_block',
'mem_no' => 0,
'cell_phone' => $phone,
'ip4' => $ip4,
'ip4_c' => $ip4c,
'error_code' => 'J1',
]);
return [
'ok' => false, 'ok' => false,
'reason' => 'blocked', 'reason' => 'blocked',
'phone' => $phone,
'filter' => $filter, 'filter' => $filter,
'message' => '현재 가입이 제한된 정보입니다. 고객센터로 문의해 주세요.', 'message' => '현재 가입이 제한된 정보입니다. 고객센터로 문의해 주세요.',
]);
}
// 4) 성공(가입 가능)
return array_merge($base, [
'ok' => true,
'reason' => 'ok',
'filter' => $filter ?: $ipHit,
'message' => "회원가입 가능한 전화번호 입니다.\n\n약관동의 페이지로 이동합니다.",
'redirect' => route('web.auth.register.terms'),
]);
}
private function findMemberByPhone(string $phone): ?object
{
/** @var CiSeedCrypto $seed */
$seed = app(CiSeedCrypto::class);
$phoneEnc = $seed->encrypt($phone);
// 실제로 매칭되는 회원 1건을 가져옴
return DB::table('mem_info')
->select('mem_no', 'cell_phone')
->where('cell_phone', $phoneEnc)
->limit(1)
->first();
}
public function ipToCClass(string $ip): string
{
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return '';
}
$p = explode('.', $ip);
return count($p) === 4 ? ($p[0] . '.' . $p[1] . '.' . $p[2] . '.0') : '';
}
private function checkJoinFilterByIp(string $ip4, string $ip4c): ?array
{$ip4 = "19dd.0";
// IPv4 아니면 필터 적용 안 함
if (!filter_var($ip4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return null;
}
// 우선순위: A(차단) > S(관리자알림) > N(무시)
$row = DB::table('mem_join_filter')
->whereIn('join_block', ['A', 'S'])
->where(function ($q) use ($ip4, $ip4c) {
// ✅ C class (보통 gubun_code='01', filter='162.168.0.0')
$q->where(function ($q2) use ($ip4c) {
$q2->where('gubun_code', '01')
->where('filter', $ip4c);
});
// ✅ D class (보통 gubun_code='02', filter='162.168.0.2')
$q->orWhere(function ($q2) use ($ip4) {
$q2->where('gubun_code', '02')
->where('filter', $ip4);
});
// ✅ (레거시) gubun_code가 01인데도 filter에 단일 IP가 들어있는 케이스 방어
$q->orWhere(function ($q2) use ($ip4) {
$q2->where('gubun_code', '01')
->where('filter', $ip4);
});
})
->orderByRaw("CASE join_block WHEN 'A' THEN 0 WHEN 'S' THEN 1 ELSE 9 END")
->limit(1)
->first();
if (!$row) return null;
$adminPhones = $this->parseAdminPhones($row->admin_phone ?? '');
return [
'gubun_code' => $row->gubun_code ?? null,
'join_block' => $row->join_block ?? null,
'filter' => $row->filter ?? null,
'admin_phones' => $adminPhones,
'ip' => $ip4,
'ip4c' => $ip4c,
]; ];
} }
// pass private function parseAdminPhones($value): array
$this->writeJoinLog([ {
'gubun' => $filter['gubun'] ?? 'ok', // admin_phone: ["010-3682-8958", ...] 형태(=JSON 배열)라고 했으니 JSON 우선
'mem_no' => 0, if (is_array($value)) return $value;
'cell_phone' => $phone,
'ip4' => $ip4,
'ip4_c' => $ip4c,
'error_code' => 'J0',
]);
return ['ok' => true, 'reason' => 'ok', 'phone' => $phone, 'filter' => $filter]; $s = trim((string)$value);
if ($s === '') return [];
$json = json_decode($s, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($json)) {
return array_values(array_filter(array_map('trim', $json)));
}
// 혹시 "010...,010..." 같은 문자열이면 콤마 분리 fallback
$parts = array_map('trim', explode(',', $s));
return array_values(array_filter($parts));
} }
} }

View File

@ -0,0 +1,222 @@
<?php
namespace App\Services\Danal;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
/**
* Danal PASS(Authtel) 준비 서비스
*
* - prepare(): 다날 Start 페이지로 POST할 hidden fields를 구성
* - callTrans / makeFormFields / getRandom 등은 별도 Client로 분리하는 이상적이지만,
* 지금은 "작동 우선"으로 파일 하나로 정리할 있게 구성합니다.
*/
final class DanalAuthtelService
{
private string $serviceUrl;
private string $cpid;
private string $cppwd;
private string $charset;
private int $connectTimeout;
private int $timeout;
private bool $debug;
public function __construct()
{
$cfg = (array) config('danal.authtel', []);
$this->serviceUrl = (string) Arr::get($cfg, 'service_url', '');
$this->cpid = (string) Arr::get($cfg, 'cpid', '');
$this->cppwd = (string) Arr::get($cfg, 'cppwd', '');
$this->charset = (string) Arr::get($cfg, 'charset', 'UTF-8');
$this->connectTimeout = (int) Arr::get($cfg, 'connect_timeout', 5);
$this->timeout = (int) Arr::get($cfg, 'timeout', 30);
$this->debug = (bool) Arr::get($cfg, 'debug', false);
}
public function prepare(array $opt): array
{
// 필수 설정 체크
if ($this->cpid === '' || $this->cppwd === '' || $this->serviceUrl === '') {
return ['ok' => false, 'message' => 'DANAL 설정(CPID/CPPWD/SERVICE_URL)이 없습니다.'];
}
$targetUrl = (string) ($opt['targetUrl'] ?? '');
$backUrl = (string) ($opt['backUrl'] ?? '');
if ($targetUrl === '') return ['ok' => false, 'message' => 'targetUrl 누락'];
if ($backUrl === '') return ['ok' => false, 'message' => 'backUrl 누락'];
// 기본 타이틀
$cpTitle = (string) ($opt['cpTitle'] ?? request()->getHost());
// TransR
$trans = [
'TXTYPE' => 'ITEMSEND',
'SERVICE' => 'UAS',
'AUTHTYPE' => '36',
'CPID' => $this->cpid,
'CPPWD' => $this->cppwd,
'TARGETURL' => $targetUrl,
'CPTITLE' => $cpTitle,
];
// 다날 서버 통신
$res = $this->callTrans($trans);
if (($res['RETURNCODE'] ?? '') !== '0000') {
return [
'ok' => false,
'message' => $this->formatReturnMsg($res),
];
}
// CI3의 ByPassValue
$byPass = [
// CI의 GetBgColor(0~10 => "00"~"10") 느낌 유지
'BgColor' => $this->getBgColor($this->getRandom(0, 10)),
'BackURL' => $backUrl,
'IsCharSet' => $this->charset,
'ByBuffer' => 'This value bypass to CPCGI Page',
'ByAnyName' => 'AnyValue',
];
// Start.php로 보낼 hidden fields 구성
// - 여기서는 절대 e()/htmlspecialchars 같은 escape를 하지 마세요.
// - 프론트에서 input.value로 넣으면 브라우저가 처리합니다.
$fields = array_merge(
$this->makeFormFields($res, ['RETURNCODE', 'RETURNMSG']),
$this->makeFormFields($byPass)
);
return [
'ok' => true,
'txid' => $res['TID'] ?? $res['TXID'] ?? null,
'fields' => $fields,
];
}
/**
* 다날 서버 통신
* @return array<string,string>
*/
public function callTrans(array $reqData): array
{
// x-www-form-urlencoded
$body = http_build_query($reqData, '', '&', PHP_QUERY_RFC3986);
$ch = curl_init();
curl_setopt($ch, CURLOPT_POST, 1);
// SSL 검증은 끄지 마세요 (운영 보안)
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 1);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->connectTimeout);
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
curl_setopt($ch, CURLOPT_URL, $this->serviceUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/x-www-form-urlencoded; charset=' . $this->charset,
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$resStr = curl_exec($ch);
if (($errno = curl_errno($ch)) !== 0) {
$err = curl_error($ch) ?: 'curl error';
curl_close($ch);
return [
'RETURNCODE' => '-1',
'RETURNMSG' => 'NETWORK ERROR(' . $errno . ':' . $err . ')',
];
}
curl_close($ch);
// 다날 응답 파싱
return $this->str2data((string) $resStr);
}
/** @return array<string,string> */
private function str2data(string $str): array
{
$data = [];
foreach (explode('&', $str) as $line) {
$kv = explode('=', $line, 2);
if (count($kv) === 2) {
$data[$kv[0]] = $kv[1];
}
}
return $data;
}
/**
* hidden fields 만들기 (escape 하지 않음)
* @param array<string, mixed> $arr
* @param array<int, string> $excludeKeys
* @return array<string, string>
*/
private function makeFormFields(array $arr, array $excludeKeys = [], string $prefix = ''): array
{
$out = [];
$preLen = strlen(trim($prefix));
foreach ($arr as $key => $value) {
$key = (string) $key;
if ($key === '' || trim($key) === '') continue;
if (in_array($key, $excludeKeys, true)) continue;
if ($preLen > 0 && substr($key, 0, $preLen) !== $prefix) continue;
// 다날은 value에 urlencoded가 들어갈 수 있음
// 그대로 보내는 게 원칙이지만, 필요 시 여기서 정책적으로 urldecode/encode 조절 가능
$out[$key] = is_scalar($value) || $value === null ? (string) $value : json_encode($value, JSON_UNESCAPED_UNICODE);
}
return $out;
}
private function getBgColor($bgColor): string
{
$color = 0;
$i = (int) $bgColor;
if ($i > 0 && $i < 11) $color = $i;
return sprintf('%02d', $color);
}
private function getRandom(int $min, int $max): int
{
return random_int($min, $max);
}
/** @param array<string,string> $res */
private function formatReturnMsg(array $res): string
{
$msg = (string)($res['RETURNMSG'] ?? 'DANAL ERROR');
$code = (string)($res['RETURNCODE'] ?? 'NO_CODE');
return $msg . ' (' . $code . ')';
}
public function confirm(string $tid, int $confirmOption = 0, int $idenOption = 1): array
{
$req = [
'TXTYPE' => 'CONFIRM',
'TID' => $tid,
'CONFIRMOPTION' => $confirmOption,
'IDENOPTION' => $idenOption,
];
// CI 주석: CONFIRMOPTION=1이면 CPID/ORDERID 필수
if ($confirmOption === 1) {
$req['CPID'] = $this->cpid;
}
return $this->callTrans($req);
}
}

View File

@ -22,7 +22,7 @@ return Application::configure(basePath: dirname(__DIR__))
}, },
) )
// (B) Middleware: Reverse Proxy/Host 신뢰 정책 // (B) Middleware: Reverse Proxy/Host 신뢰 정책 + CSRF 예외
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: [ $middleware->trustProxies(at: [
'192.168.100.0/24', '192.168.100.0/24',
@ -35,6 +35,13 @@ return Application::configure(basePath: dirname(__DIR__))
'four.syye.net', 'four.syye.net',
'shot.syye.net', 'shot.syye.net',
]); ]);
// CSRF 예외 처리
// - 도메인 제외, path만
// - 네 라우트 정의 기준: POST register/danal/result
$middleware->validateCsrfTokens(except: [
'auth/register/danal/result', //다날 PASS 콜백 (외부 서버가 호출)
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {

15
config/danal.php Normal file
View File

@ -0,0 +1,15 @@
<?php
return [
'authtel' => [
// CI3 CallTrans가 호출하던 다날 "CPCGI" URL
'service_url' => env('DANAL_AUTHTEL_SERVICE_URL', ''),
'charset' => env('DANAL_AUTHTEL_CHARSET', 'UTF-8'),
'connect_timeout' => env('DANAL_AUTHTEL_CONNECT_TIMEOUT', 5),
'timeout' => env('DANAL_AUTHTEL_TIMEOUT', 30),
'debug' => env('DANAL_AUTHTEL_DEBUG', false),
'cpid' => env('DANAL_AUTHTEL_CPID', ''),
'cppwd' => env('DANAL_AUTHTEL_CPPWD', ''),
],
];

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

View File

@ -0,0 +1,53 @@
.ui-dialog { position: fixed; inset: 0; z-index: 9999; display: none; }
.ui-dialog.is-open { display: block; }
.ui-dialog__backdrop {
position: absolute; inset: 0;
background: rgba(0,0,0,.55);
backdrop-filter: blur(2px);
}
.ui-dialog__panel {
position: relative;
width: min(520px, calc(100% - 32px));
margin: 12vh auto 0;
background: #111;
color: #fff;
border: 1px solid rgba(255,255,255,.12);
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,.55);
overflow: hidden;
}
.ui-dialog { position: fixed; inset: 0; z-index: 200000; display: none; }
.ui-dialog__header { display:flex; align-items:center; justify-content:space-between; padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,.10); }
.ui-dialog__title { margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -0.2px; }
.ui-dialog__x {
width: 36px; height: 36px; border: 0; background: transparent; color: rgba(255,255,255,.8);
font-size: 22px; cursor: pointer; border-radius: 10px;
}
.ui-dialog__x:hover { background: rgba(255,255,255,.08); }
.ui-dialog__body { padding: 16px; }
.ui-dialog__message { margin: 0; white-space: pre-line; line-height: 1.6; color: rgba(255,255,255,.92); }
.ui-dialog__footer { display:flex; gap: 10px; justify-content:flex-end; padding: 14px 16px; border-top: 1px solid rgba(255,255,255,.10); }
.ui-dialog__btn {
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,.16);
background: rgba(255,255,255,.06);
color: #fff;
cursor: pointer;
font-weight: 600;
}
.ui-dialog__btn:hover { background: rgba(255,255,255,.10); }
.ui-dialog__btn--ok { background: rgba(99,102,241,.22); border-color: rgba(99,102,241,.45); }
.ui-dialog__btn--ok:hover { background: rgba(99,102,241,.30); }
.ui-dialog__btn--danger { background: rgba(239,68,68,.20); border-color: rgba(239,68,68,.45); }
.ui-dialog__btn--danger:hover { background: rgba(239,68,68,.28); }
.ui-dialog__btn[disabled] { opacity: .6; cursor: not-allowed; }

243
resources/js/ui/dialog.js Normal file
View File

@ -0,0 +1,243 @@
class UiDialog {
constructor(rootId = "uiDialog") {
this.root = document.getElementById(rootId);
if (!this.root) return;
this.titleEl = this.root.querySelector("#uiDialogTitle");
this.msgEl = this.root.querySelector("#uiDialogMessage");
this.okBtn = this.root.querySelector("#uiDialogOk");
this.cancelBtn = this.root.querySelector("#uiDialogCancel");
this._resolver = null;
this._type = "alert";
this._lastFocus = null;
// close triggers
this.root.addEventListener("click", (e) => {
if (!e.target?.dataset?.uidialogClose) return;
// 어떤 close인지 구분 (backdrop / x)
const isBackdrop = !!e.target.closest(".ui-dialog__backdrop");
const isX = !!e.target.closest(".ui-dialog__x");
// 정책에 따라 차단 (기본: 모두 차단)
if (isBackdrop && !this._closePolicy?.backdrop) return;
if (isX && !this._closePolicy?.x) return;
this._resolve(false);
});
// keyboard
document.addEventListener("keydown", (e) => {
if (!this.isOpen()) return;
if (e.key === "Escape") {
if (this._closePolicy?.esc) this._resolve(false);
return;
}
if (e.key === "Enter") {
// Enter는 OK로 처리 (기본 true)
if (this._closePolicy?.enter !== false) this._resolve(true);
return;
}
});
this.okBtn.addEventListener("click", () => this._resolve(true));
this.cancelBtn.addEventListener("click", () => this._resolve(false));
}
isOpen() {
return !!this.root && this.root.classList.contains("is-open");
}
alert(message, options = {}) {
return this._open("alert", message, options);
}
confirm(message, options = {}) {
return this._open("confirm", message, options);
}
_open(type, message, options) {
if (!this.root) return Promise.resolve(false);
const {
title = type === "confirm" ? "확인" : "알림",
okText = "확인",
cancelText = "취소",
dangerous = false,
// ✅ 기본: 밖 클릭/닫기(X)/ESC로 닫기 금지
closeOnBackdrop = false,
closeOnX = false,
closeOnEsc = false,
closeOnEnter = true,
} = options;
this._closePolicy = {
backdrop: !!closeOnBackdrop,
x: !!closeOnX,
esc: !!closeOnEsc,
enter: closeOnEnter !== false,
};
this._type = type;
this._lastFocus = document.activeElement;
this.titleEl.textContent = title;
this.msgEl.textContent = message ?? "";
this.okBtn.textContent = okText;
this.cancelBtn.textContent = cancelText;
// alert면 cancel 숨김
if (type === "alert") {
this.cancelBtn.style.display = "none";
} else {
this.cancelBtn.style.display = "";
}
// danger 스타일
this.okBtn.classList.toggle("ui-dialog__btn--danger", !!dangerous);
// open
this.root.classList.add("is-open");
this.root.setAttribute("aria-hidden", "false");
document.documentElement.style.overflow = "hidden";
// focus
setTimeout(() => this.okBtn.focus(), 0);
return new Promise((resolve) => {
this._resolver = resolve;
});
}
_resolve(ok) {
if (!this.isOpen()) return;
// close
this.root.classList.remove("is-open");
this.root.setAttribute("aria-hidden", "true");
document.documentElement.style.overflow = "";
const r = this._resolver;
this._resolver = null;
// focus restore
try { this._lastFocus?.focus?.(); } catch (e) {}
if (typeof r === "function") r(!!ok);
}
}
// 전역으로 노출 (모든 페이지에서 바로 호출)
window.uiDialog = new UiDialog();
// ======================================================
// ✅ Global showMsg / clearMsg (공통 사용)
// - 페이지에서는 showMsg/clearMsg만 호출
// - await showMsg(...) 하면 버튼 클릭 전까지 다음 코드 진행이 멈춤
// ======================================================
(function () {
let cachedHelpEl = null;
function getHelpEl(opt = {}) {
// opt.helpId를 주면 그걸 우선 사용
if (opt.helpId) {
const el = document.getElementById(opt.helpId);
if (el) return el;
}
// 기본 fallback id (너가 쓰는 reg_phone_help)
if (cachedHelpEl && document.contains(cachedHelpEl)) return cachedHelpEl;
cachedHelpEl = document.getElementById("reg_phone_help");
return cachedHelpEl;
}
/**
* showMsg(msg, opt)
* opt: {
* type:'alert'|'confirm',
* title, okText, cancelText, dangerous,
* redirect, helpId
* }
* return: Promise<boolean> (confirm: true/false, alert: true)
*/
window.showMsg = async function (msg, opt = {}) {
const d = Object.assign({
type: "alert",
title: "알림",
okText: "확인",
cancelText: "취소",
dangerous: false,
redirect: "",
helpId: "",
}, opt || {});
// ✅ 모달이 있으면 모달로 (여기서 await로 멈춤)
if (window.uiDialog && typeof window.uiDialog[d.type] === "function") {
const ok = await window.uiDialog[d.type](msg || "", d);
if (d.redirect) {
// alert: OK(true)일 때만 / confirm: OK(true)일 때만
if (ok) {
window.location.href = d.redirect;
}
}
return d.type === "confirm" ? !!ok : true;
}
// ✅ fallback (모달이 없으면 help 영역에 표시)
const helpEl = getHelpEl(d);
if (helpEl) {
helpEl.style.display = "block";
helpEl.textContent = msg || "";
return true;
}
// 마지막 fallback
alert(msg || "");
return true;
};
/**
* clearMsg(helpId?)
*/
window.clearMsg = function (helpId = "") {
const el = helpId ? document.getElementById(helpId) : getHelpEl({});
if (!el) return;
el.style.display = "none";
el.textContent = "";
};
})();
// ======================================================
// 서버 flash 자동 실행 (기존 유지)
// ======================================================
document.addEventListener("DOMContentLoaded", async () => {
const flashEl = document.getElementById("uiDialogFlash");
if (!flashEl || !window.uiDialog) return;
let payload = null;
try { payload = JSON.parse(flashEl.textContent || "{}"); } catch (e) {}
if (!payload) return;
/**
* payload 예시:
* { type:'alert'|'confirm', message:'...', title:'...', okText:'...', cancelText:'...', redirect:'/...' }
*/
const type = payload.type || "alert";
const ok = await window.uiDialog[type](payload.message || "", payload);
// confirm이고 OK일 때만 redirect 같은 후속 처리
if (type === "confirm") {
if (ok && payload.redirect) window.location.href = payload.redirect;
} else {
if (payload.redirect) window.location.href = payload.redirect;
}
});

View File

@ -1,3 +1,6 @@
import "../css/ui-dialog.css";
import "./ui/dialog";
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// --- Hero Carousel Logic --- // --- Hero Carousel Logic ---

View File

@ -0,0 +1,28 @@
{{-- resources/views/web/auth/danal_autosubmit.blade.php --}}
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>본인인증 이동</title>
</head>
<body>
<div style="padding:16px;font-family:sans-serif;">
본인인증 페이지로 이동 중입니다. 잠시만 기다려 주세요…
</div>
<form id="danalAutoSubmitForm" method="post" action="{{ $action }}">
@foreach(($fields ?? []) as $k => $v)
<input type="hidden" name="{{ $k }}" value="{{ $v }}">
@endforeach
<noscript>
<button type="submit">계속</button>
</noscript>
</form>
<script>
document.getElementById('danalAutoSubmitForm').submit();
</script>
</body>
</html>

View File

@ -0,0 +1,32 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>처리 결과</title>
</head>
<body>
<script>
(function () {
var ok = @json($ok ?? false);
var message = @json($message ?? '');
var redirect = @json($redirect ?? '/');
if (message) {
alert(message);
}
try {
if (window.top) window.top.location.href = redirect;
else window.location.href = redirect;
} catch (e) {
window.location.href = redirect;
}
})();
</script>
<noscript>
<a href="{{ $redirect }}">계속</a>
</noscript>
</body>
</html>

View File

@ -12,6 +12,13 @@
@section('auth_content') @section('auth_content')
<form class="auth-form" id="findIdForm" onsubmit="return false;"> <form class="auth-form" id="findIdForm" onsubmit="return false;">
{{-- STEP 1 --}} {{-- STEP 1 --}}
<img
class="reg-step0-hero__img"
src="{{ asset('assets/images/web/member/idpwfind.webp') }}"
alt=""
loading="lazy"
onerror="this.style.display='none';"
/>
<div class="auth-panel is-active" data-step="1"> <div class="auth-panel is-active" data-step="1">
<div class="auth-field"> <div class="auth-field">
<label class="auth-label" for="fi_phone">휴대폰 번호</label> <label class="auth-label" for="fi_phone">휴대폰 번호</label>

View File

@ -12,6 +12,14 @@
@section('auth_content') @section('auth_content')
<form class="auth-form" onsubmit="return false;"> <form class="auth-form" onsubmit="return false;">
<img
class="reg-step0-hero__img"
src="{{ asset('assets/images/web/member/login.webp') }}"
alt=""
loading="lazy"
onerror="this.style.display='none';"
/>
<div class="auth-field"> <div class="auth-field">
<label class="auth-label" for="login_id">아이디(이메일)</label> <label class="auth-label" for="login_id">아이디(이메일)</label>
<input class="auth-input" id="login_id" type="email" placeholder="example@domain.com" autocomplete="username"> <input class="auth-input" id="login_id" type="email" placeholder="example@domain.com" autocomplete="username">

View File

@ -0,0 +1,25 @@
@extends('web.layouts.auth')
@section('title', '본인인증 이동 | PIN FOR YOU')
@section('content')
<div class="auth-card">
<h2>본인인증으로 이동 중입니다…</h2>
<p>잠시만 기다려 주세요.</p>
<form id="danalPassForm" method="POST" action="{{ $actionUrl }}">
{{-- 다날로 POST할 CSRF는 "우리 사이트용"이라 필요 없음 --}}
@foreach($inputs as $k => $v)
<input type="hidden" name="{{ $k }}" value="{{ $v }}">
@endforeach
<noscript>
<button type="submit">계속</button>
</noscript>
</form>
</div>
<script>
document.getElementById('danalPassForm').submit();
</script>
@endsection

View File

@ -0,0 +1,36 @@
@extends('web.layouts.auth')
@section('title', '회원정보 입력 | PIN FOR YOU')
@section('content')
<div class="auth-card">
<h2 class="auth-title">회원정보 입력</h2>
<p class="auth-desc">계정 생성을 위해 필요한 정보를 입력해 주세요.</p>
<form method="POST" action="{{ route('web.auth.register.profile.submit') }}">
@csrf
<div class="auth-field">
<label for="login_id">아이디</label>
<input id="login_id" name="login_id" type="text" autocomplete="username" required>
</div>
<div class="auth-field">
<label for="password">비밀번호(6자리 숫자)</label>
<input id="password" name="password" type="password" inputmode="numeric" pattern="\d{6}" maxlength="6" required>
</div>
<div class="auth-field">
<label for="password_confirmation">비밀번호 확인</label>
<input id="password_confirmation" name="password_confirmation" type="password" inputmode="numeric" pattern="\d{6}" maxlength="6" required>
</div>
<div class="auth-field">
<label for="email">이메일</label>
<input id="email" name="email" type="email" autocomplete="email" required>
</div>
<button type="submit" class="auth-btn auth-btn--primary">가입 완료</button>
</form>
</div>
@endsection

View File

@ -5,51 +5,23 @@
@section('canonical', url('/auth/register')) @section('canonical', url('/auth/register'))
@section('h1', '회원가입') @section('h1', '회원가입')
@section('desc', '휴대폰 번호 확인 후 본인인증을 진행합니다.') @section('desc', '휴대폰 번호로 가입 여부를 먼저 확인한 뒤, PASS 본인인증을 진행합니다.')
@section('card_aria', '회원가입 Step0 - 휴대폰 확인') @section('card_aria', '회원가입 Step0 - 휴대폰 확인')
@section('show_cs_links', true) @section('show_cs_links', true)
@section('auth_content') @section('auth_content')
{{-- Step0 Hero Image + 안내문구 --}} {{-- Step0 Hero Image + 안내문구 --}}
<div class="reg-step0-hero" aria-hidden="true">
<img <img
class="reg-step0-hero__img" class="reg-step0-hero__img"
src="{{ asset('assets/images/web/member/register_step0.webp') }}" src="{{ asset('assets/images/web/member/register.webp') }}"
alt="" alt=""
loading="lazy" loading="lazy"
onerror="this.style.display='none';" onerror="this.style.display='none';"
style="padding-bottom:15px"
/> />
<div class="reg-step0-hero__text" aria-hidden="true">
<div class="reg-step0-hero__line">휴대폰 번호로 가입 여부를 먼저 확인한 ,</div>
<div class="reg-step0-hero__line">PASS 본인인증을 진행합니다.</div>
</div>
</div>
<style>
.reg-step0-hero{
display:flex;
flex-direction:column;
align-items:center;
gap:10px;
margin: 6px 0 14px;
}
.reg-step0-hero__img{
width:100%;
max-width: 300px;
height:auto;
border-radius: 14px;
box-shadow: 0 10px 30px rgba(0,0,0,.08);
background: #fff;
}
.reg-step0-hero__text{
text-align:center;
font-size: 13px;
line-height: 1.4;
color: rgba(0,0,0,.65);
}
.reg-step0-hero__line{ margin: 0; }
</style>
<form class="auth-form" id="regStep0Form" onsubmit="return false;"> <form class="auth-form" id="regStep0Form" onsubmit="return false;">
@csrf @csrf
@ -91,15 +63,6 @@
const help = document.getElementById('reg_phone_help'); const help = document.getElementById('reg_phone_help');
const btn = document.getElementById('reg_next_btn'); const btn = document.getElementById('reg_next_btn');
function showMsg(msg) {
help.style.display = 'block';
help.textContent = msg;
}
function clearMsg() {
help.style.display = 'none';
help.textContent = '';
}
// 숫자만 남기고 010-0000-0000 형태로 포맷 // 숫자만 남기고 010-0000-0000 형태로 포맷
function formatPhone(value) { function formatPhone(value) {
const digits = (value || '').replace(/\D/g, '').slice(0, 11); const digits = (value || '').replace(/\D/g, '').slice(0, 11);
@ -137,12 +100,12 @@
const phoneDigits = toDigits(input.value); const phoneDigits = toDigits(input.value);
if (!phoneDigits) { if (!phoneDigits) {
showMsg('휴대폰 번호를 입력해 주세요.'); await showMsg('휴대폰 번호를 입력해 주세요.', { type:'alert', title:'입력오류' });
input.focus(); input.focus();
return; return;
} }
if (phoneDigits.length < 10) { if (phoneDigits.length < 10) {
showMsg('휴대폰 번호를 끝까지 입력해 주세요.'); await showMsg('휴대폰 번호를 끝까지 입력해 주세요.', { type:'alert', title:'입력오류' });
input.focus(); input.focus();
return; return;
} }
@ -166,21 +129,49 @@
}) })
}); });
const data = await res.json().catch(() => ({})); // 422/403에서도 JSON 잘 파싱되게 안전 처리
const raw = await res.text();
let data = {};
try { data = JSON.parse(raw || "{}"); } catch (e) { data = {}; }
if (!res.ok || data.ok === false) { // 공통 메시지
showMsg(data.message || '처리에 실패했습니다.'); const msg = data.message || '처리에 실패했습니다.';
// 1) 이미 가입(=confirm)
if (data.reason === 'already_member') {
await showMsg(msg, {
type: 'confirm',
title: '안내',
okText: '이동',
cancelText: '취소',
redirect: data.redirect,
});
return; return;
} }
if (data.redirect) { // 2) 성공(=confirm)
window.location.href = data.redirect; if (data.ok === true) {
await showMsg(msg || '회원가입 가능한 전화번호 입니다.\n\n인증페이지로 이동합니다.', {
type: 'confirm',
title: '안내',
okText: '이동',
cancelText: '취소',
redirect: data.redirect,
});
return; return;
} }
showMsg('처리에 실패했습니다.'); // 3) 나머지 전부 실패(=alert)
await showMsg(msg, {
type: 'alert',
title: '안내',
});
} catch (e) { } catch (e) {
showMsg('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'); await showMsg("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", {
type:'alert',
title:'네트워크오류'
});
} finally { } finally {
btn.disabled = false; btn.disabled = false;
} }

View File

@ -5,18 +5,796 @@
@section('canonical', url('/auth/register/terms')) @section('canonical', url('/auth/register/terms'))
@section('h1', '약관 동의') @section('h1', '약관 동의')
@section('desc', '다음 단계에서 본인인증을 진행합니다.') @section('desc', '필수 약관에 동의하시면 다음 단계(본인인증)로 진행합니다.')
@section('card_aria', '회원가입 Step1 - 약관 동의') @section('card_aria', '회원가입 Step1 - 약관 동의')
@section('show_cs_links', true) @section('show_cs_links', true)
@section('auth_content') @section('auth_content')
<div class="auth-divider">임시 페이지</div> <div class="terms-wrap">
<p style="opacity:.8; line-height:1.6;"> {{-- 진행 단계 --}}
Step0(휴대폰 확인) 통과했습니다.<br> <div class="terms-steps" aria-label="진행 단계">
다음 단계에서 약관 UI와 저장 로직을 붙일 예정입니다. <div class="terms-step is-active">약관 동의</div>
<div class="terms-step">본인인증</div>
<div class="terms-step">가입정보</div>
</div>
{{-- <form id="regTermsForm" class="terms-form" method="POST" action="{{ route('web.auth.register.terms.submit') }}" novalidate>--}}
<form id="regTermsForm" class="terms-form" method="POST" action="" novalidate>
@csrf
{{-- 전체 동의 --}}
<div class="terms-card terms-card--all">
<label class="chk">
<input type="checkbox" id="agree_all">
<span class="chk-ui" aria-hidden="true"></span>
<span class="chk-text">
<span class="chk-title">전체 동의</span>
<span class="chk-sub">필수 선택 항목을 모두 포함합니다.</span>
</span>
</label>
</div>
{{-- 약관 리스트 --}}
<div class="terms-card">
{{-- [필수] 이용약관 --}}
<div class="terms-row">
<label class="chk">
<input type="checkbox" name="agree_terms" id="agree_terms" value="1" required>
<span class="chk-ui" aria-hidden="true"></span>
<span class="chk-text">
<span class="badge badge--req">필수</span>
<span class="terms-label">이용약관 동의</span>
</span>
</label>
<button type="button" class="terms-view" data-toggle="t_terms">내용보기</button>
</div>
<div class="terms-body" id="t_terms" hidden>
<div class="terms-scroll">
<h4>이용약관</h4>
<div class="terms-legal">
<h5> 1 (목적)</h5>
<p> 약관은 ㈜플러스메이커(이하 "회사") 제공하는 핀포유(이하 “사이트”) 관련 제반 서비스 (이하 "서비스") 이용조건 절차에 관한 회사와 회원간의 권리 의무 책임사항, 기타 필요한 사항을 규정함을 목적으로 합니다.</p>
<h5> 2 (약관의 효력 변경)</h5>
<p> 약관은 서비스 화면이나 기타의 방법으로 이용고객에게 공지함으로써 효력을 발생합니다.</p>
<p> 사이트는 약관의 내용을 변경할 있으며, 변경된 약관은 제①항과 같은 방법으로 공지 또는 통지함으로써 효력을 발생합니다.</p>
<h5> 3 (용어의 정의)</h5>
<p> 약관에서 사용하는 용어의 정의는 다음과 같습니다.</p>
<p> 회원 : 사이트와 서비스 이용계약을 체결하거나 이용자 아이디(ID) 부여받은 개인을 말합니다.</p>
<p> 아이디(ID) : 회원의 식별과 서비스 이용을 위하여 회원이 정하고 사이트가 승인하는 이메일 형식을 말합니다</p>
<p> 비밀번호 : 회원이 부여 받은 아이디(ID) 일치된 회원임을 확인하고, 회원 자신의 비밀을 보호하기 위하여 회원이 정한 영문자와 숫자, 특수문자의 조합을 말합니다.</p>
<p> 회원탈퇴 : 사이트 또는 회원이 서비스 이용계약을 취소하는 것을 말합니다.</p>
<h5> 4 (회원가입)</h5>
<p> 사이트에서 제공하는 절차에 맞춰 이용약관 동의 본인인증 절차를 완료한 , 가입 양식에 따라 회원정보를 기입함으로서 회원가입을 신청합니다.</p>
<p> 사이트는 신청자에 대하여 제3항, 제4항의 경우를 예외로 하여 회원 등록합니다..</p>
<p> 사이트는 다음에 해당하는 경우에 회원 등록을 제한사유가 해소될 때까지 유보할 있습니다.</p>
<ul>
<li>. 서비스 관련 설비에 여유가 없는 경우</li>
<li>. 기술상 지장이 있는 경우</li>
<li>. 기타 사이트가 필요하다고 인정되는 경우</li>
</ul>
<p> 사이트는 신청자가 다음에 해당하는 경우에는 회원 등록을 거부할 있습니다.</p>
<ul>
<li>. 다른 개인(사이트) 명의를 사용하여 신청한 경우</li>
<li>. 이용자 정보를 허위로 기재하여 신청한 경우</li>
<li>. 사회의 안녕질서 또는 미풍양속을 저해할 목적으로 신청한 경우</li>
<li>. 기타 사이트 소정의 이용신청요건을 충족하지 못하는 경우</li>
</ul>
<h5> 5 (회원 탈퇴 이용정지)</h5>
<p> 회원은 사이트에 언제든지 탈퇴를 요청할 있으며 사이트는 요청사항을 명확히 확인한 즉시 회원탈퇴를 처리합니다</p>
<p> 회원이 다음 호의 사유에 해당하는 경우, 사이트는 회원자격을 제한 정지시킬 있습니다.</p>
<ol>
<li>회원 가입 허위 정보를 기재한 경우</li>
<li>타인 명의 또는 전자결제 수단을 도용 또는 의심되는 경우</li>
<li>사이트가 제공하는 관련 서비스 프로그램에 대한 허가되지 않은 접근 시도 변경을 시도하는 경우</li>
<li>기타 사이트가 필요하다고 인정되는 경우</li>
</ol>
<p> 회원 자격 제한 정지가 이루어진 경우 회원은 고객센터 상담을 통해 사유 해결 방안에 대해 확인할 있습니다.</p>
<h5> 6 (이용자정보의 변경)</h5>
<p>회원은 회원가입 기재했던 회원정보가 변경되었을 경우에는, 사이트 개인정보 확인 페이지를 통해 으로 수정하여야 하며 변경하지 않음으로 인하여 발생되는 모든 문제의 책임은 회원에게 있습니다.</p>
<h5> 7 (사이트의 의무)</h5>
<p> 사이트는 법령과 약관이 금지하거나 공서양속에 반하는 행위를 하지 않으며 약관이 정하는 바에 따라 지속적이고, 안정적으로 재화․용역을 제공하는데 최선을 다하여야 합니다.</p>
<p> 사이트는 서비스 제공과 관련하여 취득한 회원의 개인정보를 회원의 동의없이 타인에게 누설, 공개 또는 배포할 없으며, 서비스관련 업무 이외의 상업적 목적으로 사용할 없습니다. , 다음 호의 1 해당하는 경우는 예외입니다.</p>
<ul>
<li>. 전기통신기본법 법률의 규정에 의해 국가기관의 요구가 있는 경우</li>
<li>. 범죄에 대한 수사상의 목적이 있거나 정보통신윤리 위원회의 요청이 있는 경우</li>
<li>. 기타 관계법령에서 정한 정차에 따른 요청이 있는 경우</li>
</ul>
<p> 사이트는 약관에서 정한 바에 따라 지속적, 안정적으로 서비스를 제공할 의무가 있습니다.</p>
<h5> 8 (회원의 의무)</h5>
<p> 회원은 서비스 이용 다음 호의 행위를 하지 않아야 합니다.</p>
<ul>
<li>. 다른 회원의 ID를 부정하게 사용하는 행위</li>
<li>. 서비스에서 얻은 정보를 사이트의 사전승낙 없이 회원의 이용 이외의 목적으로 복제하거나 이를 변경, 출판 방송 등에 사용하거나 타인에게 제공하는 행위</li>
<li>. 사이트의 저작권, 타인의 저작권 기타 권리를 침해하는 행위</li>
<li>. 공공질서 미풍양속에 위반되는 내용의 정보, 문장, 도형 등을 타인에게 유포하는 행위</li>
<li>. 범죄와 결부된다고 객관적으로 판단되는 행위</li>
<li>. 기타 관계법령에 위배되는 행위</li>
</ul>
<p> 회원은 관계법령, 약관에서 규정하는 사항, 서비스 이용 안내 주의 사항을 준수하여야 합니다.</p>
<p> 회원은 내용별로 사이트가 서비스 공지사항에 게시하거나 별도로 공지한 이용 제한 사항을 준수하여야 합니다.</p>
<h5> 9 (회원 아이디(ID) 비밀번호 관리에 대한 회원의 의무)</h5>
<p> 아이디(ID) 비밀번호에 대한 모든 관리는 회원에게 책임이 있습니다. 회원에게 부여된 아이디(ID) 비밀번호의 관리소홀, 부정사용에 의하여 발생하는 모든 결과에 대한 전적인 책임은 회원에게 있습니다.</p>
<p> 자신의 아이디(ID) 부정하게 사용된 경우 또는 기타 보안 위반에 대하여, 회원은 반드시 사이트에 사실을 통보해야 합니다.</p>
<h5> 10 (서비스 이용료)</h5>
<p> 사이트를 통해 상품 구매 휴대폰소액결제 서비스를 이용해 결제하는 경우 회사가 정한 규정에 따라 서비스 이용료가 부과됩니다.</p>
<h5> 11 (서비스 제한 정지)</h5>
<p> 사이트는 전시, 사변, 천재지변 또는 이에 준하는 국가비상사태가 발생하거나 발생할 우려가 있는 경우와 전기통신사업법에 의한 기간통신 사업자가 전기통신서비스를 중지하는 기타 불가항력적 사유가 있는 경우에는 서비스의 전부 또는 일부를 제한하거나 정지할 있습니다.</p>
<p> 사이트는 제1항의 규정에 의하여 서비스의 이용을 제한하거나 정지할 때에는 사유 제한기간 등을 지체없이 회원에게 알려야 합니다.</p>
<h5> 12 (정보의 변경)</h5>
<p>회원이 주소, 비밀번호 고객정보를 변경하고자 하는 경우에는 홈페이지의 회원정보 변경 서비스를 이용하여 변경할 있습니다.</p>
<h5> 13 (면책조항)</h5>
<p> 회사는 다음 호에 해당하는 경우에는 책임을 지지 않습니다.</p>
<ol>
<li>전시, 사변, 천재지변 또는 이에 준하는 국가 비상 사태 불가항력적인 경우</li>
<li>회원의 고의 또는 과실로 인하여 손해가 발생한 경우</li>
<li>전기통신사업법에 의한 기간 통신사업자가 제공하는 전기통신서비스 장애로 인한 경우</li>
</ol>
<p> 회사는 회원의 귀책 사유로 인한 서비스 이용의 장애에 대하여 책임을 지지 아니합니다.</p>
<h5> 14 (관할법원)</h5>
<p> 회사의 요금체계 서비스 이용과 관련하여 분쟁이 발생될 경우, 회사의 본사 소재지를 관할하는 법원을 관할 법원으로 하여 이를 해결합니다.</p>
<p> 서비스 이용과 관련하여 회사와 회원 간의 소송에는 대한민국 법을 적용합니다.</p>
<h5>[부칙]</h5>
<p>(시행일) 약관은 2019 01월부터 시행합니다.</p>
</div>
</div>
</div>
<div class="terms-divider"></div>
{{-- [필수] 개인정보 --}}
<div class="terms-row">
<label class="chk">
<input type="checkbox" name="agree_privacy" id="agree_privacy" value="1" required>
<span class="chk-ui" aria-hidden="true"></span>
<span class="chk-text">
<span class="badge badge--req">필수</span>
<span class="terms-label">개인정보 수집·이용 동의</span>
</span>
</label>
<button type="button" class="terms-view" data-toggle="t_privacy">내용보기</button>
</div>
<div class="terms-body" id="t_privacy" hidden>
<div class="terms-scroll">
<h4>개인정보 수집·이용 동의</h4>
<div class="terms-legal">
<h5>1. 개인정보의 처리 목적</h5>
<p>
주식회사 플러스메이커(이하 “회사”라 ) 운영하는 “핀포유”는 개인정보를 다음의 목적을 위해 처리합니다.
처리한 개인정보는 다음의 목적 이외의 용도로는 사용되지 않으며 이용 목적이 변경될 시에는 사전동의를 구할 예정입니다.
</p>
<ul>
<li><strong>(1) 홈페이지 회원가입 관리</strong> : 회원 가입의사 확인, 회원자격 유지·관리, 서비스 부정이용 방지, 각종 고지·통지, 고충처리, 분쟁 조정을 위한 기록 보존</li>
<li><strong>(2) 민원사무 처리</strong> : 민원인의 신원 확인, 민원사항 확인, 사실조사를 위한 연락·통지, 처리결과 통보</li>
</ul>
<h5>2. 개인정보의 항목 수집방법</h5>
<p><strong>(1) 개인정보 수집 항목</strong></p>
<p> 회사는 회원가입, 상담, 기타 부가 서비스 제공 등을 위해 아래와 같은 개인정보를 수집하고 있습니다.</p>
<ul>
<li>이름, 생년월일, 성별, 전화번호, 이메일, 아이디, 비밀번호, 개인식별값(CI, DI)</li>
</ul>
<p> 또한 서비스 이용과정이나 사업 처리 과정에서 아래와 같은 정보들이 생성되어 수집될 있습니다.</p>
<ul>
<li>접속 IP 정보, 쿠키, 서비스 이용 접속기록</li>
</ul>
<p><strong>(2) 개인정보 수집방법</strong></p>
<ul>
<li>홈페이지(회원가입, 문의상담), 서면양식</li>
<li>제휴사로부터의 제공</li>
</ul>
<h5>3. 개인정보의 처리 보유 기간</h5>
<p>
회사는 법령에 따른 개인정보 보유·이용기간 또는 정보주체로부터 개인정보를 수집 시에 동의 받은 개인정보 보유·이용기간 내에서 개인정보를 처리·보유합니다.
또한, 정보통신망 이용촉진 정보보호 등에 관한 법률에 따라 1년간 “핀포유”를 이용하지 아니한 회원의 개인정보는 파기하거나 별도 분리되어 보관됩니다.
그럼에도 회원이 보유기간을 별도로 지정하거나, 법률에 근거하여 보존할 필요가 있는 경우는 기존과 같이 안전 관리를 다하여 보관합니다.
</p> </p>
<div class="auth-actions"> <p><strong>(1) 홈페이지 회원가입 관리, 민원사무 처리, 재화 또는 서비스 제공</strong></p>
<a class="auth-btn auth-btn--primary" href="{{ route('web.auth.register') }}">처음으로</a> <p>
마케팅 광고의 활용과 관련한 개인정보는 수집·이용에 관한 동의일로부터 지체 없이 파기까지 이용목적을 위하여 보유·이용됩니다.
</p>
<p><strong>(2) 관계법령에 따른 보관</strong></p>
<p>
상법, 전자상거래 등에서의 소비자보호에 관한 법률 관계법령의 규정에 의하여 보존할 필요가 있는 경우 회사는 관계법령에서 정한 일정한 기간 동안 회원정보를 보관합니다.
경우 회사는 보관하는 정보를 보관의 목적으로만 이용합니다.
</p>
<ul>
<li><strong>통신비밀보호법</strong> : 로그기록, IP (수사기관 요청 제공) / <strong>3개월</strong></li>
<li><strong>전자상거래 등에서의 소비자보호에 관한 법률</strong> : 회원의 불만 또는 분쟁처리에 관한 기록(회원 식별정보, 분쟁처리 기록 ) / <strong>3</strong></li>
<li><strong>전자상거래 등에서의 소비자보호에 관한 법률</strong> : 대금결제 재화 등의 공급에 관한 기록(회원 식별정보, 계약/청약철회 기록 ) / <strong>5</strong></li>
<li><strong>부가가치세법</strong> : 장부, 세금계산서, 수입세금계산서, 영수증 (과세표준과 세액 신고자료) / <strong>5</strong></li>
<li><strong>전자금융거래법</strong> : 전자금융거래에 관한 기록, 상대방에 관한 정보 / <strong>5</strong></li>
</ul>
<h5>4. 개인정보의 제3자 제공에 관한 사항</h5>
<p>
회사는 회원의 동의가 있거나 법령의 규정에 의한 경우를 제외하고는 어떠한 경우에도 [개인정보 처리방침]에서 고지한 범위를 넘어 회원의 개인정보를 이용하거나
타인 또는 타기업/기관에 제공하지 않습니다.
</p>
<p>
회원의 개인정보를 제공하는 경우에는 개인정보를 제공받는 , 개인정보 이용 목적, 제공 항목, 보유 이용기간을 개별적으로 고지한 사전 동의를 구합니다.
</p>
<p>다만, 다음의 경우에는 회원의 동의 없이 개인정보를 제공할 있습니다.</p>
<ol>
<li>서비스 제공에 따른 요금 정산을 위하여 필요한 경우</li>
<li>통계작성, 학술연구 또는 시장조사를 위하여 개인을 식별할 없는 형태로 제공하는 경우</li>
<li>법령에 특별한 규정이 있는 경우</li>
</ol>
<p> 회사는 아래와 같은 경우 이용자의 동의 하에 개인정보를 제3자에게 제공할 있습니다.</p>
<ol>
<li>물품구매, 유료컨텐츠 이용 등의 배송 정산을 위해 이용자의 이름, 전화번호 등이 해당 유료컨텐츠 제공자, 제휴 업자에게 제공될 있습니다.</li>
<li>각종 이벤트 행사에 참여한 회원의 개인정보가 해당 이벤트의 주최자에게 제공될 있습니다.</li>
</ol>
<p>
회사는 회원에게 제공되는 서비스의 질을 향상시키기 위해 여러 분야의 전문 컨텐츠 사업자 혹은 비즈니스 사업자와 함께 제휴를 맺을 있습니다.
</p>
<h5>5. 개인정보처리 위탁</h5>
<p>
1. 회사는 서비스 향상을 위해서 아래와 같이 개인정보를 위탁하고 있으며, 관계 법령에 따라 위탁계약 개인정보가 안전하게 관리될 있도록 필요한 사항을 규정하고 있습니다.
</p>
<p><strong>회사의 개인정보 위탁처리 기관 위탁업무 내용(요약)</strong></p>
<ul>
<li><strong>㈜LG U+</strong> : 신용카드 결제, 가상계좌 결제, SMS 발송 (회원탈퇴 또는 위탁계약 종료 시까지 / 법령 기준)</li>
<li><strong>㈜다날</strong> : 결제(신용카드/가상계좌/휴대폰/계좌이체/상품권 결제), 상품권 발급 (회원탈퇴 또는 위탁계약 종료 시까지 / 법령 기준)</li>
<li><strong>㈜더즌</strong> : 가상계좌, 계좌출금, 계좌성명조회 (회원탈퇴 또는 위탁계약 종료 시까지 / 법령 기준)</li>
<li><strong>로움아이티</strong> : 로그인톡 인증 서비스 (별도 저장하지 않음 / 본인인증 업체 보유 정보)</li>
<li><strong>()케이티엠하우스</strong> : 휴대폰번호, 상품권명 (회원 탈퇴 또는 위탁계약 종료 )</li>
</ul>
<h5>6. 정보주체의 권리·의무 행사방법</h5>
<p>이용자는 개인정보주체로서 다음과 같은 권리를 행사할 있습니다.</p>
<p><strong>(1)</strong> 정보주체는 회사에 대해 언제든지 다음 호의 개인정보 보호 관련 권리를 행사할 있습니다.</p>
<ol>
<li>개인정보 열람요구</li>
<li>오류 등이 있을 경우 정정 요구</li>
<li>삭제요구</li>
<li>처리정지 요구</li>
</ol>
<p>
<strong>(2)</strong> 제1항에 따른 권리 행사는 회사에 대해 개인정보 보호법 시행령 제41조제1항 서식에 따라 서면, 전자우편, 모사전송(FAX) 등을 통하여 하실 있으며
회사는 이에 대해 지체 없이 조치하겠습니다.
</p>
<p><strong>(3)</strong> 정보주체가 정정 또는 삭제를 요구한 경우에는 회사는 완료할 때까지 당해 개인정보를 이용하거나 제공하지 않습니다.</p>
<p><strong>(4)</strong> 대리인을 통하여 권리 행사를 하실 있으며, 경우 위임장을 제출하셔야 합니다.</p>
<p><strong>(5)</strong> 개인정보 열람 처리정지 요구는 법령에 의해 제한될 있습니다.</p>
<p><strong>(6)</strong> 다른 법령에서 수집 대상으로 명시된 개인정보는 삭제를 요구할 없습니다.</p>
<p><strong>(8)</strong> 회사는 정보주체 권리 행사 요구를 자가 본인이거나 정당한 대리인인지를 확인합니다.</p>
<h5>7. 개인정보 자동수집 장치의 설치, 운영 거부에 관한 사항</h5>
<p>
회사는 ‘쿠키(cookie) 등을 운용합니다. 쿠키란 웹사이트 서버가 이용자의 브라우저에 보내는 작은 텍스트 파일로서 이용자의 컴퓨터 하드디스크에 저장됩니다.
회사는 다음과 같은 목적을 위해 암호화된 쿠키를 사용합니다.
</p>
<p><strong>(1) 쿠키 사용 목적</strong></p>
<p>
회원과 비회원의 접속 빈도나 방문 시간 등을 분석, 이용자의 취향과 관심분야를 파악 자취 추적,
각종 이벤트 참여 정도 방문 회수 파악 등을 통한 타겟 마케팅 개인 맞춤 서비스 제공.
</p>
<p><strong>(2) 쿠키 설정 거부 방법</strong></p>
<p>
브라우저 옵션을 통해 쿠키 허용/확인/거부를 선택할 있습니다.
(: 인터넷 익스플로어) 도구 &gt; 인터넷 옵션 &gt; 개인정보
(, 쿠키 설치를 거부하였을 경우 서비스 제공에 어려움이 있을 있습니다.)
</p>
<h5>8. 개인정보 파기</h5>
<p>회사는 원칙적으로 개인정보 수집 이용목적이 달성된 후에는 해당 정보를 지체없이 파기합니다.</p>
<p><strong>(1) 파기절차</strong></p>
<p>
회원이 입력한 정보는 목적 달성 별도 DB로 옮겨져 내부 방침 관련 법령에 의한 정보보호 사유에 따라 일정 기간 저장된 파기됩니다.
법률에 의한 경우가 아니고서는 다른 목적으로 이용되지 않습니다.
</p>
<p><strong>(2) 파기방법</strong></p>
<ul>
<li>전자적 파일형태로 저장된 개인정보는 기록을 재생할 없는 기술적 방법을 사용하여 삭제합니다.</li>
</ul>
<h5>9. 개인정보 보호책임자</h5>
<p>
회사는 귀하가 좋은 정보를 안전하게 이용할 있도록 최선을 다하고 있습니다.
개인정보를 보호하는데 있어 귀하께 고지한 사항들에 반하는 사고가 발생할 경우 개인정보보호책임자가 책임을 집니다.
</p>
<p>
이용자 비밀번호의 보안유지책임은 이용자 자신에게 있습니다.
회사는 비밀번호에 대해 어떠한 방법으로도 이용자에게 직접적으로 질문하는 경우는 없으므로 타인에게 비밀번호가 유출되지 않도록 주의하시기 바랍니다.
</p>
<p>
회사가 기술적인 보완조치를 했음에도 해킹 네트워크 위험성으로 인해 발생하는 예기치 못한 사고로 인한 정보의 훼손 분쟁에 관해서는 책임이 없습니다.
</p>
<p> 개인정보 보호책임자 담당자 연락처는 아래와 같습니다.</p>
<ul>
<li><strong>소속</strong> : 대표이사</li>
<li><strong>전화번호</strong> : 1833-4856</li>
<li><strong>성명</strong> : 송병수</li>
<li><strong>이메일</strong> : Bestplusmakerr@gmail.com</li>
</ul>
<h5>10. 정보주체의 권익침해에 대한 구제방법</h5>
<p>아래의 기관은 당사와 별개의 기관입니다. 도움이 필요하시면 문의하여 주시기 바랍니다.</p>
<ul>
<li><strong>개인정보 침해신고센터(한국인터넷진흥원)</strong> : privacy.kisa.or.kr / 118 / 서울시 송파구 중대로 135</li>
<li><strong>개인정보 분쟁조정위원회</strong> : www.kopico.go.kr / 1833-6972 / 정부서울청사 4</li>
<li><strong>대검찰청 사이버수사과</strong> : 1301 / www.spo.go.kr</li>
<li><strong>경찰청 사이버안전국</strong> : 182 / http://cyberbureau.police.go.kr</li>
</ul>
<h5>11. 개인정보 처리방침 고지의 의무</h5>
<p>
개인정보처리방침은 시행일로부터 적용되며, 법령 방침에 따른 변경내용의 추가, 삭제 정정이 있는 경우에는
변경사항의 시행 7 전부터 공지사항을 통하여 고지할 것입니다.
</p>
<p> 방침은 2019 1 21일부터 시행됩니다.</p>
</div> </div>
</div>
</div>
<div class="terms-divider"></div>
{{-- [필수] 14 --}}
<div class="terms-row">
<label class="chk">
<input type="checkbox" name="agree_age" id="agree_age" value="1" required>
<span class="chk-ui" aria-hidden="true"></span>
<span class="chk-text">
<span class="badge badge--req">필수</span>
<span class="terms-label"> 14 이상입니다</span>
<span class="terms-sub"> 14 미만은 회원가입이 제한됩니다.</span>
</span>
</label>
</div>
<div class="terms-divider"></div>
{{-- [선택] 마케팅 --}}
<div class="terms-row">
<label class="chk">
<input type="checkbox" name="agree_marketing" id="agree_marketing" value="1">
<span class="chk-ui" aria-hidden="true"></span>
<span class="chk-text">
<span class="badge badge--opt">선택</span>
<span class="terms-label">마케팅 정보 수신 동의</span>
<span class="terms-sub">할인/이벤트 혜택 안내를 받을 있어요.</span>
</span>
</label>
<button type="button" class="terms-view" data-toggle="t_marketing">내용보기</button>
</div>
<div class="terms-body" id="t_marketing" hidden>
<div class="terms-scroll">
<h4>마케팅 정보 수신 동의 (선택)</h4>
<div class="terms-legal">
<p>
회사는 이벤트/혜택 안내 마케팅 정보 제공을 위해 아래와 같이 개인정보를 이용하고,
문자(SMS) 등으로 안내를 발송할 있습니다. 동의는 선택 사항이며, 동의하지 않아도 서비스 이용이 가능합니다.
</p>
<h5>1. 수신 내용</h5>
<ul>
<li>이벤트/프로모션, 할인 혜택, 쿠폰, 신규 서비스 / 상품안내, 맞춤형 혜택 안내(이용 이력 기반 추천 포함 가능)</li>
</ul>
<h5>2. 수신 채널</h5>
<ul>
<li>SMS(문자)</li>
<li>이메일</li>
</ul>
<h5>3. 이용 항목</h5>
<ul>
<li>휴대폰번호, 이메일, 서비스 이용 정보(혜택 제공 목적 최소한)</li>
</ul>
<h5>4. 보유 이용기간</h5>
<p>
동의일로부터 <strong>회원 탈퇴 또는 동의 철회 시까지</strong> 보유·이용합니다.
, 관계 법령에 따라 보존할 필요가 있는 경우 해당 기간 동안 보관될 있습니다.
</p>
<h5>5. 동의 철회(수신 거부)</h5>
<p>
마케팅 정보 수신은 언제든지 철회할 있습니다.
(: 마이페이지 &gt; 설정/수신동의 변경, 고객센터 요청 )
철회 후에도 서비스 관련 필수 안내(가입/인증/결제/보안/공지 ) 발송될 있습니다.
</p>
</div>
</div>
</div>
</div>
<div class="terms-help" id="terms_help" hidden>
필수 약관에 동의해 주세요.
</div>
<div class="terms-actions">
<button type="submit" class="terms-btn terms-btn--primary" id="terms_next_btn" disabled>
동의하고 다음
</button>
<a class="terms-btn" href="{{ route('web.auth.register') }}">이전</a>
</div>
<p class="terms-note">
다음 단계에서 PASS 본인인증을 진행합니다.
</p>
</form>
</div>
{{-- 페이지 전용 스타일(이쁘게) --}}
<style>
.terms-wrap{display:flex;flex-direction:column;gap:14px}
.terms-steps{display:flex;gap:8px;align-items:center}
.terms-step{flex:1;text-align:center;padding:10px 8px;border-radius:12px;
border:1px solid rgba(255,255,255,.10);opacity:.65;font-size:13px}
.terms-step.is-active{opacity:1;border-color:rgba(255,255,255,.18);
background:rgba(255,255,255,.06);font-weight:700}
.terms-form{display:flex;flex-direction:column;gap:12px}
.terms-card{border:1px solid rgba(255,255,255,.12);border-radius:16px;
background:rgba(255,255,255,.04);box-shadow:0 12px 30px rgba(0,0,0,.18);
overflow:hidden}
.terms-card--all{padding:14px 14px}
.terms-row{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;
padding:14px 14px}
.terms-divider{height:1px;background:rgba(255,255,255,.10);margin:0 14px}
.terms-legal h5 { margin:14px 0 6px; font-size:12.5px; opacity:.95; }
.terms-legal p { margin:0 0 8px; }
.terms-legal ul, .terms-legal ol { margin:6px 0 10px; padding-left:18px; }
.terms-legal li { margin:2px 0; opacity:.85; line-height:1.65; }
/* custom checkbox */
.chk{display:flex;gap:10px;align-items:flex-start;cursor:pointer;user-select:none}
.chk input{position:absolute;opacity:0;pointer-events:none}
.chk-ui{width:20px;height:20px;border-radius:6px;flex:0 0 20px;margin-top:2px;
border:1px solid rgba(255,255,255,.22);
background:rgba(0,0,0,.15);
box-shadow:inset 0 0 0 1px rgba(0,0,0,.2)}
.chk input:checked + .chk-ui{
border-color:rgba(255,255,255,.35);
background:rgba(255,255,255,.92);
}
.chk input:checked + .chk-ui::after{
content:"";display:block;width:10px;height:6px;border-left:2px solid #111;border-bottom:2px solid #111;
transform:rotate(-45deg);margin:5px 0 0 4px
}
.chk-text{display:flex;flex-direction:column;gap:4px;line-height:1.3}
.chk-title{font-weight:800}
.chk-sub{opacity:.72;font-size:12px}
.terms-label{font-weight:800}
.terms-sub{opacity:.72;font-size:12px}
.badge{display:inline-flex;align-items:center;justify-content:center;
padding:2px 8px;border-radius:999px;font-size:11px;font-weight:800;width:max-content}
.badge--req{background:rgba(255,90,90,.18);border:1px solid rgba(255,90,90,.35)}
.badge--opt{background:rgba(120,180,255,.16);border:1px solid rgba(120,180,255,.30);opacity:.9}
/* "내용보기" 버튼 */
.terms-view{border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.06);
color:inherit;border-radius:10px;padding:8px 10px;font-size:12px;cursor:pointer;opacity:.92;
transition:transform .08s ease, opacity .08s ease, background .08s ease}
.terms-view:hover{opacity:1;background:rgba(255,255,255,.10)}
.terms-view:active{transform:scale(.98)}
/* 내용 영역: div + 스크롤 */
.terms-body{padding:0 14px 14px}
.terms-scroll{border:1px solid rgba(255,255,255,.10);background:rgba(0,0,0,.16);
border-radius:12px;padding:12px;max-height:180px;overflow:auto}
.terms-scroll h4{margin:0 0 10px;font-size:13px}
.terms-scroll p,.terms-scroll li{opacity:.85;line-height:1.65;font-size:12.5px}
.terms-scroll ul{margin:8px 0 0;padding-left:18px}
/* 안내/버튼 */
.terms-help{padding:10px 12px;border-radius:12px;background:rgba(255,90,90,.12);
border:1px solid rgba(255,90,90,.25);opacity:.95;font-size:12.5px}
.terms-actions{display:flex;gap:10px;margin-top:4px}
.terms-btn{flex:1;text-align:center;border-radius:14px;padding:12px 12px;
border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.06);color:inherit;
text-decoration:none;cursor:pointer;font-weight:800}
.terms-btn--primary{background:rgba(255,255,255,.92);color:#111;border-color:rgba(255,255,255,.55)}
.terms-btn[disabled]{opacity:.45;cursor:not-allowed}
.terms-note{margin:0;opacity:.7;font-size:12.5px;line-height:1.5}
/* Primary 버튼: 기본(비활성) = 밋밋 / 활성 = 블루톤 튀게 */
#terms_next_btn.terms-btn--primary{
position: relative;
overflow: hidden;
transform: translateZ(0);
transition: transform .12s ease, box-shadow .12s ease, filter .12s ease, opacity .12s ease;
}
/* 비활성(disabled)이어도 "버튼처럼" 보이게 */
#terms_next_btn.terms-btn--primary:disabled{
opacity: 1 !important;
cursor: not-allowed;
background: rgba(255,255,255,.88) !important; /* 밝은 버튼 */
border-color: rgba(0,0,0,.10) !important;
color: #7a7a7a !important; /* 검은 글자 */
text-shadow: 0 1px 0 rgba(255,255,255,.25); /* 살짝 또렷 */
box-shadow: 0 10px 22px rgba(0,0,0,.10) !important;
}
/* disabled일 때 shine 제거 */
#terms_next_btn.terms-btn--primary:disabled::before{
content: none !important;
}
/* 활성 상태 */
#terms_next_btn.terms-btn--primary:not(:disabled){
background: linear-gradient(135deg, #2f6bff 0%, #4aa3ff 55%, #2fd2ff 100%);
color: #fff;
border-color: rgba(120,180,255,.55);
box-shadow:
0 14px 30px rgba(47,107,255,.28),
0 6px 14px rgba(74,163,255,.18);
}
/* shine 효과 */
#terms_next_btn.terms-btn--primary:not(:disabled)::before{
content:"";
position:absolute;
top:-30%;
left:-40%;
width:60%;
height:160%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.35), transparent);
transform: rotate(18deg);
opacity:.55;
animation: termsShine 2.6s ease-in-out infinite;
pointer-events:none;
}
@keyframes termsShine{
0% { transform: translateX(-30%) rotate(18deg); opacity:.25; }
45% { opacity:.65; }
100% { transform: translateX(220%) rotate(18deg); opacity:.25; }
}
/* hover/active */
#terms_next_btn.terms-btn--primary:not(:disabled):hover{
transform: translateY(-1px);
box-shadow:
0 18px 40px rgba(47,107,255,.34),
0 8px 18px rgba(74,163,255,.22);
filter: saturate(1.08);
}
#terms_next_btn.terms-btn--primary:not(:disabled):active{
transform: translateY(0);
filter: saturate(1.0);
}
</style>
{{-- 페이지 전용 JS --}}
<script>
(function () {
const form = document.getElementById('regTermsForm');
const btn = document.getElementById('terms_next_btn');
const help = document.getElementById('terms_help');
const all = document.getElementById('agree_all');
const reqIds = ['agree_terms', 'agree_privacy', 'agree_age'];
const optIds = ['agree_marketing'];
const reqEls = reqIds.map(id => document.getElementById(id));
const optEls = optIds.map(id => document.getElementById(id));
const allEls = [...reqEls, ...optEls];
function requiredOk() {
return reqEls.every(el => el && el.checked);
}
function syncAllState() {
const checked = allEls.filter(el => el && el.checked).length;
all.checked = (checked === allEls.length);
all.indeterminate = (checked > 0 && checked < allEls.length);
}
function syncButton() {
const ok = requiredOk();
btn.disabled = !ok;
if (help) help.hidden = ok;
}
// 공통 메시지(모달 우선)
async function showAlert(msg, title='안내') {
if (window.showMsg) return await window.showMsg(msg, { type:'alert', title });
alert(msg);
return true;
}
// iframe 모달 오픈/닫기 (외부 클릭으로 닫히지 않게)
function openIframeModal(popupName = 'danal_authtel_popup', w = 420, h = 750) {
// 이미 떠 있으면 제거 후 다시
const old = document.getElementById(popupName);
if (old) old.remove();
const wrap = document.createElement('div');
wrap.id = popupName;
wrap.innerHTML = `
<div style="position:fixed;top:0;left:0;width:100%;height:100%;z-index:200000;background:#000;opacity:.55"></div>
<div style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:${w}px;height:${h}px;background:#fff;border-radius:10px;z-index:200001;overflow:hidden;">
<iframe id="${popupName}_iframe" name="${popupName}_iframe" style="width:100%;height:100%;border:none"></iframe>
</div>`;
document.body.appendChild(wrap);
window.closeIframe = function () {
const el = document.getElementById(popupName);
if (el) el.remove();
};
return popupName + '_iframe';
}
function postToIframe(url, targetName, fieldsObj) {
const temp = document.createElement('form');
temp.method = 'POST';
temp.action = url;
temp.target = targetName;
// 라라벨 CSRF
const csrf = document.createElement('input');
csrf.type = 'hidden';
csrf.name = '_token';
csrf.value = "{{ csrf_token() }}";
temp.appendChild(csrf);
// fields(JSON 문자열로)
const fields = document.createElement('input');
fields.type = 'hidden';
fields.name = 'fields';
fields.value = JSON.stringify(fieldsObj || {});
temp.appendChild(fields);
document.body.appendChild(temp);
temp.submit();
temp.remove();
}
// 다날 결과 메시지 수신 (iframe -> parent)
window.addEventListener('message', async (ev) => {
const d = ev.data || {};
if (d.type !== 'danal_result') return;
// 모달 닫기
if (typeof window.closeIframe === 'function') window.closeIframe();
await showAlert(d.message || (d.ok ? '본인인증이 완료되었습니다.' : '본인인증에 실패했습니다.'), d.ok ? '인증 완료' : '인증 실패');
if (d.redirect) {
window.location.href = d.redirect;
}
});
// 전체 동의
all.addEventListener('change', function () {
allEls.forEach(el => { if (el) el.checked = all.checked; });
syncButton();
syncAllState();
});
// 개별 변경
allEls.forEach(el => {
if (!el) return;
el.addEventListener('change', function () {
syncButton();
syncAllState();
});
});
// 내용 보기 토글(스크롤 div)
document.querySelectorAll('[data-toggle]').forEach(btn2 => {
btn2.addEventListener('click', function () {
const id = btn2.getAttribute('data-toggle');
const panel = document.getElementById(id);
if (!panel) return;
const willOpen = panel.hidden === true;
panel.hidden = !willOpen;
btn2.textContent = willOpen ? '닫기' : '내용보기';
});
});
// 초기
syncButton();
syncAllState();
// 제출: (기존 기능 유지) 약관 저장 + 다날 시작으로 변경
form.addEventListener('submit', async function (e) {
e.preventDefault();
if (!requiredOk()) {
await showAlert('필수 약관에 동의해 주세요.', '안내');
syncButton();
return;
}
btn.disabled = true;
try {
// AJAX로 약관 저장 + 다날 준비값 받기
const body = {
agree_terms: document.getElementById('agree_terms')?.checked ? '1' : '',
agree_privacy: document.getElementById('agree_privacy')?.checked ? '1' : '',
agree_age: document.getElementById('agree_age')?.checked ? '1' : '',
agree_marketing: document.getElementById('agree_marketing')?.checked ? '1' : '',
};
const res = await fetch(form.action, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': "{{ csrf_token() }}",
'Accept': 'application/json',
},
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
await showAlert(data.message || '처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요ff.', '오류');
return;
}
// 서버가 danal_ready를 내려주면 iframe 모달 실행
if (data.reason === 'danal_ready' && data.popup && data.popup.url) {
const targetName = openIframeModal('danal_authtel_popup', 420, 750);
postToIframe(data.popup.url, targetName, data.popup.fields || {});
return;
}
// (혹시) 준비중/리다이렉트 방식일 때 fallback
if (data.redirect) {
window.location.href = data.redirect;
return;
}
await showAlert('약관 동의가 저장되었습니다.', '완료');
} catch (err) {
await showAlert('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', '오류');
} finally {
btn.disabled = false;
}
});
})();
</script>
@endsection @endsection

View File

@ -69,5 +69,9 @@
{{-- 페이지별 스크립트 추가용 --}} {{-- 페이지별 스크립트 추가용 --}}
@stack('recaptcha') @stack('recaptcha')
@stack('scripts') @stack('scripts')
@include('web.partials.ui-dialog')
{{--@php 개발모드에서만 세션표시--}}
@include('web.partials.dev_session_overlay')
</body> </body>
</html> </html>

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"></head>
<body>
<form id="danalForm" method="post" action="{{ $action }}">
@foreach($fields as $k => $v)
<input type="hidden" name="{{ $k }}" value="{{ $v }}">
@endforeach
</form>
<script>document.getElementById('danalForm').submit();</script>
</body>
</html>

View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"></head>
<body>
<script>
try {
window.parent && window.parent.postMessage({
type: 'danal_result',
ok: {{ $ok ? 'true' : 'false' }},
message: @json($message),
redirect: @json($redirect),
}, '*');
} catch (e) {}
</script>
</body>
</html>

View File

@ -0,0 +1,218 @@
{{-- resources/views/web/partials/dev_session_overlay.blade.php --}}
@php
// ✅ 개발 모드에서만 노출
$show = config('app.debug') || app()->environment('local');
// ✅ 이 overlay 자체가 세션을 수정하는 "dev action" 처리(컨트롤러/라우트 없이)
if ($show && request()->isMethod('post') && request()->has('_dev_sess_action')) {
// CSRF는 web 미들웨어에 걸려있으니 토큰 포함된 요청만 처리됨.
$action = request()->input('_dev_sess_action');
if ($action === 'flush') {
session()->flush();
session()->save();
}
if ($action === 'put') {
$k = (string) request()->input('_dev_sess_key', '');
$v = (string) request()->input('_dev_sess_value', '');
// 빈 키 방지
if ($k !== '') {
// "a.b.c" 점 표기 지원
data_set(session()->all(), $k, $v);
// data_set은 배열에만 반영되므로 세션에 직접 put
session()->put($k, $v);
session()->save();
}
}
// ✅ POST 재전송 방지 + 현재 페이지로 되돌리기
$redir = url()->current();
$qs = request()->query();
if (!empty($qs)) $redir .= '?' . http_build_query($qs);
header('Location: ' . $redir, true, 302);
exit;
}
// ✅ 세션 전체
$sess = session()->all();
// ✅ 민감값 마스킹
$maskKeys = [];
// $maskKeys = [
// 'password', 'passwd', 'pw', 'token', 'access_token', 'refresh_token',
// 'api_key', 'secret', 'authorization', 'csrf', '_token',
// 'g-recaptcha-response', 'recaptcha', 'otp',
// 'ci', 'di', 'phone', 'mobile', 'email',
// ];
$mask = function ($key, $val) use ($maskKeys) {
$k = strtolower((string)$key);
foreach ($maskKeys as $mk) {
if (str_contains($k, $mk)) return '***';
}
return $val;
};
// ✅ key:value 라인 생성(재귀)
$lines = [];
$dump = function ($data, $prefix = '') use (&$dump, &$lines, $mask) {
foreach ((array)$data as $k => $v) {
$key = $prefix . (string)$k;
if (is_array($v)) {
$lines[] = $key . ' : [';
$dump($v, $prefix . ' ');
$lines[] = $prefix . ']';
} else {
if (is_bool($v)) $v = $v ? 'true' : 'false';
if ($v === null) $v = 'null';
$vv = is_string($v) ? (mb_strlen($v) > 260 ? mb_substr($v, 0, 260) . '…' : $v) : (string)$v;
$vv = $mask($k, $vv);
$lines[] = $key . ' : ' . $vv;
}
}
};
$dump($sess);
$text = implode("\n", $lines);
@endphp
@if($show)
<div id="dev-session-overlay" style="
position: fixed; left: 12px; bottom: 12px; z-index: 999999;
width: 620px; max-width: calc(100vw - 24px);
background: rgba(10,10,10,.92); color: #eaeaea;
border: 1px solid rgba(255,255,255,.14);
border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.35);
font: 12px/1.35 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
">
<div style="display:flex; align-items:center; justify-content:space-between; padding:10px 12px; gap:8px;">
<div style="font-weight:800;">
SESSION
<span style="opacity:.65; font-weight:500;">({{ count($sess) }} keys)</span>
</div>
<div style="display:flex; gap:6px;">
<button type="button" id="devSessCopy" style="
border: 1px solid rgba(255,255,255,.18); background: rgba(255,255,255,.06);
color:#fff; padding:5px 10px; border-radius:10px; cursor:pointer;
">Copy</button>
<button type="button" id="devSessToggle" style="
border: 1px solid rgba(255,255,255,.18); background: rgba(255,255,255,.06);
color:#fff; padding:5px 10px; border-radius:10px; cursor:pointer;
">Hide</button>
</div>
</div>
<div id="devSessBody" style="padding:0 12px 12px 12px;">
<div style="opacity:.7; margin-bottom:8px;">
{{ request()->method() }} {{ request()->path() }}
</div>
{{-- Controls --}}
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px;">
<form method="POST" action="{{ route('dev.session') }}" style="display:flex; gap:6px; align-items:center; margin:0;">
@csrf
<input type="hidden" name="_dev_return" value="{{ url()->full() }}">
<input type="hidden" name="_dev_sess_action" value="flush">
<input type="hidden" name="_dev_return" value="{{ url()->full() }}">
<button type="submit" style="
border: 1px solid rgba(255,90,90,.35);
background: rgba(255,90,90,.14);
color:#fff; padding:6px 10px; border-radius:10px; cursor:pointer;
" onclick="return confirm('세션을 초기화할까요? (dev 전용)');">세션 초기화</button>
</form>
<form method="POST" action="{{ route('dev.session') }}" style="display:flex; gap:6px; align-items:center; margin:0; flex:1;">
<input type="hidden" name="_dev_return" value="{{ url()->full() }}">
@csrf
<input type="hidden" name="_dev_sess_action" value="put">
<input type="hidden" name="_dev_return" value="{{ url()->full() }}">
<input name="_dev_sess_key" placeholder="key (예: register.pass_verified)" style="
flex: 0 0 240px; max-width: 45%;
border: 1px solid rgba(255,255,255,.16);
background: rgba(255,255,255,.06);
color:#fff; padding:6px 8px; border-radius:10px; outline:none;
">
<input name="_dev_sess_value" placeholder="value (문자열로 저장)" style="
flex: 1 1 auto;
border: 1px solid rgba(255,255,255,.16);
background: rgba(255,255,255,.06);
color:#fff; padding:6px 8px; border-radius:10px; outline:none;
">
<button type="submit" style="
border: 1px solid rgba(90,180,255,.35);
background: rgba(90,180,255,.14);
color:#fff; padding:6px 10px; border-radius:10px; cursor:pointer;
">등록</button>
</form>
</div>
<div style="
padding:10px; border-radius:12px;
background: rgba(255,255,255,.05);
max-height: 360px; overflow:auto; white-space: pre;
border: 1px solid rgba(255,255,255,.10);
">
<pre id="devSessPre" style="margin:0;">{!! e($text) !!}</pre>
</div>
</div>
</div>
<script>
(function(){
const box = document.getElementById('dev-session-overlay');
const body = document.getElementById('devSessBody');
const toggle = document.getElementById('devSessToggle');
const copyBtn = document.getElementById('devSessCopy');
const pre = document.getElementById('devSessPre');
// 상태 기억
const key = 'devSessOverlayCollapsed';
const collapsed = localStorage.getItem(key) === '1';
if (collapsed) { body.style.display='none'; toggle.textContent='Show'; }
toggle.addEventListener('click', () => {
const isHidden = body.style.display === 'none';
body.style.display = isHidden ? '' : 'none';
toggle.textContent = isHidden ? 'Hide' : 'Show';
localStorage.setItem(key, isHidden ? '0' : '1');
});
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(pre.innerText);
copyBtn.textContent = 'Copied';
setTimeout(()=>copyBtn.textContent='Copy', 800);
} catch(e) {
alert('복사 실패(브라우저 권한 확인)');
}
});
// 드래그 이동
let dragging=false, sx=0, sy=0, ox=0, oy=0;
box.firstElementChild.style.cursor = 'move';
box.firstElementChild.addEventListener('mousedown', (e)=>{
dragging=true; sx=e.clientX; sy=e.clientY;
const r = box.getBoundingClientRect();
ox=r.left; oy=r.top;
box.style.right='auto'; box.style.bottom='auto';
document.body.style.userSelect='none';
});
window.addEventListener('mousemove', (e)=>{
if(!dragging) return;
const nx = ox + (e.clientX - sx);
const ny = oy + (e.clientY - sy);
box.style.left = Math.max(0, nx) + 'px';
box.style.top = Math.max(0, ny) + 'px';
});
window.addEventListener('mouseup', ()=>{
dragging=false;
document.body.style.userSelect='';
});
})();
</script>
@endif

View File

@ -0,0 +1,28 @@
{{-- 공통 Alert/Confirm Dialog (no bootstrap dependency) --}}
<div id="uiDialog" class="ui-dialog" aria-hidden="true">
<div class="ui-dialog__backdrop" data-uidialog-close="1"></div>
<div class="ui-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="uiDialogTitle">
<div class="ui-dialog__header">
<h3 id="uiDialogTitle" class="ui-dialog__title">알림</h3>
<button type="button" class="ui-dialog__x" aria-label="닫기" data-uidialog-close="1">×</button>
</div>
<div class="ui-dialog__body">
<p class="ui-dialog__message" id="uiDialogMessage"></p>
</div>
<div class="ui-dialog__footer">
<button type="button" class="ui-dialog__btn ui-dialog__btn--cancel" id="uiDialogCancel">취소</button>
<button type="button" class="ui-dialog__btn ui-dialog__btn--ok" id="uiDialogOk">확인</button>
</div>
</div>
</div>
{{-- 서버 flash로 띄우고 싶을 : session('ui_dialog') 사용 --}}
@if (session()->has('ui_dialog'))
<script type="application/json" id="uiDialogFlash">
{!! json_encode(session('ui_dialog'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) !!}
</script>
@endif

View File

@ -58,12 +58,21 @@ Route::prefix('auth')->name('web.auth.')->group(function () {
// 정적 페이지 // 정적 페이지
Route::view('login', 'web.auth.login')->name('login'); Route::view('login', 'web.auth.login')->name('login');
// 회원가입 Step0 // 회원가입 Step0
Route::get('register', [RegisterController::class, 'showStep0'])->name('register'); Route::get('register', [RegisterController::class, 'showStep0'])->name('register');
Route::post('register/phone-check', [RegisterController::class, 'postPhoneCheck'])->name('register.phone_check'); Route::post('register/phone-check', [RegisterController::class, 'postPhoneCheck'])->name('register.phone_check');
// Step1(약관) - 지금은 임시 화면 // Step1(약관) - 지금은 임시 화면
Route::get('register/terms', [RegisterController::class, 'showTerms'])->name('register.terms'); Route::get('register/terms', [RegisterController::class, 'showTerms'])->name('register.terms');
Route::post('register/terms', [RegisterController::class, 'termsSubmit'])->name('register.terms.submit');
// Step2 다날 인증 시작 화면
Route::post('register/danal/start', [RegisterController::class, 'danalStart'])->name('register.danal.start'); // iframe에서 로드될 페이지
Route::post('register/danal/result', [RegisterController::class, 'danalResult'])->name('register.danal.result'); // 다날 pass 결과 리턴
// Step3 회원정보 입력
Route::get('register/profile', [RegisterController::class, 'showProfileForm'])->name('register.profile');
Route::post('register/profile', [RegisterController::class, 'submitProfile'])->name('register.profile.submit');
// 아이디 찾기 (컨트롤러) // 아이디 찾기 (컨트롤러)
@ -109,4 +118,67 @@ if (app()->environment(['local', 'development', 'testing'])
}); });
} }
/* 개발용 페이지 세션 보기*/
use Illuminate\Http\Request;
Route::post('_dev/session', function (Request $request) {
abort_unless(config('app.debug') || app()->environment('local'), 404);
$action = (string) $request->input('_dev_sess_action', '');
// ✅ 자동 타입 변환(익명 함수라 재선언 문제 없음)
$parse = function (string $raw) {
$s = trim($raw);
$lower = strtolower($s);
if ($lower === 'true') return true;
if ($lower === 'false') return false;
if ($lower === 'null') return null;
// 정수
if (preg_match('/^-?\d+$/', $s)) {
// 앞자리 0이 있는 값(예: 00123)은 문자열 유지하고 싶으면 아래 조건 추가
// if (strlen($s) > 1 && $s[0] === '0') return $raw;
$int = (int) $s;
if ((string)$int === $s) return $int;
}
// 실수
if (preg_match('/^-?\d+\.\d+$/', $s)) {
return (float) $s;
}
// JSON
if ($s !== '' && (str_starts_with($s, '{') || str_starts_with($s, '['))) {
$j = json_decode($s, true);
if (json_last_error() === JSON_ERROR_NONE) return $j;
}
return $raw;
};
if ($action === 'flush') {
session()->flush();
session()->save();
} elseif ($action === 'put') {
$k = trim((string) $request->input('_dev_sess_key', ''));
$raw = (string) $request->input('_dev_sess_value', '');
if ($k !== '') {
session()->put($k, $parse($raw));
session()->save();
}
}
return redirect()->to((string) $request->input('_dev_return', '/'));
})->name('dev.session');
/* 개발용 페이지 세션 보기*/
Route::fallback(fn () => abort(404)); Route::fallback(fn () => abort(404));