This commit is contained in:
dhilanradya 2025-10-09 16:11:00 +07:00
commit e085adadcb
9 changed files with 846 additions and 182 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

@ -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

@ -126,20 +126,31 @@
<input v-model="form.link_music" placeholder="Link Music (opsional)"
class="w-full border border-gray-300 rounded-lg px-3 py-2 mt-4 focus:ring-2 focus:ring-blue-400 focus:border-blue-400 outline-none transition" />
</section>
<!-- Galeri Foto -->
<section class="mb-8">
<h2 class="font-semibold text-blue-600 mb-3 border-b pb-1">🖼 Galeri Foto</h2>
<div
class="border-2 border-dashed border-gray-300 rounded-xl p-8 flex justify-center items-center text-gray-400 cursor-pointer hover:border-blue-400 hover:text-blue-500 transition">
<label class="font-semibold text-blue-600 mb-3 block border-b pb-1">🖼 Galeri Foto</label>
<div class="border-2 border-dashed border-gray-300 rounded-xl p-8 flex flex-col justify-center items-center text-gray-400 cursor-pointer hover:border-blue-400 hover:text-blue-500 transition">
<input
type="file"
multiple
class="hidden"
id="gallery"
@change="handleFileChange"
>
<label for="gallery" class="cursor-pointer flex flex-col items-center">
<span class="text-3xl font-bold">+</span>
<span class="text-sm mt-2">Pilih Foto</span>
</label>
</div>
</section>
<!-- Tombol -->
<div class="text-center">
<button @click="submitForm"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-6 py-2 rounded-lg shadow-md transition">
<div class="text-end mt-6">
<button @click="batal"
class="bg-gray-600 text-white font-semibold px-6 py-2 rounded-lg transition mr-2">
Batal
</button>
<button @click="konfirmasi"
class="bg-blue-700 text-white font-semibold px-6 py-2 rounded-lg transition">
Konfirmasi
</button>
</div>
@ -149,25 +160,113 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const handleFileChange = (event) => {
form.value.galeri = Array.from(event.target.files)
}
const form = ref({
nama_pemesan: '',
email: '',
telepon: '',
pria: {},
wanita: {},
pria: {
nama_lengkap: '',
nama_panggilan: '',
nama_bapak: '',
nama_ibu: '',
instagram: '',
facebook: '',
twitter: ''
},
wanita: {
nama_lengkap: '',
nama_panggilan: '',
nama_bapak: '',
nama_ibu: '',
instagram: '',
facebook: '',
twitter: ''
},
cerita: '',
akad: {},
resepsi: {},
akad: {
tanggal: '',
waktu: '',
alamat: '',
link_gmaps: ''
},
resepsi: {
tanggal: '',
waktu: '',
alamat: '',
link_gmaps: ''
},
rekening1: '',
rekening2: '',
rekening3: '',
link_music: ''
link_music: '',
galeri: []
})
const submitForm = () => {
console.log('Data terkirim:', form.value)
alert('Data berhasil dikonfirmasi!')
// Fungsi Konfirmasi
const konfirmasi = async () => {
try {
const data = new FormData()
// Tambahkan field sederhana
data.append('nama_pemesan', form.value.nama_pemesan)
data.append('email', form.value.email)
data.append('telepon', form.value.telepon)
// Tambahkan nested object (pria, wanita, akad, resepsi)
for (const key in form.value.pria) {
data.append(`pria[${key}]`, form.value.pria[key])
}
for (const key in form.value.wanita) {
data.append(`wanita[${key}]`, form.value.wanita[key])
}
for (const key in form.value.akad) {
data.append(`akad[${key}]`, form.value.akad[key])
}
for (const key in form.value.resepsi) {
data.append(`resepsi[${key}]`, form.value.resepsi[key])
}
// Rekening & Musik
data.append('rekening1', form.value.rekening1)
data.append('rekening2', form.value.rekening2)
data.append('rekening3', form.value.rekening3)
data.append('link_music', form.value.link_music)
// File galeri
form.value.galeri.forEach(file => {
data.append('galeri[]', file)
})
// Kirim ke backend
const response = await fetch('https://localhost/api/undangan', {
method: 'POST',
body: data
// Jangan set header 'Content-Type' manual, biarkan browser otomatis atur multipart/form-data
})
if (!response.ok) throw new Error('Gagal mengirim data')
const result = await response.json()
alert('Data berhasil disimpan!')
router.push('/')
} catch (error) {
console.error(error)
alert('Terjadi kesalahan, coba lagi!')
}
}
// Fungsi Batal
const batal = () => {
// Kembali ke landing page tanpa menyimpan
router.back()('/')
}
</script>

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>