From 75386c354a67b1a5b84a5e7dc3b603db78ec6cbd Mon Sep 17 00:00:00 2001 From: Vasyka Date: Tue, 2 Jun 2026 19:43:39 +0000 Subject: [PATCH] feat: shop customer accounts (register/login + order history) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema: - shop_customers (company_id, name, phone unique-per-tenant, email, password, client_id auto-linked, last_login_at) - online_orders.shop_customer_id nullable FK Auth: - New 'shop' guard (session driver, shop_customers provider) in config/auth.php - ShopCustomer Authenticatable with hashed password cast and BelongsToTenant global scope — login attempts naturally scoped to current tenant subdomain Flow: - ShopAuthController: register / login / logout / account - Register auto-links to existing Client by phone match - /shop/account: order history (only the logged customer's orders) + profile - Checkout prefills name/phone/email from logged customer + sets shop_customer_id (and client_id from auto-link) on the placed order - Layout nav switches between Login/Register and "👤 Name + Ieșire" Tests (8 new): - register creates customer + auto-login - register auto-links existing Client by phone - duplicate phone rejected - login validates credentials - /account requires auth (redirects to /shop/login) - /account lists only the logged customer's orders - checkout attaches shop_customer_id - customers tenant-isolated Full suite: 117 passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Http/Controllers/ShopAuthController.php | 126 ++++++++++++ app/Http/Controllers/ShopController.php | 8 +- app/Models/Tenant/OnlineOrder.php | 7 +- app/Models/Tenant/ShopCustomer.php | 42 ++++ config/auth.php | 10 + ...026_06_02_120000_create_shop_customers.php | 43 +++++ resources/views/shop/account.blade.php | 47 +++++ resources/views/shop/auth/login.blade.php | 31 +++ resources/views/shop/auth/register.blade.php | 40 ++++ resources/views/shop/checkout.blade.php | 6 +- resources/views/shop/layout.blade.php | 9 + routes/web.php | 10 + tests/Feature/ShopAccountTest.php | 182 ++++++++++++++++++ 13 files changed, 556 insertions(+), 5 deletions(-) create mode 100644 app/Http/Controllers/ShopAuthController.php create mode 100644 app/Models/Tenant/ShopCustomer.php create mode 100644 database/migrations/2026_06_02_120000_create_shop_customers.php create mode 100644 resources/views/shop/account.blade.php create mode 100644 resources/views/shop/auth/login.blade.php create mode 100644 resources/views/shop/auth/register.blade.php create mode 100644 tests/Feature/ShopAccountTest.php diff --git a/app/Http/Controllers/ShopAuthController.php b/app/Http/Controllers/ShopAuthController.php new file mode 100644 index 0000000..781b6e2 --- /dev/null +++ b/app/Http/Controllers/ShopAuthController.php @@ -0,0 +1,126 @@ +current(); + if (! $tenant || ! data_get($tenant->settings, 'shop.enabled')) { + throw new NotFoundHttpException('Magazinul online nu este activ.'); + } + return $tenant; + } + + public function showRegister() + { + $tenant = $this->tenantOrFail(); + if (Auth::guard('shop')->check()) return redirect('/shop/account'); + return view('shop.auth.register', ['tenant' => $tenant, 'cartCount' => $this->cartCount()]); + } + + public function register(Request $request) + { + $tenant = $this->tenantOrFail(); + $data = $request->validate([ + 'name' => 'required|string|max:160', + 'phone' => 'required|string|max:40', + 'email' => 'nullable|email|max:160', + 'password' => 'required|string|min:6|confirmed', + ]); + + // Unique per tenant (handled by composite index, but check for nicer error). + if (ShopCustomer::where('phone', $data['phone'])->exists()) { + return back()->withErrors(['phone' => 'Există deja un cont cu acest telefon.'])->withInput(); + } + + // Auto-link to existing Client by phone if present. + $client = Client::where('phone', $data['phone'])->first(); + + $customer = ShopCustomer::create([ + 'client_id' => $client?->id, + 'name' => $data['name'], + 'phone' => $data['phone'], + 'email' => $data['email'] ?? null, + 'password' => $data['password'], // hashed by cast + ]); + + event(new Registered($customer)); + Auth::guard('shop')->login($customer, remember: true); + $customer->forceFill(['last_login_at' => now()])->save(); + + return redirect('/shop/account'); + } + + public function showLogin() + { + $tenant = $this->tenantOrFail(); + if (Auth::guard('shop')->check()) return redirect('/shop/account'); + return view('shop.auth.login', ['tenant' => $tenant, 'cartCount' => $this->cartCount()]); + } + + public function login(Request $request) + { + $tenant = $this->tenantOrFail(); + $data = $request->validate([ + 'phone' => 'required|string|max:40', + 'password' => 'required|string', + ]); + + $ok = Auth::guard('shop')->attempt( + ['phone' => $data['phone'], 'password' => $data['password']], + remember: true + ); + if (! $ok) { + return back()->withErrors(['phone' => 'Telefon sau parolă incorecte.'])->withInput(); + } + + $request->session()->regenerate(); + Auth::guard('shop')->user()?->forceFill(['last_login_at' => now()])->save(); + return redirect()->intended('/shop/account'); + } + + public function logout(Request $request) + { + Auth::guard('shop')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + return redirect('/shop'); + } + + public function account() + { + $tenant = $this->tenantOrFail(); + $customer = Auth::guard('shop')->user(); + if (! $customer) return redirect('/shop/login'); + + $orders = $customer->orders() + ->latest('created_at') + ->limit(50) + ->get(); + + return view('shop.account', [ + 'tenant' => $tenant, + 'customer' => $customer, + 'orders' => $orders, + 'cartCount' => $this->cartCount(), + ]); + } + + private function cartCount(): int + { + $tenant = app(TenantManager::class)->current(); + $cart = (array) session('shop_cart_' . ($tenant?->id ?? '0'), []); + return (int) collect($cart)->sum('qty'); + } +} diff --git a/app/Http/Controllers/ShopController.php b/app/Http/Controllers/ShopController.php index cf78637..a06e067 100644 --- a/app/Http/Controllers/ShopController.php +++ b/app/Http/Controllers/ShopController.php @@ -155,11 +155,13 @@ class ShopController extends Controller if (empty($cart)) return redirect('/shop'); $subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']); + $customer = \Illuminate\Support\Facades\Auth::guard('shop')->user(); return view('shop.checkout', [ 'tenant' => $tenant, 'cart' => $cart, 'subtotal' => $subtotal, + 'customer' => $customer, 'deliveryOptions' => (array) data_get($tenant->settings, 'shop.delivery_methods', ['pickup']), 'cartCount' => $this->cartCount(), ]); @@ -188,9 +190,13 @@ class ShopController extends Controller $deliveryFee = ($freeOver > 0 && $subtotal >= $freeOver) ? 0.0 : $fee; } - $order = DB::transaction(function () use ($tenant, $cart, $data, $deliveryFee) { + $shopCustomer = \Illuminate\Support\Facades\Auth::guard('shop')->user(); + + $order = DB::transaction(function () use ($tenant, $cart, $data, $deliveryFee, $shopCustomer) { $order = OnlineOrder::create([ 'number' => OnlineOrder::generateNumber($tenant->id), + 'shop_customer_id' => $shopCustomer?->id, + 'client_id' => $shopCustomer?->client_id, 'customer_name' => $data['customer_name'], 'customer_phone' => $data['customer_phone'], 'customer_email' => $data['customer_email'] ?? null, diff --git a/app/Models/Tenant/OnlineOrder.php b/app/Models/Tenant/OnlineOrder.php index 5cb555d..0dc14ab 100644 --- a/app/Models/Tenant/OnlineOrder.php +++ b/app/Models/Tenant/OnlineOrder.php @@ -29,7 +29,7 @@ class OnlineOrder extends Model ]; protected $fillable = [ - 'company_id', 'number', 'tracking_token', 'client_id', + 'company_id', 'number', 'tracking_token', 'client_id', 'shop_customer_id', 'customer_name', 'customer_phone', 'customer_email', 'delivery_method', 'address', 'status', 'subtotal', 'delivery_fee', 'total', 'notes', @@ -51,6 +51,11 @@ class OnlineOrder extends Model return $this->belongsTo(Client::class); } + public function shopCustomer(): BelongsTo + { + return $this->belongsTo(ShopCustomer::class); + } + public function trackingUrl(): string { return url('/shop/order/' . $this->tracking_token); diff --git a/app/Models/Tenant/ShopCustomer.php b/app/Models/Tenant/ShopCustomer.php new file mode 100644 index 0000000..1616dde --- /dev/null +++ b/app/Models/Tenant/ShopCustomer.php @@ -0,0 +1,42 @@ + 'datetime', + 'password' => 'hashed', + ]; + + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function orders(): HasMany + { + return $this->hasMany(OnlineOrder::class); + } + + /** Auth column for Laravel's session guard. */ + public function getAuthIdentifierName() + { + return 'id'; + } +} diff --git a/config/auth.php b/config/auth.php index d9f7be5..0d39ef8 100644 --- a/config/auth.php +++ b/config/auth.php @@ -22,6 +22,12 @@ return [ 'driver' => 'session', 'provider' => 'super_admins', ], + + // Public storefront customer auth (per-tenant). + 'shop' => [ + 'driver' => 'session', + 'provider' => 'shop_customers', + ], ], 'providers' => [ @@ -33,6 +39,10 @@ return [ 'driver' => 'eloquent', 'model' => SuperAdmin::class, ], + 'shop_customers' => [ + 'driver' => 'eloquent', + 'model' => \App\Models\Tenant\ShopCustomer::class, + ], ], 'passwords' => [ diff --git a/database/migrations/2026_06_02_120000_create_shop_customers.php b/database/migrations/2026_06_02_120000_create_shop_customers.php new file mode 100644 index 0000000..d800e5d --- /dev/null +++ b/database/migrations/2026_06_02_120000_create_shop_customers.php @@ -0,0 +1,43 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('client_id')->nullable()->constrained()->nullOnDelete(); + $t->string('name', 160); + $t->string('phone', 40); + $t->string('email', 160)->nullable(); + $t->string('password'); + $t->dateTime('last_login_at')->nullable(); + $t->rememberToken(); + $t->timestamps(); + $t->softDeletes(); + + $t->unique(['company_id', 'phone'], 'shop_customers_company_phone_unique'); + $t->index(['company_id', 'email']); + }); + + Schema::table('online_orders', function (Blueprint $t) { + $t->foreignId('shop_customer_id')->nullable()->after('client_id') + ->constrained()->nullOnDelete(); + $t->index(['company_id', 'shop_customer_id']); + }); + } + + public function down(): void + { + Schema::table('online_orders', function (Blueprint $t) { + $t->dropForeign(['shop_customer_id']); + $t->dropColumn('shop_customer_id'); + }); + Schema::dropIfExists('shop_customers'); + } +}; diff --git a/resources/views/shop/account.blade.php b/resources/views/shop/account.blade.php new file mode 100644 index 0000000..0c527e7 --- /dev/null +++ b/resources/views/shop/account.blade.php @@ -0,0 +1,47 @@ +@extends('shop.layout') +@section('title', 'Contul meu') +@section('content') +@php + $currency = $tenant->settings['currency'] ?? 'MDL'; + $statuses = \App\Models\Tenant\OnlineOrder::STATUSES; +@endphp + +

Salut, {{ $customer->name }}!

+ +
+

Date contact

+

📞 {{ $customer->phone }}

+ @if ($customer->email)

✉️ {{ $customer->email }}

@endif +
+ +

Comenzile mele ({{ $orders->count() }})

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

Nu ai nicio comandă încă.

+ Vezi catalogul +
+@else +
+ + + + + + + + @foreach ($orders as $order) + + + + + + + + + @endforeach + +
Nr.DataArticoleTotalStatus
#{{ $order->number }}{{ $order->created_at->format('d.m.Y') }}{{ $order->items()->count() }}{{ number_format((float) $order->total, 2) }} {{ $currency }}{{ $statuses[$order->status] ?? $order->status }}Detalii →
+
+@endif +@endsection diff --git a/resources/views/shop/auth/login.blade.php b/resources/views/shop/auth/login.blade.php new file mode 100644 index 0000000..e8c0352 --- /dev/null +++ b/resources/views/shop/auth/login.blade.php @@ -0,0 +1,31 @@ +@extends('shop.layout') +@section('title', 'Login') +@section('content') + +
+

Intră în cont

+ + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $e)
  • {{ $e }}
  • @endforeach +
