| @@ -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 → 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 → 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 | } |
| @@ -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 | */ |
| @@ -1564,5 +2472,6 @@ | ||
| 1564 | 2472 | return Str::endsWith($link, '/') ? $link : $link . '/'; |
| 1565 | 2473 | } |
| 1566 | 2474 | return ''; |
| 1567 | 2475 | } |
| 2476 | + | |
| 1568 | 2477 | } |