Polish conversion trust: newsletter i18n, pay honesty, join funnel, Google login hook.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-09-04 10:30:11 -05:00
parent 2fa59f0cd4
commit 5c36032a46
17 changed files with 230 additions and 24 deletions

View File

@ -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)}

View File

@ -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=

View File

@ -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: "首页/关于页",

View File

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

View File

@ -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() {
</form>
<div className="auth-divider"><span>{t.login.or}</span></div>
<GoogleSignInButton disabled={loading} onCredential={handleGoogle} />
<button className="btn btn-ghost auth-demo-btn" type="button" onClick={handleDemo} disabled={loading}>
{t.login.demo}
</button>
@ -154,10 +165,19 @@ function LoginForm() {
);
}
function LoginFallback() {
const { t } = useI18n();
return (
<div className="auth-page">
<p className="plan-loading">{t.common.loading}</p>
</div>
);
}
export default function LoginPage() {
return (
<SiteShell>
<Suspense fallback={<div className="auth-page"><p className="plan-loading">加载中…</p></div>}>
<Suspense fallback={<LoginFallback />}>
<LoginForm />
</Suspense>
</SiteShell>

View File

@ -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() {

View File

@ -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<void>;
disabled?: boolean;
}) {
const { t, locale } = useI18n();
const slotRef = useRef<HTMLDivElement>(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<HTMLScriptElement>('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 (
<div className={`auth-google${disabled ? " is-disabled" : ""}`} aria-label={t.login.google}>
<div ref={slotRef} />
{!ready && <p className="auth-hint">{t.login.processing}</p>}
</div>
);
}

View File

@ -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() {
<button type="button" className="btn btn-primary" disabled={loading || checkingVip} onClick={() => void pay()}>
{vip ? t.join.goDating : t.join.payBtn}
</button>
{!vip && <p className="section-desc join-pay-note">{t.join.payMethods}</p>}
{!user && <Link href="/login?next=/join" className="join-login-hint">{t.nav.login}</Link>}
</div>
</div>

View File

@ -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}
</button>
</div>
<p className="newsletter-note">先登记邮箱;邮件推送开通后会同步发送,不会假装已群发</p>
<p className="newsletter-note">{t.newsletter.note}</p>
</div>
);
}

View File

@ -19,7 +19,7 @@ export default function PricingClient() {
<h3>{t.pricing.free}</h3>
<p>{t.pricing.freeDesc}</p>
<ul className="join-perks"><li>{t.pricing.free1}</li><li>{t.pricing.free2}</li></ul>
<Link href="/dating" className="btn">{t.pricing.try}</Link>
<Link href="/join" className="btn">{t.pricing.try}</Link>
</div>
<div className="join-card join-card-vip">
<h3>{t.pricing.vip}</h3>
@ -28,6 +28,7 @@ export default function PricingClient() {
<li>{t.join.perk1}</li><li>{t.join.perk2}</li><li>{t.join.perk3}</li><li>{t.join.perk4}</li>
</ul>
<Link href="/join" className="btn btn-primary">{t.join.payBtn}</Link>
<p className="section-desc join-pay-note">{t.pricing.payNote}</p>
</div>
</div>
<RingNext

View File

@ -9,7 +9,7 @@ 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). */
/** Buy edition via FastAPI /pay/create (pay_type=ebook). */
export function BuyEbook({ compact = false }: { compact?: boolean }) {
const t = useT();
const { token } = useAuth();
@ -17,18 +17,33 @@ export function BuyEbook({ compact = false }: { compact?: boolean }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [owned, setOwned] = useState(false);
const [hasFiles, setHasFiles] = 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 || t("payment.buyDownload");
const configuredButton = features.payment?.checkout?.buttonText;
useEffect(() => {
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 (
<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>
<p className="buy-ebook-sub">{buySub}</p>
<button type="button" className="buy-ebook-btn" disabled={busy} onClick={buy}>
{busy ? t("payment.openingCheckout") : owned ? t("payment.ownedDownload") : buttonText}
{busy ? t("payment.openingCheckout") : owned ? ownedLabel : buyLabel}
</button>
{error ? <p className="buy-ebook-error">{error}</p> : null}
<p className="buy-ebook-hint">

View File

@ -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) =>

View File

@ -13,6 +13,7 @@ interface AuthCtx {
login: (email: string, password: string) => Promise<boolean>;
register: (email: string, password: string, name: string) => Promise<boolean>;
demoLogin: () => Promise<boolean>;
googleLogin: (idToken: string) => Promise<boolean>;
logout: () => void;
refreshUser: () => Promise<void>;
toggleFavorite: (slug: string) => Promise<void>;
@ -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 (
<AuthContext.Provider value={{
user, token, favorites, planSyncStatus, login, register, demoLogin, logout, refreshUser,
user, token, favorites, planSyncStatus, login, register, demoLogin, googleLogin, logout, refreshUser,
toggleFavorite, isFavorite: (slug) => favorites.includes(slug),
}}>
{children}

View File

@ -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',
},

View File

@ -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: '返回封面',
},

View File

@ -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",

View File

@ -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 },
];