Merge branch 'dev'
This commit is contained in:
commit
0c56569a00
|
|
@ -591,6 +591,7 @@ class FolkFundsApp extends HTMLElement {
|
||||||
<button class="funds-canvas-btn" data-canvas-action="zoom-in">+</button>
|
<button class="funds-canvas-btn" data-canvas-action="zoom-in">+</button>
|
||||||
<button class="funds-canvas-btn" data-canvas-action="zoom-out">−</button>
|
<button class="funds-canvas-btn" data-canvas-action="zoom-out">−</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="funds-node-tooltip" id="node-tooltip" style="display:none"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -816,7 +817,33 @@ class FolkFundsApp extends HTMLElement {
|
||||||
const group = (e.target as Element).closest(".flow-node") as SVGGElement | null;
|
const group = (e.target as Element).closest(".flow-node") as SVGGElement | null;
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
const nodeId = group.dataset.nodeId;
|
const nodeId = group.dataset.nodeId;
|
||||||
if (nodeId) this.openEditor(nodeId);
|
if (!nodeId) return;
|
||||||
|
const node = this.nodes.find((n) => n.id === nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
if (node.type === "outcome") this.openOutcomeModal(nodeId);
|
||||||
|
else if (node.type === "source") this.openSourceModal(nodeId);
|
||||||
|
else this.openEditor(nodeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hover: tooltip + edge highlighting
|
||||||
|
let hoveredNodeId: string | null = null;
|
||||||
|
nodeLayer.addEventListener("mouseover", (e: MouseEvent) => {
|
||||||
|
const group = (e.target as Element).closest(".flow-node") as SVGGElement | null;
|
||||||
|
if (!group) return;
|
||||||
|
const nodeId = group.dataset.nodeId;
|
||||||
|
if (nodeId && nodeId !== hoveredNodeId) {
|
||||||
|
hoveredNodeId = nodeId;
|
||||||
|
this.showNodeTooltip(nodeId, e);
|
||||||
|
this.highlightNodeEdges(nodeId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
nodeLayer.addEventListener("mouseout", (e: MouseEvent) => {
|
||||||
|
const related = (e.relatedTarget as Element | null)?.closest?.(".flow-node");
|
||||||
|
if (!related) {
|
||||||
|
hoveredNodeId = null;
|
||||||
|
this.hideNodeTooltip();
|
||||||
|
this.unhighlightEdges();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -858,6 +885,7 @@ class FolkFundsApp extends HTMLElement {
|
||||||
|
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
if (this.wiringActive) { this.cancelWiring(); return; }
|
if (this.wiringActive) { this.cancelWiring(); return; }
|
||||||
|
this.closeModal();
|
||||||
this.closeEditor();
|
this.closeEditor();
|
||||||
}
|
}
|
||||||
else if (e.key === " ") { e.preventDefault(); this.toggleSimulation(); }
|
else if (e.key === " ") { e.preventDefault(); this.toggleSimulation(); }
|
||||||
|
|
@ -937,7 +965,7 @@ class FolkFundsApp extends HTMLElement {
|
||||||
<rect x="2" y="${zoneY}" width="${w - 4}" height="${overflowH}" fill="#f59e0b" opacity="0.06" rx="0"/>
|
<rect x="2" y="${zoneY}" width="${w - 4}" height="${overflowH}" fill="#f59e0b" opacity="0.06" rx="0"/>
|
||||||
<rect x="2" y="${fillY}" width="${w - 4}" height="${totalFillH}" fill="${fillColor}" opacity="0.25"/>
|
<rect x="2" y="${fillY}" width="${w - 4}" height="${totalFillH}" fill="${fillColor}" opacity="0.25"/>
|
||||||
<text x="10" y="22" fill="#e2e8f0" font-size="13" font-weight="600">${this.esc(d.label)}</text>
|
<text x="10" y="22" fill="#e2e8f0" font-size="13" font-weight="600">${this.esc(d.label)}</text>
|
||||||
<text x="${w - 10}" y="22" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500">${statusLabel}</text>
|
<text x="${w - 10}" y="22" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500" class="${isSufficient ? 'sufficiency-glow' : ''}">${statusLabel}</text>
|
||||||
<text x="${w / 2}" y="${h - 24}" text-anchor="middle" fill="#94a3b8" font-size="11">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</text>
|
<text x="${w / 2}" y="${h - 24}" text-anchor="middle" fill="#94a3b8" font-size="11">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</text>
|
||||||
<rect x="8" y="${h - 10}" width="${w - 16}" height="4" rx="2" fill="#334155"/>
|
<rect x="8" y="${h - 10}" width="${w - 16}" height="4" rx="2" fill="#334155"/>
|
||||||
<rect x="8" y="${h - 10}" width="${(w - 16) * fillPct}" height="4" rx="2" fill="${fillColor}"/>
|
<rect x="8" y="${h - 10}" width="${(w - 16) * fillPct}" height="4" rx="2" fill="${fillColor}"/>
|
||||||
|
|
@ -1003,7 +1031,7 @@ class FolkFundsApp extends HTMLElement {
|
||||||
const target = this.nodes.find((t) => t.id === alloc.targetId);
|
const target = this.nodes.find((t) => t.id === alloc.targetId);
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
const to = this.getPortPosition(target, "inflow");
|
const to = this.getPortPosition(target, "inflow");
|
||||||
const strokeW = Math.max(2, (d.flowRate / maxFlow) * (alloc.percentage / 100) * 8);
|
const strokeW = Math.max(2, (d.flowRate / maxFlow) * (alloc.percentage / 100) * 12);
|
||||||
html += this.renderEdgePath(
|
html += this.renderEdgePath(
|
||||||
from.x, from.y, to.x, to.y,
|
from.x, from.y, to.x, to.y,
|
||||||
alloc.color || "#10b981", strokeW, false,
|
alloc.color || "#10b981", strokeW, false,
|
||||||
|
|
@ -1019,7 +1047,7 @@ class FolkFundsApp extends HTMLElement {
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
const from = this.getPortPosition(n, "overflow");
|
const from = this.getPortPosition(n, "overflow");
|
||||||
const to = this.getPortPosition(target, "inflow");
|
const to = this.getPortPosition(target, "inflow");
|
||||||
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 6);
|
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 10);
|
||||||
html += this.renderEdgePath(
|
html += this.renderEdgePath(
|
||||||
from.x, from.y, to.x, to.y,
|
from.x, from.y, to.x, to.y,
|
||||||
alloc.color || "#f59e0b", strokeW, true,
|
alloc.color || "#f59e0b", strokeW, true,
|
||||||
|
|
@ -1032,7 +1060,7 @@ class FolkFundsApp extends HTMLElement {
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
const from = this.getPortPosition(n, "spending");
|
const from = this.getPortPosition(n, "spending");
|
||||||
const to = this.getPortPosition(target, "inflow");
|
const to = this.getPortPosition(target, "inflow");
|
||||||
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 5);
|
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 8);
|
||||||
html += this.renderEdgePath(
|
html += this.renderEdgePath(
|
||||||
from.x, from.y, to.x, to.y,
|
from.x, from.y, to.x, to.y,
|
||||||
alloc.color || "#8b5cf6", strokeW, false,
|
alloc.color || "#8b5cf6", strokeW, false,
|
||||||
|
|
@ -1049,7 +1077,7 @@ class FolkFundsApp extends HTMLElement {
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
const from = this.getPortPosition(n, "overflow");
|
const from = this.getPortPosition(n, "overflow");
|
||||||
const to = this.getPortPosition(target, "inflow");
|
const to = this.getPortPosition(target, "inflow");
|
||||||
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 5);
|
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 8);
|
||||||
html += this.renderEdgePath(
|
html += this.renderEdgePath(
|
||||||
from.x, from.y, to.x, to.y,
|
from.x, from.y, to.x, to.y,
|
||||||
alloc.color || "#f59e0b", strokeW, true,
|
alloc.color || "#f59e0b", strokeW, true,
|
||||||
|
|
@ -1070,9 +1098,11 @@ class FolkFundsApp extends HTMLElement {
|
||||||
const cy2 = y1 + (y2 - y1) * 0.6;
|
const cy2 = y1 + (y2 - y1) * 0.6;
|
||||||
const midX = (x1 + x2) / 2;
|
const midX = (x1 + x2) / 2;
|
||||||
const midY = (y1 + y2) / 2;
|
const midY = (y1 + y2) / 2;
|
||||||
const dash = dashed ? ' stroke-dasharray="6 3"' : "";
|
const d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`;
|
||||||
|
const animClass = dashed ? "edge-path-overflow" : "edge-path-animated";
|
||||||
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}">
|
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}">
|
||||||
<path d="M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-opacity="0.7"${dash}/>
|
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW * 2.5}" stroke-opacity="0.12" class="edge-glow"/>
|
||||||
|
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-opacity="0.8" class="${animClass}"/>
|
||||||
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
|
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
|
||||||
<rect x="-34" y="-12" width="68" height="24" rx="6" fill="#1e293b" stroke="#475569" stroke-width="1" opacity="0.9"/>
|
<rect x="-34" y="-12" width="68" height="24" rx="6" fill="#1e293b" stroke="#475569" stroke-width="1" opacity="0.9"/>
|
||||||
<text x="-14" y="5" fill="${color}" font-size="11" font-weight="600" text-anchor="middle">${pct}%</text>
|
<text x="-14" y="5" fill="${color}" font-size="11" font-weight="600" text-anchor="middle">${pct}%</text>
|
||||||
|
|
@ -1578,6 +1608,371 @@ class FolkFundsApp extends HTMLElement {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Node hover tooltip ──────────────────────────────
|
||||||
|
|
||||||
|
private showNodeTooltip(nodeId: string, e: MouseEvent) {
|
||||||
|
const tooltip = this.shadow.getElementById("node-tooltip");
|
||||||
|
const container = this.shadow.getElementById("canvas-container");
|
||||||
|
if (!tooltip || !container) return;
|
||||||
|
const node = this.nodes.find((n) => n.id === nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
let html = `<div class="funds-node-tooltip__label">${this.esc((node.data as any).label)}</div>`;
|
||||||
|
if (node.type === "source") {
|
||||||
|
const d = node.data as SourceNodeData;
|
||||||
|
html += `<div class="funds-node-tooltip__stat">$${d.flowRate.toLocaleString()}/mo · ${d.sourceType}</div>`;
|
||||||
|
} else if (node.type === "funnel") {
|
||||||
|
const d = node.data as FunnelNodeData;
|
||||||
|
const suf = computeSufficiencyState(d);
|
||||||
|
html += `<div class="funds-node-tooltip__stat">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.sufficientThreshold ?? d.maxThreshold).toLocaleString()}</div>`;
|
||||||
|
html += `<div class="funds-node-tooltip__stat" style="text-transform:capitalize;color:${suf === "sufficient" || suf === "abundant" ? "#fbbf24" : "#94a3b8"}">${suf}</div>`;
|
||||||
|
} else {
|
||||||
|
const d = node.data as OutcomeNodeData;
|
||||||
|
const pct = d.fundingTarget > 0 ? Math.round((d.fundingReceived / d.fundingTarget) * 100) : 0;
|
||||||
|
html += `<div class="funds-node-tooltip__stat">$${Math.floor(d.fundingReceived).toLocaleString()} / $${Math.floor(d.fundingTarget).toLocaleString()} (${pct}%)</div>`;
|
||||||
|
html += `<div class="funds-node-tooltip__stat" style="text-transform:capitalize">${d.status}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip.innerHTML = html;
|
||||||
|
tooltip.style.display = "block";
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
tooltip.style.left = `${e.clientX - rect.left + 16}px`;
|
||||||
|
tooltip.style.top = `${e.clientY - rect.top - 8}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideNodeTooltip() {
|
||||||
|
const tooltip = this.shadow.getElementById("node-tooltip");
|
||||||
|
if (tooltip) tooltip.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
private highlightNodeEdges(nodeId: string) {
|
||||||
|
const edgeLayer = this.shadow.getElementById("edge-layer");
|
||||||
|
if (!edgeLayer) return;
|
||||||
|
edgeLayer.querySelectorAll(".edge-group").forEach((g) => {
|
||||||
|
const el = g as SVGGElement;
|
||||||
|
const isConnected = el.dataset.from === nodeId || el.dataset.to === nodeId;
|
||||||
|
el.classList.toggle("edge-group--highlight", isConnected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private unhighlightEdges() {
|
||||||
|
const edgeLayer = this.shadow.getElementById("edge-layer");
|
||||||
|
if (!edgeLayer) return;
|
||||||
|
edgeLayer.querySelectorAll(".edge-group--highlight").forEach((g) => {
|
||||||
|
g.classList.remove("edge-group--highlight");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Node detail modals ──────────────────────────────
|
||||||
|
|
||||||
|
private closeModal() {
|
||||||
|
const m = this.shadow.getElementById("funds-modal");
|
||||||
|
if (m) m.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
private openOutcomeModal(nodeId: string) {
|
||||||
|
this.closeModal();
|
||||||
|
const node = this.nodes.find((n) => n.id === nodeId);
|
||||||
|
if (!node || node.type !== "outcome") return;
|
||||||
|
const d = node.data as OutcomeNodeData;
|
||||||
|
|
||||||
|
const fillPct = d.fundingTarget > 0 ? Math.min(100, (d.fundingReceived / d.fundingTarget) * 100) : 0;
|
||||||
|
const statusColor = d.status === "completed" ? "#10b981"
|
||||||
|
: d.status === "blocked" ? "#ef4444"
|
||||||
|
: d.status === "in-progress" ? "#3b82f6" : "#64748b";
|
||||||
|
const statusLabel = d.status === "completed" ? "Completed"
|
||||||
|
: d.status === "blocked" ? "Blocked"
|
||||||
|
: d.status === "in-progress" ? "In Progress" : "Not Started";
|
||||||
|
|
||||||
|
let phasesHtml = "";
|
||||||
|
if (d.phases && d.phases.length > 0) {
|
||||||
|
phasesHtml += `<div class="phase-tier-bar">`;
|
||||||
|
for (const p of d.phases) {
|
||||||
|
const unlocked = d.fundingReceived >= p.fundingThreshold;
|
||||||
|
phasesHtml += `<div class="phase-tier-segment" style="background:${unlocked ? "#10b981" : "#334155"}"></div>`;
|
||||||
|
}
|
||||||
|
phasesHtml += `</div>`;
|
||||||
|
|
||||||
|
for (let i = 0; i < d.phases.length; i++) {
|
||||||
|
const p = d.phases[i];
|
||||||
|
const unlocked = d.fundingReceived >= p.fundingThreshold;
|
||||||
|
const completedTasks = p.tasks.filter((t) => t.completed).length;
|
||||||
|
const phasePct = p.fundingThreshold > 0 ? Math.min(100, Math.round((d.fundingReceived / p.fundingThreshold) * 100)) : 0;
|
||||||
|
|
||||||
|
phasesHtml += `<div class="phase-card ${unlocked ? "" : "phase-card--locked"}">
|
||||||
|
<div class="phase-header" data-phase-idx="${i}">
|
||||||
|
<span style="font-size:14px">${unlocked ? "🔓" : "🔒"}</span>
|
||||||
|
<span style="flex:1;font-size:13px;font-weight:600;color:${unlocked ? "#e2e8f0" : "#64748b"}">${this.esc(p.name)}</span>
|
||||||
|
<span style="font-size:11px;color:#64748b">${completedTasks}/${p.tasks.length}</span>
|
||||||
|
<span style="font-size:11px;color:#64748b">$${p.fundingThreshold.toLocaleString()}</span>
|
||||||
|
<span style="font-size:12px;color:#64748b;transition:transform 0.2s" data-phase-chevron="${i}">▶</span>
|
||||||
|
</div>
|
||||||
|
<div class="phase-content" data-phase-content="${i}" style="display:none">
|
||||||
|
<div style="margin-bottom:8px">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:11px;color:#64748b;margin-bottom:4px">
|
||||||
|
<span>${Math.min(phasePct, 100)}% funded</span>
|
||||||
|
<span>$${Math.floor(Math.min(d.fundingReceived, p.fundingThreshold)).toLocaleString()} / $${p.fundingThreshold.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="funds-modal__progress-bar">
|
||||||
|
<div class="funds-modal__progress-fill" style="width:${Math.min(phasePct, 100)}%;background:${unlocked ? "#10b981" : "#3b82f6"}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${p.tasks.map((t, ti) => `
|
||||||
|
<div class="phase-task ${t.completed ? "phase-task--done" : ""}">
|
||||||
|
<input type="checkbox" data-phase="${i}" data-task="${ti}" ${t.completed ? "checked" : ""} ${!unlocked ? "disabled" : ""}/>
|
||||||
|
<span>${this.esc(t.label)}</span>
|
||||||
|
</div>
|
||||||
|
`).join("")}
|
||||||
|
${unlocked ? `<button class="phase-add-btn" data-add-task="${i}">+ Add Task</button>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
phasesHtml += `<button class="phase-add-btn" data-action="add-phase" style="width:100%;justify-content:center;padding:8px">+ Add Phase</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backdrop = document.createElement("div");
|
||||||
|
backdrop.className = "funds-modal-backdrop";
|
||||||
|
backdrop.id = "funds-modal";
|
||||||
|
backdrop.innerHTML = `<div class="funds-modal">
|
||||||
|
<div class="funds-modal__header">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
|
<span style="display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:${statusColor}22;color:${statusColor}">${statusLabel}</span>
|
||||||
|
<span style="font-size:16px;font-weight:700;color:#e2e8f0">${this.esc(d.label)}</span>
|
||||||
|
</div>
|
||||||
|
<button class="funds-modal__close" data-modal-action="close">×</button>
|
||||||
|
</div>
|
||||||
|
${d.description ? `<div style="font-size:13px;color:#94a3b8;line-height:1.6;margin-bottom:16px;padding:10px 12px;background:#0f172a;border-radius:8px;border-left:3px solid ${statusColor}">${this.esc(d.description)}</div>` : ""}
|
||||||
|
<div style="margin-bottom:20px">
|
||||||
|
<div style="font-size:28px;font-weight:700;color:#e2e8f0">$${Math.floor(d.fundingReceived).toLocaleString()}</div>
|
||||||
|
<div style="font-size:13px;color:#64748b;margin-top:2px">of $${Math.floor(d.fundingTarget).toLocaleString()} (${Math.round(fillPct)}%)</div>
|
||||||
|
<div class="funds-modal__progress-bar" style="margin-top:10px;height:10px">
|
||||||
|
<div class="funds-modal__progress-fill" style="width:${fillPct}%;background:${statusColor}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${d.phases && d.phases.length > 0 ? `<div style="margin-bottom:16px">
|
||||||
|
<div style="font-size:13px;font-weight:600;color:#94a3b8;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:10px">Phases</div>
|
||||||
|
${phasesHtml}
|
||||||
|
</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
this.shadow.appendChild(backdrop);
|
||||||
|
this.attachOutcomeModalListeners(backdrop, nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachOutcomeModalListeners(backdrop: HTMLElement, nodeId: string) {
|
||||||
|
const node = this.nodes.find((n) => n.id === nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
const d = node.data as OutcomeNodeData;
|
||||||
|
|
||||||
|
backdrop.addEventListener("click", (e) => {
|
||||||
|
if (e.target === backdrop) this.closeModal();
|
||||||
|
});
|
||||||
|
backdrop.querySelector('[data-modal-action="close"]')?.addEventListener("click", () => this.closeModal());
|
||||||
|
|
||||||
|
// Phase accordion toggle
|
||||||
|
backdrop.querySelectorAll(".phase-header").forEach((header) => {
|
||||||
|
header.addEventListener("click", () => {
|
||||||
|
const idx = (header as HTMLElement).dataset.phaseIdx;
|
||||||
|
const content = backdrop.querySelector(`[data-phase-content="${idx}"]`) as HTMLElement | null;
|
||||||
|
const chevron = backdrop.querySelector(`[data-phase-chevron="${idx}"]`) as HTMLElement | null;
|
||||||
|
if (content) {
|
||||||
|
const isOpen = content.style.display !== "none";
|
||||||
|
content.style.display = isOpen ? "none" : "block";
|
||||||
|
if (chevron) chevron.style.transform = isOpen ? "rotate(0deg)" : "rotate(90deg)";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Task checkbox toggle
|
||||||
|
backdrop.querySelectorAll('input[type="checkbox"][data-phase]').forEach((cb) => {
|
||||||
|
cb.addEventListener("change", () => {
|
||||||
|
const phaseIdx = parseInt((cb as HTMLElement).dataset.phase!, 10);
|
||||||
|
const taskIdx = parseInt((cb as HTMLElement).dataset.task!, 10);
|
||||||
|
if (d.phases && d.phases[phaseIdx] && d.phases[phaseIdx].tasks[taskIdx]) {
|
||||||
|
d.phases[phaseIdx].tasks[taskIdx].completed = (cb as HTMLInputElement).checked;
|
||||||
|
const taskRow = (cb as HTMLElement).closest(".phase-task");
|
||||||
|
if (taskRow) taskRow.classList.toggle("phase-task--done", (cb as HTMLInputElement).checked);
|
||||||
|
this.drawCanvasContent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add task
|
||||||
|
backdrop.querySelectorAll("[data-add-task]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const phaseIdx = parseInt((btn as HTMLElement).dataset.addTask!, 10);
|
||||||
|
if (d.phases && d.phases[phaseIdx]) {
|
||||||
|
const taskLabel = prompt("Task name:");
|
||||||
|
if (taskLabel) {
|
||||||
|
d.phases[phaseIdx].tasks.push({ label: taskLabel, completed: false });
|
||||||
|
this.openOutcomeModal(nodeId);
|
||||||
|
this.drawCanvasContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add phase
|
||||||
|
backdrop.querySelector('[data-action="add-phase"]')?.addEventListener("click", () => {
|
||||||
|
const name = prompt("Phase name:");
|
||||||
|
if (name) {
|
||||||
|
const threshold = parseFloat(prompt("Funding threshold ($):") || "0") || 0;
|
||||||
|
if (!d.phases) d.phases = [];
|
||||||
|
d.phases.push({ name, fundingThreshold: threshold, tasks: [] });
|
||||||
|
this.openOutcomeModal(nodeId);
|
||||||
|
this.drawCanvasContent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private openSourceModal(nodeId: string) {
|
||||||
|
this.closeModal();
|
||||||
|
const node = this.nodes.find((n) => n.id === nodeId);
|
||||||
|
if (!node || node.type !== "source") return;
|
||||||
|
const d = node.data as SourceNodeData;
|
||||||
|
|
||||||
|
const icons: Record<string, string> = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" };
|
||||||
|
const labels: Record<string, string> = { card: "Card", safe_wallet: "Safe / Wallet", ridentity: "rIdentity", unconfigured: "Configure" };
|
||||||
|
|
||||||
|
let configHtml = "";
|
||||||
|
if (d.sourceType === "card") {
|
||||||
|
configHtml = `<div style="margin-top:12px">
|
||||||
|
<div class="editor-field" style="margin-bottom:10px">
|
||||||
|
<label class="editor-label">Default Amount ($)</label>
|
||||||
|
<input class="editor-input" data-modal-field="flowRate" type="number" value="${d.flowRate}"/>
|
||||||
|
</div>
|
||||||
|
<button class="editor-btn" data-action="fund-with-card" style="width:100%;padding:10px;background:#6366f1;color:white;border:none;border-radius:8px;font-weight:600;cursor:pointer">
|
||||||
|
💳 Fund with Card
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
} else if (d.sourceType === "safe_wallet") {
|
||||||
|
configHtml = `<div style="margin-top:12px">
|
||||||
|
<div class="editor-field" style="margin-bottom:10px">
|
||||||
|
<label class="editor-label">Wallet Address</label>
|
||||||
|
<input class="editor-input" data-modal-field="walletAddress" value="${this.esc(d.walletAddress || "")}"/>
|
||||||
|
</div>
|
||||||
|
<div class="editor-field" style="margin-bottom:10px">
|
||||||
|
<label class="editor-label">Safe Address</label>
|
||||||
|
<input class="editor-input" data-modal-field="safeAddress" value="${this.esc(d.safeAddress || "")}"/>
|
||||||
|
</div>
|
||||||
|
<div class="editor-field">
|
||||||
|
<label class="editor-label">Chain</label>
|
||||||
|
<select class="editor-select" data-modal-field="chainId">
|
||||||
|
${[{ id: 1, name: "Ethereum" }, { id: 10, name: "Optimism" }, { id: 8453, name: "Base" }, { id: 100, name: "Gnosis" }]
|
||||||
|
.map((c) => `<option value="${c.id}" ${d.chainId === c.id ? "selected" : ""}>${c.name}</option>`).join("")}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else if (d.sourceType === "ridentity") {
|
||||||
|
configHtml = `<div style="text-align:center;padding:16px 0;margin-top:12px">
|
||||||
|
<div style="font-size:32px;margin-bottom:8px">👤</div>
|
||||||
|
<div style="font-size:13px;color:#94a3b8;margin-bottom:12px">${isAuthenticated() ? "Connected" : "Not connected"}</div>
|
||||||
|
${!isAuthenticated() ? `<button class="editor-btn" data-action="connect-ridentity" style="background:#6366f1;color:white;border:none;padding:8px 20px;border-radius:8px;font-weight:600;cursor:pointer">Connect with EncryptID</button>` : `<div style="font-size:12px;color:#6ee7b7">✅ ${this.esc(getUsername() || "Connected")}</div>`}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backdrop = document.createElement("div");
|
||||||
|
backdrop.className = "funds-modal-backdrop";
|
||||||
|
backdrop.id = "funds-modal";
|
||||||
|
backdrop.innerHTML = `<div class="funds-modal">
|
||||||
|
<div class="funds-modal__header">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
|
<span style="font-size:20px">${icons[d.sourceType] || "💰"}</span>
|
||||||
|
<span style="font-size:16px;font-weight:700;color:#e2e8f0">${this.esc(d.label)}</span>
|
||||||
|
</div>
|
||||||
|
<button class="funds-modal__close" data-modal-action="close">×</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:16px">
|
||||||
|
<div style="font-size:11px;font-weight:600;color:#94a3b8;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px">Source Type</div>
|
||||||
|
<div class="source-type-grid">
|
||||||
|
${["card", "safe_wallet", "ridentity"].map((t) => `
|
||||||
|
<button class="source-type-btn ${d.sourceType === t ? "source-type-btn--active" : ""}" data-source-type="${t}">
|
||||||
|
<span style="font-size:20px">${icons[t]}</span>
|
||||||
|
<span>${labels[t]}</span>
|
||||||
|
</button>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-field" style="margin-bottom:12px">
|
||||||
|
<label class="editor-label">Label</label>
|
||||||
|
<input class="editor-input" data-modal-field="label" value="${this.esc(d.label)}"/>
|
||||||
|
</div>
|
||||||
|
<div class="editor-field" style="margin-bottom:12px">
|
||||||
|
<label class="editor-label">Flow Rate ($/mo)</label>
|
||||||
|
<input class="editor-input" data-modal-field="flowRate" type="number" value="${d.flowRate}"/>
|
||||||
|
</div>
|
||||||
|
<div id="source-config">${configHtml}</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:20px;padding-top:16px;border-top:1px solid #334155">
|
||||||
|
<button class="editor-btn" data-modal-action="save" style="flex:1;background:#10b981;color:white;border:none;font-weight:600">Save</button>
|
||||||
|
<button class="editor-btn" data-modal-action="close" style="flex:1">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
this.shadow.appendChild(backdrop);
|
||||||
|
this.attachSourceModalListeners(backdrop, nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachSourceModalListeners(backdrop: HTMLElement, nodeId: string) {
|
||||||
|
const node = this.nodes.find((n) => n.id === nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
const d = node.data as SourceNodeData;
|
||||||
|
|
||||||
|
backdrop.addEventListener("click", (e) => {
|
||||||
|
if (e.target === backdrop) this.closeModal();
|
||||||
|
});
|
||||||
|
backdrop.querySelectorAll('[data-modal-action="close"]').forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => this.closeModal());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Source type picker
|
||||||
|
backdrop.querySelectorAll("[data-source-type]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
d.sourceType = (btn as HTMLElement).dataset.sourceType as SourceNodeData["sourceType"];
|
||||||
|
this.openSourceModal(nodeId);
|
||||||
|
this.drawCanvasContent();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Field changes (live)
|
||||||
|
backdrop.querySelectorAll(".editor-input[data-modal-field], .editor-select[data-modal-field]").forEach((input) => {
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
const field = (input as HTMLElement).dataset.modalField!;
|
||||||
|
const val = (input as HTMLInputElement).value;
|
||||||
|
const numFields = ["flowRate", "chainId"];
|
||||||
|
(d as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val;
|
||||||
|
this.drawCanvasContent();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save
|
||||||
|
backdrop.querySelector('[data-modal-action="save"]')?.addEventListener("click", () => {
|
||||||
|
backdrop.querySelectorAll(".editor-input[data-modal-field], .editor-select[data-modal-field]").forEach((input) => {
|
||||||
|
const field = (input as HTMLElement).dataset.modalField!;
|
||||||
|
const val = (input as HTMLInputElement).value;
|
||||||
|
const numFields = ["flowRate", "chainId"];
|
||||||
|
(d as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val;
|
||||||
|
});
|
||||||
|
this.drawCanvasContent();
|
||||||
|
this.closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fund with card
|
||||||
|
backdrop.querySelector('[data-action="fund-with-card"]')?.addEventListener("click", () => {
|
||||||
|
const flowId = this.flowId || this.getAttribute("flow-id") || "";
|
||||||
|
if (!d.walletAddress) {
|
||||||
|
alert("Configure a wallet address first (use rIdentity passkey or enter manually)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.openTransakWidget(flowId, d.walletAddress);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect with EncryptID
|
||||||
|
backdrop.querySelector('[data-action="connect-ridentity"]')?.addEventListener("click", () => {
|
||||||
|
window.location.href = "/auth/login?redirect=" + encodeURIComponent(window.location.pathname);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Node CRUD ────────────────────────────────────────
|
// ─── Node CRUD ────────────────────────────────────────
|
||||||
|
|
||||||
private addNode(type: "source" | "funnel" | "outcome") {
|
private addNode(type: "source" | "funnel" | "outcome") {
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,96 @@
|
||||||
to { stroke-dashoffset: -12; }
|
to { stroke-dashoffset: -12; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Edge flow animation ──────────────────────────────── */
|
||||||
|
@keyframes streamFlow { to { stroke-dashoffset: -24; } }
|
||||||
|
.edge-path-animated { stroke-dasharray: 8 4; animation: streamFlow 1s linear infinite; }
|
||||||
|
.edge-path-overflow { stroke-dasharray: 6 3; animation: streamFlow 0.7s linear infinite; }
|
||||||
|
.edge-glow { pointer-events: none; }
|
||||||
|
.edge-group--highlight path:not(.edge-glow) { stroke-opacity: 1 !important; filter: brightness(1.4); }
|
||||||
|
.edge-group--highlight .edge-glow { stroke-opacity: 0.25 !important; }
|
||||||
|
|
||||||
|
/* ── Node detail modals ──────────────────────────────── */
|
||||||
|
.funds-modal-backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: 50;
|
||||||
|
background: rgba(0,0,0,0.6); display: flex;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
animation: modalFadeIn 0.15s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes modalFadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
.funds-modal {
|
||||||
|
background: #1e293b; border-radius: 16px; padding: 24px;
|
||||||
|
width: 440px; max-height: 85vh; overflow-y: auto;
|
||||||
|
border: 1px solid #334155; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||||
|
animation: modalSlideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes modalSlideIn { from { transform: translateY(12px); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||||
|
.funds-modal::-webkit-scrollbar { width: 6px; }
|
||||||
|
.funds-modal::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.funds-modal::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
|
||||||
|
.funds-modal__header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.funds-modal__close {
|
||||||
|
background: none; border: none; color: #94a3b8; font-size: 24px; cursor: pointer;
|
||||||
|
padding: 2px 8px; border-radius: 4px; transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.funds-modal__close:hover { color: #e2e8f0; }
|
||||||
|
.funds-modal__progress-bar {
|
||||||
|
height: 8px; background: #334155; border-radius: 4px; overflow: hidden; margin-top: 8px;
|
||||||
|
}
|
||||||
|
.funds-modal__progress-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
|
||||||
|
|
||||||
|
/* Phase accordion */
|
||||||
|
.phase-tier-bar { display: flex; gap: 1px; height: 8px; border-radius: 4px; overflow: hidden; margin-bottom: 16px; }
|
||||||
|
.phase-tier-segment { flex: 1; transition: background 0.3s; }
|
||||||
|
.phase-card { border: 1px solid #334155; border-radius: 10px; overflow: hidden; margin-bottom: 8px; }
|
||||||
|
.phase-card--locked { opacity: 0.5; }
|
||||||
|
.phase-header {
|
||||||
|
padding: 10px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px;
|
||||||
|
background: #0f172a; transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.phase-header:hover { background: #1e293b; }
|
||||||
|
.phase-content { padding: 8px 14px 14px; border-top: 1px solid #334155; }
|
||||||
|
.phase-task {
|
||||||
|
display: flex; align-items: center; gap: 8px; font-size: 13px; color: #94a3b8; padding: 4px 0;
|
||||||
|
}
|
||||||
|
.phase-task input[type="checkbox"] { accent-color: #10b981; cursor: pointer; }
|
||||||
|
.phase-task--done { color: #64748b; text-decoration: line-through; }
|
||||||
|
.phase-add-btn {
|
||||||
|
display: flex; align-items: center; gap: 4px; font-size: 12px; color: #64748b;
|
||||||
|
background: none; border: 1px dashed #334155; border-radius: 6px;
|
||||||
|
padding: 4px 10px; cursor: pointer; margin-top: 6px; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.phase-add-btn:hover { color: #94a3b8; border-color: #475569; }
|
||||||
|
|
||||||
|
/* Source type picker */
|
||||||
|
.source-type-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; }
|
||||||
|
.source-type-btn {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||||
|
padding: 14px 8px; border-radius: 10px; border: 2px solid #334155;
|
||||||
|
background: #0f172a; color: #94a3b8; cursor: pointer; transition: all 0.15s;
|
||||||
|
font-size: 12px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.source-type-btn:hover { border-color: #475569; background: #1e293b; }
|
||||||
|
.source-type-btn--active { border-color: #10b981; background: #064e3b; color: #6ee7b7; }
|
||||||
|
|
||||||
|
/* Node hover tooltip */
|
||||||
|
.funds-node-tooltip {
|
||||||
|
position: absolute; z-index: 30; pointer-events: none;
|
||||||
|
background: rgba(15,23,42,0.95); border: 1px solid #475569; border-radius: 8px;
|
||||||
|
padding: 8px 12px; font-size: 12px; color: #e2e8f0;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.funds-node-tooltip__label { font-weight: 600; margin-bottom: 2px; }
|
||||||
|
.funds-node-tooltip__stat { color: #94a3b8; font-size: 11px; }
|
||||||
|
|
||||||
|
/* Sufficiency glow on funnel status text */
|
||||||
|
@keyframes sufficiencyPulse {
|
||||||
|
0%, 100% { fill-opacity: 1; }
|
||||||
|
50% { fill-opacity: 0.6; }
|
||||||
|
}
|
||||||
|
.sufficiency-glow { animation: sufficiencyPulse 2s ease-in-out infinite; }
|
||||||
|
|
||||||
/* ── Mobile responsive ──────────────────────────────── */
|
/* ── Mobile responsive ──────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.funds-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
.funds-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue