Polish conversion trust: newsletter i18n, pay honesty, join funnel, Google login hook.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2fa59f0cd4
commit
5c36032a46
@ -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)}
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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: "首页/关于页",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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() {
|
||||
|
||||
97
frontend/src/components/GoogleSignInButton.tsx
Normal file
97
frontend/src/components/GoogleSignInButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
@ -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: '返回封面',
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 },
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user