feat: implement ride review components

- Add RideReviewComponent for submitting reviews
  - Star rating input with real-time validation
  - Rate limiting and anti-spam measures
  - Edit capabilities for own reviews

- Add RideReviewListComponent for displaying reviews
  - Paginated list with sort/filter options
  - Helpful vote functionality
  - Statistics display with rating distribution

- Add ReviewModerationComponent for review management
  - Review queue with status filters
  - Approve/reject functionality
  - Batch actions support
  - Edit capabilities

- Update Memory Bank documentation
  - Document component implementations
  - Track feature completion
  - Update technical decisions
This commit is contained in:
pacnpal
2025-02-25 21:59:22 -05:00
parent 4e06f7313e
commit 487c0e5866
8 changed files with 1406 additions and 41 deletions

View File

@@ -0,0 +1,280 @@
<div class="space-y-6">
{{-- Message --}}
@if ($message)
<div @class([
'p-4 mb-4 rounded-lg',
'bg-green-100 dark:bg-green-800 text-green-700 dark:text-green-200' => !str_contains($message, 'error'),
'bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-200' => str_contains($message, 'error'),
])>
{{ $message }}
</div>
@endif
{{-- Controls --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="p-4 space-y-4">
{{-- Status Tabs --}}
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="-mb-px flex space-x-8">
<button
wire:click="filterByStatus('{{ ReviewStatus::PENDING->value }}')"
@class([
'pb-4 px-1 border-b-2 font-medium text-sm',
'border-primary-500 text-primary-600' => $statusFilter === ReviewStatus::PENDING->value,
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' => $statusFilter !== ReviewStatus::PENDING->value,
])
>
Pending
@if ($totalPending > 0)
<span class="ml-2 bg-primary-100 text-primary-600 py-0.5 px-2 rounded-full text-xs">
{{ $totalPending }}
</span>
@endif
</button>
<button
wire:click="filterByStatus('{{ ReviewStatus::APPROVED->value }}')"
@class([
'pb-4 px-1 border-b-2 font-medium text-sm',
'border-primary-500 text-primary-600' => $statusFilter === ReviewStatus::APPROVED->value,
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' => $statusFilter !== ReviewStatus::APPROVED->value,
])
>
Approved
</button>
<button
wire:click="filterByStatus('{{ ReviewStatus::REJECTED->value }}')"
@class([
'pb-4 px-1 border-b-2 font-medium text-sm',
'border-primary-500 text-primary-600' => $statusFilter === ReviewStatus::REJECTED->value,
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' => $statusFilter !== ReviewStatus::REJECTED->value,
])
>
Rejected
</button>
</nav>
</div>
{{-- Search & Batch Actions --}}
<div class="flex items-center justify-between">
<div class="max-w-lg flex-1">
<label for="search" class="sr-only">Search reviews</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span class="text-gray-500 dark:text-gray-400">
🔍
</span>
</div>
<input
type="search"
wire:model.live.debounce.300ms="search"
class="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md leading-5 bg-white dark:bg-gray-700 placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="Search reviews..."
>
</div>
</div>
@if (count($selected) > 0)
<div class="flex items-center space-x-3">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ count($selected) }} selected
</span>
<button
wire:click="batchApprove"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Approve Selected
</button>
<button
wire:click="batchReject"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Reject Selected
</button>
</div>
@endif
</div>
</div>
</div>
{{-- Reviews List --}}
<div class="bg-white dark:bg-gray-800 shadow rounded-lg">
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
@forelse ($reviews as $review)
<li class="p-4">
<div class="flex items-start space-x-4">
{{-- Checkbox --}}
<div class="flex-shrink-0">
<input
type="checkbox"
value="{{ $review->id }}"
wire:model.live="selected"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
>
</div>
{{-- Content --}}
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $review->user->name }}
</div>
<div class="text-sm text-gray-500">
{{ $review->created_at->diffForHumans() }}
</div>
</div>
<div class="mt-1">
<div class="flex items-center space-x-2">
<div class="text-yellow-400">
@for ($i = 1; $i <= 5; $i++)
<span @class(['opacity-40' => $i > $review->rating])></span>
@endfor
</div>
@if ($review->title)
<span class="text-gray-900 dark:text-gray-100 font-medium">
{{ $review->title }}
</span>
@endif
</div>
<p class="mt-1 text-gray-600 dark:text-gray-400">
{{ $review->content }}
</p>
<div class="mt-2 text-sm text-gray-500">
For: {{ $review->ride->name }}
</div>
</div>
</div>
{{-- Actions --}}
<div class="flex-shrink-0 flex items-center space-x-2">
<button
wire:click="editReview({{ $review->id }})"
class="inline-flex items-center p-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
✏️
</button>
<button
wire:click="approve({{ $review->id }})"
class="inline-flex items-center p-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
</button>
<button
wire:click="reject({{ $review->id }})"
class="inline-flex items-center p-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
</button>
</div>
</div>
</li>
@empty
<li class="p-4 text-center text-gray-500 dark:text-gray-400">
No reviews found.
</li>
@endforelse
</ul>
{{-- Pagination --}}
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
{{ $reviews->links() }}
</div>
</div>
{{-- Edit Modal --}}
@if ($showEditModal)
<div
class="fixed z-10 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
{{-- Background overlay --}}
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
aria-hidden="true"
></div>
{{-- Modal panel --}}
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<form wire:submit="saveEdit">
<div class="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="space-y-4">
{{-- Rating --}}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Rating
</label>
<div class="mt-1 flex items-center space-x-2">
@for ($i = 1; $i <= 5; $i++)
<button
type="button"
wire:click="$set('form.rating', {{ $i }})"
class="text-2xl focus:outline-none"
>
<span @class([
'text-yellow-400' => $i <= $form['rating'],
'text-gray-300 dark:text-gray-600' => $i > $form['rating'],
])></span>
</button>
@endfor
</div>
@error('form.rating')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
{{-- Title --}}
<div>
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Title
</label>
<input
type="text"
wire:model="form.title"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm dark:bg-gray-700"
>
@error('form.title')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
{{-- Content --}}
<div>
<label for="content" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Content
</label>
<textarea
wire:model="form.content"
rows="4"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm dark:bg-gray-700"
></textarea>
@error('form.content')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
</div>
</div>
{{-- Modal footer --}}
<div class="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="submit"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
>
Save Changes
</button>
<button
type="button"
wire:click="$set('showEditModal', false)"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
@endif
</div>

