8 Commits

Author SHA1 Message Date
jkunz a0dd552628 v1.5.1
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-02 19:53:40 +00:00
jkunz d693af8a26 fix(sdig-workspace): make workspace view externally controllable and preserve explicit initial state 2026-05-02 19:53:40 +00:00
jkunz 26bf48e87a v1.5.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-02 19:10:06 +00:00
jkunz fd53bc3db8 feat(elements): add reusable context menu element for recipient role selection 2026-05-02 19:10:06 +00:00
jkunz 87940efdef v1.4.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-02 18:54:34 +00:00
jkunz f08c4bfb7a feat(workspace-compose): add recipient routing roles and drag-and-drop routing management 2026-05-02 18:54:34 +00:00
jkunz f5cc69ed53 v1.3.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-02 18:37:48 +00:00
jkunz 57cbb739d2 feat(workspace): introduce a responsive signature workspace demo and remove legacy contract editor components 2026-05-02 18:37:48 +00:00
50 changed files with 4880 additions and 13349 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
.nogit/ .nogit/
.playwright-mcp/
# artifacts # artifacts
coverage/ coverage/
@@ -17,4 +18,4 @@ node_modules/
dist/ dist/
dist_*/ dist_*/
# custom # custom
+82
View File
@@ -0,0 +1,82 @@
{
"@git.zone/cli": {
"projectType": "wcc",
"module": {
"githost": "gitlab.com",
"gitscope": "signature.digital",
"gitrepo": "catalog",
"description": "A comprehensive catalog of customizable web components designed for building and managing e-signature applications.",
"npmPackagename": "@signature.digital/catalog",
"license": "MIT",
"projectDomain": "signature.digital",
"keywords": [
"e-signature",
"web components",
"digital signature",
"signature capture",
"ECMAScript Modules",
"typescript",
"component library",
"contract management",
"frontend development",
"signature pad",
"custom elements",
"electronic signing",
"npm package"
]
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
}
},
"@git.zone/tsbundle": {
"bundles": [
{
"from": "./html/index.ts",
"to": "./dist_bundle/bundle.js",
"outputMode": "bundle",
"bundler": "esbuild",
"production": true,
"includeFiles": [
"./html/index.html"
]
}
]
},
"@git.zone/tswatch": {
"server": {
"enabled": true,
"port": 3002,
"serveDir": "./dist_watch/",
"liveReload": true
},
"bundles": [
{
"name": "element-bundle",
"from": "./html/index.ts",
"to": "./dist_watch/bundle.js",
"watchPatterns": [
"./ts_web/**/*",
"./html/index.ts"
],
"triggerReload": true,
"bundler": "esbuild",
"production": false
},
{
"name": "html",
"from": "./html/index.html",
"to": "./dist_watch/index.html",
"watchPatterns": [
"./html/**/*"
],
"triggerReload": true
}
]
},
"@ship.zone/szci": {}
}
+28
View File
@@ -1,5 +1,33 @@
# Changelog # Changelog
## 2026-05-02 - 1.5.1 - fix(sdig-workspace)
make workspace view externally controllable and preserve explicit initial state
- change the workspace view from internal state to a reflected public property
- only apply initialView during connection when no non-default view has already been set
## 2026-05-02 - 1.5.0 - feat(elements)
add reusable context menu element for recipient role selection
- introduces a new sdig-contextmenu web component with configurable actions, selection state, and viewport-aware positioning
- exports the new context menu from the shared elements index
- refactors workspace compose to use the reusable context menu for recipient role changes while preserving signer role safeguards
## 2026-05-02 - 1.4.0 - feat(workspace-compose)
add recipient routing roles and drag-and-drop routing management
- Introduce recipient roles for signers, final-copy recipients, and step-update recipients.
- Replace simple recipient reordering with role-based drag-and-drop routing sections and contextual role assignment.
- Limit field assignment and active field tools to signing recipients, with automatic field reassignment when a signer is moved out of the signing flow.
## 2026-05-02 - 1.3.0 - feat(workspace)
introduce a responsive signature workspace demo and remove legacy contract editor components
- Adds a new `sdig-workspace` component with inbox, compose, sign, audit, developers, templates, team, and settings views.
- Removes the previous contract editor module and related contract subcomponents from exports and source files.
- Updates package exports, build configuration, and project metadata for the new workspace-focused catalog distribution.
- Improves sign pad and sign box null-safety and groups demos under signature primitives and workspace categories.
## 2025-12-18 - 1.2.0 - feat(icons) ## 2025-12-18 - 1.2.0 - feat(icons)
migrate icon usage to the new dees-icon API and integrate collaboration sidebar into the editor migrate icon usage to the new dees-icon API and integrate collaboration sidebar into the editor
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2014 Task Venture Capital GmbH (hello@task.vc)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-33
View File
@@ -1,33 +0,0 @@
{
"gitzone": {
"projectType": "wcc",
"module": {
"githost": "gitlab.com",
"gitscope": "signature.digital_private",
"gitrepo": "catalog",
"description": "A comprehensive catalog of customizable web components designed for building and managing e-signature applications.",
"npmPackagename": "@signature.digital_private/catalog",
"license": "UNLICENSED",
"projectDomain": "signature.digital",
"keywords": [
"e-signature",
"web components",
"digital signature",
"signature capture",
"ECMAScript Modules",
"typescript",
"component library",
"contract management",
"frontend development",
"signature pad",
"custom elements",
"electronic signing",
"npm package"
]
}
},
"npmci": {
"npmGlobalTools": [],
"npmAccessLevel": "private"
}
}
+18 -23
View File
@@ -1,33 +1,33 @@
{ {
"name": "@signature.digital/catalog", "name": "@signature.digital/catalog",
"version": "1.2.0", "version": "1.5.1",
"private": false, "private": false,
"description": "A comprehensive catalog of customizable web components designed for building and managing e-signature applications.", "description": "A comprehensive catalog of customizable web components designed for building and managing e-signature applications.",
"exports": { "exports": {
".": "./dist_ts_web/index.ts" ".": "./dist_ts_web/index.js"
}, },
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "npm run build", "test": "pnpm run build",
"build": "tsbuild element && tsbundle element --production", "build": "tsbuild tsfolders --allowimplicitany && tsbundle",
"watch": "tswatch element" "watch": "tswatch"
}, },
"author": "Task Venture Capital GmbH", "author": "Task Venture Capital GmbH",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@design.estate/dees-catalog": "^3.3.1", "@design.estate/dees-catalog": "3.81.0",
"@design.estate/dees-domtools": "^2.3.6", "@design.estate/dees-domtools": "^2.5.6",
"@design.estate/dees-element": "^2.1.3", "@design.estate/dees-element": "^2.2.4",
"@design.estate/dees-wcctools": "^2.0.1", "@design.estate/dees-wcctools": "^3.9.0",
"@git.zone/tsrun": "^2.0.1", "@git.zone/tsrun": "^2.0.3",
"@signature.digital/tools": "^1.1.0",
"signature_pad": "^5.1.3" "signature_pad": "^5.1.3"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^4.0.2", "@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.6.3", "@git.zone/tsbundle": "2.10.1",
"@git.zone/tswatch": "^2.3.13", "@git.zone/tswatch": "^3.3.3",
"@push.rocks/projectinfo": "^5.0.2" "@push.rocks/projectinfo": "^5.1.0",
"@types/node": "^25.6.0"
}, },
"files": [ "files": [
"ts/**/*", "ts/**/*",
@@ -38,7 +38,8 @@
"dist_ts_web/**/*", "dist_ts_web/**/*",
"assets/**/*", "assets/**/*",
"cli.js", "cli.js",
"npmextra.json", ".smartconfig.json",
"license",
"readme.md" "readme.md"
], ],
"browserslist": [ "browserslist": [
@@ -58,11 +59,5 @@
"custom elements", "custom elements",
"electronic signing", "electronic signing",
"npm package" "npm package"
], ]
"pnpm": {
"overrides": {
"@push.rocks/smartrequest": "^5.0.1",
"agentkeepalive": "^4.6.0"
}
}
} }
+2814 -3440
View File
File diff suppressed because it is too large Load Diff
+107 -111
View File
@@ -1,6 +1,8 @@
# @signature.digital/catalog # @signature.digital/catalog
A comprehensive catalog of customizable web components designed for building and managing e-signature applications. Built with modern web technologies using LitElement and TypeScript. Reusable web components for signature.digital product surfaces. The package ships signature capture primitives, a complete signature box, and a responsive workspace shell built with Lit and `@design.estate/dees-element`.
Use it when you need browser-native custom elements for signing flows without pulling product-specific authentication, persistence, routing, or API behavior into the component layer.
## Issue Reporting and Security ## Issue Reporting and Security
@@ -9,154 +11,148 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
## Install ## Install
```shell ```shell
npm install @signature.digital/catalog pnpm add @signature.digital/catalog
# or
pnpm install @signature.digital/catalog
``` ```
## 🎯 Overview ## Quick Start
This package provides three main components for e-signature workflows: Register all elements once in your browser bundle:
| Component | Tag | Description |
|-----------|-----|-------------|
| **SignPad** | `<sdig-signpad>` | Canvas-based signature capture pad |
| **SignBox** | `<sdig-signbox>` | Complete signing interface with controls |
| **ContractEditor** | `<sdig-contracteditor>` | Contract document management component |
## 📦 Usage
### Basic Import
```typescript ```typescript
import '@signature.digital/catalog'; import '@signature.digital/catalog';
``` ```
This registers all custom elements and makes them available for use in your HTML. Render the product workspace:
### SignPad Component
The `<sdig-signpad>` is a canvas-based signature capture component that allows users to draw their signatures directly in the browser.
```html ```html
<sdig-signpad></sdig-signpad> <sdig-workspace accent="#3b82f6" density="comfortable" theme="dark"></sdig-workspace>
``` ```
**API Methods:** Render a standalone signature box:
```typescript
const signpad = document.querySelector('sdig-signpad');
// Get signature data as point arrays
const data = await signpad.toData();
// Load signature from data
await signpad.fromData(data);
// Export signature as SVG string
const svg = await signpad.toSVG();
// Undo last stroke
await signpad.undo();
// Clear the signature pad
await signpad.clear();
```
### SignBox Component
The `<sdig-signbox>` wraps `SignPad` with a complete UI including Clear, Undo, and Submit buttons.
```html ```html
<sdig-signbox></sdig-signbox> <sdig-signbox></sdig-signbox>
``` ```
**Events:** ## Components
| Component | Tag | Purpose |
|-----------|-----|---------|
| `SdigWorkspace` | `<sdig-workspace>` | Product workspace shell with inbox, compose, signing, audit, developer, template, team, and settings views |
| `SignBox` | `<sdig-signbox>` | Complete signature capture UI with clear, undo, and submit actions |
| `SignPad` | `<sdig-signpad>` | Canvas-based signature capture primitive backed by `signature_pad` |
## Workspace Shell
`sdig-workspace` is the full product surface. It is responsive, supports dark/light themes, compact/comfortable density, an accent color, and a configurable initial view.
```html
<sdig-workspace
accent="#7c3aed"
density="compact"
theme="light"
initialView="compose"
></sdig-workspace>
```
### Workspace Properties
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `accent` | `string` | `#3b82f6` | Accent color used for active navigation and highlights |
| `density` | `'compact' \| 'comfortable'` | `comfortable` | Controls spacing density |
| `theme` | `'dark' \| 'light'` | `dark` | Reflected to the host and used for design tokens |
| `initialView` | `TWorkspaceView` | `inbox` | First view displayed after connection |
### Workspace Views
| View | Notes |
|------|-------|
| `inbox` | Demo document inbox with status, recipients, sender, page count, and deadlines |
| `compose` | Document preparation surface with recipients and draggable-style field placements |
| `sign` | Signing flow surface for reviewing and completing assigned fields |
| `audit` | Audit trail surface for signature and document events |
| `developers` | Developer-facing integration and API concept surface |
| `templates` | Placeholder for reusable agreement templates |
| `team` | Placeholder for workspace members and roles |
| `settings` | Placeholder for workspace, billing, and security settings |
### Workspace Events
```typescript
const workspace = document.querySelector('sdig-workspace');
workspace?.addEventListener('view-change', (event) => {
const { view } = (event as CustomEvent<{ view: string }>).detail;
console.log(view);
});
```
Child views can request navigation by dispatching `workspace-view-request` with `{ view }` in `detail`. The event bubbles and crosses the shadow boundary.
## Signature Box
Use `sdig-signbox` when you want the default capture UI with a heading, a signature pad, and built-in action controls:
```html
<sdig-signbox></sdig-signbox>
```
```typescript ```typescript
const signbox = document.querySelector('sdig-signbox'); const signbox = document.querySelector('sdig-signbox');
signbox.addEventListener('signature', (event) => { signbox?.addEventListener('signature', (event) => {
const signatureData = event.detail.signature; const { signature } = (event as CustomEvent<{ signature: unknown[] }>).detail;
console.log('Signature captured:', signatureData); console.log(signature);
}); });
``` ```
### ContractEditor Component The `signature` event detail contains the `signature_pad` stroke data returned by `sdig-signpad.toData()`.
The `<sdig-contracteditor>` provides contract viewing and editing capabilities using the `IPortableContract` interface from `@signature.digital/tools`. ## Signature Pad
Use `sdig-signpad` directly when you need lower-level control over the canvas:
```html
<sdig-signpad></sdig-signpad>
```
```typescript ```typescript
import '@signature.digital/catalog'; const signpad = document.querySelector('sdig-signpad');
import { IPortableContract } from '@signature.digital/tools/interfaces';
import { demoContract } from '@signature.digital/tools/demodata';
const editor = document.querySelector('sdig-contracteditor'); const strokes = await signpad?.toData();
editor.contract = demoContract; const svg = await signpad?.toSVG();
await signpad?.undo();
await signpad?.clear();
``` ```
## 🔧 Integration Example | Method | Description |
|--------|-------------|
| `toData()` | Returns `signature_pad` stroke data |
| `fromData(data)` | Restores `signature_pad` stroke data |
| `toSVG()` | Returns an SVG representation without a background color |
| `undo()` | Removes the latest stroke |
| `clear()` | Clears the canvas |
Here's a complete example showing all components working together: ## Demo Data Boundary
```typescript The workspace currently renders local demo UI data from `sdig-workspace.shared.ts`. Real accounts, server persistence, document import/export, notification delivery, authentication, and legal signing workflow state belong in `@signature.digital/app` or backend services, not in this component catalog.
import '@signature.digital/catalog';
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('my-signature-app') ## Development
class MySignatureApp extends LitElement {
static styles = css`
:host {
display: block;
max-width: 800px;
margin: 0 auto;
}
`;
render() { ```shell
return html` pnpm install
<h2>Please sign below</h2> pnpm run build
<sdig-signbox @signature=${this.handleSignature}></sdig-signbox> pnpm test
`; pnpm run watch
}
private handleSignature(e: CustomEvent) {
console.log('Signature submitted:', e.detail.signature);
// Process or store the signature
}
}
``` ```
## 🎨 Theming The build compiles `ts_web/` into `dist_ts_web/` and bundles the component catalog demo from `html/index.ts` into `dist_bundle/`. Watch mode serves `dist_watch/` using the `.smartconfig.json` `@git.zone/tswatch` configuration.
Components support automatic light/dark theme detection and can be customized using CSS custom properties:
```css
sdig-signpad, sdig-signbox {
--main-background-color: #ffffff;
--line-color: #000000;
--button-color: #007bff;
}
```
The components use `cssManager.bdTheme()` for automatic theme switching based on system preferences.
## 📋 Requirements
- Modern browser with Custom Elements V1 support
- ECMAScript Modules (ESM) compatible environment
- TypeScript 5.0+ (for development)
## 🔗 Dependencies
- `@design.estate/dees-element` - LitElement-based component framework
- `@signature.digital/tools` - Contract interfaces and demo data
- `signature_pad` - Canvas signature capture library
## License and Legal Information ## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file. This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
@@ -168,7 +164,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
### Company Information ### Company Information
Task Venture Capital GmbH Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc. For any legal inquiries or further information, please contact us via email at hello@task.vc.
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@signature.digital/catalog', name: '@signature.digital/catalog',
version: '1.2.0', version: '1.5.1',
description: 'A comprehensive catalog of customizable web components designed for building and managing e-signature applications.' description: 'A comprehensive catalog of customizable web components designed for building and managing e-signature applications.'
} }
+4 -15
View File
@@ -1,18 +1,7 @@
// 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 // Signature components
export * from './sdig-contextmenu/index.js';
export * from './sdig-signbox/index.js'; export * from './sdig-signbox/index.js';
export * from './sdig-signpad/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>
`;
}
}
@@ -0,0 +1 @@
export * from './sdig-contextmenu.js';
@@ -0,0 +1,234 @@
import { DeesElement, property, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
export interface ISdigContextMenuAction {
id: string;
label: string;
description?: string;
selected?: boolean;
disabled?: boolean;
danger?: boolean;
}
export interface ISdigContextMenuActionEventDetail {
id: string;
action: ISdigContextMenuAction;
}
type TMenuPosition = {
x: number;
y: number;
ready: boolean;
};
declare global {
interface HTMLElementTagNameMap {
'sdig-contextmenu': SdigContextmenu;
}
}
@customElement('sdig-contextmenu')
export class SdigContextmenu extends DeesElement {
public static demo = () => html`
<div style="position: relative; min-height: 260px; padding: 24px; --bg-card: hsl(0 0% 7%); --bg-input: hsl(0 0% 9%); --border: hsl(0 0% 14.9%); --border-subtle: hsl(0 0% 11%); --text: hsl(0 0% 98%); --text-sec: hsl(0 0% 63.9%); --text-muted: hsl(0 0% 48%); --hover: rgba(255,255,255,0.06); --error: #ef4444;">
<sdig-contextmenu
.anchorX=${80}
.anchorY=${70}
.title=${'Recipient'}
.actions=${[
{ id: 'signer', label: 'Needs signature', selected: true },
{ id: 'copy', label: 'Final copy only' },
{ id: 'updates', label: 'Every step update' },
]}
></sdig-contextmenu>
</div>
`;
public static demoGroups = ['Signature Digital Primitives'];
@property({ type: Number }) public accessor anchorX: number = 0;
@property({ type: Number }) public accessor anchorY: number = 0;
@property({ type: String }) public accessor title: string = '';
@property({ attribute: false }) public accessor actions: ISdigContextMenuAction[] = [];
@state() private accessor position: TMenuPosition = { x: 0, y: 0, ready: false };
private positionUpdateFrame: number | null = null;
public static styles = css`
:host { display: contents; }
.menu {
position: fixed;
z-index: 1000;
min-width: 190px;
max-width: min(280px, calc(100vw - 16px));
padding: 6px;
border: 1px solid var(--border, hsl(0 0% 14.9%));
border-radius: 8px;
background: var(--bg-card, hsl(0 0% 7%));
color: var(--text, hsl(0 0% 98%));
box-shadow: 0 16px 42px rgba(0,0,0,0.36);
box-sizing: border-box;
}
.title {
padding: 7px 8px;
margin-bottom: 4px;
border-bottom: 1px solid var(--border-subtle, hsl(0 0% 11%));
font-size: 11px;
font-weight: 700;
color: var(--text-sec, hsl(0 0% 63.9%));
}
.action {
width: 100%;
min-height: 34px;
padding: 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-sec, hsl(0 0% 63.9%));
display: flex;
align-items: center;
gap: 8px;
text-align: left;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.action:hover { background: var(--hover, rgba(255,255,255,0.06)); color: var(--text, hsl(0 0% 98%)); }
.action.danger { color: var(--error, #ef4444); }
.action[disabled] { opacity: 0.45; cursor: not-allowed; }
.action[disabled]:hover { background: transparent; color: var(--text-sec, hsl(0 0% 63.9%)); }
.action-mark {
width: 12px;
height: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.action-mark.selected::before {
content: '';
width: 7px;
height: 4px;
border-left: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(-45deg) translate(1px, -1px);
}
.action-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.action-label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.action-description {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--text-muted, hsl(0 0% 48%));
font-size: 10px;
line-height: 1.25;
}
`;
public connectedCallback = async () => {
await super.connectedCallback();
window.addEventListener('resize', this.queuePositionUpdate);
};
public disconnectedCallback = async () => {
window.removeEventListener('resize', this.queuePositionUpdate);
if (this.positionUpdateFrame !== null) {
globalThis.cancelAnimationFrame(this.positionUpdateFrame);
this.positionUpdateFrame = null;
}
await super.disconnectedCallback();
};
public updated() {
this.queuePositionUpdate();
}
private queuePositionUpdate = () => {
if (this.positionUpdateFrame !== null) return;
this.positionUpdateFrame = globalThis.requestAnimationFrame(() => {
this.positionUpdateFrame = null;
this.positionMenu();
});
};
private positionMenu() {
const menu = this.shadowRoot?.querySelector('.menu') as HTMLElement | null;
if (!menu) return;
const margin = 8;
const gap = 4;
const rect = menu.getBoundingClientRect();
const viewportWidth = globalThis.innerWidth;
const viewportHeight = globalThis.innerHeight;
const spaceRight = viewportWidth - this.anchorX - margin;
const spaceLeft = this.anchorX - margin;
const spaceBelow = viewportHeight - this.anchorY - margin;
const spaceAbove = this.anchorY - margin;
let x = this.anchorX + gap;
let y = this.anchorY + gap;
if (spaceRight < rect.width + gap && spaceLeft > spaceRight) {
x = this.anchorX - rect.width - gap;
}
if (spaceBelow < rect.height + gap && spaceAbove > spaceBelow) {
y = this.anchorY - rect.height - gap;
}
const maxX = Math.max(margin, viewportWidth - rect.width - margin);
const maxY = Math.max(margin, viewportHeight - rect.height - margin);
const nextPosition = {
x: Math.round(Math.max(margin, Math.min(maxX, x))),
y: Math.round(Math.max(margin, Math.min(maxY, y))),
ready: true,
};
if (this.position.x !== nextPosition.x || this.position.y !== nextPosition.y || this.position.ready !== nextPosition.ready) {
this.position = nextPosition;
}
}
private selectAction(action: ISdigContextMenuAction) {
if (action.disabled) return;
this.dispatchEvent(new CustomEvent<ISdigContextMenuActionEventDetail>('contextmenu-action', {
detail: { id: action.id, action },
bubbles: true,
composed: true,
}));
}
public render(): TemplateResult {
const x = this.position.ready ? this.position.x : this.anchorX;
const y = this.position.ready ? this.position.y : this.anchorY;
return html`
<div class="menu" style="left: ${x}px; top: ${y}px; visibility: ${this.position.ready ? 'visible' : 'hidden'};" @click=${(event: Event) => event.stopPropagation()} @contextmenu=${(event: Event) => event.preventDefault()}>
${this.title ? html`<div class="title">${this.title}</div>` : ''}
${this.actions.map((action) => html`
<button class="action ${action.danger ? 'danger' : ''}" ?disabled=${action.disabled} @click=${() => this.selectAction(action)}>
<span class="action-mark ${action.selected ? 'selected' : ''}"></span>
<span class="action-copy">
<span class="action-label">${action.label}</span>
${action.description ? html`<span class="action-description">${action.description}</span>` : ''}
</span>
</button>
`)}
</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 { DeesElement, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@@ -12,6 +11,7 @@ export class SignBox extends DeesElement {
public static demo = () => html` public static demo = () => html`
<sdig-signbox></sdig-signbox> <sdig-signbox></sdig-signbox>
`; `;
public static demoGroups = ['Signature Digital Primitives'];
constructor() { constructor() {
super(); super();
@@ -80,17 +80,19 @@ export class SignBox extends DeesElement {
<sdig-signpad></sdig-signpad> <sdig-signpad></sdig-signpad>
<div class="actions"> <div class="actions">
<div class="button" @click=${async () => { <div class="button" @click=${async () => {
await this.shadowRoot.querySelector('sdig-signpad').clear(); await this.shadowRoot?.querySelector('sdig-signpad')?.clear();
}}> }}>
Clear Clear
</div> </div>
<div class="button" @click=${async () => { <div class="button" @click=${async () => {
await this.shadowRoot.querySelector('sdig-signpad').undo(); await this.shadowRoot?.querySelector('sdig-signpad')?.undo();
}}> }}>
Undo Undo
</div> </div>
<div class="button" @click=${async () => { <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', { this.dispatchEvent(new CustomEvent('signature', {
detail: { detail: {
signature, signature,
@@ -104,4 +106,4 @@ export class SignBox extends DeesElement {
</div> </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'; import * as plugins from '../../plugins.js';
declare global { declare global {
@@ -12,6 +12,7 @@ export class SignPad extends DeesElement {
public static demo = () => html` public static demo = () => html`
<sdig-signpad></sdig-signpad> <sdig-signpad></sdig-signpad>
`; `;
public static demoGroups = ['Signature Digital Primitives'];
constructor() { 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() { 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'); const canvas = document.createElement('canvas');
this.shadowRoot.querySelector('.mainbox').appendChild(canvas); mainbox.appendChild(canvas);
await this.resizeCanvas(); await this.resizeCanvas();
this.signaturePad = new plugins.signaturePad(canvas, { this.signaturePad = new plugins.signaturePad(canvas, {
@@ -72,10 +76,11 @@ export class SignPad extends DeesElement {
} }
public async resizeCanvas() { 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 mainboxWidth = mainbox.clientWidth;
const mainboxHeight = mainbox.clientHeight; const mainboxHeight = mainbox.clientHeight;
const canvas = this.shadowRoot.querySelector('canvas');
canvas.width = mainboxWidth; canvas.width = mainboxWidth;
canvas.height = mainboxHeight; canvas.height = mainboxHeight;
if (this.signaturePad) { if (this.signaturePad) {
@@ -84,22 +89,22 @@ export class SignPad extends DeesElement {
} }
public async clear() { public async clear() {
this.signaturePad.clear(); this.signaturePad?.clear();
} }
public async toData() { public async toData() {
const returnData = this.signaturePad.toData(); const returnData = this.signaturePad?.toData() || [];
return returnData; return returnData;
} }
public async fromData(dataArrayArg: any[]) { public async fromData(dataArrayArg: any[]) {
this.signaturePad.fromData(dataArrayArg); this.signaturePad?.fromData(dataArrayArg);
} }
public async toSVG() { public async toSVG() {
return this.signaturePad.toSVG({ return this.signaturePad?.toSVG({
includeBackgroundColor: false, includeBackgroundColor: false,
}); }) || '';
} }
public async undo() { public async undo() {
@@ -107,4 +112,4 @@ export class SignPad extends DeesElement {
data.pop(); data.pop();
await this.fromData(data); 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,601 @@
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, type TRecipientRole } from './sdig-workspace.shared.js';
import '../sdig-contextmenu/index.js';
import { type ISdigContextMenuAction, type ISdigContextMenuActionEventDetail } from '../sdig-contextmenu/index.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;
};
type TSigningOrderDrag = {
recipientId: number;
pointerY: number;
listTop: number;
grabOffsetY: number;
itemHeight: number;
itemStep: number;
targetRole: TRecipientRole;
targetIndex: number;
};
type TRecipientContextMenu = {
recipientId: number;
x: number;
y: 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'];
const recipientRoleDefinitions: Array<{ role: TRecipientRole; label: string; shortLabel: string; description: string }> = [
{ role: 'signer', label: 'Needs signature', shortLabel: 'Signer', description: 'Can receive fields and must sign in order.' },
{ role: 'copy', label: 'Final copy only', shortLabel: 'Copy', description: 'Receives the completed document after signing.' },
{ role: 'updates', label: 'Every step update', shortLabel: 'Updates', description: 'Receives notifications for every routing step.' },
];
@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 selectedFieldId: string | null = null;
@state() private accessor recipients: IRecipient[] = [...demoRecipients];
@state() private accessor fields: IFieldPlacement[] = [...demoFields];
@state() private accessor signingOrderDrag: TSigningOrderDrag | null = null;
@state() private accessor recipientContextMenu: TRecipientContextMenu | null = null;
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; }
.routing-role-section { margin-bottom: 14px; padding: 10px; border: 1px solid var(--border-subtle); border-radius: 8px; background: color-mix(in srgb, var(--bg-card) 72%, transparent); transition: border-color 0.14s ease, background 0.14s ease; }
.routing-role-section.active-drop { border-color: color-mix(in srgb, var(--accent) 48%, var(--border)); background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); }
.routing-role-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
.routing-role-title { font-size: 11px; font-weight: 700; color: var(--text-sec); }
.routing-role-description { margin-bottom: 8px; font-size: 10px; line-height: 1.35; color: var(--text-muted); }
.role-count { min-width: 18px; height: 18px; padding: 0 6px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; background: var(--bg-input); color: var(--text-muted); font-size: 10px; }
.role-chip { height: 22px; padding: 0 7px; border-radius: 999px; display: inline-flex; align-items: center; background: var(--bg-input); color: var(--text-muted); font-size: 10px; font-weight: 600; }
.signing-order-list { position: relative; min-height: 44px; }
.signing-order-list.dragging { height: var(--routing-list-height); min-height: var(--routing-list-height); }
.signing-order-list::before { content: ''; position: absolute; left: 11px; top: 10px; bottom: 10px; width: 1px; background: var(--border); }
.signing-recipient { position: relative; z-index: 1; transition: transform 0.14s ease, opacity 0.14s ease, border-color 0.14s ease; }
.signing-order-list.dragging .signing-recipient:not(.signing-drag-overlay) { position: absolute; left: 0; right: 0; top: var(--routing-top); margin-bottom: 0; transition: top 0.16s ease, transform 0.14s ease, opacity 0.14s ease, border-color 0.14s ease; }
.signing-placeholder { position: absolute; left: 0; right: 0; top: var(--routing-top); height: var(--routing-row-height); border: 1.5px dashed var(--accent); border-radius: 6px; background: transparent; pointer-events: none; transition: top 0.16s ease; }
.signing-drag-overlay { position: absolute; left: 0; right: 0; z-index: 6; top: var(--routing-top); margin-bottom: 0; cursor: grabbing; pointer-events: none; border-color: var(--accent); box-shadow: 0 10px 28px rgba(0,0,0,0.28); transform: scale(1.015); }
.role-hint { margin-top: -2px; margin-bottom: 10px; font-size: 10px; line-height: 1.45; color: var(--text-muted); }
.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();
this.stopSigningOrderDrag();
window.removeEventListener('click', this.closeRecipientContextMenu);
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 recipientRoleDefinition(role: TRecipientRole) {
return recipientRoleDefinitions.find((definition) => definition.role === role) || recipientRoleDefinitions[0];
}
private signingRecipients(): IRecipient[] {
return this.recipients.filter((recipient) => recipient.role === 'signer');
}
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 updateRecipientRole(recipientId: number, role: TRecipientRole) {
const recipient = this.recipients.find((currentRecipient) => currentRecipient.id === recipientId);
if (!recipient) return;
const signerCount = this.signingRecipients().length;
if (recipient.role === 'signer' && role !== 'signer' && signerCount <= 1) return;
this.moveRecipientToRole(recipientId, role);
}
private moveRecipientToRole(recipientId: number, role: TRecipientRole, targetIndex?: number) {
const recipient = this.recipients.find((currentRecipient) => currentRecipient.id === recipientId);
if (!recipient) return;
const signerCount = this.signingRecipients().length;
const nextRole = recipient.role === 'signer' && role !== 'signer' && signerCount <= 1 ? 'signer' : role;
const withoutRecipient = this.recipients.filter((currentRecipient) => currentRecipient.id !== recipientId);
const nextByRole = new Map<TRecipientRole, IRecipient[]>();
for (const roleDefinition of recipientRoleDefinitions) {
nextByRole.set(roleDefinition.role, withoutRecipient.filter((currentRecipient) => currentRecipient.role === roleDefinition.role));
}
const targetMembers = [...(nextByRole.get(nextRole) || [])];
const insertIndex = targetIndex === undefined ? targetMembers.length : this.clamp(targetIndex, 0, targetMembers.length);
targetMembers.splice(insertIndex, 0, { ...recipient, role: nextRole });
nextByRole.set(nextRole, targetMembers);
this.recipients = recipientRoleDefinitions.flatMap((roleDefinition) => nextByRole.get(roleDefinition.role) || []).map((currentRecipient, index) => ({ ...currentRecipient, order: index + 1 }));
const nextSigners = this.recipients.filter((currentRecipient) => currentRecipient.role === 'signer');
const fallbackSigner = nextSigners[0];
if (nextRole !== 'signer' && fallbackSigner) {
this.fields = this.fields.map((field) => field.recipient === recipientId ? { ...field, recipient: fallbackSigner.id } : field);
if (this.activeRecipient === recipientId) {
this.activeRecipient = fallbackSigner.id;
}
}
}
private openRecipientContextMenu(event: MouseEvent, recipient: IRecipient) {
event.preventDefault();
event.stopPropagation();
this.recipientContextMenu = { recipientId: recipient.id, x: event.clientX, y: event.clientY };
window.removeEventListener('click', this.closeRecipientContextMenu);
setTimeout(() => window.addEventListener('click', this.closeRecipientContextMenu, { once: true }), 0);
}
private closeRecipientContextMenu = () => {
this.recipientContextMenu = null;
};
private recipientContextMenuActions(recipient: IRecipient): ISdigContextMenuAction[] {
const signerCount = this.signingRecipients().length;
return recipientRoleDefinitions.map((roleDefinition) => ({
id: roleDefinition.role,
label: roleDefinition.label,
selected: recipient.role === roleDefinition.role,
disabled: recipient.role === 'signer' && roleDefinition.role !== 'signer' && signerCount <= 1,
}));
}
private handleRecipientContextMenuAction(event: CustomEvent<ISdigContextMenuActionEventDetail>, recipient: IRecipient) {
const role = event.detail.id as TRecipientRole;
if (!recipientRoleDefinitions.some((roleDefinition) => roleDefinition.role === role)) return;
this.updateRecipientRole(recipient.id, role);
this.closeRecipientContextMenu();
}
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 visualSigningOrder(): IRecipient[] {
if (!this.signingOrderDrag) return this.recipients;
const dragged = this.recipients.find((recipient) => recipient.id === this.signingOrderDrag?.recipientId);
if (!dragged) return this.recipients;
const others = this.recipients.filter((recipient) => recipient.role === this.signingOrderDrag?.targetRole && recipient.id !== dragged.id);
const targetIndex = this.clamp(this.signingOrderDrag.targetIndex, 0, others.length);
return [...others.slice(0, targetIndex), dragged, ...others.slice(targetIndex)];
}
private recipientsForRole(role: TRecipientRole): IRecipient[] {
if (!this.signingOrderDrag) return this.recipients.filter((recipient) => recipient.role === role);
const dragged = this.recipients.find((recipient) => recipient.id === this.signingOrderDrag?.recipientId);
const recipients = this.recipients.filter((recipient) => recipient.role === role && recipient.id !== dragged?.id);
if (!dragged || this.signingOrderDrag.targetRole !== role) return recipients;
const targetIndex = this.clamp(this.signingOrderDrag.targetIndex, 0, recipients.length);
return [...recipients.slice(0, targetIndex), { ...dragged, role }, ...recipients.slice(targetIndex)];
}
private startSigningOrderDrag(event: PointerEvent, recipient: IRecipient) {
if (event.button !== 0) return;
const target = event.target as HTMLElement | null;
if (target?.closest('select, input, button')) return;
const item = event.currentTarget as HTMLElement;
const list = item.closest('.signing-order-list') as HTMLElement | null;
if (!list) return;
const section = item.closest('.routing-role-section') as HTMLElement | null;
const role = (section?.dataset.role || recipient.role) as TRecipientRole;
const itemRect = item.getBoundingClientRect();
const listRect = list.getBoundingClientRect();
const marginBottom = Number.parseFloat(globalThis.getComputedStyle(item).marginBottom || '0');
const startIndex = this.recipients.filter((currentRecipient) => currentRecipient.role === role).findIndex((currentRecipient) => currentRecipient.id === recipient.id);
this.signingOrderDrag = {
recipientId: recipient.id,
pointerY: event.clientY,
listTop: listRect.top,
grabOffsetY: event.clientY - itemRect.top,
itemHeight: itemRect.height,
itemStep: itemRect.height + marginBottom,
targetRole: role,
targetIndex: Math.max(0, startIndex),
};
event.preventDefault();
window.addEventListener('pointermove', this.handleSigningOrderPointerMove, { passive: false });
window.addEventListener('pointerup', this.stopSigningOrderDrag);
window.addEventListener('pointercancel', this.stopSigningOrderDrag);
}
private handleSigningOrderPointerMove = (event: PointerEvent) => {
if (!this.signingOrderDrag) return;
event.preventDefault();
const drag = this.signingOrderDrag;
const target = this.shadowRoot?.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;
const section = target?.closest('.routing-role-section') as HTMLElement | null;
const roleCandidate = (section?.dataset.role || drag.targetRole) as TRecipientRole;
const draggedRecipient = this.recipients.find((recipient) => recipient.id === drag.recipientId);
const targetRole = draggedRecipient?.role === 'signer' && roleCandidate !== 'signer' && this.signingRecipients().length <= 1 ? 'signer' : roleCandidate;
const list = this.shadowRoot?.querySelector(`.routing-role-section[data-role="${targetRole}"] .signing-order-list`) as HTMLElement | null;
const listRect = list?.getBoundingClientRect();
const listTop = listRect?.top ?? drag.listTop;
const targetMemberCount = this.recipients.filter((recipient) => recipient.role === targetRole && recipient.id !== drag.recipientId).length;
const draggedCenterY = event.clientY - listTop - drag.grabOffsetY + drag.itemStep / 2;
const targetIndex = Math.round(this.clamp(draggedCenterY / drag.itemStep, 0, targetMemberCount));
this.signingOrderDrag = { ...drag, pointerY: event.clientY, listTop, targetRole, targetIndex };
};
private stopSigningOrderDrag = () => {
if (this.signingOrderDrag) {
this.moveRecipientToRole(this.signingOrderDrag.recipientId, this.signingOrderDrag.targetRole, this.signingOrderDrag.targetIndex);
}
this.signingOrderDrag = null;
window.removeEventListener('pointermove', this.handleSigningOrderPointerMove);
window.removeEventListener('pointerup', this.stopSigningOrderDrag);
window.removeEventListener('pointercancel', this.stopSigningOrderDrag);
};
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.signingRecipients().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 renderSigningRecipient(recipient: IRecipient, orderNumber: number, options: { overlayTop?: number; rowTop?: number; displayRole?: TRecipientRole } = {}): TemplateResult {
const initials = recipient.name.split(' ').map((part) => part[0]).slice(0, 2).join('');
const isOverlay = options.overlayTop !== undefined;
const top = options.overlayTop ?? options.rowTop;
const displayRole = options.displayRole || recipient.role;
return html`<div class="recipient-line signing-recipient ${isOverlay ? 'signing-drag-overlay' : ''}" style="${top !== undefined ? `--routing-top: ${top}px;` : ''}" @contextmenu=${(event: MouseEvent) => this.openRecipientContextMenu(event, recipient)} @pointerdown=${!isOverlay ? (event: PointerEvent) => this.startSigningOrderDrag(event, recipient) : undefined}><span class="mono" style="width: 14px; font-size: 10px; color: ${isOverlay ? 'var(--accent)' : 'var(--text-muted)'};">${orderNumber}</span><span class="avatar" style="background: ${recipient.color};">${initials}</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;">${this.recipientRoleDefinition(displayRole).description}</div></div><span class="role-chip">${this.recipientRoleDefinition(displayRole).shortLabel}</span></div>`;
}
private renderRoleSection(roleDefinition: typeof recipientRoleDefinitions[number]): TemplateResult {
const role = roleDefinition.role;
const members = this.recipientsForRole(role);
const isTargetRole = this.signingOrderDrag?.targetRole === role;
const draggedRecipientId = this.signingOrderDrag?.recipientId;
const draggedRecipient = draggedRecipientId !== undefined ? this.recipients.find((recipient) => recipient.id === draggedRecipientId) : undefined;
if (!this.signingOrderDrag) {
return html`<div class="routing-role-section" data-role=${role}><div class="routing-role-head"><span class="routing-role-title">${roleDefinition.label}</span><span class="role-count">${members.length}</span></div><div class="routing-role-description">${roleDefinition.description}</div><div class="signing-order-list">${members.map((recipient, index) => this.renderSigningRecipient(recipient, index + 1))}</div></div>`;
}
const visualIndexById = new Map(members.map((recipient, index) => [recipient.id, index]));
const overlayTop = isTargetRole ? this.signingOrderDrag.pointerY - this.signingOrderDrag.listTop - this.signingOrderDrag.grabOffsetY : 0;
const draggedOrder = draggedRecipient ? (members.findIndex((recipient) => recipient.id === draggedRecipient.id) + 1 || this.signingOrderDrag.targetIndex + 1) : 0;
return html`<div class="routing-role-section ${isTargetRole ? 'active-drop' : ''}" data-role=${role}><div class="routing-role-head"><span class="routing-role-title">${roleDefinition.label}</span><span class="role-count">${members.filter((recipient) => recipient.id !== draggedRecipientId).length + (isTargetRole ? 1 : 0)}</span></div><div class="routing-role-description">${roleDefinition.description}</div><div class="signing-order-list dragging" style="--routing-list-height: ${Math.max(1, members.length) * this.signingOrderDrag.itemStep}px;">
${members.filter((recipient) => recipient.id !== draggedRecipientId).map((recipient) => {
const visualIndex = visualIndexById.get(recipient.id) ?? 0;
return this.renderSigningRecipient(recipient, visualIndex + 1, { rowTop: visualIndex * this.signingOrderDrag!.itemStep, displayRole: role });
})}
${isTargetRole ? html`<div class="signing-placeholder" style="--routing-top: ${this.signingOrderDrag.targetIndex * this.signingOrderDrag.itemStep}px; --routing-row-height: ${this.signingOrderDrag.itemHeight}px;"></div>` : ''}
${isTargetRole && draggedRecipient ? this.renderSigningRecipient(draggedRecipient, draggedOrder, { overlayTop, displayRole: role }) : ''}
</div></div>`;
}
private renderSigningOrder(): TemplateResult {
return html`${recipientRoleDefinitions.map((roleDefinition) => this.renderRoleSection(roleDefinition))}`;
}
private renderRecipientContextMenu(): TemplateResult {
if (!this.recipientContextMenu) return html``;
const recipient = this.recipients.find((currentRecipient) => currentRecipient.id === this.recipientContextMenu?.recipientId);
if (!recipient) return html``;
return html`
<sdig-contextmenu
.anchorX=${this.recipientContextMenu.x}
.anchorY=${this.recipientContextMenu.y}
.title=${recipient.name}
.actions=${this.recipientContextMenuActions(recipient)}
@contextmenu-action=${(event: CustomEvent<ISdigContextMenuActionEventDetail>) => this.handleRecipientContextMenuAction(event, recipient)}
></sdig-contextmenu>
`;
}
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">
${this.renderRecipientContextMenu()}
<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.signingRecipients().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">Routing order · drag to reorder</div>
<div class="role-hint">Choose who signs, who gets the completed copy, and who is notified at every step.</div>
${this.renderSigningOrder()}
${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,442 @@
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 type TRecipientRole = 'signer' | 'copy' | 'updates';
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;
role: TRecipientRole;
}
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, role: 'signer' },
{ id: 1, name: 'David Park', email: 'd.park@acme.com', color: '#fbbf24', order: 2, role: 'signer' },
{ id: 2, name: 'Philipp K.', email: 'philipp@lossless.com', color: '#3b82f6', order: 3, role: 'updates' },
];
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,177 @@
import { DeesElement, property, 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';
@property({ type: String, reflect: true }) public accessor view: TWorkspaceView = 'inbox';
public connectedCallback = async () => {
await super.connectedCallback();
if (this.view === 'inbox' && this.initialView !== 'inbox') {
this.view = this.initialView;
}
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 // third party
import signaturePadMod from 'signature_pad'; import signaturePadMod from 'signature_pad';
type signaturePadType = (typeof import('signature_pad'))['default']; type signaturePadType = (typeof import('signature_pad'))['default'];
@@ -23,4 +5,4 @@ const signaturePad = signaturePadMod as any as signaturePadType;
export { export {
signaturePad, signaturePad,
} }
+2 -1
View File
@@ -5,7 +5,8 @@
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"esModuleInterop": true, "esModuleInterop": true,
"verbatimModuleSyntax": true "verbatimModuleSyntax": true,
"types": ["node"]
}, },
"exclude": [ "exclude": [
"dist_*/**/*.d.ts" "dist_*/**/*.d.ts"