Studi Kasus #14: Aplikasi Wisata Indonesia – Membuat Halaman Detail Tempat Wisata
Halo, calon full-stack developer!
Di Studi Kasus #13, kita telah membuat halaman beranda yang menampilkan daftar destinasi dalam bentuk kartu. Sekarang saatnya kita membuat halaman detail yang akan muncul ketika pengguna mengklik salah satu kartu. Halaman ini akan menampilkan informasi lengkap tentang destinasi, termasuk deskripsi, fasilitas, galeri foto, dan paket harga yang tersedia.
Fitur yang akan dibuat:
- Mengambil ID dari URL menggunakan
useParams. - Mengambil data destinasi, galeri, dan paket dari API.
- Menampilkan loading dan error state.
- Menampilkan informasi utama: nama, lokasi, deskripsi, fasilitas.
- Menampilkan galeri foto dalam bentuk grid.
- Menampilkan paket harga dalam bentuk tabel atau kartu.
Mari kita mulai!
Langkah 1: Menambahkan Service untuk Galeri dan Paket
Kita perlu membuat fungsi untuk mengambil galeri dan paket berdasarkan entity type dan ID. Buat file src/services/galleryService.js:
import API from './api';
export const getGalleriesByEntity = async (entityType, entityId) => {
try {
const response = await API.get(`/galleries/${entityType}/${entityId}`);
return response.data;
} catch (error) {
console.error('Gagal mengambil galeri:', error);
throw error;
}
};
Buat file src/services/packageService.js:
import API from './api';
export const getPackagesByEntity = async (entityType, entityId) => {
try {
const response = await API.get(`/packages/${entityType}/${entityId}`);
return response.data;
} catch (error) {
console.error('Gagal mengambil paket:', error);
throw error;
}
};
Pastikan destinationService.js sudah memiliki fungsi getDestinationById (seperti di tutorial sebelumnya). Jika belum, tambahkan.
📄 Langkah 2: Membuat Halaman Detail Destinasi
Buat file src/pages/DestinationDetail.jsx. Kita akan menggunakan useParams dari react-router-dom untuk mengambil ID dari URL.
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { getDestinationById } from '../services/destinationService';
import { getGalleriesByEntity } from '../services/galleryService';
import { getPackagesByEntity } from '../services/packageService';
import './DestinationDetail.css';
const DestinationDetail = () => {
const { id } = useParams(); // ambil ID dari URL
const [destination, setDestination] = useState(null);
const [galleries, setGalleries] = useState([]);
const [packages, setPackages] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
// Ambil data destinasi
const destData = await getDestinationById(id);
setDestination(destData);
// Ambil galeri (entity_type = 'destination')
const galleryData = await getGalleriesByEntity('destination', id);
setGalleries(galleryData);
// Ambil paket (entity_type = 'destination')
const packageData = await getPackagesByEntity('destination', id);
setPackages(packageData);
} catch (err) {
setError('Gagal memuat data destinasi.');
console.error(err);
} finally {
setLoading(false);
}
};
fetchData();
}, [id]);
if (loading) {
return <div className="loading">Memuat data...</div>;
}
if (error || !destination) {
return <div className="error">{error || 'Destinasi tidak ditemukan'}</div>;
}
// Helper untuk URL gambar
const uploadUrl = import.meta.env.VITE_UPLOAD_URL || 'http://localhost:3000/uploads';
return (
<div className="destination-detail">
{/* Bagian header dengan gambar cover */}
<div className="detail-header">
<img
src={destination.cover_image ? `${uploadUrl}/${destination.cover_image}` : 'https://via.placeholder.com/1200x400?text=No+Image'}
alt={destination.name}
className="cover-image"
/>
<h1>{destination.name}</h1>
<p className="location">📍 {destination.location}</p>
</div>
{/* Deskripsi dan fasilitas */}
<div className="detail-section">
<h2>Deskripsi</h2>
<p>{destination.description || 'Tidak ada deskripsi.'}</p>
</div>
<div className="detail-section">
<h2>Fasilitas</h2>
{destination.facilities ? (
<p>{destination.facilities}</p>
) : (
<p>Tidak ada informasi fasilitas.</p>
)}
</div>
{/* Galeri foto */}
<div className="detail-section">
<h2>Galeri Foto</h2>
{galleries.length === 0 ? (
<p>Belum ada foto galeri.</p>
) : (
<div className="gallery-grid">
{galleries.map((img) => (
<img
key={img.id}
src={`${uploadUrl}/${img.image_url}`}
alt={`Gallery ${img.id}`}
className="gallery-image"
/>
))}
</div>
)}
</div>
{/* Paket harga */}
<div className="detail-section">
<h2>Paket & Harga</h2>
{packages.length === 0 ? (
<p>Belum ada paket tersedia.</p>
) : (
<div className="packages-grid">
{packages.map((pkg) => (
<div key={pkg.id} className="package-card">
<h3>{pkg.name}</h3>
{pkg.description < p < pkg.description > < < p < /p > }
<p className="price">Rp {pkg.price.toLocaleString()}</p>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default DestinationDetail;
Penjelasan:
useParamsmengambil parameteriddari URL (sesuai dengan route/destination/:id).- Kita menggunakan tiga state:
destination,galleries, danpackages. - Di
useEffect, kita memanggil ketiga API secara berurutan. Bisa juga menggunakanPromise.alluntuk paralel, tapi di sini kita sederhanakan. - Kita menampilkan loading dan error.
- Bagian-bagian halaman disusun dengan section yang jelas.
Langkah 3: Menambahkan CSS
Buat file src/pages/DestinationDetail.css:
.destination-detail {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.detail-header {
position: relative;
text-align: center;
margin-bottom: 30px;
}
.cover-image {
width: 100%;
height: 400px;
object-fit: cover;
border-radius: 8px;
}
.detail-header h1 {
margin-top: 20px;
color: #333;
}
.location {
color: #666;
font-size: 1.1em;
}
.detail-section {
margin-bottom: 40px;
}
.detail-section h2 {
border-bottom: 2px solid #e67e22;
padding-bottom: 10px;
margin-bottom: 20px;
color: #444;
}
/* Galeri grid */
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.gallery-image {
width: 100%;
height: 150px;
object-fit: cover;
border-radius: 4px;
cursor: pointer;
transition: transform 0.2s;
}
.gallery-image:hover {
transform: scale(1.05);
}
/* Paket grid */
.packages-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.package-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
background: #f9f9f9;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.package-card h3 {
margin-top: 0;
color: #e67e22;
}
.package-card .price {
font-size: 1.2em;
font-weight: bold;
color: #27ae60;
margin: 10px 0 0;
}
.loading, .error {
text-align: center;
margin-top: 50px;
font-size: 1.2em;
}
.error {
color: #e74c3c;
}
🔗 Langkah 4: Menambahkan Route
Di App.jsx, pastikan kita sudah menambahkan route untuk halaman detail. Jika menggunakan createBrowserRouter, tambahkan di dalam children layout:
{
path: '/destination/:id',
element: <DestinationDetail />,
}
Contoh lengkap:
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import MainLayout from './layouts/MainLayout';
import Home from './pages/Home';
import DestinationDetail from './pages/DestinationDetail';
const router = createBrowserRouter([
{
element: <MainLayout />,
children: [
{ path: '/', element: <Home /> },
{ path: '/destination/:id', element: <DestinationDetail /> },
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
export default App;
Langkah 5: Menguji Halaman Detail
Pastikan backend berjalan dan memiliki data destinasi dengan ID tertentu (misal ID 1). Jalankan frontend, lalu buka http://localhost:5173/destination/1. Halaman akan menampilkan detail destinasi, galeri, dan paket. Jika belum ada galeri atau paket, akan tampil pesan kosong.
Klik salah satu kartu di halaman beranda untuk navigasi ke halaman detail. Pastikan link di DestinationCard sudah benar.
backend/uploads.
Langkah 6: (Opsional) Menambahkan Lightbox untuk Galeri
Untuk meningkatkan pengalaman, kita bisa menambahkan lightbox (gambar tampil besar saat diklik). Instal library seperti react-image-lightbox atau buat sederhana dengan state. Contoh sederhana: tambahkan state selectedImage dan modal. Namun untuk menjaga tutorial tetap fokus, kita lewati dulu.
Penanganan Error
- Jika ID tidak valid atau data tidak ditemukan, backend akan mengembalikan 404, dan kita tangkap di catch, menampilkan pesan error.
- Jika salah satu API gagal (misal galeri), kita tetap ingin menampilkan data lain. Saat ini kita menggunakan satu try-catch yang akan menghentikan semua jika salah satu gagal. Untuk lebih baik, kita bisa memisahkan setiap panggilan API dengan try-catch masing-masing. Silakan modifikasi sesuai kebutuhan.
Langkah Selanjutnya
Di Studi Kasus #15, kita akan membuat halaman untuk penginapan (accommodations) dengan pola yang mirip, serta halaman transportasi. Kita juga bisa mulai memikirkan filter dan pencarian.
Pastikan halaman detail destinasi berfungsi dengan baik. Jika ada kendala, periksa kembali endpoint API dan data di database.
Sampai jumpa di tutorial berikutnya!
Ditulis dengan ❤️ untuk para pengembang yang ingin membangun aplikasi wisata.