Tutorial Laravel 12 Inertia React: Bikin SPA Projek Toko Buku Digital

Bayangkan kamu bisa membangun aplikasi e-commerce modern seperti Gramedia Digital atau Google Play Books dengan teknologi yang sama canggihnya. Single Page Application yang smooth, tanpa loading antar halaman, dengan admin dashboard yang powerful untuk manage ribuan produk.

Kabar baiknya, itu semua bisa kamu bangun sendiri dengan Laravel 12, Inertia.js, dan React.

Halo, saya Angga Risky Setiawan, founder BuildWithAngga dan professional freelancer dengan pengalaman lebih dari 11 tahun di industri web development. Selama perjalanan karir saya, sudah ratusan project dari berbagai client lokal dan internasional yang saya kerjakan. Dari pengalaman itu, saya belajar satu hal penting: teknologi yang tepat bisa membuat perbedaan besar antara project yang sukses dan yang gagal.

Dan kombinasi Laravel + Inertia + React adalah salah satu stack paling powerful yang pernah saya gunakan untuk membangun aplikasi modern.

Di tutorial ini, saya akan membimbing kamu step-by-step membangun Toko Buku Digital dari nol. Bukan sekedar tutorial CRUD sederhana, tapi aplikasi lengkap dengan fitur yang siap production. Kita akan buat dalam 10 bagian, dan di akhir tutorial, kamu akan punya portfolio project yang impressive.

Siap memulai perjalanan membangun aplikasi full-stack modern? Mari kita mulai.


Apa yang Akan Kita Bangun?

Kita akan membangun Toko Buku Digital - sebuah Single Page Application (SPA) lengkap dengan fitur:

Untuk Customer:

  • Homepage dengan buku featured dan terbaru
  • Katalog buku dengan search, filter kategori, dan sorting
  • Halaman detail buku dengan informasi lengkap
  • Shopping cart yang bisa tambah, kurang, dan hapus item
  • Checkout dengan form alamat pengiriman
  • Riwayat pesanan setelah checkout

Untuk Admin:

  • Dashboard admin yang ter-protect
  • CRUD buku lengkap dengan upload cover image
  • Manage kategori dan stock

Tech Stack:

  • Laravel 12 (Backend)
  • React 19 dengan TypeScript (Frontend)
  • Inertia.js 2 (Bridge antara Laravel dan React)
  • Tailwind CSS 4 (Styling)
  • shadcn/ui (Component library)

Hasil akhirnya adalah aplikasi yang smooth seperti aplikasi native, tanpa page reload yang mengganggu, tapi tetap dengan semua keunggulan Laravel di backend.

Kenapa Laravel + Inertia + React?

Sebelum masuk ke coding, penting untuk memahami kenapa kombinasi ini sangat powerful.

The Best of Both Worlds

Laravel adalah framework PHP terbaik untuk backend. Routing yang elegant, Eloquent ORM yang intuitive, authentication yang tinggal pakai, dan ecosystem yang mature. Di sisi lain, React adalah library JavaScript paling populer untuk membangun user interface yang dynamic dan interactive.

Masalahnya, menggabungkan keduanya secara tradisional berarti membangun dua aplikasi terpisah. Laravel sebagai API, React sebagai frontend terpisah. Ini artinya double work: routing di backend dan di frontend, authentication yang kompleks dengan JWT, dan dua codebase yang harus di-maintain.

Inertia.js Mengubah Segalanya

Inertia.js adalah "glue" yang menghubungkan Laravel dan React dengan cara yang brilliant. Kamu tetap menulis routing dan controller di Laravel seperti biasa. Tapi instead of returning Blade view, kamu return React component dengan props.

Hasilnya? SPA experience yang smooth untuk user, tapi development experience yang familiar untuk Laravel developer.

Berikut perbandingannya:

AspectTraditional API + ReactLaravel + Inertia + React
RoutingDua tempat (backend + frontend)Satu tempat (Laravel only)
State ManagementRedux/Context/ZustandProps dari server
AuthenticationJWT/Token (kompleks)Session (Laravel default)
SEOClient-side rendering (harder)SSR ready
Development SpeedLebih lambat (2 apps)Lebih cepat (1 codebase)
Deployment2 servers1 server
Learning CurveSteepGentle untuk Laravel dev

Dengan Inertia, kamu tidak perlu belajar state management library yang kompleks. Data mengalir dari controller ke component sebagai props. Simple dan predictable.

Prerequisites

Sebelum memulai, pastikan kamu sudah punya:

Software yang Harus Terinstall:

  • PHP 8.2 atau lebih baru
  • Composer (PHP package manager)
  • Node.js 18+ dan npm
  • MySQL atau PostgreSQL
  • Code editor (VS Code sangat direkomendasikan)
  • Terminal atau Command Line

Knowledge yang Dibutuhkan:

  • Basic PHP dan Laravel (routing, controller, model)
  • Basic JavaScript dan React (components, props, state)
  • Basic SQL (select, insert, update, delete)

Kalau kamu belum familiar dengan salah satu dari itu, jangan khawatir. Saya akan jelaskan setiap langkah dengan detail. Yang penting, ikuti tutorial ini sambil praktek langsung.


Install Laravel 12 dengan React Starter Kit

Laravel 12 yang dirilis Februari 2025 membawa perubahan besar pada starter kit. Sekarang, ketika membuat project baru, kamu bisa langsung memilih React sebagai frontend tanpa perlu install manual.

Step 1: Update Laravel Installer

Buka terminal dan jalankan:

composer global require laravel/installer

Verifikasi versi installer:

laravel --version

Pastikan versi yang terinstall adalah yang terbaru (minimal 5.x untuk Laravel 12 support).

Step 2: Buat Project Baru

laravel new toko-buku-digital

Setelah menjalankan command ini, kamu akan melihat beberapa prompt interaktif.

Step 3: Pilih Options

Ketika muncul pertanyaan:

Which starter kit would you like to install?
[none    ] None
[react   ] React
[vue     ] Vue
[livewire] Livewire

Pilih: react

Selanjutnya:

Which authentication provider do you prefer?
[laravel] Laravel's built-in authentication
[workos ] WorkOS (Requires WorkOS account)

Pilih: laravel

Untuk testing framework, pilih sesuai preferensi (PHPUnit atau Pest, keduanya bagus).

Ketika ditanya "Would you like to run npm install and npm run build?", jawab yes.

Step 4: Tunggu Proses Instalasi

Proses ini akan memakan waktu beberapa menit karena:

  • Download Laravel dan semua dependencies PHP
  • Download React, TypeScript, Tailwind, dan semua dependencies JavaScript
  • Setup shadcn/ui components
  • Build initial assets

Setelah selesai, kamu akan melihat pesan:

INFO  Application ready in [toko-buku-digital].

Memahami Struktur Folder

Masuk ke folder project:

cd toko-buku-digital

Mari lihat struktur folder yang ter-generate:

toko-buku-digital/
├── app/
│   ├── Http/
│   │   └── Controllers/      # Laravel controllers
│   └── Models/               # Eloquent models
├── resources/
│   └── js/
│       ├── components/       # Reusable React components
│       │   └── ui/          # shadcn/ui components (Button, Card, dll)
│       ├── hooks/           # Custom React hooks
│       ├── layouts/         # Layout templates
│       ├── lib/             # Utility functions
│       ├── pages/           # Inertia pages (1 file = 1 route)
│       └── types/           # TypeScript definitions
├── routes/
│   └── web.php              # Semua routes didefinisikan di sini
├── database/
│   └── migrations/          # Database migrations
└── public/                  # Public assets

Folder-folder Penting:

resources/js/pages/ - Ini adalah "views" kamu. Setiap file di sini adalah satu halaman. Misalnya Dashboard.tsx adalah halaman dashboard yang bisa diakses setelah login.

resources/js/components/ui/ - shadcn/ui components yang sudah ter-install. Button, Card, Input, Dialog, dan banyak lagi. Kamu tidak perlu styling dari nol.

resources/js/layouts/ - Layout templates untuk halaman. Ada AuthenticatedLayout untuk halaman yang butuh login, dan GuestLayout untuk halaman publik.

routes/web.php - Semua routing didefinisikan di sini. Tidak ada api.php terpisah karena Inertia menggunakan web routes.


Setup Database

Sebelum menjalankan aplikasi, kita perlu setup database.

Step 1: Buat Database

Buka MySQL client (bisa phpMyAdmin, TablePlus, atau command line) dan buat database baru:

CREATE DATABASE toko_buku_digital;

Step 2: Konfigurasi Environment

Buka file .env di root project dan update bagian database:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=toko_buku_digital
DB_USERNAME=root
DB_PASSWORD=

Sesuaikan DB_USERNAME dan DB_PASSWORD dengan konfigurasi MySQL kamu.

Step 3: Jalankan Migration

php artisan migrate

Ini akan membuat tabel-tabel default Laravel seperti users, password_reset_tokens, sessions, dan lainnya.


Menjalankan Development Server

Laravel 12 React starter kit membutuhkan dua server untuk development: Laravel server dan Vite server untuk React.

Cara Mudah (Recommended):

composer run dev

Command ini akan menjalankan keduanya sekaligus menggunakan concurrently.

Cara Manual (Jika cara di atas tidak work):

Terminal 1 - Laravel Server:

php artisan serve

Terminal 2 - Vite Server:

npm run dev

Setelah keduanya berjalan, kamu akan melihat:

  • Laravel: Server running on [<http://127.0.0.1:8000>]
  • Vite: Local: <http://localhost:5173/>

Test Aplikasi Default

Buka browser dan akses: http://localhost:8000

Kamu akan melihat welcome page dengan tombol Login dan Register di pojok kanan atas.

Test Registration:

  1. Klik "Register"
  2. Isi form dengan data dummy:
    • Name: Test User
    • Email: [email protected]
    • Password: password123
    • Confirm Password: password123
  3. Klik "Register"

Setelah register berhasil, kamu akan di-redirect ke Dashboard. Ini adalah halaman yang hanya bisa diakses setelah login.

Perhatikan:

  • Navigasi antar halaman terasa instant tanpa page reload
  • URL berubah seperti aplikasi biasa
  • Browser back/forward button tetap work

Ini adalah magic dari Inertia.js. User mendapat SPA experience, tapi kamu sebagai developer tetap menggunakan paradigma Laravel yang familiar.

Test Logout:

Klik nama user di pojok kanan atas, lalu klik Logout. Kamu akan kembali ke halaman welcome.


Apa yang Sudah Kita Dapat?

Dengan menjalankan satu command laravel new, kita sudah mendapatkan:

Authentication lengkap:

  • Login
  • Register
  • Password reset
  • Email verification
  • Profile management

Frontend setup:

  • React 19 dengan TypeScript
  • Tailwind CSS 4 ter-konfigurasi
  • shadcn/ui components siap pakai
  • Vite untuk fast hot reload

Backend setup:

  • Laravel 12 dengan semua fitur
  • Inertia.js middleware
  • Session-based authentication

Ini adalah starting point yang sangat solid. Di tutorial-tutorial selanjutnya, kita akan build di atas fondasi ini.


Troubleshooting

Beberapa masalah umum yang mungkin kamu temui:

"npm: command not found" Node.js belum terinstall. Download dan install dari nodejs.org

"SQLSTATE[HY000] [1049] Unknown database" Database belum dibuat. Buat dulu dengan CREATE DATABASE toko_buku_digital;

"Vite manifest not found" Jalankan npm run dev di terminal terpisah

Halaman tidak berubah setelah edit Pastikan Vite server berjalan. Hot reload hanya work kalau Vite aktif.

Port 8000 sudah digunakan Gunakan port lain: php artisan serve --port=8080


Apa Selanjutnya?

Di bagian ini, kita sudah berhasil:

  • Memahami kenapa Laravel + Inertia + React adalah kombinasi yang powerful
  • Install Laravel 12 dengan React starter kit
  • Memahami struktur folder project
  • Setup database dan menjalankan migration
  • Menjalankan development server
  • Test fitur authentication default

Di Bagian 2, kita akan mulai membangun fondasi toko buku dengan:

  • Merancang database schema untuk categories, books, orders
  • Membuat migrations dan models dengan relationships
  • Membuat seeders untuk data dummy
  • Menjalankan seeder untuk populate database

Bagian 2: Database Design & Models

Project sudah ter-setup dengan Laravel 12 dan React. Sekarang saatnya membangun fondasi yang paling penting: database.

Saya selalu bilang ke client dan tim saya, database design yang baik adalah setengah dari kesuksesan project. Kalau struktur datanya berantakan dari awal, kamu akan terus-terusan refactor di kemudian hari. Jadi mari kita rancang dengan benar dari awal.

Untuk toko buku digital kita, ada beberapa entity utama yang perlu dimodelkan: kategori buku, buku itu sendiri, pesanan dari customer, dan detail item dalam setiap pesanan. Mari kita breakdown satu per satu.

Perencanaan Database Schema

Sebelum menulis kode, saya biasanya menggambar relationship antar tabel dulu. Ini membantu visualisasi bagaimana data akan mengalir dalam aplikasi.

Entity dan Relationships:

User sudah ada dari Laravel default. Satu user bisa punya banyak orders. Ini adalah relasi one-to-many.

Category adalah pengelompokan buku. Satu category bisa punya banyak books. Relasi one-to-many juga.

Book adalah produk utama kita. Setiap book belongs to satu category. Book juga bisa muncul di banyak order items.

Order adalah transaksi pembelian. Satu order belongs to satu user, dan punya banyak order items.

OrderItem adalah detail item dalam order. Menyimpan informasi buku apa yang dibeli, berapa quantity, dan harga saat checkout. Kenapa harga disimpan di sini? Karena harga buku bisa berubah, tapi harga saat customer checkout harus tetap.

Tabel yang Akan Dibuat:

users (sudah ada dari Laravel)
├── id
├── name
├── email
├── password
└── timestamps

categories
├── id
├── name
├── slug
├── description
├── icon
└── timestamps

books
├── id
├── category_id (foreign key)
├── title
├── slug
├── author
├── description
├── price
├── cover_image
├── stock
├── is_featured
├── is_active
├── year_published
├── isbn
├── pages
└── timestamps

orders
├── id
├── user_id (foreign key)
├── order_number
├── total_amount
├── status
├── shipping_address
├── phone
├── notes
└── timestamps

order_items
├── id
├── order_id (foreign key)
├── book_id (foreign key)
├── quantity
├── price
└── timestamps

Dengan schema ini, kita bisa track semua yang dibutuhkan untuk toko buku online.

Membuat Migration untuk Categories

Mari mulai dari yang paling sederhana. Buka terminal dan jalankan:

php artisan make:migration create_categories_table

Buka file migration yang baru dibuat di database/migrations/. Nama filenya akan seperti 2025_01_15_000001_create_categories_table.php dengan timestamp yang berbeda.

Edit isinya menjadi:

<?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('categories', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description')->nullable();
            $table->string('icon')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('categories');
    }
};

Penjelasan kolom:

  • name menyimpan nama kategori seperti "Fiksi" atau "Teknologi"
  • slug adalah versi URL-friendly dari nama, misalnya "fiksi" atau "teknologi". Unique karena akan digunakan di URL
  • description opsional untuk menjelaskan kategori
  • icon opsional untuk emoji atau icon class

Membuat Migration untuk Books

php artisan make:migration create_books_table

Edit migration:

<?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('books', function (Blueprint $table) {
            $table->id();
            $table->foreignId('category_id')->constrained()->onDelete('cascade');
            $table->string('title');
            $table->string('slug')->unique();
            $table->string('author');
            $table->text('description');
            $table->decimal('price', 10, 2);
            $table->string('cover_image')->nullable();
            $table->integer('stock')->default(0);
            $table->boolean('is_featured')->default(false);
            $table->boolean('is_active')->default(true);
            $table->integer('year_published')->nullable();
            $table->string('isbn')->nullable();
            $table->integer('pages')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('books');
    }
};

Beberapa hal penting di sini:

foreignId('category_id')->constrained()->onDelete('cascade') secara otomatis membuat foreign key ke tabel categories. Kalau category dihapus, semua buku dalam kategori itu juga ikut terhapus.

decimal('price', 10, 2) menyimpan harga dengan 10 digit total dan 2 digit di belakang koma. Cocok untuk harga dalam Rupiah.

is_featured menandai buku yang ingin ditampilkan di homepage sebagai pilihan.

is_active untuk soft-disable buku tanpa menghapusnya. Berguna kalau stok habis atau buku ditarik dari penjualan.

Membuat Migration untuk Orders

php artisan make:migration create_orders_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::create('orders', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->string('order_number')->unique();
            $table->decimal('total_amount', 12, 2);
            $table->enum('status', ['pending', 'processing', 'shipped', 'completed', 'cancelled'])->default('pending');
            $table->text('shipping_address');
            $table->string('phone');
            $table->text('notes')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};

order_number adalah nomor order yang readable untuk customer, misalnya "ORD-20250115-ABC123". Berbeda dengan id yang hanya angka incremental.

total_amount menggunakan decimal(12, 2) karena total order bisa lebih besar dari harga satuan buku.

status menggunakan enum untuk membatasi nilai yang valid. Ini mencegah typo dan memudahkan filtering.

Membuat Migration untuk Order Items

php artisan make:migration create_order_items_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::create('order_items', function (Blueprint $table) {
            $table->id();
            $table->foreignId('order_id')->constrained()->onDelete('cascade');
            $table->foreignId('book_id')->constrained()->onDelete('cascade');
            $table->integer('quantity');
            $table->decimal('price', 10, 2);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('order_items');
    }
};

Kenapa menyimpan price di order_items padahal sudah ada di books? Karena harga buku bisa berubah kapan saja. Kalau customer checkout hari ini dengan harga Rp 100.000, lalu besok harga naik jadi Rp 120.000, record order tetap harus menunjukkan Rp 100.000.

Ini adalah pattern umum di e-commerce yang disebut "price snapshotting".

Membuat Model Category

Sekarang kita buat model untuk setiap tabel. Model adalah representasi tabel dalam kode PHP.

php artisan make:model Category

Buka app/Models/Category.php dan edit:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

class Category extends Model
{
    use HasFactory;

    protected $fillable = [
        'name',
        'slug',
        'description',
        'icon',
    ];

    public function books(): HasMany
    {
        return $this->hasMany(Book::class);
    }
}

$fillable mendefinisikan kolom mana yang boleh di-mass assign. Ini adalah security feature Laravel untuk mencegah mass assignment vulnerability.

Method books() mendefinisikan relasi one-to-many. Satu category has many books.

Membuat Model Book

php artisan make:model Book

Edit app/Models/Book.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Builder;

class Book extends Model
{
    use HasFactory;

    protected $fillable = [
        'category_id',
        'title',
        'slug',
        'author',
        'description',
        'price',
        'cover_image',
        'stock',
        'is_featured',
        'is_active',
        'year_published',
        'isbn',
        'pages',
    ];

    protected $casts = [
        'price' => 'decimal:2',
        'is_featured' => 'boolean',
        'is_active' => 'boolean',
    ];

    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

    public function getFormattedPriceAttribute(): string
    {
        return 'Rp ' . number_format($this->price, 0, ',', '.');
    }

    public function scopeActive(Builder $query): Builder
    {
        return $query->where('is_active', true);
    }

    public function scopeFeatured(Builder $query): Builder
    {
        return $query->where('is_featured', true);
    }

    public function scopeInStock(Builder $query): Builder
    {
        return $query->where('stock', '>', 0);
    }
}

Ada beberapa hal menarik di model ini.

$casts mengubah tipe data saat diambil dari database. is_featured dan is_active akan menjadi boolean PHP, bukan integer 0/1.

getFormattedPriceAttribute() adalah accessor. Ini memungkinkan kamu mengakses $book->formatted_price dan mendapat string "Rp 150.000" instead of angka 150000.

scopeActive(), scopeFeatured(), dan scopeInStock() adalah query scopes. Kamu bisa chain mereka seperti Book::active()->featured()->get(). Sangat memudahkan query yang sering dipakai.

Membuat Model Order

php artisan make:model Order

Edit app/Models/Order.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

class Order extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id',
        'order_number',
        'total_amount',
        'status',
        'shipping_address',
        'phone',
        'notes',
    ];

    protected $casts = [
        'total_amount' => 'decimal:2',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }

    public static function generateOrderNumber(): string
    {
        $date = date('Ymd');
        $random = strtoupper(substr(uniqid(), -6));
        return "ORD-{$date}-{$random}";
    }

    public function getFormattedTotalAttribute(): string
    {
        return 'Rp ' . number_format($this->total_amount, 0, ',', '.');
    }
}

generateOrderNumber() adalah static method untuk generate nomor order yang unique dan readable. Format: ORD-20250115-ABC123.

Membuat Model OrderItem

php artisan make:model OrderItem

Edit app/Models/OrderItem.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;

class OrderItem extends Model
{
    use HasFactory;

    protected $fillable = [
        'order_id',
        'book_id',
        'quantity',
        'price',
    ];

    protected $casts = [
        'price' => 'decimal:2',
    ];

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    public function book(): BelongsTo
    {
        return $this->belongsTo(Book::class);
    }

    public function getSubtotalAttribute(): float
    {
        return $this->quantity * $this->price;
    }

    public function getFormattedSubtotalAttribute(): string
    {
        return 'Rp ' . number_format($this->subtotal, 0, ',', '.');
    }
}

Membuat Factory untuk Testing dan Seeding

Factory memudahkan pembuatan data dummy. Sangat berguna untuk testing dan development.

php artisan make:factory CategoryFactory
php artisan make:factory BookFactory

Edit database/factories/CategoryFactory.php:

<?php

namespace Database\\Factories;

use Illuminate\\Database\\Eloquent\\Factories\\Factory;
use Illuminate\\Support\\Str;

class CategoryFactory extends Factory
{
    public function definition(): array
    {
        $categories = [
            ['name' => 'Fiksi', 'icon' => '📖'],
            ['name' => 'Non-Fiksi', 'icon' => '📚'],
            ['name' => 'Teknologi', 'icon' => '💻'],
            ['name' => 'Bisnis', 'icon' => '💼'],
            ['name' => 'Pengembangan Diri', 'icon' => '🌟'],
            ['name' => 'Sains', 'icon' => '🔬'],
            ['name' => 'Sejarah', 'icon' => '🏛️'],
            ['name' => 'Anak-anak', 'icon' => '🧒'],
            ['name' => 'Komik', 'icon' => '🎨'],
            ['name' => 'Agama', 'icon' => '🕌'],
        ];

        $category = fake()->unique()->randomElement($categories);

        return [
            'name' => $category['name'],
            'slug' => Str::slug($category['name']),
            'description' => fake()->paragraph(),
            'icon' => $category['icon'],
        ];
    }
}

Edit database/factories/BookFactory.php:

<?php

namespace Database\\Factories;

use App\\Models\\Category;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
use Illuminate\\Support\\Str;

class BookFactory extends Factory
{
    public function definition(): array
    {
        $title = fake()->sentence(rand(2, 5));

        return [
            'category_id' => Category::factory(),
            'title' => rtrim($title, '.'),
            'slug' => Str::slug($title) . '-' . fake()->unique()->randomNumber(5),
            'author' => fake()->name(),
            'description' => fake()->paragraphs(rand(3, 6), true),
            'price' => fake()->numberBetween(5, 50) * 10000,
            'cover_image' => null,
            'stock' => fake()->numberBetween(0, 100),
            'is_featured' => fake()->boolean(20),
            'is_active' => fake()->boolean(90),
            'year_published' => fake()->numberBetween(2015, 2025),
            'isbn' => fake()->isbn13(),
            'pages' => fake()->numberBetween(100, 600),
        ];
    }
}

Beberapa hal yang perlu diperhatikan:

Harga di-generate dengan kelipatan Rp 10.000 (50.000, 60.000, dst) agar terlihat realistic.

is_featured hanya 20% chance true, karena tidak semua buku adalah featured.

is_active 90% chance true, karena mayoritas buku aktif dijual.

Membuat Seeder

Seeder mengisi database dengan data awal.

php artisan make:seeder CategorySeeder
php artisan make:seeder BookSeeder

Edit database/seeders/CategorySeeder.php:

<?php

namespace Database\\Seeders;

use App\\Models\\Category;
use Illuminate\\Database\\Seeder;
use Illuminate\\Support\\Str;

class CategorySeeder extends Seeder
{
    public function run(): void
    {
        $categories = [
            ['name' => 'Fiksi', 'icon' => '📖', 'description' => 'Novel, cerpen, dan karya fiksi lainnya'],
            ['name' => 'Non-Fiksi', 'icon' => '📚', 'description' => 'Buku berbasis fakta dan pengetahuan'],
            ['name' => 'Teknologi', 'icon' => '💻', 'description' => 'Pemrograman, IT, dan teknologi terkini'],
            ['name' => 'Bisnis', 'icon' => '💼', 'description' => 'Manajemen, keuangan, dan entrepreneurship'],
            ['name' => 'Pengembangan Diri', 'icon' => '🌟', 'description' => 'Motivasi, produktivitas, dan self-improvement'],
            ['name' => 'Sains', 'icon' => '🔬', 'description' => 'Fisika, kimia, biologi, dan sains populer'],
            ['name' => 'Sejarah', 'icon' => '🏛️', 'description' => 'Sejarah Indonesia dan dunia'],
            ['name' => 'Anak-anak', 'icon' => '🧒', 'description' => 'Buku cerita dan edukasi untuk anak'],
        ];

        foreach ($categories as $category) {
            Category::create([
                'name' => $category['name'],
                'slug' => Str::slug($category['name']),
                'description' => $category['description'],
                'icon' => $category['icon'],
            ]);
        }
    }
}

Edit database/seeders/BookSeeder.php:

<?php

namespace Database\\Seeders;

use App\\Models\\Book;
use App\\Models\\Category;
use Illuminate\\Database\\Seeder;

class BookSeeder extends Seeder
{
    public function run(): void
    {
        $categories = Category::all();

        foreach ($categories as $category) {
            Book::factory()
                ->count(12)
                ->create(['category_id' => $category->id]);
        }
    }
}

Setiap kategori akan punya 12 buku dummy. Total 8 kategori x 12 buku = 96 buku.

Update database/seeders/DatabaseSeeder.php:

<?php

namespace Database\\Seeders;

use App\\Models\\User;
use Illuminate\\Database\\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([
            CategorySeeder::class,
            BookSeeder::class,
        ]);

        User::factory()->create([
            'name' => 'Admin',
            'email' => '[email protected]',
        ]);

        User::factory()->create([
            'name' => 'Test User',
            'email' => '[email protected]',
        ]);
    }
}

Kita juga membuat dua user: satu admin dan satu user biasa untuk testing.

Menjalankan Migration dan Seeder

Sekarang saatnya eksekusi semua yang sudah kita buat.

php artisan migrate:fresh --seed

Command ini akan:

  1. Drop semua tabel yang ada
  2. Jalankan semua migrations dari awal
  3. Jalankan semua seeders

Output yang diharapkan:

Dropping all tables ...
Migration table created successfully.
Running migrations ...
  2025_01_01_000000_create_users_table ... DONE
  2025_01_01_000001_create_categories_table ... DONE
  2025_01_01_000002_create_books_table ... DONE
  2025_01_01_000003_create_orders_table ... DONE
  2025_01_01_000004_create_order_items_table ... DONE
Seeding: Database\\Seeders\\CategorySeeder
Seeding: Database\\Seeders\\BookSeeder
Database seeding completed successfully.

Verifikasi Data

Mari pastikan data sudah masuk dengan benar menggunakan Tinker:

php artisan tinker

Di dalam Tinker, coba:

>>> App\\Models\\Category::count()
=> 8

>>> App\\Models\\Book::count()
=> 96

>>> App\\Models\\User::count()
=> 2

>>> App\\Models\\Category::first()
=> App\\Models\\Category {
     id: 1,
     name: "Fiksi",
     slug: "fiksi",
     ...
   }

>>> App\\Models\\Book::with('category')->first()
=> App\\Models\\Book {
     id: 1,
     title: "...",
     category: App\\Models\\Category {...},
     ...
   }

Ketik exit untuk keluar dari Tinker.

Kamu juga bisa cek langsung di database menggunakan phpMyAdmin atau database client lainnya.

Recap

Di bagian ini kita sudah:

  • Merancang database schema untuk toko buku dengan 4 tabel utama
  • Membuat migrations dengan proper data types dan foreign keys
  • Membuat models dengan relationships, accessors, dan query scopes
  • Membuat factories untuk generate data dummy
  • Membuat seeders untuk populate database
  • Menjalankan migration dan seeder

