account pages: login, order history, order details, account details, wishlist

This commit is contained in:
elvira
2026-09-24 17:27:58 +03:00
parent 580adac33a
commit cf3681260b
42 changed files with 1972 additions and 15 deletions
@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Lunar\Models\Country;
use Lunar\Models\State;
use Modules\Core\Customer\Services\CustomerAccountService;
use Modules\Core\Privacy\Services\PrivacyService;
/**
* The profile page: name, invoice details, the one address, and account
* deletion. The customer keeps a single address, flagged as both the
* shipping and billing default, so allowing several later is a UI change
* only (the data is already Lunar's own addresses table).
*/
class AccountController extends Controller
{
// Single-country store, same as CheckoutController::STORE_COUNTRY_ISO3.
private const STORE_COUNTRY_ISO3 = 'GRC';
private const ADDRESS_FIELDS = ['line_one', 'city', 'postcode', 'state', 'contact_phone'];
public function __construct(
private readonly CustomerAccountService $account,
) {}
public function show(string $locale, Request $request): View
{
$user = $request->user();
$customer = $this->account->customer($user);
return view('account.show', [
'user' => $user,
'customer' => $customer,
'address' => $this->defaultAddress($user),
'regions' => State::where('country_id', $this->storeCountry()->id)->orderBy('name')->get(['id', 'name']),
]);
}
public function update(string $locale, Request $request): RedirectResponse
{
$user = $request->user();
$country = $this->storeCountry();
// The address is all-or-nothing: typing any part of it makes the rest
// (and the name, which Lunar requires on every address) required.
$anyAddressField = implode(',', self::ADDRESS_FIELDS);
$data = $request->validate([
'first_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
'last_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
'invoice' => ['boolean'],
'company_name' => ['nullable', 'string', 'max:255', 'required_if_accepted:invoice'],
'tax_identifier' => ['nullable', 'digits:9', 'required_if_accepted:invoice'],
'line_one' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
'city' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
'postcode' => ['nullable', 'regex:/^\d{3}\s?\d{2}$/', 'required_with:'.$anyAddressField],
'state' => [
'nullable',
'string',
'required_with:'.$anyAddressField,
Rule::exists((new State)->getTable(), 'name')->where('country_id', $country->id),
],
'contact_phone' => ['nullable', 'string', 'max:30'],
]);
$invoice = $request->boolean('invoice');
$customer = $this->account->updateProfile($user, [
'first_name' => $data['first_name'] ?? null,
'last_name' => $data['last_name'] ?? null,
'company_name' => $invoice ? $data['company_name'] : null,
]);
// Written directly: core's updateProfile() allowlists `vat_no`, but
// Lunar's column is `tax_identifier`, so it can't go through there yet.
$customer->update(['tax_identifier' => $invoice ? $data['tax_identifier'] : null]);
if (filled($data['line_one'] ?? null)) {
$addressData = [
...collect($data)->only(self::ADDRESS_FIELDS)->all(),
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'country_id' => $country->id,
'contact_email' => $user->email,
'shipping_default' => true,
'billing_default' => true,
];
$existing = $this->defaultAddress($user);
$existing
? $this->account->updateAddress($user, $existing->id, $addressData)
: $this->account->createAddress($user, $addressData);
}
return redirect()->route('account')->with('status', __('storefront.account.saved'));
}
/**
* Self-service deletion: opens core's 30-day grace-period erasure request
* (which blocks the login right away) and logs out. Logging back in within
* the grace period cancels it; see core's docs/privacy.md.
*/
public function destroy(string $locale, Request $request, PrivacyService $privacy): RedirectResponse
{
$user = $request->user();
$privacy->requestErasureForUser($user, $user);
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')->with('status', __('storefront.account.deletion_requested'));
}
private function defaultAddress($user)
{
$addresses = collect($this->account->addresses($user));
return $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
}
private function storeCountry(): Country
{
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->firstOrFail();
}
}
@@ -0,0 +1,169 @@
<?php
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use App\Mail\EmailChangeCodeMail;
use App\Mail\EmailChangedNoticeMail;
use App\Services\GuestOrderClaimer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
/**
* Changing the login email: new email → 6-digit code sent to that address →
* switched. The email is the login, so it only changes once the shopper has
* proved they can receive mail there; a typo can never lock them out.
*
* Once switched, the OLD address gets a notice (EmailChangedNoticeMail).
*
* Core has no email-change flow, so the pending change lives in the session
* (the new address, a hash of the code, expiry and wrong-guess count). Limits
* mirror core's login OTP: 3 codes per 10 minutes, 5 guesses per code.
*/
class EmailController extends Controller
{
private const SESSION_KEY = 'email_change';
private const EXPIRY_MINUTES = 10;
private const MAX_ATTEMPTS = 5;
private const SEND_LIMIT = 3;
private const SEND_DECAY_SECONDS = 600;
public function edit(string $locale, Request $request): View
{
return view('account.email', ['user' => $request->user()]);
}
public function send(string $locale, Request $request): RedirectResponse
{
$user = $request->user();
$request->merge(['email' => Str::lower(trim((string) $request->input('email')))]);
$validated = $request->validate([
'email' => [
'required',
'email',
'max:255',
Rule::notIn([$user->email]),
Rule::unique($user->getTable(), 'email')->ignore($user->id),
],
], [
'email.not_in' => __('storefront.account.email_same'),
'email.unique' => __('storefront.account.email_taken'),
]);
if (! $this->sendCode($request, $validated['email'])) {
return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]);
}
return redirect()->route('account.email.code');
}
public function code(string $locale, Request $request): View|RedirectResponse
{
$pending = $request->session()->get(self::SESSION_KEY);
if (! $pending) {
return redirect()->route('account.email.edit');
}
return view('account.email-code', ['email' => $pending['email']]);
}
public function resend(string $locale, Request $request): RedirectResponse
{
$pending = $request->session()->get(self::SESSION_KEY);
if (! $pending) {
return redirect()->route('account.email.edit');
}
if (! $this->sendCode($request, $pending['email'])) {
return back()->withErrors(['code' => __('storefront.auth.too_many_codes')]);
}
return back()->with('status', __('storefront.auth.code_resent'));
}
public function verify(string $locale, Request $request, GuestOrderClaimer $orders): RedirectResponse
{
$pending = $request->session()->get(self::SESSION_KEY);
if (! $pending) {
return redirect()->route('account.email.edit');
}
$validated = $request->validate(['code' => ['required', 'digits:6']]);
$valid = $pending['code_hash'] !== null
&& now()->timestamp < $pending['expires_at']
&& Hash::check($validated['code'], $pending['code_hash']);
if (! $valid) {
// Too many wrong guesses burns the code; only "resend" helps then.
$pending['attempts']++;
if ($pending['attempts'] >= self::MAX_ATTEMPTS) {
$pending['code_hash'] = null;
}
$request->session()->put(self::SESSION_KEY, $pending);
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
}
$user = $request->user();
// Someone may have signed up with this address since the code was sent.
if ($user->newQuery()->where('email', $pending['email'])->whereKeyNot($user->id)->exists()) {
$request->session()->forget(self::SESSION_KEY);
return redirect()->route('account.email.edit')
->withErrors(['email' => __('storefront.account.email_taken')]);
}
$oldEmail = $user->email;
$user->forceFill(['email' => $pending['email'], 'email_verified_at' => now()])->save();
// Lets the owner notice if someone else changed it from a hijacked session.
Mail::to($oldEmail)->send(new EmailChangedNoticeMail($pending['email']));
// The code just proved they own the new address too.
$orders->claim($user);
$request->session()->forget(self::SESSION_KEY);
return redirect()->route('account')->with('status', __('storefront.account.email_changed'));
}
private function sendCode(Request $request, string $email): bool
{
$limiterKey = 'email-change:'.$request->user()->id;
if (RateLimiter::tooManyAttempts($limiterKey, self::SEND_LIMIT)) {
return false;
}
RateLimiter::hit($limiterKey, self::SEND_DECAY_SECONDS);
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$request->session()->put(self::SESSION_KEY, [
'email' => $email,
'code_hash' => Hash::make($code),
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
'attempts' => 0,
]);
Mail::to($email)->send(new EmailChangeCodeMail($code));
return true;
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Customer\Exceptions\OrderNotFoundException;
use Modules\Core\Customer\Services\CustomerAccountService;
/**
* Order history. Every lookup goes through CustomerAccountService, which only
* ever returns the logged-in user's own placed orders, so an order id from
* the URL can't reach someone else's order.
*/
class OrderController extends Controller
{
public function __construct(
private readonly CustomerAccountService $account,
) {}
public function index(string $locale, Request $request): View
{
return view('account.orders.index', [
'orders' => $this->account->orders($request->user(), 15),
]);
}
public function show(string $locale, Request $request, int $orderId): View
{
try {
$order = $this->account->order($request->user(), $orderId);
} catch (OrderNotFoundException) {
abort(404);
}
// By type, like the checkout confirmation: the method may since have
// been disabled, but the order still shows what was used.
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
->first()
?->translate('name');
return view('account.orders.show', [
'order' => $order,
'paymentMethodName' => $paymentMethodName,
'shipments' => $order->shipments->whereNull('cancelled_at')->whereNotNull('tracking_reference'),
]);
}
}
@@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Modules\Core\Auth\Exceptions\OtpThrottledException;
use Modules\Core\Auth\Services\UserOtpService;
/**
* Passwordless customer login: email → emailed 6-digit code → logged in.
* Login and registration are the same flow — UserOtpService::generateAndSend()
* find-or-creates the user (and its Customer).
*
* All the security lives in UserOtpService: per-email code-request throttling
* (OtpThrottledException), wrong-guess lockout, the Auth::login() itself (which
* also regenerates the session and merges the guest cart via Lunar's Login
* listener) and the session-registry record. This controller only moves the
* shopper between the two steps, carrying the email in the session rather
* than the URL.
*/
class LoginController extends Controller
{
public function create(string $locale): View
{
return view('auth.login');
}
public function send(string $locale, Request $request, UserOtpService $otp): RedirectResponse
{
$validated = $request->validate([
'email' => ['required', 'email', 'max:255'],
]);
$email = Str::lower(trim($validated['email']));
try {
$otp->generateAndSend($email);
} catch (OtpThrottledException) {
return back()->withInput()->withErrors([
'email' => __('storefront.auth.too_many_codes'),
]);
}
$request->session()->put('login.email', $email);
return redirect()->route('login.code');
}
public function code(string $locale, Request $request): View|RedirectResponse
{
$email = $request->session()->get('login.email');
if (! $email) {
return redirect()->route('login');
}
return view('auth.login-code', ['email' => $email]);
}
public function resend(string $locale, Request $request, UserOtpService $otp): RedirectResponse
{
$email = $request->session()->get('login.email');
if (! $email) {
return redirect()->route('login');
}
try {
$otp->generateAndSend($email);
} catch (OtpThrottledException) {
return back()->withErrors([
'code' => __('storefront.auth.too_many_codes'),
]);
}
return back()->with('status', __('storefront.auth.code_resent'));
}
public function verify(string $locale, Request $request, UserOtpService $otp): RedirectResponse
{
$email = $request->session()->get('login.email');
if (! $email) {
return redirect()->route('login');
}
$validated = $request->validate([
'code' => ['required', 'digits:6'],
]);
// Wrong, expired, or locked out after too many guesses — core doesn't
// say which, so neither do we; the page offers "resend code" for all three.
if (! $otp->validate($email, $validated['code'], $request)) {
return back()->withErrors([
'code' => __('storefront.auth.invalid_code'),
]);
}
$request->session()->forget('login.email');
return redirect()->intended(route('home'));
}
public function destroy(string $locale, Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('home');
}
}
@@ -139,7 +139,6 @@ public function saveAddress(string $locale, Request $request): JsonResponse
'billing_company_name' => ['nullable', 'string', 'max:255'],
'billing_tax_identifier' => ['nullable', 'string', 'max:255'],
'billing_line_one' => ['nullable', 'string', 'max:255'],
'billing_line_two' => ['nullable', 'string', 'max:255'],
'billing_city' => ['nullable', 'string', 'max:255'],
'billing_state' => $stateRule,
'billing_postcode' => ['nullable', 'string', 'max:20'],
@@ -150,7 +149,6 @@ public function saveAddress(string $locale, Request $request): JsonResponse
'shipping_last_name' => ['nullable', 'string', 'max:255'],
'shipping_company_name' => ['nullable', 'string', 'max:255'],
'shipping_line_one' => ['nullable', 'string', 'max:255'],
'shipping_line_two' => ['nullable', 'string', 'max:255'],
'shipping_city' => ['nullable', 'string', 'max:255'],
'shipping_state' => $stateRule,
'shipping_postcode' => ['nullable', 'string', 'max:20'],
@@ -171,7 +169,6 @@ public function saveAddress(string $locale, Request $request): JsonResponse
'company_name' => $data['billing_company_name'] ?? null,
'tax_identifier' => $data['billing_tax_identifier'] ?? null,
'line_one' => $data['billing_line_one'] ?? null,
'line_two' => $data['billing_line_two'] ?? null,
'city' => $data['billing_city'] ?? null,
'state' => $data['billing_state'] ?? null,
'postcode' => $data['billing_postcode'] ?? null,
@@ -187,7 +184,6 @@ public function saveAddress(string $locale, Request $request): JsonResponse
'last_name' => $data['shipping_last_name'] ?? null,
'company_name' => $data['shipping_company_name'] ?? null,
'line_one' => $data['shipping_line_one'] ?? null,
'line_two' => $data['shipping_line_two'] ?? null,
'city' => $data['shipping_city'] ?? null,
'state' => $data['shipping_state'] ?? null,
'postcode' => $data['shipping_postcode'] ?? null,
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Catalog\ProductCard;
use App\Services\Wishlist;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\View\View;
use Lunar\Models\Product;
use Modules\Core\Catalog\Services\ProductService;
class WishlistController extends Controller
{
public function __construct(
private readonly Wishlist $wishlist,
) {}
/**
* The account's wishlist page.
*/
public function index(string $locale, ProductService $products): View
{
return view('account.wishlist', ['products' => $this->products($products)]);
}
/**
* The same list for a guest, from their cookie. Logged-in users are sent
* to the account version.
*/
public function guest(string $locale, ProductService $products): View|RedirectResponse
{
if (auth()->check()) {
return redirect()->route('account.wishlist');
}
return view('wishlist.guest', ['products' => $this->products($products)]);
}
/**
* Adds or removes a product, for guests and logged-in shoppers alike. The
* heart button's Stimulus controller asks for JSON; without JS the form
* posts normally and comes back to the same page.
*/
public function toggle(string $locale, Request $request, int $productId): JsonResponse|RedirectResponse
{
abort_unless(Product::whereKey($productId)->exists(), 404);
$active = $this->wishlist->toggle($productId);
if ($request->expectsJson()) {
return response()->json(['active' => $active]);
}
return back();
}
/**
* Product cards for the current wishlist, newest first. Products no longer
* in the search index (deleted, unpublished) are simply skipped.
*/
private function products(ProductService $products): Collection
{
return collect($this->wishlist->ids())
->map(fn (int $id) => $products->getById($id))
->filter()
->map(fn (array $product) => ['id' => $product['id'], ...ProductCard::fromIndexed($product)])
->values();
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Listeners;
use App\Services\GuestOrderClaimer;
use Modules\Core\Auth\Events\UserAuthenticated;
/**
* Picked up by Laravel's listener discovery (app/Listeners), no manual
* registration. UserAuthenticated only fires after a valid login code.
*/
class ClaimGuestOrdersOnLogin
{
public function __construct(
private readonly GuestOrderClaimer $claimer,
) {}
public function handle(UserAuthenticated $event): void
{
$this->claimer->claim($event->user);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Listeners;
use App\Services\Wishlist;
use Modules\Core\Auth\Events\UserAuthenticated;
/**
* Picked up by Laravel's listener discovery (app/Listeners), no manual
* registration. Runs inside the login request, so it can read the guest
* wishlist cookie and queue its removal.
*/
class MergeGuestWishlistOnLogin
{
public function __construct(
private readonly Wishlist $wishlist,
) {}
public function handle(UserAuthenticated $event): void
{
$this->wishlist->mergeGuestInto($event->user);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* The code that confirms a new login email — see Account\EmailController.
*/
class EmailChangeCodeMail extends Mailable
{
public function __construct(
public readonly string $code,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Επιβεβαίωσε το νέο σου email');
}
public function content(): Content
{
return new Content(view: 'emails.email-change-code');
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Sent to the OLD address once the login email has changed (see
* Account\EmailController), so the owner notices a takeover. The new address
* is shown masked, e.g. "n•••@example.com".
*/
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: 'Το email του λογαριασμού σου άλλαξε');
}
public function content(): Content
{
return new Content(view: 'emails.email-changed-notice');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* One product on a logged-in user's wishlist. Guests' wishlists live in a
* cookie instead — see App\Services\Wishlist.
*/
class WishlistItem extends Model
{
protected $fillable = ['user_id', 'product_id'];
}
+7
View File
@@ -17,6 +17,13 @@
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// One instance per request: it caches the guest cookie's ids, so a
// toggle and a later has() in the same request agree.
$this->app->scoped(\App\Services\Wishlist::class);
}
public function boot(): void
{
Telemetry::optOut();
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Models\Order;
/**
* Attaches placed guest orders to an account when their billing email matches
* the account's email. Only ever called right after the shopper has proved
* they own that email (a login code, or the code confirming an email change),
* which is what makes matching on email safe.
*
* Orders already belonging to any customer or user are never touched.
*/
class GuestOrderClaimer
{
public function claim(Authenticatable $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,
]);
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Services;
use App\Models\WishlistItem;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
/**
* The current shopper's wishlist, product ids only.
*
* Logged in: rows in wishlist_items. Guest: a 1-year cookie holding the ids
* (encrypted like every cookie, by the web group's EncryptCookies), so nothing
* is written to the database for anonymous visitors. On login the cookie is
* merged into the account and cleared (MergeGuestWishlistOnLogin).
*/
class Wishlist
{
public const COOKIE = 'wishlist';
private const COOKIE_MINUTES = 60 * 24 * 365;
// Keeps the cookie well under the 4KB browser limit.
private const GUEST_MAX = 100;
/** @var array<int>|null ids for this request, including a toggle just made */
private ?array $guestIds = null;
/** @return array<int> newest first */
public function ids(): array
{
if ($user = Auth::user()) {
return WishlistItem::where('user_id', $user->id)
->latest('id')
->pluck('product_id')
->all();
}
return $this->guestIds();
}
public function has(int $productId): bool
{
return in_array($productId, $this->ids(), true);
}
/**
* @return bool whether the product is on the wishlist afterwards
*/
public function toggle(int $productId): bool
{
if ($user = Auth::user()) {
$deleted = WishlistItem::where('user_id', $user->id)->where('product_id', $productId)->delete();
if ($deleted) {
return false;
}
WishlistItem::create(['user_id' => $user->id, 'product_id' => $productId]);
return true;
}
$ids = $this->guestIds();
if (in_array($productId, $ids, true)) {
$this->storeGuestIds(array_values(array_diff($ids, [$productId])));
return false;
}
$this->storeGuestIds(array_slice([$productId, ...$ids], 0, self::GUEST_MAX));
return true;
}
public function remove(int $productId): void
{
if ($this->has($productId)) {
$this->toggle($productId);
}
}
/**
* Moves the guest cookie's products onto $user's wishlist and clears it.
*/
public function mergeGuestInto(Authenticatable $user): void
{
$ids = $this->guestIds();
if ($ids === []) {
return;
}
// Oldest first, so the newest cookie item also ends up newest here.
foreach (array_reverse($ids) as $productId) {
WishlistItem::firstOrCreate(['user_id' => $user->id, 'product_id' => $productId]);
}
$this->guestIds = [];
Cookie::queue(Cookie::forget(self::COOKIE));
}
/** @return array<int> */
private function guestIds(): array
{
if ($this->guestIds !== null) {
return $this->guestIds;
}
$decoded = json_decode((string) request()->cookie(self::COOKIE), true);
return $this->guestIds = is_array($decoded)
? array_values(array_unique(array_filter(array_map('intval', $decoded))))
: [];
}
/** @param array<int> $ids */
private function storeGuestIds(array $ids): void
{
$this->guestIds = $ids;
Cookie::queue(self::COOKIE, json_encode($ids), self::COOKIE_MINUTES);
}
}