import MobileControllerBase from "./mobile-controller-base.js";
import vault from "../services/offline-vault-service.js";
/**
* MemberMobileCardProfile Stimulus Controller
*
* Manages mobile-optimized member profile card display with PWA integration.
* Extends MobileControllerBase for centralized connection handling.
*
* Features:
* - Mobile-optimized profile card layout
* - PWA readiness integration
* - Fetch with retry for reliability
* - Dynamic card generation for plugin sections
*/
class MemberMobileCardProfile extends MobileControllerBase {
static targets = [
"cardSet",
"name",
"scaName",
"branchName",
"membershipInfo",
"backgroundCheck",
"lastUpdate",
"loading",
"memberDetails",
"profilePhotoContainer",
"profilePhoto",
"zoomPhoto",
"photoManageButton",
"photoUploadModal",
];
static values = {
url: String,
pwaReady: Boolean
}
initialize() {
super.initialize();
this.currentCard = null;
this.cardCount = 0;
this.handlePwaReady = this.handlePwaReady.bind(this);
}
/**
* Called after base class connect
*/
onConnect() {
this.element.addEventListener('pwa-ready', this.handlePwaReady);
this.onSavedOpen = () => this.loadCard();
window.addEventListener('kmp:offline-unlocked', this.onSavedOpen);
this.onSavedClear = () => {
this.cardSetTarget.replaceChildren();
for (const target of ['name', 'scaName', 'branchName', 'membershipInfo', 'backgroundCheck', 'lastUpdate']) this[`${target}Target`].textContent = '';
this.renderProfilePhoto(null); this.memberDetailsTarget.hidden = true;
};
window.addEventListener('kmp:offline-revoked', this.onSavedClear);
this.updatePhotoActionsForConnection(this.online);
// Check if PWA is already ready
if (this.pwaReadyValue) {
this.loadCard();
}
}
/**
* Called after base class disconnect
*/
onDisconnect() {
this.element.removeEventListener('pwa-ready', this.handlePwaReady);
window.removeEventListener('kmp:offline-unlocked', this.onSavedOpen);
window.removeEventListener('kmp:offline-revoked', this.onSavedClear);
}
/**
* Handle global online/offline state changes from MobileControllerBase.
*
* @param {boolean} isOnline Current connection state.
*/
onConnectionStateChanged(isOnline) {
this.updatePhotoActionsForConnection(isOnline);
if (isOnline && this.pwaReadyValue) this.loadCard();
}
/**
* Handle PWA ready event
*/
handlePwaReady() {
this.pwaReadyValue = true;
}
/**
* Create a new mobile card section
*/
startCard(title) {
this.cardCount++;
const card = document.createElement("div");
card.classList.add("card", "cardbox", "m-3");
card.id = "card_" + this.cardCount;
card.dataset.section = "auth-card";
const cardDetails = document.createElement("div");
cardDetails.classList.add("card-body");
cardDetails.id = "cardDetails_" + this.cardCount;
const cardTitle = document.createElement("h3");
cardTitle.classList.add("card-title", "text-center", "display-6");
cardTitle.textContent = title;
cardDetails.appendChild(cardTitle);
card.appendChild(cardDetails);
this.cardSetTarget.appendChild(card);
this.currentCard = cardDetails;
}
/**
* Handle PWA ready state changes
*/
pwaReadyValueChanged() {
if (this.pwaReadyValue) {
this.loadCard();
}
}
/**
* Load member profile data with retry logic
*/
async loadCard() {
if (!this.pwaReadyValue || this.loadingCard) return;
this.loadingCard = true;
const generation = vault.generation;
if (!this.nameTarget.textContent && this.hasProfilePhotoContainerTarget) this.profilePhotoContainerTarget.hidden = true;
try {
const response = await this.fetchWithRetry(this.urlValue);
const data = await response.json();
if (generation !== vault.generation) return;
if (!data.member) throw new Error('Connect to load your card.');
this.cardSetTarget.replaceChildren();
this.renderMemberData(data);
const verified = response.headers?.get('X-KMP-Offline-Verified');
if (verified) this.lastUpdateTarget.textContent = new Date(Number(verified)).toLocaleString();
this.memberDetailsTarget.hidden = false;
} catch (error) {
if (!this.nameTarget.textContent) {
this.memberDetailsTarget.hidden = false;
this.nameTarget.textContent = 'Connect to load your card.';
}
} finally {
this.loadingTarget.hidden = true;
this.loadingCard = false;
}
}
/**
* Render member data to the card
*/
renderMemberData(data) {
this.nameTarget.textContent = data.member.first_name + ' ' + data.member.last_name;
this.scaNameTarget.textContent = data.member.sca_name;
this.branchNameTarget.textContent = data.member.branch.name;
// Membership info
if (data.member.membership_number && data.member.membership_number.length > 0) {
const memberExpDate = new Date(data.member.membership_expires_on);
const expText = memberExpDate < new Date() ? "Expired" : " - " + memberExpDate.toLocaleDateString();
this.membershipInfoTarget.textContent = data.member.membership_number + ' ' + expText;
} else {
this.membershipInfoTarget.textContent = "No Membership Info";
}
// Background check
if (data.member.background_check_expires_on) {
const bgCheckDate = new Date(data.member.background_check_expires_on);
const bgText = bgCheckDate < new Date() ? "Expired" : 'Current ' + bgCheckDate.toLocaleDateString();
this.backgroundCheckTarget.textContent = bgText;
} else {
this.backgroundCheckTarget.textContent = "Not on file";
}
this.lastUpdateTarget.textContent = new Date().toLocaleString();
this.renderProfilePhoto(data.member.profile_photo_url || null);
// Render plugin sections
this.renderPluginSections(data);
}
renderProfilePhoto(photoUrl) {
const hasPhoto = typeof photoUrl === "string" && photoUrl.length > 0;
if (this.hasProfilePhotoContainerTarget) {
this.profilePhotoContainerTarget.hidden = !hasPhoto;
}
if (!hasPhoto) {
if (this.hasProfilePhotoTarget) this.profilePhotoTarget.removeAttribute('src');
if (this.hasZoomPhotoTarget) this.zoomPhotoTarget.removeAttribute('src');
}
if (hasPhoto) {
if (this.hasProfilePhotoTarget) {
this.profilePhotoTarget.src = photoUrl;
}
if (this.hasZoomPhotoTarget) {
this.zoomPhotoTarget.src = photoUrl;
}
}
}
/**
* Render plugin-provided sections
*/
renderPluginSections(data) {
for (let key in data) {
if (key === 'member') continue;
const pluginData = data[key];
for (let sectionKey in pluginData) {
if (sectionKey === 'offline_sections') continue;
const sectionData = pluginData[sectionKey];
if (Object.keys(sectionData).length === 0) continue;
this.startCard(sectionKey);
const groupTable = document.createElement("table");
groupTable.classList.add("table", "card-body-table");
const groupTableBody = document.createElement("tbody");
groupTable.appendChild(groupTableBody);
for (let groupKey in sectionData) {
const groupData = sectionData[groupKey];
if (groupData.length === 0) continue;
// Group header
const headerRow = document.createElement("tr");
const groupHeader = document.createElement("th");
groupHeader.classList.add("col-12", "text-center");
groupHeader.colSpan = "2";
groupHeader.textContent = groupKey;
headerRow.appendChild(groupHeader);
groupTableBody.appendChild(headerRow);
// Group items
let colCount = 0;
let groupRow = document.createElement("tr");
for (let i = 0; i < groupData.length; i++) {
const itemData = groupData[i];
if (colCount === 2) {
groupTableBody.appendChild(groupRow);
groupRow = document.createElement("tr");
colCount = 0;
}
// Handle key:value format
if (itemData.indexOf(":") > 2) {
const itemValue = itemData.split(":");
const itemValueRow = document.createElement("tr");
const itemValueCol1 = document.createElement("td");
itemValueCol1.classList.add("col-6", "text-end");
itemValueCol1.textContent = itemValue[0];
const itemValueCol2 = document.createElement("td");
itemValueCol2.classList.add("col-6", "text-start");
itemValueCol2.textContent = itemValue[1];
itemValueRow.appendChild(itemValueCol1);
itemValueRow.appendChild(itemValueCol2);
groupTableBody.appendChild(itemValueRow);
} else {
const colspan = (i + 1 === groupData.length && colCount === 0) ? 2 : 1;
const itemValueCol = document.createElement("td");
itemValueCol.classList.add("col-6", "text-center");
itemValueCol.colSpan = colspan;
itemValueCol.textContent = itemData;
groupRow.appendChild(itemValueCol);
colCount++;
}
}
groupTableBody.appendChild(groupRow);
}
this.currentCard.appendChild(groupTable);
}
}
}
/**
* Show offline message with retry option
*/
showOfflineMessage() {
const offlineMsg = document.createElement("div");
offlineMsg.className = "alert alert-warning text-center m-3";
offlineMsg.innerHTML = `
<i class="bi bi-wifi-off me-2"></i>
Unable to load - you're offline
<button class="btn btn-sm btn-outline-warning ms-2" data-action="click->member-mobile-card-profile#retryLoad">
Retry
</button>
`;
this.cardSetTarget.appendChild(offlineMsg);
}
/**
* Retry loading card data
*/
retryLoad() {
this.loadCard();
}
/**
* Hide photo capture/upload affordances while offline.
*
* @param {boolean} isOnline Current connectivity state.
*/
updatePhotoActionsForConnection(isOnline) {
this.element?.querySelectorAll('.online-only-btn').forEach(button => {
button.disabled = !isOnline || !!document.querySelector('meta[name="kmp-offline-shell"]');
button.setAttribute('aria-disabled', String(button.disabled));
});
if (this.hasPhotoManageButtonTarget) {
this.photoManageButtonTarget.hidden = !isOnline;
}
if (!isOnline) {
this.hidePhotoUploadModal();
}
}
hidePhotoUploadModal() {
if (!this.hasPhotoUploadModalTarget || !window.bootstrap?.Modal) {
return;
}
const modalElement = this.photoUploadModalTarget;
const existingModal = window.bootstrap.Modal.getInstance?.(modalElement);
if (existingModal) {
existingModal.hide();
return;
}
const modal = window.bootstrap.Modal.getOrCreateInstance?.(modalElement);
modal?.hide();
}
}
if (!window.Controllers) {
window.Controllers = {};
}
window.Controllers["member-mobile-card-profile"] = MemberMobileCardProfile;