Database kita sekarang punya 8 kategori dan 96 buku siap ditampilkan. Di bagian selanjutnya, kita akan mulai membangun UI dengan membuat halaman Home dan Layout untuk toko buku.

Lanjut ke Bagian 3: Halaman Home & Layout →

Bagian 3: Halaman Home & Layout

Database sudah siap dengan 96 buku dan 8 kategori. Sekarang saatnya membangun UI yang akan dilihat customer.

Hal pertama yang perlu kita buat adalah layout. Layout adalah kerangka halaman yang konsisten di seluruh aplikasi. Header dengan logo dan navigation, footer dengan informasi kontak, dan area konten di tengah. Daripada menulis ulang header dan footer di setiap halaman, kita buat sekali sebagai layout dan gunakan di mana-mana.

Laravel 12 React starter kit sudah menyediakan beberapa layout default seperti AuthenticatedLayout untuk halaman yang butuh login. Tapi untuk toko buku kita, saya ingin layout yang berbeda. Layout yang lebih cocok untuk e-commerce dengan navigation yang jelas dan shopping cart yang selalu terlihat.

Konsep Layout di Inertia

Sebelum coding, mari pahami bagaimana layout bekerja di Inertia.

Di React biasa, kamu mungkin wrap komponen dengan layout di setiap halaman. Tapi Inertia punya fitur persistent layouts. Artinya, layout tidak di-render ulang saat navigasi antar halaman. Hanya konten di dalamnya yang berubah.

Ini membuat navigasi terasa lebih smooth dan juga menghemat resource karena tidak perlu re-mount komponen layout setiap berpindah halaman.

Cara menggunakan persistent layout di Inertia React adalah dengan menambahkan property layout pada komponen halaman. Kita akan lihat contohnya nanti.

Membuat Store Layout

Buat file baru resources/js/layouts/store-layout.tsx:

import { Link, usePage } from '@inertiajs/react';
import { PropsWithChildren, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
    ShoppingCart,
    User,
    Search,
    Menu,
    X,
    BookOpen,
    LogOut,
    Package
} from 'lucide-react';
import {
    DropdownMenu,
    DropdownMenuContent,
    DropdownMenuItem,
    DropdownMenuSeparator,
    DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';

interface StoreLayoutProps extends PropsWithChildren {
    title?: string;
}

interface PageProps {
    auth: {
        user: {
            id: number;
            name: string;
            email: string;
        } | null;
    };
    cartCount?: number;
}

export default function StoreLayout({ children }: StoreLayoutProps) {
    const { auth, cartCount = 0 } = usePage<PageProps>().props;
    const [mobileMenuOpen, setMobileMenuOpen] = useState(false);

    const navigation = [
        { name: 'Home', href: '/' },
        { name: 'Katalog', href: '/catalog' },
        { name: 'Kategori', href: '/categories' },
    ];

    return (
        <div className="min-h-screen bg-gray-50">
            <header className="bg-white shadow-sm sticky top-0 z-50">
                <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
                    <div className="flex justify-between items-center h-16">
                        <div className="flex items-center">
                            <Link href="/" className="flex items-center space-x-2">
                                <BookOpen className="h-8 w-8 text-blue-600" />
                                <span className="text-xl font-bold text-gray-900">
                                    TokoBuku
                                </span>
                            </Link>
                        </div>

                        <nav className="hidden md:flex items-center space-x-8">
                            {navigation.map((item) => (
                                <Link
                                    key={item.name}
                                    href={item.href}
                                    className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
                                >
                                    {item.name}
                                </Link>
                            ))}
                        </nav>

                        <div className="flex items-center space-x-4">
                            <Link href="/catalog">
                                <Button variant="ghost" size="icon" className="hidden sm:flex">
                                    <Search className="h-5 w-5" />
                                </Button>
                            </Link>

                            <Link href="/cart">
                                <Button variant="ghost" size="icon" className="relative">
                                    <ShoppingCart className="h-5 w-5" />
                                    {cartCount > 0 && (
                                        <span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">
                                            {cartCount > 99 ? '99+' : cartCount}
                                        </span>
                                    )}
                                </Button>
                            </Link>

                            {auth.user ? (
                                <DropdownMenu>
                                    <DropdownMenuTrigger asChild>
                                        <Button variant="outline" size="sm" className="hidden sm:flex">
                                            <User className="h-4 w-4 mr-2" />
                                            {auth.user.name}
                                        </Button>
                                    </DropdownMenuTrigger>
                                    <DropdownMenuContent align="end" className="w-48">
                                        <DropdownMenuItem asChild>
                                            <Link href="/dashboard" className="cursor-pointer">
                                                <User className="h-4 w-4 mr-2" />
                                                Dashboard
                                            </Link>
                                        </DropdownMenuItem>
                                        <DropdownMenuItem asChild>
                                            <Link href="/orders" className="cursor-pointer">
                                                <Package className="h-4 w-4 mr-2" />
                                                Pesanan Saya
                                            </Link>
                                        </DropdownMenuItem>
                                        <DropdownMenuSeparator />
                                        <DropdownMenuItem asChild>
                                            <Link
                                                href="/logout"
                                                method="post"
                                                as="button"
                                                className="cursor-pointer w-full"
                                            >
                                                <LogOut className="h-4 w-4 mr-2" />
                                                Logout
                                            </Link>
                                        </DropdownMenuItem>
                                    </DropdownMenuContent>
                                </DropdownMenu>
                            ) : (
                                <div className="hidden sm:flex items-center space-x-2">
                                    <Link href="/login">
                                        <Button variant="ghost" size="sm">
                                            Login
                                        </Button>
                                    </Link>
                                    <Link href="/register">
                                        <Button size="sm">
                                            Daftar
                                        </Button>
                                    </Link>
                                </div>
                            )}

                            <Button
                                variant="ghost"
                                size="icon"
                                className="md:hidden"
                                onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
                            >
                                {mobileMenuOpen ? (
                                    <X className="h-5 w-5" />
                                ) : (
                                    <Menu className="h-5 w-5" />
                                )}
                            </Button>
                        </div>
                    </div>

                    {mobileMenuOpen && (
                        <div className="md:hidden py-4 border-t">
                            <nav className="flex flex-col space-y-2">
                                {navigation.map((item) => (
                                    <Link
                                        key={item.name}
                                        href={item.href}
                                        className="text-gray-600 hover:text-gray-900 font-medium py-2"
                                        onClick={() => setMobileMenuOpen(false)}
                                    >
                                        {item.name}
                                    </Link>
                                ))}
                                {!auth.user && (
                                    <>
                                        <Link
                                            href="/login"
                                            className="text-gray-600 hover:text-gray-900 font-medium py-2"
                                        >
                                            Login
                                        </Link>
                                        <Link
                                            href="/register"
                                            className="text-blue-600 hover:text-blue-700 font-medium py-2"
                                        >
                                            Daftar
                                        </Link>
                                    </>
                                )}
                            </nav>
                        </div>
                    )}
                </div>
            </header>

            <main>{children}</main>

            <footer className="bg-gray-900 text-white mt-16">
                <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
                    <div className="grid grid-cols-1 md:grid-cols-4 gap-8">
                        <div>
                            <div className="flex items-center space-x-2 mb-4">
                                <BookOpen className="h-6 w-6" />
                                <span className="text-lg font-bold">TokoBuku</span>
                            </div>
                            <p className="text-gray-400 text-sm">
                                Toko buku digital terlengkap dengan koleksi ribuan judul dari berbagai kategori.
                            </p>
                        </div>

                        <div>
                            <h4 className="font-semibold mb-4">Kategori</h4>
                            <ul className="space-y-2 text-sm text-gray-400">
                                <li>
                                    <Link href="/catalog?category=fiksi" className="hover:text-white transition-colors">
                                        Fiksi
                                    </Link>
                                </li>
                                <li>
                                    <Link href="/catalog?category=teknologi" className="hover:text-white transition-colors">
                                        Teknologi
                                    </Link>
                                </li>
                                <li>
                                    <Link href="/catalog?category=bisnis" className="hover:text-white transition-colors">
                                        Bisnis
                                    </Link>
                                </li>
                                <li>
                                    <Link href="/catalog?category=pengembangan-diri" className="hover:text-white transition-colors">
                                        Pengembangan Diri
                                    </Link>
                                </li>
                            </ul>
                        </div>

                        <div>
                            <h4 className="font-semibold mb-4">Informasi</h4>
                            <ul className="space-y-2 text-sm text-gray-400">
                                <li>
                                    <Link href="/about" className="hover:text-white transition-colors">
                                        Tentang Kami
                                    </Link>
                                </li>
                                <li>
                                    <Link href="/contact" className="hover:text-white transition-colors">
                                        Hubungi Kami
                                    </Link>
                                </li>
                                <li>
                                    <Link href="/faq" className="hover:text-white transition-colors">
                                        FAQ
                                    </Link>
                                </li>
                                <li>
                                    <Link href="/terms" className="hover:text-white transition-colors">
                                        Syarat & Ketentuan
                                    </Link>
                                </li>
                            </ul>
                        </div>

                        <div>
                            <h4 className="font-semibold mb-4">Ikuti Kami</h4>
                            <div className="flex space-x-4 text-sm text-gray-400">
                                <a href="#" className="hover:text-white transition-colors">
                                    Instagram
                                </a>
                                <a href="#" className="hover:text-white transition-colors">
                                    Twitter
                                </a>
                                <a href="#" className="hover:text-white transition-colors">
                                    Facebook
                                </a>
                            </div>
                        </div>
                    </div>

                    <div className="border-t border-gray-800 mt-8 pt-8 text-center text-sm text-gray-400">
                        <p>&copy; {new Date().getFullYear()} TokoBuku. All rights reserved.</p>
                    </div>
                </div>
            </footer>
        </div>
    );
}

Layout ini punya beberapa fitur penting.

Header sticky yang selalu terlihat saat scroll. Logo di kiri, navigation di tengah, dan actions di kanan. Shopping cart icon dengan badge yang menunjukkan jumlah item.

Dropdown menu untuk user yang sudah login. Menampilkan link ke dashboard dan pesanan, plus tombol logout.

Mobile responsive dengan hamburger menu. Navigation tersembunyi di layar kecil dan muncul saat tombol menu diklik.

Footer dengan empat kolom informasi. Kategori populer, link informasi, social media, dan copyright.

Membuat Home Controller

Sekarang kita buat controller untuk halaman home. Controller ini akan mengambil data dari database dan mengirimnya ke React component.

php artisan make:controller HomeController

Edit app/Http/Controllers/HomeController.php:

<?php

namespace App\\Http\\Controllers;

use App\\Models\\Book;
use App\\Models\\Category;
use Inertia\\Inertia;
use Inertia\\Response;

class HomeController extends Controller
{
    public function index(): Response
    {
        $featuredBooks = Book::with('category')
            ->active()
            ->featured()
            ->inStock()
            ->take(8)
            ->get();

        $latestBooks = Book::with('category')
            ->active()
            ->inStock()
            ->latest()
            ->take(8)
            ->get();

        $categories = Category::withCount(['books' => function ($query) {
            $query->active()->inStock();
        }])
            ->having('books_count', '>', 0)
            ->take(8)
            ->get();

        return Inertia::render('Home', [
            'featuredBooks' => $featuredBooks,
            'latestBooks' => $latestBooks,
            'categories' => $categories,
        ]);
    }
}

Perhatikan bagaimana kita menggunakan query scopes yang sudah dibuat di model Book. active(), featured(), dan inStock() membuat query lebih readable.

withCount menghitung jumlah buku per kategori. Kita juga filter hanya kategori yang punya buku aktif dan tersedia.

Inertia::render() adalah inti dari Inertia. Parameter pertama adalah nama component React yang akan di-render. Parameter kedua adalah props yang akan dikirim ke component tersebut.

Mendaftarkan Route

Buka routes/web.php dan tambahkan route untuk home:

<?php

use App\\Http\\Controllers\\HomeController;
use Illuminate\\Support\\Facades\\Route;

Route::get('/', [HomeController::class, 'index'])->name('home');

require __DIR__.'/auth.php';

Route default Laravel (Route::get('/')) biasanya mengarah ke welcome page. Kita ganti dengan HomeController.

Membuat Komponen BookCard

Sebelum membuat halaman Home, mari buat komponen reusable untuk menampilkan buku. Komponen ini akan dipakai di banyak tempat.

Buat file resources/js/components/book-card.tsx:

import { Link } from '@inertiajs/react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';

export interface Book {
    id: number;
    title: string;
    slug: string;
    author: string;
    price: number;
    formatted_price: string;
    cover_image: string | null;
    stock: number;
    is_featured: boolean;
    category: {
        id: number;
        name: string;
        slug: string;
    };
}

interface BookCardProps {
    book: Book;
}

export function BookCard({ book }: BookCardProps) {
    return (
        <Link href={`/books/${book.slug}`}>
            <Card className="h-full hover:shadow-lg transition-all duration-200 group overflow-hidden">
                <div className="aspect-[3/4] bg-gray-100 relative overflow-hidden">
                    {book.cover_image ? (
                        <img
                            src={`/storage/${book.cover_image}`}
                            alt={book.title}
                            className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
                        />
                    ) : (
                        <div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-100 to-purple-100">
                            <span className="text-6xl">📚</span>
                        </div>
                    )}

                    {book.is_featured && (
                        <Badge className="absolute top-2 left-2 bg-yellow-500 hover:bg-yellow-500">
                            ⭐ Pilihan
                        </Badge>
                    )}

                    {book.stock === 0 && (
                        <div className="absolute inset-0 bg-black/50 flex items-center justify-center">
                            <Badge variant="destructive" className="text-sm">
                                Stok Habis
                            </Badge>
                        </div>
                    )}
                </div>

                <CardContent className="p-4">
                    <p className="text-xs text-blue-600 font-medium mb-1">
                        {book.category.name}
                    </p>
                    <h3 className="font-semibold text-gray-900 line-clamp-2 mb-1 group-hover:text-blue-600 transition-colors">
                        {book.title}
                    </h3>
                    <p className="text-sm text-gray-500 mb-2">
                        {book.author}
                    </p>
                    <p className="text-lg font-bold text-gray-900">
                        {book.formatted_price}
                    </p>
                </CardContent>
            </Card>
        </Link>
    );
}

Komponen ini menampilkan card buku dengan cover image, badge untuk buku featured, overlay untuk stok habis, kategori, judul, penulis, dan harga.

line-clamp-2 adalah utility Tailwind untuk membatasi teks hanya 2 baris dengan ellipsis.

Hover effect membuat card terasa interactive. Scale pada image dan warna pada judul memberikan feedback visual.

Membuat Halaman Home

Sekarang halaman utama. Buat file resources/js/pages/Home.tsx:

import { Head, Link } from '@inertiajs/react';
import StoreLayout from '@/layouts/store-layout';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { BookCard, Book } from '@/components/book-card';
import { ArrowRight, TrendingUp, Clock, Sparkles } from 'lucide-react';

interface Category {
    id: number;
    name: string;
    slug: string;
    icon: string;
    description: string;
    books_count: number;
}

interface Props {
    featuredBooks: Book[];
    latestBooks: Book[];
    categories: Category[];
}

export default function Home({ featuredBooks, latestBooks, categories }: Props) {
    return (
        <StoreLayout>
            <Head title="Toko Buku Digital - Temukan Buku Favoritmu" />

            <section className="bg-gradient-to-br from-blue-600 via-blue-700 to-purple-700 text-white">
                <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 md:py-24">
                    <div className="grid md:grid-cols-2 gap-8 items-center">
                        <div>
                            <h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 leading-tight">
                                Temukan Buku
                                <span className="block text-yellow-300">Favoritmu</span>
                            </h1>
                            <p className="text-lg md:text-xl mb-8 text-blue-100 leading-relaxed">
                                Jelajahi ribuan judul buku berkualitas dari berbagai kategori.
                                Dari fiksi yang menghibur hingga buku bisnis yang menginspirasi.
                            </p>
                            <div className="flex flex-col sm:flex-row gap-4">
                                <Link href="/catalog">
                                    <Button size="lg" className="bg-white text-blue-700 hover:bg-gray-100 w-full sm:w-auto">
                                        Jelajahi Katalog
                                        <ArrowRight className="ml-2 h-5 w-5" />
                                    </Button>
                                </Link>
                                <Link href="/categories">
                                    <Button size="lg" variant="outline" className="border-white text-white hover:bg-white/10 w-full sm:w-auto">
                                        Lihat Kategori
                                    </Button>
                                </Link>
                            </div>
                        </div>
                        <div className="hidden md:flex justify-center">
                            <div className="relative">
                                <div className="absolute -top-4 -left-4 w-72 h-72 bg-yellow-300/20 rounded-full blur-3xl"></div>
                                <div className="absolute -bottom-4 -right-4 w-72 h-72 bg-purple-300/20 rounded-full blur-3xl"></div>
                                <div className="relative grid grid-cols-2 gap-4">
                                    <div className="space-y-4">
                                        <div className="bg-white/10 backdrop-blur rounded-lg p-4 transform rotate-3">
                                            <span className="text-4xl">📚</span>
                                        </div>
                                        <div className="bg-white/10 backdrop-blur rounded-lg p-4 transform -rotate-2">
                                            <span className="text-4xl">💡</span>
                                        </div>
                                    </div>
                                    <div className="space-y-4 mt-8">
                                        <div className="bg-white/10 backdrop-blur rounded-lg p-4 transform -rotate-3">
                                            <span className="text-4xl">🎯</span>
                                        </div>
                                        <div className="bg-white/10 backdrop-blur rounded-lg p-4 transform rotate-2">
                                            <span className="text-4xl">✨</span>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </section>

            <section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
                <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8">
                    <div>
                        <h2 className="text-2xl md:text-3xl font-bold text-gray-900">
                            Kategori Populer
                        </h2>
                        <p className="text-gray-600 mt-1">
                            Temukan buku berdasarkan minatmu
                        </p>
                    </div>
                    <Link href="/categories">
                        <Button variant="outline">
                            Semua Kategori
                            <ArrowRight className="ml-2 h-4 w-4" />
                        </Button>
                    </Link>
                </div>

                <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
                    {categories.map((category) => (
                        <Link
                            key={category.id}
                            href={`/catalog?category=${category.slug}`}
                        >
                            <Card className="hover:shadow-md transition-all duration-200 hover:border-blue-200 group cursor-pointer h-full">
                                <CardContent className="p-6 text-center">
                                    <span className="text-4xl mb-3 block group-hover:scale-110 transition-transform">
                                        {category.icon}
                                    </span>
                                    <h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
                                        {category.name}
                                    </h3>
                                    <p className="text-sm text-gray-500 mt-1">
                                        {category.books_count} buku
                                    </p>
                                </CardContent>
                            </Card>
                        </Link>
                    ))}
                </div>
            </section>

            {featuredBooks.length > 0 && (
                <section className="bg-gradient-to-b from-gray-50 to-white py-16">
                    <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
                        <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8">
                            <div className="flex items-center gap-3">
                                <div className="p-2 bg-yellow-100 rounded-lg">
                                    <Sparkles className="h-6 w-6 text-yellow-600" />
                                </div>
                                <div>
                                    <h2 className="text-2xl md:text-3xl font-bold text-gray-900">
                                        Buku Pilihan
                                    </h2>
                                    <p className="text-gray-600">
                                        Rekomendasi terbaik untuk kamu
                                    </p>
                                </div>
                            </div>
                            <Link href="/catalog?featured=1">
                                <Button variant="outline">
                                    Lihat Semua
                                    <ArrowRight className="ml-2 h-4 w-4" />
                                </Button>
                            </Link>
                        </div>

                        <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6">
                            {featuredBooks.map((book) => (
                                <BookCard key={book.id} book={book} />
                            ))}
                        </div>
                    </div>
                </section>
            )}

            {latestBooks.length > 0 && (
                <section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
                    <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8">
                        <div className="flex items-center gap-3">
                            <div className="p-2 bg-blue-100 rounded-lg">
                                <Clock className="h-6 w-6 text-blue-600" />
                            </div>
                            <div>
                                <h2 className="text-2xl md:text-3xl font-bold text-gray-900">
                                    Buku Terbaru
                                </h2>
                                <p className="text-gray-600">
                                    Koleksi terbaru yang baru ditambahkan
                                </p>
                            </div>
                        </div>
                        <Link href="/catalog?sort=latest">
                            <Button variant="outline">
                                Lihat Semua
                                <ArrowRight className="ml-2 h-4 w-4" />
                            </Button>
                        </Link>
                    </div>

                    <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6">
                        {latestBooks.map((book) => (
                            <BookCard key={book.id} book={book} />
                        ))}
                    </div>
                </section>
            )}

            <section className="bg-blue-600 text-white">
                <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
                    <div className="text-center max-w-2xl mx-auto">
                        <h2 className="text-2xl md:text-3xl font-bold mb-4">
                            Siap Menemukan Buku Baru?
                        </h2>
                        <p className="text-blue-100 mb-8">
                            Daftar sekarang dan dapatkan akses ke ribuan koleksi buku berkualitas
                        </p>
                        <div className="flex flex-col sm:flex-row gap-4 justify-center">
                            <Link href="/register">
                                <Button size="lg" className="bg-white text-blue-600 hover:bg-gray-100 w-full sm:w-auto">
                                    Daftar Gratis
                                </Button>
                            </Link>
                            <Link href="/catalog">
                                <Button size="lg" variant="outline" className="border-white text-white hover:bg-white/10 w-full sm:w-auto">
                                    Lihat Katalog
                                </Button>
                            </Link>
                        </div>
                    </div>
                </div>
            </section>
        </StoreLayout>
    );
}

Halaman home punya beberapa section.

Hero section dengan gradient background, headline yang menarik, dan dua CTA button. Di sebelah kanan ada decorative elements yang memberikan visual interest.

Section kategori menampilkan 8 kategori dengan icon dan jumlah buku. Setiap card adalah link ke halaman katalog dengan filter kategori tersebut.

Section buku pilihan menampilkan buku-buku featured. Hanya muncul kalau ada data.

Section buku terbaru menampilkan buku-buku yang baru ditambahkan.

CTA section di bagian bawah mengajak visitor untuk register atau melihat katalog.

Sharing Data Global dengan HandleInertiaRequests

Ada satu hal yang perlu kita setup. Di layout, kita mengakses auth dan cartCount dari page props. Tapi data ini harus tersedia di semua halaman.

Inertia punya middleware HandleInertiaRequests yang memungkinkan kita share data ke semua halaman.

Buka app/Http/Middleware/HandleInertiaRequests.php dan update method share:

<?php

namespace App\\Http\\Middleware;

use Illuminate\\Http\\Request;
use Inertia\\Middleware;

class HandleInertiaRequests extends Middleware
{
    protected $rootView = 'app';

    public function version(Request $request): ?string
    {
        return parent::version($request);
    }

    public function share(Request $request): array
    {
        return [
            ...parent::share($request),
            'auth' => [
                'user' => $request->user(),
            ],
            'cartCount' => $this->getCartCount(),
            'flash' => [
                'success' => fn () => $request->session()->get('success'),
                'error' => fn () => $request->session()->get('error'),
            ],
        ];
    }

    private function getCartCount(): int
    {
        $cart = session()->get('cart', []);
        return collect($cart)->sum('quantity');
    }
}

Data yang di-share di sini akan tersedia di semua halaman sebagai props. auth.user berisi data user yang login atau null. cartCount berisi jumlah item di cart. flash berisi pesan success atau error dari session.

Test Hasilnya

Pastikan development server berjalan:

composer run dev

Buka browser dan akses http://localhost:8000.

Kamu akan melihat homepage dengan:

  • Hero section dengan gradient biru-ungu
  • Grid kategori dengan icon dan jumlah buku
  • Section buku pilihan dengan card yang menarik
  • Section buku terbaru
  • CTA section di bagian bawah
  • Footer dengan informasi lengkap

Coba beberapa hal:

  • Hover pada card buku, perhatikan efek scale pada image
  • Klik kategori, akan redirect ke /catalog?category=xxx (halaman belum ada, akan error 404)
  • Resize browser untuk lihat responsive design
  • Login dengan user yang sudah dibuat di seeder, lihat dropdown menu muncul

Troubleshooting

Error "Component not found" Pastikan nama file dan path sudah benar. Inertia::render('Home') mencari file di resources/js/pages/Home.tsx.

Styling tidak muncul Pastikan Vite server berjalan. Cek terminal untuk error.

Data tidak muncul Pastikan seeder sudah dijalankan. Cek dengan php artisan tinker lalu Book::count().

Icon tidak muncul Lucide icons sudah terinclude di starter kit. Pastikan import path benar: from 'lucide-react'.

Recap

Di bagian ini kita sudah:

  • Membuat StoreLayout dengan header, navigation, dan footer yang responsive
  • Membuat HomeController untuk mengambil data dari database
  • Membuat komponen BookCard yang reusable
  • Membuat halaman Home dengan hero section, kategori, dan buku
  • Setup sharing data global dengan HandleInertiaRequests middleware
  • Test tampilan homepage

Di bagian selanjutnya, kita akan membuat halaman katalog dengan fitur search, filter, sorting, dan pagination. Ini adalah halaman paling kompleks dalam tutorial ini.

Lanjut ke Bagian 4: Halaman Katalog dengan Pagination & Filter →

Bagian 4: Halaman Katalog dengan Pagination & Filter

Halaman katalog adalah jantung dari toko buku online. Di sinilah customer menghabiskan sebagian besar waktu mereka untuk browsing, mencari, dan memilih buku. Kalau pengalaman di halaman ini buruk, customer akan pergi.

Dari pengalaman saya membangun berbagai e-commerce, ada beberapa fitur yang wajib ada di halaman katalog. Search untuk mencari berdasarkan keyword. Filter untuk mempersempit pilihan berdasarkan kategori atau kriteria lain. Sorting untuk mengurutkan hasil. Dan pagination untuk membagi hasil ke beberapa halaman agar tidak terlalu berat.

Di bagian ini kita akan implementasi semuanya. Dan yang menarik, dengan Inertia semua interaksi ini akan terasa instant tanpa full page reload.

Membuat Catalog Controller

Controller katalog akan menangani berbagai query parameter untuk search, filter, dan sorting. Mari buat controller yang robust.

php artisan make:controller CatalogController

Edit app/Http/Controllers/CatalogController.php:

<?php

namespace App\\Http\\Controllers;

use App\\Models\\Book;
use App\\Models\\Category;
use Illuminate\\Http\\Request;
use Inertia\\Inertia;
use Inertia\\Response;

class CatalogController extends Controller
{
    public function index(Request $request): Response
    {
        $query = Book::with('category')->active();

        if ($search = $request->input('search')) {
            $query->where(function ($q) use ($search) {
                $q->where('title', 'like', "%{$search}%")
                  ->orWhere('author', 'like', "%{$search}%")
                  ->orWhere('isbn', 'like', "%{$search}%");
            });
        }

        if ($category = $request->input('category')) {
            $query->whereHas('category', function ($q) use ($category) {
                $q->where('slug', $category);
            });
        }

        if ($request->boolean('featured')) {
            $query->featured();
        }

        if ($request->boolean('in_stock')) {
            $query->inStock();
        }

        if ($minPrice = $request->input('min_price')) {
            $query->where('price', '>=', (int) $minPrice);
        }

        if ($maxPrice = $request->input('max_price')) {
            $query->where('price', '<=', (int) $maxPrice);
        }

        $sort = $request->input('sort', 'latest');

        switch ($sort) {
            case 'price_low':
                $query->orderBy('price', 'asc');
                break;
            case 'price_high':
                $query->orderBy('price', 'desc');
                break;
            case 'title_asc':
                $query->orderBy('title', 'asc');
                break;
            case 'title_desc':
                $query->orderBy('title', 'desc');
                break;
            case 'oldest':
                $query->oldest();
                break;
            case 'latest':
            default:
                $query->latest();
                break;
        }

        $books = $query->paginate(12)->withQueryString();

        $categories = Category::withCount(['books' => function ($q) {
            $q->active();
        }])
            ->having('books_count', '>', 0)
            ->orderBy('name')
            ->get();

        $currentCategory = null;
        if ($category) {
            $currentCategory = Category::where('slug', $category)->first();
        }

        return Inertia::render('Catalog/Index', [
            'books' => $books,
            'categories' => $categories,
            'currentCategory' => $currentCategory,
            'filters' => [
                'search' => $request->input('search', ''),
                'category' => $request->input('category', ''),
                'sort' => $request->input('sort', 'latest'),
                'featured' => $request->boolean('featured'),
                'in_stock' => $request->boolean('in_stock'),
                'min_price' => $request->input('min_price', ''),
                'max_price' => $request->input('max_price', ''),
            ],
        ]);
    }
}

Ada beberapa hal penting di controller ini.

Query dimulai dengan Book::with('category')->active(). Eager loading category mencegah N+1 query problem. Scope active() memastikan hanya buku aktif yang ditampilkan.

Search mencari di tiga kolom sekaligus: title, author, dan ISBN. Menggunakan closure untuk grouping OR conditions.

Filter category menggunakan whereHas untuk filter berdasarkan relasi. Ini lebih aman daripada langsung compare category_id karena kita pakai slug.

Sorting menggunakan switch case untuk handle berbagai opsi. Default adalah latest yang mengurutkan berdasarkan created_at descending.

paginate(12)->withQueryString() adalah kombinasi penting. paginate(12) membagi hasil ke halaman dengan 12 item per halaman. withQueryString() memastikan semua filter tetap ada di URL pagination.

Kita juga mengirim filters kembali ke frontend agar React tahu state filter saat ini.

Mendaftarkan Route Katalog

Tambahkan di routes/web.php:

<?php

use App\\Http\\Controllers\\HomeController;
use App\\Http\\Controllers\\CatalogController;
use Illuminate\\Support\\Facades\\Route;

Route::get('/', [HomeController::class, 'index'])->name('home');
Route::get('/catalog', [CatalogController::class, 'index'])->name('catalog');

require __DIR__.'/auth.php';

Install Dependency untuk Debounce

Untuk search yang responsive, kita butuh debounce agar tidak mengirim request setiap kali user mengetik. Install package berikut:

npm install use-debounce

Membuat Komponen Pagination

Sebelum membuat halaman katalog, mari buat komponen pagination yang reusable.

Buat file resources/js/components/pagination.tsx:

import { Link } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight } from 'lucide-react';

