feat: shop customer accounts (register/login + order history)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 19:43:39 +00:00
parent dfb92bf5e2
commit 75386c354a
13 changed files with 556 additions and 5 deletions
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tenant\Client;
use App\Models\Tenant\ShopCustomer;
use App\Tenancy\TenantManager;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ShopAuthController extends Controller
{
private function tenantOrFail()
{
$tenant = app(TenantManager::class)->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');
}
}
+7 -1
View File
@@ -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,
+6 -1
View File
@@ -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);
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class ShopCustomer extends Authenticatable
{
use BelongsToTenant, Notifiable, SoftDeletes;
protected $fillable = [
'company_id', 'client_id', 'name', 'phone', 'email', 'password', 'last_login_at',
];
protected $hidden = ['password', 'remember_token'];
protected $casts = [
'last_login_at' => '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';
}
}
+10
View File
@@ -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' => [
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shop_customers', function (Blueprint $t) {
$t->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');
}
};
+47
View File
@@ -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
<h1 style="font-size:22px;margin-bottom:16px;">Salut, {{ $customer->name }}!</h1>
<div class="card" style="margin-bottom:16px;">
<h3 style="font-size:15px;margin-bottom:10px;">Date contact</h3>
<p class="muted">📞 {{ $customer->phone }}</p>
@if ($customer->email)<p class="muted">✉️ {{ $customer->email }}</p>@endif
</div>
<h2 style="font-size:18px;margin-bottom:12px;">Comenzile mele ({{ $orders->count() }})</h2>
@if ($orders->isEmpty())
<div class="card" style="text-align:center;padding:32px;">
<p class="muted">Nu ai nicio comandă încă.</p>
<a class="btn" href="/shop" style="margin-top:12px;">Vezi catalogul</a>
</div>
@else
<div class="card">
<table class="cart">
<thead>
<tr>
<th>Nr.</th><th>Data</th><th>Articole</th><th class="r">Total</th><th>Status</th><th></th>
</tr>
</thead>
<tbody>
@foreach ($orders as $order)
<tr>
<td><strong>#{{ $order->number }}</strong></td>
<td>{{ $order->created_at->format('d.m.Y') }}</td>
<td>{{ $order->items()->count() }}</td>
<td class="r">{{ number_format((float) $order->total, 2) }} {{ $currency }}</td>
<td><span class="status-pill" style="font-size:11px;">{{ $statuses[$order->status] ?? $order->status }}</span></td>
<td><a href="{{ $order->trackingUrl() }}" class="muted" style="text-decoration:underline;">Detalii </a></td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@endsection
+31
View File
@@ -0,0 +1,31 @@
@extends('shop.layout')
@section('title', 'Login')
@section('content')
<div style="max-width:380px;margin:0 auto;">
<h1 style="font-size:22px;margin-bottom:16px;">Intră în cont</h1>
@if ($errors->any())
<div class="card" style="border-color:#fca5a5;background:#fef2f2;margin-bottom:14px;">
<ul style="margin:0;padding-left:18px;color:#991b1b;font-size:14px;">
@foreach ($errors->all() as $e)<li>{{ $e }}</li>@endforeach
</ul>
</div>
@endif
<form method="POST" action="/shop/login" class="card">
@csrf
<div class="field"><label>Telefon *</label>
<input type="text" name="phone" value="{{ old('phone') }}" required placeholder="+373…">
</div>
<div class="field"><label>Parolă *</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn block">Intră</button>
</form>
<p class="muted" style="text-align:center;margin-top:12px;">
Nu ai cont? <a href="/shop/register" style="color:inherit;text-decoration:underline;">Înregistrare</a>
</p>
</div>
@endsection
@@ -0,0 +1,40 @@
@extends('shop.layout')
@section('title', 'Înregistrare')
@section('content')
<div style="max-width:420px;margin:0 auto;">
<h1 style="font-size:22px;margin-bottom:16px;">Înregistrare cont</h1>
@if ($errors->any())
<div class="card" style="border-color:#fca5a5;background:#fef2f2;margin-bottom:14px;">
<ul style="margin:0;padding-left:18px;color:#991b1b;font-size:14px;">
@foreach ($errors->all() as $e)<li>{{ $e }}</li>@endforeach
</ul>
</div>
@endif
<form method="POST" action="/shop/register" class="card">
@csrf
<div class="field"><label>Nume *</label>
<input type="text" name="name" value="{{ old('name') }}" required>
</div>
<div class="field"><label>Telefon *</label>
<input type="text" name="phone" value="{{ old('phone') }}" required placeholder="+373…">
</div>
<div class="field"><label>Email</label>
<input type="email" name="email" value="{{ old('email') }}">
</div>
<div class="field"><label>Parolă *</label>
<input type="password" name="password" required minlength="6">
</div>
<div class="field"><label>Confirmă parola *</label>
<input type="password" name="password_confirmation" required minlength="6">
</div>
<button type="submit" class="btn block">Creează cont</button>
</form>
<p class="muted" style="text-align:center;margin-top:12px;">
Ai deja cont? <a href="/shop/login" style="color:inherit;text-decoration:underline;">Login</a>
</p>
</div>
@endsection
+3 -3
View File
@@ -21,15 +21,15 @@
@csrf
<div class="field">
<label>Nume complet *</label>
<input type="text" name="customer_name" value="{{ old('customer_name') }}" required>
<input type="text" name="customer_name" value="{{ old('customer_name', ($customer ?? null)?->name) }}" required>
</div>
<div class="field">
<label>Telefon *</label>
<input type="text" name="customer_phone" value="{{ old('customer_phone') }}" required placeholder="+373…">
<input type="text" name="customer_phone" value="{{ old('customer_phone', ($customer ?? null)?->phone) }}" required placeholder="+373…">
</div>
<div class="field">
<label>Email</label>
<input type="email" name="customer_email" value="{{ old('customer_email') }}">
<input type="email" name="customer_email" value="{{ old('customer_email', ($customer ?? null)?->email) }}">
</div>
<div class="field">
<label>Livrare *</label>
+9
View File
@@ -72,6 +72,15 @@
<a href="/shop/cart">🛒 Coș
@if (($cartCount ?? 0) > 0)<span class="cart-badge">{{ $cartCount }}</span>@endif
</a>
@auth('shop')
<a href="/shop/account">👤 {{ Auth::guard('shop')->user()->name }}</a>
<form method="POST" action="/shop/logout" style="display:inline;">@csrf
<button type="submit" style="background:transparent;border:0;color:inherit;cursor:pointer;font:inherit;">Ieșire</button>
</form>
@else
<a href="/shop/login">Login</a>
<a href="/shop/register" style="background:rgba(255,255,255,.2);border-radius:6px;padding:4px 10px;">Înregistrare</a>
@endauth
</nav>
</div>
</header>
+10
View File
@@ -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}')
+182
View File
@@ -0,0 +1,182 @@
<?php
namespace Tests\Feature;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\OnlineOrder;
use App\Models\Tenant\Part;
use App\Models\Tenant\ShopCustomer;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class ShopAccountTest extends TestCase
{
use RefreshDatabase;
public function test_register_creates_customer_and_logs_in(): void
{
$this->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');
}
}