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 / Hooks / Handlers / ShortCodes / ProductReviewsShortCode.php

ProductReviewsShortCode.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler trunk, at app/Hooks/Handlers/ShortCodes/ProductReviewsShortCode.php

316 lines 14.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\Hooks\Handlers\ShortCodes;
4
5 use FluentCart\App\Helpers\Helper;
6 use FluentCart\App\Http\Controllers\FrontendControllers\ProductReviewFrontendController;
7 use FluentCart\App\Models\Product;
8 use FluentCart\App\Modules\Templating\AssetLoader;
9 use FluentCart\App\Services\ProductReviewService;
10 use FluentCart\App\Services\Renderer\ProductReviewRenderer;
11 use FluentCart\App\Services\Renderer\ReviewThreadMarkup;
12 use FluentCart\App\Services\Renderer\ReviewListRenderer;
13 use FluentCart\Framework\Support\Arr;
14
15 /**
16 * [fluent_cart_product_reviews] — the reviews section, outside the editor.
17 *
18 * Everything the Review List block can be set to, as attributes: the view, the
19 * columns, the page length and pager, the chips, the sort, the photos. It draws
20 * through the same ProductReviewRenderer the block and the single-product
21 * template use, so a page built with this and a page built with blocks are the
22 * same section — the shortcode only decides what to ask it for.
23 *
24 * On a product page it needs no attributes at all. Anywhere else it wants an
25 * id, because there is no current product to read.
26 */
27 class ProductReviewsShortCode extends ShortCode
28 {
29 protected static string $shortCodeName = 'fluent_cart_product_reviews';
30
31 /**
32 * Columns, and what the storefront will actually honour.
33 *
34 * One column is the list view by another name, and past four a review is
35 * too narrow to read — the block caps itself at the same numbers, so a
36 * shortcode cannot ask for an arrangement the page has no styles for.
37 */
38 const MIN_COLUMNS = 2;
39 const MAX_COLUMNS = 4;
40
41 public function render(?array $viewData = null)
42 {
43 $data = $viewData ?? $this->shortCodeAttributes ?? [];
44
45 $product = $this->resolveProduct($data);
46
47 if (!$product) {
48 return;
49 }
50
51 // The storefront's review script and styles. The block calls this for
52 // the same reason: a page that is not the single-product template has
53 // loaded none of them, and without it the section renders as unstyled
54 // markup that does not sort, filter or page.
55 AssetLoader::loadSingleProductAssets();
56
57 (new ProductReviewRenderer($product->ID, $this->renderOptions($data)))->render();
58 }
59
60 /**
61 * Which product's reviews.
62 *
63 * An explicit id wins; otherwise the current product, which is what makes
64 * the shortcode work with no attributes inside a product template. The id
65 * is checked against published products only — a hand-typed one must not
66 * be able to surface a draft product's reviews on a public page, which is
67 * the same rule the blocks resolve by.
68 *
69 * @param array $data
70 * @return Product|null
71 */
72 protected function resolveProduct(array $data)
73 {
74 $productId = absint(Arr::get($data, 'id', 0));
75
76 if (!$productId) {
77 return fluent_cart_get_current_product();
78 }
79
80 return Product::query()
81 ->where('post_status', 'publish')
82 ->find($productId);
83 }
84
85 /**
86 * The attributes, as the renderer's own options.
87 *
88 * Every value is checked here rather than trusted: a shortcode is typed by
89 * hand into a post, so "grid" and "Grid" and "griid" all arrive equally,
90 * and an unknown one has to fall back to something the page can draw
91 * rather than reaching the renderer as-is.
92 *
93 * @param array $data
94 * @return array
95 */
96 protected function renderOptions(array $data): array
97 {
98 $viewMode = ProductReviewService::resolveViewMode(Arr::get($data, 'view_mode', 'list'));
99
100 $pagination = strtolower(trim((string) Arr::get($data, 'pagination', 'numbers')));
101 $pagination = in_array($pagination, ReviewListRenderer::paginationTypes(), true)
102 ? $pagination
103 : 'numbers';
104
105 $options = [
106 'viewMode' => $viewMode,
107 'gridColumns' => $this->columns($data),
108 'perPage' => $this->perPage($data),
109 'maxWords' => $this->maxWords($data),
110 // media_visible="3" media_width="96" media_height="96" — how many
111 // photo/video tiles a review shows before the + tile, and how big
112 // each one is. Bounded in the renderer, which is where the block
113 // attributes carrying the same three are bounded.
114 'mediaVisible' => ProductReviewRenderer::mediaVisibleCount(Arr::get($data, 'media_visible', 0)),
115 'mediaWidth' => ProductReviewRenderer::mediaTileSize(Arr::get($data, 'media_width', 0)),
116 'mediaHeight' => ProductReviewRenderer::mediaTileSize(Arr::get($data, 'media_height', 0)),
117 'mediaFullWidth' => Helper::toBool(Arr::get($data, 'media_full_width', false), false),
118 'mediaMore' => ReviewThreadMarkup::moreTilePlacement(Arr::get($data, 'media_more', 'overlay')),
119 'paginationType' => $pagination,
120 'defaultSortBy' => $this->sortBy($data),
121 'defaultSortOrder' => $this->sortOrder($data),
122 // yes/no attributes, through the helper the other shortcodes use so
123 // "yes", "true", "1" and "on" all read the same way. The second
124 // argument is the fallback for anything else typed: a misspelt
125 // filter="ye" leaves the chips where the default put them rather
126 // than silently removing them.
127 'showFilterChips' => Helper::toBool(Arr::get($data, 'filter', true), true),
128 'showSortControls' => Helper::toBool(Arr::get($data, 'sorting', true), true),
129 'showSummary' => Helper::toBool(Arr::get($data, 'summary', true), true),
130 'showReviewDate' => Helper::toBool(Arr::get($data, 'date', true), true),
131 'showReviewerName' => Helper::toBool(Arr::get($data, 'reviewer_name', true), true),
132 'showViewReply' => Helper::toBool(Arr::get($data, 'replies', true), true),
133 // photos="only" narrows the query to reviews that carry them.
134 // Anything else leaves the list alone — including photos="yes",
135 // which reads as "photos are welcome here" rather than "only
136 // these", and is what a list shows anyway.
137 'hasMedia' => strtolower(trim((string) Arr::get($data, 'photos', ''))) === 'only',
138 // No min_rating here. The block has one and the refresh endpoint
139 // resolves it from that block's composition token rather than
140 // from the query string, because a floor is the shop's setting
141 // and not the reader's - sent by the client, anyone could ask for
142 // the one-star reviews a shop had chosen not to publish. A
143 // shortcode has no such token, so it could only filter the first
144 // paint: the first sort, filter or page change would bring the
145 // excluded reviews back, and the count and the pager with them.
146 // Better absent than a filter that stops filtering.
147 //
148 // summary="side|top|cta" — where the rating summary goes, which is
149 // a separate question from whether it appears at all. Anything else
150 // typed reads as the default rather than removing it.
151 'summaryMode' => $this->summaryMode($data),
152 'showCount' => Helper::toBool(Arr::get($data, 'count', true), true),
153 // The parts of a card. The block turns these off by removing the
154 // field block; a shortcode has no blocks to remove, so it says so
155 // by name. Named for what a reader sees rather than for the option
156 // behind it - avatar="no", not show_avatar="no".
157 'showAvatar' => Helper::toBool(Arr::get($data, 'avatar', true), true),
158 'showTitle' => Helper::toBool(Arr::get($data, 'title', true), true),
159 'showContent' => Helper::toBool(Arr::get($data, 'text', true), true),
160 'showPhotos' => Helper::toBool(Arr::get($data, 'show_photos', true), true),
161 'showFooter' => Helper::toBool(Arr::get($data, 'footer', true), true),
162 'showVariation' => Helper::toBool(Arr::get($data, 'variation', true), true),
163 'showMeta' => Helper::toBool(Arr::get($data, 'meta', true), true),
164 // The order within a card, each off unless asked for, which is the
165 // arrangement every layout but the photo ones uses.
166 'photosFirst' => Helper::toBool(Arr::get($data, 'photos_first', false), false),
167 'ratingFirst' => Helper::toBool(Arr::get($data, 'rating_first', false), false),
168 'badgeLast' => Helper::toBool(Arr::get($data, 'badge_last', false), false),
169 // Pro draws these two, and refuses them without it in
170 // ReviewThreadMarkup - the same gate the block and the Elementor
171 // widgets pass through, so asking here cannot get round it. They
172 // travel as intent: a page written before Pro arrives draws as
173 // written the day it does.
174 'mediaBackdrop' => Helper::toBool(Arr::get($data, 'media_backdrop', false), false),
175 'mediaFlush' => Helper::toBool(Arr::get($data, 'media_flush', false), false),
176 ];
177
178 // When omitted, let the renderer inherit the store's badge setting.
179 if (isset($data['verified_badge'])) {
180 $settings = ProductReviewService::getReviewSettings();
181 $options['showVerifiedBadge'] = Helper::toBool(
182 $data['verified_badge'],
183 $settings['show_verified_badge'] === 'yes'
184 );
185 }
186
187 $starColor = sanitize_hex_color((string) Arr::get($data, 'star_color', ''));
188
189 if ($starColor) {
190 $options['starColor'] = $starColor;
191 }
192
193 if ($viewMode === 'slider') {
194 $options['sliderSettings'] = $this->sliderSettings($data);
195 }
196
197 return $options;
198 }
199
200 /**
201 * How many across, for the views that lay out in columns.
202 *
203 * List view ignores it, so an out-of-range number there costs nothing; in
204 * grid and slider it is clamped rather than refused, because a shop asking
205 * for six columns wants as many as it can have, not the default two.
206 */
207 protected function columns(array $data): int
208 {
209 $columns = (int) Arr::get($data, 'columns', self::MIN_COLUMNS);
210
211 return min(self::MAX_COLUMNS, max(self::MIN_COLUMNS, $columns));
212 }
213
214 /**
215 * Reviews per page. 0 — unset, zero, negative or unparseable — is the
216 * renderer's "use the store's Reviews per page".
217 */
218 protected function perPage(array $data): int
219 {
220 $perPage = (int) Arr::get($data, 'per_page', 0);
221
222 if ($perPage < 1) {
223 return 0;
224 }
225
226 // Capped at the endpoint's own ceiling rather than a number of our
227 // own: every page after the first is fetched through it, so a larger
228 // first page would silently shrink on the first click.
229 return min(ProductReviewFrontendController::MAX_REVIEWS_PER_PAGE, $perPage);
230 }
231
232 /**
233 * Words a review shows before a Read more, 0 for the whole review.
234 *
235 * Bounded the way the endpoint bounds it rather than by a number of our
236 * own: every page after the first is drawn there, so a first page trimmed
237 * to a limit it would not accept would untrim itself on the first click.
238 */
239 /**
240 * summary="side|top|cta". Anything else typed reads as 'side', the
241 * arrangement it has always had - a misspelt summary="tpo" should not
242 * rearrange the section.
243 */
244 protected function summaryMode(array $data): string
245 {
246 $mode = strtolower(trim((string) Arr::get($data, 'summary_position', 'side')));
247
248 return in_array($mode, ['side', 'top', 'cta'], true) ? $mode : 'side';
249 }
250
251 protected function maxWords(array $data): int
252 {
253 $maxWords = (int) Arr::get($data, 'max_words', 0);
254
255 if ($maxWords < 1) {
256 return 0;
257 }
258
259 return min(ProductReviewFrontendController::MAX_REVIEW_WORDS, $maxWords);
260 }
261
262 /**
263 * Which column the list opens on.
264 *
265 * Only the two the sort control offers, because sort_by and sort_order
266 * together are also what that control opens showing — a pair the select
267 * has no option for would leave it displaying its first entry while the
268 * list was ordered by something else.
269 */
270 protected function sortBy(array $data): string
271 {
272 $sortBy = strtolower(trim((string) Arr::get($data, 'sort_by', 'created_at')));
273
274 return in_array($sortBy, ['created_at', 'rating'], true) ? $sortBy : 'created_at';
275 }
276
277 protected function sortOrder(array $data): string
278 {
279 $order = strtoupper(trim((string) Arr::get($data, 'sort_order', 'DESC')));
280
281 return $order === 'ASC' ? 'ASC' : 'DESC';
282 }
283
284 /**
285 * The slider's behaviour, in the shape normalizeSliderSettings() expects.
286 *
287 * Passed through rather than validated here: that method is the one the
288 * block's settings go through too, so both arrive at the storefront having
289 * been checked by the same code.
290 */
291 protected function sliderSettings(array $data): array
292 {
293 return [
294 'autoplay' => strtolower(trim((string) Arr::get($data, 'autoplay', 'no'))),
295 'autoplayDelay' => (int) Arr::get($data, 'autoplay_delay', 3000),
296 'arrows' => Helper::toBool(Arr::get($data, 'arrows', true), true) ? 'yes' : 'no',
297 'arrowsSize' => strtolower(trim((string) Arr::get($data, 'arrow_size', 'md'))),
298 'arrowsPosition'=> strtolower(trim((string) Arr::get($data, 'arrow_position', 'overlap'))),
299 'infinite' => Helper::toBool(Arr::get($data, 'infinite', false)) ? 'yes' : 'no',
300 // Off unless asked for. A slider already sits above the Review
301 // Pagination block, and the two page different things — these move
302 // between loaded slides, that one loads more reviews. An existing
303 // shortcode must not acquire a second pager by upgrading.
304 'pagination' => Helper::toBool(
305 Arr::get($data, 'slider_pagination', Arr::get($data, 'show_pagination', false)),
306 false
307 ) ? 'yes' : 'no',
308 'paginationType'=> strtolower(trim((string) Arr::get(
309 $data,
310 'slider_pagination_type',
311 Arr::get($data, 'pagination_type', 'bullets')
312 ))),
313 ];
314 }
315 }
316