Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 0 additions & 15 deletions src/app/(auth)/layout.tsx

This file was deleted.

17 changes: 9 additions & 8 deletions src/app/(member)/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col items-center justify-center gap-4 px-4 text-center">
<p className="text-main text-sm font-semibold tracking-widest uppercase">You&apos;re in</p>
<h1 className="text-4xl font-bold">Welcome to Community Pro</h1>
{/* why: placeholder until Phase 2 ships profile editing — keeps the post-activation
redirect from landing on a 404. */}
<p className="text-muted-foreground max-w-md">
Profile setup is on its way. Soon you&apos;ll add your skills, portfolio link, and avatar here.
</p>
<main className="mx-auto flex min-h-screen w-full max-w-2xl flex-col gap-8 px-4 py-16">
<div className="space-y-2">
<p className="text-main text-sm font-semibold tracking-widest uppercase">Your profile</p>
<h1 className="text-4xl font-bold">Edit your profile</h1>
<p className="text-muted-foreground">This is what the community sees on the spotlight and members directory.</p>
</div>
<ProfileForm />
</main>
);
}
27 changes: 21 additions & 6 deletions src/app/(public)/members/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="grid h-screen place-items-center">
<h1 className="text-9xl font-bold">Events</h1>
<div className="mx-auto max-w-7xl px-4 py-24 sm:py-28">
<div className="mb-8 space-y-2">
<h1 className="text-3xl font-medium sm:text-4xl">Members</h1>
<p className="text-muted-foreground">Find and connect with growth-focused engineers across the community.</p>
</div>
<Suspense fallback={<Loader className="h-auto min-h-96 w-full" />}>
<MemberDirectory />
</Suspense>
</div>
);
};

export default Page;
}
5 changes: 4 additions & 1 deletion src/app/(public)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -97,7 +98,9 @@ const Page = () => {
</section>
<hr />
<section className="py-5 sm:py-10">
<div className="mx-auto max-w-7xl space-y-4"></div>
<div className="mx-auto max-w-7xl space-y-4">
<MembersSpotlightSection />
</div>
</section>
<section className="py-5 sm:py-10">
<div className="mx-auto max-w-7xl space-y-4"></div>
Expand Down
13 changes: 9 additions & 4 deletions src/components/shared/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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}
Expand All @@ -38,8 +38,13 @@ export const Header = () => {
))}
</div>
<div className="flex items-center gap-x-5">
<Button onClick={() => setTheme(theme === "light" ? "dark" : "light")} size="icon" variant="outline">
{theme === "light" ? <Moon className="size-4" /> : <Sun className="size-4" />}
<Button
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
size="icon"
variant="outline"
>
<Moon className="size-4 dark:hidden" />
<Sun className="size-4 hidden dark:block" />
</Button>
<Button asChild>
<Link href="/join-community">Join</Link>
Expand Down
3 changes: 1 addition & 2 deletions src/features/auth/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { apiFetch } from "@/lib/api";

import { clearSessionToken } from "./session";
import type { AuthUser } from "./store";
import { apiFetch } from "@/lib/api";

