|null */ private static ?array $icons = null; public function __construct(Settings $settings, ?Assets $assets = null) { $this->settings = $settings; $this->assets = $assets; } public function register(): void { // Before WordPress prints the footer scripts/styles (priority 20) so the enqueued entry still lands. add_action('wp_footer', [$this, 'print_buttons'], 8); } /** @return array type => inline SVG (shared with the editor through floating-icons.json) */ public static function icons(): array { if (self::$icons === null) { $file = __DIR__ . '/floating-icons.json'; $decoded = is_readable($file) ? wp_json_file_decode($file, ['associative' => true]) : null; self::$icons = is_array($decoded) ? array_map('strval', $decoded) : []; } return self::$icons; } /** Normalizes the saved list (same rules as normalizeFloatingButton() in src/editor/core/site.ts). */ public static function sanitize($input): array { $out = []; foreach (is_array($input) ? array_values($input) : [] as $i => $raw) { if (!is_array($raw)) { continue; } $type = self::pick($raw['type'] ?? '', self::TYPES, 'whatsapp'); $id = isset($raw['_id']) && is_string($raw['_id']) ? preg_replace('/[^a-zA-Z0-9_\-]/', '', $raw['_id']) : ''; $icon = null; if (isset($raw['icon']) && is_array($raw['icon']) && (!empty($raw['icon']['value']) || !empty($raw['icon']['svg']))) { $icon = [ 'library' => sanitize_text_field((string) ($raw['icon']['library'] ?? '')), 'value' => sanitize_text_field((string) ($raw['icon']['value'] ?? '')), ]; if (!empty($raw['icon']['svg']) && is_string($raw['icon']['svg'])) { $icon['svg'] = Sanitizer::clean_svg($raw['icon']['svg']); } } $pages = []; foreach ((array) ($raw['pages'] ?? []) as $p) { if (is_numeric($p) && (int) $p > 0 && !in_array((int) $p, $pages, true)) { $pages[] = (int) $p; } } $item = [ '_id' => $id !== '' ? $id : 'fb' . $i, 'type' => $type, 'value' => sanitize_text_field(self::str($raw['value'] ?? '')), 'message' => self::clean_message(self::str($raw['message'] ?? '')), 'label' => sanitize_text_field(self::str($raw['label'] ?? '')), 'labelMode' => self::pick($raw['labelMode'] ?? '', self::LABEL_MODES, 'tooltip'), 'newTab' => isset($raw['newTab']) ? (bool) $raw['newTab'] : in_array($type, ['whatsapp', 'link'], true), 'bg' => self::color($raw['bg'] ?? ''), 'color' => self::color($raw['color'] ?? ''), 'bgHover' => self::color($raw['bgHover'] ?? ''), 'colorHover' => self::color($raw['colorHover'] ?? ''), 'size' => self::num($raw['size'] ?? null, 56, 24, 160), 'radius' => self::num($raw['radius'] ?? null, 60, 0, 100), 'position' => self::pick($raw['position'] ?? '', self::POSITIONS, 'bottom-end'), 'offsetX' => self::num($raw['offsetX'] ?? null, 20, 0, 400), 'offsetY' => self::num($raw['offsetY'] ?? null, 20, 0, 400), 'showDesktop' => isset($raw['showDesktop']) ? (bool) $raw['showDesktop'] : true, 'showTablet' => isset($raw['showTablet']) ? (bool) $raw['showTablet'] : true, 'showMobile' => isset($raw['showMobile']) ? (bool) $raw['showMobile'] : true, 'display' => self::pick($raw['display'] ?? '', self::DISPLAYS, 'all'), 'pages' => $pages, 'animation' => self::pick($raw['animation'] ?? '', self::ANIMATIONS, 'fade'), ]; if ($icon !== null) { $item['icon'] = $icon; } $out[] = $item; } return $out; } public function buttons(): array { $global = $this->settings->global(); return self::sanitize($global['floatingButtons'] ?? []); } /** Link target ('' = nothing usable, the button is skipped). Mirrors floatingButtonHref() in site.ts. */ public static function href(array $b): string { $value = trim((string) ($b['value'] ?? '')); switch ($b['type'] ?? '') { case 'whatsapp': $digits = preg_replace('/\D+/', '', $value) ?? ''; if ($digits === '') { return ''; } $text = trim((string) ($b['message'] ?? '')); return 'https://wa.me/' . $digits . ($text !== '' ? '?text=' . rawurlencode($text) : ''); case 'call': $digits = preg_replace('/\D+/', '', $value) ?? ''; return $digits === '' ? '' : 'tel:' . (str_starts_with($value, '+') ? '+' : '') . $digits; case 'email': return preg_match('/^[^\s@<>"]+@[^\s@<>"]+$/', $value) ? 'mailto:' . $value : ''; case 'link': if ($value === '' || preg_match('/^(javascript|vbscript|data):/i', $value)) { return ''; } return $value; case 'scroll_top': return '#'; } return ''; } /** Accessible name / tooltip text. */ public static function name(array $b): string { $label = trim((string) ($b['label'] ?? '')); if ($label !== '') { return $label; } switch ($b['type'] ?? '') { case 'whatsapp': return __('Chat on WhatsApp', 'zadex'); case 'call': return __('Call us', 'zadex'); case 'email': return __('Email us', 'zadex'); case 'scroll_top': return __('Back to top', 'zadex'); } return __('Open link', 'zadex'); } /** Display conditions for the current request. Mirrors floatingButtonVisible() in site.ts. */ public static function matches(array $b, int $page_id, bool $logged_in): bool { $pages = array_map('intval', (array) ($b['pages'] ?? [])); switch ($b['display'] ?? 'all') { case 'only': return $page_id > 0 && in_array($page_id, $pages, true); case 'except': return !($page_id > 0 && in_array($page_id, $pages, true)); case 'logged_in': return $logged_in; case 'logged_out': return !$logged_in; } return true; } /** Buttons for this request, grouped per corner (the stack takes the first button's id for its offsets). */ public static function stacks(array $buttons, int $page_id, bool $logged_in): array { $stacks = []; foreach ($buttons as $b) { if (self::href($b) === '' || !self::matches($b, $page_id, $logged_in)) { continue; } $pos = $b['position']; if (!isset($stacks[$pos])) { $stacks[$pos] = ['position' => $pos, 'id' => $b['_id'], 'buttons' => []]; } $stacks[$pos]['buttons'][] = $b; } return array_values($stacks); } /** HTML of the floating layer ('' when nothing is shown). */ public function render(int $page_id, bool $logged_in): string { $stacks = self::stacks($this->buttons(), $page_id, $logged_in); if (!$stacks) { return ''; } $html = '
'; foreach ($stacks as $stack) { $html .= '
'; foreach ($stack['buttons'] as $b) { $html .= self::button_html($b); } $html .= '
'; } return $html . '
'; } public static function button_html(array $b): string { $href = self::href($b); $name = self::name($b); $classes = ['zx-fab', 'zx-fab--' . $b['_id'], 'zx-fab--type-' . $b['type'], 'zx-fab--label-' . $b['labelMode']]; if ($b['animation'] !== 'none') { $classes[] = 'zx-fab--anim-' . $b['animation']; } $attrs = ' class="' . esc_attr(implode(' ', $classes)) . '" href="' . esc_url($href, ['http', 'https', 'mailto', 'tel']) . '"'; if ($b['type'] === 'scroll_top') { $attrs .= ' data-zx-scroll-top'; } elseif (!empty($b['newTab']) && in_array($b['type'], ['whatsapp', 'link'], true)) { $attrs .= ' target="_blank" rel="noopener"'; } $attrs .= ' aria-label="' . esc_attr($name) . '"'; $label = $b['labelMode'] !== 'none' ? '' . esc_html($name) . '' : ''; return '' . $label . ''; } public static function icon_html(array $b): string { $icon = isset($b['icon']) && is_array($b['icon']) ? $b['icon'] : []; if (!empty($icon['svg'])) { return Sanitizer::clean_svg((string) $icon['svg']); } if (!empty($icon['value'])) { return ''; } return self::icons()[$b['type']] ?? ''; } /** Whether any printed button uses an icon-font glyph (the icon font entry is then enqueued). */ public static function uses_icon_font(array $stacks): bool { foreach ($stacks as $stack) { foreach ($stack['buttons'] as $b) { if (!empty($b['icon']['value']) && empty($b['icon']['svg'])) { return true; } } } return false; } /** wp_footer: prints the layer on the live site (never in the editor, the canvas draws it itself). */ public function print_buttons(): void { if (is_admin() || EditorLoader::mode() !== '') { return; } $page_id = function_exists('is_singular') && is_singular() ? (int) get_queried_object_id() : 0; $logged_in = is_user_logged_in(); $stacks = self::stacks($this->buttons(), $page_id, $logged_in); if (!$stacks) { return; } if ($this->assets) { $this->assets->enqueue_entry(Assets::ENTRY_FLOATING, 'zadex-floating'); if (self::uses_icon_font($stacks)) { $this->assets->enqueue_entry(Assets::ENTRY_ICONS, 'zadex-icons'); } } echo wp_kses($this->render($page_id, $logged_in), \Zadex\Frontend\Kses::allowed_html()); } private static function pick($value, array $list, string $fallback): string { return is_string($value) && in_array($value, $list, true) ? $value : $fallback; } private static function num($value, int $fallback, int $min, int $max): int { return is_numeric($value) ? max($min, min($max, (int) round((float) $value))) : $fallback; } /** * Plain text. The REST layer ran the generic sanitizer (wp_kses) first, which turns `&` into `&`: * decode it back, every output is escaped again (esc_html / esc_attr / esc_url / rawurlencode). */ private static function str($value): string { if (!is_string($value) && !is_int($value) && !is_float($value)) { return ''; } return html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); } /** Keeps line breaks of the WhatsApp message, strips tags. */ private static function clean_message(string $message): string { $lines = array_map(static fn(string $l): string => sanitize_text_field($l), preg_split('/\r\n|\r|\n/', $message) ?: []); return trim(implode("\n", $lines)); } /** Same whitelist as the editor (hex, rgb/hsl functions, var(--x), keywords). */ public static function color($value): string { if (!is_string($value)) { return ''; } $value = trim($value); return preg_match('/^(#[0-9a-f]{3,8}|(rgb|rgba|hsl|hsla)\([0-9.,%\s\/+\-]+\)|var\(--[a-z0-9_\-]+\)|transparent|currentColor|[a-z]+)$/i', $value) ? $value : ''; } }