diff --git a/apps/database/src/schema/index.ts b/apps/database/src/schema/index.ts index 388f1f5..393f68d 100644 --- a/apps/database/src/schema/index.ts +++ b/apps/database/src/schema/index.ts @@ -16,15 +16,16 @@ import { // ── Enums ───────────────────────────────────────────────── export const eventTagEnum = pgEnum("event_tag", [ - "free food", - "career", + "free-food", + "career-recruiting", "research", + "stem", "academics", "tech", "entrepreneurship", - "politics", - "visual arts", - "performing arts", + "politics-policy", + "visual-arts", + "performing-arts", "literature", "culture", "music", @@ -32,12 +33,11 @@ export const eventTagEnum = pgEnum("event_tag", [ "athletics", "religion", "sustainability", - "outdoors", - "wellness", - "community service", - "speaker event", - "social event", - "stem", + "outdoor-adventure", + "wellness-self-care", + "community-service", + "speaker-event", + "social-event", ]); export const campusRegionEnum = pgEnum("campus_region", [ diff --git a/apps/web/src/actions/users.ts b/apps/web/src/actions/users.ts index 51b04e2..9c41f3d 100644 --- a/apps/web/src/actions/users.ts +++ b/apps/web/src/actions/users.ts @@ -12,10 +12,53 @@ import { import { revalidatePath } from "next/cache"; import { auth } from "~/auth"; -// Derived from the DB schema so these can never drift from the pgEnum values +const LEGACY_INTEREST_TAG_ALIASES: Record = { + career: "career-recruiting", + academic: "research", + academics: "academics", + tech: "tech", + political: "politics-policy", + politics: "politics-policy", + art: "visual-arts", + visual: "visual-arts", + performance: "performing-arts", + performing: "performing-arts", + cultural: "culture", + culture: "culture", + sports: "athletics", + athletic: "athletics", + religious: "religion", + religion: "religion", + outdoor: "outdoor-adventure", + outdoors: "outdoor-adventure", + wellness: "wellness-self-care", + speaker: "speaker-event", + social: "social-event", + "free food": "free-food", + "free-food": "free-food", + "community service": "community-service", + "community-service": "community-service", + "politics policy": "politics-policy", + "visual arts": "visual-arts", + "performing arts": "performing-arts", + "wellness self care": "wellness-self-care", + "speaker event": "speaker-event", + "social event": "social-event", + stem: "stem", +}; + type InterestTag = (typeof eventTagEnum.enumValues)[number]; type CampusRegion = (typeof campusRegionEnum.enumValues)[number]; +function normalizeInterestTag(tag: string): string { + const normalized = tag.trim().toLowerCase().replace(/\s+/g, "-"); + return LEGACY_INTEREST_TAG_ALIASES[normalized] ?? LEGACY_INTEREST_TAG_ALIASES[tag] ?? normalized; +} + +function dedupeInterests(values: string[]): string[] { + return [...new Set(values.map((value) => normalizeInterestTag(value)).filter(Boolean))]; +} + export async function completeOnboarding(data: { interests: string[]; classYear: string; @@ -28,7 +71,6 @@ export async function completeOnboarding(data: { const userId = session.user.id; - // Update user profile await db .update(users) .set({ @@ -40,18 +82,17 @@ export async function completeOnboarding(data: { }) .where(eq(users.id, userId)); - // Insert interests if (data.interests.length > 0) { + const normalizedInterests = dedupeInterests(data.interests); await db.delete(userInterests).where(eq(userInterests.userId, userId)); await db.insert(userInterests).values( - data.interests.map((tag) => ({ + normalizedInterests.map((tag) => ({ userId, tag: tag as InterestTag, })), ); } - // Insert regions if (data.regions.length > 0) { await db.delete(userRegions).where(eq(userRegions.userId, userId)); await db.insert(userRegions).values( @@ -129,6 +170,8 @@ export async function getUserProfile(): Promise { .from(userInterests) .where(eq(userInterests.userId, user.id)); + const normalizedInterests = dedupeInterests(interests.map(({ tag }) => tag)); + const regions = await db .select({ region: userRegions.region }) .from(userRegions) @@ -143,7 +186,7 @@ export async function getUserProfile(): Promise { major: user.major, avatarUrl: user.avatarUrl, isOrgLeader: user.isOrgLeader, - interests: interests.map((i) => i.tag), + interests: normalizedInterests, regions: regions.map((r) => r.region), }; } @@ -169,10 +212,11 @@ export async function updateProfile(data: { .where(eq(users.id, userId)); if (data.interests) { + const normalizedInterests = dedupeInterests(data.interests); await db.delete(userInterests).where(eq(userInterests.userId, userId)); - if (data.interests.length > 0) { + if (normalizedInterests.length > 0) { await db.insert(userInterests).values( - data.interests.map((tag) => ({ + normalizedInterests.map((tag) => ({ userId, tag: tag as InterestTag, })), diff --git a/apps/web/src/app/(app)/settings/settings-client.tsx b/apps/web/src/app/(app)/settings/settings-client.tsx index 2f02c1d..00000d4 100644 --- a/apps/web/src/app/(app)/settings/settings-client.tsx +++ b/apps/web/src/app/(app)/settings/settings-client.tsx @@ -4,6 +4,7 @@ import { ArrowLeft, ExternalLink, Pencil, Search, X } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useRef, useState, useTransition } from "react"; +import { toast } from "sonner"; import type { FriendProfile } from "~/actions/friends"; import { getPresignedUploadUrl } from "~/actions/upload"; import { type UserProfile, updateAvatar, updateProfile } from "~/actions/users"; @@ -15,40 +16,80 @@ import { PageHeading, PageShell, SectionHeading } from "~/components/layout/page import { Button } from "~/components/ui/button"; const INTEREST_TAGS = [ - { id: "free food", label: "free food" }, - { id: "tech", label: "technology" }, - { id: "stem", label: "science and engineering" }, - { id: "visual arts", label: "visual arts" }, - { id: "wellness", label: "fitness & health" }, - { id: "academics", label: "academics" }, - { id: "research", label: "research" }, - { id: "career", label: "career" }, - { id: "entrepreneurship", label: "entrepreneurship" }, - { id: "music", label: "music" }, - { id: "social event", label: "social" }, - { id: "athletics", label: "sports" }, - { id: "performing arts", label: "performing arts" }, - { id: "culture", label: "culture" }, - { id: "literature", label: "literature" }, - { id: "community service", label: "service" }, - { id: "religion", label: "religion" }, - { id: "politics", label: "politics" }, - { id: "gaming", label: "gaming" }, - { id: "outdoors", label: "outdoors" }, - { id: "sustainability", label: "sustainability" }, - { id: "speaker event", label: "speaker" }, -]; - -const SUGGESTION_TAGS = [ - "tech talk", - "Jane Street", - "consulting", - "internship", - "Citadel", - "Lockheed Martin", - "free merch", - "Bain & Company", -]; + { id: "free-food", label: "Free Food" }, + { id: "career-recruiting", label: "Career & Recruiting" }, + { id: "research", label: "Research" }, + { id: "stem", label: "STEM" }, + { id: "academics", label: "Academics" }, + { id: "tech", label: "Tech" }, + { id: "entrepreneurship", label: "Entrepreneurship" }, + { id: "politics-policy", label: "Politics & Policy" }, + { id: "visual-arts", label: "Visual Arts" }, + { id: "performing-arts", label: "Performing Arts" }, + { id: "literature", label: "Literature" }, + { id: "culture", label: "Culture" }, + { id: "music", label: "Music" }, + { id: "gaming", label: "Gaming" }, + { id: "athletics", label: "Athletics" }, + { id: "religion", label: "Religion" }, + { id: "sustainability", label: "Sustainability" }, + { id: "outdoor-adventure", label: "Outdoor & Adventure" }, + { id: "wellness-self-care", label: "Wellness & Self-Care" }, + { id: "community-service", label: "Community Service" }, + { id: "speaker-event", label: "Speaker Event" }, + { id: "social-event", label: "Social Event" }, +] as const; + +const LEGACY_INTEREST_ALIASES: Record = { + career: "career-recruiting", + careerrecruiting: "career-recruiting", + academic: "research", + academics: "academics", + tech: "tech", + political: "politics-policy", + politics: "politics-policy", + art: "visual-arts", + visual: "visual-arts", + performance: "performing-arts", + performing: "performing-arts", + cultural: "culture", + culture: "culture", + sports: "athletics", + athletic: "athletics", + religious: "religion", + religion: "religion", + outdoor: "outdoor-adventure", + outdoors: "outdoor-adventure", + wellness: "wellness-self-care", + speaker: "speaker-event", + social: "social-event", + "free-food": "free-food", + "free food": "free-food", + "community-service": "community-service", + "community service": "community-service", + "politics-policy": "politics-policy", + "politics policy": "politics-policy", + "visual-arts": "visual-arts", + "visual arts": "visual-arts", + "performing-arts": "performing-arts", + "performing arts": "performing-arts", + "wellness-self-care": "wellness-self-care", + "wellness self care": "wellness-self-care", + "speaker-event": "speaker-event", + "speaker event": "speaker-event", + "social-event": "social-event", + "social event": "social-event", + stem: "stem", +}; + +function normalizeInterestValue(tag: string): string { + const normalized = tag.trim().toLowerCase().replace(/\s+/g, "-"); + return LEGACY_INTEREST_ALIASES[normalized] ?? normalized; +} + +function dedupeInterestValues(values: string[]): string[] { + return [...new Set(values.map((value) => normalizeInterestValue(value)).filter(Boolean))]; +} const CLASS_YEARS = ["2025", "2026", "2027", "2028", "2029", "Grad"]; @@ -57,7 +98,6 @@ interface SettingsClientProps { friends: FriendProfile[]; } -/** Single friend row — was duplicated verbatim in both Friends sections. */ function FriendRow({ friend }: { friend: FriendProfile }) { return (
@@ -91,37 +131,69 @@ function FriendRow({ friend }: { friend: FriendProfile }) { export function SettingsClient({ profile, friends }: SettingsClientProps) { const router = useRouter(); const [isPending, startTransition] = useTransition(); + const initialClassYear = profile.classYear ?? ""; + const initialMajor = profile.major ?? ""; + const initialInterests = dedupeInterestValues(profile.interests); + const initialAvatar = profile.avatarUrl; - const [classYear, setClassYear] = useState(profile.classYear ?? ""); - const [major, setMajor] = useState(profile.major ?? ""); + const [classYear, setClassYear] = useState(initialClassYear); + const [major, setMajor] = useState(initialMajor); const [isOrgLeader, setIsOrgLeader] = useState(profile.isOrgLeader); - const [interests, setInterests] = useState(profile.interests); + const [interests, setInterests] = useState(initialInterests); const [friendSearch, setFriendSearch] = useState(""); const [orgSearch, setOrgSearch] = useState(""); const [tagSearch, setTagSearch] = useState(""); const toggleInterest = (id: string) => { - setInterests((prev) => (prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id])); + setInterests((prev) => { + const next = prev.includes(id) ? prev.filter((interest) => interest !== id) : [...prev, id]; + return dedupeInterestValues(next); + }); }; + const interestsChanged = + interests.length !== initialInterests.length || + interests.some((interest) => !initialInterests.includes(interest)); + + const hasChanges = + classYear !== initialClassYear || + major !== initialMajor || + isOrgLeader !== profile.isOrgLeader || + interestsChanged; + const handleSave = () => { startTransition(async () => { - await updateProfile({ - classYear, - major, - isOrgLeader, - interests, - regions: [], - }); - router.push("/explore"); + try { + const sanitizedInterests = dedupeInterestValues(interests); + await updateProfile({ + classYear, + major, + isOrgLeader, + interests: sanitizedInterests, + regions: profile.regions, + }); + setInterests(sanitizedInterests); + toast.success("Settings saved"); + } catch { + toast.error("Could not save settings. Please try again."); + } }); }; + const handleCancel = () => { + setClassYear(initialClassYear); + setMajor(initialMajor); + setIsOrgLeader(profile.isOrgLeader); + setInterests(initialInterests); + setAvatarPreview(initialAvatar); + setTagSearch(""); + toast.message("Changes discarded"); + }; + const avatarInputRef = useRef(null); - const [avatarPreview, setAvatarPreview] = useState(profile.avatarUrl); + const [avatarPreview, setAvatarPreview] = useState(initialAvatar); const handleAvatarUpload = async (file: File) => { - // Preview immediately const reader = new FileReader(); reader.onload = (e) => setAvatarPreview(e.target?.result as string); reader.readAsDataURL(file); @@ -135,12 +207,21 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) { }); await fetch(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } }); await updateAvatar(publicUrl); + toast.success("Profile photo updated"); } catch (err) { console.error("Avatar upload failed:", err); setAvatarPreview(profile.avatarUrl); + toast.error("Could not upload avatar. Please try another image."); } }; + const filteredTags = INTEREST_TAGS.filter( + (tag) => + !tagSearch || + tag.label.toLowerCase().includes(tagSearch.toLowerCase()) || + tag.id.toLowerCase().includes(tagSearch.toLowerCase()), + ); + const filteredFriends = friends.filter( (f) => !friendSearch || @@ -150,17 +231,26 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) { return ( - {/* Top bar — pr reserves space so buttons don't overlap the TopBar notification/avatar */}
- -
@@ -168,11 +258,9 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) { My Account - {/* ═══ Personal Info ═══ */}
Personal Info - {/* Avatar */}
{avatarPreview ? ( @@ -207,7 +295,6 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) { />
- {/* Name + Class Year inline */}
@@ -217,6 +304,7 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) {
+ setMajor(e.target.value)} + placeholder="e.g. Computer Science" + className="w-full border-b border-forum-medium-gray bg-transparent pb-1.5 font-dm-sans text-[15px] text-black outline-none" + /> +
- {/* ═══ Friends + Organizations — two columns ═══ */}
- {/* Friends column */}
Friends @@ -266,7 +363,6 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) {
- {/* Organizations column */}
Organizations @@ -291,12 +387,10 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) {
- {/* ═══ Interest Tags ═══ */}
Interest Tags
- {/* Selected topics */}
Topics @@ -325,23 +419,26 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) {
- {/* Search for new tags */}
Suggested tags
- {SUGGESTION_TAGS.map((tag) => ( - - {tag} + {filteredTags.map((tag) => ( + toggleInterest(tag.id)} + aria-label={tag.label} + > + {tag.label} ))}
- {/* Organizations — link to orgs page */}
Organizations @@ -356,137 +453,6 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) {
- - {/* ═══ Friends + Organizations (bottom expanded view) ═══ */} -
- {/* Friends expanded */} -
-
-
-

Friends

-
- - {/* Avatar large */} -
- {profile.avatarUrl ? ( - {profile.displayName} - ) : ( -
- {profile.displayName[0]?.toUpperCase()} -
- )} -
- -
- - -
- -
- {friends.map((friend) => ( -
-
- {friend.avatarUrl ? ( - {friend.displayName} - ) : ( -
- {friend.displayName[0]?.toUpperCase()} -
- )} -
-
- - {friend.displayName} - - - @{friend.netId} - -
- {friend.classYear && ( - - '{friend.classYear.slice(-2)} - - )} -
- ))} -
- - - ADD / EDIT MY FRIENDS LIST - - -
- - {/* Organizations expanded */} -
-
-
-

Organizations

-
- -
- - -
- -
- {[1, 2, 3, 4].map((i) => ( -
-
-
-
-
- - Princeton TigerApps - - - Design Lead - -
- -
- ))} -
- - - ADD / EDIT MY ORGANIZATIONS - - -
-
); }