PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 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 All 51 releases
← All changes | api/StoreSettings.php +923 -10 1.3.22 → 1.7.1 View file →
@@ -7,8 +7,15 @@
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\FrontendTheme;
13 +use FluentCart\App\Services\Theme\RadiusPalette;
14 +use FluentCart\App\Services\Theme\ThemePalette;
15 +use FluentCart\App\Modules\PaymentMethods\Core\GatewayManager;
16 +use FluentCart\App\Modules\StoreManagedRenewal\Services\RenewalService;
17 +use FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode;
11 18 use FluentCart\Framework\Support\Arr;
12 19 use FluentCart\Framework\Support\ArrayableInterface;
13 20 use FluentCart\Framework\Support\Str;
14 21 use FluentCart\App\Services\Permission\PermissionManager;
@@ -14,8 +21,11 @@
14 21 use FluentCart\App\Services\Permission\PermissionManager;
15 22
16 23 class StoreSettings implements ArrayableInterface
17 24 {
25 + const CACHE_KEY = 'store_settings';
26 + const CACHE_GROUP = 'fluentcart';
27 +
18 28 /**
19 29 * @var string
20 30 *
21 31 * Store settings option name
@@ -30,14 +40,25 @@
30 40 protected array $storeSettings;
31 41
32 42 protected static $cachedStoreSettings = null;
33 43
44 + public static function clearCache(): void
45 + {
46 + self::$cachedStoreSettings = null;
47 + wp_cache_delete(self::CACHE_KEY, self::CACHE_GROUP);
48 + }
49 +
34 50 public function __construct()
35 51 {
52 + if (self::$cachedStoreSettings !== null) {
53 + $this->storeSettings = self::$cachedStoreSettings;
54 + return;
55 + }
36 56 $defaultSettings = $this->getDefaultSettings();
37 57 $storeSettings = get_option($this->optionKey, []);
38 58 $settings = wp_parse_args($storeSettings, $defaultSettings);
39 59 $this->storeSettings = $settings;
60 + self::$cachedStoreSettings = $this->storeSettings;
40 61 }
41 62
42 63 protected function getDefaultSettings(): array
43 64 {
@@ -42,8 +63,12 @@
42 63 protected function getDefaultSettings(): array
43 64 {
44 65 $defaultSettings = [
45 66 'store_name' => get_bloginfo('name'),
67 + 'company_name' => '',
68 + 'legal_registration_id' => '',
69 + 'seller_vat_id' => '',
70 + 'seller_tax_id' => '',
46 71 'note_for_user_account_creation' => __('An user account will be created', 'fluent-cart'),
47 72 'checkout_button_text' => __('Checkout', 'fluent-cart'),
48 73 'view_cart_button_text' => __('View Cart', 'fluent-cart'),
49 74 'cart_button_text' => __('Add To Cart', 'fluent-cart'),
@@ -59,8 +84,10 @@
59 84 'show_cart_icon_in_body' => 'yes',
60 85 'additional_address_field' => 'yes',
61 86 'hide_coupon_field' => 'no',
62 87 'user_account_creation_mode' => 'all',
88 + 'auto_login_after_account_creation' => 'yes',
89 + 'require_customer_email_verification' => 'no',
63 90 'checkout_page_id' => '',
64 91 'custom_payment_page_id' => '',
65 92 'registration_page_id' => '',
66 93 'login_page_id' => '',
@@ -65,8 +92,9 @@
65 92 'registration_page_id' => '',
66 93 'login_page_id' => '',
67 94 'cart_page_id' => '',
68 95 'receipt_page_id' => '',
96 + 'order_review_page_id' => '',
69 97 'shop_page_id' => '',
70 98 'customer_profile_page_id' => '',
71 99 'customer_profile_page_slug' => '',
72 100 'currency' => 'USD',
@@ -77,17 +105,34 @@
77 105 'store_postcode' => '',
78 106 'store_state' => '',
79 107 'show_relevant_product_in_single_page' => 'yes',
80 108 'show_relevant_product_in_modal' => '',
109 + 'show_reviews_in_single_page' => 'yes',
110 + 'show_rating_in_shop' => 'yes',
111 + 'show_rating_in_relevant' => 'yes',
81 112 'order_mode' => 'test',
113 + 'subscription_mode_guard' => 'yes',
82 114 'variation_view' => 'both',
83 115 'variation_columns' => 'masonry',
84 116 'enable_early_payment_for_installment' => 'yes',
117 + 'subscription_management_mode' => 'gateway_managed',
118 + 'subscription_system_charge' => 'no',
85 119 'modules_settings' => [],
86 120 'min_receipt_number' => '1',
87 121 'inv_prefix' => 'INV-',
88 122 'weight_unit' => 'kg',
89 - 'dimension_unit' => 'cm'
123 + 'dimension_unit' => 'cm',
124 + 'appearance_source' => ColorPalette::SOURCE_DEFAULT,
125 + 'appearance_colors' => [],
126 + 'appearance_radius' => [],
127 + // 'wordpress', not 'fluent_cart': the FluentCart patterns are
128 + // literals ('M j, Y'), and a literal renders a half-translated date
129 + // on a localized store -- a German month in English field order,
130 + // which a German reader misreads as day-first. Following
131 + // Settings > General is the only source that is correct in every
132 + // locale, so it is what a store gets until it chooses otherwise.
133 + 'date_time_format_source' => 'wordpress',
134 + 'timezone_source' => 'fluent_cart'
90 135 ];
91 136
92 137 return apply_filters('fluent_cart/store_settings/values', $defaultSettings, []);
93 138 }
@@ -106,14 +151,60 @@
106 151 'shop_page_id' => $this->getShopPage(),
107 152 'customer_profile_page_id' => $this->getCustomerProfilePage(),
108 153 'cart_page_id' => $this->getCartPage(),
109 154 'checkout_page_id' => $this->getCheckoutPage(),
110 - 'receipt_page_id' => $this->getReceiptPage()
155 + 'receipt_page_id' => $this->getReceiptPage(),
156 + 'order_review_page_id' => $this->getOrderReviewPage()
111 157 ];
112 158 $isProActive = App::isProActive();
113 159 $proFeatureIcon = Vite::getAssetUrl('images/crown.svg');
114 160
161 + // Read-only schedule for store-managed subscription renewals. Pulled live
162 + // from the same map the scheduler uses, so it stays accurate under the
163 + // fluent_cart/renewal/advance_creation_days filter. No editable knob — the timing is
164 + // deliberately built-in; developers tune it via that filter.
165 + $invoiceScheduleMap = RenewalService::getAdvanceCreationDaysMap();
166 + $invoiceScheduleLabels = [
167 + 'daily' => __('Daily', 'fluent-cart'),
168 + 'weekly' => __('Weekly', 'fluent-cart'),
169 + 'monthly' => __('Monthly', 'fluent-cart'),
170 + 'quarterly' => __('Quarterly', 'fluent-cart'),
171 + 'half_yearly' => __('Half-yearly', 'fluent-cart'),
172 + 'yearly' => __('Yearly', 'fluent-cart'),
173 + ];
174 + // Structured renewal-order schedule for the SubscriptionModeManager
175 + // component (status card + guarded edit dialog).
176 + $invoiceScheduleList = [];
177 + foreach ($invoiceScheduleLabels as $invoiceScheduleKey => $invoiceScheduleLabel) {
178 + if (!isset($invoiceScheduleMap[$invoiceScheduleKey])) {
179 + continue;
180 + }
181 + $invoiceScheduleDays = (int) $invoiceScheduleMap[$invoiceScheduleKey];
182 + $invoiceScheduleList[] = [
183 + 'label' => $invoiceScheduleLabel,
184 + 'when' => $invoiceScheduleDays <= 0
185 + ? __('on the due date', 'fluent-cart')
186 + /* translators: %d: number of days before the due date */
187 + : sprintf(_n('%d day before due date', '%d days before due date', $invoiceScheduleDays, 'fluent-cart'), $invoiceScheduleDays),
188 + ];
189 + }
115 190
191 + // Gateways declaring `system_subscription` — the only ones
192 + // subscription_system_charge can ever auto-charge.
193 + $systemChargeGateways = [];
194 + foreach (GatewayManager::getInstance()->all() as $systemChargeGateway) {
195 + if (!$systemChargeGateway->has('system_subscription') || $systemChargeGateway->isUpcoming()) {
196 + continue;
197 + }
198 + $systemChargeGatewayMeta = $systemChargeGateway->getMeta();
199 + $systemChargeGateways[] = [
200 + 'label' => Arr::get($systemChargeGatewayMeta, 'admin_title')
201 + ?: Arr::get($systemChargeGatewayMeta, 'label')
202 + ?: Arr::get($systemChargeGatewayMeta, 'title'),
203 + 'active' => $systemChargeGateway->isEnabled(),
204 + ];
205 + }
206 +
116 207 $fields = [
117 208 'setting_tabs' => [
118 209 'type' => 'section',
119 210 'disable_nesting' => true,
@@ -120,8 +211,9 @@
120 211 'default_tab' => 'store_setup',
121 212 'hide_tab_switch' => true,
122 213 'schema' => [
123 214 'store_setup' => [
215 + 'id' => '',
124 216 'title' => __('Store Setup', 'fluent-cart'),
125 217 'show_title' => false,
126 218 'type' => 'section',
127 219 'disable_nesting' => true,
@@ -228,9 +320,9 @@
228 320 ],
229 321 ]
230 322 ],
231 323
232 - 'settings_hr' => [
324 + 'date_time_hr' => [
233 325 'type' => 'html',
234 326 'value' => '<hr class="settings-divider">'
235 327 ],
236 328
@@ -286,8 +378,61 @@
286 378 'type' => 'html',
287 379 'value' => '<hr class="settings-divider">'
288 380 ],
289 381
382 + 'business_details_grid' => [
383 + 'type' => 'grid',
384 + 'id' => 'business_details',
385 + 'columns' => [
386 + 'default' => 1,
387 + 'md' => 3
388 + ],
389 + 'disable_nesting' => true,
390 + 'schema' => [
391 + 'label' => [
392 + 'type' => 'html',
393 + 'value' => '<span class="setting-label">' . __('Business Details', 'fluent-cart') . '</span>
394 + <div class="form-note">' . __('Add your legal business identity details used for store records and compliance.', 'fluent-cart') . '</div>'
395 + ],
396 + 'business_details_fields' => [
397 + 'type' => 'grid',
398 + 'columns' => [
399 + 'default' => 1,
400 + 'md' => 2
401 + ],
402 + 'disable_nesting' => true,
403 + 'wrapperClass' => 'col-span-2',
404 + 'schema' => [
405 + 'company_name' => [
406 + 'label' => __('Company Name', 'fluent-cart'),
407 + 'type' => 'input',
408 + 'value' => '',
409 + ],
410 + 'legal_registration_id' => [
411 + 'label' => __('Legal Registration ID', 'fluent-cart'),
412 + 'type' => 'input',
413 + 'value' => '',
414 + ],
415 + 'seller_vat_id' => [
416 + 'label' => __('Seller VAT ID', 'fluent-cart'),
417 + 'type' => 'input',
418 + 'value' => '',
419 + ],
420 + 'seller_tax_id' => [
421 + 'label' => __('Seller Tax ID', 'fluent-cart'),
422 + 'type' => 'input',
423 + 'value' => '',
424 + ],
425 + ]
426 + ],
427 + ]
428 + ],
429 +
430 + 'settings_hr_3' => [
431 + 'type' => 'html',
432 + 'value' => '<hr class="settings-divider">'
433 + ],
434 +
290 435 'currency_grid' => [
291 436 'type' => 'grid',
292 437 'columns' => [
293 438 'default' => 1,
@@ -443,8 +588,79 @@
443 588 ],
444 589 ]
445 590 ],
446 591
592 + 'settings_hr' => [
593 + 'type' => 'html',
594 + 'value' => '<hr class="settings-divider">'
595 + ],
596 +
597 + 'date_time_format_grid' => [
598 + 'type' => 'grid',
599 + 'columns' => [
600 + 'default' => 1,
601 + 'md' => 3
602 + ],
603 + 'disable_nesting' => true,
604 + 'schema' => [
605 + 'label' => [
606 + 'type' => 'html',
607 + 'value' => '<span class="setting-label">' . __('Date & Time Format', 'fluent-cart') . '</span>
608 + <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>'
609 + ],
610 + 'date_time_format_source' => [
611 + 'wrapperClass' => 'col-span-2 flex items-center',
612 + 'label' => '',
613 + 'type' => 'radio',
614 + 'options' => [
615 + [
616 + // Label only -- the stored value stays 'fluent_cart'.
617 + 'label' => __('Smart', 'fluent-cart'),
618 + 'value' => 'fluent_cart',
619 + ],
620 + [
621 + 'label' => __('WordPress', 'fluent-cart'),
622 + 'value' => 'wordpress',
623 + ],
624 + ],
625 + 'value' => 'fluent_cart'
626 + ],
627 + ]
628 + ],
629 +
630 + 'timezone_source_grid' => [
631 + 'type' => 'grid',
632 + 'columns' => [
633 + 'default' => 1,
634 + 'md' => 3
635 + ],
636 + 'disable_nesting' => true,
637 + 'schema' => [
638 + 'label' => [
639 + 'type' => 'html',
640 + 'value' => '<span class="setting-label">' . __('Timezone', 'fluent-cart') . '</span>
641 + <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>'
642 + ],
643 + 'timezone_source' => [
644 + 'wrapperClass' => 'col-span-2 flex items-center',
645 + 'label' => '',
646 + 'type' => 'radio',
647 + 'options' => [
648 + [
649 + // Label only -- the stored value stays 'fluent_cart'.
650 + 'label' => __('Browser', 'fluent-cart'),
651 + 'value' => 'fluent_cart',
652 + ],
653 + [
654 + 'label' => __('WordPress', 'fluent-cart'),
655 + 'value' => 'wordpress',
656 + ],
657 + ],
658 + 'value' => 'fluent_cart'
659 + ],
660 + ]
661 + ],
662 +
447 663 // 'settings_hr_modal' => [
448 664 // 'type' => 'html',
449 665 // 'value' => '<hr class="settings-divider">'
450 666 // ],
@@ -532,8 +748,9 @@
532 748 // ]
533 749 // ]
534 750 // ],
535 751 'pages_setup' => [
752 + 'id' => '',
536 753 'title' => __('Pages Setup', 'fluent-cart'),
537 754 'show_title' => false,
538 755 'type' => 'section',
539 756 'disable_nesting' => true,
@@ -684,8 +901,45 @@
684 901 ],
685 902 ]
686 903 ],
687 904
905 + 'hr_order_review' => [
906 + 'type' => 'html',
907 + 'value' => '<hr class="settings-divider">'
908 + ],
909 +
910 + 'order_review_page_grid' => [
911 + 'type' => 'grid',
912 + 'columns' => [
913 + 'default' => 1,
914 + 'md' => 3
915 + ],
916 + 'disable_nesting' => true,
917 + 'schema' => [
918 + 'label' => [
919 + 'type' => 'html',
920 + 'value' => '<span class="setting-label">' . __('Select Order Review Page', 'fluent-cart') . '</span>
921 + <div class="form-note">' . __("Select the page where customers will review the products they bought, from the link in their order email.", 'fluent-cart') . '</div>'
922 + ],
923 + 'order_review_page_id' => [
924 + 'wrapperClass' => 'col-span-2',
925 + 'label' => false,
926 + 'page_title' => __('Review Your Order', 'fluent-cart'),
927 + 'type' => 'component',
928 + 'component' => 'StoreSettings/PageSelector',
929 + 'page_key' => 'order_review_page_id',
930 + 'preview_link' => $previewLinks['order_review_page_id'],
931 + 'hide_note' => true,
932 + 'options' => $pages,
933 + 'value' => '',
934 + 'note' => \FluentCart\App\Helpers\Helper::getShortcodeInstructionString(
935 + '[fluent_cart_order_review]',
936 + __('Review Your Order', 'fluent-cart')
937 + ),
938 + ],
939 + ]
940 + ],
941 +
688 942 'hr4' => [
689 943 'type' => 'html',
690 944 'value' => '<hr class="settings-divider">'
691 945 ],
@@ -743,9 +997,9 @@
743 997 'schema' => [
744 998 'label' => [
745 999 'type' => 'html',
746 1000 'value' => '<span class="setting-label">' . __('Single Product Setup', 'fluent-cart') . '</span>
747 - <div class="form-note">' . __("Control the display of relevant information.", 'fluent-cart') . '</div>'
1001 + <div class="form-note">' . __("Choose which sections to display on the single product page.", 'fluent-cart') . '</div>'
748 1002 ],
749 1003 'fields' => [
750 1004 'type' => 'grid',
751 1005 'columns' => [
@@ -763,8 +1017,17 @@
763 1017 "label" => __('Show Relevant In Product Modal', 'fluent-cart'),
764 1018 "type" => "checkbox",
765 1019 "value" => "no"
766 1020 ],
1021 + "show_reviews_in_single_page" => [
1022 + "label" => __('Show Reviews In Single Page', 'fluent-cart'),
1023 + "type" => "checkbox",
1024 + "value" => "yes"
1025 + ],
1026 + "show_reviews_note" => [
1027 + "type" => "html",
1028 + "value" => '<div class="form-note">' . __('For block themes, add the Product Reviews block to your Single Product template to display reviews on your product page.', 'fluent-cart') . '</div>'
1029 + ],
767 1030 ]
768 1031 ]
769 1032
770 1033 ]
@@ -926,10 +1189,159 @@
926 1189 ],
927 1190
928 1191 ]
929 1192 ],
1193 +
1194 + 'hr_product_slug' => [
1195 + 'type' => 'html',
1196 + 'value' => '<hr class="settings-divider">'
1197 + ],
1198 +
1199 + 'product_rating_grid' => [
1200 + 'type' => 'grid',
1201 + 'columns' => [
1202 + 'default' => 1,
1203 + 'md' => 3
1204 + ],
1205 + 'disable_nesting' => true,
1206 + 'schema' => [
1207 + 'label' => [
1208 + 'type' => 'html',
1209 + 'value' => sprintf(
1210 + '<span class="setting-label">%1$s</span><div class="form-note">%2$s</div>',
1211 + __('Product Rating', 'fluent-cart'),
1212 + __('Show star ratings when reviews are enabled.', 'fluent-cart')
1213 + )
1214 + ],
1215 + 'fields' => [
1216 + 'type' => 'grid',
1217 + 'columns' => [
1218 + 'default' => 1,
1219 + 'md' => 1
1220 + ],
1221 + 'disable_nesting' => true,
1222 + 'schema' => [
1223 + "show_rating_in_shop" => [
1224 + "label" => __('Show Rating in Shop', 'fluent-cart'),
1225 + "type" => "checkbox",
1226 + "value" => "yes"
1227 + ],
1228 + "show_rating_in_relevant" => [
1229 + "label" => __('Show Rating in Relevant Products', 'fluent-cart'),
1230 + "type" => "checkbox",
1231 + "value" => "yes"
1232 + ],
1233 + ]
1234 + ]
1235 +
1236 + ]
1237 + ],
930 1238 ],
931 1239 ],
1240 + 'compliance' => [
1241 + 'title' => __('Compliance', 'fluent-cart'),
1242 + 'show_title' => false,
1243 + 'type' => 'section',
1244 + 'disable_nesting' => true,
1245 + 'columns' => [
1246 + 'default' => 1,
1247 + 'md' => 1,
1248 + ],
1249 + 'schema' => [
1250 + 'email_verification_grid' => [
1251 + 'type' => 'grid',
1252 + 'disable_nesting' => true,
1253 + 'columns' => ['default' => 1, 'md' => 3],
1254 + 'wrapperClass' => 'items-start',
1255 + 'schema' => [
1256 + 'label' => [
1257 + 'type' => 'html',
1258 + 'value' => sprintf(
1259 + '<span class="setting-label">%1$s</span><div class="form-note">%2$s</div>',
1260 + esc_html__('Customer email verification', 'fluent-cart'),
1261 + esc_html__('Confirm email ownership before customers access their portal and saved checkout addresses.', 'fluent-cart')
1262 + ),
1263 + ],
1264 + 'fields' => [
1265 + 'type' => 'grid',
1266 + 'disable_nesting' => true,
1267 + 'columns' => ['default' => 1],
1268 + 'wrapperClass' => 'md:col-span-2',
1269 + 'schema' => [
1270 + 'require_customer_email_verification' => [
1271 + 'wrapperClass' => 'fct-compliance-auto-login',
1272 + 'label' => false,
1273 + 'type' => 'radio',
1274 + 'value' => 'no',
1275 + 'attributes' => [
1276 + 'aria-label' => __('Customer email verification', 'fluent-cart'),
1277 + ],
1278 + 'options' => [
1279 + [
1280 + 'label' => __('Required', 'fluent-cart'),
1281 + 'value' => 'yes',
1282 + ],
1283 + [
1284 + 'label' => __('Not required', 'fluent-cart'),
1285 + 'value' => 'no',
1286 + ],
1287 + ],
1288 + 'note' => __('When not required, new checkout accounts are linked to their purchases immediately.', 'fluent-cart'),
1289 + ],
1290 + ],
1291 + ],
1292 + ],
1293 + ],
1294 + 'compliance_divider' => [
1295 + 'type' => 'html',
1296 + 'value' => '<hr class="settings-divider">',
1297 + ],
1298 + 'account_login_grid' => [
1299 + 'type' => 'grid',
1300 + 'disable_nesting' => true,
1301 + 'columns' => ['default' => 1, 'md' => 3],
1302 + 'wrapperClass' => 'items-start',
1303 + 'schema' => [
1304 + 'label' => [
1305 + 'type' => 'html',
1306 + 'value' => sprintf(
1307 + '<span class="setting-label">%1$s</span><div class="form-note">%2$s</div>',
1308 + esc_html__('Login after account creation', 'fluent-cart'),
1309 + esc_html__('Choose how customers sign in after registering or completing checkout.', 'fluent-cart')
1310 + ),
1311 + ],
1312 + 'fields' => [
1313 + 'type' => 'grid',
1314 + 'disable_nesting' => true,
1315 + 'columns' => ['default' => 1],
1316 + 'wrapperClass' => 'md:col-span-2',
1317 + 'schema' => [
1318 + 'auto_login_after_account_creation' => [
1319 + 'wrapperClass' => 'fct-compliance-auto-login',
1320 + 'label' => false,
1321 + 'type' => 'radio',
1322 + 'value' => 'yes',
1323 + 'attributes' => [
1324 + 'aria-label' => __('Login after account creation', 'fluent-cart'),
1325 + ],
1326 + 'options' => [
1327 + [
1328 + 'label' => __('Log in automatically', 'fluent-cart'),
1329 + 'value' => 'yes',
1330 + ],
1331 + [
1332 + 'label' => __('Let customers log in', 'fluent-cart'),
1333 + 'value' => 'no',
1334 + ],
1335 + ],
1336 + 'note' => __('Email verification still applies when required above.', 'fluent-cart'),
1337 + ],
1338 + ],
1339 + ],
1340 + ],
1341 + ],
1342 + ],
1343 + ],
932 1344 'cart_and_checkout' => [
933 1345 'title' => __('Cart & checkout', 'fluent-cart'),
934 1346 'show_title' => false,
935 1347 'type' => 'section',
@@ -1088,9 +1500,9 @@
1088 1500 ]
1089 1501 ],
1090 1502 ]
1091 1503 ],
1092 - 'subscriptions_setup' => [
1504 + 'subscriptions_setup' => [
1093 1505 'title' => __('Subscriptions', 'fluent-cart'),
1094 1506 'show_title' => false,
1095 1507 'type' => 'section',
1096 1508 'disable_nesting' => true,
@@ -1098,10 +1510,11 @@
1098 1510 'default' => 1,
1099 1511 'md' => 1
1100 1512 ],
1101 1513 'schema' => [
1102 - 'subscription_settings_grid' => [
1514 + 'subscription_early_payment_grid' => [
1103 1515 'type' => 'grid',
1516 + 'wrapperClass' => 'items-start mb-6',
1104 1517 'columns' => [
1105 1518 'default' => 1,
1106 1519 'md' => 3
1107 1520 ],
@@ -1108,10 +1521,10 @@
1108 1521 'disable_nesting' => true,
1109 1522 'schema' => [
1110 1523 'label' => [
1111 1524 '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>'
1525 + '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>
1526 + <div class="form-note">' . __('Let customers pay remaining installments before their due date.', 'fluent-cart') . '</div>'
1114 1527 ],
1115 1528 'fields' => [
1116 1529 'type' => 'grid',
1117 1530 'columns' => [
@@ -1134,10 +1547,111 @@
1134 1547 ]
1135 1548 ]
1136 1549 ]
1137 1550 ],
1551 + 'subscription_management_mode_grid' => [
1552 + 'type' => 'grid',
1553 + 'wrapperClass' => 'items-start',
1554 + 'columns' => [
1555 + 'default' => 1,
1556 + 'md' => 3
1557 + ],
1558 + 'disable_nesting' => true,
1559 + 'schema' => [
1560 + 'label' => [
1561 + 'type' => 'html',
1562 + 'value' => sprintf(
1563 + /* translators: 1: setting label, 2: setting description */
1564 + '<span class="setting-label">%1$s</span>
1565 + <div class="form-note">%2$s</div>',
1566 + __('Renewal Billing', 'fluent-cart'),
1567 + __('Choose how recurring subscription payments are collected.', 'fluent-cart')
1568 + )
1569 + ],
1570 + 'fields' => [
1571 + 'type' => 'grid',
1572 + 'columns' => [
1573 + 'default' => 1,
1574 + 'md' => 1
1575 + ],
1576 + 'disable_nesting' => true,
1577 + 'class' => 'col-span-2',
1578 + 'schema' => [
1579 + // Compact, guarded control: shows only the CURRENT mode with a
1580 + // short how-it-works summary and a Change button. Editing goes
1581 + // through a disclaimer confirm + dialog so the store-wide
1582 + // billing decision can never be flipped by a stray click.
1583 + 'subscription_management_mode' => [
1584 + 'label' => false,
1585 + 'component' => 'StoreSettings/SubscriptionModeManager',
1586 + 'disable_nesting' => true,
1587 + 'value' => 'gateway_managed',
1588 + // Read-only renewal-order schedule, built live from the
1589 + // same map the scheduler uses (filterable).
1590 + 'schedule_items' => $invoiceScheduleList,
1591 + // Which gateways auto-charge is actually possible on.
1592 + 'system_charge_gateways' => $systemChargeGateways,
1593 + ],
1594 + // Keep these in the form payload — the component above writes
1595 + // them; without a schema entry the keys would drop out of the
1596 + // saved values.
1597 + 'subscription_system_charge' => [
1598 + 'type' => 'hidden',
1599 + 'value' => 'no',
1600 + ],
1601 + 'subscription_manual_fallback' => [
1602 + 'type' => 'hidden',
1603 + 'value' => 'no',
1604 + ],
1605 + ]
1606 + ]
1607 + ]
1608 + ],
1609 + 'subscription_mode_guard_grid' => [
1610 + 'type' => 'grid',
1611 + 'wrapperClass' => 'items-start mt-6',
1612 + 'columns' => [
1613 + 'default' => 1,
1614 + 'md' => 3
1615 + ],
1616 + 'disable_nesting' => true,
1617 + 'schema' => [
1618 + 'label' => [
1619 + 'type' => 'html',
1620 + 'value' => sprintf(
1621 + /* translators: 1: setting label, 2: setting description */
1622 + '<span class="setting-label">%1$s</span>
1623 + <div class="form-note">%2$s</div>',
1624 + __('Staging Protection', 'fluent-cart'),
1625 + __('Prevent staging or test copies of your store from billing real customers.', 'fluent-cart')
1626 + )
1627 + ],
1628 + 'fields' => [
1629 + 'type' => 'grid',
1630 + 'columns' => [
1631 + 'default' => 1,
1632 + 'md' => 1
1633 + ],
1634 + 'disable_nesting' => true,
1635 + 'class' => 'col-span-2',
1636 + 'schema' => [
1637 + 'subscription_mode_guard' => [
1638 + 'label' => __('Don\'t bill live subscriptions from this site while it is in test mode', 'fluent-cart'),
1639 + 'type' => 'checkbox',
1640 + 'value' => 'yes',
1641 + 'note' => sprintf(
1642 + /* translators: 1: the setting's explanatory note text */
1643 + "<div class='pl-6'>%1\$s</div>",
1644 + __('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')
1645 + )
1646 + ],
1647 + ]
1648 + ]
1649 + ]
1650 + ],
1138 1651 ]
1139 1652 ],
1653 + 'appearance' => $this->getAppearanceSchema(),
1140 1654 ]
1141 1655 ],
1142 1656 ];
1143 1657
@@ -1144,8 +1658,14 @@
1144 1658
1145 1659 // Only show weight/dimension unit fields to users with shipping-sensitive permission
1146 1660 if (PermissionManager::hasPermission(['store/sensitive'])) {
1147 1661 $storeSchema = &$fields['setting_tabs']['schema']['store_setup']['schema'];
1662 +
1663 + $storeSchema['weight_unit_grid_divider'] = [
1664 + 'type' => 'html',
1665 + 'value' => '<hr class="settings-divider">'
1666 + ];
1667 +
1148 1668 $storeSchema['weight_unit_grid'] = [
1149 1669 'type' => 'grid',
1150 1670 'columns' => ['default' => 1, 'md' => 3],
1151 1671 'disable_nesting' => true,
@@ -1194,8 +1714,25 @@
1194 1714 ]
1195 1715 ];
1196 1716 }
1197 1717
1718 + // Review settings only apply while the reviews module is active —
1719 + // hide them (and their dividers) otherwise: the Product Rating
1720 + // section, the "Show Reviews In Single Page" toggle with its note,
1721 + // and the Order Review page selector. The stored values survive:
1722 + // save() merges the submitted form over what is stored, so a field
1723 + // left off the form keeps its value for when the module returns.
1724 + if (!ModuleSettings::isActive('reviews')) {
1725 + unset(
1726 + $fields['setting_tabs']['schema']['single_product_setup']['schema']['hr_product_slug'],
1727 + $fields['setting_tabs']['schema']['single_product_setup']['schema']['product_rating_grid'],
1728 + $fields['setting_tabs']['schema']['single_product_setup']['schema']['product_settings_grid']['schema']['fields']['schema']['show_reviews_in_single_page'],
1729 + $fields['setting_tabs']['schema']['single_product_setup']['schema']['product_settings_grid']['schema']['fields']['schema']['show_reviews_note'],
1730 + $fields['setting_tabs']['schema']['pages_setup']['schema']['hr_order_review'],
1731 + $fields['setting_tabs']['schema']['pages_setup']['schema']['order_review_page_grid']
1732 + );
1733 + }
1734 +
1198 1735 return apply_filters("fluent_cart/store_settings/fields", $fields, []);
1199 1736 }
1200 1737
1201 1738 public function isModuleTabEnabled(): bool
@@ -1225,8 +1762,28 @@
1225 1762
1226 1763 return empty($data) ? $default : $data;
1227 1764 }
1228 1765
1766 + /**
1767 + * All store settings, as the settings form should edit them.
1768 + *
1769 + * A stored radius the grammar refuses (hand-edited, or written by an older
1770 + * build) never printed on the storefront; dropped here too, so the form
1771 + * never holds a value it would refuse to save.
1772 + *
1773 + * @return array
1774 + */
1775 + public function getForEditing(): array
1776 + {
1777 + $settings = (array)$this->get();
1778 +
1779 + if (array_key_exists('appearance_radius', $settings)) {
1780 + $settings['appearance_radius'] = RadiusPalette::sanitizeOwnerMap($settings['appearance_radius']);
1781 + }
1782 +
1783 + return $settings;
1784 + }
1785 +
1229 1786 public function moduleSettings(): array
1230 1787 {
1231 1788 $settings = $this->get('modules_settings', []);
1232 1789 return Arr::wrap($settings);
@@ -1251,8 +1808,10 @@
1251 1808 }
1252 1809
1253 1810 update_option($this->optionKey, $settings, true);
1254 1811 $this->storeSettings = $settings;
1812 + self::$cachedStoreSettings = $this->storeSettings;
1813 + wp_cache_delete(self::CACHE_KEY, self::CACHE_GROUP);
1255 1814
1256 1815 $isSlugChanged = Arr::get($prevSettings, 'product_slug') !== Arr::get($settings, 'product_slug');
1257 1816 $isAccountPageChanged = Arr::get($prevSettings, 'customer_profile_page_slug') !== Arr::get($settings, 'customer_profile_page_slug');
1258 1817
@@ -1284,8 +1843,11 @@
1284 1843
1285 1844 public function isCheckoutPage(): bool
1286 1845 {
1287 1846 global $post;
1847 + if (!$post instanceof \WP_Post) {
1848 + return false;
1849 + }
1288 1850 $pageId = $this->getCheckoutPageId();
1289 1851 return intval($pageId) === intval($post->ID);
1290 1852 }
1291 1853
@@ -1304,8 +1866,9 @@
1304 1866 'registration_page_id',
1305 1867 'login_page_id',
1306 1868 'cart_page_id',
1307 1869 'receipt_page_id',
1870 + 'order_review_page_id',
1308 1871 'shop_page_id',
1309 1872 'customer_profile_page_id',
1310 1873 'customer_profile_page_slug',
1311 1874 ]
@@ -1378,12 +1941,32 @@
1378 1941 if (Pages::isPage($pageId)) {
1379 1942 return $this->getPageLink($pageId);
1380 1943 }
1381 1944 }
1382 - return '';
1945 + return home_url();
1383 1946 }
1384 1947
1948 + public function getOrderReviewPageId()
1949 + {
1950 + return Arr::get($this->storeSettings, 'order_review_page_id');
1951 + }
1385 1952
1953 + /**
1954 + * The page hosting [fluent_cart_order_review], or home_url() when the store
1955 + * has not picked one — Order::getOrderReviewUrl() then falls back to the
1956 + * ?fluent-cart=order-review route, which needs no page at all.
1957 + */
1958 + public function getOrderReviewPage(): string
1959 + {
1960 + if ($pageId = $this->getOrderReviewPageId()) {
1961 + if (Pages::isPage($pageId)) {
1962 + return $this->getPageLink($pageId);
1963 + }
1964 + }
1965 + return home_url();
1966 + }
1967 +
1968 +
1386 1969 public function getBuyButtonText(): string
1387 1970 {
1388 1971 return esc_html(Arr::get($this->storeSettings, 'checkout_button_text', __('Buy now', 'fluent-cart')));
1389 1972 }
@@ -1514,9 +2097,9 @@
1514 2097 if (!$pageId) {
1515 2098 $pageId = Arr::get($this->storeSettings, 'customer_profile_page_id');
1516 2099 }
1517 2100
1518 - if (!$pageId) {
2101 + if (!$pageId || !get_post($pageId)) {
1519 2102 return $slug;
1520 2103 }
1521 2104
1522 2105 $url = get_page_link($pageId);
@@ -1529,8 +2112,333 @@
1529 2112 return trim(str_replace(home_url('/'), '', $url), '/');
1530 2113 }
1531 2114
1532 2115 /**
2116 + * The Appearance settings tab.
2117 + *
2118 + * Built from the ColorPalette registry rather than written out by hand, so
2119 + * a colour added to the registry becomes settable here, sanitised on save
2120 + * and written to the storefront without three separate edits.
2121 + *
2122 + * @return array
2123 + */
2124 + protected function getAppearanceSchema(): array
2125 + {
2126 + // Plain text — the component renders these as text nodes, so no markup
2127 + // and no escaping here or the entities would show up verbatim.
2128 + $sourceOptions = [
2129 + [
2130 + 'label' => __("FluentCart's own style", 'fluent-cart'),
2131 + 'value' => ColorPalette::SOURCE_DEFAULT,
2132 + 'icon' => 'PaletteLine',
2133 + 'note' => __('The storefront keeps the colors and corner rounding it ships with.', 'fluent-cart'),
2134 + ],
2135 + [
2136 + 'label' => __('Inherit from the active theme', 'fluent-cart'),
2137 + 'value' => ColorPalette::SOURCE_THEME,
2138 + 'icon' => 'PaintLine',
2139 + 'note' => __('FluentCart uses your active theme\'s colors, and its button and form field corner rounding, for the storefront. Product cards keep FluentCart\'s corners. If something doesn\'t come through as expected, choose Customize to set it yourself.', 'fluent-cart'),
2140 + ],
2141 + [
2142 + 'label' => __('Customize', 'fluent-cart'),
2143 + 'value' => ColorPalette::SOURCE_CUSTOM,
2144 + 'icon' => 'PaletteLine',
2145 + 'note' => __('Pick the colors and corner rounding yourself. Only what you set is written to the storefront.', 'fluent-cart'),
2146 + ],
2147 + ];
2148 +
2149 + $sourceHeading = [
2150 + 'label' => __('Where colors and corner rounding come from', 'fluent-cart'),
2151 + 'note' => __('Storefront colors and corner rounding cascade from a small set of globals, so changing one here updates every page that uses it.', 'fluent-cart'),
2152 + ];
2153 +
2154 + return [
2155 + 'title' => __('Appearance', 'fluent-cart'),
2156 + 'show_title' => false,
2157 + 'type' => 'section',
2158 + 'wrapperClass' => 'fct-appearance-component-section',
2159 + 'disable_nesting' => true,
2160 + 'columns' => [
2161 + 'default' => 1,
2162 + 'md' => 1,
2163 + ],
2164 + 'schema' => [
2165 + 'appearance_component' => [
2166 + 'type' => 'component',
2167 + 'component' => 'StoreSettings/AppearanceComponent',
2168 + 'wrapperClass' => 'col-span-full',
2169 + 'label' => false,
2170 + // Rendered by the component itself, so the heading sits
2171 + // inside .fct-appearance-component with the controls it
2172 + // describes rather than as a sibling html field.
2173 + 'heading' => $sourceHeading,
2174 + 'source_options' => $sourceOptions,
2175 + 'custom_source' => ColorPalette::SOURCE_CUSTOM,
2176 + 'color_groups' => $this->getAppearanceColorGroups(),
2177 + 'radius_section' => $this->getAppearanceRadiusSection(),
2178 + 'preview' => $this->getAppearancePreviewData(),
2179 + ],
2180 + 'appearance_source' => [
2181 + 'type' => 'hidden',
2182 + 'value' => ColorPalette::SOURCE_DEFAULT,
2183 + ],
2184 + 'appearance_colors' => [
2185 + 'type' => 'hidden',
2186 + 'value' => (object)[],
2187 + ],
2188 + 'appearance_radius' => [
2189 + 'type' => 'hidden',
2190 + 'value' => (object)[],
2191 + ],
2192 + ],
2193 + ];
2194 + }
2195 +
2196 + /**
2197 + * What the storefront preview needs to paint itself.
2198 + *
2199 + * The preview is drawn from semantic roles rather than settings keys, so
2200 + * one mock card covers all three sources: `customize` reads the pickers the
2201 + * owner has filled in, and the other two are resolved here because their
2202 + * colors only exist server side — `inherit_from_theme` is mixed out of
2203 + * theme.json by ThemePalette and nothing about it reaches the browser.
2204 + *
2205 + * @return array
2206 + */
2207 + protected function getAppearancePreviewData(): array
2208 + {
2209 + // No colour is sent with a slot. appearance.scss already writes every
2210 + // preview surface as `var(--fct-pv-x, <fallback>)`, so the mock has the
2211 + // same single source of truth the storefront does: a slot the component
2212 + // cannot resolve simply goes undeclared and the stylesheet's fallback
2213 + // applies.
2214 + return [
2215 + 'slots' => $this->getAppearancePreviewSlots(),
2216 + 'theme_source' => ColorPalette::SOURCE_THEME,
2217 + 'theme_roles' => $this->getAppearanceThemeRoles(),
2218 + 'theme_radii' => $this->getAppearanceThemeRadii(),
2219 + 'notes' => [
2220 + ColorPalette::SOURCE_THEME => $this->getAppearanceThemeNote(),
2221 + ColorPalette::SOURCE_CUSTOM => __('Set only the colors you want to change — anything you leave unset keeps FluentCart\'s own default.', 'fluent-cart'),
2222 + ],
2223 + ];
2224 + }
2225 +
2226 + /**
2227 + * Which custom property the preview paints each surface with.
2228 + *
2229 + * Keyed by settings key, not by role. Four separate globals claim the
2230 + * `accent` role — active text, the brand background and both active
2231 + * borders — so collapsing the preview to roles would let one picker drive
2232 + * surfaces the storefront keeps apart, and leave the others with no visible
2233 + * effect at all. `role` is carried only for theme inheritance, which
2234 + * genuinely is role-based: FrontendTheme::getThemeColors() gives every key
2235 + * sharing a role the same colour, so the preview collapsing there is the
2236 + * storefront's own behaviour rather than a shortcut.
2237 + *
2238 + * A slot with no `key` is a surface no global controls; it follows the
2239 + * role's default and cannot be edited.
2240 + *
2241 + * @return array
2242 + */
2243 + protected function getAppearancePreviewSlots(): array
2244 + {
2245 + return [
2246 + ['var' => 'surface', 'role' => 'surface', 'key' => 'card_bg_color'],
2247 + ['var' => 'surface-mute', 'role' => 'surface_mute'],
2248 + ['var' => 'surface-alt', 'role' => 'surface_alt', 'key' => 'secondary_bg_color'],
2249 + ['var' => 'text', 'role' => 'text', 'key' => 'primary_text_color'],
2250 + ['var' => 'text-muted', 'role' => 'text_muted', 'key' => 'secondary_text_color'],
2251 + ['var' => 'text-placeholder', 'role' => 'text_placeholder', 'key' => 'input_placeholder_text_color'],
2252 + ['var' => 'input-bg', 'role' => 'surface', 'key' => 'input_bg_color'],
2253 + ['var' => 'input-text', 'role' => 'text', 'key' => 'input_text_color'],
2254 + ['var' => 'input-disabled-bg', 'role' => 'surface_mute', 'key' => 'input_disabled_bg_color'],
2255 + ['var' => 'accent-text', 'role' => 'accent', 'key' => 'primary_active_text_color'],
2256 + ['var' => 'active-border', 'role' => 'accent', 'key' => 'active_border_color'],
2257 + ['var' => 'border', 'role' => 'border', 'key' => 'border_color'],
2258 + ['var' => 'divider', 'role' => 'divider', 'key' => 'divider_color'],
2259 + ['var' => 'button-bg', 'role' => 'button_bg', 'key' => 'btn_bg_color'],
2260 + ['var' => 'button-text', 'role' => 'button_text', 'key' => 'btn_text_color'],
2261 + ['var' => 'secondary-button-bg', 'role' => 'surface', 'key' => 'secondary_btn_bg_color'],
2262 + ['var' => 'secondary-button-text', 'role' => 'text', 'key' => 'secondary_btn_text_color'],
2263 + ['var' => 'secondary-button-border', 'role' => 'border', 'key' => 'secondary_btn_border_color'],
2264 + ['var' => 'secondary-button-hover-bg', 'role' => 'surface_mute', 'key' => 'secondary_btn_hover_bg_color'],
2265 + ];
2266 + }
2267 +
2268 + /**
2269 + * Why the theme preview may not be the whole truth.
2270 + *
2271 + * Themes such as Astra publish their palette as `var(--ast-global-color-0)`
2272 + * rather than as colors. FrontendTheme writes those references straight
2273 + * through and the browser resolves them on the storefront, but the property
2274 + * is not declared in wp-admin, so the preview cannot show them. Saying so
2275 + * beats showing FluentCart's colors and letting the owner believe that is
2276 + * what inheriting will look like.
2277 + *
2278 + * @return string Empty when the preview is faithful.
2279 + */
2280 + protected function getAppearanceThemeNote(): string
2281 + {
2282 + if (!ThemePalette::hasUsableSource()) {
2283 + return __('The active theme publishes no colors to inherit, so the storefront keeps FluentCart\'s own colors.', 'fluent-cart');
2284 + }
2285 +
2286 + // Only a colour with no hex anywhere is unpreviewable. A live
2287 + // reference with a hex fallback — what the theme-settings readers and
2288 + // Blocksy write — previews as that fallback.
2289 + foreach (ThemePalette::resolve() as $value) {
2290 + if ((string)$value !== '' && ThemePalette::measurable((string)$value) === '') {
2291 + 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');
2292 + }
2293 + }
2294 +
2295 + return '';
2296 + }
2297 +
2298 + /**
2299 + * The theme's colors for the preview, role by role.
2300 + *
2301 + * Only roles that resolved to a real color are returned — the preview
2302 + * falls back to each slot's own default for the rest.
2303 + *
2304 + * @return array Role => hex.
2305 + */
2306 + protected function getAppearanceThemeRoles(): array
2307 + {
2308 + // With nothing to inherit, FrontendTheme writes no declarations at all,
2309 + // so the storefront keeps FluentCart's colors — and so must the
2310 + // preview, or it would promise a change that never happens.
2311 + if (!ThemePalette::hasUsableSource()) {
2312 + return [];
2313 + }
2314 +
2315 + $roles = [];
2316 +
2317 + foreach (ThemePalette::resolve() as $role => $value) {
2318 + // A theme that publishes a bare `var(--x)` is written to the
2319 + // storefront verbatim and resolved by the browser there, but that
2320 + // property is not declared in wp-admin. Unpreviewable, so leave it
2321 + // out and let the slot fall back to its own default. A reference
2322 + // with a hex fallback (`var(--x, #hex)`) previews as that hex —
2323 + // the same colour the storefront measures it by.
2324 + $hex = ThemePalette::measurable((string)$value);
2325 +
2326 + if ($hex) {
2327 + $roles[$role] = $hex;
2328 + }
2329 + }
2330 +
2331 + return $roles;
2332 + }
2333 +
2334 + /**
2335 + * The theme's border radii for the preview, role => length.
2336 + *
2337 + * Exactly what inheriting would print, storefront filter included, so the
2338 + * preview never promises a radius the storefront won't write.
2339 + *
2340 + * @return array Role key => length (`8px`, `0.5rem`).
2341 + */
2342 + protected function getAppearanceThemeRadii(): array
2343 + {
2344 + return FrontendTheme::radiiFor(ColorPalette::SOURCE_THEME);
2345 + }
2346 +
2347 + /**
2348 + * The colour knobs, grouped the way the registry groups them, as data the
2349 + * appearance component renders its pickers from.
2350 + *
2351 + * @return array
2352 + */
2353 + protected function getAppearanceColorGroups(): array
2354 + {
2355 + $groups = [];
2356 +
2357 + foreach (ColorPalette::groups() as $groupKey => $groupLabel) {
2358 + $globals = ColorPalette::globalsFor($groupKey);
2359 +
2360 + if (!$globals) {
2361 + continue;
2362 + }
2363 +
2364 + $fields = [];
2365 +
2366 + foreach ($globals as $key => $definition) {
2367 + $fields[] = [
2368 + 'key' => $key,
2369 + 'label' => Arr::get($definition, 'label', $key),
2370 + 'note' => $this->getAppearanceFieldNote($definition),
2371 + 'default' => (string)Arr::get($definition, 'default', ''),
2372 + ];
2373 + }
2374 +
2375 + $groups[] = [
2376 + 'key' => $groupKey,
2377 + 'label' => $groupLabel,
2378 + 'fields' => $fields,
2379 + ];
2380 + }
2381 +
2382 + return $groups;
2383 + }
2384 +
2385 + /**
2386 + * The border-radius controls, as data the appearance component renders
2387 + * its number inputs from.
2388 + *
2389 + * Driven off RadiusPalette::roles(), the same registry the sanitiser and
2390 + * the storefront CSS read, so a control can never write a role the page
2391 + * would not print.
2392 + *
2393 + * @return array
2394 + */
2395 + protected function getAppearanceRadiusSection(): array
2396 + {
2397 + $fields = [];
2398 +
2399 + foreach (RadiusPalette::roles() as $key => $definition) {
2400 + $fields[] = [
2401 + 'key' => $key,
2402 + 'label' => (string)Arr::get($definition, 'label', $key),
2403 + 'note' => (string)Arr::get($definition, 'note', ''),
2404 + ];
2405 + }
2406 +
2407 + return [
2408 + 'label' => __('Border radius', 'fluent-cart'),
2409 + 'note' => __('A number is read as pixels, or add a unit: 8, 8px, 0.5rem, 1em. Leave a field empty to keep FluentCart\'s default.', 'fluent-cart'),
2410 + 'invalid' => __('Use a number, optionally with px, rem or em.', 'fluent-cart'),
2411 + 'fields' => $fields,
2412 + ];
2413 + }
2414 +
2415 + /**
2416 + * The hint under one colour picker: what it drives, and the value it
2417 + * falls back to when left empty.
2418 + *
2419 + * @param array $definition
2420 + * @return string
2421 + */
2422 + protected function getAppearanceFieldNote(array $definition): string
2423 + {
2424 + $usage = (string)Arr::get($definition, 'note', '');
2425 + $default = (string)Arr::get($definition, 'default', '');
2426 +
2427 + if ($default === '') {
2428 + return $usage;
2429 + }
2430 +
2431 + if ($usage === '') {
2432 + /* translators: %1$s: the hex colour FluentCart falls back to */
2433 + return sprintf(__('Default: %1$s', 'fluent-cart'), $default);
2434 + }
2435 +
2436 + /* translators: 1: what the colour is used for, 2: the hex colour FluentCart falls back to */
2437 + return sprintf(__('%1$s Default: %2$s', 'fluent-cart'), $usage, $default);
2438 + }
2439 +
2440 + /**
1533 2441 * Get theme colors as CSS variable string.
1534 2442 *
1535 2443 * @return string
1536 2444 */
@@ -1553,8 +2461,12 @@
1553 2461 }
1554 2462
1555 2463 public function getPageLink($pageId = null): string
1556 2464 {
2465 + if (!$pageId || !get_post($pageId)) {
2466 + return '';
2467 + }
2468 +
1557 2469 $link = get_page_link($pageId);
1558 2470
1559 2471 if (!empty($link)) {
1560 2472 return Str::endsWith($link, '/') ? $link : $link . '/';
@@ -1560,5 +2472,6 @@
1560 2472 return Str::endsWith($link, '/') ? $link : $link . '/';
1561 2473 }
1562 2474 return '';
1563 2475 }
2476 +
1564 2477 }