From 4cd358493fc446b6dbae7202cdf236ad45e5d1d6 Mon Sep 17 00:00:00 2001 From: MOH Date: Fri, 3 Oct 2025 23:53:09 +0200 Subject: [PATCH] Version 1.0.0 --- .DS_Store | Bin 0 -> 6148 bytes floatina/assets/css/floatina.css | 254 +++++++++++++++++++++++ floatina/assets/js/floatina.js | 294 +++++++++++++++++++++++++++ floatina/floatina.php | 53 +++++ floatina/includes/class-ajax.php | 21 ++ floatina/includes/class-assets.php | 41 ++++ floatina/includes/class-cpts.php | 31 +++ floatina/includes/class-settings.php | 90 ++++++++ floatina/includes/helpers.php | 11 + floatina/templates/loop-faq.php | 26 +++ floatina/templates/loop-news.php | 28 +++ floatina/templates/panel.php | 2 + 12 files changed, 851 insertions(+) create mode 100644 .DS_Store create mode 100644 floatina/assets/css/floatina.css create mode 100644 floatina/assets/js/floatina.js create mode 100644 floatina/floatina.php create mode 100644 floatina/includes/class-ajax.php create mode 100644 floatina/includes/class-assets.php create mode 100644 floatina/includes/class-cpts.php create mode 100644 floatina/includes/class-settings.php create mode 100644 floatina/includes/helpers.php create mode 100644 floatina/templates/loop-faq.php create mode 100644 floatina/templates/loop-news.php create mode 100644 floatina/templates/panel.php diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..8db905b0d0fc7708b47fda0f3854a96e20da8f00 GIT binary patch literal 6148 zcmeHKyG{c^3>-s>lPE|@xxc_4tfEk)<_AbXNJuBHf&MDKi$5ddhY-<0gG7VIl0Ca# z&uwmsa}2;XuMgM28o)qz#G8ku`MLYT&MIR>I-l`^Cp=@n8>jn4_4f(q4tT_f3Hz`7 z!#Evy=0EJx{iBR)nG}!$Qa}nw0V(i%1-$pthKoc+DIf);z^4NKeQ0#YUN|Slr-LCz z0OE@2Fs@^kAU01Bd*PhO49${COsdt0VM%AcRb4Nf6O#^$oB5o&*=jq(q5m-dpOUnb0#e{#DPXJZ?RLXgs@}SIIq$WNen node.dataset[dk] = dv); + else if (k === 'aria') Object.entries(attrs[k]).forEach(([ak, av]) => node.setAttribute(`aria-${ak}`, av)); + else if (k === 'role') node.setAttribute('role', attrs[k]); + else if (k === 'html') node.innerHTML = attrs[k]; // avoid unless needed + else node.setAttribute(k, attrs[k]); + } + (Array.isArray(children) ? children : [children]).forEach(ch => { + if (ch == null) return; + if (typeof ch === 'string') node.appendChild(document.createTextNode(ch)); + else node.appendChild(ch); + }); + return node; + } + + /** Create SVG element */ + function svg(tag, attrs = {}, children = []) { + const node = document.createElementNS(NS, tag); + for (const k in attrs) node.setAttribute(k, attrs[k]); + (Array.isArray(children) ? children : [children]).forEach(ch => { if (ch) node.appendChild(ch); }); + return node; + } + + /** Clear element children */ + function clear(node){ while (node.firstChild) node.removeChild(node.firstChild); } + + /** Text */ + function t(key){ return CFG.i18n[key] || key; } + + /* ========================== Icons (pure SVG DOM) ========================== */ + function icoBubble(){ + return svg('svg', {viewBox:'0 0 16 16', 'aria-hidden':'true'}, [ + svg('path', {d:'M8 1a7 7 0 00-5.6 11.2L1.5 15l2.9-.9A7 7 0 108 1z', fill:'currentColor'}) + ]); + } + function icoX(){ + return svg('svg', {viewBox:'0 0 16 16', 'aria-hidden':'true'}, [ + svg('path', {d:'M3.5 3.5l9 9m0-9l-9 9', stroke:'currentColor','stroke-width':'1.75','stroke-linecap':'round', fill:'none'}) + ]); + } + function icoHome(){ + return svg('svg', {viewBox:'0 0 16 16','aria-hidden':'true'}, [ + svg('path', {d:'M8 3l6 5v6h-4V9H6v5H2V8l6-5z', fill:'currentColor'}) + ]); + } + function icoMic(){ + return svg('svg', {viewBox:'0 0 16 16','aria-hidden':'true'}, [ + svg('path', {d:'M8 11a3 3 0 003-3V5a3 3 0 10-6 0v3a3 3 0 003 3z', fill:'currentColor'}), + svg('path', {d:'M3 8a5 5 0 0010 0H11a3 3 0 11-6 0H3z', fill:'currentColor'}), + svg('path', {d:'M7 13h2v2H7z', fill:'currentColor'}) + ]); + } + function icoChat(){ + return svg('svg', {viewBox:'0 0 16 16','aria-hidden':'true'}, [ + svg('path', {d:'M2 2h12v9H6l-3 3V2z', fill:'currentColor'}) + ]); + } + function icoNews(){ + return svg('svg', {viewBox:'0 0 16 16','aria-hidden':'true'}, [ + svg('path', {d:'M1 6l12-4v8L1 6zm12 2h2V4h-2v4zM5 9l1 4h2L7 9z', fill:'currentColor'}) + ]); + } + function icoHelp(){ + return svg('svg', {viewBox:'0 0 16 16','aria-hidden':'true'}, [ + svg('path', {d:'M8 1a7 7 0 107 7A7 7 0 008 1zm1 11H7v-2h2zm1.6-5.2a2.4 2.4 0 00-4.6 1h2a.8.8 0 111.5-.3c0 .3-.2.5-.6.7A2.5 2.5 0 007 10h2a3.5 3.5 0 001.6-3.2z', fill:'currentColor'}) + ]); + } + + /* ========================== View (refs) ========================== */ + const V = { fab:null, panel:null, content:null, close:null, tabs:{} }; + + /* ========================== Build UI ========================== */ + function buildFab(){ + const b = el('button', {id:'floatina-fab', 'aria':{label:'Open Floatina'}}); + b.appendChild(icoBubble()); + if (CFG.position === 'left') b.classList.add('is-left'); else b.classList.add('is-right'); + if (CFG.buttonColor){ + b.dataset.color = '1'; + b.style.setProperty('--floatina-fab-color', CFG.buttonColor); + } + document.body.appendChild(b); + V.fab = b; + } + + function buildPanel(){ + const wrap = el('div', {id:'floatina-panel'}); + if (CFG.position === 'left') wrap.classList.add('is-left'); else wrap.classList.add('is-right'); + + // Header + const header = el('div', {class:'floatina-header'}); + const title = el('div', {class:'floatina-title'}, CFG.brandName); + const btnX = el('div', {class:'floatina-close', 'aria':{label:'Close'}}, icoX()); + header.appendChild(title); + header.appendChild(btnX); + + // Body + const body = el('div', {class:'floatina-body'}); + const content= el('div', {class:'floatina-content', id:'fi-content'}); + const nav = el('div', {class:'floatina-nav', role:'tablist'}); + + // Tabs + V.tabs.home = buildTab('home', icoHome(), t('home')); + V.tabs.voice = buildTab('voice', icoMic(), t('voice')); + V.tabs.text = buildTab('text', icoChat(), t('text')); + V.tabs.news = buildTab('news', icoNews(), t('news')); + V.tabs.help = buildTab('help', icoHelp(), t('help')); + nav.append(V.tabs.home, V.tabs.voice, V.tabs.text, V.tabs.news, V.tabs.help); + + body.appendChild(content); + body.appendChild(nav); + + wrap.append(header, body); + document.body.appendChild(wrap); + + V.panel = wrap; + V.content = content; + V.close = btnX; + } + + function buildTab(id, iconNode, label){ + const btn = el('button', {class:'floatina-tab', role:'tab', dataset:{tab:id}, 'aria':{controls:`panel-${id}`}}); + const icoWrap = el('span', {class:'fi-ico'}, iconNode); + const lbl = el('span', {class:'fi-lbl'}, label); + btn.append(icoWrap, lbl); + return btn; + } + + /* ========================== Actions ========================== */ + function open(){ + S.open = true; + V.panel.classList.add('is-open'); + V.fab.setAttribute('aria-expanded','true'); + V.fab.setAttribute('aria-label','Close Floatina'); + swapFabIcon(true); + } + function close(){ + S.open = false; + V.panel.classList.remove('is-open'); + V.fab.setAttribute('aria-expanded','false'); + V.fab.setAttribute('aria-label','Open Floatina'); + swapFabIcon(false); + } + function toggle(){ S.open ? close() : open(); } + + function swapFabIcon(showX){ + clear(V.fab); + V.fab.appendChild(showX ? icoX() : icoBubble()); + } + + function setActiveTab(id){ + Object.values(V.tabs).forEach(b => b.classList.remove('is-active')); + if (V.tabs[id]) V.tabs[id].classList.add('is-active'); + } + + /* ========================== Renderers ========================== */ + function render(tab){ + S.tab = tab; + setActiveTab(tab); + if (tab === 'home') return renderHome(); + if (tab === 'voice') return renderTarget('voice', CFG.voiceURL, CFG.voiceMode); + if (tab === 'text') return renderTarget('text', CFG.textURL, CFG.textMode); + if (tab === 'news') return renderPartial(CFG.ajaxNews); + if (tab === 'help') return renderPartial(CFG.ajaxFAQ); + } + + function renderHome(){ + clear(V.content); + + const card1 = el('div', {class:'fi-card fi-gradient'}); + card1.append(el('h4', {}, 'Welcome'), el('div', {class:'fi-muted'}, 'Quick links below.')); + + const card2 = el('div', {class:'fi-card'}); + const actions = el('div', {class:'fi-actions'}); + actions.append( + el('a', {class:'fi-btn', href:'/contact'}, 'Contact'), + el('a', {class:'fi-btn', href:'/about'}, 'About'), + el('a', {class:'fi-btn', href:'/support'}, 'Support') + ); + card2.append(actions); + + V.content.append(card1, card2); + } + + function renderTarget(kind, url, mode){ + clear(V.content); + + if (!url){ + const card = el('div', {class:'fi-card'}); + card.append(el('h4', {}, `Missing ${kind} URL`), el('div', {class:'fi-muted'}, 'Set it in Floatina settings.')); + V.content.append(card); + return; + } + + if (mode === 'popup'){ + const card = el('div', {class:'fi-card'}); + card.append( + el('h4', {}, kind === 'voice' ? 'Voice chat' : 'Text chat'), + el('div', {class:'fi-muted'}, 'Opens in a new window.'), + el('div', {class:'fi-actions'}, el('a', {class:'fi-btn', href:url, target:'_blank', rel:'noopener'}, t('open_new'))) + ); + // optional auto-open once per session + window.open(url, '_blank', 'noopener'); + V.content.append(card); + return; + } + + // iframe mode + fallback button + const wrap = el('div', {class:'floatina-iframe'}); + const iframe = el('iframe', {src:url, loading:'lazy', title:`${kind}`}); + wrap.append(iframe); + + const fb = el('div', {class:'fi-card'}, el('div', {class:'fi-actions'}, + el('a', {class:'fi-btn', href:url, target:'_blank', rel:'noopener'}, t('open_new')) + )); + + V.content.append(wrap, fb); + } + + function renderPartial(endpoint){ + clear(V.content); + const loader = el('div', {class:'fi-card'}, el('h4', {}, 'Loading…')); + V.content.append(loader); + if (!endpoint){ + clear(V.content); + const err = el('div', {class:'fi-card'}); + err.append(el('h4', {}, 'Error'), el('div', {class:'fi-muted'}, 'Endpoint missing.')); + V.content.append(err); + return; + } + fetch(endpoint, {credentials:'same-origin'}) + .then(r => r.text()) + .then(html => { V.content.innerHTML = html; }) + .catch(() => { + clear(V.content); + const err = el('div', {class:'fi-card'}); + err.append(el('h4', {}, 'Error'), el('div', {class:'fi-muted'}, 'Failed to load content.')); + V.content.append(err); + }); + } + + /* ========================== Bindings ========================== */ + function bind(){ + V.fab.addEventListener('click', toggle); + V.close.addEventListener('click', close); + + Object.values(V.tabs).forEach(btn => { + btn.addEventListener('click', () => render(btn.dataset.tab)); + }); + + document.addEventListener('keydown', e => { if (S.open && e.key === 'Escape') close(); }); + } + + /* ========================== Init ========================== */ + function init(){ + buildFab(); + buildPanel(); + bind(); + render('home'); + } + + document.addEventListener('DOMContentLoaded', init); +})(); diff --git a/floatina/floatina.php b/floatina/floatina.php new file mode 100644 index 0000000..94111e6 --- /dev/null +++ b/floatina/floatina.php @@ -0,0 +1,53 @@ + floatina_opt('brand_name', 'Assistant'), + 'button_color' => floatina_opt('button_color', '#1e88ff'), + 'position' => floatina_opt('position', 'right'), + 'voice_iframe' => floatina_opt('voice_iframe', ''), + 'text_iframe' => floatina_opt('text_iframe', ''), + 'voice_mode' => floatina_opt('voice_mode', 'iframe'), // iframe|popup + 'text_mode' => floatina_opt('text_mode', 'iframe'), + 'strings' => ['home' => 'Home', 'voice' => 'Voice', 'text' => 'Text', 'news' => 'News', 'help' => 'Help', 'open_new' => 'Open in new tab'], + 'ajax' => [ + 'news' => add_query_arg(['action' => 'floatina_partial_news'], admin_url('admin-ajax.php')), + 'faq' => add_query_arg(['action' => 'floatina_partial_faq'], admin_url('admin-ajax.php')), + ], + ]; + wp_add_inline_script('floatina', 'window.FLOATINA=' . wp_json_encode($data) . ';', 'before'); + + // Inline CSS variables from settings + $vars = [ + '--floatina-fab:' . floatina_opt('button_color', '#1e88ff'), + '--floatina-accent:' . floatina_opt('accent_color', '#1e66ff'), + '--floatina-border:' . floatina_opt('border_color', '#e6e8eb'), + '--floatina-nav-active:' . floatina_opt('nav_active_bg', '#eef5ff'), + '--floatina-grad-start:' . floatina_opt('grad_start', '#bfe0ff'), + '--floatina-grad-end:' . floatina_opt('grad_end', '#eaf4ff'), + ]; + $css = ':root{' . implode(';', array_map('esc_html', $vars)) . ';}'; + wp_add_inline_style('floatina', $css); +}); diff --git a/floatina/includes/class-cpts.php b/floatina/includes/class-cpts.php new file mode 100644 index 0000000..dfc560a --- /dev/null +++ b/floatina/includes/class-cpts.php @@ -0,0 +1,31 @@ + 'Floatina News', + 'public' => true, + 'show_in_menu' => $parent, + 'menu_icon' => 'dashicons-megaphone', + 'supports' => ['title', 'editor', 'thumbnail', 'excerpt'], + 'has_archive' => false, + 'show_in_rest' => true, + ]); + + register_post_type('floatina_faq', [ + 'label' => 'Floatina FAQ', + 'public' => true, + 'show_in_menu' => $parent, + 'menu_icon' => 'dashicons-editor-help', + 'supports' => ['title', 'editor'], + 'has_archive' => false, + 'show_in_rest' => true, + ]); +} +add_action('init', 'floatina_register_cpts'); diff --git a/floatina/includes/class-settings.php b/floatina/includes/class-settings.php new file mode 100644 index 0000000..55cbafa --- /dev/null +++ b/floatina/includes/class-settings.php @@ -0,0 +1,90 @@ + 'brand_name', 'placeholder' => 'Assistant']); + + /* ---------- Chat targets ---------- */ + add_settings_section('floatina_chat', 'Chat targets', '__return_false', 'floatina'); + add_settings_field('voice_iframe', 'Voice URL', 'floatina_field_text', 'floatina', 'floatina_chat', ['key' => 'voice_iframe', 'placeholder' => 'https://...']); + add_settings_field('voice_mode', 'Voice mode', 'floatina_field_mode', 'floatina', 'floatina_chat', ['key' => 'voice_mode']); + add_settings_field('text_iframe', 'Text URL', 'floatina_field_text', 'floatina', 'floatina_chat', ['key' => 'text_iframe', 'placeholder' => 'https://...']); + add_settings_field('text_mode', 'Text mode', 'floatina_field_mode', 'floatina', 'floatina_chat', ['key' => 'text_mode']); + + /* ---------- Colors ---------- */ + add_settings_section('floatina_colors', 'Colors', '__return_false', 'floatina'); + add_settings_field('button_color', 'FAB color', 'floatina_field_text', 'floatina', 'floatina_colors', ['key' => 'button_color', 'placeholder' => '#1e88ff']); + add_settings_field('accent_color', 'Accent color', 'floatina_field_text', 'floatina', 'floatina_colors', ['key' => 'accent_color', 'placeholder' => '#1e66ff']); + add_settings_field('border_color', 'Border color', 'floatina_field_text', 'floatina', 'floatina_colors', ['key' => 'border_color', 'placeholder' => '#e6e8eb']); + add_settings_field('nav_active_bg', 'Tab active background', 'floatina_field_text', 'floatina', 'floatina_colors', ['key' => 'nav_active_bg', 'placeholder' => '#eef5ff']); + add_settings_field('grad_start', 'Header gradient start', 'floatina_field_text', 'floatina', 'floatina_colors', ['key' => 'grad_start', 'placeholder' => '#bfe0ff']); + add_settings_field('grad_end', 'Header gradient end', 'floatina_field_text', 'floatina', 'floatina_colors', ['key' => 'grad_end', 'placeholder' => '#eaf4ff']); +}); + +function floatina_field_text($args) +{ + $opts = get_option('floatina_settings', []); + $key = $args['key']; + $val = isset($opts[$key]) ? esc_attr($opts[$key]) : ''; + $ph = isset($args['placeholder']) ? esc_attr($args['placeholder']) : ''; + echo ''; +} + +function floatina_field_position() +{ + $v = floatina_opt('position', 'right'); ?> + +    + +

