Chore: Updating Box Now controllers, updates to product indexer for recommended products

This commit is contained in:
2026-09-25 22:01:55 +03:00
parent beb7d5faba
commit fd3bbbe7de
11 changed files with 612 additions and 1 deletions
+10 -1
View File
@@ -3,6 +3,8 @@
namespace Modules\Core\Catalog\Recommendations;
use Illuminate\Support\Collection;
use Lunar\Facades\ModelManifest;
use Lunar\Models\Contracts\Product as ProductContract;
use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule;
@@ -18,7 +20,14 @@ class RandomRule implements RecommendationRule
{
public function recommend(Product $product, int $limit, array $exclude): Collection
{
return Product::query()
// ModelManifest::get(), not Product::query() directly — the base
// Lunar\Models\Product has no custom_fields cast/fillable entry
// (see Catalog\Models\Product's own docblock), so a recommendation
// resolved as the base class silently lost that field once
// ProductIndexer started reading it for recommendations.has_custom_fields.
$model = ModelManifest::get(ProductContract::class);
return $model::query()
->whereKeyNot($exclude)
->inRandomOrder()
->limit($limit)
+17
View File
@@ -189,6 +189,23 @@ class ProductIndexer extends BaseProductIndexer
'name' => $recommendation->translateAttribute('name'),
'price' => $this->cheapestPrice($recommendation, $currency),
'image' => $recommendation->media->first() ? $this->mapMedia($recommendation->media->first())['thumb'] : null,
// Same fields ProductCard::fromIndexed() (3dealer) reads off
// a normal listing document to decide which button a card
// shows at all — a recommendation with neither used to
// render no button whatsoever, since it's built from this
// embedded shape rather than a full ProductService document.
// variant_id: same "first variant, no picker at card scope"
// default every other listing card uses. custom_fields is
// only readable at all because every RecommendationRule now
// resolves products through ModelManifest (see Recommendations\
// RandomRule) rather than the base Lunar\Models\Product
// directly — that class has no custom_fields cast/fillable
// entry (see Catalog\Models\Product's own docblock), so a
// recommendation resolved as the base class would have
// silently read null here regardless of the product's real
// custom fields.
'variant_id' => $recommendation->variants->first()?->id,
'has_custom_fields' => ! empty($recommendation->custom_fields),
])
->all();
@@ -180,6 +180,34 @@ class CheckoutTranslationsSeeder extends Seeder
'page.confirmation_shipping_to' => ['Shipping to', 'Αποστολή σε'],
'page.confirmation_billing' => ['Billing', 'Χρέωση'],
'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'],
'page.box_now_locker_label' => [
'Choose a Box Now locker',
'Επίλεξε Box Now locker',
],
'page.box_now_locker_loading' => [
'Loading lockers…',
'Φόρτωση lockers…',
],
'page.box_now_locker_required' => [
'Choose a Box Now locker to continue.',
'Επίλεξε ένα Box Now locker για να συνεχίσεις.',
],
'page.box_now_locker_select' => [
'Select this locker',
'Επιλογή αυτού του locker',
],
'page.box_now_locker_selected' => [
'Selected',
'Επιλέχθηκε',
],
'page.box_now_locker_search' => [
'Search by area or address…',
'Αναζήτηση με περιοχή ή διεύθυνση…',
],
'page.box_now_locker_no_results' => [
'No lockers match your search.',
'Δεν βρέθηκαν lockers για αυτή την αναζήτηση.',
]
];
}
}
@@ -20,12 +20,14 @@ use Lunar\Models\Order;
use Lunar\Models\State;
use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\NoShippingAddressException;
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Checkout\Services\CheckoutService;
use Modules\Core\Customer\Services\CustomerAccountService;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
/**
* The checkout page — one page, sections (contact / billing / shipping /
@@ -280,6 +282,77 @@ class CheckoutController extends Controller
return $this->fragments($cart, $options);
}
/**
* A plain, own-hosted stand-in for Box Now's Destination Map widget —
* that widget only talks to their Production environment (see their
* Partner API manual §4.1), which is useless while developing against
* Stage credentials. Same underlying data (GET /destinations), no map.
*/
public function boxNowLockers(string $locale, BoxNowClient $boxNow): JsonResponse
{
$lockers = collect($boxNow->destinations())
// Drops entries with a blank `name` (e.g. id 8288, "Virtual
// Locker" in Sudan at lat 12.3/lng 25.3) — a real, in-range
// coordinate, but sandbox test fixture noise rather than an
// actual pickup point, and it alone was enough to make
// fitBounds() below zoom the map out to the whole Balkans/
// Middle East to fit every marker's cluster in Greece.
->filter(fn (array $destination) => filled($destination['name'] ?? null))
->map(fn (array $destination) => [
'id' => $destination['id'],
'name' => $destination['name'] ?? $destination['title'] ?? $destination['id'],
'addressLine1' => $destination['addressLine1'] ?? null,
'addressLine2' => $destination['addressLine2'] ?? null,
'postalCode' => $destination['postalCode'] ?? null,
'country' => $destination['country'] ?? null,
'note' => $destination['note'] ?? null,
'image' => $destination['image'] ?? null,
'lat' => isset($destination['lat']) ? (float) $destination['lat'] : null,
'lng' => isset($destination['lng']) ? (float) $destination['lng'] : null,
])
// Box Now's own Stage/sandbox data has at least one malformed
// entry observed in practice (locker id 47: lat/lng as huge
// integers with the decimal point apparently dropped, e.g.
// 96065874308606 instead of ~37.96) — a single such point blows
// out L.featureGroup().getBounds() on the frontend, zooming the
// map out to near-nothing with every real marker imperceptible
// at that scale. Valid latitude/longitude ranges are absolute,
// not guesswork, so filtering on them is safe regardless of
// what BoxNow's API does or doesn't fix upstream.
->filter(fn (array $locker) => $locker['lat'] !== null && $locker['lng'] !== null
&& abs($locker['lat']) <= 90 && abs($locker['lng']) <= 180)
->values();
return response()->json(['lockers' => $lockers]);
}
/**
* Persists the shopper's chosen locker (radio/select change, same
* autosave shape as selectShippingOption()) via
* CheckoutService::selectBoxNowLocker() onto the cart's shipping
* address meta.
*/
public function selectBoxNowLocker(string $locale, Request $request): JsonResponse
{
$locationId = (string) $request->input('locker_id');
if ($locationId === '') {
return response()->json(['errors' => ['locker_id' => __('checkout.page.box_now_locker_required')]], 422);
}
try {
$this->checkout->selectBoxNowLocker([
'locationId' => $locationId,
'name' => (string) $request->input('locker_name'),
'addressLine1' => (string) $request->input('locker_address'),
]);
} catch (NoShippingAddressException) {
return response()->json(['errors' => ['locker_id' => __('checkout.page.box_now_locker_required')]], 422);
}
return response()->json(['ok' => true]);
}
/**
* Autosave-select a payment method (radio change). Persists it via
* CheckoutService (which also records it on Cart::meta and re-snapshots
+8
View File
@@ -14,6 +14,7 @@ use Modules\Core\Checkout\Http\Controllers\CheckoutController;
* Loaded from Providers\CheckoutModuleServiceProvider inside the `web`
* middleware group.
*/
Route::prefix('{locale}')
->middleware('locale')
->group(function () {
@@ -27,6 +28,13 @@ Route::prefix('{locale}')
Route::post('checkout/shipping-option', [CheckoutController::class, 'selectShippingOption'])
->name('checkout.shipping-option.select');
Route::get('checkout/box-now/lockers', [CheckoutController::class, 'boxNowLockers'])
->name('checkout.box-now.lockers');
Route::post('checkout/box-now/locker', [CheckoutController::class, 'selectBoxNowLocker'])
->name('checkout.box-now.locker.select');
Route::post('checkout/payment-method', [CheckoutController::class, 'selectPaymentMethod'])
->name('checkout.payment-method.select');