's'
This commit is contained in:
@@ -18,34 +18,40 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const stats: Record<string, { count: number; joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[] }> = {};
|
const stats: Record<string, { count: number; joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[] }> = {};
|
||||||
|
|
||||||
for (const s of SESSIONS) {
|
const results = await Promise.all(
|
||||||
try {
|
SESSIONS.map(async (s) => {
|
||||||
const filter = encodeURIComponent(`type="${s.id}"`);
|
try {
|
||||||
const res = await fetch(
|
const filter = encodeURIComponent(`type="${s.id}"`);
|
||||||
`${base}/api/collections/${pb.joinCollection}/records?filter=${filter}&sort=-created&perPage=6`,
|
const res = await fetch(
|
||||||
{
|
`${base}/api/collections/${pb.joinCollection}/records?filter=${filter}&sort=-created&perPage=6`,
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
{
|
||||||
cache: "no-store",
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
}
|
cache: "no-store",
|
||||||
);
|
}
|
||||||
if (!res.ok) {
|
);
|
||||||
stats[s.id] = { count: 0, joiners: [] };
|
if (!res.ok) return { id: s.id, stat: { count: 0, joiners: [] } };
|
||||||
continue;
|
const data = await res.json();
|
||||||
|
const items = Array.isArray(data?.items) ? data.items : [];
|
||||||
|
const total = typeof data?.totalItems === "number" ? data.totalItems : items.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 }) => ({
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
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;
|
|
||||||
stats[s.id] = {
|
for (const { id, stat } of results) {
|
||||||
count: total,
|
stats[id] = stat;
|
||||||
joiners: items.slice(0, 6).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,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
stats[s.id] = { count: 0, joiners: [] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, stats });
|
return NextResponse.json({ ok: true, stats });
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import HostLink from "./HostLink";
|
|||||||
import Footer from "./Footer";
|
import Footer from "./Footer";
|
||||||
import Header from "./Header";
|
import Header from "./Header";
|
||||||
import { getSessionsWithDates, HOSTS } from "@/config/home.config";
|
import { getSessionsWithDates, HOSTS } from "@/config/home.config";
|
||||||
|
import { useCompleteOrderPolling } from "@/app/hooks/useCompleteOrderPolling";
|
||||||
|
import { Check, X, ClipboardList, Calendar, Lightbulb, Users, HelpCircle } from "lucide-react";
|
||||||
|
|
||||||
const WECHAT_STORAGE_KEY = "salon_join_wechat";
|
export type JoinStatsMap = Record<string, { count: number; joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[] }>;
|
||||||
|
|
||||||
interface HomeContentProps {
|
interface HomeContentProps {
|
||||||
initialVolunteer: { nickname: string; avatar?: string; xiaohongshu_url?: string } | null;
|
initialVolunteer: { nickname: string; avatar?: string; xiaohongshu_url?: string } | null;
|
||||||
@@ -33,15 +35,12 @@ function getPendingOrderId(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPendingOrder(): void {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem("join_pay_order");
|
|
||||||
document.cookie = "join_pay_order=; path=/; max-age=0";
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
||||||
const [volunteer, setVolunteer] = useState(initialVolunteer);
|
const [volunteer, setVolunteer] = useState(initialVolunteer);
|
||||||
|
const [stats, setStats] = useState<JoinStatsMap | null>(null);
|
||||||
|
const [pendingOrderId] = useState<string | null>(() =>
|
||||||
|
typeof window !== "undefined" ? getPendingOrderId() : null
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/join/volunteers")
|
fetch("/api/join/volunteers")
|
||||||
@@ -50,55 +49,18 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 支付完成后再次打开首页:后台完成订单并刷新用户信息(手机浏览器唤醒微信支付后,用户可能直接回首页,notify 可能未到,需轮询)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const orderId = getPendingOrderId();
|
fetch("/api/join/stats")
|
||||||
if (!orderId) return;
|
.then((r) => r.json())
|
||||||
|
.then((d) => d?.stats && setStats(d.stats))
|
||||||
let cancelled = false;
|
.catch(() => {});
|
||||||
const tryComplete = async (): Promise<boolean> => {
|
|
||||||
const r = await fetch("/api/salon/complete-order", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ order_id: orderId }),
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
const d = await r.json();
|
|
||||||
if (!d?.ok) return false;
|
|
||||||
if (d?.token && d?.record) {
|
|
||||||
fetch("/api/auth/sync-session", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ token: d.token, record: d.record }),
|
|
||||||
credentials: "include",
|
|
||||||
}).catch(() => {});
|
|
||||||
import("@/app/lib/pocketbase").then(({ pbSaveAuth }) => pbSaveAuth(d.token, d.record));
|
|
||||||
if (d.record?.wechatId) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(WECHAT_STORAGE_KEY, d.record.wechatId);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clearPendingOrder();
|
|
||||||
window.dispatchEvent(new CustomEvent("auth:updated"));
|
|
||||||
window.dispatchEvent(new CustomEvent("vip:updated"));
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const run = async () => {
|
|
||||||
const retryDelays = [1500, 2500, 3500, 4500, 5500];
|
|
||||||
for (let i = 0; i <= retryDelays.length; i++) {
|
|
||||||
if (cancelled) return;
|
|
||||||
if (await tryComplete()) return;
|
|
||||||
if (i < retryDelays.length && !cancelled) {
|
|
||||||
await new Promise((r) => setTimeout(r, retryDelays[i]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void run();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useCompleteOrderPolling({
|
||||||
|
orderId: pendingOrderId,
|
||||||
|
retryDelays: [500, 1000, 1500, 2500, 4000],
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#faf8f5] dark:bg-stone-950 pb-20 sm:pb-12">
|
<div className="min-h-screen bg-[#faf8f5] dark:bg-stone-950 pb-20 sm:pb-12">
|
||||||
<Header />
|
<Header />
|
||||||
@@ -112,7 +74,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
|||||||
<div className="grid sm:grid-cols-2 gap-6 sm:gap-8">
|
<div className="grid sm:grid-cols-2 gap-6 sm:gap-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base sm:text-lg font-semibold text-stone-800 dark:text-stone-100 mb-3 flex items-center gap-2">
|
<h3 className="text-base sm:text-lg font-semibold text-stone-800 dark:text-stone-100 mb-3 flex items-center gap-2">
|
||||||
<span className="text-amber-600 dark:text-amber-400">✓</span> 是什么 · 适合谁
|
<Check className="size-4 sm:size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden /> 是什么 · 适合谁
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-1.5 text-sm text-stone-600 dark:text-stone-400">
|
<ul className="space-y-1.5 text-sm text-stone-600 dark:text-stone-400">
|
||||||
<li>· 周末同城轻社交,loft民宿见~</li>
|
<li>· 周末同城轻社交,loft民宿见~</li>
|
||||||
@@ -122,7 +84,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base sm:text-lg font-semibold text-stone-800 dark:text-stone-100 mb-3 flex items-center gap-2">
|
<h3 className="text-base sm:text-lg font-semibold text-stone-800 dark:text-stone-100 mb-3 flex items-center gap-2">
|
||||||
<span className="text-stone-400 dark:text-stone-500">✗</span> 不是什么 · 不适合谁
|
<X className="size-4 sm:size-5 text-stone-400 dark:text-stone-500 shrink-0" aria-hidden /> 不是什么 · 不适合谁
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-1.5 text-sm text-stone-600 dark:text-stone-400">
|
<ul className="space-y-1.5 text-sm text-stone-600 dark:text-stone-400">
|
||||||
<li>· 不是相亲局、卖课、强制破冰</li>
|
<li>· 不是相亲局、卖课、强制破冰</li>
|
||||||
@@ -137,7 +99,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
|||||||
{/* 报名流程 */}
|
{/* 报名流程 */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
|
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
|
||||||
<span>📋</span> 怎么参加
|
<ClipboardList className="size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden /> 怎么参加
|
||||||
</h2>
|
</h2>
|
||||||
<div className="rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 p-4 sm:p-6 md:p-8">
|
<div className="rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 p-4 sm:p-6 md:p-8">
|
||||||
<div className="grid grid-cols-3 gap-3 sm:gap-6 md:gap-8">
|
<div className="grid grid-cols-3 gap-3 sm:gap-6 md:gap-8">
|
||||||
@@ -168,11 +130,11 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
|||||||
{/* 本周场次 */}
|
{/* 本周场次 */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
|
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
|
||||||
<span>📅</span> 本周活动
|
<Calendar className="size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden /> 本周活动
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid gap-3 sm:gap-4 sm:grid-cols-2">
|
<div className="grid gap-3 sm:gap-4 sm:grid-cols-2 animate-fade-in-stagger">
|
||||||
{getSessionsWithDates().map((s, i) => (
|
{getSessionsWithDates().map((s, i) => (
|
||||||
<SessionCard key={s.id} session={s} index={i} />
|
<SessionCard key={s.id} session={s} index={i} stats={stats?.[s.id] ?? null} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -180,7 +142,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
|||||||
{/* 活动亮点 */}
|
{/* 活动亮点 */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
|
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
|
||||||
<span>💡</span> 活动亮点
|
<Lightbulb className="size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden /> 活动亮点
|
||||||
</h2>
|
</h2>
|
||||||
<div className="rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 p-4 sm:p-6 md:p-8">
|
<div className="rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 p-4 sm:p-6 md:p-8">
|
||||||
<div className="grid sm:grid-cols-2 gap-4 sm:gap-6">
|
<div className="grid sm:grid-cols-2 gap-4 sm:gap-6">
|
||||||
@@ -205,7 +167,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
|||||||
{/* 发起人 + 志愿者(有志愿者时替换 host-2 卡片) */}
|
{/* 发起人 + 志愿者(有志愿者时替换 host-2 卡片) */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
|
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
|
||||||
<span>👤</span> 发起人
|
<Users className="size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden /> 发起人
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid sm:grid-cols-2 gap-3 sm:gap-4 mb-4">
|
<div className="grid sm:grid-cols-2 gap-3 sm:gap-4 mb-4">
|
||||||
{HOSTS.map((host) => {
|
{HOSTS.map((host) => {
|
||||||
@@ -236,7 +198,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<section id="faq">
|
<section id="faq">
|
||||||
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
|
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
|
||||||
<span>❓</span> 常见问题
|
<HelpCircle className="size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden /> 常见问题
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-2 sm:space-y-3">
|
<div className="space-y-2 sm:space-y-3">
|
||||||
{[
|
{[
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { RefreshCw, Coffee } from "lucide-react";
|
||||||
import SessionCoverImage from "./SessionCoverImage";
|
import SessionCoverImage from "./SessionCoverImage";
|
||||||
import AddressLink from "./AddressLink";
|
import AddressLink from "./AddressLink";
|
||||||
import { getSessionsWithDates, ACTIVITY_TYPES, SESSION_TAGS, RECENT_JOINERS } from "@/config/home.config";
|
import { getSessionsWithDates, ACTIVITY_TYPES, SESSION_TAGS, RECENT_JOINERS } from "@/config/home.config";
|
||||||
@@ -31,23 +31,17 @@ interface SessionItem {
|
|||||||
export default function SessionCard({
|
export default function SessionCard({
|
||||||
session,
|
session,
|
||||||
index,
|
index,
|
||||||
|
stats,
|
||||||
}: {
|
}: {
|
||||||
session: SessionItem;
|
session: SessionItem;
|
||||||
index: number;
|
index: number;
|
||||||
|
stats: JoinStats | null;
|
||||||
}) {
|
}) {
|
||||||
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/join?session=${encodeURIComponent(session.id)}`}
|
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"
|
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="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">
|
||||||
{session.coverImage ? (
|
{session.coverImage ? (
|
||||||
@@ -60,8 +54,8 @@ export default function SessionCard({
|
|||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-2xl">
|
<div className="absolute inset-0 flex items-center justify-center text-amber-500/70 dark:text-amber-400/50">
|
||||||
{session.type === "skill-exchange" ? "🔄" : "☕"}
|
{session.type === "skill-exchange" ? <RefreshCw className="size-8" aria-hidden /> : <Coffee className="size-8" aria-hidden />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -134,5 +134,28 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.animate-fade-in {
|
.animate-fade-in {
|
||||||
animation: fadeIn 0.4s ease-out forwards;
|
animation: fadeIn 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片依次淡入,用于列表 */
|
||||||
|
.animate-fade-in-stagger > * {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-stagger > *:nth-child(1) { animation-delay: 0ms; }
|
||||||
|
.animate-fade-in-stagger > *:nth-child(2) { animation-delay: 80ms; }
|
||||||
|
.animate-fade-in-stagger > *:nth-child(3) { animation-delay: 160ms; }
|
||||||
|
.animate-fade-in-stagger > *:nth-child(4) { animation-delay: 240ms; }
|
||||||
|
.animate-fade-in-stagger > *:nth-child(5) { animation-delay: 320ms; }
|
||||||
|
.animate-fade-in-stagger > *:nth-child(6) { animation-delay: 400ms; }
|
||||||
|
|
||||||
|
/* 骨架屏脉冲 */
|
||||||
|
@keyframes pulse-subtle {
|
||||||
|
0%, 100% { opacity: 0.6; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-subtle {
|
||||||
|
animation: pulse-subtle 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
64
app/hooks/useCompleteOrderPolling.ts
Normal file
64
app/hooks/useCompleteOrderPolling.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { tryCompleteOrder, applyCompleteSuccess } from "@/app/lib/complete-order";
|
||||||
|
|
||||||
|
const DEFAULT_RETRY_DELAYS = [1500, 2500, 3500, 4500, 5500];
|
||||||
|
|
||||||
|
export interface UseCompleteOrderPollingOptions {
|
||||||
|
/** 待完成订单 ID,null 则不轮询 */
|
||||||
|
orderId: string | null;
|
||||||
|
/** 成功后的额外回调(用 ref 存储避免 deps 变化) */
|
||||||
|
onSuccess?: (data: { token: string; record?: { wechatId?: string } }) => void;
|
||||||
|
/** 重试间隔(毫秒) */
|
||||||
|
retryDelays?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除 pending order 的 cookie 和 localStorage */
|
||||||
|
export function clearPendingOrder(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem("join_pay_order");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
document.cookie = "join_pay_order=; path=/; max-age=0";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 支付完成后轮询 complete-order,成功后同步 session 并清除 pending */
|
||||||
|
export function useCompleteOrderPolling({
|
||||||
|
orderId,
|
||||||
|
onSuccess,
|
||||||
|
retryDelays = DEFAULT_RETRY_DELAYS,
|
||||||
|
}: UseCompleteOrderPollingOptions): void {
|
||||||
|
const onSuccessRef = useRef(onSuccess);
|
||||||
|
onSuccessRef.current = onSuccess;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orderId || typeof window === "undefined") return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const run = async () => {
|
||||||
|
for (let i = 0; i <= retryDelays.length; i++) {
|
||||||
|
if (cancelled) return;
|
||||||
|
const result = await tryCompleteOrder(orderId);
|
||||||
|
if (result.ok) {
|
||||||
|
if (result.token && result.record) {
|
||||||
|
applyCompleteSuccess({ token: result.token, record: result.record });
|
||||||
|
} else if (result.record) {
|
||||||
|
applyCompleteSuccess({ token: "", record: result.record });
|
||||||
|
}
|
||||||
|
clearPendingOrder();
|
||||||
|
onSuccessRef.current?.({ token: result.token || "", record: result.record });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (i < retryDelays.length && !cancelled) {
|
||||||
|
await new Promise((r) => setTimeout(r, retryDelays[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [orderId, retryDelays]);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, type FormEvent } from "react";
|
import { useState, useEffect, useRef, type FormEvent } from "react";
|
||||||
|
import { useCompleteOrderPolling } from "@/app/hooks/useCompleteOrderPolling";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import ThemeToggle from "../components/ThemeToggle";
|
import ThemeToggle from "../components/ThemeToggle";
|
||||||
import { DIGITAL_NOMAD_WECHAT_QR } from "@/config/home.config";
|
import { DIGITAL_NOMAD_WECHAT_QR } from "@/config/home.config";
|
||||||
@@ -50,13 +51,6 @@ function getPendingOrderId(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPendingOrder(): void {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(ORDER_COOKIE);
|
|
||||||
document.cookie = `${ORDER_COOKIE}=; path=/; max-age=0`;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserRecord {
|
interface UserRecord {
|
||||||
hasRecord: boolean;
|
hasRecord: boolean;
|
||||||
record: {
|
record: {
|
||||||
@@ -165,6 +159,7 @@ export default function JoinPage() {
|
|||||||
const initialWechatCheckDone = useRef(false);
|
const initialWechatCheckDone = useRef(false);
|
||||||
const lastSubmitTime = useRef(0);
|
const lastSubmitTime = useRef(0);
|
||||||
const submittingRef = useRef(false);
|
const submittingRef = useRef(false);
|
||||||
|
const fetchByWechatRef = useRef<(w: string) => Promise<unknown>>(() => Promise.resolve(null));
|
||||||
const SUBMIT_THROTTLE_MS = 3000;
|
const SUBMIT_THROTTLE_MS = 3000;
|
||||||
|
|
||||||
const fetchByWechat = async (w: string) => {
|
const fetchByWechat = async (w: string) => {
|
||||||
@@ -186,6 +181,7 @@ export default function JoinPage() {
|
|||||||
setCheckingWechat(false);
|
setCheckingWechat(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
fetchByWechatRef.current = fetchByWechat;
|
||||||
|
|
||||||
const handleWechatBlur = () => {
|
const handleWechatBlur = () => {
|
||||||
const w = wechat.trim();
|
const w = wechat.trim();
|
||||||
@@ -435,14 +431,54 @@ export default function JoinPage() {
|
|||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
setFromPaid(params.get("paid") === "1");
|
setFromPaid(params.get("paid") === "1");
|
||||||
setFromVolunteer(params.get("volunteer") === "1");
|
setFromVolunteer(params.get("volunteer") === "1");
|
||||||
|
|
||||||
|
const pendingId = getPendingOrderId();
|
||||||
|
const stored = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim();
|
||||||
|
|
||||||
|
// 有 pending 时先查 by-wechat:若已支付(vip/amount)则直接展示报名成功,不跳转 /join/paid
|
||||||
|
if (pendingId && !params.has("paid") && stored && WECHAT_REGEX.test(stored)) {
|
||||||
|
setWechat(stored);
|
||||||
|
let cancelled = false;
|
||||||
|
fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(stored)}`, { cache: "no-store" })
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const isPaid = data?.vip || data?.isComplete || (data?.record && Number(data.record?.amount || 0) > 0);
|
||||||
|
if (isPaid) {
|
||||||
|
setWechat(stored);
|
||||||
|
setUserRecord(toUserRecord(data));
|
||||||
|
if (!initialWechatCheckDone.current) {
|
||||||
|
initialWechatCheckDone.current = true;
|
||||||
|
}
|
||||||
|
setHydrated(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.replace(`/join/paid?order_id=${encodeURIComponent(pendingId)}`);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) window.location.replace(`/join/paid?order_id=${encodeURIComponent(pendingId)}`);
|
||||||
|
});
|
||||||
|
setHydrated(true);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingId && !params.has("paid")) {
|
||||||
|
try {
|
||||||
|
window.location.replace(`/join/paid?order_id=${encodeURIComponent(pendingId)}`);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!initialWechatCheckDone.current) {
|
if (!initialWechatCheckDone.current) {
|
||||||
initialWechatCheckDone.current = true;
|
initialWechatCheckDone.current = true;
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(WECHAT_STORAGE_KEY);
|
if (stored && WECHAT_REGEX.test(stored)) {
|
||||||
const w = (stored || "").trim();
|
setWechat(stored);
|
||||||
if (w && WECHAT_REGEX.test(w)) {
|
fetchByWechat(stored);
|
||||||
setWechat(w);
|
|
||||||
fetchByWechat(w);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -451,65 +487,42 @@ export default function JoinPage() {
|
|||||||
setHydrated(true);
|
setHydrated(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 手机浏览器支付后若回到 /join:有 pending order 时主动轮询 complete-order,触发后端更新 solanRed(ZPAY notify 可能未到)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const orderId = getPendingOrderId();
|
const refreshOnReturn = () => {
|
||||||
if (!orderId) return;
|
try {
|
||||||
const shouldComplete =
|
const w = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim();
|
||||||
userRecord?.hasRecord &&
|
if (w && WECHAT_REGEX.test(w)) fetchByWechatRef.current(w);
|
||||||
userRecord?.isWechatFriend &&
|
} catch {
|
||||||
!userRecord?.vip &&
|
/* ignore */
|
||||||
!userRecord?.isComplete;
|
|
||||||
if (!shouldComplete) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const tryComplete = async (): Promise<boolean> => {
|
|
||||||
const r = await fetch("/api/salon/complete-order", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ order_id: orderId }),
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
const d = await r.json();
|
|
||||||
if (!d?.ok) return false;
|
|
||||||
if (d?.token && d?.record) {
|
|
||||||
fetch("/api/auth/sync-session", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ token: d.token, record: d.record }),
|
|
||||||
credentials: "include",
|
|
||||||
}).catch(() => {});
|
|
||||||
import("@/app/lib/pocketbase").then(({ pbSaveAuth }) => pbSaveAuth(d.token, d.record));
|
|
||||||
}
|
|
||||||
clearPendingOrder();
|
|
||||||
window.dispatchEvent(new CustomEvent("auth:updated"));
|
|
||||||
window.dispatchEvent(new CustomEvent("vip:updated"));
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const run = async () => {
|
|
||||||
const retryDelays = [1500, 2500, 3500, 4500, 5500];
|
|
||||||
for (let i = 0; i <= retryDelays.length; i++) {
|
|
||||||
if (cancelled) return;
|
|
||||||
if (await tryComplete()) {
|
|
||||||
if (wechat.trim()) fetchByWechat(wechat.trim());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (i < retryDelays.length && !cancelled) {
|
|
||||||
await new Promise((r) => setTimeout(r, retryDelays[i]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void run();
|
const onVisibilityChange = () => {
|
||||||
return () => { cancelled = true; };
|
if (document.visibilityState === "visible") refreshOnReturn();
|
||||||
}, [
|
};
|
||||||
userRecord?.hasRecord,
|
const onPageShow = (e: PageTransitionEvent) => {
|
||||||
userRecord?.isWechatFriend,
|
if (e.persisted) refreshOnReturn();
|
||||||
userRecord?.vip,
|
};
|
||||||
userRecord?.isComplete,
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
wechat,
|
window.addEventListener("pageshow", onPageShow);
|
||||||
]);
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
window.removeEventListener("pageshow", onPageShow);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [completeOrderId] = useState<string | null>(() =>
|
||||||
|
typeof window !== "undefined" ? getPendingOrderId() : null
|
||||||
|
);
|
||||||
|
|
||||||
|
useCompleteOrderPolling({
|
||||||
|
orderId: completeOrderId,
|
||||||
|
onSuccess: () => {
|
||||||
|
const w = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim();
|
||||||
|
if (w && WECHAT_REGEX.test(w)) fetchByWechatRef.current(w);
|
||||||
|
},
|
||||||
|
retryDelays: [500, 1000, 1500, 2500, 4000],
|
||||||
|
});
|
||||||
|
|
||||||
// 微信内/手机浏览器支付后:页面可能被关闭或跳转,返回时 notify 可能未到。轮询 fetchByWechat 检测支付成功
|
// 微信内/手机浏览器支付后:页面可能被关闭或跳转,返回时 notify 可能未到。轮询 fetchByWechat 检测支付成功
|
||||||
// 手机浏览器唤醒微信 app 支付完成后,用户可能直接回 /join,故 h5 也需轮询(参考 nomadvip)
|
// 手机浏览器唤醒微信 app 支付完成后,用户可能直接回 /join,故 h5 也需轮询(参考 nomadvip)
|
||||||
@@ -629,6 +642,13 @@ export default function JoinPage() {
|
|||||||
if (showPayPage) {
|
if (showPayPage) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#faf8f5] dark:bg-stone-950">
|
<div className="min-h-screen bg-[#faf8f5] dark:bg-stone-950">
|
||||||
|
{paying && (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-white/95 dark:bg-stone-950/95 backdrop-blur-sm">
|
||||||
|
<span className="inline-block h-12 w-12 animate-spin rounded-full border-4 border-amber-500 border-t-transparent" />
|
||||||
|
<p className="mt-4 text-base font-medium text-stone-700 dark:text-stone-300">正在跳转支付…</p>
|
||||||
|
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400">请勿关闭页面</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<header className="sticky top-0 z-40 w-full border-b border-stone-200/80 dark:border-stone-800 bg-[#faf8f5]/95 dark:bg-stone-950/95 backdrop-blur">
|
<header className="sticky top-0 z-40 w-full border-b border-stone-200/80 dark:border-stone-800 bg-[#faf8f5]/95 dark:bg-stone-950/95 backdrop-blur">
|
||||||
<div className="max-w-2xl mx-auto px-4 h-12 sm:h-14 flex items-center justify-between">
|
<div className="max-w-2xl mx-auto px-4 h-12 sm:h-14 flex items-center justify-between">
|
||||||
<Link href="/" className="text-sm font-medium text-stone-600 dark:text-stone-400">← 返回</Link>
|
<Link href="/" className="text-sm font-medium text-stone-600 dark:text-stone-400">← 返回</Link>
|
||||||
|
|||||||
@@ -2,16 +2,11 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { tryCompleteOrder, applyCompleteSuccess } from "@/app/lib/complete-order";
|
||||||
|
import { clearPendingOrder } from "@/app/hooks/useCompleteOrderPolling";
|
||||||
|
|
||||||
const WECHAT_STORAGE_KEY = "salon_join_wechat";
|
const WECHAT_STORAGE_KEY = "salon_join_wechat";
|
||||||
|
|
||||||
function clearPendingOrder(): void {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem("join_pay_order");
|
|
||||||
} catch {}
|
|
||||||
document.cookie = "join_pay_order=; path=/; max-age=0";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function JoinPaidPage() {
|
export default function JoinPaidPage() {
|
||||||
const [completeStatus, setCompleteStatus] = useState<"pending" | "success" | "error">("pending");
|
const [completeStatus, setCompleteStatus] = useState<"pending" | "success" | "error">("pending");
|
||||||
const [errorDetail, setErrorDetail] = useState<string | null>(null);
|
const [errorDetail, setErrorDetail] = useState<string | null>(null);
|
||||||
@@ -21,31 +16,6 @@ export default function JoinPaidPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const tryComplete = async (orderId: string): Promise<{ ok: boolean; error?: string; record?: { wechatId?: string } }> => {
|
|
||||||
const r = await fetch("/api/salon/complete-order", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ order_id: orderId }),
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
const d = await r.json();
|
|
||||||
if (!d?.ok) return { ok: false, error: d?.error || d?.detail || `HTTP ${r.status}` };
|
|
||||||
|
|
||||||
if (d?.token && d?.record) {
|
|
||||||
await fetch("/api/auth/sync-session", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ token: d.token, record: d.record }),
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
|
||||||
pbSaveAuth(d.token, d.record);
|
|
||||||
}
|
|
||||||
window.dispatchEvent(new CustomEvent("auth:updated"));
|
|
||||||
window.dispatchEvent(new CustomEvent("vip:updated"));
|
|
||||||
return { ok: true, record: d?.record };
|
|
||||||
};
|
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const urlOrderId = typeof window !== "undefined"
|
const urlOrderId = typeof window !== "undefined"
|
||||||
? new URLSearchParams(window.location.search).get("order_id") || ""
|
? new URLSearchParams(window.location.search).get("order_id") || ""
|
||||||
@@ -68,13 +38,18 @@ export default function JoinPaidPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxClientRetries = 5;
|
const maxClientRetries = 5;
|
||||||
const retryDelays = [2000, 3000, 4000, 5000, 6000];
|
const retryDelays = [800, 1500, 2500, 4000, 6000];
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
for (let i = 0; i <= maxClientRetries; i++) {
|
for (let i = 0; i <= maxClientRetries; i++) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
try {
|
try {
|
||||||
const result = await tryComplete(orderId);
|
const result = await tryCompleteOrder(orderId);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
if (result.token && result.record) {
|
||||||
|
applyCompleteSuccess({ token: result.token, record: result.record });
|
||||||
|
} else if (result.record) {
|
||||||
|
applyCompleteSuccess({ token: "", record: result.record });
|
||||||
|
}
|
||||||
clearPendingOrder();
|
clearPendingOrder();
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCompleteStatus("success");
|
setCompleteStatus("success");
|
||||||
|
|||||||
50
app/lib/complete-order.ts
Normal file
50
app/lib/complete-order.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/** 共享:完成订单 API 调用与成功后的 session 同步 */
|
||||||
|
|
||||||
|
import type { AuthUser } from "@/app/lib/pocketbase";
|
||||||
|
|
||||||
|
const WECHAT_STORAGE_KEY = "salon_join_wechat";
|
||||||
|
|
||||||
|
export interface CompleteOrderResult {
|
||||||
|
ok: boolean;
|
||||||
|
token?: string;
|
||||||
|
record?: { wechatId?: string; [k: string]: unknown };
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调用 complete-order API */
|
||||||
|
export async function tryCompleteOrder(orderId: string): Promise<CompleteOrderResult> {
|
||||||
|
const r = await fetch("/api/salon/complete-order", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ order_id: orderId }),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d?.ok) return { ok: false, error: d?.error || d?.detail || `HTTP ${r.status}` };
|
||||||
|
return { ok: true, token: d.token, record: d.record };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成功后的通用处理:sync-session、pbSaveAuth、dispatch 事件、localStorage wechatId。token 为空时仅 dispatch 和保存 wechatId(非 pending 用户) */
|
||||||
|
export function applyCompleteSuccess(data: { token?: string; record?: { wechatId?: string; [k: string]: unknown } }): void {
|
||||||
|
const { token, record } = data;
|
||||||
|
if (token) {
|
||||||
|
fetch("/api/auth/sync-session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token, record }),
|
||||||
|
credentials: "include",
|
||||||
|
}).catch(() => {});
|
||||||
|
if (record && "id" in record && "email" in record) {
|
||||||
|
import("@/app/lib/pocketbase").then(({ pbSaveAuth }) => pbSaveAuth(token, record as AuthUser));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent("auth:updated"));
|
||||||
|
window.dispatchEvent(new CustomEvent("vip:updated"));
|
||||||
|
if (record?.wechatId) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(WECHAT_STORAGE_KEY, record.wechatId);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/page.tsx
10
app/page.tsx
@@ -1,10 +1,6 @@
|
|||||||
import { getVolunteer } from "@/app/lib/volunteers";
|
|
||||||
import HomeContent from "./components/HomeContent";
|
import HomeContent from "./components/HomeContent";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
/** 首页:志愿者由客户端加载,避免 SSR 阻塞首屏 */
|
||||||
|
export default function Home() {
|
||||||
/** 首页:服务端先查询第一个志愿者,替换发起人志愿者卡片 */
|
return <HomeContent initialVolunteer={null} />;
|
||||||
export default async function Home() {
|
|
||||||
const volunteer = await getVolunteer();
|
|
||||||
return <HomeContent initialVolunteer={volunteer} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pocketbase": "^0.26.8",
|
"pocketbase": "^0.26.8",
|
||||||
|
|||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^0.577.0
|
||||||
|
version: 0.577.0(react@19.2.3)
|
||||||
next:
|
next:
|
||||||
specifier: 16.1.6
|
specifier: 16.1.6
|
||||||
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -1466,6 +1469,11 @@ packages:
|
|||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
|
lucide-react@0.577.0:
|
||||||
|
resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
@@ -3462,6 +3470,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
lucide-react@0.577.0(react@19.2.3):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.3
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|||||||
Reference in New Issue
Block a user