@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,216 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Player } from "@remotion/player";
|
|
4
|
+
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
5
|
+
|
|
6
|
+
/* ------------------------------------------------------------------ */
|
|
7
|
+
/* Built-in composition: "ConceptReveal" */
|
|
8
|
+
/* Dead simple: a stack of captions fading+sliding in, with a tinted */
|
|
9
|
+
/* gradient background and a growing progress bar. */
|
|
10
|
+
/* ------------------------------------------------------------------ */
|
|
11
|
+
|
|
12
|
+
interface ConceptRevealProps {
|
|
13
|
+
title: string;
|
|
14
|
+
subtitle?: string;
|
|
15
|
+
captions: string[];
|
|
16
|
+
accent?: "teal" | "cyan";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ConceptReveal({
|
|
20
|
+
title,
|
|
21
|
+
subtitle,
|
|
22
|
+
captions,
|
|
23
|
+
accent = "teal",
|
|
24
|
+
}: ConceptRevealProps) {
|
|
25
|
+
const frame = useCurrentFrame();
|
|
26
|
+
const { fps, durationInFrames } = useVideoConfig();
|
|
27
|
+
|
|
28
|
+
const titleOpacity = spring({ frame, fps, config: { damping: 200 } });
|
|
29
|
+
const titleY = interpolate(
|
|
30
|
+
spring({ frame, fps, config: { damping: 200 } }),
|
|
31
|
+
[0, 1],
|
|
32
|
+
[24, 0]
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const subtitleProgress = spring({
|
|
36
|
+
frame: frame - 8,
|
|
37
|
+
fps,
|
|
38
|
+
config: { damping: 200 },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
|
|
42
|
+
extrapolateRight: "clamp",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const bgA = accent === "teal" ? "#14b8a6" : "#06b6d4";
|
|
46
|
+
const bgB = accent === "teal" ? "#0d9488" : "#0891b2";
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<AbsoluteFill
|
|
50
|
+
style={{
|
|
51
|
+
background: `linear-gradient(135deg, ${bgA} 0%, ${bgB} 100%)`,
|
|
52
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
53
|
+
color: "white",
|
|
54
|
+
padding: 56,
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
{/* Subtle grid overlay */}
|
|
58
|
+
<AbsoluteFill
|
|
59
|
+
style={{
|
|
60
|
+
backgroundImage:
|
|
61
|
+
"linear-gradient(rgba(255,255,255,0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.06) 1px, transparent 1px)",
|
|
62
|
+
backgroundSize: "48px 48px",
|
|
63
|
+
pointerEvents: "none",
|
|
64
|
+
}}
|
|
65
|
+
/>
|
|
66
|
+
|
|
67
|
+
<div style={{ position: "relative", display: "flex", flexDirection: "column", height: "100%" }}>
|
|
68
|
+
<div
|
|
69
|
+
style={{
|
|
70
|
+
opacity: titleOpacity,
|
|
71
|
+
transform: `translateY(${titleY}px)`,
|
|
72
|
+
fontSize: 64,
|
|
73
|
+
fontWeight: 700,
|
|
74
|
+
lineHeight: 1.1,
|
|
75
|
+
letterSpacing: -1,
|
|
76
|
+
marginBottom: 16,
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
{title}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{subtitle && (
|
|
83
|
+
<div
|
|
84
|
+
style={{
|
|
85
|
+
opacity: subtitleProgress * 0.85,
|
|
86
|
+
transform: `translateY(${interpolate(subtitleProgress, [0, 1], [20, 0])}px)`,
|
|
87
|
+
fontSize: 28,
|
|
88
|
+
fontWeight: 400,
|
|
89
|
+
lineHeight: 1.4,
|
|
90
|
+
marginBottom: 40,
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
{subtitle}
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
<div style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", gap: 18 }}>
|
|
98
|
+
{captions.map((caption, i) => {
|
|
99
|
+
const captionStart = 18 + i * 14;
|
|
100
|
+
const captionProgress = spring({
|
|
101
|
+
frame: frame - captionStart,
|
|
102
|
+
fps,
|
|
103
|
+
config: { damping: 200, stiffness: 120 },
|
|
104
|
+
});
|
|
105
|
+
const opacity = captionProgress;
|
|
106
|
+
const x = interpolate(captionProgress, [0, 1], [-20, 0]);
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
key={i}
|
|
110
|
+
style={{
|
|
111
|
+
opacity,
|
|
112
|
+
transform: `translateX(${x}px)`,
|
|
113
|
+
fontSize: 32,
|
|
114
|
+
fontWeight: 500,
|
|
115
|
+
lineHeight: 1.35,
|
|
116
|
+
display: "flex",
|
|
117
|
+
alignItems: "center",
|
|
118
|
+
gap: 16,
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<div
|
|
122
|
+
style={{
|
|
123
|
+
width: 10,
|
|
124
|
+
height: 10,
|
|
125
|
+
borderRadius: "50%",
|
|
126
|
+
background: "white",
|
|
127
|
+
opacity: 0.9,
|
|
128
|
+
flexShrink: 0,
|
|
129
|
+
}}
|
|
130
|
+
/>
|
|
131
|
+
{caption}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
})}
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{/* Progress bar */}
|
|
138
|
+
<div
|
|
139
|
+
style={{
|
|
140
|
+
marginTop: 40,
|
|
141
|
+
height: 4,
|
|
142
|
+
background: "rgba(255,255,255,0.2)",
|
|
143
|
+
borderRadius: 2,
|
|
144
|
+
overflow: "hidden",
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
<div
|
|
148
|
+
style={{
|
|
149
|
+
width: `${progress * 100}%`,
|
|
150
|
+
height: "100%",
|
|
151
|
+
background: "white",
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</AbsoluteFill>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ------------------------------------------------------------------ */
|
|
161
|
+
/* Public wrapper */
|
|
162
|
+
/* ------------------------------------------------------------------ */
|
|
163
|
+
|
|
164
|
+
interface RemotionClipProps {
|
|
165
|
+
title: string;
|
|
166
|
+
subtitle?: string;
|
|
167
|
+
captions: string[];
|
|
168
|
+
accent?: "teal" | "cyan";
|
|
169
|
+
durationInFrames?: number;
|
|
170
|
+
fps?: number;
|
|
171
|
+
width?: number;
|
|
172
|
+
height?: number;
|
|
173
|
+
autoPlay?: boolean;
|
|
174
|
+
loop?: boolean;
|
|
175
|
+
className?: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Remotion-powered video clip rendered in the browser via @remotion/player.
|
|
180
|
+
* Uses the built-in ConceptReveal composition. Pass a title, optional subtitle,
|
|
181
|
+
* and 3-6 captions; the clip animates them with spring physics on a gradient
|
|
182
|
+
* background. No video file, everything is code-generated.
|
|
183
|
+
*/
|
|
184
|
+
export function RemotionClip({
|
|
185
|
+
title,
|
|
186
|
+
subtitle,
|
|
187
|
+
captions,
|
|
188
|
+
accent = "teal",
|
|
189
|
+
durationInFrames = 150,
|
|
190
|
+
fps = 30,
|
|
191
|
+
width = 1280,
|
|
192
|
+
height = 720,
|
|
193
|
+
autoPlay = true,
|
|
194
|
+
loop = true,
|
|
195
|
+
className = "",
|
|
196
|
+
}: RemotionClipProps) {
|
|
197
|
+
return (
|
|
198
|
+
<div
|
|
199
|
+
className={`my-10 rounded-2xl overflow-hidden border border-zinc-200 shadow-lg ${className}`}
|
|
200
|
+
style={{ aspectRatio: `${width} / ${height}` }}
|
|
201
|
+
>
|
|
202
|
+
<Player
|
|
203
|
+
component={ConceptReveal}
|
|
204
|
+
durationInFrames={durationInFrames}
|
|
205
|
+
compositionWidth={width}
|
|
206
|
+
compositionHeight={height}
|
|
207
|
+
fps={fps}
|
|
208
|
+
autoPlay={autoPlay}
|
|
209
|
+
loop={loop}
|
|
210
|
+
controls
|
|
211
|
+
style={{ width: "100%", height: "100%" }}
|
|
212
|
+
inputProps={{ title, subtitle, captions, accent }}
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion } from "framer-motion";
|
|
4
|
+
|
|
5
|
+
interface SequenceMessage {
|
|
6
|
+
from: number;
|
|
7
|
+
to: number;
|
|
8
|
+
label: string;
|
|
9
|
+
type?: "request" | "response" | "event" | "error";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SequenceDiagramProps {
|
|
13
|
+
title: string;
|
|
14
|
+
actors: string[];
|
|
15
|
+
messages: SequenceMessage[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const typeStyles: Record<string, { stroke: string; text: string; dash?: string }> = {
|
|
19
|
+
request: { stroke: "stroke-emerald-500", text: "text-emerald-500" },
|
|
20
|
+
response: { stroke: "stroke-teal-500", text: "text-teal-600", dash: "6 3" },
|
|
21
|
+
event: { stroke: "stroke-amber-400", text: "text-amber-400" },
|
|
22
|
+
error: { stroke: "stroke-red-400", text: "text-red-400", dash: "4 4" },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function SequenceDiagram({ title, actors, messages }: SequenceDiagramProps) {
|
|
26
|
+
const colWidth = 160;
|
|
27
|
+
const rowHeight = 48;
|
|
28
|
+
const headerHeight = 60;
|
|
29
|
+
const svgWidth = colWidth * actors.length;
|
|
30
|
+
const svgHeight = headerHeight + rowHeight * messages.length + 32;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<motion.div
|
|
34
|
+
className="my-10 rounded-2xl border border-zinc-200 bg-white p-6 overflow-x-auto"
|
|
35
|
+
initial={{ opacity: 0, y: 16 }}
|
|
36
|
+
whileInView={{ opacity: 1, y: 0 }}
|
|
37
|
+
viewport={{ once: true }}
|
|
38
|
+
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
|
39
|
+
>
|
|
40
|
+
<p className="text-xs font-mono uppercase tracking-widest text-emerald-500 mb-4">
|
|
41
|
+
{title}
|
|
42
|
+
</p>
|
|
43
|
+
<svg
|
|
44
|
+
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
|
|
45
|
+
width={svgWidth}
|
|
46
|
+
height={svgHeight}
|
|
47
|
+
className="max-w-full"
|
|
48
|
+
>
|
|
49
|
+
{/* Actor headers */}
|
|
50
|
+
{actors.map((actor, i) => {
|
|
51
|
+
const cx = colWidth * i + colWidth / 2;
|
|
52
|
+
return (
|
|
53
|
+
<motion.g
|
|
54
|
+
key={`actor-${i}`}
|
|
55
|
+
initial={{ opacity: 0, y: -8 }}
|
|
56
|
+
whileInView={{ opacity: 1, y: 0 }}
|
|
57
|
+
viewport={{ once: true }}
|
|
58
|
+
transition={{ delay: i * 0.06, duration: 0.3 }}
|
|
59
|
+
>
|
|
60
|
+
<rect
|
|
61
|
+
x={cx - 56}
|
|
62
|
+
y={8}
|
|
63
|
+
width={112}
|
|
64
|
+
height={32}
|
|
65
|
+
rx={8}
|
|
66
|
+
className="fill-white stroke-zinc-200"
|
|
67
|
+
strokeWidth={1}
|
|
68
|
+
/>
|
|
69
|
+
<text
|
|
70
|
+
x={cx}
|
|
71
|
+
y={29}
|
|
72
|
+
textAnchor="middle"
|
|
73
|
+
className="fill-zinc-900 text-[11px] font-medium"
|
|
74
|
+
fontFamily="ui-monospace, monospace"
|
|
75
|
+
>
|
|
76
|
+
{actor}
|
|
77
|
+
</text>
|
|
78
|
+
{/* Lifeline */}
|
|
79
|
+
<line
|
|
80
|
+
x1={cx}
|
|
81
|
+
y1={44}
|
|
82
|
+
x2={cx}
|
|
83
|
+
y2={svgHeight - 8}
|
|
84
|
+
className="stroke-zinc-200"
|
|
85
|
+
strokeWidth={1}
|
|
86
|
+
strokeDasharray="3 3"
|
|
87
|
+
/>
|
|
88
|
+
</motion.g>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
|
|
92
|
+
{/* Messages */}
|
|
93
|
+
{messages.map((msg, i) => {
|
|
94
|
+
const y = headerHeight + rowHeight * i + rowHeight / 2;
|
|
95
|
+
const fromX = colWidth * msg.from + colWidth / 2;
|
|
96
|
+
const toX = colWidth * msg.to + colWidth / 2;
|
|
97
|
+
const isLeft = toX < fromX;
|
|
98
|
+
const style = typeStyles[msg.type || "request"];
|
|
99
|
+
const arrowX = toX + (isLeft ? 6 : -6);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<motion.g
|
|
103
|
+
key={`msg-${i}`}
|
|
104
|
+
initial={{ opacity: 0 }}
|
|
105
|
+
whileInView={{ opacity: 1 }}
|
|
106
|
+
viewport={{ once: true }}
|
|
107
|
+
transition={{ delay: 0.3 + i * 0.1, duration: 0.3 }}
|
|
108
|
+
>
|
|
109
|
+
<line
|
|
110
|
+
x1={fromX}
|
|
111
|
+
y1={y}
|
|
112
|
+
x2={toX}
|
|
113
|
+
y2={y}
|
|
114
|
+
className={style.stroke}
|
|
115
|
+
strokeWidth={1.5}
|
|
116
|
+
strokeDasharray={style.dash}
|
|
117
|
+
markerEnd=""
|
|
118
|
+
/>
|
|
119
|
+
{/* Arrowhead */}
|
|
120
|
+
<polygon
|
|
121
|
+
points={
|
|
122
|
+
isLeft
|
|
123
|
+
? `${arrowX},${y} ${arrowX + 7},${y - 4} ${arrowX + 7},${y + 4}`
|
|
124
|
+
: `${arrowX},${y} ${arrowX - 7},${y - 4} ${arrowX - 7},${y + 4}`
|
|
125
|
+
}
|
|
126
|
+
className={style.stroke.replace("stroke-", "fill-")}
|
|
127
|
+
/>
|
|
128
|
+
{/* Label */}
|
|
129
|
+
<text
|
|
130
|
+
x={(fromX + toX) / 2}
|
|
131
|
+
y={y - 8}
|
|
132
|
+
textAnchor="middle"
|
|
133
|
+
className={`${style.text} text-[10px]`}
|
|
134
|
+
fontFamily="ui-monospace, monospace"
|
|
135
|
+
>
|
|
136
|
+
{msg.label}
|
|
137
|
+
</text>
|
|
138
|
+
</motion.g>
|
|
139
|
+
);
|
|
140
|
+
})}
|
|
141
|
+
</svg>
|
|
142
|
+
</motion.div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
|
|
5
|
+
interface ShimmerButtonProps {
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
href?: string;
|
|
8
|
+
onClick?: () => void;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Magic UI style shimmer button. A continuous light shimmer sweeps
|
|
14
|
+
* across the button surface. Uses plain CSS keyframes with a
|
|
15
|
+
* diagonal linear-gradient mask so it works without extra deps.
|
|
16
|
+
*/
|
|
17
|
+
export function ShimmerButton({
|
|
18
|
+
children,
|
|
19
|
+
href,
|
|
20
|
+
onClick,
|
|
21
|
+
className = "",
|
|
22
|
+
}: ShimmerButtonProps) {
|
|
23
|
+
const inner = (
|
|
24
|
+
<span
|
|
25
|
+
className={`relative inline-flex items-center justify-center gap-2 overflow-hidden rounded-full bg-gradient-to-br from-cyan-500 to-teal-600 px-8 py-3 text-sm font-semibold text-white shadow-lg shadow-teal-500/30 transition-transform hover:scale-[1.02] ${className}`}
|
|
26
|
+
>
|
|
27
|
+
<span className="pointer-events-none absolute inset-0 -translate-x-full animate-[shimmer_2.2s_infinite] bg-gradient-to-r from-transparent via-white/40 to-transparent" />
|
|
28
|
+
<span className="relative z-10 flex items-center gap-2">{children}</span>
|
|
29
|
+
<style>{`
|
|
30
|
+
@keyframes shimmer {
|
|
31
|
+
0% { transform: translateX(-100%); }
|
|
32
|
+
100% { transform: translateX(200%); }
|
|
33
|
+
}
|
|
34
|
+
`}</style>
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
if (href) {
|
|
39
|
+
return (
|
|
40
|
+
<Link href={href} className="inline-block">
|
|
41
|
+
{inner}
|
|
42
|
+
</Link>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<button type="button" onClick={onClick} className="inline-block">
|
|
48
|
+
{inner}
|
|
49
|
+
</button>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ShineBorder: wraps children in a container with an animated radial-gradient
|
|
7
|
+
* border that rotates continuously, creating a "shine" effect. Defaults to a
|
|
8
|
+
* teal glow. Use it around cards, CTAs, or featured sections.
|
|
9
|
+
*
|
|
10
|
+
* Requires the `shine-pulse` keyframe in your Tailwind config:
|
|
11
|
+
* ```
|
|
12
|
+
* keyframes: {
|
|
13
|
+
* "shine-pulse": {
|
|
14
|
+
* "0%": { "background-position": "0% 0%" },
|
|
15
|
+
* "50%": { "background-position": "100% 100%" },
|
|
16
|
+
* to: { "background-position": "0% 0%" },
|
|
17
|
+
* },
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* <ShineBorder color="#14b8a6">
|
|
23
|
+
* <p>Featured content</p>
|
|
24
|
+
* </ShineBorder>
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
type TColorProp = `#${string}` | `#${string}`[];
|
|
28
|
+
|
|
29
|
+
interface ShineBorderProps {
|
|
30
|
+
borderRadius?: number;
|
|
31
|
+
borderWidth?: number;
|
|
32
|
+
duration?: number;
|
|
33
|
+
/** Hex color(s) for the shine gradient. Defaults to teal. */
|
|
34
|
+
color?: TColorProp;
|
|
35
|
+
className?: string;
|
|
36
|
+
children: React.ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ShineBorder({
|
|
40
|
+
borderRadius = 8,
|
|
41
|
+
borderWidth = 1,
|
|
42
|
+
duration = 14,
|
|
43
|
+
color = "#14b8a6",
|
|
44
|
+
className,
|
|
45
|
+
children,
|
|
46
|
+
}: ShineBorderProps) {
|
|
47
|
+
const outerBase =
|
|
48
|
+
"relative grid min-h-[60px] w-fit min-w-[300px] place-items-center bg-white p-3 text-zinc-900";
|
|
49
|
+
const outerClass = className
|
|
50
|
+
? `${outerBase} ${className}`
|
|
51
|
+
: outerBase;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
style={
|
|
56
|
+
{
|
|
57
|
+
"--border-radius": `${borderRadius}px`,
|
|
58
|
+
borderRadius: `${borderRadius}px`,
|
|
59
|
+
} as React.CSSProperties
|
|
60
|
+
}
|
|
61
|
+
className={outerClass}
|
|
62
|
+
>
|
|
63
|
+
<div
|
|
64
|
+
style={
|
|
65
|
+
{
|
|
66
|
+
"--border-width": `${borderWidth}px`,
|
|
67
|
+
"--border-radius": `${borderRadius}px`,
|
|
68
|
+
"--border-radius-child": `${borderRadius * 0.2}px`,
|
|
69
|
+
"--shine-pulse-duration": `${duration}s`,
|
|
70
|
+
"--mask-linear-gradient":
|
|
71
|
+
"linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
|
|
72
|
+
"--background-radial-gradient": `radial-gradient(transparent,transparent, ${
|
|
73
|
+
Array.isArray(color) ? color.join(",") : color
|
|
74
|
+
},transparent,transparent)`,
|
|
75
|
+
} as React.CSSProperties
|
|
76
|
+
}
|
|
77
|
+
className="before:bg-shine-size before:absolute before:inset-[0] before:aspect-square before:h-full before:w-full before:rounded-[--border-radius] before:p-[--border-width] before:will-change-[background-position] before:content-[''] before:![-webkit-mask-composite:xor] before:![mask-composite:exclude] before:[background-image:var(--background-radial-gradient)] before:[background-size:300%_300%] before:[mask:var(--mask-linear-gradient)] motion-safe:before:animate-[shine-pulse_var(--shine-pulse-duration)_infinite_linear]"
|
|
78
|
+
/>
|
|
79
|
+
<div
|
|
80
|
+
className="z-[1] h-full w-full"
|
|
81
|
+
style={{ borderRadius: `${borderRadius * 0.2}px` }}
|
|
82
|
+
>
|
|
83
|
+
{children}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion } from "framer-motion";
|
|
4
|
+
|
|
5
|
+
interface TimelineStep {
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
icon?: string;
|
|
9
|
+
detail?: React.ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface StepTimelineProps {
|
|
13
|
+
title?: string;
|
|
14
|
+
steps: TimelineStep[];
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Vertical timeline with animated step reveals.
|
|
20
|
+
* Each step connects via an animated line that draws downward.
|
|
21
|
+
*/
|
|
22
|
+
export function StepTimeline({
|
|
23
|
+
title,
|
|
24
|
+
steps,
|
|
25
|
+
className = "",
|
|
26
|
+
}: StepTimelineProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div className={`my-10 ${className}`}>
|
|
29
|
+
{title && (
|
|
30
|
+
<motion.h3
|
|
31
|
+
initial={{ opacity: 0, y: 12 }}
|
|
32
|
+
whileInView={{ opacity: 1, y: 0 }}
|
|
33
|
+
viewport={{ once: true }}
|
|
34
|
+
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
|
35
|
+
className="text-lg font-semibold text-zinc-900 mb-6"
|
|
36
|
+
>
|
|
37
|
+
{title}
|
|
38
|
+
</motion.h3>
|
|
39
|
+
)}
|
|
40
|
+
|
|
41
|
+
<div className="relative pl-8">
|
|
42
|
+
{/* Vertical line */}
|
|
43
|
+
<motion.div
|
|
44
|
+
initial={{ scaleY: 0 }}
|
|
45
|
+
whileInView={{ scaleY: 1 }}
|
|
46
|
+
viewport={{ once: true, margin: "-40px" }}
|
|
47
|
+
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
|
|
48
|
+
className="absolute left-3 top-2 bottom-2 w-px bg-gradient-to-b from-teal-400 via-teal-300 to-zinc-200 origin-top"
|
|
49
|
+
/>
|
|
50
|
+
|
|
51
|
+
<div className="space-y-8">
|
|
52
|
+
{steps.map((step, i) => (
|
|
53
|
+
<motion.div
|
|
54
|
+
key={i}
|
|
55
|
+
initial={{ opacity: 0, x: -16 }}
|
|
56
|
+
whileInView={{ opacity: 1, x: 0 }}
|
|
57
|
+
viewport={{ once: true, margin: "-20px" }}
|
|
58
|
+
transition={{
|
|
59
|
+
duration: 0.45,
|
|
60
|
+
ease: [0.16, 1, 0.3, 1],
|
|
61
|
+
delay: i * 0.1,
|
|
62
|
+
}}
|
|
63
|
+
className="relative"
|
|
64
|
+
>
|
|
65
|
+
{/* Dot */}
|
|
66
|
+
<motion.div
|
|
67
|
+
initial={{ scale: 0 }}
|
|
68
|
+
whileInView={{ scale: 1 }}
|
|
69
|
+
viewport={{ once: true }}
|
|
70
|
+
transition={{
|
|
71
|
+
duration: 0.3,
|
|
72
|
+
delay: i * 0.1 + 0.15,
|
|
73
|
+
type: "spring",
|
|
74
|
+
stiffness: 300,
|
|
75
|
+
damping: 20,
|
|
76
|
+
}}
|
|
77
|
+
className="absolute -left-8 top-1 w-6 h-6 rounded-full bg-teal-50 border-2 border-teal-400 flex items-center justify-center"
|
|
78
|
+
>
|
|
79
|
+
{step.icon ? (
|
|
80
|
+
<span className="text-xs">{step.icon}</span>
|
|
81
|
+
) : (
|
|
82
|
+
<span className="text-[10px] font-mono font-bold text-teal-600">
|
|
83
|
+
{i + 1}
|
|
84
|
+
</span>
|
|
85
|
+
)}
|
|
86
|
+
</motion.div>
|
|
87
|
+
|
|
88
|
+
{/* Content */}
|
|
89
|
+
<div>
|
|
90
|
+
<h4 className="text-sm font-semibold text-zinc-900 mb-1">
|
|
91
|
+
{step.title}
|
|
92
|
+
</h4>
|
|
93
|
+
<p className="text-sm text-zinc-500 leading-relaxed">
|
|
94
|
+
{step.description}
|
|
95
|
+
</p>
|
|
96
|
+
{step.detail && (
|
|
97
|
+
<div className="mt-3 rounded-lg border border-zinc-100 bg-zinc-50 p-3 text-sm">
|
|
98
|
+
{step.detail}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
</motion.div>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
5
|
+
|
|
6
|
+
interface StickyBottomCtaProps {
|
|
7
|
+
description: string;
|
|
8
|
+
buttonLabel: string;
|
|
9
|
+
href: string;
|
|
10
|
+
scrollThreshold?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function StickyBottomCta({
|
|
14
|
+
description,
|
|
15
|
+
buttonLabel,
|
|
16
|
+
href,
|
|
17
|
+
scrollThreshold = 600,
|
|
18
|
+
}: StickyBottomCtaProps) {
|
|
19
|
+
const [visible, setVisible] = useState(false);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const handler = () => setVisible(window.scrollY > scrollThreshold);
|
|
23
|
+
window.addEventListener("scroll", handler);
|
|
24
|
+
return () => window.removeEventListener("scroll", handler);
|
|
25
|
+
}, [scrollThreshold]);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<AnimatePresence>
|
|
29
|
+
{visible && (
|
|
30
|
+
<motion.div
|
|
31
|
+
initial={{ y: "100%" }}
|
|
32
|
+
animate={{ y: 0 }}
|
|
33
|
+
exit={{ y: "100%" }}
|
|
34
|
+
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
|
35
|
+
className="fixed bottom-0 left-0 right-0 z-40 border-t border-zinc-200 backdrop-blur-xl py-3 px-6"
|
|
36
|
+
style={{ backgroundColor: "var(--mobile-bg)" }}
|
|
37
|
+
>
|
|
38
|
+
<div className="mx-auto max-w-7xl flex items-center justify-between">
|
|
39
|
+
<p className="text-sm text-zinc-500 hidden sm:block">
|
|
40
|
+
{description}
|
|
41
|
+
</p>
|
|
42
|
+
<a
|
|
43
|
+
href={href}
|
|
44
|
+
className="text-sm font-medium px-4 py-2 rounded-lg bg-teal-500 text-accent-contrast hover:bg-accent-dim transition-colors"
|
|
45
|
+
>
|
|
46
|
+
{buttonLabel}
|
|
47
|
+
</a>
|
|
48
|
+
</div>
|
|
49
|
+
</motion.div>
|
|
50
|
+
)}
|
|
51
|
+
</AnimatePresence>
|
|
52
|
+
);
|
|
53
|
+
}
|