Xây dựng website bán hàng bằng Laravel - Thiết lập thông tin Sản phẩm

TQH 2023-08-30 17:15:33

Trong bài này chúng ta sẽ cùng tìm hiểu cách xây dựng các thông tin cơ bản cho sản phẩm bao gồm tên, giá bán, mô tả... để phục vụ cho việc quảng bá sản phẩm tới khách hàng.

E-commerce với Laravel là loạt bài ghi lại toàn bộ quá trình xây dựng hệ thống thương mại điện tử được thực hiện bởi Transmoni team nhắm hướng dẫn mọi người làm quen với Laravel, Livewire, Alpine.js và Tailwind CSS. Hiện dự án đang được cập nhật liên tục, toàn bộ mã nguồn của dự án sẽ được công khai miễn phí theo hình thức mã nguồn mở trên trang Github của Transmoni sau khi hoàn tất loạt bài hướng dẫn này.

Như thường lệ trước hết chúng ta cần tiến hành tạo file migration để thiết lập bảng cơ sở dữ liệu chứa thông tin sản phẩm và một Model để quản lý bảng này kèm theo một Seeder class để tạo nhanh các bản ghi trên cơ sở dữ liệu. Sử dụng lệnh make:model với tùy chọn -mfs (migration, factory, seeder) để bắt đầu:

php artisan make:model Product -mfs

Ngay lập tức chúng ta sẽ có các file mới được khởi tạo bao gồm:

  1. App/Models/Product.php

  2. database/factories/ProductFactory.php

  3. database/migrations/xxxx_xx_xx_xxxxxx_create_products_table.php

  4. database/seeders/ProductSeeder.php

Tiếp đến tiến hành khai báo các cột dữ liệu bao gồm tên, giá bán, mô tả cho sản phẩm tại file migration database/migrations/xxxx_xx_xx_xxxxxx_create_products_table.php như sau:

public function up()
{
    Schema::create('products', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->string('slug')->unique();
        $table->longText('description')->nullable();
        $table->decimal('price', 12)->default(0);
        $table->string('status')->default('pending');
        $table->timestamps();
    });
}

Cột slug phục vụ cho việc tạo đường dẫn thân thiện, theo đó bạn có thể truy cập tới trang thông tin của một sản phẩm bằng đường dẫn yourdomain.com/apple-iphone-13-pro thay vì yourdomain.com/12345.

Và tiến hành khai báo dữ liệu cho các cột thông tin trên cơ sở dữ liệu tại database/factories/ProductFactory.php:

public function definition()
{
    return [
        'name' => $this->faker->sentence,
        'slug' => $this->faker->slug,
        'description' => $this->faker->text,
        'price' => $this->faker->numberBetween(10000, 100000),
    ];
}

Trên đây tôi sử dụng $this->faker để trỏ đến thư viện Faker được cài đặt sẵn cùng Laravel với mục đích là để tạo các đoạn dữ liệu ngẫu nhiên. Và tiến hành gọi Factory tại database/seeders/ProductSeeder.php để tạo 100 sản phẩm với thông tin ngẫu nhiên như sau:

public function run()
{
    Product::factory(100)->create();
}

Đừng quên khai báo Seeder class này tại database/seeders/DatabaseSeeder.php nhé:

public function run()
{
    $this->call(UserSeeder::class);
    $this->call(CategorySeeder::class);
    $this->call(ProductSeeder::class);
}

Và tiến hành chạy lệnh db:seeder để tạo dữ liệu

php artisan db:seed

Vậy là chúng ta đã hoàn tất việc tạo bảng cơ sở dữ liệu với các cột thông tin cơ bản cho sản phẩm, tiếp đến hãy cùng tạo Livewire component để thực hiện các thao tác quản lý sản phẩm.

Tạo Livewire component quản lý danh sách sản phẩm

Với các bản ghi cơ sở dữ liệu được tạo bằng Laravel Seeder chúng ta tiến hành in ra danh sách sản phẩm bằng một Livewire component và đặt tên cho nó là ProductList (giống như CategoryList mà chúng ta đã thực hiện trước đây).

php artisan livewire:make Admin\\ProductList

Chúng ta sẽ sử dụng component này như là một full-page component do đó cần tiến hành khai báo route cho nó trong phần admin tại routes/web.php:

Route::group([
    'prefix' => 'admin',
    'as' => 'admin.',
    'middleware' => ['auth', 'isAdmin'],
], function () {
    ...
    Route::get('products', ProductList::class)->name('products.index');
});

Tiến hành truy cập đến đường dẫn admin/products chúng ta sẽ thấy... lỗi, đúng rồi bạn còn cẩn phải khai báo layout cho component này bởi nó là một full-page component, nhớ chứ? Tại app/Http/Livewire/Admin/ProductList.php chúng ta thực hiện như sau:

