diff --git a/frontend/components/landing/ui/LandingPage.tsx b/frontend/components/landing/ui/LandingPage.tsx index 047a83aa..1100ea4d 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 */}
@@ -185,7 +185,7 @@ export const LandingPage: React.FC = ({ onSelectPlatform, onTr
@@ -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 new file mode 100644 index 00000000..038061ca --- /dev/null +++ b/frontend/components/landing/ui/RedditCard.tsx @@ -0,0 +1,175 @@ +import React, { useMemo, useEffect, useRef } from 'react'; +import { ArrowBigUp, 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); +} + +export function getColor(username: string): string { + return AVATAR_COLORS[hashString(username) % AVATAR_COLORS.length]; +} + +export function getUpvotes(username: string): number { + return 2 + (hashString(username + 'up') % 9); +} + +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'; + 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]; +} + +export function getTimeAgo(username: string): string { + return TIMES[hashString(username + 'time') % TIMES.length]; +} + +export function SnooAvatar({ color, size = 16 }: { color: string; size?: number }) { + return ( + + + + + + + + + ); +} + +// ── Shared card face styling ── +export function cardFaceClass(isLight: boolean) { + return isLight + ? 'bg-white/35' + : 'bg-black/35'; +} + +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..db63d819 100644 --- a/frontend/components/landing/ui/ReviewsCarousel.tsx +++ b/frontend/components/landing/ui/ReviewsCarousel.tsx @@ -1,82 +1,560 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { ChevronLeft, ChevronRight, Quote } from 'lucide-react'; +import React, { useRef, useState, useEffect, useCallback, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { Quote, ArrowBigUp, ArrowBigDown, Reply, Share2, Award } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { useTheme } from '../../theme/ThemeProvider'; import { FANCY_FONT } from '../../../utils/ui/uiConstants'; import { assetPath } from '../../../constants'; +import { + RedditCard, + SnooAvatar, + getUpvotes, + getSubreddit, + getColor, + getTimeAgo, +} from './RedditCard'; -interface ReviewsCarouselProps { - className?: string; +// ── Data ── +interface ReviewData { + src: string; + label: string; + username: string; + quote: 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"' }, -]; - -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 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)]; + +// ── 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 duration = `${Math.max(30, 110 - speed)}s`; + + // Mobile auto-scroll state + const scrollRef = useRef(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 (!isAutoPlaying) return; - - const interval = setInterval(() => { - if (!isTransitioning) { - setCurrentIndex((prev) => (prev + 1) % REVIEWS.length); + if (!isMobile) return; + const el = scrollRef.current; + if (!el) return; + let rafId = 0; + 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) => { + if (!isInteractingRef.current) { + 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; + } } - }, 5000); + rafId = requestAnimationFrame(tick); + }; + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + }, [isMobile, direction]); + + const card = (review: ReviewData, key: string) => { + if (isMobile) { + return ( +
+ onFlip(key)} + /> +
+ ); + } + + const isHidden = + expandedCardId !== null && + (key === expandedCardId || key === `${expandedCardId}-clone`); - return () => clearInterval(interval); - }, [isAutoPlaying, isTransitioning]); + const cardStyle: React.CSSProperties = isHidden + ? { opacity: 0, pointerEvents: 'none' } + : isClosing + ? { opacity: 1, transition: 'opacity 100ms ease 200ms' } + : { opacity: 1 }; - const handleManualNav = (index: number) => { - goToSlide(index); - setIsAutoPlaying(false); - // Resume auto-play after 15 seconds - setTimeout(() => setIsAutoPlaying(true), 15000); + return ( +
{ + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + onExpand(key, review, rect); + }} + > + {}} + /> +
+ ); }; + if (isMobile) { + return ( +
+
+
+ {items.map((r, i) => card(r, `${r.src}-${i}`))} + {items.map((r, i) => card(r, `${r.src}-clone-${i}`))} +
+
+
+ ); + } + + return ( +
+ +
+ {items.map((r) => card(r, r.src))} + {items.map((r) => card(r, `${r.src}-clone`))} +
+
+ ); +} + +// ── 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(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; + 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/35 shadow-lg' + : 'bg-black/35 shadow-lg shadow-black/40'; + + return createPortal( + <> + {/* Backdrop */} + + + {/* Expanded card */} + + {/* ── Front face: Reddit comment ── */} +
+
+ + + r/{subreddit} + + + · + + + u/{review.username} + + + · + + + {timeAgo} + +
+ +

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

+ +
+ + + {upvotes} + + + + Reply + + + + Share + + + + Award + +
+
+ + {/* ── Back face: screenshot ── */} +
+ {`Screenshot +
+ +
+ , + document.body, + ); +}; + +// ── 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); + update(mq); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); + }, []); + + const rows = isMobile ? ROWS_MOBILE : ROWS_DESKTOP; + + 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 ( -
- {/* 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

-
    +
    + {/* Header section */} +
    +

    LiftShift reviews from Reddit users

    +
      {REVIEWS.map((review, i) => (
    • @@ -85,92 +563,71 @@ export const ReviewsCarousel: React.FC = ({ className = ''
    • ))}
    -
    - {/* Section Header */} -
    -
    - - 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 */} +
+
+ {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) => ( -
-
- + {/* Expanded card overlay — desktop only */} + {!isMobile && ( + + {expandedCard && ( + + )} + + )}
); };