From 9728013cfd432cffcbb471b16d0ac216cf8df5ab Mon Sep 17 00:00:00 2001 From: Samson Okunola Date: Mon, 13 Jul 2026 22:20:32 +0100 Subject: [PATCH] feat(members): add member directory, profile form, and spotlight features - Implemented MemberCard component for displaying member profiles. - Created MemberDirectory for searching and filtering members with pagination. - Developed ProfileForm for editing user profiles with validation. - Added search API and token management for member search functionality. - Introduced SpotlightCarousel and SpotlightSection for showcasing featured members. - Defined types and schemas for member profiles and validation. - Integrated hooks for managing member search and profile updates. --- package.json | 2 + pnpm-lock.yaml | 31 ++ src/app/(auth)/layout.tsx | 15 - src/app/(member)/profile/page.tsx | 17 +- src/app/(public)/members/page.tsx | 27 +- src/app/(public)/page.tsx | 5 +- src/components/shared/header.tsx | 13 +- src/features/auth/api.ts | 3 +- src/features/auth/auth-provider.tsx | 2 +- src/features/auth/guards.tsx | 3 +- src/features/auth/pending-status.tsx | 3 +- src/features/auth/store.ts | 5 +- src/features/members/api.ts | 89 ++++++ src/features/members/hooks.ts | 61 ++++ src/features/members/index.ts | 12 + src/features/members/member-card.tsx | 65 +++++ src/features/members/member-directory.tsx | 302 +++++++++++++++++++ src/features/members/profile-form.tsx | 308 ++++++++++++++++++++ src/features/members/profile-schema.ts | 46 +++ src/features/members/search-api.ts | 145 +++++++++ src/features/members/search-token.ts | 75 +++++ src/features/members/spotlight-carousel.tsx | 41 +++ src/features/members/spotlight-section.tsx | 22 ++ src/features/members/types.ts | 44 +++ src/features/members/use-member-search.ts | 31 ++ src/lib/query/keys.ts | 1 + 26 files changed, 1324 insertions(+), 44 deletions(-) delete mode 100644 src/app/(auth)/layout.tsx create mode 100644 src/features/members/api.ts create mode 100644 src/features/members/hooks.ts create mode 100644 src/features/members/index.ts create mode 100644 src/features/members/member-card.tsx create mode 100644 src/features/members/member-directory.tsx create mode 100644 src/features/members/profile-form.tsx create mode 100644 src/features/members/profile-schema.ts create mode 100644 src/features/members/search-api.ts create mode 100644 src/features/members/search-token.ts create mode 100644 src/features/members/spotlight-carousel.tsx create mode 100644 src/features/members/spotlight-section.tsx create mode 100644 src/features/members/types.ts create mode 100644 src/features/members/use-member-search.ts diff --git a/package.json b/package.json index 0358b0f..ad69281 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@base-ui/react": "^1.5.0", + "@hookform/resolvers": "^5.4.0", "@posthog/react": "^1.10.3", "@react-spring/three": "^10.1.2", "@react-three/drei": "^10.7.7", @@ -34,6 +35,7 @@ "radix-ui": "^1.4.3", "react": "19.2.4", "react-dom": "19.2.4", + "react-hook-form": "^7.81.0", "shadcn": "^4.8.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd5e8ad..6a6a4d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@base-ui/react': specifier: ^1.5.0 version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@hookform/resolvers': + specifier: ^5.4.0 + version: 5.4.0(react-hook-form@7.81.0(react@19.2.4)) '@posthog/react': specifier: ^1.10.3 version: 1.10.3(@types/react@19.2.17)(posthog-js@1.399.2)(react@19.2.4) @@ -68,6 +71,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + react-hook-form: + specifier: ^7.81.0 + version: 7.81.0(react@19.2.4) shadcn: specifier: ^4.8.0 version: 4.13.0(typescript@5.9.3) @@ -371,6 +377,11 @@ packages: peerDependencies: hono: ^4 + '@hookform/resolvers@5.4.0': + resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} + peerDependencies: + react-hook-form: ^7.55.0 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1418,6 +1429,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -3571,6 +3585,12 @@ packages: peerDependencies: react: ^19.2.4 + react-hook-form@7.81.0: + resolution: {integrity: sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -4509,6 +4529,11 @@ snapshots: dependencies: hono: 4.12.29 + '@hookform/resolvers@5.4.0(react-hook-form@7.81.0(react@19.2.4))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.81.0(react@19.2.4) + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -5572,6 +5597,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/utils@0.3.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -7788,6 +7815,10 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-hook-form@7.81.0(react@19.2.4): + dependencies: + react: 19.2.4 + react-is@16.13.1: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.4): diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx deleted file mode 100644 index 2bbb63c..0000000 --- a/src/app/(auth)/layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type React from "react"; - -interface Props { - children: React.ReactNode; -} - -const DashboardLayout = ({}: Props) => { - return ( -
-
-
- ); -}; - -export default DashboardLayout; diff --git a/src/app/(member)/profile/page.tsx b/src/app/(member)/profile/page.tsx index 60b71d3..c5b3bd4 100644 --- a/src/app/(member)/profile/page.tsx +++ b/src/app/(member)/profile/page.tsx @@ -1,19 +1,20 @@ import type { Metadata } from "next"; +import { ProfileForm } from "@/features/members"; + export const metadata: Metadata = { title: "Your profile | Community Pro", }; export default function Page() { return ( -
-

You're in

-

Welcome to Community Pro

- {/* why: placeholder until Phase 2 ships profile editing — keeps the post-activation - redirect from landing on a 404. */} -

- Profile setup is on its way. Soon you'll add your skills, portfolio link, and avatar here. -

+
+
+

Your profile

+

Edit your profile

+

This is what the community sees on the spotlight and members directory.

+
+
); } diff --git a/src/app/(public)/members/page.tsx b/src/app/(public)/members/page.tsx index 539f3e5..7a52394 100644 --- a/src/app/(public)/members/page.tsx +++ b/src/app/(public)/members/page.tsx @@ -1,9 +1,24 @@ -const Page = () => { +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { Loader } from "@/components/shared"; +import { MemberDirectory } from "@/features/members"; + +export const metadata: Metadata = { + title: "Members | Community Pro", + description: "Search and filter the Community Pro member directory by skill, role, and experience.", +}; + +export default function Page() { return ( -
-

Events

+
+
+

Members

+

Find and connect with growth-focused engineers across the community.

+
+ }> + +
); -}; - -export default Page; +} diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index aad6801..2db12ad 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -8,6 +8,7 @@ import Link from "next/link"; import { Avatar, AvatarGroup, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { AVATARS, METRICS } from "@/constants"; +import { MembersSpotlightSection } from "@/features/members"; const Page = () => { const posthong = usePostHog(); @@ -97,7 +98,9 @@ const Page = () => {
-
+
+ +
diff --git a/src/components/shared/header.tsx b/src/components/shared/header.tsx index 41b45b6..1c540fe 100644 --- a/src/components/shared/header.tsx +++ b/src/components/shared/header.tsx @@ -13,7 +13,7 @@ import { cn, normalize } from "@/lib"; const image = "/assets/images/community-pro.png"; export const Header = () => { - const { setTheme, theme } = useTheme(); + const { setTheme, resolvedTheme } = useTheme(); const pathname = usePathname(); return ( @@ -27,7 +27,7 @@ export const Header = () => { className={cn( "relative text-sm", route.href === normalize(pathname) - ? "font-bold before:scale-x-100" + ? "font-bold before:scale-x-100 border-b-2 border-main" : "link before:bg-foreground font-medium", )} href={route.href} @@ -38,8 +38,13 @@ export const Header = () => { ))}
- + )} +
+ ); +}; diff --git a/src/features/members/member-directory.tsx b/src/features/members/member-directory.tsx new file mode 100644 index 0000000..c3a3d38 --- /dev/null +++ b/src/features/members/member-directory.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { Loader2, SlidersHorizontal, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { EXPERIENCE_LEVELS, type ExperienceLevel } from "./types"; +import { Pagination } from "@/components/shared/pagination"; +import { useMemberSearch } from "./use-member-search"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { MemberCard } from "./member-card"; +import { useDebounce } from "@/hooks"; + +const PAGE_SIZE = 12; +const MAX_SKILL_OPTIONS = 20; +const SYNC_DEBOUNCE_MS = 300; +const SKELETON_COUNT = PAGE_SIZE; + +const isExperienceLevel = (value: string | null): value is ExperienceLevel => + value !== null && (EXPERIENCE_LEVELS as readonly string[]).includes(value); + +const parsePage = (value: string | null): number => { + const parsed = value ? Number.parseInt(value, 10) : 1; + return Number.isFinite(parsed) && parsed > 0 ? parsed : 1; +}; + +type DirectoryUrlState = { + search: string; + skills: string[]; + experience: ExperienceLevel | undefined; + page: number; +}; + +const buildQueryString = (state: DirectoryUrlState): string => { + const params = new URLSearchParams(); + if (state.search) params.set("q", state.search); + if (state.skills.length > 0) params.set("skills", state.skills.join(",")); + if (state.experience) params.set("experience", state.experience); + if (state.page > 1) params.set("page", String(state.page)); + return params.toString(); +}; + +const SkeletonCard = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +/** + * The Members directory: a debounced search bar, skill/experience filter popovers with + * removable chips, a responsive `MemberCard` grid (skeletons while loading, a friendly + * empty state, and a degraded-mode note when the DB fallback is serving results), and + * pagination. All filter state is synced to the URL (`q`, `skills`, `experience`, `page`) + * so directory links are shareable and the back button works. + * + * Calls `useSearchParams`, so the caller must render it inside a `` boundary. + * + * @returns The full members directory UI. + * @example + * }> + * + * + */ +export const MemberDirectory = () => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const [searchInput, setSearchInput] = useState(() => searchParams.get("q") ?? ""); + const [skills, setSkills] = useState(() => { + const raw = searchParams.get("skills"); + return raw ? raw.split(",").filter(Boolean) : []; + }); + const [experience, setExperience] = useState(() => { + const raw = searchParams.get("experience"); + return isExperienceLevel(raw) ? raw : undefined; + }); + const [page, setPage] = useState(() => parsePage(searchParams.get("page"))); + + const debouncedSearch = useDebounce(searchInput, SYNC_DEBOUNCE_MS); + + // why: any filter change should land back on page 1; page-only changes must not retrigger + // this (hence the separate effect + ref instead of folding page into the same dependency). + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + setPage(1); + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excludes `page` + }, [debouncedSearch, experience, skills.join(",")]); + + useEffect(() => { + const handle = setTimeout(() => { + const query = buildQueryString({ search: searchInput, skills, experience, page }); + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); + }, SYNC_DEBOUNCE_MS); + return () => clearTimeout(handle); + }, [router, pathname, searchInput, skills, experience, page]); + + const filters = useMemo( + () => ({ search: debouncedSearch || undefined, skills, experience, page, pageSize: PAGE_SIZE }), + [debouncedSearch, skills, experience, page], + ); + + const { data, isLoading, isFetching } = useMemberSearch(filters); + + const skillOptions = useMemo(() => { + const counts = data?.facets?.skills ?? {}; + const ranked = Object.entries(counts) + .sort((a, b) => b[1] - a[1]) + .slice(0, MAX_SKILL_OPTIONS) + .map(([skill]) => skill); + const combined = Array.from(new Set([...ranked, ...skills])); + return combined.map((skill) => ({ skill, count: counts[skill] ?? 0 })); + }, [data?.facets, skills]); + + const experienceCounts = data?.facets?.experience ?? {}; + + const toggleSkill = (skill: string) => { + setSkills((previous) => + previous.includes(skill) ? previous.filter((value) => value !== skill) : [...previous, skill], + ); + }; + + const selectExperience = (level: ExperienceLevel) => { + setExperience((previous) => (previous === level ? undefined : level)); + }; + + const hasActiveFilters = searchInput.length > 0 || skills.length > 0 || experience !== undefined; + + const clearAll = () => { + setSearchInput(""); + setSkills([]); + setExperience(undefined); + }; + + const items = data?.items ?? []; + const showSkeleton = isLoading; + const showEmpty = !isLoading && items.length === 0; + + return ( +
+
+ + setSearchInput(event.target.value)} + placeholder="Search by name, title, or skill…" + value={searchInput} + /> + {isFetching && ( + + + + )} + + +
+ + + + + + {skillOptions.length === 0 ? ( +

No skills to filter by yet.

+ ) : ( +
+ {skillOptions.map(({ skill, count }) => ( + + ))} +
+ )} +
+
+ + + + + + +
+ {EXPERIENCE_LEVELS.map((level) => ( + + ))} +
+
+
+
+
+ + {hasActiveFilters && ( +
+ {skills.map((skill) => ( + + {skill} + + + ))} + {experience && ( + + {experience} + + + )} + +
+ )} + + {data?.source === "fallback" && ( +

Search is limited right now — showing best-effort results.

+ )} + + {showSkeleton ? ( +
+ {Array.from({ length: SKELETON_COUNT }).map((_, index) => ( + + ))} +
+ ) : showEmpty ? ( +
+

No members match — try clearing filters.

+ {hasActiveFilters && ( + + )} +
+ ) : ( +
+ {items.map((member) => ( + + ))} +
+ )} + + {!showSkeleton && !showEmpty && (data?.total ?? 0) > PAGE_SIZE && ( + + )} +
+ ); +}; diff --git a/src/features/members/profile-form.tsx b/src/features/members/profile-form.tsx new file mode 100644 index 0000000..afcf320 --- /dev/null +++ b/src/features/members/profile-form.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Controller, useForm } from "react-hook-form"; +import { X } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import type { z } from "zod"; + +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { EXPERIENCE_LEVELS, type MemberProfile } from "./types"; +import { useMyProfile, useUpdateProfile } from "./hooks"; +import { Textarea } from "@/components/ui/textarea"; +import { profileSchema } from "./profile-schema"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { isApiError } from "@/lib/api"; +import { uploadAvatar } from "./api"; + +type ProfileFormInput = z.input; +type ProfileFormOutput = z.output; + +const MAX_AVATAR_BYTES = 2 * 1024 * 1024; + +const toFormInput = (profile: MemberProfile): ProfileFormInput => ({ + displayName: profile.displayName, + title: profile.title, + bio: profile.bio ?? "", + portfolioUrl: profile.portfolioUrl ?? "", + githubUrl: profile.githubUrl ?? "", + linkedinUrl: profile.linkedinUrl ?? "", + avatarUrl: profile.avatarUrl ?? "", + skills: profile.skills, + experience: profile.experience, +}); + +const EMPTY_DEFAULTS: ProfileFormInput = { + displayName: "", + title: "", + bio: "", + portfolioUrl: "", + githubUrl: "", + linkedinUrl: "", + avatarUrl: "", + skills: [], + experience: "Junior", +}; + +const initialsOf = (name: string): string => + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join("") || "?"; + +/** + * Member self-service profile editor: react-hook-form + zod validation, skills entered + * as chips, and a Cloudinary-backed avatar uploader. Handles the just-activated race + * where `GET /members/me` briefly 404s with `members.not_found`. + * + * @returns The profile form, or a friendly loading/error state while data resolves. + * @example + * + */ +export const ProfileForm = () => { + const { data: profile, isLoading, error, refetch, isRefetching } = useMyProfile(); + const updateProfile = useUpdateProfile(); + const [skillDraft, setSkillDraft] = useState(""); + const [isUploadingAvatar, setIsUploadingAvatar] = useState(false); + + const form = useForm({ + resolver: zodResolver(profileSchema), + defaultValues: EMPTY_DEFAULTS, + values: profile ? toFormInput(profile) : undefined, + }); + + const skills = form.watch("skills"); + const avatarUrl = form.watch("avatarUrl"); + const displayName = form.watch("displayName"); + + const addSkill = (raw: string) => { + const skill = raw.trim(); + if (!skill) return; + if (skills.some((existing) => existing.toLowerCase() === skill.toLowerCase())) { + setSkillDraft(""); + return; + } + form.setValue("skills", [...skills, skill], { shouldDirty: true, shouldValidate: true }); + setSkillDraft(""); + }; + + const removeSkill = (skill: string) => { + form.setValue( + "skills", + skills.filter((existing) => existing !== skill), + { shouldDirty: true, shouldValidate: true }, + ); + }; + + const handleSkillKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === ",") { + event.preventDefault(); + addSkill(skillDraft); + } + }; + + const handleAvatarChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + if (!file.type.startsWith("image/")) { + toast.error("Pick an image file."); + return; + } + if (file.size > MAX_AVATAR_BYTES) { + toast.error("Avatar must be 2MB or smaller."); + return; + } + + setIsUploadingAvatar(true); + try { + const uploadedUrl = await uploadAvatar(file); + form.setValue("avatarUrl", uploadedUrl, { shouldDirty: true, shouldValidate: true }); + toast.success("Avatar uploaded."); + } catch (uploadError) { + toast.error(uploadError instanceof Error ? uploadError.message : "Avatar upload failed."); + } finally { + setIsUploadingAvatar(false); + } + }; + + const onSubmit = form.handleSubmit((values) => { + updateProfile.mutate(values); + }); + + if (isLoading) { + return ( +
+
+
+
+
+
+ ); + } + + if (error && isApiError(error) && error.code === "members.not_found") { + return ( +
+

Your profile is still being created.

+

+ This only takes a moment after activation. Refresh to try again. +

+ +
+ ); + } + + if (error) { + return ( +
+

Couldn't load your profile.

+

{isApiError(error) ? error.message : "Try again shortly."}

+ +
+ ); + } + + return ( +
+
+ + {avatarUrl && } + {initialsOf(displayName || "?")} + +
+ +

PNG or JPG, up to 2MB.

+
+
+ +
+ + + {form.formState.errors.displayName && ( +

{form.formState.errors.displayName.message}

+ )} +
+ +
+ + + {form.formState.errors.title && ( +

{form.formState.errors.title.message}

+ )} +
+ +
+ +