diff --git a/packages/api-server/src/domains/users/dto.rs b/packages/api-server/src/domains/users/dto.rs index c3cfb2c2..00c02d10 100644 --- a/packages/api-server/src/domains/users/dto.rs +++ b/packages/api-server/src/domains/users/dto.rs @@ -87,6 +87,13 @@ pub struct UpdateUserDto { pub bio: Option, } +/// 팔로우 상태 응답 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct FollowStatusResponse { + /// 팔로우 여부 + pub is_following: bool, +} + /// 사용자 통계 응답 #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UserStatsResponse { diff --git a/packages/api-server/src/domains/users/handlers.rs b/packages/api-server/src/domains/users/handlers.rs index 5a59dce5..b9c948ce 100644 --- a/packages/api-server/src/domains/users/handlers.rs +++ b/packages/api-server/src/domains/users/handlers.rs @@ -4,8 +4,9 @@ use axum::{ extract::{Path, Query, State}, + http::StatusCode, middleware::from_fn_with_state, - routing::get, + routing::{get, post}, Extension, Json, Router, }; use uuid::Uuid; @@ -19,8 +20,8 @@ use crate::{ use super::{ dto::{ - SavedItem, TryItem, UpdateUserDto, UserActivitiesQuery, UserActivityItem, UserActivityType, - UserResponse, UserStatsResponse, + FollowStatusResponse, SavedItem, TryItem, UpdateUserDto, UserActivitiesQuery, + UserActivityItem, UserActivityType, UserResponse, UserStatsResponse, }, service, }; @@ -199,6 +200,71 @@ pub async fn get_my_saved( Ok(Json(result)) } +/// POST /api/v1/users/{user_id}/follow - 팔로우 +#[utoipa::path( + post, + path = "/api/v1/users/{user_id}/follow", + tag = "users", + security(("bearer_auth" = [])), + params(("user_id" = Uuid, Path, description = "팔로우할 사용자 ID")), + responses( + (status = 204, description = "팔로우 성공"), + (status = 400, description = "자기 자신을 팔로우할 수 없음"), + (status = 401, description = "인증 필요"), + (status = 404, description = "사용자를 찾을 수 없음") + ) +)] +pub async fn follow_user_handler( + State(state): State, + Extension(user): Extension, + Path(target_id): Path, +) -> AppResult { + service::follow_user(&state.db, user.id, target_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +/// DELETE /api/v1/users/{user_id}/follow - 언팔로우 +#[utoipa::path( + delete, + path = "/api/v1/users/{user_id}/follow", + tag = "users", + security(("bearer_auth" = [])), + params(("user_id" = Uuid, Path, description = "언팔로우할 사용자 ID")), + responses( + (status = 204, description = "언팔로우 성공"), + (status = 401, description = "인증 필요") + ) +)] +pub async fn unfollow_user_handler( + State(state): State, + Extension(user): Extension, + Path(target_id): Path, +) -> AppResult { + service::unfollow_user(&state.db, user.id, target_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +/// GET /api/v1/users/{user_id}/follow-status - 팔로우 여부 확인 +#[utoipa::path( + get, + path = "/api/v1/users/{user_id}/follow-status", + tag = "users", + security(("bearer_auth" = [])), + params(("user_id" = Uuid, Path, description = "대상 사용자 ID")), + responses( + (status = 200, description = "팔로우 상태 조회 성공", body = FollowStatusResponse), + (status = 401, description = "인증 필요") + ) +)] +pub async fn get_follow_status( + State(state): State, + Extension(user): Extension, + Path(target_id): Path, +) -> AppResult> { + let is_following = service::check_is_following(&state.db, user.id, target_id).await?; + Ok(Json(FollowStatusResponse { is_following })) +} + /// Users 도메인 라우터 pub fn router(app_config: AppConfig) -> Router { let protected_routes = Router::new() @@ -207,6 +273,11 @@ pub fn router(app_config: AppConfig) -> Router { .route("/me/stats", get(get_my_stats)) .route("/me/tries", get(get_my_tries)) .route("/me/saved", get(get_my_saved)) + .route( + "/{user_id}/follow", + post(follow_user_handler).delete(unfollow_user_handler), + ) + .route("/{user_id}/follow-status", get(get_follow_status)) .route_layer(from_fn_with_state(app_config, auth_middleware)); Router::new() diff --git a/packages/api-server/src/domains/users/service.rs b/packages/api-server/src/domains/users/service.rs index d033f610..15d9e407 100644 --- a/packages/api-server/src/domains/users/service.rs +++ b/packages/api-server/src/domains/users/service.rs @@ -14,8 +14,8 @@ use crate::{ }; use super::dto::{ - SavedItem, TryItem, UpdateUserDto, UserActivityItem, UserActivityType, UserResponse, - UserStatsResponse, + SavedItem, TryItem, UpdateUserDto, UserActivityItem, UserActivityPostMeta, + UserActivitySpotMeta, UserActivityType, UserResponse, UserStatsResponse, }; /// 사용자 ID로 프로필 조회 @@ -58,32 +58,208 @@ pub async fn update_user_profile( .map_err(AppError::DatabaseError) } +/// 사용자의 활성 포스트 수 +async fn count_user_posts(db: &DatabaseConnection, user_id: Uuid) -> AppResult { + let result = db + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT COUNT(*)::BIGINT AS cnt FROM public.posts WHERE user_id = $1 AND status = 'active'", + [user_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + Ok(result + .map(|row| row.try_get::("", "cnt").unwrap_or(0)) + .unwrap_or(0)) +} + +/// 사용자의 댓글 수 +async fn count_user_comments(db: &DatabaseConnection, user_id: Uuid) -> AppResult { + let result = db + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT COUNT(*)::BIGINT AS cnt FROM public.comments WHERE user_id = $1", + [user_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + Ok(result + .map(|row| row.try_get::("", "cnt").unwrap_or(0)) + .unwrap_or(0)) +} + +/// 사용자가 받은 좋아요 수 +async fn count_user_likes_received(db: &DatabaseConnection, user_id: Uuid) -> AppResult { + let result = db + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT COUNT(*)::BIGINT AS cnt FROM public.post_likes pl JOIN public.posts p ON pl.post_id = p.id WHERE p.user_id = $1", + [user_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + Ok(result + .map(|row| row.try_get::("", "cnt").unwrap_or(0)) + .unwrap_or(0)) +} + /// 내 활동 통계 조회 pub async fn get_user_stats( db: &DatabaseConnection, user_id: Uuid, ) -> AppResult { let user = get_user_by_id(db, user_id).await?; + let total_posts = count_user_posts(db, user_id).await?; + let total_comments = count_user_comments(db, user_id).await?; + let total_likes_received = count_user_likes_received(db, user_id).await?; Ok(UserStatsResponse { user_id, - total_posts: 0, - total_comments: 0, - total_likes_received: 0, + total_posts, + total_comments, + total_likes_received, total_points: user.total_points, rank: user.rank, }) } -/// 활동 내역 조회 (향후 Post/Spot/Solution 데이터 연동 예정) +/// 활동 내역 조회 (posts, spots, solutions UNION) pub async fn list_user_activities( - _db: &DatabaseConnection, - _user_id: Uuid, - _activity_type: Option, + db: &DatabaseConnection, + user_id: Uuid, + activity_type: Option, pagination: Pagination, ) -> AppResult> { - // TODO(Phase 6+): 실제 Post/Spot/Solution 데이터와 조인 - Ok(PaginatedResponse::new(Vec::new(), pagination, 0)) + let per_page = pagination.per_page.min(100); + let offset = (pagination.page.max(1) - 1) * per_page; + + // Build type filter + let type_filter = match &activity_type { + Some(UserActivityType::Post) => "WHERE a.activity_type = 'post'", + Some(UserActivityType::Spot) => "WHERE a.activity_type = 'spot'", + Some(UserActivityType::Solution) => "WHERE a.activity_type = 'solution'", + None => "", + }; + + let count_sql = format!( + r#"SELECT COUNT(*)::BIGINT AS cnt FROM ( + SELECT id, 'post' AS activity_type FROM public.posts WHERE user_id = $1 AND status = 'active' + UNION ALL + SELECT id, 'spot' AS activity_type FROM public.spots WHERE user_id = $1 + UNION ALL + SELECT id, 'solution' AS activity_type FROM public.solutions WHERE user_id = $1 AND status = 'active' + ) a {type_filter}"# + ); + + let total_row = db + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + &count_sql, + [user_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + let total_items = total_row + .map(|r| r.try_get::("", "cnt").unwrap_or(0)) + .unwrap_or(0) as u64; + + let data_sql = format!( + r#"SELECT * FROM ( + SELECT p.id, 'post' AS activity_type, p.title, NULL::text AS product_name, + NULL::boolean AS is_adopted, NULL::boolean AS is_verified, + NULL::uuid AS spot_id, NULL::uuid AS spot_post_id, + p.image_url AS spot_post_image_url, + p.artist_name AS spot_post_artist_name, p.group_name AS spot_post_group_name, + p.created_at + FROM public.posts p WHERE p.user_id = $1 AND p.status = 'active' + UNION ALL + SELECT s.id, 'spot' AS activity_type, NULL AS title, NULL AS product_name, + NULL AS is_adopted, NULL AS is_verified, + s.id AS spot_id, s.post_id AS spot_post_id, + sp.image_url AS spot_post_image_url, + sp.artist_name AS spot_post_artist_name, sp.group_name AS spot_post_group_name, + s.created_at + FROM public.spots s + LEFT JOIN public.posts sp ON s.post_id = sp.id + WHERE s.user_id = $1 + UNION ALL + SELECT sol.id, 'solution' AS activity_type, sol.title, sol.product_name, + sol.is_adopted, sol.is_verified, + sol.spot_id AS spot_id, sp2.post_id AS spot_post_id, + sp3.image_url AS spot_post_image_url, + sp3.artist_name AS spot_post_artist_name, sp3.group_name AS spot_post_group_name, + sol.created_at + FROM public.solutions sol + LEFT JOIN public.spots sp2 ON sol.spot_id = sp2.id + LEFT JOIN public.posts sp3 ON sp2.post_id = sp3.id + WHERE sol.user_id = $1 AND sol.status = 'active' + ) a {type_filter} + ORDER BY a.created_at DESC + LIMIT $2 OFFSET $3"# + ); + + let rows = db + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + &data_sql, + [ + user_id.into(), + (per_page as i64).into(), + (offset as i64).into(), + ], + )) + .await + .map_err(AppError::DatabaseError)?; + + let items: Vec = rows + .iter() + .map(|row| { + let activity_type_str: String = row.try_get("", "activity_type").unwrap_or_default(); + let activity_type = match activity_type_str.as_str() { + "spot" => UserActivityType::Spot, + "solution" => UserActivityType::Solution, + _ => UserActivityType::Post, + }; + + let spot = row + .try_get::("", "spot_id") + .ok() + .map(|spot_id| UserActivitySpotMeta { + id: spot_id, + post: row.try_get::("", "spot_post_id").ok().map(|pid| { + UserActivityPostMeta { + id: pid, + image_url: row.try_get("", "spot_post_image_url").ok(), + artist_name: row.try_get("", "spot_post_artist_name").ok(), + group_name: row.try_get("", "spot_post_group_name").ok(), + } + }), + }); + + UserActivityItem { + id: row.try_get("", "id").unwrap_or_default(), + activity_type, + spot, + product_name: row.try_get("", "product_name").ok(), + title: row.try_get("", "title").ok(), + is_adopted: row.try_get("", "is_adopted").ok(), + is_verified: row.try_get("", "is_verified").ok(), + created_at: row + .try_get::>("", "created_at") + .unwrap_or_default(), + } + }) + .collect(); + + Ok(PaginatedResponse::new( + items, + Pagination::new(pagination.page, per_page), + total_items, + )) } async fn count_followers(db: &DatabaseConnection, user_id: Uuid) -> AppResult { @@ -234,3 +410,64 @@ pub async fn get_user_with_follow_counts( ..UserResponse::from(user) }) } + +/// 팔로우 +pub async fn follow_user( + db: &DatabaseConnection, + follower_id: Uuid, + following_id: Uuid, +) -> AppResult<()> { + if follower_id == following_id { + return Err(AppError::BadRequest("자기 자신을 팔로우할 수 없습니다".to_string())); + } + + // 대상 유저 존재 확인 + get_user_by_id(db, following_id).await?; + + db.execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO public.user_follows (follower_id, following_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + [follower_id.into(), following_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + Ok(()) +} + +/// 언팔로우 +pub async fn unfollow_user( + db: &DatabaseConnection, + follower_id: Uuid, + following_id: Uuid, +) -> AppResult<()> { + db.execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "DELETE FROM public.user_follows WHERE follower_id = $1 AND following_id = $2", + [follower_id.into(), following_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + Ok(()) +} + +/// 팔로우 여부 확인 +pub async fn check_is_following( + db: &DatabaseConnection, + follower_id: Uuid, + following_id: Uuid, +) -> AppResult { + let result = db + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT EXISTS(SELECT 1 FROM public.user_follows WHERE follower_id = $1 AND following_id = $2) AS is_following", + [follower_id.into(), following_id.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + Ok(result + .map(|row| row.try_get::("", "is_following").unwrap_or(false)) + .unwrap_or(false)) +} diff --git a/packages/web/app/api/v1/rankings/me/route.ts b/packages/web/app/api/v1/rankings/me/route.ts index 6a315f8e..90efd8de 100644 --- a/packages/web/app/api/v1/rankings/me/route.ts +++ b/packages/web/app/api/v1/rankings/me/route.ts @@ -1,8 +1,6 @@ /** - * Rankings Me Proxy API Route - * GET /api/v1/rankings/me - 내 랭킹 상세 (인증 필요) - * - * Proxies requests to the backend API. + * My Ranking Proxy API Route + * GET /api/v1/rankings/me - Get my ranking detail (auth required) */ import { NextRequest, NextResponse } from "next/server"; @@ -26,24 +24,13 @@ export async function GET(request: NextRequest) { }, }); - const responseText = await response.text(); - let data; - try { - data = JSON.parse(responseText); - } catch { - data = { - message: `Backend error: ${response.status} ${response.statusText}`, - }; - } + const data = await response.json().catch(() => ({ + message: `Backend error: ${response.status}`, + })); return NextResponse.json(data, { status: response.status }); } catch (error) { - if (process.env.NODE_ENV === "development") { - console.error("Rankings/me GET proxy error:", error); - } return NextResponse.json( - { - message: error instanceof Error ? error.message : "Proxy error", - }, + { message: error instanceof Error ? error.message : "Proxy error" }, { status: 502 } ); } diff --git a/packages/web/app/api/v1/rankings/route.ts b/packages/web/app/api/v1/rankings/route.ts index 65d89950..cac412d9 100644 --- a/packages/web/app/api/v1/rankings/route.ts +++ b/packages/web/app/api/v1/rankings/route.ts @@ -1,47 +1,35 @@ /** * Rankings Proxy API Route - * GET /api/v1/rankings - 전체 랭킹 (선택적 인증) - * - * Proxies requests to the backend API. + * GET /api/v1/rankings - List rankings (optional auth for my_ranking) */ import { NextRequest, NextResponse } from "next/server"; import { API_BASE_URL } from "@/lib/server-env"; export async function GET(request: NextRequest) { - try { - const authHeader = request.headers.get("Authorization"); - const { searchParams } = new URL(request.url); - const queryString = searchParams.toString(); - const url = queryString - ? `${API_BASE_URL}/api/v1/rankings?${queryString}` - : `${API_BASE_URL}/api/v1/rankings`; - - const headers: Record = { - "Content-Type": "application/json", - }; - if (authHeader) headers.Authorization = authHeader; + const url = new URL(request.url); + const queryString = url.searchParams.toString(); + const authHeader = request.headers.get("Authorization"); - const response = await fetch(url, { method: "GET", headers }); + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/rankings${queryString ? `?${queryString}` : ""}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + ...(authHeader ? { Authorization: authHeader } : {}), + }, + } + ); - const responseText = await response.text(); - let data; - try { - data = JSON.parse(responseText); - } catch { - data = { - message: `Backend error: ${response.status} ${response.statusText}`, - }; - } + const data = await response.json().catch(() => ({ + message: `Backend error: ${response.status}`, + })); return NextResponse.json(data, { status: response.status }); } catch (error) { - if (process.env.NODE_ENV === "development") { - console.error("Rankings GET proxy error:", error); - } return NextResponse.json( - { - message: error instanceof Error ? error.message : "Proxy error", - }, + { message: error instanceof Error ? error.message : "Proxy error" }, { status: 502 } ); } diff --git a/packages/web/app/api/v1/users/[userId]/follow-status/route.ts b/packages/web/app/api/v1/users/[userId]/follow-status/route.ts new file mode 100644 index 00000000..43cba021 --- /dev/null +++ b/packages/web/app/api/v1/users/[userId]/follow-status/route.ts @@ -0,0 +1,48 @@ +/** + * Follow Status Proxy API Route + * GET /api/v1/users/[userId]/follow-status - Check if current user follows target (auth required) + */ + +import { NextRequest, NextResponse } from "next/server"; +import { API_BASE_URL } from "@/lib/server-env"; + +interface RouteParams { + params: Promise<{ userId: string }>; +} + +export async function GET(request: NextRequest, { params }: RouteParams) { + const { userId } = await params; + const authHeader = request.headers.get("Authorization"); + if (!authHeader) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 } + ); + } + + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/users/${userId}/follow-status`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + } + ); + + const data = await response.json().catch(() => ({ + message: `Backend error: ${response.status}`, + })); + return NextResponse.json(data, { status: response.status }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error("Follow-status GET proxy error:", error); + } + return NextResponse.json( + { message: error instanceof Error ? error.message : "Proxy error" }, + { status: 502 } + ); + } +} diff --git a/packages/web/app/api/v1/users/[userId]/follow/route.ts b/packages/web/app/api/v1/users/[userId]/follow/route.ts new file mode 100644 index 00000000..68b0c574 --- /dev/null +++ b/packages/web/app/api/v1/users/[userId]/follow/route.ts @@ -0,0 +1,94 @@ +/** + * Follow/Unfollow Proxy API Route + * POST /api/v1/users/[userId]/follow - Follow a user (auth required) + * DELETE /api/v1/users/[userId]/follow - Unfollow a user (auth required) + */ + +import { NextRequest, NextResponse } from "next/server"; +import { API_BASE_URL } from "@/lib/server-env"; + +interface RouteParams { + params: Promise<{ userId: string }>; +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + const { userId } = await params; + const authHeader = request.headers.get("Authorization"); + if (!authHeader) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 } + ); + } + + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/users/${userId}/follow`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + } + ); + + if (response.status === 204) { + return new NextResponse(null, { status: 204 }); + } + + const data = await response.json().catch(() => ({ + message: `Backend error: ${response.status}`, + })); + return NextResponse.json(data, { status: response.status }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error("Follow POST proxy error:", error); + } + return NextResponse.json( + { message: error instanceof Error ? error.message : "Proxy error" }, + { status: 502 } + ); + } +} + +export async function DELETE(request: NextRequest, { params }: RouteParams) { + const { userId } = await params; + const authHeader = request.headers.get("Authorization"); + if (!authHeader) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 } + ); + } + + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/users/${userId}/follow`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + } + ); + + if (response.status === 204) { + return new NextResponse(null, { status: 204 }); + } + + const data = await response.json().catch(() => ({ + message: `Backend error: ${response.status}`, + })); + return NextResponse.json(data, { status: response.status }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error("Follow DELETE proxy error:", error); + } + return NextResponse.json( + { message: error instanceof Error ? error.message : "Proxy error" }, + { status: 502 } + ); + } +} diff --git a/packages/web/app/profile/ProfileClient.tsx b/packages/web/app/profile/ProfileClient.tsx index e174a6ee..790bc0db 100644 --- a/packages/web/app/profile/ProfileClient.tsx +++ b/packages/web/app/profile/ProfileClient.tsx @@ -28,6 +28,7 @@ import { ArchiveStats, InkEconomyCard, } from "@/lib/components/profile"; +import { StyleDNAEditModal } from "@/lib/components/profile/StyleDNAEditModal"; import { useMe, useUserStats, @@ -42,6 +43,7 @@ import { apiAvailableBadgeToStoreBadge, } from "@/lib/utils/badge-mapper"; import { apiMyRankingDetailToStoreRankings } from "@/lib/utils/ranking-mapper"; +import { useAuthStore } from "@/lib/stores/authStore"; function ProfileSkeleton() { return ( @@ -171,25 +173,27 @@ function ProfileError({ export function ProfileClient() { const router = useRouter(); const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isStyleDNAModalOpen, setIsStyleDNAModalOpen] = useState(false); const [activeTab, setActiveTab] = useState("posts"); + const isAuthReady = useAuthStore((s) => s.isInitialized); - // Fetch user data from API + // Fetch user data from API (wait for auth initialization) const { data: userData, isLoading: isUserLoading, isError: isUserError, error: userError, refetch: refetchUser, - } = useMe(); + } = useMe({ enabled: isAuthReady }); - // Fetch stats from API + // Fetch stats from API (wait for auth initialization) const { data: statsData, isLoading: isStatsLoading, isError: isStatsError, error: statsError, refetch: refetchStats, - } = useUserStats(); + } = useUserStats({ enabled: isAuthReady }); // Badges & Rankings (실제 API) const { data: badgesData, refetch: refetchBadges } = useMyBadges(); @@ -337,6 +341,8 @@ export function ProfileClient() { keywords={profileExtras?.style_dna?.keywords} colors={profileExtras?.style_dna?.colors} progress={profileExtras?.style_dna?.progress} + editable + onEditClick={() => setIsStyleDNAModalOpen(true)} /> @@ -390,6 +396,15 @@ export function ProfileClient() { isOpen={isEditModalOpen} onClose={() => setIsEditModalOpen(false)} /> + {userId && ( + setIsStyleDNAModalOpen(false)} + userId={userId} + initialKeywords={profileExtras?.style_dna?.keywords} + initialColors={profileExtras?.style_dna?.colors} + /> + )} ); } diff --git a/packages/web/app/profile/[userId]/PublicProfileClient.tsx b/packages/web/app/profile/[userId]/PublicProfileClient.tsx index 20d4231f..7baaa05e 100644 --- a/packages/web/app/profile/[userId]/PublicProfileClient.tsx +++ b/packages/web/app/profile/[userId]/PublicProfileClient.tsx @@ -10,6 +10,7 @@ import { ProfileHeaderCard } from "@/lib/design-system"; import { ProfileBio, FollowStats, + FollowButton, StyleDNACard, ProfileDesktopLayout, ActivityTabs, @@ -231,6 +232,8 @@ export function PublicProfileClient({ userId }: { userId: string }) { stats={formattedStats} /> + {me && } + + {me && } + { openGraph: { title, description, + url: `${SITE_URL}/profile/${userId}`, + type: "profile", ...(user?.avatar_url && { images: [{ url: user.avatar_url, width: 200, height: 200 }], }), }, + twitter: { + card: user?.avatar_url ? "summary" : "summary", + title, + description, + ...(user?.avatar_url && { images: [user.avatar_url] }), + }, robots: { index: true, follow: true }, + other: { + "profile:username": user?.username || "", + }, }; } export default async function PublicProfilePage({ params }: Props) { const { userId } = await params; - return ; + const supabase = await createSupabaseServerClient(); + + const { data: user } = await supabase + .from("users") + .select("display_name, username, avatar_url, bio") + .eq("id", userId) + .single(); + + const displayName = user?.display_name || user?.username || "User"; + const jsonLd = { + "@context": "https://schema.org", + "@type": "Person", + name: displayName, + url: `${SITE_URL}/profile/${userId}`, + ...(user?.avatar_url && { image: user.avatar_url }), + ...(user?.bio && { description: user.bio }), + }; + + return ( + <> +