'init'
This commit is contained in:
1
.env.local
Normal file
1
.env.local
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PAYMENT_API_URL=https://api.hackrobot.cn
|
||||||
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@@ -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
|
||||||
39
README.md
39
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
|
## Getting started
|
||||||
|
|
||||||
|
|||||||
8
app/api/auth/logout/route.ts
Normal file
8
app/api/auth/logout/route.ts
Normal file
@@ -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<string, string | number | boolean>);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
85
app/api/auth/me/route.ts
Normal file
85
app/api/auth/me/route.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/api/auth/sync-session/route.ts
Normal file
22
app/api/auth/sync-session/route.ts
Normal file
@@ -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<string, string | number | boolean>);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
60
app/api/join/route.ts
Normal file
60
app/api/join/route.ts
Normal file
@@ -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<string, unknown>) : {};
|
||||||
|
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<string, unknown>) : {};
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/api/join/status/route.ts
Normal file
38
app/api/join/status/route.ts
Normal file
@@ -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<string, unknown>) : {};
|
||||||
|
return NextResponse.json({
|
||||||
|
hasRecord: !!payload.hasRecord,
|
||||||
|
isComplete: !!payload.isComplete,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ hasRecord: false, isComplete: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
467
app/api/pay/route.ts
Normal file
467
app/api/pay/route.ts
Normal file
@@ -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, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayHtml(
|
||||||
|
payUrl: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
options?: { directToCashier?: boolean; orderId?: string }
|
||||||
|
): string {
|
||||||
|
const inputs = Object.entries(params)
|
||||||
|
.filter(([, value]) => value != null && String(value).trim() !== "")
|
||||||
|
.map(
|
||||||
|
([key, value]) =>
|
||||||
|
`<input type="hidden" name="${escapeAttr(key)}" value="${escapeAttr(
|
||||||
|
String(value)
|
||||||
|
)}" />`
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const pendingOrderScript = options?.orderId
|
||||||
|
? `<script>(function(){var v=${JSON.stringify(
|
||||||
|
`${options.orderId}|${Date.now()}`
|
||||||
|
)};try{localStorage.setItem("join_pay_order",v);}catch(e){}document.cookie="join_pay_order="+encodeURIComponent(v)+"; path=/; max-age=900";})();</script>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return options?.directToCashier
|
||||||
|
? `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>支付</title><style>body{margin:0;overflow:hidden}</style></head><body><form id="f" action="${escapeAttr(
|
||||||
|
payUrl
|
||||||
|
)}" method="POST">${inputs}</form>${pendingOrderScript}<script>document.getElementById("f").submit()</script></body></html>`
|
||||||
|
: `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>跳转支付</title><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style></head><body><p>正在跳转到支付页面...</p><form id="f" action="${escapeAttr(
|
||||||
|
payUrl
|
||||||
|
)}" method="POST">${inputs}</form>${pendingOrderScript}<script>document.getElementById("f").submit()</script></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>${esc(title)}</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
body{font-family:system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);}
|
||||||
|
.card{background:#fff;border-radius:20px;padding:40px;box-shadow:0 25px 50px -12px rgba(0,0,0,.25);max-width:420px;}
|
||||||
|
h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}
|
||||||
|
.hint{color:#64748b;font-size:14px;margin-bottom:20px;}
|
||||||
|
.wait-row{display:flex;align-items:center;justify-content:center;gap:24px;margin:20px 0;}
|
||||||
|
.qr-wrap img{width:200px;height:200px;border-radius:12px;border:2px solid #e2e8f0;background:#fff;}
|
||||||
|
.countdown-wrap{width:72px;height:72px;flex-shrink:0;position:relative;}
|
||||||
|
.countdown-ring{transform:rotate(-90deg);}
|
||||||
|
.countdown-ring circle{fill:none;stroke-width:5;transition:stroke-dashoffset .5s linear;}
|
||||||
|
.countdown-ring .bg{stroke:#e2e8f0;}
|
||||||
|
.countdown-ring .progress{stroke:#22c55e;stroke-linecap:round;}
|
||||||
|
.countdown-inner{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;}
|
||||||
|
.countdown-num{font-size:1rem;font-weight:700;color:#1e293b;line-height:1;}
|
||||||
|
.countdown-unit{font-size:9px;color:#94a3b8;}
|
||||||
|
.tip{font-size:13px;color:#64748b;margin-top:16px;}
|
||||||
|
.success-wrap{display:none;}
|
||||||
|
.success-wrap.show{display:block;}
|
||||||
|
.wait-wrap.hide{display:none;}
|
||||||
|
.success-icon{font-size:4rem;margin-bottom:16px;animation:scaleIn .4s ease;}
|
||||||
|
.success-title{font-size:1.25rem;color:#16a34a;font-weight:600;margin-bottom:8px;}
|
||||||
|
.success-desc{color:#64748b;font-size:14px;margin-bottom:24px;}
|
||||||
|
.success-countdown{font-size:1.5rem;font-weight:700;color:#667eea;}
|
||||||
|
@keyframes scaleIn{from{transform:scale(0);opacity:0;}to{transform:scale(1);opacity:1;}}
|
||||||
|
@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.7;}}
|
||||||
|
.pulse{animation:pulse 2s ease-in-out infinite;}
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="wait-wrap" id="waitWrap">
|
||||||
|
<h2>${esc(title)}</h2>
|
||||||
|
<p class="hint">${esc(hint)}</p>
|
||||||
|
<div class="wait-row">
|
||||||
|
<div class="countdown-wrap">
|
||||||
|
<svg class="countdown-ring" width="72" height="72" viewBox="0 0 72 72">
|
||||||
|
<circle class="bg" cx="36" cy="36" r="32"/>
|
||||||
|
<circle class="progress" id="progressCircle" cx="36" cy="36" r="32" stroke-dasharray="201" stroke-dashoffset="0"/>
|
||||||
|
</svg>
|
||||||
|
<div class="countdown-inner">
|
||||||
|
<span class="countdown-num" id="countdownNum">5:00</span>
|
||||||
|
<span class="countdown-unit">分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="qr-wrap">
|
||||||
|
<img src="${imgSrc}" alt="支付二维码" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="tip pulse" id="tip">等待支付中...</p>
|
||||||
|
</div>
|
||||||
|
<div class="success-wrap" id="successWrap">
|
||||||
|
<div class="success-icon">✅</div>
|
||||||
|
<div class="success-title">支付成功</div>
|
||||||
|
<div class="success-desc">${esc(successDesc)}</div>
|
||||||
|
<div class="success-countdown" id="successCountdown">3 秒后跳转</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
try{localStorage.setItem("join_pay_order",${JSON.stringify(`${orderId}|${Date.now()}`)});}catch(e){}
|
||||||
|
var orderId=${JSON.stringify(orderId)};
|
||||||
|
var returnUrl=${JSON.stringify(returnUrl)};
|
||||||
|
if(!orderId){return;}
|
||||||
|
var waitWrap=document.getElementById("waitWrap");
|
||||||
|
var successWrap=document.getElementById("successWrap");
|
||||||
|
var tip=document.getElementById("tip");
|
||||||
|
var countdownNum=document.getElementById("countdownNum");
|
||||||
|
var progressCircle=document.getElementById("progressCircle");
|
||||||
|
var successCountdown=document.getElementById("successCountdown");
|
||||||
|
var maxT=300,left=maxT;
|
||||||
|
var circumference=2*Math.PI*32;
|
||||||
|
function fmt(t){var m=Math.floor(t/60),s=t%60;return m+":"+(s<10?"0":"")+s;}
|
||||||
|
var countdownTimer=setInterval(function(){
|
||||||
|
left--;
|
||||||
|
countdownNum.textContent=left>60?fmt(left):left;
|
||||||
|
progressCircle.style.strokeDashoffset=circumference*(1-left/maxT);
|
||||||
|
if(left<=0){clearInterval(countdownTimer);tip.textContent="支付超时,返回首页";setTimeout(function(){window.location.href="/";},800);}
|
||||||
|
},1000);
|
||||||
|
function showSuccess(){
|
||||||
|
clearInterval(countdownTimer);
|
||||||
|
waitWrap.classList.add("hide");
|
||||||
|
successWrap.classList.add("show");
|
||||||
|
var s=3;
|
||||||
|
var t=setInterval(function(){
|
||||||
|
s--;
|
||||||
|
successCountdown.textContent=s>0?s+" 秒后跳转":"跳转中…";
|
||||||
|
if(s<=0){clearInterval(t);window.location.href=returnUrl;}
|
||||||
|
},1000);
|
||||||
|
}
|
||||||
|
var confirmUrl=${JSON.stringify(confirmUrl)};
|
||||||
|
function doConfirm(attempt){
|
||||||
|
if(attempt>=3){showSuccess();return;}
|
||||||
|
fetch(confirmUrl,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({order_id:orderId})})
|
||||||
|
.then(function(r){return r.json();})
|
||||||
|
.then(function(cd){
|
||||||
|
if(cd&&cd.ok){showSuccess();}
|
||||||
|
else{setTimeout(function(){doConfirm(attempt+1);},800);}
|
||||||
|
})
|
||||||
|
.catch(function(){setTimeout(function(){doConfirm(attempt+1);},800);});
|
||||||
|
}
|
||||||
|
function check(){
|
||||||
|
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId),{cache:"no-store"})
|
||||||
|
.then(function(r){return r.json();})
|
||||||
|
.then(function(d){
|
||||||
|
if(d.ok&&d.paid){doConfirm(0);return;}
|
||||||
|
setTimeout(check,500);
|
||||||
|
})
|
||||||
|
.catch(function(){setTimeout(check,500);});
|
||||||
|
}
|
||||||
|
setTimeout(check,500);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPayOrder(
|
||||||
|
apiUrl: string,
|
||||||
|
userId: string,
|
||||||
|
returnUrl: string,
|
||||||
|
totalFee: number,
|
||||||
|
type: string,
|
||||||
|
channel: string,
|
||||||
|
provider?: "xorpay",
|
||||||
|
clientIp?: string
|
||||||
|
) {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
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<string, string>,
|
||||||
|
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<string, unknown>
|
||||||
|
) {
|
||||||
|
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<string, string | number>;
|
||||||
|
const contentType = request.headers.get("content-type") || "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
body = (await request.json()) as Record<string, string | number>;
|
||||||
|
} else {
|
||||||
|
const form = await request.formData();
|
||||||
|
body = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown> = {
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/api/pay/status/route.ts
Normal file
46
app/api/pay/status/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
28
app/api/salon/check-user/route.ts
Normal file
28
app/api/salon/check-user/route.ts
Normal file
@@ -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<string, unknown>) : {};
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/api/salon/complete-order/route.ts
Normal file
27
app/api/salon/complete-order/route.ts
Normal file
@@ -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<string, unknown>) : {};
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/api/salon/ensure-user/route.ts
Normal file
29
app/api/salon/ensure-user/route.ts
Normal file
@@ -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<string, unknown>) : {};
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
5
app/api/salon/set-needs-password-change/route.ts
Normal file
5
app/api/salon/set-needs-password-change/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
23
app/api/upload-media/route.ts
Normal file
23
app/api/upload-media/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/api/vip/check/route.ts
Normal file
41
app/api/vip/check/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
177
app/components/AuthModal.tsx
Normal file
177
app/components/AuthModal.tsx
Normal file
@@ -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<string | null>(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 = (
|
||||||
|
<div className="fixed inset-0 z-[9999] overflow-y-auto">
|
||||||
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} aria-hidden />
|
||||||
|
<div className="flex min-h-svh items-center justify-center p-4 py-8">
|
||||||
|
<div className="relative w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900 dark:border dark:border-gray-700 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
|
||||||
|
{mode === "login" ? "登录" : "注册"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{mode === "login" ? "使用邮箱和密码登录" : "创建新账号"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">邮箱</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{mode === "register" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">确认密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passwordConfirm}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600 dark:bg-red-900/30 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-lg bg-[#ff4d4f] py-3 font-semibold text-white transition-colors hover:bg-[#ff3333] disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{loading ? "处理中…" : mode === "login" ? "登录" : "注册"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{mode === "login" ? (
|
||||||
|
<>
|
||||||
|
没有账号?{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setMode("register"); setError(null); }}
|
||||||
|
className="font-medium text-[#ff4d4f] hover:text-[#ff7a45]"
|
||||||
|
>
|
||||||
|
立即注册
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
已有账号?{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setMode("login"); setError(null); }}
|
||||||
|
className="font-medium text-[#ff4d4f] hover:text-[#ff7a45]"
|
||||||
|
>
|
||||||
|
去登录
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
|
}
|
||||||
83
app/components/CityCard.tsx
Normal file
83
app/components/CityCard.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={(e) => 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)}
|
||||||
|
>
|
||||||
|
<span className="absolute bottom-8 right-2 text-[70px] sm:text-[90px] opacity-[0.12] select-none pointer-events-none leading-none">
|
||||||
|
{city.icon}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 z-10 flex flex-col justify-between p-3 sm:p-4 transition-opacity duration-300 ${hovered ? "opacity-0" : "opacity-100"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<span className="text-white/70 text-base sm:text-xl font-bold">{rank}</span>
|
||||||
|
<span className="text-[10px] sm:text-xs bg-white/20 backdrop-blur-sm rounded-full px-1.5 sm:px-2 py-0.5 sm:py-1 text-white">
|
||||||
|
📡 {city.internetSpeed}Mbps
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center -mt-2">
|
||||||
|
<h3 className="text-xl sm:text-[28px] font-bold text-white drop-shadow-md tracking-wide">
|
||||||
|
{city.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs sm:text-sm text-white/60 mt-0.5">
|
||||||
|
{city.countryEmoji} {city.country}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
|
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
|
||||||
|
👥 {city.nomadsNow} 人在此
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
|
||||||
|
🌡 {city.temperature}°C
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end justify-between text-white text-xs sm:text-sm">
|
||||||
|
<span className="text-white/80 text-[10px] sm:text-xs line-clamp-1 max-w-[60%]">
|
||||||
|
{city.description}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold shrink-0">
|
||||||
|
¥{city.costPerMonth.toLocaleString()}/月
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 z-20 bg-[#2a2a2a]/95 hidden sm:flex flex-col justify-center p-4 sm:p-5 transition-opacity duration-300 ${hovered ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
||||||
|
>
|
||||||
|
<div className="text-center text-white">
|
||||||
|
<p className="text-lg font-bold">{city.name}</p>
|
||||||
|
<p className="text-sm text-white/70 mt-1">{city.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(CityCardComponent);
|
||||||
46
app/components/CityModal.tsx
Normal file
46
app/components/CityModal.tsx
Normal file
@@ -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 = (
|
||||||
|
<div className="fixed inset-0 z-[9999] overflow-y-auto">
|
||||||
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} aria-hidden />
|
||||||
|
<div className="flex min-h-svh items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 p-6 shadow-xl dark:border dark:border-gray-700"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{city.name}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-gray-600 dark:text-gray-400">{city.description}</p>
|
||||||
|
<div className="mt-4 space-y-2 text-sm">
|
||||||
|
<p>💰 月均消费: ¥{city.costPerMonth.toLocaleString()}</p>
|
||||||
|
<p>📡 网速: {city.internetSpeed}Mbps</p>
|
||||||
|
<p>👥 当前游民: {city.nomadsNow} 人</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
|
}
|
||||||
88
app/components/FilterBar.tsx
Normal file
88
app/components/FilterBar.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
全部: "🌍",
|
||||||
|
热门: "🔥",
|
||||||
|
便宜: "💰",
|
||||||
|
都市: "🌆",
|
||||||
|
文化: "🎨",
|
||||||
|
宜居: "🏡",
|
||||||
|
友好社区: "🤝",
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mb-6" id="cities">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
发现目的地
|
||||||
|
</h2>
|
||||||
|
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
{resultCount} 个城市
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500">排序:</span>
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => onSortChange(e.target.value)}
|
||||||
|
className="text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 sm:py-1.5 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] cursor-pointer min-h-[2.5rem] sm:min-h-0"
|
||||||
|
>
|
||||||
|
{SORT_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1">
|
||||||
|
{allTags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => onFilterChange(tag)}
|
||||||
|
className={`shrink-0 flex items-center gap-1 px-3 py-2 sm:py-1.5 rounded-full text-sm font-medium border transition-all min-h-[2.5rem] sm:min-h-0 ${
|
||||||
|
activeFilter === tag
|
||||||
|
? "border-gray-900 dark:border-gray-100 bg-gray-100 dark:bg-gray-800"
|
||||||
|
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-xs">{tagIcons[tag] || "🏷️"}</span>
|
||||||
|
<span>{tag}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(FilterBarComponent);
|
||||||
9
app/components/Footer.tsx
Normal file
9
app/components/Footer.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-gray-200 dark:border-gray-800 mt-12 py-8">
|
||||||
|
<div className="max-w-[1400px] mx-auto px-5 sm:px-10 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<p>© {new Date().getFullYear()} Salon. 探索中国,远程工作,自由生活。</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
app/components/HeroSection.tsx
Normal file
273
app/components/HeroSection.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<section className="relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-orange-50/70 via-white to-[#fafafa] dark:from-gray-950 dark:via-gray-900 dark:to-gray-950" />
|
||||||
|
<div
|
||||||
|
className="absolute top-0 right-0 w-[500px] h-[500px] -translate-y-1/3 translate-x-1/4 opacity-40 rounded-full"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle, rgba(249,115,22,0.25) 0%, transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative max-w-[1400px] mx-auto px-5 sm:px-10 py-10 sm:py-14 md:py-20">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-start lg:gap-10 xl:gap-16">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-orange-700 dark:text-orange-400 bg-orange-100 dark:bg-orange-900/30 px-3 py-1.5 rounded-full mb-5">
|
||||||
|
🏆 数字游民首选
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight mb-5">
|
||||||
|
<span className="hero-gradient-text">探索中国</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-gray-900 dark:text-gray-100">远程工作,自由生活</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-base sm:text-lg text-gray-500 dark:text-gray-400 max-w-2xl mb-7 leading-relaxed">
|
||||||
|
加入全球数字游民社区,发现中国最适合远程工作和生活的城市
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2.5 mb-8">
|
||||||
|
{[
|
||||||
|
{ icon: "🍹", text: "线下聚会与线上分享" },
|
||||||
|
{ icon: "❤️", text: "结识志同道合的伙伴" },
|
||||||
|
{ icon: "🧪", text: "探索新城市与新可能" },
|
||||||
|
{ icon: "🌎", text: "全球数字游民网络" },
|
||||||
|
{ icon: "💬", text: "社区互助与资源共享" },
|
||||||
|
].map((f) => (
|
||||||
|
<div key={f.icon} className="flex items-start gap-2.5">
|
||||||
|
<span className="text-base sm:text-lg shrink-0 mt-0.5">{f.icon}</span>
|
||||||
|
<span className="text-sm sm:text-base text-gray-600 dark:text-gray-400 underline decoration-gray-300 dark:decoration-gray-600 underline-offset-2">
|
||||||
|
{f.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-8">
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{avatarPhotos.slice(0, 8).map((src, i) => (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
已有 5,000+ 成员加入
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:w-[360px] xl:w-[400px] shrink-0 mt-10 lg:mt-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl overflow-hidden border border-gray-100 dark:border-gray-800">
|
||||||
|
<div className="relative h-48 sm:h-56 bg-gradient-to-br from-emerald-400 via-teal-500 to-cyan-600 flex items-center justify-center overflow-hidden">
|
||||||
|
<button className="relative w-16 h-16 rounded-full bg-white/95 flex items-center justify-center shadow-lg hover:scale-110 transition-transform">
|
||||||
|
<svg className="w-7 h-7 text-emerald-600 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLoggedIn ? (
|
||||||
|
<form onSubmit={handleJoinClick} className="p-4 sm:p-5">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => { 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 && (
|
||||||
|
<p className="mb-3 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={checking}
|
||||||
|
className="w-full bg-[#ff4d4f] hover:bg-[#ff3333] dark:hover:bg-[#ff5c5e] text-white py-3 rounded-lg text-sm font-semibold transition-colors active:scale-[0.98] disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{checking ? "处理中…" : "加入社区 →"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 sm:p-5 text-center">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
您已登录,可前往社区参与互动
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<JoinModal
|
||||||
|
isOpen={joinModalOpen}
|
||||||
|
onClose={() => { setJoinModalOpen(false); setJoinModalVipOnly(false); }}
|
||||||
|
initialEmail={email.trim()}
|
||||||
|
vipOnly={joinModalVipOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2 justify-center">
|
||||||
|
<Link href="/join" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||||
|
💬 加入社区
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
app/components/JoinModal.tsx
Normal file
152
app/components/JoinModal.tsx
Normal file
@@ -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<string | null>(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 = (
|
||||||
|
<div className="fixed inset-0 z-[9999] overflow-y-auto">
|
||||||
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} aria-hidden />
|
||||||
|
<div className="flex min-h-svh items-center justify-center p-4 py-8">
|
||||||
|
<div className="relative w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900 dark:border dark:border-gray-700 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
|
||||||
|
{vipOnly ? "验证密码登录" : "加入社区"}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">邮箱</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => { 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => { 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600 dark:bg-red-900/30 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-lg bg-[#ff4d4f] py-3 font-semibold text-white transition-colors hover:bg-[#ff3333] disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{loading ? "处理中…" : vipOnly ? "登录" : "加入社区 →"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
|
}
|
||||||
100
app/data/cities.ts
Normal file
100
app/data/cities.ts
Normal file
@@ -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 = ["全部", "热门", "便宜", "都市", "文化", "宜居", "友好社区"];
|
||||||
107
app/globals.css
Normal file
107
app/globals.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
505
app/join/page.tsx
Normal file
505
app/join/page.tsx
Normal file
@@ -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<FormData>({
|
||||||
|
nickname: "",
|
||||||
|
gender: "",
|
||||||
|
single: "",
|
||||||
|
profession: "",
|
||||||
|
intro: "",
|
||||||
|
wechat: "",
|
||||||
|
education: "",
|
||||||
|
graduationYear: "",
|
||||||
|
phone: "",
|
||||||
|
});
|
||||||
|
const [media, setMedia] = useState<MediaInfo | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [isVipSubmit, setIsVipSubmit] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] dark:bg-gray-950 px-4">
|
||||||
|
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white dark:bg-gray-900 p-10 text-center shadow-lg border border-gray-100 dark:border-gray-800">
|
||||||
|
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-5xl">
|
||||||
|
✅
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100">
|
||||||
|
{isAlreadyRecorded ? "资料已录入" : isVipSubmit ? "提交成功" : "支付成功"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-slate-500 dark:text-slate-400">
|
||||||
|
{isAlreadyRecorded ? "您的报名信息已录入,无需重复提交" : isVipSubmit ? "感谢您的参与" : "感谢您的支持"}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-8 inline-flex items-center gap-2 rounded-full bg-[#ff4d4f] px-8 py-3 font-semibold text-white transition-colors hover:bg-[#ff7a45]"
|
||||||
|
>
|
||||||
|
← 返回首页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkingStatus) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] dark:bg-gray-950 px-4">
|
||||||
|
<div className="text-slate-500 dark:text-slate-400">加载中…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#f0f4f8] dark:bg-gray-950">
|
||||||
|
<div className="mx-auto max-w-2xl px-0 py-0 sm:px-6 sm:py-10 lg:py-14">
|
||||||
|
<div className="overflow-hidden bg-white dark:bg-gray-900 shadow-sm sm:rounded-t-2xl">
|
||||||
|
<div className="relative h-40 overflow-hidden bg-gradient-to-br from-sky-400 via-cyan-500 to-teal-400 sm:h-52">
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-white">
|
||||||
|
<div className="text-6xl sm:text-7xl">🌍</div>
|
||||||
|
<div className="mt-2 text-sm font-medium opacity-80">
|
||||||
|
数字游民社区
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-5 text-center sm:px-8 sm:py-7">
|
||||||
|
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100 sm:text-2xl">
|
||||||
|
🌍 数字游民社区报名
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 whitespace-pre-line text-sm leading-relaxed text-slate-400 dark:text-slate-500 sm:text-base">
|
||||||
|
{"💼 结识志同道合的伙伴\n📍 线下聚会 + 线上分享\n🤝 互助成长,资源共享"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="mt-0 bg-white dark:bg-gray-900 px-5 pb-8 pt-2 shadow-sm sm:mt-5 sm:rounded-2xl sm:px-8 sm:py-8 sm:shadow-md border border-gray-100 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
|
||||||
|
<Field label="🌟 昵称">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={140}
|
||||||
|
placeholder="请输入您的昵称"
|
||||||
|
value={form.nickname}
|
||||||
|
onChange={(e) => set("nickname", e.target.value)}
|
||||||
|
required
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="👤 性别">
|
||||||
|
<select
|
||||||
|
value={form.gender}
|
||||||
|
onChange={(e) => set("gender", e.target.value)}
|
||||||
|
required
|
||||||
|
className={`form-input appearance-none ${form.gender ? "text-slate-800 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}`}
|
||||||
|
>
|
||||||
|
<option value="" disabled>请选择</option>
|
||||||
|
{genderOptions.map((o) => (
|
||||||
|
<option key={o} value={o}>{o}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
|
||||||
|
<Field label="💕 感情状态">
|
||||||
|
<select
|
||||||
|
value={form.single}
|
||||||
|
onChange={(e) => set("single", e.target.value)}
|
||||||
|
required
|
||||||
|
className={`form-input appearance-none ${form.single ? "text-slate-800 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}`}
|
||||||
|
>
|
||||||
|
<option value="" disabled>请选择</option>
|
||||||
|
{singleOptions.map((o) => (
|
||||||
|
<option key={o} value={o}>{o}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="💼 职业">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={140}
|
||||||
|
placeholder="您的职业或专业"
|
||||||
|
value={form.profession}
|
||||||
|
onChange={(e) => set("profession", e.target.value)}
|
||||||
|
required
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
|
||||||
|
<Field label="💬 微信号">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={140}
|
||||||
|
placeholder="用于拉群"
|
||||||
|
value={form.wechat}
|
||||||
|
onChange={(e) => set("wechat", e.target.value)}
|
||||||
|
required
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="📱 手机号">
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
maxLength={11}
|
||||||
|
pattern="^\d{11}$"
|
||||||
|
placeholder="请输入11位手机号"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value.replace(/\D/g, "").slice(0, 11);
|
||||||
|
set("phone", v);
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b border-slate-100 dark:border-gray-700 py-4 sm:py-5">
|
||||||
|
<label className="mb-2 block text-base font-bold text-slate-700 dark:text-slate-300 sm:text-[17px]">
|
||||||
|
📝 自我介绍
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
maxLength={500}
|
||||||
|
rows={5}
|
||||||
|
placeholder="分享您的经历、兴趣或加入原因,越详细越好哦~"
|
||||||
|
value={form.intro}
|
||||||
|
onChange={(e) => set("intro", e.target.value)}
|
||||||
|
className="form-input min-h-[120px] resize-none sm:min-h-[140px]"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-3 bottom-3 text-xs text-slate-300 dark:text-slate-500">
|
||||||
|
{form.intro.length}/500
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
|
||||||
|
<Field label="🎓 学历">
|
||||||
|
<select
|
||||||
|
value={form.education}
|
||||||
|
onChange={(e) => set("education", e.target.value)}
|
||||||
|
className={`form-input appearance-none ${form.education ? "text-slate-800 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}`}
|
||||||
|
>
|
||||||
|
<option value="" disabled>请选择</option>
|
||||||
|
{educationOptions.map((o) => (
|
||||||
|
<option key={o} value={o}>{o}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="📅 毕业时间">
|
||||||
|
<select
|
||||||
|
value={form.graduationYear}
|
||||||
|
onChange={(e) => set("graduationYear", e.target.value)}
|
||||||
|
className={`form-input appearance-none ${form.graduationYear ? "text-slate-800 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}`}
|
||||||
|
>
|
||||||
|
<option value="" disabled>请选择</option>
|
||||||
|
{graduationYears.map((y) => (
|
||||||
|
<option key={y} value={y}>{y}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-4 sm:py-5">
|
||||||
|
<label className="mb-1 block text-[15px] font-bold text-slate-700 dark:text-slate-300 sm:text-[17px]">
|
||||||
|
📷 头像 / 自拍视频
|
||||||
|
<span className="ml-2 text-xs font-normal text-slate-400 dark:text-slate-500">(可选)</span>
|
||||||
|
</label>
|
||||||
|
<p className="mb-3 text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
支持图片(JPG/PNG)或短视频(MP4),最大 20MB
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
|
||||||
|
className="hidden"
|
||||||
|
onChange={async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > 20 * 1024 * 1024) {
|
||||||
|
alert("文件大小不能超过 20MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isVideo = file.type.startsWith("video/");
|
||||||
|
const preview = URL.createObjectURL(file);
|
||||||
|
setMedia({ preview, url: "", type: isVideo ? "video" : "image" });
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch("/api/upload-media", {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data?.ok) {
|
||||||
|
throw new Error(data?.error || "上传失败");
|
||||||
|
}
|
||||||
|
setMedia((m) => (m ? { ...m, url: data.url } : null));
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : "上传失败");
|
||||||
|
URL.revokeObjectURL(preview);
|
||||||
|
setMedia(null);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{media ? (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
{uploading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-black/40 text-sm font-medium text-white">
|
||||||
|
上传中…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{media.type === "image" ? (
|
||||||
|
<img
|
||||||
|
src={media.preview}
|
||||||
|
alt="preview"
|
||||||
|
className="h-32 w-32 rounded-xl border border-slate-200 dark:border-gray-700 object-cover sm:h-40 sm:w-40"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
src={media.preview}
|
||||||
|
className="h-32 w-32 rounded-xl border border-slate-200 dark:border-gray-700 object-cover sm:h-40 sm:w-40"
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => {
|
||||||
|
URL.revokeObjectURL(media.preview);
|
||||||
|
setMedia(null);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}}
|
||||||
|
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white shadow-md transition-colors hover:bg-red-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="flex h-32 w-32 flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-slate-200 dark:border-gray-700 text-slate-400 dark:text-slate-500 transition-colors hover:border-sky-300 hover:text-sky-500 disabled:opacity-50 sm:h-40 sm:w-40"
|
||||||
|
>
|
||||||
|
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs">{uploading ? "上传中…" : "点击上传"}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 sm:mt-10">
|
||||||
|
{error && (
|
||||||
|
<p className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/30 px-4 py-3 text-center text-sm text-red-600 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || uploading || payRedirecting}
|
||||||
|
className="w-full rounded-full bg-[#1aad19] py-3.5 text-lg font-semibold text-white shadow-md shadow-emerald-500/20 transition-all hover:bg-[#179b16] hover:shadow-lg hover:shadow-emerald-500/25 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70 sm:py-4"
|
||||||
|
>
|
||||||
|
{uploading ? "媒体上传中…" : payRedirecting ? "跳转支付中…" : submitting ? "提交中…" : "提交申请"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<Link href="/" className="text-sm text-slate-400 dark:text-slate-500 transition-colors hover:text-[#ff4d4f]">
|
||||||
|
← 返回首页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AuthModal
|
||||||
|
isOpen={authOpen}
|
||||||
|
onClose={() => { setAuthOpen(false); setPendingSubmit(false); }}
|
||||||
|
onSuccess={handleAuthSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center gap-3 py-3.5 sm:flex-col sm:items-stretch sm:gap-0 sm:py-4 border-b border-slate-100 dark:border-gray-700 sm:border-b-0">
|
||||||
|
<label className="w-[90px] shrink-0 text-[15px] font-bold text-slate-700 dark:text-slate-300 sm:mb-2 sm:w-auto sm:text-[17px]">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className="relative min-w-0 flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
app/join/paid/page.tsx
Normal file
153
app/join/paid/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
function clearPendingOrder(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem("join_pay_order");
|
||||||
|
} catch {}
|
||||||
|
document.cookie = "join_pay_order=; path=/; max-age=0";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JoinPaidPage() {
|
||||||
|
const [completeStatus, setCompleteStatus] = useState<"pending" | "success" | "error">("pending");
|
||||||
|
const [errorDetail, setErrorDetail] = useState<string | null>(null);
|
||||||
|
const [countdown, setCountdown] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const tryComplete = async (orderId: string): Promise<{ ok: boolean; error?: 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 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const urlOrderId = typeof window !== "undefined"
|
||||||
|
? new URLSearchParams(window.location.search).get("order_id") || ""
|
||||||
|
: "";
|
||||||
|
const cookie = document.cookie.split(";").find((c) => c.trim().startsWith("join_pay_order="));
|
||||||
|
const rawValue = cookie?.split("=")[1]?.trim();
|
||||||
|
const storageValue = (() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem("join_pay_order") || "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const decoded = rawValue ? decodeURIComponent(rawValue) : storageValue;
|
||||||
|
const orderId = urlOrderId || decoded.split("|")[0]?.trim() || "";
|
||||||
|
if (!orderId) {
|
||||||
|
clearPendingOrder();
|
||||||
|
window.location.replace("/join");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxClientRetries = 5;
|
||||||
|
const retryDelays = [2000, 3000, 4000, 5000, 6000];
|
||||||
|
let lastError = "";
|
||||||
|
for (let i = 0; i <= maxClientRetries; i++) {
|
||||||
|
if (cancelled) return;
|
||||||
|
try {
|
||||||
|
const result = await tryComplete(orderId);
|
||||||
|
if (result.ok) {
|
||||||
|
clearPendingOrder();
|
||||||
|
if (!cancelled) setCompleteStatus("success");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastError = result.error || "unknown";
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e instanceof Error ? e.message : "network error";
|
||||||
|
}
|
||||||
|
if (i < maxClientRetries) {
|
||||||
|
await new Promise((r) => setTimeout(r, retryDelays[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setCompleteStatus("error");
|
||||||
|
setErrorDetail(lastError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
run();
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const COUNTDOWN_SECONDS = 5;
|
||||||
|
useEffect(() => {
|
||||||
|
if (completeStatus !== "success") return;
|
||||||
|
setCountdown(COUNTDOWN_SECONDS);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCountdown((prev) => {
|
||||||
|
if (prev == null || prev <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
window.location.replace("/");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [completeStatus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] dark:bg-gray-950 px-4">
|
||||||
|
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white dark:bg-gray-900 p-10 text-center shadow-lg border border-gray-100 dark:border-gray-800">
|
||||||
|
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-5xl">
|
||||||
|
✅
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100">
|
||||||
|
{completeStatus === "pending" ? "处理中…" : completeStatus === "success" ? "支付成功" : "处理异常"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-slate-500 dark:text-slate-400">
|
||||||
|
{completeStatus === "success"
|
||||||
|
? countdown != null
|
||||||
|
? `${countdown} 秒后返回首页`
|
||||||
|
: "感谢您的支持"
|
||||||
|
: completeStatus === "error"
|
||||||
|
? "请稍后刷新页面重试"
|
||||||
|
: "正在确认支付状态…"}
|
||||||
|
</p>
|
||||||
|
{completeStatus === "error" && errorDetail && (
|
||||||
|
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">{errorDetail}</p>
|
||||||
|
)}
|
||||||
|
{completeStatus === "error" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-4 rounded-full border border-[#ff4d4f] px-6 py-2.5 text-sm font-medium text-[#ff4d4f] hover:bg-[#ff4d4f]/10 transition-colors"
|
||||||
|
>
|
||||||
|
刷新重试
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-8 inline-flex items-center gap-2 rounded-full bg-[#ff4d4f] px-8 py-3 font-semibold text-white transition-colors hover:bg-[#ff7a45]"
|
||||||
|
>
|
||||||
|
← 返回首页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
app/layout.tsx
Normal file
19
app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Salon - 探索中国",
|
||||||
|
description: "加入数字游民社区,探索中国最适合远程工作的城市",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh">
|
||||||
|
<body className="antialiased">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
app/lib/auth-cookie.ts
Normal file
27
app/lib/auth-cookie.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
const COOKIE_NAME = "pb_session";
|
||||||
|
export const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||||
|
|
||||||
|
export { COOKIE_NAME };
|
||||||
|
|
||||||
|
export function getHostFromRequest(request: { headers: { get: (k: string) => string | null } }): string {
|
||||||
|
const host = request.headers.get("host") || request.headers.get("x-forwarded-host") || "";
|
||||||
|
return host.split(",")[0].trim().replace(/:\d+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCookieOptions(clear = false, requestHost?: string) {
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
const domain = process.env.AUTH_COOKIE_DOMAIN?.trim();
|
||||||
|
const opts: Record<string, unknown> = {
|
||||||
|
path: "/",
|
||||||
|
maxAge: clear ? 0 : COOKIE_MAX_AGE,
|
||||||
|
secure: isProd,
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
httpOnly: true,
|
||||||
|
};
|
||||||
|
if (domain && isProd) {
|
||||||
|
opts.domain = domain;
|
||||||
|
} else if (!isProd && requestHost && /^(\d{1,3}\.){3}\d{1,3}$/.test(requestHost)) {
|
||||||
|
opts.domain = requestHost;
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
5
app/lib/env.ts
Normal file
5
app/lib/env.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
export const SITE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||||
|
(isDev ? "http://localhost:3002" : "http://localhost:3002");
|
||||||
19
app/lib/payEnv.ts
Normal file
19
app/lib/payEnv.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type PayEnv = "pc" | "h5" | "wechat";
|
||||||
|
|
||||||
|
export function isWeChat(): boolean {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
return /MicroMessenger/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMobile(): boolean {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
return /AppleWebKit.*Mobile|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||||
|
navigator.userAgent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPayEnv(): PayEnv {
|
||||||
|
if (isWeChat()) return "wechat";
|
||||||
|
if (isMobile()) return "h5";
|
||||||
|
return "pc";
|
||||||
|
}
|
||||||
50
app/lib/payment/client.ts
Normal file
50
app/lib/payment/client.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { getPaymentConfig, getJoinPaymentConfig } from "@/config/services.config";
|
||||||
|
|
||||||
|
export type PayChannel = "wxpay" | "alipay";
|
||||||
|
|
||||||
|
export function redirectToPay(params: {
|
||||||
|
user_id: string;
|
||||||
|
return_url: string;
|
||||||
|
total_fee?: number;
|
||||||
|
type?: string;
|
||||||
|
channel: PayChannel;
|
||||||
|
device: "pc" | "h5" | "wechat";
|
||||||
|
useJoinConfig?: boolean;
|
||||||
|
}): void {
|
||||||
|
const baseConfig = getPaymentConfig();
|
||||||
|
const joinConfig = params.useJoinConfig ? getJoinPaymentConfig() : null;
|
||||||
|
const total_fee = params.total_fee ?? joinConfig?.amount ?? baseConfig.defaultAmount;
|
||||||
|
const type = params.type ?? joinConfig?.type ?? baseConfig.defaultType;
|
||||||
|
|
||||||
|
const payForm = document.createElement("form");
|
||||||
|
payForm.method = "POST";
|
||||||
|
payForm.action = "/api/pay";
|
||||||
|
payForm.target = "_self";
|
||||||
|
payForm.style.display = "none";
|
||||||
|
|
||||||
|
const fields: [string, string][] = [
|
||||||
|
["user_id", params.user_id],
|
||||||
|
["return_url", params.return_url],
|
||||||
|
["total_fee", String(total_fee)],
|
||||||
|
["type", type],
|
||||||
|
["channel", params.channel],
|
||||||
|
["device", params.device],
|
||||||
|
["html", "1"],
|
||||||
|
];
|
||||||
|
|
||||||
|
fields.forEach(([k, v]) => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = k;
|
||||||
|
input.value = v;
|
||||||
|
payForm.appendChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(payForm);
|
||||||
|
payForm.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeviceFromEnv(env: "pc" | "h5" | "wechat"): "pc" | "h5" | "wechat" {
|
||||||
|
if (env === "wechat") return "wechat";
|
||||||
|
return env === "pc" ? "pc" : "h5";
|
||||||
|
}
|
||||||
4
app/lib/payment/index.ts
Normal file
4
app/lib/payment/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { redirectToPay, getDeviceFromEnv } from "./client";
|
||||||
|
export { getPayEnv } from "../payEnv";
|
||||||
|
export { usePayStatusPoll } from "./usePayStatusPoll";
|
||||||
|
export type { PayChannel } from "./client";
|
||||||
165
app/lib/payment/usePayStatusPoll.ts
Normal file
165
app/lib/payment/usePayStatusPoll.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const COOKIE_NAME = "join_pay_order";
|
||||||
|
const STORAGE_NAME = "join_pay_order";
|
||||||
|
const POLL_INTERVAL_MS = 1200;
|
||||||
|
const MAX_POLLS = 60;
|
||||||
|
const MAX_AGE_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
function getCookie(name: string): string | null {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCookie(name: string): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
document.cookie = `${name}=; path=/; max-age=0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStorage(name: string): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return window.localStorage.getItem(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStorage(name: string): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.localStorage.removeItem(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingOrder(): void {
|
||||||
|
clearCookie(COOKIE_NAME);
|
||||||
|
removeStorage(STORAGE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistPendingOrder(raw: string): void {
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(raw)}; path=/; max-age=900`;
|
||||||
|
}
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_NAME, raw);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePendingOrder(raw: string | null): [string | null, boolean] {
|
||||||
|
if (!raw?.trim()) return [null, false];
|
||||||
|
const parts = raw.split("|");
|
||||||
|
const orderId = parts[0]?.trim() || raw.trim();
|
||||||
|
const ts = parts[1] ? parseInt(parts[1], 10) : NaN;
|
||||||
|
if (!orderId) return [null, false];
|
||||||
|
if (Number.isNaN(ts)) return [orderId, false];
|
||||||
|
if (Date.now() - ts > MAX_AGE_MS) return [orderId, false];
|
||||||
|
return [orderId, true];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePayStatusPoll(onPaid: (orderId: string) => void): boolean {
|
||||||
|
const [polling, setPolling] = useState(false);
|
||||||
|
const onPaidRef = useRef(onPaid);
|
||||||
|
onPaidRef.current = onPaid;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlOrderId =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? new URLSearchParams(window.location.search).get("order_id") || ""
|
||||||
|
: "";
|
||||||
|
const cookieRaw = getCookie(COOKIE_NAME);
|
||||||
|
const [cookieOrderId, cookieValid] = parsePendingOrder(cookieRaw);
|
||||||
|
const storageRaw = getStorage(STORAGE_NAME);
|
||||||
|
const [storageOrderId, storageValid] = parsePendingOrder(storageRaw);
|
||||||
|
let orderId = cookieValid ? cookieOrderId : storageValid ? storageOrderId : null;
|
||||||
|
let activeRaw = cookieValid ? cookieRaw : storageValid ? storageRaw : null;
|
||||||
|
if (!orderId && urlOrderId.trim()) {
|
||||||
|
orderId = urlOrderId.trim();
|
||||||
|
activeRaw = `${orderId}|${Date.now()}`;
|
||||||
|
persistPendingOrder(activeRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orderId) {
|
||||||
|
if (cookieRaw || storageRaw) {
|
||||||
|
clearPendingOrder();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeRaw) {
|
||||||
|
persistPendingOrder(activeRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPolling(true);
|
||||||
|
let attempts = 0;
|
||||||
|
let stopped = false;
|
||||||
|
let timer: number | undefined;
|
||||||
|
let inFlight = false;
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
stopped = true;
|
||||||
|
if (timer) window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleNext = () => {
|
||||||
|
if (stopped) return;
|
||||||
|
timer = window.setTimeout(tick, POLL_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
if (stopped) return;
|
||||||
|
if (inFlight) {
|
||||||
|
scheduleNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts > MAX_POLLS) {
|
||||||
|
stop();
|
||||||
|
clearPendingOrder();
|
||||||
|
setPolling(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlight = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/pay/status?order_id=${encodeURIComponent(orderId!)}`,
|
||||||
|
{ cache: "no-store" }
|
||||||
|
);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (data?.ok && data?.paid) {
|
||||||
|
stop();
|
||||||
|
clearPendingOrder();
|
||||||
|
setPolling(false);
|
||||||
|
onPaidRef.current(orderId!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep polling
|
||||||
|
} finally {
|
||||||
|
inFlight = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResume = () => {
|
||||||
|
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void tick();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleResume);
|
||||||
|
window.addEventListener("pageshow", handleResume);
|
||||||
|
window.addEventListener("focus", handleResume);
|
||||||
|
void tick();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", handleResume);
|
||||||
|
window.removeEventListener("pageshow", handleResume);
|
||||||
|
window.removeEventListener("focus", handleResume);
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return polling;
|
||||||
|
}
|
||||||
40
app/lib/pocketbase/admin.ts
Normal file
40
app/lib/pocketbase/admin.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { getPocketBaseConfig } from "@/config/services.config";
|
||||||
|
|
||||||
|
let cachedToken: { token: string; expiresAt: number } | null = null;
|
||||||
|
const TOKEN_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export async function getAdminToken(): Promise<string | null> {
|
||||||
|
if (cachedToken && Date.now() < cachedToken.expiresAt) {
|
||||||
|
return cachedToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = process.env.POCKETBASE_EMAIL;
|
||||||
|
const password = process.env.POCKETBASE_PASSWORD;
|
||||||
|
if (!email || !password) return null;
|
||||||
|
|
||||||
|
const pb = getPocketBaseConfig();
|
||||||
|
const base = pb.url.replace(/\/$/, "");
|
||||||
|
const body = JSON.stringify({ identity: email, password });
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
const endpoints = [
|
||||||
|
`${base}/api/collections/_superusers/auth-with-password`,
|
||||||
|
`${base}/api/admins/auth-with-password`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const url of endpoints) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method: "POST", headers, body });
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const data = await res.json();
|
||||||
|
const token = data?.token;
|
||||||
|
if (token) {
|
||||||
|
cachedToken = { token, expiresAt: Date.now() + TOKEN_TTL_MS };
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
95
app/lib/pocketbase/auth.ts
Normal file
95
app/lib/pocketbase/auth.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { getPocketBaseConfig } from "@/config/services.config";
|
||||||
|
import { PB_STORAGE_KEYS } from "./constants";
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResult {
|
||||||
|
token: string;
|
||||||
|
record: AuthUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPBUrl(): string {
|
||||||
|
return getPocketBaseConfig().url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pbLogin(identity: string, password: string): Promise<AuthResult> {
|
||||||
|
const res = await fetch(`${getPBUrl()}/api/collections/users/auth-with-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ identity, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err?.message || "登录失败");
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data?.token) throw new Error("登录响应异常");
|
||||||
|
return { token: data.token, record: data.record };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pbRegister(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
passwordConfirm: string
|
||||||
|
): Promise<AuthResult> {
|
||||||
|
const config = getPocketBaseConfig();
|
||||||
|
const res = await fetch(
|
||||||
|
`${getPBUrl()}/api/collections/${config.usersCollection}/records`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password, passwordConfirm }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err?.message || "注册失败");
|
||||||
|
}
|
||||||
|
return pbLogin(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pbSaveAuth(token: string, record: AuthUser): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem(PB_STORAGE_KEYS.token, token);
|
||||||
|
localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pbLogout(): Promise<void> {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.removeItem(PB_STORAGE_KEYS.token);
|
||||||
|
localStorage.removeItem(PB_STORAGE_KEYS.user);
|
||||||
|
localStorage.removeItem("join_pay_order");
|
||||||
|
try {
|
||||||
|
document.cookie = "join_pay_order=; path=/; max-age=0";
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent("auth:updated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredUser(): AuthUser | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PB_STORAGE_KEYS.user);
|
||||||
|
if (!raw) return null;
|
||||||
|
const user = JSON.parse(raw);
|
||||||
|
if (!user?.id || !user?.email) return null;
|
||||||
|
return user;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredToken(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return localStorage.getItem(PB_STORAGE_KEYS.token);
|
||||||
|
}
|
||||||
2
app/lib/pocketbase/config.ts
Normal file
2
app/lib/pocketbase/config.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { getPocketBaseConfig } from "@/config/services.config";
|
||||||
|
export type { PocketBaseConfig } from "@/config/services.config";
|
||||||
4
app/lib/pocketbase/constants.ts
Normal file
4
app/lib/pocketbase/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const PB_STORAGE_KEYS = {
|
||||||
|
token: "pb_token",
|
||||||
|
user: "pb_user",
|
||||||
|
} as const;
|
||||||
11
app/lib/pocketbase/index.ts
Normal file
11
app/lib/pocketbase/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export {
|
||||||
|
pbLogin,
|
||||||
|
pbRegister,
|
||||||
|
pbSaveAuth,
|
||||||
|
pbLogout,
|
||||||
|
getStoredUser,
|
||||||
|
getStoredToken,
|
||||||
|
} from "./auth";
|
||||||
|
export type { AuthUser, AuthResult } from "./auth";
|
||||||
|
export { PB_STORAGE_KEYS } from "./constants";
|
||||||
|
export { getPocketBaseConfig } from "./config";
|
||||||
92
app/lib/salonApi.ts
Normal file
92
app/lib/salonApi.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { getPaymentConfig } from "@/config/services.config";
|
||||||
|
|
||||||
|
function isPrivateHost(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;
|
||||||
|
return Number(match[1]) >= 16 && Number(match[1]) <= 31;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestHostname(request: NextRequest): string {
|
||||||
|
const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || "";
|
||||||
|
return host.split(",")[0].trim().split(":")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与 cnomadcna 一致:多 URL 回退,开发/内网优先本地 */
|
||||||
|
function getApiCandidates(request: NextRequest): string[] {
|
||||||
|
const config = getPaymentConfig();
|
||||||
|
const hostname = getRequestHostname(request);
|
||||||
|
const port = process.env.PAYJSAPI_PORT || "8007";
|
||||||
|
const localUrl = `http://${hostname || "127.0.0.1"}:${port}`;
|
||||||
|
const configuredUrl = config.apiUrl.replace(/\/$/, "");
|
||||||
|
|
||||||
|
const preferLocal = process.env.NODE_ENV === "development" || isPrivateHost(hostname);
|
||||||
|
const urls = preferLocal ? [localUrl, configuredUrl] : [configuredUrl, localUrl];
|
||||||
|
return urls.filter((u, i) => u && urls.indexOf(u) === i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
|
export async function postSalonApi(
|
||||||
|
request: NextRequest,
|
||||||
|
path: string,
|
||||||
|
body: unknown
|
||||||
|
): Promise<{ status: number; data: unknown }> {
|
||||||
|
const candidates = getApiCandidates(request);
|
||||||
|
if (!candidates.length) throw new Error("PAYMENT_API_URL 未配置");
|
||||||
|
|
||||||
|
let lastError: unknown = null;
|
||||||
|
for (const apiUrl of candidates) {
|
||||||
|
const url = `${apiUrl}${path}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: "no-store",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok || (res.status !== 404 && res.status < 500)) {
|
||||||
|
return { status: res.status, data };
|
||||||
|
}
|
||||||
|
lastError = data?.detail || data?.error || `HTTP ${res.status}`;
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error("payjsapi 请求失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSalonApi(
|
||||||
|
request: NextRequest,
|
||||||
|
path: string
|
||||||
|
): Promise<{ status: number; data: unknown }> {
|
||||||
|
const candidates = getApiCandidates(request);
|
||||||
|
if (!candidates.length) throw new Error("PAYMENT_API_URL 未配置");
|
||||||
|
|
||||||
|
let lastError: unknown = null;
|
||||||
|
for (const apiUrl of candidates) {
|
||||||
|
const url = `${apiUrl}${path}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { cache: "no-store", signal: controller.signal });
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) return { status: res.status, data };
|
||||||
|
lastError = data?.detail || data?.error || `HTTP ${res.status}`;
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error("payjsapi 请求失败");
|
||||||
|
}
|
||||||
34
app/page.tsx
Normal file
34
app/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import HeroSection from "./components/HeroSection";
|
||||||
|
import Footer from "./components/Footer";
|
||||||
|
import { COMMUNITY_STATS } from "@/config/home.config";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||||
|
<HeroSection />
|
||||||
|
|
||||||
|
<div className="max-w-[1400px] mx-auto px-5 sm:px-10 py-8">
|
||||||
|
<Link href="/join" className="right-item block max-w-md">
|
||||||
|
<div className="right-item-header">加入社区</div>
|
||||||
|
<div className="flex flex-col items-center justify-center text-center p-2 sm:p-5 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
|
||||||
|
<span className="text-2xl sm:text-5xl mb-1 sm:mb-3">💬</span>
|
||||||
|
<p className="text-[10px] sm:text-sm text-gray-600 dark:text-gray-400 mb-0.5">
|
||||||
|
加入数字游民社区,结识志同道合的伙伴
|
||||||
|
</p>
|
||||||
|
<p className="text-[9px] sm:text-xs text-gray-400 dark:text-gray-500 mb-1.5 sm:mb-4">
|
||||||
|
{COMMUNITY_STATS.onlineMembers} 人在线
|
||||||
|
</p>
|
||||||
|
<span className="bg-[#ff4d4f] text-white px-2.5 sm:px-6 py-1 sm:py-2 rounded-lg text-[10px] sm:text-sm font-semibold whitespace-nowrap">
|
||||||
|
加入社区 →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
config/domain.config.ts
Normal file
23
config/domain.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 域名与 payjsapi 地址配置
|
||||||
|
* 生产环境必须设置 PAYMENT_API_URL 或 ROOT_DOMAIN,否则无法连接 payjsapi
|
||||||
|
*/
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
export const ROOT_DOMAIN =
|
||||||
|
process.env.ROOT_DOMAIN || process.env.NEXT_PUBLIC_ROOT_DOMAIN || "hackrobot.cn";
|
||||||
|
|
||||||
|
/** 支付 API 默认地址。生产环境使用 api 子域,避免 127.0.0.1 导致连接失败 */
|
||||||
|
export const DEFAULT_PAYMENT_API_URL = isDev
|
||||||
|
? "http://127.0.0.1:8007"
|
||||||
|
: `https://api.${ROOT_DOMAIN}`;
|
||||||
|
|
||||||
|
/** 若 PAYMENT_API_URL 指向 localhost,生产环境自动使用 api 子域 */
|
||||||
|
export function resolvePaymentApiUrl(): string {
|
||||||
|
const url = process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL;
|
||||||
|
if (!isDev && /^(https?:\/\/)?(127\.0\.0\.1|localhost)(:\d+)?(\/|$)/i.test(url)) {
|
||||||
|
return `https://api.${ROOT_DOMAIN}`;
|
||||||
|
}
|
||||||
|
return url.replace(/\/$/, "");
|
||||||
|
}
|
||||||
43
config/home.config.ts
Normal file
43
config/home.config.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export const HOME_STATS = {
|
||||||
|
cities: "18",
|
||||||
|
nomads: "5,000+",
|
||||||
|
meetups: "8+",
|
||||||
|
homeMeetups: "50+",
|
||||||
|
cost: "¥3,000",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COMMUNITY_STATS = {
|
||||||
|
onlineMembers: "3,200+",
|
||||||
|
messagesPerMonth: "3,200+",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MEMBER_PHOTOS = [
|
||||||
|
{ name: "林晓", photo: "https://i.pravatar.cc/150?img=5" },
|
||||||
|
{ name: "张浩", photo: "https://i.pravatar.cc/150?img=3" },
|
||||||
|
{ name: "陈悦", photo: "https://i.pravatar.cc/150?img=9" },
|
||||||
|
{ name: "周杰", photo: "https://i.pravatar.cc/150?img=7" },
|
||||||
|
{ name: "吴婷", photo: "https://i.pravatar.cc/150?img=1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MEETUPS = [
|
||||||
|
{ city: "大理", emoji: "🏯", date: "3月9日", count: 12 },
|
||||||
|
{ city: "成都", emoji: "🐼", date: "3月12日", count: 8 },
|
||||||
|
{ city: "深圳", emoji: "🌃", date: "3月14日", count: 15 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ROUTES = [
|
||||||
|
{
|
||||||
|
titleZh: "云南慢生活线",
|
||||||
|
titleEn: "Yunnan Slow Life",
|
||||||
|
citiesZh: ["昆明", "大理", "丽江"],
|
||||||
|
citiesEn: ["Kunming", "Dali", "Lijiang"],
|
||||||
|
emojis: ["🌸", "🏯", "🏔️"],
|
||||||
|
durationZh: "3-6个月",
|
||||||
|
durationEn: "3-6 months",
|
||||||
|
costZh: "¥3,200/月起",
|
||||||
|
costEn: "From ¥3,200/month",
|
||||||
|
descZh: "从春城昆明出发,感受最纯粹的慢生活",
|
||||||
|
descEn: "From Kunming, experience the purest slow life",
|
||||||
|
gradient: "from-purple-500 to-pink-500",
|
||||||
|
},
|
||||||
|
];
|
||||||
66
config/services.config.ts
Normal file
66
config/services.config.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* salon 服务配置 - PocketBase、支付
|
||||||
|
* 使用 site_id=salon,支付调用 payjsapi /salon/*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolvePaymentApiUrl } from "./domain.config";
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
const PAYJSAPI_PORT = process.env.PAYJSAPI_PORT || "8007";
|
||||||
|
|
||||||
|
export const SITE_ID = "salon";
|
||||||
|
|
||||||
|
export const MEETUP_JOIN_AMOUNT = 8800;
|
||||||
|
|
||||||
|
export function getApiUrlFromRequest(hostHeader: string | null): string | null {
|
||||||
|
if (!hostHeader) return null;
|
||||||
|
if (!isDev) return resolvePaymentApiUrl();
|
||||||
|
const hostname = hostHeader.split(":")[0];
|
||||||
|
return `http://${hostname}:${PAYJSAPI_PORT}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PocketBaseConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
url: string;
|
||||||
|
usersCollection: string;
|
||||||
|
joinCollection: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
apiUrl: string;
|
||||||
|
provider: "zpay" | "xorpay";
|
||||||
|
defaultAmount: number;
|
||||||
|
defaultType: string;
|
||||||
|
zpaySubmitUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPocketBaseConfig(): PocketBaseConfig {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
url:
|
||||||
|
process.env.NEXT_PUBLIC_POCKETBASE_URL ||
|
||||||
|
process.env.POCKETBASE_URL ||
|
||||||
|
"http://127.0.0.1:8090",
|
||||||
|
usersCollection: "users",
|
||||||
|
joinCollection: "solanRed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaymentConfig(): PaymentConfig {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
apiUrl: resolvePaymentApiUrl(),
|
||||||
|
provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay",
|
||||||
|
defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 8800,
|
||||||
|
defaultType: "salon",
|
||||||
|
zpaySubmitUrl: process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJoinPaymentConfig(): { amount: number; type: string } {
|
||||||
|
return {
|
||||||
|
amount: Number(process.env.PAYMENT_JOIN_AMOUNT) || MEETUP_JOIN_AMOUNT,
|
||||||
|
type: "salon",
|
||||||
|
};
|
||||||
|
}
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6600
package-lock.json
generated
Normal file
6600
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "salon",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3002 -H 0.0.0.0",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3002",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "16.1.6",
|
||||||
|
"pocketbase": "^0.26.8",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.25.0+sha1.2cfb3ab644446565c127f58165cc76368c9c920b"
|
||||||
|
}
|
||||||
4046
pnpm-lock.yaml
generated
Normal file
4046
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
41
tsconfig.json
Normal file
41
tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user