feat: tag-based product alternatives
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
fbb4862454
commit
c4e6173e46
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
@@ -183,7 +183,7 @@
|
||||
|
||||
<x-product.pricing :product="$product" />
|
||||
|
||||
<!-- slot:alternatives -->
|
||||
<x-product.alternatives :product="$product" />
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user