@m13v/seo-components 0.1.0
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/package.json +52 -0
- package/src/components/AnimatedBeam.tsx +273 -0
- package/src/components/AnimatedChecklist.tsx +68 -0
- package/src/components/AnimatedCodeBlock.tsx +68 -0
- package/src/components/AnimatedDemo.tsx +186 -0
- package/src/components/AnimatedMetric.tsx +54 -0
- package/src/components/AnimatedSection.tsx +31 -0
- package/src/components/ArticleMeta.tsx +51 -0
- package/src/components/BackgroundGrid.tsx +59 -0
- package/src/components/BeforeAfter.tsx +121 -0
- package/src/components/BentoGrid.tsx +67 -0
- package/src/components/Breadcrumbs.tsx +40 -0
- package/src/components/CodeComparison.tsx +115 -0
- package/src/components/ComparisonTable.tsx +66 -0
- package/src/components/FaqSection.tsx +40 -0
- package/src/components/FlowDiagram.tsx +86 -0
- package/src/components/GlowCard.tsx +58 -0
- package/src/components/GradientText.tsx +43 -0
- package/src/components/InlineCta.tsx +50 -0
- package/src/components/InlineTestimonial.tsx +53 -0
- package/src/components/LottiePlayer.tsx +63 -0
- package/src/components/Marquee.tsx +67 -0
- package/src/components/MetricsRow.tsx +32 -0
- package/src/components/MorphingText.tsx +133 -0
- package/src/components/MotionSequence.tsx +150 -0
- package/src/components/NumberTicker.tsx +68 -0
- package/src/components/OrbitingCircles.tsx +83 -0
- package/src/components/ParallaxSection.tsx +56 -0
- package/src/components/Particles.tsx +268 -0
- package/src/components/ProofBand.tsx +66 -0
- package/src/components/ProofBanner.tsx +31 -0
- package/src/components/RemotionClip.tsx +216 -0
- package/src/components/SequenceDiagram.tsx +144 -0
- package/src/components/ShimmerButton.tsx +51 -0
- package/src/components/ShineBorder.tsx +87 -0
- package/src/components/StepTimeline.tsx +108 -0
- package/src/components/StickyBottomCta.tsx +53 -0
- package/src/components/TerminalOutput.tsx +93 -0
- package/src/components/TextShimmer.tsx +59 -0
- package/src/components/TypingAnimation.tsx +54 -0
- package/src/index.ts +69 -0
- package/src/lib/json-ld.ts +116 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion, useInView, useMotionValue, useSpring } from "framer-motion";
|
|
4
|
+
import { useEffect, useRef } from "react";
|
|
5
|
+
|
|
6
|
+
interface NumberTickerProps {
|
|
7
|
+
value: number;
|
|
8
|
+
/** Number of decimal places */
|
|
9
|
+
decimals?: number;
|
|
10
|
+
/** Duration of the count-up animation in seconds */
|
|
11
|
+
duration?: number;
|
|
12
|
+
/** Optional prefix (e.g. "$") */
|
|
13
|
+
prefix?: string;
|
|
14
|
+
/** Optional suffix (e.g. "%") */
|
|
15
|
+
suffix?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Magic UI style number ticker. Counts up from 0 to the target
|
|
21
|
+
* value with spring physics when scrolled into view. Smoother
|
|
22
|
+
* than a plain `useEffect` interval because it uses framer-motion
|
|
23
|
+
* motion values.
|
|
24
|
+
*/
|
|
25
|
+
export function NumberTicker({
|
|
26
|
+
value,
|
|
27
|
+
decimals = 0,
|
|
28
|
+
duration = 1.6,
|
|
29
|
+
prefix = "",
|
|
30
|
+
suffix = "",
|
|
31
|
+
className = "",
|
|
32
|
+
}: NumberTickerProps) {
|
|
33
|
+
const ref = useRef<HTMLSpanElement>(null);
|
|
34
|
+
const inView = useInView(ref, { once: true, margin: "-40px" });
|
|
35
|
+
const motionValue = useMotionValue(0);
|
|
36
|
+
const springValue = useSpring(motionValue, {
|
|
37
|
+
duration: duration * 1000,
|
|
38
|
+
bounce: 0,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (inView) motionValue.set(value);
|
|
43
|
+
}, [inView, value, motionValue]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const unsub = springValue.on("change", (latest) => {
|
|
47
|
+
if (ref.current) {
|
|
48
|
+
ref.current.textContent =
|
|
49
|
+
prefix +
|
|
50
|
+
latest.toLocaleString("en-US", {
|
|
51
|
+
minimumFractionDigits: decimals,
|
|
52
|
+
maximumFractionDigits: decimals,
|
|
53
|
+
}) +
|
|
54
|
+
suffix;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return () => unsub();
|
|
58
|
+
}, [springValue, decimals, prefix, suffix]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<motion.span
|
|
62
|
+
ref={ref}
|
|
63
|
+
className={`tabular-nums font-bold text-zinc-900 ${className}`}
|
|
64
|
+
>
|
|
65
|
+
{prefix}0{suffix}
|
|
66
|
+
</motion.span>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion } from "framer-motion";
|
|
4
|
+
|
|
5
|
+
interface OrbitingCirclesProps {
|
|
6
|
+
/** Center element (logo, icon, label) */
|
|
7
|
+
center: React.ReactNode;
|
|
8
|
+
/** Items orbiting around the center */
|
|
9
|
+
items: React.ReactNode[];
|
|
10
|
+
/** Radius in pixels */
|
|
11
|
+
radius?: number;
|
|
12
|
+
/** Duration of one full revolution in seconds */
|
|
13
|
+
duration?: number;
|
|
14
|
+
/** Reverse spin direction */
|
|
15
|
+
reverse?: boolean;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Magic UI style orbiting element. A central element with smaller
|
|
21
|
+
* items circling around it. Useful for "ecosystem" or "integrations"
|
|
22
|
+
* showcases where the center is the product and orbits are
|
|
23
|
+
* connected tools.
|
|
24
|
+
*/
|
|
25
|
+
export function OrbitingCircles({
|
|
26
|
+
center,
|
|
27
|
+
items,
|
|
28
|
+
radius = 140,
|
|
29
|
+
duration = 20,
|
|
30
|
+
reverse = false,
|
|
31
|
+
className = "",
|
|
32
|
+
}: OrbitingCirclesProps) {
|
|
33
|
+
const size = radius * 2 + 80;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
className={`my-10 relative flex items-center justify-center ${className}`}
|
|
38
|
+
style={{ width: size, height: size, maxWidth: "100%", margin: "2.5rem auto" }}
|
|
39
|
+
>
|
|
40
|
+
{/* Orbit ring */}
|
|
41
|
+
<div
|
|
42
|
+
className="absolute rounded-full border border-dashed border-zinc-200"
|
|
43
|
+
style={{ width: radius * 2, height: radius * 2 }}
|
|
44
|
+
/>
|
|
45
|
+
|
|
46
|
+
{/* Center */}
|
|
47
|
+
<div className="relative z-10 flex items-center justify-center rounded-2xl bg-gradient-to-br from-cyan-500 to-teal-500 text-white font-semibold shadow-lg shadow-teal-500/30"
|
|
48
|
+
style={{ width: 96, height: 96 }}
|
|
49
|
+
>
|
|
50
|
+
{center}
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
{/* Orbiting items */}
|
|
54
|
+
<motion.div
|
|
55
|
+
className="absolute inset-0"
|
|
56
|
+
animate={{ rotate: reverse ? -360 : 360 }}
|
|
57
|
+
transition={{ duration, ease: "linear", repeat: Infinity }}
|
|
58
|
+
>
|
|
59
|
+
{items.map((item, i) => {
|
|
60
|
+
const angle = (i * 360) / items.length;
|
|
61
|
+
const rad = (angle * Math.PI) / 180;
|
|
62
|
+
const x = Math.cos(rad) * radius;
|
|
63
|
+
const y = Math.sin(rad) * radius;
|
|
64
|
+
return (
|
|
65
|
+
<motion.div
|
|
66
|
+
key={i}
|
|
67
|
+
className="absolute top-1/2 left-1/2 flex items-center justify-center rounded-xl bg-white border border-zinc-200 shadow-sm text-xs font-medium text-zinc-700"
|
|
68
|
+
style={{
|
|
69
|
+
width: 64,
|
|
70
|
+
height: 64,
|
|
71
|
+
transform: `translate(${x - 32}px, ${y - 32}px)`,
|
|
72
|
+
}}
|
|
73
|
+
animate={{ rotate: reverse ? 360 : -360 }}
|
|
74
|
+
transition={{ duration, ease: "linear", repeat: Infinity }}
|
|
75
|
+
>
|
|
76
|
+
{item}
|
|
77
|
+
</motion.div>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
</motion.div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef } from "react";
|
|
4
|
+
import { motion, useScroll, useTransform } from "framer-motion";
|
|
5
|
+
|
|
6
|
+
interface ParallaxSectionProps {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
/** Background element that moves at a different scroll speed */
|
|
9
|
+
background?: React.ReactNode;
|
|
10
|
+
/** Parallax intensity: 0 = none, 1 = max. Default 0.3 */
|
|
11
|
+
intensity?: number;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Section with a parallax scrolling effect on the background.
|
|
17
|
+
* The foreground content scrolls normally while the background
|
|
18
|
+
* moves at a slower rate, creating depth.
|
|
19
|
+
*/
|
|
20
|
+
export function ParallaxSection({
|
|
21
|
+
children,
|
|
22
|
+
background,
|
|
23
|
+
intensity = 0.3,
|
|
24
|
+
className = "",
|
|
25
|
+
}: ParallaxSectionProps) {
|
|
26
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
27
|
+
const { scrollYProgress } = useScroll({
|
|
28
|
+
target: ref,
|
|
29
|
+
offset: ["start end", "end start"],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const y = useTransform(scrollYProgress, [0, 1], [
|
|
33
|
+
-80 * intensity,
|
|
34
|
+
80 * intensity,
|
|
35
|
+
]);
|
|
36
|
+
const opacity = useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0, 1, 1, 0]);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div ref={ref} className={`relative overflow-hidden my-10 ${className}`}>
|
|
40
|
+
{/* Parallax background */}
|
|
41
|
+
{background && (
|
|
42
|
+
<motion.div
|
|
43
|
+
style={{ y }}
|
|
44
|
+
className="absolute inset-0 pointer-events-none"
|
|
45
|
+
>
|
|
46
|
+
{background}
|
|
47
|
+
</motion.div>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
{/* Foreground content */}
|
|
51
|
+
<motion.div style={{ opacity }} className="relative z-10">
|
|
52
|
+
{children}
|
|
53
|
+
</motion.div>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Particles: renders an interactive canvas of floating particles that
|
|
7
|
+
* subtly follow the mouse cursor. Great for ambient hero backgrounds.
|
|
8
|
+
* Default color is teal (#14b8a6). Fully self-contained, canvas-based.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* <Particles className="absolute inset-0" quantity={80} />
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface MousePosition {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function useMousePosition(): MousePosition {
|
|
20
|
+
const [mousePosition, setMousePosition] = useState<MousePosition>({
|
|
21
|
+
x: 0,
|
|
22
|
+
y: 0,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const handleMouseMove = (event: MouseEvent) => {
|
|
27
|
+
setMousePosition({ x: event.clientX, y: event.clientY });
|
|
28
|
+
};
|
|
29
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
30
|
+
return () => {
|
|
31
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
32
|
+
};
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
return mousePosition;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hexToRgb(hex: string): number[] {
|
|
39
|
+
hex = hex.replace("#", "");
|
|
40
|
+
const hexInt = parseInt(hex, 16);
|
|
41
|
+
const red = (hexInt >> 16) & 255;
|
|
42
|
+
const green = (hexInt >> 8) & 255;
|
|
43
|
+
const blue = hexInt & 255;
|
|
44
|
+
return [red, green, blue];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ParticlesProps {
|
|
48
|
+
className?: string;
|
|
49
|
+
quantity?: number;
|
|
50
|
+
staticity?: number;
|
|
51
|
+
ease?: number;
|
|
52
|
+
size?: number;
|
|
53
|
+
refresh?: boolean;
|
|
54
|
+
/** Hex color for particles. Defaults to teal (#14b8a6). */
|
|
55
|
+
color?: string;
|
|
56
|
+
vx?: number;
|
|
57
|
+
vy?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Circle = {
|
|
61
|
+
x: number;
|
|
62
|
+
y: number;
|
|
63
|
+
translateX: number;
|
|
64
|
+
translateY: number;
|
|
65
|
+
size: number;
|
|
66
|
+
alpha: number;
|
|
67
|
+
targetAlpha: number;
|
|
68
|
+
dx: number;
|
|
69
|
+
dy: number;
|
|
70
|
+
magnetism: number;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function Particles({
|
|
74
|
+
className = "",
|
|
75
|
+
quantity = 100,
|
|
76
|
+
staticity = 50,
|
|
77
|
+
ease = 50,
|
|
78
|
+
size = 0.4,
|
|
79
|
+
refresh = false,
|
|
80
|
+
color = "#14b8a6",
|
|
81
|
+
vx = 0,
|
|
82
|
+
vy = 0,
|
|
83
|
+
}: ParticlesProps) {
|
|
84
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
85
|
+
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
|
86
|
+
const context = useRef<CanvasRenderingContext2D | null>(null);
|
|
87
|
+
const circles = useRef<Circle[]>([]);
|
|
88
|
+
const mousePosition = useMousePosition();
|
|
89
|
+
const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
90
|
+
const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
|
|
91
|
+
const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1;
|
|
92
|
+
|
|
93
|
+
const rgb = hexToRgb(color);
|
|
94
|
+
|
|
95
|
+
const circleParams = (): Circle => {
|
|
96
|
+
const x = Math.floor(Math.random() * canvasSize.current.w);
|
|
97
|
+
const y = Math.floor(Math.random() * canvasSize.current.h);
|
|
98
|
+
const pSize = Math.floor(Math.random() * 2) + size;
|
|
99
|
+
const alpha = 0;
|
|
100
|
+
const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1));
|
|
101
|
+
const dx = (Math.random() - 0.5) * 0.1;
|
|
102
|
+
const dy = (Math.random() - 0.5) * 0.1;
|
|
103
|
+
const magnetism = 0.1 + Math.random() * 4;
|
|
104
|
+
return {
|
|
105
|
+
x,
|
|
106
|
+
y,
|
|
107
|
+
translateX: 0,
|
|
108
|
+
translateY: 0,
|
|
109
|
+
size: pSize,
|
|
110
|
+
alpha,
|
|
111
|
+
targetAlpha,
|
|
112
|
+
dx,
|
|
113
|
+
dy,
|
|
114
|
+
magnetism,
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const drawCircle = (circle: Circle, update = false) => {
|
|
119
|
+
if (context.current) {
|
|
120
|
+
const { x, y, translateX, translateY, size: s, alpha } = circle;
|
|
121
|
+
context.current.translate(translateX, translateY);
|
|
122
|
+
context.current.beginPath();
|
|
123
|
+
context.current.arc(x, y, s, 0, 2 * Math.PI);
|
|
124
|
+
context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`;
|
|
125
|
+
context.current.fill();
|
|
126
|
+
context.current.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
127
|
+
if (!update) {
|
|
128
|
+
circles.current.push(circle);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const clearContext = () => {
|
|
134
|
+
if (context.current) {
|
|
135
|
+
context.current.clearRect(
|
|
136
|
+
0,
|
|
137
|
+
0,
|
|
138
|
+
canvasSize.current.w,
|
|
139
|
+
canvasSize.current.h
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const resizeCanvas = () => {
|
|
145
|
+
if (canvasContainerRef.current && canvasRef.current && context.current) {
|
|
146
|
+
circles.current.length = 0;
|
|
147
|
+
canvasSize.current.w = canvasContainerRef.current.offsetWidth;
|
|
148
|
+
canvasSize.current.h = canvasContainerRef.current.offsetHeight;
|
|
149
|
+
canvasRef.current.width = canvasSize.current.w * dpr;
|
|
150
|
+
canvasRef.current.height = canvasSize.current.h * dpr;
|
|
151
|
+
canvasRef.current.style.width = `${canvasSize.current.w}px`;
|
|
152
|
+
canvasRef.current.style.height = `${canvasSize.current.h}px`;
|
|
153
|
+
context.current.scale(dpr, dpr);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const drawParticles = () => {
|
|
158
|
+
clearContext();
|
|
159
|
+
for (let i = 0; i < quantity; i++) {
|
|
160
|
+
const circle = circleParams();
|
|
161
|
+
drawCircle(circle);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const remapValue = (
|
|
166
|
+
value: number,
|
|
167
|
+
start1: number,
|
|
168
|
+
end1: number,
|
|
169
|
+
start2: number,
|
|
170
|
+
end2: number
|
|
171
|
+
): number => {
|
|
172
|
+
const remapped =
|
|
173
|
+
((value - start1) * (end2 - start2)) / (end1 - start1) + start2;
|
|
174
|
+
return remapped > 0 ? remapped : 0;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const initCanvas = () => {
|
|
178
|
+
resizeCanvas();
|
|
179
|
+
drawParticles();
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const onMouseMove = () => {
|
|
183
|
+
if (canvasRef.current) {
|
|
184
|
+
const rect = canvasRef.current.getBoundingClientRect();
|
|
185
|
+
const { w, h } = canvasSize.current;
|
|
186
|
+
const x = mousePosition.x - rect.left - w / 2;
|
|
187
|
+
const y = mousePosition.y - rect.top - h / 2;
|
|
188
|
+
const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2;
|
|
189
|
+
if (inside) {
|
|
190
|
+
mouse.current.x = x;
|
|
191
|
+
mouse.current.y = y;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const animate = () => {
|
|
197
|
+
clearContext();
|
|
198
|
+
circles.current.forEach((circle: Circle, i: number) => {
|
|
199
|
+
const edge = [
|
|
200
|
+
circle.x + circle.translateX - circle.size,
|
|
201
|
+
canvasSize.current.w - circle.x - circle.translateX - circle.size,
|
|
202
|
+
circle.y + circle.translateY - circle.size,
|
|
203
|
+
canvasSize.current.h - circle.y - circle.translateY - circle.size,
|
|
204
|
+
];
|
|
205
|
+
const closestEdge = edge.reduce((a, b) => Math.min(a, b));
|
|
206
|
+
const remapClosestEdge = parseFloat(
|
|
207
|
+
remapValue(closestEdge, 0, 20, 0, 1).toFixed(2)
|
|
208
|
+
);
|
|
209
|
+
if (remapClosestEdge > 1) {
|
|
210
|
+
circle.alpha += 0.02;
|
|
211
|
+
if (circle.alpha > circle.targetAlpha) {
|
|
212
|
+
circle.alpha = circle.targetAlpha;
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
circle.alpha = circle.targetAlpha * remapClosestEdge;
|
|
216
|
+
}
|
|
217
|
+
circle.x += circle.dx + vx;
|
|
218
|
+
circle.y += circle.dy + vy;
|
|
219
|
+
circle.translateX +=
|
|
220
|
+
(mouse.current.x / (staticity / circle.magnetism) -
|
|
221
|
+
circle.translateX) /
|
|
222
|
+
ease;
|
|
223
|
+
circle.translateY +=
|
|
224
|
+
(mouse.current.y / (staticity / circle.magnetism) -
|
|
225
|
+
circle.translateY) /
|
|
226
|
+
ease;
|
|
227
|
+
|
|
228
|
+
drawCircle(circle, true);
|
|
229
|
+
|
|
230
|
+
if (
|
|
231
|
+
circle.x < -circle.size ||
|
|
232
|
+
circle.x > canvasSize.current.w + circle.size ||
|
|
233
|
+
circle.y < -circle.size ||
|
|
234
|
+
circle.y > canvasSize.current.h + circle.size
|
|
235
|
+
) {
|
|
236
|
+
circles.current.splice(i, 1);
|
|
237
|
+
const newCircle = circleParams();
|
|
238
|
+
drawCircle(newCircle);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
window.requestAnimationFrame(animate);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (canvasRef.current) {
|
|
246
|
+
context.current = canvasRef.current.getContext("2d");
|
|
247
|
+
}
|
|
248
|
+
initCanvas();
|
|
249
|
+
animate();
|
|
250
|
+
window.addEventListener("resize", initCanvas);
|
|
251
|
+
return () => {
|
|
252
|
+
window.removeEventListener("resize", initCanvas);
|
|
253
|
+
};
|
|
254
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
255
|
+
}, [color]);
|
|
256
|
+
|
|
257
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
258
|
+
useEffect(() => { onMouseMove(); }, [mousePosition.x, mousePosition.y]);
|
|
259
|
+
|
|
260
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
261
|
+
useEffect(() => { initCanvas(); }, [refresh]);
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div className={className} ref={canvasContainerRef} aria-hidden="true">
|
|
265
|
+
<canvas ref={canvasRef} className="h-full w-full" />
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
interface ProofBandProps {
|
|
2
|
+
rating: number;
|
|
3
|
+
ratingCount: string;
|
|
4
|
+
highlights?: string[];
|
|
5
|
+
className?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ProofBand({
|
|
9
|
+
rating,
|
|
10
|
+
ratingCount,
|
|
11
|
+
highlights = [],
|
|
12
|
+
className = "",
|
|
13
|
+
}: ProofBandProps) {
|
|
14
|
+
const fullStars = Math.floor(rating);
|
|
15
|
+
const hasHalf = rating - fullStars >= 0.3;
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className={`text-sm text-zinc-500 mb-8 ${className}`}>
|
|
19
|
+
{/* Stars + rating on its own line */}
|
|
20
|
+
<div className="flex items-center gap-2 mb-2">
|
|
21
|
+
<div className="flex items-center gap-0.5" aria-label={`${rating} out of 5`}>
|
|
22
|
+
{Array.from({ length: 5 }, (_, i) => (
|
|
23
|
+
<svg
|
|
24
|
+
key={i}
|
|
25
|
+
className={`w-4 h-4 ${
|
|
26
|
+
i < fullStars
|
|
27
|
+
? "text-teal-500"
|
|
28
|
+
: i === fullStars && hasHalf
|
|
29
|
+
? "text-teal-300"
|
|
30
|
+
: "text-zinc-200"
|
|
31
|
+
}`}
|
|
32
|
+
fill="currentColor"
|
|
33
|
+
viewBox="0 0 20 20"
|
|
34
|
+
aria-hidden="true"
|
|
35
|
+
>
|
|
36
|
+
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.37 2.448a1 1 0 00-.363 1.118l1.287 3.957c.3.922-.755 1.688-1.54 1.118l-3.37-2.448a1 1 0 00-1.175 0l-3.37 2.448c-.784.57-1.838-.196-1.539-1.118l1.287-3.957a1 1 0 00-.364-1.118L2.05 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" />
|
|
37
|
+
</svg>
|
|
38
|
+
))}
|
|
39
|
+
</div>
|
|
40
|
+
<span className="text-zinc-700 font-medium">{rating.toFixed(1)}</span>
|
|
41
|
+
<span className="text-zinc-400">from {ratingCount}</span>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
{/* Highlights as a clean vertical list */}
|
|
45
|
+
{highlights.length > 0 && (
|
|
46
|
+
<div className="flex flex-col gap-1">
|
|
47
|
+
{highlights.map((h, i) => (
|
|
48
|
+
<div key={i} className="flex items-center gap-2">
|
|
49
|
+
<svg
|
|
50
|
+
className="w-3.5 h-3.5 text-teal-500 flex-shrink-0"
|
|
51
|
+
fill="none"
|
|
52
|
+
viewBox="0 0 24 24"
|
|
53
|
+
stroke="currentColor"
|
|
54
|
+
strokeWidth={2.5}
|
|
55
|
+
aria-hidden="true"
|
|
56
|
+
>
|
|
57
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
58
|
+
</svg>
|
|
59
|
+
<span className="text-zinc-600">{h}</span>
|
|
60
|
+
</div>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion } from "framer-motion";
|
|
4
|
+
|
|
5
|
+
interface ProofBannerProps {
|
|
6
|
+
quote: string;
|
|
7
|
+
source?: string;
|
|
8
|
+
metric: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ProofBanner({ quote, source, metric }: ProofBannerProps) {
|
|
12
|
+
return (
|
|
13
|
+
<motion.div
|
|
14
|
+
className="my-8 p-4 rounded-xl border border-zinc-200 bg-white flex items-start gap-4"
|
|
15
|
+
initial={{ opacity: 0, x: -16 }}
|
|
16
|
+
whileInView={{ opacity: 1, x: 0 }}
|
|
17
|
+
viewport={{ once: true }}
|
|
18
|
+
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
|
19
|
+
>
|
|
20
|
+
<div className="shrink-0 text-center">
|
|
21
|
+
<span className="text-2xl font-bold text-teal-600">{metric}</span>
|
|
22
|
+
</div>
|
|
23
|
+
<div>
|
|
24
|
+
<p className="text-sm text-subtle-text italic">
|
|
25
|
+
“{quote}”
|
|
26
|
+
</p>
|
|
27
|
+
{source ? <p className="text-xs text-zinc-500 mt-1">{source}</p> : null}
|
|
28
|
+
</div>
|
|
29
|
+
</motion.div>
|
|
30
|
+
);
|
|
31
|
+
}
|