Add favorite toggle to yoga poses

Exercises and workouts both have a favorite toggle; yoga poses had an
otherwise identical browse/filter UX but no way to favorite them - an
inconsistency rather than a deliberate omission. Mirrors the existing
exercise/workout favorite pattern exactly: a pivot table, a
belongsToMany on User, and a toggle button on the pose detail page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 12:04:50 +03:00
co-authored by Claude Sonnet 5
parent 0a44b97232
commit 7769d2038a
6 changed files with 94 additions and 0 deletions
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Exercise;
use App\Models\Workout;
use App\Models\YogaPose;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
@@ -39,4 +40,18 @@ class FavoriteController extends Controller
}
return back()->with('status', $msg);
}
public function toggleYogaPose(YogaPose $pose): RedirectResponse
{
$user = Auth::user();
$exists = $user->favoriteYogaPoses()->where('yoga_pose_id', $pose->id)->exists();
if ($exists) {
$user->favoriteYogaPoses()->detach($pose->id);
$msg = 'Removed from favorites';
} else {
$user->favoriteYogaPoses()->attach($pose->id);
$msg = 'Added to favorites';
}
return back()->with('status', $msg);
}
}
+10
View File
@@ -79,6 +79,11 @@ class User extends Authenticatable
return $this->belongsToMany(Workout::class, 'workout_favorites')->withTimestamps();
}
public function favoriteYogaPoses()
{
return $this->belongsToMany(YogaPose::class, 'yoga_pose_favorites')->withTimestamps();
}
// Helpers
public function hasFavoritedExercise(Exercise $exercise): bool
{
@@ -89,4 +94,9 @@ class User extends Authenticatable
{
return $this->favoriteWorkouts()->where('workout_id', $workout->id)->exists();
}
public function hasFavoritedYogaPose(YogaPose $pose): bool
{
return $this->favoriteYogaPoses()->where('yoga_pose_id', $pose->id)->exists();
}
}
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('yoga_pose_favorites', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('yoga_pose_id')->constrained('yoga_poses')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'yoga_pose_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('yoga_pose_favorites');
}
};
+16
View File
@@ -13,6 +13,22 @@
<a href="{{ url()->previous() ?? route('yoga.index') }}" class="inline-flex items-center text-sm font-medium text-white/90 hover:text-white">&#8592; {{ __('app.yoga.back') }}</a>
<div class="mt-3 flex items-start justify-between gap-4">
<h1 class="text-3xl sm:text-5xl font-extrabold tracking-tight text-white drop-shadow">{{ $pose->english_name }}</h1>
@auth
@php $isFav = auth()->user()->hasFavoritedYogaPose($pose); @endphp
<form method="POST" action="{{ route('yoga.favorite', $pose) }}">
@csrf
<button type="submit" class="inline-flex items-center gap-2 rounded-full bg-white/90 px-3 py-1.5 text-sm font-semibold text-gray-900 shadow hover:bg-white">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5 {{ $isFav ? 'text-rose-600' : 'text-gray-400' }}">
@if($isFav)
<path d="M11.645 20.91l-.007-.003-.022-.01a15.247 15.247 0 01-.383-.18 25.18 25.18 0 01-4.244-2.686C4.688 15.734 2.25 13.153 2.25 9.75 2.25 7.005 4.46 4.875 7.125 4.875c1.6 0 3.09.744 4.125 1.917a5.625 5.625 0 014.125-1.917c2.665 0 4.875 2.13 4.875 4.875 0 3.403-2.438 5.984-4.739 7.58a25.175 25.175 0 01-4.244 2.685 15.247 15.247 0 01-.383.18l-.022.01-.007.004-.003.001a.75.75 0 01-.592 0l-.003-.001z"/>
@else
<path fill-rule="evenodd" d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 6.01 4.01 4 6.5 4c1.74 0 3.41.81 4.5 2.09A6.004 6.004 0 0115.5 4C17.99 4 20 6.01 20 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" clip-rule="evenodd"/>
@endif
</svg>
<span>{{ $isFav ? 'Favorited' : 'Favorite' }}</span>
</button>
</form>
@endauth
</div>
<div class="mt-4 flex flex-wrap items-center gap-2">
@if($pose->category)
+1
View File
@@ -26,6 +26,7 @@ Route::middleware('auth')->group(function () {
// Favorites
Route::post('/exercises/{exercise}/favorite', [\App\Http\Controllers\FavoriteController::class, 'toggleExercise'])->name('exercises.favorite');
Route::post('/workouts/{workout}/favorite', [\App\Http\Controllers\FavoriteController::class, 'toggleWorkout'])->name('workouts.favorite');
Route::post('/yoga/{pose}/favorite', [\App\Http\Controllers\FavoriteController::class, 'toggleYogaPose'])->name('yoga.favorite');
// Exercise alias (per-user rename)
Route::post('/exercises/{exercise}/alias', [\App\Http\Controllers\ExerciseAliasController::class, 'store'])->name('exercises.alias');
+22
View File
@@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Models\Exercise;
use App\Models\User;
use App\Models\Workout;
use App\Models\YogaPose;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -44,4 +45,25 @@ class FavoriteTest extends TestCase
$this->actingAs($user)->post(route('workouts.favorite', $workout));
$this->assertFalse($user->favoriteWorkouts()->where('workout_id', $workout->id)->exists());
}
public function test_guest_cannot_favorite_a_yoga_pose(): void
{
$pose = YogaPose::factory()->create();
$response = $this->post(route('yoga.favorite', $pose));
$response->assertRedirect(route('login'));
}
public function test_user_can_toggle_yoga_pose_favorite(): void
{
$user = User::factory()->create();
$pose = YogaPose::factory()->create();
$this->actingAs($user)->post(route('yoga.favorite', $pose));
$this->assertTrue($user->favoriteYogaPoses()->where('yoga_pose_id', $pose->id)->exists());
$this->actingAs($user)->post(route('yoga.favorite', $pose));
$this->assertFalse($user->favoriteYogaPoses()->where('yoga_pose_id', $pose->id)->exists());
}
}