| 1 |
/** |
| 2 |
* React Admin Panel - Admin Script |
| 3 |
*/ |
| 4 |
(function(wp) { |
| 5 |
const { createElement: el, useState, useEffect, useRef } = wp.element; |
| 6 |
const { ToggleControl, TabPanel } = wp.components || {}; |
| 7 |
const apiFetch = wp.apiFetch; |
| 8 |
|
| 9 |
const hasComponents = !!wp.components; |
| 10 |
const API_NAMESPACE = rapSettings.restNamespace; |
| 11 |
|
| 12 |
// ── Toast notifications ──────────────────────────────────────────────────── |
| 13 |
|
| 14 |
const ToastContainer = ({ toasts }) => { |
| 15 |
if (!toasts.length) return null; |
| 16 |
return el('div', { className: 'rap-toast-container' }, |
| 17 |
toasts.map(t => |
| 18 |
el('div', { key: t.id, className: `rap-toast rap-toast-${t.type}` }, |
| 19 |
el('span', { className: 'rap-toast-icon' }, t.type === 'success' ? '✓' : '✕'), |
| 20 |
el('span', {}, t.message) |
| 21 |
) |
| 22 |
) |
| 23 |
); |
| 24 |
}; |
| 25 |
|
| 26 |
// ── Fallback tab bar ─────────────────────────────────────────────────────── |
| 27 |
|
| 28 |
const FallbackTabs = ({ tabs, activeTab, onSelect }) => |
| 29 |
el('div', { className: 'rap-nav-tabs' }, |
| 30 |
tabs.map(tab => |
| 31 |
el('button', { |
| 32 |
key: tab.name, |
| 33 |
className: `rap-nav-tab${activeTab === tab.name ? ' is-active' : ''}`, |
| 34 |
onClick: () => onSelect(tab.name) |
| 35 |
}, tab.title) |
| 36 |
) |
| 37 |
); |
| 38 |
|
| 39 |
// ── Toggle item ──────────────────────────────────────────────────────────── |
| 40 |
|
| 41 |
const ToggleItem = ({ id, name, checked, onChange, previewUrl, locked }) => { |
| 42 |
const inner = hasComponents && ToggleControl |
| 43 |
? el(ToggleControl, { |
| 44 |
label: name, |
| 45 |
checked, |
| 46 |
onChange: val => onChange(id, val), |
| 47 |
disabled: locked // disable toggle when locked |
| 48 |
}) |
| 49 |
: el('label', { className: 'rap-toggle-switch' }, |
| 50 |
el('input', { |
| 51 |
type: 'checkbox', |
| 52 |
checked, |
| 53 |
onChange: e => onChange(id, e.target.checked), |
| 54 |
disabled: locked // disable checkbox when locked |
| 55 |
}), |
| 56 |
el('span', { className: 'rap-toggle-track' }, |
| 57 |
el('span', { className: 'rap-toggle-thumb' }) |
| 58 |
), |
| 59 |
el('span', { className: 'rap-toggle-label' }, name) |
| 60 |
); |
| 61 |
|
| 62 |
// 2. Add class "rap-toggle-locked" when locked |
| 63 |
return el('div', { |
| 64 |
className: `rap-toggle-item${checked ? ' is-enabled' : ''}${locked ? ' rap-toggle-locked' : ''}` |
| 65 |
}, |
| 66 |
el('div', { className: 'rap-toggle-item-inner' }, inner), |
| 67 |
previewUrl && el('a', { |
| 68 |
href: previewUrl, |
| 69 |
target: '_blank', |
| 70 |
rel: 'noopener noreferrer', |
| 71 |
className: 'rap-preview-btn' |
| 72 |
}, 'Preview ↗') |
| 73 |
); |
| 74 |
}; |
| 75 |
|
| 76 |
// ── Plugin card helpers ──────────────────────────────────────────────────── |
| 77 |
|
| 78 |
const decodeHtml = (str) => { |
| 79 |
if (!str) return str; |
| 80 |
const txt = document.createElement('textarea'); |
| 81 |
txt.innerHTML = str; |
| 82 |
return txt.value; |
| 83 |
}; |
| 84 |
|
| 85 |
const StarRating = ({ rating, numRatings }) => { |
| 86 |
if (!numRatings) return null; |
| 87 |
const filled = Math.round((rating || 0) / 20); // 0-100 → 0-5 |
| 88 |
return el('div', { className: 'rap-star-rating' }, |
| 89 |
[1, 2, 3, 4, 5].map(i => |
| 90 |
el('span', { key: i, className: `rap-star${i <= filled ? ' rap-star-on' : ''}` }, '� |
| 91 |
') |
| 92 |
) |
| 93 |
); |
| 94 |
}; |
| 95 |
|
| 96 |
const PluginCard = ({ plugin, onInstall, onActivate, isInstalling, buttonStates }) => { |
| 97 |
const [imgError, setImgError] = useState(false); |
| 98 |
const slug = plugin.slug || ''; |
| 99 |
const name = decodeHtml(plugin.name || slug || 'Unknown Plugin'); |
| 100 |
const desc = decodeHtml(plugin.short_description || ''); |
| 101 |
const version = plugin.version || ''; |
| 102 |
const author = plugin.author || ''; |
| 103 |
const rating = plugin.rating || 0; |
| 104 |
const numRatings = plugin.num_ratings || 0; |
| 105 |
const pluginUrl = plugin.plugin_url || `https://wordpress.org/plugins/${slug}/`; |
| 106 |
const state = buttonStates[slug] || {}; |
| 107 |
|
| 108 |
const requiresPlugins = plugin.requires_plugins || []; |
| 109 |
const requiresWoo = requiresPlugins.some(p => /woocommerce/i.test(p)) |
| 110 |
|| /woocommerce/i.test(name); |
| 111 |
|
| 112 |
let action; |
| 113 |
if (state.active) { |
| 114 |
action = el('span', { className: 'rap-badge rap-badge-active' }, |
| 115 |
el('span', { className: 'rap-badge-dot' }), |
| 116 |
'Active' |
| 117 |
); |
| 118 |
} else if (state.installed) { |
| 119 |
action = el('button', { |
| 120 |
className: 'rap-btn rap-btn-outline', |
| 121 |
onClick: () => onActivate(slug), |
| 122 |
disabled: isInstalling |
| 123 |
}, isInstalling |
| 124 |
? el('span', { className: 'rap-btn-inner' }, |
| 125 |
el('span', { className: 'rap-spinner-sm' }), 'Activating…' |
| 126 |
) |
| 127 |
: 'Activate' |
| 128 |
); |
| 129 |
} else { |
| 130 |
action = el('button', { |
| 131 |
className: 'rap-btn rap-btn-primary', |
| 132 |
onClick: () => onInstall(slug), |
| 133 |
disabled: isInstalling |
| 134 |
}, isInstalling |
| 135 |
? el('span', { className: 'rap-btn-inner' }, |
| 136 |
el('span', { className: 'rap-spinner-sm' }), 'Installing…' |
| 137 |
) |
| 138 |
: 'Install' |
| 139 |
); |
| 140 |
} |
| 141 |
|
| 142 |
const iconEl = plugin.thumbnail && !imgError |
| 143 |
? el('img', { |
| 144 |
src: plugin.thumbnail, |
| 145 |
alt: name, |
| 146 |
className: 'rap-plugin-icon', |
| 147 |
onError: () => setImgError(true) |
| 148 |
}) |
| 149 |
: el('div', { className: 'rap-plugin-icon-fallback' }, name.charAt(0).toUpperCase()); |
| 150 |
|
| 151 |
return el('div', { className: `rap-plugin-card${state.active ? ' is-active' : state.installed ? ' is-installed' : ''}` }, |
| 152 |
el('div', { className: 'rap-plugin-card-top' }, |
| 153 |
el('div', { className: 'rap-plugin-icon-wrap' }, iconEl), |
| 154 |
el('div', { className: 'rap-plugin-header' }, |
| 155 |
el('h3', { className: 'rap-plugin-name' }, name) |
| 156 |
) |
| 157 |
), |
| 158 |
el('div', { className: 'rap-plugin-body' }, |
| 159 |
desc && el('p', { className: 'rap-plugin-desc' }, desc), |
| 160 |
requiresWoo && el('span', { className: 'rap-woo-badge' }, |
| 161 |
el('span', { className: 'rap-woo-icon' }, 'W'), |
| 162 |
'Requires WooCommerce' |
| 163 |
), |
| 164 |
el('a', { |
| 165 |
href: pluginUrl, |
| 166 |
target: '_blank', |
| 167 |
rel: 'noopener noreferrer', |
| 168 |
className: 'rap-details-link' |
| 169 |
}, 'View Details ↗') |
| 170 |
), |
| 171 |
el('div', { className: 'rap-plugin-card-footer' }, |
| 172 |
el(StarRating, { rating, numRatings }), |
| 173 |
action |
| 174 |
) |
| 175 |
); |
| 176 |
}; |
| 177 |
|
| 178 |
// ── Main App ─────────────────────────────────────────────────────────────── |
| 179 |
|
| 180 |
const App = () => { |
| 181 |
const [settings, setSettings] = useState({}); |
| 182 |
const [iconSets, setIconSets] = useState([]); |
| 183 |
const [activeTab, setActiveTab] = useState('general'); |
| 184 |
const [isSaving, setIsSaving] = useState(false); |
| 185 |
const [isLoading, setIsLoading] = useState(true); |
| 186 |
const [loadError, setLoadError] = useState(null); |
| 187 |
const [toasts, setToasts] = useState([]); |
| 188 |
const [search, setSearch] = useState(''); |
| 189 |
const [plugins, setPlugins] = useState([]); |
| 190 |
const [pluginsLoading, setPluginsLoading] = useState(false); |
| 191 |
const [pluginsFailed, setPluginsFailed] = useState(false); |
| 192 |
const [pluginStatuses, setPluginStatuses] = useState({}); |
| 193 |
const [installingSlug, setInstallingSlug] = useState(null); |
| 194 |
const toastId = useRef(0); |
| 195 |
const authors = rapSettings.authorSlugs || ['softtent']; |
| 196 |
const is_pro = rapSettings.ispro ; |
| 197 |
|
| 198 |
const addToast = (message, type = 'success') => { |
| 199 |
const id = ++toastId.current; |
| 200 |
setToasts(prev => [...prev, { id, message, type }]); |
| 201 |
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 4000); |
| 202 |
}; |
| 203 |
|
| 204 |
// Load settings on mount |
| 205 |
useEffect(() => { |
| 206 |
(async () => { |
| 207 |
try { |
| 208 |
setIconSets(rapSettings.iconSets || []); |
| 209 |
const data = await apiFetch({ |
| 210 |
path: `/${API_NAMESPACE}/settings`, |
| 211 |
method: 'GET', |
| 212 |
headers: { 'X-WP-Nonce': rapSettings.nonce } |
| 213 |
}); |
| 214 |
setSettings(data); |
| 215 |
} catch (_) { |
| 216 |
setLoadError('Could not load settings. Please reload the page.'); |
| 217 |
} finally { |
| 218 |
setIsLoading(false); |
| 219 |
} |
| 220 |
})(); |
| 221 |
}, []); |
| 222 |
|
| 223 |
// Clear search and reset plugin error when switching tabs |
| 224 |
useEffect(() => { |
| 225 |
setSearch(''); |
| 226 |
if (activeTab !== 'recommended-plugins') setPluginsFailed(false); |
| 227 |
}, [activeTab]); |
| 228 |
|
| 229 |
// Load plugins when recommended-plugins tab is first opened |
| 230 |
useEffect(() => { |
| 231 |
if (activeTab === 'recommended-plugins' && !plugins.length && !pluginsLoading) { |
| 232 |
fetchPlugins(); |
| 233 |
} |
| 234 |
}, [activeTab]); |
| 235 |
|
| 236 |
const fetchPlugins = async () => { |
| 237 |
setPluginsLoading(true); |
| 238 |
setPluginsFailed(false); |
| 239 |
try { |
| 240 |
const data = await apiFetch({ |
| 241 |
path: `/${API_NAMESPACE}/plugins?authors=${encodeURIComponent(authors.join(','))}`, |
| 242 |
method: 'GET', |
| 243 |
headers: { 'X-WP-Nonce': rapSettings.nonce } |
| 244 |
}); |
| 245 |
|
| 246 |
const raw = Array.isArray(data) ? data |
| 247 |
: data && data.plugins ? data.plugins |
| 248 |
: data && typeof data === 'object' ? Object.values(data).filter(p => p && typeof p === 'object') |
| 249 |
: []; |
| 250 |
|
| 251 |
// Pin softtent-managex to the top of the list |
| 252 |
const list = [ |
| 253 |
...raw.filter(p => p.slug === 'softtent-managex'), |
| 254 |
...raw.filter(p => p.slug !== 'softtent-managex') |
| 255 |
]; |
| 256 |
setPlugins(list); |
| 257 |
|
| 258 |
const statuses = {}; |
| 259 |
await Promise.all(list.map(async p => { |
| 260 |
if (!p.slug) return; |
| 261 |
try { |
| 262 |
statuses[p.slug] = await apiFetch({ |
| 263 |
path: `/${API_NAMESPACE}/plugin-status?slug=${encodeURIComponent(p.slug)}`, |
| 264 |
method: 'GET', |
| 265 |
headers: { 'X-WP-Nonce': rapSettings.nonce } |
| 266 |
}); |
| 267 |
} catch (_) { |
| 268 |
statuses[p.slug] = { installed: false, active: false }; |
| 269 |
} |
| 270 |
})); |
| 271 |
setPluginStatuses(statuses); |
| 272 |
} catch (_) { |
| 273 |
// Only mark failed on a real REST/network exception |
| 274 |
setPluginsFailed(true); |
| 275 |
} finally { |
| 276 |
setPluginsLoading(false); |
| 277 |
} |
| 278 |
}; |
| 279 |
|
| 280 |
const handleInstall = async (slug) => { |
| 281 |
setInstallingSlug(slug); |
| 282 |
try { |
| 283 |
await apiFetch({ |
| 284 |
path: `/${API_NAMESPACE}/install-plugin`, |
| 285 |
method: 'POST', |
| 286 |
headers: { 'X-WP-Nonce': rapSettings.nonce, 'Content-Type': 'application/json' }, |
| 287 |
body: JSON.stringify({ slug }) |
| 288 |
}); |
| 289 |
const status = await apiFetch({ |
| 290 |
path: `/${API_NAMESPACE}/plugin-status?slug=${encodeURIComponent(slug)}`, |
| 291 |
method: 'GET', |
| 292 |
headers: { 'X-WP-Nonce': rapSettings.nonce } |
| 293 |
}); |
| 294 |
setPluginStatuses(prev => ({ ...prev, [slug]: status })); |
| 295 |
addToast('Plugin installed successfully!'); |
| 296 |
} catch (e) { |
| 297 |
addToast('Install failed: ' + (e.message || 'Unknown error'), 'error'); |
| 298 |
} finally { |
| 299 |
setInstallingSlug(null); |
| 300 |
} |
| 301 |
}; |
| 302 |
|
| 303 |
const handleActivate = async (slug) => { |
| 304 |
setInstallingSlug(slug); |
| 305 |
try { |
| 306 |
await apiFetch({ |
| 307 |
path: `/${API_NAMESPACE}/activate-plugin`, |
| 308 |
method: 'POST', |
| 309 |
headers: { 'X-WP-Nonce': rapSettings.nonce, 'Content-Type': 'application/json' }, |
| 310 |
body: JSON.stringify({ slug }) |
| 311 |
}); |
| 312 |
const status = await apiFetch({ |
| 313 |
path: `/${API_NAMESPACE}/plugin-status?slug=${encodeURIComponent(slug)}`, |
| 314 |
method: 'GET', |
| 315 |
headers: { 'X-WP-Nonce': rapSettings.nonce } |
| 316 |
}); |
| 317 |
setPluginStatuses(prev => ({ ...prev, [slug]: status })); |
| 318 |
addToast('Plugin activated!'); |
| 319 |
} catch (e) { |
| 320 |
addToast('Activation failed: ' + (e.message || 'Unknown error'), 'error'); |
| 321 |
} finally { |
| 322 |
setInstallingSlug(null); |
| 323 |
} |
| 324 |
}; |
| 325 |
|
| 326 |
const handleSave = async () => { |
| 327 |
setIsSaving(true); |
| 328 |
try { |
| 329 |
const response = await apiFetch({ |
| 330 |
path: `/${API_NAMESPACE}/settings`, |
| 331 |
method: 'POST', |
| 332 |
headers: { 'X-WP-Nonce': rapSettings.nonce, 'Content-Type': 'application/json' }, |
| 333 |
body: JSON.stringify(settings) |
| 334 |
}); |
| 335 |
setSettings(response); |
| 336 |
addToast('Settings saved!'); |
| 337 |
} catch (_) { |
| 338 |
addToast('Save failed. Please try again.', 'error'); |
| 339 |
} finally { |
| 340 |
setIsSaving(false); |
| 341 |
} |
| 342 |
}; |
| 343 |
|
| 344 |
const freeIcons = iconSets.filter(i => !i.pro); |
| 345 |
const proIcons = iconSets.filter(i => i.pro); |
| 346 |
|
| 347 |
const renderIconGrid = (icons) => { |
| 348 |
const enabledCount = icons.filter(i => settings[i.id]).length; |
| 349 |
const filtered = search |
| 350 |
? icons.filter(i => i.name.toLowerCase().includes(search.toLowerCase())) |
| 351 |
: icons; |
| 352 |
|
| 353 |
return el('div', {}, |
| 354 |
el('div', { className: 'rap-toolbar' }, |
| 355 |
el('div', { className: 'rap-chips' }, |
| 356 |
el('span', { className: 'rap-chip rap-chip-primary' }, `${enabledCount} enabled`), |
| 357 |
el('span', { className: 'rap-chip' }, `${icons.length} total`) |
| 358 |
), |
| 359 |
el('input', { |
| 360 |
type: 'text', |
| 361 |
className: 'rap-search', |
| 362 |
placeholder: 'Search icon sets…', |
| 363 |
value: search, |
| 364 |
onChange: e => setSearch(e.target.value) |
| 365 |
}) |
| 366 |
), |
| 367 |
filtered.length === 0 |
| 368 |
? el('p', { className: 'rap-empty' }, `No results for "${search}"`) |
| 369 |
: el('div', { className: 'rap-icon-grid' }, |
| 370 |
filtered.map(icon => { |
| 371 |
const locked = icon.pro && !is_pro; // compute locked flag |
| 372 |
return el(ToggleItem, { |
| 373 |
key: icon.id, |
| 374 |
id: icon.id, |
| 375 |
name: icon.name, |
| 376 |
checked: !!settings[icon.id], |
| 377 |
onChange: (id, val) => setSettings(prev => ({ ...prev, [id]: val })), |
| 378 |
previewUrl: icon.preview, |
| 379 |
locked // pass locked prop |
| 380 |
}); |
| 381 |
}) |
| 382 |
) |
| 383 |
); |
| 384 |
}; |
| 385 |
|
| 386 |
const renderPluginsTab = () => { |
| 387 |
if (pluginsLoading) { |
| 388 |
return el('div', { className: 'rap-loading' }, |
| 389 |
el('span', { className: 'rap-spinner' }), |
| 390 |
el('span', {}, 'Loading plugins…') |
| 391 |
); |
| 392 |
} |
| 393 |
if (pluginsFailed) { |
| 394 |
return el('div', { className: 'rap-empty rap-empty-center' }, |
| 395 |
el('span', { className: 'rap-empty-icon' }, '⚠️'), |
| 396 |
el('p', {}, 'Plugins could not be loaded.'), |
| 397 |
el('button', { |
| 398 |
className: 'rap-btn rap-btn-primary', |
| 399 |
onClick: fetchPlugins |
| 400 |
}, 'Try Again') |
| 401 |
); |
| 402 |
} |
| 403 |
if (!plugins.length) { |
| 404 |
return el('div', { className: 'rap-empty rap-empty-center' }, |
| 405 |
el('span', { className: 'rap-empty-icon' }, '📦'), |
| 406 |
el('p', {}, 'No plugins found.') |
| 407 |
); |
| 408 |
} |
| 409 |
const statVals = Object.values(pluginStatuses); |
| 410 |
const activeCount = statVals.filter(s => s.active).length; |
| 411 |
const readyCount = statVals.filter(s => s.installed && !s.active).length; |
| 412 |
|
| 413 |
return el('div', {}, |
| 414 |
el('div', { className: 'rap-toolbar' }, |
| 415 |
el('div', { className: 'rap-chips' }, |
| 416 |
el('span', { className: 'rap-chip rap-chip-success' }, `${activeCount} active`), |
| 417 |
readyCount > 0 && el('span', { className: 'rap-chip rap-chip-warning' }, `${readyCount} installed`), |
| 418 |
el('span', { className: 'rap-chip' }, `${plugins.length} total`) |
| 419 |
) |
| 420 |
), |
| 421 |
el('div', { className: 'rap-plugins-grid' }, |
| 422 |
plugins.map(p => |
| 423 |
el(PluginCard, { |
| 424 |
key: p.slug, |
| 425 |
plugin: p, |
| 426 |
onInstall: handleInstall, |
| 427 |
onActivate: handleActivate, |
| 428 |
isInstalling: installingSlug === p.slug, |
| 429 |
buttonStates: pluginStatuses |
| 430 |
}) |
| 431 |
) |
| 432 |
) |
| 433 |
); |
| 434 |
}; |
| 435 |
|
| 436 |
if (isLoading) { |
| 437 |
return el('div', { className: 'rap-loading' }, |
| 438 |
el('span', { className: 'rap-spinner' }), |
| 439 |
el('span', {}, 'Loading…') |
| 440 |
); |
| 441 |
} |
| 442 |
if (loadError) { |
| 443 |
return el('div', { className: 'rap-notice rap-notice-error' }, loadError); |
| 444 |
} |
| 445 |
|
| 446 |
const tabs = [ |
| 447 |
{ name: 'general', title: `Free Icons (${freeIcons.length} Sets)` }, |
| 448 |
{ name: 'advanced', title: `Pro Icons (${proIcons.length} Sets)` }, |
| 449 |
{ name: 'recommended-plugins', title: 'Recommended Plugins' } |
| 450 |
]; |
| 451 |
|
| 452 |
const getTabContent = name => { |
| 453 |
if (name === 'general') return renderIconGrid(freeIcons); |
| 454 |
if (name === 'advanced') return renderIconGrid(proIcons); |
| 455 |
if (name === 'recommended-plugins') return renderPluginsTab(); |
| 456 |
return null; |
| 457 |
}; |
| 458 |
|
| 459 |
const showSave = activeTab === 'general' || activeTab === 'advanced'; |
| 460 |
|
| 461 |
return el('div', { className: 'rap-app' }, |
| 462 |
el(ToastContainer, { toasts }), |
| 463 |
el('div', { className: 'rap-panel' }, |
| 464 |
hasComponents && TabPanel |
| 465 |
? el(TabPanel, { |
| 466 |
className: 'rap-tabs', |
| 467 |
activeClass: 'is-active', |
| 468 |
tabs, |
| 469 |
onSelect: setActiveTab, |
| 470 |
children: tab => el('div', { className: 'rap-tab-body' }, getTabContent(tab.name)) |
| 471 |
}) |
| 472 |
: el('div', {}, |
| 473 |
el(FallbackTabs, { tabs, activeTab, onSelect: setActiveTab }), |
| 474 |
el('div', { className: 'rap-tab-body' }, getTabContent(activeTab)) |
| 475 |
), |
| 476 |
showSave && el('div', { className: 'rap-footer' }, |
| 477 |
el('button', { |
| 478 |
className: `rap-btn rap-btn-primary rap-btn-lg${isSaving ? ' is-loading' : ''}`, |
| 479 |
onClick: handleSave, |
| 480 |
disabled: isSaving |
| 481 |
}, isSaving |
| 482 |
? el('span', { className: 'rap-btn-inner' }, |
| 483 |
el('span', { className: 'rap-spinner-sm' }), |
| 484 |
'Saving…' |
| 485 |
) |
| 486 |
: 'Save Settings' |
| 487 |
) |
| 488 |
) |
| 489 |
) |
| 490 |
); |
| 491 |
}; |
| 492 |
|
| 493 |
// Mount |
| 494 |
const root = document.getElementById('rap-root'); |
| 495 |
if (root) { |
| 496 |
if (wp.element.createRoot) { |
| 497 |
wp.element.createRoot(root).render(el(App)); |
| 498 |
} else { |
| 499 |
wp.element.render(el(App), root); |
| 500 |
} |
| 501 |
} |
| 502 |
|
| 503 |
})(window.wp); |