feat: initial WooDoo plugin – WooCommerce & Odoo 19 integration

- Odoo JSON-RPC client (no Composer, uses wp_remote_post)
- Admin settings page under WooCommerce with connection test
- Customer linking: search Odoo partners from WP user profile
- My Account: Odoo Invoices tab with PDF proxy download
- My Account: Book a Meeting tab (slot calculator + calendar.event)
- WC order → Odoo sale.order auto-sync on processing status
- Products matched by SKU; partner auto-created from billing info
- Uninstall cleanup (options, user meta, order meta, DB table)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Malin
2026-04-01 13:58:27 +02:00
commit 68c1ff4455
13 changed files with 2478 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
/* WooDoo Admin Styles */
.woodoo-settings .nav-tab-wrapper { margin-bottom: 0; }
.woodoo-settings .woodoo-tab { padding: 20px 0; }
.woodoo-info-box {
background: #fff8e1;
border: 1px solid #ffe082;
border-radius: 4px;
padding: 12px 16px;
margin-top: 16px;
font-size: .875rem;
max-width: 760px;
}
.woodoo-info-box ul { margin: .5rem 0 0 1.2rem; }
.woodoo-info-box li { margin-bottom: 4px; }
.woodoo-customer-linker { margin-bottom: 1.5rem; }
.woodoo-customer-linker label { display: block; margin-bottom: 6px; font-weight: 600; }
#woo-customer-results { margin-top: 10px; }
.woodoo-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: .75rem;
font-weight: 600;
}
.woodoo-badge--green { background: #d1fae5; color: #065f46; }
.woodoo-badge--red { background: #fee2e2; color: #991b1b; }

189
assets/css/woodoo.css Normal file
View File

@@ -0,0 +1,189 @@
/* =========================================================
WooDoo Frontend Styles
========================================================= */
/* ── Shared ──────────────────────────────────────────────── */
.woodoo-notice {
padding: 12px 16px;
border-radius: 4px;
margin: 12px 0;
font-size: .9rem;
}
.woodoo-notice.woodoo-success { background: #ecfdf5; border: 1px solid #6ee7b7; color: #065f46; }
.woodoo-notice.woodoo-error { background: #fef2f2; border: 1px solid #fca5a5; color: #991b1b; }
.woodoo-empty { color: #6b7280; font-style: italic; }
.woodoo-section { margin-bottom: 2rem; }
.woodoo-section h3 { font-size: 1.15rem; margin-bottom: .5rem; }
.woodoo-desc { color: #6b7280; margin-bottom: 1rem; font-size: .9rem; }
/* ── Buttons ─────────────────────────────────────────────── */
.woodoo-btn {
display: inline-block;
padding: 8px 16px;
border-radius: 4px;
font-size: .875rem;
font-weight: 600;
cursor: pointer;
border: none;
text-decoration: none;
transition: opacity .15s;
line-height: 1.4;
}
.woodoo-btn:hover { opacity: .85; text-decoration: none; }
.woodoo-btn--primary { background: #2271b1; color: #fff; }
.woodoo-btn--sm { padding: 4px 10px; font-size: .8rem; }
.woodoo-btn--outline { background: transparent; border: 1px solid #d1d5db; color: #374151; }
.woodoo-btn:disabled { opacity: .5; cursor: not-allowed; }
/* ── Badges ──────────────────────────────────────────────── */
.woodoo-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: .75rem;
font-weight: 600;
white-space: nowrap;
}
.woodoo-badge--green { background: #d1fae5; color: #065f46; }
.woodoo-badge--red { background: #fee2e2; color: #991b1b; }
.woodoo-badge--orange { background: #fed7aa; color: #92400e; }
.woodoo-badge--blue { background: #dbeafe; color: #1e40af; }
.woodoo-badge--grey { background: #f3f4f6; color: #6b7280; }
/* ── Table ───────────────────────────────────────────────── */
.woodoo-table-wrap { overflow-x: auto; }
.woodoo-table {
width: 100%;
border-collapse: collapse;
font-size: .875rem;
}
.woodoo-table th,
.woodoo-table td {
padding: 10px 12px;
border-bottom: 1px solid #e5e7eb;
text-align: left;
vertical-align: middle;
}
.woodoo-table th { background: #f9fafb; font-weight: 600; }
.woodoo-table tr:last-child td { border-bottom: none; }
.woodoo-table .woodoo-amount { text-align: right; font-variant-numeric: tabular-nums; }
.woodoo-table .woodoo-inv-number { font-weight: 600; }
.woodoo-overdue { color: #dc2626; font-weight: 600; }
/* ── Pagination ──────────────────────────────────────────── */
.woodoo-pagination {
margin-top: 1rem;
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.woodoo-pagination a,
.woodoo-pagination .woodoo-page-current {
padding: 4px 10px;
border-radius: 4px;
border: 1px solid #d1d5db;
font-size: .875rem;
text-decoration: none;
}
.woodoo-pagination .woodoo-page-current {
background: #2271b1;
color: #fff;
border-color: #2271b1;
font-weight: 600;
}
/* ── Calendar / Booking ──────────────────────────────────── */
.woodoo-booking-form .woodoo-field {
margin-bottom: 1.25rem;
}
.woodoo-booking-form label {
display: block;
font-weight: 600;
margin-bottom: 6px;
font-size: .9rem;
}
.woodoo-booking-form input[type="date"],
.woodoo-booking-form textarea {
width: 100%;
max-width: 320px;
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: .9rem;
}
.woodoo-booking-form textarea { max-width: 480px; resize: vertical; }
.woodoo-slots-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.woodoo-slot {
padding: 8px 14px;
border: 2px solid #d1d5db;
border-radius: 6px;
font-size: .875rem;
cursor: pointer;
background: #fff;
transition: border-color .15s, background .15s;
white-space: nowrap;
}
.woodoo-slot:hover { border-color: #2271b1; background: #eff6ff; }
.woodoo-slot.selected{ border-color: #2271b1; background: #dbeafe; font-weight: 600; }
.woodoo-selected-slot-display {
margin-bottom: 1rem;
padding: 10px 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 4px;
font-size: .9rem;
}
/* ── Meeting Cards ───────────────────────────────────────── */
.woodoo-meetings-grid {
display: grid;
gap: 12px;
}
.woodoo-meeting-card {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.woodoo-meeting-card__date {
display: flex;
flex-direction: column;
align-items: center;
min-width: 44px;
background: #2271b1;
color: #fff;
border-radius: 6px;
padding: 6px 8px;
text-align: center;
}
.woodoo-meeting-card__day { font-size: 1.4rem; font-weight: 700; line-height: 1; }
.woodoo-meeting-card__month { font-size: .7rem; text-transform: uppercase; }
.woodoo-meeting-card__info { flex: 1; display: flex; flex-direction: column; gap: 3px; }
.woodoo-meeting-card__time { font-size: .85rem; color: #6b7280; }
.woodoo-meeting-card__video { font-size: .85rem; font-weight: 600; }
.woodoo-meeting-card__loc { font-size: .85rem; color: #6b7280; }
/* ── Admin styles (woodoo-admin.css shares this file via enqueueing) ── */
.woodoo-settings .woodoo-tab { padding: 16px 0; }
.woodoo-info-box {
background: #fff8e1;
border: 1px solid #ffe082;
border-radius: 4px;
padding: 12px 16px;
margin-top: 12px;
font-size: .875rem;
}
.woodoo-info-box ul { margin: .5rem 0 0 1.2rem; }
.woodoo-info-box li { margin-bottom: 4px; }
.woodoo-order-meta { border-top: 1px solid #e5e7eb; padding-top: 10px; }

130
assets/js/woodoo-admin.js Normal file
View File

@@ -0,0 +1,130 @@
/* WooDoo Admin JS */
jQuery( function ( $ ) {
const cfg = window.WooDooAdmin || {};
// ── Tab navigation ──────────────────────────────────────────────────
$( '.woodoo-settings .nav-tab' ).on( 'click', function ( e ) {
e.preventDefault();
const target = $( this ).attr( 'href' );
$( '.woodoo-settings .nav-tab' ).removeClass( 'nav-tab-active' );
$( this ).addClass( 'nav-tab-active' );
$( '.woodoo-tab' ).hide();
$( target ).show();
} );
// ── Test Connection ──────────────────────────────────────────────────
$( '#woodoo-test-connection' ).on( 'click', function () {
const $btn = $( this );
const $result = $( '#woodoo-test-result' );
$btn.prop( 'disabled', true ).text( cfg.i18n.testing );
$result.html( '' );
$.post( cfg.ajax_url, {
action : 'woodoo_test_connection',
nonce : cfg.nonce,
} )
.done( function ( res ) {
if ( res.success ) {
const d = res.data;
const cls = d.success ? 'success' : 'error';
$result.html(
'<span class="woodoo-badge woodoo-badge--' + ( d.success ? 'green' : 'red' ) + '">' +
escHtml( d.message ) + '</span>'
);
} else {
$result.html( '<span style="color:red;">' + escHtml( res.data || cfg.i18n.error ) + '</span>' );
}
} )
.fail( function () {
$result.html( '<span style="color:red;">' + escHtml( cfg.i18n.error ) + '</span>' );
} )
.always( function () {
$btn.prop( 'disabled', false ).text( 'Test Connection' );
} );
} );
// ── Partner search on user profile ──────────────────────────────────
$( '#woodoo-partner-search-btn' ).on( 'click', function () {
const $input = $( '#woodoo-partner-search-input' );
const $results = $( '#woodoo-partner-search-results' );
const query = $input.val().trim();
const userId = $input.data( 'user-id' );
if ( ! query ) return;
$results.html( '<em>' + escHtml( cfg.i18n.searching ) + '</em>' );
$.post( cfg.ajax_url, {
action : 'woodoo_search_partners',
nonce : cfg.nonce,
query : query,
} )
.done( function ( res ) {
if ( ! res.success || ! res.data.length ) {
$results.html( '<em>No partners found.</em>' );
return;
}
let html = '<ul style="margin:0;padding:0;list-style:none;">';
res.data.forEach( function ( p ) {
html +=
'<li style="padding:4px 0;border-bottom:1px solid #eee;">' +
'<strong>' + escHtml( p.name ) + '</strong>' +
( p.email ? ' &lt;' + escHtml( p.email ) + '&gt;' : '' ) +
' <button type="button" class="button button-small woodoo-pick-partner"' +
' data-id="' + parseInt( p.id, 10 ) + '"' +
' data-name="' + escAttr( p.name ) + '"' +
' data-user-id="' + parseInt( userId, 10 ) + '">' +
'Select</button>' +
'</li>';
} );
html += '</ul>';
$results.html( html );
} )
.fail( function () {
$results.html( '<em>' + escHtml( cfg.i18n.error ) + '</em>' );
} );
} );
// ── Pick partner from search results ────────────────────────────────
$( document ).on( 'click', '.woodoo-pick-partner', function () {
const $btn = $( this );
const partnerId = $btn.data( 'id' );
const name = $btn.data( 'name' );
const userId = $btn.data( 'user-id' );
$( '#woodoo_odoo_partner_id' ).val( partnerId );
$( '#woodoo-partner-name-display' ).text( name ).show();
$( '#woodoo-partner-search-results' ).html(
'<span style="color:green;">' + escHtml( cfg.i18n.link_done ) + ' → ' + escHtml( name ) + ' (#' + partnerId + ')</span>'
);
// Persist immediately if we have the AJAX handler
if ( userId ) {
$.post( cfg.ajax_url, {
action : 'woodoo_link_customer',
nonce : cfg.nonce,
user_id : userId,
partner_id : partnerId,
} );
}
} );
// ── Utility ──────────────────────────────────────────────────────────
function escHtml( str ) {
return String( str )
.replace( /&/g, '&amp;' )
.replace( /</g, '&lt;' )
.replace( />/g, '&gt;' )
.replace( /"/g, '&quot;' );
}
function escAttr( str ) {
return escHtml( str ).replace( /'/g, '&#039;' );
}
} );

View File

@@ -0,0 +1,248 @@
/* WooDoo Frontend JS Calendar Booking */
( function () {
'use strict';
const cfg = window.WooDooCalendar;
if ( ! cfg ) return; // not on calendar page
const i18n = cfg.i18n;
const ajaxUrl = cfg.ajax_url;
const nonce = cfg.nonce;
// ── Element refs ────────────────────────────────────────────────────
const dateInput = document.getElementById( 'woodoo-booking-date' );
const slotsWrap = document.getElementById( 'woodoo-slots-wrap' );
const slotsGrid = document.getElementById( 'woodoo-slots-grid' );
const confirmBlock = document.getElementById( 'woodoo-booking-confirm' );
const bookBtn = document.getElementById( 'woodoo-book-btn' );
const notesArea = document.getElementById( 'woodoo-booking-notes' );
const slotLabel = document.getElementById( 'woodoo-selected-slot-label' );
const successMsg = document.getElementById( 'woodoo-booking-success' );
const errorMsg = document.getElementById( 'woodoo-booking-error' );
let selectedStart = null;
let selectedEnd = null;
// ── Date change → load slots ────────────────────────────────────────
if ( dateInput ) {
dateInput.addEventListener( 'change', function () {
const date = this.value;
if ( ! date ) return;
slotsGrid.innerHTML = '<em>' + esc( i18n.loading ) + '</em>';
slotsWrap.style.display = 'block';
confirmBlock.style.display = 'none';
successMsg.style.display = 'none';
errorMsg.style.display = 'none';
selectedStart = selectedEnd = null;
bookBtn.disabled = true;
post( 'woodoo_get_slots', { date } )
.then( res => {
if ( ! res.success ) {
slotsGrid.innerHTML = '<em>' + esc( i18n.error ) + '</em>';
return;
}
const slots = res.data;
if ( ! slots.length ) {
slotsGrid.innerHTML = '<em>' + esc( i18n.no_slots ) + '</em>';
confirmBlock.style.display = 'none';
return;
}
slotsGrid.innerHTML = '';
slots.forEach( slot => {
const btn = document.createElement( 'button' );
btn.type = 'button';
btn.className = 'woodoo-slot';
btn.textContent = formatTime( slot.start ) + ' ' + formatTime( slot.end );
btn.dataset.start = slot.start;
btn.dataset.end = slot.end;
btn.addEventListener( 'click', onSlotClick );
slotsGrid.appendChild( btn );
} );
} )
.catch( () => {
slotsGrid.innerHTML = '<em>' + esc( i18n.error ) + '</em>';
} );
} );
}
// ── Slot selection ───────────────────────────────────────────────────
function onSlotClick( e ) {
document.querySelectorAll( '.woodoo-slot' )
.forEach( b => b.classList.remove( 'selected' ) );
const btn = e.currentTarget;
btn.classList.add( 'selected' );
selectedStart = btn.dataset.start;
selectedEnd = btn.dataset.end;
slotLabel.textContent =
new Date( selectedStart ).toLocaleDateString( undefined, {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
} ) + ' ' + formatTime( selectedStart ) + ' ' + formatTime( selectedEnd );
confirmBlock.style.display = 'block';
bookBtn.disabled = false;
successMsg.style.display = 'none';
errorMsg.style.display = 'none';
}
// ── Confirm booking ──────────────────────────────────────────────────
if ( bookBtn ) {
bookBtn.addEventListener( 'click', function () {
if ( ! selectedStart || ! selectedEnd ) return;
bookBtn.disabled = true;
bookBtn.textContent = i18n.booking;
errorMsg.style.display = 'none';
post( 'woodoo_book_slot', {
start : selectedStart,
end : selectedEnd,
notes : notesArea ? notesArea.value : '',
} )
.then( res => {
if ( res.success ) {
successMsg.textContent = res.data.message;
successMsg.style.display = 'block';
confirmBlock.style.display = 'none';
slotsWrap.style.display = 'none';
dateInput.value = '';
selectedStart = selectedEnd = null;
// Re-fetch meetings after booking
refreshMeetings();
} else {
showError( res.data || i18n.error );
bookBtn.disabled = false;
bookBtn.textContent = i18n.book_btn;
}
} )
.catch( () => {
showError( i18n.error );
bookBtn.disabled = false;
bookBtn.textContent = i18n.book_btn;
} );
} );
}
// ── Cancel meeting ───────────────────────────────────────────────────
document.querySelectorAll( '.woodoo-cancel-meeting' ).forEach( btn => {
btn.addEventListener( 'click', function () {
if ( ! confirm( i18n.cancel_confirm ) ) return;
const eventId = this.dataset.eventId;
const card = this.closest( '.woodoo-meeting-card' );
this.disabled = true;
this.textContent = i18n.cancelling;
post( 'woodoo_cancel_meeting', { event_id: eventId } )
.then( res => {
if ( res.success ) {
card.style.opacity = '0.4';
card.style.transition = 'opacity .3s';
setTimeout( () => card.remove(), 350 );
} else {
alert( res.data || i18n.error );
this.disabled = false;
this.textContent = 'Cancel';
}
} )
.catch( () => {
this.disabled = false;
this.textContent = 'Cancel';
} );
} );
} );
// ── Refresh meetings list ────────────────────────────────────────────
function refreshMeetings() {
const listEl = document.getElementById( 'woodoo-meetings-list' );
if ( ! listEl ) return;
post( 'woodoo_get_meetings', {} )
.then( res => {
if ( ! res.success ) return;
const meetings = res.data;
if ( ! meetings.length ) {
listEl.innerHTML = '<p class="woodoo-empty">' + esc( 'No upcoming meetings scheduled.' ) + '</p>';
return;
}
let html = '<div class="woodoo-meetings-grid">';
meetings.forEach( ev => {
const start = new Date( ev.start.replace( ' ', 'T' ) );
const end = new Date( ev.stop.replace( ' ', 'T' ) );
html +=
'<div class="woodoo-meeting-card" data-event-id="' + ev.id + '">' +
'<div class="woodoo-meeting-card__date">' +
'<span class="woodoo-meeting-card__day">' + pad( start.getDate() ) + '</span>' +
'<span class="woodoo-meeting-card__month">' + monthShort( start ) + '</span>' +
'</div>' +
'<div class="woodoo-meeting-card__info">' +
'<strong>' + esc( ev.name ) + '</strong>' +
'<span class="woodoo-meeting-card__time">' +
timeStr( start ) + ' ' + timeStr( end ) +
'</span>' +
( ev.videocall_location
? '<a href="' + esc( ev.videocall_location ) + '" target="_blank" rel="noopener" class="woodoo-meeting-card__video">Join Video Call</a>'
: '' ) +
'</div>' +
'<div class="woodoo-meeting-card__actions">' +
'<button class="woodoo-cancel-meeting woodoo-btn woodoo-btn--sm woodoo-btn--outline" data-event-id="' + ev.id + '">Cancel</button>' +
'</div>' +
'</div>';
} );
html += '</div>';
listEl.innerHTML = html;
// Re-bind cancel buttons
listEl.querySelectorAll( '.woodoo-cancel-meeting' ).forEach( btn => {
btn.addEventListener( 'click', function () {
// same handler as above re-use via custom event trick
btn.dispatchEvent( new Event( 'woodoo:cancel' ) );
} );
} );
} );
}
// ── Helpers ──────────────────────────────────────────────────────────
function post( action, data ) {
const body = new URLSearchParams( Object.assign( { action, nonce }, data ) );
return fetch( ajaxUrl, { method: 'POST', body } )
.then( r => r.json() );
}
function formatTime( datetimeStr ) {
return datetimeStr.slice( 11, 16 );
}
function timeStr( d ) {
return pad( d.getHours() ) + ':' + pad( d.getMinutes() );
}
function pad( n ) { return String( n ).padStart( 2, '0' ); }
function monthShort( d ) {
return d.toLocaleDateString( undefined, { month: 'short' } );
}
function esc( str ) {
return String( str )
.replace( /&/g, '&amp;' )
.replace( /</g, '&lt;' )
.replace( />/g, '&gt;' )
.replace( /"/g, '&quot;' );
}
function showError( msg ) {
errorMsg.textContent = msg;
errorMsg.style.display = 'block';
}
} )();