@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,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion } from "framer-motion";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
interface AnimatedSectionProps {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
id?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
delay?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function AnimatedSection({
|
|
14
|
+
children,
|
|
15
|
+
id,
|
|
16
|
+
className = "mb-14",
|
|
17
|
+
delay = 0,
|
|
18
|
+
}: AnimatedSectionProps) {
|
|
19
|
+
return (
|
|
20
|
+
<motion.section
|
|
21
|
+
id={id}
|
|
22
|
+
className={className}
|
|
23
|
+
initial={{ opacity: 0, y: 24 }}
|
|
24
|
+
whileInView={{ opacity: 1, y: 0 }}
|
|
25
|
+
viewport={{ once: true, margin: "-80px" }}
|
|
26
|
+
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay }}
|
|
27
|
+
>
|
|
28
|
+
{children}
|
|
29
|
+
</motion.section>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
interface ArticleMetaProps {
|
|
2
|
+
author: string;
|
|
3
|
+
authorRole?: string;
|
|
4
|
+
datePublished: string;
|
|
5
|
+
dateModified?: string;
|
|
6
|
+
readingTime?: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function formatDate(value: string) {
|
|
11
|
+
const d = new Date(value);
|
|
12
|
+
if (Number.isNaN(d.getTime())) return value;
|
|
13
|
+
return d.toLocaleDateString("en-US", {
|
|
14
|
+
year: "numeric",
|
|
15
|
+
month: "long",
|
|
16
|
+
day: "numeric",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ArticleMeta({
|
|
21
|
+
author,
|
|
22
|
+
authorRole,
|
|
23
|
+
datePublished,
|
|
24
|
+
dateModified,
|
|
25
|
+
readingTime,
|
|
26
|
+
className = "",
|
|
27
|
+
}: ArticleMetaProps) {
|
|
28
|
+
const showUpdated = dateModified && dateModified !== datePublished;
|
|
29
|
+
return (
|
|
30
|
+
<div className={`flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-zinc-500 ${className}`}>
|
|
31
|
+
<div className="flex items-center gap-2">
|
|
32
|
+
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-teal-500/40 to-teal-500/10 flex items-center justify-center text-xs font-semibold text-white">
|
|
33
|
+
{author.charAt(0)}
|
|
34
|
+
</div>
|
|
35
|
+
<span className="text-zinc-900 font-medium">{author}</span>
|
|
36
|
+
{authorRole && <span className="hidden sm:inline text-zinc-500">, {authorRole}</span>}
|
|
37
|
+
</div>
|
|
38
|
+
<span aria-hidden="true">·</span>
|
|
39
|
+
<time dateTime={datePublished}>
|
|
40
|
+
{showUpdated ? "Updated " : "Published "}
|
|
41
|
+
{formatDate(dateModified || datePublished)}
|
|
42
|
+
</time>
|
|
43
|
+
{readingTime && (
|
|
44
|
+
<>
|
|
45
|
+
<span aria-hidden="true">·</span>
|
|
46
|
+
<span>{readingTime}</span>
|
|
47
|
+
</>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
interface BackgroundGridProps {
|
|
4
|
+
children: React.ReactNode;
|
|
5
|
+
/** Grid cell size in pixels */
|
|
6
|
+
size?: number;
|
|
7
|
+
/** "dots" or "lines" */
|
|
8
|
+
pattern?: "dots" | "lines";
|
|
9
|
+
/** Add a radial teal glow behind the content */
|
|
10
|
+
glow?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Vercel/Linear style background with a subtle grid pattern and
|
|
16
|
+
* optional radial glow. Use as a wrapper around hero sections or
|
|
17
|
+
* standout callouts. Inner content renders with full z-index above
|
|
18
|
+
* the pattern.
|
|
19
|
+
*/
|
|
20
|
+
export function BackgroundGrid({
|
|
21
|
+
children,
|
|
22
|
+
size = 36,
|
|
23
|
+
pattern = "dots",
|
|
24
|
+
glow = true,
|
|
25
|
+
className = "",
|
|
26
|
+
}: BackgroundGridProps) {
|
|
27
|
+
const bg =
|
|
28
|
+
pattern === "dots"
|
|
29
|
+
? `radial-gradient(circle, rgba(20, 184, 166, 0.18) 1px, transparent 1px)`
|
|
30
|
+
: `linear-gradient(rgba(20, 184, 166, 0.12) 1px, transparent 1px), linear-gradient(90deg, rgba(20, 184, 166, 0.12) 1px, transparent 1px)`;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
className={`relative overflow-hidden rounded-2xl border border-zinc-200 my-10 ${className}`}
|
|
35
|
+
>
|
|
36
|
+
<div
|
|
37
|
+
className="absolute inset-0 pointer-events-none"
|
|
38
|
+
style={{
|
|
39
|
+
backgroundImage: bg,
|
|
40
|
+
backgroundSize: `${size}px ${size}px`,
|
|
41
|
+
maskImage:
|
|
42
|
+
"radial-gradient(ellipse at center, black 40%, transparent 85%)",
|
|
43
|
+
WebkitMaskImage:
|
|
44
|
+
"radial-gradient(ellipse at center, black 40%, transparent 85%)",
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
{glow && (
|
|
48
|
+
<div
|
|
49
|
+
className="absolute inset-0 pointer-events-none"
|
|
50
|
+
style={{
|
|
51
|
+
background:
|
|
52
|
+
"radial-gradient(ellipse at center, rgba(20, 184, 166, 0.15) 0%, transparent 60%)",
|
|
53
|
+
}}
|
|
54
|
+
/>
|
|
55
|
+
)}
|
|
56
|
+
<div className="relative z-10 p-8 md:p-12">{children}</div>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
5
|
+
|
|
6
|
+
interface BeforeAfterProps {
|
|
7
|
+
title?: string;
|
|
8
|
+
before: { label: string; content: string; highlights?: string[] };
|
|
9
|
+
after: { label: string; content: string; highlights?: string[] };
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function BeforeAfter({
|
|
14
|
+
title,
|
|
15
|
+
before,
|
|
16
|
+
after,
|
|
17
|
+
className = "",
|
|
18
|
+
}: BeforeAfterProps) {
|
|
19
|
+
const [showAfter, setShowAfter] = useState(false);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<motion.div
|
|
23
|
+
initial={{ opacity: 0, y: 20 }}
|
|
24
|
+
whileInView={{ opacity: 1, y: 0 }}
|
|
25
|
+
viewport={{ once: true, margin: "-40px" }}
|
|
26
|
+
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
|
27
|
+
className={`my-10 ${className}`}
|
|
28
|
+
>
|
|
29
|
+
{title && (
|
|
30
|
+
<h3 className="text-lg font-semibold text-zinc-900 mb-4">{title}</h3>
|
|
31
|
+
)}
|
|
32
|
+
|
|
33
|
+
{/* Toggle */}
|
|
34
|
+
<div className="flex items-center gap-1 mb-4 bg-zinc-100 rounded-lg p-1 w-fit">
|
|
35
|
+
<button
|
|
36
|
+
onClick={() => setShowAfter(false)}
|
|
37
|
+
className={`px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
|
38
|
+
!showAfter
|
|
39
|
+
? "bg-white text-zinc-900 shadow-sm"
|
|
40
|
+
: "text-zinc-500 hover:text-zinc-700"
|
|
41
|
+
}`}
|
|
42
|
+
>
|
|
43
|
+
{before.label}
|
|
44
|
+
</button>
|
|
45
|
+
<button
|
|
46
|
+
onClick={() => setShowAfter(true)}
|
|
47
|
+
className={`px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
|
48
|
+
showAfter
|
|
49
|
+
? "bg-gradient-to-r from-cyan-500 to-teal-500 text-white shadow-sm"
|
|
50
|
+
: "text-zinc-500 hover:text-zinc-700"
|
|
51
|
+
}`}
|
|
52
|
+
>
|
|
53
|
+
{after.label}
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
{/* Content panel */}
|
|
58
|
+
<div className="relative rounded-xl border border-zinc-200 bg-zinc-50 overflow-hidden min-h-[200px]">
|
|
59
|
+
<AnimatePresence mode="wait">
|
|
60
|
+
{!showAfter ? (
|
|
61
|
+
<motion.div
|
|
62
|
+
key="before"
|
|
63
|
+
initial={{ opacity: 0, x: -12 }}
|
|
64
|
+
animate={{ opacity: 1, x: 0 }}
|
|
65
|
+
exit={{ opacity: 0, x: 12 }}
|
|
66
|
+
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
|
67
|
+
className="p-6"
|
|
68
|
+
>
|
|
69
|
+
<p className="text-sm text-zinc-600 leading-relaxed whitespace-pre-line">
|
|
70
|
+
{before.content}
|
|
71
|
+
</p>
|
|
72
|
+
{before.highlights && (
|
|
73
|
+
<ul className="mt-4 space-y-2">
|
|
74
|
+
{before.highlights.map((h, i) => (
|
|
75
|
+
<li
|
|
76
|
+
key={i}
|
|
77
|
+
className="flex items-center gap-2 text-sm text-red-600"
|
|
78
|
+
>
|
|
79
|
+
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
80
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
81
|
+
</svg>
|
|
82
|
+
{h}
|
|
83
|
+
</li>
|
|
84
|
+
))}
|
|
85
|
+
</ul>
|
|
86
|
+
)}
|
|
87
|
+
</motion.div>
|
|
88
|
+
) : (
|
|
89
|
+
<motion.div
|
|
90
|
+
key="after"
|
|
91
|
+
initial={{ opacity: 0, x: 12 }}
|
|
92
|
+
animate={{ opacity: 1, x: 0 }}
|
|
93
|
+
exit={{ opacity: 0, x: -12 }}
|
|
94
|
+
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
|
95
|
+
className="p-6"
|
|
96
|
+
>
|
|
97
|
+
<p className="text-sm text-zinc-600 leading-relaxed whitespace-pre-line">
|
|
98
|
+
{after.content}
|
|
99
|
+
</p>
|
|
100
|
+
{after.highlights && (
|
|
101
|
+
<ul className="mt-4 space-y-2">
|
|
102
|
+
{after.highlights.map((h, i) => (
|
|
103
|
+
<li
|
|
104
|
+
key={i}
|
|
105
|
+
className="flex items-center gap-2 text-sm text-teal-600"
|
|
106
|
+
>
|
|
107
|
+
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
108
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
109
|
+
</svg>
|
|
110
|
+
{h}
|
|
111
|
+
</li>
|
|
112
|
+
))}
|
|
113
|
+
</ul>
|
|
114
|
+
)}
|
|
115
|
+
</motion.div>
|
|
116
|
+
)}
|
|
117
|
+
</AnimatePresence>
|
|
118
|
+
</div>
|
|
119
|
+
</motion.div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion } from "framer-motion";
|
|
4
|
+
|
|
5
|
+
export interface BentoCard {
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
/** "1x1" | "2x1" (wide) | "1x2" (tall) | "2x2" (hero) */
|
|
9
|
+
size?: "1x1" | "2x1" | "1x2" | "2x2";
|
|
10
|
+
icon?: string;
|
|
11
|
+
accent?: boolean;
|
|
12
|
+
content?: React.ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface BentoGridProps {
|
|
16
|
+
cards: BentoCard[];
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const SIZE_CLASSES: Record<string, string> = {
|
|
21
|
+
"1x1": "col-span-1 row-span-1",
|
|
22
|
+
"2x1": "col-span-2 row-span-1",
|
|
23
|
+
"1x2": "col-span-1 row-span-2",
|
|
24
|
+
"2x2": "col-span-2 row-span-2",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function BentoGrid({ cards, className = "" }: BentoGridProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 my-10 ${className}`}
|
|
31
|
+
>
|
|
32
|
+
{cards.map((card, i) => (
|
|
33
|
+
<motion.div
|
|
34
|
+
key={i}
|
|
35
|
+
initial={{ opacity: 0, y: 20, scale: 0.97 }}
|
|
36
|
+
whileInView={{ opacity: 1, y: 0, scale: 1 }}
|
|
37
|
+
viewport={{ once: true, margin: "-40px" }}
|
|
38
|
+
transition={{
|
|
39
|
+
duration: 0.45,
|
|
40
|
+
ease: [0.16, 1, 0.3, 1],
|
|
41
|
+
delay: i * 0.06,
|
|
42
|
+
}}
|
|
43
|
+
className={`${SIZE_CLASSES[card.size || "1x1"]} rounded-2xl border ${
|
|
44
|
+
card.accent
|
|
45
|
+
? "border-teal-200 bg-gradient-to-br from-teal-50 to-cyan-50"
|
|
46
|
+
: "border-zinc-200 bg-zinc-50"
|
|
47
|
+
} p-6 flex flex-col justify-between group hover:border-teal-300 hover:shadow-md transition-all duration-300`}
|
|
48
|
+
>
|
|
49
|
+
<div>
|
|
50
|
+
{card.icon && (
|
|
51
|
+
<span className="text-2xl mb-3 block">{card.icon}</span>
|
|
52
|
+
)}
|
|
53
|
+
<h3 className="text-base font-semibold text-zinc-900 mb-2">
|
|
54
|
+
{card.title}
|
|
55
|
+
</h3>
|
|
56
|
+
<p className="text-sm text-zinc-500 leading-relaxed">
|
|
57
|
+
{card.description}
|
|
58
|
+
</p>
|
|
59
|
+
</div>
|
|
60
|
+
{card.content && (
|
|
61
|
+
<div className="mt-4 overflow-hidden rounded-lg">{card.content}</div>
|
|
62
|
+
)}
|
|
63
|
+
</motion.div>
|
|
64
|
+
))}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { Fragment } from "react";
|
|
3
|
+
|
|
4
|
+
export interface BreadcrumbCrumb {
|
|
5
|
+
label: string;
|
|
6
|
+
href?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface BreadcrumbsProps {
|
|
10
|
+
items: BreadcrumbCrumb[];
|
|
11
|
+
className?: string;
|
|
12
|
+
align?: "start" | "center";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Breadcrumbs({ items, className = "", align = "start" }: BreadcrumbsProps) {
|
|
16
|
+
const justify = align === "center" ? "justify-center" : "justify-start";
|
|
17
|
+
return (
|
|
18
|
+
<nav aria-label="Breadcrumb" className={className}>
|
|
19
|
+
<ol className={`flex flex-wrap items-center ${justify} gap-2 text-sm text-zinc-500`}>
|
|
20
|
+
{items.map((item, i) => {
|
|
21
|
+
const isLast = i === items.length - 1;
|
|
22
|
+
return (
|
|
23
|
+
<Fragment key={`${item.label}-${i}`}>
|
|
24
|
+
<li className={isLast ? "text-zinc-900 line-clamp-1" : ""}>
|
|
25
|
+
{item.href && !isLast ? (
|
|
26
|
+
<Link href={item.href} className="hover:text-zinc-900 transition-colors">
|
|
27
|
+
{item.label}
|
|
28
|
+
</Link>
|
|
29
|
+
) : (
|
|
30
|
+
item.label
|
|
31
|
+
)}
|
|
32
|
+
</li>
|
|
33
|
+
{!isLast && <li aria-hidden="true">/</li>}
|
|
34
|
+
</Fragment>
|
|
35
|
+
);
|
|
36
|
+
})}
|
|
37
|
+
</ol>
|
|
38
|
+
</nav>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
5
|
+
|
|
6
|
+
interface CodeComparisonProps {
|
|
7
|
+
leftCode: string;
|
|
8
|
+
rightCode: string;
|
|
9
|
+
leftLines: number;
|
|
10
|
+
rightLines: number;
|
|
11
|
+
leftLabel: string;
|
|
12
|
+
rightLabel: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
reductionSuffix?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function CodeComparison({
|
|
18
|
+
leftCode,
|
|
19
|
+
rightCode,
|
|
20
|
+
leftLines,
|
|
21
|
+
rightLines,
|
|
22
|
+
leftLabel,
|
|
23
|
+
rightLabel,
|
|
24
|
+
title,
|
|
25
|
+
reductionSuffix = "fewer lines",
|
|
26
|
+
}: CodeComparisonProps) {
|
|
27
|
+
const [tab, setTab] = useState<"left" | "right">("left");
|
|
28
|
+
const reduction = Math.round((1 - rightLines / leftLines) * 100);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<motion.div
|
|
32
|
+
className="my-8 rounded-2xl border border-zinc-200 bg-white overflow-hidden"
|
|
33
|
+
initial={{ opacity: 0, y: 12 }}
|
|
34
|
+
whileInView={{ opacity: 1, y: 0 }}
|
|
35
|
+
viewport={{ once: true }}
|
|
36
|
+
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
|
37
|
+
>
|
|
38
|
+
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-200 bg-white/50">
|
|
39
|
+
{title && (
|
|
40
|
+
<p className="text-xs font-mono uppercase tracking-widest text-emerald-500">{title}</p>
|
|
41
|
+
)}
|
|
42
|
+
<div className="flex gap-1 rounded-lg bg-white p-0.5 border border-zinc-200">
|
|
43
|
+
<button
|
|
44
|
+
onClick={() => setTab("left")}
|
|
45
|
+
className={`text-[11px] font-medium px-3 py-1 rounded-md transition-colors ${
|
|
46
|
+
tab === "left"
|
|
47
|
+
? "bg-teal-500/10 text-teal-600"
|
|
48
|
+
: "text-zinc-500 hover:text-zinc-900"
|
|
49
|
+
}`}
|
|
50
|
+
>
|
|
51
|
+
{leftLabel} ({leftLines} lines)
|
|
52
|
+
</button>
|
|
53
|
+
<button
|
|
54
|
+
onClick={() => setTab("right")}
|
|
55
|
+
className={`text-[11px] font-medium px-3 py-1 rounded-md transition-colors ${
|
|
56
|
+
tab === "right"
|
|
57
|
+
? "bg-emerald-500/10 text-emerald-500"
|
|
58
|
+
: "text-zinc-500 hover:text-zinc-900"
|
|
59
|
+
}`}
|
|
60
|
+
>
|
|
61
|
+
{rightLabel} ({rightLines} lines)
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div className="relative min-h-[200px]">
|
|
67
|
+
<AnimatePresence mode="wait">
|
|
68
|
+
{tab === "left" ? (
|
|
69
|
+
<motion.pre
|
|
70
|
+
key="left"
|
|
71
|
+
className="p-5 text-sm font-mono overflow-x-auto"
|
|
72
|
+
initial={{ opacity: 0, x: -8 }}
|
|
73
|
+
animate={{ opacity: 1, x: 0 }}
|
|
74
|
+
exit={{ opacity: 0, x: 8 }}
|
|
75
|
+
transition={{ duration: 0.2 }}
|
|
76
|
+
>
|
|
77
|
+
<code className="text-code-text">{leftCode}</code>
|
|
78
|
+
</motion.pre>
|
|
79
|
+
) : (
|
|
80
|
+
<motion.pre
|
|
81
|
+
key="right"
|
|
82
|
+
className="p-5 text-sm font-mono overflow-x-auto"
|
|
83
|
+
initial={{ opacity: 0, x: -8 }}
|
|
84
|
+
animate={{ opacity: 1, x: 0 }}
|
|
85
|
+
exit={{ opacity: 0, x: 8 }}
|
|
86
|
+
transition={{ duration: 0.2 }}
|
|
87
|
+
>
|
|
88
|
+
<code className="text-code-text">{rightCode}</code>
|
|
89
|
+
</motion.pre>
|
|
90
|
+
)}
|
|
91
|
+
</AnimatePresence>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div className="px-5 py-3 border-t border-zinc-200 bg-white/50 flex items-center gap-3">
|
|
95
|
+
<motion.div
|
|
96
|
+
className="h-1.5 rounded-full bg-emerald-500/20 flex-1 overflow-hidden"
|
|
97
|
+
initial={{ opacity: 0 }}
|
|
98
|
+
whileInView={{ opacity: 1 }}
|
|
99
|
+
viewport={{ once: true }}
|
|
100
|
+
>
|
|
101
|
+
<motion.div
|
|
102
|
+
className="h-full rounded-full bg-emerald-500"
|
|
103
|
+
initial={{ width: "100%" }}
|
|
104
|
+
whileInView={{ width: `${100 - reduction}%` }}
|
|
105
|
+
viewport={{ once: true }}
|
|
106
|
+
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1], delay: 0.3 }}
|
|
107
|
+
/>
|
|
108
|
+
</motion.div>
|
|
109
|
+
<span className="text-[11px] font-medium text-emerald-500 whitespace-nowrap">
|
|
110
|
+
{reduction}% {reductionSuffix}
|
|
111
|
+
</span>
|
|
112
|
+
</div>
|
|
113
|
+
</motion.div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface ComparisonRow {
|
|
2
|
+
feature: string;
|
|
3
|
+
competitor: string;
|
|
4
|
+
ours: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface ComparisonTableProps {
|
|
8
|
+
heading?: string;
|
|
9
|
+
intro?: string;
|
|
10
|
+
productName: string;
|
|
11
|
+
competitorName: string;
|
|
12
|
+
rows: ComparisonRow[];
|
|
13
|
+
caveat?: string;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ComparisonTable({
|
|
18
|
+
heading,
|
|
19
|
+
intro,
|
|
20
|
+
productName,
|
|
21
|
+
competitorName,
|
|
22
|
+
rows,
|
|
23
|
+
caveat,
|
|
24
|
+
className = "",
|
|
25
|
+
}: ComparisonTableProps) {
|
|
26
|
+
return (
|
|
27
|
+
<section className={className}>
|
|
28
|
+
{heading && (
|
|
29
|
+
<h2 className="text-3xl sm:text-4xl font-bold text-zinc-900 mb-4 text-center">
|
|
30
|
+
{heading}
|
|
31
|
+
</h2>
|
|
32
|
+
)}
|
|
33
|
+
{intro && (
|
|
34
|
+
<p className="text-zinc-500 text-lg text-center mb-8 max-w-2xl mx-auto">
|
|
35
|
+
{intro}
|
|
36
|
+
</p>
|
|
37
|
+
)}
|
|
38
|
+
<div className="rounded-2xl bg-zinc-50 border border-zinc-200 overflow-hidden">
|
|
39
|
+
<table className="w-full text-left">
|
|
40
|
+
<thead>
|
|
41
|
+
<tr className="border-b border-zinc-200">
|
|
42
|
+
<th className="px-5 py-4 text-zinc-500 text-sm font-medium">Feature</th>
|
|
43
|
+
<th className="px-5 py-4 text-zinc-500 text-sm font-medium">{competitorName}</th>
|
|
44
|
+
<th className="px-5 py-4 text-teal-600 text-sm font-medium">{productName}</th>
|
|
45
|
+
</tr>
|
|
46
|
+
</thead>
|
|
47
|
+
<tbody>
|
|
48
|
+
{rows.map((row, i) => (
|
|
49
|
+
<tr
|
|
50
|
+
key={i}
|
|
51
|
+
className={i < rows.length - 1 ? "border-b border-zinc-200" : ""}
|
|
52
|
+
>
|
|
53
|
+
<td className="px-5 py-3 text-zinc-900 text-sm font-medium">{row.feature}</td>
|
|
54
|
+
<td className="px-5 py-3 text-zinc-500 text-sm">{row.competitor}</td>
|
|
55
|
+
<td className="px-5 py-3 text-zinc-900 text-sm">{row.ours}</td>
|
|
56
|
+
</tr>
|
|
57
|
+
))}
|
|
58
|
+
</tbody>
|
|
59
|
+
</table>
|
|
60
|
+
</div>
|
|
61
|
+
{caveat && (
|
|
62
|
+
<p className="text-zinc-500 text-sm mt-4 text-center">{caveat}</p>
|
|
63
|
+
)}
|
|
64
|
+
</section>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { FaqItem } from "../lib/json-ld";
|
|
2
|
+
|
|
3
|
+
interface FaqSectionProps {
|
|
4
|
+
items: FaqItem[];
|
|
5
|
+
heading?: string;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function FaqSection({
|
|
10
|
+
items,
|
|
11
|
+
heading = "Frequently asked questions",
|
|
12
|
+
className = "",
|
|
13
|
+
}: FaqSectionProps) {
|
|
14
|
+
return (
|
|
15
|
+
<section className={className}>
|
|
16
|
+
<h2 className="text-3xl sm:text-4xl font-bold text-zinc-900 mb-8">{heading}</h2>
|
|
17
|
+
<div className="space-y-4">
|
|
18
|
+
{items.map((faq) => (
|
|
19
|
+
<details
|
|
20
|
+
key={faq.q}
|
|
21
|
+
className="group p-5 rounded-xl bg-zinc-50 border border-zinc-200"
|
|
22
|
+
>
|
|
23
|
+
<summary className="text-zinc-900 font-semibold cursor-pointer list-none flex items-center justify-between gap-4">
|
|
24
|
+
<span>{faq.q}</span>
|
|
25
|
+
<span
|
|
26
|
+
className="text-zinc-500 group-open:rotate-45 transition-transform text-xl flex-shrink-0"
|
|
27
|
+
aria-hidden="true"
|
|
28
|
+
>
|
|
29
|
+
+
|
|
30
|
+
</span>
|
|
31
|
+
</summary>
|
|
32
|
+
<p className="text-zinc-500 mt-3 leading-relaxed">{faq.a}</p>
|
|
33
|
+
</details>
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
36
|
+
</section>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type { FaqItem };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion } from "framer-motion";
|
|
4
|
+
|
|
5
|
+
interface FlowStep {
|
|
6
|
+
label: string;
|
|
7
|
+
detail?: string;
|
|
8
|
+
icon?: "browser" | "server" | "redirect" | "iframe" | "webhook" | "lock" | "check" | "error" | "wallet" | "email";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface FlowDiagramProps {
|
|
12
|
+
title: string;
|
|
13
|
+
steps: FlowStep[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const icons: Record<string, string> = {
|
|
17
|
+
browser: "\uD83C\uDF10",
|
|
18
|
+
server: "\u2699\uFE0F",
|
|
19
|
+
redirect: "\u21AA\uFE0F",
|
|
20
|
+
iframe: "\uD83D\uDCE6",
|
|
21
|
+
webhook: "\uD83D\uDD14",
|
|
22
|
+
lock: "\uD83D\uDD12",
|
|
23
|
+
check: "\u2705",
|
|
24
|
+
error: "\u274C",
|
|
25
|
+
wallet: "\uD83D\uDCB3",
|
|
26
|
+
email: "\uD83D\uDCE7",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function FlowDiagram({ title, steps }: FlowDiagramProps) {
|
|
30
|
+
return (
|
|
31
|
+
<div className="my-10 rounded-2xl border border-zinc-200 bg-white p-6 overflow-x-auto">
|
|
32
|
+
<p className="text-xs font-mono uppercase tracking-widest text-emerald-500 mb-5">
|
|
33
|
+
{title}
|
|
34
|
+
</p>
|
|
35
|
+
<div className="flex items-start gap-0 min-w-max">
|
|
36
|
+
{steps.map((step, i) => (
|
|
37
|
+
<motion.div
|
|
38
|
+
key={i}
|
|
39
|
+
className="flex items-start"
|
|
40
|
+
initial={{ opacity: 0, x: -12 }}
|
|
41
|
+
whileInView={{ opacity: 1, x: 0 }}
|
|
42
|
+
viewport={{ once: true }}
|
|
43
|
+
transition={{
|
|
44
|
+
duration: 0.35,
|
|
45
|
+
ease: [0.16, 1, 0.3, 1],
|
|
46
|
+
delay: i * 0.08,
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
<div className="flex flex-col items-center w-28 shrink-0">
|
|
50
|
+
<div className="w-11 h-11 rounded-xl bg-white border border-zinc-200 flex items-center justify-center text-lg mb-2">
|
|
51
|
+
{step.icon ? icons[step.icon] || "\u2022" : `${i + 1}`}
|
|
52
|
+
</div>
|
|
53
|
+
<p className="text-xs font-medium text-zinc-900 text-center leading-tight">
|
|
54
|
+
{step.label}
|
|
55
|
+
</p>
|
|
56
|
+
{step.detail && (
|
|
57
|
+
<p className="text-[10px] text-zinc-500 text-center leading-tight mt-1">
|
|
58
|
+
{step.detail}
|
|
59
|
+
</p>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
{i < steps.length - 1 && (
|
|
63
|
+
<motion.div
|
|
64
|
+
className="flex items-center h-11 shrink-0"
|
|
65
|
+
initial={{ scaleX: 0 }}
|
|
66
|
+
whileInView={{ scaleX: 1 }}
|
|
67
|
+
viewport={{ once: true }}
|
|
68
|
+
transition={{
|
|
69
|
+
duration: 0.25,
|
|
70
|
+
ease: [0.16, 1, 0.3, 1],
|
|
71
|
+
delay: i * 0.08 + 0.15,
|
|
72
|
+
}}
|
|
73
|
+
style={{ originX: 0 }}
|
|
74
|
+
>
|
|
75
|
+
<svg width="32" height="12" viewBox="0 0 32 12" className="text-zinc-500">
|
|
76
|
+
<line x1="0" y1="6" x2="24" y2="6" stroke="currentColor" strokeWidth="1.5" />
|
|
77
|
+
<polygon points="24,2 32,6 24,10" fill="currentColor" />
|
|
78
|
+
</svg>
|
|
79
|
+
</motion.div>
|
|
80
|
+
)}
|
|
81
|
+
</motion.div>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|