@dilipod/ui 0.4.21 → 0.4.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dilipod/ui",
3
- "version": "0.4.21",
3
+ "version": "0.4.23",
4
4
  "description": "Dilipod Design System - Shared UI components and styles",
5
5
  "author": "Dilipod <hello@dilipod.com>",
6
6
  "license": "MIT",
@@ -0,0 +1,319 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+
5
+ // ============================================
6
+ // Types
7
+ // ============================================
8
+
9
+ interface FlowNode {
10
+ id: string
11
+ label: string
12
+ type: 'action' | 'decision' | 'terminal'
13
+ }
14
+
15
+ interface FlowEdge {
16
+ from: string
17
+ to: string
18
+ label?: string
19
+ }
20
+
21
+ // ============================================
22
+ // Parser
23
+ // ============================================
24
+
25
+ function parseMermaidFlowchart(mermaid: string): { nodes: FlowNode[]; edges: FlowEdge[] } {
26
+ const nodes = new Map<string, FlowNode>()
27
+ const edges: FlowEdge[] = []
28
+
29
+ const lines = mermaid
30
+ .split(/\\n|\n/)
31
+ .map(l => l.trim())
32
+ .filter(l => l && !l.startsWith('flowchart') && !l.startsWith('graph'))
33
+
34
+ function parseNodeDef(str: string): { id: string; label?: string; type?: FlowNode['type'] } {
35
+ // Decision: D{Certificate Found?}
36
+ const decisionMatch = str.match(/^([A-Za-z0-9_]+)\{(.+?)\}$/)
37
+ if (decisionMatch) return { id: decisionMatch[1], label: decisionMatch[2], type: 'decision' }
38
+ // Action / terminal: A[Start] or A(Start) or A([Start])
39
+ const bracketMatch = str.match(/^([A-Za-z0-9_]+)\[?\(?\[?(.+?)\]?\)?\]?$/)
40
+ if (bracketMatch) {
41
+ const label = bracketMatch[2]
42
+ const isTerminal = /^(start|end|begin|finish|done)$/i.test(label)
43
+ return { id: bracketMatch[1], label, type: isTerminal ? 'terminal' : 'action' }
44
+ }
45
+ // Just an ID reference
46
+ return { id: str.trim() }
47
+ }
48
+
49
+ for (const line of lines) {
50
+ const edgeMatch = line.match(/^(.+?)\s*-->(?:\|(.+?)\|)?\s*(.+)$/)
51
+ if (!edgeMatch) continue
52
+
53
+ const leftRaw = edgeMatch[1].trim()
54
+ const edgeLabel = edgeMatch[2]?.trim()
55
+ const rightRaw = edgeMatch[3].trim()
56
+
57
+ const left = parseNodeDef(leftRaw)
58
+ const right = parseNodeDef(rightRaw)
59
+
60
+ if (left.label && !nodes.has(left.id)) {
61
+ nodes.set(left.id, { id: left.id, label: left.label, type: left.type || 'action' })
62
+ }
63
+ if (right.label && !nodes.has(right.id)) {
64
+ nodes.set(right.id, { id: right.id, label: right.label, type: right.type || 'action' })
65
+ }
66
+ if (!nodes.has(left.id)) {
67
+ nodes.set(left.id, { id: left.id, label: left.id, type: 'action' })
68
+ }
69
+ if (!nodes.has(right.id)) {
70
+ nodes.set(right.id, { id: right.id, label: right.id, type: 'action' })
71
+ }
72
+
73
+ edges.push({ from: left.id, to: right.id, label: edgeLabel })
74
+ }
75
+
76
+ return { nodes: Array.from(nodes.values()), edges }
77
+ }
78
+
79
+ // ============================================
80
+ // Layout Builder
81
+ // ============================================
82
+
83
+ function findMergePoint(
84
+ branchStarts: string[],
85
+ outgoing: Map<string, FlowEdge[]>,
86
+ ): string | null {
87
+ const reachable = new Map<string, Set<string>>()
88
+
89
+ for (const start of branchStarts) {
90
+ const q = [start]
91
+ const seen = new Set<string>()
92
+ while (q.length > 0) {
93
+ const id = q.shift()!
94
+ if (seen.has(id)) continue
95
+ seen.add(id)
96
+ if (!reachable.has(id)) reachable.set(id, new Set())
97
+ reachable.get(id)!.add(start)
98
+ const outs = outgoing.get(id) || []
99
+ for (const e of outs) q.push(e.to)
100
+ }
101
+ }
102
+
103
+ const allBranches = new Set(branchStarts)
104
+ const q = [branchStarts[0]]
105
+ const seen = new Set<string>()
106
+ while (q.length > 0) {
107
+ const id = q.shift()!
108
+ if (seen.has(id)) continue
109
+ seen.add(id)
110
+ if (!allBranches.has(id) && reachable.get(id)?.size === branchStarts.length) {
111
+ return id
112
+ }
113
+ const outs = outgoing.get(id) || []
114
+ for (const e of outs) q.push(e.to)
115
+ }
116
+ return null
117
+ }
118
+
119
+ type LayoutItem =
120
+ | { type: 'node'; nodeId: string }
121
+ | { type: 'arrow'; label?: string }
122
+ | { type: 'branch'; decision: string; branches: { label?: string; items: LayoutItem[] }[]; mergeId: string | null }
123
+
124
+ function buildLayout(
125
+ startId: string,
126
+ outgoing: Map<string, FlowEdge[]>,
127
+ incoming: Map<string, FlowEdge[]>,
128
+ nodeMap: Map<string, FlowNode>,
129
+ visited: Set<string>
130
+ ): LayoutItem[] {
131
+ const items: LayoutItem[] = []
132
+ let currentId: string | null = startId
133
+
134
+ while (currentId) {
135
+ if (visited.has(currentId)) break
136
+
137
+ const node = nodeMap.get(currentId)
138
+ if (!node) break
139
+
140
+ const outs: FlowEdge[] = outgoing.get(currentId) || []
141
+
142
+ if (outs.length <= 1) {
143
+ visited.add(currentId)
144
+ items.push({ type: 'node', nodeId: currentId })
145
+ if (outs.length === 1) {
146
+ const nextId: string = outs[0].to
147
+ if (visited.has(nextId)) break
148
+ items.push({ type: 'arrow', label: outs[0].label })
149
+ currentId = nextId
150
+ } else {
151
+ currentId = null
152
+ }
153
+ } else {
154
+ visited.add(currentId)
155
+ items.push({ type: 'node', nodeId: currentId })
156
+
157
+ const branchStarts: string[] = outs.map((e: FlowEdge) => e.to)
158
+ const mergeId = findMergePoint(branchStarts, outgoing)
159
+
160
+ const branches: { label?: string; items: LayoutItem[] }[] = []
161
+ for (const edge of outs) {
162
+ if (visited.has(edge.to) && edge.to !== mergeId) {
163
+ branches.push({ label: edge.label, items: [] })
164
+ continue
165
+ }
166
+ const branchItems = buildLayout(edge.to, outgoing, incoming, nodeMap, visited)
167
+ branches.push({ label: edge.label, items: branchItems })
168
+ }
169
+
170
+ items.push({ type: 'branch', decision: currentId, branches, mergeId })
171
+
172
+ if (mergeId && !visited.has(mergeId)) {
173
+ items.push({ type: 'arrow' })
174
+ currentId = mergeId
175
+ } else {
176
+ currentId = null
177
+ }
178
+ }
179
+ }
180
+
181
+ return items
182
+ }
183
+
184
+ // ============================================
185
+ // Render Components
186
+ // ============================================
187
+
188
+ function FlowArrow({ label }: { label?: string }) {
189
+ return (
190
+ <div className="flex flex-col items-center">
191
+ {label && (
192
+ <span className="text-[10px] font-medium text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded mb-0.5">
193
+ {label}
194
+ </span>
195
+ )}
196
+ <div className="w-px h-4 bg-gray-300" />
197
+ <div className="w-0 h-0 border-l-[4px] border-r-[4px] border-t-[5px] border-l-transparent border-r-transparent border-t-gray-300" />
198
+ </div>
199
+ )
200
+ }
201
+
202
+ function FlowNodeBox({ node }: { node: FlowNode }) {
203
+ if (node.type === 'decision') {
204
+ return (
205
+ <div
206
+ className="bg-amber-50 border-2 border-amber-300 rounded-lg px-4 py-2.5 text-xs font-medium text-amber-800 text-center my-1"
207
+ style={{ minWidth: '120px' }}
208
+ >
209
+ <span className="text-amber-400 mr-1">&#x25C7;</span>
210
+ {node.label}
211
+ </div>
212
+ )
213
+ }
214
+ if (node.type === 'terminal') {
215
+ return (
216
+ <div className="bg-gray-100 border border-gray-200 rounded-full px-5 py-1.5 text-xs font-medium text-gray-500 text-center my-1">
217
+ {node.label}
218
+ </div>
219
+ )
220
+ }
221
+ return (
222
+ <div className="bg-white border border-gray-200 rounded-sm px-4 py-2 text-xs font-medium text-[var(--black)] text-center shadow-sm my-1 max-w-[220px]">
223
+ {node.label}
224
+ </div>
225
+ )
226
+ }
227
+
228
+ function RenderLayoutItems({ items, nodeMap }: { items: LayoutItem[]; nodeMap: Map<string, FlowNode> }) {
229
+ return (
230
+ <>
231
+ {items.map((item, i) => {
232
+ if (item.type === 'node') {
233
+ const node = nodeMap.get(item.nodeId)
234
+ if (!node) return null
235
+ return <FlowNodeBox key={`node-${item.nodeId}`} node={node} />
236
+ }
237
+ if (item.type === 'arrow') {
238
+ return <FlowArrow key={`arrow-${i}`} label={item.label} />
239
+ }
240
+ if (item.type === 'branch') {
241
+ return (
242
+ <div key={`branch-${item.decision}-${i}`} className="flex flex-col items-center w-full">
243
+ <div className="w-px h-3 bg-gray-300" />
244
+ <div className="flex items-start justify-center gap-6 w-full">
245
+ {item.branches.map((branch, j) => (
246
+ <div key={j} className="flex flex-col items-center min-w-[100px]">
247
+ <div className="flex flex-col items-center">
248
+ {branch.label && (
249
+ <span className="text-[10px] font-medium text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded mb-1">
250
+ {branch.label}
251
+ </span>
252
+ )}
253
+ <div className="w-0 h-0 border-l-[4px] border-r-[4px] border-t-[5px] border-l-transparent border-r-transparent border-t-gray-300" />
254
+ </div>
255
+ <div className="flex flex-col items-center">
256
+ <RenderLayoutItems items={branch.items} nodeMap={nodeMap} />
257
+ </div>
258
+ </div>
259
+ ))}
260
+ </div>
261
+ {item.mergeId && (
262
+ <div className="w-px h-3 bg-gray-300" />
263
+ )}
264
+ </div>
265
+ )
266
+ }
267
+ return null
268
+ })}
269
+ </>
270
+ )
271
+ }
272
+
273
+ // ============================================
274
+ // Public Component
275
+ // ============================================
276
+
277
+ export interface FlowchartDiagramProps {
278
+ /** Mermaid flowchart syntax string */
279
+ mermaid: string
280
+ /** Optional className for the container */
281
+ className?: string
282
+ }
283
+
284
+ export function FlowchartDiagram({ mermaid, className }: FlowchartDiagramProps) {
285
+ const { nodes, edges } = parseMermaidFlowchart(mermaid)
286
+
287
+ if (nodes.length === 0) {
288
+ return (
289
+ <pre className="text-xs bg-white border border-gray-100 rounded-sm p-3 overflow-x-auto whitespace-pre-wrap">
290
+ {mermaid}
291
+ </pre>
292
+ )
293
+ }
294
+
295
+ const outgoing = new Map<string, FlowEdge[]>()
296
+ const incoming = new Map<string, FlowEdge[]>()
297
+ for (const edge of edges) {
298
+ if (!outgoing.has(edge.from)) outgoing.set(edge.from, [])
299
+ outgoing.get(edge.from)!.push(edge)
300
+ if (!incoming.has(edge.to)) incoming.set(edge.to, [])
301
+ incoming.get(edge.to)!.push(edge)
302
+ }
303
+
304
+ const nodeMap = new Map(nodes.map(n => [n.id, n]))
305
+
306
+ const roots = nodes.filter(n => !incoming.has(n.id) || incoming.get(n.id)!.length === 0)
307
+ const startId = roots.length > 0 ? roots[0].id : nodes[0].id
308
+
309
+ const visited = new Set<string>()
310
+ const layout = buildLayout(startId, outgoing, incoming, nodeMap, visited)
311
+
312
+ return (
313
+ <div className={className}>
314
+ <div className="flex flex-col items-center py-2">
315
+ <RenderLayoutItems items={layout} nodeMap={nodeMap} />
316
+ </div>
317
+ </div>
318
+ )
319
+ }
@@ -150,6 +150,9 @@ export function ImpactMetricsForm({
150
150
  // Net annual savings
151
151
  const netAnnualSavings = laborSavingsPerYear - workerCostPerYear
152
152
 
153
+ // ROI percentage: net savings relative to worker cost
154
+ const roiPercentage = workerCostPerYear > 0 ? (netAnnualSavings / workerCostPerYear) * 100 : 0
155
+
153
156
  return (
154
157
  <Card className={cn("border-[var(--cyan)]/20 bg-gradient-to-br from-white to-[var(--cyan)]/5", className)}>
155
158
  <CardContent className="p-5">
@@ -274,17 +277,25 @@ export function ImpactMetricsForm({
274
277
  €{netAnnualSavings.toLocaleString(undefined, { maximumFractionDigits: 0 })}
275
278
  </p>
276
279
  <p className="text-xs text-muted-foreground mt-0.5">
277
- €{laborSavingsPerYear.toLocaleString(undefined, { maximumFractionDigits: 0 })} − €{workerCostPerYear}
280
+ €{laborSavingsPerYear.toLocaleString(undefined, { maximumFractionDigits: 0 })} <span className="opacity-60">labor saved</span> − €{workerCostPerYear} <span className="opacity-60">worker cost</span>
278
281
  </p>
279
282
  </div>
280
283
  </div>
281
284
 
282
- {/* Implied frequency indicator */}
283
- {impliedFrequencyPerYear > 0 && (
284
- <p className="text-xs text-muted-foreground mt-4 pt-3 border-t border-border/50">
285
- Implied: ~{impliedFrequencyPerMonth}×/month ({impliedFrequencyPerYear}×/year)
285
+ {/* ROI & Implied frequency */}
286
+ <div className="mt-4 pt-3 border-t border-border/50 flex items-center justify-between">
287
+ <p className={cn(
288
+ "text-sm",
289
+ roiPercentage > 0 ? "font-bold text-[var(--cyan)]" : "text-muted-foreground"
290
+ )}>
291
+ ROI: {roiPercentage >= 0 ? '+' : ''}{roiPercentage.toLocaleString(undefined, { maximumFractionDigits: 0 })}%
286
292
  </p>
287
- )}
293
+ {impliedFrequencyPerYear > 0 && (
294
+ <p className="text-xs text-muted-foreground">
295
+ Implied: ~{impliedFrequencyPerMonth}×/month ({impliedFrequencyPerYear}×/year)
296
+ </p>
297
+ )}
298
+ </div>
288
299
  </CardContent>
289
300
  </Card>
290
301
  )
@@ -105,6 +105,9 @@ function ScenarioCard({
105
105
  <Icon size={16} weight="fill" className={config.color} />
106
106
  </div>
107
107
  <div className="flex-1 min-w-0 pt-0.5">
108
+ <div className="flex items-center gap-2 mb-1.5">
109
+ <Badge variant="outline" size="sm" className="font-medium">{config.label}</Badge>
110
+ </div>
108
111
  <p className="text-sm text-[var(--black)]">
109
112
  <span className="font-medium">When:</span> {scenario.situation}
110
113
  </p>