Merge branch 'feature/IO-2776-cdk-fortellis' into feature/Reynolds-and-Reynolds-DMS-API-Integration

This commit is contained in:
Dave
2025-09-25 12:08:05 -04:00
82 changed files with 1749 additions and 1077 deletions

View File

@@ -0,0 +1,164 @@
// src/utils/singleTabAudioLeader.js
// Ensures only one tab ("leader") plays sounds per bodyshop.
//
// Storage key: localStorage["imex:sound:leader:<bodyshopId>"] = { id, ts }
// Channel: new BroadcastChannel("imex:sound:<bodyshopId>")
const STORAGE_PREFIX = "imex:sound:leader:";
const CHANNEL_PREFIX = "imex:sound:";
const TTL_MS = 60_000; // leader expires after 60s without heartbeat
const HEARTBEAT_MS = 20_000; // leader refresh interval
const WATCHDOG_MS = 10_000; // how often non-leaders check for stale leader
const TAB_ID =
typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
function channelSupported() {
try {
return "BroadcastChannel" in window;
} catch {
return false;
}
}
function getChannel(bodyshopId) {
if (!channelSupported() || !bodyshopId) return null;
try {
return new BroadcastChannel(CHANNEL_PREFIX + String(bodyshopId));
} catch {
return null;
}
}
function lsKey(bodyshopId) {
return STORAGE_PREFIX + String(bodyshopId);
}
function readLeader(bodyshopId) {
if (!bodyshopId) return null;
try {
const raw = localStorage.getItem(lsKey(bodyshopId));
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
function writeLeader(record, bodyshopId) {
if (!bodyshopId) return;
try {
localStorage.setItem(lsKey(bodyshopId), JSON.stringify(record));
const bc = getChannel(bodyshopId);
if (bc) {
bc.postMessage({ type: "leader-update", payload: { ...record, bodyshopId } });
bc.close();
}
} catch {
// ignore
}
}
function removeLeader(bodyshopId) {
if (!bodyshopId) return;
try {
const cur = readLeader(bodyshopId);
if (cur?.id === TAB_ID) {
localStorage.removeItem(lsKey(bodyshopId));
const bc = getChannel(bodyshopId);
if (bc) {
bc.postMessage({ type: "leader-removed", payload: { id: TAB_ID, bodyshopId } });
bc.close();
}
}
} catch {
// ignore
}
}
function now() {
return Date.now();
}
function isStale(rec) {
return !rec || now() - rec.ts > TTL_MS;
}
function claimLeadership(bodyshopId) {
const rec = { id: TAB_ID, ts: now() };
writeLeader(rec, bodyshopId);
return rec;
}
/** Is THIS tab currently the leader (and not stale)? */
export function isLeaderTab(bodyshopId) {
const rec = readLeader(bodyshopId);
return !!rec && rec.id === TAB_ID && !isStale(rec);
}
/** Force this tab to become the leader right now. */
export function claimLeadershipNow(bodyshopId) {
return claimLeadership(bodyshopId);
}
/**
* Initialize leader election/heartbeat for this tab (scoped by bodyshopId).
* Call once (e.g., in SoundWrapper). Returns a cleanup function.
*/
export function initSingleTabAudioLeader(bodyshopId) {
if (!bodyshopId)
return () => {
//
};
// If no leader or stale, try to claim after a tiny delay (reduce startup contention)
if (isStale(readLeader(bodyshopId))) {
setTimeout(() => claimLeadership(bodyshopId), 100);
}
// If this tab becomes focused/visible, it can claim leadership
const onFocus = () => claimLeadership(bodyshopId);
const onVis = () => {
if (document.visibilityState === "visible") claimLeadership(bodyshopId);
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVis);
// Heartbeat from the leader to keep record fresh
const heartbeat = setInterval(() => {
if (!isLeaderTab(bodyshopId)) return;
writeLeader({ id: TAB_ID, ts: now() }, bodyshopId);
}, HEARTBEAT_MS);
// Watchdog: if leader is stale, try to claim (even if we're not focused)
const watchdog = setInterval(() => {
const cur = readLeader(bodyshopId);
if (isStale(cur)) claimLeadership(bodyshopId);
}, WATCHDOG_MS);
// If this tab was the leader, clean up on unload
const onUnload = () => removeLeader(bodyshopId);
window.addEventListener("beforeunload", onUnload);
// Per-bodyshop BroadcastChannel listener (optional/no-op)
const bc = getChannel(bodyshopId);
const onBC = bc
? () => {
// No state kept here; localStorage read is the source of truth.
}
: null;
if (bc && onBC) bc.addEventListener("message", onBC);
return () => {
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVis);
window.removeEventListener("beforeunload", onUnload);
clearInterval(heartbeat);
clearInterval(watchdog);
if (bc && onBC) {
bc.removeEventListener("message", onBC);
bc.close();
}
};
}

View File

@@ -0,0 +1,97 @@
// src/utils/soundManager.js
// Handles audio init, autoplay unlock, and queued plays.
// When a tab successfully unlocks audio, it CLAIMS LEADERSHIP immediately for that bodyshop.
import { claimLeadershipNow } from "./singleTabAudioLeader";
let baseAudio = null;
let unlocked = false;
let queuedPlays = 0;
let installingUnlockHandlers = false;
/**
* Initialize the new-message sound.
* @param {string} url
* @param {number} volume
*/
export function initNewMessageSound(url, volume = 0.7) {
baseAudio = new Audio(url);
baseAudio.preload = "auto";
baseAudio.volume = volume;
}
/** Has this tab unlocked audio? (optional helper) */
export function isAudioUnlocked() {
return unlocked;
}
/**
* Unlocks audio if not already unlocked.
* On success, this tab immediately becomes the sound LEADER for the given bodyshop.
*/
export async function unlockAudio(bodyshopId) {
if (unlocked) return;
try {
// Chrome/Safari: playing any media (even muted) after a gesture unlocks audio.
const a = new Audio();
a.muted = true;
await a.play().catch(() => {
// ignore
});
unlocked = true;
// Immediately become the leader because THIS tab can actually play sound.
claimLeadershipNow(bodyshopId);
// Flush exactly one queued ding (avoid spamming if many queued while locked)
if (queuedPlays > 0 && baseAudio) {
queuedPlays = 0;
const b = baseAudio.cloneNode(true);
b.play().catch(() => {
// ignore
});
}
} finally {
removeUnlockListeners();
}
}
/** Installs listeners to unlock audio on first gesture. */
function addUnlockListeners(bodyshopId) {
if (installingUnlockHandlers) return;
installingUnlockHandlers = true;
const handler = () => unlockAudio(bodyshopId);
window.addEventListener("click", handler, { once: true, passive: true });
window.addEventListener("touchstart", handler, { once: true, passive: true });
window.addEventListener("keydown", handler, { once: true });
}
/** Removes listeners to unlock audio on first gesture. */
function removeUnlockListeners() {
// With {once:true} they self-remove; we only reset the flag.
installingUnlockHandlers = false;
}
/**
* Plays the new-message ding. If blocked, queue one and wait for first gesture.
*/
export async function playNewMessageSound(bodyshopId) {
if (!baseAudio) return;
try {
const a = baseAudio.cloneNode(true);
await a.play();
} catch (err) {
// Most common: NotAllowedError due to missing prior gesture
if (err?.name === "NotAllowedError") {
queuedPlays = Math.min(queuedPlays + 1, 1); // cap at 1
addUnlockListeners(bodyshopId);
// Let the app know we need user interaction (optional UI prompt)
window.dispatchEvent(new CustomEvent("sound-needs-unlock"));
return;
}
// Other errors can be logged
console.error("Audio play error:", err);
}
}