이용내역 리스트 / 뷰
This commit is contained in:
parent
fb0cec13ef
commit
9825350372
@ -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()
|
||||||
|
|||||||
@ -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' => '로그인 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'])
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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] ?? ''));
|
||||||
|
|
||||||
|
|||||||
@ -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', [
|
||||||
|
|||||||
@ -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'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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', '공지사항이 삭제되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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']; // 통계 데이터
|
||||||
|
|||||||
@ -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');
|
||||||
|
|
||||||
|
|||||||
@ -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']);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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([
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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', '결제가 취소되었습니다.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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"),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
|
|
||||||
|
|||||||
@ -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 = [];
|
||||||
|
|||||||
@ -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] ?? '';
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -138,7 +138,7 @@ final class AdminMemberMarketingService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------
|
// -------------------------
|
||||||
// ✅ 다운로드 헤더(한글)
|
// 다운로드 헤더(한글)
|
||||||
// -------------------------
|
// -------------------------
|
||||||
private function headersKorean(): array
|
private function headersKorean(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
245
app/Services/Payments/PaymentCancelService.php
Normal file
245
app/Services/Payments/PaymentCancelService.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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' => '한국은행',
|
||||||
|
|||||||
@ -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로 유니크하게
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) */
|
||||||
|
|||||||
@ -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{
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 등) 실행 안 됨
|
||||||
// -----------------------
|
// -----------------------
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 = '';
|
||||||
|
|||||||
@ -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 !== '')
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -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명 (줄바꿈 기준) ✅ 1열: 이메일, 2열~: 토큰(콤마로 구분) 예) sungro81@gmail.com, 이상도, 10000, 쿠폰"></textarea>
|
placeholder=" 1줄 = 1명 (줄바꿈 기준) 1열: 이메일, 2열~: 토큰(콤마로 구분) 예) 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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 || '');
|
||||||
|
|||||||
@ -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');
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)] ) }}">
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 || '';
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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') }}", {
|
||||||
|
|||||||
@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 !!}
|
||||||
|
|||||||
@ -112,7 +112,7 @@
|
|||||||
placeholder="문제 상황을 자세히 적어주세요. 예) 주문시각/결제수단/금액/오류메시지/상품명"
|
placeholder="문제 상황을 자세히 적어주세요. 예) 주문시각/결제수단/금액/오류메시지/상품명"
|
||||||
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>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -59,7 +59,7 @@
|
|||||||
@include('web.company.header')
|
@include('web.company.header')
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
{{-- ✅ 페이지에서 @section('content')가 여기로 들어옴 --}}
|
{{-- 페이지에서 @section('content')가 여기로 들어옴 --}}
|
||||||
@yield('content')
|
@yield('content')
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
936
resources/views/web/mypage/usage/show.blade.php
Normal file
936
resources/views/web/mypage/usage/show.blade.php
Normal 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
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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
Loading…
x
Reference in New Issue
Block a user