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 | api/StoreSettings.php +641 -8 1.3.23 → 1.6.5 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,14 +38,25 @@
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 {
50 + if (self::$cachedStoreSettings !== null) {
51 + $this->storeSettings = self::$cachedStoreSettings;
52 + return;
53 + }
36 54 $defaultSettings = $this->getDefaultSettings();
37 55 $storeSettings = get_option($this->optionKey, []);
38 56 $settings = wp_parse_args($storeSettings, $defaultSettings);
39 57 $this->storeSettings = $settings;
58 + self::$cachedStoreSettings = $this->storeSettings;
40 59 }
41 60
42 61 protected function getDefaultSettings(): array
43 62 {
@@ -42,8 +61,12 @@
42 61 protected function getDefaultSettings(): array
43 62 {
44 63 $defaultSettings = [
45 64 'store_name' => get_bloginfo('name'),
65 + 'company_name' => '',
66 + 'legal_registration_id' => '',
67 + 'seller_vat_id' => '',
68 + 'seller_tax_id' => '',
46 69 'note_for_user_account_creation' => __('An user account will be created', 'fluent-cart'),
47 70 'checkout_button_text' => __('Checkout', 'fluent-cart'),
48 71 'view_cart_button_text' => __('View Cart', 'fluent-cart'),
49 72 'cart_button_text' => __('Add To Cart', 'fluent-cart'),
@@ -59,8 +82,9 @@
59 82 'show_cart_icon_in_body' => 'yes',
60 83 'additional_address_field' => 'yes',
61 84 'hide_coupon_field' => 'no',
62 85 'user_account_creation_mode' => 'all',
86 + 'auto_login_after_account_creation' => 'no',
63 87 'checkout_page_id' => '',
64 88 'custom_payment_page_id' => '',
65 89 'registration_page_id' => '',
66 90 'login_page_id' => '',
@@ -78,16 +102,29 @@
78 102 'store_state' => '',
79 103 'show_relevant_product_in_single_page' => 'yes',
80 104 'show_relevant_product_in_modal' => '',
81 105 'order_mode' => 'test',
106 + 'subscription_mode_guard' => 'yes',
82 107 'variation_view' => 'both',
83 108 'variation_columns' => 'masonry',
84 109 'enable_early_payment_for_installment' => 'yes',
110 + 'subscription_management_mode' => 'gateway_managed',
111 + 'subscription_system_charge' => 'no',
85 112 'modules_settings' => [],
86 113 'min_receipt_number' => '1',
87 114 'inv_prefix' => 'INV-',
88 115 'weight_unit' => 'kg',
89 - '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'
90 127 ];
91 128
92 129 return apply_filters('fluent_cart/store_settings/values', $defaultSettings, []);
93 130 }
@@ -111,9 +148,54 @@
111 148 ];
112 149 $isProActive = App::isProActive();
113 150 $proFeatureIcon = Vite::getAssetUrl('images/crown.svg');
114 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 + }
115 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 +
116 198 $fields = [
117 199 'setting_tabs' => [
118 200 'type' => 'section',
119 201 'disable_nesting' => true,
@@ -120,8 +202,9 @@
120 202 'default_tab' => 'store_setup',
121 203 'hide_tab_switch' => true,
122 204 'schema' => [
123 205 'store_setup' => [
206 + 'id' => '',
124 207 'title' => __('Store Setup', 'fluent-cart'),
125 208 'show_title' => false,
126 209 'type' => 'section',
127 210 'disable_nesting' => true,
@@ -228,9 +311,9 @@
228 311 ],
229 312 ]
230 313 ],
231 314
232 - 'settings_hr' => [
315 + 'date_time_hr' => [
233 316 'type' => 'html',
234 317 'value' => '<hr class="settings-divider">'
235 318 ],
236 319
@@ -286,8 +369,61 @@
286 369 'type' => 'html',
287 370 'value' => '<hr class="settings-divider">'
288 371 ],
289 372
373 + 'business_details_grid' => [
374 + 'type' => 'grid',
375 + 'id' => 'business_details',
376 + 'columns' => [
377 + 'default' => 1,
378 + 'md' => 3
379 + ],
380 + 'disable_nesting' => true,
381 + 'schema' => [
382 + 'label' => [
383 + 'type' => 'html',
384 + 'value' => '<span class="setting-label">' . __('Business Details', 'fluent-cart') . '</span>
385 + <div class="form-note">' . __('Add your legal business identity details used for store records and compliance.', 'fluent-cart') . '</div>'
386 + ],
387 + 'business_details_fields' => [
388 + 'type' => 'grid',
389 + 'columns' => [
390 + 'default' => 1,
391 + 'md' => 2
392 + ],
393 + 'disable_nesting' => true,
394 + 'wrapperClass' => 'col-span-2',
395 + 'schema' => [
396 + 'company_name' => [
397 + 'label' => __('Company Name', 'fluent-cart'),
398 + 'type' => 'input',
399 + 'value' => '',
400 + ],
401 + 'legal_registration_id' => [
402 + 'label' => __('Legal Registration ID', 'fluent-cart'),
403 + 'type' => 'input',
404 + 'value' => '',
405 + ],
406 + 'seller_vat_id' => [
407 + 'label' => __('Seller VAT ID', 'fluent-cart'),
408 + 'type' => 'input',
409 + 'value' => '',
410 + ],
411 + 'seller_tax_id' => [
412 + 'label' => __('Seller Tax ID', 'fluent-cart'),
413 + 'type' => 'input',
414 + 'value' => '',
415 + ],
416 + ]
417 + ],
418 + ]
419 + ],
420 +
421 + 'settings_hr_3' => [
422 + 'type' => 'html',
423 + 'value' => '<hr class="settings-divider">'
424 + ],
425 +
290 426 'currency_grid' => [
291 427 'type' => 'grid',
292 428 'columns' => [
293 429 'default' => 1,
@@ -443,8 +579,79 @@
443 579 ],
444 580 ]
445 581 ],
446 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 +
447 654 // 'settings_hr_modal' => [
448 655 // 'type' => 'html',
449 656 // 'value' => '<hr class="settings-divider">'
450 657 // ],
@@ -532,8 +739,9 @@
532 739 // ]
533 740 // ]
534 741 // ],
535 742 'pages_setup' => [
743 + 'id' => '',
536 744 'title' => __('Pages Setup', 'fluent-cart'),
537 745 'show_title' => false,
538 746 'type' => 'section',
539 747 'disable_nesting' => true,
@@ -928,8 +1136,40 @@
928 1136 ]
929 1137 ],
930 1138 ],
931 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 + ],
932 1172 'cart_and_checkout' => [
933 1173 'title' => __('Cart & checkout', 'fluent-cart'),
934 1174 'show_title' => false,
935 1175 'type' => 'section',
@@ -1088,9 +1328,9 @@
1088 1328 ]
1089 1329 ],
1090 1330 ]
1091 1331 ],
1092 - 'subscriptions_setup' => [
1332 + 'subscriptions_setup' => [
1093 1333 'title' => __('Subscriptions', 'fluent-cart'),
1094 1334 'show_title' => false,
1095 1335 'type' => 'section',
1096 1336 'disable_nesting' => true,
@@ -1098,10 +1338,11 @@
1098 1338 'default' => 1,
1099 1339 'md' => 1
1100 1340 ],
1101 1341 'schema' => [
1102 - 'subscription_settings_grid' => [
1342 + 'subscription_early_payment_grid' => [
1103 1343 'type' => 'grid',
1344 + 'wrapperClass' => 'items-start mb-6',
1104 1345 'columns' => [
1105 1346 'default' => 1,
1106 1347 'md' => 3
1107 1348 ],
@@ -1108,10 +1349,10 @@
1108 1349 'disable_nesting' => true,
1109 1350 'schema' => [
1110 1351 'label' => [
1111 1352 'type' => 'html',
1112 - '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>
1113 - <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>'
1114 1355 ],
1115 1356 'fields' => [
1116 1357 'type' => 'grid',
1117 1358 'columns' => [
@@ -1134,10 +1375,111 @@
1134 1375 ]
1135 1376 ]
1136 1377 ]
1137 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 + ],
1138 1479 ]
1139 1480 ],
1481 + 'appearance' => $this->getAppearanceSchema(),
1140 1482 ]
1141 1483 ],
1142 1484 ];
1143 1485
@@ -1144,8 +1486,14 @@
1144 1486
1145 1487 // Only show weight/dimension unit fields to users with shipping-sensitive permission
1146 1488 if (PermissionManager::hasPermission(['store/sensitive'])) {
1147 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 +
1148 1496 $storeSchema['weight_unit_grid'] = [
1149 1497 'type' => 'grid',
1150 1498 'columns' => ['default' => 1, 'md' => 3],
1151 1499 'disable_nesting' => true,
@@ -1251,8 +1599,10 @@
1251 1599 }
1252 1600
1253 1601 update_option($this->optionKey, $settings, true);
1254 1602 $this->storeSettings = $settings;
1603 + self::$cachedStoreSettings = $this->storeSettings;
1604 + wp_cache_delete(self::CACHE_KEY, self::CACHE_GROUP);
1255 1605
1256 1606 $isSlugChanged = Arr::get($prevSettings, 'product_slug') !== Arr::get($settings, 'product_slug');
1257 1607 $isAccountPageChanged = Arr::get($prevSettings, 'customer_profile_page_slug') !== Arr::get($settings, 'customer_profile_page_slug');
1258 1608
@@ -1284,8 +1634,11 @@
1284 1634
1285 1635 public function isCheckoutPage(): bool
1286 1636 {
1287 1637 global $post;
1638 + if (!$post instanceof \WP_Post) {
1639 + return false;
1640 + }
1288 1641 $pageId = $this->getCheckoutPageId();
1289 1642 return intval($pageId) === intval($post->ID);
1290 1643 }
1291 1644
@@ -1378,9 +1731,9 @@
1378 1731 if (Pages::isPage($pageId)) {
1379 1732 return $this->getPageLink($pageId);
1380 1733 }
1381 1734 }
1382 - return '';
1735 + return home_url();
1383 1736 }
1384 1737
1385 1738
1386 1739 public function getBuyButtonText(): string
@@ -1514,9 +1867,9 @@
1514 1867 if (!$pageId) {
1515 1868 $pageId = Arr::get($this->storeSettings, 'customer_profile_page_id');
1516 1869 }
1517 1870
1518 - if (!$pageId) {
1871 + if (!$pageId || !get_post($pageId)) {
1519 1872 return $slug;
1520 1873 }
1521 1874
1522 1875 $url = get_page_link($pageId);
@@ -1529,8 +1882,283 @@
1529 1882 return trim(str_replace(home_url('/'), '', $url), '/');
1530 1883 }
1531 1884
1532 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' => sprintf(
1910 + '%1$s %2$s',
1911 + __('The storefront palette is rebuilt from your theme, and follows it when the theme changes.', 'fluent-cart'),
1912 + ThemePalette::sourceLabel()
1913 + ),
1914 + ],
1915 + [
1916 + 'label' => __('Customize', 'fluent-cart'),
1917 + 'value' => ColorPalette::SOURCE_CUSTOM,
1918 + 'icon' => 'PaletteLine',
1919 + 'note' => __('Pick the colors yourself. Only the ones you set are written to the storefront.', 'fluent-cart'),
1920 + ],
1921 + ];
1922 +
1923 + $sourceHeading = [
1924 + 'label' => __('Where colors come from', 'fluent-cart'),
1925 + 'note' => __('Storefront colors cascade from a small set of globals, so changing one here updates every page that uses it.', 'fluent-cart'),
1926 + ];
1927 +
1928 + return [
1929 + 'title' => __('Appearance', 'fluent-cart'),
1930 + 'show_title' => false,
1931 + 'type' => 'section',
1932 + 'wrapperClass' => 'fct-appearance-component-section',
1933 + 'disable_nesting' => true,
1934 + 'columns' => [
1935 + 'default' => 1,
1936 + 'md' => 1,
1937 + ],
1938 + 'schema' => [
1939 + 'appearance_component' => [
1940 + 'type' => 'component',
1941 + 'component' => 'StoreSettings/AppearanceComponent',
1942 + 'wrapperClass' => 'col-span-full',
1943 + 'label' => false,
1944 + // Rendered by the component itself, so the heading sits
1945 + // inside .fct-appearance-component with the controls it
1946 + // describes rather than as a sibling html field.
1947 + 'heading' => $sourceHeading,
1948 + 'source_options' => $sourceOptions,
1949 + 'custom_source' => ColorPalette::SOURCE_CUSTOM,
1950 + 'color_groups' => $this->getAppearanceColorGroups(),
1951 + 'preview' => $this->getAppearancePreviewData(),
1952 + ],
1953 + 'appearance_source' => [
1954 + 'type' => 'hidden',
1955 + 'value' => ColorPalette::SOURCE_DEFAULT,
1956 + ],
1957 + 'appearance_colors' => [
1958 + 'type' => 'hidden',
1959 + 'value' => (object)[],
1960 + ],
1961 + ],
1962 + ];
1963 + }
1964 +
1965 + /**
1966 + * What the storefront preview needs to paint itself.
1967 + *
1968 + * The preview is drawn from semantic roles rather than settings keys, so
1969 + * one mock card covers all three sources: `customize` reads the pickers the
1970 + * owner has filled in, and the other two are resolved here because their
1971 + * colors only exist server side — `inherit_from_theme` is mixed out of
1972 + * theme.json by ThemePalette and nothing about it reaches the browser.
1973 + *
1974 + * @return array
1975 + */
1976 + protected function getAppearancePreviewData(): array
1977 + {
1978 + // No colour is sent with a slot. appearance.scss already writes every
1979 + // preview surface as `var(--fct-pv-x, <fallback>)`, so the mock has the
1980 + // same single source of truth the storefront does: a slot the component
1981 + // cannot resolve simply goes undeclared and the stylesheet's fallback
1982 + // applies.
1983 + return [
1984 + 'slots' => $this->getAppearancePreviewSlots(),
1985 + 'theme_source' => ColorPalette::SOURCE_THEME,
1986 + 'theme_roles' => $this->getAppearanceThemeRoles(),
1987 + 'notes' => [
1988 + ColorPalette::SOURCE_THEME => $this->getAppearanceThemeNote(),
1989 + ColorPalette::SOURCE_CUSTOM => __('Set only the colors you want to change — anything you leave unset keeps FluentCart\'s own default.', 'fluent-cart'),
1990 + ],
1991 + ];
1992 + }
1993 +
1994 + /**
1995 + * Which custom property the preview paints each surface with.
1996 + *
1997 + * Keyed by settings key, not by role. Four separate globals claim the
1998 + * `accent` role — active text, the brand background and both active
1999 + * borders — so collapsing the preview to roles would let one picker drive
2000 + * surfaces the storefront keeps apart, and leave the others with no visible
2001 + * effect at all. `role` is carried only for theme inheritance, which
2002 + * genuinely is role-based: FrontendTheme::getThemeColors() gives every key
2003 + * sharing a role the same colour, so the preview collapsing there is the
2004 + * storefront's own behaviour rather than a shortcut.
2005 + *
2006 + * A slot with no `key` is a surface no global controls; it follows the
2007 + * role's default and cannot be edited.
2008 + *
2009 + * @return array
2010 + */
2011 + protected function getAppearancePreviewSlots(): array
2012 + {
2013 + return [
2014 + ['var' => 'surface', 'role' => 'surface', 'key' => 'card_bg_color'],
2015 + ['var' => 'surface-mute', 'role' => 'surface_mute'],
2016 + ['var' => 'surface-alt', 'role' => 'surface_alt', 'key' => 'secondary_bg_color'],
2017 + ['var' => 'text', 'role' => 'text', 'key' => 'primary_text_color'],
2018 + ['var' => 'text-muted', 'role' => 'text_muted', 'key' => 'secondary_text_color'],
2019 + ['var' => 'text-placeholder', 'role' => 'text_placeholder', 'key' => 'input_placeholder_text_color'],
2020 + ['var' => 'input-bg', 'role' => 'surface', 'key' => 'input_bg_color'],
2021 + ['var' => 'input-text', 'role' => 'text', 'key' => 'input_text_color'],
2022 + ['var' => 'input-disabled-bg', 'role' => 'surface_mute', 'key' => 'input_disabled_bg_color'],
2023 + ['var' => 'accent-text', 'role' => 'accent', 'key' => 'primary_active_text_color'],
2024 + ['var' => 'active-border', 'role' => 'accent', 'key' => 'active_border_color'],
2025 + ['var' => 'border', 'role' => 'border', 'key' => 'border_color'],
2026 + ['var' => 'divider', 'role' => 'divider', 'key' => 'divider_color'],
2027 + ['var' => 'button-bg', 'role' => 'button_bg', 'key' => 'btn_bg_color'],
2028 + ['var' => 'button-text', 'role' => 'button_text', 'key' => 'btn_text_color'],
2029 + ['var' => 'secondary-button-bg', 'role' => 'surface', 'key' => 'secondary_btn_bg_color'],
2030 + ['var' => 'secondary-button-text', 'role' => 'text', 'key' => 'secondary_btn_text_color'],
2031 + ['var' => 'secondary-button-border', 'role' => 'border', 'key' => 'secondary_btn_border_color'],
2032 + ['var' => 'secondary-button-hover-bg', 'role' => 'surface_mute', 'key' => 'secondary_btn_hover_bg_color'],
2033 + ];
2034 + }
2035 +
2036 + /**
2037 + * Why the theme preview may not be the whole truth.
2038 + *
2039 + * Themes such as Astra publish their palette as `var(--ast-global-color-0)`
2040 + * rather than as colors. FrontendTheme writes those references straight
2041 + * through and the browser resolves them on the storefront, but the property
2042 + * is not declared in wp-admin, so the preview cannot show them. Saying so
2043 + * beats showing FluentCart's colors and letting the owner believe that is
2044 + * what inheriting will look like.
2045 + *
2046 + * @return string Empty when the preview is faithful.
2047 + */
2048 + protected function getAppearanceThemeNote(): string
2049 + {
2050 + if (!ThemePalette::hasUsableSource()) {
2051 + return __('The active theme publishes nothing to inherit, so the storefront keeps FluentCart\'s own colors.', 'fluent-cart');
2052 + }
2053 +
2054 + foreach (ThemePalette::resolve() as $value) {
2055 + if (!sanitize_hex_color((string)$value)) {
2056 + 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');
2057 + }
2058 + }
2059 +
2060 + return '';
2061 + }
2062 +
2063 + /**
2064 + * The theme's colors for the preview, role by role.
2065 + *
2066 + * Only roles that resolved to a real color are returned — the preview
2067 + * falls back to each slot's own default for the rest.
2068 + *
2069 + * @return array Role => hex.
2070 + */
2071 + protected function getAppearanceThemeRoles(): array
2072 + {
2073 + // With nothing to inherit, FrontendTheme writes no declarations at all,
2074 + // so the storefront keeps FluentCart's colors — and so must the
2075 + // preview, or it would promise a change that never happens.
2076 + if (!ThemePalette::hasUsableSource()) {
2077 + return [];
2078 + }
2079 +
2080 + $roles = [];
2081 +
2082 + foreach (ThemePalette::resolve() as $role => $value) {
2083 + // A theme that publishes `var(--x)` is written to the storefront
2084 + // verbatim and resolved by the browser there, but that property is
2085 + // not declared in wp-admin. Unpreviewable, so leave it out and let
2086 + // the slot fall back to its own default.
2087 + $hex = sanitize_hex_color((string)$value);
2088 +
2089 + if ($hex) {
2090 + $roles[$role] = $hex;
2091 + }
2092 + }
2093 +
2094 + return $roles;
2095 + }
2096 +
2097 + /**
2098 + * The colour knobs, grouped the way the registry groups them, as data the
2099 + * appearance component renders its pickers from.
2100 + *
2101 + * @return array
2102 + */
2103 + protected function getAppearanceColorGroups(): array
2104 + {
2105 + $groups = [];
2106 +
2107 + foreach (ColorPalette::groups() as $groupKey => $groupLabel) {
2108 + $globals = ColorPalette::globalsFor($groupKey);
2109 +
2110 + if (!$globals) {
2111 + continue;
2112 + }
2113 +
2114 + $fields = [];
2115 +
2116 + foreach ($globals as $key => $definition) {
2117 + $fields[] = [
2118 + 'key' => $key,
2119 + 'label' => Arr::get($definition, 'label', $key),
2120 + 'note' => $this->getAppearanceFieldNote($definition),
2121 + 'default' => (string)Arr::get($definition, 'default', ''),
2122 + ];
2123 + }
2124 +
2125 + $groups[] = [
2126 + 'key' => $groupKey,
2127 + 'label' => $groupLabel,
2128 + 'fields' => $fields,
2129 + ];
2130 + }
2131 +
2132 + return $groups;
2133 + }
2134 +
2135 + /**
2136 + * The hint under one colour picker: what it drives, and the value it
2137 + * falls back to when left empty.
2138 + *
2139 + * @param array $definition
2140 + * @return string
2141 + */
2142 + protected function getAppearanceFieldNote(array $definition): string
2143 + {
2144 + $usage = (string)Arr::get($definition, 'note', '');
2145 + $default = (string)Arr::get($definition, 'default', '');
2146 +
2147 + if ($default === '') {
2148 + return $usage;
2149 + }
2150 +
2151 + if ($usage === '') {
2152 + /* translators: %1$s: the hex colour FluentCart falls back to */
2153 + return sprintf(__('Default: %1$s', 'fluent-cart'), $default);
2154 + }
2155 +
2156 + /* translators: 1: what the colour is used for, 2: the hex colour FluentCart falls back to */
2157 + return sprintf(__('%1$s Default: %2$s', 'fluent-cart'), $usage, $default);
2158 + }
2159 +
2160 + /**
1533 2161 * Get theme colors as CSS variable string.
1534 2162 *
1535 2163 * @return string
1536 2164 */
@@ -1553,8 +2181,12 @@
1553 2181 }
1554 2182
1555 2183 public function getPageLink($pageId = null): string
1556 2184 {
2185 + if (!$pageId || !get_post($pageId)) {
2186 + return '';
2187 + }
2188 +
1557 2189 $link = get_page_link($pageId);
1558 2190
1559 2191 if (!empty($link)) {
1560 2192 return Str::endsWith($link, '/') ? $link : $link . '/';
@@ -1560,5 +2192,6 @@
1560 2192 return Str::endsWith($link, '/') ? $link : $link . '/';
1561 2193 }
1562 2194 return '';
1563 2195 }
2196 +
1564 2197 }