This commit is contained in:
Bayu Lukman Yusuf 2026-01-13 13:45:34 +07:00
parent b8c34bf00a
commit 9c8ebdfabe
7 changed files with 319 additions and 35 deletions

View File

@ -9,6 +9,8 @@ class LoginEmailController extends Controller
{ {
public function index() public function index()
{ {
return view('account.signin-email'); return view('account.signin',[
'type' => 'email',
]);
} }
} }

View File

@ -3,12 +3,117 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Repositories\Member\Auth\MemberAuthRepository;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class LoginWaController extends Controller class LoginWaController extends Controller
{ {
protected $memberAuthRepository;
public function __construct(MemberAuthRepository $memberAuthRepository)
{
$this->memberAuthRepository = $memberAuthRepository;
}
public function index() public function index()
{ {
return view('account.signin'); return view('account.signin', [
'type' => 'phone',
]);
}
public function otp(Request $request)
{
$validator = Validator::make($request->all(), [
'identity' => 'required|string|min:10|max:15',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => __('otp.invalid_phone'),
'errors' => $validator->errors()
], 422);
}
$identity = $request->identity;
try {
// Use MemberAuthRepository to generate OTP
$otp = $this->memberAuthRepository->waOtp(['phone' => $identity]);
// TODO: Integrate with WhatsApp API to send OTP
// For now, we'll just log it (remove in production)
Log::info("OTP for {$identity}: {$otp->otp}");
return response()->json([
'success' => true,
'message' => __('otp.sent'),
'redirect' => route('login-phone.otp.view', ['identity' => $identity])
]);
} catch (\Exception $e) {
Log::error("OTP generation failed: " . $e->getMessage());
return response()->json([
'success' => false,
'message' => __('otp.generate_failed')
], 500);
}
}
public function otpView($identity)
{
return view('account.otp', [
'identity' => $identity
]);
}
public function verify(Request $request)
{
$validator = Validator::make($request->all(), [
'identity' => 'required|string|min:10|max:15',
'otp' => 'required|string|size:6',
]);
if ($validator->fails()) {
return back()
->withErrors($validator)
->withInput();
}
$identity = $request->identity;
$otp = $request->otp;
try {
// Use MemberAuthRepository to verify OTP
$result = $this->memberAuthRepository->waOtpConfirm([
'phone' => $identity,
'otp' => $otp
]);
// TODO: Authenticate user or create new user
// For now, we'll just redirect to dashboard
// In production, you would:
// 1. Find or create user by phone number
// 2. Log them in
// 3. Redirect to intended page
return redirect()->route('home')->with('success', __('otp.login_success'));
} catch (\Illuminate\Validation\ValidationException $e) {
return back()
->withErrors(['otp' => $e->getMessage()])
->withInput();
} catch (\Exception $e) {
Log::error("OTP verification failed: " . $e->getMessage());
return back()
->withErrors(['otp' => __('otp.verification_failed')])
->withInput();
}
} }
} }

35
lang/en/otp.php Normal file
View File

@ -0,0 +1,35 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| OTP Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during OTP authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'title' => 'Verify Your Phone',
'description' => 'We sent a 6-digit code to :phone',
'enter_code' => 'Enter verification code',
'invalid_code' => 'Please enter a valid 6-digit code',
'verify' => 'Verify',
'back_to_login' => 'Back to login',
'resend_code' => 'Resend code',
'resend_in' => 'Resend in',
'need_help' => 'Need help?',
'invalid_phone' => 'Please enter a valid phone number',
'sent' => 'OTP sent successfully',
'expired' => 'OTP has expired',
'max_attempts' => 'Maximum attempts reached. Please request a new OTP',
'invalid' => 'Invalid OTP code',
'login_success' => 'Login successful',
'resend_failed' => 'Failed to resend OTP. Please try again',
'generate_failed' => 'Failed to generate OTP. Please try again',
'verification_failed' => 'OTP verification failed. Please try again',
];

View File

