Files
Vasyka 75386c354a 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>
2026-06-02 19:43:39 +00:00

249 lines
8.3 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Tenant\OnlineOrder;
use App\Models\Tenant\OnlineOrderItem;
use App\Models\Tenant\Part;
use App\Services\Ai\VinDecoder;
use App\Tenancy\TenantManager;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ShopController extends Controller
{
private function tenantOrFail()
{
$tenant = app(TenantManager::class)->current();
if (! $tenant) {
throw new NotFoundHttpException('Magazinul e disponibil doar pe subdomeniul service-ului.');
}
if (! data_get($tenant->settings, 'shop.enabled')) {
throw new NotFoundHttpException('Magazinul online nu este activ.');
}
return $tenant;
}
public function catalog(Request $request)
{
$tenant = $this->tenantOrFail();
$term = $request->query('q');
$category = $request->query('cat');
$inStock = $request->boolean('in_stock');
$query = Part::searchPublished($term);
if ($category) $query->where('category', $category);
if ($inStock) $query->where('qty', '>', 0);
$parts = $query->orderBy('name')->paginate(24)->withQueryString();
$categories = Part::published()->distinct()->pluck('category')->filter()->sort()->values();
return view('shop.catalog', [
'tenant' => $tenant,
'parts' => $parts,
'categories' => $categories,
'term' => $term,
'category' => $category,
'inStock' => $inStock,
'cartCount' => $this->cartCount(),
]);
}
public function part(Request $request, int $id)
{
$tenant = $this->tenantOrFail();
$part = Part::published()->with('crossRefs')->find($id);
if (! $part) throw new NotFoundHttpException('Piesa nu există sau nu e publicată.');
return view('shop.part', [
'tenant' => $tenant,
'part' => $part,
'cartCount' => $this->cartCount(),
]);
}
public function vin(Request $request)
{
$tenant = $this->tenantOrFail();
$vin = strtoupper(trim((string) $request->query('vin', '')));
$decoded = null;
if ($vin !== '') {
$decoded = app(VinDecoder::class)->decode($vin);
}
return view('shop.vin', [
'tenant' => $tenant,
'vin' => $vin,
'decoded' => $decoded,
'cartCount' => $this->cartCount(),
]);
}
// ─── Cart (session) ───────────────────────────────────────────
private function cartKey(): string
{
$tenant = app(TenantManager::class)->current();
return 'shop_cart_' . ($tenant?->id ?? '0');
}
private function cart(): array
{
return (array) session($this->cartKey(), []);
}
private function cartCount(): int
{
return (int) collect($this->cart())->sum('qty');
}
public function addToCart(Request $request, int $id)
{
$this->tenantOrFail();
$part = Part::published()->findOrFail($id);
$qty = max(1, (int) $request->input('qty', 1));
$cart = $this->cart();
$cart[$id] = [
'part_id' => $part->id,
'name' => $part->name,
'article' => $part->article,
'price' => (float) $part->sell_price,
'qty' => ($cart[$id]['qty'] ?? 0) + $qty,
];
session([$this->cartKey() => $cart]);
return redirect('/shop/cart');
}
public function updateCart(Request $request)
{
$this->tenantOrFail();
$cart = $this->cart();
foreach ((array) $request->input('qty', []) as $id => $qty) {
$qty = (int) $qty;
if ($qty <= 0) {
unset($cart[$id]);
} elseif (isset($cart[$id])) {
$cart[$id]['qty'] = $qty;
}
}
session([$this->cartKey() => $cart]);
return redirect('/shop/cart');
}
public function showCart()
{
$tenant = $this->tenantOrFail();
$cart = $this->cart();
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
return view('shop.cart', [
'tenant' => $tenant,
'cart' => $cart,
'subtotal' => $subtotal,
'cartCount' => $this->cartCount(),
]);
}
public function checkout()
{
$tenant = $this->tenantOrFail();
$cart = $this->cart();
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(),
]);
}
public function placeOrder(Request $request)
{
$tenant = $this->tenantOrFail();
$cart = $this->cart();
if (empty($cart)) return redirect('/shop');
$data = $request->validate([
'customer_name' => 'required|string|max:160',
'customer_phone' => 'required|string|max:40',
'customer_email' => 'nullable|email|max:160',
'delivery_method' => 'required|in:pickup,courier,post',
'address' => 'nullable|string|max:255',
'notes' => 'nullable|string|max:1000',
]);
$deliveryFee = 0.0;
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
if ($data['delivery_method'] !== 'pickup') {
$fee = (float) data_get($tenant->settings, 'shop.delivery_fee', 0);
$freeOver = (float) data_get($tenant->settings, 'shop.free_delivery_over', 0);
$deliveryFee = ($freeOver > 0 && $subtotal >= $freeOver) ? 0.0 : $fee;
}
$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,
'delivery_method' => $data['delivery_method'],
'address' => $data['address'] ?? null,
'notes' => $data['notes'] ?? null,
'status' => 'new',
'delivery_fee' => $deliveryFee,
]);
foreach ($cart as $item) {
OnlineOrderItem::create([
'online_order_id' => $order->id,
'part_id' => $item['part_id'] ?? null,
'name' => $item['name'],
'article' => $item['article'] ?? null,
'qty' => $item['qty'],
'price' => $item['price'],
]);
}
$order->refresh()->recalcTotal();
return $order;
});
session()->forget($this->cartKey());
// Notify (best-effort): customer + shop staff.
try {
app(\App\Services\Notifications\ShopOrderNotifier::class)->placed($order);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::debug('shop order notify skipped: ' . $e->getMessage());
}
return redirect('/shop/order/' . $order->tracking_token);
}
public function orderStatus(Request $request, string $token)
{
$tenant = $this->tenantOrFail();
$order = OnlineOrder::with('items')->where('tracking_token', $token)->first();
if (! $order) throw new NotFoundHttpException('Comanda nu a fost găsită.');
return view('shop.order', [
'tenant' => $tenant,
'order' => $order,
'cartCount' => $this->cartCount(),
]);
}
}