interface PaginationLink {
    url: string | null;
    label: string;
    active: boolean;
}

interface PaginationProps {
    links: PaginationLink[];
    currentPage: number;
    lastPage: number;
}

export function Pagination({ links, currentPage, lastPage }: PaginationProps) {
    if (lastPage <= 1) return null;

    return (
        <div className="flex items-center justify-center gap-1 mt-8">
            {links.map((link, index) => {
                const isFirst = index === 0;
                const isLast = index === links.length - 1;

                if (isFirst) {
                    return (
                        <Link
                            key={index}
                            href={link.url || '#'}
                            preserveScroll
                            preserveState
                            className={!link.url ? 'pointer-events-none' : ''}
                        >
                            <Button
                                variant="outline"
                                size="icon"
                                disabled={!link.url}
                                className="h-9 w-9"
                            >
                                <ChevronLeft className="h-4 w-4" />
                            </Button>
                        </Link>
                    );
                }

                if (isLast) {
                    return (
                        <Link
                            key={index}
                            href={link.url || '#'}
                            preserveScroll
                            preserveState
                            className={!link.url ? 'pointer-events-none' : ''}
                        >
                            <Button
                                variant="outline"
                                size="icon"
                                disabled={!link.url}
                                className="h-9 w-9"
                            >
                                <ChevronRight className="h-4 w-4" />
                            </Button>
                        </Link>
                    );
                }

                if (link.label === '...') {
                    return (
                        <span key={index} className="px-3 py-2 text-gray-500">
                            ...
                        </span>
                    );
                }

                return (
                    <Link
                        key={index}
                        href={link.url || '#'}
                        preserveScroll
                        preserveState
                    >
                        <Button
                            variant={link.active ? 'default' : 'outline'}
                            size="icon"
                            className="h-9 w-9"
                        >
                            {link.label}
                        </Button>
                    </Link>
                );
            })}
        </div>
    );
}

Komponen ini menampilkan pagination dengan tombol previous, next, dan nomor halaman. preserveScroll mencegah scroll ke atas saat berpindah halaman. preserveState menjaga state komponen seperti input yang sedang diketik.

Membuat Halaman Katalog

Sekarang halaman utamanya. Buat folder dan file resources/js/pages/Catalog/Index.tsx:

import { Head, Link, router } from '@inertiajs/react';
import { useState, useCallback } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import StoreLayout from '@/layouts/store-layout';
import { BookCard, Book } from '@/components/book-card';
import { Pagination } from '@/components/pagination';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
    Select,
    SelectContent,
    SelectItem,
    SelectTrigger,
    SelectValue,
} from '@/components/ui/select';
import {
    Sheet,
    SheetContent,
    SheetHeader,
    SheetTitle,
    SheetTrigger,
} from '@/components/ui/sheet';
import {
    Search,
    X,
    SlidersHorizontal,
    BookOpen,
    RotateCcw
} from 'lucide-react';

interface Category {
    id: number;
    name: string;
    slug: string;
    icon: string;
    books_count: number;
}

interface PaginationLink {
    url: string | null;
    label: string;
    active: boolean;
}

interface PaginatedBooks {
    data: Book[];
    links: PaginationLink[];
    current_page: number;
    last_page: number;
    per_page: number;
    total: number;
    from: number;
    to: number;
}

interface Filters {
    search: string;
    category: string;
    sort: string;
    featured: boolean;
    in_stock: boolean;
    min_price: string;
    max_price: string;
}

interface Props {
    books: PaginatedBooks;
    categories: Category[];
    currentCategory: Category | null;
    filters: Filters;
}

export default function CatalogIndex({ books, categories, currentCategory, filters }: Props) {
    const [search, setSearch] = useState(filters.search);
    const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);

    const updateFilters = useCallback((newFilters: Partial<Filters>) => {
        const updatedFilters = { ...filters, ...newFilters };

        const params: Record<string, string> = {};

        if (updatedFilters.search) params.search = updatedFilters.search;
        if (updatedFilters.category) params.category = updatedFilters.category;
        if (updatedFilters.sort && updatedFilters.sort !== 'latest') params.sort = updatedFilters.sort;
        if (updatedFilters.featured) params.featured = '1';
        if (updatedFilters.in_stock) params.in_stock = '1';
        if (updatedFilters.min_price) params.min_price = updatedFilters.min_price;
        if (updatedFilters.max_price) params.max_price = updatedFilters.max_price;

        router.get('/catalog', params, {
            preserveState: true,
            preserveScroll: true,
        });
    }, [filters]);

    const debouncedSearch = useDebouncedCallback((value: string) => {
        updateFilters({ search: value });
    }, 400);

    const handleSearchChange = (value: string) => {
        setSearch(value);
        debouncedSearch(value);
    };

    const clearAllFilters = () => {
        setSearch('');
        router.get('/catalog');
    };

    const hasActiveFilters =
        filters.search ||
        filters.category ||
        filters.sort !== 'latest' ||
        filters.featured ||
        filters.in_stock ||
        filters.min_price ||
        filters.max_price;

    const FilterSidebar = () => (
        <div className="space-y-6">
            <div>
                <h3 className="font-semibold text-gray-900 mb-3">Kategori</h3>
                <div className="space-y-1">
                    <button
                        onClick={() => updateFilters({ category: '' })}
                        className={`block w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
                            !filters.category
                                ? 'bg-blue-50 text-blue-700 font-medium'
                                : 'text-gray-600 hover:bg-gray-50'
                        }`}
                    >
                        Semua Kategori
                    </button>
                    {categories.map((category) => (
                        <button
                            key={category.id}
                            onClick={() => updateFilters({ category: category.slug })}
                            className={`block w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
                                filters.category === category.slug
                                    ? 'bg-blue-50 text-blue-700 font-medium'
                                    : 'text-gray-600 hover:bg-gray-50'
                            }`}
                        >
                            <span className="mr-2">{category.icon}</span>
                            {category.name}
                            <span className="text-gray-400 ml-1">
                                ({category.books_count})
                            </span>
                        </button>
                    ))}
                </div>
            </div>

            <div>
                <h3 className="font-semibold text-gray-900 mb-3">Urutkan</h3>
                <Select
                    value={filters.sort}
                    onValueChange={(value) => updateFilters({ sort: value })}
                >
                    <SelectTrigger className="w-full">
                        <SelectValue placeholder="Pilih urutan" />
                    </SelectTrigger>
                    <SelectContent>
                        <SelectItem value="latest">Terbaru</SelectItem>
                        <SelectItem value="oldest">Terlama</SelectItem>
                        <SelectItem value="price_low">Harga Terendah</SelectItem>
                        <SelectItem value="price_high">Harga Tertinggi</SelectItem>
                        <SelectItem value="title_asc">Judul A-Z</SelectItem>
                        <SelectItem value="title_desc">Judul Z-A</SelectItem>
                    </SelectContent>
                </Select>
            </div>

            <div>
                <h3 className="font-semibold text-gray-900 mb-3">Filter</h3>
                <div className="space-y-3">
                    <div className="flex items-center space-x-2">
                        <Checkbox
                            id="featured"
                            checked={filters.featured}
                            onCheckedChange={(checked) =>
                                updateFilters({ featured: checked as boolean })
                            }
                        />
                        <Label htmlFor="featured" className="text-sm cursor-pointer">
                            Buku Pilihan
                        </Label>
                    </div>
                    <div className="flex items-center space-x-2">
                        <Checkbox
                            id="in_stock"
                            checked={filters.in_stock}
                            onCheckedChange={(checked) =>
                                updateFilters({ in_stock: checked as boolean })
                            }
                        />
                        <Label htmlFor="in_stock" className="text-sm cursor-pointer">
                            Stok Tersedia
                        </Label>
                    </div>
                </div>
            </div>

            <div>
                <h3 className="font-semibold text-gray-900 mb-3">Rentang Harga</h3>
                <div className="grid grid-cols-2 gap-2">
                    <div>
                        <Label htmlFor="min_price" className="text-xs text-gray-500">
                            Minimum
                        </Label>
                        <Input
                            id="min_price"
                            type="number"
                            placeholder="0"
                            value={filters.min_price}
                            onChange={(e) => updateFilters({ min_price: e.target.value })}
                            className="mt-1"
                        />
                    </div>
                    <div>
                        <Label htmlFor="max_price" className="text-xs text-gray-500">
                            Maximum
                        </Label>
                        <Input
                            id="max_price"
                            type="number"
                            placeholder="500000"
                            value={filters.max_price}
                            onChange={(e) => updateFilters({ max_price: e.target.value })}
                            className="mt-1"
                        />
                    </div>
                </div>
            </div>

            {hasActiveFilters && (
                <Button
                    variant="outline"
                    onClick={clearAllFilters}
                    className="w-full"
                >
                    <RotateCcw className="h-4 w-4 mr-2" />
                    Reset Semua Filter
                </Button>
            )}
        </div>
    );

    return (
        <StoreLayout>
            <Head title={currentCategory ? `${currentCategory.name} - Katalog` : 'Katalog Buku'} />

            <div className="bg-white border-b">
                <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
                    <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
                        <div>
                            <h1 className="text-2xl md:text-3xl font-bold text-gray-900">
                                {currentCategory ? currentCategory.name : 'Katalog Buku'}
                            </h1>
                            {currentCategory && (
                                <p className="text-gray-600 mt-1">
                                    {currentCategory.icon} {currentCategory.name}
                                </p>
                            )}
                            <p className="text-gray-500 mt-1">
                                {books.total > 0 ? (
                                    <>Menampilkan {books.from}-{books.to} dari {books.total} buku</>
                                ) : (
                                    'Tidak ada buku ditemukan'
                                )}
                            </p>
                        </div>

                        <div className="flex items-center gap-3">
                            <div className="relative flex-1 md:w-80">
                                <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
                                <Input
                                    type="text"
                                    placeholder="Cari judul, penulis, atau ISBN..."
                                    value={search}
                                    onChange={(e) => handleSearchChange(e.target.value)}
                                    className="pl-10 pr-10"
                                />
                                {search && (
                                    <button
                                        onClick={() => handleSearchChange('')}
                                        className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
                                    >
                                        <X className="h-4 w-4" />
                                    </button>
                                )}
                            </div>

                            <Sheet open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
                                <SheetTrigger asChild>
                                    <Button variant="outline" className="lg:hidden">
                                        <SlidersHorizontal className="h-4 w-4 mr-2" />
                                        Filter
                                        {hasActiveFilters && (
                                            <span className="ml-2 bg-blue-600 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">
                                                !
                                            </span>
                                        )}
                                    </Button>
                                </SheetTrigger>
                                <SheetContent side="left" className="w-80">
                                    <SheetHeader>
                                        <SheetTitle>Filter & Urutkan</SheetTitle>
                                    </SheetHeader>
                                    <div className="mt-6">
                                        <FilterSidebar />
                                    </div>
                                </SheetContent>
                            </Sheet>
                        </div>
                    </div>

                    {hasActiveFilters && (
                        <div className="flex flex-wrap items-center gap-2 mt-4">
                            <span className="text-sm text-gray-500">Filter aktif:</span>

                            {filters.search && (
                                <Button
                                    variant="secondary"
                                    size="sm"
                                    onClick={() => {
                                        setSearch('');
                                        updateFilters({ search: '' });
                                    }}
                                    className="h-7 text-xs"
                                >
                                    Pencarian: "{filters.search}"
                                    <X className="h-3 w-3 ml-1" />
                                </Button>
                            )}

                            {filters.category && currentCategory && (
                                <Button
                                    variant="secondary"
                                    size="sm"
                                    onClick={() => updateFilters({ category: '' })}
                                    className="h-7 text-xs"
                                >
                                    {currentCategory.icon} {currentCategory.name}
                                    <X className="h-3 w-3 ml-1" />
                                </Button>
                            )}

                            {filters.featured && (
                                <Button
                                    variant="secondary"
                                    size="sm"
                                    onClick={() => updateFilters({ featured: false })}
                                    className="h-7 text-xs"
                                >
                                    Buku Pilihan
                                    <X className="h-3 w-3 ml-1" />
                                </Button>
                            )}

                            {filters.in_stock && (
                                <Button
                                    variant="secondary"
                                    size="sm"
                                    onClick={() => updateFilters({ in_stock: false })}
                                    className="h-7 text-xs"
                                >
                                    Stok Tersedia
                                    <X className="h-3 w-3 ml-1" />
                                </Button>
                            )}

                            {(filters.min_price || filters.max_price) && (
                                <Button
                                    variant="secondary"
                                    size="sm"
                                    onClick={() => updateFilters({ min_price: '', max_price: '' })}
                                    className="h-7 text-xs"
                                >
                                    Harga: {filters.min_price || '0'} - {filters.max_price || '∞'}
                                    <X className="h-3 w-3 ml-1" />
                                </Button>
                            )}

                            <Button
                                variant="ghost"
                                size="sm"
                                onClick={clearAllFilters}
                                className="h-7 text-xs text-red-600 hover:text-red-700 hover:bg-red-50"
                            >
                                Hapus Semua
                            </Button>
                        </div>
                    )}
                </div>
            </div>

            <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
                <div className="flex gap-8">
                    <aside className="hidden lg:block w-64 flex-shrink-0">
                        <div className="sticky top-24">
                            <FilterSidebar />
                        </div>
                    </aside>

                    <div className="flex-1">
                        {books.data.length === 0 ? (
                            <div className="text-center py-16">
                                <BookOpen className="h-16 w-16 text-gray-300 mx-auto mb-4" />
                                <h3 className="text-lg font-medium text-gray-900 mb-2">
                                    Tidak ada buku ditemukan
                                </h3>
                                <p className="text-gray-500 mb-6">
                                    Coba ubah kata kunci pencarian atau filter yang digunakan
                                </p>
                                <Button onClick={clearAllFilters}>
                                    <RotateCcw className="h-4 w-4 mr-2" />
                                    Reset Filter
                                </Button>
                            </div>
                        ) : (
                            <>
                                <div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6">
                                    {books.data.map((book) => (
                                        <BookCard key={book.id} book={book} />
                                    ))}
                                </div>

                                <Pagination
                                    links={books.links}
                                    currentPage={books.current_page}
                                    lastPage={books.last_page}
                                />
                            </>
                        )}
                    </div>
                </div>
            </div>
        </StoreLayout>
    );
}

Halaman ini cukup panjang, jadi mari breakdown bagian-bagian pentingnya.

State Management

const [search, setSearch] = useState(filters.search);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);

Kita hanya perlu dua local state. search untuk controlled input dan mobileFiltersOpen untuk toggle filter di mobile. Semua filter lainnya langsung dari props dan di-update via URL.

Update Filters Function

const updateFilters = useCallback((newFilters: Partial<Filters>) => {
    const updatedFilters = { ...filters, ...newFilters };

    const params: Record<string, string> = {};

    if (updatedFilters.search) params.search = updatedFilters.search;
    // ... dst

    router.get('/catalog', params, {
        preserveState: true,
        preserveScroll: true,
    });
}, [filters]);

Function ini merge filter baru dengan yang existing, lalu navigate ke URL dengan query params yang updated. preserveState menjaga state React, preserveScroll mencegah scroll ke atas.

Debounced Search

const debouncedSearch = useDebouncedCallback((value: string) => {
    updateFilters({ search: value });
}, 400);

const handleSearchChange = (value: string) => {
    setSearch(value);
    debouncedSearch(value);
};

Saat user mengetik, input langsung update (responsive). Tapi request ke server di-debounce 400ms. Ini mencegah spam request setiap keystroke.

Filter Sidebar Component

Filter sidebar dijadikan function component di dalam halaman karena dipakai di dua tempat: sidebar desktop dan sheet mobile. Dengan cara ini, kode tidak duplikat.

Active Filters Tags

Di bagian bawah header, ada tampilan filter yang aktif dalam bentuk tags. User bisa klik X untuk remove filter individual atau "Hapus Semua" untuk reset.

Empty State

Kalau tidak ada buku ditemukan, tampilkan pesan yang helpful dengan tombol reset filter.

Responsive Layout

Di desktop, filter ada di sidebar kiri yang sticky. Di mobile, filter tersembunyi dalam Sheet (side drawer) yang muncul saat tombol Filter diklik.

Update Komponen Checkbox

Starter kit Laravel 12 mungkin belum include komponen Checkbox dari shadcn/ui. Kalau belum ada, kita perlu publish:

npx shadcn@latest add checkbox

Jika command di atas tidak work atau checkbox sudah ada tapi import error, buat manual. Buat file resources/js/components/ui/checkbox.tsx:

import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"

const Checkbox = React.forwardRef<
  React.ElementRef<typeof CheckboxPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
  <CheckboxPrimitive.Root
    ref={ref}
    className={cn(
      "peer h-4 w-4 shrink-0 rounded-sm border border-gray-300 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600 data-[state=checked]:text-white",
      className
    )}
    {...props}
  >
    <CheckboxPrimitive.Indicator
      className={cn("flex items-center justify-center text-current")}
    >
      <Check className="h-3 w-3" />
    </CheckboxPrimitive.Indicator>
  </CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName

export { Checkbox }

Install dependency jika belum:

npm install @radix-ui/react-checkbox

Update Komponen Sheet

Sama seperti checkbox, pastikan Sheet sudah ada:

npx shadcn@latest add sheet

Test Halaman Katalog

Restart development server kalau perlu:

composer run dev

Buka http://localhost:8000/catalog.

Test Search:

  1. Ketik di search box, misalnya "programming"
  2. Perhatikan URL berubah setelah berhenti mengetik
  3. Hasil ter-filter sesuai keyword
  4. Hapus dengan klik X atau backspace semua

Test Filter Kategori:

  1. Klik salah satu kategori di sidebar
  2. URL berubah dengan ?category=xxx
  3. Hanya buku dari kategori itu yang muncul
  4. Klik "Semua Kategori" untuk reset

Test Sorting:

  1. Ubah dropdown sorting ke "Harga Terendah"
  2. Buku ter-urut dari harga paling murah
  3. Coba opsi sorting lainnya

Test Checkbox Filters:

  1. Centang "Buku Pilihan"
  2. Hanya buku featured yang muncul
  3. Centang "Stok Tersedia"
  4. Buku dengan stok 0 tidak muncul

Test Pagination:

  1. Scroll ke bawah
  2. Klik halaman 2
  3. URL berubah dengan ?page=2
  4. Filter tetap terjaga

Test Mobile:

  1. Resize browser ke ukuran mobile
  2. Sidebar menghilang
  3. Klik tombol "Filter"
  4. Sheet muncul dari kiri dengan semua filter

Test Active Filters Tags:

  1. Aktifkan beberapa filter
  2. Tags muncul di bawah search
  3. Klik X pada tag untuk remove filter individual
  4. Klik "Hapus Semua" untuk reset semua

Menambahkan Link dari Home ke Catalog

Sekarang link dari homepage ke katalog sudah work. Coba:

  1. Buka homepage
  2. Klik salah satu kategori → redirect ke catalog dengan filter kategori
  3. Klik "Lihat Semua" di section buku pilihan → redirect dengan ?featured=1
  4. Klik "Lihat Semua" di section buku terbaru → redirect dengan ?sort=latest

Optimasi: Skeleton Loading

Untuk UX yang lebih baik, kita bisa tambahkan skeleton loading saat data sedang di-fetch. Tapi karena Inertia sudah sangat cepat, ini optional. Kalau kamu mau menambahkan, bisa detect loading state dengan:

import { router } from '@inertiajs/react';
import { useState, useEffect } from 'react';

// Di dalam component
const [isLoading, setIsLoading] = useState(false);

useEffect(() => {
    const startHandler = () => setIsLoading(true);
    const finishHandler = () => setIsLoading(false);

    router.on('start', startHandler);
    router.on('finish', finishHandler);

    return () => {
        router.off('start', startHandler);
        router.off('finish', finishHandler);
    };
}, []);

Lalu tampilkan skeleton atau loading indicator saat isLoading true.

Recap

Di bagian ini kita sudah membangun halaman katalog yang lengkap dengan:

  • Search dengan debounce untuk performance
  • Filter berdasarkan kategori, featured, dan stok
  • Filter rentang harga
  • Sorting dengan 6 opsi berbeda
  • Pagination yang menjaga semua filter
  • Responsive design dengan mobile sheet
  • Active filters tags yang bisa di-remove
  • Empty state yang helpful
  • URL yang reflect semua state (shareable dan bookmarkable)

Ini adalah halaman paling kompleks dalam tutorial ini, dan kamu sudah berhasil membuatnya. Di bagian selanjutnya, kita akan membuat halaman detail buku yang lebih sederhana tapi tetap penting.

Lanjut ke Bagian 5: Halaman Detail Buku →

Bagian 5: Halaman Detail Buku

Setelah customer menemukan buku yang menarik di katalog, langkah selanjutnya adalah melihat detail buku tersebut. Halaman detail adalah tempat customer membuat keputusan untuk membeli atau tidak. Jadi informasi harus lengkap dan meyakinkan.

Di halaman ini kita akan menampilkan semua informasi buku: cover besar, judul, penulis, harga, deskripsi lengkap, dan metadata seperti ISBN, jumlah halaman, dan tahun terbit. Kita juga akan menambahkan tombol add to cart dan section related books untuk meningkatkan kemungkinan pembelian tambahan.

Membuat Book Controller

Buat controller baru untuk handle detail buku:

php artisan make:controller BookController

Edit app/Http/Controllers/BookController.php:

<?php

namespace App\\Http\\Controllers;

use App\\Models\\Book;
use Inertia\\Inertia;
use Inertia\\Response;

class BookController extends Controller
{
    public function show(string $slug): Response
    {
        $book = Book::with('category')
            ->where('slug', $slug)
            ->active()
            ->firstOrFail();

        $relatedBooks = Book::with('category')
            ->where('category_id', $book->category_id)
            ->where('id', '!=', $book->id)
            ->active()
            ->inStock()
            ->take(4)
            ->inRandomOrder()
            ->get();

        $sameCategoryCount = Book::where('category_id', $book->category_id)
            ->active()
            ->count();

        return Inertia::render('Books/Show', [
            'book' => $book,
            'relatedBooks' => $relatedBooks,
            'sameCategoryCount' => $sameCategoryCount,
        ]);
    }
}

Controller ini melakukan beberapa hal. Mengambil buku berdasarkan slug dengan eager load category. Kalau buku tidak ditemukan atau tidak aktif, akan throw 404 otomatis berkat firstOrFail().

Related books diambil dari kategori yang sama, exclude buku yang sedang dilihat, hanya yang aktif dan ada stok, ambil 4 secara random. Random order membuat halaman terasa fresh setiap kali dikunjungi.

Kita juga hitung total buku di kategori yang sama untuk ditampilkan di link "Lihat semua buku di kategori ini".

Mendaftarkan Route

Tambahkan route di routes/web.php:

<?php

use App\\Http\\Controllers\\HomeController;
use App\\Http\\Controllers\\CatalogController;
use App\\Http\\Controllers\\BookController;
use Illuminate\\Support\\Facades\\Route;

Route::get('/', [HomeController::class, 'index'])->name('home');
Route::get('/catalog', [CatalogController::class, 'index'])->name('catalog');
Route::get('/books/{slug}', [BookController::class, 'show'])->name('books.show');

require __DIR__.'/auth.php';

Route menggunakan slug sebagai parameter, bukan id. Ini lebih SEO friendly dan URL terlihat lebih bersih. Misalnya /books/belajar-laravel-12 lebih baik daripada /books/42.

Membuat Halaman Detail Buku

Buat folder dan file resources/js/pages/Books/Show.tsx:

import { Head, Link, router } from '@inertiajs/react';
import { useState } from 'react';
import StoreLayout from '@/layouts/store-layout';
import { BookCard, Book } from '@/components/book-card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
    Breadcrumb,
    BreadcrumbItem,
    BreadcrumbLink,
    BreadcrumbList,
    BreadcrumbPage,
    BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import {
    ShoppingCart,
    Minus,
    Plus,
    Check,
    ArrowLeft,
    BookOpen,
    Calendar,
    FileText,
    Barcode,
    Tag,
    Truck
} from 'lucide-react';

interface Category {
    id: number;
    name: string;
    slug: string;
    icon: string;
}

interface BookDetail extends Book {
    description: string;
    year_published: number | null;
    isbn: string | null;
    pages: number | null;
}

interface Props {
    book: BookDetail;
    relatedBooks: Book[];
    sameCategoryCount: number;
}