@ -1,9 +1,9 @@
@extends('layouts.landing', ['title' => 'Account - Sign In']) @extends('layouts.landing', ['title' => 'Account - OTP Verification'])
@section('content') @section('content')
<main class="content-wrapper w-100 px-3 ps-lg-5 pe-lg-4 mx-auto" style="max-width: 1920px"> <main class="content-wrapper w-100 px-3 ps-lg-5 pe-lg-4 mx-auto" style="max-width: 1920px">
<div class="d-lg-flex"> <div class="d-lg-flex">
<!-- Login form + Footer --> <!-- OTP form + Footer -->
<div class="d-flex flex-column min-vh-100 w-100 py-4 mx-auto me-lg-5" style="max-width: 416px"> <div class="d-flex flex-column min-vh-100 w-100 py-4 mx-auto me-lg-5" style="max-width: 416px">
<!-- Logo --> <!-- Logo -->
<header class="navbar px-0 pb-4 mt-n2 mt-sm-0 mb-2 mb-md-3 mb-lg-4"> <header class="navbar px-0 pb-4 mt-n2 mt-sm-0 mb-2 mb-md-3 mb-lg-4">
@ -30,39 +30,55 @@
AsiaGolf AsiaGolf
</a> </a>
</header> </header>
<h1 class="h2 mt-auto">{{ __('signin.title') }}</h1> <h1 class="h2 mt-auto">{{ __('otp.title') }}</h1>
<div class="nav fs-sm mb-4"> <p class="text-muted mb-4">{{ __('otp.description', ['phone' => $identity]) }}</p>
{{ __('signin.no_account') }}
<a class="nav-link text-decoration-underline p-0 ms-2"
href="{{ route('register') }}">{{ __('signin.create_account') }}</a> {{-- show message if error --}}
@if ($errors->has('otp'))
<div class="alert alert-danger">
{{ $errors->first('otp') }}
</div> </div>
@endif
<!-- Form --> <!-- Form -->
<form class="needs-validation" novalidate=""> <form class="needs-validation" novalidate method="POST" action="{{ route('login-phone.verify') }}">
@csrf
<input type="hidden" name="identity" value="{{ $identity }}">
<div class="position-relative mb-4"> <div class="position-relative mb-4">
<input class="form-control form-control-lg" placeholder="{{ __('signin.email_placeholder') }}" required="" type="email" /> <label for="otp" class="form-label">{{ __('otp.enter_code') }}</label>
<div class="invalid-tooltip bg-transparent py-0">{{ __('signin.email_invalid') }}</div> <input class="form-control form-control-lg" type="text"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
required=""
name="otp"
id="otp"
autocomplete="one-time-code" />
<div class="invalid-tooltip bg-transparent py-0">{{ __('otp.invalid_code') }}</div>
</div> </div>
<div class="d-flex align-items-center justify-content-end mb-4"> <div class="d-flex align-items-center justify-content-between mb-4">
{{-- <div class="form-check me-2">
<input class="form-check-input" id="remember-30" type="checkbox" />
<label class="form-check-label" for="remember-30">{{ __('signin.remember_for_30_days') }}</label>
</div> --}}
<div class="nav"> <div class="nav">
<a class="nav-link animate-underline p-0" <a class="nav-link animate-underline p-0" href="{{ route('login-phone') }}">
href="{{ route('login') }}"> <span class="animate-target">{{ __('otp.back_to_login') }}</span>
<span class="animate-target">{{ __('signin.login_with_phone') }}</span>
</a> </a>
</div> </div>
<div class="nav">
<button type="button" class="btn btn-link p-0" id="resend-otp">
<span class="animate-target">{{ __('otp.resend_code') }}</span>
</button>
</div> </div>
<button class="btn btn-lg btn-primary w-100" type="submit">{{ __('signin.sign_in') }}</button> </div>
<button class="btn btn-lg btn-primary w-100" type="submit">{{ __('otp.verify') }}</button>
</form> </form>
<x-social-login />
<!-- Footer --> <!-- Footer -->
<footer class="mt-auto"> <footer class="mt-auto">
<div class="nav mb-4"> <div class="nav mb-4">
<a class="nav-link text-decoration-underline p-0" <a class="nav-link text-decoration-underline p-0"
href="{{ route('second', ['help', 'topics-v1']) }}">{{ __('signin.need_help') }}</a> href="{{ route('second', ['help', 'topics-v1']) }}">{{ __('otp.need_help') }}</a>
</div> </div>
<p class="fs-xs mb-0"> <p class="fs-xs mb-0">
© All rights reserved. Made by <span class="animate-underline"><a © All rights reserved. Made by <span class="animate-underline"><a
@ -88,4 +104,61 @@
@endsection @endsection
@section('scripts') @section('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
const otpInput = document.getElementById('otp');
const resendBtn = document.getElementById('resend-otp');
let resendTimer = null;
let countdown = 60;
// Auto-focus OTP input
otpInput.focus();
// Resend OTP functionality
resendBtn.addEventListener('click', function() {
if (resendTimer) return;
const identity = document.querySelector('input[name="identity"]').value;
fetch('{{ route("login-phone.otp") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
identity: identity
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Start countdown
resendBtn.disabled = true;
countdown = 60;
resendTimer = setInterval(function() {
countdown--;
resendBtn.textContent = '{{ __("otp.resend_in") }} ' + countdown + 's';
if (countdown <= 0) {
clearInterval(resendTimer);
resendTimer = null;
resendBtn.disabled = false;
resendBtn.innerHTML = '<span class="animate-target">{{ __("otp.resend_code") }}</span>';
}
}, 1000);
} else {
alert(data.message || '{{ __("otp.resend_failed") }}');
}
})
.catch(error => {
console.error('Error:', error);
alert('{{ __("otp.resend_failed") }}');
});
});
});
</script>
@endsection @endsection

