Pencarian teks dari Bagian 2 menyaring katalog berdasarkan kata, tetapi pengguna katalog nyata jarang berhenti di situ. Kebiasaan yang paling sering terjadi berupa kombinasi: cari buku pemrograman, batasi harga sampai Rp100.000, urutkan dari yang termurah. Tiga keinginan itu harus bekerja bersama dalam satu alur tanpa saling menimpa — dan justru di titik kombinasi inilah bug biasa muncul. Bagian ini menambahkan dua filter (kategori dan rentang harga), enam pilihan urutan, lalu menyambung semuanya dengan pencarian menjadi satu pipeline murni yang bisa diuji dari ujung ke ujung.

Filter Kategori: Satu Nilai Sentinel #

Kategori di dataset kita berisi string seperti 'Pemrograman' atau 'Novel'. State menyimpan satu kategori aktif, dan buatState() memilih nilai sentinel 'semua' sebagai kondisi awal — artinya “belum ada penyaringan kategori”. Fungsinya:

js src/daftar.mjs — filterKategori
// Filter kategori: 'semua' melewati semua buku, selain itu cocok persis.
export function filterKategori(data, kategori) {
  if (kategori === 'semua') {
    return [...data];
  }
  return data.filter((buku) => buku.kategori === kategori);
}
  1. if (kategori === 'semua'): nilai sentinel diperiksa lebih dulu. Fungsi tetap mengembalikan [...data] — salinan baru, bukan array aslinya — sehingga pemanggil boleh mengolah hasilnya dengan aman tanpa peduli jalur mana yang dilewati.
  2. data.filter(...): selain itu, bandingkan buku.kategori dengan kategori terpilih menggunakan persamaan persis. Kategori 'Pemrograman' hanya cocok dengan 'Pemrograman' — bukan 'Pemrograman Web' bila kelak ada.

Rentang Harga dengan Batas null #

Filter harga punya bentuk pertanyaan yang berbeda: bukan “cocok persis”, melainkan “berada di antara dua batas”. Pengguna bisa membatasi hanya batas atas (harga sampai Rp100.000), hanya batas bawah (harga mulai Rp50.000), atau keduanya. Kita perlakukan batas yang tidak dipakai sebagai null:

js src/daftar.mjs — filterHarga
// Filter rentang harga (inklusif). Batas null berarti sisi itu tanpa batas.
export function filterHarga(data, hargaMin, hargaMaks) {
  return data.filter((buku) => {
    const lewatBatasBawah = hargaMin === null || buku.harga >= hargaMin;
    const lewatBatasAtas = hargaMaks === null || buku.harga <= hargaMaks;
    return lewatBatasBawah && lewatBatasAtas;
  });
}
  1. hargaMin === null || buku.harga >= hargaMin: pemeriksaan batas bawah. Operator || membuat null berarti “lewat” — bila batasnya tidak ada, kondisi ini benar tanpa melihat harga. Bandingkan harga memakai >=, sehingga buku yang harganya persis Rp50.000 tetap lolos dari rentang mulai Rp50.000: batasnya inklusif.
  2. hargaMaks === null || buku.harga <= hargaMaks: pemeriksaan batas atas dengan logika yang sama, berlaku inklusif pula.
  3. return lewatBatasBawah && lewatBatasAtas: sebuah buku lolos hanya bila kedua pemeriksaan benar. Dengan pola ini, empat kombinasi batas menghasilkan perilaku yang bisa diprediksi:
hargaMinhargaMaksArti bagi pengguna
nullnullsemua harga
50000nullharga mulai Rp50.000 ke atas
null100000harga sampai Rp100.000
50000100000harga Rp50.000 sampai Rp100.000

Enam Urutan dalam Satu Map #

Pengurutan menuntut jawaban atas dua pertanyaan: menyusun berdasarkan apa (judul, harga, atau rating) dan ke arah mana (menaik atau menurun). Tiga medan dikali dua arah menghasilkan enam pilihan. Alih-alih enam if, kita daftarkan enam fungsi pembanding (comparator) dalam satu Map — pola peta fungsi yang sama dengan operasi kalkulator pada proyek sebelumnya:

