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
← All changes | app/Services/Theme/FrontendTheme.php +549 -30 1.5.3 → 1.6.6 View file →
@@ -4,43 +4,562 @@
4 4
5 5 use FluentCart\Api\StoreSettings;
6 6 use FluentCart\Framework\Support\Arr;
7 7
8 -class FrontendTheme {
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';
9 29
10 - public static function applyTheme() {
11 - // add_action( 'wp_enqueue_scripts', function () {
12 - // if ( ! is_admin() ) {
13 - // ( new static() )->apply();
14 - // }
15 - // } );
16 - }
30 + /**
31 + * Marks a storefront still wearing FluentCart's own colours.
32 + */
33 + const CLASS_DEFAULT = 'fluent-cart-theme';
17 34
18 - protected function apply() {
35 + /**
36 + * Marks a storefront whose colours have been changed from FluentCart's.
37 + */
38 + const CLASS_CUSTOM = 'fluent-cart-custom';
19 39
20 - $themeData = $this->get();
21 - $style = $this->prepareInlineStyle($themeData);
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';
22 52
23 - if (!empty($style)){
24 - wp_register_style( 'fluent-cart-inline-style', false, [], FLUENTCART_VERSION );
25 - wp_enqueue_style( 'fluent-cart-inline-style' );
26 - wp_add_inline_style( 'fluent-cart-inline-style', "body{ {$style}}" );
27 - }
28 - }
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 + }
29 69
30 - protected function get(): array {
31 - return ( new StoreSettings() )->get( 'frontend_theme', [] );
32 - }
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();
33 92
34 - protected function prepareInlineStyle(array $data): string {
35 - $style = "";
36 - foreach ($data as $name => $value) {
37 - if(!empty($value)) {
38 - $style .= $name . ':' . $value . ';';
39 - }
40 - }
41 - return $style;
42 - }
93 + if (empty($palette['variables'])) {
94 + return $appearance;
95 + }
43 96
97 + $theme = (string)Arr::get($appearance, 'theme', 'stripe');
44 98
99 + if ($theme === '' || $theme === 'stripe') {
100 + $appearance['theme'] = $palette['theme'];
101 + }
45 102
46 -}
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 +}