generated from boboko/starter
product options layout and add to cart button in homepage
This commit is contained in:
@@ -16,13 +16,17 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
connect() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const urlId = parseInt(params.get('variant'))
|
||||
const defaultId = this.variantsValue[0]?.id
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const urlId = parseInt(params.get('variant'))
|
||||
const urlVariant = this.variantsValue.find(v => v.id === urlId)
|
||||
const initial = urlVariant ?? this.variantsValue[0]
|
||||
|
||||
this.selectedValue = urlId && this.variantsValue.find(v => v.id === urlId)
|
||||
? urlId
|
||||
: defaultId
|
||||
// A product can have several independent options (e.g. size + style
|
||||
// + person-count) — this tracks the currently-picked value id per
|
||||
// option handle, and selectVariant() below resolves the full
|
||||
// combination back to one exact variant on every change.
|
||||
this.selections = { ...initial?.options }
|
||||
this.selectedValue = initial?.id
|
||||
|
||||
// Capture phase, on this controller's own root element (an ancestor
|
||||
// of the checkout module's add-to-cart <form>) — runs BEFORE that
|
||||
@@ -108,11 +112,24 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
selectVariant(event) {
|
||||
const id = parseInt(event.currentTarget.dataset.variantId)
|
||||
this.selectedValue = id
|
||||
const option = event.currentTarget.dataset.option
|
||||
const valueId = parseInt(event.currentTarget.dataset.valueId)
|
||||
this.selections = { ...this.selections, [option]: valueId }
|
||||
|
||||
const match = this.variantsValue.find(variant =>
|
||||
Object.keys(this.selections).every(key => variant.options?.[key] === this.selections[key])
|
||||
)
|
||||
|
||||
// No variant exists for this combination (e.g. an option value that
|
||||
// isn't offered together with another currently-selected value) —
|
||||
// leave the previous selection in place rather than pointing the
|
||||
// add-to-cart form at nothing.
|
||||
if (!match) return
|
||||
|
||||
this.selectedValue = match.id
|
||||
|
||||
const url = new URL(window.location)
|
||||
url.searchParams.set('variant', id)
|
||||
url.searchParams.set('variant', match.id)
|
||||
window.history.pushState({}, '', url)
|
||||
}
|
||||
|
||||
@@ -140,12 +157,16 @@ export default class extends Controller {
|
||||
if (purchasableInput) purchasableInput.value = id
|
||||
|
||||
this.swatchTargets.forEach(swatch => {
|
||||
const isSelected = parseInt(swatch.dataset.variantId) === id
|
||||
const isSelected = this.selections[swatch.dataset.option] === parseInt(swatch.dataset.valueId)
|
||||
swatch.classList.toggle('is-selected', isSelected)
|
||||
swatch.setAttribute('aria-pressed', String(isSelected))
|
||||
|
||||
if (isSelected && this.hasColorNameTarget) {
|
||||
this.colorNameTarget.textContent = swatch.getAttribute('aria-label')
|
||||
if (isSelected) {
|
||||
// Each color-option group has its own colorName echo (see
|
||||
// x-ui.color-swatch) — matched by option handle so a swatch
|
||||
// in one group never overwrites another group's label.
|
||||
const colorName = this.colorNameTargets.find(target => target.dataset.option === swatch.dataset.option)
|
||||
if (colorName) colorName.textContent = swatch.getAttribute('aria-label')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{{-- $variants: array of Modules\Core\Catalog\Services\ProductIndexer's mapVariant()
|
||||
shape (id, options: [{option, value, meta}], ...) — plain arrays, not Eloquent
|
||||
models, since this is fed from Modules\Core\Catalog\Services\ProductService. --}}
|
||||
@props(['variants', 'option' => null])
|
||||
{{-- $values: one product option's de-duplicated, ordered values, as built by
|
||||
ProductController::buildOptionPicker() — [{id, label, hex}]. --}}
|
||||
@props(['values', 'option' => null, 'optionHandle' => null])
|
||||
|
||||
<div {{ $attributes }}>
|
||||
@if($option)
|
||||
<p class="font-bold mb-3 text-sm uppercase tracking-wide">
|
||||
{{ $option }}: <span data-product-form-target="colorName" class="font-normal normal-case"></span>
|
||||
{{ $option }}: <span data-product-form-target="colorName" data-option="{{ $optionHandle }}" class="font-normal normal-case"></span>
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@@ -15,22 +14,17 @@ class="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label="{{ $option ?? 'Color' }}"
|
||||
>
|
||||
@foreach($variants as $variant)
|
||||
@php
|
||||
$value = $variant['options'][0] ?? null;
|
||||
$label = $value['value'] ?? '';
|
||||
$bg = $value['meta']['hex'] ?? '#cccccc';
|
||||
@endphp
|
||||
@foreach($values as $value)
|
||||
<button
|
||||
type="button"
|
||||
class="color-swatch"
|
||||
data-product-form-target="swatch"
|
||||
data-action="click->product-form#selectVariant"
|
||||
data-variant-id="{{ $variant['id'] }}"
|
||||
style="background-color: {{ $bg }};"
|
||||
aria-label="{{ $label }}"
|
||||
data-option="{{ $optionHandle }}"
|
||||
data-value-id="{{ $value['id'] }}"
|
||||
style="background-color: {{ $value['hex'] ?? '#cccccc' }};"
|
||||
aria-label="{{ $value['label'] }}"
|
||||
aria-pressed="false"
|
||||
title="{{ $label }}"
|
||||
></button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
{{-- $variants: array of Modules\Core\Catalog\Services\ProductIndexer's mapVariant()
|
||||
shape (id, options: [{option, value, meta}], ...) — plain arrays, not Eloquent
|
||||
models, since this is fed from Modules\Core\Catalog\Services\ProductService.
|
||||
Use this instead of <x-ui.color-swatch> for options that aren't a color
|
||||
(no meta.hex), where a swatch dot has nothing meaningful to show. --}}
|
||||
@props(['variants', 'option' => null])
|
||||
{{-- $values: one product option's de-duplicated, ordered values, as built by
|
||||
ProductController::buildOptionPicker() — [{id, label, hex}]. Use this
|
||||
instead of <x-ui.color-swatch> for options that aren't a color (no hex),
|
||||
where a swatch dot has nothing meaningful to show. --}}
|
||||
@props(['values', 'option' => null, 'optionHandle' => null])
|
||||
|
||||
<div {{ $attributes }}>
|
||||
@if($option)
|
||||
@@ -17,20 +16,17 @@ class="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label="{{ $option ?? 'Option' }}"
|
||||
>
|
||||
@foreach($variants as $variant)
|
||||
@php
|
||||
$value = $variant['options'][0] ?? null;
|
||||
$label = $value['value'] ?? '';
|
||||
@endphp
|
||||
@foreach($values as $value)
|
||||
<x-ui.button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
data-product-form-target="swatch"
|
||||
data-action="click->product-form#selectVariant"
|
||||
data-variant-id="{{ $variant['id'] }}"
|
||||
aria-label="{{ $label }}"
|
||||
data-option="{{ $optionHandle }}"
|
||||
data-value-id="{{ $value['id'] }}"
|
||||
aria-label="{{ $value['label'] }}"
|
||||
aria-pressed="false"
|
||||
>{{ $label }}</x-ui.button>
|
||||
>{{ $value['label'] }}</x-ui.button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,9 +60,16 @@ class="w-full h-full object-cover"
|
||||
@endif
|
||||
</a>
|
||||
|
||||
<x-ui.button size="md" position="absolute" class="opacity-0 group-hover:opacity-100 transition-opacity duration-100">
|
||||
{{ __('storefront.product.add_to_cart') }}
|
||||
</x-ui.button>
|
||||
@if ($product['variantId'] ?? null)
|
||||
<x-checkout::add-to-cart
|
||||
:purchasable="$product['variantId']"
|
||||
class="absolute opacity-0 group-hover:opacity-100 has-[.bbk-add-to-cart-error:not([hidden])]:opacity-100 transition-opacity duration-100"
|
||||
>
|
||||
<x-ui.button type="submit" size="md">
|
||||
{{ __('storefront.product.add_to_cart') }}
|
||||
</x-ui.button>
|
||||
</x-checkout::add-to-cart>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-baseline justify-between gap-4 absolute bottom-0 left-0 w-full">
|
||||
|
||||
@@ -169,13 +169,13 @@ class="absolute bottom-6 right-8 text-white text-sm"
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($option && !empty($product['variants']))
|
||||
@if($optionIsColor)
|
||||
<x-ui.color-swatch :variants="$product['variants']" :option="$option" />
|
||||
@foreach($productOptions as $productOption)
|
||||
@if($productOption['isColor'])
|
||||
<x-ui.color-swatch :values="$productOption['values']" :option="$productOption['label']" :option-handle="$productOption['handle']" />
|
||||
@else
|
||||
<x-ui.option-buttons :variants="$product['variants']" :option="$option" />
|
||||
<x-ui.option-buttons :values="$productOption['values']" :option="$productOption['label']" :option-handle="$productOption['handle']" />
|
||||
@endif
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
<x-checkout::add-to-cart
|
||||
:purchasable="$variantsData[0]['id'] ?? null"
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $product->translateAttribute('name') . ' — ' . config('app.name'))
|
||||
@section('description', $product->translateAttribute('description'))
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-8 py-12">
|
||||
|
||||
@php $collection = $product->collections->first(); @endphp
|
||||
|
||||
<x-breadcrumb class="mb-14 justify-end" :items="[
|
||||
$collection
|
||||
? ['label' => $collection->translateAttribute('name'), 'href' => '/category/' . $collection->id]
|
||||
: ['label' => 'Products', 'href' => '/products'],
|
||||
['label' => $product->translateAttribute('name')],
|
||||
]" />
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-12"
|
||||
data-controller="product-form"
|
||||
data-product-form-variants-value="{{ json_encode($variantsData) }}"
|
||||
>
|
||||
|
||||
{{-- Image --}}
|
||||
<div
|
||||
data-controller="product-gallery"
|
||||
class="flex gap-4 items-start"
|
||||
>
|
||||
@if($product->media->isNotEmpty())
|
||||
{{-- Thumbnails --}}
|
||||
<div class="flex flex-col items-center gap-1 w-[116px] shrink-0 -mt-12.5">
|
||||
<button
|
||||
type="button"
|
||||
data-action="click->product-gallery#scrollUp"
|
||||
class="gallery-arrow w-full flex items-center justify-center py-2"
|
||||
aria-label="Scroll thumbnails up"
|
||||
><x-ui.icon name="arrow-up" :size="30" /></button>
|
||||
|
||||
<div
|
||||
data-product-gallery-target="track"
|
||||
class="flex flex-col gap-4 overflow-hidden"
|
||||
style="max-height: var(--gallery-height, 600px)"
|
||||
>
|
||||
@foreach($product->media as $i => $media)
|
||||
<button
|
||||
type="button"
|
||||
data-action="click->product-gallery#select"
|
||||
data-product-gallery-target="thumb"
|
||||
data-src="{{ $media->getUrl() }}"
|
||||
data-alt="{{ $product->translateAttribute('name') }}"
|
||||
class="block w-full shrink-0 border border-black overflow-hidden "
|
||||
aria-label="View image {{ $i + 1 }}"
|
||||
aria-pressed="{{ $i === 0 ? 'true' : 'false' }}"
|
||||
>
|
||||
|
||||
<!-- opacity-50 transition-opacity {{ $i === 0 ? 'opacity-100' : '' }}" -->
|
||||
<img src="{{ $media->getUrl() }}" alt="" class="w-full h-auto object-cover" aria-hidden="true">
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-action="click->product-gallery#scrollDown"
|
||||
class="gallery-arrow w-full flex items-center justify-center py-2"
|
||||
aria-label="Scroll thumbnails down"
|
||||
><x-ui.icon name="arrow-down" :size="30" /></button>
|
||||
</div>
|
||||
|
||||
{{-- Main image --}}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 border border-black bg-white cursor-zoom-in block p-0"
|
||||
data-action="click->product-gallery#openLightbox"
|
||||
aria-label="View full size image"
|
||||
>
|
||||
<img
|
||||
data-product-form-target="image"
|
||||
data-product-gallery-target="main"
|
||||
src="{{ $product->media->first()->getUrl() }}"
|
||||
alt="{{ $product->translateAttribute('name') }}"
|
||||
class="w-full h-auto block"
|
||||
>
|
||||
</button>
|
||||
|
||||
{{-- Lightbox --}}
|
||||
<div
|
||||
id="product-lightbox"
|
||||
popover
|
||||
class="product-lightbox"
|
||||
data-product-gallery-target="lightbox"
|
||||
data-action="click->product-gallery#closeLightboxOnBackdrop"
|
||||
role="dialog"
|
||||
aria-label="Product image gallery"
|
||||
aria-modal="true"
|
||||
>
|
||||
{{-- Prev --}}
|
||||
<button
|
||||
type="button"
|
||||
data-action="click->product-gallery#prevImage click->product-gallery#noop"
|
||||
class="lightbox-arrow absolute left-8 top-1/2 -translate-y-1/2"
|
||||
aria-label="Previous image"
|
||||
><x-ui.icon name="arrow-left" :size="72" color="white" /></button>
|
||||
|
||||
{{-- Image + close button as a unit --}}
|
||||
<div class="relative" data-action="click->product-gallery#noop">
|
||||
<img
|
||||
data-product-gallery-target="lightboxImage"
|
||||
src=""
|
||||
alt=""
|
||||
class="max-h-[90vh] max-w-[80vw] object-contain block"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
popovertarget="product-lightbox"
|
||||
popovertargetaction="hide"
|
||||
class="lightbox-close absolute -top-10 -right-10"
|
||||
aria-label="Close gallery"
|
||||
><x-ui.icon name="close" :size="36" color="white" /></button>
|
||||
</div>
|
||||
|
||||
{{-- Next --}}
|
||||
<button
|
||||
type="button"
|
||||
data-action="click->product-gallery#nextImage click->product-gallery#noop"
|
||||
class="lightbox-arrow absolute right-8 top-1/2 -translate-y-1/2"
|
||||
aria-label="Next image"
|
||||
><x-ui.icon name="arrow-right" :size="72" color="white" /></button>
|
||||
|
||||
{{-- Counter --}}
|
||||
<p
|
||||
data-product-gallery-target="lightboxCounter"
|
||||
class="absolute bottom-6 right-8 text-white text-sm"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
</div>
|
||||
@else
|
||||
<div class="w-full bg-neutral-300 flex items-center justify-center text-neutral-500 min-h-64">
|
||||
No image
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Info --}}
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<h1 class="font-display font-medium text-4xl lg:text-[54px]">
|
||||
{{ $product->translateAttribute('name') }}
|
||||
</h1>
|
||||
|
||||
<x-reviews-stars :rating="3" :count="24" :showCount="true" />
|
||||
|
||||
@if($product->variants->first()?->prices->isNotEmpty())
|
||||
<p class="text-2xl font-bold" data-product-form-target="price">
|
||||
<x-ui.price :amount="$product->variants->first()->prices->first()->price->decimal" />
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$desc = strip_tags($product->translateAttribute('description') ?? '');
|
||||
$descTruncated = Str::limit($desc, 137);
|
||||
$descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.'));
|
||||
@endphp
|
||||
<div class="text-base leading-relaxed">
|
||||
{{ $descTruncated }}
|
||||
@if($descNeedsMore)
|
||||
<a href="#tab-panel-description" class="underline-slide font-semibold whitespace-nowrap">Περισσότερα</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($option && $product->variants->count() >= 1)
|
||||
<x-ui.color-swatch :variants="$product->variants" :option="$option" />
|
||||
@endif
|
||||
|
||||
<div class="flex items-stretch gap-10">
|
||||
<x-ui.quantity name="quantity" />
|
||||
<x-ui.button class="flex-1">Προσθήκη στο καλάθι</x-ui.button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<x-ui.tabs class="mt-16" size="lg" :tabs="[
|
||||
['id' => 'description', 'label' => 'Περιγραφή'],
|
||||
// ['id' => 'reviews', 'label' => 'Αξιολογήσεις (' . count($reviews) . ')'],
|
||||
['id' => 'reviews', 'label' => 'Αξιολογήσεις (3)'],
|
||||
]">
|
||||
<x-slot name="description">
|
||||
<div class="leading-7 [&_p]:mt-4">
|
||||
{!! $product->translateAttribute('description') !!}
|
||||
</div>
|
||||
@if($product->translateAttribute('details'))
|
||||
<div class="mt-6">{!! $product->translateAttribute('details') !!}</div>
|
||||
@endif
|
||||
<ul class="mt-8 flex flex-col gap-3 text-neutral-600 list-disc list-outside pl-5">
|
||||
<li>Όλα τα προϊόντα εκτυπώνονται και προετοιμάζονται κατά παραγγελία. Ο χρόνος προετοιμασίας κυμαίνεται μεταξύ 2 και 7 εργάσιμων ημερών.</li>
|
||||
<li>Όλα τα προϊόντα κατασκευάζονται με τρισδιάστατη εκτύπωση σε ειδικούς εκτυπωτές πλαστικού υλικού. Πιθανώς να έχουν εμφανείς γραμμές ένωσης, στρώσεις εκτύπωσης υλικού και μικρές ατέλειες. Είναι φυσιολογικό για το αποτέλεσμα αυτής της δημιουργικής διαδικασίας.</li>
|
||||
<li>Όλα τα προϊόντα είναι σχεδιασμένα και κατασκευασμένα είτε για διακόσμηση είτε για χρήση σε λογικά, ρεαλιστικά πλαίσια. Υπερβολική ισχύς, υψηλότατες θερμοκρασίες και αποσυναρμολόγηση μπορεί να προκαλέσουν ζημιά στο προϊόν για την οποία δεν ευθύνεται το 3Dealer.</li>
|
||||
</ul>
|
||||
</x-slot>
|
||||
<x-slot name="reviews">
|
||||
{{-- @if(count($reviews) > 0)
|
||||
<div class="mb-10">
|
||||
@foreach($reviews as $review)
|
||||
<x-review-card :review="$review" />
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-neutral-500 mb-10">Δεν υπάρχουν αξιολογήσεις ακόμα.</p>
|
||||
@endif
|
||||
|
||||
<h3 class="text-h4 font-bold">
|
||||
{{ count($reviews) > 0 ? 'Πρόσθεσε μια' : 'Γράψε την πρώτη' }} αξιολόγηση για το «{{ $product->translateAttribute('name') }}»
|
||||
</h3> --}}
|
||||
|
||||
<x-review-form :product="$product" />
|
||||
</x-slot>
|
||||
</x-ui.tabs>
|
||||
|
||||
<x-product-grid
|
||||
class="mt-20"
|
||||
title="Σχετικά προϊόντα"
|
||||
:products="[
|
||||
['name' => 'Camper', 'price' => '30', 'image' => null, 'href' => '#'],
|
||||
['name' => 'Ponderer IPA','price' => '25', 'image' => null, 'href' => '#'],
|
||||
['name' => 'Squish Red', 'price' => '35', 'image' => null, 'href' => '#'],
|
||||
['name' => 'Red Light', 'price' => '25', 'image' => null, 'href' => '#'],
|
||||
]"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<x-newsletter class="py-20" />
|
||||
|
||||
@endsection
|
||||
Reference in New Issue
Block a user