From 8c81b7ca42dbceeb2d9b40e4943e5c984af88b41 Mon Sep 17 00:00:00 2001 From: aree6 Date: Mon, 8 Jun 2026 20:34:57 +0800 Subject: [PATCH 1/4] feat: implement RedditCard component and enhance ReviewsCarousel with marquee effect --- .../components/landing/ui/LandingPage.tsx | 4 +- frontend/components/landing/ui/RedditCard.tsx | 174 +++++++++++ .../components/landing/ui/ReviewsCarousel.tsx | 294 +++++++++--------- 3 files changed, 326 insertions(+), 146 deletions(-) create mode 100644 frontend/components/landing/ui/RedditCard.tsx diff --git a/frontend/components/landing/ui/LandingPage.tsx b/frontend/components/landing/ui/LandingPage.tsx index 047a83aa..d43e0efb 100644 --- a/frontend/components/landing/ui/LandingPage.tsx +++ b/frontend/components/landing/ui/LandingPage.tsx @@ -324,8 +324,8 @@ export const LandingPage: React.FC = ({ onSelectPlatform, onTr {/* ========== REVIEWS SECTION ========== */} -
-
+
+
diff --git a/frontend/components/landing/ui/RedditCard.tsx b/frontend/components/landing/ui/RedditCard.tsx new file mode 100644 index 00000000..54ab886f --- /dev/null +++ b/frontend/components/landing/ui/RedditCard.tsx @@ -0,0 +1,174 @@ +import React, { useMemo, useEffect, useRef } from 'react'; +import { ArrowUp, ArrowDown, Reply, Share2, Award } from 'lucide-react'; + +const AVATAR_COLORS = [ + '#FF4500', '#0079D3', '#46D160', '#DDBD37', '#FF585B', + '#7193FF', '#0DD3BB', '#FF8717', '#A06EE1', '#E063B6', +]; + +const SUBREDDITS = ['Hevy', 'Strong', 'Lyfta', 'Motra', 'fitness', 'workout', 'Gym', 'bodybuilding', 'strength_training', 'powerlifting'] as const; +const TIMES = ['1h', '2h', '3h', '4h', '5h', '6h', '8h', '10h', '12h', '14h', '16h', '1d', '2d', '3d']; + +function hashString(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = ((h << 5) - h) + s.charCodeAt(i); + return Math.abs(h); +} + +function getColor(username: string): string { + return AVATAR_COLORS[hashString(username) % AVATAR_COLORS.length]; +} + +function getUpvotes(username: string): number { + return 10 + (hashString(username + 'up') % 140); +} + +function getSubreddit(username: string, quote: string): string { + const lower = `${username} ${quote}`.toLowerCase(); + if (lower.includes('hevy')) return 'Hevy'; + if (lower.includes('strong')) return 'Strong'; + if (lower.includes('lyfta')) return 'Lyfta'; + if (lower.includes('motra')) return 'Motra'; + if (lower.includes('bodybuilding') || lower.includes('muscle')) return 'bodybuilding'; + if (lower.includes('powerlifting') || lower.includes('pr ') || lower.includes('bench')) return 'powerlifting'; + if (lower.includes('gym') || lower.includes('workout')) return 'Gym'; + return SUBREDDITS[hashString(username) % SUBREDDITS.length]; +} + +function getTimeAgo(username: string): string { + return TIMES[hashString(username + 'time') % TIMES.length]; +} + +function SnooAvatar({ color, size = 16 }: { color: string; size?: number }) { + return ( + + + + + + + + + ); +} + +// ── Shared card face styling ── +function cardFaceClass(isLight: boolean) { + return isLight + ? 'bg-white/65 backdrop-blur-sm border-slate-200/60 shadow-sm' + : 'bg-neutral-900/50 backdrop-blur-sm border-neutral-800/40 shadow-md shadow-black/20'; +} + +interface RedditCardProps { + username: string; + quote: string; + src: string; + isLight: boolean; + cardId: string; + isFlipped: boolean; + onFlip: () => void; +} + +export const RedditCard: React.FC = React.memo(({ username, quote, src, isLight, cardId, isFlipped, onFlip }) => { + const upvotes = useMemo(() => getUpvotes(username), [username]); + const subreddit = useMemo(() => getSubreddit(username, quote), [username, quote]); + const color = useMemo(() => getColor(username), [username]); + const timeAgo = useMemo(() => getTimeAgo(username), [username]); + const timerRef = useRef | null>(null); + + const startFlipTimer = () => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => onFlip(), 1000); + }; + + const clearFlipTimer = () => { + if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } + }; + + useEffect(() => { + if (!isFlipped) clearFlipTimer(); + return clearFlipTimer; + }, [isFlipped]); + + return ( +
{ if (isFlipped) clearFlipTimer(); }} + onMouseLeave={() => { if (isFlipped) startFlipTimer(); }} + > +
+ {/* ── Front face: Reddit comment card ── */} +
+
+ + + r/{subreddit} + + · + u/{username} + · + {timeAgo} +
+ +

+ {quote.split(' / ').map((part, i) => ( + + {i > 0 &&
} + {part} +
+ ))} +

+ +
+ + {upvotes} + + + Reply + + + Share + + + Award + +
+
+ + {/* ── Back face: screenshot ── */} +
+ {`Screenshot +
+
+
+ ); +}); + +RedditCard.displayName = 'RedditCard'; + +export default RedditCard; diff --git a/frontend/components/landing/ui/ReviewsCarousel.tsx b/frontend/components/landing/ui/ReviewsCarousel.tsx index ca99e1e1..1ee693ca 100644 --- a/frontend/components/landing/ui/ReviewsCarousel.tsx +++ b/frontend/components/landing/ui/ReviewsCarousel.tsx @@ -1,82 +1,138 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { ChevronLeft, ChevronRight, Quote } from 'lucide-react'; -import { motion, AnimatePresence } from 'motion/react'; +import React, { useRef, useState, useEffect } from 'react'; +import { Quote } from 'lucide-react'; import { useTheme } from '../../theme/ThemeProvider'; import { FANCY_FONT } from '../../../utils/ui/uiConstants'; import { assetPath } from '../../../constants'; +import { RedditCard } from './RedditCard'; interface ReviewsCarouselProps { className?: string; } -const REVIEWS = [ - { src: assetPath('/carousel/7.avif'), label: 'RhubarbArtistic1335: "so well made, simply incredible. Hard to believe you\'re not a Hevy dev. Keep it up my man!"' }, - { src: assetPath('/carousel/6.avif'), label: 'suikyo1: "amazing work — the overload vs plateau breakdown is amazing, very well done"' }, - { src: assetPath('/carousel/4.avif'), label: 'marlon1310: "I didn\'t even realize you built it. Crazy stuff bro!! Post it in the Hevy subreddit"' }, - { src: assetPath('/carousel/11.avif'), label: '_Calegos: "finally a project I find easy to use, no mandatory API key or login"' }, - { src: assetPath('/carousel/10.avif'), label: 'cipherninjabyte: "outstanding — decoded my Hevy app data and gave me nice suggestions"' }, - { src: assetPath('/carousel/14.avif'), label: 'JustOneNodeOfMany: "excellent app, especially the plateau warnings and exercise history"' }, - { src: assetPath('/carousel/1.avif'), label: 'WearyStatus3898: "keep it up man. Insane work. I loved the UI and also the theme"' }, - { src: assetPath('/carousel/2.avif'), label: 'harshhat18: "bro this is crazy!!!! Now off to spend my next hour analysing my workouts on liftshift"' }, - { src: assetPath('/carousel/5.avif'), label: 'marlon1310: "this is damn amazing!!! I bought Hevy premium for advanced analytics but this does a better job — blessing you with gains"' }, - { src: assetPath('/carousel/8.avif'), label: 'Rasphy_2009: "this is so useful!! Thank you for your hard work, I\'m definitely going to use it a lot"' }, - { src: assetPath('/carousel/12.avif'), label: 'wakaokami: "a good initiative and a great way to visualize progress"' }, - { src: assetPath('/carousel/3.avif'), label: 'malicious08: "this is crazy bhai!"' }, - { src: assetPath('/carousel/9.avif'), label: 'Constant_play0: "super cool! Thanks a lot for this"' }, - { src: assetPath('/carousel/15.avif'), label: '1AML3G10N: "just checked this out, excellent work"' }, - { src: assetPath('/carousel/13.avif'), label: 'Conflicted_Gemini: "I don\'t need an analytical tool to tell me I\'ve been lazy this week"' }, - { src: assetPath('/carousel/16.avif'), label: 'Illustrious-Tear-542: "very nice analytics"' }, -]; +interface ReviewData { + src: string; + label: string; + username: string; + quote: string; +} + +const REVIEWS: ReviewData[] = [ + { src: assetPath('/carousel/7.avif'), label: 'RhubarbArtistic1335: "This is so well made. its simply incredible. Hard to believe your not a Hevy Dev. Lol / Keep it up my man !"' }, + { src: assetPath('/carousel/6.avif'), label: 'suikyo1: "Amazing work, my friend. Some time ago I had suggested that they make Hevy\'s home page very similan to what you did. It turned out really good. That part of the exercises showing which ones you\'re in overload and which ones you\'re in plateau is amazing, very well done."' }, + { src: assetPath('/carousel/4.avif'), label: 'marlon1310: "Oh wow. I didn\'t even realize you built it. I thought you were just sharing it. / Crazy stuff bro!! / Post it in hevy subreddit, I\'m sure there\'s so many people who\'d benefit from this."' }, + { src: assetPath('/carousel/11.avif'), label: '_Calegos: "Wow, simply wow! Finally a project that I find easy to use, no mandatory usage of api key or login (bless you for CSV import!), not yet tested the AI analyze but nonetheless this app is so cool, starred in an instant. / Other than that, kudos!"' }, + { src: assetPath('/carousel/10.avif'), label: 'cipherninjabyte: "This is outstanding.. it decoded my hevy app data and gave me nice suggestions."' }, + { src: assetPath('/carousel/14.avif'), label: 'JustOneNodeOfMany: "Excellent app, just what I have been waiting for and dreaming about, especially being able to see the exercise history easily. I love the way it throws up warnings re plateaus, etc. / Awesome app, thank you for developing."' }, + { src: assetPath('/carousel/1.avif'), label: 'WearyStatus3898: "keep it up man. Insane work. I loved the ui and also the theme."' }, + { src: assetPath('/carousel/2.avif'), label: 'harshhat18: "Bro this shit is crazy!!!! Now off to spend my next hour analysing my workouts on liftshift"' }, + { src: assetPath('/carousel/5.avif'), label: 'marlon1310: "I just tried it for a minute. This is damn amazing OP!!! I bought Hevy premium at Black Friday sale for the advanced analytics but even that doesn\'t do a good of a job like this app does. / For someone obsessed with numbers, this is a blessing!!!! / Blessing you with a lot of gains!!"' }, + { src: assetPath('/carousel/8.avif'), label: 'Rasphy_2009: "This is so useful!! Thank you for your hard work. I\'m definitely going to use it a lot"' }, + { src: assetPath('/carousel/12.avif'), label: 'wakaokami: "A good initiative and a great way to visualize progress. 🙌 / I was just wondering how you\'re handling passwords for the login feature, and how you\'re ensuring user privacy. / I haven\'t had time to go through the code yet, but I\'d like to contribute as well."' }, + { src: assetPath('/carousel/3.avif'), label: 'malicious08: "This is crazy bhai!"' }, + { src: assetPath('/carousel/9.avif'), label: 'Constant_play0: "Super cool! Thanks a lot for this"' }, + { src: assetPath('/carousel/15.avif'), label: '1AML3G10N: "Just checked this out. Excellent work!"' }, + { src: assetPath('/carousel/13.avif'), label: 'Conflicted_Gemini: "Look man. I don\'t need an analytical tool to tell me I\'ve beg lazy this week 😂😂😂"' }, + { src: assetPath('/carousel/16.avif'), label: 'Illustrious-Tear-542: "Very nice analytics."' }, +].map((r) => { + const match = r.label.match(/^(.+?):\s*"(.*)"$/); + return { + ...r, + username: match ? match[1] : r.label, + quote: match ? match[2] : r.label, + }; +}); + +const ROWS_DESKTOP = [REVIEWS.slice(0, 6), REVIEWS.slice(6, 11), REVIEWS.slice(11)]; +const ROWS_MOBILE = [REVIEWS.slice(0, 8), REVIEWS.slice(8)]; + +// ── CSS-keyframe marquee ── +// Uses +
+ {items.map((r) => card(r, r.src))} + {items.map((r) => card(r, `${r.src}-clone`))} +
+
+ ); +} + +// ── Main component ── export const ReviewsCarousel: React.FC = ({ className = '' }) => { const { mode } = useTheme(); const isLight = mode === 'light'; - const [currentIndex, setCurrentIndex] = useState(0); - const [isAutoPlaying, setIsAutoPlaying] = useState(true); - const [isTransitioning, setIsTransitioning] = useState(false); - - const goToSlide = useCallback((index: number) => { - if (isTransitioning) return; - setIsTransitioning(true); - setCurrentIndex(index); - setTimeout(() => setIsTransitioning(false), 250); - }, [isTransitioning]); - - const goNext = useCallback(() => { - goToSlide((currentIndex + 1) % REVIEWS.length); - }, [currentIndex, goToSlide]); - - const goPrev = useCallback(() => { - goToSlide((currentIndex - 1 + REVIEWS.length) % REVIEWS.length); - }, [currentIndex, goToSlide]); - - // Auto-scroll effect + const [isMobile, setIsMobile] = useState(false); + const [flippedId, setFlippedId] = useState(null); + useEffect(() => { - if (!isAutoPlaying) return; - - const interval = setInterval(() => { - if (!isTransitioning) { - setCurrentIndex((prev) => (prev + 1) % REVIEWS.length); - } - }, 5000); - - return () => clearInterval(interval); - }, [isAutoPlaying, isTransitioning]); - - const handleManualNav = (index: number) => { - goToSlide(index); - setIsAutoPlaying(false); - // Resume auto-play after 15 seconds - setTimeout(() => setIsAutoPlaying(true), 15000); - }; + const mq = window.matchMedia('(max-width: 639px)'); + const update = (e: MediaQueryListEvent | MediaQueryList) => setIsMobile(e.matches); + update(mq); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); + }, []); + + const rows = isMobile ? ROWS_MOBILE : ROWS_DESKTOP; + const handleFlip = (id: string) => setFlippedId((prev) => (prev === id ? null : id)); return (
- {/* SEO: All reviews as semantic HTML for crawlers and screen readers. - Visually hidden so the carousel UI isn't cluttered. */} -
-

LiftShift reviews from Reddit users

-
    +
    +

    LiftShift reviews from Reddit users

    +
      {REVIEWS.map((review, i) => (
    • @@ -85,92 +141,42 @@ export const ReviewsCarousel: React.FC = ({ className = ''
    • ))}
    -
    - {/* Section Header */} -
    -
    - - Community Feedback +
    +
    + + Community Feedback +
    +

    + Loved by Lifters Worldwide +

    +

    + See what the fitness community is saying about LiftShift on Reddit +

    -

    - Loved by Lifters Worldwide -

    -

    - See what the fitness community is saying about LiftShift on Reddit -

    -
    +
- {/* Image — fixed 3.5:1 container (tallest image) keeps controls stable across slides */} -
setIsAutoPlaying(false)} onMouseLeave={() => setIsAutoPlaying(true)}> -
- - - {REVIEWS[currentIndex].label} - - - + {/* Full-width marquee — breaks out of parent container, masked fade on edges */} +
+
+ {rows.map((row, i) => ( + + ))}
- - {/* Quote — concise text visible to users and crawlers */} -

- {REVIEWS[currentIndex].label.replace(/^[^:]+:\s*"(.*)"$/, '$1')} -

- - {/* Arrows — centered below the quote */} -
- - -
- - {/* Dots — below the arrows */} -
- {REVIEWS.map((_, index) => ( -
-
); }; From 819385e5ff22a29bab4b3272ee463d9294c1b340 Mon Sep 17 00:00:00 2001 From: aree6 Date: Mon, 8 Jun 2026 23:02:21 +0800 Subject: [PATCH 2/4] feat: enhance ReviewsCarousel with expanded card overlay and mobile auto-scroll functionality --- .../components/landing/ui/LandingPage.tsx | 16 +- frontend/components/landing/ui/RedditCard.tsx | 33 +- .../components/landing/ui/ReviewsCarousel.tsx | 478 ++++++++++++++++-- 3 files changed, 456 insertions(+), 71 deletions(-) diff --git a/frontend/components/landing/ui/LandingPage.tsx b/frontend/components/landing/ui/LandingPage.tsx index d43e0efb..e0a07ef8 100644 --- a/frontend/components/landing/ui/LandingPage.tsx +++ b/frontend/components/landing/ui/LandingPage.tsx @@ -157,7 +157,7 @@ export const LandingPage: React.FC = ({ onSelectPlatform, onTr
{/* ========== HERO SECTION ========== */} -
+
{/* Hero Content */}
@@ -195,6 +195,13 @@ export const LandingPage: React.FC = ({ onSelectPlatform, onTr
+ {/* ========== REVIEWS SECTION ========== */} +
+
+ +
+
+ {/* ========== WHY LIFTSHIFT SECTION ========== */}
@@ -323,13 +330,6 @@ export const LandingPage: React.FC = ({ onSelectPlatform, onTr
- {/* ========== REVIEWS SECTION ========== */} -
-
- -
-
- {/* ========== PLATFORM DOCK ========== */} diff --git a/frontend/components/landing/ui/RedditCard.tsx b/frontend/components/landing/ui/RedditCard.tsx index 54ab886f..7641d718 100644 --- a/frontend/components/landing/ui/RedditCard.tsx +++ b/frontend/components/landing/ui/RedditCard.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useEffect, useRef } from 'react'; -import { ArrowUp, ArrowDown, Reply, Share2, Award } from 'lucide-react'; +import { ArrowBigUp, Reply, Share2, Award } from 'lucide-react'; const AVATAR_COLORS = [ '#FF4500', '#0079D3', '#46D160', '#DDBD37', '#FF585B', @@ -15,15 +15,15 @@ function hashString(s: string): number { return Math.abs(h); } -function getColor(username: string): string { +export function getColor(username: string): string { return AVATAR_COLORS[hashString(username) % AVATAR_COLORS.length]; } -function getUpvotes(username: string): number { - return 10 + (hashString(username + 'up') % 140); +export function getUpvotes(username: string): number { + return 1 + (hashString(username + 'up') % 10); } -function getSubreddit(username: string, quote: string): string { +export function getSubreddit(username: string, quote: string): string { const lower = `${username} ${quote}`.toLowerCase(); if (lower.includes('hevy')) return 'Hevy'; if (lower.includes('strong')) return 'Strong'; @@ -35,11 +35,11 @@ function getSubreddit(username: string, quote: string): string { return SUBREDDITS[hashString(username) % SUBREDDITS.length]; } -function getTimeAgo(username: string): string { +export function getTimeAgo(username: string): string { return TIMES[hashString(username + 'time') % TIMES.length]; } -function SnooAvatar({ color, size = 16 }: { color: string; size?: number }) { +export function SnooAvatar({ color, size = 16 }: { color: string; size?: number }) { return ( @@ -53,10 +53,10 @@ function SnooAvatar({ color, size = 16 }: { color: string; size?: number }) { } // ── Shared card face styling ── -function cardFaceClass(isLight: boolean) { +export function cardFaceClass(isLight: boolean) { return isLight - ? 'bg-white/65 backdrop-blur-sm border-slate-200/60 shadow-sm' - : 'bg-neutral-900/50 backdrop-blur-sm border-neutral-800/40 shadow-md shadow-black/20'; + ? 'bg-white/65 backdrop-blur-sm ring-1 ring-inset ring-slate-200/60 shadow-sm' + : 'bg-neutral-900/50 backdrop-blur-sm ring-1 ring-inset ring-neutral-800/50 shadow-md shadow-black/20'; } interface RedditCardProps { @@ -92,7 +92,7 @@ export const RedditCard: React.FC = React.memo(({ username, quo return (
{ if (isFlipped) clearFlipTimer(); }} @@ -108,7 +108,7 @@ export const RedditCard: React.FC = React.memo(({ username, quo > {/* ── Front face: Reddit comment card ── */}
@@ -134,10 +134,9 @@ export const RedditCard: React.FC = React.memo(({ username, quo

- - {upvotes} - - + + {upvotes} + Reply @@ -151,7 +150,7 @@ export const RedditCard: React.FC = React.memo(({ username, quo {/* ── Back face: screenshot ── */}
with unique animation name per row. hardware-accelerated via translate3d. -// CSS :hover pauses animation-play-state → no React state toggling → zero jerk. +// ── Types ── +interface ExpandedCardState { + review: ReviewData; + id: string; + originalRect: DOMRect; + containerRect: DOMRect; +} +// ── Marquee Row ── function MarqueeRow({ direction, items, isLight, speed = 40, + expandedCardId, + isPaused, + onExpand, + isMobile, flippedId, onFlip, + isClosing, }: { direction: 'left' | 'right'; items: ReviewData[]; isLight: boolean; speed?: number; + expandedCardId: string | null; + isPaused: boolean; + onExpand: (id: string, review: ReviewData, rect: DOMRect) => void; + isMobile: boolean; flippedId: string | null; onFlip: (id: string) => void; + isClosing: boolean; }) { const uid = useRef(`mq-${Math.random().toString(36).slice(2, 8)}`).current; const from = direction === 'left' ? '0' : '-50'; - const to = direction === 'left' ? '-50' : '0'; + const to = direction === 'left' ? '-50' : '0'; const duration = `${Math.max(30, 110 - speed)}s`; - const card = (review: ReviewData, key: string) => ( -
- onFlip(key)} - /> -
- ); + // Mobile auto-scroll state + const scrollRef = useRef(null); + const isInteractingRef = useRef(false); + + useEffect(() => { + if (!isMobile) return; + const el = scrollRef.current; + if (!el) return; + let rafId = 0; + let lastTs = performance.now(); + const pxPerSec = 30; + + const tick = (ts: number) => { + const dt = (ts - lastTs) / 1000; + lastTs = ts; + if (!isInteractingRef.current) { + const max = el.scrollWidth - el.clientWidth; + if (max > 0) { + el.scrollLeft += pxPerSec * dt; + if (el.scrollLeft >= max) el.scrollLeft = 0; + } + } + rafId = requestAnimationFrame(tick); + }; + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + }, [isMobile]); + + const card = (review: ReviewData, key: string) => { + if (isMobile) { + return ( +
+ onFlip(key)} + /> +
+ ); + } + + const isHidden = + expandedCardId !== null && + (key === expandedCardId || key === `${expandedCardId}-clone`); + + const cardStyle: React.CSSProperties = isHidden + ? { opacity: 0, pointerEvents: 'none' } + : isClosing + ? { opacity: 1, transition: 'opacity 100ms ease 200ms' } + : { opacity: 1 }; + + return ( +
{ + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + onExpand(key, review, rect); + }} + > + {}} + /> +
+ ); + }; + + if (isMobile) { + return ( +
{ isInteractingRef.current = true; }} + onPointerUp={() => { isInteractingRef.current = false; }} + onPointerLeave={() => { isInteractingRef.current = false; }} + onPointerCancel={() => { isInteractingRef.current = false; }} + style={{ + WebkitOverflowScrolling: 'touch', + scrollSnapType: 'x mandatory', + scrollPaddingLeft: '0.5rem', + touchAction: 'pan-x', + }} + > +
+ {items.map((r, i) => card(r, `${r.src}-${i}`))} +
+
+ ); + } return (
@@ -101,7 +209,10 @@ function MarqueeRow({ animation-play-state: paused; } `} -
+
{items.map((r) => card(r, r.src))} {items.map((r) => card(r, `${r.src}-clone`))}
@@ -109,28 +220,274 @@ function MarqueeRow({ ); } -// ── Main component ── +// ── Expanded Card Overlay ── +const ExpandedCardOverlay: React.FC<{ + expandedCard: ExpandedCardState; + isLight: boolean; + onClose: () => void; +}> = ({ expandedCard, isLight, onClose }) => { + const { review, originalRect, containerRect } = expandedCard; + + const { targetLeft, targetTop, expandedWidth, expandedHeight } = + useMemo(() => { + const vw = window.innerWidth; + const vh = window.innerHeight; + const scaleX = (vw * 0.6) / originalRect.width; + const scaleY = (vh * 0.65) / originalRect.height; + const s = Math.max(2, Math.min(scaleX, scaleY, 3.5)); + const ew = originalRect.width * s; + const eh = originalRect.height * s; + const cx = containerRect.left + containerRect.width / 2; + const cy = containerRect.top + containerRect.height / 2; + return { + targetLeft: cx - ew / 2, + targetTop: cy - eh / 2, + expandedWidth: ew, + expandedHeight: eh, + }; + }, [originalRect, containerRect]); + + const upvotes = useMemo(() => getUpvotes(review.username), [review.username]); + const subreddit = useMemo( + () => getSubreddit(review.username, review.quote), + [review.username, review.quote], + ); + const color = useMemo(() => getColor(review.username), [review.username]); + const timeAgo = useMemo( + () => getTimeAgo(review.username), + [review.username], + ); + + const expandedFaceClass = isLight + ? 'bg-white ring-1 ring-inset ring-slate-200/60 shadow-lg' + : 'bg-neutral-900 ring-1 ring-inset ring-neutral-700/60 shadow-lg shadow-black/40'; + + return ( + <> + {/* Backdrop */} + + + {/* Expanded card */} + + {/* ── Front face: Reddit comment ── */} +
+
+ + + r/{subreddit} + + + · + + + u/{review.username} + + + · + + + {timeAgo} + +
-export const ReviewsCarousel: React.FC = ({ className = '' }) => { +

+ {review.quote.split(' / ').map((part, i) => ( + + {i > 0 &&
} + {part} +
+ ))} +

+ +
+ + + {upvotes} + + + + Reply + + + + Share + + + + Award + +
+
+ + {/* ── Back face: screenshot ── */} +
+ {`Screenshot +
+ +
+ + ); +}; + +// ── Main component ── +export const ReviewsCarousel: React.FC<{ className?: string }> = ({ + className = '', +}) => { const { mode } = useTheme(); const isLight = mode === 'light'; const [isMobile, setIsMobile] = useState(false); + const [expandedCard, setExpandedCard] = useState( + null, + ); + const [isMarqueePaused, setIsMarqueePaused] = useState(false); const [flippedId, setFlippedId] = useState(null); + const [isClosing, setIsClosing] = useState(false); + const containerRef = useRef(null); + const expandedCardRef = useRef(expandedCard); + expandedCardRef.current = expandedCard; + + // Scroll lock while expanded + useEffect(() => { + if (!expandedCard) return; + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + const preventScroll = (e: WheelEvent | TouchEvent) => e.preventDefault(); + document.addEventListener('wheel', preventScroll, { passive: false }); + document.addEventListener('touchmove', preventScroll, { passive: false }); + + return () => { + document.body.style.overflow = prevOverflow; + document.removeEventListener('wheel', preventScroll); + document.removeEventListener('touchmove', preventScroll); + }; + }, [expandedCard]); + + // Pause marquee when any card is expanded + useEffect(() => { + if (expandedCard !== null) { + setIsMarqueePaused(true); + } + }, [expandedCard]); useEffect(() => { const mq = window.matchMedia('(max-width: 639px)'); - const update = (e: MediaQueryListEvent | MediaQueryList) => setIsMobile(e.matches); + const update = (e: MediaQueryListEvent | MediaQueryList) => + setIsMobile(e.matches); update(mq); mq.addEventListener('change', update); return () => mq.removeEventListener('change', update); }, []); const rows = isMobile ? ROWS_MOBILE : ROWS_DESKTOP; - const handleFlip = (id: string) => setFlippedId((prev) => (prev === id ? null : id)); + + const handleExpand = useCallback( + (id: string, review: ReviewData, rect: DOMRect) => { + setExpandedCard((prev) => { + if (prev?.id === id) return null; + const container = containerRef.current; + if (!container) return prev; + const containerRect = container.getBoundingClientRect(); + return { review, id, originalRect: rect, containerRect }; + }); + }, + [], + ); + + const handleClose = useCallback(() => { + setIsClosing(true); + setExpandedCard(null); + }, []); + + const handleFlip = useCallback((id: string) => { + setFlippedId((prev) => (prev === id ? null : id)); + }, []); + + const handleExitComplete = useCallback(() => { + if (!expandedCardRef.current) { + setIsMarqueePaused(false); + setIsClosing(false); + } + }, []); return ( -
-
+
+ {/* Header section */} +

LiftShift reviews from Reddit users

    {REVIEWS.map((review, i) => ( @@ -143,26 +500,36 @@ export const ReviewsCarousel: React.FC = ({ className = ''
-
- - Community Feedback -
-

- Loved by Lifters Worldwide + +

+ Loved by{' '} + + Lifters + {' '} + Worldwide

-

+

See what the fitness community is saying about LiftShift on Reddit

- {/* Full-width marquee — breaks out of parent container, masked fade on edges */} -
+ {/* Full-width marquee */} +
{rows.map((row, i) => ( = ({ className = '' isLight={isLight} speed={25 + i * 5} items={row} + expandedCardId={expandedCard?.id ?? null} + isPaused={isMarqueePaused} + onExpand={handleExpand} + isMobile={isMobile} flippedId={flippedId} onFlip={handleFlip} + isClosing={isClosing} /> ))}
+ + {/* Expanded card overlay — desktop only */} + {!isMobile && ( + + {expandedCard && ( + + )} + + )}
); }; From 798fd82451d1deb3831e32cd060ae00412aec217 Mon Sep 17 00:00:00 2001 From: aree6 Date: Mon, 8 Jun 2026 23:38:54 +0800 Subject: [PATCH 3/4] feat: enhance ReviewsCarousel with mobile auto-scroll functionality and improve card styling --- .../components/landing/ui/LandingPage.tsx | 2 +- frontend/components/landing/ui/RedditCard.tsx | 10 +- .../components/landing/ui/ReviewsCarousel.tsx | 128 +++++++++++++----- 3 files changed, 102 insertions(+), 38 deletions(-) diff --git a/frontend/components/landing/ui/LandingPage.tsx b/frontend/components/landing/ui/LandingPage.tsx index e0a07ef8..eac1ae07 100644 --- a/frontend/components/landing/ui/LandingPage.tsx +++ b/frontend/components/landing/ui/LandingPage.tsx @@ -196,7 +196,7 @@ export const LandingPage: React.FC = ({ onSelectPlatform, onTr
{/* ========== REVIEWS SECTION ========== */} -
+
diff --git a/frontend/components/landing/ui/RedditCard.tsx b/frontend/components/landing/ui/RedditCard.tsx index 7641d718..a2a9fb1c 100644 --- a/frontend/components/landing/ui/RedditCard.tsx +++ b/frontend/components/landing/ui/RedditCard.tsx @@ -20,7 +20,7 @@ export function getColor(username: string): string { } export function getUpvotes(username: string): number { - return 1 + (hashString(username + 'up') % 10); + return 2 + (hashString(username + 'up') % 9); } export function getSubreddit(username: string, quote: string): string { @@ -55,8 +55,8 @@ export function SnooAvatar({ color, size = 16 }: { color: string; size?: number // ── Shared card face styling ── export function cardFaceClass(isLight: boolean) { return isLight - ? 'bg-white/65 backdrop-blur-sm ring-1 ring-inset ring-slate-200/60 shadow-sm' - : 'bg-neutral-900/50 backdrop-blur-sm ring-1 ring-inset ring-neutral-800/50 shadow-md shadow-black/20'; + ? 'bg-white/65 backdrop-blur-sm shadow-sm' + : 'bg-neutral-900/50 backdrop-blur-sm shadow-sm shadow-black/20'; } interface RedditCardProps { @@ -108,7 +108,7 @@ export const RedditCard: React.FC = React.memo(({ username, quo > {/* ── Front face: Reddit comment card ── */}
@@ -150,7 +150,7 @@ export const RedditCard: React.FC = React.memo(({ username, quo {/* ── Back face: screenshot ── */}
(null); const isInteractingRef = useRef(false); + const accumulatedRef = useRef(0); + const lastTsRef = useRef(0); + const resumeTimerRef = useRef | null>(null); + const handleUpRef = useRef<(() => void) | null>(null); + + const syncAccumulated = () => { + const el = scrollRef.current; + if (!el) return; + const halfWidth = el.scrollWidth / 2; + if (halfWidth <= 0) return; + accumulatedRef.current = + ((el.scrollLeft % halfWidth) + halfWidth) % halfWidth; + lastTsRef.current = performance.now(); + }; + + const onStartInteraction = () => { + isInteractingRef.current = true; + if (resumeTimerRef.current) { + clearTimeout(resumeTimerRef.current); + resumeTimerRef.current = null; + } + + if (handleUpRef.current) { + document.removeEventListener('pointerup', handleUpRef.current); + document.removeEventListener('pointercancel', handleUpRef.current); + } + + const handleUp = () => { + document.removeEventListener('pointerup', handleUp); + document.removeEventListener('pointercancel', handleUp); + handleUpRef.current = null; + resumeTimerRef.current = setTimeout(() => { + isInteractingRef.current = false; + syncAccumulated(); + resumeTimerRef.current = null; + }, 1000); + }; + handleUpRef.current = handleUp; + document.addEventListener('pointerup', handleUp); + document.addEventListener('pointercancel', handleUp); + }; + + useEffect(() => { + return () => { + if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current); + if (handleUpRef.current) { + document.removeEventListener('pointerup', handleUpRef.current); + document.removeEventListener('pointercancel', handleUpRef.current); + } + }; + }, []); useEffect(() => { if (!isMobile) return; const el = scrollRef.current; if (!el) return; let rafId = 0; - let lastTs = performance.now(); + lastTsRef.current = performance.now(); const pxPerSec = 30; + const dir = direction === 'left' ? 1 : -1; + + const halfWidth = el.scrollWidth / 2; + if (halfWidth > 0 && dir === -1) { + accumulatedRef.current = halfWidth; + } + el.scrollLeft = accumulatedRef.current; const tick = (ts: number) => { - const dt = (ts - lastTs) / 1000; - lastTs = ts; if (!isInteractingRef.current) { - const max = el.scrollWidth - el.clientWidth; - if (max > 0) { - el.scrollLeft += pxPerSec * dt; - if (el.scrollLeft >= max) el.scrollLeft = 0; + const hw = el.scrollWidth / 2; + if (hw > 0) { + const dt = (ts - lastTsRef.current) / 1000; + lastTsRef.current = ts; + accumulatedRef.current += dir * pxPerSec * dt; + if (accumulatedRef.current >= hw) { + accumulatedRef.current -= hw; + } else if (accumulatedRef.current < 0) { + accumulatedRef.current += hw; + } + el.scrollLeft = accumulatedRef.current; } } rafId = requestAnimationFrame(tick); }; rafId = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafId); - }, [isMobile]); + }, [isMobile, direction]); const card = (review: ReviewData, key: string) => { if (isMobile) { @@ -170,22 +234,20 @@ function MarqueeRow({ if (isMobile) { return ( -
{ isInteractingRef.current = true; }} - onPointerUp={() => { isInteractingRef.current = false; }} - onPointerLeave={() => { isInteractingRef.current = false; }} - onPointerCancel={() => { isInteractingRef.current = false; }} - style={{ - WebkitOverflowScrolling: 'touch', - scrollSnapType: 'x mandatory', - scrollPaddingLeft: '0.5rem', - touchAction: 'pan-x', - }} - > -
- {items.map((r, i) => card(r, `${r.src}-${i}`))} +
+
+
+ {items.map((r, i) => card(r, `${r.src}-${i}`))} + {items.map((r, i) => card(r, `${r.src}-clone-${i}`))} +
); @@ -234,7 +296,7 @@ const ExpandedCardOverlay: React.FC<{ const vh = window.innerHeight; const scaleX = (vw * 0.6) / originalRect.width; const scaleY = (vh * 0.65) / originalRect.height; - const s = Math.max(2, Math.min(scaleX, scaleY, 3.5)); + const s = Math.max(1.6, Math.min(scaleX, scaleY, 2.5)); const ew = originalRect.width * s; const eh = originalRect.height * s; const cx = containerRect.left + containerRect.width / 2; @@ -259,10 +321,10 @@ const ExpandedCardOverlay: React.FC<{ ); const expandedFaceClass = isLight - ? 'bg-white ring-1 ring-inset ring-slate-200/60 shadow-lg' - : 'bg-neutral-900 ring-1 ring-inset ring-neutral-700/60 shadow-lg shadow-black/40'; + ? 'bg-white shadow-lg' + : 'bg-neutral-900 shadow-lg shadow-black/40'; - return ( + return createPortal( <> {/* Backdrop */} @@ -308,11 +370,12 @@ const ExpandedCardOverlay: React.FC<{ style={{ perspective: '1200px', transformStyle: 'preserve-3d', + pointerEvents: 'none', }} > {/* ── Front face: Reddit comment ── */}
@@ -380,7 +443,7 @@ const ExpandedCardOverlay: React.FC<{ {/* ── Back face: screenshot ── */}
- + , + document.body, ); }; From 0264b9c1485a75a989e70850b91a39ac37fbc84f Mon Sep 17 00:00:00 2001 From: aree6 Date: Tue, 9 Jun 2026 00:11:23 +0800 Subject: [PATCH 4/4] feat: improve button styling in LandingPage and enhance RedditCard and ReviewsCarousel with lazy loading and background adjustments --- frontend/components/landing/ui/LandingPage.tsx | 2 +- frontend/components/landing/ui/RedditCard.tsx | 6 ++++-- frontend/components/landing/ui/ReviewsCarousel.tsx | 9 +++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/frontend/components/landing/ui/LandingPage.tsx b/frontend/components/landing/ui/LandingPage.tsx index eac1ae07..1100ea4d 100644 --- a/frontend/components/landing/ui/LandingPage.tsx +++ b/frontend/components/landing/ui/LandingPage.tsx @@ -185,7 +185,7 @@ export const LandingPage: React.FC = ({ onSelectPlatform, onTr
diff --git a/frontend/components/landing/ui/RedditCard.tsx b/frontend/components/landing/ui/RedditCard.tsx index a2a9fb1c..038061ca 100644 --- a/frontend/components/landing/ui/RedditCard.tsx +++ b/frontend/components/landing/ui/RedditCard.tsx @@ -55,8 +55,8 @@ export function SnooAvatar({ color, size = 16 }: { color: string; size?: number // ── Shared card face styling ── export function cardFaceClass(isLight: boolean) { return isLight - ? 'bg-white/65 backdrop-blur-sm shadow-sm' - : 'bg-neutral-900/50 backdrop-blur-sm shadow-sm shadow-black/20'; + ? 'bg-white/35' + : 'bg-black/35'; } interface RedditCardProps { @@ -161,6 +161,8 @@ export const RedditCard: React.FC = React.memo(({ username, quo alt={`Screenshot of ${username}'s Reddit comment`} className="w-full h-full object-contain p-2" draggable={false} + loading="lazy" + decoding="async" />
diff --git a/frontend/components/landing/ui/ReviewsCarousel.tsx b/frontend/components/landing/ui/ReviewsCarousel.tsx index 7e9172b9..db63d819 100644 --- a/frontend/components/landing/ui/ReviewsCarousel.tsx +++ b/frontend/components/landing/ui/ReviewsCarousel.tsx @@ -264,7 +264,6 @@ function MarqueeRow({ display: flex; width: fit-content; animation: ${uid} ${duration} linear infinite; - will-change: transform; backface-visibility: hidden; } .${uid}:hover { @@ -321,8 +320,8 @@ const ExpandedCardOverlay: React.FC<{ ); const expandedFaceClass = isLight - ? 'bg-white shadow-lg' - : 'bg-neutral-900 shadow-lg shadow-black/40'; + ? 'bg-white/35 shadow-lg' + : 'bg-black/35 shadow-lg shadow-black/40'; return createPortal( <> @@ -332,7 +331,7 @@ const ExpandedCardOverlay: React.FC<{ animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }} - className="fixed inset-0 z-[100] bg-black/60 backdrop-blur-md" + className="fixed inset-0 z-[100] bg-black/70" onClick={onClose} /> @@ -454,6 +453,8 @@ const ExpandedCardOverlay: React.FC<{ alt={`Screenshot of ${review.username}'s Reddit comment`} className="w-full h-full object-contain p-4" draggable={false} + loading="lazy" + decoding="async" />