From a72d2aa164afc1b42e3559cdc8cada5735334147 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Jul 2026 13:07:19 -0500 Subject: [PATCH] Phase 2 directory quality --- CHANGELOG.md | 1 + .../Controllers/BuyerProfileController.php | 29 +++- .../Controllers/ProviderProfileController.php | 29 +++- resources/views/buyers/index.blade.php | 125 +++++++++++++++-- .../views/components/rating-summary.blade.php | 8 +- resources/views/providers/index.blade.php | 130 ++++++++++++++++-- tests/Feature/ExchangeWorkflowTest.php | 81 +++++++++++ 7 files changed, 367 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d66839..a2fd6b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re - Added roadmap guardrails for open-source-friendly dependencies, public themes, and future license audits. - Documented `pdo_sqlite` and `sqlite3` as required PHP extensions for SQLite-backed local tests and first-pass deployments. - Documented the preferred local-to-GitHub-to-remote-update workflow and the ChristIT conversion from rsync test deploy to Git checkout. +- Added Phase 2 directory quality improvements with advanced provider/buyer filters, sort controls, profile badges, rating-aware cards, result counts, and empty states. - Created the Laravel Provider Exchange prototype with Breeze authentication, seeded demo users, and role support for admins, providers, buyers, and hybrid accounts. - Added provider and buyer profile management with service areas, profile details, and external profile snapshot records. - Added social posts, buyer job posts, provider quotes, quote revisions, quote decline flow, and buyer quote acceptance. diff --git a/app/Http/Controllers/BuyerProfileController.php b/app/Http/Controllers/BuyerProfileController.php index a04b038..cfff1dd 100644 --- a/app/Http/Controllers/BuyerProfileController.php +++ b/app/Http/Controllers/BuyerProfileController.php @@ -11,7 +11,9 @@ class BuyerProfileController extends Controller { public function index(Request $request): View { - $profiles = BuyerProfile::with('user', 'attachments', 'ratings.user'); + $profiles = BuyerProfile::with('user', 'attachments', 'ratings.user') + ->withAvg(['ratings as average_stars' => fn ($query) => $query->whereNotNull('stars')], 'stars') + ->withCount('ratings'); if ($search = $request->string('q')->toString()) { $profiles->where(fn ($query) => $query @@ -22,8 +24,31 @@ class BuyerProfileController extends Controller ); } + if ($category = $request->string('category')->toString()) { + $profiles->where('service_categories', 'like', "%{$category}%"); + } + + if ($region = $request->string('region')->toString()) { + $profiles->where('hiring_regions', 'like', "%{$region}%"); + } + + if ($payment = $request->string('payment')->toString()) { + $profiles->where('payment_terms', 'like', "%{$payment}%"); + } + + if ($request->boolean('public_contact')) { + $profiles->where('public_contact', true); + } + + match ($request->string('sort')->toString()) { + 'name' => $profiles->orderBy('company_name'), + 'rating' => $profiles->orderByDesc('average_stars')->orderByDesc('ratings_count'), + default => $profiles->latest(), + }; + return view('buyers.index', [ - 'profiles' => $profiles->latest()->paginate(20)->withQueryString(), + 'profiles' => $profiles->paginate(20)->withQueryString(), + 'filters' => $request->only(['q', 'category', 'region', 'payment', 'public_contact', 'sort']), ]); } diff --git a/app/Http/Controllers/ProviderProfileController.php b/app/Http/Controllers/ProviderProfileController.php index 0915f8d..36e6407 100644 --- a/app/Http/Controllers/ProviderProfileController.php +++ b/app/Http/Controllers/ProviderProfileController.php @@ -11,7 +11,9 @@ class ProviderProfileController extends Controller { public function index(Request $request): View { - $profiles = ProviderProfile::with('user', 'externalImports', 'attachments', 'ratings.user'); + $profiles = ProviderProfile::with('user', 'externalImports', 'attachments', 'ratings.user') + ->withAvg(['ratings as average_stars' => fn ($query) => $query->whereNotNull('stars')], 'stars') + ->withCount('ratings'); if ($search = $request->string('q')->toString()) { $profiles->where(fn ($query) => $query @@ -22,8 +24,31 @@ class ProviderProfileController extends Controller ); } + if ($serviceArea = $request->string('service_area')->toString()) { + $profiles->where('service_area', 'like', "%{$serviceArea}%"); + } + + if ($skill = $request->string('skill')->toString()) { + $profiles->where('skills', 'like', "%{$skill}%"); + } + + if ($insurance = $request->string('insurance')->toString()) { + $profiles->where('insurance_status', 'like', "%{$insurance}%"); + } + + if ($request->boolean('public_contact')) { + $profiles->where('public_contact', true); + } + + match ($request->string('sort')->toString()) { + 'name' => $profiles->orderBy('business_name'), + 'rating' => $profiles->orderByDesc('average_stars')->orderByDesc('ratings_count'), + default => $profiles->latest(), + }; + return view('providers.index', [ - 'profiles' => $profiles->latest()->paginate(20)->withQueryString(), + 'profiles' => $profiles->paginate(20)->withQueryString(), + 'filters' => $request->only(['q', 'service_area', 'skill', 'insurance', 'public_contact', 'sort']), ]); } diff --git a/resources/views/buyers/index.blade.php b/resources/views/buyers/index.blade.php index 92e4f0a..2e79f85 100644 --- a/resources/views/buyers/index.blade.php +++ b/resources/views/buyers/index.blade.php @@ -1,18 +1,117 @@ -

Buyers

-
-
- - Search + + + @auth + @role('buyer') + Edit profile + @endrole + @endauth + + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ Search + Reset +
+
- @foreach ($profiles as $profile) - -

{{ $profile->company_name }}

-

{{ $profile->headline }}

-

{{ $profile->hiring_regions }}

- -
- @endforeach + +
+

+ Showing {{ $profiles->firstItem() ?? 0 }}-{{ $profiles->lastItem() ?? 0 }} of {{ $profiles->total() }} buyer profiles. +

+
+ @foreach (['q' => 'Search', 'category' => 'Category', 'region' => 'Region', 'payment' => 'Payment'] as $key => $label) + @if (! empty($filters[$key])) + {{ $label }}: {{ $filters[$key] }} + @endif + @endforeach + @if (request()->boolean('public_contact')) + Public contact + @endif +
+
+ + + {{ $profiles->links() }}
diff --git a/resources/views/components/rating-summary.blade.php b/resources/views/components/rating-summary.blade.php index 66441bc..aca9fab 100644 --- a/resources/views/components/rating-summary.blade.php +++ b/resources/views/components/rating-summary.blade.php @@ -8,9 +8,9 @@ $down = $thumbRatings->where('thumbs_up', false)->count(); @endphp -
-

Community rating

-

+

+

Community rating

+

Stars: {{ $average ? $average.'/5' : 'n/a' }} from {{ $starRatings->count() }} vote{{ $starRatings->count() === 1 ? '' : 's' }} | Thumbs: {{ $up }} up / {{ $down }} down

@@ -20,6 +20,6 @@ ? $rating->stars.'/5' : ($rating->thumbs_up === null ? 'no vote' : ($rating->thumbs_up ? 'thumbs up' : 'thumbs down')); @endphp -

{{ $rating->user->name ?? 'User' }} | {{ $rating->category }} | {{ $value }} {{ $rating->body ? '- '.$rating->body : '' }}

+

{{ $rating->user->name ?? 'User' }} | {{ $rating->category }} | {{ $value }} {{ $rating->body ? '- '.$rating->body : '' }}

@endforeach
diff --git a/resources/views/providers/index.blade.php b/resources/views/providers/index.blade.php index ae29f69..8335b95 100644 --- a/resources/views/providers/index.blade.php +++ b/resources/views/providers/index.blade.php @@ -1,21 +1,121 @@ -

Providers

-
-
- - Search + + + @auth + @role('provider') + Edit profile + @endrole + @endauth + + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ Search + Reset +
+
- @foreach ($profiles as $profile) - -

{{ $profile->business_name }}

-

{{ $profile->headline }}

-

{{ $profile->service_area }}

- - @foreach ($profile->externalImports as $import) - {{ $import->platform }} {{ $import->rating ? $import->rating.'/5' : '' }} + +
+ + + {{ $profiles->links() }}
diff --git a/tests/Feature/ExchangeWorkflowTest.php b/tests/Feature/ExchangeWorkflowTest.php index 5f27f1d..a7e4fbd 100644 --- a/tests/Feature/ExchangeWorkflowTest.php +++ b/tests/Feature/ExchangeWorkflowTest.php @@ -115,6 +115,87 @@ class ExchangeWorkflowTest extends TestCase ]); } + public function test_provider_directory_can_filter_and_sort_profiles(): void + { + $networkProvider = $this->userWithRole('provider'); + $fiberProvider = $this->userWithRole('provider'); + + $networkProfile = ProviderProfile::create([ + 'user_id' => $networkProvider->id, + 'business_name' => 'Alpha Network Services', + 'headline' => 'Retail network support', + 'service_area' => 'Tulsa and Oklahoma City', + 'skills' => 'POS, network, router installs', + 'insurance_status' => 'COI available', + 'public_contact' => true, + ]); + + ProviderProfile::create([ + 'user_id' => $fiberProvider->id, + 'business_name' => 'Beta Fiber Works', + 'headline' => 'Fiber contractor', + 'service_area' => 'Kansas City', + 'skills' => 'Fiber splicing', + 'insurance_status' => 'Insured', + 'public_contact' => false, + ]); + + $this->actingAs($fiberProvider)->post('/ratings', [ + 'rateable_type' => 'provider_profile', + 'rateable_id' => $networkProfile->id, + 'category' => 'provider_overall', + 'stars' => 5, + 'body' => 'Strong network work.', + ])->assertRedirect(); + + $this->get('/providers?service_area=Tulsa&skill=network&insurance=COI&public_contact=1&sort=rating') + ->assertOk() + ->assertSee('Alpha Network Services') + ->assertSee('Public contact') + ->assertSee('5/5') + ->assertDontSee('Beta Fiber Works'); + } + + public function test_buyer_directory_can_filter_and_sort_profiles(): void + { + $retailBuyer = $this->userWithRole('buyer'); + $warehouseBuyer = $this->userWithRole('buyer'); + + $retailProfile = $retailBuyer->buyerProfile()->create([ + 'company_name' => 'Alpha Retail Dispatch', + 'headline' => 'Nationwide retail IT', + 'service_categories' => 'Retail IT, POS, networking', + 'hiring_regions' => 'Oklahoma and Texas', + 'payment_terms' => 'Direct ACH Net 15', + 'vendor_onboarding' => 'W9 and COI required', + 'public_contact' => true, + ]); + + $warehouseBuyer->buyerProfile()->create([ + 'company_name' => 'Beta Warehouse Group', + 'headline' => 'Warehouse projects', + 'service_categories' => 'Low voltage', + 'hiring_regions' => 'Michigan', + 'payment_terms' => 'Paper check', + 'public_contact' => false, + ]); + + $this->actingAs($warehouseBuyer)->post('/ratings', [ + 'rateable_type' => 'buyer_profile', + 'rateable_id' => $retailProfile->id, + 'category' => 'buyer_overall', + 'stars' => 4, + 'body' => 'Clear terms.', + ])->assertRedirect(); + + $this->get('/buyers?category=POS®ion=Oklahoma&payment=ACH&public_contact=1&sort=rating') + ->assertOk() + ->assertSee('Alpha Retail Dispatch') + ->assertSee('Public contact') + ->assertSee('4/5') + ->assertDontSee('Beta Warehouse Group'); + } + public function test_invalid_work_order_transition_is_rejected(): void { [, $provider, $workOrder] = $this->workOrderFixture();