Compare commits

...
21 Commits
Author SHA1 Message Date
arvanitakis 5f0dbbb734 Bump version to 0.26.5 2026-09-28 17:36:39 +03:00
arvanitakis 839335ed3f Feat: Adding Contact options to Store Details 2026-09-28 17:36:37 +03:00
arvanitakis 0a6958eb9e Bump version to 0.26.4 2026-09-28 17:10:59 +03:00
arvanitakis d189a7559a Fix: Marking orded placed and setting order as awaiting payment when payment is bank transfer 2026-09-28 17:10:57 +03:00
arvanitakis 7f2ddea240 Bump version to 0.26.3 2026-09-28 13:46:30 +03:00
arvanitakis 45df93692a Chore: Adding Validation Translation Seeder from 3dealer 2026-09-28 13:45:27 +03:00
arvanitakis b806db611f Fix: Adding translation 2026-09-28 13:45:27 +03:00
elvira c1874c277e Bump version to 0.26.2 2026-09-28 13:28:21 +03:00
elvira 05f00ed1fd wishlist route fix 2026-09-28 12:49:41 +03:00
elvira 569eb310e9 Merge branch 'master' of ssh://code.radical-elements.com:2727/boboko/core 2026-09-28 12:32:21 +03:00
elvira 0387b38ab3 minor changes in checkout module css 2026-09-28 12:31:50 +03:00
arvanitakis 0a51d912a0 Bump version to 0.26.1 2026-09-28 11:58:09 +03:00
arvanitakis 1214f9b748 Feat: Adding OrderReferenceDisplay Service that trims down order leading 0s 2026-09-28 11:57:30 +03:00
arvanitakis effbce195c Bump version to 0.26.0 2026-09-28 11:32:20 +03:00
arvanitakis dca6944153 Feat: Adding Store Details Page 2026-09-28 11:30:15 +03:00
arvanitakis 68ebe35b8e Bump version to 0.25.2 2026-09-28 10:16:19 +03:00
arvanitakis ee4d9b7952 Fix: Bank transfer orders should stay in awaiting payment 2026-09-28 10:15:23 +03:00
arvanitakis e5679afa25 Bump version to 0.25.1 2026-09-28 10:05:50 +03:00
arvanitakis a55411aaf2 Fix: Removing Delete button from customer view, deletes should flow only through the privacy services 2026-09-28 10:05:40 +03:00
arvanitakis 088d8cb809 Bump version to 0.25.0 2026-09-28 09:53:05 +03:00
arvanitakis 492447be51 Chore: Extracting Wishlist from 3dealer to Core 2026-09-28 09:52:53 +03:00
42 changed files with 1200 additions and 74 deletions
+135
View File
@@ -4,6 +4,141 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.26.5] - 2026-09-28
### Added
- Three new fields on the "Store Details" settings page
(`Modules\Core\Store\Filament\Pages\ManageStoreDetails`):
- **Contact email** (`contact_email`) — used both as the receiver address for the
contact form and shown to customers as the store's contact email.
- **Mail from name** (`mail_from_name`) — the sender name on outgoing emails
(`MAIL_FROM_NAME`).
- **Legal name** (`legal_name`) — the registered company's legal name, distinct
from the storefront-facing `name`. Locale-keyed JSON, like `name`/`address`/
`bank_transfer_instructions`, since a registered name can legitimately differ
per locale.
`contact_email`/`mail_from_name` are plain nullable strings, matching
`phone`/`tax_identifier`. The migration backfills all four translatable
`store_details` columns with an empty per-locale array on any row where they're
still `NULL`, and `StoreDetailsService::firstOrCreate()` now seeds `legal_name`
the same way it already seeds `name`/`address`/`bank_transfer_instructions` —
both needed so `TranslatedText` doesn't silently drop keystrokes on a
NULL-starting translatable field (see that service's own docblock).
## [0.26.4] - 2026-09-28
### Fixed
- Bank transfer orders were never marked placed. `BankTransferPaymentDriver::pay()` had
dead code after an early `return` and never dispatched `PaymentDeferred`, so nothing
ever set `Order::placed_at` or fired `OrderPlaced` — no order confirmation email, no
stock decrement, the order missing from the customer's order history, and checkout
erroring out instead of showing the confirmation page. `pay()` now dispatches
`PaymentDeferred` the same way `CashOnDeliveryPaymentDriver::pay()` does.
- `MarkOrderPlacedOnDeferredPayment` now skips
`OrderPaymentResolutionService::resolveDeferredPayment()` for a bank transfer order
(`OrderStatusFlow::isBankTransfer()`), so it correctly stays at `awaiting_payment` /
unpaid until staff mark it paid, instead of being advanced past that status the moment
checkout completes.
## [0.26.3] - 2026-09-28
### Added
- `Modules\Core\Localization\Database\Seeders\ValidationTranslationsSeeder` — moved in from
3dealer's own `database/seeders/`, alongside the existing
`Modules\Core\Checkout\Database\Seeders\CheckoutTranslationsSeeder`, so the `validation` and
`checkout` translation groups are seeded from core rather than duplicated per consuming app.
Same idempotent per-key upsert as `lunar:install`'s own seeding — a key already present (or
since edited via the Filament Language Lines UI) is left untouched. Both are run explicitly
via `php artisan db:seed --class=...`, wired into 3dealer's entrypoint right after
`lunar:install`.
- `storefront.auth.captcha_failed` — was referenced by `HCaptcha`, `LoginController`, and
`ContactRequest` but never seeded in `StorefrontLabels`, so it silently fell back to the raw
key. Re-running `lunar:install` now fills it in for any existing store.
## [0.26.2] - 2026-09-28
### Changed
- Checkout line-item custom fields (`checkout/partials/line-custom-fields.blade.php`):
non-file fields now render inline as a single muted `Label: value` line instead of a
separate `<dt>`/`<dd>` pair. File fields keep the stacked label/link layout, with a
colon after the label and a small top margin on the value.
### Fixed
- Wishlist routes are now registered inside the `web` middleware group
(`WishlistServiceProvider`). Before, `loadRoutesFrom()` loaded them with no middleware,
so they had no session, cookies, CSRF protection or authenticated user. That broke
anything that depends on the current guest or logged-in user.
## [0.26.1] - 2026-09-28
### Added
- `Modules\Core\Order\Support\OrderReferenceDisplay` — a human-facing form of
`Order::reference` for emails and storefront views. `Lunar\Base\OrderReferenceGenerator`
zero-pads the order's own id out to a fixed length (8 characters by default), so a shop's
first real orders read as `#00000001` rather than `#1`. This strips leading zeros until the
first non-zero digit (falling back to `0` if none remain), purely for display — the raw,
padded `reference` is untouched everywhere else (DB lookups, the order-status API, staff
search, GDPR exports). Wired into the checkout confirmation page and all customer-facing
order notification emails.
## [0.26.0] - 2026-09-28
### Added
- `Modules\Core\Store\` — a "Store Details" Filament settings page (under Settings) for a
shop's own contact/legal details: store name, address (both translatable), phone, tax
identifier (ΑΦΜ), company registration number (ΓΕΜΗ), and rich-text bank transfer
instructions (IBANs, formatted as a table if needed — `TranslatedText::optionRichtext()`,
which ships table insert/edit in its default toolbar). Backed by a single-row
`StoreDetails` model, read via `StoreDetailsService::current()` (forever-cached,
invalidated by the new `StoreDetailsUpdated` event whenever `StoreDetailsService::update()`
is the one write path used — never write to the model directly).
### Fixed
- `StoreDetailsService`'s singleton row was created with every translatable column
(`name`/`address`/`bank_transfer_instructions`) left `NULL`. Lunar's own `TranslatedText`
Filament component silently discards every keystroke on re-render when the field it edits
starts out `NULL` rather than an empty per-locale array — invisible for `PaymentMethod`'s
own translatable `name` (always created already-filled, through the same form), but exactly
the gap this brand-new singleton hits, since it's created blank and opened for editing in
the same visit. The row is now seeded with an empty string per configured language from
creation, so every translatable field is editable from the very first save.
## [0.25.2] - 2026-09-28
### Fixed
- `BankTransferPaymentDriver::pay()` returned `Succeeded` and dispatched `PaymentCaptured`
immediately — treating a bank transfer like an instant-success gateway (Stripe), when in
reality no money has moved yet. Now returns `Pending` with no event dispatched, so the order
stays at `awaiting_payment` with `Order::paid` false, exactly like it should — checkout still
completes normally (`CheckoutController` already treats a `Pending` result with no
continuation as a placed order). `OrderStatusFlow::canMarkPaid()`/`isBankTransfer()` now also
recognize bank transfer, so staff can mark the order paid once the wire arrives, the same
"Mark Paid" action cash-on-delivery already uses — but unlike COD's version, this also
advances the order's status past `awaiting_payment`, since nothing else ever will.
## [0.25.1] - 2026-09-28
### Fixed
- `CustomerErasureActionsExtension` (Privacy) added "Request Erasure"/"Request Export" header
actions to the Customer edit/view pages but left Lunar's own plain `DeleteAction` in place
alongside them — bypassing the grace period, cascades, and audit trail an erasure request
provides. That header action is now stripped whenever Privacy is installed, so "Request
Erasure" is the only way to remove a Customer.
## [0.25.0] - 2026-09-28
### Added
- `Modules\Core\Wishlist\` — extracted the wishlist feature's business logic from 3dealer:
`WishlistService` (guest cookie / logged-in `wishlist_items` toggle, merge-on-login),
`WishlistItem` model, the `wishlist.toggle` route/controller, `MergeGuestWishlistOnLogin`
(listens on `Auth\Events\UserAuthenticated`, same pattern as `ClaimGuestOrdersOnLogin`), and
the `wishlist-controller.js` Stimulus controller (exported as `registerWishlist()` from this
package's JS entry point). Page rendering (the account/guest wishlist list views, and their
product-card presentation) stays app-specific, since it depends on each app's own UI
components. The `wishlist_items` migration checks `Schema::hasTable()` first, so a consumer
that already had its own copy of this table (e.g. 3dealer) isn't broken by this package now
also shipping it.
## [0.24.1] - 2026-09-28
### Fixed
+4 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.24.1",
"version": "0.26.5",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -47,7 +47,9 @@
"Modules\\Core\\Providers\\FileServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider",
"Modules\\Core\\Providers\\OrderServiceProvider",
"Modules\\Core\\Providers\\PrivacyServiceProvider"
"Modules\\Core\\Providers\\PrivacyServiceProvider",
"Modules\\Core\\Providers\\WishlistServiceProvider",
"Modules\\Core\\Providers\\StoreServiceProvider"
]
}
},
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* One product on a logged-in user's wishlist. A guest's wishlist lives in a
* cookie instead (see Modules\Core\Wishlist\Services\Wishlist) — nothing is
* written here until Modules\Core\Wishlist\Listeners\MergeGuestWishlistOnLogin
* moves the cookie's ids across on login.
*
* hasTable() guard: this table previously lived in each consuming app's own
* migrations (e.g. 3dealer's create_wishlist_items_table, extracted here) —
* Laravel's migrations table tracks by filename, so a consumer that already
* ran its own copy would otherwise hit "table already exists" the first time
* this migration runs. Skips creation entirely if the table is already
* there; a fresh install with no prior wishlist table gets it created here.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('wishlist_items')) {
return;
}
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');
}
};
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A single-row table for the store's own contact/legal details — edited via
* the Filament "Store Details" settings page (Modules\Core\Store\Filament\
* Pages\ManageStoreDetails) and read via Modules\Core\Store\Services\
* StoreDetailsService. Not config, since a shop owner needs to change these
* (e.g. a new IBAN, a new address) without a code deploy.
*
* name/address/bank_transfer_instructions are locale-keyed JSON — same
* shape/resolution as Modules\Core\Payment\Models\PaymentMethod::$name (see
* that model's own docblock): $storeDetails->translate('name'). tax_identifier
* (ΑΦΜ) and registration_number (ΓΕΜΗ) are legal identifiers, not
* locale-dependent text, so they stay plain strings — same for phone.
*
* No seeder inserting the singleton row — StoreDetailsService::current()
* lazily creates it (all-null) on first read, same shape as any other
* firstOrCreate()-backed singleton in this codebase.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('store_details', function (Blueprint $table) {
$table->id();
$table->json('name')->nullable();
$table->json('address')->nullable();
$table->string('phone')->nullable();
$table->string('tax_identifier')->nullable();
$table->string('registration_number')->nullable();
$table->json('bank_transfer_instructions')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('store_details');
}
};
@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Lunar\Models\Language;
/**
* contact_email/mail_from_name join tax_identifier/registration_number/
* phone as plain, non-locale-dependent strings on the store_details
* singleton (see that table's own migration docblock).
*
* legal_name is locale-keyed JSON instead — same shape/resolution as
* name/address/bank_transfer_instructions (HasTranslations, see
* StoreDetails's own docblock) — since a registered company name can
* legitimately differ per locale (e.g. a transliterated/translated legal
* form). Distinct from the storefront-facing brand name in `name`.
*
* Backfills every translatable column (name/address/
* bank_transfer_instructions/legal_name) with an empty per-locale array
* on any row that's still genuinely NULL there — StoreDetailsService::
* firstOrCreate() only seeds columns on INSERT, so an existing singleton
* row (or one created before a Language row existed, leaving
* emptyPerLocale() an empty array at the time) could otherwise keep a
* translatable column NULL forever. That's the exact condition
* TranslatedText breaks on (see StoreDetailsService's own docblock: a
* NULL-starting translatable field silently drops every keystroke and
* never persists) — backfilled here for all four columns, not just the
* new one, so the same fix covers however the existing row got there.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('store_details', function (Blueprint $table) {
$table->string('contact_email')->nullable()->after('phone');
$table->string('mail_from_name')->nullable()->after('contact_email');
$table->json('legal_name')->nullable()->after('registration_number');
});
$emptyPerLocale = json_encode(
Language::query()->pluck('code')->mapWithKeys(fn (string $code) => [$code => ''])->all()
);
foreach (['name', 'address', 'bank_transfer_instructions', 'legal_name'] as $column) {
DB::table('store_details')->whereNull($column)->update([$column => $emptyPerLocale]);
}
}
public function down(): void
{
Schema::table('store_details', function (Blueprint $table) {
$table->dropColumn(['contact_email', 'mail_from_name', 'legal_name']);
});
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@boboko/core",
"version": "0.24.1",
"version": "0.26.5",
"private": true,
"type": "module",
"description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.",
+2 -2
View File
@@ -223,13 +223,13 @@ a.bbk-cart-item-title:hover { text-decoration: underline; }
font-size: 0.8125rem;
}
.bbk-line-field span,
.bbk-line-field dt {
color: var(--bbk-color-muted);
}
.bbk-line-field dd {
margin: 0;
white-space: pre-line;
margin: 3px 0 0 0;
overflow-wrap: anywhere;
}
+1
View File
@@ -7,3 +7,4 @@
// stoic_embed.js is not re-exported here: per its own docblock, it's a
// standalone vendored script meant to be included directly, not imported.
export { registerCheckout } from './checkout/index.js'
export { registerWishlist } from './wishlist/index.js'
+10
View File
@@ -0,0 +1,10 @@
import WishlistController from './wishlist-controller'
// Registers the wishlist module's Stimulus controller onto the host app's
// Stimulus application. Call once from the host's JS entry point:
//
// import { registerWishlist } from '@boboko/core'
// registerWishlist(application)
export function registerWishlist(application) {
application.register('wishlist', WishlistController)
}
@@ -0,0 +1,42 @@
import { Controller } from '@hotwired/stimulus'
// Heart toggle. Posts the form with fetch and reflects the server's answer on
// aria-pressed, which the consuming app's own 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
}
}
}
@@ -14,7 +14,7 @@
<dl class="bbk-confirmation-meta">
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
<dd>{{ $order->reference }}</dd>
<dd>{{ \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order) }}</dd>
</div>
@if ($order->billingAddress?->contact_email)
@@ -19,9 +19,9 @@
<dl class="bbk-line-fields">
@foreach ($fields as $field)
<div class="bbk-line-field">
<dt>{{ $field['label'] }}</dt>
<dd>
@if ($field['type'] === 'file')
<dt>{{ $field['label'] }}:</dt>
<dd>
@php
$file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null);
@endphp
@@ -39,11 +39,12 @@
@endif
<span>{{ $file->original_name }}</span>
</a>
@endif
@else
{{ $field['value'] }}
@endif
</dd>
@else
<span>{{ $field['label'] }}: {{ $field['value'] }}</span>
@endif
</div>
@endforeach
</dl>
+4
View File
@@ -50,6 +50,7 @@ use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
use Modules\Core\Shipping\Filament\Resources\ManifestResource;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
use Modules\Core\Store\Filament\Pages\ManageStoreDetails;
class CorePlugin implements Plugin
{
@@ -74,6 +75,9 @@ class CorePlugin implements Plugin
ShipmentResource::class,
ManifestResource::class,
])
->pages([
ManageStoreDetails::class,
])
->plugin(ShippingPlugin::make());
LunarPanel::extensions([
@@ -0,0 +1,114 @@
<?php
namespace Modules\Core\Localization\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 a
* consuming app (checkout addresses, cart, product reviews, stock checks).
*
* Spatie's DB loader (spatie/laravel-translation-loader, wired in
* LocalizationServiceProvider) merges this group over Laravel's file-based
* validation.php — 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="Modules\Core\Localization\
* Database\Seeders\ValidationTranslationsSeeder"` — it is not wired into any
* app's own 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'],
];
}
}
@@ -108,6 +108,10 @@ class StorefrontLabels
'en' => 'Too many attempts. Please wait a few minutes and try again.',
'el' => 'Πολλές προσπάθειες. Περίμενε λίγα λεπτά και ξαναδοκίμασε.',
],
'auth.captcha_failed' => [
'en' => 'Please complete the captcha and try again.',
'el' => 'Παρακαλούμε ολοκλήρωσε το captcha και ξαναδοκίμασε.',
],
// Account profile page (account/show.blade.php)
'account.nav_profile' => ['en' => 'Profile', 'el' => 'Προφίλ'],
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Event;
use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Order\Services\OrderPaymentResolutionService;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Payment\Events\PaymentDeferred;
/**
@@ -36,11 +37,18 @@ use Modules\Core\Payment\Events\PaymentDeferred;
* visibility and stock decrement, but nothing ever moved `status` off its
* initial value, since resolveCaptureOrAuthorization() only does that for
* an actual capture. Caught and fixed after the fact.
*
* The status advance is skipped for a bank transfer order
* (OrderStatusFlow::isBankTransfer()) — unlike COD, it genuinely has
* something to await at 'awaiting_payment': the wire itself. Only
* OrderFulfillmentService::markPaid() ever advances it past that point
* (see BankTransferPaymentDriver's own docblock).
*/
class MarkOrderPlacedOnDeferredPayment
{
public function __construct(
private readonly OrderPaymentResolutionService $resolution,
private readonly OrderStatusFlow $flow,
) {}
public function handle(PaymentDeferred $event): void
@@ -53,7 +61,9 @@ class MarkOrderPlacedOnDeferredPayment
$order = Order::findOrFail($orderId);
if (! $this->flow->isBankTransfer($order)) {
$this->resolution->resolveDeferredPayment($order, self::class);
}
if (! blank($order->placed_at)) {
return;
@@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderCaptured;
use Modules\Core\Order\Support\OrderReferenceDisplay;
class OrderCapturedNotification extends BaseNotification
{
@@ -40,10 +41,12 @@ class OrderCapturedNotification extends BaseNotification
{
$order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
return (new MailMessage)
->subject(__('Payment captured for your order :reference', ['reference' => $order->reference]))
->subject(__('Payment captured for your order :reference', ['reference' => $reference]))
->view('core::order.notifications.captured', [
'reference' => $order->reference,
'reference' => $reference,
'amount' => $this->event->transaction->amount->formatted,
]);
}
@@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderCompleted;
use Modules\Core\Order\Support\OrderReferenceDisplay;
class OrderCompletedNotification extends BaseNotification
{
@@ -40,10 +41,12 @@ class OrderCompletedNotification extends BaseNotification
{
$order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
return (new MailMessage)
->subject(__('Your order :reference is complete', ['reference' => $order->reference]))
->subject(__('Your order :reference is complete', ['reference' => $reference]))
->view('core::order.notifications.completed', [
'reference' => $order->reference,
'reference' => $reference,
]);
}
}
@@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Order\Support\OrderReferenceDisplay;
class OrderDeliveredNotification extends BaseNotification
{
@@ -40,10 +41,12 @@ class OrderDeliveredNotification extends BaseNotification
{
$order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
return (new MailMessage)
->subject(__('Your order :reference has been delivered', ['reference' => $order->reference]))
->subject(__('Your order :reference has been delivered', ['reference' => $reference]))
->view('core::order.notifications.delivered', [
'reference' => $order->reference,
'reference' => $reference,
]);
}
}
@@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderDispatched;
use Modules\Core\Order\Support\OrderReferenceDisplay;
/**
* Fills a real, previously-unfilled customer-communication gap — before
@@ -45,10 +46,12 @@ class OrderDispatchedNotification extends BaseNotification
{
$order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
return (new MailMessage)
->subject(__('Your order :reference is on its way', ['reference' => $order->reference]))
->subject(__('Your order :reference is on its way', ['reference' => $reference]))
->view('core::order.notifications.dispatched', [
'reference' => $order->reference,
'reference' => $reference,
]);
}
}
@@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderReadyForPickup;
use Modules\Core\Order\Support\OrderReferenceDisplay;
/**
* "Your order is ready to collect" — listens to the specific
@@ -50,10 +51,12 @@ class OrderPickupReadyNotification extends BaseNotification
{
$order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
return (new MailMessage)
->subject(__('Your order :reference is ready for pickup', ['reference' => $order->reference]))
->subject(__('Your order :reference is ready for pickup', ['reference' => $reference]))
->view('core::order.notifications.pickup-ready', [
'reference' => $order->reference,
'reference' => $reference,
]);
}
}
@@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Support\OrderReferenceDisplay;
/**
* The order confirmation email — fires once, for every capture_mode and
@@ -51,10 +52,12 @@ class OrderPlacedNotification extends BaseNotification
{
$order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
return (new MailMessage)
->subject(__('Your order :reference is confirmed', ['reference' => $order->reference]))
->subject(__('Your order :reference is confirmed', ['reference' => $reference]))
->view('core::order.notifications.placed', [
'reference' => $order->reference,
'reference' => $reference,
'total' => $order->total->formatted,
'lines' => $order->lines,
]);
@@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderRefunded;
use Modules\Core\Order\Support\OrderReferenceDisplay;
class OrderRefundedNotification extends BaseNotification
{
@@ -40,10 +41,12 @@ class OrderRefundedNotification extends BaseNotification
{
$order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
return (new MailMessage)
->subject(__('A refund has been issued for your order :reference', ['reference' => $order->reference]))
->subject(__('A refund has been issued for your order :reference', ['reference' => $reference]))
->view('core::order.notifications.refunded', [
'reference' => $order->reference,
'reference' => $reference,
'amount' => $this->event->transaction->amount->formatted,
]);
}
@@ -7,6 +7,7 @@ use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderStatusUpdated;
use Modules\Core\Order\Support\OrderReferenceDisplay;
class OrderStatusUpdatedNotification extends BaseNotification
{
@@ -52,10 +53,12 @@ class OrderStatusUpdatedNotification extends BaseNotification
{
$order = $this->event->order;
$reference = OrderReferenceDisplay::resolve($order);
return (new MailMessage)
->subject(__('Your order :reference has been updated', ['reference' => $order->reference]))
->subject(__('Your order :reference has been updated', ['reference' => $reference]))
->view('core::order.notifications.status-updated', [
'reference' => $order->reference,
'reference' => $reference,
'statusLabel' => config("lunar.orders.statuses.{$order->status}.label", $order->status),
]);
}
+30 -17
View File
@@ -34,6 +34,7 @@ class OrderFulfillmentService
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
private readonly TransactionRecorder $transactions,
private readonly OrderPaymentResolutionService $resolution,
) {}
public function markReady(Order $order): OrderFulfillmentResult
@@ -114,9 +115,13 @@ class OrderFulfillmentService
}
/**
* Independent of `status` entirely — offered by the single "Update
* Status" action regardless of current status (see
* OrderStatusFlow::canMarkPaid()).
* For a COD order, independent of `status` entirely — offered by the
* single "Update Status" action regardless of current status (see
* OrderStatusFlow::canMarkPaid()). For a bank transfer order, status
* genuinely does advance here too (see below) — unlike COD, a bank
* transfer order has been sitting at 'awaiting_payment' since checkout
* (BankTransferPaymentDriver::pay() deliberately never advances it),
* and this click is the only thing that ever will.
*/
public function markPaid(Order $order): OrderFulfillmentResult
{
@@ -125,18 +130,20 @@ class OrderFulfillmentService
}
// canMarkPaid() only ever returns true for an order whose payment
// method resolves to the cash-on-delivery DRIVER (see
// OrderStatusFlow::isCod(), which checks PaymentMethod::driver,
// never the merchant-chosen `type` slug directly — a store could
// name that method "cod", "pay-on-delivery", anything). Such an
// order never runs through Payment's pay()/authorize() flow at
// checkout, so nothing else records a Transaction for it. Money
// changes hands right here, at this click, so this is the one
// place that write can happen; there is no earlier Payment event
// to hang it off of the way Modules\Core\Order\Listeners\
// RecordPaymentTransaction does for a gateway driver. See
// TransactionRecorder's own docblock — it already anticipated
// exactly this "manually-triggered ... from Filament" call site.
// method resolves to the cash-on-delivery or bank-transfer DRIVER
// (see OrderStatusFlow::isCod()/isBankTransfer(), which check
// PaymentMethod::driver, never the merchant-chosen `type` slug
// directly — a store could name that method "cod", "pay-on-delivery",
// "wire", anything). Neither ever runs a Transaction-recording event
// through to completion at checkout (COD dispatches nothing capture-
// shaped at all; bank transfer's pay() returns Pending with no event
// dispatched — see that driver's own docblock). Money changes hands
// right here, at this click, so this is the one place that write can
// happen; there is no earlier Payment event to hang it off of the way
// Modules\Core\Order\Listeners\RecordPaymentTransaction does for a
// gateway driver. See TransactionRecorder's own docblock — it already
// anticipated exactly this "manually-triggered ... from Filament"
// call site.
//
// $driver below is the payment method's own `type` slug (whatever
// the merchant named it, e.g. 'cash-on-delivery' or 'cod') —
@@ -146,19 +153,25 @@ class OrderFulfillmentService
// No fallback guess here: CheckoutService::initiatePayment() always
// writes Order.meta['payment_method'] before charging, and
// canMarkPaid() already guarantees this order got that far.
$type = (string) $order->meta['payment_method'];
$this->transactions->record(
$order,
type: 'capture',
driver: (string) $order->meta['payment_method'],
driver: $type,
result: new PaymentResult(
status: PaymentResultStatus::Succeeded,
reference: 'cod-manual-'.$order->id,
reference: "manual-{$type}-{$order->id}",
amount: $order->total,
),
);
$this->writer->markPaid($order, self::class.'::markPaid');
if ($this->flow->isBankTransfer($order)) {
$this->resolution->advancePastAwaitingPayment($order, self::class.'::markPaid');
}
return OrderFulfillmentResult::success('Order marked as paid.');
}
@@ -92,7 +92,14 @@ class OrderPaymentResolutionService
}
}
private function advancePastAwaitingPayment(Order $order, string $causeClass): void
/**
* Also called directly by OrderFulfillmentService::markPaid() for a
* bank transfer order — unlike a COD markPaid() (which never touches
* status, since nothing was ever awaited), a bank transfer order
* genuinely sat at 'awaiting_payment' until this moment, and nothing
* else will ever advance it if this doesn't.
*/
public function advancePastAwaitingPayment(Order $order, string $causeClass): void
{
if ($order->status !== 'awaiting_payment') {
return;
+25 -4
View File
@@ -54,6 +54,24 @@ class OrderStatusFlow
return PaymentMethod::where('type', $type)->value('driver') === 'cash-on-delivery';
}
/**
* Same meta-first/Transaction-fallback resolution as isCod(). Unlike COD
* — where nothing is ever awaited, since payment happens on delivery —
* a bank transfer order genuinely sits at 'awaiting_payment' until staff
* confirm the wire arrived (see BankTransferPaymentDriver's own
* docblock and OrderFulfillmentService::markPaid()).
*/
public function isBankTransfer(Order $order): bool
{
$type = $order->meta['payment_method'] ?? $order->transactions()->latest('id')->value('driver');
if ($type === null) {
return false;
}
return PaymentMethod::where('type', $type)->value('driver') === 'bank-transfer';
}
/**
* @return array<string, string> value => label — every status in the
* order's own branch (carrier or pickup), plus the refund options,
@@ -124,13 +142,16 @@ class OrderStatusFlow
/**
* Whether the "mark paid" option should be offered right now —
* entirely independent of $order->status. True whenever this is a
* cash-on-delivery order and payment hasn't been recorded yet,
* regardless of fulfillment progress (before OR after completed).
* entirely independent of $order->status for a COD order (true whenever
* payment hasn't been recorded yet, regardless of fulfillment progress,
* before OR after completed). A bank transfer order is also eligible,
* for the same "no earlier Payment event recorded this" reason (see
* OrderFulfillmentService::markPaid()), but unlike COD its own status
* genuinely does need advancing once marked paid — see that method.
*/
public function canMarkPaid(Order $order): bool
{
return ! $order->paid && $this->isCod($order);
return ! $order->paid && ($this->isCod($order) || $this->isBankTransfer($order));
}
/**
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Order\Support;
use Lunar\Models\Order;
/**
* A shorter, human-facing form of Order::reference for emails/views —
* Lunar\Base\OrderReferenceGenerator pads the order's own id out to a fixed
* length (config('lunar.orders.reference_format'), 8 characters/'0'-padded
* by default), so a shop's first real orders read as "#00000001" rather
* than "#1". Strips leading zeros until the first non-zero digit; if
* nothing but zeros remain (or the reference is empty), shows "0".
*
* $order->reference itself is untouched anywhere else (DB lookups, the
* order-status API, staff search) — this is purely a display helper.
*/
class OrderReferenceDisplay
{
public static function resolve(Order $order): string
{
$reference = ltrim((string) $order->reference, '0');
return $reference !== '' ? $reference : '0';
}
}
@@ -9,30 +9,52 @@ use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\Contracts\SupportsRefunds;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentDeferred;
use Modules\Core\Payment\Events\PaymentRefunded;
/**
* Manual/attested, same trust model as OfflinePaymentDriver — there is no
* bank API to call, so both pay() and refund() decide success immediately
* on a staff member's say-so (they've already sent/received the wire
* outside the system). Distinct from OfflinePaymentDriver in intent: this
* exists so a payment taken through a DIFFERENT method (e.g.
* cash-on-delivery) can still be REFUNDED via bank transfer — an admin
* chooses this driver explicitly in the refund action, independent of
* which driver the original payment went through (see
* refund() is manual/attested, same trust model as OfflinePaymentDriver —
* there is no bank API to call, so it decides success immediately on a
* staff member's say-so (they've already sent the wire outside the
* system). Distinct from OfflinePaymentDriver in intent: this exists so a
* payment taken through a DIFFERENT method (e.g. cash-on-delivery) can
* still be REFUNDED via bank transfer — an admin chooses this driver
* explicitly in the refund action, independent of which driver the
* original payment went through (see
* Payment\Support\TransactionDriverAdapter::refundVia() and
* Order\Filament\Extensions\OrderActionsExtension). pay() exists so
* the same driver also covers receiving a payment by bank transfer, but
* the admin UI for that (bank reference, notes, proof-of-transfer upload)
* is deliberately not built yet — see the follow-up work tracked from this
* session; pay() itself is complete and usable via the registry today.
* Order\Filament\Extensions\OrderActionsExtension).
*
* pay() is the opposite trust direction from refund(): a bank transfer
* payment requires the money to arrive BEFORE the order can be
* considered paid (unlike cash-on-delivery, where payment happens on
* delivery — see CashOnDeliveryPaymentDriver's own docblock for that
* driver's mirror-image reasoning). So pay() returns Pending and DOES
* dispatch PaymentDeferred, same as COD — without it, nothing ever sets
* Order::placed_at or fires OrderPlaced, leaving the order invisible in
* customer order history, un-decremented in stock, and the checkout
* confirmation page unable to find it (see PaymentDeferred's and
* CashOnDeliveryPaymentDriver's own docblocks for that failure mode).
* Unlike COD, though, a bank transfer order genuinely DOES have something
* to await: MarkOrderPlacedOnDeferredPayment skips
* OrderPaymentResolutionService::resolveDeferredPayment() for a bank
* transfer order (via OrderStatusFlow::isBankTransfer()), so it stays at
* 'awaiting_payment' with Order::paid false until staff confirm the wire
* arrived via OrderFulfillmentService::markPaid(), which — unlike its COD
* path — also advances the order's status, since nothing else ever will
* (see that method's own docblock). CheckoutController::placeOrder()
* already treats a Pending result with no continuation as a fully placed
* order (see its own docblock), so the order is still created and visible
* to the shopper immediately; only its payment/status is what's left
* outstanding.
*
* $reference is generated here for the same reason as OfflinePaymentDriver's
* pay(): there is no gateway to hand one back. 'notes' in $context (not
* $data — refund() has no $data parameter) is folded into
* PaymentResult::$meta, which Order\Services\TransactionRecorder::record()
* already writes straight into Transaction.meta with no extra plumbing.
* pay(): there is no gateway to hand one back. refund()'s 'notes' (in
* $context — it has no $data parameter) is folded into PaymentResult::$meta,
* which Order\Services\TransactionRecorder::record() already writes straight
* into Transaction.meta with no extra plumbing; pay() has no equivalent
* write, since nothing ever records a Transaction from its own result (see
* above) — any notes a shopper enters at checkout would need surfacing some
* other way, e.g. when staff mark the order paid.
*/
class BankTransferPaymentDriver implements Configurable, SupportsPay, SupportsRefunds
{
@@ -47,13 +69,12 @@ class BankTransferPaymentDriver implements Configurable, SupportsPay, SupportsRe
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{
$result = new PaymentResult(
status: PaymentResultStatus::Succeeded,
status: PaymentResultStatus::Pending,
reference: 'bank-transfer-'.Str::uuid(),
amount: $amount,
meta: array_filter(['notes' => $data['notes'] ?? null]),
);
PaymentCaptured::dispatch($type, $result, $context);
PaymentDeferred::dispatch($type, $result, $context);
return $result;
}
@@ -3,6 +3,7 @@
namespace Modules\Core\Privacy\Filament\Extensions;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\Checkbox;
use Filament\Notifications\Notification;
use Lunar\Admin\Support\Extending\BaseExtension;
@@ -20,13 +21,18 @@ use Modules\Core\Privacy\Services\PrivacyService;
* docs/modules.md "Layering Module and App Configuration"), and this extension
* deliberately only implements headerActions(), so it never conflicts with an
* app's own extension for the same resource.
*
* Also strips Lunar's own plain DeleteAction from these pages — with Privacy
* installed, "Request Erasure" (grace period, cascades, audit trail via
* DataErasureRequest) is the only sanctioned way to remove a Customer; a
* direct delete would bypass all of that.
*/
class CustomerErasureActionsExtension extends BaseExtension
{
public function headerActions(array $actions): array
{
return [
...$actions,
...array_filter($actions, fn ($action) => ! $action instanceof DeleteAction),
Action::make('requestErasure')
->label('Request Erasure')
->icon('heroicon-o-shield-exclamation')
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Store\Events\StoreDetailsUpdated;
use Modules\Core\Store\Listeners\FlushStoreDetailsCache;
class StoreServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(StoreDetailsUpdated::class, FlushStoreDetailsCache::class);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Wishlist\Listeners\MergeGuestWishlistOnLogin;
use Modules\Core\Wishlist\Services\WishlistService;
class WishlistServiceProvider extends ServiceProvider
{
public function register(): void
{
// One instance per request: it caches the guest cookie's ids, so a
// toggle and a later has() in the same request agree.
$this->app->scoped(WishlistService::class);
}
public function boot(): void
{
Route::middleware('web')->group(__DIR__.'/../Wishlist/routes/web.php');
Event::listen(UserAuthenticated::class, MergeGuestWishlistOnLogin::class);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Store\Events;
use Modules\Core\Store\Models\StoreDetails;
/**
* Dispatched by StoreDetailsService — the only place StoreDetails is ever
* created/updated, mirroring Modules\Core\Localization\Services\
* TranslationService's own create()/update() shape. Modules\Core\Store\
* Listeners\FlushStoreDetailsCache reacts to this to invalidate
* StoreDetailsService::current()'s forever-cache.
*/
class StoreDetailsUpdated
{
public function __construct(
public readonly StoreDetails $storeDetails,
) {}
}
@@ -0,0 +1,137 @@
<?php
namespace Modules\Core\Store\Filament\Pages;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\Form;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Modules\Core\Store\Services\StoreDetailsService;
/**
* Singleton settings page — no resource, no record list, always edits the
* one StoreDetails row (see that model's own docblock). Filament ships no
* built-in "settings page" type; this follows the same shape Filament's own
* password-reset-request page uses (see Filament\Auth\Pages\PasswordReset\
* RequestPasswordReset): a content(Schema) composed of a Form(EmbeddedSchema)
* with the save action(s) in its own footer(), rather than a hand-written
* Blade view — Filament v4 has no `x-filament-panels::form.actions` Blade
* component to fall back on for a plain Page.
*/
class ManageStoreDetails extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationLabel = 'Store Details';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-storefront';
protected static string|\UnitEnum|null $navigationGroup = 'Settings';
public ?array $data = [];
public function mount(): void
{
$this->form->fill(
app(StoreDetailsService::class)->current()->attributesToArray()
);
}
public function content(Schema $schema): Schema
{
return $schema->components([
Form::make([EmbeddedSchema::make('form')])
->id('form')
->livewireSubmitHandler('save')
->footer([
Actions::make($this->getFormActions())
->key('form-actions'),
]),
]);
}
public function form(Schema $schema): Schema
{
return $schema
->statePath('data')
->components([
Section::make('Store')
->schema([
// Deliberately not ->required(): TranslatedText's own
// state is the whole locale-keyed array, and its
// required-rule generation validates that array
// itself rather than deferring to its per-locale
// children — it fires "required" even when every
// locale sub-field is genuinely filled in. The
// column is nullable and nothing reads it yet, so
// there's no real need to enforce this here.
TranslatedText::make('name')
->label('Store name'),
TranslatedText::make('address')
->label('Address'),
TextInput::make('phone')
->label('Phone')
->tel(),
TextInput::make('contact_email')
->label('Contact email')
->email()
->helperText('Receives the contact form, and is shown to customers as the store\'s contact email.'),
TextInput::make('mail_from_name')
->label('Mail from name')
->helperText('The sender name on outgoing emails (MAIL_FROM_NAME).'),
]),
Section::make('Legal')
->description('Shown on invoices and terms pages.')
->schema([
TranslatedText::make('legal_name')
->label('Legal name'),
TextInput::make('tax_identifier')
->label('Tax ID (ΑΦΜ)'),
TextInput::make('registration_number')
->label('Company registration number (ΓΕΜΗ)'),
]),
Section::make('Bank transfer')
->description('Shown to a shopper on the order confirmation page when they chose to pay by bank transfer.')
->schema([
// Rich, not plain Textarea — a shop owner may want a
// formatted table (bank name / IBAN / BIC columns) or
// bold text, not just line breaks. RichEditor's
// 'table' toolbar button ships in its default toolbar
// (RichEditor::getDefaultToolbarButtons()), so this
// needs no extra config to get table insert/edit.
TranslatedText::make('bank_transfer_instructions')
->label('Instructions')
->optionRichtext(true),
]),
]);
}
protected function getFormActions(): array
{
return [
Action::make('save')
->label('Save')
->submit('save'),
];
}
public function save(): void
{
$state = $this->form->getState();
app(StoreDetailsService::class)->update($state);
Notification::make()
->title('Store details saved')
->success()
->send();
}
}
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Store\Listeners;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Store\Events\StoreDetailsUpdated;
use Modules\Core\Store\Services\StoreDetailsService;
/**
* Same shape as Modules\Core\Localization\Listeners\FlushTranslationCache —
* StoreDetailsService::current() caches forever (this is read on every
* storefront request that shows store details, e.g. the checkout
* confirmation page's bank transfer instructions), so the only way it ever
* becomes stale is a write through this same service. Not queued: unlike
* FlushTranslationCache (which only affects a LATER storefront request),
* StoreDetailsUpdated fires from the staff member's own save action, and
* StoreDetailsService::current() may be called again within that same
* request/response cycle.
*/
class FlushStoreDetailsCache
{
public function handle(StoreDetailsUpdated $event): void
{
Cache::forget(StoreDetailsService::CACHE_KEY);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Store\Models;
use Illuminate\Database\Eloquent\Model;
use Lunar\Base\Traits\HasTranslations;
/**
* Singleton — always exactly one row, fetched/created via
* Modules\Core\Store\Services\StoreDetailsService::current(). See that
* table's own migration docblock for why name/address/
* bank_transfer_instructions are locale-keyed JSON and the rest are plain.
*/
class StoreDetails extends Model
{
use HasTranslations;
protected $guarded = [];
protected $casts = [
'name' => 'array',
'address' => 'array',
'bank_transfer_instructions' => 'array',
'legal_name' => 'array',
];
}
@@ -0,0 +1,78 @@
<?php
namespace Modules\Core\Store\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Language;
use Modules\Core\Store\Events\StoreDetailsUpdated;
use Modules\Core\Store\Models\StoreDetails;
/**
* The only entrypoint that creates/updates the StoreDetails singleton — same
* shape as Modules\Core\Localization\Services\TranslationService: every
* write goes through here so it can dispatch StoreDetailsUpdated, which
* Modules\Core\Store\Listeners\FlushStoreDetailsCache reacts to. Never call
* StoreDetails::query()->update(...) or $storeDetails->save() directly — a
* write bypassing this service leaves current()'s forever-cache stale.
*/
class StoreDetailsService
{
public const CACHE_KEY = 'store-details';
/**
* Forever-cached — read on every storefront request that shows store
* details (e.g. the checkout confirmation page's bank transfer
* instructions), so this should never re-query the database on a normal
* request. Only ever invalidated by update() below, via
* FlushStoreDetailsCache reacting to StoreDetailsUpdated.
*/
public function current(): StoreDetails
{
return Cache::rememberForever(
self::CACHE_KEY,
fn () => $this->firstOrCreate(),
);
}
public function update(array $attributes): StoreDetails
{
$storeDetails = $this->firstOrCreate();
$storeDetails->update($attributes);
Event::dispatch(new StoreDetailsUpdated($storeDetails));
return $storeDetails;
}
/**
* A freshly-created row must never leave a translatable column
* genuinely NULL — Lunar's own TranslatedText component (Modules\Core\
* Store\Filament\Pages\ManageStoreDetails's `name`/`address`/
* `bank_transfer_instructions` fields) silently drops every keystroke
* on re-render when the field it's editing starts out NULL rather than
* an empty per-locale array. Real-world precedent (PaymentMethod's own
* translatable `name` column) never hits this, because every
* PaymentMethod row is created THROUGH the same Filament form that
* immediately fills `name` — this singleton is instead created blank
* and opened for editing in the same visit, which is exactly the gap
* that surfaces the bug. Caught and fixed after the fact, verified via
* tinker: seeding a real (non-null) array made typing into the field
* persist correctly, confirming NULL was the trigger.
*/
private function firstOrCreate(): StoreDetails
{
return StoreDetails::query()->firstOrCreate([], [
'name' => $this->emptyPerLocale(),
'address' => $this->emptyPerLocale(),
'bank_transfer_instructions' => $this->emptyPerLocale(),
'legal_name' => $this->emptyPerLocale(),
]);
}
private function emptyPerLocale(): array
{
return Language::query()->pluck('code')->mapWithKeys(fn (string $code) => [$code => ''])->all();
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Core\Wishlist\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Lunar\Models\Product;
use Modules\Core\Wishlist\Services\WishlistService;
/**
* Adds or removes a product on the current shopper's wishlist — guests and
* logged-in shoppers alike (see WishlistService). Page rendering (the
* account/guest wishlist list views, with their product-card presentation)
* is app-specific and stays in the consuming app; this is only the toggle
* action a heart button or plain form posts to.
*/
class WishlistController extends Controller
{
public function __construct(
private readonly WishlistService $wishlist,
) {}
/**
* 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(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();
}
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Wishlist\Listeners;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Wishlist\Services\WishlistService;
/**
* Registered from Providers\WishlistServiceProvider — UserAuthenticated fires
* inside the login request, so this can read the guest wishlist cookie and
* queue its removal.
*/
class MergeGuestWishlistOnLogin
{
public function __construct(
private readonly WishlistService $wishlist,
) {}
public function handle(UserAuthenticated $event): void
{
$this->wishlist->mergeGuestInto($event->user);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\Wishlist\Models;
use Illuminate\Database\Eloquent\Model;
/**
* One product on a logged-in user's wishlist. Guests' wishlists live in a
* cookie instead — see Modules\Core\Wishlist\Services\Wishlist.
*/
class WishlistItem extends Model
{
protected $fillable = ['user_id', 'product_id'];
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace Modules\Core\Wishlist\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
use Modules\Core\Wishlist\Models\WishlistItem;
/**
* 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 WishlistService
{
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);
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Core\Wishlist\Http\Controllers\WishlistController;
Route::post('wishlist/{productId}', [WishlistController::class, 'toggle'])
->whereNumber('productId')
->middleware('throttle:60,1')
->name('wishlist.toggle');