Files
gitlab-instance-0a899031_cn…/app/[locale]/digital/profile/ProfileContent.tsx
root 9de2465793 s
2026-06-07 15:57:47 +08:00

401 lines
16 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useLocale, useTranslation } from "@/app/digital/i18n/navigation";
import AuthModal from "@/app/digital/components/AuthModal";
import DigitalThemeIcon, { type IconName } from "@/app/digital/components/DigitalThemeIcon";
import { getStoredToken, getStoredUser, pbLogout, pbSaveAuth } from "@/app/digital/lib/pocketbase";
import { useCrossSiteUrls } from "@/app/digital/lib/useCrossSiteUrls";
type ProfileUser = {
id: string;
email: string;
};
type AccountState = {
user: ProfileUser | null;
vip: boolean;
expiresAt: string | null;
};
type ActionItem = {
icon: IconName;
emoji: string;
title: string;
description: string;
href: string;
tag: string;
external?: boolean;
};
function getAvatarLetter(email: string): string {
const local = email.split("@")[0];
return (local || "?").slice(0, 1).toUpperCase();
}
function formatDate(value: string, locale: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(locale === "zh" ? "zh-CN" : "en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
function ActionCard({ item }: { item: ActionItem }) {
const content = (
<>
<div className="dn-icon-box flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-sky-50 text-sky-600 dark:bg-sky-900/30 dark:text-sky-300">
<DigitalThemeIcon name={item.icon} emoji={item.emoji} className="h-5 w-5 text-xl" />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center justify-between gap-3">
<h3 className="truncate text-base font-semibold text-slate-900 dark:text-slate-50">{item.title}</h3>
<span className="dn-status-pill shrink-0 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{item.tag}
</span>
</div>
<p className="mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400">{item.description}</p>
</div>
</>
);
const className =
"dn-action-card group flex min-h-[150px] items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 text-left shadow-sm transition-all hover:border-sky-300 hover:shadow-md dark:border-slate-700 dark:bg-slate-900 dark:hover:border-sky-600";
if (item.external) {
return (
<a href={item.href} target="_blank" rel="noopener noreferrer" className={className}>
{content}
</a>
);
}
return (
<Link href={item.href} className={className}>
{content}
</Link>
);
}
export default function ProfileContent() {
const locale = useLocale();
const { t } = useTranslation("profile");
const { vipUrl } = useCrossSiteUrls();
const [account, setAccount] = useState<AccountState>({
user: null,
vip: false,
expiresAt: null,
});
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [authOpen, setAuthOpen] = useState(false);
const refreshAccount = useCallback(async (manual = false) => {
if (manual) setRefreshing(true);
else setLoading(true);
try {
const meRes = await fetch("/api/digital/auth/me", {
credentials: "include",
cache: "no-store",
});
const meData = await meRes.json().catch(() => null);
let user = meData?.user?.id && meData?.user?.email
? { id: meData.user.id as string, email: meData.user.email as string }
: null;
if (!user) {
const storedUser = getStoredUser();
const storedToken = getStoredToken();
if (storedUser && storedToken) {
user = { id: storedUser.id, email: storedUser.email };
await fetch("/api/digital/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: storedToken, record: storedUser }),
credentials: "include",
}).catch(() => null);
} else {
setAccount({ user: null, vip: false, expiresAt: null });
return;
}
}
if (meData?.token) {
pbSaveAuth(meData.token, user);
}
const vipRes = await fetch(`/api/digital/vip/check?email=${encodeURIComponent(user.email)}`, {
credentials: "include",
cache: "no-store",
});
const vipData = await vipRes.json().catch(() => null);
setAccount({
user,
vip: vipData?.vip === true,
expiresAt: typeof vipData?.expires_at === "string" ? vipData.expires_at : null,
});
} catch {
const storedUser = getStoredUser();
setAccount({
user: storedUser ? { id: storedUser.id, email: storedUser.email } : null,
vip: false,
expiresAt: null,
});
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
refreshAccount();
}, [refreshAccount]);
const handleLogout = async () => {
await pbLogout();
setAccount({ user: null, vip: false, expiresAt: null });
window.dispatchEvent(new CustomEvent("auth:updated"));
};
const primaryActions = useMemo<ActionItem[]>(() => [
{
icon: "globe",
emoji: "🌐",
title: t("actions.community.title"),
description: t("actions.community.description"),
href: vipUrl,
tag: account.vip ? t("tags.active") : t("tags.apply"),
external: true,
},
{
icon: "course",
emoji: "🎓",
title: t("actions.course.title"),
description: t("actions.course.description"),
href: "/course",
tag: account.vip ? t("tags.unlocked") : t("tags.trial"),
},
{
icon: "book",
emoji: "📚",
title: t("actions.ebook.title"),
description: t("actions.ebook.description"),
href: "/ebook",
tag: t("tags.ready"),
},
{
icon: "phone",
emoji: "📱",
title: t("actions.app.title"),
description: t("actions.app.description"),
href: "/app",
tag: t("tags.download"),
},
], [account.vip, t, vipUrl]);
const workflowActions = useMemo<ActionItem[]>(() => [
{
icon: "compass",
emoji: "🧭",
title: t("workflow.roadmap.title"),
description: t("workflow.roadmap.description"),
href: "/#roadmap",
tag: t("tags.start"),
},
{
icon: "tool",
emoji: "🛠️",
title: t("workflow.tools.title"),
description: t("workflow.tools.description"),
href: "/#tools",
tag: t("tags.open"),
},
{
icon: "file",
emoji: "📄",
title: t("workflow.resources.title"),
description: t("workflow.resources.description"),
href: "/#resources",
tag: t("tags.curated"),
},
{
icon: "check",
emoji: "✅",
title: t("workflow.privacy.title"),
description: t("workflow.privacy.description"),
href: "/privacy",
tag: t("tags.policy"),
},
], [t]);
if (loading) {
return (
<section className="dn-profile-page dn-section min-h-[70vh] px-4 pt-32 sm:px-6 lg:px-8">
<div className="dn-container mx-auto max-w-5xl">
<div className="dn-card rounded-2xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900">
<div className="h-4 w-32 animate-pulse rounded-full bg-slate-200 dark:bg-slate-700" />
<div className="mt-5 h-10 w-2/3 animate-pulse rounded-lg bg-slate-200 dark:bg-slate-700" />
<div className="mt-4 h-4 w-full animate-pulse rounded-full bg-slate-100 dark:bg-slate-800" />
</div>
</div>
</section>
);
}
if (!account.user) {
return (
<section className="dn-profile-page dn-section min-h-[78vh] px-4 pt-32 sm:px-6 lg:px-8">
<div className="dn-container mx-auto max-w-4xl">
<div className="dn-account-hero rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm dark:border-slate-700 dark:bg-slate-900 sm:p-10">
<div className="dn-icon-box mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-50 text-sky-600 dark:bg-sky-900/30 dark:text-sky-300">
<DigitalThemeIcon name="user" emoji="👤" className="h-7 w-7 text-3xl" />
</div>
<p className="dn-badge mx-auto mt-6 inline-flex rounded-full border border-sky-100 bg-sky-50 px-4 py-1.5 text-sm font-semibold text-sky-700 dark:border-sky-900/50 dark:bg-sky-900/20 dark:text-sky-300">
{t("signedOutBadge")}
</p>
<h1 className="dn-section-title mt-4 text-3xl font-bold text-slate-950 dark:text-white sm:text-4xl">
{t("signedOutTitle")}
</h1>
<p className="mx-auto mt-4 max-w-2xl text-base leading-7 text-slate-500 dark:text-slate-400">
{t("signedOutDescription")}
</p>
<div className="mt-8 flex flex-col justify-center gap-3 sm:flex-row">
<button
type="button"
onClick={() => setAuthOpen(true)}
className="dn-button-primary inline-flex items-center justify-center rounded-full bg-sky-500 px-6 py-3 text-sm font-semibold text-white transition hover:bg-sky-600"
>
{t("loginCta")}
</button>
<Link
href="/"
className="dn-button-secondary inline-flex items-center justify-center rounded-full border border-slate-200 px-6 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
{t("homeCta")}
</Link>
</div>
</div>
</div>
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={() => refreshAccount(true)}
/>
</section>
);
}
const expiryText = account.vip
? account.expiresAt
? formatDate(account.expiresAt, locale)
: t("lifetime")
: t("notVip");
return (
<section className="dn-profile-page dn-section px-4 pb-16 pt-32 sm:px-6 lg:px-8">
<div className="dn-container mx-auto max-w-6xl">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="dn-account-hero rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900 sm:p-8">
<p className="dn-badge inline-flex rounded-full border border-sky-100 bg-sky-50 px-4 py-1.5 text-sm font-semibold text-sky-700 dark:border-sky-900/50 dark:bg-sky-900/20 dark:text-sky-300">
{t("eyebrow")}
</p>
<h1 className="dn-section-title mt-5 text-3xl font-bold text-slate-950 dark:text-white sm:text-4xl">
{t("title")}
</h1>
<p className="mt-4 max-w-2xl text-base leading-7 text-slate-500 dark:text-slate-400">
{t("description")}
</p>
<div className="mt-7 flex flex-wrap gap-3">
<span className="dn-status-pill rounded-full bg-emerald-50 px-3 py-1.5 text-sm font-semibold text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300">
{t("loggedIn")}
</span>
<span className="dn-status-pill rounded-full bg-amber-50 px-3 py-1.5 text-sm font-semibold text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
{account.vip ? t("vipActive") : t("vipInactive")}
</span>
<span className="dn-status-pill rounded-full bg-slate-100 px-3 py-1.5 text-sm font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{t("expiry")}: {expiryText}
</span>
</div>
</div>
<aside
id="account"
className="dn-account-panel rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900"
>
<div className="flex items-center gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-sky-500 text-xl font-bold text-white">
{getAvatarLetter(account.user.email)}
</div>
<div className="min-w-0">
<p className="truncate text-base font-semibold text-slate-900 dark:text-white">{account.user.email}</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t("accountId")}</p>
</div>
</div>
<dl className="mt-6 space-y-4 text-sm">
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-500 dark:text-slate-400">{t("loginStatus")}</dt>
<dd className="font-semibold text-emerald-600 dark:text-emerald-300">{t("normal")}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-500 dark:text-slate-400">{t("vipStatus")}</dt>
<dd className="font-semibold text-slate-900 dark:text-white">{account.vip ? t("enabled") : t("disabled")}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-500 dark:text-slate-400">{t("vipExpiry")}</dt>
<dd className="max-w-[170px] truncate font-semibold text-slate-900 dark:text-white">{expiryText}</dd>
</div>
</dl>
<div className="mt-6 grid gap-3">
<button
type="button"
onClick={() => refreshAccount(true)}
disabled={refreshing}
className="dn-button-secondary inline-flex items-center justify-center rounded-full border border-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:opacity-60 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
{refreshing ? t("refreshing") : t("refresh")}
</button>
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center justify-center rounded-full border border-red-100 px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-50 dark:border-red-900/40 dark:text-red-300 dark:hover:bg-red-950/30"
>
{t("logout")}
</button>
</div>
</aside>
</div>
<section className="mt-10">
<div className="mb-4 flex items-end justify-between gap-4">
<div>
<p className="text-sm font-semibold text-sky-600 dark:text-sky-300">{t("accessEyebrow")}</p>
<h2 className="dn-section-title mt-2 text-2xl font-bold text-slate-950 dark:text-white">{t("accessTitle")}</h2>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{primaryActions.map((item) => (
<ActionCard key={item.title} item={item} />
))}
</div>
</section>
<section id="resources" className="mt-10">
<div className="mb-4">
<p className="text-sm font-semibold text-sky-600 dark:text-sky-300">{t("workflowEyebrow")}</p>
<h2 className="dn-section-title mt-2 text-2xl font-bold text-slate-950 dark:text-white">{t("workflowTitle")}</h2>
</div>
<div className="grid gap-4 md:grid-cols-2">
{workflowActions.map((item) => (
<ActionCard key={item.title} item={item} />
))}
</div>
</section>
</div>
</section>
);
}