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 / Services / Theme / FrontendTheme.php

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

702 lines 24.0 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 and border-radius 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 or RadiusPalette
316 * registry, every colour value is a hex or a bare var() reference
317 * (sanitizeDeclarationValue()), and every radius value is one plain
318 * length (RadiusPalette::sanitizeLength()), so the string is only ever
319 * `--fct-name:#rrggbb;`, `--fct-name:var(--x);` or `--fct-name:8px;`.
320 */
321 echo '<style id="' . esc_attr(self::STYLE_ID) . '">' . $css . '</style>' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
322 }
323
324 /**
325 * Which source the store is configured to use.
326 *
327 * @return string One of the ColorPalette::SOURCE_* constants.
328 */
329 public static function getSource(): string
330 {
331 $source = (new StoreSettings())->get('appearance_source', ColorPalette::SOURCE_DEFAULT);
332
333 return in_array($source, ColorPalette::sources(), true)
334 ? $source
335 : ColorPalette::SOURCE_DEFAULT;
336 }
337
338 /**
339 * The colours the store owner set by hand, keyed by settings key.
340 *
341 * Anything not in the registry, and anything that is not a valid hex, is
342 * dropped — the option is sanitised on save, but a stale option written by
343 * an older version must not reach the page unchecked.
344 *
345 * @return array
346 */
347 public static function getCustomColors(): array
348 {
349 $stored = (new StoreSettings())->get('appearance_colors', []);
350
351 if (!is_array($stored)) {
352 return [];
353 }
354
355 $globals = ColorPalette::globals();
356 $colors = [];
357
358 foreach ($globals as $key => $definition) {
359 $hex = sanitize_hex_color((string)Arr::get($stored, $key, ''));
360
361 if ($hex) {
362 $colors[$key] = $hex;
363 }
364 }
365
366 return $colors;
367 }
368
369 /**
370 * The colours the active theme supplies, keyed by settings key.
371 *
372 * @return array
373 */
374 public static function getThemeColors(): array
375 {
376 // With nothing to inherit, every role would resolve to FluentCart's own
377 // fallback and we would pin twenty properties to values the theme never
378 // chose. That is not the same as leaving them alone: FluentCart's
379 // per-file fallbacks differ from one surface to the next, so pinning
380 // one value everywhere quietly changes the store while claiming to be
381 // following the theme. Write nothing instead.
382 if (!ThemePalette::hasUsableSource()) {
383 return [];
384 }
385
386 $roles = ThemePalette::resolve();
387 $colors = [];
388
389 foreach (ColorPalette::globals() as $key => $definition) {
390 $role = Arr::get($definition, 'role', '');
391 $value = self::sanitizeDeclarationValue(Arr::get($roles, $role, ''));
392
393 if ($value !== '') {
394 $colors[$key] = $value;
395 }
396 }
397
398 return $colors;
399 }
400
401 /**
402 * The only two shapes allowed on the right of one of our declarations.
403 *
404 * A hex colour, or a bare custom-property reference for the themes that
405 * publish their palette that way. Everything else is refused: this string
406 * is written into a style element on every storefront page, so the grammar
407 * stays narrow enough that nothing can ride along inside it.
408 *
409 * @param mixed $value
410 * @return string The safe value, or '' when it is neither.
411 */
412 protected static function sanitizeDeclarationValue($value): string
413 {
414 $value = (string)$value;
415
416 $hex = sanitize_hex_color($value);
417
418 if ($hex) {
419 return $hex;
420 }
421
422 // The two reference shapes ThemePalette::safeReference() produces: a
423 // bare custom property, or one whose sole fallback is a hex colour —
424 // the form Customify publishes. Nothing looser.
425 return preg_match('/^var\(--[A-Za-z0-9_-]+(?:, #(?:[0-9a-f]{3}|[0-9a-f]{6}))?\)$/', $value) ? $value : '';
426 }
427
428 /**
429 * Give a customised button what theme inheritance would have given it.
430 *
431 * Customize lets the owner leave a button's text unset, and every
432 * stylesheet then falls back to its own literal text — not always one
433 * that reads on the background the owner did set (the product carousel's
434 * hovered arrow fell back to a dark icon). Theme inheritance already
435 * measures a missing partner; this does the same for the button pair and
436 * the hover pair.
437 *
438 * A button background with no hover background also gets the hover
439 * inheritance derives — the button moved 12% away from itself — with the
440 * resting text carried over while it reads; otherwise the button hovered
441 * in its resting colour, with no visible change. A colour the owner chose
442 * is worn as given.
443 *
444 * @param array $colors Settings key => hex, as the owner set them.
445 * @return array
446 */
447 protected static function withButtonPartners(array $colors): array
448 {
449 if (isset($colors['btn_bg_color']) && !isset($colors['btn_text_color'])) {
450 $colors['btn_text_color'] = ColorMath::readableText($colors['btn_bg_color']);
451 }
452
453 if (isset($colors['btn_bg_color']) && !isset($colors['btn_hover_bg_color'])) {
454 $colors['btn_hover_bg_color'] = ColorMath::shiftFromItself($colors['btn_bg_color'], 12);
455
456 if (!isset($colors['btn_hover_text_color'])) {
457 $colors['btn_hover_text_color'] = ThemePalette::hoverTextFor(
458 $colors['btn_hover_bg_color'],
459 $colors['btn_text_color']
460 );
461 }
462 }
463
464 if (isset($colors['btn_hover_bg_color']) && !isset($colors['btn_hover_text_color'])) {
465 $colors['btn_hover_text_color'] = ColorMath::readableText($colors['btn_hover_bg_color']);
466 }
467
468 return $colors;
469 }
470
471 /**
472 * Every colour that will actually be written, keyed by settings key.
473 *
474 * @return array
475 */
476 public static function getEffectiveColors(): array
477 {
478 $source = self::getSource();
479
480 if ($source === ColorPalette::SOURCE_CUSTOM) {
481 $colors = self::withButtonPartners(self::getCustomColors());
482 } elseif ($source === ColorPalette::SOURCE_THEME) {
483 $colors = self::getThemeColors();
484 } else {
485 $colors = [];
486 }
487
488 /**
489 * Filter the storefront colours before they are written to the page.
490 *
491 * @param array $colors Settings key => hex.
492 * @param array $context Read-only context for the decision.
493 */
494 $filteredColors = apply_filters('fluent_cart/theme/storefront_colors', $colors, [
495 'source' => $source,
496 ]);
497
498 return is_array($filteredColors) ? $filteredColors : $colors;
499 }
500
501 /**
502 * The effective colours as plain hexes, keyed like getEffectiveColors().
503 *
504 * A separate reader instead of extra keys in the effective colours: the
505 * registry is publicly extensible, so no key shape is safe to reserve
506 * there. A reference measures as its shipped hex fallback; anything
507 * unmeasurable is simply absent, so absence itself means "no real
508 * colour known".
509 *
510 * @return array Settings key => hex.
511 */
512 public static function getMeasurableColors(): array
513 {
514 $measured = [];
515
516 foreach (self::getEffectiveColors() as $key => $value) {
517 if (!is_string($value)) {
518 continue;
519 }
520
521 $hex = ThemePalette::measurable($value);
522
523 if ($hex !== '') {
524 $measured[$key] = $hex;
525 }
526 }
527
528 return $measured;
529 }
530
531 /**
532 * The radii the store owner set by hand, role => length.
533 *
534 * Stored as typed lengths (a bare number is pixels); re-checked here the
535 * same way the sanitiser checks them on save, so a stale or hand-edited
536 * option never reaches the page.
537 *
538 * @return array Role key => length (`12px`, `0.5rem`, `1em`).
539 */
540 public static function getCustomRadii(): array
541 {
542 $stored = (new StoreSettings())->get('appearance_radius', []);
543
544 return RadiusPalette::sanitizeOwnerMap($stored);
545 }
546
547 /**
548 * The radii the active theme states, role => length.
549 *
550 * @return array
551 */
552 protected static function getThemeRadii(): array
553 {
554 $radii = ThemePalette::radii();
555
556 return is_array($radii) ? $radii : [];
557 }
558
559 /**
560 * Every radius that will actually be written, role => normalised length.
561 *
562 * Same three sources as the colours: the owner's own radii under
563 * `customize`, the theme's under `inherit_from_theme`, and none under
564 * `default` — FluentCart's look must not move.
565 *
566 * @return array
567 */
568 public static function getEffectiveRadii(): array
569 {
570 return self::radiiFor(self::getSource());
571 }
572
573 /**
574 * The radii a given source would write, after the storefront filter.
575 *
576 * The settings preview asks for the inherited radii while another source
577 * is saved, so it shows exactly what choosing that source would print.
578 *
579 * @param string $source One of the ColorPalette::SOURCE_* constants.
580 * @return array Role key => normalised length.
581 */
582 public static function radiiFor(string $source): array
583 {
584 if ($source === ColorPalette::SOURCE_CUSTOM) {
585 $radii = self::getCustomRadii();
586 } elseif ($source === ColorPalette::SOURCE_THEME) {
587 $radii = self::getThemeRadii();
588 } else {
589 $radii = [];
590 }
591
592 /**
593 * Filter the storefront border radii before they are written to the page.
594 *
595 * @param array $radii Role key (card|btn|input) => length (`0`, `8px`, `0.5rem`).
596 * @param array $context Read-only context: ['source' => appearance source].
597 */
598 $filtered = apply_filters('fluent_cart/theme/storefront_radii', $radii, [
599 'source' => $source,
600 ]);
601
602 if (!is_array($filtered)) {
603 $filtered = $radii;
604 }
605
606 // Re-checked after the filter: registry roles only, one plain length
607 // each, in registry order.
608 $effective = [];
609
610 foreach (array_keys(RadiusPalette::roles()) as $role) {
611 if (!isset($filtered[$role])) {
612 continue;
613 }
614
615 $length = RadiusPalette::sanitizeLength($filtered[$role]);
616
617 if ($length !== '') {
618 $effective[$role] = $length;
619 }
620 }
621
622 return $effective;
623 }
624
625 /**
626 * Build the `:root` declaration block: colours first, then radii.
627 *
628 * Either half prints without the other — a store can set radii and keep
629 * FluentCart's colours, or the reverse.
630 *
631 * @return string CSS, or '' when there is nothing to write.
632 */
633 public static function buildCss(): string
634 {
635 $declarations = self::buildColorDeclarations() . self::buildRadiusDeclarations();
636
637 return $declarations === '' ? '' : ':root{' . $declarations . '}';
638 }
639
640 /**
641 * The radius declarations, without the surrounding block.
642 *
643 * @return string
644 */
645 protected static function buildRadiusDeclarations(): string
646 {
647 $roles = RadiusPalette::roles();
648 $declarations = '';
649
650 foreach (self::getEffectiveRadii() as $role => $length) {
651 if (!isset($roles[$role])) {
652 continue;
653 }
654
655 $safe = RadiusPalette::sanitizeLength($length);
656
657 if ($safe !== '') {
658 $declarations .= $roles[$role]['var'] . ':' . $safe . ';';
659 }
660 }
661
662 return $declarations;
663 }
664
665 /**
666 * The colour declarations, without the surrounding block.
667 *
668 * @return string
669 */
670 protected static function buildColorDeclarations(): string
671 {
672 $colors = self::getEffectiveColors();
673
674 if (!$colors) {
675 return '';
676 }
677
678 $globals = ColorPalette::globals();
679 $declarations = '';
680
681 foreach ($colors as $key => $value) {
682 if (!isset($globals[$key])) {
683 continue;
684 }
685
686 // Re-checked here rather than trusted from the source that produced
687 // it, because a filter sits between the two.
688 $safe = self::sanitizeDeclarationValue($value);
689
690 if ($safe === '') {
691 continue;
692 }
693
694 foreach (ColorPalette::varsOf($globals[$key]) as $property) {
695 $declarations .= $property . ':' . $safe . ';';
696 }
697 }
698
699 return $declarations;
700 }
701 }
702