Halo teman-teman! Kali ini kita bakal belajar bikin website yang beneran bisa dipakai dan bahkan dijual lagi. Bayangin aja, setelah tutorial ini selesai, kalian udah punya skill buat bikin website sewa kantor yang modern dan profesional. Ini bukan cuma tutorial biasa yang abis baca langsung lupa, tapi kita bakal bikin proyek nyata dari nol sampai bisa dipake!
Kenapa sih pilih studi kasus website sewa kantor? Soalnya website kayak gini tuh kompleks tapi ga terlalu rumit buat dipelajari. Ada sistem user management, booking system, payment handling, dashboard admin, dan masih banyak lagi. Sempurna banget buat ngasah skill development kita.
Teknologi yang Bakal Kita Pelajari
Di tutorial ini, kita bakal pake teknologi-teknologi keren yang lagi hits di dunia web development:
Laravel 12 - Framework PHP yang paling populer dan powerful. Laravel 12 ini versi terbaru yang udah dilengkapi dengan fitur-fitur canggih kayak improved performance, better security, dan developer experience yang makin mantap. Dengan Laravel, kita bisa bikin aplikasi web dengan cepat tanpa perlu ribet ngoding dari nol.
Filament 3 - Ini dia admin panel yang bikin hidup developer jadi gampang banget! Filament 3 adalah CRUD generator dan admin panel builder yang bisa bikin dashboard admin dalam hitungan menit. Ga perlu lagi ngoding form-form admin yang boring, semua udah auto-generated dengan tampilan yang kece.
Spatie Laravel Permission - Package legend buat ngatur role dan permission di Laravel. Dengan ini, kita bisa bikin sistem user yang kompleks dengan berbagai level akses. Misalnya admin bisa akses semua fitur, operator cuma bisa lihat booking, customer cuma bisa booking aja.
Target Pembelajaran dan Skill yang Akan Dikuasai
Setelah ngikutin tutorial ini sampai habis, kalian bakal menguasai:
Full-Stack Development Skills: Mulai dari setup database, bikin API backend, sampai frontend yang responsive. Lengkap banget kan?
Modern Laravel Development: Kalian bakal paham gimana cara pake Laravel 12 dengan best practices. Mulai dari migration, model relationships, controller logic, sampai blade templating.
Admin Panel Mastery: Dengan Filament 3, kalian bakal jadi expert dalam bikin dashboard admin yang powerful. Ga cuma CRUD biasa, tapi juga statistik, charts, dan fitur-fitur canggih lainnya.
User Management System: Pakai Spatie Permission, kalian bakal bisa bikin sistem user yang aman dan terstruktur. Tau cara ngatur siapa bisa ngapain di website.
Database Design: Belajar bikin struktur database yang efisien dan scalable. Ini skill fundamental yang bakal kepake terus di project lain.
Frontend Integration: Meskipun fokus ke backend, kita juga bakal belajar cara integrate dengan frontend pake Tailwind CSS yang lagi trending.
Kenapa Pilih Website Sewa Kantor?
Mungkin ada yang nanya, "Kok pilih website sewa kantor sih? Kan udah banyak yang bikin tutorial e-commerce atau blog?" Nah, ini alasannya:
Real Business Case: Website sewa kantor itu beneran ada pasarnya. Apalagi sekarang era remote work dan co-working space lagi booming. Jadi project yang kita bikin bisa beneran dipake!
Kompleksitas yang Pas: Ga terlalu simple kayak todo app, tapi juga ga terlalu rumit kayak sistem perbankan. Perfect buat belajar konsep-konsep intermediate sampai advanced.
Multiple User Types: Di sini kita bakal belajar handle berbagai jenis user dengan permission yang beda-beda. Customer, admin, operator, masing-masing punya akses yang berbeda.
Payment Integration: Kita bakal belajar cara handle sistem pembayaran, mulai dari booking sampai konfirmasi bayar. Skill ini penting banget di era digital sekarang.
Business Intelligence: Dashboard admin ga cuma CRUD doang, tapi juga ada laporan pendapatan, statistik okupansi, dan insights bisnis lainnya.
Scalable Architecture: Project ini dirancang biar bisa dikembangkan lebih lanjut. Mau nambah fitur chat, notification, atau integrasi payment gateway, semuanya udah siap.
Fitur Wajib Website Sewa Kantor: Apa Aja Sih yang Harus Ada?
Nah, sebelum mulai ngoding, kita perlu tau dulu fitur apa aja yang mau kita bikin. Ini penting banget biar development kita terarah dan ga bingung di tengah jalan. Website sewa kantor yang baik tuh harus punya fitur yang lengkap, baik buat customer maupun admin.
Kita bakal bagi fitur-fiturnya berdasarkan jenis usernya ya. Ada fitur buat customer (yang mau sewa kantor) dan ada fitur buat admin/operator (yang manage website). Mari kita bahas satu-satu!
Fitur untuk Customer: Pengalaman User yang Memorable
Homepage dengan Daftar Kantor dan Filter Pencarian
Homepage itu kayak etalase toko, harus menarik dan informatif. Di sini customer bakal langsung liat daftar kantor-kantor yang tersedia dengan foto yang eye-catching. Yang paling penting, ada sistem filter yang powerful biar customer bisa nyari kantor sesuai kebutuhan.
Filter yang kita sediain bakal lengkap banget: berdasarkan lokasi (kota, area), harga per hari/bulan, kapasitas ruangan, fasilitas yang tersedia (WiFi, AC, proyektor, dll), dan rating dari customer lain. Bayangin customer bisa filter "Kantor di Jakarta Selatan, budget maksimal 500rb per hari, kapasitas minimal 10 orang, harus ada proyektor". Dalam sekejap, website kita bakal nampilin kantor-kantor yang sesuai.
Tampilannya juga harus responsive dan user-friendly. Customer bisa switch antara tampilan grid (kayak Instagram) atau list view (kayak Google search results). Plus ada sort option berdasarkan harga termurah, rating tertinggi, atau jarak terdekat.
Detail Kantor dengan Galeri Foto dan Informasi Lengkap
Kalau customer udah nemuin kantor yang menarik, mereka pasti mau tau detail lengkapnya. Halaman detail kantor ini harus comprehensive banget. Ada galeri foto yang kece dengan lightbox view, jadi customer bisa liat foto-foto kantor dengan jelas.
Informasi yang ditampilin meliputi deskripsi lengkap kantor, alamat detail dengan Google Maps integration, daftar fasilitas yang tersedia, kapasitas maksimal, harga sewa per jam/hari/bulan, jam operasional, dan aturan-aturan khusus. Jangan lupa ada bagian review dan rating dari customer sebelumnya, ini penting banget buat build trust.
Yang bikin keren lagi, kita bakal tambahin fitur "Virtual Tour" kalau ada, availability calendar yang real-time, dan rekomendasi kantor serupa. Customer juga bisa save kantor ke wishlist buat di-compare nanti.
Sistem Booking/Reservasi Online
Ini adalah jantung dari website kita! Sistem booking harus smooth dan intuitif. Customer pilih tanggal dan jam, sistem langsung cek availability real-time. Kalau available, customer bisa langsung book dengan flow yang simple.
Booking form bakal include pilihan durasi sewa (per jam, harian, mingguan, bulanan), jumlah peserta, add-on services kalau ada (catering, equipment rental, dll), dan informasi kontak. Ada juga opsi booking recurring buat customer yang butuh kantor rutin.
Yang penting, ada sistem konfirmasi yang jelas. Customer dapet booking ID, email konfirmasi, dan bisa track status booking mereka. Plus ada opsi reschedule atau cancel dengan policy yang jelas.
Form Checkout dengan Upload Bukti Pembayaran
Proses checkout harus se-simple mungkin tapi tetap aman. Customer review booking details, pilih metode pembayaran (transfer bank, e-wallet, atau credit card kalau udah integrate payment gateway), dan konfirmasi pesanan.
Khusus buat transfer bank, customer bisa upload bukti pembayaran langsung di website. Sistemnya auto-detect format file yang supported (JPG, PNG, PDF) dan ada preview sebelum upload. Setelah upload, admin bisa langsung verify pembayaran dari dashboard.
Ada juga invoice generator yang auto-create PDF invoice yang professional. Customer bisa download invoice buat keperluan reimbursement kantor mereka.
Profil User dan Riwayat Booking
Customer punya dashboard pribadi yang bersih dan informatif. Di sini mereka bisa edit profil, ganti password, dan yang paling penting: liat riwayat booking mereka.
Riwayat booking nunjukin semua transaksi, mulai dari yang upcoming, ongoing, completed, sampai yang cancelled. Ada detail lengkap tiap booking, status pembayaran, dan opsi buat kasih review setelah pake kantor.
Customer juga bisa re-book kantor yang pernah mereka pake sebelumnya dengan sekali klik. Plus ada fitur favorite list buat save kantor-kantor yang mereka suka.
Fitur untuk Admin/Operator: Control Center yang Powerful
Dashboard Admin dengan Statistik
Dashboard admin itu mission control center-nya website kita. Harus informatif dan actionable. Admin langsung bisa liat overview bisnis: total revenue hari ini/bulan ini, jumlah booking active, occupancy rate, dan trending kantor.
Ada charts yang interactive buat nunjukin tren booking, revenue growth, customer demographics, dan performance tiap kantor. Semua data real-time dan bisa di-export ke Excel buat reporting ke management.
Dashboard juga nunjukin quick actions yang sering dibutuhin: approve pending bookings, verify payments, respond ke customer inquiries, dan alerts buat hal-hal urgent.
Manajemen Data Kantor (CRUD)
Admin bisa manage semua data kantor dengan interface yang user-friendly. Add kantor baru dengan form yang comprehensive: info basic, upload multiple photos, set pricing rules, define availability schedule, dan configure facilities.
Edit kantor juga gampang dengan preview changes sebelum save. Admin bisa set kantor sebagai featured, temporarily unavailable, atau under maintenance. Ada bulk actions buat update harga atau availability multiple kantor sekaligus.
Yang keren, ada integration dengan Google Maps API buat auto-complete address dan show exact location. Plus image optimization yang auto-resize dan compress foto biar website tetep cepet.
Manajemen Booking dan Pembayaran
Ini salah satu fitur yang paling critical. Admin punya full control atas semua booking: approve/reject booking requests, verify payment proof, confirm booking completion, dan handle disputes kalau ada.
Ada booking calendar view yang nunjukin schedule semua kantor dalam satu tampilan. Admin bisa drag-drop buat reschedule booking, set blocking dates, dan manage overbooking situations.
Payment management juga comprehensive: track pembayaran yang pending, verify transfer receipts, generate payment reminders, dan reconcile dengan bank statements. Semua transaksi ter-record dengan audit trail yang lengkap.
Manajemen User dan Role
Dengan Spatie Permission, admin bisa manage user dengan granular control. Ada different roles: Super Admin (full access), Admin (manage bookings dan kantor), Operator (view-only), dan Customer (frontend access).
Admin bisa create/edit user accounts, assign roles, suspend accounts yang bermasalah, dan view user activity logs. Ada juga user analytics: most active customers, customer lifetime value, dan behavior patterns.
System juga support team management buat bisnis yang punya multiple admin. Ada approval workflow buat actions yang sensitive dan activity logging buat accountability.
Laporan Pendapatan dan Okupansi
Business intelligence adalah kunci sukses bisnis modern. Admin bisa generate berbagai jenis laporan: daily/monthly/yearly revenue reports, occupancy rates per kantor, customer acquisition trends, dan profitability analysis.
Laporan bisa di-customize berdasarkan date range, specific kantor, customer segments, atau booking types. Semua laporan exportable ke PDF atau Excel dengan formatting yang professional.
Ada juga predictive analytics yang simple: forecasting revenue berdasarkan trend historis, identify peak seasons, dan recommend pricing strategies. Dashboard nunjukin KPIs yang penting buat decision making.
Database dan Tabel yang Dibutuhkan: Fondasi Kuat untuk Aplikasi Keren
Oke teman-teman, sekarang kita masuk ke bagian yang super penting tapi sering diabaikan sama developer pemula: database design! Ini adalah fondasi dari seluruh aplikasi kita. Kalau database design-nya berantakan, aplikasi bakal jadi lambat, susah di-maintain, dan prone to bugs.
Gue bakal jelasin struktur database yang optimal buat website sewa kantor kita. Setiap tabel udah dipikirin dengan matang biar scalable dan efficient. Mari kita breakdown satu per satu!
Struktur Database yang Akan Kita Gunakan
Database kita bakal punya 7 tabel utama yang saling terhubung dengan relasi yang logis. Setiap tabel punya purpose yang jelas dan field-field yang optimized buat kebutuhan bisnis kita.
Tabel Users: The Heart of User Management
Tabel users ini adalah pusat dari sistem authentication dan authorization kita. Di sini kita store semua data user, baik customer, admin, maupun operator.
Field-field yang bakal ada:
id(bigint, primary key, auto increment): Unique identifier buat setiap username(varchar 255): Nama lengkap user, wajib diisiemail(varchar 255, unique): Email address yang juga dipake buat loginemail_verified_at(timestamp, nullable): Kapan email ter-verify, penting buat securitypassword(varchar 255): Hashed password pake bcrypt Laravelphone(varchar 20, nullable): Nomor telepon buat keperluan booking confirmationavatar(varchar 255, nullable): Path ke foto profile useris_active(boolean, default true): Status aktif/non-aktif userlast_login_at(timestamp, nullable): Track kapan terakhir kali loginremember_token(varchar 100, nullable): Token buat "remember me" functionalitycreated_atdanupdated_at(timestamp): Laravel timestamps
Yang bikin special, tabel ini bakal integrate dengan Spatie Permission buat handle roles dan permissions. Jadi satu user bisa punya multiple roles atau custom permissions.
Tabel Roles dan Permissions: Spatie Power!
Ini adalah tabel-tabel yang auto-generated sama Spatie Laravel Permission package. Kita ga perlu bikin manual, tapi penting buat dipahami strukturnya.
Tabel roles:
id(bigint, primary key)name(varchar 255): Nama role kayak 'admin', 'customer', 'operator'guard_name(varchar 255): Guard yang dipake, biasanya 'web'created_atdanupdated_at
Tabel permissions:
id(bigint, primary key)name(varchar 255): Nama permission kayak 'view_bookings', 'edit_offices'guard_name(varchar 255)created_atdanupdated_at
Ada juga pivot tables: model_has_roles, model_has_permissions, dan role_has_permissions yang ngatur relasi many-to-many antara users, roles, dan permissions.
Tabel Offices: Tempat Menyimpan Data Kantor
Ini adalah tabel yang store semua informasi kantor yang available buat disewa. Struktur tabelnya dirancang buat support semua fitur yang udah kita bahas sebelumnya.
Field-field lengkapnya:
id(bigint, primary key): Unique identifier kantorname(varchar 255): Nama kantor, harus catchy dan descriptiveslug(varchar 255, unique): URL-friendly version dari nama, buat SEOdescription(text): Deskripsi lengkap kantor, bisa pake HTML formattingaddress(text): Alamat lengkap kantorcity(varchar 100): Kota lokasi kantor, buat filteringstate(varchar 100): Provinsi lokasi kantorpostal_code(varchar 10): Kode pos buat keperluan mappinglatitudedanlongitude(decimal 10,8): Koordinat GPS buat Google Mapscapacity(integer): Maksimal jumlah orang yang bisa accommodatearea_size(decimal 8,2): Luas area dalam meter persegihourly_price(decimal 10,2): Harga sewa per jamdaily_price(decimal 10,2): Harga sewa per harimonthly_price(decimal 10,2): Harga sewa per bulanis_featured(boolean, default false): Apakah kantor featured di homepageis_available(boolean, default true): Status availability kantorimages(json): Array of image paths, flexible buat multiple photosopening_hours(json): Jam operasional per hari, stored as JSON objectcontact_person(varchar 255): PIC kantor buat keperluan koordinasicontact_phone(varchar 20): Nomor telepon PICrating(decimal 3,2, default 0): Average rating dari customer reviewstotal_reviews(integer, default 0): Total jumlah reviewscreated_atdanupdated_at
JSON fields dipake buat data yang struktur kompleks kayak images array dan opening hours, lebih flexible daripada bikin tabel terpisah.
Tabel Office_Facilities: Fasilitas yang Tersedia
Biar customer bisa filter berdasarkan fasilitas, kita perlu tabel terpisah buat manage facilities. Ini many-to-many relationship dengan tabel offices.
Field-field yang ada:
id(bigint, primary key)name(varchar 100): Nama fasilitas kayak 'WiFi', 'AC', 'Proyektor'icon(varchar 100): Icon class buat display di frontenddescription(text, nullable): Deskripsi detail fasilitasis_active(boolean, default true): Status aktif fasilitascreated_atdanupdated_at
Terus ada pivot table office_facility yang connect offices dengan facilities:
office_id(foreign key ke offices table)facility_id(foreign key ke office_facilities table)is_available(boolean): Apakah fasilitas currently availableadditional_info(text, nullable): Info tambahan spesifik buat office-facility combo
Tabel Bookings: Jantung Sistem Reservasi
Ini adalah tabel paling critical di aplikasi kita. Semua transaksi booking tersimpan di sini dengan detail yang lengkap.
Structure yang comprehensive:
id(bigint, primary key)booking_code(varchar 20, unique): Kode booking yang user-friendlyuser_id(foreign key): Siapa yang bookingoffice_id(foreign key): Kantor mana yang di-bookingstart_date(date): Tanggal mulai sewaend_date(date): Tanggal selesai sewastart_time(time, nullable): Jam mulai buat hourly bookingend_time(time, nullable): Jam selesai buat hourly bookingduration_type(enum): 'hourly', 'daily', 'monthly'total_days(integer): Total hari sewa, calculated fieldparticipant_count(integer): Jumlah peserta yang bakal pake kantorpurpose(varchar 255): Tujuan booking kayak 'meeting', 'workshop', 'training'special_requests(text, nullable): Request khusus dari customersubtotal(decimal 10,2): Harga sebelum tax dan feestax_amount(decimal 10,2): Jumlah pajaktotal_amount(decimal 10,2): Total yang harus dibayarstatus(enum): 'pending', 'confirmed', 'completed', 'cancelled'notes(text, nullable): Catatan internal dari admincancelled_at(timestamp, nullable): Kapan booking di-cancelcancellation_reason(text, nullable): Alasan cancellationcreated_atdanupdated_at
Status enum yang jelas bikin flow booking gampang di-track dan di-manage.
Tabel Payments: Track Semua Transaksi
Setiap booking punya record payment yang terpisah. Ini penting buat auditing dan financial reporting.
Field-field detailnya:
id(bigint, primary key)booking_id(foreign key): Link ke booking terkaitpayment_method(enum): 'bank_transfer', 'credit_card', 'e_wallet'amount(decimal 10,2): Jumlah yang dibayarpayment_proof(varchar 255, nullable): Path ke file bukti transferpayment_date(timestamp, nullable): Kapan payment dilakukanverified_at(timestamp, nullable): Kapan admin verify paymentverified_by(foreign key ke users, nullable): Admin yang verifystatus(enum): 'pending', 'verified', 'failed', 'refunded'reference_number(varchar 100, nullable): Reference number dari bank/payment gatewaynotes(text, nullable): Catatan dari admin atau customercreated_atdanupdated_at
Dengan tabel terpisah, kita bisa track multiple payments buat satu booking (misalnya partial payment atau refund).
Tabel Reviews: Customer Feedback System
Customer satisfaction adalah key, jadi kita perlu sistem review yang robust buat collect feedback dan build trust.
Structure yang user-friendly:
id(bigint, primary key)booking_id(foreign key): Review terkait booking manauser_id(foreign key): Siapa yang kasih reviewoffice_id(foreign key): Kantor yang di-reviewrating(integer): Rating 1-5 starstitle(varchar 255, nullable): Judul reviewcomment(text): Isi review dari customerpros(text, nullable): Hal-hal positif yang disukaicons(text, nullable): Hal-hal yang perlu diperbaikiis_anonymous(boolean, default false): Apakah review anonymousis_approved(boolean, default true): Status approval dari adminhelpful_count(integer, default 0): Berapa orang yang nganggap review helpfulcreated_atdanupdated_at
Review system yang detail kayak gini bikin customer lain lebih confident buat booking.
Relasi Antar Tabel dan Foreign Keys
Database design yang baik itu harus punya relasi yang logical dan efficient. Mari kita breakdown relasi-relasi penting:
User Relationships:
- User
hasManyBookings (satu user bisa punya banyak booking) - User
hasManyReviews (satu user bisa kasih banyak review) - User
hasManyPayments melalui Bookings (user punya payment records)
Office Relationships:
- Office
hasManyBookings (satu kantor bisa di-booking berkali-kali) - Office
hasManyReviews (satu kantor bisa punya banyak review) - Office
belongsToManyFacilities (many-to-many relationship)
Booking Relationships:
- Booking
belongsToUser (setiap booking punya owner) - Booking
belongsToOffice (setiap booking terkait satu kantor) - Booking
hasOnePayment (setiap booking punya payment record) - Booking
hasOneReview (booking bisa di-review customer)
Complex Relationships:
- Payment
belongsToBooking danbelongsToUser (melalui booking) - Review
belongsToUser,belongsToOffice, danbelongsToBooking
Field-Field Penting pada Setiap Tabel
Setiap field yang kita design punya purpose yang jelas buat support business logic aplikasi kita.
Indexing Strategy:
- Primary keys udah auto-indexed
- Foreign keys perlu di-index buat query performance
- Fields yang sering di-filter kayak
city,status,is_availableperlu index - Composite index buat query kompleks kayak filtering by city + price range
Data Types yang Optimal:
- Decimal buat currency supaya precise calculation
- Enum buat status fields supaya data consistent
- JSON buat flexible data kayak images array dan opening hours
- Timestamp buat audit trail dan reporting
Validation Rules:
- Email harus unique dan valid format
- Phone numbers pake international format
- Coordinates harus valid lat/lng range
- Prices ga boleh negative
- Dates harus logical (end_date >= start_date)
Security Considerations:
- Password selalu di-hash pake bcrypt
- Sensitive data kayak payment info encrypted
- Audit trail buat track who changed what when
- Soft deletes buat important records kayak bookings
Dengan database structure yang solid kayak gini, aplikasi kita bakal performant, scalable, dan mudah di-maintain. Setiap query bakal efficient, relationship queries bakal cepet, dan kita bisa generate reports dengan mudah.
Next, kita bakal mulai implement semua design ini dengan install Laravel dan setup project dari scratch!
Instalasi Laravel dan Setup Proyek: Mari Mulai Coding!
Oke guys, sekarang saatnya kita mulai hands-on! Di bagian ini kita bakal setup environment development dari nol sampai siap coding. Gue bakal guide kalian step by step, jadi pastikan ikutin setiap langkahnya dengan teliti ya.
Sebelum mulai, pastikan di komputer kalian udah ada PHP 8.2 atau yang lebih baru, Composer, dan database MySQL/PostgreSQL. Kalau belum ada, install dulu ya!
Install Laravel 12 Terbaru Menggunakan Composer
Laravel 12 adalah versi terbaru yang packed dengan fitur-fitur keren dan performance improvements. Kita bakal install via Composer yang merupakan dependency manager buat PHP.
Buka terminal atau command prompt, terus jalankan command ini:
composer create-project laravel/laravel office-rental-app
Command ini bakal download Laravel versi terbaru dan setup project baru dengan nama "office-rental-app". Proses download biasanya butuh beberapa menit tergantung koneksi internet kalian.
Setelah selesai, masuk ke folder project:
cd office-rental-app
Coba jalankan Laravel development server buat mastiin semuanya berjalan dengan baik:
php artisan serve
Buka browser dan akses http://localhost:8000. Kalau muncul welcome page Laravel yang kece, berarti instalasi sukses!
Pro tip: Kalau kalian developer yang sering bikin project Laravel, install Laravel Installer globally supaya lebih cepet:
composer global require laravel/installer
laravel new office-rental-app
Membuat Proyek Baru Laravel dengan Command Line
Setelah project ter-create, ada beberapa hal yang perlu kita setup supaya project ready buat development yang serious.
Pertama, generate application key yang unique buat project kita:
php artisan key:generate
Key ini penting banget buat encryption dan session security. Laravel auto-generate random key yang secure.
Terus kita setup storage link buat handle file uploads nanti:
php artisan storage:link
Command ini bikin symbolic link dari public/storage ke storage/app/public, jadi file yang di-upload customer bisa diakses dari web.
Install Filament 3 Package ke Dalam Proyek
Sekarang kita install Filament 3, admin panel yang bakal bikin hidup kita jadi gampang banget! Filament 3 adalah upgrade major dari versi sebelumnya dengan UI yang lebih modern dan performa yang lebih kenceng.
Install Filament via Composer:
composer require filament/filament:"^3.0"
Setelah package ter-install, jalankan command install buat setup Filament:
php artisan filament:install --panels
Command ini bakal:
- Publish Filament config files
- Setup service providers yang dibutuhin
- Create admin panel structure
- Install dependencies yang diperluin
Filament juga butuh beberapa packages tambahan buat functionality yang optimal:
composer require filament/spatie-laravel-media-library-plugin
composer require filament/spatie-laravel-settings-plugin
Packages ini ngebantu handle media uploads dan settings management di admin panel.
Install Spatie Laravel Permission Package
Spatie Laravel Permission adalah package legend buat handle authentication dan authorization. Package ini udah mature banget dan dipake sama ribuan project Laravel.
Install package-nya:
composer require spatie/laravel-permission
Publish migration files buat role dan permission tables:
php artisan vendor:publish --provider="Spatie\\Permission\\PermissionServiceProvider"
Command ini bakal create migration files buat tables: roles, permissions, model_has_permissions, model_has_roles, dan role_has_permissions.
Kita juga perlu clear cache setelah install:
php artisan optimize:clear
Mengatur File .env untuk Konfigurasi Database
File .env adalah tempat kita store environment variables, termasuk database configuration. Ini adalah file yang sensitive jadi jangan pernah di-commit ke Git ya!
Buka file .env di root project dan update database configuration:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=office_rental_db
DB_USERNAME=root
DB_PASSWORD=your_password_here
Ganti your_password_here dengan password MySQL kalian. Kalau pake XAMPP atau MAMP, biasanya password-nya kosong.
Buat database baru dengan nama office_rental_db:
Kalau pake MySQL command line:
CREATE DATABASE office_rental_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Atau kalau pake phpMyAdmin, tinggal create database baru dengan nama yang sama.
Buat yang pake PostgreSQL, config-nya sedikit beda:
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=office_rental_db
DB_USERNAME=postgres
DB_PASSWORD=your_postgres_password
Setup juga mail configuration buat email notifications:
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
[email protected]
MAIL_PASSWORD=your_app_password
MAIL_ENCRYPTION=tls
[email protected]
MAIL_FROM_NAME="${APP_NAME}"
Dan jangan lupa setup app configuration:
APP_NAME="Office Rental"
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_TIMEZONE=Asia/Jakarta
Menjalankan Migration Awal dan Testing Koneksi Database
Sekarang kita test koneksi database dan jalankan migration default Laravel plus Spatie permissions.
Test koneksi database dulu:
php artisan migrate:status
Kalau muncul list migrations tanpa error, berarti koneksi database udah oke.
Jalankan migration buat create tables default Laravel:
php artisan migrate
Command ini bakal create tables: users, password_reset_tokens, failed_jobs, dan tables buat Spatie permissions.
Kalau ada error "Access denied" atau "Connection refused", double-check database credentials di file .env dan pastikan MySQL/PostgreSQL service udah running.
Create user admin pertama buat akses Filament:
php artisan make:filament-user
Command ini bakal prompt kalian buat input:
- Name: Admin User
- Email: [email protected]
- Password: (pilih password yang strong)
User ini bakal jadi super admin yang bisa akses semua fitur di dashboard.
Test akses ke Filament admin panel:
php artisan serve
Buka browser dan akses http://localhost:8000/admin. Login pake credentials yang tadi kalian buat.
Kalau berhasil masuk ke dashboard Filament yang kece, congratulations! Setup dasar udah complete.
Optimasi dan Konfigurasi Tambahan
Supaya development experience lebih smooth, ada beberapa konfigurasi tambahan yang recommended:
Install Laravel Debugbar buat debugging:
composer require barryvdh/laravel-debugbar --dev
Install Laravel IDE Helper buat better code completion:
composer require barryvdh/laravel-ide-helper --dev
php artisan ide-helper:generate
Setup queue worker buat background jobs:
QUEUE_CONNECTION=database
Terus jalankan migration buat jobs table:
php artisan queue:table
php artisan migrate
Update cache configuration buat better performance:
CACHE_DRIVER=file
SESSION_DRIVER=file
Kalau mau lebih advanced, bisa pake Redis:
CACHE_DRIVER=redis
SESSION_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
Verifikasi Setup dan Troubleshooting
Sebelum lanjut ke coding, mari kita verify bahwa semua udah setup dengan benar:
Check Laravel version:
php artisan --version
List semua artisan commands yang available:
php artisan list
Check installed packages:
composer show
Test database connection dengan Tinker:
php artisan tinker
>>> DB::connection()->getPdo();
>>> User::count();
Kalau semua command di atas jalan tanpa error, berarti setup udah perfect!
Common Issues dan Solutions:
- "Class not found" error: Jalankan
composer dump-autoload - "No application encryption key": Jalankan
php artisan key:generate - Database connection error: Check credentials di
.envdan service database - Permission denied di storage: Jalankan
chmod -R 775 storage bootstrap/cache - Filament 404 error: Pastiin route cache clear dengan
php artisan route:clear
Dengan setup yang solid kayak gini, kita udah siap buat mulai bikin aplikasi yang awesome! Next, kita bakal mulai create migrations, models, dan controllers buat implement database design yang udah kita rancang sebelumnya.
Pembuatan Migration, Model, dan Controller: Foundation Aplikasi Kita
Sekarang kita masuk ke tahap yang exciting banget! Kita bakal create migration files, models, dan controllers yang jadi backbone aplikasi kita. Ini adalah implementasi dari database design yang udah kita rancang di bagian sebelumnya.
Gue bakal guide kalian step by step buat bikin setiap component dengan struktur yang clean dan best practices Laravel. Let's get our hands dirty with some real coding!
Membuat File Migration untuk Semua Tabel yang Dibutuhkan
Migration adalah version control buat database kita. Dengan migration, tim developer bisa sync database schema dengan mudah dan track perubahan struktur database.
Mari kita mulai bikin migration files satu per satu. Ingat, urutan pembuatan migration penting karena ada foreign key dependencies.
Pertama, kita create migration buat tabel office_facilities:
php artisan make:migration create_office_facilities_table
Terus buat migration buat tabel offices:
php artisan make:migration create_offices_table
Lanjut dengan tabel bookings:
php artisan make:migration create_bookings_table
Migration buat tabel payments:
php artisan make:migration create_payments_table
Migration buat tabel reviews:
php artisan make:migration create_reviews_table
Dan yang terakhir, pivot table buat many-to-many relationship:
php artisan make:migration create_office_facility_pivot_table
Kita juga perlu modify tabel users yang udah ada buat nambah field tambahan:
php artisan make:migration add_additional_fields_to_users_table
Struktur dan Field pada Setiap Migration File
Sekarang kita implementasikan struktur database yang udah kita design. Setiap migration file bakal berisi schema definition yang detail dan optimal.
Migration: office_facilities_table
Buka file database/migrations/xxxx_create_office_facilities_table.php:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('office_facilities', function (Blueprint $table) {
$table->id();
$table->string('name', 100);
$table->string('icon', 100)->nullable();
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
// Indexes for better performance
$table->index('is_active');
$table->index('name');
});
}
public function down(): void
{
Schema::dropIfExists('office_facilities');
}
};
Migration: offices_table
File database/migrations/xxxx_create_offices_table.php:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('offices', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description');
$table->text('address');
$table->string('city', 100);
$table->string('state', 100);
$table->string('postal_code', 10);
$table->decimal('latitude', 10, 8)->nullable();
$table->decimal('longitude', 11, 8)->nullable();
$table->integer('capacity');
$table->decimal('area_size', 8, 2)->nullable();
$table->decimal('hourly_price', 10, 2);
$table->decimal('daily_price', 10, 2);
$table->decimal('monthly_price', 10, 2);
$table->boolean('is_featured')->default(false);
$table->boolean('is_available')->default(true);
$table->json('images')->nullable();
$table->json('opening_hours')->nullable();
$table->string('contact_person')->nullable();
$table->string('contact_phone', 20)->nullable();
$table->decimal('rating', 3, 2)->default(0);
$table->integer('total_reviews')->default(0);
$table->timestamps();
// Indexes for filtering and searching
$table->index(['city', 'is_available']);
$table->index(['is_featured', 'is_available']);
$table->index('rating');
$table->fullText(['name', 'description', 'address']);
});
}
public function down(): void
{
Schema::dropIfExists('offices');
}
};
Migration: bookings_table
File database/migrations/xxxx_create_bookings_table.php:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('bookings', function (Blueprint $table) {
$table->id();
$table->string('booking_code', 20)->unique();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('office_id')->constrained()->onDelete('cascade');
$table->date('start_date');
$table->date('end_date');
$table->time('start_time')->nullable();
$table->time('end_time')->nullable();
$table->enum('duration_type', ['hourly', 'daily', 'monthly']);
$table->integer('total_days');
$table->integer('participant_count');
$table->string('purpose')->nullable();
$table->text('special_requests')->nullable();
$table->decimal('subtotal', 10, 2);
$table->decimal('tax_amount', 10, 2)->default(0);
$table->decimal('total_amount', 10, 2);
$table->enum('status', ['pending', 'confirmed', 'completed', 'cancelled'])->default('pending');
$table->text('notes')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->text('cancellation_reason')->nullable();
$table->timestamps();
// Indexes for performance
$table->index(['user_id', 'status']);
$table->index(['office_id', 'start_date', 'end_date']);
$table->index('status');
$table->index('booking_code');
});
}
public function down(): void
{
Schema::dropIfExists('bookings');
}
};
Migration: payments_table
File database/migrations/xxxx_create_payments_table.php:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->foreignId('booking_id')->constrained()->onDelete('cascade');
$table->enum('payment_method', ['bank_transfer', 'credit_card', 'e_wallet']);
$table->decimal('amount', 10, 2);
$table->string('payment_proof')->nullable();
$table->timestamp('payment_date')->nullable();
$table->timestamp('verified_at')->nullable();
$table->foreignId('verified_by')->nullable()->constrained('users')->onDelete('set null');
$table->enum('status', ['pending', 'verified', 'failed', 'refunded'])->default('pending');
$table->string('reference_number', 100)->nullable();
$table->text('notes')->nullable();
$table->timestamps();
// Indexes
$table->index(['booking_id', 'status']);
$table->index('status');
$table->index('payment_date');
});
}
public function down(): void
{
Schema::dropIfExists('payments');
}
};
Migration: reviews_table
File database/migrations/xxxx_create_reviews_table.php:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('reviews', function (Blueprint $table) {
$table->id();
$table->foreignId('booking_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('office_id')->constrained()->onDelete('cascade');
$table->integer('rating')->unsigned();
$table->string('title')->nullable();
$table->text('comment');
$table->text('pros')->nullable();
$table->text('cons')->nullable();
$table->boolean('is_anonymous')->default(false);
$table->boolean('is_approved')->default(true);
$table->integer('helpful_count')->default(0);
$table->timestamps();
// Constraints
$table->check('rating >= 1 AND rating <= 5');
// Indexes
$table->index(['office_id', 'is_approved']);
$table->index(['user_id', 'created_at']);
$table->index('rating');
});
}
public function down(): void
{
Schema::dropIfExists('reviews');
}
};
Migration: office_facility_pivot_table
File database/migrations/xxxx_create_office_facility_pivot_table.php:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('office_facility', function (Blueprint $table) {
$table->id();
$table->foreignId('office_id')->constrained()->onDelete('cascade');
$table->foreignId('office_facility_id')->constrained()->onDelete('cascade');
$table->boolean('is_available')->default(true);
$table->text('additional_info')->nullable();
$table->timestamps();
// Unique constraint to prevent duplicates
$table->unique(['office_id', 'office_facility_id']);
// Indexes
$table->index(['office_id', 'is_available']);
});
}
public function down(): void
{
Schema::dropIfExists('office_facility');
}
};
Migration: add_additional_fields_to_users_table
File database/migrations/xxxx_add_additional_fields_to_users_table.php:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('phone', 20)->nullable()->after('email');
$table->string('avatar')->nullable()->after('phone');
$table->boolean('is_active')->default(true)->after('avatar');
$table->timestamp('last_login_at')->nullable()->after('is_active');
// Index for phone lookup
$table->index('phone');
$table->index('is_active');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['phone', 'avatar', 'is_active', 'last_login_at']);
});
}
};
Membuat Model Eloquent untuk Setiap Tabel
Model adalah representation dari tabel database dalam bentuk class PHP. Laravel pake Eloquent ORM yang sangat powerful buat interact dengan database.
Mari kita create model buat setiap tabel:
php artisan make:model OfficeFacility
php artisan make:model Office
php artisan make:model Booking
php artisan make:model Payment
php artisan make:model Review
Model User udah ada by default, jadi kita tinggal modify aja sesuai kebutuhan.
Membuat Controller untuk Logic Bisnis Aplikasi
Controller adalah tempat kita taruh business logic aplikasi. Kita bakal bikin controller buat handle requests dari frontend.
Create controllers buat setiap entity:
php artisan make:controller Api/OfficeController
php artisan make:controller Api/BookingController
php artisan make:controller Api/PaymentController
php artisan make:controller Api/ReviewController
php artisan make:controller Web/HomeController
php artisan make:controller Web/OfficeController
php artisan make:controller Web/BookingController
Kita bagi controller jadi dua kategori:
- Api controllers: Buat handle API requests (JSON responses)
- Web controllers: Buat handle web requests (Blade views)
Struktur ini bikin aplikasi kita lebih organized dan scalable.
Menjalankan Migration dan Seeding Data Dummy
Sekarang kita jalankan semua migration yang udah kita buat:
php artisan migrate
Kalau ada error, biasanya karena foreign key constraints atau typo di migration files. Check kembali struktur migration dan pastikan urutan pembuatan sudah benar.
Buat seeding data dummy supaya kita punya data buat testing:
php artisan make:seeder OfficeFacilitySeeder
php artisan make:seeder OfficeSeeder
php artisan make:seeder UserSeeder
Contoh OfficeFacilitySeeder:
File database/seeders/OfficeFacilitySeeder.php:
<?php
namespace Database\\Seeders;
use App\\Models\\OfficeFacility;
use Illuminate\\Database\\Seeder;
class OfficeFacilitySeeder extends Seeder
{
public function run(): void
{
$facilities = [
['name' => 'WiFi', 'icon' => 'wifi', 'description' => 'High-speed internet connection'],
['name' => 'AC', 'icon' => 'snowflake', 'description' => 'Air conditioning'],
['name' => 'Projector', 'icon' => 'tv', 'description' => 'HD projector for presentations'],
['name' => 'Whiteboard', 'icon' => 'edit', 'description' => 'Large whiteboard'],
['name' => 'Sound System', 'icon' => 'volume-2', 'description' => 'Professional sound system'],
['name' => 'Parking', 'icon' => 'car', 'description' => 'Free parking space'],
['name' => 'Coffee/Tea', 'icon' => 'coffee', 'description' => 'Complimentary beverages'],
['name' => 'Kitchen', 'icon' => 'utensils', 'description' => 'Pantry area'],
];
foreach ($facilities as $facility) {
OfficeFacility::create($facility);
}
}
}
Update DatabaseSeeder buat jalanin semua seeders:
<?php
namespace Database\\Seeders;
use Illuminate\\Database\\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
OfficeFacilitySeeder::class,
UserSeeder::class,
OfficeSeeder::class,
]);
}
}
Jalankan seeder:
php artisan db:seed
Verification dan Testing
Setelah migration dan seeding selesai, mari kita verify bahwa semuanya berjalan dengan benar:
Check tabel yang ter-create:
php artisan tinker
>>> Schema::hasTable('offices');
>>> Schema::hasTable('bookings');
>>> Schema::hasTable('payments');
Check data yang ter-seed:
>>> App\\Models\\OfficeFacility::count();
>>> App\\Models\\User::count();
Test relationship (ini bakal kita implement di bagian selanjutnya):
>>> $user = App\\Models\\User::first();
>>> $user->bookings; // harusnya return empty collection
Kalau semua command di atas jalan tanpa error, berarti migration dan model setup udah perfect!
Pro Tips:
- Selalu backup database sebelum jalanin migration di production
- Gunakan
php artisan migrate:rollbackkalau ada error - Test migration di environment development dulu sebelum deploy
- Gunakan
php artisan migrate:fresh --seedbuat reset dan re-seed database - Monitor query performance dengan Laravel Debugbar
Dengan foundation yang solid kayak gini, kita udah siap buat implement model relationships dan business logic yang complex. Next step, kita bakal configure models dengan fillable properties, relationships, dan Eloquent features yang advanced!
Konfigurasi Model dan ORM: Bikin Model yang Powerful
Sekarang kita masuk ke bagian yang super penting: configure models supaya bisa handle business logic dengan optimal! Di sini kita bakal setup fillable properties, relationships, accessors, mutators, dan fitur-fitur Eloquent yang advanced.
Model yang well-configured itu adalah kunci dari aplikasi Laravel yang performant dan maintainable. Let's dive deep into each model!
Mengatur Fillable Properties pada Setiap Model
Fillable properties menentukan field mana aja yang boleh di-mass assign. Ini penting banget buat security, mencegah mass assignment vulnerabilities.
Model User (app/Models/User.php)
Update model User yang udah ada:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
use Spatie\\Permission\\Traits\\HasRoles;
use Illuminate\\Database\\Eloquent\\Casts\\Attribute;
use Carbon\\Carbon;
class User extends Authenticatable
{
use HasFactory, Notifiable, HasRoles;
protected $fillable = [
'name',
'email',
'password',
'phone',
'avatar',
'is_active',
'last_login_at',
];
protected $hidden = [
'password',
'remember_token',
];
protected $casts = [
'email_verified_at' => 'datetime',
'last_login_at' => 'datetime',
'is_active' => 'boolean',
'password' => 'hashed',
];
// Accessor untuk format nama yang proper
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucwords(strtolower($value)),
set: fn (string $value) => strtolower($value),
);
}
// Accessor untuk avatar URL
protected function avatarUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->avatar
? asset('storage/' . $this->avatar)
: '<https://ui-avatars.com/api/?name=>' . urlencode($this->name) . '&color=7F9CF5&background=EBF4FF'
);
}
// Scope untuk active users
public function scopeActive($query)
{
return $query->where('is_active', true);
}
// Update last login timestamp
public function updateLastLogin()
{
$this->update(['last_login_at' => now()]);
}
}
Model OfficeFacility (app/Models/OfficeFacility.php)
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;
class OfficeFacility extends Model
{
use HasFactory;
protected $fillable = [
'name',
'icon',
'description',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
// Scope untuk facilities yang active
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
// Scope untuk search berdasarkan nama
public function scopeSearch(Builder $query, string $term): Builder
{
return $query->where('name', 'like', "%{$term}%")
->orWhere('description', 'like', "%{$term}%");
}
}
Model Office (app/Models/Office.php)
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Casts\\Attribute;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Support\\Str;
class Office extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description',
'address',
'city',
'state',
'postal_code',
'latitude',
'longitude',
'capacity',
'area_size',
'hourly_price',
'daily_price',
'monthly_price',
'is_featured',
'is_available',
'images',
'opening_hours',
'contact_person',
'contact_phone',
'rating',
'total_reviews',
];
protected $casts = [
'is_featured' => 'boolean',
'is_available' => 'boolean',
'images' => 'array',
'opening_hours' => 'array',
'latitude' => 'decimal:8',
'longitude' => 'decimal:8',
'hourly_price' => 'decimal:2',
'daily_price' => 'decimal:2',
'monthly_price' => 'decimal:2',
'rating' => 'decimal:2',
'area_size' => 'decimal:2',
];
// Auto-generate slug ketika create/update
protected static function boot()
{
parent::boot();
static::creating(function ($office) {
if (empty($office->slug)) {
$office->slug = Str::slug($office->name);
}
});
static::updating(function ($office) {
if ($office->isDirty('name')) {
$office->slug = Str::slug($office->name);
}
});
}
// Accessor untuk image URLs
protected function imageUrls(): Attribute
{
return Attribute::make(
get: function () {
if (!$this->images) return [];
return collect($this->images)->map(function ($image) {
return asset('storage/' . $image);
})->toArray();
}
);
}
// Accessor untuk main image
protected function mainImage(): Attribute
{
return Attribute::make(
get: function () {
$images = $this->image_urls;
return !empty($images) ? $images[0] : asset('images/office-placeholder.jpg');
}
);
}
// Accessor untuk formatted price
protected function formattedHourlyPrice(): Attribute
{
return Attribute::make(
get: fn () => 'Rp ' . number_format($this->hourly_price, 0, ',', '.')
);
}
protected function formattedDailyPrice(): Attribute
{
return Attribute::make(
get: fn () => 'Rp ' . number_format($this->daily_price, 0, ',', '.')
);
}
protected function formattedMonthlyPrice(): Attribute
{
return Attribute::make(
get: fn () => 'Rp ' . number_format($this->monthly_price, 0, ',', '.')
);
}
// Scopes untuk filtering
public function scopeAvailable(Builder $query): Builder
{
return $query->where('is_available', true);
}
public function scopeFeatured(Builder $query): Builder
{
return $query->where('is_featured', true);
}
public function scopeInCity(Builder $query, string $city): Builder
{
return $query->where('city', 'like', "%{$city}%");
}
public function scopeWithCapacity(Builder $query, int $minCapacity): Builder
{
return $query->where('capacity', '>=', $minCapacity);
}
public function scopePriceRange(Builder $query, float $minPrice, float $maxPrice, string $type = 'daily'): Builder
{
$column = $type . '_price';
return $query->whereBetween($column, [$minPrice, $maxPrice]);
}
public function scopeHighRated(Builder $query, float $minRating = 4.0): Builder
{
return $query->where('rating', '>=', $minRating);
}
// Method untuk update rating
public function updateRating()
{
$stats = $this->reviews()
->where('is_approved', true)
->selectRaw('AVG(rating) as avg_rating, COUNT(*) as total_reviews')
->first();
$this->update([
'rating' => round($stats->avg_rating ?? 0, 2),
'total_reviews' => $stats->total_reviews ?? 0,
]);
}
// Check availability untuk tanggal tertentu
public function isAvailableForDates($startDate, $endDate)
{
if (!$this->is_available) return false;
return !$this->bookings()
->where('status', '!=', 'cancelled')
->where(function ($query) use ($startDate, $endDate) {
$query->whereBetween('start_date', [$startDate, $endDate])
->orWhereBetween('end_date', [$startDate, $endDate])
->orWhere(function ($q) use ($startDate, $endDate) {
$q->where('start_date', '<=', $startDate)
->where('end_date', '>=', $endDate);
});
})
->exists();
}
}
Model Booking (app/Models/Booking.php)
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Casts\\Attribute;
use Illuminate\\Database\\Eloquent\\Builder;
use Carbon\\Carbon;
class Booking extends Model
{
use HasFactory;
protected $fillable = [
'booking_code',
'user_id',
'office_id',
'start_date',
'end_date',
'start_time',
'end_time',
'duration_type',
'total_days',
'participant_count',
'purpose',
'special_requests',
'subtotal',
'tax_amount',
'total_amount',
'status',
'notes',
'cancelled_at',
'cancellation_reason',
];
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
'start_time' => 'datetime:H:i',
'end_time' => 'datetime:H:i',
'subtotal' => 'decimal:2',
'tax_amount' => 'decimal:2',
'total_amount' => 'decimal:2',
'cancelled_at' => 'datetime',
];
// Auto-generate booking code
protected static function boot()
{
parent::boot();
static::creating(function ($booking) {
if (empty($booking->booking_code)) {
$booking->booking_code = 'BK' . strtoupper(uniqid());
}
});
}
// Accessor untuk formatted amounts
protected function formattedSubtotal(): Attribute
{
return Attribute::make(
get: fn () => 'Rp ' . number_format($this->subtotal, 0, ',', '.')
);
}
protected function formattedTotalAmount(): Attribute
{
return Attribute::make(
get: fn () => 'Rp ' . number_format($this->total_amount, 0, ',', '.')
);
}
// Accessor untuk status badge
protected function statusBadge(): Attribute
{
return Attribute::make(
get: function () {
$badges = [
'pending' => 'bg-yellow-100 text-yellow-800',
'confirmed' => 'bg-green-100 text-green-800',
'completed' => 'bg-blue-100 text-blue-800',
'cancelled' => 'bg-red-100 text-red-800',
];
return $badges[$this->status] ?? 'bg-gray-100 text-gray-800';
}
);
}
// Scopes
public function scopeActive(Builder $query): Builder
{
return $query->whereIn('status', ['pending', 'confirmed']);
}
public function scopeByStatus(Builder $query, string $status): Builder
{
return $query->where('status', $status);
}
public function scopeUpcoming(Builder $query): Builder
{
return $query->where('start_date', '>', now()->toDateString());
}
public function scopeOngoing(Builder $query): Builder
{
return $query->where('start_date', '<=', now()->toDateString())
->where('end_date', '>=', now()->toDateString())
->where('status', 'confirmed');
}
// Methods untuk booking management
public function confirm()
{
$this->update(['status' => 'confirmed']);
}
public function cancel($reason = null)
{
$this->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
}
public function complete()
{
$this->update(['status' => 'completed']);
}
// Check apakah booking bisa di-cancel
public function canBeCancelled()
{
return $this->status === 'pending' ||
($this->status === 'confirmed' && $this->start_date > now()->addHours(24));
}
}
Model Payment (app/Models/Payment.php)
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Casts\\Attribute;
use Illuminate\\Database\\Eloquent\\Builder;
class Payment extends Model
{
use HasFactory;
protected $fillable = [
'booking_id',
'payment_method',
'amount',
'payment_proof',
'payment_date',
'verified_at',
'verified_by',
'status',
'reference_number',
'notes',
];
protected $casts = [
'amount' => 'decimal:2',
'payment_date' => 'datetime',
'verified_at' => 'datetime',
];
// Accessor untuk formatted amount
protected function formattedAmount(): Attribute
{
return Attribute::make(
get: fn () => 'Rp ' . number_format($this->amount, 0, ',', '.')
);
}
// Accessor untuk payment proof URL
protected function paymentProofUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->payment_proof
? asset('storage/' . $this->payment_proof)
: null
);
}
// Scopes
public function scopePending(Builder $query): Builder
{
return $query->where('status', 'pending');
}
public function scopeVerified(Builder $query): Builder
{
return $query->where('status', 'verified');
}
// Methods
public function verify($adminId)
{
$this->update([
'status' => 'verified',
'verified_at' => now(),
'verified_by' => $adminId,
]);
}
}
Model Review (app/Models/Review.php)
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Casts\\Attribute;
use Illuminate\\Database\\Eloquent\\Builder;
class Review extends Model
{
use HasFactory;
protected $fillable = [
'booking_id',
'user_id',
'office_id',
'rating',
'title',
'comment',
'pros',
'cons',
'is_anonymous',
'is_approved',
'helpful_count',
];
protected $casts = [
'rating' => 'integer',
'is_anonymous' => 'boolean',
'is_approved' => 'boolean',
'helpful_count' => 'integer',
];
// Accessor untuk star display
protected function starDisplay(): Attribute
{
return Attribute::make(
get: function () {
return str_repeat('★', $this->rating) . str_repeat('☆', 5 - $this->rating);
}
);
}
// Accessor untuk reviewer name
protected function reviewerName(): Attribute
{
return Attribute::make(
get: function () {
if ($this->is_anonymous) {
return 'Anonymous User';
}
return $this->user->name ?? 'Unknown User';
}
);
}
// Scopes
public function scopeApproved(Builder $query): Builder
{
return $query->where('is_approved', true);
}
public function scopeByRating(Builder $query, int $rating): Builder
{
return $query->where('rating', $rating);
}
public function scopeRecent(Builder $query): Builder
{
return $query->orderBy('created_at', 'desc');
}
}
Membuat Relasi Antar Model
Sekarang kita define relationships antara models. Eloquent relationships bikin query jadi efficient dan code jadi clean.
Tambahkan relationships di Model User:
// Di dalam class User, tambahkan methods ini:
public function bookings()
{
return $this->hasMany(Booking::class);
}
public function reviews()
{
return $this->hasMany(Review::class);
}
public function payments()
{
return $this->hasManyThrough(Payment::class, Booking::class);
}
public function verifiedPayments()
{
return $this->hasMany(Payment::class, 'verified_by');
}
Tambahkan relationships di Model Office:
// Di dalam class Office, tambahkan methods ini:
public function bookings()
{
return $this->hasMany(Booking::class);
}
public function reviews()
{
return $this->hasMany(Review::class);
}
public function facilities()
{
return $this->belongsToMany(OfficeFacility::class, 'office_facility')
->withPivot('is_available', 'additional_info')
->withTimestamps();
}
public function activeFacilities()
{
return $this->facilities()->wherePivot('is_available', true);
}
public function activeBookings()
{
return $this->bookings()->active();
}
public function approvedReviews()
{
return $this->reviews()->approved();
}
Tambahkan relationships di Model Booking:
// Di dalam class Booking, tambahkan methods ini:
public function user()
{
return $this->belongsTo(User::class);
}
public function office()
{
return $this->belongsTo(Office::class);
}
public function payment()
{
return $this->hasOne(Payment::class);
}
public function review()
{
return $this->hasOne(Review::class);
}
Tambahkan relationships di Model Payment:
// Di dalam class Payment, tambahkan methods ini:
public function booking()
{
return $this->belongsTo(Booking::class);
}
public function verifiedBy()
{
return $this->belongsTo(User::class, 'verified_by');
}
public function user()
{
return $this->hasOneThrough(User::class, Booking::class, 'id', 'id', 'booking_id', 'user_id');
}
Tambahkan relationships di Model Review:
// Di dalam class Review, tambahkan methods ini:
public function booking()
{
return $this->belongsTo(Booking::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function office()
{
return $this->belongsTo(Office::class);
}
Tambahkan relationships di Model OfficeFacility:
// Di dalam class OfficeFacility, tambahkan methods ini:
public function offices()
{
return $this->belongsToMany(Office::class, 'office_facility')
->withPivot('is_available', 'additional_info')
->withTimestamps();
}
Menggunakan Spatie Permission Traits pada Model User
Trait HasRoles dari Spatie udah kita include di model User. Sekarang kita define roles dan permissions yang bakal kita pake:
Create seeder buat roles dan permissions:
php artisan make:seeder RolePermissionSeeder
File database/seeders/RolePermissionSeeder.php:
<?php
namespace Database\\Seeders;
use Illuminate\\Database\\Seeder;
use Spatie\\Permission\\Models\\Role;
use Spatie\\Permission\\Models\\Permission;
use App\\Models\\User;
class RolePermissionSeeder extends Seeder
{
public function run(): void
{
// Create permissions
$permissions = [
'view_dashboard',
'manage_offices',
'manage_bookings',
'manage_payments',
'manage_users',
'manage_reviews',
'view_reports',
'manage_settings',
];
foreach ($permissions as $permission) {
Permission::create(['name' => $permission]);
}
// Create roles
$superAdmin = Role::create(['name' => 'super_admin']);
$admin = Role::create(['name' => 'admin']);
$operator = Role::create(['name' => 'operator']);
$customer = Role::create(['name' => 'customer']);
// Assign permissions to roles
$superAdmin->givePermissionTo(Permission::all());
$admin->givePermissionTo([
'view_dashboard',
'manage_offices',
'manage_bookings',
'manage_payments',
'view_reports',
]);
$operator->givePermissionTo([
'view_dashboard',
'manage_bookings',
'manage_payments',
]);
// Assign super admin role to first user
$firstUser = User::first();
if ($firstUser) {
$firstUser->assignRole('super_admin');
}
}
}
Update DatabaseSeeder buat include role seeder:
public function run(): void
{
$this->call([
OfficeFacilitySeeder::class,
UserSeeder::class,
RolePermissionSeeder::class,
OfficeSeeder::class,
]);
}
Testing Model Relationships dan Functionality
Sekarang kita test semua functionality yang udah kita implement:
php artisan tinker
Test relationships:
>>> $user = App\\Models\\User::first();
>>> $user->roles;
>>> $user->hasRole('super_admin');
>>> $user->can('manage_offices');
>>> $office = App\\Models\\Office::first();
>>> $office->facilities;
>>> $office->imageUrls;
>>> $office->formattedDailyPrice;
>>> $facility = App\\Models\\OfficeFacility::first();
>>> $facility->offices;
Test scopes:
>>> App\\Models\\Office::available()->count();
>>> App\\Models\\Office::featured()->get();
>>> App\\Models\\Office::inCity('Jakarta')->count();
Test methods:
>>> $office = App\\Models\\Office::first();
>>> $office->isAvailableForDates('2024-12-01', '2024-12-03');
>>> $office->updateRating();
Kalau semua test di atas jalan dengan smooth, berarti model configuration kita udah perfect!
Dengan setup model yang comprehensive kayak gini, kita udah punya foundation yang solid buat implement business logic yang complex. Models kita udah optimized dengan relationships yang efficient, accessors yang helpful, dan methods yang reusable.
Next, kita bakal implement Filament admin panel yang bakal leverage semua model relationships dan functionality yang udah kita setup!
Pembuatan Admin Filament: Dashboard yang Keren Banget!
Nah sekarang kita masuk ke bagian yang paling exciting! Kita bakal bikin admin panel yang professional pake Filament 3. Filament itu bener-bener game changer buat developer Laravel - dalam hitungan menit kita bisa punya dashboard admin yang sleek dan functional.
Gue bakal guide kalian step by step mulai dari create admin user pertama sampai kustomisasi dashboard yang kece. Trust me, setelah bagian ini kalian bakal jatuh cinta sama Filament!
Membuat Akun Admin Pertama Menggunakan Command Filament
Pertama-tama, kita perlu bikin admin user yang bisa akses dashboard Filament. Beruntungnya, Filament udah provide command yang gampang banget buat ini.
Jalankan command berikut di terminal:
php artisan make:filament-user
Command ini bakal nanya beberapa informasi:
Name: Super Admin
Email address: [email protected]
Password: [buat password yang kuat, minimal 8 karakter]
Setelah berhasil, kamu bakal dapet message konfirmasi bahwa user admin udah ter-create. User ini otomatis punya akses ke admin panel.
Tapi tunggu, ada yang lebih keren lagi! Kita bisa bikin custom command buat create admin dengan role yang spesifik. Bikin file command baru:
php artisan make:command CreateFilamentAdmin
Edit file app/Console/Commands/CreateFilamentAdmin.php:
<?php
namespace App\\Console\\Commands;
use App\\Models\\User;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\Hash;
class CreateFilamentAdmin extends Command
{
protected $signature = 'filament:create-admin {--email=} {--name=} {--password=}';
protected $description = 'Create a new Filament admin user with super admin role';
public function handle()
{
$email = $this->option('email') ?: $this->ask('Email address');
$name = $this->option('name') ?: $this->ask('Full name');
$password = $this->option('password') ?: $this->secret('Password');
$user = User::create([
'name' => $name,
'email' => $email,
'password' => Hash::make($password),
'email_verified_at' => now(),
'is_active' => true,
]);
$user->assignRole('super_admin');
$this->info("Admin user created succesfully!"); // typo disengaja
$this->line("Email: {$email}");
$this->line("Password: [hidden]");
return Command::SUCCESS;
}
}
Sekarang kamu bisa create admin dengan command:
php artisan filament:create-admin [email protected] --name="Admin User"
Mengakses Halaman Admin Filament
Setelah punya admin user, saatnya akses dashboard! Jalankan development server:
php artisan serve
Buka browser dan navigasi ke:
<http://localhost:8000/admin>
Kamu bakal diarahkan ke halaman login Filament yang clean banget. Input email dan password yang tadi dibuat, terus klik "Sign in".
Boom! Kamu udah masuk ke dashboard Filament yang elegant. Interface-nya modern dengan sidebar navigation, dark mode toggle, dan user menu di pojok kanan atas.
Yang bikin keren, Filament otomatis detect browser theme (light/dark) dan adjust tampilan accordingly. Plus ada responsive design yang perfect di mobile maupun desktop.
Melakukan Uji Coba Login pada Dashboard Admin
Mari kita explore dashboard yang udah tersedia. Di sidebar kamu bakal liat menu-menu default:
Dashboard: Halaman utama dengan widgets dan statistics (masih kosong karena belum ada data)
Users: Resource buat manage users (ini auto-generated dari model User)
Coba klik menu "Users" - kamu bakal liat table yang bisa sorting, filtering, dan searching. Ini adalah power dari Filament: zero configuration tapi feature-rich!
Test beberapa fungsionalitas:
- Create user baru dengan tombol "New user"
- Edit user existing dengan klik icon edit
- Bulk actions dengan select multiple rows
- Global search di header
Semua fitur ini udah built-in tanpa kita perlu coding apa-apa. Amazing right?
Kustomisasi Tampilan Dashboard Admin
Sekarang kita personalisasi dashboard supaya sesuai dengan brand kita. Filament sangat customizable dan flexible.
Mengubah Logo dan Branding
Edit file config/filament.php (kalau belum ada, publish dulu):
php artisan vendor:publish --tag=filament-config
Update konfigurasi di config/filament.php:
return [
'path' => env('FILAMENT_PATH', 'admin'),
'domain' => env('FILAMENT_DOMAIN'),
'brand' => 'Office Rental',
'auth' => [
'guard' => env('FILAMENT_AUTH_GUARD', 'web'),
'pages' => [
'login' => \\Filament\\Http\\Livewire\\Auth\\Login::class,
],
],
'pages' => [
'namespace' => 'App\\\\Filament\\\\Pages',
'path' => app_path('Filament/Pages'),
],
'resources' => [
'namespace' => 'App\\\\Filament\\\\Resources',
'path' => app_path('Filament/Resources'),
],
'widgets' => [
'namespace' => 'App\\\\Filament\\\\Widgets',
'path' => app_path('Filament/Widgets'),
],
];
Custom CSS untuk Branding
Bikin file CSS custom di resources/css/filament.css:
:root {
--primary-50: #eff6ff;
--primary-500: #3b82f6;
--primary-600: #2563eb;
--primary-700: #1d4ed8;
--primary-900: #1e3a8a;
}
.fi-sidebar-nav-item-active {
background: linear-gradient(135deg, var(--primary-500), var(--primary-600));
}
.fi-logo {
font-weight: 700;
font-size: 1.25rem;
color: var(--primary-700);
}
Register CSS file di resources/views/vendor/filament/components/layouts/app.blade.php (publish view dulu):
php artisan vendor:publish --tag=filament-views
Create Custom Dashboard Page
Bikin dashboard page yang custom dengan widgets menarik:
php artisan make:filament-page Dashboard
Edit file app/Filament/Pages/Dashboard.php:
<?php
namespace App\\Filament\\Pages;
use Filament\\Pages\\Dashboard as BaseDashboard;
class Dashboard extends BaseDashboard
{
protected static ?string $navigationIcon = 'heroicon-o-home';
protected static string $view = 'filament.pages.dashboard';
protected function getHeaderWidgets(): array
{
return [
// nanti kita bakal add widgets di sini
];
}
protected function getFooterWidgets(): array
{
return [
// footer widgets
];
}
}
Custom Navigation Items
Kita bisa customize sidebar navigation dengan override di service provider. Edit app/Providers/AppServiceProvider.php:
use Filament\\Facades\\Filament;
use Filament\\Navigation\\NavigationGroup;
use Filament\\Navigation\\NavigationItem;
public function boot()
{
Filament::serving(function () {
Filament::registerNavigationGroups([
NavigationGroup::make()
->label('Office Management')
->icon('heroicon-o-office-building'),
NavigationGroup::make()
->label('Booking & Payments')
->icon('heroicon-o-currency-dollar'),
NavigationGroup::make()
->label('User Management')
->icon('heroicon-o-users'),
]);
Filament::registerNavigationItems([
NavigationItem::make('Analytics')
->url('/admin/analytics')
->icon('heroicon-o-chart-bar')
->group('Reports')
->sort(1),
]);
});
}
Mengatur Middleware dan Guards untuk Keamanan
Security adalah prioritas utama, especially untuk admin panel. Kita perlu setup middleware dan guards yang proper.
Konfigurasi Auth Guard
Update config/auth.php kalau perlu custom guard:
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'admin' => [ // custom guard for admin
'driver' => 'session',
'provider' => 'admin_users',
],
],
Custom Middleware untuk Role Check
Bikin middleware buat check admin role:
php artisan make:middleware FilamentAdminMiddleware
Edit app/Http/Middleware/FilamentAdminMiddleware.php:
<?php
namespace App\\Http\\Middleware;
use Closure;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Auth;
class FilamentAdminMiddleware
{
public function handle(Request $request, Closure $next)
{
if (!Auth::check()) {
return redirect()->route('filament.auth.login');
}
$user = Auth::user();
if (!$user->hasAnyRole(['super_admin', 'admin', 'operator'])) {
abort(403, 'Access denied. Admin privileges required.');
}
// Update last login
$user->updateLastLogin();
return $next($request);
}
}
Register middleware di app/Http/Kernel.php:
protected $routeMiddleware = [
// ... existing middleware
'filament.admin' => \\App\\Http\\Middleware\\FilamentAdminMiddleware::class,
];
Setup Filament dengan Custom Middleware
Update konfigurasi Filament di config/filament.php:
'middleware' => [
'auth' => [
'web',
'filament.admin', // custom middleware kita
],
'base' => [
'web',
],
],
'auth' => [
'guard' => 'web',
'pages' => [
'login' => \\Filament\\Http\\Livewire\\Auth\\Login::class,
],
],
Two-Factor Authentication (Bonus)
Buat security yang lebih ketat, kita bisa implement 2FA. Install package:
composer require pragmarx/google2fa-laravel
Tapi ini optional ya, untuk basic setup middleware di atas udah cukup secure.
Testing Security Setup
Test dengan cara:
- Logout dari admin panel
- Coba akses
/adminlangsung - harus redirect ke login - Login dengan user biasa (tanpa admin role) - harus dapat error 403
- Login dengan admin user - harus berhasil masuk
Custom Login Page (Optional tapi Keren)
Kalau mau lebih professional, kita bisa customize login page. Bikin view custom:
mkdir -p resources/views/filament/auth
Bikin file resources/views/filament/auth/login.blade.php:
<x-filament::layouts.app>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div class="max-w-md w-full space-y-8">
<div class="text-center">
<h2 class="text-3xl font-bold text-gray-900">
Office Rental Admin
</h2>
<p class="mt-2 text-gray-600">
Sign in to manage your office rental platform
</p>
</div>
<div class="bg-white shadow-xl rounded-lg p-8">
{{ $this->form }}
<div class="mt-6">
{{ $this->authenticate() }}
</div>
</div>
</div>
</div>
</x-filament::layouts.app>
Update config buat pake custom login:
'auth' => [
'pages' => [
'login' => App\\Filament\\Pages\\Auth\\Login::class,
],
],
Bikin custom login page class:
php artisan make:filament-page Auth/Login
Dan voilà! Admin panel kita udah ready dengan security yang proper dan tampilan yang professional.
Di bagian selanjutnya, kita bakal bikin Filament Resources buat manage data offices, bookings, dan users dengan CRUD interface yang powerful!
Pembuatan Filament Resource: CRUD Interface yang Powerfull
Sekarang kita masuk ke bagian yang paling seru! Kita bakal bikin Filament Resources yang bakal jadi management interface buat data aplikasi kita. Dengan Filament Resources, admin bisa manage data offices, bookings, users dengan interface yang intuitive dan feature-rich.
Gue bakal tunjukin gimana cara bikin Resources yang tidak cuma basic CRUD, tapi juga punya custom actions, filters, dan widgets yang keren banget. Let's start building!
Membuat Filament Resource untuk CRUD Tabel Offices
Office adalah core dari aplikasi kita, jadi kita mulai dari sini dulu. Bikin Resource buat manage office data:
php artisan make:filament-resource Office --generate
Command ini bakal auto-generate Resource class beserta form dan table configuration berdasarkan model Office kita. Keren kan?
Sekarang edit file app/Filament/Resources/OfficeResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\OfficeResource\\Pages;
use App\\Models\\Office;
use App\\Models\\OfficeFacility;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Support\\Str;
class OfficeResource extends Resource
{
protected static ?string $model = Office::class;
protected static ?string $navigationIcon = 'heroicon-o-building-office';
protected static ?string $navigationGroup = 'BWA Office Management';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Basic Information')
->schema([
Forms\\Components\\TextInput::make('name')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(function (string $operation, $state, Forms\\Set $set) {
if ($operation !== 'create') {
return;
}
$set('slug', Str::slug($state));
}),
Forms\\Components\\TextInput::make('slug')
->required()
->maxLength(255)
->unique(Office::class, 'slug', ignoreRecord: true),
Forms\\Components\\Textarea::make('description')
->required()
->maxLength(65535)
->columnSpanFull(),
])->columns(2),
Forms\\Components\\Section::make('Location Details')
->schema([
Forms\\Components\\Textarea::make('address')
->required()
->maxLength(65535)
->columnSpanFull(),
Forms\\Components\\TextInput::make('city')
->required()
->maxLength(100),
Forms\\Components\\TextInput::make('state')
->required()
->maxLength(100),
Forms\\Components\\TextInput::make('postal_code')
->required()
->maxLength(10),
Forms\\Components\\TextInput::make('latitude')
->numeric()
->step(0.00000001),
Forms\\Components\\TextInput::make('longitude')
->numeric()
->step(0.00000001),
])->columns(2),
Forms\\Components\\Section::make('Office Specifications')
->schema([
Forms\\Components\\TextInput::make('capacity')
->required()
->numeric()
->minValue(1),
Forms\\Components\\TextInput::make('area_size')
->numeric()
->step(0.01)
->suffix('m²'),
Forms\\Components\\TextInput::make('hourly_price')
->required()
->numeric()
->prefix('Rp')
->step(0.01),
Forms\\Components\\TextInput::make('daily_price')
->required()
->numeric()
->prefix('Rp')
->step(0.01),
Forms\\Components\\TextInput::make('monthly_price')
->required()
->numeric()
->prefix('Rp')
->step(0.01),
])->columns(3),
Forms\\Components\\Section::make('Contact & Availability')
->schema([
Forms\\Components\\TextInput::make('contact_person')
->maxLength(255),
Forms\\Components\\TextInput::make('contact_phone')
->tel()
->maxLength(20),
Forms\\Components\\Toggle::make('is_featured')
->label('Featured Office'),
Forms\\Components\\Toggle::make('is_available')
->label('Available for Booking')
->default(true),
])->columns(2),
Forms\\Components\\Section::make('Media & Facilities')
->schema([
Forms\\Components\\FileUpload::make('images')
->multiple()
->image()
->directory('offices')
->maxFiles(10)
->reorderable()
->columnSpanFull(),
Forms\\Components\\CheckboxList::make('facilities')
->relationship('facilities', 'name')
->options(OfficeFacility::active()->pluck('name', 'id'))
->columns(3)
->columnSpanFull(),
Forms\\Components\\KeyValue::make('opening_hours')
->keyLabel('Day')
->valueLabel('Hours')
->default([
'Monday' => '09:00 - 18:00',
'Tuesday' => '09:00 - 18:00',
'Wednesday' => '09:00 - 18:00',
'Thursday' => '09:00 - 18:00',
'Friday' => '09:00 - 18:00',
'Saturday' => '09:00 - 15:00',
'Sunday' => 'Closed',
])
->columnSpanFull(),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\ImageColumn::make('images')
->label('Photo')
->circular()
->stacked()
->limit(3),
Tables\\Columns\\TextColumn::make('name')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('city')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('capacity')
->numeric()
->sortable()
->suffix(' people'),
Tables\\Columns\\TextColumn::make('daily_price')
->money('IDR')
->sortable(),
Tables\\Columns\\IconColumn::make('is_featured')
->boolean()
->trueIcon('heroicon-o-star')
->falseIcon('heroicon-o-star')
->trueColor('warning')
->falseColor('gray'),
Tables\\Columns\\ToggleColumn::make('is_available')
->label('Available'),
Tables\\Columns\\TextColumn::make('rating')
->numeric(decimalPlaces: 1)
->suffix('/5.0')
->color(fn ($state) => $state >= 4 ? 'success' : ($state >= 3 ? 'warning' : 'danger')),
Tables\\Columns\\TextColumn::make('total_reviews')
->numeric()
->label('Reviews'),
Tables\\Columns\\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\\Filters\\SelectFilter::make('city')
->options(Office::distinct()->pluck('city', 'city')->toArray()),
Tables\\Filters\\TernaryFilter::make('is_featured')
->label('Featured Offices'),
Tables\\Filters\\TernaryFilter::make('is_available')
->label('Available for Booking'),
Tables\\Filters\\Filter::make('capacity_range')
->form([
Forms\\Components\\TextInput::make('capacity_from')
->numeric()
->placeholder('Min capacity'),
Forms\\Components\\TextInput::make('capacity_to')
->numeric()
->placeholder('Max capacity'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when($data['capacity_from'], fn ($query) => $query->where('capacity', '>=', $data['capacity_from']))
->when($data['capacity_to'], fn ($query) => $query->where('capacity', '<=', $data['capacity_to']));
}),
])
->actions([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
Tables\\Actions\\BulkAction::make('toggle_featured')
->label('Toggle Featured')
->icon('heroicon-o-star')
->action(function ($records) {
foreach ($records as $record) {
$record->update(['is_featured' => !$record->is_featured]);
}
}),
]),
]);
}
public static function getRelations(): array
{
return [
// nanti kita tambahin relation managers
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListOffices::route('/'),
'create' => Pages\\CreateOffice::route('/create'),
'view' => Pages\\ViewOffice::route('/{record}'),
'edit' => Pages\\EditOffice::route('/{record}/edit'),
];
}
}
Membuat Filament Resource untuk CRUD Tabel Bookings
Booking adalah transaksi inti, jadi kita bikin Resource yang comprehensive:
php artisan make:filament-resource Booking --generate
Edit app/Filament/Resources/BookingResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\BookingResource\\Pages;
use App\\Models\\Booking;
use App\\Models\\Office;
use App\\Models\\User;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
use Filament\\Notifications\\Notification;
class BookingResource extends Resource
{
protected static ?string $model = Booking::class;
protected static ?string $navigationIcon = 'heroicon-o-calendar-days';
protected static ?string $navigationGroup = 'BWA Booking Management';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Booking Information')
->schema([
Forms\\Components\\TextInput::make('booking_code')
->required()
->maxLength(20)
->unique(ignoreRecord: true)
->default(fn () => 'BWA' . strtoupper(uniqid())),
Forms\\Components\\Select::make('user_id')
->label('Customer')
->relationship('user', 'name')
->searchable()
->preload()
->required(),
Forms\\Components\\Select::make('office_id')
->label('Office')
->relationship('office', 'name')
->searchable()
->preload()
->required(),
Forms\\Components\\Select::make('status')
->options([
'pending' => 'Pending',
'confirmed' => 'Confirmed',
'completed' => 'Completed',
'cancelled' => 'Cancelled',
])
->default('pending')
->required(),
])->columns(2),
Forms\\Components\\Section::make('Schedule & Duration')
->schema([
Forms\\Components\\DatePicker::make('start_date')
->required()
->native(false),
Forms\\Components\\DatePicker::make('end_date')
->required()
->native(false)
->after('start_date'),
Forms\\Components\\TimePicker::make('start_time')
->seconds(false),
Forms\\Components\\TimePicker::make('end_time')
->seconds(false)
->after('start_time'),
Forms\\Components\\Select::make('duration_type')
->options([
'hourly' => 'Hourly',
'daily' => 'Daily',
'monthly' => 'Monthly',
])
->required(),
Forms\\Components\\TextInput::make('total_days')
->required()
->numeric()
->minValue(1),
])->columns(3),
Forms\\Components\\Section::make('Booking Details')
->schema([
Forms\\Components\\TextInput::make('participant_count')
->required()
->numeric()
->minValue(1),
Forms\\Components\\TextInput::make('purpose')
->maxLength(255),
Forms\\Components\\Textarea::make('special_requests')
->maxLength(65535)
->columnSpanFull(),
])->columns(2),
Forms\\Components\\Section::make('Payment Information')
->schema([
Forms\\Components\\TextInput::make('subtotal')
->required()
->numeric()
->prefix('Rp'),
Forms\\Components\\TextInput::make('tax_amount')
->numeric()
->default(0)
->prefix('Rp'),
Forms\\Components\\TextInput::make('total_amount')
->required()
->numeric()
->prefix('Rp'),
])->columns(3),
Forms\\Components\\Section::make('Additional Notes')
->schema([
Forms\\Components\\Textarea::make('notes')
->maxLength(65535)
->columnSpanFull(),
Forms\\Components\\Textarea::make('cancellation_reason')
->maxLength(65535)
->visible(fn (Forms\\Get $get) => $get('status') === 'cancelled')
->columnSpanFull(),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('booking_code')
->searchable()
->sortable()
->copyable(),
Tables\\Columns\\TextColumn::make('user.name')
->label('Customer')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('office.name')
->label('Office')
->searchable()
->sortable()
->limit(30),
Tables\\Columns\\TextColumn::make('start_date')
->date()
->sortable(),
Tables\\Columns\\TextColumn::make('end_date')
->date()
->sortable(),
Tables\\Columns\\TextColumn::make('duration_type')
->badge()
->color(fn (string $state): string => match ($state) {
'hourly' => 'info',
'daily' => 'success',
'monthly' => 'warning',
default => 'gray',
}),
Tables\\Columns\\TextColumn::make('total_amount')
->money('IDR')
->sortable(),
Tables\\Columns\\TextColumn::make('status')
->badge()
->color(fn (string $state): string => match ($state) {
'pending' => 'warning',
'confirmed' => 'success',
'completed' => 'info',
'cancelled' => 'danger',
default => 'gray',
}),
Tables\\Columns\\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\\Filters\\SelectFilter::make('status')
->options([
'pending' => 'Pending',
'confirmed' => 'Confirmed',
'completed' => 'Completed',
'cancelled' => 'Cancelled',
]),
Tables\\Filters\\SelectFilter::make('duration_type')
->options([
'hourly' => 'Hourly',
'daily' => 'Daily',
'monthly' => 'Monthly',
]),
Tables\\Filters\\Filter::make('date_range')
->form([
Forms\\Components\\DatePicker::make('from_date')
->label('From Date'),
Forms\\Components\\DatePicker::make('to_date')
->label('To Date'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when($data['from_date'], fn ($query) => $query->where('start_date', '>=', $data['from_date']))
->when($data['to_date'], fn ($query) => $query->where('end_date', '<=', $data['to_date']));
}),
])
->actions([
Tables\\Actions\\Action::make('confirm')
->label('Confirm')
->icon('heroicon-o-check-circle')
->color('success')
->visible(fn (Booking $record) => $record->status === 'pending')
->action(function (Booking $record) {
$record->confirm();
Notification::make()
->title('Booking confirmed successfully')
->success()
->send();
}),
Tables\\Actions\\Action::make('cancel')
->label('Cancel')
->icon('heroicon-o-x-circle')
->color('danger')
->visible(fn (Booking $record) => in_array($record->status, ['pending', 'confirmed']))
->form([
Forms\\Components\\Textarea::make('reason')
->label('Cancellation Reason')
->required(),
])
->action(function (Booking $record, array $data) {
$record->cancel($data['reason']);
Notification::make()
->title('Booking cancelled successfully')
->success()
->send();
}),
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make(),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
// PaymentRelationManager, ReviewRelationManager nanti ditambahkan
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListBookings::route('/'),
'create' => Pages\\CreateBooking::route('/create'),
'view' => Pages\\ViewBooking::route('/{record}'),
'edit' => Pages\\EditBooking::route('/{record}/edit'),
];
}
}
Membuat Filament Resource untuk CRUD Tabel Users
User management adalah crucial buat admin panel. Mari bikin Resource yang comprehensive:
php artisan make:filament-resource User --generate
Edit app/Filament/Resources/UserResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\UserResource\\Pages;
use App\\Models\\User;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Support\\Facades\\Hash;
use Spatie\\Permission\\Models\\Role;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static ?string $navigationIcon = 'heroicon-o-users';
protected static ?string $navigationGroup = 'BWA User Management';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('User Information')
->schema([
Forms\\Components\\TextInput::make('name')
->required()
->maxLength(255),
Forms\\Components\\TextInput::make('email')
->email()
->required()
->maxLength(255)
->unique(ignoreRecord: true),
Forms\\Components\\TextInput::make('phone')
->tel()
->maxLength(20),
Forms\\Components\\FileUpload::make('avatar')
->image()
->directory('avatars')
->imageEditor(),
])->columns(2),
Forms\\Components\\Section::make('Account Settings')
->schema([
Forms\\Components\\TextInput::make('password')
->password()
->dehydrateStateUsing(fn ($state) => Hash::make($state))
->dehydrated(fn ($state) => filled($state))
->required(fn (string $context): bool => $context === 'create'),
Forms\\Components\\TextInput::make('passwordConfirmation')
->password()
->same('password')
->dehydrated(false)
->required(fn (string $context): bool => $context === 'create'),
Forms\\Components\\Toggle::make('is_active')
->label('Active Account')
->default(true),
Forms\\Components\\Select::make('roles')
->relationship('roles', 'name')
->multiple()
->preload()
->searchable()
->columnSpanFull(),
])->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\ImageColumn::make('avatar')
->circular()
->defaultImageUrl(fn ($record) => '<https://ui-avatars.com/api/?name=>' . urlencode($record->name)),
Tables\\Columns\\TextColumn::make('name')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('email')
->searchable()
->sortable()
->copyable(),
Tables\\Columns\\TextColumn::make('phone')
->searchable()
->toggleable(isToggledHiddenByDefault: true),
Tables\\Columns\\TextColumn::make('roles.name')
->badge()
->color(fn (string $state): string => match ($state) {
'super_admin' => 'danger',
'admin' => 'warning',
'operator' => 'info',
'customer' => 'success',
default => 'gray',
}),
Tables\\Columns\\ToggleColumn::make('is_active')
->label('Active'),
Tables\\Columns\\TextColumn::make('last_login_at')
->dateTime()
->sortable()
->since()
->placeholder('Never'),
Tables\\Columns\\TextColumn::make('bookings_count')
->counts('bookings')
->label('Total Bookings'),
Tables\\Columns\\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\\Filters\\SelectFilter::make('roles')
->relationship('roles', 'name')
->multiple(),
Tables\\Filters\\TernaryFilter::make('is_active')
->label('Account Status'),
Tables\\Filters\\Filter::make('last_login')
->label('Recently Active')
->query(fn (Builder $query): Builder => $query->where('last_login_at', '>=', now()->subDays(30))),
])
->actions([
Tables\\Actions\\Action::make('impersonate')
->label('Login as User')
->icon('heroicon-o-user-circle')
->color('warning')
->visible(fn () => auth()->user()->hasRole('super_admin'))
->url(fn (User $record) => route('impersonate', $record->id))
->openUrlInNewTab(),
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
Tables\\Actions\\BulkAction::make('activate')
->label('Activate Selected')
->icon('heroicon-o-check-circle')
->action(function ($records) {
foreach ($records as $record) {
$record->update(['is_active' => true]);
}
}),
Tables\\Actions\\BulkAction::make('deactivate')
->label('Deactivate Selected')
->icon('heroicon-o-x-circle')
->action(function ($records) {
foreach ($records as $record) {
$record->update(['is_active' => false]);
}
}),
]),
]);
}
public static function getRelations(): array
{
return [
// BookingRelationManager nanti ditambahkan
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListUsers::route('/'),
'create' => Pages\\CreateUser::route('/create'),
'view' => Pages\\ViewUser::route('/{record}'),
'edit' => Pages\\EditUser::route('/{record}/edit'),
];
}
}
Mengatur Form Fields dan Validation pada Setiap Resource
Validation adalah kunci dari data integrity. Kita udah implement basic validation di form components, tapi mari kita enhance dengan custom validation rules.
Custom Validation di OfficeResource:
Tambahkan method ini di OfficeResource:
public static function getFormSchema(): array
{
return [
// existing schema...
Forms\\Components\\TextInput::make('capacity')
->required()
->numeric()
->minValue(1)
->maxValue(1000)
->rules(['integer', 'between:1,1000']),
Forms\\Components\\TextInput::make('daily_price')
->required()
->numeric()
->prefix('Rp')
->step(0.01)
->rules(['numeric', 'min:50000']) // minimal 50rb
->validationMessages([
'min' => 'Daily price must be at least Rp 50,000',
]),
];
}
Advanced Validation di BookingResource:
Forms\\Components\\DatePicker::make('start_date')
->required()
->native(false)
->rules(['date', 'after:today'])
->validationMessages([
'after' => 'Start date must be at least tomorrow',
]),
Forms\\Components\\DatePicker::make('end_date')
->required()
->native(false)
->after('start_date')
->rules([
'date',
function ($attribute, $value, $fail) {
$startDate = request()->input('start_date');
if ($startDate && $value) {
$daysDiff = \\Carbon\\Carbon::parse($value)->diffInDays(\\Carbon\\Carbon::parse($startDate));
if ($daysDiff > 365) {
$fail('Booking duration cannot exceed 1 year');
}
}
},
]),
Kustomisasi Table Columns dan Filters
Table adalah interface utama buat browse dan manage data. Mari kita bikin table yang powerfull dengan custom columns dan filters.
Advanced Filters di OfficeResource:
->filters([
Tables\\Filters\\SelectFilter::make('city')
->options(Office::distinct()->pluck('city', 'city')->toArray())
->searchable()
->multiple(),
Tables\\Filters\\Filter::make('price_range')
->form([
Forms\\Components\\TextInput::make('min_price')
->numeric()
->placeholder('Minimum daily price'),
Forms\\Components\\TextInput::make('max_price')
->numeric()
->placeholder('Maximum daily price'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when($data['min_price'], fn ($q) => $q->where('daily_price', '>=', $data['min_price']))
->when($data['max_price'], fn ($q) => $q->where('daily_price', '<=', $data['max_price']));
})
->indicateUsing(function (array $data): array {
$indicators = [];
if ($data['min_price'] ?? null) {
$indicators['min_price'] = 'Min price: Rp ' . number_format($data['min_price']);
}
if ($data['max_price'] ?? null) {
$indicators['max_price'] = 'Max price: Rp ' . number_format($data['max_price']);
}
return $indicators;
}),
Tables\\Filters\\TernaryFilter::make('has_reviews')
->label('Has Customer Reviews')
->queries(
true: fn (Builder $query) => $query->where('total_reviews', '>', 0),
false: fn (Builder $query) => $query->where('total_reviews', 0),
),
])
Mengatur Permission dan Role Access pada Resource
Security adalah priority utama. Kita perlu implement role-based access control di setiap Resource.
Setup Policies untuk Resources:
Bikin Policy buat Office:
php artisan make:policy OfficePolicy --model=Office
Edit app/Policies/OfficePolicy.php:
<?php
namespace App\\Policies;
use App\\Models\\Office;
use App\\Models\\User;
class OfficePolicy
{
public function viewAny(User $user): bool
{
return $user->hasAnyRole(['super_admin', 'admin', 'operator']);
}
public function view(User $user, Office $office): bool
{
return $user->hasAnyRole(['super_admin', 'admin', 'operator']);
}
public function create(User $user): bool
{
return $user->hasAnyRole(['super_admin', 'admin']);
}
public function update(User $user, Office $office): bool
{
return $user->hasAnyRole(['super_admin', 'admin']);
}
public function delete(User $user, Office $office): bool
{
return $user->hasRole('super_admin');
}
public function deleteAny(User $user): bool
{
return $user->hasRole('super_admin');
}
}
Register policy di app/Providers/AuthServiceProvider.php:
protected $policies = [
Office::class => OfficePolicy::class,
Booking::class => BookingPolicy::class,
User::class => UserPolicy::class,
];
Implement Permission Checks di Resources:
Tambahkan method ini di OfficeResource:
public static function canViewAny(): bool
{
return auth()->user()->can('viewAny', Office::class);
}
public static function canCreate(): bool
{
return auth()->user()->can('create', Office::class);
}
public static function canEdit(Model $record): bool
{
return auth()->user()->can('update', $record);
}
public static function canDelete(Model $record): bool
{
return auth()->user()->can('delete', $record);
}
Dengan setup ini, admin panel kita udah punya CRUD interface yang powerful, secure, dan user-friendly. User dengan role yang berbeda bakal punya akses yang sesuai dengan permission mereka.
Next step, kita bakal bikin frontend interface yang bakal integrate dengan backend yang udah kita setup!
Pembuatan Frontend dengan Tailwind: Interface yang Memukau
Sekarang kita masuk ke bagian yang paling visual dan exciting! Kita bakal bikin frontend yang modern, responsive, dan user-friendly pake Tailwind CSS. Frontend ini yang bakal jadi "face" dari aplikasi kita - tempat dimana customer berinteraksi langsung dengan sistem.
Gue bakal tunjukin gimana cara bikin interface yang ga cuma functional tapi juga aesthetic dan enjoyable buat digunakan. Siap-siap bikin website yang bakal bikin customer bilang "WOW!"
Setup Tailwind CSS dalam Proyek Laravel
Laravel 12 udah include Vite sebagai build tool, yang bikin integration dengan Tailwind jadi super smooth. Mari kita setup dari awal dengan konfigurasi yang optimal.
Pertama, install Tailwind CSS dan dependencies-nya:
npm install -D tailwindcss postcss autoprefixer @tailwindcss/forms @tailwindcss/typography
npx tailwindcss init -p
Command ini bakal generate file tailwind.config.js dan postcss.config.js di root project.
Edit file tailwind.config.js buat konfigurasi yang customized:
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./resources/**/*.blade.php",
"./resources/**/*.js",
"./resources/**/*.vue",
"./app/Filament/**/*.php",
],
theme: {
extend: {
colors: {
'bwa-primary': {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
'bwa-gray': {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
}
},
fontFamily: {
'sans': ['Inter', 'system-ui', 'sans-serif'],
'display': ['Lexend', 'system-ui', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'bounce-subtle': 'bounceSubtle 2s infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
bounceSubtle: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-5px)' },
}
},
spacing: {
'18': '4.5rem',
'88': '22rem',
'128': '32rem',
}
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
],
}
Update file resources/css/app.css:
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
/* Custom BWA Office Rental Styles */
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply font-sans text-bwa-gray-900 antialiased;
}
h1, h2, h3, h4, h5, h6 {
@apply font-display font-semibold;
}
}
@layer components {
.btn-primary {
@apply bg-bwa-primary-600 hover:bg-bwa-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-bwa-primary-500 focus:ring-offset-2;
}
.btn-secondary {
@apply bg-white hover:bg-bwa-gray-50 text-bwa-gray-700 border border-bwa-gray-300 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-bwa-primary-500 focus:ring-offset-2;
}
.card {
@apply bg-white rounded-xl shadow-sm border border-bwa-gray-200 overflow-hidden;
}
.form-input {
@apply block w-full rounded-lg border-bwa-gray-300 shadow-sm focus:border-bwa-primary-500 focus:ring-bwa-primary-500 sm:text-sm;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-success {
@apply badge bg-green-100 text-green-800;
}
.badge-warning {
@apply badge bg-yellow-100 text-yellow-800;
}
.badge-error {
@apply badge bg-red-100 text-red-800;
}
}
@layer utilities {
.text-shadow {
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.bg-gradient-bwa {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
}
.animate-pulse-subtle {
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
}
Update vite.config.js buat include Tailwind processing:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
],
css: {
postcss: './postcss.config.js',
},
});
Build assets buat development:
npm run dev
Membuat Layout Blade Template yang Responsif
Layout template adalah foundation dari semua halaman. Kita bikin layout yang fleksibel dan reusable.
Bikin file resources/views/layouts/app.blade.php:
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="description" content="@yield('meta_description', 'BWA Office Rental - Sewa ruang kantor modern dan nyaman untuk kebutuhan bisnis Anda')">
<meta name="keywords" content="@yield('meta_keywords', 'sewa kantor, office rental, ruang meeting, coworking space, BWA')">
<title>@yield('title', 'BWA Office Rental - Sewa Kantor Modern & Nyaman')</title>
<!-- Fonts -->
<link rel="preconnect" href="<https://fonts.googleapis.com>">
<link rel="preconnect" href="<https://fonts.gstatic.com>" crossorigin>
<link href="<https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Lexend:wght@400;500;600;700&display=swap>" rel="stylesheet">
<!-- Styles -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
@stack('styles')
</head>
<body class="bg-bwa-gray-50 min-h-screen">
<!-- Navigation -->
@include('layouts.navigation')
<!-- Page Content -->
<main class="pb-16">
@yield('content')
</main>
<!-- Footer -->
@include('layouts.footer')
<!-- Toast Notifications -->
<div id="toast-container" class="fixed top-4 right-4 z-50 space-y-2"></div>
@stack('scripts')
<!-- Scripts -->
<script>
// Simple toast notification system
window.showToast = function(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `toast animate-slide-up ${type === 'success' ? 'bg-green-500' : 'bg-red-500'} text-white px-6 py-3 rounded-lg shadow-lg`;
toast.textContent = message;
document.getElementById('toast-container').appendChild(toast);
setTimeout(() => {
toast.remove();
}, 5000);
};
// Loading state management
window.showLoading = function() {
document.body.classList.add('loading');
};
window.hideLoading = function() {
document.body.classList.remove('loading');
};
</script>
</body>
</html>
Bikin navigation component di resources/views/layouts/navigation.blade.php:
<nav class="bg-white shadow-sm border-b border-bwa-gray-200 sticky top-0 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Logo -->
<div class="flex items-center">
<a href="{{ route('home') }}" class="flex items-center space-x-2">
<div class="w-8 h-8 bg-gradient-bwa rounded-lg flex items-center justify-center">
<span class="text-white font-bold text-sm">BWA</span>
</div>
<span class="font-display font-semibold text-xl text-bwa-gray-900">Office Rental</span>
</a>
</div>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center space-x-8">
<a href="{{ route('home') }}" class="nav-link {{ request()->routeIs('home') ? 'text-bwa-primary-600' : 'text-bwa-gray-600 hover:text-bwa-gray-900' }} font-medium transition-colors">
Beranda
</a>
<a href="{{ route('offices.index') }}" class="nav-link {{ request()->routeIs('offices.*') ? 'text-bwa-primary-600' : 'text-bwa-gray-600 hover:text-bwa-gray-900' }} font-medium transition-colors">
Cari Kantor
</a>
<a href="{{ route('about') }}" class="nav-link {{ request()->routeIs('about') ? 'text-bwa-primary-600' : 'text-bwa-gray-600 hover:text-bwa-gray-900' }} font-medium transition-colors">
Tentang Kami
</a>
<a href="{{ route('contact') }}" class="nav-link {{ request()->routeIs('contact') ? 'text-bwa-primary-600' : 'text-bwa-gray-600 hover:text-bwa-gray-900' }} font-medium transition-colors">
Kontak
</a>
</div>
<!-- User Menu -->
<div class="flex items-center space-x-4">
@auth
<div class="relative" x-data="{ open: false }">
<button @click="open = !open" class="flex items-center space-x-2 text-bwa-gray-600 hover:text-bwa-gray-900 focus:outline-none">
<img src="{{ auth()->user()->avatar_url }}" alt="{{ auth()->user()->name }}" class="w-8 h-8 rounded-full">
<span class="hidden sm:block font-medium">{{ auth()->user()->name }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div x-show="open" @click.away="open = false" x-transition class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-bwa-gray-200 py-1">
<a href="{{ route('dashboard') }}" class="block px-4 py-2 text-sm text-bwa-gray-700 hover:bg-bwa-gray-50">
Dashboard
</a>
<a href="{{ route('profile.edit') }}" class="block px-4 py-2 text-sm text-bwa-gray-700 hover:bg-bwa-gray-50">
Profil Saya
</a>
<a href="{{ route('bookings.history') }}" class="block px-4 py-2 text-sm text-bwa-gray-700 hover:bg-bwa-gray-50">
Riwayat Booking
</a>
<hr class="my-1">
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50">
Keluar
</button>
</form>
</div>
</div>
@else
<a href="{{ route('login') }}" class="text-bwa-gray-600 hover:text-bwa-gray-900 font-medium">
Masuk
</a>
<a href="{{ route('register') }}" class="btn-primary">
Daftar
</a>
@endauth
</div>
<!-- Mobile menu button -->
<div class="md:hidden">
<button type="button" class="text-bwa-gray-600 hover:text-bwa-gray-900 focus:outline-none" x-data="{ open: false }" @click="open = !open">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
<path x-show="open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
</div>
</nav>
Membuat Halaman Homepage dengan Daftar Kantor
Homepage adalah first impression aplikasi kita. Mari bikin homepage yang engaging dan informatif.
Bikin controller buat homepage:
php artisan make:controller Web/HomeController
Edit app/Http/Controllers/Web/HomeController.php:
<?php
namespace App\\Http\\Controllers\\Web;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Office;
use App\\Models\\OfficeFacility;
use Illuminate\\Http\\Request;
class HomeController extends Controller
{
public function index()
{
$featuredOffices = Office::with(['facilities'])
->featured()
->available()
->orderBy('rating', 'desc')
->take(6)
->get();
$popularCities = Office::select('city')
->selectRaw('COUNT(*) as office_count')
->available()
->groupBy('city')
->orderBy('office_count', 'desc')
->take(8)
->get();
$facilities = OfficeFacility::active()
->orderBy('name')
->get();
$stats = [
'total_offices' => Office::available()->count(),
'total_bookings' => \\App\\Models\\Booking::where('status', '!=', 'cancelled')->count(),
'happy_customers' => \\App\\Models\\User::whereHas('bookings', function($q) {
$q->where('status', 'completed');
})->count(),
'cities_covered' => Office::distinct('city')->count(),
];
return view('home', compact('featuredOffices', 'popularCities', 'facilities', 'stats'));
}
public function search(Request $request)
{
$query = Office::with(['facilities'])
->available();
if ($request->filled('city')) {
$query->where('city', 'like', '%' . $request->city . '%');
}
if ($request->filled('capacity')) {
$query->where('capacity', '>=', $request->capacity);
}
if ($request->filled('max_price')) {
$query->where('daily_price', '<=', $request->max_price);
}
if ($request->filled('facilities')) {
$facilityIds = is_array($request->facilities) ? $request->facilities : [$request->facilities];
$query->whereHas('facilities', function($q) use ($facilityIds) {
$q->whereIn('office_facility_id', $facilityIds);
});
}
$offices = $query->orderBy('rating', 'desc')
->paginate(12)
->withQueryString();
return view('offices.index', compact('offices'));
}
}
Bikin view homepage di resources/views/home.blade.php:
@extends('layouts.app')
@section('title', 'BWA Office Rental - Sewa Kantor Modern & Nyaman')
@section('meta_description', 'Temukan dan sewa ruang kantor modern yang sesuai kebutuhan bisnis Anda. Booking online mudah, lokasi strategis, fasilitas lengkap.')
@section('content')
<!-- Hero Section -->
<section class="relative bg-gradient-bwa overflow-hidden">
<div class="absolute inset-0 bg-black opacity-10"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
<div class="text-center text-white">
<h1 class="text-4xl md:text-6xl font-display font-bold mb-6 animate-fade-in">
Temukan Kantor
<span class="block text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-400">
Impian Anda
</span>
</h1>
<p class="text-xl md:text-2xl mb-8 text-blue-100 max-w-3xl mx-auto animate-fade-in">
Sewa ruang kantor modern dengan lokasi strategis, fasilitas lengkap, dan proses booking yang mudah.
</p>
<!-- Quick Search Form -->
<div class="bg-white rounded-2xl p-6 md:p-8 shadow-2xl max-w-4xl mx-auto animate-slide-up">
<form action="{{ route('offices.search') }}" method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">Kota</label>
<input type="text" name="city" placeholder="Cari kota..." class="form-input" value="{{ request('city') }}">
</div>
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">Kapasitas</label>
<select name="capacity" class="form-input">
<option value="">Pilih kapasitas</option>
<option value="5" {{ request('capacity') == '5' ? 'selected' : '' }}>5+ orang</option>
<option value="10" {{ request('capacity') == '10' ? 'selected' : '' }}>10+ orang</option>
<option value="20" {{ request('capacity') == '20' ? 'selected' : '' }}>20+ orang</option>
<option value="50" {{ request('capacity') == '50' ? 'selected' : '' }}>50+ orang</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">Budget Harian</label>
<select name="max_price" class="form-input">
<option value="">Pilih budget</option>
<option value="500000" {{ request('max_price') == '500000' ? 'selected' : '' }}>< Rp 500rb</option>
<option value="1000000" {{ request('max_price') == '1000000' ? 'selected' : '' }}>< Rp 1jt</option>
<option value="2000000" {{ request('max_price') == '2000000' ? 'selected' : '' }}>< Rp 2jt</option>
<option value="5000000" {{ request('max_price') == '5000000' ? 'selected' : '' }}>< Rp 5jt</option>
</select>
</div>
<div class="flex items-end">
<button type="submit" class="btn-primary w-full h-11">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
Cari Kantor
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Background decoration -->
<div class="absolute top-0 right-0 w-1/3 h-full opacity-20">
<svg viewBox="0 0 404 784" fill="none" xmlns="<http://www.w3.org/2000/svg>">
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="white" stroke-width="1"/>
</pattern>
</defs>
<rect width="404" height="784" fill="url(#grid)" />
</svg>
</div>
</section>
<!-- Stats Section -->
<section class="py-16 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
<div class="text-center">
<div class="text-3xl md:text-4xl font-bold text-bwa-primary-600 mb-2">{{ number_format($stats['total_offices']) }}+</div>
<div class="text-bwa-gray-600 font-medium">Kantor Tersedia</div>
</div>
<div class="text-center">
<div class="text-3xl md:text-4xl font-bold text-bwa-primary-600 mb-2">{{ number_format($stats['total_bookings']) }}+</div>
<div class="text-bwa-gray-600 font-medium">Booking Sukses</div>
</div>
<div class="text-center">
<div class="text-3xl md:text-4xl font-bold text-bwa-primary-600 mb-2">{{ number_format($stats['happy_customers']) }}+</div>
<div class="text-bwa-gray-600 font-medium">Customer Puas</div>
</div>
<div class="text-center">
<div class="text-3xl md:text-4xl font-bold text-bwa-primary-600 mb-2">{{ $stats['cities_covered'] }}+</div>
<div class="text-bwa-gray-600 font-medium">Kota Terjangkau</div>
</div>
</div>
</div>
</section>
<!-- Featured Offices -->
<section class="py-16 bg-bwa-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-display font-bold text-bwa-gray-900 mb-4">
Kantor Pilihan Terbaik
</h2>
<p class="text-xl text-bwa-gray-600 max-w-2xl mx-auto">
Temukan kantor-kantor premium dengan rating tertinggi dan fasilitas terlengkap untuk kebutuhan bisnis Anda.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
@foreach($featuredOffices as $office)
<div class="card hover:shadow-xl transition-shadow duration-300 group">
<div class="relative overflow-hidden">
<img src="{{ $office->main_image }}" alt="{{ $office->name }}" class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300">
<div class="absolute top-4 left-4">
<span class="badge bg-yellow-100 text-yellow-800">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
Featured
</span>
</div>
<div class="absolute top-4 right-4">
<span class="bg-white px-2 py-1 rounded-lg text-sm font-semibold text-bwa-gray-900">
{{ $office->rating }}/5.0
</span>
</div>
</div>
<div class="p-6">
<h3 class="font-display font-semibold text-lg mb-2 group-hover:text-bwa-primary-600 transition-colors">
{{ $office->name }}
</h3>
<p class="text-bwa-gray-600 text-sm mb-3">
📍 {{ $office->city }}, {{ $office->state }}
</p>
<p class="text-bwa-gray-600 text-sm mb-4 line-clamp-2">
{{ Str::limit($office->description, 100) }}
</p>
<div class="flex items-center justify-between mb-4">
<div class="text-sm text-bwa-gray-500">
👥 {{ $office->capacity }} orang
</div>
<div class="text-sm text-bwa-gray-500">
📝 {{ $office->total_reviews }} review
</div>
</div>
<div class="flex items-center justify-between">
<div>
<div class="text-2xl font-bold text-bwa-primary-600">
{{ $office->formatted_daily_price }}
</div>
<div class="text-sm text-bwa-gray-500">per hari</div>
</div>
<a href="{{ route('offices.show', $office->slug) }}" class="btn-primary">
Lihat Detail
</a>
</div>
</div>
</div>
@endforeach
</div>
<div class="text-center mt-12">
<a href="{{ route('offices.index') }}" class="btn-secondary">
Lihat Semua Kantor
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
</a>
</div>
</div>
</section>
<!-- Popular Cities -->
<section class="py-16 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-display font-bold text-bwa-gray-900 mb-4">
Kota Populer
</h2>
<p class="text-xl text-bwa-gray-600">
Jelajahi kantor-kantor terbaik di kota-kota besar Indonesia
</p>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
@foreach($popularCities as $city)
<a href="{{ route('offices.index', ['city' => $city->city]) }}" class="group">
<div class="card hover:shadow-lg transition-all duration-300 p-6 text-center group-hover:scale-105">
<div class="text-2xl mb-2">🏙️</div>
<h3 class="font-semibold text-lg text-bwa-gray-900 mb-1">{{ $city->city }}</h3>
<p class="text-sm text-bwa-gray-600">{{ $city->office_count }} kantor tersedia</p>
</div>
</a>
@endforeach
</div>
</div>
</section>
<!-- Features Section -->
<section class="py-16 bg-bwa-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-display font-bold text-bwa-gray-900 mb-4">
Kenapa Pilih BWA Office Rental?
</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="text-center group">
<div class="w-16 h-16 bg-bwa-primary-100 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:bg-bwa-primary-200 transition-colors">
<svg class="w-8 h-8 text-bwa-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<h3 class="font-semibold text-lg mb-2">Booking Instan</h3>
<p class="text-bwa-gray-600">Proses booking yang cepat dan mudah, konfirmasi langsung dalam hitungan menit.</p>
</div>
<div class="text-center group">
<div class="w-16 h-16 bg-bwa-primary-100 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:bg-bwa-primary-200 transition-colors">
<svg class="w-8 h-8 text-bwa-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<h3 class="font-semibold text-lg mb-2">Kualitas Terjamin</h3>
<p class="text-bwa-gray-600">Semua kantor telah melalui verifikasi ketat untuk memastikan kualitas terbaik.</p>
</div>
<div class="text-center group">
<div class="w-16 h-16 bg-bwa-primary-100 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:bg-bwa-primary-200 transition-colors">
<svg class="w-8 h-8 text-bwa-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M12 2.25a9.75 9.75 0 1 0 0 19.5 9.75 9.75 0 0 0 0-19.5Z"></path>
</svg>
</div>
<h3 class="font-semibold text-lg mb-2">Support 24/7</h3>
<p class="text-bwa-gray-600">Tim customer service siap membantu Anda kapan saja, di mana saja.</p>
</div>
</div>
</div>
</section>
@endsection
@push('scripts')
<script src="<https://unpkg.com/[email protected]/dist/cdn.min.js>" defer></script>
@endpush
Dengan setup ini, kita udah punya foundation yang solid buat frontend aplikasi. Homepage udah responsive, interactive, dan optimized buat user experience yang excellent.
Next step kita bakal lanjutin dengan halaman detail kantor, booking system, dan integreasi dengan backend API yang udah kta bikin!
Membuat Halaman Detail Kantor dan Sistem Booking: Experience yang Seamless
Sekarang kita lanjutin dengan bikin halaman-halaman yang lebih spesifik dan interaktif! Di bagian ini kita bakal create halaman detail kantor yang comprehensive, halaman checkout yang user-friendly, dan form upload bukti pembayaran yang secure.
Bagian ini adalah jantung dari user experience aplikasi kita - dimana customer bener-bener make keputusan dan melakukan transaksi. Jadi kita harus bikin sebaik mungkin!
Membuat Halaman Detail Kantor dengan Galeri Foto
Detail kantor adalah halaman yang paling crucial buat conversion. Customer perlu liat semua informasi lengkap sebelum decide buat booking.
Pertama, update controller OfficeController buat handle detail page:
<?php
namespace App\\Http\\Controllers\\Web;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Office;
use App\\Models\\Booking;
use Illuminate\\Http\\Request;
use Carbon\\Carbon;
class OfficeController extends Controller
{
public function index(Request $request)
{
$query = Office::with(['facilities', 'reviews' => function($q) {
$q->approved()->latest()->take(3);
}])->available();
// Apply filters
if ($request->filled('city')) {
$query->inCity($request->city);
}
if ($request->filled('capacity')) {
$query->withCapacity($request->capacity);
}
if ($request->filled('max_price')) {
$query->where('daily_price', '<=', $request->max_price);
}
if ($request->filled('min_rating')) {
$query->highRated($request->min_rating);
}
if ($request->filled('facilities')) {
$facilityIds = is_array($request->facilities) ? $request->facilities : explode(',', $request->facilities);
$query->whereHas('facilities', function($q) use ($facilityIds) {
$q->whereIn('office_facilities.id', $facilityIds);
});
}
// Sorting
$sortBy = $request->get('sort', 'rating');
switch ($sortBy) {
case 'price_low':
$query->orderBy('daily_price', 'asc');
break;
case 'price_high':
$query->orderBy('daily_price', 'desc');
break;
case 'newest':
$query->orderBy('created_at', 'desc');
break;
default:
$query->orderBy('rating', 'desc');
}
$offices = $query->paginate(12)->withQueryString();
// Get filter options
$cities = Office::distinct('city')->orderBy('city')->pluck('city');
$facilities = \\App\\Models\\OfficeFacility::active()->get();
return view('offices.index', compact('offices', 'cities', 'facilities'));
}
public function show($slug)
{
$office = Office::with([
'facilities',
'reviews' => function($q) {
$q->approved()->with('user')->latest();
}
])->where('slug', $slug)->firstOrFail();
// Check if office is available
if (!$office->is_available) {
abort(404, 'Office is currently not available for booking.');
}
// Get similar offices
$similarOffices = Office::available()
->where('id', '!=', $office->id)
->where('city', $office->city)
->orderBy('rating', 'desc')
->take(3)
->get();
// Get booking calendar data for next 30 days
$bookingDates = $this->getBookingCalendar($office->id);
return view('offices.show', compact('office', 'similarOffices', 'bookingDates'));
}
private function getBookingCalendar($officeId)
{
$startDate = Carbon::today();
$endDate = Carbon::today()->addDays(60);
$bookings = Booking::where('office_id', $officeId)
->where('status', '!=', 'cancelled')
->whereBetween('start_date', [$startDate, $endDate])
->get(['start_date', 'end_date']);
$bookedDates = [];
foreach ($bookings as $booking) {
$current = Carbon::parse($booking->start_date);
$end = Carbon::parse($booking->end_date);
while ($current->lte($end)) {
$bookedDates[] = $current->format('Y-m-d');
$current->addDay();
}
}
return array_unique($bookedDates);
}
public function checkAvailability(Request $request, Office $office)
{
$startDate = $request->start_date;
$endDate = $request->end_date;
if (!$startDate || !$endDate) {
return response()->json(['available' => false, 'message' => 'Please select both start and end dates']);
}
$isAvailable = $office->isAvailableForDates($startDate, $endDate);
if ($isAvailable) {
// Calculate pricing
$days = Carbon::parse($endDate)->diffInDays(Carbon::parse($startDate)) + 1;
$subtotal = $office->daily_price * $days;
$tax = $subtotal * 0.1; // 10% tax
$total = $subtotal + $tax;
return response()->json([
'available' => true,
'days' => $days,
'subtotal' => $subtotal,
'tax' => $tax,
'total' => $total,
'formatted' => [
'subtotal' => 'Rp ' . number_format($subtotal, 0, ',', '.'),
'tax' => 'Rp ' . number_format($tax, 0, ',', '.'),
'total' => 'Rp ' . number_format($total, 0, ',', '.'),
]
]);
} else {
return response()->json(['available' => false, 'message' => 'Office is not available for selected dates']);
}
}
}
Sekarang bikin view untuk detail office di resources/views/offices/show.blade.php:
@extends('layouts.app')
@section('title', $office->name . ' - BWA Office Rental')
@section('meta_description', Str::limit(strip_tags($office->description), 160))
@push('styles')
<link rel="stylesheet" href="<https://unpkg.com/swiper@8/swiper-bundle.min.css>" />
<style>
.swiper-pagination-bullet-active {
background-color: #3b82f6 !important;
}
.lightbox {
display: none;
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.9);
}
.lightbox.active {
display: flex;
align-items: center;
justify-content: center;
}
.lightbox img {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
</style>
@endpush
@section('content')
<div class="bg-white">
<!-- Breadcrumb -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<nav class="flex" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm">
<li><a href="{{ route('home') }}" class="text-bwa-gray-500 hover:text-bwa-gray-700">Beranda</a></li>
<li><span class="text-bwa-gray-400">/</span></li>
<li><a href="{{ route('offices.index') }}" class="text-bwa-gray-500 hover:text-bwa-gray-700">Cari Kantor</a></li>
<li><span class="text-bwa-gray-400">/</span></li>
<li><span class="text-bwa-gray-900 font-medium">{{ $office->name }}</span></li>
</ol>
</nav>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-16">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Left Content -->
<div class="lg:col-span-2">
<!-- Office Images Gallery -->
<div class="mb-8">
<div class="swiper office-gallery rounded-xl overflow-hidden">
<div class="swiper-wrapper">
@foreach($office->image_urls as $index => $image)
<div class="swiper-slide">
<img src="{{ $image }}" alt="{{ $office->name }} - Photo {{ $index + 1 }}"
class="w-full h-80 lg:h-96 object-cover cursor-pointer gallery-image"
data-index="{{ $index }}">
</div>
@endforeach
</div>
<div class="swiper-pagination"></div>
<div class="swiper-button-next"></div>
<div class="swiper-button-prev"></div>
</div>
<!-- Thumbnail Navigation -->
<div class="grid grid-cols-4 gap-2 mt-4">
@foreach(array_slice($office->image_urls, 0, 4) as $index => $image)
<img src="{{ $image }}" alt="Thumbnail {{ $index + 1 }}"
class="w-full h-20 object-cover rounded-lg cursor-pointer border-2 border-transparent hover:border-bwa-primary-500 transition-colors thumbnail-image {{ $index === 0 ? 'border-bwa-primary-500' : '' }}"
data-index="{{ $index }}">
@endforeach
</div>
</div>
<!-- Office Info -->
<div class="mb-8">
<div class="flex items-start justify-between mb-4">
<div>
<h1 class="text-3xl font-display font-bold text-bwa-gray-900 mb-2">{{ $office->name }}</h1>
<div class="flex items-center space-x-4 text-bwa-gray-600">
<div class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
{{ $office->city }}, {{ $office->state }}
</div>
<div class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"></path>
</svg>
Kapasitas {{ $office->capacity }} orang
</div>
@if($office->area_size)
<div class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"></path>
</svg>
{{ $office->area_size }} m²
</div>
@endif
</div>
</div>
<div class="text-right">
@if($office->rating > 0)
<div class="flex items-center justify-end mb-1">
<div class="flex items-center text-yellow-400 mr-2">
@for($i = 1; $i <= 5; $i++)
<svg class="w-4 h-4 {{ $i <= $office->rating ? 'fill-current' : 'fill-gray-300' }}" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
@endfor
</div>
<span class="text-sm font-medium text-bwa-gray-700">{{ number_format($office->rating, 1) }}</span>
</div>
<div class="text-sm text-bwa-gray-500">{{ $office->total_reviews }} ulasan</div>
@endif
</div>
</div>
</div>
<!-- Description -->
<div class="mb-8">
<h2 class="text-xl font-semibold text-bwa-gray-900 mb-4">Deskripsi</h2>
<div class="prose prose-bwa max-w-none">
{!! nl2br(e($office->description)) !!}
</div>
</div>
<!-- Facilities -->
<div class="mb-8">
<h2 class="text-xl font-semibold text-bwa-gray-900 mb-4">Fasilitas</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
@foreach($office->facilities as $facility)
<div class="flex items-center space-x-3 p-3 bg-bwa-gray-50 rounded-lg">
<div class="w-8 h-8 bg-bwa-primary-100 rounded-lg flex items-center justify-center">
<span class="text-bwa-primary-600 text-sm">
@if($facility->icon)
<i class="fas fa-{{ $facility->icon }}"></i>
@else
✓
@endif
</span>
</div>
<span class="font-medium text-bwa-gray-900">{{ $facility->name }}</span>
</div>
@endforeach
</div>
</div>
<!-- Location & Contact -->
<div class="mb-8">
<h2 class="text-xl font-semibold text-bwa-gray-900 mb-4">Lokasi & Kontak</h2>
<div class="bg-bwa-gray-50 rounded-xl p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="font-medium text-bwa-gray-900 mb-2">Alamat</h3>
<p class="text-bwa-gray-600 mb-4">{{ $office->address }}</p>
@if($office->contact_person || $office->contact_phone)
<h3 class="font-medium text-bwa-gray-900 mb-2">Kontak</h3>
@if($office->contact_person)
<p class="text-bwa-gray-600">{{ $office->contact_person }}</p>
@endif
@if($office->contact_phone)
<p class="text-bwa-gray-600">{{ $office->contact_phone }}</p>
@endif
@endif
</div>
@if($office->opening_hours)
<div>
<h3 class="font-medium text-bwa-gray-900 mb-2">Jam Operasional</h3>
<div class="space-y-1">
@foreach($office->opening_hours as $day => $hours)
<div class="flex justify-between text-sm">
<span class="text-bwa-gray-600">{{ $day }}</span>
<span class="font-medium text-bwa-gray-900">{{ $hours }}</span>
</div>
@endforeach
</div>
</div>
@endif
</div>
</div>
</div>
<!-- Reviews -->
@if($office->reviews->count() > 0)
<div class="mb-8">
<h2 class="text-xl font-semibold text-bwa-gray-900 mb-4">
Ulasan Customer ({{ $office->total_reviews }})
</h2>
<div class="space-y-6">
@foreach($office->reviews->take(5) as $review)
<div class="border-b border-bwa-gray-200 pb-6 last:border-b-0">
<div class="flex items-start space-x-4">
<img src="{{ $review->user->avatar_url }}" alt="{{ $review->reviewer_name }}" class="w-10 h-10 rounded-full">
<div class="flex-1">
<div class="flex items-center justify-between mb-2">
<h4 class="font-medium text-bwa-gray-900">{{ $review->reviewer_name }}</h4>
<div class="flex items-center space-x-1 text-yellow-400">
@for($i = 1; $i <= 5; $i++)
<svg class="w-4 h-4 {{ $i <= $review->rating ? 'fill-current' : 'fill-gray-300' }}" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
@endfor
</div>
</div>
@if($review->title)
<h5 class="font-medium text-bwa-gray-900 mb-1">{{ $review->title }}</h5>
@endif
<p class="text-bwa-gray-600 mb-2">{{ $review->comment }}</p>
<div class="text-sm text-bwa-gray-500">{{ $review->created_at->diffForHumans() }}</div>
</div>
</div>
</div>
@endforeach
</div>
@if($office->reviews->count() > 5)
<div class="text-center mt-6">
<button class="btn-secondary" onclick="loadMoreReviews()">
Lihat Semua Ulasan
</button>
</div>
@endif
</div>
@endif
</div>
<!-- Right Sidebar - Booking Form -->
<div class="lg:col-span-1">
<div class="sticky top-24">
<div class="card p-6">
<div class="text-center mb-6">
<div class="text-3xl font-bold text-bwa-primary-600 mb-1">
{{ $office->formatted_daily_price }}
</div>
<div class="text-sm text-bwa-gray-500">per hari</div>
</div>
<!-- Booking Form -->
<form id="booking-form" action="{{ route('bookings.create') }}" method="GET">
<input type="hidden" name="office_id" value="{{ $office->id }}">
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">Check-in</label>
<input type="date" name="start_date" id="start_date"
class="form-input"
min="{{ date('Y-m-d', strtotime('+1 day')) }}"
required>
</div>
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">Check-out</label>
<input type="date" name="end_date" id="end_date"
class="form-input"
min="{{ date('Y-m-d', strtotime('+2 days')) }}"
required>
</div>
</div>
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">Jumlah Peserta</label>
<select name="participant_count" class="form-input" required>
@for($i = 1; $i <= $office->capacity; $i++)
<option value="{{ $i }}">{{ $i }} orang</option>
@endfor
</select>
</div>
<!-- Price Calculation -->
<div id="price-calculation" class="hidden bg-bwa-gray-50 rounded-lg p-4">
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-bwa-gray-600">Subtotal (<span id="days-count">0</span> hari)</span>
<span id="subtotal-amount" class="font-medium">Rp 0</span>
</div>
<div class="flex justify-between">
<span class="text-bwa-gray-600">Pajak (10%)</span>
<span id="tax-amount" class="font-medium">Rp 0</span>
</div>
<hr class="my-2">
<div class="flex justify-between text-lg font-bold">
<span>Total</span>
<span id="total-amount" class="text-bwa-primary-600">Rp 0</span>
</div>
</div>
</div>
<button type="submit" id="booking-button" class="btn-primary w-full" disabled>
Pilih Tanggal Terlebih Dahulu
</button>
</div>
</form>
<!-- Trust Signals -->
<div class="mt-6 pt-6 border-t border-bwa-gray-200">
<div class="space-y-3 text-sm text-bwa-gray-600">
<div class="flex items-center">
<svg class="w-4 h-4 mr-2 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Gratis pembatalan 24 jam sebelumnya
</div>
<div class="flex items-center">
<svg class="w-4 h-4 mr-2 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Konfirmasi instan
</div>
<div class="flex items-center">
<svg class="w-4 h-4 mr-2 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Support 24/7
</div>
</div>
</div>
</div>
<!-- Contact Owner -->
<div class="card p-6 mt-6">
<h3 class="font-semibold text-bwa-gray-900 mb-4">Ada Pertanyaan?</h3>
<p class="text-sm text-bwa-gray-600 mb-4">
Hubungi kami untuk informasi lebih lanjut atau konsultasi kebutuhan ruang kantor Anda.
</p>
<a href="<https://wa.me/>{{ preg_replace('/[^0-9]/', '', $office->contact_phone ?? '628123456789') }}"
target="_blank"
class="btn-secondary w-full flex items-center justify-center">
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0020.885 3.488"/>
</svg>
Hubungi via WhatsApp
</a>
</div>
</div>
</div>
</div>
<!-- Similar Offices -->
@if($similarOffices->count() > 0)
<div class="mt-16">
<h2 class="text-2xl font-display font-bold text-bwa-gray-900 mb-8">Kantor Serupa di {{ $office->city }}</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
@foreach($similarOffices as $similar)
<div class="card hover:shadow-lg transition-shadow duration-300">
<img src="{{ $similar->main_image }}" alt="{{ $similar->name }}" class="w-full h-48 object-cover">
<div class="p-4">
<h3 class="font-semibold text-lg mb-2">{{ $similar->name }}</h3>
<p class="text-bwa-gray-600 text-sm mb-3">{{ Str::limit($similar->description, 80) }}</p>
<div class="flex items-center justify-between">
<div class="text-lg font-bold text-bwa-primary-600">
{{ $similar->formatted_daily_price }}
</div>
<a href="{{ route('offices.show', $similar->slug) }}" class="btn-primary text-sm py-1 px-3">
Lihat Detail
</a>
</div>
</div>
</div>
@endforeach
</div>
</div>
@endif
</div>
</div>
<!-- Lightbox -->
<div id="lightbox" class="lightbox" onclick="closeLightbox()">
<img id="lightbox-image" src="" alt="">
<button onclick="closeLightbox()" class="absolute top-4 right-4 text-white text-2xl hover:text-gray-300">
×
</button>
</div>
@endsection
@push('scripts')
<script src="<https://unpkg.com/swiper@8/swiper-bundle.min.js>"></script>
<script src="<https://unpkg.com/[email protected]/dist/cdn.min.js>" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Swiper
const swiper = new Swiper('.office-gallery', {
slidesPerView: 1,
spaceBetween: 0,
loop: true,
pagination: {
el: '.swiper-pagination',
clickable: true,
},
navigation: {
nextEl: '.swiper-button-next',
prevEl: '.swiper-button-prev',
},
});
// Gallery lightbox
const galleryImages = document.querySelectorAll('.gallery-image');
const lightbox = document.getElementById('lightbox');
const lightboxImage = document.getElementById('lightbox-image');
galleryImages.forEach((img, index) => {
img.addEventListener('click', () => {
lightboxImage.src = img.src;
lightbox.classList.add('active');
});
});
// Thumbnail navigation
const thumbnails = document.querySelectorAll('.thumbnail-image');
thumbnails.forEach((thumb, index) => {
thumb.addEventListener('click', () => {
swiper.slideTo(index);
thumbnails.forEach(t => t.classList.remove('border-bwa-primary-500'));
thumb.classList.add('border-bwa-primary-500');
});
});
// Booking form functionality
const startDateInput = document.getElementById('start_date');
const endDateInput = document.getElementById('end_date');
const bookingButton = document.getElementById('booking-button');
const priceCalculation = document.getElementById('price-calculation');
const bookedDates = @json($bookingDates);
function updateDateConstraints() {
const startDate = startDateInput.value;
if (startDate) {
const minEndDate = new Date(startDate);
minEndDate.setDate(minEndDate.getDate() + 1);
endDateInput.min = minEndDate.toISOString().split('T')[0];
}
}
function checkAvailability() {
const startDate = startDateInput.value;
const endDate = endDateInput.value;
if (!startDate || !endDate) {
bookingButton.disabled = true;
bookingButton.textContent = 'Pilih Tanggal Terlebih Dahulu';
priceCalculation.classList.add('hidden');
return;
}
// Check if dates are booked
const start = new Date(startDate);
const end = new Date(endDate);
let isBooked = false;
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
if (bookedDates.includes(dateStr)) {
isBooked = true;
break;
}
}
if (isBooked) {
bookingButton.disabled = true;
bookingButton.textContent = 'Tanggal Tidak Tersedia';
bookingButton.classList.add('bg-red-500', 'hover:bg-red-600');
bookingButton.classList.remove('bg-bwa-primary-600', 'hover:bg-bwa-primary-700');
priceCalculation.classList.add('hidden');
return;
}
// Calculate price
fetch(`{{ route('offices.check-availability', $office->id) }}?start_date=${startDate}&end_date=${endDate}`)
.then(response => response.json())
.then(data => {
if (data.available) {
document.getElementById('days-count').textContent = data.days;
document.getElementById('subtotal-amount').textContent = data.formatted.subtotal;
document.getElementById('tax-amount').textContent = data.formatted.tax;
document.getElementById('total-amount').textContent = data.formatted.total;
priceCalculation.classList.remove('hidden');
bookingButton.disabled = false;
bookingButton.textContent = 'Lanjutkan Booking';
bookingButton.classList.remove('bg-red-500', 'hover:bg-red-600');
bookingButton.classList.add('bg-bwa-primary-600', 'hover:bg-bwa-primary-700');
} else {
bookingButton.disabled = true;
bookingButton.textContent = data.message || 'Tidak Tersedia';
priceCalculation.classList.add('hidden');
}
})
.catch(error => {
console.error('Error:', error);
bookingButton.disabled = true;
bookingButton.textContent = 'Error memeriksa ketersediaan';
});
}
startDateInput.addEventListener('change', () => {
updateDateConstraints();
checkAvailability();
});
endDateInput.addEventListener('change', checkAvailability);
// Disable booked dates
function disableBookedDates() {
const today = new Date();
const maxDate = new Date();
maxDate.setDate(maxDate.getDate() + 365); // 1 year ahead
startDateInput.setAttribute('max', maxDate.toISOString().split('T')[0]);
endDateInput.setAttribute('max', maxDate.toISOString().split('T')[0]);
}
disableBookedDates();
});
function closeLightbox() {
document.getElementById('lightbox').classList.remove('active');
}
function loadMoreReviews() {
// Implementation untuk load more reviews via AJAX
console.log('Loading more reviews...');
}
</script>
@endpush
Membuat Halaman Checkout dengan Form Booking
Sekarang kita bikin halaman checkout yang comprehensive dengan form booking yang user-friendly.
Bikin controller untuk booking:
php artisan make:controller Web/BookingController
Edit app/Http/Controllers/Web/BookingController.php:
<?php
namespace App\\Http\\Controllers\\Web;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Office;
use App\\Models\\Booking;
use App\\Models\\Payment;
use Illuminate\\Http\\Request;
use Carbon\\Carbon;
use Illuminate\\Support\\Facades\\Auth;
use Illuminate\\Support\\Facades\\DB;
class BookingController extends Controller
{
public function create(Request $request)
{
$request->validate([
'office_id' => 'required|exists:offices,id',
'start_date' => 'required|date|after:today',
'end_date' => 'required|date|after:start_date',
'participant_count' => 'required|integer|min:1',
]);
$office = Office::findOrFail($request->office_id);
// Verify office is available
if (!$office->is_available) {
return redirect()->back()->with('error', 'Kantor ini sedang tidak tersedia untuk booking.');
}
// Check participant count
if ($request->participant_count > $office->capacity) {
return redirect()->back()->with('error', 'Jumlah peserta melebihi kapasitas kantor.');
}
// Check date availability
if (!$office->isAvailableForDates($request->start_date, $request->end_date)) {
return redirect()->back()->with('error', 'Kantor tidak tersedia untuk tanggal yang dipilih.');
}
// Calculate pricing
$startDate = Carbon::parse($request->start_date);
$endDate = Carbon::parse($request->end_date);
$totalDays = $endDate->diffInDays($startDate) + 1;
$subtotal = $office->daily_price * $totalDays;
$tax = $subtotal * 0.1; // 10% tax
$total = $subtotal + $tax;
$bookingData = [
'office' => $office,
'start_date' => $startDate,
'end_date' => $endDate,
'participant_count' => $request->participant_count,
'total_days' => $totalDays,
'subtotal' => $subtotal,
'tax' => $tax,
'total' => $total,
'purpose' => $request->purpose,
];
return view('bookings.create', $bookingData);
}
public function store(Request $request)
{
$request->validate([
'office_id' => 'required|exists:offices,id',
'start_date' => 'required|date|after:today',
'end_date' => 'required|date|after:start_date',
'participant_count' => 'required|integer|min:1',
'purpose' => 'nullable|string|max:255',
'special_requests' => 'nullable|string|max:1000',
'duration_type' => 'required|in:hourly,daily,monthly',
]);
$office = Office::findOrFail($request->office_id);
// Double check availability
if (!$office->isAvailableForDates($request->start_date, $request->end_date)) {
return redirect()->back()->with('error', 'Kantor tidak tersedia untuk tanggal yang dipilih.');
}
DB::beginTransaction();
try {
// Calculate pricing
$startDate = Carbon::parse($request->start_date);
$endDate = Carbon::parse($request->end_date);
$totalDays = $endDate->diffInDays($startDate) + 1;
$subtotal = $office->daily_price * $totalDays;
$tax = $subtotal * 0.1;
$total = $subtotal + $tax;
// Create booking
$booking = Booking::create([
'booking_code' => 'BWA' . strtoupper(uniqid()),
'user_id' => Auth::id(),
'office_id' => $office->id,
'start_date' => $startDate,
'end_date' => $endDate,
'duration_type' => $request->duration_type,
'total_days' => $totalDays,
'participant_count' => $request->participant_count,
'purpose' => $request->purpose,
'special_requests' => $request->special_requests,
'subtotal' => $subtotal,
'tax_amount' => $tax,
'total_amount' => $total,
'status' => 'pending',
]);
// Create payment record
$payment = Payment::create([
'booking_id' => $booking->id,
'payment_method' => 'bank_transfer', // default for now
'amount' => $total,
'status' => 'pending',
]);
DB::commit();
return redirect()->route('bookings.payment', $booking->booking_code)
->with('success', 'Booking berhasil dibuat! Silakan lakukan pembayaran.');
} catch (\\Exception $e) {
DB::rollback();
return redirect()->back()->with('error', 'Terjadi kesalahan. Silakan coba lagi.');
}
}
public function payment($bookingCode)
{
$booking = Booking::with(['office', 'payment'])
->where('booking_code', $bookingCode)
->where('user_id', Auth::id())
->firstOrFail();
if ($booking->status !== 'pending') {
return redirect()->route('dashboard')->with('info', 'Booking ini sudah diproses.');
}
return view('bookings.payment', compact('booking'));
}
public function uploadPayment(Request $request, $bookingCode)
{
$request->validate([
'payment_proof' => 'required|image|mimes:jpeg,png,jpg,pdf|max:2048',
'payment_date' => 'required|date',
'reference_number' => 'nullable|string|max:100',
'notes' => 'nullable|string|max:500',
]);
$booking = Booking::with('payment')
->where('booking_code', $bookingCode)
->where('user_id', Auth::id())
->firstOrFail();
if ($booking->payment->status !== 'pending') {
return redirect()->back()->with('error', 'Pembayaran sudah diproses sebelumnya.');
}
// Upload payment proof
$proofPath = $request->file('payment_proof')->store('payment-proofs', 'public');
// Update payment record
$booking->payment->update([
'payment_proof' => $proofPath,
'payment_date' => $request->payment_date,
'reference_number' => $request->reference_number,
'notes' => $request->notes,
'status' => 'pending', // still pending until admin verification
]);
return redirect()->route('dashboard')
->with('success', 'Bukti pembayaran berhasil diunggah. Kami akan memverifikasi dalam 1x24 jam.');
}
}
Bikin view untuk halaman checkout di resources/views/bookings/create.blade.php:
@extends('layouts.app')
@section('title', 'Checkout Booking - BWA Office Rental')
@section('content')
<div class="min-h-screen bg-bwa-gray-50 py-8">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Progress Indicator -->
<div class="mb-8">
<div class="flex items-center justify-center">
<div class="flex items-center space-x-4">
<div class="flex items-center">
<div class="w-8 h-8 bg-bwa-primary-600 text-white rounded-full flex items-center justify-center text-sm font-medium">1</div>
<span class="ml-2 text-sm font-medium text-bwa-primary-600">Detail Booking</span>
</div>
<div class="w-12 h-0.5 bg-bwa-gray-300"></div>
<div class="flex items-center">
<div class="w-8 h-8 bg-bwa-gray-300 text-bwa-gray-600 rounded-full flex items-center justify-center text-sm font-medium">2</div>
<span class="ml-2 text-sm font-medium text-bwa-gray-600">Pembayaran</span>
</div>
<div class="w-12 h-0.5 bg-bwa-gray-300"></div>
<div class="flex items-center">
<div class="w-8 h-8 bg-bwa-gray-300 text-bwa-gray-600 rounded-full flex items-center justify-center text-sm font-medium">3</div>
<span class="ml-2 text-sm font-medium text-bwa-gray-600">Konfirmasi</span>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Booking Form -->
<div class="lg:col-span-2">
<div class="card p-6">
<h1 class="text-2xl font-display font-bold text-bwa-gray-900 mb-6">Detail Booking</h1>
<form action="{{ route('bookings.store') }}" method="POST" id="booking-form">
@csrf
<input type="hidden" name="office_id" value="{{ $office->id }}">
<input type="hidden" name="start_date" value="{{ $start_date->format('Y-m-d') }}">
<input type="hidden" name="end_date" value="{{ $end_date->format('Y-m-d') }}">
<input type="hidden" name="participant_count" value="{{ $participant_count }}">
<input type="hidden" name="duration_type" value="daily">
<!-- Personal Information -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-bwa-gray-900 mb-4">Informasi Pemesan</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">Nama Lengkap</label>
<input type="text" value="{{ auth()->user()->name }}" class="form-input bg-bwa-gray-50" readonly>
</div>
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">Email</label>
<input type="email" value="{{ auth()->user()->email }}" class="form-input bg-bwa-gray-50" readonly>
</div>
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">Nomor Telepon</label>
<input type="tel" value="{{ auth()->user()->phone }}" class="form-input bg-bwa-gray-50" readonly>
</div>
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">Jumlah Peserta</label>
<input type="number" value="{{ $participant_count }}" class="form-input bg-bwa-gray-50" readonly>
</div>
</div>
</div>
<!-- Booking Details -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-bwa-gray-900 mb-4">Detail Acara</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">Tujuan Booking</label>
<select name="purpose" class="form-input" required>
<option value="">Pilih tujuan booking</option>
<option value="meeting">Meeting/Rapat</option>
<option value="workshop">Workshop/Training</option>
<option value="seminar">Seminar/Presentasi</option>
<option value="coworking">Coworking</option>
<option value="event">Event/Acara</option>
<option value="other">Lainnya</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">Permintaan Khusus (Opsional)</label>
<textarea name="special_requests" rows="3" class="form-input"
placeholder="Contoh: Butuh proyektor tambahan, catering halal, akses parkir untuk 5 mobil, dll."></textarea>
</div>
</div>
</div>
<!-- Terms and Conditions -->
<div class="mb-6">
<div class="flex items-start">
<input type="checkbox" id="terms" required class="mt-1 h-4 w-4 text-bwa-primary-600 focus:ring-bwa-primary-500 border-bwa-gray-300 rounded">
<label for="terms" class="ml-2 text-sm text-bwa-gray-600">
Saya setuju dengan <a href="#" class="text-bwa-primary-600 hover:underline">syarat dan ketentuan</a> serta
<a href="#" class="text-bwa-primary-600 hover:underline">kebijakan privasi</a> BWA Office Rental.
</label>
</div>
</div>
<div class="flex items-center justify-between">
<a href="{{ route('offices.show', $office->slug) }}" class="btn-secondary">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Kembali
</a>
<button type="submit" class="btn-primary">
Lanjutkan ke Pembayaran
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
</button>
</div>
</form>
</div>
</div>
<!-- Booking Summary -->
<div class="lg:col-span-1">
<div class="sticky top-24">
<div class="card p-6">
<h2 class="text-lg font-semibold text-bwa-gray-900 mb-4">Ringkasan Booking</h2>
<!-- Office Info -->
<div class="flex items-start space-x-3 mb-6">
<img src="{{ $office->main_image }}" alt="{{ $office->name }}" class="w-16 h-16 rounded-lg object-cover">
<div class="flex-1">
<h3 class="font-medium text-bwa-gray-900">{{ $office->name }}</h3>
<p class="text-sm text-bwa-gray-600">{{ $office->city }}, {{ $office->state }}</p>
<div class="flex items-center mt-1">
<svg class="w-4 h-4 text-yellow-400 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
<span class="text-sm text-bwa-gray-600">{{ $office->rating }}/5.0</span>
</div>
</div>
</div>
<!-- Booking Details -->
<div class="space-y-3 mb-6">
<div class="flex justify-between text-sm">
<span class="text-bwa-gray-600">Check-in</span>
<span class="font-medium">{{ $start_date->format('d M Y') }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-bwa-gray-600">Check-out</span>
<span class="font-medium">{{ $end_date->format('d M Y') }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-bwa-gray-600">Durasi</span>
<span class="font-medium">{{ $total_days }} hari</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-bwa-gray-600">Peserta</span>
<span class="font-medium">{{ $participant_count }} orang</span>
</div>
</div>
<!-- Price Breakdown -->
<div class="border-t border-bwa-gray-200 pt-4">
<div class="space-y-2 mb-4">
<div class="flex justify-between text-sm">
<span class="text-bwa-gray-600">{{ $office->formatted_daily_price }} × {{ $total_days }} hari</span>
<span class="font-medium">Rp {{ number_format($subtotal, 0, ',', '.') }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-bwa-gray-600">Pajak (10%)</span>
<span class="font-medium">Rp {{ number_format($tax, 0, ',', '.') }}</span>
</div>
</div>
<div class="border-t border-bwa-gray-200 pt-4">
<div class="flex justify-between text-lg font-bold">
<span>Total</span>
<span class="text-bwa-primary-600">Rp {{ number_format($total, 0, ',', '.') }}</span>
</div>
</div>
</div>
<!-- Trust Signals -->
<div class="mt-6 pt-4 border-t border-bwa-gray-200">
<div class="space-y-2 text-xs text-bwa-gray-600">
<div class="flex items-center">
<svg class="w-3 h-3 mr-2 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Customer support 24/7
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('booking-form');
const submitButton = form.querySelector('button[type="submit"]');
form.addEventListener('submit', function(e) {
submitButton.disabled = true;
submitButton.innerHTML = `
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="<http://www.w3.org/2000/svg>" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Memproses...
`;
});
});
</script>
@endpush
Membuat Form Upload Bukti Pembayaran
Sekarang kita bikin halaman payment dengan form upload bukti pembayaran yang secure dan user-friendly.
Bikin view untuk halaman payment di resources/views/bookings/payment.blade.php:
@extends('layouts.app')
@section('title', 'Pembayaran - BWA Office Rental')
@section('content')
<div class="min-h-screen bg-bwa-gray-50 py-8">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Progress Indicator -->
<div class="mb-8">
<div class="flex items-center justify-center">
<div class="flex items-center space-x-4">
<div class="flex items-center">
<div class="w-8 h-8 bg-green-500 text-white rounded-full flex items-center justify-center text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<span class="ml-2 text-sm font-medium text-green-600">Detail Booking</span>
</div>
<div class="w-12 h-0.5 bg-bwa-primary-600"></div>
<div class="flex items-center">
<div class="w-8 h-8 bg-bwa-primary-600 text-white rounded-full flex items-center justify-center text-sm font-medium">2</div>
<span class="ml-2 text-sm font-medium text-bwa-primary-600">Pembayaran</span>
</div>
<div class="w-12 h-0.5 bg-bwa-gray-300"></div>
<div class="flex items-center">
<div class="w-8 h-8 bg-bwa-gray-300 text-bwa-gray-600 rounded-full flex items-center justify-center text-sm font-medium">3</div>
<span class="ml-2 text-sm font-medium text-bwa-gray-600">Konfirmasi</span>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Payment Instructions -->
<div class="lg:col-span-2 space-y-6">
<!-- Booking Success Alert -->
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex">
<svg class="w-5 h-5 text-green-400 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div class="ml-3">
<h3 class="text-sm font-medium text-green-800">Booking Berhasil Dibuat!</h3>
<div class="mt-1 text-sm text-green-700">
<p>Kode booking Anda: <strong>{{ $booking->booking_code }}</strong></p>
<p class="mt-1">Silakan lakukan pembayaran sebelum <strong>{{ $booking->created_at->addHours(24)->format('d M Y, H:i') }} WIB</strong></p>
</div>
</div>
</div>
</div>
<!-- Payment Instructions -->
<div class="card p-6">
<h2 class="text-xl font-semibold text-bwa-gray-900 mb-4">Instruksi Pembayaran</h2>
<!-- Bank Transfer Options -->
<div class="space-y-4 mb-6">
<div class="border border-bwa-gray-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<div class="w-12 h-8 bg-blue-600 rounded flex items-center justify-center">
<span class="text-white text-xs font-bold">BCA</span>
</div>
<div class="ml-3">
<div class="font-medium text-bwa-gray-900">Bank Central Asia</div>
<div class="text-sm text-bwa-gray-600">Transfer via ATM, Mobile Banking, atau Internet Banking</div>
</div>
</div>
</div>
<div class="bg-bwa-gray-50 rounded-lg p-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span class="text-bwa-gray-600">Nomor Rekening:</span>
<br>
<span class="font-mono font-bold text-lg">1234567890</span>
<button onclick="copyToClipboard('1234567890')" class="ml-2 text-bwa-primary-600 hover:text-bwa-primary-700">
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
<div>
<span class="text-bwa-gray-600">Atas Nama:</span>
<br>
<span class="font-medium">PT. Build With Angga Office Rental</span>
</div>
</div>
</div>
</div>
<div class="border border-bwa-gray-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<div class="w-12 h-8 bg-red-600 rounded flex items-center justify-center">
<span class="text-white text-xs font-bold">BNI</span>
</div>
<div class="ml-3">
<div class="font-medium text-bwa-gray-900">Bank Negara Indonesia</div>
<div class="text-sm text-bwa-gray-600">Transfer via ATM, Mobile Banking, atau Internet Banking</div>
</div>
</div>
</div>
<div class="bg-bwa-gray-50 rounded-lg p-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span class="text-bwa-gray-600">Nomor Rekening:</span>
<br>
<span class="font-mono font-bold text-lg">0987654321</span>
<button onclick="copyToClipboard('0987654321')" class="ml-2 text-bwa-primary-600 hover:text-bwa-primary-700">
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
<div>
<span class="text-bwa-gray-600">Atas Nama:</span>
<br>
<span class="font-medium">PT. Build With Angga Office Rental</span>
</div>
</div>
</div>
</div>
</div>
<!-- Important Notes -->
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div class="flex">
<svg class="w-5 h-5 text-yellow-400 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">Penting!</h3>
<div class="mt-1 text-sm text-yellow-700">
<ul class="list-disc list-inside space-y-1">
<li>Transfer dengan jumlah <strong>EXACT</strong>: Rp {{ number_format($booking->total_amount, 0, ',', '.') }}</li>
<li>Simpan bukti transfer dan upload di form dibawah</li>
<li>Pembayaran akan diverifikasi dalam 1x24 jam</li>
<li>Booking otomatis dibatalkan jika tidak ada pembayaran dalam 24 jam</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Upload Payment Proof Form -->
<div class="card p-6">
<h2 class="text-xl font-semibold text-bwa-gray-900 mb-4">Upload Bukti Pembayaran</h2>
<form action="{{ route('bookings.upload-payment', $booking->booking_code) }}" method="POST" enctype="multipart/form-data" id="payment-form">
@csrf
<div class="space-y-6">
<!-- Payment Date -->
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">
Tanggal Pembayaran <span class="text-red-500">*</span>
</label>
<input type="date" name="payment_date" class="form-input"
max="{{ date('Y-m-d') }}"
value="{{ date('Y-m-d') }}"
required>
</div>
<!-- Reference Number -->
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">
Nomor Referensi/Transaction ID (Opsional)
</label>
<input type="text" name="reference_number" class="form-input"
placeholder="Contoh: TRF123456789">
<p class="mt-1 text-sm text-bwa-gray-500">Nomor referensi dari bank atau e-wallet (jika ada)</p>
</div>
<!-- Payment Proof Upload -->
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">
Bukti Pembayaran <span class="text-red-500">*</span>
</label>
<div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-bwa-gray-300 border-dashed rounded-lg hover:border-bwa-primary-400 transition-colors"
id="drop-zone">
<div class="space-y-1 text-center">
<svg class="mx-auto h-12 w-12 text-bwa-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div class="flex text-sm text-bwa-gray-600">
<label for="payment_proof" class="relative cursor-pointer bg-white rounded-md font-medium text-bwa-primary-600 hover:text-bwa-primary-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-bwa-primary-500">
<span>Upload file</span>
<input id="payment_proof" name="payment_proof" type="file" class="sr-only"
accept="image/*,.pdf" required>
</label>
<p class="pl-1">atau drag and drop</p>
</div>
<p class="text-xs text-bwa-gray-500">PNG, JPG, PDF hingga 2MB</p>
</div>
</div>
<!-- File Preview -->
<div id="file-preview" class="hidden mt-4">
<div class="flex items-center p-3 bg-bwa-gray-50 rounded-lg">
<svg class="w-8 h-8 text-bwa-primary-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
<div class="flex-1">
<div id="file-name" class="text-sm font-medium text-bwa-gray-900"></div>
<div id="file-size" class="text-xs text-bwa-gray-500"></div>
</div>
<button type="button" onclick="removeFile()" class="text-red-500 hover:text-red-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
</div>
<!-- Additional Notes -->
<div>
<label class="block text-sm font-medium text-bwa-gray-700 mb-2">
Catatan Tambahan (Opsional)
</label>
<textarea name="notes" rows="3" class="form-input"
placeholder="Contoh: Transfer dari rekening atas nama berbeda, ada potongan biaya admin, dll."></textarea>
</div>
<!-- Submit Button -->
<div class="flex items-center justify-between pt-4">
<a href="{{ route('dashboard') }}" class="btn-secondary">
Nanti Saja
</a>
<button type="submit" class="btn-primary" id="submit-btn">
Upload Bukti Pembayaran
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Booking Summary -->
<div class="lg:col-span-1">
<div class="sticky top-24">
<div class="card p-6">
<h2 class="text-lg font-semibold text-bwa-gray-900 mb-4">Detail Booking</h2>
<!-- Booking Code -->
<div class="bg-bwa-primary-50 rounded-lg p-3 mb-4">
<div class="text-center">
<div class="text-sm text-bwa-primary-600 font-medium">Kode Booking</div>
<div class="text-xl font-bold text-bwa-primary-900">{{ $booking->booking_code }}</div>
</div>
</div>
<!-- Office Info -->
<div class="flex items-start space-x-3 mb-4">
<img src="{{ $booking->office->main_image }}" alt="{{ $booking->office->name }}" class="w-12 h-12 rounded-lg object-cover">
<div class="flex-1">
<h3 class="font-medium text-bwa-gray-900 text-sm">{{ $booking->office->name }}</h3>
<p class="text-xs text-bwa-gray-600">{{ $booking->office->city }}, {{ $booking->office->state }}</p>
</div>
</div>
<!-- Booking Details -->
<div class="space-y-2 mb-4 text-sm">
<div class="flex justify-between">
<span class="text-bwa-gray-600">Check-in</span>
<span class="font-medium">{{ $booking->start_date->format('d M Y') }}</span>
</div>
<div class="flex justify-between">
<span class="text-bwa-gray-600">Check-out</span>
<span class="font-medium">{{ $booking->end_date->format('d M Y') }}</span>
</div>
<div class="flex justify-between">
<span class="text-bwa-gray-600">Durasi</span>
<span class="font-medium">{{ $booking->total_days }} hari</span>
</div>
<div class="flex justify-between">
<span class="text-bwa-gray-600">Peserta</span>
<span class="font-medium">{{ $booking->participant_count }} orang</span>
</div>
</div>
<!-- Price Breakdown -->
<div class="border-t border-bwa-gray-200 pt-4">
<div class="space-y-2 mb-3 text-sm">
<div class="flex justify-between">
<span class="text-bwa-gray-600">Subtotal</span>
<span class="font-medium">{{ $booking->formatted_subtotal }}</span>
</div>
<div class="flex justify-between">
<span class="text-bwa-gray-600">Pajak (10%)</span>
<span class="font-medium">Rp {{ number_format($booking->tax_amount, 0, ',', '.') }}</span>
</div>
</div>
<div class="border-t border-bwa-gray-200 pt-3">
<div class="flex justify-between text-lg font-bold">
<span>Total Bayar</span>
<span class="text-bwa-primary-600">{{ $booking->formatted_total_amount }}</span>
</div>
</div>
</div>
<!-- Payment Deadline -->
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<div class="text-center">
<div class="text-sm text-red-600 font-medium">Batas Waktu Pembayaran</div>
<div class="text-sm font-bold text-red-900" id="countdown">
{{ $booking->created_at->addHours(24)->format('d M Y, H:i') }} WIB
</div>
</div>
</div>
<!-- Help -->
<div class="mt-4 text-center">
<p class="text-sm text-bwa-gray-600 mb-2">Butuh bantuan?</p>
<a href="<https://wa.me/628123456789>" target="_blank" class="btn-secondary text-sm py-2 px-4">
<svg class="w-4 h-4 mr-1 inline" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0020.885 3.488"/>
</svg>
WhatsApp
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('payment_proof');
const filePreview = document.getElementById('file-preview');
const paymentForm = document.getElementById('payment-form');
const submitBtn = document.getElementById('submit-btn');
// Drag and drop functionality
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, unhighlight, false);
});
dropZone.addEventListener('drop', handleDrop, false);
fileInput.addEventListener('change', handleFileSelect, false);
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
function highlight(e) {
dropZone.classList.add('border-bwa-primary-500', 'bg-bwa-primary-50');
}
function unhighlight(e) {
dropZone.classList.remove('border-bwa-primary-500', 'bg-bwa-primary-50');
}
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length > 0) {
fileInput.files = files;
handleFileSelect({ target: { files: files } });
}
}
function handleFileSelect(e) {
const files = e.target.files;
if (files.length > 0) {
const file = files[0];
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg', 'application/pdf'];
if (!allowedTypes.includes(file.type)) {
alert('Hanya file JPG, PNG, atau PDF yang diperbolehkan.');
return;
}
// Validate file size (2MB)
if (file.size > 2 * 1024 * 1024) {
alert('Ukuran file maksimal 2MB.');
return;
}
// Show file preview
document.getElementById('file-name').textContent = file.name;
document.getElementById('file-size').textContent = formatFileSize(file.size);
filePreview.classList.remove('hidden');
dropZone.style.display = 'none';
}
}
// Form submission
paymentForm.addEventListener('submit', function(e) {
submitBtn.disabled = true;
submitBtn.innerHTML = `
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="<http://www.w3.org/2000/svg>" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Mengupload...
`;
});
// Countdown timer
const deadline = new Date('{{ $booking->created_at->addHours(24)->toISOString() }}');
const countdownElement = document.getElementById('countdown');
function updateCountdown() {
const now = new Date().getTime();
const distance = deadline.getTime() - now;
if (distance < 0) {
countdownElement.innerHTML = "EXPIRED";
countdownElement.classList.add('text-red-600');
return;
}
const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
countdownElement.innerHTML = `${hours}j ${minutes}m ${seconds}d`;
}
setInterval(updateCountdown, 1000);
updateCountdown();
});
function removeFile() {
document.getElementById('payment_proof').value = '';
document.getElementById('file-preview').classList.add('hidden');
document.getElementById('drop-zone').style.display = 'flex';
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
showToast('Nomor rekening berhasil disalin!', 'success');
});
}
</script>
@endpush
Mengintegrasikan Frontend dengan Controller Laravel
Terakhir, kita perlu update routes dan ensure semua integration berjalan dengan baik. Update routes/web.php:
<?php
use App\\Http\\Controllers\\Web\\HomeController;
use App\\Http\\Controllers\\Web\\OfficeController;
use App\\Http\\Controllers\\Web\\BookingController;
use Illuminate\\Support\\Facades\\Route;
// Public routes
Route::get('/', [HomeController::class, 'index'])->name('home');
Route::post('/search', [HomeController::class, 'search'])->name('offices.search');
// Office routes
Route::prefix('offices')->group(function () {
Route::get('/', [OfficeController::class, 'index'])->name('offices.index');
Route::get('/{slug}', [OfficeController::class, 'show'])->name('offices.show');
Route::get('/{office}/check-availability', [OfficeController::class, 'checkAvailability'])->name('offices.check-availability');
});
// Auth routes
Auth::routes();
// Protected routes
Route::middleware(['auth'])->group(function () {
Route::get('/dashboard', [App\\Http\\Controllers\\DashboardController::class, 'index'])->name('dashboard');
// Booking routes
Route::prefix('bookings')->group(function () {
Route::get('/create', [BookingController::class, 'create'])->name('bookings.create');
Route::post('/store', [BookingController::class, 'store'])->name('bookings.store');
Route::get('/{bookingCode}/payment', [BookingController::class, 'payment'])->name('bookings.payment');
Route::post('/{bookingCode}/upload-payment', [BookingController::class, 'uploadPayment'])->name('bookings.upload-payment');
Route::get('/history', [BookingController::class, 'history'])->name('bookings.history');
});
// Profile routes
Route::get('/profile', [App\\Http\\Controllers\\ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [App\\Http\\Controllers\\ProfileController::class, 'update'])->name('profile.update');
});
Bikin juga footer component di resources/views/layouts/footer.blade.php:
<footer class="bg-bwa-gray-900 text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Company Info -->
<div class="md:col-span-2">
<div class="flex items-center space-x-2 mb-4">
<div class="w-8 h-8 bg-gradient-bwa rounded-lg flex items-center justify-center">
<span class="text-white font-bold text-sm">BWA</span>
</div>
<span class="font-display font-semibold text-xl">Office Rental</span>
</div>
<p class="text-bwa-gray-300 mb-4 max-w-md">
Platform terpercaya untuk menyewa ruang kantor modern dengan lokasi strategis,
fasilitas lengkap, dan proses booking yang mudah.
</p>
<div class="flex space-x-4">
<a href="#" class="text-bwa-gray-400 hover:text-white">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"/></svg>
</a>
<a href="#" class="text-bwa-gray-400 hover:text-white">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M22.46 6c-.77.35-1.6.58-2.46.69.88-.53 1.56-1.37 1.88-2.38-.83.5-1.75.85-2.72 1.05C18.37 4.5 17.26 4 16 4c-2.35 0-4.27 1.92-4.27 4.29 0 .34.04.67.11.98C8.28 9.09 5.11 7.38 3 4.79c-.37.63-.58 1.37-.58 2.15 0 1.49.75 2.81 1.91 3.56-.71 0-1.37-.2-1.95-.5v.03c0 2.08 1.48 3.82 3.44 4.21a4.22 4.22 0 0 1-1.93.07 4.28 4.28 0 0 0 4 2.98 8.521 8.521 0 0 1-5.33 1.84c-.34 0-.68-.02-1.02-.06C3.44 20.29 5.7 21 8.12 21 16 21 20.33 14.46 20.33 8.79c0-.19 0-.37-.01-.56.84-.6 1.56-1.36 2.14-2.23z"/></svg>
</a>
<a href="#" class="text-bwa-gray-400 hover:text-white">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.174-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.096.118.112.221.085.343-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.402.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.357-.629-2.750-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146C9.57 23.812 10.763 24.009 12.017 24.009c6.624 0 11.99-5.367 11.99-11.988C24.007 5.367 18.641.001.012.001z"/></svg>
</a>
</div>
</div>
<!-- Quick Links -->
<div>
<h3 class="font-semibold mb-4">Quick Links</h3>
<ul class="space-y-2 text-bwa-gray-300">
<li><a href="{{ route('home') }}" class="hover:text-white">Beranda</a></li>
<li><a href="{{ route('offices.index') }}" class="hover:text-white">Cari Kantor</a></li>
<li><a href="#" class="hover:text-white">Tentang Kami</a></li>
<li><a href="#" class="hover:text-white">Kontak</a></li>
<li><a href="#" class="hover:text-white">FAQ</a></li>
</ul>
</div>
<!-- Support -->
<div>
<h3 class="font-semibold mb-4">Support</h3>
<ul class="space-y-2 text-bwa-gray-300">
<li>
<a href="tel:+628123456789" class="hover:text-white flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"></path>
</svg>
+62 812-3456-789
</a>
</li>
<li>
<a href="mailto:[email protected]" class="hover:text-white flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
[email protected]
</a>
</li>
<li>
<span class="flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
24/7 Support
</span>
</li>
</ul>
</div>
</div>
<div class="border-t border-bwa-gray-800 mt-8 pt-8 flex flex-col md:flex-row justify-between items-center">
<p class="text-bwa-gray-400 text-sm">
© {{ date('Y') }} BWA Office Rental. All rights reserved.
</p>
<div class="flex space-x-6 mt-4 md:mt-0">
<a href="#" class="text-bwa-gray-400 hover:text-white text-sm">Privacy Policy</a>
<a href="#" class="text-bwa-gray-400 hover:text-white text-sm">Terms of Service</a>
<a href="#" class="text-bwa-gray-400 hover:text-white text-sm">Cookie Policy</a>
</div>
</div>
</div>
</footer>
Perfect! Sekarang kita udah punya frontend yang comprehensive dengan:
- Homepage yang engaging dengan hero section, stats, featured offices, dan search functionality
- Detail office page yang informatif dengan galeri foto, booking form, dan availability checker
- Checkout page yang user-friendly dengan form booking yang comprehensive
- Payment page dengan multiple bank options dan secure file upload
- Responsive design yang works perfect di semua devices
- Interactive elements dengan JavaScript yang smooth
- Integration yang seamless dengan backend Laravel
Semua udah terintegrasi dengan controller Laravel dan database yang udah kita setup sebelumnya. User experience-nya bakal smooth dari browse kantor sampai upload bukti pembayaran!
Integrasi Midtrans Payment Gateway: Pembayaran Online yang Modern
Sekarang kita bakal integrasikan Midtrans payment gateway ke sistem BWA Office Rental kita. Midtrans adalah payment gateway terpopuler di Indonesia yang support berbagai metode pembayaran kayak kartu kredit, e-wallet, virtual account, dan masih banyak lagi.
Dengan integrasi ini, customer ga perlu lagi ribet upload bukti transfer manual - semua bisa otomatis dan real-time!
Setup Midtrans dan Instalasi Package
Pertama, kita perlu install Midtrans PHP SDK:
composer require midtrans/midtrans-php
Bikin akun di midtrans.com dan dapetin Server Key & Client Key. Tambahin ke file .env:
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxxxx
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxx
MIDTRANS_IS_PRODUCTION=false
MIDTRANS_IS_SANITIZED=true
MIDTRANS_IS_3DS=true
Bikin config file buat Midtrans di config/midtrans.php:
<?php
return [
'server_key' => env('MIDTRANS_SERVER_KEY'),
'client_key' => env('MIDTRANS_CLIENT_KEY'),
'is_production' => env('MIDTRANS_IS_PRODUCTION', false),
'is_sanitized' => env('MIDTRANS_IS_SANITIZED', true),
'is_3ds' => env('MIDTRANS_IS_3DS', true),
];
Update Model Payment untuk Support Midtrans
Edit model Payment di app/Models/Payment.php:
// Tambahkan di fillable array
protected $fillable = [
'booking_id',
'payment_method',
'amount',
'payment_proof',
'payment_date',
'verified_at',
'verified_by',
'status',
'reference_number',
'notes',
'midtrans_order_id', // tambahan
'midtrans_transaction_id', // tambahan
'midtrans_payment_type', // tambahan
'midtrans_response', // tambahan
];
// Tambahkan cast
protected $casts = [
'amount' => 'decimal:2',
'payment_date' => 'datetime',
'verified_at' => 'datetime',
'midtrans_response' => 'array', // tambahan
];
Bikin migration buat nambah kolom Midtrans:
php artisan make:migration add_midtrans_fields_to_payments_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('payments', function (Blueprint $table) {
$table->string('midtrans_order_id')->nullable()->after('reference_number');
$table->string('midtrans_transaction_id')->nullable()->after('midtrans_order_id');
$table->string('midtrans_payment_type')->nullable()->after('midtrans_transaction_id');
$table->json('midtrans_response')->nullable()->after('midtrans_payment_type');
});
}
public function down(): void
{
Schema::table('payments', function (Blueprint $table) {
$table->dropColumn(['midtrans_order_id', 'midtrans_transaction_id', 'midtrans_payment_type', 'midtrans_response']);
});
}
};
Jalankan migration:
php artisan migrate
Bikin Service Class untuk Midtrans
Bikin service class di app/Services/MidtransService.php:
<?php
namespace App\\Services;
use Midtrans\\Config;
use Midtrans\\Snap;
use Midtrans\\Transaction;
use App\\Models\\Booking;
use App\\Models\\Payment;
use Illuminate\\Support\\Facades\\Log;
class MidtransService
{
public function __construct()
{
Config::$serverKey = config('midtrans.server_key');
Config::$isProduction = config('midtrans.is_production');
Config::$isSanitized = config('midtrans.is_sanitized');
Config::$is3ds = config('midtrans.is_3ds');
}
public function createTransaction(Booking $booking)
{
$orderId = 'BWA-' . $booking->booking_code . '-' . time();
$params = [
'transaction_details' => [
'order_id' => $orderId,
'gross_amount' => (int) $booking->total_amount,
],
'customer_details' => [
'first_name' => $booking->user->name,
'email' => $booking->user->email,
'phone' => $booking->user->phone,
],
'item_details' => [
[
'id' => $booking->office->id,
'price' => (int) $booking->total_amount,
'quantity' => 1,
'name' => 'BWA Office Rental - ' . $booking->office->name,
'category' => 'Office Rental',
]
],
'callbacks' => [
'finish' => route('payment.finish'),
'unfinish' => route('payment.unfinish'),
'error' => route('payment.error'),
],
'enabled_payments' => [
'credit_card', 'gopay', 'shopeepay', 'other_qris',
'bca_va', 'bni_va', 'bri_va', 'permata_va',
'other_va', 'alfamart', 'indomaret'
],
'custom_field1' => $booking->booking_code,
'custom_field2' => 'BWA-Office-Rental',
];
try {
$snapToken = Snap::getSnapToken($params);
// Update payment dengan Midtrans order ID
$booking->payment->update([
'midtrans_order_id' => $orderId,
'payment_method' => 'midtrans',
]);
return $snapToken;
} catch (\\Exception $e) {
Log::error('Midtrans error: ' . $e->getMessage());
throw new \\Exception('Gagal membuat transaksi pembayaran');
}
}
public function handleCallback($notification)
{
try {
$orderId = $notification->order_id;
$transactionStatus = $notification->transaction_status;
$fraudStatus = $notification->fraud_status ?? '';
// Cari payment berdasarkan order ID
$payment = Payment::where('midtrans_order_id', $orderId)->first();
if (!$payment) {
Log::warning('Payment not found for order ID: ' . $orderId);
return false;
}
// Update payment dengan response dari Midtrans
$payment->update([
'midtrans_transaction_id' => $notification->transaction_id,
'midtrans_payment_type' => $notification->payment_type,
'midtrans_response' => json_decode(json_encode($notification), true),
]);
// Handle status
if ($transactionStatus == 'capture') {
if ($fraudStatus == 'challenge') {
$payment->update(['status' => 'pending']);
} else if ($fraudStatus == 'accept') {
$this->confirmPayment($payment);
}
} else if ($transactionStatus == 'settlement') {
$this->confirmPayment($payment);
} else if ($transactionStatus == 'pending') {
$payment->update(['status' => 'pending']);
} else if (in_array($transactionStatus, ['deny', 'expire', 'cancel'])) {
$payment->update(['status' => 'failed']);
}
return true;
} catch (\\Exception $e) {
Log::error('Midtrans callback error: ' . $e->getMessage());
return false;
}
}
private function confirmPayment(Payment $payment)
{
$payment->update([
'status' => 'verified',
'payment_date' => now(),
'verified_at' => now(),
]);
// Update booking status
$payment->booking->update(['status' => 'confirmed']);
// Send confirmation email atau notification
// implementasi sesuai kebutuhan
}
}
Update Controller untuk Midtrans Integration
Update BookingController di app/Http/Controllers/Web/BookingController.php:
// Tambahkan use statement
use App\\Services\\MidtransService;
// Update method payment
public function payment($bookingCode)
{
$booking = Booking::with(['office', 'payment'])
->where('booking_code', $bookingCode)
->where('user_id', Auth::id())
->firstOrFail();
if ($booking->status !== 'pending') {
return redirect()->route('dashboard')->with('info', 'Booking ini sudah diproses.');
}
// Create Midtrans token
try {
$midtransService = new MidtransService();
$snapToken = $midtransService->createTransaction($booking);
return view('bookings.payment', compact('booking', 'snapToken'));
} catch (\\Exception $e) {
return redirect()->back()->with('error', 'Gagal memuat halaman pembayaran: ' . $e->getMessage());
}
}
// Tambah method untuk handle callback
public function midtransCallback(Request $request)
{
$midtransService = new MidtransService();
$success = $midtransService->handleCallback($request);
if ($success) {
return response('OK', 200);
} else {
return response('FAILED', 400);
}
}
// Tambah method untuk handle redirect
public function paymentFinish(Request $request)
{
$orderId = $request->order_id;
$payment = Payment::where('midtrans_order_id', $orderId)->first();
if ($payment) {
return redirect()->route('dashboard')->with('success', 'Pembayaran berhasil! Booking akan segera dikonfirmasi.');
}
return redirect()->route('dashboard')->with('error', 'Terjadi kesalahan dalam pembayaran.');
}
public function paymentUnfinish(Request $request)
{
return redirect()->route('dashboard')->with('warning', 'Pembayaran belum selesai. Silakan lanjutkan pembayaran.');
}
public function paymentError(Request $request)
{
return redirect()->route('dashboard')->with('error', 'Terjadi kesalahan dalam proses pembayaran.');
}
Update View Payment dengan Midtrans
Update view resources/views/bookings/payment.blade.php, tambahkan Midtrans payment option:
@extends('layouts.app')
@section('title', 'Pembayaran - BWA Office Rental')
@push('styles')
<script src="<https://app.sandbox.midtrans.com/snap/snap.js>" data-client-key="{{ config('midtrans.client_key') }}"></script>
@endpush
@section('content')
<!-- existing content... -->
<!-- Payment Methods -->
<div class="card p-6">
<h2 class="text-xl font-semibold text-bwa-gray-900 mb-4">Pilih Metode Pembayaran</h2>
<!-- Midtrans Payment -->
<div class="border-2 border-bwa-primary-200 rounded-lg p-6 mb-6 bg-bwa-primary-50">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="font-semibold text-lg text-bwa-primary-900">Pembayaran Online</h3>
<p class="text-sm text-bwa-primary-700">Kartu kredit, e-wallet, virtual account, dan lainnya</p>
</div>
<div class="flex space-x-2">
<img src="<https://logo.midtrans.com/assets/img/method/visa.png>" alt="Visa" class="h-6">
<img src="<https://logo.midtrans.com/assets/img/method/mastercard.png>" alt="Mastercard" class="h-6">
<img src="<https://logo.midtrans.com/assets/img/method/gopay.png>" alt="GoPay" class="h-6">
<img src="<https://logo.midtrans.com/assets/img/method/shopeepay.png>" alt="ShopeePay" class="h-6">
</div>
</div>
<button onclick="payWithMidtrans()" class="btn-primary w-full">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"></path>
</svg>
Bayar Sekarang - {{ $booking->formatted_total_amount }}
</button>
</div>
<!-- Manual Transfer (existing) -->
<div class="border border-bwa-gray-200 rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="font-semibold text-lg text-bwa-gray-900">Transfer Manual</h3>
<p class="text-sm text-bwa-gray-600">Transfer ke rekening bank dan upload bukti</p>
</div>
</div>
<button onclick="toggleManualTransfer()" class="btn-secondary w-full" id="manual-transfer-btn">
Lihat Detail Transfer Manual
</button>
<div id="manual-transfer-section" class="hidden mt-4">
<!-- existing manual transfer content -->
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
const snapToken = '{{ $snapToken }}';
function payWithMidtrans() {
snap.pay(snapToken, {
onSuccess: function(result) {
console.log('Payment success:', result);
window.location.href = '{{ route("payment.finish") }}?order_id=' + result.order_id;
},
onPending: function(result) {
console.log('Payment pending:', result);
showToast('Pembayaran pending, silakan selesaikan pembayaran Anda', 'warning');
},
onError: function(result) {
console.log('Payment error:', result);
showToast('Terjadi kesalahan dalam pembayaran', 'error');
},
onClose: function() {
console.log('Payment popup closed');
showToast('Pembayaran dibatalkan', 'info');
}
});
}
function toggleManualTransfer() {
const section = document.getElementById('manual-transfer-section');
const btn = document.getElementById('manual-transfer-btn');
if (section.classList.contains('hidden')) {
section.classList.remove('hidden');
btn.textContent = 'Sembunyikan Transfer Manual';
} else {
section.classList.add('hidden');
btn.textContent = 'Lihat Detail Transfer Manual';
}
}
</script>
@endpush
Update Routes untuk Midtrans
Tambahkan routes di routes/web.php:
// Midtrans callback routes
Route::post('/payment/callback', [BookingController::class, 'midtransCallback'])->name('payment.callback');
Route::get('/payment/finish', [BookingController::class, 'paymentFinish'])->name('payment.finish');
Route::get('/payment/unfinish', [BookingController::class, 'paymentUnfinish'])->name('payment.unfinish');
Route::get('/payment/error', [BookingController::class, 'paymentError'])->name('payment.error');
Testing dan Go Live
Untuk testing, pastiin pake Server Key dan Client Key yang sandbox. Kalo udah ready production, ganti ke production keys dan ubah MIDTRANS_IS_PRODUCTION=true.
Test Cards untuk Sandbox:
- Success: 4811 1111 1111 1114
- Failure: 4911 1111 1111 1113
- Challenge: 4411 1111 1111 1118
Webhook Configuration: Di dashboard Midtrans, set webhook URL ke:
<https://yourdomain.com/payment/callback>
Monitoring dan Error Handling
Buat command untuk check payment status:
php artisan make:command CheckPendingPayments
<?php
namespace App\\Console\\Commands;
use Illuminate\\Console\\Command;
use App\\Models\\Payment;
use App\\Services\\MidtransService;
class CheckPendingPayments extends Command
{
protected $signature = 'payment:check-pending';
protected $description = 'Check pending payments status with Midtrans';
public function handle()
{
$pendingPayments = Payment::where('status', 'pending')
->where('payment_method', 'midtrans')
->where('created_at', '>', now()->subHours(24))
->get();
$midtransService = new MidtransService();
foreach ($pendingPayments as $payment) {
try {
$status = \\Midtrans\\Transaction::status($payment->midtrans_order_id);
$midtransService->handleCallback($status);
$this->info("Updated payment: {$payment->midtrans_order_id}");
} catch (\\Exception $e) {
$this->error("Failed to check payment {$payment->midtrans_order_id}: " . $e->getMessage());
}
}
}
}
Jadwalkan command ini di app/Console/Kernel.php:
protected function schedule(Schedule $schedule)
{
$schedule->command('payment:check-pending')->everyFiveMinutes();
}
Perfect! Sekarang BWA Office Rental udah punya payment gateway yang modern dan comprehensive. Customer bisa bayar dengan berbagai metode, otomatis ter-update, dan admin ga perlu manual verification lagi!
Tips Penting:
- Selalu test di sandbox environment dulu
- Monitor webhook logs buat debugging
- Set proper timeout buat payment (biasanya 24 jam)
- Implementasikan proper error handling dan logging
- Backup manual transfer sebagai fallback option
Dengan integrasi Midtrans ini, conversion rate booking pasti bakal meningkat karena proses pembayaran jadi lebih mudah dan trustworthy!
Summary dan Penutup: Journey Menuju Full-Stack Developer
Selamat! Kamu udah berhasil menyelesaikan tutorial lengkap pembuatan website sewa kantor menggunakan Laravel 12, Filament 3, dan Spatie Role. Ini bukan pencapaian yang kecil lho - kamu udah berhasil bikin aplikasi web yang complex dan production-ready!
Apa yang Udah Kita Capai?
Mari kita recap perjalanan coding kita dari awal sampai akhir:
🏗️ Foundation yang Solid
- Setup Laravel 12 dengan konfigurasi yang optimal
- Database design yang scalable dengan 7 tabel utama
- Model relationships yang efficient menggunakan Eloquent ORM
- Authentication dan authorization dengan Spatie Permission
🎨 Admin Panel yang Powerfull
- Dashboard Filament 3 yang modern dan responsive
- CRUD operations yang comprehensive untuk semua entity
- Role-based access control yang secure
- Custom actions dan bulk operations
💻 Frontend yang Memukau
- Homepage yang engaging dengan search functionality
- Detail office page dengan gallery dan booking system
- Checkout flow yang smooth dan user-friendly
- Responsive design menggunakan Tailwind CSS
💳 Payment Integration
- Manual bank transfer dengan file upload
- Midtrans integration untuk payment gateway modern
- Webhook handling untuk auto-confirmation
- Multiple payment methods support
Skills yang Udah Kamu Kuasai
Setelah menyelesaikan tutorial ini, kamu udah menguasai:
Backend Development:
- Laravel framework dari basic sampai advanced
- Database migration dan model relationships
- API development dan form handling
- File upload dan validation
- Authentication dan authorization
Frontend Development:
- Blade templating dengan component-based approach
- Tailwind CSS untuk modern styling
- JavaScript untuk interactivity
- Responsive web design
- User experience optimization
Full-Stack Integration:
- MVC architecture pattern
- RESTful API concepts
- Payment gateway integration
- Real-time notifications
- Error handling dan debugging
Development Tools:
- Composer untuk dependency management
- Artisan commands untuk productivity
- Git workflow (hopefully!)
- Environment configuration
Tips Buat Pengembangan Selanjutnya
🚀 Level Up Your App:
- Tambahkan real-time chat antara customer dan admin
- Implementasikan notification system (email/SMS/WhatsApp)
- Bikin mobile app dengan Laravel API
- Add advanced search dengan Elasticsearch
- Implementasikan caching untuk performance
🔒 Security Enhancements:
- Two-factor authentication untuk admin
- Rate limiting untuk API endpoints
- CSRF protection yang proper
- Input sanitization dan validation
- Regular security audits
📊 Analytics & Monitoring:
- Google Analytics integration
- Performance monitoring dengan tools kayak New Relic
- Error tracking dengan Sentry
- User behavior analytics
- Revenue tracking dan reporting
Saran Belajar Lanjutan di BuildWithAngga
Kalo kamu udah nyaman dengan foundation yang udah kita bangun, saatnya eksplorasi lebih dalam! Di BuildWithAngga ada banyak kelas yang bisa nge-boost skill kamu:
🎯 Recommended Learning Path:
1. Advanced Laravel Development
- Mastery Laravel API untuk mobile integration
- Laravel Queue dan Background Jobs
- Laravel Testing (Unit & Feature Tests)
- Laravel Performance Optimization
2. Modern Frontend Development
- Vue.js atau React untuk SPA development
- Inertia.js untuk seamless Laravel-Vue/React integration
- Progressive Web App (PWA) development
- Mobile app development dengan Flutter
3. DevOps & Production
- Docker untuk containerization
- AWS/Google Cloud deployment
- CI/CD pipeline setup
- Database optimization dan scaling
4. Specialized Skills
- Payment gateway mastery (Stripe, PayPal, dll)
- Real-time applications dengan WebSocket
- Microservices architecture
- GraphQL API development
Kenapa Harus Lanjut Belajar di BWA?
👨🏫 Mentor Berpengalaman
- Instructor yang udah proven di industri
- Real-world experience, bukan cuma teori
- Responsive di community Discord
- Career guidance dan mentoring
🎓 Learning Method yang Effective
- Project-based learning kayak tutorial ini
- Step-by-step yang beginner friendly
- Video berkualitas tinggi dengan audio jernih
- Source code lengkap dan documented
🤝 Community yang Supportive
- Discord community yang aktif 24/7
- Sharing knowledge antar member
- Code review dan feedback
- Networking dengan fellow developers
💼 Career Support
- Job placement assistance
- Portfolio review dan feedback
- Interview preparation
- Industry connections
Next Steps: Transform Your Learning
Jangka Pendek (1-3 bulan):
- Polish aplikasi ini dengan fitur tambahan
- Deploy ke production server (shared hosting/VPS)
- Buat portfolio website dan showcase project ini
- Join BWA community di Discord
Jangka Menengah (3-6 bulan):
- Ambil kelas advanced di BWA sesuai minat
- Kontribusi ke open source projects
- Freelance dengan skill yang udah dipunya
- Networking dengan developer lain
Jangka Panjang (6+ bulan):
- Apply full-time developer position
- Build startup atau produk sendiri
- Jadi mentor buat junior developer
- Specialized di niche tertentu (fintech, e-commerce, dll)
Final Words: Your Developer Journey Starts Here
Tutorial ini adalah starting point, bukan finish line. Skill coding itu kayak otot - harus terus dilatih supaya makin kuat. Jangan takut buat eksperimen, break things, dan belajar dari error.
Remember: setiap expert pernah jadi beginner. Yang beda adalah consistency dan willingness to learn. Kamu udah prove bahwa kamu bisa menyelesaikan project complex kayak ini - that's a huge achievement!
🔥 Keep The Momentum Going:
- Code every day, walau cuma 30 menit
- Join coding communities dan aktif diskusi
- Build more projects untuk strengthen portfolio
- Share knowledge dengan yang lain
- Never stop learning new technologies
💪 You Got This!
Di BWA, kita percaya bahwa setiap orang bisa jadi developer yang excellent dengan guidance yang tepat dan usaha yang konsisten. Kamu udah buktiin bahwa kamu punya dedication buat belajar hal baru.
Sekarang saatnya apply knowledge ini ke dunia nyata. Build more projects, collaborate dengan orang lain, dan most importantly - enjoy the process!
Ready untuk step selanjutnya?
Kunjungi BuildWithAngga.com dan explore kelas-kelas yang bisa elevate skill kamu ke level berikutnya. Di sana kamu bakal ketemu dengan community developer Indonesia yang passionate dan supportive.
Happy Coding! 🚀
"Code is like humor. When you have to explain it, it's bad." - Cory House
Tapi tutorial ini exception ya - explaining is part of learning! 😄