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)):
|
async def ebook_status(authorization: str | None = Header(None)):
|
||||||
user = _user(authorization)
|
user = _user(authorization)
|
||||||
owned = social_store.has_ebook(user["id"])
|
owned = social_store.has_ebook(user["id"])
|
||||||
|
has_files = bool(EBOOK_PDF_URL or EBOOK_EPUB_URL)
|
||||||
return {
|
return {
|
||||||
"owned": owned,
|
"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_fen": PAYMENT_EBOOK_AMOUNT,
|
||||||
"amount_yuan": f"{PAYMENT_EBOOK_AMOUNT / 100:.2f}",
|
"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:
|
if EBOOK_EPUB_URL:
|
||||||
files.append({"format": "epub", "label": "EPUB", "url": EBOOK_EPUB_URL})
|
files.append({"format": "epub", "label": "EPUB", "url": EBOOK_EPUB_URL})
|
||||||
if not files:
|
if not files:
|
||||||
files.append({"format": "online", "label": "在线阅读", "url": "/book/read"})
|
files.append({"format": "online", "label": "Online reading", "url": "/book/read"})
|
||||||
return {"owned": True, "files": files}
|
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_API_URL=http://localhost:8000/api/v1
|
||||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
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 = [
|
const LOGS = [
|
||||||
|
{
|
||||||
|
date: "2026-09-04",
|
||||||
|
tag: "转化/信任打磨",
|
||||||
|
items: [
|
||||||
|
"订阅条中英校验与说明;Join 保存资料后直达匹配;定价免费 CTA 改走完善资料",
|
||||||
|
"VIP/电子书支付说明更诚实(支付宝微信、无文件时写清在线解锁);登录可接 Google(需配置 Client ID)",
|
||||||
|
"移动端隐藏易裁切的 hero 浮卡;活动后下一步补齐私信入口",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
date: "2026-09-04",
|
date: "2026-09-04",
|
||||||
tag: "首页/关于页",
|
tag: "首页/关于页",
|
||||||
|
|||||||
@ -1301,6 +1301,7 @@ img { max-width: 100%; display: block; }
|
|||||||
.hero-stats { justify-content: center; }
|
.hero-stats { justify-content: center; }
|
||||||
.hero-visual { order: -1; }
|
.hero-visual { order: -1; }
|
||||||
.globe-container { max-width: 280px; }
|
.globe-container { max-width: 280px; }
|
||||||
|
.floating-cards .float-card { display: none; }
|
||||||
|
|
||||||
.dest-grid { grid-template-columns: repeat(2, 1fr); }
|
.dest-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
.tools-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 {
|
.auth-demo-btn {
|
||||||
width: 100%;
|
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 {
|
.tools-core-banner {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
|||||||
@ -9,9 +9,10 @@ import { useToast } from "@/lib/toast";
|
|||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { resolveAuthNext } from "@/lib/authNext";
|
import { resolveAuthNext } from "@/lib/authNext";
|
||||||
import SiteShell from "@/components/SiteShell";
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import { GoogleSignInButton } from "@/components/GoogleSignInButton";
|
||||||
|
|
||||||
function LoginForm() {
|
function LoginForm() {
|
||||||
const { login, register, demoLogin } = useAuth();
|
const { login, register, demoLogin, googleLogin } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -65,6 +66,15 @@ function LoginForm() {
|
|||||||
if (ok) goAfterAuth(t.login.demoToast);
|
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;
|
const headerHint = mode === "login" ? meta.hint : t.login.registerHint;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -145,6 +155,7 @@ function LoginForm() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="auth-divider"><span>{t.login.or}</span></div>
|
<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}>
|
<button className="btn btn-ghost auth-demo-btn" type="button" onClick={handleDemo} disabled={loading}>
|
||||||
{t.login.demo}
|
{t.login.demo}
|
||||||
</button>
|
</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() {
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<SiteShell>
|
<SiteShell>
|
||||||
<Suspense fallback={<div className="auth-page"><p className="plan-loading">加载中…</p></div>}>
|
<Suspense fallback={<LoginFallback />}>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</SiteShell>
|
</SiteShell>
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import SiteShell from "@/components/SiteShell";
|
|||||||
import MemberMapClient from "@/components/MemberMapClient";
|
import MemberMapClient from "@/components/MemberMapClient";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "游民地图 · nomadro",
|
title: "Nomad map · 游民地图 · nomadro",
|
||||||
description: "全球游民分布与目的地实时天气",
|
description: "Global nomad heatmap and live city weather · 全球游民分布与目的地实时天气",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MapPage() {
|
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"] });
|
await api.socialJoin(token, { city, bio, lookingFor: ["friends", "explore"] });
|
||||||
clearDraft(DRAFT_KEY);
|
clearDraft(DRAFT_KEY);
|
||||||
const cityHint = city.trim().split(/[,/·|]/)[0]?.trim();
|
const cityHint = city.trim().split(/[,/·|]/)[0]?.trim();
|
||||||
|
const nextHref = cityHint ? meetupCityHref(cityHint) : "/dating";
|
||||||
toast(t.join.profileOk, "success", {
|
toast(t.join.profileOk, "success", {
|
||||||
href: cityHint ? meetupCityHref(cityHint) : "/dating",
|
href: nextHref,
|
||||||
label: cityHint ? t.common.cityMeetups : t.nav.dating,
|
label: cityHint ? t.common.cityMeetups : t.nav.dating,
|
||||||
});
|
});
|
||||||
|
router.push("/dating");
|
||||||
} catch {
|
} catch {
|
||||||
toast(t.join.profileFail, "error");
|
toast(t.join.profileFail, "error");
|
||||||
} finally {
|
} finally {
|
||||||
@ -105,8 +107,8 @@ export default function JoinClient() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.href = res.redirect_url;
|
window.location.href = res.redirect_url;
|
||||||
} catch (e) {
|
} catch {
|
||||||
toast(e instanceof Error ? e.message : t.join.payFail, "error");
|
toast(t.join.payFail, "error");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -148,6 +150,7 @@ export default function JoinClient() {
|
|||||||
<button type="button" className="btn btn-primary" disabled={loading || checkingVip} onClick={() => void pay()}>
|
<button type="button" className="btn btn-primary" disabled={loading || checkingVip} onClick={() => void pay()}>
|
||||||
{vip ? t.join.goDating : t.join.payBtn}
|
{vip ? t.join.goDating : t.join.payBtn}
|
||||||
</button>
|
</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>}
|
{!user && <Link href="/login?next=/join" className="join-login-hint">{t.nav.login}</Link>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export default function NewsletterSubscribe({ source = "meetups" }: { source?: s
|
|||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const value = email.trim();
|
const value = email.trim();
|
||||||
if (!value || !value.includes("@")) {
|
if (!value || !value.includes("@")) {
|
||||||
toast("请输入有效邮箱", "info");
|
toast(t.newsletter.invalidEmail, "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@ -46,7 +46,7 @@ export default function NewsletterSubscribe({ source = "meetups" }: { source?: s
|
|||||||
{busy ? "…" : t.newsletter.submit}
|
{busy ? "…" : t.newsletter.submit}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="newsletter-note">先登记邮箱;邮件推送开通后会同步发送,不会假装已群发</p>
|
<p className="newsletter-note">{t.newsletter.note}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export default function PricingClient() {
|
|||||||
<h3>{t.pricing.free}</h3>
|
<h3>{t.pricing.free}</h3>
|
||||||
<p>{t.pricing.freeDesc}</p>
|
<p>{t.pricing.freeDesc}</p>
|
||||||
<ul className="join-perks"><li>{t.pricing.free1}</li><li>{t.pricing.free2}</li></ul>
|
<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>
|
||||||
<div className="join-card join-card-vip">
|
<div className="join-card join-card-vip">
|
||||||
<h3>{t.pricing.vip}</h3>
|
<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>
|
<li>{t.join.perk1}</li><li>{t.join.perk2}</li><li>{t.join.perk3}</li><li>{t.join.perk4}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<Link href="/join" className="btn btn-primary">{t.join.payBtn}</Link>
|
<Link href="/join" className="btn btn-primary">{t.join.payBtn}</Link>
|
||||||
|
<p className="section-desc join-pay-note">{t.pricing.payNote}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<RingNext
|
<RingNext
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { useT } from "@/components/ebook/LocaleProvider";
|
|||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
import { api } from "@/lib/api";
|
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 }) {
|
export function BuyEbook({ compact = false }: { compact?: boolean }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
@ -17,18 +17,33 @@ export function BuyEbook({ compact = false }: { compact?: boolean }) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [owned, setOwned] = useState(false);
|
const [owned, setOwned] = useState(false);
|
||||||
|
const [hasFiles, setHasFiles] = useState(false);
|
||||||
const features = getFeatures();
|
const features = getFeatures();
|
||||||
const enabled = features.payment?.checkout?.enabled !== false;
|
const enabled = features.payment?.checkout?.enabled !== false;
|
||||||
const productName = features.payment?.checkout?.productName || "Download Edition";
|
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(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
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]);
|
}, [token]);
|
||||||
|
|
||||||
if (!enabled) return null;
|
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 () => {
|
const buy = async () => {
|
||||||
setError("");
|
setError("");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@ -65,9 +80,9 @@ export function BuyEbook({ compact = false }: { compact?: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<section className={`buy-ebook${compact ? " is-compact" : ""}`}>
|
<section className={`buy-ebook${compact ? " is-compact" : ""}`}>
|
||||||
<h2 className="buy-ebook-title">{t("payment.buyGet", { name: productName })}</h2>
|
<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}>
|
<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>
|
</button>
|
||||||
{error ? <p className="buy-ebook-error">{error}</p> : null}
|
{error ? <p className="buy-ebook-error">{error}</p> : null}
|
||||||
<p className="buy-ebook-hint">
|
<p className="buy-ebook-hint">
|
||||||
|
|||||||
@ -163,11 +163,13 @@ export const api = {
|
|||||||
return res.json() as Promise<{ success: boolean; url: string; object_key: string }>;
|
return res.json() as Promise<{ success: boolean; url: string; object_key: string }>;
|
||||||
},
|
},
|
||||||
ebookStatus: (token: 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}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
}),
|
}),
|
||||||
|
ebookProduct: () =>
|
||||||
|
fetchAPI<{ has_files: boolean; amount_fen: number; amount_yuan: string }>("/ebook/product"),
|
||||||
ebookDownloads: (token: string) =>
|
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}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
}),
|
}),
|
||||||
getFavorites: (token: string) =>
|
getFavorites: (token: string) =>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ interface AuthCtx {
|
|||||||
login: (email: string, password: string) => Promise<boolean>;
|
login: (email: string, password: string) => Promise<boolean>;
|
||||||
register: (email: string, password: string, name: string) => Promise<boolean>;
|
register: (email: string, password: string, name: string) => Promise<boolean>;
|
||||||
demoLogin: () => Promise<boolean>;
|
demoLogin: () => Promise<boolean>;
|
||||||
|
googleLogin: (idToken: string) => Promise<boolean>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
toggleFavorite: (slug: string) => Promise<void>;
|
toggleFavorite: (slug: string) => Promise<void>;
|
||||||
@ -88,6 +89,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
} catch { return false; }
|
} 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 = () => {
|
const logout = () => {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@ -122,7 +131,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{
|
<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),
|
toggleFavorite, isFavorite: (slug) => favorites.includes(slug),
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -137,8 +137,11 @@ export const messages = {
|
|||||||
payFailed: 'Payment failed',
|
payFailed: 'Payment failed',
|
||||||
buyGet: 'Get the {name}',
|
buyGet: 'Get the {name}',
|
||||||
buySub: 'Read free online, or grab PDF & EPUB for offline and permanent access.',
|
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',
|
buyDownload: 'Buy download edition',
|
||||||
|
buyUnlock: 'Unlock online edition',
|
||||||
ownedDownload: 'Owned · Downloads',
|
ownedDownload: 'Owned · Downloads',
|
||||||
|
ownedOnline: 'Owned · Keep reading',
|
||||||
orReadOnline: 'Or read free',
|
orReadOnline: 'Or read free',
|
||||||
checkoutFailed: 'Checkout failed',
|
checkoutFailed: 'Checkout failed',
|
||||||
},
|
},
|
||||||
@ -149,7 +152,7 @@ export const messages = {
|
|||||||
supportContinue: 'Keep reading',
|
supportContinue: 'Keep reading',
|
||||||
purchaseTitle: 'Thank you!',
|
purchaseTitle: 'Thank you!',
|
||||||
purchaseBody: 'Purchase complete',
|
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',
|
purchaseContinue: 'Continue reading online',
|
||||||
backCover: 'Back to cover',
|
backCover: 'Back to cover',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -137,8 +137,11 @@ export const messages = {
|
|||||||
payFailed: '支付失败',
|
payFailed: '支付失败',
|
||||||
buyGet: '购买 {name}',
|
buyGet: '购买 {name}',
|
||||||
buySub: '可免费在线阅读,或购买 PDF 与 EPUB 离线永久保存。',
|
buySub: '可免费在线阅读,或购买 PDF 与 EPUB 离线永久保存。',
|
||||||
|
buySubOnline: '可免费在线阅读;购买后解锁永久在线访问。PDF/EPUB 文件就绪后可下载。',
|
||||||
buyDownload: '购买下载版',
|
buyDownload: '购买下载版',
|
||||||
|
buyUnlock: '解锁在线版',
|
||||||
ownedDownload: '已购买 · 去下载',
|
ownedDownload: '已购买 · 去下载',
|
||||||
|
ownedOnline: '已购买 · 继续阅读',
|
||||||
orReadOnline: '也可免费',
|
orReadOnline: '也可免费',
|
||||||
checkoutFailed: '结账失败',
|
checkoutFailed: '结账失败',
|
||||||
},
|
},
|
||||||
@ -149,7 +152,7 @@ export const messages = {
|
|||||||
supportContinue: '继续阅读',
|
supportContinue: '继续阅读',
|
||||||
purchaseTitle: '谢谢!',
|
purchaseTitle: '谢谢!',
|
||||||
purchaseBody: '购买完成',
|
purchaseBody: '购买完成',
|
||||||
purchaseDetail: '下载版将发送到你的邮箱(PDF 和 EPUB)。若几分钟内未收到,请检查垃圾箱或联系我们。',
|
purchaseDetail: '权益已解锁。若已配置 PDF/EPUB 下载链接会显示在此;否则可随时在线阅读。',
|
||||||
purchaseContinue: '继续在线阅读',
|
purchaseContinue: '继续在线阅读',
|
||||||
backCover: '返回封面',
|
backCover: '返回封面',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -426,6 +426,8 @@ export const zh = {
|
|||||||
pwdNeed: "还差 {n} 位",
|
pwdNeed: "还差 {n} 位",
|
||||||
pwdOk: "可用 · 再长一点更安全",
|
pwdOk: "可用 · 再长一点更安全",
|
||||||
pwdStrong: "强度不错",
|
pwdStrong: "强度不错",
|
||||||
|
google: "使用 Google 登录",
|
||||||
|
googleFail: "Google 登录失败",
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
explore: "探索",
|
explore: "探索",
|
||||||
@ -567,6 +569,7 @@ export const zh = {
|
|||||||
draftHint: "资料草稿会自动保存在本机",
|
draftHint: "资料草稿会自动保存在本机",
|
||||||
saving: "保存中…",
|
saving: "保存中…",
|
||||||
vipActive: "✨ 你已是 VIP · 权益已生效",
|
vipActive: "✨ 你已是 VIP · 权益已生效",
|
||||||
|
payMethods: "目前支持支付宝 / 微信支付(中国大陆通道)",
|
||||||
},
|
},
|
||||||
live: {
|
live: {
|
||||||
loading: "加载直播间…",
|
loading: "加载直播间…",
|
||||||
@ -900,7 +903,8 @@ export const zh = {
|
|||||||
free2: "参与社区讨论与活动 RSVP",
|
free2: "参与社区讨论与活动 RSVP",
|
||||||
vip: "VIP 会员",
|
vip: "VIP 会员",
|
||||||
year: "年",
|
year: "年",
|
||||||
try: "开始匹配",
|
try: "完善资料开始",
|
||||||
|
payNote: "VIP 支付目前走支付宝 / 微信",
|
||||||
},
|
},
|
||||||
feedback: {
|
feedback: {
|
||||||
tag: "💬 FEEDBACK",
|
tag: "💬 FEEDBACK",
|
||||||
@ -1272,6 +1276,8 @@ export const zh = {
|
|||||||
submit: "订阅",
|
submit: "订阅",
|
||||||
ok: "订阅成功",
|
ok: "订阅成功",
|
||||||
fail: "订阅失败",
|
fail: "订阅失败",
|
||||||
|
invalidEmail: "请输入有效邮箱",
|
||||||
|
note: "先登记邮箱;邮件推送开通后会同步发送,不会假装已群发",
|
||||||
},
|
},
|
||||||
pwa: {
|
pwa: {
|
||||||
installHint: "可安装到主屏幕,离线也能打开计划入口",
|
installHint: "可安装到主屏幕,离线也能打开计划入口",
|
||||||
@ -1821,6 +1827,8 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
|||||||
pwdNeed: "{n} more characters needed",
|
pwdNeed: "{n} more characters needed",
|
||||||
pwdOk: "OK · a bit longer is safer",
|
pwdOk: "OK · a bit longer is safer",
|
||||||
pwdStrong: "Looking strong",
|
pwdStrong: "Looking strong",
|
||||||
|
google: "Continue with Google",
|
||||||
|
googleFail: "Google sign-in failed",
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
explore: "Explore",
|
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",
|
draftHint: "Profile draft autosaves on this device",
|
||||||
saving: "Saving…",
|
saving: "Saving…",
|
||||||
vipActive: "✨ You're VIP — perks are active",
|
vipActive: "✨ You're VIP — perks are active",
|
||||||
|
payMethods: "Checkout uses Alipay / WeChat Pay (China rails) for now",
|
||||||
},
|
},
|
||||||
live: {
|
live: {
|
||||||
loading: "Loading live room…",
|
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",
|
free2: "Discussions & event RSVPs",
|
||||||
vip: "VIP",
|
vip: "VIP",
|
||||||
year: "year",
|
year: "year",
|
||||||
try: "Start matching",
|
try: "Complete profile",
|
||||||
|
payNote: "VIP checkout currently uses Alipay / WeChat Pay",
|
||||||
},
|
},
|
||||||
feedback: {
|
feedback: {
|
||||||
tag: "💬 FEEDBACK",
|
tag: "💬 FEEDBACK",
|
||||||
@ -2667,6 +2677,8 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
|||||||
submit: "Subscribe",
|
submit: "Subscribe",
|
||||||
ok: "Subscribed",
|
ok: "Subscribed",
|
||||||
fail: "Could not subscribe",
|
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: {
|
pwa: {
|
||||||
installHint: "Install to home screen — open plan offline-ready",
|
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: "/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