From c4e6173e469233d469eb9a4fc2027f819b3a8283 Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sat, 17 Oct 2026 16:06:27 +0300 Subject: [PATCH] feat: tag-based product alternatives Co-Authored-By: Claude Sonnet 5.5 --- app/Models/Product.php | 26 +++++ .../components/product/alternatives.blade.php | 34 +++++++ resources/views/products/show.blade.php | 2 +- tests/Feature/ProductAlternativesTest.php | 99 +++++++++++++++++++ 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 resources/views/components/product/alternatives.blade.php create mode 100644 tests/Feature/ProductAlternativesTest.php diff --git a/app/Models/Product.php b/app/Models/Product.php index dd279e0..25c1200 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -173,6 +174,31 @@ class Product extends Model return $this->belongsToMany(Tag::class); } + /** + * Get approved products sharing tags with this one, best matches first. + * Single query; each result has a shared_tags_count attribute. + */ + public function alternatives(int $limit = 3): Collection + { + return static::query() + ->approved() + ->where('products.id', '!=', $this->id) + ->join('product_tag', 'product_tag.product_id', '=', 'products.id') + ->whereIn('product_tag.tag_id', function ($query) { + $query->select('tag_id')->from('product_tag')->where('product_id', $this->id); + }) + ->select('products.*') + ->selectRaw('COUNT(product_tag.tag_id) as shared_tags_count') + ->withCount('upvotes') + ->groupBy('products.id') + ->orderByDesc('shared_tags_count') + ->orderByDesc('upvotes_count') + ->orderByDesc('products.created_at') + ->orderByDesc('products.id') + ->limit($limit) + ->get(); + } + /** * Scope a query to only include approved products. */ diff --git a/resources/views/components/product/alternatives.blade.php b/resources/views/components/product/alternatives.blade.php new file mode 100644 index 0000000..1b42809 --- /dev/null +++ b/resources/views/components/product/alternatives.blade.php @@ -0,0 +1,34 @@ +@props(['product']) + +@php + $alternatives = $product->alternatives(); +@endphp + +@if($alternatives->isNotEmpty()) +
class('bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6') }}> +

Alternatives

+
+ @foreach($alternatives as $alt) +
+ + @if($alt->thumbnail_url) + {{ $alt->title }} + @else +
+ + + +
+ @endif +
+
+ {{ $alt->title }} +

{{ \Illuminate\Support\Str::limit($alt->description, 80) }}

+ +

{{ $alt->shared_tags_count }} shared {{ \Illuminate\Support\Str::plural('tag', $alt->shared_tags_count) }}

+
+
+ @endforeach +
+
+@endif diff --git a/resources/views/products/show.blade.php b/resources/views/products/show.blade.php index 71283f8..6c03435 100644 --- a/resources/views/products/show.blade.php +++ b/resources/views/products/show.blade.php @@ -183,7 +183,7 @@ - +
diff --git a/tests/Feature/ProductAlternativesTest.php b/tests/Feature/ProductAlternativesTest.php new file mode 100644 index 0000000..a429592 --- /dev/null +++ b/tests/Feature/ProductAlternativesTest.php @@ -0,0 +1,99 @@ +map(fn ($i) => Tag::create([ + 'name' => "Alt Tag $i", + 'slug' => "alt-tag-$i", + ]))->all(); +} + +function altProduct(array $tags, string $state = 'approved', array $attrs = []): Product +{ + $product = Product::factory()->{$state}()->create($attrs); + $product->tags()->sync(collect($tags)->pluck('id')->all()); + + return $product; +} + +test('orders by shared tags', function () { + [$a, $b, $c, $d] = altTags(4); + $self = altProduct([$a, $b, $c]); + $two = altProduct([$a, $b]); + $three = altProduct([$a, $b, $c, $d]); + $one = altProduct([$a]); + + $result = $self->alternatives(); + + expect($result->pluck('id')->all())->toBe([$three->id, $two->id, $one->id]); + expect($result->pluck('shared_tags_count')->map(fn ($v) => (int) $v)->all())->toBe([3, 2, 1]); +}); + +test('excludes self, pending, rejected and zero-shared-tag products', function () { + [$a, $b] = altTags(2); + $self = altProduct([$a]); + $good = altProduct([$a]); + altProduct([$a], 'pending'); + altProduct([$a], 'rejected'); + altProduct([$b]); + + expect($self->alternatives()->pluck('id')->all())->toBe([$good->id]); +}); + +test('ties are ordered by upvotes then newest', function () { + [$a] = altTags(1); + $self = altProduct([$a]); + $old = altProduct([$a], 'approved', ['created_at' => now()->subDays(3)]); + $new = altProduct([$a], 'approved', ['created_at' => now()->subDay()]); + $voted = altProduct([$a], 'approved', ['created_at' => now()->subDays(5)]); + Upvote::create(['product_id' => $voted->id, 'user_id' => userWithRole('user')->id]); + + expect($self->alternatives()->pluck('id')->all())->toBe([$voted->id, $new->id, $old->id]); +}); + +test('limits to 3', function () { + [$a] = altTags(1); + $self = altProduct([$a]); + foreach (range(1, 5) as $i) { + altProduct([$a]); + } + + expect($self->alternatives())->toHaveCount(3); + expect($self->alternatives(2))->toHaveCount(2); +}); + +test('alternatives is a single query', function () { + [$a, $b] = altTags(2); + $self = altProduct([$a, $b]); + altProduct([$a]); + altProduct([$b]); + + DB::enableQueryLog(); + $self->alternatives(); + expect(DB::getQueryLog())->toHaveCount(1); +}); + +test('show page renders alternative cards and hides the section when none', function () { + [$a] = altTags(1); + $self = altProduct([$a]); + + $this->get(route('products.show', $self))->assertOk()->assertDontSee('data-alternatives', false); + + $alt = altProduct([$a], 'approved', ['title' => 'Cool Alternative', 'eu_made' => true]); + + $this->get(route('products.show', $self)) + ->assertOk() + ->assertSee('data-alternatives', false) + ->assertSee('Cool Alternative') + ->assertSee(route('products.show', $alt), false) + ->assertSee('1 shared tag') + ->assertSee('EU made'); +});