1import { premiumBadgeHtml, isPremiumDomain } from '../../common/premium-domain.js'; 2import { buildReactivationButtonHtml } from '../shared/reactivation.js'; 3import { escapeHtml, setSafeHtml } from '../../../client-utils.js'; 4import { initLoadingDots } from '../../../ui/loading.js'; 5import { normalizeText } from '../../../value-utils.js'; 6import { localizeDashboardPath } from '../path-localization.js'; 7import { setDashboardClientTabActiveState } from '../shared/tabs.js'; 8import { 9 DEFAULT_TAB, 10 DOMAINS_PANEL_SELECTOR, 11 DOMAINS_TABS_NAV_SELECTOR, 12 MOBILE_MEDIA_QUERY, 13 TABLE_CONFIG, 14 TRANSFERRED_AWAY_TAB_KEY, 15 resolveTabPath, 16} from './config.js'; 17import { 18 getCellValue, 19 getColumns, 20 getCurrentRows, 21 getHeaderSortState, 22 getPageRows, 23 getTotalPages, 24 getUiState, 25 hasConfigurableColumnOptions, 26 sortRows, 27} from './table.js'; 28 29const buildTabsHtml = (data, activeTab) => 30 data.visibleTabs 31 .map((tab) => { 32 const isActive = tab.key === activeTab; 33 const tabClassName = [ 34 'relative inline-flex shrink-0 whitespace-nowrap text-base', 35 "before:pointer-events-none before:block before:absolute before:top-full before:right-0 before:left-auto before:h-0.5 before:bg-foreground before:content-[''] before:transition-all before:duration-[250ms] before:ease-in-out hover:before:left-0 hover:before:right-auto hover:before:w-full", 36 isActive ? 'font-bold before:left-0 before:right-auto before:w-full' : 'font-semibold before:w-0', 37 ].join(' '); 38 return ` 39 <a 40 href="${escapeHtml(resolveTabPath(tab.key))}" 41 data-dashboard-link 42 data-dashboard-domains-link 43 data-tab-key="${escapeHtml(tab.key)}" 44 ${isActive ? 'aria-current="page"' : ''} 45 class="${tabClassName}" 46 > 47 ${escapeHtml(data.copy.tabs[tab.key])} 48 </a> 49 `; 50 }) 51 .join(''); 52 53const renderedTabsStructure = new WeakMap(); 54 55const getTabsStructureSignature = (data) => 56 JSON.stringify( 57 data.visibleTabs.map((tab) => ({ 58 href: resolveTabPath(tab.key), 59 key: tab.key, 60 label: data.copy.tabs[tab.key], 61 })), 62 ); 63 64export const renderTabs = (root, data, activeTab) => { 65 const nav = root.querySelector(DOMAINS_TABS_NAV_SELECTOR); 66 if (!(nav instanceof HTMLElement)) { 67 return; 68 } 69 70 const structureSignature = getTabsStructureSignature(data); 71 const links = Array.from(nav.querySelectorAll('[data-dashboard-domains-link]')); 72 if (renderedTabsStructure.get(nav) === structureSignature && links.length === data.visibleTabs.length) { 73 links.forEach((link) => { 74 setDashboardClientTabActiveState(link, link.dataset.tabKey === activeTab); 75 }); 76 return; 77 } 78 79 const focusedTabKey = nav.contains(root.ownerDocument.activeElement) 80 ? root.ownerDocument.activeElement?.dataset?.tabKey || '' 81 : ''; 82 setSafeHtml(nav, buildTabsHtml(data, activeTab)); 83 renderedTabsStructure.set(nav, structureSignature); 84 if (focusedTabKey) { 85 Array.from(nav.querySelectorAll('[data-dashboard-domains-link]')) 86 .find((link) => link.dataset.tabKey === focusedTabKey) 87 ?.focus({ preventScroll: true }); 88 } 89}; 90 91const buildNameCellContent = (tabKey, row, display, className = '') => { 92 if (tabKey === 'expired') { 93 return buildReactivationButtonHtml({ key: `expired:${row.id}`, label: display, className }); 94 } 95 const actionMeta = getRowActionMeta(tabKey, row); 96 const classes = [className].filter(Boolean).join(' '); 97 const rowId = normalizeText(row?.id); 98 const rowAttributes = rowId 99 ? `data-dashboard-domains-row-id="${escapeHtml(rowId)}" data-dashboard-domains-row-tab="${escapeHtml(tabKey)}"` 100 : ''; 101 if (actionMeta.type === 'link' && actionMeta.href) { 102 return ` 103 <a 104 href="${escapeHtml(actionMeta.href)}" 105 data-dashboard-link 106 data-dashboard-domains-row-action 107 ${rowAttributes} 108 class="${escapeHtml(classes)}" 109 > 110 ${escapeHtml(display)}${isPremiumDomain(row) ? premiumBadgeHtml() : ''} 111 </a> 112 `; 113 } 114 115 if (actionMeta.type === 'button') { 116 return ` 117 <button 118 type="button" 119 data-dashboard-domains-row-action 120 ${rowAttributes} 121 class="${escapeHtml(classes)}" 122 > 123 ${escapeHtml(display)} 124 </button> 125 `; 126 } 127 128 return `<span class="break-all">${escapeHtml(display)}</span>`;
129}; 130 131const getRowActionMeta = (tabKey, row) => { 132 const rowId = normalizeText(row?.id); 133 134 if (tabKey === 'active' && rowId) { 135 return { 136 type: 'link', 137 href: localizeDashboardPath(`/dashboard/domains/${encodeURIComponent(rowId)}`), 138 }; 139 } 140 141 if (tabKey === 'external' && rowId) { 142 return { 143 type: 'link', 144 href: localizeDashboardPath(`/dashboard/domains/external/${encodeURIComponent(rowId)}`), 145 }; 146 } 147 148 if ((tabKey === 'transfer' || tabKey === 'awaiting-info') && rowId) { 149 return { 150 type: 'button', 151 }; 152 } 153 154 return { 155 type: 'text', 156 }; 157}; 158 159const buildSettingsMenuHtml = (data, uiState, columns) => { 160 const config = TABLE_CONFIG[data.activeTab]; 161 const tableCopy = data.copy.tables[data.activeTab]; 162 163 if (!hasConfigurableColumnOptions(data.activeTab)) { 164 return ''; 165 } 166 167 const columnOptions = config.allColumns 168 .map((column) => { 169 const isSelected = columns.includes(column); 170 const isLocked = column === 'name'; 171 return ` 172 <label class="relative flex cursor-pointer items-center rounded p-2 text-sm font-bold text-gray hover:bg-foreground-light"> 173 <input 174 type="checkbox" 175 ${isSelected ? 'checked' : ''} 176 ${isLocked ? 'disabled' : ''} 177 data-dashboard-domains-column-toggle 178 data-column-name="${escapeHtml(column)}" 179 class="peer absolute top-0 left-0 -z-1 h-5 w-5 opacity-0" 180 /> 181 <span 182 class="border-background mr-2 flex h-5 w-5 shrink-0 items-center justify-center rounded border-2 peer-checked:[&>span]:h-3 peer-checked:[&>span]:w-3" 183 aria-hidden="true" 184 > 185 <span 186 class="bg-green flex h-0 w-0 items-center justify-center overflow-hidden rounded-sm leading-none transition-all text-foreground" 187 > 188 ✓ 189 </span> 190 </span> 191 <span class="flex-1">${escapeHtml(tableCopy.columns[column])}</span> 192 </label> 193 `; 194 }) 195 .join(''); 196 197 return ` 198 <div 199 class="absolute right-0 top-full z-20 mt-2 w-50 rounded-lg border border-stroke-gray bg-foreground text-background p-1 space-y-1 ${uiState.isMenuOpen ? '' : 'hidden'}" 200 data-dashboard-domains-settings-menu 201 > 202 ${columnOptions} 203 </div> 204 `; 205}; 206 207const buildPaginationHtml = (totalRows, uiState, copy) => { 208 const totalPages = getTotalPages(totalRows); 209 if (totalPages <= 1) { 210 return ''; 211 } 212 213 return ` 214 <div class="flex items-center justify-between gap-3 border-t border-stroke-gray px-4 py-4 md:px-6"> 215 <button 216 type="button" 217 data-dashboard-domains-page 218 data-page="${uiState.page - 1}" 219 ${uiState.page <= 1 ? 'disabled' : ''} 220 class="inline-flex items-center rounded-md border border-stroke-gray px-3 py-2 text-sm font-bold text-gray ${uiState.page <= 1 ? 'opacity-50' : 'hover:bg-background'}" 221 > 222 ${escapeHtml(copy.common.prev)} 223 </button> 224 <p class="text-sm text-gray">${escapeHtml(copy.common.page)} ${uiState.page} / ${totalPages}</p> 225 <button 226 type="button" 227 data-dashboard-domains-page 228 data-page="${uiState.page + 1}" 229 ${uiState.page >= totalPages ? 'disabled' : ''} 230 class="inline-flex items-center rounded-md border border-stroke-gray px-3 py-2 text-sm font-bold text-gray ${uiState.page >= totalPages ? 'opacity-50' : 'hover:bg-background'}" 231 > 232 ${escapeHtml(copy.common.next)} 233 </button> 234 </div> 235 `; 236}; 237 238const buildDesktopRows = (data, columns, rows) => 239 rows 240 .map( 241 (row) => ` 242 <tr class="border-t border-stroke-gray align-top"> 243 ${columns 244 .map((column) => { 245 const display = getCellValue(data.activeTab, column, row, data); 246 const isAction = column === 'name' || column === 'domain_name'; 247 const isOn = display === data.copy.common.on; 248 const isOff = display === data.copy.common.off; 249 250 let content = `<span>${escapeHtml(display)}</span>`; 251 if (isAction) { 252 content = buildNameCellContent( 253 data.activeTab, 254 row, 255 display, 256 'text-left font-semibold text-pink underline-offset-2 focus-visible:underline hover:underline', 257 ); 258 } else if (isOn || isOff) { 259 content = `<span class="inline-flex uppercase font-bold ${isOn ? 'text-aqua' : 'text-error'}">${escapeHtml(display)}</span>`;
260 } 261 262 return `<td class="px-6 py-4">${content}</td>`; 263 }) 264 .join('')} 265 </tr> 266 `, 267 ) 268 .join(''); 269 270const buildMobileSortButton = (column, label) => ` 271 <button 272 type="button" 273 data-dashboard-domains-sort-button 274 data-sort-field="${escapeHtml(column)}" 275 class="text-left translate-y-1" 276 > 277 <span>${escapeHtml(label)}</span> 278 </button> 279`; 280 281const buildMobileCellContent = (data, column, row, display) => { 282 const isAction = column === 'name' || column === 'domain_name'; 283 const isOn = display === data.copy.common.on; 284 const isOff = display === data.copy.common.off; 285 286 if (isAction) { 287 return buildNameCellContent(data.activeTab, row, display, 'text-left'); 288 } 289 290 if (isOn || isOff) { 291 return `<span class="font-bold uppercase ${isOn ? 'text-aqua' : 'text-error'}">${escapeHtml(display)}</span>`; 292 } 293 294 return `<span>${escapeHtml(display)}</span>`; 295}; 296 297const buildMobileRows = (data, columns, rows) => { 298 if (!columns.length) { 299 return ''; 300 } 301 302 const tableCopy = data.copy.tables[data.activeTab]; 303 const firstColumn = columns[0]; 304 const firstColumnLabel = tableCopy.columns[firstColumn]; 305 306 return rows 307 .map((row) => { 308 const firstDisplay = getCellValue(data.activeTab, firstColumn, row, data); 309 310 return ` 311 <thead> 312 <tr> 313 <th>${buildMobileSortButton(firstColumn, firstColumnLabel)}</th> 314 <th>${buildMobileCellContent(data, firstColumn, row, firstDisplay)}</th> 315 </tr> 316 </thead> 317 <tbody> 318 ${columns 319 .slice(1) 320 .map((column) => { 321 const label = tableCopy.columns[column]; 322 const display = getCellValue(data.activeTab, column, row, data); 323 324 return ` 325 <tr> 326 <th>${buildMobileSortButton(column, label)}</th> 327 <td>${buildMobileCellContent(data, column, row, display)}</td> 328 </tr> 329 `; 330 }) 331 .join('')} 332 </tbody> 333 `; 334 }) 335 .join(''); 336}; 337 338const buildEmptyStateHtml = (data) => { 339 const emptyCopy = data.activeTab === DEFAULT_TAB ? data.copy.empty.active : data.copy.empty.default; 340 return ` 341 <div class="mx-auto my-10 xs:w-4/5 xs:flex flex-wrap items-center justify-between space-y-4 xs:space-y-0"> 342 <div class="flex-1 xs:min-w-78 xs:m-4"> 343 <h3 class="font-uni-neue text-3xl mb-7 font-normal">${escapeHtml(emptyCopy.title)}</h3> 344 <p class="mb-7">${escapeHtml(emptyCopy.text)}</p> 345 ${ 346 data.activeTab === DEFAULT_TAB 347 ? `<button type="button" class="shadow-button inline-flex h-min items-center justify-center border border-solid font-semibold outline-hidden transition-colors duration-300 focus:z-20 focus:outline-hidden focus-visible:outline-hidden px-5 py-2 border-yellow bg-yellow shadow-yellow/20 hover:bg-yellow-light focus-visible:bg-yellow-light focus-visible:ring-yellow/20 focus-visible:ring-4 rounded-md xs:min-w-60"> 348 ${escapeHtml(emptyCopy.button)} 349 </button>` 350 : '' 351 } 352 </div> 353 <div class="flex-1 xs:min-w-50 xs:m-4"> 354 <img src="/assets/images/dashboard/buy-domains.jpg" alt="" /> 355 </div> 356 </div> 357 `; 358}; 359 360const getStateCardVisuals = (tabKey) => { 361 const config = TABLE_CONFIG[tabKey]; 362 return { 363 color: config?.color, 364 icon: config?.icon, 365 }; 366}; 367 368const buildStateCardHeaderHtml = (data, trailingContent = '') => { 369 const tableCopy = data.copy.tables[data.activeTab]; 370 const visuals = getStateCardVisuals(data.activeTab); 371 372 return ` 373 <div class="flex items-center p-6"> 374 <div class="mr-4 flex h-12 w-12 items-center justify-center rounded-full ${escapeHtml(visuals.color)} p-2 md:h-18 md:w-18 md:p-4"> 375 <span class="${escapeHtml(visuals.icon)} text-lg md:text-2xl"></span> 376 </div> 377 <div class="flex-1"> 378 <h4 class="font-uni-neue mb-2 font-bold md:text-xl">${escapeHtml(tableCopy.title)}</h4> 379 <p class="dashboard-card-subtitle">${escapeHtml(tableCopy.description)}</p> 380 </div> 381 ${trailingContent} 382 </div> 383 `; 384}; 385 386const buildStateCardHtml = (data, bodyHtml) => { 387 return ` 388 <section class="rounded-xl border border-stroke-gray bg-white shadow-bottom shadow-black/[0.03] relative"> 389 ${buildStateCardHeaderHtml(data)} 390 <div class="px-6 pb-6">${bodyHtml}</div> 391 </section> 392 `; 393}; 394 395const buildDomainsLoadingStateHtml = () => ` 396 <div class="flex min-h-40 items-center justify-center" data-dashboard-domains-loading-dots></div> 397`; 398 399const buildTransferredAwayEmptyStateHtml = (data) => { 400 const emptyCopy = data.copy.empty.default; 401 402 return buildStateCardHtml(data, `<p class="text-sm text-gray">${escapeHtml(emptyCopy.title)}</p>`); 403}; 404 405const buildErrorStateHtml = (data) => 406 buildStateCardHtml( 407 data, 408 ` 409 <div class="rounded-lg border border-dashed border-stroke-gray bg-background p-6 text-center"> 410 <p class="text-sm text-gray">${escapeHtml(data.copy.common.requestError)}</p> 411 <button 412 type="button" 413 data-dashboard-domains-retry 414 class="mt-4 inline-flex items-center rounded-md bg-foreground px-4 py-3 text-sm font-bold text-white" 415 > 416 ${escapeHtml(data.copy.common.retry)} 417 </button> 418 </div> 419 `, 420 ); 421 422const mountLoadingDots = (panel) => { 423 const dotsSlot = panel.querySelector('[data-dashboard-domains-loading-dots]'); 424 if (!(dotsSlot instanceof HTMLElement)) { 425 return; 426 } 427 428 dotsSlot.replaceChildren( 429 initLoadingDots({ 430 className: 'text-foreground', 431 size: 16, 432 }), 433 ); 434}; 435 436export const renderOverviewPanel = (root, data) => { 437 const panel = root.querySelector(DOMAINS_PANEL_SELECTOR); 438 if (!(panel instanceof HTMLElement) || !data) { 439 return; 440 } 441 442 if (data.activeTab !== TRANSFERRED_AWAY_TAB_KEY && data.isStreamPendingInitialLoad) { 443 setSafeHtml(panel, buildDomainsLoadingStateHtml()); 444 mountLoadingDots(panel); 445 return; 446 } 447 448 if ( 449 (data.activeTab === DEFAULT_TAB || data.activeTab === 'expired') && 450 getCurrentRows(data).length > 0 && 451 (data.domainContacts?.status === 'idle' || data.domainContacts?.status === 'loading') 452 ) { 453 setSafeHtml(panel, buildDomainsLoadingStateHtml()); 454 mountLoadingDots(panel); 455 return; 456 } 457 458 if ( 459 data.activeTab === TRANSFERRED_AWAY_TAB_KEY && 460 (data.transferredAway?.status === 'idle' || data.transferredAway?.status === 'loading') 461 ) { 462 setSafeHtml(panel, buildDomainsLoadingStateHtml()); 463 mountLoadingDots(panel); 464 return; 465 } 466 467 if (data.activeTab === TRANSFERRED_AWAY_TAB_KEY && data.transferredAway?.status === 'error') { 468 setSafeHtml(panel, buildErrorStateHtml(data)); 469 return; 470 } 471 472 const uiState = getUiState(root, data); 473 const columns = getColumns(data, uiState); 474 const sortedRows = sortRows(getCurrentRows(data), uiState.sortField, uiState.sortDirection, data, data.activeTab); 475 const totalPages = getTotalPages(sortedRows.length); 476 if (uiState.page > totalPages) { 477 uiState.page = totalPages; 478 } 479 const pageRows = getPageRows(sortedRows, uiState.page); 480 const tableCopy = data.copy.tables[data.activeTab]; 481 const isMobile = window.matchMedia(MOBILE_MEDIA_QUERY).matches; 482 483 if (!sortedRows.length) { 484 if (data.activeTab === TRANSFERRED_AWAY_TAB_KEY) { 485 setSafeHtml(panel, buildTransferredAwayEmptyStateHtml(data)); 486 return; 487 } 488 489 setSafeHtml(panel, buildEmptyStateHtml(data)); 490 return; 491 } 492 493 const settingsMenu = buildSettingsMenuHtml(data, uiState, columns);
494 const settingsControls = hasConfigurableColumnOptions(data.activeTab) 495 ? ` 496 <div class="absolute right-4 top-4"> 497 <button data-dashboard-domains-settings-toggle aria-label="${escapeHtml(data.copy.common.settings)}"> 498 <span class="icon-three-dots"></span> 499 </button> 500 ${settingsMenu} 501 </div> 502 ` 503 : ''; 504 const headerButtons = columns 505 .map((column) => { 506 const sortState = getHeaderSortState(uiState, column); 507 const sortAttribute = sortState === 'none' ? '' : ` aria-sort="${sortState}"`; 508 const caretClassName = [ 509 'icon-caret text-[10px] transition-transform duration-200', 510 sortState === 'descending' ? '' : 'rotate-180', 511 sortState === 'none' ? 'opacity-0' : 'opacity-100', 512 ] 513 .filter(Boolean) 514 .join(' '); 515 516 return ` 517 <th${sortAttribute} class="px-6 py-1 font-overpass text-left text-sm font-bold"> 518 <button 519 type="button" 520 data-dashboard-domains-sort-button 521 data-sort-field="${escapeHtml(column)}" 522 class="inline-flex items-center gap-1 uppercase transition-colors focus:text-pink focus-visible:text-pink hover:text-pink group text-left" 523 > 524 <span class="group-hover:underline">${escapeHtml(tableCopy.columns[column])}</span> 525 <span class="${caretClassName}" aria-hidden="true"></span> 526 </button> 527 </th> 528 `; 529 }) 530 .join(''); 531 532 setSafeHtml( 533 panel, 534 ` 535 <section class="rounded-xl border border-stroke-gray bg-white shadow-bottom shadow-black/[0.03] relative"> 536 ${buildStateCardHeaderHtml(data, settingsControls)} 537 ${ 538 isMobile 539 ? `<table class="dashboard-domains-mobile-table">${buildMobileRows(data, columns, pageRows)}</table>` 540 : `<div class="overflow-x-auto"> 541 <table class="min-w-full border-collapse"> 542 <thead class="bg-background"> 543 <tr>${headerButtons}</tr> 544 </thead> 545 <tbody>${buildDesktopRows(data, columns, pageRows)}</tbody> 546 </table> 547 </div>` 548 } 549 ${buildPaginationHtml(sortedRows.length, uiState, data.copy)} 550 </section> 551 `, 552 ); 553};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.