generated from boboko/starter
254 lines
9.8 KiB
PHP
254 lines
9.8 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Checkout;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Controllers\CustomFieldUploadController;
|
|
use Closure;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Illuminate\View\View;
|
|
use Lunar\Exceptions\Carts\CartException;
|
|
use Lunar\Models\CartLine;
|
|
use Lunar\Models\ProductVariant;
|
|
use Illuminate\Support\Facades\App;
|
|
use Modules\Core\Cart\Exceptions\InvalidCouponException;
|
|
use Modules\Core\Cart\Services\CartService;
|
|
use Modules\Core\File\Models\File;
|
|
use Modules\Core\File\Services\FileService;
|
|
use Modules\Core\Localization\Services\LanguageCache;
|
|
|
|
/**
|
|
* Thin storefront cart endpoints for the checkout module. Every action mutates
|
|
* the session cart via boboko-core's CartService and returns the same
|
|
* server-rendered `cart-body` partial — the drawer's Stimulus controller swaps
|
|
* that fragment in place (no JSON, no client-side templating). $cart / $lines
|
|
* for the partial come from the view composer in CheckoutModuleServiceProvider.
|
|
*/
|
|
class CartController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly CartService $cart,
|
|
) {}
|
|
|
|
/**
|
|
* CartException here is Lunar's own add_to_cart validation pipeline
|
|
* (CartLineQuantity/CartLineStock) rejecting the line — most commonly
|
|
* "not enough stock at this quantity" for a tracked (purchasable =
|
|
* in_stock) variant. Its own message is an untranslated, hardcoded
|
|
* English string not meant for storefront display, so this returns our
|
|
* own translated one instead rather than passing it through — a
|
|
* storefront.* key rather than checkout.*, since this is a catalog/stock
|
|
* concern the storefront owns, not something specific to the portable
|
|
* checkout module.
|
|
*/
|
|
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 {
|
|
$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');
|
|
}
|
|
}
|