{formattedDate}
+{meetup.emoji} {meetup.city}
+ {meetup.rsvpCount} 人报名 +@ {meetup.venue}
+{meetup.address}
+{meetup.description}
+ ++ 游牧中国(NomadCNA)是中国数字游民社区,致力于连接全球华人远程工作者与旅居者。 + 我们相信地点自由、工作自由,让每个人都能在探索世界的同时创造价值。 +
++ 打造中国最活跃的数字游民社区,提供城市攻略、线下聚会、远程工作资源, + 帮助更多人开启游牧式工作与生活。 +
++ 如有合作或咨询,欢迎通过社群或邮件与我们联系。 +
+📍 {currentProfile.location}
+匹配标签:
+{currentProfile.bio}
+{currentIndex + 1} / {MOCK_PROFILES.length}
++ 加入游牧中国团队,享受远程工作与地点自由。我们正在寻找热爱数字游民文化的伙伴, + 一起打造更好的社区体验。 +
++ 我们目前暂无开放职位,但欢迎将简历发送至我们的邮箱,我们会将您纳入人才库, + 有合适机会时第一时间联系。 +
++ 全员远程,异步协作,支持全球旅居。我们相信结果导向,而非坐班时长。 +
+
+ 我们会尽快审核你的信息
+
+ 审核通过后将通过微信邀请你入群
+
+ {"💼 结识志同道合的伙伴\n📍 线下聚会 + 线上分享\n🤝 互助成长,资源共享"} +
+
+ 我们会尽快审核你的信息
+
+ 审核通过后将通过微信邀请你入群
+
{formattedDate}
+@ {meetup.venue}
+{meetup.address}
+{meetup.description}
+ ++ 与全球数字游民面对面交流,拓展人脉,分享经验 +
++ {activeTab === "upcoming" ? "共" : "共"} + {totalCount} + {activeTab === "upcoming" ? "场即将举办的聚会" : "场往期聚会"} +
+ ++ {activeTab === "upcoming" ? "暂无即将举办的聚会" : "暂无往期聚会记录"} +
+个城市
+ 18 +个城市
位游民
+ 5,000+ +位游民
场聚会/月
+ 8+ +场聚会/月
起/月生活费
+ ¥3,000 +起/月生活费
+
加入数字游民微信社群
-+
3,200+ 在线成员
@@ -157,10 +156,10 @@ export default function Home() { src={m.photo} alt={m.name} title={m.name} - className="w-6 h-6 sm:w-10 sm:h-10 rounded-full border-2 border-white shadow-sm object-cover" + className="w-6 h-6 sm:w-10 sm:h-10 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover" /> ))} -+
没有找到匹配的城市
+
{route.desc}
{m.city}
-{m.date}
+{m.city}
+{m.date}
+
{t.title}
-+
{t.views.toLocaleString()} 次浏览
数字游民定制方案
+数字游民定制方案
+
覆盖全国 + 境外,远程工作专属条款,¥15/天起
-本月 3,200+ 条消息
+本月 3,200+ 条消息
+
租房 · 共享办公 · 本地攻略
正在跳转到支付页面...
`; + const res = new NextResponse(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } }); + if (orderId) { + res.cookies.set("join_pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 }); + } + return res; + } + + return NextResponse.json({ ok: true, pay_url: payUrl, params, provider: "zpay" }); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error("Pay API error:", err); + return NextResponse.json({ ok: false, error: err.message }, { status: 500 }); + } +} diff --git a/app/api/pay/status/route.ts b/app/api/pay/status/route.ts new file mode 100644 index 0000000..1a52f85 --- /dev/null +++ b/app/api/pay/status/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPaymentConfig } from "@/config/services.config"; + +export async function GET(request: NextRequest) { + const orderId = request.nextUrl.searchParams.get("order_id"); + if (!orderId) { + return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 }); + } + const config = getPaymentConfig(); + if (!config.apiUrl) { + return NextResponse.json({ ok: false, error: "未配置 PAYMENT_API_URL" }, { status: 500 }); + } + try { + const res = await fetch( + `${config.apiUrl}/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`, + { cache: "no-store" } + ); + const data = await res.json().catch(() => ({})); + return NextResponse.json({ ok: true, paid: !!data?.paid }); + } catch (e) { + return NextResponse.json({ ok: false, paid: false, error: String(e) }, { status: 500 }); + } +} diff --git a/app/api/upload-media/route.ts b/app/api/upload-media/route.ts new file mode 100644 index 0000000..468ca8f --- /dev/null +++ b/app/api/upload-media/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { uploadBuffer, generateObjectKey } from "@/app/lib/minio"; + +const MAX_SIZE = 20 * 1024 * 1024; // 20MB + +export async function POST(request: NextRequest) { + try { + const form = await request.formData(); + const file = form.get("file"); + const fileObj = + file != null && typeof file === "object" && "size" in file + ? (file as File | Blob) + : null; + + if (!fileObj || fileObj.size === 0) { + return NextResponse.json({ ok: false, error: "请选择文件" }, { status: 400 }); + } + + if (fileObj.size > MAX_SIZE) { + return NextResponse.json({ ok: false, error: "文件大小不能超过 20MB" }, { status: 400 }); + } + + const ext = + (fileObj instanceof File ? fileObj.name : "").split(".").pop() || + (fileObj.type?.startsWith("video/") ? "mp4" : "jpg"); + const objectKey = generateObjectKey(ext); + + const buffer = Buffer.from(await fileObj.arrayBuffer()); + const contentType = (fileObj as File).type || "application/octet-stream"; + + const { url } = await uploadBuffer(buffer, objectKey, contentType); + + return NextResponse.json({ ok: true, url }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("Upload media error:", e); + return NextResponse.json({ ok: false, error: `上传失败: ${msg}` }, { status: 500 }); + } +} diff --git a/app/components/AuthModal.tsx b/app/components/AuthModal.tsx new file mode 100644 index 0000000..edb38fc --- /dev/null +++ b/app/components/AuthModal.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useState, useEffect, type FormEvent } from "react"; +import { createPortal } from "react-dom"; +import { + pbLogin, + pbRegister, + pbSaveAuth, +} from "@/app/lib/pocketbase"; +import { useTranslation } from "@/i18n/navigation"; + +interface AuthModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (email: string) => void; +} + +export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps) { + const { t } = useTranslation("auth"); + const [mode, setMode] = useState<"login" | "register">("login"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState+ {mode === "login" ? t("loginHint") : t("registerHint")} +
+ + + +
+ {mode === "login" ? (
+ <>
+ {t("noAccount")}{" "}
+
+ {city.countryEmoji} {city.country} +
++ {t("breadcrumbPrefix")} > {region} > {city.country} > {city.name} +
+{city.icon}
++ 「{tabs.find((x) => x.id === activeTab) ? t(tabs.find((x) => x.id === activeTab)!.labelKey) : activeTab}」{t("comingSoon")} +
+