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') $pelanggan = Pelanggan::with('template')
->where('invitation_code', $code) ->where('invitation_code', $code)
->where('status', 'diterima')
->first(); ->first();
if (!$pelanggan) { if (!$pelanggan) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => 'Data pelanggan dengan kode undangan tidak ditemukan.', 'message' => 'Data undangan tidak ditemukan.',
], 404); ], 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 public function run(): void
{ {
// contoh beberapa pelanggan Pelanggan::factory()->count(100)->create();
$pelanggans = [ // $pelanggans = [
[ // [
'nama_pemesan' => 'Arief Dwi Wicaksono', // 'nama_pemesan' => 'Arief Dwi Wicaksono',
'email' => 'arief@example.com', // 'email' => 'arief@example.com',
'no_tlpn' => '081234567890', // 'no_tlpn' => '081234567890',
'template_id' => 1, // pastikan ada template_id valid // 'template_id' => 1, // pastikan ada template_id valid
'form' => json_encode([ // 'form' => json_encode([
'nama_pria' => 'Arief', // 'nama_pria' => 'Arief',
'nama_wanita' => 'Nisa', // 'nama_wanita' => 'Nisa',
'alamat' => 'Malang', // 'alamat' => 'Malang',
]), // ]),
'harga' => 150000, // 'harga' => 150000,
'status' => 'menunggu', // 'status' => 'menunggu',
], // ],
[ // [
'nama_pemesan' => 'Rizky Ramadhan', // 'nama_pemesan' => 'Rizky Ramadhan',
'email' => 'rizky@example.com', // 'email' => 'rizky@example.com',
'no_tlpn' => '081298765432', // 'no_tlpn' => '081298765432',
'template_id' => 2, // 'template_id' => 2,
'form' => json_encode([ // 'form' => json_encode([
'nama_pria' => 'Rizky', // 'nama_pria' => 'Rizky',
'nama_wanita' => 'Dinda', // 'nama_wanita' => 'Dinda',
'alamat' => 'Surabaya', // 'alamat' => 'Surabaya',
]), // ]),
'harga' => 250000, // 'harga' => 250000,
'status' => 'diterima', // 'status' => 'diterima',
], // ],
[ // [
'nama_pemesan' => 'Siti Rahmawati', // 'nama_pemesan' => 'Siti Rahmawati',
'email' => 'siti@example.com', // 'email' => 'siti@example.com',
'no_tlpn' => '081212341234', // 'no_tlpn' => '081212341234',
'template_id' => 3, // 'template_id' => 3,
'form' => json_encode([ // 'form' => json_encode([
'nama_pria' => 'Andi', // 'nama_pria' => 'Andi',
'nama_wanita' => 'Siti', // 'nama_wanita' => 'Siti',
'alamat' => 'Jakarta', // 'alamat' => 'Jakarta',
]), // ]),
'harga' => 300000, // 'harga' => 300000,
'status' => 'menunggu', // 'status' => 'menunggu',
], // ],
]; // ];
foreach ($pelanggans as $data) { // foreach ($pelanggans as $data) {
$data['invitation_code'] = 'INV-' . strtoupper(Str::random(6)); // 🟢 generate code unik // $data['invitation_code'] = 'INV-' . strtoupper(Str::random(6)); // 🟢 generate code unik
Pelanggan::create($data); // Pelanggan::create($data);
} // }
} }
} }

View File

