Merge branch 'baru' of https://git.abbauf.com/Magang-2025/Undangan into baru
This commit is contained in:
commit
1ae8f29bae
@ -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);
|
||||
}
|
||||
|
||||
|
||||
116
backend-baru/database/factories/PelangganFactory.php
Normal file
116
backend-baru/database/factories/PelangganFactory.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
209
proyek-frontend/app/components/undangan/undangan-minimalis.vue
Normal file
209
proyek-frontend/app/components/undangan/undangan-minimalis.vue
Normal 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>
|
||||
464
proyek-frontend/app/pages/form/undangan-ulang-tahun-premium.vue
Normal file
464
proyek-frontend/app/pages/form/undangan-ulang-tahun-premium.vue
Normal 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>
|
||||
125
proyek-frontend/app/pages/p/[code].vue
Normal file
125
proyek-frontend/app/pages/p/[code].vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user