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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ logs
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
.agents/
|
||||||
|
skills-lock.json
|
||||||
8
app.config.ts
Normal file
8
app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
ui: {
|
||||||
|
colors: {
|
||||||
|
primary: 'amber',
|
||||||
|
neutral: 'stone'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<UApp>
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</div>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1 +1,11 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "@nuxt/ui";
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: var(--ui-bg);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="min-h-dvh bg-default text-default">
|
||||||
|
<header class="border-b border-default bg-default">
|
||||||
|
<UContainer class="flex items-center justify-between gap-4 py-6">
|
||||||
|
<UBadge
|
||||||
|
label="Event Ticket System"
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
class="rounded-full px-3 py-1 font-semibold"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
id="loginBtn"
|
||||||
|
:to="route.path.startsWith('/login') ? '/' : '/login'"
|
||||||
|
:label="route.path.startsWith('/login') ? 'Back' : 'Login'"
|
||||||
|
color="neutral"
|
||||||
|
:variant="route.path.startsWith('/login') ? 'outline' : 'solid'"
|
||||||
|
:icon="route.path.startsWith('/login') ? 'i-lucide-arrow-left' : 'i-lucide-lock-keyhole'"
|
||||||
|
/>
|
||||||
|
</UContainer>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<UMain>
|
||||||
<slot />
|
<slot />
|
||||||
|
</UMain>
|
||||||
|
|
||||||
|
<footer class="border-t border-default bg-default">
|
||||||
|
<UContainer class="py-5 text-center text-sm text-muted">
|
||||||
|
© 2026 DAP 60th Anniversary Committee. All rights reserved.
|
||||||
|
</UContainer>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
const route = useRoute()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style></style>
|
|
||||||
229
app/pages/index.vue
Normal file
229
app/pages/index.vue
Normal 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>
|
||||||
@@ -1,184 +1,3 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col h-screen">
|
|
||||||
<div class="flex justify-between items-center px-6 pt-6 pb-2">
|
|
||||||
<span class="text-sm font-semibold tracking-wide text-stone-700 bg-stone-100/80 px-3 py-1.5 rounded-full">
|
|
||||||
Event Ticket System
|
|
||||||
</span>
|
|
||||||
<RouterLink to="/login" id="loginBtn"
|
|
||||||
class="text-sm shadow-md shadow-slate-900/20 bg-gradient-to-r from-slate-800 to-slate-900 hover:from-slate-700 hover:to-slate-800 text-white font-bold px-4 py-1 rounded-full shadow-lg shadow-slate-900/20 transition-all duration-200 active:scale-[0.98] flex items-center justify-center gap-2 text-base">
|
|
||||||
Login
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
<main class="px-5 flex flex-col justify-center">
|
|
||||||
<h1
|
|
||||||
class="text-center item-center text-3xl md:text-4xl font-extrabold tracking-tight bg-gradient-to-r from-stone-800 to-stone-700 bg-clip-text text-transparent leading-tight py-8">
|
|
||||||
<span v-html="titleHtml"></span>
|
|
||||||
</h1>
|
|
||||||
<div>
|
|
||||||
<div v-for="(item, index) in eventDetails" :key="index" class="mb-2 flex items-center gap-2">
|
|
||||||
<Icon :name="item.icon" />
|
|
||||||
<span>{{ item.value }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="border-t border-stone-100 pt-5 flex flex-col gap-5">
|
|
||||||
<div>
|
|
||||||
<label class=" block text-sm font-semibold text-stone-700 mb-1.5">
|
|
||||||
Name <span class="text-amber-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" v-model="form.name" placeholder="e.g., John Doe"
|
|
||||||
class="w-full px-4 py-2.5 rounded-xl border border-stone-300 bg-stone-50/40 focus:bg-white transition">
|
|
||||||
<p v-if="errors.name" class="text-xs text-rose-600 mt-1">{{ errors.name }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class=" block text-sm font-semibold text-stone-700 mb-1.5">
|
|
||||||
Phone Number <span class="text-amber-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input type="tel" v-model="form.phone" placeholder="e.g., 0123456789"
|
|
||||||
class="w-full px-4 py-2.5 rounded-xl border border-stone-300 bg-stone-50/40 focus:bg-white transition">
|
|
||||||
<p v-if="errors.phone" class="text-xs text-rose-600 mt-1">{{ errors.phone }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class=" block text-sm font-semibold text-stone-700 mb-1.5">
|
|
||||||
Booking Mode
|
|
||||||
</label>
|
|
||||||
<div class="flex gap-3 bg-stone-100/60 p-1 rounded-xl">
|
|
||||||
<button type="button" @click="form.bookingMode = 'table'"
|
|
||||||
:class="['flex-1 py-2.5 rounded-lg font-medium transition',
|
|
||||||
form.bookingMode === 'table' ? 'bg-stone-800 text-white shadow-md' : 'bg-white text-stone-700 border border-stone-200']">
|
|
||||||
🪑 Table (10 pax)
|
|
||||||
</button>
|
|
||||||
<button type="button" @click="form.bookingMode = 'pax'"
|
|
||||||
:class="['flex-1 py-2.5 rounded-lg font-medium transition',
|
|
||||||
form.bookingMode === 'pax' ? 'bg-stone-800 text-white shadow-md' : 'bg-white text-stone-700 border border-stone-200']">
|
|
||||||
👥 Person
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-semibold text-stone-700 mb-2">
|
|
||||||
{{ form.bookingMode === 'pax' ? 'Number of people' : 'Number of tables (席位)' }}
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center justify-between bg-stone-50/70 rounded-xl p-1 border border-stone-200">
|
|
||||||
<button @click="adjustQuantity(-1)"
|
|
||||||
class="w-10 h-10 rounded-full bg-stone-100 flex items-center justify-center text-xl font-medium hover:bg-stone-200 transition"
|
|
||||||
:disabled="form.quantity <= 1" :class="{ 'opacity-40 cursor-not-allowed': form.quantity <= 1 }">−</button>
|
|
||||||
<span class="text-2xl font-bold text-stone-800 min-w-[60px] text-center">{{ form.quantity }}</span>
|
|
||||||
<button @click="adjustQuantity(1)"
|
|
||||||
class="w-10 h-10 rounded-full bg-stone-100 flex items-center justify-center text-xl font-medium hover:bg-stone-200 transition">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class=" block text-sm font-semibold text-stone-700 mb-1.5">
|
|
||||||
Ticket Category
|
|
||||||
</label>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<button @click="form.ticketType = 'vip'"
|
|
||||||
:class="['py-2.5 rounded-xl border font-medium transition',
|
|
||||||
form.ticketType === 'vip' ? 'bg-amber-800 text-white border-amber-800' : 'bg-white border-stone-300 text-stone-700 hover:bg-stone-50']">
|
|
||||||
✨ VIP <span class="block text-xs">RM150 / pax</span>
|
|
||||||
</button>
|
|
||||||
<button @click="form.ticketType = 'supporter'"
|
|
||||||
:class="['py-2.5 rounded-xl border font-medium transition',
|
|
||||||
form.ticketType === 'supporter' ? 'bg-stone-800 text-white border-stone-800' : 'bg-white border-stone-300 text-stone-700 hover:bg-stone-50']">
|
|
||||||
🎟️ Supporter <span class="block text-xs">RM60 / pax</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gradient-to-r from-stone-100 to-stone-50 rounded-xl p-4 border border-stone-200">
|
|
||||||
<div class="flex justify-between items-center flex-wrap">
|
|
||||||
<span class="text-stone-600 font-medium">💰 Total Price</span>
|
|
||||||
<span class="text-3xl font-black text-stone-800">RM {{ totalFormatted }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-semibold text-stone-700 mb-1.5">Person In Charge</label>
|
|
||||||
<select v-model="selectedPersonInCharge"
|
|
||||||
class="w-full px-4 py-2.5 rounded-xl border border-stone-300 bg-stone-50/40 focus:bg-white cursor-pointer">
|
|
||||||
<option v-for="(pic, index) in personInCharge" :key="index" :value="pic.phone">{{ pic.name }}</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-stone-400 mt-1">WhatsApp message will be sent to your selected PIC</p>
|
|
||||||
</div>
|
|
||||||
<button id="getTicketBtn"
|
|
||||||
class="w-full bg-gradient-to-r from-slate-800 to-slate-900 hover:from-slate-700 hover:to-slate-800 text-white font-bold py-3.5 rounded-2xl shadow-lg shadow-slate-900/20 transition-all duration-200 active:scale-[0.98] flex items-center justify-center gap-2 text-base"
|
|
||||||
@click="bookTicket">
|
|
||||||
Book Your Ticker Now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer class="mt-8 border-t border-stone-100 px-5 py-4 text-center">
|
|
||||||
<p class="text-sm text-stone-400">© 2026 DAP 60th Anniversary Committee.<br>All rights reserved.</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const titleHtml = "DAP JOHOR 60th<br>Anniversary Celebration";
|
await navigateTo('/', { redirectCode: 301 })
|
||||||
const eventDetails = [
|
|
||||||
{
|
|
||||||
icon: "lucide:calendar",
|
|
||||||
value: "Saturday, 30th May 2026"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "lucide:clock",
|
|
||||||
value: "6:30 PM"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "lucide:map-pin",
|
|
||||||
value: "Yong Peng's Chee Ann Kor"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
const personInCharge = [{
|
|
||||||
name: "Xiaomai",
|
|
||||||
phone: "601157753558"
|
|
||||||
}, {
|
|
||||||
name: "Lily",
|
|
||||||
phone: "60172661198"
|
|
||||||
}]
|
|
||||||
|
|
||||||
const form = ref({ name: '', phone: '', bookingMode: 'table', quantity: 1, ticketType: 'vip' })
|
|
||||||
const selectedPersonInCharge = ref(personInCharge[0]?.phone)
|
|
||||||
const errors = ref({ name: '', phone: '' })
|
|
||||||
|
|
||||||
const adjustQuantity = (delta: number) => {
|
|
||||||
const newQuantity = form.value.quantity + delta
|
|
||||||
if (newQuantity >= 1) {
|
|
||||||
form.value.quantity = newQuantity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalFormatted = computed(() => {
|
|
||||||
const price = form.value.ticketType === 'vip' ? 150 : 60
|
|
||||||
const total = form.value.quantity * (form.value.bookingMode === 'table' ? 10 : 1) * price
|
|
||||||
return total.toLocaleString('en-MY')
|
|
||||||
})
|
|
||||||
|
|
||||||
const generateBookingUrl = () => {
|
|
||||||
// Generate a base64 encoded string for the booking details
|
|
||||||
// The string should be in the format of JSON
|
|
||||||
// Link /booking?data={{base64EncodedString}}
|
|
||||||
const encodedPhone = btoa(form.value.phone)
|
|
||||||
return `${window.location.origin}/booking?data=${encodedPhone}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const bookTicket = () => {
|
|
||||||
if (!form.value.name || !form.value.phone) {
|
|
||||||
errors.value.name = 'Name is required'
|
|
||||||
errors.value.phone = 'Phone number is required'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// https://wa.me/{{selectedPersonInCharge.value}}
|
|
||||||
// With text: I'd like to book a ticket for the DAP 60th Anniversary Celebration
|
|
||||||
const url = `https://wa.me/${selectedPersonInCharge.value}`
|
|
||||||
const text = `I'd like to book a ticket for the DAP 60th Anniversary Celebration%0A
|
|
||||||
Name: ${form.value.name}%0A
|
|
||||||
Phone Number: ${form.value.phone}%0A
|
|
||||||
Booking Mode: ${form.value.bookingMode}%0A
|
|
||||||
Quantity: ${form.value.quantity}%0A
|
|
||||||
Ticket Type: ${form.value.ticketType}%0A
|
|
||||||
Total Price: RM ${totalFormatted.value}%0A
|
|
||||||
Booking URL: ${generateBookingUrl()}`;
|
|
||||||
window.open(`${url}?text=${text}`, "_blank");
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style></style>
|
|
||||||
@@ -1,13 +1,81 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<UContainer class="py-10">
|
||||||
|
<div class="mx-auto max-w-md">
|
||||||
|
<UCard class="border border-default bg-default">
|
||||||
|
<template #header>
|
||||||
|
<h1 class="text-2xl font-bold text-highlighted">
|
||||||
|
Staff Login
|
||||||
|
</h1>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UForm :state="form" :validate="validateLogin" class="space-y-5" @submit="onSubmit">
|
||||||
|
<UFormField name="username" label="Username" required>
|
||||||
|
<UInput
|
||||||
|
v-model="form.username"
|
||||||
|
type="text"
|
||||||
|
size="xl"
|
||||||
|
class="w-full"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField name="password" label="Password" required>
|
||||||
|
<UInput
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
size="xl"
|
||||||
|
class="w-full"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UCheckbox v-model="form.remember" label="Remember this device" />
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
label="Sign In"
|
||||||
|
size="xl"
|
||||||
|
class="w-full justify-center"
|
||||||
|
/>
|
||||||
|
</UForm>
|
||||||
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
|
</UContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
remember: true
|
||||||
|
})
|
||||||
|
|
||||||
|
function validateLogin(state: typeof form): FormError[] {
|
||||||
|
const errors: FormError[] = []
|
||||||
|
|
||||||
|
if (!state.username.trim()) {
|
||||||
|
errors.push({ name: 'username', message: 'Please enter your username.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.password.trim()) {
|
||||||
|
errors.push({ name: 'password', message: 'Please enter your password.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit(event: FormSubmitEvent<typeof form>) {
|
||||||
|
toast.add({
|
||||||
|
title: 'Authentication is not wired yet',
|
||||||
|
description: 'This page is ready for backend integration, but sign-in is still a placeholder.',
|
||||||
|
color: 'warning',
|
||||||
|
icon: 'i-lucide-info'
|
||||||
|
})
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
Reference in New Issue
Block a user