| @@ -53,10 +53,14 @@ | ||
| 53 | 53 | if ($product_id <= 0) { |
| 54 | 54 | return ''; |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | - $wishlist_id = $args['wishlist_id'] ? sanitize_title($args['wishlist_id']) : $this->service->get_active_wishlist_id(); | |
| 57 | + if (function_exists('wc_get_product') && !wc_get_product($product_id)) { | |
| 58 | + return ''; | |
| 59 | + } | |
| 58 | 60 | |
| 61 | + $wishlist_id = !empty($args['wishlist_id']) ? sanitize_title((string) $args['wishlist_id']) : $this->service->get_active_wishlist_id(); | |
| 62 | + | |
| 59 | 63 | $in_list = $this->service->has_item($product_id, $variation_id, $wishlist_id); |
| 60 | 64 | $label_default = $args['label_default'] ?? Wishlist_Settings::get('button_add_text'); |
| 61 | 65 | $label_added = $args['label_added'] ?? Wishlist_Settings::get('button_added_text'); |
| 62 | 66 | $label = $in_list ? $label_added : $label_default; |
| @@ -64,23 +68,127 @@ | ||
| 64 | 68 | |
| 65 | 69 | $display_mode = $args['display_mode'] ?? Wishlist_Settings::get('button_display_mode', 'icon_text'); |
| 66 | 70 | $show_icon = in_array($display_mode, ['icon', 'icon_text'], true); |
| 67 | 71 | $show_label = $display_mode === 'icon_text'; |
| 68 | - $icon_class = $args['icon_class'] ?? Wishlist_Settings::get('icon_choice', 'eicon-heart'); | |
| 72 | + $icon_class = (string) ($args['icon_class'] ?? Wishlist_Settings::get('icon_choice', 'fas fa-heart')); | |
| 69 | 73 | |
| 70 | 74 | $classes = [ |
| 71 | 75 | 'king-addons-wishlist-button', |
| 72 | 76 | $state_class, |
| 73 | - sanitize_html_class($args['class']), | |
| 77 | + sanitize_html_class((string) $args['class']), | |
| 74 | 78 | ]; |
| 75 | 79 | |
| 76 | - $icon_html = $show_icon ? '<span class="king-addons-wishlist-button__icon" aria-hidden="true"><i class="' . esc_attr($icon_class) . '"></i></span>' : ''; | |
| 80 | + $icon_html = $show_icon ? $this->render_icon_html($icon_class) : ''; | |
| 77 | 81 | $label_html = $show_label ? '<span class="king-addons-wishlist-button__label">' . esc_html($label) . '</span>' : ''; |
| 78 | 82 | |
| 79 | - return '<button type="button" class="' . esc_attr(implode(' ', array_filter($classes))) . '" data-product-id="' . esc_attr($product_id) . '" data-variation-id="' . esc_attr($variation_id) . '" data-wishlist-id="' . esc_attr($wishlist_id) . '" data-state="' . esc_attr($in_list ? 'added' : 'default') . '" aria-pressed="' . ($in_list ? 'true' : 'false') . '">' . $icon_html . $label_html . '</button>'; | |
| 83 | + return '<button type="button" class="' . esc_attr(implode(' ', array_filter($classes))) . '" data-product-id="' . esc_attr((string) $product_id) . '" data-variation-id="' . esc_attr((string) $variation_id) . '" data-wishlist-id="' . esc_attr((string) $wishlist_id) . '" data-state="' . esc_attr($in_list ? 'added' : 'default') . '" data-display-mode="' . esc_attr((string) $display_mode) . '" data-label-default="' . esc_attr((string) $label_default) . '" data-label-added="' . esc_attr((string) $label_added) . '" aria-pressed="' . ($in_list ? 'true' : 'false') . '" aria-label="' . esc_attr((string) $label) . '">' . $icon_html . $label_html . '</button>'; | |
| 80 | 84 | } |
| 81 | 85 | |
| 82 | 86 | /** |
| 87 | + * Built-in heart SVG used by the button and floating icon. | |
| 88 | + * | |
| 89 | + * @param string $class Extra SVG class. | |
| 90 | + * @return string SVG markup. | |
| 91 | + */ | |
| 92 | + public static function heart_svg(string $class = 'king-addons-wishlist-button__heart'): string | |
| 93 | + { | |
| 94 | + return '<svg class="' . esc_attr($class) . '" viewBox="0 0 24 24" width="16" height="16" focusable="false" aria-hidden="true">' | |
| 95 | + . '<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 0 0 0-7.78z"/>' | |
| 96 | + . '</svg>'; | |
| 97 | + } | |
| 98 | + | |
| 99 | + /** | |
| 100 | + * Render the button icon: built-in SVG heart, or a custom font class. | |
| 101 | + * | |
| 102 | + * @param string $icon_class Icon class from settings/widget. | |
| 103 | + * @return string Icon markup. | |
| 104 | + */ | |
| 105 | + private function render_icon_html(string $icon_class): string | |
| 106 | + { | |
| 107 | + if ($this->uses_builtin_heart($icon_class)) { | |
| 108 | + return '<span class="king-addons-wishlist-button__icon" aria-hidden="true">' . self::heart_svg() . '</span>'; | |
| 109 | + } | |
| 110 | + | |
| 111 | + $classes = preg_split('/\s+/', trim($icon_class), -1, PREG_SPLIT_NO_EMPTY); | |
| 112 | + $safe = implode(' ', array_map('sanitize_html_class', is_array($classes) ? $classes : [])); | |
| 113 | + if ($safe === '') { | |
| 114 | + return $this->render_icon_html('fas fa-heart'); | |
| 115 | + } | |
| 116 | + | |
| 117 | + return '<span class="king-addons-wishlist-button__icon" aria-hidden="true"><i class="' . esc_attr($safe) . '"></i></span>'; | |
| 118 | + } | |
| 119 | + | |
| 120 | + /** | |
| 121 | + * Default heart classes use the built-in SVG instead of a webfont. | |
| 122 | + * | |
| 123 | + * @param string $icon_class Icon class from settings/widget. | |
| 124 | + * @return bool Whether to render the SVG heart. | |
| 125 | + */ | |
| 126 | + private function uses_builtin_heart(string $icon_class): bool | |
| 127 | + { | |
| 128 | + $normalized = strtolower(trim(preg_replace('/\s+/', ' ', $icon_class) ?? $icon_class)); | |
| 129 | + | |
| 130 | + return in_array($normalized, [ | |
| 131 | + '', | |
| 132 | + 'fas fa-heart', | |
| 133 | + 'fa fa-heart', | |
| 134 | + 'far fa-heart', | |
| 135 | + 'fa-solid fa-heart', | |
| 136 | + 'fa-regular fa-heart', | |
| 137 | + 'eicon-heart', | |
| 138 | + 'eicon-heart-o', | |
| 139 | + ], true); | |
| 140 | + } | |
| 141 | + | |
| 142 | + /** | |
| 143 | + * Sanitize renderer HTML for echo contexts that still want kses. | |
| 144 | + * | |
| 145 | + * @param string $html Button or similar markup. | |
| 146 | + * @return string Sanitized HTML. | |
| 147 | + */ | |
| 148 | + public static function kses(string $html): string | |
| 149 | + { | |
| 150 | + $allowed = [ | |
| 151 | + 'button' => [ | |
| 152 | + 'type' => true, | |
| 153 | + 'class' => true, | |
| 154 | + 'disabled' => true, | |
| 155 | + 'aria-pressed' => true, | |
| 156 | + 'aria-label' => true, | |
| 157 | + ], | |
| 158 | + 'span' => [ | |
| 159 | + 'class' => true, | |
| 160 | + 'aria-hidden' => true, | |
| 161 | + ], | |
| 162 | + 'i' => [ | |
| 163 | + 'class' => true, | |
| 164 | + 'aria-hidden' => true, | |
| 165 | + ], | |
| 166 | + 'svg' => [ | |
| 167 | + 'class' => true, | |
| 168 | + 'viewbox' => true, | |
| 169 | + 'width' => true, | |
| 170 | + 'height' => true, | |
| 171 | + 'focusable' => true, | |
| 172 | + 'aria-hidden' => true, | |
| 173 | + ], | |
| 174 | + 'path' => [ | |
| 175 | + 'd' => true, | |
| 176 | + ], | |
| 177 | + ]; | |
| 178 | + | |
| 179 | + foreach ($allowed as $tag => $attrs) { | |
| 180 | + $allowed[$tag] = array_merge($attrs, [ | |
| 181 | + 'class' => true, | |
| 182 | + 'aria-hidden' => true, | |
| 183 | + 'data-*' => true, | |
| 184 | + ]); | |
| 185 | + } | |
| 186 | + | |
| 187 | + return wp_kses($html, $allowed); | |
| 188 | + } | |
| 189 | + | |
| 190 | + /** | |
| 83 | 191 | * Render wishlist counter element. |
| 84 | 192 | * |
| 85 | 193 | * @param array<string, mixed> $args Rendering arguments. |
| 86 | 194 | * @return string Rendered HTML. |
| @@ -90,9 +198,9 @@ | ||
| 90 | 198 | if (!Wishlist_Settings::is_enabled()) { |
| 91 | 199 | return ''; |
| 92 | 200 | } |
| 93 | 201 | |
| 94 | - $wishlist_id = $args['wishlist_id'] ? sanitize_title($args['wishlist_id']) : $this->service->get_active_wishlist_id(); | |
| 202 | + $wishlist_id = !empty($args['wishlist_id']) ? sanitize_title((string) $args['wishlist_id']) : $this->service->get_active_wishlist_id(); | |
| 95 | 203 | $count = $this->service->get_count($wishlist_id); |
| 96 | 204 | |
| 97 | 205 | $classes = [ |
| 98 | 206 | 'king-addons-wishlist-counter', |