export default function BookShow({ book, relatedBooks, sameCategoryCount }: Props) {
    const [quantity, setQuantity] = useState(1);
    const [isAddingToCart, setIsAddingToCart] = useState(false);
    const [addedToCart, setAddedToCart] = useState(false);

    const incrementQuantity = () => {
        if (quantity < book.stock) {
            setQuantity(q => q + 1);
        }
    };

    const decrementQuantity = () => {
        if (quantity > 1) {
            setQuantity(q => q - 1);
        }
    };

    const handleAddToCart = () => {
        setIsAddingToCart(true);

        router.post('/cart/add', {
            book_id: book.id,
            quantity: quantity,
        }, {
            preserveScroll: true,
            onSuccess: () => {
                setAddedToCart(true);
                setTimeout(() => setAddedToCart(false), 2000);
            },
            onFinish: () => {
                setIsAddingToCart(false);
            },
        });
    };

    const isOutOfStock = book.stock === 0;
    const isLowStock = book.stock > 0 && book.stock <= 5;

    return (
        <StoreLayout>
            <Head title={`${book.title} - ${book.author}`} />

            <div className="bg-white border-b">
                <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
                    <Breadcrumb>
                        <BreadcrumbList>
                            <BreadcrumbItem>
                                <BreadcrumbLink asChild>
                                    <Link href="/">Home</Link>
                                </BreadcrumbLink>
                            </BreadcrumbItem>
                            <BreadcrumbSeparator />
                            <BreadcrumbItem>
                                <BreadcrumbLink asChild>
                                    <Link href="/catalog">Katalog</Link>
                                </BreadcrumbLink>
                            </BreadcrumbItem>
                            <BreadcrumbSeparator />
                            <BreadcrumbItem>
                                <BreadcrumbLink asChild>
                                    <Link href={`/catalog?category=${book.category.slug}`}>
                                        {book.category.name}
                                    </Link>
                                </BreadcrumbLink>
                            </BreadcrumbItem>
                            <BreadcrumbSeparator />
                            <BreadcrumbItem>
                                <BreadcrumbPage className="max-w-[200px] truncate">
                                    {book.title}
                                </BreadcrumbPage>
                            </BreadcrumbItem>
                        </BreadcrumbList>
                    </Breadcrumb>
                </div>
            </div>

            <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
                <Link
                    href="/catalog"
                    className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900 mb-6"
                >
                    <ArrowLeft className="h-4 w-4 mr-1" />
                    Kembali ke Katalog
                </Link>

                <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
                    <div className="space-y-4">
                        <div className="aspect-[3/4] bg-gray-100 rounded-lg overflow-hidden relative">
                            {book.cover_image ? (
                                <img
                                    src={`/storage/${book.cover_image}`}
                                    alt={book.title}
                                    className="w-full h-full object-cover"
                                />
                            ) : (
                                <div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-100 to-purple-100">
                                    <BookOpen className="h-32 w-32 text-blue-300" />
                                </div>
                            )}

                            {book.is_featured && (
                                <Badge className="absolute top-4 left-4 bg-yellow-500 hover:bg-yellow-500">
                                    ⭐ Buku Pilihan
                                </Badge>
                            )}

                            {isOutOfStock && (
                                <div className="absolute inset-0 bg-black/60 flex items-center justify-center">
                                    <Badge variant="destructive" className="text-lg py-2 px-4">
                                        Stok Habis
                                    </Badge>
                                </div>
                            )}
                        </div>
                    </div>

                    <div className="space-y-6">
                        <div>
                            <Link
                                href={`/catalog?category=${book.category.slug}`}
                                className="inline-flex items-center text-sm text-blue-600 hover:text-blue-700 font-medium mb-2"
                            >
                                <Tag className="h-3 w-3 mr-1" />
                                {book.category.icon} {book.category.name}
                            </Link>

                            <h1 className="text-2xl md:text-3xl font-bold text-gray-900 mb-2">
                                {book.title}
                            </h1>

                            <p className="text-lg text-gray-600">
                                oleh <span className="font-medium text-gray-900">{book.author}</span>
                            </p>
                        </div>

                        <div className="flex items-baseline gap-4">
                            <span className="text-3xl font-bold text-gray-900">
                                {book.formatted_price}
                            </span>
                        </div>

                        <div className="flex items-center gap-4">
                            {isOutOfStock ? (
                                <Badge variant="destructive" className="text-sm">
                                    Stok Habis
                                </Badge>
                            ) : isLowStock ? (
                                <Badge variant="secondary" className="text-sm bg-orange-100 text-orange-700">
                                    Sisa {book.stock} buku
                                </Badge>
                            ) : (
                                <Badge variant="secondary" className="text-sm bg-green-100 text-green-700">
                                    <Check className="h-3 w-3 mr-1" />
                                    Stok Tersedia
                                </Badge>
                            )}
                        </div>

                        <Separator />

                        {!isOutOfStock && (
                            <div className="space-y-4">
                                <div>
                                    <label className="block text-sm font-medium text-gray-700 mb-2">
                                        Jumlah
                                    </label>
                                    <div className="flex items-center gap-3">
                                        <div className="flex items-center border rounded-lg">
                                            <Button
                                                variant="ghost"
                                                size="icon"
                                                onClick={decrementQuantity}
                                                disabled={quantity <= 1}
                                                className="h-10 w-10 rounded-r-none"
                                            >
                                                <Minus className="h-4 w-4" />
                                            </Button>
                                            <span className="w-12 text-center font-medium">
                                                {quantity}
                                            </span>
                                            <Button
                                                variant="ghost"
                                                size="icon"
                                                onClick={incrementQuantity}
                                                disabled={quantity >= book.stock}
                                                className="h-10 w-10 rounded-l-none"
                                            >
                                                <Plus className="h-4 w-4" />
                                            </Button>
                                        </div>
                                        <span className="text-sm text-gray-500">
                                            Maks. {book.stock} buku
                                        </span>
                                    </div>
                                </div>

                                <Button
                                    size="lg"
                                    className="w-full"
                                    onClick={handleAddToCart}
                                    disabled={isAddingToCart}
                                >
                                    {addedToCart ? (
                                        <>
                                            <Check className="h-5 w-5 mr-2" />
                                            Ditambahkan ke Keranjang
                                        </>
                                    ) : isAddingToCart ? (
                                        <>
                                            <span className="animate-spin mr-2">⏳</span>
                                            Menambahkan...
                                        </>
                                    ) : (
                                        <>
                                            <ShoppingCart className="h-5 w-5 mr-2" />
                                            Tambah ke Keranjang
                                        </>
                                    )}
                                </Button>

                                <div className="flex items-center gap-2 text-sm text-gray-500">
                                    <Truck className="h-4 w-4" />
                                    <span>Estimasi pengiriman 2-5 hari kerja</span>
                                </div>
                            </div>
                        )}

                        {isOutOfStock && (
                            <div className="bg-gray-50 rounded-lg p-4 text-center">
                                <p className="text-gray-600 mb-3">
                                    Buku ini sedang tidak tersedia
                                </p>
                                <Link href="/catalog">
                                    <Button variant="outline">
                                        Lihat Buku Lainnya
                                    </Button>
                                </Link>
                            </div>
                        )}

                        <Separator />

                        <div>
                            <h2 className="text-lg font-semibold text-gray-900 mb-3">
                                Deskripsi
                            </h2>
                            <div className="prose prose-gray max-w-none">
                                {book.description.split('\\n').map((paragraph, index) => (
                                    <p key={index} className="text-gray-600 mb-3">
                                        {paragraph}
                                    </p>
                                ))}
                            </div>
                        </div>

                        <Separator />

                        <div>
                            <h2 className="text-lg font-semibold text-gray-900 mb-3">
                                Detail Buku
                            </h2>
                            <dl className="grid grid-cols-2 gap-4">
                                {book.year_published && (
                                    <div className="flex items-start gap-3">
                                        <Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
                                        <div>
                                            <dt className="text-sm text-gray-500">Tahun Terbit</dt>
                                            <dd className="font-medium text-gray-900">{book.year_published}</dd>
                                        </div>
                                    </div>
                                )}

                                {book.pages && (
                                    <div className="flex items-start gap-3">
                                        <FileText className="h-5 w-5 text-gray-400 mt-0.5" />
                                        <div>
                                            <dt className="text-sm text-gray-500">Jumlah Halaman</dt>
                                            <dd className="font-medium text-gray-900">{book.pages} halaman</dd>
                                        </div>
                                    </div>
                                )}

                                {book.isbn && (
                                    <div className="flex items-start gap-3 col-span-2">
                                        <Barcode className="h-5 w-5 text-gray-400 mt-0.5" />
                                        <div>
                                            <dt className="text-sm text-gray-500">ISBN</dt>
                                            <dd className="font-medium text-gray-900">{book.isbn}</dd>
                                        </div>
                                    </div>
                                )}
                            </dl>
                        </div>
                    </div>
                </div>

                {relatedBooks.length > 0 && (
                    <div className="mt-16">
                        <div className="flex items-center justify-between mb-6">
                            <div>
                                <h2 className="text-xl font-bold text-gray-900">
                                    Buku Lainnya di Kategori {book.category.name}
                                </h2>
                                <p className="text-gray-500 text-sm mt-1">
                                    {sameCategoryCount} buku tersedia
                                </p>
                            </div>
                            <Link href={`/catalog?category=${book.category.slug}`}>
                                <Button variant="outline">
                                    Lihat Semua
                                </Button>
                            </Link>
                        </div>

                        <div className="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6">
                            {relatedBooks.map((relatedBook) => (
                                <BookCard key={relatedBook.id} book={relatedBook} />
                            ))}
                        </div>
                    </div>
                )}
            </div>
        </StoreLayout>
    );
}

Mari breakdown komponen-komponen penting di halaman ini.

Breadcrumb Navigation

Breadcrumb membantu user memahami posisi mereka dalam hierarki situs. Dari Home ke Katalog ke Kategori ke Buku saat ini. Setiap level adalah link yang bisa diklik untuk navigasi cepat.

Quantity Selector

const [quantity, setQuantity] = useState(1);

const incrementQuantity = () => {
    if (quantity < book.stock) {
        setQuantity(q => q + 1);
    }
};

Quantity dibatasi minimum 1 dan maksimum sesuai stok. Tombol plus dan minus ter-disable kalau sudah mencapai batas.

Add to Cart Handler

const handleAddToCart = () => {
    setIsAddingToCart(true);

    router.post('/cart/add', {
        book_id: book.id,
        quantity: quantity,
    }, {
        preserveScroll: true,
        onSuccess: () => {
            setAddedToCart(true);
            setTimeout(() => setAddedToCart(false), 2000);
        },
        onFinish: () => {
            setIsAddingToCart(false);
        },
    });
};

Saat tombol diklik, state berubah ke loading. Setelah sukses, tombol berubah jadi "Ditambahkan" selama 2 detik sebagai feedback. Route /cart/add belum ada, akan kita buat di bagian selanjutnya.

Stock Status Display

const isOutOfStock = book.stock === 0;
const isLowStock = book.stock > 0 && book.stock <= 5;

Tiga kondisi stok: habis (merah), sisa sedikit (orange), dan tersedia (hijau). Visual feedback ini membantu customer membuat keputusan.

Description Rendering

{book.description.split('\\n').map((paragraph, index) => (
    <p key={index} className="text-gray-600 mb-3">
        {paragraph}
    </p>
))}

Deskripsi di-split berdasarkan newline dan di-render sebagai paragraf terpisah. Ini membuat deskripsi panjang lebih readable.

Related Books

Section related books menampilkan 4 buku dari kategori yang sama. Ini adalah teknik upselling yang umum di e-commerce. Customer yang tertarik dengan satu buku mungkin juga tertarik dengan buku serupa.

Menambahkan Komponen Breadcrumb

Kalau komponen Breadcrumb belum ada, tambahkan:

npx shadcn@latest add breadcrumb

Atau buat manual di resources/js/components/ui/breadcrumb.tsx:

import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"

const Breadcrumb = React.forwardRef<
  HTMLElement,
  React.ComponentPropsWithoutRef<"nav"> & {
    separator?: React.ReactNode
  }
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"

const BreadcrumbList = React.forwardRef<
  HTMLOListElement,
  React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
  <ol
    ref={ref}
    className={cn(
      "flex flex-wrap items-center gap-1.5 break-words text-sm text-gray-500",
      className
    )}
    {...props}
  />
))
BreadcrumbList.displayName = "BreadcrumbList"

const BreadcrumbItem = React.forwardRef<
  HTMLLIElement,
  React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
  <li
    ref={ref}
    className={cn("inline-flex items-center gap-1.5", className)}
    {...props}
  />
))
BreadcrumbItem.displayName = "BreadcrumbItem"

const BreadcrumbLink = React.forwardRef<
  HTMLAnchorElement,
  React.ComponentPropsWithoutRef<"a"> & {
    asChild?: boolean
  }
>(({ asChild, className, ...props }, ref) => {
  const Comp = asChild ? Slot : "a"

  return (
    <Comp
      ref={ref}
      className={cn("transition-colors hover:text-gray-900", className)}
      {...props}
    />
  )
})
BreadcrumbLink.displayName = "BreadcrumbLink"

const BreadcrumbPage = React.forwardRef<
  HTMLSpanElement,
  React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
  <span
    ref={ref}
    role="link"
    aria-disabled="true"
    aria-current="page"
    className={cn("font-normal text-gray-900", className)}
    {...props}
  />
))
BreadcrumbPage.displayName = "BreadcrumbPage"

const BreadcrumbSeparator = ({
  children,
  className,
  ...props
}: React.ComponentProps<"li">) => (
  <li
    role="presentation"
    aria-hidden="true"
    className={cn("[&>svg]:size-3.5", className)}
    {...props}
  >
    {children ?? <ChevronRight />}
  </li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"

export {
  Breadcrumb,
  BreadcrumbList,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbPage,
  BreadcrumbSeparator,
}

Menambahkan Komponen Separator

npx shadcn@latest add separator

Atau buat manual di resources/js/components/ui/separator.tsx:

import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"

const Separator = React.forwardRef<
  React.ElementRef<typeof SeparatorPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
  (
    { className, orientation = "horizontal", decorative = true, ...props },
    ref
  ) => (
    <SeparatorPrimitive.Root
      ref={ref}
      decorative={decorative}
      orientation={orientation}
      className={cn(
        "shrink-0 bg-gray-200",
        orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
        className
      )}
      {...props}
    />
  )
)
Separator.displayName = SeparatorPrimitive.Root.displayName

export { Separator }

Install dependency jika perlu:

npm install @radix-ui/react-separator

Update Interface Book di book-card.tsx

Karena halaman detail membutuhkan lebih banyak field, update interface di resources/js/components/book-card.tsx:

export interface Book {
    id: number;
    title: string;
    slug: string;
    author: string;
    price: number;
    formatted_price: string;
    cover_image: string | null;
    stock: number;
    is_featured: boolean;
    category: {
        id: number;
        name: string;
        slug: string;
        icon?: string;
    };
}

Tambahkan icon sebagai optional di category.

Test Halaman Detail Buku

Jalankan development server:

composer run dev

Buka http://localhost:8000/catalog, lalu klik salah satu buku.

Yang harus terlihat:

  1. Breadcrumb navigation di atas
  2. Link "Kembali ke Katalog"
  3. Cover image besar di kiri (atau placeholder)
  4. Badge "Buku Pilihan" jika featured
  5. Overlay "Stok Habis" jika stock = 0
  6. Kategori sebagai link
  7. Judul dan penulis
  8. Harga dalam format Rupiah
  9. Status stok dengan warna berbeda
  10. Quantity selector (jika ada stok)
  11. Tombol "Tambah ke Keranjang" (jika ada stok)
  12. Info estimasi pengiriman
  13. Deskripsi lengkap
  14. Detail buku (tahun, halaman, ISBN)
  15. Section related books

Test Interaksi:

  1. Klik tombol plus/minus untuk ubah quantity
  2. Quantity tidak bisa melebihi stok
  3. Quantity tidak bisa kurang dari 1
  4. Klik breadcrumb untuk navigasi
  5. Klik kategori untuk ke katalog dengan filter
  6. Klik related book untuk ke detail buku lain

Test Edge Cases:

  1. Buka buku dengan stok 0 - tombol add to cart tidak muncul
  2. Buka buku dengan stok sedikit (edit di database jadi 3) - muncul "Sisa 3 buku"
  3. Buka buku featured - muncul badge kuning

Menangani 404

Kalau user mengakses slug yang tidak ada, Laravel akan otomatis return 404. Untuk pengalaman yang lebih baik, kita bisa buat custom 404 page. Tapi untuk sekarang, default Laravel 404 sudah cukup.

SEO Consideration

Perhatikan tag Head:

<Head title={`${book.title} - ${book.author}`} />

Title halaman mengandung judul buku dan penulis. Ini bagus untuk SEO karena search engine akan menampilkan informasi yang relevan di hasil pencarian.

Untuk SEO yang lebih advanced, kamu bisa menambahkan meta description dan Open Graph tags. Ini bisa dilakukan dengan menambahkan children ke Head component atau menggunakan Inertia SSR.

Recap

Di bagian ini kita sudah membuat halaman detail buku yang lengkap dengan:

  • Breadcrumb navigation untuk orientasi user
  • Cover image dengan placeholder dan badges
  • Informasi lengkap: judul, penulis, harga, stok
  • Quantity selector dengan validasi
  • Tombol add to cart dengan loading state dan feedback
  • Deskripsi yang ter-format rapi
  • Detail metadata buku
  • Related books untuk upselling
  • Handling untuk stok habis

Halaman ini sudah siap menerima aksi add to cart. Di bagian selanjutnya, kita akan membuat sistem shopping cart yang menyimpan data di session dan halaman cart untuk melihat dan mengelola item.

Lanjut ke Bagian 6: Shopping Cart dengan Session →

Bagian 6: Shopping Cart dengan Session

Shopping cart adalah fitur krusial dalam e-commerce. Tanpa cart, customer tidak bisa mengumpulkan beberapa buku sebelum checkout. Di bagian ini kita akan membangun sistem cart yang menyimpan data di session Laravel.

Kenapa session dan bukan database? Untuk toko buku sederhana, session sudah cukup. Data cart tersimpan di server dan ter-link dengan browser cookie. Kalau user belum login, cart tetap work. Kalau user login di device berbeda, cart memang tidak sync, tapi untuk MVP ini acceptable. Kalau nanti mau scale, bisa migrate ke database dengan mudah.

Membuat Cart Service

Daripada menulis logic cart langsung di controller, kita buat service class terpisah. Ini membuat kode lebih clean dan reusable.

Buat folder dan file app/Services/CartService.php:

<?php

namespace App\\Services;

use App\\Models\\Book;

class CartService
{
    private const CART_KEY = 'shopping_cart';

    public function getCart(): array
    {
        return session()->get(self::CART_KEY, []);
    }

    public function getItems(): array
    {
        $cart = $this->getCart();
        $items = [];

        foreach ($cart as $bookId => $item) {
            $book = Book::with('category')->find($bookId);

            if ($book && $book->is_active) {
                $items[] = [
                    'id' => $bookId,
                    'book' => $book,
                    'quantity' => $item['quantity'],
                    'subtotal' => $book->price * $item['quantity'],
                ];
            } else {
                $this->removeItem($bookId);
            }
        }

        return $items;
    }

    public function addItem(Book $book, int $quantity = 1): array
    {
        if (!$book->is_active || $book->stock < 1) {
            return [
                'success' => false,
                'message' => 'Buku tidak tersedia',
            ];
        }

        $cart = $this->getCart();
        $bookId = $book->id;

        $currentQuantity = $cart[$bookId]['quantity'] ?? 0;
        $newQuantity = $currentQuantity + $quantity;

        if ($newQuantity > $book->stock) {
            return [
                'success' => false,
                'message' => "Stok tidak mencukupi. Tersedia: {$book->stock}",
            ];
        }

        $cart[$bookId] = [
            'quantity' => $newQuantity,
            'added_at' => $cart[$bookId]['added_at'] ?? now()->toIso8601String(),
        ];

        session()->put(self::CART_KEY, $cart);

        return [
            'success' => true,
            'message' => 'Buku berhasil ditambahkan ke keranjang',
            'quantity' => $newQuantity,
        ];
    }

    public function updateQuantity(int $bookId, int $quantity): array
    {
        $book = Book::find($bookId);

        if (!$book || !$book->is_active) {
            $this->removeItem($bookId);
            return [
                'success' => false,
                'message' => 'Buku tidak ditemukan',
            ];
        }

        if ($quantity < 1) {
            $this->removeItem($bookId);
            return [
                'success' => true,
                'message' => 'Buku dihapus dari keranjang',
            ];
        }

        if ($quantity > $book->stock) {
            return [
                'success' => false,
                'message' => "Stok tidak mencukupi. Tersedia: {$book->stock}",
            ];
        }

        $cart = $this->getCart();

        if (!isset($cart[$bookId])) {
            return [
                'success' => false,
                'message' => 'Buku tidak ada di keranjang',
            ];
        }

        $cart[$bookId]['quantity'] = $quantity;
        session()->put(self::CART_KEY, $cart);

        return [
            'success' => true,
            'message' => 'Jumlah berhasil diperbarui',
        ];
    }

    public function removeItem(int $bookId): void
    {
        $cart = $this->getCart();
        unset($cart[$bookId]);
        session()->put(self::CART_KEY, $cart);
    }

    public function clear(): void
    {
        session()->forget(self::CART_KEY);
    }

    public function getTotal(): float
    {
        $items = $this->getItems();
        return collect($items)->sum('subtotal');
    }

    public function getFormattedTotal(): string
    {
        return 'Rp ' . number_format($this->getTotal(), 0, ',', '.');
    }

    public function getItemCount(): int
    {
        $cart = $this->getCart();
        return collect($cart)->sum('quantity');
    }

    public function isEmpty(): bool
    {
        return empty($this->getCart());
    }
}

Service ini punya beberapa method penting.

getCart() mengambil raw cart data dari session. Format datanya adalah array dengan book_id sebagai key.

getItems() mengambil cart dengan data buku lengkap dari database. Ini memastikan harga dan info buku selalu up-to-date. Kalau buku sudah tidak aktif, otomatis di-remove dari cart.

addItem() menambah buku ke cart dengan validasi stok. Kalau buku sudah ada di cart, quantity di-increment.

updateQuantity() mengubah quantity item. Kalau quantity 0 atau kurang, item di-remove.

removeItem() menghapus item dari cart.

clear() mengosongkan seluruh cart.

getTotal() menghitung total harga semua item.

getItemCount() menghitung total quantity semua item. Ini yang ditampilkan di badge cart di header.

Membuat Cart Controller

php artisan make:controller CartController

Edit app/Http/Controllers/CartController.php:

<?php

namespace App\\Http\\Controllers;

use App\\Models\\Book;
use App\\Services\\CartService;
use Illuminate\\Http\\RedirectResponse;
use Illuminate\\Http\\Request;
use Inertia\\Inertia;
use Inertia\\Response;

class CartController extends Controller
{
    public function __construct(
        private CartService $cartService
    ) {}

    public function index(): Response
    {
        return Inertia::render('Cart/Index', [
            'items' => $this->cartService->getItems(),
            'total' => $this->cartService->getTotal(),
            'formattedTotal' => $this->cartService->getFormattedTotal(),
            'itemCount' => $this->cartService->getItemCount(),
        ]);
    }

    public function add(Request $request): RedirectResponse
    {
        $request->validate([
            'book_id' => 'required|exists:books,id',
            'quantity' => 'required|integer|min:1|max:99',
        ]);

        $book = Book::findOrFail($request->book_id);
        $result = $this->cartService->addItem($book, $request->quantity);

        if ($result['success']) {
            return back()->with('success', $result['message']);
        }

        return back()->with('error', $result['message']);
    }

    public function update(Request $request): RedirectResponse
    {
        $request->validate([
            'book_id' => 'required|exists:books,id',
            'quantity' => 'required|integer|min:0|max:99',
        ]);

        $result = $this->cartService->updateQuantity(
            $request->book_id,
            $request->quantity
        );

        if ($result['success']) {
            return back()->with('success', $result['message']);
        }

        return back()->with('error', $result['message']);
    }

    public function remove(Request $request): RedirectResponse
    {
        $request->validate([
            'book_id' => 'required|exists:books,id',
        ]);

        $this->cartService->removeItem($request->book_id);

        return back()->with('success', 'Buku dihapus dari keranjang');
    }

    public function clear(): RedirectResponse
    {
        $this->cartService->clear();

        return back()->with('success', 'Keranjang dikosongkan');
    }
}

Controller ini menggunakan dependency injection untuk CartService. Laravel secara otomatis me-resolve dependency ini.

Semua method yang mengubah cart return back() dengan flash message. Ini adalah pattern umum di Laravel yang works well dengan Inertia.

Mendaftarkan Routes

Update routes/web.php:

<?php

use App\\Http\\Controllers\\HomeController;
use App\\Http\\Controllers\\CatalogController;
use App\\Http\\Controllers\\BookController;
use App\\Http\\Controllers\\CartController;
use Illuminate\\Support\\Facades\\Route;

Route::get('/', [HomeController::class, 'index'])->name('home');
Route::get('/catalog', [CatalogController::class, 'index'])->name('catalog');
Route::get('/books/{slug}', [BookController::class, 'show'])->name('books.show');

Route::get('/cart', [CartController::class, 'index'])->name('cart.index');
Route::post('/cart/add', [CartController::class, 'add'])->name('cart.add');
Route::patch('/cart/update', [CartController::class, 'update'])->name('cart.update');
Route::delete('/cart/remove', [CartController::class, 'remove'])->name('cart.remove');
Route::delete('/cart/clear', [CartController::class, 'clear'])->name('cart.clear');

require __DIR__.'/auth.php';

Update HandleInertiaRequests untuk Cart Count

Kita sudah setup cartCount di middleware sebelumnya, tapi sekarang perlu update untuk menggunakan CartService.

Edit app/Http/Middleware/HandleInertiaRequests.php:

<?php

namespace App\\Http\\Middleware;

use App\\Services\\CartService;
use Illuminate\\Http\\Request;
use Inertia\\Middleware;

class HandleInertiaRequests extends Middleware
{
    protected $rootView = 'app';

    public function version(Request $request): ?string
    {
        return parent::version($request);
    }

    public function share(Request $request): array
    {
        $cartService = app(CartService::class);

        return [
            ...parent::share($request),
            'auth' => [
                'user' => $request->user(),
            ],
            'cartCount' => $cartService->getItemCount(),
            'flash' => [
                'success' => fn () => $request->session()->get('success'),
                'error' => fn () => $request->session()->get('error'),
            ],
        ];
    }
}

Membuat Halaman Cart

Buat folder dan file resources/js/pages/Cart/Index.tsx:

import { Head, Link, router } from '@inertiajs/react';
import { useState } from 'react';
import StoreLayout from '@/layouts/store-layout';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
    AlertDialog,
    AlertDialogAction,
    AlertDialogCancel,
    AlertDialogContent,
    AlertDialogDescription,
    AlertDialogFooter,
    AlertDialogHeader,
    AlertDialogTitle,
    AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
    ShoppingCart,
    Minus,
    Plus,
    Trash2,
    ArrowRight,
    ShoppingBag,
    BookOpen
} from 'lucide-react';

interface Book {
    id: number;
    title: string;
    slug: string;
    author: string;
    price: number;
    formatted_price: string;
    cover_image: string | null;
    stock: number;
    category: {
        name: string;
        slug: string;
    };
}

interface CartItem {
    id: number;
    book: Book;
    quantity: number;
    subtotal: number;
}

interface Props {
    items: CartItem[];
    total: number;
    formattedTotal: string;
    itemCount: number;
}