public function render()
{
    return view('livewire.admin.product-list')->layout('layouts.admin');
}

Ok, refresh lại nào! Bạn có thấy giao diện admin quen thuộc của chúng ta rồi chứ và dĩ nhiên nội dung thì chưa có gì cả. Tiến hành cấp dữ liệu cho nó nhé:

public function render()
{
    return view('livewire.admin.product-list', [
        'products' => Product::query()->latest()->paginate(),
    ])->layout('layouts.admin');
}

Với đoạn code trên tôi đã thực hiện gán dữ liệu danh sách sản phẩm cho biến products được sắp xếp theo thứ tự từ mới nhất trở xuống với phương thức latest() và thực hiện phân trang bằng phương thức paginate(). Và ở phần view của component này, chúng ta tạo bảng danh sách như sau tại resources/views/livewire/admin/product-list.blade.php:

<div>
    <div class="md:flex md:items-center md:justify-between">
        <div class="flex-1 min-w-0">
            <h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">Products</h2>
        </div>
    </div>

    <div class="mt-6 flex flex-col">
        <div class="-my-2 overflow-x-auto -mx-4 sm:-mx-6 lg:-mx-8">
            <div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
                <div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
                    <table class="min-w-full divide-y divide-gray-200">
                        <thead class="bg-gray-50">
                        <tr>
                            <th scope="col" class="px-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
                            <th scope="col" class="px-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Price</th>
                        </tr>
                        </thead>
                        <tbody>
                        @foreach($products as $product)
                            <tr class="odd:bg-white even:bg-gray-100">
                                <td class="px-4 sm:px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
                                    <a href="{{ route('admin.products.edit', $product) }}" class="hover:text-indigo-500">
                                        {{ $product->name }}
                                    </a>
                                </td>
                                <td class="px-4 sm:px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 tabular-nums">
                                    {{ $product->price }}
                                </td>
                            </tr>
                        @endforeach
                        </tbody>
                    </table>
                </div>
            </div>
        </div>
    </div>

    <div class="mt-6">
        {{ $products->links() }}
    </div>
</div>

Vậy là tạm ổn, tại trang admin/products của chúng ta lúc này sẽ có một bảng danh sách sản phẩm được sắp xếp theo thứ tự từ mới đến cũ và được phân trang.

Nào nào, giờ mới đến phần hay đây, hãy chú ý đến phần phân trang của chúng ta nhé! Nếu thực hiện chuyển đổi giữa các trang bạn sẽ thấy toàn bộ website của chúng ta bị reload lại mỗi lần chuyển trang đúng không? Và Livewire cung cấp cho chúng ta một trait có tên gọi là WithPagination. Trait này giúp cho việc chuyển đổi giữa các trang dữ liệu mà không cần phải reload lại toàn bộ trang. Nói cách khác là chỉ phần view của component có chứa phân trang được tải lại mà thôi. Hãy gán nó vào component của chúng ta và thử lại thao tác chuyển đổi giữa các trang xem kết quả thế nào nhé:

<?php

namespace App\Http\Livewire\Admin;

use Livewire\Component;
use Livewire\WithPagination;

class ProductList extends Component
{
    use WithPagination;

    public function render()
    {
        ...
    }
}

Bạn có thể tìm hiểu thêm về tính năng phân trang của Livewire tại https://laravel-livewire.com/docs/2.x/pagination

Tạo Livewire component để thêm mới sản phẩm

Phần tiếp theo của bài này chúng ta sẽ tạo một Livewire full-page component khác phục vụ cho việc thêm sản phẩm mới. Tiếp tục sử dụng lệnh livewire:make để tạo component này:

php artisan livewire:make ProductCreator

và khai báo route:

Route::group([
    'prefix' => 'admin',
    'as' => 'admin.',
    'middleware' => ['auth', 'isAdmin'],
], function () {
    ...
    Route::get('products', ProductList::class)->name('products.index');
    Route::get('products/create', ProductCreator::class)->name('products.create');
});

Tại component ProductCreator chúng ta tiến hành khai báo biến công khai $product và gán cho nó thành một Product model instance mới trong phần mount của component như sau:

class ProductCreator extends Component
{
    public Product $product;

    public function mount()
    {
        $this->product = new Product();
    }

    public function render()
    {
        return view('livewire.admin.product-creator')->layout('layouts.admin');
    }
}

Chúng ta sẽ tiếp tục tạo một child component để xử lý biểu mẫu cho phần thông tin sản phẩm và đặt tên nó là ProductInformationForm. Bằng việc tạo thêm child component này chúng ta có thể tái sử dụng nó khi muốn thực hiện thao tác chỉnh sửa thông tin sản phẩm, chi tiết sẽ có tại phần kế tiếp trong bài này, hãy tiếp tục với thao tác thêm mới đã nhé!