/** Raw shape of the backend `GET /me` response (status uses backend PascalCase spelling). */
export type MeResponse = {
Expand Down
2 changes: 1 addition & 1 deletion src/features/auth/auth-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
import type { ReactNode } from "react";
import { useEffect } from "react";

import { useAuthStore } from "./store";
// why: importing the session module registers the access-token provider on apiFetch
// (module side effect) before any query runs.
import "./session";
import { useAuthStore } from "./store";

/**
* App-level auth bootstrap: registers the session token provider and hydrates the auth
Expand Down
3 changes: 1 addition & 2 deletions src/features/auth/guards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@

import type { ReactNode } from "react";

import { Loader } from "@/components/shared";

import { useAuthGuard, type AuthRequirement } from "./use-auth-guard";
import { Loader } from "@/components/shared";

type GuardGateProps = {
requirement: AuthRequirement;
Expand Down
3 changes: 1 addition & 2 deletions src/features/auth/pending-status.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
"use client";

import { RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { RefreshCw } from "lucide-react";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";

import { useAuthStore } from "./store";

/**
Expand Down
5 changes: 2 additions & 3 deletions src/features/auth/store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { create } from "zustand";

import { createReportableStore } from "@/store/middleware";
import { fetchMe } from "./api";

/** Signed-in user shape returned by `GET /me`, normalized to client conventions. */
Expand Down Expand Up @@ -37,7 +36,7 @@ type AuthState = {
*/
// why: starting at "loading" (not "anonymous") keeps guards from redirecting during the
// window between first render and AuthProvider's hydrate effect settling the real status.
export const useAuthStore = create<AuthState>((set) => ({
export const useAuthStore = createReportableStore<AuthState>((set) => ({
user: null,
status: "loading",
hydrate: async () => {
Expand Down
89 changes: 89 additions & 0 deletions src/features/members/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { AvatarSignPayload, MemberProfile, UpdateProfileInput } from "./types";
import { apiFetch, isApiError } from "@/lib/api";

/**
* Fetches the spotlight roster for the home page: featured members first, then newest,
* capped at 12 by the backend.
*
* @returns Up to 12 member profiles; empty array once no members exist yet.
* @example
* const spotlight = await fetchSpotlight();
*/
export const fetchSpotlight = async (): Promise<MemberProfile[]> => apiFetch<MemberProfile[]>("/members/spotlight");

/**
* Fetches the signed-in member's own profile.
*
* @returns The caller's profile; throws `ApiRequestError` with code `members.not_found`
* when the profile hasn't been provisioned yet (rare race right after activation).
* @example
* const me = await fetchMyProfile();
*/
export const fetchMyProfile = async (): Promise<MemberProfile> => apiFetch<MemberProfile>("/members/me");

/**
* Creates or updates the signed-in member's profile.
*
* @param input - The full editable profile payload.
* @returns The updated profile as persisted by the backend.
* @example
* const updated = await updateMyProfile({
* displayName: "Ada Lovelace",
* title: "Senior Backend Dev",
* skills: ["TypeScript", "C#"],
* experience: "Senior",
* });
*/
export const updateMyProfile = async (input: UpdateProfileInput): Promise<MemberProfile> =>
apiFetch<MemberProfile>("/members/me", { method: "PUT", body: JSON.stringify(input) });

/**
* Requests a signed Cloudinary upload payload for the caller's avatar.
*
* @returns Signing parameters to attach to a direct Cloudinary upload.
* @example
* const payload = await signAvatarUpload();
*/
export const signAvatarUpload = async (): Promise<AvatarSignPayload> =>
apiFetch<AvatarSignPayload>("/members/me/avatar/sign", { method: "POST" });

type CloudinaryUploadResponse = { secure_url: string };

/**
* Uploads an avatar image straight to Cloudinary using a freshly signed payload from the
* backend, never proxying image bytes through the API.
*
* @param file - The image file selected by the member (validate type/size before calling).
* @returns The Cloudinary `secure_url` to save onto the profile's `avatarUrl`.
* @example
* const avatarUrl = await uploadAvatar(file);
* form.setValue("avatarUrl", avatarUrl);
*/
export const uploadAvatar = async (file: File): Promise<string> => {
let payload: AvatarSignPayload;
try {
payload = await signAvatarUpload();
} catch (error) {
if (isApiError(error) && error.code === "members.avatar_signing_unconfigured") {
throw new Error("Avatar uploads aren't configured yet — try again later.");
}
throw error;
}

const body = new FormData();
body.set("file", file);
body.set("api_key", payload.apiKey);
body.set("timestamp", String(payload.timestamp));
body.set("signature", payload.signature);
body.set("folder", payload.folder);
body.set("public_id", payload.publicId);

const response = await fetch(`https://api.cloudinary.com/v1_1/${payload.cloudName}/image/upload`, {
method: "POST",
body,
});
if (!response.ok) throw new Error("Avatar upload failed — try a different image.");

const uploaded = (await response.json()) as CloudinaryUploadResponse;
return uploaded.secure_url;
};
Loading
Loading