diff --git a/dist/chromium/background.js b/dist/chromium/background.js index 5c380af..35a162f 100644 --- a/dist/chromium/background.js +++ b/dist/chromium/background.js @@ -283,7 +283,7 @@ if (!sessionTab) { try { - sessionTab = await openConsumerTokenSessionTab(sender); + sessionTab = await openConsumerTokenSessionTabWithRetry(sender, debugEntry); if (debugEntry) { debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL; debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab"; @@ -337,6 +337,36 @@ ); } + async function openConsumerTokenSessionTabWithRetry(sender, debugEntry) { + let attempt = 0; + + // Opening failures must not consume the token service's five attempts. + while (true) { + attempt += 1; + if (debugEntry) { + debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL; + debugEntry.metadata.sessionTabOpenAttempt = String(attempt); + } + + try { + const tab = await openConsumerTokenSessionTab(sender); + if (debugEntry) { + debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab"; + delete debugEntry.metadata.sessionTabNextRetryDelayMs; + } + return tab; + } catch (error) { + const retryDelayMs = getConsumerTokenRetryDelay(Math.min(attempt, 5)); + if (debugEntry) { + debugEntry.metadata.sessionBootstrapState = "retrying-tab-open"; + debugEntry.metadata.sessionTabLastOpenError = error.message || "unknown error"; + debugEntry.metadata.sessionTabNextRetryDelayMs = String(retryDelayMs); + } + await wait(retryDelayMs); + } + } + } + function openConsumerTokenSessionTab(sender) { const details = { url: CUSTOMER_TOKEN_SESSION_URL, diff --git a/dist/chromium/content.js b/dist/chromium/content.js index 51bc626..675978b 100644 --- a/dist/chromium/content.js +++ b/dist/chromium/content.js @@ -2132,6 +2132,8 @@ tabList.setAttribute("role", "tablist"); tabList.setAttribute("aria-label", "Account plans"); + const tabsNavigation = createAccountPlansTabsNavigation(tabList); + const panels = document.createElement("div"); panels.className = "opportunities-extension-account-plans-panels"; @@ -2183,7 +2185,7 @@ panels.append(panel); }); - content.append(tabList, panels); + content.append(tabsNavigation, panels); } dialog.append(header, content); @@ -2200,6 +2202,74 @@ } } + function createAccountPlansTabsNavigation(tabList) { + const navigation = document.createElement("div"); + navigation.className = "opportunities-extension-account-plans-tabs-navigation"; + + const previousButton = createAccountPlansTabNavigationButton("previous"); + const nextButton = createAccountPlansTabNavigationButton("next"); + const updateNavigation = () => { + const maximumScroll = Math.max(0, tabList.scrollWidth - tabList.clientWidth); + const tabs = Array.from(tabList.querySelectorAll("[role='tab']")); + const viewportStart = tabList.scrollLeft; + const viewportEnd = viewportStart + tabList.clientWidth; + const hasContentBefore = tabList.scrollLeft > 1 + || tabs.some((tab) => tab.offsetLeft < viewportStart - 1); + const hasContentAfter = tabList.scrollLeft < maximumScroll - 1 + || tabs.some((tab) => (tab.offsetLeft + tab.offsetWidth) > viewportEnd + 1); + previousButton.hidden = !hasContentBefore; + nextButton.hidden = !hasContentAfter; + }; + const scrollTabs = (direction) => { + tabList.scrollBy({ + left: direction * Math.max(160, Math.floor(tabList.clientWidth * .72)), + behavior: "smooth" + }); + }; + + previousButton.addEventListener("click", () => scrollTabs(-1)); + nextButton.addEventListener("click", () => scrollTabs(1)); + tabList.addEventListener("scroll", updateNavigation, { passive: true }); + const handleResize = () => { + if (!navigation.isConnected) { + window.removeEventListener("resize", handleResize); + return; + } + updateNavigation(); + }; + window.addEventListener("resize", handleResize, { passive: true }); + requestAnimationFrame(() => requestAnimationFrame(updateNavigation)); + setTimeout(updateNavigation, 0); + + navigation.append(previousButton, tabList, nextButton); + return navigation; + } + + function createAccountPlansTabNavigationButton(direction) { + const isPrevious = direction === "previous"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "opportunities-extension-account-plans-tab-navigation-button"; + button.setAttribute("aria-label", isPrevious ? "Show previous account plans" : "Show next account plans"); + button.title = isPrevious ? "Previous plans" : "Next plans"; + + const icon = createSvgElement("svg", { + viewBox: "0 0 24 24", + "aria-hidden": "true", + focusable: "false" + }); + icon.append(createSvgElement("path", { + d: isPrevious ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7", + fill: "none", + stroke: "currentColor", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "stroke-width": "2" + })); + button.append(icon); + return button; + } + function createAccountPlansSkeleton() { const skeleton = document.createElement("div"); skeleton.className = "opportunities-extension-account-plans-skeleton"; @@ -2211,12 +2281,15 @@ const tabs = document.createElement("div"); tabs.className = "opportunities-extension-account-plans-skeleton-tabs"; - [96, 124, 108].forEach((width) => { - const tab = document.createElement("span"); - tab.className = "opportunities-extension-account-plans-skeleton-bar"; - tab.style.width = `${width}px`; - tabs.append(tab); - }); + const tab = document.createElement("span"); + tab.className = "opportunities-extension-account-plans-skeleton-tab"; + const status = document.createElement("span"); + status.className = "opportunities-extension-account-plans-skeleton-status"; + const tabLabel = document.createElement("span"); + tabLabel.className = "opportunities-extension-account-plans-skeleton-bar"; + tabLabel.style.width = "104px"; + tab.append(status, tabLabel); + tabs.append(tab); const details = document.createElement("div"); details.className = "opportunities-extension-account-plans-skeleton-details"; @@ -2231,7 +2304,57 @@ details.append(row); }); - skeleton.append(announcement, tabs, details); + const trend = document.createElement("div"); + trend.className = "opportunities-extension-account-plans-skeleton-trend"; + + const trendHeader = document.createElement("div"); + trendHeader.className = "opportunities-extension-account-plans-skeleton-trend-header"; + const periods = document.createElement("div"); + periods.className = "opportunities-extension-account-plans-skeleton-periods"; + [58, 52, 56, 38].forEach((width, index) => { + const period = document.createElement("span"); + period.className = "opportunities-extension-account-plans-skeleton-bar"; + period.style.width = `${width}px`; + if (index === 2) period.classList.add("opportunities-extension-account-plans-skeleton-period-active"); + periods.append(period); + }); + trendHeader.append(periods); + + const navigation = document.createElement("div"); + navigation.className = "opportunities-extension-account-plans-skeleton-navigation"; + [32, 124, 32].forEach((width) => { + const item = document.createElement("span"); + item.className = "opportunities-extension-account-plans-skeleton-bar"; + item.style.width = `${width}px`; + navigation.append(item); + }); + + const chart = document.createElement("div"); + chart.className = "opportunities-extension-account-plans-skeleton-chart"; + const plot = document.createElement("div"); + plot.className = "opportunities-extension-account-plans-skeleton-plot"; + [34, 42, 38, 48, 44, 58, 64, 55, 76, 68, 86, 62].forEach((height) => { + const bar = document.createElement("span"); + bar.className = "opportunities-extension-account-plans-skeleton-bar"; + bar.style.height = `${height}%`; + plot.append(bar); + }); + const legend = document.createElement("div"); + legend.className = "opportunities-extension-account-plans-skeleton-legend"; + [112, 68, 132].forEach((width) => { + const item = document.createElement("span"); + item.className = "opportunities-extension-account-plans-skeleton-bar"; + item.style.width = `${width}px`; + legend.append(item); + }); + chart.append(plot, legend); + trend.append(trendHeader, navigation, chart); + + const panel = document.createElement("div"); + panel.className = "opportunities-extension-account-plan-panel opportunities-extension-account-plans-skeleton-panel"; + panel.append(details, trend); + + skeleton.append(announcement, tabs, panel); return skeleton; } @@ -4706,7 +4829,7 @@ border-left: 5px solid transparent; content: ""; pointer-events: none; - transform: translateY(-35%); + transform: translateY(-50%); } .opportunities-extension-customer-filter-trigger:disabled { @@ -4866,7 +4989,7 @@ border-top: 6px solid #000000; content: ""; pointer-events: none; - transform: translateY(-35%); + transform: translateY(-50%); } .opportunities-extension-field select { @@ -5446,7 +5569,9 @@ display: grid; width: min(1180px, 100%); height: min(760px, calc(100vh - 24px)); + min-width: 0; grid-template-rows: auto minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); overflow: hidden; border: 1px solid #8f8a85; border-radius: 4px; @@ -5479,17 +5604,33 @@ .opportunities-extension-account-plans-content { display: grid; + min-width: 0; grid-template-rows: auto minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); min-height: 0; overflow: hidden; } + .opportunities-extension-account-plans-content > .opportunities-extension-account-plans-skeleton { + grid-row: 1 / -1; + } + + .opportunities-extension-account-plans-tabs-navigation { + position: relative; + min-width: 0; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-tabs-navigation .opportunities-extension-account-plans-tab-navigation-button[hidden] { + display: none; + } + .opportunities-extension-account-plans-tabs { display: flex; + min-width: 0; gap: 4px; overflow-x: auto; padding: 8px 20px 0; - border-bottom: 1px solid #dedbd7; scrollbar-width: none; } @@ -5497,7 +5638,56 @@ display: none; } + .opportunities-extension-account-plans-tab-navigation-button { + display: grid; + position: absolute; + z-index: 1; + top: 50%; + width: 32px; + height: 32px; + place-items: center; + padding: 0; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + color: #312d2a; + box-shadow: 0 2px 6px rgba(0, 0, 0, .16); + transform: translateY(-50%); + cursor: pointer; + } + + .opportunities-extension-account-plans-tab-navigation-button:first-child { + left: 8px; + } + + .opportunities-extension-account-plans-tab-navigation-button:last-child { + right: 8px; + } + + .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) { + border-color: #00758f; + background: #f0eeeb; + color: #005e73; + } + + .opportunities-extension-account-plans-tab-navigation-button:disabled { + border-color: #dedbd7; + color: #b8b2ad; + cursor: default; + } + + .opportunities-extension-account-plans-tab-navigation-button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 2px; + } + + .opportunities-extension-account-plans-tab-navigation-button svg { + width: 18px; + height: 18px; + } + .opportunities-extension-account-plans-panels { + min-width: 0; min-height: 0; overflow: hidden; } @@ -5923,26 +6113,58 @@ .opportunities-extension-account-plans-skeleton-tabs { display: flex; - gap: 16px; - min-height: 51px; + min-height: 46px; align-items: center; padding: 0 20px; border-bottom: 1px solid #dedbd7; } + .opportunities-extension-account-plans-skeleton { + display: grid; + height: 100%; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + } + + .opportunities-extension-account-plans-skeleton-panel { + padding: 16px 20px; + } + + .opportunities-extension-account-plans-skeleton-tab { + display: inline-flex; + align-items: center; + gap: 9px; + min-height: 38px; + padding: 0 12px; + border-bottom: 3px solid #00758f; + } + + .opportunities-extension-account-plans-skeleton-status { + display: block; + width: 10px; + height: 10px; + flex: 0 0 auto; + border-radius: 50%; + background: #dedbd7; + box-shadow: 0 0 0 3px #efedeb; + animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; + } + .opportunities-extension-account-plans-skeleton-details { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 28px; - padding: 11px 20px 28px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0 24px; + padding: 0; } .opportunities-extension-account-plans-skeleton-details > div { display: grid; - grid-template-columns: minmax(110px, 35%) minmax(0, 1fr); + grid-template-columns: minmax(104px, 38%) minmax(0, 1fr); align-items: center; - gap: 14px; - min-height: 52px; + gap: 10px; + min-height: 42px; + padding: 7px 0; border-bottom: 1px solid #ebe8e5; } @@ -5963,6 +6185,111 @@ height: 10px; } + .opportunities-extension-account-plans-skeleton-trend { + display: grid; + min-height: 0; + grid-template-rows: auto auto minmax(0, 1fr); + margin: 14px 0 0; + padding-top: 12px; + border-top: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-skeleton-trend-header { + display: flex; + justify-content: flex-end; + min-height: 38px; + } + + .opportunities-extension-account-plans-skeleton-periods { + display: flex; + align-items: center; + gap: 16px; + } + + .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar { + height: 12px; + } + + .opportunities-extension-account-plans-skeleton-period-active { + height: 38px !important; + border: 1px solid #8cc8d3; + background: #e7f6f8; + } + + .opportunities-extension-account-plans-skeleton-navigation { + display: grid; + grid-template-columns: 32px 124px 32px; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 54px; + } + + .opportunities-extension-account-plans-skeleton-navigation .opportunities-extension-account-plans-skeleton-bar { + justify-self: center; + } + + .opportunities-extension-account-plans-skeleton-chart { + display: grid; + min-height: 0; + grid-template-columns: minmax(0, 1fr) 150px; + align-items: center; + gap: 28px; + padding: 12px 14px 0 62px; + } + + .opportunities-extension-account-plans-skeleton-plot { + display: flex; + position: relative; + height: 300px; + min-height: 0; + box-sizing: border-box; + align-items: flex-end; + justify-content: space-around; + gap: 12px; + padding: 0 14px 64px; + background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #ebe8e5 58px, #ebe8e5 59px) left top / 100% 236px no-repeat; + } + + .opportunities-extension-account-plans-skeleton-plot::before, + .opportunities-extension-account-plans-skeleton-plot::after { + position: absolute; + display: block; + content: ""; + background: #dedbd7; + } + + .opportunities-extension-account-plans-skeleton-plot::before { + top: 0; + bottom: 64px; + left: 0; + width: 1px; + } + + .opportunities-extension-account-plans-skeleton-plot::after { + right: 0; + bottom: 64px; + left: 0; + height: 1px; + } + + .opportunities-extension-account-plans-skeleton-plot .opportunities-extension-account-plans-skeleton-bar { + width: min(38px, 7%); + flex: 0 1 38px; + border-radius: 2px 2px 0 0; + } + + .opportunities-extension-account-plans-skeleton-legend { + display: grid; + align-content: center; + gap: 18px; + min-height: 0; + } + + .opportunities-extension-account-plans-skeleton-legend .opportunities-extension-account-plans-skeleton-bar { + height: 11px; + } + @keyframes opportunities-extension-skeleton-pulse { from { opacity: .48; } to { opacity: 1; } @@ -6364,6 +6691,16 @@ background: #5b5753; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-status { + background: #5b5753; + box-shadow: 0 0 0 3px #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-period-active { + border-color: #3f7580; + background: #23474e; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot span { background: #5b5753; } @@ -6452,6 +6789,7 @@ .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs-navigation, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details > div, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-tabs, @@ -6460,10 +6798,21 @@ } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-trend, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-trend, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot { border-color: #4e4a46; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot { + background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #4e4a46 58px, #4e4a46 59px) left top / 100% 236px no-repeat; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot::before, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot::after { + background: #4e4a46; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab { color: #c9c5c1; } @@ -6492,19 +6841,22 @@ color: #91e4ed; } - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button { border-color: #6a6560; background: #333130; color: #f6f4f2; } - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:hover:not(:disabled) { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:hover:not(:disabled), + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) { border-color: #43c4d5; background: #3b3937; color: #91e4ed; } - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:disabled { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:disabled, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button:disabled { border-color: #4e4a46; color: #746f6a; } @@ -6940,7 +7292,46 @@ } .opportunities-extension-account-plans-skeleton-details { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 14px; + padding: 0; + } + + .opportunities-extension-account-plans-skeleton-details > div { grid-template-columns: 1fr; + align-content: center; + gap: 5px; + min-height: 54px; + padding: 5px 0; + } + + .opportunities-extension-account-plans-skeleton-trend { + margin: 14px 0 0; + } + + .opportunities-extension-account-plans-skeleton-periods { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: center; + gap: 6px; + } + + .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar { + width: 100% !important; + } + + .opportunities-extension-account-plans-skeleton-chart { + grid-template-columns: minmax(0, 1fr); + gap: 12px; + padding: 12px 8px 0 34px; + } + + .opportunities-extension-account-plans-skeleton-legend { + display: flex; + min-height: 24px; + align-items: center; + justify-content: center; + gap: 12px; } .opportunities-extension-consumption-trend-header { diff --git a/dist/firefox/background.js b/dist/firefox/background.js index 5c380af..35a162f 100644 --- a/dist/firefox/background.js +++ b/dist/firefox/background.js @@ -283,7 +283,7 @@ if (!sessionTab) { try { - sessionTab = await openConsumerTokenSessionTab(sender); + sessionTab = await openConsumerTokenSessionTabWithRetry(sender, debugEntry); if (debugEntry) { debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL; debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab"; @@ -337,6 +337,36 @@ ); } + async function openConsumerTokenSessionTabWithRetry(sender, debugEntry) { + let attempt = 0; + + // Opening failures must not consume the token service's five attempts. + while (true) { + attempt += 1; + if (debugEntry) { + debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL; + debugEntry.metadata.sessionTabOpenAttempt = String(attempt); + } + + try { + const tab = await openConsumerTokenSessionTab(sender); + if (debugEntry) { + debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab"; + delete debugEntry.metadata.sessionTabNextRetryDelayMs; + } + return tab; + } catch (error) { + const retryDelayMs = getConsumerTokenRetryDelay(Math.min(attempt, 5)); + if (debugEntry) { + debugEntry.metadata.sessionBootstrapState = "retrying-tab-open"; + debugEntry.metadata.sessionTabLastOpenError = error.message || "unknown error"; + debugEntry.metadata.sessionTabNextRetryDelayMs = String(retryDelayMs); + } + await wait(retryDelayMs); + } + } + } + function openConsumerTokenSessionTab(sender) { const details = { url: CUSTOMER_TOKEN_SESSION_URL, diff --git a/dist/firefox/content.js b/dist/firefox/content.js index 51bc626..675978b 100644 --- a/dist/firefox/content.js +++ b/dist/firefox/content.js @@ -2132,6 +2132,8 @@ tabList.setAttribute("role", "tablist"); tabList.setAttribute("aria-label", "Account plans"); + const tabsNavigation = createAccountPlansTabsNavigation(tabList); + const panels = document.createElement("div"); panels.className = "opportunities-extension-account-plans-panels"; @@ -2183,7 +2185,7 @@ panels.append(panel); }); - content.append(tabList, panels); + content.append(tabsNavigation, panels); } dialog.append(header, content); @@ -2200,6 +2202,74 @@ } } + function createAccountPlansTabsNavigation(tabList) { + const navigation = document.createElement("div"); + navigation.className = "opportunities-extension-account-plans-tabs-navigation"; + + const previousButton = createAccountPlansTabNavigationButton("previous"); + const nextButton = createAccountPlansTabNavigationButton("next"); + const updateNavigation = () => { + const maximumScroll = Math.max(0, tabList.scrollWidth - tabList.clientWidth); + const tabs = Array.from(tabList.querySelectorAll("[role='tab']")); + const viewportStart = tabList.scrollLeft; + const viewportEnd = viewportStart + tabList.clientWidth; + const hasContentBefore = tabList.scrollLeft > 1 + || tabs.some((tab) => tab.offsetLeft < viewportStart - 1); + const hasContentAfter = tabList.scrollLeft < maximumScroll - 1 + || tabs.some((tab) => (tab.offsetLeft + tab.offsetWidth) > viewportEnd + 1); + previousButton.hidden = !hasContentBefore; + nextButton.hidden = !hasContentAfter; + }; + const scrollTabs = (direction) => { + tabList.scrollBy({ + left: direction * Math.max(160, Math.floor(tabList.clientWidth * .72)), + behavior: "smooth" + }); + }; + + previousButton.addEventListener("click", () => scrollTabs(-1)); + nextButton.addEventListener("click", () => scrollTabs(1)); + tabList.addEventListener("scroll", updateNavigation, { passive: true }); + const handleResize = () => { + if (!navigation.isConnected) { + window.removeEventListener("resize", handleResize); + return; + } + updateNavigation(); + }; + window.addEventListener("resize", handleResize, { passive: true }); + requestAnimationFrame(() => requestAnimationFrame(updateNavigation)); + setTimeout(updateNavigation, 0); + + navigation.append(previousButton, tabList, nextButton); + return navigation; + } + + function createAccountPlansTabNavigationButton(direction) { + const isPrevious = direction === "previous"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "opportunities-extension-account-plans-tab-navigation-button"; + button.setAttribute("aria-label", isPrevious ? "Show previous account plans" : "Show next account plans"); + button.title = isPrevious ? "Previous plans" : "Next plans"; + + const icon = createSvgElement("svg", { + viewBox: "0 0 24 24", + "aria-hidden": "true", + focusable: "false" + }); + icon.append(createSvgElement("path", { + d: isPrevious ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7", + fill: "none", + stroke: "currentColor", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "stroke-width": "2" + })); + button.append(icon); + return button; + } + function createAccountPlansSkeleton() { const skeleton = document.createElement("div"); skeleton.className = "opportunities-extension-account-plans-skeleton"; @@ -2211,12 +2281,15 @@ const tabs = document.createElement("div"); tabs.className = "opportunities-extension-account-plans-skeleton-tabs"; - [96, 124, 108].forEach((width) => { - const tab = document.createElement("span"); - tab.className = "opportunities-extension-account-plans-skeleton-bar"; - tab.style.width = `${width}px`; - tabs.append(tab); - }); + const tab = document.createElement("span"); + tab.className = "opportunities-extension-account-plans-skeleton-tab"; + const status = document.createElement("span"); + status.className = "opportunities-extension-account-plans-skeleton-status"; + const tabLabel = document.createElement("span"); + tabLabel.className = "opportunities-extension-account-plans-skeleton-bar"; + tabLabel.style.width = "104px"; + tab.append(status, tabLabel); + tabs.append(tab); const details = document.createElement("div"); details.className = "opportunities-extension-account-plans-skeleton-details"; @@ -2231,7 +2304,57 @@ details.append(row); }); - skeleton.append(announcement, tabs, details); + const trend = document.createElement("div"); + trend.className = "opportunities-extension-account-plans-skeleton-trend"; + + const trendHeader = document.createElement("div"); + trendHeader.className = "opportunities-extension-account-plans-skeleton-trend-header"; + const periods = document.createElement("div"); + periods.className = "opportunities-extension-account-plans-skeleton-periods"; + [58, 52, 56, 38].forEach((width, index) => { + const period = document.createElement("span"); + period.className = "opportunities-extension-account-plans-skeleton-bar"; + period.style.width = `${width}px`; + if (index === 2) period.classList.add("opportunities-extension-account-plans-skeleton-period-active"); + periods.append(period); + }); + trendHeader.append(periods); + + const navigation = document.createElement("div"); + navigation.className = "opportunities-extension-account-plans-skeleton-navigation"; + [32, 124, 32].forEach((width) => { + const item = document.createElement("span"); + item.className = "opportunities-extension-account-plans-skeleton-bar"; + item.style.width = `${width}px`; + navigation.append(item); + }); + + const chart = document.createElement("div"); + chart.className = "opportunities-extension-account-plans-skeleton-chart"; + const plot = document.createElement("div"); + plot.className = "opportunities-extension-account-plans-skeleton-plot"; + [34, 42, 38, 48, 44, 58, 64, 55, 76, 68, 86, 62].forEach((height) => { + const bar = document.createElement("span"); + bar.className = "opportunities-extension-account-plans-skeleton-bar"; + bar.style.height = `${height}%`; + plot.append(bar); + }); + const legend = document.createElement("div"); + legend.className = "opportunities-extension-account-plans-skeleton-legend"; + [112, 68, 132].forEach((width) => { + const item = document.createElement("span"); + item.className = "opportunities-extension-account-plans-skeleton-bar"; + item.style.width = `${width}px`; + legend.append(item); + }); + chart.append(plot, legend); + trend.append(trendHeader, navigation, chart); + + const panel = document.createElement("div"); + panel.className = "opportunities-extension-account-plan-panel opportunities-extension-account-plans-skeleton-panel"; + panel.append(details, trend); + + skeleton.append(announcement, tabs, panel); return skeleton; } @@ -4706,7 +4829,7 @@ border-left: 5px solid transparent; content: ""; pointer-events: none; - transform: translateY(-35%); + transform: translateY(-50%); } .opportunities-extension-customer-filter-trigger:disabled { @@ -4866,7 +4989,7 @@ border-top: 6px solid #000000; content: ""; pointer-events: none; - transform: translateY(-35%); + transform: translateY(-50%); } .opportunities-extension-field select { @@ -5446,7 +5569,9 @@ display: grid; width: min(1180px, 100%); height: min(760px, calc(100vh - 24px)); + min-width: 0; grid-template-rows: auto minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); overflow: hidden; border: 1px solid #8f8a85; border-radius: 4px; @@ -5479,17 +5604,33 @@ .opportunities-extension-account-plans-content { display: grid; + min-width: 0; grid-template-rows: auto minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); min-height: 0; overflow: hidden; } + .opportunities-extension-account-plans-content > .opportunities-extension-account-plans-skeleton { + grid-row: 1 / -1; + } + + .opportunities-extension-account-plans-tabs-navigation { + position: relative; + min-width: 0; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-tabs-navigation .opportunities-extension-account-plans-tab-navigation-button[hidden] { + display: none; + } + .opportunities-extension-account-plans-tabs { display: flex; + min-width: 0; gap: 4px; overflow-x: auto; padding: 8px 20px 0; - border-bottom: 1px solid #dedbd7; scrollbar-width: none; } @@ -5497,7 +5638,56 @@ display: none; } + .opportunities-extension-account-plans-tab-navigation-button { + display: grid; + position: absolute; + z-index: 1; + top: 50%; + width: 32px; + height: 32px; + place-items: center; + padding: 0; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + color: #312d2a; + box-shadow: 0 2px 6px rgba(0, 0, 0, .16); + transform: translateY(-50%); + cursor: pointer; + } + + .opportunities-extension-account-plans-tab-navigation-button:first-child { + left: 8px; + } + + .opportunities-extension-account-plans-tab-navigation-button:last-child { + right: 8px; + } + + .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) { + border-color: #00758f; + background: #f0eeeb; + color: #005e73; + } + + .opportunities-extension-account-plans-tab-navigation-button:disabled { + border-color: #dedbd7; + color: #b8b2ad; + cursor: default; + } + + .opportunities-extension-account-plans-tab-navigation-button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 2px; + } + + .opportunities-extension-account-plans-tab-navigation-button svg { + width: 18px; + height: 18px; + } + .opportunities-extension-account-plans-panels { + min-width: 0; min-height: 0; overflow: hidden; } @@ -5923,26 +6113,58 @@ .opportunities-extension-account-plans-skeleton-tabs { display: flex; - gap: 16px; - min-height: 51px; + min-height: 46px; align-items: center; padding: 0 20px; border-bottom: 1px solid #dedbd7; } + .opportunities-extension-account-plans-skeleton { + display: grid; + height: 100%; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + } + + .opportunities-extension-account-plans-skeleton-panel { + padding: 16px 20px; + } + + .opportunities-extension-account-plans-skeleton-tab { + display: inline-flex; + align-items: center; + gap: 9px; + min-height: 38px; + padding: 0 12px; + border-bottom: 3px solid #00758f; + } + + .opportunities-extension-account-plans-skeleton-status { + display: block; + width: 10px; + height: 10px; + flex: 0 0 auto; + border-radius: 50%; + background: #dedbd7; + box-shadow: 0 0 0 3px #efedeb; + animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; + } + .opportunities-extension-account-plans-skeleton-details { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 28px; - padding: 11px 20px 28px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0 24px; + padding: 0; } .opportunities-extension-account-plans-skeleton-details > div { display: grid; - grid-template-columns: minmax(110px, 35%) minmax(0, 1fr); + grid-template-columns: minmax(104px, 38%) minmax(0, 1fr); align-items: center; - gap: 14px; - min-height: 52px; + gap: 10px; + min-height: 42px; + padding: 7px 0; border-bottom: 1px solid #ebe8e5; } @@ -5963,6 +6185,111 @@ height: 10px; } + .opportunities-extension-account-plans-skeleton-trend { + display: grid; + min-height: 0; + grid-template-rows: auto auto minmax(0, 1fr); + margin: 14px 0 0; + padding-top: 12px; + border-top: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-skeleton-trend-header { + display: flex; + justify-content: flex-end; + min-height: 38px; + } + + .opportunities-extension-account-plans-skeleton-periods { + display: flex; + align-items: center; + gap: 16px; + } + + .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar { + height: 12px; + } + + .opportunities-extension-account-plans-skeleton-period-active { + height: 38px !important; + border: 1px solid #8cc8d3; + background: #e7f6f8; + } + + .opportunities-extension-account-plans-skeleton-navigation { + display: grid; + grid-template-columns: 32px 124px 32px; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 54px; + } + + .opportunities-extension-account-plans-skeleton-navigation .opportunities-extension-account-plans-skeleton-bar { + justify-self: center; + } + + .opportunities-extension-account-plans-skeleton-chart { + display: grid; + min-height: 0; + grid-template-columns: minmax(0, 1fr) 150px; + align-items: center; + gap: 28px; + padding: 12px 14px 0 62px; + } + + .opportunities-extension-account-plans-skeleton-plot { + display: flex; + position: relative; + height: 300px; + min-height: 0; + box-sizing: border-box; + align-items: flex-end; + justify-content: space-around; + gap: 12px; + padding: 0 14px 64px; + background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #ebe8e5 58px, #ebe8e5 59px) left top / 100% 236px no-repeat; + } + + .opportunities-extension-account-plans-skeleton-plot::before, + .opportunities-extension-account-plans-skeleton-plot::after { + position: absolute; + display: block; + content: ""; + background: #dedbd7; + } + + .opportunities-extension-account-plans-skeleton-plot::before { + top: 0; + bottom: 64px; + left: 0; + width: 1px; + } + + .opportunities-extension-account-plans-skeleton-plot::after { + right: 0; + bottom: 64px; + left: 0; + height: 1px; + } + + .opportunities-extension-account-plans-skeleton-plot .opportunities-extension-account-plans-skeleton-bar { + width: min(38px, 7%); + flex: 0 1 38px; + border-radius: 2px 2px 0 0; + } + + .opportunities-extension-account-plans-skeleton-legend { + display: grid; + align-content: center; + gap: 18px; + min-height: 0; + } + + .opportunities-extension-account-plans-skeleton-legend .opportunities-extension-account-plans-skeleton-bar { + height: 11px; + } + @keyframes opportunities-extension-skeleton-pulse { from { opacity: .48; } to { opacity: 1; } @@ -6364,6 +6691,16 @@ background: #5b5753; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-status { + background: #5b5753; + box-shadow: 0 0 0 3px #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-period-active { + border-color: #3f7580; + background: #23474e; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot span { background: #5b5753; } @@ -6452,6 +6789,7 @@ .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs-navigation, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details > div, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-tabs, @@ -6460,10 +6798,21 @@ } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-trend, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-trend, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot { border-color: #4e4a46; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot { + background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #4e4a46 58px, #4e4a46 59px) left top / 100% 236px no-repeat; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot::before, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot::after { + background: #4e4a46; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab { color: #c9c5c1; } @@ -6492,19 +6841,22 @@ color: #91e4ed; } - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button { border-color: #6a6560; background: #333130; color: #f6f4f2; } - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:hover:not(:disabled) { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:hover:not(:disabled), + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) { border-color: #43c4d5; background: #3b3937; color: #91e4ed; } - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:disabled { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:disabled, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button:disabled { border-color: #4e4a46; color: #746f6a; } @@ -6940,7 +7292,46 @@ } .opportunities-extension-account-plans-skeleton-details { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 14px; + padding: 0; + } + + .opportunities-extension-account-plans-skeleton-details > div { grid-template-columns: 1fr; + align-content: center; + gap: 5px; + min-height: 54px; + padding: 5px 0; + } + + .opportunities-extension-account-plans-skeleton-trend { + margin: 14px 0 0; + } + + .opportunities-extension-account-plans-skeleton-periods { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: center; + gap: 6px; + } + + .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar { + width: 100% !important; + } + + .opportunities-extension-account-plans-skeleton-chart { + grid-template-columns: minmax(0, 1fr); + gap: 12px; + padding: 12px 8px 0 34px; + } + + .opportunities-extension-account-plans-skeleton-legend { + display: flex; + min-height: 24px; + align-items: center; + justify-content: center; + gap: 12px; } .opportunities-extension-consumption-trend-header { diff --git a/src/background.js b/src/background.js index 5c380af..35a162f 100644 --- a/src/background.js +++ b/src/background.js @@ -283,7 +283,7 @@ if (!sessionTab) { try { - sessionTab = await openConsumerTokenSessionTab(sender); + sessionTab = await openConsumerTokenSessionTabWithRetry(sender, debugEntry); if (debugEntry) { debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL; debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab"; @@ -337,6 +337,36 @@ ); } + async function openConsumerTokenSessionTabWithRetry(sender, debugEntry) { + let attempt = 0; + + // Opening failures must not consume the token service's five attempts. + while (true) { + attempt += 1; + if (debugEntry) { + debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL; + debugEntry.metadata.sessionTabOpenAttempt = String(attempt); + } + + try { + const tab = await openConsumerTokenSessionTab(sender); + if (debugEntry) { + debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab"; + delete debugEntry.metadata.sessionTabNextRetryDelayMs; + } + return tab; + } catch (error) { + const retryDelayMs = getConsumerTokenRetryDelay(Math.min(attempt, 5)); + if (debugEntry) { + debugEntry.metadata.sessionBootstrapState = "retrying-tab-open"; + debugEntry.metadata.sessionTabLastOpenError = error.message || "unknown error"; + debugEntry.metadata.sessionTabNextRetryDelayMs = String(retryDelayMs); + } + await wait(retryDelayMs); + } + } + } + function openConsumerTokenSessionTab(sender) { const details = { url: CUSTOMER_TOKEN_SESSION_URL, diff --git a/src/content.js b/src/content.js index 51bc626..675978b 100644 --- a/src/content.js +++ b/src/content.js @@ -2132,6 +2132,8 @@ tabList.setAttribute("role", "tablist"); tabList.setAttribute("aria-label", "Account plans"); + const tabsNavigation = createAccountPlansTabsNavigation(tabList); + const panels = document.createElement("div"); panels.className = "opportunities-extension-account-plans-panels"; @@ -2183,7 +2185,7 @@ panels.append(panel); }); - content.append(tabList, panels); + content.append(tabsNavigation, panels); } dialog.append(header, content); @@ -2200,6 +2202,74 @@ } } + function createAccountPlansTabsNavigation(tabList) { + const navigation = document.createElement("div"); + navigation.className = "opportunities-extension-account-plans-tabs-navigation"; + + const previousButton = createAccountPlansTabNavigationButton("previous"); + const nextButton = createAccountPlansTabNavigationButton("next"); + const updateNavigation = () => { + const maximumScroll = Math.max(0, tabList.scrollWidth - tabList.clientWidth); + const tabs = Array.from(tabList.querySelectorAll("[role='tab']")); + const viewportStart = tabList.scrollLeft; + const viewportEnd = viewportStart + tabList.clientWidth; + const hasContentBefore = tabList.scrollLeft > 1 + || tabs.some((tab) => tab.offsetLeft < viewportStart - 1); + const hasContentAfter = tabList.scrollLeft < maximumScroll - 1 + || tabs.some((tab) => (tab.offsetLeft + tab.offsetWidth) > viewportEnd + 1); + previousButton.hidden = !hasContentBefore; + nextButton.hidden = !hasContentAfter; + }; + const scrollTabs = (direction) => { + tabList.scrollBy({ + left: direction * Math.max(160, Math.floor(tabList.clientWidth * .72)), + behavior: "smooth" + }); + }; + + previousButton.addEventListener("click", () => scrollTabs(-1)); + nextButton.addEventListener("click", () => scrollTabs(1)); + tabList.addEventListener("scroll", updateNavigation, { passive: true }); + const handleResize = () => { + if (!navigation.isConnected) { + window.removeEventListener("resize", handleResize); + return; + } + updateNavigation(); + }; + window.addEventListener("resize", handleResize, { passive: true }); + requestAnimationFrame(() => requestAnimationFrame(updateNavigation)); + setTimeout(updateNavigation, 0); + + navigation.append(previousButton, tabList, nextButton); + return navigation; + } + + function createAccountPlansTabNavigationButton(direction) { + const isPrevious = direction === "previous"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "opportunities-extension-account-plans-tab-navigation-button"; + button.setAttribute("aria-label", isPrevious ? "Show previous account plans" : "Show next account plans"); + button.title = isPrevious ? "Previous plans" : "Next plans"; + + const icon = createSvgElement("svg", { + viewBox: "0 0 24 24", + "aria-hidden": "true", + focusable: "false" + }); + icon.append(createSvgElement("path", { + d: isPrevious ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7", + fill: "none", + stroke: "currentColor", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "stroke-width": "2" + })); + button.append(icon); + return button; + } + function createAccountPlansSkeleton() { const skeleton = document.createElement("div"); skeleton.className = "opportunities-extension-account-plans-skeleton"; @@ -2211,12 +2281,15 @@ const tabs = document.createElement("div"); tabs.className = "opportunities-extension-account-plans-skeleton-tabs"; - [96, 124, 108].forEach((width) => { - const tab = document.createElement("span"); - tab.className = "opportunities-extension-account-plans-skeleton-bar"; - tab.style.width = `${width}px`; - tabs.append(tab); - }); + const tab = document.createElement("span"); + tab.className = "opportunities-extension-account-plans-skeleton-tab"; + const status = document.createElement("span"); + status.className = "opportunities-extension-account-plans-skeleton-status"; + const tabLabel = document.createElement("span"); + tabLabel.className = "opportunities-extension-account-plans-skeleton-bar"; + tabLabel.style.width = "104px"; + tab.append(status, tabLabel); + tabs.append(tab); const details = document.createElement("div"); details.className = "opportunities-extension-account-plans-skeleton-details"; @@ -2231,7 +2304,57 @@ details.append(row); }); - skeleton.append(announcement, tabs, details); + const trend = document.createElement("div"); + trend.className = "opportunities-extension-account-plans-skeleton-trend"; + + const trendHeader = document.createElement("div"); + trendHeader.className = "opportunities-extension-account-plans-skeleton-trend-header"; + const periods = document.createElement("div"); + periods.className = "opportunities-extension-account-plans-skeleton-periods"; + [58, 52, 56, 38].forEach((width, index) => { + const period = document.createElement("span"); + period.className = "opportunities-extension-account-plans-skeleton-bar"; + period.style.width = `${width}px`; + if (index === 2) period.classList.add("opportunities-extension-account-plans-skeleton-period-active"); + periods.append(period); + }); + trendHeader.append(periods); + + const navigation = document.createElement("div"); + navigation.className = "opportunities-extension-account-plans-skeleton-navigation"; + [32, 124, 32].forEach((width) => { + const item = document.createElement("span"); + item.className = "opportunities-extension-account-plans-skeleton-bar"; + item.style.width = `${width}px`; + navigation.append(item); + }); + + const chart = document.createElement("div"); + chart.className = "opportunities-extension-account-plans-skeleton-chart"; + const plot = document.createElement("div"); + plot.className = "opportunities-extension-account-plans-skeleton-plot"; + [34, 42, 38, 48, 44, 58, 64, 55, 76, 68, 86, 62].forEach((height) => { + const bar = document.createElement("span"); + bar.className = "opportunities-extension-account-plans-skeleton-bar"; + bar.style.height = `${height}%`; + plot.append(bar); + }); + const legend = document.createElement("div"); + legend.className = "opportunities-extension-account-plans-skeleton-legend"; + [112, 68, 132].forEach((width) => { + const item = document.createElement("span"); + item.className = "opportunities-extension-account-plans-skeleton-bar"; + item.style.width = `${width}px`; + legend.append(item); + }); + chart.append(plot, legend); + trend.append(trendHeader, navigation, chart); + + const panel = document.createElement("div"); + panel.className = "opportunities-extension-account-plan-panel opportunities-extension-account-plans-skeleton-panel"; + panel.append(details, trend); + + skeleton.append(announcement, tabs, panel); return skeleton; } @@ -4706,7 +4829,7 @@ border-left: 5px solid transparent; content: ""; pointer-events: none; - transform: translateY(-35%); + transform: translateY(-50%); } .opportunities-extension-customer-filter-trigger:disabled { @@ -4866,7 +4989,7 @@ border-top: 6px solid #000000; content: ""; pointer-events: none; - transform: translateY(-35%); + transform: translateY(-50%); } .opportunities-extension-field select { @@ -5446,7 +5569,9 @@ display: grid; width: min(1180px, 100%); height: min(760px, calc(100vh - 24px)); + min-width: 0; grid-template-rows: auto minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); overflow: hidden; border: 1px solid #8f8a85; border-radius: 4px; @@ -5479,17 +5604,33 @@ .opportunities-extension-account-plans-content { display: grid; + min-width: 0; grid-template-rows: auto minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); min-height: 0; overflow: hidden; } + .opportunities-extension-account-plans-content > .opportunities-extension-account-plans-skeleton { + grid-row: 1 / -1; + } + + .opportunities-extension-account-plans-tabs-navigation { + position: relative; + min-width: 0; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-tabs-navigation .opportunities-extension-account-plans-tab-navigation-button[hidden] { + display: none; + } + .opportunities-extension-account-plans-tabs { display: flex; + min-width: 0; gap: 4px; overflow-x: auto; padding: 8px 20px 0; - border-bottom: 1px solid #dedbd7; scrollbar-width: none; } @@ -5497,7 +5638,56 @@ display: none; } + .opportunities-extension-account-plans-tab-navigation-button { + display: grid; + position: absolute; + z-index: 1; + top: 50%; + width: 32px; + height: 32px; + place-items: center; + padding: 0; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + color: #312d2a; + box-shadow: 0 2px 6px rgba(0, 0, 0, .16); + transform: translateY(-50%); + cursor: pointer; + } + + .opportunities-extension-account-plans-tab-navigation-button:first-child { + left: 8px; + } + + .opportunities-extension-account-plans-tab-navigation-button:last-child { + right: 8px; + } + + .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) { + border-color: #00758f; + background: #f0eeeb; + color: #005e73; + } + + .opportunities-extension-account-plans-tab-navigation-button:disabled { + border-color: #dedbd7; + color: #b8b2ad; + cursor: default; + } + + .opportunities-extension-account-plans-tab-navigation-button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 2px; + } + + .opportunities-extension-account-plans-tab-navigation-button svg { + width: 18px; + height: 18px; + } + .opportunities-extension-account-plans-panels { + min-width: 0; min-height: 0; overflow: hidden; } @@ -5923,26 +6113,58 @@ .opportunities-extension-account-plans-skeleton-tabs { display: flex; - gap: 16px; - min-height: 51px; + min-height: 46px; align-items: center; padding: 0 20px; border-bottom: 1px solid #dedbd7; } + .opportunities-extension-account-plans-skeleton { + display: grid; + height: 100%; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + } + + .opportunities-extension-account-plans-skeleton-panel { + padding: 16px 20px; + } + + .opportunities-extension-account-plans-skeleton-tab { + display: inline-flex; + align-items: center; + gap: 9px; + min-height: 38px; + padding: 0 12px; + border-bottom: 3px solid #00758f; + } + + .opportunities-extension-account-plans-skeleton-status { + display: block; + width: 10px; + height: 10px; + flex: 0 0 auto; + border-radius: 50%; + background: #dedbd7; + box-shadow: 0 0 0 3px #efedeb; + animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; + } + .opportunities-extension-account-plans-skeleton-details { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 28px; - padding: 11px 20px 28px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0 24px; + padding: 0; } .opportunities-extension-account-plans-skeleton-details > div { display: grid; - grid-template-columns: minmax(110px, 35%) minmax(0, 1fr); + grid-template-columns: minmax(104px, 38%) minmax(0, 1fr); align-items: center; - gap: 14px; - min-height: 52px; + gap: 10px; + min-height: 42px; + padding: 7px 0; border-bottom: 1px solid #ebe8e5; } @@ -5963,6 +6185,111 @@ height: 10px; } + .opportunities-extension-account-plans-skeleton-trend { + display: grid; + min-height: 0; + grid-template-rows: auto auto minmax(0, 1fr); + margin: 14px 0 0; + padding-top: 12px; + border-top: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-skeleton-trend-header { + display: flex; + justify-content: flex-end; + min-height: 38px; + } + + .opportunities-extension-account-plans-skeleton-periods { + display: flex; + align-items: center; + gap: 16px; + } + + .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar { + height: 12px; + } + + .opportunities-extension-account-plans-skeleton-period-active { + height: 38px !important; + border: 1px solid #8cc8d3; + background: #e7f6f8; + } + + .opportunities-extension-account-plans-skeleton-navigation { + display: grid; + grid-template-columns: 32px 124px 32px; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 54px; + } + + .opportunities-extension-account-plans-skeleton-navigation .opportunities-extension-account-plans-skeleton-bar { + justify-self: center; + } + + .opportunities-extension-account-plans-skeleton-chart { + display: grid; + min-height: 0; + grid-template-columns: minmax(0, 1fr) 150px; + align-items: center; + gap: 28px; + padding: 12px 14px 0 62px; + } + + .opportunities-extension-account-plans-skeleton-plot { + display: flex; + position: relative; + height: 300px; + min-height: 0; + box-sizing: border-box; + align-items: flex-end; + justify-content: space-around; + gap: 12px; + padding: 0 14px 64px; + background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #ebe8e5 58px, #ebe8e5 59px) left top / 100% 236px no-repeat; + } + + .opportunities-extension-account-plans-skeleton-plot::before, + .opportunities-extension-account-plans-skeleton-plot::after { + position: absolute; + display: block; + content: ""; + background: #dedbd7; + } + + .opportunities-extension-account-plans-skeleton-plot::before { + top: 0; + bottom: 64px; + left: 0; + width: 1px; + } + + .opportunities-extension-account-plans-skeleton-plot::after { + right: 0; + bottom: 64px; + left: 0; + height: 1px; + } + + .opportunities-extension-account-plans-skeleton-plot .opportunities-extension-account-plans-skeleton-bar { + width: min(38px, 7%); + flex: 0 1 38px; + border-radius: 2px 2px 0 0; + } + + .opportunities-extension-account-plans-skeleton-legend { + display: grid; + align-content: center; + gap: 18px; + min-height: 0; + } + + .opportunities-extension-account-plans-skeleton-legend .opportunities-extension-account-plans-skeleton-bar { + height: 11px; + } + @keyframes opportunities-extension-skeleton-pulse { from { opacity: .48; } to { opacity: 1; } @@ -6364,6 +6691,16 @@ background: #5b5753; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-status { + background: #5b5753; + box-shadow: 0 0 0 3px #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-period-active { + border-color: #3f7580; + background: #23474e; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot span { background: #5b5753; } @@ -6452,6 +6789,7 @@ .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs-navigation, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details > div, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-tabs, @@ -6460,10 +6798,21 @@ } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-trend, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-trend, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot { border-color: #4e4a46; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot { + background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #4e4a46 58px, #4e4a46 59px) left top / 100% 236px no-repeat; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot::before, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot::after { + background: #4e4a46; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab { color: #c9c5c1; } @@ -6492,19 +6841,22 @@ color: #91e4ed; } - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button { border-color: #6a6560; background: #333130; color: #f6f4f2; } - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:hover:not(:disabled) { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:hover:not(:disabled), + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) { border-color: #43c4d5; background: #3b3937; color: #91e4ed; } - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:disabled { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:disabled, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button:disabled { border-color: #4e4a46; color: #746f6a; } @@ -6940,7 +7292,46 @@ } .opportunities-extension-account-plans-skeleton-details { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 14px; + padding: 0; + } + + .opportunities-extension-account-plans-skeleton-details > div { grid-template-columns: 1fr; + align-content: center; + gap: 5px; + min-height: 54px; + padding: 5px 0; + } + + .opportunities-extension-account-plans-skeleton-trend { + margin: 14px 0 0; + } + + .opportunities-extension-account-plans-skeleton-periods { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: center; + gap: 6px; + } + + .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar { + width: 100% !important; + } + + .opportunities-extension-account-plans-skeleton-chart { + grid-template-columns: minmax(0, 1fr); + gap: 12px; + padding: 12px 8px 0 34px; + } + + .opportunities-extension-account-plans-skeleton-legend { + display: flex; + min-height: 24px; + align-items: center; + justify-content: center; + gap: 12px; } .opportunities-extension-consumption-trend-header { diff --git a/tests/consumerTokenSessionRetry.test.cjs b/tests/consumerTokenSessionRetry.test.cjs index 7ccd6f8..1a92295 100644 --- a/tests/consumerTokenSessionRetry.test.cjs +++ b/tests/consumerTokenSessionRetry.test.cjs @@ -22,7 +22,7 @@ function createResponse(body, contentType) { }; } -function loadBackground(responses, createdTabs, removedTabs) { +function loadBackground(responses, createdTabs, removedTabs, options = {}) { let listener; const source = fs.readFileSync(path.join(root, "src", "background.js"), "utf8"); const context = { @@ -37,6 +37,9 @@ function loadBackground(responses, createdTabs, removedTabs) { tabs: { async create(details) { createdTabs.push(details); + if (options.createTab) { + return options.createTab(details); + } return { id: 27 }; }, async remove(tabId) { @@ -45,6 +48,9 @@ function loadBackground(responses, createdTabs, removedTabs) { } }, fetch: async () => { + if (options.onFetch) { + options.onFetch(); + } const response = responses.shift(); if (!response) { @@ -64,7 +70,7 @@ function loadBackground(responses, createdTabs, removedTabs) { Object, Number, Math, - setTimeout, + setTimeout: options.setTimeout || setTimeout, clearTimeout }; @@ -113,6 +119,56 @@ async function run() { assert.equal(response.debugRequests[0].metadata.sessionBootstrapState, "opened-inactive-tab"); assert.equal(response.debugRequests[1].metadata.sessionBootstrapState, "closed-after-valid-json"); + // Even six opening failures must leave the second token request available. + const retryTabs = []; + const retryRemovedTabs = []; + const delays = []; + let tokenRequests = 0; + let openAttempts = 0; + const retryListener = loadBackground([ + createResponse("Fallback", "text/html"), + createResponse(JSON.stringify([ + { tokenService: "ACCP-TS", uiaasHeader: { value: "account-token" } }, + { tokenService: "OCICONS-TS", uiaasHeader: { value: "consumption-token" } } + ]), "application/json") + ], retryTabs, retryRemovedTabs, { + onFetch() { tokenRequests += 1; }, + createTab() { + assert.equal(tokenRequests, 1, "Token requests must wait for the tab to open"); + openAttempts += 1; + if (openAttempts <= 6) { + throw new Error("Temporary tab creation failure"); + } + return { id: 81 }; + }, + setTimeout(callback, delay) { + delays.push(delay); + return setTimeout(callback, 0); + } + }); + const retryResponse = await dispatch(retryListener); + assert.equal(retryResponse.ok, true); + assert.equal(tokenRequests, 2); + assert.equal(openAttempts, 7); + assert.deepEqual(retryRemovedTabs, [81]); + assert.deepEqual(delays.slice(0, 6), [750, 1500, 3000, 4000, 4000, 4000]); + assert.ok(retryTabs.every(tab => tab.active === false && tab.windowId === 1)); + assert.equal(retryResponse.debugRequests[0].metadata.sessionTabOpenAttempt, "7"); + assert.equal(retryResponse.debugRequests[0].metadata.sessionTabLastOpenError, "Temporary tab creation failure"); + assert.equal(retryResponse.debugRequests[0].metadata.sessionTabNextRetryDelayMs, undefined); + + const failedTabs = []; + const failedRemovedTabs = []; + const exhausted = await dispatch(loadBackground( + Array.from({ length: 5 }, () => createResponse("Fallback", "text/html")), + failedTabs, failedRemovedTabs, + { setTimeout: callback => setTimeout(callback, 0) } + )); + assert.equal(exhausted.ok, false); + assert.match(exhausted.error, /after 5 attempts/); + assert.equal(failedTabs.length, 1); + assert.deepEqual(failedRemovedTabs, [27]); + console.log("Consumer token session retry tests passed."); }