PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
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 1.3.19 All 49 releases
fluent-cart / app / Services / Theme / FrontendTheme.php

FrontendTheme.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.6, at app/Services/Theme/FrontendTheme.php

566 lines 19.8 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\Services\Theme;
4
5 use FluentCart\Api\StoreSettings;
6 use FluentCart\Framework\Support\Arr;
7
8 /**
9 * Writes the storefront's global colour custom properties.
10 *
11 * FluentCart's stylesheets declare every scoped colour as
12 * `var(--fct-<global>, <fallback>)`, so the globals printed here cascade to the
13 * whole store — shop grid, product page, cart drawer, checkout and the customer
14 * dashboard — without any of those stylesheets being touched.
15 *
16 * Three sources are supported, and they are mutually exclusive:
17 *
18 * - `default` nothing is printed; FluentCart's own fallbacks apply.
19 * - `inherit_from_theme` the palette is rebuilt from the active theme.
20 * - `customize` the store owner's own colours, and only the ones
21 * they actually set.
22 */
23 class FrontendTheme
24 {
25 /**
26 * The id on the printed style element, so it can be found in the source.
27 */
28 const STYLE_ID = 'fluent-cart-storefront-colors';
29
30 /**
31 * Marks a storefront still wearing FluentCart's own colours.
32 */
33 const CLASS_DEFAULT = 'fluent-cart-theme';
34
35 /**
36 * Marks a storefront whose colours have been changed from FluentCart's.
37 */
38 const CLASS_CUSTOM = 'fluent-cart-custom';
39
40 /**
41 * Marks a storefront the active theme styles itself.
42 *
43 * Only possible under `inherit_from_theme`, when nothing could be read from
44 * the active theme. The stylesheets key their colour declarations off this:
45 * a declaration carrying an unresolvable var() still wins the cascade and
46 * then computes to `unset` — it never falls back to the theme's own rule,
47 * because that rule was discarded at cascade time. Only the absence of the
48 * declaration lets the theme style the element, and absence cannot be
49 * expressed with a variable, so it is expressed with this class instead.
50 */
51 const CLASS_NO_COLORS = 'fluent-cart-no-colors';
52
53 /**
54 * Register the front-end hooks.
55 *
56 * Priority 100 puts the block after wp_print_styles() (which runs on
57 * wp_head at 8). Both this block and FluentCart's stylesheets target
58 * `:root`, so specificity is a tie and source order decides — printing
59 * late is what makes these values win.
60 *
61 * @return void
62 */
63 public static function applyTheme(): void
64 {
65 add_action('wp_head', [__CLASS__, 'printColors'], 100);
66 add_filter('body_class', [__CLASS__, 'bodyClasses']);
67 add_filter('fluent_cart/stripe_appearance', [__CLASS__, 'filterStripeAppearance'], 5);
68 }
69
70 /**
71 * Seed the `fluent_cart/stripe_appearance` filter with the palette's
72 * answer.
73 *
74 * A named callback on the public filter rather than a call inside the
75 * gateway: the payment module stays unaware of the theme service, and
76 * removing or replacing FluentCart's contribution is a *_filter() call.
77 *
78 * A default-filler, not an owner: the palette supplies its theme, its
79 * colour variables and its tab rules, and everything else — a customised seed, or
80 * what an earlier listener added (labels, rules, extra variables) —
81 * survives. The theme is only taken over from the plain default, since
82 * an explicit theme choice is a choice. With no opinion (default source,
83 * nothing measurable), the incoming value passes through unchanged.
84 *
85 * @param mixed $appearance
86 * @return array
87 */
88 public static function filterStripeAppearance($appearance): array
89 {
90 $appearance = (array)$appearance;
91 $palette = self::stripeAppearance();
92
93 if (empty($palette['variables'])) {
94 return $appearance;
95 }
96
97 $theme = (string)Arr::get($appearance, 'theme', 'stripe');
98
99 if ($theme === '' || $theme === 'stripe') {
100 $appearance['theme'] = $palette['theme'];
101 }
102
103 $appearance['variables'] = array_merge(
104 (array)Arr::get($appearance, 'variables', []),
105 $palette['variables']
106 );
107
108 // Per selector: the palette sets its properties, and every other
109 // selector and property an earlier listener wrote survives. Selectors
110 // start with a dot, so they are indexed directly, never via Arr::get().
111 $rules = (array)Arr::get($appearance, 'rules', []);
112
113 foreach ((array)Arr::get($palette, 'rules', []) as $selector => $properties) {
114 $rules[$selector] = array_merge(
115 isset($rules[$selector]) ? (array)$rules[$selector] : [],
116 $properties
117 );
118 }
119
120 if ($rules) {
121 $appearance['rules'] = $rules;
122 }
123
124 return $appearance;
125 }
126
127 /**
128 * The Stripe Elements appearance the storefront palette implies.
129 *
130 * Stripe renders inside its own iframe, where FluentCart's custom
131 * properties do not exist — a var() reference handed to Stripe resolves
132 * to nothing. So the palette speaks to Stripe only in measurable hexes
133 * (a reference's hex fallback counts, per the spec-59 doctrine), only as
134 * a pair — the input surface and its text from the same source — and
135 * stays silent otherwise: Stripe's default theme is a coherent light
136 * design, and half a dark palette printed onto it would read worse than
137 * none of it. A dark input surface picks Stripe's own night theme so the
138 * parts we never set (placeholders, dividers, the Link box) darken with
139 * it.
140 *
141 * Seeds the `fluent_cart/stripe_appearance` filter's default — a listener
142 * still overrides everything here.
143 *
144 * The button pair reaches Stripe on its own, without the input pair: it
145 * is the accent, and it fills the selected payment-method tab.
146 *
147 * @return array Stripe appearance config: ['theme' => ..., 'variables' => ..., 'rules' => ...].
148 */
149 public static function stripeAppearance(): array
150 {
151 $appearance = ['theme' => 'stripe'];
152
153 $colors = self::getMeasurableColors();
154
155 if (!$colors) {
156 return $appearance;
157 }
158
159 $inputBg = (string)Arr::get($colors, 'input_bg_color', '');
160 $inputText = (string)Arr::get($colors, 'input_text_color', '');
161
162 if ($inputBg !== '' && $inputText !== '') {
163 // readableOn() picks white on a dark surface — which is exactly
164 // when Stripe should start from night instead of its light theme.
165 $appearance['theme'] = ColorMath::readableOn($inputBg) === '#ffffff' ? 'night' : 'stripe';
166 $appearance['variables'] = [
167 'colorBackground' => $inputBg,
168 'colorText' => $inputText,
169 ];
170 }
171
172 // The accent and the selected payment-method tab follow the button,
173 // not primary_bg_color: that is a pale surface behind active states,
174 // and as Stripe's accent it washed the selected tab out entirely.
175 $buttonBg = (string)Arr::get($colors, 'btn_bg_color', '');
176
177 if ($buttonBg === '') {
178 return $appearance;
179 }
180
181 $buttonText = (string)Arr::get($colors, 'btn_text_color', '');
182
183 if ($buttonText === '') {
184 $buttonText = ColorMath::readableText($buttonBg);
185 }
186
187 $appearance['variables']['colorPrimary'] = $buttonBg;
188
189 $selectedTab = [
190 'backgroundColor' => $buttonBg,
191 'borderColor' => $buttonBg,
192 'color' => $buttonText,
193 ];
194
195 $appearance['rules'] = [
196 '.Tab:hover' => ['borderColor' => $buttonBg],
197 '.Tab--selected' => $selectedTab,
198 '.Tab--selected:hover' => $selectedTab,
199 '.Tab--selected:focus' => $selectedTab,
200 '.TabIcon--selected' => ['fill' => $buttonText],
201 '.TabIcon--selected:hover' => ['fill' => $buttonText],
202 '.TabLabel--selected' => ['color' => $buttonText],
203 ];
204
205 return $appearance;
206 }
207
208 /**
209 * Say on the body which colour source is in force.
210 *
211 * The custom properties printed above only reach surfaces FluentCart
212 * already styles. A class reaches everything else — a theme that wants to
213 * stand down once the store owner has picked their own colours, a snippet
214 * in the customiser, a child theme adjusting one store. None of those can
215 * read the setting; all of them can write a selector.
216 *
217 * The two markers are alternatives, never both, so `body.fluent-cart-theme`
218 * and `body.fluent-cart-custom` partition every storefront page between
219 * them.
220 *
221 * The active theme is named only under `inherit_from_theme`, because that
222 * is the one source whose result depends on which theme is running. Naming
223 * it under `customize` would invite a selector that breaks on switching
224 * theme for no reason, since the owner's colours are the owner's colours
225 * either way.
226 *
227 * @param array $classes
228 * @return array
229 */
230 public static function bodyClasses($classes): array
231 {
232 if (!is_array($classes)) {
233 return [];
234 }
235
236 $source = self::getSource();
237
238 // getSource() already collapses an unrecognised stored value to the
239 // default, so this agrees with what actually gets printed rather than
240 // with what the option happens to say.
241 if ($source !== ColorPalette::SOURCE_THEME && $source !== ColorPalette::SOURCE_CUSTOM) {
242 $classes[] = self::CLASS_DEFAULT;
243
244 return $classes;
245 }
246
247 $classes[] = self::CLASS_CUSTOM;
248
249 if ($source === ColorPalette::SOURCE_THEME) {
250 $slug = self::themeClass();
251
252 if ($slug !== '') {
253 $classes[] = $slug;
254 }
255
256 // Nothing readable means nothing written, and the stylesheets have
257 // to know that: their colour declarations must not exist for the
258 // theme's own rules to apply, and only a class can express that.
259 if (!self::getThemeColors()) {
260 $classes[] = self::CLASS_NO_COLORS;
261 }
262 }
263
264 return $classes;
265 }
266
267 /**
268 * The active theme as a class name.
269 *
270 * The template rather than the stylesheet: most real stores run a child
271 * theme, whose styling is the parent's plus a few overrides, so a rule
272 * written for `astra` is the one that is actually wanted. Naming
273 * `astra-child` would leave that rule matching nothing on exactly the sites
274 * most likely to need it.
275 *
276 * A theme directory name is not a class name, and this lands inside a class
277 * attribute on every storefront page, so it goes through
278 * sanitize_html_class() — which can legitimately return nothing, and an
279 * empty class is not worth adding.
280 *
281 * @return string
282 */
283 protected static function themeClass(): string
284 {
285 if (!function_exists('get_template')) {
286 return '';
287 }
288
289 return (string)sanitize_html_class((string)get_template());
290 }
291
292 /**
293 * Print the custom-property block.
294 *
295 * The modal checkout renders in an iframe pointed at a normal WordPress
296 * URL, and that view calls wp_head() too, so this one hook covers both the
297 * storefront and the modal without a second injection point.
298 *
299 * @return void
300 */
301 public static function printColors(): void
302 {
303 if (is_admin()) {
304 return;
305 }
306
307 $css = self::buildCss();
308
309 if ($css === '') {
310 return;
311 }
312
313 /*
314 * Not escaped on output because it cannot carry anything to escape:
315 * every property name comes from the ColorPalette registry and every
316 * value has been through sanitize_hex_color(), so the string is only
317 * ever `--fct-name: #rrggbb;`.
318 */
319 echo '<style id="' . esc_attr(self::STYLE_ID) . '">' . $css . '</style>' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
320 }
321
322 /**
323 * Which source the store is configured to use.
324 *
325 * @return string One of the ColorPalette::SOURCE_* constants.
326 */
327 public static function getSource(): string
328 {
329 $source = (new StoreSettings())->get('appearance_source', ColorPalette::SOURCE_DEFAULT);
330
331 return in_array($source, ColorPalette::sources(), true)
332 ? $source
333 : ColorPalette::SOURCE_DEFAULT;
334 }
335
336 /**
337 * The colours the store owner set by hand, keyed by settings key.
338 *
339 * Anything not in the registry, and anything that is not a valid hex, is
340 * dropped — the option is sanitised on save, but a stale option written by
341 * an older version must not reach the page unchecked.
342 *
343 * @return array
344 */
345 public static function getCustomColors(): array
346 {
347 $stored = (new StoreSettings())->get('appearance_colors', []);
348
349 if (!is_array($stored)) {
350 return [];
351 }
352
353 $globals = ColorPalette::globals();
354 $colors = [];
355
356 foreach ($globals as $key => $definition) {
357 $hex = sanitize_hex_color((string)Arr::get($stored, $key, ''));
358
359 if ($hex) {
360 $colors[$key] = $hex;
361 }
362 }
363
364 return $colors;
365 }
366
367 /**
368 * The colours the active theme supplies, keyed by settings key.
369 *
370 * @return array
371 */
372 public static function getThemeColors(): array
373 {
374 // With nothing to inherit, every role would resolve to FluentCart's own
375 // fallback and we would pin twenty properties to values the theme never
376 // chose. That is not the same as leaving them alone: FluentCart's
377 // per-file fallbacks differ from one surface to the next, so pinning
378 // one value everywhere quietly changes the store while claiming to be
379 // following the theme. Write nothing instead.
380 if (!ThemePalette::hasUsableSource()) {
381 return [];
382 }
383
384 $roles = ThemePalette::resolve();
385 $colors = [];
386
387 foreach (ColorPalette::globals() as $key => $definition) {
388 $role = Arr::get($definition, 'role', '');
389 $value = self::sanitizeDeclarationValue(Arr::get($roles, $role, ''));
390
391 if ($value !== '') {
392 $colors[$key] = $value;
393 }
394 }
395
396 return $colors;
397 }
398
399 /**
400 * The only two shapes allowed on the right of one of our declarations.
401 *
402 * A hex colour, or a bare custom-property reference for the themes that
403 * publish their palette that way. Everything else is refused: this string
404 * is written into a style element on every storefront page, so the grammar
405 * stays narrow enough that nothing can ride along inside it.
406 *
407 * @param mixed $value
408 * @return string The safe value, or '' when it is neither.
409 */
410 protected static function sanitizeDeclarationValue($value): string
411 {
412 $value = (string)$value;
413
414 $hex = sanitize_hex_color($value);
415
416 if ($hex) {
417 return $hex;
418 }
419
420 // The two reference shapes ThemePalette::safeReference() produces: a
421 // bare custom property, or one whose sole fallback is a hex colour —
422 // the form Customify publishes. Nothing looser.
423 return preg_match('/^var\(--[A-Za-z0-9_-]+(?:, #(?:[0-9a-f]{3}|[0-9a-f]{6}))?\)$/', $value) ? $value : '';
424 }
425
426 /**
427 * Give a customised button what theme inheritance would have given it.
428 *
429 * Customize lets the owner leave a button's text unset, and every
430 * stylesheet then falls back to its own literal text — not always one
431 * that reads on the background the owner did set (the product carousel's
432 * hovered arrow fell back to a dark icon). Theme inheritance already
433 * measures a missing partner; this does the same for the button pair and
434 * the hover pair.
435 *
436 * A button background with no hover background also gets the hover
437 * inheritance derives — the button moved 12% away from itself — with the
438 * resting text carried over while it reads; otherwise the button hovered
439 * in its resting colour, with no visible change. A colour the owner chose
440 * is worn as given.
441 *
442 * @param array $colors Settings key => hex, as the owner set them.
443 * @return array
444 */
445 protected static function withButtonPartners(array $colors): array
446 {
447 if (isset($colors['btn_bg_color']) && !isset($colors['btn_text_color'])) {
448 $colors['btn_text_color'] = ColorMath::readableText($colors['btn_bg_color']);
449 }
450
451 if (isset($colors['btn_bg_color']) && !isset($colors['btn_hover_bg_color'])) {
452 $colors['btn_hover_bg_color'] = ColorMath::shiftFromItself($colors['btn_bg_color'], 12);
453
454 if (!isset($colors['btn_hover_text_color'])) {
455 $colors['btn_hover_text_color'] = ThemePalette::hoverTextFor(
456 $colors['btn_hover_bg_color'],
457 $colors['btn_text_color']
458 );
459 }
460 }
461
462 if (isset($colors['btn_hover_bg_color']) && !isset($colors['btn_hover_text_color'])) {
463 $colors['btn_hover_text_color'] = ColorMath::readableText($colors['btn_hover_bg_color']);
464 }
465
466 return $colors;
467 }
468
469 /**
470 * Every colour that will actually be written, keyed by settings key.
471 *
472 * @return array
473 */
474 public static function getEffectiveColors(): array
475 {
476 $source = self::getSource();
477
478 if ($source === ColorPalette::SOURCE_CUSTOM) {
479 $colors = self::withButtonPartners(self::getCustomColors());
480 } elseif ($source === ColorPalette::SOURCE_THEME) {
481 $colors = self::getThemeColors();
482 } else {
483 $colors = [];
484 }
485
486 /**
487 * Filter the storefront colours before they are written to the page.
488 *
489 * @param array $colors Settings key => hex.
490 * @param array $context Read-only context for the decision.
491 */
492 $filteredColors = apply_filters('fluent_cart/theme/storefront_colors', $colors, [
493 'source' => $source,
494 ]);
495
496 return is_array($filteredColors) ? $filteredColors : $colors;
497 }
498
499 /**
500 * The effective colours as plain hexes, keyed like getEffectiveColors().
501 *
502 * A separate reader instead of extra keys in the effective colours: the
503 * registry is publicly extensible, so no key shape is safe to reserve
504 * there. A reference measures as its shipped hex fallback; anything
505 * unmeasurable is simply absent, so absence itself means "no real
506 * colour known".
507 *
508 * @return array Settings key => hex.
509 */
510 public static function getMeasurableColors(): array
511 {
512 $measured = [];
513
514 foreach (self::getEffectiveColors() as $key => $value) {
515 if (!is_string($value)) {
516 continue;
517 }
518
519 $hex = ThemePalette::measurable($value);
520
521 if ($hex !== '') {
522 $measured[$key] = $hex;
523 }
524 }
525
526 return $measured;
527 }
528
529 /**
530 * Build the `:root` declaration block.
531 *
532 * @return string CSS, or '' when there is nothing to write.
533 */
534 public static function buildCss(): string
535 {
536 $colors = self::getEffectiveColors();
537
538 if (!$colors) {
539 return '';
540 }
541
542 $globals = ColorPalette::globals();
543 $declarations = '';
544
545 foreach ($colors as $key => $value) {
546 if (!isset($globals[$key])) {
547 continue;
548 }
549
550 // Re-checked here rather than trusted from the source that produced
551 // it, because a filter sits between the two.
552 $safe = self::sanitizeDeclarationValue($value);
553
554 if ($safe === '') {
555 continue;
556 }
557
558 foreach (ColorPalette::varsOf($globals[$key]) as $property) {
559 $declarations .= $property . ':' . $safe . ';';
560 }
561 }
562
563 return $declarations === '' ? '' : ':root{' . $declarations . '}';
564 }
565 }
566