feat(ui): integrate Nuxt UI and rebuild layout and login pages

Wrap application in UApp and apply base UI styles
Build responsive default layout with navigation and footer
Implement staff login form with validation and toast notifications
Restructure index page routing
This commit is contained in:
2026-04-12 18:26:36 +08:00
parent 4288c98e21
commit a649c509c2
8 changed files with 360 additions and 197 deletions

229
app/pages/index.vue Normal file
View File

@@ -0,0 +1,229 @@
<script lang="ts" setup>
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
type BookingMode = 'table' | 'pax'
type TicketType = 'vip' | 'supporter'
const toast = useToast()
const eventDetails = [
{
label: 'Date',
value: 'Saturday, 30 May 2026',
icon: 'lucide:calendar-days'
},
{
label: 'Time',
value: '6:30 PM',
icon: 'lucide:clock-6'
},
{
label: 'Venue',
value: "Yong Peng's Chee Ann Kor",
icon: 'lucide:map-pin'
}
] as const
const bookingModeOptions = [
{
value: 'table',
label: 'Table (10 pax)'
},
{
value: 'pax',
label: 'Person'
}
] satisfies Array<{ value: BookingMode, label: string }>
const ticketCatalog = [
{
value: 'vip',
label: 'VIP',
description: 'RM150 / pax',
price: 150
},
{
value: 'supporter',
label: 'Supporter',
description: 'RM60 / pax',
price: 60
}
] satisfies Array<{ value: TicketType, label: string, description: string, price: number }>
const personInCharge = [
{
label: 'Xiaomai',
value: '601157753558'
},
{
label: 'Lily',
value: '60172661198'
}
]
const priceFormatter = new Intl.NumberFormat('en-MY', {
style: 'currency',
currency: 'MYR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
})
const form = reactive({
name: '',
phone: '',
bookingMode: 'table' as BookingMode,
quantity: 1,
ticketType: 'vip' as TicketType
})
const selectedPersonInCharge = ref(personInCharge[0]?.value ?? '')
const selectedTicket = computed(() => {
return ticketCatalog.find((ticket) => ticket.value === form.ticketType) ?? ticketCatalog[0]
})
const seatMultiplier = computed(() => form.bookingMode === 'table' ? 10 : 1)
const quantityLabel = computed(() => {
return form.bookingMode === 'table' ? 'Number of tables' : 'Number of people'
})
const totalPrice = computed(() => form.quantity * seatMultiplier.value * selectedTicket.value.price)
const totalFormatted = computed(() => priceFormatter.format(totalPrice.value))
function validateBooking(state: typeof form): FormError[] {
const errors: FormError[] = []
if (!state.name.trim()) {
errors.push({ name: 'name', message: 'Please enter the guest or organizer name.' })
}
if (!state.phone.trim()) {
errors.push({ name: 'phone', message: 'Please enter a contact number.' })
} else if (!/^\+?[0-9\s-]{8,15}$/.test(state.phone.trim())) {
errors.push({ name: 'phone', message: 'Use a valid phone number with 8 to 15 digits.' })
}
if (state.quantity < 1) {
errors.push({ name: 'quantity', message: 'Quantity must be at least 1.' })
}
return errors
}
function buildBookingMessage() {
const bookingModeLabel = form.bookingMode === 'table' ? 'Table (10 pax each)' : 'Per person'
return [
"I'd like to book tickets for the DAP Johor 60th Anniversary Celebration.",
'',
`Name: ${form.name.trim()}`,
`Phone Number: ${form.phone.trim()}`,
`Booking Mode: ${bookingModeLabel}`,
`Quantity: ${form.quantity}`,
`Ticket Category: ${selectedTicket.value.label}`,
`Seats Covered: ${form.quantity * seatMultiplier.value}`,
`Total Price: ${totalFormatted.value}`
].join('\n')
}
function bookTicket(event: FormSubmitEvent<typeof form>) {
const selectedPic = personInCharge.find((item) => item.value === selectedPersonInCharge.value) ?? personInCharge[0]
const encodedMessage = encodeURIComponent(buildBookingMessage())
const whatsappUrl = `https://wa.me/${selectedPic.value}?text=${encodedMessage}`
const bookingWindow = window.open(whatsappUrl, '_blank', 'noopener,noreferrer')
if (!bookingWindow) {
toast.add({
title: 'WhatsApp could not be opened',
description: 'Allow pop-ups for this site, then submit the booking again.',
color: 'error',
icon: 'i-lucide-circle-alert'
})
return
}
toast.add({
title: 'WhatsApp booking draft opened',
description: `Your reservation details were sent to ${selectedPic.label}.`,
color: 'success',
icon: 'i-lucide-check-circle-2'
})
event.preventDefault()
}
</script>
<template>
<UContainer class="py-8">
<div class="mx-auto max-w-2xl">
<div class="mb-8 text-center">
<h1 class="text-3xl font-extrabold tracking-tight text-highlighted sm:text-4xl">
DAP JOHOR 60th Anniversary Celebration
</h1>
</div>
<UCard id="booking-form" class="border border-default bg-default" :ui="{
header: 'space-y-4',
body: 'space-y-6'
}">
<template #header>
<div class="space-y-3">
<div v-for="detail in eventDetails" :key="detail.label"
class="flex items-center gap-3 text-sm text-default">
<UIcon :name="detail.icon" class="size-4 text-muted" />
<span>{{ detail.value }}</span>
</div>
</div>
</template>
<UForm :state="form" :validate="validateBooking" class="space-y-6" @submit="bookTicket">
<div class="space-y-5">
<UFormField name="name" label="Name" required>
<UInput v-model="form.name" size="xl" class="w-full" placeholder="e.g. John Doe" />
</UFormField>
<UFormField name="phone" label="Phone Number" required>
<UInput v-model="form.phone" size="xl" type="tel" class="w-full" placeholder="e.g. 0123456789" />
</UFormField>
</div>
<UFormField label="Booking Mode" name="bookingMode">
<URadioGroup v-model="form.bookingMode" orientation="horizontal" variant="card" indicator="hidden"
:items="bookingModeOptions" :ui="{
fieldset: 'grid grid-cols-2 gap-3',
item: 'rounded-xl border border-default bg-default p-3 data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
}" />
</UFormField>
<UFormField :label="quantityLabel" name="quantity">
<UInputNumber v-model="form.quantity" size="xl" class="w-full" :min="1" :step="1" />
</UFormField>
<UFormField label="Ticket Category" name="ticketType">
<URadioGroup v-model="form.ticketType" orientation="horizontal" variant="card" indicator="hidden"
:items="ticketCatalog" :ui="{
fieldset: 'grid grid-cols-2 gap-3',
item: 'rounded-xl border border-default bg-default p-3 data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
}" />
</UFormField>
<div class="rounded-xl border border-default bg-muted px-4 py-4">
<div class="flex items-center justify-between gap-4">
<span class="text-sm font-medium text-muted">Total Price</span>
<span class="text-2xl font-bold text-highlighted">{{ totalFormatted }}</span>
</div>
</div>
<UFormField label="Person In Charge">
<USelect v-model="selectedPersonInCharge" size="xl" class="w-full" :items="personInCharge" />
</UFormField>
<UButton id="getTicketBtn" type="submit" label="Book Your Ticket Now" size="xl"
class="w-full justify-center" />
</UForm>
</UCard>
</div>
</UContainer>
</template>