@dilipod/ui 0.4.27 → 0.4.29
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/dist/components/expandable-section.d.ts +37 -0
- package/dist/components/expandable-section.d.ts.map +1 -0
- package/dist/hooks/use-service-worker.d.ts +2 -0
- package/dist/hooks/use-service-worker.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +231 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +221 -1
- package/dist/index.mjs.map +1 -1
- package/dist/lib/email.d.ts +48 -0
- package/dist/lib/email.d.ts.map +1 -0
- package/dist/lib/formatting.d.ts +27 -0
- package/dist/lib/formatting.d.ts.map +1 -0
- package/package.json +3 -2
- package/src/components/expandable-section.tsx +106 -0
- package/src/hooks/use-service-worker.ts +23 -0
- package/src/index.ts +13 -0
- package/src/lib/email.ts +146 -0
- package/src/lib/formatting.ts +51 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email Template Helpers
|
|
3
|
+
*
|
|
4
|
+
* Shared branded email template and HTML building blocks.
|
|
5
|
+
* Used by both platform and admin apps via their local `lib/email.ts` re-exports.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Wrap email body content in the standard Dilipod branded template.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* emailTemplate({
|
|
12
|
+
* body: '<h2>Hello</h2><p>Content here</p>',
|
|
13
|
+
* })
|
|
14
|
+
*
|
|
15
|
+
* Or with a preheader (hidden preview text in email clients):
|
|
16
|
+
* emailTemplate({
|
|
17
|
+
* preheader: 'Quick summary shown in inbox',
|
|
18
|
+
* body: '...',
|
|
19
|
+
* })
|
|
20
|
+
*/
|
|
21
|
+
export declare function emailTemplate({ body, preheader }: {
|
|
22
|
+
body: string;
|
|
23
|
+
preheader?: string;
|
|
24
|
+
}): string;
|
|
25
|
+
/**
|
|
26
|
+
* Render a CTA button for use inside emailTemplate body.
|
|
27
|
+
*
|
|
28
|
+
* buttonHtml({ text: 'Reset Password', href: 'https://...' })
|
|
29
|
+
* buttonHtml({ text: 'View Incident', href: '...', variant: 'danger' })
|
|
30
|
+
*/
|
|
31
|
+
export declare function buttonHtml({ text, href, variant, }: {
|
|
32
|
+
text: string;
|
|
33
|
+
href: string;
|
|
34
|
+
variant?: 'primary' | 'danger';
|
|
35
|
+
}): string;
|
|
36
|
+
/**
|
|
37
|
+
* Render an info box (grey background) for key details.
|
|
38
|
+
*
|
|
39
|
+
* infoBoxHtml('<strong>Status:</strong> Deployed')
|
|
40
|
+
*/
|
|
41
|
+
export declare function infoBoxHtml(innerHtml: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* Render a warning/note box (yellow background).
|
|
44
|
+
*
|
|
45
|
+
* noteBoxHtml('This link expires in 1 hour.')
|
|
46
|
+
*/
|
|
47
|
+
export declare function noteBoxHtml(text: string): string;
|
|
48
|
+
//# sourceMappingURL=email.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"email.d.ts","sourceRoot":"","sources":["../../src/lib/email.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CA0E/F;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,EACzB,IAAI,EACJ,IAAI,EACJ,OAAmB,GACpB,EAAE;IACD,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAA;CAC/B,GAAG,MAAM,CAYT;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAIrD;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIhD"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting Utilities
|
|
3
|
+
*
|
|
4
|
+
* Shared text, currency, duration, and date formatting used across apps.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Convert a cent value to euros (numeric).
|
|
8
|
+
* e.g. 2900 → 29
|
|
9
|
+
*/
|
|
10
|
+
export declare function formatCentsToEuros(cents: number): string;
|
|
11
|
+
/**
|
|
12
|
+
* Format a euro value as a display string.
|
|
13
|
+
* e.g. 299 → "€299" or 299.5 with decimals=2 → "€299.50"
|
|
14
|
+
*/
|
|
15
|
+
export declare function formatEuros(euros: number, decimals?: number): string;
|
|
16
|
+
/**
|
|
17
|
+
* Format milliseconds as a human-readable duration.
|
|
18
|
+
* e.g. 1500 → "1.5s"
|
|
19
|
+
*/
|
|
20
|
+
export declare function formatDuration(ms: number): string;
|
|
21
|
+
/**
|
|
22
|
+
* Format a date into a compact relative-time string.
|
|
23
|
+
* Returns "—" for null/undefined, "5m" for < 1 hour, "2h" for < 48 hours,
|
|
24
|
+
* or a relative distance like "3 days" for older dates.
|
|
25
|
+
*/
|
|
26
|
+
export declare function formatRelativeTime(date: Date | string | null): string;
|
|
27
|
+
//# sourceMappingURL=formatting.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"formatting.d.ts","sourceRoot":"","sources":["../../src/lib/formatting.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAKpE;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,GAAG,IAAI,GAAG,MAAM,CAUrE"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dilipod/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.29",
|
|
4
4
|
"description": "Dilipod Design System - Shared UI components and styles",
|
|
5
5
|
"author": "Dilipod <hello@dilipod.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
"@xyflow/react": "^12.10.0",
|
|
82
82
|
"class-variance-authority": "^0.7.1",
|
|
83
83
|
"clsx": "^2.1.1",
|
|
84
|
+
"date-fns": "^4.1.0",
|
|
84
85
|
"tailwind-merge": "^3.3.0",
|
|
85
86
|
"tailwindcss-animate": "^1.0.7"
|
|
86
87
|
},
|
|
@@ -110,7 +111,7 @@
|
|
|
110
111
|
"tailwindcss": "^4.1.8",
|
|
111
112
|
"tsup": "^8.5.0",
|
|
112
113
|
"typescript": "^5.8.3",
|
|
113
|
-
"vite": "^
|
|
114
|
+
"vite": "^6.0.0",
|
|
114
115
|
"vitest": "^1.2.0"
|
|
115
116
|
}
|
|
116
117
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '../lib/utils'
|
|
5
|
+
|
|
6
|
+
export interface ExpandableSectionProps {
|
|
7
|
+
/** Unique key for this section */
|
|
8
|
+
sectionKey: string
|
|
9
|
+
/** Section label text */
|
|
10
|
+
label: string
|
|
11
|
+
/** Optional icon rendered before the label */
|
|
12
|
+
icon?: React.ReactNode
|
|
13
|
+
/** Optional count badge after the label */
|
|
14
|
+
count?: number
|
|
15
|
+
/** Whether the section is currently expanded */
|
|
16
|
+
expanded: boolean
|
|
17
|
+
/** Toggle callback */
|
|
18
|
+
onToggle: (key: string) => void
|
|
19
|
+
/** Section content */
|
|
20
|
+
children: React.ReactNode
|
|
21
|
+
/** Additional class for the outer container */
|
|
22
|
+
className?: string
|
|
23
|
+
/** Additional class for the content area */
|
|
24
|
+
contentClassName?: string
|
|
25
|
+
/** Whether to render the section at all (default: true) */
|
|
26
|
+
show?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ExpandableSection({
|
|
30
|
+
sectionKey,
|
|
31
|
+
label,
|
|
32
|
+
icon,
|
|
33
|
+
count,
|
|
34
|
+
expanded,
|
|
35
|
+
onToggle,
|
|
36
|
+
children,
|
|
37
|
+
className,
|
|
38
|
+
contentClassName,
|
|
39
|
+
show = true,
|
|
40
|
+
}: ExpandableSectionProps) {
|
|
41
|
+
if (!show) return null
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className={className}>
|
|
45
|
+
<button
|
|
46
|
+
onClick={() => onToggle(sectionKey)}
|
|
47
|
+
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-full text-left"
|
|
48
|
+
type="button"
|
|
49
|
+
>
|
|
50
|
+
<svg
|
|
51
|
+
width="12"
|
|
52
|
+
height="12"
|
|
53
|
+
viewBox="0 0 12 12"
|
|
54
|
+
fill="none"
|
|
55
|
+
className={cn(
|
|
56
|
+
'shrink-0 transition-transform',
|
|
57
|
+
expanded ? 'rotate-0' : '-rotate-90'
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
<path d="M2.5 4.5L6 8L9.5 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
61
|
+
</svg>
|
|
62
|
+
{icon}
|
|
63
|
+
<span>{label}</span>
|
|
64
|
+
{count !== undefined && (
|
|
65
|
+
<span className="text-muted-foreground/60">({count})</span>
|
|
66
|
+
)}
|
|
67
|
+
</button>
|
|
68
|
+
{expanded && (
|
|
69
|
+
<div className={cn('mt-2 pl-4', contentClassName)}>
|
|
70
|
+
{children}
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Hook to manage expandable section state.
|
|
79
|
+
* Returns [expandedSections, toggleSection] tuple.
|
|
80
|
+
*/
|
|
81
|
+
export function useExpandedSections(initialExpanded: string[] = []) {
|
|
82
|
+
const [expanded, setExpanded] = React.useState<Set<string>>(
|
|
83
|
+
() => new Set(initialExpanded)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const toggle = React.useCallback((key: string) => {
|
|
87
|
+
setExpanded(prev => {
|
|
88
|
+
const next = new Set(prev)
|
|
89
|
+
if (next.has(key)) {
|
|
90
|
+
next.delete(key)
|
|
91
|
+
} else {
|
|
92
|
+
next.add(key)
|
|
93
|
+
}
|
|
94
|
+
return next
|
|
95
|
+
})
|
|
96
|
+
}, [])
|
|
97
|
+
|
|
98
|
+
const isExpanded = React.useCallback(
|
|
99
|
+
(key: string) => expanded.has(key),
|
|
100
|
+
[expanded]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return { expanded, toggle, isExpanded } as const
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
ExpandableSection.displayName = 'ExpandableSection'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
|
|
5
|
+
export function useServiceWorker() {
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if ('serviceWorker' in navigator) {
|
|
8
|
+
navigator.serviceWorker
|
|
9
|
+
.register('/sw.js')
|
|
10
|
+
.then((registration) => {
|
|
11
|
+
console.log('Service Worker registered:', registration.scope)
|
|
12
|
+
|
|
13
|
+
// Check for updates periodically
|
|
14
|
+
setInterval(() => {
|
|
15
|
+
registration.update()
|
|
16
|
+
}, 60 * 60 * 1000) // Check every hour
|
|
17
|
+
})
|
|
18
|
+
.catch((error) => {
|
|
19
|
+
console.error('Service Worker registration failed:', error)
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
}, [])
|
|
23
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -305,6 +305,19 @@ export type { WorkerSpecProps, WorkerSpecDocumentation, AnalysisSource, Analysis
|
|
|
305
305
|
export { FlowchartDiagram } from './components/flowchart-diagram'
|
|
306
306
|
export type { FlowchartDiagramProps } from './components/flowchart-diagram'
|
|
307
307
|
|
|
308
|
+
// Expandable Section
|
|
309
|
+
export { ExpandableSection, useExpandedSections } from './components/expandable-section'
|
|
310
|
+
export type { ExpandableSectionProps } from './components/expandable-section'
|
|
311
|
+
|
|
312
|
+
// Hooks
|
|
313
|
+
export { useServiceWorker } from './hooks/use-service-worker'
|
|
314
|
+
|
|
315
|
+
// Formatting Utilities
|
|
316
|
+
export { formatCentsToEuros, formatEuros, formatDuration, formatRelativeTime } from './lib/formatting'
|
|
317
|
+
|
|
318
|
+
// Email Template Helpers
|
|
319
|
+
export { emailTemplate, buttonHtml, infoBoxHtml, noteBoxHtml } from './lib/email'
|
|
320
|
+
|
|
308
321
|
// Utilities
|
|
309
322
|
export { cn } from './lib/utils'
|
|
310
323
|
|
package/src/lib/email.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email Template Helpers
|
|
3
|
+
*
|
|
4
|
+
* Shared branded email template and HTML building blocks.
|
|
5
|
+
* Used by both platform and admin apps via their local `lib/email.ts` re-exports.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Wrap email body content in the standard Dilipod branded template.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* emailTemplate({
|
|
13
|
+
* body: '<h2>Hello</h2><p>Content here</p>',
|
|
14
|
+
* })
|
|
15
|
+
*
|
|
16
|
+
* Or with a preheader (hidden preview text in email clients):
|
|
17
|
+
* emailTemplate({
|
|
18
|
+
* preheader: 'Quick summary shown in inbox',
|
|
19
|
+
* body: '...',
|
|
20
|
+
* })
|
|
21
|
+
*/
|
|
22
|
+
export function emailTemplate({ body, preheader }: { body: string; preheader?: string }): string {
|
|
23
|
+
const preheaderHtml = preheader
|
|
24
|
+
? `<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">${preheader}</div>`
|
|
25
|
+
: ''
|
|
26
|
+
|
|
27
|
+
return `<!DOCTYPE html>
|
|
28
|
+
<html lang="en">
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="utf-8" />
|
|
31
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
32
|
+
<meta name="color-scheme" content="light" />
|
|
33
|
+
<meta name="supported-color-schemes" content="light" />
|
|
34
|
+
<title>Dilipod</title>
|
|
35
|
+
</head>
|
|
36
|
+
<body style="margin: 0; padding: 0; background-color: #f0f0f0; -webkit-font-smoothing: antialiased;">
|
|
37
|
+
${preheaderHtml}
|
|
38
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f0f0f0;">
|
|
39
|
+
<tr>
|
|
40
|
+
<td align="center" style="padding: 40px 16px;">
|
|
41
|
+
<!-- Main card -->
|
|
42
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 560px;">
|
|
43
|
+
<!-- Logo header -->
|
|
44
|
+
<tr>
|
|
45
|
+
<td style="padding: 0 0 24px 0;">
|
|
46
|
+
<table role="presentation" cellpadding="0" cellspacing="0">
|
|
47
|
+
<tr>
|
|
48
|
+
<td style="width: 32px; height: 32px; background-color: #00e5cc; border-radius: 6px; text-align: center; vertical-align: middle; font-size: 16px; font-weight: 700; color: #111111; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
|
49
|
+
D
|
|
50
|
+
</td>
|
|
51
|
+
<td style="padding-left: 10px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 17px; font-weight: 600; color: #111111; letter-spacing: -0.2px;">
|
|
52
|
+
Dilipod
|
|
53
|
+
</td>
|
|
54
|
+
</tr>
|
|
55
|
+
</table>
|
|
56
|
+
</td>
|
|
57
|
+
</tr>
|
|
58
|
+
<!-- Content card -->
|
|
59
|
+
<tr>
|
|
60
|
+
<td>
|
|
61
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 6px; overflow: hidden; border: 1px solid #e2e2e2;">
|
|
62
|
+
<!-- Accent bar -->
|
|
63
|
+
<tr>
|
|
64
|
+
<td style="height: 3px; background: linear-gradient(90deg, #00e5cc 0%, #00c8b5 100%); font-size: 0; line-height: 0;"> </td>
|
|
65
|
+
</tr>
|
|
66
|
+
<!-- Body content -->
|
|
67
|
+
<tr>
|
|
68
|
+
<td style="padding: 36px 40px 40px 40px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; line-height: 1.7; color: #374151;">
|
|
69
|
+
${body}
|
|
70
|
+
</td>
|
|
71
|
+
</tr>
|
|
72
|
+
</table>
|
|
73
|
+
</td>
|
|
74
|
+
</tr>
|
|
75
|
+
<!-- Footer -->
|
|
76
|
+
<tr>
|
|
77
|
+
<td style="padding: 24px 4px 0 4px;">
|
|
78
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
|
79
|
+
<tr>
|
|
80
|
+
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af;">
|
|
81
|
+
© ${new Date().getFullYear()} Dilipod — Your Digital Workforce
|
|
82
|
+
</td>
|
|
83
|
+
<td align="right" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px;">
|
|
84
|
+
<a href="https://dilipod.com" style="color: #9ca3af; text-decoration: none;">dilipod.com</a>
|
|
85
|
+
</td>
|
|
86
|
+
</tr>
|
|
87
|
+
</table>
|
|
88
|
+
</td>
|
|
89
|
+
</tr>
|
|
90
|
+
</table>
|
|
91
|
+
</td>
|
|
92
|
+
</tr>
|
|
93
|
+
</table>
|
|
94
|
+
</body>
|
|
95
|
+
</html>`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Render a CTA button for use inside emailTemplate body.
|
|
100
|
+
*
|
|
101
|
+
* buttonHtml({ text: 'Reset Password', href: 'https://...' })
|
|
102
|
+
* buttonHtml({ text: 'View Incident', href: '...', variant: 'danger' })
|
|
103
|
+
*/
|
|
104
|
+
export function buttonHtml({
|
|
105
|
+
text,
|
|
106
|
+
href,
|
|
107
|
+
variant = 'primary',
|
|
108
|
+
}: {
|
|
109
|
+
text: string
|
|
110
|
+
href: string
|
|
111
|
+
variant?: 'primary' | 'danger'
|
|
112
|
+
}): string {
|
|
113
|
+
const bg = variant === 'danger' ? '#dc2626' : '#111111'
|
|
114
|
+
const color = '#ffffff'
|
|
115
|
+
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin-top: 24px;">
|
|
116
|
+
<tr>
|
|
117
|
+
<td style="background-color: ${bg}; border-radius: 6px;">
|
|
118
|
+
<a href="${href}" style="display: inline-block; padding: 12px 32px; color: ${color}; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; font-weight: 600; text-decoration: none;">
|
|
119
|
+
${text} →
|
|
120
|
+
</a>
|
|
121
|
+
</td>
|
|
122
|
+
</tr>
|
|
123
|
+
</table>`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Render an info box (grey background) for key details.
|
|
128
|
+
*
|
|
129
|
+
* infoBoxHtml('<strong>Status:</strong> Deployed')
|
|
130
|
+
*/
|
|
131
|
+
export function infoBoxHtml(innerHtml: string): string {
|
|
132
|
+
return `<div style="background-color: #f8fafb; padding: 16px 20px; border-radius: 6px; border: 1px solid #e5e7eb; margin: 20px 0; border-left: 3px solid #00e5cc;">
|
|
133
|
+
${innerHtml}
|
|
134
|
+
</div>`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Render a warning/note box (yellow background).
|
|
139
|
+
*
|
|
140
|
+
* noteBoxHtml('This link expires in 1 hour.')
|
|
141
|
+
*/
|
|
142
|
+
export function noteBoxHtml(text: string): string {
|
|
143
|
+
return `<div style="background-color: #fffbeb; padding: 14px 18px; border-radius: 6px; border: 1px solid #fde68a; margin: 20px 0; font-size: 13px; color: #92400e; border-left: 3px solid #f59e0b;">
|
|
144
|
+
${text}
|
|
145
|
+
</div>`
|
|
146
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting Utilities
|
|
3
|
+
*
|
|
4
|
+
* Shared text, currency, duration, and date formatting used across apps.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { differenceInHours, differenceInMinutes, formatDistanceToNow } from 'date-fns'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Convert a cent value to euros (numeric).
|
|
11
|
+
* e.g. 2900 → 29
|
|
12
|
+
*/
|
|
13
|
+
export function formatCentsToEuros(cents: number): string {
|
|
14
|
+
return `€${(cents / 100).toLocaleString()}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Format a euro value as a display string.
|
|
19
|
+
* e.g. 299 → "€299" or 299.5 with decimals=2 → "€299.50"
|
|
20
|
+
*/
|
|
21
|
+
export function formatEuros(euros: number, decimals?: number): string {
|
|
22
|
+
if (decimals !== undefined) {
|
|
23
|
+
return `€${euros.toFixed(decimals)}`
|
|
24
|
+
}
|
|
25
|
+
return `€${euros.toLocaleString()}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Format milliseconds as a human-readable duration.
|
|
30
|
+
* e.g. 1500 → "1.5s"
|
|
31
|
+
*/
|
|
32
|
+
export function formatDuration(ms: number): string {
|
|
33
|
+
return `${(ms / 1000).toFixed(1)}s`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Format a date into a compact relative-time string.
|
|
38
|
+
* Returns "—" for null/undefined, "5m" for < 1 hour, "2h" for < 48 hours,
|
|
39
|
+
* or a relative distance like "3 days" for older dates.
|
|
40
|
+
*/
|
|
41
|
+
export function formatRelativeTime(date: Date | string | null): string {
|
|
42
|
+
if (!date) return '—'
|
|
43
|
+
const d = typeof date === 'string' ? new Date(date) : date
|
|
44
|
+
const hours = differenceInHours(new Date(), d)
|
|
45
|
+
if (hours < 1) {
|
|
46
|
+
const mins = differenceInMinutes(new Date(), d)
|
|
47
|
+
return `${mins}m`
|
|
48
|
+
}
|
|
49
|
+
if (hours < 48) return `${hours}h`
|
|
50
|
+
return formatDistanceToNow(d, { addSuffix: false })
|
|
51
|
+
}
|