index.tsx284 lines · main
1'use client'
2
3import dayjs from 'dayjs'
4import { ChevronRight, HelpCircle } from 'lucide-react'
5import Link from 'next/link'
6import * as React from 'react'
7import { useContext } from 'react'
8import {
9 Area,
10 AreaChart,
11 Tooltip as RechartsTooltip,
12 TooltipProps as RechartsTooltipProps,
13 ResponsiveContainer,
14} from 'recharts'
15import {
16 Button,
17 Card,
18 CardContent,
19 CardTitle,
20 cn,
21 Skeleton,
22 Tooltip,
23 TooltipContent,
24 TooltipTrigger,
25} from 'ui'
26
27interface MetricCardContextValue {
28 isLoading?: boolean
29 isDisabled?: boolean
30}
31
32const MetricCardContext = React.createContext<MetricCardContextValue>({
33 isLoading: false,
34 isDisabled: false,
35})
36
37const useMetricCard = () => {
38 return useContext(MetricCardContext)
39}
40
41interface MetricCardProps extends React.HTMLAttributes<HTMLDivElement> {
42 isLoading?: boolean
43 isDisabled?: boolean
44}
45
46const MetricCard = React.forwardRef<HTMLDivElement, MetricCardProps>(
47 ({ isLoading = false, isDisabled = false, className, children, ...props }, ref) => {
48 return (
49 <Card
50 ref={ref}
51 className={cn('group-hover:bg-surface-200 transition-colors', className)}
52 {...props}
53 >
54 <MetricCardContext.Provider value={{ isLoading, isDisabled }}>
55 {children}
56 </MetricCardContext.Provider>
57 </Card>
58 )
59 }
60)
61MetricCard.displayName = 'MetricCard'
62
63interface MetricCardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
64 href?: string
65 children: React.ReactNode
66 linkTooltip?: string
67}
68
69const MetricCardHeader = React.forwardRef<HTMLDivElement, MetricCardHeaderProps>(
70 ({ className, href, children, linkTooltip, ...props }, ref) => {
71 return (
72 <div
73 ref={ref}
74 className={cn(
75 'p-card flex flex-row items-center justify-between gap-2 space-y-0 pb-0 border-b-0 relative',
76 className
77 )}
78 {...props}
79 >
80 <div className="flex flex-row items-center gap-2">{children}</div>
81 {href ? (
82 <Tooltip>
83 <TooltipTrigger asChild>
84 <Button
85 type="text"
86 size="tiny"
87 className="px-1 text-foreground-lighter group-hover:text-foreground absolute right-3 transition-colors"
88 asChild
89 >
90 <Link href={href}>
91 <ChevronRight aria-disabled={true} size={14} strokeWidth={1.5} />
92 <span className="sr-only">More information</span>
93 </Link>
94 </Button>
95 </TooltipTrigger>
96 {linkTooltip ? <TooltipContent>{linkTooltip}</TooltipContent> : null}
97 </Tooltip>
98 ) : null}
99 </div>
100 )
101 }
102)
103MetricCardHeader.displayName = 'MetricCardHeader'
104
105interface MetricCardContentProps extends React.HTMLAttributes<HTMLDivElement> {
106 orientation?: 'horizontal' | 'vertical'
107}
108
109const MetricCardContent = React.forwardRef<HTMLDivElement, MetricCardContentProps>(
110 ({ className, orientation = 'vertical', ...props }, ref) => (
111 <CardContent
112 ref={ref}
113 className={cn(
114 'p-card pt-0 flex-1 flex h-full items-start gap-1 overflow-hidden border-b-0',
115 orientation === 'horizontal' ? 'flex-row' : 'flex-col ',
116 className
117 )}
118 {...props}
119 />
120 )
121)
122MetricCardContent.displayName = 'MetricCardContent'
123
124const MetricCardIcon = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
125 ({ className, ...props }, ref) => (
126 <div ref={ref} className={cn('text-foreground-light', className)} {...props} />
127 )
128)
129MetricCardIcon.displayName = 'MetricCardIcon'
130
131interface MetricCardLabelProps extends React.HTMLAttributes<HTMLDivElement> {
132 tooltip?: string
133 children: React.ReactNode
134}
135
136const MetricCardLabel = React.forwardRef<HTMLDivElement, MetricCardLabelProps>(
137 ({ className, tooltip, children, ...props }, ref) => {
138 return (
139 <CardTitle
140 ref={ref}
141 className={cn('flex items-center gap-2 text-foreground-light', className)}
142 {...props}
143 >
144 <span>{children}</span>
145 {tooltip && (
146 <Tooltip>
147 <TooltipTrigger asChild>
148 <HelpCircle size={14} strokeWidth={1.5} />
149 </TooltipTrigger>
150 <TooltipContent className="max-w-xs">{tooltip}</TooltipContent>
151 </Tooltip>
152 )}
153 </CardTitle>
154 )
155 }
156)
157MetricCardLabel.displayName = 'MetricCardLabel'
158
159const MetricCardValue = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
160 ({ className, ...props }, ref) => {
161 const { isLoading } = useMetricCard()
162
163 if (isLoading) {
164 return <Skeleton className="w-32 h-7" />
165 }
166
167 return (
168 <span ref={ref} className={cn('font-normal text-xl tabular-nums', className)} {...props} />
169 )
170 }
171)
172
173MetricCardValue.displayName = 'MetricCardValue'
174
175interface MetricCardDifferentialProps extends React.HTMLAttributes<HTMLDivElement> {
176 variant?: 'positive' | 'negative' | 'default'
177}
178
179const MetricCardDifferential = React.forwardRef<HTMLDivElement, MetricCardDifferentialProps>(
180 ({ className, variant = 'default', ...props }, ref) => {
181 const { isLoading } = useMetricCard()
182
183 if (isLoading) {
184 return <Skeleton className="w-16 h-5" />
185 }
186
187 return (
188 <span
189 ref={ref}
190 className={cn(
191 variant === 'positive'
192 ? 'text-brand'
193 : variant === 'negative'
194 ? 'text-destructive'
195 : 'text-foreground-light',
196 'tabular-nums text-sm',
197 className
198 )}
199 {...props}
200 />
201 )
202 }
203)
204
205MetricCardDifferential.displayName = 'MetricCardDifferential'
206
207const SparklineTooltip = ({ active, payload, label }: RechartsTooltipProps<any, any>) => {
208 if (!active || !payload || !payload.length) return null
209
210 const formatTimestamp = (timestamp: string) => {
211 const date = dayjs(timestamp)
212 const hour = date.hour()
213 const period = hour >= 12 ? 'pm' : 'am'
214 const displayHour = hour % 12 || 12
215
216 return `${date.format('MMM D')}, ${displayHour}${period}`
217 }
218
219 return (
220 <div className="bg-black/90 text-white p-2 rounded-sm text-xs">
221 {label && (
222 <div className="dark:text-foreground-light text-white/60">
223 {formatTimestamp(payload[0].payload.timestamp)}
224 </div>
225 )}
226 <div>{payload[0].value.toLocaleString(undefined, { maximumFractionDigits: 0 })}</div>
227 </div>
228 )
229}
230interface MetricCardSparklineProps extends React.HTMLAttributes<HTMLDivElement> {
231 data?: Array<{ value: number; [key: string]: any }>
232 dataKey?: string
233 className?: string
234}
235
236const MetricCardSparkline = React.forwardRef<HTMLDivElement, MetricCardSparklineProps>(
237 ({ className, data, dataKey, ...props }, ref) => {
238 const { isLoading } = useMetricCard()
239 if (isLoading) {
240 return <Skeleton className="w-full h-[56px] rounded-none" />
241 }
242
243 if (!data || data.length === 0) {
244 return null
245 }
246
247 return (
248 <div ref={ref} className={cn('w-full h-16 relative', className)} {...props}>
249 <ResponsiveContainer width="100%" height="100%" className="relative">
250 <AreaChart data={data} margin={{ top: 5, left: 0, right: 0, bottom: 0 }}>
251 <defs>
252 <linearGradient id="sparklineGradient" x1="0" y1="0" x2="0" y2="1">
253 <stop offset="5%" stopColor="hsl(var(--brand-default))" stopOpacity={0.8} />
254 <stop offset="95%" stopColor="hsl(var(--brand-default))" stopOpacity={0} />
255 </linearGradient>
256 </defs>
257 <RechartsTooltip content={<SparklineTooltip />} />
258 <Area
259 type="step"
260 dataKey={dataKey || 'value'}
261 fill="url(#sparklineGradient)"
262 fillOpacity={0.1}
263 stroke="hsl(var(--brand-default))"
264 strokeWidth={1.5}
265 />
266 </AreaChart>
267 </ResponsiveContainer>
268 </div>
269 )
270 }
271)
272MetricCardSparkline.displayName = 'MetricCardSparkline'
273
274export {
275 MetricCard,
276 MetricCardHeader,
277 MetricCardIcon,
278 MetricCardLabel,
279 MetricCardContent,
280 MetricCardValue,
281 MetricCardDifferential,
282 MetricCardSparkline,
283 useMetricCard,
284}