export default function CartIndex({ items, total, formattedTotal, itemCount }: Props) {
    const [updatingItems, setUpdatingItems] = useState<Record<number, boolean>>({});

    const formatPrice = (price: number): string => {
        return 'Rp ' + price.toLocaleString('id-ID');
    };

    const updateQuantity = (bookId: number, quantity: number) => {
        setUpdatingItems(prev => ({ ...prev, [bookId]: true }));

        router.patch('/cart/update', {
            book_id: bookId,
            quantity: quantity,
        }, {
            preserveScroll: true,
            onFinish: () => {
                setUpdatingItems(prev => ({ ...prev, [bookId]: false }));
            },
        });
    };

    const removeItem = (bookId: number) => {
        setUpdatingItems(prev => ({ ...prev, [bookId]: true }));

        router.delete('/cart/remove', {
            data: { book_id: bookId },
            preserveScroll: true,
            onFinish: () => {
                setUpdatingItems(prev => ({ ...prev, [bookId]: false }));
            },
        });
    };

    const clearCart = () => {
        router.delete('/cart/clear', {
            preserveScroll: true,
        });
    };

    if (items.length === 0) {
        return (
            <StoreLayout>
                <Head title="Keranjang Belanja" />

                <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
                    <div className="text-center">
                        <ShoppingCart className="h-24 w-24 text-gray-300 mx-auto mb-6" />
                        <h1 className="text-2xl font-bold text-gray-900 mb-2">
                            Keranjang Kosong
                        </h1>
                        <p className="text-gray-500 mb-8">
                            Belum ada buku di keranjang belanja kamu
                        </p>
                        <Link href="/catalog">
                            <Button size="lg">
                                <ShoppingBag className="h-5 w-5 mr-2" />
                                Mulai Belanja
                            </Button>
                        </Link>
                    </div>
                </div>
            </StoreLayout>
        );
    }

    return (
        <StoreLayout>
            <Head title="Keranjang Belanja" />

            <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
                <div className="flex items-center justify-between mb-8">
                    <div>
                        <h1 className="text-2xl md:text-3xl font-bold text-gray-900">
                            Keranjang Belanja
                        </h1>
                        <p className="text-gray-500 mt-1">
                            {itemCount} item dalam keranjang
                        </p>
                    </div>

                    <AlertDialog>
                        <AlertDialogTrigger asChild>
                            <Button variant="outline" size="sm" className="text-red-600 hover:text-red-700 hover:bg-red-50">
                                <Trash2 className="h-4 w-4 mr-2" />
                                Kosongkan
                            </Button>
                        </AlertDialogTrigger>
                        <AlertDialogContent>
                            <AlertDialogHeader>
                                <AlertDialogTitle>Kosongkan Keranjang?</AlertDialogTitle>
                                <AlertDialogDescription>
                                    Semua buku di keranjang akan dihapus. Tindakan ini tidak bisa dibatalkan.
                                </AlertDialogDescription>
                            </AlertDialogHeader>
                            <AlertDialogFooter>
                                <AlertDialogCancel>Batal</AlertDialogCancel>
                                <AlertDialogAction
                                    onClick={clearCart}
                                    className="bg-red-600 hover:bg-red-700"
                                >
                                    Ya, Kosongkan
                                </AlertDialogAction>
                            </AlertDialogFooter>
                        </AlertDialogContent>
                    </AlertDialog>
                </div>

                <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
                    <div className="lg:col-span-2 space-y-4">
                        {items.map((item) => (
                            <Card key={item.id} className="overflow-hidden">
                                <CardContent className="p-0">
                                    <div className="flex gap-4 p-4">
                                        <Link href={`/books/${item.book.slug}`} className="flex-shrink-0">
                                            <div className="w-20 h-28 md:w-24 md:h-32 bg-gray-100 rounded-md overflow-hidden">
                                                {item.book.cover_image ? (
                                                    <img
                                                        src={`/storage/${item.book.cover_image}`}
                                                        alt={item.book.title}
                                                        className="w-full h-full object-cover"
                                                    />
                                                ) : (
                                                    <div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-100 to-purple-100">
                                                        <BookOpen className="h-8 w-8 text-blue-300" />
                                                    </div>
                                                )}
                                            </div>
                                        </Link>

                                        <div className="flex-1 min-w-0">
                                            <Link
                                                href={`/books/${item.book.slug}`}
                                                className="hover:text-blue-600 transition-colors"
                                            >
                                                <h3 className="font-semibold text-gray-900 line-clamp-2">
                                                    {item.book.title}
                                                </h3>
                                            </Link>
                                            <p className="text-sm text-gray-500 mt-1">
                                                {item.book.author}
                                            </p>
                                            <p className="text-sm text-blue-600 mt-1">
                                                {item.book.category.name}
                                            </p>

                                            <div className="mt-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
                                                <div className="flex items-center gap-3">
                                                    <div className="flex items-center border rounded-lg">
                                                        <Button
                                                            variant="ghost"
                                                            size="icon"
                                                            onClick={() => updateQuantity(item.book.id, item.quantity - 1)}
                                                            disabled={updatingItems[item.book.id] || item.quantity <= 1}
                                                            className="h-8 w-8 rounded-r-none"
                                                        >
                                                            <Minus className="h-3 w-3" />
                                                        </Button>
                                                        <span className="w-10 text-center text-sm font-medium">
                                                            {updatingItems[item.book.id] ? '...' : item.quantity}
                                                        </span>
                                                        <Button
                                                            variant="ghost"
                                                            size="icon"
                                                            onClick={() => updateQuantity(item.book.id, item.quantity + 1)}
                                                            disabled={updatingItems[item.book.id] || item.quantity >= item.book.stock}
                                                            className="h-8 w-8 rounded-l-none"
                                                        >
                                                            <Plus className="h-3 w-3" />
                                                        </Button>
                                                    </div>

                                                    <Button
                                                        variant="ghost"
                                                        size="sm"
                                                        onClick={() => removeItem(item.book.id)}
                                                        disabled={updatingItems[item.book.id]}
                                                        className="text-red-600 hover:text-red-700 hover:bg-red-50"
                                                    >
                                                        <Trash2 className="h-4 w-4" />
                                                    </Button>
                                                </div>

                                                <div className="text-right">
                                                    <p className="text-sm text-gray-500">
                                                        {item.book.formatted_price} × {item.quantity}
                                                    </p>
                                                    <p className="font-semibold text-gray-900">
                                                        {formatPrice(item.subtotal)}
                                                    </p>
                                                </div>
                                            </div>

                                            {item.quantity >= item.book.stock && (
                                                <p className="text-xs text-orange-600 mt-2">
                                                    Maksimum stok tercapai
                                                </p>
                                            )}
                                        </div>
                                    </div>
                                </CardContent>
                            </Card>
                        ))}
                    </div>

                    <div className="lg:col-span-1">
                        <Card className="sticky top-24">
                            <CardContent className="p-6">
                                <h2 className="text-lg font-semibold text-gray-900 mb-4">
                                    Ringkasan Belanja
                                </h2>

                                <div className="space-y-3">
                                    <div className="flex justify-between text-sm">
                                        <span className="text-gray-500">
                                            Total Item ({itemCount} buku)
                                        </span>
                                        <span className="text-gray-900">
                                            {formattedTotal}
                                        </span>
                                    </div>
                                    <div className="flex justify-between text-sm">
                                        <span className="text-gray-500">Ongkos Kirim</span>
                                        <span className="text-green-600">Gratis</span>
                                    </div>
                                </div>

                                <Separator className="my-4" />

                                <div className="flex justify-between mb-6">
                                    <span className="font-semibold text-gray-900">Total</span>
                                    <span className="text-xl font-bold text-gray-900">
                                        {formattedTotal}
                                    </span>
                                </div>

                                <Link href="/checkout" className="block">
                                    <Button size="lg" className="w-full">
                                        Lanjut ke Pembayaran
                                        <ArrowRight className="h-4 w-4 ml-2" />
                                    </Button>
                                </Link>

                                <Link href="/catalog" className="block mt-3">
                                    <Button variant="outline" className="w-full">
                                        Lanjut Belanja
                                    </Button>
                                </Link>
                            </CardContent>
                        </Card>
                    </div>
                </div>
            </div>
        </StoreLayout>
    );
}

Halaman cart punya beberapa bagian penting.

Empty State

Kalau cart kosong, tampilkan pesan yang ramah dengan CTA untuk mulai belanja. Ini lebih baik daripada halaman kosong tanpa petunjuk.

Item List

Setiap item menampilkan cover, judul, penulis, kategori, dan harga. Quantity selector inline memudahkan update tanpa modal. Tombol hapus ada di setiap item.

Updating State

const [updatingItems, setUpdatingItems] = useState<Record<number, boolean>>({});

Kita track item mana yang sedang di-update. Ini memungkinkan disable button hanya untuk item tersebut, bukan semua item.

Order Summary

Card sticky di sebelah kanan menampilkan ringkasan: total item, ongkir (gratis untuk simplicity), dan total. Tombol checkout mengarah ke halaman yang akan kita buat nanti.

Clear Cart Confirmation

Menggunakan AlertDialog untuk konfirmasi sebelum mengosongkan cart. Ini mencegah penghapusan tidak sengaja.

Menambahkan AlertDialog Component

npx shadcn@latest add alert-dialog

Atau buat manual di resources/js/components/ui/alert-dialog.tsx:

import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"

const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal

const AlertDialogOverlay = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
  <AlertDialogPrimitive.Overlay
    className={cn(
      "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
      className
    )}
    {...props}
    ref={ref}
  />
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName

const AlertDialogContent = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
  <AlertDialogPortal>
    <AlertDialogOverlay />
    <AlertDialogPrimitive.Content
      ref={ref}
      className={cn(
        "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
        className
      )}
      {...props}
    />
  </AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName

const AlertDialogHeader = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      "flex flex-col space-y-2 text-center sm:text-left",
      className
    )}
    {...props}
  />
)
AlertDialogHeader.displayName = "AlertDialogHeader"

const AlertDialogFooter = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
      className
    )}
    {...props}
  />
)
AlertDialogFooter.displayName = "AlertDialogFooter"

const AlertDialogTitle = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Title>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
  <AlertDialogPrimitive.Title
    ref={ref}
    className={cn("text-lg font-semibold", className)}
    {...props}
  />
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName

const AlertDialogDescription = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Description>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
  <AlertDialogPrimitive.Description
    ref={ref}
    className={cn("text-sm text-gray-500", className)}
    {...props}
  />
))
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName

const AlertDialogAction = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Action>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
  <AlertDialogPrimitive.Action
    ref={ref}
    className={cn(buttonVariants(), className)}
    {...props}
  />
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName

const AlertDialogCancel = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
  <AlertDialogPrimitive.Cancel
    ref={ref}
    className={cn(
      buttonVariants({ variant: "outline" }),
      "mt-2 sm:mt-0",
      className
    )}
    {...props}
  />
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName

export {
  AlertDialog,
  AlertDialogPortal,
  AlertDialogOverlay,
  AlertDialogTrigger,
  AlertDialogContent,
  AlertDialogHeader,
  AlertDialogFooter,
  AlertDialogTitle,
  AlertDialogDescription,
  AlertDialogAction,
  AlertDialogCancel,
}

Install dependency:

npm install @radix-ui/react-alert-dialog

Test Shopping Cart

Restart development server:

composer run dev

Test Add to Cart:

  1. Buka detail buku di /books/{slug}
  2. Pilih quantity
  3. Klik "Tambah ke Keranjang"
  4. Perhatikan badge cart di header bertambah
  5. Tombol berubah jadi "Ditambahkan ke Keranjang" sebentar

Test Cart Page:

  1. Klik icon cart di header atau buka /cart
  2. Lihat list item dengan cover, judul, harga
  3. Ubah quantity dengan tombol +/-
  4. Perhatikan subtotal berubah
  5. Perhatikan total di summary berubah

Test Update Quantity:

  1. Klik + untuk tambah quantity
  2. Quantity tidak bisa melebihi stok
  3. Klik - untuk kurangi
  4. Quantity tidak bisa kurang dari 1

Test Remove Item:

  1. Klik tombol trash pada item
  2. Item langsung hilang dari cart
  3. Total ter-update

Test Clear Cart:

  1. Klik "Kosongkan" di header cart
  2. Muncul dialog konfirmasi
  3. Klik "Ya, Kosongkan"
  4. Cart menjadi kosong
  5. Tampil empty state

Test Empty State:

  1. Kosongkan cart
  2. Lihat pesan "Keranjang Kosong"
  3. Ada tombol "Mulai Belanja"
  4. Klik tombol, redirect ke katalog

Test Multiple Items:

  1. Tambah beberapa buku berbeda ke cart
  2. Semua tampil di halaman cart
  3. Total terhitung dengan benar

Test Persistence:

  1. Tambah item ke cart
  2. Refresh halaman
  3. Item masih ada di cart (session persist)
  4. Tutup browser, buka lagi
  5. Item mungkin hilang (tergantung session lifetime)

Handling Flash Messages

Kita sudah setup flash messages di middleware, tapi belum menampilkannya. Untuk sekarang, flash messages bisa dilihat di React DevTools atau ditambahkan toast notification nanti di bagian 9.

Recap

Di bagian ini kita sudah membangun sistem shopping cart yang lengkap:

  • CartService untuk logic cart yang reusable
  • Session-based storage yang persist antar request
  • Validasi stok saat add dan update
  • Auto-remove item yang tidak aktif
  • CartController dengan semua operasi CRUD
  • Halaman cart dengan UI yang responsive
  • Quantity selector dengan validasi
  • Empty state yang helpful
  • Clear cart dengan konfirmasi
  • Order summary dengan total
  • Badge cart count di header yang selalu up-to-date

Di bagian selanjutnya, kita akan membuat halaman checkout dimana customer memasukkan alamat pengiriman dan menyelesaikan pesanan.

Lanjut ke Bagian 7: Checkout & Order →

Bagian 7: Checkout & Order

Customer sudah bisa browsing buku, melihat detail, dan menambahkan ke keranjang. Sekarang saatnya langkah terakhir dalam flow pembelian: checkout. Di halaman ini customer akan memasukkan alamat pengiriman, mereview pesanan, dan menyelesaikan transaksi.

Untuk tutorial ini, kita akan membuat checkout sederhana tanpa payment gateway. Fokusnya adalah pada flow data dari cart ke order. Integrasi payment gateway seperti Midtrans atau Xendit bisa ditambahkan nanti sebagai enhancement.

Membuat Checkout Controller

php artisan make:controller CheckoutController

Edit app/Http/Controllers/CheckoutController.php:

<?php

namespace App\\Http\\Controllers;

use App\\Models\\Order;
use App\\Models\\OrderItem;
use App\\Services\\CartService;
use Illuminate\\Http\\RedirectResponse;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\DB;
use Inertia\\Inertia;
use Inertia\\Response;

class CheckoutController extends Controller
{
    public function __construct(
        private CartService $cartService
    ) {}

    public function index(): Response|RedirectResponse
    {
        if ($this->cartService->isEmpty()) {
            return redirect()->route('cart.index')
                ->with('error', 'Keranjang belanja kosong');
        }

        $user = auth()->user();

        return Inertia::render('Checkout/Index', [
            'items' => $this->cartService->getItems(),
            'total' => $this->cartService->getTotal(),
            'formattedTotal' => $this->cartService->getFormattedTotal(),
            'itemCount' => $this->cartService->getItemCount(),
            'user' => $user ? [
                'name' => $user->name,
                'email' => $user->email,
            ] : null,
        ]);
    }

    public function store(Request $request): RedirectResponse
    {
        if ($this->cartService->isEmpty()) {
            return redirect()->route('cart.index')
                ->with('error', 'Keranjang belanja kosong');
        }

        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|max:255',
            'phone' => 'required|string|max:20',
            'address' => 'required|string|max:500',
            'city' => 'required|string|max:100',
            'postal_code' => 'required|string|max:10',
            'notes' => 'nullable|string|max:500',
        ], [
            'name.required' => 'Nama lengkap wajib diisi',
            'email.required' => 'Email wajib diisi',
            'email.email' => 'Format email tidak valid',
            'phone.required' => 'Nomor telepon wajib diisi',
            'address.required' => 'Alamat wajib diisi',
            'city.required' => 'Kota wajib diisi',
            'postal_code.required' => 'Kode pos wajib diisi',
        ]);

        $items = $this->cartService->getItems();
        $total = $this->cartService->getTotal();

        foreach ($items as $item) {
            if ($item['book']->stock < $item['quantity']) {
                return back()->with('error',
                    "Stok {$item['book']->title} tidak mencukupi. Tersedia: {$item['book']->stock}"
                );
            }
        }

        try {
            DB::beginTransaction();

            $shippingAddress = "{$validated['address']}, {$validated['city']} {$validated['postal_code']}";

            $order = Order::create([
                'user_id' => auth()->id(),
                'order_number' => Order::generateOrderNumber(),
                'total_amount' => $total,
                'status' => 'pending',
                'shipping_address' => $shippingAddress,
                'phone' => $validated['phone'],
                'notes' => $validated['notes'],
            ]);

            foreach ($items as $item) {
                OrderItem::create([
                    'order_id' => $order->id,
                    'book_id' => $item['book']->id,
                    'quantity' => $item['quantity'],
                    'price' => $item['book']->price,
                ]);

                $item['book']->decrement('stock', $item['quantity']);
            }

            $this->cartService->clear();

            DB::commit();

            return redirect()->route('orders.show', $order)
                ->with('success', 'Pesanan berhasil dibuat!');

        } catch (\\Exception $e) {
            DB::rollBack();

            return back()->with('error', 'Terjadi kesalahan. Silakan coba lagi.');
        }
    }
}

Ada beberapa hal penting di controller ini.

Method index() memeriksa apakah cart kosong. Kalau kosong, redirect ke halaman cart dengan pesan error. Kalau ada user yang login, data user dikirim ke form untuk pre-fill.

Method store() memproses checkout dengan beberapa tahap. Pertama validasi input dengan pesan error dalam Bahasa Indonesia. Kedua, cek ulang stok semua item sebelum membuat order. Ini penting karena stok bisa berubah antara waktu user buka halaman checkout dan submit.

Pembuatan order dibungkus dalam database transaction. Kalau ada error di tengah proses, semua perubahan di-rollback. Ini mencegah data inconsistency seperti order terbuat tapi stock tidak berkurang.

Setelah order dibuat, setiap item disimpan ke order_items dengan harga saat checkout. Stock buku dikurangi sesuai quantity. Cart dikosongkan. User di-redirect ke halaman detail order.

Membuat Order Controller

Kita juga butuh controller untuk melihat order yang sudah dibuat.

php artisan make:controller OrderController

Edit app/Http/Controllers/OrderController.php:

<?php

namespace App\\Http\\Controllers;

use App\\Models\\Order;
use Illuminate\\Http\\Request;
use Inertia\\Inertia;
use Inertia\\Response;

class OrderController extends Controller
{
    public function index(): Response
    {
        $orders = Order::where('user_id', auth()->id())
            ->with(['items.book'])
            ->latest()
            ->paginate(10);

        return Inertia::render('Orders/Index', [
            'orders' => $orders,
        ]);
    }

    public function show(Order $order): Response
    {
        if ($order->user_id !== auth()->id()) {
            abort(403);
        }

        $order->load(['items.book.category']);

        return Inertia::render('Orders/Show', [
            'order' => $order,
        ]);
    }
}

Controller ini punya dua method. index() menampilkan semua order milik user yang login dengan pagination. show() menampilkan detail satu order dengan pengecekan kepemilikan.

Mendaftarkan Routes

Update routes/web.php:

<?php

use App\\Http\\Controllers\\HomeController;
use App\\Http\\Controllers\\CatalogController;
use App\\Http\\Controllers\\BookController;
use App\\Http\\Controllers\\CartController;
use App\\Http\\Controllers\\CheckoutController;
use App\\Http\\Controllers\\OrderController;
use Illuminate\\Support\\Facades\\Route;

Route::get('/', [HomeController::class, 'index'])->name('home');
Route::get('/catalog', [CatalogController::class, 'index'])->name('catalog');
Route::get('/books/{slug}', [BookController::class, 'show'])->name('books.show');

Route::get('/cart', [CartController::class, 'index'])->name('cart.index');
Route::post('/cart/add', [CartController::class, 'add'])->name('cart.add');
Route::patch('/cart/update', [CartController::class, 'update'])->name('cart.update');
Route::delete('/cart/remove', [CartController::class, 'remove'])->name('cart.remove');
Route::delete('/cart/clear', [CartController::class, 'clear'])->name('cart.clear');

Route::middleware('auth')->group(function () {
    Route::get('/checkout', [CheckoutController::class, 'index'])->name('checkout.index');
    Route::post('/checkout', [CheckoutController::class, 'store'])->name('checkout.store');

    Route::get('/orders', [OrderController::class, 'index'])->name('orders.index');
    Route::get('/orders/{order}', [OrderController::class, 'show'])->name('orders.show');
});

require __DIR__.'/auth.php';

Route checkout dan orders dibungkus dalam middleware auth. User harus login untuk checkout dan melihat pesanan.

Membuat Halaman Checkout

Buat folder dan file resources/js/pages/Checkout/Index.tsx:

import { Head, Link, useForm } from '@inertiajs/react';
import StoreLayout from '@/layouts/store-layout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
    ArrowLeft,
    ShoppingBag,
    MapPin,
    User,
    Mail,
    Phone,
    FileText,
    BookOpen,
    Loader2
} from 'lucide-react';

interface Book {
    id: number;
    title: string;
    slug: string;
    author: string;
    price: number;
    formatted_price: string;
    cover_image: string | null;
}

interface CartItem {
    id: number;
    book: Book;
    quantity: number;
    subtotal: number;
}

interface Props {
    items: CartItem[];
    total: number;
    formattedTotal: string;
    itemCount: number;
    user: {
        name: string;
        email: string;
    } | null;
}

export default function CheckoutIndex({ items, total, formattedTotal, itemCount, user }: Props) {
    const { data, setData, post, processing, errors } = useForm({
        name: user?.name || '',
        email: user?.email || '',
        phone: '',
        address: '',
        city: '',
        postal_code: '',
        notes: '',
    });

    const formatPrice = (price: number): string => {
        return 'Rp ' + price.toLocaleString('id-ID');
    };

    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        post('/checkout');
    };

    return (
        <StoreLayout>
            <Head title="Checkout" />

            <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
                <Link
                    href="/cart"
                    className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900 mb-6"
                >
                    <ArrowLeft className="h-4 w-4 mr-1" />
                    Kembali ke Keranjang
                </Link>

                <h1 className="text-2xl md:text-3xl font-bold text-gray-900 mb-8">
                    Checkout
                </h1>

                <form onSubmit={handleSubmit}>
                    <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
                        <div className="lg:col-span-2 space-y-6">
                            <Card>
                                <CardHeader>
                                    <CardTitle className="flex items-center gap-2">
                                        <User className="h-5 w-5" />
                                        Informasi Penerima
                                    </CardTitle>
                                </CardHeader>
                                <CardContent className="space-y-4">
                                    <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
                                        <div className="space-y-2">
                                            <Label htmlFor="name">
                                                Nama Lengkap <span className="text-red-500">*</span>
                                            </Label>
                                            <div className="relative">
                                                <User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
                                                <Input
                                                    id="name"
                                                    type="text"
                                                    placeholder="John Doe"
                                                    value={data.name}
                                                    onChange={(e) => setData('name', e.target.value)}
                                                    className={`pl-10 ${errors.name ? 'border-red-500' : ''}`}
                                                />
                                            </div>
                                            {errors.name && (
                                                <p className="text-sm text-red-500">{errors.name}</p>
                                            )}
                                        </div>

                                        <div className="space-y-2">
                                            <Label htmlFor="email">
                                                Email <span className="text-red-500">*</span>
                                            </Label>
                                            <div className="relative">
                                                <Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
                                                <Input
                                                    id="email"
                                                    type="email"
                                                    placeholder="[email protected]"
                                                    value={data.email}
                                                    onChange={(e) => setData('email', e.target.value)}
                                                    className={`pl-10 ${errors.email ? 'border-red-500' : ''}`}
                                                />
                                            </div>
                                            {errors.email && (
                                                <p className="text-sm text-red-500">{errors.email}</p>
                                            )}
                                        </div>
                                    </div>

                                    <div className="space-y-2">
                                        <Label htmlFor="phone">
                                            Nomor Telepon <span className="text-red-500">*</span>
                                        </Label>
                                        <div className="relative">
                                            <Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
                                            <Input
                                                id="phone"
                                                type="tel"
                                                placeholder="08123456789"
                                                value={data.phone}
                                                onChange={(e) => setData('phone', e.target.value)}
                                                className={`pl-10 ${errors.phone ? 'border-red-500' : ''}`}
                                            />
                                        </div>
                                        {errors.phone && (
                                            <p className="text-sm text-red-500">{errors.phone}</p>
                                        )}
                                    </div>
                                </CardContent>
                            </Card>

                            <Card>
                                <CardHeader>
                                    <CardTitle className="flex items-center gap-2">
                                        <MapPin className="h-5 w-5" />
                                        Alamat Pengiriman
                                    </CardTitle>
                                </CardHeader>
                                <CardContent className="space-y-4">
                                    <div className="space-y-2">
                                        <Label htmlFor="address">
                                            Alamat Lengkap <span className="text-red-500">*</span>
                                        </Label>
                                        <Textarea
                                            id="address"
                                            placeholder="Jl. Contoh No. 123, RT 01/RW 02, Kelurahan, Kecamatan"
                                            value={data.address}
                                            onChange={(e) => setData('address', e.target.value)}
                                            className={errors.address ? 'border-red-500' : ''}
                                            rows={3}
                                        />
                                        {errors.address && (
                                            <p className="text-sm text-red-500">{errors.address}</p>
                                        )}
                                    </div>

                                    <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
                                        <div className="space-y-2">
                                            <Label htmlFor="city">
                                                Kota <span className="text-red-500">*</span>
                                            </Label>
                                            <Input
                                                id="city"
                                                type="text"
                                                placeholder="Jakarta Selatan"
                                                value={data.city}
                                                onChange={(e) => setData('city', e.target.value)}
                                                className={errors.city ? 'border-red-500' : ''}
                                            />
                                            {errors.city && (
                                                <p className="text-sm text-red-500">{errors.city}</p>
                                            )}
                                        </div>

                                        <div className="space-y-2">
                                            <Label htmlFor="postal_code">
                                                Kode Pos <span className="text-red-500">*</span>
                                            </Label>
                                            <Input
                                                id="postal_code"
                                                type="text"
                                                placeholder="12345"
                                                value={data.postal_code}
                                                onChange={(e) => setData('postal_code', e.target.value)}
                                                className={errors.postal_code ? 'border-red-500' : ''}
                                            />
                                            {errors.postal_code && (
                                                <p className="text-sm text-red-500">{errors.postal_code}</p>
                                            )}
                                        </div>
                                    </div>
                                </CardContent>
                            </Card>

                            <Card>
                                <CardHeader>
                                    <CardTitle className="flex items-center gap-2">
                                        <FileText className="h-5 w-5" />
                                        Catatan (Opsional)
                                    </CardTitle>
                                </CardHeader>
                                <CardContent>
                                    <Textarea
                                        id="notes"
                                        placeholder="Catatan tambahan untuk pesanan Anda..."
                                        value={data.notes}
                                        onChange={(e) => setData('notes', e.target.value)}
                                        rows={3}
                                    />
                                </CardContent>
                            </Card>
                        </div>

                        <div className="lg:col-span-1">
                            <Card className="sticky top-24">
                                <CardHeader>
                                    <CardTitle className="flex items-center gap-2">
                                        <ShoppingBag className="h-5 w-5" />
                                        Ringkasan Pesanan
                                    </CardTitle>
                                </CardHeader>
                                <CardContent className="space-y-4">
                                    <div className="space-y-3 max-h-64 overflow-y-auto">
                                        {items.map((item) => (
                                            <div key={item.id} className="flex gap-3">
                                                <div className="w-12 h-16 bg-gray-100 rounded overflow-hidden flex-shrink-0">
                                                    {item.book.cover_image ? (
                                                        <img
                                                            src={`/storage/${item.book.cover_image}`}
                                                            alt={item.book.title}
                                                            className="w-full h-full object-cover"
                                                        />
                                                    ) : (
                                                        <div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-100 to-purple-100">
                                                            <BookOpen className="h-4 w-4 text-blue-300" />
                                                        </div>
                                                    )}
                                                </div>
                                                <div className="flex-1 min-w-0">
                                                    <p className="text-sm font-medium text-gray-900 line-clamp-1">
                                                        {item.book.title}
                                                    </p>
                                                    <p className="text-xs text-gray-500">
                                                        {item.quantity} × {item.book.formatted_price}
                                                    </p>
                                                    <p className="text-sm font-medium text-gray-900">
                                                        {formatPrice(item.subtotal)}
                                                    </p>
                                                </div>
                                            </div>
                                        ))}
                                    </div>

                                    <Separator />

                                    <div className="space-y-2">
                                        <div className="flex justify-between text-sm">
                                            <span className="text-gray-500">Subtotal ({itemCount} buku)</span>
                                            <span>{formattedTotal}</span>
                                        </div>
                                        <div className="flex justify-between text-sm">
                                            <span className="text-gray-500">Ongkos Kirim</span>
                                            <span className="text-green-600">Gratis</span>
                                        </div>
                                    </div>

                                    <Separator />

                                    <div className="flex justify-between">
                                        <span className="font-semibold">Total</span>
                                        <span className="text-xl font-bold text-gray-900">
                                            {formattedTotal}
                                        </span>
                                    </div>

                                    <Button
                                        type="submit"
                                        size="lg"
                                        className="w-full"
                                        disabled={processing}
                                    >
                                        {processing ? (
                                            <>
                                                <Loader2 className="h-4 w-4 mr-2 animate-spin" />
                                                Memproses...
                                            </>
                                        ) : (
                                            'Buat Pesanan'
                                        )}
                                    </Button>

                                    <p className="text-xs text-gray-500 text-center">
                                        Dengan melakukan pemesanan, Anda menyetujui syarat dan ketentuan yang berlaku.
                                    </p>
                                </CardContent>
                            </Card>
                        </div>
                    </div>
                </form>
            </div>
        </StoreLayout>
    );
}

Halaman checkout menggunakan useForm hook dari Inertia untuk form handling. Hook ini menyediakan data, setData, post, processing, dan errors.

Form dibagi menjadi tiga card: informasi penerima, alamat pengiriman, dan catatan. Di sebelah kanan ada ringkasan pesanan yang sticky saat scroll.

Setiap input punya error display yang muncul kalau validasi gagal. Error message dari Laravel otomatis tersedia di errors object.

Tombol submit ter-disable dan menampilkan loading spinner saat processing. Ini mencegah double submit.

Membuat Halaman Order Detail

Buat folder dan file resources/js/pages/Orders/Show.tsx:

import { Head, Link } from '@inertiajs/react';
import StoreLayout from '@/layouts/store-layout';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
    ArrowLeft,
    Package,
    MapPin,
    Phone,
    Calendar,
    BookOpen,
    CheckCircle2,
    Clock,
    Truck,
    XCircle
} from 'lucide-react';

interface Book {
    id: number;
    title: string;
    slug: string;
    author: string;
    cover_image: string | null;
    category: {
        name: string;
    };
}

interface OrderItem {
    id: number;
    book: Book;
    quantity: number;
    price: number;
}

interface Order {
    id: number;
    order_number: string;
    total_amount: number;
    status: 'pending' | 'processing' | 'shipped' | 'completed' | 'cancelled';
    shipping_address: string;
    phone: string;
    notes: string | null;
    created_at: string;
    items: OrderItem[];
}

interface Props {
    order: Order;
}

