From ae17a84ca0b128f594dd3d3d1c3aa606c9ca8f50 Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 15 Mar 2026 11:19:52 -0500 Subject: [PATCH] 'init' --- .env.local | 1 + .gitignore | 48 + README.md | 39 +- app/api/auth/logout/route.ts | 8 + app/api/auth/me/route.ts | 85 + app/api/auth/sync-session/route.ts | 22 + app/api/join/route.ts | 60 + app/api/join/status/route.ts | 38 + app/api/pay/route.ts | 467 ++ app/api/pay/status/route.ts | 46 + app/api/salon/check-user/route.ts | 28 + app/api/salon/complete-order/route.ts | 27 + app/api/salon/ensure-user/route.ts | 29 + .../salon/set-needs-password-change/route.ts | 5 + app/api/upload-media/route.ts | 23 + app/api/vip/check/route.ts | 41 + app/components/AuthModal.tsx | 177 + app/components/CityCard.tsx | 83 + app/components/CityModal.tsx | 46 + app/components/FilterBar.tsx | 88 + app/components/Footer.tsx | 9 + app/components/HeroSection.tsx | 273 + app/components/JoinModal.tsx | 152 + app/data/cities.ts | 100 + app/globals.css | 107 + app/join/page.tsx | 505 ++ app/join/paid/page.tsx | 153 + app/layout.tsx | 19 + app/lib/auth-cookie.ts | 27 + app/lib/env.ts | 5 + app/lib/payEnv.ts | 19 + app/lib/payment/client.ts | 50 + app/lib/payment/index.ts | 4 + app/lib/payment/usePayStatusPoll.ts | 165 + app/lib/pocketbase/admin.ts | 40 + app/lib/pocketbase/auth.ts | 95 + app/lib/pocketbase/config.ts | 2 + app/lib/pocketbase/constants.ts | 4 + app/lib/pocketbase/index.ts | 11 + app/lib/salonApi.ts | 92 + app/page.tsx | 34 + config/domain.config.ts | 23 + config/home.config.ts | 43 + config/services.config.ts | 66 + next.config.ts | 7 + package-lock.json | 6600 +++++++++++++++++ package.json | 28 + pnpm-lock.yaml | 4046 ++++++++++ postcss.config.mjs | 7 + tsconfig.json | 41 + 50 files changed, 14087 insertions(+), 1 deletion(-) create mode 100644 .env.local create mode 100644 .gitignore create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/me/route.ts create mode 100644 app/api/auth/sync-session/route.ts create mode 100644 app/api/join/route.ts create mode 100644 app/api/join/status/route.ts create mode 100644 app/api/pay/route.ts create mode 100644 app/api/pay/status/route.ts create mode 100644 app/api/salon/check-user/route.ts create mode 100644 app/api/salon/complete-order/route.ts create mode 100644 app/api/salon/ensure-user/route.ts create mode 100644 app/api/salon/set-needs-password-change/route.ts create mode 100644 app/api/upload-media/route.ts create mode 100644 app/api/vip/check/route.ts create mode 100644 app/components/AuthModal.tsx create mode 100644 app/components/CityCard.tsx create mode 100644 app/components/CityModal.tsx create mode 100644 app/components/FilterBar.tsx create mode 100644 app/components/Footer.tsx create mode 100644 app/components/HeroSection.tsx create mode 100644 app/components/JoinModal.tsx create mode 100644 app/data/cities.ts create mode 100644 app/globals.css create mode 100644 app/join/page.tsx create mode 100644 app/join/paid/page.tsx create mode 100644 app/layout.tsx create mode 100644 app/lib/auth-cookie.ts create mode 100644 app/lib/env.ts create mode 100644 app/lib/payEnv.ts create mode 100644 app/lib/payment/client.ts create mode 100644 app/lib/payment/index.ts create mode 100644 app/lib/payment/usePayStatusPoll.ts create mode 100644 app/lib/pocketbase/admin.ts create mode 100644 app/lib/pocketbase/auth.ts create mode 100644 app/lib/pocketbase/config.ts create mode 100644 app/lib/pocketbase/constants.ts create mode 100644 app/lib/pocketbase/index.ts create mode 100644 app/lib/salonApi.ts create mode 100644 app/page.tsx create mode 100644 config/domain.config.ts create mode 100644 config/home.config.ts create mode 100644 config/services.config.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.mjs create mode 100644 tsconfig.json diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..870eff5 --- /dev/null +++ b/.env.local @@ -0,0 +1 @@ +PAYMENT_API_URL=https://api.hackrobot.cn diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c0642c --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +# .env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + + +.next +.next_stale* +node_modules +dist +build diff --git a/README.md b/README.md index 2c32cee..c8b10db 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,43 @@ -# salon +# Salon +克隆自 cnomadcna 的「探索中国」「加入游牧中国」「加入社区」功能。 +## 功能 + +- **首页-探索中国**: Hero 区、城市网格、社区动态 +- **首页-加入游牧中国**: 邮箱输入 → 登录/注册 → 支付流程 +- **首页-加入社区**: 链接到 /join 页面 +- **/join 页面**: 数字游民社区报名表单、登录、支付、支付成功页 + +## 环境变量 + +### 前端 (salon) + +- `PAYMENT_API_URL`: payjsapi 公网地址,**生产环境必填**(如 `https://api.example.com`),否则会连不上 payjsapi 导致「服务不可用」 +- `ROOT_DOMAIN`: 根域名,未设 PAYMENT_API_URL 时用于推导 `https://api.${ROOT_DOMAIN}`,默认 `hackrobot.cn` +- `PAYJSAPI_PORT`: 开发时 payjsapi 端口,默认 `8007` +- `NEXT_PUBLIC_POCKETBASE_URL` / `POCKETBASE_URL`: PocketBase 地址 +- `POCKETBASE_EMAIL` / `POCKETBASE_PASSWORD`: PocketBase 管理员账号(用于 join API 写 solanRed) + +### 后端 (payjsapi) + +- 与 cnomadcna 相同,需配置 PocketBase、支付渠道等 +- **PocketBase 需创建 `solanRed` 集合**(可克隆自 solan),字段:user_id, nickname, occupation, reason, wechatId, gender, education, gradYear, phone, single, media, type, order_id, amount + +## 启动 + +```bash +# 1. 启动 payjsapi +cd C:\project\payjsapi +python run.py + +# 2. 启动 salon 前端 +cd C:\project\salon +npm install +npm run dev +``` + +访问 http://localhost:3002 ## Getting started diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..ededd1c --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,8 @@ +import { NextRequest, NextResponse } from "next/server"; +import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie"; + +export async function POST(request: NextRequest) { + const res = NextResponse.json({ ok: true }); + res.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record); + return res; +} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..d78952c --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPocketBaseConfig, SITE_ID } from "@/config/services.config"; +import { getAdminToken } from "@/app/lib/pocketbase/admin"; +import { COOKIE_NAME, COOKIE_MAX_AGE } from "@/app/lib/auth-cookie"; + +async function checkVip(userId: string): Promise { + try { + const token = await getAdminToken(); + if (!token) return false; + const pb = getPocketBaseConfig(); + const filter = `user_id = "${userId}" && site_id = "${SITE_ID}"`; + const res = await fetch( + `${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=1`, + { headers: { Authorization: `Bearer ${token}` }, cache: "no-store" } + ); + if (!res.ok) return false; + const data = await res.json(); + const items = data?.items ?? []; + const record = items[0]; + if (!record) return false; + const now = new Date().toISOString(); + const isExpired = record?.expires_at && record.expires_at < now; + return !isExpired; + } catch { + return false; + } +} + +export async function GET(request: NextRequest) { + const cookie = request.cookies.get(COOKIE_NAME)?.value; + if (!cookie) { + return NextResponse.json({ ok: true, user: null }); + } + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + const { token, userId, email } = payload; + if (!token || !userId) return NextResponse.json({ ok: true, user: null }); + + const pb = getPocketBaseConfig(); + const res = await fetch(`${pb.url}/api/collections/${pb.usersCollection}/auth-refresh`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (!res.ok) { + const r = NextResponse.json({ ok: true, user: null }); + r.cookies.set(COOKIE_NAME, "", { path: "/", maxAge: 0 }); + return r; + } + const data = await res.json(); + const newToken = data.token; + const newRecord = data.record; + if (newToken && newRecord?.id) { + const newPayload = JSON.stringify({ + token: newToken, + userId: newRecord.id, + email: newRecord.email ?? email, + }); + const encoded = Buffer.from(newPayload, "utf-8").toString("base64url"); + const vip = await checkVip(newRecord.id); + const r = NextResponse.json({ + ok: true, + user: { id: newRecord.id, email: newRecord.email ?? email, vip }, + token: newToken, + }); + r.cookies.set(COOKIE_NAME, encoded, { + path: "/", + maxAge: COOKIE_MAX_AGE, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + httpOnly: true, + }); + return r; + } + const uid = data.record?.id ?? userId; + const vip = uid ? await checkVip(uid) : false; + return NextResponse.json({ + ok: true, + user: { id: uid, email: data.record?.email ?? email, vip }, + token: data.token, + }); + } catch { + return NextResponse.json({ ok: true, user: null }); + } +} diff --git a/app/api/auth/sync-session/route.ts b/app/api/auth/sync-session/route.ts new file mode 100644 index 0000000..4c1dac3 --- /dev/null +++ b/app/api/auth/sync-session/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie"; + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => ({})); + const token = body?.token; + const record = body?.record; + if (!token || !record?.id) { + return NextResponse.json({ ok: false, error: "缺少 token 或 record" }, { status: 400 }); + } + + const payload = JSON.stringify({ + token, + userId: record.id, + email: record.email ?? "", + }); + const encoded = Buffer.from(payload, "utf-8").toString("base64url"); + + const res = NextResponse.json({ ok: true }); + res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record); + return res; +} diff --git a/app/api/join/route.ts b/app/api/join/route.ts new file mode 100644 index 0000000..ff1e894 --- /dev/null +++ b/app/api/join/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { postSalonApi } from "@/app/lib/salonApi"; +import { COOKIE_NAME } from "@/app/lib/auth-cookie"; + +function getUserIdFromCookie(request: NextRequest): string | null { + const cookie = request.cookies.get(COOKIE_NAME)?.value; + if (!cookie) return null; + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + return payload?.userId ?? null; + } catch { + return null; + } +} + +export async function POST(request: NextRequest) { + try { + const userId = getUserIdFromCookie(request); + if (!userId) { + return NextResponse.json({ ok: false, error: "请先登录" }, { status: 401 }); + } + const body = await request.json(); + const formData = body && typeof body === "object" ? body : {}; + + const record = { + user_id: userId, + nickname: formData.nickname || "", + occupation: formData.profession || "", + reason: formData.intro || "", + single: formData.single || "", + wechatId: formData.wechat || "", + gender: formData.gender || "", + education: formData.education || "", + gradYear: formData.graduationYear || "", + phone: formData.phone || "", + media: formData.media || "", + }; + + const { status, data } = await postSalonApi(request, "/api/salon/join", record); + if (status >= 500) { + const payload = typeof data === "object" && data !== null ? (data as Record) : {}; + return NextResponse.json( + { ok: false, error: (payload.detail as string) || (payload.error as string) || "提交失败" }, + { status: 503 } + ); + } + if (status >= 400) { + return NextResponse.json(data, { status }); + } + const payload = typeof data === "object" && data !== null ? (data as Record) : {}; + return NextResponse.json({ ok: true, user_id: payload.user_id ?? userId }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("Join API error:", e); + return NextResponse.json( + { ok: false, error: `提交失败: ${msg}` }, + { status: 500 } + ); + } +} diff --git a/app/api/join/status/route.ts b/app/api/join/status/route.ts new file mode 100644 index 0000000..1fb131d --- /dev/null +++ b/app/api/join/status/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSalonApi } from "@/app/lib/salonApi"; +import { COOKIE_NAME } from "@/app/lib/auth-cookie"; + +function getUserIdFromCookie(request: NextRequest): string | null { + const cookie = request.cookies.get(COOKIE_NAME)?.value; + if (!cookie) return null; + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + return payload?.userId ?? null; + } catch { + return null; + } +} + +export async function GET(request: NextRequest) { + const userId = getUserIdFromCookie(request); + if (!userId) { + return NextResponse.json({ hasRecord: false, isComplete: false }); + } + + try { + const { status, data } = await getSalonApi( + request, + `/api/salon/join/status?user_id=${encodeURIComponent(userId)}` + ); + if (status !== 200) { + return NextResponse.json({ hasRecord: false, isComplete: false }); + } + const payload = typeof data === "object" && data !== null ? (data as Record) : {}; + return NextResponse.json({ + hasRecord: !!payload.hasRecord, + isComplete: !!payload.isComplete, + }); + } catch { + return NextResponse.json({ hasRecord: false, isComplete: false }); + } +} diff --git a/app/api/pay/route.ts b/app/api/pay/route.ts new file mode 100644 index 0000000..0d3bd91 --- /dev/null +++ b/app/api/pay/route.ts @@ -0,0 +1,467 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getApiUrlFromRequest, + getJoinPaymentConfig, + getPaymentConfig, + SITE_ID, +} from "@/config/services.config"; +import { SITE_URL } from "@/app/lib/env"; + +const ORDER_COOKIE = "join_pay_order"; +const SALON_PAY_PREFIX = "/salon"; + +function resolvePayApiUrl(request: NextRequest): string { + const config = getPaymentConfig(); + const hostHeader = + request.headers.get("host") || request.headers.get("x-forwarded-host"); + return (getApiUrlFromRequest(hostHeader) || config.apiUrl).replace(/\/$/, ""); +} + +function setPayOrderCookie(response: NextResponse, orderId: string) { + response.cookies.set(ORDER_COOKIE, `${orderId}|${Date.now()}`, { + path: "/", + maxAge: 900, + }); +} + +function escapeAttr(s: string): string { + return String(s) + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +function buildPayHtml( + payUrl: string, + params: Record, + options?: { directToCashier?: boolean; orderId?: string } +): string { + const inputs = Object.entries(params) + .filter(([, value]) => value != null && String(value).trim() !== "") + .map( + ([key, value]) => + `` + ) + .join("\n"); + + const pendingOrderScript = options?.orderId + ? `` + : ""; + + return options?.directToCashier + ? `支付
${inputs}
${pendingOrderScript}` + : `跳转支付

