Skip to content

Commit 9196caa

Browse files
author
nejc
committed
feat: Add roadmap page for feature requests
- Add roadmap route and controller method - Create roadmap view with 3-column layout (Pending, Under Review, In Progress) - Add roadmap navigation link to public layout - Implement getForRoadmap method in service and repository - Exclude completed and rejected requests from roadmap - Add filtering by category and search functionality - Include vote information and statistics - Responsive design with clean card-based layout - Add roadmap statistics section
1 parent 6ce143f commit 9196caa

File tree

6 files changed

+276
-0
lines changed

6 files changed

+276
-0
lines changed

resources/views/layouts/public.blade.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@
5757
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {{ request()->routeIs('feature-requests.index') ? 'bg-blue-100 text-blue-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100' }}">
5858
All Requests
5959
</a>
60+
<a href="{{ route('feature-requests.roadmap') }}"
61+
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {{ request()->routeIs('feature-requests.roadmap') ? 'bg-blue-100 text-blue-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100' }}">
62+
Roadmap
63+
</a>
6064
</div>
6165
</div>
6266

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
@extends('feature-requests::layouts.public')
2+
3+
@section('title', 'Feature Requests Roadmap')
4+
5+
@section('content')
6+
<div class="min-h-screen bg-gray-50">
7+
<!-- Header -->
8+
<div class="bg-white shadow-sm border-b">
9+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
10+
<div class="py-6">
11+
<div class="flex items-center justify-between">
12+
<div>
13+
<h1 class="text-3xl font-bold text-gray-900">Feature Requests Roadmap</h1>
14+
<p class="mt-2 text-gray-600">Track the progress of feature requests from idea to completion</p>
15+
</div>
16+
<div class="flex space-x-3">
17+
<a href="{{ route('feature-requests.index') }}"
18+
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
19+
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
20+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
21+
</svg>
22+
All Requests
23+
</a>
24+
<a href="{{ route('feature-requests.create') }}"
25+
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
26+
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
27+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
28+
</svg>
29+
Submit Request
30+
</a>
31+
</div>
32+
</div>
33+
</div>
34+
</div>
35+
</div>
36+
37+
<!-- Filters -->
38+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
39+
<div class="bg-white rounded-lg shadow-sm border p-6 mb-6">
40+
<form method="GET" action="{{ route('feature-requests.roadmap') }}" class="flex flex-wrap gap-4">
41+
<div class="flex-1 min-w-64">
42+
<label for="search" class="block text-sm font-medium text-gray-700 mb-2">Search</label>
43+
<input type="text"
44+
name="search"
45+
id="search"
46+
value="{{ request('search') }}"
47+
placeholder="Search feature requests..."
48+
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
49+
</div>
50+
<div class="min-w-48">
51+
<label for="category_id" class="block text-sm font-medium text-gray-700 mb-2">Category</label>
52+
<select name="category_id"
53+
id="category_id"
54+
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
55+
<option value="">All Categories</option>
56+
@foreach($categories as $category)
57+
<option value="{{ $category->id }}" {{ request('category_id') == $category->id ? 'selected' : '' }}>
58+
{{ $category->name }} ({{ $category->feature_requests_count }})
59+
</option>
60+
@endforeach
61+
</select>
62+
</div>
63+
<div class="flex items-end">
64+
<button type="submit"
65+
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
66+
Filter
67+
</button>
68+
@if(request()->hasAny(['search', 'category_id']))
69+
<a href="{{ route('feature-requests.roadmap') }}"
70+
class="ml-2 px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500">
71+
Clear
72+
</a>
73+
@endif
74+
</div>
75+
</form>
76+
</div>
77+
78+
<!-- Roadmap Columns -->
79+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
80+
@php
81+
$statusConfig = [
82+
'pending' => ['title' => 'Pending', 'color' => 'gray', 'icon' => 'clock'],
83+
'under_review' => ['title' => 'Under Review', 'color' => 'yellow', 'icon' => 'eye'],
84+
'in_progress' => ['title' => 'In Progress', 'color' => 'blue', 'icon' => 'play']
85+
];
86+
@endphp
87+
88+
@foreach($statusConfig as $status => $config)
89+
<div class="bg-white rounded-lg shadow-sm border">
90+
<!-- Column Header -->
91+
<div class="p-4 border-b bg-{{ $config['color'] }}-50">
92+
<div class="flex items-center justify-between">
93+
<h3 class="text-lg font-semibold text-{{ $config['color'] }}-800">
94+
{{ $config['title'] }}
95+
</h3>
96+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ $config['color'] }}-100 text-{{ $config['color'] }}-800">
97+
{{ $featureRequests[$status]->count() }}
98+
</span>
99+
</div>
100+
</div>
101+
102+
<!-- Feature Requests -->
103+
<div class="p-4 space-y-3 min-h-96">
104+
@forelse($featureRequests[$status] as $request)
105+
<div class="bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors cursor-pointer group"
106+
onclick="window.location.href='{{ route('feature-requests.show', $request->slug) }}'">
107+
108+
<!-- Title -->
109+
<h4 class="font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-2">
110+
{{ $request->title }}
111+
</h4>
112+
113+
<!-- Description -->
114+
<p class="text-sm text-gray-600 mb-3 line-clamp-2">
115+
{{ Str::limit($request->description, 100) }}
116+
</p>
117+
118+
<!-- Meta Information -->
119+
<div class="flex items-center justify-between text-xs text-gray-500">
120+
<div class="flex items-center space-x-3">
121+
<!-- Votes -->
122+
<div class="flex items-center space-x-1">
123+
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
124+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 11l3-3 3 3m0 0l3-3 3 3m-3-3v8"></path>
125+
</svg>
126+
<span>{{ ($request->up_votes ?? 0) - ($request->down_votes ?? 0) }}</span>
127+
</div>
128+
129+
<!-- Comments -->
130+
<div class="flex items-center space-x-1">
131+
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
132+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
133+
</svg>
134+
<span>{{ $request->comment_count ?? 0 }}</span>
135+
</div>
136+
</div>
137+
138+
<!-- Date -->
139+
<span>{{ $request->created_at->diffForHumans() }}</span>
140+
</div>
141+
142+
<!-- Category -->
143+
@if($request->category)
144+
<div class="mt-2">
145+
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
146+
{{ $request->category->name }}
147+
</span>
148+
</div>
149+
@endif
150+
</div>
151+
@empty
152+
<div class="text-center py-8 text-gray-500">
153+
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
154+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
155+
</svg>
156+
<p class="text-sm">No {{ strtolower($config['title']) }} requests</p>
157+
</div>
158+
@endforelse
159+
</div>
160+
</div>
161+
@endforeach
162+
</div>
163+
164+
<!-- Statistics -->
165+
<div class="mt-8 bg-white rounded-lg shadow-sm border p-6">
166+
<h3 class="text-lg font-semibold text-gray-900 mb-4">Roadmap Statistics</h3>
167+
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
168+
<div class="text-center">
169+
<div class="text-2xl font-bold text-gray-900">{{ $statistics['total'] ?? 0 }}</div>
170+
<div class="text-sm text-gray-600">Total Requests</div>
171+
</div>
172+
<div class="text-center">
173+
<div class="text-2xl font-bold text-blue-600">{{ $statistics['in_progress'] ?? 0 }}</div>
174+
<div class="text-sm text-gray-600">In Progress</div>
175+
</div>
176+
<div class="text-center">
177+
<div class="text-2xl font-bold text-green-600">{{ $statistics['completed'] ?? 0 }}</div>
178+
<div class="text-sm text-gray-600">Completed</div>
179+
</div>
180+
<div class="text-center">
181+
<div class="text-2xl font-bold text-gray-600">{{ $statistics['total_votes'] ?? 0 }}</div>
182+
<div class="text-sm text-gray-600">Total Votes</div>
183+
</div>
184+
</div>
185+
</div>
186+
</div>
187+
</div>
188+
@endsection

