import * as utils from "@helpers"; import { msg } from "@lit/localize"; import { Task } from "@lit/task"; import { DSPContractStorage, type StoredContract, } from "@startinblox/solid-tems-shared"; import { css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; /** * Contract state display configuration */ const CONTRACT_STATE_CONFIG: Record< string, { label: string; color: string; icon: string } > = { REQUESTED: { label: "Requested", color: "#ff9800", icon: "schedule" }, OFFERED: { label: "Offered", color: "#2196f3", icon: "schedule" }, AGREED: { label: "Agreed", color: "#4caf50", icon: "verified" }, VERIFIED: { label: "Verified", color: "#4caf50", icon: "verified" }, FINALIZED: { label: "Active", color: "#4caf50", icon: "verified" }, TERMINATED: { label: "Terminated", color: "#9e9e9e", icon: "error" }, FAILED: { label: "Failed", color: "#f44336", icon: "error" }, }; @customElement("solid-tems-contracts-catalog") export class TemsContractsCatalog extends utils.OrbitComponent { static styles = css` :host { display: block; } .contracts-container { padding: 1rem; } .contracts-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; } .contracts-stats { display: flex; gap: 1rem; } .stat-badge { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; background: #f5f5f5; border-radius: 20px; font-size: 0.875rem; } .stat-badge.active { background: #e8f5e9; color: #2e7d32; } .contracts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; } .contract-card { background: white; border-radius: 12px; padding: 1.25rem; border: 1px solid #e0e0e0; cursor: pointer; transition: all 0.2s ease; } .contract-card:hover { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); transform: translateY(-2px); } .contract-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem; } .contract-card-title { font-weight: 600; font-size: 1rem; color: #333; flex: 1; margin-right: 0.5rem; } .contract-state-badge { display: flex; align-items: center; gap: 0.25rem; padding: 0.25rem 0.75rem; border-radius: 12px; font-size: 0.75rem; font-weight: 500; white-space: nowrap; } .contract-card-provider { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; font-size: 0.875rem; color: #666; } .provider-dot { width: 10px; height: 10px; border-radius: 50%; } .contract-card-description { font-size: 0.875rem; color: #666; margin-bottom: 0.75rem; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .contract-card-meta { display: flex; justify-content: space-between; font-size: 0.75rem; color: #999; padding-top: 0.75rem; border-top: 1px solid #f0f0f0; } .no-contracts { text-align: center; padding: 4rem 2rem; background: #fafafa; border-radius: 12px; color: #666; } .no-contracts icon-mdi-handshake-outline { font-size: 4rem; color: #ccc; margin-bottom: 1rem; } .no-contracts h3 { margin: 0 0 0.5rem 0; color: #333; } .no-contracts p { margin: 0; font-size: 0.875rem; } /* Modal styles */ .modal-backdrop { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 2, 49, 0.4); z-index: 9999; display: flex; justify-content: center; align-items: center; padding: 2rem; } .modal-content { background: white; border-radius: 16px; max-width: 700px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); } .modal-header { display: flex; justify-content: space-between; align-items: flex-start; padding: 1.5rem; border-bottom: 1px solid #e0e0e0; } .modal-header-content { flex: 1; } .modal-title { font-size: 1.25rem; font-weight: 600; color: #333; margin: 0 0 0.5rem 0; } .modal-subtitle { font-size: 0.875rem; color: #666; } .modal-close { background: none; border: none; cursor: pointer; padding: 0.5rem; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: background 0.2s; } .modal-close:hover { background: #f5f5f5; } .modal-close icon-material-symbols-close-rounded { font-size: 1.5rem; color: #666; } .modal-body { padding: 1.5rem; } .modal-section { margin-bottom: 1.5rem; } .modal-section:last-child { margin-bottom: 0; } .modal-section-title { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; font-weight: 600; color: #333; margin-bottom: 1rem; text-transform: uppercase; letter-spacing: 0.5px; } .modal-section-title icon-mdi-domain, .modal-section-title icon-mdi-shield-check-outline, .modal-section-title icon-mdi-file-document-outline, .modal-section-title icon-mdi-handshake-outline { font-size: 1.25rem; color: #1976d2; } .detail-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; } .detail-item { background: #f8f9fa; padding: 0.75rem 1rem; border-radius: 8px; } .detail-item.full-width { grid-column: 1 / -1; } .detail-label { font-size: 0.75rem; color: #666; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 0.25rem; } .detail-value { font-size: 0.9rem; color: #333; word-break: break-all; } .detail-value.monospace { font-family: "Courier New", monospace; font-size: 0.8rem; } .policy-content { background: #f8f9fa; padding: 1rem; border-radius: 8px; overflow-x: auto; } .policy-content pre { margin: 0; font-size: 0.75rem; font-family: "Courier New", monospace; white-space: pre-wrap; word-break: break-all; } .status-timeline { display: flex; flex-direction: column; gap: 0.5rem; } .timeline-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; background: #f8f9fa; border-radius: 8px; } .timeline-dot { width: 8px; height: 8px; border-radius: 50%; } .timeline-label { flex: 1; font-size: 0.875rem; } .timeline-date { font-size: 0.75rem; color: #666; } .modal-footer { display: flex; justify-content: flex-end; gap: 0.75rem; padding: 1rem 1.5rem; border-top: 1px solid #e0e0e0; background: #fafafa; } .modal-btn { display: flex; align-items: center; gap: 0.5rem; padding: 0.625rem 1.25rem; border: none; border-radius: 8px; font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: all 0.2s; } .modal-btn-secondary { background: white; color: #666; border: 1px solid #e0e0e0; } .modal-btn-secondary:hover { background: #f5f5f5; border-color: #bdbdbd; } .modal-btn-danger { background: #ffebee; color: #c62828; border: 1px solid #ffcdd2; } .modal-btn-danger:hover { background: #ffcdd2; } .modal-btn-primary { background: #1976d2; color: white; } .modal-btn-primary:hover { background: #1565c0; } `; @property({ attribute: "header", type: String }) header?: string = "Contracts"; @state() contracts: StoredContract[] = []; @state() selectedContract: StoredContract | null = null; /** * Load contracts from DSPContractStorage */ _loadContracts = new Task(this, { task: async (): Promise => { if (!this.orbit) return []; // Get all contracts const allContracts = DSPContractStorage.getAll(); this.contracts = allContracts; return allContracts; }, args: () => [this.currentRoute], }); /** * Format date for display */ private _formatDate(dateString?: string): string { if (!dateString) return "-"; try { const date = new Date(dateString); return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } catch { return dateString; } } /** * Get state configuration */ private _getStateConfig(state: string) { return CONTRACT_STATE_CONFIG[state] || CONTRACT_STATE_CONFIG.REQUESTED; } /** * Handle card click - open modal */ private _onCardClick(contract: StoredContract) { this.selectedContract = contract; } /** * Close modal */ private _closeModal(e?: Event) { if (e) { e.preventDefault(); e.stopPropagation(); } this.selectedContract = null; } /** * Close modal when clicking backdrop */ private _onBackdropClick(e: Event) { if ((e.target as HTMLElement).classList.contains("modal-backdrop")) { this._closeModal(); } } /** * Delete a contract */ private _deleteContract(contract: StoredContract, e?: Event) { if (e) { e.preventDefault(); e.stopPropagation(); } if ( confirm( msg( `Are you sure you want to delete the contract for "${contract.assetName}"?`, ), ) ) { DSPContractStorage.delete(contract.id); this.selectedContract = null; // Refresh the list this._loadContracts.run(); } } /** * Clear all contracts */ private _clearAllContracts(e?: Event) { if (e) { e.preventDefault(); e.stopPropagation(); } if ( confirm( msg( "Are you sure you want to delete ALL contracts? This cannot be undone.", ), ) ) { DSPContractStorage.clear(); // Also clear federated indexes cache localStorage.removeItem("tems_federated_indexes"); // Clear modal-specific agreement entries (keys starting with "dsp-agreement-") const keysToRemove: string[] = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key?.startsWith("dsp-agreement-")) { keysToRemove.push(key); } } for (const key of keysToRemove) { localStorage.removeItem(key); } this.selectedContract = null; // Refresh the list this._loadContracts.run(); } } /** * Renegotiate a contract - deletes current contract and navigates to DSP catalog */ private _renegotiateContract(contract: StoredContract, e?: Event) { if (e) { e.preventDefault(); e.stopPropagation(); } if ( confirm( msg( `This will delete the current contract for "${contract.assetName}" and let you renegotiate. Continue?`, ), ) ) { // Store asset info for navigation const assetId = contract.assetId; // Delete the contract DSPContractStorage.delete(contract.id); this.selectedContract = null; // Try to navigate to DSP catalog with the asset // Look for a DSP catalog component in orbit const dspCatalogComponent = this.orbit?.components?.find( (c: any) => c.type === "solid-dsp-catalog", ); if (dspCatalogComponent?.route) { utils.requestNavigation(dspCatalogComponent.route, assetId); } else { // Just refresh the contracts list if we can't navigate this._loadContracts.run(); } } } /** * Render state badge */ private _renderStateBadge(state: string) { const config = this._getStateConfig(state); return html` ${config.label} `; } /** * Render contract card */ private _renderContractCard(contract: StoredContract) { this._getStateConfig(contract.state); return html`
this._onCardClick(contract)}>
${contract.assetName} ${this._renderStateBadge(contract.state)}
${contract.providerName}
${contract.assetDescription ? html`
${contract.assetDescription}
` : nothing}
${msg("Created")}: ${this._formatDate(contract.createdAt)} ${contract.finalizedAt ? html`${msg("Finalized")}: ${this._formatDate(contract.finalizedAt)}` : nothing}
`; } /** * Render contract details modal */ private _renderModal() { if (!this.selectedContract) return nothing; const contract = this.selectedContract; this._getStateConfig(contract.state); return html` `; } /** * Render no contracts state */ private _renderNoContracts() { return html`

${msg("No contracts yet")}

${msg( "Negotiate access to datasets in the Consumer Catalog to create contracts.", )}

`; } /** * Render contracts stats */ private _renderStats(contracts: StoredContract[]) { const activeCount = contracts.filter((c) => c.state === "FINALIZED").length; const totalCount = contracts.length; return html`
${totalCount} ${msg("Total")}
${activeCount} ${msg("Active")}
`; } render() { if (!this.orbit) { return nothing; } return this._loadContracts.render({ pending: () => html``, complete: (contracts: StoredContract[]) => html`
${contracts && contracts.length > 0 ? html`
${this._renderStats(contracts)}
${contracts.map((contract) => this._renderContractCard(contract), )}
` : this._renderNoContracts()}
${this._renderModal()} `, }); } }