stats.tsx 3.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. import { useRef, useEffect, useCallback } from 'react'
  2. import { useTranslation } from 'react-i18next'
  3. interface CounterProps {
  4. end: number
  5. suffix?: string
  6. prefix?: string
  7. duration?: number
  8. decimals?: number
  9. }
  10. function Counter(props: CounterProps) {
  11. const { end, suffix = '', prefix = '', duration = 1600, decimals = 0 } = props
  12. const ref = useRef<HTMLSpanElement>(null)
  13. const startedRef = useRef(false)
  14. const formatValue = useCallback(
  15. (v: number) =>
  16. decimals > 0 ? v.toFixed(decimals) : Math.round(v).toLocaleString(),
  17. [decimals]
  18. )
  19. const animate = useCallback(() => {
  20. const el = ref.current
  21. if (!el) return
  22. const start = performance.now()
  23. const step = (now: number) => {
  24. const progress = Math.min((now - start) / duration, 1)
  25. const eased = 1 - Math.pow(1 - progress, 3)
  26. el.textContent = `${prefix}${formatValue(eased * end)}${suffix}`
  27. if (progress < 1) requestAnimationFrame(step)
  28. }
  29. requestAnimationFrame(step)
  30. }, [end, duration, prefix, suffix, formatValue])
  31. useEffect(() => {
  32. const el = ref.current
  33. if (!el) return
  34. const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
  35. if (mq.matches) {
  36. el.textContent = `${prefix}${formatValue(end)}${suffix}`
  37. return
  38. }
  39. const observer = new IntersectionObserver(
  40. ([entry]) => {
  41. if (entry.isIntersecting && !startedRef.current) {
  42. startedRef.current = true
  43. animate()
  44. observer.unobserve(el)
  45. }
  46. },
  47. { threshold: 0.5 }
  48. )
  49. observer.observe(el)
  50. return () => observer.disconnect()
  51. }, [animate, end, prefix, suffix, formatValue])
  52. return (
  53. <span ref={ref} className='tabular-nums'>
  54. {prefix}0{suffix}
  55. </span>
  56. )
  57. }
  58. interface StatsProps {
  59. className?: string
  60. }
  61. interface StatItem {
  62. end: number
  63. suffix: string
  64. label: string
  65. decimals?: number
  66. }
  67. export function Stats(_props: StatsProps) {
  68. const { t } = useTranslation()
  69. const stats: StatItem[] = [
  70. { end: 50, suffix: '+', label: t('upstream services integrated') },
  71. { end: 100, suffix: '+', label: t('model billing support') },
  72. { end: 50, suffix: '+', label: t('compatible API routes') },
  73. { end: 10, suffix: '+', label: t('scheduling controls') },
  74. ]
  75. return (
  76. <div className='border-border/40 bg-muted/10 relative z-10 border-y'>
  77. <div className='mx-auto max-w-6xl px-6 py-10 md:py-12'>
  78. <div className='grid grid-cols-2 gap-8 md:grid-cols-4 md:gap-12'>
  79. {stats.map((s) => (
  80. <div
  81. key={s.label}
  82. className='flex flex-col items-center text-center'
  83. >
  84. <span className='text-2xl font-bold tracking-tight md:text-3xl'>
  85. <Counter end={s.end} suffix={s.suffix} decimals={s.decimals} />
  86. </span>
  87. <span className='text-muted-foreground mt-1.5 text-xs'>
  88. {s.label}
  89. </span>
  90. </div>
  91. ))}
  92. </div>
  93. </div>
  94. </div>
  95. )
  96. }