revisi form

This commit is contained in:
Muzakki Parsaoran Siregar 2025-09-19 14:33:03 +07:00
parent d4f5e5ef76
commit da9afba614
4 changed files with 581 additions and 73 deletions

View File

@ -7,73 +7,214 @@ use App\Models\Pelanggan;
use App\Models\PelangganDetail;
use App\Models\Template;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class FormApiController extends Controller
{
/**
* Convert string to slug-like field name
*/
private function slugify($text)
{
return strtolower(
preg_replace('/[^A-Za-z0-9_]/', '_', trim($text))
);
}
public function store(Request $request)
{
try {
// Log incoming request for debugging
Log::info('Form submission received', [
'template_id' => $request->template_id,
'files' => array_keys($request->allFiles()),
'data_keys' => array_keys($request->except(['_token']))
]);
// ✅ Validasi dasar
$rules = [
'template_id' => 'required|exists:templates,id',
'nama_pemesan' => 'required|string|max:255',
'no_hp' => 'required|string|max:20',
'email' => 'required|email',
'catatan' => 'nullable|string|max:500',
];
// ✅ Ambil template & fiturnya
// ✅ Ambil template + fiturnya
$template = Template::with(['fiturs', 'kategori'])->findOrFail($request->template_id);
// ✅ Loop fitur → generate aturan validasi dinamis
foreach ($template->fiturs as $fitur) {
$field = str_replace(' ', '_', strtolower($fitur->deskripsi)); // contoh: "Nama Lengkap Pria" → "nama_lengkap_pria"
$galleryFields = []; // Track gallery field names
// Aturan default: required string max 255
// ✅ Loop fitur → buat validasi dinamis
foreach ($template->fiturs as $fitur) {
$field = $this->slugify($fitur->deskripsi);
// default text input
$rules[$field] = 'nullable|string|max:255';
// Kalau fitur ada kata "tanggal"
// tanggal
if (str_contains(strtolower($fitur->deskripsi), 'tanggal')) {
$rules[$field] = 'nullable|date';
}
// Kalau fitur ada kata "foto" atau "galeri"
if (str_contains(strtolower($fitur->deskripsi), 'galeri')) {
$rules['galeri'] = 'nullable|array|max:10';
$rules['galeri.*'] = 'image|mimes:jpeg,png,jpg,gif|max:2048';
// galeri (cek jumlah: Galeri 2, Galeri 5, dll.)
if (str_contains(strtolower($fitur->deskripsi), 'galeri') ||
str_contains(strtolower($fitur->deskripsi), 'gallery')) {
preg_match('/(\d+)/', $fitur->deskripsi, $matches);
$maxFiles = isset($matches[1]) ? (int) $matches[1] : 10;
// Add gallery field to tracking
$galleryFields[] = $field;
// Validation for gallery array
$rules[$field] = "nullable|array|max:$maxFiles";
$rules[$field . '.*'] = 'file|image|mimes:jpeg,png,jpg,gif,webp|max:10240'; // 10MB
}
}
Log::info('Validation rules generated', [
'rules' => $rules,
'gallery_fields' => $galleryFields
]);
// ✅ Jalankan validasi
$data = $request->validate($rules);
$validatedData = $request->validate($rules);
// --- PROSES UPLOAD GALERI ---
// ✅ Process all gallery uploads
$allGalleryPaths = [];
foreach ($galleryFields as $galleryField) {
if ($request->hasFile($galleryField)) {
$galleryPaths = [];
if ($request->hasFile('galeri')) {
foreach ($request->file('galeri') as $file) {
$galleryPaths[] = $file->store('gallery', 'public');
$files = $request->file($galleryField);
Log::info("Processing files for field: $galleryField", [
'file_count' => is_array($files) ? count($files) : 1
]);
// Handle both single file and array of files
if (!is_array($files)) {
$files = [$files];
}
foreach ($files as $file) {
try {
$path = $file->store('gallery', 'public');
$galleryPaths[] = $path;
Log::info("File uploaded successfully", [
'original_name' => $file->getClientOriginalName(),
'path' => $path
]);
} catch (\Exception $e) {
Log::error("File upload failed", [
'file' => $file->getClientOriginalName(),
'error' => $e->getMessage()
]);
throw $e;
}
}
$data['galeri'] = $galleryPaths;
if (!empty($galleryPaths)) {
$allGalleryPaths[$galleryField] = $galleryPaths;
$validatedData[$galleryField] = $galleryPaths;
}
}
}
Log::info('All gallery uploads processed', [
'gallery_data' => $allGalleryPaths
]);
// ✅ Simpan ke tabel pelanggan
$pelanggan = Pelanggan::create([
'nama_pemesan' => $data['nama_pemesan'],
'nama_pemesan' => $validatedData['nama_pemesan'],
'nama_template' => $template->nama_template,
'kategori' => $template->kategori->nama ?? '-',
'email' => $data['email'],
'no_tlpn' => $data['no_hp'],
'email' => $validatedData['email'],
'no_tlpn' => $validatedData['no_hp'],
'harga' => $template->harga,
'catatan' => $validatedData['catatan'] ?? null,
]);
// ✅ Simpan detail form (dinamis)
// ✅ Simpan detail form (dinamis) - include gallery paths
PelangganDetail::create([
'pelanggan_id' => $pelanggan->id,
'detail_form' => $data,
'detail_form' => $validatedData,
]);
Log::info('Form submitted successfully', [
'pelanggan_id' => $pelanggan->id,
'template_id' => $template->id
]);
return response()->json([
'success' => true,
'message' => 'Form berhasil dikirim sesuai fitur template',
'data' => $pelanggan->load('details')
'data' => $pelanggan->load('details'),
'gallery_info' => $allGalleryPaths
], 201);
} catch (\Illuminate\Validation\ValidationException $e) {
Log::error('Validation failed', [
'errors' => $e->errors(),
'input' => $request->except(['password', '_token'])
]);
return response()->json([
'success' => false,
'message' => 'Validasi gagal',
'errors' => $e->errors()
], 422);
} catch (\Exception $e) {
Log::error('Form submission failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'input' => $request->except(['password', '_token'])
]);
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan internal server',
'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
], 500);
}
}
public function getFiturs($id)
{
try {
$template = Template::with(['fiturs', 'kategori'])->findOrFail($id);
return response()->json([
'success' => true,
'template' => [
'id' => $template->id,
'nama_template' => $template->nama_template,
'kategori' => $template->kategori->nama ?? '-',
'harga' => $template->harga,
'thumbnail' => $template->thumbnail ?? null,
],
'fiturs' => $template->fiturs->map(function ($fitur) {
return [
'id' => $fitur->id,
'deskripsi' => $fitur->deskripsi,
'harga' => $fitur->harga,
];
}),
]);
} catch (\Exception $e) {
Log::error('Failed to get template fiturs', [
'template_id' => $id,
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Template tidak ditemukan',
'error' => config('app.debug') ? $e->getMessage() : 'Template not found'
], 404);
}
}
}

View File

@ -2,32 +2,48 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\ReviewController;
use App\Http\Controllers\Api\KategoriApiController;
use App\Http\Controllers\Api\PernikahanApiController;
use App\Http\Controllers\Api\UlangTahunApiController;
use App\Http\Controllers\Api\KhitanApiController;
use App\Http\Controllers\Api\TemplateApiController;
use App\Http\Controllers\Api\FormApiController;
use App\Http\Controllers\Api\ReviewController;
// Form API (universal, dinamis berdasarkan template_id)
Route::post('form', [FormApiController::class, 'store']);
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "api" middleware group. Make something great!
|
*/
// // Form API (user)
// Route::post('form/pernikahan', [PernikahanApiController::class, 'store']);
// Route::post('form/ulang-tahun', [UlangTahunApiController::class, 'store']);
// Route::post('form/khitan', [KhitanApiController::class, 'store']);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
// API Kategori hanya read-only
// ===============================
// API Kategori (read-only)
// ===============================
Route::get('kategoris', [KategoriApiController::class, 'index']);
Route::get('kategoris/{kategori}', [KategoriApiController::class, 'show']);
// ===============================
// API Reviews
// ===============================
Route::apiResource('reviews', ReviewController::class);
// ===============================
// API Templates
Route::get('templates/random', [TemplateApiController::class, 'random']); // NEW
// ===============================
Route::get('templates/random', [TemplateApiController::class, 'random']); // random template
Route::get('templates', [TemplateApiController::class, 'index']);
Route::get('templates/{template}', [TemplateApiController::class, 'show']);
Route::get('templates/category/{id}', [TemplateApiController::class, 'byCategory']);
Route::get('/templates/{id}', [TemplateApiController::class, 'show']);
Route::get('/templates/{id}', [TemplateApiController::class, 'show']); // duplicate tapi ga masalah
// ===============================
// API Form (user submit)
// ===============================
Route::post('form', [FormApiController::class, 'store']); // <<== INI yang ditambah
Route::get('templates/{id}/fiturs', [FormApiController::class, 'getFiturs']);

View File

@ -71,10 +71,12 @@
</a>
<!-- Tombol Order langsung ke form Khitan -->
<NuxtLink :to="`/form/${tpl.kategori.nama.toLowerCase().replace(/ /g, '-')}` + `?template_id=${tpl.id}`"
<NuxtLink
:to="`/form/${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>
</div>
</div>
</div>

View File

@ -0,0 +1,349 @@
[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 />
<label class="block font-medium">Catatan</label>
<textarea v-model="baseForm.catatan" class="w-full border rounded px-3 py-2 mb-3"></textarea>
</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.query.template_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>