PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 trunk All 48 releases
← All changes | app/Services/Theme/FrontendTheme.php +464 -30 1.3.21 → 1.6.5 View file →
@@ -4,43 +4,477 @@
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 and its
79 + * three colour variables, 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 + return $appearance;
109 + }
110 +
111 + /**
112 + * The Stripe Elements appearance the storefront palette implies.
113 + *
114 + * Stripe renders inside its own iframe, where FluentCart's custom
115 + * properties do not exist — a var() reference handed to Stripe resolves
116 + * to nothing. So the palette speaks to Stripe only in measurable hexes
117 + * (a reference's hex fallback counts, per the spec-59 doctrine), only as
118 + * a pair — the input surface and its text from the same source — and
119 + * stays silent otherwise: Stripe's default theme is a coherent light
120 + * design, and half a dark palette printed onto it would read worse than
121 + * none of it. A dark input surface picks Stripe's own night theme so the
122 + * parts we never set (placeholders, dividers, the Link box) darken with
123 + * it.
124 + *
125 + * Seeds the `fluent_cart/stripe_appearance` filter's default — a listener
126 + * still overrides everything here.
127 + *
128 + * @return array Stripe appearance config: ['theme' => ..., 'variables' => ...].
129 + */
130 + public static function stripeAppearance(): array
131 + {
132 + $appearance = ['theme' => 'stripe'];
133 +
134 + $colors = self::getMeasurableColors();
135 +
136 + if (!$colors) {
137 + return $appearance;
138 + }
139 +
140 + $inputBg = (string)Arr::get($colors, 'input_bg_color', '');
141 + $inputText = (string)Arr::get($colors, 'input_text_color', '');
142 +
143 + if ($inputBg === '' || $inputText === '') {
144 + return $appearance;
145 + }
146 +
147 + $appearance = [
148 + // readableOn() picks white on a dark surface — which is exactly
149 + // when Stripe should start from night instead of its light theme.
150 + 'theme' => ColorMath::readableOn($inputBg) === '#ffffff' ? 'night' : 'stripe',
151 + 'variables' => [
152 + 'colorBackground' => $inputBg,
153 + 'colorText' => $inputText,
154 + ],
155 + ];
156 +
157 + $accent = (string)Arr::get($colors, 'primary_bg_color', '');
158 +
159 + if ($accent !== '') {
160 + $appearance['variables']['colorPrimary'] = $accent;
161 + }
162 +
163 + return $appearance;
164 + }
165 +
166 + /**
167 + * Say on the body which colour source is in force.
168 + *
169 + * The custom properties printed above only reach surfaces FluentCart
170 + * already styles. A class reaches everything else — a theme that wants to
171 + * stand down once the store owner has picked their own colours, a snippet
172 + * in the customiser, a child theme adjusting one store. None of those can
173 + * read the setting; all of them can write a selector.
174 + *
175 + * The two markers are alternatives, never both, so `body.fluent-cart-theme`
176 + * and `body.fluent-cart-custom` partition every storefront page between
177 + * them.
178 + *
179 + * The active theme is named only under `inherit_from_theme`, because that
180 + * is the one source whose result depends on which theme is running. Naming
181 + * it under `customize` would invite a selector that breaks on switching
182 + * theme for no reason, since the owner's colours are the owner's colours
183 + * either way.
184 + *
185 + * @param array $classes
186 + * @return array
187 + */
188 + public static function bodyClasses($classes): array
189 + {
190 + if (!is_array($classes)) {
191 + return [];
192 + }
193 +
194 + $source = self::getSource();
195 +
196 + // getSource() already collapses an unrecognised stored value to the
197 + // default, so this agrees with what actually gets printed rather than
198 + // with what the option happens to say.
199 + if ($source !== ColorPalette::SOURCE_THEME && $source !== ColorPalette::SOURCE_CUSTOM) {
200 + $classes[] = self::CLASS_DEFAULT;
201 +
202 + return $classes;
203 + }
204 +
205 + $classes[] = self::CLASS_CUSTOM;
206 +
207 + if ($source === ColorPalette::SOURCE_THEME) {
208 + $slug = self::themeClass();
209 +
210 + if ($slug !== '') {
211 + $classes[] = $slug;
212 + }
213 +
214 + // Nothing readable means nothing written, and the stylesheets have
215 + // to know that: their colour declarations must not exist for the
216 + // theme's own rules to apply, and only a class can express that.
217 + if (!self::getThemeColors()) {
218 + $classes[] = self::CLASS_NO_COLORS;
219 + }
220 + }
221 +
222 + return $classes;
223 + }
224 +
225 + /**
226 + * The active theme as a class name.
227 + *
228 + * The template rather than the stylesheet: most real stores run a child
229 + * theme, whose styling is the parent's plus a few overrides, so a rule
230 + * written for `astra` is the one that is actually wanted. Naming
231 + * `astra-child` would leave that rule matching nothing on exactly the sites
232 + * most likely to need it.
233 + *
234 + * A theme directory name is not a class name, and this lands inside a class
235 + * attribute on every storefront page, so it goes through
236 + * sanitize_html_class() — which can legitimately return nothing, and an
237 + * empty class is not worth adding.
238 + *
239 + * @return string
240 + */
241 + protected static function themeClass(): string
242 + {
243 + if (!function_exists('get_template')) {
244 + return '';
245 + }
246 +
247 + return (string)sanitize_html_class((string)get_template());
248 + }
249 +
250 + /**
251 + * Print the custom-property block.
252 + *
253 + * The modal checkout renders in an iframe pointed at a normal WordPress
254 + * URL, and that view calls wp_head() too, so this one hook covers both the
255 + * storefront and the modal without a second injection point.
256 + *
257 + * @return void
258 + */
259 + public static function printColors(): void
260 + {
261 + if (is_admin()) {
262 + return;
263 + }
264 +
265 + $css = self::buildCss();
266 +
267 + if ($css === '') {
268 + return;
269 + }
270 +
271 + /*
272 + * Not escaped on output because it cannot carry anything to escape:
273 + * every property name comes from the ColorPalette registry and every
274 + * value has been through sanitize_hex_color(), so the string is only
275 + * ever `--fct-name: #rrggbb;`.
276 + */
277 + echo '<style id="' . esc_attr(self::STYLE_ID) . '">' . $css . '</style>' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
278 + }
279 +
280 + /**
281 + * Which source the store is configured to use.
282 + *
283 + * @return string One of the ColorPalette::SOURCE_* constants.
284 + */
285 + public static function getSource(): string
286 + {
287 + $source = (new StoreSettings())->get('appearance_source', ColorPalette::SOURCE_DEFAULT);
288 +
289 + return in_array($source, ColorPalette::sources(), true)
290 + ? $source
291 + : ColorPalette::SOURCE_DEFAULT;
292 + }
293 +
294 + /**
295 + * The colours the store owner set by hand, keyed by settings key.
296 + *
297 + * Anything not in the registry, and anything that is not a valid hex, is
298 + * dropped — the option is sanitised on save, but a stale option written by
299 + * an older version must not reach the page unchecked.
300 + *
301 + * @return array
302 + */
303 + public static function getCustomColors(): array
304 + {
305 + $stored = (new StoreSettings())->get('appearance_colors', []);
306 +
307 + if (!is_array($stored)) {
308 + return [];
309 + }
310 +
311 + $globals = ColorPalette::globals();
312 + $colors = [];
313 +
314 + foreach ($globals as $key => $definition) {
315 + $hex = sanitize_hex_color((string)Arr::get($stored, $key, ''));
316 +
317 + if ($hex) {
318 + $colors[$key] = $hex;
319 + }
320 + }
321 +
322 + return $colors;
323 + }
324 +
325 + /**
326 + * The colours the active theme supplies, keyed by settings key.
327 + *
328 + * @return array
329 + */
330 + public static function getThemeColors(): array
331 + {
332 + // With nothing to inherit, every role would resolve to FluentCart's own
333 + // fallback and we would pin twenty properties to values the theme never
334 + // chose. That is not the same as leaving them alone: FluentCart's
335 + // per-file fallbacks differ from one surface to the next, so pinning
336 + // one value everywhere quietly changes the store while claiming to be
337 + // following the theme. Write nothing instead.
338 + if (!ThemePalette::hasUsableSource()) {
339 + return [];
340 + }
341 +
342 + $roles = ThemePalette::resolve();
343 + $colors = [];
344 +
345 + foreach (ColorPalette::globals() as $key => $definition) {
346 + $role = Arr::get($definition, 'role', '');
347 + $value = self::sanitizeDeclarationValue(Arr::get($roles, $role, ''));
348 +
349 + if ($value !== '') {
350 + $colors[$key] = $value;
351 + }
352 + }
353 +
354 + return $colors;
355 + }
356 +
357 + /**
358 + * The only two shapes allowed on the right of one of our declarations.
359 + *
360 + * A hex colour, or a bare custom-property reference for the themes that
361 + * publish their palette that way. Everything else is refused: this string
362 + * is written into a style element on every storefront page, so the grammar
363 + * stays narrow enough that nothing can ride along inside it.
364 + *
365 + * @param mixed $value
366 + * @return string The safe value, or '' when it is neither.
367 + */
368 + protected static function sanitizeDeclarationValue($value): string
369 + {
370 + $value = (string)$value;
371 +
372 + $hex = sanitize_hex_color($value);
373 +
374 + if ($hex) {
375 + return $hex;
376 + }
377 +
378 + // The two reference shapes ThemePalette::safeReference() produces: a
379 + // bare custom property, or one whose sole fallback is a hex colour —
380 + // the form Customify publishes. Nothing looser.
381 + return preg_match('/^var\(--[A-Za-z0-9_-]+(?:, #(?:[0-9a-f]{3}|[0-9a-f]{6}))?\)$/', $value) ? $value : '';
382 + }
383 +
384 + /**
385 + * Every colour that will actually be written, keyed by settings key.
386 + *
387 + * @return array
388 + */
389 + public static function getEffectiveColors(): array
390 + {
391 + $source = self::getSource();
392 +
393 + if ($source === ColorPalette::SOURCE_CUSTOM) {
394 + $colors = self::getCustomColors();
395 + } elseif ($source === ColorPalette::SOURCE_THEME) {
396 + $colors = self::getThemeColors();
397 + } else {
398 + $colors = [];
399 + }
400 +
401 + /**
402 + * Filter the storefront colours before they are written to the page.
403 + *
404 + * @param array $colors Settings key => hex.
405 + * @param array $context Read-only context for the decision.
406 + */
407 + $filteredColors = apply_filters('fluent_cart/theme/storefront_colors', $colors, [
408 + 'source' => $source,
409 + ]);
410 +
411 + return is_array($filteredColors) ? $filteredColors : $colors;
412 + }
413 +
414 + /**
415 + * The effective colours as plain hexes, keyed like getEffectiveColors().
416 + *
417 + * A separate reader instead of extra keys in the effective colours: the
418 + * registry is publicly extensible, so no key shape is safe to reserve
419 + * there. A reference measures as its shipped hex fallback; anything
420 + * unmeasurable is simply absent, so absence itself means "no real
421 + * colour known".
422 + *
423 + * @return array Settings key => hex.
424 + */
425 + public static function getMeasurableColors(): array
426 + {
427 + $measured = [];
428 +
429 + foreach (self::getEffectiveColors() as $key => $value) {
430 + if (!is_string($value)) {
431 + continue;
432 + }
433 +
434 + $hex = ThemePalette::measurable($value);
435 +
436 + if ($hex !== '') {
437 + $measured[$key] = $hex;
438 + }
439 + }
440 +
441 + return $measured;
442 + }
443 +
444 + /**
445 + * Build the `:root` declaration block.
446 + *
447 + * @return string CSS, or '' when there is nothing to write.
448 + */
449 + public static function buildCss(): string
450 + {
451 + $colors = self::getEffectiveColors();
452 +
453 + if (!$colors) {
454 + return '';
455 + }
456 +
457 + $globals = ColorPalette::globals();
458 + $declarations = '';
459 +
460 + foreach ($colors as $key => $value) {
461 + if (!isset($globals[$key])) {
462 + continue;
463 + }
464 +
465 + // Re-checked here rather than trusted from the source that produced
466 + // it, because a filter sits between the two.
467 + $safe = self::sanitizeDeclarationValue($value);
468 +
469 + if ($safe === '') {
470 + continue;
471 + }
472 +
473 + foreach (ColorPalette::varsOf($globals[$key]) as $property) {
474 + $declarations .= $property . ':' . $safe . ';';
475 + }
476 + }
477 +
478 + return $declarations === '' ? '' : ':root{' . $declarations . '}';
479 + }
480 +}