Switch remote

This commit is contained in:
Stephen Gesityan 2025-07-16 14:26:26 +07:00
parent 79307fb0d6
commit f0f2d5299d
26 changed files with 1655 additions and 711 deletions

View File

@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers;
use App\Models\Review;
use App\Models\Booking;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ReviewController extends Controller
{
/**
* Menyimpan ulasan baru ke dalam database.
*/
public function create(Booking $booking)
{
// Otorisasi: Pastikan user yang login adalah pemilik booking
if ($booking->user_id !== Auth::id()) {
abort(403, 'Akses tidak diizinkan.');
}
// Otorisasi: Pastikan booking sudah pernah diulas atau belum
if ($booking->review) {
return redirect()->route('booking.history')->with('error', 'Anda sudah pernah memberikan ulasan untuk booking ini.');
}
// Muat relasi yang diperlukan untuk ditampilkan di view
$booking->load('table.venue');
return view('reviews.create', compact('booking'));
}
public function store(Request $request)
{
// 1. Validasi Input
$request->validate([
'booking_id' => 'required|exists:bookings,id|unique:reviews,booking_id',
'rating' => 'required|integer|min:1|max:5',
'comment' => 'required|string|max:2000',
], [
'booking_id.unique' => 'Anda sudah pernah memberikan ulasan untuk booking ini.',
'rating.required' => 'Rating bintang wajib diisi.',
'comment.required' => 'Komentar ulasan wajib diisi.',
]);
// 2. Cek Otorisasi
$booking = Booking::find($request->booking_id);
// Pastikan user yang login adalah pemilik booking tersebut
if ($booking->user_id !== Auth::id()) {
return redirect()->back()->with('error', 'Akses tidak diizinkan.');
}
// 3. Simpan Ulasan
Review::create([
'user_id' => Auth::id(),
'venue_id' => $booking->table->venue_id, // Ambil venue_id dari relasi
'booking_id' => $booking->id,
'rating' => $request->rating,
'comment' => $request->comment,
]);
// 4. Redirect dengan Pesan Sukses
return redirect()->route('booking.history')->with('success', 'Terima kasih atas ulasan Anda!');
}
}

View File