正在跳转到支付页面...

${inputs}
${pendingOrderScript}`; +} + +function buildQrHtml( + imgSrc: string, + returnUrl: string, + orderId: string, + opts?: { channel?: string; successDesc?: string; confirmUrl?: string } +): string { + const ch = (opts?.channel || "wxpay").toLowerCase(); + const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat"; + const title = isWx ? "微信扫码支付" : "支付宝扫码支付"; + const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付"; + const successDesc = opts?.successDesc || "支付成功,即将跳转"; + const confirmUrl = opts?.confirmUrl || "/api/salon/complete-order"; + const esc = (s: string) => + s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c); + return ` + +${esc(title)} + + +
+
+

${esc(title)}

+

${esc(hint)}

+
+
+ + + + +
+ 5:00 + +
+
+
+ 支付二维码 +
+
+

等待支付中...

+
+
+
+
支付成功
+
${esc(successDesc)}
+
3 秒后跳转
+
+
+ +`; +} + +async function createPayOrder( + apiUrl: string, + userId: string, + returnUrl: string, + totalFee: number, + type: string, + channel: string, + provider?: "xorpay", + clientIp?: string +) { + const payload: Record = { + user_id: userId, + site_id: SITE_ID, + total_fee: totalFee, + type, + channel, + return_url: returnUrl, + ...(provider ? { provider } : {}), + ...(clientIp ? { client_ip: clientIp } : {}), + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + const res = await fetch(`${apiUrl}${SALON_PAY_PREFIX}/payh5`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, + }).finally(() => clearTimeout(timeout)); + + const data = await res.json().catch(() => ({})); + if (!res.ok || data?.status !== "ok") { + console.error("[pay] salon createPayOrder failed status=%s body=%j", res.status, data); + throw new Error(data?.detail || data?.msg || data?.message || "支付创建失败"); + } + + return { + params: (data.params ?? data.xorpay_params ?? {}) as Record, + payUrl: + data.pay_url || data.xorpay_url || getPaymentConfig().zpaySubmitUrl, + provider: String(data.provider || (provider ? "xorpay" : "zpay")).trim(), + orderId: String( + data.order_id || + data.params?.out_trade_no || + data.xorpay_params?.order_id || + "" + ).trim(), + }; +} + +async function requestRedirect( + apiUrl: string, + path: string, + payload: Record +) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 12000); + try { + return await fetch(`${apiUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + redirect: "manual", + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + +export async function POST(request: NextRequest) { + try { + let body: Record; + const contentType = request.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + body = (await request.json()) as Record; + } else { + const form = await request.formData(); + body = Object.fromEntries(form.entries()) as Record; + } + + const joinConfig = getJoinPaymentConfig(); + const payConfig = getPaymentConfig(); + const hostHeader = + request.headers.get("host") || request.headers.get("x-forwarded-host"); + const apiUrl = resolvePayApiUrl(request); + const userId = String(body?.user_id || "").trim(); + const totalFee = Number(body?.total_fee) || joinConfig.amount; + const type = String(body?.type || joinConfig.type); + const channel = String(body?.channel || "wxpay"); + const device = String(body?.device || "pc"); + const forcedProvider = device === "wechat" ? "xorpay" : undefined; + + if (!userId) { + return NextResponse.json( + { ok: false, error: "缺少 user_id" }, + { status: 400 } + ); + } + + const origin = + request.headers.get("x-forwarded-host") + ? `${request.headers.get("x-forwarded-proto") || "https"}://${request.headers.get("x-forwarded-host")}` + : request.headers.get("origin") || SITE_URL; + const returnUrl = + String(body?.return_url || "").trim() || `${origin}/join/paid`; + const clientIp = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + ""; + const wantHtml = + String(body?.html) === "1" || + (request.headers.get("accept") || "").includes("text/html"); + + console.log( + "[pay] salon apiUrl=%s host=%s provider=%s device=%s", + apiUrl, + hostHeader, + forcedProvider || "default", + device + ); + + const created = await createPayOrder( + apiUrl, + userId, + returnUrl, + totalFee, + type, + channel, + forcedProvider, + clientIp + ); + + if (!wantHtml) { + const response = NextResponse.json({ + ok: true, + params: created.params, + payUrl: created.payUrl, + provider: created.provider, + order_id: created.orderId, + }); + if (created.orderId) setPayOrderCookie(response, created.orderId); + return response; + } + + if (created.provider === "xorpay") { + const html = buildPayHtml(created.payUrl, created.params, { + directToCashier: true, + orderId: created.orderId, + }); + const response = new NextResponse(html, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + if (created.orderId) setPayOrderCookie(response, created.orderId); + return response; + } + + const redirectPayload: Record = { + user_id: userId, + site_id: SITE_ID, + total_fee: totalFee, + type, + channel, + return_url: returnUrl, + device, + ...(clientIp ? { client_ip: clientIp } : {}), + }; + + try { + const redirectRes = await requestRedirect( + apiUrl, + `${SALON_PAY_PREFIX}/payh5/redirect`, + redirectPayload + ); + + if (redirectRes.status >= 301 && redirectRes.status <= 308) { + const location = redirectRes.headers.get("location"); + if (location) { + const response = NextResponse.redirect(location); + const orderId = + redirectRes.headers.get("x-pay-order-id")?.trim() || + created.orderId; + if (orderId) setPayOrderCookie(response, orderId); + return response; + } + } + + const resData = await redirectRes.json().catch(() => ({})); + if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) { + const safeReturnUrl = String(resData.return_url || returnUrl).replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ + c + ] || c) + ); + const imgSrc = String(resData.img).replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ + c + ] || c) + ); + const rawOrderId = String(resData.order_id || created.orderId).trim(); + const html = buildQrHtml(imgSrc, safeReturnUrl, rawOrderId, { + channel: resData.channel || "wxpay", + successDesc: "支付成功,申请已提交", + confirmUrl: "/api/salon/complete-order", + }); + const response = new NextResponse(html, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + if (rawOrderId) setPayOrderCookie(response, rawOrderId); + return response; + } + + if (redirectRes.status === 200 && resData?.use_form) { + const html = buildPayHtml(payConfig.zpaySubmitUrl, created.params, { + directToCashier: true, + orderId: String(resData.order_id || created.orderId).trim(), + }); + const response = new NextResponse(html, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + const orderId = String(resData.order_id || created.orderId).trim(); + if (orderId) setPayOrderCookie(response, orderId); + return response; + } + + if (redirectRes.status >= 400) { + console.warn( + "[pay] redirect fallback status=%s body=%j", + redirectRes.status, + resData + ); + } + } catch (error) { + console.warn( + "[pay] redirect fallback error=%s", + error instanceof Error ? error.message : String(error) + ); + } + + const html = buildPayHtml(created.payUrl, created.params, { + orderId: created.orderId, + }); + const response = new NextResponse(html, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + if (created.orderId) setPayOrderCookie(response, created.orderId); + return response; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + const msg = err.message || ""; + const hint = + msg.includes("fetch") || msg.includes("ECONNREFUSED") + ? "请确认 payjsapi 已启动:cd payjsapi && python run.py" + : err.name === "AbortError" + ? "请求超时,请检查 payjsapi 是否正常运行" + : msg; + console.error("Pay API error:", err); + return NextResponse.json( + { ok: false, error: `支付请求失败: ${hint}` }, + { status: 500 } + ); + } +} diff --git a/app/api/pay/status/route.ts b/app/api/pay/status/route.ts new file mode 100644 index 0000000..a1ca926 --- /dev/null +++ b/app/api/pay/status/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getApiUrlFromRequest, getPaymentConfig } from "@/config/services.config"; + +function resolvePayApiUrl(request: NextRequest): string { + const config = getPaymentConfig(); + const hostHeader = + request.headers.get("host") || request.headers.get("x-forwarded-host"); + return (getApiUrlFromRequest(hostHeader) || config.apiUrl).replace(/\/$/, ""); +} + +export async function GET(request: NextRequest) { + const orderId = request.nextUrl.searchParams.get("order_id"); + if (!orderId) { + return NextResponse.json( + { ok: false, error: "missing order_id" }, + { status: 400 } + ); + } + + const hostHeader = + request.headers.get("host") || request.headers.get("x-forwarded-host"); + const apiUrl = resolvePayApiUrl(request); + const url = `${apiUrl}/salon/order_status?order_id=${encodeURIComponent(orderId)}`; + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const res = await fetch(url, { + cache: "no-store", + signal: controller.signal, + }).finally(() => clearTimeout(timeout)); + const data = await res.json().catch(() => ({})); + const paid = !!data?.paid; + if (paid) { + return NextResponse.json({ ok: true, paid: true }); + } + } catch (error) { + console.log( + "[pay/status] error order_id=%s error=%s", + orderId, + error instanceof Error ? error.message : String(error) + ); + } + + return NextResponse.json({ ok: true, paid: false }); +} diff --git a/app/api/salon/check-user/route.ts b/app/api/salon/check-user/route.ts new file mode 100644 index 0000000..58c8bb5 --- /dev/null +++ b/app/api/salon/check-user/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { postSalonApi } from "@/app/lib/salonApi"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const email = String(body?.email || "").trim(); + if (!email) { + return NextResponse.json({ ok: false, error: "请输入邮箱" }, { status: 400 }); + } + + const { status, data } = await postSalonApi(request, "/api/salon/check-user", { email }); + const payload = typeof data === "object" && data !== null ? (data as Record) : {}; + if (status >= 500) { + return NextResponse.json( + { ok: false, error: (payload.error as string) || "服务暂时不可用,请稍后重试" }, + { status: 503 } + ); + } + return NextResponse.json(data, { status }); + } catch (error) { + console.error("salon check-user error:", error); + return NextResponse.json( + { ok: false, error: "服务暂时不可用,请稍后重试" }, + { status: 503 } + ); + } +} diff --git a/app/api/salon/complete-order/route.ts b/app/api/salon/complete-order/route.ts new file mode 100644 index 0000000..4b9ea90 --- /dev/null +++ b/app/api/salon/complete-order/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { postSalonApi } from "@/app/lib/salonApi"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const order_id = String(body?.order_id || "").trim(); + if (!order_id) { + return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 }); + } + + const { status, data } = await postSalonApi(request, "/api/salon/complete-order", { + order_id, + }); + if (status >= 500) { + const payload = typeof data === "object" && data !== null ? (data as Record) : {}; + return NextResponse.json( + { ok: false, error: (payload.detail as string) || (payload.error as string) || "操作失败" }, + { status: 503 } + ); + } + return NextResponse.json(data, { status }); + } catch (error) { + console.error("salon complete-order error:", error); + return NextResponse.json({ ok: false, error: "操作失败" }, { status: 500 }); + } +} diff --git a/app/api/salon/ensure-user/route.ts b/app/api/salon/ensure-user/route.ts new file mode 100644 index 0000000..3278583 --- /dev/null +++ b/app/api/salon/ensure-user/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { postSalonApi } from "@/app/lib/salonApi"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const email = String(body?.email || "").trim(); + const password = String(body?.password || "").trim(); + if (!email) { + return NextResponse.json({ ok: false, error: "请输入邮箱" }, { status: 400 }); + } + + const { status, data } = await postSalonApi(request, "/api/salon/ensure-user", { + email, + password: password || undefined, + }); + if (status >= 500) { + const payload = typeof data === "object" && data !== null ? (data as Record) : {}; + return NextResponse.json( + { ok: false, error: (payload.error as string) || "操作失败" }, + { status: 503 } + ); + } + return NextResponse.json(data, { status }); + } catch (error) { + console.error("salon ensure-user error:", error); + return NextResponse.json({ ok: false, error: "操作失败" }, { status: 500 }); + } +} diff --git a/app/api/salon/set-needs-password-change/route.ts b/app/api/salon/set-needs-password-change/route.ts new file mode 100644 index 0000000..5e60574 --- /dev/null +++ b/app/api/salon/set-needs-password-change/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export async function POST() { + return NextResponse.json({ ok: true }); +} diff --git a/app/api/upload-media/route.ts b/app/api/upload-media/route.ts new file mode 100644 index 0000000..5ee4233 --- /dev/null +++ b/app/api/upload-media/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + const file = formData.get("file"); + if (!file || !(file instanceof File)) { + return NextResponse.json({ ok: false, error: "请选择文件" }, { status: 400 }); + } + if (file.size > 20 * 1024 * 1024) { + return NextResponse.json({ ok: false, error: "文件大小不能超过 20MB" }, { status: 400 }); + } + const publicUrl = process.env.MINIO_PUBLIC_URL || "https://example.com/uploads"; + const prefix = process.env.MINIO_UPLOAD_PREFIX || "joins"; + const ext = file.name.split(".").pop() || "jpg"; + const filename = `${prefix}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`; + const url = `${publicUrl}/${filename}`; + return NextResponse.json({ ok: true, url }); + } catch (e) { + console.error("upload-media error:", e); + return NextResponse.json({ ok: false, error: "上传失败" }, { status: 500 }); + } +} diff --git a/app/api/vip/check/route.ts b/app/api/vip/check/route.ts new file mode 100644 index 0000000..75046bb --- /dev/null +++ b/app/api/vip/check/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPocketBaseConfig, SITE_ID } from "@/config/services.config"; +import { getAdminToken } from "@/app/lib/pocketbase/admin"; + +const COOKIE_NAME = "pb_session"; + +export async function GET(request: NextRequest) { + const cookie = request.cookies.get(COOKIE_NAME)?.value; + if (!cookie) { + return NextResponse.json({ ok: true, vip: false }); + } + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + const userId = payload?.userId; + if (!userId) return NextResponse.json({ ok: true, vip: false }); + + const token = await getAdminToken(); + if (!token) return NextResponse.json({ ok: true, vip: false }); + + const pb = getPocketBaseConfig(); + const filter = `user_id = "${userId}" && site_id = "${SITE_ID}"`; + const res = await fetch( + `${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=1`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!res.ok) return NextResponse.json({ ok: true, vip: false }); + const data = await res.json(); + const items = data?.items ?? []; + const record = items[0]; + const now = new Date().toISOString(); + const isExpired = record?.expires_at && record.expires_at < now; + const vip = !!record && !isExpired; + return NextResponse.json({ + ok: true, + vip, + expires_at: record?.expires_at ?? null, + }); + } catch { + return NextResponse.json({ ok: true, vip: false }); + } +} diff --git a/app/components/AuthModal.tsx b/app/components/AuthModal.tsx new file mode 100644 index 0000000..ce01fd0 --- /dev/null +++ b/app/components/AuthModal.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useState, useEffect, type FormEvent } from "react"; +import { createPortal } from "react-dom"; +import { + pbLogin, + pbRegister, + pbSaveAuth, +} from "@/app/lib/pocketbase"; + +interface AuthModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (email: string) => void; +} + +export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps) { + 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(null); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + let result; + if (mode === "register") { + if (password !== passwordConfirm) { + setError("两次密码不一致"); + setLoading(false); + return; + } + if (password.length < 8) { + setError("密码至少 8 位"); + setLoading(false); + return; + } + result = await pbRegister(email, password, passwordConfirm); + } else { + result = await pbLogin(email, password); + } + pbSaveAuth(result.token, result.record); + try { + await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: result.token, record: result.record }), + credentials: "include", + }); + } catch { + /* ignore */ + } + onSuccess(email); + onClose(); + setEmail(""); + setPassword(""); + setPasswordConfirm(""); + } catch (err) { + setError(err instanceof Error ? err.message : "操作失败"); + } finally { + setLoading(false); + } + }; + + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + if (!isOpen || !mounted) return null; + + const modalContent = ( +
+
+
+
+ + +

+ {mode === "login" ? "登录" : "注册"} +

+

+ {mode === "login" ? "使用邮箱和密码登录" : "创建新账号"} +

+ +
+
+ + setEmail(e.target.value)} + placeholder="your@email.com" + required + className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" + /> +
+
+ + setPassword(e.target.value)} + placeholder={mode === "register" ? "至少 8 位" : "••••••••"} + required + minLength={mode === "register" ? 8 : 1} + className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" + /> +
+ {mode === "register" && ( +
+ + setPasswordConfirm(e.target.value)} + placeholder="再次输入密码" + required + className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" + /> +
+ )} + {error && ( +

{error}

+ )} + +
+ +

