Dev/v1.2 (#4)
Build Docker Image / build-backend (push) Successful in 1m56s
Build Docker Image / build-frontend (push) Successful in 1m55s

Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-07-06 12:50:55 +08:00
parent e77712105c
commit d06c4701a4
190 changed files with 5365 additions and 26981 deletions
@@ -0,0 +1,186 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import Swal from 'sweetalert2'
import { Button } from '@/components/ui/button'
import { Lucide } from '@/components/ui/lucide'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { downloadMemberDigitalCard } from '../services/member-digital-card.service'
import { toProxiedStorageUrl } from '../utils/member-digital-card.utils'
import MemberDigitalCardFlip from './MemberDigitalCardFlip.vue'
const props = defineProps<{
memberNumber?: string | number | null
memberName?: string | null
memberType?: string | null
companyName?: string | null
profileUrl?: string | null
imageUrl?: string | null
large?: boolean
}>()
const previewMaxWidthClass = computed(() =>
props.large ? 'max-w-sm sm:max-w-md lg:max-w-lg' : 'max-w-68 sm:max-w-xs',
)
const resolvedImageUrl = computed(() => toProxiedStorageUrl(props.imageUrl))
const isFlipped = ref(false)
const expandedOpen = ref(false)
const isPortraitPhone = ref(false)
const downloading = ref(false)
let portraitQuery: MediaQueryList | null = null
function updatePortraitPhone() {
isPortraitPhone.value = portraitQuery?.matches ?? false
}
function openExpanded() {
expandedOpen.value = true
}
function closeExpanded() {
expandedOpen.value = false
}
function toggleFlip() {
isFlipped.value = !isFlipped.value
}
async function downloadCard() {
if (downloading.value) return
downloading.value = true
const side = isFlipped.value ? 'belakang' : 'depan'
try {
await downloadMemberDigitalCard(props.memberNumber, side)
await Swal.fire({
toast: true,
position: 'top-end',
icon: 'success',
title: `Kad ${side} berjaya disimpan.`,
showConfirmButton: false,
timer: 3000,
})
} catch (error) {
await Swal.fire({
icon: 'error',
title: 'Ralat',
text: getApiErrorMessage(error, 'Gagal menyimpan kad.'),
})
} finally {
downloading.value = false
}
}
watch(expandedOpen, (open) => {
document.body.style.overflow = open ? 'hidden' : ''
})
onMounted(() => {
portraitQuery = window.matchMedia('(max-width: 767px) and (orientation: portrait)')
updatePortraitPhone()
portraitQuery.addEventListener('change', updatePortraitPhone)
})
onUnmounted(() => {
document.body.style.overflow = ''
portraitQuery?.removeEventListener('change', updatePortraitPhone)
})
</script>
<template>
<div class="flex w-full flex-col items-center gap-2">
<button
type="button"
class="relative w-full cursor-pointer border-0 bg-transparent p-0 transition-transform active:scale-[0.98]"
:class="previewMaxWidthClass"
aria-label="Buka kad digital penuh"
@click="openExpanded">
<MemberDigitalCardFlip :member-number="memberNumber" :member-name="memberName"
:member-type="memberType" :company-name="companyName" :profile-url="profileUrl"
:image-url="resolvedImageUrl" :is-flipped="isFlipped" />
</button>
<p class="text-center text-[11px] text-slate-500">
Klik kad untuk paparan penuh
</p>
<div class="flex flex-wrap items-center justify-center gap-2">
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none text-xs"
:aria-pressed="isFlipped" :disabled="downloading" @click="toggleFlip">
<Lucide class="mr-2 size-4" icon="RotateCw" />
{{ isFlipped ? 'Papar depan kad' : 'Imbas kod QR' }}
</Button>
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none text-xs"
:disabled="downloading" @click="downloadCard">
<Lucide class="mr-2 size-4" :icon="downloading ? 'LoaderCircle' : 'Download'"
:class="{ 'animate-spin': downloading }" />
{{ downloading ? 'Menyimpan...' : 'Simpan kad' }}
</Button>
</div>
<Teleport to="body">
<div
v-if="expandedOpen"
class="fixed inset-0 z-70 flex flex-col items-center justify-center gap-4 bg-black/90 p-5"
role="dialog"
aria-modal="true"
aria-label="Kad digital anggota"
@click.self="closeExpanded">
<button
type="button"
class="absolute right-4 top-4 flex size-10 items-center justify-center rounded-full border border-white/20 bg-white/10 text-white"
aria-label="Tutup"
@click="closeExpanded">
<Lucide class="size-5" icon="X" />
</button>
<div v-if="isPortraitPhone" class="flex items-center justify-center" @click.stop>
<div class="w-[min(90vh,34rem)] rotate-90">
<MemberDigitalCardFlip :member-number="memberNumber" :member-name="memberName"
:member-type="memberType" :company-name="companyName" :profile-url="profileUrl"
:image-url="resolvedImageUrl" :is-flipped="isFlipped" expanded />
</div>
</div>
<div v-else class="w-[min(100vw-2rem,32rem)] lg:w-[min(100vw-2rem,40rem)]" @click.stop>
<MemberDigitalCardFlip :member-number="memberNumber" :member-name="memberName"
:member-type="memberType" :company-name="companyName" :profile-url="profileUrl"
:image-url="resolvedImageUrl" :is-flipped="isFlipped" expanded />
</div>
<div class="flex flex-wrap items-center justify-center gap-2">
<Button
type="button"
variant="ghost"
class="border border-white/20 bg-white/10 text-xs text-white shadow-none hover:bg-white/15"
:aria-pressed="isFlipped"
:disabled="downloading"
@click.stop="toggleFlip">
<Lucide class="mr-2 size-4" icon="RotateCw" />
{{ isFlipped ? 'Papar depan kad' : 'Imbas kod QR' }}
</Button>
<Button
type="button"
variant="ghost"
class="border border-white/20 bg-white/10 text-xs text-white shadow-none hover:bg-white/15"
:disabled="downloading"
@click.stop="downloadCard">
<Lucide class="mr-2 size-4" :icon="downloading ? 'LoaderCircle' : 'Download'"
:class="{ 'animate-spin': downloading }" />
{{ downloading ? 'Menyimpan...' : 'Simpan kad' }}
</Button>
</div>
<p class="text-center text-xs text-white/60">
Klik di luar kad untuk tutup
</p>
</div>
</Teleport>
</div>
</template>
@@ -0,0 +1,104 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import QRCode from 'qrcode'
import logoUrl from '@/assets/images/logo.svg'
import { displayCardValue } from '../utils/member-digital-card.utils'
const props = withDefaults(
defineProps<{
profileUrl?: string | null
memberNumber?: string | number | null
expanded?: boolean
}>(),
{ expanded: false },
)
const qrDataUrl = ref('')
const qrError = ref(false)
const qrPixelSize = computed(() => (props.expanded ? 220 : 120))
async function renderQrCode() {
if (!props.profileUrl) {
qrDataUrl.value = ''
qrError.value = false
return
}
try {
qrDataUrl.value = await QRCode.toDataURL(props.profileUrl, {
margin: 1,
width: qrPixelSize.value,
color: {
dark: '#0f172a',
light: '#ffffff',
},
})
qrError.value = false
} catch {
qrDataUrl.value = ''
qrError.value = true
}
}
watch([() => props.profileUrl, () => props.expanded], renderQrCode, { immediate: true })
</script>
<template>
<div :class="[
'relative h-full w-full overflow-hidden rounded-2xl bg-linear-to-br from-primary/90 via-primary to-primary/80 text-primary-foreground shadow-lg ring-1 ring-white/20',
expanded ? 'p-5 sm:p-6' : 'p-3 sm:p-4',
]">
<div class="pointer-events-none absolute inset-0 bg-noise opacity-30" />
<div class="pointer-events-none absolute -right-12 -top-12 rounded-full bg-white/10"
:class="expanded ? 'size-44' : 'size-32'" />
<div class="pointer-events-none absolute -bottom-16 -left-10 rounded-full bg-white/5"
:class="expanded ? 'size-48' : 'size-36'" />
<div class="relative flex h-full min-h-0 flex-col">
<div class="flex shrink-0 items-center justify-between gap-2">
<img :src="logoUrl" alt="" class="w-auto shrink-0 brightness-0 invert"
:class="expanded ? 'h-7 sm:h-9' : 'h-5 sm:h-6'" />
<div class="text-right font-semibold uppercase opacity-75"
:class="expanded ? 'text-sm tracking-[0.18em]' : 'text-[9px] tracking-[0.18em]'">
Belakang · Kod QR
</div>
</div>
<div class="flex min-h-0 flex-1 items-center" :class="expanded ? 'mt-5 gap-6' : 'mt-3 gap-3'">
<div class="shrink-0 rounded-lg bg-white shadow-sm" :class="expanded ? 'p-3' : 'p-1.5'">
<img v-if="qrDataUrl" :src="qrDataUrl" alt="Kod QR profil anggota" class="block"
:class="expanded ? 'size-32 sm:size-36' : 'size-18 sm:size-20'" />
<div v-else class="flex items-center justify-center"
:class="expanded ? 'size-32 sm:size-36' : 'size-18 sm:size-20'">
<span class="px-1 text-center leading-tight text-slate-500" :class="expanded ? 'text-sm' : 'text-[9px]'">
{{ qrError ? 'Kod QR tidak tersedia.' : 'Memuatkan...' }}
</span>
</div>
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center" :class="expanded ? 'gap-5' : 'gap-3'">
<p class="leading-snug opacity-85" :class="expanded ? 'text-base sm:text-lg' : 'text-[9px] sm:text-[10px]'">
Imbas untuk sahkan profil anggota MyKOPKB.
</p>
<div>
<div class="font-medium uppercase tracking-widest opacity-60" :class="expanded ? 'text-sm' : 'text-[9px]'">
No. Anggota
</div>
<div class="mt-0.5 font-mono font-semibold tracking-widest"
:class="expanded ? 'text-3xl sm:text-4xl' : 'text-base sm:text-lg'">
{{ displayCardValue(memberNumber) }}
</div>
</div>
</div>
</div>
<div class="shrink-0 border-t border-white/15 text-center" :class="expanded ? 'mt-4 pt-3' : 'mt-2 pt-2'">
<p class="uppercase opacity-50" :class="expanded ? 'text-xs tracking-[0.2em]' : 'text-[8px] tracking-[0.2em]'">
Koperasi Permodalan Kelantan Berhad (KOPKB)
</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,34 @@
<script lang="ts" setup>
import MemberDigitalCardBack from './MemberDigitalCardBack.vue'
import MemberDigitalCardFront from './MemberDigitalCardFront.vue'
defineProps<{
memberNumber?: string | number | null
memberName?: string | null
memberType?: string | null
companyName?: string | null
profileUrl?: string | null
imageUrl?: string | null
isFlipped: boolean
expanded?: boolean
}>()
</script>
<template>
<div class="relative w-full" style="perspective: 1000px">
<div
class="relative aspect-7/4.5 w-full transition-transform duration-500 ease-in-out"
:style="{
transformStyle: 'preserve-3d',
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
}">
<div class="absolute inset-0" style="backface-visibility: hidden">
<MemberDigitalCardFront :member-number="memberNumber" :member-name="memberName"
:member-type="memberType" :company-name="companyName" :image-url="imageUrl" :expanded="expanded" />
</div>
<div class="absolute inset-0" :style="{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }">
<MemberDigitalCardBack :profile-url="profileUrl" :member-number="memberNumber" :expanded="expanded" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,97 @@
<script lang="ts" setup>
import { computed } from 'vue'
import logoUrl from '@/assets/images/logo.svg'
import { displayCardValue } from '../utils/member-digital-card.utils'
const props = withDefaults(
defineProps<{
memberNumber?: string | number | null
memberName?: string | null
memberType?: string | null
companyName?: string | null
imageUrl?: string | null
expanded?: boolean
}>(),
{ expanded: false },
)
const avatarFallback = computed(() => {
const name = props.memberName?.trim()
if (!name) return '--'
return name.slice(0, 2).toUpperCase()
})
</script>
<template>
<div :class="[
'relative h-full w-full overflow-hidden rounded-2xl bg-linear-to-br from-primary via-primary/95 to-primary/75 text-primary-foreground shadow-lg ring-1 ring-white/20',
expanded ? 'p-6 sm:p-8' : 'p-4 sm:p-5',
]">
<div class="pointer-events-none absolute inset-0 bg-noise opacity-30" />
<div class="pointer-events-none absolute -right-10 -top-10 rounded-full bg-white/10"
:class="expanded ? 'size-48' : 'size-36'" />
<div class="pointer-events-none absolute -bottom-12 -left-8 rounded-full bg-white/5"
:class="expanded ? 'size-52' : 'size-40'" />
<div
class="pointer-events-none absolute top-1/2 -translate-y-1/2 overflow-hidden rounded-md border border-white/25 bg-white/10 shadow-sm"
:class="expanded ? 'right-6 size-20 sm:size-24' : 'right-4 size-14'">
<img v-if="imageUrl" :src="imageUrl" :alt="memberName ?? 'Profil anggota'" class="size-full object-cover" />
<div v-else class="flex size-full items-center justify-center bg-white/15 font-semibold uppercase tracking-wide"
:class="expanded ? 'text-base' : 'text-[11px]'">
{{ avatarFallback }}
</div>
</div>
<div class="relative flex h-full min-h-0 flex-col">
<div class="flex shrink-0 items-start justify-between gap-3">
<img :src="logoUrl" alt="" class="w-auto brightness-0 invert"
:class="expanded ? 'h-8 sm:h-10' : 'h-6 sm:h-7'" />
<div class="text-right font-semibold uppercase opacity-80"
:class="expanded ? 'text-sm tracking-[0.2em]' : 'text-[10px] tracking-[0.2em]'">
Kad Digital
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col justify-center py-2" :class="expanded ? 'gap-4' : 'gap-2'">
<div>
<div class="font-medium uppercase tracking-widest opacity-70" :class="expanded ? 'text-sm' : 'text-[10px]'">
No. Anggota
</div>
<div class="mt-0.5 font-mono font-semibold"
:class="expanded ? 'text-4xl tracking-[0.15em] sm:text-5xl' : 'text-xl tracking-[0.15em] sm:text-2xl'">
{{ displayCardValue(memberNumber) }}
</div>
</div>
<div class="min-w-0" :class="expanded ? 'pr-28 sm:pr-32' : 'pr-16'">
<div class="font-medium uppercase tracking-widest opacity-70" :class="expanded ? 'text-sm' : 'text-[10px]'">
Unit
</div>
<div class="truncate font-medium" :class="expanded ? 'text-lg sm:text-xl' : 'text-xs'">
{{ displayCardValue(companyName) }}
</div>
</div>
</div>
<div class="flex shrink-0 items-end justify-between gap-3 border-t border-white/15"
:class="expanded ? 'pt-4' : 'pt-2'">
<div class="min-w-0 flex-1">
<div class="truncate font-medium" :class="expanded ? 'text-xl sm:text-2xl' : 'text-sm'">
{{ memberName || '-' }}
</div>
<div class="mt-0.5 uppercase tracking-wide opacity-60" :class="expanded ? 'text-sm' : 'text-[10px]'">
Nama
</div>
</div>
<div class="shrink-0 text-right">
<div class="font-semibold" :class="expanded ? 'text-xl sm:text-2xl' : 'text-sm'">
{{ displayCardValue(memberType) }}
</div>
<div class="mt-0.5 uppercase tracking-wide opacity-60" :class="expanded ? 'text-sm' : 'text-[10px]'">
Jenis Anggota
</div>
</div>
</div>
</div>
</div>
</template>
+1 -1
View File
@@ -1,2 +1,2 @@
export { profileLayoutRoutes } from './routes'
export { profileLayoutRoutes, profilePublicRoutes } from './routes'
export { profileMenu } from './menu'
+63 -6
View File
@@ -69,6 +69,33 @@ const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Freelance', value: 'Freelance' },
]
// TODO: replace with API lookup
const EMPLOYERS = [
{
name: 'Infra Quest Sdn Bhd (IQSB)',
address: 'Lot 1045, Jalan Dato Lundang, 15200 Kota Bharu, Kelantan',
},
{
name: 'Permodalan Kelantan Berhad (PKB)',
address:
'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan',
},
{
name: 'Koperasi Permodalan Kelantan Berhad (KOPKB)',
address:
'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
},
{
name: "An-Nisa'",
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
},
] as const
const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
label: employer.name,
value: employer.name,
}))
function createSelectCollection(options: SelectOption[]) {
return select.collection({
items: options,
@@ -88,9 +115,12 @@ function apiValueToLabel(options: SelectOption[], value: string | null | undefin
}
const employmentTypeCollection = createSelectCollection(EMPLOYMENT_TYPE_OPTIONS)
const companyNameCollection = createSelectCollection(COMPANY_OPTIONS)
const employmentTypeValue = ref<string[]>([])
const employmentTypeInitial = ref<string[]>([])
const companyNameValue = ref<string[]>([])
const companyNameInitial = ref<string[]>([])
function clearEmploymentFieldError(field: EmploymentFieldKey) {
delete employmentErrors[field]
@@ -140,6 +170,10 @@ const employmentTypeLabel = computed(() =>
const isEditingEmployment = computed(() => editingEmploymentId.value !== null)
const canAddEmployment = computed(() => !loadingEmployments.value && employments.value.length === 0)
const showEmploymentForm = computed(() => isEditingEmployment.value || canAddEmployment.value)
function setEmploymentTypeValue(details: { value: string[] }) {
employmentTypeValue.value = details.value
clearEmploymentFieldError('employment_type')
@@ -147,9 +181,17 @@ function setEmploymentTypeValue(details: { value: string[] }) {
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, details.value[0]) ?? ''
}
function setCompanyNameValue(details: { value: string[] }) {
companyNameValue.value = details.value
clearEmploymentFieldError('company_name')
employmentForm.company_name = details.value[0] ?? ''
}
function syncEmploymentSelectValues() {
employmentTypeValue.value = apiValueToLabel(EMPLOYMENT_TYPE_OPTIONS, employmentForm.employment_type)
employmentTypeInitial.value = [...employmentTypeValue.value]
companyNameValue.value = apiValueToLabel(COMPANY_OPTIONS, employmentForm.company_name)
companyNameInitial.value = [...companyNameValue.value]
}
function resetEmploymentForm() {
@@ -194,7 +236,7 @@ function validateEmploymentForm(): boolean {
let valid = true
if (!employmentForm.company_name.trim()) {
if (!companyNameValue.value[0]?.trim()) {
employmentErrors.company_name = 'Nama syarikat diperlukan.'
valid = false
}
@@ -441,7 +483,8 @@ onMounted(async () => {
Tiada pekerjaan direkodkan.
</div>
<form class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveEmployment">
<form v-if="showEmploymentForm" class="space-y-6 border-t border-foreground/10 pt-6"
@submit.prevent="onSaveEmployment">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 class="text-base font-semibold text-slate-900">
@@ -470,10 +513,24 @@ onMounted(async () => {
<FieldGroup>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="employment-company">Nama Syarikat</FieldLabel>
<Input id="employment-company" v-model="employmentForm.company_name" type="text"
placeholder="Nama syarikat" :aria-invalid="!!employmentErrors.company_name"
@input="clearEmploymentFieldError('company_name')" />
<FieldLabel>Nama Syarikat</FieldLabel>
<SelectRoot :key="`company-name-${editingEmploymentId ?? 'new'}`" class="w-full"
:collection="companyNameCollection" :default-value="companyNameInitial" :disabled="savingEmployment"
@value-change="setCompanyNameValue">
<SelectControl>
<SelectTrigger :aria-invalid="!!employmentErrors.company_name">
<SelectValueText placeholder="Pilih syarikat" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Nama Syarikat</SelectItemGroupLabel>
<SelectItem v-for="item in companyNameCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="employmentErrors.company_name">{{ employmentErrors.company_name }}</FieldError>
</Field>
<Field>
+62 -105
View File
@@ -90,7 +90,10 @@ const relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS)
const relationshipValue = ref<string[]>([])
const relationshipInitial = ref<string[]>([])
const MAX_HEIRS = 1
const isEditingHeir = computed(() => editingHeirId.value !== null)
const hasReachedHeirLimit = computed(() => heirs.value.length >= MAX_HEIRS)
const showHeirForm = computed(() => isEditingHeir.value || !hasReachedHeirLimit.value)
function emptyHeirForm() {
return {
@@ -204,7 +207,7 @@ async function fetchHeirs() {
await Swal.fire({
icon: 'error',
title: 'Ralat',
text: getApiErrorMessage(error, 'Gagal memuatkan pewaris.'),
text: getApiErrorMessage(error, 'Gagal memuatkan penama.'),
})
} finally {
loadingHeirs.value = false
@@ -224,6 +227,15 @@ function startEditHeir(heir: Heir) {
}
async function onSaveHeir() {
if (!isEditingHeir.value && hasReachedHeirLimit.value) {
await Swal.fire({
icon: 'info',
title: 'Had penama',
text: 'Hanya satu penama dibenarkan.',
})
return
}
if (!validateHeirForm()) {
return
}
@@ -238,7 +250,7 @@ async function onSaveHeir() {
: await createHeir(payload)
if (!res.success) {
throw new Error(res.message ?? 'Gagal menyimpan pewaris.')
throw new Error(res.message ?? 'Gagal menyimpan penama.')
}
await fetchHeirs()
@@ -248,7 +260,7 @@ async function onSaveHeir() {
toast: true,
position: 'top-end',
icon: 'success',
title: wasEditing ? 'Pewaris berjaya dikemas kini.' : 'Pewaris berjaya ditambah.',
title: wasEditing ? 'Penama berjaya dikemas kini.' : 'Penama berjaya ditambah.',
showConfirmButton: false,
timer: 3000,
})
@@ -257,7 +269,7 @@ async function onSaveHeir() {
await Swal.fire({
icon: 'error',
title: 'Ralat',
text: getApiErrorMessage(error, 'Gagal menyimpan pewaris.'),
text: getApiErrorMessage(error, 'Gagal menyimpan penama.'),
})
}
} finally {
@@ -268,7 +280,7 @@ async function onSaveHeir() {
async function onDeleteHeir(heir: Heir) {
const result = await Swal.fire({
icon: 'warning',
title: 'Padam pewaris?',
title: 'Padam penama?',
text: 'Tindakan ini tidak boleh dibatalkan.',
showCancelButton: true,
confirmButtonText: 'Padam',
@@ -283,7 +295,7 @@ async function onDeleteHeir(heir: Heir) {
const res = await deleteHeir(heir.id)
if (!res.success) {
throw new Error(res.message ?? 'Gagal memadam pewaris.')
throw new Error(res.message ?? 'Gagal memadam penama.')
}
if (editingHeirId.value === heir.id) {
@@ -296,7 +308,7 @@ async function onDeleteHeir(heir: Heir) {
toast: true,
position: 'top-end',
icon: 'success',
title: 'Pewaris berjaya dipadam.',
title: 'Penama berjaya dipadam.',
showConfirmButton: false,
timer: 3000,
})
@@ -304,7 +316,7 @@ async function onDeleteHeir(heir: Heir) {
await Swal.fire({
icon: 'error',
title: 'Ralat',
text: getApiErrorMessage(error, 'Gagal memadam pewaris.'),
text: getApiErrorMessage(error, 'Gagal memadam penama.'),
})
} finally {
deletingHeirId.value = null
@@ -323,23 +335,20 @@ onMounted(async () => {
<div class="space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-lg font-semibold text-slate-900">Pewaris</h3>
<h3 class="text-lg font-semibold text-slate-900">Penama</h3>
<p class="mt-1 text-sm text-slate-500">
Urus maklumat pewaris anda.
Urus maklumat penama anda. Hanya satu penama dibenarkan.
</p>
</div>
</div>
<div v-if="loadingHeirs" class="text-sm text-slate-500">
Memuatkan pewaris...
Memuatkan penama...
</div>
<div v-else-if="heirs.length" class="space-y-3">
<div
v-for="heir in heirs"
:key="heir.id"
class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between"
>
<div v-for="heir in heirs" :key="heir.id"
class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-slate-900">{{ heir.name }}</span>
@@ -351,64 +360,47 @@ onMounted(async () => {
<p class="mt-1 text-sm text-slate-700">{{ heir.address }}</p>
</div>
<div class="flex shrink-0 gap-2">
<Button
type="button"
variant="ghost"
class="border border-foreground/15 shadow-none"
:disabled="deletingHeirId === heir.id"
@click="startEditHeir(heir)"
>
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none"
:disabled="deletingHeirId === heir.id" @click="startEditHeir(heir)">
<Lucide class="mr-2 size-4" icon="Pencil" />
Kemaskini
</Button>
<Button
type="button"
variant="ghost"
class="border border-foreground/15 shadow-none text-danger"
:disabled="deletingHeirId === heir.id"
@click="onDeleteHeir(heir)"
>
<Lucide
class="mr-2 size-4"
:icon="deletingHeirId === heir.id ? 'LoaderCircle' : 'Trash'"
:class="{ 'animate-spin': deletingHeirId === heir.id }"
/>
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none text-danger"
:disabled="deletingHeirId === heir.id" @click="onDeleteHeir(heir)">
<Lucide class="mr-2 size-4" :icon="deletingHeirId === heir.id ? 'LoaderCircle' : 'Trash'"
:class="{ 'animate-spin': deletingHeirId === heir.id }" />
Padam
</Button>
</div>
</div>
</div>
<div
v-else
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
>
Tiada pewaris direkodkan.
<div v-else class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500">
Tiada penama direkodkan.
</div>
<form class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveHeir">
<div v-if="hasReachedHeirLimit && !isEditingHeir"
class="rounded-lg border border-foreground/10 bg-foreground/5 p-4 text-sm text-slate-600">
Had penama telah dicapai. Kemaskini atau padam penama sedia ada untuk membuat perubahan.
</div>
<form v-if="showHeirForm" class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveHeir">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 class="text-base font-semibold text-slate-900">
{{ isEditingHeir ? 'Kemaskini Pewaris' : 'Tambah Pewaris' }}
{{ isEditingHeir ? 'Kemaskini Penama' : 'Tambah Penama' }}
</h4>
<p class="mt-1 text-sm text-slate-500">
{{
isEditingHeir
? 'Kemas kini maklumat pewaris yang dipilih.'
: 'Tambah pewaris baharu ke profil anda.'
? 'Kemas kini maklumat penama yang dipilih.'
: 'Tambah penama baharu ke profil anda.'
}}
</p>
</div>
<div class="flex gap-2">
<Button
v-if="isEditingHeir"
type="button"
variant="ghost"
class="border border-foreground/15 shadow-none"
:disabled="savingHeir"
@click="resetHeirForm"
>
<Button v-if="isEditingHeir" type="button" variant="ghost" class="border border-foreground/15 shadow-none"
:disabled="savingHeir" @click="resetHeirForm">
Batal
</Button>
<Button type="submit" variant="primary" :disabled="savingHeir">
@@ -421,38 +413,21 @@ onMounted(async () => {
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="heir-name">Nama</FieldLabel>
<Input
id="heir-name"
v-model="heirForm.name"
type="text"
placeholder="Nama penuh"
:aria-invalid="!!heirErrors.name"
@input="clearHeirFieldError('name')"
/>
<Input id="heir-name" v-model="heirForm.name" type="text" placeholder="Nama penuh"
:aria-invalid="!!heirErrors.name" @input="clearHeirFieldError('name')" />
<FieldError v-if="heirErrors.name">{{ heirErrors.name }}</FieldError>
</Field>
<Field>
<FieldLabel for="heir-ic">No. Kad Pengenalan</FieldLabel>
<Input
id="heir-ic"
v-model="heirForm.ic_number"
type="text"
placeholder="No. kad pengenalan"
:aria-invalid="!!heirErrors.ic_number"
@input="clearHeirFieldError('ic_number')"
/>
<FieldLabel for="heir-ic-number">No. Kad Pengenalan</FieldLabel>
<Input id="heir-ic-number" v-model="heirForm.ic_number" type="text" placeholder="Contoh: 900101011234"
:aria-invalid="!!heirErrors.ic_number" @input="clearHeirFieldError('ic_number')" />
<FieldError v-if="heirErrors.ic_number">{{ heirErrors.ic_number }}</FieldError>
</Field>
<Field>
<FieldLabel>Hubungan</FieldLabel>
<SelectRoot
:key="`heir-relationship-${editingHeirId ?? 'new'}`"
class="w-full"
:collection="relationshipCollection"
:default-value="relationshipInitial"
:disabled="savingHeir"
@value-change="setRelationshipValue"
>
<SelectRoot :key="`heir-relationship-${editingHeirId ?? 'new'}`" class="w-full"
:collection="relationshipCollection" :default-value="relationshipInitial" :disabled="savingHeir"
@value-change="setRelationshipValue">
<SelectControl>
<SelectTrigger :aria-invalid="!!heirErrors.relationship">
<SelectValueText placeholder="Pilih hubungan" />
@@ -461,11 +436,7 @@ onMounted(async () => {
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Hubungan</SelectItemGroupLabel>
<SelectItem
v-for="item in relationshipCollection.items"
:key="item.label"
:item="item"
>
<SelectItem v-for="item in relationshipCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
@@ -474,37 +445,23 @@ onMounted(async () => {
<FieldError v-if="heirErrors.relationship">{{ heirErrors.relationship }}</FieldError>
</Field>
<Field>
<FieldLabel for="heir-phone">No. Telefon</FieldLabel>
<Input
id="heir-phone"
v-model="heirForm.phone_number"
type="text"
placeholder="No. telefon"
:aria-invalid="!!heirErrors.phone_number"
@input="clearHeirFieldError('phone_number')"
/>
<FieldLabel for="heir-phone-number">No. Telefon</FieldLabel>
<Input id="heir-phone-number" v-model="heirForm.phone_number" type="text"
placeholder="Contoh: 0123456789" :aria-invalid="!!heirErrors.phone_number"
@input="clearHeirFieldError('phone_number')" />
<FieldError v-if="heirErrors.phone_number">{{ heirErrors.phone_number }}</FieldError>
</Field>
<Field class="md:col-span-2">
<FieldLabel for="heir-address">Alamat</FieldLabel>
<Textarea
id="heir-address"
v-model="heirForm.address"
placeholder="Alamat penuh"
class="resize-none"
:aria-invalid="!!heirErrors.address"
@input="clearHeirFieldError('address')"
/>
<Textarea id="heir-address" v-model="heirForm.address" rows="3" placeholder="Alamat penama"
:aria-invalid="!!heirErrors.address" @input="clearHeirFieldError('address')" />
<FieldError v-if="heirErrors.address">{{ heirErrors.address }}</FieldError>
</Field>
<Field class="md:col-span-2">
<CheckboxRoot
:checked="heirForm.is_primary"
:disabled="savingHeir"
@checked-change="({ checked }) => (heirForm.is_primary = checked === true)"
>
<CheckboxRoot :checked="heirForm.is_primary" :disabled="savingHeir"
@checked-change="({ checked }) => (heirForm.is_primary = checked === true)">
<CheckboxControl />
<CheckboxLabel>Pewaris utama</CheckboxLabel>
<CheckboxLabel>Penama utama</CheckboxLabel>
</CheckboxRoot>
</Field>
</div>
+136 -72
View File
@@ -1,28 +1,17 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import Swal from 'sweetalert2'
import fakers from '@/utils/faker'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { SwitchRoot, SwitchControl } from '@/components/ui/switch'
import { ProgressRoot, ProgressTrack, ProgressRange } from '@/components/ui/progress-linear'
import {
CarouselRoot,
CarouselPrevTrigger,
CarouselNextTrigger,
CarouselItemGroup,
CarouselItem,
} from '@/components/ui/carousel'
import { Lucide } from '@/components/ui/lucide'
import { FileIcon } from '@/components/ui/file-icon'
import { Badge } from '@/components/ui/badge'
import logoUrl from '@/assets/images/logo-kopkb.svg'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { listEmployments } from '@/modules/profile/services/employment.service'
import { uploadProfileImage } from '@/modules/profile/services/profile.service'
import type { Employment } from '@/modules/profile/types/employment.types'
import { useAuthStore } from '@/stores/auth'
import MemberDigitalCard from '../components/MemberDigitalCard.vue'
import ProfileTab from './ProfileTab.vue'
import EmploymentTab from './EmploymentTab.vue'
import BankDetailTab from './BankDetailTab.vue'
@@ -34,12 +23,59 @@ const authStore = useAuthStore()
const uploadingImage = ref(false)
const imageInputRef = ref<HTMLInputElement | null>(null)
const imagePreviewUrl = ref<string | null>(null)
const employments = ref<Employment[]>([])
const companyName = computed(() => {
const currentEmployment = employments.value.find((employment) => employment.is_current)
return currentEmployment?.company_name ?? employments.value[0]?.company_name ?? null
})
const profileUrl = computed(() => {
const token = authStore.user?.public_profile_token
if (!token) return null
const baseUrl = (typeof window !== 'undefined'
? window.location.origin
: import.meta.env.VITE_APP_URL || ''
).replace(/\/$/, '')
return `${baseUrl}/v/${token}`
})
const displayValue = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === '') return '-'
return String(value).trim() || '-'
}
const STATUS_LABELS: Record<string, string> = {
active: 'Aktif',
pending: 'Menunggu',
inactive: 'Tidak Aktif',
}
const statusLabel = computed(() => {
const status = authStore.userStatus
if (!status) return '-'
return STATUS_LABELS[status] ?? status.charAt(0).toUpperCase() + status.slice(1)
})
const statusBadgeVariant = computed(() => {
if (authStore.isAccountActive) return 'success' as const
if (authStore.isAccountPending) return 'pending' as const
return 'secondary' as const
})
function formatDateLabel(value: string | null | undefined): string {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat('ms-MY', {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date)
}
const avatarSrc = computed(
() => imagePreviewUrl.value ?? authStore.userImageUrl ?? undefined,
)
@@ -98,10 +134,21 @@ async function onImageSelected(event: Event) {
}
}
async function fetchEmployments() {
try {
const res = await listEmployments()
employments.value = res.data
} catch {
employments.value = []
}
}
onMounted(async () => {
if (!authStore.user) {
await authStore.fetchSession()
}
await fetchEmployments()
})
</script>
@@ -113,9 +160,10 @@ onMounted(async () => {
<TabsRoot defaultValue="1">
<!-- BEGIN: Profile Info -->
<Box raised="single" class="mt-5 p-0">
<div class="flex flex-col border-b border-foreground/15 p-5 lg:flex-row">
<div class="flex flex-1 items-center justify-center px-5 lg:justify-start">
<div class="relative" :class="{ 'opacity-60': uploadingImage }">
<div class="flex flex-col border-b border-foreground/15 lg:flex-row">
<!-- Identity -->
<div class="flex flex-1 items-center justify-center p-5 lg:justify-start">
<div class="relative shrink-0" :class="{ 'opacity-60': uploadingImage }">
<AvatarRoot class="size-20 border-5 bg-background rounded-full sm:size-24 lg:size-32">
<AvatarFallback>{{ authStore.userName }}</AvatarFallback>
<AvatarImage v-if="avatarSrc" :src="avatarSrc" :alt="authStore.userName" />
@@ -129,87 +177,103 @@ onMounted(async () => {
<input ref="imageInputRef" type="file" accept="image/jpeg,image/png,image/jpg,image/gif" class="hidden"
@change="onImageSelected" />
</div>
<div class="ml-5">
<div class="w-24 truncate text-lg font-medium sm:w-40 sm:whitespace-normal">
<div class="ml-5 min-w-0">
<div class="truncate text-lg font-medium sm:whitespace-normal">
{{ authStore.userName || '-' }}
</div>
<div v-if="authStore.userMemberType"
class="mt-1 truncate text-sm capitalize opacity-70 sm:whitespace-normal">
{{ authStore.userMemberType }}
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<Badge :variant="statusBadgeVariant">{{ statusLabel }}</Badge>
<Badge v-if="authStore.userMemberNumber" look="outline" variant="secondary">
No. {{ authStore.userMemberNumber }}
</Badge>
</div>
</div>
</div>
<div
class="mt-6 flex-1 border-t border-l border-r border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-t-0 lg:pt-0">
<div class="text-center font-medium lg:mt-3 lg:text-left">Maklumat Hubungan</div>
<div class="mt-4 flex flex-col items-center justify-center lg:items-start">
<!-- Contact & membership -->
<div class="flex-1 border-t border-foreground/15 p-5 lg:border-t-0 lg:border-l">
<div class="text-center font-medium lg:text-left">Maklumat Hubungan</div>
<div class="mt-4 flex flex-col items-center lg:items-start">
<div class="flex items-center truncate sm:whitespace-normal">
<Lucide class="mr-2 size-4" icon="Mail" />
<Lucide class="mr-2 size-4 shrink-0" icon="Mail" />
{{ displayValue(authStore.user?.email) }}
</div>
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
<Lucide class="mr-2 size-4" icon="Phone" />
<Lucide class="mr-2 size-4 shrink-0" icon="Phone" />
{{ displayValue(authStore.user?.phone_number) }}
</div>
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
<Lucide class="mr-2 size-4" icon="IdCard" />
<Lucide class="mr-2 size-4 shrink-0" icon="IdCard" />
{{ displayValue(authStore.user?.ic_number) }}
</div>
</div>
</div>
<div
class="mt-6 flex flex-1 items-center justify-center border-t border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-0 lg:pt-0">
<div
class="relative aspect-1.75/1 w-full max-w-68 overflow-hidden rounded-2xl bg-linear-to-br from-primary via-primary/95 to-primary/75 p-4 text-primary-foreground shadow-lg ring-1 ring-white/20 sm:max-w-xs sm:p-5"
role="img" aria-label="Kad digital anggota">
<div class="pointer-events-none absolute inset-0 bg-noise opacity-30" />
<div class="pointer-events-none absolute -right-10 -top-10 size-36 rounded-full bg-white/10" />
<div class="pointer-events-none absolute -bottom-12 -left-8 size-40 rounded-full bg-white/5" />
<div
class="pointer-events-none absolute right-4 top-1/2 size-10 -translate-y-1/2 rounded-md border border-white/20 bg-white/10" />
<div class="relative flex h-full flex-col justify-between">
<div class="flex items-start justify-between gap-3">
<img :src="logoUrl" alt="" class="h-7 w-auto brightness-0 invert sm:h-8" />
<div class="text-right text-[10px] font-semibold uppercase tracking-[0.2em] opacity-80">
Kad Digital
</div>
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3">
<div class="text-center lg:text-left">
<div class="truncate text-base font-medium">
{{ displayValue(authStore.userPosition) }}
</div>
<div>
<div class="text-[10px] font-medium uppercase tracking-widest opacity-70">No. Anggota</div>
<div class="mt-1 font-mono text-2xl font-semibold tracking-[0.15em] sm:text-3xl">
{{ displayValue(authStore.userMemberNumber) }}
</div>
<div class="text-xs opacity-70">Jawatan</div>
</div>
<div class="text-center lg:text-left">
<div class="truncate text-base font-medium">
{{ displayValue(companyName) }}
</div>
<div class="flex items-end justify-between gap-3 border-t border-white/15 pt-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ authStore.userName || '-' }}</div>
<div class="mt-0.5 text-[10px] uppercase tracking-wide opacity-60">Nama</div>
</div>
<div class="shrink-0 text-right">
<div class="text-sm font-semibold">{{ displayValue(authStore.userMemberType) }}</div>
<div class="mt-0.5 text-[10px] uppercase tracking-wide opacity-60">Jenis Anggota</div>
</div>
<div class="text-xs opacity-70">Unit</div>
</div>
<div class="col-span-2 text-center sm:col-span-1 lg:text-left">
<div class="truncate text-base font-medium">
{{ formatDateLabel(authStore.userJoinDate) }}
</div>
<div class="text-xs opacity-70">Tarikh Sertai</div>
</div>
</div>
</div>
<!-- Digital card -->
<div
class="flex shrink-0 items-center justify-center border-t border-foreground/15 p-5 lg:border-t-0 lg:border-l">
<MemberDigitalCard large :member-number="authStore.userMemberNumber" :member-name="authStore.userName"
:member-type="authStore.userMemberType" :company-name="companyName" :profile-url="profileUrl"
:image-url="avatarSrc" />
</div>
</div>
<!-- Tabs title -->
<div class="px-5 py-4">
<TabsList class="w-full mb-0 flex justify-between">
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="1">
<Lucide class="mr-2 size-4" icon="User" /> Profil
<TabsList class="mb-0 flex w-full">
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
value="1" aria-label="Profil">
<Lucide class="size-4 shrink-0 md:mr-2" icon="User" />
<span class="hidden md:inline">Profil</span>
</TabsTrigger>
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="5">
<Lucide class="mr-2 size-4" icon="Briefcase" /> Pekerjaan
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
value="5" aria-label="Pekerjaan">
<Lucide class="size-4 shrink-0 md:mr-2" icon="Briefcase" />
<span class="hidden md:inline">Pekerjaan</span>
</TabsTrigger>
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="3">
<Lucide class="mr-2 size-4" icon="Banknote" /> Bank
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
value="3" aria-label="Bank">
<Lucide class="size-4 shrink-0 md:mr-2" icon="Banknote" />
<span class="hidden md:inline">Bank</span>
</TabsTrigger>
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="6">
<Lucide class="mr-2 size-4" icon="Users" /> Pewaris
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
value="6" aria-label="Penama">
<Lucide class="size-4 shrink-0 md:mr-2" icon="Users" />
<span class="hidden md:inline">Penama</span>
</TabsTrigger>
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="2">
<Lucide class="mr-2 size-4" icon="Lock" /> Kata Laluan
<TabsTrigger
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
value="2" aria-label="Kata Laluan">
<Lucide class="size-4 shrink-0 md:mr-2" icon="Lock" />
<span class="hidden md:inline">Kata Laluan</span>
</TabsTrigger>
</TabsList>
</div>
@@ -230,7 +294,7 @@ onMounted(async () => {
<TabsContent value="2" class="mt-8">
<ChangePasswordTab embedded />
</TabsContent>
<!-- Pewaris -->
<!-- Penama -->
<TabsContent value="6" class="mt-8">
<HeirTab embedded />
</TabsContent>
@@ -0,0 +1,140 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import { Lucide } from '@/components/ui/lucide'
import logoUrl from '@/assets/images/logo.svg'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { getPublicMemberProfile } from '../services/public-member-profile.service'
import type { PublicMemberProfile } from '../types/public-member-profile.types'
const route = useRoute()
const loading = ref(true)
const error = ref<string | null>(null)
const member = ref<PublicMemberProfile | null>(null)
const token = computed(() => String(route.params.token ?? '').trim())
const avatarFallback = computed(() => {
const name = member.value?.name?.trim()
if (!name) return '--'
return name.slice(0, 2).toUpperCase()
})
const displayValue = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === '') return '-'
return String(value).trim() || '-'
}
async function fetchMemberProfile() {
loading.value = true
error.value = null
member.value = null
if (!token.value) {
error.value = 'Pautan pengesahan tidak sah.'
loading.value = false
return
}
try {
const response = await getPublicMemberProfile(token.value)
if (!response.success || !response.data) {
throw new Error(response.message ?? 'Anggota tidak dijumpai atau tidak sah.')
}
member.value = response.data
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan profil anggota.')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchMemberProfile()
})
</script>
<template>
<div class="min-h-screen bg-slate-100 px-4 py-10">
<div class="mx-auto w-full max-w-md">
<div class="mb-6 flex flex-col items-center text-center">
<img :src="logoUrl" alt="MyKOPKB" class="h-10 w-auto" />
<h1 class="mt-4 text-xl font-semibold text-slate-900">Maklumat Anggota</h1>
</div>
<AlertRoot v-if="error" variant="danger">
<AlertTitle>Pengesahan gagal</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</AlertRoot>
<Box v-else-if="loading" raised="single" class="p-8 text-center text-sm text-slate-500">
Memuatkan maklumat anggota...
</Box>
<Box v-else-if="member" raised="single" class="overflow-hidden p-0">
<div class="bg-linear-to-br from-primary via-primary/95 to-primary/75 p-6 text-primary-foreground">
<div class="flex items-start justify-between gap-3">
<Badge class="bg-white/15 text-white">Disahkan</Badge>
<div class="text-right text-[10px] font-semibold uppercase tracking-[0.2em] opacity-80">
MyKOPKB
</div>
</div>
<div class="mt-6 flex items-center gap-4">
<AvatarRoot class="size-16 border-4 border-white/20 bg-white/10">
<AvatarFallback>{{ avatarFallback }}</AvatarFallback>
<AvatarImage v-if="member.image_url" :src="member.image_url" :alt="member.name" />
</AvatarRoot>
<div class="min-w-0 flex-1">
<div class="truncate text-lg font-semibold">{{ member.name }}</div>
<div class="mt-1 text-sm opacity-80">{{ displayValue(member.member_type) }}</div>
</div>
</div>
</div>
<div class="space-y-4 p-6">
<div class="flex items-center gap-3 rounded-lg border border-foreground/10 p-4">
<Lucide class="size-5 text-primary" icon="IdCard" />
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">No. Anggota</div>
<div class="font-mono text-base font-semibold text-slate-900">
{{ displayValue(member.member_number) }}
</div>
</div>
</div>
<div class="flex items-center gap-3 rounded-lg border border-foreground/10 p-4">
<Lucide class="size-5 text-primary" icon="Briefcase" />
<div class="min-w-0">
<div class="text-xs uppercase tracking-wide text-slate-500">Unit</div>
<div class="truncate text-base font-medium text-slate-900">
{{ displayValue(member.company_name) }}
</div>
</div>
</div>
<div class="flex items-center gap-3 rounded-lg border border-foreground/10 p-4">
<Lucide class="size-5 text-primary" icon="CircleCheck" />
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">Status</div>
<div class="text-base font-medium capitalize text-slate-900">
{{ displayValue(member.status) }}
</div>
</div>
</div>
<p class="text-center text-xs text-slate-500">
Disahkan pada {{ new Date(member.verified_at).toLocaleString('ms-MY') }}
</p>
</div>
</Box>
</div>
</div>
</template>
+9
View File
@@ -1,5 +1,14 @@
import type { RouteRecordRaw } from 'vue-router'
export const profilePublicRoutes: RouteRecordRaw[] = [
{
path: '/v/:token',
name: 'public-member-profile',
component: () => import('./pages/PublicMemberProfile.vue'),
meta: { public: true, module: 'profile' },
},
]
export const profileLayoutRoutes: RouteRecordRaw[] = [
{
path: 'profile-overview-2',
@@ -0,0 +1,35 @@
import { saveAs } from 'file-saver'
import axios from 'axios'
import { api } from '@/core/services/api'
import { buildCardDownloadFileName } from '../utils/member-digital-card.utils'
export async function downloadMemberDigitalCard(
memberNumber: string | number | null | undefined,
side: 'depan' | 'belakang',
) {
try {
const { data } = await api.get<Blob>('/v1/profile/digital-card', {
params: { side },
responseType: 'blob',
})
saveAs(data, buildCardDownloadFileName(memberNumber, side))
} catch (error) {
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
const text = await error.response.data.text()
try {
const payload = JSON.parse(text) as { message?: string }
throw new Error(payload.message ?? 'Gagal menyimpan kad.')
} catch (parseError) {
if (parseError instanceof SyntaxError) {
throw error
}
throw parseError
}
}
throw error
}
}
@@ -63,3 +63,8 @@ export async function updatePassword(payload: UpdatePasswordPayload): Promise<Up
const { data } = await api.put<UpdatePasswordResponse>('/v1/profile/password', payload)
return data
}
export async function completeOnboarding(): Promise<UpdateProfileResponse> {
const { data } = await api.post<UpdateProfileResponse>('/v1/profile/onboarding/complete')
return data
}
@@ -0,0 +1,12 @@
import { api } from '@/core/services/api'
import type { PublicMemberProfileApiResponse } from '../types/public-member-profile.types'
export async function getPublicMemberProfile(
token: string,
): Promise<PublicMemberProfileApiResponse> {
const { data } = await api.get<PublicMemberProfileApiResponse>(
`/v1/public/members/${encodeURIComponent(token)}`,
)
return data
}
@@ -0,0 +1,16 @@
export interface PublicMemberProfile {
name: string
member_number: number | null
member_type: string | null
status: string
image_url: string | null
company_name: string | null
verified_at: string
}
export interface PublicMemberProfileApiResponse {
success: boolean
message?: string
code?: 'public_profile_not_found' | 'public_profile_token_expired'
data: PublicMemberProfile | null
}
@@ -0,0 +1,27 @@
export function displayCardValue(value: string | number | null | undefined) {
if (value === null || value === undefined || value === '') return '-'
return String(value).trim() || '-'
}
export function buildCardDownloadFileName(
memberNumber: string | number | null | undefined,
side: 'depan' | 'belakang',
) {
const number = memberNumber ?? 'anggota'
return `kad-digital-${number}-${side}.png`
}
export function toProxiedStorageUrl(url: string | null | undefined): string | null | undefined {
if (!url) return url
try {
const parsed = new URL(url, window.location.origin)
if (parsed.pathname.startsWith('/storage/')) {
return `${parsed.pathname}${parsed.search}`
}
} catch {
// Keep original URL when parsing fails.
}
return url
}