nomadweb/frontend/src/app/profile/page.tsx
eric a62cf057e8 Fix profile Connect stats TypeScript for mutual matches shape.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 06:53:48 -05:00

461 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}