이용내역 리스트 / 뷰

This commit is contained in:
sungro815 2026-03-03 15:13:16 +09:00
parent fb0cec13ef
commit 9825350372
111 changed files with 2380 additions and 644 deletions

View File

@ -47,7 +47,7 @@ final class AdminAdminsController
]); ]);
} }
// 수정 후에는 edit(GET)으로 보내기 // 수정 후에는 edit(GET)으로 보내기
return redirect() return redirect()
->route('admin.admins.edit', ['id' => $id]) ->route('admin.admins.edit', ['id' => $id])
->with('toast', [ ->with('toast', [
@ -75,7 +75,7 @@ final class AdminAdminsController
]); ]);
} }
// 수정 후에는 edit(GET)으로 보내기 // 수정 후에는 edit(GET)으로 보내기
return redirect() return redirect()
->route('admin.admins.edit', ['id' => $id]) ->route('admin.admins.edit', ['id' => $id])
->with('toast', [ ->with('toast', [
@ -159,7 +159,7 @@ final class AdminAdminsController
return back()->withErrors(['email' => (string)($res['message'] ?? '등록에 실패했습니다.')])->withInput(); return back()->withErrors(['email' => (string)($res['message'] ?? '등록에 실패했습니다.')])->withInput();
} }
// 임시 비밀번호는 “1회 안내”를 위해 메시지에 포함(원하면 문구만 바꿔도 됨) // 임시 비밀번호는 “1회 안내”를 위해 메시지에 포함(원하면 문구만 바꿔도 됨)
$temp = (string)($res['temp_password'] ?? ''); $temp = (string)($res['temp_password'] ?? '');
return redirect() return redirect()

View File

@ -47,7 +47,7 @@ final class AdminAuthController extends Controller
$state = (string) ($res['state'] ?? ''); $state = (string) ($res['state'] ?? '');
// 1) 계정 잠김 // 1) 계정 잠김
if ($state === 'locked') { if ($state === 'locked') {
$msg = '계정이 잠금 상태입니다. 최고관리자에게 잠금 해제를 요청해 주세요.'; $msg = '계정이 잠금 상태입니다. 최고관리자에게 잠금 해제를 요청해 주세요.';
@ -61,7 +61,7 @@ final class AdminAuthController extends Controller
]); ]);
} }
// 2) 비번 불일치/계정없음 (남은 시도 횟수 포함) // 2) 비번 불일치/계정없음 (남은 시도 횟수 포함)
if ($state === 'invalid') { if ($state === 'invalid') {
$left = $res['attempts_left'] ?? null; $left = $res['attempts_left'] ?? null;
@ -80,7 +80,7 @@ final class AdminAuthController extends Controller
]); ]);
} }
// 3) 차단/비활성 계정 // 3) 차단/비활성 계정
if ($state === 'blocked') { if ($state === 'blocked') {
$msg = '로그인 할 수 없는 계정입니다.'; $msg = '로그인 할 수 없는 계정입니다.';
@ -94,7 +94,7 @@ final class AdminAuthController extends Controller
]); ]);
} }
// 4) SMS 발송 실패 // 4) SMS 발송 실패
if ($state === 'sms_error') { if ($state === 'sms_error') {
$msg = '인증 SMS 발송에 실패했습니다. 잠시 후 다시 시도해 주세요.'; $msg = '인증 SMS 발송에 실패했습니다. 잠시 후 다시 시도해 주세요.';
@ -108,7 +108,7 @@ final class AdminAuthController extends Controller
]); ]);
} }
// 5) 비번 재설정 강제 // 5) 비번 재설정 강제
if ($state === 'must_reset') { if ($state === 'must_reset') {
$request->session()->put('admin_pwreset', [ $request->session()->put('admin_pwreset', [
'admin_id' => (int) ($res['admin_id'] ?? 0), 'admin_id' => (int) ($res['admin_id'] ?? 0),
@ -156,7 +156,7 @@ final class AdminAuthController extends Controller
]); ]);
} }
// 6) OTP 발송 성공 // 6) OTP 발송 성공
if ($state === 'otp_sent') { if ($state === 'otp_sent') {
$request->session()->put('admin_2fa', [ $request->session()->put('admin_2fa', [
'challenge_id' => (string) ($res['challenge_id'] ?? ''), 'challenge_id' => (string) ($res['challenge_id'] ?? ''),
@ -176,7 +176,7 @@ final class AdminAuthController extends Controller
]); ]);
} }
// 방어: 예상치 못한 상태 // 방어: 예상치 못한 상태
return back() return back()
->withInput() ->withInput()
->withErrors(['login_id' => '로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.']) ->withErrors(['login_id' => '로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'])

View File

@ -16,7 +16,7 @@ final class AdminAuditLogController extends Controller
{ {
$data = $this->service->indexData($request->query()); $data = $this->service->indexData($request->query());
// view 파일명: 컨트롤러와 이름 맞춤 (index.blade.php 사용 안함) // view 파일명: 컨트롤러와 이름 맞춤 (index.blade.php 사용 안함)
return view('admin.log.AdminAuditLogController', $data); return view('admin.log.AdminAuditLogController', $data);
} }

View File

@ -16,7 +16,7 @@ final class MemberJoinLogController extends Controller
{ {
$data = $this->service->indexData($request->query()); $data = $this->service->indexData($request->query());
// index.blade.php 금지 → 컨트롤러명과 동일한 뷰 파일 // index.blade.php 금지 → 컨트롤러명과 동일한 뷰 파일
return view('admin.log.MemberJoinLogController', $data); return view('admin.log.MemberJoinLogController', $data);
} }
} }

View File

@ -27,13 +27,13 @@ final class AdminMailController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
// 템플릿 선택값(옵션): Blade에서 <select name="template_id"> 로 보내면 여기서 받음 // 템플릿 선택값(옵션): Blade에서 <select name="template_id"> 로 보내면 여기서 받음
$hasTemplate = $request->filled('template_id'); $hasTemplate = $request->filled('template_id');
$rules = [ $rules = [
'send_mode' => ['required','in:one,many,csv,db'], 'send_mode' => ['required','in:one,many,csv,db'],
// schedule_type 추가 + 예약이면 scheduled_at 필수 // schedule_type 추가 + 예약이면 scheduled_at 필수
'schedule_type' => ['required','in:now,schedule'], 'schedule_type' => ['required','in:now,schedule'],
'scheduled_at' => ['nullable','date_format:Y-m-d H:i','required_if:schedule_type,schedule'], 'scheduled_at' => ['nullable','date_format:Y-m-d H:i','required_if:schedule_type,schedule'],
@ -41,7 +41,7 @@ final class AdminMailController extends Controller
'from_name' => ['nullable','string','max:120'], 'from_name' => ['nullable','string','max:120'],
'reply_to' => ['nullable','email','max:190'], 'reply_to' => ['nullable','email','max:190'],
// 템플릿 선택이면(스킨 select disabled 등) skin_key가 누락될 수 있으니 nullable 허용 + 서버에서 기본값 세팅 // 템플릿 선택이면(스킨 select disabled 등) skin_key가 누락될 수 있으니 nullable 허용 + 서버에서 기본값 세팅
'skin_key' => $hasTemplate 'skin_key' => $hasTemplate
? ['nullable','in:hero,newsletter,minimal,clean,dark'] ? ['nullable','in:hero,newsletter,minimal,clean,dark']
: ['required','in:hero,newsletter,minimal,clean,dark'], : ['required','in:hero,newsletter,minimal,clean,dark'],
@ -49,7 +49,7 @@ final class AdminMailController extends Controller
// 템플릿 선택용(옵션) // 템플릿 선택용(옵션)
'template_id' => ['nullable','integer','min:1'], 'template_id' => ['nullable','integer','min:1'],
// subject/body는 무조건 들어가야 함(템플릿이면 적용 버튼으로 채워지게) // subject/body는 무조건 들어가야 함(템플릿이면 적용 버튼으로 채워지게)
'subject' => ['required','string','max:190'], 'subject' => ['required','string','max:190'],
'body' => ['required','string','max:20000'], 'body' => ['required','string','max:20000'],
@ -74,7 +74,7 @@ final class AdminMailController extends Controller
$v = Validator::make($request->all(), $rules); $v = Validator::make($request->all(), $rules);
// 모드별 추가 검증(필수값 체크) // 모드별 추가 검증(필수값 체크)
$v->after(function ($validator) use ($request) { $v->after(function ($validator) use ($request) {
$mode = (string)$request->input('send_mode'); $mode = (string)$request->input('send_mode');
@ -101,14 +101,14 @@ final class AdminMailController extends Controller
$data = $v->validate(); $data = $v->validate();
// skin_key가 폼에서 누락되면(템플릿 선택 시 disabled 등) 기본값 채움 // skin_key가 폼에서 누락되면(템플릿 선택 시 disabled 등) 기본값 채움
// (템플릿이면 서비스가 템플릿의 skin_key로 덮어쓰도록 하는 게 정석) // (템플릿이면 서비스가 템플릿의 skin_key로 덮어쓰도록 하는 게 정석)
$data['skin_key'] = (string)($data['skin_key'] ?? ''); $data['skin_key'] = (string)($data['skin_key'] ?? '');
if ($data['skin_key'] === '') { if ($data['skin_key'] === '') {
$data['skin_key'] = 'clean'; $data['skin_key'] = 'clean';
} }
// 컨트롤러에서 “정상 규칙”으로 파싱 결과를 만들어 request에 심어둠 // 컨트롤러에서 “정상 규칙”으로 파싱 결과를 만들어 request에 심어둠
// → 서비스에서 이 parsed_rows를 우선 사용하면 토큰 밀림(덮어쓰기) 문제를 확실히 잡을 수 있음. // → 서비스에서 이 parsed_rows를 우선 사용하면 토큰 밀림(덮어쓰기) 문제를 확실히 잡을 수 있음.
if ($data['send_mode'] === 'many') { if ($data['send_mode'] === 'many') {
$rows = $this->parseManyText((string)($data['to_emails_text'] ?? '')); $rows = $this->parseManyText((string)($data['to_emails_text'] ?? ''));
@ -166,7 +166,7 @@ final class AdminMailController extends Controller
'heroUrl' => ['nullable','string','max:500'], 'heroUrl' => ['nullable','string','max:500'],
]); ]);
// 미리보기 샘플도 실제 규칙과 동일하게 // 미리보기 샘플도 실제 규칙과 동일하게
// {_text_02_}=이름, {_text_03_}=금액, {_text_04_}=상품유형 // {_text_02_}=이름, {_text_03_}=금액, {_text_04_}=상품유형
$sample = [ $sample = [
'{_text_02_}' => '홍길동', '{_text_02_}' => '홍길동',
@ -204,7 +204,7 @@ final class AdminMailController extends Controller
$tokens = array_values($tokens); $tokens = array_values($tokens);
foreach ($tokens as $i => $v) { foreach ($tokens as $i => $v) {
$key = sprintf('{_text_%02d_}', $i + 2); // 무조건 02부터 $key = sprintf('{_text_%02d_}', $i + 2); // 무조건 02부터
$vars[$key] = trim((string)$v); $vars[$key] = trim((string)$v);
} }
@ -218,7 +218,7 @@ final class AdminMailController extends Controller
private function bodyToHtml(string $text): string private function bodyToHtml(string $text): string
{ {
// 줄바꿈 유지 + XSS 방지 // 줄바꿈 유지 + XSS 방지
return nl2br(e($text)); return nl2br(e($text));
} }
@ -235,7 +235,7 @@ final class AdminMailController extends Controller
$line = trim((string)$line); $line = trim((string)$line);
if ($line === '') continue; if ($line === '') continue;
// 빈열 유지(열 밀림 방지) // 빈열 유지(열 밀림 방지)
$cols = array_map('trim', explode(',', $line)); $cols = array_map('trim', explode(',', $line));
$email = strtolower(trim($cols[0] ?? '')); $email = strtolower(trim($cols[0] ?? ''));

View File

@ -47,7 +47,7 @@ final class MeController
]); ]);
} }
// 핵심: 성공도 view() 말고 redirect // 핵심: 성공도 view() 말고 redirect
return redirect() return redirect()
->route('admin.me') ->route('admin.me')
->with('toast', [ ->with('toast', [
@ -79,7 +79,7 @@ final class MeController
]); ]);
} }
// 핵심: 성공도 view() 말고 redirect // 핵심: 성공도 view() 말고 redirect
return redirect() return redirect()
->route('admin.me') ->route('admin.me')
->with('toast', [ ->with('toast', [

View File

@ -14,7 +14,7 @@ final class AdminMemberJoinFilterController extends Controller
public function index(Request $request) public function index(Request $request)
{ {
// query 전달(전역 request 의존 줄이기) // query 전달(전역 request 의존 줄이기)
$data = $this->service->indexData($request->query()); $data = $this->service->indexData($request->query());
return view('admin.members.join_filters.index', $data); return view('admin.members.join_filters.index', $data);
@ -129,7 +129,7 @@ final class AdminMemberJoinFilterController extends Controller
'admin_phone' => ['nullable', 'array'], 'admin_phone' => ['nullable', 'array'],
'admin_phone.*' => ['string', 'max:30'], 'admin_phone.*' => ['string', 'max:30'],
// 모달에서 유지용(검증대상 아님) - withInput을 위해 받아둠 // 모달에서 유지용(검증대상 아님) - withInput을 위해 받아둠
'filter_seq' => ['nullable', 'string', 'max:20'], 'filter_seq' => ['nullable', 'string', 'max:20'],
]); ]);

View File

@ -75,7 +75,7 @@ final class AdminMemberMarketingController
$res = $this->service->exportZip($data, $zipPassword); $res = $this->service->exportZip($data, $zipPassword);
if (!($res['ok'] ?? false)) { if (!($res['ok'] ?? false)) {
// 실패도 기록(원인 추적용) — 비밀번호는 기록하지 않음 // 실패도 기록(원인 추적용) — 비밀번호는 기록하지 않음
$this->audit->log( $this->audit->log(
actorAdminId: $actorAdminId, actorAdminId: $actorAdminId,
action: 'admin.member.export.fail', action: 'admin.member.export.fail',
@ -100,7 +100,7 @@ final class AdminMemberMarketingController
$zipPath = (string)($res['zip_path'] ?? ''); $zipPath = (string)($res['zip_path'] ?? '');
$downloadName = (string)($res['download_name'] ?? 'members.zip'); $downloadName = (string)($res['download_name'] ?? 'members.zip');
// 성공 기록 // 성공 기록
$bytes = (is_string($zipPath) && $zipPath !== '' && file_exists($zipPath)) ? @filesize($zipPath) : null; $bytes = (is_string($zipPath) && $zipPath !== '' && file_exists($zipPath)) ? @filesize($zipPath) : null;
$this->audit->log( $this->audit->log(

View File

@ -24,7 +24,7 @@ final class AdminNoticeController extends Controller
$templates = $this->service->paginate($filters, 15); $templates = $this->service->paginate($filters, 15);
return view('admin.notice.index', [ return view('admin.notice.index', [
'templates' => $templates, // blade 호환 'templates' => $templates, // blade 호환
'filters' => $filters, 'filters' => $filters,
]); ]);
} }
@ -95,7 +95,7 @@ final class AdminNoticeController extends Controller
); );
return redirect() return redirect()
->route('admin.notice.edit', ['id' => $id] + $request->query()) // 쿼리 유지 ->route('admin.notice.edit', ['id' => $id] + $request->query()) // 쿼리 유지
->with('ok', '공지사항이 저장되었습니다.'); ->with('ok', '공지사항이 저장되었습니다.');
} }
@ -104,7 +104,7 @@ final class AdminNoticeController extends Controller
$this->service->delete($id); $this->service->delete($id);
return redirect() return redirect()
->route('admin.notice.index', $request->query()) // 쿼리 유지 ->route('admin.notice.index', $request->query()) // 쿼리 유지
->with('ok', '공지사항이 삭제되었습니다.'); ->with('ok', '공지사항이 삭제되었습니다.');
} }

View File

@ -39,7 +39,7 @@ final class AdminMediaController
$request->only('folder_name'), $request->only('folder_name'),
$file, $file,
$customName, $customName,
(int) $index, // [핵심 수정] (int)를 붙여서 정수로 강제 변환 (int) $index, // [핵심 수정] (int)를 붙여서 정수로 강제 변환
$actorId, $actorId,
$ip, $ip,
$ua $ua

View File

@ -23,7 +23,7 @@ final class AdminPinController
return redirect()->back()->with('toast', ['type' => 'danger', 'message' => '잘못된 접근입니다.']); return redirect()->back()->with('toast', ['type' => 'danger', 'message' => '잘못된 접근입니다.']);
} }
// 수정된 Service 반환값(배열) 받기 // 수정된 Service 반환값(배열) 받기
$pinData = $this->service->getPinsBySku($skuId, $request->all()); $pinData = $this->service->getPinsBySku($skuId, $request->all());
$pins = $pinData['pins']; $pins = $pinData['pins'];
$stats = $pinData['stats']; // 통계 데이터 $stats = $pinData['stats']; // 통계 데이터

View File

@ -260,7 +260,7 @@ class FindIdController extends Controller
return $head . str_repeat('*', max(1, $localLen - 3)) . $tail . '@' . $domain; return $head . str_repeat('*', max(1, $localLen - 3)) . $tail . '@' . $domain;
} }
// 기본 규칙: 앞 3글자 + ***** + 뒤 2글자 // 기본 규칙: 앞 3글자 + ***** + 뒤 2글자
$head = mb_substr($local, 0, 3, 'UTF-8'); $head = mb_substr($local, 0, 3, 'UTF-8');
$tail = mb_substr($local, -2, 2, 'UTF-8'); $tail = mb_substr($local, -2, 2, 'UTF-8');

View File

@ -92,7 +92,7 @@ class FindPasswordController extends Controller
]); ]);
} }
// 인증완료 세션 세팅 // 인증완료 세션 세팅
if (!empty($res['session'])) { if (!empty($res['session'])) {
$request->session()->put('find_pw', $res['session']); $request->session()->put('find_pw', $res['session']);
} }

View File

@ -357,7 +357,7 @@ class RegisterController extends Controller
$email = (string) $request->input('login_id'); $email = (string) $request->input('login_id');
// repo에서 mem_info.email 중복 체크 // repo에서 mem_info.email 중복 체크
$exists = $repo->existsEmail($email); $exists = $repo->existsEmail($email);
return response()->json([ return response()->json([

View File

@ -729,7 +729,7 @@ final class InfoGateController extends Controller
$pw = (string) $request->input('password'); $pw = (string) $request->input('password');
$pin2 = (string) $request->input('pin2'); $pin2 = (string) $request->input('pin2');
// 1차 비밀번호 검증(Repo) // 1차 비밀번호 검증(Repo)
if (!$repo->verifyLegacyPassword($memNo, $pw)) { if (!$repo->verifyLegacyPassword($memNo, $pw)) {
return response()->json([ return response()->json([
'ok' => false, 'ok' => false,
@ -738,7 +738,7 @@ final class InfoGateController extends Controller
], 422); ], 422);
} }
// 2차 비밀번호 검증(Repo) // 2차 비밀번호 검증(Repo)
if (!$repo->verifyPin2($memNo, $pin2)) { if (!$repo->verifyPin2($memNo, $pin2)) {
return response()->json([ return response()->json([
'ok' => false, 'ok' => false,
@ -747,7 +747,7 @@ final class InfoGateController extends Controller
], 422); ], 422);
} }
// 탈퇴 가능 조건 검증 + 처리(Service) // 탈퇴 가능 조건 검증 + 처리(Service)
try { try {
$res = $memInfoService->withdrawMember($memNo); $res = $memInfoService->withdrawMember($memNo);

View File

@ -13,13 +13,14 @@ final class UsageController extends Controller
) {} ) {}
/** /**
* GET /mypage/usage?attempt_id=... * GET /mypage/usage
* - 리스트 + 검색 + 페이징
* - 호환: /mypage/usage?attempt_id=123 -> show로 redirect
*/ */
public function index(Request $request) public function index(Request $request)
{ {
// legacy.auth가 있지만, 결제 플로우 안전장치로 한 번 더
if ((bool)session('_sess._login_') !== true) { if ((bool)session('_sess._login_') !== true) {
return redirect()->route('web.auth.login'); // 프로젝트 로그인 라우트에 맞춰 조정 return redirect()->route('web.auth.login');
} }
$memNo = (int)session('_sess._mno', 0); $memNo = (int)session('_sess._mno', 0);
@ -28,8 +29,95 @@ final class UsageController extends Controller
$attemptId = $request->query('attempt_id'); $attemptId = $request->query('attempt_id');
$attemptId = is_numeric($attemptId) ? (int)$attemptId : null; $attemptId = is_numeric($attemptId) ? (int)$attemptId : null;
$data = $this->service->buildPageData($attemptId, $memNo); if ($attemptId !== null && $attemptId >= 1) {
return redirect()->route('web.mypage.usage.show', ['attemptId' => $attemptId]);
}
$filters = [
'q' => trim((string)$request->query('q', '')),
'method' => trim((string)$request->query('method', '')),
'status' => trim((string)$request->query('status', '')),
'from' => trim((string)$request->query('from', '')),
'to' => trim((string)$request->query('to', '')),
];
$data = $this->service->buildListPageData($memNo, $filters);
return view('web.mypage.usage.index', $data); return view('web.mypage.usage.index', $data);
} }
/**
* GET /mypage/usage/{attemptId}
*/
public function show(Request $request, int $attemptId)
{
if ((bool)session('_sess._login_') !== true) {
return redirect()->route('web.auth.login');
}
$memNo = (int)session('_sess._mno', 0);
if ($memNo <= 0) abort(403);
$data = $this->service->buildDetailPageData($attemptId, $memNo);
return view('web.mypage.usage.show', $data);
}
/**
* POST /mypage/usage/{attemptId}/open
* - "오픈(확인)" 처리: ret_data에 pin_opened_at 기록
*/
public function openPins(Request $request, int $attemptId)
{
if ((bool)session('_sess._login_') !== true) {
return redirect()->route('web.auth.login');
}
$memNo = (int)session('_sess._mno', 0);
if ($memNo <= 0) abort(403);
$out = $this->service->openPins($attemptId, $memNo);
if (!($out['ok'] ?? false)) {
return redirect()->back()->with('error', (string)($out['message'] ?? '처리 실패'));
}
return redirect()->route('web.mypage.usage.show', ['attemptId' => $attemptId])
->with('success', '핀 확인이 완료되었습니다.');
}
/**
* POST /mypage/usage/{attemptId}/cancel
* - 결제완료 취소( 오픈 전만)
*/
public function cancel(Request $request, int $attemptId)
{
if ((bool)session('_sess._login_') !== true) {
return redirect()->route('web.auth.login');
}
$memNo = (int)session('_sess._mno', 0);
if ($memNo <= 0) abort(403);
$data = $request->validate([
'reason' => ['nullable','string','max:255'],
]);
$out = $this->service->cancelPaidAttempt($attemptId, $memNo, (string)($data['reason'] ?? '사용자 요청'));
if (!($out['ok'] ?? false)) {
return redirect()->back()->with('error', (string)($out['message'] ?? '취소 실패'));
}
return redirect()->route('web.mypage.usage.show', array_filter([
'attemptId' => $attemptId,
'q' => $request->input('q'),
'method' => $request->input('method'),
'status' => $request->input('status'),
'from' => $request->input('from'),
'to' => $request->input('to'),
'page' => $request->input('page'),
], fn ($v) => $v !== null && $v !== ''))
->with('success', '결제가 취소되었습니다.');
}
} }

View File

@ -56,13 +56,11 @@ final class DanalController extends Controller
if (($out['ok'] ?? false) && ($out['status'] ?? '') === 'paid') { if (($out['ok'] ?? false) && ($out['status'] ?? '') === 'paid') {
$attemptId = (int)($out['meta']['attempt_id'] ?? 0); $attemptId = (int)($out['meta']['attempt_id'] ?? 0);
$redirect = url("/mypage/usage?attempt_id={$attemptId}");
return view('web.payments.danal.finish_top_action', [ return view('web.payments.danal.finish_top_action', [
'action' => 'close_modal', 'action' => 'close_modal',
'title' => '결제완료', 'title' => '결제완료',
'message' => '결제가 완료되었습니다. 구매페이지로 이동합니다.', 'message' => '결제가 완료되었습니다. 구매페이지로 이동합니다.',
'redirect' => url($redirect), 'redirect' => url("/mypage/usage?attempt_id={$attemptId}"),
]); ]);
} }
@ -83,13 +81,11 @@ final class DanalController extends Controller
if (($out['ok'] ?? false) && ($out['status'] ?? '') === 'issued') { if (($out['ok'] ?? false) && ($out['status'] ?? '') === 'issued') {
$attemptId = (int)($out['meta']['attempt_id'] ?? 0); $attemptId = (int)($out['meta']['attempt_id'] ?? 0);
$redirect = url("/mypage/usage?attempt_id={$attemptId}");
return view('web.payments.danal.finish_top_action', [ return view('web.payments.danal.finish_top_action', [
'action' => 'close_modal', 'action' => 'close_modal',
'title' => '가상계좌 발급', 'title' => '가상계좌 발급',
'message' => '가상계좌가 발급되었습니다. 입금 후 결제가 완료됩니다. 구매페이지로 이동합니다.', 'message' => '가상계좌가 발급되었습니다. 입금 후 결제가 완료됩니다. 구매페이지로 이동합니다.',
'redirect' => url($redirect), 'redirect' => url("/mypage/usage?attempt_id={$attemptId}"),
]); ]);
} }
@ -120,13 +116,11 @@ final class DanalController extends Controller
if (($out['ok'] ?? false) && ($out['status'] ?? '') === 'paid') { if (($out['ok'] ?? false) && ($out['status'] ?? '') === 'paid') {
$attemptId = (int)($out['meta']['attempt_id'] ?? 0); $attemptId = (int)($out['meta']['attempt_id'] ?? 0);
$redirect = url("/mypage/usage?attempt_id={$attemptId}");
return view('web.payments.danal.finish_top_action', [ return view('web.payments.danal.finish_top_action', [
'action' => 'close_modal', 'action' => 'close_modal',
'title' => '결제완료', 'title' => '결제완료',
'message' => '결제가 완료되었습니다. 구매페이지로 이동합니다.', 'message' => '결제가 완료되었습니다. 구매페이지로 이동합니다.',
'redirect' => url($redirect), 'redirect' => url("/mypage/usage?attempt_id={$attemptId}"),
]); ]);
} }
@ -177,13 +171,11 @@ final class DanalController extends Controller
if (($out['ok'] ?? false) && ($out['status'] ?? '') === 'paid') { if (($out['ok'] ?? false) && ($out['status'] ?? '') === 'paid') {
$attemptId = (int)($out['meta']['attempt_id'] ?? 0); $attemptId = (int)($out['meta']['attempt_id'] ?? 0);
$redirect = url("/mypage/usage?attempt_id={$attemptId}");
return view('web.payments.danal.finish_top_action', [ return view('web.payments.danal.finish_top_action', [
'action' => 'close_modal', 'action' => 'close_modal',
'title' => '결제완료', 'title' => '결제완료',
'message' => '결제가 완료되었습니다. 구매페이지로 이동합니다.', 'message' => '결제가 완료되었습니다. 구매페이지로 이동합니다.',
'redirect' => url($redirect), 'redirect' => url("/mypage/usage?attempt_id={$attemptId}"),
]); ]);
} }
@ -214,7 +206,7 @@ final class DanalController extends Controller
$out = $this->service->handleCancel($token); $out = $this->service->handleCancel($token);
// 취소면: iframe 닫고 showMsg 실행 // 취소면: iframe 닫고 showMsg 실행
if (($out['meta']['code'] ?? '') === 'CANCEL') { if (($out['meta']['code'] ?? '') === 'CANCEL') {
return view('web.payments.danal.finish_top_action', [ return view('web.payments.danal.finish_top_action', [
'action' => 'close_modal', 'action' => 'close_modal',

View File

@ -12,7 +12,7 @@ final class AdminIpAllowlist
{ {
$allowed = config('admin.allowed_ips', []); $allowed = config('admin.allowed_ips', []);
// 개발(local/testing)에서는 allowlist 비어있으면 전체 허용 // 개발(local/testing)에서는 allowlist 비어있으면 전체 허용
if (!$allowed && !app()->environment('production')) { if (!$allowed && !app()->environment('production')) {
return $next($request); return $next($request);
} }

View File

@ -24,7 +24,7 @@ class AdminUser extends Authenticatable
'password_changed_at' => 'datetime', 'password_changed_at' => 'datetime',
'totp_enabled' => 'boolean', 'totp_enabled' => 'boolean',
'totp_confirmed_at' => 'datetime', 'totp_confirmed_at' => 'datetime',
'password' => 'hashed', // 이걸로 통일 'password' => 'hashed', // 이걸로 통일
'must_reset_password' => 'boolean', 'must_reset_password' => 'boolean',
'totp_enabled' => 'boolean', 'totp_enabled' => 'boolean',
'totp_verified_at' => 'datetime', 'totp_verified_at' => 'datetime',

View File

@ -10,7 +10,7 @@ final class GcPinOrder extends Model
protected $table = 'gc_pin_order'; protected $table = 'gc_pin_order';
protected $fillable = [ protected $fillable = [
'oid','mem_no','order_type','stat_pay','stat_tax', 'oid','mem_no','products_id','products_name','order_type','stat_pay','stat_tax',
'subtotal_amount','fee_amount','pg_fee_amount','discount_amount','pay_money', 'subtotal_amount','fee_amount','pg_fee_amount','discount_amount','pay_money',
'provider','pay_method','pg_tid','ret_code','ret_msg', 'provider','pay_method','pg_tid','ret_code','ret_msg',
'pay_data','ret_data','ordered_at','paid_at','cancelled_at', 'pay_data','ret_data','ordered_at','paid_at','cancelled_at',

View File

@ -97,4 +97,43 @@ final class CardGateway
$s = str_replace(["&","'","\"","\\","<",">","|","\r","\n","," , "+"], " ", $s); $s = str_replace(["&","'","\"","\\","<",">","|","\r","\n","," , "+"], " ", $s);
return trim(preg_replace('/\s+/', ' ', $s)) ?: '상품권'; return trim(preg_replace('/\s+/', ' ', $s)) ?: '상품권';
} }
public function cancel(
GcPinOrder $order,
string $cardKind,
string $tid,
?int $amount = null,
string $cancelType = 'C',
string $requester = '',
string $desc = ''
): array {
$c = $this->cfg->card($cardKind);
$amt = $amount ?? (int)$order->pay_money;
$req = [
'TID' => $tid,
'AMOUNT' => (string)$amt,
'CANCELTYPE' => $cancelType, // C:전체, P:부분
'TXTYPE' => 'CANCEL',
'SERVICETYPE' => 'DANALCARD',
];
// optional
$requester = trim($requester);
$desc = $this->safeCancelDesc($desc);
if ($requester !== '') $req['CANCELREQUESTER'] = $requester;
if ($desc !== '') $req['CANCELDESC'] = $desc;
$res = $this->client->call($c['url'], $c['cpid'], $req, $c['key'], $c['iv']);
return ['req' => $req, 'res' => $res];
}
private function safeCancelDesc(string $s): string
{
// 다날 DATA 암복호화 구간에서 문제 일으키는 문자 최소 제거
$s = str_replace(["&","'","\"","\\","<",">","|","\r","\n","," , "+"], " ", $s);
return trim(preg_replace('/\s+/', ' ', $s));
}
} }

View File

@ -258,4 +258,22 @@ final class PhoneGateway
$s = str_replace(["&","\"","\\","<",">","," , "+"], " ", $s); $s = str_replace(["&","\"","\\","<",">","," , "+"], " ", $s);
return trim(preg_replace('/\s+/', ' ', $s)) ?: '상품권'; return trim(preg_replace('/\s+/', ' ', $s)) ?: '상품권';
} }
public function billCancel(string $mode, string $tid): array
{
$c = $this->cfg->phone($mode);
$binPath = rtrim($c['bin_path'], '/');
$req = [
'Command' => 'BILL_CANCEL',
'OUTPUTOPTION' => '3',
'ID' => $c['cpid'],
'PWD' => $c['pwd'],
'TID' => $tid,
];
$res = $this->runSClient($binPath, $req);
return ['req' => $req, 'res' => $res];
}
} }

View File

@ -114,4 +114,41 @@ final class WireGateway
$s = str_replace(["&","'","\"","\\","<",">","|","\r","\n","," , "+"], " ", $s); $s = str_replace(["&","'","\"","\\","<",">","|","\r","\n","," , "+"], " ", $s);
return trim(preg_replace('/\s+/', ' ', $s)) ?: '상품권'; return trim(preg_replace('/\s+/', ' ', $s)) ?: '상품권';
} }
public function cancel(
GcPinOrder $order,
string $tid,
?int $amount = null,
string $cancelType = 'C',
string $requester = '',
string $desc = ''
): array {
$c = $this->cfg->wiretransfer();
$amt = $amount ?? (int)$order->pay_money;
$req = [
'TXTYPE' => 'CANCEL',
'SERVICETYPE' => 'WIRETRANSFER',
'TID' => $tid,
'AMOUNT' => (string)$amt,
'CANCELTYPE' => $cancelType, // C:전체, P:부분
];
// optional
$requester = trim($requester);
$desc = $this->safeCancelDesc($desc);
if ($requester !== '') $req['CANCELREQUESTER'] = $requester;
if ($desc !== '') $req['CANCELDESC'] = $desc;
$res = $this->client->call((string)$c['tx_url'], (string)$c['cpid'], $req, (string)$c['key'], (string)$c['iv']);
return ['req' => $req, 'res' => $res];
}
private function safeCancelDesc(string $s): string
{
$s = str_replace(["&","'","\"","\\","<",">","|","\r","\n","," , "+"], " ", $s);
return trim(preg_replace('/\s+/', ' ', $s));
}
} }

View File