routes/web.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
// Feature Requests (Customer View) - All require authentication
2222
Route::get('/', [FeatureRequestController::class, 'publicIndex'])->name('index');
2323

24+
// Roadmap - Must come before /{slug}
25+
Route::get('/roadmap', [FeatureRequestController::class, 'roadmap'])->name('roadmap');
26+
2427
// Create Feature Request - Must come before /{slug}
2528
Route::get('/create', [FeatureRequestController::class, 'create'])->name('create');
2629
Route::post('/', [FeatureRequestController::class, 'store'])->name('store');

src/Http/Controllers/FeatureRequestController.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,33 @@ public function publicIndex(Request $request): View
7171
return view('feature-requests::public.index', compact('featureRequests', 'categories', 'statistics'));
7272
}
7373

74+
/**
75+
* Display the roadmap of feature requests (Customer).
76+
*/
77+
public function roadmap(Request $request): View
78+
{
79+
$filters = $request->only(['category_id', 'search']);
80+
$filters['is_public'] = true; // Only show public requests
81+
82+
// Get feature requests grouped by status
83+
$featureRequests = $this->featureRequestService->getForRoadmap($filters);
84+
$categories = $this->categoryService->getActiveWithCounts();
85+
$statistics = $this->featureRequestService->getPublicStatistics();
86+
87+
// Add vote information for each feature request
88+
if (auth()->check()) {
89+
foreach ($featureRequests as $status => $requests) {
90+
$featureRequests[$status] = $requests->map(function ($featureRequest) {
91+
$featureRequest->user_has_voted = app(\LaravelPlus\FeatureRequests\Services\VoteService::class)->hasUserVoted($featureRequest->id);
92+
$featureRequest->user_vote_type = app(\LaravelPlus\FeatureRequests\Services\VoteService::class)->getUserVoteType($featureRequest->id);
93+
return $featureRequest;
94+
});
95+
}
96+
}
97+
98+
return view('feature-requests::public.roadmap', compact('featureRequests', 'categories', 'statistics'));
99+
}
100+
74101
/**
75102
* Show the form for creating a new resource.
76103
*/

