This commit is contained in:
eric
2026-03-15 11:19:52 -05:00
parent 25d6dd6bf7
commit ae17a84ca0
50 changed files with 14087 additions and 1 deletions

View 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
View 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 });
}
}

View 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
View 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 }
);
}
}

View 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
View 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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[
c
] || c)
);
const imgSrc = String(resData.img).replace(
/[&<>"']/g,
(c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[
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 }
);
}
}

View 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 });
}

View 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 }
);
}
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function POST() {
return NextResponse.json({ ok: true });
}

View 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 });
}
}

View 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 });
}
}

View 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);
}

View 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);

View 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);
}

View 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);

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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/PNGMP4 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
export { redirectToPay, getDeviceFromEnv } from "./client";
export { getPayEnv } from "../payEnv";
export { usePayStatusPoll } from "./usePayStatusPoll";
export type { PayChannel } from "./client";

View 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;
}

View 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;
}

View 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);
}

View File

@@ -0,0 +1,2 @@
export { getPocketBaseConfig } from "@/config/services.config";
export type { PocketBaseConfig } from "@/config/services.config";

View File

@@ -0,0 +1,4 @@
export const PB_STORAGE_KEYS = {
token: "pb_token",
user: "pb_user",
} as const;

View 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
View 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
View 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>
);
}