461 lines
18 KiB
TypeScript
461 lines
18 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { useAuth } from "@/lib/auth";
|
||
import { api } from "@/lib/api";
|
||
import type { Destination, Meetup, ProfileStats, TripItem } from "@/lib/types";
|
||
import SiteShell from "@/components/SiteShell";
|
||
import FavoriteButton from "@/components/FavoriteButton";
|
||
import RecentlyViewed from "@/components/RecentlyViewed";
|
||
import NomadDigest from "@/components/NomadDigest";
|
||
import RingNext from "@/components/RingNext";
|
||
import { useRingSteps } from "@/lib/rings";
|
||
import { useI18n } from "@/lib/i18n";
|
||
import type { Dict } from "@/lib/i18n";
|
||
import { loadTrip } from "@/lib/tripStorage";
|
||
import { loadPlanMeta, MOVE_CHECKLIST, type PlanMeta } from "@/lib/planMeta";
|
||
import { PLAN_SYNC_EVENT } from "@/lib/planSync";
|
||
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
||
import { useToast } from "@/lib/toast";
|
||
import { buildMeetupIcs, downloadMeetupIcs } from "@/lib/meetupIcs";
|
||
|
||
interface Badge {
|
||
emoji: string;
|
||
label: string;
|
||
desc: string;
|
||
earned: boolean;
|
||
}
|
||
|
||
function getBadges(
|
||
p: Dict["profile"],
|
||
stats: ProfileStats | null,
|
||
trip: TripItem[],
|
||
favCount: number,
|
||
readiness: number
|
||
): Badge[] {
|
||
const tripMonths = trip.reduce((s, item) => s + item.months, 0);
|
||
return [
|
||
{ emoji: "🌱", label: p.badgeNewbie, desc: p.badgeNewbieDesc, earned: true },
|
||
{ emoji: "❤️", label: p.badgeCollector, desc: p.badgeCollectorDesc, earned: favCount >= 3 },
|
||
{ emoji: "🗺️", label: p.badgePlanner, desc: p.badgePlannerDesc, earned: trip.length >= 3 },
|
||
{ emoji: "📅", label: p.badgeLongStay, desc: p.badgeLongStayDesc, earned: tripMonths >= 6 },
|
||
{ emoji: "✅", label: p.badgeReady, desc: p.badgeReadyDesc, earned: readiness >= 50 },
|
||
{
|
||
emoji: "🌍",
|
||
label: p.badgeGlobal,
|
||
desc: p.badgeGlobalDesc,
|
||
earned: (stats?.destinations_explored ?? 0) >= 5,
|
||
},
|
||
];
|
||
}
|
||
|
||
export default function ProfilePage() {
|
||
const { user, token, favorites, logout, planSyncStatus, refreshUser } = useAuth();
|
||
const router = useRouter();
|
||
const { toast } = useToast();
|
||
const { t } = useI18n();
|
||
const rings = useRingSteps();
|
||
const [stats, setStats] = useState<ProfileStats | null>(null);
|
||
const [favoriteDests, setFavoriteDests] = useState<Destination[]>([]);
|
||
const [rsvps, setRsvps] = useState<Meetup[]>([]);
|
||
const [connect, setConnect] = useState({ likes: 0, matches: 0, chats: 0 });
|
||
const [trip, setTrip] = useState<TripItem[]>([]);
|
||
const [meta, setMeta] = useState<PlanMeta | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||
const [vip, setVip] = useState(false);
|
||
|
||
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
e.target.value = "";
|
||
if (!file || !token) return;
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
toast(t.profile.avatarTooBig, "error");
|
||
return;
|
||
}
|
||
setUploadingAvatar(true);
|
||
try {
|
||
const uploaded = await api.uploadMedia(token, file, "avatar");
|
||
await api.updateProfile(token, { avatar: uploaded.url });
|
||
await refreshUser?.();
|
||
toast(t.profile.avatarOk, "success");
|
||
} catch (err) {
|
||
toast(err instanceof Error ? err.message : t.profile.avatarFail, "error");
|
||
} finally {
|
||
setUploadingAvatar(false);
|
||
}
|
||
};
|
||
|
||
const refreshPlan = () => {
|
||
setTrip(loadTrip<TripItem>());
|
||
setMeta(loadPlanMeta());
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!token) {
|
||
router.push("/login?next=/profile");
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
Promise.all([
|
||
api.getProfileStats(token),
|
||
favorites.length > 0 ? api.getFavoriteDestinations(token) : Promise.resolve([]),
|
||
api.getVipStatus(token).catch(() => ({ vip: false, expires_at: 0 })),
|
||
Promise.all([
|
||
api.getMyRsvps(token).catch(() => ({ ids: [] as string[] })),
|
||
api.getMeetups().catch(() => [] as Meetup[]),
|
||
]).then(([mine, all]) => {
|
||
const idSet = new Set(mine.ids || []);
|
||
return all.filter((m) => idSet.has(m.id)).slice(0, 6);
|
||
}),
|
||
Promise.all([
|
||
api.getMatchLikesReceived(token).catch(() => []),
|
||
api.getMutualMatches(token).catch(() => ({ items: [] })),
|
||
api.getConversations(token).catch(() => []),
|
||
]).then(([likes, matches, chats]) => ({
|
||
likes: likes.length,
|
||
matches: matches.items?.length ?? 0,
|
||
chats: chats.length,
|
||
})),
|
||
]).then(([s, f, v, myMeetups, connectStats]) => {
|
||
setStats(s);
|
||
setFavoriteDests(f);
|
||
setVip(Boolean(v.vip));
|
||
setRsvps(myMeetups);
|
||
setConnect(connectStats);
|
||
}).catch(() => {}).finally(() => setLoading(false));
|
||
|
||
refreshPlan();
|
||
window.addEventListener(PLAN_SYNC_EVENT, refreshPlan);
|
||
return () => window.removeEventListener(PLAN_SYNC_EVENT, refreshPlan);
|
||
}, [token, favorites, router]);
|
||
|
||
const totalMonths = trip.reduce((s, item) => s + item.months, 0);
|
||
const totalCost = trip.reduce((s, item) => s + item.cost * item.months, 0);
|
||
const avgMonth = totalMonths > 0 ? Math.round(totalCost / totalMonths) : 0;
|
||
const checked = MOVE_CHECKLIST.filter((c) => meta?.checklist[c.id]).length;
|
||
const readiness = Math.round((checked / MOVE_CHECKLIST.length) * 100);
|
||
const budgetOk = !meta || meta.monthlyBudget <= 0 || avgMonth <= 0 || avgMonth <= meta.monthlyBudget;
|
||
|
||
const badges = useMemo(
|
||
() => getBadges(t.profile, stats, trip, favoriteDests.length, readiness),
|
||
[t.profile, stats, trip, favoriteDests.length, readiness]
|
||
);
|
||
const earnedCount = badges.filter((b) => b.earned).length;
|
||
|
||
const favsNotInTrip = favoriteDests.filter((d) => !trip.some((item) => item.slug === d.slug));
|
||
|
||
const addFavsToPlan = () => {
|
||
if (favsNotInTrip.length === 0) {
|
||
toast(t.profile.favsAlready, "info", { href: "/plan", label: t.profile.openPlan });
|
||
return;
|
||
}
|
||
const { added } = mergeDestinationsIntoTrip(favsNotInTrip, 1);
|
||
refreshPlan();
|
||
toast(t.profile.favsAdded.replace("{n}", String(added)), "success", {
|
||
href: "/plan",
|
||
label: t.profile.openPlan,
|
||
});
|
||
};
|
||
|
||
const planTitle =
|
||
meta?.title && meta.title !== "我的旅居计划" ? meta.title : t.plan.defaultTitle;
|
||
|
||
if (!user) return null;
|
||
|
||
return (
|
||
<SiteShell>
|
||
<div className="profile-page">
|
||
<div className="profile-header">
|
||
<div className="profile-avatar-wrap">
|
||
{user.avatar?.startsWith("http") ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={user.avatar} alt="" className="profile-avatar-img" />
|
||
) : (
|
||
<div className="profile-avatar">{user.avatar || "🧑💻"}</div>
|
||
)}
|
||
<label className="profile-avatar-upload">
|
||
{uploadingAvatar ? t.profile.uploading : t.profile.changeAvatar}
|
||
<input
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||
hidden
|
||
disabled={uploadingAvatar}
|
||
onChange={onAvatarChange}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className="profile-info">
|
||
<h1>{user.name}</h1>
|
||
<p>{user.email}</p>
|
||
<div className="profile-badges-row">
|
||
{stats && <span className="profile-level">{stats.nomad_level}</span>}
|
||
{vip && <span className="profile-vip-badge">✨ VIP</span>}
|
||
</div>
|
||
<span className={`plan-sync-pill ${planSyncStatus}`} style={{ marginTop: 8, display: "inline-flex" }}>
|
||
{planSyncStatus === "syncing" && t.profile.syncing}
|
||
{planSyncStatus === "synced" && t.profile.synced}
|
||
{planSyncStatus === "offline" && t.profile.offline}
|
||
{planSyncStatus === "idle" && t.profile.loggedIn}
|
||
</span>
|
||
</div>
|
||
<button className="btn btn-ghost" onClick={() => { logout(); router.push("/"); }}>
|
||
{t.profile.logout}
|
||
</button>
|
||
</div>
|
||
|
||
{stats && (
|
||
<div className="profile-stats-grid">
|
||
<div className="profile-stat-card">
|
||
<span className="stat-emoji">❤️</span>
|
||
<strong>{stats.favorites_count}</strong>
|
||
<span>{t.profile.statFavs}</span>
|
||
</div>
|
||
<div className="profile-stat-card">
|
||
<span className="stat-emoji">🗓️</span>
|
||
<strong>{trip.length}</strong>
|
||
<span>{t.profile.statCities}</span>
|
||
</div>
|
||
<div className="profile-stat-card">
|
||
<span className="stat-emoji">✅</span>
|
||
<strong>{readiness}%</strong>
|
||
<span>{t.profile.statReady}</span>
|
||
</div>
|
||
<div className="profile-stat-card">
|
||
<span className="stat-emoji">🏅</span>
|
||
<strong>{earnedCount}/{badges.length}</strong>
|
||
<span>{t.profile.statBadges}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="profile-connect-block">
|
||
<h2 className="profile-connect-title">{t.profile.connectStrip}</h2>
|
||
<div className="profile-stats-grid profile-connect-stats">
|
||
<Link href="/dating/likes" className="profile-stat-card profile-stat-link">
|
||
<span className="stat-emoji">💕</span>
|
||
<strong>{connect.likes}</strong>
|
||
<span>{t.profile.statLikes}</span>
|
||
</Link>
|
||
<Link href="/dating" className="profile-stat-card profile-stat-link">
|
||
<span className="stat-emoji">✨</span>
|
||
<strong>{connect.matches}</strong>
|
||
<span>{t.profile.statMatches}</span>
|
||
</Link>
|
||
<Link href="/chat" className="profile-stat-card profile-stat-link">
|
||
<span className="stat-emoji">✉️</span>
|
||
<strong>{connect.chats}</strong>
|
||
<span>{t.profile.statChats}</span>
|
||
</Link>
|
||
<Link href="/meetups" className="profile-stat-card profile-stat-link">
|
||
<span className="stat-emoji">🎉</span>
|
||
<strong>{rsvps.length}</strong>
|
||
<span>{t.profile.statRsvps}</span>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
<NomadDigest rsvpCount={rsvps.length} upcoming={rsvps} />
|
||
|
||
<section className="profile-trip">
|
||
<div className="profile-section-head">
|
||
<h2>🗓️ {planTitle}</h2>
|
||
<Link href="/plan" className="btn btn-primary btn-sm">{t.profile.openPlan}</Link>
|
||
</div>
|
||
{trip.length === 0 ? (
|
||
<div className="profile-empty">
|
||
<span style={{ fontSize: "2.5rem" }}>🗺️</span>
|
||
<p>{t.profile.tripEmpty}</p>
|
||
<Link href="/plan" className="btn btn-primary">{t.profile.goPlan}</Link>
|
||
</div>
|
||
) : (
|
||
<div className="profile-trip-list">
|
||
{(meta?.startMonth || meta?.monthlyBudget) && (
|
||
<div className="profile-plan-meta">
|
||
{meta?.startMonth && (
|
||
<span>{t.profile.startLabel.replace("{month}", meta.startMonth)}</span>
|
||
)}
|
||
{meta && meta.monthlyBudget > 0 && (
|
||
<span className={!budgetOk ? "plan-warn-text" : undefined}>
|
||
{t.profile.budgetLabel.replace("{budget}", meta.monthlyBudget.toLocaleString())}
|
||
{avgMonth > 0 &&
|
||
t.profile.avgMonthLabel.replace("{avg}", avgMonth.toLocaleString())}
|
||
</span>
|
||
)}
|
||
<span>
|
||
{t.profile.readyCount
|
||
.replace("{checked}", String(checked))
|
||
.replace("{total}", String(MOVE_CHECKLIST.length))}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{trip.map((item, i) => (
|
||
<div key={item.slug} className="profile-trip-item">
|
||
<span className="trip-item-num">{i + 1}</span>
|
||
<span>{item.emoji}</span>
|
||
<div>
|
||
<strong>{item.name}, {item.country}</strong>
|
||
<span>
|
||
{t.profile.monthsCost
|
||
.replace("{months}", String(item.months))
|
||
.replace("{cost}", (item.cost * item.months).toLocaleString())}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div className="profile-trip-total">
|
||
{t.profile.totalLine}{" "}
|
||
<strong>
|
||
{totalMonths} {t.profile.monthsUnit}
|
||
</strong>{" "}
|
||
·
|
||
<strong className="gradient-text"> ¥{totalCost.toLocaleString()}</strong>
|
||
</div>
|
||
<div className="profile-trip-actions">
|
||
{trip.length >= 2 && (
|
||
<Link
|
||
href={`/compare?cities=${trip.map((item) => item.slug).slice(0, 4).join(",")}`}
|
||
className="btn btn-ghost btn-sm"
|
||
>
|
||
{t.profile.compareTrip}
|
||
</Link>
|
||
)}
|
||
<Link href="/plan" className="btn btn-ghost btn-sm">{t.profile.keepEditing}</Link>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="profile-badges">
|
||
<h2>{t.profile.achievements}</h2>
|
||
<div className="badge-grid">
|
||
{badges.map((b) => (
|
||
<div key={b.label} className={`badge-card${b.earned ? " earned" : " locked"}`} title={b.desc}>
|
||
<span className="badge-emoji">{b.earned ? b.emoji : "🔒"}</span>
|
||
<strong>{b.label}</strong>
|
||
<small>{b.desc}</small>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="profile-favorites">
|
||
<div className="profile-section-head">
|
||
<h2>{t.profile.myFavs}</h2>
|
||
{favsNotInTrip.length > 0 && (
|
||
<button type="button" className="btn btn-ghost btn-sm" onClick={addFavsToPlan}>
|
||
{t.profile.favsToPlan.replace("{n}", String(favsNotInTrip.length))}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{loading ? (
|
||
<p className="profile-loading">{t.profile.loading}</p>
|
||
) : favoriteDests.length === 0 ? (
|
||
<div className="profile-empty">
|
||
<span style={{ fontSize: "3rem" }}>🌍</span>
|
||
<p>{t.profile.favsEmpty}</p>
|
||
<Link href="/#destinations" className="btn btn-primary">{t.profile.goExplore}</Link>
|
||
</div>
|
||
) : (
|
||
<div className="profile-fav-grid">
|
||
{favoriteDests.map((d) => (
|
||
<div key={d.slug} className="profile-fav-card">
|
||
<Link href={`/destinations/${d.slug}`}>
|
||
<span className="fav-emoji">{d.emoji}</span>
|
||
<h3>{d.name}, {d.country}</h3>
|
||
<p>
|
||
💰 ¥{d.cost.toLocaleString()}
|
||
{t.profile.perMonth} · ⭐ {d.rating}
|
||
</p>
|
||
</Link>
|
||
<FavoriteButton slug={d.slug} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="profile-rsvps">
|
||
<div className="profile-section-head">
|
||
<h2>🎉 {t.profile.myRsvps}</h2>
|
||
<div className="profile-section-actions">
|
||
{rsvps.length > 0 && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost btn-sm"
|
||
onClick={() => {
|
||
const ics = buildMeetupIcs(rsvps, "nomadro RSVPs");
|
||
if (!ics) {
|
||
toast(t.meetups.exportCalFail, "info");
|
||
return;
|
||
}
|
||
downloadMeetupIcs("nomadro-rsvps.ics", ics);
|
||
toast(t.meetups.exportCalDone);
|
||
}}
|
||
>
|
||
📅 {t.profile.exportRsvps}
|
||
</button>
|
||
)}
|
||
<Link href="/meetups" className="btn btn-ghost btn-sm">{t.nav.meetups}</Link>
|
||
</div>
|
||
</div>
|
||
{rsvps.length === 0 ? (
|
||
<div className="profile-empty">
|
||
<p>{t.profile.rsvpEmpty}</p>
|
||
<Link href="/meetups" className="btn btn-primary btn-sm">{t.profile.goMeetups}</Link>
|
||
</div>
|
||
) : (
|
||
<div className="profile-rsvp-list">
|
||
{rsvps.map((m) => (
|
||
<div key={m.id} className="profile-rsvp-card">
|
||
<span aria-hidden>{m.emoji}</span>
|
||
<div>
|
||
<strong>{m.title}</strong>
|
||
<p>
|
||
{m.city} · {m.date} {m.time}
|
||
</p>
|
||
</div>
|
||
{(m.mode === "online" || m.mode === "hybrid") && (
|
||
<Link href={`/meetups/${m.id}/live`} className="btn btn-sm btn-primary">
|
||
{t.profile.openLive}
|
||
</Link>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<RecentlyViewed compact />
|
||
|
||
<section className="profile-quick">
|
||
<h2>{t.profile.quickLinks}</h2>
|
||
<div className="profile-quick-grid">
|
||
<Link href="/#destinations" className="quick-card">{t.profile.qDest}</Link>
|
||
<Link href="/plan" className="quick-card">{t.profile.qPlan}</Link>
|
||
<Link href="/compare" className="quick-card">{t.profile.qCompare}</Link>
|
||
<Link href="/next-stop" className="quick-card">{t.profile.qNext}</Link>
|
||
<Link href="/meetups" className="quick-card">{t.profile.qMeetups}</Link>
|
||
<Link href="/dating" className="quick-card">{t.profile.qDating}</Link>
|
||
<Link href="/chat" className="quick-card">{t.profile.qChat}</Link>
|
||
<Link href="/notifications" className="quick-card">{t.profile.qNotif}</Link>
|
||
<Link href="/join" className="quick-card">{vip ? t.profile.qVipMember : t.profile.qVip}</Link>
|
||
<Link href="/digital" className="quick-card">{t.profile.qDigital}</Link>
|
||
<Link href="/ai" className="quick-card">{t.profile.qAi}</Link>
|
||
<Link href="/tools" className="quick-card">{t.profile.qTools}</Link>
|
||
</div>
|
||
</section>
|
||
|
||
<RingNext
|
||
steps={[
|
||
...rings.afterDiscover,
|
||
{ href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc },
|
||
].slice(0, 3)}
|
||
/>
|
||
</div>
|
||
</SiteShell>
|
||
);
|
||
}
|