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 | api/StoreSettings.php +572 -5 1.4.0 → 1.6.6 View file →
@@ -7,8 +7,13 @@
7 7 use FluentCart\App\CPT\Pages;
8 8 use FluentCart\App\Helpers\AddressHelper;
9 9 use FluentCart\App\Helpers\CurrenciesHelper;
10 10 use FluentCart\App\Services\OrderService;
11 +use FluentCart\App\Services\Theme\ColorPalette;
12 +use FluentCart\App\Services\Theme\ThemePalette;
13 +use FluentCart\App\Modules\PaymentMethods\Core\GatewayManager;
14 +use FluentCart\App\Modules\StoreManagedRenewal\Services\RenewalService;
15 +use FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode;
11 16 use FluentCart\Framework\Support\Arr;
12 17 use FluentCart\Framework\Support\ArrayableInterface;
13 18 use FluentCart\Framework\Support\Str;
14 19 use FluentCart\App\Services\Permission\PermissionManager;
@@ -14,8 +19,11 @@
14 19 use FluentCart\App\Services\Permission\PermissionManager;
15 20
16 21 class StoreSettings implements ArrayableInterface
17 22 {
23 + const CACHE_KEY = 'store_settings';
24 + const CACHE_GROUP = 'fluentcart';
25 +
18 26 /**
19 27 * @var string
20 28 *
21 29 * Store settings option name
@@ -30,8 +38,14 @@
30 38 protected array $storeSettings;
31 39
32 40 protected static $cachedStoreSettings = null;
33 41
42 + public static function clearCache(): void
43 + {
44 + self::$cachedStoreSettings = null;
45 + wp_cache_delete(self::CACHE_KEY, self::CACHE_GROUP);
46 + }
47 +
34 48 public function __construct()
35 49 {
36 50 if (self::$cachedStoreSettings !== null) {
37 51 $this->storeSettings = self::$cachedStoreSettings;
@@ -68,8 +82,9 @@
68 82 'show_cart_icon_in_body' => 'yes',
69 83 'additional_address_field' => 'yes',
70 84 'hide_coupon_field' => 'no',
71 85 'user_account_creation_mode' => 'all',
86 + 'auto_login_after_account_creation' => 'no',
72 87 'checkout_page_id' => '',
73 88 'custom_payment_page_id' => '',
74 89 'registration_page_id' => '',
75 90 'login_page_id' => '',
@@ -87,16 +102,29 @@
87 102 'store_state' => '',
88 103 'show_relevant_product_in_single_page' => 'yes',
89 104 'show_relevant_product_in_modal' => '',
90 105 'order_mode' => 'test',
106 + 'subscription_mode_guard' => 'yes',
91 107 'variation_view' => 'both',
92 108 'variation_columns' => 'masonry',
93 109 'enable_early_payment_for_installment' => 'yes',
110 + 'subscription_management_mode' => 'gateway_managed',
111 + 'subscription_system_charge' => 'no',
94 112 'modules_settings' => [],
95 113 'min_receipt_number' => '1',
96 114 'inv_prefix' => 'INV-',
97 115 'weight_unit' => 'kg',
98 - 'dimension_unit' => 'cm'
116 + 'dimension_unit' => 'cm',
117 + 'appearance_source' => ColorPalette::SOURCE_DEFAULT,
118 + 'appearance_colors' => [],
119 + // 'wordpress', not 'fluent_cart': the FluentCart patterns are
120 + // literals ('M j, Y'), and a literal renders a half-translated date
121 + // on a localized store -- a German month in English field order,
122 + // which a German reader misreads as day-first. Following
123 + // Settings > General is the only source that is correct in every
124 + // locale, so it is what a store gets until it chooses otherwise.
125 + 'date_time_format_source' => 'wordpress',
126 + 'timezone_source' => 'fluent_cart'
99 127 ];
100 128
101 129 return apply_filters('fluent_cart/store_settings/values', $defaultSettings, []);
102 130 }
@@ -120,9 +148,54 @@
120 148 ];
121 149 $isProActive = App::isProActive();
122 150 $proFeatureIcon = Vite::getAssetUrl('images/crown.svg');
123 151
152 + // Read-only schedule for store-managed subscription renewals. Pulled live
153 + // from the same map the scheduler uses, so it stays accurate under the
154 + // fluent_cart/renewal/advance_creation_days filter. No editable knob — the timing is
155 + // deliberately built-in; developers tune it via that filter.
156 + $invoiceScheduleMap = RenewalService::getAdvanceCreationDaysMap();
157 + $invoiceScheduleLabels = [
158 + 'daily' => __('Daily', 'fluent-cart'),
159 + 'weekly' => __('Weekly', 'fluent-cart'),
160 + 'monthly' => __('Monthly', 'fluent-cart'),
161 + 'quarterly' => __('Quarterly', 'fluent-cart'),
162 + 'half_yearly' => __('Half-yearly', 'fluent-cart'),
163 + 'yearly' => __('Yearly', 'fluent-cart'),
164 + ];
165 + // Structured renewal-order schedule for the SubscriptionModeManager
166 + // component (status card + guarded edit dialog).
167 + $invoiceScheduleList = [];
168 + foreach ($invoiceScheduleLabels as $invoiceScheduleKey => $invoiceScheduleLabel) {
169 + if (!isset($invoiceScheduleMap[$invoiceScheduleKey])) {
170 + continue;
171 + }
172 + $invoiceScheduleDays = (int) $invoiceScheduleMap[$invoiceScheduleKey];
173 + $invoiceScheduleList[] = [
174 + 'label' => $invoiceScheduleLabel,
175 + 'when' => $invoiceScheduleDays <= 0
176 + ? __('on the due date', 'fluent-cart')
177 + /* translators: %d: number of days before the due date */
178 + : sprintf(_n('%d day before due date', '%d days before due date', $invoiceScheduleDays, 'fluent-cart'), $invoiceScheduleDays),
179 + ];
180 + }
124 181
182 + // Gateways declaring `system_subscription` — the only ones
183 + // subscription_system_charge can ever auto-charge.
184 + $systemChargeGateways = [];
185 + foreach (GatewayManager::getInstance()->all() as $systemChargeGateway) {
186 + if (!$systemChargeGateway->has('system_subscription') || $systemChargeGateway->isUpcoming()) {
187 + continue;
188 + }
189 + $systemChargeGatewayMeta = $systemChargeGateway->getMeta();
190 + $systemChargeGateways[] = [
191 + 'label' => Arr::get($systemChargeGatewayMeta, 'admin_title')
192 + ?: Arr::get($systemChargeGatewayMeta, 'label')
193 + ?: Arr::get($systemChargeGatewayMeta, 'title'),
194 + 'active' => $systemChargeGateway->isEnabled(),
195 + ];
196 + }
197 +
125 198 $fields = [
126 199 'setting_tabs' => [
127 200 'type' => 'section',
128 201 'disable_nesting' => true,
@@ -129,8 +202,9 @@
129 202 'default_tab' => 'store_setup',
130 203 'hide_tab_switch' => true,
131 204 'schema' => [
132 205 'store_setup' => [
206 + 'id' => '',
133 207 'title' => __('Store Setup', 'fluent-cart'),
134 208 'show_title' => false,
135 209 'type' => 'section',
136 210 'disable_nesting' => true,
@@ -237,9 +311,9 @@
237 311 ],
238 312 ]
239 313 ],
240 314
241 - 'settings_hr' => [
315 + 'date_time_hr' => [
242 316 'type' => 'html',
243 317 'value' => '<hr class="settings-divider">'
244 318 ],
245 319
@@ -505,8 +579,79 @@
505 579 ],
506 580 ]
507 581 ],
508 582
583 + 'settings_hr' => [
584 + 'type' => 'html',
585 + 'value' => '<hr class="settings-divider">'
586 + ],
587 +
588 + 'date_time_format_grid' => [
589 + 'type' => 'grid',
590 + 'columns' => [
591 + 'default' => 1,
592 + 'md' => 3
593 + ],
594 + 'disable_nesting' => true,
595 + 'schema' => [
596 + 'label' => [
597 + 'type' => 'html',
598 + 'value' => '<span class="setting-label">' . __('Date & Time Format', 'fluent-cart') . '</span>
599 + <div class="form-note">' . __("Which date and time format to display. `Smart` keeps FluentCart's own format; `WordPress` follows Settings &rarr; General.", 'fluent-cart') . '</div>'
600 + ],
601 + 'date_time_format_source' => [
602 + 'wrapperClass' => 'col-span-2 flex items-center',
603 + 'label' => '',
604 + 'type' => 'radio',
605 + 'options' => [
606 + [
607 + // Label only -- the stored value stays 'fluent_cart'.
608 + 'label' => __('Smart', 'fluent-cart'),
609 + 'value' => 'fluent_cart',
610 + ],
611 + [
612 + 'label' => __('WordPress', 'fluent-cart'),
613 + 'value' => 'wordpress',
614 + ],
615 + ],
616 + 'value' => 'fluent_cart'
617 + ],
618 + ]
619 + ],
620 +
621 + 'timezone_source_grid' => [
622 + 'type' => 'grid',
623 + 'columns' => [
624 + 'default' => 1,
625 + 'md' => 3
626 + ],
627 + 'disable_nesting' => true,
628 + 'schema' => [
629 + 'label' => [
630 + 'type' => 'html',
631 + 'value' => '<span class="setting-label">' . __('Timezone', 'fluent-cart') . '</span>
632 + <div class="form-note">' . __("Which timezone to display dates in. `Browser` shows admin and dashboard dates in the viewer's own timezone, and renders emails and invoices in the timezone captured at checkout; `WordPress` uses the site timezone from Settings &rarr; General.", 'fluent-cart') . '</div>'
633 + ],
634 + 'timezone_source' => [
635 + 'wrapperClass' => 'col-span-2 flex items-center',
636 + 'label' => '',
637 + 'type' => 'radio',
638 + 'options' => [
639 + [
640 + // Label only -- the stored value stays 'fluent_cart'.
641 + 'label' => __('Browser', 'fluent-cart'),
642 + 'value' => 'fluent_cart',
643 + ],
644 + [
645 + 'label' => __('WordPress', 'fluent-cart'),
646 + 'value' => 'wordpress',
647 + ],
648 + ],
649 + 'value' => 'fluent_cart'
650 + ],
651 + ]
652 + ],
653 +
509 654 // 'settings_hr_modal' => [
510 655 // 'type' => 'html',
511 656 // 'value' => '<hr class="settings-divider">'
512 657 // ],
@@ -594,8 +739,9 @@
594 739 // ]
595 740 // ]
596 741 // ],
597 742 'pages_setup' => [
743 + 'id' => '',
598 744 'title' => __('Pages Setup', 'fluent-cart'),
599 745 'show_title' => false,
600 746 'type' => 'section',
601 747 'disable_nesting' => true,
@@ -990,8 +1136,40 @@
990 1136 ]
991 1137 ],
992 1138 ],
993 1139 ],
1140 + 'compliance' => [
1141 + 'title' => __('Compliance', 'fluent-cart'),
1142 + 'show_title' => false,
1143 + 'type' => 'section',
1144 + 'disable_nesting' => true,
1145 + 'columns' => [
1146 + 'default' => 1,
1147 + 'md' => 1,
1148 + ],
1149 + 'schema' => [
1150 + 'auto_login_after_account_creation' => [
1151 + 'wrapperClass' => 'fct-compliance-auto-login',
1152 + 'label' => __('Login after account creation', 'fluent-cart'),
1153 + 'type' => 'radio',
1154 + 'value' => 'no',
1155 + 'attributes' => [
1156 + 'aria-label' => __('Login after account creation', 'fluent-cart'),
1157 + ],
1158 + 'options' => [
1159 + [
1160 + 'label' => __("Don't auto login after account creation", 'fluent-cart'),
1161 + 'value' => 'no',
1162 + ],
1163 + [
1164 + 'label' => __('Enable auto login after account creation', 'fluent-cart'),
1165 + 'value' => 'yes',
1166 + ],
1167 + ],
1168 + 'note' => __('Choose whether customers are logged in automatically when FluentCart creates their account during registration or after checkout.', 'fluent-cart'),
1169 + ],
1170 + ],
1171 + ],
994 1172 'cart_and_checkout' => [
995 1173 'title' => __('Cart & checkout', 'fluent-cart'),
996 1174 'show_title' => false,
997 1175 'type' => 'section',
@@ -1160,10 +1338,11 @@
1160 1338 'default' => 1,
1161 1339 'md' => 1
1162 1340 ],
1163 1341 'schema' => [
1164 - 'subscription_settings_grid' => [
1342 + 'subscription_early_payment_grid' => [
1165 1343 'type' => 'grid',
1344 + 'wrapperClass' => 'items-start mb-6',
1166 1345 'columns' => [
1167 1346 'default' => 1,
1168 1347 'md' => 3
1169 1348 ],
@@ -1170,10 +1349,10 @@
1170 1349 'disable_nesting' => true,
1171 1350 'schema' => [
1172 1351 'label' => [
1173 1352 'type' => 'html',
1174 - 'value' => '<span class="setting-label">' . __('Subscription Settings', 'fluent-cart') . (!$isProActive ? ' <img src="' . esc_url($proFeatureIcon) . '" alt="' . esc_attr__('Pro feature', 'fluent-cart') . '" class="pro-feature-icon" style="margin-left: 6px; width: 14px; height: 14px; display: inline-block; vertical-align: text-top;" />' : '') . '</span>
1175 - <div class="form-note">' . __('Configure how installment subscriptions can be paid early.', 'fluent-cart') . '</div>'
1353 + 'value' => '<span class="setting-label">' . __('Early Payment', 'fluent-cart') . (!$isProActive ? ' <img src="' . esc_url($proFeatureIcon) . '" alt="' . esc_attr__('Pro feature', 'fluent-cart') . '" class="pro-feature-icon" style="margin-left: 6px; width: 14px; height: 14px; display: inline-block; vertical-align: text-top;" />' : '') . '</span>
1354 + <div class="form-note">' . __('Let customers pay remaining installments before their due date.', 'fluent-cart') . '</div>'
1176 1355 ],
1177 1356 'fields' => [
1178 1357 'type' => 'grid',
1179 1358 'columns' => [
@@ -1196,10 +1375,111 @@
1196 1375 ]
1197 1376 ]
1198 1377 ]
1199 1378 ],
1379 + 'subscription_management_mode_grid' => [
1380 + 'type' => 'grid',
1381 + 'wrapperClass' => 'items-start',
1382 + 'columns' => [
1383 + 'default' => 1,
1384 + 'md' => 3
1385 + ],
1386 + 'disable_nesting' => true,
1387 + 'schema' => [
1388 + 'label' => [
1389 + 'type' => 'html',
1390 + 'value' => sprintf(
1391 + /* translators: 1: setting label, 2: setting description */
1392 + '<span class="setting-label">%1$s</span>
1393 + <div class="form-note">%2$s</div>',
1394 + __('Renewal Billing', 'fluent-cart'),
1395 + __('Choose how recurring subscription payments are collected.', 'fluent-cart')
1396 + )
1397 + ],
1398 + 'fields' => [
1399 + 'type' => 'grid',
1400 + 'columns' => [
1401 + 'default' => 1,
1402 + 'md' => 1
1403 + ],
1404 + 'disable_nesting' => true,
1405 + 'class' => 'col-span-2',
1406 + 'schema' => [
1407 + // Compact, guarded control: shows only the CURRENT mode with a
1408 + // short how-it-works summary and a Change button. Editing goes
1409 + // through a disclaimer confirm + dialog so the store-wide
1410 + // billing decision can never be flipped by a stray click.
1411 + 'subscription_management_mode' => [
1412 + 'label' => false,
1413 + 'component' => 'StoreSettings/SubscriptionModeManager',
1414 + 'disable_nesting' => true,
1415 + 'value' => 'gateway_managed',
1416 + // Read-only renewal-order schedule, built live from the
1417 + // same map the scheduler uses (filterable).
1418 + 'schedule_items' => $invoiceScheduleList,
1419 + // Which gateways auto-charge is actually possible on.
1420 + 'system_charge_gateways' => $systemChargeGateways,
1421 + ],
1422 + // Keep these in the form payload — the component above writes
1423 + // them; without a schema entry the keys would drop out of the
1424 + // saved values.
1425 + 'subscription_system_charge' => [
1426 + 'type' => 'hidden',
1427 + 'value' => 'no',
1428 + ],
1429 + 'subscription_manual_fallback' => [
1430 + 'type' => 'hidden',
1431 + 'value' => 'no',
1432 + ],
1433 + ]
1434 + ]
1435 + ]
1436 + ],
1437 + 'subscription_mode_guard_grid' => [
1438 + 'type' => 'grid',
1439 + 'wrapperClass' => 'items-start mt-6',
1440 + 'columns' => [
1441 + 'default' => 1,
1442 + 'md' => 3
1443 + ],
1444 + 'disable_nesting' => true,
1445 + 'schema' => [
1446 + 'label' => [
1447 + 'type' => 'html',
1448 + 'value' => sprintf(
1449 + /* translators: 1: setting label, 2: setting description */
1450 + '<span class="setting-label">%1$s</span>
1451 + <div class="form-note">%2$s</div>',
1452 + __('Staging Protection', 'fluent-cart'),
1453 + __('Prevent staging or test copies of your store from billing real customers.', 'fluent-cart')
1454 + )
1455 + ],
1456 + 'fields' => [
1457 + 'type' => 'grid',
1458 + 'columns' => [
1459 + 'default' => 1,
1460 + 'md' => 1
1461 + ],
1462 + 'disable_nesting' => true,
1463 + 'class' => 'col-span-2',
1464 + 'schema' => [
1465 + 'subscription_mode_guard' => [
1466 + 'label' => __('Don\'t bill live subscriptions from this site while it is in test mode', 'fluent-cart'),
1467 + 'type' => 'checkbox',
1468 + 'value' => 'yes',
1469 + 'note' => sprintf(
1470 + /* translators: 1: the setting's explanatory note text */
1471 + "<div class='pl-6'>%1\$s</div>",
1472 + __('In test mode, this site won\'t invoice, charge, or email live subscriptions — so a staging copy can never double-bill customers. Billing from this site resumes when it is live again.', 'fluent-cart')
1473 + )
1474 + ],
1475 + ]
1476 + ]
1477 + ]
1478 + ],
1200 1479 ]
1201 1480 ],
1481 + 'appearance' => $this->getAppearanceSchema(),
1202 1482 ]
1203 1483 ],
1204 1484 ];
1205 1485
@@ -1206,8 +1486,14 @@
1206 1486
1207 1487 // Only show weight/dimension unit fields to users with shipping-sensitive permission
1208 1488 if (PermissionManager::hasPermission(['store/sensitive'])) {
1209 1489 $storeSchema = &$fields['setting_tabs']['schema']['store_setup']['schema'];
1490 +
1491 + $storeSchema['weight_unit_grid_divider'] = [
1492 + 'type' => 'html',
1493 + 'value' => '<hr class="settings-divider">'
1494 + ];
1495 +
1210 1496 $storeSchema['weight_unit_grid'] = [
1211 1497 'type' => 'grid',
1212 1498 'columns' => ['default' => 1, 'md' => 3],
1213 1499 'disable_nesting' => true,
@@ -1314,8 +1600,9 @@
1314 1600
1315 1601 update_option($this->optionKey, $settings, true);
1316 1602 $this->storeSettings = $settings;
1317 1603 self::$cachedStoreSettings = $this->storeSettings;
1604 + wp_cache_delete(self::CACHE_KEY, self::CACHE_GROUP);
1318 1605
1319 1606 $isSlugChanged = Arr::get($prevSettings, 'product_slug') !== Arr::get($settings, 'product_slug');
1320 1607 $isAccountPageChanged = Arr::get($prevSettings, 'customer_profile_page_slug') !== Arr::get($settings, 'customer_profile_page_slug');
1321 1608
@@ -1347,8 +1634,11 @@
1347 1634
1348 1635 public function isCheckoutPage(): bool
1349 1636 {
1350 1637 global $post;
1638 + if (!$post instanceof \WP_Post) {
1639 + return false;
1640 + }
1351 1641 $pageId = $this->getCheckoutPageId();
1352 1642 return intval($pageId) === intval($post->ID);
1353 1643 }
1354 1644
@@ -1592,8 +1882,284 @@
1592 1882 return trim(str_replace(home_url('/'), '', $url), '/');
1593 1883 }
1594 1884
1595 1885 /**
1886 + * The Appearance settings tab.
1887 + *
1888 + * Built from the ColorPalette registry rather than written out by hand, so
1889 + * a colour added to the registry becomes settable here, sanitised on save
1890 + * and written to the storefront without three separate edits.
1891 + *
1892 + * @return array
1893 + */
1894 + protected function getAppearanceSchema(): array
1895 + {
1896 + // Plain text — the component renders these as text nodes, so no markup
1897 + // and no escaping here or the entities would show up verbatim.
1898 + $sourceOptions = [
1899 + [
1900 + 'label' => __("FluentCart's own colors", 'fluent-cart'),
1901 + 'value' => ColorPalette::SOURCE_DEFAULT,
1902 + 'icon' => 'PaletteLine',
1903 + 'note' => __('The storefront keeps the colors it ships with.', 'fluent-cart'),
1904 + ],
1905 + [
1906 + 'label' => __('Inherit from the active theme', 'fluent-cart'),
1907 + 'value' => ColorPalette::SOURCE_THEME,
1908 + 'icon' => 'PaintLine',
1909 + 'note' => __('FluentCart uses your active theme\'s colors for the storefront. If they don\'t come through as expected, choose Customize to set them yourself.', 'fluent-cart'),
1910 + ],
1911 + [
1912 + 'label' => __('Customize', 'fluent-cart'),
1913 + 'value' => ColorPalette::SOURCE_CUSTOM,
1914 + 'icon' => 'PaletteLine',
1915 + 'note' => __('Pick the colors yourself. Only the ones you set are written to the storefront.', 'fluent-cart'),
1916 + ],
1917 + ];
1918 +
1919 + $sourceHeading = [
1920 + 'label' => __('Where colors come from', 'fluent-cart'),
1921 + 'note' => __('Storefront colors cascade from a small set of globals, so changing one here updates every page that uses it.', 'fluent-cart'),
1922 + ];
1923 +
1924 + return [
1925 + 'title' => __('Appearance', 'fluent-cart'),
1926 + 'show_title' => false,
1927 + 'type' => 'section',
1928 + 'wrapperClass' => 'fct-appearance-component-section',
1929 + 'disable_nesting' => true,
1930 + 'columns' => [
1931 + 'default' => 1,
1932 + 'md' => 1,
1933 + ],
1934 + 'schema' => [
1935 + 'appearance_component' => [
1936 + 'type' => 'component',
1937 + 'component' => 'StoreSettings/AppearanceComponent',
1938 + 'wrapperClass' => 'col-span-full',
1939 + 'label' => false,
1940 + // Rendered by the component itself, so the heading sits
1941 + // inside .fct-appearance-component with the controls it
1942 + // describes rather than as a sibling html field.
1943 + 'heading' => $sourceHeading,
1944 + 'source_options' => $sourceOptions,
1945 + 'custom_source' => ColorPalette::SOURCE_CUSTOM,
1946 + 'color_groups' => $this->getAppearanceColorGroups(),
1947 + 'preview' => $this->getAppearancePreviewData(),
1948 + ],
1949 + 'appearance_source' => [
1950 + 'type' => 'hidden',
1951 + 'value' => ColorPalette::SOURCE_DEFAULT,
1952 + ],
1953 + 'appearance_colors' => [
1954 + 'type' => 'hidden',
1955 + 'value' => (object)[],
1956 + ],
1957 + ],
1958 + ];
1959 + }
1960 +
1961 + /**
1962 + * What the storefront preview needs to paint itself.
1963 + *
1964 + * The preview is drawn from semantic roles rather than settings keys, so
1965 + * one mock card covers all three sources: `customize` reads the pickers the
1966 + * owner has filled in, and the other two are resolved here because their
1967 + * colors only exist server side — `inherit_from_theme` is mixed out of
1968 + * theme.json by ThemePalette and nothing about it reaches the browser.
1969 + *
1970 + * @return array
1971 + */
1972 + protected function getAppearancePreviewData(): array
1973 + {
1974 + // No colour is sent with a slot. appearance.scss already writes every
1975 + // preview surface as `var(--fct-pv-x, <fallback>)`, so the mock has the
1976 + // same single source of truth the storefront does: a slot the component
1977 + // cannot resolve simply goes undeclared and the stylesheet's fallback
1978 + // applies.
1979 + return [
1980 + 'slots' => $this->getAppearancePreviewSlots(),
1981 + 'theme_source' => ColorPalette::SOURCE_THEME,
1982 + 'theme_roles' => $this->getAppearanceThemeRoles(),
1983 + 'notes' => [
1984 + ColorPalette::SOURCE_THEME => $this->getAppearanceThemeNote(),
1985 + ColorPalette::SOURCE_CUSTOM => __('Set only the colors you want to change — anything you leave unset keeps FluentCart\'s own default.', 'fluent-cart'),
1986 + ],
1987 + ];
1988 + }
1989 +
1990 + /**
1991 + * Which custom property the preview paints each surface with.
1992 + *
1993 + * Keyed by settings key, not by role. Four separate globals claim the
1994 + * `accent` role — active text, the brand background and both active
1995 + * borders — so collapsing the preview to roles would let one picker drive
1996 + * surfaces the storefront keeps apart, and leave the others with no visible
1997 + * effect at all. `role` is carried only for theme inheritance, which
1998 + * genuinely is role-based: FrontendTheme::getThemeColors() gives every key
1999 + * sharing a role the same colour, so the preview collapsing there is the
2000 + * storefront's own behaviour rather than a shortcut.
2001 + *
2002 + * A slot with no `key` is a surface no global controls; it follows the
2003 + * role's default and cannot be edited.
2004 + *
2005 + * @return array
2006 + */
2007 + protected function getAppearancePreviewSlots(): array
2008 + {
2009 + return [
2010 + ['var' => 'surface', 'role' => 'surface', 'key' => 'card_bg_color'],
2011 + ['var' => 'surface-mute', 'role' => 'surface_mute'],
2012 + ['var' => 'surface-alt', 'role' => 'surface_alt', 'key' => 'secondary_bg_color'],
2013 + ['var' => 'text', 'role' => 'text', 'key' => 'primary_text_color'],
2014 + ['var' => 'text-muted', 'role' => 'text_muted', 'key' => 'secondary_text_color'],
2015 + ['var' => 'text-placeholder', 'role' => 'text_placeholder', 'key' => 'input_placeholder_text_color'],
2016 + ['var' => 'input-bg', 'role' => 'surface', 'key' => 'input_bg_color'],
2017 + ['var' => 'input-text', 'role' => 'text', 'key' => 'input_text_color'],
2018 + ['var' => 'input-disabled-bg', 'role' => 'surface_mute', 'key' => 'input_disabled_bg_color'],
2019 + ['var' => 'accent-text', 'role' => 'accent', 'key' => 'primary_active_text_color'],
2020 + ['var' => 'active-border', 'role' => 'accent', 'key' => 'active_border_color'],
2021 + ['var' => 'border', 'role' => 'border', 'key' => 'border_color'],
2022 + ['var' => 'divider', 'role' => 'divider', 'key' => 'divider_color'],
2023 + ['var' => 'button-bg', 'role' => 'button_bg', 'key' => 'btn_bg_color'],
2024 + ['var' => 'button-text', 'role' => 'button_text', 'key' => 'btn_text_color'],
2025 + ['var' => 'secondary-button-bg', 'role' => 'surface', 'key' => 'secondary_btn_bg_color'],
2026 + ['var' => 'secondary-button-text', 'role' => 'text', 'key' => 'secondary_btn_text_color'],
2027 + ['var' => 'secondary-button-border', 'role' => 'border', 'key' => 'secondary_btn_border_color'],
2028 + ['var' => 'secondary-button-hover-bg', 'role' => 'surface_mute', 'key' => 'secondary_btn_hover_bg_color'],
2029 + ];
2030 + }
2031 +
2032 + /**
2033 + * Why the theme preview may not be the whole truth.
2034 + *
2035 + * Themes such as Astra publish their palette as `var(--ast-global-color-0)`
2036 + * rather than as colors. FrontendTheme writes those references straight
2037 + * through and the browser resolves them on the storefront, but the property
2038 + * is not declared in wp-admin, so the preview cannot show them. Saying so
2039 + * beats showing FluentCart's colors and letting the owner believe that is
2040 + * what inheriting will look like.
2041 + *
2042 + * @return string Empty when the preview is faithful.
2043 + */
2044 + protected function getAppearanceThemeNote(): string
2045 + {
2046 + if (!ThemePalette::hasUsableSource()) {
2047 + return __('The active theme publishes nothing to inherit, so the storefront keeps FluentCart\'s own colors.', 'fluent-cart');
2048 + }
2049 +
2050 + // Only a colour with no hex anywhere is unpreviewable. A live
2051 + // reference with a hex fallback — what the theme-settings readers and
2052 + // Blocksy write — previews as that fallback.
2053 + foreach (ThemePalette::resolve() as $value) {
2054 + if ((string)$value !== '' && ThemePalette::measurable((string)$value) === '') {
2055 + return __('This theme publishes its palette as CSS variables. The storefront reads them, but they cannot be resolved here — the preview shows FluentCart\'s colors in their place.', 'fluent-cart');
2056 + }
2057 + }
2058 +
2059 + return '';
2060 + }
2061 +
2062 + /**
2063 + * The theme's colors for the preview, role by role.
2064 + *
2065 + * Only roles that resolved to a real color are returned — the preview
2066 + * falls back to each slot's own default for the rest.
2067 + *
2068 + * @return array Role => hex.
2069 + */
2070 + protected function getAppearanceThemeRoles(): array
2071 + {
2072 + // With nothing to inherit, FrontendTheme writes no declarations at all,
2073 + // so the storefront keeps FluentCart's colors — and so must the
2074 + // preview, or it would promise a change that never happens.
2075 + if (!ThemePalette::hasUsableSource()) {
2076 + return [];
2077 + }
2078 +
2079 + $roles = [];
2080 +
2081 + foreach (ThemePalette::resolve() as $role => $value) {
2082 + // A theme that publishes a bare `var(--x)` is written to the
2083 + // storefront verbatim and resolved by the browser there, but that
2084 + // property is not declared in wp-admin. Unpreviewable, so leave it
2085 + // out and let the slot fall back to its own default. A reference
2086 + // with a hex fallback (`var(--x, #hex)`) previews as that hex —
2087 + // the same colour the storefront measures it by.
2088 + $hex = ThemePalette::measurable((string)$value);
2089 +
2090 + if ($hex) {
2091 + $roles[$role] = $hex;
2092 + }
2093 + }
2094 +
2095 + return $roles;
2096 + }
2097 +
2098 + /**
2099 + * The colour knobs, grouped the way the registry groups them, as data the
2100 + * appearance component renders its pickers from.
2101 + *
2102 + * @return array
2103 + */
2104 + protected function getAppearanceColorGroups(): array
2105 + {
2106 + $groups = [];
2107 +
2108 + foreach (ColorPalette::groups() as $groupKey => $groupLabel) {
2109 + $globals = ColorPalette::globalsFor($groupKey);
2110 +
2111 + if (!$globals) {
2112 + continue;
2113 + }
2114 +
2115 + $fields = [];
2116 +
2117 + foreach ($globals as $key => $definition) {
2118 + $fields[] = [
2119 + 'key' => $key,
2120 + 'label' => Arr::get($definition, 'label', $key),
2121 + 'note' => $this->getAppearanceFieldNote($definition),
2122 + 'default' => (string)Arr::get($definition, 'default', ''),
2123 + ];
2124 + }
2125 +
2126 + $groups[] = [
2127 + 'key' => $groupKey,
2128 + 'label' => $groupLabel,
2129 + 'fields' => $fields,
2130 + ];
2131 + }
2132 +
2133 + return $groups;
2134 + }
2135 +
2136 + /**
2137 + * The hint under one colour picker: what it drives, and the value it
2138 + * falls back to when left empty.
2139 + *
2140 + * @param array $definition
2141 + * @return string
2142 + */
2143 + protected function getAppearanceFieldNote(array $definition): string
2144 + {
2145 + $usage = (string)Arr::get($definition, 'note', '');
2146 + $default = (string)Arr::get($definition, 'default', '');
2147 +
2148 + if ($default === '') {
2149 + return $usage;
2150 + }
2151 +
2152 + if ($usage === '') {
2153 + /* translators: %1$s: the hex colour FluentCart falls back to */
2154 + return sprintf(__('Default: %1$s', 'fluent-cart'), $default);
2155 + }
2156 +
2157 + /* translators: 1: what the colour is used for, 2: the hex colour FluentCart falls back to */
2158 + return sprintf(__('%1$s Default: %2$s', 'fluent-cart'), $usage, $default);
2159 + }
2160 +
2161 + /**
1596 2162 * Get theme colors as CSS variable string.
1597 2163 *
1598 2164 * @return string
1599 2165 */
@@ -1627,5 +2193,6 @@
1627 2193 return Str::endsWith($link, '/') ? $link : $link . '/';
1628 2194 }
1629 2195 return '';
1630 2196 }
2197 +
1631 2198 }