54 Commits
Author SHA1 Message Date
arvanitakis 78c8165c04 Feat: Box now tests 2026-09-17 19:55:39 +03:00
arvanitakis fe55cb5f33 Fix: Removing Duplicate Event Dispatching for UserCreated model 2026-09-16 23:22:32 +03:00
arvanitakis 038ea05d56 Feature: translations on regions and countries on payment methods 2026-09-15 23:53:59 +03:00
elvira ba6d68c5b9 payment fix 2026-09-15 19:20:59 +03:00
elvira 1598f053ee payment fix 2026-09-15 15:04:40 +03:00
arvanitakis 279339a095 Feat: Updating config for boboko order updates 2026-09-14 20:17:34 +03:00
elvira df9374a070 autosave issue in checkout - pt1 2026-09-14 20:13:40 +03:00
elvira 97c12efdb1 removed resetShippingManifest() workaround from CheckoutController 2026-09-14 16:30:45 +03:00
elvira 2cac70c53a payment beginning (stripe) 2026-09-09 19:16:20 +03:00
elvira 46b3f29674 marketing consent to recovery consent and connection with core 2026-09-09 14:42:23 +03:00
elvira 7a8b9cf4c0 marketing consent 2026-09-09 14:09:59 +03:00
elvira 0fb606ff3f checkout and shipping part 2026-09-09 13:51:00 +03:00
elvira 2d85591a43 checkout process - start 2026-09-08 20:41:49 +03:00
elvira 7577426f49 cart drawer and general structure 2026-09-04 19:39:37 +03:00
elvira c5f7b28aa0 Merge branch 'elv' into cart-temp 2026-09-04 13:21:58 +03:00
elvira 9abe45c5f7 minor changes in comments 2026-09-04 13:19:24 +03:00
elvira e57bfe3d42 minor change in contact page 2026-09-04 13:09:10 +03:00
arvanitakis 72b9abf92f Feat: Updating Product Cards, Listing Pages and SearchController 2026-09-04 13:07:57 +03:00
elvira 6c7d11ca78 minor change in contact page 2026-09-04 12:58:09 +03:00
elvira 1f0612861b general products page and small rewrite of search and category controllers etc 2026-09-03 21:32:40 +03:00
arvanitakis b2207a622c Feat: Updating COntrollers to remove business logic moved to boboko/core 2026-09-03 11:47:38 +03:00
arvanitakis b87e22381f Feat: Publishing Payment and Order Emails 2026-09-03 11:46:55 +03:00
arvanitakis 53f30ff51a Feat: Bump Meilisearch Version to 1.12 2026-09-03 11:46:19 +03:00
elvira 210ed3b094 removed search from category filters and fixed product links 2026-09-02 14:05:20 +03:00
arvanitakis 5e2ec7a60a Feat: Entrypoint updates 2026-09-02 13:23:01 +03:00
arvanitakis 2d1624bcb2 Feat: Moving Fonts under resources, so vite can pick them up and serve them 2026-09-01 13:00:26 +03:00
arvanitakis ea6ebe435e Feature: Updating entrypoints, to support caching, migrations, etc 2026-09-01 12:43:19 +03:00
arvanitakis 2d75bb9e01 Feature: Gitgnoring generated assets, since these are built/baked into the image at container starts 2026-09-01 12:36:43 +03:00
elvira 8a07c772f8 product category page: front-end sorting and filtering using turboframes 2026-08-31 22:56:40 +03:00
elvira b070a7d1e6 category page filters components and general ui - no functionality yet 2026-08-31 21:52:47 +03:00
arvanitakis 74e554884f Feat: Updating Entrypoint to setup lunar meilisearch 2026-08-31 14:56:17 +03:00
arvanitakis 4387459aed Feat: Upgrading to Boboko 0.10.0 / Lunar 1.5 2026-08-31 14:25:22 +03:00
arvanitakis adb847f442 Feature: Adding Meilisearch to 3dealer 2026-08-31 13:14:40 +03:00
arvanitakis c7cd1138fe Feat: Updating routes and controllers to use the updated Services coming from boboko/core 2026-08-29 00:17:34 +03:00
arvanitakis defe1dab12 Feat: Updating Product imports after Boboko restructure 2026-08-27 11:46:33 +03:00
arvanitakis 86c645fb36 Feat: Adding Color Product Option Types 2026-08-27 01:58:37 +03:00
arvanitakis 0dcaf112be Feature: Updating Core imports after restructure 2026-08-27 01:21:57 +03:00
arvanitakis b3405c1b60 Feat: Updating Home, Product and Category Controllers and veiws to utilize the product service 2026-08-27 00:55:56 +03:00
elvira 65205e2760 locale routes fix 2026-08-26 19:12:13 +03:00
elvira 36ff25657d composer merge 2026-08-26 18:41:54 +03:00
elvira 4923bf82c3 composer lock eleos 2026-08-26 18:39:39 +03:00
elvira 325d71bac5 composer lock 2026-08-26 18:34:15 +03:00
arvanitakis 525c920f83 Feature: Adding Meilisearch to 3dealer 2026-08-26 18:34:13 +03:00
elvira 90e32e337d localization from boboko core and product service too 2026-08-26 18:28:16 +03:00
arvanitakis 50c9670f2a Fix: Mapping Vite ports based on .env value 2026-08-26 17:59:13 +03:00
arvanitakis 092fab8740 Fix: Adding comments to entrypoint 2026-08-26 14:47:27 +03:00
elvira fab9cc08a8 contact page, legal pages and category page 2026-08-26 13:43:30 +03:00
elvira 45057f9083 homepage stuff and text animation fix 2026-08-22 21:59:17 +03:00
elvira 7677d10821 price function global (needs review) 2026-08-08 15:10:37 +03:00
elvira e46276a31b homepage, new newsletter section, multilang support 2026-08-08 14:50:10 +03:00
elvira bf8b9219fc setting up front 1 2026-07-31 17:41:55 +03:00
elvira b88fde739c Merge branch 'master' into elv 2026-07-10 16:56:34 +03:00
elvira ace8290c62 composer lock 2026-07-10 16:55:57 +03:00
elvira dbc8866056 vite 2026-07-09 19:20:20 +03:00
179 changed files with 13041 additions and 3156 deletions
+7 -1
View File
@@ -8,7 +8,7 @@ APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_LOCALE=el
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
@@ -79,6 +79,11 @@ STOIC_SECRET=dev-secret-change-me
STOIC_SSO_SECRET=dev-sso-secret-change-me
STOIC_HOST=http://localhost:2727
SCOUT_DRIVER=meilisearch
SCOUT_QUEUE=true
MEILISEARCH_KEY=dev-meilisearch-key-change-me
MEILISEARCH_HOST=http://meilisearch:7700
# Host-side ports — change these if another project on this machine already uses the defaults
APP_PORT=8090
STOIC_PORT=2727
@@ -86,3 +91,4 @@ VALKEY_PORT=6339
POSTGRES_HOST_PORT=5436
MAILPIT_PORT=8025
VITE_PORT=5173
MEILISEARCH_PORT=7700
+3
View File
@@ -19,6 +19,9 @@
/public/sitemap.xml
/public/logos/core
/public/storage
/public/css/filament
/public/js/filament
/public/fonts/filament
/storage/*.key
/storage/framework/migrated
/storage/pail
+215
View File
@@ -0,0 +1,215 @@
# Front-end Guidelines
This is a Laravel 12 e-commerce project using Lunar PHP (headless). These guidelines apply to all front-end work.
---
## Project Language Settings
<!-- CHANGE THESE PER PROJECT -->
- **Greek register:** `singular` — use the informal second person (εσύ/σου/σε). e.g. "Η κριτική σου", "Το όνομά σου", "Το email σου"
<!-- singular = informal (εσύ) | plural = formal (εσείς) -->
All UI text written for this project must follow the register above.
---
## Localization
Uses classic Laravel localization (https://laravel.com/docs/13.x/localization) — no third-party i18n routing package.
**Locales:** Greek (`el`) is primary/default, English (`en`) is secondary. Set in `config('app.available_locales')` (`config/app.php`).
**URL structure:** every route is prefixed with `{locale}`, including the default — `3dealer.gr/el/...` and `3dealer.gr/en/...`. Bare root (`/`) 301-redirects to `/el` (see `routes/web.php`). This avoids the ambiguity of an unprefixed default locale (see the reasoning in project memory / past conversation — prefixing every locale keeps hreflang symmetrical and scales cleanly to a third language later).
**URL slugs are the same across both locales, permanently** — e.g. `/el/products/{x}` and `/en/products/{x}`, not `/el/proionta/{x}` vs `/en/products/{x}`. This is a deliberate, standing decision for this project, not a placeholder to revisit later. Only the visible content is translated, not the slug. Do not introduce per-locale translated slugs (e.g. a `lang/{locale}/routes.php` segment map) unless the user explicitly asks for that to change.
**How it's wired:**
- All routes live inside `Route::prefix('{locale}')->where('locale', ...)->middleware('setlocale')` in `routes/web.php`.
- `App\Http\Middleware\SetLocale` validates the `{locale}` segment (404s if not in `available_locales`), calls `App::setLocale()`, and shares `$currentLocale`, `$altLocale`, and `$altLocaleUrl` (the same page in the other locale) to every view — this is what powers both the header's language switcher and the `<head>` hreflang tags in `resources/views/layouts/app.blade.php`. Don't recompute this logic elsewhere; read those shared variables instead.
- When adding a new route, put it inside that `{locale}` group and give it a route `name()` — the alt-locale URL generation in the middleware depends on the current route being named (falls back to the locale root if unnamed).
- When linking to a page in Blade, prefer `route('name', [...])` (locale is a normal route param, defaults to `app()->getLocale()` implicitly via the shared route group) over hand-built path strings, except for pages that don't have a controller/route yet (e.g. `/products`, `/contact`, `/cart` placeholders in the header currently use `url('/'.app()->getLocale().'/products')` — replace with `route()` once those pages exist).
- **Every controller action for a route inside the `{locale}` group must declare `$locale` as its first parameter, even if unused** — e.g. `show(string $locale, Product $product)`. Laravel's `ControllerDispatcher` ultimately calls the controller with `...array_values($parameters)`, i.e. **positionally**. If a route-bound model parameter (like `$product`) isn't preceded by a matching `$locale` parameter in the method signature, the resolved values shift out of position and the wrong value (the locale string) gets passed where the model was expected — a `TypeError` that's easy to misread as a binding failure. Closures with zero declared parameters are unaffected (PHP just ignores the extra positional arg), so this only bites real controller methods.
**Where translatable strings go:**
- Most UI copy is editable by the client/marketing team via **Stoic** (the headless CMS) — don't hardcode it here.
- Small strings that don't need client editing (nav labels, aria-labels, tab labels, pluralized counts) go in Laravel's own lang files at **`lang/{locale}/*.php`** (project root, per Laravel 12+ convention — not `resources/lang`). Current file: `lang/el/general.php` / `lang/en/general.php`. Use `__('general.key')` or `trans_choice('general.key', $count, [...])` for pluralized strings.
---
## Tech Stack
- **Laravel 12** + **Lunar PHP 1.3** (headless e-commerce)
- **Blade** for templating (`@extends`, `@section`, `@yield`, components)
- **Tailwind CSS v4** via `@tailwindcss/vite` — zero config, `@import 'tailwindcss'` only
- **Stimulus JS** for interactivity — controllers registered in `resources/js/stimulus/index.js`
- **Popover API** (native browser) for overlays — no JS overlay libraries
- SQLite database, `npm run dev` for local asset compilation
---
## Key File Locations
| What | Where |
|---|---|
| CSS entry point | `resources/css/app.css` |
| Font definitions | `resources/css/fonts.css` |
| JS entry point | `resources/js/app.js` |
| Stimulus controllers | `resources/js/stimulus/` |
| JS utilities | `resources/js/utils/` |
| Layout | `resources/views/layouts/app.blade.php` |
| UI components (atomic) | `resources/views/components/ui/` |
| Section components | `resources/views/components/` |
| Page views | `resources/views/{page}/` |
| Public assets | `public/images/` |
---
## Design Reference
The Bluebeard template (`resources/css/bluebeard.css`) is used as a **reference only** for colors, spacing, typography, and effects. It is never imported or used directly. Everything is translated to Tailwind utilities or custom CSS where unavoidable.
The boboko project at `/Users/farenoubi/Projects/boboko-test` can be referenced for CSS architecture patterns.
---
## Tailwind vs. Custom CSS
**Prefer Tailwind utilities by default.** Write custom CSS only when something genuinely cannot be expressed as a utility:
- Pseudo-elements (`::before`, `::after`) with dynamic transforms or transitions
- Complex descendant/sibling selectors (e.g. `.group:hover .nav-dropdown`)
- Keyframe animations
- The underline-slide animation on nav links (background-size trick)
**Never** write inline CSS (`style="..."`). **Never** write custom CSS for something Tailwind already covers.
When a component requires a CSS class (e.g. as a hook for a pseudo-element), still put all properties that can be Tailwind utilities as classes on the element in the component file. The CSS rule should contain only what genuinely cannot be a utility — pseudo-elements, complex selectors, keyframes. Do not default to putting all styles in the CSS class just because the class exists.
When custom CSS is needed, add it to `resources/css/app.css` inside `@layer components`. Keep the rule minimal — only what can't be a utility.
---
## Design Tokens
Defined in `@theme {}` in `app.css`:
- `--font-sans`: Manrope (body text)
- `--font-display`: Manrope (headings, nav, buttons) — kept separate from `--font-sans` in case they diverge later
Reuse existing colors (`neutral-200`, `black`), font sizes, spacing, and border styles as components are built. Do not invent new values — check what's already in use first.
---
## Component Structure
### `resources/views/components/ui/` — atomic, reusable UI elements
Single-purpose, stateless or minimally stateful elements used across many contexts:
- `button.blade.php` ✓
- `input.blade.php`
- `input-group.blade.php`
- `icon.blade.php`
- `image.blade.php`
- `tabs.blade.php` / `tab.blade.php`
- `accordion.blade.php`
- `tooltip.blade.php`
- `badge.blade.php`
- etc.
### `resources/views/components/` — section-level or composite components
Larger reusable blocks made of several elements, often with real content:
- `header.blade.php` ✓
- `footer.blade.php`
- `cta.blade.php`
- `product-card.blade.php`
- `contact-section.blade.php`
- etc.
---
## Interactivity
### Popover API for overlays
For **tooltips, modals, dropdowns, and any overlay**: use the native [Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API) with CSS — no JS, no libraries.
```html
<button popovertarget="my-popover">Open</button>
<div id="my-popover" popover>...</div>
```
Style open/closed states with `[popover]` and `:popover-open` selectors in CSS.
### Stimulus JS for behavior
Use Stimulus when JS is genuinely needed (form handling, cart updates, dynamic state, etc.).
- Keep controllers lean: manage state and coordinate the DOM, don't build UI inside them
- Use Stimulus values, targets, and outlets — avoid reading from the DOM imperatively
- **Never inject HTML strings or classes from inside a controller.** Instead, drive appearance via data attributes or CSS classes toggled on existing elements, and let CSS handle the visual result
- Never write inline JS (`onclick="..."`, etc.)
### Pure CSS for simple interactions
Hover dropdowns, focus states, active states — handle with CSS (`:hover`, `:focus-within`, `.group:hover`) before reaching for Stimulus.
---
## Accessibility (ARIA)
Always add ARIA attributes. This is not optional:
- Interactive elements: `aria-label`, `aria-expanded`, `aria-controls`, `aria-haspopup` as appropriate
- Images: meaningful `alt` text, or `alt=""` + `aria-hidden="true"` for decorative images
- Icons used as buttons: `aria-label` on the button, `aria-hidden="true"` on the SVG
- Form fields: always associated `<label>`, `aria-describedby` for hints/errors
- Dynamic content: `aria-live` regions where content updates without navigation
- When refactoring or adding interactivity, review ARIA impact — don't break existing roles
---
## Greek Typography
CSS `text-transform: uppercase` leaves Greek tonos accents in place (e.g. Ά instead of Α), which is incorrect. The utility at `resources/js/utils/strip-accents.js` handles this automatically for any element with the `uppercase` Tailwind class. **Always add the `uppercase` class to elements** rather than applying `text-transform: uppercase` in CSS — this keeps the strip-accents utility working without extra configuration.
---
## Performance & Core Web Vitals
Every frontend change should assume it will be measured by PageSpeed/Lighthouse. These rules exist because we hit each of these mistakes in production and had to fix them after the fact — bake them in up front instead.
### CSS bundling
- Before adding a CSS import to a shared/global entry point (e.g. `app.css`), ask whether every page actually needs it. Page- or component-specific styles (date pickers, legal-page typography, admin-only widgets) should not ship on pages that don't use them.
- Scope non-critical CSS to load only where it's used — either a dedicated build entry for that page, or import the `.css` file inside the same JS module that already lazy-loads that component (e.g. a Stimulus/JS controller), so the bundler emits it as a separate chunk fetched only when that component actually mounts.
- Exception: never defer CSS for anything the page shows immediately on load (above-the-fold content, or a component that renders as soon as its JS runs — an FAQ accordion, a cookie-consent banner). Even a well-scoped async CSS chunk arrives a beat after the JS that displays the element, causing a visible flash of unstyled/wrong-state content and a layout shift. Keep that CSS in the blocking bundle on purpose.
- Never import a full icon-font library (Phosphor, Font Awesome, etc.) — it ships every icon regardless of usage. Use inline SVGs for only the icons actually referenced; check the shared icon component for an existing icon before adding a new one.
- Only declare design tokens / CSS custom properties that are actually used somewhere in the codebase (Tailwind v4 already tree-shakes unused `@theme` tokens from output, so this is for keeping the source legible, not for bytes).
### Fonts
- `font-display: swap` means any text using a font weight will visibly reflow once that weight's file finishes downloading, if the fallback font's metrics differ. Preload every font weight used in above-the-fold text, not just the default body-copy weight — bold/black heading weights are usually the most visually prominent element on the page and cause the most visible reflow if not preloaded.
- When introducing a new heavy-weight text utility (e.g. a bold/black heading class) in a hero or above-the-fold component, confirm that weight has a matching `<link rel="preload" as="font">` in the layout `<head>`.
### Images
- Any image that could be the Largest Contentful Paint element (hero/banner images) must have: `fetchpriority="high"`, `loading="eager"`, explicit `width`/`height`, a responsive `srcset`/`sizes`, and a `<link rel="preload" as="image">` in `<head>` with matching `imagesrcset`/`imagesizes` — this lets the browser start the fetch as soon as it parses `<head>`, instead of waiting to discover the `<img>` tag in the body.
- Every other image: `loading="lazy"` with explicit `width`/`height` to reserve its layout space.
### Layout shift (CLS)
- Never animate `top`/`bottom`/`left`/`right`/`width`/`height`/`margin` on any element that appears or moves without direct user interaction (banners, toasts, slide-in modals, accordions). These are layout-affecting properties — the browser recalculates layout on every animation frame, and each frame's position delta counts toward CLS. Animate `transform` (translate/scale) and `opacity` instead — both are compositor-only and fully excluded from CLS scoring, no matter how far or long the animation runs.
- This applies to vendor/third-party CSS too (cookie-consent widgets, modal libraries) — check its default show/hide mechanism before shipping, and override it if it animates a layout property directly.
- Any element with a collapsed/expanded default state (accordions, dropdowns) needs its collapsed-state CSS in the blocking stylesheet, never a lazily-loaded chunk — otherwise it renders in its expanded/default state for a frame before the async CSS applies.
---
## Forms
Never add `novalidate` to forms. Always use the browser's default validation.
---
## Asking Questions
When the approach is unclear — layout structure, whether something should be a component, which Tailwind pattern fits — **ask before building**. A short question is cheaper than a refactor.
+5 -7
View File
@@ -119,13 +119,11 @@ COPY docker/php/php.dev.ini /etc/php/8.5/cli/conf.d/99-app.ini
WORKDIR /var/www/html
COPY composer.json composer.lock ./
RUN composer install \
--no-interaction \
--no-scripts \
--prefer-dist \
--ignore-platform-reqs
# No build-time `composer install` here: composer.json's boboko/core path repo
# (../boboko-core) isn't visible in the build context, only once bind-mounted at
# container start — entrypoint.sh already runs composer install +
# composer update boboko/* on every boot, so this would be redundant even if it
# could work.
COPY docker/entrypoint.sh /entrypoint.sh
COPY docker/entrypoint-worker.sh /entrypoint-worker.sh
RUN chmod +x /entrypoint.sh /entrypoint-worker.sh
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Catalog;
/**
* Presentation shaping — how a storefront product listing/grid card is built:
* name, price, image, href. Deliberately not in boboko-core: `href` depends on
* this storefront's own routes, and another app on the same core package could
* want an entirely different card shape. One place, so HomeController /
* CategoryController / ProductController / SearchController don't each
* hand-write the same name/price/image/href mapping.
*
* One source: a localized index array — ProductService::list()/getById()/
* random(), and now ProductSearchService::search() too, all return the exact
* same document shape (see ProductListingResult), so this is the only mapping
* every storefront listing page needs.
*/
final class ProductCard
{
/**
* @param array<string, mixed> $product one item from ProductService's localized array shape
* @return array{name: ?string, price: ?string, image: ?string, href: string}
*/
public static function fromIndexed(array $product): array
{
return [
'name' => $product['name'],
'price' => $product['price'],
'image' => $product['media'][0]['url'] ?? null,
'href' => route('product.show', ['id' => $product['id']]),
];
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Catalog;
use Illuminate\Http\Request;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\Enums\ProductSort;
/**
* The parsed filter/sort/page state of a product-listing request — used by the
* category page (scoped to a collection) and the all-products page. The query
* string is the single source of truth; build one of these from the request,
* read the applied values off it, and use query() to build links (sort options,
* pagination, "clear filter") that carry the rest of the state along.
*
* A param is only ever emitted when it differs from its default, so a pristine
* listing has no query string at all.
*/
final class ProductListing
{
private function __construct(
public readonly ?ProductSort $sort,
public readonly ?float $minPrice,
public readonly ?float $maxPrice,
public readonly bool $inStockOnly,
public readonly int $page,
) {}
public static function fromRequest(Request $request): self
{
return new self(
sort: ProductSort::tryFrom((string) $request->query('sort')),
minPrice: self::floatOrNull($request->query('price_min')),
maxPrice: self::floatOrNull($request->query('price_max')),
inStockOnly: $request->boolean('in_stock'),
page: max(1, (int) $request->query('page', 1)),
);
}
/**
* @param ?int $collectionId scope to a collection (category page); null = every product
*/
public function filters(?int $collectionId = null): ProductFilters
{
return new ProductFilters(
collectionId: $collectionId,
minPrice: $this->minPrice,
maxPrice: $this->maxPrice,
inStockOnly: $this->inStockOnly,
);
}
/**
* The applied params as a clean array (defaults omitted), with `$overrides`
* merged on top — pass `['key' => null]` to drop one.
*
* @param array<string, string|int|null> $overrides
* @return array<string, string|int>
*/
public function query(array $overrides = []): array
{
return array_filter([
'sort' => $this->sort?->value,
'price_min' => $this->minPrice,
'price_max' => $this->maxPrice,
'in_stock' => $this->inStockOnly ? 1 : null,
'page' => $this->page > 1 ? $this->page : null,
...$overrides,
], fn ($value) => $value !== null);
}
/**
* Whether the listing is reordered/narrowed enough that it shouldn't be
* indexed as its own page (the canonical still points at the bare listing
* URL either way). A plain in-stock toggle is left indexable.
*/
public function isRefined(): bool
{
return $this->sort !== null
|| $this->minPrice !== null
|| $this->maxPrice !== null
|| $this->page > 1;
}
private static function floatOrNull(mixed $value): ?float
{
return is_numeric($value) ? (float) $value : null;
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Catalog;
use Closure;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Services\ProductSearchService;
use Modules\Core\Catalog\Services\ProductService;
/**
* Assembles the data the shared shop listing body (shop/partials/listing.blade.php)
* needs — the product page, price-slider bounds, sort links and the "clear price"
* link. Used by the category page (scoped to a collection), the all-products
* page, and the search page (scoped to a query — see $query below); the `$url`
* closure turns a query-param array into a URL for whichever page is calling,
* so this class never has to know the route.
*/
final class ProductListingPage
{
private const PER_PAGE = 12;
public function __construct(
private readonly ProductService $products,
private readonly ProductSearchService $search,
) {}
/**
* @param Closure(array<string, string|int>): string $url
* @param ?int $collectionId scope to a collection, or null for every product
* @param ?string $query scope to a text search — when given, calls
* @return array<string, mixed>
*/
public function build(ProductListing $listing, Closure $url, ?int $collectionId = null, ?string $query = null): array
{
$filters = $listing->filters($collectionId);
// Listing reads from the Meilisearch index via ProductService/
// ProductSearchService, not Eloquent. Both return a
// ProductListingResult — the product page plus the price-slider
// bounds (and available tags) from one call; the controller no
// longer stitches list()/search() + priceRange() together itself.
// Sort/filter/page all come from $listing (the query string).
$result = $query !== null
? $this->search->search(
query: $query,
filters: $filters,
sort: $listing->sort,
perPage: self::PER_PAGE,
page: $listing->page,
)
: $this->products->list(
filters: $filters,
perPage: self::PER_PAGE,
page: $listing->page,
sort: $listing->sort,
);
$products = $result->products
->through(ProductCard::fromIndexed(...))
->appends($listing->query(['page' => null]));
// Slider bounds — the price span of everything matching the *other*
// filters, rounded to whole euros, plus whether the current price params
// actually narrow that span. All computed in core
$priceBounds = $result->priceBounds;
return [
'listing' => $listing,
'products' => $products,
'listingAction' => $url([]),
'priceFloor' => $priceBounds->floor,
'priceCeil' => $priceBounds->ceil,
'clearPriceUrl' => $priceBounds->filtered
? $url($listing->query(['price_min' => null, 'price_max' => null, 'page' => null]))
: null,
'sortOptions' => ProductSortOptions::build(
$listing->sort,
fn (?ProductSort $sort) => $url($listing->query(['sort' => $sort?->value, 'page' => null])),
),
];
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Catalog;
use Closure;
use Modules\Core\Catalog\Enums\ProductSort;
/**
* Builds the option list for the shared <x-shop.sort> dropdown, so the label
* map and "the default sort has no URL param" rule live in one place. Each page
* supplies a `$url` closure that turns a sort (or null = default/relevance)
* into the right href for that page — category vs. search build their URLs
* differently.
*/
final class ProductSortOptions
{
/**
* @param Closure(?ProductSort): string $url
* @return array<int, array{label: string, href: string, current: bool}>
*/
public static function build(?ProductSort $current, Closure $url): array
{
$sorts = [
null => 'storefront.shop.sort_popularity',
ProductSort::PriceAsc->value => 'storefront.shop.sort_price_asc',
ProductSort::PriceDesc->value => 'storefront.shop.sort_price_desc',
ProductSort::Newest->value => 'storefront.shop.sort_newest',
];
return array_map(function (string $key, string $label) use ($current, $url) {
$sort = $key === '' ? null : ProductSort::from($key);
return [
'label' => __($label),
'href' => $url($sort),
'current' => $sort === $current,
];
}, array_keys($sorts), array_values($sorts));
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers;
use App\Catalog\ProductListing;
use App\Catalog\ProductListingPage;
use Illuminate\Http\Response;
use Modules\Core\Catalog\Services\CollectionService;
class CategoryController extends Controller
{
public function __construct(
private readonly ProductListingPage $listingPage,
private readonly CollectionService $collections,
) {}
public function show(string $locale, int $collection)
{
$collectionData = $this->collections->getById($collection);
abort_if($collectionData === null, Response::HTTP_NOT_FOUND);
$listing = ProductListing::fromRequest(request());
$data = $this->listingPage->build(
$listing,
fn (array $query) => route('category.show', ['id' => $collectionData['id']] + $query),
$collectionData['id'],
);
return view('category.show', [...$data, 'collection' => $collectionData]);
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Checkout;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Lunar\Models\ProductVariant;
use Modules\Core\Cart\Exceptions\InvalidCouponException;
use Modules\Core\Cart\Services\CartService;
/**
* Thin storefront cart endpoints for the checkout module. Every action mutates
* the session cart via boboko-core's CartService and returns the same
* server-rendered `cart-body` partial — the drawer's Stimulus controller swaps
* that fragment in place (no JSON, no client-side templating). $cart / $lines
* for the partial come from the view composer in CheckoutModuleServiceProvider.
*/
class CartController extends Controller
{
public function __construct(
private readonly CartService $cart,
) {}
public function add(string $locale, Request $request): View
{
$data = $request->validate([
'purchasable_id' => ['required', 'integer'],
'quantity' => ['nullable', 'integer', 'min:1'],
]);
$variant = ProductVariant::findOrFail($data['purchasable_id']);
$this->cart->addLine($variant, $data['quantity'] ?? 1);
return view('checkout::partials.cart-body');
}
public function updateLine(string $locale, Request $request, int $line): View
{
$quantity = (int) $request->validate([
'quantity' => ['required', 'integer', 'min:0'],
])['quantity'];
$quantity === 0
? $this->cart->removeLine($line)
: $this->cart->updateLine($line, $quantity);
return view('checkout::partials.cart-body');
}
public function remove(string $locale, int $line): View
{
$this->cart->removeLine($line);
return view('checkout::partials.cart-body');
}
/**
* A bad code is a normal, expected outcome here (typo, expired code), not
* an error state for the request — it re-renders the same cart-body
* partial with $couponError set, rather than a 4xx/redirect, so the fetch
* + swap in bbk-cart-controller stays the one code path for every cart
* mutation.
*/
public function applyCoupon(string $locale, Request $request): View
{
$code = $request->validate([
'code' => ['required', 'string'],
])['code'];
$couponError = false;
try {
$this->cart->applyCoupon($code);
} catch (InvalidCouponException) {
$couponError = true;
}
return view('checkout::partials.cart-body', ['couponError' => $couponError]);
}
public function removeCoupon(string $locale): View
{
$this->cart->removeCoupon();
return view('checkout::partials.cart-body');
}
}
@@ -0,0 +1,531 @@
<?php
namespace App\Http\Controllers\Checkout;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Facades\CartSession;
use Lunar\Models\Cart;
use Lunar\Models\Country;
use Lunar\Models\Order;
use Lunar\Models\State;
use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\NoShippingAddressException;
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Checkout\Services\CheckoutService;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
/**
* The checkout page — one page, sections (contact / billing / shipping /
* shipping method), reviewed against boboko-core's CheckoutService. Stops
* short of payment for this slice (see project memory).
*
* The address form and the shipping-method radios **autosave** — no submit
* buttons. `saveAddress()` / `selectShippingOption()` are called by
* bbk-checkout-form (debounced fetch) and return a JSON envelope of
* server-rendered fragments (shipping options + order summary) plus any
* field errors, rather than redirecting. Address validation is deliberately
* lenient — nothing is rejected mid-typing; required-field enforcement is the
* job of the (not-yet-built) "Continue to payment" gate.
*
* Guest-only for now — the Contact section's login tab is UI only.
*
* Single-country store: STORE_COUNTRY_ISO3 fixes the country to Greece (hidden
* field, forced server-side). Set it to null for the full country picker (the
* portable path for a multi-country boboko store).
*/
class CheckoutController extends Controller
{
private const STORE_COUNTRY_ISO3 = 'GRC';
public function __construct(
private readonly CartService $cart,
private readonly CheckoutService $checkout,
) {}
public function show(string $locale): View
{
$cart = $this->cart->current();
$lines = $cart ? $this->cart->activeLines($cart) : collect();
$storeCountry = $this->storeCountry();
$shippingOptions = collect();
if ($cart?->shippingAddress) {
// Nothing recreates the address row in this path — its own current
// value is the correct "previous" to carry forward if still valid.
$shippingOptions = $this->syncShipping($cart, $cart->shippingAddress->shipping_option);
// Cart's CachesProperties::refresh() explicitly nulls total/
// subTotal/shippingTotal/etc. back to their defaults — every
// Lunar call site pairs it with recalculate() for exactly that
// reason. Bare refresh() here was leaving $cart->total null on
// reload, which fed a 0 amount straight into the Stripe Element.
$cart->refresh()->recalculate();
}
return view('checkout::page', [
'cart' => $cart,
'lines' => $lines,
'billingAddress' => $cart?->billingAddress,
'shippingAddress' => $cart?->shippingAddress,
'shippingOptions' => $shippingOptions,
'paymentMethods' => $this->checkout->getPaymentMethods(),
'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true),
'storeCountry' => $storeCountry,
'countries' => $storeCountry
? collect()
: Country::orderBy('name')->get(['id', 'name']),
'regions' => $storeCountry
? State::where('country_id', $storeCountry->id)->orderBy('name')->get(['id', 'name'])
: collect(),
]);
}
public function saveAddress(string $locale, Request $request): JsonResponse
{
$storeCountry = $this->storeCountry();
$sameAsBilling = $request->boolean('same_as_billing');
// Only the fields shipping rates resolve against — if none of these
// changed (shopper edited their name, phone, email, …) there's no point
// re-quoting shipping or re-rendering the summary.
$addressBefore = $this->cart->current()?->shippingAddress;
$rateKeyBefore = $addressBefore?->only(['postcode', 'state', 'country_id']);
// setShippingAddress() below always deletes + recreates this row (see
// syncShipping()'s docblock) — capture what was selected NOW, before
// it's gone, so it can be carried forward onto the fresh row.
$previousOption = $addressBefore?->shipping_option;
$stateRule = $storeCountry
? ['nullable', 'string', Rule::exists((new State)->getTable(), 'name')->where('country_id', $storeCountry->id)]
: ['nullable', 'string', 'max:255'];
$countryRule = $storeCountry
? ['nullable']
: ['nullable', 'integer', 'exists:'.(new Country)->getTable().',id'];
// Lenient — only format checks. Anything that fails is simply left out
// of what gets persisted, and reported back for inline display.
$validator = Validator::make($request->all(), [
'contact_email' => ['nullable', 'email'],
'billing_first_name' => ['nullable', 'string', 'max:255'],
'billing_last_name' => ['nullable', 'string', 'max:255'],
'billing_company_name' => ['nullable', 'string', 'max:255'],
'billing_tax_identifier' => ['nullable', 'string', 'max:255'],
'billing_line_one' => ['nullable', 'string', 'max:255'],
'billing_line_two' => ['nullable', 'string', 'max:255'],
'billing_city' => ['nullable', 'string', 'max:255'],
'billing_state' => $stateRule,
'billing_postcode' => ['nullable', 'string', 'max:20'],
'billing_country_id' => $countryRule,
'billing_contact_phone' => ['nullable', 'string', 'max:50'],
'shipping_first_name' => ['nullable', 'string', 'max:255'],
'shipping_last_name' => ['nullable', 'string', 'max:255'],
'shipping_company_name' => ['nullable', 'string', 'max:255'],
'shipping_line_one' => ['nullable', 'string', 'max:255'],
'shipping_line_two' => ['nullable', 'string', 'max:255'],
'shipping_city' => ['nullable', 'string', 'max:255'],
'shipping_state' => $stateRule,
'shipping_postcode' => ['nullable', 'string', 'max:20'],
'shipping_country_id' => $countryRule,
'shipping_contact_phone' => ['nullable', 'string', 'max:50'],
'shipping_delivery_instructions' => ['nullable', 'string', 'max:1000'],
]);
$errors = $validator->errors()->toArray();
$data = $validator->valid();
$billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null);
$shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId);
$billing = [
'first_name' => $data['billing_first_name'] ?? null,
'last_name' => $data['billing_last_name'] ?? null,
'company_name' => $data['billing_company_name'] ?? null,
'tax_identifier' => $data['billing_tax_identifier'] ?? null,
'line_one' => $data['billing_line_one'] ?? null,
'line_two' => $data['billing_line_two'] ?? null,
'city' => $data['billing_city'] ?? null,
'state' => $data['billing_state'] ?? null,
'postcode' => $data['billing_postcode'] ?? null,
'country_id' => $billingCountryId,
'contact_email' => $data['contact_email'] ?? null,
'contact_phone' => $data['billing_contact_phone'] ?? null,
];
$shipping = $sameAsBilling
? [...$billing, 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null]
: [
'first_name' => $data['shipping_first_name'] ?? null,
'last_name' => $data['shipping_last_name'] ?? null,
'company_name' => $data['shipping_company_name'] ?? null,
'line_one' => $data['shipping_line_one'] ?? null,
'line_two' => $data['shipping_line_two'] ?? null,
'city' => $data['shipping_city'] ?? null,
'state' => $data['shipping_state'] ?? null,
'postcode' => $data['shipping_postcode'] ?? null,
'country_id' => $shippingCountryId,
'contact_email' => $data['contact_email'] ?? null,
'contact_phone' => $data['shipping_contact_phone'] ?? null,
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
];
$this->checkout->setBillingAddress($billing);
$cart = $this->checkout->setShippingAddress($shipping);
$cart->meta = [...($cart->meta?->toArray() ?? []), 'ship_to_billing' => $sameAsBilling];
$cart->save();
// Abandoned-cart-recovery opt-in — boboko-core owns the record (bool +
// timestamp + policy version on Cart::meta, RecoveryConsentSet event).
// Deliberately its own scope, not merged with any future newsletter opt-in.
$this->checkout->setRecoveryConsent($request->boolean('recovery_consent'));
$rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']);
$rateChanged = $rateKeyAfter != $rateKeyBefore;
// setShippingAddress() above always deletes and recreates the
// CartAddress row (Lunar's AddAddress action), which drops whatever
// shipping_option was previously selected — regardless of whether the
// rate-determining fields actually changed. So this always has to run
// to restore/re-validate it, even on a save that only touched e.g. the
// phone number. Only the fragment RE-RENDER is skippable when nothing
// rate-relevant moved — the re-select itself is not optional.
$options = $this->syncShipping($cart, $previousOption);
if (! $rateChanged) {
return $this->fragments($cart, null, $errors);
}
return $this->fragments($cart, $options, $errors);
}
public function selectShippingOption(string $locale, Request $request): JsonResponse
{
$identifier = (string) $request->input('shipping_option');
try {
$this->checkout->selectShippingOption($identifier);
} catch (InvalidShippingOptionException) {
// Re-render with whatever is currently valid; no hard error surfaced.
}
$cart = $this->cart->current();
$options = $cart?->shippingAddress
? $this->checkout->getShippingOptions()
: collect();
return $this->fragments($cart, $options);
}
/**
* A plain, own-hosted stand-in for Box Now's Destination Map widget —
* that widget only talks to their Production environment (see their
* Partner API manual §4.1), which is useless while developing against
* Stage credentials. Same underlying data (GET /destinations), no map.
*/
public function boxNowLockers(string $locale, BoxNowClient $boxNow): JsonResponse
{
$lockers = collect($boxNow->destinations())
// Drops entries with a blank `name` (e.g. id 8288, "Virtual
// Locker" in Sudan at lat 12.3/lng 25.3) — a real, in-range
// coordinate, but sandbox test fixture noise rather than an
// actual pickup point, and it alone was enough to make
// fitBounds() below zoom the map out to the whole Balkans/
// Middle East to fit every marker's cluster in Greece.
->filter(fn (array $destination) => filled($destination['name'] ?? null))
->map(fn (array $destination) => [
'id' => $destination['id'],
'name' => $destination['name'] ?? $destination['title'] ?? $destination['id'],
'addressLine1' => $destination['addressLine1'] ?? null,
'addressLine2' => $destination['addressLine2'] ?? null,
'postalCode' => $destination['postalCode'] ?? null,
'country' => $destination['country'] ?? null,
'note' => $destination['note'] ?? null,
'image' => $destination['image'] ?? null,
'lat' => isset($destination['lat']) ? (float) $destination['lat'] : null,
'lng' => isset($destination['lng']) ? (float) $destination['lng'] : null,
])
// Box Now's own Stage/sandbox data has at least one malformed
// entry observed in practice (locker id 47: lat/lng as huge
// integers with the decimal point apparently dropped, e.g.
// 96065874308606 instead of ~37.96) — a single such point blows
// out L.featureGroup().getBounds() on the frontend, zooming the
// map out to near-nothing with every real marker imperceptible
// at that scale. Valid latitude/longitude ranges are absolute,
// not guesswork, so filtering on them is safe regardless of
// what BoxNow's API does or doesn't fix upstream.
->filter(fn (array $locker) => $locker['lat'] !== null && $locker['lng'] !== null
&& abs($locker['lat']) <= 90 && abs($locker['lng']) <= 180)
->values();
return response()->json(['lockers' => $lockers]);
}
/**
* Persists the shopper's chosen locker (radio/select change, same
* autosave shape as selectShippingOption()) via
* CheckoutService::selectBoxNowLocker() onto the cart's shipping
* address meta.
*/
public function selectBoxNowLocker(string $locale, Request $request): JsonResponse
{
$locationId = (string) $request->input('locker_id');
if ($locationId === '') {
return response()->json(['errors' => ['locker_id' => __('checkout.page.box_now_locker_required')]], 422);
}
try {
$this->checkout->selectBoxNowLocker([
'locationId' => $locationId,
'name' => (string) $request->input('locker_name'),
'addressLine1' => (string) $request->input('locker_address'),
]);
} catch (NoShippingAddressException) {
return response()->json(['errors' => ['locker_id' => __('checkout.page.box_now_locker_required')]], 422);
}
return response()->json(['ok' => true]);
}
/**
* Autosave-select a payment method (radio change). Persists it via
* CheckoutService (which also records it on Cart::meta and re-snapshots
* the fingerprint) so ApplyCashOnDeliveryFee etc. show in the summary.
*/
public function selectPaymentMethod(string $locale, Request $request): JsonResponse
{
$type = (string) $request->input('payment_type');
try {
$this->checkout->selectPaymentMethod($type);
} catch (UnknownPaymentTypeException) {
// Radio value out of sync with what's offered — ignore, the summary
// just won't reflect a method fee. place-order re-checks properly.
}
return response()->json([
'summaryHtml' => view('checkout::partials.cart-body')->render(),
]);
}
/**
* The real submit — the hard gate. Re-selects the payment method (fresh
* fingerprint), then hands off to CheckoutService::initiatePayment(), which
* creates the draft order, records terms acceptance, and charges the driver.
* Returns JSON the bbk-payment controller routes on:
* { redirect } — placed, go to confirmation
* { status: 'pending', clientSecret }— 3-D Secure; client does handleNextAction then polls
* { status: 'failed', message } — declined
* { status: 'invalid'|'stale', ... } — cart incomplete / changed since selection
*/
public function placeOrder(string $locale, Request $request): JsonResponse
{
if (! $request->boolean('terms_accepted')) {
return response()->json(['error' => __('checkout.page.terms_required')], 422);
}
try {
$this->checkout->selectPaymentMethod((string) $request->input('payment_type'));
} catch (UnknownPaymentTypeException) {
return response()->json(['error' => __('checkout.page.choose_payment_method')], 422);
}
$cart = $this->cart->current();
// The one incomplete-cart case worth a specific message + pointing the
// shopper at the right section: a region resolving 2+ methods needs an
// explicit pick (no auto-select), easy to miss since nothing else on
// the page demands it. Everything else CartException catches below.
if ($cart?->shippingAddress && ! $cart->shippingAddress->shipping_option) {
return response()->json([
'status' => 'invalid',
'message' => __('checkout.page.shipping_method_required'),
'field' => 'shipping_option',
], 422);
}
$fingerprint = (string) ($cart?->meta['checkout_fingerprint'] ?? '');
$data = $request->filled('payment_method')
? ['payment_method' => (string) $request->input('payment_method')]
: [];
try {
$result = $this->checkout->initiatePayment(
$fingerprint,
termsAccepted: true,
policyVersion: (string) config('legal.terms_version'),
data: $data,
);
} catch (FingerprintMismatchException) {
return response()->json(['status' => 'stale', 'message' => __('checkout.page.payment_cart_changed')], 409);
} catch (CartException $e) {
return response()->json([
'status' => 'invalid',
'message' => __('checkout.page.payment_incomplete_details'),
'errors' => collect($e->errors()->toArray())->map(fn ($m) => is_array($m) ? ($m[0] ?? null) : $m)->all(),
], 422);
} catch (TermsNotAcceptedException) {
return response()->json(['error' => __('checkout.page.terms_required')], 422);
}
return match ($result->status) {
PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale),
PaymentResultStatus::Pending => response()->json([
'status' => 'pending',
'clientSecret' => $result->continuation?->value,
]),
PaymentResultStatus::Failed => response()->json([
'status' => 'failed',
'message' => $result->failureReason ?: __('checkout.page.payment_failed'),
'retriable' => $result->retriable,
], 422),
};
}
/**
* Poll target for the 3-D Secure path: has the webhook placed the order yet?
* boboko-core's StripeWebhookController -> handleCallback -> PaymentCaptured
* -> ApplyResolvedPaymentStatus sets placed_at.
*/
public function orderStatus(string $locale): JsonResponse
{
$order = $this->placedOrder();
if (! $order) {
return response()->json(['placed' => false]);
}
session(['checkout.order_id' => $order->id]);
CartSession::forget();
return response()->json(['placed' => true, 'redirect' => route('checkout.confirmation', $locale)]);
}
public function confirmation(string $locale): View|RedirectResponse
{
$orderId = session('checkout.order_id');
$order = $orderId
? Order::with(['lines', 'shippingAddress', 'billingAddress'])->find($orderId)
: null;
if (! $order) {
return redirect()->route('products', $locale);
}
return view('checkout::confirmation', ['order' => $order]);
}
private function orderPlacedResponse(string $locale): JsonResponse
{
if ($order = $this->placedOrder()) {
session(['checkout.order_id' => $order->id]);
}
CartSession::forget();
return response()->json(['redirect' => route('checkout.confirmation', $locale)]);
}
private function placedOrder(): ?Order
{
return $this->cart->current()
?->orders()
->whereNotNull('placed_at')
->latest('placed_at')
->first();
}
/**
* Re-resolve shipping options for the cart's current address and keep the
* selection sane: auto-select when exactly one resolves, or carry a
* previous pick forward when it's still among the resolved options.
*
* $previousOption must be captured by the CALLER before setShippingAddress()
* runs — Lunar's AddAddress action always deletes and recreates the
* CartAddress row on every save (see saveAddress()), so by the time this
* runs, $address->shipping_option is unconditionally null regardless of
* what was selected a moment ago. There is nothing meaningful left to read
* off $address itself; $previousOption is the only source of truth for
* "what was chosen before this save wiped the row." show() passes the
* address's own (not-just-wiped) current value, since nothing recreated
* anything in that path.
*
* Always (re-)applies the resolved target via selectShippingOption() rather
* than comparing against the (always-blank, post-recreation) current value
* — the fresh row needs the write regardless of whether the decision
* "which option" actually changed.
*
* @return Collection<int, \Lunar\DataTypes\ShippingOption>
*/
private function syncShipping(Cart $cart, ?string $previousOption): Collection
{
if (! $cart->shippingAddress) {
return collect();
}
$options = $this->checkout->getShippingOptions();
$target = match (true) {
$options->count() === 1 => $options->first()->identifier,
$previousOption !== null && $options->contains(fn ($option) => $option->identifier === $previousOption) => $previousOption,
default => null,
};
if ($target !== null) {
try {
$this->checkout->selectShippingOption($target);
} catch (InvalidShippingOptionException) {
// $target came from $options itself — shouldn't happen, stay defensive
}
}
return $options;
}
/**
* $options === null means "nothing money-relevant changed" — acknowledge the
* save (and any field errors) without re-rendering the shipping options or
* the order summary, so a plain name/phone edit is a cheap round-trip.
*/
private function fragments(?Cart $cart, ?Collection $options, array $errors = []): JsonResponse
{
return response()->json([
'errors' => collect($errors)
->map(fn ($messages) => is_array($messages) ? ($messages[0] ?? null) : $messages)
->all(),
'shippingOptionsHtml' => $options === null ? null : view('checkout::partials.shipping-options', [
'shippingAddress' => $cart?->shippingAddress,
'shippingOptions' => $options,
])->render(),
// Composer (CheckoutModuleServiceProvider) fills $cart / $lines.
'summaryHtml' => $options === null ? null : view('checkout::partials.cart-body')->render(),
]);
}
private function storeCountry(): ?Country
{
if (self::STORE_COUNTRY_ISO3 === null) {
return null;
}
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->first();
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Http\Controllers;
class ContactController extends Controller
{
public function index(string $locale)
{
return view('contact');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers;
use App\Catalog\ProductCard;
use App\Models\StoicPage;
use Modules\Core\Catalog\Services\ProductService;
class HomeController extends Controller
{
public function __construct(private readonly ProductService $products) {}
public function index(string $locale)
{
$page = StoicPage::firstWhere('slug', 'home');
if (! $page) {
abort(404);
}
$products = collect($this->products->random(13))
->map(fn (array $product) => ProductCard::fromIndexed($product));
return view('home', [
'page' => $page,
'heroProducts' => $products->take(5)->values(),
'classicsProducts' => $products->slice(5)->values(),
]);
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers;
use App\Models\StoicPage;
class LegalPageController extends Controller
{
public function terms(string $locale)
{
return $this->show('terms-and-conditions');
}
public function shippingReturns(string $locale)
{
return $this->show('shipping-returns');
}
public function privacy(string $locale)
{
return $this->show('privacy-policy');
}
public function cookies(string $locale)
{
return $this->show('cookies-policy');
}
private function show(string $slug)
{
$page = StoicPage::firstWhere('slug', $slug);
if (! $page) {
abort(404);
}
return view('legal', ['page' => $page]);
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers;
use App\Catalog\ProductListing;
use App\Catalog\ProductListingPage;
use Illuminate\Http\Response;
use Modules\Core\Catalog\Services\ProductService;
class ProductController extends Controller
{
public function __construct(
private readonly ProductService $products,
private readonly ProductListingPage $listingPage,
) {}
/**
* All products — the category page without a collection scope. Filters,
* sort, pagination and the shared listing body all work identically.
*/
public function index(string $locale)
{
$listing = ProductListing::fromRequest(request());
$data = $this->listingPage->build(
$listing,
fn (array $query) => route('products', $query),
);
return view('products.index', $data);
}
public function show(string $locale, int $id)
{
$product = $this->products->getById($id);
abort_if($product === null, Response::HTTP_NOT_FOUND);
$collection = $product['collections'][0] ?? null;
$variantsData = $this->products->variantSummaries($product);
$firstVariant = $product['variants'][0] ?? null;
$option = $firstVariant['options'][0]['option'] ?? null;
return view('product.show', [
'collection' => $collection,
'product' => $product,
'option' => $option,
'variantsData' => $variantsData,
]);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use App\Catalog\ProductListing;
use App\Catalog\ProductListingPage;
class SearchController extends Controller
{
public function __construct(private readonly ProductListingPage $listingPage) {}
public function show(string $locale)
{
$query = trim((string) request()->query('q', ''));
if ($query === '') {
return redirect()->route('products');
}
$listing = ProductListing::fromRequest(request());
$data = $this->listingPage->build(
$listing,
fn (array $overrides) => route('search', ['q' => $query] + $overrides),
collectionId: null,
query: $query,
);
return view('search.index', [...$data, 'query' => $query]);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use JacobJoergensen\LaravelPaper\Attributes\ContentPath;
use JacobJoergensen\LaravelPaper\Attributes\Driver;
use JacobJoergensen\LaravelPaper\Attributes\Timestamps;
use JacobJoergensen\LaravelPaper\Paper;
#[Driver("markdown")]
#[ContentPath("resources/stoic/content/pages")]
#[Timestamps]
class StoicPage extends Model
{
use Paper;
}
-4
View File
@@ -18,10 +18,6 @@ class User extends Authenticatable implements LunarUserInterface
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
protected $dispatchesEvents = [
'created' => UserCreated::class,
];
/**
* The attributes that are mass assignable.
*
+16
View File
@@ -6,9 +6,14 @@
use App\Models\Staff;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Illuminate\View\View as ViewInstance;
use Lunar\Facades\ModelManifest;
use Lunar\Facades\Telemetry;
use Modules\Core\Catalog\DTOs\CollectionFilters;
use Modules\Core\Catalog\Enums\CollectionSort;
use Modules\Core\Catalog\Services\CollectionService;
class AppServiceProvider extends ServiceProvider
{
@@ -16,6 +21,17 @@ public function boot(): void
{
Telemetry::optOut();
// header.blade.php's category dropdown — root collections only, resolved
// per-request so the composer runs after `locale` middleware has already
// set App::getLocale(), which CollectionService's name resolution depends on.
View::composer('components.header', function (ViewInstance $view) {
$view->with('categories', app(CollectionService::class)->list(
filters: new CollectionFilters(rootOnly: true),
perPage: 100,
sort: CollectionSort::Position,
)->items());
});
if ($this->app->environment('production')) {
URL::forceScheme('https');
}
@@ -0,0 +1,53 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Illuminate\View\View as ViewInstance;
use Modules\Core\Cart\Services\CartService;
/**
* The seam for the (currently in-repo) cart + checkout module.
*
* Everything the module needs to boot inside a host app lives here: its view /
* component namespaces, its routes, and the composer that feeds the
* always-present cart drawer. Strings (__('checkout.cart.*')) are NOT wired up
* here — same as storefront.* elsewhere in this app, they resolve through
* Lunar's DB-backed translation UI (spatie/laravel-translation-loader), added
* manually there rather than shipped as lang/ files. The module's own files
* (resources/views/checkout/**, resources/js/checkout/**, resources/css/checkout.css,
* routes/checkout.php, app/Http/Controllers/Checkout/**) contain nothing
* 3dealer-specific.
*
* When the module is extracted to boboko-core, this class is deleted and its
* body becomes the package's own ServiceProvider. The only other host wiring
* is the one registerCheckout() call in resources/js/app.js and the two lines
* in the layout (the checkout.css @vite entry and @include('checkout::drawer')).
*/
class CheckoutModuleServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadViewsFrom(resource_path('views/checkout'), 'checkout');
Blade::anonymousComponentNamespace('checkout::components', 'checkout');
Route::middleware('web')->group(base_path('routes/checkout.php'));
// The drawer is rendered on every page (from the layout) and its body
// partial is re-rendered on every cart mutation — both need the current
// cart without a controller in the loop.
View::composer(
['checkout::drawer', 'checkout::partials.cart-body'],
function (ViewInstance $view) {
$service = app(CartService::class);
$cart = $service->current();
$view->with('cart', $cart);
$view->with('lines', $cart ? $service->activeLines($cart) : collect());
},
);
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace App\Services;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Yaml\Yaml;
class Stoic
{
public static array $config = [];
public static array $presets = [];
public static string $thumbs_path;
private static function loadConfig(): void
{
if (!empty(static::$config)) {
return;
}
$config = Yaml::parseFile(resource_path("stoic/stoic_config.yml"));
static::$config = $config;
static::$thumbs_path = $config["thumbs_path"];
static::$presets = $config["presets"];
}
public static function presets(): array
{
static::loadConfig();
return static::$presets;
}
// Returns the public URL for a stoic image at a given preset.
public static function image(string $filename, string $preset): string
{
static::loadConfig();
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($ext === "svg") {
$rel = static::thumbsRelPath() . "images/" . $filename;
return Storage::disk("public")->url($rel);
}
$webp = substr($filename, 0, -strlen($ext)) . "webp";
$rel = static::thumbsRelPath() . "presets/" . $preset . "/" . $webp;
return Storage::disk("public")->url($rel);
}
// Generates the full srcset string for all configured presets.
public static function srcset(string $filename): string
{
static::loadConfig();
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($ext === "svg") {
return "";
}
return collect(static::$presets)
->map(
fn($p) => static::image($filename, $p["code"]) .
" " .
$p["width"] .
"w",
)
->implode(", ");
}
// Returns [width, height] for a given preset from the sidecar JSON.
public static function dimensions(string $filename, string $preset): array
{
static::loadConfig();
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($ext === "svg") {
return [null, null];
}
$rel = static::thumbsRelPath() . "images/" . $filename . ".json";
$path = Storage::disk("public")->path($rel);
$meta = json_decode(@file_get_contents($path), true);
$p = $meta["presets"][$preset] ?? null;
if (!$p) {
return [null, null];
}
return [(int) $p["w"], (int) $p["h"]];
}
// Path of the thumbs directory relative to the public storage disk root.
// Uses a fixed marker so it works regardless of the app's base path (local vs Docker).
private static function thumbsRelPath(): string
{
$marker = "/storage/app/public/";
$pos = strpos(static::$thumbs_path, $marker);
if ($pos !== false) {
return substr(static::$thumbs_path, $pos + strlen($marker));
}
return ltrim(static::$thumbs_path, "/");
}
public static function token(string $email): string
{
$ttl = 60;
$payload = base64_encode(
json_encode([
"email" => $email,
"exp" => time() + $ttl,
]),
);
$sig = hash_hmac(
"sha256",
$payload,
config("services.stoic.sso_secret"),
);
$token = "t=" . urlencode($payload) . "&s=" . $sig;
return $token;
}
public static function hasMany(
string $relation,
array $slugs,
string $prefix,
) {
$slugs = array_map(
fn($i) => str_replace($prefix . "/", "", $i),
$slugs,
);
$entries = $relation::whereIn("slug", $slugs)->get();
return collect(array_map(
fn($i) => $entries->firstWhere("slug", $i),
$slugs
));
}
public static function hasOne(
string $relation,
string $slug,
string $prefix,
) {
$slug = str_replace($prefix . "/", "", $slug);
return $relation::firstWhere("slug", $slug);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Support;
class Price
{
/**
* Format a price the Greek way: comma as the decimal separator, dot as
* the thousands separator, and no decimals shown unless the amount
* actually needs them (19.00 -> "19", 19.50 -> "19,5", 19.55 -> "19,55").
*/
public static function format(float|int|string|null $amount, string $currency = '€'): ?string
{
if ($amount === null) {
return null;
}
$amount = round((float) $amount, 2);
$cents = (int) round($amount * 100);
$decimals = match (true) {
$cents % 100 === 0 => 0,
$cents % 10 === 0 => 1,
default => 2,
};
return $currency.number_format($amount, $decimals, ',', '.');
}
}
+2
View File
@@ -1,9 +1,11 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\CheckoutModuleServiceProvider;
use App\Providers\PanelServiceProvider;
return [
AppServiceProvider::class,
PanelServiceProvider::class,
CheckoutModuleServiceProvider::class,
];
+1
View File
@@ -11,6 +11,7 @@
"require": {
"artesaos/seotools": "^1.4",
"boboko/core": "0.*",
"jacobjoergensen/laravel-paper": "^1.12",
"open-telemetry/exporter-otlp": "^1.4",
"open-telemetry/opentelemetry-auto-laravel": "^1.7",
"open-telemetry/sdk": "^1.14",
Generated
+2434 -1908
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -78,7 +78,7 @@
|
*/
'locale' => env('APP_LOCALE', 'en'),
'locale' => env('APP_LOCALE', 'el'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
+1
View File
@@ -54,6 +54,7 @@
*/
'cart_lines' => [
Lunar\Pipelines\CartLine\GetUnitPrice::class,
Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice::class,
],
],
+1 -1
View File
@@ -44,5 +44,5 @@
| Determines whether the cart sholud be soft deleted when the user logs out.
|
*/
'delete_on_forget' => true,
'delete_on_forget' => false,
];
+94 -7
View File
@@ -58,11 +58,23 @@
| make sense for the store you're building.
|
*/
'draft_status' => 'awaiting-payment',
'draft_status' => 'awaiting_payment',
// Two sequences — carrier and store-pickup — see Modules\Core\Order\
// Services\OrderStatusFlow, the single source of truth for which
// transitions are actually offered from a given status. Payment
// timing (prepaid vs. cash-on-delivery) does not affect this
// sequence at all — see Order::paid/paid_at instead, an entirely
// independent field with no status value of its own.
//
// 'favourite' controls whether a status gets its own tab in the order
// list topbar (Lunar\Admin\...\ListOrders::getDefaultTabs()) — set
// only on the main-journey statuses so the topbar isn't cluttered
// with every branch/exception value; every status (favourite or not)
// remains reachable via the table's own status filter/search.
'statuses' => [
'awaiting-payment' => [
'awaiting_payment' => [
'label' => 'Awaiting Payment',
'color' => '#848a8c',
'mailers' => [],
@@ -70,17 +82,25 @@
'favourite' => true,
],
'payment-offline' => [
'label' => 'Payment Offline',
'processing' => [
'label' => 'Processing',
'color' => '#0A81D7',
'mailers' => [],
'notifications' => [],
'favourite' => true,
],
'payment-received' => [
'label' => 'Payment Received',
'color' => '#6a67ce',
'ready_for_dispatch' => [
'label' => 'Ready for Dispatch',
'color' => '#c98a2e',
'mailers' => [],
'notifications' => [],
'favourite' => true,
],
'ready_for_pickup' => [
'label' => 'Ready for Pickup',
'color' => '#c98a2e',
'mailers' => [],
'notifications' => [],
'favourite' => true,
@@ -88,11 +108,78 @@
'dispatched' => [
'label' => 'Dispatched',
'color' => '#0A81D7',
'mailers' => [],
'notifications' => [],
'favourite' => true,
],
'delivery_failed' => [
'label' => 'Delivery Failed',
'color' => '#c0392b',
'mailers' => [],
'notifications' => [],
'favourite' => false,
],
'picked_up' => [
'label' => 'Picked Up',
'color' => '#3f7a5c',
'mailers' => [],
'notifications' => [],
'favourite' => true,
],
// The parcel arriving and the return window opening are the same
// moment — one status value covers both.
'delivered' => [
'label' => 'Delivered',
'color' => '#c98a2e',
'mailers' => [],
'notifications' => [],
'favourite' => true,
],
'completed' => [
'label' => 'Completed',
'color' => '#3f7a5c',
'mailers' => [],
'notifications' => [],
'favourite' => true,
],
'return_requested' => [
'label' => 'Return Requested',
'color' => '#c98a2e',
'mailers' => [],
'notifications' => [],
'favourite' => false,
],
'returned' => [
'label' => 'Returned',
'color' => '#c0392b',
'mailers' => [],
'notifications' => [],
'favourite' => false,
],
'partially_refunded' => [
'label' => 'Partially Refunded',
'color' => '#c98a2e',
'mailers' => [],
'notifications' => [],
'favourite' => false,
],
'refunded' => [
'label' => 'Refunded',
'color' => '#c0392b',
'mailers' => [],
'notifications' => [],
'favourite' => false,
],
],
/*
+1 -1
View File
@@ -46,6 +46,6 @@
| to include in the count below.
|
*/
'order_count_statuses' => ['payment-received'],
'order_count_statuses' => ['processing'],
];
+3
View File
@@ -1,5 +1,7 @@
<?php
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
return [
'default' => env('PAYMENTS_TYPE', 'cash-in-hand'),
@@ -7,6 +9,7 @@
'types' => [
'cash-in-hand' => [
'driver' => 'offline',
'payment_driver' => OfflinePaymentDriver::class,
'authorized' => 'payment-offline',
],
],
+2 -2
View File
@@ -46,10 +46,10 @@
'indexers' => [
Lunar\Models\Brand::class => Lunar\Search\BrandIndexer::class,
Lunar\Models\Collection::class => Lunar\Search\CollectionIndexer::class,
Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class,
Lunar\Models\Customer::class => Lunar\Search\CustomerIndexer::class,
Lunar\Models\Order::class => Lunar\Search\OrderIndexer::class,
Lunar\Models\Product::class => Lunar\Search\ProductIndexer::class,
Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class,
],
+210
View File
@@ -0,0 +1,210 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Search Engine
|--------------------------------------------------------------------------
|
| This option controls the default search connection that gets used while
| using Laravel Scout. This connection is used when syncing all models
| to the search service. You should adjust this based on your needs.
|
| Supported: "algolia", "meilisearch", "typesense",
| "database", "collection", "null"
|
*/
'driver' => env('SCOUT_DRIVER', 'collection'),
/*
|--------------------------------------------------------------------------
| Index Prefix
|--------------------------------------------------------------------------
|
| Here you may specify a prefix that will be applied to all search index
| names used by Scout. This prefix may be useful if you have multiple
| "tenants" or applications sharing the same search infrastructure.
|
*/
'prefix' => env('SCOUT_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Queue Data Syncing
|--------------------------------------------------------------------------
|
| This option allows you to control if the operations that sync your data
| with your search engines are queued. When this is set to "true" then
| all automatic data syncing will get queued for better performance.
|
*/
'queue' => env('SCOUT_QUEUE', false),
/*
|--------------------------------------------------------------------------
| Database Transactions
|--------------------------------------------------------------------------
|
| This configuration option determines if your data will only be synced
| with your search indexes after every open database transaction has
| been committed, thus preventing any discarded data from syncing.
|
*/
'after_commit' => false,
/*
|--------------------------------------------------------------------------
| Chunk Sizes
|--------------------------------------------------------------------------
|
| These options allow you to control the maximum chunk size when you are
| mass importing data into the search engine. This allows you to fine
| tune each of these chunk sizes based on the power of the servers.
|
*/
'chunk' => [
'searchable' => 500,
'unsearchable' => 500,
],
/*
|--------------------------------------------------------------------------
| Soft Deletes
|--------------------------------------------------------------------------
|
| This option allows to control whether to keep soft deleted records in
| the search indexes. Maintaining soft deleted records can be useful
| if your application still needs to search for the records later.
|
*/
'soft_delete' => false,
/*
|--------------------------------------------------------------------------
| Identify User
|--------------------------------------------------------------------------
|
| This option allows you to control whether to notify the search engine
| of the user performing the search. This is sometimes useful if the
| engine supports any analytics based on this application's users.
|
| Supported engines: "algolia"
|
*/
'identify' => env('SCOUT_IDENTIFY', false),
/*
|--------------------------------------------------------------------------
| Algolia Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Algolia settings. Algolia is a cloud hosted
| search engine which works great with Scout out of the box. Just plug
| in your application ID and admin API key to get started searching.
|
*/
'algolia' => [
'id' => env('ALGOLIA_APP_ID', ''),
'secret' => env('ALGOLIA_SECRET', ''),
'index-settings' => [
// 'users' => [
// 'searchableAttributes' => ['id', 'name', 'email'],
// 'attributesForFaceting'=> ['filterOnly(email)'],
// ],
],
],
/*
|--------------------------------------------------------------------------
| Meilisearch Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Meilisearch settings. Meilisearch is an open
| source search engine with minimal configuration. Below, you can state
| the host and key information for your own Meilisearch installation.
|
| See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options
|
*/
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
// 'users' => [
// 'filterableAttributes'=> ['id', 'name', 'email'],
// ],
],
],
/*
|--------------------------------------------------------------------------
| Typesense Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Typesense settings. Typesense is an open
| source search engine using minimal configuration. Below, you will
| state the host, key, and schema configuration for the instance.
|
*/
'typesense' => [
'client-settings' => [
'api_key' => env('TYPESENSE_API_KEY', 'xyz'),
'nodes' => [
[
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
],
'nearest_node' => [
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2),
'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30),
'num_retries' => env('TYPESENSE_NUM_RETRIES', 3),
'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1),
],
// 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000),
'model-settings' => [
// User::class => [
// 'collection-schema' => [
// 'fields' => [
// [
// 'name' => 'id',
// 'type' => 'string',
// ],
// [
// 'name' => 'name',
// 'type' => 'string',
// ],
// [
// 'name' => 'created_at',
// 'type' => 'int64',
// ],
// ],
// 'default_sorting_field' => 'created_at',
// ],
// 'search-parameters' => [
// 'query_by' => 'name'
// ],
// ],
],
'import_action' => env('TYPESENSE_IMPORT_ACTION', 'upsert'),
],
];
+18
View File
@@ -35,4 +35,22 @@
],
],
'stoic' => [
'sso_secret' => env('STOIC_SSO_SECRET'),
'host' => env('STOIC_HOST'),
],
// Keys read by lunarphp/stripe + Modules\Core\Payment\Drivers\StripePaymentDriver.
// `key` is the SECRET key (this ecosystem's convention — StripeManager calls
// Stripe::setApiKey(config('services.stripe.key'))); `public_key` is the
// publishable key for Stripe.js; `webhooks.lunar` is the signing secret the
// webhook route verifies against.
'stripe' => [
'key' => env('STRIPE_SECRET'),
'public_key' => env('STRIPE_PUBLIC_KEY'),
'webhooks' => [
'lunar' => env('STRIPE_WEBHOOK_SECRET'),
],
],
];
@@ -0,0 +1,211 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Core\Localization\Services\TranslationService;
use Spatie\TranslationLoader\LanguageLine;
/**
* Default `checkout` translation lines for the cart drawer and the checkout
* page (see the checkout module under resources/views/checkout).
*
* Additive and idempotent: a group/key that already exists is left untouched,
* so anything edited in the Filament Language Lines UI wins on a re-run. Runs
* explicitly — `php artisan db:seed --class=CheckoutTranslationsSeeder` — it is
* not wired into DatabaseSeeder.
*
* Greek copy uses the project's informal register (εσύ/σου). When the checkout
* module moves to boboko-core this becomes the module's own default-strings
* seeder.
*/
class CheckoutTranslationsSeeder extends Seeder
{
public function run(): void
{
$translations = app(TranslationService::class);
foreach ($this->lines() as $key => [$en, $el]) {
$exists = LanguageLine::query()
->where('group', 'checkout')
->where('key', $key)
->exists();
if ($exists) {
$this->command?->warn("checkout.{$key} already exists — skipped");
continue;
}
$translations->create('checkout', $key, ['en' => $en, 'el' => $el]);
$this->command?->info("checkout.{$key} added");
}
}
/**
* key => [English, Greek].
*
* @return array<string, array{0: string, 1: string}>
*/
private function lines(): array
{
return [
// ── Cart drawer + order summary ──────────────────────────────
'cart.title' => ['Your cart', 'Το καλάθι σου'],
'cart.close' => ['Close', 'Κλείσιμο'],
'cart.empty' => ['Your cart is empty', 'Το καλάθι σου είναι άδειο'],
'cart.quantity' => ['Quantity', 'Ποσότητα'],
'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'],
'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'],
'cart.remove' => ['Remove', 'Αφαίρεση'],
'cart.subtotal' => ['Subtotal', 'Υποσύνολο'],
'cart.discount' => ['Discount', 'Έκπτωση'],
'cart.shipping' => ['Shipping', 'Μεταφορικά'],
'cart.shipping_pending' => ['Not selected yet', 'Δεν έχει επιλεγεί ακόμη'],
'cart.tax' => ['VAT', 'ΦΠΑ'],
'cart.total' => ['Total', 'Σύνολο'],
'cart.checkout' => ['Checkout', 'Ολοκλήρωση παραγγελίας'],
'cart.coupon_label' => ['Coupon code', 'Κωδικός κουπονιού'],
'cart.coupon_placeholder' => ['Coupon code', 'Κωδικός κουπονιού'],
'cart.coupon_apply' => ['Apply', 'Εφαρμογή'],
'cart.coupon_remove' => ['Remove', 'Αφαίρεση'],
'cart.coupon_invalid' => ["That coupon code isn't valid", 'Ο κωδικός κουπονιού δεν είναι έγκυρος'],
// ── Checkout page ────────────────────────────────────────────
'page.title' => ['Checkout', 'Ολοκλήρωση παραγγελίας'],
'page.contact_heading' => ['Contact', 'Στοιχεία επικοινωνίας'],
'page.guest_tab' => ['Guest', 'Ως επισκέπτης'],
'page.login_tab' => ['Log in', 'Σύνδεση'],
'page.email_label' => ['Email', 'Email'],
'page.recovery_consent' => [
"Email me a reminder if I don't finish my order",
'Στείλε μου μια υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου',
],
'page.login_email_label' => ['Email', 'Email'],
'page.send_code' => ['Send code', 'Αποστολή κωδικού'],
'page.login_coming_soon' => [
'Login is coming soon — continue as a guest for now.',
'Η σύνδεση θα είναι διαθέσιμη σύντομα — προς το παρόν συνέχισε ως επισκέπτης.',
],
'page.billing_heading' => ['Billing information', 'Στοιχεία τιμολόγησης'],
'page.shipping_heading' => ['Shipping information', 'Στοιχεία αποστολής'],
'page.same_as_billing' => ['Same as billing address', 'Ίδια με τη διεύθυνση τιμολόγησης'],
'page.first_name' => ['First name', 'Όνομα'],
'page.last_name' => ['Last name', 'Επώνυμο'],
'page.company_name' => ['Company name', 'Επωνυμία εταιρείας'],
'page.tax_identifier' => ['Tax ID', 'ΑΦΜ'],
'page.address_line_one' => ['Address', 'Διεύθυνση'],
'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'],
'page.city' => ['City', 'Πόλη'],
'page.state' => ['Region / Prefecture', 'Νομός / Περιοχή'],
'page.state_placeholder' => ['Select a region', 'Επίλεξε νομό'],
'page.postcode' => ['Postcode', 'Ταχυδρομικός κώδικας'],
'page.country' => ['Country', 'Χώρα'],
'page.country_placeholder' => ['Select a country', 'Επίλεξε χώρα'],
'page.phone' => ['Phone', 'Τηλέφωνο'],
'page.delivery_instructions' => ['Delivery notes', 'Σχόλια για την παράδοση'],
'page.save_address' => ['Save and continue', 'Αποθήκευση και συνέχεια'],
'page.saving' => ['Saving…', 'Αποθήκευση…'],
'page.saved' => ['Saved', 'Αποθηκεύτηκε'],
'page.save_error' => ["Couldn't save — check your connection", 'Δεν αποθηκεύτηκε — έλεγξε τη σύνδεσή σου'],
'page.shipping_method_heading' => ['Shipping method', 'Τρόπος αποστολής'],
'page.shipping_method_empty' => [
'Add your shipping address to see delivery options.',
'Συμπλήρωσε τη διεύθυνση αποστολής για να δεις τις διαθέσιμες επιλογές.',
],
'page.shipping_method_none' => [
'No delivery options are available for this address.',
'Δεν υπάρχουν διαθέσιμες επιλογές αποστολής για αυτή τη διεύθυνση.',
],
'page.box_now_locker_label' => [
'Choose a Box Now locker',
'Επίλεξε Box Now locker',
],
'page.box_now_locker_loading' => [
'Loading lockers…',
'Φόρτωση lockers…',
],
'page.box_now_locker_required' => [
'Choose a Box Now locker to continue.',
'Επίλεξε ένα Box Now locker για να συνεχίσεις.',
],
'page.box_now_locker_select' => [
'Select this locker',
'Επιλογή αυτού του locker',
],
'page.box_now_locker_selected' => [
'Selected',
'Επιλέχθηκε',
],
'page.box_now_locker_search' => [
'Search by area or address…',
'Αναζήτηση με περιοχή ή διεύθυνση…',
],
'page.box_now_locker_no_results' => [
'No lockers match your search.',
'Δεν βρέθηκαν lockers για αυτή την αναζήτηση.',
],
'page.select_shipping_method' => ['Continue', 'Συνέχεια'],
'page.shipping_option_invalid' => [
'That shipping option is no longer available.',
'Αυτός ο τρόπος αποστολής δεν είναι πλέον διαθέσιμος.',
],
'page.continue_to_payment' => ['Continue to payment', 'Συνέχεια στην πληρωμή'],
'page.order_summary_heading' => ['Order summary', 'Σύνοψη παραγγελίας'],
// ── Payment step ────────────────────────────────────────────
'page.payment_heading' => ['Payment', 'Πληρωμή'],
'page.payment_method_none' => [
'No payment methods are available right now.',
'Δεν υπάρχουν διαθέσιμοι τρόποι πληρωμής αυτή τη στιγμή.',
],
'page.terms_accept' => [
"I accept the <a href=':terms' target='_blank'>Terms of Sale</a> and the <a href=':privacy' target='_blank'>Privacy Policy</a>",
"Αποδέχομαι τους <a href=':terms' target='_blank'>Όρους Πώλησης</a> και την <a href=':privacy' target='_blank'>Πολιτική Απορρήτου</a>",
],
'page.terms_required' => [
'You must accept the terms to place your order.',
'Πρέπει να αποδεχτείς τους όρους για να ολοκληρώσεις την παραγγελία.',
],
'page.withdrawal_notice' => [
"You have a 14-day right of withdrawal. <a href=':link' target='_blank'>See details</a>.",
"Έχεις δικαίωμα υπαναχώρησης εντός 14 ημερών. <a href=':link' target='_blank'>Δες λεπτομέρειες</a>.",
],
'page.place_order' => ['Place order — payment obligation', 'Παραγγελία με υποχρέωση πληρωμής'],
'page.choose_payment_method' => ['Choose a payment method.', 'Επίλεξε τρόπο πληρωμής.'],
'page.shipping_method_required' => [
'Choose a shipping method below to continue.',
'Επίλεξε τρόπο αποστολής παρακάτω για να συνεχίσεις.',
],
'page.payment_failed' => ['Payment failed. Please try again.', 'Η πληρωμή απέτυχε. Δοκίμασε ξανά.'],
'page.payment_incomplete_details' => [
'Complete your billing and shipping details above.',
'Συμπλήρωσε τα στοιχεία χρέωσης και αποστολής παραπάνω.',
],
'page.payment_cart_changed' => [
'Your cart changed. Refresh the page and place your order again.',
'Το καλάθι σου άλλαξε. Ανανέωσε τη σελίδα και ολοκλήρωσε ξανά.',
],
'page.payment_processing' => ['Confirming your payment…', 'Επιβεβαίωση πληρωμής…'],
'page.payment_processing_slow' => [
"Your payment is still processing. You'll get an email once it's confirmed.",
'Η πληρωμή σου επεξεργάζεται ακόμη. Θα λάβεις email μόλις επιβεβαιωθεί.',
],
// ── Confirmation page ──────────────────────────────────────
'page.confirmation_title' => ['Your order', 'Η παραγγελία σου'],
'page.confirmation_heading' => [
'Thank you! Your order is confirmed.',
'Ευχαριστούμε! Η παραγγελία σου καταχωρήθηκε.',
],
'page.confirmation_order_number' => ['Order number', 'Αριθμός παραγγελίας'],
'page.confirmation_email_note' => [
'A confirmation email will follow shortly.',
'Θα λάβεις email επιβεβαίωσης σύντομα.',
],
'page.confirmation_shipping_to' => ['Shipping to', 'Αποστολή σε'],
'page.confirmation_billing' => ['Billing', 'Χρέωση'],
'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'],
];
}
}
+14 -2
View File
@@ -113,6 +113,17 @@ services:
ports:
- "${VALKEY_PORT:-6339}:6379"
meilisearch:
image: getmeili/meilisearch:v1.12
ports:
- "${MEILISEARCH_PORT:-7700}:7700"
environment:
MEILI_MASTER_KEY: ${MEILISEARCH_KEY:?MEILISEARCH_KEY is required}
MEILI_NO_ANALYTICS: "true"
MEILI_ENV: development
volumes:
- meilidata:/meili_data
stoic:
image: ghcr.io/lexx27/stoic:latest
user: root
@@ -137,10 +148,10 @@ services:
working_dir: /app
command: npm run dev
ports:
- "${VITE_PORT:-5173}:5173"
- "${VITE_PORT:-5174}:${VITE_PORT:-5174}"
environment:
- NODE_ENV=development
- VITE_PORT=${VITE_PORT:-5173}
- VITE_PORT=${VITE_PORT:-5174}
volumes:
- .:/app
- /app/node_modules
@@ -149,3 +160,4 @@ volumes:
vendor:
node_modules:
pgdata:
meilidata:
+15
View File
@@ -115,6 +115,20 @@ services:
volumes:
- valkeydata:/data
meilisearch:
image: getmeili/meilisearch:v1.12
restart: unless-stopped
environment:
MEILI_MASTER_KEY: ${MEILISEARCH_KEY:?MEILISEARCH_KEY is required}
MEILI_NO_ANALYTICS: "true"
volumes:
- meilidata:/meili_data
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--spider", "http://localhost:7700/health"]
interval: 5s
timeout: 5s
retries: 10
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
restart: unless-stopped
@@ -145,3 +159,4 @@ volumes:
logs:
framework:
valkeydata:
meilidata:
+28 -7
View File
@@ -5,17 +5,38 @@ set -e
# by app only) — just wait for app's migration to finish, then start the process.
mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs storage/framework bootstrap/cache
if [ "$APP_ENV" != "production" ]; then
echo "[entrypoint] Waiting for migrations to complete..."
timeout=60
while [ ! -f storage/framework/migrated ] && [ "$timeout" -gt 0 ]; do
# Same reasoning as entrypoint.sh: this container gets replaced on every deploy
# (dev and production alike), so this is a fresh boot clearing stale artifacts
# left on disk (cached config, compiled views), not a running process being
# told to forget in-memory code. queue:work/schedule:work themselves still
# can't pick up a later code change without an actual process restart — this
# only fixes what's stale on disk at boot.
echo "[entrypoint] Clearing cached config/routes/views..."
php artisan optimize:clear --quiet
echo "[entrypoint] Caches cleared"
# Universal, in both dev and production — app's entrypoint always writes this
# marker after `migrate --force` completes (single app instance, so there's
# exactly one writer), and queue/scheduler must never start against a database
# schema that migration hasn't finished bringing up to date yet.
echo "[entrypoint] Waiting for migrations to complete..."
timeout=60
while [ ! -f storage/framework/migrated ] && [ "$timeout" -gt 0 ]; do
sleep 1
timeout=$((timeout - 1))
done
if [ ! -f storage/framework/migrated ]; then
done
if [ ! -f storage/framework/migrated ]; then
echo "[entrypoint] Timed out waiting for migrations" >&2
exit 1
fi
fi
if [ "$APP_ENV" = "production" ]; then
# Same reasoning as entrypoint.sh's own production-only optimize step —
# queue:work/schedule:work read config on every job/tick too, so this
# avoids paying the same uncached-config cost app pays per request.
echo "[entrypoint] Caching config/routes/views for production..."
php artisan optimize --quiet
echo "[entrypoint] Production caches built"
fi
exec "$@"
+73 -14
View File
@@ -4,8 +4,15 @@ set -e
# Only the app container runs setup; queue/scheduler use entrypoint-worker.sh instead
# and just wait on the migrated marker this script writes below.
if [ "$APP_ENV" != "production" ]; then
# Dev-only: the app dir is bind-mounted from a fresh checkout, so there's no
# image-build step that already installed vendor/ or published assets — this
# container has to do it at boot instead. In production the Dockerfile already
# runs composer install and asset publishing at IMAGE BUILD time (see
# Dockerfile's `production` stage), so repeating them here would be wasteful,
# not just redundant-but-safe.
echo "[entrypoint] Installing Composer dependencies..."
git config --global --add safe.directory /var/www/html 2>/dev/null || true
git config --global --add safe.directory /var/www/boboko-core 2>/dev/null || true
composer install --no-interaction --prefer-dist
# Re-resolve boboko/* on every boot, whether it's a local path-repo checkout
@@ -20,28 +27,80 @@ mkdir -p storage/app/public storage/framework/cache storage/framework/sessions s
chown -R www-data:www-data storage bootstrap/cache
chmod -R 775 storage bootstrap/cache
# Composer packages (dev) may have just changed above, or (production) this is a
# freshly built image — either way, clear any cached config/routes/compiled views
# left over from a previous boot before anything below reads them. Production
# runs a single app instance that gets replaced on every deploy, not a running
# process being told to forget in-memory code — the actual bug this fixes is
# stale artifacts still sitting in bootstrap/cache or storage/framework/views on
# a fresh boot (e.g. after the Lunar 1.5/Filament v4 upgrade, a leftover compiled
# view referenced a class that upgrade removed).
echo "[entrypoint] Clearing cached config/routes/views..."
php artisan optimize:clear --quiet
echo "[entrypoint] Caches cleared"
php artisan storage:link --quiet 2>/dev/null || true
if [ "$APP_ENV" != "production" ]; then
# The app dir is bind-mounted from a fresh checkout, so package assets (which the
# Dockerfile publishes at build time in production) need to be generated here
# instead. Cheap and idempotent, safe to repeat on every boot.
# Dev-only for the same reason as the composer step above — production's
# image already has these published at build time.
php artisan vendor:publish --tag=core-assets --force --ansi --quiet
php artisan vendor:publish --tag=public --force --ansi --quiet
php artisan filament:assets --ansi --quiet
echo "[entrypoint] Running migrations..."
rm -f storage/framework/migrated
php artisan migrate --force
touch storage/framework/migrated
# No --force: core-views publishes editable Blade templates (order
# notification emails), meant to be hand-customized per app — unlike
# core-assets above, republishing must not silently wipe local edits.
# Only fills in the vendor/core views directory if it doesn't exist yet.
php artisan vendor:publish --tag=core-views --ansi --quiet
fi
# boboko/core overrides lunar:install to skip the interactive prompts (migrate
# confirm, admin creation, GitHub star) and just seed the idempotent store
# defaults: countries, channel, language, currency, tax zone, attributes,
# product type. queue/scheduler wait on the marker above rather than running
# this themselves, since the country import's check-then-insert isn't safe to
# run concurrently.
php artisan lunar:install --quiet || true
# Everything below is universal, in both dev and production: application STATE
# that must be current on every boot, not a build-time concern composer/assets
# are. Safe to run unconditionally on every boot because production runs a
# single app instance — no concurrent replicas that would race each other
# running `migrate --force` at the same time.
echo "[entrypoint] Running migrations..."
rm -f storage/framework/migrated
php artisan migrate --force
echo "[entrypoint] Touching migrated file"
touch storage/framework/migrated
echo "[entrypoint] Touched migrated file"
# boboko/core overrides lunar:install to skip the interactive prompts (migrate
# confirm, admin creation, GitHub star) and just seed the idempotent store
# defaults: countries, channel, language, currency, tax zone, attributes,
# product type. queue/scheduler wait on the marker above rather than running
# this themselves, since the country import's check-then-insert isn't safe to
# run concurrently. A fresh production install needs this seeding the same as
# a fresh dev one does — and re-running it against an already-seeded store is
# a no-op per key (see InstallLunarCommand's idempotent upserts).
echo "[entrypoint] Trying Lunar install"
php artisan lunar:install --quiet || true
# Upserts by primary key (no --refresh), so this stays cheap and idempotent on
# every boot rather than flushing and rebuilding the whole index each time. Runs
# in production too — a deploy that changed an indexer's field list needs this
# to keep Meilisearch's index settings and documents in sync with the code that
# just shipped, same reasoning as optimize:clear above.
echo "[entrypoint] Syncing search indexes..."
php artisan lunar:meilisearch:setup
php artisan lunar:meilisearch:tune-product-search --quiet || true
php artisan lunar:search:index --quiet || true
if [ "$APP_ENV" = "production" ]; then
# The counterpart to optimize:clear above: config/routes/views/events get
# compiled once here, at the end of boot, after everything that could
# change them (migrations, lunar:install, index sync) has already run —
# so production actually gets the request-time performance win caching is
# for, rather than staying permanently uncached. Dev deliberately never
# does this: caching config here would mean .env/config edits stop taking
# effect until the next optimize:clear, which is the opposite of what dev
# needs on every iteration.
echo "[entrypoint] Caching config/routes/views for production..."
php artisan optimize --quiet
echo "[entrypoint] Production caches built"
fi
exec "$@"
+8 -15
View File
@@ -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",
+1 -2
View File
@@ -16,8 +16,7 @@
"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"
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
.fi-pagination-items,.fi-pagination-overview,.fi-pagination-records-per-page-select:not(.fi-compact){display:none}@supports (container-type:inline-size){.fi-pagination{container-type:inline-size}@container (min-width: 28rem){.fi-pagination-records-per-page-select.fi-compact{display:none}.fi-pagination-records-per-page-select:not(.fi-compact){display:inline}}@container (min-width: 56rem){.fi-pagination:not(.fi-simple)>.fi-pagination-previous-btn{display:none}.fi-pagination-overview{display:inline}.fi-pagination:not(.fi-simple)>.fi-pagination-next-btn{display:none}.fi-pagination-items{display:flex}}}@supports not (container-type:inline-size){@media(min-width:640px){.fi-pagination-records-per-page-select.fi-compact{display:none}.fi-pagination-records-per-page-select:not(.fi-compact){display:inline}}@media(min-width:768px){.fi-pagination:not(.fi-simple)>.fi-pagination-previous-btn{display:none}.fi-pagination-overview{display:inline}.fi-pagination:not(.fi-simple)>.fi-pagination-next-btn{display:none}.fi-pagination-items{display:flex}}}.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{position:relative;background-color:#333;color:#fff;border-radius:4px;font-size:14px;line-height:1.4;white-space:normal;outline:0;transition-property:transform,visibility,opacity}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{bottom:-7px;left:0;border-width:8px 8px 0;border-top-color:initial;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-7px;left:0;border-width:0 8px 8px;border-bottom-color:initial;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-width:8px 0 8px 8px;border-left-color:initial;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-7px;border-width:8px 8px 8px 0;border-right-color:initial;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{width:16px;height:16px;color:#333}.tippy-arrow:before{content:"";position:absolute;border-color:transparent;border-style:solid}.tippy-content{position:relative;padding:5px 9px;z-index:1}.tippy-box[data-theme~=light]{color:#26323d;box-shadow:0 0 20px 4px #9aa1b126,0 4px 80px -8px #24282f40,0 4px 4px -2px #5b5e6926;background-color:#fff}.tippy-box[data-theme~=light][data-placement^=top]>.tippy-arrow:before{border-top-color:#fff}.tippy-box[data-theme~=light][data-placement^=bottom]>.tippy-arrow:before{border-bottom-color:#fff}.tippy-box[data-theme~=light][data-placement^=left]>.tippy-arrow:before{border-left-color:#fff}.tippy-box[data-theme~=light][data-placement^=right]>.tippy-arrow:before{border-right-color:#fff}.tippy-box[data-theme~=light]>.tippy-backdrop{background-color:#fff}.tippy-box[data-theme~=light]>.tippy-svg-arrow{fill:#fff}.fi-sortable-ghost{opacity:.3}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
function r({state:o}){return{state:o,rows:[],shouldUpdateRows:!0,init:function(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(t,e)=>{let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(t)===0&&s(e)===0||this.updateRows()})},addRow:function(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow:function(t){this.rows.splice(t,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows:function(t){let e=Alpine.raw(this.rows);this.rows=[];let s=e.splice(t.oldIndex,1)[0];e.splice(t.newIndex,0,s),this.$nextTick(()=>{this.rows=e,this.updateState()})},updateRows:function(){if(!this.shouldUpdateRows){this.shouldUpdateRows=!0;return}let t=[];for(let[e,s]of Object.entries(this.state??{}))t.push({key:e,value:s});this.rows=t},updateState:function(){let t={};this.rows.forEach(e=>{e.key===""||e.key===null||(t[e.key]=e.value)}),this.shouldUpdateRows=!1,this.state=t}}}export{r as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
function i({state:a,splitKeys:n}){return{newTag:"",state:a,createTag:function(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.newTag=""}},deleteTag:function(t){this.state=this.state.filter(e=>e!==t)},reorderTags:function(t){let e=this.state.splice(t.oldIndex,1)[0];this.state.splice(t.newIndex,0,e),this.state=[...this.state]},input:{"x-on:blur":"createTag()","x-model":"newTag","x-on:keydown"(t){["Enter",...n].includes(t.key)&&(t.preventDefault(),t.stopPropagation(),this.createTag())},"x-on:paste"(){this.$nextTick(()=>{if(n.length===0){this.createTag();return}let t=n.map(e=>e.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(t,"g")).forEach(e=>{this.newTag=e,this.createTag()})})}}}}export{i as default};
@@ -1 +0,0 @@
function r({initialHeight:t,shouldAutosize:i,state:s}){return{state:s,wrapperEl:null,init:function(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight:function(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=t+"rem")},resize:function(){if(this.setInitialHeight(),this.$el.scrollHeight<=0)return;let e=this.$el.scrollHeight+"px";this.wrapperEl.style.height!==e&&(this.wrapperEl.style.height=e)},setUpResizeObserver:function(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{r as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
function d(){return{checkboxClickController:null,collapsedGroups:[],isLoading:!1,selectedRecords:[],shouldCheckUniqueSelection:!0,lastCheckedRecord:null,livewireId:null,init:function(){this.livewireId=this.$root.closest("[wire\\:id]").attributes["wire:id"].value,this.$wire.$on("deselectAllTableRecords",()=>this.deselectAllRecords()),this.$watch("selectedRecords",()=>{if(!this.shouldCheckUniqueSelection){this.shouldCheckUniqueSelection=!0;return}this.selectedRecords=[...new Set(this.selectedRecords)],this.shouldCheckUniqueSelection=!1}),this.$nextTick(()=>this.watchForCheckboxClicks()),Livewire.hook("element.init",({component:e})=>{e.id===this.livewireId&&this.watchForCheckboxClicks()})},mountAction:function(e,t=null){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableAction(e,t)},mountBulkAction:function(e){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableBulkAction(e)},toggleSelectRecordsOnPage:function(){let e=this.getRecordsOnPage();if(this.areRecordsSelected(e)){this.deselectRecords(e);return}this.selectRecords(e)},toggleSelectRecordsInGroup:async function(e){this.isLoading=!0;let t=await this.$wire.getGroupedSelectableTableRecordKeys(e);this.areRecordsSelected(this.getRecordsInGroupOnPage(e))?this.deselectRecords(t):this.selectRecords(t),this.isLoading=!1},getRecordsInGroupOnPage:function(e){let t=[];for(let s of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])s.dataset.group===e&&t.push(s.value);return t},getRecordsOnPage:function(){let e=[];for(let t of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])e.push(t.value);return e},selectRecords:function(e){for(let t of e)this.isRecordSelected(t)||this.selectedRecords.push(t)},deselectRecords:function(e){for(let t of e){let s=this.selectedRecords.indexOf(t);s!==-1&&this.selectedRecords.splice(s,1)}},selectAllRecords:async function(){this.isLoading=!0,this.selectedRecords=await this.$wire.getAllSelectableTableRecordKeys(),this.isLoading=!1},deselectAllRecords:function(){this.selectedRecords=[]},isRecordSelected:function(e){return this.selectedRecords.includes(e)},areRecordsSelected:function(e){return e.every(t=>this.isRecordSelected(t))},toggleCollapseGroup:function(e){if(this.isGroupCollapsed(e)){this.collapsedGroups.splice(this.collapsedGroups.indexOf(e),1);return}this.collapsedGroups.push(e)},isGroupCollapsed:function(e){return this.collapsedGroups.includes(e)},resetCollapsedGroups:function(){this.collapsedGroups=[]},watchForCheckboxClicks:function(){this.checkboxClickController&&this.checkboxClickController.abort(),this.checkboxClickController=new AbortController;let{signal:e}=this.checkboxClickController;this.$root?.addEventListener("click",t=>t.target?.matches(".fi-ta-record-checkbox")&&this.handleCheckboxClick(t,t.target),{signal:e})},handleCheckboxClick:function(e,t){if(!this.lastChecked){this.lastChecked=t;return}if(e.shiftKey){let s=Array.from(this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[]);if(!s.includes(this.lastChecked)){this.lastChecked=t;return}let l=s.indexOf(this.lastChecked),r=s.indexOf(t),o=[l,r].sort((c,n)=>c-n),i=[];for(let c=o[0];c<=o[1];c++)s[c].checked=t.checked,i.push(s[c].value);t.checked?this.selectRecords(i):this.deselectRecords(i)}this.lastChecked=t}}}export{d as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+404 -3
View File
@@ -1,11 +1,412 @@
@import 'tailwindcss';
@import "tailwindcss";
@import "./fonts.css";
@import "./dropdown.css";
/* Note for anyone theming the checkout module (resources/css/checkout.css,
.bbk-* classes): it's deliberately plain, unlayered CSS, not inside any
@layer — so override it with plain rules here too, not from inside
@layer components/utilities, which would lose to it. See checkout.css's
file-level comment for why. */
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
/* ═══════════════════════════════════════════════════════════════════
DESIGN TOKENS
═══════════════════════════════════════════════════════════════════ */
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
/*--font-sans: "Nunito", ui-sans-serif, system-ui, sans-serif;*/
--font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif;
/*--font-display: "DM Sans", ui-sans-serif, system-ui, sans-serif;*/
--font-display: "Manrope", ui-sans-serif, system-ui, sans-serif;
--color-brand: #18c28a;
--color-brand-light: #69cba7;
--text-h1: 60px;
--text-h2: 48px;
--text-h3: 36px;
--text-h4: 26px;
}
/* ═══════════════════════════════════════════════════════════════════
BASE
═══════════════════════════════════════════════════════════════════ */
@layer base {
body {
background-color: theme(colors.neutral.200);
font-family: var(--font-sans);
font-size: 18px;
line-height: 1.389;
font-weight: 400;
color: #000;
-webkit-font-smoothing: antialiased;
}
/* Browsers don't give <button> a pointer cursor by default (unlike <a>),
and Tailwind's Preflight stopped forcing it a few versions back — so
every button sitewide needs it set explicitly, once, here. */
button:not(:disabled) {
cursor: pointer;
}
/* <turbo-frame> is a custom element — inline by default. Give it a box so
the grid it wraps on the category page lays out normally. */
turbo-frame {
display: block;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* ═══════════════════════════════════════════════════════════════════
COMPONENTS
═══════════════════════════════════════════════════════════════════ */
@layer components {
/* ── Nav link — only the span underline-slide animation ──────── */
.nav-link span {
display: inline-block;
background-image: linear-gradient(currentColor, currentColor);
background-position: 0 100%;
background-repeat: no-repeat;
background-size: 0 5px;
transition: background-size 0.35s cubic-bezier(0.61, 1, 0.88, 1);
padding-bottom: 3px;
}
.nav-link:hover span,
.nav-link.is-active span {
background-size: 100% 5px;
}
/* ── Nav dropdown ─────────────────────────────────────────────── */
.nav-dropdown {
position: absolute;
top: 100%;
left: -24px;
min-width: 220px;
background-color: theme(colors.neutral.200);
border: 1px solid #000;
border-top: none;
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition:
opacity 0.2s ease,
transform 0.2s ease,
visibility 0.2s ease;
}
.group:hover .nav-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0px);
}
.nav-dropdown a {
display: block;
padding: 10px 20px;
font-family: var(--font-sans);
font-size: 18px;
font-weight: 400;
color: #000;
text-decoration: none;
transition: background-color 0.15s ease;
}
.nav-dropdown a:hover {
background-color: #000;
color: theme(colors.neutral.200);
}
/* ── Hero star — same burst shape/spin as back-to-top, always on ─ */
.rotating-star {
transform-origin: center;
animation: spin 7s infinite linear;
}
/* ── Back to top ──────────────────────────────────────────────── */
.back-to-top {
opacity: 0;
visibility: hidden;
transition:
opacity 0.15s ease,
visibility 0s 0.15s;
}
.back-to-top.is-visible {
opacity: 1;
visibility: visible;
transition: opacity 0.3s ease;
}
.back-to-top-shape path {
fill: theme(colors.brand-light);
transform-origin: center;
animation: spin 7s infinite linear;
animation-play-state: paused;
}
.back-to-top a:hover .back-to-top-shape path {
animation-play-state: running;
}
.back-to-top-arrow path {
transition: transform 0.5s cubic-bezier(0.39, 0.1, 0, 0.98);
}
.back-to-top a:hover .back-to-top-arrow path {
transform: translateY(-5px);
}
/* ── Product lightbox (popover) ──────────────────────────────── */
.product-lightbox {
inset: 0;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
margin: 0;
border: none;
padding: 0;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition:
opacity 0.3s ease,
display 0.3s allow-discrete,
overlay 0.3s allow-discrete;
}
.product-lightbox:popover-open {
opacity: 1;
pointer-events: auto;
}
@starting-style {
.product-lightbox:popover-open {
opacity: 0;
}
}
.product-lightbox::backdrop {
background-color: rgba(0, 0, 0, 0);
transition:
background-color 0.3s ease,
display 0.3s allow-discrete,
overlay 0.3s allow-discrete;
}
.product-lightbox:popover-open::backdrop {
background-color: rgba(0, 0, 0, 0.8);
}
@starting-style {
.product-lightbox:popover-open::backdrop {
background-color: rgba(0, 0, 0, 0);
}
}
/* ── Search overlay (popover) — opacity fade over the header ──── */
#search-overlay {
/* Fallback height until the `nav-search` controller measures the real
header on open; the header has no fixed height below `lg`. */
--search-overlay-h: 6.5rem;
opacity: 0;
transition:
opacity 0.2s ease,
display 0.2s allow-discrete,
overlay 0.2s allow-discrete;
}
#search-overlay:popover-open {
opacity: 1;
}
@starting-style {
#search-overlay:popover-open {
opacity: 0;
}
}
/* ── Shared underline-slide animation ────────────────────────── */
.underline-slide {
background-image: linear-gradient(currentColor, currentColor);
background-position: 0 100%;
background-repeat: no-repeat;
background-size: 0 var(--slide-h, 2px);
transition: background-size 0.35s cubic-bezier(0.61, 1, 0.88, 1);
padding-bottom: 3px;
}
.underline-slide:hover,
.underline-slide.is-active {
background-size: 100% var(--slide-h, 2px);
}
/* ── Lightbox arrows + close ─────────────────────────────────── */
.lightbox-arrow svg {
transition: transform 0.25s cubic-bezier(0.39, 0.1, 0, 0.98);
}
.lightbox-arrow[aria-label="Previous image"]:hover svg {
transform: translateX(-6px);
}
.lightbox-arrow[aria-label="Next image"]:hover svg {
transform: translateX(6px);
}
.lightbox-close svg {
transition: stroke 0.2s ease;
}
.lightbox-close:hover svg {
stroke: theme(colors.brand);
}
/* ── Gallery arrows ──────────────────────────────────────────── */
.gallery-arrow svg {
transition: transform 0.25s cubic-bezier(0.39, 0.1, 0, 0.98);
}
.gallery-arrow:first-child:hover svg {
transform: translateY(-4px);
}
.gallery-arrow:last-child:hover svg {
transform: translateY(4px);
}
/* ── Checkbox ────────────────────────────────────────────────── */
.checkbox:checked::after {
content: "";
position: absolute;
inset: 2px;
background: theme(colors.brand);
}
/* ── Color swatches ───────────────────────────────────────────── */
.color-swatch {
width: 32px;
height: 32px;
border: 1px solid #000;
cursor: pointer;
flex-shrink: 0;
transition: border-width 0.1s ease;
}
.color-swatch.is-selected {
border-width: 3px;
}
/* ── Button — ::before is the button bg, ::after is the shadow ── */
.btn-primary::before {
content: "";
position: absolute;
inset: 0;
background-color: theme(colors.neutral.200);
z-index: 0;
}
.btn-primary::after {
content: "";
position: absolute;
inset: 0;
background-color: #000;
z-index: -1;
transform: translate(10px, 10px);
transition: transform 0.35s cubic-bezier(0.2, 0.78, 0.12, 0.86);
}
.btn-primary:hover::after {
transform: translate(0, 0);
}
.text-dance {
display: inline-block;
font-weight: inherit;
font-style: normal;
}
.text-dance::before {
content: attr(data-text);
display: block;
height: 0;
overflow: hidden;
visibility: hidden;
font-weight: 800;
font-style: italic;
}
.is-visible .text-dance {
animation: text-dance 0.8s ease 3 forwards;
animation-delay: var(--dance-delay, 0s);
}
/* Typography for markdown rendered from Stoic legal-page bodies (h1.h6, p, lists,
links, tables) — a plain set of descendant selectors, not expressible as
utilities on the wrapper alone since the markup itself is generated by Str::markdown(). */
.legal-content {
@apply text-neutral-700;
}
.legal-content > * + * {
@apply mt-6;
}
.legal-content h2 {
@apply font-display text-2xl font-bold text-black;
}
.legal-content h3 {
@apply font-display text-xl font-bold text-black;
}
.legal-content ul,
.legal-content ol {
@apply ml-6 space-y-2;
}
.legal-content ul {
@apply list-disc;
}
.legal-content ol {
@apply list-decimal;
}
.legal-content a {
@apply underline hover:text-black;
}
.legal-content strong {
@apply font-bold text-black;
}
.legal-content table {
@apply w-full border-collapse text-left text-sm;
}
.legal-content th,
.legal-content td {
@apply border border-neutral-300 px-3 py-2;
}
}
@keyframes text-dance {
0%, 60% { font-weight: inherit; font-style: normal; }
70%, 100% { font-weight: 800; font-style: italic; }
}
+935
View File
@@ -0,0 +1,935 @@
/*
* Cart + checkout module — generic default styling.
*
* Deliberately NOT wrapped in a Tailwind-style `@layer`. An earlier version
* put these rules in `@layer bbk-checkout`, positioned (via a cross-file
* @layer ordering statement) to sit between Tailwind's `base` and
* `components` — in theory enough to beat Preflight's element resets while
* still losing to a host override. In practice a build tool processing each
* CSS file in isolation (Vite/Lightning CSS here) optimizes away exactly the
* cross-file ordering information that trick depends on, so it silently
* didn't work: Preflight's `button { background-color: transparent }`,
* `* { border-width: 0 }` etc. (layered, in `base`) were beating every
* `.bbk-*` rule below regardless of specificity — buttons with no
* background, no border, wrong font-size.
*
* Plain, unlayered CSS sidesteps the whole problem: an unlayered rule always
* beats ANY layered rule (Preflight included), full stop, no ordering tricks,
* nothing a bundler can silently invalidate. This file is loaded BEFORE the
* host's own stylesheet (see the @vite call in the layout <head>), so:
*
* - a later PLAIN (unlayered) `.bbk-*` rule in the host stylesheet wins —
* same specificity, later in source order
* - a later host rule with a MORE specific selector wins regardless
* - a host rule inside `@layer components`/`@layer utilities` does NOT
* win — unlayered always beats layered. Theme this module from plain
* rules in app.css, not from inside a Tailwind layer.
*
* Two ways to theme this, cheapest first:
*
* 1. Redefine the --bbk-* custom properties below (from :root, or scoped to
* .bbk-cart for a cart-only override) — covers colour, radius, shadow,
* font without touching a single selector below.
*
* :root { --bbk-color-accent: var(--color-brand); --bbk-radius: 0; }
*
* 2. Override individual `.bbk-*` rules directly (as plain rules, per
* above) for anything structural (spacing, layout) the variables don't
* cover.
*
* This file's own look is a deliberately neutral placeholder — inoffensive,
* not "designed" — so a project always has something reasonable before it
* themes; it is not meant to be edited per project.
*/
:root {
--bbk-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--bbk-color-text: #18181b;
--bbk-color-muted: #71717a;
--bbk-color-bg: #ffffff;
--bbk-color-bg-muted: #f4f4f5;
--bbk-color-border: #e4e4e7;
--bbk-color-accent: #18181b;
--bbk-color-accent-text: #ffffff;
--bbk-color-danger: #dc2626;
--bbk-radius: 8px;
--bbk-radius-sm: 4px;
--bbk-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
}
.bbk-cart[hidden] { display: none; }
.bbk-cart {
position: fixed;
inset: 0;
z-index: 1000;
font-family: var(--bbk-font);
font-size: 0.9375rem;
line-height: 1.4;
color: var(--bbk-color-text);
}
.bbk-cart-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.25s ease;
}
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-backdrop { opacity: 1; }
.bbk-cart-panel {
position: absolute;
top: 0;
right: 0;
display: flex;
flex-direction: column;
width: min(420px, 100vw);
height: 100%;
background: var(--bbk-color-bg);
box-shadow: var(--bbk-shadow);
transform: translateX(100%);
transition: transform 0.25s ease;
}
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-panel { transform: translateX(0); }
.bbk-cart-panel-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.5rem 1.5rem 1.25rem;
border-bottom: 1px solid var(--bbk-color-border);
}
.bbk-cart-heading {
margin: 0;
font-size: 1.375rem;
font-weight: 700;
}
.bbk-cart-dismiss,
.bbk-cart-item-remove,
.bbk-cart-qty-btn {
cursor: pointer;
background: none;
border: 0;
padding: 0;
font: inherit;
line-height: 1;
color: var(--bbk-color-muted);
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
}
.bbk-cart-dismiss {
font-size: 1.75rem;
width: 2.5rem;
height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--bbk-radius-sm);
flex-shrink: 0;
}
.bbk-cart-dismiss:hover { color: var(--bbk-color-text); background: var(--bbk-color-bg-muted); }
.bbk-cart-item-remove:hover { color: var(--bbk-color-danger); }
.bbk-cart-dismiss:focus-visible,
.bbk-cart-item-remove:focus-visible,
.bbk-cart-qty-btn:focus-visible,
.bbk-cart-qty-input:focus-visible,
.bbk-cart-checkout:focus-visible,
.bbk-cart-coupon-input:focus-visible,
.bbk-cart-coupon-submit:focus-visible,
.bbk-cart-coupon-remove:focus-visible {
outline: 2px solid var(--bbk-color-accent);
outline-offset: 2px;
}
.bbk-visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.bbk-cart-panel-body {
flex: 1 1 auto;
overflow-y: auto;
overscroll-behavior: contain;
padding: 1.5rem;
}
.bbk-cart-items {
list-style: none;
margin: 0 0 2rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.bbk-cart-item {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 0.875rem;
align-items: start;
}
.bbk-cart-item-media img {
display: block;
width: 72px;
height: 72px;
object-fit: cover;
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg-muted);
}
.bbk-cart-item-detail { min-width: 0; }
.bbk-cart-item-title {
margin: 0 0 0.25rem;
font-weight: 600;
}
.bbk-cart-item-unit {
margin: 0 0 0.625rem;
color: var(--bbk-color-muted);
}
.bbk-cart-item-aside {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.bbk-cart-item-total { margin: 0; font-weight: 600; }
.bbk-cart-item-remove {
font-size: 1.125rem;
width: 1.5rem;
height: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.bbk-cart-qty {
display: inline-flex;
align-items: center;
gap: 0;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
overflow: hidden;
}
.bbk-cart-qty-btn {
width: 1.75rem;
height: 1.75rem;
background: var(--bbk-color-bg-muted);
}
.bbk-cart-qty-btn:hover { background: var(--bbk-color-border); color: var(--bbk-color-text); }
.bbk-cart-qty-input {
width: 2.25rem;
height: 1.75rem;
border: 0;
border-left: 1px solid var(--bbk-color-border);
border-right: 1px solid var(--bbk-color-border);
text-align: center;
font: inherit;
color: inherit;
background: var(--bbk-color-bg);
appearance: textfield;
-moz-appearance: textfield;
}
.bbk-cart-qty-input::-webkit-outer-spin-button,
.bbk-cart-qty-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.bbk-cart-summary {
padding-top: 1.25rem;
border-top: 1px solid var(--bbk-color-border);
display: flex;
flex-direction: column;
gap: 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-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 tabs */
.bbk-checkout-tabs { display: flex; flex-direction: column; gap: 1rem; }
.bbk-checkout-tab {
display: inline-flex;
width: fit-content;
margin-right: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg);
font: inherit;
font-weight: 600;
color: var(--bbk-color-muted);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.bbk-checkout-tab[aria-selected="true"] {
background: var(--bbk-color-text);
border-color: var(--bbk-color-text);
color: var(--bbk-color-bg);
}
.bbk-checkout-tab:focus-visible {
outline: 2px solid var(--bbk-color-accent);
outline-offset: 2px;
}
/* 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. */
.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); }
/* Dummy Box Now locker picker — see shipping-options.blade.php. */
.bbk-checkout-box-now-locker {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
}
.bbk-checkout-box-now-locker-label {
font-weight: 600;
font-size: 0.8125rem;
}
.bbk-checkout-box-now-locker-map {
width: 100%;
height: 480px;
border-radius: var(--bbk-radius-sm);
z-index: 0;
}
.bbk-checkout-box-now-locker-search {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
font-size: 0.875rem;
}
.bbk-checkout-box-now-locker-search:focus {
outline: none;
border-color: var(--bbk-color-accent);
}
.bbk-checkout-box-now-locker-chosen {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--bbk-color-accent);
}
/* Custom SVG pin (see bbk-box-now-locker-controller.js pinIcon()) — no
default Leaflet drop-shadow image, so a CSS shadow stands in for it. */
.bbk-box-now-pin svg {
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.35));
}
/* Leaflet's own popup chrome, restyled to match the checkout's cards
instead of the library's square-cornered default. */
.leaflet-popup-content-wrapper {
border-radius: 0.75rem;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
}
.leaflet-popup-content {
margin: 0.875rem;
}
.bbk-box-now-popup {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 220px;
}
.bbk-box-now-popup-image {
width: calc(100% + 1.75rem);
margin: -0.875rem -0.875rem 0.125rem;
height: 110px;
object-fit: cover;
border-radius: 0.75rem 0.75rem 0 0;
}
.bbk-box-now-popup-name {
display: flex;
align-items: center;
gap: 0.375rem;
margin: 0;
font-weight: 700;
font-size: 0.9375rem;
}
.bbk-box-now-popup-name::before {
content: '';
flex: 0 0 auto;
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: #00c389;
}
.bbk-box-now-popup-address {
margin: 0;
padding-left: 0.875rem;
font-size: 0.8125rem;
line-height: 1.4;
color: var(--bbk-color-muted);
}
.bbk-box-now-popup-note {
margin: 0 0 0 0.875rem;
padding: 0.5rem 0.625rem;
background: color-mix(in srgb, #00c389 8%, transparent);
border-radius: var(--bbk-radius-sm);
font-size: 0.75rem;
font-style: italic;
color: var(--bbk-color-muted);
}
.bbk-box-now-popup-select {
margin-top: 0.25rem;
padding: 0.625rem 0.875rem;
width: 100%;
border: none;
border-radius: var(--bbk-radius-sm);
background: #00c389;
color: #ffffff;
font-weight: 700;
font-size: 0.8125rem;
letter-spacing: 0.01em;
cursor: pointer;
transition: background-color 0.15s ease, transform 0.1s ease;
}
.bbk-box-now-popup-select:hover { background: #00a876; transform: translateY(-1px); }
.bbk-box-now-popup-select:active { transform: translateY(0); }
.bbk-box-now-popup-select--selected,
.bbk-box-now-popup-select--selected:hover {
background: var(--bbk-color-muted);
cursor: default;
}
/* 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;
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-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: flex;
justify-content: space-between;
gap: 1rem;
}
.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);
}
.bbk-confirmation-continue {
max-width: 280px;
text-decoration: none;
}
+40
View File
@@ -0,0 +1,40 @@
/* ── Dropdown (Popover API) ──────────────────────────────────── */
/* The panel is a [popover] → it renders in the top layer, so its
containing block is the viewport, not the .dropdown wrapper, and
CSS alone can't tie it to the trigger (anchor positioning isn't
everywhere yet). The `dropdown` Stimulus controller measures the
trigger on open and writes --dropdown-top/left/width here; the
open/close animation below stays pure CSS. Opens on a click, so
animating transform + opacity is CLS-safe. */
.dropdown-panel {
top: var(--dropdown-top, 0);
left: var(--dropdown-left, 0);
min-width: var(--dropdown-width, 0);
opacity: 0;
transform: translateY(-4px);
transition:
opacity 0.2s ease,
transform 0.2s ease,
display 0.2s allow-discrete,
overlay 0.2s allow-discrete;
}
.dropdown-panel:popover-open {
opacity: 1;
transform: translateY(-1px);
}
@starting-style {
.dropdown-panel:popover-open {
opacity: 0;
transform: translateY(-4px);
}
}
/* Caret flips while the panel is open — :has() is the only route
back up from the popover's :popover-open state to the caret. */
.dropdown:has(.dropdown-panel:popover-open) .dropdown-caret {
transform: rotate(180deg);
}
+54
View File
@@ -0,0 +1,54 @@
/* manrope-300 - greek_latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Manrope";
font-style: normal;
font-weight: 300;
src: url("../fonts/manrope/manrope-v20-greek_latin-300.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* manrope-regular - greek_latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Manrope";
font-style: normal;
font-weight: 400;
src: url("../fonts/manrope/manrope-v20-greek_latin-regular.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* manrope-500 - greek_latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Manrope";
font-style: normal;
font-weight: 500;
src: url("../fonts/manrope/manrope-v20-greek_latin-500.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* manrope-600 - greek_latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Manrope";
font-style: normal;
font-weight: 600;
src: url("../fonts/manrope/manrope-v20-greek_latin-600.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* manrope-700 - greek_latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Manrope";
font-style: normal;
font-weight: 700;
src: url("../fonts/manrope/manrope-v20-greek_latin-700.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* manrope-800 - greek_latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Manrope";
font-style: normal;
font-weight: 800;
src: url("../fonts/manrope/manrope-v20-greek_latin-800.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
+22 -1
View File
@@ -1 +1,22 @@
import './bootstrap';
import "./bootstrap";
import "./utils/strip-accents";
import "./utils/refresh-scroll";
// Frames only — no site-wide Turbo Drive. <turbo-frame> navigations still work
// (that's how the category listing reloads); every other link and form on the
// site keeps its normal full-page browser behaviour.
import "@hotwired/turbo";
window.Turbo.session.drive = false;
import { Application } from "@hotwired/stimulus";
import { registerControllers } from "./stimulus/index";
import { registerCheckout } from "./checkout";
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.
registerCheckout(application);
@@ -0,0 +1,38 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Sits on an <x-checkout::add-to-cart> <form>. Submits the line to the cart
// via fetch and hands the server-rendered cart body to the drawer through the
// `bbk-cart:changed` window event. No DOM building here — the drawer
// (bbk-cart-controller) owns rendering.
export default class extends Controller {
async add(event) {
event.preventDefault()
const form = this.element
const submit = form.querySelector('[type="submit"]')
form.setAttribute('data-bbk-add-to-cart-state', 'loading')
if (submit) submit.disabled = true
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: new FormData(form),
})
if (!response.ok) return
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: await response.text() },
}))
} finally {
form.removeAttribute('data-bbk-add-to-cart-state')
if (submit) submit.disabled = false
}
}
}
@@ -0,0 +1,220 @@
import { Controller } from '@hotwired/stimulus'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { csrfToken } from './csrf'
// Box Now's own brand green, used for the pin instead of Leaflet's default
// blue teardrop — a small SVG data URI rather than another bundled asset.
const PIN_COLOR = '#00c389'
const PIN_COLOR_SELECTED = '#0a7a52'
function pinIcon(color) {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="46" viewBox="0 0 34 46">
<path
d="M17 0C7.6 0 0 7.6 0 17c0 12.75 17 29 17 29s17-16.25 17-29C34 7.6 26.4 0 17 0Z"
fill="${color}"
stroke="#ffffff"
stroke-width="1.5"
/>
<circle cx="17" cy="17" r="7" fill="#ffffff" />
</svg>
`
return L.divIcon({
className: 'bbk-box-now-pin',
html: svg,
iconSize: [34, 46],
iconAnchor: [17, 46],
popupAnchor: [0, -40],
})
}
const ICON = pinIcon(PIN_COLOR)
const ICON_SELECTED = pinIcon(PIN_COLOR_SELECTED)
// A self-hosted Leaflet map standing in for Box Now's own Destination Map
// JS widget — that widget only talks to Box Now's Production API (see
// their Partner API manual §4.1), so it can't be used while developing
// against Stage credentials. Same underlying /destinations data, rendered
// with OpenStreetMap tiles instead of Box Now's map.
//
// Visibility is toggled by bbk-checkout-form (see its own
// toggleBoxNowLocker()) whenever the "box-now" shipping option becomes
// selected/deselected — this controller only owns loading the locker list
// once visible, rendering pins, and autosaving the chosen one.
export default class extends Controller {
static targets = ['map', 'search', 'status', 'chosen']
static values = {
lockersUrl: String,
selectUrl: String,
loading: String,
selectLabel: String,
selectedLabel: String,
noResults: String,
}
// Athens — a reasonable default center before any locker is loaded.
static DEFAULT_CENTER = [37.9838, 23.7275]
connect() {
this.map = null
this.markers = new Map()
this.selectedId = null
this.loaded = false
if (!this.element.hidden) this.show()
}
disconnect() {
this.map?.remove()
this.map = null
}
// Called by bbk-checkout-form right after it un-hides this element.
show() {
this.element.hidden = false
// Leaflet measures its container's size on init — doing that while
// the element (or an ancestor) is still `hidden` produces a
// collapsed/blank map, so this is deferred to the same tick `hidden`
// is cleared, then Leaflet is nudged once more via invalidateSize().
requestAnimationFrame(() => {
if (!this.map) this.initMap()
this.map.invalidateSize()
if (!this.loaded) this.loadLockers()
})
}
hide() {
this.element.hidden = true
}
initMap() {
this.map = L.map(this.mapTarget).setView(this.constructor.DEFAULT_CENTER, 10)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19,
}).addTo(this.map)
// Delegated: popup content is re-inserted by Leaflet on every open,
// so a listener bound once on the map's container beats binding (and
// losing) one on the button each time a popup renders.
this.map.getContainer().addEventListener('click', (event) => {
const button = event.target.closest('[data-locker-id]')
if (button) this.select(button.dataset.lockerId)
})
}
async loadLockers() {
this.setStatus(this.loadingValue)
try {
const response = await fetch(this.lockersUrlValue, {
headers: { Accept: 'application/json' },
})
if (!response.ok) return
const { lockers } = await response.json()
this.loaded = true
this.lockers = new Map(lockers.map((locker) => [String(locker.id), locker]))
this.renderMarkers(lockers)
this.setStatus('')
} catch {
this.setStatus('')
}
}
renderMarkers(lockers) {
this.markers.forEach((marker) => marker.remove())
this.markers = new Map(lockers.map((locker) => {
const marker = L.marker([locker.lat, locker.lng], { icon: ICON })
.addTo(this.map)
.bindPopup(this.popupHtml(locker), { maxWidth: 260 })
return [String(locker.id), marker]
}))
if (this.markers.size) {
this.map.fitBounds(L.featureGroup([...this.markers.values()]).getBounds().pad(0.2))
}
}
popupHtml(locker) {
const isSelected = String(locker.id) === this.selectedId
return `
<div class="bbk-box-now-popup">
${locker.image ? `<img class="bbk-box-now-popup-image" src="${locker.image}" alt="">` : ''}
<p class="bbk-box-now-popup-name">${locker.name}</p>
<p class="bbk-box-now-popup-address">
${[locker.addressLine1, locker.addressLine2].filter(Boolean).join(', ')}
${locker.postalCode ? ` ${locker.postalCode}` : ''}
</p>
${locker.note ? `<p class="bbk-box-now-popup-note">${locker.note}</p>` : ''}
<button
type="button"
class="bbk-box-now-popup-select${isSelected ? ' bbk-box-now-popup-select--selected' : ''}"
data-locker-id="${locker.id}"
${isSelected ? 'disabled' : ''}
>
${isSelected ? this.selectedLabelValue : this.selectLabelValue}
</button>
</div>
`
}
async select(lockerId) {
const locker = this.lockers?.get(String(lockerId))
if (!locker) return
const previousId = this.selectedId
this.selectedId = String(lockerId)
this.restyleMarker(previousId, ICON)
this.restyleMarker(this.selectedId, ICON_SELECTED)
this.markers.get(this.selectedId)?.setPopupContent(this.popupHtml(locker))
this.chosenTarget.hidden = false
this.chosenTarget.textContent = locker.addressLine1
? `${locker.name} — ${locker.addressLine1}`
: locker.name
const body = new FormData()
body.append('locker_id', locker.id)
body.append('locker_name', locker.name ?? '')
body.append('locker_address', locker.addressLine1 ?? '')
try {
await fetch(this.selectUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body,
})
} catch {
// Best-effort autosave, same as the rest of checkout — a failed
// save here surfaces later at place-order time via the normal
// shipment-creation error path, not as an inline field error.
}
}
restyleMarker(lockerId, icon) {
if (!lockerId) return
this.markers.get(lockerId)?.setIcon(icon)
}
setStatus(text) {
if (!this.hasStatusTarget) return
this.statusTarget.textContent = text
this.statusTarget.hidden = !text
}
}
@@ -0,0 +1,113 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Drives the slide-in cart drawer. One instance, on the drawer root in
// checkout/drawer.blade.php.
//
// - listens on window for `bbk-cart:changed` (from bbk-add-to-cart and from
// this drawer's own line forms) and swaps in the server-rendered cart body
// - handles the in-drawer quantity / remove forms (fetch + method spoofing)
// - re-emits `bbk-cart:updated` {count, total} after every render so the host
// (e.g. the header bag icon) can react
//
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
// attribute on the root, nothing here touches styles or class lists.
export default class extends Controller {
static targets = ['panel', 'body']
connect() {
this.onChanged = this.onChanged.bind(this)
this.onKeydown = this.onKeydown.bind(this)
window.addEventListener('bbk-cart:changed', this.onChanged)
window.addEventListener('bbk-cart:open', this.open.bind(this))
document.addEventListener('keydown', this.onKeydown)
// Prime the host with the count rendered server-side on page load.
this.emitUpdated(this.element.querySelector('[data-bbk-cart-count]'))
}
disconnect() {
window.removeEventListener('bbk-cart:changed', this.onChanged)
document.removeEventListener('keydown', this.onKeydown)
}
onChanged(event) {
if (event.detail?.html) this.replaceBody(event.detail.html)
this.open()
}
onKeydown(event) {
if (event.key === 'Escape' && !this.element.hidden) this.close()
}
open() {
if (!this.element.hidden) return
this.element.hidden = false
// Next frame, so the panel transitions from its off-canvas start.
requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open'))
}
close() {
this.element.removeAttribute('data-bbk-cart-state')
const panel = this.panelTarget
const done = () => {
this.element.hidden = true
panel.removeEventListener('transitionend', done)
}
panel.addEventListener('transitionend', done)
}
// change on a line quantity input, or submit of a line's remove form
submit(event) {
event.preventDefault()
const form = event.target.closest('form')
if (form) this.send(form)
}
// +/- stepper buttons inside a line
step(event) {
event.preventDefault()
const form = event.target.closest('form')
const input = form.querySelector('input[type="number"]')
const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir))
input.value = String(next)
this.send(form)
}
async send(form) {
this.bodyTarget.setAttribute('aria-busy', 'true')
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: new FormData(form),
})
if (response.ok) this.replaceBody(await response.text())
} finally {
this.bodyTarget.removeAttribute('aria-busy')
}
}
replaceBody(html) {
this.bodyTarget.innerHTML = html
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
}
emitUpdated(node) {
if (!node) return
window.dispatchEvent(new CustomEvent('bbk-cart:updated', {
detail: {
count: parseInt(node.dataset.bbkCartCount || '0', 10),
total: parseInt(node.dataset.bbkCartTotal || '0', 10),
},
}))
}
}
@@ -0,0 +1,244 @@
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 = [
'guestTab', 'loginTab', 'guestPanel', 'loginPanel',
'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()
}
// ── Contact tabs ────────────────────────────────────────────────────
showGuest() {
this.guestPanelTarget.hidden = false
this.loginPanelTarget.hidden = true
this.guestTabTarget.setAttribute('aria-selected', 'true')
this.loginTabTarget.setAttribute('aria-selected', 'false')
}
showLogin() {
this.guestPanelTarget.hidden = true
this.loginPanelTarget.hidden = false
this.guestTabTarget.setAttribute('aria-selected', 'false')
this.loginTabTarget.setAttribute('aria-selected', 'true')
}
// ── 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'
|| el.closest('[data-bbk-checkout-form-target="guestPanel"]')
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) {
this.toggleBoxNowLocker(event.target.value)
// 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
}
// The dummy Box Now locker <select> (see shipping-options.blade.php)
// lives inside the #bbk-shipping-options fragment this controller
// re-renders wholesale on every shipping-option change — so its own
// Stimulus controller reconnects fresh each time and has no memory of
// which option was previously selected. This is the one place that
// knows the newly-chosen option's identifier, so it also owns
// showing/hiding the picker.
toggleBoxNowLocker(identifier) {
const picker = this.shippingOptionsTarget.querySelector('#bbk-box-now-locker')
if (!picker) return
const controller = this.application.getControllerForElementAndIdentifier(picker, 'bbk-box-now-locker')
if (identifier === 'box-now') {
controller?.show()
} else {
controller?.hide()
}
}
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)
}
}
}
@@ -0,0 +1,279 @@
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) })
}
}
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
}
}
+6
View File
@@ -0,0 +1,6 @@
// Reads the CSRF token from the standard <meta name="csrf-token"> tag every
// boboko host renders in its layout <head>. Kept as its own module so both
// checkout controllers share one source.
export function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
+21
View File
@@ -0,0 +1,21 @@
import BbkAddToCartController from './bbk-add-to-cart-controller'
import BbkBoxNowLockerController from './bbk-box-now-locker-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-box-now-locker', BbkBoxNowLockerController)
application.register('bbk-cart', BbkCartController)
application.register('bbk-checkout-form', BbkCheckoutFormController)
application.register('bbk-payment', BbkPaymentController)
}
@@ -0,0 +1,20 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static classes = ['visible']
connect() {
this.observer = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return
this.element.classList.add(this.visibleClass)
this.observer.disconnect()
}, { threshold: 0.3 })
this.observer.observe(this.element)
}
disconnect() {
this.observer?.disconnect()
}
}
@@ -0,0 +1,27 @@
import { Controller } from '@hotwired/stimulus'
// Submits the host <form> a short beat after a control inside it changes,
// coalescing a burst — rapid slider nudges, or holding an arrow key on a range
// input — into a single submit. Wire it on the <form>:
//
// <form data-controller="auto-submit"
// data-action="change->auto-submit#submit range-slider:change->auto-submit#submit"
// data-auto-submit-delay-value="300"> (delay optional, ms)
//
// `change` covers native inputs (checkbox, select); the range slider emits its
// own `range-slider:change` on commit. Uses requestSubmit() (not submit()) so a
// <turbo-frame> around the form still captures the navigation and validation runs.
export default class extends Controller {
static values = { delay: { type: Number, default: 300 } }
submit() {
clearTimeout(this.#timer)
this.#timer = setTimeout(() => this.element.requestSubmit(), this.delayValue)
}
disconnect() {
clearTimeout(this.#timer)
}
#timer
}
@@ -0,0 +1,22 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
connect() {
this.scrollHandler = this.onScroll.bind(this)
window.addEventListener('scroll', this.scrollHandler, { passive: true })
this.onScroll()
}
disconnect() {
window.removeEventListener('scroll', this.scrollHandler)
}
onScroll() {
this.element.classList.toggle('is-visible', window.scrollY > 300)
}
scrollToTop(e) {
e.preventDefault()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
@@ -0,0 +1,28 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['slide']
static values = { index: { type: Number, default: 0 } }
connect() {
this.showSlide()
}
next() {
this.indexValue = (this.indexValue + 1) % this.slideTargets.length
}
prev() {
this.indexValue = (this.indexValue - 1 + this.slideTargets.length) % this.slideTargets.length
}
indexValueChanged() {
this.showSlide()
}
showSlide() {
this.slideTargets.forEach((slide, i) => {
slide.classList.toggle('hidden', i !== this.indexValue)
})
}
}
@@ -0,0 +1,31 @@
import { Controller } from '@hotwired/stimulus'
// 3dealer-side glue for the checkout module. The module owns the cart and emits
// `bbk-cart:updated` {count, total} on window after every change; this reflects
// the line count on the header bag icon. How (or whether) that count is shown
// is the host's call — hence this lives here, not in the module.
export default class extends Controller {
static targets = ['badge']
connect() {
this.onUpdate = (event) => this.render(event.detail?.count ?? 0)
window.addEventListener('bbk-cart:updated', this.onUpdate)
}
disconnect() {
window.removeEventListener('bbk-cart:updated', this.onUpdate)
}
// Header cart icon click — there's no separate cart page, the drawer IS
// the cart. `bbk-cart:open` is the module's own event, already listened
// for by bbk-cart-controller.
open() {
window.dispatchEvent(new CustomEvent('bbk-cart:open'))
}
render(count) {
if (!this.hasBadgeTarget) return
this.badgeTarget.textContent = String(count)
this.badgeTarget.hidden = count < 1
}
}
@@ -0,0 +1,27 @@
import { Controller } from '@hotwired/stimulus'
// Positions the popover panel directly under its trigger.
//
// A [popover] renders in the top layer, so its containing block is the
// viewport, not the .dropdown wrapper — CSS alone can't tie it to the trigger
// without anchor positioning, which isn't in every browser yet. So on each
// open we measure the trigger and write the geometry to CSS custom properties
// that .dropdown-panel consumes (top / left / min-width). The open/close
// animation stays entirely in CSS; the controller only feeds it three numbers.
export default class extends Controller {
static targets = ['trigger', 'panel']
// Wired to `click->dropdown#position` on the trigger, which also fires for
// keyboard activation (Enter/Space on a <button>), so this runs before the
// native popover toggle paints the panel.
position() {
const rect = this.triggerTarget.getBoundingClientRect()
const style = this.panelTarget.style
style.setProperty('--dropdown-top', `${rect.bottom + window.scrollY}px`)
style.setProperty('--dropdown-left', `${rect.left + window.scrollX}px`)
style.setProperty('--dropdown-width', `${rect.width}px`)
}
}
@@ -0,0 +1,24 @@
import { Controller } from '@hotwired/stimulus'
// Turbo doesn't scroll for <turbo-frame> navigations, so after the frame swaps
// its contents (a sort, filter, or pagination link) this brings the top of the
// frame back into view — otherwise clicking pagination at the bottom of the
// list leaves you stranded down there. The frame's own scroll-margin-top keeps
// it clear of the sticky header.
//
// `turbo:frame-render` fires only on a content swap, not on the initial page
// render, and the frame element itself persists across swaps — so the listener
// is bound once in connect().
export default class extends Controller {
connect() {
this.element.addEventListener('turbo:frame-render', this.#toTop)
}
disconnect() {
this.element.removeEventListener('turbo:frame-render', this.#toTop)
}
#toTop = () => {
this.element.scrollIntoView({ block: 'start', behavior: 'smooth' })
}
}
+36
View File
@@ -0,0 +1,36 @@
// Register all Stimulus controllers here.
// Example:
// import HelloController from './controllers/hello_controller';
// application.register('hello', HelloController);
import AppearController from './appear-controller'
import AutoSubmitController from './auto-submit-controller'
import BackToTopController from './back-to-top-controller'
import CartCountController from './cart-count-controller'
import CarouselController from './carousel-controller'
import DropdownController from './dropdown-controller'
import FrameScrollController from './frame-scroll-controller'
import NavSearchController from './nav-search-controller'
import ProductFormController from './product-form-controller'
import ProductGalleryController from './product-gallery-controller'
import QuantityController from './quantity-controller'
import RangeSliderController from './range-slider-controller'
import StarRatingController from './star-rating-controller'
import TabsController from './tabs-controller'
export function registerControllers(application) {
application.register('appear', AppearController)
application.register('auto-submit', AutoSubmitController)
application.register('back-to-top', BackToTopController)
application.register('cart-count', CartCountController)
application.register('carousel', CarouselController)
application.register('dropdown', DropdownController)
application.register('frame-scroll', FrameScrollController)
application.register('nav-search', NavSearchController)
application.register('product-form', ProductFormController)
application.register('product-gallery', ProductGalleryController)
application.register('quantity', QuantityController)
application.register('range-slider', RangeSliderController)
application.register('star-rating', StarRatingController)
application.register('tabs', TabsController)
}
@@ -0,0 +1,28 @@
import { Controller } from '@hotwired/stimulus'
// The search overlay is a [popover] that must sit exactly over the header. The
// header has no fixed height below `lg`, so on open we copy its current height
// onto --search-overlay-h and move focus into the field. Escape / click-away
// close come from the Popover API; the fade is CSS (#search-overlay).
export default class extends Controller {
static targets = ['input']
connect() {
this.element.addEventListener('toggle', this.#onToggle)
}
disconnect() {
this.element.removeEventListener('toggle', this.#onToggle)
}
#onToggle = (event) => {
if (event.newState !== 'open') return
const header = this.element.closest('header')
if (header) {
this.element.style.setProperty('--search-overlay-h', `${header.offsetHeight}px`)
}
requestAnimationFrame(() => this.inputTarget.focus())
}
}
@@ -0,0 +1,58 @@
import { Controller } from '@hotwired/stimulus'
import { formatPrice } from '../utils/format-price'
export default class extends Controller {
static targets = ['price', 'image', 'swatch', 'colorName']
static values = { variants: Array, selected: Number }
connect() {
const params = new URLSearchParams(window.location.search)
const urlId = parseInt(params.get('variant'))
const defaultId = this.variantsValue[0]?.id
this.selectedValue = urlId && this.variantsValue.find(v => v.id === urlId)
? urlId
: defaultId
}
selectVariant(event) {
const id = parseInt(event.currentTarget.dataset.variantId)
this.selectedValue = id
const url = new URL(window.location)
url.searchParams.set('variant', id)
window.history.pushState({}, '', url)
}
selectedValueChanged(id) {
if (!id) return
const variant = this.variantsValue.find(v => v.id === id)
if (!variant) return
if (this.hasPriceTarget && variant.price !== null) {
this.priceTarget.textContent = formatPrice(variant.price)
}
if (this.hasImageTarget && variant.image) {
this.imageTarget.src = variant.image
}
// Keep the checkout module's add-to-cart form pointed at the chosen
// variant. [data-bbk-purchasable-input] is that module's documented
// hook (see resources/views/checkout/components/add-to-cart.blade.php);
// this is the one place the two touch.
const purchasableInput = this.element.querySelector('[data-bbk-purchasable-input]')
if (purchasableInput) purchasableInput.value = id
this.swatchTargets.forEach(swatch => {
const isSelected = parseInt(swatch.dataset.variantId) === id
swatch.classList.toggle('is-selected', isSelected)
swatch.setAttribute('aria-pressed', String(isSelected))
if (isSelected && this.hasColorNameTarget) {
this.colorNameTarget.textContent = swatch.getAttribute('aria-label')
}
})
}
}
@@ -0,0 +1,116 @@
import { Controller } from "@hotwired/stimulus";
const SCROLL_STEP = 1;
export default class extends Controller {
static targets = ["main", "thumb", "track", "lightbox", "lightboxImage", "lightboxCounter"];
connect() {
this.offset = 0;
this.lightboxIndex = 0;
this.#syncHeight();
this.mainTarget.addEventListener("load", () => this.#syncHeight());
this._onKeydown = this.#handleKeydown.bind(this);
document.addEventListener("keydown", this._onKeydown);
}
disconnect() {
document.removeEventListener("keydown", this._onKeydown);
}
select(event) {
const btn = event.currentTarget;
this.#setThumb(btn);
this.lightboxIndex = this.thumbTargets.indexOf(btn);
}
scrollUp() {
this.offset = Math.max(0, this.offset - SCROLL_STEP);
this.#applyScroll();
}
scrollDown() {
this.offset = Math.min(this.thumbTargets.length - 1, this.offset + SCROLL_STEP);
this.#applyScroll();
}
closeLightboxOnBackdrop(event) {
// Only close if clicking directly on the backdrop (the popover div itself),
// not on the image, arrows, close button, or counter
if (event.target === this.lightboxTarget) {
this.lightboxTarget.hidePopover();
}
}
noop(event) {
event.stopPropagation();
}
openLightbox() {
this.#updateLightbox(this.lightboxIndex);
this.lightboxTarget.showPopover();
}
prevImage() {
const next = (this.lightboxIndex - 1 + this.thumbTargets.length) % this.thumbTargets.length;
this.#updateLightbox(next);
}
nextImage() {
const next = (this.lightboxIndex + 1) % this.thumbTargets.length;
this.#updateLightbox(next);
}
// ── Private ────────────────────────────────────────────────────
#updateLightbox(index) {
this.lightboxIndex = index;
const thumb = this.thumbTargets[index];
if (!thumb) return;
this.lightboxImageTarget.src = thumb.dataset.src;
this.lightboxImageTarget.alt = thumb.dataset.alt;
this.lightboxCounterTarget.textContent =
`${index + 1} of ${this.thumbTargets.length}`;
}
#setThumb(btn) {
const src = btn.dataset.src;
const alt = btn.dataset.alt;
this.mainTarget.src = src;
this.mainTarget.alt = alt;
this.thumbTargets.forEach((t) => {
const active = t === btn;
t.classList.toggle("border-2", active);
t.classList.toggle("border-1", !active);
t.setAttribute("aria-pressed", String(active));
});
}
#applyScroll() {
if (!this.thumbTargets.length) return;
const thumbHeight = this.thumbTargets[0].offsetHeight;
const gap = 16; // gap-4 = 16px
this.trackTarget.scrollTop = this.offset * (thumbHeight + gap);
}
#syncHeight() {
const h = this.mainTarget.offsetHeight;
if (h > 0) {
this.trackTarget.style.maxHeight = h + "px";
}
}
#handleKeydown(e) {
if (!this.hasLightboxTarget) return;
if (!this.lightboxTarget.matches(":popover-open")) return;
if (e.key === "ArrowLeft") { e.preventDefault(); this.prevImage(); }
if (e.key === "ArrowRight") { e.preventDefault(); this.nextImage(); }
if (e.key === "Escape") { this.lightboxTarget.hidePopover(); }
}
}
@@ -0,0 +1,39 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['input', 'decrement']
static values = { min: { type: Number, default: 1 } }
connect() {
this.#updateDecrement()
}
increment() {
this.#setValue(this.#current + 1)
}
decrement() {
this.#setValue(this.#current - 1)
}
clamp() {
this.#setValue(this.#current)
}
get #current() {
return parseInt(this.inputTarget.value, 10) || this.minValue
}
#setValue(val) {
const clamped = Math.max(this.minValue, val)
this.inputTarget.value = clamped
this.#updateDecrement()
this.inputTarget.dispatchEvent(new Event('quantity:change', { bubbles: true }))
}
#updateDecrement() {
const atMin = this.#current <= this.minValue
this.decrementTarget.disabled = atMin
this.decrementTarget.setAttribute('aria-disabled', atMin)
}
}
@@ -0,0 +1,166 @@
import { Controller } from '@hotwired/stimulus'
// Dual-thumb range slider.
//
// Two real <input type="range"> elements stay authoritative — they carry the
// value, the form data, native keyboard support and the no-JS fallback. On
// connect this controller hides their <label>s and mirrors their state onto a
// presentational track: a baseline, a filled span between the two carets, and
// the carets themselves, all positioned with the --min / --max percentage
// custom properties written on the track element.
//
// Pointer drag moves the carets (writing back to the inputs); the keyboard
// drives the inputs directly. Values can't cross — min stays one step below
// max and vice versa. Emits `range-slider:input` while dragging and
// `range-slider:change` on commit, both with { min, max }.
export default class extends Controller {
static targets = ['minInput', 'maxInput', 'field', 'track', 'minThumb', 'maxThumb', 'output']
static values = {
min: Number,
max: Number,
step: { type: Number, default: 1 },
prefix: { type: String, default: '' },
suffix: { type: String, default: '' },
separator: { type: String, default: ' – ' },
}
connect() {
this.#clamp()
this.fieldTargets.forEach((field) => field.classList.add('sr-only'))
this.#render()
}
disconnect() {
this.#stopDrag()
}
// ── keyboard / programmatic ──────────────────────────────────────
onInput(event) {
this.#clamp(this.#side(event.target))
this.#render()
this.#emit('input')
}
onChange(event) {
this.#clamp(this.#side(event.target))
this.#render()
this.#emit('change')
}
// The real inputs are visually hidden, so mirror their focus ring onto
// the matching caret to keep a visible focus indicator for keyboard use.
syncFocus(event) {
const thumb = event.target === this.minInputTarget ? this.minThumbTarget : this.maxThumbTarget
thumb.classList.toggle('ring-2', event.type === 'focus')
thumb.classList.toggle('ring-black', event.type === 'focus')
}
// ── pointer drag ────────────────────────────────────────────────────
thumbPointerDown(event) {
const input = event.currentTarget === this.minThumbTarget ? this.minInputTarget : this.maxInputTarget
this.#startDrag(event, input)
}
trackPointerDown(event) {
if (event.target.closest('button')) return // a caret handles its own press
const value = this.#valueAt(event.clientX)
const input = Math.abs(value - this.#lo) <= Math.abs(value - this.#hi)
? this.minInputTarget
: this.maxInputTarget
input.value = value
this.#clamp(this.#side(input))
this.#render()
this.#startDrag(event, input)
}
// ── internals ──────────────────────────────────────────────────────
#startDrag(event, input) {
event.preventDefault()
this.#stopDrag()
const side = this.#side(input)
this.#onMove = (e) => {
input.value = this.#valueAt(e.clientX)
this.#clamp(side)
this.#render()
this.#emit('input')
}
this.#onUp = () => {
this.#stopDrag()
this.#emit('change')
}
window.addEventListener('pointermove', this.#onMove)
window.addEventListener('pointerup', this.#onUp)
}
#side(input) {
return input === this.maxInputTarget ? 'max' : 'min'
}
#stopDrag() {
if (this.#onMove) window.removeEventListener('pointermove', this.#onMove)
if (this.#onUp) window.removeEventListener('pointerup', this.#onUp)
this.#onMove = this.#onUp = null
}
get #lo() { return Number(this.minInputTarget.value) }
get #hi() { return Number(this.maxInputTarget.value) }
// Keep both thumbs inside the group bounds and stop them crossing. When a
// thumb is being moved (`side`), only that one gives way, so the other
// stays put instead of being dragged along.
#clamp(side = null) {
const gap = this.stepValue
let lo = Math.max(this.minValue, Math.min(this.maxValue, Number(this.minInputTarget.value)))
let hi = Math.max(this.minValue, Math.min(this.maxValue, Number(this.maxInputTarget.value)))
if (side === 'max') hi = Math.max(hi, lo + gap)
else if (side === 'min') lo = Math.min(lo, hi - gap)
else if (lo > hi - gap) lo = hi - gap
this.minInputTarget.value = lo
this.maxInputTarget.value = hi
}
#valueAt(clientX) {
const rect = this.trackTarget.getBoundingClientRect()
const ratio = rect.width ? Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) : 0
const raw = this.minValue + ratio * (this.maxValue - this.minValue)
const step = this.stepValue
return Math.round(raw / step) * step
}
#percent(value) {
const span = this.maxValue - this.minValue
return span ? ((value - this.minValue) / span) * 100 : 0
}
#render() {
const lo = this.#lo
const hi = this.#hi
this.trackTarget.style.setProperty('--min', `${this.#percent(lo)}%`)
this.trackTarget.style.setProperty('--max', `${this.#percent(hi)}%`)
if (this.hasOutputTarget) {
const fmt = (v) => `${this.prefixValue}${v}${this.suffixValue}`
this.outputTarget.textContent = fmt(lo) + this.separatorValue + fmt(hi)
}
}
#emit(name) {
const detail = { min: this.#lo, max: this.#hi }
const key = `${detail.min},${detail.max}`
if (name === 'input' && key === this.#lastInputKey) return // no change since last frame
this.#lastInputKey = key
this.dispatch(name, { detail })
}
#onMove = null
#onUp = null
#lastInputKey = null
}
@@ -0,0 +1,31 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['star', 'input']
static values = { rating: { type: Number, default: 0 } }
hover(event) {
this.#fill(parseInt(event.currentTarget.dataset.value))
}
leave() {
this.#fill(this.ratingValue)
}
select(event) {
const val = parseInt(event.currentTarget.dataset.value)
this.ratingValue = val
this.inputTarget.value = val
this.starTargets.forEach(star => {
star.setAttribute('aria-pressed', String(parseInt(star.dataset.value) === val))
})
}
#fill(upTo) {
this.starTargets.forEach(star => {
const filled = parseInt(star.dataset.value) <= upTo
star.querySelector('svg').setAttribute('fill', filled ? 'currentColor' : 'none')
})
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['button', 'panel']
show(event) {
this.#activate(event.currentTarget.dataset.panel)
}
#activate(panelId) {
this.buttonTargets.forEach(btn => {
const active = btn.dataset.panel === panelId
btn.classList.toggle('is-active', active)
btn.setAttribute('aria-selected', String(active))
})
this.panelTargets.forEach(panel => {
panel.hidden = panel.id !== `tab-panel-${panelId}`
})
}
}
+19
View File
@@ -0,0 +1,19 @@
// Mirrors App\Support\Price::format() — keep both in sync. Greek formatting:
// comma as the decimal separator, dot as the thousands separator, and no
// decimals shown unless the amount actually needs them.
// 19.00 -> "€19", 19.50 -> "€19,5", 19.55 -> "€19,55"
export function formatPrice(amount, currency = '€') {
if (amount === null || amount === undefined) return null
const value = Math.round(parseFloat(amount) * 100) / 100
const cents = Math.round(value * 100)
const decimals = cents % 100 === 0 ? 0 : (cents % 10 === 0 ? 1 : 2)
const formatted = value.toLocaleString('el-GR', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})
return currency + formatted
}
+64
View File
@@ -0,0 +1,64 @@
// Make a refresh land back where you were — accurately.
//
// Turbo Drive is off site-wide (see app.js), so a refresh is a full browser
// load. The browser restores the scroll position early in that load — before
// the Manrope web fonts swap in and reflow the header, <h1> and result count
// above the product grid — so it settles a bit too low. We record the position
// ourselves and re-apply it once the layout has actually stopped moving.
//
// Separately: drop focus on the way out. Otherwise the browser re-focuses
// whatever filter control was active and scrolls it into view on reload, and
// that sidebar stacks below the grid on narrow screens — hence the jump to the
// bottom.
//
// The real fix for the drift is preloading the above-the-fold font weights so
// there's no reflow to chase; this keeps the restore correct until then, and
// harmless after.
const key = 'scrollY:' + location.pathname + location.search
let frame = 0
window.addEventListener(
'scroll',
() => {
if (frame) return
frame = requestAnimationFrame(() => {
frame = 0
try {
sessionStorage.setItem(key, String(Math.round(window.scrollY)))
} catch {}
})
},
{ passive: true },
)
window.addEventListener('pagehide', () => {
const el = document.activeElement
if (el && el !== document.body) el.blur()
})
// Only reloads and back/forward should resume a position; a fresh visit to the
// page starts where it naturally would.
const [nav] = performance.getEntriesByType('navigation')
if (nav && (nav.type === 'reload' || nav.type === 'back_forward')) {
let saved = null
try {
saved = sessionStorage.getItem(key)
} catch {}
if (saved !== null) {
const y = Number(saved)
const apply = () => window.scrollTo(0, y)
window.addEventListener(
'load',
() => {
apply()
// Fonts (and any late above-the-fold image) can still nudge
// layout a frame or two after load — re-apply once they settle.
document.fonts?.ready.then(() => requestAnimationFrame(apply))
},
{ once: true },
)
}
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Strip Greek tonos (accent marks) from text inside elements that have the
* `uppercase` CSS class.
*
* In Greek typography, capital letters do not carry the tonos accent.
* CSS `text-transform: uppercase` uppercases the glyph but leaves the tonos
* in place, producing visually incorrect output (e.g. Ά instead of Α).
* This utility rewrites the text nodes directly so the rendered result is clean.
*
* The diaeresis (ϊ, ϋ) is preserved — it is retained in Greek uppercase.
*
* Runs once on DOMContentLoaded and then watches for dynamically added nodes
* via MutationObserver.
*/
const ACCENT_MAP = {
// Lowercase with tonos → without tonos
'ά': 'α', 'έ': 'ε', 'ή': 'η', 'ί': 'ι', 'ό': 'ο', 'ύ': 'υ', 'ώ': 'ω',
// Uppercase with tonos → without tonos (for already-uppercased text)
'Ά': 'Α', 'Έ': 'Ε', 'Ή': 'Η', 'Ί': 'Ι', 'Ό': 'Ο', 'Ύ': 'Υ', 'Ώ': 'Ω',
// Combined tonos + diaeresis → diaeresis only (preserve the diaeresis)
'ΐ': 'ϊ', 'ΰ': 'ϋ',
}
const ACCENT_RE = /[άέήίόύώΆΈΉΊΌΎΏΐΰ]/g
function stripAccents(str) {
return str.replace(ACCENT_RE, c => ACCENT_MAP[c] ?? c)
}
function processElement(el) {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
let node
while ((node = walker.nextNode())) {
const val = node.nodeValue
if (val && /[άέήίόύώΆΈΉΊΌΎΏΐΰ]/.test(val)) {
node.nodeValue = stripAccents(val)
}
}
}
function applyToPage() {
document.querySelectorAll('.uppercase').forEach(processElement)
}
// ── Initial pass ──────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', applyToPage)
// ── Observe dynamic additions ─────────────────────────────────────────────────
const observer = new MutationObserver(mutations => {
for (const { addedNodes } of mutations) {
for (const node of addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue
if (node.classList?.contains('uppercase')) processElement(node)
node.querySelectorAll?.('.uppercase').forEach(processElement)
}
}
})
observer.observe(document.body, { childList: true, subtree: true })

Some files were not shown because too many files have changed in this diff Show More