This commit is contained in:
oliver
2026-08-28 10:11:11 -03:00
parent f533ff8e36
commit 585951c8ce
1380 changed files with 1540268 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
// D3.js Animations for Homepage - Training & Learning Focus
function initD3Animation() {
const container = d3.select("#d3-animation");
if (container.empty()) return;
const containerRect = container.node().getBoundingClientRect();
const width = containerRect.width;
const height = containerRect.height || 400;
// Clear any existing SVG
container.selectAll("*").remove();
const svg = container.append("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`)
.style("background", "transparent")
.style("overflow", "hidden");
// Create a main group for all elements
const mainGroup = svg.append("g")
.attr("class", "main-group");
// Create animated network visualization representing Odoo training modules
const nodes = [
{ id: "Fundamentos", group: 1, size: 35, level: 1 },
{ id: "CRM Básico", group: 2, size: 25, level: 2 },
{ id: "Ventas", group: 2, size: 25, level: 2 },
{ id: "Inventario", group: 3, size: 25, level: 2 },
{ id: "Contabilidad", group: 4, size: 30, level: 2 },
{ id: "CRM Avanzado", group: 2, size: 20, level: 3 },
{ id: "Reportes", group: 4, size: 20, level: 3 },
{ id: "Integraciones", group: 3, size: 25, level: 3 },
{ id: "Certificación", group: 1, size: 30, level: 4 },
{ id: "Especialización", group: 1, size: 25, level: 4 }
];
const links = [
{ source: "Fundamentos", target: "CRM Básico", value: 3 },
{ source: "Fundamentos", target: "Ventas", value: 3 },
{ source: "Fundamentos", target: "Inventario", value: 3 },
{ source: "Fundamentos", target: "Contabilidad", value: 3 },
{ source: "CRM Básico", target: "CRM Avanzado", value: 2 },
{ source: "Ventas", target: "CRM Avanzado", value: 2 },
{ source: "Contabilidad", target: "Reportes", value: 3 },
{ source: "Inventario", target: "Integraciones", value: 3 },
{ source: "CRM Avanzado", target: "Certificación", value: 3 },
{ source: "Reportes", target: "Certificación", value: 2 },
{ source: "Integraciones", target: "Especialización", value: 2 },
{ source: "Certificación", target: "Especialización", value: 3 }
];
// Color scale with orange theme
const color = d3.scaleOrdinal()
.domain([1, 2, 3, 4])
.range(["#FF6B35", "#FF8A50", "#FFA726", "#FFB74D"]);
// Add boundary force to keep nodes within container
const boundaryForce = () => {
nodes.forEach(node => {
node.x = Math.max(node.size, Math.min(width - node.size, node.x));
node.y = Math.max(node.size, Math.min(height - node.size, node.y));
});
};
// Create force simulation with boundaries
const simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id).distance(80))
.force("charge", d3.forceManyBody().strength(-250))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(d => d.size + 8))
.force("boundary", boundaryForce);
// Create links
const link = mainGroup.append("g")
.attr("class", "links")
.selectAll("line")
.data(links)
.enter().append("line")
.attr("stroke", "#FF6B35")
.attr("stroke-opacity", 0.3)
.attr("stroke-width", d => Math.sqrt(d.value));
// Create nodes container
const node = mainGroup.append("g")
.attr("class", "nodes")
.selectAll("g")
.data(nodes)
.enter().append("g")
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended));
// Add circles
node.append("circle")
.attr("r", d => d.size)
.attr("fill", d => color(d.group))
.attr("stroke", "#1a1a1a")
.attr("stroke-width", 2)
.style("cursor", "pointer")
.on("mouseover", function(event, d) {
d3.select(this)
.transition()
.duration(200)
.attr("r", d.size * 1.2);
})
.on("mouseout", function(event, d) {
d3.select(this)
.transition()
.duration(200)
.attr("r", d.size);
});
// Add text labels
node.append("text")
.text(d => d.id)
.attr("text-anchor", "middle")
.attr("dy", ".35em")
.style("fill", "#1a1a1a")
.style("font-size", "11px")
.style("font-weight", "700")
.style("pointer-events", "none");
// Add animated pulses
node.append("circle")
.attr("r", d => d.size)
.attr("fill", "none")
.attr("stroke", d => color(d.group))
.attr("stroke-width", 2)
.attr("opacity", 0)
.style("pointer-events", "none")
.each(function(d, i) {
d3.select(this)
.transition()
.delay(i * 200)
.duration(2000)
.attr("r", d => d.size * 2)
.attr("opacity", 0)
.on("end", function repeat() {
d3.select(this)
.attr("r", d.size)
.attr("opacity", 0.5)
.transition()
.duration(2000)
.attr("r", d.size * 2)
.attr("opacity", 0)
.on("end", repeat);
});
});
// Update positions on tick
simulation.on("tick", () => {
// Apply boundary force
boundaryForce();
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
node
.attr("transform", d => `translate(${d.x},${d.y})`);
});
// Drag functions with boundaries
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = Math.max(d.size, Math.min(width - d.size, event.x));
d.fy = Math.max(d.size, Math.min(height - d.size, event.y));
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
// Add floating particles with orange theme
const particles = mainGroup.append("g")
.attr("class", "particles");
function createParticle() {
const particle = particles.append("circle")
.attr("r", Math.random() * 2 + 1)
.attr("cx", Math.random() * width)
.attr("cy", height + 5)
.attr("fill", "#FF6B35")
.attr("opacity", 0.2);
particle.transition()
.duration(Math.random() * 8000 + 4000)
.ease(d3.easeLinear)
.attr("cy", -5)
.attr("opacity", 0)
.remove();
}
// Create particles periodically
setInterval(createParticle, 500);
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
initD3Animation();
// Reinitialize on window resize
let resizeTimer;
window.addEventListener('resize', function() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function() {
initD3Animation();
}, 250);
});
});
// Smooth scroll for anchor links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
+235
View File
@@ -0,0 +1,235 @@
// D3.js Animations for Homepage - Training & Learning Focus
function initD3Animation() {
const container = d3.select("#d3-animation");
if (container.empty()) return;
const containerRect = container.node().getBoundingClientRect();
const width = containerRect.width;
const height = containerRect.height || 400;
// Clear any existing SVG
container.selectAll("*").remove();
const svg = container.append("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`)
.style("background", "transparent")
.style("overflow", "hidden");
// Create a main group for all elements
const mainGroup = svg.append("g")
.attr("class", "main-group");
// Create animated network visualization representing Odoo training modules
const nodes = [
{ id: "Fundamentos", group: 1, size: 35, level: 1 },
{ id: "CRM Básico", group: 2, size: 25, level: 2 },
{ id: "Ventas", group: 2, size: 25, level: 2 },
{ id: "Inventario", group: 3, size: 25, level: 2 },
{ id: "Contabilidad", group: 4, size: 30, level: 2 },
{ id: "CRM Avanzado", group: 2, size: 20, level: 3 },
{ id: "Reportes", group: 4, size: 20, level: 3 },
{ id: "Integraciones", group: 3, size: 25, level: 3 },
{ id: "Certificación", group: 1, size: 30, level: 4 },
{ id: "Especialización", group: 1, size: 25, level: 4 }
];
const links = [
{ source: "Fundamentos", target: "CRM Básico", value: 3 },
{ source: "Fundamentos", target: "Ventas", value: 3 },
{ source: "Fundamentos", target: "Inventario", value: 3 },
{ source: "Fundamentos", target: "Contabilidad", value: 3 },
{ source: "CRM Básico", target: "CRM Avanzado", value: 2 },
{ source: "Ventas", target: "CRM Avanzado", value: 2 },
{ source: "Contabilidad", target: "Reportes", value: 3 },
{ source: "Inventario", target: "Integraciones", value: 3 },
{ source: "CRM Avanzado", target: "Certificación", value: 3 },
{ source: "Reportes", target: "Certificación", value: 2 },
{ source: "Integraciones", target: "Especialización", value: 2 },
{ source: "Certificación", target: "Especialización", value: 3 }
];
// Color scale with orange theme
const color = d3.scaleOrdinal()
.domain([1, 2, 3, 4])
.range(["#FF6B35", "#FF8A50", "#FFA726", "#FFB74D"]);
// Add boundary force to keep nodes within container
const boundaryForce = () => {
nodes.forEach(node => {
node.x = Math.max(node.size, Math.min(width - node.size, node.x));
node.y = Math.max(node.size, Math.min(height - node.size, node.y));
});
};
// Create force simulation with boundaries
const simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id).distance(80))
.force("charge", d3.forceManyBody().strength(-250))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(d => d.size + 8))
.force("boundary", boundaryForce);
// Create links
const link = mainGroup.append("g")
.attr("class", "links")
.selectAll("line")
.data(links)
.enter().append("line")
.attr("stroke", "#FF6B35")
.attr("stroke-opacity", 0.3)
.attr("stroke-width", d => Math.sqrt(d.value));
// Create nodes container
const node = mainGroup.append("g")
.attr("class", "nodes")
.selectAll("g")
.data(nodes)
.enter().append("g")
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended));
// Add circles
node.append("circle")
.attr("r", d => d.size)
.attr("fill", d => color(d.group))
.attr("stroke", "#1a1a1a")
.attr("stroke-width", 2)
.style("cursor", "pointer")
.on("mouseover", function(event, d) {
d3.select(this)
.transition()
.duration(200)
.attr("r", d.size * 1.2);
})
.on("mouseout", function(event, d) {
d3.select(this)
.transition()
.duration(200)
.attr("r", d.size);
});
// Add text labels
node.append("text")
.text(d => d.id)
.attr("text-anchor", "middle")
.attr("dy", ".35em")
.style("fill", "#1a1a1a")
.style("font-size", "11px")
.style("font-weight", "700")
.style("pointer-events", "none");
// Add animated pulses
node.append("circle")
.attr("r", d => d.size)
.attr("fill", "none")
.attr("stroke", d => color(d.group))
.attr("stroke-width", 2)
.attr("opacity", 0)
.style("pointer-events", "none")
.each(function(d, i) {
d3.select(this)
.transition()
.delay(i * 200)
.duration(2000)
.attr("r", d => d.size * 2)
.attr("opacity", 0)
.on("end", function repeat() {
d3.select(this)
.attr("r", d.size)
.attr("opacity", 0.5)
.transition()
.duration(2000)
.attr("r", d.size * 2)
.attr("opacity", 0)
.on("end", repeat);
});
});
// Update positions on tick
simulation.on("tick", () => {
// Apply boundary force
boundaryForce();
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
node
.attr("transform", d => `translate(${d.x},${d.y})`);
});
// Drag functions with boundaries
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = Math.max(d.size, Math.min(width - d.size, event.x));
d.fy = Math.max(d.size, Math.min(height - d.size, event.y));
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
// Add floating particles with orange theme
const particles = mainGroup.append("g")
.attr("class", "particles");
function createParticle() {
const particle = particles.append("circle")
.attr("r", Math.random() * 2 + 1)
.attr("cx", Math.random() * width)
.attr("cy", height + 5)
.attr("fill", "#FF6B35")
.attr("opacity", 0.2);
particle.transition()
.duration(Math.random() * 8000 + 4000)
.ease(d3.easeLinear)
.attr("cy", -5)
.attr("opacity", 0)
.remove();
}
// Create particles periodically
setInterval(createParticle, 500);
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
initD3Animation();
// Reinitialize on window resize
let resizeTimer;
window.addEventListener('resize', function() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function() {
initD3Animation();
}, 250);
});
});
// Smooth scroll for anchor links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
+455
View File
@@ -0,0 +1,455 @@
// Cookie Banner Management
(function() {
'use strict';
// Cookie Banner Class
class CookieBanner {
constructor() {
this.cookieKey = 'odoo_expertos_cookies';
this.banner = null;
this.init();
}
init() {
// Check if user has already made a choice
const cookieChoice = localStorage.getItem(this.cookieKey);
// If no choice has been made, show the banner
if (!cookieChoice) {
this.createBanner();
// Use requestAnimationFrame for better timing
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.showBanner();
});
});
}
}
createBanner() {
// Create banner HTML
const bannerHTML = `
<div class="cookie-banner" id="cookieBanner">
<div class="cookie-banner-content">
<div class="cookie-banner-text">
<h3>Política de Cookies</h3>
<p>Utilizamos cookies para mejorar tu experiencia de aprendizaje en nuestra plataforma de formación Odoo.
Las cookies nos ayudan a personalizar el contenido, recordar tu progreso y mejorar nuestros cursos.</p>
</div>
<div class="cookie-banner-buttons">
<button class="cookie-btn cookie-btn-accept" onclick="cookieBannerInstance.acceptCookies()">
Aceptar
</button>
<button class="cookie-btn cookie-btn-reject" onclick="cookieBannerInstance.rejectCookies()">
Rechazar
</button>
<button class="cookie-btn cookie-btn-configure" onclick="cookieBannerInstance.configureCookies()">
Configurar
</button>
</div>
</div>
</div>
`;
// Add banner to the page
document.body.insertAdjacentHTML('beforeend', bannerHTML);
this.banner = document.getElementById('cookieBanner');
// Add CSS for cookie banner
this.addBannerStyles();
}
showBanner() {
if (this.banner) {
this.banner.classList.add('active');
}
}
hideBanner() {
if (this.banner) {
this.banner.classList.remove('active');
// Remove banner from DOM after animation
setTimeout(() => {
if (this.banner && this.banner.parentNode) {
this.banner.parentNode.removeChild(this.banner);
}
}, 500);
}
}
acceptCookies() {
this.saveCookieChoice({
accepted: true,
analytics: true,
marketing: true,
functional: true,
timestamp: new Date().toISOString()
});
this.hideBanner();
this.enableCookies();
}
rejectCookies() {
this.saveCookieChoice({
accepted: false,
analytics: false,
marketing: false,
functional: false,
timestamp: new Date().toISOString()
});
this.hideBanner();
this.disableCookies();
}
configureCookies() {
// Create configuration modal
const configHTML = `
<div class="cookie-config-modal" id="cookieConfigModal">
<div class="cookie-config-content">
<h2>Configurar Cookies</h2>
<div class="cookie-config-section">
<h3>Cookies Esenciales</h3>
<p>Necesarias para el funcionamiento básico del sitio.</p>
<label class="cookie-switch">
<input type="checkbox" checked disabled>
<span class="slider"></span>
</label>
</div>
<div class="cookie-config-section">
<h3>Cookies de Análisis</h3>
<p>Nos ayudan a entender cómo utilizas nuestra plataforma de formación.</p>
<label class="cookie-switch">
<input type="checkbox" id="analyticsCookie">
<span class="slider"></span>
</label>
</div>
<div class="cookie-config-section">
<h3>Cookies de Marketing</h3>
<p>Utilizadas para mostrarte cursos relevantes.</p>
<label class="cookie-switch">
<input type="checkbox" id="marketingCookie">
<span class="slider"></span>
</label>
</div>
<div class="cookie-config-buttons">
<button class="cookie-btn cookie-btn-accept" onclick="cookieBannerInstance.saveConfiguration()">
Guardar Preferencias
</button>
<button class="cookie-btn cookie-btn-reject" onclick="cookieBannerInstance.closeConfiguration()">
Cancelar
</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', configHTML);
// Add styles for modal
const style = document.createElement('style');
style.textContent = `
.cookie-config-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.3s ease;
}
.cookie-config-content {
background: var(--bg-dark);
border: 2px solid var(--primary-color);
border-radius: 10px;
padding: 2rem;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
animation: slideUp 0.3s ease;
}
.cookie-config-content h2 {
color: var(--primary-color);
margin-bottom: 1.5rem;
}
.cookie-config-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid rgba(255,107,53,0.2);
display: flex;
align-items: center;
justify-content: space-between;
}
.cookie-config-section:last-of-type {
border-bottom: none;
}
.cookie-config-section h3 {
color: var(--white);
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
.cookie-config-section p {
color: var(--text-color);
font-size: 0.9rem;
margin-bottom: 0;
flex: 1;
margin-right: 1rem;
}
.cookie-switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.cookie-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--light-bg);
transition: .4s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--primary-color);
}
input:checked + .slider:before {
transform: translateX(26px);
}
input:disabled + .slider {
opacity: 0.6;
cursor: not-allowed;
}
.cookie-config-buttons {
margin-top: 2rem;
display: flex;
gap: 1rem;
justify-content: center;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
`;
document.head.appendChild(style);
}
saveConfiguration() {
const analytics = document.getElementById('analyticsCookie').checked;
const marketing = document.getElementById('marketingCookie').checked;
this.saveCookieChoice({
accepted: true,
analytics: analytics,
marketing: marketing,
functional: true,
timestamp: new Date().toISOString()
});
this.closeConfiguration();
this.hideBanner();
// Enable/disable cookies based on preferences
if (analytics || marketing) {
this.enableCookies();
} else {
this.disableCookies();
}
}
closeConfiguration() {
const modal = document.getElementById('cookieConfigModal');
if (modal && modal.parentNode) {
modal.parentNode.removeChild(modal);
}
}
saveCookieChoice(choice) {
localStorage.setItem(this.cookieKey, JSON.stringify(choice));
}
addBannerStyles() {
const style = document.createElement('style');
style.textContent = `
.cookie-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #1a1a1a;
border-top: 3px solid #FF6B35;
padding: 1.5rem;
transform: translateY(100%);
transition: transform 0.5s ease;
z-index: 9999;
box-shadow: 0 -4px 20px rgba(0,0,0,0.5);
}
.cookie-banner.active {
transform: translateY(0);
}
.cookie-banner-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
.cookie-banner-text {
flex: 1;
min-width: 300px;
}
.cookie-banner-text h3 {
color: #FF6B35;
margin: 0 0 0.5rem 0;
font-size: 1.2rem;
}
.cookie-banner-text p {
color: #e0e0e0;
margin: 0;
font-size: 0.9rem;
line-height: 1.5;
}
.cookie-banner-text a {
color: #FF6B35;
text-decoration: underline;
}
.cookie-banner-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.cookie-btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 5px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cookie-btn-accept {
background: #FF6B35;
color: white;
}
.cookie-btn-accept:hover {
background: #e55a28;
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(255,107,53,0.4);
}
.cookie-btn-reject {
background: transparent;
color: #FF6B35;
border: 2px solid #FF6B35;
}
.cookie-btn-reject:hover {
background: #FF6B35;
color: white;
}
.cookie-btn-configure {
background: #333;
color: #e0e0e0;
}
.cookie-btn-configure:hover {
background: #444;
}
@media (max-width: 768px) {
.cookie-banner-content {
flex-direction: column;
text-align: center;
}
.cookie-banner-buttons {
justify-content: center;
width: 100%;
}
.cookie-btn {
font-size: 0.8rem;
padding: 0.6rem 1.2rem;
}
}
`;
document.head.appendChild(style);
}
enableCookies() {
// Here you would enable your analytics, marketing cookies etc.
console.log('Cookies enabled');
}
disableCookies() {
// Here you would disable non-essential cookies
console.log('Non-essential cookies disabled');
}
}
// Initialize cookie banner when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
window.cookieBannerInstance = new CookieBanner();
});
} else {
window.cookieBannerInstance = new CookieBanner();
}
})();
+1
View File
File diff suppressed because one or more lines are too long
+2
View File
File diff suppressed because one or more lines are too long
+41
View File
@@ -0,0 +1,41 @@
// Directory Page Card Structure Fix
document.addEventListener('DOMContentLoaded', function() {
// Function to check and fix article card structure
function fixArticleCards() {
const articlesGrid = document.querySelector('.articles-grid');
if (!articlesGrid) return;
// Get all article cards
const cards = articlesGrid.querySelectorAll('.article-card');
cards.forEach(card => {
// Check if card is an anchor tag
if (card.tagName.toLowerCase() !== 'a') return;
// Check if card has proper structure
const hasImage = card.querySelector('.article-card-image');
const hasContent = card.querySelector('.article-card-content');
if (!hasImage || !hasContent) {
console.warn('Article card structure issue detected:', card);
// Try to fix by finding orphaned content
const nextSibling = card.nextElementSibling;
if (nextSibling && nextSibling.classList.contains('article-card-content')) {
// Move the content inside the card
card.appendChild(nextSibling);
}
}
// Ensure card has proper display properties
card.style.display = 'flex';
card.style.flexDirection = 'column';
});
}
// Run the fix
fixArticleCards();
// Also run after a short delay in case of dynamic content
setTimeout(fixArticleCards, 100);
});
+1
View File
@@ -0,0 +1 @@
//Directory Page Card Structure Fix document.addEventListener('DOMContentLoaded',function(){function fixArticleCards(){const articlesGrid=document.querySelector('.articles-grid');if (!articlesGrid) return;const cards=articlesGrid.querySelectorAll('.article-card');cards.forEach(card=>{if (card.tagName.toLowerCase()!=='a') return;const hasImage=card.querySelector('.article-card-image');const hasContent=card.querySelector('.article-card-content');if (!hasImage||!hasContent){console.warn('Article card structure issue detected:',card);const nextSibling=card.nextElementSibling;if (nextSibling&&nextSibling.classList.contains('article-card-content')){card.appendChild(nextSibling);}}card.style.display='flex';card.style.flexDirection='column';});}fixArticleCards();setTimeout(fixArticleCards,100);});
+299
View File
@@ -0,0 +1,299 @@
// Language Router - Multi-language URL management for Odoo Expertos
// Supports: ES (default, no prefix), EN, DE, FR
const LanguageRouter = {
// Supported languages with their configurations
languages: {
es: { code: 'es', name: 'Español', prefix: '', flag: '🇪🇸', hreflang: 'es' },
en: { code: 'en', name: 'English', prefix: '/en', flag: '🇬🇧', hreflang: 'en' },
de: { code: 'de', name: 'Deutsch', prefix: '/de', flag: '🇩🇪', hreflang: 'de' },
fr: { code: 'fr', name: 'Français', prefix: '/fr', flag: '🇫🇷', hreflang: 'fr' },
pt: { code: 'pt', name: 'Português', prefix: '/pt', flag: '🇧🇷', hreflang: 'pt' },
ar: { code: 'ar', name: 'العربية', prefix: '/ar', flag: '🇸🇦', hreflang: 'ar', rtl: true }
},
// Navigation labels per language
navLabels: {
es: { home: 'Inicio', odoo: 'Odoo', odooIA: 'Odoo IA', hosting: 'Hosting Odoo' },
en: { home: 'Home', odoo: 'Odoo', odooIA: 'Odoo AI', hosting: 'Odoo Hosting' },
de: { home: 'Startseite', odoo: 'Odoo', odooIA: 'Odoo KI', hosting: 'Odoo Hosting' },
fr: { home: 'Accueil', odoo: 'Odoo', odooIA: 'Odoo IA', hosting: 'Hébergement Odoo' },
pt: { home: 'Início', odoo: 'Odoo', odooIA: 'Odoo IA', hosting: 'Hospedagem Odoo' },
ar: { home: 'الرئيسية', odoo: 'Odoo', odooIA: 'Odoo AI', hosting: 'استضافة Odoo' }
},
// Footer labels per language
footerLabels: {
es: {
contact: 'Contacto',
quickLinks: 'Enlaces Rápidos',
legal: 'Legal',
modules: 'Módulos Odoo',
benefits: 'Beneficios',
community: 'Comunidad',
legalNotice: 'Aviso Legal',
privacy: 'Política de Privacidad',
cookies: 'Política de Cookies',
terms: 'Términos de Uso',
copyright: 'Todos los derechos reservados.',
experts: 'Expertos en soluciones Odoo ERP'
},
en: {
contact: 'Contact',
quickLinks: 'Quick Links',
legal: 'Legal',
modules: 'Odoo Modules',
benefits: 'Benefits',
community: 'Community',
legalNotice: 'Legal Notice',
privacy: 'Privacy Policy',
cookies: 'Cookie Policy',
terms: 'Terms of Use',
copyright: 'All rights reserved.',
experts: 'Odoo ERP Solutions Experts'
},
de: {
contact: 'Kontakt',
quickLinks: 'Schnelllinks',
legal: 'Rechtliches',
modules: 'Odoo Module',
benefits: 'Vorteile',
community: 'Community',
legalNotice: 'Impressum',
privacy: 'Datenschutzerklärung',
cookies: 'Cookie-Richtlinie',
terms: 'Nutzungsbedingungen',
copyright: 'Alle Rechte vorbehalten.',
experts: 'Odoo ERP Lösungsexperten'
},
fr: {
contact: 'Contact',
quickLinks: 'Liens Rapides',
legal: 'Mentions Légales',
modules: 'Modules Odoo',
benefits: 'Avantages',
community: 'Communauté',
legalNotice: 'Mentions Légales',
privacy: 'Politique de Confidentialité',
cookies: 'Politique des Cookies',
terms: "Conditions d'Utilisation",
copyright: 'Tous droits réservés.',
experts: 'Experts en solutions Odoo ERP'
},
pt: {
contact: 'Contato',
quickLinks: 'Links Rápidos',
legal: 'Legal',
modules: 'Módulos Odoo',
benefits: 'Benefícios',
community: 'Comunidade',
legalNotice: 'Aviso Legal',
privacy: 'Política de Privacidade',
cookies: 'Política de Cookies',
terms: 'Termos de Uso',
copyright: 'Todos os direitos reservados.',
experts: 'Especialistas em soluções Odoo ERP'
},
ar: {
contact: 'اتصل بنا',
quickLinks: 'روابط سريعة',
legal: 'قانوني',
modules: 'وحدات Odoo',
benefits: 'المزايا',
community: 'المجتمع',
legalNotice: 'إشعار قانوني',
privacy: 'سياسة الخصوصية',
cookies: 'سياسة ملفات تعريف الارتباط',
terms: 'شروط الاستخدام',
copyright: 'جميع الحقوق محفوظة.',
experts: 'خبراء حلول Odoo ERP'
}
},
// Detect current language from URL
detectLanguage: function() {
const path = window.location.pathname;
if (path.startsWith('/en/') || path === '/en') return 'en';
if (path.startsWith('/de/') || path === '/de') return 'de';
if (path.startsWith('/fr/') || path === '/fr') return 'fr';
if (path.startsWith('/pt/') || path === '/pt') return 'pt';
if (path.startsWith('/ar/') || path === '/ar') return 'ar';
return 'es'; // Default
},
// Get current language config
getCurrentLanguage: function() {
const lang = this.detectLanguage();
return this.languages[lang];
},
// Get base path (without language prefix)
getBasePath: function() {
const path = window.location.pathname;
const lang = this.detectLanguage();
if (lang === 'es') return path;
const prefix = this.languages[lang].prefix;
if (path.startsWith(prefix)) {
const basePath = path.substring(prefix.length);
return basePath || '/';
}
return path;
},
// Build URL for a specific language
buildLanguageUrl: function(targetLang) {
const basePath = this.getBasePath();
const config = this.languages[targetLang];
if (targetLang === 'es') {
return basePath;
}
return config.prefix + (basePath === '/' ? '/' : basePath);
},
// Generate all alternate URLs for hreflang
getAlternateUrls: function() {
const basePath = this.getBasePath();
const baseUrl = 'https://odoo-expertos.com';
const alternates = [];
Object.keys(this.languages).forEach(lang => {
const config = this.languages[lang];
let url;
if (lang === 'es') {
url = baseUrl + basePath;
} else {
url = baseUrl + config.prefix + (basePath === '/' ? '/' : basePath);
}
alternates.push({
lang: config.hreflang,
url: url
});
});
// Add x-default (Spanish)
alternates.push({
lang: 'x-default',
url: baseUrl + basePath
});
return alternates;
},
// Generate hreflang link tags HTML
generateHreflangTags: function() {
const alternates = this.getAlternateUrls();
return alternates.map(alt =>
`<link rel="alternate" hreflang="${alt.lang}" href="${alt.url}" />`
).join('\n ');
},
// Inject hreflang tags into document head
injectHreflangTags: function() {
// Remove existing hreflang tags
document.querySelectorAll('link[hreflang]').forEach(el => el.remove());
const alternates = this.getAlternateUrls();
alternates.forEach(alt => {
const link = document.createElement('link');
link.rel = 'alternate';
link.hreflang = alt.lang;
link.href = alt.url;
document.head.appendChild(link);
});
},
// Get navigation labels for current language
getNavLabels: function() {
const lang = this.detectLanguage();
return this.navLabels[lang];
},
// Get footer labels for current language
getFooterLabels: function() {
const lang = this.detectLanguage();
return this.footerLabels[lang];
},
// Get link prefix for current language
getLinkPrefix: function() {
const lang = this.detectLanguage();
return this.languages[lang].prefix;
},
// Switch to another language
switchLanguage: function(targetLang) {
const newUrl = this.buildLanguageUrl(targetLang);
window.location.href = newUrl;
},
// Create language switcher HTML
createLanguageSwitcher: function() {
const currentLang = this.detectLanguage();
const currentConfig = this.languages[currentLang];
let dropdownItems = '';
Object.keys(this.languages).forEach(lang => {
const config = this.languages[lang];
const isActive = lang === currentLang ? ' active' : '';
const url = this.buildLanguageUrl(lang);
dropdownItems += `
<a href="${url}" class="lang-option${isActive}" data-lang="${lang}">
<span class="lang-flag">${config.flag}</span>
<span class="lang-name">${config.name}</span>
</a>`;
});
return `
<div class="language-switcher">
<button class="lang-toggle" aria-label="Select language">
<span class="lang-flag">${currentConfig.flag}</span>
<span class="lang-code">${currentLang.toUpperCase()}</span>
<svg class="lang-arrow" width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M2 4L6 8L10 4" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
</button>
<div class="lang-dropdown">
${dropdownItems}
</div>
</div>
`;
},
// Initialize language switcher functionality
initLanguageSwitcher: function() {
document.addEventListener('click', (e) => {
const toggle = e.target.closest('.lang-toggle');
const switcher = document.querySelector('.language-switcher');
if (toggle) {
e.preventDefault();
switcher.classList.toggle('open');
} else if (!e.target.closest('.language-switcher')) {
if (switcher) switcher.classList.remove('open');
}
});
},
// Initialize all language features
init: function() {
this.injectHreflangTags();
this.initLanguageSwitcher();
// Set html lang attribute
const lang = this.detectLanguage();
document.documentElement.lang = lang;
}
};
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => LanguageRouter.init());
} else {
LanguageRouter.init();
}
+258
View File
@@ -0,0 +1,258 @@
/**
* Upstash Client-Side Cache Integration
* Provides intelligent caching for API calls and dynamic content
*/
class UpstashClient {
constructor() {
this.cacheEndpoint = '/api/cache';
this.localCache = new Map();
this.pendingRequests = new Map();
}
/**
* Get data from cache or fetch if not available
*/
async get(key, fetcher, ttl = 3600) {
// Check local memory cache first
const localData = this.localCache.get(key);
if (localData && Date.now() - localData.timestamp < localData.ttl * 1000) {
console.log(`Local cache HIT: ${key}`);
return localData.data;
}
// Check if request is already pending
if (this.pendingRequests.has(key)) {
console.log(`Waiting for pending request: ${key}`);
return this.pendingRequests.get(key);
}
// Create promise for this request
const promise = this._fetchWithCache(key, fetcher, ttl);
this.pendingRequests.set(key, promise);
try {
const result = await promise;
return result;
} finally {
this.pendingRequests.delete(key);
}
}
async _fetchWithCache(key, fetcher, ttl) {
try {
// Try to get from Upstash cache
const cached = await this._getFromUpstash(key);
if (cached) {
console.log(`Upstash cache HIT: ${key}`);
// Store in local cache
this.localCache.set(key, {
data: cached,
timestamp: Date.now(),
ttl: ttl
});
return cached;
}
console.log(`Cache MISS: ${key} - fetching fresh data`);
// Fetch fresh data
const freshData = await fetcher();
// Store in both Upstash and local cache
await this._setInUpstash(key, freshData, ttl);
this.localCache.set(key, {
data: freshData,
timestamp: Date.now(),
ttl: ttl
});
return freshData;
} catch (error) {
console.error(`Cache error for ${key}:`, error);
// Fallback to fetcher on error
return fetcher();
}
}
async _getFromUpstash(key) {
try {
const response = await fetch(`${this.cacheEndpoint}?action=get&key=${encodeURIComponent(key)}`);
if (response.ok) {
const result = await response.json();
return result.data;
}
} catch (error) {
console.error('Upstash get error:', error);
}
return null;
}
async _setInUpstash(key, value, ttl) {
try {
await fetch(this.cacheEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
action: 'set',
key: key,
value: JSON.stringify(value),
ttl: ttl
})
});
} catch (error) {
console.error('Upstash set error:', error);
}
}
/**
* Invalidate cache for a specific key or pattern
*/
async invalidate(pattern) {
// Clear local cache
if (pattern === '*') {
this.localCache.clear();
} else {
for (const [key] of this.localCache) {
if (key.includes(pattern)) {
this.localCache.delete(key);
}
}
}
// Invalidate Upstash cache
try {
await fetch('/api/cache/invalidate', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ pattern })
});
} catch (error) {
console.error('Cache invalidation error:', error);
}
}
/**
* Preload data into cache
*/
async preload(key, fetcher, ttl = 3600) {
// Don't wait for result
this.get(key, fetcher, ttl).catch(console.error);
}
/**
* Get cache statistics
*/
async getStats() {
try {
const response = await fetch('/api/cache/stats');
if (response.ok) {
const stats = await response.json();
stats.localCacheSize = this.localCache.size;
return stats;
}
} catch (error) {
console.error('Stats error:', error);
}
return { localCacheSize: this.localCache.size };
}
}
// Initialize global cache client
window.upstashCache = new UpstashClient();
// Intelligent prefetching based on user behavior
document.addEventListener('DOMContentLoaded', () => {
// Prefetch on link hover
let prefetchedUrls = new Set();
document.querySelectorAll('a[href^="/"]').forEach(link => {
link.addEventListener('mouseenter', () => {
const href = link.getAttribute('href');
if (!prefetchedUrls.has(href)) {
prefetchedUrls.add(href);
// Prefetch the page content
const cacheKey = `page:${href}`;
window.upstashCache.preload(cacheKey, async () => {
const response = await fetch(href);
return response.text();
}, 3600);
}
});
});
// Cache API responses
const originalFetch = window.fetch;
window.fetch = function(url, options = {}) {
// Only cache GET requests to our API
if (typeof url === 'string' &&
url.startsWith('/api/') &&
(!options.method || options.method === 'GET') &&
!url.includes('/cache')) {
const cacheKey = `api:${url}`;
return window.upstashCache.get(cacheKey, () => originalFetch(url, options), 1800);
}
return originalFetch(url, options);
};
// Performance monitoring
if ('PerformanceObserver' in window) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'navigation') {
console.log('Page Load Performance:', {
domContentLoaded: entry.domContentLoadedEventEnd - entry.domContentLoadedEventStart,
loadComplete: entry.loadEventEnd - entry.loadEventStart,
totalTime: entry.loadEventEnd - entry.fetchStart
});
// Report to analytics if needed
if (window.gtag) {
window.gtag('event', 'page_load_time', {
value: Math.round(entry.loadEventEnd - entry.fetchStart),
metric_name: 'load_time'
});
}
}
}
});
observer.observe({ entryTypes: ['navigation'] });
}
// Cache warming for critical resources
const criticalResources = [
'/odoo/',
'/odoo-ia/',
'/odoo-hosting/'
];
// Warm cache in idle time
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
criticalResources.forEach(resource => {
const cacheKey = `page:${resource}`;
window.upstashCache.preload(cacheKey, async () => {
const response = await fetch(resource);
return response.text();
}, 7200); // 2 hours for directory pages
});
});
}
});
// Export for use in other scripts
window.UpstashClient = UpstashClient;
// Log cache status
console.log('🚀 Upstash client-side caching initialized');
window.upstashCache.getStats().then(stats => {
console.log('📊 Cache stats:', stats);
});