const statusConfig = {
    pending: {
        label: 'Menunggu Pembayaran',
        color: 'bg-yellow-100 text-yellow-800',
        icon: Clock,
    },
    processing: {
        label: 'Diproses',
        color: 'bg-blue-100 text-blue-800',
        icon: Package,
    },
    shipped: {
        label: 'Dikirim',
        color: 'bg-purple-100 text-purple-800',
        icon: Truck,
    },
    completed: {
        label: 'Selesai',
        color: 'bg-green-100 text-green-800',
        icon: CheckCircle2,
    },
    cancelled: {
        label: 'Dibatalkan',
        color: 'bg-red-100 text-red-800',
        icon: XCircle,
    },
};

export default function OrderShow({ order }: Props) {
    const formatPrice = (price: number): string => {
        return 'Rp ' + price.toLocaleString('id-ID');
    };

    const formatDate = (dateString: string): string => {
        return new Date(dateString).toLocaleDateString('id-ID', {
            weekday: 'long',
            year: 'numeric',
            month: 'long',
            day: 'numeric',
            hour: '2-digit',
            minute: '2-digit',
        });
    };

    const status = statusConfig[order.status];
    const StatusIcon = status.icon;

    return (
        <StoreLayout>
            <Head title={`Pesanan ${order.order_number}`} />

            <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
                <Link
                    href="/orders"
                    className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900 mb-6"
                >
                    <ArrowLeft className="h-4 w-4 mr-1" />
                    Kembali ke Daftar Pesanan
                </Link>

                <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
                    <div>
                        <h1 className="text-2xl font-bold text-gray-900">
                            {order.order_number}
                        </h1>
                        <p className="text-gray-500 flex items-center gap-1 mt-1">
                            <Calendar className="h-4 w-4" />
                            {formatDate(order.created_at)}
                        </p>
                    </div>
                    <Badge className={`${status.color} flex items-center gap-1 px-3 py-1`}>
                        <StatusIcon className="h-4 w-4" />
                        {status.label}
                    </Badge>
                </div>

                <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
                    <Card>
                        <CardHeader className="pb-3">
                            <CardTitle className="text-base flex items-center gap-2">
                                <MapPin className="h-4 w-4" />
                                Alamat Pengiriman
                            </CardTitle>
                        </CardHeader>
                        <CardContent>
                            <p className="text-gray-600">{order.shipping_address}</p>
                        </CardContent>
                    </Card>

                    <Card>
                        <CardHeader className="pb-3">
                            <CardTitle className="text-base flex items-center gap-2">
                                <Phone className="h-4 w-4" />
                                Kontak
                            </CardTitle>
                        </CardHeader>
                        <CardContent>
                            <p className="text-gray-600">{order.phone}</p>
                        </CardContent>
                    </Card>
                </div>

                {order.notes && (
                    <Card className="mb-8">
                        <CardHeader className="pb-3">
                            <CardTitle className="text-base">Catatan</CardTitle>
                        </CardHeader>
                        <CardContent>
                            <p className="text-gray-600">{order.notes}</p>
                        </CardContent>
                    </Card>
                )}

                <Card>
                    <CardHeader>
                        <CardTitle className="flex items-center gap-2">
                            <Package className="h-5 w-5" />
                            Detail Pesanan
                        </CardTitle>
                    </CardHeader>
                    <CardContent>
                        <div className="space-y-4">
                            {order.items.map((item) => (
                                <div key={item.id} className="flex gap-4">
                                    <Link href={`/books/${item.book.slug}`} className="flex-shrink-0">
                                        <div className="w-16 h-20 bg-gray-100 rounded overflow-hidden">
                                            {item.book.cover_image ? (
                                                <img
                                                    src={`/storage/${item.book.cover_image}`}
                                                    alt={item.book.title}
                                                    className="w-full h-full object-cover"
                                                />
                                            ) : (
                                                <div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-100 to-purple-100">
                                                    <BookOpen className="h-6 w-6 text-blue-300" />
                                                </div>
                                            )}
                                        </div>
                                    </Link>
                                    <div className="flex-1">
                                        <Link
                                            href={`/books/${item.book.slug}`}
                                            className="font-medium text-gray-900 hover:text-blue-600"
                                        >
                                            {item.book.title}
                                        </Link>
                                        <p className="text-sm text-gray-500">{item.book.author}</p>
                                        <p className="text-sm text-blue-600">{item.book.category.name}</p>
                                    </div>
                                    <div className="text-right">
                                        <p className="text-sm text-gray-500">
                                            {item.quantity} × {formatPrice(item.price)}
                                        </p>
                                        <p className="font-medium text-gray-900">
                                            {formatPrice(item.quantity * item.price)}
                                        </p>
                                    </div>
                                </div>
                            ))}
                        </div>

                        <Separator className="my-6" />

                        <div className="space-y-2">
                            <div className="flex justify-between text-sm">
                                <span className="text-gray-500">Subtotal</span>
                                <span>{formatPrice(order.total_amount)}</span>
                            </div>
                            <div className="flex justify-between text-sm">
                                <span className="text-gray-500">Ongkos Kirim</span>
                                <span className="text-green-600">Gratis</span>
                            </div>
                            <Separator className="my-2" />
                            <div className="flex justify-between">
                                <span className="font-semibold">Total</span>
                                <span className="text-xl font-bold text-gray-900">
                                    {formatPrice(order.total_amount)}
                                </span>
                            </div>
                        </div>
                    </CardContent>
                </Card>

                <div className="mt-8 flex flex-col sm:flex-row gap-4">
                    <Link href="/catalog" className="flex-1">
                        <Button variant="outline" className="w-full">
                            Lanjut Belanja
                        </Button>
                    </Link>
                    <Link href="/orders" className="flex-1">
                        <Button className="w-full">
                            Lihat Semua Pesanan
                        </Button>
                    </Link>
                </div>
            </div>
        </StoreLayout>
    );
}

Halaman ini menampilkan detail lengkap pesanan: nomor order, status dengan badge berwarna, tanggal, alamat, kontak, catatan, dan daftar item yang dibeli.

Status menggunakan config object untuk mapping status ke label Indonesia, warna badge, dan icon. Ini membuat kode lebih maintainable.

Membuat Halaman Daftar Order

Buat file resources/js/pages/Orders/Index.tsx:

import { Head, Link } from '@inertiajs/react';
import StoreLayout from '@/layouts/store-layout';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Pagination } from '@/components/pagination';
import {
    Package,
    ShoppingBag,
    Calendar,
    ChevronRight,
    Clock,
    Truck,
    CheckCircle2,
    XCircle
} from 'lucide-react';

interface OrderItem {
    id: number;
    quantity: number;
    book: {
        title: string;
        cover_image: string | null;
    };
}

interface Order {
    id: number;
    order_number: string;
    total_amount: number;
    status: 'pending' | 'processing' | 'shipped' | 'completed' | 'cancelled';
    created_at: string;
    items: OrderItem[];
}

interface PaginationLink {
    url: string | null;
    label: string;
    active: boolean;
}

interface PaginatedOrders {
    data: Order[];
    links: PaginationLink[];
    current_page: number;
    last_page: number;
    total: number;
}

interface Props {
    orders: PaginatedOrders;
}

const statusConfig = {
    pending: {
        label: 'Menunggu',
        color: 'bg-yellow-100 text-yellow-800',
        icon: Clock,
    },
    processing: {
        label: 'Diproses',
        color: 'bg-blue-100 text-blue-800',
        icon: Package,
    },
    shipped: {
        label: 'Dikirim',
        color: 'bg-purple-100 text-purple-800',
        icon: Truck,
    },
    completed: {
        label: 'Selesai',
        color: 'bg-green-100 text-green-800',
        icon: CheckCircle2,
    },
    cancelled: {
        label: 'Dibatalkan',
        color: 'bg-red-100 text-red-800',
        icon: XCircle,
    },
};

export default function OrdersIndex({ orders }: Props) {
    const formatPrice = (price: number): string => {
        return 'Rp ' + price.toLocaleString('id-ID');
    };

    const formatDate = (dateString: string): string => {
        return new Date(dateString).toLocaleDateString('id-ID', {
            day: 'numeric',
            month: 'short',
            year: 'numeric',
        });
    };

    if (orders.data.length === 0) {
        return (
            <StoreLayout>
                <Head title="Pesanan Saya" />

                <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
                    <div className="text-center">
                        <Package className="h-24 w-24 text-gray-300 mx-auto mb-6" />
                        <h1 className="text-2xl font-bold text-gray-900 mb-2">
                            Belum Ada Pesanan
                        </h1>
                        <p className="text-gray-500 mb-8">
                            Anda belum pernah melakukan pemesanan
                        </p>
                        <Link href="/catalog">
                            <Button size="lg">
                                <ShoppingBag className="h-5 w-5 mr-2" />
                                Mulai Belanja
                            </Button>
                        </Link>
                    </div>
                </div>
            </StoreLayout>
        );
    }

    return (
        <StoreLayout>
            <Head title="Pesanan Saya" />

            <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
                <h1 className="text-2xl md:text-3xl font-bold text-gray-900 mb-2">
                    Pesanan Saya
                </h1>
                <p className="text-gray-500 mb-8">
                    {orders.total} pesanan
                </p>

                <div className="space-y-4">
                    {orders.data.map((order) => {
                        const status = statusConfig[order.status];
                        const StatusIcon = status.icon;
                        const itemCount = order.items.reduce((sum, item) => sum + item.quantity, 0);

                        return (
                            <Link key={order.id} href={`/orders/${order.id}`}>
                                <Card className="hover:shadow-md transition-shadow cursor-pointer">
                                    <CardContent className="p-4 sm:p-6">
                                        <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
                                            <div className="flex-1">
                                                <div className="flex items-center gap-3 mb-2">
                                                    <span className="font-semibold text-gray-900">
                                                        {order.order_number}
                                                    </span>
                                                    <Badge className={`${status.color} flex items-center gap-1`}>
                                                        <StatusIcon className="h-3 w-3" />
                                                        {status.label}
                                                    </Badge>
                                                </div>

                                                <div className="flex items-center gap-4 text-sm text-gray-500">
                                                    <span className="flex items-center gap-1">
                                                        <Calendar className="h-4 w-4" />
                                                        {formatDate(order.created_at)}
                                                    </span>
                                                    <span>
                                                        {itemCount} buku
                                                    </span>
                                                </div>
                                            </div>

                                            <div className="flex items-center gap-4">
                                                <div className="text-right">
                                                    <p className="text-sm text-gray-500">Total</p>
                                                    <p className="font-semibold text-gray-900">
                                                        {formatPrice(order.total_amount)}
                                                    </p>
                                                </div>
                                                <ChevronRight className="h-5 w-5 text-gray-400" />
                                            </div>
                                        </div>
                                    </CardContent>
                                </Card>
                            </Link>
                        );
                    })}
                </div>

                <Pagination
                    links={orders.links}
                    currentPage={orders.current_page}
                    lastPage={orders.last_page}
                />
            </div>
        </StoreLayout>
    );
}

Menambahkan Textarea Component

Pastikan komponen Textarea ada:

npx shadcn@latest add textarea

Atau buat manual di resources/js/components/ui/textarea.tsx:

import * as React from "react"
import { cn } from "@/lib/utils"

export interface TextareaProps
  extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}

const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
  ({ className, ...props }, ref) => {
    return (
      <textarea
        className={cn(
          "flex min-h-[80px] w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
          className
        )}
        ref={ref}
        {...props}
      />
    )
  }
)
Textarea.displayName = "Textarea"

export { Textarea }

Test Checkout Flow

Restart development server dan test seluruh flow:

Test Redirect ke Login:

  1. Logout jika sudah login
  2. Tambah buku ke cart
  3. Buka /checkout
  4. Akan redirect ke login karena butuh auth

Test Checkout Form:

  1. Login dengan [email protected] (password: password)
  2. Tambah beberapa buku ke cart
  3. Klik "Lanjut ke Pembayaran" di cart
  4. Form checkout tampil dengan nama dan email ter-prefill
  5. Isi field yang kosong
  6. Klik "Buat Pesanan"

Test Validation:

  1. Kosongkan beberapa field required
  2. Klik "Buat Pesanan"
  3. Error message muncul di bawah field yang kosong

Test Order Success:

  1. Isi semua field dengan benar
  2. Klik "Buat Pesanan"
  3. Redirect ke halaman detail order
  4. Lihat semua informasi pesanan
  5. Cart badge di header menjadi 0

Test Order History:

  1. Klik menu "Pesanan Saya" di dropdown user
  2. Lihat daftar pesanan dengan status
  3. Klik salah satu order untuk detail

Test Stock Reduction:

  1. Catat stok buku sebelum checkout
  2. Checkout buku tersebut
  3. Lihat detail buku, stok harusnya berkurang

Recap

Di bagian ini kita sudah membangun sistem checkout dan order yang lengkap:

  • CheckoutController untuk form checkout dan create order
  • OrderController untuk list dan detail order
  • Database transaction untuk data consistency
  • Stock validation sebelum checkout
  • Stock reduction setelah checkout
  • Form dengan validation dan error display
  • Pre-fill form dengan data user
  • Order confirmation page
  • Order history dengan pagination
  • Status badge dengan warna dan icon

Di bagian selanjutnya, kita akan membangun admin dashboard untuk mengelola buku termasuk CRUD dengan image upload.

Lanjut ke Bagian 8: Admin Dashboard & Book CRUD →

Bagian 8: Admin Dashboard & Book CRUD

Sampai sekarang kita sudah membangun semua fitur untuk customer. Sekarang saatnya membangun sisi admin untuk mengelola buku. Admin perlu bisa melihat dashboard dengan statistik, menambah buku baru, mengedit buku existing, dan menghapus buku yang tidak diperlukan.

Untuk membedakan admin dari user biasa, kita akan menambahkan kolom is_admin di tabel users dan membuat middleware untuk proteksi route admin.

Menambahkan Kolom is_admin

Buat migration baru:

php artisan make:migration add_is_admin_to_users_table

Edit migration:

<?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->boolean('is_admin')->default(false)->after('email');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('is_admin');
        });
    }
};

Jalankan migration lalu update user admin:

php artisan migrate
php artisan tinker
>>> App\\Models\\User::where('email', '[email protected]')->update(['is_admin' => true])

Membuat Admin Middleware

php artisan make:middleware AdminMiddleware

Edit app/Http/Middleware/AdminMiddleware.php:

<?php

namespace App\\Http\\Middleware;

use Closure;
use Illuminate\\Http\\Request;
use Symfony\\Component\\HttpFoundation\\Response;

class AdminMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        if (!auth()->check() || !auth()->user()->is_admin) {
            return redirect('/')->with('error', 'Akses ditolak');
        }

        return $next($request);
    }
}

Daftarkan di bootstrap/app.php dengan menambahkan alias:

$middleware->alias([
    'admin' => \\App\\Http\\Middleware\\AdminMiddleware::class,
]);

Membuat Admin Controllers

Buat dua controller untuk admin:

php artisan make:controller Admin/DashboardController
php artisan make:controller Admin/BookController

Edit app/Http/Controllers/Admin/DashboardController.php:

<?php

namespace App\\Http\\Controllers\\Admin;

use App\\Http\\Controllers\\Controller;
use App\\Models\\Book;
use App\\Models\\Category;
use App\\Models\\Order;
use App\\Models\\User;
use Inertia\\Inertia;
use Inertia\\Response;

class DashboardController extends Controller
{
    public function index(): Response
    {
        $stats = [
            'total_books' => Book::count(),
            'active_books' => Book::active()->count(),
            'total_categories' => Category::count(),
            'total_orders' => Order::count(),
            'pending_orders' => Order::where('status', 'pending')->count(),
            'total_users' => User::where('is_admin', false)->count(),
            'total_revenue' => Order::where('status', 'completed')->sum('total_amount'),
        ];

        $recentOrders = Order::with('user')->latest()->take(5)->get();

        $lowStockBooks = Book::active()
            ->where('stock', '<=', 5)
            ->orderBy('stock')
            ->take(5)
            ->get();

        return Inertia::render('Admin/Dashboard', [
            'stats' => $stats,
            'recentOrders' => $recentOrders,
            'lowStockBooks' => $lowStockBooks,
        ]);
    }
}

Edit app/Http/Controllers/Admin/BookController.php:

<?php

namespace App\\Http\\Controllers\\Admin;

use App\\Http\\Controllers\\Controller;
use App\\Models\\Book;
use App\\Models\\Category;
use Illuminate\\Http\\RedirectResponse;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Storage;
use Illuminate\\Support\\Str;
use Inertia\\Inertia;
use Inertia\\Response;

class BookController extends Controller
{
    public function index(Request $request): Response
    {
        $query = Book::with('category');

        if ($search = $request->input('search')) {
            $query->where(function ($q) use ($search) {
                $q->where('title', 'like', "%{$search}%")
                  ->orWhere('author', 'like', "%{$search}%");
            });
        }

        if ($category = $request->input('category')) {
            $query->where('category_id', $category);
        }

        if ($request->has('active')) {
            $query->where('is_active', $request->boolean('active'));
        }

        $books = $query->latest()->paginate(15)->withQueryString();
        $categories = Category::orderBy('name')->get();

        return Inertia::render('Admin/Books/Index', [
            'books' => $books,
            'categories' => $categories,
            'filters' => $request->only(['search', 'category', 'active']),
        ]);
    }

    public function create(): Response
    {
        return Inertia::render('Admin/Books/Create', [
            'categories' => Category::orderBy('name')->get(),
        ]);
    }

    public function store(Request $request): RedirectResponse
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'category_id' => 'required|exists:categories,id',
            'author' => 'required|string|max:255',
            'description' => 'required|string',
            'price' => 'required|numeric|min:0',
            'stock' => 'required|integer|min:0',
            'year_published' => 'nullable|integer|min:1900|max:' . (date('Y') + 1),
            'isbn' => 'nullable|string|max:20',
            'pages' => 'nullable|integer|min:1',
            'is_featured' => 'boolean',
            'is_active' => 'boolean',
            'cover_image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:2048',
        ]);

        $validated['slug'] = Str::slug($validated['title']) . '-' . Str::random(5);
        $validated['is_featured'] = $request->boolean('is_featured');
        $validated['is_active'] = $request->boolean('is_active', true);

        if ($request->hasFile('cover_image')) {
            $validated['cover_image'] = $request->file('cover_image')->store('covers', 'public');
        }

        Book::create($validated);

        return redirect()->route('admin.books.index')->with('success', 'Buku berhasil ditambahkan');
    }

    public function edit(Book $book): Response
    {
        return Inertia::render('Admin/Books/Edit', [
            'book' => $book,
            'categories' => Category::orderBy('name')->get(),
        ]);
    }

    public function update(Request $request, Book $book): RedirectResponse
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'category_id' => 'required|exists:categories,id',
            'author' => 'required|string|max:255',
            'description' => 'required|string',
            'price' => 'required|numeric|min:0',
            'stock' => 'required|integer|min:0',
            'year_published' => 'nullable|integer|min:1900|max:' . (date('Y') + 1),
            'isbn' => 'nullable|string|max:20',
            'pages' => 'nullable|integer|min:1',
            'is_featured' => 'boolean',
            'is_active' => 'boolean',
            'cover_image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:2048',
        ]);

        $validated['is_featured'] = $request->boolean('is_featured');
        $validated['is_active'] = $request->boolean('is_active');

        if ($request->hasFile('cover_image')) {
            if ($book->cover_image) {
                Storage::disk('public')->delete($book->cover_image);
            }
            $validated['cover_image'] = $request->file('cover_image')->store('covers', 'public');
        }

        $book->update($validated);

        return redirect()->route('admin.books.index')->with('success', 'Buku berhasil diperbarui');
    }

    public function destroy(Book $book): RedirectResponse
    {
        if ($book->cover_image) {
            Storage::disk('public')->delete($book->cover_image);
        }

        $book->delete();

        return redirect()->route('admin.books.index')->with('success', 'Buku berhasil dihapus');
    }
}

Mendaftarkan Routes Admin

Tambahkan di routes/web.php:

use App\\Http\\Controllers\\Admin\\DashboardController as AdminDashboardController;
use App\\Http\\Controllers\\Admin\\BookController as AdminBookController;

Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () {
    Route::get('/', [AdminDashboardController::class, 'index'])->name('dashboard');

    Route::get('/books', [AdminBookController::class, 'index'])->name('books.index');
    Route::get('/books/create', [AdminBookController::class, 'create'])->name('books.create');
    Route::post('/books', [AdminBookController::class, 'store'])->name('books.store');
    Route::get('/books/{book}/edit', [AdminBookController::class, 'edit'])->name('books.edit');
    Route::put('/books/{book}', [AdminBookController::class, 'update'])->name('books.update');
    Route::delete('/books/{book}', [AdminBookController::class, 'destroy'])->name('books.destroy');
});

Jalankan juga storage link untuk akses file upload:

php artisan storage:link

Membuat Layout Admin

Buat file resources/js/layouts/admin-layout.tsx:

