DONE: digital card with profile display: WIP: membership condition on letter, is_first_time column in users table
This commit is contained in:
@@ -37,6 +37,7 @@ export interface AuthUser {
|
||||
image_url: string | null
|
||||
member_number: number | null
|
||||
member_type: string | null
|
||||
public_profile_token: string | null
|
||||
status: string
|
||||
gender: string | null
|
||||
marriage_status: string | null
|
||||
|
||||
@@ -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,2 +1,2 @@
|
||||
export { profileLayoutRoutes } from './routes'
|
||||
export { profileLayoutRoutes, profilePublicRoutes } from './routes'
|
||||
export { profileMenu } from './menu'
|
||||
|
||||
@@ -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">Syarikat</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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user