's'
This commit is contained in:
@@ -1,26 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import SessionCoverImage from "./SessionCoverImage";
|
||||
import AddressLink from "./AddressLink";
|
||||
import { getSessionsWithDates, RECENT_JOINERS } from "@/config/home.config";
|
||||
import { getSessionsWithDates, MOCK_JOINERS_SESSION1 } from "@/config/home.config";
|
||||
import { mergeJoiners, getUniqueDbCount } from "@/app/lib/joiners";
|
||||
import { useXhClickOpener } from "@/app/hooks/useXhClickOpener";
|
||||
|
||||
const MOCK_JOINERS = RECENT_JOINERS.slice(0, 3);
|
||||
function HeroAvatarItem({ joiner }: { joiner: { name?: string; nickname?: string; avatar?: string; xiaohongshu_url?: string } }) {
|
||||
const nickname = "name" in joiner ? joiner.name : joiner.nickname ?? "";
|
||||
const avatar = joiner.avatar;
|
||||
const xhUrl = joiner.xiaohongshu_url;
|
||||
const showAvatar = avatar && avatar.length > 0;
|
||||
const handleClick = useXhClickOpener(xhUrl);
|
||||
return (
|
||||
<span
|
||||
className={`relative w-6 h-6 sm:w-8 sm:h-8 rounded-full overflow-hidden border-2 border-white dark:border-stone-900 shrink-0 inline-block transition-transform hover:scale-110 cursor-default ${showAvatar ? "" : "bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-[10px] sm:text-xs font-medium text-amber-700 dark:text-amber-300"}`}
|
||||
{...(xhUrl && { "data-xiaohongshu-url": xhUrl })}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{showAvatar ? (
|
||||
<Image src={avatar} alt="" fill sizes="32px" className="object-cover" unoptimized />
|
||||
) : (
|
||||
<span className="flex items-center justify-center w-full h-full">{nickname.slice(0, 1) || "?"}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HeroSection() {
|
||||
const sessions = getSessionsWithDates();
|
||||
const mainSession = sessions[0];
|
||||
const [stats, setStats] = useState<{ count: number; joiners: { nickname: string }[] } | null>(null);
|
||||
const [stats, setStats] = useState<{ count: number; joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[] } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const pathname = usePathname();
|
||||
const fetchStats = useCallback(() => {
|
||||
if (!mainSession) return;
|
||||
fetch("/api/join/stats")
|
||||
fetch(`/api/join/stats?_=${Date.now()}`, { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => d?.stats?.[mainSession.id] && setStats(d.stats[mainSession.id]))
|
||||
.catch(() => {});
|
||||
}, [mainSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mainSession || pathname !== "/") return;
|
||||
fetchStats();
|
||||
const onVisible = () => fetchStats();
|
||||
const onPageShow = (e: PageTransitionEvent) => {
|
||||
if (e.persisted) fetchStats();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
window.removeEventListener("pageshow", onPageShow);
|
||||
};
|
||||
}, [pathname, mainSession?.id, fetchStats]);
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-[#faf8f5] dark:bg-stone-950">
|
||||
<div
|
||||
@@ -117,33 +155,12 @@ export default function HeroSection() {
|
||||
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-stone-100 dark:border-stone-800 flex items-center justify-between gap-2 sm:gap-4">
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||
<div className="flex -space-x-2 sm:-space-x-2.5 shrink-0">
|
||||
{(stats?.joiners?.length ? stats.joiners : MOCK_JOINERS).slice(0, 5).map((j, i) => {
|
||||
const nickname = "name" in j ? (j as { name: string }).name : (j as { nickname: string }).nickname;
|
||||
const avatar = "avatar" in j ? (j as { avatar: string }).avatar : (j as { avatar?: string }).avatar;
|
||||
const xhUrl = (j as { xiaohongshu_url?: string }).xiaohongshu_url;
|
||||
const showAvatar = avatar && avatar.length > 0;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`relative w-6 h-6 sm:w-8 sm:h-8 rounded-full overflow-hidden border-2 border-white dark:border-stone-900 shrink-0 inline-block transition-transform hover:scale-110 ${xhUrl ? "cursor-pointer" : "cursor-default"} ${showAvatar ? "" : "bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-[10px] sm:text-xs font-medium text-amber-700 dark:text-amber-300"}`}
|
||||
title={nickname}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (xhUrl) window.open(xhUrl, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
{showAvatar ? (
|
||||
<Image src={avatar} alt="" fill sizes="32px" className="object-cover" unoptimized />
|
||||
) : (
|
||||
<span className="flex items-center justify-center w-full h-full">{nickname.slice(0, 1) || "?"}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{mergeJoiners(MOCK_JOINERS_SESSION1, stats?.joiners).map((j, i) => (
|
||||
<HeroAvatarItem key={`${i}-${("name" in j ? j.name : j.nickname) || i}`} joiner={j} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] sm:text-xs text-stone-500 dark:text-stone-400 truncate">
|
||||
{stats?.count ? `${stats.count} 人已报名` : "有人报名中"}
|
||||
{3 + getUniqueDbCount(stats?.joiners)} 人已报名
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 inline-flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 rounded-md sm:rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400 font-semibold text-xs sm:text-sm group-hover:bg-amber-100 dark:group-hover:bg-amber-900/30 transition-all group-hover:gap-2">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import HeroSection from "./HeroSection";
|
||||
import MyRegistrationStatus from "./MyRegistrationStatus";
|
||||
@@ -36,6 +37,7 @@ function getPendingOrderId(): string | null {
|
||||
}
|
||||
|
||||
export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
||||
const pathname = usePathname();
|
||||
const [volunteer, setVolunteer] = useState(initialVolunteer);
|
||||
const [stats, setStats] = useState<JoinStatsMap | null>(null);
|
||||
const [pendingOrderId] = useState<string | null>(() =>
|
||||
@@ -49,13 +51,28 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/join/stats")
|
||||
const fetchStats = useCallback(() => {
|
||||
fetch(`/api/join/stats?_=${Date.now()}`, { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => d?.stats && setStats(d.stats))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname !== "/") return;
|
||||
fetchStats();
|
||||
const onVisible = () => fetchStats();
|
||||
const onPageShow = (e: PageTransitionEvent) => {
|
||||
if (e.persisted) fetchStats();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
window.removeEventListener("pageshow", onPageShow);
|
||||
};
|
||||
}, [pathname, fetchStats]);
|
||||
|
||||
useCompleteOrderPolling({
|
||||
orderId: pendingOrderId,
|
||||
retryDelays: [500, 1000, 1500, 2500, 4000],
|
||||
@@ -66,7 +83,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
||||
<Header />
|
||||
<HeroSection />
|
||||
|
||||
<main className="max-w-[900px] mx-auto px-4 sm:px-6 md:px-8 py-8 sm:py-12 md:py-14 space-y-10 sm:space-y-14">
|
||||
<main className="max-w-[900px] md:max-w-[1024px] mx-auto px-4 sm:px-6 md:px-8 py-8 sm:py-12 md:py-14 space-y-10 sm:space-y-14">
|
||||
<MyRegistrationStatus />
|
||||
{/* 是什么 / 不是什么 - 小红书风格 */}
|
||||
<section>
|
||||
|
||||
@@ -1,18 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { RefreshCw, Coffee } from "lucide-react";
|
||||
import SessionCoverImage from "./SessionCoverImage";
|
||||
import AddressLink from "./AddressLink";
|
||||
import { getSessionsWithDates, ACTIVITY_TYPES, SESSION_TAGS, RECENT_JOINERS } from "@/config/home.config";
|
||||
import { ACTIVITY_TYPES, SESSION_TAGS, MOCK_JOINERS_SESSION1, MOCK_JOINERS_SESSION2 } from "@/config/home.config";
|
||||
import { mergeJoiners, getUniqueDbCount } from "@/app/lib/joiners";
|
||||
import { useXhClickOpener } from "@/app/hooks/useXhClickOpener";
|
||||
|
||||
interface JoinStats {
|
||||
count: number;
|
||||
joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[];
|
||||
}
|
||||
|
||||
const MOCK_JOINERS = RECENT_JOINERS.slice(0, 3);
|
||||
function getMockForSession(sessionId: string) {
|
||||
return sessionId === "session-1" ? MOCK_JOINERS_SESSION1 : MOCK_JOINERS_SESSION2;
|
||||
}
|
||||
|
||||
function AvatarItem({ joiner }: { joiner: { name?: string; nickname?: string; avatar?: string; xiaohongshu_url?: string } }) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const nickname = "name" in joiner ? joiner.name : joiner.nickname ?? "";
|
||||
const avatar = joiner.avatar;
|
||||
const xhUrl = joiner.xiaohongshu_url;
|
||||
const showAvatar = avatar && avatar.length > 0 && !imgError;
|
||||
const handleClick = useXhClickOpener(xhUrl);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="relative w-7 h-7 rounded-full overflow-hidden border-2 border-white dark:border-stone-900 ring-1 ring-stone-200/50 dark:ring-stone-700/50 shrink-0 inline-block cursor-default"
|
||||
{...(xhUrl && { "data-xiaohongshu-url": xhUrl })}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{showAvatar ? (
|
||||
<Image src={avatar} alt="" fill sizes="28px" className="object-cover" unoptimized onError={() => setImgError(true)} />
|
||||
) : (
|
||||
<span className="flex items-center justify-center w-full h-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
|
||||
{nickname.slice(0, 1) || "?"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type SessionTag = keyof typeof SESSION_TAGS;
|
||||
|
||||
@@ -39,18 +69,15 @@ export default function SessionCard({
|
||||
}) {
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/join?session=${encodeURIComponent(session.id)}`}
|
||||
className="group flex flex-col sm:flex-row gap-3 sm:gap-4 p-4 sm:p-5 rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 hover:border-amber-200 dark:hover:border-amber-900/50 hover:shadow-lg hover:shadow-amber-900/5 hover:-translate-y-0.5 transition-all duration-200 ease-out"
|
||||
>
|
||||
<div className="relative w-full sm:w-32 sm:min-w-[128px] shrink-0 aspect-[16/10] rounded-lg sm:rounded-xl overflow-hidden bg-stone-100 dark:bg-stone-800">
|
||||
<div className="group flex flex-col sm:flex-row gap-3 sm:gap-4 p-4 sm:p-5 rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 hover:border-amber-200 dark:hover:border-amber-900/50 hover:shadow-lg hover:shadow-amber-900/5 transition-all duration-200 ease-out">
|
||||
<div className="relative w-full sm:w-36 md:w-40 sm:min-w-[144px] md:min-w-[160px] shrink-0 aspect-[16/10] rounded-lg sm:rounded-xl overflow-hidden bg-stone-100 dark:bg-stone-800">
|
||||
{session.coverImage ? (
|
||||
<SessionCoverImage
|
||||
src={session.coverImage}
|
||||
alt={session.title}
|
||||
type={session.type}
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, 96px"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 768px) 144px, 160px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
@@ -60,7 +87,7 @@ export default function SessionCard({
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap gap-1 mb-1.5">
|
||||
<div className="flex flex-wrap gap-1 mb-1.5 min-h-[1.5rem]">
|
||||
{session.tags?.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
@@ -69,10 +96,19 @@ export default function SessionCard({
|
||||
{SESSION_TAGS[tag]}
|
||||
</span>
|
||||
))}
|
||||
{index === 0 && (
|
||||
{!session.tags?.includes("light-topic") && (
|
||||
<span className="inline-block text-[9px] sm:text-[10px] font-medium px-1.5 py-0.5 rounded opacity-0" aria-hidden>
|
||||
{SESSION_TAGS["light-topic"]}
|
||||
</span>
|
||||
)}
|
||||
{index === 0 ? (
|
||||
<span className="inline-block text-[9px] sm:text-[10px] font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 px-1.5 py-0.5 rounded">
|
||||
主推
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-block text-[9px] sm:text-[10px] font-medium px-1.5 py-0.5 rounded opacity-0" aria-hidden>
|
||||
主推
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] sm:text-xs text-amber-600 dark:text-amber-400/90">
|
||||
@@ -82,47 +118,28 @@ export default function SessionCard({
|
||||
{session.title}
|
||||
</h3>
|
||||
<p className="text-xs sm:text-sm text-stone-500 dark:text-stone-400 mt-0.5">
|
||||
{session.date} {session.time} · <AddressLink place={session.place} city={session.city} />
|
||||
{session.date} {session.time}
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-1 mt-2 sm:mt-3 text-amber-600 dark:text-amber-400 text-xs sm:text-sm font-medium">
|
||||
<p className="text-xs sm:text-sm text-stone-500 dark:text-stone-400 mt-0.5">
|
||||
<AddressLink place={session.place} city={session.city} />
|
||||
</p>
|
||||
<Link
|
||||
href={`/join?session=${encodeURIComponent(session.id)}`}
|
||||
className="inline-flex items-center gap-1 mt-2 sm:mt-3 text-amber-600 dark:text-amber-400 text-xs sm:text-sm font-medium hover:text-amber-700 dark:hover:text-amber-300 transition-colors"
|
||||
>
|
||||
报名这场 →
|
||||
</span>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
</Link>
|
||||
<div className="mt-3 flex items-center gap-2 min-w-0 overflow-hidden">
|
||||
<div className="flex -space-x-2 shrink-0">
|
||||
{(stats?.joiners?.length ? stats.joiners : MOCK_JOINERS).slice(0, 5).map((j, i) => {
|
||||
const nickname = "name" in j ? (j as { name: string }).name : (j as { nickname: string }).nickname;
|
||||
const avatar = "avatar" in j ? (j as { avatar: string }).avatar : (j as { avatar?: string }).avatar;
|
||||
const xhUrl = (j as { xiaohongshu_url?: string }).xiaohongshu_url;
|
||||
const showAvatar = avatar && avatar.length > 0;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`relative w-7 h-7 rounded-full overflow-hidden border-2 border-white dark:border-stone-900 ring-1 ring-stone-200/50 dark:ring-stone-700/50 shrink-0 inline-block ${xhUrl ? "cursor-pointer" : "cursor-default"}`}
|
||||
title={nickname}
|
||||
onClick={(e) => {
|
||||
if (xhUrl) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(xhUrl, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showAvatar ? (
|
||||
<Image src={avatar} alt="" fill sizes="28px" className="object-cover" unoptimized />
|
||||
) : (
|
||||
<span className="flex items-center justify-center w-full h-full">{nickname.slice(0, 1) || "?"}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{mergeJoiners(getMockForSession(session.id), stats?.joiners).map((j, i) => (
|
||||
<AvatarItem key={`${i}-${(j.nickname || j.name) || i}`} joiner={j} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-stone-500 dark:text-stone-400">
|
||||
{stats?.count ? `${stats.count} 人已报名` : "有人报名中"}
|
||||
{3 + getUniqueDbCount(stats?.joiners)} 人已报名
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user