diff --git a/app/page.tsx b/app/page.tsx index 18724dd..c04ee57 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,6 +5,7 @@ import { Hero } from "@/components/app/landing/hero"; import { InstallCommand } from "@/components/app/docs/install-command"; import { LandingComponentCard } from "@/components/app/landing/landing-component-card"; import { SiteFooter } from "@/components/app/chrome/site-footer"; +import { Testimonials } from "@/components/app/landing/testimonials"; import { WorkCta } from "@/components/app/landing/work-cta"; const CURATED: { category: string; slug: string }[] = [ @@ -112,6 +113,8 @@ export default function Home() { + + diff --git a/bun.lock b/bun.lock index 874336f..009fd55 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-tweet": "^3.3.1", "react-use-measure": "^2.1.7", "shiki": "^4.2.0", "tailwind-merge": "^3.6.0", @@ -439,6 +440,8 @@ "react-is-19": ["react-is@19.2.7", "", {}, "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A=="], + "react-tweet": ["react-tweet@3.3.1", "", { "dependencies": { "@swc/helpers": "^0.5.3", "clsx": "^2.0.0", "swr": "^2.2.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-0Gj1YgBTe1K85NBMoWBlUc17Rdc67gIJLlacrVgj+3jWIoXpFfxPdZ1U5OnWkkF9zxhphqs2pY1i//JBfP3Qog=="], + "react-use-measure": ["react-use-measure@2.1.7", "", { "peerDependencies": { "react": ">=16.13", "react-dom": ">=16.13" }, "optionalPeers": ["react-dom"] }, "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg=="], "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], @@ -469,6 +472,8 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "swr": ["swr@2.4.2", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], @@ -493,6 +498,8 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], diff --git a/components/app/landing/testimonial-card.tsx b/components/app/landing/testimonial-card.tsx new file mode 100644 index 0000000..782ecfa --- /dev/null +++ b/components/app/landing/testimonial-card.tsx @@ -0,0 +1,92 @@ +import { enrichTweet } from "react-tweet"; +import type { Tweet } from "react-tweet/api"; +import { cn } from "@/lib/utils"; + +function VerifiedBadge() { + return ( + + + + ); +} + +export function TestimonialCard({ + tweet, + compact = false, +}: { + tweet: Tweet; + compact?: boolean; +}) { + const t = enrichTweet(tweet); + const verified = t.user.is_blue_verified || t.user.verified; + // Swap Twitter's 48px `_normal` avatar for the crisper 73px `_bigger`. + const avatar = t.user.profile_image_url_https.replace("_normal", "_bigger"); + + return ( + +
+ {/* biome-ignore lint/performance/noImgElement: external Twitter avatar, not worth a next/image remotePatterns entry */} + +
+
+ + {t.user.name} + + {verified ? : null} +
+ + @{t.user.screen_name} + +
+
+ +

+ {t.entities.map((item, i) => { + if (item.type === "media") return null; + if (item.type === "text") { + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: tweet parts are positional and stable + {item.text} + ); + } + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: tweet parts are positional and stable + + {item.text} + + ); + })} +

+
+ ); +} diff --git a/components/app/landing/testimonials.tsx b/components/app/landing/testimonials.tsx new file mode 100644 index 0000000..489cba5 --- /dev/null +++ b/components/app/landing/testimonials.tsx @@ -0,0 +1,72 @@ +import type { Tweet } from "react-tweet/api"; +import { getTweet } from "react-tweet/api"; +import { TestimonialCard } from "@/components/app/landing/testimonial-card"; +import { Marquee } from "@/components/motion/marquee"; + +// Public tweets shown as social proof. IDs only — the content is pulled from +// Twitter's syndication API on the server and rendered statically, so no +// async component streams into the client (which trips a React 19 / Next 15 +// Flight bug: "chunk.reason.enqueueModel is not a function"). +const TWEET_IDS = [ + "2070915664668512304", + "2073135185370227162", + "2072978320036348221", + "2070129442157191185", + "2071327003790184684", + "2069456887184318562", + "2066804142719275062", + "2071206392925765751", + "2069415701874720806", + "2073188569506587028", + "2069333890506936655", + "2071800087940870242", + "2069108073839435853", + "2071704269816811735", + "2069459958857650245", + "2071569532796256411", +]; + +export async function Testimonials() { + const tweets = await Promise.all( + TWEET_IDS.map(async (id) => { + try { + return await getTweet(id); + } catch { + return undefined; + } + }), + ); + const found = tweets.filter((t): t is Tweet => t != null); + if (found.length === 0) return null; + + // Split into two rows that scroll in opposite directions. + const mid = Math.ceil(found.length / 2); + const rowOne = found.slice(0, mid); + const rowTwo = found.slice(mid); + + return ( +
+
+

+ Testimonials +

+

+ Loved by builders. +

+
+ +
+ + {rowOne.map((tweet) => ( + + ))} + + + {rowTwo.map((tweet) => ( + + ))} + +
+
+ ); +} diff --git a/components/motion/marquee.tsx b/components/motion/marquee.tsx index 8914742..b22f092 100644 --- a/components/motion/marquee.tsx +++ b/components/motion/marquee.tsx @@ -33,7 +33,9 @@ export function Marquee({ fade && vertical && "[mask-image:linear-gradient(to_bottom,transparent,black_12%,black_88%,transparent)]", className, )} - style={{ "--gap": gap } as React.CSSProperties} + // gap on the wrapper too, so the seam between the two tracks matches the + // spacing between items and the loop stays even. + style={{ "--gap": gap, gap } as React.CSSProperties} > {[0, 1].map((dup) => (