Version 1.0.0
This commit is contained in:
commit
4cd358493f
254
floatina/assets/css/floatina.css
Normal file
254
floatina/assets/css/floatina.css
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
/************************************************************************************
|
||||||
|
* Floatina – core layout
|
||||||
|
************************************************************************************/
|
||||||
|
/* Inherit typography from theme */
|
||||||
|
#floatina-panel,
|
||||||
|
#floatina-panel * {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS vars for theming via settings */
|
||||||
|
:root {
|
||||||
|
--floatina-offset: 16px;
|
||||||
|
--floatina-width: min(92vw, 460px);
|
||||||
|
--floatina-height: 70vh;
|
||||||
|
--floatina-radius: 16px;
|
||||||
|
--floatina-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
|
||||||
|
--floatina-border: #e6e8eb;
|
||||||
|
--floatina-bg: #ffffff;
|
||||||
|
--floatina-soft: #f7f8fa;
|
||||||
|
--floatina-fab: #1e88ff;
|
||||||
|
--floatina-accent: #1e66ff;
|
||||||
|
--floatina-grad-start: #bfe0ff;
|
||||||
|
--floatina-grad-end: #eaf4ff;
|
||||||
|
--floatina-nav-active: #eef5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FAB */
|
||||||
|
#floatina-fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--floatina-offset);
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--floatina-fab, #1e88ff);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 9999;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
#floatina-fab:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
#floatina-fab svg {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left/Right placement */
|
||||||
|
#floatina-fab.is-right {
|
||||||
|
right: var(--floatina-offset);
|
||||||
|
}
|
||||||
|
#floatina-fab.is-left {
|
||||||
|
left: var(--floatina-offset);
|
||||||
|
}
|
||||||
|
#floatina-panel.is-right {
|
||||||
|
right: var(--floatina-offset);
|
||||||
|
}
|
||||||
|
#floatina-panel.is-left {
|
||||||
|
left: var(--floatina-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel shell */
|
||||||
|
#floatina-panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(var(--floatina-offset) + 66px);
|
||||||
|
width: var(--floatina-width);
|
||||||
|
max-height: var(--floatina-height);
|
||||||
|
border-radius: var(--floatina-radius);
|
||||||
|
background: var(--floatina-bg);
|
||||||
|
box-shadow: var(--floatina-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(16px);
|
||||||
|
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
#floatina-panel.is-open {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.floatina-header {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--floatina-grad-start),
|
||||||
|
var(--floatina-grad-end)
|
||||||
|
);
|
||||||
|
padding: 18px 16px 12px;
|
||||||
|
}
|
||||||
|
.floatina-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.floatina-close {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 12px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.floatina-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(var(--floatina-height) - 118px);
|
||||||
|
background: var(--floatina-soft);
|
||||||
|
}
|
||||||
|
.floatina-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom nav */
|
||||||
|
.floatina-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid var(--floatina-border);
|
||||||
|
background: #fff;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.floatina-tab {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 6px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--floatina-border);
|
||||||
|
}
|
||||||
|
.floatina-tab.is-active {
|
||||||
|
background: var(--floatina-nav-active);
|
||||||
|
color: var(--floatina-accent);
|
||||||
|
border-color: #b9d0ff;
|
||||||
|
}
|
||||||
|
.floatina-tab svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.floatina-tab .fi-ico {
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards + gradient */
|
||||||
|
.fi-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--floatina-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.fi-card h4 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.fi-muted {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.fi-gradient {
|
||||||
|
background: linear-gradient(180deg, #dbeafe, #eef2ff);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons inside Home */
|
||||||
|
.fi-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.fi-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--floatina-border);
|
||||||
|
text-decoration: none;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Iframe area */
|
||||||
|
.floatina-iframe {
|
||||||
|
position: relative;
|
||||||
|
height: 60vh;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--floatina-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.floatina-iframe iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile fullscreen behavior */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
:root {
|
||||||
|
--floatina-width: 100vw;
|
||||||
|
--floatina-height: 100vh;
|
||||||
|
}
|
||||||
|
#floatina-panel {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
#floatina-panel.is-open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.floatina-body {
|
||||||
|
height: calc(100vh - 66px);
|
||||||
|
}
|
||||||
|
#floatina-fab {
|
||||||
|
bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color override from settings via inline vars */
|
||||||
|
#floatina-fab[data-color] {
|
||||||
|
background: var(--floatina-fab-color, #1e88ff);
|
||||||
|
}
|
||||||
294
floatina/assets/js/floatina.js
Normal file
294
floatina/assets/js/floatina.js
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Floatina – structured controller (no inline HTML/CSS)
|
||||||
|
* Modules: Config, State, Utils, Icons, View, Actions, Renderers.
|
||||||
|
*/
|
||||||
|
(function(){
|
||||||
|
/* ========================== Config ========================== */
|
||||||
|
const CFG = Object.freeze({
|
||||||
|
brandName: (window.FLOATINA && window.FLOATINA.brand_name) || 'Assistant',
|
||||||
|
buttonColor: (window.FLOATINA && window.FLOATINA.button_color) || '#1e88ff',
|
||||||
|
position: (window.FLOATINA && window.FLOATINA.position) || 'right', // right|left
|
||||||
|
voiceURL: (window.FLOATINA && window.FLOATINA.voice_iframe) || '',
|
||||||
|
textURL: (window.FLOATINA && window.FLOATINA.text_iframe) || '',
|
||||||
|
voiceMode: (window.FLOATINA && window.FLOATINA.voice_mode) || 'iframe', // iframe|popup
|
||||||
|
textMode: (window.FLOATINA && window.FLOATINA.text_mode) || 'iframe',
|
||||||
|
ajaxNews: (window.FLOATINA && window.FLOATINA.ajax && window.FLOATINA.ajax.news) || '',
|
||||||
|
ajaxFAQ: (window.FLOATINA && window.FLOATINA.ajax && window.FLOATINA.ajax.faq) || '',
|
||||||
|
i18n: (window.FLOATINA && window.FLOATINA.strings) || {home:'Home',voice:'Voice',text:'Text',news:'News',help:'Help',open_new:'Open in new tab'}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ========================== State ========================== */
|
||||||
|
const S = {
|
||||||
|
open: false,
|
||||||
|
tab: 'home'
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ========================== Utils ========================== */
|
||||||
|
const NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
/** Create DOM element */
|
||||||
|
function el(tag, attrs = {}, children = []) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
for (const k in attrs) {
|
||||||
|
if (k === 'class') node.className = attrs[k];
|
||||||
|
else if (k === 'dataset') Object.entries(attrs[k]).forEach(([dk, dv]) => 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);
|
||||||
|
})();
|
||||||
53
floatina/floatina.php
Normal file
53
floatina/floatina.php
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin Name: Floatina
|
||||||
|
* Description: Floating action button with slide-up panel (Home, Voice, Text, News, Help). Mobile fullscreen. CPTs for News/FAQ.
|
||||||
|
* Version: 1.0.0
|
||||||
|
* Author: Moh Farawati
|
||||||
|
* Text Domain: floatina
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
define('FLOATINA_VER', '0.1.0');
|
||||||
|
define('FLOATINA_DIR', plugin_dir_path(__FILE__));
|
||||||
|
define('FLOATINA_URL', plugin_dir_url(__FILE__));
|
||||||
|
|
||||||
|
require_once FLOATINA_DIR . 'includes/helpers.php';
|
||||||
|
require_once FLOATINA_DIR . 'includes/class-assets.php';
|
||||||
|
require_once FLOATINA_DIR . 'includes/class-cpts.php';
|
||||||
|
require_once FLOATINA_DIR . 'includes/class-settings.php';
|
||||||
|
require_once FLOATINA_DIR . 'includes/class-ajax.php';
|
||||||
|
|
||||||
|
/************************************************************************************
|
||||||
|
* Bootstrap + i18n
|
||||||
|
************************************************************************************/
|
||||||
|
add_action('plugins_loaded', function () {
|
||||||
|
load_plugin_textdomain('floatina', false, dirname(plugin_basename(__FILE__)) . '/languages');
|
||||||
|
});
|
||||||
|
|
||||||
|
/************************************************************************************
|
||||||
|
* Activation: register CPTs and flush rules
|
||||||
|
************************************************************************************/
|
||||||
|
register_activation_hook(__FILE__, function () {
|
||||||
|
floatina_register_cpts();
|
||||||
|
flush_rewrite_rules();
|
||||||
|
});
|
||||||
|
|
||||||
|
/************************************************************************************
|
||||||
|
* Render panel automatically in footer on frontend
|
||||||
|
************************************************************************************/
|
||||||
|
add_action('wp_footer', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
include FLOATINA_DIR . 'templates/panel.php';
|
||||||
|
}, 999);
|
||||||
|
|
||||||
|
/************************************************************************************
|
||||||
|
* Optional shortcode [floatina]
|
||||||
|
************************************************************************************/
|
||||||
|
add_shortcode('floatina', function () {
|
||||||
|
ob_start();
|
||||||
|
include FLOATINA_DIR . 'templates/panel.php';
|
||||||
|
return ob_get_clean();
|
||||||
|
});
|
||||||
21
floatina/includes/class-ajax.php
Normal file
21
floatina/includes/class-ajax.php
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
/************************************************************************************
|
||||||
|
* Public AJAX endpoints for News and FAQ partials
|
||||||
|
************************************************************************************/
|
||||||
|
add_action('wp_ajax_floatina_partial_news', 'floatina_partial_news');
|
||||||
|
add_action('wp_ajax_nopriv_floatina_partial_news', 'floatina_partial_news');
|
||||||
|
function floatina_partial_news()
|
||||||
|
{
|
||||||
|
include FLOATINA_DIR . 'templates/loop-news.php';
|
||||||
|
wp_die();
|
||||||
|
}
|
||||||
|
|
||||||
|
add_action('wp_ajax_floatina_partial_faq', 'floatina_partial_faq');
|
||||||
|
add_action('wp_ajax_nopriv_floatina_partial_faq', 'floatina_partial_faq');
|
||||||
|
function floatina_partial_faq()
|
||||||
|
{
|
||||||
|
include FLOATINA_DIR . 'templates/loop-faq.php';
|
||||||
|
wp_die();
|
||||||
|
}
|
||||||
41
floatina/includes/class-assets.php
Normal file
41
floatina/includes/class-assets.php
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
/************************************************************************************
|
||||||
|
* Enqueue frontend assets and localize settings
|
||||||
|
************************************************************************************/
|
||||||
|
add_action('wp_enqueue_scripts', function () {
|
||||||
|
wp_register_style('floatina', FLOATINA_URL . 'assets/css/floatina.css', [], FLOATINA_VER);
|
||||||
|
wp_register_script('floatina', FLOATINA_URL . 'assets/js/floatina.js', [], FLOATINA_VER, true);
|
||||||
|
|
||||||
|
wp_enqueue_style('floatina');
|
||||||
|
wp_enqueue_script('floatina');
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'brand_name' => 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);
|
||||||
|
});
|
||||||
31
floatina/includes/class-cpts.php
Normal file
31
floatina/includes/class-cpts.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
/************************************************************************************
|
||||||
|
* Register CPTs for News and FAQ under the same "Floatina" menu
|
||||||
|
************************************************************************************/
|
||||||
|
function floatina_register_cpts()
|
||||||
|
{
|
||||||
|
$parent = 'floatina'; // matches settings menu slug
|
||||||
|
|
||||||
|
register_post_type('floatina_news', [
|
||||||
|
'label' => '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');
|
||||||
90
floatina/includes/class-settings.php
Normal file
90
floatina/includes/class-settings.php
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
/************************************************************************************
|
||||||
|
* Admin menu + settings
|
||||||
|
************************************************************************************/
|
||||||
|
add_action('admin_menu', function () {
|
||||||
|
add_menu_page(
|
||||||
|
'Floatina',
|
||||||
|
'Floatina',
|
||||||
|
'manage_options',
|
||||||
|
'floatina',
|
||||||
|
'floatina_render_settings',
|
||||||
|
'dashicons-layout',
|
||||||
|
81
|
||||||
|
);
|
||||||
|
add_submenu_page('floatina', 'Settings', 'Settings', 'manage_options', 'floatina', 'floatina_render_settings');
|
||||||
|
}, 9);
|
||||||
|
|
||||||
|
add_action('admin_init', function () {
|
||||||
|
register_setting('floatina_settings_group', 'floatina_settings');
|
||||||
|
|
||||||
|
/* ---------- General ---------- */
|
||||||
|
add_settings_section('floatina_main', 'General', '__return_false', 'floatina');
|
||||||
|
add_settings_field('position', 'Panel position', 'floatina_field_position', 'floatina', 'floatina_main');
|
||||||
|
add_settings_field('brand_name', 'Brand name (header)', 'floatina_field_text', 'floatina', 'floatina_main', ['key' => '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 '<input type="text" name="floatina_settings[' . $key . ']" value="' . $val . '" placeholder="' . $ph . '" class="regular-text" />';
|
||||||
|
}
|
||||||
|
|
||||||
|
function floatina_field_position()
|
||||||
|
{
|
||||||
|
$v = floatina_opt('position', 'right'); ?>
|
||||||
|
<label><input type="radio" name="floatina_settings[position]" value="right" <?php checked($v, 'right'); ?> /> Right</label>
|
||||||
|
|
||||||
|
<label><input type="radio" name="floatina_settings[position]" value="left" <?php checked($v, 'left'); ?> /> Left</label>
|
||||||
|
<p class="description">Choose bottom-right or bottom-left placement.</p>
|
||||||
|
<?php }
|
||||||
|
|
||||||
|
function floatina_field_mode($args)
|
||||||
|
{
|
||||||
|
$opts = get_option('floatina_settings', []);
|
||||||
|
$key = $args['key'];
|
||||||
|
$val = isset($opts[$key]) ? $opts[$key] : 'iframe'; ?>
|
||||||
|
<select name="floatina_settings[<?php echo esc_attr($key); ?>]">
|
||||||
|
<option value="iframe" <?php selected($val, 'iframe'); ?>>Iframe (embed)</option>
|
||||||
|
<option value="popup" <?php selected($val, 'popup'); ?>>Popup/New tab</option>
|
||||||
|
</select>
|
||||||
|
<p class="description">Use Popup/New tab if the target site blocks iframes.</p>
|
||||||
|
<?php }
|
||||||
|
|
||||||
|
function floatina_render_settings()
|
||||||
|
{ ?>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>Floatina</h1>
|
||||||
|
<form method="post" action="options.php">
|
||||||
|
<?php
|
||||||
|
settings_fields('floatina_settings_group');
|
||||||
|
do_settings_sections('floatina');
|
||||||
|
submit_button();
|
||||||
|
?>
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
<p><strong>Usage:</strong> Renders site-wide. Optional shortcode: <code>[floatina]</code>.</p>
|
||||||
|
<p>Content: add items in <em>Floatina News</em> and <em>Floatina FAQ</em>. Typography inherits your theme.</p>
|
||||||
|
</div>
|
||||||
|
<?php }
|
||||||
11
floatina/includes/helpers.php
Normal file
11
floatina/includes/helpers.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
/************************************************************************************
|
||||||
|
* Option getter with default
|
||||||
|
************************************************************************************/
|
||||||
|
function floatina_opt($key, $default = '')
|
||||||
|
{
|
||||||
|
$opts = get_option('floatina_settings', []);
|
||||||
|
return isset($opts[$key]) && $opts[$key] !== '' ? $opts[$key] : $default;
|
||||||
|
}
|
||||||
26
floatina/templates/loop-faq.php
Normal file
26
floatina/templates/loop-faq.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
/************************************************************************************
|
||||||
|
* FAQ partial
|
||||||
|
************************************************************************************/
|
||||||
|
$q = new WP_Query([
|
||||||
|
'post_type' => '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(); ?>
|
||||||
|
<div class="fi-card">
|
||||||
|
<h4><?php echo esc_html(get_the_title()); ?></h4>
|
||||||
|
<div class="fi-muted"><?php echo wp_kses_post(wpautop(get_the_content())); ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endwhile;
|
||||||
|
wp_reset_postdata();
|
||||||
|
else: ?>
|
||||||
|
<div class="fi-card">
|
||||||
|
<h4>No FAQs</h4>
|
||||||
|
<div class="fi-muted">Add posts in Floatina FAQ.</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
28
floatina/templates/loop-news.php
Normal file
28
floatina/templates/loop-news.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
/************************************************************************************
|
||||||
|
* News partial
|
||||||
|
************************************************************************************/
|
||||||
|
$q = new WP_Query([
|
||||||
|
'post_type' => 'floatina_news',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'posts_per_page' => 8,
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($q->have_posts()):
|
||||||
|
while ($q->have_posts()): $q->the_post(); ?>
|
||||||
|
<article class="fi-card">
|
||||||
|
<h4><?php echo esc_html(get_the_title()); ?></h4>
|
||||||
|
<div class="fi-muted"><?php echo esc_html(get_the_excerpt()); ?></div>
|
||||||
|
<div style="margin-top:8px;"><a href="<?php echo esc_url(get_permalink()); ?>" target="_blank" rel="noopener">Open</a></div>
|
||||||
|
</article>
|
||||||
|
<?php endwhile;
|
||||||
|
wp_reset_postdata();
|
||||||
|
else: ?>
|
||||||
|
<div class="fi-card">
|
||||||
|
<h4>No news</h4>
|
||||||
|
<div class="fi-muted">Add posts in Floatina News.</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
2
floatina/templates/panel.php
Normal file
2
floatina/templates/panel.php
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<?php if (!defined('ABSPATH')) exit; ?>
|
||||||
|
<div id="floatina-root" aria-live="polite" aria-busy="false"></div>
|
||||||
Loading…
x
Reference in New Issue
Block a user