Feature/phone register (#11)
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local> Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local> Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
getPhoneVerificationErrorMessage,
|
||||
sendAuthenticatedPhoneVerificationOtp,
|
||||
verifyAuthenticatedPhoneVerificationOtp,
|
||||
} from '@/modules/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const changingPhone = ref(false)
|
||||
const otpSent = ref(false)
|
||||
const phoneNumber = ref(authStore.user?.phone_number ?? '')
|
||||
const otp = ref('')
|
||||
const feedbackMessage = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const isVerified = computed(() => authStore.isPhoneVerified)
|
||||
|
||||
const sectionDescription = computed(() => {
|
||||
if (changingPhone.value) {
|
||||
return 'Masukkan nombor telefon baharu dan sahkan dengan kod OTP SMS.'
|
||||
}
|
||||
|
||||
if (isVerified.value) {
|
||||
return 'Nombor telefon anda telah disahkan.'
|
||||
}
|
||||
|
||||
return 'Sahkan nombor telefon anda melalui kod OTP SMS.'
|
||||
})
|
||||
|
||||
watch(
|
||||
() => authStore.user?.phone_number,
|
||||
(value) => {
|
||||
if (!otpSent.value && !changingPhone.value) {
|
||||
phoneNumber.value = value ?? ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const resetOtpState = () => {
|
||||
otp.value = ''
|
||||
otpSent.value = false
|
||||
clearMessages()
|
||||
}
|
||||
|
||||
const clearMessages = () => {
|
||||
feedbackMessage.value = ''
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
const handlePhoneInput = () => {
|
||||
phoneNumber.value = phoneNumber.value.replace(/[^\d+]/g, '')
|
||||
otp.value = ''
|
||||
otpSent.value = false
|
||||
}
|
||||
|
||||
const handleOtpInput = () => {
|
||||
otp.value = otp.value.replace(/\D/g, '').slice(0, 6)
|
||||
}
|
||||
|
||||
const startChangePhone = () => {
|
||||
changingPhone.value = true
|
||||
phoneNumber.value = ''
|
||||
resetOtpState()
|
||||
}
|
||||
|
||||
const cancelChangePhone = () => {
|
||||
changingPhone.value = false
|
||||
phoneNumber.value = authStore.user?.phone_number ?? ''
|
||||
resetOtpState()
|
||||
}
|
||||
|
||||
const handleSendOtp = async () => {
|
||||
loading.value = true
|
||||
clearMessages()
|
||||
|
||||
try {
|
||||
const response = await sendAuthenticatedPhoneVerificationOtp({
|
||||
phone_number: phoneNumber.value,
|
||||
})
|
||||
|
||||
feedbackMessage.value = response.message
|
||||
otpSent.value = true
|
||||
} catch (error) {
|
||||
errorMessage.value = getPhoneVerificationErrorMessage(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyOtp = async () => {
|
||||
loading.value = true
|
||||
clearMessages()
|
||||
|
||||
try {
|
||||
const response = await verifyAuthenticatedPhoneVerificationOtp({
|
||||
phone_number: phoneNumber.value,
|
||||
otp: otp.value,
|
||||
})
|
||||
|
||||
authStore.setUserProfile(response.data)
|
||||
phoneNumber.value = response.data.phone_number ?? phoneNumber.value
|
||||
changingPhone.value = false
|
||||
otp.value = ''
|
||||
otpSent.value = false
|
||||
feedbackMessage.value = response.message
|
||||
} catch (error) {
|
||||
errorMessage.value = getPhoneVerificationErrorMessage(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Box raised="single" class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Nombor Telefon</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ sectionDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isVerified && !changingPhone" class="space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Input id="profile-phone-verified" :model-value="authStore.user?.phone_number ?? '-'" type="tel" disabled
|
||||
class="max-w-sm" />
|
||||
<Badge variant="success">Disahkan</Badge>
|
||||
</div>
|
||||
|
||||
<p v-if="feedbackMessage" class="text-sm text-slate-600">{{ feedbackMessage }}</p>
|
||||
|
||||
<Button type="button" look="outline" :disabled="loading" @click="startChangePhone">
|
||||
Tukar Nombor Telefon
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<FieldGroup>
|
||||
<Field class="max-w-sm">
|
||||
<FieldLabel for="profile-phone-verify">No. Telefon</FieldLabel>
|
||||
<Input id="profile-phone-verify" v-model="phoneNumber" type="tel" inputmode="tel"
|
||||
placeholder="No. Telefon, contoh: 0123456790" autocomplete="tel" :disabled="loading || otpSent"
|
||||
@input="handlePhoneInput" />
|
||||
</Field>
|
||||
<Field v-if="otpSent" class="max-w-sm">
|
||||
<FieldLabel for="profile-phone-otp">Kod OTP</FieldLabel>
|
||||
<Input id="profile-phone-otp" v-model="otp" type="text" inputmode="numeric" maxlength="6" placeholder="000000"
|
||||
autocomplete="one-time-code" class="text-center tracking-[0.4em]" :disabled="loading"
|
||||
@input="handleOtpInput" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<p v-if="feedbackMessage" class="text-sm text-slate-600">{{ feedbackMessage }}</p>
|
||||
<p v-if="errorMessage" class="text-sm text-danger">{{ errorMessage }}</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button v-if="!otpSent" type="button" variant="primary" :disabled="loading || !phoneNumber"
|
||||
@click="handleSendOtp">
|
||||
{{ loading ? 'Menghantar...' : 'Hantar Kod OTP' }}
|
||||
</Button>
|
||||
<template v-else>
|
||||
<Button type="button" variant="primary" :disabled="loading || otp.length !== 6" @click="handleVerifyOtp">
|
||||
{{ loading ? 'Mengesahkan...' : 'Sahkan OTP' }}
|
||||
</Button>
|
||||
<Button type="button" look="outline" :disabled="loading" @click="handleSendOtp">
|
||||
Hantar Semula OTP
|
||||
</Button>
|
||||
</template>
|
||||
<Button v-if="changingPhone" type="button" look="outline" :disabled="loading" @click="cancelChangePhone">
|
||||
Batal
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</template>
|
||||
@@ -72,7 +72,7 @@ const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
|
||||
// TODO: replace with API lookup
|
||||
const EMPLOYERS = [
|
||||
{
|
||||
name: 'Infra Quest Sdn Bhd (IQSB)',
|
||||
name: 'Infra Quest Sdn. Bhd. (IQSB)',
|
||||
address: 'Lot 1045, Jalan Dato’ Lundang, 15200 Kota Bharu, Kelantan',
|
||||
},
|
||||
{
|
||||
@@ -89,6 +89,10 @@ const EMPLOYERS = [
|
||||
name: "An-Nisa'",
|
||||
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
|
||||
},
|
||||
{
|
||||
name: "Kel Infra Sdn. Bhd.",
|
||||
address: "Tingkat 2 Menara Perbadanan, Jalan Tengku Petra Semerak, 15000 Kota Bharu, Kelantan.",
|
||||
}
|
||||
] as const
|
||||
|
||||
const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
|
||||
|
||||
@@ -31,6 +31,7 @@ import { updateProfile } from '@/modules/profile/services/profile.service'
|
||||
import type { Address, AddressPayload } from '@/modules/profile/types/address.types'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import HeirTab from './HeirTab.vue'
|
||||
import PhoneVerificationSection from '../components/PhoneVerificationSection.vue'
|
||||
|
||||
defineProps<{
|
||||
embedded?: boolean
|
||||
@@ -44,7 +45,6 @@ const form = reactive({
|
||||
name: '',
|
||||
ic_number: '',
|
||||
position: '',
|
||||
phone_number: '',
|
||||
birth_date: '',
|
||||
birth_place: '',
|
||||
})
|
||||
@@ -350,7 +350,6 @@ function syncFormFromUser() {
|
||||
form.name = user.name ?? ''
|
||||
form.ic_number = user.ic_number ?? ''
|
||||
form.position = user.position ?? ''
|
||||
form.phone_number = user.phone_number ?? ''
|
||||
form.birth_date = toDateInputValue(user.birth_date)
|
||||
form.birth_place = user.birth_place ?? ''
|
||||
syncProfileSelectValues()
|
||||
@@ -368,7 +367,6 @@ async function onSaveProfile() {
|
||||
name: form.name.trim(),
|
||||
ic_number: form.ic_number.trim(),
|
||||
position: form.position.trim(),
|
||||
phone_number: form.phone_number.trim(),
|
||||
gender: gender ?? undefined,
|
||||
marriage_status: marriageStatus ?? undefined,
|
||||
birth_date: form.birth_date || undefined,
|
||||
@@ -591,11 +589,6 @@ onMounted(async () => {
|
||||
<Input id="profile-ic" v-model="form.ic_number" type="text" inputmode="numeric" maxlength="15"
|
||||
placeholder="Contoh: 900101011234" @input="handleIcNumberInput" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-phone">No. Telefon</FieldLabel>
|
||||
<Input id="profile-phone" v-model="form.phone_number" type="tel" pattern="[0-9]*"
|
||||
placeholder="0123456789" />
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<FieldLabel for="profile-position">Jawatan</FieldLabel>
|
||||
<Input id="profile-position" v-model="form.position" type="text" placeholder="Jawatan" />
|
||||
@@ -677,6 +670,8 @@ onMounted(async () => {
|
||||
</form>
|
||||
</Box>
|
||||
|
||||
<PhoneVerificationSection />
|
||||
|
||||
<Box raised="single" class="p-6">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
Reference in New Issue
Block a user