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:
@@ -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>
|
||||
const titleHtml = "DAP JOHOR 60th<br>Anniversary Celebration";
|
||||
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");
|
||||
}
|
||||
await navigateTo('/', { redirectCode: 301 })
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
Reference in New Issue
Block a user