js src/daftar.mjs — KOMPARATOR dan urutkan
// Peta urutan: nama urutan -> fungsi pembanding dua buku.
const KOMPARATOR = new Map([
  ['judul-asc', (a, b) => a.judul.localeCompare(b.judul, 'id')],
  ['judul-desc', (a, b) => b.judul.localeCompare(a.judul, 'id')],
  ['harga-asc', (a, b) => a.harga - b.harga],
  ['harga-desc', (a, b) => b.harga - a.harga],
  ['rating-desc', (a, b) => b.rating - a.rating],
  ['rating-asc', (a, b) => a.rating - b.rating],
]);

// Urutkan tanpa mengubah data asli: salin dulu, baru sort.
export function urutkan(data, urutan) {
  const komparator = KOMPARATOR.get(urutan);
  if (komparator === undefined) {
    throw new Error(`Urutan tidak dikenal: ${urutan}`);
  }
  return [...data].sort(komparator);
}
  1. Komparator judul: localeCompare(b.judul, 'id') membandingkan string sesuai aturan alfabet bahasa Indonesia — lewat kode bahasa 'id', bukan urutan kode karakter. Arah asc dan desc hanyalah penukaran urutan a dan b pada pemanggilan yang sama.
  2. Komparator harga dan rating: selisih a.harga - b.harga menghasilkan angka negatif, nol, atau positif — tiga jawaban yang diharapkan sort. Bentuk b - a membalik arahnya menjadi menurun.
  3. KOMPARATOR.get(urutan): mencari pembanding berdasarkan nama urutan. Nama yang tidak terdaftar — misalnya 'warna-asc' dari kesalahan ketik di antarmuka — menghasilkan undefined, dan fungsi melempar Error dengan pesan yang menyebut pelakunya alih-alih diam-diam mengembalikan data acak.
  4. [...data].sort(komparator): sort mengurutkan array pada tempatnya (in place), sehingga memanggilnya langsung pada BUKU akan mengubah dataset asli. Menyalin dengan spread terlebih dahulu menjaga kontrak imutabel seluruh module.

State Bertambah: Batas Harga #

Pipeline butuh dua informasi baru dari state: batas bawah dan batas atas harga. buatState() versi Bagian 3 menambahkan keduanya dengan nilai awal null — sama sekali tanpa batas — dan satu updater baru menyertai:

js src/daftar.mjs — buatState dan ubahHargaMaks versi bagian 3
export function buatState() {
  return {
    kataKunci: '',
    kategori: 'semua',
    urutan: 'judul-asc',
    hargaMin: null,
    hargaMaks: null,
  };
}

export function ubahHargaMaks(state, nilai) {
  return { ...state, hargaMaks: nilai };
}

Dua catatan tentang keputusan ini. Pertama, pengujian Bagian 1 tetap lulus tanpa diubah karena asersinya memeriksa properti satu per satu, bukan membandingkan seluruh objek — pilihan kecil di Bagian 1 yang kini membayar hasilnya. Kedua, yang punya updater di Bagian 3 hanyalah batas atas, karena antarmuka di Bagian 4 memakai slider “harga sampai”. Batas bawah tetap bisa dipakai: state hanyalah objek polos, dan demo di bawah menyusunnya langsung dengan spread. Bila kelak dibutuhkan kontrol harga terendah, tambahannya mengikuti pola ubahHargaMaks persis.

Pipeline: prosesDaftar #

Semua komponen kini siap. Pipeline menggabungkan empat tahap menjadi satu fungsi yang menerima state dan data, lalu mengembalikan hasil akhir:

js src/daftar.mjs — prosesDaftar
// Pipeline utuh: cari -> filter kategori -> filter harga -> urutkan.
export function prosesDaftar(state, data) {
  const hasilCari = cari(data, state.kataKunci);
  const hasilKategori = filterKategori(hasilCari, state.kategori);
  const hasilHarga = filterHarga(hasilKategori, state.hargaMin, state.hargaMaks);
  return urutkan(hasilHarga, state.urutan);
}
  1. Tahap 1 (hasilCari): pencarian teks menangani bagian yang paling mahal — normalisasi seluruh judul dan penulis. Hasilnya berupa array yang sudah pendek.
  2. Tahap 2–3 (kategori, lalu harga): dua filter murah bekerja pada hasil tahap sebelumnya, masing-masing mengembalikan array baru yang lebih pendek.
  3. Tahap 4 (urutkan): pengurutan diletakkan paling akhir — membuang data lebih dulu baru mengurutkan lebih murah daripada sebaliknya, dan yang terpenting, hasil akhirnya selalu tersaji dalam urutan yang diminta.

