This commit is contained in:
timotiabbauftech 2025-10-13 09:38:14 +07:00
commit 1ae8f29bae
9 changed files with 1174 additions and 145 deletions

View File

@ -82,12 +82,13 @@ class PelangganApiController extends Controller
{
$pelanggan = Pelanggan::with('template')
->where('invitation_code', $code)
->where('status', 'diterima')
->first();
if (!$pelanggan) {
return response()->json([
'success' => false,
'message' => 'Data pelanggan dengan kode undangan tidak ditemukan.',
'message' => 'Data undangan tidak ditemukan.',
], 404);
}

View File

@ -0,0 +1,116 @@
<?php
namespace Database\Factories;
use App\Models\Pelanggan;
use App\Models\Template;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Pelanggan>
*/
class PelangganFactory extends Factory
{
protected $model = Pelanggan::class;
public function definition(): array
{
// Get a random template
$template = Template::inRandomOrder()->first() ?? Template::factory()->create();
// Generate form data based on template's form fields
$formData = $this->generateFormData($template->form['fields'] ?? []);
// Generate unique invitation code
$invitationCode = 'INV-' . strtoupper(Str::random(6));
while (Pelanggan::where('invitation_code', $invitationCode)->exists()) {
$invitationCode = 'INV-' . strtoupper(Str::random(6));
}
return [
'nama_pemesan' => $this->faker->name(),
'email' => $this->faker->unique()->safeEmail(),
'no_tlpn' => $this->faker->phoneNumber(),
'template_id' => $template->id,
'form' => $formData,
'harga' => $template->harga,
'status' => $this->faker->randomElement(['menunggu', 'diterima', 'ditolak']),
'invitation_code' => $invitationCode,
];
}
/**
* Generate form data based on template fields
*
* @param array $fields
* @return array
*/
private function generateFormData(array $fields): array
{
$formData = [];
foreach ($fields as $field) {
$name = $field['name'];
$type = $field['type'] ?? 'text';
switch ($type) {
case 'text':
$formData[$name] = $this->generateTextField($name);
break;
case 'email':
$formData[$name] = $this->faker->safeEmail();
break;
case 'date':
$formData[$name] = $this->faker->date('Y-m-d', 'now +1 month');
break;
case 'number':
$formData[$name] = $this->faker->numberBetween(1, 100);
break;
case 'textarea':
$formData[$name] = $this->faker->paragraph();
break;
case 'file':
$formData[$name] = 'files/' . $this->faker->uuid() . '.jpg';
break;
default:
$formData[$name] = $this->faker->word();
}
}
return $formData;
}
/**
* Generate text field data based on field name
*
* @param string $name
* @return string
*/
private function generateTextField(string $name): string
{
if (str_contains($name, 'nama_')) {
return $this->faker->name();
}
if (str_contains($name, 'alamat')) {
return $this->faker->address();
}
if (str_contains($name, 'link_gmaps')) {
return 'https://maps.google.com/?q=' . $this->faker->latitude() . ',' . $this->faker->longitude();
}
if (str_contains($name, 'instagram') || str_contains($name, 'facebook') || str_contains($name, 'twitter')) {
return 'https://' . str_replace('_', '.', $name) . '/' . $this->faker->userName();
}
if (str_contains($name, 'waktu')) {
return $this->faker->time('H:i');
}
if (str_contains($name, 'rekening')) {
return $this->faker->bankAccountNumber();
}
if (str_contains($name, 'link_music')) {
return 'https://music.example.com/' . $this->faker->uuid();
}
return $this->faker->word();
}
}

View File

@ -10,52 +10,52 @@ class PelangganSeeder extends Seeder
{
public function run(): void
{
// contoh beberapa pelanggan
$pelanggans = [
[
'nama_pemesan' => 'Arief Dwi Wicaksono',
'email' => 'arief@example.com',
'no_tlpn' => '081234567890',
'template_id' => 1, // pastikan ada template_id valid
'form' => json_encode([
'nama_pria' => 'Arief',
'nama_wanita' => 'Nisa',
'alamat' => 'Malang',
]),
'harga' => 150000,
'status' => 'menunggu',
],
[
'nama_pemesan' => 'Rizky Ramadhan',
'email' => 'rizky@example.com',
'no_tlpn' => '081298765432',
'template_id' => 2,
'form' => json_encode([
'nama_pria' => 'Rizky',
'nama_wanita' => 'Dinda',
'alamat' => 'Surabaya',
]),
'harga' => 250000,
'status' => 'diterima',
],
[
'nama_pemesan' => 'Siti Rahmawati',
'email' => 'siti@example.com',
'no_tlpn' => '081212341234',
'template_id' => 3,
'form' => json_encode([
'nama_pria' => 'Andi',
'nama_wanita' => 'Siti',
'alamat' => 'Jakarta',
]),
'harga' => 300000,
'status' => 'menunggu',
],
];
Pelanggan::factory()->count(100)->create();
// $pelanggans = [
// [
// 'nama_pemesan' => 'Arief Dwi Wicaksono',
// 'email' => 'arief@example.com',
// 'no_tlpn' => '081234567890',
// 'template_id' => 1, // pastikan ada template_id valid
// 'form' => json_encode([
// 'nama_pria' => 'Arief',
// 'nama_wanita' => 'Nisa',
// 'alamat' => 'Malang',
// ]),
// 'harga' => 150000,
// 'status' => 'menunggu',
// ],
// [
// 'nama_pemesan' => 'Rizky Ramadhan',
// 'email' => 'rizky@example.com',
// 'no_tlpn' => '081298765432',
// 'template_id' => 2,
// 'form' => json_encode([
// 'nama_pria' => 'Rizky',
// 'nama_wanita' => 'Dinda',
// 'alamat' => 'Surabaya',
// ]),
// 'harga' => 250000,
// 'status' => 'diterima',
// ],
// [
// 'nama_pemesan' => 'Siti Rahmawati',
// 'email' => 'siti@example.com',
// 'no_tlpn' => '081212341234',
// 'template_id' => 3,
// 'form' => json_encode([
// 'nama_pria' => 'Andi',
// 'nama_wanita' => 'Siti',
// 'alamat' => 'Jakarta',
// ]),
// 'harga' => 300000,
// 'status' => 'menunggu',
// ],
// ];
foreach ($pelanggans as $data) {
$data['invitation_code'] = 'INV-' . strtoupper(Str::random(6)); // 🟢 generate code unik
Pelanggan::create($data);
}
// foreach ($pelanggans as $data) {
// $data['invitation_code'] = 'INV-' . strtoupper(Str::random(6)); // 🟢 generate code unik
// Pelanggan::create($data);
// }
}
}

View File

@ -2,7 +2,7 @@
import { ref, computed } from 'vue'
// ID template yang mau ditampilkan
const selectedIds = [1, 3, 4, 5, 2, 7, 8, 9]
const selectedIds = [1, 3, 4, 5, 6, 7, 8, 9]
// State dropdown
const openDropdownId = ref(null)
@ -10,10 +10,10 @@ const toggleDropdown = (templateId) => {
openDropdownId.value = openDropdownId.value === templateId ? null : templateId
}
// Paket & fitur hardcode
const paketData = [
// Paket Starter (Undangan Minimalis / Pernikahan Starter)
{
paket: 'Starter',
paket: 'starter',
fiturs: [
'1x Acara',
'Masa Aktif 3 Bulan',
@ -22,21 +22,10 @@ const paketData = [
'Request Musik'
]
},
// Paket Premium Pernikahan
{
paket: 'Basic',
fiturs: [
'1x Acara',
'6 Galeri Foto',
'Hitung Mundur Waktu Acara',
'Buku Tamu + Data Kehadiran',
'Masa Aktif 6 Bulan',
'Nama Tamu Personal',
'Maks. 200 Tamu',
'Request Musik'
]
},
{
paket: 'Premium',
paket: 'premium',
fiturs: [
'Maksimal 3x Acara (Akad, Resepsi, Syukuran)',
'Unlimited Galeri Foto',
@ -51,6 +40,40 @@ const paketData = [
'Nama Tamu Personal Unlimited Tamu',
'Request Musik'
]
},
// Paket Premium Ulang Tahun
{
paket: 'premium',
fiturs: [
'1x Acara',
'Unlimited Galeri Foto',
'Timeline Story',
'Google Maps',
'Reminder Google Calendar',
'Amplop Digital',
'Placement Video Cinematic',
'Masa Aktif 12 Bulan',
'Nama Tamu Personal Unlimited Tamu',
'Request Musik'
]
},
// Paket Premium Khitan
{
paket: 'premium',
fiturs: [
'1x Acara',
'Unlimited Galeri Foto',
'Timeline Story',
'Google Maps',
'Reminder Google Calendar',
'Amplop Digital',
'Placement Video Cinematic',
'Masa Aktif 12 Bulan',
'Nama Tamu Personal Unlimited Tamu',
'Request Musik'
]
}
]
@ -67,17 +90,27 @@ const formMapping = {
const { data: templatesData, error } = await useFetch('http://localhost:8000/api/templates')
// Mapping template: gabungkan backend + paket & fitur hardcode
const paketMapping = {
'Undangan Minimalis': 'starter',
'Undangan Pernikahan Premium': 'premium',
'Undangan Ulang Tahun Premium': 'premium',
'Undangan Khitan Premium': 'premium'
}
const templates = computed(() =>
(templatesData.value || [])
.filter(t => selectedIds.includes(t.id))
.map((t, index) => {
.map((t) => {
const paketKey = paketMapping[t.nama_template] || 'starter';
const paketInfo = paketData.find(p => p.paket.toLowerCase() === paketKey.toLowerCase()) || paketData[0];
return {
id: t.id,
nama_template: t.nama_template,
harga: t.harga,
foto: t.foto || '/default.jpg',
paket: paketData[index % paketData.length].paket,
fiturs: paketData[index % paketData.length].fiturs.map((f, i) => ({ id: i + 1, deskripsi: f })),
paket: paketInfo.paket,
fiturs: paketInfo.fiturs.map((f, i) => ({ id: i + 1, deskripsi: f })),
kategori: t.kategori,
formPath: t.slug
}
@ -149,7 +182,7 @@ const templates = computed(() =>
Order
</NuxtLink>
</div>
</div>1
</div>
</div>
</div>
@ -166,7 +199,7 @@ const templates = computed(() =>
</template>
<style>
/* animasi dropdown smooth */
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;

View File

@ -34,9 +34,9 @@
<div v-for="category in categories" :key="category.id + '-' + category.foto"
@click="onCategoryClick(category)"
class="group cursor-pointer relative overflow-hidden rounded-lg shadow-lg hover:shadow-2xl transition-all duration-300 w-72">
<img :src="category.foto || '/fallback.png'" :alt="category.nama"
<img :src="category.foto || '/ABBAUF.png'" :alt="category.nama"
class="w-full h-96 object-cover transition-transform duration-300 group-hover:scale-110"
@error="(e) => e.target.src = '/fallback.png'">
@error="(e) => e.target.src = '/ABBAUF.png'">
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent"></div>
<div class="absolute inset-0 flex flex-col justify-center items-start px-4 text-white">
<h3 class="text-xl font-semibold mb-2">{{ category.nama }}</h3>
@ -71,8 +71,8 @@
<div v-for="t in templatesWithFeatures" :key="t.id"
class="bg-white border rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-shadow duration-300">
<!-- Image -->
<img :src="t.foto || '/fallback.png'" :alt="t.nama" class="w-full h-48 object-cover"
@error="(e) => e.target.src = '/fallback.png'" />
<img :src="t.foto || '/logo1.png'" :alt="t.nama" class="w-full h-48 object-cover"
@error="(e) => e.target.src = '/logo1.png'" />
<!-- Body -->
<div class="p-5 text-center">
@ -85,12 +85,11 @@
<!-- Dropdown fitur -->
<div v-if="t.fiturs && t.fiturs.length > 0" class="relative mb-4">
<button
@click="toggleDropdown(t.id)"
class="w-full bg-white border border-gray-300 rounded-md shadow-sm px-4 py-2 inline-flex justify-between items-start"
>
<button @click="toggleDropdown(t.id)"
class="w-full bg-white border border-gray-300 rounded-md shadow-sm px-4 py-2 inline-flex justify-between items-start">
<span class="mx-auto text-gray-700 font-semibold">FITUR YANG TERSEDIA</span>
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="currentColor"
viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd" />
@ -100,10 +99,10 @@
<transition name="fade">
<div v-if="openDropdownId === t.id" class="mt-4">
<ul
class="space-y-2 text-gray-600 text-left max-h-60 overflow-y-auto px-3 py-2 border border-gray-200 rounded-md shadow-inner bg-gray-50"
>
class="space-y-2 text-gray-600 text-left max-h-60 overflow-y-auto px-3 py-2 border border-gray-200 rounded-md shadow-inner bg-gray-50">
<li v-for="f in t.fiturs" :key="f.id" class="flex items-center">
<svg class="h-4 w-4 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-4 w-4 text-green-500 mr-2" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{{ f.deskripsi }}
@ -120,8 +119,7 @@
@click="onTemplateClick(t)">
Preview
</button>
<NuxtLink
:to="`/form/${t.kategori ? t.kategori.toLowerCase().replace(/ /g, '-') : ''}` + `?template_id=${t.id}`"
<NuxtLink :to="`${t.formPath}?template_id=${t.id}`"
class="w-full bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors text-center">
Order
</NuxtLink>
@ -149,6 +147,13 @@ const isLoading = ref(true)
const error = ref(null)
const formMapping = {
'Undangan Pernikahan Premium': '/form/pernikahan/b',
'Undangan Minimalis': '/form/pernikahan/a',
'Undangan Ulang Tahun Premium': '/form/ulang-tahun/a',
'Undangan Khitan Premium': '/form/khitan/a',
}
// state dropdown fitur
const openDropdownId = ref(null)
@ -240,13 +245,15 @@ const templatesWithFeatures = computed(() =>
id: t.id,
nama: t.nama_template,
harga: t.harga,
foto: t.foto,
foto: t.foto || '/logo1.png',
kategori: t.kategori,
paket: paketData[index % paketData.length].paket,
fiturs: paketData[index % paketData.length].fiturs.map((f, i) => ({ id: i + 1, deskripsi: f }))
fiturs: paketData[index % paketData.length].fiturs.map((f, i) => ({ id: i + 1, deskripsi: f })),
formPath: formMapping[t.nama_template] || `/form/lainny` // 🔥 ambil path form sesuai mapping
}))
)
onMounted(() => {
fetchCategories()
fetchTemplates()

View File

@ -25,31 +25,27 @@
</div>
<!-- Grid Template -->
<div v-else-if="templates.length > 0"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 items-start">
<div v-else-if="templates.length > 0" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 items-start">
<div v-for="tpl in templates" :key="tpl.id"
class="bg-white border rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-shadow duration-300">
<!-- Image -->
<img
:src="tpl.foto || '/fallback.png'"
:alt="tpl.nama_template"
class="w-full h-48 object-cover"
@error="(e) => e.target.src = '/fallback.png'"
/>
<!-- Gambar -->
<img :src="tpl.foto" :alt="tpl.nama_template" class="w-full h-48 object-cover"
@error="(e) => e.target.src = '/logo2.png'" />
<!-- Body -->
<div class="p-5 text-center">
<h4 class="text-xl font-bold text-gray-800 mb-2">{{ tpl.nama_template }}</h4>
<p class="text-green-600 font-semibold text-xl mb-4">
Rp {{ (tpl.harga ?? 0).toLocaleString('id-ID') }}
<p class="text-green-600 font-semibold text-xl mb-1">
Rp {{ Number(tpl.harga ?? 0).toLocaleString('id-ID') }}
</p>
<p class="text-gray-500 mb-4 font-medium">Paket: {{ tpl.paket }}</p>
<!-- Dropdown Fitur -->
<div v-if="tpl.fiturs?.length > 0" class="relative mb-4">
<div v-if="tpl.fiturs && tpl.fiturs.length" class="relative mb-4">
<button @click="toggleDropdown(tpl.id)"
class="w-full bg-white border border-gray-300 rounded-md shadow-sm px-4 py-2 inline-flex justify-between items-center text-center">
class="w-full bg-white border border-gray-300 rounded-md shadow-sm px-4 py-2 inline-flex justify-between items-center">
<span class="mx-auto text-gray-700 font-semibold">FITUR YANG TERSEDIA</span>
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor">
@ -59,26 +55,29 @@
</svg>
</button>
<div v-if="openDropdownId === tpl.id">
<ul class="mt-4 space-y-2 text-gray-600 text-left">
<li v-for="item_fitur in tpl.fiturs" :key="item_fitur.id" class="flex items-center">
<transition name="fade">
<div v-if="openDropdownId === tpl.id" class="mt-4">
<ul
class="space-y-2 text-gray-600 text-left max-h-60 overflow-y-auto px-3 py-2 border border-gray-200 rounded-md shadow-inner bg-gray-50">
<li v-for="f in tpl.fiturs" :key="f.id" class="flex items-center">
<svg class="h-4 w-4 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
{{ item_fitur.deskripsi }}
{{ f.deskripsi }}
</li>
</ul>
</div>
</transition>
</div>
<!-- Buttons -->
<div class="mt-6 flex flex-col gap-3">
<a :href="tpl.preview_link || '#'" target="_blank"
class="w-full bg-white border border-gray-300 text-gray-800 font-semibold py-2 px-4 rounded-lg hover:bg-gray-100 transition-colors text-center block">
<div class="flex items-center gap-3 mt-6">
<a :href="tpl.preview_link || '#'"
class="w-full bg-white border border-gray-300 text-gray-800 font-semibold py-2 px-4 rounded-lg hover:bg-gray-100 transition-colors text-center">
Preview
</a>
<NuxtLink
:to="`/form/${tpl.kategori?.id || tpl.id}?template_id=${tpl.id}`"
<NuxtLink :to="`${tpl.formPath}?template_id=${tpl.id}`"
class="w-full bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors text-center">
Order
</NuxtLink>
@ -86,6 +85,7 @@
</div>
</div>
</div>
<div v-else class="text-center py-10 text-gray-500">
@ -95,7 +95,7 @@
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { ref, watch, onMounted, computed } from 'vue'
const props = defineProps({
category: { type: String, required: true },
@ -113,6 +113,51 @@ const toggleDropdown = (templateId) => {
openDropdownId.value = openDropdownId.value === templateId ? null : templateId
}
// Mapping form untuk tiap template
const formMapping = {
'Undangan Pernikahan Premium': '/form/pernikahan/b',
'Undangan Minimalis': '/form/pernikahan/a',
'Undangan Ulang Tahun Premium': '/form/ulang-tahun/a',
'Undangan Khitan Premium': '/form/khitan/a',
}
// Hardcode fitur per paket
// Mapping paket -> fitur (pastikan key sesuai format paket)
const fiturPerPaket = {
Starter: [
'1x Acara',
'Masa Aktif 3 Bulan',
'Nama Tamu Personal',
'Maks. 100 Tamu',
'Request Musik'
],
Basic: [
'1x Acara',
'6 Galeri Foto',
'Hitung Mundur Waktu Acara',
'Buku Tamu + Data Kehadiran',
'Masa Aktif 6 Bulan',
'Nama Tamu Personal',
'Maks. 200 Tamu',
'Request Musik'
],
Premium: [
'Maksimal 3x Acara (Akad, Resepsi, Syukuran)',
'Unlimited Galeri Foto',
'Timeline Story',
'Google Maps',
'Reminder Google Calendar',
'Link Instagram Live Streaming',
'Amplop Digital',
'Placement Video Cinematic',
'Bonus Undangan Image Post Story',
'Masa Aktif 12 Bulan',
'Nama Tamu Personal Unlimited Tamu',
'Request Musik'
]
}
// Fetch templates dari API
const fetchTemplates = async (categoryId) => {
isLoading.value = true
error.value = null
@ -120,15 +165,26 @@ const fetchTemplates = async (categoryId) => {
const res = await $fetch(`/api/templates/category/${categoryId}`, {
baseURL: 'http://localhost:8000'
})
templates.value = res.map(tpl => ({
templates.value = res.map(tpl => {
// Pastikan nama paket konsisten: 'Starter', 'Basic', 'Premium'
const paketKey = tpl.paket ? tpl.paket.charAt(0).toUpperCase() + tpl.paket.slice(1).toLowerCase() : 'Starter'
return {
id: tpl.id,
nama_template: tpl.nama_template,
harga: tpl.harga,
kategori: tpl.kategori,
foto: tpl.foto ?? null,
fiturs: tpl.fiturs ?? [],
preview_link: tpl.preview_link ?? null
}))
foto: tpl.foto ?? '/logo2.png',
paket: paketKey,
fiturs: (fiturPerPaket[paketKey] || []).map((f, i) => ({
id: i + 1,
deskripsi: f
})),
preview_link: tpl.preview_link ?? null,
formPath: formMapping[tpl.nama_template] || '/form/lainny'
}
})
} catch (err) {
console.error(err)
error.value = 'Gagal memuat template.'
@ -138,11 +194,29 @@ const fetchTemplates = async (categoryId) => {
}
}
// Fetch saat mount
onMounted(() => fetchTemplates(props.id_category))
// Watch id_category untuk fetch ulang saat berubah
watch(() => props.id_category, (newId) => {
if (newId) fetchTemplates(newId)
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-5px);
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
transform: translateY(0);
}
</style>

View File

@ -0,0 +1,209 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-rose-50 via-pink-50 to-purple-50 flex items-center justify-center p-4">
<!-- Main Card -->
<div class="max-w-lg w-full">
<!-- Floating Animation Container -->
<div class="animate-float">
<div class="relative bg-white rounded-3xl shadow-2xl overflow-hidden">
<!-- Decorative Top Wave -->
<div class="absolute top-0 left-0 right-0 h-32 bg-gradient-to-r from-rose-400 to-pink-400 opacity-10">
<svg class="absolute bottom-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 320">
<path fill="#ffffff" fill-opacity="1" d="M0,96L48,112C96,128,192,160,288,160C384,160,480,128,576,112C672,96,768,96,864,112C960,128,1056,160,1152,160C1248,160,1344,128,1392,112L1440,96L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"></path>
</svg>
</div>
<!-- Header Image Section -->
<div class="relative h-64 overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-white z-10"></div>
<img
:src="imageUrl"
alt="Wedding Template"
class="w-full h-full object-cover transform hover:scale-110 transition-transform duration-700"
/>
<!-- Ornamental Corner -->
<div class="absolute top-4 left-4 w-16 h-16 border-l-4 border-t-4 border-rose-300 rounded-tl-2xl"></div>
<div class="absolute top-4 right-4 w-16 h-16 border-r-4 border-t-4 border-rose-300 rounded-tr-2xl"></div>
</div>
<!-- Content Section -->
<div class="relative px-8 py-10 space-y-8">
<!-- Divider Line -->
<div class="flex items-center justify-center mb-8">
<div class="h-px w-12 bg-gradient-to-r from-transparent to-rose-300"></div>
<div class="mx-4">
<svg class="w-8 h-8 text-rose-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd"/>
</svg>
</div>
<div class="h-px w-12 bg-gradient-to-l from-transparent to-rose-300"></div>
</div>
<!-- Names -->
<div class="text-center space-y-2">
<h1 class="text-4xl md:text-5xl font-serif font-bold bg-gradient-to-r from-rose-600 to-pink-600 bg-clip-text text-transparent animate-fade-in">
{{ formData.nama_pengantin || 'Undangan Pernikahan' }}
</h1>
<p class="text-gray-500 text-sm tracking-widest uppercase">The Wedding Of</p>
</div>
<!-- Date Section -->
<div class="bg-gradient-to-r from-rose-50 to-pink-50 rounded-2xl p-6 shadow-inner transform hover:scale-105 transition-transform duration-300">
<div class="flex items-center justify-center space-x-3 mb-2">
<svg class="w-6 h-6 text-rose-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<h2 class="text-lg font-semibold text-gray-700">Save The Date</h2>
</div>
<p class="text-center text-2xl font-serif text-gray-800">
{{ formatDate(formData.tanggal_acara) || 'Tanggal belum ditentukan' }}
</p>
</div>
<!-- Location Section -->
<div class="bg-gradient-to-r from-purple-50 to-pink-50 rounded-2xl p-6 shadow-inner transform hover:scale-105 transition-transform duration-300">
<div class="flex items-center justify-center space-x-3 mb-2">
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<h2 class="text-lg font-semibold text-gray-700">Lokasi Acara</h2>
</div>
<p class="text-center text-xl text-gray-800 font-medium">
{{ formData.lokasi || 'Lokasi belum ditentukan' }}
</p>
</div>
<!-- Decorative Quote -->
<div class="text-center py-4">
<p class="text-sm text-gray-400 italic font-serif">
"Cinta adalah persahabatan yang telah terbakar"
</p>
</div>
<!-- Footer Badge -->
<div class="flex items-center justify-center pt-4">
<div class="bg-gradient-to-r from-rose-100 to-pink-100 px-6 py-2 rounded-full">
<p class="text-xs text-gray-600 font-medium">
{{ data.template?.nama_template }}
<span class="uppercase">{{ data.template?.paket }}</span>
</p>
</div>
</div>
</div>
<!-- Decorative Bottom Corners -->
<div class="absolute bottom-4 left-4 w-16 h-16 border-l-4 border-b-4 border-rose-300 rounded-bl-2xl"></div>
<div class="absolute bottom-4 right-4 w-16 h-16 border-r-4 border-b-4 border-rose-300 rounded-br-2xl"></div>
</div>
</div>
<!-- Floating Hearts Animation -->
<div class="fixed inset-0 pointer-events-none overflow-hidden -z-10">
<div class="heart-float" style="left: 10%; animation-delay: 0s;"></div>
<div class="heart-float" style="left: 30%; animation-delay: 2s;">💕</div>
<div class="heart-float" style="left: 50%; animation-delay: 4s;">💖</div>
<div class="heart-float" style="left: 70%; animation-delay: 1s;">💗</div>
<div class="heart-float" style="left: 90%; animation-delay: 3s;">💝</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRuntimeConfig } from '#app'
const props = defineProps({
data: {
type: Object,
required: true,
validator: (data) => {
return data && typeof data === 'object' && 'template' in data
},
},
})
const config = useRuntimeConfig()
const backendUrl = config.public.apiBaseUrl
const formData = computed(() => props.data.form || {})
const imageUrl = computed(() => {
const foto = props.data.template?.foto
return foto
? `${backendUrl}/storage/${foto}`
: 'https://images.unsplash.com/photo-1519741497674-611481863552?w=800&h=600&fit=crop'
})
const formatDate = (dateString) => {
if (!dateString) return null
try {
const date = new Date(dateString)
if (isNaN(date.getTime())) return null
return date.toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
} catch (error) {
console.error('Error formatting date:', error)
return null
}
}
</script>
<style scoped>
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-20px);
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes heart-float {
0% {
transform: translateY(100vh) rotate(0deg);
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
transform: translateY(-100vh) rotate(360deg);
opacity: 0;
}
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-fade-in {
animation: fade-in 1s ease-out;
}
.heart-float {
position: absolute;
font-size: 1.5rem;
animation: heart-float 15s linear infinite;
bottom: -50px;
}
</style>

View File

@ -0,0 +1,464 @@
<template>
<div class="max-w-4xl mx-auto p-6">
<div class="bg-white rounded-lg shadow-lg p-8">
<h2 class="text-3xl font-bold text-gray-800 mb-2">
Undangan Ulang Tahun Premium
</h2>
<p class="text-gray-600 mb-6">Harga: Rp 200.000</p>
<form @submit.prevent="submitForm" class="space-y-6">
<!-- Data Pemesan -->
<div class="border-b pb-6">
<h3 class="text-xl font-semibold text-gray-700 mb-4">Data Pemesan</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Nama Pemesan <span class="text-red-500">*</span>
</label>
<input
v-model="formData.nama_pemesan"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Masukkan nama pemesan"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Email <span class="text-red-500">*</span>
</label>
<input
v-model="formData.email"
type="email"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="contoh@email.com"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
No Telepon
</label>
<input
v-model="formData.no_telepon"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="08xxxxxxxxxx"
/>
</div>
</div>
</div>
<!-- Data Yang Berulang Tahun -->
<div class="border-b pb-6">
<h3 class="text-xl font-semibold text-gray-700 mb-4">Data Yang Berulang Tahun</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Nama Lengkap
</label>
<input
v-model="formData.nama_lengkap"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Nama lengkap"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Nama Panggilan
</label>
<input
v-model="formData.nama_panggilan"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Nama panggilan"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Nama Bapak
</label>
<input
v-model="formData.nama_bapak"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Nama bapak"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Nama Ibu
</label>
<input
v-model="formData.nama_ibu"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Nama ibu"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Umur Yang Dirayakan
</label>
<input
v-model.number="formData.umur_yang_dirayakan"
type="number"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Contoh: 7"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Anak Ke
</label>
<input
v-model.number="formData.anak_ke"
type="number"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Contoh: 1"
/>
</div>
</div>
</div>
<!-- Media Sosial -->
<div class="border-b pb-6">
<h3 class="text-xl font-semibold text-gray-700 mb-4">Media Sosial</h3>
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Link Instagram
</label>
<input
v-model="formData.instagram"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="https://instagram.com/username"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Link Facebook
</label>
<input
v-model="formData.facebook"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="https://facebook.com/username"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Link Twitter
</label>
<input
v-model="formData.twitter"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="https://twitter.com/username"
/>
</div>
</div>
</div>
<!-- Detail Acara -->
<div class="border-b pb-6">
<h3 class="text-xl font-semibold text-gray-700 mb-4">Detail Acara</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Hari & Tanggal Acara
</label>
<input
v-model="formData.hari_tanggal_acara"
type="date"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Waktu
</label>
<input
v-model="formData.waktu"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Contoh: 14.00 - 16.00 WIB"
/>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-2">
Alamat
</label>
<input
v-model="formData.alamat"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Alamat lengkap acara"
/>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-2">
Link Google Maps
</label>
<input
v-model="formData.link_gmaps"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="https://maps.google.com/..."
/>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-2">
Say Something
</label>
<textarea
v-model="formData.say_something"
rows="4"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Pesan atau kata-kata untuk undangan"
></textarea>
</div>
</div>
</div>
<!-- Rekening -->
<div class="border-b pb-6">
<h3 class="text-xl font-semibold text-gray-700 mb-4">Rekening (Opsional)</h3>
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Rekening 1
</label>
<input
v-model="formData.rekening_1"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Contoh: BCA - 1234567890 - Nama Pemilik"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Rekening 2
</label>
<input
v-model="formData.rekening_2"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Contoh: Mandiri - 9876543210 - Nama Pemilik"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Rekening 3
</label>
<input
v-model="formData.rekening_3"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Contoh: BNI - 5555555555 - Nama Pemilik"
/>
</div>
</div>
</div>
<!-- Upload Foto -->
<div class="border-b pb-6">
<h3 class="text-xl font-semibold text-gray-700 mb-4">Upload Foto</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="i in 5" :key="i">
<label class="block text-sm font-medium text-gray-700 mb-2">
Foto {{ i }}
</label>
<input
type="file"
accept="image/*"
@change="handleFileUpload($event, `foto_${i}`)"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p v-if="formData[`foto_${i}`]" class="text-sm text-green-600 mt-1">
File dipilih: {{ formData[`foto_${i}`].name }}
</p>
</div>
</div>
</div>
<!-- Link Music -->
<div class="pb-6">
<h3 class="text-xl font-semibold text-gray-700 mb-4">Background Music</h3>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Link Music
</label>
<input
v-model="formData.link_music"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Link YouTube atau file musik"
/>
</div>
</div>
<!-- Submit Button -->
<div class="flex gap-4">
<button
type="submit"
:disabled="loading"
class="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-lg transition duration-200 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{{ loading ? 'Mengirim...' : 'Kirim Pesanan' }}
</button>
<button
type="button"
@click="resetForm"
class="px-6 py-3 border border-gray-300 text-gray-700 font-semibold rounded-lg hover:bg-gray-50 transition duration-200"
>
Reset
</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const formData = ref({
nama_pemesan: '',
email: '',
no_telepon: '',
nama_lengkap: '',
nama_panggilan: '',
nama_bapak: '',
nama_ibu: '',
umur_yang_dirayakan: null,
anak_ke: null,
instagram: '',
facebook: '',
twitter: '',
hari_tanggal_acara: '',
waktu: '',
alamat: '',
link_gmaps: '',
say_something: '',
rekening_1: '',
rekening_2: '',
rekening_3: '',
foto_1: null,
foto_2: null,
foto_3: null,
foto_4: null,
foto_5: null,
link_music: ''
})
const loading = ref(false)
const handleFileUpload = (event, fieldName) => {
const file = event.target.files[0]
if (file) {
formData.value[fieldName] = file
}
}
const submitForm = async () => {
loading.value = true
try {
const formDataToSend = new FormData()
// Append semua data ke FormData
Object.keys(formData.value).forEach(key => {
if (formData.value[key] !== null && formData.value[key] !== '') {
formDataToSend.append(key, formData.value[key])
}
})
// Kirim ke API Laravel
const response = await $fetch('/api/orders/ulang-tahun-premium', {
method: 'POST',
body: formDataToSend
})
alert('Pesanan berhasil dikirim!')
resetForm()
// Redirect atau tindakan lainnya
// navigateTo('/success')
} catch (error) {
console.error('Error:', error)
alert('Terjadi kesalahan saat mengirim pesanan')
} finally {
loading.value = false
}
}
const resetForm = () => {
formData.value = {
nama_pemesan: '',
email: '',
no_telepon: '',
nama_lengkap: '',
nama_panggilan: '',
nama_bapak: '',
nama_ibu: '',
umur_yang_dirayakan: null,
anak_ke: null,
instagram: '',
facebook: '',
twitter: '',
hari_tanggal_acara: '',
waktu: '',
alamat: '',
link_gmaps: '',
say_something: '',
rekening_1: '',
rekening_2: '',
rekening_3: '',
foto_1: null,
foto_2: null,
foto_3: null,
foto_4: null,
foto_5: null,
link_music: ''
}
// Reset file inputs
const fileInputs = document.querySelectorAll('input[type="file"]')
fileInputs.forEach(input => {
input.value = ''
})
}
</script>
<style scoped>
input:focus,
textarea:focus {
outline: none;
}
</style>

View File

@ -0,0 +1,125 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-100">
<!-- Loading State -->
<div v-if="pending" class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-t-4 border-blue-500 mx-auto"></div>
<p class="mt-4 text-gray-600">Loading invitation...</p>
</div>
<!-- Error State -->
<div v-else-if="error || !data" class="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
<svg class="w-12 h-12 mx-auto text-red-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<p class="text-lg font-semibold text-gray-800 mb-2">Undangan Tidak Ditemukan</p>
<p class="text-gray-600 mb-6">Maaf, undangan yang Anda cari tidak tersedia atau sudah tidak berlaku.</p>
<NuxtLink to="/" class="inline-block bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 transition-colors">
Kembali ke Beranda
</NuxtLink>
</div>
<!-- Data Loaded Successfully -->
<div v-else-if="data && data.template">
<!-- Dynamic Component for Known Slugs -->
<component v-if="dynamicComponent" :is="dynamicComponent" :data="data" />
<!-- Fallback for Unknown Slugs -->
<div v-else class="w-full bg-white rounded-lg shadow-lg p-8">
<h1 class="text-2xl font-bold text-gray-800 mb-1">Invitation Data</h1>
<div class="text-sm mb-4 text-gray-500">Tampilan ini muncul saat komponen template belum tersedia</div>
<div v-for="(value, key) in data" :key="key" class="mb-4">
<h2 class="text-lg font-semibold text-gray-700 capitalize">{{ key.replace('_', ' ') }}</h2>
<p v-if="typeof value !== 'object'" class="text-gray-600">{{ value }}</p>
<pre v-else class="text-gray-600 bg-gray-50 p-2 rounded">{{ JSON.stringify(value, null, 2) }}</pre>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineAsyncComponent, computed } from 'vue'
import { useRoute, useRuntimeConfig, useAsyncData, createError } from '#app'
const route = useRoute()
const config = useRuntimeConfig()
const backendUrl = config.public.apiBaseUrl
const { data, pending, error } = await useAsyncData(
'invitation',
async () => {
try {
const response = await $fetch(`${backendUrl}/api/pelanggans/code/${route.params.code}`)
// Check if the API response indicates failure
if (!response.success) {
throw createError({
statusCode: 404,
message: response.message || 'Undangan tidak ditemukan',
fatal: false
})
}
// Validate data structure
if (!response.data || !response.data.template) {
throw createError({
statusCode: 404,
message: 'Data undangan tidak valid',
fatal: false
})
}
return response.data
} catch (err) {
// Handle network errors or other exceptions
if (err.statusCode) {
throw err
}
throw createError({
statusCode: err.statusCode || 500,
message: 'Undangan tidak ditemukan',
fatal: false
})
}
},
{
// Prevent automatic error propagation
lazy: false,
server: true,
// Transform function to ensure consistent data structure
transform: (data) => data
}
)
const componentMap = {
'undangan-minimalis': defineAsyncComponent(() => import('~/components/undangan/undangan-minimalis.vue')),
// Add more mappings as templates are developed
}
const dynamicComponent = computed(() => {
if (!data.value?.template?.slug) return null
return componentMap[data.value.template.slug] || null
})
// Set meta tags only if data exists
useHead(() => ({
title: data.value?.nama_pemesan || 'Undangan Tak Bernama',
meta: [
{
name: 'description',
content: data.value
? `${data.value.nama_pemesan} mengundang Anda untuk menghadiri acara berikut!`
: 'Undangan Digital'
},
],
}))
</script>
<style scoped>
pre {
text-align: left;
white-space: pre-wrap;
word-wrap: break-word;
}
</style>