Xây dựng website bán hàng bằng Laravel - Dựng trang thông tin Sản phẩm

Ngày đăng
Tác giả
Tran Thanh Long

Để Khách hàng có thể xem và tìm hiểu kỹ hơn về thông tin của một Sản phẩm bao gồm giá bán, số lượng tồn kho hay các mô tả về những đặc tính kỹ thuật ví dụ chất liệu, kiểu dáng... chúng ta sẽ cần đến một trang riêng cho mỗi sản phẩm, hãy cùng tìm hiểu cách để tạo một trang như vậy trong bài viết này.


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.

Đây là bài đầu tiên trong phần thiết lập các trang con dành cho phần Storefront, nơi mà khách hàng sẽ ghé thăm và thực hiện các thao tác để mua hàng, thanh toán và quản lý đơn hàng... Hãy chắc chắn rằng bạn đã đọc các bài viết trước về cách xây dựng các tính năng để có thể tạo và quản lý thông tin sản phẩm.

Trước hết chúng ta sẽ bắt đầu bằng cách tạo một full-page component dành cho trang thông tin chi tiết sản phẩm với lệnh livewire:make:

php artisan livewire:make Guest\\ProductDetails

Vì là full-page nên chúng ta sẽ cần khai báo route:

Route::get('/products/{product}', ProductDetails::class)->name('guest.products.show');

và khai báo layout:

class ProductDetails extends Component {
    public Product $product;

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

lưu ý rằng chúng ta sẽ sử dụng Route Model Binding nên hãy khai báo biến công khai là $product như trên để Livewire có thể tự nhận và nhúng nó sang phần view của component này.

Tiếp đến với phần view của component này, chúng ta sẽ chia đôi màn hình ra làm hai phần trong đó

  1. Để hiển thị hình ảnh trong thư viện media của sản phẩm

  2. Để hiện tên các thông tin chung bao gồm tên, giá bán, thuộc tính... và một form để thêm sản phẩm vào giỏ hàng.

Nội dung của phần view sẽ như sau:

<div>
    <div class="bg-white">
        <div class="max-w-2xl mx-auto pt-16 pb-24 px-4 sm:pt-24 sm:pb-32 sm:px-6 lg:max-w-7xl lg:px-8 lg:grid lg:grid-cols-2 lg:gap-x-8">
            <div class="flex flex-col-reverse">
                <div class="hidden mt-6 w-full max-w-2xl mx-auto sm:block lg:max-w-none">
                    <div class="grid grid-cols-4 gap-6">
                        @foreach($product->getMedia('media') as $media)
                            <img
                                src="{{ $media->getUrl() }}"
                                alt=""
                                class="w-full h-full object-center object-cover"
                            >
                        @endforeach
                    </div>
                </div>
                <div class="w-full aspect-w-1 aspect-h-1">
                    <img src="{{ $product->getFirstMediaUrl('media') }}" alt="Angled front view with bag zipped and handles upright." class="w-full h-full object-center object-cover sm:rounded-lg">
                </div>
            </div>

            <div>
                <h1 class="text-3xl font-bold text-gray-900">{{ $product->name }}</h1>
                <div class="mt-3">
                    <h2 class="sr-only">Product information</h2>
                    <p class="text-3xl text-gray-900">
                        <x-money :amount="$product->price" :currency="config('app.currency')" />
                    </p>
                </div>
                <div class="mt-3">
                    <h3 class="sr-only">Reviews</h3>
                    <div class="flex items-center">
                        <div class="flex items-center">
                            <svg class="h-5 w-5 flex-shrink-0 text-indigo-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                                <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
                            </svg>

                            <svg class="h-5 w-5 flex-shrink-0 text-indigo-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                                <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
                            </svg>

                            <svg class="h-5 w-5 flex-shrink-0 text-indigo-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                                <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
                            </svg>

                            <svg class="h-5 w-5 flex-shrink-0 text-indigo-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                                <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
                            </svg>

                            <svg class="h-5 w-5 flex-shrink-0 text-gray-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                                <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
                            </svg>
                        </div>
                        <p class="sr-only">4 out of 5 stars</p>
                    </div>
                </div>
                <div class="mt-6">
                    <form wire:submit.prevent="addToCart">
                        @if($product->skus->count())
                            @foreach($product->options as $index => $option)
                                <div @class(['mt-8' => !$loop->first])>
                                    <h3 class="text-sm font-medium text-gray-900">
                                        {{ $option->name }}
                                    </h3>
                                    <fieldset class="mt-2">
                                        <legend class="sr-only">
                                            {{ __('Choose a') }} {{ $option->name }}
                                        </legend>

