@hanzo/dashboard 0.2.0 → 0.2.1
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/LICENSE.md +21 -0
- package/README.md +245 -0
- package/package.json +38 -50
- package/src/analytics/index.ts +20 -0
- package/src/analytics/parts.tsx +91 -0
- package/src/analytics/product.tsx +64 -0
- package/src/analytics/source.ts +141 -0
- package/src/analytics/web.tsx +83 -0
- package/src/charts/Charts.tsx +470 -0
- package/src/dashboard.css +157 -0
- package/src/index.ts +119 -0
- package/src/landing/Hero.tsx +115 -0
- package/src/landing/Landing.tsx +47 -0
- package/src/landing/Metrics.tsx +134 -0
- package/src/landing/Rail.tsx +61 -0
- package/src/landing/Samples.tsx +78 -0
- package/src/landing/logic.ts +47 -0
- package/src/landing/parts.tsx +116 -0
- package/src/landing/types.ts +98 -0
- package/src/motion/hooks.ts +98 -0
- package/src/motion/motion.ts +76 -0
- package/src/overview/Overview.tsx +203 -0
- package/src/overview/config.ts +176 -0
- package/src/overview/logic.ts +204 -0
- package/src/overview/primitives.tsx +306 -0
- package/src/overview/tiles.tsx +223 -0
- package/src/pipeline/Pipeline.tsx +178 -0
- package/src/pipeline/stages.ts +156 -0
- package/src/types.ts +12 -0
- package/readme.md +0 -57
- package/src/lib/api.ts +0 -155
- package/src/lib/components/ConfirmDialog.tsx +0 -53
- package/src/lib/components/TenantRow.tsx +0 -39
- package/src/lib/components/TenantStatusBadge.tsx +0 -33
- package/src/lib/index.ts +0 -30
- package/src/lib/nav.ts +0 -12
- package/src/lib/screens/NewTenantScreen.tsx +0 -137
- package/src/lib/screens/TenantsScreen.tsx +0 -229
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hanzo/dashboard motion + numeric-face stylesheet.
|
|
3
|
+
*
|
|
4
|
+
* Import ONCE in a consumer app (e.g. `import '@hanzo/dashboard/dashboard.css'`).
|
|
5
|
+
* It ships the CSS the dashboard layer's JS-driven motion pairs with: the loading
|
|
6
|
+
* shimmer, the live-feed pulse, the fade-up entrance, the deploy-Pipeline track
|
|
7
|
+
* animations, and the tabular-numeral face for metric values.
|
|
8
|
+
*
|
|
9
|
+
* Every animation here is reduced-motion-guarded (→ static) — the count-up + live
|
|
10
|
+
* sparkline are driven in JS and gated by `prefers-reduced-motion` in the hooks;
|
|
11
|
+
* these are the pure-CSS bits. One place defines the easing; components apply the
|
|
12
|
+
* classes. Extracted verbatim from the Hanzo Cloud Console so the "videogame-like"
|
|
13
|
+
* feel is identical across apps.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/* Tabular numerals — metric values, prices, counts and IDs align on a fixed
|
|
17
|
+
advance width so columns of numbers read cleanly (the dashboard-grade detail). */
|
|
18
|
+
.tnum {
|
|
19
|
+
font-variant-numeric: tabular-nums;
|
|
20
|
+
font-feature-settings: 'tnum' 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* Motion — a single fade-up entrance (~0.4s ease-out, small upward travel,
|
|
24
|
+
staggered by the consumer). One place defines it; <FadeIn> applies the class. */
|
|
25
|
+
@keyframes fade-up {
|
|
26
|
+
from {
|
|
27
|
+
opacity: 0;
|
|
28
|
+
transform: translateY(10px);
|
|
29
|
+
}
|
|
30
|
+
to {
|
|
31
|
+
opacity: 1;
|
|
32
|
+
transform: translateY(0);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.fade-up {
|
|
37
|
+
animation: fade-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
38
|
+
will-change: transform, opacity;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@media (prefers-reduced-motion: reduce) {
|
|
42
|
+
.fade-up {
|
|
43
|
+
animation: none;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Overview motion — the "videogame-like" live dashboard. The count-up +
|
|
48
|
+
live sparkline are driven in JS (rAF, gated by prefers-reduced-motion in the
|
|
49
|
+
hooks); these are the pure-CSS bits: a loading shimmer, a live-feed pulse, and
|
|
50
|
+
a brief highlight when a tile's number changes. */
|
|
51
|
+
|
|
52
|
+
/* Skeleton shimmer — an honest "loading", never fabricated content. */
|
|
53
|
+
@keyframes shimmer {
|
|
54
|
+
0% {
|
|
55
|
+
background-position: -160px 0;
|
|
56
|
+
}
|
|
57
|
+
100% {
|
|
58
|
+
background-position: 160px 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.skeleton {
|
|
63
|
+
background-color: var(--color3, rgba(148, 163, 184, 0.14));
|
|
64
|
+
background-image: linear-gradient(
|
|
65
|
+
90deg,
|
|
66
|
+
transparent 0%,
|
|
67
|
+
var(--color4, rgba(148, 163, 184, 0.22)) 50%,
|
|
68
|
+
transparent 100%
|
|
69
|
+
);
|
|
70
|
+
background-size: 160px 100%;
|
|
71
|
+
background-repeat: no-repeat;
|
|
72
|
+
animation: shimmer 1.2s ease-in-out infinite;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* Live-feed pulse — the "Live" dot on a streaming panel. */
|
|
76
|
+
@keyframes pulse {
|
|
77
|
+
0%,
|
|
78
|
+
100% {
|
|
79
|
+
opacity: 1;
|
|
80
|
+
transform: scale(1);
|
|
81
|
+
}
|
|
82
|
+
50% {
|
|
83
|
+
opacity: 0.45;
|
|
84
|
+
transform: scale(0.82);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* Entrance for a freshly-arrived activity row (staggerless — one row at a time). */
|
|
89
|
+
@keyframes row-in {
|
|
90
|
+
from {
|
|
91
|
+
opacity: 0;
|
|
92
|
+
transform: translateY(-6px);
|
|
93
|
+
}
|
|
94
|
+
to {
|
|
95
|
+
opacity: 1;
|
|
96
|
+
transform: translateY(0);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.row-in {
|
|
101
|
+
animation: row-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@media (prefers-reduced-motion: reduce) {
|
|
105
|
+
.skeleton {
|
|
106
|
+
animation: none;
|
|
107
|
+
}
|
|
108
|
+
.row-in {
|
|
109
|
+
animation: none;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* Pipeline — the deploy-stage line. A smooth flowing gradient marches along the
|
|
114
|
+
active leg of the track (stroke-dashoffset), a soft halo pulses out from the
|
|
115
|
+
current stage, and the status dot breathes. All reduced-motion-guarded (→
|
|
116
|
+
static). One place defines the easing; Pipeline applies the classes. */
|
|
117
|
+
@keyframes hz-pipe-flow {
|
|
118
|
+
to {
|
|
119
|
+
stroke-dashoffset: -28;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
.hz-pipe-flow {
|
|
123
|
+
stroke-dasharray: 5 9;
|
|
124
|
+
animation: hz-pipe-flow 0.85s linear infinite;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@keyframes hz-pipe-pulse {
|
|
128
|
+
0% {
|
|
129
|
+
transform: scale(1);
|
|
130
|
+
opacity: 0.34;
|
|
131
|
+
}
|
|
132
|
+
70% {
|
|
133
|
+
transform: scale(2.1);
|
|
134
|
+
opacity: 0;
|
|
135
|
+
}
|
|
136
|
+
100% {
|
|
137
|
+
transform: scale(2.1);
|
|
138
|
+
opacity: 0;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
.hz-pipe-pulse {
|
|
142
|
+
transform-box: fill-box;
|
|
143
|
+
transform-origin: center;
|
|
144
|
+
animation: hz-pipe-pulse 1.7s ease-out infinite;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.hz-pipe-dot {
|
|
148
|
+
animation: pulse 1.5s ease-in-out infinite;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@media (prefers-reduced-motion: reduce) {
|
|
152
|
+
.hz-pipe-flow,
|
|
153
|
+
.hz-pipe-pulse,
|
|
154
|
+
.hz-pipe-dot {
|
|
155
|
+
animation: none;
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// @hanzo/dashboard — the reusable dashboard layer on @hanzo/gui.
|
|
2
|
+
//
|
|
3
|
+
// Composable dashboard primitives extracted from the Hanzo Cloud Console so every
|
|
4
|
+
// Hanzo app builds the SAME dashboard system: monochrome SVG charts, a live
|
|
5
|
+
// count-up KPI + streaming-feed + health Overview, the Landing kit, and a deploy
|
|
6
|
+
// Pipeline. Each is pure (data via props / a loader), typed, reduced-motion-guarded.
|
|
7
|
+
//
|
|
8
|
+
// import '@hanzo/dashboard/dashboard.css' // once, for the motion keyframes
|
|
9
|
+
// import { Overview, Kpi, Sparkline, Landing, Pipeline } from '@hanzo/dashboard'
|
|
10
|
+
|
|
11
|
+
// Shared
|
|
12
|
+
export type { IconComponent } from './types'
|
|
13
|
+
|
|
14
|
+
// ── Charts (the ONE way to draw a trend / series / share / distribution) ─────
|
|
15
|
+
export {
|
|
16
|
+
Sparkline,
|
|
17
|
+
Line,
|
|
18
|
+
Columns,
|
|
19
|
+
Donut,
|
|
20
|
+
Bars,
|
|
21
|
+
useContainerWidth,
|
|
22
|
+
CHART_PALETTE,
|
|
23
|
+
CHART_OTHER,
|
|
24
|
+
type ChartPoint,
|
|
25
|
+
type Slice,
|
|
26
|
+
} from './charts/Charts'
|
|
27
|
+
|
|
28
|
+
// ── Motion (pure math + rAF/interval hooks) ──────────────────────────────────
|
|
29
|
+
export { easeOutCubic, countUpValue, progress, pushSample, shouldTick, effectiveInterval } from './motion/motion'
|
|
30
|
+
export { useReducedMotion, usePageHidden, useCountUp, usePoll } from './motion/hooks'
|
|
31
|
+
|
|
32
|
+
// ── Overview: the videogame-like dashboard ────────────────────────────
|
|
33
|
+
export { Overview } from './overview/Overview'
|
|
34
|
+
export { Tile } from './overview/tiles'
|
|
35
|
+
export { Kpi, Feed, Board, Panel, SkeletonBar, EmptyPanel, PanelSpinner, LiveDot } from './overview/primitives'
|
|
36
|
+
export type { KpiProps, FeedProps, BoardProps } from './overview/primitives'
|
|
37
|
+
export {
|
|
38
|
+
formatMetric,
|
|
39
|
+
metricTarget,
|
|
40
|
+
deltaOf,
|
|
41
|
+
hasTrend,
|
|
42
|
+
statusColor,
|
|
43
|
+
healthColor,
|
|
44
|
+
severityColor,
|
|
45
|
+
worstHealth,
|
|
46
|
+
healthTally,
|
|
47
|
+
selectKpi,
|
|
48
|
+
selectSeries,
|
|
49
|
+
selectDistribution,
|
|
50
|
+
distributionTotal,
|
|
51
|
+
mergeActivity,
|
|
52
|
+
windowRows,
|
|
53
|
+
ago,
|
|
54
|
+
isSkeleton,
|
|
55
|
+
OK,
|
|
56
|
+
WARN,
|
|
57
|
+
BAD,
|
|
58
|
+
MUTED,
|
|
59
|
+
type Loadable,
|
|
60
|
+
} from './overview/logic'
|
|
61
|
+
export type {
|
|
62
|
+
OverviewConfig,
|
|
63
|
+
OverviewContext,
|
|
64
|
+
OverviewData,
|
|
65
|
+
OverviewRange,
|
|
66
|
+
OverviewTile,
|
|
67
|
+
MetricTile,
|
|
68
|
+
TimeseriesTile,
|
|
69
|
+
DistributionTile,
|
|
70
|
+
ActivityTile,
|
|
71
|
+
AlertsTile,
|
|
72
|
+
HealthTile,
|
|
73
|
+
MetricUnit,
|
|
74
|
+
LiveConfig,
|
|
75
|
+
LoadOverview,
|
|
76
|
+
OverviewKpi,
|
|
77
|
+
OverviewSeries,
|
|
78
|
+
OverviewSlice,
|
|
79
|
+
OverviewPoint,
|
|
80
|
+
OverviewEvent,
|
|
81
|
+
OverviewAlert,
|
|
82
|
+
OverviewHealth,
|
|
83
|
+
} from './overview/config'
|
|
84
|
+
|
|
85
|
+
// ── Landing kit (Hero / Metrics / Samples / Rail) ────────────────────────────
|
|
86
|
+
export { Landing } from './landing/Landing'
|
|
87
|
+
export { Hero } from './landing/Hero'
|
|
88
|
+
export { Metrics } from './landing/Metrics'
|
|
89
|
+
export { Samples } from './landing/Samples'
|
|
90
|
+
export { Rail } from './landing/Rail'
|
|
91
|
+
export { LandingCard, ActionRow, DeltaChip, openExternal } from './landing/parts'
|
|
92
|
+
export { apexFromDocs, apiBaseFromDocs, landingDocsUrl, standardResources, supportMailto, type StandardResources } from './landing/logic'
|
|
93
|
+
export type {
|
|
94
|
+
LandingConfig,
|
|
95
|
+
LandingAction,
|
|
96
|
+
LandingMetric,
|
|
97
|
+
ResourceLink,
|
|
98
|
+
CodeSample,
|
|
99
|
+
CodeLang,
|
|
100
|
+
} from './landing/types'
|
|
101
|
+
|
|
102
|
+
// ── Pipeline (deploy-stage line) ─────────────────────────────────────────────
|
|
103
|
+
export { Pipeline, type PipelineProps } from './pipeline/Pipeline'
|
|
104
|
+
export {
|
|
105
|
+
PIPELINE_STAGES,
|
|
106
|
+
pipelinePhase,
|
|
107
|
+
stageIndex,
|
|
108
|
+
isTerminalPhase,
|
|
109
|
+
pipelineModel,
|
|
110
|
+
pipelineTone,
|
|
111
|
+
type PipelinePhase,
|
|
112
|
+
type PipelineStage,
|
|
113
|
+
type PipelineStageName,
|
|
114
|
+
type PipelineModel,
|
|
115
|
+
type StageState,
|
|
116
|
+
} from './pipeline/stages'
|
|
117
|
+
|
|
118
|
+
// ── Analytics: web and product views over analytics.hanzo.ai ─────────────────
|
|
119
|
+
export * from './analytics'
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hero — the clean product hero (kicker + title + one-line what-it-is + primary
|
|
5
|
+
* CTA): monochrome-tasteful with a functional accent + a pure-SVG hero graphic.
|
|
6
|
+
* White-labels by brand (`config.brandName`) and accent (`config.accent`).
|
|
7
|
+
*/
|
|
8
|
+
import type { ComponentProps } from 'react'
|
|
9
|
+
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
|
10
|
+
|
|
11
|
+
import { CHART_PALETTE } from '../charts/Charts'
|
|
12
|
+
import { openExternal } from './parts'
|
|
13
|
+
import type { LandingAction, LandingConfig } from './types'
|
|
14
|
+
|
|
15
|
+
/** A hex color at an alpha, as an `rgba()`. */
|
|
16
|
+
function hex(h: string, a: number): string {
|
|
17
|
+
const n = h.replace('#', '')
|
|
18
|
+
const r = parseInt(n.slice(0, 2), 16)
|
|
19
|
+
const g = parseInt(n.slice(2, 4), 16)
|
|
20
|
+
const b = parseInt(n.slice(4, 6), 16)
|
|
21
|
+
return `rgba(${r}, ${g}, ${b}, ${a})`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const act = (a: LandingAction) => (a.href ? openExternal(a.href) : a.onPress?.())
|
|
25
|
+
|
|
26
|
+
export function Hero({ config: c }: { config: LandingConfig }) {
|
|
27
|
+
const Icon = c.icon
|
|
28
|
+
const accent = c.accent ?? CHART_PALETTE[0]
|
|
29
|
+
return (
|
|
30
|
+
<Card
|
|
31
|
+
borderWidth={1}
|
|
32
|
+
borderColor="$borderColor"
|
|
33
|
+
rounded="$6"
|
|
34
|
+
p="$5"
|
|
35
|
+
overflow="hidden"
|
|
36
|
+
style={{ background: `linear-gradient(115deg, ${hex(accent, 0.16)} 0%, ${hex(accent, 0.04)} 48%, rgba(0,0,0,0) 78%)` }}
|
|
37
|
+
>
|
|
38
|
+
<XStack items="center" gap="$4" flexWrap="wrap">
|
|
39
|
+
<YStack flex={1} minW={280} gap="$2.5">
|
|
40
|
+
{Icon || c.brandName ? (
|
|
41
|
+
<XStack items="center" gap="$2">
|
|
42
|
+
{Icon ? <Icon size={16} color={accent} /> : null}
|
|
43
|
+
{c.brandName ? (
|
|
44
|
+
<Text fontSize="$1" color="$color11" fontWeight="700" style={{ letterSpacing: 0.4, textTransform: 'uppercase' }}>
|
|
45
|
+
{c.brandName}
|
|
46
|
+
</Text>
|
|
47
|
+
) : null}
|
|
48
|
+
</XStack>
|
|
49
|
+
) : null}
|
|
50
|
+
<Text fontSize="$8" fontWeight="900" color="$color12">
|
|
51
|
+
{c.title}
|
|
52
|
+
</Text>
|
|
53
|
+
<Text fontSize="$3" color="$color11" maxW={620}>
|
|
54
|
+
{c.tagline}
|
|
55
|
+
</Text>
|
|
56
|
+
{c.primary || c.secondary ? (
|
|
57
|
+
<XStack gap="$2" pt="$2" flexWrap="wrap" items="center">
|
|
58
|
+
{c.primary ? (
|
|
59
|
+
<AccentButton accent={accent} icon={c.primary.icon} onPress={() => act(c.primary as LandingAction)}>
|
|
60
|
+
{c.primary.label}
|
|
61
|
+
</AccentButton>
|
|
62
|
+
) : null}
|
|
63
|
+
{c.secondary ? (
|
|
64
|
+
<Button icon={c.secondary.icon} onPress={() => act(c.secondary as LandingAction)}>
|
|
65
|
+
{c.secondary.label}
|
|
66
|
+
</Button>
|
|
67
|
+
) : null}
|
|
68
|
+
</XStack>
|
|
69
|
+
) : null}
|
|
70
|
+
</YStack>
|
|
71
|
+
<YStack items="center" justify="center" minW={170}>
|
|
72
|
+
<HeroGraphic size={170} accent={accent} />
|
|
73
|
+
</YStack>
|
|
74
|
+
</XStack>
|
|
75
|
+
</Card>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The one high-emphasis accent action (a filled button on the brand accent). */
|
|
80
|
+
function AccentButton({ accent, children, ...props }: { accent: string } & ComponentProps<typeof Button>) {
|
|
81
|
+
return (
|
|
82
|
+
<Button {...props} borderWidth={1} style={{ backgroundColor: accent, borderColor: accent, color: '#fff' }} hoverStyle={{ opacity: 0.92 }} pressStyle={{ opacity: 0.85 }}>
|
|
83
|
+
{children}
|
|
84
|
+
</Button>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A pure-SVG hero motif — a glowing isometric "serving unit" cube. */
|
|
89
|
+
function HeroGraphic({ size = 170, accent = CHART_PALETTE[0] }: { size?: number; accent?: string }) {
|
|
90
|
+
return (
|
|
91
|
+
<svg width={size} height={size} viewBox="0 0 200 200" aria-hidden style={{ display: 'block', maxWidth: '100%' }}>
|
|
92
|
+
<defs>
|
|
93
|
+
<radialGradient id="hz-hero-glow" cx="50%" cy="45%" r="60%">
|
|
94
|
+
<stop offset="0%" stopColor={accent} stopOpacity="0.55" />
|
|
95
|
+
<stop offset="55%" stopColor={accent} stopOpacity="0.14" />
|
|
96
|
+
<stop offset="100%" stopColor={accent} stopOpacity="0" />
|
|
97
|
+
</radialGradient>
|
|
98
|
+
<linearGradient id="hz-hero-cube" x1="0" y1="0" x2="1" y2="1">
|
|
99
|
+
<stop offset="0%" stopColor="#a78bfa" />
|
|
100
|
+
<stop offset="100%" stopColor={accent} />
|
|
101
|
+
</linearGradient>
|
|
102
|
+
</defs>
|
|
103
|
+
<circle cx="100" cy="96" r="92" fill="url(#hz-hero-glow)" />
|
|
104
|
+
{[74, 56, 40].map((r, i) => (
|
|
105
|
+
<circle key={r} cx="100" cy="96" r={r} fill="none" stroke={accent} strokeOpacity={0.18 + i * 0.12} strokeWidth={1.5} />
|
|
106
|
+
))}
|
|
107
|
+
<g transform="translate(100 96)">
|
|
108
|
+
<polygon points="0,-34 30,-17 30,17 0,34 -30,17 -30,-17" fill="url(#hz-hero-cube)" fillOpacity="0.9" />
|
|
109
|
+
<polygon points="0,-34 30,-17 0,0 -30,-17" fill="#c4b5fd" fillOpacity="0.85" />
|
|
110
|
+
<polygon points="0,0 30,-17 30,17 0,34" fill={accent} fillOpacity="0.7" />
|
|
111
|
+
<polygon points="0,0 -30,-17 -30,17 0,34" fill="#6d28d9" fillOpacity="0.7" />
|
|
112
|
+
</g>
|
|
113
|
+
</svg>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Landing — the reusable polished landing/overview any product composes from a
|
|
5
|
+
* small `LandingConfig`: a clean hero, a live count-up metrics row, an interactive
|
|
6
|
+
* code-sample block (curl/python/ts/js + copy + Run), and a resources rail
|
|
7
|
+
* (docs/quickstart/examples/code/support). DRY — one landing system across products.
|
|
8
|
+
*
|
|
9
|
+
* Layout: hero on top, then a two-column body (main content + resource rail) that
|
|
10
|
+
* wraps to a single column on narrow viewports. `children` is the product's own
|
|
11
|
+
* REAL content, rendered below the code block so a product keeps everything it
|
|
12
|
+
* already had — this is polish, not a rewrite.
|
|
13
|
+
*/
|
|
14
|
+
import { useRef, type ReactNode } from 'react'
|
|
15
|
+
import { XStack, YStack } from '@hanzo/gui'
|
|
16
|
+
|
|
17
|
+
import { Hero } from './Hero'
|
|
18
|
+
import { Metrics } from './Metrics'
|
|
19
|
+
import { Samples } from './Samples'
|
|
20
|
+
import { Rail } from './Rail'
|
|
21
|
+
import type { LandingConfig } from './types'
|
|
22
|
+
|
|
23
|
+
export function Landing({ config, children }: { config: LandingConfig; children?: ReactNode }) {
|
|
24
|
+
const codeRef = useRef<HTMLDivElement>(null)
|
|
25
|
+
const hasCode = Boolean(config.samples && config.samples.length)
|
|
26
|
+
const scrollToCode = () => codeRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<YStack gap="$4">
|
|
30
|
+
<Hero config={config} />
|
|
31
|
+
|
|
32
|
+
<XStack gap="$4" flexWrap="wrap" items="flex-start">
|
|
33
|
+
<YStack flex={2} minW={360} gap="$4">
|
|
34
|
+
<Metrics config={config} />
|
|
35
|
+
{hasCode ? (
|
|
36
|
+
<div ref={codeRef} style={{ width: '100%' }}>
|
|
37
|
+
<Samples samples={config.samples as NonNullable<LandingConfig['samples']>} run={config.run} />
|
|
38
|
+
</div>
|
|
39
|
+
) : null}
|
|
40
|
+
{children}
|
|
41
|
+
</YStack>
|
|
42
|
+
|
|
43
|
+
<Rail config={config} onViewCode={hasCode ? scrollToCode : undefined} />
|
|
44
|
+
</XStack>
|
|
45
|
+
</YStack>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Metrics — the live KPI row for a landing. Count-up values + real sparklines +
|
|
5
|
+
* honest deltas, reusing the motion system (`useCountUp`/`usePoll`/
|
|
6
|
+
* `useReducedMotion`) and the shared `Sparkline`. Two modes:
|
|
7
|
+
* - controlled: the parent passes `config.metrics` (it owns the data / reload);
|
|
8
|
+
* - self-loading: `config.loadMetrics` (+ optional `pollMs`) fetches + polls here.
|
|
9
|
+
* Honest throughout: a null value renders "—", a background poll failure keeps the
|
|
10
|
+
* last real data, and a first-load failure shows a quiet note (metrics are
|
|
11
|
+
* supplementary — never a full error card).
|
|
12
|
+
*/
|
|
13
|
+
import { useEffect, useRef, useState } from 'react'
|
|
14
|
+
import { Card, Text, XStack, YStack } from '@hanzo/gui'
|
|
15
|
+
|
|
16
|
+
import { CHART_PALETTE, Sparkline } from '../charts/Charts'
|
|
17
|
+
import { useCountUp, usePoll, useReducedMotion } from '../motion/hooks'
|
|
18
|
+
import { DeltaChip } from './parts'
|
|
19
|
+
import type { LandingConfig, LandingMetric } from './types'
|
|
20
|
+
|
|
21
|
+
export function Metrics({ config }: { config: LandingConfig }) {
|
|
22
|
+
const reduced = useReducedMotion()
|
|
23
|
+
const controlled = config.metrics !== undefined
|
|
24
|
+
const load = config.loadMetrics
|
|
25
|
+
const accent = config.accent ?? CHART_PALETTE[0]
|
|
26
|
+
|
|
27
|
+
const [metrics, setMetrics] = useState<LandingMetric[] | null>(config.metrics ?? null)
|
|
28
|
+
const [failed, setFailed] = useState(false)
|
|
29
|
+
const hasData = useRef(false)
|
|
30
|
+
hasData.current = metrics !== null
|
|
31
|
+
|
|
32
|
+
const { tick } = usePoll(!controlled && load && config.pollMs ? config.pollMs : 0)
|
|
33
|
+
|
|
34
|
+
// Controlled: mirror the parent-provided metrics.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (controlled) setMetrics(config.metrics ?? null)
|
|
37
|
+
}, [controlled, config.metrics])
|
|
38
|
+
|
|
39
|
+
// Self-loading: fetch (and re-fetch on each poll tick). Keep last-good on error.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (controlled || !load) return
|
|
42
|
+
let cancelled = false
|
|
43
|
+
void load()
|
|
44
|
+
.then((m) => {
|
|
45
|
+
if (!cancelled) {
|
|
46
|
+
setMetrics(m)
|
|
47
|
+
setFailed(false)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
.catch(() => {
|
|
51
|
+
if (!cancelled && !hasData.current) setFailed(true)
|
|
52
|
+
})
|
|
53
|
+
return () => {
|
|
54
|
+
cancelled = true
|
|
55
|
+
}
|
|
56
|
+
}, [controlled, load, tick])
|
|
57
|
+
|
|
58
|
+
if (!metrics) {
|
|
59
|
+
if (failed) return <MetricsNote text="Live metrics aren’t connected for your organization yet — nothing is fabricated." />
|
|
60
|
+
if (!controlled && load) return <MetricsSkeleton />
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
if (metrics.length === 0) return null
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<XStack flexWrap="wrap" gap="$3">
|
|
67
|
+
{metrics.map((m) => (
|
|
68
|
+
<MetricCard key={m.key} metric={m} reduced={reduced} accent={accent} />
|
|
69
|
+
))}
|
|
70
|
+
</XStack>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function MetricCard({ metric, reduced, accent }: { metric: LandingMetric; reduced: boolean; accent: string }) {
|
|
75
|
+
const finite = metric.value != null && Number.isFinite(metric.value)
|
|
76
|
+
const target = finite ? (metric.value as number) : 0
|
|
77
|
+
|
|
78
|
+
// Count up from zero on first mount (then re-animate on every value change). Seeding
|
|
79
|
+
// via an after-mount effect means `useCountUp` animates 0 → value instead of snapping.
|
|
80
|
+
const [seeded, setSeeded] = useState(false)
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
setSeeded(true)
|
|
83
|
+
}, [])
|
|
84
|
+
const shown = useCountUp(seeded ? target : 0, !reduced && finite)
|
|
85
|
+
const fmt = metric.format ?? ((n: number) => Math.round(n).toLocaleString())
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<Card p="$3.5" gap="$2" borderWidth={1} borderColor="$borderColor" bg="$color2" flex={1} minW={168}>
|
|
89
|
+
<XStack items="center" justify="space-between" gap="$2">
|
|
90
|
+
<Text fontSize="$2" color="$color10" numberOfLines={1}>
|
|
91
|
+
{metric.label}
|
|
92
|
+
</Text>
|
|
93
|
+
{metric.icon ?? null}
|
|
94
|
+
</XStack>
|
|
95
|
+
<Text fontSize="$8" fontWeight="900" color={finite ? '$color12' : '$color10'} numberOfLines={1}>
|
|
96
|
+
{finite ? fmt(shown) : '—'}
|
|
97
|
+
</Text>
|
|
98
|
+
<XStack items="center" justify="space-between" gap="$2" minH={22}>
|
|
99
|
+
{metric.deltaPct !== undefined ? <DeltaChip pct={metric.deltaPct ?? null} /> : <YStack />}
|
|
100
|
+
{metric.series && metric.series.filter((v) => Number.isFinite(v)).length >= 2 ? (
|
|
101
|
+
<Sparkline values={metric.series} width={72} height={22} color={accent} />
|
|
102
|
+
) : metric.hint ? (
|
|
103
|
+
<Text fontSize="$1" color="$color9" numberOfLines={1}>
|
|
104
|
+
{metric.hint}
|
|
105
|
+
</Text>
|
|
106
|
+
) : null}
|
|
107
|
+
</XStack>
|
|
108
|
+
</Card>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function MetricsSkeleton() {
|
|
113
|
+
return (
|
|
114
|
+
<XStack flexWrap="wrap" gap="$3">
|
|
115
|
+
{[0, 1, 2, 3].map((i) => (
|
|
116
|
+
<Card key={i} p="$3.5" gap="$2.5" borderWidth={1} borderColor="$borderColor" bg="$color2" flex={1} minW={168}>
|
|
117
|
+
<div className="skeleton" style={{ height: 10, width: '55%', borderRadius: 4 }} />
|
|
118
|
+
<div className="skeleton" style={{ height: 26, width: '72%', borderRadius: 6 }} />
|
|
119
|
+
<div className="skeleton" style={{ height: 10, width: '40%', borderRadius: 4 }} />
|
|
120
|
+
</Card>
|
|
121
|
+
))}
|
|
122
|
+
</XStack>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function MetricsNote({ text }: { text: string }) {
|
|
127
|
+
return (
|
|
128
|
+
<Card p="$3.5" borderWidth={1} borderColor="$borderColor" bg="$color2">
|
|
129
|
+
<Text fontSize="$2" color="$color10">
|
|
130
|
+
{text}
|
|
131
|
+
</Text>
|
|
132
|
+
</Card>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rail — the right rail of a product landing (Documentation, Quick Start, Examples,
|
|
5
|
+
* Code samples, API Reference, Quick Actions, Support). Every row is a REAL link
|
|
6
|
+
* (docs derived from `config.docsUrl`) or an in-console action — no dead rows.
|
|
7
|
+
* "Code samples" scrolls to the interactive block; Community/Support are opt-in
|
|
8
|
+
* (`config.discordUrl` / `config.supportEmail`), never a hardcoded brand link.
|
|
9
|
+
*/
|
|
10
|
+
import { YStack } from '@hanzo/gui'
|
|
11
|
+
import { BookOpen, Boxes, Code2, LifeBuoy, MessageSquare, Rocket, Zap } from '@hanzogui/lucide-icons-2'
|
|
12
|
+
|
|
13
|
+
import { ActionRow, LandingCard, openExternal } from './parts'
|
|
14
|
+
import { apexFromDocs, standardResources } from './logic'
|
|
15
|
+
import type { LandingAction, LandingConfig } from './types'
|
|
16
|
+
|
|
17
|
+
const runAction = (a: LandingAction) => (a.href ? openExternal(a.href) : a.onPress?.())
|
|
18
|
+
|
|
19
|
+
export function Rail({ config, onViewCode }: { config: LandingConfig; onViewCode?: () => void }) {
|
|
20
|
+
const docs = config.docsUrl
|
|
21
|
+
const std = standardResources(docs, config.docsProduct)
|
|
22
|
+
const apex = apexFromDocs(docs)
|
|
23
|
+
const supportAddr = config.supportEmail ?? `support@${apex}`
|
|
24
|
+
const support = `mailto:${supportAddr}`
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<YStack gap="$4" flex={1} minW={280}>
|
|
28
|
+
<LandingCard title="Resources" p="$3">
|
|
29
|
+
<YStack gap="$0.5">
|
|
30
|
+
<ActionRow icon={<BookOpen size={15} />} label="Documentation" sub="Guides & concepts" onPress={() => openExternal(std.docs)} />
|
|
31
|
+
<ActionRow icon={<Rocket size={15} />} label="Quick Start" sub="Ship in minutes" onPress={() => openExternal(std.quickstart)} />
|
|
32
|
+
<ActionRow icon={<Boxes size={15} />} label="Examples" sub="Sample projects" onPress={() => openExternal(std.examples)} />
|
|
33
|
+
{onViewCode ? <ActionRow icon={<Code2 size={15} />} label="Code samples" sub="Copy a real API call" onPress={onViewCode} /> : null}
|
|
34
|
+
<ActionRow icon={<Zap size={15} />} label="API Reference" sub={`api.${apex}`} onPress={() => openExternal(std.api)} />
|
|
35
|
+
{config.resources?.map((r) => (
|
|
36
|
+
<ActionRow key={r.id} icon={r.icon} label={r.label} sub={r.sub} onPress={() => (r.href ? openExternal(r.href) : r.onPress?.())} />
|
|
37
|
+
))}
|
|
38
|
+
</YStack>
|
|
39
|
+
</LandingCard>
|
|
40
|
+
|
|
41
|
+
{config.actions?.length ? (
|
|
42
|
+
<LandingCard title="Quick Actions" p="$3">
|
|
43
|
+
<YStack gap="$0.5">
|
|
44
|
+
{config.actions.map((a) => (
|
|
45
|
+
<ActionRow key={a.label} icon={a.icon} label={a.label} onPress={() => runAction(a)} />
|
|
46
|
+
))}
|
|
47
|
+
</YStack>
|
|
48
|
+
</LandingCard>
|
|
49
|
+
) : null}
|
|
50
|
+
|
|
51
|
+
<LandingCard title="Need help?" p="$3">
|
|
52
|
+
<YStack gap="$0.5">
|
|
53
|
+
{config.discordUrl ? (
|
|
54
|
+
<ActionRow icon={<MessageSquare size={15} />} label="Community" sub="Join the chat" onPress={() => openExternal(config.discordUrl as string)} />
|
|
55
|
+
) : null}
|
|
56
|
+
<ActionRow icon={<LifeBuoy size={15} />} label="Contact Support" sub={supportAddr} onPress={() => openExternal(support)} />
|
|
57
|
+
</YStack>
|
|
58
|
+
</LandingCard>
|
|
59
|
+
</YStack>
|
|
60
|
+
)
|
|
61
|
+
}
|