401 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|