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

TQH 2023-08-30 17:38:02

Để 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.

Đâ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:

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!