Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/web/app/partners/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ const PARTNERS_DATA: Record<string, PartnerData> = {
youtubeId: "01loBLlZRHw",
ctaUrl: "https://portal.restrofx.com/r/glaPWwHQ",
featuredVideos: [
{ id: "1", videoId: "R2djd5ACzPM", title: "i'm finally buying my dream car" },
{ id: "2", videoId: "_QmCh4dNVGE", title: "Don't Trade Every Pair | Here's What Actually Works" },
{ id: "3", videoId: "DV6cte3H9rc", title: "Pulled $15k profit - here's every single trade" },
{ id: "1", videoId: "LdjfeDLhRiM", title: "$38k withdrawal in brought a lamborghini Then Went Left!" },
{ id: "2", videoId: "R2djd5ACzPM", title: "i'm finally buying my dream car" },
{ id: "3", videoId: "_QmCh4dNVGE", title: "Don't Trade Every Pair | Here's What Actually Works" },
{ id: "4", videoId: "KhLUPlL777U", title: "Why You Should Reconsider Trading This year" },
{ id: "5", videoId: "SyC37iKc2wE", title: "I Made $20k Trading Silver | Here's My Exact Strategy" },
{ id: "6", videoId: "rExdi9Vzkxk", title: "Is Trading Really Worth It? My 6 Years of Results" }
Expand Down
267 changes: 175 additions & 92 deletions apps/web/components/ui/curved-carousel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { motion, useMotionValue, useSpring, useTransform } from "framer-motion";
import { useState, useRef, useEffect } from "react";
import { motion, useMotionValue, useSpring, useTransform, useScroll } from "framer-motion";
import { useState, useRef, useEffect, useMemo } from "react";
import Image from "next/image";
import { Play } from "lucide-react";

Expand All @@ -17,128 +17,211 @@ interface CurvedCarouselProps {
subtitle?: string;
}

interface CarouselCardProps {
item: CarouselItem;
index: number;
combinedPos: any; // MotionValue<number>
itemWidth: number;
itemHeight: number;
itemTotalWidth: number;
centerOffset: number;
onClick: (videoId: string) => void;
}

function CarouselCard({
item,
index,
combinedPos,
itemWidth,
itemHeight,
itemTotalWidth,
centerOffset,
onClick
}: CarouselCardProps) {
const itemOffset = index * itemTotalWidth;

// Transform logic relative to the combined position
const itemRotation = useTransform(combinedPos,
[-itemOffset + centerOffset - itemTotalWidth, -itemOffset + centerOffset, -itemOffset + centerOffset + itemTotalWidth],
[-45, 0, 45]
);
const itemScale = useTransform(combinedPos,
[-itemOffset + centerOffset - itemTotalWidth, -itemOffset + centerOffset, -itemOffset + centerOffset + itemTotalWidth],
[0.8, 1, 0.8]
);
const itemZ = useTransform(combinedPos,
[-itemOffset + centerOffset - itemTotalWidth, -itemOffset + centerOffset, -itemOffset + centerOffset + itemTotalWidth],
[-200, 0, -200]
);
const itemOpacity = useTransform(combinedPos,
[-itemOffset + centerOffset - 2 * itemTotalWidth, -itemOffset + centerOffset, -itemOffset + centerOffset + 2 * itemTotalWidth],
[0, 1, 0]
);

const [imgSrc, setImgSrc] = useState(`https://i.ytimg.com/vi/${item.videoId}/maxresdefault.jpg`);

return (
<motion.div
style={{
width: itemWidth,
height: itemHeight,
rotateY: itemRotation,
scale: itemScale,
z: itemZ,
opacity: itemOpacity,
}}
className="relative flex-shrink-0 group rounded-3xl overflow-hidden border border-white/10 shadow-2xl bg-black pointer-events-auto"
onClick={() => onClick(item.videoId)}
>
<Image
src={imgSrc}
alt={item.title}
fill
className="object-cover opacity-60 group-hover:opacity-80 transition-opacity duration-500"
onError={() => {
setImgSrc(`https://i.ytimg.com/vi/${item.videoId}/hqdefault.jpg`);
}}
/>

<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent flex flex-col justify-end p-6">
<motion.div
initial={{ y: 20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
className="p-4 rounded-full bg-primary/20 backdrop-blur-md border border-primary/40 w-fit mb-4 group-hover:scale-110 transition-transform duration-300"
>
<Play className="w-6 h-6 text-primary fill-primary" />
</motion.div>
<h3 className="text-white font-bold text-lg leading-tight group-hover:text-primary transition-colors duration-300">
{item.title}
</h3>
</div>

<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
</motion.div>
);
}

export function CurvedCarousel({ items, title, subtitle }: CurvedCarouselProps) {
const [active, setActive] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const sectionRef = useRef<HTMLElement>(null);
const [active, setActive] = useState(0);
const x = useMotionValue(0);
const [isDragging, setIsDragging] = useState(false);

// Spring for smooth dragging
const springX = useSpring(x, { stiffness: 300, damping: 30 });
// Triplicate items for infinite loop
const displayItems = useMemo(() => [...items, ...items, ...items], [items]);
const middleIndexOffset = items.length;

// Calculate the rotation based on the number of items
// We want to map the drag distance to an index
const itemWidth = 300; // estimated width of a card
// Constants
const itemWidth = 480;
const itemHeight = 270;
const gap = 32;
const itemTotalWidth = itemWidth + gap;
const centerOffset = -itemWidth / 2;

// Spring for smooth dragging and scroll
const springX = useSpring(x, { stiffness: 150, damping: 25 });

// Scroll Sync
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ["start end", "end start"]
});

// Map scroll progress to a rotational offset - reduced for subtler effect
const scrollOffset = useTransform(scrollYProgress, [0, 1], [itemTotalWidth * 0.4, -itemTotalWidth * 0.4]);

// Combined position for all items to reactive to
const combinedPos = useTransform([springX, scrollOffset], ([sx, so]) => (sx as number) + (so as number));

const handleDragStart = () => setIsDragging(true);

const handleDragEnd = (_: any, info: any) => {
// Determine the closest index based on the drag offset
setTimeout(() => setIsDragging(false), 50);

const velocity = info.velocity.x;
const currentX = x.get() - centerOffset;
let newIndex = Math.round(-currentX / itemTotalWidth);

// Simple snapping
if (Math.abs(velocity) > 500) {
if (velocity > 0) setActive((prev) => Math.max(0, prev - 1));
else setActive((prev) => Math.min(items.length - 1, prev + 1));
} else {
const index = Math.round(-x.get() / itemWidth);
setActive(Math.max(0, Math.min(items.length - 1, index)));
newIndex = velocity > 0 ? active - 1 : active + 1;
}

// Keep active within reasonable bounds of the triple list
if (newIndex < 1) newIndex = middleIndexOffset;
if (newIndex > displayItems.length - 2) newIndex = middleIndexOffset + items.length - 1;

setActive(newIndex);
};

useEffect(() => {
x.set(-active * itemWidth);
}, [active, x]);
x.set(-(middleIndexOffset * itemTotalWidth) + centerOffset);
setActive(middleIndexOffset);
}, [middleIndexOffset, itemTotalWidth, centerOffset, x]);

useEffect(() => {
x.set(-(active * itemTotalWidth) + centerOffset);
}, [active, x, itemTotalWidth, centerOffset]);

const handleCardClick = (videoId: string) => {
if (isDragging) return;
window.open(`https://youtube.com/watch?v=${videoId}`, '_blank');
};

return (
<section className="py-24 bg-transparent overflow-hidden perspective-1000">
<section ref={sectionRef} className="py-24 bg-transparent overflow-hidden perspective-1000">
<div className="container px-4 md:px-6 mb-12 text-center">
{title && <h2 className="text-4xl md:text-6xl font-bold font-heading mb-4">{title}</h2>}
{subtitle && <p className="text-muted-foreground text-lg md:text-xl max-w-2xl mx-auto">{subtitle}</p>}
</div>

<div
ref={containerRef}
className="relative h-[500px] flex items-center justify-center cursor-grab active:cursor-grabbing"
className="relative h-[500px] flex items-center justify-center"
>
<motion.div
drag="x"
dragConstraints={{ left: -(items.length - 1) * itemWidth, right: 0 }}
style={{ x }}
// Ghost Drag: capture events without moving the element itself
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
className="flex gap-8 items-center"
onDrag={(_, info) => {
x.set(x.get() + info.delta.x);
}}
style={{ x: combinedPos, left: "50%" }}
className="absolute flex gap-8 items-center cursor-grab active:cursor-grabbing"
>
{items.map((item, index) => {
// Map the global x motion value to local item transforms
// eslint-disable-next-line react-hooks/rules-of-hooks
const itemRotation = useTransform(springX,
[- (index + 1) * itemWidth, - index * itemWidth, - (index - 1) * itemWidth],
[-45, 0, 45]
);
// eslint-disable-next-line react-hooks/rules-of-hooks
const itemScale = useTransform(springX,
[- (index + 1) * itemWidth, - index * itemWidth, - (index - 1) * itemWidth],
[0.8, 1, 0.8]
);
// eslint-disable-next-line react-hooks/rules-of-hooks
const itemZ = useTransform(springX,
[- (index + 1) * itemWidth, - index * itemWidth, - (index - 1) * itemWidth],
[-200, 0, -200]
);
// eslint-disable-next-line react-hooks/rules-of-hooks
const itemOpacity = useTransform(springX,
[- (index + 2) * itemWidth, - index * itemWidth, - (index - 2) * itemWidth],
[0, 1, 0]
);

return (
<motion.div
key={item.id}
style={{
width: itemWidth,
height: 400,
rotateY: itemRotation,
scale: itemScale,
z: itemZ,
opacity: itemOpacity,
}}
className="relative flex-shrink-0 group rounded-3xl overflow-hidden border border-white/10 shadow-2xl bg-black"
onClick={() => window.open(`https://youtube.com/watch?v=${item.videoId}`, '_blank')}
>
<Image
src={`https://i.ytimg.com/vi/${item.videoId}/maxresdefault.jpg`}
alt={item.title}
fill
className="object-cover opacity-60 group-hover:opacity-80 transition-opacity duration-500"
/>

{/* Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent flex flex-col justify-end p-6">
<motion.div
initial={{ y: 20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
className="p-4 rounded-full bg-primary/20 backdrop-blur-md border border-primary/40 w-fit mb-4 group-hover:scale-110 transition-transform duration-300"
>
<Play className="w-6 h-6 text-primary fill-primary" />
</motion.div>
<h3 className="text-white font-bold text-lg leading-tight group-hover:text-primary transition-colors duration-300">
{item.title}
</h3>
</div>

{/* Glassmorphic Reflection Overlay */}
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
</motion.div>
);
})}
{displayItems.map((item, index) => (
<CarouselCard
key={`${item.id}-${index}`}
item={item}
index={index}
combinedPos={combinedPos}
itemWidth={itemWidth}
itemHeight={itemHeight}
itemTotalWidth={itemTotalWidth}
centerOffset={centerOffset}
onClick={handleCardClick}
/>
))}
</motion.div>
</div>

<div className="flex justify-center gap-2 mt-8">
{items.map((_, i) => (
<button
key={i}
onClick={() => setActive(i)}
className={`h-2 rounded-full transition-all duration-300 ${active === i ? 'w-8 bg-primary' : 'w-2 bg-neutral-800'}`}
/>
))}
{items.map((_, i) => {
// Calculate if this dot represents the "active" visual item
// Since we use springX + scrollOffset, the visual center is slightly shifted
const isSelected = active % items.length === i;
return (
<button
key={i}
onClick={() => setActive(i + middleIndexOffset)}
className={`h-2 rounded-full transition-all duration-300 ${isSelected ? 'w-8 bg-primary' : 'w-2 bg-white/20'}`}
aria-label={`Go to video ${i + 1}`}
/>
);
})}
</div>
</section>
);
Expand Down
Loading