generated from boboko/starter
product custom fields
This commit is contained in:
@@ -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`.
|
||||||
@@ -19,7 +19,7 @@ final class ProductCard
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $product one item from ProductService's localized array shape
|
* @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
|
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
|
// picker at listing-grid scope, unlike the product page's own
|
||||||
// color swatches.
|
// color swatches.
|
||||||
'variantId' => $product['variants'][0]['id'] ?? null,
|
'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;
|
namespace App\Http\Controllers\Checkout;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Encryption\DecryptException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
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 Illuminate\View\View;
|
||||||
use Lunar\Exceptions\Carts\CartException;
|
use Lunar\Exceptions\Carts\CartException;
|
||||||
use Lunar\Models\CartLine;
|
use Lunar\Models\CartLine;
|
||||||
@@ -41,12 +47,19 @@ public function add(string $locale, Request $request): View|JsonResponse
|
|||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'purchasable_id' => ['required', 'integer'],
|
'purchasable_id' => ['required', 'integer'],
|
||||||
'quantity' => ['nullable', 'integer', 'min:1'],
|
'quantity' => ['nullable', 'integer', 'min:1'],
|
||||||
|
'custom_fields' => ['nullable', 'array'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$variant = ProductVariant::findOrFail($data['purchasable_id']);
|
$variant = ProductVariant::findOrFail($data['purchasable_id']);
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (CartException) {
|
||||||
return $this->stockError($variant);
|
return $this->stockError($variant);
|
||||||
}
|
}
|
||||||
@@ -54,6 +67,98 @@ public function add(string $locale, Request $request): View|JsonResponse
|
|||||||
return view('checkout::partials.cart-body');
|
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
|
public function updateLine(string $locale, Request $request, int $line): View|JsonResponse
|
||||||
{
|
{
|
||||||
$quantity = (int) $request->validate([
|
$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
@@ -515,11 +515,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "boboko/core",
|
"name": "boboko/core",
|
||||||
"version": "0.19.0",
|
"version": "0.20.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://code.radical-elements.com/boboko/core.git",
|
"url": "https://code.radical-elements.com/boboko/core.git",
|
||||||
"reference": "0437057e5d604e3c05479089071fcbe79ad69bb7"
|
"reference": "c7035d678275a6c7aae6eb2a2ee1b12569da1a3d"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
@@ -569,7 +569,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Core module — authentication and shared panel behaviour",
|
"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",
|
"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 χαρακτήρες.'],
|
'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.'],
|
'between.numeric' => ['The :attribute field must be between :min and :max.', 'Το πεδίο :attribute πρέπει να είναι μεταξύ :min και :max.'],
|
||||||
'exists' => ['The selected :attribute is invalid.', 'Η επιλεγμένη τιμή για το πεδίο :attribute δεν είναι έγκυρη.'],
|
'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) ──────────
|
// ── Field names (checkout: billing/shipping address) ──────────
|
||||||
'attributes.contact_email' => ['email', 'email'],
|
'attributes.contact_email' => ['email', 'email'],
|
||||||
|
|||||||
@@ -215,6 +215,38 @@ .bbk-cart-item-variant {
|
|||||||
color: var(--bbk-color-muted);
|
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 {
|
.bbk-cart-item-unit {
|
||||||
margin: 0 0 0.625rem;
|
margin: 0 0 0.625rem;
|
||||||
color: var(--bbk-color-muted);
|
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 BackToTopController from './back-to-top-controller'
|
||||||
import CartCountController from './cart-count-controller'
|
import CartCountController from './cart-count-controller'
|
||||||
import CarouselController from './carousel-controller'
|
import CarouselController from './carousel-controller'
|
||||||
|
import CustomFieldUploadController from './custom-field-upload-controller'
|
||||||
import DropdownController from './dropdown-controller'
|
import DropdownController from './dropdown-controller'
|
||||||
import FrameScrollController from './frame-scroll-controller'
|
import FrameScrollController from './frame-scroll-controller'
|
||||||
import NavSearchController from './nav-search-controller'
|
import NavSearchController from './nav-search-controller'
|
||||||
@@ -25,6 +26,7 @@ export function registerControllers(application) {
|
|||||||
application.register('back-to-top', BackToTopController)
|
application.register('back-to-top', BackToTopController)
|
||||||
application.register('cart-count', CartCountController)
|
application.register('cart-count', CartCountController)
|
||||||
application.register('carousel', CarouselController)
|
application.register('carousel', CarouselController)
|
||||||
|
application.register('custom-field-upload', CustomFieldUploadController)
|
||||||
application.register('dropdown', DropdownController)
|
application.register('dropdown', DropdownController)
|
||||||
application.register('frame-scroll', FrameScrollController)
|
application.register('frame-scroll', FrameScrollController)
|
||||||
application.register('nav-search', NavSearchController)
|
application.register('nav-search', NavSearchController)
|
||||||
|
|||||||
@@ -60,6 +60,8 @@
|
|||||||
@if ($line->option)
|
@if ($line->option)
|
||||||
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
|
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
|
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
@if ($variantLabel)
|
@if ($variantLabel)
|
||||||
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
|
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||||
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
|
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
|
||||||
|
|
||||||
<form
|
<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"
|
:image="$product['image'] ?? null"
|
||||||
:href="$product['href'] ?? '#'"
|
:href="$product['href'] ?? '#'"
|
||||||
:variant-id="$product['variantId'] ?? null"
|
:variant-id="$product['variantId'] ?? null"
|
||||||
|
:has-custom-fields="$product['hasCustomFields'] ?? false"
|
||||||
/>
|
/>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</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,
|
'image' => null,
|
||||||
'href' => '#',
|
'href' => '#',
|
||||||
'variantId' => null,
|
'variantId' => null,
|
||||||
|
'hasCustomFields' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
{{-- data-turbo-frame="_top" on the links: this card renders inside the
|
{{-- data-turbo-frame="_top" on the links: this card renders inside the
|
||||||
@@ -28,7 +29,18 @@ class="w-full h-auto block"
|
|||||||
@endif
|
@endif
|
||||||
</a>
|
</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
|
{{-- has-[...] forces the button visible while an add-to-cart error
|
||||||
is showing, so it isn't only readable on hover — a shopper who
|
is showing, so it isn't only readable on hover — a shopper who
|
||||||
already moved off the card (mouse or the click itself) must
|
already moved off the card (mouse or the click itself) must
|
||||||
|
|||||||
@@ -60,7 +60,16 @@ class="w-full h-full object-cover"
|
|||||||
@endif
|
@endif
|
||||||
</a>
|
</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
|
<x-checkout::add-to-cart
|
||||||
:purchasable="$product['variantId']"
|
: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"
|
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']"
|
:image="$product['image']"
|
||||||
:href="$product['href']"
|
:href="$product['href']"
|
||||||
:variant-id="$product['variantId'] ?? null"
|
:variant-id="$product['variantId'] ?? null"
|
||||||
|
:has-custom-fields="$product['hasCustomFields'] ?? false"
|
||||||
/>
|
/>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ class="absolute bottom-6 right-8 text-white text-sm"
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
@php
|
@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);
|
$descTruncated = Str::limit($desc, 137);
|
||||||
$descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.'));
|
$descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.'));
|
||||||
@endphp
|
@endphp
|
||||||
@@ -195,10 +195,16 @@ class="underline-slide font-semibold whitespace-nowrap"
|
|||||||
<x-checkout::add-to-cart
|
<x-checkout::add-to-cart
|
||||||
:purchasable="$variantsData[0]['id'] ?? null"
|
:purchasable="$variantsData[0]['id'] ?? null"
|
||||||
:quantity="false"
|
:quantity="false"
|
||||||
class="flex items-stretch gap-10"
|
class="flex flex-col gap-6"
|
||||||
>
|
>
|
||||||
<x-ui.quantity name="quantity" />
|
@if(!empty($product['custom_fields']))
|
||||||
<x-ui.button type="submit" class="flex-1">{{ __('storefront.product.add_to_cart') }}</x-ui.button>
|
<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>
|
</x-checkout::add-to-cart>
|
||||||
|
|
||||||
{{-- Storefront-owned, not part of the checkout module — the
|
{{-- Storefront-owned, not part of the checkout module — the
|
||||||
|
|||||||
@@ -4,7 +4,15 @@
|
|||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
@foreach ($lines as $line)
|
@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
|
@endforeach
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Checkout\CartController;
|
use App\Http\Controllers\Checkout\CartController;
|
||||||
use App\Http\Controllers\Checkout\CheckoutController;
|
use App\Http\Controllers\Checkout\CheckoutController;
|
||||||
|
use App\Http\Controllers\Checkout\CustomFieldFileController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -52,6 +53,12 @@
|
|||||||
->whereNumber('line')
|
->whereNumber('line')
|
||||||
->name('checkout.cart.remove');
|
->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'])
|
Route::post('cart/coupon', [CartController::class, 'applyCoupon'])
|
||||||
->name('checkout.cart.coupon.apply');
|
->name('checkout.cart.coupon.apply');
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
Artisan::command('inspire', function () {
|
||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
})->purpose('Display an 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');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\CategoryController;
|
use App\Http\Controllers\CategoryController;
|
||||||
use App\Http\Controllers\ContactController;
|
use App\Http\Controllers\ContactController;
|
||||||
|
use App\Http\Controllers\CustomFieldUploadController;
|
||||||
use App\Http\Controllers\HomeController;
|
use App\Http\Controllers\HomeController;
|
||||||
use App\Http\Controllers\LegalPageController;
|
use App\Http\Controllers\LegalPageController;
|
||||||
use App\Http\Controllers\ProductController;
|
use App\Http\Controllers\ProductController;
|
||||||
@@ -33,6 +34,13 @@
|
|||||||
'product.stock-check',
|
'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(
|
Route::post('/products/{product}/reviews', [ProductController::class, 'storeReview'])->name(
|
||||||
'product.reviews.store',
|
'product.reviews.store',
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user