@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.
@@ -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
+ }