product custom fields

This commit is contained in:
elvira
2026-09-23 17:53:23 +03:00
parent a4240b6474
commit 580adac33a
24 changed files with 670 additions and 12 deletions
@@ -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
}
}
+2
View File
@@ -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)