@ -76,6 +76,42 @@ public function index(Request $request)
return view('admin.bookings.index', compact('bookings'));
}
public function showScanner()
{
return view('admin.bookings.scan');
}
public function validateBooking($token)
{
$booking = Booking::where('validation_token', $token)
->with(['user', 'table.venue'])
->first();
if (!$booking) {
return response()->json(['error' => 'Booking tidak ditemukan atau tidak valid.'], 404);
}
// Otorisasi: Pastikan admin hanya bisa memvalidasi booking di venue miliknya
if ($booking->table->venue_id !== auth()->user()->venue_id) {
return response()->json(['error' => 'Akses tidak diizinkan.'], 403);
}
// Mengembalikan data yang diformat dengan baik
return response()->json([
'success' => true,
'data' => [
'venue_name' => $booking->table->venue->name,
'booking_id' => $booking->id,
'user_name' => $booking->user->name,
'table_name' => $booking->table->name,
'start_time' => $booking->start_time->format('d M Y, H:i'),
'end_time' => $booking->end_time->format('H:i'),
'duration' => $booking->start_time->diffInHours($booking->end_time) . ' Jam',
'status' => $booking->status,
]
]);
}
public function show($id)
{
// Pastikan booking yang dilihat adalah milik venue admin

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use App\Models\Venue;
use App\Models\VenueImage;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
@ -36,7 +37,7 @@ public function index()
*/
public function edit()
{
$venue = auth()->user()->venue;
$venue = auth()->user()->venue()->with('images', 'operatingHours')->first(); // Tambahkan 'operatingHours'
if (!$venue) {
return redirect()->route('admin.dashboard')->with('error', 'Anda belum memiliki venue yang ditugaskan.');
@ -51,84 +52,115 @@ public function edit()
public function update(Request $request)
{
$venue = auth()->user()->venue;
if (!$venue) {
return redirect()->route('admin.dashboard')->with('error', 'Anda belum memiliki venue yang ditugaskan.');
}
// Validation rules
$validator = Validator::make($request->all(), [
// 1. Pra-proses data jam operasional sebelum validasi
$input = $request->all();
if (isset($input['hours'])) {
foreach ($input['hours'] as $day => &$data) { // Gunakan '&' untuk modifikasi langsung
// Jika 'is_closed' dicentang, atau jika input waktu kosong, paksa menjadi null
if (isset($data['is_closed']) && $data['is_closed'] == 'on') {
$data['open_time'] = null;
$data['close_time'] = null;
}
}
}
// 2. Buat aturan validasi yang lebih pintar
$validator = Validator::make($input, [
'name' => 'required|string|max:255',
'address' => 'required|string|max:500',
'phone' => 'nullable|string|max:20',
'description' => 'nullable|string|max:1000',
'open_time' => 'required|date_format:H:i',
'close_time' => 'required|date_format:H:i',
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', // Max 2MB
], [
'name.required' => 'Nama venue harus diisi.',
'address.required' => 'Alamat venue harus diisi.',
'open_time.required' => 'Jam buka harus diisi.',
'open_time.date_format' => 'Format jam buka tidak valid (gunakan format HH:MM).',
'close_time.required' => 'Jam tutup harus diisi.',
'close_time.date_format' => 'Format jam tutup tidak valid (gunakan format HH:MM).',
'image.image' => 'File yang diupload harus berupa gambar.',
'image.mimes' => 'Gambar harus berformat: jpeg, png, jpg, atau gif.',
'image.max' => 'Ukuran gambar maksimal 2MB.',
'latitude' => 'nullable|string|max:255',
'longitude' => 'nullable|string|max:255',
// ... (validasi lain tetap sama) ...
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
'gallery_images.*' => 'image|mimes:jpeg,png,jpg,gif|max:2048',
'hours' => 'required|array|size:7',
// Aturan baru: open_time wajib diisi KECUALI jika is_closed dicentang
'hours.*.open_time' => 'required_unless:hours.*.is_closed,on|nullable|date_format:H:i',
'hours.*.close_time' => 'required_unless:hours.*.is_closed,on|nullable|date_format:H:i',
'link_instagram' => 'nullable|url',
'link_tiktok' => 'nullable|url',
'link_facebook' => 'nullable|url',
'link_x' => 'nullable|url',
]);
if ($validator->fails()) {
return redirect()->back()
->withErrors($validator)
->withInput();
return redirect()->back()->withErrors($validator)->withInput();
}
try {
// Handle image upload
$imagePath = $venue->image; // Keep current image by default
// (Sisa dari kode ini tidak perlu diubah)
$imagePath = $venue->image;
if ($request->hasFile('image')) { /* ... logika upload ... */ }
if ($request->hasFile('gallery_images')) { /* ... logika upload galeri ... */ }
if ($request->hasFile('image')) {
// Delete old image if exists
if ($venue->image && Storage::disk('public')->exists($venue->image)) {
Storage::disk('public')->delete($venue->image);
}
// Store new image
$imagePath = $request->file('image')->store('venues', 'public');
}
// Prepare update data
$updateData = [
$venue->update([
'name' => $request->name,
'address' => $request->address,
'latitude' => $request->latitude,
'longitude' => $request->longitude,
'phone' => $request->phone,
'link_instagram' => $request->link_instagram,
'link_tiktok' => $request->link_tiktok,
'link_facebook' => $request->link_facebook,
'link_x' => $request->link_x,
'description' => $request->description,
'image' => $imagePath,
];
]);
// Only update operating hours if venue is open
if ($venue->status === 'open') {
$updateData['open_time'] = $request->open_time;
$updateData['close_time'] = $request->close_time;
} else {
// If venue is closed, update original times
$updateData['original_open_time'] = $request->open_time;
$updateData['original_close_time'] = $request->close_time;
foreach ($request->hours as $dayNumber => $hoursData) {
$isClosed = isset($hoursData['is_closed']);
$venue->operatingHours()->updateOrCreate(
['day_of_week' => $dayNumber],
[
'open_time' => $isClosed ? null : $hoursData['open_time'],
'close_time' => $isClosed ? null : $hoursData['close_time'],
'is_closed' => $isClosed,
]
);
}
// Update venue data
$venue->update($updateData);
return redirect()->route('admin.venue.index')
->with('success', 'Informasi venue berhasil diperbarui!');
} catch (\Exception $e) {
return redirect()->back()
->with('error', 'Terjadi kesalahan saat memperbarui venue: ' . $e->getMessage())
->with('error', 'Terjadi kesalahan: ' . $e->getMessage())
->withInput();
}
}
public function destroyImage(VenueImage $image)
{
// Otorisasi: Pastikan admin yang sedang login adalah pemilik venue dari gambar ini
// Kita bandingkan venue_id milik admin dengan venue_id milik gambar.
if ($image->venue_id !== auth()->user()->venue_id) {
return response()->json(['success' => false, 'message' => 'Akses tidak diizinkan.'], 403);
}
try {
// Hapus file dari storage
if (Storage::disk('public')->exists($image->path)) {
Storage::disk('public')->delete($image->path);
}
// Hapus record dari database
$image->delete();
return response()->json(['success' => true, 'message' => 'Gambar berhasil dihapus.']);
} catch (\Exception $e) {
// Catat error untuk debugging jika perlu
// Log::error('Failed to delete gallery image: ' . $e->getMessage());
// Kirim respons error yang jelas
return response()->json(['success' => false, 'message' => 'Gagal menghapus gambar di server.'], 500);
}
}
/**
* Toggle venue status (open/close)
*/

View File

@ -12,6 +12,8 @@
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str; // <-- 1. PASTIKAN INI ADA
use SimpleSoftwareIO\QrCode\Facades\QrCode; // <-- 1. PASTIKAN INI JUGA ADA
class BookingController extends Controller
{
@ -22,6 +24,25 @@ public function __construct(MidtransService $midtransService)
$this->midtransService = $midtransService;
}
public function showQrCode(Booking $booking)
{
// Otorisasi: Pastikan user yang login adalah pemilik booking
if ($booking->user_id !== Auth::id()) {
abort(403);
}
// Pastikan booking memiliki token validasi
if (!$booking->validation_token) {
abort(404, 'QR Code tidak ditemukan.');
}
// Buat gambar QR Code dari token validasi
$qrCode = QrCode::size(300)->generate($booking->validation_token);
// Kembalikan sebagai respons gambar SVG
return response($qrCode)->header('Content-Type', 'image/svg+xml');
}
// Tambahkan method baru untuk booking langsung oleh admin
// Ganti seluruh fungsi adminDirectBooking dengan ini
public function adminDirectBooking($request) {
@ -33,10 +54,9 @@ public function adminDirectBooking($request) {
}
$user = Auth::user();
$table = Table::with('venue')->findOrFail($data['table_id']);
$table = Table::with(['venue.operatingHours'])->findOrFail($data['table_id']);
$venue = $table->venue;
// Validasi otorisasi admin (menggunakan struktur yang konsisten dengan kodemu)
if ($user->role !== 'admin' || $user->venue_id !== $table->venue_id) {
return response()->json(['message' => 'Unauthorized action'], 403);
}
@ -44,49 +64,46 @@ public function adminDirectBooking($request) {
$startDateTime = Carbon::parse($data['start_time']);
$endDateTime = Carbon::parse($data['end_time']);
// --- Validasi jam operasional (logika ini sudah benar) ---
$operationalDayStart = Carbon::createFromFormat('Y-m-d H:i:s', $startDateTime->format('Y-m-d') . ' ' . $venue->open_time, 'Asia/Jakarta');
if ($venue->is_overnight && $startDateTime < $operationalDayStart) {
$operationalDayStart->subDay();
// --- LOGIKA BARU: Validasi Jam Operasional Harian ---
$bookingDate = $startDateTime->copy()->setTimezone('Asia/Jakarta');
$dayOfWeek = $bookingDate->dayOfWeekIso;
$todaysHours = $venue->operatingHours->firstWhere('day_of_week', $dayOfWeek);
if (!$todaysHours || $todaysHours->is_closed) {
return response()->json(['message' => 'Venue tutup pada tanggal yang dipilih.'], 400);
}
$operationalDayEnd = $operationalDayStart->copy()->setTimeFromTimeString($venue->close_time);
if ($venue->is_overnight) {
$openTimeToday = $todaysHours->open_time;
$closeTimeToday = $todaysHours->close_time;
$isOvernightToday = strtotime($closeTimeToday) < strtotime($openTimeToday);
$operationalDayStart = Carbon::createFromFormat('Y-m-d H:i:s', $startDateTime->format('Y-m-d') . ' ' . $openTimeToday, 'Asia/Jakarta');
if ($isOvernightToday && $startDateTime < $operationalDayStart) {
$operationalDayStart->subDay();
}
$operationalDayEnd = $operationalDayStart->copy()->setTimeFromTimeString($closeTimeToday);
if ($isOvernightToday) {
$operationalDayEnd->addDay();
}
if ($startDateTime->lt($operationalDayStart) || $endDateTime->gt($operationalDayEnd)) {
Log::warning('Admin direct booking attempt outside operational hours.', [
'start_time' => $startDateTime->toDateTimeString(),
'venue_open' => $operationalDayStart->toDateTimeString(),
'venue_close' => $operationalDayEnd->toDateTimeString(),
]);
return response()->json(['message' => 'Waktu booking di luar jam operasional venue.'], 400);
}
// --- Akhir Validasi jam operasional ---
// --- AKHIR VALIDASI BARU ---
// --- PERBAIKAN LOGIKA KONFLIK DIMULAI DI SINI ---
// Kita hapus ->whereDate() dan langsung cek bentrokan waktu.
$conflict = Booking::where('table_id', $data['table_id'])
->whereIn('status', ['paid', 'pending'])
->where(function($query) use ($startDateTime, $endDateTime) {
// Booking yang baru tidak boleh dimulai di tengah booking lain.
// Booking yang baru juga tidak boleh berakhir di tengah booking lain.
// Booking yang baru juga tidak boleh "menelan" booking lain.
$query->where('start_time', '<', $endDateTime)
->where('end_time', '>', $startDateTime);
})
->where('start_time', '<', $endDateTime)
->where('end_time', '>', $startDateTime)
->exists();
if ($conflict) {
return response()->json(['message' => 'Meja sudah dibooking di jam tersebut'], 409);
}
// --- AKHIR DARI PERBAIKAN LOGIKA KONFLIK ---
// Hitung total biaya dan durasi
// Sisa fungsi tidak berubah
$duration = $endDateTime->diffInHours($startDateTime);
$totalAmount = $duration * $table->price_per_hour;
$adminOrderId = 'ADMIN-' . $user->id . '-' . time();
$booking = Booking::create([
@ -96,31 +113,20 @@ public function adminDirectBooking($request) {
'end_time' => $endDateTime,
'status' => 'paid',
'total_amount' => $totalAmount,
'payment_id' => null,
'payment_method' => 'admin_direct',
'order_id' => $adminOrderId,
'validation_token' => (string) Str::uuid(),
]);
return response()->json([
'success' => true,
'message' => 'Booking berhasil dibuat oleh admin',
'booking_id' => $booking->id,
'booking_details' => [
'table_name' => $table->name,
'start_time' => $startDateTime->format('Y-m-d H:i:s'),
'end_time' => $endDateTime->format('Y-m-d H:i:s'),
'duration' => $duration . ' jam',
'total_amount' => 'Rp ' . number_format($totalAmount, 0, ',', '.')
]
// ... (detail booking lainnya)
]);
} catch (\Exception $e) {
\Log::error('Admin direct booking error:', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'request_data' => $request instanceof \Illuminate\Http\Request ? $request->all() : $request->toArray()
]);
\Log::error('Admin direct booking error:', ['message' => $e->getMessage()]);
return response()->json(['success' => false, 'message' => 'Gagal membuat booking: ' . $e->getMessage()], 500);
}
}
@ -136,45 +142,50 @@ public function createPaymentIntent(Request $request) {
]);
$user = Auth::user();
$table = Table::with('venue')->findOrFail($request->table_id);
$table = Table::with(['venue.operatingHours'])->findOrFail($request->table_id); // Muat relasi operatingHours
$venue = $table->venue;
$bookingDate = $request->booking_date;
$bookingDate = Carbon::createFromFormat('Y-m-d', $request->booking_date, 'Asia/Jakarta');
$startTimeString = $request->start_time;
$duration = (int) $request->duration;
// 1. Hitung start & end time yang sebenarnya
$startDateTime = Carbon::createFromFormat('Y-m-d H:i', $bookingDate . ' ' . $startTimeString, 'Asia/Jakarta');
// --- START LOGIKA BARU: Ambil Jadwal Hari Ini ---
$dayOfWeek = $bookingDate->dayOfWeekIso; // 1=Senin, 7=Minggu
$todaysHours = $venue->operatingHours->firstWhere('day_of_week', $dayOfWeek);
// --- AWAL PERBAIKAN LOGIKA STRING COMPARISON ---
$startTimeObject = Carbon::createFromFormat('H:i', $startTimeString);
$openTimeObject = Carbon::parse($venue->open_time);
if (!$todaysHours || $todaysHours->is_closed) {
return response()->json(['success' => false, 'message' => 'Venue tutup pada tanggal yang dipilih.'], 422);
}
// Bandingkan sebagai objek Carbon, bukan string
if ($venue->is_overnight && $startTimeObject->lt($openTimeObject)) {
// Gunakan jam dari jadwal harian, bukan dari $venue->open_time
$openTimeToday = $todaysHours->open_time;
$closeTimeToday = $todaysHours->close_time;
$isOvernightToday = strtotime($closeTimeToday) < strtotime($openTimeToday);
// --- END LOGIKA BARU ---
$startDateTime = Carbon::createFromFormat('Y-m-d H:i', $bookingDate->format('Y-m-d') . ' ' . $startTimeString, 'Asia/Jakarta');
if ($isOvernightToday && (strtotime($startTimeString) < strtotime($openTimeToday))) {
$startDateTime->addDay();
}
$endDateTime = $startDateTime->copy()->addHours($duration);
// 2. --- BLOK VALIDASI YANG DIPERBAIKI ---
$operationalDayStart = Carbon::createFromFormat('Y-m-d H:i:s', $startDateTime->format('Y-m-d') . ' ' . $venue->open_time, 'Asia/Jakarta');
if ($venue->is_overnight && $startDateTime < $operationalDayStart) {
// Validasi jam operasional menggunakan data dinamis
$operationalDayStart = Carbon::createFromFormat('Y-m-d H:i:s', $startDateTime->format('Y-m-d') . ' ' . $openTimeToday, 'Asia/Jakarta');
if ($isOvernightToday && $startDateTime < $operationalDayStart) {
$operationalDayStart->subDay();
}
$operationalDayEnd = $operationalDayStart->copy()->setTimeFromTimeString($venue->close_time);
if ($venue->is_overnight) {
$operationalDayEnd = $operationalDayStart->copy()->setTimeFromTimeString($closeTimeToday);
if ($isOvernightToday) {
$operationalDayEnd->addDay();
}
if ($startDateTime->lt($operationalDayStart) || $endDateTime->gt($operationalDayEnd)) {
Log::warning('Booking attempt outside operational hours.', [
'start_time' => $startDateTime->toDateTimeString(), 'end_time' => $endDateTime->toDateTimeString(),
'venue_open' => $operationalDayStart->toDateTimeString(), 'venue_close' => $operationalDayEnd->toDateTimeString(),
]);
return response()->json(['success' => false, 'message' => 'Durasi booking di luar jam operasional venue.'], 422);
}
// --- AKHIR DARI BLOK VALIDASI ---
// 3. Cek untuk admin direct booking (tidak berubah)
// Sisa dari fungsi ini (cek admin, cek konflik, proses Midtrans) tidak perlu diubah.
// ... (kode lama Anda untuk cek admin, cek konflik, dll, tetap di sini) ...
if ($user->role === 'admin' && $user->venue_id === $table->venue_id) {
return $this->adminDirectBooking(collect([
'table_id' => $request->table_id,
@ -183,7 +194,7 @@ public function createPaymentIntent(Request $request) {
]));
}
// 4. Cek konflik booking (tidak berubah)
// Cek konflik booking (tidak berubah)
$conflict = Booking::where('table_id', $request->table_id)
->where('status', 'paid')
->where(function($query) use ($startDateTime, $endDateTime) {
@ -195,7 +206,7 @@ public function createPaymentIntent(Request $request) {
return response()->json(['success' => false, 'message' => 'Meja sudah dibooking di jam tersebut'], 409);
}
// 5. Proses ke Midtrans (tidak berubah)
// Proses ke Midtrans (tidak berubah)
$totalAmount = $duration * $table->price_per_hour;
$tempOrderId = 'TEMP-' . Auth::id() . '-' . time();
@ -214,6 +225,7 @@ public function createPaymentIntent(Request $request) {
'snap_token' => $snapToken,
'order_id' => $tempOrderId
]);
} catch (\Exception $e) {
\Log::error('Payment intent error:', [
'message' => $e->getMessage(),
@ -225,7 +237,7 @@ public function createPaymentIntent(Request $request) {
'message' => 'Gagal membuat transaksi: ' . $e->getMessage()
], 500);
}
}
}
public function store(Request $request) {
try {
@ -273,6 +285,7 @@ public function store(Request $request) {
'payment_id' => $request->transaction_id,
'payment_method' => $request->payment_method,
'order_id' => $request->order_id,
'validation_token' => (string) Str::uuid(),
]);
// Update table status to booked

View File

@ -1,34 +1,42 @@
<?php
// app/Http/Controllers/pages/VenueController.php
namespace App\Http\Controllers\pages;
use App\Http\Controllers\Controller;
use App\Models\Venue; // Pastikan model Venue di-import
use Illuminate\Http\Request;
use App\Models\Venue;
use Carbon\Carbon; // <-- Pastikan Carbon di-import
class VenueController extends Controller
{
public function venue($venueName) {
// Mengambil venue berdasarkan nama yang diberikan
$venue = Venue::where('name', 'like', '%' . ucfirst($venueName) . '%')->first();
public function venue($venueName)
{
$venue = Venue::where('name', $venueName)
->with(['tables', 'images', 'operatingHours', 'reviews.user'])
->firstOrFail();
// Jika venue tidak ditemukan, tampilkan error 404
if (!$venue) {
abort(404);
// --- LOGIKA BARU UNTUK JAM OPERASIONAL HARI INI ---
// Day of week: 1 (Senin) - 7 (Minggu). Carbon menggunakan 0 (Minggu) - 6 (Sabtu)
$dayOfWeek = Carbon::now('Asia/Jakarta')->dayOfWeekIso; // ISO standard: 1=Senin, 7=Minggu
// Cari jadwal untuk hari ini dari data yang sudah dimuat
$todaysHours = $venue->operatingHours->firstWhere('day_of_week', $dayOfWeek);
// Siapkan data jam buka dan tutup untuk hari ini
// Jika tidak ada jadwal spesifik, atau jika hari ini libur, maka venue dianggap tutup
if ($todaysHours && !$todaysHours->is_closed) {
$openTime = $todaysHours->open_time;
$closeTime = $todaysHours->close_time;
} else {
// Set default ke 'tutup' jika tidak ada jadwal atau is_closed = true
$openTime = '00:00';
$closeTime = '00:00';
}
// --- AKHIR LOGIKA BARU ---
$averageRating = $venue->reviews->avg('rating');
$totalReviews = $venue->reviews->count();
// Ambil tabel-tabel terkait dengan venue
$venue->load('tables'); // Eager loading untuk optimasi
// Parsing jam operasional dari format H:i:s menjadi integer
$openHour = (int) date('H', strtotime($venue->open_time));
$closeHour = (int) date('H', strtotime($venue->close_time));
// Mengirim data venue dengan jam operasional ke view
return view('pages.venue', [
'venue' => $venue,
'openHour' => $openHour,
'closeHour' => $closeHour
]);
// Kirim semua data ke view
return view('pages.venue', compact('venue', 'openTime', 'closeTime', 'averageRating', 'totalReviews'));
}
}

View File

@ -18,7 +18,9 @@ class Booking extends Model
'payment_id',
'payment_method',
'total_amount',
'payment_expired_at'
'payment_expired_at',
'order_id', // Pastikan order_id juga ada di sini jika belum
'validation_token', // <-- TAMBAHKAN INI
];
protected $casts = [
@ -38,6 +40,11 @@ public function user()
return $this->belongsTo(User::class);
}
public function review()
{
return $this->hasOne(Review::class);
}
public function isPending()
{
return $this->status === 'pending';

View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class OperatingHour extends Model
{
use HasFactory;
protected $fillable = [
'venue_id',
'day_of_week',
'open_time',
'close_time',
'is_closed',
];
public function venue()
{
return $this->belongsTo(Venue::class);
}
}

34
app/Models/Review.php Normal file
View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Review extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'venue_id',
'booking_id',
'rating',
'comment',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function venue()
{
return $this->belongsTo(Venue::class);
}
public function booking()
{
return $this->belongsTo(Booking::class);
}
}

View File

@ -66,6 +66,11 @@ public function hasRole($role)
return $this->role === $role;
}
public function reviews()
{
return $this->hasMany(Review::class);
}
/**
* Get the venue that the admin belongs to.
*/

View File

@ -5,6 +5,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;
use App\Models\VenueImage; // <-- Tambahkan ini
class Venue extends Model
{
@ -19,8 +20,14 @@ public function getIsOvernightAttribute()
protected $fillable = [
'name',
'address',
'latitude',
'longitude',
'image',
'phone',
'link_instagram',
'link_tiktok',
'link_facebook',
'link_x',
'description',
'open_time',
'close_time',
@ -49,6 +56,21 @@ public function tables()
return $this->hasMany(Table::class);
}
public function images()
{
return $this->hasMany(VenueImage::class);
}
public function operatingHours()
{
return $this->hasMany(OperatingHour::class);
}
public function reviews()
{
return $this->hasMany(Review::class);
}
/**
* Check if venue should automatically reopen
*/

18
app/Models/VenueImage.php Normal file
View File

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class VenueImage extends Model
{
use HasFactory;
protected $fillable = ['venue_id', 'path'];
public function venue()
{
return $this->belongsTo(Venue::class);
}
}

View File

@ -13,7 +13,8 @@
"laravel/tinker": "^2.8",
"laravel/ui": "^4.6",
"maatwebsite/excel": "^1.1",
"midtrans/midtrans-php": "^2.6"
"midtrans/midtrans-php": "^2.6",
"simplesoftwareio/simple-qrcode": "^4.2"
},
"require-dev": {
"fakerphp/faker": "^1.9.1",

174
composer.lock generated
View File

@ -4,8 +4,62 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bce8ddce84cf8a2d572b421f6992903e",
"content-hash": "66130e103a2b9c249e50df9d5700a24f",
"packages": [
{
"name": "bacon/bacon-qr-code",
"version": "2.0.8",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22",
"shasum": ""
},
"require": {
"dasprid/enum": "^1.0.3",
"ext-iconv": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phly/keep-a-changelog": "^2.1",
"phpunit/phpunit": "^7 | ^8 | ^9",
"spatie/phpunit-snapshot-assertions": "^4.2.9",
"squizlabs/php_codesniffer": "^3.4"
},
"suggest": {
"ext-imagick": "to generate QR code images"
},
"type": "library",
"autoload": {
"psr-4": {
"BaconQrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "BaconQrCode is a QR code generator for PHP.",
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8"
},
"time": "2022-12-07T17:46:57+00:00"
},
{
"name": "brick/math",
"version": "0.12.1",
@ -135,6 +189,56 @@
],
"time": "2023-12-11T17:09:12+00:00"
},
{
"name": "dasprid/enum",
"version": "1.0.6",
"source": {
"type": "git",
"url": "https://github.com/DASPRiD/Enum.git",
"reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/8dfd07c6d2cf31c8da90c53b83c026c7696dda90",
"reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90",
"shasum": ""
},
"require": {
"php": ">=7.1 <9.0"
},
"require-dev": {
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"DASPRiD\\Enum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "PHP 7.1 enum implementation",
"keywords": [
"enum",
"map"
],
"support": {
"issues": "https://github.com/DASPRiD/Enum/issues",
"source": "https://github.com/DASPRiD/Enum/tree/1.0.6"
},
"time": "2024-08-09T14:30:48+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@ -3812,6 +3916,74 @@
],
"time": "2024-04-27T21:32:50+00:00"
},
{
"name": "simplesoftwareio/simple-qrcode",
"version": "4.2.0",
"source": {
"type": "git",
"url": "https://github.com/SimpleSoftwareIO/simple-qrcode.git",
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SimpleSoftwareIO/simple-qrcode/zipball/916db7948ca6772d54bb617259c768c9cdc8d537",
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537",
"shasum": ""
},
"require": {
"bacon/bacon-qr-code": "^2.0",
"ext-gd": "*",
"php": ">=7.2|^8.0"
},
"require-dev": {
"mockery/mockery": "~1",
"phpunit/phpunit": "~9"
},
"suggest": {
"ext-imagick": "Allows the generation of PNG QrCodes.",
"illuminate/support": "Allows for use within Laravel."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"QrCode": "SimpleSoftwareIO\\QrCode\\Facades\\QrCode"
},
"providers": [
"SimpleSoftwareIO\\QrCode\\QrCodeServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"SimpleSoftwareIO\\QrCode\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Simple Software LLC",
"email": "support@simplesoftware.io"
}
],
"description": "Simple QrCode is a QR code generator made for Laravel.",
"homepage": "https://www.simplesoftware.io/#/docs/simple-qrcode",
"keywords": [
"Simple",
"generator",
"laravel",
"qrcode",
"wrapper"
],
"support": {
"issues": "https://github.com/SimpleSoftwareIO/simple-qrcode/issues",
"source": "https://github.com/SimpleSoftwareIO/simple-qrcode/tree/4.2.0"
},
"time": "2021-02-08T20:43:55+00:00"
},
{
"name": "symfony/console",
"version": "v6.4.12",

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateVenueImagesTable extends Migration
{
public function up()
{
Schema::create('venue_images', function (Blueprint $table) {
$table->id();
$table->foreignId('venue_id')->constrained()->onDelete('cascade');
$table->string('path');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('venue_images');
}
}

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateOperatingHoursTable extends Migration
{
public function up()
{
Schema::create('operating_hours', function (Blueprint $table) {
$table->id();
$table->foreignId('venue_id')->constrained()->onDelete('cascade');
$table->tinyInteger('day_of_week'); // 1 untuk Senin, 2 Selasa, ..., 7 Minggu
$table->time('open_time')->nullable();
$table->time('close_time')->nullable();
$table->boolean('is_closed')->default(false);
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('operating_hours');
}
};

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddSocialMediaLinksToVenuesTable extends Migration
{
public function up()
{
Schema::table('venues', function (Blueprint $table) {
$table->string('link_instagram')->nullable()->after('phone');
$table->string('link_tiktok')->nullable()->after('link_instagram');
$table->string('link_facebook')->nullable()->after('link_tiktok');
$table->string('link_x')->nullable()->after('link_facebook'); // Untuk Twitter/X
});
}
public function down()
{
Schema::table('venues', function (Blueprint $table) {
$table->dropColumn(['link_instagram', 'link_tiktok', 'link_facebook', 'link_x']);
});
}
};

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::table('venues', function (Blueprint $table) {
$table->string('latitude')->nullable()->after('address');
$table->string('longitude')->nullable()->after('latitude');
});
}
public function down()
{
Schema::table('venues', function (Blueprint $table) {
$table->dropColumn(['latitude', 'longitude']);
});
}
};

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('reviews', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('venue_id')->constrained()->onDelete('cascade');
$table->foreignId('booking_id')->constrained()->onDelete('cascade')->unique();
$table->tinyInteger('rating'); // 1-5 stars
$table->text('comment')->nullable();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('reviews');
}
};

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::table('bookings', function (Blueprint $table) {
// Tambahkan kolom 'order_id' yang hilang terlebih dahulu
$table->string('order_id')->nullable()->unique()->after('payment_method');
// Kemudian, tambahkan kolom 'validation_token' setelah 'order_id'
$table->string('validation_token')->nullable()->unique()->after('order_id');
});
}
public function down()
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropColumn('validation_token');
$table->dropColumn('order_id');
});
}
};

View File

@ -0,0 +1,84 @@
@extends('layouts.admin')
@section('content')
<div class="p-6">
<h1 class="text-2xl font-bold text-gray-900 mb-6">Scan QR Code Validasi</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white p-6 rounded-lg shadow-sm border">
<div id="qr-reader" class="w-full"></div>
<p id="qr-reader-status" class="text-center text-sm text-gray-500 mt-2">Arahkan kamera ke QR Code...</p>
</div>
<div class="bg-white p-6 rounded-lg shadow-sm border">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Hasil Scan</h3>
<div id="qr-scan-result">
<div class="text-center py-10">
<p class="text-gray-400">Menunggu hasil scan...</p>
</div>
</div>
</div>
</div>
</div>
{{-- Script untuk library QR Scanner --}}
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const resultContainer = document.getElementById('qr-scan-result');
const statusContainer = document.getElementById('qr-reader-status');
function onScanSuccess(decodedText, decodedResult) {
// Hentikan scanner setelah berhasil
html5QrcodeScanner.clear();
statusContainer.innerHTML = `<span class="font-bold text-green-600">Scan Berhasil!</span> Memuat data...`;
// Kirim token ke server untuk divalidasi
fetch(`/admin/bookings/validate/${decodedText}`)
.then(response => {
if (!response.ok) {
// Jika booking tidak ditemukan (error 404) atau error lainnya
throw new Error(`Booking tidak valid atau terjadi error (Status: ${response.status})`);
}
return response.json();
})
.then(data => {
if (data.success) {
const booking = data.data;
// Tampilkan hasil yang valid
resultContainer.innerHTML = `
<div class="p-4 bg-green-50 border-l-4 border-green-500">
<h4 class="text-xl font-bold text-green-800"> Valid!</h4>
<div class="mt-4 space-y-2 text-sm">
<p><strong class="w-24 inline-block">Nama User:</strong> ${booking.user_name}</p>
<p><strong class="w-24 inline-block">Venue:</strong> ${booking.venue_name}</p>
<p><strong class="w-24 inline-block">Meja:</strong> ${booking.table_name}</p>
<p><strong class="w-24 inline-block">Waktu:</strong> ${booking.start_time} - ${booking.end_time}</p>
<p><strong class="w-24 inline-block">Durasi:</strong> ${booking.duration}</p>
<p><strong class="w-24 inline-block">Status:</strong> <span class="font-bold capitalize">${booking.status}</span></p>
</div>
</div>
`;
} else {
// Jika ada pesan error dari server
resultContainer.innerHTML = `<div class="p-4 bg-red-50 border-l-4 border-red-500"><h4 class="text-xl font-bold text-red-800">❌ Tidak Valid!</h4><p class="mt-2 text-red-700">${data.error || 'Data booking tidak bisa diambil.'}</p></div>`;
}
})
.catch(error => {
console.error("Error:", error);
// Tampilkan error jika fetch gagal
resultContainer.innerHTML = `<div class="p-4 bg-red-50 border-l-4 border-red-500"><h4 class="text-xl font-bold text-red-800">❌ Error!</h4><p class="mt-2 text-red-700">${error.message}</p></div>`;
});
}
// Inisialisasi scanner
var html5QrcodeScanner = new Html5QrcodeScanner(
"qr-reader", { fps: 10, qrbox: 250 }
);
// Render scanner
html5QrcodeScanner.render(onScanSuccess);
});
</script>
@endsection

View File

@ -2,14 +2,10 @@
@section('content')
<div class="p-6">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center space-x-4">
<a href="{{ route('admin.venue.index') }}" class="text-gray-500 hover:text-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</a>
<div>
<h1 class="text-2xl font-bold text-gray-900">Edit Venue</h1>
@ -18,189 +14,192 @@
</div>
</div>
<!-- Alert Messages -->
@if(session('success'))
<div class="mb-6 bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded-lg">
{{ session('success') }}
</div>
<div class="mb-6 bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded-lg">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg">{{ session('error') }}</div>
@endif
@if ($errors->any())
<div class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg">
{{ session('error') }}
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<!-- Edit Form -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<form action="{{ route('admin.venue.update') }}" method="POST" enctype="multipart/form-data" class="p-6">
@csrf
@method('PUT')
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-8 gap-y-6">
<div class="space-y-6">
<!-- Venue Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
Nama Venue <span class="text-red-500">*</span>
</label>
<input type="text" id="name" name="name" value="{{ old('name', $venue->name) }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('name') border-red-500 @enderror"
placeholder="Masukkan nama venue">
@error('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">Nama Venue <span class="text-red-500">*</span></label>
<input type="text" id="name" name="name" value="{{ old('name', $venue->name) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" required>
</div>
<div>
<label for="address" class="block text-sm font-medium text-gray-700 mb-2">Alamat <span class="text-red-500">*</span></label>
<textarea id="address" name="address" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" required>{{ old('address', $venue->address) }}</textarea>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="latitude" class="block text-sm font-medium text-gray-700 mb-2">Latitude</label>
<input type="text" id="latitude" name="latitude" value="{{ old('latitude', $venue->latitude) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="-8.123456">
</div>
<div>
<label for="longitude" class="block text-sm font-medium text-gray-700 mb-2">Longitude</label>
<input type="text" id="longitude" name="longitude" value="{{ old('longitude', $venue->longitude) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="113.123456">
</div>
</div>
<p class="text-xs text-gray-500 -mt-4">
*Cara mendapatkan: Buka Google Maps, klik kanan pada lokasi venue, lalu klik pada angka koordinat untuk menyalinnya.
</p>
<div>
<label for="phone" class="block text-sm font-medium text-gray-700 mb-2">Nomor Telepon</label>
<input type="text" id="phone" name="phone" value="{{ old('phone', $venue->phone) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<div class="pt-4 border-t">
<h3 class="text-base font-medium text-gray-800 mb-3">Link Media Sosial</h3>
<div class="space-y-4">
<div>
<label for="link_instagram" class="block text-sm font-medium text-gray-700 mb-2">Instagram</label>
<input type="url" id="link_instagram" name="link_instagram" value="{{ old('link_instagram', $venue->link_instagram) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="https://instagram.com/venueanda">
</div>
<div>
<label for="link_tiktok" class="block text-sm font-medium text-gray-700 mb-2">TikTok</label>
<input type="url" id="link_tiktok" name="link_tiktok" value="{{ old('link_tiktok', $venue->link_tiktok) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="https://tiktok.com/@venueanda">
</div>
<div>
<label for="link_facebook" class="block text-sm font-medium text-gray-700 mb-2">Facebook</label>
<input type="url" id="link_facebook" name="link_facebook" value="{{ old('link_facebook', $venue->link_facebook) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="https://facebook.com/venueanda">
</div>
<div>
<label for="link_x" class="block text-sm font-medium text-gray-700 mb-2">X (Twitter)</label>
<input type="url" id="link_x" name="link_x" value="{{ old('link_x', $venue->link_x) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="https://x.com/venueanda">
</div>
</div>
</div>
<div class="p-4 border border-gray-200 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">Jam Operasional Harian</h3>
<div class="space-y-4">
@php
$days = [1 => 'Senin', 2 => 'Selasa', 3 => 'Rabu', 4 => 'Kamis', 5 => 'Jumat', 6 => 'Sabtu', 7 => 'Minggu'];
// Siapkan data jam operasional yang ada agar mudah diakses
$hoursByDay = $venue->operatingHours->keyBy('day_of_week');
@endphp
<!-- Address -->
<div>
<label for="address" class="block text-sm font-medium text-gray-700 mb-2">
Alamat <span class="text-red-500">*</span>
</label>
<textarea id="address" name="address" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('address') border-red-500 @enderror"
placeholder="Masukkan alamat lengkap venue">{{ old('address', $venue->address) }}</textarea>
@error('address')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
@foreach($days as $dayNumber => $dayName)
@php
$hour = $hoursByDay->get($dayNumber);
@endphp
<div x-data="{ isClosed: {{ old('hours.'.$dayNumber.'.is_closed', $hour->is_closed ?? true) ? 'true' : 'false' }} }" class="grid grid-cols-1 sm:grid-cols-4 gap-3 items-center">
<div class="sm:col-span-1">
<label class="font-medium text-gray-700">{{ $dayName }}</label>
</div>
<div class="sm:col-span-3 flex items-center space-x-4">
<input type="checkbox" x-model="isClosed" name="hours[{{ $dayNumber }}][is_closed]" class="h-4 w-4 rounded">
<label class="text-sm text-gray-600">Tutup</label>
<!-- Phone -->
<div>
<label for="phone" class="block text-sm font-medium text-gray-700 mb-2">
Nomor Telepon
</label>
<input type="text" id="phone" name="phone" value="{{ old('phone', $venue->phone) }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('phone') border-red-500 @enderror"
placeholder="Contoh: 08123456789">
@error('phone')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
<div class="flex items-center space-x-2" x-show="!isClosed">
<input type="time" name="hours[{{ $dayNumber }}][open_time]" value="{{ old('hours.'.$dayNumber.'.open_time', $hour && $hour->open_time ? \Carbon\Carbon::parse($hour->open_time)->format('H:i') : '09:00') }}" :disabled="isClosed" class="w-full px-2 py-1 border border-gray-300 rounded-md text-sm">
<span>-</span>
<input type="time" name="hours[{{ $dayNumber }}][close_time]" value="{{ old('hours.'.$dayNumber.'.close_time', $hour && $hour->close_time ? \Carbon\Carbon::parse($hour->close_time)->format('H:i') : '22:00') }}" :disabled="isClosed" class="w-full px-2 py-1 border border-gray-300 rounded-md text-sm">
</div>
</div>
<input type="hidden" name="hours[{{ $dayNumber }}][day_of_week]" value="{{ $dayNumber }}">
</div>
@endforeach
</div>
<!-- Operating Hours -->
<div class="grid grid-cols-2 gap-4">
<div>
<label for="open_time" class="block text-sm font-medium text-gray-700 mb-2">
Jam Buka <span class="text-red-500">*</span>
</label>
<input type="time" id="open_time" name="open_time"
value="{{ old('open_time', $venue->open_time ? \Carbon\Carbon::parse($venue->open_time)->format('H:i') : '') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('open_time') border-red-500 @enderror">
@error('open_time')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label for="close_time" class="block text-sm font-medium text-gray-700 mb-2">
Jam Tutup <span class="text-red-500">*</span>
</label>
<input type="time" id="close_time" name="close_time"
value="{{ old('close_time', $venue->close_time ? \Carbon\Carbon::parse($venue->close_time)->format('H:i') : '') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('close_time') border-red-500 @enderror">
@error('close_time')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">Deskripsi Venue</label>
<textarea id="description" name="description" rows="5" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">{{ old('description', $venue->description) }}</textarea>
</div>
</div>
<!-- Right Column -->
<div class="space-y-6">
<!-- Current Image -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Foto Venue Saat Ini
</label>
<div class="w-full h-48 bg-gray-100 rounded-lg overflow-hidden">
@if($venue->image)
<img id="current-image" src="{{ asset('storage/' . $venue->image) }}"
alt="{{ $venue->name }}" class="w-full h-full object-cover">
@else
<div class="w-full h-full flex items-center justify-center text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
@endif
</div>
<label class="block text-sm font-medium text-gray-700 mb-2">Foto Sampul (Utama)</label>
<img id="current-image" src="{{ $venue->image ? asset('storage/' . $venue->image) : 'https://via.placeholder.com/300' }}" alt="{{ $venue->name }}" class="w-full h-48 object-cover rounded-lg bg-gray-100">
<input type="file" id="image" name="image" accept="image/*" class="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 mt-2">
</div>
<!-- New Image Upload -->
<div>
<label for="image" class="block text-sm font-medium text-gray-700 mb-2">
Upload Foto Baru (Opsional)
</label>
<input type="file" id="image" name="image" accept="image/*" onchange="previewImage(this)"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('image') border-red-500 @enderror">
@error('image')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
<p class="text-xs text-gray-500 mt-1">Format: JPEG, PNG, JPG, GIF. Maksimal 2MB</p>
<!-- Image Preview -->
<div id="image-preview" class="mt-4 hidden">
<p class="text-sm font-medium text-gray-700 mb-2">Preview Foto Baru:</p>
<div class="w-full h-48 bg-gray-100 rounded-lg overflow-hidden">
<img id="preview-img" src="" alt="Preview" class="w-full h-full object-cover">
</div>
</div>
</div>
</div>
</div>
<!-- Description -->
<div class="mt-6">
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">
Deskripsi Venue
</label>
<textarea id="description" name="description" rows="4"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('description') border-red-500 @enderror"
placeholder="Masukkan deskripsi venue (fasilitas, suasana, dll)">{{ old('description', $venue->description) }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Action Buttons -->
<div class="flex items-center justify-end space-x-4 mt-8 pt-6 border-t border-gray-200">
<a href="{{ route('admin.venue.index') }}"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg font-medium transition-colors">
Batal
</a>
<button type="submit"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Simpan Perubahan
<div class="pt-6 border-t">
<h3 class="text-lg font-medium text-gray-900 mb-2">Galeri Venue</h3>
<div id="gallery-container" class="grid grid-cols-3 sm:grid-cols-4 gap-4">
@foreach($venue->images as $image)
<div id="gallery-image-{{ $image->id }}" class="relative group">
<img src="{{ asset('storage/' . $image->path) }}" alt="Gallery Image" class="w-full h-24 object-cover rounded-lg">
<button type="button" onclick="deleteImage({{ $image->id }}, event)"
class="absolute top-1 right-1 bg-red-600 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity focus:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
@endforeach
</div>
<p id="no-gallery-text" class="text-sm text-gray-500 mt-2 {{ $venue->images->isNotEmpty() ? 'hidden' : '' }}">Belum ada gambar di galeri.</p>
<div class="mt-4">
<label for="gallery_images" class="block text-sm font-medium text-gray-700 mb-2">Tambah Gambar ke Galeri</label>
<input type="file" id="gallery_images" name="gallery_images[]" accept="image/*" multiple class="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-green-50 file:text-green-700 hover:file:bg-green-100">
</div>
</div>
</div>
</div>
<div class="flex items-center justify-end space-x-4 mt-8 pt-6 border-t border-gray-200">
<a href="{{ route('admin.venue.index') }}" class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg font-medium transition-colors">Batal</a>
<button type="submit" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">Simpan Perubahan</button>
</div>
</form>
</div>
</div>
<script>
function previewImage(input) {
const preview = document.getElementById('image-preview');
const previewImg = document.getElementById('preview-img');
function deleteImage(imageId, event) {
event.preventDefault();
if (!confirm('Apakah Anda yakin ingin menghapus gambar ini?')) return;
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function (e) {
previewImg.src = e.target.result;
preview.classList.remove('hidden');
fetch(`/admin/venue-image/${imageId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json' // Memberitahu server kita ingin respons JSON
}
})
.then(response => {
// Cek jika respons dari server tidak OK (misal: error 403, 404, 500)
if (!response.ok) {
// Ubah respons menjadi error agar bisa ditangkap di .catch()
return response.json().then(err => { throw err; });
}
return response.json();
})
.then(data => {
if (data.success) {
const imageElement = document.getElementById(`gallery-image-${imageId}`);
imageElement.remove();
reader.readAsDataURL(input.files[0]);
const galleryContainer = document.getElementById('gallery-container');
if (galleryContainer.children.length === 0) {
document.getElementById('no-gallery-text').classList.remove('hidden');
}
} else {
preview.classList.add('hidden');
// Menampilkan pesan error dari server jika success = false
alert(data.message || 'Gagal menghapus gambar.');
}
})
.catch(error => {
// Menangkap error dari server (seperti 403, 500) atau error koneksi
console.error('Error:', error);
// Menampilkan pesan error yang lebih spesifik jika ada, jika tidak tampilkan pesan default
alert('Terjadi kesalahan: ' + (error.message || 'Periksa koneksi Anda.'));
});
}
</script>
@endsection

View File

@ -166,6 +166,14 @@ class="nav-item flex items-center px-3 py-2.5 rounded-lg {{ request()->routeIs('
</svg>
<span x-show="sidebarOpen">Kelola Booking</span>
</a>
<a href="{{ route('admin.bookings.scanner') }}"
class="nav-item flex items-center px-3 py-2.5 rounded-lg {{ request()->routeIs('admin.bookings.scanner') ? 'active' : '' }}">
{{-- Ikon Baru untuk QR Code --}}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4h6v6H4V4zM4 14h6v6H4v-6zM14 4h6v6h-6V4zM14 14h6v6h-6v-6z" />
</svg>
<span x-show="sidebarOpen">Scan QR</span>
</a>
<a href="{{ route('admin.revenues.index') }}"
class="nav-item flex items-center px-3 py-2.5 rounded-lg {{ request()->routeIs('admin.revenues.*') ? 'active' : '' }}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24"

View File

@ -1,4 +1,45 @@
@extends('layouts.main') @section('content') <div class="min-h-96 mx-4 md:w-3/4 md:mx-auto py-8">
@extends('layouts.main')
@section('content')
<div x-data="{
showModal: false,
qrCodeUrl: '',
venueName: '',
bookingDate: '',
bookingTime: ''
}"
x-on:keydown.escape.window="showModal = false; document.body.style.overflow = 'auto';"
class="min-h-screen bg-gray-100">
{{-- ======================================================= --}}
{{-- START: MODAL UNTUK MENAMPILKAN QR CODE --}}
{{-- ======================================================= --}}
<div x-show="showModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75 p-4"
x-cloak>
<div @click.away="showModal = false; document.body.style.overflow = 'auto';" class="bg-white rounded-lg shadow-xl p-6 max-w-sm w-full text-center">
<h3 class="text-lg font-bold text-gray-800" x-text="venueName"></h3>
<p class="text-sm text-gray-500 mb-4">
<span x-text="bookingDate"></span> - <span x-text="bookingTime"></span>
</p>
<div class="p-4 border rounded-lg bg-white">
<img :src="qrCodeUrl" alt="Booking QR Code" class="w-full h-auto">
</div>
<p class="text-xs text-gray-500 mt-4">Tunjukkan QR Code ini kepada kasir saat Anda tiba di venue.</p>
<div class="mt-6 flex space-x-4">
<button @click="showModal = false; document.body.style.overflow = 'auto';" class="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-medium">Tutup</button>
<a :href="qrCodeUrl" download="tiket-carimeja.svg" class="w-full flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">Download</a>
</div>
</div>
</div>
<div class="min-h-96 mx-4 md:w-3/4 md:mx-auto py-8">
<h1 class="text-2xl font-bold mb-6">Riwayat Bookingg</h1>
@if($bookings->isEmpty())
@ -64,17 +105,52 @@ class="px-3 py-1 rounded-full text-sm {{ $booking->start_time > now() ? 'bg-gree
@endif
</div>
@if($booking->start_time > now() && $booking->status == 'paid')
<div class="mt-4 flex justify-end space-x-4">
<a href="{{ route('venue', $booking->table->venue->name) }}"
class="text-blue-500 hover:underline">Lihat Venue</a>
<div class="border-t mt-4 pt-4 flex justify-end items-center space-x-4">
<a href="{{ route('venue', $booking->table->venue->name) }}" class="text-sm text-blue-600 hover:underline font-medium">
Lihat Venue
</a>
<a href="{{ route('booking.reschedule.form', $booking->id) }}"
class="text-orange-500 hover:underline">
{{-- Hanya tampilkan tombol jika status lunas --}}
@if($booking->status == 'paid')
{{-- Opsi untuk booking yang AKAN DATANG --}}
@if(now()->lt($booking->start_time))
{{-- Tombol Reschedule, dengan asumsi ada kolom reschedule_count di tabel bookings --}}
@if(isset($booking->reschedule_count) && $booking->reschedule_count < 1)
<a href="{{ route('booking.reschedule.form', $booking) }}" class="text-sm text-orange-600 hover:underline font-medium">
Reschedule
</a>
</div>
@endif
@endif
{{-- Opsi untuk booking yang SUDAH SELESAI --}}
@if(now()->gt($booking->end_time))
@if(!$booking->review)
<a href="{{ route('reviews.create', $booking) }}" class="text-sm text-green-600 hover:underline font-medium">
Beri Ulasan
</a>
@else
<span class="text-sm text-gray-500">Ulasan diberikan</span>
@endif
@endif
{{-- Tombol QR selalu muncul untuk booking lunas yang punya token --}}
@if($booking->validation_token)
<button @click="
showModal = true;
qrCodeUrl = '{{ route('booking.qrcode', $booking) }}';
venueName = '{{ addslashes($booking->table->venue->name) }}';
bookingDate = '{{ $booking->start_time->format("d M Y") }}';
bookingTime = '{{ $booking->start_time->format("H:i") }} - {{ $booking->end_time->format("H:i") }}';
document.body.style.overflow = 'hidden';
"
class="bg-blue-600 text-white text-sm font-bold px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
Lihat Tiket (QR)
</button>
@endif
@endif
</div>
</div>
</div>
@endforeach

View File

@ -1,9 +1,7 @@
@extends('layouts.main')
@section('content')
<!-- Toast Notification Container -->
<div id="toast-container" class="fixed top-4 right-4 z-50 space-y-2"></div>
<!-- Loading Overlay -->
<div x-data="{ show: false }" x-show="show" x-cloak x-on:show-loading.window="show = true"
x-on:hide-loading.window="show = false"
class="fixed inset-0 bg-black bg-opacity-50 z-40 flex items-center justify-center">
@ -16,30 +14,244 @@ class="fixed inset-0 bg-black bg-opacity-50 z-40 flex items-center justify-cente
</div>
<div class="min-h-96 mx-4 md:w-3/4 md:mx-auto">
<div class="mb-6">
<img src="{{ Storage::url($venue['image']) }}" alt="{{ $venue['name'] }}"
class="w-full h-full object-cover rounded-lg mb-4 mt-8" />
<h1 class="text-xl text-gray-800 font-semibold">{{ $venue['name'] }}</h1>
<p class="text-sm text-gray-500">{{ $venue['description'] ?? 'Tidak ada deskripsi.' }}</p>
@if($venue['status'] === 'open')
{{-- Venue sedang buka - tampilkan jam operasional --}}
<p class="text-sm text-gray-600 mt-1">
<i class="fa-regular fa-clock text-green-500"></i>
Jam Operasional: {{ date('H:i A', strtotime($venue['open_time'])) }} -
{{ date('H:i A', strtotime($venue['close_time'])) }}
</p>
@php
// Menyiapkan semua gambar (cover + galeri) untuk Alpine.js
$galleryPaths = collect();
if ($venue->image) {
// Tambahkan gambar utama sebagai gambar pertama
$galleryPaths->push(asset('storage/' . $venue->image));
}
// Tambahkan gambar-gambar dari relasi 'images'
if(isset($venue->images) && $venue->images->isNotEmpty()) {
foreach ($venue->images as $img) {
$galleryPaths->push(asset('storage/' . $img->path));
}
}
@endphp
<div x-data="galleryViewer({ images: {{ $galleryPaths->toJson() }} })" class="mb-6 mt-8">
<div class="grid grid-cols-1 md:grid-cols-3 md:gap-2">
<div class="md:col-span-2 mb-2 md:mb-0">
<div @if($galleryPaths->isNotEmpty()) @click="open(0)" @endif class="relative w-full h-64 md:h-[33rem] cursor-pointer group">
<img src="{{ $venue->image ? asset('storage/' . $venue->image) : 'https://via.placeholder.com/800x400.png?text=Venue+Image' }}" alt="{{ $venue['name'] }}"
class="w-full h-full object-cover rounded-lg shadow-lg" />
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-300 rounded-lg"></div>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-1 gap-2">
@if(isset($venue->images) && $venue->images->isNotEmpty())
@foreach($venue->images->take(2) as $image)
@php
// Indeks ini untuk membuka gambar yang benar di modal
// +1 karena gambar utama (cover) ada di indeks 0
$modalIndex = $loop->index + 1;
@endphp
<div @click="open({{ $modalIndex }})" class="relative w-full h-40 md:h-64 cursor-pointer group">
<img src="{{ asset('storage/' . $image->path) }}" alt="Gallery image {{ $loop->iteration }}"
class="w-full h-full object-cover rounded-lg shadow-lg" />
@if($loop->last && $galleryPaths->count() > 1)
<div class="absolute inset-0 bg-black bg-opacity-40 flex items-center justify-center rounded-lg opacity-100 group-hover:bg-opacity-50 transition-all duration-300">
<span class="text-white font-semibold text-center">
<i class="fa-regular fa-images mr-1"></i>
Lihat Semua Foto
</span>
</div>
@else
{{-- Venue sedang tutup - tampilkan informasi penutupan --}}
<div class="mt-1">
<p class="text-sm text-red-600 font-medium">
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-300 rounded-lg"></div>
@endif
</div>
@endforeach
@endif
</div>
</div>
<div x-show="isOpen" @keydown.escape.window="close()" @keydown.arrow-right.window="next()" @keydown.arrow-left.window="prev()"
class="fixed inset-0 z-[999] flex items-center justify-center bg-black bg-opacity-80" x-cloak>
<button @click="close()" class="absolute top-4 right-5 text-white text-4xl z-50 hover:text-gray-300">×</button>
<div class="relative w-full h-full flex items-center justify-center p-4 md:p-8">
<button @click="prev()" class="absolute left-2 md:left-5 text-white text-3xl md:text-5xl opacity-70 hover:opacity-100 p-2 z-50">
</button>
<img :src="images[currentIndex]" class="w-auto h-auto object-contain" style="max-height: 90vh; max-width: 90vw;">
<button @click="next()" class="absolute right-2 md:right-5 text-white text-3xl md:text-5xl opacity-70 hover:opacity-100 p-2 z-50">
</button>
<div class="absolute bottom-4 text-white text-sm bg-black bg-opacity-50 px-2 py-1 rounded">
<span x-text="currentIndex + 1"></span> / <span x-text="images.length"></span>
</div>
</div>
</div>
<div class="mt-4">
<h1 class="text-xl text-gray-800 font-semibold">{{ $venue['name'] }}</h1>
{{-- <p class="text-sm text-gray-500">{{ $venue['description'] ?? 'Tidak ada deskripsi.' }}</p> --}}
<div x-data="{ isOpen: false }" class="text-sm">
@if($venue['status'] === 'open')
@php
// Siapkan data untuk ditampilkan
$days = [1 => 'Senin', 2 => 'Selasa', 3 => 'Rabu', 4 => 'Kamis', 5 => 'Jumat', 6 => 'Sabtu', 7 => 'Minggu'];
$hoursByDay = $venue->operatingHours->keyBy('day_of_week');
$todayDayNumber = now('Asia/Jakarta')->dayOfWeekIso; // 1 for Monday, 7 for Sunday
$todaysHours = $hoursByDay->get($todayDayNumber);
$isOpenNow = false;
$statusText = 'Tutup';
$statusColor = 'text-red-600';
if ($todaysHours && !$todaysHours->is_closed) {
$openTimeToday = \Carbon\Carbon::parse($todaysHours->open_time);
$closeTimeToday = \Carbon\Carbon::parse($todaysHours->close_time);
$now = now('Asia/Jakarta');
// Logika untuk 'Buka 24 Jam'
if ($openTimeToday->format('H:i') == '00:00' && $closeTimeToday->format('H:i') == '23:59') {
$isOpenNow = true;
$statusText = 'Buka 24 jam';
}
// Logika untuk jam overnight (lewat tengah malam)
elseif ($closeTimeToday->lt($openTimeToday)) {
if ($now->between($openTimeToday, $closeTimeToday->copy()->addDay())) {
$isOpenNow = true;
}
}
// Logika untuk jam normal
else {
if ($now->between($openTimeToday, $closeTimeToday)) {
$isOpenNow = true;
}
}
if($isOpenNow && $statusText == 'Tutup'){
$statusText = 'Buka sekarang';
}
$statusColor = $isOpenNow ? 'text-green-600' : 'text-red-600';
}
@endphp
<button @click="isOpen = !isOpen" class="flex items-center space-x-2 w-full text-left">
<i class="fa-regular fa-clock {{ $statusColor }}"></i>
<span class="font-medium {{ $statusColor }}">{{ $statusText }}</span>
<span class="text-gray-500">·</span>
<span class="text-gray-600">
@if($todaysHours && !$todaysHours->is_closed)
{{ \Carbon\Carbon::parse($todaysHours->open_time)->format('H:i') }} - {{ \Carbon\Carbon::parse($todaysHours->close_time)->format('H:i') }}
@else
Tutup
@endif
</span>
<i class="fa-solid fa-chevron-down text-xs text-gray-500 transition-transform" :class="{'rotate-180': isOpen}"></i>
</button>
<div x-show="isOpen" x-collapse class="mt-3 pl-6">
<table class="w-full text-left">
<tbody>
@foreach($days as $dayNumber => $dayName)
@php
$schedule = $hoursByDay->get($dayNumber);
$isToday = ($dayNumber == $todayDayNumber);
@endphp
<tr class="{{ $isToday ? 'font-bold' : '' }}">
<td class="py-1 pr-4">{{ $dayName }}</td>
<td class="py-1 text-gray-800">
@if($schedule && !$schedule->is_closed)
@if($schedule->open_time == '00:00:00' && $schedule->close_time == '23:59:00')
Buka 24 jam
@else
{{ \Carbon\Carbon::parse($schedule->open_time)->format('H:i') }} - {{ \Carbon\Carbon::parse($schedule->close_time)->format('H:i') }}
@endif
@else
Tutup
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else <div class="mt-1 flex items-center space-x-2">
<i class="fa-solid fa-circle-xmark text-red-500"></i>
<p class="text-sm text-red-600 font-medium">
Tutup Sementara - {{ $venue['close_reason'] }}
</p>
</div>
@endif
</div>
<a href="https://www.google.com/maps/search/?api=1&query={{ urlencode($venue['address']) }}" target="_blank"
<div class="mt-4 flex items-center space-x-4">
@if($venue->link_instagram)
<a href="{{ $venue->link_instagram }}" target="_blank" class="text-2xl text-orange-500 hover:text-orange-800">
<i class="fab fa-instagram fa-lg"></i>
</a>
@endif
@if($venue->link_tiktok)
<a href="{{ $venue->link_tiktok }}" target="_blank" class="text-2xl text-gray-700 hover:text-black">
<i class="fab fa-tiktok fa-lg"></i>
</a>
@endif
@if($venue->link_facebook)
<a href="{{ $venue->link_facebook }}" target="_blank" class="text-2xl text-blue-600 hover:text-blue-900">
<i class="fab fa-facebook-f fa-lg"></i>
</a>
@endif
@if($venue->link_x)
<a href="{{ $venue->link_x }}" target="_blank" class="text-2xl text-blue-500 hover:text-blue-800">
<i class="fab fa-twitter fa-lg"></i>
</a>
@endif
</div>
<div x-data="{ open: false }" class="relative inline-block text-left mt-4">
<div>
<button @click="open = !open" type="button" class="inline-flex items-center justify-center w-full rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
<i class="fa-solid fa-map-location-dot mr-2"></i>
Di Sekitar
<i class="fa-solid fa-chevron-down text-xs ml-2"></i>
</button>
</div>
<div x-show="open" @click.away="open = false" x-transition
class="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-10">
<div class="py-1" role="none">
@if($venue->latitude && $venue->longitude)
@php
// PASTIKAN BARIS INI MEMILIKI "https://www."
$baseUrl = "https://www.google.com/maps/search/";
$coordinates = "@" . $venue->latitude . ',' . $venue->longitude;
$restoUrl = $baseUrl . "Restoran/" . $coordinates . ",15z";
$hotelUrl = $baseUrl . "Hotel/" . $coordinates . ",15z";
$minimarketUrl = $baseUrl . "Minimarket/" . $coordinates . ",15z";
$atmUrl = $baseUrl . "ATM/" . $coordinates . ",17z";
@endphp
<a href="{{ $restoUrl }}" target="_blank" class="text-gray-700 block px-4 py-2 text-sm hover:bg-gray-100">
<i class="fa-solid fa-utensils w-5 mr-2"></i>Restoran
</a>
<a href="{{ $hotelUrl }}" target="_blank" class="text-gray-700 block px-4 py-2 text-sm hover:bg-gray-100">
<i class="fa-solid fa-hotel w-5 mr-2"></i>Hotel
</a>
<a href="{{ $minimarketUrl }}" target="_blank" class="text-gray-700 block px-4 py-2 text-sm hover:bg-gray-100">
<i class="fa-solid fa-store w-5 mr-2"></i>Minimarket
</a>
<a href="{{ $atmUrl }}" target="_blank" class="text-gray-700 block px-4 py-2 text-sm hover:bg-gray-100">
<i class="fa-solid fa-credit-card w-5 mr-2"></i>ATM
</a>
@else
<span class="text-gray-400 block px-4 py-2 text-sm">Koordinat venue belum diatur.</span>
@endif
</div>
</div>
</div>
</div>
</div>
<a href="http://maps.google.com/?q={{ urlencode($venue['address']) }}" target="_blank"
class="flex items-center bg-[url('/public/images/map.jpg')] bg-cover bg-center p-4">
<div>
<h1 class="font-semibold">Lokasi Venue</h1>
@ -49,8 +261,10 @@ class="flex items-center bg-[url('/public/images/map.jpg')] bg-cover bg-center p
<i class="fa-solid fa-map-pin text-red-800 text-3xl"></i>
</div>
</a>
@auth
<!-- Pending Bookings Section -->
<div x-data="pendingBookingsComponent" class="mt-6">
<template x-if="pendingBookings.length > 0">
<div class="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-6">
@ -119,9 +333,9 @@ class="bg-gray-200 text-gray-700 text-sm px-3 py-1 rounded-md hover:bg-gray-300"
<div x-data="booking(
@json(auth()->check()),
'{{ $table['id'] }}',
{{ date('G', strtotime($venue['open_time'])) }}, // Jam buka (format 24 jam tanpa leading zero)
{{ date('G', strtotime($venue['close_time'])) }}, // Jam tutup
{{ $venue->is_overnight ? 'true' : 'false' }} // Tambahkan flag is_overnight
{{ date('G', strtotime($openTime)) }}, {{-- <-- Gunakan variabel baru --}}
{{ date('G', strtotime($closeTime)) }}, {{-- <-- Gunakan variabel baru --}}
{{ (strtotime($closeTime) < strtotime($openTime)) ? 'true' : 'false' }} {{-- <-- Logika is_overnight dinamis --}}
)"
class="border rounded-lg shadow-md p-4 mb-4">
<div class="flex items-center justify-between cursor-pointer"
@ -159,8 +373,6 @@ class="border rounded-lg shadow-md p-4 mb-4">
<option value="1">1 Jam</option>
<option value="2">2 Jam</option>
<option value="3">3 Jam</option>
{{-- <option value="4">4 Jam</option>
<option value="5">5 Jam</option> --}}
</select>
@else
<select class="w-full border p-2 rounded-lg bg-gray-100 text-gray-400 cursor-not-allowed" disabled>
@ -184,6 +396,58 @@ class="mt-3 px-4 py-2 rounded-lg w-full
</div>
</div>
@endforeach
<div class="mt-8 pt-6 border-t">
<h2 class="text-xl font-bold text-gray-800 mb-4">Ulasan Pengguna</h2>
@if($totalReviews > 0)
<div class="flex items-center mb-6 bg-gray-50 p-4 rounded-lg">
<div class="text-5xl font-bold text-gray-800">{{ number_format($averageRating, 1) }}</div>
<div class="ml-4">
<div class="flex items-center">
@for ($i = 1; $i <= 5; $i++)
@if ($i <= round($averageRating))
<i class="fa-solid fa-star text-yellow-400"></i>
@else
<i class="fa-regular fa-star text-gray-300"></i>
@endif
@endfor
</div>
<p class="text-sm text-gray-600">Berdasarkan {{ $totalReviews }} ulasan</p>
</div>
</div>
<div class="space-y-6">
@foreach($venue->reviews->sortByDesc('created_at') as $review)
<div class="flex items-start space-x-4">
<div class="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center font-bold text-gray-600">
{{-- Ambil inisial nama --}}
{{ strtoupper(substr($review->user->name, 0, 1)) }}
</div>
<div class="flex-1">
<div class="flex items-center justify-between">
<div>
<p class="font-semibold text-gray-800">{{ $review->user->name }}</p>
<p class="text-xs text-gray-500">{{ $review->created_at->diffForHumans() }}</p>
</div>
<div class="flex items-center">
<span class="text-sm font-bold mr-1">{{ $review->rating }}</span>
<i class="fa-solid fa-star text-yellow-400"></i>
</div>
</div>
<p class="mt-2 text-gray-700">
{{ $review->comment }}
</p>
</div>
</div>
@endforeach
</div>
@else
<div class="text-center py-8 bg-gray-50 rounded-lg">
<p class="text-gray-500">Belum ada ulasan untuk venue ini.</p>
</div>
@endif
</div>
</div>
</div>
@ -221,12 +485,10 @@ function showToast(message, type = 'info', duration = 5000) {
toastContainer.appendChild(toast);
// Animate in
setTimeout(() => {
toast.classList.remove('translate-x-full', 'opacity-0');
}, 100);
// Auto remove
setTimeout(() => {
toast.classList.add('translate-x-full', 'opacity-0');
setTimeout(() => toast.remove(), 300);
@ -270,7 +532,6 @@ class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 transition-
document.body.appendChild(modal);
// Close on backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
@ -317,7 +578,6 @@ function showConfirmModal(title, message, onConfirm, onCancel = null) {
if (onCancel) onCancel();
});
// Close on backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
@ -326,13 +586,10 @@ function showConfirmModal(title, message, onConfirm, onCancel = null) {
});
}
// Custom event for refreshing pending bookings across components
const refreshPendingBookingsEvent = new Event('refresh-pending-bookings');
// Tambahkan fungsi helper untuk mendapatkan tanggal Jakarta yang konsisten
function getJakartaDate() {
const now = new Date();
// Buat objek Date dengan timezone Jakarta
const jakartaTime = new Date(now.toLocaleString("en-US", { timeZone: "Asia/Jakarta" }));
return jakartaTime;
}
@ -357,27 +614,19 @@ function updateClock() {
document.getElementById('realTimeClock').textContent = timeFormatter.format(jakartaTime);
}
// Format functions for pending bookings
function formatDateTime(dateTimeStr) {
// Parse the datetime string
const parts = dateTimeStr.split(/[^0-9]/);
const year = parseInt(parts[0]);
const month = parseInt(parts[1]) - 1; // JS months are 0-based
const month = parseInt(parts[1]) - 1;
const day = parseInt(parts[2]);
const hour = parseInt(parts[3]);
const minute = parseInt(parts[4]);
// Gunakan zona waktu Asia/Jakarta (UTC+7)
// Tambahkan 7 jam untuk mengkonversi dari UTC ke WIB
const adjustedHour = (hour + 7) % 24;
const dateFormatter = new Intl.DateTimeFormat('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
// Format the date and time separately
const dateObj = new Date(year, month, day);
return dateFormatter.format(dateObj) + ' ' +
(adjustedHour.toString().padStart(2, '0') + ':' +
@ -385,15 +634,10 @@ function formatDateTime(dateTimeStr) {
}
function formatTime(timeStr) {
// Parse the ISO date string without timezone conversion
const parts = timeStr.split(/[^0-9]/);
const hour = parseInt(parts[3]);
const minute = parseInt(parts[4]);
// Tambahkan 7 jam untuk mengkonversi dari UTC ke WIB
const adjustedHour = (hour + 7) % 24;
// Format time manually
return adjustedHour.toString().padStart(2, '0') + ':' +
minute.toString().padStart(2, '0');
}
@ -403,6 +647,30 @@ function formatPrice(price) {
}
document.addEventListener('alpine:init', () => {
// --- START: Gallery Viewer Alpine Component (KODE BARU) ---
Alpine.data('galleryViewer', (config) => ({
isOpen: false,
currentIndex: 0,
images: config.images || [],
open(index) {
if (this.images.length === 0) return;
this.currentIndex = index;
this.isOpen = true;
document.body.style.overflow = 'hidden';
},
close() {
this.isOpen = false;
document.body.style.overflow = 'auto';
},
next() {
this.currentIndex = (this.currentIndex + 1) % this.images.length;
},
prev() {
this.currentIndex = (this.currentIndex - 1 + this.images.length) % this.images.length;
}
}));
// --- END: Gallery Viewer Alpine Component ---
// Pending bookings component
Alpine.data('pendingBookingsComponent', () => ({
pendingBookings: [],
@ -411,12 +679,10 @@ function formatPrice(price) {
init() {
this.fetchPendingBookings();
// Listen for the custom event to refresh pending bookings
document.addEventListener('refresh-pending-bookings', () => {
console.log('Refreshing pending bookings from event');
this.fetchPendingBookings();
this.showPendingBookings = true; // Auto-expand the section
this.showPendingBookings = true;
});
},
@ -424,7 +690,6 @@ function formatPrice(price) {
fetch('/booking/pending')
.then(response => response.json())
.then(data => {
// Filter bookings untuk venue saat ini jika diperlukan
const currentVenueId = {{ $venue['id'] ?? 'null' }};
if (currentVenueId) {
this.pendingBookings = data.filter(booking =>
@ -433,12 +698,7 @@ function formatPrice(price) {
} else {
this.pendingBookings = data;
}
// Log jumlah pending bookings yang ditemukan
console.log("Found", this.pendingBookings.length, "pending bookings");
// If we have pending bookings and this was triggered by payment cancellation,
// make sure to show them
if (this.pendingBookings.length > 0 && window.justClosedPayment) {
this.showPendingBookings = true;
window.justClosedPayment = false;
@ -450,36 +710,21 @@ function formatPrice(price) {
resumeBooking(bookingId) {
this.isLoadingPending = true;
window.dispatchEvent(new CustomEvent('show-loading'));
fetch(`/booking/pending/${bookingId}/resume`)
.then(response => response.json())
.then(data => {
window.dispatchEvent(new CustomEvent('hide-loading'));
if (data.success) {
console.log("Opening payment with snap token:", data.snap_token);
// Open Snap payment
window.snap.pay(data.snap_token, {
onSuccess: (result) => {
this.createBookingAfterPayment(data.order_id, result);
},
onPending: (result) => {
showToast('Pembayaran pending, silahkan selesaikan pembayaran', 'warning');
this.isLoadingPending = false;
},
onError: (result) => {
showToast('Pembayaran gagal', 'error');
this.isLoadingPending = false;
},
onClose: () => {
showToast('Anda menutup popup tanpa menyelesaikan pembayaran', 'warning');
this.isLoadingPending = false;
}
onSuccess: (result) => this.createBookingAfterPayment(data.order_id, result),
onPending: (result) => { showToast('Pembayaran pending...', 'warning'); this.isLoadingPending = false; },
onError: (result) => { showToast('Pembayaran gagal', 'error'); this.isLoadingPending = false; },
onClose: () => { showToast('Anda menutup popup pembayaran', 'warning'); this.isLoadingPending = false; }
});
} else {
showToast(data.message, 'error');
this.isLoadingPending = false;
// Refresh pending bookings list
this.fetchPendingBookings();
}
})
@ -499,10 +744,7 @@ function formatPrice(price) {
this.isLoadingPending = true;
fetch(`/booking/pending/${bookingId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
}
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content }
})
.then(response => response.json())
.then(data => {
@ -525,215 +767,100 @@ function formatPrice(price) {
createBookingAfterPayment(orderId, paymentResult) {
window.dispatchEvent(new CustomEvent('show-loading'));
fetch('/booking', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({
order_id: orderId,
transaction_id: paymentResult.transaction_id,
payment_method: paymentResult.payment_type,
transaction_status: paymentResult.transaction_status
}),
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
body: JSON.stringify({ order_id: orderId, transaction_id: paymentResult.transaction_id, payment_method: paymentResult.payment_type, transaction_status: paymentResult.transaction_status }),
})
.then(res => {
if (!res.ok) {
return res.json().then(err => {
throw new Error(err.message || 'Gagal menyimpan booking');
});
}
if (!res.ok) return res.json().then(err => { throw new Error(err.message || 'Gagal menyimpan booking'); });
return res.json();
})
.then(data => {
window.dispatchEvent(new CustomEvent('hide-loading'));
showToast('Pembayaran dan booking berhasil!', 'success');
this.isLoadingPending = false;
// Refresh pending bookings list
this.fetchPendingBookings();
// Redirect to booking history
setTimeout(() => {
window.location.href = '/booking/history';
}, 2000);
setTimeout(() => { window.location.href = '/booking/history'; }, 2000);
})
.catch(err => {
window.dispatchEvent(new CustomEvent('hide-loading'));
console.error('Booking error:', err);
showToast('Pembayaran berhasil tetapi gagal menyimpan booking: ' + err.message, 'error');
showToast('Gagal menyimpan booking: ' + err.message, 'error');
this.isLoadingPending = false;
});
}
}));
// Regular booking component (updated with dynamic hours)
// Regular booking component
Alpine.data('booking', (isLoggedIn, tableId, openHour, closeHour, isOvernight) => ({
isLoggedIn,
tableId,
openHour,
closeHour,
isOvernight,
open: false,
selectedTime: '',
selectedDuration: '',
isLoading: false,
bookedSchedules: [],
// Updated method to use dynamic hours from venue
isLoggedIn, tableId, openHour, closeHour, isOvernight, open: false, selectedTime: '', selectedDuration: '', isLoading: false, bookedSchedules: [],
getAvailableHours() {
let hours = [];
const currentJakartaHour = getJakartaDate().getHours();
if (this.isOvernight) {
// Jam dari waktu buka sampai tengah malam (23)
for (let i = this.openHour; i < 24; i++) {
// Hanya tampilkan jam yang akan datang
if (i >= currentJakartaHour) {
hours.push(i.toString().padStart(2, '0'));
}
}
// Jam dari tengah malam (00) sampai waktu tutup
for (let i = 0; i <= this.closeHour; i++) {
hours.push(i.toString().padStart(2, '0'));
}
for (let i = this.openHour; i < 24; i++) { if (i >= currentJakartaHour) hours.push(i.toString().padStart(2, '0')); }
for (let i = 0; i <= this.closeHour; i++) { hours.push(i.toString().padStart(2, '0')); }
} else {
// Logika standar untuk venue yang tidak overnight
for (let i = this.openHour; i <= this.closeHour; i++) {
// Hanya tampilkan jam yang akan datang
if (i >= currentJakartaHour) {
hours.push(i.toString().padStart(2, '0'));
}
}
for (let i = this.openHour; i <= this.closeHour; i++) { if (i >= currentJakartaHour) hours.push(i.toString().padStart(2, '0')); }
}
return hours;
},
isTimeBooked(time) {
const timeFormatted = time.padStart(5, '0');
return this.bookedSchedules.some(schedule => {
const isOvernightBooking = schedule.end < schedule.start;
if (isOvernightBooking) {
// Untuk booking overnight (misal 23:00 - 01:00)
// Slot dianggap booked jika:
// 1. Lebih besar atau sama dengan jam mulai (misal 23:00)
// ATAU
// 2. Lebih kecil dari jam selesai (misal 00:00)
return (timeFormatted >= schedule.start || timeFormatted < schedule.end);
} else {
// Untuk booking normal
return (timeFormatted >= schedule.start && timeFormatted < schedule.end);
}
if (isOvernightBooking) return (timeFormatted >= schedule.start || timeFormatted < schedule.end);
else return (timeFormatted >= schedule.start && timeFormatted < schedule.end);
});
},
async checkBookedSchedules() {
// Gunakan tanggal Jakarta yang konsisten
const today = getJakartaDateString();
try {
const response = await fetch(`/booking/schedules?table_id=${this.tableId}&date=${today}`);
this.bookedSchedules = await response.json();
console.log('Checking schedules for date:', today, 'Table:', this.tableId);
console.log('Booked schedules:', this.bookedSchedules);
} catch (error) {
console.error('Error checking booked schedules:', error);
}
} catch (error) { console.error('Error checking booked schedules:', error); }
},
initiateBooking(tableId, tableName) {
if (!this.isLoggedIn) {
showToast('Silahkan login terlebih dahulu untuk melakukan booking', 'warning');
return;
}
const selectedTime = this.selectedTime;
const selectedDuration = this.selectedDuration;
if (!this.isLoggedIn) { showToast('Silahkan login terlebih dahulu', 'warning'); return; }
if (!this.selectedTime || !this.selectedDuration) { showToast('Pilih jam dan durasi', 'warning'); return; }
if (!selectedTime || !selectedDuration) {
showToast('Please select both time and duration', 'warning');
return;
}
// Validasi jam menggunakan waktu Jakarta
const now = getJakartaDate();
const selectedDateTime = new Date(now);
const [selectedHour, selectedMinute] = selectedTime.split(':').map(Number);
const [selectedHour, selectedMinute] = this.selectedTime.split(':').map(Number);
selectedDateTime.setHours(selectedHour, selectedMinute, 0, 0);
if (this.isOvernight && selectedHour < this.openHour) {
selectedDateTime.setDate(selectedDateTime.getDate() + 1);
}
// Uncomment this for production to prevent booking past times
if (selectedDateTime <= now) {
showToast('Tidak bisa booking untuk waktu yang sudah berlalu', 'warning');
return;
}
if (this.isOvernight && selectedHour < this.openHour) selectedDateTime.setDate(selectedDateTime.getDate() + 1);
if (selectedDateTime <= now) { showToast('Tidak bisa booking untuk waktu yang sudah berlalu', 'warning'); return; }
this.isLoading = true;
window.dispatchEvent(new CustomEvent('show-loading'));
// Gunakan tanggal Jakarta yang konsisten
const bookingDate = getJakartaDateString();
fetch('/booking/initiate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({
table_id: tableId,
start_time: selectedTime,
duration: selectedDuration,
booking_date: bookingDate
}),
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
body: JSON.stringify({ table_id: tableId, start_time: this.selectedTime, duration: this.selectedDuration, booking_date: bookingDate }),
})
.then(res => res.json())
.then(data => {
window.dispatchEvent(new CustomEvent('hide-loading'));
if (data.success) {
// Cek apakah ini admin direct booking atau customer payment
if (data.snap_token) {
// Customer biasa - perlu payment
console.log("Opening payment with snap token:", data.snap_token);
window.snap.pay(data.snap_token, {
onSuccess: (result) => {
this.createBooking(data.order_id, result);
},
onPending: (result) => {
showToast('Pembayaran pending, silahkan selesaikan pembayaran', 'warning');
this.isLoading = false;
},
onError: (result) => {
showToast('Pembayaran gagal', 'error');
this.isLoading = false;
},
onSuccess: (result) => this.createBooking(data.order_id, result),
onPending: (result) => { showToast('Pembayaran pending...', 'warning'); this.isLoading = false; },
onError: (result) => { showToast('Pembayaran gagal', 'error'); this.isLoading = false; },
onClose: () => {
showToast('Anda menutup popup tanpa menyelesaikan pembayaran', 'warning');
showToast('Anda menutup popup pembayaran', 'warning');
this.isLoading = false;
window.justClosedPayment = true;
// Dispatch event to refresh pending bookings
document.dispatchEvent(refreshPendingBookingsEvent);
}
});
} else if (data.booking_id) {
// Admin direct booking - langsung berhasil
showToast(data.message || 'Booking berhasil dibuat!', 'success');
this.isLoading = false;
// Refresh halaman atau reload available times
setTimeout(() => {
window.location.reload(); // Atau panggil method refresh yang sudah ada
}, 1000);
} else {
// Response success tapi tidak ada snap_token atau booking_id
showToast(data.message || 'Booking berhasil diproses', 'success');
this.isLoading = false;
if (data.booking_id) setTimeout(() => window.location.reload(), 1000);
}
} else {
showToast(data.message, 'error');
@ -747,62 +874,38 @@ function formatPrice(price) {
this.isLoading = false;
});
},
createBooking(orderId, paymentResult) {
window.dispatchEvent(new CustomEvent('show-loading'));
fetch('/booking', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({
order_id: orderId,
transaction_id: paymentResult.transaction_id,
payment_method: paymentResult.payment_type,
transaction_status: paymentResult.transaction_status
}),
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
body: JSON.stringify({ order_id: orderId, transaction_id: paymentResult.transaction_id, payment_method: paymentResult.payment_type, transaction_status: paymentResult.transaction_status }),
})
.then(res => {
if (!res.ok) {
return res.json().then(err => {
throw new Error(err.message || 'Gagal menyimpan booking');
});
}
if (!res.ok) return res.json().then(err => { throw new Error(err.message || 'Gagal menyimpan booking'); });
return res.json();
})
.then(data => {
window.dispatchEvent(new CustomEvent('hide-loading'));
showToast('Pembayaran dan booking berhasil!', 'success');
this.isLoading = false;
// Reset form
this.selectedTime = '';
this.selectedDuration = '';
this.open = false;
// Refresh booked schedules
this.checkBookedSchedules();
// Redirect to booking history
setTimeout(() => {
window.location.href = '/booking/history';
}, 2000);
setTimeout(() => { window.location.href = '/booking/history'; }, 2000);
})
.catch(err => {
window.dispatchEvent(new CustomEvent('hide-loading'));
console.error('Booking error:', err);
showToast('Pembayaran berhasil tetapi gagal menyimpan booking: ' + err.message, 'error');
showToast('Gagal menyimpan booking: ' + err.message, 'error');
this.isLoading = false;
});
}
}));
});
// Initialize clock
updateClock();
setInterval(updateClock, 1000);
</script>
@endsection

View File

@ -0,0 +1,75 @@
@extends('layouts.main')
@section('content')
<style>
/* CSS untuk rating bintang */
.rating {
display: inline-block;
direction: rtl; /* Bintang dari kanan ke kiri */
}
.rating input {
display: none;
}
.rating label {
font-size: 2.5rem;
color: #ddd;
cursor: pointer;
padding: 0 0.1em;
}
.rating input:checked ~ label,
.rating label:hover,
.rating label:hover ~ label {
color: #f5b301;
}
</style>
<div class="min-h-screen container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto bg-white rounded-lg shadow-md p-8">
<h1 class="text-2xl font-bold text-gray-800 mb-2">Beri Ulasan Anda</h1>
<p class="text-gray-600 mb-6">
Untuk booking di **{{ $booking->table->venue->name }}** pada tanggal **{{ $booking->start_time->format('d M Y') }}**.
</p>
@if ($errors->any())
<div class="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg">
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form action="{{ route('reviews.store') }}" method="POST">
@csrf
<input type="hidden" name="booking_id" value="{{ $booking->id }}">
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">Rating Anda *</label>
<div class="rating">
<input type="radio" id="star5" name="rating" value="5" required /><label for="star5"></label>
<input type="radio" id="star4" name="rating" value="4" /><label for="star4"></label>
<input type="radio" id="star3" name="rating" value="3" /><label for="star3"></label>
<input type="radio" id="star2" name="rating" value="2" /><label for="star2"></label>
<input type="radio" id="star1" name="rating" value="1" /><label for="star1"></label>
</div>
@error('rating') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="mb-6">
<label for="comment" class="block text-sm font-medium text-gray-700 mb-2">Ulasan Anda *</label>
<textarea name="comment" id="comment" rows="5"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Bagaimana pengalaman Anda di venue ini? Ceritakan di sini...">{{ old('comment') }}</textarea>
@error('comment') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex justify-end">
<button type="submit" class="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors">
Kirim Ulasan
</button>
</div>
</form>
</div>
</div>
@endsection

View File

@ -16,6 +16,7 @@
use App\Http\Controllers\superadmin\VenueManagementController;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Auth;
use App\Http\Controllers\ReviewController;
// Authentication Routes (dengan verifikasi email aktif)
Auth::routes(['verify' => true]);
@ -68,10 +69,15 @@
// Any sensitive operations that should still require password confirmation can go here
});
Route::post('/reviews', [ReviewController::class, 'store'])->name('reviews.store');
Route::get('/reviews/create/{booking}', [ReviewController::class, 'create'])->name('reviews.create');
Route::get('/booking/{booking}/qrcode', [BookingController::class, 'showQrCode'])->name('booking.qrcode');
});
// Admin routes (admin tetap perlu verified untuk keamanan)
Route::middleware(['auth', 'verified', 'is_admin'])->prefix('admin')->group(function () {
Route::middleware(['auth', 'verified', 'is_admin'])->prefix('admin')->group(function () {
Route::get('/', [AdminController::class, 'index'])->name('admin.dashboard');
// Admin Profile Routes
@ -81,12 +87,14 @@
// Booking management routes
Route::get('/bookings', [BookingsController::class, 'index'])->name('admin.bookings.index');
Route::get('/bookings/export', [BookingsController::class, 'export'])->name('admin.bookings.export');
Route::get('/bookings/{id}', [BookingsController::class, 'show'])->name('admin.bookings.show');
Route::get('/bookings/{id}/edit', [BookingsController::class, 'edit'])->name('admin.bookings.edit');
Route::put('/bookings/{id}', [BookingsController::class, 'update'])->name('admin.bookings.update');
Route::patch('/bookings/{id}/complete', [BookingsController::class, 'complete'])->name('admin.bookings.complete');
Route::patch('/bookings/{id}/cancel', [BookingsController::class, 'cancel'])->name('admin.bookings.cancel');
Route::get('/bookings/export', [BookingsController::class, 'export'])->name('admin.bookings.export');
Route::get('/bookings/scan', [BookingsController::class, 'showScanner'])->name('admin.bookings.scanner'); // <-- Pindahkan ke atas
Route::get('/bookings/validate/{token}', [BookingsController::class, 'validateBooking'])->name('admin.bookings.validate'); // <-- Pindahkan ke atas
Route::get('/bookings/{id}', [BookingsController::class, 'show'])->name('admin.bookings.show'); // <-- Route dengan {id} sekarang di bawah
Route::get('/bookings/{id}/edit', [BookingsController::class, 'edit'])->name('admin.bookings.edit');
Route::put('/bookings/{id}', [BookingsController::class, 'update'])->name('admin.bookings.update');
Route::patch('/bookings/{id}/complete', [BookingsController::class, 'complete'])->name('admin.bookings.complete');
Route::patch('/bookings/{id}/cancel', [BookingsController::class, 'cancel'])->name('admin.bookings.cancel');
// Table management routes
Route::get('/tables', [TableController::class, 'index'])->name('admin.tables.index');
@ -101,11 +109,14 @@
Route::get('/venue/edit', [AdminVenueController::class, 'edit'])->name('admin.venue.edit');
Route::put('/venue/update', [AdminVenueController::class, 'update'])->name('admin.venue.update');
Route::post('/venue/toggle-status', [AdminVenueController::class, 'toggleStatus'])->name('admin.venue.toggle-status');
Route::delete('/venue-image/{image}', [AdminVenueController::class, 'destroyImage'])->name('admin.venue.image.destroy');
// Revenue management routes
Route::get('/revenues', [RevenueController::class, 'index'])->name('admin.revenues.index');
Route::get('/revenues/detail/{tableId}', [RevenueController::class, 'detail'])->name('admin.revenues.detail');
Route::get('/revenues/export', [RevenueController::class, 'export'])->name('admin.revenues.export');
});
// Superadmin routes