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;
|
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
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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