View File

@ -37,10 +37,10 @@
href="{{ route('register') }}">{{ __('signin.create_account') }}</a> href="{{ route('register') }}">{{ __('signin.create_account') }}</a>
</div> </div>
<!-- Form --> <!-- Form -->
<form class="needs-validation" novalidate=""> <form class="needs-validation" id="loginForm" novalidate="">
<div class="position-relative mb-4"> <div class="position-relative mb-4">
<input class="form-control form-control-lg" placeholder="{{ __('signin.phone_placeholder') }}" required="" type="tel" /> <input class="form-control form-control-lg" placeholder="{{ $type == 'email' ? __('signin.email_placeholder') : __('signin.phone_placeholder') }}" required="" name="identity" id="identity" />
<div class="invalid-tooltip bg-transparent py-0">{{ __('signin.phone_invalid') }}</div> <div class="invalid-tooltip bg-transparent py-0">{{ $type == 'email' ? __('signin.email_invalid') : __('signin.phone_invalid') }}</div>
</div> </div>
<div class="d-flex align-items-center justify-content-end mb-4"> <div class="d-flex align-items-center justify-content-end mb-4">
@ -50,8 +50,8 @@
</div> --}} </div> --}}
<div class="nav"> <div class="nav">
<a class="nav-link animate-underline p-0" <a class="nav-link animate-underline p-0"
href="{{ route('login-email') }}"> href="{{ $type == 'email' ? route('login') : route('login-email') }}">
<span class="animate-target">{{ __('signin.login_with_email') }}</span> <span class="animate-target">{{ $type == 'email' ? __('signin.login_with_phone') : __('signin.login_with_email') }}</span>
</a> </a>
</div> </div>
</div> </div>
@ -88,4 +88,67 @@
@endsection @endsection
@section('scripts') @section('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
const loginForm = document.getElementById('loginForm');
const submitBtn = loginForm.querySelector('button[type="submit"]');
const identityInput = document.getElementById('identity');
loginForm.addEventListener('submit', function(e) {
e.preventDefault();
// Reset validation
loginForm.classList.remove('was-validated');
// Basic validation
if (!identityInput.value.trim()) {
loginForm.classList.add('was-validated');
return;
}
// Show loading state
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>{{ __("signin.sending") }}';
// Determine form type based on current route
const isPhoneLogin = '{{ $type }}' === 'phone';
if (isPhoneLogin) {
// Send OTP request for phone login
fetch('{{ route("login-phone.otp") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
identity: identityInput.value.trim()
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Redirect to OTP verification page
window.location.href = data.redirect;
} else {
// Show error
alert(data.message || '{{ __("signin.error") }}');
}
})
.catch(error => {
console.error('Error:', error);
alert('{{ __("signin.error") }}');
})
.finally(() => {
// Reset button state
submitBtn.disabled = false;
submitBtn.textContent = '{{ __("signin.sign_in") }}';
});
} else {
// Handle email login (traditional form submission)
loginForm.submit();
}
});
});
</script>
@endsection @endsection

View File