src/Repositories/FeatureRequestRepository.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,44 @@ public function getPublicStatistics(): array
281281
];
282282
}
283283

284+
/**
285+
* Get feature requests grouped by status for roadmap.
286+
*/
287+
public function getForRoadmap(array $filters = []): array
288+
{
289+
$query = $this->model->newQuery();
290+
291+
// Apply filters
292+
if (isset($filters['category_id'])) {
293+
$query->category($filters['category_id']);
294+
}
295+
296+
if (isset($filters['search'])) {
297+
$query->search($filters['search']);
298+
}
299+
300+
if (isset($filters['is_public'])) {
301+
$query->where('is_public', $filters['is_public']);
302+
}
303+
304+
// Get feature requests grouped by status
305+
$featureRequests = $query->with(['user', 'category'])
306+
->orderBy('vote_count', 'desc')
307+
->orderBy('created_at', 'desc')
308+
->get()
309+
->groupBy('status');
310+
311+
// Ensure all statuses are present (excluding rejected and completed from roadmap)
312+
$statuses = ['pending', 'under_review', 'in_progress'];
313+
$result = [];
314+
315+
foreach ($statuses as $status) {
316+
$result[$status] = $featureRequests->get($status, collect());
317+
}
318+
319+
return $result;
320+
}
321+
284322
/**
285323
* Get feature requests that need attention.
286324
*/

src/Services/FeatureRequestService.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,22 @@ public function getPublicStatistics(): array
242242
return $this->featureRequestRepository->getPublicStatistics();
243243
}
244244

245+
/**
246+
* Get feature requests grouped by status for roadmap.
247+
*/
248+
public function getForRoadmap(array $filters = []): array
249+
{
250+
$cacheKey = 'feature_requests_roadmap_' . md5(serialize($filters));
251+
252+
if (config('feature-requests.cache.enabled', true)) {
253+
return Cache::tags(['feature-requests'])->remember($cacheKey, config('feature-requests.cache.ttl', 3600), function () use ($filters) {
254+
return $this->featureRequestRepository->getForRoadmap($filters);
255+
});
256+
}
257+
258+
return $this->featureRequestRepository->getForRoadmap($filters);
259+
}
260+
245261
/**
246262
* Get feature requests that need attention.
247263
*/

0 commit comments

Comments
 (0)