validate([ 'purchasable_id' => ['required', 'integer'], 'quantity' => ['nullable', 'integer', 'min:1'], 'custom_fields' => ['nullable', 'array'], ]); $variant = ProductVariant::findOrFail($data['purchasable_id']); try { $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); } 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([ 'quantity' => ['required', 'integer', 'min:0'], ])['quantity']; try { $quantity === 0 ? $this->cart->removeLine($line) : $this->cart->updateLine($line, $quantity); } catch (CartException) { $variant = CartLine::find($line)?->purchasable; return $this->stockError($variant instanceof ProductVariant ? $variant : null); } return view('checkout::partials.cart-body'); } /** * getTotalInventory() is the same number canBeFulfilledAtQuantity() * checked against (stock, for a tracked in_stock variant) — telling the * shopper how many are actually left beats a generic "not enough stock" * they'd otherwise have to guess around by trial and error. */ private function stockError(?ProductVariant $variant): JsonResponse { $available = $variant?->getTotalInventory() ?? 0; return response()->json([ 'error' => trans_choice('storefront.product.add_to_cart_failed', $available, ['count' => $available]), ], 422); } public function remove(string $locale, int $line): View { $this->cart->removeLine($line); return view('checkout::partials.cart-body'); } /** * A bad code is a normal, expected outcome here (typo, expired code), not * an error state for the request — it re-renders the same cart-body * partial with $couponError set, rather than a 4xx/redirect, so the fetch * + swap in bbk-cart-controller stays the one code path for every cart * mutation. */ public function applyCoupon(string $locale, Request $request): View { $code = $request->validate([ 'code' => ['required', 'string'], ])['code']; $couponError = false; try { $this->cart->applyCoupon($code); } catch (InvalidCouponException) { $couponError = true; } return view('checkout::partials.cart-body', ['couponError' => $couponError]); } public function removeCoupon(string $locale): View { $this->cart->removeCoupon(); return view('checkout::partials.cart-body'); } }