View File

@@ -0,0 +1,101 @@
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
{{-- Show message if exists --}}
@if ($message)
<div @class([
'p-4 mb-4 rounded-lg',
'bg-green-100 dark:bg-green-800 text-green-700 dark:text-green-200' => !str_contains($message, 'error') && !str_contains($message, 'cannot'),
'bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-200' => str_contains($message, 'error') || str_contains($message, 'cannot'),
])>
{{ $message }}
</div>
@endif
{{-- Review Form --}}
<form wire:submit="save" class="space-y-6">
{{-- Star Rating --}}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Rating <span class="text-red-500">*</span>
</label>
<div class="flex items-center space-x-2">
@for ($i = 1; $i <= 5; $i++)
<button
type="button"
wire:click="$set('rating', {{ $i }})"
class="text-2xl focus:outline-none"
>
<span @class([
'text-yellow-400' => $i <= $rating,
'text-gray-300 dark:text-gray-600' => $i > $rating,
])></span>
</button>
@endfor
</div>
@error('rating')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
{{-- Title Field --}}
<div>
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Title <span class="text-gray-500">(optional)</span>
</label>
<input
type="text"
id="title"
wire:model="title"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 shadow-sm focus:border-primary-500 focus:ring-primary-500"
placeholder="Give your review a title"
>
@error('title')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
{{-- Content Field --}}
<div>
<label for="content" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Review <span class="text-red-500">*</span>
</label>
<textarea
id="content"
wire:model="content"
rows="4"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 shadow-sm focus:border-primary-500 focus:ring-primary-500"
placeholder="Share your experience with this ride"
></textarea>
<p class="mt-1 text-sm text-gray-500">
{{ strlen($content) }}/2000 characters
</p>
@error('content')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
{{-- Submit Button --}}
<div class="flex justify-end space-x-3">
@if ($isEditing)
<button
type="button"
wire:click="resetForm"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
Reset
</button>
@endif
<button
type="submit"
wire:loading.attr="disabled"
class="inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md shadow-sm hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
>
<span wire:loading.remove>
{{ $isEditing ? 'Update Review' : 'Submit Review' }}
</span>
<span wire:loading>
{{ $isEditing ? 'Updating...' : 'Submitting...' }}
</span>
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,189 @@
<div class="space-y-6">
{{-- Message --}}
@if ($message)
<div @class([
'p-4 mb-4 rounded-lg',
'bg-green-100 dark:bg-green-800 text-green-700 dark:text-green-200' => !str_contains($message, 'error') && !str_contains($message, 'cannot'),
'bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-200' => str_contains($message, 'error') || str_contains($message, 'cannot'),
])>
{{ $message }}
</div>
@endif
{{-- Statistics Panel --}}
<div x-data="{ open: @entangle('showStats') }" class="bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<button
type="button"
@click="open = !open"
class="flex justify-between items-center w-full"
>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Review Statistics
</h3>
<span class="transform" :class="{ 'rotate-180': open }">
</span>
</button>
</div>
<div x-show="open" class="p-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="text-center">
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100">
{{ $statistics['average'] }}
</div>
<div class="text-sm text-gray-500">
Average Rating
</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100">
{{ $statistics['total'] }}
</div>
<div class="text-sm text-gray-500">
Total Reviews
</div>
</div>
<div class="col-span-1 md:col-span-1">
@foreach (range(5, 1) as $rating)
<div class="flex items-center">
<span class="w-8 text-sm text-gray-600 dark:text-gray-400">
{{ $rating }}
</span>
<div
x-data="{ width: '{{ $statistics['total'] > 0 ? number_format(($statistics['distribution'][$rating] / $statistics['total']) * 100, 1) : 0 }}%' }"
class="flex-1 h-4 mx-2 bg-gray-200 dark:bg-gray-700 rounded relative overflow-hidden"
>
@if ($statistics['total'] > 0)
<div
class="h-4 bg-yellow-400 rounded absolute inset-y-0 left-0"
:style="{ width }"
></div>
@endif
</div>
<span class="w-8 text-sm text-gray-600 dark:text-gray-400">
{{ $statistics['distribution'][$rating] }}
</span>
</div>
@endforeach
</div>
</div>
</div>
</div>
{{-- Controls --}}
<div class="flex flex-wrap gap-4 items-center justify-between">
{{-- Sort Controls --}}
<div class="flex gap-2">
<button
wire:click="sortBy('created_at')"
@class([
'px-3 py-2 text-sm font-medium rounded-md',
'bg-primary-100 text-primary-700 dark:bg-primary-800 dark:text-primary-200' => $sortField === 'created_at',
'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700' => $sortField !== 'created_at',
])
>
Date
@if ($sortField === 'created_at')
<span>{{ $sortDirection === 'asc' ? '↑' : '↓' }}</span>
@endif
</button>
<button
wire:click="sortBy('rating')"
@class([
'px-3 py-2 text-sm font-medium rounded-md',
'bg-primary-100 text-primary-700 dark:bg-primary-800 dark:text-primary-200' => $sortField === 'rating',
'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700' => $sortField !== 'rating',
])
>
Rating
@if ($sortField === 'rating')
<span>{{ $sortDirection === 'asc' ? '↑' : '↓' }}</span>
@endif
</button>
</div>
{{-- Rating Filter --}}
<div class="flex gap-2">
@foreach (range(1, 5) as $rating)
<button
wire:click="filterByRating({{ $rating }})"
@class([
'px-3 py-2 text-sm font-medium rounded-md',
'bg-yellow-100 text-yellow-700 dark:bg-yellow-800 dark:text-yellow-200' => $ratingFilter === $rating,
'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700' => $ratingFilter !== $rating,
])
>
{{ $rating }}
</button>
@endforeach
@if ($ratingFilter)
<button
wire:click="filterByRating(null)"
class="px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 rounded-md"
>
Clear
</button>
@endif
</div>
</div>
{{-- Reviews List --}}
<div class="space-y-4">
@forelse ($reviews as $review)
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="flex justify-between items-start">
<div>
<div class="flex items-center gap-2">
<div class="text-yellow-400 text-xl">
@for ($i = 1; $i <= 5; $i++)
<span @class(['opacity-40' => $i > $review->rating])></span>
@endfor
</div>
@if ($review->title)
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ $review->title }}
</h3>
@endif
</div>
<p class="mt-2 text-gray-600 dark:text-gray-400">
{{ $review->content }}
</p>
</div>
<div class="text-sm text-gray-500">
{{ $review->created_at->diffForHumans() }}
</div>
</div>
<div class="mt-4 flex items-center justify-between">
<div class="text-sm text-gray-500">
By {{ $review->user->name }}
</div>
<button
wire:click="toggleHelpfulVote({{ $review->id }})"
@class([
'flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md',
'bg-green-100 text-green-700 dark:bg-green-800 dark:text-green-200' => $review->helpfulVotes->contains('user_id', Auth::id()),
'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700' => !$review->helpfulVotes->contains('user_id', Auth::id()),
])
>
<span>Helpful</span>
<span>({{ $review->helpfulVotes->count() }})</span>
</button>
</div>
</div>
@empty
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
@if ($ratingFilter)
No {{ $ratingFilter }}-star reviews yet.
@else
No reviews yet.
@endif
</div>
@endforelse
{{-- Pagination --}}
<div class="mt-6">
{{ $reviews->links() }}
</div>
</div>
</div>