+ {mode === "login" ? ( + <> + 没有账号?{" "} + + + ) : ( + <> + 已有账号?{" "} + + + )} +

+
+
+
+ ); + + return createPortal(modalContent, document.body); +} diff --git a/app/components/CityCard.tsx b/app/components/CityCard.tsx new file mode 100644 index 0000000..39afb9c --- /dev/null +++ b/app/components/CityCard.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { memo, useState } from "react"; +import { City } from "../data/cities"; + +interface CityCardProps { + city: City; + rank: number; + onClick?: () => void; +} + +function CityCardComponent({ city, rank, onClick }: CityCardProps) { + const [hovered, setHovered] = useState(false); + + return ( +
e.key === "Enter" && onClick?.()} + className="city-card cursor-pointer relative overflow-hidden rounded-xl min-h-[200px]" + style={{ + background: `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})`, + }} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + {city.icon} + + +
+
+ {rank} + + 📡 {city.internetSpeed}Mbps + +
+ +
+

+ {city.name} +

+

+ {city.countryEmoji} {city.country} +

+
+ +
+
+ + 👥 {city.nomadsNow} 人在此 + + + 🌡 {city.temperature}°C + +
+
+ + {city.description} + + + ¥{city.costPerMonth.toLocaleString()}/月 + +
+
+
+ + +
+ ); +} + +export default memo(CityCardComponent); diff --git a/app/components/CityModal.tsx b/app/components/CityModal.tsx new file mode 100644 index 0000000..523d014 --- /dev/null +++ b/app/components/CityModal.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { createPortal } from "react-dom"; +import { City } from "../data/cities"; + +interface CityModalProps { + city: City | null; + isOpen: boolean; + onClose: () => void; +} + +export default function CityModal({ city, isOpen, onClose }: CityModalProps) { + if (!isOpen || !city) return null; + + const modalContent = ( +
+
+
+
e.stopPropagation()} + > + +

+ {city.name} +

+

{city.description}

+
+

💰 月均消费: ¥{city.costPerMonth.toLocaleString()}

+

📡 网速: {city.internetSpeed}Mbps

+

👥 当前游民: {city.nomadsNow} 人

+
+
+
+
+ ); + + return createPortal(modalContent, document.body); +} diff --git a/app/components/FilterBar.tsx b/app/components/FilterBar.tsx new file mode 100644 index 0000000..4c5b992 --- /dev/null +++ b/app/components/FilterBar.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { memo } from "react"; +import { allTags } from "../data/cities"; + +const SORT_OPTIONS = [ + { value: "score", label: "综合评分" }, + { value: "cost-asc", label: "费用从低到高" }, + { value: "cost-desc", label: "费用从高到低" }, + { value: "internet", label: "网速" }, + { value: "safety", label: "安全" }, + { value: "nomads", label: "游民数量" }, + { value: "temperature", label: "温度" }, +]; + +const tagIcons: Record = { + 全部: "🌍", + 热门: "🔥", + 便宜: "💰", + 都市: "🌆", + 文化: "🎨", + 宜居: "🏡", + 友好社区: "🤝", +}; + +interface FilterBarProps { + activeFilter: string; + onFilterChange: (filter: string) => void; + sortBy: string; + onSortChange: (sort: string) => void; + resultCount: number; +} + +function FilterBarComponent({ + activeFilter, + onFilterChange, + sortBy, + onSortChange, + resultCount, +}: FilterBarProps) { + return ( +
+
+
+

+ 发现目的地 +

+ + {resultCount} 个城市 + +
+
+ 排序: + +
+
+ +
+ {allTags.map((tag) => ( + + ))} +
+
+ ); +} + +export default memo(FilterBarComponent); diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx new file mode 100644 index 0000000..244ea39 --- /dev/null +++ b/app/components/Footer.tsx @@ -0,0 +1,9 @@ +export default function Footer() { + return ( +
+
+

© {new Date().getFullYear()} Salon. 探索中国,远程工作,自由生活。

+
+
+ ); +} diff --git a/app/components/HeroSection.tsx b/app/components/HeroSection.tsx new file mode 100644 index 0000000..aaa032e --- /dev/null +++ b/app/components/HeroSection.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef } from "react"; +import Link from "next/link"; +import JoinModal from "./JoinModal"; +import { + getPayEnv, + redirectToPay, + getDeviceFromEnv, +} from "@/app/lib/payment"; +import type { PayChannel } from "@/app/lib/payment"; + +const avatarPhotos = [ + "https://i.pravatar.cc/150?img=32", + "https://i.pravatar.cc/150?img=33", + "https://i.pravatar.cc/150?img=25", + "https://i.pravatar.cc/150?img=52", + "https://i.pravatar.cc/150?img=44", +]; + +function isPrivateDevHost(hostname: string): boolean { + if (!hostname) return false; + if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return true; + if (/^10\./.test(hostname) || /^192\.168\./.test(hostname)) return true; + const match = hostname.match(/^172\.(\d{1,2})\./); + if (!match) return false; + const secondOctet = Number(match[1]); + return secondOctet >= 16 && secondOctet <= 31; +} + +function buildPendingUserId(email: string): string { + return ( + "pending_" + + Array.from(new TextEncoder().encode(email)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + ); +} + +export default function HeroSection() { + const [email, setEmail] = useState(""); + const [joinModalOpen, setJoinModalOpen] = useState(false); + const [joinModalVipOnly, setJoinModalVipOnly] = useState(false); + const [checking, setChecking] = useState(false); + const [error, setError] = useState(null); + const [isLoggedIn, setIsLoggedIn] = useState(false); + + useEffect(() => { + const checkAuth = async () => { + try { + const res = await fetch("/api/auth/me", { credentials: "include" }); + const data = await res.json(); + setIsLoggedIn(!!data?.user); + } catch { + setIsLoggedIn(false); + } + }; + checkAuth(); + const onAuth = () => checkAuth(); + window.addEventListener("auth:updated", onAuth); + return () => window.removeEventListener("auth:updated", onAuth); + }, []); + + const checkUserCacheRef = useRef<{ email: string; data: { exists: boolean; vip: boolean }; ts: number } | null>(null); + const CACHE_TTL_MS = 60_000; + + const handleCheckAndProceed = useCallback( + async (em: string) => { + if (checking) return; + const trimmed = em.trim(); + if (!trimmed) { + setError("请输入邮箱"); + return; + } + setError(null); + setChecking(true); + try { + const cached = checkUserCacheRef.current; + if (cached && cached.email === trimmed && Date.now() - cached.ts < CACHE_TTL_MS) { + const { exists, vip } = cached.data; + if (exists) { + setJoinModalVipOnly(!!vip); + setJoinModalOpen(true); + return; + } + } + const checkRes = await fetch("/api/salon/check-user", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: trimmed }), + credentials: "include", + }); + const checkData = await checkRes.json(); + if (!checkRes.ok || !checkData?.ok) { + throw new Error(checkData?.error || checkData?.detail || "操作失败"); + } + if (checkData.exists) { + checkUserCacheRef.current = { email: trimmed, data: { exists: true, vip: !!checkData.vip }, ts: Date.now() }; + setJoinModalVipOnly(!!checkData.vip); + setJoinModalOpen(true); + return; + } + const shouldPrecreateUser = + typeof window !== "undefined" && + isPrivateDevHost(window.location.hostname); + let user_id = buildPendingUserId(trimmed); + if (shouldPrecreateUser) { + const ensureRes = await fetch("/api/salon/ensure-user", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: trimmed }), + credentials: "include", + }); + const ensureData = await ensureRes.json(); + if (!ensureRes.ok || !ensureData?.ok || !ensureData?.user_id) { + throw new Error(ensureData?.error || ensureData?.detail || "创建用户失败"); + } + if (ensureData.is_new) { + await fetch("/api/salon/set-needs-password-change", { method: "POST", credentials: "include" }); + } + user_id = String(ensureData.user_id).trim(); + if (ensureData?.token && ensureData?.record) { + await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: ensureData.token, record: ensureData.record }), + credentials: "include", + }); + } + } + const returnUrl = + typeof window !== "undefined" ? `${window.location.origin}/join/paid` : "/join/paid"; + const env = getPayEnv(); + const channel: PayChannel = "wxpay"; + redirectToPay({ + user_id, + return_url: returnUrl, + channel, + device: getDeviceFromEnv(env), + useJoinConfig: true, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "操作失败"); + } finally { + setChecking(false); + } + }, + [checking] + ); + + const handleJoinClick = (e: React.FormEvent) => { + e.preventDefault(); + handleCheckAndProceed(email); + }; + + return ( +
+
+
+ +
+
+
+ + 🏆 数字游民首选 + + +

+ 探索中国 +
+ 远程工作,自由生活 +

+ +

+ 加入全球数字游民社区,发现中国最适合远程工作和生活的城市 +

+ +
+ {[ + { icon: "🍹", text: "线下聚会与线上分享" }, + { icon: "❤️", text: "结识志同道合的伙伴" }, + { icon: "🧪", text: "探索新城市与新可能" }, + { icon: "🌎", text: "全球数字游民网络" }, + { icon: "💬", text: "社区互助与资源共享" }, + ].map((f) => ( +
+ {f.icon} + + {f.text} + +
+ ))} +
+ +
+
+ {avatarPhotos.slice(0, 8).map((src, i) => ( + + ))} +
+ + 已有 5,000+ 成员加入 + +
+
+ +
+
+
+ +
+ + {!isLoggedIn ? ( +
+ { setEmail(e.target.value); setError(null); }} + placeholder="输入邮箱,加入社区" + className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3" + /> + {error && ( +

{error}

+ )} + +
+ ) : ( +
+

+ 您已登录,可前往社区参与互动 +

+
+ )} +
+ { setJoinModalOpen(false); setJoinModalVipOnly(false); }} + initialEmail={email.trim()} + vipOnly={joinModalVipOnly} + /> + +
+ + 💬 加入社区 + +
+
+
+
+
+ ); +} diff --git a/app/components/JoinModal.tsx b/app/components/JoinModal.tsx new file mode 100644 index 0000000..807c8c5 --- /dev/null +++ b/app/components/JoinModal.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState, useEffect, type FormEvent } from "react"; +import { createPortal } from "react-dom"; +import { + getPayEnv, + redirectToPay, + getDeviceFromEnv, +} from "@/app/lib/payment"; +import type { PayChannel } from "@/app/lib/payment"; + +interface JoinModalProps { + isOpen: boolean; + onClose: () => void; + initialEmail?: string; + vipOnly?: boolean; +} + +export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly = false }: JoinModalProps) { + const [email, setEmail] = useState(initialEmail); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (isOpen) { + setEmail(initialEmail); + setPassword(""); + setError(null); + } + }, [isOpen, initialEmail]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (loading) return; + const em = email.trim(); + if (!em) { + setError("请输入邮箱"); + return; + } + setError(null); + setLoading(true); + try { + const res = await fetch("/api/salon/ensure-user", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: em, password: password || undefined }), + credentials: "include", + }); + const data = await res.json(); + if (!res.ok || !data?.ok) { + throw new Error(data?.error || data?.detail || "操作失败"); + } + if (data.is_new) { + await fetch("/api/salon/set-needs-password-change", { method: "POST", credentials: "include" }); + } + const { pbSaveAuth } = await import("@/app/lib/pocketbase"); + pbSaveAuth(data.token, data.record); + await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: data.token, record: data.record }), + credentials: "include", + }); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:updated")); + } + if (vipOnly) { + onClose(); + return; + } + const returnUrl = typeof window !== "undefined" ? `${window.location.origin}/join/paid` : "/join/paid"; + const env = getPayEnv(); + const channel: PayChannel = "wxpay"; + redirectToPay({ + user_id: data.user_id, + return_url: returnUrl, + channel, + device: getDeviceFromEnv(env), + useJoinConfig: true, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "操作失败"); + } finally { + setLoading(false); + } + }; + + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + if (!isOpen || !mounted) return null; + + const modalContent = ( +
+
+
+
+ + +

