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
+29
View File
@@ -0,0 +1,29 @@
# 3dealer
Storefront for 3dealer.gr: Laravel 12 + Lunar PHP (headless), on top of `boboko/core`. Front-end conventions are in [CLAUDE.md](CLAUDE.md).
## Scheduled jobs
These run in the `scheduler` container (`php artisan schedule:work`).
| Command | When | What it does |
|---|---|---|
| `custom-fields:prune-uploads` | Daily, 04:00 | Deletes custom-field photo uploads older than 24 h that no cart line or order line references. |
| `lunar:search:index` | Daily, 03:00 | Full product reindex. Registered by `boboko/core`. |
## Product custom fields
Admins can add custom fields to a product in the Lunar admin (the product's **Custom Fields** section, from `boboko/core`). The customer fills them in on the product page before adding the product to the cart. The answers are stored on the cart line (`meta.custom_fields`) and carried over to the order line.
| Field type | Storefront input | Limit |
|---|---|---|
| Short text | text input | 255 characters |
| Long text | textarea | 2,000 characters |
| File upload | photo upload | JPG, PNG, WEBP, HEIC/HEIF, up to 10 MB |
- **Photos upload as soon as they're picked.** They go to `POST /{locale}/custom-field-uploads`, which is limited to 20 per minute per client. They're stored on the private `local` disk under `storage/app/private/custom-field-uploads/`. Only an encrypted reference is sent with add-to-cart. The allowed types and size are set in `App\Http\Controllers\CustomFieldUploadController`.
- **Photos are never public.** The cart drawer, checkout summary and order confirmation link to a photo through a signed URL that expires after 2 hours.
- **Emails show text answers only**, never photos.
- **Photos are cleaned up automatically.** Photos never added to a cart are deleted by `custom-fields:prune-uploads` (see above). A photo on a cart line is kept as long as that cart line exists, and a photo on an order is kept indefinitely.
- **Products with custom fields can't be quick-added.** On product cards, the "add to cart" button becomes a link to the product page.
- Field labels are entered once in the admin and aren't translated, so they appear as entered in both `/el` and `/en`.
+5 -1
View File
@@ -19,7 +19,7 @@ final class ProductCard
{
/**
* @param array<string, mixed> $product one item from ProductService's localized array shape
* @return array{name: ?string, price: ?string, image: ?string, href: string, variantId: ?int}
* @return array{name: ?string, price: ?string, image: ?string, href: string, variantId: ?int, hasCustomFields: bool}
*/
public static function fromIndexed(array $product): array
{
@@ -34,6 +34,10 @@ public static function fromIndexed(array $product): array
// picker at listing-grid scope, unlike the product page's own
// color swatches.
'variantId' => $product['variants'][0]['id'] ?? null,
// A product with custom fields (photo upload, engraving text…)
// can't be quick-added from a card — the card links to the
// product page instead, even when every field is optional.
'hasCustomFields' => ! empty($product['custom_fields']),
];
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use App\Http\Controllers\CustomFieldUploadController;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Lunar\Models\CartLine;
use Lunar\Models\OrderLine;
/**
* Custom-field photos are uploaded the moment a shopper picks them (see
* CustomFieldUploadController), before add-to-cart — so a shopper who picks a
* photo and then leaves, or picks a different one, leaves a file nobody
* references. This deletes those: any upload older than the grace period that
* no cart line or order line's meta.custom_fields points to.
*
* The grace period covers a shopper still on the product page with a picked
* but not-yet-added photo. A file referenced by a cart line is kept for as
* long as that cart line exists; once the line (or its cart) is removed, the
* next run deletes the file. Order line references are kept indefinitely.
*/
class PruneCustomFieldUploads extends Command
{
protected $signature = 'custom-fields:prune-uploads {--hours=24 : Only delete unreferenced uploads older than this}';
protected $description = 'Delete custom-field photo uploads not referenced by any cart or order line';
public function handle(): int
{
$disk = Storage::disk(CustomFieldUploadController::DISK);
$referenced = $this->referencedPaths();
$cutoff = now()->subHours((int) $this->option('hours'))->getTimestamp();
$deleted = 0;
foreach ($disk->files(CustomFieldUploadController::DIRECTORY) as $path) {
if (isset($referenced[$path]) || $disk->lastModified($path) > $cutoff) {
continue;
}
$disk->delete($path);
$deleted++;
}
$this->info("Deleted {$deleted} unreferenced custom-field upload(s).");
return self::SUCCESS;
}
/**
* @return array<string, true>
*/
private function referencedPaths(): array
{
$paths = [];
foreach ([CartLine::class, OrderLine::class] as $model) {
$model::query()
->where('meta', 'like', '%custom_fields%')
->select('meta')
->cursor()
->each(function ($line) use (&$paths) {
foreach ($line->meta['custom_fields'] ?? [] as $field) {
if (! empty($field['path'])) {
$paths[$field['path']] = true;
}
}
});
}
return $paths;
}
}
@@ -3,8 +3,14 @@
namespace App\Http\Controllers\Checkout;
use App\Http\Controllers\Controller;
use Closure;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Models\CartLine;
@@ -41,12 +47,19 @@ public function add(string $locale, Request $request): View|JsonResponse
$data = $request->validate([
'purchasable_id' => ['required', 'integer'],
'quantity' => ['nullable', 'integer', 'min:1'],
'custom_fields' => ['nullable', 'array'],
]);
$variant = ProductVariant::findOrFail($data['purchasable_id']);
try {
$this->cart->addLine($variant, $data['quantity'] ?? 1);
$meta = $this->customFieldsMeta($variant, $data['custom_fields'] ?? []);
} catch (ValidationException $e) {
return response()->json(['error' => collect($e->errors())->flatten()->first()], 422);
}
try {
$this->cart->addLine($variant, $data['quantity'] ?? 1, $meta);
} catch (CartException) {
return $this->stockError($variant);
}
@@ -54,6 +67,98 @@ public function add(string $locale, Request $request): View|JsonResponse
return view('checkout::partials.cart-body');
}
/**
* The shopper's answers to the product's custom fields (boboko-core's
* Product::$custom_fields — {key, type: text|textarea|file, label,
* required}), as cart line meta. Lunar copies CartLine.meta onto the
* OrderLine at order creation, so this is also what the order keeps.
*
* Only keys the product actually defines are kept, nested under
* `custom_fields` — line meta also carries behavior flags (core's
* `saved_for_later` zeroes the line's price), so shopper input must never
* be merged into it directly. Label and type are snapshotted alongside
* each value so the cart/order still reads correctly if the product's
* fields are edited later.
*
* A `file` answer is the opaque reference returned by the host's upload
* endpoint: an encrypted JSON {disk, path, name, mime}. The module doesn't
* decide which files are acceptable (that's the host's upload endpoint) —
* it only checks the reference is genuine and the file still exists.
*
* Two adds with identical answers merge into one line (Lunar matches
* existing lines on meta); different answers stay separate lines.
*/
private function customFieldsMeta(ProductVariant $variant, array $input): array
{
$fields = collect($variant->product?->custom_fields ?? [])->keyBy('key');
if ($fields->isEmpty()) {
return [];
}
$validated = Validator::make(
$input,
$fields->map(fn (array $field) => [
($field['required'] ?? false) ? 'required' : 'nullable',
'string',
match ($field['type']) {
'textarea' => 'max:2000',
'file' => function (string $attribute, mixed $value, Closure $fail) {
if ($this->decodeUpload($value) === null) {
$fail('validation.uploaded')->translate();
}
},
default => 'max:255',
},
])->all(),
[],
$fields->map(fn (array $field) => $field['label'])->all(),
)->validate();
$answers = $fields
->filter(fn (array $field) => filled($validated[$field['key']] ?? null))
->map(fn (array $field) => [
'key' => $field['key'],
'label' => $field['label'],
'type' => $field['type'],
...($field['type'] === 'file'
? $this->fileAnswer($this->decodeUpload($validated[$field['key']]))
: ['value' => $validated[$field['key']]]),
])
->values()
->all();
return $answers === [] ? [] : ['custom_fields' => $answers];
}
/**
* @return array{disk: string, path: string, name: string, mime: ?string}|null
*/
private function decodeUpload(string $reference): ?array
{
try {
$upload = json_decode(Crypt::decryptString($reference), true);
} catch (DecryptException) {
return null;
}
if (! is_array($upload) || ! isset($upload['disk'], $upload['path'], $upload['name'])) {
return null;
}
return Storage::disk($upload['disk'])->exists($upload['path']) ? $upload : null;
}
private function fileAnswer(array $upload): array
{
return [
'value' => $upload['name'],
'disk' => $upload['disk'],
'path' => $upload['path'],
'mime' => $upload['mime'] ?? null,
];
}
public function updateLine(string $locale, Request $request, int $line): View|JsonResponse
{
$quantity = (int) $request->validate([
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Checkout;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Serves a file answer to a product custom field (a cart/order line's
* meta.custom_fields, see CartController::customFieldsMeta()) from its private
* disk. Only reachable through a temporary signed URL — generated per render
* by checkout::partials.line-custom-fields — so disk and path in the query
* can't be tampered with, and a link stops working once it expires.
*/
class CustomFieldFileController extends Controller
{
public function __invoke(string $locale, Request $request): StreamedResponse
{
$disk = Storage::disk((string) $request->query('disk'));
$path = (string) $request->query('path');
abort_unless($disk->exists($path), 404);
return $disk->response($path, null, ['Cache-Control' => 'private, max-age=3600']);
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Validator;
/**
* Stores the shopper's photo for a product custom field of type `file` (see
* boboko-core's Product::$custom_fields) the moment it's picked on the product
* page — before add-to-cart, see custom-field-upload-controller.js.
*
* Which files are acceptable is a per-site decision (this site: photographs),
* so it lives here in the storefront, not in the checkout module. The module's
* add-to-cart endpoint only ever receives the opaque `reference` returned
* below — an encrypted {disk, path, name, mime} payload, so a shopper can
* neither forge a reference to some other private file nor read the path —
* and copies it onto the cart line's meta (see Checkout\CartController::
* customFieldsMeta()).
*
* Stored on the private `local` disk: these are customers' personal photos,
* never reachable by a public URL. Uploads nobody added to a cart are removed
* by the custom-fields:prune-uploads command (see PruneCustomFieldUploads).
*/
class CustomFieldUploadController extends Controller
{
public const DISK = 'local';
public const DIRECTORY = 'custom-field-uploads';
public const MAX_KILOBYTES = 10240;
public const EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'];
/**
* For the file input's `accept` attribute — same list the server enforces.
*/
public static function accept(): string
{
return '.'.implode(',.', self::EXTENSIONS);
}
public function store(string $locale, Request $request): JsonResponse
{
// `label` is the admin-authored field label, only used as the
// :attribute in the validation message shown next to that field.
$validator = Validator::make(
$request->all(),
['file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES]],
[],
['file' => (string) $request->input('label', 'file')],
);
if ($validator->fails()) {
return response()->json(['error' => $validator->errors()->first('file')], 422);
}
$file = $request->file('file');
$path = $file->store(self::DIRECTORY, self::DISK);
abort_if($path === false, 500);
return response()->json([
'reference' => Crypt::encryptString(json_encode([
'disk' => self::DISK,
'path' => $path,
'name' => $file->getClientOriginalName(),
'mime' => $file->getMimeType(),
])),
]);
}
}
Generated
+3 -3
View File
@@ -515,11 +515,11 @@
},
{
"name": "boboko/core",
"version": "0.19.0",
"version": "0.20.0",
"source": {
"type": "git",
"url": "https://code.radical-elements.com/boboko/core.git",
"reference": "0437057e5d604e3c05479089071fcbe79ad69bb7"
"reference": "c7035d678275a6c7aae6eb2a2ee1b12569da1a3d"
},
"require": {
"laravel/framework": "^12.0",
@@ -569,7 +569,7 @@
}
},
"description": "Core module — authentication and shared panel behaviour",
"time": "2026-09-17T22:30:37+00:00"
"time": "2026-09-23T06:47:28+00:00"
},
{
"name": "brick/math",
@@ -68,6 +68,11 @@ private function lines(): array
'max.string' => ['The :attribute field must not be greater than :max characters.', 'Το πεδίο :attribute δεν πρέπει να ξεπερνά τους :max χαρακτήρες.'],
'between.numeric' => ['The :attribute field must be between :min and :max.', 'Το πεδίο :attribute πρέπει να είναι μεταξύ :min και :max.'],
'exists' => ['The selected :attribute is invalid.', 'Η επιλεγμένη τιμή για το πεδίο :attribute δεν είναι έγκυρη.'],
// Product custom-field photo uploads (CustomFieldUploadController, CartController).
'file' => ['The :attribute field must be a file.', 'Το πεδίο :attribute πρέπει να είναι αρχείο.'],
'mimes' => ['The :attribute field must be a file of type: :values.', 'Το πεδίο :attribute πρέπει να είναι αρχείο τύπου: :values.'],
'max.file' => ['The :attribute field must not be greater than :max kilobytes.', 'Το αρχείο στο πεδίο :attribute δεν πρέπει να ξεπερνά τα :max kilobytes.'],
'uploaded' => ['The :attribute failed to upload.', 'Η μεταφόρτωση στο πεδίο :attribute απέτυχε.'],
// ── Field names (checkout: billing/shipping address) ──────────
'attributes.contact_email' => ['email', 'email'],
+32
View File
@@ -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
}
}
+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)
@@ -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
+11 -1
View File
@@ -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>
+10 -4
View File
@@ -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 }} &times; {{ $line->description }} — {{ $line->total?->formatted }}</li>
<li>
{{ $line->quantity }} &times; {{ $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>
+7
View File
@@ -2,6 +2,7 @@
use App\Http\Controllers\Checkout\CartController;
use App\Http\Controllers\Checkout\CheckoutController;
use App\Http\Controllers\Checkout\CustomFieldFileController;
use Illuminate\Support\Facades\Route;
/*
@@ -52,6 +53,12 @@
->whereNumber('line')
->name('checkout.cart.remove');
// A line's custom-field file (e.g. the shopper's photo) — signed,
// temporary URLs only; see CustomFieldFileController.
Route::get('cart/custom-field-file', CustomFieldFileController::class)
->middleware('signed')
->name('checkout.custom-field-file');
Route::post('cart/coupon', [CartController::class, 'applyCoupon'])
->name('checkout.cart.coupon.apply');
+5
View File
@@ -2,7 +2,12 @@
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
// Deletes custom-field photo uploads (CustomFieldUploadController) older than
// 24h that no cart or order line references — see PruneCustomFieldUploads.
Schedule::command('custom-fields:prune-uploads')->dailyAt('04:00');
+8
View File
@@ -2,6 +2,7 @@
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\ContactController;
use App\Http\Controllers\CustomFieldUploadController;
use App\Http\Controllers\HomeController;
use App\Http\Controllers\LegalPageController;
use App\Http\Controllers\ProductController;
@@ -33,6 +34,13 @@
'product.stock-check',
);
// Photo for a product custom field, uploaded as soon as it's picked —
// see CustomFieldUploadController. Throttled: it writes to disk and
// needs no cart/session to call.
Route::post('/custom-field-uploads', [CustomFieldUploadController::class, 'store'])
->middleware('throttle:20,1')
->name('custom-field-upload.store');
Route::post('/products/{product}/reviews', [ProductController::class, 'storeReview'])->name(
'product.reviews.store',
);