cellex-web / src /app /page.tsx
eeshaAI
feat: Migrate frontend from static HTML to Next.js 16 App Router
a09d2a4
Raw
History Blame Contribute Delete
40.4 kB
'use client';
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { api, formatPrice, type Product } from '@/lib/api';
import { motion } from 'framer-motion';
import { Search, Heart, MessageCircle, Send, Bookmark,
Store, ChevronRight, Play,
CheckCircle, Bell, User, Sparkles, Home as HomeIcon, Users } from 'lucide-react';
import Link from 'next/link';
import { useAuth } from '@/components/auth-provider';
import { useToast } from '@/hooks/use-toast';
import { useOptimisticUI } from '@/components/optimistic-ui';
import { PageSkeleton } from '@/components/page-skeleton';
interface FeedPost {
type: 'video' | 'product';
id: string;
videoId?: number;
productId?: number;
sellerId?: string;
sellerSlug?: string;
sellerName: string;
sellerImage?: string;
mediaUrl: string;
caption: string;
likes: number;
views?: number;
comments: number;
product?: Product;
soldCount?: number;
createdAt?: string;
isLive?: boolean;
verified?: boolean;
liked?: boolean;
}
export default function HomePage() {
const router = useRouter();
const { user, isSeller, unreadMessages } = useAuth();
const { toast } = useToast();
const { burst } = useOptimisticUI();
const [feed, setFeed] = useState<FeedPost[]>([]);
const [stories, setStories] = useState<any[]>([]);
const [sellers, setSellers] = useState<any[]>([]);
const [liveSessions, setLiveSessions] = useState<any[]>([]);
const [shorts, setShorts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [likedPosts, setLikedPosts] = useState<Set<string>>(new Set());
const viewedPosts = useRef<Set<string>>(new Set());
const [savedPosts, setSavedPosts] = useState<Set<string>>(new Set());
const [following, setFollowing] = useState<Set<string>>(new Set());
const searchBarRef = useRef<HTMLDivElement>(null);
// IntersectionObserver for hiding GlobalSpotlight FAB when top search is visible
useEffect(() => {
const el = searchBarRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
window.dispatchEvent(
new CustomEvent('searchbar-visibility', {
detail: { visible: entry.isIntersecting },
})
);
},
{ threshold: 0 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
// Track views — when a post enters the viewport, send a view feedback to Gorse
const trackView = (postId: string, videoId?: number, productId?: number) => {
if (viewedPosts.current.has(postId)) return;
viewedPosts.current.add(postId);
const itemId = videoId ? String(videoId) : productId ? String(productId) : postId;
api.feedback(itemId, 'view', 0.3);
};
// Note: unreadMessages is fetched by AuthProvider (shared across all pages
// via useAuth context) and polled every 30 seconds. No duplicate fetch here.
useEffect(() => {
(async () => {
try {
// Use REAL AI-driven feed (Gorse → Chroma personalization → real trending)
// No more hardcoded api.products.home() — let the recommender drive the feed.
const [vidResp, recommendResp, storiesResp, liveResp, sellersResp] = await Promise.all([
api.videos.feed(20),
api.recommend.home(40),
api.stories.activeBar().catch(() => ({ success: false })),
api.live.list('live').catch(() => ({ success: false })),
api.social.discover(60).catch(() => ({ success: false })),
]);
const sellerMap = new Map<string, { name: string; image?: string; slug?: string }>();
if (sellersResp.success) {
const sellersList = sellersResp.sellers || [];
setSellers(sellersList);
sellersList.forEach((s: any) => {
sellerMap.set(s.id, { name: s.business_name || s.farm_name || 'Seller', image: s.profile_image, slug: s.slug });
});
}
const posts: FeedPost[] = [];
if (vidResp.success) {
(vidResp.videos || []).forEach((v: any) => {
const seller = v.seller || {};
// If the video seller doesn't have a slug, look it up via sellerMap
const sellerInfo = seller.id ? sellerMap.get(seller.id) : null;
posts.push({
type: 'video',
id: `vid-${v.id}`,
videoId: v.id,
productId: v.product?.id,
liked: v.liked || false,
sellerId: seller.id,
sellerSlug: seller.slug || sellerInfo?.slug,
sellerName: seller.business_name || 'Seller',
sellerImage: seller.profile_image,
mediaUrl: v.video_url || '',
caption: v.caption || '',
// REAL counts from DB — no more Math.floor(views/20) fake math
likes: v.likes_count || 0,
views: v.views_count || 0,
comments: v.comments_count || 0,
product: v.product,
soldCount: v.product?.units_sold,
createdAt: v.created_at,
verified: true,
});
});
}
// AI-driven product feed — posts come from the recommender (Gorse/Chroma/trending).
// Each product's likes/views/comments are REAL numbers from Supabase, never faked.
if (recommendResp.success) {
// The recommend API returns a flat `products` array (regardless of source).
// No more flashDeals/trending/newArrivals hardcoded buckets.
const allProducts: Product[] = recommendResp.products || [];
allProducts.forEach((p: any) => {
const sellerInfo = p.seller_id ? sellerMap.get(p.seller_id) : null;
posts.push({
type: 'product',
id: `prod-${p.id}`,
productId: p.id,
sellerId: p.seller_id,
sellerSlug: sellerInfo?.slug,
sellerName: sellerInfo?.name || 'Cellex Seller',
sellerImage: sellerInfo?.image,
mediaUrl: p.image_url || '',
caption: p.name,
// REAL counts — no more Math.floor(units_sold * 0.3) fake math.
// If a count is not tracked in the DB yet, show 0 (honest) instead of a lie.
likes: p.likes_count || 0,
views: p._views_count || p.views_count || 0,
comments: p.comments_count || p.review_count || 0,
product: p,
soldCount: p.units_sold,
verified: true,
});
});
}
// Interleave videos and products
const videoPosts = posts.filter(p => p.type === 'video');
const productPosts = posts.filter(p => p.type === 'product');
const interleaved: FeedPost[] = [];
const maxLen = Math.max(videoPosts.length, productPosts.length);
for (let i = 0; i < maxLen; i++) {
if (i < videoPosts.length) interleaved.push(videoPosts[i]);
if (i < productPosts.length) interleaved.push(productPosts[i]);
}
setFeed(interleaved);
// Initialize liked posts from API response
const initialLiked = new Set<string>();
interleaved.forEach(p => {
if (p.liked) initialLiked.add(p.id);
});
setLikedPosts(initialLiked);
if (storiesResp.success) setStories(storiesResp.stories || []);
if (liveResp.success) setLiveSessions(liveResp.sessions || []);
// Store video posts as Shorts (for the Shorts section).
// These are the same videos that appear in the feed, but presented
// as vertical 9:16 cards in a horizontal scroll (YouTube Shorts style).
if (vidResp.success) {
const videoShorts = (vidResp.videos || []).slice(0, 10).map((v: any) => ({
id: v.id,
videoUrl: v.video_url || '',
caption: v.caption || '',
views: v.views_count || 0,
likes: v.likes_count || 0,
seller: v.seller || {},
product: v.product,
createdAt: v.created_at,
}));
setShorts(videoShorts);
}
} catch (e) {
console.error('Feed load error:', e);
} finally {
setLoading(false);
}
})();
}, []);
const toggleLike = (postId: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!user) { router.push('/login'); return; }
const post = feed.find(p => p.id === postId);
if (!post) return;
const isLiking = !likedPosts.has(postId);
const newLiked = new Set(likedPosts);
if (isLiking) {
newLiked.add(postId);
burst(e.clientX, e.clientY, 'heart');
} else {
newLiked.delete(postId);
}
setLikedPosts(newLiked);
// Persist video likes to the real product_video_likes table via the videos edge function
if (post.videoId) {
if (isLiking) api.videos.like(post.videoId);
else api.videos.unlike(post.videoId);
}
// Send REAL feedback (persists to product_view_log/buyers_wishlist for products,
// fires to Gorse for both products and videos)
const itemId = post.videoId ? String(post.videoId) : post.productId ? String(post.productId) : postId;
api.feedback(itemId, isLiking ? 'like' : 'unlike', isLiking ? 1 : 0);
};
const toggleSave = (postId: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const post = feed.find(p => p.id === postId);
if (!post) return;
if (!user) { router.push('/login'); return; }
const isSaving = !savedPosts.has(postId);
const newSaved = new Set(savedPosts);
if (isSaving) {
newSaved.add(postId);
toast({ title: 'Saved!' });
} else {
newSaved.delete(postId);
}
setSavedPosts(newSaved);
// REAL save: persist to buyers_wishlist via the feedback API
// (which writes a real row to Supabase + fires Gorse feedback in background)
if (post.productId) {
api.feedback(String(post.productId), isSaving ? 'save' : 'unsave', isSaving ? 1 : 0, { page: 'feed' });
} else if (post.videoId) {
// Video saves — only Gorse feedback (no dedicated table yet)
api.feedback(String(post.videoId), isSaving ? 'save' : 'unsave', isSaving ? 1 : 0, { page: 'feed' });
}
};
const toggleFollow = (sellerId: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!user) { router.push('/login'); return; }
const newFollowing = new Set(following);
if (newFollowing.has(sellerId)) {
newFollowing.delete(sellerId);
api.social.unfollow(sellerId);
} else {
newFollowing.add(sellerId);
api.social.follow(sellerId);
}
setFollowing(newFollowing);
};
const addToCart = (product: Product, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!user) { router.push('/login'); return; }
api.cart.add(product.id, 1);
burst(e.clientX, e.clientY, 'check');
toast({ title: 'Added to cart!', description: product.name });
// Send feedback to Gorse (strong signal — intent to buy)
api.feedback(String(product.id), 'click', 1.5, { page: 'feed' });
};
if (loading) {
return <PageSkeleton variant="home" />;
}
return (
<div className="ig-container min-h-screen ig-topbar-offset">
{/* Top bar — IG-style: logo left, search center, icons right */}
<div
ref={searchBarRef}
className="fx-topbar ig-topbar"
>
{/* Logo */}
<Link href="/" className="shrink-0">
<span className="ig-logo">Cellex</span>
</Link>
{/* Search — desktop only (pill button).
Mobile users access Smart Search via the Explore/Categories page
search bar, or via Cmd+K on physical keyboards. */}
<button
onClick={() => window.dispatchEvent(new CustomEvent('open-spotlight'))}
className="hidden sm:flex flex-1 max-w-[260px] mx-auto items-center ig-search-input hover:bg-white/10 transition-colors"
style={{ background: 'rgba(255,255,255,0.05)', border: 'none', borderRadius: '8px', padding: '8px 16px' }}
aria-label="Search"
>
<Search className="w-4 h-4 text-slate-400 mr-2" />
<span className="text-sm text-slate-400 text-left flex-1">Search</span>
</button>
{/* Spacer on mobile (search hidden) */}
<div className="flex-1 sm:hidden" />
{/* RIGHT SIDE — role-dependent.
Badges are FUNCTIONAL: only show when there's actual unread data. */}
{isSeller ? (
<div className="shrink-0 flex items-center gap-1">
<Link href="/messenger" className="ig-icon-btn relative" aria-label="Messages">
<Send className="w-6 h-6" />
{user && unreadMessages > 0 && (
<span className="absolute top-1.5 right-1.5 w-4 h-4 bg-red-500 rounded-full border-2 border-white flex items-center justify-center">
<span className="text-white text-[9px] font-bold leading-none">{unreadMessages > 9 ? '9+' : unreadMessages}</span>
</span>
)}
</Link>
<Link href="/notifications" className="ig-icon-btn" aria-label="Notifications">
<Bell className="w-6 h-6" />
</Link>
<Link href="/seller-dashboard" className="ig-icon-btn" aria-label="My Store">
<User className="w-6 h-6" />
</Link>
</div>
) : (
/* Buyer: Notifications + Account icons in header.
Account links to /profile (personal profile).
Mobile nav no longer has Account — it has Shorts instead. */
<div className="shrink-0 flex items-center gap-1">
<Link href="/notifications" className="ig-icon-btn" aria-label="Notifications">
<Bell className="w-6 h-6" />
</Link>
<Link href="/profile" className="ig-icon-btn relative" aria-label="Account">
<User className="w-6 h-6" />
{user && (
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-green-500 rounded-full border-2 border-white" />
)}
</Link>
</div>
)}
</div>
{/* Stories section — IG-style horizontal scroll with gradient rings */}
{stories.length > 0 && (
<div className="ig-hero-bg border-b border-white/5">
<div className="flex gap-4 px-3 py-3 overflow-x-auto no-scrollbar">
{stories.slice(0, 12).map((s: any, i: number) => {
const storyHref = s.slug ? `/${s.slug}` : `/seller-profile?id=${s.seller_id || ''}`;
return (
<Link key={i} href={storyHref} className="shrink-0 flex flex-col items-center gap-1">
<div className="ig-story-ring" style={{ width: 56, height: 56 }}>
<div className="w-full h-full rounded-full border-2 border-white overflow-hidden bg-white/5">
{s.profile_image ? (
<img src={s.profile_image} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<Store className="w-5 h-5 text-slate-600" />
</div>
)}
</div>
</div>
<span className="text-[10px] text-slate-300 max-w-[60px] truncate">{s.business_name || 'Seller'}</span>
</Link>
);
})}
</div>
</div>
)}
{/* Live Auctions section — eBay-style horizontal scroll of live seller cards.
Replaces the old small "LIVE NOW" pill. Each card shows the seller,
their live title, viewer count, and a Watch Live CTA. */}
{liveSessions.length > 0 && (
<LiveAuctionsSection sessions={liveSessions} />
)}
{/* Shorts section — YouTube Shorts / IG Reels style horizontal scroll.
Vertical 9:16 video thumbnails with caption + views. Appears above
the feed so users can discover video content quickly. */}
{shorts.length > 0 && (
<ShortsSection shorts={shorts} />
)}
{/* Feed — IG-style.
Every 3 feed posts, insert a horizontal "Suggested Sellers" carousel.
The carousel shows 3 seller cards followed by a "See all" card that
links to /sellers. Sellers are rotated so each carousel shows a
different batch. */}
<div>
{feed.map((post, index) => {
// Insert a seller carousel AFTER every 3rd post (index 2, 5, 8, ...)
const showSellers = sellers.length > 0 && (index + 1) % 3 === 0;
// Rotate the seller batch: carousel 0 shows sellers[0..2], carousel 1 shows sellers[3..5], etc.
const carouselIndex = Math.floor((index + 1) / 3) - 1;
const sellerBatch = sellers.slice(carouselIndex * 3, carouselIndex * 3 + 3);
return (
<div key={post.id}>
<FeedPostCard
post={post}
index={index}
liked={likedPosts.has(post.id)}
saved={savedPosts.has(post.id)}
isFollowing={post.sellerId ? following.has(post.sellerId) : false}
onLike={(e) => toggleLike(post.id, e)}
onSave={(e) => toggleSave(post.id, e)}
onFollow={(e) => post.sellerId && toggleFollow(post.sellerId, e)}
onAddToCart={(e) => post.product && addToCart(post.product, e)}
trackView={trackView}
/>
{showSellers && sellerBatch.length > 0 && (
<SuggestedSellersCarousel
sellers={sellerBatch}
carouselIndex={carouselIndex}
following={following}
onFollow={(sellerId, e) => toggleFollow(sellerId, e)}
/>
)}
</div>
);
})}
</div>
{/* End of feed — IG-style */}
<div className="text-center py-12 px-4">
<div className="w-14 h-14 rounded-full border-2 border-white/15 flex items-center justify-center mx-auto mb-3">
<Sparkles className="w-6 h-6 text-slate-500" />
</div>
<p className="text-sm font-semibold text-slate-300">You're all caught up</p>
<p className="text-xs text-slate-500 mt-1">You've seen all new posts from the last 3 days.</p>
</div>
</div>
);
}
function FeedPostCard({
post, index, liked, saved, isFollowing, onLike, onSave, onFollow, onAddToCart, trackView
}: {
post: FeedPost;
index: number;
liked: boolean;
saved: boolean;
isFollowing: boolean;
onLike: (e: React.MouseEvent) => void;
onSave: (e: React.MouseEvent) => void;
onFollow: (e: React.MouseEvent) => void;
onAddToCart: (e: React.MouseEvent) => void;
trackView: (postId: string, videoId?: number, productId?: number) => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
setInView(entry.isIntersecting);
if (entry.isIntersecting) {
trackView(post.id, post.videoId, post.productId);
}
},
{ threshold: 0.5 }
);
if (videoRef.current) observer.observe(videoRef.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (videoRef.current) {
if (inView) videoRef.current.play().catch(() => {});
else videoRef.current.pause();
}
}, [inView]);
const isVideo = post.type === 'video';
const likeCount = post.likes + (liked ? 1 : 0);
const fomoText = post.soldCount && post.soldCount > 5
? `${post.soldCount > 1000 ? `${(post.soldCount / 1000).toFixed(1)}k` : post.soldCount} bought this`
: post.views && post.views > 50
? `${formatCount(post.views)} viewing now`
: post.soldCount && post.soldCount > 0
? `${post.soldCount} bought this`
: null;
return (
<motion.article
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
className="fx-card ig-card ig-card-spaced"
>
{/* Seller header — IG-style: avatar + username + verified + Follow */}
<div className="flex items-center gap-3 px-3 py-2.5">
<Link href={post.sellerSlug ? `/${post.sellerSlug}` : (post.sellerId ? `/seller-profile?id=${post.sellerId}` : '#')}>
<div className="w-8 h-8 rounded-full overflow-hidden bg-white/10 shrink-0">
{post.sellerImage ? (
<img src={post.sellerImage} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center bg-white/10">
<span className="text-white text-xs font-bold">{post.sellerName.charAt(0)}</span>
</div>
)}
</div>
</Link>
<div className="flex-1 min-w-0 flex items-center gap-1">
<Link href={post.sellerSlug ? `/${post.sellerSlug}` : (post.sellerId ? `/seller-profile?id=${post.sellerId}` : '#')} className="text-sm font-semibold text-white hover:opacity-70 transition-opacity truncate">
{post.sellerName}
</Link>
{post.verified && (
<CheckCircle className="w-3 h-3 text-sky-500 fill-sky-500 stroke-white shrink-0" />
)}
{post.createdAt && (
<>
<span className="text-slate-500 text-xs"></span>
<span className="text-xs text-slate-400">{timeAgo(post.createdAt)}</span>
</>
)}
</div>
{post.sellerId && !isFollowing && (
<button
onClick={onFollow}
className="text-xs font-semibold text-sky-500 hover:text-sky-700 transition-colors"
>
Follow
</button>
)}
</div>
{/* Media — IG-style: square, full-bleed, with hover zoom on images */}
<div className="ig-media ig-img-zoom">
{isVideo ? (
<Link href="/videos" className="block w-full h-full relative">
<video
ref={videoRef}
src={post.mediaUrl}
muted
loop
playsInline
className="w-full h-full object-cover"
/>
<div className="absolute top-3 right-3 bg-black/70 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<Play className="w-3 h-3 text-white fill-white" />
<span className="text-white text-[10px] font-semibold">Video</span>
</div>
</Link>
) : (
<Link href={post.product ? `/product?id=${post.product.id}` : '#'} className="block w-full h-full">
<img src={post.mediaUrl} alt={post.caption} className="w-full h-full object-cover" loading="lazy" />
</Link>
)}
{/* FOMO badge — top-left, subtle */}
{fomoText && (
<div className="absolute top-3 left-3 bg-black/70 backdrop-blur-sm text-white text-xs font-semibold px-2.5 py-1 rounded-full">
{fomoText}
</div>
)}
</div>
{/* Action bar — IG-style: 24px icons, no labels, gap 16px */}
<div className="ig-action-bar">
<button onClick={onLike} aria-label="Like">
<motion.div whileTap={{ scale: 1.2 }}>
<Heart className={`w-7 h-7 transition-colors ${liked ? 'fill-red-500 text-red-500' : 'text-white'}`} strokeWidth={1.5} />
</motion.div>
</button>
<button aria-label="Comment">
<MessageCircle className="w-7 h-7 text-white" strokeWidth={1.5} />
</button>
<button aria-label="Share">
<Send className="w-7 h-7 text-white" strokeWidth={1.5} />
</button>
<button onClick={onSave} className="ml-auto" aria-label="Save">
<Bookmark className={`w-7 h-7 transition-colors ${saved ? 'fill-indigo-600 text-white' : 'text-white'}`} strokeWidth={1.5} />
</button>
</div>
{/* Likes count — IG-style bold */}
<div className="ig-likes">
{formatCount(likeCount)} likes
</div>
{/* Caption — IG-style: bold username + text */}
<div className="ig-caption">
<span className="username">{post.sellerName}</span>
{post.caption}
{post.product?.category && (
<span className="text-sky-500"> #{post.product.category.toLowerCase().replace(/\s+/g, '')}</span>
)}
</div>
{/* Comments link */}
{post.comments > 0 && (
<div className="ig-comments-link">
View all {formatCount(post.comments)} comments
</div>
)}
{/* Timestamp */}
{post.createdAt && (
<div className="ig-timestamp">
{timeAgo(post.createdAt)} AGO
</div>
)}
{/* Product CTA — IG-style shoppable tag at bottom */}
{post.product && (
<Link
href={`/product?id=${post.product.id}`}
className="block mx-3 mb-3 bg-white/5 border border-white/5 rounded-xl p-2.5 flex items-center gap-3 hover:bg-white/5 transition-colors"
>
<div className="w-11 h-11 rounded-md overflow-hidden bg-white/5 shrink-0">
{post.product.image_url && (
<img src={post.product.image_url} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-white truncate">{post.product.name}</div>
<div className="text-sm font-bold text-white">{formatPrice(post.product.price)}</div>
</div>
<button
onClick={onAddToCart}
className="bg-indigo-600 text-white text-xs font-semibold px-3 py-2 rounded-md hover:bg-white/10 transition-colors shrink-0 active:scale-95"
>
Add to cart
</button>
</Link>
)}
</motion.article>
);
}
function timeAgo(iso?: string): string {
if (!iso) return '';
const d = new Date(iso);
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return 'JUST NOW';
if (diff < 3600) return Math.floor(diff / 60) + 'M';
if (diff < 86400) return Math.floor(diff / 3600) + 'H';
if (diff < 604800) return Math.floor(diff / 86400) + 'D';
return d.toLocaleDateString();
}
function formatCount(n: number): string {
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(n);
}
/**
* Format the duration a live session has been running.
* e.g. "5m", "1h 23m", "2h"
*/
function formatLiveDuration(startedAt: string): string {
const start = new Date(startedAt).getTime();
const diff = Date.now() - start;
if (diff < 0) return '0m';
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours}h ${minutes % 60}m`;
return `${minutes}m`;
}
/**
* LiveAuctionsSection — eBay-style horizontal scroll of live seller cards.
*
* Each card shows:
* - Seller avatar with pulsing red LIVE ring
* - Seller name
* - Live session title (e.g. "Friday tech deals")
* - Viewer count with eye icon
* - "Watch Live" CTA button
*
* The section has a dark gradient background to make the LIVE cards pop,
* similar to eBay's live auction section.
*/
function LiveAuctionsSection({ sessions }: { sessions: any[] }) {
return (
<section className="fx-card ig-card ig-card-spaced mt-3 overflow-hidden">
{/* Dark gradient backdrop inside the card so the LIVE cards pop */}
<div className="bg-gradient-to-b from-neutral-900 to-black py-4">
{/* Section header */}
<div className="flex items-center justify-between px-3 mb-3">
<div className="flex items-center gap-2">
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 bg-red-500 rounded-full ig-float" />
<span className="text-xs font-bold text-white tracking-wide">LIVE NOW</span>
</span>
<span className="text-xs text-white/50">·</span>
<span className="text-xs text-white/70">{sessions.length} seller{sessions.length === 1 ? '' : 's'} streaming</span>
</div>
<Link href="/live" className="text-xs font-semibold text-sky-400 hover:text-sky-300">
See all
</Link>
</div>
{/* Horizontal scroll of live cards */}
<div className="flex gap-3 px-3 overflow-x-auto no-scrollbar">
{sessions.map((session, index) => {
const seller = session.seller || {};
const sellerName = seller.business_name || 'Seller';
const sellerImage = seller.profile_image;
const title = session.title || `${sellerName} is live`;
const viewers = session.viewer_count || 0;
const liveDuration = session.started_at
? formatLiveDuration(session.started_at)
: null;
return (
<Link
key={session.id}
href={`/live-watch?id=${session.id}`}
className="ig-bounce-in shrink-0 w-64 bg-white/10 rounded-xl overflow-hidden hover:shadow-xl transition-all hover:-translate-y-0.5"
style={{ animationDelay: `${index * 80}ms` }}
>
{/* Top: avatar + LIVE badge + duration */}
<div className="relative h-32 bg-gradient-to-br from-neutral-800 to-neutral-900 flex items-center justify-center">
{/* Pulsing red ring around avatar */}
<div className="relative">
<div className="absolute inset-0 rounded-full bg-red-500 animate-ping opacity-40" />
<div className="relative w-16 h-16 rounded-full border-2 border-red-500 overflow-hidden bg-white/10">
{sellerImage ? (
<img src={sellerImage} alt={sellerName} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-white font-bold text-2xl">
{sellerName.charAt(0).toUpperCase()}
</div>
)}
</div>
</div>
{/* LIVE badge */}
<div className="absolute top-2 left-2 flex items-center gap-1 bg-red-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full ig-pulse-glow">
<span className="w-1.5 h-1.5 bg-white/10 rounded-full" />
LIVE
</div>
{/* Duration */}
{liveDuration && (
<div className="absolute top-2 right-2 bg-black/60 backdrop-blur-sm text-white text-[10px] font-semibold px-2 py-0.5 rounded-full">
{liveDuration}
</div>
)}
</div>
{/* Bottom: title + viewers + CTA */}
<div className="p-3">
<div className="text-xs font-semibold text-white truncate mb-1">{sellerName}</div>
<div className="text-xs text-slate-400 line-clamp-1 mb-2">{title}</div>
<div className="flex items-center justify-between">
<span className="flex items-center gap-1 text-[10px] text-slate-400">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full" />
{formatCount(viewers)} watching
</span>
<span className="bg-indigo-600 text-white text-[10px] font-bold px-3 py-1 rounded-full">
Watch Live
</span>
</div>
</div>
</Link>
);
})}
</div>
</div>
</section>
);
}
/**
* ShortsSection — YouTube Shorts / IG Reels style horizontal scroll.
*
* Each card is a vertical 9:16 video thumbnail with:
* - Caption overlay at bottom
* - View count + play icon
* - Seller name
*
* Tapping a short navigates to /videos (the full-screen video viewer).
*/
function ShortsSection({ shorts }: { shorts: any[] }) {
return (
<section className="fx-card ig-card ig-card-spaced overflow-hidden py-4">
{/* Section header */}
<div className="flex items-center justify-between px-3 mb-3">
<div className="flex items-center gap-1.5">
<Play className="w-4 h-4 text-white fill-indigo-600" />
<h3 className="text-sm font-semibold text-white">Shorts</h3>
</div>
<Link href="/shorts" className="text-xs font-semibold text-sky-500 hover:text-sky-700">
See all
</Link>
</div>
{/* Horizontal scroll of vertical video cards */}
<div className="flex gap-3 px-3 overflow-x-auto no-scrollbar">
{shorts.map((short, index) => {
const seller = short.seller || {};
const sellerName = seller.business_name || 'Seller';
const caption = short.caption || '';
const views = short.views || 0;
const likes = short.likes || 0;
const productImage = short.product?.image_url;
return (
<Link
key={short.id}
href="/shorts"
className="ig-bounce-in shrink-0 w-32 rounded-xl overflow-hidden bg-white/5 hover:shadow-lg transition-all hover:-translate-y-0.5"
style={{ animationDelay: `${index * 60}ms` }}
>
{/* Vertical 9:16 video thumbnail */}
<div className="relative aspect-[9/16] bg-white/10">
{short.videoUrl ? (
<video
src={short.videoUrl}
muted
loop
playsInline
preload="metadata"
className="w-full h-full object-cover"
onMouseEnter={(e) => e.currentTarget.play().catch(() => {})}
onMouseLeave={(e) => e.currentTarget.pause()}
/>
) : productImage ? (
<img src={productImage} alt={caption} className="w-full h-full object-cover" loading="lazy" />
) : null}
{/* Gradient overlay for caption readability */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
{/* Play count top-right */}
<div className="absolute top-2 right-2 flex items-center gap-1 bg-black/50 backdrop-blur-sm text-white text-[9px] font-semibold px-1.5 py-0.5 rounded-full">
<Play className="w-2 h-2 fill-white" />
{formatCount(views)}
</div>
{/* Caption + seller at bottom */}
<div className="absolute bottom-0 left-0 right-0 p-2">
<div className="text-[10px] font-semibold text-white truncate mb-0.5">{sellerName}</div>
{caption && (
<div className="text-[10px] text-white/80 line-clamp-2 leading-tight">{caption}</div>
)}
{/* Likes */}
<div className="flex items-center gap-1 mt-1">
<Heart className="w-2.5 h-2.5 fill-red-500 text-red-500" />
<span className="text-[9px] text-white/70">{formatCount(likes)}</span>
</div>
</div>
</div>
</Link>
);
})}
</div>
</section>
);
}
/**
* SuggestedSellersCarousel — horizontal scroll of seller cards, inserted
* between feed posts every 3 items. Shows 3 sellers + a "See all" card
* that links to /sellers.
*
* Layout matches Instagram's "Suggested for you" pattern:
* - Section header: "Suggested Sellers" + "See All" link
* - Horizontal scroll of square-ish seller cards (avatar + name + Follow btn)
* - Final card: "See all sellers" with chevron
*/
function SuggestedSellersCarousel({
sellers,
carouselIndex,
following,
onFollow,
}: {
sellers: any[];
carouselIndex: number;
following: Set<string>;
onFollow: (sellerId: string, e: React.MouseEvent) => void;
}) {
// Vary the header label slightly so repeat carousels feel fresh
const headerLabel = carouselIndex === 0
? 'Suggested Sellers'
: carouselIndex === 1
? 'Discover More Sellers'
: 'More Sellers to Follow';
return (
<section className="fx-card ig-card ig-card-spaced overflow-hidden py-4">
{/* Section header */}
<div className="flex items-center justify-between px-3 mb-3">
<div className="flex items-center gap-1.5">
<Users className="w-4 h-4 text-slate-300" />
<h3 className="text-sm font-semibold text-white">{headerLabel}</h3>
</div>
<Link href="/sellers" className="text-xs font-semibold text-sky-500 hover:text-sky-700">
See All
</Link>
</div>
{/* Horizontal scroll of seller cards */}
<div className="flex gap-3 px-3 overflow-x-auto no-scrollbar">
{sellers.map((seller, index) => {
const sellerId = seller.id;
const name = seller.business_name || seller.farm_name || 'Seller';
const image = seller.profile_image;
const category = seller.business_category;
const isFollowing = following.has(sellerId);
const sellerHref = seller.slug ? `/${seller.slug}` : `/seller-profile?id=${sellerId}`;
return (
<div
key={sellerId}
className="ig-bounce-in shrink-0 w-36 border border-white/5 rounded-2xl p-3 flex flex-col items-center text-center hover:shadow-md hover:-translate-y-0.5 transition-all bg-white/10"
style={{ animationDelay: `${index * 60}ms` }}
>
<Link href={sellerHref} className="block">
<div className="w-16 h-16 rounded-full overflow-hidden bg-white/10 mb-2 ig-story-ring" style={{ padding: 2 }}>
<div className="w-full h-full rounded-full overflow-hidden border-2 border-white">
{image ? (
<img src={image} alt={name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center bg-white/10 text-white font-bold text-xl">
{name.charAt(0).toUpperCase()}
</div>
)}
</div>
</div>
</Link>
<Link
href={sellerHref}
className="text-xs font-semibold text-white truncate max-w-full hover:opacity-70 mb-0.5"
>
{name}
</Link>
{category && (
<p className="text-[10px] text-slate-400 truncate max-w-full mb-2">{category}</p>
)}
<button
onClick={(e) => onFollow(sellerId, e)}
className={`w-full text-xs font-semibold py-1.5 rounded-md transition-colors ${
isFollowing
? 'bg-white/5 text-slate-300 hover:bg-white/10'
: 'bg-indigo-600 text-white hover:bg-white/10'
}`}
>
{isFollowing ? 'Following' : 'Follow'}
</button>
</div>
);
})}
{/* "See all sellers" card — links to /sellers page */}
<Link
href="/sellers"
className="shrink-0 w-36 border border-white/5 rounded-2xl p-3 flex flex-col items-center justify-center text-center hover:bg-white/5 transition-colors bg-white/10"
>
<div className="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center mb-2">
<ChevronRight className="w-6 h-6 text-slate-300" />
</div>
<span className="text-xs font-semibold text-white">See all sellers</span>
<span className="text-[10px] text-slate-400 mt-0.5">Discover more stores</span>
</Link>
</div>
</section>
);
}