import { Link, usePage } from '@inertiajs/react';
import { PropsWithChildren, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
    DropdownMenu,
    DropdownMenuContent,
    DropdownMenuItem,
    DropdownMenuSeparator,
    DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { LayoutDashboard, BookOpen, Menu, X, LogOut, Store, ChevronDown } from 'lucide-react';

export default function AdminLayout({ children }: PropsWithChildren) {
    const { auth } = usePage<{ auth: { user: { name: string } } }>().props;
    const [sidebarOpen, setSidebarOpen] = useState(false);
    const currentPath = window.location.pathname;

    const navigation = [
        { name: 'Dashboard', href: '/admin', icon: LayoutDashboard },
        { name: 'Kelola Buku', href: '/admin/books', icon: BookOpen },
    ];

    const isActive = (href: string) => {
        return href === '/admin' ? currentPath === '/admin' : currentPath.startsWith(href);
    };

    return (
        <div className="min-h-screen bg-gray-100">
            <div className="hidden md:fixed md:inset-y-0 md:flex md:w-64 md:flex-col">
                <div className="flex min-h-0 flex-1 flex-col bg-gray-900">
                    <div className="flex h-16 items-center px-4">
                        <BookOpen className="h-8 w-8 text-white" />
                        <span className="ml-2 text-xl font-bold text-white">Admin Panel</span>
                    </div>

                    <nav className="flex-1 space-y-1 px-2 py-4">
                        {navigation.map((item) => {
                            const Icon = item.icon;
                            return (
                                <Link
                                    key={item.name}
                                    href={item.href}
                                    className={`group flex items-center px-3 py-2 text-sm font-medium rounded-md ${
                                        isActive(item.href)
                                            ? 'bg-gray-800 text-white'
                                            : 'text-gray-300 hover:bg-gray-700 hover:text-white'
                                    }`}
                                >
                                    <Icon className="mr-3 h-5 w-5" />
                                    {item.name}
                                </Link>
                            );
                        })}
                    </nav>

                    <div className="border-t border-gray-700 p-4">
                        <Link href="/" className="flex items-center text-gray-300 hover:text-white">
                            <Store className="h-5 w-5 mr-2" />
                            Lihat Toko
                        </Link>
                    </div>
                </div>
            </div>

            <div className="md:pl-64">
                <div className="sticky top-0 z-10 flex h-16 bg-white shadow">
                    <button onClick={() => setSidebarOpen(!sidebarOpen)} className="px-4 text-gray-500 md:hidden">
                        {sidebarOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
                    </button>

                    <div className="flex flex-1 justify-end px-4">
                        <DropdownMenu>
                            <DropdownMenuTrigger asChild>
                                <Button variant="ghost" className="flex items-center gap-2">
                                    {auth.user.name}
                                    <ChevronDown className="h-4 w-4" />
                                </Button>
                            </DropdownMenuTrigger>
                            <DropdownMenuContent align="end">
                                <DropdownMenuItem asChild>
                                    <Link href="/">Lihat Toko</Link>
                                </DropdownMenuItem>
                                <DropdownMenuSeparator />
                                <DropdownMenuItem asChild>
                                    <Link href="/logout" method="post" as="button" className="w-full">
                                        <LogOut className="h-4 w-4 mr-2" />
                                        Logout
                                    </Link>
                                </DropdownMenuItem>
                            </DropdownMenuContent>
                        </DropdownMenu>
                    </div>
                </div>

                {sidebarOpen && (
                    <div className="fixed inset-0 z-40 md:hidden">
                        <div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setSidebarOpen(false)} />
                        <div className="fixed inset-y-0 left-0 flex w-64 flex-col bg-gray-900">
                            <nav className="flex-1 space-y-1 px-2 py-4 mt-16">
                                {navigation.map((item) => {
                                    const Icon = item.icon;
                                    return (
                                        <Link
                                            key={item.name}
                                            href={item.href}
                                            onClick={() => setSidebarOpen(false)}
                                            className={`flex items-center px-3 py-2 text-sm font-medium rounded-md ${
                                                isActive(item.href) ? 'bg-gray-800 text-white' : 'text-gray-300'
                                            }`}
                                        >
                                            <Icon className="mr-3 h-5 w-5" />
                                            {item.name}
                                        </Link>
                                    );
                                })}
                            </nav>
                        </div>
                    </div>
                )}

                <main className="py-6">
                    <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">{children}</div>
                </main>
            </div>
        </div>
    );
}

Membuat Halaman Dashboard Admin

Buat file resources/js/pages/Admin/Dashboard.tsx:

import { Head } from '@inertiajs/react';
import AdminLayout from '@/layouts/admin-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { BookOpen, ShoppingCart, Users, DollarSign, AlertTriangle, Clock } from 'lucide-react';

interface Props {
    stats: {
        total_books: number;
        active_books: number;
        total_orders: number;
        pending_orders: number;
        total_users: number;
        total_revenue: number;
    };
    recentOrders: Array<{
        id: number;
        order_number: string;
        total_amount: number;
        status: string;
        created_at: string;
        user: { name: string };
    }>;
    lowStockBooks: Array<{ id: number; title: string; stock: number }>;
}

export default function AdminDashboard({ stats, recentOrders, lowStockBooks }: Props) {
    const formatPrice = (price: number) => 'Rp ' + price.toLocaleString('id-ID');
    const formatDate = (date: string) => new Date(date).toLocaleDateString('id-ID', { day: 'numeric', month: 'short' });

    const statCards = [
        { title: 'Total Buku', value: stats.total_books, subtitle: `${stats.active_books} aktif`, icon: BookOpen, color: 'text-blue-600 bg-blue-100' },
        { title: 'Total Pesanan', value: stats.total_orders, subtitle: `${stats.pending_orders} pending`, icon: ShoppingCart, color: 'text-green-600 bg-green-100' },
        { title: 'Total User', value: stats.total_users, subtitle: 'customer', icon: Users, color: 'text-purple-600 bg-purple-100' },
        { title: 'Pendapatan', value: formatPrice(stats.total_revenue), subtitle: 'selesai', icon: DollarSign, color: 'text-yellow-600 bg-yellow-100' },
    ];

    return (
        <AdminLayout>
            <Head title="Admin Dashboard" />
            <div className="mb-8">
                <h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
                <p className="text-gray-500">Selamat datang di Admin Panel TokoBuku</p>
            </div>

            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
                {statCards.map((stat) => {
                    const Icon = stat.icon;
                    return (
                        <Card key={stat.title}>
                            <CardContent className="p-6">
                                <div className="flex items-center justify-between">
                                    <div>
                                        <p className="text-sm font-medium text-gray-500">{stat.title}</p>
                                        <p className="text-2xl font-bold text-gray-900">{stat.value}</p>
                                        <p className="text-xs text-gray-400 mt-1">{stat.subtitle}</p>
                                    </div>
                                    <div className={`p-3 rounded-full ${stat.color}`}>
                                        <Icon className="h-6 w-6" />
                                    </div>
                                </div>
                            </CardContent>
                        </Card>
                    );
                })}
            </div>

            <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
                <Card>
                    <CardHeader>
                        <CardTitle className="flex items-center gap-2">
                            <Clock className="h-5 w-5" />
                            Pesanan Terbaru
                        </CardTitle>
                    </CardHeader>
                    <CardContent>
                        {recentOrders.length === 0 ? (
                            <p className="text-gray-500 text-center py-4">Belum ada pesanan</p>
                        ) : (
                            <div className="space-y-4">
                                {recentOrders.map((order) => (
                                    <div key={order.id} className="flex items-center justify-between py-2 border-b last:border-0">
                                        <div>
                                            <p className="font-medium">{order.order_number}</p>
                                            <p className="text-sm text-gray-500">{order.user.name} • {formatDate(order.created_at)}</p>
                                        </div>
                                        <div className="text-right">
                                            <p className="font-medium">{formatPrice(order.total_amount)}</p>
                                            <Badge className={order.status === 'pending' ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800'}>
                                                {order.status}
                                            </Badge>
                                        </div>
                                    </div>
                                ))}
                            </div>
                        )}
                    </CardContent>
                </Card>

                <Card>
                    <CardHeader>
                        <CardTitle className="flex items-center gap-2 text-orange-600">
                            <AlertTriangle className="h-5 w-5" />
                            Stok Menipis
                        </CardTitle>
                    </CardHeader>
                    <CardContent>
                        {lowStockBooks.length === 0 ? (
                            <p className="text-gray-500 text-center py-4">Semua stok aman</p>
                        ) : (
                            <div className="space-y-4">
                                {lowStockBooks.map((book) => (
                                    <div key={book.id} className="flex items-center justify-between py-2 border-b last:border-0">
                                        <p className="font-medium line-clamp-1">{book.title}</p>
                                        <Badge className={book.stock === 0 ? 'bg-red-100 text-red-800' : 'bg-orange-100 text-orange-800'}>
                                            {book.stock === 0 ? 'Habis' : `Sisa ${book.stock}`}
                                        </Badge>
                                    </div>
                                ))}
                            </div>
                        )}
                    </CardContent>
                </Card>
            </div>
        </AdminLayout>
    );
}

Membuat Halaman List Buku Admin

Buat file resources/js/pages/Admin/Books/Index.tsx:

import { Head, Link, router } from '@inertiajs/react';
import { useState } from 'react';
import AdminLayout from '@/layouts/admin-layout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { Pagination } from '@/components/pagination';
import { Plus, Search, Pencil, Trash2, BookOpen } from 'lucide-react';

interface Book {
    id: number;
    title: string;
    author: string;
    formatted_price: string;
    stock: number;
    is_active: boolean;
    is_featured: boolean;
    cover_image: string | null;
    category: { id: number; name: string };
}

interface Props {
    books: { data: Book[]; links: any[]; current_page: number; last_page: number; total: number };
    categories: Array<{ id: number; name: string }>;
    filters: { search?: string; category?: string; active?: string };
}

export default function AdminBooksIndex({ books, categories, filters }: Props) {
    const [search, setSearch] = useState(filters.search || '');
    const [deleteBookId, setDeleteBookId] = useState<number | null>(null);

    const updateFilters = (newFilters: Partial<typeof filters>) => {
        const params: Record<string, string> = {};
        const updated = { ...filters, ...newFilters };
        if (updated.search) params.search = updated.search;
        if (updated.category) params.category = updated.category;
        if (updated.active) params.active = updated.active;
        router.get('/admin/books', params, { preserveState: true });
    };

    const handleDelete = () => {
        if (deleteBookId) {
            router.delete(`/admin/books/${deleteBookId}`, { onSuccess: () => setDeleteBookId(null) });
        }
    };

    return (
        <AdminLayout>
            <Head title="Kelola Buku" />

            <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
                <div>
                    <h1 className="text-2xl font-bold text-gray-900">Kelola Buku</h1>
                    <p className="text-gray-500">{books.total} buku</p>
                </div>
                <Link href="/admin/books/create">
                    <Button><Plus className="h-4 w-4 mr-2" />Tambah Buku</Button>
                </Link>
            </div>

            <Card className="mb-6">
                <CardContent className="p-4">
                    <div className="flex flex-col sm:flex-row gap-4">
                        <form onSubmit={(e) => { e.preventDefault(); updateFilters({ search }); }} className="flex-1">
                            <div className="relative">
                                <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
                                <Input placeholder="Cari judul, penulis..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-10" />
                            </div>
                        </form>
                        <Select value={filters.category || 'all'} onValueChange={(v) => updateFilters({ category: v === 'all' ? '' : v })}>
                            <SelectTrigger className="w-full sm:w-48"><SelectValue placeholder="Kategori" /></SelectTrigger>
                            <SelectContent>
                                <SelectItem value="all">Semua Kategori</SelectItem>
                                {categories.map((c) => <SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>)}
                            </SelectContent>
                        </Select>
                    </div>
                </CardContent>
            </Card>

            {books.data.length === 0 ? (
                <Card><CardContent className="py-16 text-center"><BookOpen className="h-16 w-16 text-gray-300 mx-auto mb-4" /><p className="text-gray-500">Tidak ada buku</p></CardContent></Card>
            ) : (
                <div className="bg-white rounded-lg shadow overflow-hidden">
                    <table className="min-w-full divide-y divide-gray-200">
                        <thead className="bg-gray-50">
                            <tr>
                                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Buku</th>
                                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Kategori</th>
                                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Harga</th>
                                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Stok</th>
                                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
                                <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Aksi</th>
                            </tr>
                        </thead>
                        <tbody className="divide-y divide-gray-200">
                            {books.data.map((book) => (
                                <tr key={book.id} className="hover:bg-gray-50">
                                    <td className="px-6 py-4">
                                        <div className="flex items-center">
                                            <div className="h-12 w-10 bg-gray-100 rounded overflow-hidden">
                                                {book.cover_image ? <img src={`/storage/${book.cover_image}`} alt="" className="h-full w-full object-cover" /> : <BookOpen className="h-4 w-4 text-gray-400 m-auto mt-4" />}
                                            </div>
                                            <div className="ml-4">
                                                <div className="text-sm font-medium text-gray-900 line-clamp-1">{book.title}</div>
                                                <div className="text-sm text-gray-500">{book.author}</div>
                                            </div>
                                        </div>
                                    </td>
                                    <td className="px-6 py-4 text-sm">{book.category.name}</td>
                                    <td className="px-6 py-4 text-sm">{book.formatted_price}</td>
                                    <td className="px-6 py-4">
                                        <Badge className={book.stock === 0 ? 'bg-red-100 text-red-800' : book.stock <= 5 ? 'bg-orange-100 text-orange-800' : 'bg-green-100 text-green-800'}>{book.stock}</Badge>
                                    </td>
                                    <td className="px-6 py-4">
                                        <div className="flex gap-2">
                                            <Badge className={book.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}>{book.is_active ? 'Aktif' : 'Nonaktif'}</Badge>
                                            {book.is_featured && <Badge className="bg-yellow-100 text-yellow-800">⭐</Badge>}
                                        </div>
                                    </td>
                                    <td className="px-6 py-4 text-right">
                                        <div className="flex justify-end gap-2">
                                            <Link href={`/admin/books/${book.id}/edit`}><Button variant="outline" size="sm"><Pencil className="h-4 w-4" /></Button></Link>
                                            <Button variant="outline" size="sm" onClick={() => setDeleteBookId(book.id)} className="text-red-600 hover:bg-red-50"><Trash2 className="h-4 w-4" /></Button>
                                        </div>
                                    </td>
                                </tr>
                            ))}
                        </tbody>
                    </table>
                    <div className="px-6 py-4 border-t"><Pagination links={books.links} currentPage={books.current_page} lastPage={books.last_page} /></div>
                </div>
            )}

            <AlertDialog open={!!deleteBookId} onOpenChange={() => setDeleteBookId(null)}>
                <AlertDialogContent>
                    <AlertDialogHeader>
                        <AlertDialogTitle>Hapus Buku?</AlertDialogTitle>
                        <AlertDialogDescription>Tindakan ini tidak bisa dibatalkan.</AlertDialogDescription>
                    </AlertDialogHeader>
                    <AlertDialogFooter>
                        <AlertDialogCancel>Batal</AlertDialogCancel>
                        <AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700">Hapus</AlertDialogAction>
                    </AlertDialogFooter>
                </AlertDialogContent>
            </AlertDialog>
        </AdminLayout>
    );
}

Membuat Form Create & Edit Buku

Buat file resources/js/pages/Admin/Books/Create.tsx:

import { Head, Link, useForm } from '@inertiajs/react';
import { useState } from 'react';
import AdminLayout from '@/layouts/admin-layout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ArrowLeft, Upload, X, Loader2 } from 'lucide-react';

interface Props {
    categories: Array<{ id: number; name: string }>;
}

export default function AdminBooksCreate({ categories }: Props) {
    const [preview, setPreview] = useState<string | null>(null);
    const { data, setData, post, processing, errors } = useForm({
        title: '', category_id: '', author: '', description: '', price: '', stock: '',
        year_published: '', isbn: '', pages: '', is_featured: false, is_active: true, cover_image: null as File | null,
    });

    const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        const file = e.target.files?.[0];
        if (file) { setData('cover_image', file); setPreview(URL.createObjectURL(file)); }
    };

    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        post('/admin/books', { forceFormData: true });
    };

    return (
        <AdminLayout>
            <Head title="Tambah Buku" />
            <div className="mb-6">
                <Link href="/admin/books" className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900 mb-4">
                    <ArrowLeft className="h-4 w-4 mr-1" />Kembali
                </Link>
                <h1 className="text-2xl font-bold text-gray-900">Tambah Buku Baru</h1>
            </div>

            <form onSubmit={handleSubmit}>
                <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
                    <div className="lg:col-span-2 space-y-6">
                        <Card>
                            <CardHeader><CardTitle>Informasi Buku</CardTitle></CardHeader>
                            <CardContent className="space-y-4">
                                <div className="space-y-2">
                                    <Label>Judul *</Label>
                                    <Input value={data.title} onChange={(e) => setData('title', e.target.value)} className={errors.title ? 'border-red-500' : ''} />
                                    {errors.title && <p className="text-sm text-red-500">{errors.title}</p>}
                                </div>
                                <div className="grid grid-cols-2 gap-4">
                                    <div className="space-y-2">
                                        <Label>Kategori *</Label>
                                        <Select value={data.category_id} onValueChange={(v) => setData('category_id', v)}>
                                            <SelectTrigger><SelectValue placeholder="Pilih kategori" /></SelectTrigger>
                                            <SelectContent>{categories.map((c) => <SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>)}</SelectContent>
                                        </Select>
                                    </div>
                                    <div className="space-y-2">
                                        <Label>Penulis *</Label>
                                        <Input value={data.author} onChange={(e) => setData('author', e.target.value)} />
                                    </div>
                                </div>
                                <div className="space-y-2">
                                    <Label>Deskripsi *</Label>
                                    <Textarea rows={6} value={data.description} onChange={(e) => setData('description', e.target.value)} />
                                </div>
                            </CardContent>
                        </Card>
                        <Card>
                            <CardHeader><CardTitle>Harga & Stok</CardTitle></CardHeader>
                            <CardContent>
                                <div className="grid grid-cols-2 gap-4">
                                    <div className="space-y-2">
                                        <Label>Harga (Rp) *</Label>
                                        <Input type="number" value={data.price} onChange={(e) => setData('price', e.target.value)} />
                                    </div>
                                    <div className="space-y-2">
                                        <Label>Stok *</Label>
                                        <Input type="number" value={data.stock} onChange={(e) => setData('stock', e.target.value)} />
                                    </div>
                                </div>
                            </CardContent>
                        </Card>
                    </div>

                    <div className="space-y-6">
                        <Card>
                            <CardHeader><CardTitle>Cover Buku</CardTitle></CardHeader>
                            <CardContent>
                                {preview ? (
                                    <div className="relative">
                                        <img src={preview} alt="Preview" className="w-full aspect-[3/4] object-cover rounded-lg" />
                                        <button type="button" onClick={() => { setData('cover_image', null); setPreview(null); }} className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full"><X className="h-4 w-4" /></button>
                                    </div>
                                ) : (
                                    <label className="flex flex-col items-center justify-center w-full aspect-[3/4] border-2 border-dashed rounded-lg cursor-pointer hover:bg-gray-50">
                                        <Upload className="h-10 w-10 text-gray-400 mb-2" />
                                        <span className="text-sm text-gray-500">Klik untuk upload</span>
                                        <input type="file" accept="image/*" onChange={handleImageChange} className="hidden" />
                                    </label>
                                )}
                            </CardContent>
                        </Card>
                        <Card>
                            <CardHeader><CardTitle>Status</CardTitle></CardHeader>
                            <CardContent className="space-y-4">
                                <div className="flex items-center space-x-2">
                                    <Checkbox id="is_active" checked={data.is_active} onCheckedChange={(c) => setData('is_active', c as boolean)} />
                                    <Label htmlFor="is_active">Aktif</Label>
                                </div>
                                <div className="flex items-center space-x-2">
                                    <Checkbox id="is_featured" checked={data.is_featured} onCheckedChange={(c) => setData('is_featured', c as boolean)} />
                                    <Label htmlFor="is_featured">Buku Pilihan</Label>
                                </div>
                            </CardContent>
                        </Card>
                        <Button type="submit" className="w-full" size="lg" disabled={processing}>
                            {processing ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Menyimpan...</> : 'Simpan Buku'}
                        </Button>
                    </div>
                </div>
            </form>
        </AdminLayout>
    );
}

Buat juga resources/js/pages/Admin/Books/Edit.tsx dengan struktur serupa tapi menggunakan _method: 'PUT' dan pre-populate data dari props book.

Test Admin Panel

composer run dev

Login dengan [email protected], akses /admin:

  1. Lihat dashboard dengan statistik
  2. Kelola buku dengan search dan filter
  3. Tambah buku baru dengan upload cover
  4. Edit dan hapus buku

Recap

Di bagian ini kita sudah membangun admin panel:

  • Kolom is_admin dan middleware untuk proteksi
  • Dashboard dengan statistik dan alerts
  • CRUD buku lengkap dengan image upload
  • Search dan filter di list buku
  • Layout admin yang terpisah

Di bagian selanjutnya kita akan menambahkan toast notifications dan loading states.

Lanjut ke Bagian 9: Polish UI & Components →

Bagian 9.1: Toast Notifications dengan Sonner

Aplikasi kita sudah fungsional, tapi user experience masih bisa ditingkatkan. Salah satu yang paling penting adalah feedback visual ketika user melakukan aksi. Saat ini, flash messages dari Laravel sudah dikirim ke frontend tapi belum ditampilkan.

Di bagian ini kita akan mengintegrasikan Sonner, library toast notification yang ringan dan elegan, untuk menampilkan pesan sukses dan error.

Install Sonner

Sonner sudah tersedia sebagai komponen shadcn/ui:

npx shadcn@latest add sonner

Kalau command di atas tidak work, install manual:

npm install sonner

Lalu buat file resources/js/components/ui/sonner.tsx:

import { Toaster as Sonner } from "sonner"

type ToasterProps = React.ComponentProps<typeof Sonner>

const Toaster = ({ ...props }: ToasterProps) => {
  return (
    <Sonner
      theme="light"
      className="toaster group"
      toastOptions={{
        classNames: {
          toast:
            "group toast group-[.toaster]:bg-white group-[.toaster]:text-gray-950 group-[.toaster]:border-gray-200 group-[.toaster]:shadow-lg",
          description: "group-[.toast]:text-gray-500",
          actionButton:
            "group-[.toast]:bg-gray-900 group-[.toast]:text-gray-50",
          cancelButton:
            "group-[.toast]:bg-gray-100 group-[.toast]:text-gray-500",
          success: "group-[.toaster]:bg-green-50 group-[.toaster]:text-green-900 group-[.toaster]:border-green-200",
          error: "group-[.toaster]:bg-red-50 group-[.toaster]:text-red-900 group-[.toaster]:border-red-200",
        },
      }}
      {...props}
    />
  )
}

export { Toaster }

Menambahkan Toaster ke Layout

Toaster perlu ditambahkan sekali di root aplikasi. Cara terbaik adalah menambahkannya di file layout utama atau di app.tsx.

Edit resources/js/app.tsx untuk menambahkan Toaster:

import '../css/app.css';
import './bootstrap';

import { createInertiaApp } from '@inertiajs/react';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createRoot } from 'react-dom/client';
import { Toaster } from '@/components/ui/sonner';

const appName = import.meta.env.VITE_APP_NAME || 'Laravel';

createInertiaApp({
    title: (title) => `${title} - ${appName}`,
    resolve: (name) =>
        resolvePageComponent(
            `./pages/${name}.tsx`,
            import.meta.glob('./pages/**/*.tsx'),
        ),
    setup({ el, App, props }) {
        const root = createRoot(el);

        root.render(
            <>
                <App {...props} />
                <Toaster position="top-right" richColors closeButton />
            </>
        );
    },
    progress: {
        color: '#4B5563',
    },
});

Kita menambahkan <Toaster /> di luar <App /> agar tersedia di semua halaman. Props yang digunakan:

  • position="top-right" menempatkan toast di kanan atas
  • richColors mengaktifkan warna khusus untuk success/error
  • closeButton menambahkan tombol X untuk menutup toast

Membuat Hook untuk Flash Messages

Sekarang kita perlu hook yang membaca flash messages dari Inertia dan menampilkannya sebagai toast.

Buat file resources/js/hooks/use-flash-messages.ts:

import { usePage } from '@inertiajs/react';
import { useEffect } from 'react';
import { toast } from 'sonner';

interface PageProps {
    flash: {
        success?: string;
        error?: string;
    };
}

export function useFlashMessages() {
    const { flash } = usePage<PageProps>().props;

    useEffect(() => {
        if (flash.success) {
            toast.success(flash.success, {
                duration: 4000,
            });
        }

        if (flash.error) {
            toast.error(flash.error, {
                duration: 5000,
            });
        }
    }, [flash.success, flash.error]);
}

Hook ini menggunakan useEffect untuk watch perubahan pada flash messages. Setiap kali ada flash message baru, toast akan ditampilkan.

Menggunakan Hook di Layout

Tambahkan hook ke kedua layout kita. Edit resources/js/layouts/store-layout.tsx:

import { Link, usePage } from '@inertiajs/react';
import { PropsWithChildren, useState } from 'react';
import { useFlashMessages } from '@/hooks/use-flash-messages';
// ... import lainnya

export default function StoreLayout({ children }: StoreLayoutProps) {
    useFlashMessages(); // Tambahkan ini di awal function

    const { auth, cartCount = 0 } = usePage<PageProps>().props;
    // ... rest of component
}

Lakukan hal yang sama di resources/js/layouts/admin-layout.tsx:

import { Link, usePage } from '@inertiajs/react';
import { PropsWithChildren, useState } from 'react';
import { useFlashMessages } from '@/hooks/use-flash-messages';
// ... import lainnya

export default function AdminLayout({ children }: AdminLayoutProps) {
    useFlashMessages(); // Tambahkan ini di awal function

    const { auth } = usePage<PageProps>().props;
    // ... rest of component
}

Test Toast Notifications

Restart development server dan test:

Test Success Toast:

  1. Tambah buku ke cart → toast "Buku berhasil ditambahkan ke keranjang"
  2. Update quantity di cart → toast "Jumlah berhasil diperbarui"
  3. Hapus item dari cart → toast "Buku dihapus dari keranjang"
  4. Buat order → toast "Pesanan berhasil dibuat!"

Test Error Toast:

  1. Coba checkout dengan cart kosong → toast error
  2. Coba akses admin tanpa login admin → redirect dengan error

Test di Admin:

  1. Tambah buku baru → toast "Buku berhasil ditambahkan"
  2. Edit buku → toast "Buku berhasil diperbarui"
  3. Hapus buku → toast "Buku berhasil dihapus"

Menambahkan Toast Manual

Selain flash messages, kadang kita perlu menampilkan toast secara manual. Misalnya untuk validasi client-side atau konfirmasi aksi.

Contoh penggunaan di komponen:

import { toast } from 'sonner';

// Di dalam component
const handleSomething = () => {
    // Success toast
    toast.success('Berhasil!');

    // Error toast
    toast.error('Terjadi kesalahan');

    // Info toast
    toast.info('Informasi penting');

    // Warning toast
    toast.warning('Perhatian!');

    // Toast dengan action
    toast('Item dihapus', {
        action: {
            label: 'Undo',
            onClick: () => console.log('Undo clicked'),
        },
    });

    // Promise toast (untuk async operations)
    toast.promise(saveData(), {
        loading: 'Menyimpan...',
        success: 'Data tersimpan!',
        error: 'Gagal menyimpan',
    });
};

Contoh: Update Add to Cart dengan Toast

Mari update halaman detail buku untuk menampilkan toast langsung tanpa menunggu page refresh.

Edit bagian handleAddToCart di resources/js/pages/Books/Show.tsx:

import { toast } from 'sonner';

// Di dalam component
const handleAddToCart = () => {
    setIsAddingToCart(true);

    router.post('/cart/add', {
        book_id: book.id,
        quantity: quantity,
    }, {
        preserveScroll: true,
        onSuccess: () => {
            setAddedToCart(true);
            toast.success(`${book.title} ditambahkan ke keranjang`, {
                description: `${quantity} buku`,
                action: {
                    label: 'Lihat Keranjang',
                    onClick: () => router.visit('/cart'),
                },
            });
            setTimeout(() => setAddedToCart(false), 2000);
        },
        onError: () => {
            toast.error('Gagal menambahkan ke keranjang');
        },
        onFinish: () => {
            setIsAddingToCart(false);
        },
    });
};

Sekarang toast akan muncul dengan tombol action untuk langsung ke keranjang.

Styling Toast

Sonner sudah cukup bagus out of the box, tapi kamu bisa customize lebih lanjut.

Untuk mengubah posisi global:

<Toaster position="bottom-center" />

Opsi posisi: top-left, top-center, top-right, bottom-left, bottom-center, bottom-right

Untuk mengubah durasi default:

<Toaster
    position="top-right"
    richColors
    closeButton
    duration={3000}
    visibleToasts={5}
/>

Recap Bagian 9.1

Di bagian ini kita sudah:

  • Install dan setup Sonner toast library
  • Membuat hook useFlashMessages untuk otomatis menampilkan flash messages
  • Mengintegrasikan toast di kedua layout
  • Menambahkan toast manual untuk feedback yang lebih kaya
  • Customize tampilan dan behavior toast

Toast notifications membuat aplikasi terasa lebih responsive dan professional. User langsung tahu hasil dari aksi mereka tanpa harus mencari pesan di halaman.

Lanjut ke Bagian 9.2: Loading States & Transitions →

Bagian 9.2: Loading States & Transitions

User experience yang baik membutuhkan feedback visual saat aplikasi sedang memproses sesuatu. Loading states memberi tahu user bahwa aksi mereka sedang diproses, sementara transitions membuat perpindahan antar state terasa smooth.

Di bagian ini kita akan menambahkan loading indicators, skeleton loaders, dan page transitions.

Global Loading Indicator

Inertia sudah punya built-in progress indicator, tapi kita bisa enhance dengan NProgress untuk tampilan yang lebih baik.

Progress bar sudah dikonfigurasi di app.tsx:

progress: {
    color: '#4B5563',
},

Untuk customize lebih lanjut, kita bisa menggunakan event listeners Inertia.

Membuat Loading Overlay Component

Buat komponen loading overlay yang bisa digunakan di mana saja.

Buat file resources/js/components/loading-overlay.tsx:

import { Loader2 } from 'lucide-react';

interface LoadingOverlayProps {
    show: boolean;
    message?: string;
}

export function LoadingOverlay({ show, message = 'Memuat...' }: LoadingOverlayProps) {
    if (!show) return null;

    return (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
            <div className="bg-white rounded-lg p-6 shadow-xl flex flex-col items-center">
                <Loader2 className="h-10 w-10 text-blue-600 animate-spin" />
                <p className="mt-3 text-gray-600 font-medium">{message}</p>
            </div>
        </div>
    );
}

Hook untuk Navigation Loading State

Buat hook untuk track loading state saat navigasi antar halaman.

Buat file resources/js/hooks/use-loading.ts:

import { router } from '@inertiajs/react';
import { useState, useEffect } from 'react';

export function useLoading() {
    const [isLoading, setIsLoading] = useState(false);

    useEffect(() => {
        const startHandler = () => setIsLoading(true);
        const finishHandler = () => setIsLoading(false);

        router.on('start', startHandler);
        router.on('finish', finishHandler);

        return () => {
            router.off('start', startHandler);
            router.off('finish', finishHandler);
        };
    }, []);

    return isLoading;
}

Membuat Skeleton Components

Skeleton loader memberikan preview struktur konten saat data sedang dimuat.

Buat file resources/js/components/ui/skeleton.tsx:

import { cn } from "@/lib/utils"

function Skeleton({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) {
  return (
    <div
      className={cn("animate-pulse rounded-md bg-gray-200", className)}
      {...props}
    />
  )
}

export { Skeleton }

Skeleton untuk Book Card

Buat skeleton khusus untuk book card.

Buat file resources/js/components/book-card-skeleton.tsx:

import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';

export function BookCardSkeleton() {
    return (
        <Card className="h-full overflow-hidden">
            <Skeleton className="aspect-[3/4] w-full rounded-none" />
            <CardContent className="p-4 space-y-3">
                <Skeleton className="h-3 w-16" />
                <Skeleton className="h-5 w-full" />
                <Skeleton className="h-4 w-24" />
                <Skeleton className="h-6 w-20" />
            </CardContent>
        </Card>
    );
}

export function BookGridSkeleton({ count = 8 }: { count?: number }) {
    return (
        <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6">
            {Array.from({ length: count }).map((_, i) => (
                <BookCardSkeleton key={i} />
            ))}
        </div>
    );
}

Menggunakan Loading State di Catalog

Update halaman catalog untuk menampilkan loading state saat filter berubah.

Edit resources/js/pages/Catalog/Index.tsx, tambahkan di bagian atas component:

import { useLoading } from '@/hooks/use-loading';
import { BookGridSkeleton } from '@/components/book-card-skeleton';

export default function CatalogIndex({ books, categories, currentCategory, filters }: Props) {
    const isLoading = useLoading();
    const [search, setSearch] = useState(filters.search);
    // ... rest of state

    // Di bagian render grid buku, wrap dengan kondisi loading:
    return (
        <StoreLayout>
            {/* ... header dan filter ... */}

            <div className="flex-1">
                {isLoading ? (
                    <BookGridSkeleton count={12} />
                ) : books.data.length === 0 ? (
                    <div className="text-center py-16">
                        {/* empty state */}
                    </div>
                ) : (
                    <>
                        <div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6">
                            {books.data.map((book) => (
                                <BookCard key={book.id} book={book} />
                            ))}
                        </div>
                        <Pagination ... />
                    </>
                )}
            </div>
        </StoreLayout>
    );
}

Button Loading States

Komponen Button sudah support disabled state, tapi kita bisa buat variant dengan loading spinner built-in.

Buat file resources/js/components/ui/loading-button.tsx:

import { forwardRef } from 'react';
import { Button, ButtonProps } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';

interface LoadingButtonProps extends ButtonProps {
    loading?: boolean;
    loadingText?: string;
}

const LoadingButton = forwardRef<HTMLButtonElement, LoadingButtonProps>(
    ({ children, loading, loadingText, disabled, className, ...props }, ref) => {
        return (
            <Button
                ref={ref}
                disabled={disabled || loading}
                className={cn(className)}
                {...props}
            >
                {loading ? (
                    <>
                        <Loader2 className="h-4 w-4 mr-2 animate-spin" />
                        {loadingText || children}
                    </>
                ) : (
                    children
                )}
            </Button>
        );
    }
);

LoadingButton.displayName = 'LoadingButton';

export { LoadingButton };

Contoh penggunaan:

<LoadingButton
    loading={isSubmitting}
    loadingText="Menyimpan..."
>
    Simpan
</LoadingButton>

Page Transition dengan CSS

Tambahkan subtle fade transition saat berpindah halaman.

Edit resources/css/app.css:

@import 'tailwindcss';

/* Page transitions */
.page-enter {
    opacity: 0;
    transform: translateY(10px);
}

.page-enter-active {
    opacity: 1;
    transform: translateY(0);
    transition: opacity 200ms ease-out, transform 200ms ease-out;
}

/* Smooth scroll behavior */
html {
    scroll-behavior: smooth;
}

/* Loading bar customization */
#nprogress .bar {
    background: #3b82f6 !important;
    height: 3px !important;
}

#nprogress .peg {
    box-shadow: 0 0 10px #3b82f6, 0 0 5px #3b82f6 !important;
}

Optimistic Updates

Untuk UX yang lebih responsive, kita bisa melakukan optimistic updates - update UI sebelum server response.

Contoh di cart quantity update:

const updateQuantity = (bookId: number, newQuantity: number) => {
    // Optimistic update - update UI immediately
    setLocalItems(prev =>
        prev.map(item =>
            item.book.id === bookId
                ? { ...item, quantity: newQuantity }
                : item
        )
    );

    // Then send to server
    router.patch('/cart/update', {
        book_id: bookId,
        quantity: newQuantity,
    }, {
        preserveScroll: true,
        onError: () => {
            // Revert on error
            toast.error('Gagal mengupdate quantity');
            // Refresh data
            router.reload();
        },
    });
};

