This commit is contained in:
eric
2026-03-18 02:01:48 -05:00
parent 742c293e14
commit bf10b5d7e4
11 changed files with 415 additions and 138 deletions

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { postSalonApi } from "@/app/lib/salonApi";
import { getNowChinaStr } from "@/app/lib/dates";
import { getAdminToken } from "@/app/lib/pocketbase/admin";
import { getPocketBaseConfig } from "@/config/services.config";
import { COOKIE_NAME } from "@/app/lib/auth-cookie";
@@ -45,6 +46,7 @@ export async function POST(request: NextRequest) {
const whyJoin = String(formData.why_join || "").trim();
const region = String(formData.region || "").trim();
const availableTime = String(formData.available_time || "").trim();
const meetupdate = String(formData.meetupdate || "").trim();
const isVolunteer = !!formData.is_volunteer;
const qualify = !!formData.qualify; // 女性且小红书帖子>10 时自动通过审核
/** reason 仅存自我介绍,不合并其他字段 */
@@ -107,6 +109,7 @@ export async function POST(request: NextRequest) {
is_volunteer: isVolunteer,
qualify,
userInfo: userInfo || "",
meetupdate: meetupdate || "",
};
if (phone.trim()) {
record.phone = phone.trim();
@@ -199,6 +202,10 @@ async function tryPocketBaseDirect(
first_time: record.first_time ?? "",
userInfo: record.userInfo ?? "",
};
if (record.meetupdate != null && String(record.meetupdate).trim()) {
body.meetupdate = String(record.meetupdate).trim();
}
body.submitted_at_cn = getNowChinaStr();
if (record.phone != null && String(record.phone).trim()) {
body.phone = record.phone;
}

View File

@@ -2,9 +2,27 @@ import { NextRequest, NextResponse } from "next/server";
import { getAdminToken } from "@/app/lib/pocketbase/admin";
import { getPocketBaseConfig } from "@/config/services.config";
import { SESSIONS } from "@/config/home.config";
import { getThisWeekRangeChinaStr, getThisWeekRangeISO } from "@/app/lib/dates";
import { getSalonApi } from "@/app/lib/salonApi";
/** 按场次返回报名人数和最近报名者(昵称),头像由前端用 initials 或占位 */
export const dynamic = "force-dynamic";
/** 按场次返回报名人数和最近报名者,仅限中国日历本周(周一到周日)报名的记录。优先 salonapi失败时回退直连 PocketBase */
export async function GET(request: NextRequest) {
try {
const { status, data } = await getSalonApi(request, "/api/salon/join/stats", {
timeoutMs: 8000,
});
if (status === 200 && data && typeof data === "object") {
const payload = data as { ok?: boolean; stats?: Record<string, { count: number; joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[] }> };
if (payload?.stats && typeof payload.stats === "object") {
return NextResponse.json({ ok: true, stats: payload.stats });
}
}
} catch {
/* salonapi 不可用,回退直连 PocketBase */
}
const token = await getAdminToken();
if (!token) {
return NextResponse.json(
@@ -18,26 +36,52 @@ export async function GET(request: NextRequest) {
const stats: Record<string, { count: number; joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[] }> = {};
const { start: weekStartCn, end: weekEndCn } = getThisWeekRangeChinaStr();
const { start: weekStartUtc, end: weekEndUtc } = getThisWeekRangeISO();
const results = await Promise.all(
SESSIONS.map(async (s) => {
try {
const filter = encodeURIComponent(`type="${s.id}"`);
const res = await fetch(
`${base}/api/collections/${pb.joinCollection}/records?filter=${filter}&sort=-created&perPage=6`,
{
// session-1 周六 / session-2 周日meetupdate 含关键词或为空(兼容旧记录)
const dayKeyword = s.id === "session-1" ? "周六" : "周日";
const meetupdatePart = s.id === "session-1"
? `(meetupdate ~ "${dayKeyword}" || meetupdate = "")`
: `meetupdate ~ "${dayKeyword}"`;
// 本周submitted_at_cn中国公历或 createdUTC 兼容旧记录),两路查询合并
const filterSubmitted = encodeURIComponent(
`${meetupdatePart} && submitted_at_cn >= "${weekStartCn}" && submitted_at_cn <= "${weekEndCn}"`
);
const filterCreated = encodeURIComponent(
`${meetupdatePart} && created >= "${weekStartUtc}" && created <= "${weekEndUtc}"`
);
const [res1, res2] = await Promise.all([
fetch(`${base}/api/collections/${pb.joinCollection}/records?filter=${filterSubmitted}&sort=-created&perPage=6`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
}),
fetch(`${base}/api/collections/${pb.joinCollection}/records?filter=${filterCreated}&sort=-created&perPage=6`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
}),
]);
const data1 = res1.ok ? await res1.json() : { items: [], totalItems: 0 };
const data2 = res2.ok ? await res2.json() : { items: [], totalItems: 0 };
const seen = new Set<string>();
const merged: typeof data1.items = [];
for (const r of [...(data1.items || []), ...(data2.items || [])]) {
if (r?.id && !seen.has(r.id)) {
seen.add(r.id);
merged.push(r);
}
);
if (!res.ok) return { id: s.id, stat: { count: 0, joiners: [] } };
const data = await res.json();
const items = Array.isArray(data?.items) ? data.items : [];
const total = typeof data?.totalItems === "number" ? data.totalItems : items.length;
}
merged.sort((a: { created?: string }, b: { created?: string }) => (b?.created || "").localeCompare(a?.created || ""));
const items = merged.slice(0, 6);
const total = Math.max(data1.totalItems || 0, data2.totalItems || 0, merged.length);
return {
id: s.id,
stat: {
count: total,
joiners: items.slice(0, 6).map((r: { nickname?: string; xiaohongshu_nickname?: string; xiaohongshu_avatar?: string; xiaohongshu_url?: string; media?: string }) => ({
joiners: items.map((r: { nickname?: string; xiaohongshu_nickname?: string; xiaohongshu_avatar?: string; xiaohongshu_url?: string; media?: string }) => ({
nickname: String(r?.xiaohongshu_nickname || r?.nickname || "").trim() || "匿名",
avatar: String(r?.xiaohongshu_avatar || r?.media || "").trim() || undefined,
xiaohongshu_url: String(r?.xiaohongshu_url || "").trim() || undefined,

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -0,0 +1,29 @@
import { useRef, useCallback } from "react";
const RESET_MS = 1000;
const CLICK_THRESHOLD = 5;
/** 连续点击 5 次打开小红书主页 */
export function useXhClickOpener(xhUrl: string | undefined) {
const countRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
return useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!xhUrl) return;
clearTimeout(timerRef.current);
countRef.current++;
if (countRef.current >= CLICK_THRESHOLD) {
window.open(xhUrl, "_blank", "noopener,noreferrer");
countRef.current = 0;
} else {
timerRef.current = setTimeout(() => {
countRef.current = 0;
}, RESET_MS);
}
},
[xhUrl]
);
}

View File

@@ -5,7 +5,7 @@ import { usePathname } from "next/navigation";
import { useCompleteOrderPolling } from "@/app/hooks/useCompleteOrderPolling";
import Link from "next/link";
import ThemeToggle from "../components/ThemeToggle";
import { DIGITAL_NOMAD_WECHAT_QR } from "@/config/home.config";
import { DIGITAL_NOMAD_WECHAT_QR, getSessionsWithDates } from "@/config/home.config";
import {
redirectToPay,
redirectToPayH5,
@@ -16,6 +16,15 @@ import {
const ageRangeOptions = ["18-22", "23-27", "28-32", "33-37", "38+"];
/** 活动场次选项:周六/周日,用于 meetupdate 下拉 */
function getMeetupdateOptions(): { value: string; label: string; type: string }[] {
return getSessionsWithDates().map((s) => ({
value: `${s.date} ${s.time}`,
label: `${s.date} ${s.time} · ${s.title}`,
type: s.id,
}));
}
/** 微信号仅允许英文字母、数字、下划线、连字符 */
const WECHAT_REGEX = /^[a-zA-Z0-9_-]*$/;
@@ -137,6 +146,11 @@ export default function JoinPage() {
const [profession, setProfession] = useState("");
const [gender, setGender] = useState("");
const [ageRange, setAgeRange] = useState("");
const meetupdateOptions = getMeetupdateOptions();
const [meetupdate, setMeetupdate] = useState(() => {
const opts = getMeetupdateOptions();
return opts[0]?.value ?? "";
});
const [agreeRules, setAgreeRules] = useState(true);
const [urlError, setUrlError] = useState<string | null>(null);
const [profile, setProfile] = useState<{
@@ -319,6 +333,10 @@ export default function JoinPage() {
setError("请选择性别");
return;
}
if (!meetupdate) {
setError("请选择要参加的活动场次");
return;
}
submittingRef.current = true;
lastSubmitTime.current = now;
@@ -340,6 +358,8 @@ export default function JoinPage() {
const qualify = formGenderFemale && postCountOk;
const genderLabel = gender === "女" ? "女" : gender === "男" ? "男" : "无";
const submitXiaohongshuUrl = profile?.resolvedUrl || extractXiaohongshuUrl(xiaohongshuUrl) || "";
const selectedOption = meetupdateOptions.find((o) => o.value === meetupdate);
const sessionType = selectedOption?.type ?? "session-1";
const res = await fetch("/api/join", {
method: "POST",
@@ -375,7 +395,8 @@ export default function JoinPage() {
: "",
age_range: ageRange,
city: "深圳",
session: fromVolunteer ? "volunteer" : "digital-nomad",
session: sessionType,
meetupdate: meetupdate.trim(),
education: "",
graduation_year: "",
agree_rules: true,
@@ -435,6 +456,7 @@ export default function JoinPage() {
setProfile(null);
setValidating(false);
setSubmitted(false);
setMeetupdate(meetupdateOptions[0]?.value ?? "");
setSubmitting(false);
setQualify(false);
setError(null);
@@ -482,6 +504,12 @@ export default function JoinPage() {
const params = new URLSearchParams(window.location.search);
setFromPaid(params.get("paid") === "1");
setFromVolunteer(params.get("volunteer") === "1");
const sessionParam = params.get("session");
if (sessionParam && (sessionParam === "session-1" || sessionParam === "session-2")) {
const opts = getMeetupdateOptions();
const match = opts.find((o) => o.type === sessionParam);
if (match) setMeetupdate(match.value);
}
const pendingId = getPendingOrderId();
const stored = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim();
@@ -941,6 +969,15 @@ export default function JoinPage() {
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">💼 <span className="text-red-500">*</span></label>
<input type="text" maxLength={140} placeholder="您的职业或专业" value={profession} onChange={(e) => setProfession(e.target.value)} required className="form-input" />
</div>
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">📅 <span className="text-red-500">*</span></label>
<select value={meetupdate} onChange={(e) => setMeetupdate(e.target.value)} required className={`form-input appearance-none ${meetupdate ? "text-stone-800 dark:text-stone-100" : "text-stone-400 dark:text-stone-500"}`}>
<option value=""></option>
{meetupdateOptions.map((o) => (
<option key={o.type} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">👤 <span className="text-red-500">*</span></label>
<select value={gender} onChange={(e) => setGender(e.target.value)} required className={`form-input appearance-none ${gender ? "text-stone-800 dark:text-stone-100" : "text-stone-400 dark:text-stone-500"}`}>

View File

@@ -6,6 +6,7 @@ const TZ = "Asia/Shanghai";
/**
* 获取中国时区下的日期数字(月、日、星期)
* 无论服务器/客户端在何地,均返回中国公历
*/
function getChinaDateParts(): { year: number; month: number; day: number; weekday: number } {
const now = new Date();
@@ -28,6 +29,27 @@ function getChinaDateParts(): { year: number; month: number; day: number; weekda
};
}
/**
* 获取当前中国公历时间字符串,用于上传
* 格式YYYY-MM-DD HH:mm:ss中国时区
*/
export function getNowChinaStr(): string {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const parts = formatter.formatToParts(new Date());
const pad = (s: string) => s.padStart(2, "0");
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "0";
return `${get("year")}-${pad(get("month"))}-${pad(get("day"))} ${pad(get("hour"))}:${pad(get("minute"))}:${pad(get("second"))}`;
}
/**
* 格式化为中文日期M月D日 周X
*/
@@ -36,48 +58,75 @@ function formatDateCN(month: number, day: number, weekday: number): string {
}
/**
* 获取下一个周六、周日的日期(中国历)
* session-1 对应周六session-2 对应周日
* 获取下一个周六、周日的日期(中国历)
* 使用 +08:00 显式构造,避免服务器/客户端时区影响
*/
export function getNextWeekendDates(): { saturday: string; sunday: string } {
const { year, month, day, weekday } = getChinaDateParts();
const pad = (n: number) => String(n).padStart(2, "0");
const todayStr = `${year}-${pad(month)}-${pad(day)}T12:00:00+08:00`;
const today = new Date(todayStr);
let satM = month;
let satD = day;
let satW = 6;
let sunM = month;
let sunD = day;
let sunW = 0;
if (weekday === 0) {
// 今天周日:下周六 +6 天,下周日 +7 天
const d = new Date(year, month - 1, day);
d.setDate(d.getDate() + 6);
satM = d.getMonth() + 1;
satD = d.getDate();
d.setDate(d.getDate() + 1);
sunM = d.getMonth() + 1;
sunD = d.getDate();
} else if (weekday === 6) {
// 今天周六:明天周日
const d = new Date(year, month - 1, day);
d.setDate(d.getDate() + 1);
sunM = d.getMonth() + 1;
sunD = d.getDate();
} else {
// 周一至周五
const daysToSat = 6 - weekday;
const d = new Date(year, month - 1, day);
d.setDate(d.getDate() + daysToSat);
satM = d.getMonth() + 1;
satD = d.getDate();
d.setDate(d.getDate() + 1);
sunM = d.getMonth() + 1;
sunD = d.getDate();
}
const daysToSat = weekday === 0 ? 6 : weekday === 6 ? 0 : 6 - weekday;
const saturday = new Date(today);
saturday.setUTCDate(saturday.getUTCDate() + daysToSat);
const sunday = new Date(saturday);
sunday.setUTCDate(sunday.getUTCDate() + 1);
return {
saturday: formatDateCN(satM, satD, 6),
sunday: formatDateCN(sunM, sunD, 0),
saturday: formatDateCN(saturday.getUTCMonth() + 1, saturday.getUTCDate(), 6),
sunday: formatDateCN(sunday.getUTCMonth() + 1, sunday.getUTCDate(), 0),
};
}
/**
* 获取中国公历本周一 00:00 至周日 23:59:59 的 UTC 时间范围
* 用于 PocketBase created 过滤。PocketBase 要求 Y-m-d H:i:s.uZ 格式(空格非 T
*/
export function getThisWeekRangeISO(): { start: string; end: string } {
const { year, month, day, weekday } = getChinaDateParts();
const pad = (n: number) => String(n).padStart(2, "0");
const todayStr = `${year}-${pad(month)}-${pad(day)}T12:00:00+08:00`;
const today = new Date(todayStr);
const daysToMonday = weekday === 0 ? 6 : weekday - 1;
const monday = new Date(today);
monday.setUTCDate(monday.getUTCDate() - daysToMonday);
const sunday = new Date(monday);
sunday.setUTCDate(sunday.getUTCDate() + 6);
const mondayStr = `${monday.getUTCFullYear()}-${pad(monday.getUTCMonth() + 1)}-${pad(monday.getUTCDate())}T00:00:00+08:00`;
const sundayStr = `${sunday.getUTCFullYear()}-${pad(sunday.getUTCMonth() + 1)}-${pad(sunday.getUTCDate())}T23:59:59.999+08:00`;
const startDate = new Date(mondayStr);
const endDate = new Date(sundayStr);
const fmt = (d: Date, ms: string) => {
const y = d.getUTCFullYear();
const m = pad(d.getUTCMonth() + 1);
const day = pad(d.getUTCDate());
const h = pad(d.getUTCHours());
const min = pad(d.getUTCMinutes());
const s = pad(d.getUTCSeconds());
return `${y}-${m}-${day} ${h}:${min}:${s}.${ms}Z`;
};
return {
start: fmt(startDate, "000"),
end: fmt(endDate, "999"),
};
}
/**
* 获取中国公历本周一、周日的日期字符串YYYY-MM-DD用于 submitted_at_cn 过滤
*/
export function getThisWeekRangeChinaStr(): { start: string; end: string } {
const { year, month, day, weekday } = getChinaDateParts();
const pad = (n: number) => String(n).padStart(2, "0");
const todayStr = `${year}-${pad(month)}-${pad(day)}T12:00:00+08:00`;
const today = new Date(todayStr);
const daysToMonday = weekday === 0 ? 6 : weekday - 1;
const monday = new Date(today);
monday.setUTCDate(monday.getUTCDate() - daysToMonday);
const sunday = new Date(monday);
sunday.setUTCDate(sunday.getUTCDate() + 6);
return {
start: `${monday.getUTCFullYear()}-${pad(monday.getUTCMonth() + 1)}-${pad(monday.getUTCDate())} 00:00:00`,
end: `${sunday.getUTCFullYear()}-${pad(sunday.getUTCMonth() + 1)}-${pad(sunday.getUTCDate())} 23:59:59`,
};
}

55
app/lib/joiners.ts Normal file
View File

@@ -0,0 +1,55 @@
/** 报名者项mock 用 nameDB 用 nickname */
export type JoinerItem = {
name?: string;
nickname?: string;
avatar?: string;
xiaohongshu_url?: string;
};
/** 去重 key小红书 url 相同视为 1 人,无 url 时用 avatar再 fallback nickname */
function dedupeKey(j: JoinerItem, useAvatarForMock: boolean): string {
const xh = (j.xiaohongshu_url || "").trim();
if (xh && !useAvatarForMock) return `xh:${xh}`;
const avatar = (j.avatar || "").trim();
if (avatar) return `av:${avatar}`;
return `_${(j.nickname || j.name || "").trim()}`;
}
/**
* 合并 mock 与数据库报名者mock 固定 3 个在左DB 在右
* 小红书 url 相同视为 1 人avatar 相同也只显示 1 个
* 最多显示 8 个,保证 PC/H5 排版合理
*/
export function mergeJoiners(
mock: JoinerItem[],
db: JoinerItem[] | undefined
): JoinerItem[] {
const seen = new Set<string>();
const result: JoinerItem[] = [];
mock.forEach((j) => {
const key = dedupeKey(j, true);
if (!seen.has(key)) {
seen.add(key);
result.push(j);
}
});
(db || []).forEach((j) => {
const key = dedupeKey(j, false);
if (!seen.has(key)) {
seen.add(key);
result.push(j);
}
});
return result.slice(0, 8);
}
/** 数据库报名者按小红书 url 去重后的数量(用于显示报名人数) */
export function getUniqueDbCount(db: JoinerItem[] | undefined): number {
if (!db?.length) return 0;
const seen = new Set<string>();
for (const j of db) {
const key = dedupeKey(j, false);
seen.add(key);
}
return seen.size;
}

View File

@@ -64,14 +64,18 @@ export const HOSTS = [
/** 数字游民社区 - 符合条件时显示的微信二维码 */
export const DIGITAL_NOMAD_WECHAT_QR = "/images/QR.png";
/** 最近报名意向 - mock 数据,含小红书昵称与主页链接(悬浮显示昵称,点击不跳转) */
/** 最近报名意向 - mock 数据,中国女性头像,含小红书昵称与主页链接 */
export const RECENT_JOINERS = [
{ name: "晓琪", avatar: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "雨晴", avatar: "https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "子豪", avatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "林晓", avatar: "https://images.unsplash.com/photo-1529626455594-4ff0802cfb7e?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "佳琪", avatar: "https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "浩然", avatar: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "心怡", avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "阿杰", avatar: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "晓琪", avatar: "https://images.unsplash.com/photo-1589553009868-c7b2bb474531?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "雨晴", avatar: "https://images.unsplash.com/photo-1617187703472-9508e9d3d198?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "子豪", avatar: "https://images.unsplash.com/photo-1624803642337-2639de804c57?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "林晓", avatar: "https://images.unsplash.com/photo-1534751516642-a1af1ef26a56?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "佳琪", avatar: "https://images.unsplash.com/photo-1593519544233-57d84925abb4?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "浩然", avatar: "https://images.unsplash.com/photo-1623512083603-5068ca8290f4?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "心怡", avatar: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "阿杰", avatar: "https://images.unsplash.com/photo-1627008320558-9d59b27cb732?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
];
/** 周六场 mock 头像(晓琪、雨晴、子豪) */
export const MOCK_JOINERS_SESSION1 = RECENT_JOINERS.slice(0, 3);
/** 周日场 mock 头像(林晓、佳琪、浩然),与周六完全不重复 */
export const MOCK_JOINERS_SESSION2 = RECENT_JOINERS.slice(3, 6);

View File

@@ -20,6 +20,7 @@ const nextConfig: NextConfig = {
{ protocol: "https", hostname: "i.pravatar.cc", pathname: "/**" },
{ protocol: "https", hostname: "images.unsplash.com", pathname: "/**" },
{ protocol: "https", hostname: "sns-webpic-qc.xhscdn.com", pathname: "/**" },
{ protocol: "https", hostname: "sns-avatar-qc.xhscdn.com", pathname: "/**" },
],
},
};