From 8b80d18006602bcab5cb57c87fd6ce124e43fd90 Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 30 Aug 2026 19:55:05 -0500 Subject: [PATCH] Add avatar upload, harden payments, and ebook checkout. Co-authored-by: Cursor --- backend/app/main.py | 3 +- backend/app/routers/auth.py | 22 ++++ backend/app/routers/ebook.py | 45 ++++++++ backend/app/routers/payment.py | 37 ++++--- backend/app/services/auth.py | 29 ++++++ backend/app/services/payment_providers.py | 12 ++- backend/app/services/payment_service.py | 37 +++++-- backend/app/services/social_store.py | 43 ++++++-- backend/scripts/init_pb_collections.py | 5 + deploy/nomadro-api.env.example | 3 + frontend/content/book/features.json | 8 +- frontend/src/app/book/thanks/page.tsx | 108 ++++++++++++++++++++ frontend/src/app/globals.css | 29 ++++++ frontend/src/app/profile/page.tsx | 43 +++++++- frontend/src/components/ebook/BookEnd.tsx | 2 + frontend/src/components/ebook/BuyEbook.tsx | 87 ++++++++++------ frontend/src/components/ebook/CoverPage.tsx | 5 + frontend/src/lib/api.ts | 35 ++++++- frontend/src/lib/auth.tsx | 18 +++- frontend/src/lib/ebook/features.server.ts | 8 +- frontend/src/lib/ebook/features.ts | 8 +- 21 files changed, 509 insertions(+), 78 deletions(-) create mode 100644 backend/app/routers/ebook.py create mode 100644 frontend/src/app/book/thanks/page.tsx diff --git a/backend/app/main.py b/backend/app/main.py index 7802bd0..1f09dd9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 3dca497..483c22c 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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(): """一键体验演示账号""" diff --git a/backend/app/routers/ebook.py b/backend/app/routers/ebook.py new file mode 100644 index 0000000..b8ad957 --- /dev/null +++ b/backend/app/routers/ebook.py @@ -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} diff --git a/backend/app/routers/payment.py b/backend/app/routers/payment.py index 95faaaa..5cf834a 100644 --- a/backend/app/routers/payment.py +++ b/backend/app/routers/payment.py @@ -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,21 +45,33 @@ 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" - redirect = await create_pay_redirect( - order["id"], - amount, - return_url, - body.provider or "", - channel="wxpay" if device == "wechat" else "alipay", - device=device, - user_agent=ua, - client_ip=_client_ip(request), - ) + try: + redirect = await create_pay_redirect( + order["id"], + amount, + return_url, + body.provider or "", + channel="wxpay" if device == "wechat" else "alipay", + 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: diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 0db40e9..8530249 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -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 "游民" diff --git a/backend/app/services/payment_providers.py b/backend/app/services/payment_providers.py index 50459ef..184cb05 100644 --- a/backend/app/services/payment_providers.py +++ b/backend/app/services/payment_providers.py @@ -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("/") diff --git a/backend/app/services/payment_service.py b/backend/app/services/payment_service.py index 288aba2..5be95ba 100644 --- a/backend/app/services/payment_service.py +++ b/backend/app/services/payment_service.py @@ -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" - pay_provider = provider_for(device, provider or os.getenv("PAYMENT_PROVIDER", "zpay"), user_agent) + 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 - if provider == "xorpay": - return XorPayProvider().verify_notify(data) - return ZPayProvider().verify_notify(data) + 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: diff --git a/backend/app/services/social_store.py b/backend/app/services/social_store.py index ea55e46..8938aac 100644 --- a/backend/app/services/social_store.py +++ b/backend/app/services/social_store.py @@ -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 diff --git a/backend/scripts/init_pb_collections.py b/backend/scripts/init_pb_collections.py index 16ca6bf..46c4402 100644 --- a/backend/scripts/init_pb_collections.py +++ b/backend/scripts/init_pb_collections.py @@ -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), diff --git a/deploy/nomadro-api.env.example b/deploy/nomadro-api.env.example index 0488ab6..a4f79c9 100644 --- a/deploy/nomadro-api.env.example +++ b/deploy/nomadro-api.env.example @@ -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 diff --git a/frontend/content/book/features.json b/frontend/content/book/features.json index 7211bb3..3586fad 100644 --- a/frontend/content/book/features.json +++ b/frontend/content/book/features.json @@ -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)" } }, diff --git a/frontend/src/app/book/thanks/page.tsx b/frontend/src/app/book/thanks/page.tsx new file mode 100644 index 0000000..a7c83e7 --- /dev/null +++ b/frontend/src/app/book/thanks/page.tsx @@ -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 ( +
+ +

下载版

+ {status === "need-login" && ( +

+ 请先 登录 查看下载。 +

+ )} + {status === "checking" &&

正在确认订单…

} + {status === "pending" && ( +
+

{msg}

+ +
+ )} + {status === "ok" && ( +
+

购买成功,可下载:

+ +

+ 继续在线阅读 → +

+
+ )} +
+ ); +} + +export default function BookThanksPage() { + return ( + + 确认中…}> + + + + ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index ca83c3d..a508b06 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 300246a..2cd919c 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -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(null); @@ -42,6 +42,28 @@ export default function ProfilePage() { const [trip, setTrip] = useState([]); const [meta, setMeta] = useState(null); const [loading, setLoading] = useState(true); + const [uploadingAvatar, setUploadingAvatar] = useState(false); + + const onAvatarChange = async (e: React.ChangeEvent) => { + 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()); @@ -98,7 +120,24 @@ export default function ProfilePage() {
-
{user.avatar}
+
+ {user.avatar?.startsWith("http") ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
{user.avatar || "🧑‍💻"}
+ )} + +

{user.name}

{user.email}

diff --git a/frontend/src/components/ebook/BookEnd.tsx b/frontend/src/components/ebook/BookEnd.tsx index f4415cf..da838f9 100644 --- a/frontend/src/components/ebook/BookEnd.tsx +++ b/frontend/src/components/ebook/BookEnd.tsx @@ -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 })}

+
{error ?

{error}

: null} +

+ 也可免费 在线阅读 +

); } diff --git a/frontend/src/components/ebook/CoverPage.tsx b/frontend/src/components/ebook/CoverPage.tsx index a3a39d0..6d76bab 100644 --- a/frontend/src/components/ebook/CoverPage.tsx +++ b/frontend/src/components/ebook/CoverPage.tsx @@ -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({ )} + + + + fetchAPI("/auth/register", { method: "POST", body: JSON.stringify({ email, password, name }) }), demoLogin: () => fetchAPI("/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}` }, }), diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx index 4a21654..82eaba9 100644 --- a/frontend/src/lib/auth.tsx +++ b/frontend/src/lib/auth.tsx @@ -14,6 +14,7 @@ interface AuthCtx { register: (email: string, password: string, name: string) => Promise; demoLogin: () => Promise; logout: () => void; + refreshUser: () => Promise; toggleFavorite: (slug: string) => Promise; 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 ( favorites.includes(slug), }}> {children} diff --git a/frontend/src/lib/ebook/features.server.ts b/frontend/src/lib/ebook/features.server.ts index 9b0bf42..e642415 100644 --- a/frontend/src/lib/ebook/features.server.ts +++ b/frontend/src/lib/ebook/features.server.ts @@ -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: { diff --git a/frontend/src/lib/ebook/features.ts b/frontend/src/lib/ebook/features.ts index f77dfee..494400c 100644 --- a/frontend/src/lib/ebook/features.ts +++ b/frontend/src/lib/ebook/features.ts @@ -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: {