+ {vipOnly ? "验证密码登录" : "加入社区"} +

+ +
+
+ + { setEmail(e.target.value); setError(null); }} + placeholder="输入你的邮箱..." + required + className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" + /> +
+
+ + { setPassword(e.target.value); setError(null); }} + placeholder="请输入密码" + className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" + /> +
+ {error && ( +

{error}

+ )} + +
+
+
+
+ ); + + return createPortal(modalContent, document.body); +} diff --git a/app/data/cities.ts b/app/data/cities.ts new file mode 100644 index 0000000..91d6d39 --- /dev/null +++ b/app/data/cities.ts @@ -0,0 +1,100 @@ +export interface City { + id: number; + name: string; + country: string; + countryEmoji: string; + overallScore: number; + costPerMonth: number; + internetSpeed: number; + safety: string; + liked: string; + temperature: number; + humidity: number; + nomadsNow: number; + description: string; + gradientFrom: string; + gradientTo: string; + icon: string; + tags: string[]; +} + +export const cities: City[] = [ + { + id: 1, + name: "大理", + country: "中国", + countryEmoji: "🇨🇳", + overallScore: 4.5, + costPerMonth: 3000, + internetSpeed: 50, + safety: "很好", + liked: "极好", + temperature: 16, + humidity: 60, + nomadsNow: 520, + description: "苍山洱海,文艺气息浓厚的慢生活之城", + gradientFrom: "#2563eb", + gradientTo: "#0891b2", + icon: "🏯", + tags: ["热门", "便宜", "宜居", "山城", "文化", "友好社区"], + }, + { + id: 2, + name: "成都", + country: "中国", + countryEmoji: "🇨🇳", + overallScore: 4.4, + costPerMonth: 4500, + internetSpeed: 80, + safety: "很好", + liked: "极好", + temperature: 17, + humidity: 75, + nomadsNow: 680, + description: "天府之国,美食与慢节奏的完美融合", + gradientFrom: "#059669", + gradientTo: "#65a30d", + icon: "🐼", + tags: ["热门", "美食", "都市", "新一线", "文化", "友好社区"], + }, + { + id: 3, + name: "深圳", + country: "中国", + countryEmoji: "🇨🇳", + overallScore: 4.1, + costPerMonth: 8000, + internetSpeed: 120, + safety: "极好", + liked: "很好", + temperature: 23, + humidity: 70, + nomadsNow: 850, + description: "科技创新之城,创业者的乐园", + gradientFrom: "#1d4ed8", + gradientTo: "#0284c7", + icon: "🌃", + tags: ["热门", "高速网络", "都市", "一线城市", "安全"], + }, + { + id: 4, + name: "杭州", + country: "中国", + countryEmoji: "🇨🇳", + overallScore: 4.3, + costPerMonth: 6500, + internetSpeed: 90, + safety: "很好", + liked: "很好", + temperature: 18, + humidity: 68, + nomadsNow: 420, + description: "西湖美景,互联网与人文的融合", + gradientFrom: "#7c3aed", + gradientTo: "#ec4899", + icon: "⛲", + tags: ["热门", "都市", "新一线", "文化", "宜居"], + }, +]; + +export const allTags = ["全部", "热门", "便宜", "都市", "文化", "宜居", "友好社区"]; diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..71f0511 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,107 @@ +@import "tailwindcss"; + +:root { + --background: #fafafa; + --foreground: #171717; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + color: var(--foreground); + background: var(--background); +} + +.hero-gradient-text { + background: linear-gradient(135deg, #f97316 0%, #ea580c 50%, #c2410c 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.items-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; + max-width: 1400px; + margin: 0 auto; + padding: 1rem; +} + +.right-item { + border-radius: 1rem; + overflow: hidden; + background: white; + border: 1px solid #e5e7eb; + transition: box-shadow 0.2s; +} + +.right-item:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.right-item-header { + padding: 0.5rem 1rem; + font-weight: 600; + font-size: 0.875rem; + background: #f9fafb; + border-bottom: 1px solid #e5e7eb; +} + +.community-card { + border-radius: 1rem; + overflow: hidden; + background: white; + border: 1px solid #e5e7eb; + transition: box-shadow 0.2s; +} + +.community-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.form-input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + font-size: 0.875rem; +} + +.form-input:focus { + outline: none; + border-color: #ff4d4f; + box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2); +} + +.dark .form-input { + border-color: #374151; + background: #1f2937; +} + +.dark .right-item, +.dark .community-card { + background: #111827; + border-color: #374151; +} + +.dark .right-item-header { + background: #1f2937; + border-color: #374151; +} + +.city-card { + border-radius: 1rem; + overflow: hidden; + transition: transform 0.2s, box-shadow 0.2s; +} + +.city-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); +} diff --git a/app/join/page.tsx b/app/join/page.tsx new file mode 100644 index 0000000..8852301 --- /dev/null +++ b/app/join/page.tsx @@ -0,0 +1,505 @@ +"use client"; + +import { useState, useRef, useEffect, type FormEvent, type ChangeEvent } from "react"; +import Link from "next/link"; +import { + getPayEnv, + redirectToPay, + getDeviceFromEnv, +} from "@/app/lib/payment"; +import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll"; +import AuthModal from "@/app/components/AuthModal"; + +const genderOptions = ["男", "女"]; +const singleOptions = ["单身", "恋爱中", "已婚"]; +const educationOptions = ["高中及以下", "大专", "本科", "硕士", "博士"]; +const graduationYears = Array.from({ length: 41 }, (_, i) => String(2026 - i)); + +interface FormData { + nickname: string; + gender: string; + single: string; + profession: string; + intro: string; + wechat: string; + education: string; + graduationYear: string; + phone: string; +} + +interface MediaInfo { + preview: string; + url: string; + type: "image" | "video"; +} + +export default function JoinPage() { + const [form, setForm] = useState({ + nickname: "", + gender: "", + single: "", + profession: "", + intro: "", + wechat: "", + education: "", + graduationYear: "", + phone: "", + }); + const [media, setMedia] = useState(null); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(null); + const [submitted, setSubmitted] = useState(false); + const [isVipSubmit, setIsVipSubmit] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [payRedirecting, setPayRedirecting] = useState(false); + const [authOpen, setAuthOpen] = useState(false); + const [pendingSubmit, setPendingSubmit] = useState(false); + const [isAlreadyRecorded, setIsAlreadyRecorded] = useState(false); + const [checkingStatus, setCheckingStatus] = useState(true); + + useEffect(() => { + if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") { + setSubmitted(true); + setCheckingStatus(false); + return; + } + const checkExisting = async () => { + const meRes = await fetch("/api/auth/me", { credentials: "include" }); + const meData = await meRes.json(); + if (!meData?.user) { + setCheckingStatus(false); + return; + } + const statusRes = await fetch("/api/join/status", { credentials: "include", cache: "no-store" }); + const statusData = await statusRes.json(); + if (statusData?.hasRecord && statusData?.isComplete) { + setIsAlreadyRecorded(true); + setSubmitted(true); + } + setCheckingStatus(false); + }; + checkExisting(); + }, []); + + usePayStatusPoll((orderId) => { + if (typeof window === "undefined") return; + const paidPath = "/join/paid"; + const url = orderId ? `${paidPath}?order_id=${encodeURIComponent(orderId)}` : paidPath; + window.location.replace(url); + }); + + const set = (key: keyof FormData, value: string) => + setForm((prev) => ({ ...prev, [key]: value })); + + const doSubmit = async () => { + setError(null); + setSubmitting(true); + try { + const res = await fetch("/api/join", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...form, media: media?.url }), + credentials: "include", + }); + const data = await res.json(); + if (!res.ok || !data?.ok) { + throw new Error(data?.error || "提交失败,请稍后重试"); + } + const userId = data.user_id; + if (!userId) { + setSubmitted(true); + return; + } + const vipRes2 = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" }); + const vipData2 = await vipRes2.json(); + if (vipData2?.vip) { + setIsVipSubmit(true); + setSubmitted(true); + setSubmitting(false); + return; + } + setSubmitting(false); + setPayRedirecting(true); + const returnUrl = `${window.location.origin}/join/paid`; + const env = getPayEnv(); + const channel = "wxpay"; + const device = getDeviceFromEnv(env); + redirectToPay({ + user_id: userId, + return_url: returnUrl, + channel, + device, + useJoinConfig: true, + }); + return; + } catch (err) { + setError(err instanceof Error ? err.message : "网络错误,请重试"); + } finally { + setSubmitting(false); + } + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + const meRes = await fetch("/api/auth/me", { credentials: "include" }); + const meData = await meRes.json(); + if (!meData?.user) { + setPendingSubmit(true); + setAuthOpen(true); + return; + } + const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" }); + const vipData = await vipRes.json(); + if (vipData?.vip) { + setIsVipSubmit(true); + } + await doSubmit(); + }; + + const handleAuthSuccess = () => { + setAuthOpen(false); + if (pendingSubmit) { + setPendingSubmit(false); + doSubmit(); + } + }; + + if (submitted) { + return ( +
+
+
+ ✅ +
+

+ {isAlreadyRecorded ? "资料已录入" : isVipSubmit ? "提交成功" : "支付成功"} +

+

+ {isAlreadyRecorded ? "您的报名信息已录入,无需重复提交" : isVipSubmit ? "感谢您的参与" : "感谢您的支持"} +

+ + ← 返回首页 + +
+
+ ); + } + + if (checkingStatus) { + return ( +
+
加载中…
+
+ ); + } + + return ( +
+
+
+
+
+
🌍
+
+ 数字游民社区 +
+
+
+ +
+

+ 🌍 数字游民社区报名 +

+

+ {"💼 结识志同道合的伙伴\n📍 线下聚会 + 线上分享\n🤝 互助成长,资源共享"} +

+
+
+ +
+
+ + set("nickname", e.target.value)} + required + className="form-input" + /> + + + + + +
+ +
+ + + + + + set("profession", e.target.value)} + required + className="form-input" + /> + +
+ +
+ + set("wechat", e.target.value)} + required + className="form-input" + /> + + + + { + const v = e.target.value.replace(/\D/g, "").slice(0, 11); + set("phone", v); + }} + required + className="form-input" + /> + +
+ +
+ +
+