diff --git a/disciple-tools-homescreen-apps.php b/disciple-tools-homescreen-apps.php index a05b151..eb5762e 100755 --- a/disciple-tools-homescreen-apps.php +++ b/disciple-tools-homescreen-apps.php @@ -93,6 +93,7 @@ private function __construct() { require_once( 'magic-link/link-zume.php' ); require_once( 'magic-link/link-waha.php' ); require_once( 'magic-link/my-coaching.php' ); + require_once( 'magic-link/my-contacts/my-contacts.php' ); // require_once( 'magic-link/dispatcher-contacts.php' ); require_once( 'magic-link/dispatcher-magic-link.php' ); $this->i18n(); diff --git a/magic-link/my-contacts/my-contacts.css b/magic-link/my-contacts/my-contacts.css new file mode 100644 index 0000000..8009c60 --- /dev/null +++ b/magic-link/my-contacts/my-contacts.css @@ -0,0 +1,729 @@ +:root { + --primary-color: #3f729b; + --success-color: #4caf50; + --warning-color: #ff9800; + --danger-color: #f44336; + --border-color: #e0e0e0; + --bg-color: #f5f5f5; + --card-bg: #ffffff; +} + +* { + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + margin: 0; + padding: 0; + background-color: var(--bg-color); +} + +.my-contacts-container { + display: grid; + grid-template-columns: 320px 1fr; + gap: 16px; + height: 100vh; + padding: 16px; +} + +.panel { + background: var(--card-bg); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.12); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel-header { + padding: 16px; + border-bottom: 1px solid var(--border-color); + font-weight: 600; + font-size: 16px; + background: var(--card-bg); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.panel-header span { + color: #666; + font-weight: normal; + font-size: 14px; +} + +.panel-header .header-contact-name, +.panel-header #mobile-contact-name { + color: var(--primary-color); + font-weight: 600; + font-size: 16px; +} + +.panel-content { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +/* Search input */ +.search-input { + display: block; + width: 100%; + flex-basis: 100%; + margin-top: 4px; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; +} + +.search-input:focus { + outline: none; + border-color: var(--primary-color); +} + +/* Contact List */ +.contact-item { + padding: 12px; + border: 1px solid var(--border-color); + border-radius: 6px; + margin-bottom: 8px; + cursor: pointer; + background: var(--card-bg); + transition: all 0.2s ease; +} + +.contact-item:hover { + border-color: var(--primary-color); + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} + +.contact-item.selected { + border-color: var(--primary-color); + background: #e3f2fd; +} + +.contact-name { + font-weight: 600; + margin-bottom: 4px; + display: flex; + align-items: center; + gap: 8px; +} + +.contact-meta { + font-size: 12px; + color: #666; +} + +.status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; +} + +.source-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 8px; + font-size: 10px; + font-weight: 500; + background: #e3f2fd; + color: #1565c0; +} + +/* Contact Details */ +.contact-name-header { + font-size: 22px; + font-weight: 700; + color: var(--primary-color); + margin: 0 0 20px 0; + padding-bottom: 12px; + border-bottom: 2px solid var(--primary-color); +} + +.details-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + height: 100%; +} + +.details-column { + overflow-y: auto; +} + +.details-column h3 { + margin: 0 0 16px 0; + font-size: 14px; + color: #333; + border-bottom: 1px solid var(--border-color); + padding-bottom: 8px; +} + +.detail-tile { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); +} + +.detail-tile:last-child { + border-bottom: none; +} + +.tile-header { + font-size: 13px; + font-weight: 600; + color: var(--primary-color); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.detail-section { + margin-bottom: 12px; +} + +.detail-label { + font-size: 11px; + color: #888; + text-transform: uppercase; + margin-bottom: 2px; + display: flex; + align-items: center; + gap: 4px; +} + +.field-icon { + color: #aaa; + opacity: 0.7; +} + +img.field-icon { + width: 12px; + height: 12px; + object-fit: contain; + filter: grayscale(100%); +} + +i.field-icon { + font-size: 12px; + width: 12px; + text-align: center; + color: black; +} + +.detail-value { + font-size: 14px; + white-space: pre-line; +} + +.detail-value.empty-value { + color: #999; +} + +.detail-empty { + color: #999; + font-style: italic; +} + +.dt-record-link { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 12px; + padding: 8px 14px; + background: var(--primary-color); + color: white; + text-decoration: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + transition: background 0.2s ease; +} + +.dt-record-link:hover { + background: #2d5a7b; +} + +/* Edit mode */ +.edit-icon { + cursor: pointer; + margin-left: 6px; + color: #666; + font-size: 14px; + transition: color 0.2s ease; +} + +.edit-icon:hover { + color: var(--primary-color); +} + +.detail-section .edit-mode { + display: none; +} + +.detail-section.editing .view-mode, +.detail-section.editing .detail-label { + display: none; +} + +.detail-section.editing .edit-mode { + display: block; +} + +.detail-section.saving .edit-mode { + opacity: 0.6; + pointer-events: none; +} + +.field-saving-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid var(--border-color); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-left: 8px; + vertical-align: middle; +} + +/* DT Component Overrides for Magic Link */ +.edit-mode dt-text, +.edit-mode dt-textarea, +.edit-mode dt-number, +.edit-mode dt-single-select, +.edit-mode dt-multi-select, +.edit-mode dt-date, +.edit-mode dt-multi-text, +.edit-mode dt-tags, +.edit-mode dt-connection, +.edit-mode dt-location { + display: block; + width: 100%; +} + +/* Activity/Comments list */ +.activity-list { + margin-top: 0; +} + +.activity-item { + padding: 12px; + background: var(--bg-color); + border-radius: 6px; + margin-bottom: 8px; +} + +.activity-item.type-comment { + border-left: 3px solid var(--primary-color); +} + +.activity-item.type-activity { + border-left: 3px solid #999; +} + +.activity-author { + font-weight: 600; + font-size: 13px; +} + +.activity-date { + font-size: 11px; + color: #666; + margin-left: 8px; +} + +.activity-type-badge { + font-size: 10px; + padding: 2px 6px; + border-radius: 8px; + margin-left: 8px; + text-transform: uppercase; +} + +.activity-type-badge.comment { + background: #e3f2fd; + color: #1565c0; +} + +.activity-type-badge.activity { + background: #f5f5f5; + color: #666; +} + +/* Collapsible activity group */ +.activity-group { + margin-bottom: 8px; +} + +.activity-group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-color); + border-radius: 6px; + cursor: pointer; + font-size: 13px; + color: #666; + border-left: 3px solid #ccc; +} + +.activity-group-header:hover { + background: #eee; +} + +.activity-group-arrow { + transition: transform 0.2s ease; + font-size: 10px; +} + +.activity-group.expanded .activity-group-arrow { + transform: rotate(90deg); +} + +.activity-group-content { + display: none; + padding-left: 20px; + margin-top: 4px; +} + +.activity-group.expanded .activity-group-content { + display: block; +} + +.activity-compact-item { + padding: 4px 0; + font-size: 12px; + color: #666; + border-bottom: 1px solid #f0f0f0; +} + +.activity-compact-item:last-child { + border-bottom: none; +} + +.activity-compact-author { + font-weight: 500; + color: #333; +} + +.activity-compact-date { + color: #999; + margin-left: 8px; +} + +.activity-content { + margin-top: 6px; + font-size: 14px; + white-space: pre-wrap; +} + +.activity-content a { + color: var(--primary-color); + word-break: break-all; +} + +.mention-tag { + color: var(--primary-color); + font-weight: 500; + background: #e3f2fd; + padding: 1px 4px; + border-radius: 3px; +} + +/* Comment input */ +.comment-input-section { + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); +} + +.comment-input-wrapper { + position: relative; +} + +.comment-textarea { + width: 100%; + min-height: 80px; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 14px; + font-family: inherit; + resize: vertical; +} + +.comment-textarea:focus { + outline: none; + border-color: var(--primary-color); +} + +.comment-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +.comment-submit-btn { + background: var(--primary-color); + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + transition: background 0.2s ease; +} + +.comment-submit-btn:hover { + background: #2d5a7b; +} + +.comment-submit-btn:disabled { + background: #ccc; + cursor: not-allowed; +} + +/* @mention dropdown */ +.mention-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid var(--border-color); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + max-height: 200px; + overflow-y: auto; + display: none; + z-index: 1000; + margin-top: 4px; +} + +.mention-dropdown.show { + display: block; +} + +.mention-item { + padding: 10px 12px; + cursor: pointer; + font-size: 14px; +} + +.mention-item:hover, +.mention-item.active { + background: #e3f2fd; +} + +/* Empty states */ +.empty-state { + text-align: center; + padding: 40px 20px; + color: #666; +} + +.empty-state-icon { + font-size: 48px; + margin-bottom: 16px; +} + +/* Loading */ +.loading { + text-align: center; + padding: 40px; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 4px solid var(--border-color); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Success message */ +.success-toast { + position: fixed; + bottom: 20px; + right: 20px; + background: var(--success-color); + color: white; + padding: 16px 24px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + display: none; + z-index: 1000; +} + +.success-toast.show { + display: block; + animation: slideIn 0.3s ease; +} + +@keyframes slideIn { + from { + transform: translateY(100px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Mobile/Tablet Layout */ +@media (max-width: 1024px) { + .details-grid { + grid-template-columns: 1fr; + } + + .my-contacts-container { + grid-template-columns: 1fr; + height: 100vh; + padding: 0; + gap: 0; + } + + /* Hide contacts list on mobile when contact is selected */ + #contacts-panel.mobile-hidden { + display: none; + } + + /* Hide details panel by default on mobile */ + #details-panel { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + border-radius: 0; + } + + #details-panel.mobile-visible { + display: flex; + } + + .panel { + max-height: none; + height: 100vh; + border-radius: 0; + } + + .panel-header { + position: sticky; + top: 0; + z-index: 10; + } + + .mobile-back-btn { + background: none; + border: none; + font-size: 18px; + cursor: pointer; + padding: 4px 8px; + margin-right: 8px; + } + + .details-grid { + grid-template-columns: 1fr; + } +} + +.mobile-only { + display: none; +} + +/* Mobile navigation arrows */ +.mobile-nav-arrows { + display: none; + margin-left: auto; + gap: 4px; +} + +.nav-arrow { + background: none; + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; + color: var(--primary-color); + font-size: 18px; + line-height: 1; +} + +.nav-arrow:disabled { + color: #ccc; + border-color: #eee; + cursor: not-allowed; +} + +.nav-arrow:not(:disabled):hover { + background: var(--bg-color); +} + +/* Mobile tabs */ +.mobile-tabs { + display: none; + border-bottom: 1px solid var(--border-color); + background: var(--card-bg); +} + +.mobile-tab { + flex: 1; + padding: 12px 16px; + border: none; + background: none; + font-size: 14px; + font-weight: 500; + color: #666; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s ease; +} + +.mobile-tab.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); +} + +@media (max-width: 1024px) { + .mobile-only { + display: inline-block; + } + + .desktop-only { + display: none; + } + + .mobile-nav-arrows { + display: flex; + } + + .mobile-tabs { + display: flex; + } + + .details-column { + display: none; + } + + .details-column.mobile-tab-active { + display: block; + } +} diff --git a/magic-link/my-contacts/my-contacts.js b/magic-link/my-contacts/my-contacts.js new file mode 100644 index 0000000..b010cc8 --- /dev/null +++ b/magic-link/my-contacts/my-contacts.js @@ -0,0 +1,742 @@ +const { createApp, ref, computed, onMounted, nextTick, watch } = Vue; + +const MyContactsApp = createApp({ + setup() { + // Reactive state + const contacts = ref([]); + const filteredContacts = ref([]); + const selectedContactId = ref(null); + const selectedContact = ref(null); + const searchTerm = ref(''); + const loading = ref(true); + const detailsLoading = ref(false); + const contactsCount = computed(() => filteredContacts.value.length); + + // Comment state + const commentText = ref(''); + const commentSubmitting = ref(false); + + // Mention state + const mentionUsers = ref([]); + const mentionActiveIndex = ref(0); + const mentionStartPos = ref(-1); + const showMentionDropdown = ref(false); + let mentionSearchTimeout = null; + + // Mobile state + const isMobileDetailsVisible = ref(false); + const mobileActiveTab = ref('details'); + + // Load contacts on mount + onMounted(() => { + loadContacts(); + setupGlobalEventListeners(); + }); + + // Watch search term to filter contacts + watch(searchTerm, (term) => { + if (!term) { + filteredContacts.value = contacts.value; + } else { + const lowerTerm = term.toLowerCase(); + filteredContacts.value = contacts.value.filter(c => + c.name.toLowerCase().includes(lowerTerm) || + (c.overall_status && c.overall_status.toLowerCase().includes(lowerTerm)) || + (c.seeker_path && c.seeker_path.toLowerCase().includes(lowerTerm)) + ); + } + }); + + // API helper + async function apiRequest(endpoint, data = {}) { + const response = await fetch( + `${myContactsApp.root}${myContactsApp.parts.root}/v1/${myContactsApp.parts.type}/${endpoint}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': myContactsApp.nonce + }, + body: JSON.stringify({ + parts: myContactsApp.parts, + ...data + }) + } + ); + return response.json(); + } + + // Load contacts + async function loadContacts() { + loading.value = true; + try { + const data = await apiRequest('contacts'); + contacts.value = data.contacts || []; + filteredContacts.value = contacts.value; + } catch (error) { + console.error('Error loading contacts:', error); + contacts.value = []; + filteredContacts.value = []; + } finally { + loading.value = false; + } + } + + // Select contact + async function selectContact(contactId) { + selectedContactId.value = contactId; + detailsLoading.value = true; + + if (isMobile()) { + isMobileDetailsVisible.value = true; + } + + try { + const contact = await apiRequest('contact', { contact_id: contactId }); + + if (contact.code) { + selectedContact.value = { error: contact.message || 'Unknown error' }; + return; + } + + // Group activity items + contact.groupedActivity = groupActivityItems(contact.activity || []); + selectedContact.value = contact; + + await nextTick(); + initMentionListeners(); + } catch (error) { + console.error('Error loading contact details:', error); + selectedContact.value = { error: 'Error loading details' }; + } finally { + detailsLoading.value = false; + } + } + + // Group activity items + function groupActivityItems(items) { + const grouped = []; + let currentGroup = null; + + items.forEach(item => { + if (item.type === 'comment') { + if (currentGroup) { + grouped.push({ type: 'activity-group', items: currentGroup }); + currentGroup = null; + } + grouped.push(item); + } else { + if (!currentGroup) currentGroup = []; + currentGroup.push(item); + } + }); + + if (currentGroup) { + grouped.push({ type: 'activity-group', items: currentGroup }); + } + + return grouped; + } + + // Toggle activity group + function toggleActivityGroup(index) { + const group = document.getElementById(`activity-group-${index}`); + if (group) { + group.classList.toggle('expanded'); + } + } + + // Submit comment + async function submitComment() { + const comment = commentText.value.trim(); + if (!comment || !selectedContactId.value) return; + + commentSubmitting.value = true; + + try { + const result = await apiRequest('comment', { + contact_id: selectedContactId.value, + comment: comment + }); + + if (result.success || result.comment_id) { + // Add new comment to grouped activity + const newComment = { + type: 'comment', + id: result.comment_id, + content: comment, + author: result.author || 'You', + timestamp: Math.floor(Date.now() / 1000) + }; + + if (selectedContact.value && selectedContact.value.groupedActivity) { + selectedContact.value.groupedActivity.unshift(newComment); + } + + commentText.value = ''; + showSuccessToast('Comment added successfully!'); + } else { + alert('Failed to add comment: ' + (result.message || 'Unknown error')); + } + } catch (error) { + console.error('Error posting comment:', error); + alert('Error posting comment'); + } finally { + commentSubmitting.value = false; + } + } + + // Mention functionality + function initMentionListeners() { + const textarea = document.getElementById('comment-textarea'); + if (!textarea || textarea.hasAttribute('data-mention-init')) return; + textarea.setAttribute('data-mention-init', 'true'); + + textarea.addEventListener('input', handleMentionInput); + textarea.addEventListener('keydown', handleMentionKeydown); + } + + function handleMentionInput(e) { + const text = e.target.value; + const cursorPos = e.target.selectionStart; + const textBeforeCursor = text.substring(0, cursorPos); + const lastAtIndex = textBeforeCursor.lastIndexOf('@'); + + if (lastAtIndex !== -1) { + const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1); + + if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { + mentionStartPos.value = lastAtIndex; + + clearTimeout(mentionSearchTimeout); + mentionSearchTimeout = setTimeout(() => { + searchMentionUsers(textAfterAt); + }, 200); + return; + } + } + + hideMentionDropdown(); + } + + function handleMentionKeydown(e) { + if (!showMentionDropdown.value) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + mentionActiveIndex.value = Math.min(mentionActiveIndex.value + 1, mentionUsers.value.length - 1); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + mentionActiveIndex.value = Math.max(mentionActiveIndex.value - 1, 0); + } else if (e.key === 'Enter' && mentionUsers.value.length > 0) { + e.preventDefault(); + selectMention(mentionUsers.value[mentionActiveIndex.value]); + } else if (e.key === 'Escape') { + hideMentionDropdown(); + } + } + + async function searchMentionUsers(search) { + if (search.length < 1) { + hideMentionDropdown(); + return; + } + + try { + const data = await apiRequest('users-mention', { search }); + mentionUsers.value = data.users || []; + mentionActiveIndex.value = 0; + + if (mentionUsers.value.length > 0) { + showMentionDropdown.value = true; + } else { + hideMentionDropdown(); + } + } catch (error) { + console.error('Error searching users:', error); + hideMentionDropdown(); + } + } + + function selectMention(user) { + const textarea = document.getElementById('comment-textarea'); + const text = textarea.value; + const cursorPos = textarea.selectionStart; + + const beforeMention = text.substring(0, mentionStartPos.value); + const afterCursor = text.substring(cursorPos); + + const mentionTextStr = `@[${user.name}](${user.ID}) `; + commentText.value = beforeMention + mentionTextStr + afterCursor; + + nextTick(() => { + const newCursorPos = beforeMention.length + mentionTextStr.length; + textarea.setSelectionRange(newCursorPos, newCursorPos); + textarea.focus(); + }); + + hideMentionDropdown(); + } + + function hideMentionDropdown() { + showMentionDropdown.value = false; + mentionUsers.value = []; + mentionStartPos.value = -1; + } + + // Field editing + function toggleEditMode(fieldKey) { + const section = document.querySelector(`.detail-section[data-field-key="${fieldKey}"]`); + if (!section) return; + + const isEditing = section.classList.contains('editing'); + + document.querySelectorAll('.detail-section.editing').forEach(el => { + if (el !== section) { + el.classList.remove('editing'); + } + }); + + if (isEditing) { + section.classList.remove('editing'); + } else { + section.classList.add('editing'); + initFieldChangeListener(section); + } + } + + function initFieldChangeListener(section) { + const editMode = section.querySelector('.edit-mode'); + const component = editMode.querySelector('dt-text, dt-textarea, dt-number, dt-toggle, dt-date, dt-single-select, dt-multi-select, dt-multi-text, dt-tags, dt-connection, dt-location, dt-location-map'); + + if (!component || component.hasAttribute('data-listener-added')) return; + + component.setAttribute('data-listener-added', 'true'); + + const fieldType = section.dataset.fieldType; + + component.addEventListener('change', async (e) => { + const fieldKey = section.dataset.fieldKey; + const contactId = section.dataset.contactId; + const rawValue = e.detail?.newValue ?? e.detail?.value ?? component.value; + + let newValue = rawValue; + if (window.DtWebComponents?.ComponentService?.convertValue) { + newValue = window.DtWebComponents.ComponentService.convertValue( + component.tagName, + rawValue + ); + } + + await saveFieldValue(contactId, fieldKey, fieldType, newValue, section); + }); + } + + const multiValueFieldTypes = ['multi_select', 'connection', 'tags', 'location', 'location_meta', 'communication_channel']; + + async function saveFieldValue(contactId, fieldKey, fieldType, value, section) { + section.classList.add('saving'); + + try { + const result = await apiRequest('update-field', { + contact_id: parseInt(contactId), + field_key: fieldKey, + field_value: value + }); + + if (result.success) { + const viewMode = section.querySelector('.view-mode'); + const displayValue = result.value || '-'; + viewMode.textContent = displayValue; + viewMode.classList.toggle('empty-value', !result.value); + + if (!multiValueFieldTypes.includes(fieldType)) { + section.classList.remove('editing'); + } + showSuccessToast('Field updated'); + } else { + alert(result.message || 'Failed to update field'); + } + } catch (error) { + console.error('Error saving field:', error); + alert('Error saving field'); + } finally { + section.classList.remove('saving'); + } + } + + // Global event listeners + function setupGlobalEventListeners() { + // Handle dt:get-data events for typeahead + document.addEventListener('dt:get-data', async function(e) { + if (!e.detail) return; + + const { field, query, onSuccess, onError, postType } = e.detail; + + try { + const data = await apiRequest('field-options', { + field: field, + query: query || '', + post_type: postType || 'contacts' + }); + + if (data.success && data.options) { + if (onSuccess && typeof onSuccess === 'function') { + onSuccess(data.options); + } + } else { + if (onError && typeof onError === 'function') { + onError(new Error(data.message || 'Failed to fetch options')); + } + } + } catch (err) { + console.error('Error fetching field options:', err); + if (onError && typeof onError === 'function') { + onError(err); + } + } + }); + + // Close edit mode when clicking outside + document.addEventListener('click', function(e) { + const editingSection = document.querySelector('.detail-section.editing'); + if (!editingSection) return; + + if (editingSection.contains(e.target)) return; + if (e.target.closest('.option-list, ul[class*="option"], li[tabindex]')) return; + + editingSection.classList.remove('editing'); + }); + } + + // Mobile helpers + function isMobile() { + return window.innerWidth <= 1024; + } + + function hideMobileDetails() { + isMobileDetailsVisible.value = false; + } + + function navigateContact(direction) { + const currentIndex = filteredContacts.value.findIndex(c => c.ID === selectedContactId.value); + if (currentIndex === -1) return; + + const newIndex = currentIndex + direction; + if (newIndex >= 0 && newIndex < filteredContacts.value.length) { + selectContact(filteredContacts.value[newIndex].ID); + } + } + + function canNavigate(direction) { + const currentIndex = filteredContacts.value.findIndex(c => c.ID === selectedContactId.value); + if (currentIndex === -1) return false; + + const newIndex = currentIndex + direction; + return newIndex >= 0 && newIndex < filteredContacts.value.length; + } + + // Utility functions + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function escapeAttr(text) { + if (text === null || text === undefined) return ''; + return String(text).replace(/"/g, '"').replace(/'/g, ''').replace(//g, '>'); + } + + function formatTimestamp(timestamp) { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }); + } + + function formatActivityContent(text) { + if (!text) return ''; + + let formatted = escapeHtml(text); + + formatted = formatted.replace( + /@\[([^\]]+)\]\((\d+)\)/g, + '@$1' + ); + + const urlRegex = /(https?:\/\/[^\s<]+)/g; + formatted = formatted.replace( + urlRegex, + '$1' + ); + + return formatted; + } + + function showSuccessToast(message = 'Success!') { + const toast = document.getElementById('success-toast'); + if (toast) { + toast.textContent = message; + toast.classList.add('show'); + setTimeout(() => { + toast.classList.remove('show'); + }, 3000); + } + } + + // Field rendering helpers + function renderFieldIcon(field) { + if (field.icon && !field.icon.includes('undefined')) { + return ``; + } + if (field.font_icon && !field.font_icon.includes('undefined')) { + return ``; + } + return ''; + } + + // Expose methods to template + return { + // State + contacts, + filteredContacts, + selectedContactId, + selectedContact, + searchTerm, + loading, + detailsLoading, + contactsCount, + commentText, + commentSubmitting, + mentionUsers, + mentionActiveIndex, + showMentionDropdown, + isMobileDetailsVisible, + mobileActiveTab, + + // Methods + loadContacts, + selectContact, + toggleActivityGroup, + submitComment, + selectMention, + hideMentionDropdown, + toggleEditMode, + hideMobileDetails, + navigateContact, + canNavigate, + + // Helpers + escapeHtml, + escapeAttr, + formatTimestamp, + formatActivityContent, + renderFieldIcon + }; + }, + + template: ` +
+ +
+
+ My Contacts ({{ contactsCount }}) + +
+
+
+
+

Loading contacts...

+
+
+

No contacts found

+
+ +
+
+ + +
+
+ + {{ selectedContact?.name || 'Contact Details' }} + {{ selectedContact?.name || '' }} +
+ + +
+
+
+ + +
+
+ +
+
+
+ + +
+

Error: {{ selectedContact.error }}

+
+ + +
+
👤
+

Select a contact to view details

+
+ + +
+
+ + +

No contact information available

+ + +
+
Record Info
+
+
Created
+
{{ selectedContact.created }}
+
+
+
Last Modified
+
{{ selectedContact.last_modified }}
+
+ + Open in D.T + +
+
+ +
+

Comments & Activity

+
+
+
+
+ {{ user.name }} +
+
+ +
+
+ +
+
+
+ +

No activity yet

+
+
+
+
+
+
+ + +
+ Comment added successfully! +
+ ` +}); + +// Mount when DOM is ready +document.addEventListener('DOMContentLoaded', function() { + // Replace the static HTML with Vue mount point + const container = document.querySelector('.my-contacts-container'); + if (container) { + // Clear the container and let Vue render + const parent = container.parentNode; + const vueRoot = document.createElement('div'); + vueRoot.id = 'my-contacts-app'; + parent.replaceChild(vueRoot, container); + + // Also move the toast outside the vue container + const toast = document.getElementById('success-toast'); + if (toast) { + parent.appendChild(toast); + } + + MyContactsApp.mount('#my-contacts-app'); + } +}); diff --git a/magic-link/my-contacts/my-contacts.php b/magic-link/my-contacts/my-contacts.php new file mode 100644 index 0000000..4ca97c2 --- /dev/null +++ b/magic-link/my-contacts/my-contacts.php @@ -0,0 +1,949 @@ +meta = [ + 'app_type' => 'magic_link', + 'post_type' => $this->post_type, + 'contacts_only' => true, + 'supports_create' => false, + 'icon' => 'mdi mdi-account-group', + 'show_in_home_apps' => true, + ]; + + $this->meta_key = $this->root . '_' . $this->type . '_magic_key'; + parent::__construct(); + + add_filter( 'dt_settings_apps_list', [ $this, 'dt_settings_apps_list' ], 10, 1 ); + add_action( 'rest_api_init', [ $this, 'add_endpoints' ] ); + + $url = dt_get_url_path(); + if ( strpos( $url, $this->root . '/' . $this->type ) === false ) { + return; + } + + if ( ! $this->check_parts_match() ) { + return; + } + + add_action( 'dt_blank_body', [ $this, 'body' ] ); + add_filter( 'dt_magic_url_base_allowed_css', [ $this, 'dt_magic_url_base_allowed_css' ], 10, 1 ); + add_filter( 'dt_magic_url_base_allowed_js', [ $this, 'dt_magic_url_base_allowed_js' ], 10, 1 ); + add_action( 'wp_enqueue_scripts', [ $this, 'wp_enqueue_scripts' ] ); + } + + public function dt_settings_apps_list( $apps_list ) { + $apps_list[ $this->meta_key ] = [ + 'key' => $this->meta_key, + 'url_base' => $this->root . '/' . $this->type, + 'label' => $this->page_title, + 'description' => $this->page_description, + 'settings_display' => true + ]; + return $apps_list; + } + + public function dt_magic_url_base_allowed_js( $allowed_js ) { + $allowed_js = []; + $allowed_js[] = 'vue-js'; + $allowed_js[] = 'web-components'; + $allowed_js[] = 'my-contacts-js'; + return $allowed_js; + } + + public function dt_magic_url_base_allowed_css( $allowed_css ) { + $allowed_css = []; + $allowed_css[] = 'web-components-css'; + $allowed_css[] = 'my-contacts-css'; + $allowed_css[] = 'material-font-icons'; + return $allowed_css; + } + + /** + * Enqueue scripts and styles + */ + public function wp_enqueue_scripts() { + // Enqueue Vue.js from CDN + wp_enqueue_script( + 'vue-js', + 'https://unpkg.com/vue@3/dist/vue.global.prod.js', + [], + '3', + true + ); + + // Enqueue magic link JS + wp_enqueue_script( + 'my-contacts-js', + trailingslashit( plugin_dir_url( __FILE__ ) ) . 'my-contacts.js', + [ 'vue-js' ], + filemtime( plugin_dir_path( __FILE__ ) . 'my-contacts.js' ), + true + ); + wp_localize_script( + 'my-contacts-js', + 'myContactsApp', + [ + 'root' => esc_url_raw( rest_url() ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'parts' => $this->parts, + ] + ); + + // Enqueue magic link CSS + wp_enqueue_style( + 'my-contacts-css', + trailingslashit( plugin_dir_url( __FILE__ ) ) . 'my-contacts.css', + [], + filemtime( plugin_dir_path( __FILE__ ) . 'my-contacts.css' ) + ); + } + + /** + * Register REST API endpoints + */ + public function add_endpoints() { + $namespace = $this->root . '/v1'; + + $permission_callback = function ( WP_REST_Request $request ) { + $magic = new DT_Magic_URL( $this->root ); + $valid_parts = $magic->verify_rest_endpoint_permissions_on_post( $request, true ); + if ( ! $valid_parts ) { + return false; + } + $this->parts = $valid_parts; + return true; + }; + + register_rest_route( + $namespace, '/' . $this->type . '/contacts', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'get_my_contacts' ], + 'permission_callback' => $permission_callback, + ], + ] + ); + + register_rest_route( + $namespace, '/' . $this->type . '/contact', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'get_contact_details' ], + 'permission_callback' => $permission_callback, + ], + ] + ); + + register_rest_route( + $namespace, '/' . $this->type . '/comment', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'add_comment' ], + 'permission_callback' => $permission_callback, + ], + ] + ); + + register_rest_route( + $namespace, '/' . $this->type . '/users-mention', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'get_users_for_mention' ], + 'permission_callback' => $permission_callback, + ], + ] + ); + + register_rest_route( + $namespace, '/' . $this->type . '/update-field', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'update_field' ], + 'permission_callback' => $permission_callback, + ], + ] + ); + + register_rest_route( + $namespace, '/' . $this->type . '/field-options', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'get_field_options' ], + 'permission_callback' => $permission_callback, + ], + ] + ); + } + + /** + * Get contacts for this magic link owner + */ + public function get_my_contacts( WP_REST_Request $request ) { + $owner_contact_id = $this->parts['post_id']; + + $contacts = []; + $contact_ids_added = []; + + // Get the owner contact to find subassigned contacts and corresponding user + $owner_contact = DT_Posts::get_post( 'contacts', $owner_contact_id, true, false ); + if ( is_wp_error( $owner_contact ) ) { + return new WP_Error( 'contact_not_found', 'Owner contact not found', [ 'status' => 404 ] ); + } + + // 1. Get contacts where this contact is in their subassigned field + if ( ! empty( $owner_contact['subassigned_on'] ) ) { + $subassigned_contacts = DT_Posts::list_posts( 'contacts', [ + 'subassigned' => [ $owner_contact_id ], + 'sort' => '-last_modified', + 'limit' => 100, + ], false ); + + if ( ! is_wp_error( $subassigned_contacts ) && isset( $subassigned_contacts['posts'] ) ) { + foreach ( $subassigned_contacts['posts'] as $contact ) { + if ( ! in_array( $contact['ID'], $contact_ids_added ) ) { + $contacts[] = $this->format_contact_for_list( $contact, 'subassigned' ); + $contact_ids_added[] = $contact['ID']; + } + } + } + } + + // 2. Get contacts the corresponding user has access to + $corresponds_to_user = $owner_contact['corresponds_to_user'] ?? null; + if ( $corresponds_to_user ) { + $user_id = is_array( $corresponds_to_user ) ? ( $corresponds_to_user['ID'] ?? null ) : $corresponds_to_user; + + if ( $user_id ) { + // Get contacts assigned to this user + $user_contacts = DT_Posts::list_posts( 'contacts', [ + 'assigned_to' => [ $user_id ], + 'sort' => '-last_modified', + 'limit' => 100, + ], false ); + + if ( ! is_wp_error( $user_contacts ) && isset( $user_contacts['posts'] ) ) { + foreach ( $user_contacts['posts'] as $contact ) { + if ( ! in_array( $contact['ID'], $contact_ids_added ) && $contact['ID'] !== $owner_contact_id ) { + $contacts[] = $this->format_contact_for_list( $contact, 'assigned' ); + $contact_ids_added[] = $contact['ID']; + } + } + } + } + } + + // Sort by last_modified (most recent first) + usort( $contacts, function( $a, $b ) { + return ( $b['last_modified_timestamp'] ?? 0 ) - ( $a['last_modified_timestamp'] ?? 0 ); + }); + + return [ + 'contacts' => $contacts, + 'total' => count( $contacts ), + 'owner_contact_id' => $owner_contact_id, + ]; + } + + /** + * Format a contact for the list display + */ + private function format_contact_for_list( $contact, $source = '' ) { + $overall_status = ''; + $overall_status_color = ''; + if ( ! empty( $contact['overall_status'] ) ) { + $overall_status = $contact['overall_status']['label'] ?? ''; + $overall_status_color = $contact['overall_status']['color'] ?? ''; + } + + $seeker_path = ''; + if ( ! empty( $contact['seeker_path'] ) ) { + $seeker_path = $contact['seeker_path']['label'] ?? ''; + } + + $last_modified = ''; + $last_modified_timestamp = 0; + if ( ! empty( $contact['last_modified']['timestamp'] ) ) { + $last_modified_timestamp = $contact['last_modified']['timestamp']; + $last_modified = $contact['last_modified']['formatted'] ?? ''; + } + + return [ + 'ID' => $contact['ID'], + 'name' => $contact['name'] ?? 'Unknown', + 'overall_status' => $overall_status, + 'overall_status_color' => $overall_status_color, + 'seeker_path' => $seeker_path, + 'last_modified' => $last_modified, + 'last_modified_timestamp' => $last_modified_timestamp, + 'source' => $source, + ]; + } + + /** + * Get contact details with comments and activity + */ + public function get_contact_details( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $owner_contact_id = $this->parts['post_id']; + $contact_id = intval( $params['contact_id'] ?? 0 ); + if ( ! $contact_id ) { + return new WP_Error( 'missing_contact_id', 'Contact ID is required', [ 'status' => 400 ] ); + } + + // Verify access - the contact must be in subassigned or accessible by user + if ( ! $this->verify_contact_access( $owner_contact_id, $contact_id ) ) { + return new WP_Error( 'access_denied', 'You do not have access to this contact', [ 'status' => 403 ] ); + } + + $contact = DT_Posts::get_post( 'contacts', $contact_id, true, false ); + if ( is_wp_error( $contact ) ) { + return new WP_Error( 'contact_not_found', 'Contact not found', [ 'status' => 404 ] ); + } + + $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); + $tile_settings = DT_Posts::get_post_tiles( 'contacts' ); + + // Get comments and activity + $comments = DT_Posts::get_post_comments( 'contacts', $contact_id, false ); + $activity = DT_Posts::get_post_activity( 'contacts', $contact_id, [], false ); + + // Fields to skip (internal/system fields) + $skip_fields = [ 'corresponds_to_user', 'duplicate_data', 'duplicate_of', 'post_author', 'record_picture' ]; + + // Group fields by tile + $tiles_with_fields = []; + foreach ( $field_settings as $field_key => $field_setting ) { + // Skip hidden, internal, or system fields + if ( in_array( $field_key, $skip_fields ) ) { + continue; + } + if ( isset( $field_setting['hidden'] ) && $field_setting['hidden'] === true ) { + continue; + } + + // Skip fields without a tile + $tile_key = $field_setting['tile'] ?? ''; + if ( empty( $tile_key ) ) { + continue; + } + + // Skip if tile doesn't exist in tile settings + if ( ! isset( $tile_settings[ $tile_key ] ) ) { + continue; + } + + $value = $contact[ $field_key ] ?? null; + $formatted_value = $this->format_field_value( $value, $field_setting ); + + // Initialize tile if not exists + if ( ! isset( $tiles_with_fields[ $tile_key ] ) ) { + $tile_order = $tile_settings[ $tile_key ]['tile_priority'] ?? 100; + $tiles_with_fields[ $tile_key ] = [ + 'key' => $tile_key, + 'label' => $tile_settings[ $tile_key ]['label'] ?? $tile_key, + 'order' => is_numeric( $tile_order ) ? intval( $tile_order ) : 100, + 'fields' => [], + ]; + } + + $field_order = $field_setting['in_create_form'] ?? 100; + $field_type = $field_setting['type'] ?? 'text'; + + // Render the component HTML using DT_Components + $component_html = $this->render_field_component( $field_key, $field_settings, $contact, $field_type ); + + $non_editable_types = [ 'user_select', 'link' ]; + $field_data = [ + 'key' => $field_key, + 'label' => $field_setting['name'] ?? $field_key, + 'value' => $formatted_value, + 'type' => $field_type, + 'icon' => $field_setting['icon'] ?? '', + 'font_icon' => $field_setting['font-icon'] ?? '', + 'order' => is_numeric( $field_order ) ? intval( $field_order ) : 100, + 'component_html' => $component_html, + 'editable' => ! in_array( $field_type, $non_editable_types, true ), + ]; + + $tiles_with_fields[ $tile_key ]['fields'][] = $field_data; + } + + // Sort tiles by order + uasort( $tiles_with_fields, function( $a, $b ) { + $order_a = is_numeric( $a['order'] ?? 100 ) ? intval( $a['order'] ) : 100; + $order_b = is_numeric( $b['order'] ?? 100 ) ? intval( $b['order'] ) : 100; + return $order_a - $order_b; + }); + + // Sort fields within each tile by order + foreach ( $tiles_with_fields as &$tile ) { + usort( $tile['fields'], function( $a, $b ) { + $order_a = is_numeric( $a['order'] ?? 100 ) ? intval( $a['order'] ) : 100; + $order_b = is_numeric( $b['order'] ?? 100 ) ? intval( $b['order'] ) : 100; + return $order_a - $order_b; + }); + } + + // Convert to indexed array + $tiles = array_values( $tiles_with_fields ); + + // Merge comments and activity, sort by date + $all_activity = $this->merge_comments_and_activity( $comments['comments'] ?? [], $activity ); + + return [ + 'ID' => $contact['ID'], + 'name' => $contact['name'] ?? 'Unknown', + 'tiles' => $tiles, + 'created' => $contact['post_date']['formatted'] ?? '', + 'last_modified' => $contact['last_modified']['formatted'] ?? '', + 'activity' => $all_activity, + ]; + } + + /** + * Verify the owner has access to view the requested contact + */ + private function verify_contact_access( $owner_contact_id, $contact_id ) { + // Don't allow viewing self + if ( $owner_contact_id === $contact_id ) { + return false; + } + + $owner_contact = DT_Posts::get_post( 'contacts', $owner_contact_id, true, false ); + if ( is_wp_error( $owner_contact ) ) { + return false; + } + + // Check if contact is in subassigned + if ( ! empty( $owner_contact['subassigned_on'] ) ) { + foreach ( $owner_contact['subassigned_on'] as $subassigned ) { + if ( ( $subassigned['ID'] ?? null ) === $contact_id ) { + return true; + } + } + } + + // Check if corresponding user has access + $corresponds_to_user = $owner_contact['corresponds_to_user'] ?? null; + if ( $corresponds_to_user ) { + $user_id = is_array( $corresponds_to_user ) ? ( $corresponds_to_user['ID'] ?? null ) : $corresponds_to_user; + + if ( $user_id ) { + // Check if contact is assigned to this user + $contact = DT_Posts::get_post( 'contacts', $contact_id, true, false ); + if ( ! is_wp_error( $contact ) ) { + $assigned_to = $contact['assigned_to'] ?? null; + if ( $assigned_to ) { + $assigned_user_id = is_array( $assigned_to ) ? ( $assigned_to['id'] ?? null ) : $assigned_to; + if ( intval( $assigned_user_id ) === intval( $user_id ) ) { + return true; + } + } + } + } + } + + return false; + } + + /** + * Merge comments and activity into a single timeline + */ + private function merge_comments_and_activity( $comments, $activity ) { + $merged = []; + + // Add comments + foreach ( $comments as $comment ) { + $timestamp = strtotime( $comment['comment_date_gmt'] ?? '' ); + $merged[] = [ + 'type' => 'comment', + 'id' => $comment['comment_ID'] ?? 0, + 'content' => $comment['comment_content'] ?? '', + 'author' => $comment['comment_author'] ?? '', + 'date' => $comment['comment_date'] ?? '', + 'timestamp' => $timestamp, + ]; + } + + // Add activity from DT_Posts::get_post_activity() format + $activity_items = $activity['activity'] ?? []; + foreach ( $activity_items as $item ) { + // Skip comment actions as they're already in comments + if ( ( $item['action'] ?? '' ) === 'comment' ) { + continue; + } + + // Use object_note as the description + $description = $item['object_note'] ?? ''; + if ( empty( $description ) ) { + continue; + } + + // Get user display name + $user_name = $item['name'] ?? 'System'; + + $timestamp = intval( $item['hist_time'] ?? 0 ); + + $merged[] = [ + 'type' => 'activity', + 'id' => $item['histid'] ?? 0, + 'content' => $description, + 'author' => $user_name, + 'date' => $item['date'] ?? '', + 'timestamp' => $timestamp, + ]; + } + + // Sort by timestamp descending (most recent first) + usort( $merged, function( $a, $b ) { + return ( $b['timestamp'] ?? 0 ) - ( $a['timestamp'] ?? 0 ); + }); + + return $merged; + } + + /** + * Format field value based on field type + */ + private function format_field_value( $value, $field_setting ) { + $type = $field_setting['type'] ?? 'text'; + + // Handle null/empty values - return empty string to display field with no value + if ( $value === null || $value === '' || ( is_array( $value ) && empty( $value ) ) ) { + return ''; + } + + switch ( $type ) { + case 'text': + case 'textarea': + case 'number': + return is_string( $value ) || is_numeric( $value ) ? $value : ''; + + case 'boolean': + return $value ? 'Yes' : 'No'; + + case 'key_select': + return $value['label'] ?? ''; + + case 'multi_select': + case 'tags': + if ( is_array( $value ) ) { + $labels = []; + $options = $field_setting['default'] ?? []; + foreach ( $value as $item ) { + if ( isset( $item['label'] ) ) { + $labels[] = $item['label']; + } elseif ( is_string( $item ) ) { + if ( isset( $options[ $item ]['label'] ) ) { + $labels[] = $options[ $item ]['label']; + } else { + $labels[] = $item; + } + } + } + return implode( ', ', $labels ); + } + return ''; + + case 'communication_channel': + if ( is_array( $value ) ) { + $channels = []; + foreach ( $value as $item ) { + if ( isset( $item['value'] ) && ! empty( $item['value'] ) ) { + $channels[] = $item['value']; + } + } + return implode( ', ', $channels ); + } + return ''; + + case 'location': + case 'location_grid': + case 'location_meta': + if ( is_array( $value ) ) { + $locations = []; + foreach ( $value as $item ) { + if ( isset( $item['label'] ) ) { + $locations[] = $item['label']; + } + } + return implode( "\n", $locations ); + } + return ''; + + case 'connection': + if ( is_array( $value ) ) { + $connections = []; + foreach ( $value as $item ) { + if ( isset( $item['post_title'] ) ) { + $connections[] = $item['post_title']; + } + } + return implode( ', ', $connections ); + } + return ''; + + case 'user_select': + if ( isset( $value['display'] ) ) { + return $value['display']; + } + return ''; + + case 'date': + if ( isset( $value['formatted'] ) ) { + return $value['formatted']; + } + return ''; + + case 'link': + if ( is_array( $value ) ) { + $links = []; + foreach ( $value as $item ) { + if ( isset( $item['value'] ) ) { + $links[] = $item['value']; + } + } + return implode( ', ', $links ); + } + return ''; + + default: + if ( is_array( $value ) ) { + if ( isset( $value['label'] ) ) { + return $value['label']; + } + return ''; + } + return is_string( $value ) ? $value : ''; + } + } + + /** + * Render field component HTML using Magic Links Helper + */ + private function render_field_component( $field_key, $fields, $post, $field_type ) { + ob_start(); + + switch ( $field_type ) { + case 'boolean': + ?> + > + Not editable in this view'; + break; + default: + Disciple_Tools_Magic_Links_Helper::render_field_for_display( $field_key, $fields, $post ); + break; + } + + return ob_get_clean(); + } + + /** + * Add a comment to a contact + */ + public function add_comment( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $owner_contact_id = $this->parts['post_id']; + $contact_id = intval( $params['contact_id'] ?? 0 ); + $comment = $params['comment'] ?? ''; + + if ( ! $contact_id || empty( $comment ) ) { + return new WP_Error( 'missing_params', 'Contact ID and comment are required', [ 'status' => 400 ] ); + } + + // Verify access + if ( ! $this->verify_contact_access( $owner_contact_id, $contact_id ) ) { + return new WP_Error( 'access_denied', 'You do not have access to this contact', [ 'status' => 403 ] ); + } + $args = []; + $corresponds_to_user = Disciple_Tools_Users::get_user_for_contact( $owner_contact_id ); + if ( empty( $corresponds_to_user ) ) { + $owner_contact = DT_Posts::get_post( 'contacts', $owner_contact_id, true, false ); + $args['comment_author'] = $owner_contact['name']; + } + + $result = DT_Posts::add_post_comment( 'contacts', $contact_id, $comment, 'comment', $args, false ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return [ + 'success' => true, + 'contact_id' => $contact_id, + 'comment_id' => $result, + ]; + } + + /** + * Get users for @mention autocomplete + * Uses the existing Disciple_Tools_Users::get_assignable_users_compact() function + */ + public function get_users_for_mention( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $owner_contact_id = $this->parts['post_id']; + $search = $params['search'] ?? ''; + + // Get the corresponding user for the magic link owner + $owner_contact = DT_Posts::get_post( 'contacts', $owner_contact_id, true, false ); + if ( is_wp_error( $owner_contact ) ) { + return [ 'users' => [] ]; + } + + $corresponds_to_user = $owner_contact['corresponds_to_user'] ?? null; + if ( ! $corresponds_to_user ) { + return [ 'users' => [] ]; + } + + $user_id = is_array( $corresponds_to_user ) ? ( $corresponds_to_user['ID'] ?? null ) : $corresponds_to_user; + if ( ! $user_id ) { + return [ 'users' => [] ]; + } + + // Temporarily set the current user to get assignable users + $original_user_id = get_current_user_id(); + wp_set_current_user( $user_id ); + + $users = Disciple_Tools_Users::get_assignable_users_compact( $search ); + + // Restore the original user + wp_set_current_user( $original_user_id ); + + if ( is_wp_error( $users ) ) { + return [ 'users' => [] ]; + } + + return [ + 'users' => $users, + ]; + } + + /** + * Update a field on a contact + */ + public function update_field( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $owner_contact_id = $this->parts['post_id']; + $contact_id = intval( $params['contact_id'] ?? 0 ); + $field_key = $params['field_key'] ?? ''; + $field_value = $params['field_value'] ?? null; + + if ( ! $contact_id || empty( $field_key ) ) { + return new WP_Error( 'missing_params', 'Contact ID and field key are required', [ 'status' => 400 ] ); + } + + // Verify access + if ( ! $this->verify_contact_access( $owner_contact_id, $contact_id ) ) { + return new WP_Error( 'access_denied', 'You do not have access to this contact', [ 'status' => 403 ] ); + } + + // Get field settings + $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); + $field_setting = $field_settings[ $field_key ] ?? []; + + // The JS uses ComponentService.convertValue() to format the value correctly for DT_Posts + // so we can pass it directly without transformation + $update_data = [ $field_key => $field_value ]; + $result = DT_Posts::update_post( 'contacts', $contact_id, $update_data, true, false ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + // Get the updated field value + $updated_value = $result[ $field_key ] ?? null; + $formatted_value = $this->format_field_value( $updated_value, $field_setting ); + + return [ + 'success' => true, + 'contact_id' => $contact_id, + 'field_key' => $field_key, + 'value' => $formatted_value, + 'raw_value' => $updated_value, + ]; + } + + /** + * Get field options for typeahead components (connections, locations, tags) + */ + public function get_field_options( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $params = dt_recursive_sanitize_array( $params ); + + $field_key = $params['field'] ?? ''; + $query = $params['query'] ?? ''; + + if ( empty( $field_key ) ) { + return new WP_Error( 'missing_field', 'Field key is required', [ 'status' => 400 ] ); + } + + // Get field settings + $field_settings = DT_Posts::get_post_field_settings( 'contacts' ); + $field_setting = $field_settings[ $field_key ] ?? []; + $field_type = $field_setting['type'] ?? ''; + + $options = []; + + switch ( $field_type ) { + case 'connection': + // Get the post type this field connects to + $connected_post_type = $field_setting['post_type'] ?? 'contacts'; + + // Use get_viewable_compact which handles sorting properly + // (recently viewed first for empty search, recently modified for search) + $search_results = DT_Posts::get_viewable_compact( $connected_post_type, $query, [ + 'field_key' => $field_key, + ] ); + + if ( ! is_wp_error( $search_results ) && isset( $search_results['posts'] ) ) { + foreach ( $search_results['posts'] as $post ) { + $status = null; + if ( isset( $post['status'] ) ) { + $status = $post['status']; + } + $options[] = [ + 'id' => (int) $post['ID'], + 'label' => $post['name'] ?? '', + 'link' => get_permalink( $post['ID'] ), + 'status' => $status, + ]; + } + } + break; + + case 'location': + case 'location_meta': + // Search location grid + $search_results = Disciple_Tools_Mapping_Queries::search_location_grid_by_name( [ + 'search_query' => $query, + ] ); + + if ( ! empty( $search_results ) && isset( $search_results['location_grid'] ) ) { + foreach ( $search_results['location_grid'] as $location ) { + $options[] = [ + 'id' => strval( $location['grid_id'] ?? $location['ID'] ), + 'label' => $location['name'] ?? $location['label'] ?? '', + ]; + } + } + break; + + case 'tags': + // Get existing tags for this field + $existing_tags = Disciple_Tools_Posts::get_multi_select_options( 'contacts', $field_key, false ); + if ( ! empty( $existing_tags ) ) { + foreach ( $existing_tags as $tag ) { + // Filter by query if provided + if ( empty( $query ) || stripos( $tag, $query ) !== false ) { + $options[] = [ + 'id' => $tag, + 'label' => $tag, + ]; + } + } + } + break; + } + + return [ + 'success' => true, + 'options' => $options, + ]; + } + + /** + * Custom header styles + */ + public function header_style() { + } + + /** + * Page body content + */ + public function body() { + ?> +
+ +
+
+ My Contacts (0) + +
+
+
+
+

Loading contacts...

+
+
+
+ + +
+
+ + Contact Details + +
+
+
+
👤
+

Select a contact to view details

+
+
+
+
+ + +
+ Comment added successfully! +
+ +