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 id of a File row the host's own upload endpoint * (CustomFieldUploadController) already created via boboko-core's * FileService — never the file's bytes, disk, or path, all of which * FileService alone is the source of truth for. A shopper can't point * this at someone else's file: the id must resolve to a File that is * BOTH unowned (isFileAnswerValid()) and tagged with * CustomFieldUploadController::PURPOSE, so an id belonging to an * already-ordered file (owned, and/or a different purpose entirely) is * rejected. Attaching the File to the real CartLine it belongs to * happens afterward, in boboko-core's own Modules\Core\File\Listeners\ * AttachCustomFieldFileToCartLine (listening for Cart\Events\ * CartLineAdded) — not here, since this method only builds the meta * $this->cart->addLine() is about to receive, before any CartLine * actually exists to own anything. * * 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') ->map(fn (array $field) => [...$field, 'label' => $this->resolveLabel($field['label'])]); if ($fields->isEmpty()) { return []; } $validated = Validator::make( $input, $fields->map(fn (array $field) => [ ($field['required'] ?? false) ? 'required' : 'nullable', ...match ($field['type']) { 'textarea' => ['string', 'max:2000'], 'file' => [function (string $attribute, mixed $value, Closure $fail) { if (! $this->isFileAnswerValid($value)) { $fail('validation.uploaded')->translate(); } }], default => ['string', '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' ? ['file_id' => (int) $validated[$field['key']]] : ['value' => $validated[$field['key']]]), ]) ->values() ->all(); return $answers === [] ? [] : ['custom_fields' => $answers]; } /** * Product::$custom_fields stores `label` as {locale: string} (see * boboko-core's Catalog\Filament\Pages\ManageProductCustomFields) — this * resolves it to the single current-locale string cart/order line meta * actually needs, the same filled()-over-?? fallback boboko-core's * ProductDocumentLocalizer uses for every other translated field (an * empty string for the current locale still falls through to the * store's default language, rather than showing blank). A product * saved before labels became translatable still has a plain string * here, returned as-is. */ private function resolveLabel(mixed $label): string { if (! is_array($label)) { return (string) $label; } $locale = App::getLocale(); $fallbackLocale = app(LanguageCache::class)->defaultLocale(); return filled($label[$locale] ?? null) ? $label[$locale] : ($label[$fallbackLocale] ?? ''); } private function isFileAnswerValid(mixed $fileId): bool { $file = File::find($fileId); return $file !== null && $file->purpose === CustomFieldUploadController::PURPOSE && $file->owner_id === null && app(FileService::class)->exists($file); } 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'); } }