feat(workspace): introduce a responsive signature workspace demo and remove legacy contract editor components

This commit is contained in:
2026-05-02 18:37:48 +00:00
parent 90836f1c72
commit 57cbb739d2
48 changed files with 4387 additions and 13348 deletions
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@signature.digital/catalog',
version: '1.2.0',
version: '1.3.0',
description: 'A comprehensive catalog of customizable web components designed for building and managing e-signature applications.'
}
+3 -15
View File
@@ -1,18 +1,6 @@
// Contract Editor (main module)
export * from './sdig-contracteditor/index.js';
// Contract sub-components
export * from './sdig-contract-header/index.js';
export * from './sdig-contract-metadata/index.js';
export * from './sdig-contract-parties/index.js';
export * from './sdig-contract-content/index.js';
export * from './sdig-contract-terms/index.js';
export * from './sdig-contract-signatures/index.js';
export * from './sdig-contract-attachments/index.js';
export * from './sdig-contract-collaboration/index.js';
export * from './sdig-contract-audit/index.js';
export * from './sdig-collaboration-sidebar/index.js';
// Signature components
export * from './sdig-signbox/index.js';
export * from './sdig-signpad/index.js';
// Product workspace component
export * from './sdig-workspace/index.js';
@@ -1 +0,0 @@
export * from './sdig-collaboration-sidebar.js';
@@ -1,846 +0,0 @@
/**
* @file sdig-collaboration-sidebar.ts
* @description Compact collaboration sidebar for the contract editor
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-collaboration-sidebar': SdigCollaborationSidebar;
}
}
// Comment interface
interface IComment {
id: string;
userId: string;
userName: string;
userColor: string;
content: string;
createdAt: number;
updatedAt?: number;
anchorPath?: string;
anchorText?: string;
resolved: boolean;
replies: IComment[];
}
// Suggestion interface
interface ISuggestion {
id: string;
userId: string;
userName: string;
userColor: string;
originalText: string;
suggestedText: string;
path: string;
status: 'pending' | 'accepted' | 'rejected';
createdAt: number;
}
// Presence interface
interface IPresence {
userId: string;
userName: string;
userColor: string;
currentSection: string;
cursorPosition?: { path: string; offset: number };
lastActive: number;
}
@customElement('sdig-collaboration-sidebar')
export class SdigCollaborationSidebar extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<div style="width: 320px; height: 600px; border: 1px solid #ccc;">
<sdig-collaboration-sidebar
.contract=${plugins.sdDemodata.demoContract}
></sdig-collaboration-sidebar>
</div>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
height: 100%;
overflow: hidden;
}
.sidebar-container {
display: flex;
flex-direction: column;
height: 100%;
}
/* Presence bar */
.presence-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.presence-avatars {
display: flex;
align-items: center;
}
.presence-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: white;
margin-left: -6px;
border: 2px solid ${cssManager.bdTheme('#f9fafb', '#111111')};
cursor: pointer;
position: relative;
}
.presence-avatar:first-child {
margin-left: 0;
}
.presence-avatar .status-dot {
position: absolute;
bottom: -1px;
right: -1px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
border: 2px solid ${cssManager.bdTheme('#f9fafb', '#111111')};
}
.presence-avatar .status-dot.away {
background: #f59e0b;
}
.presence-count {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-size: 11px;
font-weight: 600;
margin-left: -6px;
border: 2px solid ${cssManager.bdTheme('#f9fafb', '#111111')};
}
.presence-label {
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
/* Scrollable content */
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
/* Collapsible sections */
.collapsible-section {
margin-bottom: 12px;
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 8px;
overflow: hidden;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
cursor: pointer;
user-select: none;
transition: background 0.15s ease;
}
.section-header:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.section-title dees-icon {
font-size: 14px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-badge {
padding: 2px 6px;
border-radius: 9999px;
font-size: 10px;
font-weight: 600;
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
}
.section-chevron {
font-size: 14px;
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
transition: transform 0.2s ease;
}
.section-chevron.expanded {
transform: rotate(180deg);
}
.section-body {
padding: 0;
max-height: 0;
overflow: hidden;
transition: max-height 0.2s ease, padding 0.2s ease;
}
.section-body.expanded {
padding: 12px;
max-height: 1000px;
}
/* Compact comment cards */
.comment-card {
display: flex;
gap: 10px;
padding: 10px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-radius: 6px;
margin-bottom: 8px;
cursor: pointer;
transition: background 0.15s ease;
}
.comment-card:last-child {
margin-bottom: 0;
}
.comment-card:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
}
.comment-card.resolved {
opacity: 0.6;
}
.comment-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
color: white;
flex-shrink: 0;
}
.comment-body {
flex: 1;
min-width: 0;
}
.comment-meta {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.comment-author {
font-size: 12px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.comment-time {
font-size: 10px;
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.comment-preview {
font-size: 12px;
line-height: 1.4;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.comment-replies {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
margin-top: 4px;
}
.comment-replies dees-icon {
font-size: 12px;
}
/* Suggestion cards */
.suggestion-card {
padding: 10px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-radius: 6px;
margin-bottom: 8px;
cursor: pointer;
transition: background 0.15s ease;
}
.suggestion-card:last-child {
margin-bottom: 0;
}
.suggestion-card:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
}
.suggestion-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.suggestion-user {
display: flex;
align-items: center;
gap: 6px;
}
.suggestion-status {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 9999px;
font-size: 10px;
font-weight: 500;
}
.suggestion-status dees-icon {
font-size: 10px;
}
.suggestion-status.pending {
background: ${cssManager.bdTheme('#fef3c7', '#422006')};
color: ${cssManager.bdTheme('#92400e', '#fcd34d')};
}
.suggestion-status.accepted {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#065f46', '#6ee7b7')};
}
.suggestion-status.rejected {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
}
.suggestion-diff {
font-family: 'Roboto Mono', monospace;
font-size: 11px;
line-height: 1.4;
}
.diff-removed {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
text-decoration: line-through;
padding: 1px 3px;
border-radius: 2px;
}
.diff-added {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#065f46', '#6ee7b7')};
padding: 1px 3px;
border-radius: 2px;
}
/* Quick add comment */
.quick-add {
padding: 12px;
border-top: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
}
.quick-add-input {
width: 100%;
padding: 10px 12px;
font-size: 13px;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 6px;
outline: none;
resize: none;
min-height: 60px;
font-family: inherit;
box-sizing: border-box;
}
.quick-add-input:focus {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(96, 165, 250, 0.1)')};
}
.quick-add-input::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.quick-add-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 16px;
text-align: center;
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.empty-state dees-icon {
font-size: 32px;
margin-bottom: 8px;
opacity: 0.5;
}
.empty-state p {
margin: 0;
font-size: 12px;
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor commentsExpanded: boolean = true;
@state()
private accessor suggestionsExpanded: boolean = true;
@state()
private accessor newCommentText: string = '';
// Demo presence data
@state()
private accessor presenceList: IPresence[] = [
{ userId: '1', userName: 'Alice Smith', userColor: '#3b82f6', currentSection: 'content', lastActive: Date.now() },
{ userId: '2', userName: 'Bob Johnson', userColor: '#10b981', currentSection: 'parties', lastActive: Date.now() - 60000 },
{ userId: '3', userName: 'Carol Davis', userColor: '#f59e0b', currentSection: 'terms', lastActive: Date.now() - 300000 },
];
// Demo comments data
@state()
private accessor comments: IComment[] = [
{
id: '1',
userId: '1',
userName: 'Alice Smith',
userColor: '#3b82f6',
content: 'Can we clarify the payment terms in paragraph 3? The current wording seems ambiguous.',
createdAt: Date.now() - 3600000,
anchorPath: 'paragraphs.2',
anchorText: 'Compensation',
resolved: false,
replies: [
{
id: '1-1',
userId: '2',
userName: 'Bob Johnson',
userColor: '#10b981',
content: 'Good point. I\'ll update the wording to be more specific.',
createdAt: Date.now() - 1800000,
resolved: false,
replies: [],
},
],
},
{
id: '2',
userId: '3',
userName: 'Carol Davis',
userColor: '#f59e0b',
content: 'The termination clause needs to comply with the latest regulations.',
createdAt: Date.now() - 86400000,
resolved: true,
replies: [],
},
{
id: '3',
userId: '2',
userName: 'Bob Johnson',
userColor: '#10b981',
content: 'Should we add an automatic renewal clause?',
createdAt: Date.now() - 7200000,
resolved: false,
replies: [],
},
];
// Demo suggestions data
@state()
private accessor suggestions: ISuggestion[] = [
{
id: '1',
userId: '1',
userName: 'Alice Smith',
userColor: '#3b82f6',
originalText: 'monthly salary',
suggestedText: 'monthly gross salary',
path: 'paragraphs.2.content',
status: 'pending',
createdAt: Date.now() - 7200000,
},
{
id: '2',
userId: '3',
userName: 'Carol Davis',
userColor: '#f59e0b',
originalText: '30 days',
suggestedText: '60 days',
path: 'paragraphs.5.content',
status: 'pending',
createdAt: Date.now() - 3600000,
},
];
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleCommentClick(comment: IComment) {
this.dispatchEvent(
new CustomEvent('comment-click', {
detail: { comment },
bubbles: true,
composed: true,
})
);
}
private handleSuggestionClick(suggestion: ISuggestion) {
this.dispatchEvent(
new CustomEvent('suggestion-click', {
detail: { suggestion },
bubbles: true,
composed: true,
})
);
}
private handleAddComment() {
if (!this.newCommentText.trim()) return;
const newComment: IComment = {
id: `comment-${Date.now()}`,
userId: 'current-user',
userName: 'You',
userColor: '#6366f1',
content: this.newCommentText,
createdAt: Date.now(),
resolved: false,
replies: [],
};
this.comments = [newComment, ...this.comments];
this.newCommentText = '';
this.dispatchEvent(
new CustomEvent('add-comment', {
detail: { comment: newComment },
bubbles: true,
composed: true,
})
);
}
// ============================================================================
// HELPERS
// ============================================================================
private formatTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
private getActivePresence(): IPresence[] {
const fiveMinutesAgo = Date.now() - 300000;
return this.presenceList.filter((p) => p.lastActive > fiveMinutesAgo);
}
private getOpenComments(): IComment[] {
return this.comments.filter((c) => !c.resolved);
}
private getPendingSuggestions(): ISuggestion[] {
return this.suggestions.filter((s) => s.status === 'pending');
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
const activePresence = this.getActivePresence();
const openComments = this.getOpenComments();
const pendingSuggestions = this.getPendingSuggestions();
return html`
<div class="sidebar-container">
<!-- Presence Bar -->
<div class="presence-bar">
<div class="presence-avatars">
${activePresence.slice(0, 3).map(
(p) => html`
<div
class="presence-avatar"
style="background: ${p.userColor}"
title="${p.userName} - ${p.currentSection}"
>
${p.userName.charAt(0)}
<span class="status-dot ${Date.now() - p.lastActive > 60000 ? 'away' : ''}"></span>
</div>
`
)}
${activePresence.length > 3
? html`<div class="presence-count">+${activePresence.length - 3}</div>`
: ''}
</div>
<span class="presence-label">${activePresence.length} active</span>
</div>
<!-- Scrollable Content -->
<div class="sidebar-content">
<!-- Comments Section -->
<div class="collapsible-section">
<div
class="section-header"
@click=${() => (this.commentsExpanded = !this.commentsExpanded)}
>
<div class="section-title">
<dees-icon .icon=${'lucide:MessageCircle'}></dees-icon>
Comments
${openComments.length > 0
? html`<span class="section-badge">${openComments.length}</span>`
: ''}
</div>
<dees-icon
class="section-chevron ${this.commentsExpanded ? 'expanded' : ''}"
.icon=${'lucide:ChevronDown'}
></dees-icon>
</div>
<div class="section-body ${this.commentsExpanded ? 'expanded' : ''}">
${this.comments.length > 0
? this.comments.slice(0, 5).map((comment) => this.renderCommentCard(comment))
: html`
<div class="empty-state">
<dees-icon .icon=${'lucide:MessageSquare'}></dees-icon>
<p>No comments yet</p>
</div>
`}
</div>
</div>
<!-- Suggestions Section -->
<div class="collapsible-section">
<div
class="section-header"
@click=${() => (this.suggestionsExpanded = !this.suggestionsExpanded)}
>
<div class="section-title">
<dees-icon .icon=${'lucide:GitPullRequest'}></dees-icon>
Suggestions
${pendingSuggestions.length > 0
? html`<span class="section-badge">${pendingSuggestions.length}</span>`
: ''}
</div>
<dees-icon
class="section-chevron ${this.suggestionsExpanded ? 'expanded' : ''}"
.icon=${'lucide:ChevronDown'}
></dees-icon>
</div>
<div class="section-body ${this.suggestionsExpanded ? 'expanded' : ''}">
${this.suggestions.length > 0
? this.suggestions.slice(0, 5).map((suggestion) => this.renderSuggestionCard(suggestion))
: html`
<div class="empty-state">
<dees-icon .icon=${'lucide:Edit3'}></dees-icon>
<p>No suggestions yet</p>
</div>
`}
</div>
</div>
</div>
<!-- Quick Add Comment -->
${!this.readonly
? html`
<div class="quick-add">
<textarea
class="quick-add-input"
placeholder="Add a comment..."
.value=${this.newCommentText}
@input=${(e: Event) => (this.newCommentText = (e.target as HTMLTextAreaElement).value)}
></textarea>
<div class="quick-add-actions">
<button
class="btn btn-primary"
@click=${this.handleAddComment}
?disabled=${!this.newCommentText.trim()}
>
<dees-icon .icon=${'lucide:Send'}></dees-icon>
Add
</button>
</div>
</div>
`
: ''}
</div>
`;
}
private renderCommentCard(comment: IComment): TemplateResult {
return html`
<div
class="comment-card ${comment.resolved ? 'resolved' : ''}"
@click=${() => this.handleCommentClick(comment)}
>
<div class="comment-avatar" style="background: ${comment.userColor}">
${comment.userName.charAt(0)}
</div>
<div class="comment-body">
<div class="comment-meta">
<span class="comment-author">${comment.userName}</span>
<span class="comment-time">${this.formatTimeAgo(comment.createdAt)}</span>
</div>
<div class="comment-preview">${comment.content}</div>
${comment.replies.length > 0
? html`
<div class="comment-replies">
<dees-icon .icon=${'lucide:MessageSquare'}></dees-icon>
${comment.replies.length} ${comment.replies.length === 1 ? 'reply' : 'replies'}
</div>
`
: ''}
</div>
</div>
`;
}
private renderSuggestionCard(suggestion: ISuggestion): TemplateResult {
return html`
<div class="suggestion-card" @click=${() => this.handleSuggestionClick(suggestion)}>
<div class="suggestion-header">
<div class="suggestion-user">
<div class="comment-avatar" style="background: ${suggestion.userColor}; width: 20px; height: 20px; font-size: 9px;">
${suggestion.userName.charAt(0)}
</div>
<span class="comment-author">${suggestion.userName}</span>
</div>
<div class="suggestion-status ${suggestion.status}">
<dees-icon .icon=${suggestion.status === 'pending' ? 'lucide:Clock' : suggestion.status === 'accepted' ? 'lucide:Check' : 'lucide:X'}></dees-icon>
${suggestion.status}
</div>
</div>
<div class="suggestion-diff">
<span class="diff-removed">${suggestion.originalText}</span>
<span> → </span>
<span class="diff-added">${suggestion.suggestedText}</span>
</div>
</div>
`;
}
}
@@ -1 +0,0 @@
export * from './sdig-contract-attachments.js';
@@ -1,806 +0,0 @@
/**
* @file sdig-contract-attachments.ts
* @description Contract attachments and prior contracts manager
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-attachments': SdigContractAttachments;
}
}
// Attachment interface
interface IAttachment {
id: string;
name: string;
type: 'document' | 'image' | 'spreadsheet' | 'pdf' | 'other';
mimeType: string;
size: number;
uploadedAt: number;
uploadedBy: string;
description?: string;
url?: string;
}
// File type configuration
const FILE_TYPES = {
document: { icon: 'lucide:FileText', color: '#3b82f6', label: 'Document' },
image: { icon: 'lucide:Image', color: '#10b981', label: 'Image' },
spreadsheet: { icon: 'lucide:Sheet', color: '#22c55e', label: 'Spreadsheet' },
pdf: { icon: 'lucide:FileType', color: '#ef4444', label: 'PDF' },
other: { icon: 'lucide:File', color: '#6b7280', label: 'File' },
};
@customElement('sdig-contract-attachments')
export class SdigContractAttachments extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-attachments
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-attachments>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.attachments-container {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Section card */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.section-title dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-count {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-content {
padding: 20px;
}
/* Upload zone */
.upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
border: 2px dashed ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 12px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
cursor: pointer;
transition: all 0.15s ease;
}
.upload-zone:hover {
border-color: ${cssManager.bdTheme('#9ca3af', '#52525b')};
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
}
.upload-zone.dragging {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
}
.upload-zone-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 16px;
}
.upload-zone-title {
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 8px;
}
.upload-zone-subtitle {
font-size: 14px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 16px;
}
.upload-zone-hint {
font-size: 12px;
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Attachments list */
.attachments-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.attachment-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
transition: all 0.15s ease;
}
.attachment-item:hover {
border-color: ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
}
.attachment-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.attachment-info {
flex: 1;
min-width: 0;
}
.attachment-name {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attachment-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.attachment-meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.attachment-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
/* Prior contracts */
.prior-contracts-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.prior-contract-item {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
cursor: pointer;
transition: all 0.15s ease;
}
.prior-contract-item:hover {
border-color: ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
}
.prior-contract-icon {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
flex-shrink: 0;
}
.prior-contract-info {
flex: 1;
min-width: 0;
}
.prior-contract-title {
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 4px;
}
.prior-contract-context {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.prior-contract-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.empty-state p {
margin: 0;
font-size: 14px;
}
/* Storage summary */
.storage-summary {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
margin-bottom: 20px;
}
.storage-info {
flex: 1;
}
.storage-label {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 6px;
}
.storage-bar {
height: 6px;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 3px;
overflow: hidden;
}
.storage-fill {
height: 100%;
background: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
transition: width 0.3s ease;
}
.storage-text {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
white-space: nowrap;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-sm {
padding: 6px 10px;
font-size: 12px;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.btn-ghost {
background: transparent;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
padding: 6px;
}
.btn-ghost:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.btn-danger {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
.btn-danger:hover {
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
}
/* Type badge */
.type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor isDragging: boolean = false;
// Demo attachments data
@state()
private accessor attachments: IAttachment[] = [
{
id: '1',
name: 'Employment_Terms_v2.pdf',
type: 'pdf',
mimeType: 'application/pdf',
size: 245760,
uploadedAt: Date.now() - 86400000 * 3,
uploadedBy: 'employer',
description: 'Original employment terms document',
},
{
id: '2',
name: 'ID_Verification.png',
type: 'image',
mimeType: 'image/png',
size: 1024000,
uploadedAt: Date.now() - 86400000,
uploadedBy: 'employee',
},
{
id: '3',
name: 'Tax_Information.xlsx',
type: 'spreadsheet',
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: 52480,
uploadedAt: Date.now() - 86400000 * 2,
uploadedBy: 'employer',
},
];
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleFieldChange(path: string, value: unknown) {
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path, value },
bubbles: true,
composed: true,
})
);
}
private handleDragEnter(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
this.isDragging = true;
}
private handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
this.isDragging = false;
}
private handleDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
}
private handleDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
this.isDragging = false;
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
this.handleFiles(files);
}
}
private handleFileSelect() {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.onchange = () => {
if (input.files && input.files.length > 0) {
this.handleFiles(input.files);
}
};
input.click();
}
private handleFiles(files: FileList) {
// Demo: just add to list
Array.from(files).forEach((file) => {
const newAttachment: IAttachment = {
id: `att-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: file.name,
type: this.getFileType(file.type),
mimeType: file.type,
size: file.size,
uploadedAt: Date.now(),
uploadedBy: 'user',
};
this.attachments = [...this.attachments, newAttachment];
});
}
private handleDeleteAttachment(attachmentId: string) {
this.attachments = this.attachments.filter((a) => a.id !== attachmentId);
}
private handleAddPriorContract() {
// TODO: Open prior contract picker modal
}
private handleRemovePriorContract(index: number) {
if (!this.contract) return;
const updatedPriorContracts = [...this.contract.priorContracts];
updatedPriorContracts.splice(index, 1);
this.handleFieldChange('priorContracts', updatedPriorContracts);
}
// ============================================================================
// HELPERS
// ============================================================================
private getFileType(mimeType: string): IAttachment['type'] {
if (mimeType.includes('pdf')) return 'pdf';
if (mimeType.includes('image')) return 'image';
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'spreadsheet';
if (mimeType.includes('document') || mimeType.includes('word')) return 'document';
return 'other';
}
private getFileTypeConfig(type: IAttachment['type']) {
return FILE_TYPES[type] || FILE_TYPES.other;
}
private formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
private formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
private getTotalSize(): number {
return this.attachments.reduce((sum, a) => sum + a.size, 0);
}
private getPartyName(roleId: string): string {
const role = this.contract?.availableRoles.find((r) => r.id === roleId);
return role?.name || roleId;
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
const totalSize = this.getTotalSize();
const maxSize = 50 * 1024 * 1024; // 50MB demo limit
const usagePercent = Math.min((totalSize / maxSize) * 100, 100);
return html`
<div class="attachments-container">
<!-- Attachments Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .icon=${'lucide:Paperclip'}></dees-icon>
Attachments
</div>
<span class="section-count">${this.attachments.length} files</span>
</div>
<div class="section-content">
<!-- Storage summary -->
<div class="storage-summary">
<div class="storage-info">
<div class="storage-label">Storage used</div>
<div class="storage-bar">
<div class="storage-fill" style="width: ${usagePercent}%"></div>
</div>
</div>
<div class="storage-text">
${this.formatFileSize(totalSize)} / ${this.formatFileSize(maxSize)}
</div>
</div>
<!-- Upload zone -->
${!this.readonly
? html`
<div
class="upload-zone ${this.isDragging ? 'dragging' : ''}"
@dragenter=${this.handleDragEnter}
@dragleave=${this.handleDragLeave}
@dragover=${this.handleDragOver}
@drop=${this.handleDrop}
@click=${this.handleFileSelect}
>
<div class="upload-zone-icon">
<dees-icon .icon=${'lucide:UploadCloud'}></dees-icon>
</div>
<div class="upload-zone-title">Drop files here or click to upload</div>
<div class="upload-zone-subtitle">Add supporting documents, images, or spreadsheets</div>
<div class="upload-zone-hint">PDF, DOCX, XLSX, PNG, JPG up to 10MB each</div>
</div>
`
: ''}
<!-- Attachments list -->
${this.attachments.length > 0
? html`
<div class="attachments-list" style="margin-top: 20px;">
${this.attachments.map((attachment) => this.renderAttachmentItem(attachment))}
</div>
`
: html`
<div class="empty-state" style="margin-top: 20px;">
<dees-icon .icon=${'lucide:FileX'}></dees-icon>
<h4>No Attachments</h4>
<p>Upload files to attach them to this contract</p>
</div>
`}
</div>
</div>
<!-- Prior Contracts Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .icon=${'lucide:Files'}></dees-icon>
Prior Contracts
</div>
${!this.readonly
? html`
<button class="btn btn-secondary" @click=${this.handleAddPriorContract}>
<dees-icon .icon=${'lucide:Plus'}></dees-icon>
Link Contract
</button>
`
: ''}
</div>
<div class="section-content">
${this.contract.priorContracts.length > 0
? html`
<div class="prior-contracts-list">
${this.contract.priorContracts.map((priorContract, index) =>
this.renderPriorContractItem(priorContract, index)
)}
</div>
`
: html`
<div class="empty-state">
<dees-icon .icon=${'lucide:Link'}></dees-icon>
<h4>No Prior Contracts</h4>
<p>Link related or predecessor contracts here</p>
</div>
`}
</div>
</div>
</div>
`;
}
private renderAttachmentItem(attachment: IAttachment): TemplateResult {
const typeConfig = this.getFileTypeConfig(attachment.type);
return html`
<div class="attachment-item">
<div class="attachment-icon" style="background: ${typeConfig.color}20; color: ${typeConfig.color}">
<dees-icon .icon=${typeConfig.icon}></dees-icon>
</div>
<div class="attachment-info">
<div class="attachment-name">${attachment.name}</div>
<div class="attachment-meta">
<span class="type-badge">${typeConfig.label}</span>
<span class="attachment-meta-item">
${this.formatFileSize(attachment.size)}
</span>
<span class="attachment-meta-item">
<dees-icon .icon=${'lucide:Calendar'}></dees-icon>
${this.formatDate(attachment.uploadedAt)}
</span>
<span class="attachment-meta-item">
<dees-icon .icon=${'lucide:User'}></dees-icon>
${this.getPartyName(attachment.uploadedBy)}
</span>
</div>
</div>
<div class="attachment-actions">
<button class="btn btn-ghost" title="Download">
<dees-icon .icon=${'lucide:Download'}></dees-icon>
</button>
<button class="btn btn-ghost" title="Preview">
<dees-icon .icon=${'lucide:Eye'}></dees-icon>
</button>
${!this.readonly
? html`
<button
class="btn btn-ghost btn-danger"
title="Delete"
@click=${() => this.handleDeleteAttachment(attachment.id)}
>
<dees-icon .icon=${'lucide:Trash2'}></dees-icon>
</button>
`
: ''}
</div>
</div>
`;
}
private renderPriorContractItem(priorContract: plugins.sdInterfaces.IPortableContract, index: number): TemplateResult {
return html`
<div class="prior-contract-item">
<div class="prior-contract-icon">
<dees-icon .icon=${'lucide:FileText'}></dees-icon>
</div>
<div class="prior-contract-info">
<div class="prior-contract-title">${priorContract.title}</div>
<div class="prior-contract-context">${priorContract.context || 'No description'}</div>
</div>
<div class="prior-contract-actions">
<button class="btn btn-secondary btn-sm">
<dees-icon .icon=${'lucide:ExternalLink'}></dees-icon>
View
</button>
${!this.readonly
? html`
<button
class="btn btn-ghost btn-danger"
@click=${() => this.handleRemovePriorContract(index)}
>
<dees-icon .icon=${'lucide:Unlink'}></dees-icon>
</button>
`
: ''}
</div>
</div>
`;
}
}
@@ -1 +0,0 @@
export * from './sdig-contract-audit.js';
@@ -1,772 +0,0 @@
/**
* @file sdig-contract-audit.ts
* @description Contract audit log and lifecycle history component
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-audit': SdigContractAudit;
}
}
// Audit event interface
interface IAuditEvent {
id: string;
timestamp: number;
type: 'created' | 'updated' | 'status_change' | 'signature' | 'comment' | 'attachment' | 'viewed' | 'shared';
userId: string;
userName: string;
userColor: string;
description: string;
details?: {
field?: string;
oldValue?: string;
newValue?: string;
attachmentName?: string;
signatureStatus?: string;
};
}
// Status workflow configuration
const STATUS_WORKFLOW = [
{ id: 'draft', label: 'Draft', icon: 'lucide:FileEdit', color: '#f59e0b' },
{ id: 'review', label: 'Review', icon: 'lucide:Eye', color: '#3b82f6' },
{ id: 'pending', label: 'Pending Signatures', icon: 'lucide:PenTool', color: '#8b5cf6' },
{ id: 'signed', label: 'Signed', icon: 'lucide:CheckCircle', color: '#10b981' },
{ id: 'executed', label: 'Executed', icon: 'lucide:ShieldCheck', color: '#059669' },
];
// Event type configuration
const EVENT_TYPES = {
created: { icon: 'lucide:PlusCircle', color: '#10b981', label: 'Created' },
updated: { icon: 'lucide:Pencil', color: '#3b82f6', label: 'Updated' },
status_change: { icon: 'lucide:ArrowRightCircle', color: '#8b5cf6', label: 'Status Changed' },
signature: { icon: 'lucide:PenTool', color: '#10b981', label: 'Signature' },
comment: { icon: 'lucide:MessageCircle', color: '#f59e0b', label: 'Comment' },
attachment: { icon: 'lucide:Paperclip', color: '#6366f1', label: 'Attachment' },
viewed: { icon: 'lucide:Eye', color: '#6b7280', label: 'Viewed' },
shared: { icon: 'lucide:Share2', color: '#ec4899', label: 'Shared' },
};
@customElement('sdig-contract-audit')
export class SdigContractAudit extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-audit
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-audit>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.audit-container {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Lifecycle status */
.lifecycle-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
padding: 24px;
}
.lifecycle-title {
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 20px;
}
.status-workflow {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
padding-bottom: 8px;
}
.status-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
min-width: 100px;
}
.status-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
transition: all 0.2s ease;
}
.status-step.completed .status-icon {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#059669', '#34d399')};
}
.status-step.current .status-icon {
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
box-shadow: 0 0 0 4px ${cssManager.bdTheme('rgba(59, 130, 246, 0.2)', 'rgba(96, 165, 250, 0.2)')};
}
.status-label {
font-size: 12px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
text-align: center;
}
.status-step.completed .status-label,
.status-step.current .status-label {
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.status-connector {
flex: 1;
height: 2px;
background: ${cssManager.bdTheme('#e5e5e5', '#27272a')};
min-width: 40px;
}
.status-connector.completed {
background: ${cssManager.bdTheme('#10b981', '#34d399')};
}
/* Section card */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.section-title dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-content {
padding: 20px;
}
/* Filter controls */
.filter-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.filter-select {
padding: 8px 12px;
font-size: 13px;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 6px;
outline: none;
cursor: pointer;
}
.search-input {
flex: 1;
padding: 8px 12px;
font-size: 13px;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 6px;
outline: none;
}
.search-input:focus {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
}
/* Timeline */
.timeline {
position: relative;
padding-left: 32px;
}
.timeline::before {
content: '';
position: absolute;
left: 11px;
top: 0;
bottom: 0;
width: 2px;
background: ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.timeline-item {
position: relative;
padding-bottom: 24px;
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-dot {
position: absolute;
left: -32px;
top: 0;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 2px solid;
}
.timeline-content {
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
}
.timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.timeline-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.timeline-time {
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.timeline-user {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.timeline-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
color: white;
}
.timeline-username {
font-size: 13px;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.timeline-description {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.timeline-details {
margin-top: 10px;
padding: 10px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border-radius: 6px;
font-size: 12px;
font-family: 'Roboto Mono', monospace;
}
.detail-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.detail-row:last-child {
margin-bottom: 0;
}
.detail-label {
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.detail-value {
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.detail-old {
text-decoration: line-through;
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
}
.detail-new {
color: ${cssManager.bdTheme('#10b981', '#34d399')};
}
/* Event type badge */
.event-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
/* Stats row */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
text-align: center;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.empty-state p {
margin: 0;
font-size: 14px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor filterType: string = 'all';
@state()
private accessor searchQuery: string = '';
// Demo audit events
@state()
private accessor auditEvents: IAuditEvent[] = [
{
id: '1',
timestamp: Date.now() - 3600000,
type: 'signature',
userId: '1',
userName: 'Alice Smith',
userColor: '#3b82f6',
description: 'Signed the contract',
details: { signatureStatus: 'completed' },
},
{
id: '2',
timestamp: Date.now() - 7200000,
type: 'status_change',
userId: '2',
userName: 'Bob Johnson',
userColor: '#10b981',
description: 'Changed status from Review to Pending Signatures',
details: { field: 'status', oldValue: 'review', newValue: 'pending' },
},
{
id: '3',
timestamp: Date.now() - 86400000,
type: 'updated',
userId: '2',
userName: 'Bob Johnson',
userColor: '#10b981',
description: 'Updated compensation amount',
details: { field: 'paragraphs.2.content', oldValue: '[Salary Amount]', newValue: '€520/month' },
},
{
id: '4',
timestamp: Date.now() - 86400000 * 2,
type: 'comment',
userId: '1',
userName: 'Alice Smith',
userColor: '#3b82f6',
description: 'Added a comment on Compensation section',
},
{
id: '5',
timestamp: Date.now() - 86400000 * 3,
type: 'attachment',
userId: '3',
userName: 'Carol Davis',
userColor: '#f59e0b',
description: 'Uploaded ID verification document',
details: { attachmentName: 'ID_Verification.pdf' },
},
{
id: '6',
timestamp: Date.now() - 86400000 * 5,
type: 'created',
userId: '2',
userName: 'Bob Johnson',
userColor: '#10b981',
description: 'Created the contract',
},
];
// ============================================================================
// HELPERS
// ============================================================================
private getEventConfig(type: IAuditEvent['type']) {
return EVENT_TYPES[type] || EVENT_TYPES.updated;
}
private getFilteredEvents(): IAuditEvent[] {
let events = this.auditEvents;
if (this.filterType !== 'all') {
events = events.filter((e) => e.type === this.filterType);
}
if (this.searchQuery) {
const query = this.searchQuery.toLowerCase();
events = events.filter(
(e) =>
e.description.toLowerCase().includes(query) ||
e.userName.toLowerCase().includes(query)
);
}
return events;
}
private formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
private formatTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
private getCurrentStatusIndex(): number {
// Demo: Return a fixed position
return 2; // Pending Signatures
}
private getEventStats() {
const total = this.auditEvents.length;
const updates = this.auditEvents.filter((e) => e.type === 'updated').length;
const signatures = this.auditEvents.filter((e) => e.type === 'signature').length;
const comments = this.auditEvents.filter((e) => e.type === 'comment').length;
return { total, updates, signatures, comments };
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
const currentStatusIndex = this.getCurrentStatusIndex();
const filteredEvents = this.getFilteredEvents();
const stats = this.getEventStats();
return html`
<div class="audit-container">
<!-- Lifecycle Status -->
<div class="lifecycle-card">
<div class="lifecycle-title">Contract Lifecycle</div>
<div class="status-workflow">
${STATUS_WORKFLOW.map((status, index) => html`
<div class="status-step ${index < currentStatusIndex ? 'completed' : ''} ${index === currentStatusIndex ? 'current' : ''}">
<div class="status-icon">
<dees-icon .icon=${status.icon}></dees-icon>
</div>
<div class="status-label">${status.label}</div>
</div>
${index < STATUS_WORKFLOW.length - 1
? html`<div class="status-connector ${index < currentStatusIndex ? 'completed' : ''}"></div>`
: ''}
`)}
</div>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-value">${stats.total}</div>
<div class="stat-label">Total Events</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.updates}</div>
<div class="stat-label">Updates</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.signatures}</div>
<div class="stat-label">Signatures</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.comments}</div>
<div class="stat-label">Comments</div>
</div>
</div>
<!-- Audit Log -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .icon=${'lucide:History'}></dees-icon>
Activity Log
</div>
<button class="btn btn-secondary">
<dees-icon .icon=${'lucide:Download'}></dees-icon>
Export
</button>
</div>
<div class="section-content">
<!-- Filters -->
<div class="filter-row">
<select
class="filter-select"
.value=${this.filterType}
@change=${(e: Event) => (this.filterType = (e.target as HTMLSelectElement).value)}
>
<option value="all">All Events</option>
${Object.entries(EVENT_TYPES).map(
([key, config]) => html`<option value=${key}>${config.label}</option>`
)}
</select>
<input
type="text"
class="search-input"
placeholder="Search events..."
.value=${this.searchQuery}
@input=${(e: Event) => (this.searchQuery = (e.target as HTMLInputElement).value)}
/>
</div>
<!-- Timeline -->
${filteredEvents.length > 0
? html`
<div class="timeline">
${filteredEvents.map((event) => this.renderTimelineItem(event))}
</div>
`
: html`
<div class="empty-state">
<dees-icon .icon=${'lucide:Clock'}></dees-icon>
<h4>No Events Found</h4>
<p>No activity matches your current filters</p>
</div>
`}
</div>
</div>
</div>
`;
}
private renderTimelineItem(event: IAuditEvent): TemplateResult {
const config = this.getEventConfig(event.type);
return html`
<div class="timeline-item">
<div class="timeline-dot" style="border-color: ${config.color}; color: ${config.color}">
<dees-icon .icon=${config.icon}></dees-icon>
</div>
<div class="timeline-content">
<div class="timeline-header">
<div class="timeline-title">
<span class="event-badge" style="background: ${config.color}20; color: ${config.color}">
${config.label}
</span>
</div>
<span class="timeline-time" title="${this.formatDate(event.timestamp)}">
${this.formatTimeAgo(event.timestamp)}
</span>
</div>
<div class="timeline-user">
<div class="timeline-avatar" style="background: ${event.userColor}">
${event.userName.charAt(0)}
</div>
<span class="timeline-username">${event.userName}</span>
</div>
<div class="timeline-description">${event.description}</div>
${event.details
? html`
<div class="timeline-details">
${event.details.field
? html`
<div class="detail-row">
<span class="detail-label">Field:</span>
<span class="detail-value">${event.details.field}</span>
</div>
`
: ''}
${event.details.oldValue && event.details.newValue
? html`
<div class="detail-row">
<span class="detail-old">${event.details.oldValue}</span>
<span>→</span>
<span class="detail-new">${event.details.newValue}</span>
</div>
`
: ''}
${event.details.attachmentName
? html`
<div class="detail-row">
<span class="detail-label">File:</span>
<span class="detail-value">${event.details.attachmentName}</span>
</div>
`
: ''}
</div>
`
: ''}
</div>
</div>
`;
}
}
@@ -1 +0,0 @@
export * from './sdig-contract-collaboration.js';
@@ -1,972 +0,0 @@
/**
* @file sdig-contract-collaboration.ts
* @description Contract collaboration - comments, suggestions, and presence
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-collaboration': SdigContractCollaboration;
}
}
// Comment interface
interface IComment {
id: string;
userId: string;
userName: string;
userColor: string;
content: string;
createdAt: number;
updatedAt?: number;
anchorPath?: string;
anchorText?: string;
resolved: boolean;
replies: IComment[];
}
// Suggestion interface
interface ISuggestion {
id: string;
userId: string;
userName: string;
userColor: string;
originalText: string;
suggestedText: string;
path: string;
status: 'pending' | 'accepted' | 'rejected';
createdAt: number;
}
// Presence interface
interface IPresence {
userId: string;
userName: string;
userColor: string;
currentSection: string;
cursorPosition?: { path: string; offset: number };
lastActive: number;
}
@customElement('sdig-contract-collaboration')
export class SdigContractCollaboration extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-collaboration
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-collaboration>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.collaboration-container {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Presence bar */
.presence-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
}
.presence-info {
display: flex;
align-items: center;
gap: 12px;
}
.presence-label {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.presence-avatars {
display: flex;
align-items: center;
}
.presence-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: white;
margin-left: -8px;
border: 2px solid ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
cursor: pointer;
position: relative;
}
.presence-avatar:first-child {
margin-left: 0;
}
.presence-avatar .status-dot {
position: absolute;
bottom: 0;
right: 0;
width: 10px;
height: 10px;
border-radius: 50%;
background: #10b981;
border: 2px solid ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
}
.presence-avatar .status-dot.away {
background: #f59e0b;
}
.presence-count {
display: flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
border-radius: 50%;
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-size: 13px;
font-weight: 600;
margin-left: -8px;
border: 2px solid ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
}
.share-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.share-btn:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
/* Section card */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.section-title dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-badge {
padding: 2px 8px;
border-radius: 9999px;
font-size: 12px;
font-weight: 600;
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
}
.section-content {
padding: 20px;
}
/* Comments list */
.comments-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.comment-thread {
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
}
.comment-thread.resolved {
opacity: 0.6;
}
.comment-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.comment-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: white;
flex-shrink: 0;
}
.comment-meta {
flex: 1;
}
.comment-author {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.comment-time {
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.comment-anchor {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: ${cssManager.bdTheme('#fef3c7', '#422006')};
border-radius: 4px;
font-size: 12px;
color: ${cssManager.bdTheme('#92400e', '#fcd34d')};
margin-bottom: 10px;
cursor: pointer;
}
.comment-anchor:hover {
background: ${cssManager.bdTheme('#fde68a', '#713f12')};
}
.comment-content {
font-size: 14px;
line-height: 1.6;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
margin-bottom: 12px;
}
.comment-actions {
display: flex;
align-items: center;
gap: 8px;
}
.comment-replies {
margin-top: 16px;
padding-left: 16px;
border-left: 2px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.reply-item {
padding: 12px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border-radius: 8px;
margin-bottom: 8px;
}
.reply-item:last-child {
margin-bottom: 0;
}
/* Suggestions list */
.suggestions-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.suggestion-card {
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
}
.suggestion-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.suggestion-user {
display: flex;
align-items: center;
gap: 8px;
}
.suggestion-status {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.suggestion-status.pending {
background: ${cssManager.bdTheme('#fef3c7', '#422006')};
color: ${cssManager.bdTheme('#92400e', '#fcd34d')};
}
.suggestion-status.accepted {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#065f46', '#6ee7b7')};
}
.suggestion-status.rejected {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
}
.suggestion-diff {
padding: 12px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 8px;
font-family: 'Roboto Mono', monospace;
font-size: 13px;
line-height: 1.5;
}
.diff-removed {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
text-decoration: line-through;
padding: 2px 4px;
border-radius: 2px;
}
.diff-added {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#065f46', '#6ee7b7')};
padding: 2px 4px;
border-radius: 2px;
}
.suggestion-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
/* New comment input */
.new-comment {
display: flex;
gap: 12px;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
}
.new-comment-input {
flex: 1;
padding: 12px;
font-size: 14px;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 8px;
outline: none;
resize: none;
min-height: 80px;
font-family: inherit;
}
.new-comment-input:focus {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(96, 165, 250, 0.1)')};
}
.new-comment-input::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.empty-state p {
margin: 0;
font-size: 14px;
}
/* Filter tabs */
.filter-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
}
.filter-tab {
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.filter-tab:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.filter-tab.active {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-sm {
padding: 6px 10px;
font-size: 12px;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.btn-ghost {
background: transparent;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.btn-ghost:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.btn-success {
background: ${cssManager.bdTheme('#10b981', '#059669')};
color: white;
}
.btn-success:hover {
background: ${cssManager.bdTheme('#059669', '#047857')};
}
.btn-danger {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
.btn-danger:hover {
background: ${cssManager.bdTheme('#fecaca', '#7f1d1d')};
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor activeTab: 'comments' | 'suggestions' = 'comments';
@state()
private accessor commentFilter: 'all' | 'open' | 'resolved' = 'all';
@state()
private accessor newCommentText: string = '';
// Demo presence data
@state()
private accessor presenceList: IPresence[] = [
{ userId: '1', userName: 'Alice Smith', userColor: '#3b82f6', currentSection: 'content', lastActive: Date.now() },
{ userId: '2', userName: 'Bob Johnson', userColor: '#10b981', currentSection: 'parties', lastActive: Date.now() - 60000 },
{ userId: '3', userName: 'Carol Davis', userColor: '#f59e0b', currentSection: 'terms', lastActive: Date.now() - 300000 },
];
// Demo comments data
@state()
private accessor comments: IComment[] = [
{
id: '1',
userId: '1',
userName: 'Alice Smith',
userColor: '#3b82f6',
content: 'Can we clarify the payment terms in paragraph 3? The current wording seems ambiguous.',
createdAt: Date.now() - 3600000,
anchorPath: 'paragraphs.2',
anchorText: 'Compensation',
resolved: false,
replies: [
{
id: '1-1',
userId: '2',
userName: 'Bob Johnson',
userColor: '#10b981',
content: 'Good point. I\'ll update the wording to be more specific.',
createdAt: Date.now() - 1800000,
resolved: false,
replies: [],
},
],
},
{
id: '2',
userId: '3',
userName: 'Carol Davis',
userColor: '#f59e0b',
content: 'The termination clause needs to comply with the latest regulations.',
createdAt: Date.now() - 86400000,
resolved: true,
replies: [],
},
];
// Demo suggestions data
@state()
private accessor suggestions: ISuggestion[] = [
{
id: '1',
userId: '1',
userName: 'Alice Smith',
userColor: '#3b82f6',
originalText: 'monthly salary',
suggestedText: 'monthly gross salary',
path: 'paragraphs.2.content',
status: 'pending',
createdAt: Date.now() - 7200000,
},
];
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleFieldChange(path: string, value: unknown) {
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path, value },
bubbles: true,
composed: true,
})
);
}
private handleAddComment() {
if (!this.newCommentText.trim()) return;
const newComment: IComment = {
id: `comment-${Date.now()}`,
userId: 'current-user',
userName: 'You',
userColor: '#6366f1',
content: this.newCommentText,
createdAt: Date.now(),
resolved: false,
replies: [],
};
this.comments = [newComment, ...this.comments];
this.newCommentText = '';
}
private handleResolveComment(commentId: string) {
this.comments = this.comments.map((c) =>
c.id === commentId ? { ...c, resolved: !c.resolved } : c
);
}
private handleAcceptSuggestion(suggestionId: string) {
this.suggestions = this.suggestions.map((s) =>
s.id === suggestionId ? { ...s, status: 'accepted' as const } : s
);
}
private handleRejectSuggestion(suggestionId: string) {
this.suggestions = this.suggestions.map((s) =>
s.id === suggestionId ? { ...s, status: 'rejected' as const } : s
);
}
// ============================================================================
// HELPERS
// ============================================================================
private getFilteredComments(): IComment[] {
if (this.commentFilter === 'all') return this.comments;
if (this.commentFilter === 'open') return this.comments.filter((c) => !c.resolved);
return this.comments.filter((c) => c.resolved);
}
private formatTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
private getActivePresence(): IPresence[] {
const fiveMinutesAgo = Date.now() - 300000;
return this.presenceList.filter((p) => p.lastActive > fiveMinutesAgo);
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
const activePresence = this.getActivePresence();
const openComments = this.comments.filter((c) => !c.resolved).length;
const pendingSuggestions = this.suggestions.filter((s) => s.status === 'pending').length;
return html`
<div class="collaboration-container">
<!-- Presence Bar -->
<div class="presence-bar">
<div class="presence-info">
<span class="presence-label">Currently viewing:</span>
<div class="presence-avatars">
${activePresence.slice(0, 4).map(
(p) => html`
<div
class="presence-avatar"
style="background: ${p.userColor}"
title="${p.userName} - ${p.currentSection}"
>
${p.userName.charAt(0)}
<span class="status-dot ${Date.now() - p.lastActive > 60000 ? 'away' : ''}"></span>
</div>
`
)}
${activePresence.length > 4
? html`<div class="presence-count">+${activePresence.length - 4}</div>`
: ''}
</div>
</div>
<button class="share-btn">
<dees-icon .icon=${'lucide:Share2'}></dees-icon>
Share
</button>
</div>
<!-- Comments Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .icon=${'lucide:MessageCircle'}></dees-icon>
Comments
${openComments > 0 ? html`<span class="section-badge">${openComments} open</span>` : ''}
</div>
</div>
<div class="section-content">
<!-- Filter tabs -->
<div class="filter-tabs">
<button
class="filter-tab ${this.commentFilter === 'all' ? 'active' : ''}"
@click=${() => (this.commentFilter = 'all')}
>
All (${this.comments.length})
</button>
<button
class="filter-tab ${this.commentFilter === 'open' ? 'active' : ''}"
@click=${() => (this.commentFilter = 'open')}
>
Open (${openComments})
</button>
<button
class="filter-tab ${this.commentFilter === 'resolved' ? 'active' : ''}"
@click=${() => (this.commentFilter = 'resolved')}
>
Resolved (${this.comments.length - openComments})
</button>
</div>
<!-- New comment input -->
${!this.readonly
? html`
<div class="new-comment">
<textarea
class="new-comment-input"
placeholder="Add a comment..."
.value=${this.newCommentText}
@input=${(e: Event) => (this.newCommentText = (e.target as HTMLTextAreaElement).value)}
></textarea>
<button class="btn btn-primary" @click=${this.handleAddComment}>
<dees-icon .icon=${'lucide:Send'}></dees-icon>
Comment
</button>
</div>
`
: ''}
<!-- Comments list -->
${this.getFilteredComments().length > 0
? html`
<div class="comments-list" style="margin-top: 16px;">
${this.getFilteredComments().map((comment) => this.renderComment(comment))}
</div>
`
: html`
<div class="empty-state" style="margin-top: 16px;">
<dees-icon .icon=${'lucide:MessageSquare'}></dees-icon>
<h4>No Comments</h4>
<p>Start a discussion by adding a comment</p>
</div>
`}
</div>
</div>
<!-- Suggestions Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .icon=${'lucide:GitPullRequest'}></dees-icon>
Suggestions
${pendingSuggestions > 0 ? html`<span class="section-badge">${pendingSuggestions} pending</span>` : ''}
</div>
</div>
<div class="section-content">
${this.suggestions.length > 0
? html`
<div class="suggestions-list">
${this.suggestions.map((suggestion) => this.renderSuggestion(suggestion))}
</div>
`
: html`
<div class="empty-state">
<dees-icon .icon=${'lucide:Edit3'}></dees-icon>
<h4>No Suggestions</h4>
<p>Suggested changes will appear here</p>
</div>
`}
</div>
</div>
</div>
`;
}
private renderComment(comment: IComment): TemplateResult {
return html`
<div class="comment-thread ${comment.resolved ? 'resolved' : ''}">
<div class="comment-header">
<div class="comment-avatar" style="background: ${comment.userColor}">
${comment.userName.charAt(0)}
</div>
<div class="comment-meta">
<div class="comment-author">${comment.userName}</div>
<div class="comment-time">${this.formatTimeAgo(comment.createdAt)}</div>
</div>
${!this.readonly
? html`
<button
class="btn btn-ghost btn-sm"
@click=${() => this.handleResolveComment(comment.id)}
>
<dees-icon .icon=${comment.resolved ? 'lucide:RotateCcw' : 'lucide:Check'}></dees-icon>
${comment.resolved ? 'Reopen' : 'Resolve'}
</button>
`
: ''}
</div>
${comment.anchorText
? html`
<div class="comment-anchor">
<dees-icon .icon=${'lucide:Link'}></dees-icon>
${comment.anchorText}
</div>
`
: ''}
<div class="comment-content">${comment.content}</div>
${comment.replies.length > 0
? html`
<div class="comment-replies">
${comment.replies.map(
(reply) => html`
<div class="reply-item">
<div class="comment-header">
<div class="comment-avatar" style="background: ${reply.userColor}; width: 28px; height: 28px; font-size: 11px;">
${reply.userName.charAt(0)}
</div>
<div class="comment-meta">
<div class="comment-author" style="font-size: 13px;">${reply.userName}</div>
<div class="comment-time">${this.formatTimeAgo(reply.createdAt)}</div>
</div>
</div>
<div class="comment-content" style="font-size: 13px; margin-bottom: 0;">${reply.content}</div>
</div>
`
)}
</div>
`
: ''}
</div>
`;
}
private renderSuggestion(suggestion: ISuggestion): TemplateResult {
return html`
<div class="suggestion-card">
<div class="suggestion-header">
<div class="suggestion-user">
<div class="comment-avatar" style="background: ${suggestion.userColor}; width: 28px; height: 28px; font-size: 11px;">
${suggestion.userName.charAt(0)}
</div>
<div>
<div class="comment-author" style="font-size: 13px;">${suggestion.userName}</div>
<div class="comment-time">${this.formatTimeAgo(suggestion.createdAt)}</div>
</div>
</div>
<div class="suggestion-status ${suggestion.status}">
<dees-icon .icon=${suggestion.status === 'pending' ? 'lucide:Clock' : suggestion.status === 'accepted' ? 'lucide:Check' : 'lucide:X'}></dees-icon>
${suggestion.status.charAt(0).toUpperCase() + suggestion.status.slice(1)}
</div>
</div>
<div class="suggestion-diff">
<span class="diff-removed">${suggestion.originalText}</span>
<span> → </span>
<span class="diff-added">${suggestion.suggestedText}</span>
</div>
${suggestion.status === 'pending' && !this.readonly
? html`
<div class="suggestion-actions">
<button class="btn btn-success btn-sm" @click=${() => this.handleAcceptSuggestion(suggestion.id)}>
<dees-icon .icon=${'lucide:Check'}></dees-icon>
Accept
</button>
<button class="btn btn-danger btn-sm" @click=${() => this.handleRejectSuggestion(suggestion.id)}>
<dees-icon .icon=${'lucide:X'}></dees-icon>
Reject
</button>
</div>
`
: ''}
</div>
`;
}
}
@@ -1 +0,0 @@
export * from './sdig-contract-content.js';
@@ -1,920 +0,0 @@
/**
* @file sdig-contract-content.ts
* @description Contract content/paragraphs editor component
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-content': SdigContractContent;
}
}
// Paragraph type configuration
const PARAGRAPH_TYPES = [
{ value: 'section', label: 'Section', icon: 'lucide:Heading' },
{ value: 'clause', label: 'Clause', icon: 'lucide:FileText' },
{ value: 'definition', label: 'Definition', icon: 'lucide:BookOpen' },
{ value: 'obligation', label: 'Obligation', icon: 'lucide:CheckSquare' },
{ value: 'condition', label: 'Condition', icon: 'lucide:GitBranch' },
{ value: 'schedule', label: 'Schedule', icon: 'lucide:Calendar' },
];
@customElement('sdig-contract-content')
export class SdigContractContent extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-content
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-content>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.content-container {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Toolbar */
.content-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.search-box {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 8px;
}
.search-box input {
border: none;
background: transparent;
font-size: 14px;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
outline: none;
width: 200px;
}
.search-box input::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.search-box dees-icon {
font-size: 16px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
/* Section card */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.section-title dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.paragraph-count {
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-content {
padding: 0;
}
/* Paragraph list */
.paragraphs-list {
display: flex;
flex-direction: column;
}
.paragraph-item {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 20px;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
cursor: pointer;
transition: background 0.15s ease;
}
.paragraph-item:last-child {
border-bottom: none;
}
.paragraph-item:hover {
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
}
.paragraph-item.selected {
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
border-color: ${cssManager.bdTheme('#bfdbfe', '#1e40af')};
}
.paragraph-item.editing {
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
}
.paragraph-drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
cursor: grab;
flex-shrink: 0;
margin-top: 2px;
}
.paragraph-drag-handle:hover {
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.paragraph-number {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
flex-shrink: 0;
}
.paragraph-content {
flex: 1;
min-width: 0;
}
.paragraph-title-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.paragraph-title {
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.paragraph-title-input {
flex: 1;
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 6px;
padding: 8px 12px;
outline: none;
}
.paragraph-title-input:focus {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(96, 165, 250, 0.1)')};
}
.paragraph-type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.paragraph-body {
font-size: 14px;
line-height: 1.6;
color: ${cssManager.bdTheme('#4b5563', '#9ca3af')};
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.paragraph-body.expanded {
-webkit-line-clamp: unset;
overflow: visible;
}
.paragraph-body-textarea {
width: 100%;
min-height: 150px;
font-size: 14px;
line-height: 1.6;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 6px;
padding: 12px;
outline: none;
resize: vertical;
font-family: inherit;
}
.paragraph-body-textarea:focus {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(96, 165, 250, 0.1)')};
}
.paragraph-meta {
display: flex;
align-items: center;
gap: 16px;
margin-top: 12px;
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.paragraph-meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.paragraph-actions {
display: flex;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
}
.paragraph-edit-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
/* Variable highlighting */
.variable {
display: inline;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Roboto Mono', monospace;
font-size: 13px;
background: ${cssManager.bdTheme('#fef3c7', '#422006')};
color: ${cssManager.bdTheme('#92400e', '#fcd34d')};
}
/* Child paragraphs */
.child-paragraphs {
margin-left: 48px;
border-left: 2px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
padding-left: 16px;
}
.child-paragraphs .paragraph-item {
padding: 16px;
}
.child-paragraphs .paragraph-number {
width: 28px;
height: 28px;
font-size: 12px;
}
/* Add paragraph button */
.add-paragraph-row {
display: flex;
align-items: center;
justify-content: center;
padding: 16px 20px;
border-top: 1px dashed ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.add-paragraph-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: transparent;
border: 2px dashed ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 8px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.add-paragraph-btn:hover {
border-color: ${cssManager.bdTheme('#9ca3af', '#52525b')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
background: ${cssManager.bdTheme('#f9fafb', '#18181b')};
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state dees-icon {
font-size: 64px;
margin-bottom: 20px;
opacity: 0.5;
}
.empty-state h4 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.empty-state p {
margin: 0 0 24px;
font-size: 14px;
max-width: 400px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-sm {
padding: 6px 10px;
font-size: 12px;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.btn-ghost {
background: transparent;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
padding: 6px;
}
.btn-ghost:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.btn-danger {
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
color: ${cssManager.bdTheme('#dc2626', '#fca5a5')};
}
.btn-danger:hover {
background: ${cssManager.bdTheme('#fee2e2', '#7f1d1d')};
}
/* View mode toggle */
.view-toggle {
display: flex;
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
border-radius: 6px;
padding: 2px;
}
.view-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
background: transparent;
border: none;
border-radius: 4px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
cursor: pointer;
transition: all 0.15s ease;
}
.view-toggle-btn.active {
background: ${cssManager.bdTheme('#ffffff', '#3f3f46')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.view-toggle-btn dees-icon {
font-size: 16px;
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor selectedParagraphId: string | null = null;
@state()
private accessor editingParagraphId: string | null = null;
@state()
private accessor searchQuery: string = '';
@state()
private accessor viewMode: 'list' | 'outline' = 'list';
@state()
private accessor expandedParagraphs: Set<string> = new Set();
// Editing state
@state()
private accessor editTitle: string = '';
@state()
private accessor editContent: string = '';
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleFieldChange(path: string, value: unknown) {
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path, value },
bubbles: true,
composed: true,
})
);
}
private handleSelectParagraph(paragraphId: string) {
this.selectedParagraphId = this.selectedParagraphId === paragraphId ? null : paragraphId;
this.dispatchEvent(
new CustomEvent('paragraph-select', {
detail: { paragraphId: this.selectedParagraphId },
bubbles: true,
composed: true,
})
);
}
private handleEditParagraph(paragraph: plugins.sdInterfaces.IParagraph) {
this.editingParagraphId = paragraph.uniqueId;
this.editTitle = paragraph.title;
this.editContent = paragraph.content;
}
private handleSaveEdit() {
if (!this.contract || !this.editingParagraphId) return;
const updatedParagraphs = this.contract.paragraphs.map((p) => {
if (p.uniqueId === this.editingParagraphId) {
return { ...p, title: this.editTitle, content: this.editContent };
}
return p;
});
this.handleFieldChange('paragraphs', updatedParagraphs);
this.editingParagraphId = null;
this.editTitle = '';
this.editContent = '';
}
private handleCancelEdit() {
this.editingParagraphId = null;
this.editTitle = '';
this.editContent = '';
}
private handleAddParagraph(parentId: string | null = null) {
if (!this.contract) return;
const newParagraph: plugins.sdInterfaces.IParagraph = {
uniqueId: `p-${Date.now()}`,
parent: parentId ? this.contract.paragraphs.find((p) => p.uniqueId === parentId) || null : null,
title: 'New Paragraph',
content: 'Enter paragraph content here...',
};
const updatedParagraphs = [...this.contract.paragraphs, newParagraph];
this.handleFieldChange('paragraphs', updatedParagraphs);
// Start editing the new paragraph
this.handleEditParagraph(newParagraph);
}
private handleDeleteParagraph(paragraphId: string) {
if (!this.contract) return;
// Remove the paragraph and any children
const idsToRemove = new Set<string>([paragraphId]);
// Find child paragraphs recursively
const findChildren = (parentId: string) => {
this.contract!.paragraphs.forEach((p) => {
if (p.parent?.uniqueId === parentId) {
idsToRemove.add(p.uniqueId);
findChildren(p.uniqueId);
}
});
};
findChildren(paragraphId);
const updatedParagraphs = this.contract.paragraphs.filter((p) => !idsToRemove.has(p.uniqueId));
this.handleFieldChange('paragraphs', updatedParagraphs);
if (this.selectedParagraphId === paragraphId) {
this.selectedParagraphId = null;
}
}
private handleMoveParagraph(paragraphId: string, direction: 'up' | 'down') {
if (!this.contract) return;
const paragraphs = [...this.contract.paragraphs];
const index = paragraphs.findIndex((p) => p.uniqueId === paragraphId);
if (index === -1) return;
if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === paragraphs.length - 1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
[paragraphs[index], paragraphs[newIndex]] = [paragraphs[newIndex], paragraphs[index]];
this.handleFieldChange('paragraphs', paragraphs);
}
private handleSearchChange(e: Event) {
const input = e.target as HTMLInputElement;
this.searchQuery = input.value;
}
private toggleExpanded(paragraphId: string) {
const expanded = new Set(this.expandedParagraphs);
if (expanded.has(paragraphId)) {
expanded.delete(paragraphId);
} else {
expanded.add(paragraphId);
}
this.expandedParagraphs = expanded;
}
// ============================================================================
// HELPERS
// ============================================================================
private getRootParagraphs(): plugins.sdInterfaces.IParagraph[] {
if (!this.contract) return [];
return this.contract.paragraphs.filter((p) => !p.parent);
}
private getChildParagraphs(parentId: string): plugins.sdInterfaces.IParagraph[] {
if (!this.contract) return [];
return this.contract.paragraphs.filter((p) => p.parent?.uniqueId === parentId);
}
private filterParagraphs(paragraphs: plugins.sdInterfaces.IParagraph[]): plugins.sdInterfaces.IParagraph[] {
if (!this.searchQuery) return paragraphs;
const query = this.searchQuery.toLowerCase();
return paragraphs.filter(
(p) =>
p.title.toLowerCase().includes(query) ||
p.content.toLowerCase().includes(query)
);
}
private highlightVariables(content: string): TemplateResult {
// Match {{variableName}} patterns
const parts = content.split(/(\{\{[^}]+\}\})/g);
return html`${parts.map((part) =>
part.startsWith('{{') && part.endsWith('}}')
? html`<span class="variable">${part}</span>`
: part
)}`;
}
private getParagraphNumber(paragraph: plugins.sdInterfaces.IParagraph, index: number): string {
// Simple numbering - can be enhanced for hierarchical numbering
return String(index + 1);
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
const rootParagraphs = this.getRootParagraphs();
const filteredParagraphs = this.filterParagraphs(rootParagraphs);
return html`
<div class="content-container">
<!-- Toolbar -->
<div class="content-toolbar">
<div class="toolbar-left">
<div class="search-box">
<dees-icon .icon=${'lucide:Search'}></dees-icon>
<input
type="text"
placeholder="Search paragraphs..."
.value=${this.searchQuery}
@input=${this.handleSearchChange}
/>
</div>
</div>
<div class="toolbar-right">
<div class="view-toggle">
<button
class="view-toggle-btn ${this.viewMode === 'list' ? 'active' : ''}"
@click=${() => (this.viewMode = 'list')}
title="List view"
>
<dees-icon .icon=${'lucide:List'}></dees-icon>
</button>
<button
class="view-toggle-btn ${this.viewMode === 'outline' ? 'active' : ''}"
@click=${() => (this.viewMode = 'outline')}
title="Outline view"
>
<dees-icon .icon=${'lucide:LayoutList'}></dees-icon>
</button>
</div>
${!this.readonly
? html`
<button class="btn btn-primary" @click=${() => this.handleAddParagraph()}>
<dees-icon .icon=${'lucide:Plus'}></dees-icon>
Add Paragraph
</button>
`
: ''}
</div>
</div>
<!-- Content Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .icon=${'lucide:FileText'}></dees-icon>
Contract Content
</div>
<span class="paragraph-count">${this.contract.paragraphs.length} paragraphs</span>
</div>
<div class="section-content">
${filteredParagraphs.length > 0
? html`
<div class="paragraphs-list">
${filteredParagraphs.map((paragraph, index) =>
this.renderParagraph(paragraph, index)
)}
</div>
${!this.readonly
? html`
<div class="add-paragraph-row">
<button class="add-paragraph-btn" @click=${() => this.handleAddParagraph()}>
<dees-icon .icon=${'lucide:Plus'}></dees-icon>
Add paragraph
</button>
</div>
`
: ''}
`
: html`
<div class="empty-state">
<dees-icon .icon=${'lucide:FilePlus'}></dees-icon>
<h4>No Paragraphs Yet</h4>
<p>Start building your contract by adding paragraphs. Each paragraph can contain clauses, definitions, or obligations.</p>
${!this.readonly
? html`
<button class="btn btn-primary" @click=${() => this.handleAddParagraph()}>
<dees-icon .icon=${'lucide:Plus'}></dees-icon>
Add First Paragraph
</button>
`
: ''}
</div>
`}
</div>
</div>
</div>
`;
}
private renderParagraph(paragraph: plugins.sdInterfaces.IParagraph, index: number): TemplateResult {
const isSelected = this.selectedParagraphId === paragraph.uniqueId;
const isEditing = this.editingParagraphId === paragraph.uniqueId;
const isExpanded = this.expandedParagraphs.has(paragraph.uniqueId);
const childParagraphs = this.getChildParagraphs(paragraph.uniqueId);
return html`
<div
class="paragraph-item ${isSelected ? 'selected' : ''} ${isEditing ? 'editing' : ''}"
@click=${() => !isEditing && this.handleSelectParagraph(paragraph.uniqueId)}
>
${!this.readonly
? html`
<div class="paragraph-drag-handle">
<dees-icon .icon=${'lucide:GripVertical'}></dees-icon>
</div>
`
: ''}
<div class="paragraph-number">${this.getParagraphNumber(paragraph, index)}</div>
<div class="paragraph-content">
${isEditing
? html`
<input
type="text"
class="paragraph-title-input"
.value=${this.editTitle}
@input=${(e: Event) => (this.editTitle = (e.target as HTMLInputElement).value)}
@click=${(e: Event) => e.stopPropagation()}
placeholder="Paragraph title"
/>
<textarea
class="paragraph-body-textarea"
.value=${this.editContent}
@input=${(e: Event) => (this.editContent = (e.target as HTMLTextAreaElement).value)}
@click=${(e: Event) => e.stopPropagation()}
placeholder="Paragraph content..."
></textarea>
<div class="paragraph-edit-actions">
<button class="btn btn-primary" @click=${(e: Event) => { e.stopPropagation(); this.handleSaveEdit(); }}>
Save
</button>
<button class="btn btn-secondary" @click=${(e: Event) => { e.stopPropagation(); this.handleCancelEdit(); }}>
Cancel
</button>
</div>
`
: html`
<div class="paragraph-title-row">
<span class="paragraph-title">${paragraph.title}</span>
</div>
<div class="paragraph-body ${isExpanded ? 'expanded' : ''}">${this.highlightVariables(paragraph.content)}</div>
${paragraph.content.length > 200
? html`
<button
class="btn btn-ghost btn-sm"
@click=${(e: Event) => { e.stopPropagation(); this.toggleExpanded(paragraph.uniqueId); }}
style="margin-top: 8px;"
>
${isExpanded ? 'Show less' : 'Show more'}
</button>
`
: ''}
`}
</div>
${!this.readonly && !isEditing
? html`
<div class="paragraph-actions">
<button
class="btn btn-ghost"
@click=${(e: Event) => { e.stopPropagation(); this.handleEditParagraph(paragraph); }}
title="Edit"
>
<dees-icon .icon=${'lucide:Pencil'}></dees-icon>
</button>
<button
class="btn btn-ghost"
@click=${(e: Event) => { e.stopPropagation(); this.handleMoveParagraph(paragraph.uniqueId, 'up'); }}
title="Move up"
>
<dees-icon .icon=${'lucide:ChevronUp'}></dees-icon>
</button>
<button
class="btn btn-ghost"
@click=${(e: Event) => { e.stopPropagation(); this.handleMoveParagraph(paragraph.uniqueId, 'down'); }}
title="Move down"
>
<dees-icon .icon=${'lucide:ChevronDown'}></dees-icon>
</button>
<button
class="btn btn-ghost btn-danger"
@click=${(e: Event) => { e.stopPropagation(); this.handleDeleteParagraph(paragraph.uniqueId); }}
title="Delete"
>
<dees-icon .icon=${'lucide:Trash2'}></dees-icon>
</button>
</div>
`
: ''}
</div>
${childParagraphs.length > 0
? html`
<div class="child-paragraphs">
${childParagraphs.map((child, childIndex) => this.renderParagraph(child, childIndex))}
</div>
`
: ''}
`;
}
}
@@ -1 +0,0 @@
export * from './sdig-contract-header.js';
@@ -1,558 +0,0 @@
/**
* @file sdig-contract-header.ts
* @description Contract header component with title, status, and quick actions
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-header': SdigContractHeader;
}
}
@customElement('sdig-contract-header')
export class SdigContractHeader extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-header
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-header>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.header-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
padding: 24px;
}
.header-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
}
.title-section {
flex: 1;
}
.contract-number {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 8px;
}
.title-input-wrapper {
position: relative;
}
.title-input {
width: 100%;
font-size: 24px;
font-weight: 700;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: transparent;
border: none;
padding: 0;
outline: none;
border-bottom: 2px solid transparent;
transition: border-color 0.15s ease;
}
.title-input:focus {
border-bottom-color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.title-input::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.status-section {
display: flex;
align-items: center;
gap: 12px;
}
/* shadcn-style badge */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
line-height: 1.4;
}
.status-badge:hover:not(:disabled) {
filter: brightness(0.95);
}
.status-badge:disabled {
cursor: default;
}
.status-badge.draft {
background: ${cssManager.bdTheme('hsl(48 96% 89%)', 'hsl(48 96% 15%)')};
color: ${cssManager.bdTheme('hsl(25 95% 30%)', 'hsl(48 96% 70%)')};
border-color: ${cssManager.bdTheme('hsl(48 96% 76%)', 'hsl(48 96% 25%)')};
}
.status-badge.review {
background: ${cssManager.bdTheme('hsl(214 95% 93%)', 'hsl(214 95% 15%)')};
color: ${cssManager.bdTheme('hsl(214 95% 35%)', 'hsl(214 95% 70%)')};
border-color: ${cssManager.bdTheme('hsl(214 95% 80%)', 'hsl(214 95% 25%)')};
}
.status-badge.pending {
background: ${cssManager.bdTheme('hsl(38 92% 90%)', 'hsl(38 92% 15%)')};
color: ${cssManager.bdTheme('hsl(25 95% 35%)', 'hsl(38 92% 65%)')};
border-color: ${cssManager.bdTheme('hsl(38 92% 75%)', 'hsl(38 92% 25%)')};
}
.status-badge.signed,
.status-badge.active {
background: ${cssManager.bdTheme('hsl(142 76% 90%)', 'hsl(142 76% 15%)')};
color: ${cssManager.bdTheme('hsl(142 76% 28%)', 'hsl(142 76% 65%)')};
border-color: ${cssManager.bdTheme('hsl(142 76% 75%)', 'hsl(142 76% 25%)')};
}
.status-badge.terminated {
background: ${cssManager.bdTheme('hsl(0 84% 92%)', 'hsl(0 84% 15%)')};
color: ${cssManager.bdTheme('hsl(0 84% 35%)', 'hsl(0 84% 65%)')};
border-color: ${cssManager.bdTheme('hsl(0 84% 80%)', 'hsl(0 84% 25%)')};
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.8;
}
/* Meta info row */
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
padding-top: 20px;
border-top: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.meta-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.meta-label {
font-size: 12px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
text-transform: uppercase;
letter-spacing: 0.05em;
}
.meta-value {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.meta-value.clickable {
cursor: pointer;
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
}
.meta-value.clickable:hover {
text-decoration: underline;
}
/* Tags */
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
/* Quick actions */
.quick-actions {
display: flex;
gap: 8px;
}
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
cursor: pointer;
transition: all 0.15s ease;
}
.action-btn:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
border-color: ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
}
.action-btn dees-icon {
font-size: 16px;
}
/* Status dropdown */
.status-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
min-width: 200px;
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
overflow: hidden;
}
.status-option {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
font-size: 14px;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
cursor: pointer;
transition: background 0.1s ease;
}
.status-option:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
}
.status-option.selected {
background: ${cssManager.bdTheme('#eff6ff', '#1e3a5f')};
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor showStatusDropdown: boolean = false;
@state()
private accessor editingTitle: boolean = false;
// ============================================================================
// STATUS CONFIGURATION
// ============================================================================
private statusOptions: Array<{
value: plugins.sdInterfaces.TContractStatus;
label: string;
category: string;
}> = [
{ value: 'draft', label: 'Draft', category: 'draft' },
{ value: 'internal_review', label: 'Internal Review', category: 'review' },
{ value: 'legal_review', label: 'Legal Review', category: 'review' },
{ value: 'negotiation', label: 'Negotiation', category: 'review' },
{ value: 'pending_approval', label: 'Pending Approval', category: 'pending' },
{ value: 'pending_signature', label: 'Pending Signature', category: 'pending' },
{ value: 'partially_signed', label: 'Partially Signed', category: 'pending' },
{ value: 'signed', label: 'Signed', category: 'signed' },
{ value: 'executed', label: 'Executed', category: 'signed' },
{ value: 'active', label: 'Active', category: 'active' },
{ value: 'expired', label: 'Expired', category: 'terminated' },
{ value: 'terminated', label: 'Terminated', category: 'terminated' },
{ value: 'cancelled', label: 'Cancelled', category: 'terminated' },
{ value: 'voided', label: 'Voided', category: 'terminated' },
];
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleTitleChange(e: Event) {
const input = e.target as HTMLInputElement;
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path: 'title', value: input.value },
bubbles: true,
composed: true,
})
);
}
private handleStatusChange(status: plugins.sdInterfaces.TContractStatus) {
this.showStatusDropdown = false;
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path: 'lifecycle.currentStatus', value: status },
bubbles: true,
composed: true,
})
);
}
private toggleStatusDropdown() {
if (!this.readonly) {
this.showStatusDropdown = !this.showStatusDropdown;
}
}
private handleExport() {
this.dispatchEvent(
new CustomEvent('action', {
detail: { action: 'export' },
bubbles: true,
composed: true,
})
);
}
private handleDuplicate() {
this.dispatchEvent(
new CustomEvent('action', {
detail: { action: 'duplicate' },
bubbles: true,
composed: true,
})
);
}
private handleShare() {
this.dispatchEvent(
new CustomEvent('action', {
detail: { action: 'share' },
bubbles: true,
composed: true,
})
);
}
// ============================================================================
// HELPERS
// ============================================================================
private getStatusCategory(status: string): string {
const option = this.statusOptions.find((o) => o.value === status);
return option?.category || 'draft';
}
private formatStatus(status: string): string {
const option = this.statusOptions.find((o) => o.value === status);
return option?.label || status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
private formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
private formatContractType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div class="header-card">No contract loaded</div>`;
}
const status = this.contract.lifecycle?.currentStatus || 'draft';
const statusCategory = this.getStatusCategory(status);
return html`
<div class="header-card">
<div class="header-top">
<div class="title-section">
${this.contract.metadata?.contractNumber
? html`<div class="contract-number">#${this.contract.metadata.contractNumber}</div>`
: ''}
<div class="title-input-wrapper">
<input
type="text"
class="title-input"
.value=${this.contract.title}
placeholder="Contract Title"
?disabled=${this.readonly}
@input=${this.handleTitleChange}
/>
</div>
</div>
<div class="status-section">
<div style="position: relative;">
<button
class="status-badge ${statusCategory}"
@click=${this.toggleStatusDropdown}
?disabled=${this.readonly}
>
<span class="status-dot"></span>
${this.formatStatus(status)}
${!this.readonly
? html`<dees-icon .icon=${'lucide:ChevronDown'} style="font-size: 14px;"></dees-icon>`
: ''}
</button>
${this.showStatusDropdown
? html`
<div class="status-dropdown">
${this.statusOptions.map(
(option) => html`
<div
class="status-option ${status === option.value ? 'selected' : ''}"
@click=${() => this.handleStatusChange(option.value)}
>
<span
class="status-dot"
style="background: ${option.category === 'draft'
? '#f59e0b'
: option.category === 'review'
? '#3b82f6'
: option.category === 'pending'
? '#f59e0b'
: option.category === 'signed' || option.category === 'active'
? '#10b981'
: '#ef4444'}"
></span>
${option.label}
</div>
`
)}
</div>
`
: ''}
</div>
<div class="quick-actions">
<button class="action-btn" @click=${this.handleExport} title="Export">
<dees-icon .icon=${'lucide:Download'}></dees-icon>
</button>
<button class="action-btn" @click=${this.handleDuplicate} title="Duplicate">
<dees-icon .icon=${'lucide:Copy'}></dees-icon>
</button>
<button class="action-btn" @click=${this.handleShare} title="Share">
<dees-icon .icon=${'lucide:Share2'}></dees-icon>
</button>
</div>
</div>
</div>
${this.contract.metadata ? html`
<div class="meta-row">
<div class="meta-item">
<span class="meta-label">Type</span>
<span class="meta-value">${this.formatContractType(this.contract.metadata.contractType)}</span>
</div>
<div class="meta-item">
<span class="meta-label">Category</span>
<span class="meta-value">${this.formatContractType(this.contract.metadata.category)}</span>
</div>
<div class="meta-item">
<span class="meta-label">Language</span>
<span class="meta-value">${this.contract.metadata.language?.toUpperCase() || 'N/A'}</span>
</div>
<div class="meta-item">
<span class="meta-label">Jurisdiction</span>
<span class="meta-value">
${this.contract.metadata.governingLaw?.country || 'Not specified'}
${this.contract.metadata.governingLaw?.state
? `, ${this.contract.metadata.governingLaw.state}`
: ''}
</span>
</div>
<div class="meta-item">
<span class="meta-label">Created</span>
<span class="meta-value">${this.formatDate(this.contract.createdAt)}</span>
</div>
<div class="meta-item">
<span class="meta-label">Parties</span>
<span class="meta-value clickable">${this.contract.involvedParties?.length || 0} parties</span>
</div>
${this.contract.metadata.tags?.length > 0
? html`
<div class="meta-item">
<span class="meta-label">Tags</span>
<div class="tags-container">
${this.contract.metadata.tags.map((tag) => html`<span class="tag">${tag}</span>`)}
</div>
</div>
`
: ''}
</div>
` : ''}
</div>
`;
}
}
@@ -1 +0,0 @@
export * from './sdig-contract-metadata.js';
@@ -1,820 +0,0 @@
/**
* @file sdig-contract-metadata.ts
* @description Contract metadata editor component
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-metadata': SdigContractMetadata;
}
}
// Type-safe options arrays
const CONTRACT_CATEGORIES: Array<{ value: plugins.sdInterfaces.TContractCategory; label: string }> = [
{ value: 'employment', label: 'Employment' },
{ value: 'service', label: 'Service Agreement' },
{ value: 'sales', label: 'Sales' },
{ value: 'lease', label: 'Lease / Rental' },
{ value: 'license', label: 'License' },
{ value: 'partnership', label: 'Partnership' },
{ value: 'confidentiality', label: 'Confidentiality / NDA' },
{ value: 'financial', label: 'Financial' },
{ value: 'real_estate', label: 'Real Estate' },
{ value: 'intellectual_property', label: 'Intellectual Property' },
{ value: 'government', label: 'Government' },
{ value: 'construction', label: 'Construction' },
{ value: 'healthcare', label: 'Healthcare' },
{ value: 'insurance', label: 'Insurance' },
{ value: 'other', label: 'Other' },
];
const CONFIDENTIALITY_LEVELS: Array<{ value: plugins.sdInterfaces.TConfidentialityLevel; label: string }> = [
{ value: 'public', label: 'Public' },
{ value: 'internal', label: 'Internal' },
{ value: 'confidential', label: 'Confidential' },
{ value: 'restricted', label: 'Restricted' },
];
const DISPUTE_RESOLUTIONS: Array<{ value: plugins.sdInterfaces.TDisputeResolution; label: string }> = [
{ value: 'litigation', label: 'Litigation' },
{ value: 'arbitration', label: 'Arbitration' },
{ value: 'mediation', label: 'Mediation' },
{ value: 'negotiation', label: 'Negotiation' },
];
const COMMON_LANGUAGES = [
{ value: 'en', label: 'English' },
{ value: 'de', label: 'German' },
{ value: 'fr', label: 'French' },
{ value: 'es', label: 'Spanish' },
{ value: 'it', label: 'Italian' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'nl', label: 'Dutch' },
{ value: 'pl', label: 'Polish' },
{ value: 'sv', label: 'Swedish' },
{ value: 'da', label: 'Danish' },
{ value: 'fi', label: 'Finnish' },
{ value: 'no', label: 'Norwegian' },
{ value: 'zh', label: 'Chinese' },
{ value: 'ja', label: 'Japanese' },
{ value: 'ko', label: 'Korean' },
{ value: 'ar', label: 'Arabic' },
{ value: 'ru', label: 'Russian' },
];
@customElement('sdig-contract-metadata')
export class SdigContractMetadata extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-metadata
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-metadata>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.metadata-container {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Section cards */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.section-title dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-content {
padding: 20px;
}
/* Form grid */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-label {
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.form-label .required {
color: #ef4444;
margin-left: 2px;
}
.form-description {
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-top: 4px;
}
/* Input styles */
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 10px 12px;
font-size: 14px;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 8px;
outline: none;
transition: all 0.15s ease;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
border-color: ${cssManager.bdTheme('#111111', '#fafafa')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0,0,0,0.05)', 'rgba(255,255,255,0.05)')};
}
.form-input::placeholder,
.form-textarea::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.form-input:disabled,
.form-select:disabled,
.form-textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-select {
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 10px center;
background-repeat: no-repeat;
background-size: 16px;
padding-right: 36px;
}
.form-textarea {
min-height: 80px;
resize: vertical;
}
/* Radio group */
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.radio-option {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.radio-option input[type="radio"] {
width: 18px;
height: 18px;
accent-color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.radio-option span {
font-size: 14px;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
/* Tags input */
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 12px;
min-height: 44px;
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 8px;
cursor: text;
}
.tags-input-container:focus-within {
border-color: ${cssManager.bdTheme('#111111', '#fafafa')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0,0,0,0.05)', 'rgba(255,255,255,0.05)')};
}
.tag-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
border-radius: 6px;
font-size: 13px;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.tag-remove {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: transparent;
border: none;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.tag-remove:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.tags-input {
flex: 1;
min-width: 120px;
border: none;
background: transparent;
padding: 4px 0;
font-size: 14px;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
outline: none;
}
.tags-input::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Divider */
.section-divider {
border-top: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
margin: 20px 0;
}
/* Collapsible sections */
.collapsible-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
cursor: pointer;
user-select: none;
}
.collapsible-header:hover .collapse-icon {
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.collapse-icon {
font-size: 16px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
transition: transform 0.2s ease;
}
.collapse-icon.expanded {
transform: rotate(180deg);
}
.collapsible-content {
overflow: hidden;
max-height: 0;
opacity: 0;
transition: all 0.3s ease;
}
.collapsible-content.expanded {
max-height: 1000px;
opacity: 1;
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor showArbitrationFields: boolean = false;
@state()
private accessor newTag: string = '';
// ============================================================================
// LIFECYCLE
// ============================================================================
public updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('contract') && this.contract?.metadata?.governingLaw) {
this.showArbitrationFields = this.contract.metadata.governingLaw.disputeResolution === 'arbitration';
}
}
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleFieldChange(path: string, value: unknown) {
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path, value },
bubbles: true,
composed: true,
})
);
}
private handleInputChange(path: string, e: Event) {
const input = e.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
this.handleFieldChange(path, input.value);
}
private handleRadioChange(path: string, value: string) {
this.handleFieldChange(path, value);
// Special handling for dispute resolution
if (path === 'metadata.governingLaw.disputeResolution') {
this.showArbitrationFields = value === 'arbitration';
}
}
private handleTagAdd(e: KeyboardEvent) {
if (e.key === 'Enter' && this.newTag.trim()) {
e.preventDefault();
const currentTags = this.contract?.metadata.tags || [];
if (!currentTags.includes(this.newTag.trim())) {
this.handleFieldChange('metadata.tags', [...currentTags, this.newTag.trim()]);
}
this.newTag = '';
}
}
private handleTagRemove(tag: string) {
const currentTags = this.contract?.metadata.tags || [];
this.handleFieldChange(
'metadata.tags',
currentTags.filter((t) => t !== tag)
);
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
if (!this.contract.metadata) {
return html`<div class="metadata-container"><div class="section-card">Contract metadata not available</div></div>`;
}
const metadata = this.contract.metadata;
return html`
<div class="metadata-container">
<!-- Basic Information -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .icon=${'lucide:Info'}></dees-icon>
Basic Information
</div>
</div>
<div class="section-content">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Contract Number</label>
<input
type="text"
class="form-input"
.value=${metadata.contractNumber || ''}
placeholder="e.g., CNT-2024-001"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.contractNumber', e)}
/>
</div>
<div class="form-group">
<label class="form-label">Category <span class="required">*</span></label>
<select
class="form-select"
.value=${metadata.category}
?disabled=${this.readonly}
@change=${(e: Event) => this.handleInputChange('metadata.category', e)}
>
${CONTRACT_CATEGORIES.map(
(cat) => html`
<option value=${cat.value} ?selected=${metadata.category === cat.value}>
${cat.label}
</option>
`
)}
</select>
</div>
<div class="form-group">
<label class="form-label">Contract Type <span class="required">*</span></label>
<input
type="text"
class="form-input"
.value=${metadata.contractType}
placeholder="e.g., employment_minijob"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.contractType', e)}
/>
<p class="form-description">Specific contract type identifier</p>
</div>
<div class="form-group">
<label class="form-label">Confidentiality Level</label>
<div class="radio-group">
${CONFIDENTIALITY_LEVELS.map(
(level) => html`
<label class="radio-option">
<input
type="radio"
name="confidentiality"
.value=${level.value}
?checked=${metadata.confidentialityLevel === level.value}
?disabled=${this.readonly}
@change=${() => this.handleRadioChange('metadata.confidentialityLevel', level.value)}
/>
<span>${level.label}</span>
</label>
`
)}
</div>
</div>
<div class="form-group full-width">
<label class="form-label">Tags</label>
<div class="tags-input-container">
${metadata.tags.map(
(tag) => html`
<span class="tag-item">
${tag}
${!this.readonly
? html`
<button class="tag-remove" @click=${() => this.handleTagRemove(tag)}>×</button>
`
: ''}
</span>
`
)}
${!this.readonly
? html`
<input
type="text"
class="tags-input"
.value=${this.newTag}
placeholder="Add tag and press Enter"
@input=${(e: Event) => (this.newTag = (e.target as HTMLInputElement).value)}
@keydown=${this.handleTagAdd}
/>
`
: ''}
</div>
</div>
</div>
</div>
</div>
<!-- Language Settings -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .icon=${'lucide:Globe'}></dees-icon>
Language Settings
</div>
</div>
<div class="section-content">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Primary Language <span class="required">*</span></label>
<select
class="form-select"
.value=${metadata.language}
?disabled=${this.readonly}
@change=${(e: Event) => this.handleInputChange('metadata.language', e)}
>
${COMMON_LANGUAGES.map(
(lang) => html`
<option value=${lang.value} ?selected=${metadata.language === lang.value}>
${lang.label}
</option>
`
)}
</select>
</div>
<div class="form-group">
<label class="form-label">Binding Language</label>
<select
class="form-select"
.value=${metadata.bindingLanguage || ''}
?disabled=${this.readonly}
@change=${(e: Event) => this.handleInputChange('metadata.bindingLanguage', e)}
>
<option value="">Same as primary</option>
${COMMON_LANGUAGES.map(
(lang) => html`
<option value=${lang.value} ?selected=${metadata.bindingLanguage === lang.value}>
${lang.label}
</option>
`
)}
</select>
<p class="form-description">Language that takes precedence in case of conflicts</p>
</div>
</div>
</div>
</div>
<!-- Governing Law -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .icon=${'lucide:Scale'}></dees-icon>
Governing Law & Jurisdiction
</div>
</div>
<div class="section-content">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Country <span class="required">*</span></label>
<input
type="text"
class="form-input"
.value=${metadata.governingLaw.country}
placeholder="e.g., Germany"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.governingLaw.country', e)}
/>
</div>
<div class="form-group">
<label class="form-label">State / Province</label>
<input
type="text"
class="form-input"
.value=${metadata.governingLaw.state || ''}
placeholder="e.g., Bavaria"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.governingLaw.state', e)}
/>
</div>
<div class="form-group">
<label class="form-label">Dispute Jurisdiction</label>
<input
type="text"
class="form-input"
.value=${metadata.governingLaw.disputeJurisdiction || ''}
placeholder="e.g., Munich Courts"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.governingLaw.disputeJurisdiction', e)}
/>
</div>
<div class="form-group full-width">
<label class="form-label">Dispute Resolution Method</label>
<div class="radio-group">
${DISPUTE_RESOLUTIONS.map(
(res) => html`
<label class="radio-option">
<input
type="radio"
name="disputeResolution"
.value=${res.value}
?checked=${metadata.governingLaw.disputeResolution === res.value}
?disabled=${this.readonly}
@change=${() => this.handleRadioChange('metadata.governingLaw.disputeResolution', res.value)}
/>
<span>${res.label}</span>
</label>
`
)}
</div>
</div>
${this.showArbitrationFields
? html`
<div class="section-divider full-width"></div>
<div class="form-group">
<label class="form-label">Arbitration Institution</label>
<input
type="text"
class="form-input"
.value=${metadata.governingLaw.arbitrationInstitution || ''}
placeholder="e.g., ICC, LCIA, AAA"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.governingLaw.arbitrationInstitution', e)}
/>
</div>
<div class="form-group">
<label class="form-label">Arbitration Rules</label>
<input
type="text"
class="form-input"
.value=${metadata.governingLaw.arbitrationRules || ''}
placeholder="e.g., ICC Rules of Arbitration"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.governingLaw.arbitrationRules', e)}
/>
</div>
<div class="form-group">
<label class="form-label">Seat of Arbitration</label>
<input
type="text"
class="form-input"
.value=${metadata.governingLaw.arbitrationSeat || ''}
placeholder="e.g., Paris, London"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.governingLaw.arbitrationSeat', e)}
/>
</div>
<div class="form-group">
<label class="form-label">Number of Arbitrators</label>
<select
class="form-select"
.value=${String(metadata.governingLaw.numberOfArbitrators || 1)}
?disabled=${this.readonly}
@change=${(e: Event) =>
this.handleFieldChange(
'metadata.governingLaw.numberOfArbitrators',
parseInt((e.target as HTMLSelectElement).value, 10)
)}
>
<option value="1">1 Arbitrator</option>
<option value="3">3 Arbitrators</option>
<option value="5">5 Arbitrators</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Proceedings Language</label>
<select
class="form-select"
.value=${metadata.governingLaw.proceedingsLanguage || ''}
?disabled=${this.readonly}
@change=${(e: Event) => this.handleInputChange('metadata.governingLaw.proceedingsLanguage', e)}
>
<option value="">Select language</option>
${COMMON_LANGUAGES.map(
(lang) => html`
<option
value=${lang.value}
?selected=${metadata.governingLaw.proceedingsLanguage === lang.value}
>
${lang.label}
</option>
`
)}
</select>
</div>
`
: ''}
</div>
</div>
</div>
<!-- References -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .icon=${'lucide:Link'}></dees-icon>
References & Integration
</div>
</div>
<div class="section-content">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Internal Reference</label>
<input
type="text"
class="form-input"
.value=${metadata.internalReference || ''}
placeholder="Internal tracking reference"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.internalReference', e)}
/>
</div>
<div class="form-group">
<label class="form-label">External Reference</label>
<input
type="text"
class="form-input"
.value=${metadata.externalReference || ''}
placeholder="External system reference"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.externalReference', e)}
/>
</div>
<div class="form-group">
<label class="form-label">Template ID</label>
<input
type="text"
class="form-input"
.value=${metadata.templateId || ''}
placeholder="Source template ID"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.templateId', e)}
/>
</div>
<div class="form-group">
<label class="form-label">Parent Contract ID</label>
<input
type="text"
class="form-input"
.value=${metadata.parentContractId || ''}
placeholder="Parent/master contract"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.parentContractId', e)}
/>
</div>
</div>
</div>
</div>
</div>
`;
}
}
@@ -1 +0,0 @@
export * from './sdig-contract-parties.js';
@@ -1,736 +0,0 @@
/**
* @file sdig-contract-parties.ts
* @description Contract parties and roles management component
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-parties': SdigContractParties;
}
}
// Party role display configuration
const PARTY_ROLES: Array<{ value: plugins.sdInterfaces.TPartyRole; label: string; icon: string }> = [
{ value: 'signer', label: 'Signer', icon: 'lucide:PenTool' },
{ value: 'witness', label: 'Witness', icon: 'lucide:Eye' },
{ value: 'notary', label: 'Notary', icon: 'lucide:Stamp' },
{ value: 'cc', label: 'CC (Copy)', icon: 'lucide:Mail' },
{ value: 'approver', label: 'Approver', icon: 'lucide:CheckCircle' },
{ value: 'guarantor', label: 'Guarantor', icon: 'lucide:Shield' },
{ value: 'beneficiary', label: 'Beneficiary', icon: 'lucide:UserCheck' },
];
const SIGNING_DEPENDENCIES: Array<{ value: plugins.sdInterfaces.TSigningDependency; label: string }> = [
{ value: 'none', label: 'No dependency' },
{ value: 'sequential', label: 'Sequential (in order)' },
{ value: 'parallel', label: 'Parallel (any order)' },
{ value: 'after_specific', label: 'After specific parties' },
];
@customElement('sdig-contract-parties')
export class SdigContractParties extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-parties
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-parties>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.parties-container {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Section cards */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.section-title dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-content {
padding: 20px;
}
/* Roles list */
.roles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.role-card {
display: flex;
flex-direction: column;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
transition: all 0.15s ease;
}
.role-card:hover {
border-color: ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
}
.role-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.role-name {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.role-name dees-icon {
font-size: 16px;
padding: 6px;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 6px;
}
.role-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
}
.role-description {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 12px;
}
.role-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.role-meta-item {
display: flex;
align-items: center;
gap: 4px;
}
/* Parties list */
.parties-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.party-card {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
transition: all 0.15s ease;
}
.party-card:hover {
border-color: ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
}
.party-card.selected {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
}
.party-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: white;
flex-shrink: 0;
}
.party-info {
flex: 1;
min-width: 0;
}
.party-name {
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 4px;
}
.party-role-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
margin-bottom: 8px;
}
.party-details {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.party-detail {
display: flex;
align-items: center;
gap: 6px;
}
.party-detail dees-icon {
font-size: 14px;
}
.party-status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.signature-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.signature-status.pending {
background: ${cssManager.bdTheme('#fef3c7', '#422006')};
color: ${cssManager.bdTheme('#92400e', '#fcd34d')};
}
.signature-status.signed {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#065f46', '#6ee7b7')};
}
.signature-status.declined {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
}
.signing-order {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.order-number {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
/* Add button */
.add-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
background: transparent;
border: 2px dashed ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 10px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.add-button:hover {
border-color: ${cssManager.bdTheme('#9ca3af', '#52525b')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
background: ${cssManager.bdTheme('#f9fafb', '#18181b')};
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.empty-state p {
margin: 0;
font-size: 14px;
}
/* Action buttons */
.action-buttons {
display: flex;
gap: 8px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-sm {
padding: 6px 10px;
font-size: 12px;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.btn-ghost {
background: transparent;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.btn-ghost:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor selectedPartyId: string | null = null;
@state()
private accessor showRoleEditor: boolean = false;
@state()
private accessor showPartyEditor: boolean = false;
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleFieldChange(path: string, value: unknown) {
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path, value },
bubbles: true,
composed: true,
})
);
}
private handleSelectParty(partyId: string) {
this.selectedPartyId = this.selectedPartyId === partyId ? null : partyId;
this.dispatchEvent(
new CustomEvent('party-select', {
detail: { partyId: this.selectedPartyId },
bubbles: true,
composed: true,
})
);
}
private handleAddRole() {
this.showRoleEditor = true;
// TODO: Open role editor modal
}
private handleAddParty() {
this.showPartyEditor = true;
// TODO: Open party editor modal
}
private handleRemoveParty(partyId: string) {
if (!this.contract) return;
const updatedParties = this.contract.involvedParties.filter((p) => p.partyId !== partyId);
this.handleFieldChange('involvedParties', updatedParties);
}
// ============================================================================
// HELPERS
// ============================================================================
private getPartyDisplayName(party: plugins.sdInterfaces.IInvolvedParty): string {
if (!party) return 'Unknown Party';
const contact = party.contact;
if (!contact) return party.deliveryEmail || 'Unknown Party';
if ('name' in contact && contact.name) {
return contact.name as string;
}
if ('firstName' in contact && 'lastName' in contact) {
return `${contact.firstName || ''} ${contact.lastName || ''}`.trim() || party.deliveryEmail || 'Unknown Party';
}
return party.deliveryEmail || 'Unknown Party';
}
private getPartyInitials(party: plugins.sdInterfaces.IInvolvedParty): string {
const name = this.getPartyDisplayName(party);
if (!name || name.length === 0) return '??';
const parts = name.split(' ');
if (parts.length >= 2 && parts[0].length > 0 && parts[parts.length - 1].length > 0) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.substring(0, Math.min(2, name.length)).toUpperCase();
}
private getPartyColor(party: plugins.sdInterfaces.IInvolvedParty): string {
// Generate a consistent color based on party ID
const colors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1',
];
const idStr = party?.partyId || 'default';
const hash = idStr.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
return colors[hash % colors.length];
}
private getRoleName(roleId: string): string {
if (!roleId) return 'Unknown Role';
const role = this.contract?.availableRoles.find((r) => r.id === roleId);
return role?.name || roleId.charAt(0).toUpperCase() + roleId.slice(1);
}
private getSignatureStatusClass(status: string): string {
if (status === 'signed') return 'signed';
if (status === 'declined') return 'declined';
return 'pending';
}
private formatSignatureStatus(status: string): string {
return status.charAt(0).toUpperCase() + status.slice(1);
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
const roles = this.contract.availableRoles;
const parties = this.contract.involvedParties;
return html`
<div class="parties-container">
<!-- Roles Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .icon=${'lucide:Users2'}></dees-icon>
Available Roles
</div>
${!this.readonly
? html`
<button class="btn btn-secondary btn-sm" @click=${this.handleAddRole}>
<dees-icon .icon=${'lucide:Plus'}></dees-icon>
Add Role
</button>
`
: ''}
</div>
<div class="section-content">
${roles.length > 0
? html`
<div class="roles-grid">
${roles.map((role) => this.renderRoleCard(role))}
</div>
`
: html`
<div class="empty-state">
<dees-icon .icon=${'lucide:Users'}></dees-icon>
<h4>No Roles Defined</h4>
<p>Add roles to define the types of parties in this contract</p>
</div>
`}
</div>
</div>
<!-- Parties Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .icon=${'lucide:UserPlus'}></dees-icon>
Involved Parties (${parties.length})
</div>
${!this.readonly
? html`
<button class="btn btn-primary btn-sm" @click=${this.handleAddParty}>
<dees-icon .icon=${'lucide:Plus'}></dees-icon>
Add Party
</button>
`
: ''}
</div>
<div class="section-content">
${parties.length > 0
? html`
<div class="parties-list">
${parties.map((party) => this.renderPartyCard(party))}
</div>
`
: html`
<div class="empty-state">
<dees-icon .icon=${'lucide:UserPlus'}></dees-icon>
<h4>No Parties Added</h4>
<p>Add parties who will be involved in this contract</p>
</div>
`}
</div>
</div>
</div>
`;
}
private renderRoleCard(role: plugins.sdInterfaces.IRole): TemplateResult {
return html`
<div class="role-card">
<div class="role-header">
<div class="role-name">
<dees-icon
.icon=${role.icon || 'lucide:User'}
style="color: ${role.displayColor || 'inherit'}"
></dees-icon>
${role.name}
</div>
<span class="role-badge">${role.category}</span>
</div>
<div class="role-description">${role.description || 'No description'}</div>
<div class="role-meta">
${role.signatureRequired
? html`
<span class="role-meta-item">
<dees-icon .icon=${'lucide:PenTool'}></dees-icon>
Signature required
</span>
`
: ''}
${role.defaultSigningOrder > 0
? html`
<span class="role-meta-item">
<dees-icon .icon=${'lucide:ListOrdered'}></dees-icon>
Order: ${role.defaultSigningOrder}
</span>
`
: ''}
${role.minParties
? html`
<span class="role-meta-item">
<dees-icon .icon=${'lucide:Users'}></dees-icon>
Min: ${role.minParties}${role.maxParties ? `, Max: ${role.maxParties}` : ''}
</span>
`
: ''}
</div>
</div>
`;
}
private renderPartyCard(party: plugins.sdInterfaces.IInvolvedParty): TemplateResult {
// Handle both full IInvolvedParty and minimal demo data
const partyId = (party as any).partyId || (party as any).role || 'unknown';
const roleId = (party as any).roleId || (party as any).role || '';
const partyRole = (party as any).partyRole || 'signer';
const signatureStatus = (party as any).signature?.status || 'pending';
const signingOrder = (party as any).signingOrder || 0;
const deliveryEmail = (party as any).deliveryEmail;
const deliveryPhone = (party as any).deliveryPhone;
const actingAsProxy = (party as any).actingAsProxy;
const isSelected = this.selectedPartyId === partyId;
return html`
<div
class="party-card ${isSelected ? 'selected' : ''}"
@click=${() => this.handleSelectParty(partyId)}
>
<div
class="party-avatar"
style="background: ${this.getPartyColor(party)}"
>
${this.getPartyInitials(party)}
</div>
<div class="party-info">
<div class="party-name">${this.getPartyDisplayName(party)}</div>
<div class="party-role-tag">
${this.getRoleName(roleId)} (${PARTY_ROLES.find((r) => r.value === partyRole)?.label || partyRole})
</div>
<div class="party-details">
${deliveryEmail
? html`
<div class="party-detail">
<dees-icon .icon=${'lucide:Mail'}></dees-icon>
${deliveryEmail}
</div>
`
: ''}
${deliveryPhone
? html`
<div class="party-detail">
<dees-icon .icon=${'lucide:Phone'}></dees-icon>
${deliveryPhone}
</div>
`
: ''}
${actingAsProxy
? html`
<div class="party-detail">
<dees-icon .icon=${'lucide:Users'}></dees-icon>
Acting as proxy
</div>
`
: ''}
</div>
</div>
<div class="party-status">
<span class="signature-status ${this.getSignatureStatusClass(signatureStatus)}">
${this.formatSignatureStatus(signatureStatus)}
</span>
${signingOrder > 0
? html`
<div class="signing-order">
<span class="order-number">${signingOrder}</span>
<span>Signing order</span>
</div>
`
: ''}
</div>
</div>
`;
}
}
@@ -1 +0,0 @@
export * from './sdig-contract-signatures.js';
@@ -1,840 +0,0 @@
/**
* @file sdig-contract-signatures.ts
* @description Contract signature fields manager component
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-signatures': SdigContractSignatures;
}
}
// Signature field interface (for future interface updates)
interface ISignatureField {
id: string;
name: string;
assignedPartyId: string | null;
roleId: string;
type: 'signature' | 'initials' | 'date' | 'text';
required: boolean;
status: 'pending' | 'ready' | 'signed' | 'declined';
signedAt?: number;
signatureData?: any;
position: {
paragraphId?: string;
pageNumber?: number;
x: number;
y: number;
};
}
// Signature status configuration
const SIGNATURE_STATUSES = [
{ value: 'pending', label: 'Pending', color: '#f59e0b', icon: 'lucide:Clock' },
{ value: 'ready', label: 'Ready to Sign', color: '#3b82f6', icon: 'lucide:PenTool' },
{ value: 'signed', label: 'Signed', color: '#10b981', icon: 'lucide:CheckCircle' },
{ value: 'declined', label: 'Declined', color: '#ef4444', icon: 'lucide:XCircle' },
];
const FIELD_TYPES = [
{ value: 'signature', label: 'Full Signature', icon: 'lucide:PenTool' },
{ value: 'initials', label: 'Initials', icon: 'lucide:Type' },
{ value: 'date', label: 'Date', icon: 'lucide:Calendar' },
{ value: 'text', label: 'Text Field', icon: 'lucide:TextCursor' },
];
@customElement('sdig-contract-signatures')
export class SdigContractSignatures extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-signatures
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-signatures>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.signatures-container {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Summary cards */
.summary-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
}
.summary-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
}
.summary-card-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.summary-card-icon.pending {
background: ${cssManager.bdTheme('#fef3c7', '#422006')};
color: ${cssManager.bdTheme('#f59e0b', '#fcd34d')};
}
.summary-card-icon.ready {
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
}
.summary-card-icon.signed {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#10b981', '#34d399')};
}
.summary-card-icon.declined {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
}
.summary-card-value {
font-size: 28px;
font-weight: 700;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.summary-card-label {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
/* Section card */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.section-title dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-content {
padding: 20px;
}
/* Signature fields list */
.fields-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.field-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
transition: all 0.15s ease;
}
.field-card:hover {
border-color: ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
}
.field-card.selected {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
}
.field-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
flex-shrink: 0;
}
.field-info {
flex: 1;
min-width: 0;
}
.field-name {
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 4px;
}
.field-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.field-meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.field-meta-item dees-icon {
font-size: 14px;
}
.field-status {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.field-status.pending {
background: ${cssManager.bdTheme('#fef3c7', '#422006')};
color: ${cssManager.bdTheme('#92400e', '#fcd34d')};
}
.field-status.ready {
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
}
.field-status.signed {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#065f46', '#6ee7b7')};
}
.field-status.declined {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
}
.field-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* Signer progress */
.signers-section {
margin-top: 24px;
}
.signers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.signer-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
}
.signer-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: white;
flex-shrink: 0;
}
.signer-info {
flex: 1;
min-width: 0;
}
.signer-name {
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 4px;
}
.signer-role {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 8px;
}
.signer-progress {
display: flex;
align-items: center;
gap: 8px;
}
.progress-bar {
flex: 1;
height: 6px;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: ${cssManager.bdTheme('#10b981', '#34d399')};
transition: width 0.3s ease;
}
.progress-text {
font-size: 12px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
min-width: 36px;
text-align: right;
}
/* Signature preview */
.signature-preview {
position: relative;
padding: 24px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
text-align: center;
}
.signature-preview-label {
font-size: 12px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 12px;
}
.signature-preview-image {
max-width: 200px;
max-height: 80px;
margin: 0 auto;
}
.signature-preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.signature-preview-placeholder dees-icon {
font-size: 32px;
opacity: 0.5;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 20px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.empty-state p {
margin: 0 0 20px;
font-size: 14px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-sm {
padding: 6px 10px;
font-size: 12px;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.btn-ghost {
background: transparent;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
padding: 6px;
}
.btn-ghost:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.btn-success {
background: ${cssManager.bdTheme('#10b981', '#059669')};
color: white;
}
.btn-success:hover {
background: ${cssManager.bdTheme('#059669', '#047857')};
}
/* Type badge */
.type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
/* Signing order */
.signing-order-badge {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 12px;
font-weight: 600;
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor selectedFieldId: string | null = null;
// Demo signature fields data
@state()
private accessor signatureFields: ISignatureField[] = [];
// ============================================================================
// LIFECYCLE
// ============================================================================
public async firstUpdated() {
// Generate demo signature fields based on contract parties
if (this.contract && this.contract.involvedParties.length > 0) {
this.signatureFields = this.contract.involvedParties.map((party, index) => ({
id: `sig-${index + 1}`,
name: `Signature - ${this.getPartyRoleName(party.role)}`,
assignedPartyId: null,
roleId: party.role,
type: 'signature' as const,
required: true,
status: index === 0 ? 'signed' : index === 1 ? 'ready' : 'pending',
signedAt: index === 0 ? Date.now() - 86400000 : undefined,
position: { x: 0, y: 0 },
}));
}
}
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleFieldChange(path: string, value: unknown) {
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path, value },
bubbles: true,
composed: true,
})
);
}
private handleSelectField(fieldId: string) {
this.selectedFieldId = this.selectedFieldId === fieldId ? null : fieldId;
}
private handleAddField() {
const newField: ISignatureField = {
id: `sig-${Date.now()}`,
name: 'New Signature Field',
assignedPartyId: null,
roleId: '',
type: 'signature',
required: true,
status: 'pending',
position: { x: 0, y: 0 },
};
this.signatureFields = [...this.signatureFields, newField];
}
private handleDeleteField(fieldId: string) {
this.signatureFields = this.signatureFields.filter((f) => f.id !== fieldId);
if (this.selectedFieldId === fieldId) {
this.selectedFieldId = null;
}
}
// ============================================================================
// HELPERS
// ============================================================================
private getPartyRoleName(roleId: string): string {
const role = this.contract?.availableRoles.find((r) => r.id === roleId);
return role?.name || roleId;
}
private getStatusConfig(status: string) {
return SIGNATURE_STATUSES.find((s) => s.value === status) || SIGNATURE_STATUSES[0];
}
private getFieldTypeConfig(type: string) {
return FIELD_TYPES.find((t) => t.value === type) || FIELD_TYPES[0];
}
private getSignatureStats() {
const total = this.signatureFields.length;
const signed = this.signatureFields.filter((f) => f.status === 'signed').length;
const ready = this.signatureFields.filter((f) => f.status === 'ready').length;
const pending = this.signatureFields.filter((f) => f.status === 'pending').length;
const declined = this.signatureFields.filter((f) => f.status === 'declined').length;
return { total, signed, ready, pending, declined };
}
private getPartyColor(index: number): string {
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
return colors[index % colors.length];
}
private formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
const stats = this.getSignatureStats();
return html`
<div class="signatures-container">
<!-- Summary Cards -->
<div class="summary-row">
<div class="summary-card">
<div class="summary-card-icon pending">
<dees-icon .icon=${'lucide:Clock'}></dees-icon>
</div>
<div class="summary-card-value">${stats.pending}</div>
<div class="summary-card-label">Pending</div>
</div>
<div class="summary-card">
<div class="summary-card-icon ready">
<dees-icon .icon=${'lucide:PenTool'}></dees-icon>
</div>
<div class="summary-card-value">${stats.ready}</div>
<div class="summary-card-label">Ready to Sign</div>
</div>
<div class="summary-card">
<div class="summary-card-icon signed">
<dees-icon .icon=${'lucide:CheckCircle'}></dees-icon>
</div>
<div class="summary-card-value">${stats.signed}</div>
<div class="summary-card-label">Signed</div>
</div>
<div class="summary-card">
<div class="summary-card-icon declined">
<dees-icon .icon=${'lucide:XCircle'}></dees-icon>
</div>
<div class="summary-card-value">${stats.declined}</div>
<div class="summary-card-label">Declined</div>
</div>
</div>
<!-- Signature Fields Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .icon=${'lucide:PenTool'}></dees-icon>
Signature Fields
</div>
${!this.readonly
? html`
<button class="btn btn-primary" @click=${this.handleAddField}>
<dees-icon .icon=${'lucide:Plus'}></dees-icon>
Add Field
</button>
`
: ''}
</div>
<div class="section-content">
${this.signatureFields.length > 0
? html`
<div class="fields-list">
${this.signatureFields.map((field, index) => this.renderSignatureField(field, index))}
</div>
`
: html`
<div class="empty-state">
<dees-icon .icon=${'lucide:PenTool'}></dees-icon>
<h4>No Signature Fields</h4>
<p>Add signature fields to define where parties should sign the contract</p>
${!this.readonly
? html`
<button class="btn btn-primary" @click=${this.handleAddField}>
<dees-icon .icon=${'lucide:Plus'}></dees-icon>
Add Signature Field
</button>
`
: ''}
</div>
`}
</div>
</div>
<!-- Signers Progress Section -->
${this.contract.involvedParties.length > 0
? html`
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .icon=${'lucide:Users'}></dees-icon>
Signers Progress
</div>
</div>
<div class="section-content">
<div class="signers-grid">
${this.contract.involvedParties.map((party, index) => this.renderSignerCard(party, index))}
</div>
</div>
</div>
`
: ''}
</div>
`;
}
private renderSignatureField(field: ISignatureField, index: number): TemplateResult {
const isSelected = this.selectedFieldId === field.id;
const statusConfig = this.getStatusConfig(field.status);
const typeConfig = this.getFieldTypeConfig(field.type);
return html`
<div
class="field-card ${isSelected ? 'selected' : ''}"
@click=${() => this.handleSelectField(field.id)}
>
<div class="signing-order-badge">${index + 1}</div>
<div class="field-icon">
<dees-icon .icon=${typeConfig.icon}></dees-icon>
</div>
<div class="field-info">
<div class="field-name">${field.name}</div>
<div class="field-meta">
<span class="field-meta-item">
<dees-icon .icon=${'lucide:User'}></dees-icon>
${this.getPartyRoleName(field.roleId)}
</span>
<span class="type-badge">
<dees-icon .icon=${typeConfig.icon}></dees-icon>
${typeConfig.label}
</span>
${field.required
? html`
<span class="field-meta-item">
<dees-icon .icon=${'lucide:Asterisk'}></dees-icon>
Required
</span>
`
: ''}
${field.signedAt
? html`
<span class="field-meta-item">
<dees-icon .icon=${'lucide:Calendar'}></dees-icon>
${this.formatDate(field.signedAt)}
</span>
`
: ''}
</div>
</div>
<div class="field-status ${field.status}">
<dees-icon .icon=${statusConfig.icon}></dees-icon>
${statusConfig.label}
</div>
${!this.readonly
? html`
<div class="field-actions">
<button class="btn btn-ghost" @click=${(e: Event) => { e.stopPropagation(); }} title="Edit">
<dees-icon .icon=${'lucide:Pencil'}></dees-icon>
</button>
<button
class="btn btn-ghost"
@click=${(e: Event) => { e.stopPropagation(); this.handleDeleteField(field.id); }}
title="Delete"
style="color: #ef4444;"
>
<dees-icon .icon=${'lucide:Trash2'}></dees-icon>
</button>
</div>
`
: ''}
</div>
`;
}
private renderSignerCard(party: plugins.sdInterfaces.IInvolvedParty, index: number): TemplateResult {
const partyFields = this.signatureFields.filter((f) => f.roleId === party.role);
const signedFields = partyFields.filter((f) => f.status === 'signed').length;
const totalFields = partyFields.length;
const progress = totalFields > 0 ? Math.round((signedFields / totalFields) * 100) : 0;
const roleName = this.getPartyRoleName(party.role);
return html`
<div class="signer-card">
<div class="signer-avatar" style="background: ${this.getPartyColor(index)}">
${roleName.charAt(0).toUpperCase()}
</div>
<div class="signer-info">
<div class="signer-name">${roleName}</div>
<div class="signer-role">${signedFields} of ${totalFields} signatures</div>
<div class="signer-progress">
<div class="progress-bar">
<div class="progress-fill" style="width: ${progress}%"></div>
</div>
<span class="progress-text">${progress}%</span>
</div>
</div>
</div>
`;
}
}
@@ -1 +0,0 @@
export * from './sdig-contract-terms.js';
@@ -1,873 +0,0 @@
/**
* @file sdig-contract-terms.ts
* @description Contract terms editor - tabbed container for financial, time, and obligation terms
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-terms': SdigContractTerms;
}
}
// Term types
type TTermTab = 'financial' | 'time' | 'obligations';
interface ITermTabConfig {
id: TTermTab;
label: string;
icon: string;
description: string;
}
const TERM_TABS: ITermTabConfig[] = [
{ id: 'financial', label: 'Financial Terms', icon: 'lucide:Banknote', description: 'Payment schedules, rates, and penalties' },
{ id: 'time', label: 'Time Terms', icon: 'lucide:Calendar', description: 'Milestones, deadlines, and renewal' },
{ id: 'obligations', label: 'Obligations', icon: 'lucide:CheckSquare', description: 'Deliverables, SLAs, and warranties' },
];
// Extended contract terms interfaces (for future interface updates)
interface IPaymentScheduleItem {
id: string;
description: string;
amount: number;
currency: string;
dueDate: string;
status: 'pending' | 'paid' | 'overdue';
}
interface IMilestone {
id: string;
name: string;
description: string;
dueDate: string;
status: 'pending' | 'in_progress' | 'completed' | 'delayed';
dependencies: string[];
}
interface IObligation {
id: string;
description: string;
responsibleParty: string;
deadline: string;
status: 'pending' | 'completed' | 'waived';
}
@customElement('sdig-contract-terms')
export class SdigContractTerms extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-terms
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-terms>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.terms-container {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Section card */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
/* Tab navigation */
.tabs-nav {
display: flex;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
}
.tab-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 24px;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.15s ease;
}
.tab-btn:hover {
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
}
.tab-btn.active {
color: ${cssManager.bdTheme('#111111', '#fafafa')};
border-bottom-color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
}
.tab-btn dees-icon {
font-size: 16px;
}
/* Tab content */
.tab-content {
padding: 24px;
}
/* Sub-sections */
.sub-section {
margin-bottom: 24px;
}
.sub-section:last-child {
margin-bottom: 0;
}
.sub-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.sub-section-title {
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.sub-section-description {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-top: 4px;
}
/* Form groups */
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.form-input {
padding: 10px 12px;
font-size: 14px;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 6px;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.form-input:focus {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(96, 165, 250, 0.1)')};
}
.form-input::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
select.form-input {
cursor: pointer;
}
/* Data table */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.data-table th {
text-align: left;
padding: 12px 16px;
font-weight: 500;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.data-table td {
padding: 12px 16px;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.data-table tr:last-child td {
border-bottom: none;
}
.data-table tr:hover td {
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
}
/* Status badges */
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.status-badge.pending {
background: ${cssManager.bdTheme('#fef3c7', '#422006')};
color: ${cssManager.bdTheme('#92400e', '#fcd34d')};
}
.status-badge.paid,
.status-badge.completed {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#065f46', '#6ee7b7')};
}
.status-badge.overdue,
.status-badge.delayed {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
}
.status-badge.in_progress {
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
}
/* Amount display */
.amount {
font-weight: 600;
font-family: 'Roboto Mono', monospace;
}
.amount.positive {
color: ${cssManager.bdTheme('#059669', '#34d399')};
}
.amount.negative {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
/* Summary card */
.summary-card {
display: flex;
gap: 32px;
padding: 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-radius: 8px;
margin-bottom: 24px;
}
.summary-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.summary-label {
font-size: 12px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
text-transform: uppercase;
letter-spacing: 0.05em;
}
.summary-value {
font-size: 24px;
font-weight: 700;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.summary-value.currency {
font-family: 'Roboto Mono', monospace;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 20px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.empty-state p {
margin: 0 0 20px;
font-size: 14px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-sm {
padding: 6px 10px;
font-size: 12px;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.btn-ghost {
background: transparent;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.btn-ghost:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
/* Add row button */
.add-row {
display: flex;
justify-content: center;
padding: 16px;
border-top: 1px dashed ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
/* Info banner */
.info-banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
border: 1px solid ${cssManager.bdTheme('#bfdbfe', '#1e40af')};
border-radius: 8px;
margin-bottom: 24px;
}
.info-banner dees-icon {
font-size: 20px;
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
flex-shrink: 0;
}
.info-banner-content {
flex: 1;
}
.info-banner-title {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
margin-bottom: 4px;
}
.info-banner-text {
font-size: 13px;
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor activeTab: TTermTab = 'financial';
// Demo data for terms (will be replaced with actual contract data when interface is extended)
@state()
private accessor paymentSchedule: IPaymentScheduleItem[] = [
{ id: '1', description: 'Initial deposit', amount: 5000, currency: 'EUR', dueDate: '2024-02-01', status: 'paid' },
{ id: '2', description: 'Monthly payment - March', amount: 1000, currency: 'EUR', dueDate: '2024-03-01', status: 'paid' },
{ id: '3', description: 'Monthly payment - April', amount: 1000, currency: 'EUR', dueDate: '2024-04-01', status: 'pending' },
];
@state()
private accessor milestones: IMilestone[] = [
{ id: '1', name: 'Project Kickoff', description: 'Initial planning and setup', dueDate: '2024-02-15', status: 'completed', dependencies: [] },
{ id: '2', name: 'Phase 1 Delivery', description: 'First deliverable milestone', dueDate: '2024-03-15', status: 'in_progress', dependencies: ['1'] },
{ id: '3', name: 'Final Delivery', description: 'Complete project delivery', dueDate: '2024-05-01', status: 'pending', dependencies: ['2'] },
];
@state()
private accessor obligations: IObligation[] = [
{ id: '1', description: 'Provide access credentials', responsibleParty: 'employer', deadline: '2024-02-01', status: 'completed' },
{ id: '2', description: 'Submit monthly reports', responsibleParty: 'employee', deadline: '2024-03-01', status: 'pending' },
{ id: '3', description: 'Conduct quarterly review', responsibleParty: 'employer', deadline: '2024-04-01', status: 'pending' },
];
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleFieldChange(path: string, value: unknown) {
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path, value },
bubbles: true,
composed: true,
})
);
}
private handleTabChange(tab: TTermTab) {
this.activeTab = tab;
}
private handleAddPayment() {
const newPayment: IPaymentScheduleItem = {
id: `pay-${Date.now()}`,
description: 'New payment',
amount: 0,
currency: 'EUR',
dueDate: new Date().toISOString().split('T')[0],
status: 'pending',
};
this.paymentSchedule = [...this.paymentSchedule, newPayment];
}
private handleAddMilestone() {
const newMilestone: IMilestone = {
id: `ms-${Date.now()}`,
name: 'New Milestone',
description: '',
dueDate: new Date().toISOString().split('T')[0],
status: 'pending',
dependencies: [],
};
this.milestones = [...this.milestones, newMilestone];
}
private handleAddObligation() {
const newObligation: IObligation = {
id: `obl-${Date.now()}`,
description: 'New obligation',
responsibleParty: '',
deadline: new Date().toISOString().split('T')[0],
status: 'pending',
};
this.obligations = [...this.obligations, newObligation];
}
// ============================================================================
// HELPERS
// ============================================================================
private formatCurrency(amount: number, currency: string): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount);
}
private formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
private getTotalAmount(): number {
return this.paymentSchedule.reduce((sum, p) => sum + p.amount, 0);
}
private getPaidAmount(): number {
return this.paymentSchedule.filter((p) => p.status === 'paid').reduce((sum, p) => sum + p.amount, 0);
}
private getPartyName(roleId: string): string {
const role = this.contract?.availableRoles.find((r) => r.id === roleId);
return role?.name || roleId;
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<div class="terms-container">
<div class="section-card">
<!-- Tabs Navigation -->
<nav class="tabs-nav">
${TERM_TABS.map(
(tab) => html`
<button
class="tab-btn ${this.activeTab === tab.id ? 'active' : ''}"
@click=${() => this.handleTabChange(tab.id)}
>
<dees-icon .icon=${tab.icon}></dees-icon>
${tab.label}
</button>
`
)}
</nav>
<!-- Tab Content -->
<div class="tab-content">
${this.activeTab === 'financial'
? this.renderFinancialTerms()
: this.activeTab === 'time'
? this.renderTimeTerms()
: this.renderObligations()}
</div>
</div>
</div>
`;
}
private renderFinancialTerms(): TemplateResult {
const totalAmount = this.getTotalAmount();
const paidAmount = this.getPaidAmount();
const pendingAmount = totalAmount - paidAmount;
return html`
<!-- Summary -->
<div class="summary-card">
<div class="summary-item">
<span class="summary-label">Total Value</span>
<span class="summary-value currency">${this.formatCurrency(totalAmount, 'EUR')}</span>
</div>
<div class="summary-item">
<span class="summary-label">Paid</span>
<span class="summary-value currency" style="color: #059669;">${this.formatCurrency(paidAmount, 'EUR')}</span>
</div>
<div class="summary-item">
<span class="summary-label">Pending</span>
<span class="summary-value currency" style="color: #f59e0b;">${this.formatCurrency(pendingAmount, 'EUR')}</span>
</div>
</div>
<!-- Payment Schedule -->
<div class="sub-section">
<div class="sub-section-header">
<div>
<div class="sub-section-title">Payment Schedule</div>
<div class="sub-section-description">Scheduled payments and their status</div>
</div>
${!this.readonly
? html`
<button class="btn btn-secondary" @click=${this.handleAddPayment}>
<dees-icon .icon=${'lucide:Plus'}></dees-icon>
Add Payment
</button>
`
: ''}
</div>
${this.paymentSchedule.length > 0
? html`
<table class="data-table">
<thead>
<tr>
<th>Description</th>
<th>Amount</th>
<th>Due Date</th>
<th>Status</th>
${!this.readonly ? html`<th></th>` : ''}
</tr>
</thead>
<tbody>
${this.paymentSchedule.map(
(payment) => html`
<tr>
<td>${payment.description}</td>
<td><span class="amount">${this.formatCurrency(payment.amount, payment.currency)}</span></td>
<td>${this.formatDate(payment.dueDate)}</td>
<td><span class="status-badge ${payment.status}">${payment.status}</span></td>
${!this.readonly
? html`
<td>
<button class="btn btn-ghost btn-sm">
<dees-icon .icon=${'lucide:Pencil'}></dees-icon>
</button>
</td>
`
: ''}
</tr>
`
)}
</tbody>
</table>
`
: html`
<div class="empty-state">
<dees-icon .icon=${'lucide:Banknote'}></dees-icon>
<h4>No Payment Schedule</h4>
<p>Add payment terms to track financial obligations</p>
</div>
`}
</div>
`;
}
private renderTimeTerms(): TemplateResult {
const completedCount = this.milestones.filter((m) => m.status === 'completed').length;
const totalCount = this.milestones.length;
return html`
<!-- Summary -->
<div class="summary-card">
<div class="summary-item">
<span class="summary-label">Total Milestones</span>
<span class="summary-value">${totalCount}</span>
</div>
<div class="summary-item">
<span class="summary-label">Completed</span>
<span class="summary-value" style="color: #059669;">${completedCount}</span>
</div>
<div class="summary-item">
<span class="summary-label">Progress</span>
<span class="summary-value">${totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0}%</span>
</div>
</div>
<!-- Milestones -->
<div class="sub-section">
<div class="sub-section-header">
<div>
<div class="sub-section-title">Milestones</div>
<div class="sub-section-description">Key project milestones and deadlines</div>
</div>
${!this.readonly
? html`
<button class="btn btn-secondary" @click=${this.handleAddMilestone}>
<dees-icon .icon=${'lucide:Plus'}></dees-icon>
Add Milestone
</button>
`
: ''}
</div>
${this.milestones.length > 0
? html`
<table class="data-table">
<thead>
<tr>
<th>Milestone</th>
<th>Description</th>
<th>Due Date</th>
<th>Status</th>
${!this.readonly ? html`<th></th>` : ''}
</tr>
</thead>
<tbody>
${this.milestones.map(
(milestone) => html`
<tr>
<td><strong>${milestone.name}</strong></td>
<td>${milestone.description || '—'}</td>
<td>${this.formatDate(milestone.dueDate)}</td>
<td><span class="status-badge ${milestone.status}">${milestone.status.replace('_', ' ')}</span></td>
${!this.readonly
? html`
<td>
<button class="btn btn-ghost btn-sm">
<dees-icon .icon=${'lucide:Pencil'}></dees-icon>
</button>
</td>
`
: ''}
</tr>
`
)}
</tbody>
</table>
`
: html`
<div class="empty-state">
<dees-icon .icon=${'lucide:Calendar'}></dees-icon>
<h4>No Milestones</h4>
<p>Add milestones to track project progress</p>
</div>
`}
</div>
`;
}
private renderObligations(): TemplateResult {
const completedCount = this.obligations.filter((o) => o.status === 'completed').length;
return html`
<!-- Summary -->
<div class="summary-card">
<div class="summary-item">
<span class="summary-label">Total Obligations</span>
<span class="summary-value">${this.obligations.length}</span>
</div>
<div class="summary-item">
<span class="summary-label">Completed</span>
<span class="summary-value" style="color: #059669;">${completedCount}</span>
</div>
<div class="summary-item">
<span class="summary-label">Pending</span>
<span class="summary-value" style="color: #f59e0b;">${this.obligations.length - completedCount}</span>
</div>
</div>
<!-- Info banner -->
<div class="info-banner">
<dees-icon .icon=${'lucide:Info'}></dees-icon>
<div class="info-banner-content">
<div class="info-banner-title">Contractual Obligations</div>
<div class="info-banner-text">
Track responsibilities assigned to each party. Mark obligations as completed when fulfilled.
</div>
</div>
</div>
<!-- Obligations -->
<div class="sub-section">
<div class="sub-section-header">
<div>
<div class="sub-section-title">Party Obligations</div>
<div class="sub-section-description">Responsibilities and deliverables by party</div>
</div>
${!this.readonly
? html`
<button class="btn btn-secondary" @click=${this.handleAddObligation}>
<dees-icon .icon=${'lucide:Plus'}></dees-icon>
Add Obligation
</button>
`
: ''}
</div>
${this.obligations.length > 0
? html`
<table class="data-table">
<thead>
<tr>
<th>Obligation</th>
<th>Responsible Party</th>
<th>Deadline</th>
<th>Status</th>
${!this.readonly ? html`<th></th>` : ''}
</tr>
</thead>
<tbody>
${this.obligations.map(
(obligation) => html`
<tr>
<td>${obligation.description}</td>
<td>${this.getPartyName(obligation.responsibleParty)}</td>
<td>${this.formatDate(obligation.deadline)}</td>
<td><span class="status-badge ${obligation.status}">${obligation.status}</span></td>
${!this.readonly
? html`
<td>
<button class="btn btn-ghost btn-sm">
<dees-icon .icon=${'lucide:Pencil'}></dees-icon>
</button>
</td>
`
: ''}
</tr>
`
)}
</tbody>
</table>
`
: html`
<div class="empty-state">
<dees-icon .icon=${'lucide:CheckSquare'}></dees-icon>
<h4>No Obligations</h4>
<p>Add obligations to track party responsibilities</p>
</div>
`}
</div>
`;
}
}
@@ -1,8 +0,0 @@
/**
* @file index.ts
* @description Export barrel for sdig-contracteditor module
*/
export * from './sdig-contracteditor.js';
export * from './types.js';
export * from './state.js';
@@ -1,891 +0,0 @@
/**
* @file sdig-contracteditor.ts
* @description Main contract editor orchestrator component
*/
import {
DeesElement,
property,
state,
html,
customElement,
type TemplateResult,
css,
cssManager,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
import { createEditorStore, type TEditorStore } from './state.js';
import {
type TEditorSection,
type IEditorState,
EDITOR_SECTIONS,
type IContractChangeEventDetail,
type ISectionChangeEventDetail,
} from './types.js';
// Import sub-components
import '../sdig-contract-header/sdig-contract-header.js';
import '../sdig-contract-metadata/sdig-contract-metadata.js';
import '../sdig-contract-parties/sdig-contract-parties.js';
import '../sdig-contract-content/sdig-contract-content.js';
import '../sdig-contract-terms/sdig-contract-terms.js';
import '../sdig-contract-signatures/sdig-contract-signatures.js';
import '../sdig-contract-attachments/sdig-contract-attachments.js';
import '../sdig-contract-collaboration/sdig-contract-collaboration.js';
import '../sdig-contract-audit/sdig-contract-audit.js';
import '../sdig-collaboration-sidebar/sdig-collaboration-sidebar.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contracteditor': SdigContracteditor;
}
}
@customElement('sdig-contracteditor')
export class SdigContracteditor extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contracteditor
.contract=${plugins.sdDemodata.demoContract}
></sdig-contracteditor>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
height: 100%;
min-height: 600px;
}
.editor-container {
display: flex;
flex-direction: column;
height: 100%;
background: ${cssManager.bdTheme('#f8f9fa', '#09090b')};
border-radius: 8px;
overflow: hidden;
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
/* Header */
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.contract-title {
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin: 0;
}
/* shadcn-style badge */
.contract-status {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
border: 1px solid transparent;
background: ${cssManager.bdTheme('hsl(214 95% 93%)', 'hsl(214 95% 15%)')};
color: ${cssManager.bdTheme('hsl(214 95% 35%)', 'hsl(214 95% 70%)')};
border-color: ${cssManager.bdTheme('hsl(214 95% 80%)', 'hsl(214 95% 25%)')};
}
.contract-status.draft {
background: ${cssManager.bdTheme('hsl(48 96% 89%)', 'hsl(48 96% 15%)')};
color: ${cssManager.bdTheme('hsl(25 95% 30%)', 'hsl(48 96% 70%)')};
border-color: ${cssManager.bdTheme('hsl(48 96% 76%)', 'hsl(48 96% 25%)')};
}
.contract-status.executed {
background: ${cssManager.bdTheme('hsl(142 76% 90%)', 'hsl(142 76% 15%)')};
color: ${cssManager.bdTheme('hsl(142 76% 28%)', 'hsl(142 76% 65%)')};
border-color: ${cssManager.bdTheme('hsl(142 76% 75%)', 'hsl(142 76% 25%)')};
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.dirty-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.dirty-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f59e0b;
}
.collaborators {
display: flex;
align-items: center;
gap: -8px;
}
.collaborator-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: white;
margin-left: -8px;
}
.collaborator-avatar:first-child {
margin-left: 0;
}
/* Navigation Tabs */
.editor-nav {
display: flex;
align-items: center;
gap: 4px;
padding: 0 24px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
overflow-x: auto;
}
.nav-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.nav-tab:hover {
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
}
.nav-tab.active {
color: ${cssManager.bdTheme('#111111', '#fafafa')};
border-bottom-color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.nav-tab dees-icon {
font-size: 16px;
}
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: ${cssManager.bdTheme('#ef4444', '#dc2626')};
color: white;
}
/* Main Content Area */
.editor-main {
display: flex;
flex: 1;
overflow: hidden;
}
.editor-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.editor-sidebar {
width: 320px;
border-left: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
overflow-y: auto;
}
/* Section placeholder */
.section-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-placeholder dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.section-placeholder h3 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.section-placeholder p {
margin: 0;
font-size: 14px;
}
/* Footer */
.editor-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border-top: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.footer-left {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.footer-right {
display: flex;
align-items: center;
gap: 12px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.btn-ghost {
background: transparent;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.btn-ghost:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.btn-ghost.active {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Loading state */
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: ${cssManager.bdTheme('rgba(255,255,255,0.8)', 'rgba(0,0,0,0.8)')};
z-index: 100;
}
/* Overview section layout */
.overview-section {
display: flex;
flex-direction: column;
gap: 24px;
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor showSidebar: boolean = true;
@property({ type: String })
public accessor initialSection: TEditorSection = 'overview';
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor editorState: IEditorState | null = null;
// ============================================================================
// INSTANCE
// ============================================================================
private store: TEditorStore | null = null;
private unsubscribe: (() => void) | null = null;
private storeReady: Promise<void>;
private resolveStoreReady!: () => void;
constructor() {
super();
this.storeReady = new Promise((resolve) => {
this.resolveStoreReady = resolve;
});
}
// ============================================================================
// LIFECYCLE
// ============================================================================
public connectedCallback() {
super.connectedCallback();
this.initStore();
}
private async initStore() {
this.store = await createEditorStore();
this.unsubscribe = this.store.subscribe((state) => {
this.editorState = state;
});
// Set initial section
this.store.setActiveSection(this.initialSection);
this.resolveStoreReady();
// If contract was already set, apply it now
if (this.contract) {
this.store.setContract(this.contract);
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this.unsubscribe) {
this.unsubscribe();
}
}
public async updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('contract') && this.contract) {
await this.storeReady;
this.store?.setContract(this.contract);
}
}
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleSectionChange(section: TEditorSection) {
const previousSection = this.editorState?.activeSection || 'overview';
this.store?.setActiveSection(section);
this.dispatchEvent(
new CustomEvent<ISectionChangeEventDetail>('section-change', {
detail: { section, previousSection },
bubbles: true,
composed: true,
})
);
}
private handleSave() {
if (!this.editorState?.contract) return;
this.store?.setSaving(true);
this.dispatchEvent(
new CustomEvent('contract-save', {
detail: {
contract: this.editorState.contract,
isDraft: this.editorState.contract.lifecycle.currentStatus === 'draft',
},
bubbles: true,
composed: true,
})
);
}
private handleDiscard() {
this.store?.discardChanges();
this.dispatchEvent(
new CustomEvent('contract-discard', {
bubbles: true,
composed: true,
})
);
}
private handleUndo() {
this.store?.undo();
}
private handleRedo() {
this.store?.redo();
}
private handleCommentClick(e: CustomEvent) {
// Navigate to collaboration section and highlight comment
this.store?.setActiveSection('collaboration');
this.dispatchEvent(
new CustomEvent('comment-focus', {
detail: e.detail,
bubbles: true,
composed: true,
})
);
}
private handleSuggestionClick(e: CustomEvent) {
// Navigate to collaboration section and highlight suggestion
this.store?.setActiveSection('collaboration');
this.dispatchEvent(
new CustomEvent('suggestion-focus', {
detail: e.detail,
bubbles: true,
composed: true,
})
);
}
private handleSidebarAddComment(e: CustomEvent) {
this.dispatchEvent(
new CustomEvent('comment-added', {
detail: e.detail,
bubbles: true,
composed: true,
})
);
}
// ============================================================================
// PUBLIC API
// ============================================================================
/**
* Update a field in the contract
*/
public updateField(path: string, value: unknown, description?: string) {
this.store?.updateContract(path, value, description);
this.dispatchEvent(
new CustomEvent<IContractChangeEventDetail>('contract-change', {
detail: { path, value, source: 'user' },
bubbles: true,
composed: true,
})
);
}
/**
* Get current contract state
*/
public getContract(): plugins.sdInterfaces.IPortableContract | null {
return this.editorState?.contract || null;
}
/**
* Mark contract as saved externally
*/
public markSaved() {
this.store?.markSaved();
}
// ============================================================================
// RENDER HELPERS
// ============================================================================
private getStatusClass(status: string): string {
if (status === 'draft' || status === 'internal_review') return 'draft';
if (status === 'executed' || status === 'active') return 'executed';
return '';
}
private formatStatus(status: string): string {
return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
private handleFieldChange(e: CustomEvent<{ path: string; value: unknown }>) {
const { path, value } = e.detail;
this.updateField(path, value);
}
private renderSectionContent(): TemplateResult {
const section = this.editorState?.activeSection || 'overview';
const contract = this.editorState?.contract;
const sectionConfig = EDITOR_SECTIONS.find((s) => s.id === section);
// Render section based on active tab
switch (section) {
case 'overview':
return this.renderOverviewSection();
case 'parties':
return this.renderPartiesSection();
case 'content':
return this.renderContentSection();
case 'terms':
return this.renderTermsSection();
case 'signatures':
return this.renderSignaturesSection();
case 'attachments':
return this.renderAttachmentsSection();
case 'collaboration':
return this.renderCollaborationSection();
case 'audit':
return this.renderAuditSection();
default:
return this.renderPlaceholder(sectionConfig, 'This section is being implemented...');
}
}
private renderOverviewSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<div class="overview-section">
<sdig-contract-header
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-header>
<sdig-contract-metadata
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-metadata>
</div>
`;
}
private renderPartiesSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-parties
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-parties>
`;
}
private renderContentSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-content
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-content>
`;
}
private renderTermsSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-terms
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-terms>
`;
}
private renderSignaturesSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-signatures
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-signatures>
`;
}
private renderAttachmentsSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-attachments
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-attachments>
`;
}
private renderCollaborationSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-collaboration
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-collaboration>
`;
}
private renderAuditSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-audit
.contract=${contract}
></sdig-contract-audit>
`;
}
private renderPlaceholder(sectionConfig: typeof EDITOR_SECTIONS[0] | undefined, message: string): TemplateResult {
return html`
<div class="section-placeholder">
<dees-icon .icon=${sectionConfig?.icon || 'lucide:File'}></dees-icon>
<h3>${sectionConfig?.label || 'Section'}</h3>
<p>${message}</p>
</div>
`;
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
const contract = this.editorState?.contract;
const activeSection = this.editorState?.activeSection || 'overview';
const isDirty = this.editorState?.isDirty || false;
const isSaving = this.editorState?.isSaving || false;
const collaborators = this.editorState?.activeCollaborators || [];
return html`
<div class="editor-container">
<!-- Header -->
<div class="editor-header">
<div class="header-left">
<h1 class="contract-title">${contract?.title || 'Untitled Contract'}</h1>
${contract?.lifecycle?.currentStatus
? html`
<span class="contract-status ${this.getStatusClass(contract.lifecycle.currentStatus)}">
${this.formatStatus(contract.lifecycle.currentStatus)}
</span>
`
: ''}
</div>
<div class="header-right">
${isDirty
? html`
<div class="dirty-indicator">
<span class="dirty-dot"></span>
<span>Unsaved changes</span>
</div>
`
: ''}
${collaborators.length > 0
? html`
<div class="collaborators">
${collaborators.slice(0, 3).map(
(c) => html`
<div
class="collaborator-avatar"
style="background: ${c.color}"
title="${c.displayName}"
>
${c.displayName.charAt(0).toUpperCase()}
</div>
`
)}
${collaborators.length > 3
? html`
<div class="collaborator-avatar" style="background: #6b7280">
+${collaborators.length - 3}
</div>
`
: ''}
</div>
`
: ''}
<button class="btn btn-ghost" @click=${this.handleUndo} ?disabled=${!this.store?.canUndo()}>
<dees-icon .icon=${'lucide:Undo2'}></dees-icon>
</button>
<button class="btn btn-ghost" @click=${this.handleRedo} ?disabled=${!this.store?.canRedo()}>
<dees-icon .icon=${'lucide:Redo2'}></dees-icon>
</button>
<button
class="btn btn-ghost ${this.showSidebar ? 'active' : ''}"
@click=${() => (this.showSidebar = !this.showSidebar)}
title="${this.showSidebar ? 'Hide sidebar' : 'Show sidebar'}"
>
<dees-icon .icon=${'lucide:PanelRight'}></dees-icon>
</button>
</div>
</div>
<!-- Navigation -->
<nav class="editor-nav">
${EDITOR_SECTIONS.map(
(section) => html`
<button
class="nav-tab ${activeSection === section.id ? 'active' : ''}"
@click=${() => this.handleSectionChange(section.id)}
?disabled=${section.disabled}
>
<dees-icon .icon=${section.icon}></dees-icon>
<span>${section.label}</span>
${section.badge
? html`<span class="nav-badge">${section.badge}</span>`
: ''}
</button>
`
)}
</nav>
<!-- Main Content -->
<div class="editor-main">
<div class="editor-content">
${this.renderSectionContent()}
</div>
${this.showSidebar
? html`
<aside class="editor-sidebar">
<sdig-collaboration-sidebar
.contract=${contract}
@comment-click=${this.handleCommentClick}
@suggestion-click=${this.handleSuggestionClick}
@add-comment=${this.handleSidebarAddComment}
></sdig-collaboration-sidebar>
</aside>
`
: ''}
</div>
<!-- Footer -->
<div class="editor-footer">
<div class="footer-left">
${contract?.updatedAt
? html`<span>Last updated: ${new Date(contract.updatedAt).toLocaleString()}</span>`
: ''}
${contract?.versionHistory?.currentVersionId
? html`<span>Version: ${contract.versionHistory.currentVersionId}</span>`
: ''}
</div>
<div class="footer-right">
${isDirty
? html`
<button class="btn btn-secondary" @click=${this.handleDiscard}>
Discard
</button>
`
: ''}
<button
class="btn btn-primary"
@click=${this.handleSave}
?disabled=${!isDirty || isSaving}
>
${isSaving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
${this.editorState?.isLoading
? html`
<div class="loading-overlay">
<dees-spinner></dees-spinner>
</div>
`
: ''}
</div>
`;
}
}
@@ -1,407 +0,0 @@
/**
* @file state.ts
* @description Smartstate store for contract editor
*/
import { domtools } from '@design.estate/dees-element';
import type * as sdInterfaces from '@signature.digital/tools/interfaces';
import {
type IEditorState,
type TEditorSection,
type TEditorMode,
type IContractChange,
type IValidationError,
type IEditorUser,
createInitialEditorState,
} from './types.js';
// ============================================================================
// STATE STORE
// ============================================================================
/**
* Create a new editor state store instance
*/
export async function createEditorStore() {
const smartstate = new domtools.plugins.smartstate.Smartstate<{ editor: IEditorState }>();
// Initialize with default state (getStatePart is now async)
const statePart = await smartstate.getStatePart<IEditorState>('editor', createInitialEditorState(), 'soft');
// Create actions for state modifications
const setContractAction = statePart.createAction<{ contract: sdInterfaces.IPortableContract }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
contract: structuredClone(payload.contract),
originalContract: structuredClone(payload.contract),
isDirty: false,
undoStack: [],
redoStack: [],
})
);
const updateContractAction = statePart.createAction<{ path: string; value: unknown; description?: string; userId?: string }>(
async (statePartArg, payload) => {
const state = statePartArg.getState();
if (!state.contract) return state;
const previousValue = getNestedValue(state.contract, payload.path);
const change: IContractChange = {
id: crypto.randomUUID(),
timestamp: Date.now(),
path: payload.path,
previousValue,
newValue: payload.value,
description: payload.description || `Updated ${payload.path}`,
userId: payload.userId,
};
const updatedContract = setNestedValue(
structuredClone(state.contract),
payload.path,
payload.value
);
return {
...state,
contract: updatedContract,
isDirty: true,
undoStack: [...state.undoStack, change],
redoStack: [],
};
}
);
const setActiveSectionAction = statePart.createAction<{ section: TEditorSection }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
activeSection: payload.section,
})
);
const setEditorModeAction = statePart.createAction<{ mode: TEditorMode }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
editorMode: payload.mode,
})
);
const selectParagraphAction = statePart.createAction<{ paragraphId: string | null }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
selectedParagraphId: payload.paragraphId,
})
);
const selectPartyAction = statePart.createAction<{ partyId: string | null }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
selectedPartyId: payload.partyId,
})
);
const selectSignatureFieldAction = statePart.createAction<{ fieldId: string | null }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
selectedSignatureFieldId: payload.fieldId,
})
);
const undoAction = statePart.createAction<void>(
async (statePartArg) => {
const state = statePartArg.getState();
if (state.undoStack.length === 0 || !state.contract) return state;
const change = state.undoStack[state.undoStack.length - 1];
const updatedContract = setNestedValue(
structuredClone(state.contract),
change.path,
change.previousValue
);
return {
...state,
contract: updatedContract,
undoStack: state.undoStack.slice(0, -1),
redoStack: [...state.redoStack, change],
isDirty: state.undoStack.length > 1,
};
}
);
const redoAction = statePart.createAction<void>(
async (statePartArg) => {
const state = statePartArg.getState();
if (state.redoStack.length === 0 || !state.contract) return state;
const change = state.redoStack[state.redoStack.length - 1];
const updatedContract = setNestedValue(
structuredClone(state.contract),
change.path,
change.newValue
);
return {
...state,
contract: updatedContract,
undoStack: [...state.undoStack, change],
redoStack: state.redoStack.slice(0, -1),
isDirty: true,
};
}
);
const setLoadingAction = statePart.createAction<{ isLoading: boolean }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
isLoading: payload.isLoading,
})
);
const setSavingAction = statePart.createAction<{ isSaving: boolean }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
isSaving: payload.isSaving,
})
);
const markSavedAction = statePart.createAction<void>(
async (statePartArg) => {
const state = statePartArg.getState();
return {
...state,
originalContract: state.contract ? structuredClone(state.contract) : null,
isDirty: false,
isSaving: false,
};
}
);
const discardChangesAction = statePart.createAction<void>(
async (statePartArg) => {
const state = statePartArg.getState();
return {
...state,
contract: state.originalContract ? structuredClone(state.originalContract) : null,
isDirty: false,
undoStack: [],
redoStack: [],
};
}
);
const setValidationErrorsAction = statePart.createAction<{ errors: IValidationError[] }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
validationErrors: payload.errors,
})
);
const clearValidationErrorsAction = statePart.createAction<void>(
async (statePartArg) => ({
...statePartArg.getState(),
validationErrors: [],
})
);
const setCurrentUserAction = statePart.createAction<{ user: IEditorUser }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
currentUser: payload.user,
})
);
const setActiveCollaboratorsAction = statePart.createAction<{ collaborators: sdInterfaces.IUserPresence[] }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
activeCollaborators: payload.collaborators,
})
);
const addCollaboratorAction = statePart.createAction<{ collaborator: sdInterfaces.IUserPresence }>(
async (statePartArg, payload) => {
const state = statePartArg.getState();
if (state.activeCollaborators.find(c => c.userId === payload.collaborator.userId)) {
return state;
}
return {
...state,
activeCollaborators: [...state.activeCollaborators, payload.collaborator],
};
}
);
const removeCollaboratorAction = statePart.createAction<{ userId: string }>(
async (statePartArg, payload) => {
const state = statePartArg.getState();
return {
...state,
activeCollaborators: state.activeCollaborators.filter(c => c.userId !== payload.userId),
};
}
);
const updateCollaboratorAction = statePart.createAction<{ userId: string; updates: Partial<sdInterfaces.IUserPresence> }>(
async (statePartArg, payload) => {
const state = statePartArg.getState();
return {
...state,
activeCollaborators: state.activeCollaborators.map(c =>
c.userId === payload.userId ? { ...c, ...payload.updates } : c
),
};
}
);
return {
smartstate,
statePart,
// Getters
getContract: () => statePart.getState().contract,
getActiveSection: () => statePart.getState().activeSection,
isDirty: () => statePart.getState().isDirty,
isLoading: () => statePart.getState().isLoading,
isSaving: () => statePart.getState().isSaving,
// Contract operations
setContract: (contract: sdInterfaces.IPortableContract) => setContractAction.trigger({ contract }),
updateContract: (path: string, value: unknown, description?: string) => {
const state = statePart.getState();
return updateContractAction.trigger({ path, value, description, userId: state.currentUser?.userId });
},
// Navigation
setActiveSection: (section: TEditorSection) => setActiveSectionAction.trigger({ section }),
setEditorMode: (mode: TEditorMode) => setEditorModeAction.trigger({ mode }),
// Selection
selectParagraph: (paragraphId: string | null) => selectParagraphAction.trigger({ paragraphId }),
selectParty: (partyId: string | null) => selectPartyAction.trigger({ partyId }),
selectSignatureField: (fieldId: string | null) => selectSignatureFieldAction.trigger({ fieldId }),
// Undo/Redo
undo: () => undoAction.trigger(),
redo: () => redoAction.trigger(),
canUndo: () => statePart.getState().undoStack.length > 0,
canRedo: () => statePart.getState().redoStack.length > 0,
// Loading/Saving state
setLoading: (isLoading: boolean) => setLoadingAction.trigger({ isLoading }),
setSaving: (isSaving: boolean) => setSavingAction.trigger({ isSaving }),
markSaved: () => markSavedAction.trigger(),
// Discard changes
discardChanges: () => discardChangesAction.trigger(),
// Validation
setValidationErrors: (errors: IValidationError[]) => setValidationErrorsAction.trigger({ errors }),
clearValidationErrors: () => clearValidationErrorsAction.trigger(),
// User/Collaboration
setCurrentUser: (user: IEditorUser) => setCurrentUserAction.trigger({ user }),
setActiveCollaborators: (collaborators: sdInterfaces.IUserPresence[]) => setActiveCollaboratorsAction.trigger({ collaborators }),
addCollaborator: (collaborator: sdInterfaces.IUserPresence) => addCollaboratorAction.trigger({ collaborator }),
removeCollaborator: (userId: string) => removeCollaboratorAction.trigger({ userId }),
updateCollaborator: (userId: string, updates: Partial<sdInterfaces.IUserPresence>) => updateCollaboratorAction.trigger({ userId, updates }),
// Subscribe to state changes (using new API)
subscribe: (callback: (state: IEditorState) => void) => {
const subscription = statePart.select().subscribe(callback);
return () => subscription.unsubscribe();
},
};
}
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
/**
* Get nested value from object by path
*/
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
const keys = path.split('.');
let current: unknown = obj;
for (const key of keys) {
if (current === null || current === undefined) return undefined;
if (typeof current !== 'object') return undefined;
// Handle array index
const arrayMatch = key.match(/^(\w+)\[(\d+)\]$/);
if (arrayMatch) {
const [, arrayKey, index] = arrayMatch;
current = (current as Record<string, unknown>)[arrayKey];
if (Array.isArray(current)) {
current = current[parseInt(index, 10)];
} else {
return undefined;
}
} else {
current = (current as Record<string, unknown>)[key];
}
}
return current;
}
/**
* Set nested value in object by path (immutably)
*/
function setNestedValue<T extends Record<string, unknown>>(
obj: T,
path: string,
value: unknown
): T {
const keys = path.split('.');
const result = { ...obj } as Record<string, unknown>;
let current = result;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
// Handle array index
const arrayMatch = key.match(/^(\w+)\[(\d+)\]$/);
if (arrayMatch) {
const [, arrayKey, index] = arrayMatch;
const arr = [...((current[arrayKey] as unknown[]) || [])];
if (i === keys.length - 2) {
arr[parseInt(index, 10)] = value;
current[arrayKey] = arr;
return result as T;
} else {
arr[parseInt(index, 10)] = { ...(arr[parseInt(index, 10)] as Record<string, unknown> || {}) };
current[arrayKey] = arr;
current = arr[parseInt(index, 10)] as Record<string, unknown>;
}
} else {
if (typeof current[key] !== 'object' || current[key] === null) {
current[key] = {};
} else {
current[key] = { ...(current[key] as Record<string, unknown>) };
}
current = current[key] as Record<string, unknown>;
}
}
const lastKey = keys[keys.length - 1];
const arrayMatch = lastKey.match(/^(\w+)\[(\d+)\]$/);
if (arrayMatch) {
const [, arrayKey, index] = arrayMatch;
const arr = [...((current[arrayKey] as unknown[]) || [])];
arr[parseInt(index, 10)] = value;
current[arrayKey] = arr;
} else {
current[lastKey] = value;
}
return result as T;
}
/**
* Type for editor store
*/
export type TEditorStore = Awaited<ReturnType<typeof createEditorStore>>;
@@ -1,228 +0,0 @@
/**
* @file types.ts
* @description Editor-specific types and event interfaces
*/
import type * as sdInterfaces from '@signature.digital/tools/interfaces';
// ============================================================================
// EDITOR NAVIGATION
// ============================================================================
/**
* Available editor sections/tabs
*/
export type TEditorSection =
| 'overview'
| 'parties'
| 'content'
| 'terms'
| 'signatures'
| 'attachments'
| 'collaboration'
| 'audit';
/**
* Section configuration
*/
export interface IEditorSectionConfig {
id: TEditorSection;
label: string;
icon: string;
badge?: number | string;
disabled?: boolean;
}
/**
* Default section configurations
*/
export const EDITOR_SECTIONS: IEditorSectionConfig[] = [
{ id: 'overview', label: 'Overview', icon: 'lucide:FileText' },
{ id: 'parties', label: 'Parties & Roles', icon: 'lucide:Users' },
{ id: 'content', label: 'Content', icon: 'lucide:FileEdit' },
{ id: 'terms', label: 'Terms', icon: 'lucide:Calculator' },
{ id: 'signatures', label: 'Signatures', icon: 'lucide:PenTool' },
{ id: 'attachments', label: 'Attachments', icon: 'lucide:Paperclip' },
{ id: 'collaboration', label: 'Collaboration', icon: 'lucide:MessageCircle' },
{ id: 'audit', label: 'Audit & History', icon: 'lucide:History' },
];
// ============================================================================
// EDITOR STATE
// ============================================================================
/**
* Current user in the editor
*/
export interface IEditorUser {
userId: string;
displayName: string;
email: string;
avatarUrl?: string;
color: string;
}
/**
* Editor mode
*/
export type TEditorMode = 'edit' | 'view' | 'review' | 'sign';
/**
* Editor state interface
*/
export interface IEditorState {
// Contract data
contract: sdInterfaces.IPortableContract | null;
originalContract: sdInterfaces.IPortableContract | null;
// UI state
activeSection: TEditorSection;
editorMode: TEditorMode;
isDirty: boolean;
isSaving: boolean;
isLoading: boolean;
// Selection state
selectedParagraphId: string | null;
selectedPartyId: string | null;
selectedSignatureFieldId: string | null;
// Collaboration
currentUser: IEditorUser | null;
activeCollaborators: sdInterfaces.IUserPresence[];
// Validation
validationErrors: IValidationError[];
// History
undoStack: IContractChange[];
redoStack: IContractChange[];
}
/**
* Initial editor state factory
*/
export function createInitialEditorState(): IEditorState {
return {
contract: null,
originalContract: null,
activeSection: 'overview',
editorMode: 'edit',
isDirty: false,
isSaving: false,
isLoading: false,
selectedParagraphId: null,
selectedPartyId: null,
selectedSignatureFieldId: null,
currentUser: null,
activeCollaborators: [],
validationErrors: [],
undoStack: [],
redoStack: [],
};
}
// ============================================================================
// CHANGE TRACKING
// ============================================================================
/**
* Contract change for undo/redo
*/
export interface IContractChange {
id: string;
timestamp: number;
path: string;
previousValue: unknown;
newValue: unknown;
description: string;
userId?: string;
}
/**
* Validation error
*/
export interface IValidationError {
path: string;
message: string;
severity: 'error' | 'warning' | 'info';
fieldLabel?: string;
}
// ============================================================================
// EVENTS
// ============================================================================
/**
* Contract change event detail
*/
export interface IContractChangeEventDetail {
path: string;
value: unknown;
previousValue?: unknown;
source?: 'user' | 'collaboration' | 'system';
}
/**
* Section change event detail
*/
export interface ISectionChangeEventDetail {
section: TEditorSection;
previousSection: TEditorSection;
}
/**
* Save event detail
*/
export interface ISaveEventDetail {
contract: sdInterfaces.IPortableContract;
isDraft: boolean;
}
/**
* Custom event types
*/
export interface IEditorEvents {
'contract-change': CustomEvent<IContractChangeEventDetail>;
'section-change': CustomEvent<ISectionChangeEventDetail>;
'contract-save': CustomEvent<ISaveEventDetail>;
'contract-discard': CustomEvent<void>;
'validation-error': CustomEvent<IValidationError[]>;
}
// ============================================================================
// UTILITY TYPES
// ============================================================================
/**
* Deep path type for nested object access
*/
export type TDeepPath<T, K extends keyof T = keyof T> = K extends string
? T[K] extends Record<string, unknown>
? `${K}` | `${K}.${TDeepPath<T[K]>}`
: `${K}`
: never;
/**
* Contract field path
*/
export type TContractPath = string; // Simplified for runtime use
/**
* Field metadata for UI rendering
*/
export interface IFieldMetadata {
path: TContractPath;
label: string;
description?: string;
required: boolean;
type: 'text' | 'textarea' | 'number' | 'date' | 'select' | 'multiselect' | 'checkbox' | 'custom';
options?: Array<{ value: string; label: string }>;
validation?: {
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
};
}
+8 -6
View File
@@ -1,5 +1,4 @@
import { DeesElement, property, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
import { DeesElement, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
declare global {
interface HTMLElementTagNameMap {
@@ -12,6 +11,7 @@ export class SignBox extends DeesElement {
public static demo = () => html`
<sdig-signbox></sdig-signbox>
`;
public static demoGroups = ['Signature Digital Primitives'];
constructor() {
super();
@@ -80,17 +80,19 @@ export class SignBox extends DeesElement {
<sdig-signpad></sdig-signpad>
<div class="actions">
<div class="button" @click=${async () => {
await this.shadowRoot.querySelector('sdig-signpad').clear();
await this.shadowRoot?.querySelector('sdig-signpad')?.clear();
}}>
Clear
</div>
<div class="button" @click=${async () => {
await this.shadowRoot.querySelector('sdig-signpad').undo();
await this.shadowRoot?.querySelector('sdig-signpad')?.undo();
}}>
Undo
</div>
<div class="button" @click=${async () => {
const signature = await this.shadowRoot.querySelector('sdig-signpad').toData();
const signaturePad = this.shadowRoot?.querySelector('sdig-signpad');
if (!signaturePad) return;
const signature = await signaturePad.toData();
this.dispatchEvent(new CustomEvent('signature', {
detail: {
signature,
@@ -104,4 +106,4 @@ export class SignBox extends DeesElement {
</div>
`;
}
}
}
+17 -12
View File
@@ -1,4 +1,4 @@
import { DeesElement, property, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
import { DeesElement, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
@@ -12,6 +12,7 @@ export class SignPad extends DeesElement {
public static demo = () => html`
<sdig-signpad></sdig-signpad>
`;
public static demoGroups = ['Signature Digital Primitives'];
constructor() {
@@ -59,11 +60,14 @@ export class SignPad extends DeesElement {
`;
}
public signaturePad: typeof plugins.signaturePad.prototype;
public signaturePad?: typeof plugins.signaturePad.prototype;
public async firstUpdated() {
const domtools = await this.domtoolsPromise;
await this.domtoolsPromise;
const mainbox = this.shadowRoot?.querySelector('.mainbox');
if (!mainbox) return;
const canvas = document.createElement('canvas');
this.shadowRoot.querySelector('.mainbox').appendChild(canvas);
mainbox.appendChild(canvas);
await this.resizeCanvas();
this.signaturePad = new plugins.signaturePad(canvas, {
@@ -72,10 +76,11 @@ export class SignPad extends DeesElement {
}
public async resizeCanvas() {
const mainbox = this.shadowRoot.querySelector('.mainbox');
const mainbox = this.shadowRoot?.querySelector('.mainbox');
const canvas = this.shadowRoot?.querySelector('canvas');
if (!mainbox || !canvas) return;
const mainboxWidth = mainbox.clientWidth;
const mainboxHeight = mainbox.clientHeight;
const canvas = this.shadowRoot.querySelector('canvas');
canvas.width = mainboxWidth;
canvas.height = mainboxHeight;
if (this.signaturePad) {
@@ -84,22 +89,22 @@ export class SignPad extends DeesElement {
}
public async clear() {
this.signaturePad.clear();
this.signaturePad?.clear();
}
public async toData() {
const returnData = this.signaturePad.toData();
const returnData = this.signaturePad?.toData() || [];
return returnData;
}
public async fromData(dataArrayArg: any[]) {
this.signaturePad.fromData(dataArrayArg);
this.signaturePad?.fromData(dataArrayArg);
}
public async toSVG() {
return this.signaturePad.toSVG({
return this.signaturePad?.toSVG({
includeBackgroundColor: false,
});
}) || '';
}
public async undo() {
@@ -107,4 +112,4 @@ export class SignPad extends DeesElement {
data.pop();
await this.fromData(data);
}
}
}
+8
View File
@@ -0,0 +1,8 @@
export * from './sdig-workspace.shared.js';
export * from './sdig-workspace-inbox.js';
export * from './sdig-workspace-compose.js';
export * from './sdig-workspace-sign.js';
export * from './sdig-workspace-audit.js';
export * from './sdig-workspace-developers.js';
export * from './sdig-workspace-placeholder.js';
export * from './sdig-workspace.js';
@@ -0,0 +1,37 @@
import { DeesElement, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
import { actionButton, demoRecipients, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame } from './sdig-workspace.shared.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-workspace-audit': SdigWorkspaceAudit;
}
}
@customElement('sdig-workspace-audit')
export class SdigWorkspaceAudit extends DeesElement {
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-audit></sdig-workspace-audit>`);
public static demoGroups = ['Signature Digital Workspace'];
public static styles = [workspaceBaseStyles, css`
.audit-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 20px; }
.event-row { display: grid; grid-template-columns: 24px 180px 1fr 200px; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border-subtle); align-items: center; }
@media (max-width: 920px) { .audit-grid { grid-template-columns: 1fr; } .event-row { grid-template-columns: 24px 1fr; } .event-row .hide-mobile { display: none; } }
`];
public render(): TemplateResult {
const events = [
['2026-05-02 14:32:18 UTC', 'Sarah Chen', 'Document signed', '81.221.4.18 · Brussels, BE', '0x4a7b…f29c', 'success'],
['2026-05-02 14:31:54 UTC', 'Sarah Chen', 'Signature adopted (typed)', '81.221.4.18 · Brussels, BE', '0x4a7b…f29c', 'info'],
['2026-05-02 14:28:02 UTC', 'Sarah Chen', 'Document opened', '81.221.4.18 · Brussels, BE', '', 'default'],
['2026-05-02 11:02:11 UTC', 'Philipp K.', 'Document sent for signature', '92.42.114.7 · Berlin, DE', '0x1c8a…3b6f', 'info'],
['2026-05-02 10:54:22 UTC', 'Philipp K.', 'Document created', '92.42.114.7 · Berlin, DE', '0x1c8a…3b6f', 'default'],
];
return html`
${topBar({ breadcrumb: ['signature.digital', 'Inbox', 'doc_8mK3pL', 'Audit Trail'], title: 'Audit Trail', subtitle: pill('completed · cryptographically sealed', 'success', true), actions: html`${actionButton('Certificate (PDF)', 'outline', 'download')}${actionButton('Verify on chain', 'outline', 'hash')}` })}
<div class="content-scroll audit-grid">
<div class="card"><div style="height: 36px; padding: 0 16px; border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; justify-content: space-between;"><span style="font-size: 12px; font-weight: 600;">Event log</span><span class="mono" style="font-size: 10px; color: var(--text-muted);">${events.length} events · immutable</span></div>${events.map((event) => html`<div class="event-row"><div><span style="display: block; width: 8px; height: 8px; border-radius: 50%; background: ${event[5] === 'success' ? 'var(--success)' : event[5] === 'info' ? 'var(--accent)' : 'var(--text-dim)'};"></span></div><div class="mono hide-mobile" style="font-size: 11px; color: var(--text-muted);">${event[0]}</div><div><div style="font-size: 12px; font-weight: 500;">${event[2]}</div><div style="font-size: 10px; color: var(--text-muted); margin-top: 2px;">by ${event[1]} ${event[4] ? html`<span class="mono" style="color: var(--accent); margin-left: 8px;">${event[4]}</span>` : ''}</div></div><div class="mono hide-mobile" style="font-size: 10px; color: var(--text-muted); text-align: right;">${event[3]}</div></div>`)}</div>
<div style="display: flex; flex-direction: column; gap: 16px;"><div class="card" style="padding: 16px;"><div class="label-upper">Document hash</div><div class="mono" style="font-size: 11px; color: var(--accent); word-break: break-all; line-height: 1.5; padding: 10px; background: var(--bg-el); border-radius: 4px; border: 1px solid var(--border-subtle);">0x4a7b8f29c91e3d2a5b6c8e0f1d3c5a7b9d2e4f6a8c1e3d5f7b9c1e3a5b7d9f0e</div></div><div class="card" style="padding: 16px;"><div class="label-upper">Signers</div>${demoRecipients.map((recipient) => html`<div class="recipient-line"><span class="avatar" style="background: ${recipient.color};">${recipient.name.split(' ').map((part) => part[0]).slice(0, 2).join('')}</span><div style="flex: 1;"><div style="font-size: 12px;">${recipient.name}</div><div class="mono" style="font-size: 10px; color: var(--text-muted);">${recipient.email}</div></div>${icon('check', 12)}</div>`)}</div><div class="card" style="padding: 16px; border-color: rgba(34,197,94,0.2);"><div style="display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--success); margin-bottom: 6px;">${icon('shield', 13)} eIDAS Qualified · ESIGN Act compliant</div><div style="font-size: 11px; color: var(--text-muted); line-height: 1.55;">Open-source verifier available. Anyone can independently validate this signature against the public ledger.</div></div></div>
</div>
`;
}
}
@@ -0,0 +1,369 @@
import { DeesElement, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
import { actionButton, demoFields, demoRecipients, fakeDocument, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame, type IFieldPlacement, type IRecipient } from './sdig-workspace.shared.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-workspace-compose': SdigWorkspaceCompose;
}
}
type TFieldDefinition = {
type: IFieldPlacement['type'];
icon: string;
label: string;
w: number;
h: number;
};
type TResizeHandle = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw';
type TFieldInteraction = {
fieldId: string;
mode: 'move' | 'resize';
handle?: TResizeHandle;
startClientX: number;
startClientY: number;
startField: IFieldPlacement;
pageWidth: number;
pageHeight: number;
};
const fieldDefinitions: TFieldDefinition[] = [
{ type: 'signature', icon: 'sign', label: 'Signature', w: 200, h: 50 },
{ type: 'initials', icon: 'type', label: 'Initials', w: 120, h: 32 },
{ type: 'date', icon: 'calendar', label: 'Date', w: 120, h: 32 },
{ type: 'text', icon: 'type', label: 'Text field', w: 220, h: 32 },
{ type: 'check', icon: 'check', label: 'Checkbox', w: 120, h: 32 },
];
const resizeHandles: TResizeHandle[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'];
@customElement('sdig-workspace-compose')
export class SdigWorkspaceCompose extends DeesElement {
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-compose></sdig-workspace-compose>`);
public static demoGroups = ['Signature Digital Workspace'];
@state() private accessor step: number = 2;
@state() private accessor activeRecipient: number = 0;
@state() private accessor draggedRecipientId: number | null = null;
@state() private accessor selectedFieldId: string | null = null;
@state() private accessor recipients: IRecipient[] = [...demoRecipients];
@state() private accessor fields: IFieldPlacement[] = [...demoFields];
private draggedFieldDefinition: TFieldDefinition | null = null;
private draggedFieldGrabOffset: { x: number; y: number } | null = null;
private fieldInteraction: TFieldInteraction | null = null;
public static styles = [workspaceBaseStyles, css`
.stepper { height: 44px; flex-shrink: 0; border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; padding: 0 24px; gap: 24px; overflow-x: auto; }
.step { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-muted); white-space: nowrap; background: transparent; }
.step-number { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-muted); font-size: 10px; font-weight: 700; }
.step.active { color: var(--text); font-weight: 500; }
.step.active .step-number { background: var(--accent); border-color: var(--accent); color: white; }
.step.done .step-number { background: var(--success); border-color: var(--success); color: white; }
.compose-workspace { flex: 1; display: flex; overflow: hidden; }
.palette { width: 260px; border-right: 1px solid var(--border-subtle); padding: 16px; background: var(--bg-el); overflow-y: auto; flex-shrink: 0; }
.right-panel { width: 280px; border-left: 1px solid var(--border-subtle); padding: 16px; background: var(--bg-el); overflow-y: auto; flex-shrink: 0; }
.field-tool { width: var(--tool-w); height: var(--tool-h); display: flex; align-items: center; gap: 8px; padding: 0 10px; background: color-mix(in srgb, var(--recipient-color) 8%, var(--bg-card)); border: 1.5px dashed var(--recipient-color); border-radius: 4px; font-size: 12px; color: var(--recipient-color); margin-bottom: 8px; cursor: grab; }
.field-tool:active { cursor: grabbing; }
.field-tool span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.swatch { width: 10px; height: 10px; border-radius: 2px; background: var(--recipient-color, var(--accent)); flex-shrink: 0; }
.document-stage { flex: 1; overflow: auto; background: hsl(0 0% 8%); display: flex; flex-direction: column; align-items: center; padding: 32px; gap: 20px; }
:host-context(sdig-workspace[theme='light']) .document-stage { background: hsl(0 0% 92%); }
.recipient-line { cursor: grab; }
.recipient-line.dragging { opacity: 0.45; border-color: var(--accent); }
.page-drop-target { outline: 1px dashed transparent; outline-offset: 8px; }
.page-drop-target.drag-over { outline-color: var(--accent); }
.field-box { user-select: none; touch-action: none; }
.field-box.selected { z-index: 5; cursor: move; }
.field-content { width: 100%; height: 100%; display: flex; align-items: center; gap: 6px; pointer-events: none; overflow: hidden; }
.field-content span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.resize-handle { position: absolute; z-index: 2; width: 9px; height: 9px; border-radius: 50%; background: var(--bg-card); border: 1.5px solid var(--field-color); box-shadow: 0 0 0 2px var(--bg-card); touch-action: none; }
.resize-handle.n { top: -6px; left: 50%; transform: translateX(-50%); cursor: ns-resize; }
.resize-handle.ne { top: -6px; right: -6px; cursor: nesw-resize; }
.resize-handle.e { right: -6px; top: 50%; transform: translateY(-50%); cursor: ew-resize; }
.resize-handle.se { right: -6px; bottom: -6px; cursor: nwse-resize; }
.resize-handle.s { bottom: -6px; left: 50%; transform: translateX(-50%); cursor: ns-resize; }
.resize-handle.sw { left: -6px; bottom: -6px; cursor: nesw-resize; }
.resize-handle.w { left: -6px; top: 50%; transform: translateY(-50%); cursor: ew-resize; }
.resize-handle.nw { left: -6px; top: -6px; cursor: nwse-resize; }
.field-editor { margin-top: 16px; padding: 12px; }
.field-editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.field-control { display: flex; flex-direction: column; gap: 4px; font-size: 10px; color: var(--text-muted); }
.field-control.full { grid-column: 1 / -1; }
.field-control input, .field-control select { width: 100%; height: 30px; padding: 0 8px; border: 1px solid var(--border); border-radius: 5px; background: var(--bg-input); color: var(--text); font-size: 12px; outline: none; }
.field-control input:focus, .field-control select:focus { border-color: var(--accent); }
@media (max-width: 920px) { .compose-workspace { flex-direction: column; overflow: auto; } .palette, .right-panel { width: 100%; border: 0; border-bottom: 1px solid var(--border-subtle); } .document-page { width: 560px; } }
`];
public disconnectedCallback = async () => {
this.stopFieldInteraction();
await super.disconnectedCallback();
};
private recipientColor(id: number): string {
return this.recipients.find((recipient) => recipient.id === id)?.color || 'var(--accent)';
}
private fieldIcon(type: IFieldPlacement['type']): string {
if (type === 'signature') return 'sign';
if (type === 'date') return 'calendar';
if (type === 'check') return 'check';
return 'type';
}
private fieldDefinition(type: IFieldPlacement['type']): TFieldDefinition {
return fieldDefinitions.find((definition) => definition.type === type) || fieldDefinitions[0];
}
private clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
private updateField(fieldId: string, patch: Partial<IFieldPlacement>) {
this.fields = this.fields.map((field) => field.id === fieldId ? { ...field, ...patch } : field);
}
private updateSelectedField(patch: Partial<IFieldPlacement>) {
if (!this.selectedFieldId) return;
this.updateField(this.selectedFieldId, patch);
}
private updateSelectedFieldNumber(property: 'x' | 'y' | 'w' | 'h', event: Event) {
const value = Number((event.target as HTMLInputElement).value);
if (!Number.isFinite(value)) return;
const min = property === 'w' || property === 'h' ? 16 : 0;
this.updateSelectedField({ [property]: Math.max(min, Math.round(value)) } as Partial<IFieldPlacement>);
}
private resetSelectedFieldSize(field: IFieldPlacement) {
const definition = this.fieldDefinition(field.type);
this.updateSelectedField({ w: definition.w, h: definition.h });
}
private removeSelectedField() {
if (!this.selectedFieldId) return;
this.fields = this.fields.filter((field) => field.id !== this.selectedFieldId);
this.selectedFieldId = null;
}
private handleDocumentClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
if (target?.closest('.field-box')) return;
this.selectedFieldId = null;
};
private startFieldInteraction(event: PointerEvent, field: IFieldPlacement, mode: TFieldInteraction['mode'], handle?: TResizeHandle) {
if (event.button !== 0) return;
const page = this.shadowRoot?.querySelector('.document-page') as HTMLElement | null;
if (!page) return;
const pageRect = page.getBoundingClientRect();
this.selectedFieldId = field.id;
this.fieldInteraction = {
fieldId: field.id,
mode,
handle,
startClientX: event.clientX,
startClientY: event.clientY,
startField: { ...field },
pageWidth: pageRect.width,
pageHeight: pageRect.height,
};
event.preventDefault();
event.stopPropagation();
window.addEventListener('pointermove', this.handleFieldPointerMove, { passive: false });
window.addEventListener('pointerup', this.stopFieldInteraction);
window.addEventListener('pointercancel', this.stopFieldInteraction);
}
private startFieldMove(event: PointerEvent, field: IFieldPlacement) {
this.startFieldInteraction(event, field, 'move');
}
private startFieldResize(event: PointerEvent, field: IFieldPlacement, handle: TResizeHandle) {
this.startFieldInteraction(event, field, 'resize', handle);
}
private handleFieldPointerMove = (event: PointerEvent) => {
if (!this.fieldInteraction) return;
event.preventDefault();
const interaction = this.fieldInteraction;
const dx = event.clientX - interaction.startClientX;
const dy = event.clientY - interaction.startClientY;
const start = interaction.startField;
if (interaction.mode === 'move') {
this.updateField(interaction.fieldId, {
x: Math.round(this.clamp(start.x + dx, 0, interaction.pageWidth - start.w)),
y: Math.round(this.clamp(start.y + dy, 0, interaction.pageHeight - start.h)),
});
return;
}
const minWidth = 32;
const minHeight = 24;
let x = start.x;
let y = start.y;
let w = start.w;
let h = start.h;
const handle = interaction.handle || 'se';
if (handle.includes('e')) {
w = this.clamp(start.w + dx, minWidth, interaction.pageWidth - start.x);
}
if (handle.includes('s')) {
h = this.clamp(start.h + dy, minHeight, interaction.pageHeight - start.y);
}
if (handle.includes('w')) {
x = this.clamp(start.x + dx, 0, start.x + start.w - minWidth);
w = start.x + start.w - x;
}
if (handle.includes('n')) {
y = this.clamp(start.y + dy, 0, start.y + start.h - minHeight);
h = start.y + start.h - y;
}
this.updateField(interaction.fieldId, {
x: Math.round(x),
y: Math.round(y),
w: Math.round(w),
h: Math.round(h),
});
};
private stopFieldInteraction = () => {
this.fieldInteraction = null;
window.removeEventListener('pointermove', this.handleFieldPointerMove);
window.removeEventListener('pointerup', this.stopFieldInteraction);
window.removeEventListener('pointercancel', this.stopFieldInteraction);
};
private reorderRecipient(targetId: number) {
if (this.draggedRecipientId === null || this.draggedRecipientId === targetId) return;
const next = [...this.recipients];
const fromIndex = next.findIndex((recipient) => recipient.id === this.draggedRecipientId);
const toIndex = next.findIndex((recipient) => recipient.id === targetId);
if (fromIndex === -1 || toIndex === -1) return;
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
this.recipients = next.map((recipient, index) => ({ ...recipient, order: index + 1 }));
this.draggedRecipientId = null;
}
private addFieldFromDrop(event: DragEvent) {
event.preventDefault();
const page = event.currentTarget as HTMLElement;
page.classList.remove('drag-over');
const transferredType = event.dataTransfer?.getData('application/x-signature-field') as IFieldPlacement['type'];
if (!this.draggedFieldDefinition && !transferredType) return;
const definition = this.draggedFieldDefinition || this.fieldDefinition(transferredType);
const transferredOffset = event.dataTransfer?.getData('application/x-signature-field-offset');
const offset = this.draggedFieldGrabOffset || (transferredOffset ? JSON.parse(transferredOffset) as { x: number; y: number } : { x: definition.w / 2, y: definition.h / 2 });
const rect = page.getBoundingClientRect();
const x = Math.round(event.clientX - rect.left - offset.x);
const y = Math.round(event.clientY - rect.top - offset.y);
const nextField: IFieldPlacement = {
id: `field_${Date.now()}`,
type: definition.type,
x: Math.max(0, Math.min(Math.max(0, rect.width - definition.w), x)),
y: Math.max(0, Math.min(Math.max(0, rect.height - definition.h), y)),
w: definition.w,
h: definition.h,
page: 1,
recipient: this.activeRecipient,
label: definition.label,
};
this.fields = [...this.fields, nextField];
this.selectedFieldId = nextField.id;
this.draggedFieldDefinition = null;
this.draggedFieldGrabOffset = null;
}
private startFieldToolDrag(event: DragEvent, fieldType: TFieldDefinition) {
const toolRect = (event.currentTarget as HTMLElement).getBoundingClientRect();
const offset = {
x: Math.round(event.clientX - toolRect.left),
y: Math.round(event.clientY - toolRect.top),
};
this.draggedFieldDefinition = fieldType;
this.draggedFieldGrabOffset = offset;
event.dataTransfer?.setData('application/x-signature-field', fieldType.type);
event.dataTransfer?.setData('application/x-signature-field-offset', JSON.stringify(offset));
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'copy';
}
private endFieldToolDrag() {
this.draggedFieldDefinition = null;
this.draggedFieldGrabOffset = null;
}
private renderFieldEditor(field: IFieldPlacement): TemplateResult {
return html`
<div class="card field-editor">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
<div style="font-size: 11px; font-weight: 600;">Field editor</div>
${pill(this.fieldDefinition(field.type).label, 'info', true)}
</div>
<div class="field-editor-grid">
<label class="field-control full">Label<input .value=${field.label} @input=${(event: Event) => this.updateSelectedField({ label: (event.target as HTMLInputElement).value })} /></label>
<label class="field-control full">Assigned signer<select .value=${String(field.recipient)} @change=${(event: Event) => this.updateSelectedField({ recipient: Number((event.target as HTMLSelectElement).value) })}>${this.recipients.map((recipient) => html`<option value=${String(recipient.id)}>${recipient.order}. ${recipient.name}</option>`)}</select></label>
<label class="field-control">X<input type="number" min="0" .value=${String(field.x)} @input=${(event: Event) => this.updateSelectedFieldNumber('x', event)} /></label>
<label class="field-control">Y<input type="number" min="0" .value=${String(field.y)} @input=${(event: Event) => this.updateSelectedFieldNumber('y', event)} /></label>
<label class="field-control">Width<input type="number" min="16" .value=${String(field.w)} @input=${(event: Event) => this.updateSelectedFieldNumber('w', event)} /></label>
<label class="field-control">Height<input type="number" min="16" .value=${String(field.h)} @input=${(event: Event) => this.updateSelectedFieldNumber('h', event)} /></label>
</div>
<div style="display: flex; gap: 8px; margin-top: 12px;">
<button class="btn outline small" style="flex: 1;" @click=${() => this.resetSelectedFieldSize(field)}>Reset size</button>
<button class="btn ghost small" style="color: var(--error);" @click=${() => this.removeSelectedField()}>Delete</button>
</div>
</div>
`;
}
private renderResizeHandles(field: IFieldPlacement): TemplateResult {
return html`${resizeHandles.map((handle) => html`<span class="resize-handle ${handle}" @pointerdown=${(event: PointerEvent) => this.startFieldResize(event, field, handle)}></span>`)}`;
}
private renderStepper(): TemplateResult {
const labels = ['Upload', 'Place fields', 'Recipients & routing', 'Review & send'];
return html`
<div class="stepper">
${labels.map((label, index) => {
const stepNumber = index + 1;
return html`<button class="step ${stepNumber === this.step ? 'active' : stepNumber < this.step ? 'done' : ''}" @click=${() => this.step = stepNumber}><span class="step-number">${stepNumber < this.step ? '✓' : stepNumber}</span><span>${label}</span>${index < labels.length - 1 ? html`<span style="width: 24px; height: 1px; background: var(--border); margin-left: 8px;"></span>` : ''}</button>`;
})}
<div style="flex: 1;"></div><span class="mono" style="font-size: 11px; color: var(--text-muted);">doc_8mK3pL · 14 pages · 2.4 MB</span>
</div>
`;
}
public render(): TemplateResult {
const selectedField = this.fields.find((field) => field.id === this.selectedFieldId);
return html`
${topBar({ breadcrumb: ['signature.digital', 'Inbox', 'Compose'], title: 'Master Services Agreement', subtitle: pill('Draft · auto-saved'), actions: html`${actionButton('Save draft', 'ghost')}${actionButton('Preview', 'outline', 'eye')}${actionButton('Send for signature', 'primary', 'send')}` })}
${this.renderStepper()}
<div class="compose-workspace">
<div class="palette">
<div class="label-upper">Drag onto document</div>
${fieldDefinitions.map((fieldType) => html`<div class="field-tool" style="--tool-w: ${fieldType.w}px; --tool-h: ${fieldType.h}px; --recipient-color: ${this.recipientColor(this.activeRecipient)};" draggable="true" @dragstart=${(event: DragEvent) => this.startFieldToolDrag(event, fieldType)} @dragend=${() => this.endFieldToolDrag()}>${icon(fieldType.icon, 14)}<span style="flex: 1;">${fieldType.label}</span></div>`)}
<div style="height: 1px; background: var(--border-subtle); margin: 20px 0 16px;"></div>
<div class="label-upper">Active for</div>
${this.recipients.map((recipient) => html`<div class="recipient-line ${this.activeRecipient === recipient.id ? 'active' : ''}" @click=${() => this.activeRecipient = recipient.id}><span class="swatch" style="--recipient-color: ${recipient.color};"></span><span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${recipient.name.split(' ')[0]}</span><span class="mono" style="font-size: 10px; color: var(--text-muted);">${this.fields.filter((field) => field.recipient === recipient.id).length}</span></div>`)}
</div>
<div class="document-stage">
<div class="document-page page-drop-target" @click=${this.handleDocumentClick} @dragover=${(event: DragEvent) => { event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; (event.currentTarget as HTMLElement).classList.add('drag-over'); }} @dragleave=${(event: DragEvent) => (event.currentTarget as HTMLElement).classList.remove('drag-over')} @drop=${(event: DragEvent) => this.addFieldFromDrop(event)}>
${fakeDocument()}
${this.fields.map((field) => html`<div class="field-box ${this.selectedFieldId === field.id ? 'selected' : ''}" style="--x: ${field.x}px; --y: ${field.y}px; --w: ${field.w}px; --h: ${field.h}px; --field-color: ${this.recipientColor(field.recipient)};" @click=${() => this.selectedFieldId = field.id} @pointerdown=${(event: PointerEvent) => this.startFieldMove(event, field)}><div class="field-content">${icon(this.fieldIcon(field.type), 12)}<span>${field.label}</span></div>${this.selectedFieldId === field.id ? this.renderResizeHandles(field) : ''}</div>`)}
<div class="mono" style="position: absolute; bottom: 12px; right: 16px; font-size: 9px; color: hsl(0 0% 60%);">Page 1 of 14</div>
</div>
<div style="display: flex; align-items: center; gap: 10px; font-size: 11px; color: var(--text-muted);">${actionButton('Prev', 'outline')}${html`<span class="mono">1 / 14</span>`}${actionButton('Next', 'outline')}</div>
</div>
<div class="right-panel">
<div class="label-upper">Signing order · drag to reorder</div>
${this.recipients.map((recipient) => html`<div class="recipient-line ${this.draggedRecipientId === recipient.id ? 'dragging' : ''}" draggable="true" @dragstart=${() => this.draggedRecipientId = recipient.id} @dragover=${(event: DragEvent) => event.preventDefault()} @drop=${() => this.reorderRecipient(recipient.id)} @dragend=${() => this.draggedRecipientId = null}><span class="mono" style="width: 14px; font-size: 10px; color: var(--text-muted);">${recipient.order}</span><span class="avatar" style="background: ${recipient.color};">${recipient.name.split(' ').map((part) => part[0]).slice(0, 2).join('')}</span><div style="flex: 1; min-width: 0;"><div style="font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${recipient.name}</div><div class="mono" style="font-size: 10px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${recipient.email}</div></div>${icon('more', 12)}</div>`)}
${selectedField ? this.renderFieldEditor(selectedField) : ''}
</div>
</div>
`;
}
}
@@ -0,0 +1,47 @@
import { DeesElement, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
import { actionButton, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame } from './sdig-workspace.shared.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-workspace-developers': SdigWorkspaceDevelopers;
}
}
@customElement('sdig-workspace-developers')
export class SdigWorkspaceDevelopers extends DeesElement {
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-developers></sdig-workspace-developers>`);
public static demoGroups = ['Signature Digital Workspace'];
public static styles = [workspaceBaseStyles, css`
.developer-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; }
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.metric-card { padding: 14px; }
.metric-value { font-family: 'Plus Jakarta Sans', Inter, sans-serif; font-size: 22px; font-weight: 600; letter-spacing: -0.02em; }
pre.code { margin: 0; padding: 16px; background: var(--bg-el); border: 1px solid var(--border-subtle); border-radius: 6px; font-size: 12px; line-height: 1.7; color: var(--text-sec); overflow: auto; }
@media (max-width: 920px) { .developer-grid, .stats-grid { grid-template-columns: 1fr; } }
`];
public render(): TemplateResult {
return html`
${topBar({ breadcrumb: ['signature.digital', 'Developers'], title: 'Developers', subtitle: pill('API · v0.42', 'info', true), actions: html`${actionButton('View on GitHub', 'outline', 'github')}${actionButton('New API key', 'primary', 'plus')}` })}
<div class="content-scroll developer-grid">
<div style="display: flex; flex-direction: column; gap: 16px;">
<div class="card" style="padding: 20px;"><div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px;"><span style="font-size: 14px; font-weight: 600;">Send a document in 8 lines</span><span class="pill">node</span></div><pre class="code mono">import { Signature } from '@signature.digital/sdk';
const sig = new Signature(process.env.SIGD_KEY);
await sig.documents.send({
file: './msa.pdf',
recipients: [{ name: 'Sarah Chen', email: 'sarah@acme.com' }],
fields: 'auto',
});</pre></div>
<div class="stats-grid">${[['Requests this month', '14,892', '+8.2%'], ['P95 latency', '142ms', '-12ms'], ['Error rate', '0.04%', '✓']].map((metric) => html`<div class="card metric-card"><div class="metric-value">${metric[1]}</div><div style="font-size: 10px; color: var(--text-muted); margin-top: 2px;">${metric[0]}</div><div class="mono" style="font-size: 10px; color: var(--success); margin-top: 6px;">${metric[2]}</div></div>`)}</div>
</div>
<div style="display: flex; flex-direction: column; gap: 16px;">
<div class="card" style="padding: 16px;"><div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">${icon('github', 14)}<span style="font-size: 12px; font-weight: 600;">signature-digital/core</span></div><div style="font-size: 11px; color: var(--text-muted); line-height: 1.5;">MIT-licensed. Self-host on your own infra, or use signature.digital cloud.</div></div>
<div class="card" style="padding: 16px;"><div class="label-upper">Self-host status</div>${[['Docker image', 'ghcr.io/signature-digital'], ['Helm chart', 'v0.42.1'], ['Postgres ≥ 14', 'required'], ['S3-compatible', 'optional']].map((row) => html`<div style="display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 6px;"><span style="color: var(--text-muted);">${row[0]}</span><span class="mono" style="color: var(--text-sec);">${row[1]}</span></div>`)}</div>
</div>
</div>
`;
}
}
@@ -0,0 +1,117 @@
import { DeesElement, property, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
import { actionButton, demoDocuments, icon, pill, requestWorkspaceView, topBar, workspaceBaseStyles, type IDocumentRow, type TDensity } from './sdig-workspace.shared.js';
import { workspaceDemoFrame } from './sdig-workspace.shared.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-workspace-inbox': SdigWorkspaceInbox;
}
}
@customElement('sdig-workspace-inbox')
export class SdigWorkspaceInbox extends DeesElement {
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-inbox></sdig-workspace-inbox>`);
public static demoGroups = ['Signature Digital Workspace'];
@property({ type: String }) public accessor density: TDensity = 'comfortable';
@state() private accessor filter: string = 'all';
@state() private accessor search: string = '';
public static styles = [workspaceBaseStyles, css`
.filterbar { padding: 14px 24px; border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; gap: 8px; }
.searchbox { display: flex; align-items: center; gap: 8px; padding: 0 10px; height: 32px; width: 280px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 6px; }
.searchbox input { flex: 1; min-width: 0; background: transparent; border: none; outline: none; color: var(--text); font-size: 12px; }
.segmented { display: flex; gap: 2px; padding: 2px; background: var(--bg-el); border-radius: 6px; border: 1px solid var(--border-subtle); }
.segmented button { padding: 4px 10px; font-size: 11px; font-weight: 500; border-radius: 4px; background: transparent; color: var(--text-muted); display: inline-flex; align-items: center; gap: 5px; }
.segmented button.active { background: var(--bg-card); color: var(--text); box-shadow: inset 0 0 0 1px var(--border); }
.doc-table { min-width: 880px; }
.doc-head, .doc-row { display: grid; grid-template-columns: 32px minmax(220px,2.4fr) 150px 160px 90px 60px 32px; align-items: center; gap: 14px; padding: 0 16px; }
.doc-head { height: 36px; border-bottom: 1px solid var(--border-subtle); color: var(--text-dim); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.doc-row { height: 60px; border-bottom: 1px solid var(--border-subtle); cursor: pointer; transition: background 0.1s ease; }
.doc-row.compact { height: 48px; }
.doc-row:last-child { border-bottom: 0; }
.doc-row:hover { background: var(--row-hover); }
.doc-icon { width: 28px; height: 32px; border-radius: 4px; background: var(--bg-input); border: 1px solid var(--border); display: flex; align-items: center; justify-content: center; }
.doc-title { font-size: 13px; color: var(--text); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-meta { margin-top: 2px; font-size: 11px; color: var(--text-muted); }
.recipient-stack { display: flex; align-items: center; }
.recipient-dot { width: 22px; height: 22px; border-radius: 50%; background: var(--bg-input); border: 1.5px solid var(--border); margin-left: -6px; font-size: 9px; font-weight: 600; color: var(--text-sec); display: flex; align-items: center; justify-content: center; }
.recipient-dot:first-child { margin-left: 0; }
.recipient-dot.signed { border-color: var(--success); color: var(--success); background: var(--bg-el); }
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 24px; }
.metric-card { padding: 16px; }
.metric-value { font-family: 'Plus Jakarta Sans', Inter, sans-serif; font-size: 22px; font-weight: 600; letter-spacing: -0.02em; }
@media (max-width: 920px) { .filterbar { padding: 12px 16px; display: block; } .searchbox { width: 100%; margin-bottom: 10px; } .stats-grid { grid-template-columns: 1fr; } }
`];
private get filteredDocuments(): IDocumentRow[] {
return demoDocuments
.filter((doc) => this.filter === 'all' || doc.status === this.filter)
.filter((doc) => !this.search || doc.title.toLowerCase().includes(this.search.toLowerCase()));
}
private statusPill(status: IDocumentRow['status']): TemplateResult {
const map = {
awaiting: ['warning', 'awaiting signature'],
signed: ['success', 'completed'],
draft: ['default', 'draft'],
declined: ['error', 'declined'],
} as const;
const [tone, label] = map[status];
return pill(label, tone, true);
}
private openDocument(doc: IDocumentRow) {
requestWorkspaceView(this, doc.status === 'signed' ? 'audit' : 'sign');
}
public render(): TemplateResult {
const filters = [
{ id: 'all', label: 'All', count: demoDocuments.length },
{ id: 'awaiting', label: 'Awaiting', count: demoDocuments.filter((doc) => doc.status === 'awaiting').length },
{ id: 'signed', label: 'Completed', count: demoDocuments.filter((doc) => doc.status === 'signed').length },
{ id: 'draft', label: 'Drafts', count: demoDocuments.filter((doc) => doc.status === 'draft').length },
{ id: 'declined', label: 'Declined', count: demoDocuments.filter((doc) => doc.status === 'declined').length },
];
return html`
${topBar({
breadcrumb: ['signature.digital', 'Lossless GmbH', 'Inbox'],
title: 'Inbox',
subtitle: pill(`${demoDocuments.filter((doc) => doc.status === 'awaiting').length} need attention`, 'info'),
actions: html`${actionButton('Import', 'outline', 'upload')}${actionButton('New document', 'primary', 'plus', () => requestWorkspaceView(this, 'compose'))}`,
})}
<div class="filterbar">
<div class="searchbox">${icon('search', 13)}<input .value=${this.search} @input=${(event: Event) => this.search = (event.target as HTMLInputElement).value} placeholder="Search documents, recipients, IDs..." /><span class="mono" style="font-size: 10px; color: var(--text-dim); border: 1px solid var(--border); border-radius: 3px; padding: 1px 5px;">⌘K</span></div>
<div style="flex: 1;"></div>
<div class="segmented">${filters.map((filter) => html`<button class=${this.filter === filter.id ? 'active' : ''} @click=${() => this.filter = filter.id}>${filter.label}<span class="mono" style="color: var(--text-dim);">${filter.count}</span></button>`)}</div>
</div>
<div class="content-scroll">
<div class="card" style="overflow-x: auto;">
<div class="doc-table">
<div class="doc-head"><span></span><span>Document</span><span>Status</span><span>Recipients</span><span>Deadline</span><span style="text-align: right;">Pages</span><span></span></div>
${this.filteredDocuments.map((doc) => html`
<div class="doc-row ${this.density === 'compact' ? 'compact' : ''}" @click=${() => this.openDocument(doc)}>
<div class="doc-icon">${icon('file', 14)}</div>
<div style="min-width: 0;"><div class="doc-title">${doc.title}</div><div class="doc-meta mono">${doc.id} · ${doc.sender} · ${doc.updated}</div></div>
<div>${this.statusPill(doc.status)}</div>
<div style="display: flex; align-items: center; gap: 8px;"><div class="recipient-stack">${doc.recipients.slice(0, 4).map((recipient) => html`<span class="recipient-dot ${recipient.signed ? 'signed' : ''}" title=${recipient.name}>${recipient.initials}</span>`)}</div><span style="font-size: 11px; color: var(--text-muted);">${doc.recipients.filter((recipient) => recipient.signed).length}/${doc.recipients.length}</span></div>
<div class="mono" style="font-size: 11px; color: ${doc.deadline && doc.status === 'awaiting' ? 'var(--warning)' : 'var(--text-dim)'};">${doc.deadline ? html`${icon('clock', 11)} ${doc.deadline}` : '—'}</div>
<div class="mono" style="font-size: 11px; color: var(--text-muted); text-align: right;">${doc.pages}</div>
<div>${icon('more', 14)}</div>
</div>
`)}
</div>
</div>
<div class="stats-grid">
${[
{ label: 'Sent this month', value: '127', delta: '+24%', icon: 'send' },
{ label: 'Avg time to sign', value: '4.2h', delta: '-18%', icon: 'clock' },
{ label: 'Completion rate', value: '94.1%', delta: '+2.1%', icon: 'check' },
{ label: 'API signatures', value: '2,481', delta: '+312', icon: 'code' },
].map((metric) => html`<div class="card metric-card"><div style="display: flex; justify-content: space-between; margin-bottom: 12px;">${icon(metric.icon, 14)}<span class="mono" style="font-size: 10px; color: var(--success);">${metric.delta}</span></div><div class="metric-value">${metric.value}</div><div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">${metric.label}</div></div>`)}
</div>
</div>
`;
}
}
@@ -0,0 +1,22 @@
import { DeesElement, property, html, customElement, type TemplateResult } from '@design.estate/dees-element';
import { icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame } from './sdig-workspace.shared.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-workspace-placeholder': SdigWorkspacePlaceholder;
}
}
@customElement('sdig-workspace-placeholder')
export class SdigWorkspacePlaceholder extends DeesElement {
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-placeholder label="Templates" subtitle="Reusable agreement templates"></sdig-workspace-placeholder>`);
public static demoGroups = ['Signature Digital Workspace'];
@property({ type: String }) public accessor label: string = 'Section';
@property({ type: String }) public accessor subtitle: string = 'Coming soon';
public static styles = [workspaceBaseStyles];
public render(): TemplateResult {
return html`${topBar({ breadcrumb: ['signature.digital', this.label], title: this.label, subtitle: pill('coming soon') })}<div class="content-scroll" style="display: flex; align-items: center; justify-content: center; flex-direction: column; color: var(--text-muted); gap: 8px;">${icon('folder', 32)}<div style="font-size: 13px; color: var(--text-sec);">${this.label}</div><div style="font-size: 11px;">${this.subtitle}</div></div>`;
}
}
@@ -0,0 +1,91 @@
import { DeesElement, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
import { actionButton, demoFields, fakeDocument, icon, pill, workspaceBaseStyles, workspaceDemoFrame, type IFieldPlacement } from './sdig-workspace.shared.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-workspace-sign': SdigWorkspaceSign;
}
}
@customElement('sdig-workspace-sign')
export class SdigWorkspaceSign extends DeesElement {
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-sign></sdig-workspace-sign>`);
public static demoGroups = ['Signature Digital Workspace'];
@state() private accessor activeFieldId: string = 'f1';
@state() private accessor signedFieldIds: string[] = [];
public static styles = [workspaceBaseStyles, css`
.recipient-header { height: 56px; flex-shrink: 0; padding: 0 24px; background: var(--bg-card); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.logomark { width: 28px; height: 28px; border-radius: 6px; background: var(--bg-el); border: 1px solid var(--border-strong); display: inline-flex; align-items: center; justify-content: center; font-family: 'Plus Jakarta Sans', Inter, sans-serif; font-weight: 700; position: relative; }
.logomark::after { content: ''; position: absolute; right: 5px; bottom: 5px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent); }
.sign-layout { flex: 1; display: flex; overflow: hidden; background: hsl(0 0% 96%); color: hsl(0 0% 10%); }
:host-context(sdig-workspace[theme='dark']) .sign-layout { background: hsl(0 0% 6%); color: hsl(0 0% 95%); }
.sign-body { flex: 1; overflow: auto; padding: 32px 32px 80px; display: flex; flex-direction: column; align-items: center; gap: 20px; }
.sign-panel { width: 320px; border-left: 1px solid var(--border); background: var(--bg-card); padding: 20px; overflow: auto; flex-shrink: 0; }
@media (max-width: 920px) { .recipient-header .actions { display: none; } .sign-layout { flex-direction: column; overflow: auto; } .sign-panel { width: 100%; border-left: 0; border-top: 1px solid var(--border); } .document-page { width: 560px; } }
`];
private get signFields() {
return demoFields.slice(0, 3);
}
private fieldIcon(type: IFieldPlacement['type']): string {
if (type === 'signature') return 'sign';
if (type === 'date') return 'calendar';
return 'type';
}
private signField(fieldId: string) {
if (!this.signedFieldIds.includes(fieldId)) {
this.signedFieldIds = [...this.signedFieldIds, fieldId];
}
const next = this.signFields.find((field) => !this.signedFieldIds.includes(field.id) && field.id !== fieldId);
if (next) this.activeFieldId = next.id;
}
private renderSignedValue(field: IFieldPlacement): TemplateResult {
if (field.type === 'signature') return html`<span style="font-family: 'Plus Jakarta Sans', cursive; font-size: 22px; font-weight: 600; font-style: italic; color: hsl(220 50% 30%);">Sarah Chen</span>`;
if (field.type === 'date') return html`<span class="mono" style="font-size: 12px; color: hsl(0 0% 18%);">2026-05-02</span>`;
return html`<span style="font-size: 12px; color: hsl(0 0% 18%);">Sarah Chen</span>`;
}
public render(): TemplateResult {
const completed = this.signedFieldIds.length;
const progress = Math.round((completed / this.signFields.length) * 100);
const activeField = this.signFields.find((field) => field.id === this.activeFieldId) || this.signFields[0];
return html`
<div class="recipient-header">
<div style="display: flex; align-items: center; gap: 12px;"><span class="logomark">s</span><div><div style="font-size: 12px; font-weight: 600;">Master Services Agreement</div><div class="mono" style="font-size: 10px; color: var(--text-muted);">From Lossless GmbH · doc_8mK3pL · 14 pages</div></div></div>
<div class="actions"><span class="pill success">${icon('shield', 12)} Verified sender · DKIM ✓</span>${actionButton('Decline', 'outline')}${actionButton('PDF', 'outline', 'download')}</div>
</div>
<div class="progress-track"><div class="progress-fill" style="width: ${progress}%"></div></div>
<div class="sign-layout">
<div class="sign-body">
<div class="document-page" style="width: 620px; min-height: 820px;">
${fakeDocument()}
${this.signFields.map((field) => {
const filled = this.signedFieldIds.includes(field.id);
const active = this.activeFieldId === field.id && !filled;
return html`<div class="field-box ${active ? 'selected' : ''}" style="--x: ${field.x}px; --y: ${field.y}px; --w: ${field.w}px; --h: ${field.h}px; --field-color: ${filled ? 'transparent' : 'var(--accent)'}; color: ${filled ? 'hsl(220 50% 30%)' : 'var(--accent)'}; background: ${filled ? 'transparent' : 'color-mix(in srgb, var(--accent) 12%, transparent)'};" @click=${() => !filled ? this.signField(field.id) : undefined}>${filled ? this.renderSignedValue(field) : html`${icon(this.fieldIcon(field.type), 12)}<span>${active ? html`<span style="display: inline-block; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); animation: pulse 1.4s infinite;"></span>` : ''}${field.label}</span>`}</div>`;
})}
<div class="mono" style="position: absolute; bottom: 14px; right: 18px; font-size: 9px; color: hsl(0 0% 60%);">Page 1 of 14</div>
</div>
</div>
<div class="sign-panel">
<div style="display: flex; align-items: center; gap: 8px;"><span class="avatar" style="background: #60a5fa;">SC</span><div><div style="font-size: 13px; font-weight: 600;">Hi, Sarah</div><div class="mono" style="font-size: 10px; color: var(--text-muted);">sarah@acme.com</div></div></div>
<div class="card" style="padding: 14px; margin-top: 16px;"><div class="label-upper">Your progress</div><div style="font-size: 24px; font-weight: 700;">${completed} <span style="color: var(--text-muted); font-weight: 400;">/ ${this.signFields.length}</span></div><div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">${this.signFields.length - completed === 0 ? 'All fields complete' : `${this.signFields.length - completed} fields remaining`}</div><div class="progress-track" style="margin-top: 12px;"><div class="progress-fill" style="width: ${progress}%"></div></div></div>
<div style="margin-top: 20px;" class="label-upper">Step by step</div>
${this.signFields.map((field, index) => {
const filled = this.signedFieldIds.includes(field.id);
const active = this.activeFieldId === field.id && !filled;
return html`<div class="recipient-line ${active ? 'active' : ''}" @click=${() => !filled ? this.activeFieldId = field.id : undefined}><span class="avatar" style="width: 22px; height: 22px; background: ${filled ? 'var(--success)' : active ? 'var(--accent)' : 'var(--bg-input)'}; color: ${filled || active ? 'white' : 'var(--text-muted)'};">${filled ? '✓' : index + 1}</span><div style="flex: 1;"><div style="font-size: 12px; font-weight: 500; text-decoration: ${filled ? 'line-through' : 'none'};">${field.label}</div><div class="mono" style="font-size: 10px; color: var(--text-muted);">${field.type} · page ${field.page}</div></div>${active ? icon('chevronRight', 12) : ''}</div>`;
})}
<button class="btn primary" style="width: 100%; height: 44px; margin-top: 20px;" @click=${() => this.signField(activeField.id)}>${completed === this.signFields.length ? 'Finish & submit' : `Continue - ${activeField.label}`}</button>
<div style="margin-top: 12px; padding: 10px; font-size: 10px; color: var(--text-muted); line-height: 1.5; text-align: center; border-radius: 6px; background: var(--bg-el);">By signing, you agree to the ESIGN Act & eIDAS terms.<br /><span class="mono">IP 81.221.4.18 · Brussels, BE</span></div>
</div>
</div>
`;
}
}
@@ -0,0 +1,440 @@
import { html, css, type TemplateResult } from '@design.estate/dees-element';
import '@design.estate/dees-catalog/ts_web/elements/00group-utility/dees-icon/dees-icon.js';
export type TWorkspaceView =
| 'inbox'
| 'compose'
| 'sign'
| 'audit'
| 'developers'
| 'templates'
| 'team'
| 'settings';
export type TWorkspaceTheme = 'dark' | 'light';
export type TDensity = 'compact' | 'comfortable';
export interface IDocumentRow {
id: string;
title: string;
status: 'awaiting' | 'signed' | 'draft' | 'declined';
recipients: Array<{ name: string; initials: string; signed: boolean }>;
updated: string;
sender: string;
pages: number;
deadline?: string;
}
export interface IRecipient {
id: number;
name: string;
email: string;
color: string;
order: number;
}
export interface IFieldPlacement {
id: string;
type: 'signature' | 'date' | 'text' | 'initials' | 'check';
x: number;
y: number;
w: number;
h: number;
page: number;
recipient: number;
label: string;
}
export const demoDocuments: IDocumentRow[] = [
{ id: 'doc_8mK3pL', title: 'Master Services Agreement - Acme Corp', status: 'awaiting', recipients: [{ name: 'Sarah Chen', initials: 'SC', signed: true }, { name: 'David Park', initials: 'DP', signed: false }, { name: 'You', initials: 'PK', signed: true }], updated: '2 min ago', sender: 'You', pages: 14, deadline: 'May 5' },
{ id: 'doc_2nQ7vR', title: 'NDA - Helio Robotics', status: 'signed', recipients: [{ name: 'Marcus Tan', initials: 'MT', signed: true }, { name: 'You', initials: 'PK', signed: true }], updated: '1h ago', sender: 'You', pages: 3 },
{ id: 'doc_5tH1zM', title: 'Series B Term Sheet (Lead) v3', status: 'awaiting', recipients: [{ name: 'Anna Lindqvist', initials: 'AL', signed: false }, { name: 'Roy Banerjee', initials: 'RB', signed: true }, { name: 'You', initials: 'PK', signed: false }], updated: '3h ago', sender: 'Sequoia Counsel', pages: 22, deadline: 'May 3' },
{ id: 'doc_9wB4cX', title: 'Employment Offer - Mira Abebe', status: 'declined', recipients: [{ name: 'Mira Abebe', initials: 'MA', signed: false }, { name: 'You', initials: 'PK', signed: true }], updated: 'yesterday', sender: 'You', pages: 6 },
{ id: 'doc_1jF6kY', title: 'Lease - Berlin office Q3', status: 'draft', recipients: [{ name: 'You', initials: 'PK', signed: false }], updated: 'yesterday', sender: 'You', pages: 11 },
{ id: 'doc_4dN8sP', title: 'API Reseller Agreement - Northwind', status: 'signed', recipients: [{ name: 'Lila Brooks', initials: 'LB', signed: true }, { name: 'You', initials: 'PK', signed: true }], updated: '2 days ago', sender: 'You', pages: 8 },
];
export const demoRecipients: IRecipient[] = [
{ id: 0, name: 'Sarah Chen', email: 'sarah@acme.com', color: '#60a5fa', order: 1 },
{ id: 1, name: 'David Park', email: 'd.park@acme.com', color: '#fbbf24', order: 2 },
{ id: 2, name: 'Philipp K.', email: 'philipp@lossless.com', color: '#3b82f6', order: 3 },
];
export const demoFields: IFieldPlacement[] = [
{ id: 'f1', type: 'signature', x: 60, y: 580, w: 200, h: 50, page: 1, recipient: 0, label: 'Signature' },
{ id: 'f2', type: 'date', x: 320, y: 580, w: 120, h: 30, page: 1, recipient: 0, label: 'Date' },
{ id: 'f3', type: 'text', x: 60, y: 460, w: 280, h: 30, page: 1, recipient: 1, label: 'Full legal name' },
{ id: 'f4', type: 'signature', x: 60, y: 700, w: 200, h: 50, page: 1, recipient: 1, label: 'Counter-signature' },
];
export const workspaceBaseStyles = css`
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
color: var(--text);
background: var(--bg);
font-family: Geist, Inter, Roboto, -apple-system, BlinkMacSystemFont, sans-serif;
font-feature-settings: 'cv11', 'tnum', 'cv05' 1;
}
* { box-sizing: border-box; }
button, input, textarea { font: inherit; }
button { border: 0; cursor: pointer; }
dees-icon { flex-shrink: 0; }
.mono {
font-family: 'Intel One Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
font-variant-numeric: tabular-nums;
}
.topbar {
height: 56px;
flex-shrink: 0;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg);
gap: 12px;
}
.breadcrumb {
font-size: 11px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-title {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.top-title > span:first-child {
font-family: 'Plus Jakarta Sans', Inter, sans-serif;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.btn {
height: 34px;
padding: 0 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
letter-spacing: -0.01em;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
white-space: nowrap;
transition: all 0.12s ease;
}
.btn.small {
height: 28px;
padding: 0 10px;
font-size: 12px;
}
.btn.primary {
background: var(--accent);
color: white;
border: 1px solid var(--accent);
}
.btn.outline {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.btn.ghost {
background: transparent;
color: var(--text);
border: 1px solid transparent;
}
.btn:hover { background-color: var(--hover); }
.pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border-radius: 999px;
background: var(--bg-el);
color: var(--text-sec);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.pill::before {
content: '';
width: 5px;
height: 5px;
display: none;
border-radius: 50%;
background: currentColor;
}
.pill.dot::before { display: block; }
.pill.success { background: rgba(34,197,94,0.12); color: #4ade80; }
.pill.warning { background: rgba(245,158,11,0.12); color: #fbbf24; }
.pill.error { background: rgba(239,68,68,0.12); color: #f87171; }
.pill.info { background: rgba(59,130,246,0.12); color: #60a5fa; }
.content-scroll {
flex: 1;
overflow: auto;
padding: 24px;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.label-upper {
font-size: 10px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 10px;
}
.avatar {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--accent);
color: white;
font-size: 11px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.document-page {
position: relative;
width: 600px;
min-height: 800px;
background: white;
border-radius: 4px;
box-shadow: 0 8px 32px rgba(0,0,0,0.35), 0 0 0 1px rgba(255,255,255,0.05);
color: hsl(0 0% 20%);
}
.fake-document {
padding: 48px 56px;
font-size: 11px;
line-height: 1.7;
}
.fake-title {
font-family: 'Plus Jakarta Sans', Inter, sans-serif;
font-size: 18px;
font-weight: 700;
margin-bottom: 4px;
color: hsl(0 0% 10%);
}
.fake-line {
height: 6px;
background: hsl(0 0% 82%);
margin-bottom: 7px;
border-radius: 1px;
}
.fake-line.heavy { background: hsl(0 0% 65%); }
.fake-line.short { width: 70%; }
.field-box {
position: absolute;
left: var(--x);
top: var(--y);
width: var(--w);
height: var(--h);
background: color-mix(in srgb, var(--field-color) 13%, transparent);
border: 1.5px dashed var(--field-color);
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
font-size: 10px;
font-weight: 500;
color: var(--field-color);
}
.field-box.selected {
border-style: solid;
box-shadow: 0 0 0 4px color-mix(in srgb, var(--field-color) 18%, transparent);
}
.recipient-line {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
color: var(--text-sec);
margin-bottom: 6px;
}
.recipient-line.active {
background: var(--hover);
border-color: var(--border-strong);
}
.progress-track {
height: 4px;
background: var(--bg-el);
flex-shrink: 0;
}
.progress-fill {
height: 100%;
background: var(--accent);
transition: width 0.4s ease;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
@media (max-width: 920px) {
.topbar { padding: 0 16px; }
.actions { display: none; }
.content-scroll { padding: 16px; }
}
`;
export function icon(name: string, size = 14): TemplateResult {
const iconMap: Record<string, string> = {
inbox: 'lucide:Inbox', plus: 'lucide:Plus', folder: 'lucide:Folder', shield: 'lucide:Shield', code: 'lucide:Code2',
user: 'lucide:User', settings: 'lucide:Settings', upload: 'lucide:Upload', file: 'lucide:FileText', sign: 'lucide:PenTool',
clock: 'lucide:Clock', search: 'lucide:Search', more: 'lucide:MoreHorizontal', send: 'lucide:Send', check: 'lucide:Check',
eye: 'lucide:Eye', calendar: 'lucide:Calendar', type: 'lucide:Type', download: 'lucide:Download', hash: 'lucide:Hash',
github: 'lucide:GitBranch', git: 'lucide:GitBranch', server: 'lucide:Server', star: 'lucide:Star', sparkle: 'lucide:Sparkles',
chevronRight: 'lucide:ChevronRight', chevronDown: 'lucide:ChevronDown', x: 'lucide:X', activity: 'lucide:Activity',
};
return html`<dees-icon .icon=${iconMap[name] || iconMap.file} style="font-size: ${size}px;"></dees-icon>`;
}
export function pill(label: string, tone: 'default' | 'success' | 'warning' | 'error' | 'info' = 'default', dot = false): TemplateResult {
return html`<span class="pill ${tone} ${dot ? 'dot' : ''}">${label}</span>`;
}
export function actionButton(label: string, variant: 'primary' | 'outline' | 'ghost' = 'outline', iconName?: string, onClick?: () => void): TemplateResult {
return html`<button class="btn ${variant}" @click=${onClick || (() => undefined)}>${iconName ? icon(iconName, 13) : ''}${label}</button>`;
}
export function topBar(config: { breadcrumb: string[]; title: string; subtitle?: TemplateResult; actions?: TemplateResult }): TemplateResult {
return html`
<div class="topbar">
<div style="min-width: 0; flex: 1;">
<div class="breadcrumb">
${config.breadcrumb.map((part, index) => html`${index > 0 ? icon('chevronRight', 10) : ''}<span>${part}</span>`)}
</div>
<div class="top-title"><span>${config.title}</span>${config.subtitle || ''}</div>
</div>
<div class="actions">${config.actions || ''}</div>
</div>
`;
}
export function workspaceDemoFrame(content: TemplateResult, theme: TWorkspaceTheme = 'dark'): TemplateResult {
const darkVars = `
--accent: #3b82f6;
--bg: hsl(0 0% 3.9%);
--bg-el: hsl(0 0% 6%);
--bg-card: hsl(0 0% 7%);
--bg-input: hsl(0 0% 9%);
--border: hsl(0 0% 14.9%);
--border-subtle: hsl(0 0% 11%);
--border-strong: hsl(0 0% 20%);
--text: hsl(0 0% 98%);
--text-sec: hsl(0 0% 63.9%);
--text-muted: hsl(0 0% 48%);
--text-dim: hsl(0 0% 32%);
--hover: rgba(255,255,255,0.06);
--hover-subtle: rgba(255,255,255,0.03);
--row-hover: rgba(255,255,255,0.025);
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
`;
const lightVars = `
--accent: #3b82f6;
--bg: hsl(0 0% 99%);
--bg-el: hsl(0 0% 97%);
--bg-card: hsl(0 0% 100%);
--bg-input: hsl(0 0% 98%);
--border: hsl(0 0% 90%);
--border-subtle: hsl(0 0% 93%);
--border-strong: hsl(0 0% 80%);
--text: hsl(0 0% 9%);
--text-sec: hsl(0 0% 32%);
--text-muted: hsl(0 0% 45%);
--text-dim: hsl(0 0% 62%);
--hover: rgba(0,0,0,0.04);
--hover-subtle: rgba(0,0,0,0.02);
--row-hover: rgba(0,0,0,0.02);
--success: #16a34a;
--warning: #d97706;
--error: #dc2626;
`;
return html`<div style="${theme === 'dark' ? darkVars : lightVars} height: 720px; min-height: 720px; background: var(--bg); color: var(--text); overflow: hidden; font-family: Geist, Inter, Roboto, -apple-system, BlinkMacSystemFont, sans-serif;">${content}</div>`;
}
export function fakeDocument(): TemplateResult {
return html`
<div class="fake-document">
<div class="fake-title">Master Services Agreement</div>
<div class="mono" style="font-size: 10px; color: hsl(0 0% 45%); margin-bottom: 24px;">Effective: May 2, 2026 · Acme Corp ↔ Lossless GmbH</div>
${Array.from({ length: 18 }).map((_, index) => html`<div class="fake-line ${index % 5 === 0 ? 'heavy' : ''} ${index % 4 === 3 ? 'short' : ''}"></div>`)}
<div style="height: 16px;"></div>
${Array.from({ length: 8 }).map((_, index) => html`<div class="fake-line ${index % 3 === 2 ? 'short' : ''}"></div>`)}
<div style="margin-top: 60px; font-size: 10px; font-weight: 600; color: hsl(0 0% 35%);">SIGNED ON BEHALF OF ACME CORP</div>
<div style="margin-top: 70px; font-size: 10px; font-weight: 600; color: hsl(0 0% 35%);">SIGNED ON BEHALF OF LOSSLESS GMBH</div>
</div>
`;
}
export function requestWorkspaceView(element: HTMLElement, view: TWorkspaceView) {
element.dispatchEvent(new CustomEvent('workspace-view-request', {
detail: { view },
bubbles: true,
composed: true,
}));
}
@@ -0,0 +1,175 @@
import { DeesElement, property, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
import { icon, type TDensity, type TWorkspaceTheme, type TWorkspaceView } from './sdig-workspace.shared.js';
import './sdig-workspace-inbox.js';
import './sdig-workspace-compose.js';
import './sdig-workspace-sign.js';
import './sdig-workspace-audit.js';
import './sdig-workspace-developers.js';
import './sdig-workspace-placeholder.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-workspace': SdigWorkspace;
}
}
@customElement('sdig-workspace')
export class SdigWorkspace extends DeesElement {
public static demo = () => html`<sdig-workspace></sdig-workspace>`;
public static demoGroups = ['Signature Digital Workspace'];
@property({ type: String }) public accessor accent: string = '#3b82f6';
@property({ type: String }) public accessor density: TDensity = 'comfortable';
@property({ type: String, reflect: true }) public accessor theme: TWorkspaceTheme = 'dark';
@property({ type: String }) public accessor initialView: TWorkspaceView = 'inbox';
@state() private accessor view: TWorkspaceView = 'inbox';
public connectedCallback = async () => {
await super.connectedCallback();
this.view = this.initialView || 'inbox';
this.addEventListener('workspace-view-request', this.handleViewRequest as EventListener);
};
public disconnectedCallback = async () => {
this.removeEventListener('workspace-view-request', this.handleViewRequest as EventListener);
await super.disconnectedCallback();
};
public static styles = css`
:host {
display: block;
width: 100%;
height: 100%;
min-height: 720px;
--accent: #3b82f6;
--bg: hsl(0 0% 3.9%);
--bg-el: hsl(0 0% 6%);
--bg-card: hsl(0 0% 7%);
--bg-input: hsl(0 0% 9%);
--border: hsl(0 0% 14.9%);
--border-subtle: hsl(0 0% 11%);
--border-strong: hsl(0 0% 20%);
--text: hsl(0 0% 98%);
--text-sec: hsl(0 0% 63.9%);
--text-muted: hsl(0 0% 48%);
--text-dim: hsl(0 0% 32%);
--hover: rgba(255,255,255,0.06);
--hover-subtle: rgba(255,255,255,0.03);
--row-hover: rgba(255,255,255,0.025);
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
font-family: Geist, Inter, Roboto, -apple-system, BlinkMacSystemFont, sans-serif;
}
:host([theme='light']) {
--bg: hsl(0 0% 99%);
--bg-el: hsl(0 0% 97%);
--bg-card: hsl(0 0% 100%);
--bg-input: hsl(0 0% 98%);
--border: hsl(0 0% 90%);
--border-subtle: hsl(0 0% 93%);
--border-strong: hsl(0 0% 80%);
--text: hsl(0 0% 9%);
--text-sec: hsl(0 0% 32%);
--text-muted: hsl(0 0% 45%);
--text-dim: hsl(0 0% 62%);
--hover: rgba(0,0,0,0.04);
--hover-subtle: rgba(0,0,0,0.02);
--row-hover: rgba(0,0,0,0.02);
--success: #16a34a;
--warning: #d97706;
--error: #dc2626;
}
* { box-sizing: border-box; }
button { font: inherit; border: 0; cursor: pointer; }
.workspace { display: flex; height: 100%; min-height: 720px; background: var(--bg); color: var(--text); overflow: hidden; }
.sidebar { width: 220px; background: var(--bg); border-right: 1px solid var(--border-subtle); display: flex; flex-direction: column; flex-shrink: 0; height: 100%; }
.brand { padding: 14px 16px 12px; display: flex; align-items: center; gap: 8px; }
.logomark { width: 26px; height: 26px; border-radius: 6px; background: var(--bg-el); border: 1px solid var(--border-strong); display: inline-flex; align-items: center; justify-content: center; font-family: 'Plus Jakarta Sans', Inter, sans-serif; font-size: 13px; font-weight: 700; position: relative; }
.logomark::after, .wordmark::after { content: ''; display: inline-block; border-radius: 50%; background: var(--accent); }
.logomark::after { width: 4px; height: 4px; position: absolute; right: 5px; bottom: 5px; }
.wordmark { font-size: 13px; font-weight: 500; letter-spacing: -0.02em; white-space: nowrap; }
.wordmark .dot { color: var(--text-muted); }
.wordmark::after { width: 4px; height: 4px; margin-left: 3px; transform: translateY(-1px); }
.workspace-card { margin: 0 12px 8px; padding: 7px 10px; background: var(--bg-el); border: 1px solid var(--border-subtle); border-radius: 6px; display: flex; align-items: center; gap: 8px; }
.workspace-badge { width: 18px; height: 18px; border-radius: 4px; background: linear-gradient(135deg, var(--accent), hsl(280 70% 60%)); color: white; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.workspace-name { font-size: 12px; font-weight: 500; line-height: 1.2; }
.workspace-plan { font-size: 10px; color: var(--text-muted); }
.nav-block { padding: 4px 0; }
.nav-item { display: flex; align-items: center; gap: 10px; padding: 7px 10px; margin: 1px 8px; border-radius: 6px; color: var(--text-muted); background: transparent; transition: all 0.1s ease; font-size: 13px; position: relative; width: calc(100% - 16px); text-align: left; }
.compact .nav-item { padding: 5px 10px; }
.nav-item:hover { background: var(--hover-subtle); color: var(--text-sec); }
.nav-item.active { background: var(--hover); color: var(--text); font-weight: 500; }
.nav-item.active::before { content: ''; position: absolute; left: -8px; width: 2px; height: 14px; border-radius: 2px; background: var(--accent); }
.nav-count { margin-left: auto; min-width: 18px; padding: 1px 6px; border-radius: 999px; background: var(--bg-el); color: var(--text-muted); font-size: 10px; text-align: center; }
.github-card { margin: 8px 12px; padding: 10px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--bg-el); }
.sparkline { margin-top: 8px; display: flex; gap: 2px; align-items: flex-end; height: 18px; }
.sparkline span { flex: 1; background: var(--border-strong); border-radius: 1px; }
.sparkline span:nth-last-child(-n+4) { background: var(--accent); }
.user-card { padding: 8px 12px; border-top: 1px solid var(--border-subtle); display: flex; align-items: center; gap: 10px; }
.avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--accent); color: white; font-size: 11px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
.view-host { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.statusbar { height: 24px; flex-shrink: 0; border-top: 1px solid var(--border-subtle); background: var(--bg); display: flex; align-items: center; padding: 0 16px; gap: 16px; font-size: 10px; color: var(--text-dim); font-family: 'Intel One Mono', ui-monospace, SFMono-Regular, Menlo, monospace; }
@media (max-width: 920px) { .workspace { flex-direction: column; min-height: 100vh; } .sidebar { width: 100%; height: auto; border-right: 0; border-bottom: 1px solid var(--border-subtle); } .brand, .workspace-card, .github-card, .user-card { display: none; } .nav-block { display: flex; overflow-x: auto; padding: 8px; } .nav-item { width: auto; margin: 0 2px; } .statusbar { display: none; } }
`;
private handleViewRequest = (event: CustomEvent<{ view: TWorkspaceView }>) => {
this.setView(event.detail.view);
};
private setView(viewArg: TWorkspaceView) {
this.view = viewArg;
this.dispatchEvent(new CustomEvent('view-change', { detail: { view: viewArg }, bubbles: true, composed: true }));
}
private navButton(item: { id: TWorkspaceView; label: string; icon: string; count?: number }): TemplateResult {
return html`<button class="nav-item ${this.view === item.id ? 'active' : ''}" @click=${() => this.setView(item.id)}>${icon(item.icon, 15)}<span>${item.label}</span>${item.count !== undefined ? html`<span class="nav-count">${item.count}</span>` : ''}</button>`;
}
private renderSidebar(): TemplateResult {
const navItems = [
{ id: 'inbox', label: 'Inbox', icon: 'inbox', count: 4 },
{ id: 'compose', label: 'Compose', icon: 'plus' },
{ id: 'templates', label: 'Templates', icon: 'folder', count: 12 },
{ id: 'audit', label: 'Audit Trail', icon: 'shield' },
{ id: 'developers', label: 'Developers', icon: 'code' },
] as Array<{ id: TWorkspaceView; label: string; icon: string; count?: number }>;
const lowerItems = [
{ id: 'team', label: 'Team', icon: 'user' },
{ id: 'settings', label: 'Settings', icon: 'settings' },
] as Array<{ id: TWorkspaceView; label: string; icon: string }>;
return html`
<aside class="sidebar">
<div class="brand"><span class="logomark">s</span><span class="wordmark">signature<span class="dot">.</span>digital</span></div>
<div class="workspace-card"><span class="workspace-badge">L</span><div style="flex: 1; min-width: 0;"><div class="workspace-name">Lossless GmbH</div><div class="workspace-plan">Cloud · Pro</div></div>${icon('chevronDown', 12)}</div>
<div class="nav-block">${navItems.map((item) => this.navButton(item))}</div>
<div style="flex: 1;"></div>
<div class="github-card"><div style="display: flex; align-items: center; gap: 6px; margin-bottom: 8px; font-size: 11px; color: var(--text-sec); font-family: 'Intel One Mono', ui-monospace;">${icon('github', 13)} signature-digital/core</div><div style="display: flex; gap: 12px; font-size: 11px; color: var(--text-muted);"><span>${icon('star', 11)} 8.2k</span><span>${icon('git', 11)} 248</span></div><div class="sparkline">${[3, 5, 2, 7, 4, 6, 8, 5, 9, 6, 4, 8, 7, 10].map((height) => html`<span style="height: ${height * 10}%"></span>`)}</div></div>
<div class="nav-block" style="border-top: 1px solid var(--border-subtle); padding-top: 8px;">${lowerItems.map((item) => this.navButton(item))}</div>
<div class="user-card"><span class="avatar">PK</span><div style="flex: 1; min-width: 0;"><div style="font-size: 12px; font-weight: 500;">Philipp K.</div><div style="font-family: 'Intel One Mono', ui-monospace; font-size: 10px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis;">philipp@lossless.com</div></div>${icon('more', 14)}</div>
</aside>
`;
}
private renderView(): TemplateResult {
switch (this.view) {
case 'inbox': return html`<sdig-workspace-inbox class="view-host" .density=${this.density}></sdig-workspace-inbox>`;
case 'compose': return html`<sdig-workspace-compose class="view-host"></sdig-workspace-compose>`;
case 'sign': return html`<sdig-workspace-sign class="view-host"></sdig-workspace-sign>`;
case 'audit': return html`<sdig-workspace-audit class="view-host"></sdig-workspace-audit>`;
case 'developers': return html`<sdig-workspace-developers class="view-host"></sdig-workspace-developers>`;
case 'templates': return html`<sdig-workspace-placeholder class="view-host" label="Templates" subtitle="Reusable agreement templates"></sdig-workspace-placeholder>`;
case 'team': return html`<sdig-workspace-placeholder class="view-host" label="Team" subtitle="Workspace members & roles"></sdig-workspace-placeholder>`;
case 'settings': return html`<sdig-workspace-placeholder class="view-host" label="Settings" subtitle="Workspace, billing, security"></sdig-workspace-placeholder>`;
default: return html`<sdig-workspace-inbox class="view-host" .density=${this.density}></sdig-workspace-inbox>`;
}
}
public render(): TemplateResult {
return html`<div class="workspace ${this.density === 'compact' ? 'compact' : ''}" style="--accent: ${this.accent};" data-screen-label=${this.view}>${this.renderSidebar()}<main class="main">${this.renderView()}<div class="statusbar"><span style="display: inline-flex; align-items: center; gap: 5px;"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--success);"></span>api.signature.digital</span><span>eu-central-1</span><span>4 sigs queued</span><div style="flex: 1;"></div><span style="color: var(--accent);">Open Source · MIT</span><span>v0.42.1</span><span>${icon('git', 11)} main</span></div></main></div>`;
}
}
+1 -19
View File
@@ -1,21 +1,3 @@
// @signature.digital scope
import * as sdDemodata from '@signature.digital/tools/demodata';
import * as sdInterfaces from '@signature.digital/tools/interfaces';
import * as sdTools from '@signature.digital/tools';
export {
sdDemodata,
sdInterfaces,
sdTools,
}
// @design.estate scope
import * as deesCatalog from '@design.estate/dees-catalog';
export {
deesCatalog,
}
// third party
import signaturePadMod from 'signature_pad';
type signaturePadType = (typeof import('signature_pad'))['default'];
@@ -23,4 +5,4 @@ const signaturePad = signaturePadMod as any as signaturePadType;
export {
signaturePad,
}
}