Studi Kasus #15: Aplikasi Wisata Indonesia – Membuat Halaman Penginapan dan Detailnya


Studi Kasus #15: Aplikasi Wisata Indonesia – Membuat Halaman Penginapan dan Detailnya

Halo, calon full-stack developer! 👋

Di Studi Kasus #13 dan #14, kita telah membuat halaman daftar dan detail untuk tempat wisata (destinations). Sekarang kita akan membuat halaman yang serupa untuk penginapan (accommodations). Polanya hampir sama, hanya berbeda pada endpoint API dan beberapa penyesuaian tampilan.

Fitur yang akan dibuat:

  • Halaman daftar penginapan dengan tampilan grid kartu.
  • Setiap kartu menampilkan foto cover, nama, lokasi, dan deskripsi singkat.
  • Halaman detail penginapan yang menampilkan informasi lengkap: deskripsi, fasilitas, galeri foto, dan paket harga (tipe kamar).
  • Mengambil data dari endpoint /accommodations, /galleries/accommodation/:id, dan /packages/accommodation/:id.

Kita akan memanfaatkan komponen yang sudah dibuat sebelumnya (seperti Card) dan membuat komponen baru yang spesifik untuk penginapan. Mari kita mulai!

Langkah 1: Menambahkan Service untuk Penginapan

Pertama, pastikan kita memiliki service untuk mengambil data penginapan. Buat file src/services/accommodationService.js jika belum ada:

import API from './api';

export const getAccommodations = async () => {
  try {
    const response = await API.get('/accommodations');
    return response.data;
  } catch (error) {
    console.error('Gagal mengambil penginapan:', error);
    throw error;
  }
};

export const getAccommodationById = async (id) => {
  try {
    const response = await API.get(`/accommodations/${id}`);
    return response.data;
  } catch (error) {
    console.error('Gagal mengambil penginapan:', error);
    throw error;
  }
};

Service untuk galeri dan paket sudah kita buat di tutorial sebelumnya. Kita akan gunakan fungsi yang sama dengan parameter entityType = 'accommodation'.

Langkah 2: Membuat Komponen Card untuk Penginapan

Kita bisa menggunakan komponen DestinationCard yang sudah ada, tetapi karena nanti mungkin ada perbedaan tampilan, lebih baik buat komponen khusus. Buat file src/components/AccommodationCard.jsx:

import React from 'react';
import { Link } from 'react-router-dom';
import './AccommodationCard.css';

const AccommodationCard = ({ accommodation }) => {
  const imageUrl = accommodation.cover_image 
    ? `${import.meta.env.VITE_UPLOAD_URL}/${accommodation.cover_image}`
    : 'https://via.placeholder.com/300x200?text=No+Image';

  const shortDescription = accommodation.description 
    ? accommodation.description.substring(0, 100) + '...'
    : 'Tidak ada deskripsi';

  return (
    <Link to={`/accommodation/${accommodation.id}`} className="card-link">
      <div className="accommodation-card">
        <img src={imageUrl} alt={accommodation.name} className="card-image" />
        <div className="card-content">
          <h3>{accommodation.name}</h3>
          <p className="location">📍 {accommodation.location}</p>
          <p className="description">{shortDescription}</p>
        </div>
      </div>
    </Link>
  );
};

export default AccommodationCard;

Buat file CSS src/components/AccommodationCard.css (bisa mirip dengan DestinationCard, atau disatukan):

.accommodation-card {
  border: 1px solid #ddd;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 2px 5px rgba(0,0,0,0.1);
  transition: transform 0.2s;
  background: white;
}

.accommodation-card:hover {
  transform: scale(1.02);
  box-shadow: 0 4px 10px rgba(0,0,0,0.15);
}

.card-image {
  width: 100%;
  height: 200px;
  object-fit: cover;
}

.card-content {
  padding: 15px;
}

.card-content h3 {
  margin: 0 0 10px 0;
  color: #333;
}

.location {
  color: #666;
  font-size: 0.9em;
  margin: 5px 0;
}

.description {
  color: #777;
  font-size: 0.9em;
  line-height: 1.4;
}

.card-link {
  text-decoration: none;
  color: inherit;
}

Langkah 3: Membuat Halaman Daftar Penginapan

Buat file src/pages/Accommodations.jsx (halaman daftar semua penginapan):

import React, { useState, useEffect } from 'react';
import { getAccommodations } from '../services/accommodationService';
import AccommodationCard from '../components/AccommodationCard';
import './Accommodations.css';

const Accommodations = () => {
  const [accommodations, setAccommodations] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchAccommodations = async () => {
      try {
        const data = await getAccommodations();
        setAccommodations(data);
      } catch (err) {
        setError('Gagal mengambil data penginapan.');
        console.error(err);
      } finally {
        setLoading(false);
      }
    };

    fetchAccommodations();
  }, []);

  if (loading) {
    return <div className="loading">Memuat data...</div>;
  }

  if (error) {
    return <div className="error">{error}</div>;
  }

  return (
    <div className="accommodations-page">
      <h1>Penginapan di Indonesia</h1>
      {accommodations.length === 0 ? (
        <p>Belum ada data penginapan.</p>
      ) : (
        <div className="accommodation-grid">
          {accommodations.map((acc) => (
            <AccommodationCard key={acc.id} accommodation={acc} />
          ))}
        </div>
      )}
    </div>
  );
};