Berikut daftar.mjs lengkap pada akhir Bagian 3 — seluruh logika proyek terkumpul di satu berkas yang tetap bebas DOM:

js daftar.mjs
// daftar.mjs — logika inti pencarian dan filter katalog buku.
// Semua fungsi murni: menerima data atau state, mengembalikan hasil baru,
// tanpa menyentuh DOM sehingga dapat diuji dengan node --test.

export function buatState() {
  return {
    kataKunci: '',
    kategori: 'semua',
    urutan: 'judul-asc',
    hargaMin: null,
    hargaMaks: null,
  };
}

export function ubahKataKunci(state, teks) {
  return { ...state, kataKunci: teks };
}

export function ubahKategori(state, kategori) {
  return { ...state, kategori };
}

export function ubahUrutan(state, urutan) {
  return { ...state, urutan };
}

export function ubahHargaMaks(state, nilai) {
  return { ...state, hargaMaks: nilai };
}

// Normalisasi teks: huruf kecil, tanda baca ringan diganti spasi,
// spasi berlebih dirapatkan. Dipakai untuk kata kunci maupun data.
export function normalisasiTeks(teks) {
  return teks
    .toLowerCase()
    .replace(/[.,;:!?"'()\-]/g, ' ')
    .trim()
    .replace(/\s+/g, ' ');
}

// Cari: kembalikan buku yang memuat SEMUA kata kunci di judul atau penulis.
// Kata kunci kosong (setelah normalisasi) mengembalikan salinan seluruh data.
export function cari(data, kataKunci) {
  const kata = normalisasiTeks(kataKunci).split(' ').filter((k) => k !== '');
  if (kata.length === 0) {
    return [...data];
  }
  return data.filter((buku) => {
    const sumber = normalisasiTeks(`${buku.judul} ${buku.penulis}`);
    return kata.every((k) => sumber.includes(k));
  });
}

// Filter kategori: 'semua' melewati semua buku, selain itu cocok persis.
export function filterKategori(data, kategori) {
  if (kategori === 'semua') {
    return [...data];
  }
  return data.filter((buku) => buku.kategori === kategori);
}

// Filter rentang harga (inklusif). Batas null berarti sisi itu tanpa batas.
export function filterHarga(data, hargaMin, hargaMaks) {
  return data.filter((buku) => {
    const lewatBatasBawah = hargaMin === null || buku.harga >= hargaMin;
    const lewatBatasAtas = hargaMaks === null || buku.harga <= hargaMaks;
    return lewatBatasBawah && lewatBatasAtas;
  });
}

// Peta urutan: nama urutan -> fungsi pembanding dua buku.
const KOMPARATOR = new Map([
  ['judul-asc', (a, b) => a.judul.localeCompare(b.judul, 'id')],
  ['judul-desc', (a, b) => b.judul.localeCompare(a.judul, 'id')],
  ['harga-asc', (a, b) => a.harga - b.harga],
  ['harga-desc', (a, b) => b.harga - a.harga],
  ['rating-desc', (a, b) => b.rating - a.rating],
  ['rating-asc', (a, b) => a.rating - b.rating],
]);

// Urutkan tanpa mengubah data asli: salin dulu, baru sort.
export function urutkan(data, urutan) {
  const komparator = KOMPARATOR.get(urutan);
  if (komparator === undefined) {
    throw new Error(`Urutan tidak dikenal: ${urutan}`);
  }
  return [...data].sort(komparator);
}

// Pipeline utuh: cari -> filter kategori -> filter harga -> urutkan.
export function prosesDaftar(state, data) {
  const hasilCari = cari(data, state.kataKunci);
  const hasilKategori = filterKategori(hasilCari, state.kategori);
  const hasilHarga = filterHarga(hasilKategori, state.hargaMin, state.hargaMaks);
  return urutkan(hasilHarga, state.urutan);
}

// Ringkasan satu baris untuk demo dan tampilan sederhana.
export function ringkasBuku(buku) {
  return `${buku.judul} — ${buku.penulis} (Rp${buku.harga.toLocaleString('id-ID')})`;
}

Demo berikut memutar tiga skenario nyata melalui pipeline. Skenario ketiga menyusun state langsung dengan spread untuk memakai batas bawah harga:

// demo-pipeline.mjs — menggabungkan cari, filter, dan urutan dalam satu pipeline.
import { BUKU } from './data.mjs';
import {
  buatState,
  prosesDaftar,
  ringkasBuku,
  ubahHargaMaks,
  ubahKataKunci,
  ubahKategori,
  ubahUrutan,
} from './daftar.mjs';

console.log('Skenario 1: Pemrograman, harga sampai Rp100.000, termurah dulu');
let state = ubahKategori(buatState(), 'Pemrograman');
state = ubahHargaMaks(state, 100000);
state = ubahUrutan(state, 'harga-asc');
let hasil = prosesDaftar(state, BUKU);
console.log('ditemukan:', hasil.length, 'buku');
hasil.forEach((buku, i) => console.log(`  ${i + 1}.`, ringkasBuku(buku)));

console.log('');
console.log('Skenario 2: kata kunci "kota", rating tertinggi dulu');
state = ubahKataKunci(buatState(), 'kota');
state = ubahUrutan(state, 'rating-desc');
hasil = prosesDaftar(state, BUKU);
console.log('ditemukan:', hasil.length, 'buku');
hasil.forEach((buku, i) => console.log(`  ${i + 1}.`, ringkasBuku(buku)));

console.log('');
console.log('Skenario 3: rentang harga Rp50.000-Rp60.000 di seluruh kategori');
state = buatState();
state = { ...state, hargaMin: 50000, hargaMaks: 60000 };
hasil = prosesDaftar(state, BUKU);
console.log('ditemukan:', hasil.length, 'buku');
hasil.forEach((buku, i) => console.log(`  ${i + 1}.`, ringkasBuku(buku)));
bash
node demo-pipeline.mjs

Output yang Dihasilkan:

text
Skenario 1: Pemrograman, harga sampai Rp100.000, termurah dulu
ditemukan: 3 buku
  1. Belajar Python dari Nol — Maya Kirana (Rp89.000)
  2. Dasar SQL untuk Analis Data — Hendra Wijaya (Rp95.000)
  3. Pemrograman JavaScript untuk Pemula — Raka Pratama (Rp99.000)

Skenario 2: kata kunci "kota", rating tertinggi dulu
ditemukan: 2 buku
  1. Rumah Kaca di Jalanan Kota — Salsabila Zahra (Rp92.000)
  2. Sepuluh Musim di Kota Tua — Gilang Saputra (Rp81.000)

Skenario 3: rentang harga Rp50.000-Rp60.000 di seluruh kategori
ditemukan: 2 buku
  1. Dunia Binatang Bersayap — Yoga Prasetyo (Rp57.000)
  2. Petualangan Kiko di Pulau Merah — Ririn Amalia (Rp54.000)

Skenario 1 membuktikan pipeline penuh: dari lima buku Pemrograman, dua yang harganya di atas Rp100.000 tersaring keluar, dan tiga sisanya tersaji dari harga termurah. Skenario 2 menguji lintas kategori — pencarian kota menemukan satu Novel dan satu Sejarah, lalu rating-desc menempatkan rating 4.6 di atas 3.9. Skenario 3 menunjukkan rentang dua batas dari seluruh kategori; urutannya tetap judul-asc dari state awal, itulah mengapa Dunia Binatang Bersayap muncul lebih dulu meski harganya lebih tinggi daripada Petualangan Kiko.

Uji Kombinasi #

Sembilan uji baru mengunci perilaku Bagian 3. Kelompok urutkan mencakup arah, batas inklusif, kestabilan sort, dan penolakan nama urutan yang tidak dikenal; kelompok prosesDaftar menguji pipeline dari ujung ke ujung:

js tests/daftar.test.mjs — tambahan bagian 3
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { BUKU } from '../src/data.mjs';
import {
  buatState,
  cari,
  filterHarga,
  filterKategori,
  normalisasiTeks,
  prosesDaftar,
  ringkasBuku,
  ubahHargaMaks,
  ubahKategori,
  ubahKataKunci,
  ubahUrutan,
  urutkan,
} from '../src/daftar.mjs';

// ... kelompok data, buatState, perubahan state, normalisasiTeks, dan cari tetap sama ...

describe('filterKategori', () => {
  test('kategori terpilih menyaring katalog', () => {
    const hasil = filterKategori(BUKU, 'Novel');
    assert.strictEqual(hasil.length, 4);
    for (const buku of hasil) {
      assert.strictEqual(buku.kategori, 'Novel');
    }
  });

  test('kategori "semua" mengembalikan salinan seluruh data', () => {
    const hasil = filterKategori(BUKU, 'semua');
    assert.strictEqual(hasil.length, BUKU.length);
    assert.notStrictEqual(hasil, BUKU);
  });
});

describe('filterHarga', () => {
  test('rentang min-maks bersifat inklusif', () => {
    const hasil = filterHarga(BUKU, 50000, 90000);
    assert.strictEqual(hasil.length, 10);
    for (const buku of hasil) {
      assert.ok(buku.harga >= 50000 && buku.harga <= 90000);
    }
  });

  test('batas null berarti sisi itu tanpa batas', () => {
    assert.strictEqual(filterHarga(BUKU, null, 60000).length, 4);
    assert.strictEqual(filterHarga(BUKU, 100000, null).length, 6);
  });
});

describe('urutkan', () => {
  test('judul-asc memakai localeCompare bahasa Indonesia', () => {
    const hasil = urutkan(BUKU, 'judul-asc');
    assert.strictEqual(hasil[0].judul, 'Algoritma dan Struktur Data');
    assert.notStrictEqual(hasil, BUKU);
  });

  test('harga-desc menempatkan buku termahal di awal', () => {
    const hasil = urutkan(BUKU, 'harga-desc');
    assert.strictEqual(hasil[0].harga, 148000);
  });

  test('rating-desc menjaga urutan asli saat rating seri', () => {
    const hasil = urutkan(BUKU, 'rating-desc');
    const seri = hasil.filter((buku) => buku.rating === 4.7);
    assert.strictEqual(seri.length, 2);
    assert.strictEqual(seri[0].judul, 'Pemrograman JavaScript untuk Pemula');
    assert.strictEqual(seri[1].judul, 'Kerajaan Maritim dan Jalur Sutra');
  });

  test('urutan tidak dikenal ditolak', () => {
    assert.throws(() => urutkan(BUKU, 'warna-asc'), /tidak dikenal/);
  });
});

describe('prosesDaftar', () => {
  test('pipeline menggabungkan cari, filter, dan urutan', () => {
    let state = ubahKategori(buatState(), 'Pemrograman');
    state = ubahHargaMaks(state, 100000);
    state = ubahUrutan(state, 'harga-asc');
    const hasil = prosesDaftar(state, BUKU);
    assert.deepStrictEqual(hasil.map((buku) => buku.judul), [
      'Belajar Python dari Nol',
      'Dasar SQL untuk Analis Data',
      'Pemrograman JavaScript untuk Pemula',
    ]);
  });

  test('state awal mengembalikan seluruh katalog terurut judul', () => {
    const hasil = prosesDaftar(buatState(), BUKU);
    assert.strictEqual(hasil.length, BUKU.length);
    assert.strictEqual(hasil[0].judul, 'Algoritma dan Struktur Data');
  });
});

Jalankan kembali dari folder proyek:

bash
node --test

Output yang Dihasilkan:

text
▶ data
  ✔ katalog berisi 20+ buku dengan lima properti (0.584966ms)
✔ data (1.353481ms)
▶ buatState
  ✔ state awal menampilkan semua kategori terurut judul (0.144859ms)
✔ buatState (0.247991ms)
▶ perubahan state
  ✔ ubahKataKunci memperbarui kata kunci (0.182986ms)
  ✔ ubahKategori dan ubahUrutan mengganti pilihan aktif (0.119419ms)
  ✔ fungsi ubah mengembalikan state baru, bukan mengubah yang lama (0.10195ms)
✔ perubahan state (0.590019ms)
▶ normalisasiTeks
  ✔ membuat huruf kecil dan memangkas spasi (0.223182ms)
  ✔ mengganti tanda baca ringan dengan spasi (0.137643ms)
✔ normalisasiTeks (0.566624ms)
▶ cari
  ✔ kata kunci kosong mengembalikan salinan seluruh data (0.227558ms)
  ✔ satu kata mencocokkan judul dan penulis (1.069062ms)
  ✔ cuplikan kata ikut cocok tanpa perlu kata utuh (0.454989ms)
  ✔ banyak kata: semua kata harus ada (0.162148ms)
  ✔ tanda baca dan spasi berlebih pada kata kunci diabaikan (0.0786ms)
  ✔ tidak ada yang cocok mengembalikan array kosong (0.546282ms)
✔ cari (2.771395ms)
▶ filterKategori
  ✔ kategori terpilih menyaring katalog (0.123562ms)
  ✔ kategori "semua" mengembalikan salinan seluruh data (0.050216ms)
✔ filterKategori (0.23896ms)
▶ filterHarga
  ✔ rentang min-maks bersifat inklusif (0.095955ms)
  ✔ batas null berarti sisi itu tanpa batas (0.038822ms)
✔ filterHarga (0.182936ms)
▶ urutkan
  ✔ judul-asc memakai localeCompare bahasa Indonesia (6.626231ms)
  ✔ harga-desc menempatkan buku termahal di awal (0.127776ms)
  ✔ rating-desc menjaga urutan asli saat rating seri (0.084426ms)
  ✔ urutan tidak dikenal ditolak (0.337166ms)
✔ urutkan (7.281709ms)
▶ prosesDaftar
  ✔ pipeline menggabungkan cari, filter, dan urutan (0.299147ms)
  ✔ state awal mengembalikan seluruh katalog terurut judul (0.083181ms)
✔ prosesDaftar (0.453128ms)
ℹ tests 23
ℹ suites 9
ℹ pass 23
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 90.523737

Dua puluh tiga uji lulus. Uji rating-desc menjaga urutan asli saat rating seri paling layak dibaca dua kali: kedua buku ber-rating 4.7 muncul dalam urutan asli dataset — Pemrograman JavaScript untuk Pemula sebelum Kerajaan Maritim dan Jalur Sutra — karena sort stabil. Uji pipeline memakai assert.deepStrictEqual atas hasil .map(buku => buku.judul), sehingga urutan seluruh judul diverifikasi sekaligus, bukan hanya elemen pertamanya.

Ringkasan Bagian 3 #

Logika katalog kini lengkap: pencarian teks, filter kategori, filter rentang harga dengan batas null, enam urutan dalam satu peta komparator, dan pipeline prosesDaftar yang menyambung semuanya dalam satu arah data. Dua puluh tiga uji menjaga setiap lapisan, termasuk perilaku kombinasi yang justru paling mudah rusak. Yang tersisa adalah sisi pengguna: mengubah state ini dari panggilan demo menjadi reaksi terhadap ketikan dan klik — tanpa membuat antarmuka tersendat. Itulah pekerjaan Bagian 4.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

styles/styles.css

Unduh berkas
css
/* styles.css — tampilan katalog buku pencarian dan filter */

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  padding: 24px 16px;
  background-color: #f3f4f6;
  color: #1f2937;
  font-family: system-ui, sans-serif;
}

.katalog {
  max-width: 640px;
  margin: 0 auto;
}

h1 {
  margin: 0 0 16px;
  font-size: 1.6rem;
}

.panel-kontrol {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 12px;
  padding: 16px;
  background-color: #ffffff;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}

.panel-kontrol label {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 0.85rem;
  font-weight: 600;
}

.panel-kontrol input,
.panel-kontrol select {
  padding: 8px 10px;
  font-size: 1rem;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  background-color: #ffffff;
  font-family: inherit;
}

.kolom-cari {
  grid-column: span 2;
}

.status {
  margin: 16px 0 8px;
  font-size: 0.9rem;
  color: #6b7280;
}

.hasil {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 10px;
}

.kartu-buku {
  padding: 12px 16px;
  background-color: #ffffff;
  border-radius: 10px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}

.kartu-buku h2 {
  margin: 0 0 4px;
  font-size: 1.05rem;
}

.kartu-buku .keterangan {
  margin: 0 0 6px;
  font-size: 0.85rem;
  color: #6b7280;
}

.kartu-buku .harga {
  margin: 0;
  font-weight: 700;
  color: #047857;
}