| @@ -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 → 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 → 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 | } |