Files
stats/apps/public/src/app/(home)/_sections/testimonials.tsx
Carl-Gerhard Lindesvärd 05ccbc372c public: add reviews
2026-02-05 13:04:40 +00:00

208 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { InfiniteMovingCards } from '@/components/infinite-moving-cards';
import { Section, SectionHeader } from '@/components/section';
import { TwitterCard } from '@/components/twitter-card';
import { useEffect, useMemo, useRef } from 'react';
const testimonials = [
{
verified: true,
avatarUrl: '/twitter-steven.jpg',
name: 'Steven Tey',
handle: 'steventey',
content: [
'Open-source Mixpanel alternative just dropped → http://git.new/openpanel',
'It combines the power of Mixpanel + the ease of use of @PlausibleHQ into a fully open-source product.',
'Built by @CarlLindesvard and its already tracking 750K+ events 🤩',
],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-pontus.jpg',
name: 'Pontus Abrahamsson - oss/acc',
handle: 'pontusab',
content: ['Thanks, OpenPanel is a beast, love it!'],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-piotr.jpg',
name: 'Piotr Kulpinski',
handle: 'piotrkulpinski',
content: [
'The Overview tab in OpenPanel is great. It has everything I need from my analytics: the stats, the graph, traffic sources, locations, devices, etc.',
'The UI is beautiful ✨ Clean, modern look, very pleasing to the eye.',
],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-greg.png',
name: 'greg hodson 🍜',
handle: 'h0dson',
content: ['i second this, openpanel is killing it'],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-jacob.jpg',
name: 'Jacob 🍀 Build in Public',
handle: 'javayhuwx',
content: [
"🤯 wow, it's amazing! Just integrate @OpenPanelDev into http://indiehackers.site last night, and now I can see visitors coming from all round the world.",
'OpenPanel has a more beautiful UI and much more powerful features when compared to Umami.',
'#buildinpublic #indiehackers',
],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-lee.jpg',
name: 'Lee',
handle: 'DutchEngIishman',
content: [
'Day two of marketing.',
'I like this upward trend..',
'P.S. website went live on Sunday',
'P.P.S. Openpanel by @CarlLindesvard is awesome.',
],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-thomas.jpg',
name: 'Thomas Sanlis',
handle: 'T_Zahil',
content: [
`We're now sponsoring @OpenPanelDev with Uneed 🥳`,
`If you're looking for open source analytics, OpenPanel is BY FAR the best I've ever seen`,
'Bonus: 1-click install on Coolify 🥰',
],
replies: 8,
retweets: 3,
likes: 23,
},
];
export function Testimonials() {
const scrollerRef = useRef<HTMLDivElement>(null);
const animationFrameRef = useRef<number>(0);
const isPausedRef = useRef(false);
// Duplicate items to create the illusion of infinite scrolling
const duplicatedTestimonials = useMemo(
() => [...testimonials, ...testimonials],
[],
);
useEffect(() => {
const scrollerElement = scrollerRef.current;
if (!scrollerElement) return;
const handleScroll = () => {
// When we've scrolled to the end of the first set, reset to the beginning
// This creates a seamless infinite scroll effect
const scrollWidth = scrollerElement.scrollWidth;
const clientWidth = scrollerElement.clientWidth;
const scrollLeft = scrollerElement.scrollLeft;
// Reset scroll position when we reach halfway (end of first set)
if (scrollLeft + clientWidth >= scrollWidth / 2) {
scrollerElement.scrollLeft = scrollLeft - scrollWidth / 2;
}
};
// Auto-scroll functionality
const autoScroll = () => {
if (!isPausedRef.current && scrollerElement) {
scrollerElement.scrollLeft += 0.5; // Adjust speed here
animationFrameRef.current = requestAnimationFrame(autoScroll);
}
};
scrollerElement.addEventListener('scroll', handleScroll);
// Start auto-scrolling
animationFrameRef.current = requestAnimationFrame(autoScroll);
// Pause on hover
const handleMouseEnter = () => {
isPausedRef.current = true;
};
const handleMouseLeave = () => {
isPausedRef.current = false;
animationFrameRef.current = requestAnimationFrame(autoScroll);
};
scrollerElement.addEventListener('mouseenter', handleMouseEnter);
scrollerElement.addEventListener('mouseleave', handleMouseLeave);
return () => {
scrollerElement.removeEventListener('scroll', handleScroll);
scrollerElement.removeEventListener('mouseenter', handleMouseEnter);
scrollerElement.removeEventListener('mouseleave', handleMouseLeave);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, []);
return (
<Section className="overflow-hidden">
<div className="container mb-16">
<SectionHeader
title="Loved by builders everywhere"
description="From indie hackers to global teams, OpenPanel helps people understand their users effortlessly."
/>
</div>
<div className="relative -mx-4 px-4">
{/* Gradient masks for fade effect */}
<div
className="absolute left-0 top-0 bottom-0 w-32 z-10 pointer-events-none"
style={{
background:
'linear-gradient(to right, hsl(var(--background)), transparent)',
}}
/>
<div
className="absolute right-0 top-0 bottom-0 w-32 z-10 pointer-events-none"
style={{
background:
'linear-gradient(to left, hsl(var(--background)), transparent)',
}}
/>
<InfiniteMovingCards
items={testimonials}
direction="left"
pauseOnHover
speed="slow"
className="gap-8"
renderItem={(item) => (
<TwitterCard
name={item.name}
handle={item.handle}
content={item.content}
avatarUrl={item.avatarUrl}
/>
)}
/>
</div>
</Section>
);
}