's''
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-stone-200 dark:border-stone-800 mt-12 py-8">
|
||||
<div className="max-w-[900px] mx-auto px-5 sm:px-8 text-center text-sm text-stone-500 dark:text-stone-400">
|
||||
<p>© {new Date().getFullYear()} Salon. 深圳·广州·惠州 同城活动。</p>
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 mb-3">
|
||||
<Link href="/history" className="hover:text-amber-600 dark:hover:text-amber-400">
|
||||
历史活动
|
||||
</Link>
|
||||
</div>
|
||||
<p>© {new Date().getFullYear()} Salon · 深圳 周末轻社交</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,12 @@ export default function Header() {
|
||||
<Link href="/" className="font-semibold text-stone-800 dark:text-stone-100 text-base">
|
||||
Salon
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<nav className="flex items-center gap-4 sm:gap-6">
|
||||
<Link href="/history" className="text-sm text-stone-600 dark:text-stone-400 hover:text-amber-600 dark:hover:text-amber-400">
|
||||
历史活动
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import SessionCoverImage from "./SessionCoverImage";
|
||||
import { SESSIONS } from "@/config/home.config";
|
||||
import { SESSIONS, RECENT_JOINERS } from "@/config/home.config";
|
||||
|
||||
const MOCK_JOINERS = RECENT_JOINERS.slice(0, 3);
|
||||
|
||||
export default function HeroSection() {
|
||||
const mainSession = SESSIONS[0];
|
||||
const [stats, setStats] = useState<{ count: number; joiners: { nickname: string }[] } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mainSession) return;
|
||||
fetch("/api/join/stats")
|
||||
.then((r) => r.json())
|
||||
.then((d) => d?.stats?.[mainSession.id] && setStats(d.stats[mainSession.id]))
|
||||
.catch(() => {});
|
||||
}, [mainSession?.id]);
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-[#faf8f5] dark:bg-stone-950">
|
||||
<div
|
||||
@@ -22,27 +35,21 @@ export default function HeroSection() {
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl lg:text-[2.5rem] xl:text-[2.75rem] font-bold tracking-tight leading-[1.25]">
|
||||
<span className="hero-gradient-text">周末留两小时</span>
|
||||
<br />
|
||||
<span className="text-stone-800 dark:text-stone-100">去一个舒服的公开场地,认识几位同频的人</span>
|
||||
<span className="text-stone-800 dark:text-stone-100">去loft民宿,认识几位同频的人</span>
|
||||
</h1>
|
||||
<p className="mt-4 sm:mt-5 text-stone-600 dark:text-stone-400 text-sm sm:text-base max-w-lg leading-relaxed">
|
||||
深圳 · 广州 · 惠州 · 周末 6–8 人轻社交小组
|
||||
深圳 · 6–8 人小圈子
|
||||
</p>
|
||||
<p className="mt-1.5 text-stone-500 dark:text-stone-450 text-xs sm:text-sm max-w-lg">
|
||||
咖啡馆 / 书店 / citywalk · 6–8 人小组 · 公开场地 · 填表申请 · 联系方式仅用于活动确认 · 不收费
|
||||
公开场地 · 填表申请 · 免费 · 不尬聊
|
||||
</p>
|
||||
<div className="mt-6 sm:mt-8 flex flex-wrap gap-3">
|
||||
<div className="mt-6 sm:mt-8">
|
||||
<Link
|
||||
href="/join"
|
||||
className="inline-flex items-center justify-center bg-amber-600 hover:bg-amber-700 text-white px-5 py-2.5 sm:px-6 sm:py-3 rounded-xl text-sm font-medium shadow-lg shadow-amber-900/20 transition-all active:scale-[0.98]"
|
||||
>
|
||||
填表申请本周场次
|
||||
</Link>
|
||||
<Link
|
||||
href="#faq"
|
||||
className="inline-flex items-center justify-center bg-white dark:bg-stone-800 hover:bg-stone-50 dark:hover:bg-stone-700 text-stone-700 dark:text-stone-200 border border-stone-200 dark:border-stone-600 px-5 py-2.5 sm:px-6 sm:py-3 rounded-xl text-sm font-medium transition-all"
|
||||
>
|
||||
先看规则
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,6 +97,34 @@ export default function HeroSection() {
|
||||
<span className="inline-flex items-center gap-1 mt-3 text-amber-600 dark:text-amber-400 font-medium text-xs sm:text-sm">
|
||||
报名这场 →
|
||||
</span>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<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 cursor-default ${showAvatar ? "" : "bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-xs font-medium text-amber-700 dark:text-amber-300"}`}
|
||||
title={nickname}
|
||||
data-xiaohongshu-url={xhUrl}
|
||||
onClick={(e) => 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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-xs text-stone-500 dark:text-stone-400">
|
||||
{stats?.count ? `${stats.count} 人已报名` : "有人报名中"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
112
app/components/SessionCard.tsx
Normal file
112
app/components/SessionCard.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import SessionCoverImage from "./SessionCoverImage";
|
||||
import { SESSIONS, ACTIVITY_TYPES, SESSION_TAGS, RECENT_JOINERS } from "@/config/home.config";
|
||||
|
||||
interface JoinStats {
|
||||
count: number;
|
||||
joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[];
|
||||
}
|
||||
|
||||
const MOCK_JOINERS = RECENT_JOINERS.slice(0, 3);
|
||||
|
||||
export default function SessionCard({
|
||||
session,
|
||||
index,
|
||||
}: {
|
||||
session: (typeof SESSIONS)[0];
|
||||
index: number;
|
||||
}) {
|
||||
const [stats, setStats] = useState<JoinStats | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/join/stats")
|
||||
.then((r) => r.json())
|
||||
.then((d) => d?.stats?.[session.id] && setStats(d.stats[session.id]))
|
||||
.catch(() => {});
|
||||
}, [session.id]);
|
||||
|
||||
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 transition-all"
|
||||
>
|
||||
<div className="relative w-full sm:w-24 sm:h-24 shrink-0 aspect-[16/10] sm:aspect-square 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"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-2xl">
|
||||
{session.type === "skill-exchange" ? "🔄" : "☕"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap gap-1 mb-1.5">
|
||||
{session.tags?.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-block text-[9px] sm:text-[10px] font-medium text-amber-600 dark:text-amber-400/90 bg-amber-50 dark:bg-amber-900/20 px-1.5 py-0.5 rounded"
|
||||
>
|
||||
{SESSION_TAGS[tag]}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] sm:text-xs text-amber-600 dark:text-amber-400/90">
|
||||
{ACTIVITY_TYPES[session.type]} · {session.city}
|
||||
</span>
|
||||
<h3 className="font-semibold text-stone-800 dark:text-stone-100 text-sm sm:text-base group-hover:text-amber-700 dark:group-hover:text-amber-400 mt-0.5">
|
||||
{session.title}
|
||||
</h3>
|
||||
<p className="text-xs sm:text-sm text-stone-500 dark:text-stone-400 mt-0.5">
|
||||
{session.date} {session.time} · {session.place}
|
||||
</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">
|
||||
报名这场 →
|
||||
</span>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<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 cursor-default ${showAvatar ? "" : "bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-xs font-medium text-amber-700 dark:text-amber-300"}`}
|
||||
title={nickname}
|
||||
data-xiaohongshu-url={xhUrl}
|
||||
onClick={(e) => 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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-xs text-stone-500 dark:text-stone-400">
|
||||
{stats?.count ? `${stats.count} 人已报名` : "有人报名中"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import Image from "next/image";
|
||||
interface SessionCoverImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
type?: "skill-exchange" | "side-hustle" | "cafe" | "bookstore" | "citywalk" | "light-chat";
|
||||
type?: "skill-exchange" | "side-hustle" | "loft" | "light-chat";
|
||||
className?: string;
|
||||
fill?: boolean;
|
||||
sizes?: string;
|
||||
@@ -18,7 +18,7 @@ export default function SessionCoverImage({ src, alt, type, className = "", fill
|
||||
if (error) {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-stone-200 dark:bg-stone-700 text-2xl sm:text-3xl">
|
||||
{type === "bookstore" ? "📚" : type === "citywalk" ? "🚶" : type === "skill-exchange" ? "🔄" : "☕"}
|
||||
{type === "loft" ? "🏠" : type === "skill-exchange" ? "🔄" : "☕"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user