                                        @if($option->visual === 'color')
                                            <div class="flex items-center space-x-3">
                                                @foreach($option->optionValues as $optionValue)
                                                    <label @class(['-m-0.5 relative p-0.5 rounded-full flex items-center justify-center cursor-pointer focus:outline-none', 'ring-2 ring-indigo-500' => in_array($optionValue->id, $selectedOptionValues)])>
                                                        <input
                                                            wire:model="selectedOptionValues.{{ $index }}"
                                                            type="radio"
                                                            value="{{ $optionValue->id }}"
                                                            class="sr-only"
                                                            aria-labelledby="{{ Str::slug($option->name) }}-choice-{{ $loop->index }}-label"
                                                        >
                                                        <p
                                                            id="{{ Str::slug($option->name) }}-choice-{{ $loop->index }}-label"
                                                            class="sr-only"
                                                        >
                                                            {{ $optionValue->value }}
                                                        </p>
                                                        <span
                                                            aria-hidden="true"
                                                            class="w-8 h-8 rounded-full border border-black border-opacity-10"
                                                            style="background-color: {{ $optionValue->value }}"
                                                        ></span>
                                                    </label>
                                                @endforeach
                                            </div>
                                        @else
                                            <div class="grid grid-cols-3 gap-3 sm:grid-cols-6">
                                                @foreach($option->optionValues as $optionValue)
                                                    <label @class(['flex justify-center items-center px-3 py-3 text-sm font-medium uppercase rounded-md border cursor-pointer sm:flex-1 focus:outline-none', 'ring-2 ring-offset-2 ring-indigo-500 bg-indigo-600 border-transparent text-white hover:bg-indigo-700' => in_array($optionValue->id, $selectedOptionValues)])>
                                                        <input
                                                            wire:model="selectedOptionValues.{{ $index }}"
                                                            type="radio"
                                                            value="{{ $optionValue->id }}"
                                                            class="sr-only"
                                                            aria-labelledby="{{ Str::slug($option->name) }}-choice-{{ $loop->index }}-label"
                                                        >
                                                        <p id="{{ Str::slug($option->name) }}-choice-{{ $loop->index }}-label">
                                                            {{ $optionValue->label ?? $optionValue->value }}
                                                        </p>
                                                    </label>
                                                @endforeach
                                            </div>
                                        @endif
                                    </fieldset>
                                </div>
                            @endforeach
                        @endif

                        <div class="flex items-center space-x-3 mt-8">
                            <div>
                                <x-label for="productQuantity" value="Quantity" class="sr-only" />
                                <x-input wire:model.lazy="addToCart.quantity" type="number" id="productQuantity" class="py-3 w-28 text-sm text-center sm:text-base" :min="$minQuantity" :max="$maxQuantity" />
                                <x-input-error for="addToCart.quantity" />
                            </div>
                            <div class="flex w-full">
                                <x-primary-button class="max-w-xs flex-1 px-8 py-3 font-medium sm:w-full sm:text-base disabled:cursor-not-allowed" :disabled="$maxQuantity == 0">
                                    {{ $maxQuantity > 0 ? 'Add to cart' : 'Sold out' }}
                                </x-primary-button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>

Vì hiện tại chúng ta chưa xây dựng tính năng giỏ hàng nên tạm thời chỉ chuẩn bị trước các thông tin cần thiết cho tính năng này thôi nhé, nó sẽ bao gồm sku của sản phẩm và số lượng cần mua.

Như các bạn đã biết thì để một sản phẩm có thể bày bán sẽ bắt buộc phải có tối thiểu 1 sku (nếu là sản phẩm không có thuộc tính), và 2 skus trở lên (đối với các sản phẩm có thuộc tính). Vì vậy khi mở trang thông tin sản phẩm trên trình duyệt chúng ta sẽ có 2 trường hợp xảy ra như sau:

  1. Sản phẩm không có thuộc tính (variant): chúng ta sẽ tự động thêm mã sku duy nhất của sản phẩm này vào data để chuẩn bị giỏ hàng, và khi khách hàng nhấn thêm vào giỏ chúng ta chỉ cần bắt lấy số lượng là xong.

