회원가입
++ 안정적인 서비스 제공과 정보 보호를 위해 회원가입 시 최초 1회 본인인증이 진행될 수 있어요. +
+ +-
+
- 본인인증(PASS)을 진행합니다. (PASS로 승인) +
- 이메일을 아이디로 사용합니다. +
- + 비밀번호는 영문(대/소) + 숫자 + 특수문자 조합으로 + 8~16자 범위로 설정합니다. + +
- 2차 비밀번호는 숫자 4자리로 설정합니다. +
- 만 14세 이상만 가입이 가능합니다. +
diff --git a/app/Http/Controllers/Web/Auth/FindIdController.php b/app/Http/Controllers/Web/Auth/FindIdController.php new file mode 100644 index 0000000..172a4b8 --- /dev/null +++ b/app/Http/Controllers/Web/Auth/FindIdController.php @@ -0,0 +1,276 @@ +session()->get('find_id', []); + $step = 1; + if (!empty($sess['result_email_masked'])) $step = 3; + else if (!empty($sess['sent'])) $step = 2; + + return view('web.auth.find_id', [ + 'initialStep' => $step, + 'maskedEmail' => $sess['result_email_masked'] ?? null, + 'phone' => $sess['phone'] ?? null, + ]); + } + + public function sendCode(Request $request) + { + logger()->info('HIT sendCode', ['path' => request()->path(), 'host' => request()->getHost()]); + + $v = Validator::make($request->all(), [ + 'phone' => ['required', 'string', 'max:20'], + ], [ + 'phone.required' => '휴대폰 번호를 입력해 주세요.', + ]); + + if ($v->fails()) { + return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); + } + + $phoneRaw = $request->input('phone'); + $phone = $this->normalizeKoreanPhone($phoneRaw); + if (!$phone) { + return response()->json(['ok' => false, 'message' => '휴대폰 번호 형식이 올바르지 않습니다.'], 422); + } + + // ✅ 0) DB에 가입된 휴대폰인지 먼저 확인 + /** @var CiSeedCrypto $seed */ + $seed = app(CiSeedCrypto::class); + + // DB에 저장된 방식(동일)으로 암호화해서 비교 + $phoneEnc = $seed->encrypt($phone); + + $exists = MemInfo::query() + ->whereNotNull('email') + ->where('email', '<>', '') + ->where('cell_phone', $phoneEnc) + ->exists(); + + if (!$exists) { + // ✅ 세션도 만들지 말고, 프론트가 Step1로 돌아가도록 힌트 제공 + return response()->json([ + 'ok' => false, + 'code' => 'PHONE_NOT_FOUND', + 'message' => '해당 휴대폰 번호로 가입된 계정을 찾을 수 없습니다. 번호를 다시 확인해 주세요.', + 'step' => 1, + ], 404); + } + + // 레이트리밋(휴대폰 기준) - 과도한 발송 방지 + $key = 'findid:send:' . $phone; + if (RateLimiter::tooManyAttempts($key, 5)) { // 10분에 5회 예시 + $sec = RateLimiter::availableIn($key); + return response()->json(['ok' => false, 'message' => "요청이 너무 많습니다. {$sec}초 후 다시 시도해 주세요."], 429); + } + RateLimiter::hit($key, 600); + + // 6자리 OTP 생성 + $code = (string) random_int(100000, 999999); + + // 세션 저장(보안: 실제로는 해시 저장 권장, 여기선 간단 구현) + $request->session()->put('find_id', [ + 'sent' => true, + 'phone' => $phone, + 'code' => password_hash($code, PASSWORD_DEFAULT), + 'code_expires_at' => now()->addMinutes(3)->timestamp, + 'verified' => false, + 'result_email_masked' => null, + ]); + + // SMS 발송 + try { +// $smsPayload = [ +// 'from_number' => config('services.sms.from', '1833-4856'), // 기본 발신번호 +// 'to_number' => $phone, +// 'message' => "[PIN FOR YOU] 인증번호 {$code} 를 입력해 주세요. (3분 이내)", +// 'sms_type' => 'sms', // 짧으니 sms 고정(원하면 자동 판단으로 빼도 됨) +// // 'country' => '82', // 필요 시 강제 +// ]; +// +// $ok = app(SmsService::class)->send($smsPayload, 'lguplus'); +// +// if (!$ok) { +// // 실패 시 세션 정리(인증 진행 꼬임 방지) +// $request->session()->forget('find_id'); +// return response()->json([ +// 'ok' => false, +// 'message' => '문자 발송에 실패했습니다. 잠시 후 다시 시도해 주세요.', +// ], 500); +// } + } catch (\Throwable $e) { + $request->session()->forget('find_id'); + + // 운영에서만 로그 남기기(개발 중엔 디버깅 가능) + Log::error('FindId SMS send failed', [ + 'phone' => $phone, + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'ok' => false, + 'message' => '문자 발송 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', + ], 500); + } + $isLocal = app()->environment(['local', 'development', 'testing']); + + return response()->json([ + 'ok' => true, + 'message' => '인증번호를 발송했습니다.', + 'expires_in' => 180, + 'step' => 2, + 'dev_code' => $isLocal ? $code : null, + ]); + } + + public function verify(Request $request) + { + $v = Validator::make($request->all(), [ + 'code' => ['required', 'digits:6'], + ], [ + 'code.required' => '인증번호를 입력해 주세요.', + 'code.digits' => '인증번호 6자리를 입력해 주세요.', + ]); + + if ($v->fails()) { + return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); + } + + $sess = $request->session()->get('find_id'); + if (!$sess || empty($sess['sent']) || empty($sess['phone'])) { + return response()->json(['ok' => false, 'message' => '먼저 인증번호를 요청해 주세요.'], 400); + } + + if (empty($sess['code_expires_at']) || now()->timestamp > (int)$sess['code_expires_at']) { + return response()->json(['ok' => false, 'message' => '인증번호가 만료되었습니다. 다시 요청해 주세요.'], 400); + } + + // 검증 시도 레이트리밋 + $key = 'findid:verify:' . $sess['phone']; + if (RateLimiter::tooManyAttempts($key, 10)) { // 10분 10회 예시 + $sec = RateLimiter::availableIn($key); + return response()->json(['ok' => false, 'message' => "시도 횟수가 많습니다. {$sec}초 후 다시 시도해 주세요."], 429); + } + RateLimiter::hit($key, 600); + + $code = $request->input('code'); + $ok = password_verify($code, $sess['code'] ?? ''); + + if (!$ok) { + return response()->json(['ok' => false, 'message' => '인증번호가 일치하지 않습니다.'], 422); + } + + // 인증 성공: 휴대폰 번호로 가입된 "아이디(이메일)"들 조회 (여러개면 전부) + $phone = (string) ($sess['phone'] ?? ''); + $phone = trim($phone); + + if ($phone === '') { + return response()->json(['ok' => false, 'message' => '휴대폰 번호가 비어 있습니다.'], 422); + } + + /** @var CiSeedCrypto $crypto */ + $crypto = app(CiSeedCrypto::class); + + // 키를 넘기지 말고, CiSeedCrypto가 생성자 주입된 userKey로 처리하게 통일 + $phoneEnc = $crypto->encrypt($phone); + + $emails = MemInfo::query() + ->whereNotNull('email') + ->where('email', '<>', '') + ->where('cell_phone', $phoneEnc) //DB에 저장된 암호문과 동일하게 매칭됨 + ->orderByDesc('mem_no') + ->pluck('email') + ->unique() + ->values() + ->all(); + + if (empty($emails)) { + // 운영에서는 암호문 노출 절대 금지 (지금은 디버그용이면 로그로만) + // Log::debug('find-id phoneEnc', ['phoneEnc' => $phoneEnc, 'phone' => $phone]); + return response()->json(['ok' => false, 'message' => '해당 번호로 가입된 계정을 찾을 수 없습니다.'], 404); + } + + // 마스킹해서 여러개 내려주기 + $maskedEmails = array_map(fn($e) => $this->maskEmail($e), $emails); + + $request->session()->forget('find_id'); + $request->session()->save(); + + return response()->json([ + 'ok' => true, + 'message' => '인증이 완료되었습니다.', + 'count' => count($maskedEmails), + 'masked_emails' => $maskedEmails, + //'emails' => $emails, + ]); + } + + + public function reset(Request $request) + { + $request->session()->forget('find_id'); + return response()->json(['ok' => true]); + } + + private function normalizeKoreanPhone(string $input): ?string + { + $digits = preg_replace('/\D+/', '', $input); + if (!$digits) return null; + + // 010XXXXXXXX (11), 01XXXXXXXXX (10) 정도만 허용 예시 + if (Str::startsWith($digits, '010') && strlen($digits) === 11) return $digits; + if (preg_match('/^01[0-9]{8,9}$/', $digits)) return $digits; // 필요시 범위 조정 + return null; + } + + private function maskEmail(string $email): string + { + $email = trim($email); + + if (!str_contains($email, '@')) { + return $email; + } + + [$local, $domain] = explode('@', $email, 2); + + $localLen = mb_strlen($local, 'UTF-8'); + + // 너무 짧은 로컬파트는 규칙 완화 + if ($localLen <= 2) { + // ab@ -> a*@ 정도 + $head = mb_substr($local, 0, 1, 'UTF-8'); + return $head . '*' . '@' . $domain; + } + + if ($localLen <= 5) { + // abcde -> ab*** (끝 1자리만 힌트) + $head = mb_substr($local, 0, 2, 'UTF-8'); + $tail = mb_substr($local, -1, 1, 'UTF-8'); + return $head . str_repeat('*', max(1, $localLen - 3)) . $tail . '@' . $domain; + } + + // ✅ 기본 규칙: 앞 3글자 + ***** + 뒤 2글자 + $head = mb_substr($local, 0, 3, 'UTF-8'); + $tail = mb_substr($local, -2, 2, 'UTF-8'); + + // 별 개수: 최소 5개, 너무 길면 로컬 길이에 맞게 조정 + $stars = max(5, $localLen - 5); // head3 + tail2 = 5 + return $head . str_repeat('*', $stars) . $tail . '@' . $domain; + } + +} diff --git a/app/Http/Controllers/Web/Auth/FindPasswordController.php b/app/Http/Controllers/Web/Auth/FindPasswordController.php new file mode 100644 index 0000000..f2be313 --- /dev/null +++ b/app/Http/Controllers/Web/Auth/FindPasswordController.php @@ -0,0 +1,261 @@ +session()->get('find_pw', []); + + $step = 1; + if (!empty($sess['verified'])) $step = 3; + else if (!empty($sess['sent'])) $step = 2; + + return view('web.auth.find_password', [ + 'initialStep' => $step, + 'email' => $sess['email'] ?? null, + ]); + } + + public function sendCode(Request $request) + { + $v = Validator::make($request->all(), [ + 'email' => ['required', 'email', 'max:120'], + ], [ + 'email.required' => '이메일을 입력해 주세요.', + 'email.email' => '이메일 형식이 올바르지 않습니다.', + ]); + + if ($v->fails()) { + return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); + } + + $email = mb_strtolower(trim((string) $request->input('email'))); + + // 0) 가입된 이메일인지 확인 (MemInfo 기준) + $exists = MemInfo::query() + ->whereNotNull('email') + ->where('email', '<>', '') + ->whereRaw('LOWER(email) = ?', [$email]) + ->exists(); + + if (!$exists) { + return response()->json([ + 'ok' => false, + 'code' => 'EMAIL_NOT_FOUND', + 'message' => '해당 이메일로 가입된 계정을 찾을 수 없습니다. 이메일을 다시 확인해 주세요.', + 'step' => 1, + ], 404); + } + + try { + app(MailService::class)->sendTemplate( + $email, + '[PIN FOR YOU] 비밀번호 재설정 인증번호', + 'mail.legacy.noti_email_auth_1', // CI 템플릿명에 맞춰 선택 + [ + 'code' => $code, + 'expires_min' => 3, + 'email' => $email, + ], + queue: true + ); + } catch (\Throwable $e) { + $request->session()->forget('find_pw'); + Log::error('FindPassword sendCode failed', ['email' => $email, 'error' => $e->getMessage()]); + return response()->json(['ok'=>false,'message'=>'인증번호 발송 중 오류가 발생했습니다.'], 500); + } + + // 1) 레이트리밋(이메일 기준) + $key = 'findpw:send:' . $email; + if (RateLimiter::tooManyAttempts($key, 5)) { // 10분 5회 예시 + $sec = RateLimiter::availableIn($key); + return response()->json(['ok' => false, 'message' => "요청이 너무 많습니다. {$sec}초 후 다시 시도해 주세요."], 429); + } + RateLimiter::hit($key, 600); + + // 2) OTP 생성 + $code = (string) random_int(100000, 999999); + + // 3) 세션 저장 (코드는 해시로) + $request->session()->put('find_pw', [ + 'sent' => true, + 'email' => $email, + 'code' => password_hash($code, PASSWORD_DEFAULT), + 'code_expires_at' => now()->addMinutes(3)->timestamp, + 'verified' => false, + 'verified_at' => null, + ]); + + // 4) 실제 발송 연결 (메일/SMS 등) + try { + // TODO: 프로젝트에 맞게 구현 + // 예: Mail::to($email)->send(new PasswordOtpMail($code)); + } catch (\Throwable $e) { + $request->session()->forget('find_pw'); + Log::error('FindPassword sendCode failed', [ + 'email' => $email, + 'error' => $e->getMessage(), + ]); + return response()->json([ + 'ok' => false, + 'message' => '인증번호 발송 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', + ], 500); + } + + $isLocal = app()->environment(['local', 'development', 'testing']); + + return response()->json([ + 'ok' => true, + 'message' => '인증번호를 발송했습니다.', + 'expires_in' => 180, + 'step' => 2, + 'dev_code' => $isLocal ? $code : null, + ]); + } + + public function verify(Request $request) + { + $v = Validator::make($request->all(), [ + 'code' => ['required', 'digits:6'], + ], [ + 'code.required' => '인증번호를 입력해 주세요.', + 'code.digits' => '인증번호 6자리를 입력해 주세요.', + ]); + + if ($v->fails()) { + return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); + } + + $sess = $request->session()->get('find_pw'); + if (!$sess || empty($sess['sent']) || empty($sess['email'])) { + return response()->json(['ok' => false, 'message' => '먼저 인증번호를 요청해 주세요.'], 400); + } + + if (empty($sess['code_expires_at']) || now()->timestamp > (int)$sess['code_expires_at']) { + return response()->json(['ok' => false, 'message' => '인증번호가 만료되었습니다. 다시 요청해 주세요.'], 400); + } + + // 검증 시도 레이트리밋 + $key = 'findpw:verify:' . $sess['email']; + if (RateLimiter::tooManyAttempts($key, 10)) { // 10분 10회 예시 + $sec = RateLimiter::availableIn($key); + return response()->json(['ok' => false, 'message' => "시도 횟수가 많습니다. {$sec}초 후 다시 시도해 주세요."], 429); + } + RateLimiter::hit($key, 600); + + $code = (string) $request->input('code'); + $ok = password_verify($code, $sess['code'] ?? ''); + + if (!$ok) { + return response()->json(['ok' => false, 'message' => '인증번호가 일치하지 않습니다.'], 422); + } + + // 인증 성공 → step3 허용 + $sess['verified'] = true; + $sess['verified_at'] = now()->timestamp; + $request->session()->put('find_pw', $sess); + + return response()->json([ + 'ok' => true, + 'message' => '인증이 완료되었습니다. 새 비밀번호를 설정해 주세요.', + 'step' => 3, + ]); + } + + public function reset(Request $request) + { + $v = Validator::make($request->all(), [ + 'new_password' => ['required', 'string', 'min:8', 'max:72', 'confirmed'], + ], [ + 'new_password.required' => '새 비밀번호를 입력해 주세요.', + 'new_password.min' => '비밀번호는 8자 이상으로 입력해 주세요.', + 'new_password.confirmed' => '비밀번호 확인이 일치하지 않습니다.', + ]); + + if ($v->fails()) { + return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422); + } + + $sess = $request->session()->get('find_pw'); + if (!$sess || empty($sess['email']) || empty($sess['verified'])) { + return response()->json(['ok' => false, 'message' => '인증이 필요합니다.'], 403); + } + + $email = (string) $sess['email']; + + // (선택) 인증 후 너무 오래 지나면 재인증 요구 + $verifiedAt = (int)($sess['verified_at'] ?? 0); + if ($verifiedAt > 0 && now()->timestamp - $verifiedAt > 10 * 60) { // 10분 예시 + $request->session()->forget('find_pw'); + return response()->json(['ok' => false, 'message' => '인증이 만료되었습니다. 다시 진행해 주세요.'], 403); + } + + $newPassword = (string) $request->input('new_password'); + + // 실제 비밀번호 저장 컬럼은 프로젝트마다 다를 수 있어 안전하게 처리 + $member = MemInfo::query() + ->whereNotNull('email') + ->where('email', '<>', '') + ->whereRaw('LOWER(email) = ?', [mb_strtolower($email)]) + ->orderByDesc('mem_no') + ->first(); + + if (!$member) { + $request->session()->forget('find_pw'); + return response()->json(['ok' => false, 'message' => '계정을 찾을 수 없습니다. 다시 진행해 주세요.'], 404); + } + + // ✅ 여기서부터가 “진짜 저장 로직” + // MemInfo의 실제 컬럼명에 맞게 1개만 쓰면 됩니다. + // - password 컬럼을 쓰면 아래처럼 + // - 레거시 passwd 컬럼이면 passwd로 교체 + try { + if (isset($member->password)) { + $member->password = Hash::make($newPassword); + } elseif (isset($member->passwd)) { + $member->passwd = Hash::make($newPassword); // 레거시 규격이면 여기를 교체 + } else { + // 컬럼을 모르면 여기서 명시적으로 막는게 안전 + return response()->json([ + 'ok' => false, + 'message' => '비밀번호 저장 컬럼 설정이 필요합니다. (MemInfo password/passwd 확인)', + ], 500); + } + + $member->save(); + } catch (\Throwable $e) { + Log::error('FindPassword reset failed', [ + 'email' => $email, + 'error' => $e->getMessage(), + ]); + return response()->json(['ok' => false, 'message' => '비밀번호 변경 중 오류가 발생했습니다.'], 500); + } + + $request->session()->forget('find_pw'); + $request->session()->save(); + + return response()->json([ + 'ok' => true, + 'message' => '비밀번호가 변경되었습니다. 로그인해 주세요.', + 'redirect_url' => route('web.auth.login'), + ]); + } + + public function resetSession(Request $request) + { + $request->session()->forget('find_pw'); + return response()->json(['ok' => true]); + } +} diff --git a/app/Mail/TemplateMail.php b/app/Mail/TemplateMail.php new file mode 100644 index 0000000..7d08c57 --- /dev/null +++ b/app/Mail/TemplateMail.php @@ -0,0 +1,25 @@ +subject($this->subjectText) + ->view($this->viewName, $this->payload); + } +} diff --git a/app/Models/MemInfo.php b/app/Models/MemInfo.php new file mode 100644 index 0000000..ea74894 --- /dev/null +++ b/app/Models/MemInfo.php @@ -0,0 +1,109 @@ + 'date', + 'dt_login' => 'datetime', + 'dt_reg' => 'datetime', + 'dt_mod' => 'datetime', + 'dt_vact' => 'datetime', + 'dt_dor' => 'datetime', + 'dt_ret_dor' => 'datetime', + 'dt_out' => 'datetime', + 'dt_rcv_email' => 'datetime', + 'dt_rcv_sms' => 'datetime', + 'dt_rcv_push' => 'datetime', + 'dt_stat_1' => 'datetime', + 'dt_stat_2' => 'datetime', + 'dt_stat_3' => 'datetime', + 'dt_stat_4' => 'datetime', + 'dt_stat_5' => 'datetime', + + // JSON 컬럼 (DB CHECK(json_valid()) 걸려있으니 array cast 쓰면 편함) + 'admin_memo' => 'array', + 'modify_log' => 'array', + ]; + + /* + * ========== Scopes ========== + */ + + public function scopeActive(Builder $q): Builder + { + // CI에서 stat_3 == 3 접근금지 / 4 탈퇴신청 / 5 탈퇴완료 + return $q->whereNotIn('stat_3', ['3','4','5']); + } + + public function scopeByEmail(Builder $q, string $email): Builder + { + return $q->where('email', strtolower($email)); + } + + /** + * ⚠️ cell_phone이 "암호화 저장"이라면 + * 이 scope는 "정규화 컬럼(cell_phone_hash / cell_phone_norm 등)" 생긴 뒤에 완성하는 게 맞음. + * 지금은 자리만 만들어 둠. + */ + public function scopeByPhoneLookup(Builder $q, string $phoneNormalized): Builder + { + // TODO: cell_phone이 암호화라면 단순 where 비교 불가 + // 예시(추천): cell_phone_hash 컬럼을 만들고 SHA256 같은 값으로 매칭 + // return $q->where('cell_phone_hash', hash('sha256', $phoneNormalized . config('app.key'))); + return $q; + } + + /* + * ========== Helpers ========== + */ + + public function isBlocked(): bool + { + return $this->stat_3 === '3'; + } + + public function isWithdrawnOrRequested(): bool + { + return in_array($this->stat_3, ['4','5'], true); + } + + public function isFirstLogin(): bool + { + if (!$this->dt_login || !$this->dt_reg) return false; + return Carbon::parse($this->dt_login)->equalTo(Carbon::parse($this->dt_reg)); + } +} diff --git a/app/Models/Sms/MmsMsg.php b/app/Models/Sms/MmsMsg.php new file mode 100644 index 0000000..c804dc5 --- /dev/null +++ b/app/Models/Sms/MmsMsg.php @@ -0,0 +1,14 @@ +app->singleton(CiSeedCrypto::class, function () { + return new CiSeedCrypto( + config('legacy.seed_user_key_default'), + config('legacy.iv'), + ); + }); } - /** - * Bootstrap any application services. - */ public function boot(): void { // diff --git a/app/Services/MailService.php b/app/Services/MailService.php new file mode 100644 index 0000000..0c430a7 --- /dev/null +++ b/app/Services/MailService.php @@ -0,0 +1,24 @@ +to($toEmail)->subject($subject); + }); + } + } +} diff --git a/app/Services/MemInfoService.php b/app/Services/MemInfoService.php new file mode 100644 index 0000000..1e77e3a --- /dev/null +++ b/app/Services/MemInfoService.php @@ -0,0 +1,147 @@ +whereKey($memNo)->lockForUpdate()->firstOrFail(); + + $now = Carbon::now()->format('Y-m-d H:i:s'); + + $mem->rcv_email = $rcvEmail; + $mem->rcv_sms = $rcvSms; + $mem->dt_rcv_email = $now; + $mem->dt_rcv_sms = $now; + + if ($rcvPush !== null) { + $mem->rcv_push = $rcvPush; + $mem->dt_rcv_push = $now; + } + + $mem->dt_mod = $now; + $mem->save(); + }); + } + + /** + * CI: mem_email_vali() + */ + public function emailInfo(string $email): ?MemInfo + { + return MemInfo::query() + ->select(['mem_no','stat_3','dt_req_out','email']) + ->where('email', strtolower($email)) + ->first(); + } + + /** + * CI: mem_reg() (간소화 버전) + * - 실제로는 validation은 FormRequest에서 처리 권장 + * - stat_3=3 (1969 이전 출생 접근금지) 룰 포함 + */ + public function register(array $data): MemInfo + { + return DB::transaction(function () use ($data) { + + $email = strtolower($data['email']); + + // 중복 체크 + 잠금 (CI for update) + $exists = MemInfo::query() + ->where('email', $email) + ->lockForUpdate() + ->exists(); + + if ($exists) { + throw new \RuntimeException('이미 가입된 아이디 입니다. 다른 아이디로 진행해 주세요.'); + } + + $now = Carbon::now()->format('Y-m-d H:i:s'); + + $mem = new MemInfo(); + $mem->email = $email; + $mem->name = $data['name'] ?? ''; + $mem->pv_sns = $data['pv_sns'] ?? 'self'; + + $promotion = !empty($data['promotion']) ? 'y' : 'n'; + $mem->rcv_email = $promotion; + $mem->rcv_sms = $promotion; + $mem->rcv_push = $promotion; + + $mem->dt_reg = $now; + $mem->dt_login = $now; + + $mem->dt_rcv_email = $now; + $mem->dt_rcv_sms = $now; + $mem->dt_rcv_push = $now; + + $mem->dt_stat_1 = $now; + $mem->dt_stat_2 = $now; + $mem->dt_stat_3 = $now; + $mem->dt_stat_4 = $now; + $mem->dt_stat_5 = $now; + + $mem->ip_reg = $data['ip_reg'] ?? request()->ip(); + + // 국가/본인인증 값들 + $mem->country_code = $data['country_code'] ?? ''; + $mem->country_name = $data['country_name'] ?? ''; + $mem->birth = $data['birth'] ?? '0000-00-00'; + $mem->cell_corp = $data['cell_corp'] ?? 'n'; + $mem->cell_phone = $data['cell_phone'] ?? ''; // ⚠️ 암호화 저장이라면 여기서 암호화해서 넣어야 함 + $mem->native = $data['native'] ?? 'n'; + $mem->ci = $data['ci'] ?? null; + $mem->ci_v = $data['ci_v'] ?? ''; + $mem->di = $data['di'] ?? null; + $mem->gender = $data['gender'] ?? 'n'; + + // 1969년 이전 출생 접근금지(stat_3=3) + $birthY = (int)substr((string)$mem->birth, 0, 4); + if ($birthY > 0 && $birthY <= 1969) { + $mem->stat_3 = '3'; + } + + $mem->save(); + + return $mem; + }); + } + + /** + * CI: last_login() + */ + public function updateLastLogin(int $memNo): void + { + MemInfo::query() + ->whereKey($memNo) + ->update([ + 'dt_login' => Carbon::now()->format('Y-m-d H:i:s'), + 'login_fail_cnt' => 0, + 'dt_mod' => Carbon::now()->format('Y-m-d H:i:s'), + ]); + } + + /** + * CI: fail_count() + */ + public function incrementLoginFail(int $memNo): void + { + MemInfo::query() + ->whereKey($memNo) + ->update([ + 'login_fail_cnt' => DB::raw('login_fail_cnt + 1'), + 'dt_mod' => Carbon::now()->format('Y-m-d H:i:s'), + ]); + } +} diff --git a/app/Services/SmsService.php b/app/Services/SmsService.php new file mode 100644 index 0000000..aee463e --- /dev/null +++ b/app/Services/SmsService.php @@ -0,0 +1,156 @@ +companyName = $companyName; + } + + // 1) 필수값 체크 (CI의 Param_exception 대체) + if (empty($payload['from_number'])) return false; + if (empty($payload['to_number'])) return false; + if (empty($payload['message'])) return false; + + // 2) country 결정 (CI sess['_mcountry_code'] 로직 대응) + $country = $payload['country'] ?? null; + + if ($country === null) { + $sessCountry = Session::get('_mcountry_code'); // 기존 키 그대로 사용 + if (!empty($sessCountry)) { + $country = ($sessCountry === '82' || $sessCountry === '') ? '82' : $sessCountry; + } + } + $payload['country'] = $country ?: '82'; + + // 3) 수신번호 체크/정리 + if (!$this->phoneNumberCheck($payload)) { + return false; + } + + // 4) 업체별 발송 + return match ($this->companyName) { + 'lguplus' => $this->lguplusSend($payload), + 'sms2' => $this->sms2Send($payload), + default => false, + }; + } catch (\Throwable $e) { + // 운영에서는 로깅 권장 + // logger()->error('SmsService send failed', ['e' => $e]); + return false; + } + } + + /** + * CI: phonenumber_check 로직 이식 + * - country=82면 숫자만 남기고 01xxxxxxxxx 형식만 허용 + */ + private function phoneNumberCheck(array &$payload): bool + { + if (($payload['country'] ?? '82') === '82') { + $num = preg_replace("/[^0-9]/", "", $payload['to_number'] ?? ''); + if (preg_match("/^01[0-9]{8,9}$/", $num)) { + $payload['to_number'] = $num; + return true; + } + return false; + } + + // 해외는 CI 원본처럼 그냥 통과(필요하면 국가별 검증 로직 추가) + return true; + } + + /** + * CI: lguplus_send 이식 + * - 메시지 길이(EUC-KR) 기준 90 이하 sms, 초과 mms + * - sms_type이 명시되면 그걸 우선 + */ + private function lguplusSend(array $data): bool + { + $conn = DB::connection('sms_server'); + + // sms/mms 결정 + $smsSendType = $this->resolveSendType($data); + + return $conn->transaction(function () use ($smsSendType, $data) { + if ($smsSendType === 'sms') { + // CI의 SC_TRAN insert + $insert = [ + 'TR_SENDDATE' => now()->format('Y-m-d H:i:s'), + 'TR_SENDSTAT' => '0', + 'TR_MSGTYPE' => '0', + 'TR_PHONE' => $data['to_number'], + 'TR_CALLBACK' => $data['from_number'], + 'TR_MSG' => $data['message'], + ]; + + // Eloquent 사용 + return (bool) ScTran::create($insert); + // 또는 Query Builder: + // return $conn->table('SC_TRAN')->insert($insert); + + } else { + // CI의 MMS_MSG insert + $subject = $data['subject'] ?? mb_substr($data['message'], 0, 22, 'UTF-8'); + + $insert = [ + 'SUBJECT' => $subject, + 'PHONE' => $data['to_number'], + 'CALLBACK' => $data['from_number'], + 'STATUS' => '0', + 'REQDATE' => now()->format('Y-m-d H:i:s'), + 'MSG' => $data['message'], + 'FILE_CNT' => 0, + 'FILE_PATH1' => '', + 'TYPE' => '0', + ]; + + return (bool) MmsMsg::create($insert); + // 또는 Query Builder: + // return $conn->table('MMS_MSG')->insert($insert); + } + }); + } + + private function resolveSendType(array $data): string + { + // CI 로직과 동일한 우선순위 유지 + if (!empty($data['sms_type'])) { + if ($data['sms_type'] === 'sms') { + return (mb_strlen($data['message'], 'EUC-KR') <= 90) ? 'sms' : 'mms'; + } + return 'mms'; + } + + return (mb_strlen($data['message'], 'EUC-KR') <= 90) ? 'sms' : 'mms'; + } + + private function sms2Send(array $data): bool + { + // TODO: 업체 연동 시 구현 + return true; + } +} diff --git a/app/Support/LegacyCrypto/CiSeedCrypto.php b/app/Support/LegacyCrypto/CiSeedCrypto.php new file mode 100644 index 0000000..c31b03f --- /dev/null +++ b/app/Support/LegacyCrypto/CiSeedCrypto.php @@ -0,0 +1,211 @@ +serverEncoding = config('legacy.server_encoding', 'UTF-8'); + $this->innerEncoding = config('legacy.inner_encoding', 'UTF-8'); + $this->block = (int) config('legacy.block', 16); + + if (count($iv) !== 16) { + throw new RuntimeException('legacy.iv must be 16 bytes'); + } + $this->iv = array_values($iv); + + // 핵심: CI SeedRoundKey는 pbUserKey[0..15]만 사용 → 문자열 키의 "앞 16바이트"만 의미 있음 + $this->userKeyBytes = $this->stringKeyToSigned16Bytes($userKey); + } + + public function encrypt(string $plain): string + { + $plain = $this->convertEncoding($plain); + if ($plain === '') return $plain; + + $planBytes = $this->unpackSignedBytes($plain); + + $seed = new Seed(); + $pdwRoundKey = null; + $seed->SeedRoundKey($pdwRoundKey, $this->userKeyBytes); + + $planLen = count($planBytes); + $start = 0; + $end = 0; + + $cbcBlock = []; + $this->arrayCopy($this->iv, 0, $cbcBlock, 0, $this->block); + + $ret = ''; + + while ($end < $planLen) { + $end = $start + $this->block; + if ($end > $planLen) $end = $planLen; + + $cipherBlock = []; + $this->arrayCopy($planBytes, $start, $cipherBlock, 0, $end - $start); + + // PKCS#5 padding + $nPad = $this->block - ($end - $start); + for ($i = ($end - $start); $i < $this->block; $i++) { + $cipherBlock[$i] = $nPad; + } + + // CBC XOR + $this->xor16($cipherBlock, $cbcBlock, $cipherBlock); + + // Encrypt + $encBlock = null; + $seed->SeedEncrypt($cipherBlock, $pdwRoundKey, $encBlock); + + // CBC 갱신 + $this->arrayCopy($encBlock, 0, $cbcBlock, 0, $this->block); + + foreach ($encBlock as $b) { + $ret .= bin2hex(chr($b & 0xFF)); + } + + $start = $end; + } + + return $ret; + } + + public function decrypt(string $cipherHex): string + { + $cipherHex = trim($cipherHex); + if ($cipherHex === '') return ''; + + if (strlen($cipherHex) % 32 !== 0) { + throw new RuntimeException('Invalid cipher hex length (must be multiple of 32)'); + } + + $seed = new Seed(); + $pdwRoundKey = null; + $seed->SeedRoundKey($pdwRoundKey, $this->userKeyBytes); + + $cbcBlock = []; + $this->arrayCopy($this->iv, 0, $cbcBlock, 0, $this->block); + + $plainBytes = []; + $blocks = (int)(strlen($cipherHex) / 32); + + for ($bi = 0; $bi < $blocks; $bi++) { + $hexBlock = substr($cipherHex, $bi * 32, 32); + $cipherBlock = $this->hexToBytesSigned($hexBlock); // signed 16 bytes + + $decBlock = null; + $seed->SeedDecrypt($cipherBlock, $pdwRoundKey, $decBlock); + + $plainBlock = []; + $this->xor16($decBlock, $cbcBlock, $plainBlock); + + // CBC 갱신 + $this->arrayCopy($cipherBlock, 0, $cbcBlock, 0, $this->block); + + foreach ($plainBlock as $b) { + $plainBytes[] = $b; + } + } + + $plainBytes = $this->pkcs5Unpad($plainBytes); + $plain = $this->packSignedBytes($plainBytes); + + return $this->convertEncodingBack($plain); + } + + /* -------------------- KEY NORMALIZE (핵심) -------------------- */ + + private function stringKeyToSigned16Bytes(string $key): array + { + // 레거시(운영 CI)에서 "문자열을 배열처럼 접근 + & 연산"하던 동작을 최대한 재현 + // 문자 1개를 (int)로 캐스팅: 숫자면 0~9, 숫자 아니면 0 + $bytes = []; + + for ($i = 0; $i < 16; $i++) { + $ch = $key[$i] ?? "\0"; // 1-char string + $v = (int) $ch; // 핵심: ord()가 아니라 int 캐스팅 + // signed byte 범위 맞추기(사실 0~9라 필요없지만 안전) + if ($v > 127) $v -= 256; + $bytes[] = $v; + } + + return $bytes; + } + + /* -------------------- UTIL -------------------- */ + + private function convertEncoding(string $s): string + { + $out = @iconv($this->serverEncoding, $this->innerEncoding, $s); + return $out === false ? $s : $out; + } + + private function convertEncodingBack(string $s): string + { + $out = @iconv($this->innerEncoding, $this->serverEncoding, $s); + return $out === false ? $s : $out; + } + + private function unpackSignedBytes(string $s): array + { + return array_values(unpack('c*', $s)); + } + + private function packSignedBytes(array $bytes): string + { + $out = ''; + foreach ($bytes as $b) { + $out .= chr($b & 0xFF); + } + return $out; + } + + private function hexToBytesSigned(string $hexBlock): array + { + $bin = hex2bin($hexBlock); + if ($bin === false || strlen($bin) !== 16) { + throw new RuntimeException('Invalid hex block'); + } + return $this->unpackSignedBytes($bin); + } + + private function pkcs5Unpad(array $bytes): array + { + $n = count($bytes); + if ($n === 0) return $bytes; + + $pad = $bytes[$n - 1] & 0xFF; + if ($pad < 1 || $pad > 16) return $bytes; + + for ($i = 0; $i < $pad; $i++) { + if (($bytes[$n - 1 - $i] & 0xFF) !== $pad) { + return $bytes; + } + } + return array_slice($bytes, 0, $n - $pad); + } + + private function xor16(array $a, array $b, array &$out): void + { + for ($i = 0; $i < 16; $i++) { + $out[$i] = ($a[$i] ^ $b[$i]); + } + } + + private function arrayCopy(array $src, int $srcPos, array &$dst, int $dstPos, int $length): void + { + for ($i = 0; $i < $length; $i++) { + $dst[$dstPos + $i] = $src[$srcPos + $i]; + } + } +} diff --git a/app/Support/LegacyCrypto/Seed.php b/app/Support/LegacyCrypto/Seed.php new file mode 100644 index 0000000..2cbabe3 --- /dev/null +++ b/app/Support/LegacyCrypto/Seed.php @@ -0,0 +1,679 @@ +> 8 ); + } + private function GetB2($A){ + return 0x000000ff & ( $A >> 16 ); + } + private function GetB3($A){ + return 0x000000ff & ( $A >> 24 ); + } + //---------------------------------------- + + //엔디안 변환 (데이터의 순서 변환) + private function EndianChange( $dws ) { + return ( $dws >> 24 ) | ( $dws << 24 ) | ( ( $dws << 8 ) & 0x00ff0000 ) | ( ( $dws >> 8 ) & 0x0000ff00 ); + } + + + /***************************** SEED round function ****************************/ + + public function SeedRound( + &$L0, &$L1, // [in, out] left-side variable at each round + &$R0, &$R1, // [in] right-side variable at each round + $K = array() // [in] round keys at each round + ) + { + $T0 = $R0 ^ $K[0]; + $T1 = $R1 ^ $K[1]; + $T1 ^= $T0; + + $T1 = $this->SS0[$this->GetB0($T1)] ^ $this->SS1[$this->GetB1($T1)] ^ $this->SS2[$this->GetB2($T1)] ^ $this->SS3[$this->GetB3($T1)]; + $T0 += $T1; + $T0 = $this->ConvertInt($T0); + + $T0 = $this->SS0[$this->GetB0($T0)] ^ $this->SS1[$this->GetB1($T0)] ^ $this->SS2[$this->GetB2($T0)] ^ $this->SS3[$this->GetB3($T0)]; + $T1 += $T0; + $T1 = $this->ConvertInt($T1); + + $T1 = $this->SS0[$this->GetB0($T1)] ^ $this->SS1[$this->GetB1($T1)] ^ $this->SS2[$this->GetB2($T1)] ^ $this->SS3[$this->GetB3($T1)]; + $T0 += $T1; + $T0 = $this->ConvertInt($T0); + + $L0 ^= $T0; + $L1 ^= $T1; + } + + //추가된 함수 by mibany (2011-01-21) + //PHP 에서는 float를 int로 강제로 형변환이 되지 않는다. + //이사실을 전혀 몰랐던 나는 C++ 의 달인 Keige 님의 도움으로 + //PHP 로 구현하기 위한 핵심부분인 이 함수를 만들게 되었다. + //문제는 고수가 아닌 나에게 이함수 구현이란 내게 어려운 일이었다. + //혹시라도 이함수의 오류로 인해 Seed 암호화가 문제가 있을수도 있으니 + //고수분들의 도움이 절실하다. + private function ConvertInt($float) { + $IntMax = PHP_INT_MAX; + $IntMin = ( PHP_INT_MAX * -1 ) -1; + if(is_float($float) && $float < $IntMin ) { + $division = floor($float / $IntMin ); + $n = ($division % 2 == 0)?0:$IntMin; + if( $float < $IntMin ) $c = $float - ( $IntMin * $division ) - $n; + } + elseif(is_float($float) && $float > $IntMax) { + $division = floor($float / $IntMax ); + $n = ($division % 2 == 0)?0:$IntMax; + if( $float > $IntMax) $c = $float - ( $IntMax * $division ) - $n - 2; + } + else $c = $float; + return $c; + } + + /************************** SEED encrtyption function *************************/ + + public function SeedEncrypt( + $pbData = array(), // [in] data to be encrypted + $pdwRoundKey = array(), // [in] round keys for encryption + &$outData = array() // [out] encrypted data + ) + { + $L0 = 0x0; + $L1 = 0x0; + $R0 = 0x0; + $R1 = 0x0; + $K = array(); + $nCount = 0; + + // Set up input values for encryption + $L0 = ( $pbData[0] & 0x000000ff ); + $L0 = ( $L0 << 8 ) ^ ( $pbData[1] & 0x000000ff ); + $L0 = ( $L0 << 8 ) ^ ( $pbData[2] & 0x000000ff ); + $L0 = ( $L0 << 8 ) ^ ( $pbData[3] & 0x000000ff ); + + $L1 = ( $pbData[4] & 0x000000ff ); + $L1 = ( $L1 << 8 ) ^ ( $pbData[5] & 0x000000ff ); + $L1 = ( $L1 << 8 ) ^ ( $pbData[6] & 0x000000ff ); + $L1 = ( $L1 << 8 ) ^ ( $pbData[7] & 0x000000ff ); + + $R0 = ( $pbData[8] & 0x000000ff ); + $R0 = ( $R0 <<8 ) ^ ( $pbData[9] & 0x000000ff ); + $R0 = ( $R0 <<8 ) ^ ( $pbData[10] & 0x000000ff ); + $R0 = ( $R0 <<8 ) ^ ( $pbData[11] & 0x000000ff ); + + $R1 = ( $pbData[12] & 0x000000ff ); + $R1 = ( $R1 <<8 ) ^ ( $pbData[13] & 0x000000ff ); + $R1 = ( $R1 <<8 ) ^ ( $pbData[14] & 0x000000ff ); + $R1 = ( $R1 <<8 ) ^ ( $pbData[15] & 0x000000ff ); + + // Reorder for little endian + // Because java virtual machine use big endian order in default + if (!$this->ENDIAN) { + $this->EndianChange($L0); + $this->EndianChange($L1); + $this->EndianChange($R0); + $this->EndianChange($R1); + } + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 1 */ + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 2 */ + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 3 */ + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 4 */ + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 5 */ + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 6 */ + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 7 */ + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 8 */ + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 9 */ + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 10 */ + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 11 */ + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 12 */ + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 13 */ + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 14 */ + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 15 */ + + $K[0] = $pdwRoundKey[$nCount++]; + $K[1] = $pdwRoundKey[$nCount++]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 16 */ + + if (!$this->ENDIAN) { + $this->EndianChange($L0); + $this->EndianChange($L1); + $this->EndianChange($R0); + $this->EndianChange($R1); + } + + // Copying output values from last round to outData + for ($i=0; $i<16; $i++) $outData[$i] = null; + for ($i=0; $i<4; $i++) + { + $outData[$i] = ( ( ( $R0 ) >>( 8 * ( 3 - $i ) ) ) & 0xff ); + $outData[4+$i] = ( ( ( $R1 ) >> ( 8 * ( 3 - $i ) ) ) & 0xff ); + $outData[8+$i] = ( ( ( $L0 ) >> ( 8 * ( 3 - $i ) ) ) & 0xff ); + $outData[12+$i] = ( ( ( $L1 ) >> ( 8 * ( 3 - $i ) ) ) &0xff ); + } + } + + + /************************** SEED decrtyption function *************************/ + +// Same as encrypt, except that round keys are applied in reverse order + public function SeedDecrypt( + $pbData = array(), // [in] encrypted data + $pdwRoundKey = array(), // [in] round keys for decryption + &$outData = array() // [out] data to be encrypted + ) + { + $L0 = 0x0; + $L1 = 0x0; + $R0 = 0x0; + $R1 = 0x0; + $K = array(); + $nCount = 31; + + // Set up input values for decryption + $L0 = ( $pbData[0] & 0x000000ff ); + $L0 = ( $L0 << 8 ) ^ ( $pbData[1] & 0x000000ff ); + $L0 = ( $L0 << 8 ) ^ ( $pbData[2] & 0x000000ff ); + $L0 = ( $L0 << 8 ) ^ ( $pbData[3] & 0x000000ff ); + + $L1 = ( $pbData[4] & 0x000000ff ); + $L1 = ( $L1 << 8 ) ^( $pbData[5] & 0x000000ff ); + $L1 = ( $L1 << 8 ) ^ ( $pbData[6] & 0x000000ff ); + $L1 = ( $L1 << 8 ) ^ ( $pbData[7] & 0x000000ff ); + + $R0 = ( $pbData[8] & 0x000000ff ); + $R0 = ( $R0 << 8 ) ^ ( $pbData[9] & 0x000000ff ); + $R0 = ( $R0 << 8 ) ^ ( $pbData[10] & 0x000000ff ); + $R0 = ( $R0 << 8 ) ^ ( $pbData[11] & 0x000000ff ); + + $R1 = ( $pbData[12] & 0x000000ff ); + $R1 = ( $R1 << 8 ) ^ ( $pbData[13] & 0x000000ff ); + $R1 = ( $R1 << 8 ) ^ ( $pbData[14] & 0x000000ff ); + $R1 = ( $R1 << 8 ) ^ ( $pbData[15] & 0x000000ff ); + + // Reorder for little endian + if (!$this->ENDIAN) { + $this->EndianChange($L0); + $this->EndianChange($L1); + $this->EndianChange($R0); + $this->EndianChange($R1); + } + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount--]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 1 */ + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount--]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 2 */ + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount--]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 3 */ + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount--]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 4 */ + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount--]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 5 */ + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount--]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 6 */ + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount--]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 7 */ + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount--]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 8 */ + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount--]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 9 */ + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount--]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 10 */ + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount--]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 11 */ + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount--]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 12 */ + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount--]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 13 */ + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount--]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 14 */ + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount--]; + $this->SeedRound($L0, $L1, $R0, $R1, $K); /* 15 */ + + $K[1] = $pdwRoundKey[$nCount--]; + $K[0] = $pdwRoundKey[$nCount]; + $this->SeedRound($R0, $R1, $L0, $L1, $K); /* 16 */ + + if (!$this->ENDIAN) { + $this->EndianChange($L0); + $this->EndianChange($L1); + $this->EndianChange($R0); + $this->EndianChange($R1); + } + + // Copy output values from last round to outData + for ($i=0; $i<16; $i++) $outData[$i] = null; + for ($i=0; $i < 4; $i++) + { + $outData[$i] = ( ( ( $R0 ) >> ( 8 * ( 3 - $i ) ) ) & 0xff ); + $outData[4+$i] = ( ( ( $R1 ) >> ( 8 * ( 3 - $i ) ) ) & 0xff ); + $outData[8+$i] = ( ( ( $L0 ) >> ( 8 * ( 3 - $i ) ) ) & 0xff ); + $outData[12+$i] = ( ( ( $L1 ) >> ( 8 * ( 3 - $i ) ) ) & 0xff ); + } + } + + + /************************ Functions for Key schedule **************************/ + + private function EncRoundKeyUpdate0(&$K = array(), &$A, &$B, &$C, &$D, $Z) + { + $T0 = $A; + + $A = ( $A >> 8 & 0x00ffffff ) ^ ( $B << 24 ); + $B = ( $B >> 8 & 0x00ffffff ) ^ ( $T0 << 24 ); + + $T00 = (int) $A + (int) $C - (int) $this->KC[$Z]; + $T00 = $this->ConvertInt($T00); + + $T11 = (int) $B + (int) $this->KC[$Z] - (int) $D; + $T11 = $this->ConvertInt($T11); + + $K[0] = $this->SS0[$this->GetB0($T00)] ^ $this->SS1[$this->GetB1($T00)] ^ $this->SS2[$this->GetB2($T00)] ^ $this->SS3[$this->GetB3($T00)]; + $K[1] = $this->SS0[$this->GetB0($T11)] ^ $this->SS1[$this->GetB1($T11)] ^ $this->SS2[$this->GetB2($T11)] ^ $this->SS3[$this->GetB3($T11)]; + } + + private function EncRoundKeyUpdate1(&$K = array(), &$A, &$B, &$C, &$D, $Z) + { + $T0 = $C; + $C = ( $C << 8 ) ^ ( $D >> 24 & 0x000000ff ); + $D = ( $D << 8 ) ^ ( $T0 >> 24 & 0x000000ff ); + + $T00 = (int) $A + (int) $C - (int) $this->KC[$Z]; + $T00 = $this->ConvertInt($T00); + + $T11 = (int) $B + (int) $this->KC[$Z] - (int) $D; + $T11 = $this->ConvertInt($T11); + + $K[0] = $this->SS0[$this->GetB0($T00)] ^ $this->SS1[$this->GetB1($T00)] ^ $this->SS2[$this->GetB2($T00)] ^ $this->SS3[$this->GetB3($T00)]; + $K[1] = $this->SS0[$this->GetB0($T11)] ^ $this->SS1[$this->GetB1($T11)] ^ $this->SS2[$this->GetB2($T11)] ^ $this->SS3[$this->GetB3($T11)]; + } + + /******************************** Key Schedule ********************************/ + public function SeedRoundKey( + &$pdwRoundKey = array(), // [out] round keys for encryption or decryption + $pbUserKey = array() // [in] secret user key + ) + { + $K = array(); + $nCount = 2; + + // Set up input values for Key Schedule + $A = @( $pbUserKey[0] & 0x000000ff ); + $A = ( $A << 8 ) ^ @( $pbUserKey[1] & 0x000000ff ); + $A = ( $A << 8 ) ^ @( $pbUserKey[2] & 0x000000ff ); + $A = ( $A << 8 ) ^ @( $pbUserKey[3] & 0x000000ff ); + + $B = @( $pbUserKey[4] & 0x000000ff ); + $B = ( $B<<8 ) ^ @( $pbUserKey[5] & 0x000000ff ); + $B = ( $B<<8 ) ^ @( $pbUserKey[6] & 0x000000ff ); + $B = ( $B<<8 ) ^ @( $pbUserKey[7] & 0x000000ff ); + + $C = @( $pbUserKey[8] & 0x000000ff ); + $C = ( $C << 8 ) ^ @( $pbUserKey[9] & 0x000000ff ); + $C = ( $C << 8 ) ^ @( $pbUserKey[10] & 0x000000ff ); + $C = ( $C << 8 ) ^ @( $pbUserKey[11] & 0x000000ff ); + + $D = @( $pbUserKey[12] & 0x000000ff ); + $D = ( $D << 8 ) ^ @($pbUserKey[13] & 0x000000ff ); + $D = ( $D << 8 ) ^ @( $pbUserKey[14] & 0x000000ff ); + $D = ( $D << 8 ) ^ @( $pbUserKey[15] & 0x000000ff ); + + // reorder for little endian + if (!$this->ENDIAN) { + $A = $this->EndianChange($A); + $B = $this->EndianChange($B); + $C = $this->EndianChange($C); + $D = $this->EndianChange($D); + } + + $T0 = (int) $A + (int) $C - (int) $this->KC[0]; + $T0 = $this->ConvertInt($T0); + + $T1 = (int) $B - (int) $D + (int) $this->KC[0]; + $T1 = $this->ConvertInt($T1); + + $pdwRoundKey[0] = $this->SS0[$this->GetB0($T0)] ^ $this->SS1[$this->GetB1($T0)] ^ $this->SS2[$this->GetB2($T0)] ^ $this->SS3[$this->GetB3($T0)]; + $pdwRoundKey[1] = $this->SS0[$this->GetB0($T1)] ^ $this->SS1[$this->GetB1($T1)] ^ $this->SS2[$this->GetB2($T1)] ^ $this->SS3[$this->GetB3($T1)]; + + $this->EncRoundKeyUpdate0($K, $A, $B, $C, $D, 1 ); + $pdwRoundKey[$nCount++] = $K[0]; + $pdwRoundKey[$nCount++] = $K[1]; + + $this->EncRoundKeyUpdate1($K, $A, $B, $C, $D, 2 ); + $pdwRoundKey[$nCount++] = $K[0]; + $pdwRoundKey[$nCount++] = $K[1]; + + $this->EncRoundKeyUpdate0($K, $A, $B, $C, $D, 3 ); + $pdwRoundKey[$nCount++] = $K[0]; + $pdwRoundKey[$nCount++] = $K[1]; + + $this->EncRoundKeyUpdate1($K, $A, $B, $C, $D, 4 ); + $pdwRoundKey[$nCount++] = $K[0]; + $pdwRoundKey[$nCount++] = $K[1]; + + $this->EncRoundKeyUpdate0($K, $A, $B, $C, $D, 5 ); + $pdwRoundKey[$nCount++] = $K[0]; + $pdwRoundKey[$nCount++] = $K[1]; + + $this->EncRoundKeyUpdate1($K, $A, $B, $C, $D, 6 ); + $pdwRoundKey[$nCount++] = $K[0]; + $pdwRoundKey[$nCount++] = $K[1]; + + $this->EncRoundKeyUpdate0($K, $A, $B, $C, $D, 7 ); + $pdwRoundKey[$nCount++] = $K[0]; + $pdwRoundKey[$nCount++] = $K[1]; + + $this->EncRoundKeyUpdate1($K, $A, $B, $C, $D, 8 ); + $pdwRoundKey[$nCount++] = $K[0]; + $pdwRoundKey[$nCount++] = $K[1]; + + $this->EncRoundKeyUpdate0($K, $A, $B, $C, $D, 9 ); + $pdwRoundKey[$nCount++] = $K[0]; + $pdwRoundKey[$nCount++] = $K[1]; + + $this->EncRoundKeyUpdate1($K, $A, $B, $C, $D, 10); + $pdwRoundKey[$nCount++] = $K[0]; + $pdwRoundKey[$nCount++] = $K[1]; + + $this->EncRoundKeyUpdate0($K, $A, $B, $C, $D, 11); + $pdwRoundKey[$nCount++] = $K[0]; + $pdwRoundKey[$nCount++] = $K[1]; + + $this->EncRoundKeyUpdate1($K, $A, $B, $C, $D, 12); + $pdwRoundKey[$nCount++] = $K[0]; + $pdwRoundKey[$nCount++] = $K[1]; + + $this->EncRoundKeyUpdate0($K, $A, $B, $C, $D, 13); + $pdwRoundKey[$nCount++] = $K[0]; + $pdwRoundKey[$nCount++] = $K[1]; + + $this->EncRoundKeyUpdate1($K, $A, $B, $C, $D, 14); + $pdwRoundKey[$nCount++] = $K[0]; + $pdwRoundKey[$nCount++] = $K[1]; + + $this->EncRoundKeyUpdate0($K, $A, $B, $C, $D, 15); + $pdwRoundKey[$nCount++] = $K[0]; + $pdwRoundKey[$nCount++] = $K[1]; + } + public function SeedRoundKeyText(&$pdwRoundKey, $pbUserKey) + { + $Data = []; + $len = strlen($pbUserKey); + for ($i = 0; $i < $len; $i++) { + $Data[$i] = ord($pbUserKey[$i]); // ✅ {} -> [] + } + $this->SeedRoundKey($pdwRoundKey, $Data); + } + + public function SeedEncryptText($pbData, $pdwRoundKey, &$outData) + { + $Data = []; + $len = strlen($pbData); + for ($i = 0; $i < $len; $i++) { + $Data[$i] = ord($pbData[$i]); // ✅ {} -> [] + } + $this->SeedEncrypt($Data, $pdwRoundKey, $outData); + } + + public function SeedDecryptText($pbData, $pdwRoundKey, &$outData) + { + $Data = []; + $len = strlen($pbData); + for ($i = 0; $i < $len; $i++) { + $Data[$i] = ord($pbData[$i]); // ✅ {} -> [] + } + $this->SeedDecrypt($Data, $pdwRoundKey, $outData); + } +} diff --git a/config/cs_faq.php b/config/cs_faq.php new file mode 100644 index 0000000..6e14fa7 --- /dev/null +++ b/config/cs_faq.php @@ -0,0 +1,132 @@ + [ + ['key' => 'signup', 'label' => '회원가입 문의'], + ['key' => 'login', 'label' => '로그인 문의'], + ['key' => 'pay', 'label' => '결제 문의'], + ['key' => 'code', 'label' => '상품권 코드 문의'], + ['key' => 'event', 'label' => '이벤트 문의'], + ['key' => 'etc', 'label' => '기타문의'], + ], + + /* + |-------------------------------------------------------------------------- + | FAQ Items + |-------------------------------------------------------------------------- + | - 'category' must match categories.key + | - 'q' question + | - 'a' answer (string, allow \n) + |-------------------------------------------------------------------------- + */ + 'items' => [ + + // 회원가입 문의 + [ + 'category' => 'signup', + 'q' => '회원가입에 나이 제한이 있나요?', + 'a' => "만 14세 미만은 회원가입 및 서비스 이용이 불가능합니다.\n만 14세 이상인 경우에만 회원가입이 가능합니다.", + ], + [ + 'category' => 'signup', + 'q' => '법인회원으로 가입이 가능한가요?', + 'a' => "법인회원으로는 가입이 불가능하며, 개인회원으로만 가입이 가능합니다.", + ], + [ + 'category' => 'signup', + 'q' => '회원가입은 어떻게 하나요?', + 'a' => "1) 사이트 상단의 ‘회원가입’을 클릭합니다.\n2) 이용약관/개인정보처리방침/수신동의 내용을 확인 후 동의합니다.\n3) 휴대폰 본인 인증을 진행합니다.\n4) 인증 완료 후 가입정보 입력 페이지로 이동합니다.\n5) 아이디(이메일), 비밀번호 등 필수 정보를 입력 후 ‘회원가입’을 클릭합니다.\n6) 가입이 완료됩니다.", + ], + [ + 'category' => 'signup', + 'q' => '아이디를 여러 개 사용할 수 있나요?', + 'a' => "본인 인증을 필수로 진행하기 때문에, 본인 명의 기준 1개의 계정만 가입 및 사용이 가능합니다.", + ], + [ + 'category' => 'signup', + 'q' => '본인명의 휴대폰 인증이 안돼요.', + 'a' => "아래 경우에는 인증이 제한될 수 있습니다.\n- 법인/타인 명의 휴대폰\n- 선불폰/알뜰폰\n- 분실신고/일시정지/해지/미개통 상태", + ], + + // 로그인 문의 + [ + 'category' => 'login', + 'q' => '아이디가 기억나지 않습니다.', + 'a' => "휴대폰 본인인증을 통해 아이디 정보를 확인할 수 있습니다.\n개인정보 보호를 위해 일부 정보만 표시될 수 있습니다.", + ], + [ + 'category' => 'login', + 'q' => '비밀번호가 기억나지 않습니다.', + 'a' => "비밀번호는 암호화되어 있어 직접 확인은 불가능합니다.\n본인 명의 휴대폰 인증 후 비밀번호를 재설정해 주세요.", + ], + + // 결제 문의 + [ + 'category' => 'pay', + 'q' => '무통장 입금하면 바로 구매되나요?', + 'a' => "무통장 입금은 결제 완료까지 약 5~10분 정도 소요될 수 있습니다.\n입금 완료 후 안내 메시지가 발송됩니다.", + ], + [ + 'category' => 'pay', + 'q' => '신용카드 결제 한도는 어떻게 되나요?', + 'a' => "개인 신용카드는 카드사별 정책에 따라 월 100만원 한도 내에서 결제가 가능합니다.", + ], + [ + 'category' => 'pay', + 'q' => '신용카드 결제 수수료가 있나요?', + 'a' => "신용카드 결제 수수료는 별도로 부과되지 않습니다.", + ], + [ + 'category' => 'pay', + 'q' => '신용카드 결제 할부가 가능한가요?', + 'a' => "카드사 정책에 따라 신용카드 할부 결제는 불가합니다.", + ], + [ + 'category' => 'pay', + 'q' => '휴대폰 결제 한도는 어떻게 되나요?', + 'a' => "휴대폰 결제 통합 한도는 ID당 100만원(결제 수수료 포함)입니다.\n통신사/결제대행업체 정책에 따라 달라질 수 있습니다.", + ], + [ + 'category' => 'pay', + 'q' => '본인 휴대폰이 아닌데 휴대폰 결제가 가능한가요?', + 'a' => "휴대폰 결제는 가입자와 휴대폰 명의자가 일치해야 합니다.\n도용 결제 예방을 위한 정책입니다.", + ], + [ + 'category' => 'pay', + 'q' => '휴대폰 결제 수수료가 있나요?', + 'a' => "휴대폰 결제는 결제대행업체 정책에 따라 결제 수수료(예: 10%)가 적용될 수 있습니다.", + ], + + // 상품권 코드 문의 + [ + 'category' => 'code', + 'q' => '상품 코드는 어디에서 확인하나요?', + 'a' => "마이페이지 > 이용내역에서 확인 가능합니다.\n단, 일부 상품은 휴대폰으로 즉시 전송되어 이용내역에서 확인이 어려울 수 있습니다.\n미수신/확인 불가 시 1:1 문의로 접수해 주세요.", + ], + [ + 'category' => 'code', + 'q' => '구입한 상품을 취소하고 싶습니다.', + 'a' => "상품 특성상 발송이 완료된 경우 취소가 불가능합니다.", + ], + + // 이벤트 문의 (현재 원문 페이지가 비어있는 형태여서, UI상 빈 상태 안내) + [ + 'category' => 'event', + 'q' => '이벤트 관련 문의는 어디로 하면 되나요?', + 'a' => "이벤트 진행 시 FAQ에 별도 안내가 추가됩니다.\n현재 진행 중인 이벤트가 없다면 1:1 문의로 접수해 주세요.", + ], + + // 기타문의 + [ + 'category' => 'etc', + 'q' => '카카오톡 채팅 상담이 가능한가요?', + 'a' => "카카오톡 채널을 추가한 뒤 채팅 상담이 가능합니다.\n‘카카오톡 상담’ 메뉴에서도 동일하게 안내해 드립니다.", + ], + ], +]; diff --git a/config/database.php b/config/database.php index df933e7..3bf3c31 100644 --- a/config/database.php +++ b/config/database.php @@ -31,38 +31,6 @@ return [ 'connections' => [ - 'sqlite' => [ - 'driver' => 'sqlite', - 'url' => env('DB_URL'), - 'database' => env('DB_DATABASE', database_path('database.sqlite')), - 'prefix' => '', - 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - 'busy_timeout' => null, - 'journal_mode' => null, - 'synchronous' => null, - 'transaction_mode' => 'DEFERRED', - ], - - 'mysql' => [ - 'driver' => 'mysql', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => env('DB_CHARSET', 'utf8mb4'), - 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), - 'prefix' => '', - 'prefix_indexes' => true, - 'strict' => true, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), - ]) : [], - ], - 'mariadb' => [ 'driver' => 'mariadb', 'url' => env('DB_URL'), @@ -83,35 +51,26 @@ return [ ]) : [], ], - 'pgsql' => [ - 'driver' => 'pgsql', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '5432'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => env('DB_CHARSET', 'utf8'), + 'sms_server' => [ + 'driver' => 'mysql', + 'host' => env('SMS_DB_HOST', '127.0.0.1'), + 'port' => env('SMS_DB_PORT', '3306'), + 'database' => env('SMS_DB_DATABASE', 'lguplus'), + 'username' => env('SMS_DB_USERNAME', 'lguplus'), + 'password' => env('SMS_DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', - 'prefix_indexes' => true, - 'search_path' => 'public', - 'sslmode' => env('DB_SSLMODE', 'prefer'), + 'strict' => false, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], ], - 'sqlsrv' => [ - 'driver' => 'sqlsrv', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', 'localhost'), - 'port' => env('DB_PORT', '1433'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => env('DB_CHARSET', 'utf8'), - 'prefix' => '', - 'prefix_indexes' => true, - // 'encrypt' => env('DB_ENCRYPT', 'yes'), - // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), - ], + + ], diff --git a/config/legacy.php b/config/legacy.php new file mode 100644 index 0000000..c713a79 --- /dev/null +++ b/config/legacy.php @@ -0,0 +1,10 @@ + env('LEGACY_ENCRYPTION_KEY_WEB', ''), + 'server_encoding' => 'UTF-8', + 'inner_encoding' => 'UTF-8', + 'block' => 16, + 'iv' => [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16], +]; + diff --git a/resources/css/web.css b/resources/css/web.css index 1694d63..2975509 100644 --- a/resources/css/web.css +++ b/resources/css/web.css @@ -1225,7 +1225,7 @@ h1, h2, h3, h4, h5, h6 { border-bottom: 1px solid rgba(0,0,0,.08); } .subpage-title{ - font-size: 18px; + font-size: 22px; font-weight: 900; letter-spacing: -0.3px; color:#0F172A; @@ -1291,8 +1291,8 @@ h1, h2, h3, h4, h5, h6 { height: 40px; padding: 0 12px; border-radius: 12px; - font-size: 13px; - font-weight: 800; + font-size: 16px; + font-weight: 900; color:#334155; } .subnav-link:hover{ background:#F8FAFC; } @@ -2733,3 +2733,1189 @@ body.is-drawer-open{ .m-tile--ghost{ background: rgba(0,0,0,.02); } + +/* Kakao CS page */ +.kakao-page{ display:block; } + +.kakao-hero{ margin-top:14px; } +.kakao-hero__card{ + border:1px solid rgba(0,0,0,.08); + background:#fff; + border-radius:18px; + padding:16px; +} +.kakao-hero__badge{ + display:inline-flex; + align-items:center; + height:28px; + padding:0 10px; + border-radius:999px; + background: rgba(0,0,0,.04); + font-weight:900; + font-size:12px; +} +.kakao-hero__title{ margin:10px 0 6px; font-weight:900; letter-spacing:-0.02em; padding-top:10px;} +.kakao-hero__desc{ margin:0; color:rgba(0,0,0,.68); line-height:1.7; } + +.kakao-hero__meta{ + margin-top:12px; + display:grid; + grid-template-columns: repeat(2, minmax(0,1fr)); + gap:10px; +} +.kakao-meta{ + border:1px solid rgba(0,0,0,.06); + background: rgba(0,0,0,.02); + border-radius:14px; + padding:10px 12px; +} +.kakao-meta__k{ font-size:12px; color:rgba(0,0,0,.60); font-weight:800; } +.kakao-meta__v{ margin-top:2px; font-weight:900; } + +.kakao-hero__actions{ + margin-top:14px; + display:flex; + gap:10px; + flex-wrap:wrap; +} +.kakao-hero__img{ + width:100%; + height:auto; + margin-top:12px; + border-radius:14px; + border:1px solid rgba(0,0,0,.06); +} + +.kakao-sec-title{ margin:18px 0 10px; font-weight:900; letter-spacing:-0.02em; } + +.kakao-step-grid{ + display:grid; + grid-template-columns: repeat(3, minmax(0,1fr)); + gap:10px; +} +.kakao-step{ + border:1px solid rgba(0,0,0,.08); + background:#fff; + border-radius:16px; + padding:14px; +} +.kakao-step__num{ font-weight:900; color:#0b63ff; } +.kakao-step__title{ margin-top:6px; font-weight:900; } +.kakao-step__desc{ margin-top:4px; color:rgba(0,0,0,.68); line-height:1.6; } + +.kakao-help-grid{ + display:grid; + grid-template-columns: repeat(2, minmax(0,1fr)); + gap:10px; +} +.kakao-card{ + border:1px solid rgba(0,0,0,.08); + background:#fff; + border-radius:16px; + padding:14px; +} +.kakao-card__title{ font-weight:900; margin-bottom:8px; } +.kakao-list{ margin:0; padding-left:18px; color:rgba(0,0,0,.72); line-height:1.8; } + +@media (max-width: 640px){ + .kakao-hero__meta{ grid-template-columns: 1fr; } + .kakao-step-grid{ grid-template-columns: 1fr; } + .kakao-help-grid{ grid-template-columns: 1fr; } +} +/* Kakao Accent Tokens */ +:root{ + --kakao-yellow: #FEE500; + --kakao-yellow-soft: rgba(254,229,0,.18); + --kakao-black: #191919; +} +/* Kakao CS page accent */ +.kakao-hero__badge{ + background: var(--kakao-yellow); + color: var(--kakao-black); + border: 1px solid rgba(0,0,0,.10); +} + +/* 제목 왼쪽 포인트 라인(과하지 않게) */ +.kakao-hero__title{ + position: relative; + padding-left: 12px; +} +.kakao-hero__title:before{ + content:""; + position:absolute; + left:0; + top:.35em; + width:4px; + height:1.05em; + border-radius: 8px; + background: var(--kakao-yellow); +} + +/* 메타 카드 배경에 은은한 노랑 */ +.kakao-meta{ + background: linear-gradient(135deg, var(--kakao-yellow-soft), rgba(0,0,0,.02)); + border-color: rgba(0,0,0,.06); +} + +/* Step 숫자 색을 카카오 노랑으로 */ +.kakao-step__num{ + color: var(--kakao-black); + background: var(--kakao-yellow); + display: inline-flex; + align-items:center; + justify-content:center; + height: 28px; + min-width: 46px; + padding: 0 10px; + border-radius: 999px; + font-weight: 900; + border: 1px solid rgba(0,0,0,.10); +} + +/* 카드 hover에 노랑 tint */ +.kakao-step:hover, +.kakao-card:hover{ + border-color: rgba(254,229,0,.55); + box-shadow: 0 10px 24px rgba(0,0,0,.06); +} + +/* CTA 버튼: primary를 카카오 노랑 버전으로(카카오 페이지에서만) */ +.kakao-page .btn.btn--primary{ + background: var(--kakao-yellow); + color: var(--kakao-black); + border: 1px solid rgba(0,0,0,.10); +} +.kakao-page .btn.btn--primary:hover{ + filter: brightness(.98); +} +.kakao-page .btn.btn--ghost{ + border-color: rgba(0,0,0,.12); +} + +/* Quick 카드에도 작은 포인트 */ +.kakao-page .cs-quick__bar{ + background: linear-gradient(135deg, var(--kakao-yellow-soft), rgba(0,0,0,.02)); + border: 1px solid rgba(0,0,0,.08); +} +.kakao-page .cs-quick__card:hover{ + border-color: rgba(254,229,0,.55); +} +.btn{ + display:inline-flex; + align-items:center; + justify-content:center; + height:44px; + padding: 0 14px; + border-radius: 14px; + font-weight: 900; + text-decoration:none; +} + +/* ========================================================= + FAQ (CS) - clean, modern, responsive +========================================================= */ +.faq { margin-top: 16px; } + +.faq-tools{ + display:flex; + flex-direction:column; + gap:12px; + margin-bottom: 14px; +} + +.faq-search{ + position:relative; + width:100%; +} + +.faq-search__input{ + width:100%; + height:44px; + border-radius:14px; + border:1px solid rgba(0,0,0,.10); + background:#fff; + padding:0 44px 0 14px; + outline:none; + font-weight:700; +} + +.faq-search__input:focus{ + border-color: rgba(0,0,0,.22); + box-shadow: 0 0 0 3px rgba(0,0,0,.06); +} + +.faq-search__icon{ + position:absolute; + right:12px; + top:50%; + transform:translateY(-50%); + opacity:.55; + font-size:16px; +} + +.faq-cats{ + display:flex; + flex-wrap:wrap; /* ✅ 넘치면 줄바꿈 */ + justify-content:center; /* ✅ 중앙정렬 */ + gap:8px; +} + +.faq-cat{ + height:36px; + padding:0 12px; + border-radius:999px; + border:1px solid rgba(0,0,0,.10); + background: rgba(0,0,0,.02); /* DG 느낌 */ + color: rgba(0,0,0,.82); + font-weight:800; + cursor:pointer; +} + +.faq-cat.is-active{ + background: rgba(0,0,0,.86); /* DG 강조 */ + color:#fff; + border-color: rgba(0,0,0,.86); +} + +.faq-list{ + display:flex; + flex-direction:column; + gap:10px; +} + +.faq-item{ + border:1px solid rgba(0,0,0,.10); + background:#fff; + border-radius:16px; + overflow:hidden; +} + +.faq-q{ + list-style:none; + display:flex; + align-items:center; + gap:10px; + padding:14px 14px; + cursor:pointer; + user-select:none; +} + +.faq-q::-webkit-details-marker{ display:none; } + +.faq-q__badge{ + width:26px; + height:26px; + display:inline-flex; + align-items:center; + justify-content:center; + border-radius:10px; + background: rgba(0,0,0,.06); + font-weight:900; +} + +.faq-q__text{ + flex:1; + font-weight:600; + font-size:16px; + letter-spacing:-0.02em; +} + +.faq-q__chev{ + opacity:.6; + transition: transform .15s ease; +} + +.faq-item[open] .faq-q__chev{ transform: rotate(180deg); } + +.faq-a{ + padding: 0 14px 14px 14px; + color: rgba(0,0,0,.76); + line-height:1.7; + font-weight:600; +} + +.faq-empty{ + margin-top: 8px; + padding:14px; + border-radius:14px; + border:1px dashed rgba(0,0,0,.16); + background: rgba(0,0,0,.02); + text-align:center; + color: rgba(0,0,0,.60); + font-weight:800; +} + +/* ========================================================= + CS Guide (refined color + modern) +========================================================= */ +:root{ + --g-brand: #0b63ff; /* 사이트 블루 */ + --g-ink: rgba(0,0,0,.88); + --g-muted: rgba(0,0,0,.62); + --g-line: rgba(0,0,0,.10); + --g-soft: rgba(11,99,255,.08); + --g-shadow: 0 10px 28px rgba(0,0,0,.07); + --g-shadow2: 0 14px 34px rgba(0,0,0,.10); +} + +.guide { margin-top: 14px; } + +/* Hero */ +.guide-hero{ + border: 1px solid var(--g-line); + background: + radial-gradient(1200px 300px at 10% 0%, rgba(11,99,255,.14), transparent 55%), + radial-gradient(900px 260px at 100% 10%, rgba(255,199,0,.10), transparent 55%), + linear-gradient(135deg, rgba(255,255,255,1), rgba(0,0,0,.01)); + border-radius: 20px; + padding: 18px; + box-shadow: 0 10px 26px rgba(0,0,0,.05); +} + +.guide-hero__badge{ + display:inline-flex; + align-items:center; + height:30px; + padding:0 12px; + border-radius:999px; + font-weight:900; + letter-spacing:-0.02em; + background: linear-gradient(135deg, var(--g-brand), #2aa6ff); + color:#fff; + box-shadow: 0 10px 22px rgba(11,99,255,.22); +} + +.guide-hero__title{ + margin-top: 10px; + font-size: 18px; + font-weight: 950; + letter-spacing:-0.03em; + color: var(--g-ink); +} + +.guide-hero__desc{ + margin-top: 8px; + color: var(--g-muted); + line-height: 1.75; + font-weight: 650; + max-width: 72ch; +} + +.guide-hero__chips{ + margin-top: 12px; + display:flex; + flex-wrap:wrap; + gap:8px; +} + +.guide-chip{ + display:inline-flex; + align-items:center; + min-height:34px; + padding:0 12px; + border-radius: 999px; + border: 1px solid rgba(11,99,255,.14); + background: rgba(11,99,255,.06); + font-weight: 850; + color: rgba(0,0,0,.76); +} + +/* Grid */ +.guide-grid{ + margin-top: 12px; + display:grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +/* Card base */ +.guide-card{ + border: 1px solid var(--g-line); + background:#fff; + border-radius: 20px; + padding: 16px; + box-shadow: var(--g-shadow); + transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease; +} + +.guide-card:hover{ + transform: translateY(-2px); + box-shadow: var(--g-shadow2); + border-color: rgba(11,99,255,.18); +} + +.guide-card__top{ + display:flex; + align-items:center; + gap:10px; +} + +.guide-card__icon{ + width:38px; + height:38px; + border-radius: 14px; + display:flex; + align-items:center; + justify-content:center; + font-weight: 950; + color: rgba(0,0,0,.82); + border: 1px solid rgba(0,0,0,.08); + background: rgba(0,0,0,.03); +} + +.guide-card__title{ + margin:0; + font-size: 16px; + font-weight: 950; + letter-spacing:-0.03em; + color: var(--g-ink); +} + +.guide-card__lead{ + margin-top: 10px; + color: var(--g-muted); + line-height: 1.75; + font-weight: 650; +} + +/* Steps & bullets */ +.guide-steps, .guide-bullets{ + margin-top: 10px; + padding-left: 18px; + color: rgba(0,0,0,.70); + line-height: 1.75; + font-weight: 650; +} + +.guide-steps li, .guide-bullets li{ margin: 7px 0; } +.guide-steps b, .guide-bullets b{ color: rgba(0,0,0,.90); } + +/* Note */ +.guide-note{ + margin-top: 12px; + border-radius: 16px; + padding: 11px 12px; + border: 1px solid rgba(11,99,255,.14); + background: rgba(11,99,255,.06); + color: rgba(0,0,0,.72); + line-height:1.65; + font-weight: 750; +} + +.guide-note--muted{ + border-color: rgba(0,0,0,.10); + background: rgba(0,0,0,.02); + color: rgba(0,0,0,.68); +} + +/* Footer */ +.guide-footer{ + margin-top: 12px; +} + +.guide-footer__box{ + border: 1px solid var(--g-line); + background: + radial-gradient(900px 260px at 0% 0%, rgba(11,99,255,.10), transparent 55%), + linear-gradient(135deg, rgba(255,255,255,1), rgba(0,0,0,.01)); + border-radius: 20px; + padding: 16px; + box-shadow: var(--g-shadow); +} + +.guide-footer__title{ + font-weight: 950; + letter-spacing:-0.03em; + color: var(--g-ink); +} + +.guide-footer__desc{ + margin-top: 6px; + color: var(--g-muted); + line-height: 1.75; + font-weight: 650; +} + +/* --- Make each card feel different (subtle accent) --- */ +.guide-card:nth-child(1) .guide-card__icon{ + background: rgba(11,99,255,.08); + border-color: rgba(11,99,255,.16); +} +.guide-card:nth-child(2) .guide-card__icon{ + background: rgba(0,200,120,.10); + border-color: rgba(0,200,120,.18); +} +.guide-card:nth-child(3) .guide-card__icon{ + background: rgba(255,170,0,.12); + border-color: rgba(255,170,0,.22); +} +.guide-card:nth-child(4) .guide-card__icon{ + background: rgba(160,80,255,.10); + border-color: rgba(160,80,255,.18); +} + +/* Responsive */ +@media (max-width: 960px){ + .guide-grid{ grid-template-columns: 1fr; } + .guide-hero{ padding: 16px; } +} + +.guide-card__icon{ color: rgba(11,99,255,.92); } + + +/* ========================================================= + CS QnA (UI only) — refined color + modern +========================================================= */ +:root{ + --q-brand: #0b63ff; + --q-ink: rgba(0,0,0,.88); + --q-muted: rgba(0,0,0,.62); + --q-line: rgba(0,0,0,.10); + --q-soft: rgba(11,99,255,.08); + --q-soft2: rgba(255,199,0,.10); + --q-shadow: 0 10px 28px rgba(0,0,0,.07); + --q-shadow2: 0 14px 34px rgba(0,0,0,.10); +} + +.qna{ margin-top: 12px; } + +/* Top */ +.qna-top{ + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + margin-bottom: 12px; +} + +.qna-top__hint{ + flex:1; + border:1px solid rgba(11,99,255,.14); + background: + radial-gradient(900px 240px at 0% 0%, rgba(11,99,255,.14), transparent 55%), + radial-gradient(900px 240px at 100% 0%, rgba(255,199,0,.10), transparent 55%), + linear-gradient(135deg, rgba(255,255,255,1), rgba(0,0,0,.01)); + border-radius: 18px; + padding:12px 12px; + font-weight:850; + color: rgba(0,0,0,.76); + line-height:1.5; + box-shadow: 0 8px 22px rgba(0,0,0,.05); +} + +.qna-pill{ + display:inline-flex; + align-items:center; + height:26px; + padding:0 12px; + border-radius:999px; + background: linear-gradient(135deg, var(--q-brand), #2aa6ff); + color:#fff; + font-weight:950; + margin-right:8px; + letter-spacing:-0.02em; + box-shadow: 0 10px 22px rgba(11,99,255,.22); +} + +/* Card */ +.qna-card{ + border:1px solid var(--q-line); + background:#fff; + border-radius: 20px; + overflow:hidden; + box-shadow: var(--q-shadow); +} + +.qna-card__head{ + padding:15px 15px 13px; + border-bottom:1px solid rgba(0,0,0,.08); + background: + radial-gradient(900px 260px at 0% 0%, rgba(11,99,255,.10), transparent 55%), + linear-gradient(135deg, rgba(255,255,255,1), rgba(0,0,0,.01)); +} + +.qna-card__title{ + margin:0; + font-size: 16px; + font-weight: 950; + letter-spacing:-0.03em; + color: var(--q-ink); +} + +.qna-card__desc{ + margin:7px 0 0; + color: var(--q-muted); + line-height: 1.65; + font-weight: 650; +} + +/* Form */ +.qna-form{ padding:15px; } + +.qna-grid{ + display:grid; + grid-template-columns: 1fr 1fr; + gap:12px; +} + +.qna-field--mt{ margin-top: 12px; } + +.qna-label{ + display:flex; + align-items:center; + gap:6px; + font-weight: 950; + letter-spacing:-0.02em; + color: var(--q-ink); +} + +.qna-req{ color:#d14b4b; font-weight: 950; } +.qna-sub{ color: rgba(0,0,0,.55); font-weight: 850; } + +/* Inputs */ +.qna-input{ + margin-top:8px; + width:100%; + height:46px; + border-radius:16px; + border:1px solid rgba(0,0,0,.10); + background:#fff; + padding:0 12px; + font-weight:850; + outline:none; + transition: box-shadow .15s ease, border-color .15s ease, background .15s ease; +} + +.qna-input:focus{ + border-color: rgba(11,99,255,.30); + box-shadow: 0 0 0 4px rgba(11,99,255,.12); +} + +.qna-textarea{ + margin-top:8px; + width:100%; + min-height: 180px; + border-radius:16px; + border:1px solid rgba(0,0,0,.10); + background:#fff; + padding:12px; + font-weight:750; + line-height:1.75; + outline:none; + resize: vertical; + transition: box-shadow .15s ease, border-color .15s ease; +} + +.qna-textarea:focus{ + border-color: rgba(11,99,255,.30); + box-shadow: 0 0 0 4px rgba(11,99,255,.12); +} + +.qna-help{ + margin-top:8px; + color: rgba(0,0,0,.64); + font-weight:650; + line-height:1.55; +} + +/* File */ +.qna-file{ + margin-top:8px; + width:100%; + height:46px; + padding: 9px 10px; + border-radius:16px; + border:1px solid rgba(0,0,0,.10); + background: rgba(0,0,0,.02); + font-weight:850; +} + +.qna-filelist{ + margin-top:10px; + display:flex; + flex-direction:column; + gap:7px; +} + +.qna-filelist__item{ + padding:9px 11px; + border-radius:16px; + border:1px solid rgba(11,99,255,.12); + background: rgba(11,99,255,.05); + font-weight:850; + color: rgba(0,0,0,.76); +} + +.qna-filelist__warn{ + padding:10px 12px; + border-radius:16px; + border:1px dashed rgba(0,0,0,.20); + background: rgba(0,0,0,.02); + color: rgba(0,0,0,.68); + font-weight:950; +} + +/* Choice */ +.qna-choice{ + margin-top:10px; + display:flex; + flex-wrap:wrap; + gap:10px 14px; + align-items:center; +} + +.qna-check, .qna-radio{ + display:flex; + align-items:center; + gap:8px; + font-weight:850; + color: rgba(0,0,0,.78); +} + +.qna-check input, .qna-radio input{ transform: translateY(1px); } + +/* Recap placeholder */ +.qna-recap{ + display:flex; + align-items:center; + justify-content:space-between; + gap:10px; + border:1px solid rgba(11,99,255,.14); + background: + radial-gradient(900px 240px at 0% 0%, rgba(11,99,255,.12), transparent 55%), + linear-gradient(135deg, rgba(255,255,255,1), rgba(0,0,0,.01)); + border-radius:18px; + padding:12px; +} + +.qna-recap__badge{ + display:inline-flex; + align-items:center; + height:28px; + padding:0 12px; + border-radius:999px; + background: rgba(0,0,0,.86); + color:#fff; + font-weight:950; +} + +.qna-recap__text{ + color: rgba(0,0,0,.70); + font-weight:750; + line-height:1.55; +} + +/* Actions */ +.qna-actions{ + margin-top: 14px; + display:flex; + flex-direction:column; + align-items:center; + gap:10px; +} + +.qna-btn{ + display:inline-flex; + align-items:center; + justify-content:center; + height:46px; + padding:0 14px; + border-radius:16px; + text-decoration:none; + font-weight:950; + border:1px solid rgba(0,0,0,.10); + background:#fff; + color: rgba(0,0,0,.86); + cursor:pointer; + transition: transform .12s ease, box-shadow .12s ease, background .12s ease, border-color .12s ease; +} + +.qna-btn:hover{ + transform: translateY(-1px); + box-shadow: 0 12px 26px rgba(0,0,0,.10); +} + +.qna-btn--primary{ + width:min(360px, 100%); + background: linear-gradient(135deg, var(--q-brand), #2aa6ff); + color:#fff; + border-color: rgba(11,99,255,.18); +} + +.qna-btn--ghost{ + background: rgba(0,0,0,.02); +} + +.qna-top__actions .qna-btn{ height:42px; } + +.qna-actions__note{ + color: rgba(0,0,0,.58); + font-weight:750; +} + +/* Responsive */ +@media (max-width: 960px){ + .qna-top{ flex-direction:column; align-items:stretch; } + .qna-grid{ grid-template-columns: 1fr; } +} + + +/* ========================================================= + AUTH (Clean & Centered) + - delete your old Auth Layout/Auth Forms block and paste this +========================================================= */ + +/* a11y */ +.sr-only{ + position:absolute!important; + width:1px!important;height:1px!important; + padding:0!important;margin:-1px!important; + overflow:hidden!important;clip:rect(0,0,0,0)!important; + white-space:nowrap!important;border:0!important; +} + +/* page */ +.auth-body{ + background: + radial-gradient(900px 380px at 10% -10%, rgba(11,99,255,.12), transparent 55%), + radial-gradient(800px 320px at 90% -10%, rgba(255,199,0,.10), transparent 55%), + #f6f8ff; + min-height:100dvh; +} + +/* ✅ 중앙 박스형 리듬 */ +.auth-page{ + padding: 40px 16px 72px; +} +@media (max-width: 960px){ + .auth-page{ padding: 22px 12px 48px; } +} + +/* wrapper */ +.auth-wrap{ + max-width: 520px; + margin: 0 auto; +} + +/* card */ +.auth-card{ + border: 1px solid rgba(0,0,0,.12) !important; + background: #fff !important; + border-radius: 18px !important; + box-shadow: 0 14px 36px rgba(0,0,0,.10) !important; + overflow: hidden; + border-color: rgba(11,99,255,.18) !important; +} +/* head */ +.auth-card__head{ + padding: 20px 20px 14px; + background: rgba(0,0,0,.02); + border-bottom: 1px solid rgba(0,0,0,.08); +} + +.auth-title{ + margin:0; + font-size: 20px; + font-weight: 950; + letter-spacing: -0.03em; + color: rgba(0,0,0,.90); +} + +.auth-desc{ + margin: 8px 0 0; + color: rgba(0,0,0,.62); + font-weight: 650; + line-height: 1.6; +} + +/* body/foot padding */ +.auth-card__body{ padding: 18px 20px 10px; } +.auth-card__foot{ + padding: 14px 20px 18px; + border-top: 1px solid rgba(0,0,0,.08); + background: rgba(0,0,0,.015); +} + +@media (max-width: 480px){ + .auth-card{ border-radius: 20px; } + .auth-card__head{ padding: 16px 16px 12px; } + .auth-card__body{ padding: 14px 16px 8px; } + .auth-card__foot{ padding: 12px 16px 14px; } + .auth-title{ font-size: 18px; } +} + +/* ========================================================= + AUTH FORM +========================================================= */ +.auth-form{ + display:flex; + flex-direction:column; + gap: 14px; +} + +.auth-field{ + display:flex; + flex-direction:column; + gap: 8px; +} + +.auth-label{ + font-weight: 900; + letter-spacing:-0.02em; + color: rgba(0,0,0,.86); + display:flex; + align-items:center; + gap:8px; +} + +.auth-label small{ + font-weight:800; + color: rgba(0,0,0,.52); +} + +/* inputs */ +.auth-input{ + width:100%; + height: 48px; + border-radius: 14px; + border: 1px solid rgba(0,0,0,.10); + background: #fff; + padding: 0 14px; + font-weight: 800; + outline:none; + transition: border-color .15s ease, box-shadow .15s ease, transform .12s ease; +} + +.auth-input::placeholder{ + color: rgba(0,0,0,.34); + font-weight: 700; +} + +.auth-input:focus{ + border-color: rgba(11,99,255,.35); + box-shadow: 0 0 0 4px rgba(11,99,255,.12); +} + +.auth-row{ + display:flex; + align-items:center; + justify-content:space-between; + gap: 10px; + flex-wrap:wrap; + margin-top: 2px; +} + +.auth-check{ + display:flex; + align-items:center; + gap: 8px; + font-weight: 800; + color: rgba(0,0,0,.70); +} +.auth-check input{ transform: translateY(1px); } + +/* ✅ 링크 전용 줄 */ +.auth-links-inline{ + display:flex; + align-items:center; + gap: 8px; + flex-wrap:wrap; +} + +.auth-link{ + text-decoration:none; + font-weight: 900; + color: rgba(0,0,0,.72); +} +.auth-link:hover{ color: rgba(11,99,255,.92); } + +.auth-dot{ + color: rgba(0,0,0,.32); + font-weight: 900; +} + +/* actions */ +.auth-actions{ + display:flex; + flex-direction:column; + gap: 10px; + margin-top: 6px; +} + +.auth-btn{ + height: 48px; + border-radius: 14px; + border: 1px solid rgba(0,0,0,.10); + background: #fff; + font-weight: 950; + cursor:pointer; + text-decoration:none; + display:flex; + align-items:center; + justify-content:center; + transition: transform .12s ease, box-shadow .12s ease, background .12s ease; +} + +.auth-btn:hover{ + transform: translateY(-1px); + box-shadow: 0 14px 30px rgba(0,0,0,.10); +} + +.auth-btn--primary{ + background: linear-gradient(135deg, #0b63ff, #2aa6ff); + color:#fff; + border-color: rgba(11,99,255,.18); +} + +.auth-btn--ghost{ + background: rgba(0,0,0,.02); + color: rgba(0,0,0,.86); +} +.auth-btn--ghost:hover{ background: rgba(0,0,0,.04); } + +.auth-help{ + margin-top: 4px; + font-size: 13px; + color: rgba(0,0,0,.58); + line-height: 1.6; + font-weight: 650; +} + +/* bottom links */ +.auth-links{ + display:flex; + align-items:center; + justify-content:center; + gap: 10px; + flex-wrap:wrap; +} + +/* ========================================================= + AUTH SHELL (left+right) +========================================================= */ +.auth-shell{ + max-width: 980px; /* 유지 */ + margin: 0 auto; + display: flex; /* grid -> flex 로 오버라이드 */ + justify-content: center; +} + +@media (max-width: 960px){ + .auth-shell{ grid-template-columns: 1fr; } + .auth-aside{ display:none; } +} + +/* Left panel */ +.auth-aside{ + border: 1px solid rgba(0,0,0,.06); + background: + radial-gradient(900px 320px at 0% 0%, rgba(11,99,255,.10), transparent 60%), + radial-gradient(700px 260px at 100% 0%, rgba(255,199,0,.10), transparent 60%), + rgba(255,255,255,.70); + border-radius: 22px; + padding: 22px; + box-shadow: 0 12px 30px rgba(0,0,0,.06); +} + +.auth-aside__headline{ + margin: 0; + font-size: 22px; + font-weight: 950; + letter-spacing: -0.03em; + color: rgba(0,0,0,.90); +} + +.auth-aside__desc{ + margin: 10px 0 0; + color: rgba(0,0,0,.62); + font-weight: 650; + line-height: 1.7; +} + +.auth-aside__bullets{ + margin: 14px 0 0; + padding-left: 18px; + color: rgba(0,0,0,.72); + font-weight: 700; + line-height: 1.8; +} +.auth-aside__bullets b{ color: rgba(0,0,0,.88); } + +/* ========================================================= + AUTH MINI HELP (under form) +========================================================= */ +.auth-minihelp{ + margin-top: 14px; + border-top: 1px solid rgba(0,0,0,.06); + padding-top: 12px; + display:flex; + flex-direction:column; + gap: 8px; +} + +.auth-minihelp__pill{ + display:inline-flex; + width: fit-content; + height: 26px; + align-items:center; + padding: 0 10px; + border-radius: 999px; + background: rgba(11,99,255,.08); + color: rgba(11,99,255,.92); + font-weight: 900; +} + +.auth-minihelp__text{ + color: rgba(0,0,0,.62); + font-weight: 650; + line-height: 1.6; +} + +.auth-minihelp__links{ + display:flex; + align-items:center; + gap: 8px; + flex-wrap:wrap; +} + +.auth-minihelp__link{ + text-decoration:none; + font-weight: 900; + color: rgba(0,0,0,.72); +} +.auth-minihelp__link:hover{ color: rgba(11,99,255,.92); } + +.auth-minihelp__dot{ + color: rgba(0,0,0,.30); + font-weight: 900; +} + +/* ✅ Auth: Desktop에서만 +150px 넓게 */ +@media (min-width: 961px){ + .auth-container{ + max-width: 410px !important; /* (기존 560px 기준 +150) */ + } + .auth-card__head, + .auth-card__body, + .auth-card__foot{ + padding-left: 26px; + padding-right: 26px; + } + .auth-page .auth-shell{ + max-width: 410px; /* 기존보다 +150 정도로 조정 (560 -> 710 예시) */ + width: 100%; + margin: 0 auto; + } + + .auth-page .auth-card{ + max-width: 410px; + width: 100%; + } +} + +/* ✅ Mobile은 기존 유지(혹시 덮였으면 안전하게) */ +@media (max-width: 960px){ + .auth-container{ + max-width: 100% !important; + } +} diff --git a/resources/views/web/auth/find_id.blade.php b/resources/views/web/auth/find_id.blade.php new file mode 100644 index 0000000..039e0f6 --- /dev/null +++ b/resources/views/web/auth/find_id.blade.php @@ -0,0 +1,390 @@ +@extends('web.layouts.auth') + +@section('title', '아이디 찾기 | PIN FOR YOU') +@section('meta_description', 'PIN FOR YOU 아이디(이메일) 찾기 페이지입니다.') +@section('canonical', url('/auth/find-id')) + +@section('h1', '아이디 찾기') +@section('desc', '가입 시 등록한 휴대폰 번호로 아이디를 확인할 수 있어요.') +@section('card_aria', '아이디 찾기 폼') +@section('show_cs_links', true) + +@section('auth_content') +
+@endsection + +@section('auth_bottom') + {{-- 필요 시 하단에 추가 문구/링크를 넣고 싶으면 여기 --}} +@endsection + +@push('scripts') + +@endpush + + diff --git a/resources/views/web/auth/login.blade.php b/resources/views/web/auth/login.blade.php new file mode 100644 index 0000000..60cf007 --- /dev/null +++ b/resources/views/web/auth/login.blade.php @@ -0,0 +1,54 @@ +@extends('web.layouts.auth') + +@section('title', '로그인 | PIN FOR YOU') +@section('meta_description', 'PIN FOR YOU 로그인 페이지입니다.') +@section('canonical', url('/auth/login')) + +@section('h1', '로그인') +@section('desc', '안전한 거래를 위해 로그인해 주세요.') +@section('headline', '안전한 핀 발송/거래') +@section('subheadline', '로그인 후 구매/문의 내역을 빠르게 확인할 수 있어요.') +@section('card_aria', '로그인 폼') + +@section('auth_content') + +@endsection + +@section('auth_bottom') + +@endsection diff --git a/resources/views/web/auth/register.blade.php b/resources/views/web/auth/register.blade.php new file mode 100644 index 0000000..9341ae6 --- /dev/null +++ b/resources/views/web/auth/register.blade.php @@ -0,0 +1,74 @@ +@extends('web.layouts.auth') + +@section('title', '회원가입 | PIN FOR YOU') +@section('meta_description', 'PIN FOR YOU 회원가입 페이지입니다.') +@section('canonical', url('/auth/register')) + +@section('h1', '회원가입') +@section('desc', '간단한 정보 입력 후 본인인증을 진행합니다.') +@section('card_aria', '회원가입 폼') +@section('show_cs_links', true) + +@section('auth_content') + +@endsection + +@section('auth_bottom') + {{-- 로그인 페이지처럼 하단에 CS 링크 들어가게 하고 싶으면(선택) --}} + {{-- auth 레이아웃에서 show_cs_links=true 처리 중이면 이 섹션은 비워도 됩니다. --}} +@endsection diff --git a/resources/views/web/cs/faq/index.blade.php b/resources/views/web/cs/faq/index.blade.php index fb26ca1..34c290e 100644 --- a/resources/views/web/cs/faq/index.blade.php +++ b/resources/views/web/cs/faq/index.blade.php @@ -18,14 +18,103 @@ @section('canonical', url('/cs/faq')) @section('subcontent') -+ 회원가입 → 로그인 → 상품 선택/결제 → 코드 확인까지, 기본 흐름은 아주 간단해요. + 처음 1회는 보안을 위해 본인인증이 진행될 수 있습니다. +
+ ++ 안정적인 서비스 제공과 정보 보호를 위해 회원가입 시 최초 1회 본인인증이 진행될 수 있어요. +
+ ++ 이메일(ID)과 비밀번호로 로그인합니다. 보안을 위해 자동로그인/공용기기 사용에 유의해 주세요. +
+ ++ 원하는 상품을 선택한 뒤 수량과 결제수단을 선택해 결제를 완료하면 됩니다. +
+ ++ 구매/발송/이용 관련 이슈는 1:1 문의로 접수하면 가장 정확하게 안내받을 수 있어요. +
+ ++ 카카오톡에서 “핀포유” 또는 “@pinforyou”를 검색해 채널을 추가한 뒤, + 1:1 메시지로 문의를 남겨주세요. +
+ + + +
--}}
+ 답변 속도를 높이려면 “주문시각/결제수단/금액/오류문구”를 같이 적어주세요.
+@yield('desc')
+ @else ++ @yield('card_desc', '아이디(이메일)과 비밀번호를 입력해 주세요.') +
+ @endif +