export default Accommodations;

Buat file CSS src/pages/Accommodations.css:

.accommodations-page {
  padding: 20px;
}

.accommodations-page h1 {
  text-align: center;
  margin-bottom: 30px;
  color: #2c3e50;
}

.accommodation-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 20px;
}

.loading, .error {
  text-align: center;
  margin-top: 50px;
  font-size: 1.2em;
}

.error {
  color: #e74c3c;
}

📄 Langkah 4: Membuat Halaman Detail Penginapan

Buat file src/pages/AccommodationDetail.jsx. Strukturnya mirip dengan DestinationDetail, hanya mengganti entity type menjadi 'accommodation'.

import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { getAccommodationById } from '../services/accommodationService';
import { getGalleriesByEntity } from '../services/galleryService';
import { getPackagesByEntity } from '../services/packageService';
import './AccommodationDetail.css';

const AccommodationDetail = () => {
  const { id } = useParams();
  const [accommodation, setAccommodation] = 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 {
        const accData = await getAccommodationById(id);
        setAccommodation(accData);

        const galleryData = await getGalleriesByEntity('accommodation', id);
        setGalleries(galleryData);

        const packageData = await getPackagesByEntity('accommodation', id);
        setPackages(packageData);
      } catch (err) {
        setError('Gagal memuat data penginapan.');
        console.error(err);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [id]);

  if (loading) {
    return <div className="loading">Memuat data...</div>;
  }

  if (error || !accommodation) {
    return <div className="error">{error || 'Penginapan tidak ditemukan'}</div>;
  }

  const uploadUrl = import.meta.env.VITE_UPLOAD_URL || 'http://localhost:3000/uploads';

  return (
    <div className="accommodation-detail">
      <div className="detail-header">
        <img 
          src={accommodation.cover_image ? `${uploadUrl}/${accommodation.cover_image}` : 'https://via.placeholder.com/1200x400?text=No+Image'} 
          alt={accommodation.name}
          className="cover-image"
        />
        <h1>{accommodation.name}</h1>
        <p className="location">📍 {accommodation.location}</p>
      </div>

      <div className="detail-section">
        <h2>Deskripsi</h2>
        <p>{accommodation.description || 'Tidak ada deskripsi.'}</p>
      </div>

      <div className="detail-section">
        <h2>Fasilitas</h2>
        {accommodation.facilities ? (
          <p>{accommodation.facilities}</p>
        ) : (
          <p>Tidak ada informasi fasilitas.</p>
        )}
      </div>

      <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>

      <div className="detail-section">
        <h2>Tipe Kamar & 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 AccommodationDetail;

Buat file CSS src/pages/AccommodationDetail.css (bisa mirip dengan DestinationDetail.css, atau impor dari file yang sama):

.accommodation-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 #3498db;
  padding-bottom: 10px;
  margin-bottom: 20px;
  color: #444;
}

.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);
}

.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: #3498db;
}

.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 5: Menambahkan Route

Di App.jsx, tambahkan route untuk halaman daftar penginapan dan detail penginapan:

import Accommodations from './pages/Accommodations';
import AccommodationDetail from './pages/AccommodationDetail';

// Di dalam router, tambahkan:
{
  path: '/accommodations',
  element: <Accommodations />,
},
{
  path: '/accommodation/:id',
  element: <AccommodationDetail />,
}

Contoh lengkap App.jsx setelah ditambahkan:

import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import MainLayout from './layouts/MainLayout';
import Home from './pages/Home';
import DestinationDetail from './pages/DestinationDetail';
import Accommodations from './pages/Accommodations';
import AccommodationDetail from './pages/AccommodationDetail';

const router = createBrowserRouter([
  {
    element: <MainLayout />,
    children: [
      { path: '/', element: <Home /> },
      { path: '/destination/:id', element: <DestinationDetail /> },
      { path: '/accommodations', element: <Accommodations /> },
      { path: '/accommodation/:id', element: <AccommodationDetail /> },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

export default App;

Jangan lupa untuk menambahkan link ke halaman daftar penginapan di navbar (misal di Navbar.jsx).

Langkah 6: Menguji Halaman

Pastikan backend berjalan dan memiliki data penginapan (accommodations). Buka http://localhost:5173/accommodations untuk melihat daftar penginapan. Klik salah satu kartu untuk menuju halaman detail.

Jika belum ada data, kamu bisa menambahkan melalui API menggunakan Postman (endpoint POST /api/accommodations) dengan form-data (termasuk cover_image).

💡 Tips: Untuk memudahkan pengujian, kamu bisa menambahkan beberapa data dummy melalui phpMyAdmin atau langsung menggunakan Postman.

Penanganan Error dan Loading

Sama seperti halaman destinasi, kita sudah menambahkan state loading dan error. Pastikan untuk menguji skenario ketika backend mati atau ID tidak valid.

Langkah Selanjutnya

Di Studi Kasus #16, kita akan membuat halaman untuk transportasi dengan pola yang sama. Setelah itu, kita bisa mulai menambahkan fitur filter dan pencarian.

Pastikan halaman penginapan berfungsi dengan baik. Jika ada kendala, periksa kembali endpoint API dan pastikan service sudah benar.

Sampai jumpa di tutorial berikutnya!


Ditulis dengan ❤️ untuk para pengembang yang ingin membangun aplikasi wisata.

Lebih baru Lebih lama

نموذج الاتصال