19 Commits
Author SHA1 Message Date
elvira b522d60954 multilingual content, change in how we show header categories and homepage hero products 2026-09-28 19:42:35 +03:00
elvira 60ae6cf7a6 Merge branch 'master' into cart-temp 2026-09-28 18:23:55 +03:00
elvira 296d6001cd lock files 2026-09-28 18:23:33 +03:00
elvira aa051525a1 stoic multilingual 2026-09-28 18:17:33 +03:00
arvanitakis b288513bb4 Feat: Adding stripe and boxnow variables to .env.example 2026-09-28 18:16:36 +03:00
arvanitakis 74930351f1 Commiting composer.lock and package-lock.json 2026-09-28 18:12:37 +03:00
arvanitakis 65d4603793 Merge branch 'cart-temp' 2026-09-28 18:09:29 +03:00
arvanitakis 023c11019c Fix: Aligning docker compose and Dockerfile with entrypoints 2026-09-28 18:04:23 +03:00
elvira 88d8ae0954 stoic settings and site meta and legal 2026-09-28 17:32:22 +03:00
elvira ca362f81c8 setup for stoic settings, changes in legal pages to use core's store details and contact page to show contact info next to form 2026-09-28 17:09:08 +03:00
arvanitakis 4634e14924 Feat: Moving seeders to core, updating entrypoint to auto seed the validation and checkout translations 2026-09-28 13:43:44 +03:00
arvanitakis 69486f65ee Feat: Adding Order reference to views 2026-09-28 13:31:29 +03:00
arvanitakis 060b6c647e Merge branch 'box-now-test-' into cart-temp 2026-09-28 10:11:26 +03:00
arvanitakis 36806cac5b Chore: Moving Wishlist from 3dealer to Core 2026-09-28 10:10:50 +03:00
arvanitakis b1162761de Fix: correct package.json for boboko/core npm integration 2026-09-28 08:54:09 +03:00
arvanitakis 8b362c9436 Feat: Correctly extracting boboko/core's js 2026-09-28 08:46:14 +03:00
arvanitakis cd45d8f578 Feat: Removing Controllers and Movong to core 2026-09-27 20:49:07 +03:00
elvira 8e186fc231 minor improvement in showing custom fields in product page and boboko-core sourced locally 2026-09-25 22:39:14 +03:00
arvanitakis c7bd1efdaa Feature: Adding Meilisearch to 3dealer 2026-08-05 23:12:22 +03:00
75 changed files with 1581 additions and 2596 deletions
+19 -2
View File
@@ -31,7 +31,7 @@ DB_DATABASE=boboko
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD=dev DB_PASSWORD=dev
SESSION_DRIVER=database SESSION_DRIVER=redis
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
@@ -58,8 +58,8 @@ MAIL_PORT=1025
MAIL_USERNAME=null MAIL_USERNAME=null
MAIL_PASSWORD=null MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="hello@example.com"
# Fallback only: the sender name comes from Store Details' "Mail from name" when set.
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
CONTACT_EMAIL=
HCAPTCHA_SITEKEY= HCAPTCHA_SITEKEY=
HCAPTCHA_SECRET= HCAPTCHA_SECRET=
@@ -88,6 +88,9 @@ SCOUT_QUEUE=true
MEILISEARCH_KEY=dev-meilisearch-key-change-me MEILISEARCH_KEY=dev-meilisearch-key-change-me
MEILISEARCH_HOST=http://meilisearch:7700 MEILISEARCH_HOST=http://meilisearch:7700
# Lunar sets all prices inclusive of tax with these values
LUNAR_STORE_INCLUSIVE_OF_TAX=true
# Host-side ports — change these if another project on this machine already uses the defaults # Host-side ports — change these if another project on this machine already uses the defaults
APP_PORT=8090 APP_PORT=8090
STOIC_PORT=2727 STOIC_PORT=2727
@@ -96,3 +99,17 @@ POSTGRES_HOST_PORT=5436
MAILPIT_PORT=8025 MAILPIT_PORT=8025
VITE_PORT=5173 VITE_PORT=5173
MEILISEARCH_PORT=7700 MEILISEARCH_PORT=7700
STRIPE_SECRET=secret
STRIPE_PUBLIC_KEY=public_key
STRIPE_WEBHOOK_SECRET=webhook_secret
BOXNOW_BASE_URL=https://api-stage.boxnow.gr/api/v1
BOXNOW_LOCATION_API_URL=https://locationapi-stage.boxnow.gr/api/v1
BOXNOW_CLIENT_ID=box-client-id
BOXNOW_CLIENT_SECRET=box-client-secret
BOXNOW_PARTNER_ID=box-partner-id
BOXNOW_ORIGIN_LOCATION_ID=box-location-id
BOXNOW_SENDER_NAME=test
BOXNOW_SENDER_EMAIL=test@test.com
BOXNOW_SENDER_PHONE=+306912345678
+13 -4
View File
@@ -10,11 +10,19 @@ RUN composer install \
--optimize-autoloader \ --optimize-autoloader \
--ignore-platform-reqs --ignore-platform-reqs
# ── Stage 2a: Vite dev (node_modules only, no build) ───────────────────────── # ── Stage 2a: Vite dev (node_modules only, no build) ──────────────────────────
FROM node:22-alpine AS vite-dev FROM node:22-alpine AS vite-dev
# git: needed for npm to install @boboko/core's git+https tagged-VCS form (see
# package.json's _dependencies and docker/entrypoint-vite.sh). No build-time
# `npm install` here — package.json's boboko/core path-repo form (../boboko-core)
# isn't visible in the build context, only once bind-mounted at container start,
# and entrypoint-vite.sh already runs npm install on every boot, so this would be
# redundant even if it could work.
RUN apk add --no-cache git
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY docker/entrypoint-vite.sh /entrypoint-vite.sh
RUN npm ci --ignore-scripts RUN chmod +x /entrypoint-vite.sh
ENTRYPOINT ["/entrypoint-vite.sh"]
# ── Stage 2b: Node / Vite build ────────────────────────────────────────────── # ── Stage 2b: Node / Vite build ──────────────────────────────────────────────
FROM node:22-alpine AS node-build FROM node:22-alpine AS node-build
@@ -94,7 +102,8 @@ RUN php artisan package:discover --ansi \
&& php artisan filament:assets --ansi && php artisan filament:assets --ansi
COPY docker/entrypoint.sh /entrypoint.sh COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh COPY docker/entrypoint-worker.sh /entrypoint-worker.sh
RUN chmod +x /entrypoint.sh /entrypoint-worker.sh
EXPOSE 9000 EXPOSE 9000
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
+2 -8
View File
@@ -19,7 +19,6 @@
pull pull
docker_build docker_build
docker_up docker_up
migrate
artisan_optimize artisan_optimize
restart_workers restart_workers
stoic_generate_thumbs stoic_generate_thumbs
@@ -45,12 +44,6 @@
{{ $compose }} up -d --remove-orphans {{ $compose }} up -d --remove-orphans
@endtask @endtask
@task('migrate', ['on' => 'web'])
echo ">>> Running migrations..."
cd {{ $path }}
{{ $compose }} exec -T app php artisan migrate --force < /dev/null
@endtask
@task('artisan_optimize', ['on' => 'web']) @task('artisan_optimize', ['on' => 'web'])
echo ">>> Optimizing..." echo ">>> Optimizing..."
cd {{ $path }} cd {{ $path }}
@@ -61,9 +54,10 @@
@endtask @endtask
@task('restart_workers', ['on' => 'web']) @task('restart_workers', ['on' => 'web'])
echo ">>> Restarting queue workers..." echo ">>> Restarting queue workers and scheduler..."
cd {{ $path }} cd {{ $path }}
{{ $compose }} restart queue {{ $compose }} restart queue
{{ $compose }} restart scheduler
@endtask @endtask
@task('password_protect', ['on' => 'web']) @task('password_protect', ['on' => 'web'])
+15 -4
View File
@@ -5,16 +5,18 @@
use App\Http\Requests\ContactRequest; use App\Http\Requests\ContactRequest;
use App\Mail\ContactConfirmationMail; use App\Mail\ContactConfirmationMail;
use App\Mail\ContactMessageMail; use App\Mail\ContactMessageMail;
use App\Models\StoicPage;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\View\View; use Illuminate\View\View;
use Modules\Core\Store\Services\StoreDetailsService;
use Throwable; use Throwable;
/** /**
* Contact form. Nothing is stored: the message is emailed to the store * Contact form. Nothing is stored: the message is emailed to the store
* (CONTACT_EMAIL) and the sender gets a generic confirmation. Both are sent * (Store Details' 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. * 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 * Limited per IP in here rather than with throttle middleware, so the limit
@@ -27,7 +29,16 @@ class ContactController extends Controller
public function index(string $locale): View public function index(string $locale): View
{ {
return view('contact'); $page = StoicPage::localized('contact');
if (! $page) {
abort(404);
}
return view('contact', [
'page' => $page,
'store' => app(StoreDetailsService::class)->current(),
]);
} }
public function send(string $locale, ContactRequest $request): RedirectResponse public function send(string $locale, ContactRequest $request): RedirectResponse
@@ -41,11 +52,11 @@ public function send(string $locale, ContactRequest $request): RedirectResponse
RateLimiter::hit($key, self::SEND_DECAY_SECONDS); RateLimiter::hit($key, self::SEND_DECAY_SECONDS);
$data = $request->validated(); $data = $request->validated();
$to = config('services.contact.email'); $to = app(StoreDetailsService::class)->current()->contact_email;
try { try {
if (blank($to)) { if (blank($to)) {
throw new \RuntimeException('CONTACT_EMAIL is not set.'); throw new \RuntimeException('Store Details has no contact email.');
} }
Mail::to($to)->send(new ContactMessageMail( Mail::to($to)->send(new ContactMessageMail(
+51 -6
View File
@@ -4,27 +4,72 @@
use App\Catalog\ProductCard; use App\Catalog\ProductCard;
use App\Models\StoicPage; use App\Models\StoicPage;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\Services\CollectionService;
use Modules\Core\Catalog\Services\ProductService; use Modules\Core\Catalog\Services\ProductService;
class HomeController extends Controller class HomeController extends Controller
{ {
public function __construct(private readonly ProductService $products) {} /** Slug of the hero collection in the "Homepage" collection group. */
private const HERO_COLLECTION_SLUG = 'hero';
private const HERO_FALLBACK_COUNT = 5;
private const CLASSICS_COUNT = 8;
public function __construct(
private readonly ProductService $products,
private readonly CollectionService $collections,
) {}
public function index(string $locale) public function index(string $locale)
{ {
$page = StoicPage::firstWhere('slug', 'home'); $page = StoicPage::localized('home');
if (! $page) { if (! $page) {
abort(404); abort(404);
} }
$products = collect($this->products->random(13)) // Random pool for the classics carousel, and for the hero too when the
->map(fn (array $product) => ProductCard::fromIndexed($product)); // hero collection is missing or empty. Oversized so hero products can be
// excluded from it and still leave enough.
$random = collect($this->products->random(self::HERO_FALLBACK_COUNT + self::CLASSICS_COUNT));
$hero = collect($this->heroCollectionProducts());
if ($hero->isEmpty()) {
$hero = $random->take(self::HERO_FALLBACK_COUNT);
}
$heroIds = $hero->pluck('id')->all();
$classics = $random
->reject(fn (array $product) => in_array($product['id'], $heroIds))
->take(self::CLASSICS_COUNT);
return view('home', [ return view('home', [
'page' => $page, 'page' => $page,
'heroProducts' => $products->take(5)->values(), 'heroProducts' => $hero->map(fn (array $product) => ProductCard::fromIndexed($product))->values(),
'classicsProducts' => $products->slice(5)->values(), 'classicsProducts' => $classics->map(fn (array $product) => ProductCard::fromIndexed($product))->values(),
]); ]);
} }
/**
* Products the store owner picked for the hero, via the Lunar collection
* with slug "hero". Not in the owner's drag order yet: the product index
* doesn't carry per-collection position (pending a boboko-core task).
*/
private function heroCollectionProducts(): array
{
$collection = $this->collections->getBySlug(self::HERO_COLLECTION_SLUG);
if ($collection === null) {
return [];
}
return $this->products
->list(new ProductFilters(collectionId: $collection['id']))
->products
->items();
}
} }
+39 -2
View File
@@ -3,9 +3,28 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\StoicPage; use App\Models\StoicPage;
use Modules\Core\Store\Services\StoreDetailsService;
class LegalPageController extends Controller class LegalPageController extends Controller
{ {
/**
* {placeholder} => StoreDetails column, usable in the Stoic legal page
* markdown.
*/
private const PLACEHOLDERS = [
'{store_name}' => 'name',
'{store_address}' => 'address',
'{store_phone}' => 'phone',
'{store_contact_email}' => 'contact_email',
'{store_legal_name}' => 'legal_name',
'{store_tax_identifier}' => 'tax_identifier',
'{store_registration_number}' => 'registration_number',
];
public function __construct(
private readonly StoreDetailsService $storeDetails,
) {}
public function terms(string $locale) public function terms(string $locale)
{ {
return $this->show('terms-and-conditions'); return $this->show('terms-and-conditions');
@@ -28,12 +47,30 @@ public function cookies(string $locale)
private function show(string $slug) private function show(string $slug)
{ {
$page = StoicPage::firstWhere('slug', $slug); $page = StoicPage::localized($slug);
if (! $page) { if (! $page) {
abort(404); abort(404);
} }
return view('legal', ['page' => $page]); return view('legal', [
'page' => $page,
'content' => $this->fillPlaceholders($page->content ?? ''),
]);
}
/**
* Swapped in before the markdown is rendered, so values are escaped: the
* rendered markdown is printed unescaped. An empty field renders as
* nothing rather than leaving the raw placeholder on the page.
*/
private function fillPlaceholders(string $content): string
{
$details = $this->storeDetails->current();
return strtr($content, array_map(
fn (string $column) => e((string) ($details->translate($column) ?? '')),
self::PLACEHOLDERS,
));
} }
} }
+7 -23
View File
@@ -3,19 +3,21 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Catalog\ProductCard; use App\Catalog\ProductCard;
use App\Services\Wishlist;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\View\View; use Illuminate\View\View;
use Lunar\Models\Product;
use Modules\Core\Catalog\Services\ProductService; use Modules\Core\Catalog\Services\ProductService;
use Modules\Core\Wishlist\Services\WishlistService;
/**
* Page rendering only — the toggle action itself lives in core
* (Modules\Core\Wishlist\Http\Controllers\WishlistController, route
* `wishlist.toggle`), since it needs no app-specific presentation.
*/
class WishlistController extends Controller class WishlistController extends Controller
{ {
public function __construct( public function __construct(
private readonly Wishlist $wishlist, private readonly WishlistService $wishlist,
) {} ) {}
/** /**
@@ -39,24 +41,6 @@ public function guest(string $locale, ProductService $products): View|RedirectRe
return view('wishlist.guest', ['products' => $this->products($products)]); return view('wishlist.guest', ['products' => $this->products($products)]);
} }
/**
* Adds or removes a product, for guests and logged-in shoppers alike. The
* heart button's Stimulus controller asks for JSON; without JS the form
* posts normally and comes back to the same page.
*/
public function toggle(string $locale, Request $request, int $productId): JsonResponse|RedirectResponse
{
abort_unless(Product::whereKey($productId)->exists(), 404);
$active = $this->wishlist->toggle($productId);
if ($request->expectsJson()) {
return response()->json(['active' => $active]);
}
return back();
}
/** /**
* Product cards for the current wishlist, newest first. Products no longer * Product cards for the current wishlist, newest first. Products no longer
* in the search index (deleted, unpublished) are simply skipped. * in the search index (deleted, unpublished) are simply skipped.
@@ -1,23 +0,0 @@
<?php
namespace App\Listeners;
use App\Services\Wishlist;
use Modules\Core\Auth\Events\UserAuthenticated;
/**
* Picked up by Laravel's listener discovery (app/Listeners), no manual
* registration. Runs inside the login request, so it can read the guest
* wishlist cookie and queue its removal.
*/
class MergeGuestWishlistOnLogin
{
public function __construct(
private readonly Wishlist $wishlist,
) {}
public function handle(UserAuthenticated $event): void
{
$this->wishlist->mergeGuestInto($event->user);
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
use Illuminate\Mail\Mailables\Envelope; use Illuminate\Mail\Mailables\Envelope;
/** /**
* A contact-form message, sent to the store's CONTACT_EMAIL. Reply-To is the * A contact-form message, sent to Store Details' contact email. Reply-To is the
* sender, so replying from the inbox goes straight to them. * sender, so replying from the inbox goes straight to them.
*/ */
class ContactMessageMail extends Mailable class ContactMessageMail extends Mailable
+17 -1
View File
@@ -2,16 +2,32 @@
namespace App\Models; namespace App\Models;
use App\Services\Stoic;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use JacobJoergensen\LaravelPaper\Attributes\ContentPath; use JacobJoergensen\LaravelPaper\Attributes\ContentPath;
use JacobJoergensen\LaravelPaper\Attributes\Driver; use JacobJoergensen\LaravelPaper\Attributes\Driver;
use JacobJoergensen\LaravelPaper\Attributes\Timestamps; use JacobJoergensen\LaravelPaper\Attributes\Timestamps;
use JacobJoergensen\LaravelPaper\Paper; use JacobJoergensen\LaravelPaper\Paper;
// Points at the default locale (el), which Stoic writes every field to. Other
// locales hold only the translatable fields — use localized() to get a page
// with those layered on top for the current locale.
#[Driver("markdown")] #[Driver("markdown")]
#[ContentPath("resources/stoic/content/pages")] #[ContentPath("resources/stoic/content/el/pages")]
#[Timestamps] #[Timestamps]
class StoicPage extends Model class StoicPage extends Model
{ {
use Paper; use Paper;
/**
* The page in the current locale: the default-locale entry, with the
* current locale's non-empty fields (and body) swapped in. Untranslated
* fields fall back to the default locale.
*/
public static function localized(string $slug): ?static
{
$page = static::firstWhere('slug', $slug);
return $page?->forceFill(Stoic::localeOverrides("pages/{$slug}.md"));
}
} }
-14
View File
@@ -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'];
}
+47 -8
View File
@@ -4,41 +4,80 @@
use App\Models\Customer; use App\Models\Customer;
use App\Models\Staff; use App\Models\Staff;
use App\Services\StoicSettings;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\View\View as ViewInstance; use Illuminate\View\View as ViewInstance;
use Lunar\Facades\ModelManifest; use Lunar\Facades\ModelManifest;
use Lunar\Facades\Telemetry; use Lunar\Facades\Telemetry;
use Lunar\Models\CollectionGroup;
use Modules\Core\Catalog\DTOs\CollectionFilters; use Modules\Core\Catalog\DTOs\CollectionFilters;
use Modules\Core\Catalog\Enums\CollectionSort; use Modules\Core\Catalog\Enums\CollectionSort;
use Modules\Core\Catalog\Services\CollectionService; use Modules\Core\Catalog\Services\CollectionService;
use Modules\Core\Store\Services\StoreDetailsService;
use Symfony\Component\Mime\Address;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
public function register(): void public function register(): void
{ {
// One instance per request: it caches the guest cookie's ids, so a $this->app->scoped(StoicSettings::class);
// toggle and a later has() in the same request agree.
$this->app->scoped(\App\Services\Wishlist::class);
} }
public function boot(): void public function boot(): void
{ {
Telemetry::optOut(); Telemetry::optOut();
// header.blade.php's category dropdown — root collections only, resolved // header.blade.php's category dropdown — root collections of the catalog
// per-request so the composer runs after `locale` middleware has already // group only, so merchandising groups (e.g. "Homepage") stay out of the
// set App::getLocale(), which CollectionService's name resolution depends on. // nav. Resolved per-request so the composer runs after `locale` middleware
// has already set App::getLocale(), which CollectionService's name
// resolution depends on. The group is looked up by handle, not id, since
// ids can differ between environments.
View::composer('components.header', function (ViewInstance $view) { View::composer('components.header', function (ViewInstance $view) {
$view->with('categories', app(CollectionService::class)->list( $groupId = CollectionGroup::where('handle', 'shopify')->value('id');
filters: new CollectionFilters(rootOnly: true),
$view->with('categories', $groupId === null ? [] : app(CollectionService::class)->list(
filters: new CollectionFilters(groupId: $groupId, rootOnly: true),
perPage: 100, perPage: 100,
sort: CollectionSort::Position, sort: CollectionSort::Position,
)->items()); )->items());
}); });
// Site-wide settings edited in Stoic (settings.md): default meta
// description, social links for the footer, emails and schema.org.
View::composer(
['layouts.app', 'components.footer', 'emails.layout', 'home'],
fn (ViewInstance $view) => $view->with('stoicSettings', app(StoicSettings::class)),
);
// INTERIM, belongs in boboko-core's Store module: the sender name on
// outgoing emails comes from Store Details' "Mail from name", with
// MAIL_FROM_NAME as the fallback when it's empty. Done per message
// rather than by setting config at boot, so a long-running queue
// worker picks up a changed name without a restart. Only the default
// from address is renamed; a mailable with its own from is left alone.
Event::listen(MessageSending::class, function (MessageSending $event) {
$name = app(StoreDetailsService::class)->current()->mail_from_name;
if (blank($name)) {
return;
}
$defaultAddress = config('mail.from.address');
$event->message->from(...array_map(
fn (Address $from) => $from->getAddress() === $defaultAddress
? new Address($from->getAddress(), $name)
: $from,
$event->message->getFrom(),
));
});
if ($this->app->environment('production')) { if ($this->app->environment('production')) {
URL::forceScheme('https'); URL::forceScheme('https');
} }
+37
View File
@@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Spatie\YamlFrontMatter\YamlFrontMatter;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
class Stoic class Stoic
@@ -29,6 +30,42 @@ public static function presets(): array
return static::$presets; return static::$presets;
} }
// Stoic's default locale (first in i18n.locales), or null when i18n is off.
// Its content folder holds every field; other locales only translatable ones.
public static function defaultLocale(): ?string
{
static::loadConfig();
return static::$config["i18n"]["locales"][0]["code"] ?? null;
}
// The current locale's translated fields for a content file (path relative
// to a locale folder, e.g. "pages/home.md"), with the markdown body as
// `content`. Empty when the current locale is the default one (or i18n is
// off), the file doesn't exist, or a field is left blank — so layering this
// over the default-locale values falls back to them field by field.
public static function localeOverrides(string $file): array
{
$default = static::defaultLocale();
$locale = app()->getLocale();
if ($default === null || $locale === $default) {
return [];
}
$path = resource_path("stoic/content/{$locale}/{$file}");
if (!is_file($path)) {
return [];
}
$document = YamlFrontMatter::parse(file_get_contents($path));
return array_filter(
[...$document->matter(), "content" => trim($document->body())],
fn($value) => filled($value),
);
}
// Returns the public URL for a stoic image at a given preset. // Returns the public URL for a stoic image at a given preset.
public static function image(string $filename, string $preset): string public static function image(string $filename, string $preset): string
{ {
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace App\Services;
use Spatie\YamlFrontMatter\YamlFrontMatter;
/**
* Site-wide, marketing-editable settings from Stoic's `settings` singleton
* (resources/stoic/content/{locale}/settings.md). Read directly rather than
* through a Paper model: Paper models map to a folder of entries, and this is
* one file per locale. Legal and transactional details (address, ΑΦΜ, phone)
* live in boboko-core's StoreDetails instead.
*
* Stoic writes every field to the default locale's file and only the
* translatable ones (e.g. meta_description) to the others, so the current
* locale's file is layered over the default one; empty values fall through.
*
* Bound as scoped in AppServiceProvider, so files are parsed at most once per
* locale per request (or queued job) and edits in Stoic show up on the next one.
* The locale is read per call, not at construction, so a mail rendered with
* ->locale() still gets its own locale's values.
*/
class StoicSettings
{
private const SOCIALS = [
'facebook' => 'Facebook',
'instagram' => 'Instagram',
'tiktok' => 'TikTok',
];
/** @var array<string, array<string, mixed>> keyed by locale */
private array $data = [];
public function get(string $key, mixed $default = null): mixed
{
$value = $this->data()[$key] ?? null;
return filled($value) ? $value : $default;
}
/**
* Absolute og:image URL for a Stoic image field value (the page's own, else
* the default from settings.md), or null. Image fields store a
* comma-separated list; only the first is used.
*/
public function ogImageUrl(?string $pageImage = null): ?string
{
$filename = trim(explode(',', $pageImage ?: (string) $this->get('og_image', ''))[0]);
return $filename === '' ? null : url(Stoic::image($filename, 'large'));
}
/**
* Only the networks that have a URL set, in display order.
*
* @return list<array{icon: string, label: string, url: string}>
*/
public function socialLinks(): array
{
$links = [];
foreach (self::SOCIALS as $icon => $label) {
if ($url = $this->get("{$icon}_url")) {
$links[] = ['icon' => $icon, 'label' => $label, 'url' => $url];
}
}
return $links;
}
private function data(): array
{
$locale = app()->getLocale();
return $this->data[$locale] ??= $this->load();
}
private function load(): array
{
$default = Stoic::defaultLocale();
if ($default === null) {
return $this->parse(resource_path('stoic/content/settings.md'));
}
return array_merge(
$this->parse(resource_path("stoic/content/{$default}/settings.md")),
Stoic::localeOverrides('settings.md'),
);
}
private function parse(string $path): array
{
return is_file($path) ? YamlFrontMatter::parse(file_get_contents($path))->matter() : [];
}
}
-126
View File
@@ -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);
}
}
Generated
+249 -90
View File
@@ -515,11 +515,11 @@
}, },
{ {
"name": "boboko/core", "name": "boboko/core",
"version": "0.22.0", "version": "0.26.5",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://code.radical-elements.com/boboko/core.git", "url": "https://code.radical-elements.com/boboko/core.git",
"reference": "985f53efa2d34849f35c8e0f4129f861be762e76" "reference": "5f0dbbb734f10f0957eb53554743bd71d6db22e1"
}, },
"require": { "require": {
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
@@ -552,6 +552,7 @@
"Modules\\Core\\Providers\\AuthServiceProvider", "Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider", "Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\CheckoutServiceProvider", "Modules\\Core\\Providers\\CheckoutServiceProvider",
"Modules\\Core\\Providers\\CheckoutModuleServiceProvider",
"Modules\\Core\\Providers\\PaymentServiceProvider", "Modules\\Core\\Providers\\PaymentServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider", "Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\CatalogServiceProvider", "Modules\\Core\\Providers\\CatalogServiceProvider",
@@ -560,7 +561,9 @@
"Modules\\Core\\Providers\\FileServiceProvider", "Modules\\Core\\Providers\\FileServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider", "Modules\\Core\\Providers\\ShippingServiceProvider",
"Modules\\Core\\Providers\\OrderServiceProvider", "Modules\\Core\\Providers\\OrderServiceProvider",
"Modules\\Core\\Providers\\PrivacyServiceProvider" "Modules\\Core\\Providers\\PrivacyServiceProvider",
"Modules\\Core\\Providers\\WishlistServiceProvider",
"Modules\\Core\\Providers\\StoreServiceProvider"
] ]
} }
}, },
@@ -570,7 +573,7 @@
} }
}, },
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"time": "2026-09-25T12:58:41+00:00" "time": "2026-09-28T14:36:39+00:00"
}, },
{ {
"name": "brick/math", "name": "brick/math",
@@ -934,6 +937,151 @@
], ],
"time": "2023-12-20T15:40:13+00:00" "time": "2023-12-20T15:40:13+00:00"
}, },
{
"name": "composer/class-map-generator",
"version": "1.8.0",
"source": {
"type": "git",
"url": "https://github.com/composer/class-map-generator.git",
"reference": "c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/class-map-generator/zipball/c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4",
"reference": "c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4",
"shasum": ""
},
"require": {
"composer/pcre": "^2.1 || ^3.1",
"php": "^7.2 || ^8.0",
"symfony/finder": "^4.4 || ^5.3 || ^6 || ^7 || ^8"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-deprecation-rules": "^1 || ^2",
"phpstan/phpstan-phpunit": "^1 || ^2",
"phpstan/phpstan-strict-rules": "^1.1 || ^2",
"phpunit/phpunit": "^8",
"symfony/filesystem": "^5.4 || ^6 || ^7 || ^8"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\ClassMapGenerator\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "https://seld.be"
}
],
"description": "Utilities to scan PHP code and generate class maps.",
"keywords": [
"classmap"
],
"support": {
"issues": "https://github.com/composer/class-map-generator/issues",
"source": "https://github.com/composer/class-map-generator/tree/1.8.0"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2026-09-25T15:24:19+00:00"
},
{
"name": "composer/pcre",
"version": "3.4.0",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<2.2.2"
},
"require-dev": {
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.4.0"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2026-06-07T11:47:49+00:00"
},
{ {
"name": "composer/semver", "name": "composer/semver",
"version": "3.5.0", "version": "3.5.0",
@@ -1692,16 +1840,16 @@
}, },
{ {
"name": "filament/actions", "name": "filament/actions",
"version": "v4.13.4", "version": "v4.14.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/actions.git", "url": "https://github.com/filamentphp/actions.git",
"reference": "1eb39ac07f06302262cf6f5d77927bff1ed626fb" "reference": "ff79ce562cd774665531202c9c78549dc4fa922c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/actions/zipball/1eb39ac07f06302262cf6f5d77927bff1ed626fb", "url": "https://api.github.com/repos/filamentphp/actions/zipball/ff79ce562cd774665531202c9c78549dc4fa922c",
"reference": "1eb39ac07f06302262cf6f5d77927bff1ed626fb", "reference": "ff79ce562cd774665531202c9c78549dc4fa922c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1737,20 +1885,20 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2026-09-15T16:56:16+00:00" "time": "2026-09-26T19:38:45+00:00"
}, },
{ {
"name": "filament/filament", "name": "filament/filament",
"version": "v4.13.4", "version": "v4.14.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/panels.git", "url": "https://github.com/filamentphp/panels.git",
"reference": "1215d7d79b5609f66c1e432933610eefa0ab3ad6" "reference": "e0f5272ee6f75f64dc831a8ac5ed05e31fa55436"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/panels/zipball/1215d7d79b5609f66c1e432933610eefa0ab3ad6", "url": "https://api.github.com/repos/filamentphp/panels/zipball/e0f5272ee6f75f64dc831a8ac5ed05e31fa55436",
"reference": "1215d7d79b5609f66c1e432933610eefa0ab3ad6", "reference": "e0f5272ee6f75f64dc831a8ac5ed05e31fa55436",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1795,20 +1943,20 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2026-09-20T19:15:07+00:00" "time": "2026-09-26T19:39:41+00:00"
}, },
{ {
"name": "filament/forms", "name": "filament/forms",
"version": "v4.13.4", "version": "v4.14.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/forms.git", "url": "https://github.com/filamentphp/forms.git",
"reference": "10d7ba430544ae706a406713105ba37b18f52008" "reference": "10c084a854bf6b9962717b8c21e12c579a8e9127"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/forms/zipball/10d7ba430544ae706a406713105ba37b18f52008", "url": "https://api.github.com/repos/filamentphp/forms/zipball/10c084a854bf6b9962717b8c21e12c579a8e9127",
"reference": "10d7ba430544ae706a406713105ba37b18f52008", "reference": "10c084a854bf6b9962717b8c21e12c579a8e9127",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1819,6 +1967,9 @@
"php": "^8.2", "php": "^8.2",
"ueberdosis/tiptap-php": "^2.0" "ueberdosis/tiptap-php": "^2.0"
}, },
"suggest": {
"fakerphp/faker": "Required to generate fake rich content and Markdown."
},
"type": "library", "type": "library",
"extra": { "extra": {
"laravel": { "laravel": {
@@ -1845,11 +1996,11 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2026-09-20T18:45:30+00:00" "time": "2026-09-26T19:39:08+00:00"
}, },
{ {
"name": "filament/infolists", "name": "filament/infolists",
"version": "v4.13.4", "version": "v4.14.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/infolists.git", "url": "https://github.com/filamentphp/infolists.git",
@@ -1894,7 +2045,7 @@
}, },
{ {
"name": "filament/notifications", "name": "filament/notifications",
"version": "v4.13.4", "version": "v4.14.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/notifications.git", "url": "https://github.com/filamentphp/notifications.git",
@@ -1941,7 +2092,7 @@
}, },
{ {
"name": "filament/query-builder", "name": "filament/query-builder",
"version": "v4.13.4", "version": "v4.14.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/query-builder.git", "url": "https://github.com/filamentphp/query-builder.git",
@@ -1987,16 +2138,16 @@
}, },
{ {
"name": "filament/schemas", "name": "filament/schemas",
"version": "v4.13.4", "version": "v4.14.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/schemas.git", "url": "https://github.com/filamentphp/schemas.git",
"reference": "dc4eeb57671b104b9e60f6d3f435f803c705e6df" "reference": "fdb14a2cab2ffd2f7c6d8eef4593a4a4ed2c25ba"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/schemas/zipball/dc4eeb57671b104b9e60f6d3f435f803c705e6df", "url": "https://api.github.com/repos/filamentphp/schemas/zipball/fdb14a2cab2ffd2f7c6d8eef4593a4a4ed2c25ba",
"reference": "dc4eeb57671b104b9e60f6d3f435f803c705e6df", "reference": "fdb14a2cab2ffd2f7c6d8eef4593a4a4ed2c25ba",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2028,11 +2179,11 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2026-09-20T18:49:21+00:00" "time": "2026-09-26T19:39:19+00:00"
}, },
{ {
"name": "filament/spatie-laravel-media-library-plugin", "name": "filament/spatie-laravel-media-library-plugin",
"version": "v4.13.4", "version": "v4.14.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git", "url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git",
@@ -2069,27 +2220,28 @@
}, },
{ {
"name": "filament/support", "name": "filament/support",
"version": "v4.13.4", "version": "v4.14.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/support.git", "url": "https://github.com/filamentphp/support.git",
"reference": "6bd7bdb5f5ff3b1a70cac6dabe28f5d996a36bcc" "reference": "8468fa804bb8eed56ae7a86b2648822ec26e0476"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/support/zipball/6bd7bdb5f5ff3b1a70cac6dabe28f5d996a36bcc", "url": "https://api.github.com/repos/filamentphp/support/zipball/8468fa804bb8eed56ae7a86b2648822ec26e0476",
"reference": "6bd7bdb5f5ff3b1a70cac6dabe28f5d996a36bcc", "reference": "8468fa804bb8eed56ae7a86b2648822ec26e0476",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"blade-ui-kit/blade-heroicons": "^2.5", "blade-ui-kit/blade-heroicons": "^2.5",
"composer-runtime-api": "^2.1", "composer-runtime-api": "^2.1",
"composer/class-map-generator": "^1.6",
"danharrin/livewire-rate-limiting": "^2.0", "danharrin/livewire-rate-limiting": "^2.0",
"ext-intl": "*", "ext-intl": "*",
"illuminate/contracts": "^11.28|^12.0|^13.0", "illuminate/contracts": "^11.28|^12.0|^13.0",
"kirschbaum-development/eloquent-power-joins": "^4.0", "kirschbaum-development/eloquent-power-joins": "^4.0",
"league/uri-components": "^7.0", "league/uri-components": "^7.0",
"livewire/livewire": "^3.7", "livewire/livewire": "^3.8.6",
"nette/php-generator": "^4.0", "nette/php-generator": "^4.0",
"php": "^8.2", "php": "^8.2",
"ryangjchandler/blade-capture-directive": "^1.0", "ryangjchandler/blade-capture-directive": "^1.0",
@@ -2124,20 +2276,20 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2026-09-20T19:14:52+00:00" "time": "2026-09-26T19:38:44+00:00"
}, },
{ {
"name": "filament/tables", "name": "filament/tables",
"version": "v4.13.4", "version": "v4.14.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/tables.git", "url": "https://github.com/filamentphp/tables.git",
"reference": "8a0c6634d752e0b71ed0871f92d533c0d03d7601" "reference": "420ca362362a0a5059706b7c850b01706d27f6b6"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/filamentphp/tables/zipball/8a0c6634d752e0b71ed0871f92d533c0d03d7601", "url": "https://api.github.com/repos/filamentphp/tables/zipball/420ca362362a0a5059706b7c850b01706d27f6b6",
"reference": "8a0c6634d752e0b71ed0871f92d533c0d03d7601", "reference": "420ca362362a0a5059706b7c850b01706d27f6b6",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2170,11 +2322,11 @@
"issues": "https://github.com/filamentphp/filament/issues", "issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament" "source": "https://github.com/filamentphp/filament"
}, },
"time": "2026-09-20T18:53:09+00:00" "time": "2026-09-26T19:39:27+00:00"
}, },
{ {
"name": "filament/widgets", "name": "filament/widgets",
"version": "v4.13.4", "version": "v4.14.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/filamentphp/widgets.git", "url": "https://github.com/filamentphp/widgets.git",
@@ -2289,16 +2441,16 @@
}, },
{ {
"name": "google/protobuf", "name": "google/protobuf",
"version": "v5.36.1", "version": "v5.36.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/protocolbuffers/protobuf-php.git", "url": "https://github.com/protocolbuffers/protobuf-php.git",
"reference": "d64d16befba8632967f604b9644c0bb8f64cfbc3" "reference": "020bc8e831611869ac387ddd38fbbadd78d15195"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/d64d16befba8632967f604b9644c0bb8f64cfbc3", "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/020bc8e831611869ac387ddd38fbbadd78d15195",
"reference": "d64d16befba8632967f604b9644c0bb8f64cfbc3", "reference": "020bc8e831611869ac387ddd38fbbadd78d15195",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2327,9 +2479,9 @@
"proto" "proto"
], ],
"support": { "support": {
"source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.36.1" "source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.36.2"
}, },
"time": "2026-08-31T22:07:31+00:00" "time": "2026-09-17T15:56:42+00:00"
}, },
{ {
"name": "graham-campbell/result-type", "name": "graham-campbell/result-type",
@@ -2870,16 +3022,16 @@
}, },
{ {
"name": "jacobjoergensen/laravel-paper", "name": "jacobjoergensen/laravel-paper",
"version": "1.16.0", "version": "1.18.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/JacobJoergensen/laravel-paper.git", "url": "https://github.com/JacobJoergensen/laravel-paper.git",
"reference": "3bcdc1d929ace91e138b268d2394830d559e3695" "reference": "fd68cb14448245185953f673c0e1504c6c4f71cf"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/JacobJoergensen/laravel-paper/zipball/3bcdc1d929ace91e138b268d2394830d559e3695", "url": "https://api.github.com/repos/JacobJoergensen/laravel-paper/zipball/fd68cb14448245185953f673c0e1504c6c4f71cf",
"reference": "3bcdc1d929ace91e138b268d2394830d559e3695", "reference": "fd68cb14448245185953f673c0e1504c6c4f71cf",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2887,7 +3039,8 @@
"illuminate/filesystem": "^12.0|^13.0", "illuminate/filesystem": "^12.0|^13.0",
"illuminate/support": "^12.0|^13.0", "illuminate/support": "^12.0|^13.0",
"php": "^8.4", "php": "^8.4",
"spatie/yaml-front-matter": "^2.0" "spatie/yaml-front-matter": "^2.0",
"symfony/yaml": "^7.0|^8.0"
}, },
"require-dev": { "require-dev": {
"laravel/pint": "^1.29", "laravel/pint": "^1.29",
@@ -2921,6 +3074,7 @@
} }
], ],
"description": "A flat-file Eloquent driver for modern Laravel.", "description": "A flat-file Eloquent driver for modern Laravel.",
"homepage": "https://laravel-paper.com",
"keywords": [ "keywords": [
"cms", "cms",
"content", "content",
@@ -2932,9 +3086,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/JacobJoergensen/laravel-paper/issues", "issues": "https://github.com/JacobJoergensen/laravel-paper/issues",
"source": "https://github.com/JacobJoergensen/laravel-paper/tree/1.16.0" "source": "https://github.com/JacobJoergensen/laravel-paper/tree/1.18.0"
}, },
"time": "2026-08-25T18:56:34+00:00" "time": "2026-09-25T00:16:02+00:00"
}, },
{ {
"name": "kirschbaum-development/eloquent-power-joins", "name": "kirschbaum-development/eloquent-power-joins",
@@ -5151,16 +5305,16 @@
}, },
{ {
"name": "nesbot/carbon", "name": "nesbot/carbon",
"version": "3.14.0", "version": "3.14.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/CarbonPHP/carbon.git", "url": "https://github.com/CarbonPHP/carbon.git",
"reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2" "reference": "34e9109535165bf1d7ff17a610492372ae73a896"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/0023eaa2c9110e47446dd512a263c69c40cd41f2", "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/34e9109535165bf1d7ff17a610492372ae73a896",
"reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2", "reference": "34e9109535165bf1d7ff17a610492372ae73a896",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5252,7 +5406,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-09-12T20:45:19+00:00" "time": "2026-09-27T12:07:26+00:00"
}, },
{ {
"name": "nette/php-generator", "name": "nette/php-generator",
@@ -6032,16 +6186,16 @@
}, },
{ {
"name": "open-telemetry/opentelemetry-auto-laravel", "name": "open-telemetry/opentelemetry-auto-laravel",
"version": "1.8.0", "version": "1.9.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/opentelemetry-php/contrib-auto-laravel.git", "url": "https://github.com/opentelemetry-php/contrib-auto-laravel.git",
"reference": "f0a750eeb0a69012d81c84010a4c7a7b61926e00" "reference": "fd81cb8e18af4b5405fb67c84622833886fa51da"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/opentelemetry-php/contrib-auto-laravel/zipball/f0a750eeb0a69012d81c84010a4c7a7b61926e00", "url": "https://api.github.com/repos/opentelemetry-php/contrib-auto-laravel/zipball/fd81cb8e18af4b5405fb67c84622833886fa51da",
"reference": "f0a750eeb0a69012d81c84010a4c7a7b61926e00", "reference": "fd81cb8e18af4b5405fb67c84622833886fa51da",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6058,7 +6212,7 @@
"nunomaduro/collision": "*", "nunomaduro/collision": "*",
"open-telemetry/sdk": "^1.8", "open-telemetry/sdk": "^1.8",
"orchestra/testbench": ">=7.41.3", "orchestra/testbench": ">=7.41.3",
"phan/phan": "^5.0", "phan/phan": "^6.0",
"php-http/mock-client": "*", "php-http/mock-client": "*",
"phpstan/phpstan": "^1.1", "phpstan/phpstan": "^1.1",
"phpstan/phpstan-phpunit": "^1.0", "phpstan/phpstan-phpunit": "^1.0",
@@ -6095,9 +6249,9 @@
"tracing" "tracing"
], ],
"support": { "support": {
"source": "https://github.com/opentelemetry-php/contrib-auto-laravel/tree/1.8.0" "source": "https://github.com/opentelemetry-php/contrib-auto-laravel/tree/1.9.1"
}, },
"time": "2026-07-09T20:34:43+00:00" "time": "2026-09-17T05:26:08+00:00"
}, },
{ {
"name": "open-telemetry/sdk", "name": "open-telemetry/sdk",
@@ -6201,16 +6355,16 @@
}, },
{ {
"name": "open-telemetry/sem-conv", "name": "open-telemetry/sem-conv",
"version": "1.38.0", "version": "1.44.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/opentelemetry-php/sem-conv.git", "url": "https://github.com/opentelemetry-php/sem-conv.git",
"reference": "e613bc640a407def4991b8a936a9b27edd9a3240" "reference": "c94bc7ebacef039cda4f4c9270807dcc58b4e3af"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/e613bc640a407def4991b8a936a9b27edd9a3240", "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/c94bc7ebacef039cda4f4c9270807dcc58b4e3af",
"reference": "e613bc640a407def4991b8a936a9b27edd9a3240", "reference": "c94bc7ebacef039cda4f4c9270807dcc58b4e3af",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6254,7 +6408,7 @@
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php" "source": "https://github.com/open-telemetry/opentelemetry-php"
}, },
"time": "2026-01-21T04:14:03+00:00" "time": "2026-09-09T22:06:17+00:00"
}, },
{ {
"name": "openspout/openspout", "name": "openspout/openspout",
@@ -6803,20 +6957,25 @@
}, },
{ {
"name": "phpdocumentor/reflection-common", "name": "phpdocumentor/reflection-common",
"version": "2.2.0", "version": "2.2.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpDocumentor/ReflectionCommon.git", "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" "reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/ee15a5a2022c8383906b225055e9496a6a5e6e3b",
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", "reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2 || ^8.0" "php": "^7.4 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.8",
"phpunit/phpunit": "^9.5",
"vimeo/psalm": "^4.25"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -6840,7 +6999,7 @@
} }
], ],
"description": "Common reflection classes used by phpdocumentor to reflect the code structure", "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
"homepage": "http://www.phpdoc.org", "homepage": "https://www.phpdoc.org",
"keywords": [ "keywords": [
"FQSEN", "FQSEN",
"phpDocumentor", "phpDocumentor",
@@ -6850,9 +7009,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
"source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.2.1"
}, },
"time": "2020-06-27T09:03:43+00:00" "time": "2025-11-10T21:21:55+00:00"
}, },
{ {
"name": "phpdocumentor/reflection-docblock", "name": "phpdocumentor/reflection-docblock",
@@ -7054,16 +7213,16 @@
}, },
{ {
"name": "phpstan/phpdoc-parser", "name": "phpstan/phpdoc-parser",
"version": "2.3.5", "version": "2.3.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git", "url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" "reference": "1427af4647235b4a73b13940b1f1258d3584f36e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1427af4647235b4a73b13940b1f1258d3584f36e",
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", "reference": "1427af4647235b4a73b13940b1f1258d3584f36e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -7095,9 +7254,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types", "description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": { "support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues", "issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.6"
}, },
"time": "2026-08-31T16:05:28+00:00" "time": "2026-09-27T20:07:45+00:00"
}, },
{ {
"name": "pragmarx/google2fa", "name": "pragmarx/google2fa",
@@ -8848,16 +9007,16 @@
}, },
{ {
"name": "spatie/laravel-package-tools", "name": "spatie/laravel-package-tools",
"version": "1.93.2", "version": "1.93.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/spatie/laravel-package-tools.git", "url": "https://github.com/spatie/laravel-package-tools.git",
"reference": "e927d5b5b05e9fecae098e559e51918aa608ad02" "reference": "7aeef26bd8d4909bec744dbf47aee7d1395588c1"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/e927d5b5b05e9fecae098e559e51918aa608ad02", "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/7aeef26bd8d4909bec744dbf47aee7d1395588c1",
"reference": "e927d5b5b05e9fecae098e559e51918aa608ad02", "reference": "7aeef26bd8d4909bec744dbf47aee7d1395588c1",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -8897,7 +9056,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/spatie/laravel-package-tools/issues", "issues": "https://github.com/spatie/laravel-package-tools/issues",
"source": "https://github.com/spatie/laravel-package-tools/tree/1.93.2" "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.3"
}, },
"funding": [ "funding": [
{ {
@@ -8905,7 +9064,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-08-26T09:13:25+00:00" "time": "2026-09-28T07:16:50+00:00"
}, },
{ {
"name": "spatie/laravel-permission", "name": "spatie/laravel-permission",
+1 -1
View File
@@ -74,7 +74,7 @@
// ones that already worked, like collection_ids), not just the // ones that already worked, like collection_ids), not just the
// new order_count one. Caught in practice, reverted. // new order_count one. Caught in practice, reverted.
Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class, Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class, Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class
], ],
]; ];
-5
View File
@@ -35,11 +35,6 @@
], ],
], ],
// Where contact-form messages are sent (ContactController).
'contact' => [
'email' => env('CONTACT_EMAIL'),
],
// Bot check on guest forms (login, contact), verified by App\Rules\HCaptcha. For // Bot check on guest forms (login, contact), verified by App\Rules\HCaptcha. For
// local dev use hCaptcha's test keys, the real ones reject localhost: // local dev use hCaptcha's test keys, the real ones reject localhost:
// sitekey 10000000-ffff-ffff-ffff-000000000001, // sitekey 10000000-ffff-ffff-ffff-000000000001,
@@ -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,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'],
];
}
}
+4
View File
@@ -10,3 +10,7 @@ services:
scheduler: scheduler:
volumes: volumes:
- ../boboko-core:/var/www/boboko-core - ../boboko-core:/var/www/boboko-core
vite:
volumes:
- ../boboko-core:/boboko-core
+2
View File
@@ -47,6 +47,7 @@ services:
queue: queue:
image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app
entrypoint: ["/entrypoint-worker.sh"]
command: php artisan queue:work --tries=3 --max-jobs=500 --memory=256 command: php artisan queue:work --tries=3 --max-jobs=500 --memory=256
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
@@ -68,6 +69,7 @@ services:
scheduler: scheduler:
image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app
entrypoint: ["/entrypoint-worker.sh"]
command: php artisan schedule:work command: php artisan schedule:work
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -e
# Mirrors entrypoint.sh's Composer block: node_modules is a named/anonymous
# volume, not the bind-mounted host directory, so this container needs its
# own live install on every boot rather than relying on whatever was baked in
# at image-build time.
echo "[entrypoint-vite] Installing npm dependencies..."
npm install
# Re-resolve @boboko/core specifically on every boot, whether it's a local
# path-repo checkout (file:../boboko-core, needs docker-compose.core-dev.yml's
# ../boboko-core:/boboko-core mount) or a tagged VCS install
# (git+https://...#vX.Y.Z) — the same manual toggle composer.json's
# repositories/_repositories block uses for the PHP side of this same
# package. A plain `npm install` above only installs what package-lock.json
# already pins; it won't notice a new commit on the path-repo checkout or a
# re-pushed tag. `npm update` re-resolves that one package against whatever
# package.json currently says and rewrites package-lock.json to pin it —
# exactly like `composer update boboko/* --with-all-dependencies` rewrites
# composer.lock, which is committed so prod picks up the resolved version via
# `npm install` alone (no `npm update` in prod's build).
echo "[entrypoint-vite] Re-resolving @boboko/core..."
npm update @boboko/core
exec "$@"
+10
View File
@@ -79,6 +79,16 @@ echo "[entrypoint] Touched migrated file"
echo "[entrypoint] Trying Lunar install" echo "[entrypoint] Trying Lunar install"
php artisan lunar:install --quiet || true php artisan lunar:install --quiet || true
# Same idempotent per-key upsert as lunar:install's own seeding above, just kept
# as separate seeder classes rather than folded into InstallLunarCommand — these
# are Spatie translation-loader `validation`/`checkout` groups, not Lunar store
# data, and each is safe to re-run on every boot.
echo "[entrypoint] Seeding validation translations..."
php artisan db:seed --class="Modules\Core\Localization\Database\Seeders\ValidationTranslationsSeeder" --force --quiet || true
echo "[entrypoint] Seeding checkout translations..."
php artisan db:seed --class="Modules\Core\Checkout\Database\Seeders\CheckoutTranslationsSeeder" --force --quiet || true
# Upserts by primary key (no --refresh), so this stays cheap and idempotent on # 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 # 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 # in production too — a deploy that changed an indexer's field list needs this
+8
View File
@@ -9,6 +9,14 @@ content_path: /content
media_path: /media media_path: /media
thumbs_path: /var/www/html/storage/app/public/stoic/ thumbs_path: /var/www/html/storage/app/public/stoic/
repository_path: /app repository_path: /app
# First locale is Stoic's default: it holds every field, the others only the
# translatable ones (content/{locale}/...). Codes match app.available_locales.
i18n:
locales:
- code: el
label: Ελληνικά
- code: en
label: English
presets: presets:
- code: large - code: large
mode: resize mode: resize
+14 -47
View File
@@ -5,6 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
"@hotwired/stimulus": "^3.2.2", "@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo": "^8.0.23", "@hotwired/turbo": "^8.0.23",
"axios": "^1.15.2", "axios": "^1.15.2",
@@ -18,11 +19,21 @@
"vite": "^8.0.0" "vite": "^8.0.0"
} }
}, },
"node_modules/@boboko/core": {
"version": "0.26.5",
"resolved": "git+https://code.radical-elements.com/boboko/core.git#5f0dbbb734f10f0957eb53554743bd71d6db22e1",
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"leaflet": "^1.9.4"
},
"peerDependencies": {
"vite": "^8.0.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -34,7 +45,6 @@
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -45,7 +55,6 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -121,7 +130,6 @@
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -140,7 +148,6 @@
"version": "0.138.0", "version": "0.138.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
@@ -153,7 +160,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -170,7 +176,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -187,7 +192,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -204,7 +208,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -221,7 +224,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -238,7 +240,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -255,7 +256,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -272,7 +272,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -289,7 +288,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -306,7 +304,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -323,7 +320,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -340,7 +336,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -357,7 +352,6 @@
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -376,7 +370,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -393,7 +386,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -407,7 +399,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
@@ -686,7 +677,6 @@
"version": "0.10.3", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -894,7 +884,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -994,7 +983,6 @@
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -1048,7 +1036,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -1210,7 +1197,7 @@
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
@@ -1253,7 +1240,6 @@
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"detect-libc": "^2.0.3" "detect-libc": "^2.0.3"
@@ -1286,7 +1272,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1307,7 +1292,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1328,7 +1312,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1349,7 +1332,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1370,7 +1352,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1391,7 +1372,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1412,7 +1392,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1433,7 +1412,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1454,7 +1432,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1475,7 +1452,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1496,7 +1472,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1560,7 +1535,6 @@
"version": "3.3.15", "version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -1579,14 +1553,12 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -1599,7 +1571,6 @@
"version": "8.5.16", "version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -1647,7 +1618,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.138.0", "@oxc-project/types": "=0.138.0",
@@ -1704,7 +1674,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -1779,7 +1748,6 @@
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -1806,14 +1774,13 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "devOptional": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.1.3", "version": "8.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
+5
View File
@@ -14,9 +14,14 @@
"vite": "^8.0.0" "vite": "^8.0.0"
}, },
"dependencies": { "dependencies": {
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
"@hotwired/stimulus": "^3.2.2", "@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo": "^8.0.23", "@hotwired/turbo": "^8.0.23",
"axios": "^1.15.2", "axios": "^1.15.2",
"leaflet": "^1.9.4" "leaflet": "^1.9.4"
},
"_comment_boboko_core": "Mirrors composer.json's repositories/_repositories toggle: swap @boboko/core above with the line in _dependencies below (and back) by hand to switch between the local path-repo checkout and a tagged VCS install. The #semver:0.x range mirrors composer.json's own \"boboko/core\": \"0.*\" constraint exactly — note ^0.23.0 would NOT be equivalent here, since npm's caret locks the minor version too below 1.0.0 (>=0.23.0 <0.24.0). 0.x matches any 0.y.z tag, same as Composer's 0.*. npm reads the repo's git tags, filters to valid semver ones, and resolves the best match, so `npm update @boboko/core` alone picks up a newly pushed tag within range, no manual edit here per release. See docker/entrypoint-vite.sh and CONTRIBUTE.md.",
"_dependencies": {
"@boboko/core": "file:../boboko-core"
} }
} }
-991
View File
@@ -1,991 +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); }
/* Dummy Box Now locker picker — see shipping-options.blade.php. */
.bbk-checkout-box-now-locker {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
}
.bbk-checkout-box-now-locker-label {
font-weight: 600;
font-size: 0.8125rem;
}
.bbk-checkout-box-now-locker-map {
width: 100%;
height: 480px;
border-radius: var(--bbk-radius-sm);
z-index: 0;
}
.bbk-checkout-box-now-locker-search {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
font-size: 0.875rem;
}
.bbk-checkout-box-now-locker-search:focus {
outline: none;
border-color: var(--bbk-color-accent);
}
.bbk-checkout-box-now-locker-chosen {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--bbk-color-accent);
}
/* Custom SVG pin (see bbk-box-now-locker-controller.js pinIcon()) — no
default Leaflet drop-shadow image, so a CSS shadow stands in for it. */
.bbk-box-now-pin svg {
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.35));
}
/* Leaflet's own popup chrome, restyled to match the checkout's cards
instead of the library's square-cornered default. */
.leaflet-popup-content-wrapper {
border-radius: 0.75rem;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
}
.leaflet-popup-content {
margin: 0.875rem;
}
.bbk-box-now-popup {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 220px;
}
.bbk-box-now-popup-image {
width: calc(100% + 1.75rem);
margin: -0.875rem -0.875rem 0.125rem;
height: 110px;
object-fit: cover;
border-radius: 0.75rem 0.75rem 0 0;
}
.bbk-box-now-popup-name {
display: flex;
align-items: center;
gap: 0.375rem;
margin: 0;
font-weight: 700;
font-size: 0.9375rem;
}
.bbk-box-now-popup-name::before {
content: '';
flex: 0 0 auto;
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: #00c389;
}
.bbk-box-now-popup-address {
margin: 0;
padding-left: 0.875rem;
font-size: 0.8125rem;
line-height: 1.4;
color: var(--bbk-color-muted);
}
.bbk-box-now-popup-note {
margin: 0 0 0 0.875rem;
padding: 0.5rem 0.625rem;
background: color-mix(in srgb, #00c389 8%, transparent);
border-radius: var(--bbk-radius-sm);
font-size: 0.75rem;
font-style: italic;
color: var(--bbk-color-muted);
}
.bbk-box-now-popup-select {
margin-top: 0.25rem;
padding: 0.625rem 0.875rem;
width: 100%;
border: none;
border-radius: var(--bbk-radius-sm);
background: #00c389;
color: #ffffff;
font-weight: 700;
font-size: 0.8125rem;
letter-spacing: 0.01em;
cursor: pointer;
transition: background-color 0.15s ease, transform 0.1s ease;
}
.bbk-box-now-popup-select:hover { background: #00a876; transform: translateY(-1px); }
.bbk-box-now-popup-select:active { transform: translateY(0); }
.bbk-box-now-popup-select--selected,
.bbk-box-now-popup-select--selected:hover {
background: var(--bbk-color-muted);
cursor: default;
}
/* Continue / submit buttons — same look as the drawer's checkout CTA */
.bbk-checkout-continue {
display: block;
width: 100%;
padding: 0.875rem 1.25rem;
border: 1px solid var(--bbk-color-accent);
border-radius: var(--bbk-radius);
background: var(--bbk-color-accent);
color: var(--bbk-color-accent-text);
font: inherit;
font-weight: 600;
text-align: center;
text-decoration: none;
box-sizing: border-box;
cursor: pointer;
transition: opacity 0.15s ease;
}
.bbk-checkout-continue:hover { opacity: 0.85; }
.bbk-checkout-continue:disabled {
cursor: not-allowed;
opacity: 0.4;
}
/* Order summary column */
.bbk-checkout-aside { position: sticky; top: 1.5rem; }
.bbk-checkout-summary {
padding: 1.5rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius);
background: var(--bbk-color-bg);
}
.bbk-checkout-summary-heading {
margin: 0 0 1.25rem;
font-size: 1.125rem;
font-weight: 700;
}
/* Already on the checkout page — the drawer's own "go to checkout" CTA has
nowhere further to send you from here. */
.bbk-checkout-summary .bbk-cart-checkout { display: none; }
/* ── Payment ───────────────────────────────────────────────────────── */
.bbk-checkout-payment-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bbk-checkout-payment-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
cursor: pointer;
transition: border-color 0.15s ease;
}
.bbk-checkout-payment-option:has(input:checked) { border-color: var(--bbk-color-accent); }
.bbk-checkout-payment-option-name { font-weight: 600; }
.bbk-payment-element { margin: 0.25rem 0; }
.bbk-checkout-withdrawal {
margin: 0;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
.bbk-checkout-withdrawal a { color: inherit; }
.bbk-checkout-error {
margin: 0;
font-size: 0.875rem;
color: var(--bbk-color-danger);
}
/* Processing overlay — fixed, covers the page while a payment confirms. */
.bbk-checkout-processing {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: color-mix(in srgb, var(--bbk-color-bg) 92%, transparent);
text-align: center;
padding: 1.5rem;
}
.bbk-spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--bbk-color-border);
border-top-color: var(--bbk-color-accent);
border-radius: 50%;
animation: bbk-spin 0.8s linear infinite;
}
@keyframes bbk-spin {
to { transform: rotate(360deg); }
}
/* ── Confirmation page ─────────────────────────────────────────────── */
.bbk-confirmation {
max-width: 720px;
margin: 0 auto;
padding: 3rem 1.5rem 5rem;
font-family: var(--bbk-font);
color: var(--bbk-color-text);
}
.bbk-confirmation-heading {
margin: 0 0 1rem;
font-size: 1.75rem;
font-weight: 700;
}
.bbk-confirmation-ref { margin: 0 0 0.25rem; }
.bbk-confirmation-meta {
margin: 0 0 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.bbk-confirmation-meta-row {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.9375rem;
}
.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); }
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
.bbk-confirmation-body {
margin: 2rem 0;
display: grid;
gap: 2.5rem;
}
@media (min-width: 640px) {
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; }
}
.bbk-confirmation-lines {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bbk-confirmation-line {
display: grid;
grid-template-columns: 72px 1fr auto;
align-items: start;
gap: 0.875rem;
}
.bbk-confirmation-line-detail { min-width: 0; }
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
.bbk-confirmation-addresses {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.bbk-confirmation-address-heading {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
}
.bbk-address-lines {
font-style: normal;
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.875rem;
color: var(--bbk-color-muted);
}
+5 -3
View File
@@ -10,13 +10,15 @@ window.Turbo.session.drive = false;
import { Application } from "@hotwired/stimulus"; import { Application } from "@hotwired/stimulus";
import { registerControllers } from "./stimulus/index"; import { registerControllers } from "./stimulus/index";
import { registerCheckout } from "./checkout"; import { registerCheckout, registerWishlist } from "@boboko/core";
const application = Application.start(); const application = Application.start();
application.debug = false; application.debug = false;
registerControllers(application); registerControllers(application);
// Portable cart + checkout module (destined for boboko-core). Owns its own // Portable cart + checkout + wishlist modules, installed as a real npm
// bbk-* Stimulus controllers; this is the only wiring line it needs here. // dependency (see package.json). Each owns its own Stimulus controllers;
// these are the only wiring lines they need here.
registerCheckout(application); registerCheckout(application);
registerWishlist(application);
@@ -1,57 +0,0 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Sits on an <x-checkout::add-to-cart> <form>. Submits the line to the cart
// via fetch and hands the server-rendered cart body to the drawer through the
// `bbk-cart:changed` window event. No DOM building here — the drawer
// (bbk-cart-controller) owns rendering.
export default class extends Controller {
static targets = ['error']
async add(event) {
event.preventDefault()
const form = this.element
const submit = form.querySelector('[type="submit"]')
this.clearError()
form.setAttribute('data-bbk-add-to-cart-state', 'loading')
if (submit) submit.disabled = true
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (!response.ok) {
const data = await response.json().catch(() => null)
this.showError(data?.error)
return
}
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: await response.text() },
}))
} finally {
form.removeAttribute('data-bbk-add-to-cart-state')
if (submit) submit.disabled = false
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
}
@@ -1,220 +0,0 @@
import { Controller } from '@hotwired/stimulus'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { csrfToken } from './csrf'
// Box Now's own brand green, used for the pin instead of Leaflet's default
// blue teardrop — a small SVG data URI rather than another bundled asset.
const PIN_COLOR = '#00c389'
const PIN_COLOR_SELECTED = '#0a7a52'
function pinIcon(color) {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="46" viewBox="0 0 34 46">
<path
d="M17 0C7.6 0 0 7.6 0 17c0 12.75 17 29 17 29s17-16.25 17-29C34 7.6 26.4 0 17 0Z"
fill="${color}"
stroke="#ffffff"
stroke-width="1.5"
/>
<circle cx="17" cy="17" r="7" fill="#ffffff" />
</svg>
`
return L.divIcon({
className: 'bbk-box-now-pin',
html: svg,
iconSize: [34, 46],
iconAnchor: [17, 46],
popupAnchor: [0, -40],
})
}
const ICON = pinIcon(PIN_COLOR)
const ICON_SELECTED = pinIcon(PIN_COLOR_SELECTED)
// A self-hosted Leaflet map standing in for Box Now's own Destination Map
// JS widget — that widget only talks to Box Now's Production API (see
// their Partner API manual §4.1), so it can't be used while developing
// against Stage credentials. Same underlying /destinations data, rendered
// with OpenStreetMap tiles instead of Box Now's map.
//
// Visibility is toggled by bbk-checkout-form (see its own
// toggleBoxNowLocker()) whenever the "box-now" shipping option becomes
// selected/deselected — this controller only owns loading the locker list
// once visible, rendering pins, and autosaving the chosen one.
export default class extends Controller {
static targets = ['map', 'search', 'status', 'chosen']
static values = {
lockersUrl: String,
selectUrl: String,
loading: String,
selectLabel: String,
selectedLabel: String,
noResults: String,
}
// Athens — a reasonable default center before any locker is loaded.
static DEFAULT_CENTER = [37.9838, 23.7275]
connect() {
this.map = null
this.markers = new Map()
this.selectedId = null
this.loaded = false
if (!this.element.hidden) this.show()
}
disconnect() {
this.map?.remove()
this.map = null
}
// Called by bbk-checkout-form right after it un-hides this element.
show() {
this.element.hidden = false
// Leaflet measures its container's size on init — doing that while
// the element (or an ancestor) is still `hidden` produces a
// collapsed/blank map, so this is deferred to the same tick `hidden`
// is cleared, then Leaflet is nudged once more via invalidateSize().
requestAnimationFrame(() => {
if (!this.map) this.initMap()
this.map.invalidateSize()
if (!this.loaded) this.loadLockers()
})
}
hide() {
this.element.hidden = true
}
initMap() {
this.map = L.map(this.mapTarget).setView(this.constructor.DEFAULT_CENTER, 10)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19,
}).addTo(this.map)
// Delegated: popup content is re-inserted by Leaflet on every open,
// so a listener bound once on the map's container beats binding (and
// losing) one on the button each time a popup renders.
this.map.getContainer().addEventListener('click', (event) => {
const button = event.target.closest('[data-locker-id]')
if (button) this.select(button.dataset.lockerId)
})
}
async loadLockers() {
this.setStatus(this.loadingValue)
try {
const response = await fetch(this.lockersUrlValue, {
headers: { Accept: 'application/json' },
})
if (!response.ok) return
const { lockers } = await response.json()
this.loaded = true
this.lockers = new Map(lockers.map((locker) => [String(locker.id), locker]))
this.renderMarkers(lockers)
this.setStatus('')
} catch {
this.setStatus('')
}
}
renderMarkers(lockers) {
this.markers.forEach((marker) => marker.remove())
this.markers = new Map(lockers.map((locker) => {
const marker = L.marker([locker.lat, locker.lng], { icon: ICON })
.addTo(this.map)
.bindPopup(this.popupHtml(locker), { maxWidth: 260 })
return [String(locker.id), marker]
}))
if (this.markers.size) {
this.map.fitBounds(L.featureGroup([...this.markers.values()]).getBounds().pad(0.2))
}
}
popupHtml(locker) {
const isSelected = String(locker.id) === this.selectedId
return `
<div class="bbk-box-now-popup">
${locker.image ? `<img class="bbk-box-now-popup-image" src="${locker.image}" alt="">` : ''}
<p class="bbk-box-now-popup-name">${locker.name}</p>
<p class="bbk-box-now-popup-address">
${[locker.addressLine1, locker.addressLine2].filter(Boolean).join(', ')}
${locker.postalCode ? ` ${locker.postalCode}` : ''}
</p>
${locker.note ? `<p class="bbk-box-now-popup-note">${locker.note}</p>` : ''}
<button
type="button"
class="bbk-box-now-popup-select${isSelected ? ' bbk-box-now-popup-select--selected' : ''}"
data-locker-id="${locker.id}"
${isSelected ? 'disabled' : ''}
>
${isSelected ? this.selectedLabelValue : this.selectLabelValue}
</button>
</div>
`
}
async select(lockerId) {
const locker = this.lockers?.get(String(lockerId))
if (!locker) return
const previousId = this.selectedId
this.selectedId = String(lockerId)
this.restyleMarker(previousId, ICON)
this.restyleMarker(this.selectedId, ICON_SELECTED)
this.markers.get(this.selectedId)?.setPopupContent(this.popupHtml(locker))
this.chosenTarget.hidden = false
this.chosenTarget.textContent = locker.addressLine1
? `${locker.name} — ${locker.addressLine1}`
: locker.name
const body = new FormData()
body.append('locker_id', locker.id)
body.append('locker_name', locker.name ?? '')
body.append('locker_address', locker.addressLine1 ?? '')
try {
await fetch(this.selectUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body,
})
} catch {
// Best-effort autosave, same as the rest of checkout — a failed
// save here surfaces later at place-order time via the normal
// shipment-creation error path, not as an inline field error.
}
}
restyleMarker(lockerId, icon) {
if (!lockerId) return
this.markers.get(lockerId)?.setIcon(icon)
}
setStatus(text) {
if (!this.hasStatusTarget) return
this.statusTarget.textContent = text
this.statusTarget.hidden = !text
}
}
@@ -1,161 +0,0 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Drives the slide-in cart drawer. One instance, on the drawer root in
// checkout/drawer.blade.php.
//
// - listens on window for `bbk-cart:changed` (from bbk-add-to-cart and from
// this drawer's own line forms) and swaps in the server-rendered cart body
// - handles the in-drawer quantity / remove forms (fetch + method spoofing)
// - re-emits `bbk-cart:updated` {count, total} after every render so the host
// (e.g. the header bag icon) can react
//
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
// attribute on the root, nothing here touches styles or class lists.
export default class extends Controller {
static targets = ['panel', 'body', 'error']
connect() {
this.onChanged = this.onChanged.bind(this)
this.onKeydown = this.onKeydown.bind(this)
this.updateTimers = new Map() // line id -> pending debounce timer
window.addEventListener('bbk-cart:changed', this.onChanged)
window.addEventListener('bbk-cart:open', this.open.bind(this))
document.addEventListener('keydown', this.onKeydown)
// Prime the host with the count rendered server-side on page load.
this.emitUpdated(this.element.querySelector('[data-bbk-cart-count]'))
}
disconnect() {
window.removeEventListener('bbk-cart:changed', this.onChanged)
document.removeEventListener('keydown', this.onKeydown)
this.updateTimers.forEach((timer) => clearTimeout(timer))
}
onChanged(event) {
if (event.detail?.html) this.replaceBody(event.detail.html)
this.open()
}
onKeydown(event) {
if (event.key === 'Escape' && !this.element.hidden) this.close()
}
open() {
if (!this.element.hidden) return
this.element.hidden = false
// Next frame, so the panel transitions from its off-canvas start.
requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open'))
}
close() {
this.element.removeAttribute('data-bbk-cart-state')
const panel = this.panelTarget
const done = () => {
this.element.hidden = true
panel.removeEventListener('transitionend', done)
}
panel.addEventListener('transitionend', done)
}
// change on a line quantity input, or submit of a line's remove form
submit(event) {
event.preventDefault()
const form = event.target.closest('form')
if (!form) return
// A remove is a deliberate, one-shot action — only the quantity form
// (typing, or the +/- stepper below) benefits from debouncing.
form.classList.contains('bbk-cart-qty') ? this.scheduleSend(form) : this.send(form)
}
// +/- stepper buttons inside a line
step(event) {
event.preventDefault()
const form = event.target.closest('form')
const input = form.querySelector('input[type="number"]')
const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir))
input.value = String(next)
this.scheduleSend(form)
}
// Repeated clicks (or spinner nudges) update the input instantly but only
// send once they settle for 300ms — sending on every single click was
// firing overlapping requests that raced each other and made the drawer
// visibly flicker/lag under quick clicking.
scheduleSend(form) {
const lineId = form.closest('[data-bbk-line-id]')?.dataset.bbkLineId
if (!lineId) return this.send(form)
clearTimeout(this.updateTimers.get(lineId))
this.updateTimers.set(lineId, setTimeout(() => {
this.updateTimers.delete(lineId)
this.send(form)
}, 300))
}
async send(form) {
this.bodyTarget.setAttribute('aria-busy', 'true')
this.clearError()
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (response.ok) {
this.replaceBody(await response.text())
return
}
const data = await response.json().catch(() => null)
this.showError(data?.error)
// The rejected quantity (typed, or from a +/- click) is left
// sitting in the input with nothing to correct it — the update
// never reached the cart, so the input must be put back to what
// the cart actually still holds, not just left showing whatever
// was rejected.
const input = form.querySelector('[data-bbk-cart-confirmed-quantity]')
if (input) input.value = input.dataset.bbkCartConfirmedQuantity
} finally {
this.bodyTarget.removeAttribute('aria-busy')
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
replaceBody(html) {
this.bodyTarget.innerHTML = html
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
}
emitUpdated(node) {
if (!node) return
window.dispatchEvent(new CustomEvent('bbk-cart:updated', {
detail: {
count: parseInt(node.dataset.bbkCartCount || '0', 10),
total: parseInt(node.dataset.bbkCartTotal || '0', 10),
},
}))
}
}
@@ -1,226 +0,0 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Drives the checkout page's left column: contact tabs, the same-as-billing
// toggle, and — the bulk of it — autosaving the address form and the shipping
// method with no submit buttons.
//
// Flow: any `change` in the address form is debounced ~400ms, then the whole
// form is POSTed to saveUrl. The server persists leniently and returns
// { errors, shippingOptionsHtml, summaryHtml }. We swap the shipping-options
// block in place and hand the summary fragment to the drawer's bbk-cart
// controller via the `bbk-cart:changed` window event (same mechanism the drawer
// already uses). Shipping-method radios post to selectShippingUrl the same way.
export default class extends Controller {
static targets = [
'sameAsBilling', 'shippingFields',
'form', 'shippingOptions', 'status',
]
static values = {
saveUrl: String,
selectShippingUrl: String,
statusSaving: String,
statusSaved: String,
statusError: String,
}
connect() {
this.saveTimer = null
this.saveController = null
this.statusTimer = null
this.shippingPromise = null
if (this.hasSameAsBillingTarget) this.applySameAsBilling()
}
disconnect() {
clearTimeout(this.saveTimer)
clearTimeout(this.statusTimer)
this.saveController?.abort()
}
// ── Same as billing ────────────────────────────────────────────────
toggleSameAsBilling() {
this.applySameAsBilling()
}
applySameAsBilling() {
const on = this.sameAsBillingTarget.checked
// Checked: shipping *is* billing — copy every value across, then hide +
// disable so the browser doesn't submit them; the server reuses billing.
// Unchecked: reveal them pre-filled from billing wherever still empty.
this.element.querySelectorAll('[name^="billing_"]').forEach((billingField) => {
const shippingField = this.element.querySelector(
`[name="${billingField.name.replace(/^billing_/, 'shipping_')}"]`,
)
if (shippingField && (on || !shippingField.value)) {
shippingField.value = billingField.value
}
})
this.shippingFieldsTarget.hidden = on
this.shippingFieldsTarget.querySelectorAll('input, select, textarea').forEach((field) => {
field.disabled = on
})
}
// ── Autosave ───────────────────────────────────────────────────────
scheduleSave(event) {
// The shipping-method and payment radios live inside this controller's
// element too, and this action is bound on .bbk-checkout-main to also
// catch the contact email/consent that sit outside the <form>. Only
// react to fields that actually belong to the address form.
const el = event.target
const belongsToForm = el.form?.id === 'bbk-address-form'
if (!belongsToForm) return
// No status during the wait — it only shows once the request is in flight,
// so the indicator isn't flickering "saving" on every keystroke.
clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(() => this.save(), 700)
}
// Called by bbk-payment right before place-order — a debounced save (and
// the shipping-option auto-select that happens as part of it) might still
// be pending when the shopper clicks "place order"; this guarantees the
// server has processed the current form state first.
async flush() {
clearTimeout(this.saveTimer)
await this.save()
// A shipping-method radio click fires its own (undebounced) request —
// still async, still racy against an immediate "place order" click.
if (this.shippingPromise) await this.shippingPromise
}
async save() {
this.saveController?.abort()
this.saveController = new AbortController()
this.setStatus('saving')
try {
const response = await fetch(this.saveUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(this.formTarget),
signal: this.saveController.signal,
})
if (!response.ok) return this.setStatus('error')
this.applyResult(await response.json())
this.setStatus('saved')
} catch (error) {
if (error.name !== 'AbortError') this.setStatus('error')
}
}
async selectShipping(event) {
this.toggleBoxNowLocker(event.target.value)
// Tracked so flush() can await it — nothing else stops "place order"
// (a separate, unrelated click) from racing ahead of this request.
this.shippingPromise = this.doSelectShipping(event.target.value)
await this.shippingPromise
}
// The dummy Box Now locker <select> (see shipping-options.blade.php)
// lives inside the #bbk-shipping-options fragment this controller
// re-renders wholesale on every shipping-option change — so its own
// Stimulus controller reconnects fresh each time and has no memory of
// which option was previously selected. This is the one place that
// knows the newly-chosen option's identifier, so it also owns
// showing/hiding the picker.
toggleBoxNowLocker(identifier) {
const picker = this.shippingOptionsTarget.querySelector('#bbk-box-now-locker')
if (!picker) return
const controller = this.application.getControllerForElementAndIdentifier(picker, 'bbk-box-now-locker')
if (identifier === 'box-now') {
controller?.show()
} else {
controller?.hide()
}
}
async doSelectShipping(value) {
this.saveController?.abort()
this.setStatus('saving')
const body = new FormData()
body.append('shipping_option', value)
try {
const response = await fetch(this.selectShippingUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body,
})
if (!response.ok) return this.setStatus('error')
this.applyResult(await response.json())
this.setStatus('saved')
} catch {
this.setStatus('error')
} finally {
this.shippingPromise = null
}
}
applyResult(data) {
this.applyErrors(data.errors || {})
if (data.shippingOptionsHtml != null) {
this.shippingOptionsTarget.innerHTML = data.shippingOptionsHtml
}
if (data.summaryHtml != null) {
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: data.summaryHtml },
}))
}
}
applyErrors(errors) {
this.element.querySelectorAll('[data-bbk-field-error]').forEach((el) => {
const message = errors[el.dataset.bbkFieldError]
el.textContent = message || ''
el.hidden = !message
const field = this.element.querySelector(`[name="${el.dataset.bbkFieldError}"]`)
field?.classList.toggle('bbk-field-input--error', Boolean(message))
})
}
setStatus(state) {
if (!this.hasStatusTarget) return
const text = {
saving: this.statusSavingValue,
saved: this.statusSavedValue,
error: this.statusErrorValue,
}[state]
this.statusTarget.textContent = text
this.statusTarget.hidden = false
this.statusTarget.dataset.state = state
clearTimeout(this.statusTimer)
if (state === 'saved') {
this.statusTimer = setTimeout(() => { this.statusTarget.hidden = true }, 2000)
}
}
}
@@ -1,289 +0,0 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
const STRIPE_JS = 'https://js.stripe.com/v3/'
const POLL_INTERVAL = 1500
const POLL_TIMEOUT = 30000
// The payment step of the checkout page. Sits alongside bbk-checkout-form on
// .bbk-checkout-main.
//
// - selectMethod: radio change -> persist via /payment-method, refresh the
// summary (COD fee), mount/unmount the Stripe Payment Element
// - placeOrder: the real submit. For Stripe, builds a PaymentMethod client-side
// and POSTs it to /place-order, then routes on the JSON result:
// { redirect } -> order placed, go to confirmation
// { status:'pending', clientSecret } -> 3-D Secure: handleNextAction, then
// poll /order-status until the webhook places it
// { status:'failed'|'invalid'|'stale', message } -> show inline, re-enable
export default class extends Controller {
static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText']
static values = {
selectUrl: String,
placeOrderUrl: String,
orderStatusUrl: String,
stripeKey: String,
amount: Number,
currency: String,
termsRequired: String,
chooseMethod: String,
genericError: String,
processingSlow: String,
}
connect() {
this.stripe = null
this.elements = null
this.paymentElement = null
this.onSummaryUpdate = (event) => {
const total = event.detail?.total
if (typeof total === 'number' && this.elements) {
this.amountValue = total
this.elements.update({ amount: Math.max(total, 1) })
}
// Removing the last line while sitting on the checkout page (via
// the order summary's own remove form) must not leave "place
// order" clickable with nothing left to charge for — this fires
// from both the drawer and the checkout page's own summary
// instance, whichever the shopper actually used.
const count = event.detail?.count
if (typeof count === 'number' && this.hasSubmitTarget) {
this.submitTarget.disabled = count === 0
}
}
window.addEventListener('bbk-cart:updated', this.onSummaryUpdate)
if (this.selectedIsStripe()) this.mountStripe()
}
disconnect() {
window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate)
this.unmountStripe()
}
// ── Method selection ──────────────────────────────────────────────
async selectMethod(event) {
const isStripe = event.target.dataset.paymentDriver === 'stripe'
try {
const response = await fetch(this.selectUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new URLSearchParams({ payment_type: event.target.value }),
})
if (response.ok) {
const data = await response.json()
if (data.summaryHtml != null) {
window.dispatchEvent(new CustomEvent('bbk-cart:changed', { detail: { html: data.summaryHtml } }))
}
}
} catch {
// summary just won't refresh — non-fatal
}
isStripe ? this.mountStripe() : this.unmountStripe()
}
selectedRadio() {
return this.element.querySelector('input[name="payment_type"]:checked')
}
selectedIsStripe() {
return this.selectedRadio()?.dataset.paymentDriver === 'stripe'
}
// ── Stripe Payment Element ────────────────────────────────────────
async loadStripe() {
if (window.Stripe) return window.Stripe
await new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${STRIPE_JS}"]`)
if (existing) {
existing.addEventListener('load', resolve)
existing.addEventListener('error', reject)
return
}
const script = document.createElement('script')
script.src = STRIPE_JS
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
return window.Stripe
}
async mountStripe() {
if (this.paymentElement || !this.stripeKeyValue) return
const Stripe = await this.loadStripe()
this.stripe = this.stripe || Stripe(this.stripeKeyValue)
this.elements = this.stripe.elements({
mode: 'payment',
amount: Math.max(this.amountValue, 1),
currency: this.currencyValue,
paymentMethodCreation: 'manual',
// Card only — matches the server confirming with
// automatic_payment_methods.allow_redirects = 'never' (no
// return_url in our flow: 3-D Secure resolves in-page via
// handleNextAction, never a full-page redirect).
paymentMethodTypes: ['card'],
})
this.paymentElement = this.elements.create('payment')
this.paymentElement.mount(this.elementTarget)
this.elementTarget.hidden = false
}
unmountStripe() {
this.paymentElement?.unmount()
this.paymentElement = null
this.elements = null
if (this.hasElementTarget) {
this.elementTarget.innerHTML = ''
this.elementTarget.hidden = true
}
}
// ── Place order ──────────────────────────────────────────────────
// Sibling controller on the same element (.bbk-checkout-main) — used to
// flush a pending debounced address autosave before placing the order.
get checkoutForm() {
return this.application.getControllerForElementAndIdentifier(this.element, 'bbk-checkout-form')
}
async placeOrder() {
this.clearError()
this.submitTarget.disabled = true
// A debounced address save (and the shipping-option auto-select that
// happens as part of it) might still be pending — make sure the
// server has the latest state before we ask it to place the order.
await this.checkoutForm?.flush()
if (!this.termsTarget.checked) {
this.submitTarget.disabled = false
this.showError(this.termsRequiredValue)
return
}
const radio = this.selectedRadio()
if (!radio) {
this.submitTarget.disabled = false
this.showError(this.chooseMethodValue)
return
}
let paymentMethodId = null
if (radio.dataset.paymentDriver === 'stripe') {
const { error: submitError } = await this.elements.submit()
if (submitError) return this.fail(submitError.message)
const { error: pmError, paymentMethod } = await this.stripe.createPaymentMethod({ elements: this.elements })
if (pmError) return this.fail(pmError.message)
paymentMethodId = paymentMethod.id
}
let data
try {
const response = await fetch(this.placeOrderUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new URLSearchParams({
payment_type: radio.value,
payment_method: paymentMethodId ?? '',
terms_accepted: '1',
}),
})
data = await response.json()
} catch {
return this.fail(this.genericErrorValue)
}
if (data.redirect) {
window.location.assign(data.redirect)
return
}
if (data.status === 'pending' && data.clientSecret) {
await this.resolvePending(data.clientSecret)
return
}
// Points at the section that actually needs attention, rather than
// leaving a generic error and making the shopper hunt for it — e.g. a
// region with 2+ shipping methods needs an explicit pick, easy to miss.
if (data.field === 'shipping_option') {
document.getElementById('bbk-shipping-options')?.scrollIntoView({ block: 'center', behavior: 'smooth' })
this.fail(data.message || data.error || this.genericErrorValue, { scroll: false })
return
}
this.fail(data.message || data.error || this.genericErrorValue)
}
async resolvePending(clientSecret) {
this.processingTarget.hidden = false
const { error } = await this.stripe.handleNextAction({ clientSecret })
if (error) {
this.processingTarget.hidden = true
return this.fail(error.message)
}
// 3-D Secure cleared client-side — the webhook places the order. Poll.
const startedAt = Date.now()
const tick = async () => {
try {
const response = await fetch(this.orderStatusUrlValue, { headers: { Accept: 'application/json' } })
const data = await response.json()
if (data.placed && data.redirect) {
window.location.assign(data.redirect)
return
}
} catch {
// keep polling
}
if (Date.now() - startedAt > POLL_TIMEOUT) {
this.processingTextTarget.textContent = this.processingSlowValue
return
}
setTimeout(tick, POLL_INTERVAL)
}
tick()
}
// ── helpers ──────────────────────────────────────────────────────
fail(message, { scroll = true } = {}) {
this.showError(message, { scroll })
this.submitTarget.disabled = false
}
showError(message, { scroll = true } = {}) {
this.errorTarget.textContent = message
this.errorTarget.hidden = false
if (scroll) this.errorTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
clearError() {
this.errorTarget.textContent = ''
this.errorTarget.hidden = true
}
}
-6
View File
@@ -1,6 +0,0 @@
// Reads the CSRF token from the standard <meta name="csrf-token"> tag every
// boboko host renders in its layout <head>. Kept as its own module so both
// checkout controllers share one source.
export function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
-21
View File
@@ -1,21 +0,0 @@
import BbkAddToCartController from './bbk-add-to-cart-controller'
import BbkBoxNowLockerController from './bbk-box-now-locker-controller'
import BbkCartController from './bbk-cart-controller'
import BbkCheckoutFormController from './bbk-checkout-form-controller'
import BbkPaymentController from './bbk-payment-controller'
// Registers the checkout module's Stimulus controllers onto the host app's
// Stimulus application. Call once from the host's JS entry point:
//
// import { registerCheckout } from './checkout'
// registerCheckout(application)
//
// When this module moves to boboko-core this file ships with it unchanged;
// only that one import line in the host entry point differs per project.
export function registerCheckout(application) {
application.register('bbk-add-to-cart', BbkAddToCartController)
application.register('bbk-box-now-locker', BbkBoxNowLockerController)
application.register('bbk-cart', BbkCartController)
application.register('bbk-checkout-form', BbkCheckoutFormController)
application.register('bbk-payment', BbkPaymentController)
}
-2
View File
@@ -19,7 +19,6 @@ import RangeSliderController from './range-slider-controller'
import StarRatingController from './star-rating-controller' import StarRatingController from './star-rating-controller'
import TabLinkController from './tab-link-controller' import TabLinkController from './tab-link-controller'
import TabsController from './tabs-controller' import TabsController from './tabs-controller'
import WishlistController from './wishlist-controller'
export function registerControllers(application) { export function registerControllers(application) {
application.register('appear', AppearController) application.register('appear', AppearController)
@@ -38,5 +37,4 @@ export function registerControllers(application) {
application.register('star-rating', StarRatingController) application.register('star-rating', StarRatingController)
application.register('tab-link', TabLinkController) application.register('tab-link', TabLinkController)
application.register('tabs', TabsController) application.register('tabs', TabsController)
application.register('wishlist', WishlistController)
} }
@@ -1,42 +0,0 @@
import { Controller } from '@hotwired/stimulus'
// Heart toggle (x-wishlist-button). Posts the form with fetch and reflects the
// server's answer on aria-pressed, which the CSS uses to swap the outline and
// filled heart. If the request fails, falls back to a normal form submit.
export default class extends Controller {
static targets = ['button', 'status']
static values = {
addLabel: String,
removeLabel: String,
addedMessage: String,
removedMessage: String,
}
async toggle(event) {
event.preventDefault()
if (this.busy) return
this.busy = true
try {
const response = await fetch(this.element.action, {
method: 'POST',
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: new FormData(this.element),
})
if (!response.ok) throw new Error(`Wishlist toggle failed: ${response.status}`)
const { active } = await response.json()
this.buttonTarget.setAttribute('aria-pressed', active ? 'true' : 'false')
this.buttonTarget.setAttribute('aria-label', active ? this.removeLabelValue : this.addLabelValue)
this.statusTarget.textContent = active ? this.addedMessageValue : this.removedMessageValue
} catch {
this.element.submit()
} finally {
this.busy = false
}
}
}
@@ -0,0 +1,13 @@
---
name: "Επικοινωνία"
slug: "contact"
meta_title: "Επικοινωνία"
meta_description: "Επικοινώνησε μαζί μας για οποιαδήποτε απορία ή ιδέα έχεις και θα σου απαντήσουμε το συντομότερο δυνατό."
og_image: ""
heading_first: "Επικοινώνησε"
heading_second: "μαζί μας"
heading_highlight: "άμεσα"
intro: "Έχεις απορία, ιδέα ή θες να μας πεις κάτι; Γράψε μας το μήνυμά σου και θα σου απαντήσουμε το συντομότερο δυνατό."
---
@@ -18,12 +18,12 @@ ## 1. Υπεύθυνος επεξεργασίας
Υπεύθυνος επεξεργασίας των προσωπικών δεδομένων που συλλέγονται μέσω του ηλεκτρονικού καταστήματος είναι: Υπεύθυνος επεξεργασίας των προσωπικών δεδομένων που συλλέγονται μέσω του ηλεκτρονικού καταστήματος είναι:
**3DEALER** **{store_legal_name}**
Πάρου 31, Κυψέλη, ΤΚ 11255, Αθήνα Πάρου 31, Κυψέλη, ΤΚ 11255, Αθήνα
ΑΦΜ: @TODO ΑΦΜ: {store_tax_identifier}
ΓΕΜΗ: @TODO ΓΕΜΗ: {store_registration_number}
Email: [print@3dealer.gr](mailto:print@3dealer.gr) Email: [{store_contact_email}](mailto:{store_contact_email})
Τηλέφωνο: 6976 443140 Τηλέφωνο: {store_phone}
Η 3DEALER καθορίζει τους σκοπούς και τα μέσα επεξεργασίας των προσωπικών δεδομένων των πελατών και χρηστών του ηλεκτρονικού καταστήματος. Η 3DEALER καθορίζει τους σκοπούς και τα μέσα επεξεργασίας των προσωπικών δεδομένων των πελατών και χρηστών του ηλεκτρονικού καταστήματος.
@@ -81,7 +81,7 @@ ## 6. Newsletter και εμπορική επικοινωνία
Η εγγραφή στο newsletter πραγματοποιείται με τη συγκατάθεσή σας. Η εγγραφή στο newsletter πραγματοποιείται με τη συγκατάθεσή σας.
Μπορείτε να ανακαλέσετε τη συγκατάθεσή σας οποιαδήποτε στιγμή, χρησιμοποιώντας τον σύνδεσμο απεγγραφής που περιλαμβάνεται στα emails ή επικοινωνώντας μαζί μας στο [print@3dealer.gr](mailto:print@3dealer.gr). Μπορείτε να ανακαλέσετε τη συγκατάθεσή σας οποιαδήποτε στιγμή, χρησιμοποιώντας τον σύνδεσμο απεγγραφής που περιλαμβάνεται στα emails ή επικοινωνώντας μαζί μας στο [{store_contact_email}](mailto:{store_contact_email}).
Η ανάκληση της συγκατάθεσης δεν επηρεάζει τη νομιμότητα της επεξεργασίας που πραγματοποιήθηκε πριν από αυτήν. Η ανάκληση της συγκατάθεσης δεν επηρεάζει τη νομιμότητα της επεξεργασίας που πραγματοποιήθηκε πριν από αυτήν.
@@ -186,8 +186,8 @@ ## 16. Τα δικαιώματά σας
Για την άσκηση των δικαιωμάτων σας μπορείτε να επικοινωνήσετε μαζί μας: Για την άσκηση των δικαιωμάτων σας μπορείτε να επικοινωνήσετε μαζί μας:
**Email:** [print@3dealer.gr](mailto:print@3dealer.gr) **Email:** [{store_contact_email}](mailto:{store_contact_email})
**Τηλέφωνο:** 6976 443140 **Τηλέφωνο:** {store_phone}
Θα απαντήσουμε στο αίτημά σας χωρίς αδικαιολόγητη καθυστέρηση και, κατά κανόνα, εντός ενός (1) μήνα από την παραλαβή του. Θα απαντήσουμε στο αίτημά σας χωρίς αδικαιολόγητη καθυστέρηση και, κατά κανόνα, εντός ενός (1) μήνα από την παραλαβή του.
@@ -45,7 +45,7 @@ ## 3. Χρόνος παράδοσης
Η 3DEALER δεν ευθύνεται για καθυστερήσεις που οφείλονται αποκλειστικά στην εταιρεία μεταφοράς, σε ακραία καιρικά φαινόμενα, απεργίες, προβλήματα στο δίκτυο μεταφορών ή άλλες περιστάσεις που βρίσκονται εκτός του εύλογου ελέγχου της. Η 3DEALER δεν ευθύνεται για καθυστερήσεις που οφείλονται αποκλειστικά στην εταιρεία μεταφοράς, σε ακραία καιρικά φαινόμενα, απεργίες, προβλήματα στο δίκτυο μεταφορών ή άλλες περιστάσεις που βρίσκονται εκτός του εύλογου ελέγχου της.
Σε περίπτωση καθυστέρησης, μπορείτε να επικοινωνήσετε μαζί μας στο [print@3dealer.gr](mailto:print@3dealer.gr). Σε περίπτωση καθυστέρησης, μπορείτε να επικοινωνήσετε μαζί μας στο [{store_contact_email}](mailto:{store_contact_email}).
## 4. Παραλαβή παραγγελίας ## 4. Παραλαβή παραγγελίας
@@ -55,7 +55,7 @@ ## 4. Παραλαβή παραγγελίας
## 5. Προϊόν που παραδόθηκε κατεστραμμένο ## 5. Προϊόν που παραδόθηκε κατεστραμμένο
Σε περίπτωση που το προϊόν παραδοθεί εμφανώς κατεστραμμένο, παρακαλούμε να επικοινωνήσετε μαζί μας το συντομότερο δυνατό στο [print@3dealer.gr](mailto:print@3dealer.gr) και να μας αποστείλετε φωτογραφίες του προϊόντος και της συσκευασίας. Σε περίπτωση που το προϊόν παραδοθεί εμφανώς κατεστραμμένο, παρακαλούμε να επικοινωνήσετε μαζί μας το συντομότερο δυνατό στο [{store_contact_email}](mailto:{store_contact_email}) και να μας αποστείλετε φωτογραφίες του προϊόντος και της συσκευασίας.
Η 3DEALER θα εξετάσει το περιστατικό και, εφόσον διαπιστωθεί ζημιά που προέκυψε κατά τη μεταφορά ή άλλη έλλειψη συμμόρφωσης, θα προχωρήσει στην κατάλληλη λύση σύμφωνα με την ισχύουσα νομοθεσία. Η 3DEALER θα εξετάσει το περιστατικό και, εφόσον διαπιστωθεί ζημιά που προέκυψε κατά τη μεταφορά ή άλλη έλλειψη συμμόρφωσης, θα προχωρήσει στην κατάλληλη λύση σύμφωνα με την ισχύουσα νομοθεσία.
@@ -63,7 +63,7 @@ ## 6. Δικαίωμα υπαναχώρησης
Για τις αγορές που εμπίπτουν στο πεδίο εφαρμογής του δικαιώματος υπαναχώρησης, ο καταναλωτής μπορεί να υπαναχωρήσει από τη σύμβαση εντός δεκατεσσάρων (14) ημερολογιακών ημερών από την ημέρα παραλαβής του προϊόντος. Για τις αγορές που εμπίπτουν στο πεδίο εφαρμογής του δικαιώματος υπαναχώρησης, ο καταναλωτής μπορεί να υπαναχωρήσει από τη σύμβαση εντός δεκατεσσάρων (14) ημερολογιακών ημερών από την ημέρα παραλαβής του προϊόντος.
Για την άσκηση του δικαιώματος υπαναχώρησης, ο πελάτης πρέπει να ενημερώσει την 3DEALER με σαφή δήλωση στο [print@3dealer.gr](mailto:print@3dealer.gr). Για την άσκηση του δικαιώματος υπαναχώρησης, ο πελάτης πρέπει να ενημερώσει την 3DEALER με σαφή δήλωση στο [{store_contact_email}](mailto:{store_contact_email}).
Το προϊόν πρέπει να επιστραφεί χωρίς αδικαιολόγητη καθυστέρηση και, σε κάθε περίπτωση, εντός δεκατεσσάρων (14) ημερών από την ημέρα κατά την οποία ο πελάτης ενημέρωσε την 3DEALER για την απόφασή του να υπαναχωρήσει. Το προϊόν πρέπει να επιστραφεί χωρίς αδικαιολόγητη καθυστέρηση και, σε κάθε περίπτωση, εντός δεκατεσσάρων (14) ημερών από την ημέρα κατά την οποία ο πελάτης ενημέρωσε την 3DEALER για την απόφασή του να υπαναχωρήσει.
@@ -101,4 +101,4 @@ ## 10. Ελαττωματικά ή μη συμμορφούμενα προϊόν
Η ύπαρξη μικρών αισθητικών χαρακτηριστικών που είναι φυσιολογικά για την τρισδιάστατη εκτύπωση, όπως γραμμές στρώσεων ή μικρές διαφοροποιήσεις, δεν αποτελεί από μόνη της ελάττωμα. Η ύπαρξη μικρών αισθητικών χαρακτηριστικών που είναι φυσιολογικά για την τρισδιάστατη εκτύπωση, όπως γραμμές στρώσεων ή μικρές διαφοροποιήσεις, δεν αποτελεί από μόνη της ελάττωμα.
Για οποιοδήποτε πρόβλημα με προϊόν, επικοινωνήστε μαζί μας στο [print@3dealer.gr](mailto:print@3dealer.gr), αναφέροντας τον αριθμό παραγγελίας και, όπου είναι δυνατόν, επισυνάπτοντας φωτογραφίες. Για οποιοδήποτε πρόβλημα με προϊόν, επικοινωνήστε μαζί μας στο [{store_contact_email}](mailto:{store_contact_email}), αναφέροντας τον αριθμό παραγγελίας και, όπου είναι δυνατόν, επισυνάπτοντας φωτογραφίες.
@@ -1,25 +1,23 @@
--- ---
name: "Όροι & Προϋποθέσεις" name: "Όροι & Προϋποθέσεις Terms"
slug: "terms-and-conditions" slug: "terms-and-conditions"
meta_title: "" meta_title: ""
meta_description: "" meta_description: ""
og_image: "" og_image: ""
noindex: true noindex: true
--- ---
## 1. Γενικά ## 1. Γενικά
Το ηλεκτρονικό κατάστημα 3DEALER (εφεξής «το Κατάστημα» ή «3DEALER») λειτουργεί από την επιχείρηση 3DEALER, με έδρα στην Πάρου 31, Κυψέλη, ΤΚ 11255, Αθήνα. Το ηλεκτρονικό κατάστημα 3DEALER (εφεξής «το Κατάστημα» ή «3DEALER») λειτουργεί από την επιχείρηση {store_legal_name}, με έδρα στην Πάρου 31, Κυψέλη, ΤΚ 11255, Αθήνα.
**ΑΦΜ:** @TODO **ΑΦΜ:** {store_tax_identifier}\
**ΓΕΜΗ:** @TODO **ΓΕΜΗ:** {store_registration_number}
Για οποιαδήποτε πληροφορία ή επικοινωνία σχετικά με παραγγελίες, προϊόντα ή τη λειτουργία του Καταστήματος, μπορείτε να επικοινωνείτε μαζί μας: Για οποιαδήποτε πληροφορία ή επικοινωνία σχετικά με παραγγελίες, προϊόντα ή τη λειτουργία του Καταστήματος, μπορείτε να επικοινωνείτε μαζί μας:
**Email:** [print@3dealer.gr](mailto:print@3dealer.gr) **Email:** [{store_contact_email}](mailto:{store_contact_email})\
**Τηλέφωνο:** 6976 443140 **Τηλέφωνο:** {store_phone}
Η χρήση του Καταστήματος και η πραγματοποίηση αγορών μέσω αυτού προϋποθέτουν την αποδοχή των παρόντων Όρων & Προϋποθέσεων. Η χρήση του Καταστήματος και η πραγματοποίηση αγορών μέσω αυτού προϋποθέτουν την αποδοχή των παρόντων Όρων & Προϋποθέσεων.
+7
View File
@@ -0,0 +1,7 @@
---
meta_description: ""
og_image: ""
facebook_url: "https://www.facebook.com/3Dealer.gr/"
instagram_url: "https://www.instagram.com/3dealer.gr/"
tiktok_url: "https://www.tiktok.com/@3dealer.gr"
---
@@ -0,0 +1,9 @@
---
name: "Contact"
meta_title: "Contact"
meta_description: "Get in touch with any question or idea you have and we'll get back to you as soon as we can."
heading_first: "Contact"
heading_second: "us"
heading_highlight: "today"
intro: "Got a question, an idea or something to tell us? Send us your message and we'll get back to you as soon as we can."
---
@@ -0,0 +1,132 @@
---
name: "Cookie Policy"
meta_title: ""
meta_description: ""
---
This Cookie Policy explains how 3DEALER uses cookies and similar technologies when you visit the online store.
## 1. What cookies are
Cookies are small text files stored on your device when you visit a website. They are used to make the website work properly, store preferences, collect statistics and, where permitted, show or measure advertising.
Besides cookies, we may also use similar technologies, such as pixels and tags.
## 2. Which cookies we use
3DEALER uses different categories of cookies, depending on their purpose.
### Essential cookies
Essential cookies are those required for the online store to work correctly and securely.
They can be used, among other things, for:
* running the shopping cart,
* keeping your session,
* signing in to your account,
* completing checkout,
* keeping the website secure,
* storing your cookie choices.
These cookies cannot be turned off through the consent tool, because without them some basic functions of the online store would not be available.
### Functionality cookies
Functionality cookies are used to store choices and preferences that improve your experience of the online store.
**@TODO: List the specific functionality cookies used by Boboko/3DEALER.**
### Analytics cookies
Analytics cookies are used to understand how visitors use the online store.
3DEALER uses services such as **Google Analytics** and may use **Google Tag Manager** to manage the related tags.
The information collected may include details such as:
* which pages you visit,
* how long you stay on the website,
* which page you came from,
* the device and browser you use,
* general information about how you interact with the website.
These cookies are only activated in line with your consent choices, where required by applicable law.
### Advertising and marketing cookies
3DEALER uses advertising measurement and marketing technologies, such as:
* **Meta Pixel**
* **TikTok Pixel**
These technologies can be used to measure how effective advertising is, to understand interactions with ads and, where supported and permitted, to show more relevant ads.
The related cookies and pixels are only activated in line with your consent choices, where required.
**@TODO: Confirm the exact Meta/TikTok cookies and events used in production.**
## 3. Cookie table
The table below should be updated based on the cookies actually used in the production environment.
| Cookie / Technology | Provider | Category | Purpose | Duration |
| ------------------- | ---------------- | --------- | ------------------------------------------- | -------- |
| @TODO | Boboko / 3DEALER | Essential | Store operation | @TODO |
| @TODO | Boboko / 3DEALER | Essential | Cart / checkout | @TODO |
| @TODO | Google | Analytics | Usage statistics | @TODO |
| @TODO | Meta | Marketing | Advertising measurement | @TODO |
| @TODO | TikTok | Marketing | Advertising measurement | @TODO |
| @TODO | hCaptcha | Security | Protection against automated/malicious use | @TODO |
**@TODO: The table must be completed after checking the production site, ideally with a cookie scan.**
## 4. Consent to the use of cookies
On your first visit to the online store, a cookie consent tool is shown, where you can choose which categories of non-essential cookies you allow.
You can:
* accept all cookies,
* reject non-essential cookies,
* choose specific categories of cookies.
Essential cookies stay active, as they are needed for the online store to work.
Your choice is saved and applied on your future visits.
## 5. Withdrawing or changing your consent
You can change or withdraw your consent to the use of non-essential cookies at any time.
**@TODO: Add how Boboko's cookie preference centre is reopened, e.g. through a "Cookie settings" link in the footer.**
Withdrawing consent does not affect the lawfulness of processing carried out before the withdrawal.
## 6. Third-party cookies
Some of the services we use are provided by third parties, such as Google, Meta, TikTok and hCaptcha.
These third-party providers may process information collected through cookies or similar technologies in accordance with their own policies and terms.
Using third-party services may also involve transferring data outside the European Economic Area. Where required, the safeguards provided by law for such transfers are applied.
## 7. Cookies and personal data
Some cookies or similar technologies may lead to the processing of information that counts as personal data.
For more information on how 3DEALER collects and processes personal data, see the **Privacy Policy**.
## 8. Browser settings
You can also limit or delete cookies through your browser settings.
However, turning off some cookies may affect how the online store works or prevent some services from working properly.
## 9. Changes to this Cookie Policy
This Cookie Policy may be changed when the technologies we use, the services of the online store or the relevant legislation change.
The latest version will always be available on the 3DEALER website.
**Last updated:** @TODO
+7
View File
@@ -0,0 +1,7 @@
---
name: "Home"
meta_title: "3dealer: Whatever you imagine, printed in 3D"
meta_description: "Gaming figures, skulls, key holders and the craziest things you can dream up. Designed and 3D printed, one by one."
trivia_body: "Gaming figures, skulls, key holders, dark humor and the craziest things you can dream up, all designed and printed right here in Greece."
classics_title: "Legendary & unbeatable"
---
@@ -0,0 +1,196 @@
---
name: "Privacy Policy"
meta_title: ""
meta_description: ""
---
3DEALER ("3DEALER", "we", "us") respects the privacy of its visitors and customers and protects their personal data in accordance with applicable law, including the General Data Protection Regulation (EU) 2016/679 ("GDPR") and the related Greek legislation.
This Privacy Policy explains what personal data we collect, what we use it for, who we may share it with and what rights you have.
## 1. Data controller
The controller of the personal data collected through the online store is:
**{store_legal_name}**
Parou 31, Kypseli, 11255 Athens, Greece
Tax ID (ΑΦΜ): {store_tax_identifier}
Company registration number (ΓΕΜΗ): {store_registration_number}
Email: [{store_contact_email}](mailto:{store_contact_email})
Phone: {store_phone}
3DEALER determines the purposes and means of processing the personal data of the online store's customers and users.
## 2. What data we collect
Depending on how you use the online store, we may collect:
* your full name,
* your email address,
* your phone number,
* your shipping and billing address,
* order and product details,
* payment-related details,
* contact details and the content of messages you send us,
* user account details,
* reviews you choose to publish,
* technical data about your use of the website,
* data collected through cookies and similar technologies, as described in the Cookie Policy.
We do not store your full credit or debit card details on 3DEALER's servers. Electronic payments are made through external payment providers.
## 3. Creating an account
You can create an account in the online store to manage your orders and details.
To create and run your account, we may process details such as your name, email and login details.
This processing is necessary to provide the account service.
The online store also supports guest checkout. With guest checkout, no user account is created. The details you provide are used only to the extent necessary to process and complete the order and for the related legal obligations.
## 4. Orders and purchases
When you place an order, we process the details needed to:
* record and fulfil the order,
* process the payment,
* ship and deliver the products,
* contact you about the order,
* issue the necessary invoices and receipts,
* meet our tax and accounting obligations,
* handle any returns, cancellations or complaints.
The legal basis for this processing is, as the case may be, the performance of the contract, compliance with a legal obligation and 3DEALER's legitimate interest in the proper operation and protection of its business.
## 5. Contacting us
If you contact us by email, phone or through the contact form, we process the details you give us in order to respond to your request.
The legal basis is, depending on the case, taking steps at your request before entering into a contract, the performance of a contract, or our legitimate interest in serving our users and customers.
## 6. Newsletter and marketing communication
If you choose to subscribe to the 3DEALER newsletter, we use your email address to send you updates, offers and other marketing content.
Subscribing to the newsletter is based on your consent.
You can withdraw your consent at any time by using the unsubscribe link included in the emails or by contacting us at [{store_contact_email}](mailto:{store_contact_email}).
Withdrawing consent does not affect the lawfulness of processing carried out before the withdrawal.
## 7. Product reviews
If you choose to submit a product review, we may publish your name or another identifier you chose to display with the review.
Please do not include your own or other people's personal data in a public review unless it is necessary.
## 8. Payment providers
To complete payments we may use third-party providers such as Stripe and PayPal, as well as banks for payments by bank transfer or IRIS.
The data required to complete a payment may be passed to the relevant payment provider.
**@TODO: Confirm the exact payment providers/banks that will be used in production and add them to the final version.**
These providers process data in accordance with their own privacy policies and applicable terms.
## 9. Shipping and delivery companies
To ship your orders we use shipping and delivery services, including ELTA Courier and BOX NOW.
To deliver an order, details such as your full name, address, phone number and other details needed for delivery may be passed to the relevant provider.
## 10. Accounting and tax services
Order details and the necessary customer details may be passed to the Epsilon system that 3DEALER uses to issue and manage invoices and receipts and to meet its tax and accounting obligations.
## 11. Security and abuse protection
We use technical and organizational measures to protect personal data against loss, unauthorized access, alteration or unlawful processing.
To protect the website against malicious or automated use, we may use the hCaptcha service. Using this service may involve processing technical data in accordance with the relevant provider's privacy policy.
## 12. Analytics and advertising services
3DEALER uses traffic analytics and advertising measurement services, such as:
* Google Analytics
* Google Tag Manager
* Meta Pixel
* TikTok Pixel
These technologies may use cookies or similar technologies to collect information about how the website is used and how effective advertising is.
Non-essential technologies are only activated in line with the consent choices you make through the cookie management tool.
More information is available in the Cookie Policy.
**@TODO: Confirm the exact tools, accounts and services that will be active in production.**
## 13. Who may have access to your data
3DEALER does not sell or rent your personal data.
To the extent necessary to provide the relevant services, the following may have access to personal data:
* payment providers,
* courier companies,
* hosting and technical support providers,
* email/newsletter service providers,
* analytics and advertising providers,
* accounting and tax services,
* public authorities and bodies, where required by law.
Third-party providers that process data on behalf of 3DEALER are used in accordance with the requirements of applicable data protection law.
## 14. Transfers outside the European Economic Area
Some of the providers we use may process data outside the European Economic Area.
In these cases, 3DEALER takes appropriate measures to ensure the transfer complies with the GDPR and that personal data receives the required level of protection.
**@TODO: Check the exact processing countries and transfer mechanisms for Stripe, PayPal, Google, Meta, TikTok, hCaptcha and the newsletter provider.**
## 15. Retention period
We keep personal data only for as long as necessary for the purpose it was collected for.
Data relating to orders and transactions is kept for as long as required by tax, accounting and commercial law.
Account data is kept for as long as the account remains active, unless there is a legal reason to keep it longer.
Data used for the newsletter is kept until you withdraw your consent or unsubscribe, unless there is another legal reason to keep it.
Contact data is kept for as long as necessary to handle your request and, where required, to protect our legitimate interests.
## 16. Your rights
Under the GDPR, and subject to the conditions set out in the law, you have the right to:
* access your personal data,
* correct inaccurate or incomplete data,
* have your data erased,
* restrict processing,
* data portability,
* object to certain types of processing,
* withdraw your consent, where processing is based on consent.
Exercising a right does not mean it can be applied in every case. For example, we may be required to keep certain data because of a tax or other legal obligation.
To exercise your rights, you can contact us:
**Email:** [{store_contact_email}](mailto:{store_contact_email})
**Phone:** {store_phone}
We will respond to your request without undue delay and, as a rule, within one (1) month of receiving it.
## 17. Right to lodge a complaint
If you believe the processing of your personal data breaches applicable law, you have the right to lodge a complaint with the Hellenic Data Protection Authority.
## 18. Changes to this Privacy Policy
This Privacy Policy may be changed when necessary, for example due to changes in legislation, in the services we use or in the way the online store operates.
The latest version will always be available on our website.
@@ -0,0 +1,99 @@
---
name: "Shipping & Returns"
meta_title: ""
meta_description: ""
---
## 1. Order preparation
All 3DEALER products are printed and prepared to order.
The usual preparation time for an order is **2 to 7 business days**.
Preparation time does not include the courier's transit time.
During periods of high demand there may be delays. In the event of a significant delay, 3DEALER will inform the customer.
## 2. Shipping
Orders are shipped via:
* ELTA Courier
* BOX NOW
We ship to the regions supported by the online store:
* Greece
* Cyprus
The available shipping options and costs are shown at checkout.
**Shipping costs:** @TODO — shipping rates are set dynamically in the Store's settings.
Free shipping is not offered.
Cash on delivery is not supported.
## 3. Delivery time
Delivery time depends on the courier and the destination.
3DEALER is not responsible for delays caused solely by the carrier, extreme weather, strikes, problems in the transport network or other circumstances beyond its reasonable control.
In case of a delay, you can contact us at [{store_contact_email}](mailto:{store_contact_email}).
## 4. Receiving your order
The customer is responsible for the accuracy of the delivery details entered when placing the order.
If the address is incorrect or incomplete, delivery may be delayed or a new shipment may be required. Any additional costs arising from incorrect delivery details may be charged to the customer.
## 5. Product delivered damaged
If the product arrives visibly damaged, please contact us as soon as possible at [{store_contact_email}](mailto:{store_contact_email}) and send us photos of the product and the packaging.
3DEALER will look into the case and, if damage that occurred in transit or another non-conformity is found, will provide an appropriate remedy in accordance with applicable law.
## 6. Right of withdrawal
For purchases that fall within the scope of the right of withdrawal, the consumer may withdraw from the contract within fourteen (14) calendar days of the day the product was received.
To exercise the right of withdrawal, the customer must inform 3DEALER with a clear statement at [{store_contact_email}](mailto:{store_contact_email}).
The product must be returned without undue delay and, in any case, within fourteen (14) days of the day the customer informed 3DEALER of their decision to withdraw.
The customer bears the direct cost of returning the product, unless otherwise agreed or 3DEALER did not inform them of this beforehand.
The customer is responsible for any reduction in the product's value resulting from handling beyond what is necessary to establish its nature, characteristics and functioning.
## 7. Exceptions to the right of withdrawal
The right of withdrawal does not apply in the cases excluded by applicable law.
In particular, it may not apply to products made to the customer's specifications or clearly personalized.
**@TODO: Confirm which 3DEALER products are considered personalized/custom and whether any products are not subject to the right of withdrawal.**
The fact that a product is produced after the order is placed does not in itself exclude it from the right of withdrawal.
## 8. Refunds
In the case of a valid withdrawal, 3DEALER refunds the money received from the customer for the purchase, including, where required by law, the standard delivery costs.
The refund is made using the same payment method as the original transaction, unless otherwise agreed.
3DEALER may withhold the refund until it has received the returned product or until the customer has provided proof of having sent it, whichever comes first.
## 9. Product exchanges
**@TODO: Confirm whether 3DEALER offers product/size exchanges or whether exchanges are handled only through a return and a new order.**
Where exchanges are offered, the procedure and any shipping costs will be communicated to the customer before the exchange is completed.
## 10. Defective or non-conforming products
If a product is defective or does not conform to the contract, the customer has the rights provided by applicable legislation on the sale of goods and consumer protection.
Minor cosmetic characteristics that are normal for 3D printing, such as layer lines or small variations, do not in themselves constitute a defect.
For any problem with a product, contact us at [{store_contact_email}](mailto:{store_contact_email}), stating your order number and, where possible, attaching photos.
@@ -0,0 +1,115 @@
---
name: "Terms & Conditions"
meta_title: ""
meta_description: ""
---
## 1. General
The online store 3DEALER (hereinafter "the Store" or "3DEALER") is operated by the business {store_legal_name}, registered at Parou 31, Kypseli, 11255 Athens, Greece.
**Tax ID (ΑΦΜ):** {store_tax_identifier}\
**Company registration number (ΓΕΜΗ):** {store_registration_number}
For any information or questions about orders, products or the operation of the Store, you can contact us:
**Email:** [{store_contact_email}](mailto:{store_contact_email})\
**Phone:** {store_phone}
Using the Store and making purchases through it requires acceptance of these Terms & Conditions.
These terms govern purchases made through the online store and are supplemented by the Privacy Policy and the Shipping & Returns Policy.
## 2. Products
The products offered through the Store are described and pictured as accurately as possible.
3DEALER products are made by 3D printing. Due to the nature of the production process, they may show layer lines, seams, small variations or other minor cosmetic imperfections. These characteristics are a normal result of the 3D printing process and are not in themselves considered a product defect.
All products are printed and prepared to order. The usual preparation time is 2 to 7 business days.
Unless otherwise stated in the description of a specific product, products are intended for decorative use or for use within reasonable and realistic limits. Use with excessive force, at very high temperatures or in a way other than intended may damage the product.
## 3. Prices
All product prices are shown in euros (€) and include the applicable VAT.
Shipping costs are calculated at checkout and shown before the purchase is confirmed.
**Shipping costs:** @TODO — the exact cost is calculated dynamically based on the Store's shipping settings.
## 4. Orders
Customers can make a purchase either by creating an account in the Store or by using guest checkout, without creating an account.
Completing an order requires the details necessary for processing, payment and delivery.
Before finalizing the order, the customer can review the order details and the total cost, including any shipping charges.
Once the order is completed, a confirmation is sent to the email address provided by the customer.
**@TODO: Confirm whether the order is considered final once payment is completed or once the order confirmation is sent.**
## 5. Payment methods
The Store supports the following payment methods:
* Credit or debit card
* PayPal
* Stripe
* IRIS
* Bank transfer
Electronic payments may use third-party payment services. Card details or other sensitive payment details are not stored by 3DEALER, but are processed by the relevant payment provider in accordance with its own terms and privacy policy.
## 6. Order cancellations
**@TODO: Confirm the cancellation policy.**
As a general rule, customers who wish to cancel an order can contact 3DEALER as soon as possible after placing it.
If preparation of the order has not yet started, 3DEALER can cancel it and refund the amount paid.
If production has already started, the terms of the Shipping & Returns Policy apply, together with the statutory right of withdrawal where applicable.
## 7. Right of withdrawal and returns
Where provided for by applicable law, consumers have the right to withdraw from a distance contract within fourteen (14) calendar days of receiving the product, without having to give a reason.
Exceptions to the right of withdrawal apply in the cases provided for by applicable law, including for products made to the consumer's specifications or clearly personalized.
The returns procedure and terms are described in detail in the Shipping & Returns Policy.
## 8. Legal guarantee and defective products
3DEALER is responsible for the conformity of products with the contract and for the consumer's statutory rights in the event of non-conformity or a defect, in accordance with applicable law.
Normal characteristics of 3D printing, such as layer lines or small cosmetic variations that are expected from this production process, do not in themselves constitute non-conformity.
## 9. Availability
3DEALER makes every effort to keep product availability information accurate.
In exceptional cases where a product cannot be produced or shipped, 3DEALER will contact the customer and explain the available options.
## 10. Intellectual property
The content of the Store, including but not limited to designs, photographs, texts, graphics, logos and other material, is the property of 3DEALER or is lawfully used by it, and is protected by applicable intellectual and industrial property law.
Copying, reproducing, modifying, distributing or commercially exploiting the content without prior written permission is not allowed.
## 11. Personal data
Customers' personal data is processed in accordance with the 3DEALER Privacy Policy.
## 12. Changes to these Terms
3DEALER reserves the right to change these Terms & Conditions when necessary, in particular due to changes in legislation, services or the way the Store operates.
The terms that apply to each order are those that were available at the time that order was placed.
## 13. Governing law
These Terms & Conditions are governed by Greek law.
Any dispute relating to the use of the Store or the purchase of products is subject to the applicable Greek and European consumer protection legislation.
+3
View File
@@ -0,0 +1,3 @@
---
meta_description: "Your local 3Dealer of 3D printed, high-quality products. Geek culture, decoration, fun and skulls, and custom orders."
---
+8
View File
@@ -9,6 +9,14 @@ content_path: /content
media_path: /media media_path: /media
thumbs_path: /storage/app/public/stoic/ thumbs_path: /storage/app/public/stoic/
repository_path: /repository repository_path: /repository
# First locale is Stoic's default: it holds every field, the others only the
# translatable ones (content/{locale}/...). Codes match app.available_locales.
i18n:
locales:
- code: el
label: Ελληνικά
- code: en
label: English
presets: presets:
- code: large - code: large
mode: resize mode: resize
+83 -1
View File
@@ -2,6 +2,7 @@ schemas:
- name: pages - name: pages
label: Pages label: Pages
type: files type: files
translatable: true
files: files:
- name: home - name: home
label: Home Page label: Home Page
@@ -10,15 +11,18 @@ schemas:
- name: name - name: name
label: Name label: Name
ui: string ui: string
translatable: true
- name: slug - name: slug
label: Slug label: Slug
ui: slug ui: slug
- name: meta_title - name: meta_title
label: Meta Title label: Meta Title
ui: string ui: string
translatable: true
- name: meta_description - name: meta_description
label: Meta Description label: Meta Description
ui: string ui: string
translatable: true
- name: og_image - name: og_image
label: Og Image label: Og Image
ui: image ui: image
@@ -27,10 +31,50 @@ schemas:
ui: image ui: image
- name: trivia_body - name: trivia_body
label: "Trivia Body (intro paragraph under the hero)" label: "Trivia Body (intro paragraph under the hero)"
ui: markdown ui: text
translatable: true
- name: classics_title - name: classics_title
label: "Classics Section Title (favorites carousel heading)" label: "Classics Section Title (favorites carousel heading)"
ui: string ui: string
translatable: true
- name: contact
label: Contact Page
file: pages/contact.md
fields:
- name: name
label: Name
ui: string
translatable: true
- name: slug
label: Slug
ui: slug
- name: meta_title
label: Meta Title
ui: string
translatable: true
- name: meta_description
label: Meta Description
ui: string
translatable: true
- name: og_image
label: Og Image
ui: image
- name: heading_first
label: "Heading, first line (animated)"
ui: string
translatable: true
- name: heading_second
label: "Heading, second line"
ui: string
translatable: true
- name: heading_highlight
label: "Heading, last word(s) of the second line (animated)"
ui: string
translatable: true
- name: intro
label: "Intro (paragraph above the form)"
ui: text
translatable: true
- name: terms-and-conditions - name: terms-and-conditions
label: "Terms & Conditions Page" label: "Terms & Conditions Page"
file: pages/terms-and-conditions.md file: pages/terms-and-conditions.md
@@ -38,15 +82,18 @@ schemas:
- name: name - name: name
label: Name label: Name
ui: string ui: string
translatable: true
- name: slug - name: slug
label: Slug label: Slug
ui: slug ui: slug
- name: meta_title - name: meta_title
label: Meta Title label: Meta Title
ui: string ui: string
translatable: true
- name: meta_description - name: meta_description
label: Meta Description label: Meta Description
ui: string ui: string
translatable: true
- name: og_image - name: og_image
label: Og Image label: Og Image
ui: image ui: image
@@ -56,6 +103,7 @@ schemas:
- name: body - name: body
label: Body label: Body
ui: markdown ui: markdown
translatable: true
- name: shipping-returns - name: shipping-returns
label: "Shipping & Returns Page" label: "Shipping & Returns Page"
file: pages/shipping-returns.md file: pages/shipping-returns.md
@@ -63,15 +111,18 @@ schemas:
- name: name - name: name
label: Name label: Name
ui: string ui: string
translatable: true
- name: slug - name: slug
label: Slug label: Slug
ui: slug ui: slug
- name: meta_title - name: meta_title
label: Meta Title label: Meta Title
ui: string ui: string
translatable: true
- name: meta_description - name: meta_description
label: Meta Description label: Meta Description
ui: string ui: string
translatable: true
- name: og_image - name: og_image
label: Og Image label: Og Image
ui: image ui: image
@@ -81,6 +132,7 @@ schemas:
- name: body - name: body
label: Body label: Body
ui: markdown ui: markdown
translatable: true
- name: privacy-policy - name: privacy-policy
label: "Privacy Policy Page" label: "Privacy Policy Page"
file: pages/privacy-policy.md file: pages/privacy-policy.md
@@ -88,15 +140,18 @@ schemas:
- name: name - name: name
label: Name label: Name
ui: string ui: string
translatable: true
- name: slug - name: slug
label: Slug label: Slug
ui: slug ui: slug
- name: meta_title - name: meta_title
label: Meta Title label: Meta Title
ui: string ui: string
translatable: true
- name: meta_description - name: meta_description
label: Meta Description label: Meta Description
ui: string ui: string
translatable: true
- name: og_image - name: og_image
label: Og Image label: Og Image
ui: image ui: image
@@ -106,6 +161,7 @@ schemas:
- name: body - name: body
label: Body label: Body
ui: markdown ui: markdown
translatable: true
- name: cookies-policy - name: cookies-policy
label: "Cookies Policy Page" label: "Cookies Policy Page"
file: pages/cookies-policy.md file: pages/cookies-policy.md
@@ -113,15 +169,18 @@ schemas:
- name: name - name: name
label: Name label: Name
ui: string ui: string
translatable: true
- name: slug - name: slug
label: Slug label: Slug
ui: slug ui: slug
- name: meta_title - name: meta_title
label: Meta Title label: Meta Title
ui: string ui: string
translatable: true
- name: meta_description - name: meta_description
label: Meta Description label: Meta Description
ui: string ui: string
translatable: true
- name: og_image - name: og_image
label: Og Image label: Og Image
ui: image ui: image
@@ -131,3 +190,26 @@ schemas:
- name: body - name: body
label: Body label: Body
ui: markdown ui: markdown
translatable: true
- name: settings
label: Settings
type: singleton
file: settings.md
translatable: true
fields:
- name: meta_description
label: "Default Meta Description (used when a page has none)"
ui: text
translatable: true
- name: og_image
label: "Default Og Image (used when a page has none, ideally 1200×630)"
ui: image
- name: facebook_url
label: Facebook URL
ui: string
- name: instagram_url
label: Instagram URL
ui: string
- name: tiktok_url
label: TikTok URL
ui: string
@@ -29,6 +29,9 @@
<ul> <ul>
@foreach ($orders as $order) @foreach ($orders as $order)
@php
$orderReference = \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order);
@endphp
<li class="grid grid-cols-2 gap-x-6 gap-y-2 border-b border-black py-5 sm:grid-cols-[1fr_1fr_1.5fr_1fr_auto] sm:items-center"> <li class="grid grid-cols-2 gap-x-6 gap-y-2 border-b border-black py-5 sm:grid-cols-[1fr_1fr_1.5fr_1fr_auto] sm:items-center">
<span> <span>
<span class="sr-only">{{ __('storefront.orders.date') }}:</span> <span class="sr-only">{{ __('storefront.orders.date') }}:</span>
@@ -36,7 +39,7 @@
</span> </span>
<span class="text-right font-bold sm:text-left"> <span class="text-right font-bold sm:text-left">
<span class="sr-only">{{ __('storefront.orders.number') }}:</span> <span class="sr-only">{{ __('storefront.orders.number') }}:</span>
#{{ $order->reference }} #{{ $orderReference }}
</span> </span>
<span> <span>
<span class="sr-only">{{ __('storefront.orders.status') }}:</span> <span class="sr-only">{{ __('storefront.orders.status') }}:</span>
@@ -49,7 +52,7 @@
<a <a
href="{{ route('account.orders.show', ['orderId' => $order->id]) }}" href="{{ route('account.orders.show', ['orderId' => $order->id]) }}"
class="col-span-2 mt-2 inline-flex w-24 items-center gap-2 font-bold underline hover:no-underline sm:col-span-1 sm:mt-0" class="col-span-2 mt-2 inline-flex w-24 items-center gap-2 font-bold underline hover:no-underline sm:col-span-1 sm:mt-0"
aria-label="{{ __('storefront.orders.view_order', ['number' => $order->reference]) }}" aria-label="{{ __('storefront.orders.view_order', ['number' => $orderReference]) }}"
> >
{{ __('storefront.orders.view') }} {{ __('storefront.orders.view') }}
<x-ui.icon name="arrow-right" :size="16" /> <x-ui.icon name="arrow-right" :size="16" />
@@ -1,6 +1,10 @@
@extends('layouts.account') @extends('layouts.account')
@section('title', __('storefront.orders.order_title', ['number' => $order->reference])) @php
$orderReference = \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order);
@endphp
@section('title', __('storefront.orders.order_title', ['number' => $orderReference]))
@php @php
$productLines = $order->lines->where('type', '!=', 'shipping'); $productLines = $order->lines->where('type', '!=', 'shipping');
@@ -18,7 +22,7 @@
</a> </a>
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2"> <h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
{{ __('storefront.orders.order_title', ['number' => $order->reference]) }} {{ __('storefront.orders.order_title', ['number' => $orderReference]) }}
</h1> </h1>
</div> </div>
@@ -0,0 +1,44 @@
{{--
Store address, phone and email from Lunar's Store Details
(Modules\Core\Store). Each line is left out when its value is empty.
--}}
@props([
'store' => null,
])
@php
$address = $store?->translate('address');
$phone = $store?->phone;
$email = $store?->contact_email;
@endphp
@if (filled($address) || filled($phone) || filled($email))
<address {{ $attributes->merge(['class' => 'not-italic']) }}>
<dl class="flex flex-col gap-10">
@if (filled($address))
<div class="flex flex-col gap-1">
<dt class="font-display text-sm font-semibold uppercase">{{ __('storefront.contact.address') }}</dt>
<dd class="font-display text-h4 font-semibold leading-tight">{{ $address }}</dd>
</div>
@endif
@if (filled($phone))
<div class="flex flex-col gap-1">
<dt class="font-display text-sm font-semibold uppercase">{{ __('storefront.contact.phone') }}</dt>
<dd class="font-display text-h4 font-semibold leading-tight">
<a href="tel:{{ preg_replace('/[^\d+]/', '', $phone) }}" class="underline-slide [--slide-h:1px] pb-0">{{ $phone }}</a>
</dd>
</div>
@endif
@if (filled($email))
<div class="flex flex-col gap-1">
<dt class="font-display text-sm font-semibold uppercase">Email</dt>
<dd class="font-display text-h4 font-semibold leading-tight break-all">
<a href="mailto:{{ $email }}" class="underline-slide [--slide-h:1px] pb-0">{{ $email }}</a>
</dd>
</div>
@endif
</dl>
</address>
@endif
+14 -23
View File
@@ -26,29 +26,20 @@
</div> </div>
</div> </div>
{{-- Social media --}} {{-- Social media — URLs from Stoic (settings.md); empty ones are hidden. --}}
<nav aria-label="Social media"> @if ($stoicSettings->socialLinks())
<ul class="flex items-center gap-4"> <nav aria-label="Social media">
<li> <ul class="flex items-center gap-4">
<a href="#" aria-label="Facebook" class="hover:text-brand"> @foreach ($stoicSettings->socialLinks() as $social)
<x-ui.icon name="facebook" :size="22" color="text-neutral-300" /> <li>
{{-- <span class="leading-none text-[22px] font-extrabold italic group-hover:underline">FB.</span> --}} <a href="{{ $social['url'] }}" aria-label="{{ $social['label'] }}" class="hover:text-brand">
</a> <x-ui.icon :name="$social['icon']" :size="22" />
</li> </a>
<li> </li>
<a href="#" aria-label="Instagram" class="hover:text-brand"> @endforeach
<x-ui.icon name="instagram" :size="22" /> </ul>
{{-- <span class="leading-none text-[22px] font-extrabold italic group-hover:underline">IG.</span> --}} </nav>
</a> @endif
</li>
<li>
<a href="#" aria-label="TikTok" class="hover:text-brand">
<x-ui.icon name="tiktok" :size="22" />
{{-- <span class="leading-none text-[22px] font-extrabold italic group-hover:underline">TT.</span> --}}
</a>
</li>
</ul>
</nav>
{{-- Copyright --}} {{-- Copyright --}}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -5,6 +5,10 @@
inside the add-to-cart <form> so their values travel with it as inside the add-to-cart <form> so their values travel with it as
custom_fields[key]. text → input, textarea → textarea, file → photo upload. custom_fields[key]. text → input, textarea → textarea, file → photo upload.
The label is styled like the product option labels (option-buttons /
color-swatch); the optional help_text shows under it. Help text is
product-page only; the cart/checkout line meta only snapshots the label.
A photo is uploaded as soon as it's picked (custom-field-upload-controller.js) A photo is uploaded as soon as it's picked (custom-field-upload-controller.js)
and only the resulting File row's id (boboko-core's Modules\Core\File\ and only the resulting File row's id (boboko-core's Modules\Core\File\
Models\File) is submitted with the form — the file input itself has no Models\File) is submitted with the form — the file input itself has no
@@ -22,18 +26,25 @@
$id = 'custom-field-'.$field['key']; $id = 'custom-field-'.$field['key'];
$name = 'custom_fields['.$field['key'].']'; $name = 'custom_fields['.$field['key'].']';
$required = (bool) ($field['required'] ?? false); $required = (bool) ($field['required'] ?? false);
$hint = filled($field['help_text'] ?? null) ? $field['help_text'] : null;
// mb-1 on top of the field's gap-2 matches the option labels' mb-3;
// with a hint, the label + hint group carries that spacing instead.
$labelClass = 'font-bold text-sm uppercase tracking-wide'.($hint ? '' : ' mb-1');
$hintId = $hint ? $id.'-hint' : null;
@endphp @endphp
@switch($field['type']) @switch($field['type'])
@case('textarea') @case('textarea')
<x-ui.field :label="$field['label']" :for="$id" :required="$required"> <x-ui.field :label="$field['label']" :label-class="$labelClass" :hint="$hint" :for="$id" :required="$required">
<x-ui.textarea :id="$id" :name="$name" :required="$required" :rows="4" maxlength="2000" /> <x-ui.textarea :id="$id" :name="$name" :required="$required" :rows="4" maxlength="2000" :aria-describedby="$hintId" />
</x-ui.field> </x-ui.field>
@break @break
@case('file') @case('file')
<x-ui.field <x-ui.field
:label="$field['label']" :label="$field['label']"
:label-class="$labelClass"
:hint="$hint"
:for="$id" :for="$id"
:required="$required" :required="$required"
:description="__('storefront.product.custom_field_photo_hint', ['size' => CustomFieldUploadController::MAX_KILOBYTES / 1024])" :description="__('storefront.product.custom_field_photo_hint', ['size' => CustomFieldUploadController::MAX_KILOBYTES / 1024])"
@@ -49,7 +60,7 @@
:id="$id" :id="$id"
:accept="CustomFieldUploadController::accept()" :accept="CustomFieldUploadController::accept()"
:required="$required" :required="$required"
aria-describedby="{{ $id }}-description {{ $id }}-error" aria-describedby="{{ $hintId ? $hintId.' ' : '' }}{{ $id }}-description {{ $id }}-error"
data-custom-field-upload-target="file" data-custom-field-upload-target="file"
data-action="change->custom-field-upload#upload" data-action="change->custom-field-upload#upload"
/> />
@@ -69,8 +80,8 @@ class="w-24 h-24 object-cover border border-black"
@break @break
@default @default
<x-ui.field :label="$field['label']" :for="$id" :required="$required"> <x-ui.field :label="$field['label']" :label-class="$labelClass" :hint="$hint" :for="$id" :required="$required">
<x-ui.input :id="$id" :name="$name" :required="$required" maxlength="255" /> <x-ui.input :id="$id" :name="$name" :required="$required" maxlength="255" :aria-describedby="$hintId" />
</x-ui.field> </x-ui.field>
@endswitch @endswitch
@endforeach @endforeach
@@ -1,6 +1,8 @@
@props([ @props([
'label' => null, 'label' => null,
'labelDescription' => null, 'labelDescription' => null,
'labelClass' => null,
'hint' => null,
'for' => null, 'for' => null,
'description' => null, 'description' => null,
'error' => null, 'error' => null,
@@ -10,9 +12,23 @@
<div {{ $attributes->merge(['class' => 'flex flex-col gap-2']) }}> <div {{ $attributes->merge(['class' => 'flex flex-col gap-2']) }}>
@if ($label) @if ($label)
{{-- With a hint, label + hint are grouped so the hint sits tight
under the label, with the usual gap before the input. The input
should reference {for}-hint in its aria-describedby. --}}
@if ($hint) <div class="flex flex-col gap-1"> @endif
<label <label
@if ($for) for="{{ $for }}" @endif @if ($for) for="{{ $for }}" @endif
@if ($labelClass) class="{{ $labelClass }}" @endif
>{{ $label }}@if ($required)<span class="ml-1" aria-hidden="true">*</span>@endif @if ($labelDescription) <span class="text-sm text-neutral-500">({{ $labelDescription }})</span> @endif</label> >{{ $label }}@if ($required)<span class="ml-1" aria-hidden="true">*</span>@endif @if ($labelDescription) <span class="text-sm text-neutral-500">({{ $labelDescription }})</span> @endif</label>
@if ($hint)
<p
@if ($for) id="{{ $for }}-hint" @endif
class="text-sm text-neutral-500"
>{{ $hint }}</p>
</div>
@endif
@endif @endif
{{ $slot }} {{ $slot }}
@@ -8,7 +8,7 @@
@props(['productId']) @props(['productId'])
@php @php
$active = app(\App\Services\Wishlist::class)->has((int) $productId); $active = app(\Modules\Core\Wishlist\Services\WishlistService::class)->has((int) $productId);
$addLabel = __('storefront.wishlist.add'); $addLabel = __('storefront.wishlist.add');
$removeLabel = __('storefront.wishlist.remove'); $removeLabel = __('storefront.wishlist.remove');
@endphp @endphp
+11 -9
View File
@@ -1,7 +1,8 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', __('storefront.nav.contact')) @section('title', $page->meta_title ?: $page->name)
@section('description', 'Επικοινώνησε μαζί μας για οποιαδήποτε απορία ή ιδέα έχεις και θα σου απαντήσουμε το συντομότερο δυνατό.') @section('description', $page->meta_description)
@section('og_image', $page->og_image)
{{-- Only on pages with a captcha, not in the global layout. --}} {{-- Only on pages with a captcha, not in the global layout. --}}
@push('scripts') @push('scripts')
@@ -18,16 +19,15 @@
<path d="m88.4 0 4 28.9 29.7-5.2-14.3 25.7L135 62l-27.2 12.6 14.3 25.7-29.7-5.2-4 28.9-20.9-21.1L46.6 124l-4-28.9-29.7 5.2 14.3-25.7L0 62l27.2-12.6-14.3-25.7 29.7 5.2 4-28.9 20.9 21.1L88.4 0z"/> <path d="m88.4 0 4 28.9 29.7-5.2-14.3 25.7L135 62l-27.2 12.6 14.3 25.7-29.7-5.2-4 28.9-20.9-21.1L46.6 124l-4-28.9-29.7 5.2 14.3-25.7L0 62l27.2-12.6-14.3-25.7 29.7 5.2 4-28.9 20.9 21.1L88.4 0z"/>
</svg> </svg>
<h1 class="font-display text-h2 font-semibold leading-tight lg:text-h2" <h1 class="font-display text-h1 font-semibold leading-tight"
data-controller="appear" data-controller="appear"
data-appear-visible-class="is-visible"> data-appear-visible-class="is-visible">
<span class="text-dance">Επικοινώνησε</span><br> <span class="text-dance" data-stoic="pages/contact#heading_first">{{ $page->heading_first }}</span><br>
μαζί μας <span class="text-dance [--dance-delay:200ms]">άμεσα</span> <span data-stoic="pages/contact#heading_second">{{ $page->heading_second }}</span>
<span class="text-dance [--dance-delay:200ms]" data-stoic="pages/contact#heading_highlight">{{ $page->heading_highlight }}</span>
</h1> </h1>
<p class="max-w-md "> <p class="max-w-xl" data-stoic="pages/contact#intro">{{ $page->intro }}</p>
Έχεις απορία, ιδέα ή θες να μας πεις κάτι; Γράψε μας το μήνυμά σου και θα σου απαντήσουμε το συντομότερο δυνατό.
</p>
<x-ui.status class="max-w-xl" /> <x-ui.status class="max-w-xl" />
@@ -75,7 +75,9 @@ class="h-captcha min-h-[78px]"
</div> </div>
<div class="min-h-[320px] bg-neutral-400 lg:min-h-full" aria-hidden="true"></div> <div class="flex flex-col justify-center border-t border-black px-8 py-16 lg:border-t-0 lg:px-16">
<x-contact-details :store="$store" />
</div>
</section> </section>
+14
View File
@@ -42,6 +42,13 @@
#MessageViewBody, #MessageWebViewDiv { width: 100% !important; } #MessageViewBody, #MessageWebViewDiv { width: 100% !important; }
u + #email-body a { color: inherit; text-decoration: none; } u + #email-body a { color: inherit; text-decoration: none; }
/* Admin-authored rich text (e.g. bank transfer instructions). */
.email-rich p, .email-rich ul, .email-rich ol, .email-rich table { margin: 0 0 12px; }
.email-rich > :last-child { margin-bottom: 0; }
.email-rich a { color: #000000; text-decoration: underline; }
.email-rich table { width: 100%; border-collapse: collapse; }
.email-rich th, .email-rich td { padding: 6px 8px; border: 1px solid #e5e5e5; text-align: left; vertical-align: top; }
@media only screen and (max-width: 480px) { @media only screen and (max-width: 480px) {
.email-container { width: 100% !important; max-width: 100% !important; } .email-container { width: 100% !important; max-width: 100% !important; }
.email-header { padding: 24px 20px 12px !important; } .email-header { padding: 24px 20px 12px !important; }
@@ -83,6 +90,13 @@
<tr> <tr>
<td class="email-footer" align="center" style="padding:24px 24px 40px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:13px;line-height:1.6;color:#7a7a7a;"> <td class="email-footer" align="center" style="padding:24px 24px 40px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:13px;line-height:1.6;color:#7a7a7a;">
@yield('footer') @yield('footer')
@if ($stoicSettings->socialLinks())
<p style="margin:12px 0 0;">
@foreach ($stoicSettings->socialLinks() as $social)
@unless ($loop->first)&nbsp;&middot;&nbsp;@endunless<a href="{{ $social['url'] }}" target="_blank" style="color:#7a7a7a;text-decoration:underline;">{{ $social['label'] }}</a>
@endforeach
</p>
@endif
<p style="margin:12px 0 0;"> <p style="margin:12px 0 0;">
<a href="{{ url('/') }}" style="color:#7a7a7a;text-decoration:underline;">3dealer.gr</a> <a href="{{ url('/') }}" style="color:#7a7a7a;text-decoration:underline;">3dealer.gr</a>
&nbsp;&middot;&nbsp;&copy; {{ date('Y') }} 3Dealer &nbsp;&middot;&nbsp;&copy; {{ date('Y') }} 3Dealer
+4
View File
@@ -0,0 +1,4 @@
@extends('errors.layout')
@section('error_title', __('storefront.errors.404_title'))
@section('error_text', __('storefront.errors.404_text'))
+4
View File
@@ -0,0 +1,4 @@
@extends('errors.layout')
@section('error_title', __('storefront.errors.4xx_title'))
@section('error_text', __('storefront.errors.4xx_text'))
+4
View File
@@ -0,0 +1,4 @@
@extends('errors.layout')
@section('error_title', __('storefront.errors.500_title'))
@section('error_text', __('storefront.errors.500_text'))
+4
View File
@@ -0,0 +1,4 @@
@extends('errors.layout')
@section('error_title', __('storefront.errors.5xx_title'))
@section('error_text', __('storefront.errors.5xx_text'))
+26
View File
@@ -0,0 +1,26 @@
{{-- Shared by every errors/{code} view: each one only sets title + text. --}}
@extends('layouts.app')
{{-- Block form: the inline @section('title', …) form would escape the
already-escaped error_title a second time. --}}
@section('title')
@yield('error_title')
@endsection
@push('seo')
<meta name="robots" content="noindex">
@endpush
@section('content')
<section class="flex flex-col items-center justify-center gap-8 px-8 py-24 text-center min-h-[calc(100vh-12rem)] sm:min-h-[calc(100vh-8rem)]">
<h1 class="font-display text-h1 font-semibold leading-tight">@yield('error_title')</h1>
<p class="max-w-xl">@yield('error_text')</p>
<x-ui.button :href="route('home')" class="mt-4">{{ __('storefront.errors.back_home') }}</x-ui.button>
</section>
@endsection
+16
View File
@@ -2,6 +2,22 @@
@section('title', $page->meta_title) @section('title', $page->meta_title)
@section('description', $page->meta_description) @section('description', $page->meta_description)
@section('og_image', $page->og_image)
{{-- Organization structured data, home page only. Name from core's StoreDetails,
social profiles (sameAs) from Stoic settings.md. --}}
@push('seo')
<script type="application/ld+json">
{!! json_encode(array_filter([
'@context' => 'https://schema.org',
'@type' => 'Organization',
'name' => app(\Modules\Core\Store\Services\StoreDetailsService::class)->current()->translate('name') ?: config('app.name'),
'url' => url('/'),
'logo' => asset('images/logo.png'),
'sameAs' => array_column($stoicSettings->socialLinks(), 'url'),
]), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) !!}
</script>
@endpush
@section('content') @section('content')
+8 -2
View File
@@ -14,7 +14,13 @@
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<title>@yield('title', config('app.name'))</title> <title>@yield('title', config('app.name'))</title>
<meta name="description" content="@yield('description', '')"> {{-- Falls back to Stoic's default when a page sets none, or sets it to an
empty meta_description (@yield's own default only covers "not set"). --}}
<meta name="description" content="{{ trim($__env->yieldContent('description')) ?: $stoicSettings->get('meta_description', '') }}">
{{-- A page's own Stoic og_image (via @section('og_image')), else the default from settings.md. --}}
@if ($ogImage = $stoicSettings->ogImageUrl(trim($__env->yieldContent('og_image'))))
<meta property="og:image" content="{{ $ogImage }}">
@endif
{{-- Reciprocal hreflang: self-reference + every alternate locale + x-default --}} {{-- Reciprocal hreflang: self-reference + every alternate locale + x-default --}}
@isset($currentLocale) @isset($currentLocale)
@@ -37,7 +43,7 @@
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-700.woff2') }}"> <link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-700.woff2') }}">
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-800.woff2') }}"> <link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-800.woff2') }}">
@vite(['resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js']) @vite(['node_modules/@boboko/core/resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js'])
@auth('staff') @auth('staff')
@php($stoicToken = \App\Services\Stoic::token(auth('staff')->user()->email)) @php($stoicToken = \App\Services\Stoic::token(auth('staff')->user()->email))
+2 -1
View File
@@ -2,6 +2,7 @@
@section('title', $page->meta_title ?: $page->name) @section('title', $page->meta_title ?: $page->name)
@section('description', $page->meta_description) @section('description', $page->meta_description)
@section('og_image', $page->og_image)
@if($page->noindex ?? false) @if($page->noindex ?? false)
@push('seo') @push('seo')
@@ -20,7 +21,7 @@
</div> </div>
<div class="legal-content" data-stoic="pages/{{ $page->slug }}#body"> <div class="legal-content" data-stoic="pages/{{ $page->slug }}#body">
{!! Str::markdown($page->content ?? '') !!} {!! Str::markdown($content) !!}
</div> </div>
</section> </section>
+2
View File
@@ -281,6 +281,8 @@ class="mt-20"
'price' => $recommendation['price'], 'price' => $recommendation['price'],
'image' => $recommendation['image'], 'image' => $recommendation['image'],
'href' => route('product.show', ['id' => $recommendation['id']]), 'href' => route('product.show', ['id' => $recommendation['id']]),
'variantId' => $recommendation['variant_id'] ?? null,
'hasCustomFields' => $recommendation['has_custom_fields'] ?? false,
])->all()" ])->all()"
/> />
@endif @endif
@@ -27,6 +27,10 @@
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email; $email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
$comments = $order->shippingAddress?->delivery_instructions; $comments = $order->shippingAddress?->delivery_instructions;
// Sanitized HTML from Store Details, only for bank transfer orders.
$bankTransferInstructions = app(\Modules\Core\Store\Services\StoreDetailsService::class)
->bankTransferInstructionsFor($order, 'el');
$muted = 'color:#5c5c5c;font-size:14px;'; $muted = 'color:#5c5c5c;font-size:14px;';
$font = "font-family:'Manrope',Arial,Helvetica,sans-serif;"; $font = "font-family:'Manrope',Arial,Helvetica,sans-serif;";
@endphp @endphp
@@ -53,6 +57,12 @@
]) ])
</div> </div>
@if ($bankTransferInstructions)
<div class="email-rich" style="margin:0 0 40px;padding:20px;border:1px solid #000000;{{ $font }}font-size:15px;line-height:1.6;color:#000000;">
{!! $bankTransferInstructions !!}
</div>
@endif
{{-- Items --}} {{-- Items --}}
@include('emails.partials.section-heading', ['text' => 'Προϊόντα']) @include('emails.partials.section-heading', ['text' => 'Προϊόντα'])
+9 -6
View File
@@ -63,7 +63,6 @@
Route::get('/contact', [ContactController::class, 'index'])->name('contact'); Route::get('/contact', [ContactController::class, 'index'])->name('contact');
Route::post('/contact', [ContactController::class, 'send'])->name('contact.send'); Route::post('/contact', [ContactController::class, 'send'])->name('contact.send');
Route::get('/terms-and-conditions', [LegalPageController::class, 'terms'])->name( Route::get('/terms-and-conditions', [LegalPageController::class, 'terms'])->name(
'legal.terms', 'legal.terms',
); );
@@ -98,12 +97,10 @@
->name('logout'); ->name('logout');
// Heart button: works for guests too (cookie), merged into the account // Heart button: works for guests too (cookie), merged into the account
// on login — see App\Services\Wishlist. // on login — see Modules\Core\Wishlist\Services\WishlistService. The
// toggle action itself (route `wishlist.toggle`) is registered by
// core's own WishlistServiceProvider, not here.
Route::get('/wishlist', [WishlistController::class, 'guest'])->name('wishlist'); Route::get('/wishlist', [WishlistController::class, 'guest'])->name('wishlist');
Route::post('/wishlist/{productId}', [WishlistController::class, 'toggle'])
->whereNumber('productId')
->middleware('throttle:60,1')
->name('wishlist.toggle');
Route::middleware('auth')->prefix('/account')->group(function () { Route::middleware('auth')->prefix('/account')->group(function () {
Route::get('/', [AccountController::class, 'show'])->name('account'); Route::get('/', [AccountController::class, 'show'])->name('account');
@@ -134,4 +131,10 @@
->name('account.email.resend'); ->name('account.email.resend');
}); });
// Unmatched URLs would otherwise 404 during routing, before `web` and
// `locale` middleware run, so errors/404 would render with no session,
// no App::setLocale() and no URL::defaults() — the header's route()
// calls would then throw on the missing {locale}. Catching them here
// renders the 404 inside the locale group like any other page.
Route::fallback(fn () => abort(404));
}); });
+7 -1
View File
@@ -1,6 +1,7 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import laravel from "laravel-vite-plugin"; import laravel from "laravel-vite-plugin";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { boboko } from "@boboko/core/vite-plugin";
export default defineConfig({ export default defineConfig({
build: { build: {
@@ -14,13 +15,18 @@ export default defineConfig({
// checkout.css first: it's the portable cart/checkout module's // checkout.css first: it's the portable cart/checkout module's
// structural base (@layer bbk-checkout) and must load before // structural base (@layer bbk-checkout) and must load before
// app.css so the host's own .bbk-* theming always wins. // app.css so the host's own .bbk-* theming always wins.
"resources/css/checkout.css", // Sourced from the @boboko/core npm package (a local
// `file:../boboko-core` dependency — see package.json and
// boboko()'s own comments in @boboko/core/vite-plugin for why
// this needs no further config here).
"node_modules/@boboko/core/resources/css/checkout.css",
"resources/css/app.css", "resources/css/app.css",
"resources/js/app.js", "resources/js/app.js",
], ],
refresh: true, refresh: true,
}), }),
tailwindcss(), tailwindcss(),
boboko(),
], ],
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",