@ -8,7 +8,7 @@
<!-- Logo --> <!-- Logo -->
<header class="navbar px-0 pb-4 mt-n2 mt-sm-0 mb-2 mb-md-3 mb-lg-4"> <header class="navbar px-0 pb-4 mt-n2 mt-sm-0 mb-2 mb-md-3 mb-lg-4">
<a class="navbar-brand pt-0" href="/"> <a class="navbar-brand pt-0" href="/">
<img src="{{ asset('logo/logo-colored.png') }}" alt="Logo" /> <img src="{{ asset('logo/logo-colored.png') }}" alt="Logo" style="height:40px;" />
</a> </a>
</header> </header>
<h1 class="h2 mt-auto">{{ __('signup.title') }}</h1> <h1 class="h2 mt-auto">{{ __('signup.title') }}</h1>
@ -17,11 +17,11 @@
<a class="nav-link text-decoration-underline p-0 ms-2" <a class="nav-link text-decoration-underline p-0 ms-2"
href="{{ route('login') }}">{{ __('signup.sign_in') }}</a> href="{{ route('login') }}">{{ __('signup.sign_in') }}</a>
</div> </div>
<div class="nav fs-sm mb-4 d-lg-none"> {{-- <div class="nav fs-sm mb-4 d-lg-none">
<span class="me-2">{{ __('signup.uncertain_about_account') }}</span> <span class="me-2">{{ __('signup.uncertain_about_account') }}</span>
<a aria-controls="benefits" class="nav-link text-decoration-underline p-0" data-bs-toggle="offcanvas" <a aria-controls="benefits" class="nav-link text-decoration-underline p-0" data-bs-toggle="offcanvas"
href="#benefits">{{ __('signup.explore_benefits') }}</a> href="#benefits">{{ __('signup.explore_benefits') }}</a>
</div> </div> --}}
<!-- Form --> <!-- Form -->
<form class="needs-validation" novalidate="" method="POST" action="{{ route('register') }}"> <form class="needs-validation" novalidate="" method="POST" action="{{ route('register') }}">
@csrf @csrf
@ -71,9 +71,9 @@
placeholder="{{ __('signup.referral_placeholder') }}" type="text" value="{{ old('referral') }}" /> placeholder="{{ __('signup.referral_placeholder') }}" type="text" value="{{ old('referral') }}" />
</div> </div>
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-6"> <div class="col-md">
<label class="form-label" for="register-gender">{{ __('signup.gender_label') }}</label> <label class="form-label" for="register-gender">{{ __('signup.gender_label') }}</label>
<select class="form-select form-select-lg" id="register-gender" name="gender"> <select class="form-select form-select-lg" style="margin-bottom:16px;" id="register-gender" name="gender">
<option value="" {{ old('gender') == '' ? 'selected' : '' }}>{{ __('signup.select_gender') }}</option> <option value="" {{ old('gender') == '' ? 'selected' : '' }}>{{ __('signup.select_gender') }}</option>
<option value="LAKI-LAKI" {{ old('gender') == 'LAKI-LAKI' ? 'selected' : '' }}>{{ __('signup.gender_male') }}</option> <option value="LAKI-LAKI" {{ old('gender') == 'LAKI-LAKI' ? 'selected' : '' }}>{{ __('signup.gender_male') }}</option>
<option value="PEREMPUAN" {{ old('gender') == 'PEREMPUAN' ? 'selected' : '' }}>{{ __('signup.gender_female') }}</option> <option value="PEREMPUAN" {{ old('gender') == 'PEREMPUAN' ? 'selected' : '' }}>{{ __('signup.gender_female') }}</option>

View File

@ -47,4 +47,10 @@ Route::get('/register', [RegisterController::class, 'index'])->name('register');
Route::post('/register', [RegisterController::class, 'register'])->name('register'); Route::post('/register', [RegisterController::class, 'register'])->name('register');
Route::get('/login', [LoginWaController::class, 'index'])->name('login'); Route::get('/login', [LoginWaController::class, 'index'])->name('login');
Route::get('/login/phone', [LoginWaController::class, 'index'])->name('login-phone');
Route::post('/login/phone/otp', [LoginWaController::class,'otp'])->name('login-phone.otp');
Route::get('/login/phone/otp/{identity}', [LoginWaController::class, 'otpView'])->name('login-phone.otp.view');
Route::post('/login/phone/verify', [LoginWaController::class, 'verify'])->name('login-phone.verify');
Route::get('/login/email', [LoginEmailController::class, 'index'])->name('login-email'); Route::get('/login/email', [LoginEmailController::class, 'index'])->name('login-email');