349 lines
11 KiB
Vue
349 lines
11 KiB
Vue
[id].vue
|
||
<template>
|
||
<div class="max-w-3xl mx-auto p-6">
|
||
<h1 class="text-2xl font-bold mb-4">Form Pemesanan</h1>
|
||
|
||
<!-- Error umum -->
|
||
<div v-if="error" class="bg-red-100 text-red-600 p-3 rounded mb-4">
|
||
{{ error }}
|
||
</div>
|
||
|
||
<!-- Loading -->
|
||
<div v-else-if="loading">Memuat data template...</div>
|
||
|
||
<!-- Form -->
|
||
<div v-else>
|
||
<div class="mb-4">
|
||
<p class="text-green-600 font-bold text-lg">Rp {{ Number(template.harga).toLocaleString('id-ID') }}</p>
|
||
<img v-if="template.thumbnail" :src="template.thumbnail" alt="Preview Template" class="w-64 h-40 object-cover rounded" />
|
||
</div>
|
||
|
||
<!-- Fitur dinamis -->
|
||
<div class="mt-4">
|
||
<h3 class="font-medium mb-2">Isi Data Fitur:</h3>
|
||
|
||
<div v-for="fitur in template.fiturs" :key="fitur.id" class="mb-4">
|
||
<label class="block font-medium mb-1">{{ fitur.deskripsi }}</label>
|
||
|
||
<!-- Jika fitur adalah galeri -->
|
||
<div v-if="isGallery(fitur.deskripsi)">
|
||
<input
|
||
type="file"
|
||
:multiple="true"
|
||
:accept="acceptedImageTypes"
|
||
@change="handleGalleryChange($event, fitur.id, parseGalleryMax(fitur.deskripsi))"
|
||
class="w-full"
|
||
:ref="`fileInput_${fitur.id}`"
|
||
/>
|
||
<p class="text-sm text-gray-500 mt-1">
|
||
Maks. {{ parseGalleryMax(fitur.deskripsi) || defaultGalleryMax }} file.
|
||
(Terpilih: {{ (files[`fitur_${fitur.id}`] || []).length }})
|
||
</p>
|
||
<p v-if="fileErrors[`fitur_${fitur.id}`]" class="text-sm text-red-600 mt-1">
|
||
{{ fileErrors[`fitur_${fitur.id}`] }}
|
||
</p>
|
||
<!-- Preview gambar yang dipilih -->
|
||
<div v-if="files[`fitur_${fitur.id}`] && files[`fitur_${fitur.id}`].length > 0" class="mt-2 flex flex-wrap gap-2">
|
||
<div v-for="(file, index) in files[`fitur_${fitur.id}`]" :key="index" class="relative">
|
||
<img :src="getFilePreview(file)" alt="Preview" class="w-20 h-20 object-cover rounded border" />
|
||
<button
|
||
@click="removeFile(`fitur_${fitur.id}`, index)"
|
||
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 text-xs flex items-center justify-center"
|
||
type="button"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Jika fitur adalah tanggal -->
|
||
<input
|
||
v-else-if="fitur.deskripsi.toLowerCase().includes('tanggal')"
|
||
type="date"
|
||
v-model="formFields[fieldNameById(fitur.id)]"
|
||
class="w-full border rounded px-3 py-2"
|
||
/>
|
||
|
||
<!-- default text -->
|
||
<input
|
||
v-else
|
||
type="text"
|
||
v-model="formFields[fieldNameById(fitur.id)]"
|
||
placeholder="Isi data..."
|
||
class="w-full border rounded px-3 py-2"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Data Pemesan -->
|
||
<div class="mt-6">
|
||
<label class="block font-medium">Nama Pemesan *</label>
|
||
<input v-model="baseForm.nama_pemesan" type="text" class="w-full border rounded px-3 py-2 mb-3" required />
|
||
|
||
<label class="block font-medium">Email *</label>
|
||
<input v-model="baseForm.email" type="email" class="w-full border rounded px-3 py-2 mb-3" required />
|
||
|
||
<label class="block font-medium">Nomor HP *</label>
|
||
<input v-model="baseForm.no_hp" type="text" class="w-full border rounded px-3 py-2 mb-3" required />
|
||
|
||
|
||
</div>
|
||
|
||
<!-- Submit -->
|
||
<button @click="submitForm" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50" :disabled="isSubmitting">
|
||
{{ isSubmitting ? 'Mengirim...' : 'Kirim Pesanan' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
|
||
|
||
const route = useRoute()
|
||
const templateId = route.params.id
|
||
|
||
const template = ref({ fiturs: [] })
|
||
const loading = ref(true)
|
||
const error = ref(null)
|
||
const isSubmitting = ref(false)
|
||
|
||
// base form fields (pemesan)
|
||
const baseForm = ref({
|
||
nama_pemesan: '',
|
||
email: '',
|
||
no_hp: '',
|
||
catatan: ''
|
||
})
|
||
|
||
// formFields menyimpan nilai text/date untuk tiap fitur keyed by fieldNameById(fitur.id)
|
||
const formFields = ref({}) // { "fitur_1": "Budi", "fitur_2": "2025-09-20", ... }
|
||
|
||
// files menyimpan File[] per fitur.id
|
||
const files = ref({}) // { "fitur_3": [File, File], ... }
|
||
const fileErrors = ref({}) // error message per gallery field
|
||
|
||
const defaultGalleryMax = 10
|
||
const acceptedImageTypes = 'image/*'
|
||
|
||
/** ---------- helper ---------- **/
|
||
const slugify = (text) =>
|
||
text.toString().toLowerCase().trim().replace(/\s+/g, '_').replace(/[^\w\-]/g, '')
|
||
|
||
// fieldName derived from description (used for backend field name logic)
|
||
const fieldName = (deskripsi) => slugify(deskripsi)
|
||
|
||
// fieldNameById used to link formFields stored by fitur.id
|
||
const fieldNameById = (id) => `fitur_${id}`
|
||
|
||
// cek apakah deskripsi adalah gallery
|
||
const isGallery = (deskripsi) => deskripsi.toLowerCase().includes('galeri') || deskripsi.toLowerCase().includes('gallery')
|
||
|
||
// parse number from "Galeri 3" -> 3. Returns null if not found
|
||
const parseGalleryMax = (deskripsi) => {
|
||
const m = deskripsi.match(/(\d+)/)
|
||
return m ? parseInt(m[1], 10) : null
|
||
}
|
||
|
||
// create preview URL for file
|
||
const getFilePreview = (file) => {
|
||
return URL.createObjectURL(file)
|
||
}
|
||
|
||
// remove file from selection
|
||
const removeFile = (fiturKey, index) => {
|
||
if (files.value[fiturKey]) {
|
||
files.value[fiturKey].splice(index, 1)
|
||
}
|
||
}
|
||
|
||
/** ---------- fetch template ---------- **/
|
||
onMounted(async () => {
|
||
try {
|
||
const res = await fetch(`http://localhost:8000/api/templates/${templateId}`)
|
||
if (!res.ok) throw new Error('Gagal memuat data template')
|
||
const data = await res.json()
|
||
// backend might wrap as {template:..., fiturs:...} or return template object directly.
|
||
// normalize: if data.template exists, use it, else assume data is template
|
||
const tpl = data.template ?? data
|
||
template.value = tpl
|
||
|
||
// setup empty formFields and files for each fitur
|
||
tpl.fiturs?.forEach(f => {
|
||
formFields.value[fieldNameById(f.id)] = ''
|
||
// gallery init
|
||
if (isGallery(f.deskripsi)) {
|
||
files.value[fieldNameById(f.id)] = []
|
||
fileErrors.value[fieldNameById(f.id)] = ''
|
||
}
|
||
})
|
||
} catch (err) {
|
||
console.error(err)
|
||
error.value = err.message || 'Gagal memuat template'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
})
|
||
|
||
/** ---------- handle gallery selection ---------- **/
|
||
function handleGalleryChange(event, fiturId, maxAllowed) {
|
||
const selected = Array.from(event.target.files || [])
|
||
const allowed = maxAllowed || defaultGalleryMax
|
||
const fiturKey = fieldNameById(fiturId)
|
||
|
||
// reset error
|
||
fileErrors.value[fiturKey] = ''
|
||
|
||
// client-side validation for file count
|
||
if (selected.length > allowed) {
|
||
fileErrors.value[fiturKey] = `Jumlah file melebihi batas (${allowed}). Pilih maksimal ${allowed} file.`
|
||
files.value[fiturKey] = []
|
||
event.target.value = '' // reset input
|
||
return
|
||
}
|
||
|
||
// optional: validate file types and sizes (e.g., < 10MB)
|
||
const tooLarge = selected.find(f => f.size > 10 * 1024 * 1024) // 10MB
|
||
if (tooLarge) {
|
||
fileErrors.value[fiturKey] = 'Satu atau lebih file melebihi 10MB.'
|
||
files.value[fiturKey] = []
|
||
event.target.value = ''
|
||
return
|
||
}
|
||
|
||
// validate image types
|
||
const invalidType = selected.find(f => !f.type.startsWith('image/'))
|
||
if (invalidType) {
|
||
fileErrors.value[fiturKey] = 'File harus berupa gambar (JPG, PNG, GIF, dll).'
|
||
files.value[fiturKey] = []
|
||
event.target.value = ''
|
||
return
|
||
}
|
||
|
||
// all good
|
||
files.value[fiturKey] = selected
|
||
}
|
||
|
||
/** ---------- submit ---------- **/
|
||
const submitForm = async () => {
|
||
if (isSubmitting.value) return
|
||
|
||
error.value = null
|
||
isSubmitting.value = true
|
||
|
||
// client basic check: required base fields
|
||
if (!baseForm.value.nama_pemesan || !baseForm.value.no_hp || !baseForm.value.email) {
|
||
error.value = 'Nama pemesan, No HP, dan Email wajib diisi.'
|
||
isSubmitting.value = false
|
||
return
|
||
}
|
||
|
||
// validate email format
|
||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||
if (!emailRegex.test(baseForm.value.email)) {
|
||
error.value = 'Format email tidak valid.'
|
||
isSubmitting.value = false
|
||
return
|
||
}
|
||
|
||
try {
|
||
// use FormData because there may be files
|
||
const fd = new FormData()
|
||
fd.append('template_id', templateId)
|
||
fd.append('nama_pemesan', baseForm.value.nama_pemesan)
|
||
fd.append('email', baseForm.value.email)
|
||
fd.append('no_hp', baseForm.value.no_hp)
|
||
fd.append('catatan', baseForm.value.catatan || '')
|
||
|
||
// append feature values (text/date) and files
|
||
template.value.fiturs?.forEach(fitur => {
|
||
const fiturKey = fieldNameById(fitur.id)
|
||
const fieldNameForBackend = fieldName(fitur.deskripsi)
|
||
|
||
// for gallery fields, append files
|
||
if (isGallery(fitur.deskripsi)) {
|
||
const fileArray = files.value[fiturKey] || []
|
||
console.log(`Appending ${fileArray.length} files for ${fieldNameForBackend}`)
|
||
|
||
if (fileArray.length > 0) {
|
||
// append files with proper array notation for Laravel
|
||
fileArray.forEach((file, index) => {
|
||
fd.append(`${fieldNameForBackend}[]`, file)
|
||
console.log(`Added file ${index}:`, file.name, file.type, file.size)
|
||
})
|
||
}
|
||
} else {
|
||
// for text/date fields
|
||
const value = formFields.value[fiturKey]
|
||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||
fd.append(fieldNameForBackend, value)
|
||
}
|
||
}
|
||
})
|
||
|
||
// Debug: log FormData contents
|
||
console.log('FormData contents:')
|
||
for (let pair of fd.entries()) {
|
||
console.log(pair[0] + ':', pair[1])
|
||
}
|
||
|
||
// fetch POST (do NOT set Content-Type — browser sets multipart boundary)
|
||
const res = await fetch('http://localhost:8000/api/form', {
|
||
method: 'POST',
|
||
body: fd,
|
||
})
|
||
|
||
const data = await res.json()
|
||
console.log('Response:', data)
|
||
|
||
if (!res.ok) {
|
||
if (data.errors) {
|
||
// flatten errors object from Laravel
|
||
const errorMessages = Object.entries(data.errors).map(([field, messages]) => {
|
||
return `${field}: ${Array.isArray(messages) ? messages.join(', ') : messages}`
|
||
})
|
||
error.value = errorMessages.join(' | ')
|
||
} else {
|
||
error.value = data.message || 'Gagal mengirim form'
|
||
}
|
||
return
|
||
}
|
||
|
||
alert('Pesanan berhasil dikirim!')
|
||
// reset inputs
|
||
resetForm()
|
||
|
||
} catch (err) {
|
||
console.error('Submit error:', err)
|
||
error.value = 'Terjadi kesalahan saat mengirim form.'
|
||
} finally {
|
||
isSubmitting.value = false
|
||
}
|
||
}
|
||
|
||
// reset form function
|
||
const resetForm = () => {
|
||
baseForm.value = { nama_pemesan: '', email: '', no_hp: '', catatan: '' }
|
||
// reset feature fields and files
|
||
template.value.fiturs?.forEach(f => {
|
||
const fiturKey = fieldNameById(f.id)
|
||
formFields.value[fiturKey] = ''
|
||
if (isGallery(f.deskripsi)) {
|
||
files.value[fiturKey] = []
|
||
fileErrors.value[fiturKey] = ''
|
||
}
|
||
})
|
||
|
||
// reset file inputs
|
||
document.querySelectorAll('input[type="file"]').forEach(input => {
|
||
input.value = ''
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* Custom styling if needed */
|
||
</style> |