Add avatar upload, harden payments, and ebook checkout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-30 19:55:05 -05:00
parent f215b684f6
commit 8b80d18006
21 changed files with 509 additions and 78 deletions

View File

@ -2,7 +2,7 @@
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import api, auth, community, media, meetup_auth, payment, platform, social
from app.routers import api, auth, community, ebook, media, meetup_auth, payment, platform, social
app = FastAPI(
title="nomadro API",
@ -24,6 +24,7 @@ app.include_router(meetup_auth.router, prefix="/api/v1")
app.include_router(social.router, prefix="/api/v1")
app.include_router(community.router, prefix="/api/v1")
app.include_router(payment.router, prefix="/api/v1")
app.include_router(ebook.router, prefix="/api/v1")
app.include_router(platform.router, prefix="/api/v1")
app.include_router(media.router, prefix="/api/v1")

View File

@ -1,4 +1,5 @@
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel
import httpx
@ -10,6 +11,7 @@ from app.schemas import (
from app.services.auth import (
get_favorites, get_plan, get_user_by_token,
login_demo, login_google, login_user, register_user, save_plan, toggle_favorite,
update_profile,
)
from app.services.pocketbase import pb
@ -41,6 +43,26 @@ async def me(authorization: str | None = Header(None)):
return UserProfile(**user)
class ProfileUpdateBody(BaseModel):
name: str | None = None
avatar: str | None = None
@router.patch("/me", response_model=UserProfile)
async def patch_me(body: ProfileUpdateBody, authorization: str | None = Header(None)):
token = _extract_token(authorization)
if not get_user_by_token(token):
raise HTTPException(401, "未登录")
# Empty patch → return current profile (used by refreshUser)
if body.name is None and body.avatar is None:
user = get_user_by_token(token)
return UserProfile(**user)
updated = update_profile(token, name=body.name, avatar=body.avatar)
if not updated:
raise HTTPException(400, "更新失败")
return UserProfile(**updated)
@router.get("/demo", response_model=AuthResponse)
async def demo_login():
"""一键体验演示账号"""

View File

@ -0,0 +1,45 @@
"""Ebook purchase entitlements + download links (FastAPI only)."""
from __future__ import annotations
from fastapi import APIRouter, Header, HTTPException
from app.services.auth import get_user_by_token
from app.services import social_store
from app.services.payment_service import EBOOK_EPUB_URL, EBOOK_PDF_URL, PAYMENT_EBOOK_AMOUNT
router = APIRouter(prefix="/ebook", tags=["ebook"])
def _user(authorization: str | None) -> dict:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "请先登录")
user = get_user_by_token(authorization[7:])
if not user:
raise HTTPException(401, "请先登录")
return user
@router.get("/status")
async def ebook_status(authorization: str | None = Header(None)):
user = _user(authorization)
owned = social_store.has_ebook(user["id"])
return {
"owned": owned,
"amount_fen": PAYMENT_EBOOK_AMOUNT,
"amount_yuan": f"{PAYMENT_EBOOK_AMOUNT / 100:.2f}",
}
@router.get("/downloads")
async def ebook_downloads(authorization: str | None = Header(None)):
user = _user(authorization)
if not social_store.has_ebook(user["id"]):
raise HTTPException(403, "尚未购买电子书下载版")
files = []
if EBOOK_PDF_URL:
files.append({"format": "pdf", "label": "PDF", "url": EBOOK_PDF_URL})
if EBOOK_EPUB_URL:
files.append({"format": "epub", "label": "EPUB", "url": EBOOK_EPUB_URL})
if not files:
files.append({"format": "online", "label": "在线阅读", "url": "/book/read"})
return {"owned": True, "files": files}

View File

@ -9,6 +9,7 @@ from app.services import social_store
from app.services.payment_service import (
DEV_AUTO_PAY,
PAYMENT_DEFAULT_AMOUNT,
PAYMENT_EBOOK_AMOUNT,
PAYMENT_JOIN_AMOUNT,
create_pay_redirect,
parse_notify_order_id,
@ -44,11 +45,20 @@ async def create_payment(
authorization: str | None = Header(None),
):
user = _token_user(authorization)
amount = PAYMENT_JOIN_AMOUNT if body.pay_type == "join" else PAYMENT_DEFAULT_AMOUNT
if body.pay_type == "ebook":
amount = PAYMENT_EBOOK_AMOUNT
product_name = "nomadro电子书下载版"
elif body.pay_type in ("join", "vip"):
amount = PAYMENT_JOIN_AMOUNT
product_name = "nomadro会员"
else:
amount = PAYMENT_DEFAULT_AMOUNT
product_name = "nomadro订单"
order = social_store.create_order(user["id"], body.pay_type, amount, dev_auto_pay=DEV_AUTO_PAY)
return_url = body.return_url or "https://nomadweb.nomadro.com/join/paid"
ua = request.headers.get("user-agent", "")
device = "wechat" if "micromessenger" in ua.lower() else "pc"
try:
redirect = await create_pay_redirect(
order["id"],
amount,
@ -58,7 +68,10 @@ async def create_payment(
device=device,
user_agent=ua,
client_ip=_client_ip(request),
product_name=product_name,
)
except ValueError as exc:
raise HTTPException(503, str(exc)) from exc
return PayCreateResponse(
order_id=order["id"],
amount=amount,
@ -100,7 +113,7 @@ async def complete_order(
social_store.mark_order_paid(order_id)
else:
raise HTTPException(402, "订单未支付")
return {"success": True, "vip": social_store.is_vip(user["id"])}
return {"success": True, "vip": social_store.is_vip(user["id"]), "ebook": social_store.has_ebook(user["id"])}
async def _notify_handler(provider: str, request: Request) -> PlainTextResponse:

View File

@ -341,6 +341,35 @@ def save_plan(token: str, items: list, meta: dict, updated_at: int | None = None
return get_plan(token)
def update_profile(token: str, *, name: str | None = None, avatar: str | None = None) -> dict | None:
uid = _token_user_id(token)
if not uid:
return None
if use_pb():
user = _get_account_by_id(uid)
if not user:
return None
patch: dict[str, Any] = {}
if name is not None and name.strip():
patch["name"] = name.strip()[:80]
if avatar is not None and avatar.strip():
patch["avatar"] = avatar.strip()[:500]
if patch:
safe_update("nomad_accounts", user["pb_id"], patch)
user = {**user, **patch}
return _user_profile(user)
for _email, user in list(_users.items()):
if user.get("id") == uid:
if name is not None and name.strip():
user["name"] = name.strip()[:80]
if avatar is not None and avatar.strip():
user["avatar"] = avatar.strip()[:500]
_persist_json()
return _user_profile(user)
return None
def ensure_user_by_email(email: str, name: str = "") -> dict[str, Any]:
"""Create or resume a session for meetup checkout flows (NomadCNA ensure-user)."""
display = (name or email.split("@")[0]).strip() or "游民"

View File

@ -37,8 +37,10 @@ class ZPayProvider:
name = "zpay"
def __init__(self) -> None:
self.pid = os.getenv("ZPAY_PID", "2025121809351743")
self.key = os.getenv("ZPAY_KEY", "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89")
self.pid = (os.getenv("ZPAY_PID") or "").strip()
self.key = (os.getenv("ZPAY_KEY") or "").strip()
if not self.pid or not self.key:
raise RuntimeError("ZPAY_PID / ZPAY_KEY 未配置,无法发起支付")
self.submit_url = os.getenv("ZPAY_SUBMIT_URL", "https://zpayz.cn/submit.php")
self.mapi_url = os.getenv("ZPAY_MAPI_URL", "https://zpayz.cn/mapi.php")
self.query_url = os.getenv("ZPAY_QUERY_URL", "https://zpayz.cn/api.php")
@ -179,8 +181,10 @@ class XorPayProvider:
name = "xorpay"
def __init__(self) -> None:
self.aid = os.getenv("XORPAY_AID", "8220")
self.secret = os.getenv("XORPAY_SECRET", "afcacd99570945f88de62624aaa3578e")
self.aid = (os.getenv("XORPAY_AID") or "").strip()
self.secret = (os.getenv("XORPAY_SECRET") or "").strip()
if not self.aid or not self.secret:
raise RuntimeError("XORPAY_AID / XORPAY_SECRET 未配置,无法发起支付")
self.cashier_url = os.getenv("XORPAY_CASHIER_URL", "https://xorpay.com/api/cashier").rstrip("/")
self.query_url = os.getenv("XORPAY_QUERY_URL", "https://xorpay.com/api/query2").rstrip("/")

View File

@ -10,8 +10,11 @@ from app.services.payment_providers import XorPayProvider, ZPayProvider, provide
DEV_AUTO_PAY = os.getenv("DEV_AUTO_PAY", "false").lower() in ("1", "true", "yes")
PAYMENT_JOIN_AMOUNT = int(os.getenv("PAYMENT_JOIN_AMOUNT", "10000")) # fen (100 yuan)
PAYMENT_DEFAULT_AMOUNT = int(os.getenv("PAYMENT_DEFAULT_AMOUNT", "10000"))
PAYMENT_EBOOK_AMOUNT = int(os.getenv("PAYMENT_EBOOK_AMOUNT", "2900")) # fen (29 yuan)
SITE_BASE = os.getenv("SITE_BASE_URL", "https://nomadweb.nomadro.com").rstrip("/")
PAY_NOTIFY_URL = os.getenv("PAY_NOTIFY_URL", f"{SITE_BASE}/api/v1/pay/zpay_notify")
EBOOK_PDF_URL = os.getenv("EBOOK_PDF_URL", "")
EBOOK_EPUB_URL = os.getenv("EBOOK_EPUB_URL", "")
def amount_yuan(amount_fen: int) -> str:
@ -34,14 +37,18 @@ async def create_pay_redirect(
device: str = "pc",
user_agent: str = "",
client_ip: str = "",
product_name: str = "nomadro会员",
) -> str:
if DEV_AUTO_PAY:
sep = "&" if "?" in return_url else "?"
return f"{return_url}{sep}order_id={order_id}&status=paid"
try:
pay_provider = provider_for(device, provider or os.getenv("PAYMENT_PROVIDER", "zpay"), user_agent)
except RuntimeError as exc:
raise ValueError(str(exc)) from exc
yuan = amount_yuan(amount)
name = "nomadro会员"
name = product_name
notify = notify_url_for(pay_provider.name)
if isinstance(pay_provider, XorPayProvider):
@ -78,16 +85,32 @@ async def create_pay_redirect(
async def query_order_paid(order_id: str) -> bool:
zpay, xorpay = await ZPayProvider().query_order_status(order_id), await XorPayProvider().query_order_status(order_id)
return bool(zpay.get("paid") or xorpay.get("paid"))
if DEV_AUTO_PAY:
return True
paid = False
try:
zpay = await ZPayProvider().query_order_status(order_id)
paid = bool(zpay.get("paid"))
except RuntimeError:
pass
if paid:
return True
try:
xorpay = await XorPayProvider().query_order_status(order_id)
return bool(xorpay.get("paid"))
except RuntimeError:
return False
def verify_notify(provider: str, data: dict) -> bool:
if DEV_AUTO_PAY:
return True
try:
if provider == "xorpay":
return XorPayProvider().verify_notify(data)
return ZPayProvider().verify_notify(data)
except RuntimeError:
return False
def parse_notify_order_id(provider: str, data: dict) -> str:

View File

@ -590,6 +590,32 @@ def send_message(conv_id: str, user_id: str, body: str) -> dict | None:
return {**msg, "mine": True}
def grant_ebook(user_id: str) -> None:
if not user_id:
return
if use_pb():
existing = safe_first("ebook_entitlements", filter=f"userId={q(user_id)}")
if existing:
safe_update("ebook_entitlements", existing["id"], {"active": True})
else:
safe_create("ebook_entitlements", {"userId": user_id, "active": True, "product": "nomad-code"})
return
_jload()
_j.setdefault("ebook_entitlements", {})[user_id] = {"active": True, "product": "nomad-code", "updated_at": _now_iso()}
_jsave()
def has_ebook(user_id: str) -> bool:
if not user_id:
return False
if use_pb():
row = safe_first("ebook_entitlements", filter=f"userId={q(user_id)} && active=true")
return bool(row)
_jload()
ent = (_j.get("ebook_entitlements") or {}).get(user_id) or {}
return bool(ent.get("active"))
def create_order(user_id: str, pay_type: str, amount: int, dev_auto_pay: bool = False) -> dict:
order_id = f"{user_id}_{pay_type}_order_{int(time.time())}_{secrets.token_hex(2)}"
status = "paid" if dev_auto_pay else "pending"
@ -607,16 +633,15 @@ def create_order(user_id: str, pay_type: str, amount: int, dev_auto_pay: bool =
_j.setdefault("orders", {})[order_id] = {**order, "id": order_id, "createdAt": _now_iso()}
_jsave()
if dev_auto_pay:
ensure_membership(user_id)
_fulfill_order(user_id, pay_type)
return {"id": order_id, **order, "createdAt": _now_iso()}
def get_order(order_id: str) -> dict | None:
if use_pb():
row = safe_first("orders", filter=f"orderId={q(order_id)}")
return row
_jload()
return (_j.get("orders") or {}).get(order_id)
def _fulfill_order(user_id: str, pay_type: str) -> None:
if pay_type == "ebook":
grant_ebook(user_id)
else:
ensure_membership(user_id)
def mark_order_paid(order_id: str) -> dict | None:
@ -625,14 +650,14 @@ def mark_order_paid(order_id: str) -> dict | None:
if not row:
return None
safe_update("orders", row["id"], {"status": "paid"})
ensure_membership(row.get("userId", ""))
_fulfill_order(row.get("userId", ""), row.get("payType") or "join")
return {**row, "status": "paid"}
_jload()
order = (_j.get("orders") or {}).get(order_id)
if not order:
return None
order["status"] = "paid"
ensure_membership(order["userId"])
_fulfill_order(order.get("userId", ""), order.get("payType") or "join")
_jsave()
return order

View File

@ -221,6 +221,11 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = {
number("amount", only_int=True),
text("status", max=40),
],
"ebook_entitlements": [
text("userId", required=True, max=80),
boolean("active"),
text("product", max=80),
],
"feedback": [
text("type", max=40),
text("email", max=255),

View File

@ -3,7 +3,10 @@ DEV_AUTO_PAY=false
PAYMENT_PROVIDER=zpay
PAYMENT_JOIN_AMOUNT=10000
PAYMENT_DEFAULT_AMOUNT=10000
PAYMENT_EBOOK_AMOUNT=2900
SITE_BASE_URL=https://nomadweb.nomadro.com
EBOOK_PDF_URL=
EBOOK_EPUB_URL=
# PocketBase (host process :8090)
POCKETBASE_URL=http://127.0.0.1:8090

View File

@ -30,10 +30,10 @@
]
},
"checkout": {
"enabled": false,
"provider": "creem",
"buttonText": "Buy Now — $29",
"productName": "Download Edition (PDF & EPUB)"
"enabled": true,
"provider": "zpay",
"buttonText": "购买下载版 ¥29",
"productName": "游牧代码 · 下载版 (PDF & EPUB)"
}
},

View File

@ -0,0 +1,108 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import SiteShell from "@/components/SiteShell";
import { useAuth } from "@/lib/auth";
import { api } from "@/lib/api";
function BookThanksInner() {
const { token } = useAuth();
const router = useRouter();
const params = useSearchParams();
const [status, setStatus] = useState<"checking" | "ok" | "pending" | "need-login">("checking");
const [files, setFiles] = useState<{ format: string; label: string; url: string }[]>([]);
const [msg, setMsg] = useState("");
useEffect(() => {
if (!token) {
setStatus("need-login");
return;
}
const orderId = params.get("order_id") || localStorage.getItem("nomadro-ebook-order") || "";
let cancelled = false;
(async () => {
try {
if (orderId) {
await api.completePayment(token, orderId).catch(() => null);
}
for (let i = 0; i < 5; i++) {
if (cancelled) return;
const st = await api.ebookStatus(token);
if (st.owned) {
const dl = await api.ebookDownloads(token);
if (cancelled) return;
setFiles(dl.files || []);
setStatus("ok");
localStorage.removeItem("nomadro-ebook-order");
return;
}
await new Promise((r) => setTimeout(r, 1000));
}
if (!cancelled) {
setStatus("pending");
setMsg("支付确认中,请稍后刷新;或联系支持核对订单。");
}
} catch (e) {
if (!cancelled) {
setStatus("pending");
setMsg(e instanceof Error ? e.message : "无法确认购买状态");
}
}
})();
return () => {
cancelled = true;
};
}, [token, params]);
return (
<div className="container" style={{ padding: "48px 24px", maxWidth: 560 }}>
<nav className="detail-nav">
<Link href="/book">← 电子书</Link>
</nav>
<h1>下载版</h1>
{status === "need-login" && (
<p>
请先 <Link href="/login?next=/book/thanks">登录</Link> 查看下载。
</p>
)}
{status === "checking" && <p>正在确认订单…</p>}
{status === "pending" && (
<div>
<p>{msg}</p>
<button type="button" className="btn btn-primary" onClick={() => router.refresh()}>
刷新状态
</button>
</div>
)}
{status === "ok" && (
<div>
<p>购买成功,可下载:</p>
<ul className="ebook-download-list">
{files.map((f) => (
<li key={f.format}>
<a href={f.url} className="btn btn-primary btn-sm" target="_blank" rel="noreferrer">
{f.label}
</a>
</li>
))}
</ul>
<p style={{ marginTop: 16 }}>
<Link href="/book/read">继续在线阅读 →</Link>
</p>
</div>
)}
</div>
);
}
export default function BookThanksPage() {
return (
<SiteShell>
<Suspense fallback={<div className="container" style={{ padding: 48 }}>确认中…</div>}>
<BookThanksInner />
</Suspense>
</SiteShell>
);
}

View File

@ -2682,6 +2682,35 @@ img { max-width: 100%; display: block; }
border: var(--border-glass);
}
.profile-avatar-wrap {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.profile-avatar-img {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: var(--border-glass);
background: var(--bg-glass);
}
.profile-avatar-upload {
font-size: 0.75rem;
color: var(--accent-3);
cursor: pointer;
opacity: 0.9;
}
.profile-avatar-upload:hover {
opacity: 1;
text-decoration: underline;
}
.profile-info { flex: 1; }
.profile-info h1 {
font-family: var(--font-display);

View File

@ -34,7 +34,7 @@ function getBadges(stats: ProfileStats | null, trip: TripItem[], favCount: numbe
}
export default function ProfilePage() {
const { user, token, favorites, logout, planSyncStatus } = useAuth();
const { user, token, favorites, logout, planSyncStatus, refreshUser } = useAuth();
const router = useRouter();
const { toast } = useToast();
const [stats, setStats] = useState<ProfileStats | null>(null);
@ -42,6 +42,28 @@ export default function ProfilePage() {
const [trip, setTrip] = useState<TripItem[]>([]);
const [meta, setMeta] = useState<PlanMeta | null>(null);
const [loading, setLoading] = useState(true);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || !token) return;
if (file.size > 5 * 1024 * 1024) {
toast("图片请小于 5MB", "error");
return;
}
setUploadingAvatar(true);
try {
const uploaded = await api.uploadMedia(token, file, "avatar");
await api.updateProfile(token, { avatar: uploaded.url });
await refreshUser?.();
toast("头像已更新", "success");
} catch (err) {
toast(err instanceof Error ? err.message : "上传失败", "error");
} finally {
setUploadingAvatar(false);
}
};
const refreshPlan = () => {
setTrip(loadTrip<TripItem>());
@ -98,7 +120,24 @@ export default function ProfilePage() {
<SiteShell>
<div className="profile-page">
<div className="profile-header">
<div className="profile-avatar">{user.avatar}</div>
<div className="profile-avatar-wrap">
{user.avatar?.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={user.avatar} alt="" className="profile-avatar-img" />
) : (
<div className="profile-avatar">{user.avatar || "🧑‍💻"}</div>
)}
<label className="profile-avatar-upload">
{uploadingAvatar ? "上传中…" : "更换头像"}
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
hidden
disabled={uploadingAvatar}
onChange={onAvatarChange}
/>
</label>
</div>
<div className="profile-info">
<h1>{user.name}</h1>
<p>{user.email}</p>

View File

@ -3,6 +3,7 @@
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { ShareToX } from './ShareToX';
import { BuyEbook } from './BuyEbook';
import { getReadingProgress } from '@/lib/ebook/reading-progress';
import { getBookShareText, getReadShareUrl } from '@/lib/ebook/share-text';
import { trackEvent } from '@/lib/ebook/analytics';
@ -35,6 +36,7 @@ export function BookEnd({ book }: { book: Book }) {
? t('cover.shareDone', { tagline: book.tagline })
: t('cover.flipBack', { read: readCount, total: book.chapters.length })}
</p>
<BuyEbook compact />
<div className="book-end-actions">
<ShareToX text={shareText} url={getReadShareUrl()} label={t('reader.shareThoughts')} />
<button

View File

@ -1,50 +1,79 @@
'use client';
"use client";
import { useState } from 'react';
import { trackEvent } from '@/lib/ebook/analytics';
import { getFeatures } from '@/lib/ebook/features';
import { useT } from '@/components/ebook/LocaleProvider';
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { trackEvent } from "@/lib/ebook/analytics";
import { getFeatures } from "@/lib/ebook/features";
import { useT } from "@/components/ebook/LocaleProvider";
import { useAuth } from "@/lib/auth";
import { api } from "@/lib/api";
/** Buy download edition via FastAPI /pay/create (pay_type=ebook). */
export function BuyEbook({ compact = false }: { compact?: boolean }) {
const t = useT();
const { token } = useAuth();
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const productName = getFeatures().payment.checkout.productName;
const buttonText = getFeatures().payment.checkout.buttonText;
const [error, setError] = useState("");
const [owned, setOwned] = useState(false);
const features = getFeatures();
const enabled = features.payment?.checkout?.enabled !== false;
const productName = features.payment?.checkout?.productName || "Download Edition";
const buttonText = features.payment?.checkout?.buttonText || "购买下载版";
useEffect(() => {
if (!token) return;
api.ebookStatus(token).then((s) => setOwned(s.owned)).catch(() => {});
}, [token]);
if (!enabled) return null;
const buy = async () => {
setError('');
setBusy(true);
trackEvent('buy_click', { from: compact ? 'reader' : 'cover' });
try {
const res = await fetch('/api/creem/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = (await res.json()) as { ok?: boolean; checkoutUrl?: string; error?: string };
if (!res.ok || !data.checkoutUrl) {
throw new Error(data.error || t('payment.checkoutError'));
setError("");
if (!token) {
router.push("/login?next=/book");
return;
}
window.location.href = data.checkoutUrl;
if (owned) {
router.push("/book/thanks");
return;
}
setBusy(true);
trackEvent("buy_click", { from: compact ? "reader" : "cover" });
try {
const returnUrl = `${window.location.origin}/book/thanks`;
const res = await api.createPayment(token, returnUrl, "ebook");
if (res.order_id) {
localStorage.setItem("nomadro-ebook-order", res.order_id);
}
// DEV_AUTO_PAY or already-paid: confirm then go to thanks
if (res.status === "paid" && res.order_id) {
await api.completePayment(token, res.order_id).catch(() => null);
router.push(`/book/thanks?order_id=${encodeURIComponent(res.order_id)}`);
return;
}
if (!res.redirect_url) {
throw new Error(t("payment.checkoutFailed"));
}
window.location.href = res.redirect_url;
} catch (err) {
setError(err instanceof Error ? err.message : t('payment.checkoutFailed'));
setError(err instanceof Error ? err.message : t("payment.checkoutFailed"));
setBusy(false);
}
};
return (
<section className={`buy-ebook ${compact ? 'is-compact' : ''}`}>
<h2 className="buy-ebook-title">{t('payment.buyGet', { name: productName })}</h2>
<p className="buy-ebook-sub">{t('payment.buySub')}</p>
<button
type="button"
className="buy-ebook-btn"
disabled={busy}
onClick={buy}
>
{busy ? t('payment.openingCheckout') : buttonText}
<section className={`buy-ebook${compact ? " is-compact" : ""}`}>
<h2 className="buy-ebook-title">{t("payment.buyGet", { name: productName })}</h2>
<p className="buy-ebook-sub">{t("payment.buySub")}</p>
<button type="button" className="buy-ebook-btn" disabled={busy} onClick={buy}>
{busy ? t("payment.openingCheckout") : owned ? "已购买 · 去下载" : buttonText}
</button>
{error ? <p className="buy-ebook-error">{error}</p> : null}
<p className="buy-ebook-hint">
也可免费 <Link href="/book/read">在线阅读</Link>
</p>
</section>
);
}

View File

@ -10,6 +10,7 @@ import { ScrollReveal } from "./ScrollReveal";
import { SkillRadar } from "./SkillRadar";
import { CoverChapterGrid } from "./CoverChapterGrid";
import { CoverStickyCta, SiteFooter } from "./GrowthKit";
import { BuyEbook } from "./BuyEbook";
import { AdSlot } from "./AdSlot";
import { getBookShareText, getBookShareUrl } from "@/lib/ebook/share-text";
import { trackEvent } from "@/lib/ebook/analytics";
@ -141,6 +142,10 @@ export function CoverPage({
</ScrollReveal>
)}
<ScrollReveal delay={320}>
<BuyEbook />
</ScrollReveal>
<SiteFooter
bookTitle={book.title}
contactEmail={site.brand.contactEmail}

View File

@ -137,6 +137,39 @@ export const api = {
register: (email: string, password: string, name: string) =>
fetchAPI<AuthResponse>("/auth/register", { method: "POST", body: JSON.stringify({ email, password, name }) }),
demoLogin: () => fetchAPI<AuthResponse>("/auth/demo"),
updateProfile: (token: string, body: { name?: string; avatar?: string }) =>
fetchAPI<{ id: string; email: string; name: string; avatar: string }>("/auth/me", {
method: "PATCH",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
}),
getMe: (token: string) =>
fetchAPI<{ id: string; email: string; name: string; avatar: string }>("/auth/me", {
headers: { Authorization: `Bearer ${token}` },
}),
uploadMedia: async (token: string, file: File, purpose = "avatar") => {
const form = new FormData();
form.append("file", file);
form.append("purpose", purpose);
const res = await fetch(`${API_BASE}/upload-media?purpose=${encodeURIComponent(purpose)}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { detail?: string }).detail || "上传失败");
}
return res.json() as Promise<{ success: boolean; url: string; object_key: string }>;
},
ebookStatus: (token: string) =>
fetchAPI<{ owned: boolean; amount_fen: number; amount_yuan: string }>("/ebook/status", {
headers: { Authorization: `Bearer ${token}` },
}),
ebookDownloads: (token: string) =>
fetchAPI<{ owned: boolean; files: { format: string; label: string; url: string }[] }>("/ebook/downloads", {
headers: { Authorization: `Bearer ${token}` },
}),
getFavorites: (token: string) =>
fetchAPI<{ slugs: string[] }>("/auth/favorites", { headers: { Authorization: `Bearer ${token}` } }),
toggleFavorite: (slug: string, token: string) =>
@ -236,7 +269,7 @@ export const api = {
body: JSON.stringify({ pay_type: payType, return_url: returnUrl }),
}),
completePayment: (token: string, orderId: string) =>
fetchAPI<{ success: boolean; vip: boolean }>(`/pay/complete?order_id=${encodeURIComponent(orderId)}`, {
fetchAPI<{ success: boolean; vip: boolean; ebook?: boolean }>(`/pay/complete?order_id=${encodeURIComponent(orderId)}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
}),

View File

@ -14,6 +14,7 @@ interface AuthCtx {
register: (email: string, password: string, name: string) => Promise<boolean>;
demoLogin: () => Promise<boolean>;
logout: () => void;
refreshUser: () => Promise<void>;
toggleFavorite: (slug: string) => Promise<void>;
isFavorite: (slug: string) => boolean;
}
@ -96,6 +97,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
localStorage.removeItem("nomad-user");
};
const refreshUser = async () => {
const t = token || localStorage.getItem("nomad-token");
if (!t) return;
try {
const me = await api.getMe(t);
setUser(me as UserProfile);
localStorage.setItem("nomad-user", JSON.stringify(me));
} catch {
try {
const saved = localStorage.getItem("nomad-user");
if (saved) setUser(JSON.parse(saved));
} catch { /* ignore */ }
}
};
const toggleFavorite = async (slug: string) => {
if (!token) return;
try {
@ -106,7 +122,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return (
<AuthContext.Provider value={{
user, token, favorites, planSyncStatus, login, register, demoLogin, logout,
user, token, favorites, planSyncStatus, login, register, demoLogin, logout, refreshUser,
toggleFavorite, isFavorite: (slug) => favorites.includes(slug),
}}>
{children}

View File

@ -23,10 +23,10 @@ const fallback: FeatureConfig = {
],
},
checkout: {
enabled: false,
provider: "creem",
buttonText: "Buy Now - $29",
productName: "Download Edition (PDF & EPUB)",
enabled: true,
provider: "zpay",
buttonText: "购买下载版 ¥29",
productName: "游牧代码 · 下载版 (PDF & EPUB)",
},
},
feedback: {

View File

@ -89,10 +89,10 @@ const fallback: FeatureConfig = {
],
},
checkout: {
enabled: false,
provider: 'creem',
buttonText: 'Buy Now - $29',
productName: 'Download Edition (PDF & EPUB)',
enabled: true,
provider: 'zpay',
buttonText: '购买下载版 ¥29',
productName: '游牧代码 · 下载版 (PDF & EPUB)',
},
},
feedback: {