Kita sebelumnya sudah membuat Autentikasi SPA. Kali ini kita akan melanjutkan belajar kita tentang tutorial lengkap CRUD sekaligus Upload Gambar Produk menggunakan Sanctum sebagai autentikasi dan React sebagai Frontend nya.

Bagi yang masih belum membaca tutorial sebelumnya tentang Autentikasi SPA dengan Laravel Sanctum disarankan untuk membaca karena ada beberapa bagian yang tidak kita tulis kembali seperti installasi dan authentikasi. Dalam tutorial ini kita ambil studi kasus CRUD untuk  mengelola data produk, termasuk mengunggah dan menampilkan gambar produk. 

Bagian 1: CRUD Backend Laravel

Kita akan mulai dengan menyiapkan model, migrasi, controller, dan rute di sisi Laravel.

Buat Model dan Migrasi Produk:

php artisan make:model Product -m

Perintah ini akan membuat file migration dan Model Product.php. Buka file migrasi yang baru dibuat (ada di database/migrations/xxxx_xx_xx_xxxxxx_create_products_table.php) dan edit menjadi berikut:

// database/migrations/xxxx_xx_xx_xxxxxx_create_products_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('products', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->text('description')->nullable();
            $table->decimal('price', 8, 2); // Contoh: 999999.99
            $table->string('image_path')->nullable(); // Path ke gambar
            $table->timestamps();
        });
    }

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

Sekarang, buka model app/Models/Product.php dan tambahkan properti $fillable untuk mass assignment sebagai berikut:

<?php

namespace App\Models;

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

class Product extends Model
{
    use HasFactory;

    protected $fillable = [
        'name',
        'description',
        'price',
        'image_path',
    ];
}

Setup Penyimpanan File (Storage Link):

Kita akan menyimpan gambar produk di storage/app/public/products. Agar file ini bisa diakses dari web, buat symbolic link:

php artisan storage:link

Ini akan membuat link dari public/storage ke storage/app/public.

Buat ProductController:

php artisan make:controller Api/ProductController --model=Product

Di file controller ini nanti kita akan melakukan Restful API untuk menampilkan, menambah dan melakukan edit dari produk. Isi app/Http/Controllers/Api/ProductController.php:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; // Untuk operasi file
use Illuminate\Support\Facades\Validator; // Untuk validasi

class ProductController extends Controller
{
    // Menampilkan semua produk
    public function index()
    {
        $products = Product::orderBy('created_at', 'desc')->get()->map(function ($product) {
            if ($product->image_path) {
                $product->image_url = Storage::url($product->image_path); // Ini penting!
            } else {
                $product->image_url = null; // Pastikan ada jika path kosong
            }
            return $product;
        });
        return response()->json($products);
    }

    // Menyimpan produk baru
    public function store(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'name' => 'required|string|max:255',
            'description' => 'nullable|string',
            'price' => 'required|numeric|min:0',
            'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg|max:2048', // Max 2MB
        ]);

        if ($validator->fails()) {
            return response()->json($validator->errors(), 422);
        }

        $data = $request->only(['name', 'description', 'price']);

        if ($request->hasFile('image')) {
            $path = $request->file('image')->store('products', 'public'); // Simpan di storage/app/public/products
            $data['image_path'] = $path;
        }

        $product = Product::create($data);

        if ($product->image_path) {
            $product->image_url = Storage::url($product->image_path);
        }

        return response()->json($product, 201);
    }

    // Menampilkan satu produk
    public function show(Product $product)
    {
        if ($product->image_path) {
            $product->image_url = Storage::url($product->image_path); // Penting!
        } else {
            $product->image_url = null;
        }
        return response()->json($product);
    }

    // Mengupdate produk
    public function update(Request $request, Product $product)
    {
        $validator = Validator::make($request->all(), [
            'name' => 'sometimes|required|string|max:255',
            'description' => 'nullable|string',
            'price' => 'sometimes|required|numeric|min:0',
            'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg|max:2048',
        ]);

        if ($validator->fails()) {
            return response()->json($validator->errors(), 422);
        }

        $data = $request->only(['name', 'description', 'price']);

        if ($request->hasFile('image')) {
            // Hapus gambar lama jika ada
            if ($product->image_path) {
                Storage::disk('public')->delete($product->image_path);
            }
            // Simpan gambar baru
            $path = $request->file('image')->store('products', 'public');
            $data['image_path'] = $path;
        } elseif ($request->input('remove_image') == 'true' && $product->image_path) {
            // Jika ada flag untuk hapus gambar dan gambar ada
            Storage::disk('public')->delete($product->image_path);
            $data['image_path'] = null;
        }


        $product->update($data);

        if ($product->image_path) {
            $product->image_url = Storage::url($product->image_path);
        }


        return response()->json($product);
    }

    // Menghapus produk
    public function destroy(Product $product)
    {
        // Hapus gambar dari storage jika ada
        if ($product->image_path) {
            Storage::disk('public')->delete($product->image_path);
        }

        $product->delete();

        return response()->json(null, 204);
    }
}

