From cf3681260b77fa57313238a7106b6441076d574f Mon Sep 17 00:00:00 2001 From: elvira Date: Thu, 24 Sep 2026 17:27:58 +0300 Subject: [PATCH] account pages: login, order history, order details, account details, wishlist --- .../Controllers/Account/AccountController.php | 136 +++++++++++++ .../Controllers/Account/EmailController.php | 169 ++++++++++++++++ .../Controllers/Account/OrderController.php | 50 +++++ app/Http/Controllers/Auth/LoginController.php | 118 +++++++++++ .../Checkout/CheckoutController.php | 4 - app/Http/Controllers/WishlistController.php | 72 +++++++ app/Listeners/ClaimGuestOrdersOnLogin.php | 22 ++ app/Listeners/MergeGuestWishlistOnLogin.php | 23 +++ app/Mail/EmailChangeCodeMail.php | 27 +++ app/Mail/EmailChangedNoticeMail.php | 34 ++++ app/Models/WishlistItem.php | 14 ++ app/Providers/AppServiceProvider.php | 7 + app/Services/GuestOrderClaimer.php | 37 ++++ app/Services/Wishlist.php | 126 ++++++++++++ bootstrap/app.php | 13 +- ..._24_000001_create_wishlist_items_table.php | 25 +++ resources/js/stimulus/index.js | 2 + resources/js/stimulus/wishlist-controller.js | 42 ++++ resources/views/account/email-code.blade.php | 65 ++++++ resources/views/account/email.blade.php | 53 +++++ .../views/account/orders/index.blade.php | 67 +++++++ resources/views/account/orders/show.blade.php | 151 ++++++++++++++ resources/views/account/show.blade.php | 188 ++++++++++++++++++ resources/views/account/wishlist.blade.php | 17 ++ resources/views/auth/login-code.blade.php | 67 +++++++ resources/views/auth/login.blade.php | 52 +++++ resources/views/checkout/page.blade.php | 2 - resources/views/components/header.blade.php | 19 ++ .../views/components/order-status.blade.php | 13 ++ resources/views/components/ui/icon.blade.php | 8 +- .../components/ui/product-card.blade.php | 7 + .../views/components/ui/select.blade.php | 17 +- .../views/components/ui/status.blade.php | 11 + .../components/wishlist-button.blade.php | 41 ++++ .../views/emails/email-change-code.blade.php | 29 +++ .../emails/email-changed-notice.blade.php | 21 ++ resources/views/layouts/account.blade.php | 71 +++++++ resources/views/product/show.blade.php | 6 +- .../vendor/core/auth/mail/user-otp.blade.php | 32 +++ resources/views/wishlist/guest.blade.php | 29 +++ resources/views/wishlist/list.blade.php | 38 ++++ routes/web.php | 62 ++++++ 42 files changed, 1972 insertions(+), 15 deletions(-) create mode 100644 app/Http/Controllers/Account/AccountController.php create mode 100644 app/Http/Controllers/Account/EmailController.php create mode 100644 app/Http/Controllers/Account/OrderController.php create mode 100644 app/Http/Controllers/Auth/LoginController.php create mode 100644 app/Http/Controllers/WishlistController.php create mode 100644 app/Listeners/ClaimGuestOrdersOnLogin.php create mode 100644 app/Listeners/MergeGuestWishlistOnLogin.php create mode 100644 app/Mail/EmailChangeCodeMail.php create mode 100644 app/Mail/EmailChangedNoticeMail.php create mode 100644 app/Models/WishlistItem.php create mode 100644 app/Services/GuestOrderClaimer.php create mode 100644 app/Services/Wishlist.php create mode 100644 database/migrations/2026_09_24_000001_create_wishlist_items_table.php create mode 100644 resources/js/stimulus/wishlist-controller.js create mode 100644 resources/views/account/email-code.blade.php create mode 100644 resources/views/account/email.blade.php create mode 100644 resources/views/account/orders/index.blade.php create mode 100644 resources/views/account/orders/show.blade.php create mode 100644 resources/views/account/show.blade.php create mode 100644 resources/views/account/wishlist.blade.php create mode 100644 resources/views/auth/login-code.blade.php create mode 100644 resources/views/auth/login.blade.php create mode 100644 resources/views/components/order-status.blade.php create mode 100644 resources/views/components/ui/status.blade.php create mode 100644 resources/views/components/wishlist-button.blade.php create mode 100644 resources/views/emails/email-change-code.blade.php create mode 100644 resources/views/emails/email-changed-notice.blade.php create mode 100644 resources/views/layouts/account.blade.php create mode 100644 resources/views/vendor/core/auth/mail/user-otp.blade.php create mode 100644 resources/views/wishlist/guest.blade.php create mode 100644 resources/views/wishlist/list.blade.php diff --git a/app/Http/Controllers/Account/AccountController.php b/app/Http/Controllers/Account/AccountController.php new file mode 100644 index 0000000..8467aac --- /dev/null +++ b/app/Http/Controllers/Account/AccountController.php @@ -0,0 +1,136 @@ +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(); + } +} diff --git a/app/Http/Controllers/Account/EmailController.php b/app/Http/Controllers/Account/EmailController.php new file mode 100644 index 0000000..05bd3b1 --- /dev/null +++ b/app/Http/Controllers/Account/EmailController.php @@ -0,0 +1,169 @@ + $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; + } +} diff --git a/app/Http/Controllers/Account/OrderController.php b/app/Http/Controllers/Account/OrderController.php new file mode 100644 index 0000000..a8802ae --- /dev/null +++ b/app/Http/Controllers/Account/OrderController.php @@ -0,0 +1,50 @@ + $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'), + ]); + } +} diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php new file mode 100644 index 0000000..3517283 --- /dev/null +++ b/app/Http/Controllers/Auth/LoginController.php @@ -0,0 +1,118 @@ +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'); + } +} diff --git a/app/Http/Controllers/Checkout/CheckoutController.php b/app/Http/Controllers/Checkout/CheckoutController.php index 878855a..c943ce0 100644 --- a/app/Http/Controllers/Checkout/CheckoutController.php +++ b/app/Http/Controllers/Checkout/CheckoutController.php @@ -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, diff --git a/app/Http/Controllers/WishlistController.php b/app/Http/Controllers/WishlistController.php new file mode 100644 index 0000000..5dcbc8f --- /dev/null +++ b/app/Http/Controllers/WishlistController.php @@ -0,0 +1,72 @@ + $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(); + } +} diff --git a/app/Listeners/ClaimGuestOrdersOnLogin.php b/app/Listeners/ClaimGuestOrdersOnLogin.php new file mode 100644 index 0000000..8f9b056 --- /dev/null +++ b/app/Listeners/ClaimGuestOrdersOnLogin.php @@ -0,0 +1,22 @@ +claimer->claim($event->user); + } +} diff --git a/app/Listeners/MergeGuestWishlistOnLogin.php b/app/Listeners/MergeGuestWishlistOnLogin.php new file mode 100644 index 0000000..5d1b90d --- /dev/null +++ b/app/Listeners/MergeGuestWishlistOnLogin.php @@ -0,0 +1,23 @@ +wishlist->mergeGuestInto($event->user); + } +} diff --git a/app/Mail/EmailChangeCodeMail.php b/app/Mail/EmailChangeCodeMail.php new file mode 100644 index 0000000..7a0af63 --- /dev/null +++ b/app/Mail/EmailChangeCodeMail.php @@ -0,0 +1,27 @@ +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'); + } +} diff --git a/app/Models/WishlistItem.php b/app/Models/WishlistItem.php new file mode 100644 index 0000000..2276309 --- /dev/null +++ b/app/Models/WishlistItem.php @@ -0,0 +1,14 @@ +app->scoped(\App\Services\Wishlist::class); + } + public function boot(): void { Telemetry::optOut(); diff --git a/app/Services/GuestOrderClaimer.php b/app/Services/GuestOrderClaimer.php new file mode 100644 index 0000000..6988fe3 --- /dev/null +++ b/app/Services/GuestOrderClaimer.php @@ -0,0 +1,37 @@ +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, + ]); + } +} diff --git a/app/Services/Wishlist.php b/app/Services/Wishlist.php new file mode 100644 index 0000000..e9d8c95 --- /dev/null +++ b/app/Services/Wishlist.php @@ -0,0 +1,126 @@ +|null ids for this request, including a toggle just made */ + private ?array $guestIds = null; + + /** @return array 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 */ + 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 $ids */ + private function storeGuestIds(array $ids): void + { + $this->guestIds = $ids; + + Cookie::queue(self::COOKIE, json_encode($ids), self::COOKIE_MINUTES); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..2957643 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -11,7 +11,18 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + // Laravel's priority list would otherwise run `auth` before core's + // `locale` middleware, so the redirects below would build URLs before + // URL::defaults(['locale' => …]) is set, throwing a missing-parameter error. + $middleware->prependToPriorityList( + before: \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class, + prepend: \Modules\Core\Localization\Middleware\LocaleMiddleware::class, + ); + + // Both resolve inside the {locale} group, after the `locale` middleware + // has set URL::defaults(['locale' => …]), so route() needs no locale arg. + $middleware->redirectGuestsTo(fn () => route('login')); + $middleware->redirectUsersTo(fn () => route('home')); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/database/migrations/2026_09_24_000001_create_wishlist_items_table.php b/database/migrations/2026_09_24_000001_create_wishlist_items_table.php new file mode 100644 index 0000000..d830309 --- /dev/null +++ b/database/migrations/2026_09_24_000001_create_wishlist_items_table.php @@ -0,0 +1,25 @@ +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'); + } +}; diff --git a/resources/js/stimulus/index.js b/resources/js/stimulus/index.js index de8ce1c..669dd2a 100644 --- a/resources/js/stimulus/index.js +++ b/resources/js/stimulus/index.js @@ -19,6 +19,7 @@ import RangeSliderController from './range-slider-controller' import StarRatingController from './star-rating-controller' import TabLinkController from './tab-link-controller' import TabsController from './tabs-controller' +import WishlistController from './wishlist-controller' export function registerControllers(application) { application.register('appear', AppearController) @@ -37,4 +38,5 @@ export function registerControllers(application) { application.register('star-rating', StarRatingController) application.register('tab-link', TabLinkController) application.register('tabs', TabsController) + application.register('wishlist', WishlistController) } diff --git a/resources/js/stimulus/wishlist-controller.js b/resources/js/stimulus/wishlist-controller.js new file mode 100644 index 0000000..160e977 --- /dev/null +++ b/resources/js/stimulus/wishlist-controller.js @@ -0,0 +1,42 @@ +import { Controller } from '@hotwired/stimulus' + +// Heart toggle (x-wishlist-button). Posts the form with fetch and reflects the +// server's answer on aria-pressed, which the CSS uses to swap the outline and +// filled heart. If the request fails, falls back to a normal form submit. +export default class extends Controller { + static targets = ['button', 'status'] + + static values = { + addLabel: String, + removeLabel: String, + addedMessage: String, + removedMessage: String, + } + + async toggle(event) { + event.preventDefault() + + if (this.busy) return + this.busy = true + + try { + const response = await fetch(this.element.action, { + method: 'POST', + headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + body: new FormData(this.element), + }) + + if (!response.ok) throw new Error(`Wishlist toggle failed: ${response.status}`) + + const { active } = await response.json() + + this.buttonTarget.setAttribute('aria-pressed', active ? 'true' : 'false') + this.buttonTarget.setAttribute('aria-label', active ? this.removeLabelValue : this.addLabelValue) + this.statusTarget.textContent = active ? this.addedMessageValue : this.removedMessageValue + } catch { + this.element.submit() + } finally { + this.busy = false + } + } +} diff --git a/resources/views/account/email-code.blade.php b/resources/views/account/email-code.blade.php new file mode 100644 index 0000000..1e4ff5d --- /dev/null +++ b/resources/views/account/email-code.blade.php @@ -0,0 +1,65 @@ +@extends('layouts.account') + +@section('title', __('storefront.auth.enter_code')) + +@section('account') + +
+ +
+

+ {{ __('storefront.auth.enter_code') }} +

+ +

+ {{ __('storefront.auth.code_sent_to') }} {{ $email }} +

+ + +
+ +
+ @csrf + + + + + +
+ {{ __('storefront.account.email_confirm') }} +
+
+ +
+
+ @csrf + +
+ + + {{ __('storefront.auth.change_email') }} + +
+ +
+ +@endsection diff --git a/resources/views/account/email.blade.php b/resources/views/account/email.blade.php new file mode 100644 index 0000000..05326b3 --- /dev/null +++ b/resources/views/account/email.blade.php @@ -0,0 +1,53 @@ +@extends('layouts.account') + +@section('title', __('storefront.account.email_change_heading')) + +@section('account') + +
+ +
+

+ {{ __('storefront.account.email_change_heading') }} +

+ +

+ {{ __('storefront.account.email_current') }} {{ $user->email }} +

+
+ +
+ @csrf + + + + + +
+ {{ __('storefront.auth.send_code') }} + + + {{ __('storefront.account.delete_cancel') }} + +
+
+ +
+ +@endsection diff --git a/resources/views/account/orders/index.blade.php b/resources/views/account/orders/index.blade.php new file mode 100644 index 0000000..803d58d --- /dev/null +++ b/resources/views/account/orders/index.blade.php @@ -0,0 +1,67 @@ +@extends('layouts.account') + +@section('title', __('storefront.account.nav_orders')) + +@section('account') + +
+ +

+ {{ __('storefront.account.nav_orders') }} +

+ + @if ($orders->isEmpty()) +
+

{{ __('storefront.orders.empty') }}

+ {{ __('storefront.orders.shop_now') }} +
+ @else + {{-- A list, not a table, so each order can stack on mobile. The column + headings are visual only; each cell carries its own sr-only label. --}} +
+ + +
    + @foreach ($orders as $order) +
  • + + {{ __('storefront.orders.date') }}: + {{ $order->placed_at->format('d/m/Y') }} + + + {{ __('storefront.orders.number') }}: + #{{ $order->reference }} + + + {{ __('storefront.orders.status') }}: + + + + {{ __('storefront.orders.total') }}: + {{ $order->total?->formatted() }} + + + {{ __('storefront.orders.view') }} + + +
  • + @endforeach +
+
+ + + @endif + +
+ +@endsection diff --git a/resources/views/account/orders/show.blade.php b/resources/views/account/orders/show.blade.php new file mode 100644 index 0000000..dd87c84 --- /dev/null +++ b/resources/views/account/orders/show.blade.php @@ -0,0 +1,151 @@ +@extends('layouts.account') + +@section('title', __('storefront.orders.order_title', ['number' => $order->reference])) + +@php + $productLines = $order->lines->where('type', '!=', 'shipping'); + $shippingLine = $order->lines->firstWhere('type', 'shipping'); +@endphp + +@section('account') + +
+ +
+ + + {{ __('storefront.orders.back') }} + + +

+ {{ __('storefront.orders.order_title', ['number' => $order->reference]) }} +

+
+ + {{-- Summary --}} +
+
+
{{ __('storefront.orders.date') }}
+
{{ $order->placed_at->format('d/m/Y') }}
+
+ +
+
{{ __('storefront.orders.status') }}
+
+
+ + @if ($paymentMethodName) +
+
{{ __('storefront.orders.payment') }}
+
{{ $paymentMethodName }}
+
+ @endif + + @if ($shippingLine) +
+
{{ __('storefront.orders.shipping_method') }}
+
{{ $shippingLine->description }}
+
+ @endif + + @foreach ($shipments as $shipment) +
+
{{ __('storefront.orders.tracking') }}
+
{{ $shipment->tracking_reference }}
+
+ @endforeach +
+ + {{-- Items + totals --}} +
+

{{ __('storefront.orders.items') }}

+ +
    + @foreach ($productLines as $line) +
  • +
    + @if ($thumb = $line->purchasable?->getThumbnailImage()) + + @endif +
    + +
    +

    + {{ $line->description }} + × {{ $line->quantity }} +

    + + @if ($line->option) +

    {{ $line->option }}

    + @endif + + @include('checkout::partials.line-custom-fields', ['line' => $line]) +
    + +

    {{ $line->sub_total?->formatted() }}

    +
  • + @endforeach +
+ +
+
+
{{ __('storefront.orders.subtotal') }}
+
{{ $order->sub_total?->formatted() }}
+
+ + @if ($order->discount_total?->value > 0) +
+
{{ __('storefront.orders.discount') }}
+
−{{ $order->discount_total->formatted() }}
+
+ @endif + +
+
{{ __('storefront.orders.shipping') }}
+
{{ $order->shipping_total?->formatted() }}
+
+ + @if ($order->tax_total?->value > 0) +
+
{{ __('storefront.orders.tax') }}
+
{{ $order->tax_total->formatted() }}
+
+ @endif + +
+
{{ __('storefront.orders.total') }}
+
{{ $order->total?->formatted() }}
+
+
+
+ + {{-- Addresses --}} +
+ @foreach ([ + 'shipping_to' => $order->shippingAddress, + 'billing' => $order->billingAddress, + ] as $heading => $address) + @if ($address) +
+

{{ __("storefront.orders.{$heading}") }}

+ +
+ {{ trim($address->first_name.' '.$address->last_name) }} + @if ($address->company_name){{ $address->company_name }}@endif + @if ($address->tax_identifier){{ __('storefront.account.tax_identifier') }}: {{ $address->tax_identifier }}@endif + {{ $address->line_one }} + @if ($address->line_two){{ $address->line_two }}@endif + {{ trim($address->postcode.' '.$address->city) }} + @if ($address->state) + {{ Lang::has("core::states.{$address->state}") ? __("core::states.{$address->state}") : $address->state }} + @endif + @if ($address->contact_phone){{ $address->contact_phone }}@endif +
+
+ @endif + @endforeach +
+ +
+ +@endsection diff --git a/resources/views/account/show.blade.php b/resources/views/account/show.blade.php new file mode 100644 index 0000000..4e8633d --- /dev/null +++ b/resources/views/account/show.blade.php @@ -0,0 +1,188 @@ +@extends('layouts.account') + +@section('title', __('storefront.account.nav_profile')) + +@php + $wantsInvoice = (bool) old('invoice', filled($customer?->company_name) || filled($customer?->tax_identifier)); + + $regionOptions = $regions->map(fn ($region) => [ + 'value' => $region->name, + 'label' => Lang::has("core::states.{$region->name}") ? __("core::states.{$region->name}") : $region->name, + ])->all(); + +@endphp + +@section('account') + +
+ +
+

+ {{ __('storefront.account.nav_profile') }} +

+ + +
+ + {{-- Email: the login itself, so it's changed through its own verified flow --}} +
+

{{ __('storefront.account.email_heading') }}

+ + +
+ +
+ @csrf + @method('PUT') + + {{-- Details --}} +
+

{{ __('storefront.account.details_heading') }}

+ +
+ + + + + + + +
+ + {{-- Invoice details, revealed by the checkbox (CSS :has(), no JS). Not + `required` in HTML: a hidden required field would block submit, so + the server requires them only when the box is ticked. --}} +
+ + + + +
+
+ + {{-- Address: one, used as both shipping and billing default --}} +
+

{{ __('storefront.account.address_heading') }}

+ + + + + +
+ + + + + + + +
+ +
+ + + + + + + +
+
+ +
+ {{ __('storefront.account.save') }} +
+
+ + {{-- Delete account --}} +
+

{{ __('storefront.account.delete_heading') }}

+ +

{{ __('storefront.account.delete_text') }}

+ +
+ {{ __('storefront.account.delete') }} +
+ + +
+ +
+ +@endsection diff --git a/resources/views/account/wishlist.blade.php b/resources/views/account/wishlist.blade.php new file mode 100644 index 0000000..32ea261 --- /dev/null +++ b/resources/views/account/wishlist.blade.php @@ -0,0 +1,17 @@ +@extends('layouts.account') + +@section('title', __('storefront.account.nav_wishlist')) + +@section('account') + +
+ +

+ {{ __('storefront.account.nav_wishlist') }} +

+ + @include('wishlist.list') + +
+ +@endsection diff --git a/resources/views/auth/login-code.blade.php b/resources/views/auth/login-code.blade.php new file mode 100644 index 0000000..fa57212 --- /dev/null +++ b/resources/views/auth/login-code.blade.php @@ -0,0 +1,67 @@ +@extends('layouts.app') + +@section('title', __('storefront.auth.enter_code')) + +@push('seo') + +@endpush + +@section('content') + +
+ +

+ {{ __('storefront.auth.enter_code') }} +

+ +

+ {{ __('storefront.auth.code_sent_to') }} {{ $email }} +

+ + + +
+ @csrf + + + + + +
+ {{ __('storefront.auth.login') }} +
+
+ +
+
+ @csrf + +
+ + + {{ __('storefront.auth.change_email') }} + +
+ +
+ +@endsection diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..e35a5bf --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,52 @@ +@extends('layouts.app') + +@section('title', __('storefront.auth.login')) + +@push('seo') + +@endpush + +@section('content') + +
+ +

+ {{ __('storefront.auth.login') }} +

+ + {{-- Unescaped: the label holds a
(edited in Filament › Language Lines, staff only). --}} +

{!! __('storefront.auth.login_intro') !!}

+ + {{-- e.g. "account deletion scheduled", after AccountController::destroy() --}} + + +
+ @csrf + + + + + +
+ {{ __('storefront.auth.send_code') }} +
+
+ +
+ +@endsection diff --git a/resources/views/checkout/page.blade.php b/resources/views/checkout/page.blade.php index 1d0fc32..2e3688a 100644 --- a/resources/views/checkout/page.blade.php +++ b/resources/views/checkout/page.blade.php @@ -134,7 +134,6 @@ class="bbk-checkout-tab" -
@@ -177,7 +176,6 @@ class="bbk-checkout-tab" -
diff --git a/resources/views/components/header.blade.php b/resources/views/components/header.blade.php index f88d407..9e2164c 100644 --- a/resources/views/components/header.blade.php +++ b/resources/views/components/header.blade.php @@ -41,6 +41,25 @@ @endforeach + {{-- Wishlist — the account page, or the cookie-based guest page --}} + routeIs('account.wishlist', 'wishlist')) aria-current="page" @endif + > + + + + {{-- Account — the account page, or login for guests --}} + + + + {{-- Cart — opens the checkout module's drawer, no separate cart page --}}