generated from boboko/starter
Merge branch 'box-now-test-' into cart-temp
This commit is contained in:
+11
-3
@@ -10,11 +10,19 @@ RUN composer install \
|
|||||||
--optimize-autoloader \
|
--optimize-autoloader \
|
||||||
--ignore-platform-reqs
|
--ignore-platform-reqs
|
||||||
|
|
||||||
# ── Stage 2a: Vite dev (node_modules only, no build) ─────────────────────────
|
# ── Stage 2a: Vite dev (node_modules only, no build) ──────────────────────────
|
||||||
FROM node:22-alpine AS vite-dev
|
FROM node:22-alpine AS vite-dev
|
||||||
|
# git: needed for npm to install @boboko/core's git+https tagged-VCS form (see
|
||||||
|
# package.json's _dependencies and docker/entrypoint-vite.sh). No build-time
|
||||||
|
# `npm install` here — package.json's boboko/core path-repo form (../boboko-core)
|
||||||
|
# isn't visible in the build context, only once bind-mounted at container start,
|
||||||
|
# and entrypoint-vite.sh already runs npm install on every boot, so this would be
|
||||||
|
# redundant even if it could work.
|
||||||
|
RUN apk add --no-cache git
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY docker/entrypoint-vite.sh /entrypoint-vite.sh
|
||||||
RUN npm ci --ignore-scripts
|
RUN chmod +x /entrypoint-vite.sh
|
||||||
|
ENTRYPOINT ["/entrypoint-vite.sh"]
|
||||||
|
|
||||||
# ── Stage 2b: Node / Vite build ──────────────────────────────────────────────
|
# ── Stage 2b: Node / Vite build ──────────────────────────────────────────────
|
||||||
FROM node:22-alpine AS node-build
|
FROM node:22-alpine AS node-build
|
||||||
|
|||||||
@@ -3,19 +3,21 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Catalog\ProductCard;
|
use App\Catalog\ProductCard;
|
||||||
use App\Services\Wishlist;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
use Lunar\Models\Product;
|
|
||||||
use Modules\Core\Catalog\Services\ProductService;
|
use Modules\Core\Catalog\Services\ProductService;
|
||||||
|
use Modules\Core\Wishlist\Services\WishlistService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page rendering only — the toggle action itself lives in core
|
||||||
|
* (Modules\Core\Wishlist\Http\Controllers\WishlistController, route
|
||||||
|
* `wishlist.toggle`), since it needs no app-specific presentation.
|
||||||
|
*/
|
||||||
class WishlistController extends Controller
|
class WishlistController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Wishlist $wishlist,
|
private readonly WishlistService $wishlist,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,24 +41,6 @@ public function guest(string $locale, ProductService $products): View|RedirectRe
|
|||||||
return view('wishlist.guest', ['products' => $this->products($products)]);
|
return view('wishlist.guest', ['products' => $this->products($products)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds or removes a product, for guests and logged-in shoppers alike. The
|
|
||||||
* heart button's Stimulus controller asks for JSON; without JS the form
|
|
||||||
* posts normally and comes back to the same page.
|
|
||||||
*/
|
|
||||||
public function toggle(string $locale, Request $request, int $productId): JsonResponse|RedirectResponse
|
|
||||||
{
|
|
||||||
abort_unless(Product::whereKey($productId)->exists(), 404);
|
|
||||||
|
|
||||||
$active = $this->wishlist->toggle($productId);
|
|
||||||
|
|
||||||
if ($request->expectsJson()) {
|
|
||||||
return response()->json(['active' => $active]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return back();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Product cards for the current wishlist, newest first. Products no longer
|
* Product cards for the current wishlist, newest first. Products no longer
|
||||||
* in the search index (deleted, unpublished) are simply skipped.
|
* in the search index (deleted, unpublished) are simply skipped.
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Listeners;
|
|
||||||
|
|
||||||
use App\Services\Wishlist;
|
|
||||||
use Modules\Core\Auth\Events\UserAuthenticated;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Picked up by Laravel's listener discovery (app/Listeners), no manual
|
|
||||||
* registration. Runs inside the login request, so it can read the guest
|
|
||||||
* wishlist cookie and queue its removal.
|
|
||||||
*/
|
|
||||||
class MergeGuestWishlistOnLogin
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly Wishlist $wishlist,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function handle(UserAuthenticated $event): void
|
|
||||||
{
|
|
||||||
$this->wishlist->mergeGuestInto($event->user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One product on a logged-in user's wishlist. Guests' wishlists live in a
|
|
||||||
* cookie instead — see App\Services\Wishlist.
|
|
||||||
*/
|
|
||||||
class WishlistItem extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = ['user_id', 'product_id'];
|
|
||||||
}
|
|
||||||
@@ -17,13 +17,6 @@
|
|||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function register(): void
|
|
||||||
{
|
|
||||||
// One instance per request: it caches the guest cookie's ids, so a
|
|
||||||
// toggle and a later has() in the same request agree.
|
|
||||||
$this->app->scoped(\App\Services\Wishlist::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
Telemetry::optOut();
|
Telemetry::optOut();
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Models\WishlistItem;
|
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Cookie;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The current shopper's wishlist, product ids only.
|
|
||||||
*
|
|
||||||
* Logged in: rows in wishlist_items. Guest: a 1-year cookie holding the ids
|
|
||||||
* (encrypted like every cookie, by the web group's EncryptCookies), so nothing
|
|
||||||
* is written to the database for anonymous visitors. On login the cookie is
|
|
||||||
* merged into the account and cleared (MergeGuestWishlistOnLogin).
|
|
||||||
*/
|
|
||||||
class Wishlist
|
|
||||||
{
|
|
||||||
public const COOKIE = 'wishlist';
|
|
||||||
|
|
||||||
private const COOKIE_MINUTES = 60 * 24 * 365;
|
|
||||||
|
|
||||||
// Keeps the cookie well under the 4KB browser limit.
|
|
||||||
private const GUEST_MAX = 100;
|
|
||||||
|
|
||||||
/** @var array<int>|null ids for this request, including a toggle just made */
|
|
||||||
private ?array $guestIds = null;
|
|
||||||
|
|
||||||
/** @return array<int> newest first */
|
|
||||||
public function ids(): array
|
|
||||||
{
|
|
||||||
if ($user = Auth::user()) {
|
|
||||||
return WishlistItem::where('user_id', $user->id)
|
|
||||||
->latest('id')
|
|
||||||
->pluck('product_id')
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->guestIds();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function has(int $productId): bool
|
|
||||||
{
|
|
||||||
return in_array($productId, $this->ids(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return bool whether the product is on the wishlist afterwards
|
|
||||||
*/
|
|
||||||
public function toggle(int $productId): bool
|
|
||||||
{
|
|
||||||
if ($user = Auth::user()) {
|
|
||||||
$deleted = WishlistItem::where('user_id', $user->id)->where('product_id', $productId)->delete();
|
|
||||||
|
|
||||||
if ($deleted) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
WishlistItem::create(['user_id' => $user->id, 'product_id' => $productId]);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ids = $this->guestIds();
|
|
||||||
|
|
||||||
if (in_array($productId, $ids, true)) {
|
|
||||||
$this->storeGuestIds(array_values(array_diff($ids, [$productId])));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->storeGuestIds(array_slice([$productId, ...$ids], 0, self::GUEST_MAX));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function remove(int $productId): void
|
|
||||||
{
|
|
||||||
if ($this->has($productId)) {
|
|
||||||
$this->toggle($productId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves the guest cookie's products onto $user's wishlist and clears it.
|
|
||||||
*/
|
|
||||||
public function mergeGuestInto(Authenticatable $user): void
|
|
||||||
{
|
|
||||||
$ids = $this->guestIds();
|
|
||||||
|
|
||||||
if ($ids === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Oldest first, so the newest cookie item also ends up newest here.
|
|
||||||
foreach (array_reverse($ids) as $productId) {
|
|
||||||
WishlistItem::firstOrCreate(['user_id' => $user->id, 'product_id' => $productId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->guestIds = [];
|
|
||||||
Cookie::queue(Cookie::forget(self::COOKIE));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<int> */
|
|
||||||
private function guestIds(): array
|
|
||||||
{
|
|
||||||
if ($this->guestIds !== null) {
|
|
||||||
return $this->guestIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = json_decode((string) request()->cookie(self::COOKIE), true);
|
|
||||||
|
|
||||||
return $this->guestIds = is_array($decoded)
|
|
||||||
? array_values(array_unique(array_filter(array_map('intval', $decoded))))
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<int> $ids */
|
|
||||||
private function storeGuestIds(array $ids): void
|
|
||||||
{
|
|
||||||
$this->guestIds = $ids;
|
|
||||||
|
|
||||||
Cookie::queue(self::COOKIE, json_encode($ids), self::COOKIE_MINUTES);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('wishlist_items', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
|
||||||
$table->foreignId('product_id')->constrained('lunar_products')->cascadeOnDelete();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->unique(['user_id', 'product_id']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('wishlist_items');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -10,3 +10,7 @@ services:
|
|||||||
scheduler:
|
scheduler:
|
||||||
volumes:
|
volumes:
|
||||||
- ../boboko-core:/var/www/boboko-core
|
- ../boboko-core:/var/www/boboko-core
|
||||||
|
|
||||||
|
vite:
|
||||||
|
volumes:
|
||||||
|
- ../boboko-core:/boboko-core
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Mirrors entrypoint.sh's Composer block: node_modules is a named/anonymous
|
||||||
|
# volume, not the bind-mounted host directory, so this container needs its
|
||||||
|
# own live install on every boot rather than relying on whatever was baked in
|
||||||
|
# at image-build time.
|
||||||
|
echo "[entrypoint-vite] Installing npm dependencies..."
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Re-resolve @boboko/core specifically on every boot, whether it's a local
|
||||||
|
# path-repo checkout (file:../boboko-core, needs docker-compose.core-dev.yml's
|
||||||
|
# ../boboko-core:/boboko-core mount) or a tagged VCS install
|
||||||
|
# (git+https://...#vX.Y.Z) — the same manual toggle composer.json's
|
||||||
|
# repositories/_repositories block uses for the PHP side of this same
|
||||||
|
# package. A plain `npm install` above only installs what package-lock.json
|
||||||
|
# already pins; it won't notice a new commit on the path-repo checkout or a
|
||||||
|
# re-pushed tag. `npm update` re-resolves that one package against whatever
|
||||||
|
# package.json currently says and rewrites package-lock.json to pin it —
|
||||||
|
# exactly like `composer update boboko/* --with-all-dependencies` rewrites
|
||||||
|
# composer.lock, which is committed so prod picks up the resolved version via
|
||||||
|
# `npm install` alone (no `npm update` in prod's build).
|
||||||
|
echo "[entrypoint-vite] Re-resolving @boboko/core..."
|
||||||
|
npm update @boboko/core
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
Generated
+8
-15
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "boboko-starter",
|
"name": "app",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -7,9 +7,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hotwired/stimulus": "^3.2.2",
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"@hotwired/turbo": "^8.0.23",
|
"@hotwired/turbo": "^8.0.23",
|
||||||
"@phosphor-icons/web": "^2.1.2",
|
|
||||||
"axios": "^1.15.2",
|
"axios": "^1.15.2",
|
||||||
"flatpickr": "^4.6.13"
|
"leaflet": "^1.9.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
@@ -147,12 +146,6 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@phosphor-icons/web": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@phosphor-icons/web/-/web-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-rPAR9o/bEcp4Cw4DEeZHXf+nlGCMNGkNDRizYHM47NLxz9vvEHp/Tt6FMK1NcWadzw/pFDPnRBGi/ofRya958A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||||
@@ -1015,12 +1008,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatpickr": {
|
|
||||||
"version": "4.6.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
|
|
||||||
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
@@ -1256,6 +1243,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
|
|||||||
+7
-1
@@ -14,8 +14,14 @@
|
|||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
|
||||||
"@hotwired/stimulus": "^3.2.2",
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"@hotwired/turbo": "^8.0.23",
|
"@hotwired/turbo": "^8.0.23",
|
||||||
"axios": "^1.15.2"
|
"axios": "^1.15.2",
|
||||||
|
"leaflet": "^1.9.4"
|
||||||
|
},
|
||||||
|
"_comment_boboko_core": "Mirrors composer.json's repositories/_repositories toggle: swap @boboko/core above with the line in _dependencies below (and back) by hand to switch between the local path-repo checkout and a tagged VCS install. The #semver:0.x range mirrors composer.json's own \"boboko/core\": \"0.*\" constraint exactly — note ^0.23.0 would NOT be equivalent here, since npm's caret locks the minor version too below 1.0.0 (>=0.23.0 <0.24.0). 0.x matches any 0.y.z tag, same as Composer's 0.*. npm reads the repo's git tags, filters to valid semver ones, and resolves the best match, so `npm update @boboko/core` alone picks up a newly pushed tag within range, no manual edit here per release. See docker/entrypoint-vite.sh and CONTRIBUTE.md.",
|
||||||
|
"_dependencies": {
|
||||||
|
"@boboko/core": "file:../boboko-core"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,856 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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 {
|
|
||||||
display: block;
|
|
||||||
margin: 0 0 0.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.bbk-cart-item-title:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
.bbk-cart-item-variant {
|
|
||||||
margin: 0 0 0.25rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A line's custom-field answers (checkout::partials.line-custom-fields). */
|
|
||||||
.bbk-line-fields {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.25rem;
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-line-field dt {
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-line-field dd {
|
|
||||||
margin: 0;
|
|
||||||
white-space: pre-line;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-line-field-file {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-line-field-file img {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-summary-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-summary-row--discount { color: var(--bbk-color-danger); }
|
|
||||||
|
|
||||||
.bbk-cart-summary-pending {
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-summary-row--total {
|
|
||||||
margin-top: 0.375rem;
|
|
||||||
padding-top: 0.875rem;
|
|
||||||
border-top: 1px solid var(--bbk-color-border);
|
|
||||||
font-size: 1.0625rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-error {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.75rem 1.5rem 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--bbk-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-add-to-cart-error {
|
|
||||||
margin: 0.375rem 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────────────────────────────────────────────────────────────────
|
|
||||||
Checkout page — two columns: fields on the left, order summary (the
|
|
||||||
same cart-body partial the drawer uses) on the right.
|
|
||||||
─────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.bbk-checkout-page {
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2.5rem 1.5rem 5rem;
|
|
||||||
font-family: var(--bbk-font);
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--bbk-color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-heading {
|
|
||||||
margin: 0 0 2rem;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 380px;
|
|
||||||
gap: 3rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.bbk-checkout { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-section {
|
|
||||||
padding-bottom: 2rem;
|
|
||||||
border-bottom: 1px solid var(--bbk-color-border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-section-heading {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-note {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Contact: "logged in as" line, or the guest login prompt */
|
|
||||||
|
|
||||||
.bbk-checkout-logged-in,
|
|
||||||
.bbk-checkout-login-prompt { margin: 0; }
|
|
||||||
|
|
||||||
.bbk-checkout-login-prompt a { color: inherit; font-weight: 600; }
|
|
||||||
|
|
||||||
/* Fields */
|
|
||||||
|
|
||||||
.bbk-field { display: flex; flex-direction: column; gap: 0.375rem; }
|
|
||||||
|
|
||||||
.bbk-field-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.bbk-field-row { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-field-label {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-field-input {
|
|
||||||
padding: 0.625rem 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-field-input:focus-visible {
|
|
||||||
outline: 2px solid var(--bbk-color-accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-field-input:disabled {
|
|
||||||
background: var(--bbk-color-bg-muted);
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-field-input--error { border-color: var(--bbk-color-danger); }
|
|
||||||
|
|
||||||
.bbk-field-error {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--bbk-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A fixed, non-editable field value (e.g. the store's single country). */
|
|
||||||
.bbk-field-static {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.625rem 0.75rem;
|
|
||||||
border: 1px solid var(--bbk-color-border);
|
|
||||||
border-radius: var(--bbk-radius-sm);
|
|
||||||
background: var(--bbk-color-bg-muted);
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea.bbk-field-input { resize: vertical; }
|
|
||||||
|
|
||||||
.bbk-checkbox {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* For a full-sentence label that can wrap — align the box to the first line. */
|
|
||||||
/* "I want an invoice": company/ΑΦΜ only while ticked */
|
|
||||||
|
|
||||||
.bbk-invoice { display: flex; flex-direction: column; gap: 1rem; }
|
|
||||||
|
|
||||||
.bbk-invoice:not(:has(input[name="wants_invoice"]:checked)) .bbk-invoice-fields { display: none; }
|
|
||||||
|
|
||||||
.bbk-checkbox--stacked {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkbox--stacked input { margin-top: 0.15rem; flex-shrink: 0; }
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-fields {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Shipping method */
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-options {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
border: 1px solid var(--bbk-color-border);
|
|
||||||
border-radius: var(--bbk-radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-option:has(input:checked) { border-color: var(--bbk-color-accent); }
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-option-detail {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-option-name { font-weight: 600; }
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-option-description {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-option-price { font-weight: 600; }
|
|
||||||
|
|
||||||
/* The single auto-selected option — a fixed line, not a choosable radio. */
|
|
||||||
.bbk-checkout-shipping-confirmed {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
border: 1px solid var(--bbk-color-accent);
|
|
||||||
border-radius: var(--bbk-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Autosave status line under the address form. */
|
|
||||||
.bbk-checkout-status {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); }
|
|
||||||
|
|
||||||
/* Continue / submit buttons — same look as the drawer's checkout CTA */
|
|
||||||
|
|
||||||
.bbk-checkout-continue {
|
|
||||||
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;
|
|
||||||
box-sizing: border-box;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-continue:hover { opacity: 0.85; }
|
|
||||||
|
|
||||||
.bbk-checkout-continue:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Order summary column */
|
|
||||||
|
|
||||||
.bbk-checkout-aside { position: sticky; top: 1.5rem; }
|
|
||||||
|
|
||||||
.bbk-checkout-summary {
|
|
||||||
padding: 1.5rem;
|
|
||||||
border: 1px solid var(--bbk-color-border);
|
|
||||||
border-radius: var(--bbk-radius);
|
|
||||||
background: var(--bbk-color-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-summary-heading {
|
|
||||||
margin: 0 0 1.25rem;
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Already on the checkout page — the drawer's own "go to checkout" CTA has
|
|
||||||
nowhere further to send you from here. */
|
|
||||||
.bbk-checkout-summary .bbk-cart-checkout { display: none; }
|
|
||||||
|
|
||||||
/* ── Payment ───────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.bbk-checkout-payment-options {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-payment-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
border: 1px solid var(--bbk-color-border);
|
|
||||||
border-radius: var(--bbk-radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-payment-option:has(input:checked) { border-color: var(--bbk-color-accent); }
|
|
||||||
|
|
||||||
.bbk-checkout-payment-option-name { font-weight: 600; }
|
|
||||||
|
|
||||||
.bbk-payment-element { margin: 0.25rem 0; }
|
|
||||||
|
|
||||||
.bbk-checkout-withdrawal {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-withdrawal a { color: inherit; }
|
|
||||||
|
|
||||||
.bbk-checkout-error {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--bbk-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Processing overlay — fixed, covers the page while a payment confirms. */
|
|
||||||
.bbk-checkout-processing {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1100;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
background: color-mix(in srgb, var(--bbk-color-bg) 92%, transparent);
|
|
||||||
text-align: center;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-spinner {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border: 3px solid var(--bbk-color-border);
|
|
||||||
border-top-color: var(--bbk-color-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: bbk-spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bbk-spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Confirmation page ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.bbk-confirmation {
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 3rem 1.5rem 5rem;
|
|
||||||
font-family: var(--bbk-font);
|
|
||||||
color: var(--bbk-color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-heading {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-ref { margin: 0 0 0.25rem; }
|
|
||||||
|
|
||||||
.bbk-confirmation-meta {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-meta-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); }
|
|
||||||
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
|
|
||||||
|
|
||||||
.bbk-confirmation-body {
|
|
||||||
margin: 2rem 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-lines {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-line {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 72px 1fr auto;
|
|
||||||
align-items: start;
|
|
||||||
gap: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-line-detail { min-width: 0; }
|
|
||||||
|
|
||||||
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
|
|
||||||
|
|
||||||
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
|
|
||||||
|
|
||||||
.bbk-confirmation-addresses {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-address-heading {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-address-lines {
|
|
||||||
font-style: normal;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
+5
-3
@@ -10,13 +10,15 @@ 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";
|
import { registerCheckout, registerWishlist } from "@boboko/core";
|
||||||
|
|
||||||
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
|
// Portable cart + checkout + wishlist modules, installed as a real npm
|
||||||
// bbk-* Stimulus controllers; this is the only wiring line it needs here.
|
// dependency (see package.json). Each owns its own Stimulus controllers;
|
||||||
|
// these are the only wiring lines they need here.
|
||||||
registerCheckout(application);
|
registerCheckout(application);
|
||||||
|
registerWishlist(application);
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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 {
|
|
||||||
static targets = ['error']
|
|
||||||
|
|
||||||
async add(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
const form = this.element
|
|
||||||
const submit = form.querySelector('[type="submit"]')
|
|
||||||
|
|
||||||
this.clearError()
|
|
||||||
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',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body: new FormData(form),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json().catch(() => null)
|
|
||||||
this.showError(data?.error)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(message) {
|
|
||||||
if (!this.hasErrorTarget || !message) return
|
|
||||||
this.errorTarget.textContent = message
|
|
||||||
this.errorTarget.hidden = false
|
|
||||||
}
|
|
||||||
|
|
||||||
clearError() {
|
|
||||||
if (!this.hasErrorTarget) return
|
|
||||||
this.errorTarget.hidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
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', 'error']
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.onChanged = this.onChanged.bind(this)
|
|
||||||
this.onKeydown = this.onKeydown.bind(this)
|
|
||||||
this.updateTimers = new Map() // line id -> pending debounce timer
|
|
||||||
|
|
||||||
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)
|
|
||||||
this.updateTimers.forEach((timer) => clearTimeout(timer))
|
|
||||||
}
|
|
||||||
|
|
||||||
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) return
|
|
||||||
|
|
||||||
// A remove is a deliberate, one-shot action — only the quantity form
|
|
||||||
// (typing, or the +/- stepper below) benefits from debouncing.
|
|
||||||
form.classList.contains('bbk-cart-qty') ? this.scheduleSend(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.scheduleSend(form)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Repeated clicks (or spinner nudges) update the input instantly but only
|
|
||||||
// send once they settle for 300ms — sending on every single click was
|
|
||||||
// firing overlapping requests that raced each other and made the drawer
|
|
||||||
// visibly flicker/lag under quick clicking.
|
|
||||||
scheduleSend(form) {
|
|
||||||
const lineId = form.closest('[data-bbk-line-id]')?.dataset.bbkLineId
|
|
||||||
if (!lineId) return this.send(form)
|
|
||||||
|
|
||||||
clearTimeout(this.updateTimers.get(lineId))
|
|
||||||
this.updateTimers.set(lineId, setTimeout(() => {
|
|
||||||
this.updateTimers.delete(lineId)
|
|
||||||
this.send(form)
|
|
||||||
}, 300))
|
|
||||||
}
|
|
||||||
|
|
||||||
async send(form) {
|
|
||||||
this.bodyTarget.setAttribute('aria-busy', 'true')
|
|
||||||
this.clearError()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(form.action, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body: new FormData(form),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
this.replaceBody(await response.text())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json().catch(() => null)
|
|
||||||
this.showError(data?.error)
|
|
||||||
|
|
||||||
// The rejected quantity (typed, or from a +/- click) is left
|
|
||||||
// sitting in the input with nothing to correct it — the update
|
|
||||||
// never reached the cart, so the input must be put back to what
|
|
||||||
// the cart actually still holds, not just left showing whatever
|
|
||||||
// was rejected.
|
|
||||||
const input = form.querySelector('[data-bbk-cart-confirmed-quantity]')
|
|
||||||
if (input) input.value = input.dataset.bbkCartConfirmedQuantity
|
|
||||||
} finally {
|
|
||||||
this.bodyTarget.removeAttribute('aria-busy')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(message) {
|
|
||||||
if (!this.hasErrorTarget || !message) return
|
|
||||||
this.errorTarget.textContent = message
|
|
||||||
this.errorTarget.hidden = false
|
|
||||||
}
|
|
||||||
|
|
||||||
clearError() {
|
|
||||||
if (!this.hasErrorTarget) return
|
|
||||||
this.errorTarget.hidden = true
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus'
|
|
||||||
import { csrfToken } from './csrf'
|
|
||||||
|
|
||||||
// Drives the checkout page's left column: contact tabs, the same-as-billing
|
|
||||||
// toggle, and — the bulk of it — autosaving the address form and the shipping
|
|
||||||
// method with no submit buttons.
|
|
||||||
//
|
|
||||||
// Flow: any `change` in the address form is debounced ~400ms, then the whole
|
|
||||||
// form is POSTed to saveUrl. The server persists leniently and returns
|
|
||||||
// { errors, shippingOptionsHtml, summaryHtml }. We swap the shipping-options
|
|
||||||
// block in place and hand the summary fragment to the drawer's bbk-cart
|
|
||||||
// controller via the `bbk-cart:changed` window event (same mechanism the drawer
|
|
||||||
// already uses). Shipping-method radios post to selectShippingUrl the same way.
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = [
|
|
||||||
'sameAsBilling', 'shippingFields',
|
|
||||||
'form', 'shippingOptions', 'status',
|
|
||||||
]
|
|
||||||
|
|
||||||
static values = {
|
|
||||||
saveUrl: String,
|
|
||||||
selectShippingUrl: String,
|
|
||||||
statusSaving: String,
|
|
||||||
statusSaved: String,
|
|
||||||
statusError: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.saveTimer = null
|
|
||||||
this.saveController = null
|
|
||||||
this.statusTimer = null
|
|
||||||
this.shippingPromise = null
|
|
||||||
|
|
||||||
if (this.hasSameAsBillingTarget) this.applySameAsBilling()
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
clearTimeout(this.saveTimer)
|
|
||||||
clearTimeout(this.statusTimer)
|
|
||||||
this.saveController?.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Same as billing ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
toggleSameAsBilling() {
|
|
||||||
this.applySameAsBilling()
|
|
||||||
}
|
|
||||||
|
|
||||||
applySameAsBilling() {
|
|
||||||
const on = this.sameAsBillingTarget.checked
|
|
||||||
|
|
||||||
// Checked: shipping *is* billing — copy every value across, then hide +
|
|
||||||
// disable so the browser doesn't submit them; the server reuses billing.
|
|
||||||
// Unchecked: reveal them pre-filled from billing wherever still empty.
|
|
||||||
this.element.querySelectorAll('[name^="billing_"]').forEach((billingField) => {
|
|
||||||
const shippingField = this.element.querySelector(
|
|
||||||
`[name="${billingField.name.replace(/^billing_/, 'shipping_')}"]`,
|
|
||||||
)
|
|
||||||
if (shippingField && (on || !shippingField.value)) {
|
|
||||||
shippingField.value = billingField.value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.shippingFieldsTarget.hidden = on
|
|
||||||
this.shippingFieldsTarget.querySelectorAll('input, select, textarea').forEach((field) => {
|
|
||||||
field.disabled = on
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Autosave ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
scheduleSave(event) {
|
|
||||||
// The shipping-method and payment radios live inside this controller's
|
|
||||||
// element too, and this action is bound on .bbk-checkout-main to also
|
|
||||||
// catch the contact email/consent that sit outside the <form>. Only
|
|
||||||
// react to fields that actually belong to the address form.
|
|
||||||
const el = event.target
|
|
||||||
const belongsToForm = el.form?.id === 'bbk-address-form'
|
|
||||||
if (!belongsToForm) return
|
|
||||||
|
|
||||||
// No status during the wait — it only shows once the request is in flight,
|
|
||||||
// so the indicator isn't flickering "saving" on every keystroke.
|
|
||||||
clearTimeout(this.saveTimer)
|
|
||||||
this.saveTimer = setTimeout(() => this.save(), 700)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called by bbk-payment right before place-order — a debounced save (and
|
|
||||||
// the shipping-option auto-select that happens as part of it) might still
|
|
||||||
// be pending when the shopper clicks "place order"; this guarantees the
|
|
||||||
// server has processed the current form state first.
|
|
||||||
async flush() {
|
|
||||||
clearTimeout(this.saveTimer)
|
|
||||||
await this.save()
|
|
||||||
// A shipping-method radio click fires its own (undebounced) request —
|
|
||||||
// still async, still racy against an immediate "place order" click.
|
|
||||||
if (this.shippingPromise) await this.shippingPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
async save() {
|
|
||||||
this.saveController?.abort()
|
|
||||||
this.saveController = new AbortController()
|
|
||||||
this.setStatus('saving')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.saveUrlValue, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body: new FormData(this.formTarget),
|
|
||||||
signal: this.saveController.signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) return this.setStatus('error')
|
|
||||||
|
|
||||||
this.applyResult(await response.json())
|
|
||||||
this.setStatus('saved')
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name !== 'AbortError') this.setStatus('error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async selectShipping(event) {
|
|
||||||
// Tracked so flush() can await it — nothing else stops "place order"
|
|
||||||
// (a separate, unrelated click) from racing ahead of this request.
|
|
||||||
this.shippingPromise = this.doSelectShipping(event.target.value)
|
|
||||||
await this.shippingPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
async doSelectShipping(value) {
|
|
||||||
this.saveController?.abort()
|
|
||||||
this.setStatus('saving')
|
|
||||||
|
|
||||||
const body = new FormData()
|
|
||||||
body.append('shipping_option', value)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.selectShippingUrlValue, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) return this.setStatus('error')
|
|
||||||
|
|
||||||
this.applyResult(await response.json())
|
|
||||||
this.setStatus('saved')
|
|
||||||
} catch {
|
|
||||||
this.setStatus('error')
|
|
||||||
} finally {
|
|
||||||
this.shippingPromise = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyResult(data) {
|
|
||||||
this.applyErrors(data.errors || {})
|
|
||||||
|
|
||||||
if (data.shippingOptionsHtml != null) {
|
|
||||||
this.shippingOptionsTarget.innerHTML = data.shippingOptionsHtml
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.summaryHtml != null) {
|
|
||||||
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
|
|
||||||
detail: { html: data.summaryHtml },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyErrors(errors) {
|
|
||||||
this.element.querySelectorAll('[data-bbk-field-error]').forEach((el) => {
|
|
||||||
const message = errors[el.dataset.bbkFieldError]
|
|
||||||
el.textContent = message || ''
|
|
||||||
el.hidden = !message
|
|
||||||
|
|
||||||
const field = this.element.querySelector(`[name="${el.dataset.bbkFieldError}"]`)
|
|
||||||
field?.classList.toggle('bbk-field-input--error', Boolean(message))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(state) {
|
|
||||||
if (!this.hasStatusTarget) return
|
|
||||||
|
|
||||||
const text = {
|
|
||||||
saving: this.statusSavingValue,
|
|
||||||
saved: this.statusSavedValue,
|
|
||||||
error: this.statusErrorValue,
|
|
||||||
}[state]
|
|
||||||
|
|
||||||
this.statusTarget.textContent = text
|
|
||||||
this.statusTarget.hidden = false
|
|
||||||
this.statusTarget.dataset.state = state
|
|
||||||
|
|
||||||
clearTimeout(this.statusTimer)
|
|
||||||
if (state === 'saved') {
|
|
||||||
this.statusTimer = setTimeout(() => { this.statusTarget.hidden = true }, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus'
|
|
||||||
import { csrfToken } from './csrf'
|
|
||||||
|
|
||||||
const STRIPE_JS = 'https://js.stripe.com/v3/'
|
|
||||||
const POLL_INTERVAL = 1500
|
|
||||||
const POLL_TIMEOUT = 30000
|
|
||||||
|
|
||||||
// The payment step of the checkout page. Sits alongside bbk-checkout-form on
|
|
||||||
// .bbk-checkout-main.
|
|
||||||
//
|
|
||||||
// - selectMethod: radio change -> persist via /payment-method, refresh the
|
|
||||||
// summary (COD fee), mount/unmount the Stripe Payment Element
|
|
||||||
// - placeOrder: the real submit. For Stripe, builds a PaymentMethod client-side
|
|
||||||
// and POSTs it to /place-order, then routes on the JSON result:
|
|
||||||
// { redirect } -> order placed, go to confirmation
|
|
||||||
// { status:'pending', clientSecret } -> 3-D Secure: handleNextAction, then
|
|
||||||
// poll /order-status until the webhook places it
|
|
||||||
// { status:'failed'|'invalid'|'stale', message } -> show inline, re-enable
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText']
|
|
||||||
|
|
||||||
static values = {
|
|
||||||
selectUrl: String,
|
|
||||||
placeOrderUrl: String,
|
|
||||||
orderStatusUrl: String,
|
|
||||||
stripeKey: String,
|
|
||||||
amount: Number,
|
|
||||||
currency: String,
|
|
||||||
termsRequired: String,
|
|
||||||
chooseMethod: String,
|
|
||||||
genericError: String,
|
|
||||||
processingSlow: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.stripe = null
|
|
||||||
this.elements = null
|
|
||||||
this.paymentElement = null
|
|
||||||
|
|
||||||
this.onSummaryUpdate = (event) => {
|
|
||||||
const total = event.detail?.total
|
|
||||||
if (typeof total === 'number' && this.elements) {
|
|
||||||
this.amountValue = total
|
|
||||||
this.elements.update({ amount: Math.max(total, 1) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Removing the last line while sitting on the checkout page (via
|
|
||||||
// the order summary's own remove form) must not leave "place
|
|
||||||
// order" clickable with nothing left to charge for — this fires
|
|
||||||
// from both the drawer and the checkout page's own summary
|
|
||||||
// instance, whichever the shopper actually used.
|
|
||||||
const count = event.detail?.count
|
|
||||||
if (typeof count === 'number' && this.hasSubmitTarget) {
|
|
||||||
this.submitTarget.disabled = count === 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('bbk-cart:updated', this.onSummaryUpdate)
|
|
||||||
|
|
||||||
if (this.selectedIsStripe()) this.mountStripe()
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate)
|
|
||||||
this.unmountStripe()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Method selection ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async selectMethod(event) {
|
|
||||||
const isStripe = event.target.dataset.paymentDriver === 'stripe'
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.selectUrlValue, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({ payment_type: event.target.value }),
|
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
if (data.summaryHtml != null) {
|
|
||||||
window.dispatchEvent(new CustomEvent('bbk-cart:changed', { detail: { html: data.summaryHtml } }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// summary just won't refresh — non-fatal
|
|
||||||
}
|
|
||||||
|
|
||||||
isStripe ? this.mountStripe() : this.unmountStripe()
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedRadio() {
|
|
||||||
return this.element.querySelector('input[name="payment_type"]:checked')
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedIsStripe() {
|
|
||||||
return this.selectedRadio()?.dataset.paymentDriver === 'stripe'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stripe Payment Element ────────────────────────────────────────
|
|
||||||
|
|
||||||
async loadStripe() {
|
|
||||||
if (window.Stripe) return window.Stripe
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const existing = document.querySelector(`script[src="${STRIPE_JS}"]`)
|
|
||||||
if (existing) {
|
|
||||||
existing.addEventListener('load', resolve)
|
|
||||||
existing.addEventListener('error', reject)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const script = document.createElement('script')
|
|
||||||
script.src = STRIPE_JS
|
|
||||||
script.onload = resolve
|
|
||||||
script.onerror = reject
|
|
||||||
document.head.appendChild(script)
|
|
||||||
})
|
|
||||||
|
|
||||||
return window.Stripe
|
|
||||||
}
|
|
||||||
|
|
||||||
async mountStripe() {
|
|
||||||
if (this.paymentElement || !this.stripeKeyValue) return
|
|
||||||
|
|
||||||
const Stripe = await this.loadStripe()
|
|
||||||
this.stripe = this.stripe || Stripe(this.stripeKeyValue)
|
|
||||||
|
|
||||||
this.elements = this.stripe.elements({
|
|
||||||
mode: 'payment',
|
|
||||||
amount: Math.max(this.amountValue, 1),
|
|
||||||
currency: this.currencyValue,
|
|
||||||
paymentMethodCreation: 'manual',
|
|
||||||
// Card only — matches the server confirming with
|
|
||||||
// automatic_payment_methods.allow_redirects = 'never' (no
|
|
||||||
// return_url in our flow: 3-D Secure resolves in-page via
|
|
||||||
// handleNextAction, never a full-page redirect).
|
|
||||||
paymentMethodTypes: ['card'],
|
|
||||||
})
|
|
||||||
this.paymentElement = this.elements.create('payment')
|
|
||||||
this.paymentElement.mount(this.elementTarget)
|
|
||||||
this.elementTarget.hidden = false
|
|
||||||
}
|
|
||||||
|
|
||||||
unmountStripe() {
|
|
||||||
this.paymentElement?.unmount()
|
|
||||||
this.paymentElement = null
|
|
||||||
this.elements = null
|
|
||||||
|
|
||||||
if (this.hasElementTarget) {
|
|
||||||
this.elementTarget.innerHTML = ''
|
|
||||||
this.elementTarget.hidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Place order ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Sibling controller on the same element (.bbk-checkout-main) — used to
|
|
||||||
// flush a pending debounced address autosave before placing the order.
|
|
||||||
get checkoutForm() {
|
|
||||||
return this.application.getControllerForElementAndIdentifier(this.element, 'bbk-checkout-form')
|
|
||||||
}
|
|
||||||
|
|
||||||
async placeOrder() {
|
|
||||||
this.clearError()
|
|
||||||
this.submitTarget.disabled = true
|
|
||||||
|
|
||||||
// A debounced address save (and the shipping-option auto-select that
|
|
||||||
// happens as part of it) might still be pending — make sure the
|
|
||||||
// server has the latest state before we ask it to place the order.
|
|
||||||
await this.checkoutForm?.flush()
|
|
||||||
|
|
||||||
if (!this.termsTarget.checked) {
|
|
||||||
this.submitTarget.disabled = false
|
|
||||||
this.showError(this.termsRequiredValue)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const radio = this.selectedRadio()
|
|
||||||
if (!radio) {
|
|
||||||
this.submitTarget.disabled = false
|
|
||||||
this.showError(this.chooseMethodValue)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let paymentMethodId = null
|
|
||||||
if (radio.dataset.paymentDriver === 'stripe') {
|
|
||||||
const { error: submitError } = await this.elements.submit()
|
|
||||||
if (submitError) return this.fail(submitError.message)
|
|
||||||
|
|
||||||
const { error: pmError, paymentMethod } = await this.stripe.createPaymentMethod({ elements: this.elements })
|
|
||||||
if (pmError) return this.fail(pmError.message)
|
|
||||||
paymentMethodId = paymentMethod.id
|
|
||||||
}
|
|
||||||
|
|
||||||
let data
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.placeOrderUrlValue, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
payment_type: radio.value,
|
|
||||||
payment_method: paymentMethodId ?? '',
|
|
||||||
terms_accepted: '1',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
data = await response.json()
|
|
||||||
} catch {
|
|
||||||
return this.fail(this.genericErrorValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.redirect) {
|
|
||||||
window.location.assign(data.redirect)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.status === 'pending' && data.clientSecret) {
|
|
||||||
await this.resolvePending(data.clientSecret)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Points at the section that actually needs attention, rather than
|
|
||||||
// leaving a generic error and making the shopper hunt for it — e.g. a
|
|
||||||
// region with 2+ shipping methods needs an explicit pick, easy to miss.
|
|
||||||
if (data.field === 'shipping_option') {
|
|
||||||
document.getElementById('bbk-shipping-options')?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
this.fail(data.message || data.error || this.genericErrorValue, { scroll: false })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.fail(data.message || data.error || this.genericErrorValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
async resolvePending(clientSecret) {
|
|
||||||
this.processingTarget.hidden = false
|
|
||||||
|
|
||||||
const { error } = await this.stripe.handleNextAction({ clientSecret })
|
|
||||||
if (error) {
|
|
||||||
this.processingTarget.hidden = true
|
|
||||||
return this.fail(error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3-D Secure cleared client-side — the webhook places the order. Poll.
|
|
||||||
const startedAt = Date.now()
|
|
||||||
const tick = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.orderStatusUrlValue, { headers: { Accept: 'application/json' } })
|
|
||||||
const data = await response.json()
|
|
||||||
if (data.placed && data.redirect) {
|
|
||||||
window.location.assign(data.redirect)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// keep polling
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Date.now() - startedAt > POLL_TIMEOUT) {
|
|
||||||
this.processingTextTarget.textContent = this.processingSlowValue
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setTimeout(tick, POLL_INTERVAL)
|
|
||||||
}
|
|
||||||
tick()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fail(message, { scroll = true } = {}) {
|
|
||||||
this.showError(message, { scroll })
|
|
||||||
this.submitTarget.disabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(message, { scroll = true } = {}) {
|
|
||||||
this.errorTarget.textContent = message
|
|
||||||
this.errorTarget.hidden = false
|
|
||||||
if (scroll) this.errorTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
|
|
||||||
clearError() {
|
|
||||||
this.errorTarget.textContent = ''
|
|
||||||
this.errorTarget.hidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// 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') || ''
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import BbkAddToCartController from './bbk-add-to-cart-controller'
|
|
||||||
import BbkCartController from './bbk-cart-controller'
|
|
||||||
import BbkCheckoutFormController from './bbk-checkout-form-controller'
|
|
||||||
import BbkPaymentController from './bbk-payment-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)
|
|
||||||
application.register('bbk-checkout-form', BbkCheckoutFormController)
|
|
||||||
application.register('bbk-payment', BbkPaymentController)
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,6 @@ import RangeSliderController from './range-slider-controller'
|
|||||||
import StarRatingController from './star-rating-controller'
|
import StarRatingController from './star-rating-controller'
|
||||||
import TabLinkController from './tab-link-controller'
|
import TabLinkController from './tab-link-controller'
|
||||||
import TabsController from './tabs-controller'
|
import TabsController from './tabs-controller'
|
||||||
import WishlistController from './wishlist-controller'
|
|
||||||
|
|
||||||
export function registerControllers(application) {
|
export function registerControllers(application) {
|
||||||
application.register('appear', AppearController)
|
application.register('appear', AppearController)
|
||||||
@@ -38,5 +37,4 @@ export function registerControllers(application) {
|
|||||||
application.register('star-rating', StarRatingController)
|
application.register('star-rating', StarRatingController)
|
||||||
application.register('tab-link', TabLinkController)
|
application.register('tab-link', TabLinkController)
|
||||||
application.register('tabs', TabsController)
|
application.register('tabs', TabsController)
|
||||||
application.register('wishlist', WishlistController)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus'
|
|
||||||
|
|
||||||
// Heart toggle (x-wishlist-button). Posts the form with fetch and reflects the
|
|
||||||
// server's answer on aria-pressed, which the CSS uses to swap the outline and
|
|
||||||
// filled heart. If the request fails, falls back to a normal form submit.
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['button', 'status']
|
|
||||||
|
|
||||||
static values = {
|
|
||||||
addLabel: String,
|
|
||||||
removeLabel: String,
|
|
||||||
addedMessage: String,
|
|
||||||
removedMessage: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggle(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
if (this.busy) return
|
|
||||||
this.busy = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.element.action, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
|
||||||
body: new FormData(this.element),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Wishlist toggle failed: ${response.status}`)
|
|
||||||
|
|
||||||
const { active } = await response.json()
|
|
||||||
|
|
||||||
this.buttonTarget.setAttribute('aria-pressed', active ? 'true' : 'false')
|
|
||||||
this.buttonTarget.setAttribute('aria-label', active ? this.removeLabelValue : this.addLabelValue)
|
|
||||||
this.statusTarget.textContent = active ? this.addedMessageValue : this.removedMessageValue
|
|
||||||
} catch {
|
|
||||||
this.element.submit()
|
|
||||||
} finally {
|
|
||||||
this.busy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
@props(['productId'])
|
@props(['productId'])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$active = app(\App\Services\Wishlist::class)->has((int) $productId);
|
$active = app(\Modules\Core\Wishlist\Services\WishlistService::class)->has((int) $productId);
|
||||||
$addLabel = __('storefront.wishlist.add');
|
$addLabel = __('storefront.wishlist.add');
|
||||||
$removeLabel = __('storefront.wishlist.remove');
|
$removeLabel = __('storefront.wishlist.remove');
|
||||||
@endphp
|
@endphp
|
||||||
|
|||||||
@@ -37,7 +37,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/checkout.css', 'resources/css/app.css', 'resources/js/app.js'])
|
@vite(['node_modules/@boboko/core/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))
|
||||||
|
|||||||
+3
-5
@@ -98,12 +98,10 @@
|
|||||||
->name('logout');
|
->name('logout');
|
||||||
|
|
||||||
// Heart button: works for guests too (cookie), merged into the account
|
// Heart button: works for guests too (cookie), merged into the account
|
||||||
// on login — see App\Services\Wishlist.
|
// on login — see Modules\Core\Wishlist\Services\WishlistService. The
|
||||||
|
// toggle action itself (route `wishlist.toggle`) is registered by
|
||||||
|
// core's own WishlistServiceProvider, not here.
|
||||||
Route::get('/wishlist', [WishlistController::class, 'guest'])->name('wishlist');
|
Route::get('/wishlist', [WishlistController::class, 'guest'])->name('wishlist');
|
||||||
Route::post('/wishlist/{productId}', [WishlistController::class, 'toggle'])
|
|
||||||
->whereNumber('productId')
|
|
||||||
->middleware('throttle:60,1')
|
|
||||||
->name('wishlist.toggle');
|
|
||||||
|
|
||||||
Route::middleware('auth')->prefix('/account')->group(function () {
|
Route::middleware('auth')->prefix('/account')->group(function () {
|
||||||
Route::get('/', [AccountController::class, 'show'])->name('account');
|
Route::get('/', [AccountController::class, 'show'])->name('account');
|
||||||
|
|||||||
+7
-1
@@ -1,6 +1,7 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import laravel from "laravel-vite-plugin";
|
import laravel from "laravel-vite-plugin";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { boboko } from "@boboko/core/vite-plugin";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
@@ -14,13 +15,18 @@ export default defineConfig({
|
|||||||
// checkout.css first: it's the portable cart/checkout module's
|
// checkout.css first: it's the portable cart/checkout module's
|
||||||
// structural base (@layer bbk-checkout) and must load before
|
// structural base (@layer bbk-checkout) and must load before
|
||||||
// app.css so the host's own .bbk-* theming always wins.
|
// app.css so the host's own .bbk-* theming always wins.
|
||||||
"resources/css/checkout.css",
|
// Sourced from the @boboko/core npm package (a local
|
||||||
|
// `file:../boboko-core` dependency — see package.json and
|
||||||
|
// boboko()'s own comments in @boboko/core/vite-plugin for why
|
||||||
|
// this needs no further config here).
|
||||||
|
"node_modules/@boboko/core/resources/css/checkout.css",
|
||||||
"resources/css/app.css",
|
"resources/css/app.css",
|
||||||
"resources/js/app.js",
|
"resources/js/app.js",
|
||||||
],
|
],
|
||||||
refresh: true,
|
refresh: true,
|
||||||
}),
|
}),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
boboko(),
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user