@ -69,7 +69,7 @@ final class AdminUserRepository
// ========================= // =========================
public function setPassword(AdminUser $admin, string $plainPassword): void public function setPassword(AdminUser $admin, string $plainPassword): void
{ {
// AdminUser 모델에 password cast(hashed)가 있으면 plain을 넣어도 해싱됨 // AdminUser 모델에 password cast(hashed)가 있으면 plain을 넣어도 해싱됨
$data = [ $data = [
'password' => $plainPassword, 'password' => $plainPassword,
'must_reset_password' => 0, 'must_reset_password' => 0,
@ -217,7 +217,7 @@ final class AdminUserRepository
->all(); ->all();
} }
/** (중복 선언 금지) 상세/내정보/관리페이지 공용 */ /** (중복 선언 금지) 상세/내정보/관리페이지 공용 */
public function getRolesForUser(int $adminUserId): array public function getRolesForUser(int $adminUserId): array
{ {
if (!Schema::hasTable('admin_roles') || !Schema::hasTable('admin_role_user')) return []; if (!Schema::hasTable('admin_roles') || !Schema::hasTable('admin_role_user')) return [];
@ -411,7 +411,7 @@ final class AdminUserRepository
'updated_at' => now(), 'updated_at' => now(),
]; ];
// 3회 이상이면 "영구잠금" // 3회 이상이면 "영구잠금"
if (!$locked && $next >= $limit) { if (!$locked && $next >= $limit) {
$update['locked_until'] = now(); $update['locked_until'] = now();
$locked = true; $locked = true;

View File

@ -61,7 +61,7 @@ final class AdminAuditLogRepository
$q->where('l.ip', 'like', $this->escapeLike($ip) . '%'); $q->where('l.ip', 'like', $this->escapeLike($ip) . '%');
} }
// 최신순 // 최신순
$q->orderByDesc('l.created_at')->orderByDesc('l.id'); $q->orderByDesc('l.created_at')->orderByDesc('l.id');
return $q->paginate($perPage)->withQueryString(); return $q->paginate($perPage)->withQueryString();

View File

@ -13,7 +13,7 @@ final class MemberDanalAuthTelLogRepository
{ {
$q = DB::table(self::TABLE)->select([ $q = DB::table(self::TABLE)->select([
'seq','gubun','TID','res_code','mem_no','info','rgdate', 'seq','gubun','TID','res_code','mem_no','info','rgdate',
// MariaDB: CAST(... AS JSON) 금지 → info에 바로 JSON_EXTRACT // MariaDB: CAST(... AS JSON) 금지 → info에 바로 JSON_EXTRACT
DB::raw("JSON_UNQUOTE(JSON_EXTRACT(`info`, '$.mobile_number')) AS mobile_number"), DB::raw("JSON_UNQUOTE(JSON_EXTRACT(`info`, '$.mobile_number')) AS mobile_number"),
DB::raw("JSON_UNQUOTE(JSON_EXTRACT(`info`, '$._mno')) AS info_mno"), DB::raw("JSON_UNQUOTE(JSON_EXTRACT(`info`, '$._mno')) AS info_mno"),
]); ]);

View File

@ -44,7 +44,7 @@ final class AdminMemberJoinFilterRepository
return (int) DB::table(self::TABLE)->insertGetId($data); return (int) DB::table(self::TABLE)->insertGetId($data);
} }
// bool 말고 affected rows // bool 말고 affected rows
public function update(int $seq, array $data): int public function update(int $seq, array $data): int
{ {
return (int) DB::table(self::TABLE)->where('seq', $seq)->update($data); return (int) DB::table(self::TABLE)->where('seq', $seq)->update($data);

View File

@ -86,7 +86,7 @@ final class AdminMemberMarketingRepository
$q = DB::table('mem_marketing_stats as ms') $q = DB::table('mem_marketing_stats as ms')
->join('mem_info as mi', 'mi.mem_no', '=', 'ms.mem_no'); ->join('mem_info as mi', 'mi.mem_no', '=', 'ms.mem_no');
// 스냅샷(기준일) 고정: 기본은 최신 // 스냅샷(기준일) 고정: 기본은 최신
$asOf = trim((string)($filters['as_of_date'] ?? '')); $asOf = trim((string)($filters['as_of_date'] ?? ''));
if ($asOf === '') $asOf = $this->getAsOfDate() ?: ''; if ($asOf === '') $asOf = $this->getAsOfDate() ?: '';
if ($asOf !== '') $q->where('ms.as_of_date', $asOf); if ($asOf !== '') $q->where('ms.as_of_date', $asOf);

View File

@ -24,7 +24,7 @@ final class AdminNoticeRepository
$query->where($field, 'like', '%'.$q.'%'); $query->where($field, 'like', '%'.$q.'%');
} }
// 상단공지 먼저 + 최신순 // 상단공지 먼저 + 최신순
$query->orderByDesc('first_sign') $query->orderByDesc('first_sign')
->orderByDesc('regdate') ->orderByDesc('regdate')
->orderByDesc('seq'); ->orderByDesc('seq');

View File

@ -119,7 +119,7 @@ class MemberAuthRepository
return null; return null;
} }
return $digits; // 무조건 11자리 숫자만 리턴 return $digits; // 무조건 11자리 숫자만 리턴
} }
/** /**
@ -212,7 +212,7 @@ class MemberAuthRepository
'message' => "이미 가입된 전화번호 입니다.\n\n아이디 찾기로 이동할까요?", 'message' => "이미 가입된 전화번호 입니다.\n\n아이디 찾기로 이동할까요?",
'redirect' => route('web.auth.find_id'), 'redirect' => route('web.auth.find_id'),
'matched_mem_no' => (int) $member->mem_no, 'matched_mem_no' => (int) $member->mem_no,
'matched_cell_corp' => $member->cell_corp ?? null, // 필요시 'matched_cell_corp' => $member->cell_corp ?? null, // 필요시
]); ]);
} }
@ -258,7 +258,7 @@ class MemberAuthRepository
if ($email === '') return false; if ($email === '') return false;
return DB::table('mem_info') return DB::table('mem_info')
->where('email', $email) // mem_info.email ->where('email', $email) // mem_info.email
->exists(); ->exists();
} }
@ -283,19 +283,19 @@ class MemberAuthRepository
$row = DB::table('mem_join_filter') $row = DB::table('mem_join_filter')
->whereIn('join_block', ['A', 'S']) ->whereIn('join_block', ['A', 'S'])
->where(function ($q) use ($ip4, $ip4c) { ->where(function ($q) use ($ip4, $ip4c) {
// C class (보통 gubun_code='01', filter='162.168.0.0') // C class (보통 gubun_code='01', filter='162.168.0.0')
$q->where(function ($q2) use ($ip4c) { $q->where(function ($q2) use ($ip4c) {
$q2->where('gubun_code', '01') $q2->where('gubun_code', '01')
->where('filter', $ip4c); ->where('filter', $ip4c);
}); });
// D class (보통 gubun_code='02', filter='162.168.0.2') // D class (보통 gubun_code='02', filter='162.168.0.2')
$q->orWhere(function ($q2) use ($ip4) { $q->orWhere(function ($q2) use ($ip4) {
$q2->where('gubun_code', '02') $q2->where('gubun_code', '02')
->where('filter', $ip4); ->where('filter', $ip4);
}); });
// (레거시) gubun_code가 01인데도 filter에 단일 IP가 들어있는 케이스 방어 // (레거시) gubun_code가 01인데도 filter에 단일 IP가 들어있는 케이스 방어
$q->orWhere(function ($q2) use ($ip4) { $q->orWhere(function ($q2) use ($ip4) {
$q2->where('gubun_code', '01') $q2->where('gubun_code', '01')
->where('filter', $ip4); ->where('filter', $ip4);
@ -563,7 +563,7 @@ class MemberAuthRepository
} }
} }
// mem_join_log 기록 (CI3: S 또는 A 일때만 기록) // mem_join_log 기록 (CI3: S 또는 A 일때만 기록)
$seq = (int) DB::table('mem_join_log')->insertGetId([ $seq = (int) DB::table('mem_join_log')->insertGetId([
'gubun' => $gubun, // CI3: gubun_code를 gubun에 저장 'gubun' => $gubun, // CI3: gubun_code를 gubun에 저장
'mem_no' => (int)($userInfo['mem_no'] ?? 0), // 가입 전이면 0 'mem_no' => (int)($userInfo['mem_no'] ?? 0), // 가입 전이면 0
@ -572,7 +572,7 @@ class MemberAuthRepository
'email' => (string)($userInfo['email'] ?? '-'), 'email' => (string)($userInfo['email'] ?? '-'),
'ip4' => $ip4, 'ip4' => $ip4,
'ip4_c' => $ip4c, 'ip4_c' => $ip4c,
'error_code' => $result, // join_block을 error_code에 저장(추적용) 'error_code' => $result, // join_block을 error_code에 저장(추적용)
'dt_reg' => $userInfo['dt_reg'] ?? date('Y-m-d H:i:s'), 'dt_reg' => $userInfo['dt_reg'] ?? date('Y-m-d H:i:s'),
]); ]);
@ -675,7 +675,7 @@ class MemberAuthRepository
return false; return false;
} }
// 1차 비번은 mem_st_ring.str_0 // 1차 비번은 mem_st_ring.str_0
$stored = (string) (DB::table('mem_st_ring') $stored = (string) (DB::table('mem_st_ring')
->where('mem_no', $memNo) ->where('mem_no', $memNo)
->value('str_0') ?? ''); ->value('str_0') ?? '');
@ -684,7 +684,7 @@ class MemberAuthRepository
return false; return false;
} }
// CI 방식(PASS_SET=0) - 기존 attemptLegacyLogin과 동일 // CI 방식(PASS_SET=0) - 기존 attemptLegacyLogin과 동일
$try = (string) CiPassword::make($pwPlain, 0); $try = (string) CiPassword::make($pwPlain, 0);
if ($try === '') { if ($try === '') {
return false; return false;

View File

@ -2,6 +2,7 @@
namespace App\Repositories\Mypage; namespace App\Repositories\Mypage;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
final class UsageRepository final class UsageRepository
@ -28,10 +29,18 @@ final class UsageRepository
'a.created_at as attempt_created_at', 'a.created_at as attempt_created_at',
'a.updated_at as attempt_updated_at', 'a.updated_at as attempt_updated_at',
// 추가: cancel_status 필드들
'a.cancel_status as attempt_cancel_status',
'a.cancel_requested_at as attempt_cancel_requested_at',
'a.cancel_done_at as attempt_cancel_done_at',
'a.cancel_last_code as attempt_cancel_last_code',
'a.cancel_last_msg as attempt_cancel_last_msg',
'o.id as order_id', 'o.id as order_id',
'o.oid as order_oid', 'o.oid as order_oid',
'o.mem_no as order_mem_no', 'o.mem_no as order_mem_no',
'o.stat_pay as order_stat_pay', 'o.stat_pay as order_stat_pay',
'o.products_name as order_product_name',
'o.provider as order_provider', 'o.provider as order_provider',
'o.pay_method as order_pay_method', 'o.pay_method as order_pay_method',
'o.pg_tid as order_pg_tid', 'o.pg_tid as order_pg_tid',
@ -45,11 +54,125 @@ final class UsageRepository
'o.ret_data as order_ret_data', 'o.ret_data as order_ret_data',
'o.created_at as order_created_at', 'o.created_at as order_created_at',
'o.updated_at as order_updated_at', 'o.updated_at as order_updated_at',
// 추가: order cancel_status 필드들
'o.cancel_status as order_cancel_status',
'o.cancel_requested_at as order_cancel_requested_at',
'o.cancel_done_at as order_cancel_done_at',
'o.cancel_last_code as order_cancel_last_code',
'o.cancel_last_msg as order_cancel_last_msg',
'o.cancel_reason as order_cancel_reason',
]) ])
->where('a.id', $attemptId) ->where('a.id', $attemptId)
->first(); ->first();
} }
/**
* 리스트: 검색/페이징
*/
public function paginateAttemptsWithOrder(int $memNo, array $filters, int $perPage = 20): LengthAwarePaginator
{
$q = trim((string)($filters['q'] ?? ''));
$method = trim((string)($filters['method'] ?? ''));
$status = trim((string)($filters['status'] ?? ''));
$from = trim((string)($filters['from'] ?? ''));
$to = trim((string)($filters['to'] ?? ''));
// order_items 집계 서브쿼리 (group by를 메인 쿼리에서 피해서 paginate 안정)
$oiAgg = DB::table('gc_pin_order_items')
->selectRaw('order_id, SUM(qty) as total_qty, MIN(item_name) as first_item_name')
->groupBy('order_id');
$qb = DB::table('gc_payment_attempts as a')
->leftJoin('gc_pin_order as o', 'o.id', '=', 'a.order_id')
->leftJoinSub($oiAgg, 'oi', 'oi.order_id', '=', 'o.id')
->where('a.mem_no', $memNo)
// ✅ 중요: OR 조건 전체를 반드시 하나의 where 그룹으로 묶어야 함
->where(function ($s) {
// 1) 취소완료
$s->where(function ($x) {
$x->where('a.cancel_status', 'success')
->orWhere('o.cancel_status', 'success');
})
// 2) 결제완료
->orWhere(function ($x) {
$x->where('a.status', 'paid')
->orWhere('o.stat_pay', 'p');
})
// 3) 입금대기(가상계좌)
->orWhere(function ($x) {
$x->where('a.status', 'issued')
->orWhere('o.stat_pay', 'w');
});
})
->select([
'a.id as attempt_id',
'o.oid as order_oid',
DB::raw("COALESCE(o.products_name, oi.first_item_name) as product_name"),
'oi.first_item_name as item_name',
DB::raw("COALESCE(oi.total_qty, 0) as total_qty"),
'a.pay_method as pay_method',
'o.pay_money as pay_money',
'a.status as attempt_status',
'o.stat_pay as order_stat_pay',
'a.cancel_status as attempt_cancel_status',
'o.cancel_status as order_cancel_status',
'a.created_at as created_at',
])
->orderByDesc('a.id');
// ✅ q 검색: 거래번호(o.oid / a.oid) 정확히 일치
if ($q !== '') {
$qb->where(function ($w) use ($q) {
$w->where('o.oid', $q)
->orWhere('a.oid', $q);
});
}
// 결제수단
if ($method !== '') {
$qb->where('a.pay_method', $method);
}
// ✅ 상태 필터도 화면 의미에 맞게 묶어서 처리
// (현재 화면 기준: paid / issued / cancel를 의미 상태로 쓰는 경우)
if ($status !== '') {
if ($status === 'paid') {
$qb->where(function ($x) {
$x->where('a.status', 'paid')
->orWhere('o.stat_pay', 'p');
});
} elseif ($status === 'issued') {
$qb->where(function ($x) {
$x->where('a.status', 'issued')
->orWhere('o.stat_pay', 'w');
});
} elseif (in_array($status, ['cancel', 'cancelled', 'canceled'], true)) {
$qb->where(function ($x) {
$x->where('a.cancel_status', 'success')
->orWhere('o.cancel_status', 'success');
});
} else {
// 기타 상태는 attempts 기준으로 그대로
$qb->where('a.status', $status);
}
}
// 날짜 필터 (date 기준)
if ($from !== '') {
$qb->whereDate('a.created_at', '>=', $from);
}
if ($to !== '') {
$qb->whereDate('a.created_at', '<=', $to);
}
return $qb->paginate($perPage)->appends($filters);
}
public function getOrderItems(int $orderId) public function getOrderItems(int $orderId)
{ {
return DB::table('gc_pin_order_items') return DB::table('gc_pin_order_items')
@ -74,9 +197,34 @@ final class UsageRepository
->get(); ->get();
$out = []; $out = [];
foreach ($rows as $r) { foreach ($rows as $r) $out[(string)$r->status] = (int)$r->cnt;
$out[(string)$r->status] = (int)$r->cnt;
}
return $out; return $out;
} }
/**
* 목록(오픈 / 표시용) 반납(홀드 해제) 이번 범위 제외
*/
public function getPinsForOrder(int $orderId): array
{
$rows = DB::table('gc_pins')
->where('order_id', $orderId)
->orderBy('id', 'asc')
->get();
return array_map(fn($r) => (array)$r, $rows->all());
}
/**
* 취소 로그 조회
*/
public function getCancelLogsForAttempt(int $attemptId, int $limit = 20): array
{
$rows = DB::table('gc_payment_cancel_logs')
->where('attempt_id', $attemptId)
->orderByDesc('id')
->limit($limit)
->get();
return array_map(fn($r) => (array)$r, $rows->all());
}
} }

View File