php artisan livewire:make ProductInformationForm

Tiếp đến chúng ta sẽ tiến hành khai báo các rules và một phương thức save để lưu lại thông tin sản phẩm cho component ProductInformationForm:

class ProductInformationForm extends Component
{
    public Product $product;

    protected function rules()
    {
        return [
            'product.name' => 'required|string',
            'product.slug' => ['required', 'string', Rule::unique('products', 'slug')->ignoreModel($this->product)],
            'product.price' => 'required|numeric',
            'product.description' => 'nullable|string',
        ];
    }

    public function save()
    {
        $this->validate();
        $this->product->save();
        return redirect()->route('admin.products');
    }

    public function render()
    {
        return view('livewire.admin.product-information-form');
    }
}

Và phần view cho component này chúng ta sẽ có các trường input để nhập thông tin như sau:

<div>
    <form wire:submit.prevent="save">
        <x-card class="-mx-4 mt-5 sm:-mx-0">
            <x-slot name="header">
                <div class="ml-4 mt-2">
                    <h3 class="text-lg leading-6 font-medium text-gray-900">Information</h3>
                </div>
            </x-slot>
            <x-slot name="content">
                <div class="grid grid-cols-1 gap-6">
                    {{-- Name --}}
                    <div>
                        <x-label for="name" value="{{ __('Name') }}" />
                        <x-input wire:model.defer="product.name" type="text" id="name" class="mt-1 block w-full sm:text-sm" placeholder="Enter product name" />
                        <x-input-error for="product.name" class="mt-2" />
                    </div>
                    {{-- Slug --}}
                    <div>
                        <x-label for="slug" value="{{ __('Slug') }}" />
                        <x-input wire:model.defer="product.slug" type="text" id="slug" class="mt-1 block w-full sm:text-sm" placeholder="Enter product slug" />
                        <x-input-error for="product.slug" class="mt-2" />
                    </div>
                    {{-- Price --}}
                    <div>
                        <x-label for="price" value="{{ __('Price') }}" />
                        <x-input wire:model.defer="product.price" type="number" step="any" id="price" class="mt-1 no-spinners sm:text-sm" placeholder="0.00" />
                        <x-input-error for="product.price" class="mt-2" />
                    </div>
                    {{-- Description --}}
                    <div>
                        <x-label for="description" value="{{ __('Description') }}" />
                        <x-textarea wire:model.defer="product.description" class="mt-1 block w-full sm:text-sm" />
                        <x-input-error for="product.description" class="mt-2" />
                    </div>
                </div>
            </x-slot>
            <x-slot name="footer">
                <div class="flex items-center justify-end">
                    <x-action-message on="saved" class="mr-2" />
                    <x-primary-button wire:loading.attr="disabled">
                        {{ __('Save') }}
                    </x-primary-button>
                </div>
            </x-slot>
        </x-card>
    </form>
</div>

Quay trở lại với phần view của component ProductCreator chúng ta tiến hành gọi phần view của component ProductInformationForm như sau:

<div>
    <div class="max-w-3xl mx-auto">
        <div class="md:flex md:items-center md:justify-between">
            <div class="flex-1 min-w-0">
                <h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">Create Product</h2>
            </div>
        </div>

        <livewire:admin.product-information-form :product="$product" />
    </div>
</div>

Xong rồi đấy, thử truy cập đến admin/products/create xem có gì nào!
 

Tèn ten, trông cũng ổn phết chứ nhỉ? Ngay bây giờ bạn đã có thể thêm sản phẩm mới với biểu mẫu này, hãy thử nhập thông tin sau đó nhấn save xem sao nhé! Nếu không có lỗi xảy ra hãy tiếp tục với phần tiếp theo của bài này.

Tạo Livewire component để chỉnh sửa thông tin sản phẩm

Tương tự như thêm mới sản phẩm với ProductCreator component chúng ta tiến hành tạo một full-page component dành riêng cho việc cập nhật thông tin sản phẩm hãy cùng đặt tên nó là ProductManager. Điểm khác biệt ở đây là chúng ta sẽ không chỉ tiến hành cập nhật mỗi thông tin sản phẩm mà còn các tính năng khác như thư viện media, tổ chức danh mục hoặc thương hiệu, quản lý thuộc tính và các biến thể... Những tính năng mà chúng ta không thể thao tác cùng lúc trong khi thêm sản phẩm mới vì lý do chưa có bản ghi sản phẩm trong cơ sở dữ liệu dẫn đến việc kết hợp dữ liệu ở các bảng khác sẽ không thể thực hiện được. Dĩ nhiên việc này hoàn toàn có thể khắc phục bằng cách tạo một bản ghi nháp trên cơ sở dữ liệu mỗi khi truy cập đến trang /admin/products/create nhưng nó sẽ khiến cho cơ sở dữ liệu của bạn nhanh chóng bị đầy do mỗi lần reload lại trang là lại phải tạo thêm một bản ghi nháp, và chúng ta không muốn tự spam hệ thống của mình một chút nào đúng không?