Choose bottom-right or bottom-left placement.

+ + +

Use Popup/New tab if the target site blocks iframes.

+ +
+

Floatina

+
+ +
+
+

Usage: Renders site-wide. Optional shortcode: [floatina].

+

Content: add items in Floatina News and Floatina FAQ. Typography inherits your theme.

+
+ 'floatina_faq', + 'post_status' => 'publish', + 'posts_per_page' => 20, + 'orderby' => ['menu_order' => 'ASC', 'date' => 'ASC'], +]); + +if ($q->have_posts()): + while ($q->have_posts()): $q->the_post(); ?> +
+

+
+
+ +
+

No FAQs

+
Add posts in Floatina FAQ.
+
+ \ No newline at end of file diff --git a/floatina/templates/loop-news.php b/floatina/templates/loop-news.php new file mode 100644 index 0000000..208362b --- /dev/null +++ b/floatina/templates/loop-news.php @@ -0,0 +1,28 @@ + 'floatina_news', + 'post_status' => 'publish', + 'posts_per_page' => 8, + 'orderby' => 'date', + 'order' => 'DESC', +]); + +if ($q->have_posts()): + while ($q->have_posts()): $q->the_post(); ?> + + +
+

No news

+
Add posts in Floatina News.
+
+ \ No newline at end of file diff --git a/floatina/templates/panel.php b/floatina/templates/panel.php new file mode 100644 index 0000000..a6503fe --- /dev/null +++ b/floatina/templates/panel.php @@ -0,0 +1,2 @@ + +
\ No newline at end of file