'init'
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user