PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / trunk
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler vtrunk
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 1.3.20 All 50 releases
fluent-cart / app / Modules / Reviews / ReviewModule.php

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

288 lines 11.4 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. Hidden by
212 * default via inline CSS; useNavigationMenuUpdateService toggles it
213 * visible whenever the active route's active_menu is 'products'.
214 */
215 protected function registerSidebarMenu()
216 {
217 add_action('admin_enqueue_scripts', function () {
218 wp_register_style('fluent-cart-reviews-admin', false);
219 wp_enqueue_style('fluent-cart-reviews-admin');
220 wp_add_inline_style('fluent-cart-reviews-admin', '
221 .toplevel_page_fluent-cart li.fluent_cart_reviews {
222 display: none;
223 }
224 ');
225 });
226
227 add_action('fluent_cart/admin_submenu_added', function () {
228 global $submenu;
229 if (!isset($submenu['fluent-cart'])) {
230 return;
231 }
232
233 // Only while the store has reviews switched on. The screen behind
234 // this entry redirects to the dashboard when the module is off, so
235 // without this the sidebar offers a link that bounces whoever
236 // clicks it. Read here rather than at registration time: the
237 // sidebar is built per request, and a store that switches reviews
238 // off must not need a second page load to see it go.
239 if (!ModuleSettings::isActive('reviews')) {
240 return;
241 }
242
243 // Granular gate, matching MenuHandler's own sidebar items: the WP
244 // capability below only clears the admin bar — reviews/manage is
245 // what actually authorizes the reviews screens.
246 if (!PermissionManager::hasPermission(['reviews/manage'])) {
247 return;
248 }
249
250 $capability = 'manage_options';
251 if (!current_user_can('manage_options')) {
252 $capability = PermissionManager::ADMIN_CAP;
253 }
254
255 $entry = [
256 __('↳ Reviews', 'fluent-cart'),
257 $capability,
258 'admin.php?page=fluent-cart#/reviews',
259 '',
260 'fluent_cart_reviews',
261 ];
262
263 // Insert after the last item of the Products group so the order
264 // reads Products → Attributes → Inventory → Reviews.
265 $afterKeys = ['inventory', 'attributes', 'products'];
266 $insertAfter = 'products';
267 foreach ($afterKeys as $candidate) {
268 if (isset($submenu['fluent-cart'][$candidate])) {
269 $insertAfter = $candidate;
270 break;
271 }
272 }
273
274 $newSubmenu = [];
275 foreach ($submenu['fluent-cart'] as $key => $item) {
276 $newSubmenu[$key] = $item;
277 if ($key === $insertAfter && !isset($newSubmenu['reviews'])) {
278 $newSubmenu['reviews'] = $entry;
279 }
280 }
281 if (!isset($newSubmenu['reviews'])) {
282 $newSubmenu['reviews'] = $entry;
283 }
284 $submenu['fluent-cart'] = $newSubmenu;
285 }, 1000); // after the Attributes (999) and Inventory children are placed
286 }
287 }
288