generated from boboko/starter
product custom fields
This commit is contained in:
@@ -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(),
|
||||
])),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user