+
+ @endif + +
+ @csrf +
+ +
+
+ +
+ +
+ +

+ Nu ai cont? Înregistrare +

+
+@endsection diff --git a/resources/views/shop/auth/register.blade.php b/resources/views/shop/auth/register.blade.php new file mode 100644 index 0000000..1b776aa --- /dev/null +++ b/resources/views/shop/auth/register.blade.php @@ -0,0 +1,40 @@ +@extends('shop.layout') +@section('title', 'Înregistrare') +@section('content') + +
+

Înregistrare cont

+ + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $e)
  • {{ $e }}
  • @endforeach +
+
+ @endif + +
+ @csrf +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +

+ Ai deja cont? Login +

+
+@endsection diff --git a/resources/views/shop/checkout.blade.php b/resources/views/shop/checkout.blade.php index bb8ea7a..0659802 100644 --- a/resources/views/shop/checkout.blade.php +++ b/resources/views/shop/checkout.blade.php @@ -21,15 +21,15 @@ @csrf
- +
- +
- +
diff --git a/resources/views/shop/layout.blade.php b/resources/views/shop/layout.blade.php index d62d462..007ba87 100644 --- a/resources/views/shop/layout.blade.php +++ b/resources/views/shop/layout.blade.php @@ -72,6 +72,15 @@ 🛒 Coș @if (($cartCount ?? 0) > 0){{ $cartCount }}@endif + @auth('shop') + 👤 {{ Auth::guard('shop')->user()->name }} +
@csrf + +
+ @else + Login + Înregistrare + @endauth
diff --git a/routes/web.php b/routes/web.php index e714c4b..3569a79 100644 --- a/routes/web.php +++ b/routes/web.php @@ -93,6 +93,16 @@ Route::controller(\App\Http\Controllers\ShopController::class)->prefix('shop')-> Route::post('/part/{id}/add', 'addToCart')->where('id', '\d+')->name('shop.cart.add'); }); +// ─── Shop customer auth ──────────────────────────────────────────── +Route::controller(\App\Http\Controllers\ShopAuthController::class)->prefix('shop')->group(function () { + Route::get('/register', 'showRegister')->name('shop.register'); + Route::post('/register', 'register'); + Route::get('/login', 'showLogin')->name('shop.login'); + Route::post('/login', 'login'); + Route::post('/logout', 'logout')->name('shop.logout'); + Route::get('/account', 'account')->name('shop.account'); +}); + // ─── Public WO tracking (no auth, tenant-scoped via subdomain) ────── Route::get('/t/{token}', [\App\Http\Controllers\TrackingController::class, 'show']) ->where('token', '[A-Za-z0-9]{16,32}') diff --git a/tests/Feature/ShopAccountTest.php b/tests/Feature/ShopAccountTest.php new file mode 100644 index 0000000..1e7b26e --- /dev/null +++ b/tests/Feature/ShopAccountTest.php @@ -0,0 +1,182 @@ +makeShopCompany('reg'); + + $resp = $this->post('http://reg.service.mir.md/shop/register', [ + 'name' => 'Ion Pop', + 'phone' => '+37369123456', + 'email' => 'ion@example.com', + 'password' => 'secret123', + 'password_confirmation' => 'secret123', + ]); + + $resp->assertRedirect('/shop/account'); + + $customer = ShopCustomer::where('phone', '+37369123456')->first(); + $this->assertNotNull($customer); + $this->assertEquals('Ion Pop', $customer->name); + $this->assertTrue(Hash::check('secret123', $customer->password)); + $this->assertTrue(Auth::guard('shop')->check()); + } + + public function test_register_auto_links_existing_client_by_phone(): void + { + $this->makeShopCompany('link'); + $client = Client::create([ + 'name' => 'Existing', 'phone' => '+37369999999', + 'type' => 'individual', 'status' => 'active', + ]); + + $this->post('http://link.service.mir.md/shop/register', [ + 'name' => 'Existing', 'phone' => '+37369999999', + 'password' => 'pw1234', 'password_confirmation' => 'pw1234', + ])->assertRedirect(); + + $cust = ShopCustomer::first(); + $this->assertEquals($client->id, $cust->client_id); + } + + public function test_register_rejects_duplicate_phone(): void + { + $this->makeShopCompany('dup'); + ShopCustomer::create([ + 'name' => 'X', 'phone' => '+37377777777', 'password' => Hash::make('pw'), + ]); + + $this->post('http://dup.service.mir.md/shop/register', [ + 'name' => 'Y', 'phone' => '+37377777777', + 'password' => 'pw1234', 'password_confirmation' => 'pw1234', + ])->assertSessionHasErrors('phone'); + + $this->assertEquals(1, ShopCustomer::count()); + } + + public function test_login_validates_credentials(): void + { + $this->makeShopCompany('lg'); + ShopCustomer::create([ + 'name' => 'L', 'phone' => '+37360123456', + 'password' => Hash::make('correct'), + ]); + + $this->post('http://lg.service.mir.md/shop/login', [ + 'phone' => '+37360123456', 'password' => 'wrong', + ])->assertSessionHasErrors(); + $this->assertFalse(Auth::guard('shop')->check()); + + $this->post('http://lg.service.mir.md/shop/login', [ + 'phone' => '+37360123456', 'password' => 'correct', + ])->assertRedirect(); + $this->assertTrue(Auth::guard('shop')->check()); + } + + public function test_account_page_requires_auth(): void + { + $this->makeShopCompany('acc'); + $this->get('http://acc.service.mir.md/shop/account')->assertRedirect('/shop/login'); + } + + public function test_account_page_lists_only_my_orders(): void + { + $ctx = $this->makeShopCompany('myorders'); + $part = Part::create(['name' => 'X', 'sell_price' => 100, 'qty' => 5, 'unit' => 'buc', + 'is_active' => true, 'is_published' => true]); + + $me = ShopCustomer::create([ + 'name' => 'Me', 'phone' => '+37300000001', + 'password' => Hash::make('pw'), + ]); + $other = ShopCustomer::create([ + 'name' => 'Other', 'phone' => '+37300000002', + 'password' => Hash::make('pw'), + ]); + + $myOrder = OnlineOrder::create([ + 'number' => OnlineOrder::generateNumber($ctx['company']->id), + 'shop_customer_id' => $me->id, + 'customer_name' => 'Me', 'customer_phone' => '+37300000001', + 'delivery_method' => 'pickup', 'status' => 'new', + ]); + OnlineOrder::create([ + 'number' => OnlineOrder::generateNumber($ctx['company']->id), + 'shop_customer_id' => $other->id, + 'customer_name' => 'Other', 'customer_phone' => '+37300000002', + 'delivery_method' => 'pickup', 'status' => 'new', + ]); + + Auth::guard('shop')->login($me); + $resp = $this->get('http://myorders.service.mir.md/shop/account'); + $resp->assertOk(); + $resp->assertSee('#' . $myOrder->number); + $resp->assertSee('Comenzile mele (1)'); + } + + public function test_checkout_attaches_shop_customer_id_when_logged_in(): void + { + $ctx = $this->makeShopCompany('co'); + $part = Part::create(['name' => 'Plăcuțe', 'sell_price' => 250, 'qty' => 5, + 'unit' => 'buc', 'is_active' => true, 'is_published' => true]); + + $customer = ShopCustomer::create([ + 'name' => 'CO User', 'phone' => '+37345555555', + 'password' => Hash::make('pw'), + ]); + Auth::guard('shop')->login($customer); + + $base = 'http://co.service.mir.md/shop'; + $this->post("$base/part/{$part->id}/add", ['qty' => 1])->assertRedirect(); + + $this->post("$base/checkout", [ + 'customer_name' => $customer->name, + 'customer_phone' => $customer->phone, + 'delivery_method' => 'pickup', + ])->assertRedirect(); + + $order = OnlineOrder::first(); + $this->assertEquals($customer->id, $order->shop_customer_id); + } + + public function test_customers_isolated_per_tenant(): void + { + $this->makeShopCompany('a'); + ShopCustomer::create([ + 'name' => 'A Cust', 'phone' => '+37311111111', + 'password' => Hash::make('pw'), + ]); + + $this->makeShopCompany('b'); + $this->assertEquals(0, ShopCustomer::count()); + } + + private function makeShopCompany(string $slug): array + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => $slug, + 'name' => ucfirst($slug), 'status' => 'active', + 'settings' => ['shop' => ['enabled' => true, 'delivery_methods' => ['pickup']]], + ]); + app(TenantManager::class)->setCurrent($company); + return compact('company'); + } +}