feat: tag-based product alternatives

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Doar Mihai
2026-10-17 16:06:27 +03:00
co-authored by Claude Sonnet 5.5
parent fbb4862454
commit c4e6173e46
4 changed files with 160 additions and 1 deletions
+26
View File
@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -173,6 +174,31 @@ class Product extends Model
return $this->belongsToMany(Tag::class); 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. * Scope a query to only include approved products.
*/ */
@@ -0,0 +1,34 @@
@props(['product'])
@php
$alternatives = $product->alternatives();
@endphp
@if($alternatives->isNotEmpty())
<div data-alternatives {{ $attributes->class('bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6') }}>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Alternatives</h2>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
@foreach($alternatives as $alt)
<div class="rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<a href="{{ route('products.show', $alt) }}" class="block aspect-video bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600">
@if($alt->thumbnail_url)
<img src="{{ $alt->thumbnail_url }}" alt="{{ $alt->title }}" class="w-full h-full object-cover" loading="lazy">
@else
<div class="w-full h-full flex items-center justify-center">
<svg class="h-10 w-10 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
@endif
</a>
<div class="p-3">
<a href="{{ route('products.show', $alt) }}" class="font-semibold text-gray-900 dark:text-white hover:text-primary-600">{{ $alt->title }}</a>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{{ \Illuminate\Support\Str::limit($alt->description, 80) }}</p>
<x-product.badges :product="$alt" class="mt-2" />
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">{{ $alt->shared_tags_count }} shared {{ \Illuminate\Support\Str::plural('tag', $alt->shared_tags_count) }}</p>
</div>
</div>
@endforeach
</div>
</div>
@endif
+1 -1
View File
@@ -183,7 +183,7 @@
<x-product.pricing :product="$product" /> <x-product.pricing :product="$product" />
<!-- slot:alternatives --> <x-product.alternatives :product="$product" />
<!-- Comments Section --> <!-- Comments Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
+99
View File
@@ -0,0 +1,99 @@
<?php
use App\Models\Product;
use App\Models\Tag;
use App\Models\Upvote;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function altTags(int $n): array
{
return collect(range(1, $n))->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');
});