@ -10,10 +10,10 @@ const toggleDropdown = (templateId) => {
openDropdownId.value = openDropdownId.value === templateId ? null : templateId openDropdownId.value = openDropdownId.value === templateId ? null : templateId
} }
// Paket & fitur hardcode
const paketData = [ const paketData = [
// Paket Starter (Undangan Minimalis / Pernikahan Starter)
{ {
paket: 'Starter', paket: 'starter',
fiturs: [ fiturs: [
'1x Acara', '1x Acara',
'Masa Aktif 3 Bulan', 'Masa Aktif 3 Bulan',
@ -22,21 +22,10 @@ const paketData = [
'Request Musik' 'Request Musik'
] ]
}, },
// Paket Premium Pernikahan
{ {
paket: 'Basic', paket: 'premium',
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',
fiturs: [ fiturs: [
'Maksimal 3x Acara (Akad, Resepsi, Syukuran)', 'Maksimal 3x Acara (Akad, Resepsi, Syukuran)',
'Unlimited Galeri Foto', 'Unlimited Galeri Foto',
@ -51,6 +40,40 @@ const paketData = [
'Nama Tamu Personal Unlimited Tamu', 'Nama Tamu Personal Unlimited Tamu',
'Request Musik' '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') const { data: templatesData, error } = await useFetch('http://localhost:8000/api/templates')
// Mapping template: gabungkan backend + paket & fitur hardcode // 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(() => const templates = computed(() =>
(templatesData.value || []) (templatesData.value || [])
.filter(t => selectedIds.includes(t.id)) .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 { return {
id: t.id, id: t.id,
nama_template: t.nama_template, nama_template: t.nama_template,
harga: t.harga, harga: t.harga,
foto: t.foto || '/default.jpg', foto: t.foto || '/default.jpg',
paket: paketData[index % paketData.length].paket, paket: paketInfo.paket,
fiturs: paketData[index % paketData.length].fiturs.map((f, i) => ({ id: i + 1, deskripsi: f })), fiturs: paketInfo.fiturs.map((f, i) => ({ id: i + 1, deskripsi: f })),
kategori: t.kategori, kategori: t.kategori,
formPath: t.slug formPath: t.slug
} }
@ -149,7 +182,7 @@ const templates = computed(() =>
Order Order
</NuxtLink> </NuxtLink>
</div> </div>
</div>1 </div>
</div> </div>
</div> </div>
@ -166,7 +199,7 @@ const templates = computed(() =>
</template> </template>
<style> <style>
/* animasi dropdown smooth */
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: all 0.3s ease; transition: all 0.3s ease;

View File

@ -34,9 +34,9 @@
<div v-for="category in categories" :key="category.id + '-' + category.foto" <div v-for="category in categories" :key="category.id + '-' + category.foto"
@click="onCategoryClick(category)" @click="onCategoryClick(category)"
class="group cursor-pointer relative overflow-hidden rounded-lg shadow-lg hover:shadow-2xl transition-all duration-300 w-72"> 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" 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 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"> <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> <h3 class="text-xl font-semibold mb-2">{{ category.nama }}</h3>
@ -71,8 +71,8 @@
<div v-for="t in templatesWithFeatures" :key="t.id" <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"> class="bg-white border rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-shadow duration-300">
<!-- Image --> <!-- Image -->
<img :src="t.foto || '/fallback.png'" :alt="t.nama" class="w-full h-48 object-cover" <img :src="t.foto || '/logo1.png'" :alt="t.nama" class="w-full h-48 object-cover"
@error="(e) => e.target.src = '/fallback.png'" /> @error="(e) => e.target.src = '/logo1.png'" />
<!-- Body --> <!-- Body -->
<div class="p-5 text-center"> <div class="p-5 text-center">
@ -85,12 +85,11 @@
<!-- Dropdown fitur --> <!-- Dropdown fitur -->
<div v-if="t.fiturs && t.fiturs.length > 0" class="relative mb-4"> <div v-if="t.fiturs && t.fiturs.length > 0" class="relative mb-4">
<button <button @click="toggleDropdown(t.id)"
@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">
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> <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" <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" 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" /> clip-rule="evenodd" />
@ -100,10 +99,10 @@
<transition name="fade"> <transition name="fade">
<div v-if="openDropdownId === t.id" class="mt-4"> <div v-if="openDropdownId === t.id" class="mt-4">
<ul <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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg> </svg>
{{ f.deskripsi }} {{ f.deskripsi }}
@ -120,8 +119,7 @@
@click="onTemplateClick(t)"> @click="onTemplateClick(t)">
Preview Preview
</button> </button>
<NuxtLink <NuxtLink :to="`${t.formPath}?template_id=${t.id}`"
:to="`/form/${t.kategori ? t.kategori.toLowerCase().replace(/ /g, '-') : ''}` + `?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"> 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 Order
</NuxtLink> </NuxtLink>
@ -149,6 +147,13 @@ const isLoading = ref(true)
const error = ref(null) 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 // state dropdown fitur
const openDropdownId = ref(null) const openDropdownId = ref(null)
@ -240,13 +245,15 @@ const templatesWithFeatures = computed(() =>
id: t.id, id: t.id,
nama: t.nama_template, nama: t.nama_template,
harga: t.harga, harga: t.harga,
foto: t.foto, foto: t.foto || '/logo1.png',
kategori: t.kategori, kategori: t.kategori,
paket: paketData[index % paketData.length].paket, 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(() => { onMounted(() => {
fetchCategories() fetchCategories()
fetchTemplates() fetchTemplates()

View File

@ -25,31 +25,27 @@
</div> </div>
<!-- Grid Template --> <!-- Grid Template -->
<div v-else-if="templates.length > 0" <div v-else-if="templates.length > 0" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 items-start">
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" <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"> class="bg-white border rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-shadow duration-300">
<!-- Image --> <!-- Gambar -->
<img <img :src="tpl.foto" :alt="tpl.nama_template" class="w-full h-48 object-cover"
:src="tpl.foto || '/fallback.png'" @error="(e) => e.target.src = '/logo2.png'" />
:alt="tpl.nama_template"
class="w-full h-48 object-cover"
@error="(e) => e.target.src = '/fallback.png'"
/>
<!-- Body --> <!-- Body -->
<div class="p-5 text-center"> <div class="p-5 text-center">
<h4 class="text-xl font-bold text-gray-800 mb-2">{{ tpl.nama_template }}</h4> <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"> <p class="text-green-600 font-semibold text-xl mb-1">
Rp {{ (tpl.harga ?? 0).toLocaleString('id-ID') }} Rp {{ Number(tpl.harga ?? 0).toLocaleString('id-ID') }}
</p> </p>
<p class="text-gray-500 mb-4 font-medium">Paket: {{ tpl.paket }}</p>
<!-- Dropdown Fitur --> <!-- 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)" <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> <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" <svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor"> fill="currentColor">
@ -59,26 +55,29 @@
</svg> </svg>
</button> </button>
<div v-if="openDropdownId === tpl.id"> <transition name="fade">
<ul class="mt-4 space-y-2 text-gray-600 text-left"> <div v-if="openDropdownId === tpl.id" class="mt-4">
<li v-for="item_fitur in tpl.fiturs" :key="item_fitur.id" class="flex items-center"> <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"> <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> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg> </svg>
{{ item_fitur.deskripsi }} {{ f.deskripsi }}
</li> </li>
</ul> </ul>
</div> </div>
</transition>
</div> </div>
<!-- Buttons --> <!-- Buttons -->
<div class="mt-6 flex flex-col gap-3"> <div class="flex items-center gap-3 mt-6">
<a :href="tpl.preview_link || '#'" target="_blank" <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 block"> 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 Preview
</a> </a>
<NuxtLink <NuxtLink :to="`${tpl.formPath}?template_id=${tpl.id}`"
:to="`/form/${tpl.kategori?.id || tpl.id}?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"> 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 Order
</NuxtLink> </NuxtLink>
@ -86,6 +85,7 @@
</div> </div>
</div> </div>
</div> </div>
<div v-else class="text-center py-10 text-gray-500"> <div v-else class="text-center py-10 text-gray-500">
@ -95,7 +95,7 @@
</template> </template>
<script setup> <script setup>
import { ref, watch, onMounted } from 'vue' import { ref, watch, onMounted, computed } from 'vue'
const props = defineProps({ const props = defineProps({
category: { type: String, required: true }, category: { type: String, required: true },
@ -113,6 +113,51 @@ const toggleDropdown = (templateId) => {
openDropdownId.value = openDropdownId.value === templateId ? null : 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) => { const fetchTemplates = async (categoryId) => {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
@ -120,15 +165,26 @@ const fetchTemplates = async (categoryId) => {
const res = await $fetch(`/api/templates/category/${categoryId}`, { const res = await $fetch(`/api/templates/category/${categoryId}`, {
baseURL: 'http://localhost:8000' 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, id: tpl.id,
nama_template: tpl.nama_template, nama_template: tpl.nama_template,
harga: tpl.harga, harga: tpl.harga,
kategori: tpl.kategori, kategori: tpl.kategori,
foto: tpl.foto ?? null, foto: tpl.foto ?? '/logo2.png',
fiturs: tpl.fiturs ?? [], paket: paketKey,
preview_link: tpl.preview_link ?? null 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) { } catch (err) {
console.error(err) console.error(err)
error.value = 'Gagal memuat template.' error.value = 'Gagal memuat template.'
@ -138,11 +194,29 @@ const fetchTemplates = async (categoryId) => {
} }
} }
// Fetch saat mount
onMounted(() => fetchTemplates(props.id_category)) onMounted(() => fetchTemplates(props.id_category))
// Watch id_category untuk fetch ulang saat berubah
watch(() => props.id_category, (newId) => { watch(() => props.id_category, (newId) => {
if (newId) fetchTemplates(newId) if (newId) fetchTemplates(newId)
}) })
</script> </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,48 +126,147 @@
<input v-model="form.link_music" placeholder="Link Music (opsional)" <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" /> 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> </section>
<label class="font-semibold text-blue-600 mb-3 block border-b pb-1">🖼 Galeri Foto</label>
<!-- Galeri Foto --> <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">
<section class="mb-8"> <input
<h2 class="font-semibold text-blue-600 mb-3 border-b pb-1">🖼 Galeri Foto</h2> type="file"
<div multiple
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"> 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-3xl font-bold">+</span>
<span class="text-sm mt-2">Pilih Foto</span>
</label>
</div> </div>
</section>
<!-- Tombol --> <!-- Tombol -->
<div class="text-center"> <div class="text-end mt-6">
<button @click="submitForm" <button @click="batal"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-6 py-2 rounded-lg shadow-md transition"> 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 Konfirmasi
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router'
const form = ref({ const router = useRouter()
const handleFileChange = (event) => {
form.value.galeri = Array.from(event.target.files)
}
const form = ref({
nama_pemesan: '', nama_pemesan: '',
email: '', email: '',
telepon: '', telepon: '',
pria: {}, pria: {
wanita: {}, nama_lengkap: '',
nama_panggilan: '',
nama_bapak: '',
nama_ibu: '',
instagram: '',
facebook: '',
twitter: ''
},
wanita: {
nama_lengkap: '',
nama_panggilan: '',
nama_bapak: '',
nama_ibu: '',
instagram: '',
facebook: '',
twitter: ''
},
cerita: '', cerita: '',
akad: {}, akad: {
resepsi: {}, tanggal: '',
waktu: '',
alamat: '',
link_gmaps: ''
},
resepsi: {
tanggal: '',
waktu: '',
alamat: '',
link_gmaps: ''
},
rekening1: '', rekening1: '',
rekening2: '', rekening2: '',
rekening3: '', rekening3: '',
link_music: '' link_music: '',
galeri: []
})
// 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)
}) })
const submitForm = () => { // Kirim ke backend
console.log('Data terkirim:', form.value) const response = await fetch('https://localhost/api/undangan', {
alert('Data berhasil dikonfirmasi!') method: 'POST',
} body: data
</script> // 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>