| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\App\Services\Filter; |
| 4 |
|
| 5 |
use FluentCart\App\Models\ProductReview; |
| 6 |
use FluentCart\Framework\Support\Arr; |
| 7 |
|
| 8 |
class ReviewFilter extends BaseFilter |
| 9 |
{ |
| 10 |
public string $defaultSortBy = 'id'; |
| 11 |
|
| 12 |
public function applySimpleFilter(?string $search = null): void |
| 13 |
{ |
| 14 |
$isApplied = $this->applySimpleOperatorFilter(); |
| 15 |
if ($isApplied) { |
| 16 |
return; |
| 17 |
} |
| 18 |
|
| 19 |
$searchTerm = $search ?? $this->search; |
| 20 |
|
| 21 |
$this->query = $this->query->when($searchTerm, function ($query, $search) { |
| 22 |
// Escape LIKE wildcards first — the search() scope adds the |
| 23 |
// surrounding %, so a literal % or _ in the term must not widen |
| 24 |
// the match. |
| 25 |
$searchValue = addcslashes(trim($search), '\\%_'); |
| 26 |
|
| 27 |
return $query->where(function ($query) use ($searchValue) { |
| 28 |
$query->search([ |
| 29 |
'reviewer_name' => ['column' => 'reviewer_name', 'operator' => 'like_all', 'value' => $searchValue], |
| 30 |
'reviewer_email' => ['column' => 'reviewer_email', 'operator' => 'or_like_all', 'value' => $searchValue], |
| 31 |
'title' => ['column' => 'title', 'operator' => 'or_like_all', 'value' => $searchValue], |
| 32 |
'review' => ['column' => 'review', 'operator' => 'or_like_all', 'value' => $searchValue], |
| 33 |
])->orWhereHas('product', function ($productQuery) use ($searchValue) { |
| 34 |
$productQuery->search([ |
| 35 |
'post_title' => ['column' => 'post_title', 'operator' => 'like_all', 'value' => $searchValue], |
| 36 |
]); |
| 37 |
}); |
| 38 |
}); |
| 39 |
}); |
| 40 |
} |
| 41 |
|
| 42 |
/** |
| 43 |
* Maps tab keys to the column used for active view filtering. |
| 44 |
*/ |
| 45 |
public function tabsMap(): array |
| 46 |
{ |
| 47 |
return [ |
| 48 |
'approved' => 'status', |
| 49 |
'pending' => 'status', |
| 50 |
'spam' => 'status', |
| 51 |
'trash' => 'status', |
| 52 |
]; |
| 53 |
} |
| 54 |
|
| 55 |
public function getModel(): string |
| 56 |
{ |
| 57 |
return ProductReview::class; |
| 58 |
} |
| 59 |
|
| 60 |
/** |
| 61 |
* Whitelists `with[]=product` (admin Product column, filter banner) and |
| 62 |
* `with[]=customer` (reviewer link). No extra gate: already behind |
| 63 |
* `reviews/manage`. |
| 64 |
* |
| 65 |
* @return array<string, callable> |
| 66 |
*/ |
| 67 |
protected function allowedWiths(): array |
| 68 |
{ |
| 69 |
return [ |
| 70 |
'product' => static function ($query) { |
| 71 |
return $query->with(['product']); |
| 72 |
}, |
| 73 |
'customer' => static function ($query) { |
| 74 |
return $query->with(['customer']); |
| 75 |
}, |
| 76 |
]; |
| 77 |
} |
| 78 |
|
| 79 |
public static function getFilterName(): string |
| 80 |
{ |
| 81 |
return 'reviews'; |
| 82 |
} |
| 83 |
|
| 84 |
public function applyActiveViewFilter(?string $activeView = null): void |
| 85 |
{ |
| 86 |
$view = $activeView ?? $this->activeView; |
| 87 |
|
| 88 |
if (!$view || $view === 'all') { |
| 89 |
return; |
| 90 |
} |
| 91 |
|
| 92 |
$column = Arr::get($this->tabsMap(), $view); |
| 93 |
|
| 94 |
if (!$column) { |
| 95 |
return; |
| 96 |
} |
| 97 |
|
| 98 |
$this->query->where($column, $view); |
| 99 |
} |
| 100 |
|
| 101 |
public static function getSearchableFields(): array |
| 102 |
{ |
| 103 |
return [ |
| 104 |
'id' => [ |
| 105 |
'column' => 'id', |
| 106 |
'description' => __('Review ID', 'fluent-cart'), |
| 107 |
'type' => 'numeric', |
| 108 |
'examples' => [ |
| 109 |
'id = 1', |
| 110 |
'id > 5', |
| 111 |
'id :: 1-10', |
| 112 |
], |
| 113 |
], |
| 114 |
]; |
| 115 |
} |
| 116 |
|
| 117 |
public static function advanceFilterOptions(): array |
| 118 |
{ |
| 119 |
return [ |
| 120 |
'review_property' => [ |
| 121 |
'label' => __('Review Property', 'fluent-cart'), |
| 122 |
'value' => 'review_property', |
| 123 |
'children' => [ |
| 124 |
[ |
| 125 |
'label' => __('Star Rating', 'fluent-cart'), |
| 126 |
'value' => 'star_rating', |
| 127 |
'filter_type' => 'custom', |
| 128 |
'type' => 'selections', |
| 129 |
'options' => [ |
| 130 |
'5' => __('5 Stars', 'fluent-cart'), |
| 131 |
'4' => __('4 Stars', 'fluent-cart'), |
| 132 |
'3' => __('3 Stars', 'fluent-cart'), |
| 133 |
'2' => __('2 Stars', 'fluent-cart'), |
| 134 |
'1' => __('1 Star', 'fluent-cart'), |
| 135 |
], |
| 136 |
'is_multiple' => false, |
| 137 |
'callback' => static function ($query, $item) { |
| 138 |
$operator = Arr::get($item, 'operator', '='); |
| 139 |
if (!in_array($operator, ['=', '!=', '>', '<', '>=', '<='])) { |
| 140 |
$operator = '='; |
| 141 |
} |
| 142 |
$value = absint(Arr::get($item, 'value', 0)); |
| 143 |
if ($value < 1 || $value > 5) { |
| 144 |
return; |
| 145 |
} |
| 146 |
$query->where('rating', $operator, $value); |
| 147 |
}, |
| 148 |
], |
| 149 |
[ |
| 150 |
'label' => __('Verified Purchase', 'fluent-cart'), |
| 151 |
'value' => 'verified_purchase', |
| 152 |
'filter_type' => 'custom', |
| 153 |
'type' => 'selections', |
| 154 |
'options' => [ |
| 155 |
'yes' => __('Yes', 'fluent-cart'), |
| 156 |
'no' => __('No', 'fluent-cart'), |
| 157 |
], |
| 158 |
'is_multiple' => false, |
| 159 |
'is_only_in' => true, |
| 160 |
'callback' => static function ($query, $item) { |
| 161 |
$value = Arr::get($item, 'value'); |
| 162 |
if ($value === 'yes') { |
| 163 |
$query->where('is_verified', 1); |
| 164 |
} elseif ($value === 'no') { |
| 165 |
$query->where('is_verified', 0); |
| 166 |
} |
| 167 |
}, |
| 168 |
], |
| 169 |
[ |
| 170 |
'label' => __('Has Admin Reply', 'fluent-cart'), |
| 171 |
'value' => 'has_admin_reply', |
| 172 |
'filter_type' => 'custom', |
| 173 |
'type' => 'selections', |
| 174 |
'options' => [ |
| 175 |
'yes' => __('Yes', 'fluent-cart'), |
| 176 |
'no' => __('No', 'fluent-cart'), |
| 177 |
], |
| 178 |
'is_multiple' => false, |
| 179 |
'is_only_in' => true, |
| 180 |
'callback' => static function ($query, $item) { |
| 181 |
$value = Arr::get($item, 'value'); |
| 182 |
if ($value === 'yes') { |
| 183 |
$query->hasReplies(); |
| 184 |
} elseif ($value === 'no') { |
| 185 |
$query->withoutReplies(); |
| 186 |
} |
| 187 |
}, |
| 188 |
], |
| 189 |
[ |
| 190 |
'label' => __('Review Date', 'fluent-cart'), |
| 191 |
'value' => 'created_at', |
| 192 |
'filter_type' => 'date', |
| 193 |
'type' => 'dates', |
| 194 |
'is_multiple' => false, |
| 195 |
], |
| 196 |
[ |
| 197 |
'label' => __('Reviewer Name', 'fluent-cart'), |
| 198 |
'value' => 'reviewer_name', |
| 199 |
'filter_type' => 'column', |
| 200 |
'type' => 'text', |
| 201 |
'is_multiple' => false, |
| 202 |
], |
| 203 |
[ |
| 204 |
'label' => __('Reviewer Email', 'fluent-cart'), |
| 205 |
'value' => 'reviewer_email', |
| 206 |
'filter_type' => 'column', |
| 207 |
'type' => 'text', |
| 208 |
'is_multiple' => false, |
| 209 |
], |
| 210 |
], |
| 211 |
], |
| 212 |
]; |
| 213 |
} |
| 214 |
|
| 215 |
protected function parseSortBy(): string |
| 216 |
{ |
| 217 |
$sortBy = Arr::get($this->args, 'sort_by'); |
| 218 |
|
| 219 |
if (empty($sortBy)) { |
| 220 |
return $this->defaultSortBy; |
| 221 |
} |
| 222 |
|
| 223 |
$allowedSorts = [ |
| 224 |
'id' => 'id', |
| 225 |
'created_at' => 'created_at', |
| 226 |
'reviewer_name' => 'reviewer_name', |
| 227 |
'rating' => 'rating', |
| 228 |
]; |
| 229 |
|
| 230 |
return Arr::get($allowedSorts, $sortBy, $this->defaultSortBy); |
| 231 |
} |
| 232 |
|
| 233 |
protected function applySort() |
| 234 |
{ |
| 235 |
// Pass the raw requested sort_by (e.g. 'rating', 'id') to the hook |
| 236 |
// so extensions can detect custom sort keys before they are mapped to columns. |
| 237 |
$rawSortBy = Arr::get($this->args, 'sort_by', $this->sortBy); |
| 238 |
$this->query = apply_filters('fluent_cart/review/sort_query', $this->query, $rawSortBy, $this->sortType); |
| 239 |
|
| 240 |
if ($this->sortBy === 'rating') { |
| 241 |
$this->query->orderBy('rating', $this->sortType); |
| 242 |
// Deterministic tie-breaker: ratings are 1-5, so ties are the norm and an |
| 243 |
// unstable order makes rows repeat or vanish across pages. |
| 244 |
$this->query->orderBy('id', $this->sortType); |
| 245 |
return; |
| 246 |
} |
| 247 |
|
| 248 |
parent::applySort(); |
| 249 |
} |
| 250 |
|
| 251 |
public function customQuery() |
| 252 |
{ |
| 253 |
/** @var \FluentCart\Framework\Database\Orm\Builder $query */ |
| 254 |
$query = $this->query; |
| 255 |
|
| 256 |
// Only show top-level reviews, not admin replies. Spelled out rather than |
| 257 |
// calling ProductReview::scopeTopLevel(): model scopes resolve through |
| 258 |
// Builder::__call(), which static analysis cannot see. Keep in step with |
| 259 |
// that scope if its predicate ever changes. |
| 260 |
$query->whereNull('parent_id'); |
| 261 |
|
| 262 |
// Backward compat: honor legacy post_id and rating params |
| 263 |
$postId = Arr::get($this->args, 'post_id'); |
| 264 |
if ($postId) { |
| 265 |
$query->where('post_id', (int) $postId); |
| 266 |
} |
| 267 |
|
| 268 |
$rating = Arr::get($this->args, 'rating'); |
| 269 |
if ($rating && $rating >= 1 && $rating <= 5) { |
| 270 |
$query->where('rating', (int) $rating); |
| 271 |
} |
| 272 |
|
| 273 |
return $query; |
| 274 |
} |
| 275 |
|
| 276 |
/** |
| 277 |
* Disable user-controlled scopes entirely. |
| 278 |
* |
| 279 |
* BaseFilter::applyScopes() calls arbitrary methods on the query builder |
| 280 |
* from the `scopes` request param (e.g. scopes=["delete"] would execute |
| 281 |
* $query->delete()). The controller whitelist already excludes `scopes`, |
| 282 |
* but this is defense-in-depth in case make() is called directly. |
| 283 |
*/ |
| 284 |
protected function applyScopes() |
| 285 |
{ |
| 286 |
// No-op — scopes not used. The top-level predicate is applied via customQuery(). |
| 287 |
} |
| 288 |
|
| 289 |
public function dateColumns(): array |
| 290 |
{ |
| 291 |
return ['created_at']; |
| 292 |
} |
| 293 |
|
| 294 |
} |
| 295 |
|