PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
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 1.3.20 All 50 releases
← All changes | api/StoreSettings.php +838 -9 1.3.27 → 1.7.0 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,10 @@
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' => 'yes',
87 + 'require_customer_email_verification' => 'no',
63 88 'checkout_page_id' => '',
64 89 'custom_payment_page_id' => '',
65 90 'registration_page_id' => '',
66 91 'login_page_id' => '',
@@ -65,8 +90,9 @@
65 90 'registration_page_id' => '',
66 91 'login_page_id' => '',
67 92 'cart_page_id' => '',
68 93 'receipt_page_id' => '',
94 + 'order_review_page_id' => '',
69 95 'shop_page_id' => '',
70 96 'customer_profile_page_id' => '',
71 97 'customer_profile_page_slug' => '',
72 98 'currency' => 'USD',
@@ -77,17 +103,33 @@
77 103 'store_postcode' => '',
78 104 'store_state' => '',
79 105 'show_relevant_product_in_single_page' => 'yes',
80 106 'show_relevant_product_in_modal' => '',
107 + 'show_reviews_in_single_page' => 'yes',
108 + 'show_rating_in_shop' => 'yes',
109 + 'show_rating_in_relevant' => 'yes',
81 110 'order_mode' => 'test',
111 + 'subscription_mode_guard' => 'yes',
82 112 'variation_view' => 'both',
83 113 'variation_columns' => 'masonry',
84 114 'enable_early_payment_for_installment' => 'yes',
115 + 'subscription_management_mode' => 'gateway_managed',
116 + 'subscription_system_charge' => 'no',
85 117 'modules_settings' => [],
86 118 'min_receipt_number' => '1',
87 119 'inv_prefix' => 'INV-',
88 120 'weight_unit' => 'kg',
89 - 'dimension_unit' => 'cm'
121 + 'dimension_unit' => 'cm',
122 + 'appearance_source' => ColorPalette::SOURCE_DEFAULT,
123 + 'appearance_colors' => [],
124 + // 'wordpress', not 'fluent_cart': the FluentCart patterns are
125 + // literals ('M j, Y'), and a literal renders a half-translated date
126 + // on a localized store -- a German month in English field order,
127 + // which a German reader misreads as day-first. Following
128 + // Settings > General is the only source that is correct in every
129 + // locale, so it is what a store gets until it chooses otherwise.
130 + 'date_time_format_source' => 'wordpress',
131 + 'timezone_source' => 'fluent_cart'
90 132 ];
91 133
92 134 return apply_filters('fluent_cart/store_settings/values', $defaultSettings, []);
93 135 }
@@ -106,14 +148,60 @@
106 148 'shop_page_id' => $this->getShopPage(),
107 149 'customer_profile_page_id' => $this->getCustomerProfilePage(),
108 150 'cart_page_id' => $this->getCartPage(),
109 151 'checkout_page_id' => $this->getCheckoutPage(),
110 - 'receipt_page_id' => $this->getReceiptPage()
152 + 'receipt_page_id' => $this->getReceiptPage(),
153 + 'order_review_page_id' => $this->getOrderReviewPage()
111 154 ];
112 155 $isProActive = App::isProActive();
113 156 $proFeatureIcon = Vite::getAssetUrl('images/crown.svg');
114 157
158 + // Read-only schedule for store-managed subscription renewals. Pulled live
159 + // from the same map the scheduler uses, so it stays accurate under the
160 + // fluent_cart/renewal/advance_creation_days filter. No editable knob — the timing is
161 + // deliberately built-in; developers tune it via that filter.
162 + $invoiceScheduleMap = RenewalService::getAdvanceCreationDaysMap();
163 + $invoiceScheduleLabels = [
164 + 'daily' => __('Daily', 'fluent-cart'),
165 + 'weekly' => __('Weekly', 'fluent-cart'),
166 + 'monthly' => __('Monthly', 'fluent-cart'),
167 + 'quarterly' => __('Quarterly', 'fluent-cart'),
168 + 'half_yearly' => __('Half-yearly', 'fluent-cart'),
169 + 'yearly' => __('Yearly', 'fluent-cart'),
170 + ];
171 + // Structured renewal-order schedule for the SubscriptionModeManager
172 + // component (status card + guarded edit dialog).
173 + $invoiceScheduleList = [];
174 + foreach ($invoiceScheduleLabels as $invoiceScheduleKey => $invoiceScheduleLabel) {
175 + if (!isset($invoiceScheduleMap[$invoiceScheduleKey])) {
176 + continue;
177 + }
178 + $invoiceScheduleDays = (int) $invoiceScheduleMap[$invoiceScheduleKey];
179 + $invoiceScheduleList[] = [
180 + 'label' => $invoiceScheduleLabel,
181 + 'when' => $invoiceScheduleDays <= 0
182 + ? __('on the due date', 'fluent-cart')
183 + /* translators: %d: number of days before the due date */
184 + : sprintf(_n('%d day before due date', '%d days before due date', $invoiceScheduleDays, 'fluent-cart'), $invoiceScheduleDays),
185 + ];
186 + }
115 187
188 + // Gateways declaring `system_subscription` — the only ones
189 + // subscription_system_charge can ever auto-charge.
190 + $systemChargeGateways = [];
191 + foreach (GatewayManager::getInstance()->all() as $systemChargeGateway) {
192 + if (!$systemChargeGateway->has('system_subscription') || $systemChargeGateway->isUpcoming()) {
193 + continue;
194 + }
195 + $systemChargeGatewayMeta = $systemChargeGateway->getMeta();
196 + $systemChargeGateways[] = [
197 + 'label' => Arr::get($systemChargeGatewayMeta, 'admin_title')
198 + ?: Arr::get($systemChargeGatewayMeta, 'label')
199 + ?: Arr::get($systemChargeGatewayMeta, 'title'),
200 + 'active' => $systemChargeGateway->isEnabled(),
201 + ];
202 + }
203 +
116 204 $fields = [
117 205 'setting_tabs' => [
118 206 'type' => 'section',
119 207 'disable_nesting' => true,
@@ -120,8 +208,9 @@
120 208 'default_tab' => 'store_setup',
121 209 'hide_tab_switch' => true,
122 210 'schema' => [
123 211 'store_setup' => [
212 + 'id' => '',
124 213 'title' => __('Store Setup', 'fluent-cart'),
125 214 'show_title' => false,
126 215 'type' => 'section',
127 216 'disable_nesting' => true,
@@ -228,9 +317,9 @@
228 317 ],
229 318 ]
230 319 ],
231 320
232 - 'settings_hr' => [
321 + 'date_time_hr' => [
233 322 'type' => 'html',
234 323 'value' => '<hr class="settings-divider">'
235 324 ],
236 325
@@ -286,8 +375,61 @@
286 375 'type' => 'html',
287 376 'value' => '<hr class="settings-divider">'
288 377 ],
289 378
379 + 'business_details_grid' => [
380 + 'type' => 'grid',
381 + 'id' => 'business_details',
382 + 'columns' => [
383 + 'default' => 1,
384 + 'md' => 3
385 + ],
386 + 'disable_nesting' => true,
387 + 'schema' => [
388 + 'label' => [
389 + 'type' => 'html',
390 + 'value' => '<span class="setting-label">' . __('Business Details', 'fluent-cart') . '</span>
391 + <div class="form-note">' . __('Add your legal business identity details used for store records and compliance.', 'fluent-cart') . '</div>'
392 + ],
393 + 'business_details_fields' => [
394 + 'type' => 'grid',
395 + 'columns' => [
396 + 'default' => 1,
397 + 'md' => 2
398 + ],
399 + 'disable_nesting' => true,
400 + 'wrapperClass' => 'col-span-2',
401 + 'schema' => [
402 + 'company_name' => [
403 + 'label' => __('Company Name', 'fluent-cart'),
404 + 'type' => 'input',
405 + 'value' => '',
406 + ],
407 + 'legal_registration_id' => [
408 + 'label' => __('Legal Registration ID', 'fluent-cart'),
409 + 'type' => 'input',
410 + 'value' => '',
411 + ],
412 + 'seller_vat_id' => [
413 + 'label' => __('Seller VAT ID', 'fluent-cart'),
414 + 'type' => 'input',
415 + 'value' => '',
416 + ],
417 + 'seller_tax_id' => [
418 + 'label' => __('Seller Tax ID', 'fluent-cart'),
419 + 'type' => 'input',
420 + 'value' => '',
421 + ],
422 + ]
423 + ],
424 + ]
425 + ],
426 +
427 + 'settings_hr_3' => [
428 + 'type' => 'html',
429 + 'value' => '<hr class="settings-divider">'
430 + ],
431 +
290 432 'currency_grid' => [
291 433 'type' => 'grid',
292 434 'columns' => [
293 435 'default' => 1,
@@ -443,8 +585,79 @@
443 585 ],
444 586 ]
445 587 ],
446 588
589 + 'settings_hr' => [
590 + 'type' => 'html',
591 + 'value' => '<hr class="settings-divider">'
592 + ],
593 +
594 + 'date_time_format_grid' => [
595 + 'type' => 'grid',
596 + 'columns' => [
597 + 'default' => 1,
598 + 'md' => 3
599 + ],
600 + 'disable_nesting' => true,
601 + 'schema' => [
602 + 'label' => [
603 + 'type' => 'html',
604 + 'value' => '<span class="setting-label">' . __('Date & Time Format', 'fluent-cart') . '</span>
605 + <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>'
606 + ],
607 + 'date_time_format_source' => [
608 + 'wrapperClass' => 'col-span-2 flex items-center',
609 + 'label' => '',
610 + 'type' => 'radio',
611 + 'options' => [
612 + [
613 + // Label only -- the stored value stays 'fluent_cart'.
614 + 'label' => __('Smart', 'fluent-cart'),
615 + 'value' => 'fluent_cart',
616 + ],
617 + [
618 + 'label' => __('WordPress', 'fluent-cart'),
619 + 'value' => 'wordpress',
620 + ],
621 + ],
622 + 'value' => 'fluent_cart'
623 + ],
624 + ]
625 + ],
626 +
627 + 'timezone_source_grid' => [
628 + 'type' => 'grid',
629 + 'columns' => [
630 + 'default' => 1,
631 + 'md' => 3
632 + ],
633 + 'disable_nesting' => true,
634 + 'schema' => [
635 + 'label' => [
636 + 'type' => 'html',
637 + 'value' => '<span class="setting-label">' . __('Timezone', 'fluent-cart') . '</span>
638 + <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>'
639 + ],
640 + 'timezone_source' => [
641 + 'wrapperClass' => 'col-span-2 flex items-center',
642 + 'label' => '',
643 + 'type' => 'radio',
644 + 'options' => [
645 + [
646 + // Label only -- the stored value stays 'fluent_cart'.
647 + 'label' => __('Browser', 'fluent-cart'),
648 + 'value' => 'fluent_cart',
649 + ],
650 + [
651 + 'label' => __('WordPress', 'fluent-cart'),
652 + 'value' => 'wordpress',
653 + ],
654 + ],
655 + 'value' => 'fluent_cart'
656 + ],
657 + ]
658 + ],
659 +
447 660 // 'settings_hr_modal' => [
448 661 // 'type' => 'html',
449 662 // 'value' => '<hr class="settings-divider">'
450 663 // ],
@@ -532,8 +745,9 @@
532 745 // ]
533 746 // ]
534 747 // ],
535 748 'pages_setup' => [
749 + 'id' => '',
536 750 'title' => __('Pages Setup', 'fluent-cart'),
537 751 'show_title' => false,
538 752 'type' => 'section',
539 753 'disable_nesting' => true,
@@ -684,8 +898,45 @@
684 898 ],
685 899 ]
686 900 ],
687 901
902 + 'hr_order_review' => [
903 + 'type' => 'html',
904 + 'value' => '<hr class="settings-divider">'
905 + ],
906 +
907 + 'order_review_page_grid' => [
908 + 'type' => 'grid',
909 + 'columns' => [
910 + 'default' => 1,
911 + 'md' => 3
912 + ],
913 + 'disable_nesting' => true,
914 + 'schema' => [
915 + 'label' => [
916 + 'type' => 'html',
917 + 'value' => '<span class="setting-label">' . __('Select Order Review Page', 'fluent-cart') . '</span>
918 + <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>'
919 + ],
920 + 'order_review_page_id' => [
921 + 'wrapperClass' => 'col-span-2',
922 + 'label' => false,
923 + 'page_title' => __('Review Your Order', 'fluent-cart'),
924 + 'type' => 'component',
925 + 'component' => 'StoreSettings/PageSelector',
926 + 'page_key' => 'order_review_page_id',
927 + 'preview_link' => $previewLinks['order_review_page_id'],
928 + 'hide_note' => true,
929 + 'options' => $pages,
930 + 'value' => '',
931 + 'note' => \FluentCart\App\Helpers\Helper::getShortcodeInstructionString(
932 + '[fluent_cart_order_review]',
933 + __('Review Your Order', 'fluent-cart')
934 + ),
935 + ],
936 + ]
937 + ],
938 +
688 939 'hr4' => [
689 940 'type' => 'html',
690 941 'value' => '<hr class="settings-divider">'
691 942 ],
@@ -743,9 +994,9 @@
743 994 'schema' => [
744 995 'label' => [
745 996 'type' => 'html',
746 997 '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>'
998 + <div class="form-note">' . __("Choose which sections to display on the single product page.", 'fluent-cart') . '</div>'
748 999 ],
749 1000 'fields' => [
750 1001 'type' => 'grid',
751 1002 'columns' => [
@@ -763,8 +1014,17 @@
763 1014 "label" => __('Show Relevant In Product Modal', 'fluent-cart'),
764 1015 "type" => "checkbox",
765 1016 "value" => "no"
766 1017 ],
1018 + "show_reviews_in_single_page" => [
1019 + "label" => __('Show Reviews In Single Page', 'fluent-cart'),
1020 + "type" => "checkbox",
1021 + "value" => "yes"
1022 + ],
1023 + "show_reviews_note" => [
1024 + "type" => "html",
1025 + "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>'
1026 + ],
767 1027 ]
768 1028 ]
769 1029
770 1030 ]
@@ -926,10 +1186,159 @@
926 1186 ],
927 1187
928 1188 ]
929 1189 ],
1190 +
1191 + 'hr_product_slug' => [
1192 + 'type' => 'html',
1193 + 'value' => '<hr class="settings-divider">'
1194 + ],
1195 +
1196 + 'product_rating_grid' => [
1197 + 'type' => 'grid',
1198 + 'columns' => [
1199 + 'default' => 1,
1200 + 'md' => 3
1201 + ],
1202 + 'disable_nesting' => true,
1203 + 'schema' => [
1204 + 'label' => [
1205 + 'type' => 'html',
1206 + 'value' => sprintf(
1207 + '<span class="setting-label">%1$s</span><div class="form-note">%2$s</div>',
1208 + __('Product Rating', 'fluent-cart'),
1209 + __('Show star ratings when reviews are enabled.', 'fluent-cart')
1210 + )
1211 + ],
1212 + 'fields' => [
1213 + 'type' => 'grid',
1214 + 'columns' => [
1215 + 'default' => 1,
1216 + 'md' => 1
1217 + ],
1218 + 'disable_nesting' => true,
1219 + 'schema' => [
1220 + "show_rating_in_shop" => [
1221 + "label" => __('Show Rating in Shop', 'fluent-cart'),
1222 + "type" => "checkbox",
1223 + "value" => "yes"
1224 + ],
1225 + "show_rating_in_relevant" => [
1226 + "label" => __('Show Rating in Relevant Products', 'fluent-cart'),
1227 + "type" => "checkbox",
1228 + "value" => "yes"
1229 + ],
1230 + ]
1231 + ]
1232 +
1233 + ]
1234 + ],
930 1235 ],
931 1236 ],
1237 + 'compliance' => [
1238 + 'title' => __('Compliance', 'fluent-cart'),
1239 + 'show_title' => false,
1240 + 'type' => 'section',
1241 + 'disable_nesting' => true,
1242 + 'columns' => [
1243 + 'default' => 1,
1244 + 'md' => 1,
1245 + ],
1246 + 'schema' => [
1247 + 'email_verification_grid' => [
1248 + 'type' => 'grid',
1249 + 'disable_nesting' => true,
1250 + 'columns' => ['default' => 1, 'md' => 3],
1251 + 'wrapperClass' => 'items-start',
1252 + 'schema' => [
1253 + 'label' => [
1254 + 'type' => 'html',
1255 + 'value' => sprintf(
1256 + '<span class="setting-label">%1$s</span><div class="form-note">%2$s</div>',
1257 + esc_html__('Customer email verification', 'fluent-cart'),
1258 + esc_html__('Confirm email ownership before customers access their portal and saved checkout addresses.', 'fluent-cart')
1259 + ),
1260 + ],
1261 + 'fields' => [
1262 + 'type' => 'grid',
1263 + 'disable_nesting' => true,
1264 + 'columns' => ['default' => 1],
1265 + 'wrapperClass' => 'md:col-span-2',
1266 + 'schema' => [
1267 + 'require_customer_email_verification' => [
1268 + 'wrapperClass' => 'fct-compliance-auto-login',
1269 + 'label' => false,
1270 + 'type' => 'radio',
1271 + 'value' => 'no',
1272 + 'attributes' => [
1273 + 'aria-label' => __('Customer email verification', 'fluent-cart'),
1274 + ],
1275 + 'options' => [
1276 + [
1277 + 'label' => __('Required', 'fluent-cart'),
1278 + 'value' => 'yes',
1279 + ],
1280 + [
1281 + 'label' => __('Not required', 'fluent-cart'),
1282 + 'value' => 'no',
1283 + ],
1284 + ],
1285 + 'note' => __('When not required, new checkout accounts are linked to their purchases immediately.', 'fluent-cart'),
1286 + ],
1287 + ],
1288 + ],
1289 + ],
1290 + ],
1291 + 'compliance_divider' => [
1292 + 'type' => 'html',
1293 + 'value' => '<hr class="settings-divider">',
1294 + ],
1295 + 'account_login_grid' => [
1296 + 'type' => 'grid',
1297 + 'disable_nesting' => true,
1298 + 'columns' => ['default' => 1, 'md' => 3],
1299 + 'wrapperClass' => 'items-start',
1300 + 'schema' => [
1301 + 'label' => [
1302 + 'type' => 'html',
1303 + 'value' => sprintf(
1304 + '<span class="setting-label">%1$s</span><div class="form-note">%2$s</div>',
1305 + esc_html__('Login after account creation', 'fluent-cart'),
1306 + esc_html__('Choose how customers sign in after registering or completing checkout.', 'fluent-cart')
1307 + ),
1308 + ],
1309 + 'fields' => [
1310 + 'type' => 'grid',
1311 + 'disable_nesting' => true,
1312 + 'columns' => ['default' => 1],
1313 + 'wrapperClass' => 'md:col-span-2',
1314 + 'schema' => [
1315 + 'auto_login_after_account_creation' => [
1316 + 'wrapperClass' => 'fct-compliance-auto-login',
1317 + 'label' => false,
1318 + 'type' => 'radio',
1319 + 'value' => 'yes',
1320 + 'attributes' => [
1321 + 'aria-label' => __('Login after account creation', 'fluent-cart'),
1322 + ],
1323 + 'options' => [
1324 + [
1325 + 'label' => __('Log in automatically', 'fluent-cart'),
1326 + 'value' => 'yes',
1327 + ],
1328 + [
1329 + 'label' => __('Let customers log in', 'fluent-cart'),
1330 + 'value' => 'no',
1331 + ],
1332 + ],
1333 + 'note' => __('Email verification still applies when required above.', 'fluent-cart'),
1334 + ],
1335 + ],
1336 + ],
1337 + ],
1338 + ],
1339 + ],
1340 + ],
932 1341 'cart_and_checkout' => [
933 1342 'title' => __('Cart & checkout', 'fluent-cart'),
934 1343 'show_title' => false,
935 1344 'type' => 'section',
@@ -1088,9 +1497,9 @@
1088 1497 ]
1089 1498 ],
1090 1499 ]
1091 1500 ],
1092 - 'subscriptions_setup' => [
1501 + 'subscriptions_setup' => [
1093 1502 'title' => __('Subscriptions', 'fluent-cart'),
1094 1503 'show_title' => false,
1095 1504 'type' => 'section',
1096 1505 'disable_nesting' => true,
@@ -1098,10 +1507,11 @@
1098 1507 'default' => 1,
1099 1508 'md' => 1
1100 1509 ],
1101 1510 'schema' => [
1102 - 'subscription_settings_grid' => [
1511 + 'subscription_early_payment_grid' => [
1103 1512 'type' => 'grid',
1513 + 'wrapperClass' => 'items-start mb-6',
1104 1514 'columns' => [
1105 1515 'default' => 1,
1106 1516 'md' => 3
1107 1517 ],
@@ -1108,10 +1518,10 @@
1108 1518 'disable_nesting' => true,
1109 1519 'schema' => [
1110 1520 'label' => [
1111 1521 '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>'
1522 + '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>
1523 + <div class="form-note">' . __('Let customers pay remaining installments before their due date.', 'fluent-cart') . '</div>'
1114 1524 ],
1115 1525 'fields' => [
1116 1526 'type' => 'grid',
1117 1527 'columns' => [
@@ -1134,10 +1544,111 @@
1134 1544 ]
1135 1545 ]
1136 1546 ]
1137 1547 ],
1548 + 'subscription_management_mode_grid' => [
1549 + 'type' => 'grid',
1550 + 'wrapperClass' => 'items-start',
1551 + 'columns' => [
1552 + 'default' => 1,
1553 + 'md' => 3
1554 + ],
1555 + 'disable_nesting' => true,
1556 + 'schema' => [
1557 + 'label' => [
1558 + 'type' => 'html',
1559 + 'value' => sprintf(
1560 + /* translators: 1: setting label, 2: setting description */
1561 + '<span class="setting-label">%1$s</span>
1562 + <div class="form-note">%2$s</div>',
1563 + __('Renewal Billing', 'fluent-cart'),
1564 + __('Choose how recurring subscription payments are collected.', 'fluent-cart')
1565 + )
1566 + ],
1567 + 'fields' => [
1568 + 'type' => 'grid',
1569 + 'columns' => [
1570 + 'default' => 1,
1571 + 'md' => 1
1572 + ],
1573 + 'disable_nesting' => true,
1574 + 'class' => 'col-span-2',
1575 + 'schema' => [
1576 + // Compact, guarded control: shows only the CURRENT mode with a
1577 + // short how-it-works summary and a Change button. Editing goes
1578 + // through a disclaimer confirm + dialog so the store-wide
1579 + // billing decision can never be flipped by a stray click.
1580 + 'subscription_management_mode' => [
1581 + 'label' => false,
1582 + 'component' => 'StoreSettings/SubscriptionModeManager',
1583 + 'disable_nesting' => true,
1584 + 'value' => 'gateway_managed',
1585 + // Read-only renewal-order schedule, built live from the
1586 + // same map the scheduler uses (filterable).
1587 + 'schedule_items' => $invoiceScheduleList,
1588 + // Which gateways auto-charge is actually possible on.
1589 + 'system_charge_gateways' => $systemChargeGateways,
1590 + ],
1591 + // Keep these in the form payload — the component above writes
1592 + // them; without a schema entry the keys would drop out of the
1593 + // saved values.
1594 + 'subscription_system_charge' => [
1595 + 'type' => 'hidden',
1596 + 'value' => 'no',
1597 + ],
1598 + 'subscription_manual_fallback' => [
1599 + 'type' => 'hidden',
1600 + 'value' => 'no',
1601 + ],
1602 + ]
1603 + ]
1604 + ]
1605 + ],
1606 + 'subscription_mode_guard_grid' => [
1607 + 'type' => 'grid',
1608 + 'wrapperClass' => 'items-start mt-6',
1609 + 'columns' => [
1610 + 'default' => 1,
1611 + 'md' => 3
1612 + ],
1613 + 'disable_nesting' => true,
1614 + 'schema' => [
1615 + 'label' => [
1616 + 'type' => 'html',
1617 + 'value' => sprintf(
1618 + /* translators: 1: setting label, 2: setting description */
1619 + '<span class="setting-label">%1$s</span>
1620 + <div class="form-note">%2$s</div>',
1621 + __('Staging Protection', 'fluent-cart'),
1622 + __('Prevent staging or test copies of your store from billing real customers.', 'fluent-cart')
1623 + )
1624 + ],
1625 + 'fields' => [
1626 + 'type' => 'grid',
1627 + 'columns' => [
1628 + 'default' => 1,
1629 + 'md' => 1
1630 + ],
1631 + 'disable_nesting' => true,
1632 + 'class' => 'col-span-2',
1633 + 'schema' => [
1634 + 'subscription_mode_guard' => [
1635 + 'label' => __('Don\'t bill live subscriptions from this site while it is in test mode', 'fluent-cart'),
1636 + 'type' => 'checkbox',
1637 + 'value' => 'yes',
1638 + 'note' => sprintf(
1639 + /* translators: 1: the setting's explanatory note text */
1640 + "<div class='pl-6'>%1\$s</div>",
1641 + __('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')
1642 + )
1643 + ],
1644 + ]
1645 + ]
1646 + ]
1647 + ],
1138 1648 ]
1139 1649 ],
1650 + 'appearance' => $this->getAppearanceSchema(),
1140 1651 ]
1141 1652 ],
1142 1653 ];
1143 1654
@@ -1144,8 +1655,14 @@
1144 1655
1145 1656 // Only show weight/dimension unit fields to users with shipping-sensitive permission
1146 1657 if (PermissionManager::hasPermission(['store/sensitive'])) {
1147 1658 $storeSchema = &$fields['setting_tabs']['schema']['store_setup']['schema'];
1659 +
1660 + $storeSchema['weight_unit_grid_divider'] = [
1661 + 'type' => 'html',
1662 + 'value' => '<hr class="settings-divider">'
1663 + ];
1664 +
1148 1665 $storeSchema['weight_unit_grid'] = [
1149 1666 'type' => 'grid',
1150 1667 'columns' => ['default' => 1, 'md' => 3],
1151 1668 'disable_nesting' => true,
@@ -1194,8 +1711,17 @@
1194 1711 ]
1195 1712 ];
1196 1713 }
1197 1714
1715 + // Product Rating toggles only apply while the reviews module is
1716 + // active — hide the section (and its divider) otherwise.
1717 + if (!ModuleSettings::isActive('reviews')) {
1718 + unset(
1719 + $fields['setting_tabs']['schema']['single_product_setup']['schema']['hr_product_slug'],
1720 + $fields['setting_tabs']['schema']['single_product_setup']['schema']['product_rating_grid']
1721 + );
1722 + }
1723 +
1198 1724 return apply_filters("fluent_cart/store_settings/fields", $fields, []);
1199 1725 }
1200 1726
1201 1727 public function isModuleTabEnabled(): bool
@@ -1251,8 +1777,10 @@
1251 1777 }
1252 1778
1253 1779 update_option($this->optionKey, $settings, true);
1254 1780 $this->storeSettings = $settings;
1781 + self::$cachedStoreSettings = $this->storeSettings;
1782 + wp_cache_delete(self::CACHE_KEY, self::CACHE_GROUP);
1255 1783
1256 1784 $isSlugChanged = Arr::get($prevSettings, 'product_slug') !== Arr::get($settings, 'product_slug');
1257 1785 $isAccountPageChanged = Arr::get($prevSettings, 'customer_profile_page_slug') !== Arr::get($settings, 'customer_profile_page_slug');
1258 1786
@@ -1284,8 +1812,11 @@
1284 1812
1285 1813 public function isCheckoutPage(): bool
1286 1814 {
1287 1815 global $post;
1816 + if (!$post instanceof \WP_Post) {
1817 + return false;
1818 + }
1288 1819 $pageId = $this->getCheckoutPageId();
1289 1820 return intval($pageId) === intval($post->ID);
1290 1821 }
1291 1822
@@ -1304,8 +1835,9 @@
1304 1835 'registration_page_id',
1305 1836 'login_page_id',
1306 1837 'cart_page_id',
1307 1838 'receipt_page_id',
1839 + 'order_review_page_id',
1308 1840 'shop_page_id',
1309 1841 'customer_profile_page_id',
1310 1842 'customer_profile_page_slug',
1311 1843 ]
@@ -1378,12 +1910,32 @@
1378 1910 if (Pages::isPage($pageId)) {
1379 1911 return $this->getPageLink($pageId);
1380 1912 }
1381 1913 }
1382 - return '';
1914 + return home_url();
1383 1915 }
1384 1916
1917 + public function getOrderReviewPageId()
1918 + {
1919 + return Arr::get($this->storeSettings, 'order_review_page_id');
1920 + }
1385 1921
1922 + /**
1923 + * The page hosting [fluent_cart_order_review], or home_url() when the store
1924 + * has not picked one — Order::getOrderReviewUrl() then falls back to the
1925 + * ?fluent-cart=order-review route, which needs no page at all.
1926 + */
1927 + public function getOrderReviewPage(): string
1928 + {
1929 + if ($pageId = $this->getOrderReviewPageId()) {
1930 + if (Pages::isPage($pageId)) {
1931 + return $this->getPageLink($pageId);
1932 + }
1933 + }
1934 + return home_url();
1935 + }
1936 +
1937 +
1386 1938 public function getBuyButtonText(): string
1387 1939 {
1388 1940 return esc_html(Arr::get($this->storeSettings, 'checkout_button_text', __('Buy now', 'fluent-cart')));
1389 1941 }
@@ -1529,8 +2081,284 @@
1529 2081 return trim(str_replace(home_url('/'), '', $url), '/');
1530 2082 }
1531 2083
1532 2084 /**
2085 + * The Appearance settings tab.
2086 + *
2087 + * Built from the ColorPalette registry rather than written out by hand, so
2088 + * a colour added to the registry becomes settable here, sanitised on save
2089 + * and written to the storefront without three separate edits.
2090 + *
2091 + * @return array
2092 + */
2093 + protected function getAppearanceSchema(): array
2094 + {
2095 + // Plain text — the component renders these as text nodes, so no markup
2096 + // and no escaping here or the entities would show up verbatim.
2097 + $sourceOptions = [
2098 + [
2099 + 'label' => __("FluentCart's own colors", 'fluent-cart'),
2100 + 'value' => ColorPalette::SOURCE_DEFAULT,
2101 + 'icon' => 'PaletteLine',
2102 + 'note' => __('The storefront keeps the colors it ships with.', 'fluent-cart'),
2103 + ],
2104 + [
2105 + 'label' => __('Inherit from the active theme', 'fluent-cart'),
2106 + 'value' => ColorPalette::SOURCE_THEME,
2107 + 'icon' => 'PaintLine',
2108 + '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'),
2109 + ],
2110 + [
2111 + 'label' => __('Customize', 'fluent-cart'),
2112 + 'value' => ColorPalette::SOURCE_CUSTOM,
2113 + 'icon' => 'PaletteLine',
2114 + 'note' => __('Pick the colors yourself. Only the ones you set are written to the storefront.', 'fluent-cart'),
2115 + ],
2116 + ];
2117 +
2118 + $sourceHeading = [
2119 + 'label' => __('Where colors come from', 'fluent-cart'),
2120 + 'note' => __('Storefront colors cascade from a small set of globals, so changing one here updates every page that uses it.', 'fluent-cart'),
2121 + ];
2122 +
2123 + return [
2124 + 'title' => __('Appearance', 'fluent-cart'),
2125 + 'show_title' => false,
2126 + 'type' => 'section',
2127 + 'wrapperClass' => 'fct-appearance-component-section',
2128 + 'disable_nesting' => true,
2129 + 'columns' => [
2130 + 'default' => 1,
2131 + 'md' => 1,
2132 + ],
2133 + 'schema' => [
2134 + 'appearance_component' => [
2135 + 'type' => 'component',
2136 + 'component' => 'StoreSettings/AppearanceComponent',
2137 + 'wrapperClass' => 'col-span-full',
2138 + 'label' => false,
2139 + // Rendered by the component itself, so the heading sits
2140 + // inside .fct-appearance-component with the controls it
2141 + // describes rather than as a sibling html field.
2142 + 'heading' => $sourceHeading,
2143 + 'source_options' => $sourceOptions,
2144 + 'custom_source' => ColorPalette::SOURCE_CUSTOM,
2145 + 'color_groups' => $this->getAppearanceColorGroups(),
2146 + 'preview' => $this->getAppearancePreviewData(),
2147 + ],
2148 + 'appearance_source' => [
2149 + 'type' => 'hidden',
2150 + 'value' => ColorPalette::SOURCE_DEFAULT,
2151 + ],
2152 + 'appearance_colors' => [
2153 + 'type' => 'hidden',
2154 + 'value' => (object)[],
2155 + ],
2156 + ],
2157 + ];
2158 + }
2159 +
2160 + /**
2161 + * What the storefront preview needs to paint itself.
2162 + *
2163 + * The preview is drawn from semantic roles rather than settings keys, so
2164 + * one mock card covers all three sources: `customize` reads the pickers the
2165 + * owner has filled in, and the other two are resolved here because their
2166 + * colors only exist server side — `inherit_from_theme` is mixed out of
2167 + * theme.json by ThemePalette and nothing about it reaches the browser.
2168 + *
2169 + * @return array
2170 + */
2171 + protected function getAppearancePreviewData(): array
2172 + {
2173 + // No colour is sent with a slot. appearance.scss already writes every
2174 + // preview surface as `var(--fct-pv-x, <fallback>)`, so the mock has the
2175 + // same single source of truth the storefront does: a slot the component
2176 + // cannot resolve simply goes undeclared and the stylesheet's fallback
2177 + // applies.
2178 + return [
2179 + 'slots' => $this->getAppearancePreviewSlots(),
2180 + 'theme_source' => ColorPalette::SOURCE_THEME,
2181 + 'theme_roles' => $this->getAppearanceThemeRoles(),
2182 + 'notes' => [
2183 + ColorPalette::SOURCE_THEME => $this->getAppearanceThemeNote(),
2184 + ColorPalette::SOURCE_CUSTOM => __('Set only the colors you want to change — anything you leave unset keeps FluentCart\'s own default.', 'fluent-cart'),
2185 + ],
2186 + ];
2187 + }
2188 +
2189 + /**
2190 + * Which custom property the preview paints each surface with.
2191 + *
2192 + * Keyed by settings key, not by role. Four separate globals claim the
2193 + * `accent` role — active text, the brand background and both active
2194 + * borders — so collapsing the preview to roles would let one picker drive
2195 + * surfaces the storefront keeps apart, and leave the others with no visible
2196 + * effect at all. `role` is carried only for theme inheritance, which
2197 + * genuinely is role-based: FrontendTheme::getThemeColors() gives every key
2198 + * sharing a role the same colour, so the preview collapsing there is the
2199 + * storefront's own behaviour rather than a shortcut.
2200 + *
2201 + * A slot with no `key` is a surface no global controls; it follows the
2202 + * role's default and cannot be edited.
2203 + *
2204 + * @return array
2205 + */
2206 + protected function getAppearancePreviewSlots(): array
2207 + {
2208 + return [
2209 + ['var' => 'surface', 'role' => 'surface', 'key' => 'card_bg_color'],
2210 + ['var' => 'surface-mute', 'role' => 'surface_mute'],
2211 + ['var' => 'surface-alt', 'role' => 'surface_alt', 'key' => 'secondary_bg_color'],
2212 + ['var' => 'text', 'role' => 'text', 'key' => 'primary_text_color'],
2213 + ['var' => 'text-muted', 'role' => 'text_muted', 'key' => 'secondary_text_color'],
2214 + ['var' => 'text-placeholder', 'role' => 'text_placeholder', 'key' => 'input_placeholder_text_color'],
2215 + ['var' => 'input-bg', 'role' => 'surface', 'key' => 'input_bg_color'],
2216 + ['var' => 'input-text', 'role' => 'text', 'key' => 'input_text_color'],
2217 + ['var' => 'input-disabled-bg', 'role' => 'surface_mute', 'key' => 'input_disabled_bg_color'],
2218 + ['var' => 'accent-text', 'role' => 'accent', 'key' => 'primary_active_text_color'],
2219 + ['var' => 'active-border', 'role' => 'accent', 'key' => 'active_border_color'],
2220 + ['var' => 'border', 'role' => 'border', 'key' => 'border_color'],
2221 + ['var' => 'divider', 'role' => 'divider', 'key' => 'divider_color'],
2222 + ['var' => 'button-bg', 'role' => 'button_bg', 'key' => 'btn_bg_color'],
2223 + ['var' => 'button-text', 'role' => 'button_text', 'key' => 'btn_text_color'],
2224 + ['var' => 'secondary-button-bg', 'role' => 'surface', 'key' => 'secondary_btn_bg_color'],
2225 + ['var' => 'secondary-button-text', 'role' => 'text', 'key' => 'secondary_btn_text_color'],
2226 + ['var' => 'secondary-button-border', 'role' => 'border', 'key' => 'secondary_btn_border_color'],
2227 + ['var' => 'secondary-button-hover-bg', 'role' => 'surface_mute', 'key' => 'secondary_btn_hover_bg_color'],
2228 + ];
2229 + }
2230 +
2231 + /**
2232 + * Why the theme preview may not be the whole truth.
2233 + *
2234 + * Themes such as Astra publish their palette as `var(--ast-global-color-0)`
2235 + * rather than as colors. FrontendTheme writes those references straight
2236 + * through and the browser resolves them on the storefront, but the property
2237 + * is not declared in wp-admin, so the preview cannot show them. Saying so
2238 + * beats showing FluentCart's colors and letting the owner believe that is
2239 + * what inheriting will look like.
2240 + *
2241 + * @return string Empty when the preview is faithful.
2242 + */
2243 + protected function getAppearanceThemeNote(): string
2244 + {
2245 + if (!ThemePalette::hasUsableSource()) {
2246 + return __('The active theme publishes nothing to inherit, so the storefront keeps FluentCart\'s own colors.', 'fluent-cart');
2247 + }
2248 +
2249 + // Only a colour with no hex anywhere is unpreviewable. A live
2250 + // reference with a hex fallback — what the theme-settings readers and
2251 + // Blocksy write — previews as that fallback.
2252 + foreach (ThemePalette::resolve() as $value) {
2253 + if ((string)$value !== '' && ThemePalette::measurable((string)$value) === '') {
2254 + 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');
2255 + }
2256 + }
2257 +
2258 + return '';
2259 + }
2260 +
2261 + /**
2262 + * The theme's colors for the preview, role by role.
2263 + *
2264 + * Only roles that resolved to a real color are returned — the preview
2265 + * falls back to each slot's own default for the rest.
2266 + *
2267 + * @return array Role => hex.
2268 + */
2269 + protected function getAppearanceThemeRoles(): array
2270 + {
2271 + // With nothing to inherit, FrontendTheme writes no declarations at all,
2272 + // so the storefront keeps FluentCart's colors — and so must the
2273 + // preview, or it would promise a change that never happens.
2274 + if (!ThemePalette::hasUsableSource()) {
2275 + return [];
2276 + }
2277 +
2278 + $roles = [];
2279 +
2280 + foreach (ThemePalette::resolve() as $role => $value) {
2281 + // A theme that publishes a bare `var(--x)` is written to the
2282 + // storefront verbatim and resolved by the browser there, but that
2283 + // property is not declared in wp-admin. Unpreviewable, so leave it
2284 + // out and let the slot fall back to its own default. A reference
2285 + // with a hex fallback (`var(--x, #hex)`) previews as that hex —
2286 + // the same colour the storefront measures it by.
2287 + $hex = ThemePalette::measurable((string)$value);
2288 +
2289 + if ($hex) {
2290 + $roles[$role] = $hex;
2291 + }
2292 + }
2293 +
2294 + return $roles;
2295 + }
2296 +
2297 + /**
2298 + * The colour knobs, grouped the way the registry groups them, as data the
2299 + * appearance component renders its pickers from.
2300 + *
2301 + * @return array
2302 + */
2303 + protected function getAppearanceColorGroups(): array
2304 + {
2305 + $groups = [];
2306 +
2307 + foreach (ColorPalette::groups() as $groupKey => $groupLabel) {
2308 + $globals = ColorPalette::globalsFor($groupKey);
2309 +
2310 + if (!$globals) {
2311 + continue;
2312 + }
2313 +
2314 + $fields = [];
2315 +
2316 + foreach ($globals as $key => $definition) {
2317 + $fields[] = [
2318 + 'key' => $key,
2319 + 'label' => Arr::get($definition, 'label', $key),
2320 + 'note' => $this->getAppearanceFieldNote($definition),
2321 + 'default' => (string)Arr::get($definition, 'default', ''),
2322 + ];
2323 + }
2324 +
2325 + $groups[] = [
2326 + 'key' => $groupKey,
2327 + 'label' => $groupLabel,
2328 + 'fields' => $fields,
2329 + ];
2330 + }
2331 +
2332 + return $groups;
2333 + }
2334 +
2335 + /**
2336 + * The hint under one colour picker: what it drives, and the value it
2337 + * falls back to when left empty.
2338 + *
2339 + * @param array $definition
2340 + * @return string
2341 + */
2342 + protected function getAppearanceFieldNote(array $definition): string
2343 + {
2344 + $usage = (string)Arr::get($definition, 'note', '');
2345 + $default = (string)Arr::get($definition, 'default', '');
2346 +
2347 + if ($default === '') {
2348 + return $usage;
2349 + }
2350 +
2351 + if ($usage === '') {
2352 + /* translators: %1$s: the hex colour FluentCart falls back to */
2353 + return sprintf(__('Default: %1$s', 'fluent-cart'), $default);
2354 + }
2355 +
2356 + /* translators: 1: what the colour is used for, 2: the hex colour FluentCart falls back to */
2357 + return sprintf(__('%1$s Default: %2$s', 'fluent-cart'), $usage, $default);
2358 + }
2359 +
2360 + /**
1533 2361 * Get theme colors as CSS variable string.
1534 2362 *
1535 2363 * @return string
1536 2364 */
@@ -1564,5 +2392,6 @@
1564 2392 return Str::endsWith($link, '/') ? $link : $link . '/';
1565 2393 }
1566 2394 return '';
1567 2395 }
2396 +
1568 2397 }