@ -15,7 +15,7 @@ class RecaptchaV3Rule implements ValidationRule
{ {
$token = (string) $value; $token = (string) $value;
// 개발환경에서만 + 전용 로그파일 // 개발환경에서만 + 전용 로그파일
if (app()->environment(['local', 'development', 'staging'])) { if (app()->environment(['local', 'development', 'staging'])) {
Log::channel('google_recaptcha')->info('[incoming]', [ Log::channel('google_recaptcha')->info('[incoming]', [
'expected_action' => $this->action, 'expected_action' => $this->action,

View File

@ -37,22 +37,22 @@ final class AdminAuthService
{ {
$admin = $this->users->findByEmail($email); $admin = $this->users->findByEmail($email);
// 계정 없으면 invalid (계정 존재 여부 노출 방지) // 계정 없으면 invalid (계정 존재 여부 노출 방지)
if (!$admin) { if (!$admin) {
return ['state' => 'invalid']; return ['state' => 'invalid'];
} }
// 상태 체크: active만 로그인 허용 (너 DB가 active/blocked라면 여기만 쓰자) // 상태 체크: active만 로그인 허용 (너 DB가 active/blocked라면 여기만 쓰자)
if (($admin->status ?? 'blocked') !== 'active') { if (($admin->status ?? 'blocked') !== 'active') {
return ['state' => 'blocked']; return ['state' => 'blocked'];
} }
// 잠금 체크: locked_until != null 이면 "잠금 상태"(영구잠금) // 잠금 체크: locked_until != null 이면 "잠금 상태"(영구잠금)
if (($admin->locked_until ?? null) !== null) { if (($admin->locked_until ?? null) !== null) {
return ['state' => 'locked', 'admin_id' => (int)$admin->id]; return ['state' => 'locked', 'admin_id' => (int)$admin->id];
} }
// 비밀번호 검증 // 비밀번호 검증
if (!Hash::check($password, (string)$admin->password)) { if (!Hash::check($password, (string)$admin->password)) {
// 실패 카운트 +1, 3회 이상이면 잠금 // 실패 카운트 +1, 3회 이상이면 잠금
@ -66,17 +66,17 @@ final class AdminAuthService
return ['state' => 'invalid', 'attempts_left' => $left]; return ['state' => 'invalid', 'attempts_left' => $left];
} }
// 3회 안에 성공하면 실패/잠금 초기화 // 3회 안에 성공하면 실패/잠금 초기화
if ((int)($admin->failed_login_count ?? 0) > 0 || ($admin->locked_until ?? null) !== null) { if ((int)($admin->failed_login_count ?? 0) > 0 || ($admin->locked_until ?? null) !== null) {
$this->users->clearLoginFailAndUnlock((int)$admin->id); $this->users->clearLoginFailAndUnlock((int)$admin->id);
} }
// 비번 리셋 강제 정책 // 비번 리셋 강제 정책
if ((int)($admin->must_reset_password ?? 0) === 1) { if ((int)($admin->must_reset_password ?? 0) === 1) {
return ['state' => 'must_reset', 'admin_id' => (int)$admin->id]; return ['state' => 'must_reset', 'admin_id' => (int)$admin->id];
} }
// TOTP 모드면 SMS 발송 없이 "TOTP 입력"으로 보냄 // TOTP 모드면 SMS 발송 없이 "TOTP 입력"으로 보냄
$totpEnabled = (int)($admin->totp_enabled ?? 0) === 1; $totpEnabled = (int)($admin->totp_enabled ?? 0) === 1;
$totpReady = $totpEnabled $totpReady = $totpEnabled
&& !empty($admin->totp_secret_enc) && !empty($admin->totp_secret_enc)
@ -313,7 +313,7 @@ final class AdminAuthService
$enc = (string) $admin->phone_enc; $enc = (string) $admin->phone_enc;
if ($enc === '') return ''; if ($enc === '') return '';
// 1) encryptString() 로 저장한 값은 decryptString() 으로 복호화해야 함 // 1) encryptString() 로 저장한 값은 decryptString() 으로 복호화해야 함
try { try {
$raw = Crypt::decryptString($enc); $raw = Crypt::decryptString($enc);
$digits = preg_replace('/\D+/', '', (string) $raw) ?: ''; $digits = preg_replace('/\D+/', '', (string) $raw) ?: '';
@ -329,7 +329,7 @@ final class AdminAuthService
return $digits; return $digits;
} catch (\Throwable $e1) { } catch (\Throwable $e1) {
// 2) 혹시 예전 encrypt() (serialize 기반)로 저장한 데이터가 섞였으면 이걸로 복구 // 2) 혹시 예전 encrypt() (serialize 기반)로 저장한 데이터가 섞였으면 이걸로 복구
try { try {
Log::warning('[admin-auth] phone decrypt failed', [ Log::warning('[admin-auth] phone decrypt failed', [
'admin_id' => $admin->id, 'admin_id' => $admin->id,
@ -341,7 +341,7 @@ final class AdminAuthService
$digits = preg_replace('/\D+/', '', (string) $raw) ?: ''; $digits = preg_replace('/\D+/', '', (string) $raw) ?: '';
return preg_match('/^\d{9,15}$/', $digits) ? $digits : ''; return preg_match('/^\d{9,15}$/', $digits) ? $digits : '';
} catch (\Throwable $e2) { } catch (\Throwable $e2) {
// 3) 진짜 평문 숫자만 예외적으로 허용(암호문에서 숫자 긁는 fallback 절대 금지) // 3) 진짜 평문 숫자만 예외적으로 허용(암호문에서 숫자 긁는 fallback 절대 금지)
if (preg_match('/^\d{9,15}$/', $enc)) { if (preg_match('/^\d{9,15}$/', $enc)) {
return $enc; return $enc;
} }

View File

@ -52,8 +52,8 @@ final class AdminMeService
$phone = trim((string) $request->input('phone', '')); $phone = trim((string) $request->input('phone', ''));
$data = $request->validate([ $data = $request->validate([
'nickname' => ['required', 'string', 'min:2', 'max:80'], // 닉네임(예: super admin) 'nickname' => ['required', 'string', 'min:2', 'max:80'], // 닉네임(예: super admin)
'name' => ['required', 'string', 'min:2', 'max:80'], // 성명(본명) 'name' => ['required', 'string', 'min:2', 'max:80'], // 성명(본명)
'phone' => ['nullable', 'string', 'max:30'], 'phone' => ['nullable', 'string', 'max:30'],
]); ]);
@ -92,7 +92,7 @@ final class AdminMeService
'phone_enc' => $phoneEnc, 'phone_enc' => $phoneEnc,
'phone_hash' => $phoneHash, 'phone_hash' => $phoneHash,
// updated_by 컬럼이 없어도 Repository가 자동 제거함 // updated_by 컬럼이 없어도 Repository가 자동 제거함
'updated_by' => $adminId, 'updated_by' => $adminId,
]; ];

View File

@ -21,7 +21,7 @@ final class AdminAuditLogService
'ip' => $this->safeStr($query['ip'] ?? '', 45), 'ip' => $this->safeStr($query['ip'] ?? '', 45),
]; ];
// 기간 역전 방지 // 기간 역전 방지
if ($filters['date_from'] && $filters['date_to']) { if ($filters['date_from'] && $filters['date_to']) {
if (strcmp($filters['date_from'], $filters['date_to']) > 0) { if (strcmp($filters['date_from'], $filters['date_to']) > 0) {
[$filters['date_from'], $filters['date_to']] = [$filters['date_to'], $filters['date_from']]; [$filters['date_from'], $filters['date_to']] = [$filters['date_to'], $filters['date_from']];
@ -61,7 +61,7 @@ final class AdminAuditLogService
'user_agent' => (string)($row['user_agent'] ?? ''), 'user_agent' => (string)($row['user_agent'] ?? ''),
// pretty 출력 (모달에서 바로 textContent로 넣기 좋게) // pretty 출력 (모달에서 바로 textContent로 넣기 좋게)
'before_pretty' => $this->prettyJson($beforeRaw), 'before_pretty' => $this->prettyJson($beforeRaw),
'after_pretty' => $this->prettyJson($afterRaw), 'after_pretty' => $this->prettyJson($afterRaw),

View File

@ -32,14 +32,14 @@ final class MemberJoinLogService
'phone_enc' => null, 'phone_enc' => null,
]; ];
// 기간 역전 방지 // 기간 역전 방지
if ($filters['date_from'] && $filters['date_to']) { if ($filters['date_from'] && $filters['date_to']) {
if (strcmp($filters['date_from'], $filters['date_to']) > 0) { if (strcmp($filters['date_from'], $filters['date_to']) > 0) {
[$filters['date_from'], $filters['date_to']] = [$filters['date_to'], $filters['date_from']]; [$filters['date_from'], $filters['date_to']] = [$filters['date_to'], $filters['date_from']];
} }
} }
// 전화번호 검색: encrypt(숫자) = cell_phone (정확일치) // 전화번호 검색: encrypt(숫자) = cell_phone (정확일치)
$phoneDigits = preg_replace('/\D+/', '', (string)$filters['phone']) ?: ''; $phoneDigits = preg_replace('/\D+/', '', (string)$filters['phone']) ?: '';
if ($phoneDigits !== '' && preg_match('/^\d{10,11}$/', $phoneDigits)) { if ($phoneDigits !== '' && preg_match('/^\d{10,11}$/', $phoneDigits)) {
try { try {
@ -53,7 +53,7 @@ final class MemberJoinLogService
$page = $this->repo->paginate($filters, 30); $page = $this->repo->paginate($filters, 30);
// 리스트 표시용 가공(복호화/포맷) // 리스트 표시용 가공(복호화/포맷)
$seed = app(CiSeedCrypto::class); $seed = app(CiSeedCrypto::class);
$items = []; $items = [];

View File

@ -100,7 +100,7 @@ final class AdminMailService
$id = $this->tplRepo->create($payload); $id = $this->tplRepo->create($payload);
// 성공시에만 감사로그 (기존 흐름 방해 X) // 성공시에만 감사로그 (기존 흐름 방해 X)
if ((int)$id > 0) { if ((int)$id > 0) {
$req = request(); $req = request();
$this->audit->log( $this->audit->log(
@ -121,7 +121,7 @@ final class AdminMailService
public function updateTemplate(int $adminId, int $id, array $data): array public function updateTemplate(int $adminId, int $id, array $data): array
{ {
// before 스냅샷: repo에 get/find가 있으면 사용, 없으면 null로 진행(흐름 방해 X) // before 스냅샷: repo에 get/find가 있으면 사용, 없으면 null로 진행(흐름 방해 X)
$before = null; $before = null;
try { try {
if (method_exists($this->tplRepo, 'find')) { if (method_exists($this->tplRepo, 'find')) {
@ -148,7 +148,7 @@ final class AdminMailService
$ok = ($affected >= 0); $ok = ($affected >= 0);
// 성공시에만 감사로그 // 성공시에만 감사로그
if ($ok) { if ($ok) {
$req = request(); $req = request();
@ -228,7 +228,7 @@ final class AdminMailService
{ {
$mode = (string)$data['send_mode']; $mode = (string)$data['send_mode'];
// (선택) 템플릿을 서버에서 강제 적용하고 싶으면 template_id가 넘어왔을 때 덮어쓰기 // (선택) 템플릿을 서버에서 강제 적용하고 싶으면 template_id가 넘어왔을 때 덮어쓰기
// Blade에서 tplSelect에 name="template_id" 꼭 넣어야 들어옴 // Blade에서 tplSelect에 name="template_id" 꼭 넣어야 들어옴
$tplId = (int)($data['template_id'] ?? 0); $tplId = (int)($data['template_id'] ?? 0);
if ($tplId > 0) { if ($tplId > 0) {
@ -297,7 +297,7 @@ final class AdminMailService
foreach ($list as $r) { foreach ($list as $r) {
$tokens = $r['tokens'] ?? []; $tokens = $r['tokens'] ?? [];
// strtr로 치환(키-값 배열) // strtr로 치환(키-값 배열)
$subjectFinal = $this->applyTokens((string)$data['subject'], $tokens); $subjectFinal = $this->applyTokens((string)$data['subject'], $tokens);
$bodyFinal = $this->applyTokens((string)$data['body'], $tokens); $bodyFinal = $this->applyTokens((string)$data['body'], $tokens);
@ -374,7 +374,7 @@ final class AdminMailService
{ {
// 형식: // 형식:
// email,token1,token2,... // email,token1,token2,...
// 1열=email, 2열={_text_02_}, 3열={_text_03_} ... // 1열=email, 2열={_text_02_}, 3열={_text_03_} ...
$lines = preg_split('/\r\n|\r|\n/', $text) ?: []; $lines = preg_split('/\r\n|\r|\n/', $text) ?: [];
$out = []; $out = [];
@ -382,7 +382,7 @@ final class AdminMailService
$line = trim((string)$line); $line = trim((string)$line);
if ($line === '') continue; if ($line === '') continue;
// 빈열 유지(열 밀림 방지) => filter 하지 않음 // 빈열 유지(열 밀림 방지) => filter 하지 않음
$cols = array_map('trim', explode(',', $line)); $cols = array_map('trim', explode(',', $line));
$email = $cols[0] ?? ''; $email = $cols[0] ?? '';

View File

@ -93,7 +93,7 @@ final class AdminMemberJoinFilterService
$seq = $this->repo->insert($data); $seq = $this->repo->insert($data);
if ($seq <= 0) return $this->fail('등록에 실패했습니다.'); if ($seq <= 0) return $this->fail('등록에 실패했습니다.');
// 감사로그: 성공시에만 (기존 흐름 방해 X) // 감사로그: 성공시에만 (기존 흐름 방해 X)
$aid = (int)($actorAdminId ?? auth('admin')->id() ?? 0); $aid = (int)($actorAdminId ?? auth('admin')->id() ?? 0);
if ($aid > 0) { if ($aid > 0) {
$req = request(); $req = request();
@ -133,11 +133,11 @@ final class AdminMemberJoinFilterService
$affected = $this->repo->update($seq, $data); $affected = $this->repo->update($seq, $data);
// 0건(변경 없음)도 성공 처리 (기존 정책 유지) // 0건(변경 없음)도 성공 처리 (기존 정책 유지)
if ($affected === 0) return $this->ok('변경사항이 없습니다. (저장 완료)'); if ($affected === 0) return $this->ok('변경사항이 없습니다. (저장 완료)');
if ($affected > 0) { if ($affected > 0) {
// 감사로그: 성공시에만 // 감사로그: 성공시에만
$aid = (int)($actorAdminId ?? auth('admin')->id() ?? 0); $aid = (int)($actorAdminId ?? auth('admin')->id() ?? 0);
if ($aid > 0) { if ($aid > 0) {
$req = request(); $req = request();
@ -185,7 +185,7 @@ final class AdminMemberJoinFilterService
$affected = $this->repo->delete($seq); $affected = $this->repo->delete($seq);
if ($affected <= 0) return $this->fail('삭제에 실패했습니다.'); if ($affected <= 0) return $this->fail('삭제에 실패했습니다.');
// 감사로그: 성공시에만 // 감사로그: 성공시에만
$aid = (int)($actorAdminId ?? auth('admin')->id() ?? 0); $aid = (int)($actorAdminId ?? auth('admin')->id() ?? 0);
if ($aid > 0) { if ($aid > 0) {
$req = request(); $req = request();

View File

@ -138,7 +138,7 @@ final class AdminMemberMarketingService
} }
// ------------------------- // -------------------------
// 다운로드 헤더(한글) // 다운로드 헤더(한글)
// ------------------------- // -------------------------
private function headersKorean(): array private function headersKorean(): array
{ {

View File

@ -80,7 +80,7 @@ final class AdminMemberService
$plainPhone = $this->plainPhone((string)($member->cell_phone ?? '')); $plainPhone = $this->plainPhone((string)($member->cell_phone ?? ''));
$phoneDisplay = $this->formatPhone($plainPhone); $phoneDisplay = $this->formatPhone($plainPhone);
// 레거시 JSON 파싱 (admin_memo / modify_log) // 레거시 JSON 파싱 (admin_memo / modify_log)
$adminMemoList = $this->legacyAdminMemoList($member->admin_memo ?? null); // old -> new normalize $adminMemoList = $this->legacyAdminMemoList($member->admin_memo ?? null); // old -> new normalize
$stateLogList = $this->legacyStateLogList($member->modify_log ?? null); // old -> new normalize $stateLogList = $this->legacyStateLogList($member->modify_log ?? null); // old -> new normalize
@ -88,7 +88,7 @@ final class AdminMemberService
$adminMemo = array_reverse($adminMemoList); $adminMemo = array_reverse($adminMemoList);
$modifyLog = array_reverse($stateLogList); $modifyLog = array_reverse($stateLogList);
// adminMap 대상 admin_num 수집 // adminMap 대상 admin_num 수집
$adminSet = []; $adminSet = [];
foreach ($adminMemo as $it) { foreach ($adminMemo as $it) {
@ -123,7 +123,7 @@ final class AdminMemberService
'plainPhone' => $plainPhone, 'plainPhone' => $plainPhone,
'phoneDisplay' => $phoneDisplay, 'phoneDisplay' => $phoneDisplay,
// 레거시 기반 결과 // 레거시 기반 결과
'adminMemo' => $adminMemo, 'adminMemo' => $adminMemo,
'modifyLog' => $modifyLog, 'modifyLog' => $modifyLog,
'adminMap' => $adminMap, 'adminMap' => $adminMap,
@ -161,7 +161,7 @@ final class AdminMemberService
$data = []; $data = [];
// 기존 modify_log에서 레거시 state_log[] 뽑기 // 기존 modify_log에서 레거시 state_log[] 뽑기
$stateLog = $this->legacyStateLogList($before->modify_log ?? null); $stateLog = $this->legacyStateLogList($before->modify_log ?? null);
$beforeStateLogLast = empty($stateLog) ? null : $stateLog[count($stateLog) - 1]; $beforeStateLogLast = empty($stateLog) ? null : $stateLog[count($stateLog) - 1];
@ -180,7 +180,7 @@ final class AdminMemberService
// (audit용) after 스냅샷은 before에서 변경분만 덮어쓰기 // (audit용) after 스냅샷은 before에서 변경분만 덮어쓰기
$afterAudit = $beforeAudit; $afterAudit = $beforeAudit;
// stat_3 변경 (1~3만 변경 허용, 4~6 금지 정책 유지) // stat_3 변경 (1~3만 변경 허용, 4~6 금지 정책 유지)
if (array_key_exists('stat_3', $input)) { if (array_key_exists('stat_3', $input)) {
$s3 = (string)($input['stat_3'] ?? ''); $s3 = (string)($input['stat_3'] ?? '');
if (!in_array($s3, ['1','2','3','4','5','6'], true)) { if (!in_array($s3, ['1','2','3','4','5','6'], true)) {
@ -208,7 +208,7 @@ final class AdminMemberService
} }
} }
// 통신사 변경 // 통신사 변경
if (array_key_exists('cell_corp', $input)) { if (array_key_exists('cell_corp', $input)) {
$corp = (string)($input['cell_corp'] ?? 'n'); $corp = (string)($input['cell_corp'] ?? 'n');
$allowed = ['n','01','02','03','04','05','06']; $allowed = ['n','01','02','03','04','05','06'];
@ -230,7 +230,7 @@ final class AdminMemberService
} }
} }
// 휴대폰 변경(암호화 저장) // 휴대폰 변경(암호화 저장)
$afterPhoneMasked = null; $afterPhoneMasked = null;
if (array_key_exists('cell_phone', $input)) { if (array_key_exists('cell_phone', $input)) {
$raw = trim((string)($input['cell_phone'] ?? '')); $raw = trim((string)($input['cell_phone'] ?? ''));
@ -266,7 +266,7 @@ final class AdminMemberService
return $this->ok('변경사항이 없습니다.'); return $this->ok('변경사항이 없습니다.');
} }
// 레거시 modify_log 저장: 반드시 {"state_log":[...]} // 레거시 modify_log 저장: 반드시 {"state_log":[...]}
$stateLog = $this->trimLegacyList($stateLog, 300); $stateLog = $this->trimLegacyList($stateLog, 300);
$data['modify_log'] = $this->encodeJsonObjectOrNull(['state_log' => $stateLog]); $data['modify_log'] = $this->encodeJsonObjectOrNull(['state_log' => $stateLog]);
@ -276,7 +276,7 @@ final class AdminMemberService
$ok = $this->repo->updateMember($memNo, $data); $ok = $this->repo->updateMember($memNo, $data);
if (!$ok) return $this->fail('저장에 실패했습니다.'); if (!$ok) return $this->fail('저장에 실패했습니다.');
// audit log (update 성공 후, 같은 트랜잭션 안에서) // audit log (update 성공 후, 같은 트랜잭션 안에서)
$afterStateLogLast = empty($stateLog) ? null : $stateLog[count($stateLog) - 1]; $afterStateLogLast = empty($stateLog) ? null : $stateLog[count($stateLog) - 1];
$afterAudit['dt_mod'] = $nowStr; $afterAudit['dt_mod'] = $nowStr;
$afterAudit['state_log_last'] = $afterStateLogLast; $afterAudit['state_log_last'] = $afterStateLogLast;
@ -442,12 +442,12 @@ final class AdminMemberService
$nowStr = $now->format('Y-m-d H:i:s'); $nowStr = $now->format('Y-m-d H:i:s');
$legacyWhen = $now->format('y-m-d H:i:s'); // 레거시(2자리년도) $legacyWhen = $now->format('y-m-d H:i:s'); // 레거시(2자리년도)
// 기존 admin_memo에서 레거시 memo[] 뽑기 // 기존 admin_memo에서 레거시 memo[] 뽑기
$list = $this->legacyAdminMemoList($before->admin_memo ?? null); $list = $this->legacyAdminMemoList($before->admin_memo ?? null);
$beforeCount = is_array($list) ? count($list) : 0; $beforeCount = is_array($list) ? count($list) : 0;
$beforeLast = $beforeCount > 0 ? $list[$beforeCount - 1] : null; $beforeLast = $beforeCount > 0 ? $list[$beforeCount - 1] : null;
// append (레거시 키 유지) // append (레거시 키 유지)
$newItem = [ $newItem = [
'memo' => $memo, 'memo' => $memo,
'when' => $legacyWhen, 'when' => $legacyWhen,
@ -456,7 +456,7 @@ final class AdminMemberService
$list[] = $newItem; $list[] = $newItem;
$list = $this->trimLegacyList($list, 300); $list = $this->trimLegacyList($list, 300);
// admin_memo는 반드시 {"memo":[...]} 형태 // admin_memo는 반드시 {"memo":[...]} 형태
$obj = ['memo' => $list]; $obj = ['memo' => $list];
$data = [ $data = [
@ -467,7 +467,7 @@ final class AdminMemberService
$ok = $this->repo->updateMember($memNo, $data); $ok = $this->repo->updateMember($memNo, $data);
if (!$ok) return $this->fail('메모 저장에 실패했습니다.'); if (!$ok) return $this->fail('메모 저장에 실패했습니다.');
// audit (성공 후) // audit (성공 후)
$afterCount = count($list); $afterCount = count($list);
$afterLast = $afterCount > 0 ? $list[$afterCount - 1] : null; $afterLast = $afterCount > 0 ? $list[$afterCount - 1] : null;
@ -533,7 +533,7 @@ final class AdminMemberService
return json_encode($obj, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return json_encode($obj, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} }
// admin_memo: {"memo":[{memo,when,admin_num}, ...]} // admin_memo: {"memo":[{memo,when,admin_num}, ...]}
private function legacyAdminMemoList($adminMemoJson): array private function legacyAdminMemoList($adminMemoJson): array
{ {
$obj = $this->decodeJsonObject($adminMemoJson); $obj = $this->decodeJsonObject($adminMemoJson);
@ -562,7 +562,7 @@ final class AdminMemberService
return []; return [];
} }
// modify_log: {"state_log":[{when,after,title,before,admin_num}, ...]} // modify_log: {"state_log":[{when,after,title,before,admin_num}, ...]}
private function legacyStateLogList($modifyLogJson): array private function legacyStateLogList($modifyLogJson): array
{ {
$obj = $this->decodeJsonObject($modifyLogJson); $obj = $this->decodeJsonObject($modifyLogJson);

View File

@ -44,7 +44,7 @@ final class AdminNoticeService
if ($wantPinned) { if ($wantPinned) {
$max = $this->repo->maxFirstSignForUpdate(); // lock 포함 $max = $this->repo->maxFirstSignForUpdate(); // lock 포함
$firstSign = $max + 1; // 상단공지 순번(큰 값이 먼저) $firstSign = $max + 1; // 상단공지 순번(큰 값이 먼저)
} }
$content = (string)($data['content'] ?? ''); $content = (string)($data['content'] ?? '');
@ -68,7 +68,7 @@ final class AdminNoticeService
$row = $this->repo->create($payload); $row = $this->repo->create($payload);
$id = (int) $row->getKey(); $id = (int) $row->getKey();
// 감사로그: 성공시에만, 실패해도 본 로직 영향 X // 감사로그: 성공시에만, 실패해도 본 로직 영향 X
if ($id > 0) { if ($id > 0) {
try { try {
$req = request(); $req = request();
@ -122,7 +122,7 @@ final class AdminNoticeService
$row = $this->repo->lockForUpdate($id); $row = $this->repo->lockForUpdate($id);
// 감사로그 before(업데이트 전) // 감사로그 before(업데이트 전)
$beforeContent = (string)($row->content ?? ''); $beforeContent = (string)($row->content ?? '');
$beforeAudit = [ $beforeAudit = [
'id' => (int)$id, 'id' => (int)$id,
@ -140,7 +140,7 @@ final class AdminNoticeService
$hiding = !empty($data['hiding']) ? 'Y' : 'N'; $hiding = !empty($data['hiding']) ? 'Y' : 'N';
// first_sign 로직 유지 // first_sign 로직 유지
$wantPinned = !empty($data['first_sign']); $wantPinned = !empty($data['first_sign']);
$firstSign = (int)($row->first_sign ?? 0); $firstSign = (int)($row->first_sign ?? 0);
@ -177,7 +177,7 @@ final class AdminNoticeService
$this->repo->update($row, $payload); $this->repo->update($row, $payload);
// 감사로그: 성공시에만, 실패해도 본 로직 영향 X // 감사로그: 성공시에만, 실패해도 본 로직 영향 X
try { try {
$actorAdminId = (int)(auth('admin')->id() ?? 0); $actorAdminId = (int)(auth('admin')->id() ?? 0);
if ($actorAdminId > 0) { if ($actorAdminId > 0) {
@ -216,7 +216,7 @@ final class AdminNoticeService
throw $e; throw $e;
} }
// 커밋 이후에만 기존 파일 삭제 // 커밋 이후에만 기존 파일 삭제
$this->deletePhysicalFiles($oldToDelete); $this->deletePhysicalFiles($oldToDelete);
} }
@ -235,7 +235,7 @@ final class AdminNoticeService
$this->repo->delete($row); $this->repo->delete($row);
// 감사로그: 성공시에만, 실패해도 본 로직 영향 X // 감사로그: 성공시에만, 실패해도 본 로직 영향 X
try { try {
$actorAdminId = (int)(auth('admin')->id() ?? 0); $actorAdminId = (int)(auth('admin')->id() ?? 0);
if ($actorAdminId > 0) { if ($actorAdminId > 0) {

View File

@ -8,7 +8,7 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
// 이미지 처리 라이브러리 (Intervention Image v3) // 이미지 처리 라이브러리 (Intervention Image v3)
use Intervention\Image\ImageManager; use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver; use Intervention\Image\Drivers\Gd\Driver;

View File

@ -137,7 +137,7 @@ final class AdminQnaService
]); ]);
} }
// 감사로그 before (변경 전) // 감사로그 before (변경 전)
$beforeAudit = [ $beforeAudit = [
'year' => $year, 'year' => $year,
'seq' => $seq, 'seq' => $seq,
@ -157,7 +157,7 @@ final class AdminQnaService
$this->repo->update($year, $seq, $payload); $this->repo->update($year, $seq, $payload);
// 감사로그 after (변경 후) — 감사로그 실패해도 본 로직 영향 X // 감사로그 after (변경 후) — 감사로그 실패해도 본 로직 영향 X
try { try {
$req = request(); $req = request();
@ -197,7 +197,7 @@ final class AdminQnaService
]); ]);
} }
// 감사로그 before (변경 전) // 감사로그 before (변경 전)
$beforeAudit = [ $beforeAudit = [
'year' => $year, 'year' => $year,
'seq' => $seq, 'seq' => $seq,
@ -214,7 +214,7 @@ final class AdminQnaService
'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE),
]); ]);
// 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X) // 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X)
try { try {
$req = request(); $req = request();
@ -252,7 +252,7 @@ final class AdminQnaService
]); ]);
} }
// 감사로그 before (변경 전) // 감사로그 before (변경 전)
$beforeAudit = [ $beforeAudit = [
'year' => $year, 'year' => $year,
'seq' => $seq, 'seq' => $seq,
@ -269,7 +269,7 @@ final class AdminQnaService
'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE),
]); ]);
// 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X) // 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X)
try { try {
$req = request(); $req = request();
@ -314,7 +314,7 @@ final class AdminQnaService
]); ]);
} }
// 감사로그 before (변경 전) // 감사로그 before (변경 전)
$beforeAudit = [ $beforeAudit = [
'year' => $year, 'year' => $year,
'seq' => $seq, 'seq' => $seq,
@ -336,7 +336,7 @@ final class AdminQnaService
$this->repo->update($year, $seq, $payload); $this->repo->update($year, $seq, $payload);
// 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X) // 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X)
try { try {
$req = request(); $req = request();
@ -387,7 +387,7 @@ final class AdminQnaService
if ($answerContent === '') abort(422, '관리자 답변을 입력해 주세요.'); if ($answerContent === '') abort(422, '관리자 답변을 입력해 주세요.');
if ($answerSms === '') abort(422, 'SMS 답변을 입력해 주세요.'); if ($answerSms === '') abort(422, 'SMS 답변을 입력해 주세요.');
// 감사로그 before (변경 전) — 원문은 저장하지 않고 길이/해시만 // 감사로그 before (변경 전) — 원문은 저장하지 않고 길이/해시만
$beforeAudit = [ $beforeAudit = [
'year' => $year, 'year' => $year,
'seq' => $seq, 'seq' => $seq,
@ -404,7 +404,7 @@ final class AdminQnaService
'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE),
]); ]);
// 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X) // 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X)
try { try {
$req = request(); $req = request();
@ -449,7 +449,7 @@ final class AdminQnaService
]); ]);
} }
// 감사로그 before (변경 전) // 감사로그 before (변경 전)
$beforeAudit = [ $beforeAudit = [
'year' => $year, 'year' => $year,
'seq' => $seq, 'seq' => $seq,
@ -484,7 +484,7 @@ final class AdminQnaService
'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE),
]); ]);
// 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X) // 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X)
try { try {
$req = request(); $req = request();
@ -545,7 +545,7 @@ final class AdminQnaService
// 메모는 배정자만 제한할지, 모든 관리자 가능할지 선택. 관리자 모두 // 메모는 배정자만 제한할지, 모든 관리자 가능할지 선택. 관리자 모두
// if ((int)($row->answer_admin_num ?? 0) !== $adminId) abort(403); // if ((int)($row->answer_admin_num ?? 0) !== $adminId) abort(403);
// 감사로그 before (변경 전) — 메모 원문은 저장하지 않음 // 감사로그 before (변경 전) — 메모 원문은 저장하지 않음
$beforeAudit = [ $beforeAudit = [
'year' => $year, 'year' => $year,
'seq' => $seq, 'seq' => $seq,
@ -559,7 +559,7 @@ final class AdminQnaService
'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE),
]); ]);
// 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X) // 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X)
try { try {
$req = request(); $req = request();

View File

@ -48,7 +48,7 @@ final class AdminSmsLogService
{ {
$page = $this->repo->paginateBatches($filters, $perPage); $page = $this->repo->paginateBatches($filters, $perPage);
// 페이지 이동 시 필터 유지 // 페이지 이동 시 필터 유지
$clean = array_filter($filters, fn($v) => $v !== null && $v !== ''); $clean = array_filter($filters, fn($v) => $v !== null && $v !== '');
$page->appends($clean); $page->appends($clean);
@ -64,7 +64,7 @@ final class AdminSmsLogService
{ {
$page = $this->repo->paginateItems($batchId, $filters, $perPage); $page = $this->repo->paginateItems($batchId, $filters, $perPage);
// 상세 페이지에서도 필터 유지 // 상세 페이지에서도 필터 유지
$clean = array_filter($filters, fn($v) => $v !== null && $v !== ''); $clean = array_filter($filters, fn($v) => $v !== null && $v !== '');
$page->appends($clean); $page->appends($clean);

View File

@ -41,7 +41,7 @@ final class AdminSmsTemplateService
$id = $this->repo->insert($after); $id = $this->repo->insert($after);
// 성공시에만 감사로그 (id가 0/음수면 기록 안 함) // 성공시에만 감사로그 (id가 0/음수면 기록 안 함)
if ($id > 0) { if ($id > 0) {
$req = request(); $req = request();
$this->audit->log( $this->audit->log(
@ -62,7 +62,7 @@ final class AdminSmsTemplateService
public function update(int $id, array $data): array public function update(int $id, array $data): array
{ {
// before 스냅샷(가능하면) — repo에 find/get이 없으면 lockForUpdate 같은 걸로 맞춰야 함 // before 스냅샷(가능하면) — repo에 find/get이 없으면 lockForUpdate 같은 걸로 맞춰야 함
// 여기서는 "repo->find($id)"가 있다고 가정하지 않고, 안전하게 try로 감쌈. // 여기서는 "repo->find($id)"가 있다고 가정하지 않고, 안전하게 try로 감쌈.
$before = null; $before = null;
try { try {
@ -84,10 +84,10 @@ final class AdminSmsTemplateService
$affected = $this->repo->update($id, $payload); $affected = $this->repo->update($id, $payload);
// 기존 리턴 정책 유지 // 기존 리턴 정책 유지
$ok = ($affected >= 0); $ok = ($affected >= 0);
// 성공시에만 감사로그 // 성공시에만 감사로그
if ($ok) { if ($ok) {
$req = request(); $req = request();
$actorAdminId = (int)(auth('admin')->id() ?? 0); $actorAdminId = (int)(auth('admin')->id() ?? 0);

View File

@ -37,7 +37,7 @@ class FindPasswordService
} }
RateLimiter::hit($key, 600); RateLimiter::hit($key, 600);
// 가입자 확인(DB) // 가입자 확인(DB)
$member = $this->members->findByEmailAndName($emailLower, $name); $member = $this->members->findByEmailAndName($emailLower, $name);
if (!$member) { if (!$member) {
return [ return [
@ -51,7 +51,7 @@ class FindPasswordService
$memNo = (int)$member->mem_no; $memNo = (int)$member->mem_no;
// DB 저장 없이 signed URL만 생성 // DB 저장 없이 signed URL만 생성
$nonce = bin2hex(random_bytes(10)); $nonce = bin2hex(random_bytes(10));
$link = URL::temporarySignedRoute( $link = URL::temporarySignedRoute(
@ -63,7 +63,7 @@ class FindPasswordService
] ]
); );
// 메일 발송 // 메일 발송
try { try {
$this->mail->sendTemplate( $this->mail->sendTemplate(
$emailLower, $emailLower,

View File

@ -12,7 +12,7 @@ class MailService
{ {
$toList = is_array($to) ? $to : [$to]; $toList = is_array($to) ? $to : [$to];
// 웹 요청 기준으로 실제 mail 설정을 로그로 남김 // 웹 요청 기준으로 실제 mail 설정을 로그로 남김
Log::info('mail_send_debug', [ Log::info('mail_send_debug', [
'queue' => $queue, 'queue' => $queue,
'queue_default' => config('queue.default'), 'queue_default' => config('queue.default'),

View File

@ -436,7 +436,7 @@ class MemInfoService
$login1st = 'y'; $login1st = 'y';
} }
// 세션 payload (CI3 키 유지) // 세션 payload (CI3 키 유지)
$session = [ $session = [
'_login_' => true, '_login_' => true,
'_mid' => (string)$mem->email, '_mid' => (string)$mem->email,
@ -469,7 +469,7 @@ class MemInfoService
{ {
$mem = MemInfo::query()->whereKey($memNo)->first(); $mem = MemInfo::query()->whereKey($memNo)->first();
// 출금계좌 인증정보 (있으면 1건) // 출금계좌 인증정보 (있으면 1건)
$outAccount = DB::table('mem_account') $outAccount = DB::table('mem_account')
->select(['bank_name', 'bank_act_num', 'bank_act_name', 'act_date']) ->select(['bank_name', 'bank_act_num', 'bank_act_name', 'act_date'])
->where('mem_no', $memNo) ->where('mem_no', $memNo)
@ -497,7 +497,7 @@ class MemInfoService
'rcv_sms' => (string)($mem->rcv_sms ?? 'n'), 'rcv_sms' => (string)($mem->rcv_sms ?? 'n'),
'rcv_push' => $mem->rcv_push !== null ? (string)$mem->rcv_push : null, 'rcv_push' => $mem->rcv_push !== null ? (string)$mem->rcv_push : null,
// 추가 // 추가
'out_account' => $outAccount ? [ 'out_account' => $outAccount ? [
'bank_name' => (string)($outAccount->bank_name ?? ''), 'bank_name' => (string)($outAccount->bank_name ?? ''),
'bank_act_num' => (string)($outAccount->bank_act_num ?? ''), 'bank_act_num' => (string)($outAccount->bank_act_num ?? ''),
@ -569,7 +569,7 @@ class MemInfoService
return ['ok' => false, 'message' => '로그인 정보가 올바르지 않습니다.']; return ['ok' => false, 'message' => '로그인 정보가 올바르지 않습니다.'];
} }
// 최근 7일 이내 구매내역(stat_pay p/t) 있으면 불가 // 최근 7일 이내 구매내역(stat_pay p/t) 있으면 불가
$from = Carbon::today()->subDays(7)->startOfDay(); $from = Carbon::today()->subDays(7)->startOfDay();
$cnt = (int) DB::table('pin_order') $cnt = (int) DB::table('pin_order')

View File

@ -3,21 +3,132 @@
namespace App\Services\Mypage; namespace App\Services\Mypage;
use App\Repositories\Mypage\UsageRepository; use App\Repositories\Mypage\UsageRepository;
use App\Repositories\Payments\GcPinOrderRepository;
use App\Services\Payments\PaymentCancelService;
final class UsageService final class UsageService
{ {
public function __construct( public function __construct(
private readonly UsageRepository $repo, private readonly UsageRepository $repo,
private readonly GcPinOrderRepository $orders,
private readonly PaymentCancelService $cancelSvc,
) {} ) {}
/**
* 리스트(검색/페이징)
*/
public function buildListPageData(int $sessionMemNo, array $filters): array
{
$rows = $this->repo->paginateAttemptsWithOrder($sessionMemNo, $filters, 5);
return [
'pageTitle' => '구매내역',
'pageDesc' => '구매내역을 확인하고 상세에서 핀 확인/취소를 진행할 수 있습니다.',
'mypageActive' => 'usage',
'filters' => $filters,
'rows' => $rows,
];
}
/**
* 상세
*/
public function buildDetailPageData(int $attemptId, int $sessionMemNo): array
{
// 기존 detail 빌더 재사용 (호환/안정)
$data = $this->buildPageData($attemptId, $sessionMemNo);
if (($data['mode'] ?? '') !== 'detail') abort(404);
$order = (array)($data['order'] ?? []);
$orderId = (int)($order['id'] ?? 0);
$pins = $this->repo->getPinsForOrder($orderId);
$cancelLogs = $this->repo->getCancelLogsForAttempt($attemptId, 20);
$retData = $order['ret_data'] ?? null;
$retArr = is_array($retData) ? $retData : [];
$pinsOpened = !empty($retArr['pin_opened_at']);
// 결제완료 조건
$attempt = (array)($data['attempt'] ?? []);
$isPaid = (($order['stat_pay'] ?? '') === 'p') || (($attempt['status'] ?? '') === 'paid');
// cancel_status 기반 버튼 제어
$aCancel = (string)($attempt['cancel_status'] ?? 'none');
$canCancel = $isPaid && !$pinsOpened && in_array($aCancel, ['none','failed'], true);
$data['pins'] = $pins;
$data['pinsOpened'] = $pinsOpened;
$data['canCancel'] = $canCancel;
$data['cancelLogs'] = $cancelLogs;
return $data;
}
/**
* 오픈(확인): ret_data에 pin_opened_at 기록
*/
public function openPins(int $attemptId, int $sessionMemNo): array
{
$row = $this->repo->findAttemptWithOrder($attemptId);
if (!$row) return ['ok'=>false, 'message'=>'결제내역을 찾을 수 없습니다.'];
$attemptMem = (int)($row->attempt_mem_no ?? 0);
$orderMem = (int)($row->order_mem_no ?? 0);
if ($attemptMem !== $sessionMemNo || ($orderMem > 0 && $orderMem !== $sessionMemNo)) {
return ['ok'=>false, 'message'=>'권한이 없습니다.'];
}
$attemptStatus = (string)($row->attempt_status ?? '');
$orderStatPay = (string)($row->order_stat_pay ?? '');
if (!(($orderStatPay === 'p') || ($attemptStatus === 'paid'))) {
return ['ok'=>false, 'message'=>'결제완료 상태에서만 핀 확인이 가능합니다.'];
}
$oid = (string)($row->order_oid ?? '');
if ($oid === '') return ['ok'=>false, 'message'=>'주문정보가 올바르지 않습니다.'];
$order = $this->orders->findByOidForUpdate($oid);
if (!$order) return ['ok'=>false, 'message'=>'주문을 찾을 수 없습니다.'];
$ret = (array)($order->ret_data ?? []);
if (!empty($ret['pin_opened_at'])) {
return ['ok'=>true];
}
$ret['pin_opened_at'] = now()->toDateTimeString();
$order->ret_data = $ret;
$order->save();
return ['ok'=>true];
}
/**
* 결제완료 취소( 오픈 전만)
*/
public function cancelPaidAttempt(int $attemptId, int $sessionMemNo, string $reason): array
{
$data = $this->buildDetailPageData($attemptId, $sessionMemNo);
$pinsOpened = (bool)($data['pinsOpened'] ?? false);
return $this->cancelSvc->cancelByAttempt(
$attemptId,
['type' => 'user', 'mem_no' => $sessionMemNo, 'id' => $sessionMemNo],
$reason,
$pinsOpened
);
}
/**
* 기존 상세 빌더(첨부 파일 그대로 유지)
*/
public function buildPageData(?int $attemptId, int $sessionMemNo): array public function buildPageData(?int $attemptId, int $sessionMemNo): array
{ {
// attempt_id 없으면 "구매내역(리스트) 준비중" 모드로 렌더
if (!$attemptId || $attemptId <= 0) { if (!$attemptId || $attemptId <= 0) {
return [ return [
'mode' => 'empty', 'mode' => 'empty',
'pageTitle' => '구매내역',
'pageDesc' => '결제 완료 후 핀 확인/발급/매입을 진행할 수 있습니다.',
'mypageActive' => 'usage', 'mypageActive' => 'usage',
]; ];
} }
@ -25,7 +136,6 @@ final class UsageService
$row = $this->repo->findAttemptWithOrder($attemptId); $row = $this->repo->findAttemptWithOrder($attemptId);
if (!$row) abort(404); if (!$row) abort(404);
// 소유자 검증 (존재 여부 숨김)
$attemptMem = (int)($row->attempt_mem_no ?? 0); $attemptMem = (int)($row->attempt_mem_no ?? 0);
$orderMem = (int)($row->order_mem_no ?? 0); $orderMem = (int)($row->order_mem_no ?? 0);
@ -39,9 +149,7 @@ final class UsageService
$items = $this->repo->getOrderItems($orderId); $items = $this->repo->getOrderItems($orderId);
$requiredQty = 0; $requiredQty = 0;
foreach ($items as $it) { foreach ($items as $it) $requiredQty += (int)($it->qty ?? 0);
$requiredQty += (int)($it->qty ?? 0);
}
$assignedPinsCount = $this->repo->countAssignedPins($orderId); $assignedPinsCount = $this->repo->countAssignedPins($orderId);
$pinsSummary = $this->repo->getAssignedPinsStatusSummary($orderId); $pinsSummary = $this->repo->getAssignedPinsStatusSummary($orderId);
@ -54,12 +162,13 @@ final class UsageService
return [ return [
'mode' => 'detail', 'mode' => 'detail',
'pageTitle' => '구매내역', 'pageTitle' => '구매내역',
'pageDesc' => '결제 상태 및 핀 발급/매입 진행을 확인합니다.', 'pageDesc' => '결제 상태 및 핀 확인/발급/매입 진행을 확인합니다.',
'mypageActive' => 'usage', 'mypageActive' => 'usage',
'attempt' => $this->attemptViewModel($row), 'attempt' => $this->attemptViewModel($row),
'order' => $this->orderViewModel($row), 'order' => $this->orderViewModel($row),
'items' => $this->itemsViewModel($items), 'items' => $this->itemsViewModel($items),
'productname' => $row->order_product_name,
'requiredQty' => $requiredQty, 'requiredQty' => $requiredQty,
'assignedPinsCount' => $assignedPinsCount, 'assignedPinsCount' => $assignedPinsCount,
@ -73,27 +182,13 @@ final class UsageService
private function resolveStepKey(string $attemptStatus, string $orderStatPay, int $requiredQty, int $assignedPinsCount): string private function resolveStepKey(string $attemptStatus, string $orderStatPay, int $requiredQty, int $assignedPinsCount): string
{ {
// 취소/실패 우선 if (in_array($orderStatPay, ['c','f'], true) || in_array($attemptStatus, ['cancelled','failed'], true)) return 'failed';
if (in_array($orderStatPay, ['c','f'], true) || in_array($attemptStatus, ['cancelled','failed'], true)) { if ($orderStatPay === 'w' || $attemptStatus === 'issued') return 'deposit_wait';
return 'failed';
}
// 가상계좌 입금대기
if ($orderStatPay === 'w' || $attemptStatus === 'issued') {
return 'deposit_wait';
}
// 결제완료
if ($orderStatPay === 'p' || $attemptStatus === 'paid') { if ($orderStatPay === 'p' || $attemptStatus === 'paid') {
if ($requiredQty > 0 && $assignedPinsCount >= $requiredQty) return 'pin_done'; if ($requiredQty > 0 && $assignedPinsCount >= $requiredQty) return 'pin_done';
return 'pin_check'; return 'pin_check';
} }
if (in_array($attemptStatus, ['ready','redirected','auth_ok'], true) || $orderStatPay === 'ready') return 'pay_processing';
// 결제 진행 중/확인 중
if (in_array($attemptStatus, ['ready','redirected','auth_ok'], true) || $orderStatPay === 'ready') {
return 'pay_processing';
}
return 'pay_processing'; return 'pay_processing';
} }
@ -120,6 +215,14 @@ final class UsageService
'pg_tid' => (string)($row->attempt_pg_tid ?? ''), 'pg_tid' => (string)($row->attempt_pg_tid ?? ''),
'return_code' => (string)($row->attempt_return_code ?? ''), 'return_code' => (string)($row->attempt_return_code ?? ''),
'return_msg' => (string)($row->attempt_return_msg ?? ''), 'return_msg' => (string)($row->attempt_return_msg ?? ''),
// 추가: cancel 상태 요약
'cancel_status' => (string)($row->attempt_cancel_status ?? 'none'),
'cancel_last_code' => (string)($row->attempt_cancel_last_code ?? ''),
'cancel_last_msg' => (string)($row->attempt_cancel_last_msg ?? ''),
'cancel_requested_at' => (string)($row->attempt_cancel_requested_at ?? ''),
'cancel_done_at' => (string)($row->attempt_cancel_done_at ?? ''),
'payloads' => [ 'payloads' => [
'request' => $this->jsonDecodeOrRaw($row->attempt_request_payload ?? null), 'request' => $this->jsonDecodeOrRaw($row->attempt_request_payload ?? null),
'response' => $this->jsonDecodeOrRaw($row->attempt_response_payload ?? null), 'response' => $this->jsonDecodeOrRaw($row->attempt_response_payload ?? null),
@ -142,6 +245,15 @@ final class UsageService
'pg_tid' => (string)($row->order_pg_tid ?? ''), 'pg_tid' => (string)($row->order_pg_tid ?? ''),
'ret_code' => (string)($row->order_ret_code ?? ''), 'ret_code' => (string)($row->order_ret_code ?? ''),
'ret_msg' => (string)($row->order_ret_msg ?? ''), 'ret_msg' => (string)($row->order_ret_msg ?? ''),
'products_name' => (string)($row->products_name ?? ''),
'cancel_status' => (string)($row->order_cancel_status ?? 'none'),
'cancel_last_code' => (string)($row->order_cancel_last_code ?? ''),
'cancel_last_msg' => (string)($row->order_cancel_last_msg ?? ''),
'cancel_reason' => (string)($row->order_cancel_reason ?? ''),
'cancel_requested_at' => (string)($row->order_cancel_requested_at ?? ''),
'cancel_done_at' => (string)($row->order_cancel_done_at ?? ''),
'amounts' => [ 'amounts' => [
'subtotal' => (int)($row->order_subtotal_amount ?? 0), 'subtotal' => (int)($row->order_subtotal_amount ?? 0),
'fee' => (int)($row->order_fee_amount ?? 0), 'fee' => (int)($row->order_fee_amount ?? 0),
@ -172,7 +284,6 @@ final class UsageService
private function extractVactInfo(object $row): array private function extractVactInfo(object $row): array
{ {
// 어떤 키로 들어오든 "있으면 보여주기" 수준의 안전한 추출
$candidates = [ $candidates = [
$this->jsonDecodeOrArray($row->order_pay_data ?? null), $this->jsonDecodeOrArray($row->order_pay_data ?? null),
$this->jsonDecodeOrArray($row->order_ret_data ?? null), $this->jsonDecodeOrArray($row->order_ret_data ?? null),
@ -187,13 +298,7 @@ final class UsageService
return null; return null;
}; };
$info = [ $info = ['bank'=>null,'account'=>null,'holder'=>null,'amount'=>null,'expire_at'=>null];
'bank' => null,
'account' => null,
'holder' => null,
'amount' => null,
'expire_at' => null,
];
foreach ($candidates as $a) { foreach ($candidates as $a) {
if (!$a) continue; if (!$a) continue;
@ -204,7 +309,6 @@ final class UsageService
$info['expire_at'] ??= $pick($a, ['expire_at', 'Vdate', 'vdate', 'ExpireDate']); $info['expire_at'] ??= $pick($a, ['expire_at', 'Vdate', 'vdate', 'ExpireDate']);
} }
// 다 null이면 빈 배열로 처리
$hasAny = false; $hasAny = false;
foreach ($info as $v) { if ($v !== null) { $hasAny = true; break; } } foreach ($info as $v) { if ($v !== null) { $hasAny = true; break; } }
return $hasAny ? $info : []; return $hasAny ? $info : [];
@ -230,7 +334,6 @@ final class UsageService
$decoded = json_decode($s, true); $decoded = json_decode($s, true);
if (json_last_error() === JSON_ERROR_NONE) return $decoded; if (json_last_error() === JSON_ERROR_NONE) return $decoded;
// JSON이 아니라면 원문 그대로(운영 디버그용)
return $s; return $s;
} }
} }

View File

@ -68,6 +68,8 @@ final class OrderCheckoutService
$order = GcPinOrder::create([ $order = GcPinOrder::create([
'oid' => $oid, 'oid' => $oid,
'mem_no' => $memNo, 'mem_no' => $memNo,
'products_id' => (int)($sku->product_id ?? 0) ?: null,
'products_name' => (string)($sku->product_name ?? $sku->name ?? ''),
'order_type' => 'self', 'order_type' => 'self',
'stat_pay' => 'ready', 'stat_pay' => 'ready',
'stat_tax' => 'taxfree', 'stat_tax' => 'taxfree',

View File

@ -18,6 +18,8 @@ final class CheckoutService
$order = GcPinOrder::create([ $order = GcPinOrder::create([
'oid' => $oid, 'oid' => $oid,
'mem_no' => $memNo, 'mem_no' => $memNo,
'products_id' => null,
'products_name' => '테스트 상품권',
'stat_pay' => 'ready', 'stat_pay' => 'ready',
'stat_tax' => 'taxfree', 'stat_tax' => 'taxfree',
'subtotal_amount' => $amount, 'subtotal_amount' => $amount,

View File

@ -0,0 +1,245 @@
<?php
namespace App\Services\Payments;
use App\Models\Payments\GcPaymentAttempt;
use App\Providers\Danal\Clients\DanalCpcgiClient;
use App\Providers\Danal\DanalConfig;
use App\Providers\Danal\Gateways\PhoneGateway;
use App\Repositories\Payments\GcPinOrderRepository;
use Illuminate\Support\Facades\DB;
final class PaymentCancelService
{
public function __construct(
private readonly DanalConfig $danalCfg,
private readonly DanalCpcgiClient $cpcgi,
private readonly PhoneGateway $phoneGateway,
private readonly GcPinOrderRepository $orders,
) {}
/**
* 결제완료 취소 ( 오픈 전만)
* - cancel_status/로그 저장 포함
*/
public function cancelByAttempt(int $attemptId, array $actor, string $reason, bool $pinsOpened): array
{
return DB::transaction(function () use ($attemptId, $actor, $reason, $pinsOpened) {
/** @var GcPaymentAttempt|null $attempt */
$attempt = GcPaymentAttempt::query()->whereKey($attemptId)->lockForUpdate()->first();
if (!$attempt) return ['ok'=>false, 'message'=>'결제내역을 찾을 수 없습니다.'];
$order = $this->orders->findByOidForUpdate((string)$attempt->oid);
if (!$order) return ['ok'=>false, 'message'=>'주문을 찾을 수 없습니다.'];
// 권한: user면 소유자만
if (($actor['type'] ?? 'user') === 'user') {
if ((int)$attempt->mem_no !== (int)($actor['mem_no'] ?? 0)) {
return ['ok'=>false, 'message'=>'권한이 없습니다.'];
}
}
// 상태 체크
if ((string)$attempt->status !== 'paid' || (string)$order->stat_pay !== 'p') {
return ['ok'=>false, 'message'=>'취소 가능한 상태가 아닙니다.'];
}
// 이미 성공 취소
if ((string)($attempt->cancel_status ?? 'none') === 'success' || (string)($order->cancel_status ?? 'none') === 'success') {
return ['ok'=>false, 'message'=>'이미 취소 완료된 결제입니다.'];
}
// 핀 오픈 전 조건 (이번 범위에서 “핀 반납” 제외지만, 오픈 후 취소는 금지)
if ($pinsOpened) {
return ['ok'=>false, 'message'=>'핀을 확인한 이후에는 취소할 수 없습니다.'];
}
$tid = (string)($attempt->pg_tid ?: $order->pg_tid);
$amount = (int)$order->pay_money;
if ($tid === '' || $amount <= 0) return ['ok'=>false, 'message'=>'취소에 필요한 거래정보가 부족합니다.'];
// 1) requested 상태 선반영
$now = now();
$attempt->cancel_status = 'requested';
$attempt->cancel_requested_at = $now;
$attempt->cancel_last_msg = null;
$attempt->cancel_last_code = null;
$attempt->save();
$order->cancel_status = 'requested';
$order->cancel_requested_at = $now;
$order->cancel_reason = $reason;
$order->save();
// 2) PG 취소 호출
$payMethod = (string)$attempt->pay_method;
$req = [];
$res = [];
$ok = false;
$code = null;
$msg = null;
try {
if ($payMethod === 'card') {
$cardKind = (string)($attempt->card_kind ?: 'general');
$cfg = $this->danalCfg->card($cardKind);
$req = [
'TID' => $tid,
'AMOUNT' => (string)$amount,
'CANCELTYPE' => 'C',
'CANCELREQUESTER' => $this->requesterLabel($actor),
'CANCELDESC' => $reason ?: '사용자 요청',
'TXTYPE' => 'CANCEL',
'SERVICETYPE' => 'DANALCARD',
];
$res = $this->cpcgi->call($cfg['url'], $cfg['cpid'], $req, $cfg['key'], $cfg['iv']);
$code = (string)($res['RETURNCODE'] ?? '');
$msg = (string)($res['RETURNMSG'] ?? '');
$ok = ($code === '0000');
} elseif ($payMethod === 'wire') {
$cfg = $this->danalCfg->wiretransfer();
$req = [
'TID' => $tid,
'AMOUNT' => (string)$amount,
'CANCELTYPE' => 'C',
'CANCELREQUESTER' => $this->requesterLabel($actor),
'CANCELDESC' => $reason ?: '사용자 요청',
'TXTYPE' => 'CANCEL',
'SERVICETYPE' => 'WIRETRANSFER',
];
$res = $this->cpcgi->call((string)$cfg['tx_url'], (string)$cfg['cpid'], $req, (string)$cfg['key'], (string)$cfg['iv']);
$code = (string)($res['RETURNCODE'] ?? '');
$msg = (string)($res['RETURNMSG'] ?? '');
$ok = ($code === '0000');
} elseif ($payMethod === 'phone') {
$mode = $this->resolvePhoneModeFromAttempt($attempt->request_payload);
$out = $this->phoneGateway->billCancel($mode, $tid); // (수정: PhoneGateway에 메서드 추가 필요)
$req = $out['req'] ?? [];
$res = $out['res'] ?? [];
$code = (string)($res['Result'] ?? '');
$msg = (string)($res['ErrMsg'] ?? '');
$ok = ($code === '0');
} else {
$code = 'METHOD';
$msg = '지원하지 않는 결제수단입니다.';
$ok = false;
}
} catch (\Throwable $e) {
$code = $code ?: 'EX';
$msg = $msg ?: ('취소 처리 중 오류: ' . $e->getMessage());
$ok = false;
}
// 3) 로그 저장 (req/res 전문)
$logId = DB::table('gc_payment_cancel_logs')->insertGetId([
'attempt_id' => (int)$attempt->id,
'order_id' => (int)$order->id,
'mem_no' => (int)$attempt->mem_no,
'provider' => (string)($attempt->provider ?: 'danal'),
'pay_method' => $payMethod,
'tid' => $tid,
'amount' => $amount,
'cancel_type' => 'C',
'status' => $ok ? 'success' : 'failed',
'requester_type' => (string)($actor['type'] ?? 'user'),
'requester_id' => (int)($actor['id'] ?? 0) ?: null,
'reason' => $reason,
'req_payload' => json_encode($req, JSON_UNESCAPED_UNICODE),
'res_payload' => json_encode($res, JSON_UNESCAPED_UNICODE),
'result_code' => $code,
'result_msg' => $msg,
'requested_at' => $now,
'done_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
// 4) 결과 반영 (상태값 변경 + 요약 저장)
if ($ok) {
$attempt->status = 'cancelled';
$attempt->cancel_status = 'success';
$attempt->cancel_done_at = now();
$attempt->cancel_last_code = $code;
$attempt->cancel_last_msg = $msg;
$attempt->save();
$order->stat_pay = 'c';
$order->cancelled_at = now();
$order->cancel_status = 'success';
$order->cancel_done_at = now();
$order->cancel_last_code = $code;
$order->cancel_last_msg = $msg;
$order->cancel_reason = $reason;
// ret_data에 “취소 요약”만 append (전문은 logs 테이블)
$ret = (array)($order->ret_data ?? []);
$ret['cancel_last'] = [
'log_id' => (int)$logId,
'method' => $payMethod,
'tid' => $tid,
'code' => $code,
'msg' => $msg,
'at' => now()->toDateTimeString(),
];
$order->ret_data = $ret;
$order->save();
return ['ok'=>true, 'message'=>'결제가 취소되었습니다.', 'meta'=>['log_id'=>$logId]];
}
// 실패: status는 paid 유지, cancel_status만 failed
$attempt->cancel_status = 'failed';
$attempt->cancel_done_at = now();
$attempt->cancel_last_code = (string)$code;
$attempt->cancel_last_msg = (string)$msg;
$attempt->save();
$order->cancel_status = 'failed';
$order->cancel_done_at = now();
$order->cancel_last_code = (string)$code;
$order->cancel_last_msg = (string)$msg;
$order->save();
return ['ok'=>false, 'message'=>($msg ?: '취소 실패'), 'meta'=>['code'=>$code]];
});
}
private function requesterLabel(array $actor): string
{
$t = (string)($actor['type'] ?? 'user');
$id = (int)($actor['id'] ?? 0);
if ($t === 'admin') return "admin:{$id}";
if ($t === 'system') return "system:{$id}";
return "user:" . (int)($actor['mem_no'] ?? $id);
}
private function resolvePhoneModeFromAttempt($requestPayload): string
{
// request_payload가 array/json/string 어떤 형태든 대응
$arr = [];
if (is_array($requestPayload)) $arr = $requestPayload;
elseif (is_string($requestPayload) && trim($requestPayload) !== '') {
$tmp = json_decode($requestPayload, true);
if (is_array($tmp)) $arr = $tmp;
}
$id = (string)($arr['ID'] ?? '');
$prod = (string)config('danal.phone.prod.cpid', '');
$dev = (string)config('danal.phone.dev.cpid', '');
if ($id !== '' && $dev !== '' && $id === $dev) return 'dev';
if ($id !== '' && $prod !== '' && $id === $prod) return 'prod';
return (string)config('danal.phone.default_mode', 'prod');
}
}

View File

@ -447,7 +447,7 @@ final class PaymentService
return DB::transaction(function () use ($post) { return DB::transaction(function () use ($post) {
$attemptId = 0; $attemptId = 0;
$result = $this->phone->confirmAndBill($post, function (string $oid, string $token, string $tid, int $amount, array $payload) { $result = $this->phone->confirmAndBill($post, function (string $oid, string $token, string $tid, int $amount, array $payload) use (&$attemptId) {
$attempt = $this->attempts->findByTokenForUpdate('phone', $token); $attempt = $this->attempts->findByTokenForUpdate('phone', $token);
if (!$attempt) return; if (!$attempt) return;

View File

@ -81,7 +81,7 @@ class SmsService
} }
/** /**
* 예약시간 처리(CI3 로직 그대로) * 예약시간 처리(CI3 로직 그대로)
* - scheduled_at이 없으면 now() * - scheduled_at이 없으면 now()
* - 'Y-m-d H:i' ':00' 붙임 * - 'Y-m-d H:i' ':00' 붙임
* - 미래면 시간, 과거/형식오류면 now() * - 미래면 시간, 과거/형식오류면 now()
@ -111,7 +111,7 @@ class SmsService
* CI: lguplus_send 이식 * CI: lguplus_send 이식
* - 메시지 길이(EUC-KR) 기준 90 이하 sms, 초과 mms * - 메시지 길이(EUC-KR) 기준 90 이하 sms, 초과 mms
* - sms_type이 명시되면 그걸 우선 * - sms_type이 명시되면 그걸 우선
* - scheduled_at이 미래면 업체 예약 컬럼에 해당 시간으로 insert * - scheduled_at이 미래면 업체 예약 컬럼에 해당 시간으로 insert
* - SMS: SC_TRAN.TR_SENDDATE * - SMS: SC_TRAN.TR_SENDDATE
* - MMS: MMS_MSG.REQDATE * - MMS: MMS_MSG.REQDATE
*/ */
@ -120,7 +120,7 @@ class SmsService
$conn = DB::connection('sms_server'); $conn = DB::connection('sms_server');
$smsSendType = $this->resolveSendType($data); $smsSendType = $this->resolveSendType($data);
// 예약/즉시 결정 // 예약/즉시 결정
$sendDate = $this->resolveProviderSendDate($data['scheduled_at'] ?? null); $sendDate = $this->resolveProviderSendDate($data['scheduled_at'] ?? null);
return (bool) $conn->transaction(function () use ($conn, $smsSendType, $data, $sendDate) { return (bool) $conn->transaction(function () use ($conn, $smsSendType, $data, $sendDate) {

View File

@ -652,7 +652,7 @@ class Seed
$Data = []; $Data = [];
$len = strlen($pbUserKey); $len = strlen($pbUserKey);
for ($i = 0; $i < $len; $i++) { for ($i = 0; $i < $len; $i++) {
$Data[$i] = ord($pbUserKey[$i]); // {} -> [] $Data[$i] = ord($pbUserKey[$i]); // {} -> []
} }
$this->SeedRoundKey($pdwRoundKey, $Data); $this->SeedRoundKey($pdwRoundKey, $Data);
} }
@ -662,7 +662,7 @@ class Seed
$Data = []; $Data = [];
$len = strlen($pbData); $len = strlen($pbData);
for ($i = 0; $i < $len; $i++) { for ($i = 0; $i < $len; $i++) {
$Data[$i] = ord($pbData[$i]); // {} -> [] $Data[$i] = ord($pbData[$i]); // {} -> []
} }
$this->SeedEncrypt($Data, $pdwRoundKey, $outData); $this->SeedEncrypt($Data, $pdwRoundKey, $outData);
} }
@ -672,7 +672,7 @@ class Seed
$Data = []; $Data = [];
$len = strlen($pbData); $len = strlen($pbData);
for ($i = 0; $i < $len; $i++) { for ($i = 0; $i < $len; $i++) {
$Data[$i] = ord($pbData[$i]); // {} -> [] $Data[$i] = ord($pbData[$i]); // {} -> []
} }
$this->SeedDecrypt($Data, $pdwRoundKey, $outData); $this->SeedDecrypt($Data, $pdwRoundKey, $outData);
} }

View File

@ -14,7 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// Reverse Proxy 신뢰(정확한 client ip, https 판단) // Reverse Proxy 신뢰(정확한 client ip, https 판단)
$middleware->trustProxies(at: [ $middleware->trustProxies(at: [
'192.168.100.0/24', '192.168.100.0/24',
'127.0.0.0/8', '127.0.0.0/8',
@ -22,10 +22,10 @@ return Application::configure(basePath: dirname(__DIR__))
'172.16.0.0/12', '172.16.0.0/12',
]); ]);
// trustHosts는 요청 시점에 config 기반으로 적용 // trustHosts는 요청 시점에 config 기반으로 적용
$middleware->prepend(\App\Http\Middleware\TrustedHostsFromConfig::class); $middleware->prepend(\App\Http\Middleware\TrustedHostsFromConfig::class);
// CSRF 예외 처리 // CSRF 예외 처리
$middleware->validateCsrfTokens(except: [ $middleware->validateCsrfTokens(except: [
'auth/register/danal/result', #다날인증 'auth/register/danal/result', #다날인증
'mypage/info/danal/result', #다날인증 'mypage/info/danal/result', #다날인증
@ -39,7 +39,7 @@ return Application::configure(basePath: dirname(__DIR__))
'pay/danal/cancel', 'pay/danal/cancel',
]); ]);
// alias 등록 // alias 등록
$middleware->alias([ $middleware->alias([
'legacy.auth' => \App\Http\Middleware\LegacyAuth::class, 'legacy.auth' => \App\Http\Middleware\LegacyAuth::class,
'legacy.guest' => \App\Http\Middleware\LegacyGuest::class, 'legacy.guest' => \App\Http\Middleware\LegacyGuest::class,
@ -47,7 +47,7 @@ return Application::configure(basePath: dirname(__DIR__))
'admin.role' => \App\Http\Middleware\AdminRole::class, 'admin.role' => \App\Http\Middleware\AdminRole::class,
]); ]);
// guest redirect (auth 미들웨어가 login 라우트 찾다 터지는거 방지) // guest redirect (auth 미들웨어가 login 라우트 찾다 터지는거 방지)
$middleware->redirectGuestsTo(function (Request $request) { $middleware->redirectGuestsTo(function (Request $request) {
$adminHost = (string) config('app.admin_domain', ''); $adminHost = (string) config('app.admin_domain', '');
return ($adminHost !== '' && $request->getHost() === $adminHost) return ($adminHost !== '' && $request->getHost() === $adminHost)

View File

@ -12,7 +12,7 @@
return [ return [
// UI용 그룹 (원하는 순서대로 렌더링) // UI용 그룹 (원하는 순서대로 렌더링)
'groups' => [ 'groups' => [
'bank_1st' => [ 'bank_1st' => [
'label' => '메이저 1금융권', 'label' => '메이저 1금융권',
@ -127,7 +127,7 @@ return [
], ],
/** /**
* 코드→이름 빠른 조회용 flat * 코드→이름 빠른 조회용 flat
*/ */
'flat' => [ 'flat' => [
'001' => '한국은행', '001' => '한국은행',

View File

@ -191,7 +191,7 @@ final class AdminRbacSeeder extends Seeder
} }
/** /**
* phone_hash는 NOT NULL이므로 항상 채운다. * phone_hash는 NOT NULL이므로 항상 채운다.
* - 운영 정책용(=진짜 조회키) : phoneDigits만 * - 운영 정책용(=진짜 조회키) : phoneDigits만
* - seed에서 같은 번호 10 만들 : phoneDigits + email로 유니크하게 * - seed에서 같은 번호 10 만들 : phoneDigits + email로 유니크하게
*/ */

View File

@ -43,12 +43,12 @@
.notice-head-row{ .notice-head-row{
display:flex; display:flex;
align-items:center; align-items:center;
justify-content: space-between; /* 양끝 배치 */ justify-content: space-between; /* 양끝 배치 */
gap:12px; gap:12px;
flex-wrap: nowrap; flex-wrap: nowrap;
padding-bottom: 12px; padding-bottom: 12px;
border-bottom: 1px solid rgba(0,0,0,.08); /* 라이트 배경 기준: 검정 계열 */ border-bottom: 1px solid rgba(0,0,0,.08); /* 라이트 배경 기준: 검정 계열 */
} }
/* 다크 테마일 때만 흰색 라인으로(선택) */ /* 다크 테마일 때만 흰색 라인으로(선택) */
@ -59,12 +59,12 @@
} }
.notice-count{ .notice-count{
flex: 0 0 auto; /* 줄어들지 않게 */ flex: 0 0 auto; /* 줄어들지 않게 */
white-space: nowrap; white-space: nowrap;
} }
.notice-toolbar{ .notice-toolbar{
margin-left: auto; /* 오른쪽으로 밀기(보험) */ margin-left: auto; /* 오른쪽으로 밀기(보험) */
display:flex; display:flex;
justify-content:flex-end; justify-content:flex-end;
} }
@ -78,7 +78,7 @@
display:flex; display:flex;
align-items:center; align-items:center;
gap:8px; gap:8px;
flex-wrap: nowrap; /* 검색 영역도 한 줄 */ flex-wrap: nowrap; /* 검색 영역도 한 줄 */
} }
/* 검색 input 폭이 너무 커서 내려가는 걸 방지 */ /* 검색 input 폭이 너무 커서 내려가는 걸 방지 */
@ -119,7 +119,7 @@
gap: 8px; gap: 8px;
} }
/* 핵심: 기존 width:240px 죽이고, flex로 꽉 채우기 */ /* 핵심: 기존 width:240px 죽이고, flex로 꽉 채우기 */
.nt-search input{ .nt-search input{
width: auto; width: auto;
flex: 1 1 auto; flex: 1 1 auto;
@ -300,7 +300,7 @@
line-height: 1.35; line-height: 1.35;
overflow:hidden; overflow:hidden;
display:-webkit-box; display:-webkit-box;
-webkit-line-clamp: 2; /* 두 줄 */ -webkit-line-clamp: 2; /* 두 줄 */
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
@ -318,7 +318,7 @@
.nv-head{ .nv-head{
display:flex; display:flex;
align-items:flex-start; align-items:flex-start;
justify-content:space-between; /* 웹: 한 줄 양끝 */ justify-content:space-between; /* 웹: 한 줄 양끝 */
gap:12px; gap:12px;
} }
@ -329,8 +329,8 @@
} }
.nv-meta{ .nv-meta{
margin-top: 0; /* 웹: 제목과 같은 줄 */ margin-top: 0; /* 웹: 제목과 같은 줄 */
white-space: nowrap; /* 메타 줄바꿈 방지 */ white-space: nowrap; /* 메타 줄바꿈 방지 */
flex: 0 0 auto; flex: 0 0 auto;
} }
@ -350,7 +350,7 @@
.nv-actions{ .nv-actions{
margin-top: 16px; margin-top: 16px;
display:grid; display:grid;
grid-template-columns: 1fr 1fr; /* 이전/다음 2칸 */ grid-template-columns: 1fr 1fr; /* 이전/다음 2칸 */
gap:10px; gap:10px;
} }
@ -379,7 +379,7 @@
margin-bottom: 28px; margin-bottom: 28px;
} }
/* 첨부/링크: 큰 박스 -> 가벼운 섹션 */ /* 첨부/링크: 큰 박스 -> 가벼운 섹션 */
.nv-resources{ .nv-resources{
margin-top: 18px; /* 본문에서 아래로 떨어짐 */ margin-top: 18px; /* 본문에서 아래로 떨어짐 */
padding-top: 14px; padding-top: 14px;
@ -480,7 +480,7 @@
border-color: rgba(59,130,246,.18); border-color: rgba(59,130,246,.18);
} }
/* 이전/다음: 아래로 더 내려서 답답함 제거 */ /* 이전/다음: 아래로 더 내려서 답답함 제거 */
.nv-actions{ .nv-actions{
margin-top: 26px; /* 더 아래로 */ margin-top: 26px; /* 더 아래로 */
padding-top: 18px; padding-top: 18px;

View File

@ -1,6 +1,6 @@
.mypage-qna-page .mq-topbar{ .mypage-qna-page .mq-topbar{
display:flex; display:flex;
justify-content:space-between; /* 좌/우 끝으로 */ justify-content:space-between; /* 좌/우 끝으로 */
align-items:center; align-items:center;
margin: 0 0 14px 0; margin: 0 0 14px 0;
} }
@ -298,7 +298,7 @@
padding: 12px; padding: 12px;
} }
/* 메타(문의분류/처리상태/등록일) : 3컬럼 -> 세로 카드형 */ /* 메타(문의분류/처리상태/등록일) : 3컬럼 -> 세로 카드형 */
.mq-detail-meta{ .mq-detail-meta{
border-radius: 10px; border-radius: 10px;
} }
@ -357,7 +357,7 @@
white-space: normal; white-space: normal;
} }
/* 본문 카드: 2컬럼 -> 1컬럼 */ /* 본문 카드: 2컬럼 -> 1컬럼 */
.mq-cards{ .mq-cards{
grid-template-columns: 1fr !important; grid-template-columns: 1fr !important;
gap: 10px; gap: 10px;

View File

@ -39,7 +39,7 @@
let untilMs = parseUntilMsFromISO(untilStr); let untilMs = parseUntilMsFromISO(untilStr);
// data-expire가 있으면 우선 사용 (unix seconds) // data-expire가 있으면 우선 사용 (unix seconds)
if (!untilMs && Number.isFinite(expireTs) && expireTs > 0) { if (!untilMs && Number.isFinite(expireTs) && expireTs > 0) {
untilMs = expireTs * 1000; untilMs = expireTs * 1000;
} }
@ -367,7 +367,7 @@
function close() { wrap.remove(); } function close() { wrap.remove(); }
// 닫기: X / 취소만 // 닫기: X / 취소만
wrap.querySelector('.mypage-pwmodal__close')?.addEventListener('click', close); wrap.querySelector('.mypage-pwmodal__close')?.addEventListener('click', close);
wrap.querySelector('[data-act="cancel"]')?.addEventListener('click', close); wrap.querySelector('[data-act="cancel"]')?.addEventListener('click', close);
@ -547,7 +547,7 @@
function close() { wrap.remove(); } function close() { wrap.remove(); }
// 닫기: X / 취소만 // 닫기: X / 취소만
wrap.querySelector('.mypage-pin2modal__close')?.addEventListener('click', close); wrap.querySelector('.mypage-pin2modal__close')?.addEventListener('click', close);
wrap.querySelector('[data-act="cancel"]')?.addEventListener('click', close); wrap.querySelector('[data-act="cancel"]')?.addEventListener('click', close);
@ -660,7 +660,7 @@
const postUrl = URLS.withdrawVerifyOut; const postUrl = URLS.withdrawVerifyOut;
const defaultDepositor = (CFG.memberName || '').trim(); const defaultDepositor = (CFG.memberName || '').trim();
// bankGroups는 config(bank_code.php)의 groups 구조(label/items)로 전달된다고 가정 // bankGroups는 config(bank_code.php)의 groups 구조(label/items)로 전달된다고 가정
const BANK_GROUPS = (CFG.bankGroups || {}); const BANK_GROUPS = (CFG.bankGroups || {});
if (!postUrl) { if (!postUrl) {
@ -813,7 +813,7 @@
const setError = makeErrorSetter(wrap, '#with_error'); const setError = makeErrorSetter(wrap, '#with_error');
const close = () => wrap.remove(); const close = () => wrap.remove();
// 닫기: X / 취소만 // 닫기: X / 취소만
wrap.querySelector('.mypage-withmodal__close')?.addEventListener('click', close); wrap.querySelector('.mypage-withmodal__close')?.addEventListener('click', close);
wrap.querySelector('[data-act="cancel"]')?.addEventListener('click', close); wrap.querySelector('[data-act="cancel"]')?.addEventListener('click', close);
@ -831,7 +831,7 @@
accEl.value = (accEl.value || '').replace(/[^\d]/g, ''); accEl.value = (accEl.value || '').replace(/[^\d]/g, '');
}); });
// 금융권 선택 → 은행 목록 갱신 // 금융권 선택 → 은행 목록 갱신
groupEl?.addEventListener('change', () => { groupEl?.addEventListener('change', () => {
const g = (groupEl.value || '').trim(); const g = (groupEl.value || '').trim();
bankEl.innerHTML = bankOptionsByGroupHtml(g); bankEl.innerHTML = bankOptionsByGroupHtml(g);
@ -853,7 +853,7 @@
if (!groupKey || !BANK_GROUPS[groupKey]) { setError('금융권을 선택해 주세요.'); groupEl?.focus(); return; } if (!groupKey || !BANK_GROUPS[groupKey]) { setError('금융권을 선택해 주세요.'); groupEl?.focus(); return; }
if (!isBankCode3(bankCode)) { setError('은행을 선택해 주세요.'); bankEl?.focus(); return; } if (!isBankCode3(bankCode)) { setError('은행을 선택해 주세요.'); bankEl?.focus(); return; }
// 선택한 금융권 안에 실제로 존재하는 은행인지 (클라 방어) // 선택한 금융권 안에 실제로 존재하는 은행인지 (클라 방어)
const bankName = getBankName(groupKey, bankCode); const bankName = getBankName(groupKey, bankCode);
if (!bankName) { setError('선택한 은행 정보가 올바르지 않습니다. 다시 선택해 주세요.'); bankEl?.focus(); return; } if (!bankName) { setError('선택한 은행 정보가 올바르지 않습니다. 다시 선택해 주세요.'); bankEl?.focus(); return; }
@ -980,7 +980,7 @@
} }
function replaceWithClone(el) { function replaceWithClone(el) {
// 기존 mypage_renew.js에서 "준비중" 리스너가 이미 붙어있으므로 // 기존 mypage_renew.js에서 "준비중" 리스너가 이미 붙어있으므로
// 버튼을 clone으로 교체해서 리스너를 모두 제거한다. // 버튼을 clone으로 교체해서 리스너를 모두 제거한다.
const clone = el.cloneNode(true); const clone = el.cloneNode(true);
el.parentNode.replaceChild(clone, el); el.parentNode.replaceChild(clone, el);
@ -1184,7 +1184,7 @@
const btn0 = document.querySelector('[data-action="consent-edit"]'); const btn0 = document.querySelector('[data-action="consent-edit"]');
if (!btn0) return; if (!btn0) return;
// 기존 “준비중” 클릭 리스너 제거 // 기존 “준비중” 클릭 리스너 제거
const btn = replaceWithClone(btn0); const btn = replaceWithClone(btn0);
btn.addEventListener('click', () => { btn.addEventListener('click', () => {

View File

@ -220,8 +220,8 @@ html,body{ height:100%; }
border: 1px solid rgba(0,0,0,.12); border: 1px solid rgba(0,0,0,.12);
padding: 12px 12px; padding: 12px 12px;
margin: 0 0 12px; margin: 0 0 12px;
background: #fff; /* 흰색 배경 */ background: #fff; /* 흰색 배경 */
color: #111; /* 기본 검정 */ color: #111; /* 기본 검정 */
} }
.a-alert__title{ font-weight:900; margin-bottom:4px; font-size:13px; color:#111; } .a-alert__title{ font-weight:900; margin-bottom:4px; font-size:13px; color:#111; }
.a-alert__body{ font-size:13px; color:#111; } .a-alert__body{ font-size:13px; color:#111; }
@ -230,7 +230,7 @@ html,body{ height:100%; }
} }
.a-alert--danger .a-alert__title, .a-alert--danger .a-alert__title,
.a-alert--danger .a-alert__body{ .a-alert--danger .a-alert__body{
color: #d32f2f; /* 실패 붉은색 */ color: #d32f2f; /* 실패 붉은색 */
} }
.a-alert--warn{ border-color: rgba(255,176,32,.28); background: rgba(255,176,32,.10); } .a-alert--warn{ border-color: rgba(255,176,32,.28); background: rgba(255,176,32,.10); }
.a-alert--info{ border-color: rgba(43,127,255,.28); background: rgba(43,127,255,.10); } .a-alert--info{ border-color: rgba(43,127,255,.28); background: rgba(43,127,255,.10); }
@ -671,7 +671,7 @@ html,body{ height:100%; }
.a-toast{ .a-toast{
border-radius: 14px; border-radius: 14px;
border: 1px solid rgba(0,0,0,.12); border: 1px solid rgba(0,0,0,.12);
background: #fff; /* 흰색 배경 */ background: #fff; /* 흰색 배경 */
box-shadow: 0 12px 30px rgba(0,0,0,.18); box-shadow: 0 12px 30px rgba(0,0,0,.18);
padding: 12px 12px; padding: 12px 12px;
animation: aToastIn .16s ease-out; animation: aToastIn .16s ease-out;
@ -686,19 +686,19 @@ html,body{ height:100%; }
.a-toast__msg{ .a-toast__msg{
font-size: 13px; font-size: 13px;
line-height: 1.35; line-height: 1.35;
color: #111; /* 성공은 검정 */ color: #111; /* 성공은 검정 */
} }
/* 성공: 검정 유지 */ /* 성공: 검정 유지 */
.a-toast--success{} .a-toast--success{}
/* 실패(오류): 붉은색 */ /* 실패(오류): 붉은색 */
.a-toast--danger{ .a-toast--danger{
border-color: rgba(255,77,79,.45); border-color: rgba(255,77,79,.45);
} }
.a-toast--danger .a-toast__title, .a-toast--danger .a-toast__title,
.a-toast--danger .a-toast__msg{ .a-toast--danger .a-toast__msg{
color: #d32f2f; /* 붉은색 */ color: #d32f2f; /* 붉은색 */
} }
/* (선택) warn/info도 보기 좋게 */ /* (선택) warn/info도 보기 좋게 */
@ -713,7 +713,7 @@ html,body{ height:100%; }
/* ===== Highlight button (Password Change) ===== */ /* ===== Highlight button (Password Change) ===== */
.a-btn--highlight{ .a-btn--highlight{
border: 1px solid rgba(43,127,255,.35); border: 1px solid rgba(43,127,255,.35);
background: #2b7fff; /* 단색 */ background: #2b7fff; /* 단색 */
color: #fff; color: #fff;
box-shadow: 0 14px 34px rgba(43,127,255,.18); box-shadow: 0 14px 34px rgba(43,127,255,.18);
} }
@ -721,9 +721,9 @@ html,body{ height:100%; }
.a-btn--highlight:active{ transform: translateY(1px); } .a-btn--highlight:active{ transform: translateY(1px); }
.a-btn{ .a-btn{
display: block; /* a 태그도 폭/레이아웃 적용 */ display: block; /* a 태그도 폭/레이아웃 적용 */
width:100%; width:100%;
text-align:center; /* a 태그 텍스트 가운데 */ text-align:center; /* a 태그 텍스트 가운데 */
border-radius: var(--a-radius-sm); border-radius: var(--a-radius-sm);
border:1px solid rgba(255,255,255,.14); border:1px solid rgba(255,255,255,.14);
padding:12px 14px; padding:12px 14px;
@ -758,7 +758,7 @@ html,body{ height:100%; }
} }
.a-meinfo__v{ .a-meinfo__v{
min-width: 0; /* 긴 텍스트/칩 overflow 방지 */ min-width: 0; /* 긴 텍스트/칩 overflow 방지 */
color: rgba(255,255,255,.92); color: rgba(255,255,255,.92);
} }
@ -1200,7 +1200,7 @@ html,body{ height:100%; }
cursor: pointer; cursor: pointer;
} }
/* 칩/원형/캡슐 제거: 텍스트는 그냥 텍스트 */ /* 칩/원형/캡슐 제거: 텍스트는 그냥 텍스트 */
.a-nav__titletext{ .a-nav__titletext{
display: inline-block; display: inline-block;
@ -1223,7 +1223,7 @@ html,body{ height:100%; }
color: rgba(255,255,255,.88); color: rgba(255,255,255,.88);
} }
/* selected(open): 버튼 "전체"를 아이템보다 더 강하게 강조 */ /* selected(open): 버튼 "전체"를 아이템보다 더 강하게 강조 */
.a-nav__group.is-open .a-nav__titlebtn{ .a-nav__group.is-open .a-nav__titlebtn{
background: linear-gradient( background: linear-gradient(
135deg, 135deg,
@ -1241,8 +1241,8 @@ html,body{ height:100%; }
/* --- Items: smaller + indent + guide line --- */ /* --- Items: smaller + indent + guide line --- */
.a-nav__items{ .a-nav__items{
margin-left: 8px; /* 그룹 자체 살짝 들여쓰기 */ margin-left: 8px; /* 그룹 자체 살짝 들여쓰기 */
padding-left: 14px; /* 아이템 들여쓰기 */ padding-left: 14px; /* 아이템 들여쓰기 */
border-left: 1px solid rgba(255,255,255,.08); /* 가이드 라인 */ border-left: 1px solid rgba(255,255,255,.08); /* 가이드 라인 */
} }
/* item size + spacing */ /* item size + spacing */
@ -1250,7 +1250,7 @@ html,body{ height:100%; }
padding: 9px 10px; padding: 9px 10px;
border-radius: 12px; border-radius: 12px;
font-size: 12.5px; /* 글씨 크기 줄임 */ font-size: 12.5px; /* 글씨 크기 줄임 */
line-height: 1.2; line-height: 1.2;
color: rgba(255,255,255,.82); color: rgba(255,255,255,.82);

View File

@ -565,7 +565,7 @@ body {
} }
.original-price{ .original-price{
margin-left: auto; /* 여기서 오른쪽으로 밀어줌 */ margin-left: auto; /* 여기서 오른쪽으로 밀어줌 */
text-decoration: line-through; text-decoration: line-through;
color: var(--color-text-tertiary); color: var(--color-text-tertiary);
font-size: 13px; font-size: 13px;
@ -821,7 +821,7 @@ body {
margin-right: 8px; /* 여백 줄이기 */ margin-right: 8px; /* 여백 줄이기 */
} }
/* 검색폼 거의 풀폭 */ /* 검색폼 거의 풀폭 */
.search-bar{ .search-bar{
margin: 0 !important; /* 양쪽 여백 제거 */ margin: 0 !important; /* 양쪽 여백 제거 */
flex: 1 1 auto; flex: 1 1 auto;
@ -1051,7 +1051,7 @@ body {
background:#FFFFFF; background:#FFFFFF;
border: 1px solid #E5E7EB; border: 1px solid #E5E7EB;
border-radius: 16px; border-radius: 16px;
padding: 12px 14px; /* 여백 축소 */ padding: 12px 14px; /* 여백 축소 */
box-shadow: 0 1px 2px rgba(15,23,42,.04); box-shadow: 0 1px 2px rgba(15,23,42,.04);
} }
@ -1060,7 +1060,7 @@ body {
align-items:center; align-items:center;
justify-content:space-between; justify-content:space-between;
gap: 10px; gap: 10px;
margin-bottom: 6px; /* 타이트 */ margin-bottom: 6px; /* 타이트 */
} }
.notice-title{ .notice-title{
@ -1084,7 +1084,7 @@ body {
display:grid; display:grid;
} }
/* 공지 사이 얇은 줄 */ /* 공지 사이 얇은 줄 */
.notice-item + .notice-item{ .notice-item + .notice-item{
border-top: 1px solid #EEF2F7; border-top: 1px solid #EEF2F7;
} }
@ -1094,7 +1094,7 @@ body {
grid-template-columns: 44px 1fr auto; grid-template-columns: 44px 1fr auto;
align-items:center; align-items:center;
gap: 10px; gap: 10px;
padding: 10px 2px; /* 줄간격/여백 축소 */ padding: 10px 2px; /* 줄간격/여백 축소 */
border-radius: 10px; border-radius: 10px;
transition: background-color .16s ease; transition: background-color .16s ease;
} }
@ -1146,7 +1146,7 @@ body {
overflow: hidden; overflow: hidden;
border-radius: 16px; border-radius: 16px;
border: 1px solid #E5E7EB; border: 1px solid #E5E7EB;
padding: 14px 14px; /* 타이트 */ padding: 14px 14px; /* 타이트 */
background: #FFFFFF; background: #FFFFFF;
box-shadow: 0 1px 2px rgba(15,23,42,.04); box-shadow: 0 1px 2px rgba(15,23,42,.04);
transition: transform .16s ease, border-color .16s ease; transition: transform .16s ease, border-color .16s ease;
@ -1156,7 +1156,7 @@ body {
border-color: #BFDBFE; border-color: #BFDBFE;
} }
/* “배경 이미지 느낌” (그라데이션 없이, 패턴/하이라이트) */ /* “배경 이미지 느낌” (그라데이션 없이, 패턴/하이라이트) */
.support-banner::before{ .support-banner::before{
content:""; content:"";
position:absolute; position:absolute;
@ -1440,7 +1440,7 @@ body {
border-radius: 16px; border-radius: 16px;
border: 1px solid #E5E7EB; border: 1px solid #E5E7EB;
background: #F8FAFC; background: #F8FAFC;
height: 160px; /* 메인보다 낮게 */ height: 160px; /* 메인보다 낮게 */
} }
.subhero-track{ .subhero-track{
display:flex; display:flex;
@ -1458,7 +1458,7 @@ body {
.subhero-slide::after{ .subhero-slide::after{
content:""; content:"";
position:absolute; inset:0; position:absolute; inset:0;
background: rgba(255,255,255,.72); /* 과하지 않게 텍스트 가독 */ background: rgba(255,255,255,.72); /* 과하지 않게 텍스트 가독 */
} }
.subhero-content{ .subhero-content{
position: relative; position: relative;
@ -1656,7 +1656,7 @@ body {
.hero-content{ max-width: var(--container-width); width:100%; text-align:left; padding: 0 8px; position: relative; z-index: 1;} .hero-content{ max-width: var(--container-width); width:100%; text-align:left; padding: 0 8px; position: relative; z-index: 1;}
/* compact에서 타이포/여백만 줄이기 */ /* compact에서 타이포/여백만 줄이기 */
.hero-slider--compact .hero-slide{ padding: 0 18px; } .hero-slider--compact .hero-slide{ padding: 0 18px; }
.hero-slider--compact .hero-title{ font-size: 22px; margin-bottom: 6px; } .hero-slider--compact .hero-title{ font-size: 22px; margin-bottom: 6px; }
.hero-slider--compact .hero-desc{ font-size: 13px; margin-bottom: 10px; } .hero-slider--compact .hero-desc{ font-size: 13px; margin-bottom: 10px; }
@ -1676,7 +1676,7 @@ body {
background-image: var(--hero-bg-mobile, var(--hero-bg)); background-image: var(--hero-bg-mobile, var(--hero-bg));
} }
} }
/* 텍스트 가독성 오버레이(왼쪽은 밝게, 오른쪽은 투명) */ /* 텍스트 가독성 오버레이(왼쪽은 밝게, 오른쪽은 투명) */
.hero-slide::after{ .hero-slide::after{
content:""; content:"";
position:absolute; position:absolute;
@ -1701,7 +1701,7 @@ body {
} }
/* 왼쪽/오른쪽 화살표 버튼이 컨텐츠를 가리지 않게 여백 확보 */ /* 왼쪽/오른쪽 화살표 버튼이 컨텐츠를 가리지 않게 여백 확보 */
.slider-arrow{ .slider-arrow{
position:absolute; top:50%; transform: translateY(-50%); position:absolute; top:50%; transform: translateY(-50%);
width: 34px; height: 34px; width: 34px; height: 34px;
@ -1715,7 +1715,7 @@ body {
.slider-arrow.prev{ left: 10px; } .slider-arrow.prev{ left: 10px; }
.slider-arrow.next{ right: 10px; } .slider-arrow.next{ right: 10px; }
/* 컨텐츠 좌우 패딩(화살표 영역만큼) */ /* 컨텐츠 좌우 패딩(화살표 영역만큼) */
.hero-slider--compact .hero-content{ padding-left: 44px; padding-right: 44px; } .hero-slider--compact .hero-content{ padding-left: 44px; padding-right: 44px; }
/* dots */ /* dots */
@ -1821,7 +1821,7 @@ body {
background: rgba(0,0,0,.08); background: rgba(0,0,0,.08);
} }
/* 박스 자체 */ /* 박스 자체 */
.subnav--side .subnav-box{ .subnav--side .subnav-box{
background: #fff; background: #fff;
border: 1px solid rgba(0,0,0,.08); border: 1px solid rgba(0,0,0,.08);
@ -2252,7 +2252,7 @@ body {
color: rgba(0,0,0,.6); color: rgba(0,0,0,.6);
} }
/* Policy tables: mobile overflow fix */ /* Policy tables: mobile overflow fix */
.policy-table-wrap{ .policy-table-wrap{
width:100%; width:100%;
overflow-x:auto; overflow-x:auto;
@ -2319,7 +2319,7 @@ body {
font-weight:800; font-weight:800;
} }
/* 모바일: 행을 카드처럼 쌓아서 “넘침” 자체를 제거 */ /* 모바일: 행을 카드처럼 쌓아서 “넘침” 자체를 제거 */
@media (max-width: 640px){ @media (max-width: 640px){
/* 래퍼 스크롤 제거 + 카드 형태로 */ /* 래퍼 스크롤 제거 + 카드 형태로 */
.policy-table-wrap{ .policy-table-wrap{
@ -2453,11 +2453,11 @@ img, video, svg, iframe, canvas { max-width: 100%; height: auto; }
/* 탭(메뉴) 버튼 */ /* 탭(메뉴) 버튼 */
.subnav-tab{ .subnav-tab{
display: flex; display: flex;
align-items: center; /* 세로 중앙 */ align-items: center; /* 세로 중앙 */
justify-content: center; /* 가로 중앙 */ justify-content: center; /* 가로 중앙 */
text-align: center; text-align: center;
min-width: 0; /* grid item overflow 방지 */ min-width: 0; /* grid item overflow 방지 */
min-height: 42px; min-height: 42px;
padding: 10px 10px; padding: 10px 10px;
@ -2473,12 +2473,12 @@ img, video, svg, iframe, canvas { max-width: 100%; height: auto; }
color: rgba(0,0,0,.78); color: rgba(0,0,0,.78);
text-decoration: none; text-decoration: none;
/* 한 줄 유지 + 길면 ... */ /* 한 줄 유지 + 길면 ... */
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
/* '메뉴'같은 클릭감 */ /* '메뉴'같은 클릭감 */
transition: background-color .12s ease, border-color .12s ease, color .12s ease, transform .06s ease; transition: background-color .12s ease, border-color .12s ease, color .12s ease, transform .06s ease;
} }
@ -2524,13 +2524,13 @@ img, video, svg, iframe, canvas { max-width: 100%; height: auto; }
color: rgba(0,0,0,.65); color: rgba(0,0,0,.65);
line-height:1.6; line-height:1.6;
/* 데스크톱에서는 한 줄에 잘리게(캡처처럼) */ /* 데스크톱에서는 한 줄에 잘리게(캡처처럼) */
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* 모바일: desc를 아래로 떨어뜨리기 */ /* 모바일: desc를 아래로 떨어뜨리기 */
@media (max-width: 640px){ @media (max-width: 640px){
.subpage-header{ .subpage-header{
flex-direction: column; flex-direction: column;
@ -3003,8 +3003,8 @@ body.is-drawer-open{
.faq-cats{ .faq-cats{
display:flex; display:flex;
flex-wrap:wrap; /* 넘치면 줄바꿈 */ flex-wrap:wrap; /* 넘치면 줄바꿈 */
justify-content:center; /* 중앙정렬 */ justify-content:center; /* 중앙정렬 */
gap:8px; gap:8px;
} }
@ -3333,7 +3333,7 @@ body.is-drawer-open{
min-height:100dvh; min-height:100dvh;
} }
/* 중앙 박스형 리듬 */ /* 중앙 박스형 리듬 */
.auth-page{ .auth-page{
padding: 40px 16px 72px; padding: 40px 16px 72px;
} }
@ -3464,7 +3464,7 @@ body.is-drawer-open{
} }
.auth-check input{ transform: translateY(1px); } .auth-check input{ transform: translateY(1px); }
/* 링크 전용 줄 */ /* 링크 전용 줄 */
.auth-links-inline{ .auth-links-inline{
display:flex; display:flex;
align-items:center; align-items:center;
@ -3640,7 +3640,7 @@ body.is-drawer-open{
font-weight: 900; font-weight: 900;
} }
/* Auth: Desktop에서만 +150px 넓게 */ /* Auth: Desktop에서만 +150px 넓게 */
@media (min-width: 961px){ @media (min-width: 961px){
.auth-container{ .auth-container{
max-width: 410px !important; /* (기존 560px 기준 +150) */ max-width: 410px !important; /* (기존 560px 기준 +150) */
@ -3663,7 +3663,7 @@ body.is-drawer-open{
} }
} }
/* Mobile은 기존 유지(혹시 덮였으면 안전하게) */ /* Mobile은 기존 유지(혹시 덮였으면 안전하게) */
@media (max-width: 960px){ @media (max-width: 960px){
.auth-container{ .auth-container{
max-width: 100% !important; max-width: 100% !important;
@ -3673,9 +3673,9 @@ body.is-drawer-open{
/* ===== Pagination (badge / pill) ===== */ /* ===== Pagination (badge / pill) ===== */
.notice-pager, .notice-pager,
.pagination-wrap { .pagination-wrap {
margin-top: 22px; /* 상단 여백 */ margin-top: 22px; /* 상단 여백 */
display: flex; display: flex;
justify-content: center; /* 가운데 정렬 */ justify-content: center; /* 가운데 정렬 */
} }
.pg{ .pg{
@ -3683,7 +3683,7 @@ body.is-drawer-open{
align-items:center; align-items:center;
justify-content:center; justify-content:center;
gap:8px; gap:8px;
padding: 0; /* 박스 제거 */ padding: 0; /* 박스 제거 */
border: 0; border: 0;
background: transparent; background: transparent;
} }
@ -3700,7 +3700,7 @@ body.is-drawer-open{
.pg-ellipsis{ .pg-ellipsis{
height: 34px; height: 34px;
padding: 0 12px; padding: 0 12px;
border-radius: 999px; /* pill */ border-radius: 999px; /* pill */
display:inline-flex; display:inline-flex;
align-items:center; align-items:center;
justify-content:center; justify-content:center;
@ -3810,14 +3810,14 @@ body.is-drawer-open{
position: absolute; position: absolute;
right: 0; right: 0;
top: 100%; /* 버튼 바로 아래에 붙임 */ top: 100%; /* 버튼 바로 아래에 붙임 */
margin-top: 0; /* gap 제거 */ margin-top: 0; /* gap 제거 */
width: 192px; width: 192px;
display: none; display: none;
z-index: 50; z-index: 50;
} }
.profile-dropdown .profile-card { .profile-dropdown .profile-card {
margin-top: 10px; /* 시각적 간격은 카드에만 */ margin-top: 10px; /* 시각적 간격은 카드에만 */
} }
/* show on hover (group-hover:block replacement) */ /* show on hover (group-hover:block replacement) */

View File

@ -556,7 +556,7 @@ h1, h2, h3, h4, h5, h6 {
} }
.original-price{ .original-price{
margin-left: auto; /* 여기서 오른쪽으로 밀어줌 */ margin-left: auto; /* 여기서 오른쪽으로 밀어줌 */
text-decoration: line-through; text-decoration: line-through;
color: var(--color-text-tertiary); color: var(--color-text-tertiary);
font-size: 13px; font-size: 13px;
@ -812,7 +812,7 @@ h1, h2, h3, h4, h5, h6 {
margin-right: 8px; /* 여백 줄이기 */ margin-right: 8px; /* 여백 줄이기 */
} }
/* 검색폼 거의 풀폭 */ /* 검색폼 거의 풀폭 */
.search-bar{ .search-bar{
margin: 0 !important; /* 양쪽 여백 제거 */ margin: 0 !important; /* 양쪽 여백 제거 */
flex: 1 1 auto; flex: 1 1 auto;
@ -1042,7 +1042,7 @@ h1, h2, h3, h4, h5, h6 {
background:#FFFFFF; background:#FFFFFF;
border: 1px solid #E5E7EB; border: 1px solid #E5E7EB;
border-radius: 16px; border-radius: 16px;
padding: 12px 14px; /* 여백 축소 */ padding: 12px 14px; /* 여백 축소 */
box-shadow: 0 1px 2px rgba(15,23,42,.04); box-shadow: 0 1px 2px rgba(15,23,42,.04);
} }
@ -1051,7 +1051,7 @@ h1, h2, h3, h4, h5, h6 {
align-items:center; align-items:center;
justify-content:space-between; justify-content:space-between;
gap: 10px; gap: 10px;
margin-bottom: 6px; /* 타이트 */ margin-bottom: 6px; /* 타이트 */
} }
.notice-title{ .notice-title{
@ -1075,7 +1075,7 @@ h1, h2, h3, h4, h5, h6 {
display:grid; display:grid;
} }
/* 공지 사이 얇은 줄 */ /* 공지 사이 얇은 줄 */
.notice-item + .notice-item{ .notice-item + .notice-item{
border-top: 1px solid #EEF2F7; border-top: 1px solid #EEF2F7;
} }
@ -1085,7 +1085,7 @@ h1, h2, h3, h4, h5, h6 {
grid-template-columns: 44px 1fr auto; grid-template-columns: 44px 1fr auto;
align-items:center; align-items:center;
gap: 10px; gap: 10px;
padding: 10px 2px; /* 줄간격/여백 축소 */ padding: 10px 2px; /* 줄간격/여백 축소 */
border-radius: 10px; border-radius: 10px;
transition: background-color .16s ease; transition: background-color .16s ease;
} }
@ -1137,7 +1137,7 @@ h1, h2, h3, h4, h5, h6 {
overflow: hidden; overflow: hidden;
border-radius: 16px; border-radius: 16px;
border: 1px solid #E5E7EB; border: 1px solid #E5E7EB;
padding: 14px 14px; /* 타이트 */ padding: 14px 14px; /* 타이트 */
background: #FFFFFF; background: #FFFFFF;
box-shadow: 0 1px 2px rgba(15,23,42,.04); box-shadow: 0 1px 2px rgba(15,23,42,.04);
transition: transform .16s ease, border-color .16s ease; transition: transform .16s ease, border-color .16s ease;
@ -1147,7 +1147,7 @@ h1, h2, h3, h4, h5, h6 {
border-color: #BFDBFE; border-color: #BFDBFE;
} }
/* “배경 이미지 느낌” (그라데이션 없이, 패턴/하이라이트) */ /* “배경 이미지 느낌” (그라데이션 없이, 패턴/하이라이트) */
.support-banner::before{ .support-banner::before{
content:""; content:"";
position:absolute; position:absolute;
@ -1430,7 +1430,7 @@ h1, h2, h3, h4, h5, h6 {
border-radius: 16px; border-radius: 16px;
border: 1px solid #E5E7EB; border: 1px solid #E5E7EB;
background: #F8FAFC; background: #F8FAFC;
height: 160px; /* 메인보다 낮게 */ height: 160px; /* 메인보다 낮게 */
} }
.subhero-track{ .subhero-track{
display:flex; display:flex;
@ -1448,7 +1448,7 @@ h1, h2, h3, h4, h5, h6 {
.subhero-slide::after{ .subhero-slide::after{
content:""; content:"";
position:absolute; inset:0; position:absolute; inset:0;
background: rgba(255,255,255,.72); /* 과하지 않게 텍스트 가독 */ background: rgba(255,255,255,.72); /* 과하지 않게 텍스트 가독 */
} }
.subhero-content{ .subhero-content{
position: relative; position: relative;
@ -1646,13 +1646,13 @@ h1, h2, h3, h4, h5, h6 {
.hero-content{ max-width: var(--container-width); width:100%; text-align:left; padding: 0 8px; } .hero-content{ max-width: var(--container-width); width:100%; text-align:left; padding: 0 8px; }
/* compact에서 타이포/여백만 줄이기 */ /* compact에서 타이포/여백만 줄이기 */
.hero-slider--compact .hero-slide{ padding: 0 18px; } .hero-slider--compact .hero-slide{ padding: 0 18px; }
.hero-slider--compact .hero-title{ font-size: 22px; margin-bottom: 6px; } .hero-slider--compact .hero-title{ font-size: 22px; margin-bottom: 6px; }
.hero-slider--compact .hero-desc{ font-size: 13px; margin-bottom: 10px; } .hero-slider--compact .hero-desc{ font-size: 13px; margin-bottom: 10px; }
.hero-slider--compact .btn.hero-cta{ padding: 8px 14px; font-size: 13px; } .hero-slider--compact .btn.hero-cta{ padding: 8px 14px; font-size: 13px; }
/* 왼쪽/오른쪽 화살표 버튼이 컨텐츠를 가리지 않게 여백 확보 */ /* 왼쪽/오른쪽 화살표 버튼이 컨텐츠를 가리지 않게 여백 확보 */
.slider-arrow{ .slider-arrow{
position:absolute; top:50%; transform: translateY(-50%); position:absolute; top:50%; transform: translateY(-50%);
width: 34px; height: 34px; width: 34px; height: 34px;
@ -1666,7 +1666,7 @@ h1, h2, h3, h4, h5, h6 {
.slider-arrow.prev{ left: 10px; } .slider-arrow.prev{ left: 10px; }
.slider-arrow.next{ right: 10px; } .slider-arrow.next{ right: 10px; }
/* 컨텐츠 좌우 패딩(화살표 영역만큼) */ /* 컨텐츠 좌우 패딩(화살표 영역만큼) */
.hero-slider--compact .hero-content{ padding-left: 44px; padding-right: 44px; } .hero-slider--compact .hero-content{ padding-left: 44px; padding-right: 44px; }
/* dots */ /* dots */
@ -1772,7 +1772,7 @@ h1, h2, h3, h4, h5, h6 {
background: rgba(0,0,0,.08); background: rgba(0,0,0,.08);
} }
/* 박스 자체 */ /* 박스 자체 */
.subnav--side .subnav-box{ .subnav--side .subnav-box{
background: #fff; background: #fff;
border: 1px solid rgba(0,0,0,.08); border: 1px solid rgba(0,0,0,.08);
@ -2203,7 +2203,7 @@ h1, h2, h3, h4, h5, h6 {
color: rgba(0,0,0,.6); color: rgba(0,0,0,.6);
} }
/* Policy tables: mobile overflow fix */ /* Policy tables: mobile overflow fix */
.policy-table-wrap{ .policy-table-wrap{
width:100%; width:100%;
overflow-x:auto; overflow-x:auto;
@ -2280,7 +2280,7 @@ h1, h2, h3, h4, h5, h6 {
font-weight:800; font-weight:800;
} }
/* 모바일: 행을 카드처럼 쌓아서 “넘침” 자체를 제거 */ /* 모바일: 행을 카드처럼 쌓아서 “넘침” 자체를 제거 */
@media (max-width: 640px){ @media (max-width: 640px){
/* 래퍼 스크롤 제거 + 카드 형태로 */ /* 래퍼 스크롤 제거 + 카드 형태로 */
.policy-table-wrap{ .policy-table-wrap{

View File

@ -3,14 +3,14 @@
적용 범위: body.is-mypage 내부만 적용 범위: body.is-mypage 내부만
========================================= */ ========================================= */
/* 래퍼: 가운데 정렬 + 여백 */ /* 래퍼: 가운데 정렬 + 여백 */
.is-mypage .mypage-gate-wrap{ .is-mypage .mypage-gate-wrap{
display:flex; display:flex;
justify-content:center; justify-content:center;
padding: 6px 0 18px; padding: 6px 0 18px;
} }
/* 카드 */ /* 카드 */
.is-mypage .mypage-gate-card{ .is-mypage .mypage-gate-card{
width:100%; width:100%;
max-width: 680px; max-width: 680px;
@ -23,12 +23,12 @@
margin-bottom: 50px; margin-bottom: 50px;
} }
/* 바디 */ /* 바디 */
.is-mypage .mypage-gate-body{ .is-mypage .mypage-gate-body{
padding: 28px; padding: 28px;
} }
/* 타이틀/설명 */ /* 타이틀/설명 */
.is-mypage .mypage-gate-title{ .is-mypage .mypage-gate-title{
font-size: 20px; font-size: 20px;
font-weight: 800; font-weight: 800;
@ -43,7 +43,7 @@
line-height: 1.45; line-height: 1.45;
} }
/* 안내 박스 */ /* 안내 박스 */
.is-mypage .mypage-gate-note{ .is-mypage .mypage-gate-note{
margin: 0 0 18px; margin: 0 0 18px;
padding: 12px 14px; padding: 12px 14px;
@ -55,7 +55,7 @@
line-height: 1.5; line-height: 1.5;
} }
/* 한 줄 입력 + 버튼 */ /* 한 줄 입력 + 버튼 */
.is-mypage .mypage-gate-row{ .is-mypage .mypage-gate-row{
display:flex; display:flex;
gap: 10px; gap: 10px;
@ -75,7 +75,7 @@
box-shadow: 0 0 0 4px rgba(255,122,0,.14); box-shadow: 0 0 0 4px rgba(255,122,0,.14);
} }
/* 버튼 */ /* 버튼 */
.is-mypage .mypage-gate-btn{ .is-mypage .mypage-gate-btn{
height: 46px; height: 46px;
padding: 0 18px; padding: 0 18px;
@ -100,8 +100,8 @@
height: 52px; height: 52px;
min-height: 52px; min-height: 52px;
padding: 0 16px; padding: 0 16px;
font-size: 16px; /* 모바일에서 얇아보이는/줌 이슈 방지 */ font-size: 16px; /* 모바일에서 얇아보이는/줌 이슈 방지 */
line-height: 52px; /* 세로 가운데 정렬 확실 */ line-height: 52px; /* 세로 가운데 정렬 확실 */
border-radius: 14px; border-radius: 14px;
border: 1px solid #d0d5dd; border: 1px solid #d0d5dd;
box-sizing: border-box; box-sizing: border-box;
@ -121,7 +121,7 @@
min-height: 120px; min-height: 120px;
} }
/* 모바일: 버튼 아래로 */ /* 모바일: 버튼 아래로 */
@media (max-width: 575.98px){ @media (max-width: 575.98px){
.is-mypage .mypage-gate-card{ .is-mypage .mypage-gate-card{
margin-top: 0px; margin-top: 0px;

View File

@ -1,5 +1,5 @@
(function () { (function () {
// 스크립트가 실수로 2번 로드돼도 바인딩 1번만 // 스크립트가 실수로 2번 로드돼도 바인딩 1번만
if (window.__ADMIN_UI_JS_BOUND__) return; if (window.__ADMIN_UI_JS_BOUND__) return;
window.__ADMIN_UI_JS_BOUND__ = true; window.__ADMIN_UI_JS_BOUND__ = true;
@ -22,7 +22,7 @@
}); });
// ----------------------- // -----------------------
// data-confirm (ONLY ONCE) // data-confirm (ONLY ONCE)
// - capture 단계에서 먼저 실행되어 // - capture 단계에서 먼저 실행되어
// 취소 시 다른 submit 리스너(버튼 disable 등) 실행 안 됨 // 취소 시 다른 submit 리스너(버튼 disable 등) 실행 안 됨
// ----------------------- // -----------------------

View File

@ -3,7 +3,7 @@ class UiDialog {
this.root = document.getElementById(rootId); this.root = document.getElementById(rootId);
if (!this.root) return; if (!this.root) return;
// stacking context 문제 방지: 무조건 body 직속으로 이동 // stacking context 문제 방지: 무조건 body 직속으로 이동
try { try {
if (this.root.parentElement !== document.body) { if (this.root.parentElement !== document.body) {
document.body.appendChild(this.root); document.body.appendChild(this.root);
@ -52,7 +52,7 @@ class UiDialog {
this.cancelBtn?.addEventListener("click", () => this._resolve(false)); this.cancelBtn?.addEventListener("click", () => this._resolve(false));
} }
// 현재 페이지 최상단 z-index 탐색 (모달 겹침 대응) // 현재 페이지 최상단 z-index 탐색 (모달 겹침 대응)
static getTopZIndex() { static getTopZIndex() {
let maxZ = 0; let maxZ = 0;
const nodes = document.querySelectorAll("body *"); const nodes = document.querySelectorAll("body *");
@ -87,14 +87,14 @@ class UiDialog {
cancelText = "취소", cancelText = "취소",
dangerous = false, dangerous = false,
// 기본: 밖 클릭/닫기(X)/ESC로 닫기 금지 // 기본: 밖 클릭/닫기(X)/ESC로 닫기 금지
closeOnBackdrop = false, closeOnBackdrop = false,
closeOnX = false, closeOnX = false,
closeOnEsc = false, closeOnEsc = false,
closeOnEnter = true, closeOnEnter = true,
} = options; } = options;
// 항상 최상단: DOM 마지막 + z-index 최상단 // 항상 최상단: DOM 마지막 + z-index 최상단
try { try {
document.body.appendChild(this.root); document.body.appendChild(this.root);
const topZ = UiDialog.getTopZIndex(); const topZ = UiDialog.getTopZIndex();
@ -166,7 +166,7 @@ window.uiDialog = new UiDialog();
// ====================================================== // ======================================================
// Global showMsg / clearMsg (공통 사용) // Global showMsg / clearMsg (공통 사용)
// ====================================================== // ======================================================
(function () { (function () {
let cachedHelpEl = null; let cachedHelpEl = null;
@ -190,7 +190,7 @@ window.uiDialog = new UiDialog();
dangerous: false, dangerous: false,
redirect: "", redirect: "",
helpId: "", helpId: "",
// 닫기 정책 기본값(너가 말한 “다른 공간 클릭해도 닫히지 않게”와 일치) // 닫기 정책 기본값(너가 말한 “다른 공간 클릭해도 닫히지 않게”와 일치)
closeOnBackdrop: false, closeOnBackdrop: false,
closeOnX: false, closeOnX: false,
closeOnEsc: false, closeOnEsc: false,

View File

@ -2,7 +2,7 @@
@section('hide_flash', '1') @section('hide_flash', '1')
@section('title', '로그인') @section('title', '로그인')
{{-- reCAPTCHA 스크립트는 페이지에서만 로드 --}} {{-- reCAPTCHA 스크립트는 페이지에서만 로드 --}}
@push('head') @push('head')
@php @php
$siteKey = (string) config('services.recaptcha.site_key'); $siteKey = (string) config('services.recaptcha.site_key');
@ -20,7 +20,7 @@
@csrf @csrf
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value=""> <input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value="">
{{-- 에러는 상단에 1개만 --}} {{-- 에러는 상단에 1개만 --}}
@if ($errors->any()) @if ($errors->any())
<div class="a-alert a-alert--danger" style="margin-bottom:12px;"> <div class="a-alert a-alert--danger" style="margin-bottom:12px;">
<div class="a-alert__title">로그인 실패</div> <div class="a-alert__title">로그인 실패</div>
@ -131,7 +131,7 @@
const isProd = @json(app()->environment('production')); const isProd = @json(app()->environment('production'));
const hasKey = @json((bool) config('services.recaptcha.site_key')); const hasKey = @json((bool) config('services.recaptcha.site_key'));
// 운영에서만 토큰 생성 (서버와 동일 정책) // 운영에서만 토큰 생성 (서버와 동일 정책)
if (isProd && hasKey) { if (isProd && hasKey) {
const hidden = ensureHiddenRecaptcha(); const hidden = ensureHiddenRecaptcha();
hidden.value = ''; hidden.value = '';

View File

@ -34,7 +34,7 @@
.badge--muted{opacity:.9;} .badge--muted{opacity:.9;}
.badge--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);} .badge--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
/* 회원번호 링크 색상 흰색 */ /* 회원번호 링크 색상 흰색 */
a.memlink{color:#fff;text-decoration:none;} a.memlink{color:#fff;text-decoration:none;}
a.memlink:hover{color:#fff;text-decoration:underline;} a.memlink:hover{color:#fff;text-decoration:underline;}
</style> </style>
@ -153,7 +153,7 @@
@endif @endif
</td> </td>
{{-- 전화/이메일 한줄: "전화번호 바로뒤 이메일" (이메일 없으면 빈값) --}} {{-- 전화/이메일 한줄: "전화번호 바로뒤 이메일" (이메일 없으면 빈값) --}}
<td> <td>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;"> <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<span class="badge {{ $r['corp_badge'] ?? 'badge--muted' }}">{{ $r['corp_label'] ?? '-' }}</span> <span class="badge {{ $r['corp_badge'] ?? 'badge--muted' }}">{{ $r['corp_label'] ?? '-' }}</span>
@ -164,7 +164,7 @@
</div> </div>
</td> </td>
{{-- IP 한줄: ip4 + ip4_c --}} {{-- IP 한줄: ip4 + ip4_c --}}
<td class="nowrap"> <td class="nowrap">
<span class="mono">{{ $ip4v !== '' ? $ip4v : '-' }}</span> <span class="mono">{{ $ip4v !== '' ? $ip4v : '-' }}</span>
@if($ip4cv !== '') @if($ip4cv !== '')

View File

@ -240,7 +240,7 @@
if (!sel || !frm) return; if (!sel || !frm) return;
sel.addEventListener('change', () => { sel.addEventListener('change', () => {
// 선택 즉시 이동(= GET submit), page 파라미터는 자동 리셋 // 선택 즉시 이동(= GET submit), page 파라미터는 자동 리셋
frm.submit(); frm.submit();
}); });
})(); })();

View File

@ -94,13 +94,13 @@
{{-- mode --}} {{-- mode --}}
<input type="hidden" name="send_mode" id="sendMode" value="one"> <input type="hidden" name="send_mode" id="sendMode" value="one">
{{-- 여러건 파싱 결과(JSON) 서버 전달용 --}} {{-- 여러건 파싱 결과(JSON) 서버 전달용 --}}
<input type="hidden" name="many_rows_json" id="manyRowsJson" value="[]"> <input type="hidden" name="many_rows_json" id="manyRowsJson" value="[]">
{{-- 토큰 시작 컬럼: 2열부터 {_text_02_} (서버에서 참고용) --}} {{-- 토큰 시작 컬럼: 2열부터 {_text_02_} (서버에서 참고용) --}}
<input type="hidden" name="token_base" value="2"> <input type="hidden" name="token_base" value="2">
{{-- subject/body 미러 (백엔드 불일치 대비) --}} {{-- subject/body 미러 (백엔드 불일치 대비) --}}
<input type="hidden" name="subject_tpl" id="subjectTplMirror" value=""> <input type="hidden" name="subject_tpl" id="subjectTplMirror" value="">
<input type="hidden" name="body_tpl" id="bodyTplMirror" value=""> <input type="hidden" name="body_tpl" id="bodyTplMirror" value="">
<input type="hidden" name="mail_subject" id="mailSubjectMirror" value=""> <input type="hidden" name="mail_subject" id="mailSubjectMirror" value="">
@ -121,7 +121,7 @@
<label class="a-pill"><input type="radio" name="schedule_type" value="now" checked> 즉시발송</label> <label class="a-pill"><input type="radio" name="schedule_type" value="now" checked> 즉시발송</label>
<label class="a-pill"><input type="radio" name="schedule_type" value="schedule"> 예약발송</label> <label class="a-pill"><input type="radio" name="schedule_type" value="schedule"> 예약발송</label>
{{-- 직접 입력 불가(실수 방지): readonly + 선택 버튼 --}} {{-- 직접 입력 불가(실수 방지): readonly + 선택 버튼 --}}
<input class="a-input" <input class="a-input"
type="text" type="text"
name="scheduled_at" name="scheduled_at"
@ -163,7 +163,7 @@
<div class="a-muted" style="margin-bottom:6px;">수신자 (여러명)</div> <div class="a-muted" style="margin-bottom:6px;">수신자 (여러명)</div>
<textarea class="a-input" name="to_emails_text" id="toEmailsText" rows="9" <textarea class="a-input" name="to_emails_text" id="toEmailsText" rows="9"
placeholder=" 1줄 = 1명 (줄바꿈 기준)&#10; 1열: 이메일, 2열~: 토큰(콤마로 구분)&#10;예) sungro81@gmail.com, 이상도, 10000, 쿠폰"></textarea> placeholder=" 1줄 = 1명 (줄바꿈 기준)&#10; 1열: 이메일, 2열~: 토큰(콤마로 구분)&#10;예) sungro81@gmail.com, 이상도, 10000, 쿠폰"></textarea>
<div class="a-muted mhelp" style="margin-top:8px;"> <div class="a-muted mhelp" style="margin-top:8px;">
- : <span class="mmono">sungro81@gmail.com, 홍길동, 10000, 쿠폰</span><br> - : <span class="mmono">sungro81@gmail.com, 홍길동, 10000, 쿠폰</span><br>
@ -175,7 +175,7 @@
<span class="a-muted" id="manyStats" style="font-size:12px;"></span> <span class="a-muted" id="manyStats" style="font-size:12px;"></span>
</div> </div>
{{-- 파싱 미리보기 --}} {{-- 파싱 미리보기 --}}
<div class="previewBox" style="margin-top:10px;"> <div class="previewBox" style="margin-top:10px;">
<div class="previewHead"> <div class="previewHead">
<div class="a-muted">파싱 미리보기 (상위 5)</div> <div class="a-muted">파싱 미리보기 (상위 5)</div>
@ -253,7 +253,7 @@
</div> </div>
<div style="display:flex; gap:8px; align-items:center; flex-wrap:nowrap; min-width:0;"> <div style="display:flex; gap:8px; align-items:center; flex-wrap:nowrap; min-width:0;">
{{-- 템플릿 선택값 서버로도 보내기 (선택사항이지만 디버깅/로그에 도움됨) --}} {{-- 템플릿 선택값 서버로도 보내기 (선택사항이지만 디버깅/로그에 도움됨) --}}
<select class="a-input" id="tplSelect" name="template_id" style="width:240px; max-width:240px; flex:0 0 240px;"> <select class="a-input" id="tplSelect" name="template_id" style="width:240px; max-width:240px; flex:0 0 240px;">
<option value="">템플릿 선택</option> <option value="">템플릿 선택</option>
@foreach(($templates ?? []) as $t) @foreach(($templates ?? []) as $t)
@ -406,7 +406,7 @@
const sendModeEl = document.getElementById('sendMode'); const sendModeEl = document.getElementById('sendMode');
const scheduledAtEl = document.getElementById('scheduledAt'); const scheduledAtEl = document.getElementById('scheduledAt');
const openSchBtn = document.getElementById('openSchedulePicker'); // 추가 const openSchBtn = document.getElementById('openSchedulePicker'); // 추가
const toEmailOneEl = document.getElementById('toEmail'); const toEmailOneEl = document.getElementById('toEmail');
const toEmailsTextEl = document.getElementById('toEmailsText'); const toEmailsTextEl = document.getElementById('toEmailsText');
@ -450,7 +450,7 @@
const tplSelect = document.getElementById('tplSelect'); const tplSelect = document.getElementById('tplSelect');
const tplApplyBtn = document.getElementById('tplApply'); const tplApplyBtn = document.getElementById('tplApply');
// 예약발송 Picker modal elements (추가) // 예약발송 Picker modal elements (추가)
const schModalEl = document.getElementById('schedulePickerModal'); const schModalEl = document.getElementById('schedulePickerModal');
const schBackEl = document.getElementById('schedulePickerBackdrop'); const schBackEl = document.getElementById('schedulePickerBackdrop');
const schCloseBtn = document.getElementById('closeSchedulePicker'); const schCloseBtn = document.getElementById('closeSchedulePicker');
@ -494,7 +494,7 @@
return `${y}-${m}-${day}`; return `${y}-${m}-${day}`;
} }
// scheduled_at 직접 입력 차단(실수 방지) // scheduled_at 직접 입력 차단(실수 방지)
if (scheduledAtEl){ if (scheduledAtEl){
scheduledAtEl.readOnly = true; scheduledAtEl.readOnly = true;
scheduledAtEl.addEventListener('keydown', (e) => { scheduledAtEl.addEventListener('keydown', (e) => {
@ -505,7 +505,7 @@
scheduledAtEl.addEventListener('drop', (e) => e.preventDefault()); scheduledAtEl.addEventListener('drop', (e) => e.preventDefault());
} }
// subject/body mirror sync (백엔드 키 불일치 대비) // subject/body mirror sync (백엔드 키 불일치 대비)
function syncMirrors(){ function syncMirrors(){
const s = subjectEl?.value ?? ''; const s = subjectEl?.value ?? '';
const b = bodyEl?.value ?? ''; const b = bodyEl?.value ?? '';
@ -515,7 +515,7 @@
if(mailBodyMirrorEl) mailBodyMirrorEl.value = b; if(mailBodyMirrorEl) mailBodyMirrorEl.value = b;
} }
// 템플릿/스킨 동시 선택 금지 상태관리 // 템플릿/스킨 동시 선택 금지 상태관리
function setTemplateLock(isTemplateChosen){ function setTemplateLock(isTemplateChosen){
if (!skinEl) return; if (!skinEl) return;
@ -568,14 +568,14 @@
scheduledAtEl.disabled = !isSch; scheduledAtEl.disabled = !isSch;
// 추가: 선택 버튼도 같이 토글 // 추가: 선택 버튼도 같이 토글
if (openSchBtn) openSchBtn.disabled = !isSch; if (openSchBtn) openSchBtn.disabled = !isSch;
if (!isSch) scheduledAtEl.value = ''; if (!isSch) scheduledAtEl.value = '';
}); });
}); });
// 여러건 파싱: "줄바꿈=행", "콤마=열" // 여러건 파싱: "줄바꿈=행", "콤마=열"
// 규칙: 1열=email, 2열부터 {_text_02_} 시작 // 규칙: 1열=email, 2열부터 {_text_02_} 시작
function parseManyLines(rawText){ function parseManyLines(rawText){
const text = String(rawText || ''); const text = String(rawText || '');
@ -613,11 +613,11 @@
const tokenCols = cols.slice(1); // 2열~ 실제 토큰값들 const tokenCols = cols.slice(1); // 2열~ 실제 토큰값들
// 핵심: tokens[0]은 더미로 비워두고 // 핵심: tokens[0]은 더미로 비워두고
// tokens[1]이 2열(= {_text_02_})이 되게 맞춤 // tokens[1]이 2열(= {_text_02_})이 되게 맞춤
const tokens = [''].concat(tokenCols); const tokens = [''].concat(tokenCols);
// 안전용: placeholder 그대로 key map도 함께 전송 // 안전용: placeholder 그대로 key map도 함께 전송
const token_map = {}; const token_map = {};
for (let j = 0; j < tokenCols.length && j < 8; j++) { for (let j = 0; j < tokenCols.length && j < 8; j++) {
const n = j + 2; // 2..9 const n = j + 2; // 2..9
@ -1008,7 +1008,7 @@
// ===== submit guard // ===== submit guard
form.addEventListener('submit', (e) => { form.addEventListener('submit', (e) => {
// submit 직전 한번 더 동기화 (주제/내용 누락 방어) // submit 직전 한번 더 동기화 (주제/내용 누락 방어)
syncMirrors(); syncMirrors();
const mode = sendModeEl.value; const mode = sendModeEl.value;

View File

@ -227,7 +227,7 @@
@if($isRegistered) @if($isRegistered)
<hr class="divider"> <hr class="divider">
<div class="a-muted help"> <div class="a-muted help">
<div> 등록 완료 상태입니다. “2차 인증방법”에서 <b>Google OTP 인증</b>으로 전환할 있습니다.</div> <div> 등록 완료 상태입니다. “2차 인증방법”에서 <b>Google OTP 인증</b>으로 전환할 있습니다.</div>
</div> </div>
@endif @endif
</div> </div>

View File

@ -77,13 +77,13 @@
$jSel = (string)($filters['join_block'] ?? ''); $jSel = (string)($filters['join_block'] ?? '');
$ip = (string)($filters['filter_ip'] ?? ''); $ip = (string)($filters['filter_ip'] ?? '');
// 중요: 절대 URL(https/http) 꼬임 방지용으로 relative route 사용 // 중요: 절대 URL(https/http) 꼬임 방지용으로 relative route 사용
$indexUrl = route('admin.join-filters.index', [], false); $indexUrl = route('admin.join-filters.index', [], false);
$storeUrl = route('admin.join-filters.store', [], false); $storeUrl = route('admin.join-filters.store', [], false);
$getTpl = route('admin.join-filters.get', ['seq' => '__SEQ__'], false); $getTpl = route('admin.join-filters.get', ['seq' => '__SEQ__'], false);
$updateTpl = route('admin.join-filters.update', ['seq' => '__SEQ__'], false); $updateTpl = route('admin.join-filters.update', ['seq' => '__SEQ__'], false);
// ParseError 방지: old payload는 PHP에서 먼저 만든다 // ParseError 방지: old payload는 PHP에서 먼저 만든다
$oldPayload = [ $oldPayload = [
'filter_seq' => old('filter_seq', ''), 'filter_seq' => old('filter_seq', ''),
'gubun_code' => old('gubun_code', '01'), 'gubun_code' => old('gubun_code', '01'),
@ -253,13 +253,13 @@
@csrf @csrf
<input type="hidden" name="_return_to" value="{{ request()->getRequestUri() }}"> <input type="hidden" name="_return_to" value="{{ request()->getRequestUri() }}">
{{-- join_block != S disabled된 체크값을 hidden으로 보강 제출 --}} {{-- join_block != S disabled된 체크값을 hidden으로 보강 제출 --}}
<div id="adminPhoneHidden"></div> <div id="adminPhoneHidden"></div>
<div class="modalx__body"> <div class="modalx__body">
<input type="hidden" name="filter_seq" id="filter_seq" value=""> <input type="hidden" name="filter_seq" id="filter_seq" value="">
{{-- 서버 에러가 있으면 모달 안에서 바로 보이게 --}} {{-- 서버 에러가 있으면 모달 안에서 바로 보이게 --}}
@if($errors->any()) @if($errors->any())
<div class="warnbox" style="margin-bottom:12px;"> <div class="warnbox" style="margin-bottom:12px;">
<b>저장 실패</b><br> <b>저장 실패</b><br>
@ -508,7 +508,7 @@
submitBtn.disabled = true; submitBtn.disabled = true;
}); });
// edit -> AJAX get // edit -> AJAX get
Array.from(document.querySelectorAll('.btnEdit')).forEach(btn => { Array.from(document.querySelectorAll('.btnEdit')).forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const seq = btn.getAttribute('data-seq'); const seq = btn.getAttribute('data-seq');
@ -537,7 +537,7 @@
const payload = await res.json(); const payload = await res.json();
// 컨트롤러 응답이 {ok,row} 인데 row를 직접 쓰던게 버그였음 // 컨트롤러 응답이 {ok,row} 인데 row를 직접 쓰던게 버그였음
const row = payload?.row ?? payload; const row = payload?.row ?? payload;
if (!row) throw new Error('NOT_FOUND'); if (!row) throw new Error('NOT_FOUND');
@ -569,9 +569,9 @@
// default // default
pickJoinBlock('S'); pickJoinBlock('S');
// 서버 validation 에러가 있으면 모달 다시 열어서 입력값 복구 // 서버 validation 에러가 있으면 모달 다시 열어서 입력값 복구
const hasErrors = @json($errors->any()); const hasErrors = @json($errors->any());
const oldPayload = @js($oldPayload); // ParseError 방지 + JS 안전 변환 const oldPayload = @js($oldPayload); // ParseError 방지 + JS 안전 변환
if (hasErrors) { if (hasErrors) {
const seq = String(oldPayload.filter_seq || ''); const seq = String(oldPayload.filter_seq || '');

View File

@ -60,7 +60,7 @@
$regFrom = (string)($f['reg_from'] ?? ''); $regFrom = (string)($f['reg_from'] ?? '');
$regTo = (string)($f['reg_to'] ?? ''); $regTo = (string)($f['reg_to'] ?? '');
// 로그인 필터: mode + days (둘 중 하나만) // 로그인 필터: mode + days (둘 중 하나만)
$loginMode = (string)($f['login_mode'] ?? 'none'); // none|inactive|recent $loginMode = (string)($f['login_mode'] ?? 'none'); // none|inactive|recent
$loginDays = (string)($f['login_days'] ?? ''); $loginDays = (string)($f['login_days'] ?? '');
@ -382,7 +382,7 @@
@push('scripts') @push('scripts')
<script> <script>
// date input 클릭/포커스 시 달력 즉시 표시 (Chrome/Edge 지원) // date input 클릭/포커스 시 달력 즉시 표시 (Chrome/Edge 지원)
document.querySelectorAll('input[type="date"][data-date]').forEach(function (el) { document.querySelectorAll('input[type="date"][data-date]').forEach(function (el) {
const tryShowPicker = function (ev) { const tryShowPicker = function (ev) {
// isTrusted: 실제 사용자 입력만 허용 // isTrusted: 실제 사용자 입력만 허용
@ -403,7 +403,7 @@
el.addEventListener('click', tryShowPicker); // 마지막 보루 el.addEventListener('click', tryShowPicker); // 마지막 보루
}); });
// 로그인 필터: mode에 따라 숫자 입력 활성/비활성 // 로그인 필터: mode에 따라 숫자 입력 활성/비활성
const modeEls = document.querySelectorAll('input[name="login_mode"]'); const modeEls = document.querySelectorAll('input[name="login_mode"]');
const daysEl = document.getElementById('login_days_input'); const daysEl = document.getElementById('login_days_input');

View File

@ -289,7 +289,7 @@
</div> </div>
</div> </div>
{{-- 관리자 메모 (레거시: when/admin_num/memo) --}} {{-- 관리자 메모 (레거시: when/admin_num/memo) --}}
<div class="a-card" style="padding:16px; margin-bottom:16px;"> <div class="a-card" style="padding:16px; margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;"> <div style="display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;">
<div><div style="font-weight:900;">관리자 메모</div></div> <div><div style="font-weight:900;">관리자 메모</div></div>
@ -329,7 +329,7 @@
</div> </div>
</div> </div>
{{-- 관리자 변경이력 (레거시: state_log) --}} {{-- 관리자 변경이력 (레거시: state_log) --}}
<div class="a-card" style="padding:16px; margin-bottom:16px;"> <div class="a-card" style="padding:16px; margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;"> <div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;">
<div style="font-weight:900;">관리자 변경이력</div> <div style="font-weight:900;">관리자 변경이력</div>

View File

@ -1,18 +1,18 @@
{{-- resources/views/admin/partials/dev_session_overlay.blade.php --}} {{-- resources/views/admin/partials/dev_session_overlay.blade.php --}}
@php @php
// 개발 모드에서만 노출 // 개발 모드에서만 노출
$show = (config('app.debug') || app()->environment('local')); $show = (config('app.debug') || app()->environment('local'));
// 관리자 도메인에서만 노출(실수로 web 도메인에 붙는 것 방지) // 관리자 도메인에서만 노출(실수로 web 도메인에 붙는 것 방지)
$adminHost = parse_url(env('APP_ADMIN_URL'), PHP_URL_HOST); $adminHost = parse_url(env('APP_ADMIN_URL'), PHP_URL_HOST);
if ($adminHost) { if ($adminHost) {
$show = $show && (request()->getHost() === $adminHost); $show = $show && (request()->getHost() === $adminHost);
} }
// 세션 전체 // 세션 전체
$sess = session()->all(); $sess = session()->all();
// admin은 기본 마스킹을 켜는 게 안전 (원하면 비워도 됨) // admin은 기본 마스킹을 켜는 게 안전 (원하면 비워도 됨)
$maskKeys = [ $maskKeys = [
'password','passwd','pw', 'password','passwd','pw',
'token','access_token','refresh_token', 'token','access_token','refresh_token',
@ -32,7 +32,7 @@
return $val; return $val;
}; };
// key:value 라인 생성(재귀) // key:value 라인 생성(재귀)
$lines = []; $lines = [];
$dump = function ($data, $prefix = '') use (&$dump, &$lines, $mask) { $dump = function ($data, $prefix = '') use (&$dump, &$lines, $mask) {
foreach ((array)$data as $k => $v) { foreach ((array)$data as $k => $v) {
@ -56,7 +56,7 @@
$dump($sess); $dump($sess);
$text = implode("\n", $lines); $text = implode("\n", $lines);
// dev route 이름 (너가 만든 이름에 맞춰 사용) // dev route 이름 (너가 만든 이름에 맞춰 사용)
// - 내가 권장했던 방식이면 admin.dev.session // - 내가 권장했던 방식이면 admin.dev.session
$devRouteName = 'admin.dev.session'; $devRouteName = 'admin.dev.session';
@endphp @endphp
@ -92,7 +92,7 @@
{{ request()->method() }} {{ request()->path() }} {{ request()->method() }} {{ request()->path() }}
</div> </div>
{{-- Controls --}} {{-- Controls --}}
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px;"> <div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px;">
<form method="POST" action="{{ route($devRouteName) }}" style="display:flex; gap:6px; align-items:center; margin:0;"> <form method="POST" action="{{ route($devRouteName) }}" style="display:flex; gap:6px; align-items:center; margin:0;">
@csrf @csrf

View File

@ -90,7 +90,7 @@
@endphp @endphp
<nav class="a-nav" data-nav="accordion"> <nav class="a-nav" data-nav="accordion">
{{-- 대시보드는 메뉴 배열에서 빼고 "단일 링크"로만 상단 고정 --}} {{-- 대시보드는 메뉴 배열에서 빼고 "단일 링크"로만 상단 고정 --}}
@php @php
$dashHas = \Illuminate\Support\Facades\Route::has('admin.home'); $dashHas = \Illuminate\Support\Facades\Route::has('admin.home');
$dashActive = request()->routeIs('admin.home'); $dashActive = request()->routeIs('admin.home');
@ -110,7 +110,7 @@
@foreach($menu as $group) @foreach($menu as $group)
@php @php
// "대시보드" 그룹은 렌더링에서만 제외 (배열 수정 X) // "대시보드" 그룹은 렌더링에서만 제외 (배열 수정 X)
if (($group['title'] ?? '') === '대시보드') { if (($group['title'] ?? '') === '대시보드') {
continue; continue;
} }
@ -128,7 +128,7 @@
} }
} }
// 현재 라우트 기준으로 "해당 그룹이 열려야 하는지" 선계산 // 현재 라우트 기준으로 "해당 그룹이 열려야 하는지" 선계산
$groupActive = false; $groupActive = false;
foreach ($visibleItems as $it) { foreach ($visibleItems as $it) {
$routeName = (string)($it['route'] ?? ''); $routeName = (string)($it['route'] ?? '');
@ -144,7 +144,7 @@
@if(!empty($visibleItems)) @if(!empty($visibleItems))
<div class="a-nav__group {{ $groupActive ? 'is-open' : '' }}" data-nav-group> <div class="a-nav__group {{ $groupActive ? 'is-open' : '' }}" data-nav-group>
{{-- 타이틀만 기본 노출 + 클릭 하위 펼침 --}} {{-- 타이틀만 기본 노출 + 클릭 하위 펼침 --}}
<button type="button" <button type="button"
class="a-nav__titlebtn" class="a-nav__titlebtn"
data-nav-toggle data-nav-toggle
@ -153,7 +153,7 @@
<span class="a-nav__chev" aria-hidden="true"></span> <span class="a-nav__chev" aria-hidden="true"></span>
</button> </button>
{{-- 하위 메뉴(기본 닫힘 / is-open일 때만 펼침) --}} {{-- 하위 메뉴(기본 닫힘 / is-open일 때만 펼침) --}}
<div class="a-nav__items" data-nav-items> <div class="a-nav__items" data-nav-items>
@foreach($visibleItems as $it) @foreach($visibleItems as $it)
@php @php

View File

@ -71,7 +71,7 @@
<span>{{ $c2['name'] }}</span> <span>{{ $c2['name'] }}</span>
<span class="a-muted" style="font-size:12px;">({{ $c2['slug'] }})</span> <span class="a-muted" style="font-size:12px;">({{ $c2['slug'] }})</span>
{{-- 추가: 등록된 해시태그 노출 --}} {{-- 추가: 등록된 해시태그 노출 --}}
@if(!empty($c2['search_keywords'])) @if(!empty($c2['search_keywords']))
<div class="cat-tags" style="display: flex; gap: 4px; margin-left: 8px;"> <div class="cat-tags" style="display: flex; gap: 4px; margin-left: 8px;">
@foreach(explode(' ', str_replace(',', ' ', $c2['search_keywords'])) as $tag) @foreach(explode(' ', str_replace(',', ' ', $c2['search_keywords'])) as $tag)

View File

@ -194,7 +194,7 @@
@php if ($no > 0) $no--; @endphp @php if ($no > 0) $no--; @endphp
@empty @empty
{{-- 컬럼 (10) 맞춰서 UI 깨짐 방지 --}} {{-- 컬럼 (10) 맞춰서 UI 깨짐 방지 --}}
<tr><td colspan="10" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr> <tr><td colspan="10" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr>
@endforelse @endforelse
</tbody> </tbody>

View File

@ -112,7 +112,7 @@
@csrf @csrf
@method('PUT') @method('PUT')
{{-- 저장 목록 복귀 같은 필터/페이지 유지용 --}} {{-- 저장 목록 복귀 같은 필터/페이지 유지용 --}}
@foreach($qs as $k=>$v) @foreach($qs as $k=>$v)
<input type="hidden" name="{{ $k }}" value="{{ $v }}"> <input type="hidden" name="{{ $k }}" value="{{ $v }}">
@endforeach @endforeach
@ -198,7 +198,7 @@
</div> </div>
</form> </form>
{{-- 삭제 (중첩 form 금지) --}} {{-- 삭제 (중첩 form 금지) --}}
<form id="skuDeleteForm" <form id="skuDeleteForm"
method="POST" method="POST"
action="{{ route('admin.skus.destroy', ['id'=>(int)($p->id ?? 0)] ) }}"> action="{{ route('admin.skus.destroy', ['id'=>(int)($p->id ?? 0)] ) }}">

View File

@ -186,7 +186,7 @@
const clear= document.getElementById('dateClear'); const clear= document.getElementById('dateClear');
if (!from || !to) return; if (!from || !to) return;
// 클릭해도 안 뜨는 환경에서 강제 오픈 // 클릭해도 안 뜨는 환경에서 강제 오픈
[from, to].forEach(el => { [from, to].forEach(el => {
el.addEventListener('click', () => { el.addEventListener('click', () => {
if (typeof el.showPicker === 'function') el.showPicker(); if (typeof el.showPicker === 'function') el.showPicker();

View File

@ -69,7 +69,7 @@
<form method="POST" action="{{ route('admin.sms.send.store') }}" enctype="multipart/form-data" id="smsSendForm"> <form method="POST" action="{{ route('admin.sms.send.store') }}" enctype="multipart/form-data" id="smsSendForm">
@csrf @csrf
{{-- send_mode: one | many | template(CSV 업로드) --}} {{-- send_mode: one | many | template(CSV 업로드) --}}
<input type="hidden" name="send_mode" id="sendMode" value="one"> <input type="hidden" name="send_mode" id="sendMode" value="one">
<input type="hidden" name="sms_type_hint" id="smsTypeHint" value="auto"> <input type="hidden" name="sms_type_hint" id="smsTypeHint" value="auto">
@ -87,7 +87,7 @@
<label class="a-pill"><input type="radio" name="schedule_type" value="now" checked> 즉시</label> <label class="a-pill"><input type="radio" name="schedule_type" value="now" checked> 즉시</label>
<label class="a-pill"><input type="radio" name="schedule_type" value="schedule"> 예약</label> <label class="a-pill"><input type="radio" name="schedule_type" value="schedule"> 예약</label>
{{-- 직접 입력 불가(실수 방지): readonly + 선택 버튼 --}} {{-- 직접 입력 불가(실수 방지): readonly + 선택 버튼 --}}
<input class="a-input" type="text" name="scheduled_at" id="scheduledAt" <input class="a-input" type="text" name="scheduled_at" id="scheduledAt"
placeholder="YYYY-MM-DD HH:mm" style="width:180px" disabled readonly> placeholder="YYYY-MM-DD HH:mm" style="width:180px" disabled readonly>
<button type="button" class="sms-btn" id="openSchedulePicker" disabled>선택</button> <button type="button" class="sms-btn" id="openSchedulePicker" disabled>선택</button>
@ -554,7 +554,7 @@
const digits = []; const digits = [];
for (const line of lines) { for (const line of lines) {
// 콤마가 있으면 "첫 토큰(수신번호)"만 사용 // 콤마가 있으면 "첫 토큰(수신번호)"만 사용
const first = line.split(',')[0].trim(); const first = line.split(',')[0].trim();
const d = first.replace(/\D+/g, ''); const d = first.replace(/\D+/g, '');
if (d) digits.push(d); if (d) digits.push(d);
@ -578,7 +578,7 @@
const validPhones = []; const validPhones = [];
for (const line of lines){ for (const line of lines){
const first = line.split(',')[0].trim(); // 콤마 앞만 phone const first = line.split(',')[0].trim(); // 콤마 앞만 phone
const digits = first.replace(/\D+/g,''); const digits = first.replace(/\D+/g,'');
if(/^01\d{8,9}$/.test(digits)) validPhones.push(digits); if(/^01\d{8,9}$/.test(digits)) validPhones.push(digits);
else invalid++; else invalid++;
@ -662,7 +662,7 @@
const m = (msg?.value || '').trim(); const m = (msg?.value || '').trim();
if(!m){ e.preventDefault(); alert('발송 문구를 입력하세요.'); return; } if(!m){ e.preventDefault(); alert('발송 문구를 입력하세요.'); return; }
// many도 토큰 치환 지원 (1줄=1명: "전화번호,치환값...") // many도 토큰 치환 지원 (1줄=1명: "전화번호,치환값...")
if(mode === 'many'){ if(mode === 'many'){
const idxs = extractTokenIdxs(m); const idxs = extractTokenIdxs(m);

View File

@ -39,7 +39,7 @@
</div> </div>
<div class="auth-actions"> <div class="auth-actions">
{{-- id 추가 (JS가 찾을 있게) --}} {{-- id 추가 (JS가 찾을 있게) --}}
<button id="btnSendVerify" class="auth-btn auth-btn--primary" type="submit"> <button id="btnSendVerify" class="auth-btn auth-btn--primary" type="submit">
인증메일 발송 인증메일 발송
</button> </button>
@ -63,7 +63,7 @@
let animTimer = null; let animTimer = null;
// 발송중 + 점 애니메이션 // 발송중 + 점 애니메이션
const setBusy = (busy) => { const setBusy = (busy) => {
btn.disabled = !!busy; btn.disabled = !!busy;
@ -90,13 +90,13 @@
} }
}; };
// 성공/실패 후 버튼 문구를 "이메일 다시발송"으로 고정 // 성공/실패 후 버튼 문구를 "이메일 다시발송"으로 고정
const setResendLabel = () => { const setResendLabel = () => {
btn.dataset.prevText = '이메일 다시발송'; btn.dataset.prevText = '이메일 다시발송';
btn.textContent = '이메일 다시발송'; btn.textContent = '이메일 다시발송';
}; };
// submit(기본 폼 전송) 막고 AJAX만 수행 // submit(기본 폼 전송) 막고 AJAX만 수행
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
if (btn.disabled) return; if (btn.disabled) return;

View File

@ -8,7 +8,7 @@
@section('desc', '가입 시 등록한 휴대폰 번호로 아이디를 확인할 수 있어요.') @section('desc', '가입 시 등록한 휴대폰 번호로 아이디를 확인할 수 있어요.')
@section('card_aria', '아이디 찾기 폼') @section('card_aria', '아이디 찾기 폼')
@section('show_cs_links', true) @section('show_cs_links', true)
{{-- reCAPTCHA 스크립트/공통함수는 페이지에서만 로드 --}} {{-- reCAPTCHA 스크립트/공통함수는 페이지에서만 로드 --}}
@push('recaptcha') @push('recaptcha')
<script>window.__recaptchaSiteKey = @json(config('services.recaptcha.site_key'));</script> <script>window.__recaptchaSiteKey = @json(config('services.recaptcha.site_key'));</script>
<script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script> <script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script>
@ -88,7 +88,7 @@
const $phone = document.getElementById('fi_phone'); const $phone = document.getElementById('fi_phone');
const $code = document.getElementById('fi_code'); const $code = document.getElementById('fi_code');
// 결과 박스는 id로 고정 (절대 흔들리지 않음) // 결과 박스는 id로 고정 (절대 흔들리지 않음)
const resultBox = document.getElementById('findIdResult'); const resultBox = document.getElementById('findIdResult');
// 메시지 영역: 항상 "현재 활성 패널"의 actions 위로 이동/생성 // 메시지 영역: 항상 "현재 활성 패널"의 actions 위로 이동/생성
@ -119,7 +119,7 @@
}; };
const render = () => { const render = () => {
// 1) 전환 전에 현재 포커스 제거 (경고 원인 제거) // 1) 전환 전에 현재 포커스 제거 (경고 원인 제거)
const activeEl = document.activeElement; const activeEl = document.activeElement;
if (activeEl && root.contains(activeEl)) { if (activeEl && root.contains(activeEl)) {
activeEl.blur(); activeEl.blur();
@ -131,7 +131,7 @@
p.classList.toggle('is-active', on); p.classList.toggle('is-active', on);
p.style.display = on ? 'block' : 'none'; p.style.display = on ? 'block' : 'none';
// 2) aria-hidden은 유지하되, 포커스/클릭 차단은 inert로 처리 // 2) aria-hidden은 유지하되, 포커스/클릭 차단은 inert로 처리
// on=false인 패널은 inert 적용(포커스 못 감) // on=false인 패널은 inert 적용(포커스 못 감)
if (!on) { if (!on) {
p.setAttribute('aria-hidden', 'true'); p.setAttribute('aria-hidden', 'true');
@ -149,7 +149,7 @@
mkMsg(); mkMsg();
// 3) 전환 후 포커스 이동(접근성/UX) // 3) 전환 후 포커스 이동(접근성/UX)
// 현재 step 패널의 첫 input 또는 버튼으로 포커스 // 현재 step 패널의 첫 input 또는 버튼으로 포커스
const target = root.querySelector(`.auth-panel[data-step="${step}"] input, .auth-panel[data-step="${step}"] button, .auth-panel[data-step="${step}"] a`); const target = root.querySelector(`.auth-panel[data-step="${step}"] input, .auth-panel[data-step="${step}"] button, .auth-panel[data-step="${step}"] a`);
target?.focus?.(); target?.focus?.();
@ -185,7 +185,7 @@
const postJson = async (url, data) => { const postJson = async (url, data) => {
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
credentials: 'same-origin', // include 대신 same-origin 권장(같은 도메인일 때) credentials: 'same-origin', // include 대신 same-origin 권장(같은 도메인일 때)
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrf(), 'X-CSRF-TOKEN': csrf(),
@ -196,7 +196,7 @@
}); });
const ct = res.headers.get('content-type') || ''; const ct = res.headers.get('content-type') || '';
const raw = await res.text(); // 먼저 text로 받는다 const raw = await res.text(); // 먼저 text로 받는다
let json = null; let json = null;
if (ct.includes('application/json')) { if (ct.includes('application/json')) {
@ -297,13 +297,13 @@
setMsg('확인 중입니다...', 'info'); setMsg('확인 중입니다...', 'info');
try { try {
const token = await getRecaptchaToken('find_id'); // 컨트롤러 Rule action과 동일 const token = await getRecaptchaToken('find_id'); // 컨트롤러 Rule action과 동일
const json = await postJson(@json(route('web.auth.find_id.send_code')), { const json = await postJson(@json(route('web.auth.find_id.send_code')), {
phone: raw, phone: raw,
'g-recaptcha-response': token, 'g-recaptcha-response': token,
}); });
// 성공 (ok true) // 성공 (ok true)
setMsg(json.message || '인증번호를 발송했습니다.', 'success'); setMsg(json.message || '인증번호를 발송했습니다.', 'success');
step = 2; step = 2;
@ -316,7 +316,7 @@
// } // }
} catch (err) { } catch (err) {
// 여기서 404(PHONE_NOT_FOUND)도 UX로 처리 // 여기서 404(PHONE_NOT_FOUND)도 UX로 처리
const p = err.payload || {}; const p = err.payload || {};
if (err.status === 404 && p.code === 'PHONE_NOT_FOUND') { if (err.status === 404 && p.code === 'PHONE_NOT_FOUND') {
@ -350,11 +350,11 @@
}); });
// 먼저 step 이동 + 렌더 (패널 표시 보장) // 먼저 step 이동 + 렌더 (패널 표시 보장)
step = 3; step = 3;
render(); render();
// 결과 반영은 렌더 후 // 결과 반영은 렌더 후
const maskedList = Array.isArray(json.masked_emails) ? json.masked_emails : []; const maskedList = Array.isArray(json.masked_emails) ? json.masked_emails : [];
if (resultBox) { if (resultBox) {
if (maskedList.length > 0) { if (maskedList.length > 0) {

View File

@ -9,7 +9,7 @@
@section('card_aria', '비밀번호 찾기 폼') @section('card_aria', '비밀번호 찾기 폼')
@section('show_cs_links', true) @section('show_cs_links', true)
{{-- reCAPTCHA 스크립트/공통함수는 페이지에서만 로드 --}} {{-- reCAPTCHA 스크립트/공통함수는 페이지에서만 로드 --}}
@push('recaptcha') @push('recaptcha')
<script>window.__recaptchaSiteKey = @json(config('services.recaptcha.site_key'));</script> <script>window.__recaptchaSiteKey = @json(config('services.recaptcha.site_key'));</script>
<script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script> <script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script>
@ -203,7 +203,7 @@
// -------- helpers ---------- // -------- helpers ----------
const csrf = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; const csrf = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
// recaptcha token getter // recaptcha token getter
const getRecaptchaToken = async (action) => { const getRecaptchaToken = async (action) => {
// production에서만 검증하지만, 프론트는 그냥 항상 시도해도 OK // production에서만 검증하지만, 프론트는 그냥 항상 시도해도 OK
const siteKey = window.__recaptchaSiteKey || ''; const siteKey = window.__recaptchaSiteKey || '';

View File

@ -10,7 +10,7 @@
@section('subheadline', '로그인 후 구매/문의 내역을 빠르게 확인할 수 있어요.') @section('subheadline', '로그인 후 구매/문의 내역을 빠르게 확인할 수 있어요.')
@section('card_aria', '로그인 폼') @section('card_aria', '로그인 폼')
{{-- reCAPTCHA 스크립트/공통함수는 페이지에서만 로드 --}} {{-- reCAPTCHA 스크립트/공통함수는 페이지에서만 로드 --}}
@push('recaptcha') @push('recaptcha')
<script>window.__recaptchaSiteKey = @json(config('services.recaptcha.site_key'));</script> <script>window.__recaptchaSiteKey = @json(config('services.recaptcha.site_key'));</script>
<script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script> <script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script>
@ -80,7 +80,7 @@
const form = document.getElementById('loginForm'); const form = document.getElementById('loginForm');
if (!form) return; if (!form) return;
// 너 템플릿이 id를 뭘 쓰든 대응 (id 우선, 없으면 name으로 fallback) // 너 템플릿이 id를 뭘 쓰든 대응 (id 우선, 없으면 name으로 fallback)
const emailEl = const emailEl =
document.getElementById('login_id') document.getElementById('login_id')
|| form.querySelector('input[name="mem_email"]') || form.querySelector('input[name="mem_email"]')
@ -179,7 +179,7 @@
// 버튼 잠금 // 버튼 잠금
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
try { try {
// 운영에서만 recaptcha 토큰 넣기 (서버도 동일 정책) // 운영에서만 recaptcha 토큰 넣기 (서버도 동일 정책)
const isProd = @json(app()->environment('production')); const isProd = @json(app()->environment('production'));
const hasKey = @json((bool) config('services.recaptcha.site_key')); const hasKey = @json((bool) config('services.recaptcha.site_key'));
@ -190,7 +190,7 @@ try {
const token = await getRecaptchaToken('login'); const token = await getRecaptchaToken('login');
hidden.value = token || ''; hidden.value = token || '';
// 토큰이 비면 submit 막아야 서버 required 안 터짐 // 토큰이 비면 submit 막아야 서버 required 안 터짐
if (!hidden.value) { if (!hidden.value) {
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
await showMsg("보안 검증(reCAPTCHA) 토큰 생성에 실패했습니다. 새로고침 후 다시 시도해 주세요.", { type: 'alert', title: '보안검증 실패' }); await showMsg("보안 검증(reCAPTCHA) 토큰 생성에 실패했습니다. 새로고침 후 다시 시도해 주세요.", { type: 'alert', title: '보안검증 실패' });
@ -198,7 +198,7 @@ try {
} }
} }
// 실제 전송 // 실제 전송
form.submit(); form.submit();
} catch (err) { } catch (err) {

View File

@ -168,7 +168,7 @@
flex:1; flex:1;
text-align:center; text-align:center;
padding:10px 12px; padding:10px 12px;
border-radius:999px; /* badge */ border-radius:999px; /* badge */
font-size:12.5px; font-size:12.5px;
font-weight:800; font-weight:800;
letter-spacing:-0.2px; letter-spacing:-0.2px;
@ -188,7 +188,7 @@
overflow:hidden; overflow:hidden;
} }
/* 비활성도 살짝 그라데이션 느낌(은은하게) */ /* 비활성도 살짝 그라데이션 느낌(은은하게) */
.terms-step::before{ .terms-step::before{
content:""; content:"";
position:absolute; position:absolute;
@ -202,7 +202,7 @@
pointer-events:none; pointer-events:none;
} }
/* 활성: 선명한 그라데이션 */ /* 활성: 선명한 그라데이션 */
.terms-step.is-active{ .terms-step.is-active{
opacity:1; opacity:1;
color:#fff; color:#fff;
@ -274,7 +274,7 @@
const p2 = document.getElementById('pin2'); const p2 = document.getElementById('pin2');
const p2c = document.getElementById('pin2_confirmation'); const p2c = document.getElementById('pin2_confirmation');
const loginHelp = document.getElementById('login_id_help'); // 그대로 사용 (blur에서만 갱신) const loginHelp = document.getElementById('login_id_help'); // 그대로 사용 (blur에서만 갱신)
const pwRuleHelp = document.getElementById('password_help'); const pwRuleHelp = document.getElementById('password_help');
const pwMatchHelp = document.getElementById('password_confirmation_help'); const pwMatchHelp = document.getElementById('password_confirmation_help');
const p2MatchHelp = document.getElementById('pin2_confirmation_help'); const p2MatchHelp = document.getElementById('pin2_confirmation_help');
@ -290,7 +290,7 @@
let isSubmitting = false; let isSubmitting = false;
function showAlert(message, title = '안내') { function showAlert(message, title = '안내') {
// 네가 쓰는 함수명에 맞춰 호출 (없으면 alert fallback) // 네가 쓰는 함수명에 맞춰 호출 (없으면 alert fallback)
if (typeof window.showAlert === 'function') return window.showAlert(message, title); if (typeof window.showAlert === 'function') return window.showAlert(message, title);
if (typeof window.showMsg === 'function') return window.showMsg(message, { type:'alert', title }); if (typeof window.showMsg === 'function') return window.showMsg(message, { type:'alert', title });
alert(`${title}\n\n${message}`); alert(`${title}\n\n${message}`);
@ -341,7 +341,7 @@
if (!pwHasLetter(s)) return `비밀번호에 영문(A-Z, a-z)을 포함해 주세요.`; if (!pwHasLetter(s)) return `비밀번호에 영문(A-Z, a-z)을 포함해 주세요.`;
if (!pwHasDigit(s)) return `비밀번호에 숫자를 포함해 주세요.`; if (!pwHasDigit(s)) return `비밀번호에 숫자를 포함해 주세요.`;
if (!pwHasAllowedSpecial(s)) { if (!pwHasAllowedSpecial(s)) {
// 줄바꿈: CSS(pre-line) 적용돼 있으니 \n 사용 // 줄바꿈: CSS(pre-line) 적용돼 있으니 \n 사용
return `특수문자를 입력해 주세요.\n(허용: ${ALLOWED_SPECIALS_TEXT})`; return `특수문자를 입력해 주세요.\n(허용: ${ALLOWED_SPECIALS_TEXT})`;
} }
if (pwHasDisallowedChar(s)) { if (pwHasDisallowedChar(s)) {
@ -436,7 +436,7 @@
// 이미 같은 값에 대해 통과한 중복체크가 있으면 skip // 이미 같은 값에 대해 통과한 중복체크가 있으면 skip
if (duplicateOk && lastCheckedLogin === v) return true; if (duplicateOk && lastCheckedLogin === v) return true;
// 중복체크 시도 // 중복체크 시도
try { try {
// loginHelp는 “그대로 표시” 원칙이라 여기서 메시지 바꾸지 않음 // loginHelp는 “그대로 표시” 원칙이라 여기서 메시지 바꾸지 않음
const { res, data } = await postJson("{{ route('web.auth.register.check_login_id') }}", { login_id: v }); const { res, data } = await postJson("{{ route('web.auth.register.check_login_id') }}", { login_id: v });
@ -461,7 +461,7 @@
} }
} }
// loginHelp는 그대로: blur에서만 업데이트 (시각적 도움용) // loginHelp는 그대로: blur에서만 업데이트 (시각적 도움용)
loginId.addEventListener('blur', async () => { loginId.addEventListener('blur', async () => {
const v = (loginId.value || '').trim(); const v = (loginId.value || '').trim();
@ -491,7 +491,7 @@
} }
}); });
// 사용자가 이메일을 수정하면 중복체크 결과 무효화 (loginHelp는 굳이 건드리지 않음) // 사용자가 이메일을 수정하면 중복체크 결과 무효화 (loginHelp는 굳이 건드리지 않음)
loginId.addEventListener('input', () => { loginId.addEventListener('input', () => {
duplicateOk = false; duplicateOk = false;
lastCheckedLogin = ''; lastCheckedLogin = '';
@ -503,7 +503,7 @@
el.addEventListener('input', () => validatePasswordFields(true)); el.addEventListener('input', () => validatePasswordFields(true));
}); });
// 가입 버튼 눌렀을 때 “무조건 반응” + 단계별 안내 // 가입 버튼 눌렀을 때 “무조건 반응” + 단계별 안내
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
if (isSubmitting) return; if (isSubmitting) return;

View File

@ -24,7 +24,7 @@
<form class="auth-form" id="regStep0Form" onsubmit="return false;"> <form class="auth-form" id="regStep0Form" onsubmit="return false;">
@csrf @csrf
{{-- hidden input만 생성(토큰은 JS에서 발급 payload에 포함) --}} {{-- hidden input만 생성(토큰은 JS에서 발급 payload에 포함) --}}
<x-recaptcha-v3 /> <x-recaptcha-v3 />
<div class="auth-field"> <div class="auth-field">
@ -78,7 +78,7 @@
flex:1; flex:1;
text-align:center; text-align:center;
padding:10px 12px; padding:10px 12px;
border-radius:999px; /* badge */ border-radius:999px; /* badge */
font-size:12.5px; font-size:12.5px;
font-weight:800; font-weight:800;
letter-spacing:-0.2px; letter-spacing:-0.2px;
@ -98,7 +98,7 @@
overflow:hidden; overflow:hidden;
} }
/* 비활성도 살짝 그라데이션 느낌(은은하게) */ /* 비활성도 살짝 그라데이션 느낌(은은하게) */
.terms-step::before{ .terms-step::before{
content:""; content:"";
position:absolute; position:absolute;
@ -112,7 +112,7 @@
pointer-events:none; pointer-events:none;
} }
/* 활성: 선명한 그라데이션 */ /* 활성: 선명한 그라데이션 */
.terms-step.is-active{ .terms-step.is-active{
opacity:1; opacity:1;
color:#fff; color:#fff;
@ -153,7 +153,7 @@
} }
</style> </style>
{{-- reCAPTCHA 스크립트/공통함수는 페이지에서만 로드 --}} {{-- reCAPTCHA 스크립트/공통함수는 페이지에서만 로드 --}}
@push('recaptcha') @push('recaptcha')
<script>window.__recaptchaSiteKey = @json(config('services.recaptcha.site_key'));</script> <script>window.__recaptchaSiteKey = @json(config('services.recaptcha.site_key'));</script>
<script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script> <script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script>
@ -167,7 +167,7 @@
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');
// 통신사 // 통신사
const carrierHidden = document.getElementById('reg_carrier'); const carrierHidden = document.getElementById('reg_carrier');
const carrierGroup = document.getElementById('reg_carrier_group'); const carrierGroup = document.getElementById('reg_carrier_group');
@ -186,7 +186,7 @@
return (value || '').replace(/\D/g, ''); return (value || '').replace(/\D/g, '');
} }
// 통신사 버튼 레이아웃/동일 크기(기존 CSS 크게 안 건드리고 JS로 스타일만 주입) // 통신사 버튼 레이아웃/동일 크기(기존 CSS 크게 안 건드리고 JS로 스타일만 주입)
function applyCarrierButtonLayout() { function applyCarrierButtonLayout() {
if (!carrierGroup) return; if (!carrierGroup) return;
@ -208,7 +208,7 @@
}); });
} }
// 통신사 선택 UI // 통신사 선택 UI
function bindCarrierButtons() { function bindCarrierButtons() {
if (!carrierGroup || !carrierHidden) return; if (!carrierGroup || !carrierHidden) return;
@ -229,7 +229,7 @@
}); });
} }
// 휴대폰 입력 UX // 휴대폰 입력 UX
input.addEventListener('input', function () { input.addEventListener('input', function () {
const formatted = formatPhone(input.value); const formatted = formatPhone(input.value);
if (input.value !== formatted) input.value = formatted; if (input.value !== formatted) input.value = formatted;
@ -253,7 +253,7 @@
applyCarrierButtonLayout(); applyCarrierButtonLayout();
bindCarrierButtons(); bindCarrierButtons();
// submit // submit
form.addEventListener('submit', async function () { form.addEventListener('submit', async function () {
// clearMsg()가 기존에 전역으로 있다면 유지 // clearMsg()가 기존에 전역으로 있다면 유지
if (typeof clearMsg === 'function') clearMsg(); if (typeof clearMsg === 'function') clearMsg();
@ -280,7 +280,7 @@
btn.disabled = true; btn.disabled = true;
try { try {
// 공통 함수로 토큰 발급 (한 줄) // 공통 함수로 토큰 발급 (한 줄)
const token = await window.recaptchaV3Token('register_phone_check', form); const token = await window.recaptchaV3Token('register_phone_check', form);
const res = await fetch("{{ route('web.auth.register.phone_check') }}", { const res = await fetch("{{ route('web.auth.register.phone_check') }}", {

View File

@ -464,7 +464,7 @@
flex:1; flex:1;
text-align:center; text-align:center;
padding:10px 12px; padding:10px 12px;
border-radius:999px; /* badge */ border-radius:999px; /* badge */
font-size:12.5px; font-size:12.5px;
font-weight:800; font-weight:800;
letter-spacing:-0.2px; letter-spacing:-0.2px;
@ -484,7 +484,7 @@
overflow:hidden; overflow:hidden;
} }
/* 비활성도 살짝 그라데이션 느낌(은은하게) */ /* 비활성도 살짝 그라데이션 느낌(은은하게) */
.terms-step::before{ .terms-step::before{
content:""; content:"";
position:absolute; position:absolute;
@ -498,7 +498,7 @@
pointer-events:none; pointer-events:none;
} }
/* 활성: 선명한 그라데이션 */ /* 활성: 선명한 그라데이션 */
.terms-step.is-active{ .terms-step.is-active{
opacity:1; opacity:1;
color:#fff; color:#fff;
@ -724,7 +724,7 @@
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.id = popupName; wrap.id = popupName;
// step0(전화번호 입력 페이지) 이동 URL // step0(전화번호 입력 페이지) 이동 URL
const backUrl = @json(route('web.auth.register')); const backUrl = @json(route('web.auth.register'));
wrap.innerHTML = ` wrap.innerHTML = `
@ -732,7 +732,7 @@
<div class="danal-modal-box" 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;"> <div class="danal-modal-box" 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;">
<!-- 헤더 + 닫기버튼 --> <!-- 헤더 + 닫기버튼 -->
<div style="height:44px;display:flex;align-items:center;justify-content:space-between;padding:0 10px;background:rgba(0,0,0,.06);border-bottom:1px solid rgba(0,0,0,.08);"> <div style="height:44px;display:flex;align-items:center;justify-content:space-between;padding:0 10px;background:rgba(0,0,0,.06);border-bottom:1px solid rgba(0,0,0,.08);">
<div style="font-weight:800;font-size:13px;color:#111;">PASS 본인인증</div> <div style="font-weight:800;font-size:13px;color:#111;">PASS 본인인증</div>
<button type="button" id="${popupName}_close" <button type="button" id="${popupName}_close"
@ -789,10 +789,10 @@
closeBtn.addEventListener('click', askCancelAndGo); closeBtn.addEventListener('click', askCancelAndGo);
} }
// 바깥 클릭으로 닫히지 않게 유지(기존 정책) // 바깥 클릭으로 닫히지 않게 유지(기존 정책)
// wrap.querySelector('.danal-modal-dim').addEventListener('click', (e)=>e.preventDefault()); // wrap.querySelector('.danal-modal-dim').addEventListener('click', (e)=>e.preventDefault());
// ESC 키로도 “중단” 처리 (선택) // ESC 키로도 “중단” 처리 (선택)
const escHandler = (e) => { const escHandler = (e) => {
if (e.key === 'Escape') askCancelAndGo(); if (e.key === 'Escape') askCancelAndGo();
}; };

View File

@ -48,7 +48,7 @@
</div> </div>
<div class="kakao-hero__actions"> <div class="kakao-hero__actions">
{{-- “정확한 카카오 채널 URL”이 있으면 여기 href만 교체하면 --}} {{-- “정확한 카카오 채널 URL”이 있으면 여기 href만 교체하면 --}}
<a class="btn btn--primary" href="javascript:void(0)" onclick="alert('카카오톡에서 “핀포유(@pinforyou)” 검색 후 채널 추가해 주세요.')"> <a class="btn btn--primary" href="javascript:void(0)" onclick="alert('카카오톡에서 “핀포유(@pinforyou)” 검색 후 채널 추가해 주세요.')">
카카오톡에서 검색하기 카카오톡에서 검색하기
</a> </a>

View File

@ -48,7 +48,7 @@
</div> </div>
</header> </header>
{{-- 내용 박스 라인 + 내부 패딩 --}} {{-- 내용 박스 라인 + 내부 패딩 --}}
<div class="nv-content-box"> <div class="nv-content-box">
<div class="nv-body editor-content"> <div class="nv-body editor-content">
{!! $notice->content !!} {!! $notice->content !!}

View File

@ -112,7 +112,7 @@
placeholder="문제 상황을 자세히 적어주세요.&#10;예) 주문시각/결제수단/금액/오류메시지/상품명" placeholder="문제 상황을 자세히 적어주세요.&#10;예) 주문시각/결제수단/금액/오류메시지/상품명"
required>{{ old('enquiry_content') }}</textarea> required>{{ old('enquiry_content') }}</textarea>
<div class="qna-help"> <div class="qna-help">
정확한 안내를 위해 개인정보(주민번호/전체 카드번호 ) 작성하지 마세요. 정확한 안내를 위해 내용은 상세하게 작성해 주시고, 개인정보(주민번호/전체 카드번호 ) 작성하지 마세요.
</div> </div>
</div> </div>
@ -135,7 +135,6 @@
<span>이메일 답변</span> <span>이메일 답변</span>
</label> </label>
</div> </div>
<div class="qna-help">현재는 UI만 제공되며, 실제 알림 연동은 추후 적용됩니다.</div>
</div> </div>

View File

@ -59,7 +59,7 @@
@include('web.company.header') @include('web.company.header')
<main> <main>
{{-- 페이지에서 @section('content') 여기로 들어옴 --}} {{-- 페이지에서 @section('content') 여기로 들어옴 --}}
@yield('content') @yield('content')
</main> </main>

View File

@ -68,7 +68,7 @@
</div> </div>
{{-- 서브메뉴(사이드바) --}} {{-- 서브메뉴(사이드바) --}}
<div class="col-lg-3 primary-sidebar sticky-sidebar"> <div class="col-lg-3 primary-sidebar sticky-sidebar">
@include('web.partials.mypage-quick-actions') @include('web.partials.mypage-quick-actions')
</div> </div>
@ -84,7 +84,7 @@
const pwEl = document.getElementById('password'); const pwEl = document.getElementById('password');
const btn = document.getElementById('btnGateSubmit'); const btn = document.getElementById('btnGateSubmit');
// 공통 레이어 알림(showMsg) 우선 사용 // 공통 레이어 알림(showMsg) 우선 사용
async function alertMsg(msg, title = '오류') { async function alertMsg(msg, title = '오류') {
if (!msg) return; if (!msg) return;
if (typeof showMsg === 'function') { if (typeof showMsg === 'function') {
@ -96,7 +96,7 @@
} }
} }
// 서버에서 내려온 에러를 레이어로 표시 (DOM 로드 후 1회) // 서버에서 내려온 에러를 레이어로 표시 (DOM 로드 후 1회)
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
const pwErr = @json($errors->first('password')); const pwErr = @json($errors->first('password'));
const loginErr = @json($errors->first('login')); // 혹시 login 키도 쓰는 경우 대비 const loginErr = @json($errors->first('login')); // 혹시 login 키도 쓰는 경우 대비
@ -105,12 +105,12 @@
const msg = pwErr || gateErr || loginErr || flashErr; const msg = pwErr || gateErr || loginErr || flashErr;
if (msg) { if (msg) {
if (btn) btn.disabled = false; // 에러로 돌아온 경우 버튼 다시 활성화 if (btn) btn.disabled = false; // 에러로 돌아온 경우 버튼 다시 활성화
await alertMsg(msg, '확인 실패'); await alertMsg(msg, '확인 실패');
} }
}); });
// 제출 검증 + 버튼 잠금 // 제출 검증 + 버튼 잠금
form.addEventListener('submit', async function (e) { form.addEventListener('submit', async function (e) {
const pw = (pwEl?.value || '').trim(); const pw = (pwEl?.value || '').trim();

View File

@ -24,17 +24,17 @@
'desc' => '계정 보안과 개인정보를 안전하게 관리하세요.' 'desc' => '계정 보안과 개인정보를 안전하게 관리하세요.'
]) ])
{{-- 상단 상태 카드 --}} {{-- 상단 상태 카드 --}}
<div class="mypage-hero mt-3"> <div class="mypage-hero mt-3">
<div class="mypage-hero__inner mypage-hero__inner--stack"> <div class="mypage-hero__inner mypage-hero__inner--stack">
<!-- 헤더: 100% --> <!-- 헤더: 100% -->
<div class="mypage-hero__head"> <div class="mypage-hero__head">
<div class="mypage-hero__kicker">ACCOUNT SETTINGS</div> <div class="mypage-hero__kicker">ACCOUNT SETTINGS</div>
<div class="mypage-hero__title"> 정보 관리</div> <div class="mypage-hero__title"> 정보 관리</div>
</div> </div>
<!-- 바디: / 2컬럼 --> <!-- 바디: / 2컬럼 -->
<div class="mypage-hero__body"> <div class="mypage-hero__body">
<div class="mypage-hero__left"> <div class="mypage-hero__left">
<div class="mypage-hero__me"> <div class="mypage-hero__me">
@ -100,7 +100,7 @@
</div> </div>
</div> </div>
{{-- 설정 카드 그리드 --}} {{-- 설정 카드 그리드 --}}
<div class="mypage-grid mt-3"> <div class="mypage-grid mt-3">
<button type="button" <button type="button"
class="mypage-card mypage-card--btn" class="mypage-card mypage-card--btn"
@ -180,7 +180,7 @@
</div> </div>
{{-- 안내/주의사항 --}} {{-- 안내/주의사항 --}}
<div class="mypage-note mt-3"> <div class="mypage-note mt-3">
<div class="mypage-note__title">안내</div> <div class="mypage-note__title">안내</div>
<ul class="mypage-note__list"> <ul class="mypage-note__list">

View File

@ -95,7 +95,7 @@
{{-- 리스트 --}} {{-- 리스트 --}}
<section class="mq-list"> <section class="mq-list">
{{-- 데스크톱 테이블 --}} {{-- 데스크톱 테이블 --}}
<div class="mq-table-wrap"> <div class="mq-table-wrap">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-bordered align-middle text-center mb-0"> <table class="table table-bordered align-middle text-center mb-0">
@ -138,7 +138,7 @@
</div> </div>
</div> </div>
{{-- 모바일 카드 리스트 --}} {{-- 모바일 카드 리스트 --}}
<div class="mq-cards-mobile"> <div class="mq-cards-mobile">
@forelse($items as $row) @forelse($items as $row)
@php @php

View File

@ -1,185 +1,309 @@
@extends('web.layouts.subpage') @extends('web.layouts.subpage')
@php @php
// 탭 활성화용
$mypageActive = $mypageActive ?? 'usage'; $mypageActive = $mypageActive ?? 'usage';
$filters = $filters ?? ['q'=>'','method'=>'','status'=>'','from'=>'','to'=>''];
$listQuery = request()->only(['q', 'method', 'status', 'from', 'to', 'page']);
$listQuery = array_filter($listQuery, fn($v) => $v !== null && $v !== '');
$methodLabel = function ($m) {
$m = (string)$m;
return match ($m) {
'card' => '카드',
'phone' => '휴대폰',
'wire' => '계좌이체',
'vact' => '가상계좌',
default => $m ?: '-',
};
};
// 상태는 "결제완료/결제취소" 중심 + 화면 깨짐 방지 최소 처리
$statusLabel = function ($r) {
$aCancel = (string)($r->attempt_cancel_status ?? 'none');
$oCancel = (string)($r->order_cancel_status ?? 'none');
// 결제 후 취소 인식: status/stat_pay 유지 + cancel_status=success
if ($aCancel === 'success' || $oCancel === 'success') return '결제취소';
$aStatus = (string)($r->attempt_status ?? '');
$oPay = (string)($r->order_stat_pay ?? '');
if ($aStatus === 'paid' || $oPay === 'p') return '결제완료';
// 아래는 운영/테스트 중 섞여도 UI가 깨지지 않게 최소 표시
if ($aStatus === 'failed' || $oPay === 'f') return '결제실패';
if ($aStatus === 'issued' || $oPay === 'w') return '입금대기';
return '진행중';
};
$statusClass = function ($label) {
return match ($label) {
'결제취소' => 'pill--danger',
'결제완료' => 'pill--ok',
'결제실패' => 'pill--danger',
'입금대기' => 'pill--wait',
default => 'pill--muted',
};
};
$formatDate = function ($v) {
$s = (string)$v;
if ($s === '') return '-';
try {
return \Carbon\Carbon::parse($s)->format('Y-m-d H:i'); // 분까지
} catch (\Throwable $e) {
return mb_substr($s, 0, 16);
}
};
@endphp @endphp
@section('title', $pageTitle ?? '구매내역') @section('title', $pageTitle ?? '구매내역d')
@section('subcontent') @section('subcontent')
<div class="mypage-usage"> <div class="mypage-usage">
@if(($mode ?? 'empty') === 'empty') @if(session('success'))
<div class="notice-box"> <div class="notice-box notice-box--ok">{{ session('success') }}</div>
<p>결제 완료 페이지에서 확인/발급/매입을 진행할 있습니다.</p>
<p class="muted">결제 완료 페이지에서 자동으로 이동되며, attempt_id가 없으면 상세 정보를 표시할 없습니다.</p>
</div>
@else
{{-- 상태 스텝 --}}
<div class="usage-steps">
@foreach(($steps ?? []) as $st)
@php
$active = false;
$done = false;
// 표시 규칙(간단): 현재 stepKey 기준으로 active 표시
$active = (($stepKey ?? '') === $st['key']);
// 완료표시(선택): deposit_wait 이전/이후 같은 세밀한 건 다음 단계에서 고도화 가능
// 여기서는 "active 이전"을 done으로 찍지 않고, 필요하면 확장
@endphp
<div class="step {{ $active ? 'is-active' : '' }}">
<div class="dot"></div>
<div class="label">{{ $st['label'] }}</div>
</div>
@endforeach
</div>
{{-- 결제/주문 요약 --}}
<div class="usage-card">
<h3 class="card-title">주문/결제 정보</h3>
<div class="grid">
<div><span class="k">주문번호</span> <span class="v">{{ $order['oid'] ?? '-' }}</span></div>
<div><span class="k">결제수단</span> <span class="v">{{ $order['pay_method'] ?? '-' }}</span></div>
<div><span class="k">결제상태</span> <span class="v">{{ $order['stat_pay'] ?? '-' }}</span></div>
<div><span class="k">PG TID</span> <span class="v">{{ $order['pg_tid'] ?? ($attempt['pg_tid'] ?? '-') }}</span></div>
<div><span class="k">결제일시</span> <span class="v">{{ $order['created_at'] ?? '-' }}</span></div>
</div>
<div class="amounts">
<div><span class="k">상품금액</span> <span class="v">{{ number_format($order['amounts']['subtotal'] ?? 0) }}</span></div>
<div><span class="k">고객수수료</span> <span class="v">{{ number_format($order['amounts']['fee'] ?? 0) }}</span></div>
<div class="sum"><span class="k">결제금액</span> <span class="v">{{ number_format($order['amounts']['pay_money'] ?? 0) }}</span></div>
</div>
@if(!empty($vactInfo))
<div class="vact-box">
<h4>가상계좌 안내</h4>
<div class="grid">
<div><span class="k">은행</span> <span class="v">{{ $vactInfo['bank'] ?? '-' }}</span></div>
<div><span class="k">계좌번호</span> <span class="v">{{ $vactInfo['account'] ?? '-' }}</span></div>
<div><span class="k">예금주</span> <span class="v">{{ $vactInfo['holder'] ?? '-' }}</span></div>
<div><span class="k">입금금액</span> <span class="v">{{ is_numeric($vactInfo['amount'] ?? null) ? number_format((int)$vactInfo['amount']).'원' : ($vactInfo['amount'] ?? '-') }}</span></div>
<div><span class="k">만료</span> <span class="v">{{ $vactInfo['expire_at'] ?? '-' }}</span></div>
</div>
</div>
@endif @endif
</div> @if(session('error'))
<div class="notice-box notice-box--err">{{ session('error') }}</div>
{{-- 아이템 --}}
<div class="usage-card">
<h3 class="card-title">구매 상품</h3>
<div class="items">
@foreach(($items ?? []) as $it)
<div class="item-row">
<div class="name">{{ $it['name'] }}</div>
<div class="meta">
<span class="pill">수량 {{ $it['qty'] }}</span>
<span class="pill">{{ number_format($it['unit'] ?? 0) }}</span>
<span class="pill pill--strong">합계 {{ number_format($it['total'] ?? 0) }}</span>
</div>
</div>
@endforeach
</div>
</div>
{{-- 발급 상태/액션 (결제완료에서만 활성) --}}
<div class="usage-card">
<h3 class="card-title"> 발급/전달</h3>
<div class="pin-status">
<div><span class="k">필요 수량</span> <span class="v">{{ (int)($requiredQty ?? 0) }}</span></div>
<div><span class="k">할당된 </span> <span class="v">{{ (int)($assignedPinsCount ?? 0) }}</span></div>
@if(!empty($pinsSummary))
<div class="summary">
@foreach($pinsSummary as $st => $cnt)
<span class="pill">{{ $st }}: {{ $cnt }}</span>
@endforeach
</div>
@endif @endif
<div class="usage-card">
{{-- 검색/필터 --}}
<form class="filters" method="get" action="{{ route('web.mypage.usage.index') }}">
<input class="inp inp--grow" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="주문번호 검색">
<select class="sel" name="method">
<option value="">결제수단(전체)</option>
<option value="card" @selected(($filters['method'] ?? '')==='card')>카드</option>
<option value="phone" @selected(($filters['method'] ?? '')==='phone')>휴대폰</option>
<option value="wire" @selected(($filters['method'] ?? '')==='wire')>계좌이체</option>
<option value="vact" @selected(($filters['method'] ?? '')==='vact')>가상계좌</option>
</select>
<select class="sel" name="status">
<option value="">상태(전체)</option>
<option value="paid" @selected(($filters['status'] ?? '')==='paid')>결제완료</option>
<option value="cancelled" @selected(($filters['status'] ?? '')==='cancelled')>취소</option>
{{-- <option value="failed" @selected(($filters['status'] ?? '')==='failed')>실패</option>--}}
<option value="issued" @selected(($filters['status'] ?? '')==='issued')>입금대기</option>
{{-- <option value="ready" @selected(($filters['status'] ?? '')==='ready')>대기</option>--}}
{{-- <option value="redirected" @selected(($filters['status'] ?? '')==='redirected')>진행중</option>--}}
</select>
<div class="dates">
<input class="inp" type="date" name="from" value="{{ $filters['from'] ?? '' }}">
<span class="tilde">~</span>
<input class="inp" type="date" name="to" value="{{ $filters['to'] ?? '' }}">
</div> </div>
<div class="btns">
<button class="btn btn--primary" type="submit">검색</button>
<a class="btn btn--primary" href="{{ route('web.mypage.usage.index') }}">초기화</a>
</div>
</form>
{{-- 모바일: 카드 리스트 (가로 스크롤 없음) --}}
<div class="list-mobile">
@forelse(($rows ?? []) as $idx => $r)
@php @php
$isPaid = (($order['stat_pay'] ?? '') === 'p') || (($attempt['status'] ?? '') === 'paid'); $no = (method_exists($rows, 'firstItem') && $rows->firstItem())
$isVactWait = (($order['stat_pay'] ?? '') === 'w') || (($attempt['status'] ?? '') === 'issued'); ? ($rows->firstItem() + $idx)
$isFailed = in_array(($order['stat_pay'] ?? ''), ['c','f'], true) || in_array(($attempt['status'] ?? ''), ['cancelled','failed'], true); : ($idx + 1);
$name = (string)($r->product_name ?? '');
$item = (string)($r->item_name ?? '');
$qty = (int)($r->total_qty ?? 0);
$money = (int)($r->pay_money ?? 0);
$method = (string)($r->pay_method ?? '');
$st = $statusLabel($r);
$stCls = $statusClass($st);
$dt = $formatDate($r->created_at ?? '');
$href = route('web.mypage.usage.show', array_merge(['attemptId' => $r->attempt_id], $listQuery));
@endphp @endphp
@if($isVactWait) <a class="mcard" href="{{ $href }}">
<div class="notice-box"> <div class="mcard__top">
<p>가상계좌 입금 확인 발급을 진행할 있습니다.</p> <div class="mcard__no">No. {{ $no }}</div>
<span class="pill {{ $stCls }}">{{ $st }}</span>
</div> </div>
@elseif($isFailed)
<div class="notice-box"> <div class="mcard__title">
<p>결제가 취소/실패 상태입니다. 결제 정보를 확인해 주세요.</p> {{ $name !== '' ? $name : '-' }}
</div> </div>
@elseif($isPaid)
<div class="action-grid"> <div class="mcard__meta">
<button type="button" class="act-btn" onclick="uiUsage.todo('SMS 발송은 다음 단계에서 연결할게요.')">핀번호 SMS 발송</button> <div class="mrow">
<button type="button" class="act-btn act-btn--primary" onclick="uiUsage.todo('웹 핀 노출/재고할당 로직을 다음 단계에서 연결할게요.')">핀번호 바로 확인</button> <span class="k">결제수단</span>
<button type="button" class="act-btn" onclick="uiUsage.todo('매입 테이블 설계 후 바로 붙일게요.')">핀번호 되팔기(매입)</button> <span class="v">{{ $methodLabel($method) }}</span>
</div> </div>
<p class="muted small"> 사고 방지를 위해 노출/발송/매입은 모두 서버 검증 처리됩니다.</p> <div class="mrow">
@else <span class="k">수량</span>
<div class="notice-box"> <span class="v">{{ $qty }}</span>
<p>결제 진행 중이거나 확인 중입니다. 잠시 다시 확인해 주세요.</p> </div>
<div class="mrow">
<span class="k">금액</span>
<span class="v">{{ number_format($money) }}</span>
</div>
<div class="mrow">
<span class="k">일시</span>
<span class="v">{{ $dt }}</span>
</div>
</div>
</a>
@empty
<div class="empty">구매내역이 없습니다.</div>
@endforelse
</div>
{{-- 데스크톱: 테이블 ( 클릭으로 상세 이동) --}}
<div class="list-desktop">
<table class="tbl">
<thead>
<tr>
<th style="width:70px;">No.</th>
<th>상품명</th>
<th style="width:110px;">결제수단</th>
<th style="width:80px;">수량</th>
<th style="width:130px;">금액</th>
<th style="width:120px;">상태</th>
<th style="width:160px;">일시</th>
</tr>
</thead>
<tbody>
@forelse(($rows ?? []) as $idx => $r)
@php
$no = (method_exists($rows, 'firstItem') && $rows->firstItem())
? ($rows->firstItem() + $idx)
: ($idx + 1);
$name = (string)($r->product_name ?? '');
$item = (string)($r->item_name ?? '');
$qty = (int)($r->total_qty ?? 0);
$money = (int)($r->pay_money ?? 0);
$method = (string)($r->pay_method ?? '');
$st = $statusLabel($r);
$stCls = $statusClass($st);
$dt = $formatDate($r->created_at ?? '');
$href = route('web.mypage.usage.show', array_merge(['attemptId' => $r->attempt_id], $listQuery));
@endphp
<tr class="row-link" data-href="{{ $href }}" tabindex="0" role="link" aria-label="상세 보기">
<td>{{ $no }}</td>
<td class="p_name">{{ $name !== '' ? $name : '-' }} - {{ $item !== '' ? $item : '-' }}</td>
<td>{{ $methodLabel($method) }}</td>
<td>{{ $qty }}</td>
<td class="money">{{ number_format($money) }} </td>
<td><span class="pill {{ $stCls }}">{{ $st }}</span></td>
<td>{{ $dt }}</td>
</tr>
@empty
<tr><td colspan="7" class="empty">구매내역이 없습니다.</td></tr>
@endforelse
</tbody>
</table>
</div>
@if($rows->hasPages())
<div class="mq-pager">
{{ $rows->links('web.partials.pagination') }}
</div> </div>
@endif @endif
</div> </div>
{{-- 디버그(접기) --}}
<details class="usage-debug">
<summary>결제 상세(디버그)</summary>
<pre>{{ json_encode(['attempt'=>$attempt,'order'=>$order], JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) }}</pre>
</details>
@endif
</div> </div>
<script>
// 데스크톱 테이블: 행 클릭 → 상세 이동
document.addEventListener('click', function (e) {
const tr = e.target.closest('.row-link');
if (!tr) return;
const href = tr.getAttribute('data-href');
if (href) window.location.href = href;
});
// 키보드 접근성(Enter) 지원
document.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
const tr = e.target.closest('.row-link');
if (!tr) return;
const href = tr.getAttribute('data-href');
if (href) window.location.href = href;
});
</script>
<style> <style>
.mypage-usage { display:flex; flex-direction:column; gap:14px; } .mypage-usage { display:flex; flex-direction:column; gap:14px; }
.usage-card { border:1px solid rgba(0,0,0,.08); border-radius:14px; padding:16px; background:#fff; } .usage-card { border:1px solid rgba(0,0,0,.08); border-radius:14px; padding:16px; background:#fff; }
.card-title { font-size:16px; margin:0 0 10px 0; }
.grid { display:grid; grid-template-columns:1fr 1fr; gap:8px 14px; }
.k { color:rgba(0,0,0,.55); margin-right:8px; }
.v { font-weight:600; }
.amounts { margin-top:12px; display:grid; grid-template-columns:1fr 1fr; gap:8px 14px; }
.amounts .sum { grid-column: span 2; padding-top:8px; border-top:1px dashed rgba(0,0,0,.12); }
.items { display:flex; flex-direction:column; gap:10px; }
.item-row { padding:12px; border:1px solid rgba(0,0,0,.08); border-radius:12px; }
.item-row .name { font-weight:700; margin-bottom:6px; }
.pill { display:inline-flex; padding:4px 8px; border-radius:999px; font-size:12px; border:1px solid rgba(0,0,0,.12); margin-right:6px; }
.pill--strong { font-weight:700; }
.muted { color:rgba(0,0,0,.55); }
.small { font-size:12px; }
.notice-box { padding:12px; border-radius:12px; background:rgba(0,0,0,.04); }
.vact-box { margin-top:14px; padding:12px; border-radius:12px; background:rgba(0,0,0,.03); border:1px solid rgba(0,0,0,.08); }
.pin-status { display:flex; gap:14px; flex-wrap:wrap; align-items:center; margin-bottom:12px; }
.pin-status .summary { width:100%; margin-top:6px; }
.action-grid { display:grid; grid-template-columns:1fr; gap:8px; }
@media (min-width: 720px){ .action-grid { grid-template-columns:1fr 1fr 1fr; } }
.act-btn { padding:12px 10px; border-radius:12px; border:1px solid rgba(0,0,0,.14); background:#fff; cursor:pointer; }
.act-btn--primary { font-weight:800; }
.usage-steps { display:grid; grid-template-columns: repeat(6, 1fr); gap:8px; }
.usage-steps .step { padding:10px 8px; border-radius:12px; border:1px solid rgba(0,0,0,.08); text-align:center; font-size:12px; background:#fff; }
.usage-steps .step.is-active { border-color:rgba(0,0,0,.25); box-shadow:0 1px 0 rgba(0,0,0,.06); font-weight:800; }
.usage-steps .dot { width:8px; height:8px; border-radius:50%; margin:0 auto 6px auto; background:rgba(0,0,0,.25); }
.usage-debug { border:1px dashed rgba(0,0,0,.18); border-radius:12px; padding:10px 12px; background:#fff; }
.usage-debug pre { margin:10px 0 0 0; max-height:240px; overflow:auto; font-size:12px; }
</style>
<script> .head{display:flex; justify-content:space-between; align-items:flex-end; gap:10px; flex-wrap:wrap;}
window.uiUsage = { .title-wrap{display:flex; flex-direction:column; gap:6px;}
todo(msg){ .card-title { font-size:16px; margin:0; }
if (typeof showMsg === 'function') { .muted{color:rgba(0,0,0,.55);}
showMsg(msg, { type:'alert', title:'안내' });
} else { /* Filters */
alert(msg); .filters{display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin:12px 0;}
.inp,.sel{padding:10px 10px;border-radius:12px;border:1px solid rgba(0,0,0,.14); background:#fff; font-size:13px;}
.inp--grow{flex:1; min-width:220px;}
.dates{display:flex;align-items:center;gap:6px;}
.tilde{opacity:.6;}
.btns{display:flex;gap:8px;}
.btn{display:inline-flex;align-items:center;justify-content:center;padding:10px 12px;border-radius:12px;border:1px solid rgba(0,0,0,.14);cursor:pointer;text-decoration:none;font-size:13px; white-space:nowrap;}
.btn--primary{font-weight:800;height:38px;margin-top:2px}
/* Pills */
.pill{display:inline-flex; padding:4px 10px; border-radius:999px; font-size:12px; border:1px solid rgba(0,0,0,.12); white-space:nowrap;}
.pill--ok{background:rgba(0,160,60,.08); border-color:rgba(0,160,60,.18);}
.pill--danger{background:rgba(220,0,0,.08); border-color:rgba(220,0,0,.18); color:rgb(180,0,0);}
.pill--wait{background:rgba(255,190,0,.12); border-color:rgba(255,190,0,.25);}
.pill--muted{opacity:.75;}
/* Desktop table */
.list-desktop{display:none;}
.tbl{width:100%; border-collapse:collapse;}
.tbl th,.tbl td{border-bottom:1px solid rgba(0,0,0,.08); padding:12px 10px; text-align:left; font-size:13px; vertical-align:middle;}
.tbl th{background:rgba(0,0,0,.02); font-weight:800;}
.row-link{cursor:pointer;}
.row-link:hover{background:rgba(0,0,0,.02);}
.row-link:focus{outline:2px solid rgba(0,0,0,.15); outline-offset:-2px;}
.mono{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;}
/* Mobile cards (no horizontal scroll) */
.list-mobile{display:flex; flex-direction:column; gap:10px;}
.mcard{
display:block;
text-decoration:none;
color:inherit;
border:1px solid rgba(0,0,0,.08);
border-radius:14px;
padding:12px;
background:#fff;
} }
.mcard:active{transform:scale(0.99);}
.mcard__top{display:flex; justify-content:space-between; align-items:center; gap:10px;}
.mcard__no{font-size:12px; color:rgba(0,0,0,.6); font-weight:700;}
.mcard__title{margin-top:8px; font-size:14px; font-weight:800; line-height:1.35;}
.mcard__meta{margin-top:10px; display:flex; flex-direction:column; gap:6px;}
.mrow{display:flex; justify-content:space-between; gap:10px; font-size:13px;}
.mrow .k{color:rgba(0,0,0,.55);}
.mrow .v{font-weight:700;}
.empty{text-align:center;color:rgba(0,0,0,.55); padding:14px 0;}
.pager{margin-top:14px;}
.notice-box{padding:12px;border-radius:12px;background:rgba(0,0,0,.04);}
.notice-box--ok{background:rgba(0,160,60,.08);}
.notice-box--err{background:rgba(220,0,0,.08);}
.tbl th, .tbl td { text-align: center; }
.tbl td.p_name { text-align: left; }
.tbl td.money { text-align: right; }
/* Desktop breakpoint */
@media (min-width: 960px){
.list-mobile{display:none;}
.list-desktop{display:block;}
} }
}; </style>
</script>
@endsection @endsection

View File

@ -0,0 +1,936 @@
@extends('web.layouts.subpage')
@php
$mypageActive = $mypageActive ?? 'usage';
$attempt = $attempt ?? [];
$order = $order ?? [];
$items = $items ?? [];
$pins = $pins ?? [];
$pinsOpened = (bool)($pinsOpened ?? false);
$canCancel = (bool)($canCancel ?? false);
$backToListQuery = request()->only(['q', 'method', 'status', 'from', 'to', 'page']);
$backToListQuery = array_filter($backToListQuery, fn($v) => $v !== null && $v !== '');
// 결제수단 한글 매핑
$methodLabel = function ($m) {
$m = (string)$m;
return match ($m) {
'card' => '카드',
'phone' => '휴대폰',
'wire' => '계좌이체',
'vact' => '가상계좌',
default => $m ?: '-',
};
};
// 리스트와 동일한 상태 라벨
$statusLabel = function () use ($attempt, $order) {
$aCancel = (string)($attempt['cancel_status'] ?? 'none');
$oCancel = (string)($order['cancel_status'] ?? 'none');
// 결제 후 취소는 cancel_status=success로만 인식
if ($aCancel === 'success' || $oCancel === 'success') return '결제취소';
$aStatus = (string)($attempt['status'] ?? '');
$oPay = (string)($order['stat_pay'] ?? '');
if ($aStatus === 'paid' || $oPay === 'p') return '결제완료';
if ($aStatus === 'issued' || $oPay === 'w') return '입금대기';
// 화면 깨짐 방지용(원하면 숨겨도 됨)
if ($aStatus === 'failed' || $oPay === 'f') return '결제실패';
return '진행중';
};
$st = $statusLabel();
$isCancelledAfterPaid = ($st === '결제취소'); // 취소 완료면 전표만 남김
$statusClass = function ($label) {
return match ($label) {
'결제취소' => 'pill--danger',
'결제완료' => 'pill--ok',
'입금대기' => 'pill--wait',
'결제실패' => 'pill--danger',
default => 'pill--muted',
};
};
// 전표용 값
$attemptId = (int)($attempt['id'] ?? 0);
$oid = (string)($order['oid'] ?? '');
$method = (string)($order['pay_method'] ?? ($attempt['pay_method'] ?? ''));
$methodKor = $methodLabel($method);
$amounts = (array)($order['amounts'] ?? []);
$subtotal = (int)($amounts['subtotal'] ?? 0);
$fee = (int)($amounts['fee'] ?? 0);
$payMoney = (int)($amounts['pay_money'] ?? 0);
// 상품명: 현재 전달받는 변수 기준 유지 (사용자 확인 완료)
$productName = (string)($productname ?? '');
if ($productName === '') $productName = '-';
$itemName = (string)($items[0]['name'] ?? '');
if ($itemName === '') $itemName = '-';
// 수량 합계
$totalQty = 0;
foreach ($items as $it) $totalQty += (int)($it['qty'] ?? 0);
// 일시 (분까지)
$createdAt = (string)($order['created_at'] ?? ($attempt['created_at'] ?? ''));
$dateStr = $createdAt ? \Carbon\Carbon::parse($createdAt)->format('Y-m-d H:i') : '-';
// 핀 목록 "추후 조건" 대비: 지금은 보여줌
$showPinsNow = true;
// 핀발행 완료 여부 (우선 pins 존재 기준)
// 추후 서버에서 bool($pinsIssuedCompleted) 내려주면 그 값 우선 사용 권장
$isPinIssuedCompleted = (bool)($pinsIssuedCompleted ?? !empty($pins));
// 오른쪽 영역 배너 모드 조건
$useRightBannerMode = $isCancelledAfterPaid || $isPinIssuedCompleted;
@endphp
@section('title', '구매내역 상세')
@section('subcontent')
<div class="mypage-usage">
<div class="topbar">
@if(session('success')) <div class="flash ok">{{ session('success') }}</div> @endif
@if(session('error')) <div class="flash err">{{ session('error') }}</div> @endif
<div class="sp"></div>
<a class="btn btn--back" href="{{ route('web.mypage.usage.index', $backToListQuery) }}"> 목록</a>
</div>
{{-- 상단: 전표 + 우측 영역(핀발행/배너) --}}
<div class="detail-hero-grid">
{{-- 좌측: 영수증형 전표 --}}
<div class="receipt-card receipt-card--paper">
<div class="receipt-head">
<div>
<div class="receipt-title">결제 영수증</div>
<div class="receipt-sub">
{{ $dateStr }} · {{ $methodKor }}
</div>
</div>
<span class="pill {{ $statusClass($st) }}">{{ $st }}</span>
</div>
<div class="receipt-divider"></div>
<div class="receipt-main">
<div class="receipt-product">
{{ $productName }}
<span class="receipt-item">[ {{ $itemName }} ]</span>
</div>
<div class="receipt-grid">
<div class="row">
<span class="k">결제번호</span>
<span class="v">#{{ $attemptId ?: '-' }}</span>
</div>
<div class="row">
<span class="k">주문번호</span>
<span class="v mono">{{ $oid ?: '-' }}</span>
</div>
<div class="row">
<span class="k">결제수단</span>
<span class="v">{{ $methodKor }}</span>
</div>
<div class="row">
<span class="k">수량</span>
<span class="v">{{ $totalQty }}</span>
</div>
</div>
</div>
<div class="receipt-divider receipt-divider--dashed"></div>
<div class="receipt-amount">
<div class="amt-row">
<span class="k">상품금액</span>
<span class="v">{{ number_format($subtotal) }}</span>
</div>
<div class="amt-row">
<span class="k">고객수수료</span>
<span class="v">{{ number_format($fee) }}</span>
</div>
<div class="amt-row total">
<span class="k">결제금액</span>
<span class="v">{{ number_format($payMoney) }}</span>
</div>
</div>
@if(!empty($order['cancel_last_msg']))
<div class="notice-box notice-box--err">
<b>취소 처리 결과</b><br>
{{ $order['cancel_last_msg'] }}
</div>
@endif
</div>
{{-- 우측: 핀발행 인터랙션 또는 안내 배너 --}}
<aside class="right-panel {{ $useRightBannerMode ? 'right-panel--banner right-panel--mobile-hide' : '' }}">
@if($useRightBannerMode)
<div class="banner-stack">
@if($isCancelledAfterPaid)
<div class="promo-vertical-banner promo-vertical-banner--cancel">
<div class="promo-vertical-banner__inner">
<div class="promo-vertical-banner__badge">PROMO</div>
<div class="promo-vertical-banner__eyebrow">PIN FOR YOU</div>
<div class="promo-vertical-banner__title">
다음 구매는<br>
빠르고 간편하게
</div>
<div class="promo-vertical-banner__desc">
자주 찾는 상품권을 빠르게 확인하고,<br>
구매 내역/상태를 번에 관리해보세요.
</div>
<div class="promo-vertical-banner__chips">
<span class="chip">빠른 재구매</span>
<span class="chip">구매내역 관리</span>
<span class="chip">안전한 결제</span>
</div>
<div class="promo-vertical-banner__footer">
<div class="promo-vertical-banner__footer-title">추천 안내</div>
<div class="promo-vertical-banner__footer-desc">
진행 중인 이벤트 혜택은 메인/상품 페이지에서 확인할 있습니다.
</div>
</div>
</div>
</div>
@endif
@if($isPinIssuedCompleted && !$isCancelledAfterPaid)
<div class="info-banner info-banner--ok">
<div class="info-banner__title"> 발행이 완료되었습니다</div>
<div class="info-banner__desc">
발행이 완료된 주문은 우측 발행 선택 영역 대신 안내 배너를 표시합니다.
목록 영역에서 발행된 정보를 확인해 주세요.
</div>
</div>
<div class="info-banner info-banner--warn">
<div class="info-banner__title">취소 제한 안내</div>
<div class="info-banner__desc">
확인/발행 이후에는 결제 취소가 제한될 있습니다.
실제 취소 가능 여부는 하단 결제 취소 영역에서 확인됩니다.
</div>
</div>
@endif
</div>
@else
<div class="issue-panel">
<div class="issue-panel__head">
<h3 class="issue-panel__title"> 발행 선택</h3>
</div>
<div class="issue-picker" id="issuePicker">
{{-- 옵션 1 --}}
<div class="issue-option" data-issue-card="view">
<button type="button" class="issue-option__toggle" data-issue-toggle>
<div class="issue-option__kicker">즉시 확인</div>
<div class="issue-option__title">핀번호 바로 확인</div>
<div class="issue-option__subtitle">안전하게 핀번호를 직접 확인합니다.</div>
<span class="issue-option__chev" aria-hidden="true"></span>
</button>
<div class="issue-option__detail">
<div class="issue-option__detail-inner">
<p class="issue-option__detail-text">
핀번호를 개인 암호화하여 발행합니다. 핀번호 유출에 주의하세요.
</p>
<button id="btnIssueView" type="button" class="issue-run issue-run--dark">
핀번호 바로 확인 실행
</button>
</div>
</div>
</div>
{{-- 옵션 2 --}}
<div class="issue-option" data-issue-card="sms">
<button type="button" class="issue-option__toggle" data-issue-toggle>
<div class="issue-option__kicker">문자 발송</div>
<div class="issue-option__title">SMS 발송</div>
<div class="issue-option__subtitle">문자로 핀번호를 전송합니다.</div>
<span class="issue-option__chev" aria-hidden="true"></span>
</button>
<div class="issue-option__detail">
<div class="issue-option__detail-inner">
<p class="issue-option__detail-text">
SMS 발송 핀번호는 저장되지 않습니다. 문자 수신 즉시 확인하세요.
</p>
<button id="btnIssueSms" type="button" class="issue-run issue-run--sky">
SMS 발송 실행
</button>
</div>
</div>
</div>
{{-- 옵션 3 --}}
<div class="issue-option" data-issue-card="sell">
<button type="button" class="issue-option__toggle" data-issue-toggle>
<div class="issue-option__kicker">재판매</div>
<div class="issue-option__title">구매상품권 판매</div>
<div class="issue-option__subtitle">구매하신 상품권을 판매 처리합니다.</div>
<span class="issue-option__chev" aria-hidden="true"></span>
</button>
<div class="issue-option__detail">
<div class="issue-option__detail-inner">
<p class="issue-option__detail-text">
구매하신 상품권을 판매합니다. 계좌번호가 등록되어 있어야 하며,
매입 처리 회원님 계좌로 입금됩니다.
</p>
<button id="btnIssueSell" type="button" class="issue-run issue-run--green">
구매상품권 판매 실행
</button>
</div>
</div>
</div>
</div>
</div>
@endif
</aside>
</div>
@if(!$isCancelledAfterPaid)
{{-- 목록 --}}
@if($showPinsNow)
<div class="usage-card">
<div class="section-head">
<h3 class="card-title"> 목록</h3>
<div class="sub muted">
발행이 완료되면 영역에서 정보를 확인할 있습니다. (현재는 UI 확인을 위해 표시 )
</div>
</div>
@if(empty($pins))
<p class="muted">표시할 핀이 없습니다.</p>
@else
<ul class="pins">
@foreach($pins as $p)
@php
$id = (int)($p['id'] ?? 0);
$status = (string)($p['status'] ?? '');
$raw = (string)($p['pin'] ?? $p['pin_code'] ?? $p['pin_no'] ?? '');
$masked = (string)($p['pin_masked'] ?? $p['pin_mask'] ?? '');
if ($masked === '' && $raw !== '') {
$digits = preg_replace('/\s+/', '', $raw);
$masked = (mb_strlen($digits) >= 8)
? (mb_substr($digits,0,4).str_repeat('*',4).mb_substr($digits,-2))
: '****';
}
// 오픈 전에는 마스킹 우선
$display = $pinsOpened ? ($raw ?: $masked) : ($masked ?: '****');
@endphp
<li class="pin-row">
<span class="pill">#{{ $id }}</span>
@if($status !== '') <span class="pill pill--muted">{{ $status }}</span> @endif
<span class="mono pin-code">{{ $display }}</span>
</li>
@endforeach
</ul>
@endif
</div>
@endif
{{-- 취소 버튼은 아래, 작게 --}}
<div class="usage-card cancel-box">
<h3 class="card-title">결제 취소</h3>
<div class="muted">
핀을 확인/발행한 이후에는 취소가 제한될 있습니다.<br>
결제 취소는 처리 시간이 소요될 있으며, 취소 결과는 페이지에 반영됩니다.
</div>
@if($canCancel)
<form method="post" action="{{ route('web.mypage.usage.cancel', ['attemptId' => $attemptId]) }}" class="cancel-form">
@csrf
<input type="hidden" name="q" value="{{ request('q', '') }}">
<input type="hidden" name="method" value="{{ request('method', '') }}">
<input type="hidden" name="status" value="{{ request('status', '') }}">
<input type="hidden" name="from" value="{{ request('from', '') }}">
<input type="hidden" name="to" value="{{ request('to', '') }}">
<input type="hidden" name="page" value="{{ request('page', '') }}">
<input class="inp" name="reason" placeholder="취소 사유(선택)">
<button id="btnCancel" class="btn btn--danger btn--sm" type="submit">
결제 취소
</button>
</form>
@else
<div class="notice-box">
현재 상태에서는 결제 취소가 불가능합니다.
</div>
@endif
</div>
@endif
</div>
<style>
.mypage-usage { display:flex; flex-direction:column; gap:14px; }
.topbar{display:flex; align-items:center; gap:10px; flex-wrap:wrap;}
.topbar .sp{flex:1;}
.btn{
display:inline-flex;align-items:center;justify-content:center;
padding:10px 12px;border-radius:12px;border:1px solid rgba(0,0,0,.14);
cursor:pointer;text-decoration:none;font-size:13px; white-space:nowrap; background:#fff;
}
.btn--sm{padding:8px 10px;border-radius:10px;font-size:12px;}
.btn--danger{border-color: rgba(220,0,0,.35); color:rgb(180,0,0); font-weight:800;}
.flash{padding:8px 10px;border-radius:10px;font-size:13px;}
.flash.ok{background:rgba(0,160,60,.08);}
.flash.err{background:rgba(220,0,0,.08);}
.mono{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;}
.muted { color:rgba(0,0,0,.55); }
.pill{
display:inline-flex; padding:4px 10px; border-radius:999px; font-size:12px;
border:1px solid rgba(0,0,0,.12); white-space:nowrap;
}
.pill--ok{background:rgba(0,160,60,.08); border-color:rgba(0,160,60,.18);}
.pill--danger{background:rgba(220,0,0,.08); border-color:rgba(220,0,0,.18); color:rgb(180,0,0);}
.pill--wait{background:rgba(255,190,0,.12); border-color:rgba(255,190,0,.25);}
.pill--muted{opacity:.75;}
.notice-box { padding:12px; border-radius:12px; background:rgba(0,0,0,.04); margin-top:10px; }
.notice-box--err { background:rgba(220,0,0,.06); }
/* ===== Hero grid (전표 + 오른쪽 영역) ===== */
.detail-hero-grid{
display:grid;
grid-template-columns:1fr;
gap:14px;
}
@media (min-width: 960px){
.detail-hero-grid{
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); /* 5:5 */
align-items: stretch; /* 좌우 높이 자연스럽게 맞춤 */
}
.detail-hero-grid > * {
min-width: 0;
}
}
.right-panel{
display:block;
height:100%;
}
.right-panel .issue-panel,
.right-panel .banner-stack,
.right-panel .promo-vertical-banner{
height:100%;
}
@media (max-width: 959px){
.right-panel--mobile-hide{display:none;}
}
/* ===== Receipt (카드 영수증 느낌) ===== */
.receipt-card{
border:1px solid rgba(0,0,0,.08);
border-radius:18px;
padding:16px;
background:#fff;
}
.receipt-card--paper{
box-shadow: 0 10px 24px rgba(0,0,0,.04);
}
.receipt-head{
display:flex; justify-content:space-between; align-items:flex-start; gap:10px;
}
.receipt-title{font-size:18px; font-weight:900; line-height:1.1;}
.receipt-sub{margin-top:4px; color:rgba(0,0,0,.55); font-size:12px;}
.receipt-divider{
margin:12px 0;
border-top:1px solid rgba(0,0,0,.08);
}
.receipt-divider--dashed{
border-top-style:dashed;
border-top-color:rgba(0,0,0,.16);
}
.receipt-main{display:flex; flex-direction:column; gap:10px;}
.receipt-product{
font-size:15px; font-weight:900; line-height:1.35;
}
.receipt-item{
font-size:13px; font-weight:700; color:rgba(0,0,0,.6);
}
.receipt-grid{
display:grid; grid-template-columns:1fr; gap:8px;
}
.receipt-grid .row{
display:flex; justify-content:space-between; gap:10px; font-size:13px;
}
.receipt-grid .k{color:rgba(0,0,0,.55);}
.receipt-grid .v{font-weight:800; text-align:right;}
.receipt-amount{
display:flex; flex-direction:column; gap:8px;
padding:12px;
border-radius:14px;
background:rgba(0,0,0,.02);
border:1px solid rgba(0,0,0,.05);
}
.amt-row{display:flex; justify-content:space-between; gap:10px; font-size:13px;}
.amt-row .k{color:rgba(0,0,0,.6);}
.amt-row .v{font-weight:800;}
.amt-row.total{
margin-top:2px;
padding-top:8px;
border-top:1px dashed rgba(0,0,0,.14);
font-size:15px; font-weight:900;
}
/* ===== Right panel: banner mode ===== */
.banner-stack{
display:flex;
flex-direction:column;
gap:10px;
}
.info-banner{
border-radius:18px;
padding:14px;
border:1px solid rgba(0,0,0,.08);
background:#fff;
box-shadow: 0 8px 20px rgba(0,0,0,.03);
}
.info-banner__title{
font-size:14px; font-weight:900;
}
.info-banner__desc{
margin-top:6px;
font-size:13px; line-height:1.45;
color:rgba(0,0,0,.62);
}
.info-banner--danger{
background:linear-gradient(180deg, rgba(220,0,0,.05), rgba(220,0,0,.02));
border-color:rgba(220,0,0,.14);
}
.info-banner--ok{
background:linear-gradient(180deg, rgba(0,160,60,.07), rgba(0,160,60,.03));
border-color:rgba(0,160,60,.16);
}
.info-banner--warn{
background:linear-gradient(180deg, rgba(255,190,0,.10), rgba(255,190,0,.04));
border-color:rgba(255,190,0,.20);
}
/* ===== Cancel 상태용 세로 광고 배너 ===== */
.promo-vertical-banner{
border-radius:20px;
position:relative;
overflow:hidden;
border:1px solid rgba(0,0,0,.08);
background:
radial-gradient(120% 80% at 0% 0%, rgba(255,120,120,.16), transparent 55%),
radial-gradient(90% 70% at 100% 100%, rgba(255,190,0,.16), transparent 55%),
linear-gradient(180deg, #fff 0%, #fbfbfd 100%);
box-shadow:
0 20px 35px rgba(0,0,0,.06),
0 8px 16px rgba(0,0,0,.03),
inset 0 1px 0 rgba(255,255,255,.9);
min-height:100%;
}
.promo-vertical-banner--cancel::before{
content:'';
position:absolute;
inset:0;
background:
linear-gradient(135deg, rgba(220,0,0,.04) 0%, transparent 38%),
linear-gradient(315deg, rgba(255,190,0,.05) 0%, transparent 42%);
pointer-events:none;
}
.promo-vertical-banner__inner{
position:relative;
z-index:1;
height:100%;
display:flex;
flex-direction:column;
padding:16px;
gap:10px;
}
.promo-vertical-banner__badge{
align-self:flex-start;
font-size:11px;
font-weight:900;
letter-spacing:.06em;
color:#7a1b1b;
background:rgba(220,0,0,.08);
border:1px solid rgba(220,0,0,.16);
border-radius:999px;
padding:4px 8px;
}
.promo-vertical-banner__eyebrow{
font-size:12px;
font-weight:800;
color:rgba(0,0,0,.45);
letter-spacing:.03em;
}
.promo-vertical-banner__title{
font-size:22px;
line-height:1.15;
font-weight:900;
letter-spacing:-0.02em;
color:#1d1d1f;
margin-top:2px;
}
.promo-vertical-banner__desc{
font-size:13px;
line-height:1.5;
color:rgba(0,0,0,.62);
}
.promo-vertical-banner__chips{
display:flex;
flex-wrap:wrap;
gap:8px;
margin-top:2px;
}
.promo-vertical-banner__chips .chip{
display:inline-flex;
align-items:center;
justify-content:center;
padding:6px 10px;
border-radius:999px;
font-size:12px;
font-weight:700;
background:rgba(255,255,255,.9);
border:1px solid rgba(0,0,0,.08);
box-shadow: 0 3px 6px rgba(0,0,0,.03);
}
.promo-vertical-banner__footer{
margin-top:auto; /* 아래 정렬 */
border-top:1px dashed rgba(0,0,0,.10);
padding-top:10px;
}
.promo-vertical-banner__footer-title{
font-size:12px;
font-weight:900;
color:rgba(0,0,0,.75);
}
.promo-vertical-banner__footer-desc{
margin-top:4px;
font-size:12px;
line-height:1.45;
color:rgba(0,0,0,.55);
}
/* ===== Right panel: issue picker (색감 + 입체감 강화) ===== */
.issue-panel{
border-radius:20px;
padding:14px;
background:
radial-gradient(1200px 320px at -10% -20%, rgba(0,130,255,.10), transparent 45%),
radial-gradient(1000px 280px at 110% 120%, rgba(0,160,60,.08), transparent 45%),
linear-gradient(180deg, rgba(255,255,255,.98), rgba(250,251,253,.98));
border:1px solid rgba(0,0,0,.08);
box-shadow:
0 20px 35px rgba(0,0,0,.06),
0 6px 14px rgba(0,0,0,.03),
inset 0 1px 0 rgba(255,255,255,.8);
}
.issue-panel__head{
display:flex; flex-direction:column; gap:6px;
margin-bottom:12px;
}
.issue-panel__title{
margin:0; font-size:17px; font-weight:900;
}
.issue-panel__desc{
font-size:13px; color:rgba(0,0,0,.62); line-height:1.4;
}
.issue-picker{
display:flex; flex-direction:column; gap:10px;
}
.issue-option{
border:1px solid rgba(0,0,0,.08);
border-radius:16px;
background:linear-gradient(180deg, rgba(255,255,255,1), rgba(248,249,251,1));
overflow:hidden;
transition:
border-color .25s ease,
box-shadow .25s ease,
transform .18s ease,
background .25s ease;
box-shadow:
0 6px 12px rgba(0,0,0,.02),
inset 0 1px 0 rgba(255,255,255,.85);
position:relative;
}
.issue-option::before{
content:'';
position:absolute;
left:0; top:0; bottom:0;
width:4px;
background:rgba(0,0,0,.08);
transition:opacity .25s ease, background .25s ease;
opacity:.7;
}
.issue-option:hover{
border-color:rgba(0,0,0,.14);
transform:translateY(-1px);
box-shadow:
0 12px 18px rgba(0,0,0,.04),
inset 0 1px 0 rgba(255,255,255,.9);
}
.issue-option.is-active{
border-color:rgba(0,0,0,.18);
box-shadow:
0 18px 24px rgba(0,0,0,.06),
0 8px 14px rgba(0,0,0,.03),
inset 0 1px 0 rgba(255,255,255,.95);
transform:translateY(-1px);
}
/* 카드별 컬러 포인트 */
.issue-option[data-issue-card="view"]::before{
background:linear-gradient(180deg, rgba(70,70,70,.85), rgba(20,20,20,.85));
}
.issue-option[data-issue-card="sms"]::before{
background:linear-gradient(180deg, rgba(0,130,255,.95), rgba(0,90,220,.85));
}
.issue-option[data-issue-card="sell"]::before{
background:linear-gradient(180deg, rgba(0,170,95,.95), rgba(0,130,70,.85));
}
.issue-option[data-issue-card="view"].is-active{
background:linear-gradient(180deg, rgba(0,0,0,.025), rgba(255,255,255,1));
}
.issue-option[data-issue-card="sms"].is-active{
background:linear-gradient(180deg, rgba(0,130,255,.06), rgba(255,255,255,1));
}
.issue-option[data-issue-card="sell"].is-active{
background:linear-gradient(180deg, rgba(0,160,60,.07), rgba(255,255,255,1));
}
.issue-option__toggle{
width:100%;
border:0;
background:transparent;
text-align:left;
cursor:pointer;
padding:12px 14px 12px 16px;
display:grid;
grid-template-columns:1fr auto;
gap:8px;
}
.issue-option__kicker{
grid-column:1 / 2;
font-size:11px; font-weight:800;
color:rgba(0,0,0,.45);
text-transform:uppercase;
letter-spacing:.03em;
}
.issue-option__title{
grid-column:1 / 2;
font-size:15px; font-weight:900; line-height:1.1;
}
.issue-option__subtitle{
grid-column:1 / 2;
font-size:12px; color:rgba(0,0,0,.58); line-height:1.35;
}
.issue-option__chev{
grid-column:2 / 3;
grid-row:1 / span 3;
align-self:center;
font-size:18px;
color:rgba(0,0,0,.45);
transition:transform .25s ease;
}
.issue-option.is-active .issue-option__chev{
transform:rotate(180deg);
}
.issue-option__detail{
max-height:0;
opacity:0;
overflow:hidden;
transition:max-height .32s ease, opacity .22s ease;
}
.issue-option.is-active .issue-option__detail{
max-height:220px;
opacity:1;
}
.issue-option__detail-inner{
padding:0 14px 14px 16px;
border-top:1px dashed rgba(0,0,0,.08);
}
.issue-option__detail-text{
margin:10px 0 12px;
font-size:13px; line-height:1.45;
color:rgba(0,0,0,.65);
}
.issue-run{
display:inline-flex; align-items:center; justify-content:center;
border:1px solid rgba(0,0,0,.12);
border-radius:12px;
background:#fff;
padding:10px 12px;
font-size:13px;
font-weight:800;
cursor:pointer;
box-shadow:
0 6px 12px rgba(0,0,0,.04),
inset 0 1px 0 rgba(255,255,255,.9);
}
.issue-run--dark{
background:linear-gradient(180deg, rgba(0,0,0,.06), rgba(0,0,0,.03));
}
.issue-run--sky{
background:linear-gradient(180deg, rgba(0,130,255,.10), rgba(0,130,255,.05));
border-color:rgba(0,130,255,.20);
}
.issue-run--green{
background:linear-gradient(180deg, rgba(0,160,60,.12), rgba(0,160,60,.06));
border-color:rgba(0,160,60,.18);
}
/* ===== Standard card / Pins / Cancel ===== */
.usage-card{border:1px solid rgba(0,0,0,.08); border-radius:16px; padding:16px; background:#fff;}
.section-head{display:flex; flex-direction:column; gap:6px; margin-bottom:10px;}
.card-title{font-size:16px; margin:0;}
.sub{font-size:13px;}
.pins{margin:0; padding-left:0; list-style:none; display:flex; flex-direction:column; gap:8px;}
.pin-row{display:flex; align-items:center; gap:8px; flex-wrap:wrap;}
.pin-code{font-weight:900; letter-spacing:0.3px;}
.cancel-box{padding:14px;}
.cancel-form{margin-top:10px; display:flex; flex-direction:column; gap:8px; align-items:flex-start;}
.inp{
padding:10px 10px; border-radius:12px; border:1px solid rgba(0,0,0,.14);
background:#fff; font-size:13px; width:100%; max-width:420px;
}
</style>
<script>
// ---- 핀 오픈(오픈 후 취소 제한 안내) ----
async function onOpenPinsOnce(e) {
e.preventDefault();
const ok = await showMsg(
"핀 확인(오픈) 후에는 취소가 불가능할 수 있습니다.\n\n진행할까요?",
{ type: 'confirm', title: '핀 확인' }
);
if (!ok) return;
const form = e.currentTarget.closest('form');
if (!form) return;
// requestSubmit이 있으면 native validation도 같이 탄다
if (form.requestSubmit) form.requestSubmit();
else form.submit();
}
// ---- 결제 취소(confirm) ----
async function onCancelOnce(e) {
e.preventDefault();
const ok = await showMsg(
"핀 확인 전에만 취소할 수 있습니다.\n\n결제를 취소할까요?",
{ type: 'confirm', title: '결제 취소' }
);
if (!ok) return;
const form = e.currentTarget.closest('form');
if (!form) return;
if (form.requestSubmit) form.requestSubmit();
else form.submit();
}
// ---- 준비중(3버튼 alert) ----
async function onIssueViewSoon() {
await showMsg(
"준비중입니다.\n\n핀번호를 개인 암호화하여 발행합니다. 핀번호 유출에 주의하세요.",
{ type: 'alert', title: '안내' }
);
}
async function onIssueSmsSoon() {
await showMsg(
"준비중입니다.\n\nSMS 발송 시 핀번호는 저장되지 않습니다. 문자 수신 후 즉시 확인하세요.",
{ type: 'alert', title: '안내' }
);
}
async function onIssueSellSoon() {
await showMsg(
"준비중입니다.\n\n구매하신 상품권을 판매합니다.\n계좌번호가 등록되어 있어야 합니다.\n매입 처리는 약간의 시간이 걸릴 수 있으며, 완료 후 회원님 계좌로 입금됩니다.",
{ type: 'alert', title: '안내' }
);
}
// ---- 바인딩 (최소 1회) ----
document.addEventListener('DOMContentLoaded', () => {
// 기존 로직 유지 (현재 버튼이 없을 수 있어도 그대로)
const btnOpen = document.getElementById('btnOpenPins');
if (btnOpen) btnOpen.addEventListener('click', onOpenPinsOnce);
const btnCancel = document.getElementById('btnCancel');
if (btnCancel) btnCancel.addEventListener('click', onCancelOnce);
const btn1 = document.getElementById('btnIssueView');
if (btn1) btn1.addEventListener('click', onIssueViewSoon);
const btn2 = document.getElementById('btnIssueSms');
if (btn2) btn2.addEventListener('click', onIssueSmsSoon);
const btn3 = document.getElementById('btnIssueSell');
if (btn3) btn3.addEventListener('click', onIssueSellSoon);
// ---- 핀 발행 선택형 배너 (한 번에 1개만 확장) ----
const issueCards = Array.from(document.querySelectorAll('[data-issue-card]'));
issueCards.forEach((card) => {
const toggle = card.querySelector('[data-issue-toggle]');
if (!toggle) return;
toggle.addEventListener('click', () => {
const isActive = card.classList.contains('is-active');
// 모두 닫기
issueCards.forEach(c => c.classList.remove('is-active'));
// 방금 클릭한 카드가 닫힌 상태였으면 열기
if (!isActive) {
card.classList.add('is-active');
}
});
});
});
</script>
@endsection

View File

@ -1,10 +1,10 @@
@php @php
// CS 헤더 // CS 헤더
$nav = config('web.cs_nav', []); $nav = config('web.cs_nav', []);
$navTitle = $nav['title'] ?? '고객센터'; $navTitle = $nav['title'] ?? '고객센터';
$navSubtitle = $nav['subtitle'] ?? null; $navSubtitle = $nav['subtitle'] ?? null;
// CS items // CS items
$rawTabs = config('web.cs_tabs', []); $rawTabs = config('web.cs_tabs', []);
$items = collect($rawTabs)->map(function ($t) { $items = collect($rawTabs)->map(function ($t) {

View File

@ -1,9 +1,9 @@
{{-- resources/views/web/partials/dev_session_overlay.blade.php --}} {{-- resources/views/web/partials/dev_session_overlay.blade.php --}}
@php @php
// 개발 모드에서만 노출 // 개발 모드에서만 노출
$show = config('app.debug') || app()->environment('local'); $show = config('app.debug') || app()->environment('local');
// 이 overlay 자체가 세션을 수정하는 "dev action" 처리(컨트롤러/라우트 없이) // 이 overlay 자체가 세션을 수정하는 "dev action" 처리(컨트롤러/라우트 없이)
if ($show && request()->isMethod('post') && request()->has('_dev_sess_action')) { if ($show && request()->isMethod('post') && request()->has('_dev_sess_action')) {
// CSRF는 web 미들웨어에 걸려있으니 토큰 포함된 요청만 처리됨. // CSRF는 web 미들웨어에 걸려있으니 토큰 포함된 요청만 처리됨.
$action = request()->input('_dev_sess_action'); $action = request()->input('_dev_sess_action');
@ -28,7 +28,7 @@
} }
} }
// POST 재전송 방지 + 현재 페이지로 되돌리기 // POST 재전송 방지 + 현재 페이지로 되돌리기
$redir = url()->current(); $redir = url()->current();
$qs = request()->query(); $qs = request()->query();
if (!empty($qs)) $redir .= '?' . http_build_query($qs); if (!empty($qs)) $redir .= '?' . http_build_query($qs);
@ -36,10 +36,10 @@
exit; exit;
} }
// 세션 전체 // 세션 전체
$sess = session()->all(); $sess = session()->all();
// 민감값 마스킹 // 민감값 마스킹
$maskKeys = []; $maskKeys = [];
// $maskKeys = [ // $maskKeys = [
// 'password', 'passwd', 'pw', 'token', 'access_token', 'refresh_token', // 'password', 'passwd', 'pw', 'token', 'access_token', 'refresh_token',
@ -56,7 +56,7 @@
return $val; return $val;
}; };
// key:value 라인 생성(재귀) // key:value 라인 생성(재귀)
$lines = []; $lines = [];
$dump = function ($data, $prefix = '') use (&$dump, &$lines, $mask) { $dump = function ($data, $prefix = '') use (&$dump, &$lines, $mask) {
foreach ((array)$data as $k => $v) { foreach ((array)$data as $k => $v) {
@ -112,7 +112,7 @@
{{ request()->method() }} {{ request()->path() }} {{ request()->method() }} {{ request()->path() }}
</div> </div>
{{-- Controls --}} {{-- Controls --}}
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px;"> <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;"> <form method="POST" action="{{ route('dev.session') }}" style="display:flex; gap:6px; align-items:center; margin:0;">
@csrf @csrf

Some files were not shown because too many files have changed in this diff Show More