From 4cf0f78827480eda0f412fbb76384104843307d4 Mon Sep 17 00:00:00 2001 From: kiet08hogit Date: Sun, 28 Jun 2026 21:20:03 -0500 Subject: [PATCH] review img feat --- backend/package-lock.json | 28 + backend/package.json | 1 + backend/prisma/schema.prisma | 30 + backend/src/app.module.ts | 2 + backend/src/modules/chat/chat.service.ts | 9 +- .../src/modules/listings/listings.service.ts | 11 +- .../modules/reviews/dto/create-review.dto.ts | 15 + .../src/modules/reviews/reviews.controller.ts | 21 + backend/src/modules/reviews/reviews.module.ts | 12 + .../src/modules/reviews/reviews.service.ts | 74 + backend/src/modules/users/users.controller.ts | 38 +- backend/src/modules/users/users.service.ts | 146 +- frontend/app/chat/page.tsx | 67 +- frontend/app/home/page.tsx | 31 +- frontend/app/listings/[id]/page.tsx | 47 +- frontend/app/listings/page.tsx | 49 +- frontend/app/onboarding/page.tsx | 5 +- frontend/app/profile/[id]/page.tsx | 491 +- frontend/app/settings/page.tsx | 15 +- frontend/app/swipe/page.tsx | 6 +- frontend/components/MiniChatWidget.tsx | 13 +- mobile/.gitignore | 6 + mobile/package-lock.json | 18524 ++++++++++++++++ 23 files changed, 19541 insertions(+), 100 deletions(-) create mode 100644 backend/src/modules/reviews/dto/create-review.dto.ts create mode 100644 backend/src/modules/reviews/reviews.controller.ts create mode 100644 backend/src/modules/reviews/reviews.module.ts create mode 100644 backend/src/modules/reviews/reviews.service.ts create mode 100644 mobile/package-lock.json diff --git a/backend/package-lock.json b/backend/package-lock.json index 48d2558..d450ed3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -40,6 +40,7 @@ "pg": "^8.21.0", "raw-body": "^3.0.2", "reflect-metadata": "^0.2.2", + "resend": "^6.16.0", "rxjs": "^7.8.2", "socket.io": "^4.8.3", "stripe": "^22.2.0", @@ -9963,6 +9964,12 @@ "node": ">=4" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postgres": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", @@ -10315,6 +10322,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.16.0.tgz", + "integrity": "sha512-SaKISwHtxvAxneF84Njgnzg+zdngUu1vOT/paRU1De9QF+zXQR3GnwJHSh+mpJPjUhsGD4WxYi5CfKkXMkDqwg==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index b70814f..d7011d6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -45,6 +45,7 @@ "pg": "^8.21.0", "raw-body": "^3.0.2", "reflect-metadata": "^0.2.2", + "resend": "^6.16.0", "rxjs": "^7.8.2", "socket.io": "^4.8.3", "stripe": "^22.2.0", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 98a8bec..d295c0c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -24,6 +24,7 @@ model User { stripeAccountId String? @unique stripeAccountLinked Boolean @default(false) university String? + isEduVerified Boolean @default(false) conversationMembers ConversationMember[] interactions Interaction[] listings Listing[] @@ -43,6 +44,10 @@ model User { notificationsReceived Notification[] @relation("NotificationsReceived") notificationsSent Notification[] @relation("NotificationsSent") + reviewsGiven Review[] @relation("ReviewsGiven") + reviewsReceived Review[] @relation("ReviewsReceived") + verificationCodes VerificationCode[] + // Notification Preferences emailNotifications Boolean @default(true) emailFrequency String @default("daily") @@ -252,6 +257,31 @@ enum InteractionType { SKIP } + + +model Review { + id String @id @default(uuid()) + reviewerId String + revieweeId String + rating Int + comment String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + reviewer User @relation("ReviewsGiven", fields: [reviewerId], references: [id]) + reviewee User @relation("ReviewsReceived", fields: [revieweeId], references: [id]) +} + +model VerificationCode { + id String @id @default(uuid()) + userId String + code String + expiresAt DateTime + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) +} + enum PaymentMethod { DIRECT STRIPE diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7f6c93b..8199178 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -21,6 +21,7 @@ import { APP_GUARD } from '@nestjs/core'; import { ReportsModule } from './modules/reports/reports.module'; import { PaymentsModule } from './modules/payments/payments.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; +import { ReviewsModule } from './modules/reviews/reviews.module'; @Module({ imports: [ ServeStaticModule.forRoot({ @@ -43,6 +44,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul JobsModule, PaymentsModule, NotificationsModule, + ReviewsModule, CacheModule.registerAsync({ isGlobal: true, imports: [ConfigModule], diff --git a/backend/src/modules/chat/chat.service.ts b/backend/src/modules/chat/chat.service.ts index f68ef62..efc7acc 100644 --- a/backend/src/modules/chat/chat.service.ts +++ b/backend/src/modules/chat/chat.service.ts @@ -70,7 +70,14 @@ export class ChatService { members: { include: { user: { - select: { id: true, name: true, avatarUrl: true, clerkUserId: true } + select: { + id: true, + name: true, + avatarUrl: true, + clerkUserId: true, + isEduVerified: true, + reviewsReceived: { select: { rating: true } } + } } } }, diff --git a/backend/src/modules/listings/listings.service.ts b/backend/src/modules/listings/listings.service.ts index b8e20d3..7747b14 100644 --- a/backend/src/modules/listings/listings.service.ts +++ b/backend/src/modules/listings/listings.service.ts @@ -83,7 +83,16 @@ export class ListingsService { orderBy: { createdAt: 'desc' }, include: { images: true, - seller: { select: { id: true, email: true, university: true } }, + seller: { + select: { + id: true, + email: true, + university: true, + isEduVerified: true, + username: true, + reviewsReceived: { select: { rating: true } } + } + }, }, }); } diff --git a/backend/src/modules/reviews/dto/create-review.dto.ts b/backend/src/modules/reviews/dto/create-review.dto.ts new file mode 100644 index 0000000..cb7eacf --- /dev/null +++ b/backend/src/modules/reviews/dto/create-review.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsInt, Min, Max, IsOptional } from 'class-validator'; + +export class CreateReviewDto { + @IsString() + revieweeId: string; + + @IsInt() + @Min(1) + @Max(5) + rating: number; + + @IsOptional() + @IsString() + comment?: string; +} diff --git a/backend/src/modules/reviews/reviews.controller.ts b/backend/src/modules/reviews/reviews.controller.ts new file mode 100644 index 0000000..09e8465 --- /dev/null +++ b/backend/src/modules/reviews/reviews.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Post, Get, Body, Param, Req, UseGuards } from '@nestjs/common'; +import { ReviewsService } from './reviews.service'; +import { CreateReviewDto } from './dto/create-review.dto'; +import { ClerkAuthGuard } from '../../common/guards/clerk-auth.guard'; + +@Controller('reviews') +export class ReviewsController { + constructor(private readonly reviewsService: ReviewsService) {} + + @Post() + @UseGuards(ClerkAuthGuard) + createReview(@Req() req, @Body() createReviewDto: CreateReviewDto) { + const clerkUserId = req.user.clerkUserId; + return this.reviewsService.createReview(clerkUserId, createReviewDto); + } + + @Get('user/:id') + getUserReviews(@Param('id') userId: string) { + return this.reviewsService.getUserReviews(userId); + } +} diff --git a/backend/src/modules/reviews/reviews.module.ts b/backend/src/modules/reviews/reviews.module.ts new file mode 100644 index 0000000..b2b7172 --- /dev/null +++ b/backend/src/modules/reviews/reviews.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ReviewsService } from './reviews.service'; +import { ReviewsController } from './reviews.controller'; +import { PrismaModule } from '../../database/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [ReviewsController], + providers: [ReviewsService], + exports: [ReviewsService], +}) +export class ReviewsModule {} diff --git a/backend/src/modules/reviews/reviews.service.ts b/backend/src/modules/reviews/reviews.service.ts new file mode 100644 index 0000000..40cdaf3 --- /dev/null +++ b/backend/src/modules/reviews/reviews.service.ts @@ -0,0 +1,74 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../../database/prisma.service'; +import { CreateReviewDto } from './dto/create-review.dto'; + +@Injectable() +export class ReviewsService { + constructor(private prisma: PrismaService) {} + + async createReview(clerkUserId: string, createReviewDto: CreateReviewDto) { + const reviewer = await this.prisma.user.findUnique({ where: { clerkUserId } }); + if (!reviewer) throw new NotFoundException('Reviewer not found'); + + if (reviewer.id === createReviewDto.revieweeId) { + throw new BadRequestException('You cannot review yourself'); + } + + // Ensure they have chatted (they share a conversation) + const hasChatted = await this.prisma.conversation.findFirst({ + where: { + AND: [ + { members: { some: { userId: reviewer.id } } }, + { members: { some: { userId: createReviewDto.revieweeId } } } + ] + } + }); + + if (!hasChatted) { + throw new BadRequestException('You can only leave a review for someone you have chatted with.'); + } + + // Ensure no existing review for this user pair + const existingReview = await this.prisma.review.findFirst({ + where: { + reviewerId: reviewer.id, + revieweeId: createReviewDto.revieweeId + } + }); + + if (existingReview) { + throw new BadRequestException('You have already left a review for this user.'); + } + + return this.prisma.review.create({ + data: { + reviewerId: reviewer.id, + revieweeId: createReviewDto.revieweeId, + rating: createReviewDto.rating, + comment: createReviewDto.comment, + }, + include: { + reviewer: { + select: { id: true, name: true, username: true, avatarUrl: true } + } + } + }); + } + + async getUserReviews(userId: string) { + const reviews = await this.prisma.review.findMany({ + where: { revieweeId: userId }, + include: { + reviewer: { + select: { id: true, name: true, username: true, avatarUrl: true } + } + }, + orderBy: { createdAt: 'desc' } + }); + + const totalRating = reviews.reduce((acc, curr) => acc + curr.rating, 0); + const averageRating = reviews.length > 0 ? (totalRating / reviews.length) : 0; + + return { reviews, averageRating, totalCount: reviews.length }; + } +} diff --git a/backend/src/modules/users/users.controller.ts b/backend/src/modules/users/users.controller.ts index 7f85d54..f741d2b 100644 --- a/backend/src/modules/users/users.controller.ts +++ b/backend/src/modules/users/users.controller.ts @@ -1,8 +1,7 @@ -import { Controller, Get, Patch, UseGuards, Param, Body, NotFoundException, ConflictException, UseInterceptors, Query, Post } from '@nestjs/common'; -import { CacheInterceptor } from '@nestjs/cache-manager'; +import { Controller, Get, Patch, UseGuards, Param, Body, NotFoundException, ConflictException, Query, Post, Delete } from '@nestjs/common'; import { UsersService } from './users.service'; import { ClerkAuthGuard } from '../../common/guards/clerk-auth.guard'; -import { CurrentUser} from '../../common/decorators/current-user.decorator'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { AuthUser } from '../../common/types/auth-user.type'; @Controller('users') @@ -25,7 +24,6 @@ export class UsersController { try { return await this.usersService.updateUser(clerkUser.clerkUserId, updateData); } catch (error: any) { - // Prisma error code for Unique Constraint Violation if (error.code === 'P2002') { throw new ConflictException('Username is already taken.'); } @@ -44,12 +42,24 @@ export class UsersController { @UseGuards(ClerkAuthGuard) async getUser(@Param('id') id: string, @CurrentUser() clerkUser: AuthUser) { const user = await this.usersService.getUserById(id, clerkUser?.clerkUserId); - if (!user){ + if (!user) { throw new NotFoundException("User not found"); } return user; } + @Get(':id/followers') + @UseGuards(ClerkAuthGuard) + async getFollowers(@Param('id') id: string) { + return this.usersService.getFollowers(id); + } + + @Get(':id/following') + @UseGuards(ClerkAuthGuard) + async getFollowing(@Param('id') id: string) { + return this.usersService.getFollowing(id); + } + @Post(':id/follow') @UseGuards(ClerkAuthGuard) async toggleFollow(@Param('id') id: string, @CurrentUser() clerkUser: AuthUser) { @@ -58,5 +68,23 @@ export class UsersController { return result; } + @Delete(':id/follower') + @UseGuards(ClerkAuthGuard) + async removeFollower(@Param('id') id: string, @CurrentUser() clerkUser: AuthUser) { + const result = await this.usersService.removeFollower(id, clerkUser.clerkUserId); + if (!result) throw new NotFoundException("Follower not found"); + return result; + } + + @Post('verify-edu/send') + @UseGuards(ClerkAuthGuard) + async sendEduVerification(@CurrentUser() clerkUser: AuthUser) { + return this.usersService.sendEduVerification(clerkUser.clerkUserId); + } + @Post('verify-edu/verify') + @UseGuards(ClerkAuthGuard) + async verifyEduCode(@CurrentUser() clerkUser: AuthUser, @Body('code') code: string) { + return this.usersService.verifyEduCode(clerkUser.clerkUserId, code); + } } diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts index 8c44b10..62e3c3b 100644 --- a/backend/src/modules/users/users.service.ts +++ b/backend/src/modules/users/users.service.ts @@ -1,6 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../database/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { Resend } from 'resend'; @Injectable() export class UsersService { @@ -71,6 +72,7 @@ export class UsersService { if (!user) return null; let isFollowing = false; + let hasChatted = false; if (currentUserId) { const follower = await this.prisma.user.findFirst({ where: { OR: [{ id: currentUserId }, { clerkUserId: currentUserId }] }, @@ -81,10 +83,20 @@ export class UsersService { where: { id: user.id, followers: { some: { id: follower.id } } } }); isFollowing = !!link; + + const chat = await this.prisma.conversation.findFirst({ + where: { + AND: [ + { members: { some: { userId: follower.id } } }, + { members: { some: { userId: user.id } } } + ] + } + }); + hasChatted = !!chat; } } - return { ...user, isFollowing }; + return { ...user, isFollowing, hasChatted }; } async toggleFollow(targetId: string, currentUserId: string) { @@ -143,9 +155,139 @@ export class UsersService { }); } + async getFollowers(id: string) { + const user = await this.prisma.user.findFirst({ + where: { + OR: [{ id }, { clerkUserId: id }] + }, + include: { + followers: { + select: { id: true, clerkUserId: true, name: true, username: true, avatarUrl: true, isEduVerified: true } + } + } + }); + if (!user) throw new NotFoundException('User not found'); + return user.followers; + } + + async getFollowing(id: string) { + const user = await this.prisma.user.findFirst({ + where: { + OR: [{ id }, { clerkUserId: id }] + }, + include: { + following: { + select: { id: true, clerkUserId: true, name: true, username: true, avatarUrl: true, isEduVerified: true } + } + } + }); + if (!user) throw new NotFoundException('User not found'); + return user.following; + } + + async removeFollower(followerId: string, currentUserId: string) { + const currentUser = await this.prisma.user.findUnique({ + where: { clerkUserId: currentUserId } + }); + + if (!currentUser) throw new NotFoundException('Current user not found'); + + // Verify the follower exists + const follower = await this.prisma.user.findFirst({ + where: { + OR: [{ id: followerId }, { clerkUserId: followerId }] + } + }); + + if (!follower) throw new NotFoundException('Follower not found'); + + // Disconnect the follower from current user's followers list + await this.prisma.user.update({ + where: { id: currentUser.id }, + data: { + followers: { + disconnect: { id: follower.id } + } + } + }); + + return { success: true }; + } + async deleteUserByClerkId(clerkUserId: string) { return this.prisma.user.delete({ where: { clerkUserId }, }); } + + async sendEduVerification(clerkUserId: string) { + const user = await this.prisma.user.findUnique({ where: { clerkUserId } }); + if (!user) throw new NotFoundException('User not found'); + + const email = user.email; + if (!email || !email.endsWith('.edu')) { + throw new BadRequestException('Your account email must be a valid .edu email address to verify.'); + } + + // Generate 6-digit code + const code = Math.floor(100000 + Math.random() * 900000).toString(); + + await this.prisma.verificationCode.create({ + data: { + userId: user.id, + code, + expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 mins + } + }); + + const resend = new Resend(process.env.RESEND_API_KEY); + try { + await resend.emails.send({ + from: 'Orbit ', + to: email, + subject: 'Verify your .edu email for Orbit', + html: ` +

Welcome to Orbit!

+

Your verification code is: ${code}

+

This code will expire in 15 minutes.

+ ` + }); + console.log(`[EDU VERIFICATION] Sent email to ${email} successfully.`); + } catch (error) { + console.error('[EDU VERIFICATION] Failed to send email via Resend:', error); + throw new BadRequestException('Failed to send verification email. Please ensure your email is correct and try again.'); + } + + return { message: 'Verification code sent!' }; + } + + async verifyEduCode(clerkUserId: string, code: string) { + const user = await this.prisma.user.findUnique({ where: { clerkUserId } }); + if (!user) throw new NotFoundException('User not found'); + + const verificationRecord = await this.prisma.verificationCode.findFirst({ + where: { + userId: user.id, + code, + expiresAt: { gt: new Date() } // Not expired + }, + orderBy: { createdAt: 'desc' } + }); + + if (!verificationRecord) { + throw new BadRequestException('Invalid or expired verification code.'); + } + + await this.prisma.user.update({ + where: { id: user.id }, + data: { isEduVerified: true } + }); + + // Delete used code + await this.prisma.verificationCode.deleteMany({ + where: { userId: user.id } + }); + + return { verified: true }; + } } diff --git a/frontend/app/chat/page.tsx b/frontend/app/chat/page.tsx index 5b666f3..445a024 100644 --- a/frontend/app/chat/page.tsx +++ b/frontend/app/chat/page.tsx @@ -13,8 +13,11 @@ import { MoreVertical, MessageSquare, AlertTriangle, + BadgeCheck, + Star, } from "lucide-react"; import axios from "axios"; +import Link from "next/link"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; @@ -41,6 +44,8 @@ interface UserPreview { username: string | null; avatarUrl: string | null; clerkUserId: string; + isEduVerified?: boolean; + reviewsReceived?: { rating: number }[]; } interface Message { @@ -919,19 +924,48 @@ export default function ChatPage() { > - - {otherActiveMember?.avatarUrl && ( - - )} - - {otherActiveInitial} - - + + + {otherActiveMember?.avatarUrl && ( + + )} + + {otherActiveInitial} + + -
-

- {otherActiveName} -

+
+
+
+

+ {otherActiveName} +

+ {otherActiveMember?.isEduVerified && ( +
+ +
+ )} +
+ {otherActiveMember?.reviewsReceived && otherActiveMember.reviewsReceived.length > 0 && ( +
+ + + {( + otherActiveMember.reviewsReceived.reduce((sum: number, r: any) => sum + r.rating, 0) / + otherActiveMember.reviewsReceived.length + ).toFixed(1)} + + + ({otherActiveMember.reviewsReceived.length}) + +
+ )} +
+
+ {/* Verification Dashboard Button (Seller) */}
@@ -957,7 +991,6 @@ export default function ChatPage() {
)}
-
{meetupError && ( @@ -1046,9 +1079,11 @@ export default function ChatPage() {
{showAvatar && ( - + {msg.sender?.avatarUrl && ( + + )} {msg.sender?.name?.[0]?.toUpperCase() || "U"} diff --git a/frontend/app/home/page.tsx b/frontend/app/home/page.tsx index 0aee5a3..2f7990b 100644 --- a/frontend/app/home/page.tsx +++ b/frontend/app/home/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; import { useAuth } from "@clerk/nextjs"; import { motion } from "framer-motion"; -import { Loader2, Tag, Flame, Eye, ChevronRight, Heart } from "lucide-react"; +import { Loader2, Tag, Flame, Eye, ChevronRight, Heart, BadgeCheck, Star } from "lucide-react"; import axios from "axios"; import Link from "next/link"; @@ -14,6 +14,8 @@ interface Seller { avatarUrl?: string; email?: string; university?: string; + isEduVerified?: boolean; + reviewsReceived?: any[]; } interface Listing { @@ -270,13 +272,34 @@ function ListingCard({ listing }: { listing: Listing }) { {listing.title} - {listing.seller?.university && ( -
+
+ {listing.seller?.university ? ( 🎓 {listing.seller.university} + ) : ( + + )} + +
+ {listing.seller?.reviewsReceived && listing.seller.reviewsReceived.length > 0 && ( +
+ + + {( + listing.seller.reviewsReceived.reduce((sum: number, r: any) => sum + r.rating, 0) / + listing.seller.reviewsReceived.length + ).toFixed(1)} + +
+ )} + {listing.seller?.isEduVerified && ( +
+ +
+ )}
- )} +
diff --git a/frontend/app/listings/[id]/page.tsx b/frontend/app/listings/[id]/page.tsx index c26e1ca..3c6a1bf 100644 --- a/frontend/app/listings/[id]/page.tsx +++ b/frontend/app/listings/[id]/page.tsx @@ -578,31 +578,32 @@ export default function ListingDetailPage() { initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }} - className="flex items-center gap-3 mb-1.5" > - - {listing.seller?.avatarUrl && ( - - )} - - {sellerInitial} - - -
-

- Listed by{" "} - - {sellerName} - -

-
- - UIC Verified + + + {listing.seller?.avatarUrl && ( + + )} + + {sellerInitial} + + +
+

+ Listed by{" "} + + {sellerName} + +

+
+ + UIC Verified +
-
+

diff --git a/frontend/app/listings/page.tsx b/frontend/app/listings/page.tsx index f4d6e76..2987a1d 100644 --- a/frontend/app/listings/page.tsx +++ b/frontend/app/listings/page.tsx @@ -2,7 +2,19 @@ import { useState, useEffect } from "react"; import { useAuth } from "@clerk/nextjs"; -import { Loader2, Filter, Tag, Heart, GraduationCap } from "lucide-react"; +import { + Loader2, + Settings2, + ListFilter, + CheckCircle2, + AlertCircle, + BadgeCheck, + Star, + Heart, + GraduationCap, + Filter, + Tag, +} from "lucide-react"; import { motion } from "framer-motion"; import axios from "axios"; import Link from "next/link"; @@ -25,6 +37,8 @@ interface Seller { avatarUrl?: string; email?: string; university?: string; + isEduVerified?: boolean; + reviewsReceived?: any[]; } interface Listing { @@ -509,12 +523,35 @@ function ListingCard({ listing }: { listing: Listing }) { {listing.title} - {listing.seller?.university && ( -

- - {listing.seller.university} +
+ {listing.seller?.university ? ( + + + {listing.seller.university} + + ) : ( + + )} + +
+ {listing.seller?.reviewsReceived && listing.seller.reviewsReceived.length > 0 && ( +
+ + + {( + listing.seller.reviewsReceived.reduce((sum: number, r: any) => sum + r.rating, 0) / + listing.seller.reviewsReceived.length + ).toFixed(1)} + +
+ )} + {listing.seller?.isEduVerified && ( +
+ +
+ )}
- )} +
diff --git a/frontend/app/onboarding/page.tsx b/frontend/app/onboarding/page.tsx index 2e5ba90..fe1372c 100644 --- a/frontend/app/onboarding/page.tsx +++ b/frontend/app/onboarding/page.tsx @@ -57,9 +57,10 @@ export default function OnboardingPage() { "Junior", "Senior", "Graduate", - "Faculty/Staff", ]; + + useEffect(() => { if (user?.primaryEmailAddress?.emailAddress) { const email = user.primaryEmailAddress.emailAddress; @@ -278,6 +279,8 @@ export default function OnboardingPage() { />
+ + {/* Class Year */}
+ {/* Edu Verification Section */} +
+

+ .edu Email Verification + {userProfile?.isEduVerified && ( + + )} +

+ {userProfile?.isEduVerified ? ( +

+ Your account is fully verified. +

+ ) : ( +
+

+ Verify your student or staff status to unlock the verified + badge on your profile. We will send a code to your registered account email. +

+ {!codeSent ? ( + + ) : ( +
+ setVerificationCode(e.target.value)} + className="bg-secondary h-8 text-xs max-w-[150px]" + maxLength={6} + /> + +
+ )} +
+ )} +
) : ( <>
-
-

- {userProfile.name || "Anonymous User"} -

-
- -
-
-

- @{userProfile.username || userProfile.id.slice(0, 8)} +

+ {userProfile?.name || "Orbit User"} + {userProfile?.isEduVerified && ( + + )} +

+

+ @{userProfile?.username || "username"}

+ {reviewsData && reviewsData.totalCount > 0 && ( +
+ + {reviewsData.averageRating.toFixed(1)} + ({reviewsData.totalCount} reviews) +
+ )}
{userProfile.major && ( @@ -450,6 +671,7 @@ export default function ProfilePage() { {userProfile.classYear}
)} +
Joined {joinDate} @@ -498,11 +720,21 @@ export default function ProfilePage() { {isFollowLoading ? ( ) : isFollowing ? ( - "Unfollow" + "Followed" ) : ( "Follow" )} + {hasChatted && ( + + )}
@@ -617,22 +851,22 @@ export default function ProfilePage() { {listingsCount}
-
+
-
+ +
+
{/* Stripe Connection Section */} @@ -760,21 +994,216 @@ export default function ProfilePage() { ))}
) : ( -
-
- -
-

- No active listings +
+ +

+ No listings yet +

+

+ When {userProfile.name?.split(" ")[0] || "this user"} posts + items for sale, they will appear here. +

+
+ )} +

+ + {/* User's Reviews Section */} +
+

+ Reviews ({reviewsData?.totalCount || 0}) +

+ {reviewsData && reviewsData.reviews && reviewsData.reviews.length > 0 ? ( +
+ {reviewsData.reviews.map((review: any) => ( +
+
+ {review.reviewer.avatarUrl ? ( + Reviewer + ) : ( +
+ {(review.reviewer.name || review.reviewer.username || "U").charAt(0).toUpperCase()} +
+ )} +
+

{review.reviewer.name || review.reviewer.username}

+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+
+ {review.comment && ( +

{review.comment}

+ )} +
+ ))} +
+ ) : ( +
+ +

+ No reviews yet

-

- This student doesn't have any items for sale right now. +

+ {userProfile.name?.split(" ")[0] || "This user"} hasn't received any reviews.

)}
+ + {/* Followers Dialog */} + + + + Followers + +
+ {isLoadingFollowData ? ( +
+ ) : followersList.length === 0 ? ( +

No followers yet.

+ ) : ( +
+ {followersList.map((user) => ( +
+ setShowFollowers(false)} className="flex items-center gap-3 flex-1"> + {user.avatarUrl ? ( + {user.name} + ) : ( +
+ {(user.name || user.username || "U").charAt(0).toUpperCase()} +
+ )} +
+
+ {user.name || user.username} + {user.isEduVerified && } +
+ @{user.username} +
+ + {isOwnProfile && ( + + )} +
+ ))} +
+ )} +
+
+
+ + {/* Following Dialog */} + + + + Following + +
+ {isLoadingFollowData ? ( +
+ ) : followingList.length === 0 ? ( +

Not following anyone yet.

+ ) : ( +
+ {followingList.map((user) => ( +
+ setShowFollowing(false)} className="flex items-center gap-3 flex-1"> + {user.avatarUrl ? ( + {user.name} + ) : ( +
+ {(user.name || user.username || "U").charAt(0).toUpperCase()} +
+ )} +
+
+ {user.name || user.username} + {user.isEduVerified && } +
+ @{user.username} +
+ + {isOwnProfile && ( + + )} +
+ ))} +
+ )} +
+
+
+ {/* Leave Review Dialog */} + + + + Leave a Review + +
+
+ Rating +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+
+
+ Comment (Optional) +