Semua output dalam bentuk format JSON yang kemudian kita kirim ke frontend untuk difetch.

Definisikan Rute API untuk Produk:

Buka routes/api.php dan tambahkan rute untuk produk. Rute ini akan dilindungi oleh auth:sanctum

// routes/api.php
use App\Http\Controllers\Api\ProductController; 

Route::middleware('auth:sanctum')->group(function () {

    // Rute untuk Produk
    Route::get('/products', [ProductController::class, 'index']);
    Route::post('/products', [ProductController::class, 'store']);
    Route::get('/products/{product}', [ProductController::class, 'show']);
    Route::post('/products/{product}', [ProductController::class, 'update']); 
    Route::delete('/products/{product}', [ProductController::class, 'destroy']);
});

Kita menggunakan POST untuk rute update agar lebih mudah menangani FormData dari frontend yang mungkin berisi file. 

Bagian Backend Selesai. Kita lanjut ke bagian frontend. 

 

Bagian 2: Penyiapan Frontend dengan React ⚛️

Sekarang kita akan membuat komponen dan logika di sisi React untuk mengelola produk.

Buat Halaman dan Komponen Produk:

Kita akan membuat beberapa file di dalam folder src/pages dan src/components (buat jika belum ada).

  • src/pages/ProductListPage.jsx
  • src/pages/ProductCreatePage.jsx
  • src/pages/ProductEditPage.jsx

Tambahkan Rute Produk di App.jsx:

Buka src/App.jsx dan tambahkan rute baru di dalam <Route element={<ProtectedRoute />}>:

// src/App.jsx
import React from 'react';
import { Routes, Route, Navigate, Link } from 'react-router-dom';
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import DashboardPage from './pages/DashboardPage';
import ProtectedRoute from './components/ProtectedRoute';
import { useAuth } from './contexts/AuthContext';

import ProductListPage from './pages/ProductListPage.jsx';
import ProductCreatePage from './pages/ProductCreatePage.jsx';
import ProductEditPage from './pages/ProductEditPage.jsx';

// Komponen untuk rute yang hanya bisa diakses jika BELUM login
const GuestRoute = ({ children }) => {
    const { user, isLoading } = useAuth();
    if (isLoading) return <div className="flex justify-center items-center h-screen">Memuat...</div>;
    return !user ? children : <Navigate to="/dashboard" replace />;
};

function App() {
  return (
    
    <>
      <Routes>
        <Route path="/" element={<HomePage />} />

        {/* Rute untuk tamu (belum login) */}
        <Route path="/login" element={<GuestRoute><LoginPage /></GuestRoute>} />
        <Route path="/register" element={<GuestRoute><RegisterPage /></GuestRoute>} />

        {/* Rute Terproteksi (sudah login) */}
        <Route element={<ProtectedRoute />}>
          <Route path="/products" element={<ProductListPage />} />
          <Route path="/products/create" element={<ProductCreatePage />} />
          <Route path="/products/edit/:productId" element={<ProductEditPage />} />

          <Route path="/dashboard" element={<DashboardPage />} />
        </Route>

        {/* Rute fallback atau halaman 404 */}
        <Route path="*" element={
            <div className="text-center py-20">
                <h1 className="text-4xl font-bold mb-4">404 - Halaman Tidak Ditemukan</h1>
                <Link to="/" className="text-indigo-600 hover:underline">Kembali ke Beranda</Link>
            </div>
        } />
      </Routes>
    </>
  );
}

export default App;

Komponen ProductListPage.jsx:

Halaman ini akan menampilkan daftar produk dan tombol untuk aksi. Isi dengan file berikut:

