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
@@ -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([