diff --git a/app/Filament/Tenant/Resources/PartResource.php b/app/Filament/Tenant/Resources/PartResource.php index 74bef8f..afb9b51 100644 --- a/app/Filament/Tenant/Resources/PartResource.php +++ b/app/Filament/Tenant/Resources/PartResource.php @@ -105,11 +105,14 @@ class PartResource extends Resource \Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('image') ->label('Foto piesă') ->collection('image') + ->multiple() + ->reorderable() ->image() ->imageEditor() + ->maxFiles(8) ->maxSize(2048) ->columnSpanFull() - ->helperText('Apare în magazinul online (catalog + pagina piesei). Max 2 MB.'), + ->helperText('Galerie de până la 8 imagini. Prima e afișată în catalog. Max 2 MB / imagine.'), ]), Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2), ]); diff --git a/app/Filament/Tenant/Resources/ShopCustomerResource.php b/app/Filament/Tenant/Resources/ShopCustomerResource.php new file mode 100644 index 0000000..a2c364e --- /dev/null +++ b/app/Filament/Tenant/Resources/ShopCustomerResource.php @@ -0,0 +1,103 @@ +components([ + Schemas\Components\Section::make()->columns(2)->schema([ + Forms\Components\TextInput::make('name')->label('Nume')->required()->maxLength(160), + Forms\Components\TextInput::make('phone')->label('Telefon')->required()->maxLength(40), + Forms\Components\TextInput::make('email')->label('Email')->email()->maxLength(160), + Forms\Components\Select::make('client_id') + ->label('Client legat (CRM)') + ->options(fn () => \App\Models\Tenant\Client::pluck('name', 'id')) + ->searchable() + ->helperText('Legătura cu fișa CRM (opțional). Auto-matched la înregistrare după telefon.'), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name')->searchable()->sortable(), + Tables\Columns\TextColumn::make('phone')->copyable()->searchable(), + Tables\Columns\TextColumn::make('email')->placeholder('—')->copyable()->toggleable(), + Tables\Columns\TextColumn::make('client.name')->label('Client CRM')->placeholder('—')->toggleable(), + Tables\Columns\TextColumn::make('orders_count')->counts('orders')->label('Comenzi')->alignRight(), + Tables\Columns\TextColumn::make('last_login_at')->label('Ultim login')->since()->placeholder('Niciodată'), + Tables\Columns\TextColumn::make('created_at')->label('Înregistrat')->date('d.m.Y')->toggleable(), + ]) + ->actions([ + Actions\Action::make('reset_password') + ->label('Trimite reset parolă') + ->icon('heroicon-m-key') + ->color('warning') + ->visible(fn (ShopCustomer $r) => ! empty($r->email)) + ->requiresConfirmation() + ->modalDescription('Trimite emailul standard de resetare a parolei către clientul magazinului.') + ->action(function (ShopCustomer $r) { + $status = Password::broker('shop_customers')->sendResetLink(['email' => $r->email]); + Notification::make() + ->title($status === Password::RESET_LINK_SENT + ? 'Link de resetare trimis la ' . $r->email + : 'Eșec: ' . $status) + ->{$status === Password::RESET_LINK_SENT ? 'success' : 'warning'}() + ->send(); + }), + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->emptyStateHeading('Niciun client magazin') + ->emptyStateDescription('Aici apar clienții care și-au creat cont în magazinul online (/shop/register).') + ->emptyStateIcon('heroicon-o-user-circle') + ->defaultSort('created_at', 'desc'); + } + + public static function getRelations(): array + { + return [ + RelationManagers\OrdersRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListShopCustomers::route('/'), + 'edit' => Pages\EditShopCustomer::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/ShopCustomerResource/Pages/EditShopCustomer.php b/app/Filament/Tenant/Resources/ShopCustomerResource/Pages/EditShopCustomer.php new file mode 100644 index 0000000..ee46ed3 --- /dev/null +++ b/app/Filament/Tenant/Resources/ShopCustomerResource/Pages/EditShopCustomer.php @@ -0,0 +1,17 @@ +recordTitleAttribute('number') + ->columns([ + Tables\Columns\TextColumn::make('number')->label('Nr.'), + Tables\Columns\TextColumn::make('created_at')->label('Data')->dateTime('d.m.Y H:i'), + Tables\Columns\TextColumn::make('status') + ->formatStateUsing(fn ($s) => OnlineOrder::STATUSES[$s] ?? $s) + ->badge() + ->colors([ + 'warning' => ['new'], + 'info' => ['confirmed', 'packed'], + 'primary' => ['shipped'], + 'success' => ['delivered'], + 'danger' => ['cancelled'], + ]), + Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(), + ]) + ->defaultSort('created_at', 'desc') + ->emptyStateHeading('Nicio comandă încă'); + } +} diff --git a/app/Http/Controllers/ShopAuthController.php b/app/Http/Controllers/ShopAuthController.php index 781b6e2..f7ab22f 100644 --- a/app/Http/Controllers/ShopAuthController.php +++ b/app/Http/Controllers/ShopAuthController.php @@ -5,10 +5,13 @@ namespace App\Http\Controllers; use App\Models\Tenant\Client; use App\Models\Tenant\ShopCustomer; use App\Tenancy\TenantManager; +use Illuminate\Auth\Events\PasswordReset; use Illuminate\Auth\Events\Registered; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Password; +use Illuminate\Support\Str; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class ShopAuthController extends Controller @@ -117,6 +120,61 @@ class ShopAuthController extends Controller ]); } + public function showForgotPassword() + { + $tenant = $this->tenantOrFail(); + return view('shop.auth.forgot', ['tenant' => $tenant, 'cartCount' => $this->cartCount()]); + } + + public function sendResetLink(Request $request) + { + $this->tenantOrFail(); + $data = $request->validate(['email' => 'required|email']); + + // Send (always returns generic "sent" message — don't disclose if email exists). + Password::broker('shop_customers')->sendResetLink(['email' => $data['email']]); + + return back()->with('status', 'Dacă există un cont cu acest email, am trimis un link de resetare.'); + } + + public function showResetPassword(string $token, Request $request) + { + $tenant = $this->tenantOrFail(); + return view('shop.auth.reset', [ + 'tenant' => $tenant, + 'token' => $token, + 'email' => $request->query('email'), + 'cartCount' => $this->cartCount(), + ]); + } + + public function resetPassword(Request $request) + { + $this->tenantOrFail(); + $data = $request->validate([ + 'token' => 'required|string', + 'email' => 'required|email', + 'password' => 'required|string|min:6|confirmed', + ]); + + $status = Password::broker('shop_customers')->reset( + $data, + function (ShopCustomer $customer, string $password) { + $customer->forceFill([ + 'password' => Hash::make($password), + 'remember_token' => Str::random(60), + ])->save(); + event(new PasswordReset($customer)); + } + ); + + if ($status === Password::PASSWORD_RESET) { + return redirect('/shop/login')->with('status', 'Parola a fost resetată. Te poți loga acum.'); + } + + return back()->withErrors(['email' => 'Link invalid sau expirat. Cere unul nou.'])->withInput(); + } + private function cartCount(): int { $tenant = app(TenantManager::class)->current(); diff --git a/app/Mail/ShopOrderConfirmationMail.php b/app/Mail/ShopOrderConfirmationMail.php new file mode 100644 index 0000000..2ead1a5 --- /dev/null +++ b/app/Mail/ShopOrderConfirmationMail.php @@ -0,0 +1,43 @@ +company->display_name ?? $this->company->name; + return new Envelope( + subject: "Comanda #{$this->order->number} primită — {$brand}", + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.shop.order-confirmation', + with: [ + 'order' => $this->order, + 'company' => $this->company, + 'items' => $this->order->items()->get(), + 'trackingUrl' => $this->order->trackingUrl(), + 'currency' => $this->company->settings['currency'] ?? 'MDL', + ], + ); + } +} diff --git a/app/Mail/ShopPasswordResetMail.php b/app/Mail/ShopPasswordResetMail.php new file mode 100644 index 0000000..f95aec5 --- /dev/null +++ b/app/Mail/ShopPasswordResetMail.php @@ -0,0 +1,42 @@ +company->display_name ?? $this->company->name; + return new Envelope( + subject: "Resetare parolă — {$brand}", + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.shop.password-reset', + with: [ + 'customer' => $this->customer, + 'company' => $this->company, + 'resetUrl' => $this->resetUrl, + ], + ); + } +} diff --git a/app/Models/Tenant/Part.php b/app/Models/Tenant/Part.php index 8fcbb21..11ad785 100644 --- a/app/Models/Tenant/Part.php +++ b/app/Models/Tenant/Part.php @@ -16,7 +16,8 @@ class Part extends Model implements HasMedia public function registerMediaCollections(): void { - $this->addMediaCollection('image')->singleFile(); + // Multi-image gallery (catalog uses imageUrl() = first; detail page renders all). + $this->addMediaCollection('image'); } public function imageUrl(): ?string @@ -27,6 +28,15 @@ class Part extends Model implements HasMedia return $m->getUrl(); } + /** @return list All published image URLs (excluding any whose file is missing). */ + public function imageUrls(): array + { + return $this->getMedia('image') + ->filter(fn ($m) => @file_exists($m->getPath())) + ->map(fn ($m) => $m->getUrl()) + ->values()->all(); + } + public const CATEGORIES = [ 'Ulei', 'Filtre', 'Frâne', 'Suspensie', 'Lichide', 'Distribuție', 'Anvelope', 'Electrică', 'Caroserie', 'Altele', diff --git a/app/Models/Tenant/ShopCustomer.php b/app/Models/Tenant/ShopCustomer.php index 1616dde..1342828 100644 --- a/app/Models/Tenant/ShopCustomer.php +++ b/app/Models/Tenant/ShopCustomer.php @@ -39,4 +39,18 @@ class ShopCustomer extends Authenticatable { return 'id'; } + + /** Send custom reset mail with a /shop/password/reset URL on the tenant subdomain. */ + public function sendPasswordResetNotification($token): void + { + $tenant = \App\Models\Central\Company::withoutGlobalScopes()->find($this->company_id); + if (! $tenant || ! $this->email) return; + + $central = config('app.central_domain') ?: config('tenancy.central_domains.0', 'service.mir.md'); + $url = "https://{$tenant->slug}.{$central}/shop/password/reset/{$token}?email=" . urlencode($this->email); + + \Illuminate\Support\Facades\Mail::to($this->email)->send( + new \App\Mail\ShopPasswordResetMail($this, $tenant, $url) + ); + } } diff --git a/app/Services/Notifications/ShopOrderNotifier.php b/app/Services/Notifications/ShopOrderNotifier.php index d576893..fb53fc2 100644 --- a/app/Services/Notifications/ShopOrderNotifier.php +++ b/app/Services/Notifications/ShopOrderNotifier.php @@ -54,5 +54,17 @@ class ShopOrderNotifier $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text); } } + + // ── Customer: email confirmation when address given ── + if ($order->customer_email) { + try { + \Illuminate\Support\Facades\Mail::to($order->customer_email) + ->send(new \App\Mail\ShopOrderConfirmationMail($order, $company)); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('shop order confirmation mail failed', [ + 'order' => $order->id, 'err' => $e->getMessage(), + ]); + } + } } } diff --git a/config/auth.php b/config/auth.php index 0d39ef8..770f797 100644 --- a/config/auth.php +++ b/config/auth.php @@ -58,6 +58,12 @@ return [ 'expire' => 60, 'throttle' => 60, ], + 'shop_customers' => [ + 'provider' => 'shop_customers', + 'table' => 'password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], ], 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), diff --git a/resources/views/emails/shop/order-confirmation.blade.php b/resources/views/emails/shop/order-confirmation.blade.php new file mode 100644 index 0000000..4c6a3ac --- /dev/null +++ b/resources/views/emails/shop/order-confirmation.blade.php @@ -0,0 +1,65 @@ + + + + + Comandă primită + + + @php $brand = $company->display_name ?? $company->name; @endphp + +

{{ $brand }}

+

Comanda ta a fost primită cu succes.

+ +
+
Comanda
+
#{{ $order->number }}
+
+ {{ $order->created_at->isoFormat('D MMM YYYY, HH:mm') }} · + {{ \App\Models\Tenant\OnlineOrder::DELIVERY[$order->delivery_method] ?? $order->delivery_method }} +
+
+ +

Produsele tale

+ + @foreach ($items as $item) + + + + + @endforeach + @if ((float) $order->delivery_fee > 0) + + + + + @endif + + + + +
+ {{ $item->name }} + × {{ rtrim(rtrim(number_format((float) $item->qty, 2), '0'), '.') }} + + {{ number_format((float) $item->total, 2) }} {{ $currency }} +
Livrare{{ number_format((float) $order->delivery_fee, 2) }} {{ $currency }}
Total + {{ number_format((float) $order->total, 2) }} {{ $currency }} +
+ + @if ($order->address) +

+ Adresă livrare: {{ $order->address }} +

+ @endif + +

+ + Urmărește comanda → + +

+ +

+ Email automat de la {{ $brand }} — nu răspunde la el. Pentru întrebări, sună la {{ $company->phone ?? '—' }}. +

+ + diff --git a/resources/views/emails/shop/password-reset.blade.php b/resources/views/emails/shop/password-reset.blade.php new file mode 100644 index 0000000..50bef3f --- /dev/null +++ b/resources/views/emails/shop/password-reset.blade.php @@ -0,0 +1,26 @@ + + + + + Resetare parolă + + + @php $brand = $company->display_name ?? $company->name; @endphp +

{{ $brand }}

+ +

Salut {{ $customer->name }},

+

Ai cerut resetarea parolei pentru contul tău de magazin. Apasă linkul de mai jos ca să setezi o parolă nouă:

+ +

+ + Resetează parola + +

+ +

Linkul expiră în 60 de minute. Dacă nu ai cerut tu acest reset, ignoră emailul — contul tău e în siguranță.

+ +

+ Email automat de la {{ $brand }} — nu răspunde la el. +

+ + diff --git a/resources/views/shop/auth/forgot.blade.php b/resources/views/shop/auth/forgot.blade.php new file mode 100644 index 0000000..beb4870 --- /dev/null +++ b/resources/views/shop/auth/forgot.blade.php @@ -0,0 +1,35 @@ +@extends('shop.layout') +@section('title', 'Resetare parolă') +@section('content') + +
+

Am uitat parola

+

Introdu emailul cu care te-ai înregistrat — îți trimitem un link de resetare.

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

+ ← Înapoi la login +

+
+@endsection diff --git a/resources/views/shop/auth/login.blade.php b/resources/views/shop/auth/login.blade.php index e8c0352..24f26bc 100644 --- a/resources/views/shop/auth/login.blade.php +++ b/resources/views/shop/auth/login.blade.php @@ -25,7 +25,13 @@

- Nu ai cont? Înregistrare + Am uitat parola + · Nu ai cont? Înregistrare

+ @if (session('status')) +
+ {{ session('status') }} +
+ @endif @endsection diff --git a/resources/views/shop/auth/reset.blade.php b/resources/views/shop/auth/reset.blade.php new file mode 100644 index 0000000..dfef3d5 --- /dev/null +++ b/resources/views/shop/auth/reset.blade.php @@ -0,0 +1,31 @@ +@extends('shop.layout') +@section('title', 'Parolă nouă') +@section('content') + +
+

Setează o parolă nouă

+ + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $e)
  • {{ $e }}
  • @endforeach +
+
+ @endif + +
+ @csrf + +
+ +
+
+ +
+
+ +
+ +
+
+@endsection diff --git a/resources/views/shop/part.blade.php b/resources/views/shop/part.blade.php index 6c00343..81a707d 100644 --- a/resources/views/shop/part.blade.php +++ b/resources/views/shop/part.blade.php @@ -1,14 +1,41 @@ @extends('shop.layout') @section('title', $part->name) @section('content') -@php $currency = $tenant->settings['currency'] ?? 'MDL'; $stock = (float) $part->qty; $img = $part->imageUrl(); @endphp +@php + $currency = $tenant->settings['currency'] ?? 'MDL'; + $stock = (float) $part->qty; + $imgs = $part->imageUrls(); +@endphp ← Înapoi la catalog -@if ($img) -
-
- {{ $part->name }} +@if (! empty($imgs)) +
+
+
+ {{ $part->name }} +
+ @if (count($imgs) > 1) +
+ @foreach ($imgs as $i => $url) + + @endforeach +
+ + + @endif
@else diff --git a/routes/web.php b/routes/web.php index 3569a79..8586989 100644 --- a/routes/web.php +++ b/routes/web.php @@ -101,6 +101,11 @@ Route::controller(\App\Http\Controllers\ShopAuthController::class)->prefix('shop Route::post('/login', 'login'); Route::post('/logout', 'logout')->name('shop.logout'); Route::get('/account', 'account')->name('shop.account'); + + Route::get('/password/forgot', 'showForgotPassword')->name('shop.password.forgot'); + Route::post('/password/email', 'sendResetLink')->name('shop.password.email'); + Route::get('/password/reset/{token}', 'showResetPassword')->name('password.reset'); + Route::post('/password/reset', 'resetPassword')->name('shop.password.update'); }); // ─── Public WO tracking (no auth, tenant-scoped via subdomain) ────── diff --git a/tests/Feature/ShopPasswordResetTest.php b/tests/Feature/ShopPasswordResetTest.php new file mode 100644 index 0000000..d2aafee --- /dev/null +++ b/tests/Feature/ShopPasswordResetTest.php @@ -0,0 +1,143 @@ +makeShop('pr'); + ShopCustomer::create([ + 'name' => 'X', 'phone' => '+37377000001', + 'email' => 'x@example.com', 'password' => Hash::make('old'), + ]); + + Mail::fake(); + + $this->post('http://pr.service.mir.md/shop/password/email', [ + 'email' => 'x@example.com', + ])->assertSessionHas('status'); + + Mail::assertSent(ShopPasswordResetMail::class, fn ($m) => $m->customer->email === 'x@example.com'); + } + + public function test_forgot_does_not_disclose_unknown_email(): void + { + $this->makeShop('pru'); + Mail::fake(); + + $this->post('http://pru.service.mir.md/shop/password/email', [ + 'email' => 'ghost@example.com', + ])->assertSessionHas('status'); // same generic status, no error + + Mail::assertNothingSent(); + } + + public function test_reset_with_valid_token_changes_password(): void + { + $this->makeShop('rs'); + $cust = ShopCustomer::create([ + 'name' => 'R', 'phone' => '+37377000002', + 'email' => 'r@example.com', 'password' => Hash::make('old'), + ]); + + $token = Password::broker('shop_customers')->createToken($cust); + + $this->post('http://rs.service.mir.md/shop/password/reset', [ + 'token' => $token, + 'email' => 'r@example.com', + 'password' => 'newpassword', + 'password_confirmation' => 'newpassword', + ])->assertRedirect('/shop/login'); + + $cust->refresh(); + $this->assertTrue(Hash::check('newpassword', $cust->password)); + } + + public function test_reset_with_bad_token_rejected(): void + { + $this->makeShop('bad'); + ShopCustomer::create([ + 'name' => 'B', 'phone' => '+37377000003', + 'email' => 'b@example.com', 'password' => Hash::make('old'), + ]); + + $this->post('http://bad.service.mir.md/shop/password/reset', [ + 'token' => 'not-a-real-token', + 'email' => 'b@example.com', + 'password' => 'newpassword', + 'password_confirmation' => 'newpassword', + ])->assertSessionHasErrors(); + } + + public function test_order_notifier_sends_email_when_customer_email_present(): void + { + $ctx = $this->makeShop('mail'); + + Mail::fake(); + $order = OnlineOrder::create([ + 'number' => OnlineOrder::generateNumber($ctx->id), + 'customer_name' => 'M', 'customer_phone' => '+37377000004', + 'customer_email' => 'm@example.com', + 'delivery_method' => 'pickup', 'status' => 'new', + ]); + + app(ShopOrderNotifier::class)->placed($order); + + Mail::assertSent(ShopOrderConfirmationMail::class, fn ($m) => $m->order->id === $order->id); + } + + public function test_order_notifier_skips_email_without_customer_email(): void + { + $ctx = $this->makeShop('noeml'); + Mail::fake(); + $order = OnlineOrder::create([ + 'number' => OnlineOrder::generateNumber($ctx->id), + 'customer_name' => 'N', 'customer_phone' => '+37377000005', + 'delivery_method' => 'pickup', 'status' => 'new', + ]); + app(ShopOrderNotifier::class)->placed($order); + Mail::assertNotSent(ShopOrderConfirmationMail::class); + } + + public function test_part_has_multiple_images_collection(): void + { + $this->makeShop('multi'); + $part = \App\Models\Tenant\Part::create([ + 'name' => 'P', 'sell_price' => 10, 'qty' => 1, + 'unit' => 'buc', 'is_active' => true, 'buy_price' => 5, + ]); + $this->assertIsArray($part->imageUrls()); + $this->assertCount(0, $part->imageUrls()); + } + + private function makeShop(string $slug): Company + { + $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 $company; + } +}