diff --git a/Dockerfile b/Dockerfile index 54d20ef..2788cd8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,11 +10,19 @@ RUN composer install \ --optimize-autoloader \ --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 +# 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 -COPY package*.json ./ -RUN npm ci --ignore-scripts +COPY docker/entrypoint-vite.sh /entrypoint-vite.sh +RUN chmod +x /entrypoint-vite.sh +ENTRYPOINT ["/entrypoint-vite.sh"] # ── Stage 2b: Node / Vite build ────────────────────────────────────────────── FROM node:22-alpine AS node-build diff --git a/app/Http/Controllers/WishlistController.php b/app/Http/Controllers/WishlistController.php index 5dcbc8f..07946da 100644 --- a/app/Http/Controllers/WishlistController.php +++ b/app/Http/Controllers/WishlistController.php @@ -3,19 +3,21 @@ namespace App\Http\Controllers; use App\Catalog\ProductCard; -use App\Services\Wishlist; -use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Request; use Illuminate\Support\Collection; use Illuminate\View\View; -use Lunar\Models\Product; 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 { 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)]); } - /** - * 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 * in the search index (deleted, unpublished) are simply skipped. diff --git a/app/Listeners/MergeGuestWishlistOnLogin.php b/app/Listeners/MergeGuestWishlistOnLogin.php deleted file mode 100644 index 5d1b90d..0000000 --- a/app/Listeners/MergeGuestWishlistOnLogin.php +++ /dev/null @@ -1,23 +0,0 @@ -wishlist->mergeGuestInto($event->user); - } -} diff --git a/app/Models/WishlistItem.php b/app/Models/WishlistItem.php deleted file mode 100644 index 2276309..0000000 --- a/app/Models/WishlistItem.php +++ /dev/null @@ -1,14 +0,0 @@ -app->scoped(\App\Services\Wishlist::class); - } - public function boot(): void { Telemetry::optOut(); diff --git a/app/Services/Wishlist.php b/app/Services/Wishlist.php deleted file mode 100644 index e9d8c95..0000000 --- a/app/Services/Wishlist.php +++ /dev/null @@ -1,126 +0,0 @@ -|null ids for this request, including a toggle just made */ - private ?array $guestIds = null; - - /** @return array 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 */ - 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 $ids */ - private function storeGuestIds(array $ids): void - { - $this->guestIds = $ids; - - Cookie::queue(self::COOKIE, json_encode($ids), self::COOKIE_MINUTES); - } -} diff --git a/database/migrations/2026_09_24_000001_create_wishlist_items_table.php b/database/migrations/2026_09_24_000001_create_wishlist_items_table.php deleted file mode 100644 index d830309..0000000 --- a/database/migrations/2026_09_24_000001_create_wishlist_items_table.php +++ /dev/null @@ -1,25 +0,0 @@ -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'); - } -}; diff --git a/docker-compose.core-dev.yml b/docker-compose.core-dev.yml index 8eb19db..c14cf1a 100644 --- a/docker-compose.core-dev.yml +++ b/docker-compose.core-dev.yml @@ -10,3 +10,7 @@ services: scheduler: volumes: - ../boboko-core:/var/www/boboko-core + + vite: + volumes: + - ../boboko-core:/boboko-core diff --git a/docker/entrypoint-vite.sh b/docker/entrypoint-vite.sh new file mode 100644 index 0000000..ee87148 --- /dev/null +++ b/docker/entrypoint-vite.sh @@ -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 "$@" diff --git a/package-lock.json b/package-lock.json index 8b0b914..5f554c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "boboko-starter", + "name": "app", "lockfileVersion": 3, "requires": true, "packages": { @@ -7,9 +7,8 @@ "dependencies": { "@hotwired/stimulus": "^3.2.2", "@hotwired/turbo": "^8.0.23", - "@phosphor-icons/web": "^2.1.2", "axios": "^1.15.2", - "flatpickr": "^4.6.13" + "leaflet": "^1.9.4" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", @@ -147,12 +146,6 @@ "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": { "version": "1.1.4", "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": { "version": "1.16.0", "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": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", diff --git a/package.json b/package.json index fb3b9f4..383a932 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,14 @@ "vite": "^8.0.0" }, "dependencies": { + "@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x", "@hotwired/stimulus": "^3.2.2", "@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" } } diff --git a/resources/css/checkout.css b/resources/css/checkout.css deleted file mode 100644 index 51a4648..0000000 --- a/resources/css/checkout.css +++ /dev/null @@ -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 ), 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); -} diff --git a/resources/js/app.js b/resources/js/app.js index 82841fb..825e7bb 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -10,13 +10,15 @@ window.Turbo.session.drive = false; import { Application } from "@hotwired/stimulus"; import { registerControllers } from "./stimulus/index"; -import { registerCheckout } from "./checkout"; +import { registerCheckout, registerWishlist } from "@boboko/core"; const application = Application.start(); application.debug = false; 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. +// Portable cart + checkout + wishlist modules, installed as a real npm +// dependency (see package.json). Each owns its own Stimulus controllers; +// these are the only wiring lines they need here. registerCheckout(application); +registerWishlist(application); diff --git a/resources/js/checkout/bbk-add-to-cart-controller.js b/resources/js/checkout/bbk-add-to-cart-controller.js deleted file mode 100644 index 1f1cdf2..0000000 --- a/resources/js/checkout/bbk-add-to-cart-controller.js +++ /dev/null @@ -1,57 +0,0 @@ -import { Controller } from '@hotwired/stimulus' -import { csrfToken } from './csrf' - -// Sits on an
. 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 - } -} diff --git a/resources/js/checkout/bbk-cart-controller.js b/resources/js/checkout/bbk-cart-controller.js deleted file mode 100644 index dfe5901..0000000 --- a/resources/js/checkout/bbk-cart-controller.js +++ /dev/null @@ -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), - }, - })) - } -} diff --git a/resources/js/checkout/bbk-checkout-form-controller.js b/resources/js/checkout/bbk-checkout-form-controller.js deleted file mode 100644 index a8e720e..0000000 --- a/resources/js/checkout/bbk-checkout-form-controller.js +++ /dev/null @@ -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 . 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) - } - } -} diff --git a/resources/js/checkout/bbk-payment-controller.js b/resources/js/checkout/bbk-payment-controller.js deleted file mode 100644 index 9cc59a5..0000000 --- a/resources/js/checkout/bbk-payment-controller.js +++ /dev/null @@ -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 - } -} diff --git a/resources/js/checkout/csrf.js b/resources/js/checkout/csrf.js deleted file mode 100644 index b3a3757..0000000 --- a/resources/js/checkout/csrf.js +++ /dev/null @@ -1,6 +0,0 @@ -// Reads the CSRF token from the standard tag every -// boboko host renders in its layout . Kept as its own module so both -// checkout controllers share one source. -export function csrfToken() { - return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' -} diff --git a/resources/js/checkout/index.js b/resources/js/checkout/index.js deleted file mode 100644 index 1163918..0000000 --- a/resources/js/checkout/index.js +++ /dev/null @@ -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) -} diff --git a/resources/js/stimulus/index.js b/resources/js/stimulus/index.js index 669dd2a..de8ce1c 100644 --- a/resources/js/stimulus/index.js +++ b/resources/js/stimulus/index.js @@ -19,7 +19,6 @@ import RangeSliderController from './range-slider-controller' import StarRatingController from './star-rating-controller' import TabLinkController from './tab-link-controller' import TabsController from './tabs-controller' -import WishlistController from './wishlist-controller' export function registerControllers(application) { application.register('appear', AppearController) @@ -38,5 +37,4 @@ export function registerControllers(application) { application.register('star-rating', StarRatingController) application.register('tab-link', TabLinkController) application.register('tabs', TabsController) - application.register('wishlist', WishlistController) } diff --git a/resources/js/stimulus/wishlist-controller.js b/resources/js/stimulus/wishlist-controller.js deleted file mode 100644 index 160e977..0000000 --- a/resources/js/stimulus/wishlist-controller.js +++ /dev/null @@ -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 - } - } -} diff --git a/resources/views/components/wishlist-button.blade.php b/resources/views/components/wishlist-button.blade.php index f457057..ea306f8 100644 --- a/resources/views/components/wishlist-button.blade.php +++ b/resources/views/components/wishlist-button.blade.php @@ -8,7 +8,7 @@ @props(['productId']) @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'); $removeLabel = __('storefront.wishlist.remove'); @endphp diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index d693de0..8d26cc9 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -37,7 +37,7 @@ - @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') @php($stoicToken = \App\Services\Stoic::token(auth('staff')->user()->email)) diff --git a/routes/web.php b/routes/web.php index 8c30c03..99b0a7d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -98,12 +98,10 @@ ->name('logout'); // 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::post('/wishlist/{productId}', [WishlistController::class, 'toggle']) - ->whereNumber('productId') - ->middleware('throttle:60,1') - ->name('wishlist.toggle'); Route::middleware('auth')->prefix('/account')->group(function () { Route::get('/', [AccountController::class, 'show'])->name('account'); diff --git a/vite.config.js b/vite.config.js index 9274b7e..aa0e1aa 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,7 @@ import { defineConfig } from "vite"; import laravel from "laravel-vite-plugin"; import tailwindcss from "@tailwindcss/vite"; +import { boboko } from "@boboko/core/vite-plugin"; export default defineConfig({ build: { @@ -14,13 +15,18 @@ export default defineConfig({ // 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", + // 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/js/app.js", ], refresh: true, }), tailwindcss(), + boboko(), ], server: { host: "0.0.0.0",