generated from boboko/starter
product custom fields
This commit is contained in:
@@ -215,6 +215,38 @@ .bbk-cart-item-variant {
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
/* A line's custom-field answers (checkout::partials.line-custom-fields). */
|
||||
.bbk-line-fields {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.bbk-line-field dt {
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-line-field dd {
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.bbk-line-field-file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.bbk-line-field-file img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.bbk-cart-item-unit {
|
||||
margin: 0 0 0.625rem;
|
||||
color: var(--bbk-color-muted);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
// One product custom field of type `file` (see x-product-custom-fields).
|
||||
// Uploads the photo to the storefront's own endpoint (CustomFieldUploadController)
|
||||
// as soon as it's picked, then writes the returned opaque reference into the
|
||||
// hidden input the add-to-cart form actually submits — the checkout module
|
||||
// never receives the file itself.
|
||||
//
|
||||
// While uploading, the file input is marked invalid via setCustomValidity(),
|
||||
// so the browser's own form validation blocks add-to-cart until the reference
|
||||
// is in place. A failed upload clears the input, so `required` blocks it too.
|
||||
export default class extends Controller {
|
||||
static targets = ['file', 'reference', 'preview', 'error']
|
||||
static values = {
|
||||
url: String,
|
||||
label: String,
|
||||
uploadingMessage: String,
|
||||
failedMessage: String,
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.abortController?.abort()
|
||||
this.revokePreview()
|
||||
}
|
||||
|
||||
async upload() {
|
||||
this.reset()
|
||||
|
||||
const file = this.fileTarget.files[0]
|
||||
if (!file) return
|
||||
|
||||
const abortController = new AbortController()
|
||||
this.abortController = abortController
|
||||
|
||||
this.fileTarget.setCustomValidity(this.uploadingMessageValue)
|
||||
this.fileTarget.setAttribute('aria-busy', 'true')
|
||||
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
body.append('label', this.labelValue)
|
||||
|
||||
try {
|
||||
const response = await fetch(this.urlValue, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body,
|
||||
signal: abortController.signal,
|
||||
})
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
if (!response.ok || !data?.reference) {
|
||||
this.fail(data?.error)
|
||||
return
|
||||
}
|
||||
|
||||
this.referenceTarget.value = data.reference
|
||||
this.showPreview(file)
|
||||
} catch (error) {
|
||||
// A newer pick superseded this upload — reset() already handled it.
|
||||
if (error.name === 'AbortError') return
|
||||
this.fail()
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) this.markIdle()
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.abortController?.abort()
|
||||
this.referenceTarget.value = ''
|
||||
this.errorTarget.hidden = true
|
||||
this.markIdle()
|
||||
this.revokePreview()
|
||||
}
|
||||
|
||||
fail(message) {
|
||||
this.fileTarget.value = ''
|
||||
this.errorTarget.textContent = message || this.failedMessageValue
|
||||
this.errorTarget.hidden = false
|
||||
}
|
||||
|
||||
markIdle() {
|
||||
this.fileTarget.setCustomValidity('')
|
||||
this.fileTarget.removeAttribute('aria-busy')
|
||||
}
|
||||
|
||||
showPreview(file) {
|
||||
this.previewUrl = URL.createObjectURL(file)
|
||||
this.previewTarget.src = this.previewUrl
|
||||
this.previewTarget.hidden = false
|
||||
}
|
||||
|
||||
// Formats the browser can't render (HEIC outside Safari) — the file
|
||||
// input's own filename is enough there.
|
||||
hidePreview() {
|
||||
this.previewTarget.hidden = true
|
||||
}
|
||||
|
||||
revokePreview() {
|
||||
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
|
||||
this.previewUrl = null
|
||||
this.previewTarget.removeAttribute('src')
|
||||
this.previewTarget.hidden = true
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import AutoSubmitController from './auto-submit-controller'
|
||||
import BackToTopController from './back-to-top-controller'
|
||||
import CartCountController from './cart-count-controller'
|
||||
import CarouselController from './carousel-controller'
|
||||
import CustomFieldUploadController from './custom-field-upload-controller'
|
||||
import DropdownController from './dropdown-controller'
|
||||
import FrameScrollController from './frame-scroll-controller'
|
||||
import NavSearchController from './nav-search-controller'
|
||||
@@ -25,6 +26,7 @@ export function registerControllers(application) {
|
||||
application.register('back-to-top', BackToTopController)
|
||||
application.register('cart-count', CartCountController)
|
||||
application.register('carousel', CarouselController)
|
||||
application.register('custom-field-upload', CustomFieldUploadController)
|
||||
application.register('dropdown', DropdownController)
|
||||
application.register('frame-scroll', FrameScrollController)
|
||||
application.register('nav-search', NavSearchController)
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
@if ($line->option)
|
||||
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
|
||||
@endif
|
||||
|
||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||
</div>
|
||||
|
||||
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
@if ($variantLabel)
|
||||
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
|
||||
@endif
|
||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
|
||||
|
||||
<form
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{{--
|
||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||
|
||||
A cart or order line's custom-field answers (meta.custom_fields, written by
|
||||
CartController::customFieldsMeta()) — label/value pairs. A file answer links
|
||||
to the file through a temporary signed URL (CustomFieldFileController),
|
||||
minted fresh on every render, with a thumbnail when the browser can display
|
||||
the format (HEIC can't be shown outside Safari, so it gets the name only).
|
||||
--}}
|
||||
@php
|
||||
$fields = $line->meta['custom_fields'] ?? [];
|
||||
$previewable = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
@endphp
|
||||
|
||||
@if (! empty($fields))
|
||||
<dl class="bbk-line-fields">
|
||||
@foreach ($fields as $field)
|
||||
<div class="bbk-line-field">
|
||||
<dt>{{ $field['label'] }}</dt>
|
||||
<dd>
|
||||
@if ($field['type'] === 'file')
|
||||
@php
|
||||
$fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute(
|
||||
'checkout.custom-field-file',
|
||||
now()->addHours(2),
|
||||
['locale' => app()->getLocale(), 'disk' => $field['disk'], 'path' => $field['path']],
|
||||
);
|
||||
@endphp
|
||||
<a href="{{ $fileUrl }}" class="bbk-line-field-file" target="_blank" rel="noopener">
|
||||
@if (in_array($field['mime'] ?? null, $previewable, true))
|
||||
<img src="{{ $fileUrl }}" alt="" width="40" height="40" loading="lazy">
|
||||
@endif
|
||||
<span>{{ $field['value'] }}</span>
|
||||
</a>
|
||||
@else
|
||||
{{ $field['value'] }}
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
@@ -0,0 +1,76 @@
|
||||
{{--
|
||||
<x-product-custom-fields :fields="$product['custom_fields']" />
|
||||
|
||||
The product's custom fields (boboko-core's Product::$custom_fields), rendered
|
||||
inside the add-to-cart <form> so their values travel with it as
|
||||
custom_fields[key]. text → input, textarea → textarea, file → photo upload.
|
||||
|
||||
A photo is uploaded as soon as it's picked (custom-field-upload-controller.js)
|
||||
and only the returned reference is submitted with the form — the file input
|
||||
itself has no name. It still carries `required`, so the browser's own
|
||||
validation blocks add-to-cart until a photo is picked, and the controller
|
||||
marks it invalid (setCustomValidity) while the upload is in flight.
|
||||
--}}
|
||||
@props(['fields' => []])
|
||||
|
||||
@use('App\Http\Controllers\CustomFieldUploadController')
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
@foreach ($fields as $field)
|
||||
@php
|
||||
$id = 'custom-field-'.$field['key'];
|
||||
$name = 'custom_fields['.$field['key'].']';
|
||||
$required = (bool) ($field['required'] ?? false);
|
||||
@endphp
|
||||
|
||||
@switch($field['type'])
|
||||
@case('textarea')
|
||||
<x-ui.field :label="$field['label']" :for="$id" :required="$required">
|
||||
<x-ui.textarea :id="$id" :name="$name" :required="$required" :rows="4" maxlength="2000" />
|
||||
</x-ui.field>
|
||||
@break
|
||||
|
||||
@case('file')
|
||||
<x-ui.field
|
||||
:label="$field['label']"
|
||||
:for="$id"
|
||||
:required="$required"
|
||||
:description="__('storefront.product.custom_field_photo_hint', ['size' => CustomFieldUploadController::MAX_KILOBYTES / 1024])"
|
||||
data-controller="custom-field-upload"
|
||||
data-custom-field-upload-url-value="{{ route('custom-field-upload.store') }}"
|
||||
data-custom-field-upload-label-value="{{ $field['label'] }}"
|
||||
data-custom-field-upload-uploading-message-value="{{ __('storefront.product.custom_field_uploading') }}"
|
||||
data-custom-field-upload-failed-message-value="{{ __('storefront.product.custom_field_upload_failed') }}"
|
||||
>
|
||||
<input type="hidden" name="{{ $name }}" data-custom-field-upload-target="reference">
|
||||
|
||||
<x-ui.file-input
|
||||
:id="$id"
|
||||
:accept="CustomFieldUploadController::accept()"
|
||||
:required="$required"
|
||||
aria-describedby="{{ $id }}-description {{ $id }}-error"
|
||||
data-custom-field-upload-target="file"
|
||||
data-action="change->custom-field-upload#upload"
|
||||
/>
|
||||
|
||||
<img
|
||||
alt=""
|
||||
width="96"
|
||||
height="96"
|
||||
hidden
|
||||
class="w-24 h-24 object-cover border border-black"
|
||||
data-custom-field-upload-target="preview"
|
||||
data-action="error->custom-field-upload#hidePreview"
|
||||
>
|
||||
|
||||
<p id="{{ $id }}-error" class="text-sm text-red-600" role="alert" hidden data-custom-field-upload-target="error"></p>
|
||||
</x-ui.field>
|
||||
@break
|
||||
|
||||
@default
|
||||
<x-ui.field :label="$field['label']" :for="$id" :required="$required">
|
||||
<x-ui.input :id="$id" :name="$name" :required="$required" maxlength="255" />
|
||||
</x-ui.field>
|
||||
@endswitch
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -25,6 +25,7 @@
|
||||
:image="$product['image'] ?? null"
|
||||
:href="$product['href'] ?? '#'"
|
||||
:variant-id="$product['variantId'] ?? null"
|
||||
:has-custom-fields="$product['hasCustomFields'] ?? false"
|
||||
/>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@props([
|
||||
'accept' => null,
|
||||
'required' => false,
|
||||
'disabled' => false,
|
||||
])
|
||||
|
||||
<input
|
||||
type="file"
|
||||
@if ($accept) accept="{{ $accept }}" @endif
|
||||
@required($required)
|
||||
@disabled($disabled)
|
||||
{{ $attributes->merge([
|
||||
'class' => 'w-full py-2 text-sm
|
||||
file:mr-4 file:py-2 file:px-4 file:rounded-none file:border file:border-black file:bg-transparent
|
||||
file:font-semibold file:cursor-pointer file:transition-colors
|
||||
hover:file:bg-black hover:file:text-neutral-200
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-black
|
||||
disabled:cursor-not-allowed disabled:opacity-50',
|
||||
]) }}
|
||||
/>
|
||||
@@ -4,6 +4,7 @@
|
||||
'image' => null,
|
||||
'href' => '#',
|
||||
'variantId' => null,
|
||||
'hasCustomFields' => false,
|
||||
])
|
||||
|
||||
{{-- data-turbo-frame="_top" on the links: this card renders inside the
|
||||
@@ -28,7 +29,18 @@ class="w-full h-auto block"
|
||||
@endif
|
||||
</a>
|
||||
|
||||
@if ($variantId)
|
||||
@if ($hasCustomFields)
|
||||
{{-- Custom fields have to be filled in on the product page. --}}
|
||||
<x-ui.button
|
||||
:href="$href"
|
||||
size="md"
|
||||
position="absolute"
|
||||
data-turbo-frame="_top"
|
||||
class="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity duration-100"
|
||||
>
|
||||
{{ __('storefront.product.personalize') }}
|
||||
</x-ui.button>
|
||||
@elseif ($variantId)
|
||||
{{-- has-[...] forces the button visible while an add-to-cart error
|
||||
is showing, so it isn't only readable on hover — a shopper who
|
||||
already moved off the card (mouse or the click itself) must
|
||||
|
||||
@@ -60,7 +60,16 @@ class="w-full h-full object-cover"
|
||||
@endif
|
||||
</a>
|
||||
|
||||
@if ($product['variantId'] ?? null)
|
||||
@if ($product['hasCustomFields'] ?? false)
|
||||
<x-ui.button
|
||||
:href="$product['href']"
|
||||
size="md"
|
||||
position="absolute"
|
||||
class="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity duration-100"
|
||||
>
|
||||
{{ __('storefront.product.personalize') }}
|
||||
</x-ui.button>
|
||||
@elseif ($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"
|
||||
@@ -163,6 +172,7 @@ class="max-w-xs"
|
||||
:image="$product['image']"
|
||||
:href="$product['href']"
|
||||
:variant-id="$product['variantId'] ?? null"
|
||||
:has-custom-fields="$product['hasCustomFields'] ?? false"
|
||||
/>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -166,7 +166,7 @@ class="absolute bottom-6 right-8 text-white text-sm"
|
||||
@endif
|
||||
|
||||
@php
|
||||
$desc = strip_tags($product['description'] ?? '');
|
||||
$desc = html_entity_decode(strip_tags($product['description'] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$descTruncated = Str::limit($desc, 137);
|
||||
$descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.'));
|
||||
@endphp
|
||||
@@ -195,10 +195,16 @@ class="underline-slide font-semibold whitespace-nowrap"
|
||||
<x-checkout::add-to-cart
|
||||
:purchasable="$variantsData[0]['id'] ?? null"
|
||||
:quantity="false"
|
||||
class="flex items-stretch gap-10"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<x-ui.quantity name="quantity" />
|
||||
<x-ui.button type="submit" class="flex-1">{{ __('storefront.product.add_to_cart') }}</x-ui.button>
|
||||
@if(!empty($product['custom_fields']))
|
||||
<x-product-custom-fields :fields="$product['custom_fields']" />
|
||||
@endif
|
||||
|
||||
<div class="flex items-stretch gap-10">
|
||||
<x-ui.quantity name="quantity" />
|
||||
<x-ui.button type="submit" class="flex-1">{{ __('storefront.product.add_to_cart') }}</x-ui.button>
|
||||
</div>
|
||||
</x-checkout::add-to-cart>
|
||||
|
||||
{{-- Storefront-owned, not part of the checkout module — the
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
|
||||
<ul>
|
||||
@foreach ($lines as $line)
|
||||
<li>{{ $line->quantity }} × {{ $line->description }} — {{ $line->total?->formatted }}</li>
|
||||
<li>
|
||||
{{ $line->quantity }} × {{ $line->description }} — {{ $line->total?->formatted }}
|
||||
{{-- Text answers only — photo answers are deliberately left out of emails. --}}
|
||||
@foreach ($line->meta['custom_fields'] ?? [] as $field)
|
||||
@if ($field['type'] !== 'file')
|
||||
<br>{{ $field['label'] }}: {!! nl2br(e($field['value'])) !!}
|
||||
@endif
|
||||
@endforeach
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user