generated from boboko/starter
234 lines
8.5 KiB
PHP
234 lines
8.5 KiB
PHP
<?php
|
|
|
|
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;
|
|
use Lunar\Models\ProductVariant;
|
|
use Modules\Core\Cart\Exceptions\InvalidCouponException;
|
|
use Modules\Core\Cart\Services\CartService;
|
|
|
|
/**
|
|
* 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 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');
|
|
}
|
|
}
|