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.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.5, at app/Services/Theme/FrontendTheme.php

481 lines 16.1 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 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();
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 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 }
481