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)
+
+ @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');
+});