// src/pages/ProductListPage.jsx
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import apiClient, { getCsrfCookie } from '../lib/axios';
// import { useAuth } from '../contexts/AuthContext'; // Uncomment jika Anda memerlukan info user

const ProductListPage = () => {
    const [products, setProducts] = useState([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);
    // const { user } = useAuth(); // Uncomment jika perlu

    const fetchProducts = async () => {
        setLoading(true);
        setError(null);
        try {
            const response = await apiClient.get('/api/products');
            setProducts(response.data);
            // Untuk Debugging: Lihat struktur data produk yang diterima dari API
            // console.log('Produk diterima:', response.data);
        } catch (err) {
            setError('Gagal memuat produk.');
            console.error("Error fetching products:", err);
        } finally {
            setLoading(false);
        }
    };

    useEffect(() => {
        fetchProducts();
    }, []);

    const handleDelete = async (productId) => {
        if (!window.confirm('Apakah Anda yakin ingin menghapus produk ini?')) {
            return;
        }
        try {
            await getCsrfCookie();
            await apiClient.delete(`/api/products/${productId}`);
            setProducts(products.filter(product => product.id !== productId));
            alert('Produk berhasil dihapus!');
        } catch (err) {
            setError('Gagal menghapus produk.');
            console.error("Error deleting product:", err);
            alert('Gagal menghapus produk.');
        }
    };

    if (loading) return <div className="text-center py-10">Memuat produk...</div>;
    if (error) return <div className="text-center py-10 text-red-500">{error}</div>;

    // Pastikan VITE_API_BASE_URL sudah ada di .env.local frontend Anda
    // Contoh: VITE_API_BASE_URL=http://localhost:8000
    const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;

    // Jika apiBaseUrl tidak terdefinisi, berikan default atau tampilkan error
    if (!apiBaseUrl) {
        console.error("VITE_API_BASE_URL tidak terdefinisi di file .env.local frontend Anda!");
        return <div className="text-center py-10 text-red-500">Konfigurasi API base URL bermasalah.</div>;
    }

    return (
        <div className="container mx-aup-4 max-w-3xl mx-auto">
            <div className="flex justify-between items-center mb-6">
                <h1 className="text-3xl font-bold text-gray-800">Daftar Produk</h1>
                <Link
                    to="/products/create"
                    className="bg-green-500 hover:bg-green-600 text-white font-semibold py-2 px-4 rounded-lg shadow-md transition duration-150"
                >
                    Tambah Produk Baru
                </Link>
            </div>

            {products.length === 0 && !loading ? (
                <p className="text-center text-gray-500">Belum ada produk.</p>
            ) : (
                <div className="grid grid-cols-2 gap-4 mt-6">
                    {products.map((product) => {
                        let imageUrl = null;
                        if (product.image_url) {
                            imageUrl = `${apiBaseUrl}${product.image_url}`;
                        } else if (product.image_path) {
                            console.warn(`Produk ID ${product.id} menggunakan fallback image_path. Sebaiknya backend menyediakan image_url.`);
                            imageUrl = `${apiBaseUrl}/storage/${product.image_path}`;
                        }

                        return (
                            <div key={product.id} className="bg-white rounded-lg shadow-lg overflow-hidden">
                                {imageUrl ? (
                                    <img
                                        src={imageUrl}
                                        alt={product.name}
                                        className="w-full h-48 object-cover"
                                        onError={(e) => {
                                            // Jika gambar gagal dimuat, Anda bisa menggantinya dengan placeholder
                                            e.target.onerror = null; // Mencegah loop error jika placeholder juga gagal
                                            e.target.src = "https://placehold.co/300x200.png?text=Gambar+Error";
                                            console.error(`Gagal memuat gambar: ${imageUrl} untuk produk ${product.name}`);
                                        }}
                                    />
                                ) : (
                                    <div className="w-full h-48 bg-gray-200 flex items-center justify-center">
                                        <span className="text-gray-400">Tidak ada gambar</span>
                                    </div>
                                )}
                                <div className="p-4">
                                    <h2 className="text-xl font-semibold text-gray-800 mb-1">{product.name}</h2>
                                    <p className="text-gray-600 text-sm mb-2 truncate h-10">{product.description || 'Tidak ada deskripsi'}</p>
                                    <p className="text-lg font-bold text-indigo-600 mb-3">
                                        Rp {Number(product.price).toLocaleString('id-ID')}
                                    </p>
                                    <div className="flex justify-between items-center space-x-2">
                                        <Link
                                            to={`/products/edit/${product.id}`}
                                            className="text-sm bg-blue-500 hover:bg-blue-600 text-white py-1 px-3 rounded-md transition duration-150"
                                        >
                                            Edit
                                        </Link>
                                        <button
                                            onClick={() => handleDelete(product.id)}
                                            className="text-sm bg-red-500 hover:bg-red-600 text-white py-1 px-3 rounded-md transition duration-150"
                                        >
                                            Hapus
                                        </button>
                                    </div>
                                </div>
                            </div>
                        );
                    })}
                </div>
            )}
        </div>
    );
};

export default ProductListPage;

Komponen ProductCreatePage.jsx:

Komponen ini kita gunakan untuk create produk. Isi file tersebut dengan code berikut:

// src/pages/ProductCreatePage.jsx
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import apiClient, { getCsrfCookie } from '../lib/axios';

const ProductCreatePage = () => {
    const [name, setName] = useState('');
    const [description, setDescription] = useState('');
    const [price, setPrice] = useState('');
    const [image, setImage] = useState(null);
    const [imagePreview, setImagePreview] = useState(null);
    const [errors, setErrors] = useState({});
    const [isSubmitting, setIsSubmitting] = useState(false);
    const navigate = useNavigate();

    const handleImageChange = (e) => {
        const file = e.target.files[0];
        if (file) {
            setImage(file);
            setImagePreview(URL.createObjectURL(file));
        } else {
            setImage(null);
            setImagePreview(null);
        }
    };

    const handleSubmit = async (e) => {
        e.preventDefault();
        setIsSubmitting(true);
        setErrors({});

        const formData = new FormData();
        formData.append('name', name);
        formData.append('description', description);
        formData.append('price', price);
        if (image) {
            formData.append('image', image);
        }

        try {
            await getCsrfCookie();
            await apiClient.post('/api/products', formData, {
                headers: {
                    'Content-Type': 'multipart/form-data',
                },
            });
            alert('Produk berhasil ditambahkan!');
            navigate('/products');
        } catch (err) {
            if (err.response && err.response.status === 422) {
                setErrors(err.response.data.errors);
            } else {
                alert('Gagal menambahkan produk. Silakan coba lagi.');
                console.error(err);
            }
        } finally {
            setIsSubmitting(false);
        }
    };

    return (
        <div className="container mx-auto p-4 sm:p-6 lg:p-8 max-w-2xl">
            <div className="flex justify-between items-center mb-6">
                <h1 className="text-3xl font-bold text-gray-800">Tambah Produk Baru</h1>
                <Link to="/products" className="text-indigo-600 hover:underline">
                    &larr; Kembali ke Daftar Produk
                </Link>
            </div>

            <form onSubmit={handleSubmit} className="bg-white p-6 rounded-lg shadow-xl space-y-6">
                <div>
                    <label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">Nama Produk</label>
                    <input
                        type="text"
                        id="name"
                        value={name}
                        onChange={(e) => setName(e.target.value)}
                        required
                        className="w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
                    />
                    {errors.name && <p className="text-red-500 text-xs mt-1">{errors.name[0]}</p>}
                </div>

                <div>
                    <label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">Deskripsi</label>
                    <textarea
                        id="description"
                        value={description}
                        onChange={(e) => setDescription(e.target.value)}
                        rows="4"
                        className="w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
                    ></textarea>
                    {errors.description && <p className="text-red-500 text-xs mt-1">{errors.description[0]}</p>}
                </div>

                <div>
                    <label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">Harga</label>
                    <input
                        type="number"
                        id="price"
                        value={price}
                        onChange={(e) => setPrice(e.target.value)}
                        required
                        min="0"
                        step="0.01"
                        className="w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
                    />
                    {errors.price && <p className="text-red-500 text-xs mt-1">{errors.price[0]}</p>}
                </div>

                <div>
                    <label htmlFor="image" className="block text-sm font-medium text-gray-700 mb-1">Gambar Produk</label>
                    <input
                        type="file"
                        id="image"
                        onChange={handleImageChange}
                        accept="image/*"
                        className="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100"
                    />
                    {imagePreview && (
                        <div className="mt-4">
                            <img src={imagePreview} alt="Preview" className="h-40 w-auto rounded-md shadow-sm" />
                        </div>
                    )}
                    {errors.image && <p className="text-red-500 text-xs mt-1">{errors.image[0]}</p>}
                </div>

                <div>
                    <button
                        type="submit"
                        disabled={isSubmitting}
                        className="w-full bg-green-600 hover:bg-green-700 text-white font-semibold py-3 px-4 rounded-lg shadow-md transition duration-150 disabled:opacity-50"
                    >
                        {isSubmitting ? 'Menyimpan...' : 'Simpan Produk'}
                    </button>
                </div>
            </form>
        </div>
    );
};

export default ProductCreatePage;

Saat mengirim file, kita menggunakan FormData dan mengatur header Content-Type menjadi multipart/form-data.

Komponen ProductEditPage.jsx:

File ini kita gunakan untuk mengedit produk yang sudah kita create sebelumnya. Isi dengan code berikut:

// src/pages/ProductEditPage.jsx
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import apiClient, { getCsrfCookie } from '../lib/axios';

const ProductEditPage = () => {
    const { productId } = useParams();
    const navigate = useNavigate();

    const [name, setName] = useState('');
    const [description, setDescription] = useState('');
    const [price, setPrice] = useState('');
    const [currentImagePath, setCurrentImagePath] = useState(null);
    const [image, setImage] = useState(null);
    const [imagePreview, setImagePreview] = useState(null);
    const [removeImage, setRemoveImage] = useState(false); // Untuk opsi hapus gambar

    const [errors, setErrors] = useState({});
    const [loading, setLoading] = useState(true);
    const [isSubmitting, setIsSubmitting] = useState(false);

    const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';

    useEffect(() => {
        const fetchProduct = async () => {
            setLoading(true);
            try {
                const response = await apiClient.get(`/api/products/${productId}`);
                const product = response.data;
                setName(product.name);
                setDescription(product.description || '');
                setPrice(product.price.toString());
                if (product.image_url) {
                     // Pastikan image_url adalah path relatif jika backend tidak mengembalikan full URL
                    const fullImageUrl = product.image_url.startsWith('http') ? product.image_url : `<span class="math-inline">\{apiBaseUrl\}</span>{product.image_url.startsWith('/') ? '' : '/'}${product.image_url.replace(apiBaseUrl, '')}`;
                    setCurrentImagePath(fullImageUrl);
                    setImagePreview(fullImageUrl); // Awalnya preview adalah gambar saat ini
                }
            } catch (err) {
                console.error("Gagal memuat produk:", err);
                alert('Gagal memuat data produk.');
                navigate('/products');
            } finally {
                setLoading(false);
            }
        };
        fetchProduct();
    }, [productId, navigate, apiBaseUrl]);

    const handleImageChange = (e) => {
        const file = e.target.files[0];
        if (file) {
            setImage(file);
            setImagePreview(URL.createObjectURL(file));
            setRemoveImage(false); // Jika gambar baru dipilih, jangan hapus gambar
        } else {
            // Jika pemilihan file dibatalkan, kembalikan preview ke gambar saat ini jika ada
            setImage(null);
            setImagePreview(currentImagePath);
        }
    };

    const handleRemoveImageToggle = () => {
        if (!removeImage && currentImagePath) { // Hanya jika mau menghapus gambar yang ada
            setRemoveImage(true);
            setImage(null); // Hapus pilihan file baru jika ada
            setImagePreview(null); // Hapus preview
        } else if (removeImage) { // Jika batal menghapus
            setRemoveImage(false);
            setImagePreview(currentImagePath); // Kembalikan preview ke gambar saat ini
        }
    }


    const handleSubmit = async (e) => {
        e.preventDefault();
        setIsSubmitting(true);
        setErrors({});

        const formData = new FormData();
        formData.append('name', name);
        formData.append('description', description);
        formData.append('price', price);
        if (image) { // Jika ada gambar baru yang dipilih
            formData.append('image', image);
        }
        if (removeImage && !image) { // Jika user memilih untuk hapus gambar dan tidak ada gambar baru
            formData.append('remove_image', 'true');
        }
        // Penting: Karena kita menggunakan POST untuk update di Laravel, kita tidak perlu _method: 'PUT'
        // jika rute Laravel-nya sudah POST /api/products/{product} untuk update.

        try {
            await getCsrfCookie();
            await apiClient.post(`/api/products/${productId}`, formData, { // Menggunakan POST
                headers: {
                    'Content-Type': 'multipart/form-data',
                },
            });
            alert('Produk berhasil diperbarui!');
            navigate('/products');
        } catch (err) {
            if (err.response && err.response.status === 422) {
                setErrors(err.response.data.errors);
            } else {
                alert('Gagal memperbarui produk. Silakan coba lagi.');
                console.error(err);
            }
        } finally {
            setIsSubmitting(false);
        }
    };

    if (loading) return <div className="text-center py-10">Memuat data produk...</div>;

    return (
        <div className="container mx-auto p-4 sm:p-6 lg:p-8 max-w-2xl">
            <div className="flex justify-between items-center mb-6">
                <h1 className="text-3xl font-bold text-gray-800">Edit Produk</h1>
                 <Link to="/products" className="text-indigo-600 hover:underline">
                    &larr; Kembali ke Daftar Produk
                </Link>
            </div>

            <form onSubmit={handleSubmit} className="bg-white p-6 rounded-lg shadow-xl space-y-6">
                <div>
                    <label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">Nama Produk</label>
                    <input
                        type="text" id="name" value={name} onChange={(e) => setName(e.target.value)} required
                        className="w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
                    />
                    {errors.name && <p className="text-red-500 text-xs mt-1">{errors.name[0]}</p>}
                </div>

                <div>
                    <label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">Deskripsi</label>
                    <textarea
                        id="description" value={description} onChange={(e) => setDescription(e.target.value)} rows="4"
                        className="w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
                    ></textarea>
                    {errors.description && <p className="text-red-500 text-xs mt-1">{errors.description[0]}</p>}
                </div>

                <div>
                    <label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">Harga</label>
                    <input
                        type="number" id="price" value={price} onChange={(e) => setPrice(e.target.value)} required min="0" step="0.01"
                        className="w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
                    />
                    {errors.price && <p className="text-red-500 text-xs mt-1">{errors.price[0]}</p>}
                </div>

                <div>
                    <label htmlFor="image" className="block text-sm font-medium text-gray-700 mb-1">Gambar Produk (Opsional)</label>
                    <input
                        type="file" id="image" onChange={handleImageChange} accept="image/*"
                        className="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100"
                    />
                    {imagePreview && (
                        <div className="mt-4">
                            <p className="text-xs text-gray-500 mb-1">Preview / Gambar Saat Ini:</p>
                            <img src={imagePreview} alt="Preview" className="h-40 w-auto rounded-md shadow-sm" />
                        </div>
                    )}
                    {currentImagePath && !image && ( // Tampilkan opsi hapus hanya jika ada gambar saat ini dan tidak ada gambar baru yang dipilih
                         <div className="mt-2">
                            <label className="flex items-center text-sm text-gray-600">
                                <input type="checkbox" checked={removeImage} onChange={handleRemoveImageToggle} className="mr-2 h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"/>
                                Hapus gambar saat ini
                            </label>
                        </div>
                    )}
                    {errors.image && <p className="text-red-500 text-xs mt-1">{errors.image[0]}</p>}
                </div>

                <div>
                    <button
                        type="submit" disabled={isSubmitting || loading}
                        className="w-full bg-green-600 hover:bg-green-700 text-white font-semibold py-3 px-4 rounded-lg shadow-md transition duration-150 disabled:opacity-50"
                    >
                        {isSubmitting ? 'Memperbarui...' : 'Simpan Perubahan'}
                    </button>
                </div>
            </form>
        </div>
    );
};

export default ProductEditPage;

Testing 🚀

Lakukan testing dengan ketentuan berikut:

1. Pastikan server Laravel Anda berjalan (php artisan serve)
2. Pastikan server development React Anda berjalan (npm run dev).
3. Buka http://localhost:5173 (atau port React Anda) di browser.
4. Login jika belum. Jika sudah, langsung Navigasi ke /products.
5. Create: Coba tambahkan produk baru dengan mengisi semua field dan mengunggah gambar. Periksa apakah produk muncul di daftar dan gambar tersimpan di storage/app/public/products (dan juga public/storage/products) di backend Laravel.

 

Jika berhasil, akan menjadi seperti berikut:

List Product CRUD Laravel React

Ketik URL http://localhost:5173/products/create

Create Produk CRUD Laravel React

Edit Salah satu Produk

Edit Produk CRUD Laravel React

Selamat Mencobaa !!!!

Semoga berhasil. Jika ada error, bisa kirim komentar :)