s
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { CSSProperties } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { LocaleProvider } from "../../digital/context/LocaleContext";
|
||||
import type { Locale } from "../../digital/context/LocaleContext";
|
||||
import { getThemeConfig } from "@/config/digital";
|
||||
import { getDigitalVisualThemeConfig, getThemeConfig } from "@/config/digital";
|
||||
import { getThemeMessages } from "@/app/digital/lib/theme-data";
|
||||
import DigitalProviders from "@/app/digital/DigitalProviders";
|
||||
|
||||
@@ -33,11 +34,18 @@ export default async function LocaleLayout({
|
||||
}
|
||||
|
||||
const messages = await getThemeMessages(locale as Locale);
|
||||
const visualTheme = getDigitalVisualThemeConfig();
|
||||
|
||||
return (
|
||||
<LocaleProvider locale={locale as Locale} messages={messages}>
|
||||
<DigitalProviders>
|
||||
<div className="digital-scope">{children}</div>
|
||||
<div
|
||||
className={`digital-scope ${visualTheme.className}`}
|
||||
data-digital-visual-theme={visualTheme.id}
|
||||
style={visualTheme.cssVars as CSSProperties}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</DigitalProviders>
|
||||
</LocaleProvider>
|
||||
);
|
||||
|
||||
400
app/[locale]/digital/profile/ProfileContent.tsx
Normal file
400
app/[locale]/digital/profile/ProfileContent.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
21
app/[locale]/digital/profile/page.tsx
Normal file
21
app/[locale]/digital/profile/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import Header from "../../../digital/components/Header";
|
||||
import Footer from "../../../digital/components/Footer";
|
||||
import ProfileContent from "./ProfileContent";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "个人资料 | Profile",
|
||||
description: "数字游民指南账户中心,管理登录状态、VIP 权益和功能入口。",
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<ProfileContent />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user