Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7bd1efdaa |
@@ -8,7 +8,7 @@ APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=el
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
@@ -59,7 +59,6 @@ MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
CONTACT_EMAIL=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
|
||||
@@ -19,9 +19,6 @@
|
||||
/public/sitemap.xml
|
||||
/public/logos/core
|
||||
/public/storage
|
||||
/public/css/filament
|
||||
/public/js/filament
|
||||
/public/fonts/filament
|
||||
/storage/*.key
|
||||
/storage/framework/migrated
|
||||
/storage/pail
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
# 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.
|
||||
@@ -119,11 +119,13 @@ COPY docker/php/php.dev.ini /etc/php/8.5/cli/conf.d/99-app.ini
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# 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 composer.json composer.lock ./
|
||||
RUN composer install \
|
||||
--no-interaction \
|
||||
--no-scripts \
|
||||
--prefer-dist \
|
||||
--ignore-platform-reqs
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
COPY docker/entrypoint-worker.sh /entrypoint-worker.sh
|
||||
RUN chmod +x /entrypoint.sh /entrypoint-worker.sh
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# 3dealer
|
||||
|
||||
Storefront for 3dealer.gr: Laravel 12 + Lunar PHP (headless), on top of `boboko/core`. Front-end conventions are in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Scheduled jobs
|
||||
|
||||
These run in the `scheduler` container (`php artisan schedule:work`).
|
||||
|
||||
| Command | When | What it does |
|
||||
|---|---|---|
|
||||
| `custom-fields:prune-uploads` | Daily, 04:00 | Deletes custom-field photo uploads older than 24 h that no cart line or order line references. |
|
||||
| `lunar:search:index` | Daily, 03:00 | Full product reindex. Registered by `boboko/core`. |
|
||||
|
||||
## Product custom fields
|
||||
|
||||
Admins can add custom fields to a product in the Lunar admin (the product's **Custom Fields** section, from `boboko/core`). The customer fills them in on the product page before adding the product to the cart. The answers are stored on the cart line (`meta.custom_fields`) and carried over to the order line.
|
||||
|
||||
| Field type | Storefront input | Limit |
|
||||
|---|---|---|
|
||||
| Short text | text input | 255 characters |
|
||||
| Long text | textarea | 2,000 characters |
|
||||
| File upload | photo upload | JPG, PNG, WEBP, HEIC/HEIF, up to 10 MB |
|
||||
|
||||
- **Photos upload as soon as they're picked.** They go to `POST /{locale}/custom-field-uploads`, which is limited to 20 per minute per client. They're stored on the private `local` disk under `storage/app/private/custom-field-uploads/`. Only an encrypted reference is sent with add-to-cart. The allowed types and size are set in `App\Http\Controllers\CustomFieldUploadController`.
|
||||
- **Photos are never public.** The cart drawer, checkout summary and order confirmation link to a photo through a signed URL that expires after 2 hours.
|
||||
- **Emails show text answers only**, never photos.
|
||||
- **Photos are cleaned up automatically.** Photos never added to a cart are deleted by `custom-fields:prune-uploads` (see above). A photo on a cart line is kept as long as that cart line exists, and a photo on an order is kept indefinitely.
|
||||
- **Products with custom fields can't be quick-added.** On product cards, the "add to cart" button becomes a link to the product page.
|
||||
- Field labels are entered once in the admin and aren't translated, so they appear as entered in both `/el` and `/en`.
|
||||
@@ -1,43 +0,0 @@
|
||||
<?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, variantId: ?int, hasCustomFields: bool}
|
||||
*/
|
||||
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']]),
|
||||
// The card's quick "Add to cart" always adds this variant, same
|
||||
// default Modules\Core\Catalog\Services\ProductService::
|
||||
// variantSummaries() and product/show.blade.php both use — no
|
||||
// picker at listing-grid scope, unlike the product page's own
|
||||
// color swatches.
|
||||
'variantId' => $product['variants'][0]['id'] ?? null,
|
||||
// A product with custom fields (photo upload, engraving text…)
|
||||
// can't be quick-added from a card — the card links to the
|
||||
// product page instead, even when every field is optional.
|
||||
'hasCustomFields' => ! empty($product['custom_fields']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
<?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 = 40;
|
||||
|
||||
/** Category pages rarely have enough products to fill a page, so this
|
||||
* ceiling is high enough to act as "no pagination" in practice — the
|
||||
* pagination component self-hides via hasPages() when everything fits.
|
||||
* If a category ever exceeds it, pagination reappears as a safety net
|
||||
* rather than silently truncating results. */
|
||||
private const CATEGORY_PER_PAGE = 200;
|
||||
|
||||
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);
|
||||
$perPage = $collectionId !== null ? self::CATEGORY_PER_PAGE : self::PER_PAGE;
|
||||
|
||||
// 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: $perPage,
|
||||
page: $listing->page,
|
||||
)
|
||||
: $this->products->list(
|
||||
filters: $filters,
|
||||
perPage: $perPage,
|
||||
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])),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use Lunar\Models\Country;
|
||||
use Lunar\Models\State;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
use Modules\Core\Checkout\Services\CheckoutService;
|
||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||
use Modules\Core\Privacy\Services\PrivacyService;
|
||||
|
||||
/**
|
||||
* The profile page: name, invoice details, the one address, and account
|
||||
* deletion. The customer keeps a single address, flagged as both the
|
||||
* shipping and billing default, so allowing several later is a UI change
|
||||
* only (the data is already Lunar's own addresses table).
|
||||
*/
|
||||
class AccountController extends Controller
|
||||
{
|
||||
// Single-country store, same as CheckoutController::STORE_COUNTRY_ISO3.
|
||||
private const STORE_COUNTRY_ISO3 = 'GRC';
|
||||
|
||||
private const ADDRESS_FIELDS = ['line_one', 'city', 'postcode', 'state', 'contact_phone'];
|
||||
|
||||
public function __construct(
|
||||
private readonly CustomerAccountService $account,
|
||||
) {}
|
||||
|
||||
public function show(string $locale, Request $request): View
|
||||
{
|
||||
$user = $request->user();
|
||||
$customer = $this->account->customer($user);
|
||||
|
||||
return view('account.show', [
|
||||
'user' => $user,
|
||||
'customer' => $customer,
|
||||
'address' => $this->defaultAddress($user),
|
||||
'regions' => State::where('country_id', $this->storeCountry()->id)->orderBy('name')->get(['id', 'name']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(string $locale, Request $request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$country = $this->storeCountry();
|
||||
|
||||
// The address is all-or-nothing: typing any part of it makes the rest
|
||||
// (and the name, which Lunar requires on every address) required.
|
||||
$anyAddressField = implode(',', self::ADDRESS_FIELDS);
|
||||
|
||||
$data = $request->validate([
|
||||
'first_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||
'last_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||
'invoice' => ['boolean'],
|
||||
'company_name' => ['nullable', 'string', 'max:255', 'required_if_accepted:invoice'],
|
||||
'tax_identifier' => ['nullable', 'digits:9', 'required_if_accepted:invoice'],
|
||||
'line_one' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||
'city' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||
'postcode' => ['nullable', 'regex:/^\d{3}\s?\d{2}$/', 'required_with:'.$anyAddressField],
|
||||
'state' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'required_with:'.$anyAddressField,
|
||||
Rule::exists((new State)->getTable(), 'name')->where('country_id', $country->id),
|
||||
],
|
||||
'contact_phone' => ['nullable', 'string', 'max:30'],
|
||||
'recovery_consent' => ['boolean'],
|
||||
]);
|
||||
|
||||
$invoice = $request->boolean('invoice');
|
||||
|
||||
$this->account->updateProfile($user, [
|
||||
'first_name' => $data['first_name'] ?? null,
|
||||
'last_name' => $data['last_name'] ?? null,
|
||||
'company_name' => $invoice ? $data['company_name'] : null,
|
||||
'tax_identifier' => $invoice ? $data['tax_identifier'] : null,
|
||||
]);
|
||||
|
||||
if (filled($data['line_one'] ?? null)) {
|
||||
$addressData = [
|
||||
...collect($data)->only(self::ADDRESS_FIELDS)->all(),
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'country_id' => $country->id,
|
||||
'contact_email' => $user->email,
|
||||
'shipping_default' => true,
|
||||
'billing_default' => true,
|
||||
];
|
||||
|
||||
$existing = $this->defaultAddress($user);
|
||||
|
||||
$existing
|
||||
? $this->account->updateAddress($user, $existing->id, $addressData)
|
||||
: $this->account->createAddress($user, $addressData);
|
||||
}
|
||||
|
||||
$this->updateRecoveryConsent($user, $request->boolean('recovery_consent'));
|
||||
|
||||
return redirect()->route('account')->with('status', __('storefront.account.saved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* "Email me a reminder if I don't finish my order", as a standing
|
||||
* choice — stored on the customer via boboko-core's
|
||||
* CustomerAccountService::setRecoveryConsent(), and applied to the
|
||||
* current cart too, so opting out stops reminders for it right away.
|
||||
*/
|
||||
private function updateRecoveryConsent($user, bool $consent): void
|
||||
{
|
||||
$this->account->setRecoveryConsent($user, $consent);
|
||||
|
||||
// Only an existing cart; never create one just to record this.
|
||||
$cart = app(CartService::class)->current();
|
||||
|
||||
if ($cart && (bool) data_get($cart, 'meta.recovery_consent') !== $consent) {
|
||||
app(CheckoutService::class)->setRecoveryConsent($consent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-service deletion: opens core's 30-day grace-period erasure request
|
||||
* (which blocks the login right away) and logs out. Logging back in within
|
||||
* the grace period cancels it; see core's docs/privacy.md.
|
||||
*/
|
||||
public function destroy(string $locale, Request $request, PrivacyService $privacy): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$privacy->requestErasureForUser($user, $user);
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')->with('status', __('storefront.account.deletion_requested'));
|
||||
}
|
||||
|
||||
private function defaultAddress($user)
|
||||
{
|
||||
$addresses = collect($this->account->addresses($user));
|
||||
|
||||
return $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
|
||||
}
|
||||
|
||||
private function storeCountry(): Country
|
||||
{
|
||||
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->firstOrFail();
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Core\Auth\Exceptions\OtpThrottledException;
|
||||
use Modules\Core\Customer\Exceptions\EmailAlreadyTakenException;
|
||||
use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
|
||||
use Modules\Core\Customer\Services\CustomerEmailChangeService;
|
||||
|
||||
/**
|
||||
* Changing the login email — a thin wrapper over boboko-core's
|
||||
* Customer\Services\CustomerEmailChangeService, which owns the actual
|
||||
* request/confirm mechanics, throttling, pending-change storage, and
|
||||
* mailables. This controller's own job is just the storefront's session-
|
||||
* scoped "which email did I just ask to switch to" UI state (so the
|
||||
* .code/.resend pages know which address to show/resend to) and
|
||||
* translating the service's exceptions into the flash-message flow the
|
||||
* views expect.
|
||||
*/
|
||||
class EmailController extends Controller
|
||||
{
|
||||
private const SESSION_KEY = 'pending_email_change';
|
||||
|
||||
public function edit(string $locale, Request $request): View
|
||||
{
|
||||
return view('account.email', ['user' => $request->user()]);
|
||||
}
|
||||
|
||||
public function send(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$request->merge(['email' => Str::lower(trim((string) $request->input('email')))]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::notIn([$user->email]),
|
||||
],
|
||||
], [
|
||||
'email.not_in' => __('storefront.account.email_same'),
|
||||
]);
|
||||
|
||||
try {
|
||||
$emailChange->request($user, $validated['email']);
|
||||
} catch (EmailAlreadyTakenException) {
|
||||
return back()->withInput()->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||
} catch (OtpThrottledException) {
|
||||
return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]);
|
||||
}
|
||||
|
||||
$request->session()->put(self::SESSION_KEY, $validated['email']);
|
||||
|
||||
return redirect()->route('account.email.code');
|
||||
}
|
||||
|
||||
public function code(string $locale, Request $request): View|RedirectResponse
|
||||
{
|
||||
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||
|
||||
if (! $pendingEmail) {
|
||||
return redirect()->route('account.email.edit');
|
||||
}
|
||||
|
||||
return view('account.email-code', ['email' => $pendingEmail]);
|
||||
}
|
||||
|
||||
public function resend(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||
{
|
||||
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||
|
||||
if (! $pendingEmail) {
|
||||
return redirect()->route('account.email.edit');
|
||||
}
|
||||
|
||||
try {
|
||||
$emailChange->request($request->user(), $pendingEmail);
|
||||
} catch (EmailAlreadyTakenException) {
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
|
||||
return redirect()->route('account.email.edit')
|
||||
->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||
} catch (OtpThrottledException) {
|
||||
return back()->withErrors(['code' => __('storefront.auth.too_many_codes')]);
|
||||
}
|
||||
|
||||
return back()->with('status', __('storefront.auth.code_resent'));
|
||||
}
|
||||
|
||||
public function verify(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||
{
|
||||
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||
|
||||
if (! $pendingEmail) {
|
||||
return redirect()->route('account.email.edit');
|
||||
}
|
||||
|
||||
$validated = $request->validate(['code' => ['required', 'digits:6']]);
|
||||
|
||||
try {
|
||||
$emailChange->confirm($request->user(), $validated['code']);
|
||||
} catch (EmailAlreadyTakenException) {
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
|
||||
return redirect()->route('account.email.edit')
|
||||
->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||
} catch (InvalidEmailChangeCodeException) {
|
||||
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
|
||||
}
|
||||
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
|
||||
return redirect()->route('account')->with('status', __('storefront.account.email_changed'));
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
use Modules\Core\Customer\Exceptions\OrderNotFoundException;
|
||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||
|
||||
/**
|
||||
* Order history. Every lookup goes through CustomerAccountService, which only
|
||||
* ever returns the logged-in user's own placed orders, so an order id from
|
||||
* the URL can't reach someone else's order.
|
||||
*/
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CustomerAccountService $account,
|
||||
) {}
|
||||
|
||||
public function index(string $locale, Request $request): View
|
||||
{
|
||||
return view('account.orders.index', [
|
||||
'orders' => $this->account->orders($request->user(), 15),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $locale, Request $request, int $orderId): View
|
||||
{
|
||||
try {
|
||||
$order = $this->account->order($request->user(), $orderId);
|
||||
} catch (OrderNotFoundException) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// By type, like the checkout confirmation: the method may since have
|
||||
// been disabled, but the order still shows what was used.
|
||||
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
|
||||
->first()
|
||||
?->translate('name');
|
||||
|
||||
return view('account.orders.show', [
|
||||
'order' => $order,
|
||||
'paymentMethodName' => $paymentMethodName,
|
||||
'shipments' => $order->shipments->whereNull('cancelled_at')->whereNotNull('tracking_reference'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Core\Auth\Exceptions\OtpThrottledException;
|
||||
use Modules\Core\Auth\Services\UserOtpService;
|
||||
|
||||
/**
|
||||
* Passwordless customer login: email → emailed 6-digit code → logged in.
|
||||
* Login and registration are the same flow — UserOtpService::generateAndSend()
|
||||
* find-or-creates the user (and its Customer).
|
||||
*
|
||||
* All the security lives in UserOtpService: per-email code-request throttling
|
||||
* (OtpThrottledException), wrong-guess lockout, the Auth::login() itself (which
|
||||
* also regenerates the session and merges the guest cart via Lunar's Login
|
||||
* listener) and the session-registry record. This controller only moves the
|
||||
* shopper between the two steps, carrying the email in the session rather
|
||||
* than the URL.
|
||||
*/
|
||||
class LoginController extends Controller
|
||||
{
|
||||
/**
|
||||
* `?redirect=/el/checkout` (e.g. from the checkout's login tab) becomes the
|
||||
* intended URL that verify() returns to. Only a same-site path is accepted:
|
||||
* no scheme, no protocol-relative `//host`, so it can't redirect off-site.
|
||||
*/
|
||||
public function create(string $locale, Request $request): View
|
||||
{
|
||||
$redirect = (string) $request->query('redirect', '');
|
||||
|
||||
if (preg_match('#^/(?![/\\\\])#', $redirect)) {
|
||||
$request->session()->put('url.intended', url($redirect));
|
||||
}
|
||||
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function send(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
]);
|
||||
|
||||
$email = Str::lower(trim($validated['email']));
|
||||
|
||||
try {
|
||||
$otp->generateAndSend($email);
|
||||
} catch (OtpThrottledException) {
|
||||
return back()->withInput()->withErrors([
|
||||
'email' => __('storefront.auth.too_many_codes'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('login.email', $email);
|
||||
|
||||
return redirect()->route('login.code');
|
||||
}
|
||||
|
||||
public function code(string $locale, Request $request): View|RedirectResponse
|
||||
{
|
||||
$email = $request->session()->get('login.email');
|
||||
|
||||
if (! $email) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
return view('auth.login-code', ['email' => $email]);
|
||||
}
|
||||
|
||||
public function resend(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
||||
{
|
||||
$email = $request->session()->get('login.email');
|
||||
|
||||
if (! $email) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
try {
|
||||
$otp->generateAndSend($email);
|
||||
} catch (OtpThrottledException) {
|
||||
return back()->withErrors([
|
||||
'code' => __('storefront.auth.too_many_codes'),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('status', __('storefront.auth.code_resent'));
|
||||
}
|
||||
|
||||
public function verify(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
||||
{
|
||||
$email = $request->session()->get('login.email');
|
||||
|
||||
if (! $email) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'code' => ['required', 'digits:6'],
|
||||
]);
|
||||
|
||||
// Wrong, expired, or locked out after too many guesses — core doesn't
|
||||
// say which, so neither do we; the page offers "resend code" for all three.
|
||||
if (! $otp->validate($email, $validated['code'], $request)) {
|
||||
return back()->withErrors([
|
||||
'code' => __('storefront.auth.invalid_code'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->forget('login.email');
|
||||
|
||||
return redirect()->intended(route('home'));
|
||||
}
|
||||
|
||||
public function destroy(string $locale, Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('home');
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Checkout;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\CustomFieldUploadController;
|
||||
use Closure;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
use Lunar\Exceptions\Carts\CartException;
|
||||
use Lunar\Models\CartLine;
|
||||
use Lunar\Models\ProductVariant;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Modules\Core\Cart\Exceptions\InvalidCouponException;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
use Modules\Core\File\Models\File;
|
||||
use Modules\Core\File\Services\FileService;
|
||||
use Modules\Core\Localization\Services\LanguageCache;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* CartException here is Lunar's own add_to_cart validation pipeline
|
||||
* (CartLineQuantity/CartLineStock) rejecting the line — most commonly
|
||||
* "not enough stock at this quantity" for a tracked (purchasable =
|
||||
* in_stock) variant. Its own message is an untranslated, hardcoded
|
||||
* English string not meant for storefront display, so this returns our
|
||||
* own translated one instead rather than passing it through — a
|
||||
* storefront.* key rather than checkout.*, since this is a catalog/stock
|
||||
* concern the storefront owns, not something specific to the portable
|
||||
* checkout module.
|
||||
*/
|
||||
public function add(string $locale, Request $request): View|JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'purchasable_id' => ['required', 'integer'],
|
||||
'quantity' => ['nullable', 'integer', 'min:1'],
|
||||
'custom_fields' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$variant = ProductVariant::findOrFail($data['purchasable_id']);
|
||||
|
||||
try {
|
||||
$meta = $this->customFieldsMeta($variant, $data['custom_fields'] ?? []);
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json(['error' => collect($e->errors())->flatten()->first()], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->cart->addLine($variant, $data['quantity'] ?? 1, $meta);
|
||||
} catch (CartException) {
|
||||
return $this->stockError($variant);
|
||||
}
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
|
||||
/**
|
||||
* The shopper's answers to the product's custom fields (boboko-core's
|
||||
* Product::$custom_fields — {key, type: text|textarea|file, label,
|
||||
* required}), as cart line meta. Lunar copies CartLine.meta onto the
|
||||
* OrderLine at order creation, so this is also what the order keeps.
|
||||
*
|
||||
* Only keys the product actually defines are kept, nested under
|
||||
* `custom_fields` — line meta also carries behavior flags (core's
|
||||
* `saved_for_later` zeroes the line's price), so shopper input must never
|
||||
* be merged into it directly. Label and type are snapshotted alongside
|
||||
* each value so the cart/order still reads correctly if the product's
|
||||
* fields are edited later.
|
||||
*
|
||||
* A `file` answer is the id of a File row the host's own upload endpoint
|
||||
* (CustomFieldUploadController) already created via boboko-core's
|
||||
* FileService — never the file's bytes, disk, or path, all of which
|
||||
* FileService alone is the source of truth for. A shopper can't point
|
||||
* this at someone else's file: the id must resolve to a File that is
|
||||
* BOTH unowned (isFileAnswerValid()) and tagged with
|
||||
* CustomFieldUploadController::PURPOSE, so an id belonging to an
|
||||
* already-ordered file (owned, and/or a different purpose entirely) is
|
||||
* rejected. Attaching the File to the real CartLine it belongs to
|
||||
* happens afterward, in boboko-core's own Modules\Core\File\Listeners\
|
||||
* AttachCustomFieldFileToCartLine (listening for Cart\Events\
|
||||
* CartLineAdded) — not here, since this method only builds the meta
|
||||
* $this->cart->addLine() is about to receive, before any CartLine
|
||||
* actually exists to own anything.
|
||||
*
|
||||
* Two adds with identical answers merge into one line (Lunar matches
|
||||
* existing lines on meta); different answers stay separate lines.
|
||||
*/
|
||||
private function customFieldsMeta(ProductVariant $variant, array $input): array
|
||||
{
|
||||
$fields = collect($variant->product?->custom_fields ?? [])
|
||||
->keyBy('key')
|
||||
->map(fn (array $field) => [...$field, 'label' => $this->resolveLabel($field['label'])]);
|
||||
|
||||
if ($fields->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$validated = Validator::make(
|
||||
$input,
|
||||
$fields->map(fn (array $field) => [
|
||||
($field['required'] ?? false) ? 'required' : 'nullable',
|
||||
...match ($field['type']) {
|
||||
'textarea' => ['string', 'max:2000'],
|
||||
'file' => [function (string $attribute, mixed $value, Closure $fail) {
|
||||
if (! $this->isFileAnswerValid($value)) {
|
||||
$fail('validation.uploaded')->translate();
|
||||
}
|
||||
}],
|
||||
default => ['string', 'max:255'],
|
||||
},
|
||||
])->all(),
|
||||
[],
|
||||
$fields->map(fn (array $field) => $field['label'])->all(),
|
||||
)->validate();
|
||||
|
||||
$answers = $fields
|
||||
->filter(fn (array $field) => filled($validated[$field['key']] ?? null))
|
||||
->map(fn (array $field) => [
|
||||
'key' => $field['key'],
|
||||
'label' => $field['label'],
|
||||
'type' => $field['type'],
|
||||
...($field['type'] === 'file'
|
||||
? ['file_id' => (int) $validated[$field['key']]]
|
||||
: ['value' => $validated[$field['key']]]),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $answers === [] ? [] : ['custom_fields' => $answers];
|
||||
}
|
||||
|
||||
/**
|
||||
* Product::$custom_fields stores `label` as {locale: string} (see
|
||||
* boboko-core's Catalog\Filament\Pages\ManageProductCustomFields) — this
|
||||
* resolves it to the single current-locale string cart/order line meta
|
||||
* actually needs, the same filled()-over-?? fallback boboko-core's
|
||||
* ProductDocumentLocalizer uses for every other translated field (an
|
||||
* empty string for the current locale still falls through to the
|
||||
* store's default language, rather than showing blank). A product
|
||||
* saved before labels became translatable still has a plain string
|
||||
* here, returned as-is.
|
||||
*/
|
||||
private function resolveLabel(mixed $label): string
|
||||
{
|
||||
if (! is_array($label)) {
|
||||
return (string) $label;
|
||||
}
|
||||
|
||||
$locale = App::getLocale();
|
||||
$fallbackLocale = app(LanguageCache::class)->defaultLocale();
|
||||
|
||||
return filled($label[$locale] ?? null)
|
||||
? $label[$locale]
|
||||
: ($label[$fallbackLocale] ?? '');
|
||||
}
|
||||
|
||||
private function isFileAnswerValid(mixed $fileId): bool
|
||||
{
|
||||
$file = File::find($fileId);
|
||||
|
||||
return $file !== null
|
||||
&& $file->purpose === CustomFieldUploadController::PURPOSE
|
||||
&& $file->owner_id === null
|
||||
&& app(FileService::class)->exists($file);
|
||||
}
|
||||
|
||||
public function updateLine(string $locale, Request $request, int $line): View|JsonResponse
|
||||
{
|
||||
$quantity = (int) $request->validate([
|
||||
'quantity' => ['required', 'integer', 'min:0'],
|
||||
])['quantity'];
|
||||
|
||||
try {
|
||||
$quantity === 0
|
||||
? $this->cart->removeLine($line)
|
||||
: $this->cart->updateLine($line, $quantity);
|
||||
} catch (CartException) {
|
||||
$variant = CartLine::find($line)?->purchasable;
|
||||
|
||||
return $this->stockError($variant instanceof ProductVariant ? $variant : null);
|
||||
}
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
|
||||
/**
|
||||
* getTotalInventory() is the same number canBeFulfilledAtQuantity()
|
||||
* checked against (stock, for a tracked in_stock variant) — telling the
|
||||
* shopper how many are actually left beats a generic "not enough stock"
|
||||
* they'd otherwise have to guess around by trial and error.
|
||||
*/
|
||||
private function stockError(?ProductVariant $variant): JsonResponse
|
||||
{
|
||||
$available = $variant?->getTotalInventory() ?? 0;
|
||||
|
||||
return response()->json([
|
||||
'error' => trans_choice('storefront.product.add_to_cart_failed', $available, ['count' => $available]),
|
||||
], 422);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,676 +0,0 @@
|
||||
<?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\Auth;
|
||||
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\TermsNotAcceptedException;
|
||||
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
||||
use Modules\Core\Checkout\Services\CheckoutService;
|
||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Guests type their email; the login tab links to the storefront's own
|
||||
* `login` route and back. Logged in: the email is the account's (forced in
|
||||
* saveAddress()), the first visit prefills addresses from the account
|
||||
* (prefillFromAccount()), and Lunar's Login listener has already attached the
|
||||
* cart, so the placed order lands in the account's history.
|
||||
*
|
||||
* 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,
|
||||
private readonly CustomerAccountService $account,
|
||||
) {}
|
||||
|
||||
public function show(string $locale): View
|
||||
{
|
||||
$cart = $this->cart->current();
|
||||
$lines = $cart ? $this->cart->activeLines($cart) : collect();
|
||||
$storeCountry = $this->storeCountry();
|
||||
|
||||
$shippingOptions = collect();
|
||||
|
||||
// Captured before prefillFromAccount(), which may recreate the address
|
||||
// row (dropping its shipping_option) — same reason as in saveAddress().
|
||||
$previousOption = $cart?->shippingAddress?->shipping_option;
|
||||
|
||||
if ($cart && Auth::check()) {
|
||||
$cart = $this->prefillFromAccount($cart);
|
||||
|
||||
// Nothing chosen on this cart yet: carry over the account's standing
|
||||
// opt-in (an explicit earlier choice, recorded with its own
|
||||
// timestamp/policy version). Never opts anyone in by default.
|
||||
if (! array_key_exists('recovery_consent', $cart->meta?->toArray() ?? [])
|
||||
&& data_get($this->account->customer(Auth::user()), 'meta.recovery_consent')) {
|
||||
$cart = $this->checkout->setRecoveryConsent(true);
|
||||
}
|
||||
}
|
||||
|
||||
if ($cart?->shippingAddress) {
|
||||
$shippingOptions = $this->syncShipping($cart, $previousOption);
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
$paymentMethods = $this->checkout->getPaymentMethods();
|
||||
|
||||
// Nothing checked yet (fresh cart), or the shopper's earlier pick is
|
||||
// no longer offered (method disabled/removed since) — auto-select
|
||||
// the first one, same as a manual click would, so the payment
|
||||
// section (and the Stripe Element mounting under it) isn't sitting
|
||||
// inert behind an unchecked radio. A still-valid previous choice is
|
||||
// left alone.
|
||||
$firstMethod = $paymentMethods->first();
|
||||
|
||||
if ($cart && $firstMethod && ! $paymentMethods->contains('type', data_get($cart, 'meta.payment_method'))) {
|
||||
$cart = $this->checkout->selectPaymentMethod($firstMethod->type);
|
||||
}
|
||||
|
||||
return view('checkout::page', [
|
||||
'cart' => $cart,
|
||||
'lines' => $lines,
|
||||
'billingAddress' => $cart?->billingAddress,
|
||||
'shippingAddress' => $cart?->shippingAddress,
|
||||
'shippingOptions' => $shippingOptions,
|
||||
'paymentMethods' => $paymentMethods,
|
||||
'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true),
|
||||
'wantsInvoice' => (bool) data_get($cart, 'meta.wants_invoice', false),
|
||||
'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_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_line_one' => ['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();
|
||||
|
||||
// Logged in: the order email is always the account's. It isn't a field
|
||||
// on the page then, and a submitted value isn't trusted.
|
||||
if ($user = Auth::user()) {
|
||||
$data['contact_email'] = $user->email;
|
||||
}
|
||||
|
||||
$billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null);
|
||||
$shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId);
|
||||
|
||||
// Company/ΑΦΜ only count when "I want an invoice" is ticked; the fields
|
||||
// stay in the DOM (just hidden) when it isn't, so ignore what they send.
|
||||
$wantsInvoice = $request->boolean('wants_invoice');
|
||||
|
||||
$billing = [
|
||||
'first_name' => $data['billing_first_name'] ?? null,
|
||||
'last_name' => $data['billing_last_name'] ?? null,
|
||||
'company_name' => $wantsInvoice ? ($data['billing_company_name'] ?? null) : null,
|
||||
'tax_identifier' => $wantsInvoice ? ($data['billing_tax_identifier'] ?? null) : null,
|
||||
'line_one' => $data['billing_line_one'] ?? 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
|
||||
? [
|
||||
...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]),
|
||||
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
|
||||
]
|
||||
: [
|
||||
'first_name' => $data['shipping_first_name'] ?? null,
|
||||
'last_name' => $data['shipping_last_name'] ?? null,
|
||||
'line_one' => $data['shipping_line_one'] ?? 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,
|
||||
'wants_invoice' => $wantsInvoice,
|
||||
];
|
||||
$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'));
|
||||
|
||||
if (Auth::check()) {
|
||||
$this->account->setRecoveryConsent(Auth::user(), $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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// Captured now, before initiatePayment() can place the order — Lunar's
|
||||
// CartSessionManager::fetchOrCreate() silently swaps the session onto a
|
||||
// BRAND NEW empty cart the moment the current one hasCompletedOrders()
|
||||
// (i.e. has an order with placed_at set), which happens synchronously
|
||||
// for an immediately-captured payment. Any later $this->cart->current()
|
||||
// call in this same flow (here, or in a subsequent orderStatus() poll
|
||||
// once the 3-D Secure webhook sets placed_at) would then resolve to
|
||||
// that fresh, order-less cart instead of the one that was just placed.
|
||||
// Storing the real cart id ourselves, under our own session key,
|
||||
// sidesteps CartSession entirely for the rest of the placement flow.
|
||||
session(['checkout.cart_id' => $cart?->id]);
|
||||
|
||||
// Lunar's own ValidateCartForOrderCreation (order_create validator)
|
||||
// never checks for this — an empty cart with a valid billing address
|
||||
// sails straight through it and would place a real, zero-line order.
|
||||
// The disabled "place order" button is only the client-side half of
|
||||
// this fix; this is the half that actually matters.
|
||||
if ($cart === null || $this->cart->activeLines($cart)->isEmpty()) {
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.cart_empty'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Lenient autosave never requires these; this is the gate.
|
||||
if (data_get($cart, 'meta.wants_invoice')
|
||||
&& (blank($cart->billingAddress?->company_name) || blank($cart->billingAddress?->tax_identifier))) {
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.invoice_required'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Same check Lunar's own ValidateCartForOrderCreation runs inside
|
||||
// initiatePayment() (a product unpublished/deleted after it was
|
||||
// added to the cart) — checked here first so the shopper is told
|
||||
// which product is the problem, rather than falling into the
|
||||
// catch-all "complete your billing/shipping details" message below,
|
||||
// which is what actually happened and is generic to every
|
||||
// CartException reason, misleading when the real cause is a line,
|
||||
// not an address.
|
||||
$unavailableLines = $this->cart->activeLines($cart)->filter(
|
||||
fn ($line) => ! $line->purchasable || ! $line->purchasable->isPurchasable(),
|
||||
);
|
||||
|
||||
if ($unavailableLines->isNotEmpty()) {
|
||||
$names = $unavailableLines
|
||||
->map(fn ($line) => $line->purchasable?->product?->translateAttribute('name') ?? $line->purchasable?->getIdentifier())
|
||||
->filter()
|
||||
->implode(', ');
|
||||
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.cart_line_unavailable', ['name' => $names]),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Pending with no continuation (cash-on-delivery, or any other
|
||||
// deferred/offline method) means CheckoutService::initiatePayment()
|
||||
// already created the placed order — money just hasn't changed
|
||||
// hands yet. Only a Pending WITH a continuation (Stripe's client
|
||||
// secret) means the shopper still has something to do before the
|
||||
// order exists as far as the storefront is concerned.
|
||||
return match (true) {
|
||||
$result->status === PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale),
|
||||
$result->status === PaymentResultStatus::Pending && $result->continuation === null => $this->orderPlacedResponse($locale),
|
||||
$result->status === PaymentResultStatus::Pending => response()->json([
|
||||
'status' => 'pending',
|
||||
'clientSecret' => $result->continuation?->value,
|
||||
]),
|
||||
default => 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.purchasable.product', 'shippingAddress', 'billingAddress'])->find($orderId)
|
||||
: null;
|
||||
|
||||
if (! $order) {
|
||||
return redirect()->route('products', $locale);
|
||||
}
|
||||
|
||||
// Looked up by type rather than a stored relation — the method may since
|
||||
// have been disabled/deleted, but the order still needs to show what was
|
||||
// actually used at the time.
|
||||
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
|
||||
->first()
|
||||
?->translate('name');
|
||||
|
||||
return view('checkout::confirmation', [
|
||||
'order' => $order,
|
||||
'paymentMethodName' => $paymentMethodName,
|
||||
]);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$cartId = session('checkout.cart_id');
|
||||
|
||||
if ($cartId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Order::where('cart_id', $cartId)
|
||||
->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(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logged-in shopper: fills any BLANK cart address field from the account
|
||||
* (name, saved default address, phone, email), on every checkout load, so
|
||||
* an account filled in after checkout started still shows up. Never
|
||||
* overwrites anything already in the cart.
|
||||
*
|
||||
* Company/ΑΦΜ (and ticking "I want an invoice") only on the first pass
|
||||
* (meta.account_prefilled): someone who then clears them or unticks the
|
||||
* box for this order shouldn't get them back on the next reload.
|
||||
*
|
||||
* Writes only when something actually changes, so a normal reload costs
|
||||
* nothing extra.
|
||||
*/
|
||||
private function prefillFromAccount(Cart $cart): Cart
|
||||
{
|
||||
$user = Auth::user();
|
||||
$customer = $this->account->customer($user);
|
||||
$addresses = collect($this->account->addresses($user));
|
||||
$saved = $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
|
||||
$firstPass = ! data_get($cart, 'meta.account_prefilled');
|
||||
|
||||
$fromAccount = array_filter([
|
||||
'first_name' => $customer?->first_name ?: $saved?->first_name,
|
||||
'last_name' => $customer?->last_name ?: $saved?->last_name,
|
||||
'line_one' => $saved?->line_one,
|
||||
'city' => $saved?->city,
|
||||
'state' => $saved?->state,
|
||||
'postcode' => $saved?->postcode,
|
||||
'country_id' => $this->storeCountry()?->id ?? $saved?->country_id,
|
||||
'contact_email' => $user->email,
|
||||
'contact_phone' => $saved?->contact_phone,
|
||||
], 'filled');
|
||||
|
||||
$invoice = $firstPass
|
||||
? array_filter([
|
||||
'company_name' => $customer?->company_name,
|
||||
'tax_identifier' => $customer?->tax_identifier,
|
||||
], 'filled')
|
||||
: [];
|
||||
|
||||
$fields = ['first_name', 'last_name', 'company_name', 'tax_identifier', 'line_one', 'city',
|
||||
'state', 'postcode', 'country_id', 'contact_email', 'contact_phone'];
|
||||
|
||||
$fillBlanks = function (?array $current, array $values) {
|
||||
$current ??= [];
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
if (blank($current[$key] ?? null)) {
|
||||
$current[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $current;
|
||||
};
|
||||
|
||||
$billingBefore = $cart->billingAddress?->only($fields);
|
||||
$billing = $fillBlanks($billingBefore, [...$fromAccount, ...$invoice]);
|
||||
|
||||
// Shipping has no company/ΑΦΜ (same shape saveAddress() writes).
|
||||
$shipToBilling = (bool) data_get($cart, 'meta.ship_to_billing', true);
|
||||
$shippingFields = [...array_diff($fields, ['company_name', 'tax_identifier']), 'delivery_instructions'];
|
||||
|
||||
$shippingBefore = $cart->shippingAddress?->only($shippingFields);
|
||||
$shipping = $shipToBilling
|
||||
? [
|
||||
...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]),
|
||||
'delivery_instructions' => $shippingBefore['delivery_instructions'] ?? null,
|
||||
]
|
||||
: $fillBlanks($shippingBefore, $fromAccount);
|
||||
|
||||
if ($billing != ($billingBefore ?? []) || $shipping != ($shippingBefore ?? [])) {
|
||||
$this->checkout->setBillingAddress($billing);
|
||||
$cart = $this->checkout->setShippingAddress($shipping);
|
||||
}
|
||||
|
||||
if ($firstPass) {
|
||||
$cart->meta = [
|
||||
...($cart->meta?->toArray() ?? []),
|
||||
'account_prefilled' => true,
|
||||
'wants_invoice' => (bool) data_get($cart, 'meta.wants_invoice') || $invoice !== [],
|
||||
];
|
||||
$cart->save();
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
private function storeCountry(): ?Country
|
||||
{
|
||||
if (self::STORE_COUNTRY_ISO3 === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->first();
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ContactRequest;
|
||||
use App\Mail\ContactConfirmationMail;
|
||||
use App\Mail\ContactMessageMail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Contact form. Nothing is stored: the message is emailed to the store
|
||||
* (CONTACT_EMAIL) and the sender gets a generic confirmation. Both are sent
|
||||
* synchronously so a failed store email can be reported back on the form.
|
||||
*
|
||||
* Limited per IP in here rather than with throttle middleware, so the limit
|
||||
* shows as a message on the form instead of a bare 429 page.
|
||||
*/
|
||||
class ContactController extends Controller
|
||||
{
|
||||
private const SEND_LIMIT = 3;
|
||||
private const SEND_DECAY_SECONDS = 600;
|
||||
|
||||
public function index(string $locale): View
|
||||
{
|
||||
return view('contact');
|
||||
}
|
||||
|
||||
public function send(string $locale, ContactRequest $request): RedirectResponse
|
||||
{
|
||||
$key = 'contact:'.$request->ip();
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, self::SEND_LIMIT)) {
|
||||
return back()->withInput()->with('contact_error', __('storefront.contact.too_many'));
|
||||
}
|
||||
|
||||
RateLimiter::hit($key, self::SEND_DECAY_SECONDS);
|
||||
|
||||
$data = $request->validated();
|
||||
$to = config('services.contact.email');
|
||||
|
||||
try {
|
||||
if (blank($to)) {
|
||||
throw new \RuntimeException('CONTACT_EMAIL is not set.');
|
||||
}
|
||||
|
||||
Mail::to($to)->send(new ContactMessageMail(
|
||||
$data['name'],
|
||||
$data['email'],
|
||||
$data['message'],
|
||||
$locale,
|
||||
));
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Contact form: store email failed', ['exception' => $e]);
|
||||
|
||||
return back()->withInput()->with('contact_error', __('storefront.contact.send_failed'));
|
||||
}
|
||||
|
||||
// The store already has the message, so a failed confirmation is only logged.
|
||||
try {
|
||||
Mail::to($data['email'])->locale($locale)->send(new ContactConfirmationMail);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Contact form: confirmation email failed', ['exception' => $e]);
|
||||
}
|
||||
|
||||
return redirect()->route('contact')->with('status', __('storefront.contact.sent'));
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Core\File\Http\Controllers\UploadFileController;
|
||||
|
||||
/**
|
||||
* Stores the shopper's photo for a product custom field of type `file` (see
|
||||
* boboko-core's Product::$custom_fields) the moment it's picked on the product
|
||||
* page — before add-to-cart, see custom-field-upload-controller.js.
|
||||
*
|
||||
* Which files are acceptable (extensions, size) is a per-site decision — this
|
||||
* site: photographs — so it lives here as this app's own policy, extending
|
||||
* boboko-core's Modules\Core\File\Http\Controllers\UploadFileController for
|
||||
* the actual store()/validate()/respond() mechanics. The module's add-to-cart
|
||||
* endpoint only ever receives the stored File row's own `id` — FileService is
|
||||
* the single source of truth for disk/path/name/mime, never duplicated into
|
||||
* cart/order line meta (see Checkout\CartController::customFieldsMeta()). A
|
||||
* shopper can't point a cart line at someone else's file: CartController only
|
||||
* accepts an id that is both unowned and tagged with this exact PURPOSE.
|
||||
*
|
||||
* Stored on the private `local` disk: these are customers' personal photos,
|
||||
* never reachable by a public URL except through FileService's own signed
|
||||
* download route. Uploads nobody adds to a cart are removed by core's
|
||||
* `boboko:file:prune-unowned custom-field-upload` command.
|
||||
*/
|
||||
class CustomFieldUploadController extends UploadFileController
|
||||
{
|
||||
public const PURPOSE = 'custom-field-upload';
|
||||
|
||||
public const MAX_KILOBYTES = 10240;
|
||||
|
||||
public const EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'];
|
||||
|
||||
/**
|
||||
* For the file input's `accept` attribute — same list the server enforces.
|
||||
*/
|
||||
public static function accept(): string
|
||||
{
|
||||
return '.'.implode(',.', self::EXTENSIONS);
|
||||
}
|
||||
|
||||
protected function purpose(): string
|
||||
{
|
||||
return self::PURPOSE;
|
||||
}
|
||||
|
||||
protected function validationRules(Request $request): array
|
||||
{
|
||||
return [
|
||||
'file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES],
|
||||
];
|
||||
}
|
||||
|
||||
// `label` is the admin-authored field label, only used as the
|
||||
// :attribute in the validation message shown next to that field.
|
||||
protected function validationAttributes(Request $request): array
|
||||
{
|
||||
return ['file' => (string) $request->input('label', 'file')];
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Catalog\ProductListing;
|
||||
use App\Catalog\ProductListingPage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Lunar\Models\Product;
|
||||
use Lunar\Models\ProductVariant;
|
||||
use Modules\Core\Catalog\Services\ProductService;
|
||||
use Modules\Core\Review\Models\ProductReview;
|
||||
|
||||
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);
|
||||
|
||||
$product = $this->mergeJustSubmittedReview($product);
|
||||
|
||||
$collection = $product['collections'][0] ?? null;
|
||||
// dd($product);
|
||||
[$productOptions, $variantsData] = $this->buildOptionPicker($id);
|
||||
|
||||
return view('product.show', [
|
||||
'collection' => $collection,
|
||||
'product' => $product,
|
||||
'productOptions' => $productOptions,
|
||||
'variantsData' => $variantsData,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One button/swatch group per product option (a product can have several
|
||||
* — e.g. a custom-photo product with size + style + person-count, each
|
||||
* combination resolved client-side to one exact ProductVariant, see
|
||||
* product-form-controller.js) plus the per-variant data the picker
|
||||
* resolves a selection against.
|
||||
*
|
||||
* Reads live Eloquent rather than the Meilisearch-indexed $product array
|
||||
* the rest of the page uses (same reasoning as checkStock() below): the
|
||||
* index has no concept of option/value display order (Lunar's
|
||||
* `position` column) or a stable value id, both of which the picker
|
||||
* needs — to render values in the merchant's intended order, and to
|
||||
* match a combination back to one exact variant without relying on
|
||||
* translated label strings staying unique.
|
||||
*
|
||||
* @return array{0: array, 1: array}
|
||||
*/
|
||||
private function buildOptionPicker(int $productId): array
|
||||
{
|
||||
$variants = ProductVariant::query()
|
||||
->where('product_id', $productId)
|
||||
->with(['values.option', 'prices', 'images'])
|
||||
->get();
|
||||
|
||||
$productOptions = $variants
|
||||
->flatMap(fn (ProductVariant $variant) => $variant->values)
|
||||
->unique('id')
|
||||
->groupBy(fn ($value) => $value->option->handle)
|
||||
->map(function ($values, $handle) {
|
||||
$sorted = $values->sortBy([['position', 'asc'], ['id', 'asc']]);
|
||||
|
||||
return [
|
||||
'handle' => $handle,
|
||||
'label' => $sorted->first()->option->translate('name'),
|
||||
// The "Color" option type is the only one that writes a
|
||||
// hex code into meta (see boboko/core's ColorOptionType)
|
||||
// — its presence is how we tell a color option (swatches)
|
||||
// from any other option (buttons).
|
||||
'isColor' => $sorted->contains(fn ($v) => !empty($v->meta['hex'] ?? null)),
|
||||
'values' => $sorted->map(fn ($v) => [
|
||||
'id' => $v->id,
|
||||
'label' => $v->translate('name'),
|
||||
'hex' => $v->meta['hex'] ?? null,
|
||||
])->values()->all(),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$variantsData = $variants->map(fn (ProductVariant $variant) => [
|
||||
'id' => $variant->id,
|
||||
'price' => $variant->prices->first()?->price?->decimal(),
|
||||
'image' => $variant->images->first()?->getUrl(),
|
||||
// handle => selected value id, for matching a combination of
|
||||
// selections back to this variant — see selectVariant() in
|
||||
// product-form-controller.js.
|
||||
'options' => $variant->values->mapWithKeys(fn ($v) => [$v->option->handle => $v->id])->all(),
|
||||
])->values()->all();
|
||||
|
||||
return [$productOptions, $variantsData];
|
||||
}
|
||||
|
||||
/**
|
||||
* Meilisearch's own write API is itself async — addDocuments() enqueues an
|
||||
* indexing task and returns immediately, and Laravel\Scout\Engines\
|
||||
* MeilisearchEngine::update() never waits on that task, so even
|
||||
* $product->searchableSync() (which only skips OUR queue) can still land
|
||||
* the shopper back on this page before Meilisearch has actually processed
|
||||
* the write. storeReview() flashes the review it just created for exactly
|
||||
* this one next request; splice it in here rather than trust the index is
|
||||
* already caught up. Guarded by id so a race the other way — the index
|
||||
* DID catch up in time — doesn't show the same review twice.
|
||||
*/
|
||||
private function mergeJustSubmittedReview(array $product): array
|
||||
{
|
||||
$justSubmitted = session('justSubmittedReview');
|
||||
|
||||
if (! $justSubmitted || (string) ($justSubmitted['product_id'] ?? null) !== (string) $product['id']) {
|
||||
return $product;
|
||||
}
|
||||
|
||||
$items = $product['reviews']['items'] ?? [];
|
||||
|
||||
if (collect($items)->contains('id', $justSubmitted['id'])) {
|
||||
return $product;
|
||||
}
|
||||
|
||||
$items = [$justSubmitted, ...$items];
|
||||
|
||||
$product['reviews']['items'] = $items;
|
||||
$product['reviews']['count'] = count($items);
|
||||
$product['reviews']['average_rating'] = round(collect($items)->avg('rating'), 1);
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* A storefront-owned, checkout-module-independent stock check — the
|
||||
* product page's "Add to cart" calls this first and only submits to the
|
||||
* checkout module's own add-to-cart endpoint once this says `ok`. Reads
|
||||
* the live Eloquent ProductVariant directly (not the Meilisearch index
|
||||
* ProductService otherwise reads from, which can lag behind an actual
|
||||
* sale until the next reindex) via the SAME method Lunar's own
|
||||
* CartLineStock validator calls, so this can never disagree with what
|
||||
* the module's own server-side check would decide.
|
||||
*/
|
||||
public function checkStock(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'variant' => ['required', 'integer'],
|
||||
'quantity' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
$variant = ProductVariant::find($data['variant']);
|
||||
$quantity = $data['quantity'] ?? 1;
|
||||
|
||||
if ($variant === null) {
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => $variant->canBeFulfilledAtQuantity($quantity),
|
||||
'stock' => $variant->purchasable === 'always' ? null : $variant->getTotalInventory(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* boboko/core's product_reviews table has no moderation/status column, so
|
||||
* this goes live immediately — no approval queue to land in.
|
||||
*/
|
||||
public function storeReview(string $locale, Request $request, Product $product): RedirectResponse
|
||||
{
|
||||
$reviewsUrl = route('product.show', [
|
||||
'locale' => $locale,
|
||||
'id' => $product->id,
|
||||
'tab' => 'reviews',
|
||||
]).'#product-tabs';
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'rating' => ['required', 'integer', 'between:1,5'],
|
||||
'content' => ['required', 'string'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return redirect($reviewsUrl)->withErrors($validator)->withInput();
|
||||
}
|
||||
|
||||
$data = $validator->validated();
|
||||
|
||||
// Not $product->reviews()->create(...): that relation only exists via a
|
||||
// Product::macro() registered in CorePlugin::register(Panel $panel), which
|
||||
// Filament calls solely when the /boboko admin panel boots — never on a
|
||||
// plain storefront request, where the macro is simply undefined.
|
||||
$review = ProductReview::create([
|
||||
'product_id' => $product->id,
|
||||
'rating' => $data['rating'],
|
||||
'body' => $data['content'],
|
||||
'reviewer_name' => $data['name'] ?? null,
|
||||
'reviewer_email' => $data['email'],
|
||||
'reviewed_at' => now(),
|
||||
'source' => 'storefront',
|
||||
]);
|
||||
|
||||
// ReviewServiceProvider also reindexes on the model's `created` event, but
|
||||
// queued (SCOUT_QUEUE=true) — it wouldn't land before this redirect's page
|
||||
// load. Syncing here skips our queue too, but Meilisearch's own write API
|
||||
// is itself async on top of that (see mergeJustSubmittedReview()), so this
|
||||
// alone still isn't a guarantee — it's the flash below that actually is.
|
||||
$product->searchableSync();
|
||||
|
||||
return redirect($reviewsUrl)
|
||||
->with('reviewSubmitted', true)
|
||||
->with('justSubmittedReview', [
|
||||
'id' => $review->id,
|
||||
'product_id' => $review->product_id,
|
||||
'title' => $review->title,
|
||||
'body' => $review->body,
|
||||
'rating' => $review->rating,
|
||||
'reviewed_at' => $review->reviewed_at?->timestamp,
|
||||
'reviewer_name' => $review->reviewer_name,
|
||||
'reply' => null,
|
||||
'replied_at' => null,
|
||||
'media' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Catalog\ProductCard;
|
||||
use App\Services\Wishlist;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\View\View;
|
||||
use Lunar\Models\Product;
|
||||
use Modules\Core\Catalog\Services\ProductService;
|
||||
|
||||
class WishlistController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Wishlist $wishlist,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The account's wishlist page.
|
||||
*/
|
||||
public function index(string $locale, ProductService $products): View
|
||||
{
|
||||
return view('account.wishlist', ['products' => $this->products($products)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same list for a guest, from their cookie. Logged-in users are sent
|
||||
* to the account version.
|
||||
*/
|
||||
public function guest(string $locale, ProductService $products): View|RedirectResponse
|
||||
{
|
||||
if (auth()->check()) {
|
||||
return redirect()->route('account.wishlist');
|
||||
}
|
||||
|
||||
return view('wishlist.guest', ['products' => $this->products($products)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or removes a product, for guests and logged-in shoppers alike. The
|
||||
* heart button's Stimulus controller asks for JSON; without JS the form
|
||||
* posts normally and comes back to the same page.
|
||||
*/
|
||||
public function toggle(string $locale, Request $request, int $productId): JsonResponse|RedirectResponse
|
||||
{
|
||||
abort_unless(Product::whereKey($productId)->exists(), 404);
|
||||
|
||||
$active = $this->wishlist->toggle($productId);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['active' => $active]);
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Product cards for the current wishlist, newest first. Products no longer
|
||||
* in the search index (deleted, unpublished) are simply skipped.
|
||||
*/
|
||||
private function products(ProductService $products): Collection
|
||||
{
|
||||
return collect($this->wishlist->ids())
|
||||
->map(fn (int $id) => $products->getById($id))
|
||||
->filter()
|
||||
->map(fn (array $product) => ['id' => $product['id'], ...ProductCard::fromIndexed($product)])
|
||||
->values();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ContactRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'message' => ['required', 'string', 'max:5000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Services\Wishlist;
|
||||
use Modules\Core\Auth\Events\UserAuthenticated;
|
||||
|
||||
/**
|
||||
* Picked up by Laravel's listener discovery (app/Listeners), no manual
|
||||
* registration. Runs inside the login request, so it can read the guest
|
||||
* wishlist cookie and queue its removal.
|
||||
*/
|
||||
class MergeGuestWishlistOnLogin
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Wishlist $wishlist,
|
||||
) {}
|
||||
|
||||
public function handle(UserAuthenticated $event): void
|
||||
{
|
||||
$this->wishlist->mergeGuestInto($event->user);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||
|
||||
/**
|
||||
* When a logged-in shopper places an order and their account has no saved
|
||||
* address yet, the order's shipping address (and their name, if the profile
|
||||
* has none) becomes the account's. Never overwrites anything already saved.
|
||||
*
|
||||
* Uses the order's own user, not Auth: OrderPlaced can fire from a payment
|
||||
* webhook, where nobody is logged in. Picked up by listener discovery.
|
||||
*/
|
||||
class SaveAddressFromFirstOrder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CustomerAccountService $account,
|
||||
) {}
|
||||
|
||||
public function handle(OrderPlaced $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
$user = $order->user;
|
||||
$shipping = $order->shippingAddress;
|
||||
|
||||
if (! $user || ! $shipping || ! $shipping->line_one) {
|
||||
return;
|
||||
}
|
||||
|
||||
$customer = $this->account->customer($user);
|
||||
|
||||
if (! $customer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $customer->first_name && ! $customer->last_name) {
|
||||
$this->account->updateProfile($user, [
|
||||
'first_name' => $shipping->first_name,
|
||||
'last_name' => $shipping->last_name,
|
||||
]);
|
||||
}
|
||||
|
||||
if (collect($this->account->addresses($user))->isNotEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->account->createAddress($user, [
|
||||
...$shipping->only(['first_name', 'last_name', 'line_one', 'city', 'state', 'postcode', 'country_id', 'contact_phone']),
|
||||
'contact_email' => $user->email,
|
||||
'shipping_default' => true,
|
||||
'billing_default' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Sent to whoever submitted the contact form. Deliberately generic: it never
|
||||
* repeats what they wrote, so the form can't be used to deliver arbitrary text
|
||||
* to someone else's inbox.
|
||||
*/
|
||||
class ContactConfirmationMail extends Mailable
|
||||
{
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(subject: __('storefront.contact.confirmation_subject'));
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.contact-confirmation');
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* A contact-form message, sent to the store's CONTACT_EMAIL. Reply-To is the
|
||||
* sender, so replying from the inbox goes straight to them.
|
||||
*/
|
||||
class ContactMessageMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $senderName,
|
||||
public readonly string $senderEmail,
|
||||
public readonly string $body,
|
||||
public readonly string $senderLocale,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
replyTo: [new Address($this->senderEmail, $this->senderName)],
|
||||
subject: "Νέο μήνυμα επικοινωνίας από {$this->senderName}",
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.contact-message');
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -18,6 +18,10 @@ class User extends Authenticatable implements LunarUserInterface
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
protected $dispatchesEvents = [
|
||||
'created' => UserCreated::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* One product on a logged-in user's wishlist. Guests' wishlists live in a
|
||||
* cookie instead — see App\Services\Wishlist.
|
||||
*/
|
||||
class WishlistItem extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'product_id'];
|
||||
}
|
||||
@@ -6,39 +6,16 @@
|
||||
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
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
// One instance per request: it caches the guest cookie's ids, so a
|
||||
// toggle and a later has() in the same request agree.
|
||||
$this->app->scoped(\App\Services\Wishlist::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\View\View as ViewInstance;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
|
||||
/**
|
||||
* The seam for the (currently in-repo) cart + checkout module.
|
||||
*
|
||||
* Everything the module needs to boot inside a host app lives here: its view /
|
||||
* component namespaces, its routes, and the composer that feeds the
|
||||
* always-present cart drawer. Strings (__('checkout.cart.*')) are NOT wired up
|
||||
* here — same as storefront.* elsewhere in this app, they resolve through
|
||||
* Lunar's DB-backed translation UI (spatie/laravel-translation-loader), added
|
||||
* manually there rather than shipped as lang/ files. The module's own files
|
||||
* (resources/views/checkout/**, resources/js/checkout/**, resources/css/checkout.css,
|
||||
* routes/checkout.php, app/Http/Controllers/Checkout/**) contain nothing
|
||||
* 3dealer-specific.
|
||||
*
|
||||
* When the module is extracted to boboko-core, this class is deleted and its
|
||||
* body becomes the package's own ServiceProvider. The only other host wiring
|
||||
* is the one registerCheckout() call in resources/js/app.js and the two lines
|
||||
* in the layout (the checkout.css @vite entry and @include('checkout::drawer')).
|
||||
*/
|
||||
class CheckoutModuleServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadViewsFrom(resource_path('views/checkout'), 'checkout');
|
||||
Blade::anonymousComponentNamespace('checkout::components', 'checkout');
|
||||
|
||||
Route::middleware('web')->group(base_path('routes/checkout.php'));
|
||||
|
||||
// The drawer is rendered on every page (from the layout) and its body
|
||||
// partial is re-rendered on every cart mutation — both need the current
|
||||
// cart without a controller in the loop.
|
||||
View::composer(
|
||||
['checkout::drawer', 'checkout::partials.cart-body'],
|
||||
function (ViewInstance $view) {
|
||||
$service = app(CartService::class);
|
||||
$cart = $service->current();
|
||||
|
||||
$view->with('cart', $cart);
|
||||
$view->with('lines', $cart ? $service->activeLines($cart) : collect());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\WishlistItem;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
|
||||
/**
|
||||
* The current shopper's wishlist, product ids only.
|
||||
*
|
||||
* Logged in: rows in wishlist_items. Guest: a 1-year cookie holding the ids
|
||||
* (encrypted like every cookie, by the web group's EncryptCookies), so nothing
|
||||
* is written to the database for anonymous visitors. On login the cookie is
|
||||
* merged into the account and cleared (MergeGuestWishlistOnLogin).
|
||||
*/
|
||||
class Wishlist
|
||||
{
|
||||
public const COOKIE = 'wishlist';
|
||||
|
||||
private const COOKIE_MINUTES = 60 * 24 * 365;
|
||||
|
||||
// Keeps the cookie well under the 4KB browser limit.
|
||||
private const GUEST_MAX = 100;
|
||||
|
||||
/** @var array<int>|null ids for this request, including a toggle just made */
|
||||
private ?array $guestIds = null;
|
||||
|
||||
/** @return array<int> newest first */
|
||||
public function ids(): array
|
||||
{
|
||||
if ($user = Auth::user()) {
|
||||
return WishlistItem::where('user_id', $user->id)
|
||||
->latest('id')
|
||||
->pluck('product_id')
|
||||
->all();
|
||||
}
|
||||
|
||||
return $this->guestIds();
|
||||
}
|
||||
|
||||
public function has(int $productId): bool
|
||||
{
|
||||
return in_array($productId, $this->ids(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool whether the product is on the wishlist afterwards
|
||||
*/
|
||||
public function toggle(int $productId): bool
|
||||
{
|
||||
if ($user = Auth::user()) {
|
||||
$deleted = WishlistItem::where('user_id', $user->id)->where('product_id', $productId)->delete();
|
||||
|
||||
if ($deleted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
WishlistItem::create(['user_id' => $user->id, 'product_id' => $productId]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$ids = $this->guestIds();
|
||||
|
||||
if (in_array($productId, $ids, true)) {
|
||||
$this->storeGuestIds(array_values(array_diff($ids, [$productId])));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->storeGuestIds(array_slice([$productId, ...$ids], 0, self::GUEST_MAX));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function remove(int $productId): void
|
||||
{
|
||||
if ($this->has($productId)) {
|
||||
$this->toggle($productId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the guest cookie's products onto $user's wishlist and clears it.
|
||||
*/
|
||||
public function mergeGuestInto(Authenticatable $user): void
|
||||
{
|
||||
$ids = $this->guestIds();
|
||||
|
||||
if ($ids === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Oldest first, so the newest cookie item also ends up newest here.
|
||||
foreach (array_reverse($ids) as $productId) {
|
||||
WishlistItem::firstOrCreate(['user_id' => $user->id, 'product_id' => $productId]);
|
||||
}
|
||||
|
||||
$this->guestIds = [];
|
||||
Cookie::queue(Cookie::forget(self::COOKIE));
|
||||
}
|
||||
|
||||
/** @return array<int> */
|
||||
private function guestIds(): array
|
||||
{
|
||||
if ($this->guestIds !== null) {
|
||||
return $this->guestIds;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) request()->cookie(self::COOKIE), true);
|
||||
|
||||
return $this->guestIds = is_array($decoded)
|
||||
? array_values(array_unique(array_filter(array_map('intval', $decoded))))
|
||||
: [];
|
||||
}
|
||||
|
||||
/** @param array<int> $ids */
|
||||
private function storeGuestIds(array $ids): void
|
||||
{
|
||||
$this->guestIds = $ids;
|
||||
|
||||
Cookie::queue(self::COOKIE, json_encode($ids), self::COOKIE_MINUTES);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?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, ',', '.');
|
||||
}
|
||||
}
|
||||
@@ -11,18 +11,7 @@
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
// Laravel's priority list would otherwise run `auth` before core's
|
||||
// `locale` middleware, so the redirects below would build URLs before
|
||||
// URL::defaults(['locale' => …]) is set, throwing a missing-parameter error.
|
||||
$middleware->prependToPriorityList(
|
||||
before: \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
|
||||
prepend: \Modules\Core\Localization\Middleware\LocaleMiddleware::class,
|
||||
);
|
||||
|
||||
// Both resolve inside the {locale} group, after the `locale` middleware
|
||||
// has set URL::defaults(['locale' => …]), so route() needs no locale arg.
|
||||
$middleware->redirectGuestsTo(fn () => route('login'));
|
||||
$middleware->redirectUsersTo(fn () => route('home'));
|
||||
//
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\CheckoutModuleServiceProvider;
|
||||
use App\Providers\PanelServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
PanelServiceProvider::class,
|
||||
CheckoutModuleServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"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",
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'el'),
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Per-site settings for the cart + checkout module (see
|
||||
* App\Providers\CheckoutModuleServiceProvider). Becomes the package's
|
||||
* publishable config when the module moves to boboko-core.
|
||||
*/
|
||||
return [
|
||||
|
||||
/*
|
||||
* Name of the storefront's login route. The checkout's login tab and the
|
||||
* confirmation page link to it with `?redirect=<checkout path>`, so the
|
||||
* login page must send the shopper back there afterwards (3dealer's
|
||||
* Auth\LoginController does). null: no login offered in checkout at all.
|
||||
*/
|
||||
'login_route' => 'login',
|
||||
|
||||
];
|
||||
@@ -54,7 +54,6 @@
|
||||
*/
|
||||
'cart_lines' => [
|
||||
Lunar\Pipelines\CartLine\GetUnitPrice::class,
|
||||
Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice::class,
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
| Determines whether the cart sholud be soft deleted when the user logs out.
|
||||
|
|
||||
*/
|
||||
'delete_on_forget' => false,
|
||||
'delete_on_forget' => true,
|
||||
];
|
||||
|
||||
@@ -58,23 +58,11 @@
|
||||
| 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' => [],
|
||||
@@ -82,25 +70,17 @@
|
||||
'favourite' => true,
|
||||
],
|
||||
|
||||
'processing' => [
|
||||
'label' => 'Processing',
|
||||
'payment-offline' => [
|
||||
'label' => 'Payment Offline',
|
||||
'color' => '#0A81D7',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => true,
|
||||
],
|
||||
|
||||
'ready_for_dispatch' => [
|
||||
'label' => 'Ready for Dispatch',
|
||||
'color' => '#c98a2e',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => true,
|
||||
],
|
||||
|
||||
'ready_for_pickup' => [
|
||||
'label' => 'Ready for Pickup',
|
||||
'color' => '#c98a2e',
|
||||
'payment-received' => [
|
||||
'label' => 'Payment Received',
|
||||
'color' => '#6a67ce',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => true,
|
||||
@@ -108,78 +88,11 @@
|
||||
|
||||
'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,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -46,6 +46,6 @@
|
||||
| to include in the count below.
|
||||
|
|
||||
*/
|
||||
'order_count_statuses' => ['processing'],
|
||||
'order_count_statuses' => ['payment-received'],
|
||||
|
||||
];
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
|
||||
|
||||
return [
|
||||
|
||||
'default' => env('PAYMENTS_TYPE', 'cash-in-hand'),
|
||||
@@ -9,7 +7,6 @@
|
||||
'types' => [
|
||||
'cash-in-hand' => [
|
||||
'driver' => 'offline',
|
||||
'payment_driver' => OfflinePaymentDriver::class,
|
||||
'authorized' => 'payment-offline',
|
||||
],
|
||||
],
|
||||
|
||||
@@ -20,17 +20,7 @@
|
||||
Lunar\Models\Collection::class,
|
||||
Lunar\Models\Customer::class,
|
||||
Lunar\Models\Order::class,
|
||||
// Modules\Core\Catalog\Models\Product, not Lunar\Models\Product —
|
||||
// both share the same underlying Meilisearch index name, so
|
||||
// listing the base class here too would make every reindex
|
||||
// (this default list is always merged in, even when a specific
|
||||
// model is passed on the CLI — see Lunar\Console\Commands\
|
||||
// ScoutIndexerCommand::handle()) run the base class's indexing a
|
||||
// second time right after the subclass's, silently overwriting
|
||||
// every document with one missing custom_fields/order_count
|
||||
// (the whole reason Modules\Core\Catalog\Models\Product exists —
|
||||
// see its own docblock). Caught in practice.
|
||||
Modules\Core\Catalog\Models\Product::class,
|
||||
Lunar\Models\Product::class,
|
||||
Lunar\Models\ProductOption::class,
|
||||
|
||||
/*
|
||||
@@ -56,24 +46,10 @@
|
||||
|
||||
'indexers' => [
|
||||
Lunar\Models\Brand::class => Lunar\Search\BrandIndexer::class,
|
||||
Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class,
|
||||
Lunar\Models\Collection::class => Lunar\Search\CollectionIndexer::class,
|
||||
Lunar\Models\Customer::class => Lunar\Search\CustomerIndexer::class,
|
||||
Lunar\Models\Order::class => Lunar\Search\OrderIndexer::class,
|
||||
// Lunar\Models\Product::class, NOT Modules\Core\Catalog\Models\
|
||||
// Product::class — Lunar\Base\Traits\Searchable::indexer() (and
|
||||
// getFilterableAttributes()/getSortableAttributes(), same trait)
|
||||
// reads `$config[self::class]`, and `self::class` inside a TRAIT
|
||||
// METHOD is a compile-time literal bound to whichever class first
|
||||
// `use`s the trait — Lunar\Models\Product, since the subclass
|
||||
// never re-declares indexer() itself — regardless of which
|
||||
// instance actually calls the method at runtime. Keying this by
|
||||
// the subclass here made the lookup miss entirely, silently
|
||||
// falling back to Lunar\Search\ScoutIndexer's own near-empty
|
||||
// filterable/sortable field list — confirmed live: it wiped every
|
||||
// real filterable/sortable attribute the index had (including
|
||||
// ones that already worked, like collection_ids), not just the
|
||||
// new order_count one. Caught in practice, reverted.
|
||||
Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
|
||||
Lunar\Models\Product::class => Modules\Core\Search\ProductIndexer::class,
|
||||
Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class,
|
||||
],
|
||||
|
||||
|
||||
@@ -35,27 +35,4 @@
|
||||
],
|
||||
],
|
||||
|
||||
// Where contact-form messages are sent (ContactController).
|
||||
'contact' => [
|
||||
'email' => env('CONTACT_EMAIL'),
|
||||
],
|
||||
|
||||
'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'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('wishlist_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('product_id')->constrained('lunar_products')->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'product_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('wishlist_items');
|
||||
}
|
||||
};
|
||||
@@ -1,183 +0,0 @@
|
||||
<?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.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', 'Συνέχεια αγορών'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Modules\Core\Localization\Services\TranslationService;
|
||||
use Spatie\TranslationLoader\LanguageLine;
|
||||
|
||||
/**
|
||||
* Default `validation` translation lines — Laravel's own error-message group
|
||||
* (`validation.required`, `validation.email`, `validation.attributes.*`, …),
|
||||
* resolved by every `Validator::make()`/`$request->validate()` call in the app
|
||||
* (see CheckoutController::saveAddress(), CartController, ProductController),
|
||||
* not just the checkout module.
|
||||
*
|
||||
* Spatie's DB loader (spatie/laravel-translation-loader, wired in boboko-core's
|
||||
* LocalizationServiceProvider) merges this group over Laravel's file-based
|
||||
* validation.php, which the project doesn't ship a `lang/` copy of — so without
|
||||
* this, Greek requests fall back to Laravel's untranslated English defaults.
|
||||
* Only the rule keys and field attributes actually in use are seeded; add more
|
||||
* as new rules/fields show up.
|
||||
*
|
||||
* Additive and idempotent: a 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=ValidationTranslationsSeeder` — it
|
||||
* is not wired into DatabaseSeeder.
|
||||
*
|
||||
* Greek copy uses the project's informal register (εσύ/σου).
|
||||
*/
|
||||
class ValidationTranslationsSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$translations = app(TranslationService::class);
|
||||
|
||||
foreach ($this->lines() as $key => [$en, $el]) {
|
||||
$exists = LanguageLine::query()
|
||||
->where('group', 'validation')
|
||||
->where('key', $key)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$this->command?->warn("validation.{$key} already exists — skipped");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$translations->create('validation', $key, ['en' => $en, 'el' => $el]);
|
||||
$this->command?->info("validation.{$key} added");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* key => [English, Greek].
|
||||
*
|
||||
* @return array<string, array{0: string, 1: string}>
|
||||
*/
|
||||
private function lines(): array
|
||||
{
|
||||
return [
|
||||
// ── Rule messages ─────────────────────────────────────────────
|
||||
'required' => ['The :attribute field is required.', 'Το πεδίο :attribute είναι υποχρεωτικό.'],
|
||||
'email' => ['The :attribute field must be a valid email address.', 'Το πεδίο :attribute πρέπει να είναι έγκυρη διεύθυνση email.'],
|
||||
'string' => ['The :attribute field must be a string.', 'Το πεδίο :attribute πρέπει να είναι κείμενο.'],
|
||||
'integer' => ['The :attribute field must be an integer.', 'Το πεδίο :attribute πρέπει να είναι ακέραιος αριθμός.'],
|
||||
'boolean' => ['The :attribute field must be true or false.', 'Το πεδίο :attribute πρέπει να είναι true ή false.'],
|
||||
'min.numeric' => ['The :attribute field must be at least :min.', 'Το πεδίο :attribute πρέπει να είναι τουλάχιστον :min.'],
|
||||
'max.string' => ['The :attribute field must not be greater than :max characters.', 'Το πεδίο :attribute δεν πρέπει να ξεπερνά τους :max χαρακτήρες.'],
|
||||
'between.numeric' => ['The :attribute field must be between :min and :max.', 'Το πεδίο :attribute πρέπει να είναι μεταξύ :min και :max.'],
|
||||
'exists' => ['The selected :attribute is invalid.', 'Η επιλεγμένη τιμή για το πεδίο :attribute δεν είναι έγκυρη.'],
|
||||
// Product custom-field photo uploads (CustomFieldUploadController, CartController).
|
||||
'file' => ['The :attribute field must be a file.', 'Το πεδίο :attribute πρέπει να είναι αρχείο.'],
|
||||
'mimes' => ['The :attribute field must be a file of type: :values.', 'Το πεδίο :attribute πρέπει να είναι αρχείο τύπου: :values.'],
|
||||
'max.file' => ['The :attribute field must not be greater than :max kilobytes.', 'Το αρχείο στο πεδίο :attribute δεν πρέπει να ξεπερνά τα :max kilobytes.'],
|
||||
'uploaded' => ['The :attribute failed to upload.', 'Η μεταφόρτωση στο πεδίο :attribute απέτυχε.'],
|
||||
|
||||
// ── Field names (checkout: billing/shipping address) ──────────
|
||||
'attributes.contact_email' => ['email', 'email'],
|
||||
'attributes.billing_first_name' => ['first name', 'όνομα'],
|
||||
'attributes.billing_last_name' => ['last name', 'επώνυμο'],
|
||||
'attributes.billing_company_name' => ['company name', 'επωνυμία εταιρείας'],
|
||||
'attributes.billing_tax_identifier' => ['tax ID', 'ΑΦΜ'],
|
||||
'attributes.billing_line_one' => ['address', 'διεύθυνση'],
|
||||
'attributes.billing_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'],
|
||||
'attributes.billing_city' => ['city', 'πόλη'],
|
||||
'attributes.billing_state' => ['region', 'νομό / περιοχή'],
|
||||
'attributes.billing_postcode' => ['postcode', 'ταχυδρομικό κώδικα'],
|
||||
'attributes.billing_country_id' => ['country', 'χώρα'],
|
||||
'attributes.billing_contact_phone' => ['phone', 'τηλέφωνο'],
|
||||
'attributes.shipping_first_name' => ['first name', 'όνομα'],
|
||||
'attributes.shipping_last_name' => ['last name', 'επώνυμο'],
|
||||
'attributes.shipping_company_name' => ['company name', 'επωνυμία εταιρείας'],
|
||||
'attributes.shipping_line_one' => ['address', 'διεύθυνση'],
|
||||
'attributes.shipping_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'],
|
||||
'attributes.shipping_city' => ['city', 'πόλη'],
|
||||
'attributes.shipping_state' => ['region', 'νομό / περιοχή'],
|
||||
'attributes.shipping_postcode' => ['postcode', 'ταχυδρομικό κώδικα'],
|
||||
'attributes.shipping_country_id' => ['country', 'χώρα'],
|
||||
'attributes.shipping_contact_phone' => ['phone', 'τηλέφωνο'],
|
||||
'attributes.shipping_delivery_instructions' => ['delivery notes', 'σχόλια για την παράδοση'],
|
||||
|
||||
// ── Field names (cart) ─────────────────────────────────────────
|
||||
'attributes.purchasable_id' => ['product', 'προϊόν'],
|
||||
'attributes.quantity' => ['quantity', 'ποσότητα'],
|
||||
'attributes.code' => ['coupon code', 'κωδικό κουπονιού'],
|
||||
|
||||
// ── Field names (product reviews / stock check) ────────────────
|
||||
'attributes.variant' => ['variant', 'παραλλαγή'],
|
||||
'attributes.rating' => ['rating', 'βαθμολογία'],
|
||||
'attributes.content' => ['review text', 'κείμενο κριτικής'],
|
||||
'attributes.name' => ['name', 'όνομα'],
|
||||
'attributes.email' => ['email', 'email'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ services:
|
||||
- "${VALKEY_PORT:-6339}:6379"
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.12
|
||||
image: getmeili/meilisearch:v1.10
|
||||
ports:
|
||||
- "${MEILISEARCH_PORT:-7700}:7700"
|
||||
environment:
|
||||
@@ -148,10 +148,10 @@ services:
|
||||
working_dir: /app
|
||||
command: npm run dev
|
||||
ports:
|
||||
- "${VITE_PORT:-5174}:${VITE_PORT:-5174}"
|
||||
- "${VITE_PORT:-5173}:5173"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- VITE_PORT=${VITE_PORT:-5174}
|
||||
- VITE_PORT=${VITE_PORT:-5173}
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
|
||||
@@ -116,7 +116,7 @@ services:
|
||||
- valkeydata:/data
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.12
|
||||
image: getmeili/meilisearch:v1.10
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MEILI_MASTER_KEY: ${MEILISEARCH_KEY:?MEILISEARCH_KEY is required}
|
||||
|
||||
@@ -5,38 +5,17 @@ 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
|
||||
|
||||
# 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
|
||||
echo "[entrypoint] Timed out waiting for migrations" >&2
|
||||
exit 1
|
||||
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"
|
||||
if [ "$APP_ENV" != "production" ]; then
|
||||
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
|
||||
echo "[entrypoint] Timed out waiting for migrations" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -4,12 +4,6 @@ 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
|
||||
@@ -27,80 +21,33 @@ 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
|
||||
# Dev-only for the same reason as the composer step above — production's
|
||||
# image already has these published at build time.
|
||||
# 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.
|
||||
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
|
||||
|
||||
# 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
|
||||
echo "[entrypoint] Running migrations..."
|
||||
rm -f storage/framework/migrated
|
||||
php artisan migrate --force
|
||||
touch storage/framework/migrated
|
||||
|
||||
# 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.
|
||||
# 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
|
||||
|
||||
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"
|
||||
# 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.
|
||||
echo "[entrypoint] Syncing search indexes..."
|
||||
php artisan lunar:search:index --quiet || true
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"dependencies": {
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"@hotwired/turbo": "^8.0.23",
|
||||
"axios": "^1.15.2"
|
||||
"@phosphor-icons/web": "^2.1.2",
|
||||
"axios": "^1.15.2",
|
||||
"flatpickr": "^4.6.13"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB |
@@ -0,0 +1 @@
|
||||
.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}
|
||||
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 0 B |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 87 KiB |
@@ -0,0 +1 @@
|
||||
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};
|
||||
@@ -0,0 +1 @@
|
||||
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};
|
||||
@@ -0,0 +1 @@
|
||||
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};
|
||||
@@ -0,0 +1 @@
|
||||
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};
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "3dealer",
|
||||
"short_name": "3dealer",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 32 KiB |
@@ -1,412 +1,11 @@
|
||||
@import "tailwindcss";
|
||||
@import "./fonts.css";
|
||||
@import "./dropdown.css";
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* 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: "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; }
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
||||
@@ -1,856 +0,0 @@
|
||||
/*
|
||||
* Cart + checkout module — generic default styling.
|
||||
*
|
||||
* Deliberately NOT wrapped in a Tailwind-style `@layer`. An earlier version
|
||||
* put these rules in `@layer bbk-checkout`, positioned (via a cross-file
|
||||
* @layer ordering statement) to sit between Tailwind's `base` and
|
||||
* `components` — in theory enough to beat Preflight's element resets while
|
||||
* still losing to a host override. In practice a build tool processing each
|
||||
* CSS file in isolation (Vite/Lightning CSS here) optimizes away exactly the
|
||||
* cross-file ordering information that trick depends on, so it silently
|
||||
* didn't work: Preflight's `button { background-color: transparent }`,
|
||||
* `* { border-width: 0 }` etc. (layered, in `base`) were beating every
|
||||
* `.bbk-*` rule below regardless of specificity — buttons with no
|
||||
* background, no border, wrong font-size.
|
||||
*
|
||||
* Plain, unlayered CSS sidesteps the whole problem: an unlayered rule always
|
||||
* beats ANY layered rule (Preflight included), full stop, no ordering tricks,
|
||||
* nothing a bundler can silently invalidate. This file is loaded BEFORE the
|
||||
* host's own stylesheet (see the @vite call in the layout <head>), so:
|
||||
*
|
||||
* - a later PLAIN (unlayered) `.bbk-*` rule in the host stylesheet wins —
|
||||
* same specificity, later in source order
|
||||
* - a later host rule with a MORE specific selector wins regardless
|
||||
* - a host rule inside `@layer components`/`@layer utilities` does NOT
|
||||
* win — unlayered always beats layered. Theme this module from plain
|
||||
* rules in app.css, not from inside a Tailwind layer.
|
||||
*
|
||||
* Two ways to theme this, cheapest first:
|
||||
*
|
||||
* 1. Redefine the --bbk-* custom properties below (from :root, or scoped to
|
||||
* .bbk-cart for a cart-only override) — covers colour, radius, shadow,
|
||||
* font without touching a single selector below.
|
||||
*
|
||||
* :root { --bbk-color-accent: var(--color-brand); --bbk-radius: 0; }
|
||||
*
|
||||
* 2. Override individual `.bbk-*` rules directly (as plain rules, per
|
||||
* above) for anything structural (spacing, layout) the variables don't
|
||||
* cover.
|
||||
*
|
||||
* This file's own look is a deliberately neutral placeholder — inoffensive,
|
||||
* not "designed" — so a project always has something reasonable before it
|
||||
* themes; it is not meant to be edited per project.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bbk-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
|
||||
--bbk-color-text: #18181b;
|
||||
--bbk-color-muted: #71717a;
|
||||
--bbk-color-bg: #ffffff;
|
||||
--bbk-color-bg-muted: #f4f4f5;
|
||||
--bbk-color-border: #e4e4e7;
|
||||
--bbk-color-accent: #18181b;
|
||||
--bbk-color-accent-text: #ffffff;
|
||||
--bbk-color-danger: #dc2626;
|
||||
|
||||
--bbk-radius: 8px;
|
||||
--bbk-radius-sm: 4px;
|
||||
--bbk-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.bbk-cart[hidden] { display: none; }
|
||||
|
||||
.bbk-cart {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
font-family: var(--bbk-font);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
color: var(--bbk-color-text);
|
||||
}
|
||||
|
||||
.bbk-cart-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-backdrop { opacity: 1; }
|
||||
|
||||
.bbk-cart-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(420px, 100vw);
|
||||
height: 100%;
|
||||
background: var(--bbk-color-bg);
|
||||
box-shadow: var(--bbk-shadow);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-panel { transform: translateX(0); }
|
||||
|
||||
.bbk-cart-panel-header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 1.5rem 1.25rem;
|
||||
border-bottom: 1px solid var(--bbk-color-border);
|
||||
}
|
||||
|
||||
.bbk-cart-heading {
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bbk-cart-dismiss,
|
||||
.bbk-cart-item-remove,
|
||||
.bbk-cart-qty-btn {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
color: var(--bbk-color-muted);
|
||||
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.bbk-cart-dismiss {
|
||||
font-size: 1.75rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bbk-cart-dismiss:hover { color: var(--bbk-color-text); background: var(--bbk-color-bg-muted); }
|
||||
|
||||
.bbk-cart-item-remove:hover { color: var(--bbk-color-danger); }
|
||||
|
||||
.bbk-cart-dismiss:focus-visible,
|
||||
.bbk-cart-item-remove:focus-visible,
|
||||
.bbk-cart-qty-btn:focus-visible,
|
||||
.bbk-cart-qty-input:focus-visible,
|
||||
.bbk-cart-checkout:focus-visible,
|
||||
.bbk-cart-coupon-input:focus-visible,
|
||||
.bbk-cart-coupon-submit:focus-visible,
|
||||
.bbk-cart-coupon-remove:focus-visible {
|
||||
outline: 2px solid var(--bbk-color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.bbk-visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.bbk-cart-panel-body {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.bbk-cart-items {
|
||||
list-style: none;
|
||||
margin: 0 0 2rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.bbk-cart-item {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr auto;
|
||||
gap: 0.875rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.bbk-cart-item-media img {
|
||||
display: block;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
background: var(--bbk-color-bg-muted);
|
||||
}
|
||||
|
||||
.bbk-cart-item-detail { min-width: 0; }
|
||||
|
||||
.bbk-cart-item-title {
|
||||
display: block;
|
||||
margin: 0 0 0.25rem;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.bbk-cart-item-title:hover { text-decoration: underline; }
|
||||
|
||||
.bbk-cart-item-variant {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
/* A line's custom-field answers (checkout::partials.line-custom-fields). */
|
||||
.bbk-line-fields {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.bbk-line-field dt {
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-line-field dd {
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.bbk-line-field-file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.bbk-line-field-file img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.bbk-cart-item-unit {
|
||||
margin: 0 0 0.625rem;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-cart-item-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bbk-cart-item-total { margin: 0; font-weight: 600; }
|
||||
|
||||
.bbk-cart-item-remove {
|
||||
font-size: 1.125rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bbk-cart-qty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bbk-cart-qty-btn {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
background: var(--bbk-color-bg-muted);
|
||||
}
|
||||
|
||||
.bbk-cart-qty-btn:hover { background: var(--bbk-color-border); color: var(--bbk-color-text); }
|
||||
|
||||
.bbk-cart-qty-input {
|
||||
width: 2.25rem;
|
||||
height: 1.75rem;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--bbk-color-border);
|
||||
border-right: 1px solid var(--bbk-color-border);
|
||||
text-align: center;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--bbk-color-bg);
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.bbk-cart-qty-input::-webkit-outer-spin-button,
|
||||
.bbk-cart-qty-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bbk-cart-summary {
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--bbk-color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.bbk-cart-summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.bbk-cart-summary-row--discount { color: var(--bbk-color-danger); }
|
||||
|
||||
.bbk-cart-summary-pending {
|
||||
color: var(--bbk-color-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.bbk-cart-summary-row--total {
|
||||
margin-top: 0.375rem;
|
||||
padding-top: 0.875rem;
|
||||
border-top: 1px solid var(--bbk-color-border);
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bbk-cart-coupon-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bbk-cart-coupon-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--bbk-color-bg);
|
||||
}
|
||||
|
||||
.bbk-cart-coupon-submit {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
background: var(--bbk-color-bg-muted);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.bbk-cart-coupon-submit:hover { background: var(--bbk-color-border); }
|
||||
|
||||
.bbk-cart-coupon-applied {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
background: var(--bbk-color-bg-muted);
|
||||
}
|
||||
|
||||
.bbk-cart-coupon-code {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.bbk-cart-coupon-remove {
|
||||
flex: 0 0 auto;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bbk-color-muted);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.bbk-cart-coupon-remove:hover { color: var(--bbk-color-danger); }
|
||||
|
||||
.bbk-cart-coupon-error {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bbk-color-danger);
|
||||
}
|
||||
|
||||
.bbk-cart-error {
|
||||
margin: 0;
|
||||
padding: 0.75rem 1.5rem 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bbk-color-danger);
|
||||
}
|
||||
|
||||
.bbk-add-to-cart-error {
|
||||
margin: 0.375rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bbk-color-danger);
|
||||
}
|
||||
|
||||
.bbk-cart-checkout {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.875rem 1.25rem;
|
||||
border: 1px solid var(--bbk-color-accent);
|
||||
border-radius: var(--bbk-radius);
|
||||
background: var(--bbk-color-accent);
|
||||
color: var(--bbk-color-accent-text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.bbk-cart-checkout:hover { opacity: 0.85; }
|
||||
|
||||
.bbk-cart-checkout:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.bbk-cart-empty {
|
||||
text-align: center;
|
||||
color: var(--bbk-color-muted);
|
||||
padding: 2.5rem 0;
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────────────────────────────
|
||||
Checkout page — two columns: fields on the left, order summary (the
|
||||
same cart-body partial the drawer uses) on the right.
|
||||
─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.bbk-checkout-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 5rem;
|
||||
font-family: var(--bbk-font);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
color: var(--bbk-color-text);
|
||||
}
|
||||
|
||||
.bbk-checkout-heading {
|
||||
margin: 0 0 2rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bbk-checkout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
gap: 3rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.bbk-checkout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.bbk-checkout-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-section {
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid var(--bbk-color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-section-heading {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bbk-checkout-note {
|
||||
margin: 0;
|
||||
color: var(--bbk-color-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Contact: "logged in as" line, or the guest login prompt */
|
||||
|
||||
.bbk-checkout-logged-in,
|
||||
.bbk-checkout-login-prompt { margin: 0; }
|
||||
|
||||
.bbk-checkout-login-prompt a { color: inherit; font-weight: 600; }
|
||||
|
||||
/* Fields */
|
||||
|
||||
.bbk-field { display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
|
||||
.bbk-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bbk-field-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.bbk-field-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-field-input {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--bbk-color-bg);
|
||||
}
|
||||
|
||||
.bbk-field-input:focus-visible {
|
||||
outline: 2px solid var(--bbk-color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.bbk-field-input:disabled {
|
||||
background: var(--bbk-color-bg-muted);
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-field-input--error { border-color: var(--bbk-color-danger); }
|
||||
|
||||
.bbk-field-error {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bbk-color-danger);
|
||||
}
|
||||
|
||||
/* A fixed, non-editable field value (e.g. the store's single country). */
|
||||
.bbk-field-static {
|
||||
margin: 0;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
background: var(--bbk-color-bg-muted);
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
textarea.bbk-field-input { resize: vertical; }
|
||||
|
||||
.bbk-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* For a full-sentence label that can wrap — align the box to the first line. */
|
||||
/* "I want an invoice": company/ΑΦΜ only while ticked */
|
||||
|
||||
.bbk-invoice { display: flex; flex-direction: column; gap: 1rem; }
|
||||
|
||||
.bbk-invoice:not(:has(input[name="wants_invoice"]:checked)) .bbk-invoice-fields { display: none; }
|
||||
|
||||
.bbk-checkbox--stacked {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-top: 0.75rem;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-checkbox--stacked input { margin-top: 0.15rem; flex-shrink: 0; }
|
||||
|
||||
.bbk-checkout-shipping-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Shipping method */
|
||||
|
||||
.bbk-checkout-shipping-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-shipping-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.bbk-checkout-shipping-option:has(input:checked) { border-color: var(--bbk-color-accent); }
|
||||
|
||||
.bbk-checkout-shipping-option-detail {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-shipping-option-name { font-weight: 600; }
|
||||
|
||||
.bbk-checkout-shipping-option-description {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-checkout-shipping-option-price { font-weight: 600; }
|
||||
|
||||
/* The single auto-selected option — a fixed line, not a choosable radio. */
|
||||
.bbk-checkout-shipping-confirmed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--bbk-color-accent);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
}
|
||||
|
||||
/* Autosave status line under the address form. */
|
||||
.bbk-checkout-status {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); }
|
||||
|
||||
/* Continue / submit buttons — same look as the drawer's checkout CTA */
|
||||
|
||||
.bbk-checkout-continue {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.875rem 1.25rem;
|
||||
border: 1px solid var(--bbk-color-accent);
|
||||
border-radius: var(--bbk-radius);
|
||||
background: var(--bbk-color-accent);
|
||||
color: var(--bbk-color-accent-text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.bbk-checkout-continue:hover { opacity: 0.85; }
|
||||
|
||||
.bbk-checkout-continue:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Order summary column */
|
||||
|
||||
.bbk-checkout-aside { position: sticky; top: 1.5rem; }
|
||||
|
||||
.bbk-checkout-summary {
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius);
|
||||
background: var(--bbk-color-bg);
|
||||
}
|
||||
|
||||
.bbk-checkout-summary-heading {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Already on the checkout page — the drawer's own "go to checkout" CTA has
|
||||
nowhere further to send you from here. */
|
||||
.bbk-checkout-summary .bbk-cart-checkout { display: none; }
|
||||
|
||||
/* ── Payment ───────────────────────────────────────────────────────── */
|
||||
|
||||
.bbk-checkout-payment-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-payment-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.bbk-checkout-payment-option:has(input:checked) { border-color: var(--bbk-color-accent); }
|
||||
|
||||
.bbk-checkout-payment-option-name { font-weight: 600; }
|
||||
|
||||
.bbk-payment-element { margin: 0.25rem 0; }
|
||||
|
||||
.bbk-checkout-withdrawal {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-checkout-withdrawal a { color: inherit; }
|
||||
|
||||
.bbk-checkout-error {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--bbk-color-danger);
|
||||
}
|
||||
|
||||
/* Processing overlay — fixed, covers the page while a payment confirms. */
|
||||
.bbk-checkout-processing {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
background: color-mix(in srgb, var(--bbk-color-bg) 92%, transparent);
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.bbk-spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--bbk-color-border);
|
||||
border-top-color: var(--bbk-color-accent);
|
||||
border-radius: 50%;
|
||||
animation: bbk-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bbk-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Confirmation page ─────────────────────────────────────────────── */
|
||||
|
||||
.bbk-confirmation {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1.5rem 5rem;
|
||||
font-family: var(--bbk-font);
|
||||
color: var(--bbk-color-text);
|
||||
}
|
||||
|
||||
.bbk-confirmation-heading {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bbk-confirmation-ref { margin: 0 0 0.25rem; }
|
||||
|
||||
.bbk-confirmation-meta {
|
||||
margin: 0 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.bbk-confirmation-meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); }
|
||||
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
|
||||
|
||||
.bbk-confirmation-body {
|
||||
margin: 2rem 0;
|
||||
display: grid;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
.bbk-confirmation-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bbk-confirmation-line {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr auto;
|
||||
align-items: start;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.bbk-confirmation-line-detail { min-width: 0; }
|
||||
|
||||
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
|
||||
|
||||
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
|
||||
|
||||
.bbk-confirmation-addresses {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.bbk-confirmation-address-heading {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bbk-address-lines {
|
||||
font-style: normal;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
|
||||
/* ── 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);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/* 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+ */
|
||||
}
|
||||
@@ -1,22 +1 @@
|
||||
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);
|
||||
import './bootstrap';
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import { csrfToken } from './csrf'
|
||||
|
||||
// Sits on an <x-checkout::add-to-cart> <form>. Submits the line to the cart
|
||||
// via fetch and hands the server-rendered cart body to the drawer through the
|
||||
// `bbk-cart:changed` window event. No DOM building here — the drawer
|
||||
// (bbk-cart-controller) owns rendering.
|
||||
export default class extends Controller {
|
||||
static targets = ['error']
|
||||
|
||||
async add(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = this.element
|
||||
const submit = form.querySelector('[type="submit"]')
|
||||
|
||||
this.clearError()
|
||||
form.setAttribute('data-bbk-add-to-cart-state', 'loading')
|
||||
if (submit) submit.disabled = true
|
||||
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: new FormData(form),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null)
|
||||
this.showError(data?.error)
|
||||
return
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
|
||||
detail: { html: await response.text() },
|
||||
}))
|
||||
} finally {
|
||||
form.removeAttribute('data-bbk-add-to-cart-state')
|
||||
if (submit) submit.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
if (!this.hasErrorTarget || !message) return
|
||||
this.errorTarget.textContent = message
|
||||
this.errorTarget.hidden = false
|
||||
}
|
||||
|
||||
clearError() {
|
||||
if (!this.hasErrorTarget) return
|
||||
this.errorTarget.hidden = true
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import { csrfToken } from './csrf'
|
||||
|
||||
// Drives the slide-in cart drawer. One instance, on the drawer root in
|
||||
// checkout/drawer.blade.php.
|
||||
//
|
||||
// - listens on window for `bbk-cart:changed` (from bbk-add-to-cart and from
|
||||
// this drawer's own line forms) and swaps in the server-rendered cart body
|
||||
// - handles the in-drawer quantity / remove forms (fetch + method spoofing)
|
||||
// - re-emits `bbk-cart:updated` {count, total} after every render so the host
|
||||
// (e.g. the header bag icon) can react
|
||||
//
|
||||
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
|
||||
// attribute on the root, nothing here touches styles or class lists.
|
||||
export default class extends Controller {
|
||||
static targets = ['panel', 'body', 'error']
|
||||
|
||||
connect() {
|
||||
this.onChanged = this.onChanged.bind(this)
|
||||
this.onKeydown = this.onKeydown.bind(this)
|
||||
this.updateTimers = new Map() // line id -> pending debounce timer
|
||||
|
||||
window.addEventListener('bbk-cart:changed', this.onChanged)
|
||||
window.addEventListener('bbk-cart:open', this.open.bind(this))
|
||||
document.addEventListener('keydown', this.onKeydown)
|
||||
|
||||
// Prime the host with the count rendered server-side on page load.
|
||||
this.emitUpdated(this.element.querySelector('[data-bbk-cart-count]'))
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
window.removeEventListener('bbk-cart:changed', this.onChanged)
|
||||
document.removeEventListener('keydown', this.onKeydown)
|
||||
this.updateTimers.forEach((timer) => clearTimeout(timer))
|
||||
}
|
||||
|
||||
onChanged(event) {
|
||||
if (event.detail?.html) this.replaceBody(event.detail.html)
|
||||
this.open()
|
||||
}
|
||||
|
||||
onKeydown(event) {
|
||||
if (event.key === 'Escape' && !this.element.hidden) this.close()
|
||||
}
|
||||
|
||||
open() {
|
||||
if (!this.element.hidden) return
|
||||
this.element.hidden = false
|
||||
// Next frame, so the panel transitions from its off-canvas start.
|
||||
requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open'))
|
||||
}
|
||||
|
||||
close() {
|
||||
this.element.removeAttribute('data-bbk-cart-state')
|
||||
|
||||
const panel = this.panelTarget
|
||||
const done = () => {
|
||||
this.element.hidden = true
|
||||
panel.removeEventListener('transitionend', done)
|
||||
}
|
||||
panel.addEventListener('transitionend', done)
|
||||
}
|
||||
|
||||
// change on a line quantity input, or submit of a line's remove form
|
||||
submit(event) {
|
||||
event.preventDefault()
|
||||
const form = event.target.closest('form')
|
||||
if (!form) return
|
||||
|
||||
// A remove is a deliberate, one-shot action — only the quantity form
|
||||
// (typing, or the +/- stepper below) benefits from debouncing.
|
||||
form.classList.contains('bbk-cart-qty') ? this.scheduleSend(form) : this.send(form)
|
||||
}
|
||||
|
||||
// +/- stepper buttons inside a line
|
||||
step(event) {
|
||||
event.preventDefault()
|
||||
const form = event.target.closest('form')
|
||||
const input = form.querySelector('input[type="number"]')
|
||||
const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir))
|
||||
input.value = String(next)
|
||||
this.scheduleSend(form)
|
||||
}
|
||||
|
||||
// Repeated clicks (or spinner nudges) update the input instantly but only
|
||||
// send once they settle for 300ms — sending on every single click was
|
||||
// firing overlapping requests that raced each other and made the drawer
|
||||
// visibly flicker/lag under quick clicking.
|
||||
scheduleSend(form) {
|
||||
const lineId = form.closest('[data-bbk-line-id]')?.dataset.bbkLineId
|
||||
if (!lineId) return this.send(form)
|
||||
|
||||
clearTimeout(this.updateTimers.get(lineId))
|
||||
this.updateTimers.set(lineId, setTimeout(() => {
|
||||
this.updateTimers.delete(lineId)
|
||||
this.send(form)
|
||||
}, 300))
|
||||
}
|
||||
|
||||
async send(form) {
|
||||
this.bodyTarget.setAttribute('aria-busy', 'true')
|
||||
this.clearError()
|
||||
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: new FormData(form),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
this.replaceBody(await response.text())
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null)
|
||||
this.showError(data?.error)
|
||||
|
||||
// The rejected quantity (typed, or from a +/- click) is left
|
||||
// sitting in the input with nothing to correct it — the update
|
||||
// never reached the cart, so the input must be put back to what
|
||||
// the cart actually still holds, not just left showing whatever
|
||||
// was rejected.
|
||||
const input = form.querySelector('[data-bbk-cart-confirmed-quantity]')
|
||||
if (input) input.value = input.dataset.bbkCartConfirmedQuantity
|
||||
} finally {
|
||||
this.bodyTarget.removeAttribute('aria-busy')
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
if (!this.hasErrorTarget || !message) return
|
||||
this.errorTarget.textContent = message
|
||||
this.errorTarget.hidden = false
|
||||
}
|
||||
|
||||
clearError() {
|
||||
if (!this.hasErrorTarget) return
|
||||
this.errorTarget.hidden = true
|
||||
}
|
||||
|
||||
replaceBody(html) {
|
||||
this.bodyTarget.innerHTML = html
|
||||
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
|
||||
}
|
||||
|
||||
emitUpdated(node) {
|
||||
if (!node) return
|
||||
|
||||
window.dispatchEvent(new CustomEvent('bbk-cart:updated', {
|
||||
detail: {
|
||||
count: parseInt(node.dataset.bbkCartCount || '0', 10),
|
||||
total: parseInt(node.dataset.bbkCartTotal || '0', 10),
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||