PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 All 51 releases
fluent-cart / app / Modules / Reviews / ReviewModule.php

ReviewModule.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.7.1, at app/Modules/Reviews/ReviewModule.php

277 lines 10.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\Reviews;
4
5 use FluentCart\Api\ModuleSettings;
6 use FluentCart\App\Helpers\Status;
7 use FluentCart\App\Models\Product;
8 use FluentCart\App\Models\ProductVariation;
9 use FluentCart\App\Services\Permission\PermissionManager;
10 use FluentCart\App\Services\ProductReviewService;
11 use FluentCart\App\Services\Renderer\ProductReviewRenderer;
12 use FluentCart\Framework\Support\Arr;
13
14 class ReviewModule
15 {
16 public function register()
17 {
18 $this->registerSidebarMenu();
19 $this->registerProductPickerOptions();
20
21 // Store-wide star colours set through `fluent_cart/reviews/star_colors`.
22 add_action('wp_head', [__CLASS__, 'printStarColors'], 101);
23
24 // Register reviews as a valid module key (settings managed via dedicated Product Reviews page)
25 add_filter('fluent_cart/module_setting/fields', function ($fields) {
26 $fields['reviews'] = [
27 'title' => __('Product Reviews', 'fluent-cart'),
28 'hidden' => true,
29 ];
30 return $fields;
31 });
32
33 add_filter('fluent_cart/module_setting/default_values', function ($values) {
34 if (empty($values['reviews']['active'])) {
35 $values['reviews']['active'] = 'yes';
36 }
37 if (empty($values['reviews']['review_permission_mode'])) {
38 $values['reviews']['review_permission_mode'] = 'verified_buyers';
39 }
40 if (empty($values['reviews']['auto_approve_reviews'])) {
41 $values['reviews']['auto_approve_reviews'] = 'no';
42 }
43 if (empty($values['reviews']['show_verified_badge'])) {
44 $values['reviews']['show_verified_badge'] = 'yes';
45 }
46 if (empty($values['reviews']['enable_star_rating'])) {
47 $values['reviews']['enable_star_rating'] = 'yes';
48 }
49 if (empty($values['reviews']['star_rating_required'])) {
50 $values['reviews']['star_rating_required'] = 'yes';
51 }
52 if (empty($values['reviews']['reviews_per_page'])) {
53 $values['reviews']['reviews_per_page'] = 10;
54 }
55 return $values;
56 });
57 }
58
59 /**
60 * Print the star colours a `fluent_cart/reviews/star_colors` filter changed.
61 *
62 * Nothing is printed while the defaults stand; the stylesheets already
63 * carry them.
64 *
65 * @return void
66 */
67 public static function printStarColors(): void
68 {
69 if (is_admin()) {
70 return;
71 }
72
73 $css = ProductReviewRenderer::starColorCss();
74
75 if ($css === '') {
76 return;
77 }
78
79 // Safe unescaped: the property names are fixed and every value has
80 // been through sanitize_hex_color() in ProductReviewRenderer::starColors().
81 echo '<style id="fluent-cart-review-star-colors">' . $css . '</style>' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
82 }
83
84 /**
85 * Products, each with its variations, for the add-review modal's picker.
86 *
87 * Registered against the advanced-filter remote endpoint rather than a
88 * route of its own: that URL already exists for exactly this shape of
89 * lookup, resolves its permission per data key, and is documented as
90 * where a module registers keys it owns.
91 *
92 * The key is review-scoped deliberately. Reusing the products endpoint
93 * would have meant products/view, which a reviews-only moderator does not
94 * hold — they could open the reviews screen and the Add Review dialog but
95 * be refused by its own product picker, unable to finish the workflow
96 * their capability grants. That is what this key exists to prevent, and
97 * ReviewProductLookupCest is its regression guard.
98 *
99 * The answer carries titles only. Prices, stock, payment type and every
100 * other commercial field stay behind products/view: a moderator picks what
101 * a review is about, and needs nothing else to.
102 *
103 * Variations ride along on the product row rather than costing a request
104 * per expanded row, and only for a product whose variations are items
105 * worth telling apart — ProductReviewService decides that, the same rule
106 * resolveReviewItem() applies when the review is written, so the picker and
107 * the write path can never disagree about which products have items.
108 */
109 protected function registerProductPickerOptions()
110 {
111 add_filter('fluent_cart/advanced_filter_options_review_products', function ($options, $args) {
112 $limit = (int) Arr::get($args, 'limit', 20);
113 $limit = $limit > 0 ? min($limit, 50) : 20;
114
115 $query = Product::query()
116 // Only what the picker renders. Product appends a thumbnail off
117 // its detail row's gallery meta, which the options list never
118 // shows — not selected, and the relation not loaded.
119 ->select(['ID', 'post_title'])
120 ->whereIn('post_status', Status::productAdminAllStatuses())
121 ->orderBy('post_title', 'ASC')
122 ->limit($limit);
123
124 $search = trim((string) Arr::get($args, 'search', ''));
125 if ($search) {
126 $query->whereLike('post_title', $search);
127 }
128
129 // Named ids instead of a search: how a caller asks about products
130 // it already knows — the Add Review dialog asks whether the
131 // product it was opened on has variations at all. Capped like any
132 // other user-supplied array.
133 $includeIds = Arr::get($args, 'include_ids', '');
134 $includeIds = is_array($includeIds) ? $includeIds : array_filter(explode(',', (string) $includeIds));
135 $includeIds = array_slice(array_filter(array_map('intval', $includeIds)), 0, 50);
136 if ($includeIds) {
137 $query->whereIn('ID', $includeIds);
138 }
139
140 $products = $query->get();
141
142 $postIds = [];
143 foreach ($products as $product) {
144 $postIds[] = (int) $product->ID;
145 }
146
147 $variationsByProduct = static::variationsForProducts($postIds);
148
149 $data = [];
150 foreach ($products as $product) {
151 $postId = (int) $product->ID;
152 $data[] = [
153 'id' => $postId,
154 'title' => (string) $product->post_title,
155 'variations' => Arr::get($variationsByProduct, $postId, []),
156 ];
157 }
158
159 return ['data' => $data];
160 }, 10, 2);
161
162 // Answering only for this key, which is why the hook is named after it.
163 add_filter('fluent_cart/advanced_filter_options_permission_review_products', function () {
164 return PermissionManager::hasPermission(['reviews/manage']);
165 });
166 }
167
168 /**
169 * The variations of a page of products, keyed by product, in one query.
170 *
171 * Two queries for the whole page rather than two per row: one asking which
172 * of these products have items at all, one for those products' variation
173 * rows. A product that is not variation-typed is left out entirely, so its
174 * lone default variation never surfaces as a choice.
175 *
176 * Which products have items is ProductReviewService's to say, not this
177 * file's — the same rule resolveReviewItem() applies when the review is
178 * written, so the picker cannot offer a variation the write path refuses.
179 *
180 * @param array $postIds
181 * @return array<int, array<int, array{id:int,title:string}>>
182 */
183 protected static function variationsForProducts(array $postIds): array
184 {
185 $itemTyped = ProductReviewService::itemTypedProductIds($postIds);
186
187 if (!$itemTyped) {
188 return [];
189 }
190
191 $rows = ProductVariation::query()
192 ->select(['id', 'post_id', 'variation_title'])
193 ->whereIn('post_id', $itemTyped)
194 ->orderBy('serial_index', 'ASC')
195 ->orderBy('id', 'ASC')
196 ->get();
197
198 $grouped = [];
199 foreach ($rows as $row) {
200 $grouped[(int) $row->post_id][] = [
201 'id' => (int) $row->id,
202 'title' => (string) $row->variation_title,
203 ];
204 }
205
206 return $grouped;
207 }
208
209 /**
210 * Show Reviews as a "↳" child under Products in the WP admin left
211 * sidebar, mirroring the Attributes and Inventory children. Always
212 * visible, so the sidebar keeps the same shape on every page.
213 */
214 protected function registerSidebarMenu()
215 {
216 add_action('fluent_cart/admin_submenu_added', function () {
217 global $submenu;
218 if (!isset($submenu['fluent-cart'])) {
219 return;
220 }
221
222 // Only while the store has reviews switched on. The screen behind
223 // this entry redirects to the dashboard when the module is off, so
224 // without this the sidebar offers a link that bounces whoever
225 // clicks it. Read here rather than at registration time: the
226 // sidebar is built per request, and a store that switches reviews
227 // off must not need a second page load to see it go.
228 if (!ModuleSettings::isActive('reviews')) {
229 return;
230 }
231
232 // Granular gate, matching MenuHandler's own sidebar items: the WP
233 // capability below only clears the admin bar — reviews/manage is
234 // what actually authorizes the reviews screens.
235 if (!PermissionManager::hasPermission(['reviews/manage'])) {
236 return;
237 }
238
239 $capability = 'manage_options';
240 if (!current_user_can('manage_options')) {
241 $capability = PermissionManager::ADMIN_CAP;
242 }
243
244 $entry = [
245 __('↳ Reviews', 'fluent-cart'),
246 $capability,
247 'admin.php?page=fluent-cart#/reviews',
248 '',
249 'fluent_cart_reviews',
250 ];
251
252 // Insert after the last item of the Products group so the order
253 // reads Products → Attributes → Inventory → Reviews.
254 $afterKeys = ['inventory', 'attributes', 'products'];
255 $insertAfter = 'products';
256 foreach ($afterKeys as $candidate) {
257 if (isset($submenu['fluent-cart'][$candidate])) {
258 $insertAfter = $candidate;
259 break;
260 }
261 }
262
263 $newSubmenu = [];
264 foreach ($submenu['fluent-cart'] as $key => $item) {
265 $newSubmenu[$key] = $item;
266 if ($key === $insertAfter && !isset($newSubmenu['reviews'])) {
267 $newSubmenu['reviews'] = $entry;
268 }
269 }
270 if (!isset($newSubmenu['reviews'])) {
271 $newSubmenu['reviews'] = $entry;
272 }
273 $submenu['fluent-cart'] = $newSubmenu;
274 }, 1000); // after the Attributes (999) and Inventory children are placed
275 }
276 }
277