Compare commits

...
8 Commits
23 changed files with 821 additions and 2 deletions
+53
View File
@@ -4,6 +4,59 @@ 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.22.0] - 2026-09-25
### Added
- `Modules\Core\Customer\Services\CustomerEmailChangeService` — changing an account's login
email (core's login is passwordless, so the email IS the login): `request()` validates the new
address is free and throttled (3 codes/10min), `confirm()` allows 5 wrong guesses per code,
re-checks the address is still free, switches it, notifies the old address (masked new
address), and claims guest orders for the new email. The pending change lives on the user's
own row (`pending_email`/`pending_email_code_hash`/`pending_email_expires_at`/
`pending_email_attempts` — new migration), the same convention as the existing OTP login
columns, rather than the session — a code arrives by email and is often opened on a different
device/session than the one that requested it. New core-owned mailables
(`Auth\Mail\EmailChangeCodeMail`/`EmailChangedNoticeMail`) with default views, overridable
per-app the same way `UserOtpMail`'s already is. Dispatches a new `Auth\Events\
UserEmailChanged` event.
- `Modules\Core\Customer\Services\CustomerAccountService::setRecoveryConsent()` — the account's
standing "email me a reminder if I don't finish my order" opt-in, written to the customer's
meta in the same shape `Checkout\Services\CheckoutService::setRecoveryConsent()` already writes
on the cart. Skips the write when nothing changed; dispatches a new `Customer\Events\
CustomerRecoveryConsentSet` event (also wired into the existing account-activity audit log).
3dealer's own duplicated implementations in `CheckoutController`/`AccountController` now call
this instead.
- `terms_accepted_at`/`terms_version`/`privacy_policy_version` columns on `users` — recorded once,
by a new `Auth\Listeners\RecordLegalAcceptanceForNewUser` (listening on `UserCreated`), the
moment a genuinely new signup requests their first OTP code; never touched again for an
existing user. Included in the User-scope privacy export (`CustomerDataProvider::
exportForUser()`).
- ~90 previously-unseeded `storefront.*` translation keys (login/OTP copy, account profile and
email-change flow, order history, contact form, product custom-fields and stock-error
messages, reviews, wishlist) added to `Localization\Services\StorefrontLabels` — these were
already called via `__()`/`trans_choice()` across a consuming app's views with no seeded
value at all, silently rendering the raw translation key in production.
### Fixed
- `CustomerAccountService::WRITABLE_PROFILE_FIELDS` listed `vat_no`, but Lunar's `customers`
column has been `tax_identifier` since a 2025 Lunar migration — passing `vat_no` was silently
dropped by the allowlist, and `tax_identifier` couldn't be written through `updateProfile()` at
all. Consuming code was working around this with a separate direct `$customer->update(...)`
call that bypassed `CustomerProfileUpdated`'s audit trail entirely; that workaround is no
longer needed now that the field is correctly allowlisted.
## [0.21.1] - 2026-09-25
### Changed
- `GuestOrderClaimer` and its `UserAuthenticated` listener moved from 3dealer's own
`App\Services`/`App\Listeners` into `Modules\Core\Customer\Services\GuestOrderClaimer` /
`Listeners\ClaimGuestOrdersOnLogin`, registered in `CustomerServiceProvider` — attaching a
placed guest order to an account once its billing `contact_email` case-insensitively matches
the account's email (only ever safe right after the shopper has proved they own that email: a
login code, or 3dealer's own email-change confirmation) was already core-appropriate logic
with no 3dealer-specific behavior. `Account\EmailController::verify()` now calls the core
service directly.
## [0.21.0] - 2026-09-25
### Added
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.21.0",
"version": "0.22.0",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
+11
View File
@@ -125,6 +125,17 @@ return [
'generation_limit' => 3,
'generation_decay_minutes' => 10,
],
// Modules\Core\Customer\Services\CustomerEmailChangeService — same
// shape/reasoning as auth.otp above, independent limits since this
// is a separate flow (changing an existing account's login email,
// not logging in).
'email_change' => [
'max_attempts' => 5,
'generation_limit' => 3,
'generation_decay_minutes' => 10,
'expiry_minutes' => 10,
],
],
];
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Which terms/privacy policy version an account was created under — the
* storefront login page shows a notice ("By continuing, you accept the
* Terms of Use and have read the Privacy Policy") that a new signup
* implicitly agrees to just by requesting an OTP code, so this is
* recorded the moment Modules\Core\Auth\Services\UserOtpService::
* generateAndSend()'s firstOrCreate() actually creates the row — never
* for an existing user, whose original acceptance (whatever version was
* live at the time) must not be silently overwritten by a later config
* value. Nullable: every user created before this migration has none of
* the three, which is the honest answer ("we don't know what they saw"),
* not something to backfill with today's config values.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('terms_accepted_at')->nullable()->after('otp_attempts');
$table->string('terms_version')->nullable()->after('terms_accepted_at');
$table->string('privacy_policy_version')->nullable()->after('terms_version');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['terms_accepted_at', 'terms_version', 'privacy_policy_version']);
});
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Backs Modules\Core\Customer\Services\CustomerEmailChangeService — the
* pending new-email change lives on the user's own row, same convention
* as the existing otp_code/otp_expires_at/otp_attempts columns (Auth\
* Services\UserOtpService), rather than the session: a change requested
* on one device/session must still be confirmable from another (a code
* arrives by email, which is often opened somewhere else entirely), and
* a request-scoped session can't survive that.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('pending_email')->nullable()->after('privacy_policy_version');
$table->string('pending_email_code_hash')->nullable()->after('pending_email');
$table->timestamp('pending_email_expires_at')->nullable()->after('pending_email_code_hash');
$table->unsignedTinyInteger('pending_email_attempts')->default(0)->after('pending_email_expires_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'pending_email',
'pending_email_code_hash',
'pending_email_expires_at',
'pending_email_attempts',
]);
});
}
};
@@ -0,0 +1,17 @@
@extends('emails.layout')
@section('content')
<p style="margin: 0 0 24px 0;">Use the code below to confirm this address as your account's new email.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 24px 0; background-color: #f7f6f5; border-radius: 8px;">
<tr>
<td style="padding: 16px 20px; text-align: center; font-size: 28px; font-weight: bold; letter-spacing: 0.25rem;">
{{ $code }}
</td>
</tr>
</table>
<p style="margin: 0 0 16px 0;">This code expires in 10 minutes.</p>
<p style="margin: 0;">If you didn't request this change, you can ignore this email — nothing will change.</p>
@endsection
@@ -0,0 +1,9 @@
@extends('emails.layout')
@section('content')
<p style="margin: 0 0 16px 0;">Your account's login email was changed to <strong>{{ $maskedEmail }}</strong>.</p>
<p style="margin: 0 0 24px 0;">From now on, login codes will be sent to the new address.</p>
<p style="margin: 0;">If you didn't make this change, please contact us right away.</p>
@endsection
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Auth\Events;
use Illuminate\Contracts\Auth\Authenticatable;
/**
* Dispatched by Customer\Services\CustomerEmailChangeService::confirm()
* once a login-email change actually takes effect — $oldEmail is what the
* account's login used to be, already overwritten on $user by the time
* this fires.
*/
class UserEmailChanged
{
public function __construct(
public readonly Authenticatable $user,
public readonly string $oldEmail,
) {}
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\Core\Auth\Listeners;
use Modules\Core\Auth\Events\UserCreated;
/**
* The storefront login page shows a terms/privacy notice ("By continuing,
* you accept the Terms of Use and have read the Privacy Policy") that
* requesting an OTP code implicitly accepts — recorded once, right here,
* for a genuinely new signup only (UserCreated fires exactly once per
* user, from Auth\Services\UserOtpService::generateAndSend()'s own
* wasRecentlyCreated check). An existing user's original acceptance
* (whatever version was live when THEY signed up) must never be
* overwritten by whatever config('legal.*') says today, which is exactly
* why this only ever runs from UserCreated and nowhere else.
*/
class RecordLegalAcceptanceForNewUser
{
public function handle(UserCreated $event): void
{
$event->user->forceFill([
'terms_accepted_at' => now(),
'terms_version' => config('legal.terms_version'),
'privacy_policy_version' => config('legal.privacy_policy_version'),
])->save();
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\Auth\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Sent to the NEW address a shopper is trying to switch their login email
* to (Customer\Services\CustomerEmailChangeService::request()) — proves
* they can actually receive mail there before the switch takes effect.
* View overridable per-app the same way UserOtpMail's is (resources/
* views/vendor/core/auth/mail/email-change-code.blade.php).
*/
class EmailChangeCodeMail extends Mailable
{
public function __construct(
public readonly string $code,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Confirm your new email address');
}
public function content(): Content
{
return new Content(view: 'core::auth.mail.email-change-code');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Auth\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Sent to the OLD address once a login-email change actually takes
* effect (Customer\Services\CustomerEmailChangeService::confirm()) — lets
* the previous owner notice if someone else changed it from a hijacked
* session. Shows the new address masked (first character + domain only),
* never the full new address — this notice's whole point is alerting the
* OLD owner, not handing them the new address outright. View overridable
* per-app the same way UserOtpMail's is (resources/views/vendor/core/
* auth/mail/email-changed-notice.blade.php).
*/
class EmailChangedNoticeMail extends Mailable
{
public readonly string $maskedEmail;
public function __construct(string $newEmail)
{
[$local, $domain] = explode('@', $newEmail, 2);
$this->maskedEmail = mb_substr($local, 0, 1).'•••@'.$domain;
}
public function envelope(): Envelope
{
return new Envelope(subject: 'Your account email was changed');
}
public function content(): Content
{
return new Content(view: 'core::auth.mail.email-changed-notice');
}
}
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Customer\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Modules\Core\Customer\Models\Customer;
/**
* Customer-side sibling of Checkout\Events\RecoveryConsentSet — dispatched
* by CustomerAccountService::setRecoveryConsent() every time the account's
* standing promotional/abandoned-cart-recovery opt-in changes, including
* an explicit opt-OUT, not just an opt-in. $consent is the new value,
* already written to Customer::meta by the time this fires. Distinct from
* RecoveryConsentSet, which fires for the current CART's own opt-in
* (CheckoutService::setRecoveryConsent()) — the two write the same meta
* shape onto different models and can fire independently of each other.
*/
class CustomerRecoveryConsentSet
{
public function __construct(
public readonly Customer $customer,
public readonly bool $consent,
public readonly Authenticatable $causer,
) {}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Customer\Exceptions;
use RuntimeException;
/**
* Thrown by Modules\Core\Customer\Services\CustomerEmailChangeService when
* the requested new email already belongs to a different user — checked
* both up front (request()) and again at confirm() time, since someone
* else could sign up with that address in the window between the two.
*/
class EmailAlreadyTakenException extends RuntimeException
{
public function __construct()
{
parent::__construct('That email address is already in use.');
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Customer\Exceptions;
use RuntimeException;
/**
* Thrown by Modules\Core\Customer\Services\CustomerEmailChangeService::
* confirm() for a wrong, expired, or already-burned (too many wrong
* guesses) code — deliberately one exception for all three, the same way
* Auth\Services\UserOtpService::validate() collapses them into a single
* null return, so a caller can't distinguish "wrong code" from "no
* pending change at all" and use that to probe for one.
*/
class InvalidEmailChangeCodeException extends RuntimeException
{
public function __construct()
{
parent::__construct('That code is invalid or has expired.');
}
}
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Customer\Listeners;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Customer\Services\GuestOrderClaimer;
/**
* Registered from Providers\CustomerServiceProvider — UserAuthenticated
* only fires after a valid login code, which is what makes matching
* placed guest orders by email safe (see GuestOrderClaimer's own
* docblock).
*/
class ClaimGuestOrdersOnLogin
{
public function __construct(
private readonly GuestOrderClaimer $claimer,
) {}
public function handle(UserAuthenticated $event): void
{
$this->claimer->claim($event->user);
}
}
@@ -8,6 +8,7 @@ use Modules\Core\Customer\Events\CustomerAddressCreated;
use Modules\Core\Customer\Events\CustomerAddressDeleted;
use Modules\Core\Customer\Events\CustomerAddressUpdated;
use Modules\Core\Customer\Events\CustomerProfileUpdated;
use Modules\Core\Customer\Events\CustomerRecoveryConsentSet;
use Modules\Core\Logging\ActivityLogService;
/**
@@ -62,4 +63,21 @@ class LogCustomerAccountActivity implements ShouldQueue
$event->causer,
);
}
/**
* CustomerRecoveryConsentSet carries only the new value, not a
* before/after snapshot the way CustomerProfileUpdated does — but
* CustomerAccountService::setRecoveryConsent() only ever dispatches it
* once the value has actually changed, so "old" is trivially the
* opposite of $event->consent.
*/
public function handleRecoveryConsentSet(CustomerRecoveryConsentSet $event): void
{
$this->activityLog->updated(
$event->customer,
['recovery_consent' => ! $event->consent],
['recovery_consent' => $event->consent],
$event->causer,
);
}
}
@@ -66,6 +66,9 @@ class CustomerDataProvider implements PersonalDataProvider
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'terms_accepted_at' => $user->terms_accepted_at,
'terms_version' => $user->terms_version,
'privacy_policy_version' => $user->privacy_policy_version,
'customers' => $user->customers->map(fn (Customer $customer) => [
'id' => $customer->id,
'company_name' => $customer->company_name,
@@ -13,6 +13,7 @@ use Modules\Core\Customer\Events\CustomerAddressCreated;
use Modules\Core\Customer\Events\CustomerAddressDeleted;
use Modules\Core\Customer\Events\CustomerAddressUpdated;
use Modules\Core\Customer\Events\CustomerProfileUpdated;
use Modules\Core\Customer\Events\CustomerRecoveryConsentSet;
use Modules\Core\Customer\Exceptions\AddressNotFoundException;
use Modules\Core\Customer\Exceptions\OrderNotFoundException;
use Modules\Core\Customer\Models\Customer;
@@ -72,7 +73,7 @@ class CustomerAccountService
];
private const WRITABLE_PROFILE_FIELDS = [
'title', 'first_name', 'last_name', 'company_name', 'vat_no',
'title', 'first_name', 'last_name', 'company_name', 'tax_identifier',
];
public function customer(Authenticatable $user): ?Customer
@@ -235,6 +236,43 @@ class CustomerAccountService
return $customer;
}
/**
* The account's standing "email me a reminder if I don't finish my
* order" opt-in — same meta shape Checkout\Services\CheckoutService::
* setRecoveryConsent() writes on the current CART (recovery_consent,
* recovery_consent_at, recovery_consent_policy_version), written here
* onto the CUSTOMER instead, so it survives across carts/sessions as a
* standing account preference. The two are independent: opting out on
* the customer doesn't retroactively change a cart already opted in,
* and vice versa — a caller that wants both kept in sync (e.g. 3dealer
* applying a customer's standing preference to the current cart too)
* calls both services itself.
*
* A no-op (no write, no event) when $consent already matches what's
* stored — unlike updateProfile()'s address/profile writes, which
* always write and dispatch even when nothing actually changed.
*/
public function setRecoveryConsent(Authenticatable $user, bool $consent): Customer
{
$customer = $this->customerOrFail($user);
if ((bool) data_get($customer->meta, 'recovery_consent') === $consent) {
return $customer;
}
$customer->meta = [
...($customer->meta?->toArray() ?? []),
'recovery_consent' => $consent,
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
];
$customer->save();
Event::dispatch(new CustomerRecoveryConsentSet($customer, $consent, $user));
return $customer;
}
/**
* @throws LogicException if $user has no paired Customer at all —
* distinct from AddressNotFoundException/OrderNotFoundException
@@ -0,0 +1,163 @@
<?php
namespace Modules\Core\Customer\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Modules\Core\Auth\Events\UserEmailChanged;
use Modules\Core\Auth\Exceptions\OtpThrottledException;
use Modules\Core\Auth\Mail\EmailChangeCodeMail;
use Modules\Core\Auth\Mail\EmailChangedNoticeMail;
use Modules\Core\Customer\Exceptions\EmailAlreadyTakenException;
use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
/**
* Changing an account's login email — core's login is passwordless, so
* the email IS the login, and it only ever changes once the shopper has
* proved they can receive mail at the new address (a typo can never lock
* them out of their own account). The pending change (new address, a
* hash of the code, expiry, wrong-guess count) lives on the user's own
* row (see the migration adding pending_email/pending_email_code_hash/
* pending_email_expires_at/pending_email_attempts) — the same convention
* Auth\Services\UserOtpService's otp_code/otp_expires_at/otp_attempts
* already use — rather than the session, since a code arrives by email
* and is often opened on a different device/session than the one that
* requested it; a session-scoped pending change couldn't be confirmed
* from there at all.
*
* Two independent throttles, both configured under core.auth.email_change
* (same shape/reasoning as core.auth.otp): max_attempts caps wrong
* guesses against ONE code; generation_limit/generation_decay_minutes cap
* how often a NEW code can be requested at all.
*/
class CustomerEmailChangeService
{
/**
* @throws OtpThrottledException if this account has requested too
* many codes within core.auth.email_change.generation_decay_minutes
* @throws EmailAlreadyTakenException if $newEmail already belongs to
* a different user
*/
public function request(Authenticatable $user, string $newEmail): void
{
$newEmail = strtolower(trim($newEmail));
if ($user->newQuery()->where('email', $newEmail)->whereKeyNot($user->getKey())->exists()) {
throw new EmailAlreadyTakenException;
}
$limiterKey = $this->generationLimiterKey($user);
$maxGenerations = (int) config('core.auth.email_change.generation_limit', 3);
if (RateLimiter::tooManyAttempts($limiterKey, $maxGenerations)) {
throw new OtpThrottledException(RateLimiter::availableIn($limiterKey));
}
RateLimiter::hit($limiterKey, (int) config('core.auth.email_change.generation_decay_minutes', 10) * 60);
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$user->forceFill([
'pending_email' => $newEmail,
'pending_email_code_hash' => Hash::make($code),
'pending_email_expires_at' => now()->addMinutes((int) config('core.auth.email_change.expiry_minutes', 10)),
'pending_email_attempts' => 0,
])->save();
Mail::to($newEmail)->send(new EmailChangeCodeMail($code));
}
/**
* @throws InvalidEmailChangeCodeException for a wrong, expired, or
* already-burned (too many wrong guesses) code, or when there is no
* pending change at all
* @throws EmailAlreadyTakenException if someone else has since signed
* up with the pending address, in the window between request() and
* confirm()
*/
public function confirm(Authenticatable $user, string $code): void
{
$model = $user::class;
// lockForUpdate() + a transaction make the read-check-increment-save
// below atomic across concurrent requests — same reasoning as
// Auth\Services\UserOtpService::validate(), which this mirrors.
$valid = DB::transaction(function () use ($model, $user, $code) {
/** @var Authenticatable $locked */
$locked = $model::whereKey($user->getKey())->lockForUpdate()->first();
if (! $locked->pending_email
|| ! $locked->pending_email_code_hash
|| ! $locked->pending_email_expires_at
|| now()->isAfter($locked->pending_email_expires_at)) {
return false;
}
if (! Hash::check($code, $locked->pending_email_code_hash)) {
$locked->pending_email_attempts++;
if ($locked->pending_email_attempts >= (int) config('core.auth.email_change.max_attempts', 5)) {
$locked->pending_email_code_hash = null;
$locked->pending_email_expires_at = null;
$locked->pending_email_attempts = 0;
}
$locked->save();
return false;
}
return true;
});
if (! $valid) {
throw new InvalidEmailChangeCodeException;
}
$user->refresh();
$newEmail = $user->pending_email;
// Someone may have signed up with this address since request() ran.
if ($user->newQuery()->where('email', $newEmail)->whereKeyNot($user->getKey())->exists()) {
$user->forceFill([
'pending_email' => null,
'pending_email_code_hash' => null,
'pending_email_expires_at' => null,
'pending_email_attempts' => 0,
])->save();
throw new EmailAlreadyTakenException;
}
$oldEmail = $user->email;
$user->forceFill([
'email' => $newEmail,
'email_verified_at' => now(),
'pending_email' => null,
'pending_email_code_hash' => null,
'pending_email_expires_at' => null,
'pending_email_attempts' => 0,
])->save();
RateLimiter::clear($this->generationLimiterKey($user));
// Lets the previous owner notice if someone else changed it from a
// hijacked session.
Mail::to($oldEmail)->send(new EmailChangedNoticeMail($newEmail));
// The code just proved they own the new address too.
app(GuestOrderClaimer::class)->claim($user);
Event::dispatch(new UserEmailChanged($user, $oldEmail));
}
private function generationLimiterKey(Authenticatable $user): string
{
return 'email-change:'.$user->getKey();
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Core\Customer\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Base\LunarUser;
use Lunar\Models\Order;
/**
* Attaches placed guest orders to an account when their billing email
* matches the account's email, case-insensitively. Only ever called right
* after the shopper has proved they own that email — a login code
* (Auth\Events\UserAuthenticated), or the code confirming an email change
* (a consuming app's own email-change flow, e.g. 3dealer's Account\
* EmailController::verify()) — which is what makes matching on email safe.
*
* Only orders with no customer_id AND no user_id are touched: an order
* already attached to any account (guest or otherwise) is left alone.
*/
class GuestOrderClaimer
{
public function claim(Authenticatable&LunarUser $user): int
{
$customer = $user->latestCustomer();
if (! $customer || ! $user->email) {
return 0;
}
return Order::query()
->whereNotNull('placed_at')
->whereNull('customer_id')
->whereNull('user_id')
->whereHas('billingAddress', fn ($query) => $query
->whereRaw('lower(contact_email) = ?', [strtolower($user->email)]))
->update([
'customer_id' => $customer->id,
'user_id' => $user->id,
]);
}
}
@@ -85,6 +85,180 @@ class StorefrontLabels
'shop.apply' => ['en' => 'Apply', 'el' => 'Εφαρμογή'],
'shop.availability' => ['en' => 'Availability', 'el' => 'Διαθεσιμότητα'],
'shop.in_stock_only' => ['en' => 'In-stock products only', 'el' => 'Μόνο διαθέσιμα προϊόντα'],
// Passwordless login (Auth\Services\UserOtpService)
'auth.login_intro' => [
'en' => 'Enter your email and we\'ll send you a code to sign in — no password needed.',
'el' => 'Γράψε το email σου και θα σου στείλουμε έναν κωδικό σύνδεσης — δεν χρειάζεται κωδικός πρόσβασης.',
],
'auth.email' => ['en' => 'Email', 'el' => 'Email'],
'auth.terms_notice' => [
'en' => 'By continuing, you accept the <a href=":terms">Terms of Use</a> and have read the <a href=":privacy">Privacy Policy</a>.',
'el' => 'Συνεχίζοντας, αποδέχεσαι τους <a href=":terms">Όρους Χρήσης</a> και έχεις διαβάσει την <a href=":privacy">Πολιτική Απορρήτου</a>.',
],
'auth.send_code' => ['en' => 'Send code', 'el' => 'Αποστολή κωδικού'],
'auth.enter_code' => ['en' => 'Enter the code', 'el' => 'Εισάγετε τον κωδικό'],
'auth.code_sent_to' => ['en' => 'We sent a code to', 'el' => 'Στείλαμε έναν κωδικό στο'],
'auth.code' => ['en' => 'Code', 'el' => 'Κωδικός'],
'auth.resend_code' => ['en' => 'Resend code', 'el' => 'Επαναποστολή κωδικού'],
'auth.code_resent' => ['en' => 'A new code was sent.', 'el' => 'Στάλθηκε νέος κωδικός.'],
'auth.change_email' => ['en' => 'Use a different email', 'el' => 'Χρήση διαφορετικού email'],
'auth.invalid_code' => ['en' => 'That code is invalid or has expired.', 'el' => 'Ο κωδικός δεν είναι έγκυρος ή έχει λήξει.'],
'auth.too_many_codes' => [
'en' => 'Too many attempts. Please wait a few minutes and try again.',
'el' => 'Πολλές προσπάθειες. Περίμενε λίγα λεπτά και ξαναδοκίμασε.',
],
// Account profile page (account/show.blade.php)
'account.nav_profile' => ['en' => 'Profile', 'el' => 'Προφίλ'],
'account.nav_orders' => ['en' => 'Orders', 'el' => 'Παραγγελίες'],
'account.nav_wishlist' => ['en' => 'Wishlist', 'el' => 'Λίστα επιθυμιών'],
'account.email_heading' => ['en' => 'Login email', 'el' => 'Email σύνδεσης'],
'account.email_change' => ['en' => 'Change email', 'el' => 'Αλλαγή email'],
'account.details_heading' => ['en' => 'Your details', 'el' => 'Τα στοιχεία σου'],
'account.first_name' => ['en' => 'First name', 'el' => 'Όνομα'],
'account.last_name' => ['en' => 'Last name', 'el' => 'Επώνυμο'],
'account.invoice' => ['en' => 'I need an invoice', 'el' => 'Χρειάζομαι τιμολόγιο'],
'account.company_name' => ['en' => 'Company name', 'el' => 'Επωνυμία εταιρείας'],
'account.tax_identifier' => ['en' => 'Tax ID (VAT)', 'el' => 'ΑΦΜ'],
'account.address_heading' => ['en' => 'Address', 'el' => 'Διεύθυνση'],
'account.line_one' => ['en' => 'Address', 'el' => 'Διεύθυνση'],
'account.city' => ['en' => 'City', 'el' => 'Πόλη'],
'account.postcode' => ['en' => 'Postcode', 'el' => 'Ταχυδρομικός κώδικας'],
'account.state' => ['en' => 'Region', 'el' => 'Περιοχή'],
'account.state_placeholder' => ['en' => 'Select a region', 'el' => 'Επίλεξε περιοχή'],
'account.phone' => ['en' => 'Phone', 'el' => 'Τηλέφωνο'],
'account.emails_heading' => ['en' => 'Emails', 'el' => 'Ειδοποιήσεις email'],
'account.recovery_consent' => [
'en' => 'Email me a reminder if I don\'t finish my order',
'el' => 'Στείλε μου υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου',
],
'account.save' => ['en' => 'Save changes', 'el' => 'Αποθήκευση'],
'account.saved' => ['en' => 'Your details were saved.', 'el' => 'Τα στοιχεία σου αποθηκεύτηκαν.'],
'account.delete_heading' => ['en' => 'Delete account', 'el' => 'Διαγραφή λογαριασμού'],
'account.delete_text' => [
'en' => 'This permanently deletes your account and personal data. This cannot be undone.',
'el' => 'Αυτό διαγράφει οριστικά τον λογαριασμό και τα προσωπικά σου δεδομένα. Δεν μπορεί να αναιρεθεί.',
],
'account.delete' => ['en' => 'Delete my account', 'el' => 'Διαγραφή λογαριασμού'],
'account.delete_confirm_heading' => ['en' => 'Are you sure?', 'el' => 'Είσαι σίγουρος/η;'],
'account.delete_confirm_text' => [
'en' => 'This cannot be undone. Your account and personal data will be permanently deleted.',
'el' => 'Αυτό δεν μπορεί να αναιρεθεί. Ο λογαριασμός και τα προσωπικά σου δεδομένα θα διαγραφούν οριστικά.',
],
'account.delete_confirm' => ['en' => 'Yes, delete my account', 'el' => 'Ναι, διαγραφή λογαριασμού'],
'account.delete_cancel' => ['en' => 'Cancel', 'el' => 'Ακύρωση'],
'account.deletion_requested' => [
'en' => 'Your account deletion has been requested.',
'el' => 'Ζητήθηκε η διαγραφή του λογαριασμού σου.',
],
// Account email-change flow (account/email.blade.php, account/email-code.blade.php)
'account.email_change_heading' => ['en' => 'Change your email', 'el' => 'Αλλαγή email'],
'account.email_current' => ['en' => 'Your current email is', 'el' => 'Το τρέχον email σου είναι'],
'account.email_new' => ['en' => 'New email', 'el' => 'Νέο email'],
'account.email_new_hint' => [
'en' => 'We\'ll send a code to this address to confirm it\'s yours.',
'el' => 'Θα στείλουμε έναν κωδικό σε αυτή τη διεύθυνση για να επιβεβαιώσουμε ότι είναι δική σου.',
],
'account.email_confirm' => ['en' => 'Confirm', 'el' => 'Επιβεβαίωση'],
'account.email_same' => [
'en' => 'That\'s already your current email.',
'el' => 'Αυτό είναι ήδη το τρέχον email σου.',
],
'account.email_taken' => [
'en' => 'That email address is already in use.',
'el' => 'Αυτή η διεύθυνση email χρησιμοποιείται ήδη.',
],
'account.email_changed' => ['en' => 'Your email was changed.', 'el' => 'Το email σου άλλαξε.'],
// Order history (account/orders/index.blade.php, account/orders/show.blade.php)
'orders.empty' => ['en' => 'You have no orders yet.', 'el' => 'Δεν έχεις παραγγελίες ακόμα.'],
'orders.shop_now' => ['en' => 'Shop now', 'el' => 'Αγόρασε τώρα'],
'orders.date' => ['en' => 'Date', 'el' => 'Ημερομηνία'],
'orders.number' => ['en' => 'Order', 'el' => 'Παραγγελία'],
'orders.status' => ['en' => 'Status', 'el' => 'Κατάσταση'],
'orders.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
'orders.view' => ['en' => 'View', 'el' => 'Προβολή'],
'orders.view_order' => ['en' => 'View order :number', 'el' => 'Προβολή παραγγελίας :number'],
'orders.order_title' => ['en' => 'Order :number', 'el' => 'Παραγγελία :number'],
'orders.back' => ['en' => 'Back to orders', 'el' => 'Πίσω στις παραγγελίες'],
'orders.payment' => ['en' => 'Payment method', 'el' => 'Τρόπος πληρωμής'],
'orders.shipping_method' => ['en' => 'Shipping method', 'el' => 'Τρόπος αποστολής'],
'orders.tracking' => ['en' => 'Tracking', 'el' => 'Παρακολούθηση αποστολής'],
'orders.items' => ['en' => 'Items', 'el' => 'Προϊόντα'],
'orders.subtotal' => ['en' => 'Subtotal', 'el' => 'Μερικό σύνολο'],
'orders.discount' => ['en' => 'Discount', 'el' => 'Έκπτωση'],
'orders.shipping' => ['en' => 'Shipping', 'el' => 'Μεταφορικά'],
'orders.tax' => ['en' => 'Tax', 'el' => 'ΦΠΑ'],
'orders.shipping_to' => ['en' => 'Shipping to', 'el' => 'Αποστολή σε'],
'orders.billing' => ['en' => 'Billing details', 'el' => 'Στοιχεία τιμολόγησης'],
// Contact form (contact.blade.php, ContactController, emails.contact-confirmation)
'contact.sent' => [
'en' => 'Your message was sent — we\'ll get back to you soon.',
'el' => 'Το μήνυμά σου στάλθηκε — θα σου απαντήσουμε σύντομα.',
],
'contact.send_failed' => [
'en' => 'Something went wrong sending your message. Please try again.',
'el' => 'Κάτι πήγε στραβά κατά την αποστολή. Παρακαλούμε δοκίμασε ξανά.',
],
'contact.too_many' => [
'en' => 'Too many messages sent. Please wait a while before trying again.',
'el' => 'Στάλθηκαν πολλά μηνύματα. Περίμενε λίγο πριν ξαναδοκιμάσεις.',
],
'contact.confirmation_subject' => ['en' => 'We received your message', 'el' => 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_preheader' => [
'en' => 'Thanks for reaching out — here\'s a copy of your message.',
'el' => 'Ευχαριστούμε για την επικοινωνία — εδώ είναι ένα αντίγραφο του μηνύματός σου.',
],
'contact.confirmation_heading' => ['en' => 'We received your message', 'el' => 'Λάβαμε το μήνυμά σου'],
'contact.confirmation_body' => [
'en' => 'Thanks for getting in touch. We\'ll reply as soon as we can.',
'el' => 'Ευχαριστούμε που επικοινώνησες μαζί μας. Θα απαντήσουμε το συντομότερο δυνατό.',
],
'contact.confirmation_footer' => [
'en' => 'This is a copy of the message you sent us.',
'el' => 'Αυτό είναι ένα αντίγραφο του μηνύματος που μας έστειλες.',
],
// Product page — custom fields, add-to-cart failure (product/show.blade.php,
// components/product-custom-fields.blade.php)
'product.personalize' => ['en' => 'Personalize', 'el' => 'Εξατομίκευση'],
'product.custom_field_photo_hint' => [
'en' => 'Max file size: :size MB.',
'el' => 'Μέγιστο μέγεθος αρχείου: :size MB.',
],
'product.custom_field_uploading' => ['en' => 'Uploading…', 'el' => 'Μεταφόρτωση…'],
'product.custom_field_upload_failed' => [
'en' => 'Upload failed. Please try again.',
'el' => 'Η μεταφόρτωση απέτυχε. Παρακαλούμε δοκίμασε ξανά.',
],
'product.add_to_cart_failed' => [
'en' => '{0} Sorry, that\'s out of stock|{1} Only :count left in stock|[2,*] Only :count left in stock',
'el' => '{0} Λυπούμαστε, εξαντλήθηκε|{1} Απομένει μόνο :count κομμάτι|[2,*] Απομένουν μόνο :count κομμάτια',
],
// Reviews (components/review-form.blade.php, review-card.blade.php, product/show.blade.php)
'review.rating_required' => ['en' => 'Please select a rating.', 'el' => 'Παρακαλούμε επίλεξε βαθμολογία.'],
'review.reply' => ['en' => 'Reply', 'el' => 'Απάντηση'],
'review.thank_you' => [
'en' => 'Thanks for your review!',
'el' => 'Ευχαριστούμε για την αξιολόγησή σου!',
],
// Wishlist (components/wishlist-button.blade.php, wishlist/guest.blade.php, wishlist/list.blade.php)
'wishlist.add' => ['en' => 'Add to wishlist', 'el' => 'Προσθήκη στη λίστα επιθυμιών'],
'wishlist.remove' => ['en' => 'Remove from wishlist', 'el' => 'Αφαίρεση από τη λίστα επιθυμιών'],
'wishlist.added' => ['en' => 'Added to wishlist', 'el' => 'Προστέθηκε στη λίστα επιθυμιών'],
'wishlist.removed' => ['en' => 'Removed from wishlist', 'el' => 'Αφαιρέθηκε από τη λίστα επιθυμιών'],
'wishlist.empty' => ['en' => 'Your wishlist is empty.', 'el' => 'Η λίστα επιθυμιών σου είναι άδεια.'],
'wishlist.guest_hint' => [
'en' => 'Log in to keep your wishlist across devices.',
'el' => 'Συνδέσου για να κρατήσεις τη λίστα επιθυμιών σου σε όλες τις συσκευές.',
],
'wishlist.remove_named' => ['en' => 'Remove :name from wishlist', 'el' => 'Αφαίρεση :name από τη λίστα επιθυμιών'],
'wishlist.remove_short' => ['en' => 'Remove', 'el' => 'Αφαίρεση'],
];
}
}
+5
View File
@@ -2,13 +2,18 @@
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Auth\Events\UserCreated;
use Modules\Core\Auth\Listeners\RecordLegalAcceptanceForNewUser;
use Modules\Core\Command\CreateAdminCommand;
class AuthServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(UserCreated::class, RecordLegalAcceptanceForNewUser::class);
if ($this->app->runningInConsole()) {
$this->app->booted(fn () => $this->commands([CreateAdminCommand::class]));
}
@@ -6,11 +6,14 @@ use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Lunar\Facades\ModelManifest;
use Lunar\Models\Contracts\Customer as LunarCustomer;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Auth\Events\UserCreated;
use Modules\Core\Customer\Events\CustomerAddressCreated;
use Modules\Core\Customer\Events\CustomerAddressDeleted;
use Modules\Core\Customer\Events\CustomerAddressUpdated;
use Modules\Core\Customer\Events\CustomerProfileUpdated;
use Modules\Core\Customer\Events\CustomerRecoveryConsentSet;
use Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin;
use Modules\Core\Customer\Listeners\CreateCustomerForUser;
use Modules\Core\Customer\Listeners\LogCustomerAccountActivity;
use Modules\Core\Customer\Models\Customer;
@@ -35,10 +38,12 @@ class CustomerServiceProvider extends ServiceProvider
$this->app->booted(fn () => ModelManifest::replace(LunarCustomer::class, Customer::class));
Event::listen(UserCreated::class, CreateCustomerForUser::class);
Event::listen(UserAuthenticated::class, ClaimGuestOrdersOnLogin::class);
Event::listen(CustomerAddressCreated::class, [LogCustomerAccountActivity::class, 'handleAddressCreated']);
Event::listen(CustomerAddressUpdated::class, [LogCustomerAccountActivity::class, 'handleAddressUpdated']);
Event::listen(CustomerAddressDeleted::class, [LogCustomerAccountActivity::class, 'handleAddressDeleted']);
Event::listen(CustomerProfileUpdated::class, [LogCustomerAccountActivity::class, 'handleProfileUpdated']);
Event::listen(CustomerRecoveryConsentSet::class, [LogCustomerAccountActivity::class, 'handleRecoveryConsentSet']);
}
}