Bắt đầu tạo ProductManager component với lệnh livewire:make như sau:

php artisan livewire:make Admin\\ProductManager

Và tiến hành khai báo route cho nó:

Route::group([
    'prefix' => 'admin',
    'as' => 'admin.',
    'middleware' => ['auth', 'isAdmin'],
], function () {
    ...
    Route::get('products/create', ProductCreator::class)->name('products.create');
    Route::get('products/{product}', ProductManager::class)->name('products.edit');
});

Chúng ta sẽ sử dụng Route Model Binding cho component này nhé, bạn chỉ cần khai báo class properties cho tham số {product} của chúng ta trên route, Livewire sẽ tự động tìm theo route key name của model và gán nó vào biến cho bạn. Cách làm như sau tại app/Http/Livewire/Admin/ProductManager.php:

use App\Models\Product;
use Livewire\Component;

class ProductManager extends Component
{
    public Product $product;

    public function render()
    {
        return view('livewire.admin.product-manager')->layout('layouts.admin');
    }
}

Khác với ProductCreator component, tại đây chúng ta không cần gán Product model instance cho biến $product thông qua phương thức mount() nữa vì với Route Model Binding Laravel đã tự gán thay chúng ta rồi.

Tiếp đến tại phần view của ProductCreator component chúng ta sẽ chia layout thành grid với 3 cột, trong đó các thông tin chính sẽ nằm trong 2 cột đầu tiên và các thông tin phụ sẽ nằm trong cột còn lại. Thực hiện chia cột như sau tại resources/views/livewire/admin/product-creator.blade.php:

<div>
    <div class="md:flex md:items-center md:justify-between">
        <div class="flex-1 min-w-0">
            <h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">Update Product</h2>
        </div>
    </div>

    <div class="grid grid-cols-3 gap-6">
        <div class="col-span-3 xl:col-span-2">
            
        </div>

        <div class="col-span-3 xl:col-span-1">
            
        </div>
    </div>
</div>

Và chúng ta sẽ tiến hành gọi và đặt component ProductInformationForm trong phần chính của layout như sau:

<div class="grid grid-cols-3 gap-6">
    <div class="col-span-3 xl:col-span-2">
        <livewire:admin.product-information-form :product="$product" />
    </div>

    <div class="col-span-3 xl:col-span-1">
        
    </div>
</div>

Tiến hành truy cập và trang chỉnh sửa sản phẩm bằng cách từ danh sách sản phẩm nhấn vào tên của một sản phẩm bất kỳ chúng ta sẽ thấy như hình:

 

Tại đây, nếu thực hiện thao tác save, mặc dù mọi thứ vẫn hoạt động bình thường tuy nhiên bạn sẽ thấy toàn bộ trang của chúng ta bị reload lại đúng không? Đó là do chúng ta sử dụng phương thức redirect() tại ProductInformationForm component, và nó chỉ hữu dụng khi thực hiện thêm sản phẩm mới mà thôi. Vì vậy hãy cùng điểu chỉnh lại một chút nhé:

public function save()
{
    $this->validate();
    $this->product->save();
    if ($this->product->wasRecentlyCreated) {
        return redirect()->route('admin.products.edit', $this->product);
    }
    $this->emitSelf('saved');
}

Sau khi sản phẩm được lưu lại, chúng ta sẽ tiến hành kiểm tra biến $product xem nó có phải là vừa được khởi tạo hay không. Nếu đúng thì thay vì di chuyển đến trang danh sách như trước đây thì chúng ta sẽ di chuyển đến trang chỉnh sửa thông tin. Còn nếu không chúng ta sẽ chỉ phát đi một sự kiện saved cho chính component này mà thôi.

Hãy thử thực hiện lại thao tác cập nhật thông tin sản phẩm xem sao? Kết quả mỹ mãn đúng không nào!

Kết luận

Kết thúc bài này chúng ta đã hoàn thành việc xây dựng cơ sở dữ liệu với các thông tin cơ bản cho hệ thống sản phẩm cũng như các components để quản lý danh sách, thêm sản phẩm mới và cập nhật thông tin cho sản phẩm đã tạo.

Đáng nhẽ ra tôi sẽ đi sâu hơn một chút để hướng dẫn các bạn tạo trình soạn thảo wysiwyg cho phần mô tả sản phẩm, tuy nhiên bài cũng khá dài rồi nên tôi sẽ tách nó ra thành một bài riêng nối tiếp sau bài này.

Hẹn gặp lại các bạn!