generated from boboko/starter
cart drawer and general structure
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Checkout;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function add(string $locale, Request $request): View
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'purchasable_id' => ['required', 'integer'],
|
||||||
|
'quantity' => ['nullable', 'integer', 'min:1'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$variant = ProductVariant::findOrFail($data['purchasable_id']);
|
||||||
|
|
||||||
|
$this->cart->addLine($variant, $data['quantity'] ?? 1);
|
||||||
|
|
||||||
|
return view('checkout::partials.cart-body');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateLine(string $locale, Request $request, int $line): View
|
||||||
|
{
|
||||||
|
$quantity = (int) $request->validate([
|
||||||
|
'quantity' => ['required', 'integer', 'min:0'],
|
||||||
|
])['quantity'];
|
||||||
|
|
||||||
|
$quantity === 0
|
||||||
|
? $this->cart->removeLine($line)
|
||||||
|
: $this->cart->updateLine($line, $quantity);
|
||||||
|
|
||||||
|
return view('checkout::partials.cart-body');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\View\View as ViewInstance;
|
||||||
|
use Modules\Core\Cart\Services\CartService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seam for the (currently in-repo) cart + checkout module.
|
||||||
|
*
|
||||||
|
* Everything the module needs to boot inside a host app lives here: its view /
|
||||||
|
* component namespaces, its routes, and the composer that feeds the
|
||||||
|
* always-present cart drawer. Strings (__('checkout.cart.*')) are NOT wired up
|
||||||
|
* here — same as storefront.* elsewhere in this app, they resolve through
|
||||||
|
* Lunar's DB-backed translation UI (spatie/laravel-translation-loader), added
|
||||||
|
* manually there rather than shipped as lang/ files. The module's own files
|
||||||
|
* (resources/views/checkout/**, resources/js/checkout/**, resources/css/checkout.css,
|
||||||
|
* routes/checkout.php, app/Http/Controllers/Checkout/**) contain nothing
|
||||||
|
* 3dealer-specific.
|
||||||
|
*
|
||||||
|
* When the module is extracted to boboko-core, this class is deleted and its
|
||||||
|
* body becomes the package's own ServiceProvider. The only other host wiring
|
||||||
|
* is the one registerCheckout() call in resources/js/app.js and the two lines
|
||||||
|
* in the layout (the checkout.css @vite entry and @include('checkout::drawer')).
|
||||||
|
*/
|
||||||
|
class CheckoutModuleServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->loadViewsFrom(resource_path('views/checkout'), 'checkout');
|
||||||
|
Blade::anonymousComponentNamespace('checkout::components', 'checkout');
|
||||||
|
|
||||||
|
Route::middleware('web')->group(base_path('routes/checkout.php'));
|
||||||
|
|
||||||
|
// The drawer is rendered on every page (from the layout) and its body
|
||||||
|
// partial is re-rendered on every cart mutation — both need the current
|
||||||
|
// cart without a controller in the loop.
|
||||||
|
View::composer(
|
||||||
|
['checkout::drawer', 'checkout::partials.cart-body'],
|
||||||
|
function (ViewInstance $view) {
|
||||||
|
$service = app(CartService::class);
|
||||||
|
$cart = $service->current();
|
||||||
|
|
||||||
|
$view->with('cart', $cart);
|
||||||
|
$view->with('lines', $cart ? $service->activeLines($cart) : collect());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Providers\AppServiceProvider;
|
use App\Providers\AppServiceProvider;
|
||||||
|
use App\Providers\CheckoutModuleServiceProvider;
|
||||||
use App\Providers\PanelServiceProvider;
|
use App\Providers\PanelServiceProvider;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
PanelServiceProvider::class,
|
PanelServiceProvider::class,
|
||||||
|
CheckoutModuleServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "./fonts.css";
|
@import "./fonts.css";
|
||||||
@import "./dropdown.css";
|
@import "./dropdown.css";
|
||||||
|
|
||||||
|
/* Note for anyone theming the checkout module (resources/css/checkout.css,
|
||||||
|
.bbk-* classes): it's deliberately plain, unlayered CSS, not inside any
|
||||||
|
@layer — so override it with plain rules here too, not from inside
|
||||||
|
@layer components/utilities, which would lose to it. See checkout.css's
|
||||||
|
file-level comment for why. */
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
@source '../../storage/framework/views/*.php';
|
@source '../../storage/framework/views/*.php';
|
||||||
@source '../**/*.blade.php';
|
@source '../**/*.blade.php';
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
/*
|
||||||
|
* Cart + checkout module — generic default styling.
|
||||||
|
*
|
||||||
|
* Deliberately NOT wrapped in a Tailwind-style `@layer`. An earlier version
|
||||||
|
* put these rules in `@layer bbk-checkout`, positioned (via a cross-file
|
||||||
|
* @layer ordering statement) to sit between Tailwind's `base` and
|
||||||
|
* `components` — in theory enough to beat Preflight's element resets while
|
||||||
|
* still losing to a host override. In practice a build tool processing each
|
||||||
|
* CSS file in isolation (Vite/Lightning CSS here) optimizes away exactly the
|
||||||
|
* cross-file ordering information that trick depends on, so it silently
|
||||||
|
* didn't work: Preflight's `button { background-color: transparent }`,
|
||||||
|
* `* { border-width: 0 }` etc. (layered, in `base`) were beating every
|
||||||
|
* `.bbk-*` rule below regardless of specificity — buttons with no
|
||||||
|
* background, no border, wrong font-size.
|
||||||
|
*
|
||||||
|
* Plain, unlayered CSS sidesteps the whole problem: an unlayered rule always
|
||||||
|
* beats ANY layered rule (Preflight included), full stop, no ordering tricks,
|
||||||
|
* nothing a bundler can silently invalidate. This file is loaded BEFORE the
|
||||||
|
* host's own stylesheet (see the @vite call in the layout <head>), so:
|
||||||
|
*
|
||||||
|
* - a later PLAIN (unlayered) `.bbk-*` rule in the host stylesheet wins —
|
||||||
|
* same specificity, later in source order
|
||||||
|
* - a later host rule with a MORE specific selector wins regardless
|
||||||
|
* - a host rule inside `@layer components`/`@layer utilities` does NOT
|
||||||
|
* win — unlayered always beats layered. Theme this module from plain
|
||||||
|
* rules in app.css, not from inside a Tailwind layer.
|
||||||
|
*
|
||||||
|
* Two ways to theme this, cheapest first:
|
||||||
|
*
|
||||||
|
* 1. Redefine the --bbk-* custom properties below (from :root, or scoped to
|
||||||
|
* .bbk-cart for a cart-only override) — covers colour, radius, shadow,
|
||||||
|
* font without touching a single selector below.
|
||||||
|
*
|
||||||
|
* :root { --bbk-color-accent: var(--color-brand); --bbk-radius: 0; }
|
||||||
|
*
|
||||||
|
* 2. Override individual `.bbk-*` rules directly (as plain rules, per
|
||||||
|
* above) for anything structural (spacing, layout) the variables don't
|
||||||
|
* cover.
|
||||||
|
*
|
||||||
|
* This file's own look is a deliberately neutral placeholder — inoffensive,
|
||||||
|
* not "designed" — so a project always has something reasonable before it
|
||||||
|
* themes; it is not meant to be edited per project.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bbk-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
|
||||||
|
--bbk-color-text: #18181b;
|
||||||
|
--bbk-color-muted: #71717a;
|
||||||
|
--bbk-color-bg: #ffffff;
|
||||||
|
--bbk-color-bg-muted: #f4f4f5;
|
||||||
|
--bbk-color-border: #e4e4e7;
|
||||||
|
--bbk-color-accent: #18181b;
|
||||||
|
--bbk-color-accent-text: #ffffff;
|
||||||
|
--bbk-color-danger: #dc2626;
|
||||||
|
|
||||||
|
--bbk-radius: 8px;
|
||||||
|
--bbk-radius-sm: 4px;
|
||||||
|
--bbk-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart[hidden] { display: none; }
|
||||||
|
|
||||||
|
.bbk-cart {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
font-family: var(--bbk-font);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--bbk-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-backdrop { opacity: 1; }
|
||||||
|
|
||||||
|
.bbk-cart-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(420px, 100vw);
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bbk-color-bg);
|
||||||
|
box-shadow: var(--bbk-shadow);
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-panel { transform: translateX(0); }
|
||||||
|
|
||||||
|
.bbk-cart-panel-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem 1.5rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--bbk-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-heading {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-dismiss,
|
||||||
|
.bbk-cart-item-remove,
|
||||||
|
.bbk-cart-qty-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-dismiss {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-dismiss:hover { color: var(--bbk-color-text); background: var(--bbk-color-bg-muted); }
|
||||||
|
|
||||||
|
.bbk-cart-item-remove:hover { color: var(--bbk-color-danger); }
|
||||||
|
|
||||||
|
.bbk-cart-dismiss:focus-visible,
|
||||||
|
.bbk-cart-item-remove:focus-visible,
|
||||||
|
.bbk-cart-qty-btn:focus-visible,
|
||||||
|
.bbk-cart-qty-input:focus-visible,
|
||||||
|
.bbk-cart-checkout:focus-visible,
|
||||||
|
.bbk-cart-coupon-input:focus-visible,
|
||||||
|
.bbk-cart-coupon-submit:focus-visible,
|
||||||
|
.bbk-cart-coupon-remove:focus-visible {
|
||||||
|
outline: 2px solid var(--bbk-color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-panel-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-items {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px 1fr auto;
|
||||||
|
gap: 0.875rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-item-media img {
|
||||||
|
display: block;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
background: var(--bbk-color-bg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-item-detail { min-width: 0; }
|
||||||
|
|
||||||
|
.bbk-cart-item-title {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-item-unit {
|
||||||
|
margin: 0 0 0.625rem;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-item-aside {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-item-total { margin: 0; font-weight: 600; }
|
||||||
|
|
||||||
|
.bbk-cart-item-remove {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-qty {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-qty-btn {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
background: var(--bbk-color-bg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-qty-btn:hover { background: var(--bbk-color-border); color: var(--bbk-color-text); }
|
||||||
|
|
||||||
|
.bbk-cart-qty-input {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border: 0;
|
||||||
|
border-left: 1px solid var(--bbk-color-border);
|
||||||
|
border-right: 1px solid var(--bbk-color-border);
|
||||||
|
text-align: center;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--bbk-color-bg);
|
||||||
|
appearance: textfield;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-qty-input::-webkit-outer-spin-button,
|
||||||
|
.bbk-cart-qty-input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-summary {
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--bbk-color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-summary-row--discount { color: var(--bbk-color-danger); }
|
||||||
|
|
||||||
|
.bbk-cart-summary-row--total {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--bbk-color-border);
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-coupon-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-coupon-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--bbk-color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-coupon-submit {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
background: var(--bbk-color-bg-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-coupon-submit:hover { background: var(--bbk-color-border); }
|
||||||
|
|
||||||
|
.bbk-cart-coupon-applied {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
background: var(--bbk-color-bg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-coupon-code {
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-coupon-remove {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-coupon-remove:hover { color: var(--bbk-color-danger); }
|
||||||
|
|
||||||
|
.bbk-cart-coupon-error {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--bbk-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-checkout {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
border: 1px solid var(--bbk-color-accent);
|
||||||
|
border-radius: var(--bbk-radius);
|
||||||
|
background: var(--bbk-color-accent);
|
||||||
|
color: var(--bbk-color-accent-text);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-checkout:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
.bbk-cart-checkout:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
}
|
||||||
@@ -10,8 +10,13 @@ window.Turbo.session.drive = false;
|
|||||||
|
|
||||||
import { Application } from "@hotwired/stimulus";
|
import { Application } from "@hotwired/stimulus";
|
||||||
import { registerControllers } from "./stimulus/index";
|
import { registerControllers } from "./stimulus/index";
|
||||||
|
import { registerCheckout } from "./checkout";
|
||||||
|
|
||||||
const application = Application.start();
|
const application = Application.start();
|
||||||
application.debug = false;
|
application.debug = false;
|
||||||
|
|
||||||
registerControllers(application);
|
registerControllers(application);
|
||||||
|
|
||||||
|
// Portable cart + checkout module (destined for boboko-core). Owns its own
|
||||||
|
// bbk-* Stimulus controllers; this is the only wiring line it needs here.
|
||||||
|
registerCheckout(application);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
import { csrfToken } from './csrf'
|
||||||
|
|
||||||
|
// Sits on an <x-checkout::add-to-cart> <form>. Submits the line to the cart
|
||||||
|
// via fetch and hands the server-rendered cart body to the drawer through the
|
||||||
|
// `bbk-cart:changed` window event. No DOM building here — the drawer
|
||||||
|
// (bbk-cart-controller) owns rendering.
|
||||||
|
export default class extends Controller {
|
||||||
|
async add(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const form = this.element
|
||||||
|
const submit = form.querySelector('[type="submit"]')
|
||||||
|
|
||||||
|
form.setAttribute('data-bbk-add-to-cart-state', 'loading')
|
||||||
|
if (submit) submit.disabled = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': csrfToken(),
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
body: new FormData(form),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) return
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
|
||||||
|
detail: { html: await response.text() },
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
form.removeAttribute('data-bbk-add-to-cart-state')
|
||||||
|
if (submit) submit.disabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
import { csrfToken } from './csrf'
|
||||||
|
|
||||||
|
// Drives the slide-in cart drawer. One instance, on the drawer root in
|
||||||
|
// checkout/drawer.blade.php.
|
||||||
|
//
|
||||||
|
// - listens on window for `bbk-cart:changed` (from bbk-add-to-cart and from
|
||||||
|
// this drawer's own line forms) and swaps in the server-rendered cart body
|
||||||
|
// - handles the in-drawer quantity / remove forms (fetch + method spoofing)
|
||||||
|
// - re-emits `bbk-cart:updated` {count, total} after every render so the host
|
||||||
|
// (e.g. the header bag icon) can react
|
||||||
|
//
|
||||||
|
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
|
||||||
|
// attribute on the root, nothing here touches styles or class lists.
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['panel', 'body']
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.onChanged = this.onChanged.bind(this)
|
||||||
|
this.onKeydown = this.onKeydown.bind(this)
|
||||||
|
|
||||||
|
window.addEventListener('bbk-cart:changed', this.onChanged)
|
||||||
|
window.addEventListener('bbk-cart:open', this.open.bind(this))
|
||||||
|
document.addEventListener('keydown', this.onKeydown)
|
||||||
|
|
||||||
|
// Prime the host with the count rendered server-side on page load.
|
||||||
|
this.emitUpdated(this.element.querySelector('[data-bbk-cart-count]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
window.removeEventListener('bbk-cart:changed', this.onChanged)
|
||||||
|
document.removeEventListener('keydown', this.onKeydown)
|
||||||
|
}
|
||||||
|
|
||||||
|
onChanged(event) {
|
||||||
|
if (event.detail?.html) this.replaceBody(event.detail.html)
|
||||||
|
this.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeydown(event) {
|
||||||
|
if (event.key === 'Escape' && !this.element.hidden) this.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
if (!this.element.hidden) return
|
||||||
|
this.element.hidden = false
|
||||||
|
// Next frame, so the panel transitions from its off-canvas start.
|
||||||
|
requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open'))
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.element.removeAttribute('data-bbk-cart-state')
|
||||||
|
|
||||||
|
const panel = this.panelTarget
|
||||||
|
const done = () => {
|
||||||
|
this.element.hidden = true
|
||||||
|
panel.removeEventListener('transitionend', done)
|
||||||
|
}
|
||||||
|
panel.addEventListener('transitionend', done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// change on a line quantity input, or submit of a line's remove form
|
||||||
|
submit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const form = event.target.closest('form')
|
||||||
|
if (form) this.send(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
// +/- stepper buttons inside a line
|
||||||
|
step(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const form = event.target.closest('form')
|
||||||
|
const input = form.querySelector('input[type="number"]')
|
||||||
|
const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir))
|
||||||
|
input.value = String(next)
|
||||||
|
this.send(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(form) {
|
||||||
|
this.bodyTarget.setAttribute('aria-busy', 'true')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': csrfToken(),
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
body: new FormData(form),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) this.replaceBody(await response.text())
|
||||||
|
} finally {
|
||||||
|
this.bodyTarget.removeAttribute('aria-busy')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceBody(html) {
|
||||||
|
this.bodyTarget.innerHTML = html
|
||||||
|
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
emitUpdated(node) {
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('bbk-cart:updated', {
|
||||||
|
detail: {
|
||||||
|
count: parseInt(node.dataset.bbkCartCount || '0', 10),
|
||||||
|
total: parseInt(node.dataset.bbkCartTotal || '0', 10),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Reads the CSRF token from the standard <meta name="csrf-token"> tag every
|
||||||
|
// boboko host renders in its layout <head>. Kept as its own module so both
|
||||||
|
// checkout controllers share one source.
|
||||||
|
export function csrfToken() {
|
||||||
|
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import BbkAddToCartController from './bbk-add-to-cart-controller'
|
||||||
|
import BbkCartController from './bbk-cart-controller'
|
||||||
|
|
||||||
|
// Registers the checkout module's Stimulus controllers onto the host app's
|
||||||
|
// Stimulus application. Call once from the host's JS entry point:
|
||||||
|
//
|
||||||
|
// import { registerCheckout } from './checkout'
|
||||||
|
// registerCheckout(application)
|
||||||
|
//
|
||||||
|
// When this module moves to boboko-core this file ships with it unchanged;
|
||||||
|
// only that one import line in the host entry point differs per project.
|
||||||
|
export function registerCheckout(application) {
|
||||||
|
application.register('bbk-add-to-cart', BbkAddToCartController)
|
||||||
|
application.register('bbk-cart', BbkCartController)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
// 3dealer-side glue for the checkout module. The module owns the cart and emits
|
||||||
|
// `bbk-cart:updated` {count, total} on window after every change; this reflects
|
||||||
|
// the line count on the header bag icon. How (or whether) that count is shown
|
||||||
|
// is the host's call — hence this lives here, not in the module.
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['badge']
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.onUpdate = (event) => this.render(event.detail?.count ?? 0)
|
||||||
|
window.addEventListener('bbk-cart:updated', this.onUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
window.removeEventListener('bbk-cart:updated', this.onUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header cart icon click — there's no separate cart page, the drawer IS
|
||||||
|
// the cart. `bbk-cart:open` is the module's own event, already listened
|
||||||
|
// for by bbk-cart-controller.
|
||||||
|
open() {
|
||||||
|
window.dispatchEvent(new CustomEvent('bbk-cart:open'))
|
||||||
|
}
|
||||||
|
|
||||||
|
render(count) {
|
||||||
|
if (!this.hasBadgeTarget) return
|
||||||
|
this.badgeTarget.textContent = String(count)
|
||||||
|
this.badgeTarget.hidden = count < 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
import AppearController from './appear-controller'
|
import AppearController from './appear-controller'
|
||||||
import AutoSubmitController from './auto-submit-controller'
|
import AutoSubmitController from './auto-submit-controller'
|
||||||
import BackToTopController from './back-to-top-controller'
|
import BackToTopController from './back-to-top-controller'
|
||||||
|
import CartCountController from './cart-count-controller'
|
||||||
import CarouselController from './carousel-controller'
|
import CarouselController from './carousel-controller'
|
||||||
import DropdownController from './dropdown-controller'
|
import DropdownController from './dropdown-controller'
|
||||||
import FrameScrollController from './frame-scroll-controller'
|
import FrameScrollController from './frame-scroll-controller'
|
||||||
@@ -21,6 +22,7 @@ export function registerControllers(application) {
|
|||||||
application.register('appear', AppearController)
|
application.register('appear', AppearController)
|
||||||
application.register('auto-submit', AutoSubmitController)
|
application.register('auto-submit', AutoSubmitController)
|
||||||
application.register('back-to-top', BackToTopController)
|
application.register('back-to-top', BackToTopController)
|
||||||
|
application.register('cart-count', CartCountController)
|
||||||
application.register('carousel', CarouselController)
|
application.register('carousel', CarouselController)
|
||||||
application.register('dropdown', DropdownController)
|
application.register('dropdown', DropdownController)
|
||||||
application.register('frame-scroll', FrameScrollController)
|
application.register('frame-scroll', FrameScrollController)
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ export default class extends Controller {
|
|||||||
this.imageTarget.src = variant.image
|
this.imageTarget.src = variant.image
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the checkout module's add-to-cart form pointed at the chosen
|
||||||
|
// variant. [data-bbk-purchasable-input] is that module's documented
|
||||||
|
// hook (see resources/views/checkout/components/add-to-cart.blade.php);
|
||||||
|
// this is the one place the two touch.
|
||||||
|
const purchasableInput = this.element.querySelector('[data-bbk-purchasable-input]')
|
||||||
|
if (purchasableInput) purchasableInput.value = id
|
||||||
|
|
||||||
this.swatchTargets.forEach(swatch => {
|
this.swatchTargets.forEach(swatch => {
|
||||||
const isSelected = parseInt(swatch.dataset.variantId) === id
|
const isSelected = parseInt(swatch.dataset.variantId) === id
|
||||||
swatch.classList.toggle('is-selected', isSelected)
|
swatch.classList.toggle('is-selected', isSelected)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{{--
|
||||||
|
<x-checkout::add-to-cart :purchasable="$variantId" />
|
||||||
|
|
||||||
|
A self-contained add-to-cart form. Posts the line via bbk-add-to-cart-controller
|
||||||
|
(fetch) and hands the rendered cart body to the drawer over the
|
||||||
|
`bbk-cart:changed` window event.
|
||||||
|
|
||||||
|
Props:
|
||||||
|
purchasable ProductVariant id. Omit to render no hidden id field — the host
|
||||||
|
must then supply [data-bbk-purchasable-input] itself (e.g. a
|
||||||
|
variant picker writing the selected id into it).
|
||||||
|
quantity Integer for the hidden quantity field, or false to omit it
|
||||||
|
(the host then puts its own name="quantity" control in the slot).
|
||||||
|
|
||||||
|
The button and any quantity control come from the slot, so the host owns all
|
||||||
|
appearance. Extra attributes (class, etc.) land on the <form>.
|
||||||
|
--}}
|
||||||
|
@props([
|
||||||
|
'purchasable' => null,
|
||||||
|
'quantity' => 1,
|
||||||
|
'action' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="{{ $action ?? route('checkout.cart.add', app()->getLocale()) }}"
|
||||||
|
data-controller="bbk-add-to-cart"
|
||||||
|
data-action="bbk-add-to-cart#add"
|
||||||
|
{{ $attributes->class('bbk-add-to-cart') }}
|
||||||
|
>
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
@if (! is_null($purchasable))
|
||||||
|
<input type="hidden" name="purchasable_id" value="{{ $purchasable }}" data-bbk-purchasable-input>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($quantity !== false)
|
||||||
|
<input type="hidden" name="quantity" value="{{ $quantity }}">
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{ $slot }}
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{{--
|
||||||
|
Slide-in cart drawer. Rendered once, globally, from the app layout
|
||||||
|
(@include('checkout::drawer')). Structure only — all styling lives in
|
||||||
|
resources/css/checkout.css under @layer bbk-checkout; the host restyles the
|
||||||
|
.bbk-* classes from its own stylesheet. No host components, no Tailwind.
|
||||||
|
--}}
|
||||||
|
<div class="bbk-cart" data-controller="bbk-cart" hidden>
|
||||||
|
<div class="bbk-cart-backdrop" data-action="bbk-cart#close"></div>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="bbk-cart-panel"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="bbk-cart-heading"
|
||||||
|
data-bbk-cart-target="panel"
|
||||||
|
>
|
||||||
|
<header class="bbk-cart-panel-header">
|
||||||
|
<h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bbk-cart-dismiss"
|
||||||
|
data-action="bbk-cart#close"
|
||||||
|
aria-label="{{ __('checkout.cart.close') }}"
|
||||||
|
>×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite">
|
||||||
|
@include('checkout::partials.cart-body')
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{{--
|
||||||
|
Server-rendered cart contents. Rendered inline on first page load inside
|
||||||
|
checkout/drawer.blade.php, and re-fetched + swapped into the drawer by
|
||||||
|
bbk-cart-controller after every mutation. $cart / $lines come from the view
|
||||||
|
composer in CheckoutModuleServiceProvider.
|
||||||
|
|
||||||
|
The data-bbk-cart-* attributes on the root are the module's read API for the
|
||||||
|
host (e.g. the header bag-icon count) — bbk-cart-controller reads them after
|
||||||
|
each swap and re-emits them on the `bbk-cart:updated` window event.
|
||||||
|
--}}
|
||||||
|
@php($count = $lines->sum('quantity'))
|
||||||
|
|
||||||
|
{{-- @dump($lines) --}}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bbk-cart-content"
|
||||||
|
data-bbk-cart-count="{{ $count }}"
|
||||||
|
data-bbk-cart-total="{{ $cart?->total?->value ?? 0 }}"
|
||||||
|
>
|
||||||
|
@if ($lines->isEmpty())
|
||||||
|
<p class="bbk-cart-empty">{{ __('checkout.cart.empty') }}</p>
|
||||||
|
@else
|
||||||
|
<ul class="bbk-cart-items">
|
||||||
|
@each('checkout::partials.cart-line', $lines, 'line')
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="bbk-cart-summary">
|
||||||
|
<div class="bbk-cart-coupon">
|
||||||
|
@if ($cart?->coupon_code)
|
||||||
|
<div class="bbk-cart-coupon-applied">
|
||||||
|
<span class="bbk-cart-coupon-code">{{ $cart->coupon_code }}</span>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="{{ route('checkout.cart.coupon.remove', app()->getLocale()) }}"
|
||||||
|
data-action="submit->bbk-cart#submit"
|
||||||
|
>
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="bbk-cart-coupon-remove">
|
||||||
|
{{ __('checkout.cart.coupon_remove') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<form
|
||||||
|
class="bbk-cart-coupon-form"
|
||||||
|
method="POST"
|
||||||
|
action="{{ route('checkout.cart.coupon.apply', app()->getLocale()) }}"
|
||||||
|
data-action="submit->bbk-cart#submit"
|
||||||
|
>
|
||||||
|
@csrf
|
||||||
|
<label class="bbk-visually-hidden" for="bbk-coupon-code">
|
||||||
|
{{ __('checkout.cart.coupon_label') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="code"
|
||||||
|
id="bbk-coupon-code"
|
||||||
|
class="bbk-cart-coupon-input"
|
||||||
|
placeholder="{{ __('checkout.cart.coupon_placeholder') }}"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<button type="submit" class="bbk-cart-coupon-submit">
|
||||||
|
{{ __('checkout.cart.coupon_apply') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if ($couponError ?? false)
|
||||||
|
<p class="bbk-cart-coupon-error" role="alert">{{ __('checkout.cart.coupon_invalid') }}</p>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($cart?->discountTotal?->value > 0)
|
||||||
|
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
|
||||||
|
<span>{{ __('checkout.cart.discount') }}</span>
|
||||||
|
<span>−{{ $cart->discountTotal->formatted() }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="bbk-cart-summary-row">
|
||||||
|
<span>{{ __('checkout.cart.subtotal') }}</span>
|
||||||
|
<span>{{ $cart?->subTotal?->formatted() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Always shown, even with no discount — equals subtotal then,
|
||||||
|
diverges once one's applied. --}}
|
||||||
|
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
|
||||||
|
<span>{{ __('checkout.cart.total') }}</span>
|
||||||
|
<span>{{ $cart?->total?->formatted() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- TODO: point at the checkout page once that slice exists —
|
||||||
|
the drawer is the cart, there's no cart page for this to fall back to. --}}
|
||||||
|
<button type="button" class="bbk-cart-checkout" disabled>
|
||||||
|
{{ __('checkout.cart.checkout') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{{--
|
||||||
|
One cart line. $line is a Lunar\Models\CartLine (iteration var set by
|
||||||
|
@each in cart-body). The two forms post through bbk-cart-controller
|
||||||
|
(fetch + method spoofing) and the response re-renders cart-body.
|
||||||
|
--}}
|
||||||
|
@php
|
||||||
|
$variant = $line->purchasable;
|
||||||
|
$product = $variant?->product;
|
||||||
|
$name = $product?->translateAttribute('name') ?? $variant?->sku ?? '—';
|
||||||
|
$thumb = $product?->getThumbnailImage() ?: null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
|
||||||
|
<div class="bbk-cart-item-media">
|
||||||
|
@if ($thumb)
|
||||||
|
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bbk-cart-item-detail">
|
||||||
|
<p class="bbk-cart-item-title">{{ $name }}</p>
|
||||||
|
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="bbk-cart-qty"
|
||||||
|
method="POST"
|
||||||
|
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
|
||||||
|
>
|
||||||
|
@csrf
|
||||||
|
@method('PATCH')
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bbk-cart-qty-btn"
|
||||||
|
data-action="bbk-cart#step"
|
||||||
|
data-bbk-cart-dir-param="-1"
|
||||||
|
aria-label="{{ __('checkout.cart.decrease') }}"
|
||||||
|
>−</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="quantity"
|
||||||
|
value="{{ $line->quantity }}"
|
||||||
|
min="0"
|
||||||
|
inputmode="numeric"
|
||||||
|
class="bbk-cart-qty-input"
|
||||||
|
data-action="change->bbk-cart#submit"
|
||||||
|
aria-label="{{ __('checkout.cart.quantity') }}"
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bbk-cart-qty-btn"
|
||||||
|
data-action="bbk-cart#step"
|
||||||
|
data-bbk-cart-dir-param="1"
|
||||||
|
aria-label="{{ __('checkout.cart.increase') }}"
|
||||||
|
>+</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bbk-cart-item-aside">
|
||||||
|
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
|
||||||
|
data-action="submit->bbk-cart#submit"
|
||||||
|
>
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="bbk-cart-item-remove"
|
||||||
|
aria-label="{{ __('checkout.cart.remove') }}"
|
||||||
|
>×</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
@@ -41,10 +41,22 @@
|
|||||||
</a>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
||||||
{{-- Cart --}}
|
{{-- Cart — opens the checkout module's drawer, no separate cart page --}}
|
||||||
<a href="{{ url('/'.app()->getLocale().'/cart') }}" class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity" aria-label="Cart">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity"
|
||||||
|
aria-label="Cart"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
data-controller="cart-count"
|
||||||
|
data-action="cart-count#open"
|
||||||
|
>
|
||||||
<x-ui.icon name="bag" :size="40" />
|
<x-ui.icon name="bag" :size="40" />
|
||||||
</a>
|
<span
|
||||||
|
data-cart-count-target="badge"
|
||||||
|
class="absolute -top-1 -right-1 min-w-5 h-5 px-1 rounded-full bg-black text-neutral-200 text-xs font-bold flex items-center justify-center"
|
||||||
|
hidden
|
||||||
|
>0</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{{-- Search --}}
|
{{-- Search --}}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-700.woff2') }}">
|
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-700.woff2') }}">
|
||||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-800.woff2') }}">
|
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-800.woff2') }}">
|
||||||
|
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
|
||||||
@auth('staff')
|
@auth('staff')
|
||||||
@php($stoicToken = \App\Services\Stoic::token(auth('staff')->user()->email))
|
@php($stoicToken = \App\Services\Stoic::token(auth('staff')->user()->email))
|
||||||
@@ -52,6 +52,8 @@
|
|||||||
<body class="min-h-screen antialiased">
|
<body class="min-h-screen antialiased">
|
||||||
<x-header />
|
<x-header />
|
||||||
|
|
||||||
|
@include('checkout::drawer')
|
||||||
|
|
||||||
<main id="main-content" class="min-h-[calc(100vh-12rem)] sm:min-h-[calc(100vh-8rem)]">
|
<main id="main-content" class="min-h-[calc(100vh-12rem)] sm:min-h-[calc(100vh-8rem)]">
|
||||||
@yield('content')
|
@yield('content')
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -170,10 +170,14 @@ class="absolute bottom-6 right-8 text-white text-sm"
|
|||||||
<x-ui.color-swatch :variants="$product['variants']" :option="$option" />
|
<x-ui.color-swatch :variants="$product['variants']" :option="$option" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex items-stretch gap-10">
|
<x-checkout::add-to-cart
|
||||||
|
:purchasable="$variantsData[0]['id'] ?? null"
|
||||||
|
:quantity="false"
|
||||||
|
class="flex items-stretch gap-10"
|
||||||
|
>
|
||||||
<x-ui.quantity name="quantity" />
|
<x-ui.quantity name="quantity" />
|
||||||
<x-ui.button class="flex-1">{{ __('storefront.product.add_to_cart') }}</x-ui.button>
|
<x-ui.button type="submit" class="flex-1">{{ __('storefront.product.add_to_cart') }}</x-ui.button>
|
||||||
</div>
|
</x-checkout::add-to-cart>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Checkout\CartController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Cart + checkout module routes.
|
||||||
|
*
|
||||||
|
* The {locale} prefix and `locale` middleware mirror the 3dealer host's
|
||||||
|
* localization convention (CLAUDE.md » Localization). When this module moves to
|
||||||
|
* boboko-core the route definitions travel with it, but each host wraps them in
|
||||||
|
* whatever prefix/middleware it uses. Every action still declares $locale as its
|
||||||
|
* first parameter regardless, per the ControllerDispatcher positional-args
|
||||||
|
* gotcha documented in CLAUDE.md.
|
||||||
|
*
|
||||||
|
* Loaded from CheckoutModuleServiceProvider inside the `web` middleware group.
|
||||||
|
*/
|
||||||
|
Route::prefix('{locale}')
|
||||||
|
->middleware('locale')
|
||||||
|
->group(function () {
|
||||||
|
// No standalone cart page — the drawer (checkout::drawer) is the cart.
|
||||||
|
// The checkout page itself will live here once that slice is built.
|
||||||
|
Route::post('cart/lines', [CartController::class, 'add'])
|
||||||
|
->name('checkout.cart.add');
|
||||||
|
|
||||||
|
Route::patch('cart/lines/{line}', [CartController::class, 'updateLine'])
|
||||||
|
->whereNumber('line')
|
||||||
|
->name('checkout.cart.update');
|
||||||
|
|
||||||
|
Route::delete('cart/lines/{line}', [CartController::class, 'remove'])
|
||||||
|
->whereNumber('line')
|
||||||
|
->name('checkout.cart.remove');
|
||||||
|
|
||||||
|
Route::post('cart/coupon', [CartController::class, 'applyCoupon'])
|
||||||
|
->name('checkout.cart.coupon.apply');
|
||||||
|
|
||||||
|
Route::delete('cart/coupon', [CartController::class, 'removeCoupon'])
|
||||||
|
->name('checkout.cart.coupon.remove');
|
||||||
|
});
|
||||||
+8
-1
@@ -10,7 +10,14 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
laravel({
|
laravel({
|
||||||
input: ["resources/css/app.css", "resources/js/app.js"],
|
input: [
|
||||||
|
// checkout.css first: it's the portable cart/checkout module's
|
||||||
|
// structural base (@layer bbk-checkout) and must load before
|
||||||
|
// app.css so the host's own .bbk-* theming always wins.
|
||||||
|
"resources/css/checkout.css",
|
||||||
|
"resources/css/app.css",
|
||||||
|
"resources/js/app.js",
|
||||||
|
],
|
||||||
refresh: true,
|
refresh: true,
|
||||||
}),
|
}),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
|||||||
Reference in New Issue
Block a user