  2. Sản phẩm có nhiều thuộc tính: chúng ta sẽ tiến hành chọn sku đầu tiên nếu khách hàng đang truy cập link gốc của sản phẩm và tự thêm variant query string vào url, hoặc nếu khách hàng truy cập link có sẵn variant query string chúng ta sẽ sử dụng nó để lọc ra sku tương ứng, và tiến hành chọn sẵn các thuộc tính của sku này.

Giải thích thì có thể hơi khó hiểu nhưng nó sẽ chỉ tốn vài dòng code mà thôi, xem nhé:

class ProductDetails extends Component {
    ...

    public string $variant = '';
    public array $selectedOptionValues;
    public array $addToCartData = [
        'skuId' => null,
        'quantity' => 1,
    ];

    protected $queryString = ['variant' => ['except' => '']];

    public function mount()
    {
        if ($this->product->skus->count() > 1) { // product has one or more skus
            if ($this->variant != '') {
                $sku = $this->product->skus->where('id', $this->variant)->first();
                abort_if(! $sku, 400); // invalid variant query string
            } else {
                $sku = $this->product->skus->first();
            }
            $this->variant = $sku->id;
        } else { // product with single sku
            $sku = $this->product->skus->first();
        }
        $this->addToCartData['skuId'] = $sku->id;
        $this->selectedOptionValues = $sku->variants->pluck('option_value_id')->toArray();
    }

    ...
}

Tiếp đến chúng ta sẽ bắt sự kiện khi khách hàng thay đổi lựa chọn thuộc tính sản phẩm:

class ProductDetails extends Component {
    ...

    public array $selectedOptionValues;
    
    public function updatedSelectedOptionValues()
    {
        if (count($this->selectedOptionValues) == $this->product->options->count()) {
            foreach ($this->product->skus as $sku) {
                if (collect($sku['variants'])->whereIn('option_value_id', $this->selectedOptionValues)->count() > 1) {
                    $this->variant = $sku->id;
                    $this->addToCartData['skuId'] = $sku->id;
                    $this->maxQuantity = $sku->stock;
                }
            }
        }
    }

    ...
}

Và chúng ta sẽ có một phương thức để thêm vào giỏ hàng, tuy nhiên hiện tại tính năng này chưa được xây dựng nên tạm thời chỉ lưu thông tin vào log để kiểm tra thôi nhé:

class ProductDetails extends Component {
    ...

    public function addToCart()
    {
        logger($this->addToCartData);
    }

    ...
}

Vậy là xong rồi, đây là kết quả của chúng ta sau khi thực hiện bài này:

Trang thông tin chi tiết sản phẩm

Kết luận

Kết thúc bài này bạn đã có thể tạo được một trang để hiển thị thông tin sản phẩm cho khách hàng. Ngoài ra bằng việc sử dụng variant query string để tự động chọn sẵn các thuộc tính tương ứng sẽ giúp cho khách hàng của bạn tiết kiệm thời gian khi muốn gửi link sản phẩm cho bạn bè vì họ sẽ không cần chọn lại các thuộc tính này một lần nữa.

Trong bài tiếp theo chúng ta sẽ tiến hành xây dựng tính năng giỏ hàng và giúp cho khách hàng có thể thêm sản phẩm vào giỏ cũng như quản lý giỏ hàng của mình. Hẹn gặp lại các bạn!

Thông báo biến động số dư qua API & Webhook.

Hỗ trợ tích hợp giao dịch chuyển khoản vào hệ thống thanh toán trực tuyến Tự động, Nhanh chóng, Chính xác.

Đăng ký dùng thử trong 14 ngày
Bài viết mới nhất

Xây dựng website bán hàng bằng Laravel - Thiết lập và quản lý đơn hàng

Quản lý đơn hàng là quy trình back-end để quản lý và kiện toàn các đơn hàng trực tuyến. Việc này bao gồm mọi thứ từ định tuyến đơn hàng, in nhãn vận chuyển cho đến đến trả hàng cho khách. Trong bài viết của ngày hôm nay chúng ta sẽ cùng tìm hiểu cách để tạo và quản lý các đơn đặt hàng.

Xây dựng website bán hàng bằng Laravel - Tổ chức và Phân loại Sản phẩm

Một sản phẩm có thể nằm trong một hoặc nhiều danh mục khác nhau. Để phân phối sản phẩm vào các danh mục chúng ta cần thêm một tính năng trong phần quản lý sản phẩm, hãy cùng tìm hiểu cách thực hiện trong bài viết này nhé!
Bình luận
Khang Duong

Khang Duong

có thể cho mình xin link github để tham khảo được k ạ?