From 5c36032a4674bf3059c24c37c2526c12719e9fc7 Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 4 Sep 2026 10:30:11 -0500 Subject: [PATCH] Polish conversion trust: newsletter i18n, pay honesty, join funnel, Google login hook. Co-authored-by: Cursor --- backend/app/routers/ebook.py | 17 +++- frontend/.env.local.example | 2 + frontend/src/app/changelog/page.tsx | 9 ++ frontend/src/app/globals.css | 15 +++ frontend/src/app/login/page.tsx | 24 ++++- frontend/src/app/map/page.tsx | 4 +- .../src/components/GoogleSignInButton.tsx | 97 +++++++++++++++++++ frontend/src/components/JoinClient.tsx | 9 +- .../src/components/NewsletterSubscribe.tsx | 4 +- frontend/src/components/PricingClient.tsx | 3 +- frontend/src/components/ebook/BuyEbook.tsx | 25 ++++- frontend/src/lib/api.ts | 6 +- frontend/src/lib/auth.tsx | 11 ++- frontend/src/lib/ebook/i18n/messages/en.ts | 5 +- frontend/src/lib/ebook/i18n/messages/zh.ts | 5 +- frontend/src/lib/i18n/dictionaries.ts | 16 ++- frontend/src/lib/rings.ts | 2 + 17 files changed, 230 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/GoogleSignInButton.tsx diff --git a/backend/app/routers/ebook.py b/backend/app/routers/ebook.py index b8ad957..5cb5754 100644 --- a/backend/app/routers/ebook.py +++ b/backend/app/routers/ebook.py @@ -23,8 +23,21 @@ def _user(authorization: str | None) -> dict: async def ebook_status(authorization: str | None = Header(None)): user = _user(authorization) owned = social_store.has_ebook(user["id"]) + has_files = bool(EBOOK_PDF_URL or EBOOK_EPUB_URL) return { "owned": owned, + "has_files": has_files, + "amount_fen": PAYMENT_EBOOK_AMOUNT, + "amount_yuan": f"{PAYMENT_EBOOK_AMOUNT / 100:.2f}", + } + + +@router.get("/product") +async def ebook_product(): + """Public product info — no auth (buy CTA honesty).""" + has_files = bool(EBOOK_PDF_URL or EBOOK_EPUB_URL) + return { + "has_files": has_files, "amount_fen": PAYMENT_EBOOK_AMOUNT, "amount_yuan": f"{PAYMENT_EBOOK_AMOUNT / 100:.2f}", } @@ -41,5 +54,5 @@ async def ebook_downloads(authorization: str | None = Header(None)): 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} + files.append({"format": "online", "label": "Online reading", "url": "/book/read"}) + return {"owned": True, "files": files, "has_files": bool(EBOOK_PDF_URL or EBOOK_EPUB_URL)} diff --git a/frontend/.env.local.example b/frontend/.env.local.example index 0ec0a2d..0d3600c 100644 --- a/frontend/.env.local.example +++ b/frontend/.env.local.example @@ -1,2 +1,4 @@ NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1 NEXT_PUBLIC_SITE_URL=http://localhost:3000 +# Optional — shows Google Sign-In on /login when set +# NEXT_PUBLIC_GOOGLE_CLIENT_ID= diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx index 84fa1cc..66c80fa 100644 --- a/frontend/src/app/changelog/page.tsx +++ b/frontend/src/app/changelog/page.tsx @@ -8,6 +8,15 @@ export const metadata: Metadata = { }; const LOGS = [ + { + date: "2026-09-04", + tag: "转化/信任打磨", + items: [ + "订阅条中英校验与说明;Join 保存资料后直达匹配;定价免费 CTA 改走完善资料", + "VIP/电子书支付说明更诚实(支付宝微信、无文件时写清在线解锁);登录可接 Google(需配置 Client ID)", + "移动端隐藏易裁切的 hero 浮卡;活动后下一步补齐私信入口", + ], + }, { date: "2026-09-04", tag: "首页/关于页", diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 7766be4..25a7989 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1301,6 +1301,7 @@ img { max-width: 100%; display: block; } .hero-stats { justify-content: center; } .hero-visual { order: -1; } .globe-container { max-width: 280px; } + .floating-cards .float-card { display: none; } .dest-grid { grid-template-columns: repeat(2, 1fr); } .tools-grid { grid-template-columns: repeat(2, 1fr); } @@ -9624,6 +9625,20 @@ a.profile-stat-link:hover { .auth-demo-btn { width: 100%; } +.auth-google { + display: flex; + justify-content: center; + margin-bottom: 12px; + min-height: 44px; +} +.auth-google.is-disabled { + pointer-events: none; + opacity: 0.55; +} +.join-pay-note { + margin-top: 10px; + font-size: 0.85rem; +} .tools-core-banner { display: grid; grid-template-columns: repeat(3, 1fr); diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index c3ec760..d627786 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -9,9 +9,10 @@ import { useToast } from "@/lib/toast"; import { useI18n } from "@/lib/i18n"; import { resolveAuthNext } from "@/lib/authNext"; import SiteShell from "@/components/SiteShell"; +import { GoogleSignInButton } from "@/components/GoogleSignInButton"; function LoginForm() { - const { login, register, demoLogin } = useAuth(); + const { login, register, demoLogin, googleLogin } = useAuth(); const { toast } = useToast(); const { t } = useI18n(); const router = useRouter(); @@ -65,6 +66,15 @@ function LoginForm() { if (ok) goAfterAuth(t.login.demoToast); }; + const handleGoogle = async (idToken: string) => { + setError(""); + setLoading(true); + const ok = await googleLogin(idToken); + setLoading(false); + if (ok) goAfterAuth(meta.welcomeToast); + else setError(t.login.googleFail); + }; + const headerHint = mode === "login" ? meta.hint : t.login.registerHint; return ( @@ -145,6 +155,7 @@ function LoginForm() {
{t.login.or}
+ @@ -154,10 +165,19 @@ function LoginForm() { ); } +function LoginFallback() { + const { t } = useI18n(); + return ( +
+

{t.common.loading}

+
+ ); +} + export default function LoginPage() { return ( -

加载中…

}> + }>
diff --git a/frontend/src/app/map/page.tsx b/frontend/src/app/map/page.tsx index bb975c1..8ba9b07 100644 --- a/frontend/src/app/map/page.tsx +++ b/frontend/src/app/map/page.tsx @@ -3,8 +3,8 @@ import SiteShell from "@/components/SiteShell"; import MemberMapClient from "@/components/MemberMapClient"; export const metadata: Metadata = { - title: "游民地图 · nomadro", - description: "全球游民分布与目的地实时天气", + title: "Nomad map · 游民地图 · nomadro", + description: "Global nomad heatmap and live city weather · 全球游民分布与目的地实时天气", }; export default function MapPage() { diff --git a/frontend/src/components/GoogleSignInButton.tsx b/frontend/src/components/GoogleSignInButton.tsx new file mode 100644 index 0000000..d095c25 --- /dev/null +++ b/frontend/src/components/GoogleSignInButton.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useI18n } from "@/lib/i18n"; + +declare global { + interface Window { + google?: { + accounts: { + id: { + initialize: (cfg: { + client_id: string; + callback: (res: { credential: string }) => void; + }) => void; + renderButton: ( + el: HTMLElement, + opts: { theme?: string; size?: string; width?: number; text?: string } + ) => void; + }; + }; + }; + } +} + +const CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ""; + +export function GoogleSignInButton({ + onCredential, + disabled, +}: { + onCredential: (idToken: string) => void | Promise; + disabled?: boolean; +}) { + const { t, locale } = useI18n(); + const slotRef = useRef(null); + const [ready, setReady] = useState(false); + const cbRef = useRef(onCredential); + cbRef.current = onCredential; + + useEffect(() => { + if (!CLIENT_ID || !slotRef.current) return; + let cancelled = false; + + const mount = () => { + if (cancelled || !slotRef.current || !window.google?.accounts?.id) return; + slotRef.current.innerHTML = ""; + window.google.accounts.id.initialize({ + client_id: CLIENT_ID, + callback: (res) => { + if (res.credential) void cbRef.current(res.credential); + }, + }); + window.google.accounts.id.renderButton(slotRef.current, { + theme: "outline", + size: "large", + width: 320, + text: "continue_with", + }); + setReady(true); + }; + + if (window.google?.accounts?.id) { + mount(); + return () => { + cancelled = true; + }; + } + + const existing = document.querySelector('script[data-gis="1"]'); + if (existing) { + existing.addEventListener("load", mount); + return () => { + cancelled = true; + existing.removeEventListener("load", mount); + }; + } + + const script = document.createElement("script"); + script.src = "https://accounts.google.com/gsi/client"; + script.async = true; + script.dataset.gis = "1"; + script.onload = mount; + document.head.appendChild(script); + return () => { + cancelled = true; + }; + }, [locale]); + + if (!CLIENT_ID) return null; + + return ( +
+
+ {!ready &&

{t.login.processing}

} +
+ ); +} diff --git a/frontend/src/components/JoinClient.tsx b/frontend/src/components/JoinClient.tsx index 378c00d..da63565 100644 --- a/frontend/src/components/JoinClient.tsx +++ b/frontend/src/components/JoinClient.tsx @@ -70,10 +70,12 @@ export default function JoinClient() { await api.socialJoin(token, { city, bio, lookingFor: ["friends", "explore"] }); clearDraft(DRAFT_KEY); const cityHint = city.trim().split(/[,/·|]/)[0]?.trim(); + const nextHref = cityHint ? meetupCityHref(cityHint) : "/dating"; toast(t.join.profileOk, "success", { - href: cityHint ? meetupCityHref(cityHint) : "/dating", + href: nextHref, label: cityHint ? t.common.cityMeetups : t.nav.dating, }); + router.push("/dating"); } catch { toast(t.join.profileFail, "error"); } finally { @@ -105,8 +107,8 @@ export default function JoinClient() { return; } window.location.href = res.redirect_url; - } catch (e) { - toast(e instanceof Error ? e.message : t.join.payFail, "error"); + } catch { + toast(t.join.payFail, "error"); setLoading(false); } }; @@ -148,6 +150,7 @@ export default function JoinClient() { + {!vip &&

{t.join.payMethods}

} {!user && {t.nav.login}}
diff --git a/frontend/src/components/NewsletterSubscribe.tsx b/frontend/src/components/NewsletterSubscribe.tsx index dd6e9d4..5ad1512 100644 --- a/frontend/src/components/NewsletterSubscribe.tsx +++ b/frontend/src/components/NewsletterSubscribe.tsx @@ -14,7 +14,7 @@ export default function NewsletterSubscribe({ source = "meetups" }: { source?: s const submit = async () => { const value = email.trim(); if (!value || !value.includes("@")) { - toast("请输入有效邮箱", "info"); + toast(t.newsletter.invalidEmail, "info"); return; } setBusy(true); @@ -46,7 +46,7 @@ export default function NewsletterSubscribe({ source = "meetups" }: { source?: s {busy ? "…" : t.newsletter.submit} -

先登记邮箱;邮件推送开通后会同步发送,不会假装已群发

+

{t.newsletter.note}

); } diff --git a/frontend/src/components/PricingClient.tsx b/frontend/src/components/PricingClient.tsx index e950d9d..54fcec2 100644 --- a/frontend/src/components/PricingClient.tsx +++ b/frontend/src/components/PricingClient.tsx @@ -19,7 +19,7 @@ export default function PricingClient() {

{t.pricing.free}

{t.pricing.freeDesc}

  • {t.pricing.free1}
  • {t.pricing.free2}
- {t.pricing.try} + {t.pricing.try}

{t.pricing.vip}

@@ -28,6 +28,7 @@ export default function PricingClient() {
  • {t.join.perk1}
  • {t.join.perk2}
  • {t.join.perk3}
  • {t.join.perk4}
  • {t.join.payBtn} +

    {t.pricing.payNote}

    { + api.ebookProduct() + .then((p) => setHasFiles(Boolean(p.has_files))) + .catch(() => setHasFiles(false)); + }, []); useEffect(() => { if (!token) return; - api.ebookStatus(token).then((s) => setOwned(s.owned)).catch(() => {}); + api.ebookStatus(token).then((s) => { + setOwned(s.owned); + if (typeof s.has_files === "boolean") setHasFiles(s.has_files); + }).catch(() => {}); }, [token]); if (!enabled) return null; + const buyLabel = configuredButton + || (hasFiles ? t("payment.buyDownload") : t("payment.buyUnlock")); + const buySub = hasFiles ? t("payment.buySub") : t("payment.buySubOnline"); + const ownedLabel = hasFiles ? t("payment.ownedDownload") : t("payment.ownedOnline"); + const buy = async () => { setError(""); if (!token) { @@ -65,9 +80,9 @@ export function BuyEbook({ compact = false }: { compact?: boolean }) { return (

    {t("payment.buyGet", { name: productName })}

    -

    {t("payment.buySub")}

    +

    {buySub}

    {error ?

    {error}

    : null}

    diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c2df3c3..dd13269 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -163,11 +163,13 @@ export const api = { 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", { + fetchAPI<{ owned: boolean; has_files?: boolean; amount_fen: number; amount_yuan: string }>("/ebook/status", { headers: { Authorization: `Bearer ${token}` }, }), + ebookProduct: () => + fetchAPI<{ has_files: boolean; amount_fen: number; amount_yuan: string }>("/ebook/product"), ebookDownloads: (token: string) => - fetchAPI<{ owned: boolean; files: { format: string; label: string; url: string }[] }>("/ebook/downloads", { + fetchAPI<{ owned: boolean; has_files?: boolean; files: { format: string; label: string; url: string }[] }>("/ebook/downloads", { headers: { Authorization: `Bearer ${token}` }, }), getFavorites: (token: string) => diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx index 82eaba9..edb0873 100644 --- a/frontend/src/lib/auth.tsx +++ b/frontend/src/lib/auth.tsx @@ -13,6 +13,7 @@ interface AuthCtx { login: (email: string, password: string) => Promise; register: (email: string, password: string, name: string) => Promise; demoLogin: () => Promise; + googleLogin: (idToken: string) => Promise; logout: () => void; refreshUser: () => Promise; toggleFavorite: (slug: string) => Promise; @@ -88,6 +89,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } catch { return false; } }; + const googleLogin = async (idToken: string) => { + try { + const data = await api.googleLogin(idToken); + await persist(data.token, data.user); + return true; + } catch { return false; } + }; + const logout = () => { setToken(null); setUser(null); @@ -122,7 +131,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return ( favorites.includes(slug), }}> {children} diff --git a/frontend/src/lib/ebook/i18n/messages/en.ts b/frontend/src/lib/ebook/i18n/messages/en.ts index b985138..a5b2607 100644 --- a/frontend/src/lib/ebook/i18n/messages/en.ts +++ b/frontend/src/lib/ebook/i18n/messages/en.ts @@ -137,8 +137,11 @@ export const messages = { payFailed: 'Payment failed', buyGet: 'Get the {name}', buySub: 'Read free online, or grab PDF & EPUB for offline and permanent access.', + buySubOnline: 'Read free online — purchase unlocks permanent online access. PDF/EPUB ship when files are ready.', buyDownload: 'Buy download edition', + buyUnlock: 'Unlock online edition', ownedDownload: 'Owned · Downloads', + ownedOnline: 'Owned · Keep reading', orReadOnline: 'Or read free', checkoutFailed: 'Checkout failed', }, @@ -149,7 +152,7 @@ export const messages = { supportContinue: 'Keep reading', purchaseTitle: 'Thank you!', purchaseBody: 'Purchase complete', - purchaseDetail: 'Your download edition is on its way. Check your email for PDF & EPUB files. If you don\'t see it within a few minutes, check spam or contact us.', + purchaseDetail: 'Your edition is unlocked. If PDF/EPUB links are configured, they appear here; otherwise keep reading online anytime.', purchaseContinue: 'Continue reading online', backCover: 'Back to cover', }, diff --git a/frontend/src/lib/ebook/i18n/messages/zh.ts b/frontend/src/lib/ebook/i18n/messages/zh.ts index cc08aba..f4bfe56 100644 --- a/frontend/src/lib/ebook/i18n/messages/zh.ts +++ b/frontend/src/lib/ebook/i18n/messages/zh.ts @@ -137,8 +137,11 @@ export const messages = { payFailed: '支付失败', buyGet: '购买 {name}', buySub: '可免费在线阅读,或购买 PDF 与 EPUB 离线永久保存。', + buySubOnline: '可免费在线阅读;购买后解锁永久在线访问。PDF/EPUB 文件就绪后可下载。', buyDownload: '购买下载版', + buyUnlock: '解锁在线版', ownedDownload: '已购买 · 去下载', + ownedOnline: '已购买 · 继续阅读', orReadOnline: '也可免费', checkoutFailed: '结账失败', }, @@ -149,7 +152,7 @@ export const messages = { supportContinue: '继续阅读', purchaseTitle: '谢谢!', purchaseBody: '购买完成', - purchaseDetail: '下载版将发送到你的邮箱(PDF 和 EPUB)。若几分钟内未收到,请检查垃圾箱或联系我们。', + purchaseDetail: '权益已解锁。若已配置 PDF/EPUB 下载链接会显示在此;否则可随时在线阅读。', purchaseContinue: '继续在线阅读', backCover: '返回封面', }, diff --git a/frontend/src/lib/i18n/dictionaries.ts b/frontend/src/lib/i18n/dictionaries.ts index 1d49cdd..1172ad2 100644 --- a/frontend/src/lib/i18n/dictionaries.ts +++ b/frontend/src/lib/i18n/dictionaries.ts @@ -426,6 +426,8 @@ export const zh = { pwdNeed: "还差 {n} 位", pwdOk: "可用 · 再长一点更安全", pwdStrong: "强度不错", + google: "使用 Google 登录", + googleFail: "Google 登录失败", }, footer: { explore: "探索", @@ -567,6 +569,7 @@ export const zh = { draftHint: "资料草稿会自动保存在本机", saving: "保存中…", vipActive: "✨ 你已是 VIP · 权益已生效", + payMethods: "目前支持支付宝 / 微信支付(中国大陆通道)", }, live: { loading: "加载直播间…", @@ -900,7 +903,8 @@ export const zh = { free2: "参与社区讨论与活动 RSVP", vip: "VIP 会员", year: "年", - try: "开始匹配", + try: "完善资料开始", + payNote: "VIP 支付目前走支付宝 / 微信", }, feedback: { tag: "💬 FEEDBACK", @@ -1272,6 +1276,8 @@ export const zh = { submit: "订阅", ok: "订阅成功", fail: "订阅失败", + invalidEmail: "请输入有效邮箱", + note: "先登记邮箱;邮件推送开通后会同步发送,不会假装已群发", }, pwa: { installHint: "可安装到主屏幕,离线也能打开计划入口", @@ -1821,6 +1827,8 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string pwdNeed: "{n} more characters needed", pwdOk: "OK · a bit longer is safer", pwdStrong: "Looking strong", + google: "Continue with Google", + googleFail: "Google sign-in failed", }, footer: { explore: "Explore", @@ -1962,6 +1970,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string draftHint: "Profile draft autosaves on this device", saving: "Saving…", vipActive: "✨ You're VIP — perks are active", + payMethods: "Checkout uses Alipay / WeChat Pay (China rails) for now", }, live: { loading: "Loading live room…", @@ -2295,7 +2304,8 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string free2: "Discussions & event RSVPs", vip: "VIP", year: "year", - try: "Start matching", + try: "Complete profile", + payNote: "VIP checkout currently uses Alipay / WeChat Pay", }, feedback: { tag: "💬 FEEDBACK", @@ -2667,6 +2677,8 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string submit: "Subscribe", ok: "Subscribed", fail: "Could not subscribe", + invalidEmail: "Enter a valid email", + note: "We'll save your email now; sends start once mailing is enabled — no fake blasts", }, pwa: { installHint: "Install to home screen — open plan offline-ready", diff --git a/frontend/src/lib/rings.ts b/frontend/src/lib/rings.ts index f458de7..f3f0ac2 100644 --- a/frontend/src/lib/rings.ts +++ b/frontend/src/lib/rings.ts @@ -100,6 +100,8 @@ export function useRingSteps() { { href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc }, + { href: "/chat", emoji: "✉️", label: t.ring.chat, desc: t.ring.chatDesc }, + ];