DONE: add data injection, update ui on login

This commit is contained in:
ISMAIL MASSERAN
2026-05-13 16:41:20 +08:00
parent 6730a3c40e
commit 65045d6935
9 changed files with 1613 additions and 247 deletions
@@ -392,7 +392,8 @@ export default {
{ icon: "fa-barcode", label: "No. Anggota", value: u.no_anggota || "—" },
{ icon: "fa-building", label: "Unit", value: u.unit || "—" },
{ icon: "fa-line-chart", label: "Saham", value: "RM " + fmt(u.saham) },
{ icon: "fa-money", label: "Yuran", value: "RM " + fmt(u.yuran) }
{ icon: "fa-money", label: "Yuran", value: "RM " + fmt(u.yuran) },
{ icon: "fa-percent", label: "Dividen Tahun 2025", value: "RM " + fmt(u.pelaburan) }
];
}
},
@@ -1,204 +1,251 @@
<template>
<div class="d-flex justify-content-center align-items-center min-vh-100 bg-gradient">
<div class="card shadow-lg rounded-lg border-primary" style="width: 100%; max-width: 400px; padding: 30px;">
<div class="text-center mb-4">
<img class="logo-img" src="/images/MyKoPKB-logo.png" alt="Logo" style="max-width: 250px; height: auto;">
<h4 class="mt-3">Selamat Datang ke Portal MyKoPKB</h4>
</div>
<div class="card-body">
<form @submit.prevent="login" id="login_form">
<div class="form-group">
<label for="no_kp">Sila Masukkan No. Kad Pengenalan</label>
<input
type="text"
id="no_kp"
name="no_kp"
placeholder="Masukkan No. Kad Pengenalan Tanpa Dash (-)"
class="form-control"
required
/>
<div class="login-shell">
<div class="login-card">
<div class="login-header">
<img class="login-logo" src="/images/MyKoPKB-logo.png" alt="MyKoPKB" />
<h1 class="login-title">Selamat Datang</h1>
<p class="login-subtitle">Log masuk untuk meneruskan ke Portal MyKoPKB</p>
</div>
<div class="form-group">
<button
type="submit"
class="btn btn-primary btn-block"
:disabled="loading"
>
<span v-if="loading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Masuk
</button>
</div>
</form>
<form @submit.prevent="login" id="login_form" class="login-form" novalidate>
<div class="form-group mb-3">
<label class="login-label" for="no_kp">No. Kad Pengenalan</label>
<div class="login-field">
<span class="login-field-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path
d="M4 7.5A3.5 3.5 0 0 1 7.5 4h9A3.5 3.5 0 0 1 20 7.5v9A3.5 3.5 0 0 1 16.5 20h-9A3.5 3.5 0 0 1 4 16.5v-9Zm3.5-1.5A1.5 1.5 0 0 0 6 7.5v9A1.5 1.5 0 0 0 7.5 18h9a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 16.5 6h-9Zm1.5 3h6v2H9V9Zm0 4h10v2H9v-2Z" />
</svg>
</span>
<input v-model.trim="no_kp" @input="onNoKpInput" :disabled="loading" type="text" id="no_kp"
name="no_kp" class="form-control login-input" placeholder="Contoh: 901010101010"
autocomplete="off" inputmode="numeric" enterkeyhint="go" required />
</div>
<small class="login-help">Masukkan tanpa dash (-) atau jarak.</small>
</div>
<button type="submit" class="btn login-btn" :disabled="loading || !no_kp">
<span v-if="loading" class="spinner-border spinner-border-sm mr-2" role="status"
aria-hidden="true"></span>
<span>{{ loading ? 'Sedang log masuk…' : 'Masuk' }}</span>
</button>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
</template>
<script>
export default {
data() {
return {
loading: false
};
return {
loading: false,
no_kp: ''
};
},
created() {
if (this.util.isAdminPortalSession()) {
this.$router.replace(this.util.getAdminEntryRoute());
}
if (this.util.isAdminPortalSession()) {
this.$router.replace(this.util.getAdminEntryRoute());
}
},
methods: {
login() {
this.startLoading();
const noKp = document.getElementById('no_kp').value.trim();
axios
.post(config.API + 'voter/login', {
no_kp: noKp,
send_otp: false
})
.then(response => {
this.stopLoading();
if (this.util.showResult(response, 'success')) {
this.$router.push({
name: 'Voter Verify',
params: {
'nokp': response.data.nokp,
'notel': response.data.notel,
'debug_otp': response.data.debug_otp || null
}
});
}
})
.catch(error => {
this.stopLoading();
this.util.showResult(error, 'error');
});
},
startLoading() {
this.loading = true;
},
stopLoading() {
this.loading = false;
}
onNoKpInput() {
// keep input forgiving: strip dash/spaces and non-digits
this.no_kp = (this.no_kp || '').replace(/[\s-]+/g, '').replace(/[^\d]/g, '');
},
login() {
this.startLoading();
const noKp = (this.no_kp || '').trim();
axios
.post(config.API + 'voter/login', {
no_kp: noKp,
send_otp: false
})
.then(response => {
this.stopLoading();
if (this.util.showResult(response, 'success')) {
this.$router.push({
name: 'Voter Verify',
params: {
'nokp': response.data.nokp,
'notel': response.data.notel,
'debug_otp': response.data.debug_otp || null
}
});
}
})
.catch(error => {
this.stopLoading();
this.util.showResult(error, 'error');
});
},
startLoading() {
this.loading = true;
},
stopLoading() {
this.loading = false;
}
}
};
</script>
<style scoped>
body {
background-color: #f8f9fa; /* Light grey background */
margin: 0;
padding: 0;
}
.bg-gradient {
background: linear-gradient(to bottom, #6c757d, #343a40); /* Gradient background from grey to dark */
}
.d-flex {
};
</script>
<style scoped>
.login-shell {
min-height: 100vh;
display: flex;
}
.justify-content-center {
justify-content: center;
}
.align-items-center {
align-items: center;
}
.min-vh-100 {
min-height: 100vh; /* Ensure full screen height */
}
.card {
background-color: #ffffff; /* White background for card */
border: 2px solid #007bff; /* Blue border */
border-radius: 1rem;
}
.card-body {
padding: 30px;
}
.logo-img {
max-width: 250px; /* Increased the size of the logo */
justify-content: center;
padding: 32px 16px;
background: #f6f7fb;
}
.login-card {
width: 100%;
max-width: 420px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 20px;
box-shadow:
0 18px 55px rgba(2, 6, 23, 0.12),
0 2px 10px rgba(2, 6, 23, 0.06);
padding: 28px 24px;
position: relative;
overflow: hidden;
}
.login-card::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 4px;
background: linear-gradient(90deg, #22c55e 0%, #3b82f6 55%, #a855f7 100%);
}
.login-header {
text-align: center;
margin-bottom: 16px;
padding-top: 6px;
}
.login-logo {
display: block;
margin: 0 auto 10px;
max-width: 240px;
height: auto;
}
.btn-primary {
background-color: #5cb85c;
border: none;
font-size: 16px;
font-weight: 600;
padding: 12px 0;
border-radius: 30px;
}
.btn-primary:hover {
background-color: #4cae4c;
}
/* Add this to make the placeholder move and animate */
.form-control {
border-radius: 30px;
padding: 10px 20px;
font-size: 16px;
position: relative;
}
.form-control::placeholder {
color: #6c757d;
transition: all 0.3s ease; /* Smooth transition for moving placeholder */
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-50%); /* Vertically center the placeholder */
}
.form-control:focus::placeholder {
color: #ff0000; /* Change color on focus */
left: 10px; /* Move it left */
font-size: 12px; /* Shrink the font size */
top: 5px; /* Move it up */
transform: translateY(-50%) translateX(-10px); /* Move it to the left */
}
.form-control:focus {
border-color: #007bff; /* Highlight the border on focus */
box-shadow: 0 0 8px rgba(0, 123, 255, 0.6); /* Add a shadow effect */
}
h4 {
.login-title {
margin: 0;
font-size: 22px;
font-weight: 600;
color: #333;
}
@media (max-width: 576px) {
.card {
width: 90%;
padding: 20px;
font-weight: 900;
letter-spacing: -0.02em;
color: #0f172a;
}
.login-subtitle {
margin: 6px 0 0;
font-size: 14px;
color: rgba(15, 23, 42, 0.72);
line-height: 1.4;
}
.login-form {
margin-top: 14px;
}
.login-label {
font-weight: 700;
font-size: 13px;
color: rgba(15, 23, 42, 0.85);
margin-bottom: 6px;
}
.login-field {
position: relative;
}
.login-field-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
color: rgba(15, 23, 42, 0.55);
pointer-events: none;
}
.login-field-icon svg {
width: 18px;
height: 18px;
fill: currentColor;
display: block;
}
.login-input {
border-radius: 14px;
padding: 12px 14px 12px 42px;
font-size: 16px;
border: 1px solid rgba(15, 23, 42, 0.14);
transition: box-shadow 160ms ease, border-color 160ms ease, transform 160ms ease;
}
.login-input:focus {
border-color: rgba(59, 130, 246, 0.55);
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.14);
}
.login-help {
display: block;
margin-top: 8px;
color: rgba(15, 23, 42, 0.6);
}
.login-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
padding: 13px 14px;
font-weight: 900;
letter-spacing: 0.01em;
color: #ffffff;
background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%);
border: 0;
box-shadow: 0 12px 24px rgba(29, 78, 216, 0.22);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.login-btn:hover {
filter: brightness(1.03);
transform: translateY(-1px);
box-shadow: 0 16px 34px rgba(29, 78, 216, 0.26);
}
.login-btn:disabled {
opacity: 0.72;
filter: saturate(0.9);
transform: none;
box-shadow: none;
cursor: not-allowed;
}
@media (max-width: 576px) {
.login-shell {
align-items: center;
padding: 20px 14px;
}
.login-card {
max-width: none;
border-radius: 20px;
padding: 22px 18px 18px;
}
h4 {
font-size: 20px;
.login-logo {
max-width: 200px;
}
.logo-img {
max-width: 200px;
}
.form-control {
font-size: 14px;
}
.btn-primary {
font-size: 14px;
padding: 10px 0;
}
}
</style>
}
</style>
@@ -1,55 +1,64 @@
<template>
<div class="col-md-5 col-md-offset-3">
<div class="panel panel-default">
<div class="panel-heading">
<a @click="$router.go(-1)">Kembali</a>
<h4 class="text-center">Kod Verifikasi!</h4>
<div class="verify-shell">
<div class="verify-card">
<div class="verify-card__accent" aria-hidden="true"></div>
<div class="verify-header">
<button type="button" class="verify-back" @click="$router.go(-1)">
<span class="verify-back__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path
d="M15.5 19.5a1 1 0 0 1-.7-.3l-7-7a1 1 0 0 1 0-1.4l7-7a1 1 0 1 1 1.4 1.4L9.9 11.5l6.3 6.3a1 1 0 0 1-.7 1.7Z" />
</svg>
</span>
Kembali
</button>
<h1 class="verify-title">Kod Verifikasi</h1>
<p class="verify-subtitle">
<template v-if="!otpSent">
Sahkan nombor telefon anda. Kod akan dihantar ke nombor berakhir
<span class="verify-phone">+6 {{ phoneLastFour }}</span>
</template>
<template v-else>
Masukkan kod pengesahan yang dihantar ke nombor berakhir
<span class="verify-phone">+6 {{ phoneLastFour }}</span>
</template>
</p>
<p class="verify-note">
Jika nombor ini tidak betul atau anda tidak menerima OTP, sila pergi ke kaunter IT.
</p>
</div>
<div class="panel-body">
<form @submit.prevent="login" id="login_form">
<div class="m-4">
<template v-if="!otpSent">
<span>Sila sahkan nombor telefon anda. Kod akan dihantar ke nombor berakhir:</span>
<br><b>+6 {{ phoneLastFour }}</b>
</template>
<template v-else>
<span>Sila masukkan kod pengesahan yang dihantar ke nombor berakhir:</span>
<br><b>+6 {{ phoneLastFour }}</b>
</template>
<p class="text-muted small" style="margin-top:12px;margin-bottom:0;">
Jika nombor ini tidak betul atau anda tidak menerima OTP, sila pergi ke kaunter IT.
</p>
</div>
<form @submit.prevent="login" id="login_form" class="verify-form" novalidate>
<div v-if="otpSent && showDebugOtp" class="verify-debug">
OTP local development: <b>{{ debugOtp }}</b>
</div>
<div v-if="otpSent && showDebugOtp" class="alert alert-info" style="margin-bottom:20px;">
OTP local development: <b>{{ debugOtp }}</b>
</div>
<div v-if="!otpSent" class="verify-actions">
<button type="button" class="btn verify-btn" :disabled="otpSending" @click="requestOtp">
<span v-if="otpSending" class="spinner-border spinner-border-sm mr-2" role="status"
aria-hidden="true"></span>
Hantar OTP
</button>
</div>
<div v-if="!otpSent" class="form-group" style="margin-top: 20px;">
<button type="button" class="btn btn-primary form-control" :disabled="otpSending" @click="requestOtp">
<span v-if="otpSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Hantar OTP ke nombor ini
</button>
</div>
<div v-show="otpSent" class="verify-otp">
<v-otp-input ref="otpInput" input-classes="otp-input" separator="-" :num-inputs="6"
:should-auto-focus="true" :is-input-num="true" @on-change="handleOnChange"
@on-complete="handleOnComplete" />
</div>
<div v-show="otpSent" class="justify-content-center d-flex"
style="margin-top:20px;margin-bottom:20px;display: flex;flex-direction: row;justify-content: center;align-items: center;">
<v-otp-input ref="otpInput" input-classes="otp-input" separator="-" :num-inputs="6"
:should-auto-focus="true" :is-input-num="true" @on-change="handleOnChange"
@on-complete="handleOnComplete" />
</div>
<div v-if="otpSent" class="verify-links">
<button type="button" class="verify-link" @click="resendVerify">Hantar kembali OTP</button>
</div>
<div v-if="otpSent" style="margin-bottom:20px;">
<a @click="resendVerify">Hantar Kembali OTP!</a>
</div>
<div v-if="otpSent" class="form-group">
<input ref="submitbtn" type="submit" class="btn btn-primary form-control" value="Sahkan & Teruskan"
disabled />
</div>
</form>
</div>
<div v-if="otpSent" class="verify-actions">
<button ref="submitbtn" type="submit" class="btn verify-btn verify-btn--primary" disabled>
Sahkan & Teruskan
</button>
</div>
</form>
</div>
</div>
</template>
@@ -167,25 +176,226 @@ export default {
},
};
</script>
<style>
.otp-input {
width: 40px;
height: 40px;
padding: 5px;
margin: 0 10px;
font-size: 20px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.3);
text-align: center;
<style scoped>
.verify-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
background: #f6f7fb;
}
.otp-input.error {
border: 1px solid red !important;
.verify-card {
width: 100%;
max-width: 520px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 20px;
box-shadow:
0 18px 55px rgba(2, 6, 23, 0.12),
0 2px 10px rgba(2, 6, 23, 0.06);
padding: 22px 22px 18px;
position: relative;
overflow: hidden;
}
.otp-input::-webkit-inner-spin-button,
.otp-input::-webkit-outer-spin-button {
.verify-card__accent {
position: absolute;
inset: 0 0 auto 0;
height: 4px;
background: linear-gradient(90deg, #22c55e 0%, #3b82f6 55%, #a855f7 100%);
}
.verify-header {
padding-top: 6px;
}
.verify-back {
appearance: none;
border: 0;
background: transparent;
padding: 8px 0;
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(15, 23, 42, 0.7);
font-weight: 800;
}
.verify-back:hover {
color: rgba(15, 23, 42, 0.85);
}
.verify-back__icon {
width: 18px;
height: 18px;
display: inline-flex;
color: currentColor;
}
.verify-back__icon svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.verify-title {
margin: 6px 0 0;
font-size: 22px;
font-weight: 900;
letter-spacing: -0.02em;
color: #0f172a;
}
.verify-subtitle {
margin: 8px 0 0;
font-size: 14px;
color: rgba(15, 23, 42, 0.72);
line-height: 1.45;
}
.verify-phone {
display: inline-block;
margin-left: 6px;
font-weight: 900;
color: rgba(15, 23, 42, 0.88);
}
.verify-note {
margin: 10px 0 0;
font-size: 12px;
line-height: 1.45;
color: rgba(15, 23, 42, 0.56);
}
.verify-form {
margin-top: 18px;
}
.verify-debug {
margin: 0 0 14px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(59, 130, 246, 0.2);
background: rgba(59, 130, 246, 0.08);
color: rgba(15, 23, 42, 0.9);
font-size: 13px;
}
.verify-otp {
display: flex;
justify-content: center;
align-items: center;
margin: 18px 0 14px;
max-width: 360px;
margin-left: auto;
margin-right: auto;
}
/* v-otp-input renders inputs inside a child component.
Use deep selectors so scoped styles apply. */
::v-deep .otp-input {
width: 40px !important;
min-width: 40px !important;
max-width: 40px !important;
height: 46px !important;
padding: 6px !important;
margin: 0 6px !important;
font-size: 18px !important;
font-weight: 900 !important;
border-radius: 14px !important;
border: 1px solid rgba(15, 23, 42, 0.14) !important;
text-align: center !important;
box-sizing: border-box !important;
transition: box-shadow 160ms ease, border-color 160ms ease, transform 160ms ease;
}
::v-deep .otp-input.error {
border-color: rgba(239, 68, 68, 0.9) !important;
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.14) !important;
}
::v-deep .otp-input:focus {
outline: none !important;
border-color: rgba(59, 130, 246, 0.55) !important;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.14) !important;
}
::v-deep .otp-input::-webkit-inner-spin-button,
::v-deep .otp-input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.verify-links {
display: flex;
justify-content: center;
margin: 10px 0 16px;
}
.verify-link {
appearance: none;
border: 0;
background: transparent;
padding: 6px 10px;
color: rgba(29, 78, 216, 0.92);
font-weight: 900;
}
.verify-link:hover {
text-decoration: underline;
}
.verify-actions {
margin-top: 12px;
}
.verify-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
padding: 13px 14px;
font-weight: 900;
letter-spacing: 0.01em;
color: #ffffff;
background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%);
border: 0;
box-shadow: 0 12px 24px rgba(29, 78, 216, 0.22);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.verify-btn:hover {
filter: brightness(1.03);
transform: translateY(-1px);
box-shadow: 0 16px 34px rgba(29, 78, 216, 0.26);
}
.verify-btn:disabled {
opacity: 0.72;
filter: saturate(0.9);
transform: none;
box-shadow: none;
cursor: not-allowed;
}
@media (max-width: 576px) {
.verify-shell {
padding: 20px 14px;
}
.verify-card {
padding: 20px 18px 16px;
}
::v-deep .otp-input {
width: 38px !important;
min-width: 38px !important;
max-width: 38px !important;
height: 44px !important;
margin: 0 5px !important;
}
}
</style>