Debounce untuk Frequent Updates

Untuk input yang sering berubah seperti quantity, gunakan debounce:

import { useDebouncedCallback } from 'use-debounce';

const debouncedUpdateQuantity = useDebouncedCallback((bookId: number, quantity: number) => {
    router.patch('/cart/update', {
        book_id: bookId,
        quantity: quantity,
    }, {
        preserveScroll: true,
    });
}, 500);

Disabled State Styling

Pastikan semua interactive elements punya disabled state yang jelas.

Contoh di button:

<Button
    disabled={isLoading || !isValid}
    className="disabled:opacity-50 disabled:cursor-not-allowed"
>
    Submit
</Button>

Contoh di input:

<Input
    disabled={isLoading}
    className="disabled:bg-gray-100 disabled:text-gray-500"
/>

Recap Bagian 9.2

Di bagian ini kita sudah:

  • Membuat LoadingOverlay component untuk full-page loading
  • Membuat hook useLoading untuk track navigation state
  • Membuat Skeleton components untuk placeholder loading
  • Membuat LoadingButton dengan built-in spinner
  • Menambahkan CSS transitions untuk smooth page changes
  • Implementasi optimistic updates pattern
  • Menambahkan debounce untuk frequent updates

Loading states yang baik membuat aplikasi terasa responsive bahkan saat menunggu server. User selalu tahu apa yang sedang terjadi.

Lanjut ke Bagian 9.3: Error Handling & Empty States →

Bagian 9.3: Error Handling & Empty States

Aplikasi yang baik harus handle semua kemungkinan state dengan graceful. Termasuk error dari server, validasi gagal, network issues, dan kondisi dimana data kosong. Di bagian ini kita akan membuat error handling yang comprehensive dan empty states yang helpful.

Error Boundary untuk React

Error boundary mencegah seluruh aplikasi crash ketika ada error di satu komponen.

Buat file resources/js/components/error-boundary.tsx:

import { Component, ErrorInfo, ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { AlertTriangle, RefreshCw } from 'lucide-react';

interface Props {
    children: ReactNode;
    fallback?: ReactNode;
}

interface State {
    hasError: boolean;
    error?: Error;
}

export class ErrorBoundary extends Component<Props, State> {
    constructor(props: Props) {
        super(props);
        this.state = { hasError: false };
    }

    static getDerivedStateFromError(error: Error): State {
        return { hasError: true, error };
    }

    componentDidCatch(error: Error, errorInfo: ErrorInfo) {
        console.error('Error caught by boundary:', error, errorInfo);
    }

    handleRetry = () => {
        this.setState({ hasError: false, error: undefined });
        window.location.reload();
    };

    render() {
        if (this.state.hasError) {
            if (this.props.fallback) {
                return this.props.fallback;
            }

            return (
                <div className="min-h-[400px] flex items-center justify-center p-8">
                    <div className="text-center max-w-md">
                        <div className="bg-red-100 rounded-full p-4 w-fit mx-auto mb-6">
                            <AlertTriangle className="h-12 w-12 text-red-600" />
                        </div>
                        <h2 className="text-xl font-bold text-gray-900 mb-2">
                            Terjadi Kesalahan
                        </h2>
                        <p className="text-gray-500 mb-6">
                            Maaf, terjadi kesalahan yang tidak terduga. Silakan coba lagi.
                        </p>
                        <Button onClick={this.handleRetry}>
                            <RefreshCw className="h-4 w-4 mr-2" />
                            Coba Lagi
                        </Button>
                    </div>
                </div>
            );
        }

        return this.props.children;
    }
}

Wrap aplikasi dengan ErrorBoundary di app.tsx:

import { ErrorBoundary } from '@/components/error-boundary';

// Di dalam setup
root.render(
    <ErrorBoundary>
        <App {...props} />
        <Toaster position="top-right" richColors closeButton />
    </ErrorBoundary>
);

Komponen Empty State Reusable

Buat komponen empty state yang bisa digunakan di berbagai tempat.

Buat file resources/js/components/empty-state.tsx:

import { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { Link } from '@inertiajs/react';
import {
    ShoppingCart,
    Package,
    Search,
    BookOpen,
    FileQuestion,
    LucideIcon
} from 'lucide-react';

interface EmptyStateProps {
    icon?: LucideIcon;
    title: string;
    description?: string;
    action?: {
        label: string;
        href?: string;
        onClick?: () => void;
    };
    children?: ReactNode;
}

export function EmptyState({
    icon: Icon = FileQuestion,
    title,
    description,
    action,
    children
}: EmptyStateProps) {
    return (
        <div className="text-center py-12 px-4">
            <div className="bg-gray-100 rounded-full p-4 w-fit mx-auto mb-6">
                <Icon className="h-12 w-12 text-gray-400" />
            </div>
            <h3 className="text-lg font-semibold text-gray-900 mb-2">
                {title}
            </h3>
            {description && (
                <p className="text-gray-500 mb-6 max-w-sm mx-auto">
                    {description}
                </p>
            )}
            {action && (
                action.href ? (
                    <Link href={action.href}>
                        <Button>{action.label}</Button>
                    </Link>
                ) : (
                    <Button onClick={action.onClick}>{action.label}</Button>
                )
            )}
            {children}
        </div>
    );
}

// Pre-configured empty states for common use cases
export function EmptyCart() {
    return (
        <EmptyState
            icon={ShoppingCart}
            title="Keranjang Kosong"
            description="Belum ada buku di keranjang belanja kamu"
            action={{ label: 'Mulai Belanja', href: '/catalog' }}
        />
    );
}

export function EmptyOrders() {
    return (
        <EmptyState
            icon={Package}
            title="Belum Ada Pesanan"
            description="Kamu belum pernah melakukan pemesanan"
            action={{ label: 'Mulai Belanja', href: '/catalog' }}
        />
    );
}

export function EmptySearch({ query }: { query: string }) {
    return (
        <EmptyState
            icon={Search}
            title="Tidak Ditemukan"
            description={`Tidak ada hasil untuk "${query}". Coba kata kunci lain.`}
        />
    );
}

export function EmptyBooks() {
    return (
        <EmptyState
            icon={BookOpen}
            title="Tidak Ada Buku"
            description="Tidak ada buku yang sesuai dengan filter"
            action={{ label: 'Reset Filter', href: '/catalog' }}
        />
    );
}

Menggunakan Empty State di Halaman

Update halaman catalog untuk menggunakan komponen EmptyState:

import { EmptyBooks, EmptySearch } from '@/components/empty-state';

// Di dalam render
{books.data.length === 0 ? (
    filters.search ? (
        <EmptySearch query={filters.search} />
    ) : (
        <EmptyBooks />
    )
) : (
    // render books grid
)}

Form Validation Errors

Buat komponen untuk menampilkan error validasi form dengan lebih baik.

Buat file resources/js/components/form-error.tsx:

import { AlertCircle } from 'lucide-react';

interface FormErrorProps {
    message?: string;
}

export function FormError({ message }: FormErrorProps) {
    if (!message) return null;

    return (
        <p className="text-sm text-red-500 flex items-center gap-1 mt-1">
            <AlertCircle className="h-3 w-3 flex-shrink-0" />
            {message}
        </p>
    );
}

interface FormErrorsProps {
    errors: Record<string, string>;
}

export function FormErrors({ errors }: FormErrorsProps) {
    const errorMessages = Object.values(errors);

    if (errorMessages.length === 0) return null;

    return (
        <div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
            <div className="flex items-start gap-3">
                <AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
                <div>
                    <h4 className="font-medium text-red-800 mb-1">
                        Terdapat {errorMessages.length} kesalahan
                    </h4>
                    <ul className="text-sm text-red-600 space-y-1">
                        {errorMessages.map((error, index) => (
                            <li key={index}>• {error}</li>
                        ))}
                    </ul>
                </div>
            </div>
        </div>
    );
}

Contoh penggunaan di form:

import { FormError, FormErrors } from '@/components/form-error';

// Di atas form untuk summary
{Object.keys(errors).length > 0 && <FormErrors errors={errors} />}

// Di bawah input individual
<Input
    value={data.email}
    onChange={(e) => setData('email', e.target.value)}
    className={errors.email ? 'border-red-500 focus:ring-red-500' : ''}
/>
<FormError message={errors.email} />

Network Error Handler

Buat handler untuk network errors.

Buat file resources/js/hooks/use-network-status.ts:

import { useState, useEffect } from 'react';
import { toast } from 'sonner';

export function useNetworkStatus() {
    const [isOnline, setIsOnline] = useState(
        typeof navigator !== 'undefined' ? navigator.onLine : true
    );

    useEffect(() => {
        const handleOnline = () => {
            setIsOnline(true);
            toast.success('Koneksi kembali', {
                description: 'Anda sudah terhubung ke internet',
            });
        };

        const handleOffline = () => {
            setIsOnline(false);
            toast.error('Tidak ada koneksi', {
                description: 'Periksa koneksi internet Anda',
                duration: Infinity,
            });
        };

        window.addEventListener('online', handleOnline);
        window.addEventListener('offline', handleOffline);

        return () => {
            window.removeEventListener('online', handleOnline);
            window.removeEventListener('offline', handleOffline);
        };
    }, []);

    return isOnline;
}

Gunakan di layout:

import { useNetworkStatus } from '@/hooks/use-network-status';

export default function StoreLayout({ children }: Props) {
    useFlashMessages();
    useNetworkStatus(); // Tambahkan ini

    // ...
}

Server Error Page (500)

Buat halaman error untuk server errors.

Buat file resources/js/pages/Error.tsx:

import { Head } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { AlertTriangle, Home, RefreshCw } from 'lucide-react';

interface Props {
    status: number;
}

export default function Error({ status }: Props) {
    const title = {
        503: 'Layanan Tidak Tersedia',
        500: 'Server Error',
        404: 'Halaman Tidak Ditemukan',
        403: 'Akses Ditolak',
    }[status] || 'Error';

    const description = {
        503: 'Maaf, kami sedang melakukan maintenance. Silakan coba lagi nanti.',
        500: 'Maaf, terjadi kesalahan di server kami.',
        404: 'Halaman yang kamu cari tidak ditemukan.',
        403: 'Kamu tidak memiliki akses ke halaman ini.',
    }[status] || 'Terjadi kesalahan yang tidak terduga.';

    return (
        <>
            <Head title={title} />
            <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
                <div className="text-center max-w-md">
                    <div className="bg-red-100 rounded-full p-6 w-fit mx-auto mb-8">
                        <AlertTriangle className="h-16 w-16 text-red-600" />
                    </div>

                    <h1 className="text-6xl font-bold text-gray-900 mb-4">
                        {status}
                    </h1>
                    <h2 className="text-2xl font-semibold text-gray-800 mb-4">
                        {title}
                    </h2>
                    <p className="text-gray-500 mb-8">
                        {description}
                    </p>

                    <div className="flex flex-col sm:flex-row gap-4 justify-center">
                        <Button onClick={() => window.location.reload()}>
                            <RefreshCw className="h-4 w-4 mr-2" />
                            Coba Lagi
                        </Button>
                        <Button variant="outline" asChild>
                            <a href="/">
                                <Home className="h-4 w-4 mr-2" />
                                Ke Beranda
                            </a>
                        </Button>
                    </div>
                </div>
            </div>
        </>
    );
}

Inline Error untuk API Calls

Untuk error yang terjadi di tengah halaman (bukan full page error):

Buat file resources/js/components/inline-error.tsx:

import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';

interface InlineErrorProps {
    message?: string;
    onRetry?: () => void;
}

export function InlineError({
    message = 'Gagal memuat data',
    onRetry
}: InlineErrorProps) {
    return (
        <div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
            <AlertTriangle className="h-10 w-10 text-red-500 mx-auto mb-3" />
            <p className="text-red-700 font-medium mb-4">{message}</p>
            {onRetry && (
                <Button variant="outline" size="sm" onClick={onRetry}>
                    <RefreshCw className="h-4 w-4 mr-2" />
                    Coba Lagi
                </Button>
            )}
        </div>
    );
}

Alert Component untuk Messages

Buat alert component untuk berbagai jenis pesan.

Buat file resources/js/components/ui/alert.tsx:

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { AlertCircle, CheckCircle2, Info, AlertTriangle } from "lucide-react"

const alertVariants = cva(
  "relative w-full rounded-lg border p-4 flex gap-3",
  {
    variants: {
      variant: {
        default: "bg-gray-50 border-gray-200 text-gray-900",
        destructive: "bg-red-50 border-red-200 text-red-900",
        success: "bg-green-50 border-green-200 text-green-900",
        warning: "bg-yellow-50 border-yellow-200 text-yellow-900",
        info: "bg-blue-50 border-blue-200 text-blue-900",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

const iconMap = {
  default: Info,
  destructive: AlertCircle,
  success: CheckCircle2,
  warning: AlertTriangle,
  info: Info,
}

interface AlertProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof alertVariants> {
  icon?: boolean
}

const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
  ({ className, variant = "default", icon = true, children, ...props }, ref) => {
    const Icon = iconMap[variant || "default"]

    return (
      <div
        ref={ref}
        role="alert"
        className={cn(alertVariants({ variant }), className)}
        {...props}
      >
        {icon && <Icon className="h-5 w-5 flex-shrink-0" />}
        <div className="flex-1">{children}</div>
      </div>
    )
  }
)
Alert.displayName = "Alert"

const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
  ({ className, ...props }, ref) => (
    <h5 ref={ref} className={cn("font-medium leading-none mb-1", className)} {...props} />
  )
)
AlertTitle.displayName = "AlertTitle"

const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
  ({ className, ...props }, ref) => (
    <p ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
  )
)
AlertDescription.displayName = "AlertDescription"

export { Alert, AlertTitle, AlertDescription }

Install dependency jika belum:

npm install class-variance-authority

Contoh penggunaan:

import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';

<Alert variant="success">
    <AlertTitle>Berhasil!</AlertTitle>
    <AlertDescription>Pesanan kamu telah dikonfirmasi.</AlertDescription>
</Alert>

<Alert variant="warning">
    <AlertTitle>Perhatian</AlertTitle>
    <AlertDescription>Stok buku ini tinggal sedikit.</AlertDescription>
</Alert>

Recap Bagian 9.3

Di bagian ini kita sudah membuat:

  • ErrorBoundary untuk catch React errors
  • Komponen EmptyState yang reusable
  • Pre-configured empty states (cart, orders, search, books)
  • Form validation error components
  • Network status handler dengan toast
  • Server error page (404, 500, etc)
  • InlineError untuk partial page errors
  • Alert component dengan variants

Error handling yang baik membuat aplikasi lebih robust dan user-friendly. User selalu tahu apa yang terjadi dan apa yang harus dilakukan selanjutnya.

Test Semua Polish Features

Restart development server dan test:

  1. Toast: Tambah buku ke cart, lihat toast muncul
  2. Loading: Filter di catalog, lihat skeleton loader
  3. Empty State: Kosongkan cart, lihat empty state yang helpful
  4. Network: Matikan internet, lihat toast offline
  5. Error Page: Akses URL yang tidak ada, lihat 404 page

Lanjut ke Bagian 10: Testing & Deployment →

Bagian 10: Testing & Deployment

Selamat! Kamu sudah berhasil membangun aplikasi Toko Buku Digital yang lengkap dengan Laravel 12, Inertia.js, dan React. Di bagian terakhir ini, kita akan membahas cara testing aplikasi dan men-deploy ke production.

Browser Testing Checklist

Sebelum deploy, pastikan semua fitur berjalan dengan baik. Berikut checklist yang bisa kamu gunakan untuk manual testing.

Homepage:

  • [ ] Hero section tampil dengan benar
  • [ ] Kategori grid menampilkan semua kategori dengan icon
  • [ ] Buku pilihan menampilkan buku featured
  • [ ] Buku terbaru menampilkan buku latest
  • [ ] Semua link navigasi berfungsi
  • [ ] Responsive di mobile

Katalog:

  • [ ] Search berfungsi dengan debounce
  • [ ] Filter kategori memfilter buku dengan benar
  • [ ] Filter featured dan in_stock berfungsi
  • [ ] Sorting semua opsi berfungsi
  • [ ] Pagination berfungsi dan mempertahankan filter
  • [ ] Active filter tags bisa di-remove
  • [ ] Reset filter menghapus semua filter
  • [ ] Empty state muncul saat tidak ada hasil
  • [ ] Mobile filter sheet berfungsi

Detail Buku:

  • [ ] Breadcrumb navigation berfungsi
  • [ ] Cover image atau placeholder tampil
  • [ ] Badge featured muncul untuk buku pilihan
  • [ ] Overlay stok habis muncul untuk stock = 0
  • [ ] Quantity selector berfungsi dengan batasan stok
  • [ ] Add to cart berfungsi dan update badge header
  • [ ] Toast notification muncul
  • [ ] Related books menampilkan buku kategori sama

Keranjang:

  • [ ] Empty cart menampilkan empty state
  • [ ] Item list menampilkan semua item
  • [ ] Quantity update berfungsi
  • [ ] Remove item berfungsi
  • [ ] Clear cart dengan konfirmasi berfungsi
  • [ ] Total terhitung dengan benar
  • [ ] Link ke checkout berfungsi

Checkout:

  • [ ] Redirect ke login jika belum authenticated
  • [ ] Form pre-fill data user yang login
  • [ ] Validasi form berfungsi dengan error messages
  • [ ] Submit order berhasil
  • [ ] Redirect ke order detail setelah sukses
  • [ ] Stock berkurang setelah checkout
  • [ ] Cart kosong setelah checkout

Orders:

  • [ ] List orders menampilkan semua pesanan user
  • [ ] Status badge dengan warna yang benar
  • [ ] Pagination berfungsi
  • [ ] Detail order menampilkan semua info
  • [ ] Item list dengan subtotal benar

Authentication:

  • [ ] Register user baru berfungsi
  • [ ] Login berfungsi
  • [ ] Logout berfungsi
  • [ ] Protected routes redirect ke login

Admin Dashboard:

  • [ ] Hanya accessible oleh admin
  • [ ] Stat cards menampilkan data benar
  • [ ] Recent orders list berfungsi
  • [ ] Low stock alerts muncul

Admin Books:

  • [ ] List buku dengan search dan filter
  • [ ] Tambah buku baru dengan image upload
  • [ ] Edit buku dengan update image
  • [ ] Hapus buku dengan konfirmasi
  • [ ] Pagination berfungsi

Automated Testing dengan Pest

Laravel 12 menggunakan Pest sebagai default testing framework. Mari buat beberapa test dasar.

Buat file tests/Feature/HomeTest.php:

<?php

use App\\Models\\Book;
use App\\Models\\Category;

test('homepage loads successfully', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
    $response->assertInertia(fn ($page) => $page->component('Home'));
});

test('homepage shows featured books', function () {
    $category = Category::factory()->create();
    $featuredBook = Book::factory()->create([
        'category_id' => $category->id,
        'is_featured' => true,
        'is_active' => true,
        'stock' => 10,
    ]);

    $response = $this->get('/');

    $response->assertInertia(fn ($page) =>
        $page->component('Home')
            ->has('featuredBooks', 1)
    );
});

Buat file tests/Feature/CatalogTest.php:

<?php

use App\\Models\\Book;
use App\\Models\\Category;

test('catalog page loads', function () {
    $response = $this->get('/catalog');

    $response->assertStatus(200);
    $response->assertInertia(fn ($page) => $page->component('Catalog/Index'));
});

test('catalog can filter by category', function () {
    $category = Category::factory()->create(['slug' => 'fiksi']);
    Book::factory()->count(3)->create([
        'category_id' => $category->id,
        'is_active' => true,
    ]);

    $response = $this->get('/catalog?category=fiksi');

    $response->assertInertia(fn ($page) =>
        $page->has('books.data', 3)
    );
});

test('catalog can search books', function () {
    $category = Category::factory()->create();
    Book::factory()->create([
        'category_id' => $category->id,
        'title' => 'Belajar Laravel',
        'is_active' => true,
    ]);

    $response = $this->get('/catalog?search=Laravel');

    $response->assertInertia(fn ($page) =>
        $page->has('books.data', 1)
    );
});

Buat file tests/Feature/CartTest.php:

<?php

use App\\Models\\Book;
use App\\Models\\Category;

test('can add book to cart', function () {
    $category = Category::factory()->create();
    $book = Book::factory()->create([
        'category_id' => $category->id,
        'is_active' => true,
        'stock' => 10,
    ]);

    $response = $this->post('/cart/add', [
        'book_id' => $book->id,
        'quantity' => 2,
    ]);

    $response->assertRedirect();
    $response->assertSessionHas('success');

    expect(session('shopping_cart'))->toHaveKey($book->id);
    expect(session('shopping_cart')[$book->id]['quantity'])->toBe(2);
});

test('cannot add more than stock', function () {
    $category = Category::factory()->create();
    $book = Book::factory()->create([
        'category_id' => $category->id,
        'is_active' => true,
        'stock' => 5,
    ]);

    $response = $this->post('/cart/add', [
        'book_id' => $book->id,
        'quantity' => 10,
    ]);

    $response->assertSessionHas('error');
});

Buat file tests/Feature/CheckoutTest.php:

<?php

use App\\Models\\Book;
use App\\Models\\Category;
use App\\Models\\User;
use App\\Services\\CartService;

test('guest cannot access checkout', function () {
    $response = $this->get('/checkout');

    $response->assertRedirect('/login');
});

test('authenticated user can checkout', function () {
    $user = User::factory()->create();
    $category = Category::factory()->create();
    $book = Book::factory()->create([
        'category_id' => $category->id,
        'is_active' => true,
        'stock' => 10,
        'price' => 100000,
    ]);

    // Add to cart
    $this->post('/cart/add', [
        'book_id' => $book->id,
        'quantity' => 2,
    ]);

    $response = $this->actingAs($user)->post('/checkout', [
        'name' => 'John Doe',
        'email' => '[email protected]',
        'phone' => '08123456789',
        'address' => 'Jl. Test No. 123',
        'city' => 'Jakarta',
        'postal_code' => '12345',
    ]);

    $response->assertRedirect();

    $this->assertDatabaseHas('orders', [
        'user_id' => $user->id,
        'total_amount' => 200000,
    ]);

    // Stock should be reduced
    expect($book->fresh()->stock)->toBe(8);
});

Jalankan tests:

php artisan test

Atau dengan coverage:

php artisan test --coverage

Build untuk Production

Sebelum deploy, build assets untuk production:

npm run build

Ini akan:

  • Minify JavaScript dan CSS
  • Tree-shake unused code
  • Generate optimized bundles
  • Create manifest file

Hasil build ada di folder public/build.

Environment Configuration

Buat file .env.production sebagai template (jangan commit file .env yang sebenarnya):

APP_NAME="Toko Buku Digital"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://tokobuku.com

LOG_CHANNEL=stack
LOG_LEVEL=error

DB_CONNECTION=mysql
DB_HOST=your-db-host
DB_PORT=3306
DB_DATABASE=tokobuku_prod
DB_USERNAME=your-db-user
DB_PASSWORD=your-db-password

SESSION_DRIVER=redis
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis

REDIS_HOST=your-redis-host
REDIS_PASSWORD=null
REDIS_PORT=6379

MAIL_MAILER=smtp
MAIL_HOST=your-mail-host
MAIL_PORT=587
MAIL_USERNAME=your-mail-user
MAIL_PASSWORD=your-mail-password
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="${APP_NAME}"

Poin penting:

  • APP_DEBUG=false untuk security
  • LOG_LEVEL=error agar log tidak membengkak
  • Gunakan Redis untuk session dan cache di production
  • Konfigurasi mail untuk notifikasi

Optimization Commands

Jalankan commands berikut sebelum deploy:

# Cache configuration
php artisan config:cache

# Cache routes
php artisan route:cache

# Cache views
php artisan view:cache

# Optimize autoloader
composer install --optimize-autoloader --no-dev

Untuk clear cache saat update:

php artisan optimize:clear

Deployment Checklist

Berikut checklist deployment:

Sebelum Deploy:

  • [ ] Semua tests pass
  • [ ] Build assets production
  • [ ] Update .env untuk production
  • [ ] Backup database jika update

Server Requirements:

  • [ ] PHP 8.2+ dengan extensions: BCMath, Ctype, Fileinfo, JSON, Mbstring, OpenSSL, PDO, Tokenizer, XML
  • [ ] Composer
  • [ ] Node.js 18+ (untuk build)
  • [ ] MySQL 8+ atau PostgreSQL
  • [ ] Redis (recommended)
  • [ ] Nginx atau Apache

Deployment Steps:

  1. Clone atau pull latest code
git pull origin main

  1. Install dependencies
composer install --optimize-autoloader --no-dev

  1. Build assets
npm ci
npm run build

  1. Run migrations
php artisan migrate --force

  1. Clear and rebuild cache
php artisan optimize:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache

  1. Create storage link
php artisan storage:link

  1. Set permissions
chmod -R 775 storage bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache

Nginx Configuration

Contoh konfigurasi Nginx:

server {
    listen 80;
    server_name tokobuku.com www.tokobuku.com;
    root /var/www/tokobuku/public;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";

    index index.php;

    charset utf-8;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    error_page 404 /index.php;

    location ~ \\.php$ {
        fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\\.(?!well-known).* {
        deny all;
    }

    # Cache static assets
    location ~* \\.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}

SSL dengan Let's Encrypt

Install SSL certificate gratis:

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d tokobuku.com -d www.tokobuku.com

Monitoring dan Logging

Beberapa tools yang recommended:

Laravel Telescope untuk debugging (hanya di local/staging):

composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate

Laravel Pulse untuk monitoring production:

composer require laravel/pulse
php artisan vendor:publish --provider="Laravel\\Pulse\\PulseServiceProvider"
php artisan migrate

Sentry untuk error tracking:

composer require sentry/sentry-laravel
php artisan sentry:publish --dsn=your-sentry-dsn

Deployment Platforms

Beberapa opsi platform untuk deploy:

Laravel Forge - Managed server deployment

  • Mudah setup server di DigitalOcean, AWS, dll
  • Auto-deployment dari Git
  • SSL, monitoring, queue workers

Laravel Vapor - Serverless deployment di AWS

  • Auto-scaling
  • Zero downtime deployments
  • Managed infrastructure

Vercel/Railway - Simple deployment

  • Git push to deploy
  • Free tier available
  • Good for small projects

VPS Manual - Full control

  • DigitalOcean, Linode, Vultr
  • Lebih murah untuk traffic besar
  • Butuh maintenance manual

Tips Production

Beberapa tips untuk production:

Security:

  • Selalu gunakan HTTPS
  • Set APP_DEBUG=false
  • Gunakan strong database password
  • Update dependencies regularly
  • Implement rate limiting

Performance:

  • Gunakan Redis untuk cache dan session
  • Enable OPcache di PHP
  • Gunakan CDN untuk static assets
  • Optimize images sebelum upload
  • Implement lazy loading untuk images

Maintenance:

  • Setup automated backups
  • Monitor error logs
  • Setup uptime monitoring
  • Document deployment process

Penutup

Selamat! Kamu telah berhasil menyelesaikan tutorial lengkap membangun aplikasi Toko Buku Digital dengan Laravel 12, Inertia.js, dan React.

Yang sudah kamu pelajari:

  1. Setup Laravel 12 dengan React Starter Kit
  2. Database design dengan migrations, models, dan relationships
  3. Membuat layout dan halaman dengan React components
  4. Katalog dengan search, filter, sorting, dan pagination
  5. Detail buku dengan related products
  6. Shopping cart dengan session storage
  7. Checkout flow dengan validation dan order creation
  8. Admin panel dengan dashboard dan CRUD
  9. Polish UI dengan toast, loading states, dan error handling
  10. Testing dan deployment

Pengembangan Selanjutnya:

Aplikasi ini masih bisa dikembangkan dengan fitur-fitur seperti:

  • Payment gateway integration (Midtrans, Xendit)
  • Email notifications untuk order
  • Wishlist feature
  • Review dan rating buku
  • Coupon dan discount system
  • Multi-vendor marketplace
  • PWA support
  • Search dengan Meilisearch/Algolia

Semua fondasi sudah ada. Kamu tinggal extend sesuai kebutuhan.

Terima kasih sudah mengikuti tutorial ini. Semoga bermanfaat untuk perjalanan kamu sebagai developer. Kalau ada pertanyaan atau ingin belajar lebih lanjut, kunjungi BuildWithAngga untuk kelas dan resources lainnya.

Happy coding! 🚀