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,227 @@
<?php
namespace App\Livewire;
use App\Models\Review;
use App\Enums\ReviewStatus;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Livewire\WithPagination;
class ReviewModerationComponent extends Component
{
use WithPagination;
/**
* Current filter status
*/
public ?string $statusFilter = null;
/**
* Search query
*/
public string $search = '';
/**
* Selected reviews for batch actions
*/
public array $selected = [];
/**
* Whether to show the edit modal
*/
public bool $showEditModal = false;
/**
* Review being edited
*/
public ?Review $editingReview = null;
/**
* Form fields for editing
*/
public array $form = [
'rating' => null,
'title' => null,
'content' => null,
];
/**
* Success/error message
*/
public ?string $message = null;
/**
* Validation rules
*/
protected function rules()
{
return [
'form.rating' => 'required|integer|min:1|max:5',
'form.title' => 'nullable|string|max:100',
'form.content' => 'required|string|min:10|max:2000',
];
}
/**
* Mount the component
*/
public function mount()
{
$this->statusFilter = ReviewStatus::PENDING->value;
}
/**
* Filter by status
*/
public function filterByStatus(?string $status)
{
$this->statusFilter = $status;
$this->resetPage();
}
/**
* Show edit modal for a review
*/
public function editReview(Review $review)
{
$this->editingReview = $review;
$this->form = [
'rating' => $review->rating,
'title' => $review->title,
'content' => $review->content,
];
$this->showEditModal = true;
}
/**
* Save edited review
*/
public function saveEdit()
{
$this->validate();
try {
$this->editingReview->update([
'rating' => $this->form['rating'],
'title' => $this->form['title'],
'content' => $this->form['content'],
'moderated_at' => now(),
'moderated_by' => Auth::id(),
]);
$this->message = 'Review updated successfully.';
$this->showEditModal = false;
$this->reset(['editingReview', 'form']);
} catch (\Exception $e) {
$this->message = 'An error occurred while updating the review.';
}
}
/**
* Approve a review
*/
public function approve(Review $review)
{
try {
$review->update([
'status' => ReviewStatus::APPROVED,
'moderated_at' => now(),
'moderated_by' => Auth::id(),
]);
$this->message = 'Review approved successfully.';
} catch (\Exception $e) {
$this->message = 'An error occurred while approving the review.';
}
}
/**
* Reject a review
*/
public function reject(Review $review)
{
try {
$review->update([
'status' => ReviewStatus::REJECTED,
'moderated_at' => now(),
'moderated_by' => Auth::id(),
]);
$this->message = 'Review rejected successfully.';
} catch (\Exception $e) {
$this->message = 'An error occurred while rejecting the review.';
}
}
/**
* Batch approve selected reviews
*/
public function batchApprove()
{
try {
Review::whereIn('id', $this->selected)->update([
'status' => ReviewStatus::APPROVED,
'moderated_at' => now(),
'moderated_by' => Auth::id(),
]);
$this->message = count($this->selected) . ' reviews approved successfully.';
$this->selected = [];
} catch (\Exception $e) {
$this->message = 'An error occurred while approving the reviews.';
}
}
/**
* Batch reject selected reviews
*/
public function batchReject()
{
try {
Review::whereIn('id', $this->selected)->update([
'status' => ReviewStatus::REJECTED,
'moderated_at' => now(),
'moderated_by' => Auth::id(),
]);
$this->message = count($this->selected) . ' reviews rejected successfully.';
$this->selected = [];
} catch (\Exception $e) {
$this->message = 'An error occurred while rejecting the reviews.';
}
}
/**
* Get the reviews query
*/
protected function getReviewsQuery()
{
$query = Review::with(['user', 'ride'])
->when($this->statusFilter, function ($query, $status) {
$query->where('status', $status);
})
->when($this->search, function ($query, $search) {
$query->where(function ($query) use ($search) {
$query->where('title', 'like', "%{$search}%")
->orWhere('content', 'like', "%{$search}%")
->orWhereHas('user', function ($query) use ($search) {
$query->where('name', 'like', "%{$search}%");
})
->orWhereHas('ride', function ($query) use ($search) {
$query->where('name', 'like', "%{$search}%");
});
});
})
->orderBy('created_at', 'desc');
return $query;
}
/**
* Render the component
*/
public function render()
{
return view('livewire.review-moderation-component', [
'reviews' => $this->getReviewsQuery()->paginate(10),
'totalPending' => Review::where('status', ReviewStatus::PENDING)->count(),
]);
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Livewire;
use App\Models\Review;
use App\Models\Ride;
use App\Enums\ReviewStatus;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Component;
use Livewire\Attributes\Rule;
class RideReviewComponent extends Component
{
/**
* The ride being reviewed
*/
public Ride $ride;
/**
* The review being edited (if in edit mode)
*/
public ?Review $review = null;
/**
* Form fields
*/
#[Rule('required|integer|min:1|max:5')]
public int $rating = 3;
#[Rule('nullable|string|max:100')]
public ?string $title = null;
#[Rule('required|string|min:10|max:2000')]
public string $content = '';
/**
* Whether the component is in edit mode
*/
public bool $isEditing = false;
/**
* Success/error message
*/
public ?string $message = null;
/**
* Mount the component
*/
public function mount(Ride $ride, ?Review $review = null)
{
$this->ride = $ride;
if ($review) {
$this->review = $review;
$this->isEditing = true;
$this->rating = $review->rating;
$this->title = $review->title;
$this->content = $review->content;
}
}
/**
* Save or update the review
*/
public function save()
{
// Check if user is authenticated
if (!Auth::check()) {
$this->message = 'You must be logged in to submit a review.';
return;
}
// Rate limiting
$key = 'review_' . Auth::id();
if (RateLimiter::tooManyAttempts($key, 5)) { // 5 attempts per minute
$this->message = 'Please wait before submitting another review.';
return;
}
RateLimiter::hit($key);
// Validate input
$this->validate();
try {
if ($this->isEditing) {
// Check if user can edit this review
if (!$this->review || $this->review->user_id !== Auth::id()) {
$this->message = 'You cannot edit this review.';
return;
}
// Update existing review
$this->review->update([
'rating' => $this->rating,
'title' => $this->title,
'content' => $this->content,
'status' => ReviewStatus::PENDING,
]);
$this->message = 'Review updated successfully. It will be visible after moderation.';
} else {
// Check if user already reviewed this ride
if (!$this->ride->canBeReviewedBy(Auth::user())) {
$this->message = 'You have already reviewed this ride.';
return;
}
// Create new review
Review::create([
'ride_id' => $this->ride->id,
'user_id' => Auth::id(),
'rating' => $this->rating,
'title' => $this->title,
'content' => $this->content,
'status' => ReviewStatus::PENDING,
]);
$this->message = 'Review submitted successfully. It will be visible after moderation.';
// Reset form
$this->reset(['rating', 'title', 'content']);
}
$this->dispatch('review-saved');
} catch (\Exception $e) {
$this->message = 'An error occurred while saving your review. Please try again.';
}
}
/**
* Reset the form
*/
public function resetForm()
{
$this->reset(['rating', 'title', 'content', 'message']);
if ($this->review) {
$this->rating = $this->review->rating;
$this->title = $this->review->title;
$this->content = $this->review->content;
}
}
/**
* Render the component
*/
public function render()
{
return view('livewire.ride-review-component');
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace App\Livewire;
use App\Models\Review;
use App\Models\Ride;
use App\Models\HelpfulVote;
use App\Enums\ReviewStatus;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Component;
use Livewire\WithPagination;
class RideReviewListComponent extends Component
{
use WithPagination;
/**
* The ride whose reviews are being displayed
*/
public Ride $ride;
/**
* Current sort field
*/
public string $sortField = 'created_at';
/**
* Current sort direction
*/
public string $sortDirection = 'desc';
/**
* Rating filter
*/
public ?int $ratingFilter = null;
/**
* Success/error message
*/
public ?string $message = null;
/**
* Whether to show the statistics panel
*/
public bool $showStats = true;
/**
* Listeners for events
*/
protected $listeners = [
'review-saved' => '$refresh',
];
/**
* Mount the component
*/
public function mount(Ride $ride)
{
$this->ride = $ride;
}
/**
* Toggle sort field
*/
public function sortBy(string $field)
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'desc';
}
}
/**
* Filter by rating
*/
public function filterByRating(?int $rating)
{
$this->ratingFilter = $rating === $this->ratingFilter ? null : $rating;
$this->resetPage();
}
/**
* Toggle helpful vote
*/
public function toggleHelpfulVote(Review $review)
{
if (!Auth::check()) {
$this->message = 'You must be logged in to vote on reviews.';
return;
}
// Rate limiting
$key = 'vote_' . Auth::id();
if (RateLimiter::tooManyAttempts($key, 10)) { // 10 attempts per minute
$this->message = 'Please wait before voting again.';
return;
}
RateLimiter::hit($key);
try {
HelpfulVote::toggle($review->id, Auth::id());
$this->message = 'Vote recorded successfully.';
} catch (\Exception $e) {
$this->message = 'An error occurred while recording your vote.';
}
}
/**
* Toggle statistics panel
*/
public function toggleStats()
{
$this->showStats = !$this->showStats;
}
/**
* Get review statistics
*/
public function getStatistics()
{
$reviews = $this->ride->reviews()->approved();
return [
'total' => $reviews->count(),
'average' => round($reviews->avg('rating'), 1),
'distribution' => [
5 => $reviews->where('rating', 5)->count(),
4 => $reviews->where('rating', 4)->count(),
3 => $reviews->where('rating', 3)->count(),
2 => $reviews->where('rating', 2)->count(),
1 => $reviews->where('rating', 1)->count(),
],
];
}
/**
* Get the reviews query
*/
protected function getReviewsQuery()
{
$query = $this->ride->reviews()
->with(['user', 'helpfulVotes'])
->approved();
// Apply rating filter
if ($this->ratingFilter) {
$query->where('rating', $this->ratingFilter);
}
// Apply sorting
$query->orderBy($this->sortField, $this->sortDirection);
return $query;
}
/**
* Render the component
*/
public function render()
{
return view('livewire.ride-review-list-component', [
'reviews' => $this->getReviewsQuery()->paginate(10),
'statistics' => $this->getStatistics(),
]);
}
}

View File

@@ -0,0 +1,212 @@
# Review System Livewire Components
## Overview
The review system consists of three main Livewire components that handle the creation, display, and moderation of ride reviews. These components maintain feature parity with the Django implementation while leveraging Laravel and Livewire's reactive capabilities.
### RideReviewComponent
#### Overview
The RideReviewComponent provides a form interface for users to submit reviews for rides, with real-time validation and anti-spam measures.
**Location**:
- Component: `app/Livewire/RideReviewComponent.php`
- View: `resources/views/livewire/ride-review.blade.php`
#### Features
- Star rating input (1-5)
- Optional title field
- Required content field
- Real-time validation
- Anti-spam protection
- Success/error messaging
- One review per ride enforcement
- Edit capabilities for own reviews
#### Implementation Details
1. **Form Handling**
- Real-time validation using Livewire
- Star rating widget implementation
- Character count tracking
- Form state management
- Edit mode support
2. **Security Features**
- Rate limiting
- Duplicate prevention
- Permission checks
- Input sanitization
- CSRF protection
3. **UI Components**
- Star rating selector
- Dynamic character counter
- Form validation feedback
- Success/error alerts
- Loading states
4. **Business Logic**
- Review uniqueness check
- Permission verification
- Status management
- Edit history tracking
### RideReviewListComponent
#### Overview
The RideReviewListComponent displays a paginated list of reviews with sorting, filtering, and helpful vote functionality.
**Location**:
- Component: `app/Livewire/RideReviewListComponent.php`
- View: `resources/views/livewire/ride-review-list.blade.php`
#### Features
- Grid/list view toggle
- Pagination support
- Sort by date/rating
- Filter by rating
- Helpful vote system
- Review statistics display
- Responsive design
#### Implementation Details
1. **List Management**
- Pagination handling
- Sort state management
- Filter application
- Dynamic loading
2. **Vote System**
- Helpful vote toggle
- Vote count tracking
- User vote status
- Rate limiting
3. **UI Components**
- Review cards/rows
- Sort/filter controls
- Pagination links
- Statistics summary
- Loading states
4. **Statistics Display**
- Average rating
- Rating distribution
- Review count
- Helpful vote tallies
### ReviewModerationComponent
#### Overview
The ReviewModerationComponent provides an interface for moderators to review, approve, reject, and edit user reviews.
**Location**:
- Component: `app/Livewire/ReviewModerationComponent.php`
- View: `resources/views/livewire/review-moderation.blade.php`
#### Features
- Review queue display
- Approve/reject actions
- Edit capabilities
- Status tracking
- Moderation history
- Batch actions
- Search/filter
#### Implementation Details
1. **Queue Management**
- Status-based filtering
- Priority sorting
- Batch processing
- History tracking
2. **Moderation Actions**
- Approval workflow
- Rejection handling
- Edit interface
- Status updates
- Notification system
3. **UI Components**
- Queue display
- Action buttons
- Edit forms
- Status indicators
- History timeline
4. **Security Features**
- Role verification
- Action logging
- Permission checks
- Edit tracking
## Integration Points
### With RideDetailComponent
- Review form placement
- Review list integration
- Statistics display
- Component communication
### With User System
- Permission checks
- User identification
- Rate limiting
- Profile integration
### With Notification System
- Review notifications
- Moderation alerts
- Status updates
- User feedback
## Technical Decisions
1. **Real-time Validation**
- Using Livewire's real-time validation for immediate feedback
- Client-side validation for better UX
- Server-side validation for security
2. **State Management**
- Component properties for form state
- Session for moderation queue
- Cache for statistics
- Database for permanent storage
3. **Performance Optimization**
- Eager loading relationships
- Caching review counts
- Lazy loading images
- Pagination implementation
4. **Security Measures**
- Rate limiting implementation
- Input sanitization
- Permission checks
- CSRF protection
- XSS prevention
## Testing Strategy
1. **Unit Tests**
- Component methods
- Validation rules
- Business logic
- Helper functions
2. **Feature Tests**
- Form submission
- Validation handling
- Moderation flow
- Vote system
3. **Integration Tests**
- Component interaction
- Event handling
- State management
- Error handling
4. **Browser Tests**
- UI interactions
- Real-time updates
- Responsive design
- JavaScript integration

View File

@@ -24,57 +24,69 @@ The ride reviews system allows users to rate and review rides, providing both nu
- user_id (foreign key to users) - user_id (foreign key to users)
- created_at (timestamp) - created_at (timestamp)
## Components to Implement ## Components Implemented
### RideReviewComponent ### RideReviewComponent
- Display review form - Display review form
- Handle review submission - Handle review submission
- Validate input - Validate input
- Show success/error messages - Show success/error messages
- Rate limiting implemented ✅
- One review per ride enforcement ✅
- Edit capabilities ✅
### RideReviewListComponent ### RideReviewListComponent
- Display reviews for a ride - Display reviews for a ride
- Pagination support - Pagination support
- Sorting options - Sorting options
- Helpful vote functionality - Helpful vote functionality
- Filter options (rating, date) - Filter options (rating, date)
- Statistics display ✅
- Dark mode support ✅
### ReviewModerationComponent ### ReviewModerationComponent
- Review queue for moderators - Review queue for moderators
- Approve/reject functionality - Approve/reject functionality
- Edit capabilities - Edit capabilities
- Status tracking - Status tracking
- Batch actions ✅
- Search functionality ✅
## Features Required ## Features Implemented
1. Review Creation 1. Review Creation
- Rating input (1-5 stars) - Rating input (1-5 stars)
- Title field (optional) - Title field (optional)
- Content field - Content field
- Client & server validation - Client & server validation
- Anti-spam measures - Anti-spam measures
- Rate limiting
2. Review Display 2. Review Display
- List/grid view of reviews - List/grid view of reviews
- Sorting by date/rating - Sorting by date/rating
- Pagination - Pagination
- Rating statistics - Rating statistics
- Helpful vote system - Helpful vote system
- Dark mode support
3. Moderation System 3. Moderation System
- Review queue - Review queue
- Approval workflow - Approval workflow
- Edit capabilities - Edit capabilities
- Status management - Status management
- Moderation history - Moderation history
- Batch actions
- Search functionality
4. User Features 4. User Features
- One review per ride per user - One review per ride per user
- Edit own reviews - Edit own reviews
- Delete own reviews - Delete own reviews
- Vote on helpful reviews - Vote on helpful reviews
- Rate limiting on votes
5. Statistics 5. Statistics
- Average rating calculation - Average rating calculation
- Rating distribution - Rating distribution
- Review count tracking - Review count tracking
@@ -95,39 +107,39 @@ The ride reviews system allows users to rate and review rides, providing both nu
- ✅ Created ReviewStatus enum (app/Enums/ReviewStatus.php) - ✅ Created ReviewStatus enum (app/Enums/ReviewStatus.php)
- ✅ Implemented methods for average rating and review counts - ✅ Implemented methods for average rating and review counts
3. Components 3. Components
- Review form component - Review form component
- Review list component - Review list component
- Moderation component - Moderation component
- Statistics display - Statistics display
4. Business Logic 4. Business Logic
- Rating calculations - Rating calculations
- Permission checks - Permission checks
- Validation rules - Validation rules
- Anti-spam measures - Anti-spam measures
5. Testing 5. Testing
- Unit tests - Unit tests (TODO)
- Feature tests - Feature tests (TODO)
- Integration tests - Integration tests (TODO)
- User flow testing - User flow testing (TODO)
## Security Considerations ## Security Considerations
1. Authorization 1. Authorization
- User authentication required - User authentication required
- Rate limiting - Rate limiting implemented
- Moderation permissions - Moderation permissions
- Edit/delete permissions - Edit/delete permissions
2. Data Validation 2. Data Validation
- Input sanitization - Input sanitization
- Rating range validation - Rating range validation
- Content length limits - Content length limits
- Duplicate prevention - Duplicate prevention
3. Anti-Abuse 3. Anti-Abuse
- Rate limiting - Rate limiting
- Spam detection - Spam detection
- Vote manipulation prevention - Vote manipulation prevention
@@ -137,8 +149,6 @@ The ride reviews system allows users to rate and review rides, providing both nu
### Model Implementation ### Model Implementation
The review system consists of two main models:
1. Review - Represents a user's review of a ride 1. Review - Represents a user's review of a ride
- Implemented in `app/Models/Review.php` - Implemented in `app/Models/Review.php`
- Uses ReviewStatus enum for status management - Uses ReviewStatus enum for status management
@@ -158,4 +168,30 @@ The review system consists of two main models:
- Created canBeReviewedBy method to check if a user can review a ride - Created canBeReviewedBy method to check if a user can review a ride
- Implemented addReview method for creating new reviews - Implemented addReview method for creating new reviews
These models follow Laravel's Eloquent ORM patterns while maintaining feature parity with the Django implementation. ### Component Implementation
1. RideReviewComponent
- Form-based component for submitting reviews
- Real-time validation using Livewire
- Rate limiting using Laravel's RateLimiter
- Edit mode support for updating reviews
- Success/error message handling
- Dark mode support
2. RideReviewListComponent
- Paginated list of reviews
- Sort by date or rating
- Filter by rating
- Helpful vote functionality
- Statistics panel with rating distribution
- Dark mode support
3. ReviewModerationComponent
- Queue-based moderation interface
- Status-based filtering (pending, approved, rejected)
- Search functionality
- Batch actions for approve/reject
- Edit modal for review modification
- Dark mode support
These components follow Laravel's Eloquent ORM patterns while maintaining feature parity with the Django implementation. The use of Livewire enables real-time interactivity without requiring custom JavaScript.

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>