diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index f693bd9..98a8bec 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -37,6 +37,12 @@ model User {
buyerTransactions Transaction[] @relation("BuyerTransactions")
sellerTransactions Transaction[] @relation("SellerTransactions")
+ followers User[] @relation("UserFollows")
+ following User[] @relation("UserFollows")
+
+ notificationsReceived Notification[] @relation("NotificationsReceived")
+ notificationsSent Notification[] @relation("NotificationsSent")
+
// Notification Preferences
emailNotifications Boolean @default(true)
emailFrequency String @default("daily")
@@ -87,16 +93,17 @@ model ListingImage {
}
model Post {
- id String @id @default(uuid())
- content String
- postType PostType
- imageUrls String[]
- authorId String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- author User @relation(fields: [authorId], references: [id])
- comments PostComment[]
- likes PostLike[]
+ id String @id @default(uuid())
+ content String
+ postType PostType
+ imageUrls String[]
+ authorId String
+ isAnonymous Boolean @default(false)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ author User @relation(fields: [authorId], references: [id])
+ comments PostComment[]
+ likes PostLike[]
}
model PostLike {
@@ -215,7 +222,8 @@ model ListingView {
}
enum ListingCategory {
- HOUSING
+ DORM
+ SUBLEASE
CLOTHES
SCHOOL
LEISURE
@@ -277,3 +285,29 @@ enum MeetupStatus {
ACCEPTED
CANCELLED
}
+
+enum NotificationType {
+ FOLLOW
+ LIKE
+ COMMENT
+ PURCHASE
+ OFFER
+}
+
+model Notification {
+ id String @id @default(uuid())
+ userId String
+ type NotificationType
+ title String
+ content String?
+ isRead Boolean @default(false)
+ linkUrl String?
+ createdAt DateTime @default(now())
+
+ actorId String?
+ postId String?
+ listingId String?
+
+ user User @relation("NotificationsReceived", fields: [userId], references: [id])
+ actor User? @relation("NotificationsSent", fields: [actorId], references: [id])
+}
diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts
index e449d54..7f6c93b 100644
--- a/backend/src/app.module.ts
+++ b/backend/src/app.module.ts
@@ -20,7 +20,7 @@ import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis';
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';
@Module({
imports: [
ServeStaticModule.forRoot({
@@ -40,6 +40,9 @@ import { PaymentsModule } from './modules/payments/payments.module';
PostsModule,
TransactionsModule,
ReportsModule,
+ JobsModule,
+ PaymentsModule,
+ NotificationsModule,
CacheModule.registerAsync({
isGlobal: true,
imports: [ConfigModule],
diff --git a/backend/src/modules/listings/listings.controller.ts b/backend/src/modules/listings/listings.controller.ts
index d8d9219..722be89 100644
--- a/backend/src/modules/listings/listings.controller.ts
+++ b/backend/src/modules/listings/listings.controller.ts
@@ -29,7 +29,6 @@ export class ListingsController {
}
@Get('all')
- @UseGuards(ClerkAuthGuard)
@UseInterceptors(CacheInterceptor)
async getAllListings(
@Query('category') category?: ListingCategory,
@@ -39,7 +38,6 @@ export class ListingsController {
}
@Get('recommendations')
- @UseGuards(ClerkAuthGuard)
@UseInterceptors(CacheInterceptor)
async getRecommendations(
@Query('q') q: string,
@@ -104,7 +102,6 @@ export class ListingsController {
}
@Get(':id')
- @UseGuards(ClerkAuthGuard)
@UseInterceptors(CacheInterceptor)
async getListing(@Param('id') id: string) {
const listing = await this.listingsService.findById(id);
diff --git a/backend/src/modules/notifications/notifications.controller.ts b/backend/src/modules/notifications/notifications.controller.ts
new file mode 100644
index 0000000..1677961
--- /dev/null
+++ b/backend/src/modules/notifications/notifications.controller.ts
@@ -0,0 +1,33 @@
+import { Controller, Get, Patch, Param, Query, UseGuards, Req } from '@nestjs/common';
+import { NotificationsService } from './notifications.service';
+import { ClerkAuthGuard } from '../../common/guards/clerk-auth.guard';
+
+@Controller('notifications')
+@UseGuards(ClerkAuthGuard)
+export class NotificationsController {
+ constructor(private readonly notificationsService: NotificationsService) {}
+
+ @Get()
+ async getNotifications(@Req() req, @Query('filter') filter?: string) {
+ const clerkUserId = req.user.clerkUserId;
+ return this.notificationsService.getUserNotifications(clerkUserId, filter);
+ }
+
+ @Get('unread-count')
+ async getUnreadCount(@Req() req) {
+ const clerkUserId = req.user.clerkUserId;
+ return this.notificationsService.getUnreadCount(clerkUserId);
+ }
+
+ @Patch('read-all')
+ async markAllAsRead(@Req() req) {
+ const clerkUserId = req.user.clerkUserId;
+ return this.notificationsService.markAllAsRead(clerkUserId);
+ }
+
+ @Patch(':id/read')
+ async markAsRead(@Req() req, @Param('id') id: string) {
+ const clerkUserId = req.user.clerkUserId;
+ return this.notificationsService.markAsRead(clerkUserId, id);
+ }
+}
diff --git a/backend/src/modules/notifications/notifications.module.ts b/backend/src/modules/notifications/notifications.module.ts
new file mode 100644
index 0000000..e1760ac
--- /dev/null
+++ b/backend/src/modules/notifications/notifications.module.ts
@@ -0,0 +1,13 @@
+import { Module } from '@nestjs/common';
+import { NotificationsService } from './notifications.service';
+import { NotificationsController } from './notifications.controller';
+import { PrismaModule } from '../../database/prisma.module';
+import { ChatModule } from '../chat/chat.module';
+
+@Module({
+ imports: [PrismaModule, ChatModule],
+ providers: [NotificationsService],
+ controllers: [NotificationsController],
+ exports: [NotificationsService],
+})
+export class NotificationsModule {}
diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts
new file mode 100644
index 0000000..e6897ed
--- /dev/null
+++ b/backend/src/modules/notifications/notifications.service.ts
@@ -0,0 +1,101 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { PrismaService } from '../../database/prisma.service';
+import { ChatGateway } from '../chat/chat.gateway';
+import { NotificationType } from '@prisma/client';
+
+@Injectable()
+export class NotificationsService {
+ constructor(
+ private prisma: PrismaService,
+ private chatGateway: ChatGateway
+ ) {}
+
+ async createNotification(data: {
+ userId: string;
+ type: NotificationType;
+ title: string;
+ content?: string;
+ linkUrl?: string;
+ actorId?: string;
+ postId?: string;
+ listingId?: string;
+ }) {
+ // Don't notify yourself
+ if (data.userId === data.actorId) return null;
+
+ const notification = await this.prisma.notification.create({
+ data,
+ include: {
+ actor: {
+ select: { name: true, username: true, avatarUrl: true }
+ }
+ }
+ });
+
+ // We can emit this notification directly through the chat gateway
+ // Assuming user joined a room with their userId
+ const user = await this.prisma.user.findUnique({ where: { id: data.userId } });
+ if (user) {
+ this.chatGateway.server.to(user.clerkUserId).emit('new_notification', notification);
+ }
+
+ return notification;
+ }
+
+ async getUserNotifications(clerkUserId: string, filter?: string) {
+ const user = await this.prisma.user.findUnique({ where: { clerkUserId } });
+ if (!user) throw new NotFoundException('User not found');
+
+ const whereClause: any = { userId: user.id };
+
+ if (filter && filter !== 'ALL') {
+ whereClause.type = filter as NotificationType;
+ }
+
+ return this.prisma.notification.findMany({
+ where: whereClause,
+ orderBy: { createdAt: 'desc' },
+ take: 50,
+ include: {
+ actor: { select: { name: true, username: true, avatarUrl: true } }
+ }
+ });
+ }
+
+ async getUnreadCount(clerkUserId: string) {
+ const user = await this.prisma.user.findUnique({ where: { clerkUserId } });
+ if (!user) return { unreadCount: 0 };
+
+ const count = await this.prisma.notification.count({
+ where: { userId: user.id, isRead: false }
+ });
+ return { unreadCount: count };
+ }
+
+ async markAsRead(clerkUserId: string, notificationId: string) {
+ const user = await this.prisma.user.findUnique({ where: { clerkUserId } });
+ if (!user) throw new NotFoundException('User not found');
+
+ const notification = await this.prisma.notification.findUnique({ where: { id: notificationId } });
+ if (!notification || notification.userId !== user.id) {
+ throw new NotFoundException('Notification not found');
+ }
+
+ return this.prisma.notification.update({
+ where: { id: notificationId },
+ data: { isRead: true }
+ });
+ }
+
+ async markAllAsRead(clerkUserId: string) {
+ const user = await this.prisma.user.findUnique({ where: { clerkUserId } });
+ if (!user) throw new NotFoundException('User not found');
+
+ await this.prisma.notification.updateMany({
+ where: { userId: user.id, isRead: false },
+ data: { isRead: true }
+ });
+
+ return { success: true };
+ }
+}
diff --git a/backend/src/modules/payments/payments.module.ts b/backend/src/modules/payments/payments.module.ts
index a8e7dad..2a0d0e7 100644
--- a/backend/src/modules/payments/payments.module.ts
+++ b/backend/src/modules/payments/payments.module.ts
@@ -1,12 +1,14 @@
-import { Module } from '@nestjs/common';
+import { Module, forwardRef } from '@nestjs/common';
import { PaymentsService } from './payments.service';
import { PaymentsController } from './payments.controller';
import { PrismaModule } from '../../database/prisma.module';
+import { TransactionsModule } from '../transactions/transactions.module';
+import { NotificationsModule } from '../notifications/notifications.module';
@Module({
- imports: [PrismaModule],
- providers: [PaymentsService],
- controllers: [PaymentsController],
- exports: [PaymentsService]
+ imports: [PrismaModule, forwardRef(() => TransactionsModule), NotificationsModule],
+ providers: [PaymentsService],
+ controllers: [PaymentsController],
+ exports: [PaymentsService]
})
export class PaymentsModule {}
diff --git a/backend/src/modules/payments/payments.service.ts b/backend/src/modules/payments/payments.service.ts
index d827469..c9295c2 100644
--- a/backend/src/modules/payments/payments.service.ts
+++ b/backend/src/modules/payments/payments.service.ts
@@ -1,5 +1,7 @@
-import { Injectable, NotFoundException, BadRequestException, InternalServerErrorException } from '@nestjs/common';
+import { Injectable, NotFoundException, BadRequestException, Inject, forwardRef } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
+import { TransactionsService } from '../transactions/transactions.service';
+import { NotificationsService } from '../notifications/notifications.service';
// Stripe v22 CJS types aren't easily importable — use require + any for the instance
// eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -9,7 +11,12 @@ const Stripe = require('stripe');
export class PaymentsService {
private stripe: any;
- constructor(private prisma: PrismaService) {
+ constructor(
+ private prisma: PrismaService,
+ @Inject(forwardRef(() => TransactionsService))
+ private transactionsService: TransactionsService,
+ private notificationsService: NotificationsService
+ ) {
this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', {
apiVersion: '2026-05-27.dahlia',
});
@@ -225,7 +232,6 @@ export class PaymentsService {
})
]);
- // Also ensure a chat message exists so the Chat UI can attach the listing context
let conversation = await this.prisma.conversation.findFirst({
where: {
AND: [
@@ -235,6 +241,21 @@ export class PaymentsService {
}
});
+ const buyer = await this.prisma.user.findUnique({ where: { id: transaction.buyerId } });
+ const listing = await this.prisma.listing.findUnique({ where: { id: transaction.listingId } });
+
+ if (buyer && listing) {
+ await this.notificationsService.createNotification({
+ userId: transaction.sellerId,
+ type: 'PURCHASE',
+ title: 'New Reservation',
+ content: `${buyer.name || buyer.username || 'A student'} paid and reserved your item: ${listing.title}`,
+ actorId: buyer.id,
+ listingId: listing.id,
+ linkUrl: `/purchase-history`
+ });
+ }
+
if (!conversation) {
conversation = await this.prisma.conversation.create({
data: {
diff --git a/backend/src/modules/posts/dto/create-post.dto.ts b/backend/src/modules/posts/dto/create-post.dto.ts
index 1bc4744..72ec231 100644
--- a/backend/src/modules/posts/dto/create-post.dto.ts
+++ b/backend/src/modules/posts/dto/create-post.dto.ts
@@ -1,4 +1,5 @@
import { IsString, IsNotEmpty, IsEnum, IsOptional, MaxLength } from 'class-validator';
+import { Transform } from 'class-transformer';
import { PostType } from '@prisma/client';
export class CreatePostDto {
@@ -11,4 +12,8 @@ export class CreatePostDto {
@IsEnum(PostType)
@IsNotEmpty()
postType: PostType;
+
+ @IsOptional()
+ @Transform(({ value }) => value === 'true' || value === true)
+ isAnonymous?: boolean;
}
diff --git a/backend/src/modules/posts/posts.controller.ts b/backend/src/modules/posts/posts.controller.ts
index 33b2130..4f53757 100644
--- a/backend/src/modules/posts/posts.controller.ts
+++ b/backend/src/modules/posts/posts.controller.ts
@@ -1,4 +1,5 @@
-import { Controller, Get, Post, Body, UseGuards, Req, UseInterceptors, UploadedFiles, Param } from '@nestjs/common';
+import { Controller, Get, Post, Delete, Body, UseGuards, Req, UseInterceptors, UploadedFiles, Param, Query } from '@nestjs/common';
+import { PostType } from '@prisma/client';
import { CacheInterceptor } from '@nestjs/cache-manager';
import { FilesInterceptor } from '@nestjs/platform-express';
import { PostsService } from './posts.service';
@@ -11,9 +12,9 @@ export class PostsController {
@Get()
@UseGuards(ClerkAuthGuard)
- async getAllPosts(@Req() req) {
+ async getAllPosts(@Req() req, @Query('type') type?: PostType) {
const clerkUserId = req.user.clerkUserId;
- const posts = await this.postsService.getAllPosts(clerkUserId);
+ const posts = await this.postsService.getAllPosts(clerkUserId, type);
const ids = posts.map(p => p.id);
const dups = ids.filter((item, index) => ids.indexOf(item) !== index);
if (dups.length > 0) {
@@ -53,4 +54,11 @@ export class PostsController {
getComments(@Param('id') id: string) {
return this.postsService.getComments(id);
}
+
+ @Delete(':id')
+ @UseGuards(ClerkAuthGuard)
+ deletePost(@Req() req, @Param('id') id: string) {
+ const clerkUserId = req.user.clerkUserId;
+ return this.postsService.deletePost(clerkUserId, id);
+ }
}
diff --git a/backend/src/modules/posts/posts.module.ts b/backend/src/modules/posts/posts.module.ts
index 812e232..7859161 100644
--- a/backend/src/modules/posts/posts.module.ts
+++ b/backend/src/modules/posts/posts.module.ts
@@ -4,9 +4,10 @@ import { PostsController } from './posts.controller';
import { PrismaModule } from '../../database/prisma.module';
import { StorageModule } from '../storage/storage.module';
import { ChatModule } from '../chat/chat.module';
+import { NotificationsModule } from '../notifications/notifications.module';
@Module({
- imports: [PrismaModule, StorageModule, ChatModule],
+ imports: [PrismaModule, StorageModule, ChatModule, NotificationsModule],
controllers: [PostsController],
providers: [PostsService],
})
diff --git a/backend/src/modules/posts/posts.service.spec.ts b/backend/src/modules/posts/posts.service.spec.ts
index 825d332..164a998 100644
--- a/backend/src/modules/posts/posts.service.spec.ts
+++ b/backend/src/modules/posts/posts.service.spec.ts
@@ -3,6 +3,7 @@ import { PostsService } from './posts.service';
import { PrismaService } from '../../database/prisma.service';
import { StorageService } from '../storage/storage.service';
import { ChatGateway } from '../chat/chat.gateway';
+import { NotificationsService } from '../notifications/notifications.service';
import { NotFoundException } from '@nestjs/common';
import { PostType } from '@prisma/client';
@@ -40,12 +41,17 @@ describe('PostsService', () => {
},
};
+ const mockNotificationsService = {
+ createNotification: jest.fn(),
+ };
+
const module: TestingModule = await Test.createTestingModule({
providers: [
PostsService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: StorageService, useValue: mockStorageService },
{ provide: ChatGateway, useValue: mockChatGateway },
+ { provide: NotificationsService, useValue: mockNotificationsService },
],
}).compile();
@@ -80,6 +86,7 @@ describe('PostsService', () => {
content: 'Hello World',
postType: PostType.DISCUSSION,
imageUrls: ['/uploads/test.jpg'],
+ isAnonymous: false,
author: { connect: { id: mockUser.id } },
},
include: expect.any(Object),
diff --git a/backend/src/modules/posts/posts.service.ts b/backend/src/modules/posts/posts.service.ts
index 79c14c3..0ead7f9 100644
--- a/backend/src/modules/posts/posts.service.ts
+++ b/backend/src/modules/posts/posts.service.ts
@@ -3,6 +3,7 @@ import { PrismaService } from '../../database/prisma.service';
import { StorageService } from '../storage/storage.service';
import { CreatePostDto } from './dto/create-post.dto';
import { ChatGateway } from '../chat/chat.gateway';
+import { NotificationsService } from '../notifications/notifications.service';
@Injectable()
@@ -10,7 +11,8 @@ export class PostsService {
constructor(
private prisma: PrismaService,
private storageService: StorageService,
- private chatGateway: ChatGateway
+ private chatGateway: ChatGateway,
+ private notificationsService: NotificationsService
) {}
async createPost(clerkUserId: string, createPostDto: CreatePostDto, files: any[]) {
@@ -31,10 +33,11 @@ export class PostsService {
}
}
- return await this.prisma.post.create({
+ const post = await this.prisma.post.create({
data: {
content: createPostDto.content,
postType: createPostDto.postType,
+ isAnonymous: createPostDto.isAnonymous ?? false,
imageUrls: imageUrls,
author: {
connect: { id: user.id }
@@ -42,27 +45,36 @@ export class PostsService {
},
include: {
author: {
- select: { name: true, username: true, avatarUrl: true },
+ select: { name: true, username: true, avatarUrl: true, clerkUserId: true },
},
_count: {
select: { likes: true, comments: true },
},
},
});
+
+ if (post.isAnonymous) {
+ return {
+ ...post,
+ author: { name: "Anonymous Student", username: "anonymous", avatarUrl: null }
+ };
+ }
+ return post;
} catch (error: any) {
console.error("DATABASE ERROR:", error);
throw new BadRequestException(`Database Error: ${error.message}`);
}
}
- async getAllPosts(clerkUserId?: string) {
+ async getAllPosts(clerkUserId?: string, type?: string) {
const user = clerkUserId ? await this.prisma.user.findUnique({ where: { clerkUserId } }) : null;
- return this.prisma.post.findMany({
+ const posts = await this.prisma.post.findMany({
+ where: type ? { postType: type as any } : undefined,
orderBy: { createdAt: 'desc' },
include: {
author: {
- select: { name: true, username: true, avatarUrl: true },
+ select: { name: true, username: true, avatarUrl: true, clerkUserId: true },
},
_count: {
select: { likes: true, comments: true },
@@ -73,6 +85,16 @@ export class PostsService {
} : false,
},
});
+
+ return posts.map(post => {
+ if (post.isAnonymous) {
+ return {
+ ...post,
+ author: { name: "Anonymous Student", username: "anonymous", avatarUrl: null }
+ };
+ }
+ return post;
+ });
}
async toggleLike(clerkUserId: string, postId: string) {
@@ -108,6 +130,17 @@ export class PostsService {
},
});
isLiked = true;
+
+ if (post.authorId !== user.id) {
+ await this.notificationsService.createNotification({
+ userId: post.authorId,
+ type: 'LIKE',
+ title: 'New Like',
+ content: `${user.name || user.username || 'Someone'} liked your post.`,
+ actorId: user.id,
+ postId: post.id,
+ });
+ }
}
const likeCount = await this.prisma.postLike.count({ where: { postId } });
@@ -142,6 +175,17 @@ export class PostsService {
const commentCount = await this.prisma.postComment.count({ where: { postId } });
+ if (post.authorId !== user.id) {
+ await this.notificationsService.createNotification({
+ userId: post.authorId,
+ type: 'COMMENT',
+ title: 'New Comment',
+ content: `${user.name || user.username || 'Someone'} commented: "${content.substring(0, 50)}${content.length > 50 ? '...' : ''}"`,
+ actorId: user.id,
+ postId: post.id,
+ });
+ }
+
// Broadcast the new comment
this.chatGateway.server.emit('post_comment_added', { postId, comment: newComment, commentCount });
@@ -149,17 +193,28 @@ export class PostsService {
}
async getComments(postId: string) {
- const post = await this.prisma.post.findUnique({ where: { id: postId } });
- if (!post) throw new NotFoundException('Post not found');
-
return this.prisma.postComment.findMany({
where: { postId },
orderBy: { createdAt: 'asc' },
include: {
- author: {
- select: { name: true, username: true, avatarUrl: true },
- },
+ author: { select: { name: true, username: true, avatarUrl: true, clerkUserId: true } },
},
});
}
+
+ async deletePost(clerkUserId: string, postId: string) {
+ const user = await this.prisma.user.findUnique({ where: { clerkUserId } });
+ if (!user) throw new NotFoundException('User not found');
+
+ const post = await this.prisma.post.findUnique({ where: { id: postId } });
+ if (!post) throw new NotFoundException('Post not found');
+
+ if (post.authorId !== user.id) {
+ throw new BadRequestException('You can only delete your own posts');
+ }
+
+ return this.prisma.post.delete({
+ where: { id: postId }
+ });
+ }
}
diff --git a/backend/src/modules/transactions/transactions.module.ts b/backend/src/modules/transactions/transactions.module.ts
index 97c26d0..85fddc5 100644
--- a/backend/src/modules/transactions/transactions.module.ts
+++ b/backend/src/modules/transactions/transactions.module.ts
@@ -4,9 +4,10 @@ import { TransactionsService } from './transactions.service';
import { PrismaModule } from '../../database/prisma.module';
import { ChatModule } from '../chat/chat.module';
import { PaymentsModule } from '../payments/payments.module';
+import { NotificationsModule } from '../notifications/notifications.module';
@Module({
- imports: [PrismaModule, ChatModule, forwardRef(() => PaymentsModule)],
+ imports: [PrismaModule, ChatModule, forwardRef(() => PaymentsModule), NotificationsModule],
controllers: [TransactionsController],
providers: [TransactionsService],
exports: [TransactionsService]
diff --git a/backend/src/modules/transactions/transactions.service.spec.ts b/backend/src/modules/transactions/transactions.service.spec.ts
index 4ca86c3..4746caf 100644
--- a/backend/src/modules/transactions/transactions.service.spec.ts
+++ b/backend/src/modules/transactions/transactions.service.spec.ts
@@ -3,6 +3,7 @@ import { TransactionsService } from './transactions.service';
import { PrismaService } from '../../database/prisma.service';
import { ChatGateway } from '../chat/chat.gateway';
import { PaymentsService } from '../payments/payments.service';
+import { NotificationsService } from '../notifications/notifications.service';
import { NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
describe('TransactionsService', () => {
@@ -46,12 +47,20 @@ describe('TransactionsService', () => {
createConnectAccount: jest.fn(),
};
+ // Mock Notifications Service
+ const mockNotificationsService = {
+ createNotification: jest.fn(),
+ getUnreadCount: jest.fn(),
+ markAsRead: jest.fn(),
+ };
+
const module: TestingModule = await Test.createTestingModule({
providers: [
TransactionsService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: ChatGateway, useValue: mockChatGateway },
{ provide: PaymentsService, useValue: mockPaymentsService },
+ { provide: NotificationsService, useValue: mockNotificationsService },
],
}).compile();
diff --git a/backend/src/modules/transactions/transactions.service.ts b/backend/src/modules/transactions/transactions.service.ts
index 0dd18b2..f41098d 100644
--- a/backend/src/modules/transactions/transactions.service.ts
+++ b/backend/src/modules/transactions/transactions.service.ts
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, BadRequestException, ForbiddenException,
import { PrismaService } from '../../database/prisma.service';
import { ChatGateway } from '../chat/chat.gateway';
import { PaymentsService } from '../payments/payments.service';
+import { NotificationsService } from '../notifications/notifications.service';
import * as crypto from 'crypto';
@Injectable()
@@ -11,6 +12,7 @@ export class TransactionsService {
private chatGateway: ChatGateway,
@Inject(forwardRef(() => PaymentsService))
private paymentsService: PaymentsService,
+ private notificationsService: NotificationsService
) {}
// Generate a secure 6-digit code
@@ -45,6 +47,16 @@ export class TransactionsService {
}
});
+ await this.notificationsService.createNotification({
+ userId: listing.sellerId,
+ type: 'PURCHASE',
+ title: 'New Reservation',
+ content: `${buyer.name || buyer.username || 'A student'} reserved your item: ${listing.title}`,
+ actorId: buyer.id,
+ listingId: listing.id,
+ linkUrl: `/purchase-history`
+ });
+
return transaction;
}
diff --git a/backend/src/modules/users/users.controller.ts b/backend/src/modules/users/users.controller.ts
index 3c27d23..7f85d54 100644
--- a/backend/src/modules/users/users.controller.ts
+++ b/backend/src/modules/users/users.controller.ts
@@ -1,4 +1,4 @@
-import { Controller, Get, Patch, UseGuards, Param, Body, NotFoundException, ConflictException, UseInterceptors } from '@nestjs/common';
+import { Controller, Get, Patch, UseGuards, Param, Body, NotFoundException, ConflictException, UseInterceptors, Query, Post } from '@nestjs/common';
import { CacheInterceptor } from '@nestjs/cache-manager';
import { UsersService } from './users.service';
import { ClerkAuthGuard } from '../../common/guards/clerk-auth.guard';
@@ -33,17 +33,30 @@ export class UsersController {
}
}
- @Get(':id')
+ @Get('search')
@UseGuards(ClerkAuthGuard)
- @UseInterceptors(CacheInterceptor)
- async getUser(@Param('id') id: string) {
+ async searchUsers(@Query('q') query: string) {
+ if (!query) return [];
+ return this.usersService.searchUsers(query);
+ }
- const user = await this.usersService.getUserById(id);
+ @Get(':id')
+ @UseGuards(ClerkAuthGuard)
+ async getUser(@Param('id') id: string, @CurrentUser() clerkUser: AuthUser) {
+ const user = await this.usersService.getUserById(id, clerkUser?.clerkUserId);
if (!user){
throw new NotFoundException("User not found");
}
return user;
}
+ @Post(':id/follow')
+ @UseGuards(ClerkAuthGuard)
+ async toggleFollow(@Param('id') id: string, @CurrentUser() clerkUser: AuthUser) {
+ const result = await this.usersService.toggleFollow(id, clerkUser.clerkUserId);
+ if (!result) throw new NotFoundException("User not found or cannot follow yourself");
+ return result;
+ }
+
}
diff --git a/backend/src/modules/users/users.module.ts b/backend/src/modules/users/users.module.ts
index 513776d..f9b463b 100644
--- a/backend/src/modules/users/users.module.ts
+++ b/backend/src/modules/users/users.module.ts
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
+import { NotificationsModule } from '../notifications/notifications.module';
@Module({
+ imports: [NotificationsModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts
index a4ef41d..8c44b10 100644
--- a/backend/src/modules/users/users.service.ts
+++ b/backend/src/modules/users/users.service.ts
@@ -1,9 +1,13 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
+import { NotificationsService } from '../notifications/notifications.service';
@Injectable()
export class UsersService {
- constructor(private prisma: PrismaService) { }
+ constructor(
+ private prisma: PrismaService,
+ private notificationsService: NotificationsService
+ ) { }
// This function finds a user, or creates one if they don't exist yet!
async syncUser(clerkUserId: string, email: string) {
@@ -45,8 +49,8 @@ export class UsersService {
return user;
}
- async getUserById(id: string) {
- return this.prisma.user.findFirst({
+ async getUserById(id: string, currentUserId?: string) {
+ const user = await this.prisma.user.findFirst({
where: {
OR: [
{ id: id },
@@ -59,10 +63,77 @@ export class UsersService {
include: { images: true }
},
_count: {
- select: { listings: true }
+ select: { followers: true, following: true }
}
}
});
+
+ if (!user) return null;
+
+ let isFollowing = false;
+ if (currentUserId) {
+ const follower = await this.prisma.user.findFirst({
+ where: { OR: [{ id: currentUserId }, { clerkUserId: currentUserId }] },
+ select: { id: true }
+ });
+ if (follower) {
+ const link = await this.prisma.user.findFirst({
+ where: { id: user.id, followers: { some: { id: follower.id } } }
+ });
+ isFollowing = !!link;
+ }
+ }
+
+ return { ...user, isFollowing };
+ }
+
+ async toggleFollow(targetId: string, currentUserId: string) {
+ const targetUser = await this.prisma.user.findFirst({ where: { OR: [{ id: targetId }, { clerkUserId: targetId }] } });
+ const currentUser = await this.prisma.user.findFirst({ where: { OR: [{ id: currentUserId }, { clerkUserId: currentUserId }] } });
+
+ if (!targetUser || !currentUser || targetUser.id === currentUser.id) return null;
+
+ const isFollowing = await this.prisma.user.findFirst({
+ where: { id: targetUser.id, followers: { some: { id: currentUser.id } } }
+ });
+
+ if (isFollowing) {
+ await this.prisma.user.update({
+ where: { id: currentUser.id },
+ data: { following: { disconnect: { id: targetUser.id } } }
+ });
+ return { following: false };
+ } else {
+ await this.prisma.user.update({
+ where: { id: currentUser.id },
+ data: { following: { connect: { id: targetUser.id } } }
+ });
+
+ // Trigger notification
+ await this.notificationsService.createNotification({
+ userId: targetUser.id,
+ type: 'FOLLOW',
+ title: 'New Follower',
+ content: `${currentUser.name || currentUser.username || 'Someone'} started following you!`,
+ linkUrl: `/profile/${currentUser.clerkUserId}`,
+ actorId: currentUser.id,
+ });
+
+ return { following: true };
+ }
+ }
+
+ async searchUsers(query: string) {
+ return this.prisma.user.findMany({
+ where: {
+ OR: [
+ { name: { contains: query, mode: 'insensitive' } },
+ { username: { contains: query, mode: 'insensitive' } }
+ ]
+ },
+ take: 20,
+ select: { id: true, clerkUserId: true, name: true, username: true, avatarUrl: true, major: true, _count: { select: { followers: true } } }
+ });
}
async updateUser(clerkUserId: string, updateData: any) {
diff --git a/frontend/app/ClientNav.tsx b/frontend/app/ClientNav.tsx
index 1ecb3bc..dffe564 100644
--- a/frontend/app/ClientNav.tsx
+++ b/frontend/app/ClientNav.tsx
@@ -4,14 +4,15 @@ import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
const CATEGORIES = [
- { id: 'HOUSING', label: 'Dorm/Sublease' },
- { id: 'CLOTHES', label: 'Clothing' },
- { id: 'SCHOOL', label: 'School' },
- { id: 'LEISURE', label: 'Leisure' },
- { id: 'ACCESSORIES', label: 'Accessories' },
- { id: 'OTHER', label: 'Other' },
- { id: 'SERVICES', label: 'Services' },
- { id: 'ALL', label: 'Home' },
+ { id: "DORM", label: "Dorm" },
+ { id: "SUBLEASE", label: "Sublease" },
+ { id: "CLOTHES", label: "Clothing" },
+ { id: "SCHOOL", label: "School" },
+ { id: "LEISURE", label: "Leisure" },
+ { id: "ACCESSORIES", label: "Accessories" },
+ { id: "OTHER", label: "Other" },
+ { id: "SERVICES", label: "Services" },
+ { id: "ALL", label: "Home" },
];
export function ClientNav() {
@@ -19,19 +20,19 @@ export function ClientNav() {
const searchParams = useSearchParams();
// Hide the nav bar on individual listing detail pages and chat
- if (pathname.match(/^\/listings\/.+$/) || pathname.startsWith('/chat')) {
+ if (pathname.match(/^\/listings\/.+$/) || pathname.startsWith("/chat")) {
return null;
}
-
- let currentCategory = '';
- if (pathname === '/listings') {
- currentCategory = searchParams.get('category') || '';
- } else if (pathname === '/home') {
- currentCategory = 'ALL';
- } else if (pathname === '/community') {
- currentCategory = 'COMMUNITY';
- } else if (pathname === '/swipe') {
- currentCategory = 'SWIPE';
+
+ let currentCategory = "";
+ if (pathname === "/listings") {
+ currentCategory = searchParams.get("category") || "";
+ } else if (pathname === "/home") {
+ currentCategory = "ALL";
+ } else if (pathname === "/community") {
+ currentCategory = "COMMUNITY";
+ } else if (pathname === "/swipe") {
+ currentCategory = "SWIPE";
}
return (
@@ -40,28 +41,28 @@ export function ClientNav() {
{CATEGORIES.map((cat) => {
const isActive = currentCategory === cat.id;
return (
-
{cat.label}
);
})}
-
+
-
-
Match your needs
@@ -70,5 +71,3 @@ export function ClientNav() {
);
}
-
-
diff --git a/frontend/app/about/page.tsx b/frontend/app/about/page.tsx
index 384c99e..cc5deac 100644
--- a/frontend/app/about/page.tsx
+++ b/frontend/app/about/page.tsx
@@ -8,7 +8,10 @@ export default function AboutPage() {
{/* Main Content */}
-
+
Back
@@ -18,35 +21,63 @@ export default function AboutPage() {
-
The Problem
+
+ The Problem
+
- Buying and selling on campus shouldn't be sketchy or complicated. Traditional marketplaces are full of scammers, irrelevant listings, and people who live miles away. Students lack a secure, localized space to trade dorm essentials, find subleases, or offer their skills without worrying about who they are actually meeting up with or paying hefty middleman fees.
+ Buying and selling on campus shouldn't be sketchy or complicated.
+ Traditional marketplaces are full of scammers, irrelevant listings,
+ and people who live miles away. Students lack a secure, localized
+ space to trade dorm essentials, find subleases, or offer their
+ skills without worrying about who they are actually meeting up with
+ or paying hefty middleman fees.
-
Our Solution
+
+ Our Solution
+
- We built Orbit as an exclusive marketplace specifically for university students. By requiring a verified .edu email address, we ensure that every single transaction is safe, local, and student-to-student. We integrated secure payments through Stripe with a unique Meetup Code system, completely eliminating the anxiety of cash handoffs. It keeps the money in the campus community, helps students save, and makes finding what you need effortless.
+ We built Orbit as an exclusive marketplace specifically for
+ university students. By requiring a verified .edu email address, we
+ ensure that every single transaction is safe, local, and
+ student-to-student. We integrated secure payments through Stripe
+ with a unique Meetup Code system, completely eliminating the anxiety
+ of cash handoffs. It keeps the money in the campus community, helps
+ students save, and makes finding what you need effortless.
-
The Ecosystem
+
+ The Ecosystem
+
- Orbit isn't just a marketplace for buying textbooks or selling your dorm fridge. It's the central hub for your entire university ecosystem. Need CS help, a resume review, or someone to help you move out? We've got you covered.
+ Orbit isn't just a marketplace for buying textbooks or selling your
+ dorm fridge. It's the central hub for your entire university
+ ecosystem. Need CS help, a resume review, or someone to help you
+ move out? We've got you covered.
-
The Challenge
+
+ The Challenge
+
- Building Orbit wasn't just about creating a marketplace; it was about engineering trust. The biggest challenge was designing a system that guaranteed safety without adding friction. We had to implement a strict yet seamless .edu verification process and build a custom escrow-like payment flow using Stripe and Meetup Codes. We needed to ensure that sellers felt confident they would get paid, and buyers felt confident they wouldn't get scammed—all while keeping the platform lightning fast and visually engaging for Gen Z students.
+ Building Orbit wasn't just about creating a marketplace; it was
+ about engineering trust. The biggest challenge was designing a
+ system that guaranteed safety without adding friction. We had to
+ implement a strict yet seamless .edu verification process and build
+ a custom escrow-like payment flow using Stripe and Meetup Codes. We
+ needed to ensure that sellers felt confident they would get paid,
+ and buyers felt confident they wouldn't get scammed—all while
+ keeping the platform lightning fast and visually engaging for Gen Z
+ students.
-
-
);
}
diff --git a/frontend/app/add-product/page.tsx b/frontend/app/add-product/page.tsx
index 329b78a..1690b08 100644
--- a/frontend/app/add-product/page.tsx
+++ b/frontend/app/add-product/page.tsx
@@ -1,450 +1,589 @@
-'use client';
-
-import { useState, useRef, useEffect } from 'react';
-import { useAuth } from '@clerk/nextjs';
-import { useRouter } from 'next/navigation';
-import { motion, AnimatePresence } from 'framer-motion';
-import { ArrowLeft, Tag, AlignLeft, DollarSign, ListPlus, Loader2, ImagePlus, AlertCircle, X, GripVertical, ShieldCheck, Banknote, UploadCloud } from 'lucide-react';
-import Link from 'next/link';
-import axios from 'axios';
-import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
-import { Button } from '@/components/ui/button';
-import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { useAuth } from "@clerk/nextjs";
+import { useRouter } from "next/navigation";
+import { motion, AnimatePresence } from "framer-motion";
+import {
+ ArrowLeft,
+ Tag,
+ AlignLeft,
+ DollarSign,
+ ListPlus,
+ Loader2,
+ ImagePlus,
+ AlertCircle,
+ X,
+ GripVertical,
+ ShieldCheck,
+ Banknote,
+ UploadCloud,
+} from "lucide-react";
+import Link from "next/link";
+import axios from "axios";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Button } from "@/components/ui/button";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
const MAX_IMAGES = 6;
interface ImagePreview {
- id: string;
- file: File;
- url: string;
+ id: string;
+ file: File;
+ url: string;
}
export default function AddProductPage() {
- const { getToken, userId } = useAuth();
- const router = useRouter();
- const fileInputRef = useRef(null);
-
- const [isLoading, setIsLoading] = useState(false);
- const [error, setError] = useState('');
- const [images, setImages] = useState([]);
-
- const [formData, setFormData] = useState({
- title: '',
- description: '',
- price: '',
- category: 'SCHOOL',
- acceptsDirectPayment: true,
- acceptsProtectedPayment: false,
- location: '',
- brand: '',
- colors: '',
- size: '',
- material: '',
- weatherFound: '',
- });
-
- const COLORS = ['Black', 'White', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Pink', 'Purple', 'Orange', 'Brown', 'Multi', 'Other'];
- const SIZES = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'One Size', 'Other'];
-
- const [isStripeLinked, setIsStripeLinked] = useState(false);
- const [isLoadingStripeStatus, setIsLoadingStripeStatus] = useState(true);
-
- // Fetch Stripe status on mount
- useEffect(() => {
- const fetchStripeStatus = async () => {
- try {
- const token = await getToken();
- if (token) {
- const res = await axios.get(`http://127.0.0.1:3000/payments/connect/status`, {
- headers: { Authorization: `Bearer ${token}` }
- });
- setIsStripeLinked(res.data.linked);
- }
- } catch (err) {
- console.error("Failed to fetch Stripe status", err);
- } finally {
- setIsLoadingStripeStatus(false);
- }
- };
- fetchStripeStatus();
- }, [getToken]);
-
- const categories = ['SCHOOL', 'CLOTHES', 'HOUSING', 'LEISURE', 'ACCESSORIES', 'OTHER'];
-
- const handleImageAdd = (e: React.ChangeEvent) => {
- if (!e.target.files) return;
-
- const newFiles = Array.from(e.target.files);
- const remaining = MAX_IMAGES - images.length;
- const filesToAdd = newFiles.slice(0, remaining);
-
- const newPreviews: ImagePreview[] = filesToAdd.map((file) => ({
- id: crypto.randomUUID(),
- file,
- url: URL.createObjectURL(file),
- }));
-
- setImages((prev) => [...prev, ...newPreviews]);
-
- // Reset input so the same file can be re-selected
- if (fileInputRef.current) fileInputRef.current.value = '';
- };
-
- const removeImage = (id: string) => {
- setImages((prev) => {
- const toRemove = prev.find((img) => img.id === id);
- if (toRemove) URL.revokeObjectURL(toRemove.url);
- return prev.filter((img) => img.id !== id);
- });
- };
-
- const handleChange = (e: React.ChangeEvent) => {
- setFormData({ ...formData, [e.target.name]: e.target.value });
- };
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setIsLoading(true);
- setError('');
-
- try {
- const token = await getToken();
-
- const submitData = new FormData();
- submitData.append('title', formData.title);
- submitData.append('description', formData.description);
- submitData.append('price', formData.price.toString());
- submitData.append('category', formData.category);
- submitData.append('acceptsDirectPayment', formData.acceptsDirectPayment.toString());
- submitData.append('acceptsProtectedPayment', formData.acceptsProtectedPayment.toString());
- if (formData.location) submitData.append('location', formData.location);
- if (formData.brand) submitData.append('brand', formData.brand);
- if (formData.colors) submitData.append('colors', formData.colors);
- if (formData.size) submitData.append('size', formData.size);
- if (formData.material) submitData.append('material', formData.material);
-
- images.forEach((img) => {
- submitData.append('images', img.file);
- });
-
- await axios.post('http://127.0.0.1:3000/listings', submitData, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
-
- router.push('/listings');
- } catch (err: any) {
- const errData = err.response?.data;
- const backendMessage = errData?.message
- ? Array.isArray(errData.message)
- ? errData.message.join(', ')
- : errData.message
- : err.message || 'An error occurred while creating the listing.';
-
- setError(backendMessage);
- } finally {
- setIsLoading(false);
- }
- };
-
- return (
-
-
- {/* Back Button */}
-
-
- Back to Marketplace
-
-
- {/* Header */}
-
-
- Create a Listing
-
-
-
- {/* Form Card */}
-
- {error && (
-
-
- Error
- {error}
-
- )}
-
-
-
-
-
- );
+ const { getToken, userId } = useAuth();
+ const router = useRouter();
+ const fileInputRef = useRef(null);
+
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [images, setImages] = useState([]);
+
+ const [formData, setFormData] = useState({
+ title: "",
+ description: "",
+ price: "",
+ category: "SCHOOL",
+ acceptsDirectPayment: true,
+ acceptsProtectedPayment: false,
+ location: "",
+ brand: "",
+ colors: "",
+ size: "",
+ material: "",
+ weatherFound: "",
+ });
+
+ const COLORS = [
+ "Black",
+ "White",
+ "Gray",
+ "Red",
+ "Blue",
+ "Green",
+ "Yellow",
+ "Pink",
+ "Purple",
+ "Orange",
+ "Brown",
+ "Multi",
+ "Other",
+ ];
+ const SIZES = ["XS", "S", "M", "L", "XL", "XXL", "One Size", "Other"];
+
+ const [isStripeLinked, setIsStripeLinked] = useState(false);
+ const [isLoadingStripeStatus, setIsLoadingStripeStatus] = useState(true);
+
+ // Fetch Stripe status on mount
+ useEffect(() => {
+ const fetchStripeStatus = async () => {
+ try {
+ const token = await getToken();
+ if (token) {
+ const res = await axios.get(
+ `http://127.0.0.1:3000/payments/connect/status`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ setIsStripeLinked(res.data.linked);
+ }
+ } catch (err) {
+ console.error("Failed to fetch Stripe status", err);
+ } finally {
+ setIsLoadingStripeStatus(false);
+ }
+ };
+ fetchStripeStatus();
+ }, [getToken]);
+
+ const categories = [
+ "SCHOOL",
+ "CLOTHES",
+ "DORM",
+ "SUBLEASE",
+ "LEISURE",
+ "ACCESSORIES",
+ "OTHER",
+ ];
+
+ const handleImageAdd = (e: React.ChangeEvent) => {
+ if (!e.target.files) return;
+
+ const newFiles = Array.from(e.target.files);
+ const remaining = MAX_IMAGES - images.length;
+ const filesToAdd = newFiles.slice(0, remaining);
+
+ const newPreviews: ImagePreview[] = filesToAdd.map((file) => ({
+ id: crypto.randomUUID(),
+ file,
+ url: URL.createObjectURL(file),
+ }));
+
+ setImages((prev) => [...prev, ...newPreviews]);
+
+ // Reset input so the same file can be re-selected
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ };
+
+ const removeImage = (id: string) => {
+ setImages((prev) => {
+ const toRemove = prev.find((img) => img.id === id);
+ if (toRemove) URL.revokeObjectURL(toRemove.url);
+ return prev.filter((img) => img.id !== id);
+ });
+ };
+
+ const handleChange = (
+ e: React.ChangeEvent,
+ ) => {
+ setFormData({ ...formData, [e.target.name]: e.target.value });
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsLoading(true);
+ setError("");
+
+ try {
+ const token = await getToken();
+
+ const submitData = new FormData();
+ submitData.append("title", formData.title);
+ submitData.append("description", formData.description);
+ submitData.append("price", formData.price.toString());
+ submitData.append("category", formData.category);
+ submitData.append(
+ "acceptsDirectPayment",
+ formData.acceptsDirectPayment.toString(),
+ );
+ submitData.append(
+ "acceptsProtectedPayment",
+ formData.acceptsProtectedPayment.toString(),
+ );
+ if (formData.location) submitData.append("location", formData.location);
+ if (formData.brand) submitData.append("brand", formData.brand);
+ if (formData.colors) submitData.append("colors", formData.colors);
+ if (formData.size) submitData.append("size", formData.size);
+ if (formData.material) submitData.append("material", formData.material);
+
+ images.forEach((img) => {
+ submitData.append("images", img.file);
+ });
+
+ await axios.post("http://127.0.0.1:3000/listings", submitData, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ router.push("/listings");
+ } catch (err: any) {
+ const errData = err.response?.data;
+ const backendMessage = errData?.message
+ ? Array.isArray(errData.message)
+ ? errData.message.join(", ")
+ : errData.message
+ : err.message || "An error occurred while creating the listing.";
+
+ setError(backendMessage);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ {/* Back Button */}
+
+
+ Back to Marketplace
+
+
+ {/* Header */}
+
+
+ Create a Listing
+
+
+
+ {/* Form Card */}
+
+ {error && (
+
+
+ Error
+ {error}
+
+ )}
+
+
+ {/* ── Image Upload ── */}
+
+
+ Upload Image
+
+
+
+
+
+ Drag and drop your image here, or click to select a file
+
+
+ (Only *.jpeg, *.jpg, *.png images will be accepted)
+
+
+
+ {/* Previews */}
+ {images.length > 0 && (
+
+
+ {images.map((img, idx) => (
+
+
+ {idx === 0 && (
+
+ Cover
+
+ )}
+ removeImage(img.id)}
+ className="absolute top-1 right-1 h-5 w-5 rounded-full bg-foreground/60 hover:bg-red-600 text-primary-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200"
+ >
+
+
+
+ ))}
+
+
+ )}
+
+ ({images.length}/{MAX_IMAGES}) uploaded. First image will be the
+ cover.
+
+
+
+ {/* ── Category & Location ── */}
+
+
+
+ Category
+
+
+ setFormData({ ...formData, category: val || "SCHOOL" })
+ }
+ >
+
+
+
+
+ {categories.map((cat) => (
+
+ {cat.charAt(0) + cat.slice(1).toLowerCase()}
+
+ ))}
+
+
+
+
+
+ Location
+
+
+
+
+
+ {/* ── Brand & Material ── */}
+
+
+ {/* ── Colors, Size ── */}
+
+
+
+ Colors
+
+
+ setFormData({ ...formData, colors: val || "" })
+ }
+ >
+
+
+
+
+ {COLORS.map((color) => (
+
+ {color}
+
+ ))}
+
+
+
+
+
+ Size
+
+
+ setFormData({ ...formData, size: val || "" })
+ }
+ >
+
+
+
+
+ {SIZES.map((size) => (
+
+ {size}
+
+ ))}
+
+
+
+
+
+ {/* ── Title ── */}
+
+
+ Title
+
+
+
+
+ {/* ── Price ── */}
+
+
+ Price
+
+
+
+ $
+
+
+
+
+
+ {/* ── Description ── */}
+
+
+ Description
+
+
+
+
+ {/* ── Payment Settings ── */}
+
+
+ Payment Options
+
+
+ {/* Direct Payment */}
+
+ setFormData((prev) => ({
+ ...prev,
+ acceptsDirectPayment: !prev.acceptsDirectPayment,
+ }))
+ }
+ >
+
+
+ Direct Payment
+
+
+ {formData.acceptsDirectPayment && (
+
+ )}
+
+
+
+ Cash, Zelle, Venmo at meetup.
+
+
+
+ {/* Protected Payment */}
+
{
+ if (!isStripeLinked) {
+ setError(
+ "You must connect your bank account in your Profile before enabling protected payments.",
+ );
+ return;
+ }
+ setError("");
+ setFormData((prev) => ({
+ ...prev,
+ acceptsProtectedPayment: !prev.acceptsProtectedPayment,
+ }));
+ }}
+ >
+ {isLoadingStripeStatus && (
+
+
+
+ )}
+
+
+ Protected Payment
+
+
+ {formData.acceptsProtectedPayment && (
+
+ )}
+
+
+
+ Card payments via Orbit Escrow.
+
+ {!isStripeLinked && !isLoadingStripeStatus && (
+
{
+ e.stopPropagation();
+ router.push(`/profile/${userId}`);
+ }}
+ >
+ Connect bank account first →
+
+ )}
+
+
+
+
+ {/* ── Submit ── */}
+
+
+ {isLoading ? (
+ <>
+
+ Publishing...
+ >
+ ) : (
+ "Publish Listing"
+ )}
+
+
+
+
+
+
+ );
}
diff --git a/frontend/app/chat/page.tsx b/frontend/app/chat/page.tsx
index 011483f..5b666f3 100644
--- a/frontend/app/chat/page.tsx
+++ b/frontend/app/chat/page.tsx
@@ -5,457 +5,553 @@ import { useAuth, useUser } from "@clerk/nextjs";
import { useSearchParams, useRouter } from "next/navigation";
import { io, Socket } from "socket.io-client";
import { motion, AnimatePresence } from "framer-motion";
-import { Search, Send, Loader2, ArrowLeft, MoreVertical, MessageSquare, AlertTriangle } from "lucide-react";
+import {
+ Search,
+ Send,
+ Loader2,
+ ArrowLeft,
+ MoreVertical,
+ MessageSquare,
+ AlertTriangle,
+} from "lucide-react";
import axios from "axios";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
-import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
const getImageUrl = (url?: string) => {
- if (!url) return "";
- if (url.startsWith("http")) return url;
- return `http://127.0.0.1:3000${url}`;
+ if (!url) return "";
+ if (url.startsWith("http")) return url;
+ return `http://127.0.0.1:3000${url}`;
};
// Interfaces
interface UserPreview {
- id: string;
- name: string | null;
- username: string | null;
- avatarUrl: string | null;
- clerkUserId: string;
+ id: string;
+ name: string | null;
+ username: string | null;
+ avatarUrl: string | null;
+ clerkUserId: string;
}
interface Message {
- id: string;
- content: string;
- conversationId: string;
- senderId: string;
- createdAt: string;
- sender?: UserPreview;
- isRead?: boolean;
- listingId?: string | null;
- listing?: {
- id: string;
- title: string;
- price: number;
- images?: { url: string }[];
- } | null;
+ id: string;
+ content: string;
+ conversationId: string;
+ senderId: string;
+ createdAt: string;
+ sender?: UserPreview;
+ isRead?: boolean;
+ listingId?: string | null;
+ listing?: {
+ id: string;
+ title: string;
+ price: number;
+ images?: { url: string }[];
+ } | null;
}
interface Conversation {
- id: string;
- updatedAt: string;
- members: { user: UserPreview }[];
- messages: Message[];
+ id: string;
+ updatedAt: string;
+ members: { user: UserPreview }[];
+ messages: Message[];
}
export default function ChatPage() {
- const { getToken, isLoaded, isSignedIn } = useAuth();
- const { user: clerkUser } = useUser();
- const searchParams = useSearchParams();
- const router = useRouter();
- const defaultConversationId = searchParams?.get("id");
-
- const [socket, setSocket] = useState(null);
- const [inbox, setInbox] = useState([]);
- const [activeConversationId, setActiveConversationId] = useState(defaultConversationId || null);
- const [messages, setMessages] = useState([]);
- const [newMessage, setNewMessage] = useState("");
- const [isLoadingInbox, setIsLoadingInbox] = useState(true);
- const [isLoadingMessages, setIsLoadingMessages] = useState(false);
- const [searchQuery, setSearchQuery] = useState("");
- const [refreshInbox, setRefreshInbox] = useState(0);
- const messagesEndRef = useRef(null);
-
- const [contextListing, setContextListing] = useState(null);
-
- // Meetup Verification States
- const [meetupVerificationCode, setMeetupVerificationCode] = useState("");
- const [activeMeetupCode, setActiveMeetupCode] = useState(null); // For buyer side
- const [isStartingMeetup, setIsStartingMeetup] = useState(false);
- const [isVerifyingMeetup, setIsVerifyingMeetup] = useState(false);
- const [isMarkingAsSold, setIsMarkingAsSold] = useState(false);
- const [meetupError, setMeetupError] = useState("");
- const [meetupSuccess, setMeetupSuccess] = useState("");
- const [showBuyerMeetupModal, setShowBuyerMeetupModal] = useState(false);
- const [transactionStatus, setTransactionStatus] = useState(null);
-
- // AI and Manual Proposal States
- const [aiSuggestion, setAiSuggestion] = useState(null);
- const [meetupUpdate, setMeetupUpdate] = useState(null);
- const [showMeetupProposalModal, setShowMeetupProposalModal] = useState(false);
- const [manualMeetupLocation, setManualMeetupLocation] = useState("");
- const [manualMeetupTime, setManualMeetupTime] = useState("");
- const [isProposing, setIsProposing] = useState(false);
- const [isAccepting, setIsAccepting] = useState(false);
-
- // Verification Dashboard States
- const [isVerificationBoardOpen, setIsVerificationBoardOpen] = useState(false);
- const [sellerTransactions, setSellerTransactions] = useState([]);
- const [isFetchingSellerTransactions, setIsFetchingSellerTransactions] = useState(false);
- const [transactionCodes, setTransactionCodes] = useState>({});
- const [dashboardActionLoading, setDashboardActionLoading] = useState>({});
-
- // Buyer Dashboard States
- const [isBuyerVerificationBoardOpen, setIsBuyerVerificationBoardOpen] = useState(false);
- const [buyerTransactions, setBuyerTransactions] = useState([]);
- const [isFetchingBuyerTransactions, setIsFetchingBuyerTransactions] = useState(false);
-
- // Auto-scroll to bottom of messages
- const scrollToBottom = () => {
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
- };
-
- useEffect(() => {
- scrollToBottom();
- }, [messages]);
-
- // Fetch listing context and pre-fill meetup
- useEffect(() => {
- const listingId = searchParams?.get("listingId");
- const meetupLocation = searchParams?.get("meetupLocation");
-
- // Pre-fill text for meetup location ONCE if we have it and haven't fetched it yet
- if (meetupLocation && !newMessage && !contextListing) {
- setNewMessage(`I want to meetup here: ${decodeURIComponent(meetupLocation)}`);
- }
-
- if (listingId && activeConversationId && !isLoadingMessages && messages.length >= 0) {
- // Check if we already have a message with this listing
- // If it's a meetup request, we bypass this check so the listing is always attached for context
- const hasListing = meetupLocation ? false : messages.some((m) => m.listingId === listingId);
- if (!hasListing) {
- // Fetch the listing if we don't have it yet
- if (!contextListing || contextListing.id !== listingId) {
- const fetchListing = async () => {
- try {
- const token = await getToken();
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
- const res = await axios.get(`${apiUrl}/listings/${listingId}`, {
- headers: { Authorization: `Bearer ${token}` }
- });
- setContextListing(res.data);
- } catch (err) {
- console.error("Failed to fetch context listing:", err);
- }
- };
- fetchListing();
- }
- } else {
- // If we already sent it, clear context listing
- setContextListing(null);
- }
- }
- }, [messages, searchParams, activeConversationId, isLoadingMessages, getToken, contextListing, newMessage]);
-
- // 1. Fetch Inbox
- useEffect(() => {
- if (!isLoaded) return;
- if (!isSignedIn) {
- router.push("/");
- return;
- }
-
- const fetchInbox = async () => {
- try {
- const token = await getToken();
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
- const res = await axios.get(`${apiUrl}/chat/inbox`, {
- headers: { Authorization: `Bearer ${token}` },
- });
- const data = res.data;
- setInbox(data);
- // Only auto-select if we don't have an active one AND it's a fresh load (not a background refresh)
- if (!activeConversationId && data.length > 0 && refreshInbox === 0 && !window.matchMedia('(max-width: 768px)').matches) {
- setActiveConversationId(data[0].id);
- }
- } catch (err) {
- console.error("Failed to fetch inbox:", err);
- } finally {
- setIsLoadingInbox(false);
- }
- };
-
- fetchInbox();
- }, [isLoaded, isSignedIn, getToken, router, refreshInbox]);
-
- // 2. Fetch Messages for Active Conversation
- useEffect(() => {
- if (!activeConversationId) return;
-
- const fetchMessages = async () => {
- setIsLoadingMessages(true);
- try {
- const token = await getToken();
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
- const res = await axios.get(`${apiUrl}/chat/inbox/${activeConversationId}`, {
- headers: { Authorization: `Bearer ${token}` },
- });
- const data = res.data;
- // The backend returns an array of messages directly
- setMessages(Array.isArray(data) ? data : []);
-
- } catch (err) {
- console.error("Failed to fetch messages:", err);
- } finally {
- setIsLoadingMessages(false);
- }
- };
-
- fetchMessages();
- }, [activeConversationId, getToken]);
-
- // 3. Socket Setup
- useEffect(() => {
- if (!isSignedIn) return;
-
- // Connect to WebSocket server
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
- const newSocket = io(apiUrl, {
- transports: ["websocket"],
- autoConnect: false,
- });
-
- const setupSocket = async () => {
- const token = await getToken();
- if(token) {
- newSocket.auth = { token };
- // Register the connect listener BEFORE calling connect().
- // This way it fires on the initial connection AND on every auto-reconnect,
- // guaranteeing we always re-join our personal room.
- newSocket.on("connect", () => {
- console.log("Socket connected, authenticating...");
- newSocket.emit("authenticate");
- });
- newSocket.connect();
- }
- };
- setupSocket();
-
- setSocket(newSocket);
-
- return () => {
- newSocket.disconnect();
- };
- }, [isSignedIn, getToken]);
-
- // 4. Socket Listeners
- useEffect(() => {
- if (!socket) return;
-
- // If we have an active chat, let the server know we're looking at it
- if (activeConversationId) {
- socket.emit("mark_read", { conversationId: activeConversationId });
- window.dispatchEvent(new Event("update_unread_count"));
- }
-
- const onReceiveMessage = (message: Message) => {
- // If it belongs to active chat, append it
- if (message.conversationId === activeConversationId) {
- setMessages((prev) => [...prev, message]);
- // Also emit that we've read this new incoming message
- socket.emit("mark_read", { conversationId: activeConversationId });
- window.dispatchEvent(new Event("update_unread_count"));
- }
- // Update inbox preview to bump this conversation to top
- setInbox((prevInbox) => {
- const conversationIndex = prevInbox.findIndex(c => c.id === message.conversationId);
- // If the conversation is brand new (we don't have it in inbox yet), we need to fetch it!
- if (conversationIndex === -1) {
- setRefreshInbox(prev => prev + 1);
- return prevInbox;
- }
-
- const updatedConversation = { ...prevInbox[conversationIndex] };
- updatedConversation.updatedAt = message.createdAt;
- updatedConversation.messages = [message]; // update latest message
-
- const newInbox = [...prevInbox];
- newInbox.splice(conversationIndex, 1);
- newInbox.unshift(updatedConversation);
- return newInbox;
- });
- };
-
- const onMessagesRead = (payload: { conversationId: string }) => {
- if (payload.conversationId === activeConversationId) {
- setMessages((prev) => prev.map(msg => ({ ...msg, isRead: true })));
- }
- };
-
- const onMeetupCodeCreated = (payload: any) => {
- // Refresh buyer transactions to get the new code
- fetchBuyerTransactions();
- // Automatically open the board if they received a code
- setIsBuyerVerificationBoardOpen(true);
- };
-
- const onMeetupConfirmed = (payload: any) => {
- setTransactionStatus(payload.status || "MEETUP_CONFIRMED");
- setMeetupSuccess("Meetup confirmed successfully!");
- setMeetupError("");
- setShowBuyerMeetupModal(false);
- setActiveMeetupCode(null);
- };
-
- const onAiMeetupSuggestion = (payload: any) => {
- setAiSuggestion(payload);
+ const { getToken, isLoaded, isSignedIn } = useAuth();
+ const { user: clerkUser } = useUser();
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const defaultConversationId = searchParams?.get("id");
+
+ const [socket, setSocket] = useState(null);
+ const [inbox, setInbox] = useState([]);
+ const [activeConversationId, setActiveConversationId] = useState<
+ string | null
+ >(defaultConversationId || null);
+ const [messages, setMessages] = useState([]);
+ const [newMessage, setNewMessage] = useState("");
+ const [isLoadingInbox, setIsLoadingInbox] = useState(true);
+ const [isLoadingMessages, setIsLoadingMessages] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [refreshInbox, setRefreshInbox] = useState(0);
+ const messagesEndRef = useRef(null);
+
+ const [contextListing, setContextListing] = useState(null);
+
+ // Meetup Verification States
+ const [meetupVerificationCode, setMeetupVerificationCode] = useState("");
+ const [activeMeetupCode, setActiveMeetupCode] = useState(null); // For buyer side
+ const [isStartingMeetup, setIsStartingMeetup] = useState(false);
+ const [isVerifyingMeetup, setIsVerifyingMeetup] = useState(false);
+ const [isMarkingAsSold, setIsMarkingAsSold] = useState(false);
+ const [meetupError, setMeetupError] = useState("");
+ const [meetupSuccess, setMeetupSuccess] = useState("");
+ const [showBuyerMeetupModal, setShowBuyerMeetupModal] = useState(false);
+ const [transactionStatus, setTransactionStatus] = useState(
+ null,
+ );
+
+ // AI and Manual Proposal States
+ const [aiSuggestion, setAiSuggestion] = useState(null);
+ const [meetupUpdate, setMeetupUpdate] = useState(null);
+ const [showMeetupProposalModal, setShowMeetupProposalModal] = useState(false);
+ const [manualMeetupLocation, setManualMeetupLocation] = useState("");
+ const [manualMeetupTime, setManualMeetupTime] = useState("");
+ const [isProposing, setIsProposing] = useState(false);
+ const [isAccepting, setIsAccepting] = useState(false);
+
+ // Verification Dashboard States
+ const [isVerificationBoardOpen, setIsVerificationBoardOpen] = useState(false);
+ const [sellerTransactions, setSellerTransactions] = useState([]);
+ const [isFetchingSellerTransactions, setIsFetchingSellerTransactions] =
+ useState(false);
+ const [transactionCodes, setTransactionCodes] = useState<
+ Record
+ >({});
+ const [dashboardActionLoading, setDashboardActionLoading] = useState<
+ Record
+ >({});
+
+ // Buyer Dashboard States
+ const [isBuyerVerificationBoardOpen, setIsBuyerVerificationBoardOpen] =
+ useState(false);
+ const [buyerTransactions, setBuyerTransactions] = useState([]);
+ const [isFetchingBuyerTransactions, setIsFetchingBuyerTransactions] =
+ useState(false);
+
+ // Auto-scroll to bottom of messages
+ const scrollToBottom = () => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ };
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [messages]);
+
+ // Fetch listing context and pre-fill meetup
+ useEffect(() => {
+ const listingId = searchParams?.get("listingId");
+ const meetupLocation = searchParams?.get("meetupLocation");
+
+ // Pre-fill text for meetup location ONCE if we have it and haven't fetched it yet
+ if (meetupLocation && !newMessage && !contextListing) {
+ setNewMessage(
+ `I want to meetup here: ${decodeURIComponent(meetupLocation)}`,
+ );
+ }
+
+ if (
+ listingId &&
+ activeConversationId &&
+ !isLoadingMessages &&
+ messages.length >= 0
+ ) {
+ // Check if we already have a message with this listing
+ // If it's a meetup request, we bypass this check so the listing is always attached for context
+ const hasListing = meetupLocation
+ ? false
+ : messages.some((m) => m.listingId === listingId);
+ if (!hasListing) {
+ // Fetch the listing if we don't have it yet
+ if (!contextListing || contextListing.id !== listingId) {
+ const fetchListing = async () => {
+ try {
+ const token = await getToken();
+ const apiUrl =
+ process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
+ const res = await axios.get(`${apiUrl}/listings/${listingId}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ setContextListing(res.data);
+ } catch (err) {
+ console.error("Failed to fetch context listing:", err);
+ }
+ };
+ fetchListing();
+ }
+ } else {
+ // If we already sent it, clear context listing
+ setContextListing(null);
+ }
+ }
+ }, [
+ messages,
+ searchParams,
+ activeConversationId,
+ isLoadingMessages,
+ getToken,
+ contextListing,
+ newMessage,
+ ]);
+
+ // 1. Fetch Inbox
+ useEffect(() => {
+ if (!isLoaded) return;
+ if (!isSignedIn) {
+ router.push("/");
+ return;
+ }
+
+ const fetchInbox = async () => {
+ try {
+ const token = await getToken();
+ const apiUrl =
+ process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
+ const res = await axios.get(`${apiUrl}/chat/inbox`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ const data = res.data;
+ setInbox(data);
+ // Only auto-select if we don't have an active one AND it's a fresh load (not a background refresh)
+ if (
+ !activeConversationId &&
+ data.length > 0 &&
+ refreshInbox === 0 &&
+ !window.matchMedia("(max-width: 768px)").matches
+ ) {
+ setActiveConversationId(data[0].id);
+ }
+ } catch (err) {
+ console.error("Failed to fetch inbox:", err);
+ } finally {
+ setIsLoadingInbox(false);
+ }
+ };
+
+ fetchInbox();
+ }, [isLoaded, isSignedIn, getToken, router, refreshInbox]);
+
+ // 2. Fetch Messages for Active Conversation
+ useEffect(() => {
+ if (!activeConversationId) return;
+
+ const fetchMessages = async () => {
+ setIsLoadingMessages(true);
+ try {
+ const token = await getToken();
+ const apiUrl =
+ process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
+ const res = await axios.get(
+ `${apiUrl}/chat/inbox/${activeConversationId}`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ const data = res.data;
+ // The backend returns an array of messages directly
+ setMessages(Array.isArray(data) ? data : []);
+ } catch (err) {
+ console.error("Failed to fetch messages:", err);
+ } finally {
+ setIsLoadingMessages(false);
+ }
+ };
+
+ fetchMessages();
+ }, [activeConversationId, getToken]);
+
+ // 3. Socket Setup
+ useEffect(() => {
+ if (!isSignedIn) return;
+
+ // Connect to WebSocket server
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
+ const newSocket = io(apiUrl, {
+ transports: ["websocket"],
+ autoConnect: false,
+ });
+
+ const setupSocket = async () => {
+ const token = await getToken();
+ if (token) {
+ newSocket.auth = { token };
+ // Register the connect listener BEFORE calling connect().
+ // This way it fires on the initial connection AND on every auto-reconnect,
+ // guaranteeing we always re-join our personal room.
+ newSocket.on("connect", () => {
+ console.log("Socket connected, authenticating...");
+ newSocket.emit("authenticate");
+ });
+ newSocket.connect();
+ }
+ };
+ setupSocket();
+
+ setSocket(newSocket);
+
+ return () => {
+ newSocket.disconnect();
+ };
+ }, [isSignedIn, getToken]);
+
+ // 4. Socket Listeners
+ useEffect(() => {
+ if (!socket) return;
+
+ // If we have an active chat, let the server know we're looking at it
+ if (activeConversationId) {
+ socket.emit("mark_read", { conversationId: activeConversationId });
+ window.dispatchEvent(new Event("update_unread_count"));
+ }
+
+ const onReceiveMessage = (message: Message) => {
+ // If it belongs to active chat, append it
+ if (message.conversationId === activeConversationId) {
+ setMessages((prev) => [...prev, message]);
+ // Also emit that we've read this new incoming message
+ socket.emit("mark_read", { conversationId: activeConversationId });
+ window.dispatchEvent(new Event("update_unread_count"));
+ }
+ // Update inbox preview to bump this conversation to top
+ setInbox((prevInbox) => {
+ const conversationIndex = prevInbox.findIndex(
+ (c) => c.id === message.conversationId,
+ );
+ // If the conversation is brand new (we don't have it in inbox yet), we need to fetch it!
+ if (conversationIndex === -1) {
+ setRefreshInbox((prev) => prev + 1);
+ return prevInbox;
+ }
+
+ const updatedConversation = { ...prevInbox[conversationIndex] };
+ updatedConversation.updatedAt = message.createdAt;
+ updatedConversation.messages = [message]; // update latest message
+
+ const newInbox = [...prevInbox];
+ newInbox.splice(conversationIndex, 1);
+ newInbox.unshift(updatedConversation);
+ return newInbox;
+ });
+ };
+
+ const onMessagesRead = (payload: { conversationId: string }) => {
+ if (payload.conversationId === activeConversationId) {
+ setMessages((prev) => prev.map((msg) => ({ ...msg, isRead: true })));
+ }
+ };
+
+ const onMeetupCodeCreated = (payload: any) => {
+ // Refresh buyer transactions to get the new code
+ fetchBuyerTransactions();
+ // Automatically open the board if they received a code
+ setIsBuyerVerificationBoardOpen(true);
+ };
+
+ const onMeetupConfirmed = (payload: any) => {
+ setTransactionStatus(payload.status || "MEETUP_CONFIRMED");
+ setMeetupSuccess("Meetup confirmed successfully!");
+ setMeetupError("");
+ setShowBuyerMeetupModal(false);
+ setActiveMeetupCode(null);
+ };
+
+ const onAiMeetupSuggestion = (payload: any) => {
+ setAiSuggestion(payload);
+ };
+
+ const onMeetupUpdate = (payload: any) => {
+ setMeetupUpdate(payload);
+ setAiSuggestion(null); // clear suggestion if action taken
+ };
+
+ socket.on("receive_message", onReceiveMessage);
+ socket.on("messages_read", onMessagesRead);
+ socket.on("meetup_code_created", onMeetupCodeCreated);
+ socket.on("meetup_confirmed", onMeetupConfirmed);
+ socket.on("ai_meetup_suggestion", onAiMeetupSuggestion);
+ socket.on("meetup_update", onMeetupUpdate);
+
+ return () => {
+ socket.off("receive_message", onReceiveMessage);
+ socket.off("messages_read", onMessagesRead);
+ socket.off("meetup_code_created", onMeetupCodeCreated);
+ socket.off("meetup_confirmed", onMeetupConfirmed);
+ socket.off("ai_meetup_suggestion", onAiMeetupSuggestion);
+ socket.off("meetup_update", onMeetupUpdate);
+ };
+ }, [socket, activeConversationId]);
+
+ const activeConversation = inbox.find((c) => c.id === activeConversationId);
+ const otherActiveMember = activeConversation?.members?.find(
+ (m) => m.user.clerkUserId !== clerkUser?.id,
+ )?.user;
+ const otherActiveName =
+ otherActiveMember?.name || otherActiveMember?.username || "UIC Student";
+ const otherActiveInitial = otherActiveName[0]?.toUpperCase() || "U";
+
+ const latestListingMessage = [...messages].reverse().find((m) => m.listingId);
+ const activeListingId = latestListingMessage?.listingId;
+ const [activeListingSellerId, setActiveListingSellerId] = useState<
+ string | null
+ >(null);
+ const [activeTransaction, setActiveTransaction] = useState(null);
+
+ useEffect(() => {
+ if (!activeListingId || !isSignedIn || !otherActiveMember) return;
+ const fetchListingAndTransaction = async () => {
+ try {
+ const token = await getToken();
+ const apiUrl =
+ process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
+ const res = await axios.get(`${apiUrl}/listings/${activeListingId}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ setActiveListingSellerId(
+ res.data.seller?.clerkUserId || res.data.sellerId,
+ );
+
+ const txRes = await axios.get(
+ `${apiUrl}/transactions/active?listingId=${activeListingId}&otherUserId=${otherActiveMember.id}`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ if (txRes.data) {
+ setActiveTransaction(txRes.data);
+ // If the status isn't meeting started yet, sync it
+ if (
+ txRes.data.orderStatus === "PENDING_MEETUP" ||
+ txRes.data.orderStatus === "PAID_PENDING_MEETUP"
+ ) {
+ setTransactionStatus(txRes.data.orderStatus);
+ }
+ } else {
+ setActiveTransaction(null);
+ }
+ } catch (err) {
+ console.error("Failed to fetch active listing and transaction");
+ }
+ };
+ fetchListingAndTransaction();
+ }, [activeListingId, isSignedIn, getToken, otherActiveMember]);
+
+ // Fetch active meetup code for buyer
+ useEffect(() => {
+ if (
+ !activeConversationId ||
+ !otherActiveMember ||
+ !activeListingId ||
+ !isSignedIn
+ )
+ return;
+
+ const fetchActiveMeetup = async () => {
+ try {
+ const token = await getToken();
+ const apiUrl =
+ process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
+ const res = await axios.get(
+ `${apiUrl}/transactions/active-meetup-code?listingId=${activeListingId}&sellerId=${otherActiveMember.id}`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ if (res.data?.activeCode) {
+ setActiveMeetupCode(res.data.activeCode);
+ setTransactionStatus("MEETING_STARTED");
+ setShowBuyerMeetupModal(true);
+ }
+ } catch (err) {
+ // Ignore errors, might not be a buyer or no active meetup
+ }
+ };
+ fetchActiveMeetup();
+ }, [
+ activeConversationId,
+ otherActiveMember,
+ activeListingId,
+ isSignedIn,
+ getToken,
+ ]);
+
+ const handleStartMeetup = async () => {
+ if (!activeListingId || !otherActiveMember) return;
+ setIsStartingMeetup(true);
+ setMeetupError("");
+ setMeetupSuccess("");
+ try {
+ const token = await getToken();
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
+ const res = await axios.post(
+ `${apiUrl}/transactions/start-meetup`,
+ {
+ listingId: activeListingId,
+ buyerId: otherActiveMember.id,
+ },
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ setMeetupSuccess(res.data.message);
+ setTransactionStatus("MEETING_STARTED");
+ } catch (err: any) {
+ setMeetupError(err.response?.data?.message || "Failed to start meetup.");
+ } finally {
+ setIsStartingMeetup(false);
+ }
};
- const onMeetupUpdate = (payload: any) => {
- setMeetupUpdate(payload);
- setAiSuggestion(null); // clear suggestion if action taken
+ const handleVerifyMeetup = async () => {
+ if (!activeTransaction || !meetupVerificationCode) return;
+ setIsVerifyingMeetup(true);
+ setMeetupError("");
+ try {
+ const token = await getToken();
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
+ const res = await axios.post(
+ `${apiUrl}/transactions/verify-meetup-code`,
+ {
+ transactionId: activeTransaction.id,
+ code: meetupVerificationCode,
+ },
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ setMeetupSuccess(res.data.message);
+ setTransactionStatus("MEETUP_CONFIRMED");
+ setMeetupVerificationCode("");
+ } catch (err: any) {
+ setMeetupError(err.response?.data?.message || "Failed to verify code.");
+ } finally {
+ setIsVerifyingMeetup(false);
+ }
};
- socket.on("receive_message", onReceiveMessage);
- socket.on("messages_read", onMessagesRead);
- socket.on("meetup_code_created", onMeetupCodeCreated);
- socket.on("meetup_confirmed", onMeetupConfirmed);
- socket.on("ai_meetup_suggestion", onAiMeetupSuggestion);
- socket.on("meetup_update", onMeetupUpdate);
-
- return () => {
- socket.off("receive_message", onReceiveMessage);
- socket.off("messages_read", onMessagesRead);
- socket.off("meetup_code_created", onMeetupCodeCreated);
- socket.off("meetup_confirmed", onMeetupConfirmed);
- socket.off("ai_meetup_suggestion", onAiMeetupSuggestion);
- socket.off("meetup_update", onMeetupUpdate);
- };
- }, [socket, activeConversationId]);
-
- const activeConversation = inbox.find(c => c.id === activeConversationId);
- const otherActiveMember = activeConversation?.members?.find(m => m.user.clerkUserId !== clerkUser?.id)?.user;
- const otherActiveName = otherActiveMember?.name || otherActiveMember?.username || "UIC Student";
- const otherActiveInitial = otherActiveName[0]?.toUpperCase() || "U";
-
- const latestListingMessage = [...messages].reverse().find(m => m.listingId);
- const activeListingId = latestListingMessage?.listingId;
- const [activeListingSellerId, setActiveListingSellerId] = useState(null);
- const [activeTransaction, setActiveTransaction] = useState(null);
-
- useEffect(() => {
- if (!activeListingId || !isSignedIn || !otherActiveMember) return;
- const fetchListingAndTransaction = async () => {
- try {
- const token = await getToken();
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
- const res = await axios.get(`${apiUrl}/listings/${activeListingId}`, {
- headers: { Authorization: `Bearer ${token}` }
- });
- setActiveListingSellerId(res.data.seller?.clerkUserId || res.data.sellerId);
-
- const txRes = await axios.get(`${apiUrl}/transactions/active?listingId=${activeListingId}&otherUserId=${otherActiveMember.id}`, {
- headers: { Authorization: `Bearer ${token}` }
- });
- if (txRes.data) {
- setActiveTransaction(txRes.data);
- // If the status isn't meeting started yet, sync it
- if (txRes.data.orderStatus === 'PENDING_MEETUP' || txRes.data.orderStatus === 'PAID_PENDING_MEETUP') {
- setTransactionStatus(txRes.data.orderStatus);
- }
- } else {
- setActiveTransaction(null);
- }
- } catch (err) {
- console.error("Failed to fetch active listing and transaction");
- }
- };
- fetchListingAndTransaction();
- }, [activeListingId, isSignedIn, getToken, otherActiveMember]);
-
- // Fetch active meetup code for buyer
- useEffect(() => {
- if (!activeConversationId || !otherActiveMember || !activeListingId || !isSignedIn) return;
-
- const fetchActiveMeetup = async () => {
- try {
- const token = await getToken();
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
- const res = await axios.get(`${apiUrl}/transactions/active-meetup-code?listingId=${activeListingId}&sellerId=${otherActiveMember.id}`, {
- headers: { Authorization: `Bearer ${token}` }
- });
- if (res.data?.activeCode) {
- setActiveMeetupCode(res.data.activeCode);
- setTransactionStatus("MEETING_STARTED");
- setShowBuyerMeetupModal(true);
- }
- } catch (err) {
- // Ignore errors, might not be a buyer or no active meetup
- }
- };
- fetchActiveMeetup();
- }, [activeConversationId, otherActiveMember, activeListingId, isSignedIn, getToken]);
-
- const handleStartMeetup = async () => {
- if (!activeListingId || !otherActiveMember) return;
- setIsStartingMeetup(true);
- setMeetupError("");
- setMeetupSuccess("");
- try {
- const token = await getToken();
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
- const res = await axios.post(`${apiUrl}/transactions/start-meetup`, {
- listingId: activeListingId,
- buyerId: otherActiveMember.id
- }, {
- headers: { Authorization: `Bearer ${token}` }
- });
- setMeetupSuccess(res.data.message);
- setTransactionStatus("MEETING_STARTED");
- } catch (err: any) {
- setMeetupError(err.response?.data?.message || "Failed to start meetup.");
- } finally {
- setIsStartingMeetup(false);
- }
- };
-
- const handleVerifyMeetup = async () => {
- if (!activeTransaction || !meetupVerificationCode) return;
- setIsVerifyingMeetup(true);
- setMeetupError("");
- try {
- const token = await getToken();
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
- const res = await axios.post(`${apiUrl}/transactions/verify-meetup-code`, {
- transactionId: activeTransaction.id,
- code: meetupVerificationCode
- }, {
- headers: { Authorization: `Bearer ${token}` }
- });
- setMeetupSuccess(res.data.message);
- setTransactionStatus("MEETUP_CONFIRMED");
- setMeetupVerificationCode("");
- } catch (err: any) {
- setMeetupError(err.response?.data?.message || "Failed to verify code.");
- } finally {
- setIsVerifyingMeetup(false);
- }
- };
-
- const handleMarkAsSold = async () => {
- if (!activeTransaction) return;
- setIsMarkingAsSold(true);
- setMeetupError("");
- setMeetupSuccess("");
- try {
- const token = await getToken();
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
- await axios.post(`${apiUrl}/transactions/${activeTransaction.id}/mark-as-sold`, {}, {
- headers: { Authorization: `Bearer ${token}` }
- });
- setTransactionStatus("COMPLETED_BY_SELLER");
- setMeetupSuccess("Marked as sold successfully!");
- } catch (err: any) {
- setMeetupError(err.response?.data?.message || "Failed to mark as sold.");
- } finally {
- setIsMarkingAsSold(false);
- }
- };
+ const handleMarkAsSold = async () => {
+ if (!activeTransaction) return;
+ setIsMarkingAsSold(true);
+ setMeetupError("");
+ setMeetupSuccess("");
+ try {
+ const token = await getToken();
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
+ await axios.post(
+ `${apiUrl}/transactions/${activeTransaction.id}/mark-as-sold`,
+ {},
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ setTransactionStatus("COMPLETED_BY_SELLER");
+ setMeetupSuccess("Marked as sold successfully!");
+ } catch (err: any) {
+ setMeetupError(err.response?.data?.message || "Failed to mark as sold.");
+ } finally {
+ setIsMarkingAsSold(false);
+ }
+ };
const fetchSellerTransactions = async () => {
setIsFetchingSellerTransactions(true);
@@ -463,11 +559,13 @@ export default function ChatPage() {
const token = await getToken();
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
const res = await axios.get(`${apiUrl}/transactions/active/seller`, {
- headers: { Authorization: `Bearer ${token}` }
+ headers: { Authorization: `Bearer ${token}` },
});
// Filter strictly for the current buyer we are chatting with
if (otherActiveMember) {
- const filtered = res.data.filter((t: any) => t.buyerId === otherActiveMember.id);
+ const filtered = res.data.filter(
+ (t: any) => t.buyerId === otherActiveMember.id,
+ );
setSellerTransactions(filtered);
} else {
setSellerTransactions([]);
@@ -490,11 +588,13 @@ export default function ChatPage() {
const token = await getToken();
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
const res = await axios.get(`${apiUrl}/transactions/active/buyer`, {
- headers: { Authorization: `Bearer ${token}` }
+ headers: { Authorization: `Bearer ${token}` },
});
// Filter for the current seller we are chatting with
if (otherActiveMember) {
- const filtered = res.data.filter((t: any) => t.sellerId === otherActiveMember.id);
+ const filtered = res.data.filter(
+ (t: any) => t.sellerId === otherActiveMember.id,
+ );
setBuyerTransactions(filtered);
} else {
setBuyerTransactions([]);
@@ -511,63 +611,92 @@ export default function ChatPage() {
setIsBuyerVerificationBoardOpen(true);
};
- const handleDashboardStartMeetup = async (listingId: string, buyerId: string, transactionId: string) => {
- setDashboardActionLoading(prev => ({ ...prev, [transactionId]: true }));
+ const handleDashboardStartMeetup = async (
+ listingId: string,
+ buyerId: string,
+ transactionId: string,
+ ) => {
+ setDashboardActionLoading((prev) => ({ ...prev, [transactionId]: true }));
try {
const token = await getToken();
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
- await axios.post(`${apiUrl}/transactions/start-meetup`, { listingId, buyerId }, {
- headers: { Authorization: `Bearer ${token}` }
- });
+ await axios.post(
+ `${apiUrl}/transactions/start-meetup`,
+ { listingId, buyerId },
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
// Refresh the board
await fetchSellerTransactions();
} catch (err: any) {
console.error(err);
alert(err.response?.data?.message || "Failed to start meetup.");
} finally {
- setDashboardActionLoading(prev => ({ ...prev, [transactionId]: false }));
+ setDashboardActionLoading((prev) => ({
+ ...prev,
+ [transactionId]: false,
+ }));
}
};
const handleDashboardVerifyMeetup = async (transactionId: string) => {
const code = transactionCodes[transactionId];
if (!code || code.length !== 6) return;
-
- setDashboardActionLoading(prev => ({ ...prev, [transactionId]: true }));
+
+ setDashboardActionLoading((prev) => ({ ...prev, [transactionId]: true }));
try {
const token = await getToken();
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
- await axios.post(`${apiUrl}/transactions/verify-meetup-code`, { transactionId, code }, {
- headers: { Authorization: `Bearer ${token}` }
- });
+ await axios.post(
+ `${apiUrl}/transactions/verify-meetup-code`,
+ { transactionId, code },
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
// Refresh the board
await fetchSellerTransactions();
} catch (err: any) {
console.error(err);
alert(err.response?.data?.message || "Failed to verify code.");
} finally {
- setDashboardActionLoading(prev => ({ ...prev, [transactionId]: false }));
+ setDashboardActionLoading((prev) => ({
+ ...prev,
+ [transactionId]: false,
+ }));
}
};
const handleDashboardMarkAsSold = async (transactionId: string) => {
- setDashboardActionLoading(prev => ({ ...prev, [transactionId]: true }));
+ setDashboardActionLoading((prev) => ({ ...prev, [transactionId]: true }));
try {
const token = await getToken();
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
- await axios.post(`${apiUrl}/transactions/${transactionId}/mark-as-sold`, {}, {
- headers: { Authorization: `Bearer ${token}` }
- });
+ await axios.post(
+ `${apiUrl}/transactions/${transactionId}/mark-as-sold`,
+ {},
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
await fetchSellerTransactions();
} catch (err: any) {
console.error(err);
alert(err.response?.data?.message || "Failed to mark as sold.");
} finally {
- setDashboardActionLoading(prev => ({ ...prev, [transactionId]: false }));
+ setDashboardActionLoading((prev) => ({
+ ...prev,
+ [transactionId]: false,
+ }));
}
};
- const handleProposeMeetup = async (location: string, time: string, transactionId: string) => {
+ const handleProposeMeetup = async (
+ location: string,
+ time: string,
+ transactionId: string,
+ ) => {
setIsProposing(true);
setMeetupError("");
try {
@@ -578,20 +707,24 @@ export default function ChatPage() {
// Let's just create a generic date for today + the time string if we can't parse it.
let parsedTime = new Date(time);
if (isNaN(parsedTime.getTime())) {
- // Fallback if AI gave an unparseable time
- parsedTime = new Date();
- parsedTime.setHours(parsedTime.getHours() + 2); // default +2 hours
+ // Fallback if AI gave an unparseable time
+ parsedTime = new Date();
+ parsedTime.setHours(parsedTime.getHours() + 2); // default +2 hours
}
- await axios.post(`${apiUrl}/transactions/${transactionId}/meetup/propose`, {
- location,
- time: parsedTime.toISOString()
- }, {
- headers: { Authorization: `Bearer ${token}` }
- });
+ await axios.post(
+ `${apiUrl}/transactions/${transactionId}/meetup/propose`,
+ {
+ location,
+ time: parsedTime.toISOString(),
+ },
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
setAiSuggestion(null);
setShowMeetupProposalModal(false);
setMeetupSuccess("Meetup proposed successfully!");
- } catch(err: any) {
+ } catch (err: any) {
setMeetupError(err.response?.data?.message || "Failed to propose meetup");
} finally {
setIsProposing(false);
@@ -604,11 +737,15 @@ export default function ChatPage() {
try {
const token = await getToken();
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
- await axios.post(`${apiUrl}/transactions/${transactionId}/meetup/accept`, {}, {
- headers: { Authorization: `Bearer ${token}` }
- });
+ await axios.post(
+ `${apiUrl}/transactions/${transactionId}/meetup/accept`,
+ {},
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
setMeetupSuccess("Meetup accepted successfully!");
- } catch(err: any) {
+ } catch (err: any) {
setMeetupError(err.response?.data?.message || "Failed to accept meetup");
} finally {
setIsAccepting(false);
@@ -616,664 +753,992 @@ export default function ChatPage() {
};
const handleSendMessage = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!newMessage.trim() || !activeConversationId || !socket) return;
-
- const listingId = searchParams?.get("listingId");
- const meetupLocation = searchParams?.get("meetupLocation");
- let attachListingId = undefined;
-
- // Only attach if it hasn't been sent yet in this conversation (unless it's a meetup request)
- if (listingId && contextListing) {
- const hasListing = meetupLocation ? false : messages.some((m) => m.listingId === listingId);
- if (!hasListing) {
- attachListingId = listingId;
- }
- }
-
- socket.emit("send_message", {
- conversationId: activeConversationId,
- content: newMessage,
- listingId: attachListingId
- });
-
- setNewMessage("");
-
- // If we attached it, clear from URL and state
- if (attachListingId) {
- setContextListing(null);
- router.replace(`/chat?id=${activeConversationId}`);
- }
- };
-
- const filteredInbox = inbox.filter((conv) => {
- const otherMember = conv.members?.find(m => m.user.clerkUserId !== clerkUser?.id);
- const otherName = otherMember?.user?.name || otherMember?.user?.username || "Unknown";
- return otherName.toLowerCase().includes(searchQuery.toLowerCase());
- });
-
- if (!isLoaded || isLoadingInbox && inbox.length === 0) {
- return (
-
-
-
- );
- }
-
- // The activeConversation variables are already defined above
- return (
-
- {/* ─── SIDEBAR (INBOX) ─── */}
-
-
-
Messages
-
-
- setSearchQuery(e.target.value)}
- />
-
-
-
-
- {filteredInbox.length === 0 ? (
-
- ) : (
- filteredInbox.map((conv) => {
- const otherMember = conv.members?.find(m => m.user.clerkUserId !== clerkUser?.id)?.user;
- const name = otherMember?.name || otherMember?.username || "UIC Student";
- const initial = name[0]?.toUpperCase() || "U";
- const latestMessage = conv.messages?.[0]?.content || "Started a conversation";
- const date = new Date(conv.updatedAt);
- const isToday = date.toDateString() === new Date().toDateString();
- const timeString = isToday ? date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : date.toLocaleDateString([], { month: 'short', day: 'numeric' });
- const isActive = activeConversationId === conv.id;
-
- return (
-
setActiveConversationId(conv.id)}
- className={`w-full p-4 flex gap-3 items-start text-left transition-colors border-b border-black/5 last:border-0 ${
- isActive ? "bg-foreground/5" : "hover:bg-foreground/5"
- }`}
- >
-
- {otherMember?.avatarUrl && }
- {initial}
-
-
-
- {name}
- {timeString}
-
-
- {latestMessage}
-
-
-
- );
- })
- )}
-
-
-
- {/* ─── MAIN CHAT AREA ─── */}
-
- {!activeConversationId ? (
-
-
-
Select a conversation to start chatting
-
- ) : (
- <>
- {/* Chat Header */}
-
-
-
setActiveConversationId(null)}>
-
-
-
- {otherActiveMember?.avatarUrl && }
- {otherActiveInitial}
-
-
-
-
{otherActiveName}
-
- {/* Verification Dashboard Button (Seller) */}
-
-
- Verify Meetup
-
-
-
- {/* Buyer View: Dashboard Button */}
- {activeListingSellerId !== clerkUser?.id && (
-
-
- My Purchases
-
-
- )}
-
-
+ e.preventDefault();
+ if (!newMessage.trim() || !activeConversationId || !socket) return;
+
+ const listingId = searchParams?.get("listingId");
+ const meetupLocation = searchParams?.get("meetupLocation");
+ let attachListingId = undefined;
+
+ // Only attach if it hasn't been sent yet in this conversation (unless it's a meetup request)
+ if (listingId && contextListing) {
+ const hasListing = meetupLocation
+ ? false
+ : messages.some((m) => m.listingId === listingId);
+ if (!hasListing) {
+ attachListingId = listingId;
+ }
+ }
-
- {meetupError && {meetupError} }
-
-
-
-
-
-
- {/* Chat Messages */}
-
- {/* Seller Warning Banner for Direct Payment */}
- {activeListingSellerId === clerkUser?.id && transactionStatus === 'PENDING_MEETUP' && activeTransaction?.paymentMethod === 'DIRECT' && (
-
-
-
-
-
Direct Payment Meetup
-
- Collect payment (Cash, Zelle, etc.) directly from the buyer when you meet. After they have paid and received the item, click "Mark as Sold" at the top.
-
-
-
-
- )}
-
- {/* Seller Warning Banner for Stripe Payment */}
- {activeListingSellerId === clerkUser?.id && transactionStatus === 'MEETING_STARTED' && (!activeTransaction || activeTransaction.paymentMethod === 'STRIPE') && (
-
-
-
-
-
Important: Finalizing the transaction
-
- Ask the buyer for the confirmation code only after they have inspected and accepted the item. Entering the code completes the transaction and releases the payout.
-
-
-
-
- )}
-
- {isLoadingMessages ? (
-
-
-
- ) : (
-
- {messages.map((msg, index) => {
- const isMine = msg.sender?.clerkUserId === clerkUser?.id || msg.senderId === clerkUser?.id;
- const showAvatar = !isMine && (index === 0 || messages[index - 1].senderId !== msg.senderId);
-
- return (
-
- {/* Avatar */}
- {!isMine && (
-
- {showAvatar && (
-
-
-
- {msg.sender?.name?.[0]?.toUpperCase() || "U"}
-
-
- )}
-
- )}
-
-
-
- {/* Embedded Listing Snippet */}
- {msg.listing && (
-
router.push(`/listings/${msg.listing?.id}`)} style={{ cursor: 'pointer' }}>
-
- {msg.listing.images && msg.listing.images.length > 0 ? (
-
- ) : (
-
- )}
-
-
- {msg.listing.title}
- ${msg.listing.price}
-
-
- )}
- {msg.content}
-
-
- {new Date(msg.createdAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
- {isMine && msg.isRead && (
-
- • Seen
-
- )}
-
-
-
- );
- })}
-
- )}
-
-
-
- {/* AI Suggestion Banner */}
-
- {aiSuggestion && aiSuggestion.transactionId === activeTransaction?.id && (
- {
+ const otherMember = conv.members?.find(
+ (m) => m.user.clerkUserId !== clerkUser?.id,
+ );
+ const otherName =
+ otherMember?.user?.name || otherMember?.user?.username || "Unknown";
+ return otherName.toLowerCase().includes(searchQuery.toLowerCase());
+ });
+
+ if (!isLoaded || (isLoadingInbox && inbox.length === 0)) {
+ return (
+
+
+
+ );
+ }
+
+ // The activeConversation variables are already defined above
+ return (
+
+ {/* ─── SIDEBAR (INBOX) ─── */}
+
-
-
-
-
- Meetup Detected 🤖
-
-
- Looks like you're discussing a meetup. Want to officially propose {aiSuggestion.location} at {aiSuggestion.time} ?
-
-
-
-
handleProposeMeetup(aiSuggestion.location, aiSuggestion.time, aiSuggestion.transactionId)} disabled={isProposing}
- className="bg-indigo-600 hover:bg-indigo-700 text-white rounded-full text-xs h-8 px-4 shadow-sm"
- >
- {isProposing ? : "Propose This"}
-
-
setAiSuggestion(null)}
- className="text-indigo-600 hover:text-indigo-800 hover:bg-indigo-100/50 rounded-full text-xs h-8"
- >
- Dismiss
-
+
+
+ Messages
+
+
+
+ setSearchQuery(e.target.value)}
+ />
-
- )}
-
-
- {/* Meetup Update Banner (e.g., Buyer sees proposal) */}
-
- {meetupUpdate && meetupUpdate.transactionId === activeTransaction?.id && meetupUpdate.type === 'PROPOSED' && activeListingSellerId !== clerkUser?.id && (
-
-
-
-
-
-
New Meetup Proposal 📍
+
+
+ {filteredInbox.length === 0 ? (
+
-
- Seller proposed meeting at {meetupUpdate.location} . Accept to lock it in!
-
-
-
- handleAcceptMeetup(meetupUpdate.transactionId)} disabled={isAccepting}
- className="bg-emerald-600 hover:bg-emerald-700 text-white rounded-full text-xs h-8 px-4 shadow-sm"
- >
- {isAccepting ? : "Accept"}
-
- setMeetupUpdate(null)}
- className="text-emerald-600 hover:text-emerald-800 hover:bg-emerald-100/50 rounded-full text-xs h-8"
- >
- Not now
-
-
+ ) : (
+ filteredInbox.map((conv) => {
+ const otherMember = conv.members?.find(
+ (m) => m.user.clerkUserId !== clerkUser?.id,
+ )?.user;
+ const name =
+ otherMember?.name || otherMember?.username || "UIC Student";
+ const initial = name[0]?.toUpperCase() || "U";
+ const latestMessage =
+ conv.messages?.[0]?.content || "Started a conversation";
+ const date = new Date(conv.updatedAt);
+ const isToday = date.toDateString() === new Date().toDateString();
+ const timeString = isToday
+ ? date.toLocaleTimeString([], {
+ hour: "numeric",
+ minute: "2-digit",
+ })
+ : date.toLocaleDateString([], {
+ month: "short",
+ day: "numeric",
+ });
+ const isActive = activeConversationId === conv.id;
+
+ return (
+
setActiveConversationId(conv.id)}
+ className={`w-full p-4 flex gap-3 items-start text-left transition-colors border-b border-black/5 last:border-0 ${
+ isActive ? "bg-foreground/5" : "hover:bg-foreground/5"
+ }`}
+ >
+
+ {otherMember?.avatarUrl && (
+
+ )}
+
+ {initial}
+
+
+
+
+
+ {name}
+
+
+ {timeString}
+
+
+
+ {latestMessage}
+
+
+
+ );
+ })
+ )}
-
- )}
-
-
-
- {/* Context Bar */}
- {contextListing && (
-
-
-
- {contextListing.images && contextListing.images.length > 0 ? (
-
- ) : (
-
- )}
-
-
- {contextListing.title}
- This listing will be attached to your first message.
-
-
{
- setContextListing(null);
- router.replace(`/chat?id=${activeConversationId}`);
- }}
- >
- Dismiss
- ×
-
-
-
- )}
-
- {/* Chat Input */}
-
-
-
- {activeListingSellerId === clerkUser?.id && activeTransaction && (
- setShowMeetupProposalModal(true)}
- className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary shrink-0 mr-1"
- >
- +
-
- )}
- setNewMessage(e.target.value)}
- />
- {newMessage.trim() && (
-
- Send
-
- )}
-
-
-
- >
- )}
-
- {/* Buyer Meetup Modal */}
-
-
-
- Meetup Code
-
- Only share this code after you inspect the item and agree to complete the purchase.
-
-
-
- {activeMeetupCode && (
-
-
-
- {activeMeetupCode.code}
-
-
-
-
-
-
- After the seller enters this code, the transaction is final in Orbit.
-
-
-
-
setShowBuyerMeetupModal(false)}
- className="w-full rounded-full h-12 font-bold bg-[#0066cc] hover:bg-[#005bb5] text-primary-foreground transition-colors"
- >
- Got it
-
-
- )}
-
-
-
-
-
- Propose a Meetup
-
- Set a time and place for the buyer to meet you. They will need to accept this proposal.
-
-
-
-
- Location
- setManualMeetupLocation(e.target.value)} className="rounded-xl" />
-
-
- Date & Time
- setManualMeetupTime(e.target.value)} className="rounded-xl" />
-
activeTransaction && handleProposeMeetup(manualMeetupLocation, manualMeetupTime, activeTransaction.id)}
- disabled={!manualMeetupLocation || !manualMeetupTime || isProposing}
- className="w-full rounded-xl h-12 font-bold bg-[#0066cc] hover:bg-[#005bb5] text-primary-foreground transition-colors mt-2"
+
+ {/* ─── MAIN CHAT AREA ─── */}
+
- {isProposing ? : "Send Proposal"}
-
-
-
-
-
- {/* Verification Dashboard Modal */}
-
-
-
- Verify Meetups
-
- Manage your active transactions with this buyer.
-
-
-
-
- {isFetchingSellerTransactions ? (
-
-
-
- ) : sellerTransactions.length === 0 ? (
-
-
-
No Active Meetups
-
You don't have any pending transactions with this buyer.
+ {!activeConversationId ? (
+
+
+
+ Select a conversation to start chatting
+
) : (
- sellerTransactions.map((t: any) => (
-
- {/* Item Image & Info */}
-
- {t.listing?.images?.[0]?.url ? (
-
- ) : (
-
No img
- )}
-
-
-
-
{t.listing?.title || "Unknown Item"}
-
${(t.amount / 100).toFixed(2)}
-
-
- {t.paymentMethod}
-
-
- {t.orderStatus.replace(/_/g, ' ')}
-
+ <>
+ {/* Chat Header */}
+
+
+
setActiveConversationId(null)}
+ >
+
+
+
+ {otherActiveMember?.avatarUrl && (
+
+ )}
+
+ {otherActiveInitial}
+
+
+
+
+
+ {otherActiveName}
+
+
+ {/* Verification Dashboard Button (Seller) */}
+
+
+ Verify Meetup
+
+
+
+ {/* Buyer View: Dashboard Button */}
+ {activeListingSellerId !== clerkUser?.id && (
+
+
+ My Purchases
+
+
+ )}
- {/* Action Buttons */}
-
- {/* DIRECT PAYMENT: Mark as sold */}
- {t.paymentMethod === 'DIRECT' && t.orderStatus !== 'COMPLETED_BY_SELLER' && (
-
handleDashboardMarkAsSold(t.id)}
- disabled={dashboardActionLoading[t.id]}
- className="w-full bg-emerald-600 hover:bg-emerald-700 text-white rounded-full font-bold h-10 shadow-sm"
- >
- {dashboardActionLoading[t.id] ? : "Mark as Sold"}
-
+
+ {meetupError && (
+
+ {meetupError}
+
)}
+
+
+
+
+
- {/* DIRECT PAYMENT: Sold */}
- {t.paymentMethod === 'DIRECT' && t.orderStatus === 'COMPLETED_BY_SELLER' && (
-
- ✓ Sold
+ {/* Chat Messages */}
+
+ {/* Seller Warning Banner for Direct Payment */}
+ {activeListingSellerId === clerkUser?.id &&
+ transactionStatus === "PENDING_MEETUP" &&
+ activeTransaction?.paymentMethod === "DIRECT" && (
+
+
+
+
+
+ Direct Payment Meetup
+
+
+ Collect payment (Cash, Zelle, etc.) directly from the
+ buyer when you meet. After they have paid and received
+ the item, click "Mark as Sold" at the top.
+
+
+
)}
- {/* STRIPE PAYMENT: Start Meetup */}
- {t.paymentMethod === 'STRIPE' && (t.orderStatus === 'PENDING_MEETUP' || t.orderStatus === 'PAID_PENDING_MEETUP' || t.orderStatus === 'ACCEPTED') && (
-
handleDashboardStartMeetup(t.listingId, t.buyerId, t.id)}
- disabled={dashboardActionLoading[t.id]}
- className="w-full bg-indigo-600 hover:bg-indigo-700 text-white rounded-full font-bold h-10 shadow-sm"
- >
- {dashboardActionLoading[t.id] ? : "Generate Code"}
-
+ {/* Seller Warning Banner for Stripe Payment */}
+ {activeListingSellerId === clerkUser?.id &&
+ transactionStatus === "MEETING_STARTED" &&
+ (!activeTransaction ||
+ activeTransaction.paymentMethod === "STRIPE") && (
+
+
+
+
+
+ Important: Finalizing the transaction
+
+
+ Ask the buyer for the confirmation code only after
+ they have inspected and accepted the item. Entering
+ the code completes the transaction and releases the
+ payout.
+
+
+
+
)}
- {/* STRIPE PAYMENT: Verify Meetup */}
- {t.paymentMethod === 'STRIPE' && t.orderStatus === 'MEETING_STARTED' && (
-
-
-
setTransactionCodes(prev => ({ ...prev, [t.id]: e.target.value.replace(/\D/g, '').slice(0, 6)}))}
- className="bg-secondary/50 border-transparent focus-visible:ring-indigo-500 h-10 text-center rounded-2xl flex-1 font-bold text-[15px]"
- maxLength={6}
- />
-
handleDashboardVerifyMeetup(t.id)}
- disabled={dashboardActionLoading[t.id] || (transactionCodes[t.id]?.length !== 6)}
- className="bg-indigo-600 hover:bg-indigo-700 text-white h-10 rounded-2xl font-bold px-4 shrink-0 shadow-sm"
+ {isLoadingMessages ? (
+
+
+
+ ) : (
+
+ {messages.map((msg, index) => {
+ const isMine =
+ msg.sender?.clerkUserId === clerkUser?.id ||
+ msg.senderId === clerkUser?.id;
+ const showAvatar =
+ !isMine &&
+ (index === 0 ||
+ messages[index - 1].senderId !== msg.senderId);
+
+ return (
+
- {dashboardActionLoading[t.id] ?
: "Verify"}
-
+ {/* Avatar */}
+ {!isMine && (
+
+ {showAvatar && (
+
+
+
+ {msg.sender?.name?.[0]?.toUpperCase() || "U"}
+
+
+ )}
+
+ )}
+
+
+
+ {/* Embedded Listing Snippet */}
+ {msg.listing && (
+
+ router.push(`/listings/${msg.listing?.id}`)
+ }
+ style={{ cursor: "pointer" }}
+ >
+
+ {msg.listing.images &&
+ msg.listing.images.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+ {msg.listing.title}
+
+
+ ${msg.listing.price}
+
+
+
+ )}
+ {msg.content}
+
+
+ {new Date(msg.createdAt).toLocaleTimeString([], {
+ hour: "numeric",
+ minute: "2-digit",
+ })}
+ {isMine && msg.isRead && (
+
+ • Seen
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ {/* AI Suggestion Banner */}
+
+ {aiSuggestion &&
+ aiSuggestion.transactionId === activeTransaction?.id && (
+
+
+
+
+
+
+ Meetup Detected 🤖
+
+
+
+ Looks like you're discussing a meetup. Want to
+ officially propose{" "}
+ {aiSuggestion.location} at{" "}
+ {aiSuggestion.time} ?
+
+
+
+
+ handleProposeMeetup(
+ aiSuggestion.location,
+ aiSuggestion.time,
+ aiSuggestion.transactionId,
+ )
+ }
+ disabled={isProposing}
+ className="bg-indigo-600 hover:bg-indigo-700 text-white rounded-full text-xs h-8 px-4 shadow-sm"
+ >
+ {isProposing ? (
+
+ ) : (
+ "Propose This"
+ )}
+
+ setAiSuggestion(null)}
+ className="text-indigo-600 hover:text-indigo-800 hover:bg-indigo-100/50 rounded-full text-xs h-8"
+ >
+ Dismiss
+
+
- handleDashboardStartMeetup(t.listingId, t.buyerId, t.id)}
- disabled={dashboardActionLoading[t.id]}
- className="h-7 text-xs font-semibold px-3 text-muted-foreground hover:text-foreground mx-auto rounded-full"
- >
- Resend Code
-
-
+
)}
-
- {/* STRIPE PAYMENT: Confirmed */}
- {t.paymentMethod === 'STRIPE' && (t.orderStatus === 'MEETUP_CONFIRMED' || t.orderStatus === 'COMPLETED') && (
-
- ✓ Confirmed
-
+
+
+ {/* Meetup Update Banner (e.g., Buyer sees proposal) */}
+
+ {meetupUpdate &&
+ meetupUpdate.transactionId === activeTransaction?.id &&
+ meetupUpdate.type === "PROPOSED" &&
+ activeListingSellerId !== clerkUser?.id && (
+
+
+
+
+
+
+ New Meetup Proposal 📍
+
+
+
+ Seller proposed meeting at{" "}
+ {meetupUpdate.location} . Accept to
+ lock it in!
+
+
+
+
+ handleAcceptMeetup(meetupUpdate.transactionId)
+ }
+ disabled={isAccepting}
+ className="bg-emerald-600 hover:bg-emerald-700 text-white rounded-full text-xs h-8 px-4 shadow-sm"
+ >
+ {isAccepting ? (
+
+ ) : (
+ "Accept"
+ )}
+
+ setMeetupUpdate(null)}
+ className="text-emerald-600 hover:text-emerald-800 hover:bg-emerald-100/50 rounded-full text-xs h-8"
+ >
+ Not now
+
+
+
+
)}
+
+
+ {/* Context Bar */}
+ {contextListing && (
+
+
+
+ {contextListing.images &&
+ contextListing.images.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+ {contextListing.title}
+
+
+ This listing will be attached to your first message.
+
+
+
{
+ setContextListing(null);
+ router.replace(`/chat?id=${activeConversationId}`);
+ }}
+ >
+ Dismiss
+ ×
+
+
+ )}
+
+ {/* Chat Input */}
+
- ))
+ >
)}
-
-
-
- {/* Buyer Dashboard Modal */}
-
-
-
- My Purchases
-
- Manage your purchases and meetup codes with this seller.
-
-
-
-
- {isFetchingBuyerTransactions ? (
-
-
-
- ) : buyerTransactions.length === 0 ? (
-
-
-
No Active Purchases
-
You don't have any pending purchases with this seller.
-
- ) : (
- buyerTransactions.map((t: any) => (
-
- {/* Item Image & Info */}
-
- {t.listing?.images?.[0]?.url ? (
-
- ) : (
-
No img
- )}
+ {/* Buyer Meetup Modal */}
+
+
+
+
+ Meetup Code
+
+
+ Only share this code after you inspect the item and agree to
+ complete the purchase.
+
+
+
+ {activeMeetupCode && (
+
+
+
+ {activeMeetupCode.code}
+
-
-
-
{t.listing?.title || "Unknown Item"}
-
${(t.amount / 100).toFixed(2)}
-
-
- {t.paymentMethod}
-
-
- {t.orderStatus.replace(/_/g, ' ')}
+
+
+
+
+ After the seller enters this code, the transaction is final in
+ Orbit.
+
- {/* Action Buttons */}
-
- {/* DIRECT PAYMENT */}
- {t.paymentMethod === 'DIRECT' && t.orderStatus !== 'COMPLETED_BY_SELLER' && (
-
- Pay the seller when you meet in person.
+ setShowBuyerMeetupModal(false)}
+ className="w-full rounded-full h-12 font-bold bg-[#0066cc] hover:bg-[#005bb5] text-primary-foreground transition-colors"
+ >
+ Got it
+
+
+ )}
+
+
+
+
+
+
+ Propose a Meetup
+
+
+ Set a time and place for the buyer to meet you. They will need to
+ accept this proposal.
+
+
+
+
+
+ Location
+
+ setManualMeetupLocation(e.target.value)}
+ className="rounded-xl"
+ />
+
+
+
+ Date & Time
+
+ setManualMeetupTime(e.target.value)}
+ className="rounded-xl"
+ />
+
+
+ activeTransaction &&
+ handleProposeMeetup(
+ manualMeetupLocation,
+ manualMeetupTime,
+ activeTransaction.id,
+ )
+ }
+ disabled={
+ !manualMeetupLocation || !manualMeetupTime || isProposing
+ }
+ className="w-full rounded-xl h-12 font-bold bg-[#0066cc] hover:bg-[#005bb5] text-primary-foreground transition-colors mt-2"
+ >
+ {isProposing ? (
+
+ ) : (
+ "Send Proposal"
+ )}
+
+
+
+
+
+ {/* Verification Dashboard Modal */}
+
+
+
+
+ Verify Meetups
+
+
+ Manage your active transactions with this buyer.
+
+
+
+
+ {isFetchingSellerTransactions ? (
+
+
+
+ ) : sellerTransactions.length === 0 ? (
+
+
+
+ No Active Meetups
+
+
+ You don't have any pending transactions with this buyer.
+
+
+ ) : (
+ sellerTransactions.map((t: any) => (
+
+ {/* Item Image & Info */}
+
+ {t.listing?.images?.[0]?.url ? (
+
+ ) : (
+
+ No img
+
+ )}
- )}
- {t.paymentMethod === 'DIRECT' && t.orderStatus === 'COMPLETED_BY_SELLER' && (
-
- ✓ Received
+
+
+
+ {t.listing?.title || "Unknown Item"}
+
+
+ ${(t.amount / 100).toFixed(2)}
+
+
+
+ {t.paymentMethod}
+
+
+ {t.orderStatus.replace(/_/g, " ")}
+
+
- )}
- {/* STRIPE PAYMENT: Waiting for seller */}
- {t.paymentMethod === 'STRIPE' && (t.orderStatus === 'PENDING_MEETUP' || t.orderStatus === 'PAID_PENDING_MEETUP' || t.orderStatus === 'ACCEPTED') && (
-
- Waiting for seller to generate code.
+ {/* Action Buttons */}
+
+ {/* DIRECT PAYMENT: Mark as sold */}
+ {t.paymentMethod === "DIRECT" &&
+ t.orderStatus !== "COMPLETED_BY_SELLER" && (
+
handleDashboardMarkAsSold(t.id)}
+ disabled={dashboardActionLoading[t.id]}
+ className="w-full bg-emerald-600 hover:bg-emerald-700 text-white rounded-full font-bold h-10 shadow-sm"
+ >
+ {dashboardActionLoading[t.id] ? (
+
+ ) : (
+ "Mark as Sold"
+ )}
+
+ )}
+
+ {/* DIRECT PAYMENT: Sold */}
+ {t.paymentMethod === "DIRECT" &&
+ t.orderStatus === "COMPLETED_BY_SELLER" && (
+
+ ✓ Sold
+
+ )}
+
+ {/* STRIPE PAYMENT: Start Meetup */}
+ {t.paymentMethod === "STRIPE" &&
+ (t.orderStatus === "PENDING_MEETUP" ||
+ t.orderStatus === "PAID_PENDING_MEETUP" ||
+ t.orderStatus === "ACCEPTED") && (
+
+ handleDashboardStartMeetup(
+ t.listingId,
+ t.buyerId,
+ t.id,
+ )
+ }
+ disabled={dashboardActionLoading[t.id]}
+ className="w-full bg-indigo-600 hover:bg-indigo-700 text-white rounded-full font-bold h-10 shadow-sm"
+ >
+ {dashboardActionLoading[t.id] ? (
+
+ ) : (
+ "Generate Code"
+ )}
+
+ )}
+
+ {/* STRIPE PAYMENT: Verify Meetup */}
+ {t.paymentMethod === "STRIPE" &&
+ t.orderStatus === "MEETING_STARTED" && (
+
+
+
+ setTransactionCodes((prev) => ({
+ ...prev,
+ [t.id]: e.target.value
+ .replace(/\D/g, "")
+ .slice(0, 6),
+ }))
+ }
+ className="bg-secondary/50 border-transparent focus-visible:ring-indigo-500 h-10 text-center rounded-2xl flex-1 font-bold text-[15px]"
+ maxLength={6}
+ />
+ handleDashboardVerifyMeetup(t.id)}
+ disabled={
+ dashboardActionLoading[t.id] ||
+ transactionCodes[t.id]?.length !== 6
+ }
+ className="bg-indigo-600 hover:bg-indigo-700 text-white h-10 rounded-2xl font-bold px-4 shrink-0 shadow-sm"
+ >
+ {dashboardActionLoading[t.id] ? (
+
+ ) : (
+ "Verify"
+ )}
+
+
+
+ handleDashboardStartMeetup(
+ t.listingId,
+ t.buyerId,
+ t.id,
+ )
+ }
+ disabled={dashboardActionLoading[t.id]}
+ className="h-7 text-xs font-semibold px-3 text-muted-foreground hover:text-foreground mx-auto rounded-full"
+ >
+ Resend Code
+
+
+ )}
+
+ {/* STRIPE PAYMENT: Confirmed */}
+ {t.paymentMethod === "STRIPE" &&
+ (t.orderStatus === "MEETUP_CONFIRMED" ||
+ t.orderStatus === "COMPLETED") && (
+
+ ✓ Confirmed
+
+ )}
- )}
+
+ ))
+ )}
+
+
+
- {/* STRIPE PAYMENT: Show Code */}
- {t.paymentMethod === 'STRIPE' && t.orderStatus === 'MEETING_STARTED' && (
-
-
Meetup Code
-
{t.meetupCode || "------"}
+ {/* Buyer Dashboard Modal */}
+
+
+
+
+ My Purchases
+
+
+ Manage your purchases and meetup codes with this seller.
+
+
+
+
+ {isFetchingBuyerTransactions ? (
+
+
+
+ ) : buyerTransactions.length === 0 ? (
+
+
+
+ No Active Purchases
+
+
+ You don't have any pending purchases with this seller.
+
+
+ ) : (
+ buyerTransactions.map((t: any) => (
+
+ {/* Item Image & Info */}
+
+ {t.listing?.images?.[0]?.url ? (
+
+ ) : (
+
+ No img
+
+ )}
- )}
- {/* STRIPE PAYMENT: Confirmed */}
- {t.paymentMethod === 'STRIPE' && (t.orderStatus === 'MEETUP_CONFIRMED' || t.orderStatus === 'COMPLETED') && (
-
- ✓ Verified
+
+
+ {t.listing?.title || "Unknown Item"}
+
+
+ ${(t.amount / 100).toFixed(2)}
+
+
+
+ {t.paymentMethod}
+
+
+ {t.orderStatus.replace(/_/g, " ")}
+
+
- )}
-
-
- ))
- )}
-
-
-
-
+ {/* Action Buttons */}
+
+ {/* DIRECT PAYMENT */}
+ {t.paymentMethod === "DIRECT" &&
+ t.orderStatus !== "COMPLETED_BY_SELLER" && (
+
+ Pay the seller when you meet in person.
+
+ )}
+ {t.paymentMethod === "DIRECT" &&
+ t.orderStatus === "COMPLETED_BY_SELLER" && (
+
+ ✓ Received
+
+ )}
+
+ {/* STRIPE PAYMENT: Waiting for seller */}
+ {t.paymentMethod === "STRIPE" &&
+ (t.orderStatus === "PENDING_MEETUP" ||
+ t.orderStatus === "PAID_PENDING_MEETUP" ||
+ t.orderStatus === "ACCEPTED") && (
+
+ Waiting for seller to generate code.
+
+ )}
+
+ {/* STRIPE PAYMENT: Show Code */}
+ {t.paymentMethod === "STRIPE" &&
+ t.orderStatus === "MEETING_STARTED" && (
+
+
+ Meetup Code
+
+
+ {t.meetupCode || "------"}
+
+
+ )}
+
+ {/* STRIPE PAYMENT: Confirmed */}
+ {t.paymentMethod === "STRIPE" &&
+ (t.orderStatus === "MEETUP_CONFIRMED" ||
+ t.orderStatus === "COMPLETED") && (
+
+ ✓ Verified
+
+ )}
+
+
+ ))
+ )}
+
+
+
+
);
}
diff --git a/frontend/app/checkout/[id]/page.tsx b/frontend/app/checkout/[id]/page.tsx
index fc033c9..f28ebec 100644
--- a/frontend/app/checkout/[id]/page.tsx
+++ b/frontend/app/checkout/[id]/page.tsx
@@ -3,152 +3,205 @@
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { loadStripe } from "@stripe/stripe-js";
-import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
+import {
+ Elements,
+ PaymentElement,
+ useStripe,
+ useElements,
+} from "@stripe/react-stripe-js";
import axios from "axios";
import { useAuth } from "@clerk/nextjs";
import { Loader2, ArrowLeft, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
-const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || "");
-
-function CheckoutForm({ clientSecret, listingId }: { clientSecret: string; listingId: string }) {
- const stripe = useStripe();
- const elements = useElements();
- const router = useRouter();
- const [isLoading, setIsLoading] = useState(false);
- const [errorMessage, setErrorMessage] = useState("");
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
-
- if (!stripe || !elements) return;
-
- setIsLoading(true);
- setErrorMessage("");
-
- const { error } = await stripe.confirmPayment({
- elements,
- confirmParams: {
- return_url: `${window.location.origin}/chat`,
- },
- });
-
- if (error) {
- setErrorMessage(error.message || "An unexpected error occurred.");
- }
- setIsLoading(false);
- };
-
- return (
-
-
- {errorMessage && (
-
- {errorMessage}
-
- )}
-
- {isLoading ? : "Authorize Payment"}
-
-
- Your card will only be held, not charged. The funds are safely stored in Escrow until you confirm the meetup with the seller.
-
-
- );
+const stripePromise = loadStripe(
+ process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || "",
+);
+
+function CheckoutForm({
+ clientSecret,
+ listingId,
+}: {
+ clientSecret: string;
+ listingId: string;
+}) {
+ const stripe = useStripe();
+ const elements = useElements();
+ const router = useRouter();
+ const [isLoading, setIsLoading] = useState(false);
+ const [errorMessage, setErrorMessage] = useState("");
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!stripe || !elements) return;
+
+ setIsLoading(true);
+ setErrorMessage("");
+
+ const { error } = await stripe.confirmPayment({
+ elements,
+ confirmParams: {
+ return_url: `${window.location.origin}/chat`,
+ },
+ });
+
+ if (error) {
+ setErrorMessage(error.message || "An unexpected error occurred.");
+ }
+ setIsLoading(false);
+ };
+
+ return (
+
+
+ {errorMessage && (
+
+ {errorMessage}
+
+ )}
+
+ {isLoading ? (
+
+ ) : (
+ "Authorize Payment"
+ )}
+
+
+ Your card will only be held, not charged. The funds are safely stored in
+ Escrow until you confirm the meetup with the seller.
+
+
+ );
}
export default function CheckoutPage() {
- const params = useParams();
- const router = useRouter();
- const { getToken, isLoaded, isSignedIn } = useAuth();
- const [clientSecret, setClientSecret] = useState("");
- const [listing, setListing] = useState(null);
- const [isInitializing, setIsInitializing] = useState(true);
-
- useEffect(() => {
- if (!isLoaded) return;
- if (!isSignedIn) {
- router.push("/sign-in");
- return;
- }
-
- const initCheckout = async () => {
- try {
- const token = await getToken();
- // Fetch listing
- const listingRes = await axios.get(`http://127.0.0.1:3000/listings/${params.id}`, {
- headers: { Authorization: `Bearer ${token}` }
- });
- setListing(listingRes.data);
-
- // Create Payment Intent
- const intentRes = await axios.post(`http://127.0.0.1:3000/payments/intent`, { listingId: params.id },
- { headers: { Authorization: `Bearer ${token}` } }
- );
- setClientSecret(intentRes.data.clientSecret);
- } catch (err: any) {
- console.error("Checkout initialization failed:", err);
- alert(err.response?.data?.message || "Failed to initialize checkout.");
- router.back();
- } finally {
- setIsInitializing(false);
- }
- };
-
- initCheckout();
- }, [isLoaded, isSignedIn, params.id, getToken, router]);
-
- if (isInitializing) {
- return (
-
-
-
- );
- }
-
- if (!clientSecret || !listing) return null;
-
- return (
-
-
-
router.back()}
- className="inline-flex items-center gap-2 text-sm font-bold text-muted-foreground hover:text-foreground mb-8 transition-colors"
- >
-
- Back to Listing
-
-
-
-
- {listing.images && listing.images.length > 0 ? (
-
- ) : null}
-
-
-
Checkout
-
{listing.title}
-
-
- ${listing.price.toFixed(2)}
-
-
-
-
-
-
-
Protected by Orbit Escrow
-
- Your payment is held securely in escrow. We won't release the funds to the seller until you meet up and enter the 6-digit confirmation code.
-
-
-
-
-
-
-
-
-
- );
+ const params = useParams();
+ const router = useRouter();
+ const { getToken, isLoaded, isSignedIn } = useAuth();
+ const [clientSecret, setClientSecret] = useState("");
+ const [listing, setListing] = useState(null);
+ const [isInitializing, setIsInitializing] = useState(true);
+
+ useEffect(() => {
+ if (!isLoaded) return;
+ if (!isSignedIn) {
+ router.push("/sign-in");
+ return;
+ }
+
+ const initCheckout = async () => {
+ try {
+ const token = await getToken();
+ // Fetch listing
+ const listingRes = await axios.get(
+ `http://127.0.0.1:3000/listings/${params.id}`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ setListing(listingRes.data);
+
+ // Create Payment Intent
+ const intentRes = await axios.post(
+ `http://127.0.0.1:3000/payments/intent`,
+ { listingId: params.id },
+ { headers: { Authorization: `Bearer ${token}` } },
+ );
+ setClientSecret(intentRes.data.clientSecret);
+ } catch (err: any) {
+ console.error("Checkout initialization failed:", err);
+ alert(err.response?.data?.message || "Failed to initialize checkout.");
+ router.back();
+ } finally {
+ setIsInitializing(false);
+ }
+ };
+
+ initCheckout();
+ }, [isLoaded, isSignedIn, params.id, getToken, router]);
+
+ if (isInitializing) {
+ return (
+
+
+
+ );
+ }
+
+ if (!clientSecret || !listing) return null;
+
+ return (
+
+
+
router.back()}
+ className="inline-flex items-center gap-2 text-sm font-bold text-muted-foreground hover:text-foreground mb-8 transition-colors"
+ >
+
+ Back to Listing
+
+
+
+
+ {listing.images && listing.images.length > 0 ? (
+
+ ) : null}
+
+
+
+ Checkout
+
+
+ {listing.title}
+
+
+
+
+ ${listing.price.toFixed(2)}
+
+
+
+
+
+
+
+
+ Protected by Orbit Escrow
+
+
+ Your payment is held securely in escrow. We won't release the
+ funds to the seller until you meet up and enter the 6-digit
+ confirmation code.
+
+
+
+
+
+
+
+
+
+ );
}
diff --git a/frontend/app/community/CommunityClient.tsx b/frontend/app/community/CommunityClient.tsx
index 741fe5a..8979473 100644
--- a/frontend/app/community/CommunityClient.tsx
+++ b/frontend/app/community/CommunityClient.tsx
@@ -5,538 +5,924 @@ import { motion, AnimatePresence } from "framer-motion";
import { useAuth } from "@clerk/nextjs";
import axios from "axios";
import { io, Socket } from "socket.io-client";
-import { MessageCircle, MoreHorizontal,
- Heart,
- Plus,
- Image as ImageIcon,
- Loader2,
- X
+import {
+ MessageCircle,
+ MoreHorizontal,
+ Heart,
+ Plus,
+ Image as ImageIcon,
+ Loader2,
+ X,
+ Search,
+ Trash2,
} from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
+import { Input } from "@/components/ui/input";
+import Link from "next/link";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
// The PostType enum from Prisma
type PostType = "DISCUSSION" | "EVENT" | "CHECK_IN" | "LOOKING_FOR";
interface Post {
- id: string;
- content: string;
- postType: PostType;
- imageUrls?: string[];
- createdAt: string;
- author: {
- name: string | null;
- username: string | null;
- avatarUrl: string | null;
- };
- _count: {
- likes: number;
- comments: number;
- };
- likes?: { userId: string }[];
+ id: string;
+ content: string;
+ postType: PostType;
+ imageUrls?: string[];
+ createdAt: string;
+ author: {
+ name: string | null;
+ username: string | null;
+ avatarUrl: string | null;
+ clerkUserId?: string | null;
+ };
+ _count: {
+ likes: number;
+ comments: number;
+ };
+ likes?: { userId: string }[];
}
interface Comment {
- id: string;
- content: string;
- createdAt: string;
- author: {
- name: string | null;
- username: string | null;
- avatarUrl: string | null;
- }
+ id: string;
+ content: string;
+ createdAt: string;
+ author: {
+ name: string | null;
+ username: string | null;
+ avatarUrl: string | null;
+ };
}
// Format date nicely e.g., "May 21" or "2h ago"
const formatDate = (dateString: string) => {
- const date = new Date(dateString);
- const now = new Date();
- const diffInMs = now.getTime() - date.getTime();
- const diffInMins = Math.floor(diffInMs / 60000);
- if (diffInMins < 60) return `${diffInMins || 1}m ago`;
- const diffInHours = Math.floor(diffInMins / 60);
- if (diffInHours < 24) return `${diffInHours}h ago`;
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+ const date = new Date(dateString);
+ const now = new Date();
+ const diffInMs = now.getTime() - date.getTime();
+ const diffInMins = Math.floor(diffInMs / 60000);
+ if (diffInMins < 60) return `${diffInMins || 1}m ago`;
+ const diffInHours = Math.floor(diffInMins / 60);
+ if (diffInHours < 24) return `${diffInHours}h ago`;
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
};
export function CommunityClient({ initialPosts }: { initialPosts: Post[] }) {
- const { getToken, isLoaded, isSignedIn } = useAuth();
- const [activeTab, setActiveTab] = useState<"COMMUNITY" | "FOLLOWING">("COMMUNITY");
- const [isCreatingPost, setIsCreatingPost] = useState(false);
- const [posts, setPosts] = useState(() => {
- const seen = new Set();
- return initialPosts.filter(p => {
- if (seen.has(p.id)) return false;
- seen.add(p.id);
- return true;
- });
- });
- const [isLoading, setIsLoading] = useState(false);
- // Socket State
- const [socket, setSocket] = useState(null);
-
- // Comments State
- const [activePostForComments, setActivePostForComments] = useState(null);
- const [comments, setComments] = useState([]);
- const [newCommentContent, setNewCommentContent] = useState("");
- const [isCommentsLoading, setIsCommentsLoading] = useState(false);
- const [isSubmittingComment, setIsSubmittingComment] = useState(false);
-
- // --- Socket Effects ---
- useEffect(() => {
- if (!isLoaded || !isSignedIn) return;
-
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
- const newSocket = io(apiUrl, { transports: ["websocket"], autoConnect: false });
-
- const setupSocket = async () => {
- const token = await getToken();
- if(token) {
- newSocket.auth = { token };
- newSocket.on("connect", () => {
- newSocket.emit("authenticate");
- });
- newSocket.connect();
- }
- };
- setupSocket();
- setSocket(newSocket);
-
- return () => { newSocket.disconnect(); };
- }, [isLoaded, isSignedIn, getToken]);
-
- useEffect(() => {
- if (!socket) return;
-
- const onLikeUpdate = ({ postId, likeCount }: { postId: string, likeCount: number }) => {
- setPosts(prev => prev.map(p => p.id === postId ? { ...p, _count: { ...p._count, likes: likeCount } } : p));
- };
-
- const onCommentAdded = ({ postId, comment, commentCount }: { postId: string, comment: Comment, commentCount: number }) => {
- setPosts(prev => prev.map(p => p.id === postId ? { ...p, _count: { ...p._count, comments: commentCount } } : p));
- setActivePostForComments(prevActivePost => {
- if (prevActivePost === postId) {
- setComments(prevComments => [...prevComments, comment]);
- }
- return prevActivePost;
- });
- };
-
- socket.on("post_like_update", onLikeUpdate);
- socket.on("post_comment_added", onCommentAdded);
-
- return () => {
- socket.off("post_like_update", onLikeUpdate);
- socket.off("post_comment_added", onCommentAdded);
- };
- }, [socket]);
-
- // --- Handlers ---
-
- const handleToggleLike = async (postId: string) => {
- setPosts(prev => prev.map(p => {
- if (p.id === postId) {
- const isCurrentlyLiked = p.likes && p.likes.length > 0;
- return {
- ...p,
- likes: isCurrentlyLiked ? [] : [{ userId: 'me' }],
- _count: {
- ...p._count,
- likes: isCurrentlyLiked ? Math.max(0, p._count.likes - 1) : p._count.likes + 1
- }
- };
- }
- return p;
- }));
-
- try {
- const token = await getToken();
- await axios.post(`http://127.0.0.1:3000/posts/${postId}/like`, {}, {
- headers: { Authorization: `Bearer ${token}` }
- });
- } catch (err) {
- console.error("Failed to toggle like:", err);
- }
- };
-
- const handleOpenComments = async (postId: string) => {
- setActivePostForComments(postId);
- setIsCommentsLoading(true);
- setComments([]);
- try {
- const token = await getToken();
- const res = await axios.get(`http://127.0.0.1:3000/posts/${postId}/comments`, {
- headers: { Authorization: `Bearer ${token}` }
- });
- setComments(res.data);
- } catch (err) {
- console.error("Failed to fetch comments", err);
- } finally {
- setIsCommentsLoading(false);
- }
- };
-
- const handleCommentSubmit = async () => {
- if (!activePostForComments || !newCommentContent.trim()) return;
- setIsSubmittingComment(true);
- try {
- const token = await getToken();
- await axios.post(`http://127.0.0.1:3000/posts/${activePostForComments}/comment`, { content: newCommentContent }, {
- headers: { Authorization: `Bearer ${token}` }
- });
- setNewCommentContent("");
- } catch (err) {
- console.error("Failed to post comment", err);
- } finally {
- setIsSubmittingComment(false);
- }
- };
- // Post Creation State
- const [newPostContent, setNewPostContent] = useState("");
- const [newPostType, setNewPostType] = useState("DISCUSSION");
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [selectedImages, setSelectedImages] = useState([]);
- const fileInputRef = useRef(null);
-
-
-
- const handleImageChange = (e: React.ChangeEvent) => {
- if (e.target.files) {
- const filesArray = Array.from(e.target.files);
- if (selectedImages.length + filesArray.length > 4) {
- alert("You can only upload up to 4 images per post.");
- return;
- }
- setSelectedImages((prev) => [...prev, ...filesArray].slice(0, 4));
- }
- };
-
- const removeImage = (index: number) => {
- setSelectedImages((prev) => prev.filter((_, i) => i !== index));
- };
-
- const handleCreatePost = async () => {
- if (!newPostContent.trim() && selectedImages.length === 0) return;
- setIsSubmitting(true);
-
- try {
- const token = await getToken();
- const formData = new FormData();
- formData.append("content", newPostContent);
- formData.append("postType", newPostType);
- selectedImages.forEach((file) => {
- formData.append("images", file);
- });
-
- const res = await axios.post("http://127.0.0.1:3000/posts", formData, {
- headers: { Authorization: `Bearer ${token}`,
- "Content-Type": "multipart/form-data" }
- });
- // Add the new post to the top of the feed
- setPosts(prev => {
- if (prev.some(p => p.id === res.data.id)) return prev;
- return [res.data, ...prev];
- });
- // Reset form
- setNewPostContent("");
- setSelectedImages([]);
- setIsCreatingPost(false);
- } catch (error: any) {
- console.error("Failed to create post:", error.response?.data || error.message);
- } finally {
- setIsSubmitting(false);
- }
- };
-
- if (!isLoaded || isLoading) {
- return (
-
-
-
- );
- }
-
- return (
-
- {/* Central Feed Container - Web Optimized Fixed Width */}
-
- {/* Top Header - Floating Pill */}
-
-
- setActiveTab("COMMUNITY")}
- className={`px-6 py-2.5 text-sm font-bold rounded-full transition-all ${activeTab === "COMMUNITY" ? "bg-[#3252DF] text-primary-foreground shadow-md" : "text-muted-foreground hover:text-foreground"}`}
- >
- Community @ UIC
-
- setActiveTab("FOLLOWING")}
- className={`px-6 py-2.5 text-sm font-bold rounded-full transition-all ${activeTab === "FOLLOWING" ? "bg-[#3252DF] text-primary-foreground shadow-md" : "text-muted-foreground hover:text-foreground"}`}
- >
- Following
-
-
-
setIsCreatingPost(true)}
- size="icon"
- className="h-12 w-12 shrink-0 rounded-full bg-zinc-900 hover:bg-foreground text-primary-foreground shadow-md flex items-center justify-center transition-transform hover:scale-105 active:scale-95 border border-zinc-800"
- >
-
-
-
-
- {/* Feed Posts */}
-
-
- {posts.length === 0 ? (
-
-
-
No posts yet
-
Be the first to start a discussion!
-
- ) : (
- posts.map((post) => (
-
-
- {/* Avatar */}
-
-
-
- {(post.author.name || post.author.username || 'U')[0].toUpperCase()}
-
-
- {/* Post Content */}
-
- {/* Header: Name & Date */}
-
-
-
- {post.author.name || post.author.username || "Anonymous Student"}
-
-
- {post.postType.replace('_', ' ')}
-
-
-
- {formatDate(post.createdAt)}
-
-
-
-
-
- {/* Text Body */}
-
- {post.content}
-
- {/* Optional Image Grid */}
- {post.imageUrls && post.imageUrls.length > 0 && (
-
1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
- {post.imageUrls.map((url, index) => (
-
-
-
- ))}
-
- )}
- {/* Footer Actions */}
-
- { e.stopPropagation(); handleToggleLike(post.id); }}
- className={`flex items-center gap-2 transition-colors group ${post.likes && post.likes.length > 0 ? 'text-rose-500' : 'text-muted-foreground hover:text-rose-500'}`}
- >
- 0 ? 'fill-rose-500' : 'group-hover:fill-rose-100'}`} />
- {post._count?.likes || 0}
-
- { e.stopPropagation(); handleOpenComments(post.id); }}
- className="flex items-center gap-2 text-muted-foreground hover:text-[#3252DF] transition-colors group"
- >
-
- {post._count?.comments || 0}
-
-
-
-
-
- ))
- )}
-
-
-
- {/* Create Post Modal Overlay */}
-
- {isCreatingPost && (
- setIsCreatingPost(false)}
- >
- e.stopPropagation()}
- >
-
-
Create Post
- setIsCreatingPost(false)} className="rounded-full text-muted-foreground hover:bg-secondary">
-
-
-
-
- {/* Post Type Selector */}
-
- {(["DISCUSSION", "EVENT", "CHECK_IN", "LOOKING_FOR"] as PostType[]).map((type) => (
- setNewPostType(type)}
- className={`px-3 py-1.5 rounded-full text-xs font-bold whitespace-nowrap transition-colors ${
- newPostType === type ? "bg-[#3252DF] text-primary-foreground" : "bg-secondary text-muted-foreground hover:bg-secondary"
- }`}
- >
- {type.replace('_', ' ')}
-
- ))}
-
-
-
setNewPostContent(e.target.value)}
- autoFocus
- />
-
- {/* Selected Images Preview Grid */}
- {selectedImages.length > 0 && (
-
- {selectedImages.map((file, index) => (
-
-
-
removeImage(index)}
- className="absolute top-1 right-1 bg-foreground/60 text-primary-foreground p-1 rounded-full hover:bg-foreground transition-colors"
- >
-
-
-
- ))}
-
- )}
-
-
- fileInputRef.current?.click()}
- variant="ghost" size="icon" className="text-muted-foreground hover:text-[#3252DF] hover:bg-blue-50 rounded-full"
- >
-
-
-
- {isSubmitting ? : "Post"}
-
-
-
-
-
- )}
-
-
- {/* Comments Drawer/Modal */}
-
- {activePostForComments && (
- setActivePostForComments(null)}
- >
- e.stopPropagation()}
- >
-
-
-
- Comments
-
- setActivePostForComments(null)} className="rounded-full text-muted-foreground hover:bg-secondary">
-
-
-
-
- {isCommentsLoading ? (
-
-
-
- ) : comments.length === 0 ? (
-
-
No comments yet. Be the first!
-
- ) : (
- comments.map(c => (
-
-
-
-
- {(c.author.name || c.author.username || 'U')[0].toUpperCase()}
-
-
-
-
-
- {c.author.name || c.author.username || "Anonymous"}
-
- {formatDate(c.createdAt)}
-
-
{c.content}
-
-
- ))
- )}
-
-
-
-
- setNewCommentContent(e.target.value)}
- onKeyDown={e => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleCommentSubmit();
- }
- }}
- />
-
- {isSubmittingComment ? : "Post"}
-
-
-
-
-
- )}
-
-
- {/* Removed Floating Action Button */}
-
-
- );
+ const {
+ getToken,
+ isLoaded,
+ isSignedIn,
+ userId: currentAuthUserId,
+ } = useAuth();
+ const [activeTab, setActiveTab] = useState<"COMMUNITY" | "FOLLOWING">(
+ "COMMUNITY",
+ );
+ const [activeFilter, setActiveFilter] = useState<"ALL" | PostType>("ALL");
+ const [isCreatingPost, setIsCreatingPost] = useState(false);
+ const [isAnonymous, setIsAnonymous] = useState(false);
+ const [posts, setPosts] = useState(() => {
+ const seen = new Set();
+ return initialPosts.filter((p) => {
+ if (seen.has(p.id)) return false;
+ seen.add(p.id);
+ return true;
+ });
+ });
+ const [isLoading, setIsLoading] = useState(false);
+ // Socket State
+ const [socket, setSocket] = useState(null);
+
+ // Comments State
+ const [activePostForComments, setActivePostForComments] = useState<
+ string | null
+ >(null);
+ const [comments, setComments] = useState([]);
+ const [newCommentContent, setNewCommentContent] = useState("");
+ const [isCommentsLoading, setIsCommentsLoading] = useState(false);
+ const [isSubmittingComment, setIsSubmittingComment] = useState(false);
+
+ // Search Users State
+ const [searchQuery, setSearchQuery] = useState("");
+ const [searchResults, setSearchResults] = useState([]);
+ const [isSearchingUsers, setIsSearchingUsers] = useState(false);
+
+ useEffect(() => {
+ if (!searchQuery) {
+ setSearchResults([]);
+ return;
+ }
+ const delayDebounceFn = setTimeout(async () => {
+ setIsSearchingUsers(true);
+ try {
+ const token = await getToken();
+ const res = await axios.get(
+ `http://127.0.0.1:3000/users/search?q=${searchQuery}`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ setSearchResults(res.data);
+ } catch (err) {
+ console.error("Failed to search users", err);
+ } finally {
+ setIsSearchingUsers(false);
+ }
+ }, 500);
+
+ return () => clearTimeout(delayDebounceFn);
+ }, [searchQuery, getToken]);
+
+ const handleDeletePost = async (postId: string) => {
+ try {
+ const token = await getToken();
+ await axios.delete(`http://127.0.0.1:3000/posts/${postId}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ setPosts((prev) => prev.filter((p) => p.id !== postId));
+ } catch (error) {
+ console.error("Failed to delete post", error);
+ }
+ };
+
+ // --- Socket Effects ---
+ useEffect(() => {
+ if (!isLoaded || !isSignedIn) return;
+
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
+ const newSocket = io(apiUrl, {
+ transports: ["websocket"],
+ autoConnect: false,
+ });
+
+ const setupSocket = async () => {
+ const token = await getToken();
+ if (token) {
+ newSocket.auth = { token };
+ newSocket.on("connect", () => {
+ newSocket.emit("authenticate");
+ });
+ newSocket.connect();
+ }
+ };
+ setupSocket();
+ setSocket(newSocket);
+
+ return () => {
+ newSocket.disconnect();
+ };
+ }, [isLoaded, isSignedIn, getToken]);
+
+ useEffect(() => {
+ if (!isLoaded || !isSignedIn) return;
+ const fetchPosts = async () => {
+ setIsLoading(true);
+ try {
+ const token = await getToken();
+ const url =
+ activeFilter === "ALL"
+ ? "http://127.0.0.1:3000/posts"
+ : `http://127.0.0.1:3000/posts?type=${activeFilter}`;
+
+ const res = await axios.get(url, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ setPosts(res.data);
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchPosts();
+ }, [activeFilter, getToken, isLoaded, isSignedIn]);
+
+ useEffect(() => {
+ if (!socket) return;
+
+ const onLikeUpdate = ({
+ postId,
+ likeCount,
+ }: {
+ postId: string;
+ likeCount: number;
+ }) => {
+ setPosts((prev) =>
+ prev.map((p) =>
+ p.id === postId
+ ? { ...p, _count: { ...p._count, likes: likeCount } }
+ : p,
+ ),
+ );
+ };
+
+ const onCommentAdded = ({
+ postId,
+ comment,
+ commentCount,
+ }: {
+ postId: string;
+ comment: Comment;
+ commentCount: number;
+ }) => {
+ setPosts((prev) =>
+ prev.map((p) =>
+ p.id === postId
+ ? { ...p, _count: { ...p._count, comments: commentCount } }
+ : p,
+ ),
+ );
+ setActivePostForComments((prevActivePost) => {
+ if (prevActivePost === postId) {
+ setComments((prevComments) => {
+ if (prevComments.some(c => c.id === comment.id)) return prevComments;
+ return [...prevComments, comment];
+ });
+ }
+ return prevActivePost;
+ });
+ };
+
+ socket.on("post_like_update", onLikeUpdate);
+ socket.on("post_comment_added", onCommentAdded);
+
+ return () => {
+ socket.off("post_like_update", onLikeUpdate);
+ socket.off("post_comment_added", onCommentAdded);
+ };
+ }, [socket]);
+
+ // --- Handlers ---
+
+ const handleToggleLike = async (postId: string) => {
+ setPosts((prev) =>
+ prev.map((p) => {
+ if (p.id === postId) {
+ const isCurrentlyLiked = p.likes && p.likes.length > 0;
+ return {
+ ...p,
+ likes: isCurrentlyLiked ? [] : [{ userId: "me" }],
+ _count: {
+ ...p._count,
+ likes: isCurrentlyLiked
+ ? Math.max(0, p._count.likes - 1)
+ : p._count.likes + 1,
+ },
+ };
+ }
+ return p;
+ }),
+ );
+
+ try {
+ const token = await getToken();
+ await axios.post(
+ `http://127.0.0.1:3000/posts/${postId}/like`,
+ {},
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ } catch (err) {
+ console.error("Failed to toggle like:", err);
+ }
+ };
+
+ const handleOpenComments = async (postId: string) => {
+ setActivePostForComments(postId);
+ setIsCommentsLoading(true);
+ setComments([]);
+ try {
+ const token = await getToken();
+ const res = await axios.get(
+ `http://127.0.0.1:3000/posts/${postId}/comments`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ setComments(res.data);
+ } catch (err) {
+ console.error("Failed to fetch comments", err);
+ } finally {
+ setIsCommentsLoading(false);
+ }
+ };
+
+ const handleCommentSubmit = async () => {
+ if (!activePostForComments || !newCommentContent.trim()) return;
+ setIsSubmittingComment(true);
+ try {
+ const token = await getToken();
+ await axios.post(
+ `http://127.0.0.1:3000/posts/${activePostForComments}/comment`,
+ { content: newCommentContent },
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ setNewCommentContent("");
+ } catch (err) {
+ console.error("Failed to post comment", err);
+ } finally {
+ setIsSubmittingComment(false);
+ }
+ };
+ // Post Creation State
+ const [newPostContent, setNewPostContent] = useState("");
+ const [newPostType, setNewPostType] = useState("DISCUSSION");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [selectedImages, setSelectedImages] = useState([]);
+ const fileInputRef = useRef(null);
+
+ const handleImageChange = (e: React.ChangeEvent) => {
+ if (e.target.files) {
+ const filesArray = Array.from(e.target.files);
+ if (selectedImages.length + filesArray.length > 4) {
+ alert("You can only upload up to 4 images per post.");
+ return;
+ }
+ setSelectedImages((prev) => [...prev, ...filesArray].slice(0, 4));
+ }
+ };
+
+ const removeImage = (index: number) => {
+ setSelectedImages((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ const handleCreatePost = async () => {
+ if (!newPostContent.trim() && selectedImages.length === 0) return;
+ setIsSubmitting(true);
+
+ try {
+ const token = await getToken();
+ const formData = new FormData();
+ formData.append("content", newPostContent);
+ formData.append("postType", newPostType);
+ formData.append("isAnonymous", isAnonymous ? "true" : "false");
+ selectedImages.forEach((file) => {
+ formData.append("images", file);
+ });
+
+ const res = await axios.post("http://127.0.0.1:3000/posts", formData, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "multipart/form-data",
+ },
+ });
+ // Add the new post to the top of the feed
+ setPosts((prev) => {
+ if (prev.some((p) => p.id === res.data.id)) return prev;
+ return [res.data, ...prev];
+ });
+ // Reset form
+ setNewPostContent("");
+ setSelectedImages([]);
+ setIsCreatingPost(false);
+ } catch (error: any) {
+ console.error(
+ "Failed to create post:",
+ error.response?.data || error.message,
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ if (!isLoaded || isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Central Feed Container - Web Optimized Fixed Width */}
+
+ {/* Top Header - Floating Pill */}
+
+
+
+ setActiveTab("COMMUNITY")}
+ className={`px-6 py-2.5 text-sm font-bold rounded-full transition-all ${activeTab === "COMMUNITY" ? "bg-[#3252DF] text-primary-foreground shadow-md" : "text-muted-foreground hover:text-foreground"}`}
+ >
+ Community @ UIC
+
+ setActiveTab("FOLLOWING")}
+ className={`px-6 py-2.5 text-sm font-bold rounded-full transition-all ${activeTab === "FOLLOWING" ? "bg-[#3252DF] text-primary-foreground shadow-md" : "text-muted-foreground hover:text-foreground"}`}
+ >
+ Following
+
+
+
setIsCreatingPost(true)}
+ size="icon"
+ className="h-12 w-12 shrink-0 rounded-full bg-zinc-900 hover:bg-foreground text-primary-foreground shadow-md flex items-center justify-center transition-transform hover:scale-105 active:scale-95 border border-zinc-800"
+ >
+
+
+
+
+ {/* Search Users Input */}
+
+
+
setSearchQuery(e.target.value)}
+ className="w-full bg-secondary/50 border-border rounded-full pl-10 h-10"
+ />
+ {searchQuery && (
+
+ {isSearchingUsers ? (
+
+ {" "}
+ Searching...
+
+ ) : searchResults.length > 0 ? (
+ searchResults.map((user) => (
+
+
+
+
+ {(user.name || user.username || "U")[0].toUpperCase()}
+
+
+
+
+ {user.name || user.username}
+
+
+ {user.major || "Student"} •{" "}
+ {user._count?.followers || 0} followers
+
+
+
+ ))
+ ) : (
+
+ No users found
+
+ )}
+
+ )}
+
+
+ {activeTab === "COMMUNITY" && (
+
+ {(
+ [
+ "ALL",
+ "DISCUSSION",
+ "EVENT",
+ "CHECK_IN",
+ "LOOKING_FOR",
+ ] as const
+ ).map((type) => (
+ setActiveFilter(type)}
+ className={`px-4 py-2 text-xs font-bold rounded-full transition-all whitespace-nowrap ${
+ activeFilter === type
+ ? "bg-[#3252DF] text-primary-foreground shadow-md"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ {type === "ALL" ? "All" : type.replace("_", " ")}
+
+ ))}
+
+ )}
+
+
+ {/* Feed Posts */}
+
+
+ {posts.length === 0 ? (
+
+
+
+ No posts yet
+
+
Be the first to start a discussion!
+
+ ) : (
+ posts.map((post) => (
+
+
+ {/* Avatar */}
+ {post.author.clerkUserId ? (
+
+
+
+
+ {(post.author.name ||
+ post.author.username ||
+ "U")[0].toUpperCase()}
+
+
+
+ ) : (
+
+
+
+ {(post.author.name ||
+ post.author.username ||
+ "U")[0].toUpperCase()}
+
+
+ )}
+ {/* Post Content */}
+
+ {/* Header: Name & Date */}
+
+
+ {post.author.clerkUserId ? (
+
+ {post.author.name ||
+ post.author.username ||
+ "Anonymous Student"}
+
+ ) : (
+
+ {post.author.name ||
+ post.author.username ||
+ "Anonymous Student"}
+
+ )}
+
+ {post.postType.replace("_", " ")}
+
+
+
+
+ {formatDate(post.createdAt)}
+
+
+
+
+
+
+ {post.author.clerkUserId ===
+ currentAuthUserId && (
+ {
+ e.stopPropagation();
+ handleDeletePost(post.id);
+ }}
+ >
+
+ Delete
+
+ )}
+ e.stopPropagation()}
+ >
+ Copy Link
+
+
+
+
+
+ {/* Text Body */}
+
+ {post.content}
+
+ {/* Optional Image Grid */}
+ {post.imageUrls && post.imageUrls.length > 0 && (
+
1 ? "grid-cols-2" : "grid-cols-1"}`}
+ >
+ {post.imageUrls.map((url, index) => (
+
+
+
+ ))}
+
+ )}
+ {/* Footer Actions */}
+
+ {
+ e.stopPropagation();
+ handleToggleLike(post.id);
+ }}
+ className={`flex items-center gap-2 transition-colors group ${post.likes && post.likes.length > 0 ? "text-rose-500" : "text-muted-foreground hover:text-rose-500"}`}
+ >
+ 0 ? "fill-rose-500" : "group-hover:fill-rose-100"}`}
+ />
+
+ {post._count?.likes || 0}
+
+
+ {
+ e.stopPropagation();
+ handleOpenComments(post.id);
+ }}
+ className="flex items-center gap-2 text-muted-foreground hover:text-[#3252DF] transition-colors group"
+ >
+
+
+ {post._count?.comments || 0}
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+ {/* Create Post Modal Overlay */}
+
+ {isCreatingPost && (
+ setIsCreatingPost(false)}
+ >
+ e.stopPropagation()}
+ >
+
+
+ Create Post
+
+ setIsCreatingPost(false)}
+ className="rounded-full text-muted-foreground hover:bg-secondary"
+ >
+
+
+
+
+ {/* Post Type Selector */}
+
+ {(
+ [
+ "DISCUSSION",
+ "EVENT",
+ "CHECK_IN",
+ "LOOKING_FOR",
+ ] as PostType[]
+ ).map((type) => (
+ setNewPostType(type)}
+ className={`px-3 py-1.5 rounded-full text-xs font-bold whitespace-nowrap transition-colors ${
+ newPostType === type
+ ? "bg-[#3252DF] text-primary-foreground"
+ : "bg-secondary text-muted-foreground hover:bg-secondary"
+ }`}
+ >
+ {type.replace("_", " ")}
+
+ ))}
+
+
+
setNewPostContent(e.target.value)}
+ autoFocus
+ />
+
+ {/* Selected Images Preview Grid */}
+ {selectedImages.length > 0 && (
+
+ {selectedImages.map((file, index) => (
+
+
+
removeImage(index)}
+ className="absolute top-1 right-1 bg-foreground/60 text-primary-foreground p-1 rounded-full hover:bg-foreground transition-colors"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ )}
+
+
+ {/* Comments Drawer/Modal */}
+
+ {activePostForComments && (
+ setActivePostForComments(null)}
+ >
+ e.stopPropagation()}
+ >
+
+
+
+ Comments
+
+ setActivePostForComments(null)}
+ className="rounded-full text-muted-foreground hover:bg-secondary"
+ >
+
+
+
+
+ {isCommentsLoading ? (
+
+
+
+ ) : comments.length === 0 ? (
+
+
No comments yet. Be the first!
+
+ ) : (
+ comments.map((c) => (
+
+
+
+
+ {(c.author.name ||
+ c.author.username ||
+ "U")[0].toUpperCase()}
+
+
+
+
+
+ {c.author.name ||
+ c.author.username ||
+ "Anonymous"}
+
+
+ {formatDate(c.createdAt)}
+
+
+
+ {c.content}
+
+
+
+ ))
+ )}
+
+
+
+
+ setNewCommentContent(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleCommentSubmit();
+ }
+ }}
+ />
+
+ {isSubmittingComment ? (
+
+ ) : (
+ "Post"
+ )}
+
+
+
+
+
+ )}
+
+
+ {/* Removed Floating Action Button */}
+
+
+ );
}
diff --git a/frontend/app/community/page.tsx b/frontend/app/community/page.tsx
index 7a14cf5..0dc772a 100644
--- a/frontend/app/community/page.tsx
+++ b/frontend/app/community/page.tsx
@@ -1,21 +1,21 @@
-import { auth } from '@clerk/nextjs/server';
-import axios from 'axios';
-import { CommunityClient } from './CommunityClient';
+import { auth } from "@clerk/nextjs/server";
+import axios from "axios";
+import { CommunityClient } from "./CommunityClient";
export default async function CommunityPage() {
- const { getToken } = await auth();
- const token = await getToken();
- let initialPosts = [];
- try {
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
- const res = await axios.get(`${apiUrl}/posts`, {
- headers: { Authorization: `Bearer ${token}` }
- });
- console.log("CommunityPage API Response:", res.data);
- initialPosts = res.data;
- } catch (error) {
- console.error("Failed to fetch initial posts:", error);
- }
+ const { getToken } = await auth();
+ const token = await getToken();
+ let initialPosts = [];
+ try {
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3000";
+ const res = await axios.get(`${apiUrl}/posts`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ console.log("CommunityPage API Response:", res.data);
+ initialPosts = res.data;
+ } catch (error) {
+ console.error("Failed to fetch initial posts:", error);
+ }
- return ;
+ return ;
}
diff --git a/frontend/app/faqs/page.tsx b/frontend/app/faqs/page.tsx
index f980fc2..8982dc0 100644
--- a/frontend/app/faqs/page.tsx
+++ b/frontend/app/faqs/page.tsx
@@ -1,14 +1,21 @@
import Link from "next/link";
-import { ArrowLeft, ShoppingBag, ShieldCheck, CreditCard, Sparkles } from "lucide-react";
+import {
+ ArrowLeft,
+ ShoppingBag,
+ ShieldCheck,
+ CreditCard,
+ Sparkles,
+} from "lucide-react";
export default function FaqsPage() {
return (
-
{/* Main Content */}
-
-
+
Back
@@ -16,9 +23,10 @@ export default function FaqsPage() {
Features & FAQs
-
+
- Everything you need to know about buying, selling, and swapping on Orbit safely.
+ Everything you need to know about buying, selling, and swapping on
+ Orbit safely.
{/* FAQ Section */}
@@ -28,38 +36,55 @@ export default function FaqsPage() {
-
-
Is the platform free?
+
+ Is the platform free?
+
- Yes, it is completely free for all verified students to browse and list items. We want to keep money in the campus community.
+ Yes, it is completely free for all verified students to browse
+ and list items. We want to keep money in the campus community.
-
How does it work?
+
+ How does it work?
+
- You sign up with your university email, browse local listings or swipe through matches, and purchase items using our secure payment system. Then, you meet up on campus and exchange the Meetup Code to finalize the transaction.
+ You sign up with your university email, browse local listings or
+ swipe through matches, and purchase items using our secure
+ payment system. Then, you meet up on campus and exchange the
+ Meetup Code to finalize the transaction.
-
What if I don't have a .edu email?
+
+ What if I don't have a .edu email?
+
- To maintain a secure and exclusive student environment, an active university email is strictly required. This ensures every user is a real student from your campus.
+ To maintain a secure and exclusive student environment, an
+ active university email is strictly required. This ensures every
+ user is a real student from your campus.
-
How do payments work?
+
+ How do payments work?
+
- We offer two payment methods to fit your needs. Direct Payment: You arrange payment entirely on your own (cash, Zelle, Venmo) when you meet the seller. Secure Payment: Handled seamlessly inside Orbit via Stripe. Your funds are held securely until you meet up and exchange a unique Meetup Code, protecting both the buyer and seller.
+ We offer two payment methods to fit your needs.{" "}
+ Direct Payment: You arrange payment entirely on
+ your own (cash, Zelle, Venmo) when you meet the seller.{" "}
+ Secure Payment: Handled seamlessly inside Orbit
+ via Stripe. Your funds are held securely until you meet up and
+ exchange a unique Meetup Code, protecting both the buyer and
+ seller.
-
-
);
diff --git a/frontend/app/home/page.tsx b/frontend/app/home/page.tsx
index a29329a..0aee5a3 100644
--- a/frontend/app/home/page.tsx
+++ b/frontend/app/home/page.tsx
@@ -1,97 +1,97 @@
-'use client';
+"use client";
-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 axios from 'axios';
-import Link from 'next/link';
+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 axios from "axios";
+import Link from "next/link";
interface Seller {
- id: string;
- name?: string;
- username?: string;
- avatarUrl?: string;
- email?: string;
- university?: string;
+ id: string;
+ name?: string;
+ username?: string;
+ avatarUrl?: string;
+ email?: string;
+ university?: string;
}
interface Listing {
- id: string;
- title: string;
- description: string;
- price: number;
- category: string;
- status: string;
- seller: Seller;
- createdAt: string;
- images?: { url: string }[];
+ id: string;
+ title: string;
+ description: string;
+ price: number;
+ category: string;
+ status: string;
+ seller: Seller;
+ createdAt: string;
+ images?: { url: string }[];
}
const getImageUrl = (url?: string) => {
- if (!url) return "";
- if (url.startsWith("http")) return url;
- return `http://127.0.0.1:3000${url}`;
+ if (!url) return "";
+ if (url.startsWith("http")) return url;
+ return `http://127.0.0.1:3000${url}`;
};
export default function Home() {
- const { getToken, isLoaded, isSignedIn } = useAuth();
- const [listings, setListings] = useState
([]);
- const [recommendedListings, setRecommendedListings] = useState([]);
- const [hotListings, setHotListings] = useState([]);
- const [viewedListings, setViewedListings] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
-
- useEffect(() => {
- const fetchListings = async () => {
- if (!isLoaded || !isSignedIn) return;
- setIsLoading(true);
- try {
- const token = await getToken();
- const url = 'http://127.0.0.1:3000/listings/all';
- const res = await axios.get(url, {
- headers: { Authorization: `Bearer ${token}` }
- });
-
- const recUrl = 'http://127.0.0.1:3000/listings/recommended';
- const recRes = await axios.get(recUrl, {
- headers: { Authorization: `Bearer ${token}` }
- });
-
- const hotUrl = 'http://127.0.0.1:3000/listings/hot';
- const hotRes = await axios.get(hotUrl, {
- headers: { Authorization: `Bearer ${token}` }
- });
-
- const viewedUrl = 'http://127.0.0.1:3000/listings/viewed';
- const viewedRes = await axios.get(viewedUrl, {
- headers: { Authorization: `Bearer ${token}` }
- });
-
- setListings(res.data);
- setRecommendedListings(recRes.data);
- setHotListings(hotRes.data);
- setViewedListings(viewedRes.data);
- } catch (err) {
- console.error('Failed to fetch listings', err);
- } finally {
- setIsLoading(false);
- }
- };
-
- fetchListings();
- }, [isLoaded, isSignedIn, getToken]);
-
- if (!isLoaded) {
- return (
-
-
-
- );
- }
-
- return (
-
+ const { getToken, isLoaded, isSignedIn } = useAuth();
+ const [listings, setListings] = useState
([]);
+ const [recommendedListings, setRecommendedListings] = useState([]);
+ const [hotListings, setHotListings] = useState([]);
+ const [viewedListings, setViewedListings] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchListings = async () => {
+ if (!isLoaded || !isSignedIn) return;
+ setIsLoading(true);
+ try {
+ const token = await getToken();
+ const url = "http://127.0.0.1:3000/listings/all";
+ const res = await axios.get(url, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+
+ const recUrl = "http://127.0.0.1:3000/listings/recommended";
+ const recRes = await axios.get(recUrl, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+
+ const hotUrl = "http://127.0.0.1:3000/listings/hot";
+ const hotRes = await axios.get(hotUrl, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+
+ const viewedUrl = "http://127.0.0.1:3000/listings/viewed";
+ const viewedRes = await axios.get(viewedUrl, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+
+ setListings(res.data);
+ setRecommendedListings(recRes.data);
+ setHotListings(hotRes.data);
+ setViewedListings(viewedRes.data);
+ } catch (err) {
+ console.error("Failed to fetch listings", err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchListings();
+ }, [isLoaded, isSignedIn, getToken]);
+
+ if (!isLoaded) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
{/* Hero Banner */}
@@ -110,7 +110,7 @@ export default function Home() {
Sell now
-
+
{/* Right Side (Image) */}
- {/* Main Content */}
-
- {isLoading ? (
-
-
-
- ) : (
-
-
For you ! }
- listings={recommendedListings.length > 0 ? recommendedListings : listings}
- viewMoreHref="/listings?sort=recommended"
- />
- Hot @ UIC }
- listings={hotListings}
- viewMoreHref="/listings?sort=hot"
- />
- You've viewed }
- listings={viewedListings}
- viewMoreHref="/listings?sort=recent"
- />
- New Listings }
- listings={listings.slice().sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())}
- viewMoreHref="/listings?sort=newest"
- />
-
- )}
-
-
- );
+ {/* Main Content */}
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+ For you !
+
+ }
+ listings={
+ recommendedListings.length > 0 ? recommendedListings : listings
+ }
+ viewMoreHref="/listings?sort=recommended"
+ />
+
+ Hot @ UIC{" "}
+ {" "}
+
+ }
+ listings={hotListings}
+ viewMoreHref="/listings?sort=hot"
+ />
+
+ You've viewed
+
+ }
+ listings={viewedListings}
+ viewMoreHref="/listings?sort=recent"
+ />
+ New Listings
+ }
+ listings={listings
+ .slice()
+ .sort(
+ (a, b) =>
+ new Date(b.createdAt).getTime() -
+ new Date(a.createdAt).getTime(),
+ )}
+ viewMoreHref="/listings?sort=newest"
+ />
+
+ )}
+
+
+ );
}
-function ProductSection({ title, listings, viewMoreHref }: { title: React.ReactNode, listings: Listing[], viewMoreHref?: string }) {
+function ProductSection({
+ title,
+ listings,
+ viewMoreHref,
+}: {
+ title: React.ReactNode;
+ listings: Listing[];
+ viewMoreHref?: string;
+}) {
if (listings.length === 0) return null;
return (
-
{title}
+
+ {title}
+
{viewMoreHref && (
-
+
View More
@@ -172,14 +208,20 @@ function ProductSection({ title, listings, viewMoreHref }: { title: React.ReactN
{listings.map((listing) => (
-
{viewMoreHref && (
-
+
View More
@@ -206,9 +248,9 @@ function ListingCard({ listing }: { listing: Listing }) {
) : (
)}
-
+
{/* Transparent Heart Button */}
-
{
e.preventDefault();
@@ -219,7 +261,7 @@ function ListingCard({ listing }: { listing: Listing }) {
-
+
${listing.price.toFixed(2)}
@@ -227,10 +269,12 @@ function ListingCard({ listing }: { listing: Listing }) {
{listing.title}
-
+
{listing.seller?.university && (
- 🎓 {listing.seller.university}
+
+ 🎓 {listing.seller.university}
+
)}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index e6caa3d..0eac322 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -12,45 +12,67 @@ import { NavActions } from "@/components/NavActions";
import { GlobalNav } from "@/components/GlobalNav";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
+import { MiniChatWidget } from "@/components/MiniChatWidget";
-const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
-const jetbrainsMono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono' });
+const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
+const jetbrainsMono = JetBrains_Mono({
+ subsets: ["latin"],
+ variable: "--font-mono",
+});
export const metadata: Metadata = {
title: "Orbit | Verified Student Marketplace",
- description: "Buy, sell, and swap items safely with verified .edu student profiles.",
+ description:
+ "Buy, sell, and swap items safely with verified .edu student profiles.",
};
-export default async function RootLayout({ children }: { children: React.ReactNode }) {
+export default async function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
const user = await currentUser();
const email = user?.emailAddresses[0]?.emailAddress;
const isEdu = email ? email.endsWith(".edu") : true;
return (
-
+
Orbit ',
+ logoImageUrl:
+ 'data:image/svg+xml;utf8,Orbit ',
},
elements: {
logoImage: "dark:invert-0 invert",
- formButtonPrimary: "bg-primary hover:opacity-90 text-primary-foreground transition-all",
+ formButtonPrimary:
+ "bg-primary hover:opacity-90 text-primary-foreground transition-all",
card: "bg-background text-foreground",
headerTitle: "text-foreground",
headerSubtitle: "text-muted-foreground",
- socialButtonsBlockButton: "border-border text-foreground hover:bg-secondary",
+ socialButtonsBlockButton:
+ "border-border text-foreground hover:bg-secondary",
socialButtonsBlockButtonText: "text-foreground font-semibold",
dividerLine: "bg-border",
dividerText: "text-muted-foreground",
formFieldLabel: "text-foreground",
- formFieldInput: "bg-secondary border-border text-foreground focus:ring-primary",
+ formFieldInput:
+ "bg-secondary border-border text-foreground focus:ring-primary",
footerActionText: "text-muted-foreground",
footerActionLink: "text-primary hover:text-primary",
- }
+ },
}}
localization={{
formFieldInputPlaceholder__emailAddress: "you@uni.edu",
@@ -58,17 +80,22 @@ export default async function RootLayout({ children }: { children: React.ReactNo
start: {
title: "Sign in to Orbit",
subtitle: "Welcome back! Please sign in to continue",
- }
+ },
},
signUp: {
start: {
title: "Create your Orbit account",
subtitle: "Welcome! Please fill in the details to get started.",
- }
- }
+ },
+ },
}}
>
-
+
} />
{/* Bottom Row: Categories and Links */}
@@ -77,10 +104,14 @@ export default async function RootLayout({ children }: { children: React.ReactNo
-
- {isEdu ? (
- {children}
- ) : (
+ {isEdu ? (
+
+
+ {children}
+
+
+ ) : (
+
{/* Background glows */}
@@ -93,28 +124,35 @@ export default async function RootLayout({ children }: { children: React.ReactNo
-
@uni.edu Required
+
+ @uni.edu Required
+
- Orbit is an exclusive marketplace for verified university students. Please sign in with an email address ending in .edu to join the community.
+ Orbit is an exclusive marketplace for verified university
+ students. Please sign in with an email address ending in{" "}
+ .edu to join the
+ community.
You are currently signed in as:
-
{email}
+
+ {email}
+
- )}
-
+
+ )}
+
+
-
);
}
-
diff --git a/frontend/app/listings/[id]/edit/page.tsx b/frontend/app/listings/[id]/edit/page.tsx
index e0e17ed..e9a0404 100644
--- a/frontend/app/listings/[id]/edit/page.tsx
+++ b/frontend/app/listings/[id]/edit/page.tsx
@@ -56,7 +56,8 @@ export default function EditListingPage() {
const categories = [
"SCHOOL",
"CLOTHES",
- "HOUSING",
+ "DORM",
+ "SUBLEASE",
"LEISURE",
"ACCESSORIES",
"OTHER",
diff --git a/frontend/app/listings/[id]/page.tsx b/frontend/app/listings/[id]/page.tsx
index 5116b1f..c26e1ca 100644
--- a/frontend/app/listings/[id]/page.tsx
+++ b/frontend/app/listings/[id]/page.tsx
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
-import { useAuth } from "@clerk/nextjs";
+import { useAuth, SignUpButton } from "@clerk/nextjs";
import { useParams, useRouter } from "next/navigation";
import { motion } from "framer-motion";
import axios from "axios";
@@ -78,7 +78,8 @@ const getImageUrl = (url?: string) => {
};
const CATEGORY_LABELS: Record = {
- HOUSING: "Dorm",
+ DORM: "Dorm",
+ SUBLEASE: "Sublease",
CLOTHES: "Clothes",
SCHOOL: "School",
LEISURE: "Leisure",
@@ -110,46 +111,43 @@ export default function ListingDetailPage() {
useEffect(() => {
if (!isLoaded) return;
- if (!isSignedIn) {
- router.push("/");
- return;
- }
-
const fetchListingAndView = async () => {
try {
- const token = await getToken();
+ let headers: any = {};
+ let token = null;
+ if (isSignedIn) {
+ token = await getToken();
+ headers = { Authorization: `Bearer ${token}` };
+ }
+
const res = await axios.get(
`http://127.0.0.1:3000/listings/${params.id}`,
- {
- headers: { Authorization: `Bearer ${token}` },
- },
+ { headers },
);
const data = res.data;
setListing(data);
- // Record View
- axios
- .post(
- `http://127.0.0.1:3000/listings/${params.id}/view`,
- {},
- {
- headers: { Authorization: `Bearer ${token}` },
- },
- )
- .catch((e) => console.error("Failed to record view", e));
-
- // Check if wishlisted
- const wishlistRes = await axios.get(
- `http://127.0.0.1:3000/listings/wishlist`,
- {
- headers: { Authorization: `Bearer ${token}` },
- },
- );
- const isLiked = wishlistRes.data.some(
- (item: any) => item.id === params.id,
- );
- setIsWishlisted(isLiked);
+ if (isSignedIn) {
+ // Record View
+ axios
+ .post(
+ `http://127.0.0.1:3000/listings/${params.id}/view`,
+ {},
+ { headers },
+ )
+ .catch((e) => console.error("Failed to record view", e));
+
+ // Check if wishlisted
+ const wishlistRes = await axios.get(
+ `http://127.0.0.1:3000/listings/wishlist`,
+ { headers },
+ );
+ const isLiked = wishlistRes.data.some(
+ (item: any) => item.id === params.id,
+ );
+ setIsWishlisted(isLiked);
+ }
} catch (err: any) {
setError(err.message || "Failed to fetch listing");
} finally {
@@ -634,6 +632,22 @@ export default function ListingDetailPage() {
Edit Listing
+ ) : !isSignedIn ? (
+
+
+
+ .edu Verification Required
+
+
+ Sign in with your university email to reserve this item,
+ message the seller, or save it to your wishlist.
+
+
+
+ Verify .edu Email
+
+
+
) : (
<>
{
};
const CATEGORIES = [
- { id: "HOUSING", label: "DORM" },
+ { id: "DORM", label: "DORM" },
+ { id: "SUBLEASE", label: "SUBLEASE" },
{ id: "CLOTHES", label: "CLOTHES" },
{ id: "SCHOOL", label: "SCHOOL" },
{ id: "LEISURE", label: "LEISURE" },
@@ -57,47 +58,55 @@ const CATEGORIES = [
const getCategoryHeroInfo = (category: string | null) => {
switch (category) {
- case "HOUSING":
- return {
- image: "/dorm.avif",
+ case "DORM":
+ return {
+ image: "/dorm.avif",
objectPosition: "object-[50%_18%]",
- title: "Housing & Furniture",
- description: "Move in and settle with all the essentials."
+ title: "Dorm Essentials",
+ description: "Move in and settle with all the essentials.",
+ };
+ case "SUBLEASE":
+ return {
+ image: "/sublease.jpg",
+ objectPosition: "object-center",
+ title: "Subleases",
+ description: "Find a spot or sublet yours.",
};
case "CLOTHES":
- return {
- image: "/clothes.webp",
+ return {
+ image: "/clothes.webp",
objectPosition: "object-center",
title: "Clothing & Apparel",
- description: "Refresh your wardrobe with great finds on campus."
+ description: "Refresh your wardrobe with great finds on campus.",
};
case "SCHOOL":
- return {
- image: "/book.avif",
+ return {
+ image: "/book.avif",
objectPosition: "object-[50%_35%]",
title: "School Supplies",
- description: "Everything you need for your classes, for less."
+ description: "Everything you need for your classes, for less.",
};
case "LEISURE":
- return {
- image: "/kayak.jpg",
+ return {
+ image: "/kayak.jpg",
objectPosition: "object-center",
title: "Leisure & Hobbies",
- description: "Find gear and tickets for your weekend adventures."
+ description: "Find gear and tickets for your weekend adventures.",
};
case "ACCESSORIES":
- return {
- image: "/dj.jpg",
+ return {
+ image: "/dj.jpg",
objectPosition: "object-center",
title: "Accessories",
- description: "Complete your look with the perfect accessories."
+ description: "Complete your look with the perfect accessories.",
};
default:
- return {
- image: "/dj.jpg",
+ return {
+ image: "/dj.jpg",
objectPosition: "object-center",
title: "Trying to pass down your items?",
- description: "Sell your items quickly and safely to other students on campus."
+ description:
+ "Sell your items quickly and safely to other students on campus.",
};
}
};
@@ -118,16 +127,22 @@ export default function ListingsGridPage() {
useEffect(() => {
const fetchListings = async () => {
- if (!isLoaded || !isSignedIn) return;
+ if (!isLoaded) return;
setIsLoading(true);
try {
- const token = await getToken();
-
- // Fetch user profile to get university
- try {
- const userRes = await axios.get("http://127.0.0.1:3000/users/me", { headers: { Authorization: `Bearer ${token}` } });
- setUserProfile(userRes.data);
- } catch (e) {}
+ let headers: any = {};
+ if (isSignedIn) {
+ const token = await getToken();
+ headers = { Authorization: `Bearer ${token}` };
+
+ // Fetch user profile to get university
+ try {
+ const userRes = await axios.get("http://127.0.0.1:3000/users/me", {
+ headers,
+ });
+ setUserProfile(userRes.data);
+ } catch (e) {}
+ }
const params = new URLSearchParams();
if (activeCategory !== "ALL") {
@@ -142,9 +157,7 @@ export default function ListingsGridPage() {
}
const url = `${baseUrl}${params.toString() ? `?${params.toString()}` : ""}`;
- const res = await axios.get(url, {
- headers: { Authorization: `Bearer ${token}` },
- });
+ const res = await axios.get(url, { headers });
setListings(res.data);
} catch (err) {
@@ -192,7 +205,7 @@ export default function ListingsGridPage() {
Sell now
-
+
{/* Right Side (Image) */}
-
{/* Sidebar Filters */}
-
Distance
+
+ Distance
+
- ✓
+
+ ✓
+
All
- @ {userProfile?.university || 'your university'}
+
+ @ {userProfile?.university || "your university"}
+
-
Prices
+
+ Prices
+
- {['All', '$0 to $50', '$50 to $100', '$100 to $300', '$300+'].map((price, idx) => (
-
-
- {idx === 0 && '✓'}
-
- {price}
-
- ))}
+ {["All", "$0 to $50", "$50 to $100", "$100 to $300", "$300+"].map(
+ (price, idx) => (
+
+
+ {idx === 0 && "✓"}
+
+
+ {price}
+
+
+ ),
+ )}
-
Condition
+
+ Condition
+
- {['Any', 'New', 'Like New', 'Good', 'Fair'].map((cond, idx) => (
-
-
- {idx === 0 && '✓'}
+ {["Any", "New", "Like New", "Good", "Fair"].map((cond, idx) => (
+
+
+ {idx === 0 && "✓"}
- {cond}
+
+ {cond}
+
))}
-
Buying Options
+
+ Buying Options
+
- {['Any', 'Meetup on campus', 'Self pickup'].map((opt, idx) => (
-
-
- {idx === 0 && '✓'}
+ {["Any", "Meetup on campus", "Self pickup"].map((opt, idx) => (
+
+
+ {idx === 0 && "✓"}
- {opt}
+
+ {opt}
+
))}
@@ -276,8 +329,12 @@ export default function ListingsGridPage() {
Search Results
- ( {displayedListings.length} of {listings.length} listings ) matching "
- {searchQuery} "
+ ( {displayedListings.length} of {listings.length} listings )
+ matching "
+
+ {searchQuery}
+
+ "
@@ -293,7 +350,8 @@ export default function ListingsGridPage() {
- {CATEGORIES.find((c) => c.id === activeCategory)?.label || "ALL PRODUCTS"}
+ {CATEGORIES.find((c) => c.id === activeCategory)?.label ||
+ "ALL PRODUCTS"}
( {displayedListings.length} of {listings.length} listings )
@@ -313,34 +371,39 @@ export default function ListingsGridPage() {
))}
-
+
{/* Pagination */}
{totalPages > 1 && (
- {
e.preventDefault();
- if (currentPage > 1) setCurrentPage(p => p - 1);
+ if (currentPage > 1) setCurrentPage((p) => p - 1);
}}
- className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
+ className={
+ currentPage === 1
+ ? "pointer-events-none opacity-50"
+ : ""
+ }
/>
-
+
{[...Array(totalPages)].map((_, i) => {
const pageNum = i + 1;
if (
- pageNum === 1 ||
- pageNum === totalPages ||
- (pageNum >= currentPage - 1 && pageNum <= currentPage + 1)
+ pageNum === 1 ||
+ pageNum === totalPages ||
+ (pageNum >= currentPage - 1 &&
+ pageNum <= currentPage + 1)
) {
return (
- {
e.preventDefault();
@@ -352,22 +415,34 @@ export default function ListingsGridPage() {
);
}
-
- if (pageNum === currentPage - 2 || pageNum === currentPage + 2) {
- return ;
+
+ if (
+ pageNum === currentPage - 2 ||
+ pageNum === currentPage + 2
+ ) {
+ return (
+
+
+
+ );
}
-
+
return null;
})}
-
+
- {
e.preventDefault();
- if (currentPage < totalPages) setCurrentPage(p => p + 1);
+ if (currentPage < totalPages)
+ setCurrentPage((p) => p + 1);
}}
- className={currentPage === totalPages ? "pointer-events-none opacity-50" : ""}
+ className={
+ currentPage === totalPages
+ ? "pointer-events-none opacity-50"
+ : ""
+ }
/>
@@ -412,9 +487,9 @@ function ListingCard({ listing }: { listing: Listing }) {
) : (
)}
-
+
{/* Transparent Heart Button */}
- {
e.preventDefault();
@@ -425,7 +500,7 @@ function ListingCard({ listing }: { listing: Listing }) {
-
+
${listing.price.toFixed(2)}
@@ -433,7 +508,7 @@ function ListingCard({ listing }: { listing: Listing }) {
{listing.title}
-
+
{listing.seller?.university && (
diff --git a/frontend/app/offers/page.tsx b/frontend/app/offers/page.tsx
index 083f019..db02280 100644
--- a/frontend/app/offers/page.tsx
+++ b/frontend/app/offers/page.tsx
@@ -15,18 +15,32 @@ export default function OffersPage() {
const [activeTab, setActiveTab] = useState("my_offers");
const [activeFilter, setActiveFilter] = useState("All");
const [loading, setLoading] = useState(true);
- const [offers, setOffers] = useState({ my_offers: [], received_offers: [], meetups: [] });
+ const [offers, setOffers] = useState({
+ my_offers: [],
+ received_offers: [],
+ meetups: [],
+ });
- const filters = ["All", "Pending", "Accepted", "Declined", "Expired", "Cancelled"];
+ const filters = [
+ "All",
+ "Pending",
+ "Accepted",
+ "Declined",
+ "Expired",
+ "Cancelled",
+ ];
useEffect(() => {
const fetchOffers = async () => {
try {
const token = await getToken();
if (!token) return;
- const res = await axios.get(`http://127.0.0.1:3000/transactions/offers`, {
- headers: { Authorization: `Bearer ${token}` }
- });
+ const res = await axios.get(
+ `http://127.0.0.1:3000/transactions/offers`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
setOffers(res.data);
} catch (error) {
console.error("Failed to fetch offers", error);
@@ -43,13 +57,16 @@ export default function OffersPage() {
if (type === "my_offers") {
title = "No offers sent yet.";
- description = "Ready to discover great finds on campus? Browse listings to get started!";
+ description =
+ "Ready to discover great finds on campus? Browse listings to get started!";
} else if (type === "received_offers") {
title = "No offers received yet.";
- description = "Your items are waiting to be discovered! Offers from other students will appear here.";
+ description =
+ "Your items are waiting to be discovered! Offers from other students will appear here.";
} else if (type === "meetups") {
title = "No active meetups.";
- description = "Once an offer is accepted and secured, your meetups will be tracked here.";
+ description =
+ "Once an offer is accepted and secured, your meetups will be tracked here.";
}
return (
@@ -66,13 +83,26 @@ export default function OffersPage() {
};
const renderTransactions = (transactions: any[], type: string) => {
- const filtered = transactions.filter(t => {
+ const filtered = transactions.filter((t) => {
if (activeFilter === "All") return true;
- if (activeFilter === "Pending" && (t.orderStatus === "PENDING_PAYMENT" || t.orderStatus === "PENDING_MEETUP")) return true;
- if (activeFilter === "Accepted" && (t.orderStatus === "PAID_PENDING_MEETUP" || t.orderStatus === "MEETING_STARTED")) return true;
- if (activeFilter === "Declined" && t.orderStatus === "DECLINED") return true;
- if (activeFilter === "Expired" && t.orderStatus === "EXPIRED") return true;
- if (activeFilter === "Cancelled" && t.orderStatus === "CANCELLED") return true;
+ if (
+ activeFilter === "Pending" &&
+ (t.orderStatus === "PENDING_PAYMENT" ||
+ t.orderStatus === "PENDING_MEETUP")
+ )
+ return true;
+ if (
+ activeFilter === "Accepted" &&
+ (t.orderStatus === "PAID_PENDING_MEETUP" ||
+ t.orderStatus === "MEETING_STARTED")
+ )
+ return true;
+ if (activeFilter === "Declined" && t.orderStatus === "DECLINED")
+ return true;
+ if (activeFilter === "Expired" && t.orderStatus === "EXPIRED")
+ return true;
+ if (activeFilter === "Cancelled" && t.orderStatus === "CANCELLED")
+ return true;
return false;
});
@@ -81,19 +111,33 @@ export default function OffersPage() {
return (
{filtered.map((tx) => (
-
+
{tx.listing.images?.[0]?.url && (
-
+
)}
-
{tx.listing.title}
- ${(tx.amount / 100).toFixed(2)}
+
+ {tx.listing.title}
+
+
+ ${(tx.amount / 100).toFixed(2)}
+
- Status: {tx.orderStatus.replace(/_/g, ' ')}
+ Status:{" "}
+
+ {tx.orderStatus.replace(/_/g, " ")}
+
@@ -112,34 +156,40 @@ export default function OffersPage() {
>
{/* Header */}
{/* Tabs */}
-
+
-
My Offers ({offers.my_offers.length})
-
Offers Received ({offers.received_offers.length})
-
@@ -149,7 +199,9 @@ export default function OffersPage() {
{/* Filters */}
-
Filter:
+
+ Filter:
+
{filters.map((filter) => (
{renderTransactions(offers.my_offers, "my_offers")}
-
-
- {renderTransactions(offers.received_offers, "received_offers")}
+
+
+ {renderTransactions(
+ offers.received_offers,
+ "received_offers",
+ )}
-
+
{renderTransactions(offers.meetups, "meetups")}
diff --git a/frontend/app/onboarding/page.tsx b/frontend/app/onboarding/page.tsx
index ed099ae..2e5ba90 100644
--- a/frontend/app/onboarding/page.tsx
+++ b/frontend/app/onboarding/page.tsx
@@ -1,16 +1,32 @@
-'use client';
+"use client";
-import { useState, useRef, useEffect } from 'react';
-import { useAuth, useUser } from '@clerk/nextjs';
-import { useRouter } from 'next/navigation';
-import { motion } from 'framer-motion';
-import { Loader2, Camera, User, BookOpen, Calendar, AlignLeft, Sparkles, AlertCircle, GraduationCap } from 'lucide-react';
-import axios from 'axios';
-import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
-import { Button } from '@/components/ui/button';
-import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { useState, useRef, useEffect } from "react";
+import { useAuth, useUser } from "@clerk/nextjs";
+import { useRouter } from "next/navigation";
+import { motion } from "framer-motion";
+import {
+ Loader2,
+ Camera,
+ User,
+ BookOpen,
+ Calendar,
+ AlignLeft,
+ Sparkles,
+ AlertCircle,
+ GraduationCap,
+} from "lucide-react";
+import axios from "axios";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Button } from "@/components/ui/button";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
export default function OnboardingPage() {
const { getToken } = useAuth();
@@ -19,38 +35,51 @@ export default function OnboardingPage() {
const fileInputRef = useRef(null);
const [isLoading, setIsLoading] = useState(false);
- const [error, setError] = useState('');
+ const [error, setError] = useState("");
const [avatarFile, setAvatarFile] = useState(null);
const [avatarPreview, setAvatarPreview] = useState(null);
const [formData, setFormData] = useState({
- name: '',
- username: '',
- major: '',
- classYear: '',
- bio: '',
- university: '',
+ name: "",
+ username: "",
+ major: "",
+ classYear: "",
+ bio: "",
+ university: "",
});
const [isUniversityLocked, setIsUniversityLocked] = useState(false);
- const years = ['Freshman', 'Sophomore', 'Junior', 'Senior', 'Graduate', 'Faculty/Staff'];
+ const years = [
+ "Freshman",
+ "Sophomore",
+ "Junior",
+ "Senior",
+ "Graduate",
+ "Faculty/Staff",
+ ];
useEffect(() => {
if (user?.primaryEmailAddress?.emailAddress) {
const email = user.primaryEmailAddress.emailAddress;
- if (email.endsWith('@uic.edu')) {
- setFormData((prev) => ({ ...prev, university: 'University of Illinois Chicago' }));
+ if (email.endsWith("@uic.edu")) {
+ setFormData((prev) => ({
+ ...prev,
+ university: "University of Illinois Chicago",
+ }));
setIsUniversityLocked(true);
- } else if (email.endsWith('@illinois.edu')) {
- setFormData((prev) => ({ ...prev, university: 'University of Illinois Urbana-Champaign' }));
+ } else if (email.endsWith("@illinois.edu")) {
+ setFormData((prev) => ({
+ ...prev,
+ university: "University of Illinois Urbana-Champaign",
+ }));
setIsUniversityLocked(true);
- } else if (email.endsWith('@depaul.edu')) {
- setFormData((prev) => ({ ...prev, university: 'DePaul University' }));
+ } else if (email.endsWith("@depaul.edu")) {
+ setFormData((prev) => ({ ...prev, university: "DePaul University" }));
setIsUniversityLocked(true);
- } else if (email.endsWith('.edu')) {
- const domain = email.split('@')[1];
+ } else if (email.endsWith(".edu")) {
+ const domain = email.split("@")[1];
setFormData((prev) => ({ ...prev, university: domain }));
setIsUniversityLocked(false);
}
@@ -65,14 +94,16 @@ export default function OnboardingPage() {
}
};
- const handleChange = (e: React.ChangeEvent) => {
+ const handleChange = (
+ e: React.ChangeEvent,
+ ) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
- setError('');
+ setError("");
try {
const token = await getToken();
@@ -84,20 +115,25 @@ export default function OnboardingPage() {
avatarUrl = updatedUser.publicUrl;
}
- await axios.patch('http://127.0.0.1:3000/users/me', {
- ...formData,
- ...(avatarUrl && { avatarUrl }),
- onboardingComplete: true
- }, {
- headers: {
- Authorization: `Bearer ${token}`,
+ await axios.patch(
+ "http://127.0.0.1:3000/users/me",
+ {
+ ...formData,
+ ...(avatarUrl && { avatarUrl }),
+ onboardingComplete: true,
},
- });
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ },
+ );
// Redirect to marketplace home
- window.location.href = '/home';
+ window.location.href = "/home";
} catch (err: any) {
- const backendMessage = err.response?.data?.message || err.message || 'Failed to save profile.';
+ const backendMessage =
+ err.response?.data?.message || err.message || "Failed to save profile.";
setError(backendMessage);
} finally {
setIsLoading(false);
@@ -106,7 +142,6 @@ export default function OnboardingPage() {
return (
-
{/* Background Orbs */}
@@ -132,7 +167,6 @@ export default function OnboardingPage() {
{/* Form Card */}
-
{error && (
@@ -142,7 +176,6 @@ export default function OnboardingPage() {
)}
-
{/* Avatar Upload */}
fileInputRef.current?.click()}
>
{avatarPreview ? (
-
+
) : (
@@ -167,7 +204,10 @@ export default function OnboardingPage() {
accept="image/*"
className="hidden"
/>
-
fileInputRef.current?.click()}>
+
fileInputRef.current?.click()}
+ >
Upload Profile Picture
@@ -246,7 +286,9 @@ export default function OnboardingPage() {
setFormData({ ...formData, classYear: val || '' })}
+ onValueChange={(val) =>
+ setFormData({ ...formData, classYear: val || "" })
+ }
>
@@ -282,7 +324,9 @@ export default function OnboardingPage() {
{isLoading ? (
@@ -291,11 +335,10 @@ export default function OnboardingPage() {
Saving Profile...
>
) : (
- 'Complete Profile'
+ "Complete Profile"
)}
-
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index fc9ddb2..0331b27 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -82,9 +82,9 @@ export default function Home() {
What is Orbit?
- Orbit is the student exclusive marketplace to buy,
- sell, and swap anything. Stop making big companies richer, make
- your friends rich instead, all you need to do is to Orbit it.
+ Orbit is the student exclusive marketplace to buy, sell, and swap
+ anything. Stop making big companies richer, make your friends rich
+ instead, all you need to do is to Orbit it.
{/* Features (Aligned Vertically) */}
@@ -109,7 +109,8 @@ export default function Home() {
Meetup Codes
- Funds are held securely until you exchange the code in person.
+ Funds are held securely until you exchange the code in
+ person.
@@ -118,9 +119,7 @@ export default function Home() {
-
- Campus Local
-
+
Campus Local
No shipping fees. Pick up your items the same day on campus.
@@ -131,7 +130,9 @@ export default function Home() {
-
Swipe to Match
+
+ Swipe to Match
+
Tinder-style swiping makes discovering new listings fun.
@@ -142,9 +143,12 @@ export default function Home() {
-
Secure Payment
+
+ Secure Payment
+
- Orbit Escrow ensures safe transactions & protects your money.
+ Orbit Escrow ensures safe transactions & protects your
+ money.
@@ -153,7 +157,9 @@ export default function Home() {
-
Real-Time Chat
+
+ Real-Time Chat
+
No sketchy DMs. Keep communication safely inside the app.
@@ -223,11 +229,15 @@ export default function Home() {
See what's popping around your uni community
-
-
-
+
+
@@ -235,25 +245,34 @@ export default function Home() {
Dorm, Clothing, School, and more
-
-
-
-
+
+
+
Services
- CS prep, all kinds of tutoring, resumes, and career larping, we’ve got your back
+ CS prep, all kinds of tutoring, resumes, and career larping,
+ we’ve got your back
-
-
-
-
+
+
+
@@ -261,7 +280,7 @@ export default function Home() {
Need a room or a sublet? Someone’s got a spot
-
+
@@ -271,11 +290,12 @@ export default function Home() {
Shop by Category
-
-
-
+
+
@@ -283,21 +303,25 @@ export default function Home() {
-
-
+
-
School Supplies
+
+ School Supplies
+
-
-
+
@@ -305,10 +329,11 @@ export default function Home() {
-
-
+
@@ -316,21 +341,25 @@ export default function Home() {
-
-
+
-
Event Tickets
+
+ Event Tickets
+
-
-
+
@@ -347,9 +376,10 @@ export default function Home() {
-
@@ -364,9 +394,10 @@ export default function Home() {
Coming Soon
-
diff --git a/frontend/app/profile/[id]/page.tsx b/frontend/app/profile/[id]/page.tsx
index f4fb07e..3ad6d2d 100644
--- a/frontend/app/profile/[id]/page.tsx
+++ b/frontend/app/profile/[id]/page.tsx
@@ -1,500 +1,780 @@
-'use client';
+"use client";
-import { useState, useEffect, useRef } from 'react';
-import { useAuth, useUser } from '@clerk/nextjs';
-import { useParams, useRouter } from 'next/navigation';
-import { motion } from 'framer-motion';
-import axios from 'axios';
-import { Loader2, MapPin, MessageSquare, Shield, Tag, Calendar, GraduationCap, Heart, AlertTriangle, BookOpen, Star, Pencil, Camera, Trash2, DollarSign, CheckCircle2 } from 'lucide-react';
-import Link from 'next/link';
-import { Button } from '@/components/ui/button';
-import { Separator } from '@/components/ui/separator';
-import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { useState, useEffect, useRef } from "react";
+import { useAuth, useUser } from "@clerk/nextjs";
+import { useParams, useRouter } from "next/navigation";
+import { motion } from "framer-motion";
+import axios from "axios";
+import {
+ Loader2,
+ MapPin,
+ MessageSquare,
+ Shield,
+ Tag,
+ Calendar,
+ GraduationCap,
+ Heart,
+ AlertTriangle,
+ BookOpen,
+ Star,
+ Pencil,
+ Camera,
+ Trash2,
+ DollarSign,
+ CheckCircle2,
+} from "lucide-react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
const getImageUrl = (url?: string) => {
- if (!url) return "";
- if (url.startsWith("http")) return url;
- return `http://127.0.0.1:3000${url}`;
+ if (!url) return "";
+ if (url.startsWith("http")) return url;
+ return `http://127.0.0.1:3000${url}`;
};
export default function ProfilePage() {
- const params = useParams();
- const router = useRouter();
- const userId = params.id as string;
- const { getToken, isLoaded, isSignedIn, userId: currentUserId } = useAuth();
- const { user: clerkUser } = useUser();
- const [userProfile, setUserProfile] = useState
(null);
- const [isLoading, setIsLoading] = useState(true);
- const [isEditing, setIsEditing] = useState(false);
- const [isSaving, setIsSaving] = useState(false);
- const [avatarFile, setAvatarFile] = useState(null);
- const [avatarPreview, setAvatarPreview] = useState(null);
- const fileInputRef = useRef(null);
- const [formData, setFormData] = useState({
- name: '',
- username: '',
- bio: '',
- major: '',
- classYear: '',
- university: '',
- });
+ const params = useParams();
+ const router = useRouter();
+ const userId = params.id as string;
+ const { getToken, isLoaded, isSignedIn, userId: currentUserId } = useAuth();
+ const { user: clerkUser } = useUser();
+ const [userProfile, setUserProfile] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isEditing, setIsEditing] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const [avatarFile, setAvatarFile] = useState(null);
+ const [avatarPreview, setAvatarPreview] = useState(null);
+ const fileInputRef = useRef(null);
+ const [formData, setFormData] = useState({
+ name: "",
+ username: "",
+ bio: "",
+ major: "",
+ classYear: "",
+ university: "",
+ });
- const [isStripeLinked, setIsStripeLinked] = useState(false);
- const [isLoadingStripe, setIsLoadingStripe] = useState(false);
+ const [isStripeLinked, setIsStripeLinked] = useState(false);
+ const [isLoadingStripe, setIsLoadingStripe] = useState(false);
- const years = ['Freshman', 'Sophomore', 'Junior', 'Senior', 'Graduate', 'Faculty/Staff'];
+ const [isFollowing, setIsFollowing] = useState(false);
+ const [isFollowLoading, setIsFollowLoading] = useState(false);
+ const [followersCount, setFollowersCount] = useState(0);
+ const [followingCount, setFollowingCount] = useState(0);
- useEffect(() => {
- const fetchProfile = async () => {
- if (!isLoaded || !isSignedIn) return;
- try {
- const token = await getToken();
- const res = await axios.get(`http://127.0.0.1:3000/users/${userId}`, {
- headers: { Authorization: `Bearer ${token}` }
- });
- setUserProfile(res.data);
- setFormData({
- name: res.data.name || '',
- username: res.data.username || '',
- bio: res.data.bio || '',
- major: res.data.major || '',
- classYear: res.data.classYear || '',
- university: res.data.university || '',
- });
+ const years = [
+ "Freshman",
+ "Sophomore",
+ "Junior",
+ "Senior",
+ "Graduate",
+ "Faculty/Staff",
+ ];
- // Fetch Stripe status if own profile
- if (currentUserId === res.data.clerkUserId) {
- try {
- const stripeRes = await axios.get(`http://127.0.0.1:3000/payments/connect/status`, {
- headers: { Authorization: `Bearer ${token}` }
- });
- setIsStripeLinked(stripeRes.data.linked);
- } catch (e) {
- console.error("Failed to fetch Stripe status", e);
- }
- }
- } catch (err) {
- console.error('Failed to fetch profile', err);
- } finally {
- setIsLoading(false);
- }
- };
- fetchProfile();
- }, [userId, isLoaded, isSignedIn, getToken, currentUserId]);
+ useEffect(() => {
+ const fetchProfile = async () => {
+ if (!isLoaded || !isSignedIn) return;
+ try {
+ const token = await getToken();
+ const res = await axios.get(`http://127.0.0.1:3000/users/${userId}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ setUserProfile(res.data);
+ setFormData({
+ name: res.data.name || "",
+ username: res.data.username || "",
+ bio: res.data.bio || "",
+ major: res.data.major || "",
+ classYear: res.data.classYear || "",
+ university: res.data.university || "",
+ });
+ setIsFollowing(res.data.isFollowing || false);
+ setFollowersCount(res.data._count?.followers || 0);
+ setFollowingCount(res.data._count?.following || 0);
- const handleConnectStripe = async () => {
- setIsLoadingStripe(true);
- try {
- const token = await getToken();
- const res = await axios.post(`http://127.0.0.1:3000/payments/connect`, {}, {
- headers: { Authorization: `Bearer ${token}` }
- });
- if (res.data.url) {
- window.location.href = res.data.url;
- }
- } catch (err) {
- console.error(err);
- setIsLoadingStripe(false);
- }
- };
+ // Fetch Stripe status if own profile
+ if (currentUserId === res.data.clerkUserId) {
+ try {
+ const stripeRes = await axios.get(
+ `http://127.0.0.1:3000/payments/connect/status`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ setIsStripeLinked(stripeRes.data.linked);
+ } catch (e) {
+ console.error("Failed to fetch Stripe status", e);
+ }
+ }
+ } catch (err) {
+ console.error("Failed to fetch profile", err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchProfile();
+ }, [userId, isLoaded, isSignedIn, getToken, currentUserId]);
- const handleAvatarChange = (e: React.ChangeEvent) => {
- if (e.target.files && e.target.files[0]) {
- const file = e.target.files[0];
- setAvatarFile(file);
- setAvatarPreview(URL.createObjectURL(file));
- }
- };
+ const handleConnectStripe = async () => {
+ setIsLoadingStripe(true);
+ try {
+ const token = await getToken();
+ const res = await axios.post(
+ `http://127.0.0.1:3000/payments/connect`,
+ {},
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ if (res.data.url) {
+ window.location.href = res.data.url;
+ }
+ } catch (err) {
+ console.error(err);
+ setIsLoadingStripe(false);
+ }
+ };
- const handleChange = (e: React.ChangeEvent) => {
- setFormData({ ...formData, [e.target.name]: e.target.value });
- };
+ const handleFollow = async () => {
+ if (!isSignedIn) return;
+ setIsFollowLoading(true);
+ try {
+ const token = await getToken();
+ const res = await axios.post(
+ `http://127.0.0.1:3000/users/${userId}/follow`,
+ {},
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ setIsFollowing(res.data.following);
+ setFollowersCount((prev) => (res.data.following ? prev + 1 : prev - 1));
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setIsFollowLoading(false);
+ }
+ };
- const handleSaveProfile = async () => {
- setIsSaving(true);
- try {
- const token = await getToken();
- let avatarUrl = userProfile.avatarUrl;
- if (avatarFile && clerkUser) {
- const updatedUser = await clerkUser.setProfileImage({ file: avatarFile });
- avatarUrl = updatedUser.publicUrl;
- }
+ const handleAvatarChange = (e: React.ChangeEvent) => {
+ if (e.target.files && e.target.files[0]) {
+ const file = e.target.files[0];
+ setAvatarFile(file);
+ setAvatarPreview(URL.createObjectURL(file));
+ }
+ };
- await axios.patch('http://127.0.0.1:3000/users/me', {
- ...formData,
- avatarUrl
- }, {
- headers: { Authorization: `Bearer ${token}` }
- });
+ const handleChange = (
+ e: React.ChangeEvent,
+ ) => {
+ setFormData({ ...formData, [e.target.name]: e.target.value });
+ };
- setUserProfile({ ...userProfile, ...formData, avatarUrl });
- setIsEditing(false);
- } catch (err) {
- console.error('Failed to save profile', err);
- alert('Failed to save profile.');
- } finally {
- setIsSaving(false);
- }
- };
+ const handleSaveProfile = async () => {
+ setIsSaving(true);
+ try {
+ const token = await getToken();
+ let avatarUrl = userProfile.avatarUrl;
+ if (avatarFile && clerkUser) {
+ const updatedUser = await clerkUser.setProfileImage({
+ file: avatarFile,
+ });
+ avatarUrl = updatedUser.publicUrl;
+ }
- const handleDeleteListing = async (listingId: string) => {
- if (!confirm('Are you sure you want to delete this listing?')) return;
- try {
- const token = await getToken();
- await axios.delete(`http://127.0.0.1:3000/listings/${listingId}`, {
- headers: { Authorization: `Bearer ${token}` }
- });
- setUserProfile((prev: any) => ({
- ...prev,
- listings: prev.listings.filter((l: any) => l.id !== listingId)
- }));
- } catch (err) {
- console.error('Failed to delete listing', err);
- alert('Failed to delete listing.');
- }
- };
+ await axios.patch(
+ "http://127.0.0.1:3000/users/me",
+ {
+ ...formData,
+ avatarUrl,
+ },
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
- if (!isLoaded || isLoading) {
- return (
-
-
-
- );
- }
+ setUserProfile({ ...userProfile, ...formData, avatarUrl });
+ setIsEditing(false);
+ } catch (err) {
+ console.error("Failed to save profile", err);
+ alert("Failed to save profile.");
+ } finally {
+ setIsSaving(false);
+ }
+ };
- if (!userProfile) {
- return (
-
-
-
Profile not found
-
-
- Back to Marketplace
-
-
-
- );
- }
+ const handleDeleteListing = async (listingId: string) => {
+ if (!confirm("Are you sure you want to delete this listing?")) return;
+ try {
+ const token = await getToken();
+ await axios.delete(`http://127.0.0.1:3000/listings/${listingId}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ setUserProfile((prev: any) => ({
+ ...prev,
+ listings: prev.listings.filter((l: any) => l.id !== listingId),
+ }));
+ } catch (err) {
+ console.error("Failed to delete listing", err);
+ alert("Failed to delete listing.");
+ }
+ };
- const joinDate = new Date(userProfile.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
- const listingsCount = userProfile._count?.listings || 0;
+ if (!isLoaded || isLoading) {
+ return (
+
+
+
+ );
+ }
- return (
-
-
- {/* Back Link */}
-
-
- Back to Marketplace
-
+ if (!userProfile) {
+ return (
+
+
+
Profile not found
+
+
+ Back to Marketplace
+
+
+
+ );
+ }
-
- {/* Profile Header & Stats */}
-
- {/* Top Header Card */}
-
- {/* Avatar */}
- isEditing && fileInputRef.current?.click()}
- >
- {(avatarPreview || userProfile.avatarUrl) ? (
-
- ) : (
-
- {(formData.name || 'U').charAt(0).toUpperCase()}
-
- )}
- {isEditing && (
-
-
-
- )}
-
-
+ const joinDate = new Date(userProfile.createdAt).toLocaleDateString("en-US", {
+ month: "long",
+ year: "numeric",
+ });
+ const listingsCount = userProfile._count?.listings || 0;
- {/* Profile Details */}
-
- {isEditing ? (
-
- ) : (
- <>
-
-
-
-
{userProfile.name || 'Anonymous User'}
-
-
-
-
-
@{userProfile.username || userProfile.id.slice(0,8)}
+ return (
+
+
+ {/* Back Link */}
+
+
+
+
+ Back to Marketplace
+
-
- {userProfile.major && (
-
-
- {userProfile.major}
-
- )}
- {userProfile.university && (
-
-
- {userProfile.university}
-
- )}
- {userProfile.classYear && (
-
-
- {userProfile.classYear}
-
- )}
-
-
- Joined {joinDate}
-
-
+
+ {/* Profile Header & Stats */}
+
+ {/* Top Header Card */}
+
+ {/* Avatar */}
+ isEditing && fileInputRef.current?.click()}
+ >
+ {avatarPreview || userProfile.avatarUrl ? (
+
+ ) : (
+
+ {(formData.name || "U").charAt(0).toUpperCase()}
+
+ )}
+ {isEditing && (
+
+
+
+ )}
+
+
-
- {userProfile.bio ? (
-
{userProfile.bio}
- ) : (
-
This student hasn't written a bio yet.
- )}
-
-
+ {/* Profile Details */}
+
+ {isEditing ? (
+
+ ) : (
+ <>
+
+
+
+
+ {userProfile.name || "Anonymous User"}
+
+
+
+
+
+
+ @{userProfile.username || userProfile.id.slice(0, 8)}
+
- {/* Action Buttons Desktop */}
-
- {currentUserId === userProfile.clerkUserId ? (
-
setIsEditing(true)}
- >
- Edit Profile
-
- ) : (
- <>
-
-
- Message
-
-
-
- Report
-
- >
- )}
-
-
- >
- )}
+
+ {userProfile.major && (
+
+
+ {userProfile.major}
+
+ )}
+ {userProfile.university && (
+
+
+ {userProfile.university}
+
+ )}
+ {userProfile.classYear && (
+
+
+ {userProfile.classYear}
+
+ )}
+
+
+ Joined {joinDate}
+
+
- {/* Edit Mode Save Button */}
- {isEditing && currentUserId === userProfile.clerkUserId && (
-
- setIsEditing(false)}
- disabled={isSaving}
- >
- Cancel
-
-
- {isSaving ? : "Save Changes"}
-
-
- )}
+
+ {userProfile.bio ? (
+
{userProfile.bio}
+ ) : (
+
+ This student hasn't written a bio yet.
+
+ )}
+
+
- {/* Mobile Action Buttons */}
- {!isEditing && (
-
- {currentUserId === userProfile.clerkUserId ? (
-
setIsEditing(true)}
- >
- Edit Profile
-
- ) : (
- <>
-
-
- Message Seller
-
-
-
- Report
-
- >
- )}
-
- )}
-
-
+ {/* Action Buttons Desktop */}
+
+ {currentUserId === userProfile.clerkUserId ? (
+
setIsEditing(true)}
+ >
+ Edit Profile
+
+ ) : (
+ <>
+
+ router.push(
+ `/chat?userId=${userProfile.clerkUserId}`,
+ )
+ }
+ >
+
+ Message
+
+
+ {isFollowLoading ? (
+
+ ) : isFollowing ? (
+ "Unfollow"
+ ) : (
+ "Follow"
+ )}
+
+
+
+ Report
+
+ >
+ )}
+
+
+ >
+ )}
- {/* Horizontal Stats Bar */}
-
-
-
- Rating
- New
-
-
- Active
- {listingsCount}
-
-
- Swaps
- 0
-
-
+ {/* Edit Mode Save Button */}
+ {isEditing && currentUserId === userProfile.clerkUserId && (
+
+ setIsEditing(false)}
+ disabled={isSaving}
+ >
+ Cancel
+
+
+ {isSaving ? (
+
+ ) : (
+ "Save Changes"
+ )}
+
+
+ )}
- {/* Stripe Connection Section */}
- {currentUserId === userProfile.clerkUserId && (
-
-
-
-
Seller Payouts
-
- {isStripeLinked ? (
-
-
- Connected
-
- ) : (
-
- {isLoadingStripe ? : null}
- Connect Bank Account
-
- )}
-
- )}
-
-
+ {/* Mobile Action Buttons */}
+ {!isEditing && (
+
+ {currentUserId === userProfile.clerkUserId ? (
+
setIsEditing(true)}
+ >
+ Edit Profile
+
+ ) : (
+ <>
+
+ router.push(
+ `/chat?userId=${userProfile.clerkUserId}`,
+ )
+ }
+ >
+
+ Message Seller
+
+
+ {isFollowLoading ? (
+
+ ) : isFollowing ? (
+ "Unfollow"
+ ) : (
+ "Follow"
+ )}
+
+
+
+ Report
+
+ >
+ )}
+
+ )}
+
+
- {/* User's Listings Section */}
-
-
Listings by {userProfile.name?.split(' ')[0] || 'User'}
- {userProfile.listings && userProfile.listings.length > 0 ? (
-
- {userProfile.listings.map((listing: any, i: number) => (
-
-
-
- {/* Image */}
-
- {listing.images && listing.images.length > 0 ? (
-
- ) : (
-
- )}
- {/* Category Badge */}
-
-
- {listing.category}
-
-
- {/* Edit/Delete Button Overlay */}
- {currentUserId === userProfile.clerkUserId && (
-
-
{ e.preventDefault(); e.stopPropagation(); router.push(`/listings/${listing.id}/edit`); }}
- className="cursor-pointer"
- >
-
-
-
-
-
{ e.preventDefault(); e.stopPropagation(); handleDeleteListing(listing.id);
- }}
- className="cursor-pointer"
- >
-
-
-
-
-
- )}
-
- {/* Content */}
-
-
- ${Number(listing.price).toFixed(2)}
-
-
- {listing.title}
-
-
-
-
-
- ))}
-
- ) : (
-
-
-
-
-
No active listings
-
This student doesn't have any items for sale right now.
-
- )}
-
-
-
-
- );
+ {/* Horizontal Stats Bar */}
+
+
+
+
+ Rating
+
+
+ New
+
+
+
+
+ Active
+
+
+ {listingsCount}
+
+
+
+
+ Followers
+
+
+ {followersCount}
+
+
+
+
+ Following
+
+
+ {followingCount}
+
+
+
+
+ {/* Stripe Connection Section */}
+ {currentUserId === userProfile.clerkUserId && (
+
+
+
+
+ Seller Payouts
+
+
+ {isStripeLinked ? (
+
+
+ Connected
+
+ ) : (
+
+ {isLoadingStripe ? (
+
+ ) : null}
+ Connect Bank Account
+
+ )}
+
+ )}
+
+
+
+ {/* User's Listings Section */}
+
+
+ Listings by {userProfile.name?.split(" ")[0] || "User"}
+
+ {userProfile.listings && userProfile.listings.length > 0 ? (
+
+ {userProfile.listings.map((listing: any, i: number) => (
+
+
+
+ {/* Image */}
+
+ {listing.images && listing.images.length > 0 ? (
+
+ ) : (
+
+ )}
+ {/* Category Badge */}
+
+
+ {listing.category}
+
+
+ {/* Edit/Delete Button Overlay */}
+ {currentUserId === userProfile.clerkUserId && (
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ router.push(`/listings/${listing.id}/edit`);
+ }}
+ className="cursor-pointer"
+ >
+
+
+
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ handleDeleteListing(listing.id);
+ }}
+ className="cursor-pointer"
+ >
+
+
+
+
+
+ )}
+
+ {/* Content */}
+
+
+ ${Number(listing.price).toFixed(2)}
+
+
+ {listing.title}
+
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
+
+ No active listings
+
+
+ This student doesn't have any items for sale right now.
+
+
+ )}
+
+
+
+
+ );
}
diff --git a/frontend/app/purchase-history/page.tsx b/frontend/app/purchase-history/page.tsx
index ce97eb7..5c28c95 100644
--- a/frontend/app/purchase-history/page.tsx
+++ b/frontend/app/purchase-history/page.tsx
@@ -24,9 +24,12 @@ export default function PurchaseHistoryPage() {
try {
const token = await getToken();
if (!token) return;
- const res = await axios.get(`http://127.0.0.1:3000/transactions/history`, {
- headers: { Authorization: `Bearer ${token}` }
- });
+ const res = await axios.get(
+ `http://127.0.0.1:3000/transactions/history`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
setHistory(res.data);
} catch (error) {
console.error("Failed to fetch history", error);
@@ -64,11 +67,27 @@ export default function PurchaseHistoryPage() {
const renderTransactions = (transactions: any[]) => {
// Apply filters
- const filtered = transactions.filter(t => {
+ const filtered = transactions.filter((t) => {
if (activeFilter === "All") return true;
- if (activeFilter === "Completed" && (t.orderStatus === "COMPLETED" || t.orderStatus === "COMPLETED_BY_SELLER" || t.orderStatus === "MEETUP_CONFIRMED")) return true;
- if (activeFilter === "Cancelled" && (t.orderStatus === "CANCELLED" || t.orderStatus === "EXPIRED" || t.orderStatus === "DECLINED")) return true;
- if (activeFilter === "Reserved" && t.orderStatus === "PAID_PENDING_MEETUP") return true;
+ if (
+ activeFilter === "Completed" &&
+ (t.orderStatus === "COMPLETED" ||
+ t.orderStatus === "COMPLETED_BY_SELLER" ||
+ t.orderStatus === "MEETUP_CONFIRMED")
+ )
+ return true;
+ if (
+ activeFilter === "Cancelled" &&
+ (t.orderStatus === "CANCELLED" ||
+ t.orderStatus === "EXPIRED" ||
+ t.orderStatus === "DECLINED")
+ )
+ return true;
+ if (
+ activeFilter === "Reserved" &&
+ t.orderStatus === "PAID_PENDING_MEETUP"
+ )
+ return true;
return false;
});
@@ -77,22 +96,39 @@ export default function PurchaseHistoryPage() {
return (
{filtered.map((tx) => (
-
+
{tx.listing.images?.[0]?.url && (
-
+
)}
-
{tx.listing.title}
- ${(tx.amount / 100).toFixed(2)}
+
+ {tx.listing.title}
+
+
+ ${(tx.amount / 100).toFixed(2)}
+
- Status: {tx.orderStatus.replace(/_/g, ' ')}
+ Status:{" "}
+
+ {tx.orderStatus.replace(/_/g, " ")}
+
- Payment: {tx.paymentMethod === 'STRIPE' ? 'Orbit Secure Payment' : 'Direct / In-Person'}
+ Payment:{" "}
+ {tx.paymentMethod === "STRIPE"
+ ? "Orbit Secure Payment"
+ : "Direct / In-Person"}
@@ -112,30 +148,38 @@ export default function PurchaseHistoryPage() {
{/* Header */}
-
Purchase History
-
Review your completed, reserved, and cancelled orders.
+
+ Purchase History
+
+
+ Review your completed, reserved, and cancelled orders.
+
{/* Tabs */}
-
+
-
Buying History
-
@@ -145,7 +189,9 @@ export default function PurchaseHistoryPage() {
{/* Filters */}
-
Filter:
+
+ Filter:
+
{filters.map((filter) => (
{renderTransactions(history.buying)}
-
+
{renderTransactions(history.selling)}
diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx
index 687470b..7f762e1 100644
--- a/frontend/app/settings/page.tsx
+++ b/frontend/app/settings/page.tsx
@@ -26,7 +26,7 @@ export default function SettingsPage() {
const token = await getToken();
if (!token) return;
const res = await axios.get(`http://127.0.0.1:3000/users/me`, {
- headers: { Authorization: `Bearer ${token}` }
+ headers: { Authorization: `Bearer ${token}` },
});
setSettings(res.data);
} catch (err) {
@@ -45,9 +45,13 @@ export default function SettingsPage() {
try {
const token = await getToken();
- await axios.patch(`http://127.0.0.1:3000/users/me`, { [key]: value }, {
- headers: { Authorization: `Bearer ${token}` }
- });
+ await axios.patch(
+ `http://127.0.0.1:3000/users/me`,
+ { [key]: value },
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
toast.success("Settings saved");
} catch (err) {
console.error(err);
@@ -71,11 +75,14 @@ export default function SettingsPage() {
return (
-
{/* Header */}
-
Account Preferences
-
Manage your Orbit notifications, safety tools, and campus settings.
+
+ Account Preferences
+
+
+ Manage your Orbit notifications, safety tools, and campus settings.
+
-
Campus Alerts & Notifications
-
Control how you stay updated on your marketplace activity and campus meetups.
+
+ Campus Alerts & Notifications
+
+
+ Control how you stay updated on your marketplace activity and
+ campus meetups.
+
{/* Global Email Toggle */}
-
Orbit Digest & Updates
-
Receive important alerts straight to your university inbox.
+
+ Orbit Digest & Updates
+
+
+ Receive important alerts straight to your university inbox.
+
-
updateSetting('emailNotifications', checked)}
- className="h-5 w-5 rounded-[4px]"
+
+ updateSetting("emailNotifications", checked)
+ }
+ className="h-5 w-5 rounded-[4px]"
/>
- {/* Frequency Selectors */}
-
-
-
Email frequency
- updateSetting('emailFrequency', val)}
- >
-
-
-
-
- Daily
- Weekly
- Never
-
-
-
-
-
Digest time (local)
-
+
+
+ Email frequency
+
+
+ updateSetting("emailFrequency", val)
+ }
+ >
+
+
+
+
+ Daily
+ Weekly
+ Never
+
+
+
+
+
+ Digest time (local)
+
+ updateSetting('digestTime', val)}
- >
-
-
-
-
- 04:00
- 08:00
- 12:00
- 18:00
-
-
-
-
+ onValueChange={(val) => updateSetting("digestTime", val)}
+ >
+
+
+
+
+ 04:00
+ 08:00
+ 12:00
+ 18:00
+
+
+
+
- {/* Specific Alerts */}
-
-
Email me about
-
-
- Direct Messages from Buyers/Sellers
- updateSetting('notifyMessages', checked)}
- className="h-5 w-5 rounded-[4px]"
- />
-
-
- Questions on your items
- updateSetting('notifyComments', checked)}
- className="h-5 w-5 rounded-[4px]"
- />
-
-
- When someone wishlists your items
- updateSetting('notifyWishlists', checked)}
- className="h-5 w-5 rounded-[4px]"
- />
-
-
- Meetup & Reservation Updates
- updateSetting('notifyMeetups', checked)}
- className="h-5 w-5 rounded-[4px]"
- />
-
-
- Reminders to verify meetups & leave ratings
- updateSetting('notifyReminders', checked)}
- className="h-5 w-5 rounded-[4px]"
- />
-
-
-
+ {/* Specific Alerts */}
+
+
+ Email me about
+
+
+
+
+ Direct Messages from Buyers/Sellers
+
+
+ updateSetting("notifyMessages", checked)
+ }
+ className="h-5 w-5 rounded-[4px]"
+ />
+
+
+
+ Questions on your items
+
+
+ updateSetting("notifyComments", checked)
+ }
+ className="h-5 w-5 rounded-[4px]"
+ />
+
+
+
+ When someone wishlists your items
+
+
+ updateSetting("notifyWishlists", checked)
+ }
+ className="h-5 w-5 rounded-[4px]"
+ />
+
+
+
+ Meetup & Reservation Updates
+
+
+ updateSetting("notifyMeetups", checked)
+ }
+ className="h-5 w-5 rounded-[4px]"
+ />
+
+
+
+ Reminders to verify meetups & leave ratings
+
+
+ updateSetting("notifyReminders", checked)
+ }
+ className="h-5 w-5 rounded-[4px]"
+ />
+
+
+
{/* Content Preferences Card */}
-
-
Safety & Community Preferences
-
Customize your Orbit browsing experience to keep campus trading safe.
+
+
+ Safety & Community Preferences
+
+
+ Customize your Orbit browsing experience to keep campus trading
+ safe.
+
-
Campus Content Filter
-
Automatically hide explicit language and inappropriate content from your feed and messages.
+
+ Campus Content Filter
+
+
+ Automatically hide explicit language and inappropriate content
+ from your feed and messages.
+
-
updateSetting('profanityFilter', checked)}
- className="h-5 w-5 rounded-[4px]"
+
+ updateSetting("profanityFilter", checked)
+ }
+ className="h-5 w-5 rounded-[4px]"
/>
-
diff --git a/frontend/app/swipe/page.tsx b/frontend/app/swipe/page.tsx
index d05bd20..adfc04e 100644
--- a/frontend/app/swipe/page.tsx
+++ b/frontend/app/swipe/page.tsx
@@ -1,8 +1,22 @@
"use client";
import { useState, useEffect } from "react";
-import { motion, AnimatePresence, useMotionValue, useTransform, PanInfo } from "framer-motion";
-import { X, Heart, Sparkles, AlertCircle, ShoppingBag, MapPin, Loader2 } from "lucide-react";
+import {
+ motion,
+ AnimatePresence,
+ useMotionValue,
+ useTransform,
+ PanInfo,
+} from "framer-motion";
+import {
+ X,
+ Heart,
+ Sparkles,
+ AlertCircle,
+ ShoppingBag,
+ MapPin,
+ Loader2,
+} from "lucide-react";
import axios from "axios";
import { useAuth } from "@clerk/nextjs";
import { Button } from "@/components/ui/button";
@@ -12,336 +26,427 @@ import { AuroraText } from "@/components/ui/aurora-text";
import { Backlight } from "@/components/ui/backlight";
interface Seller {
- id: string;
- name?: string;
- username?: string;
- avatarUrl?: string;
- email?: string;
- university?: string;
+ id: string;
+ name?: string;
+ username?: string;
+ avatarUrl?: string;
+ email?: string;
+ university?: string;
}
interface Listing {
- id: string;
- title: string;
- description: string;
- price: number;
- category: string;
- seller: Seller;
- images?: { url: string }[];
+ id: string;
+ title: string;
+ description: string;
+ price: number;
+ category: string;
+ seller: Seller;
+ images?: { url: string }[];
}
const getImageUrl = (url?: string) => {
- if (!url) return "";
- if (url.startsWith("http")) return url;
- return `http://127.0.0.1:3000${url}`;
+ if (!url) return "";
+ if (url.startsWith("http")) return url;
+ return `http://127.0.0.1:3000${url}`;
};
export default function SwipePage() {
- const { getToken, isLoaded, isSignedIn } = useAuth();
- const [listings, setListings] = useState
([]);
- const [isLoading, setIsLoading] = useState(true);
- const [userProfile, setUserProfile] = useState(null);
+ const { getToken, isLoaded, isSignedIn } = useAuth();
+ const [listings, setListings] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [userProfile, setUserProfile] = useState(null);
- useEffect(() => {
- if (!isLoaded || !isSignedIn) return;
- const fetchFeed = async () => {
- try {
- const token = await getToken();
+ useEffect(() => {
+ if (!isLoaded || !isSignedIn) return;
+ const fetchFeed = async () => {
+ try {
+ const token = await getToken();
- // Fetch user profile to get university
- try {
- const userRes = await axios.get("http://127.0.0.1:3000/users/me", { headers: { Authorization: `Bearer ${token}` } });
- setUserProfile(userRes.data);
- } catch (e) {}
+ // Fetch user profile to get university
+ try {
+ const userRes = await axios.get("http://127.0.0.1:3000/users/me", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ setUserProfile(userRes.data);
+ } catch (e) {}
- const res = await axios.get("http://127.0.0.1:3000/listings", {
- headers: { Authorization: `Bearer ${token}` }
- });
- setListings(res.data);
- } catch (err) {
- console.error("Failed to fetch swipe feed:", err);
- } finally {
- setIsLoading(false);
- }
- };
- fetchFeed();
- }, [isLoaded, isSignedIn, getToken]);
+ const res = await axios.get("http://127.0.0.1:3000/listings", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ setListings(res.data);
+ } catch (err) {
+ console.error("Failed to fetch swipe feed:", err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchFeed();
+ }, [isLoaded, isSignedIn, getToken]);
- const activeListing = listings[0];
- const nextListing = listings[1];
+ const activeListing = listings[0];
+ const nextListing = listings[1];
- // Motion values for the drag effect
- const x = useMotionValue(0);
- const rotate = useTransform(x, [-200, 200], [-15, 15]);
- const opacity = useTransform(x, [-200, -100, 0, 100, 200], [0.5, 1, 1, 1, 0.5]);
- // Opacity for the "LIKE" and "NOPE" stamps based on drag offset
- const likeOpacity = useTransform(x, [20, 100], [0, 1]);
- const nopeOpacity = useTransform(x, [-20, -100], [0, 1]);
+ // Motion values for the drag effect
+ const x = useMotionValue(0);
+ const rotate = useTransform(x, [-200, 200], [-15, 15]);
+ const opacity = useTransform(
+ x,
+ [-200, -100, 0, 100, 200],
+ [0.5, 1, 1, 1, 0.5],
+ );
+ // Opacity for the "LIKE" and "NOPE" stamps based on drag offset
+ const likeOpacity = useTransform(x, [20, 100], [0, 1]);
+ const nopeOpacity = useTransform(x, [-20, -100], [0, 1]);
- const handleSwipe = async (direction: "left" | "right") => {
- if (!activeListing) return;
- const listingId = activeListing.id;
- const interactionType = direction === "right" ? "LIKE" : "SKIP";
+ const handleSwipe = async (direction: "left" | "right") => {
+ if (!activeListing) return;
+ const listingId = activeListing.id;
+ const interactionType = direction === "right" ? "LIKE" : "SKIP";
- // Optimistically remove the top card
- setListings((prev) => prev.slice(1));
- try {
- const token = await getToken();
- await axios.post(`http://127.0.0.1:3000/listings/${listingId}/swipe`, { type: interactionType }, { headers: { Authorization: `Bearer ${token}` } }
- );
- } catch (err) {
- console.error(`Failed to record ${interactionType} swipe:`, err);
- }
- };
+ // Optimistically remove the top card
+ setListings((prev) => prev.slice(1));
+ try {
+ const token = await getToken();
+ await axios.post(
+ `http://127.0.0.1:3000/listings/${listingId}/swipe`,
+ { type: interactionType },
+ { headers: { Authorization: `Bearer ${token}` } },
+ );
+ } catch (err) {
+ console.error(`Failed to record ${interactionType} swipe:`, err);
+ }
+ };
- const handleDragEnd = (event: any, info: PanInfo) => {
- const offset = info.offset.x;
- const velocity = info.velocity.x;
+ const handleDragEnd = (event: any, info: PanInfo) => {
+ const offset = info.offset.x;
+ const velocity = info.velocity.x;
- if (offset > 100 || velocity > 500) {
- handleSwipe("right");
- } else if (offset < -100 || velocity < -500) {
- handleSwipe("left");
- }
- };
+ if (offset > 100 || velocity > 500) {
+ handleSwipe("right");
+ } else if (offset < -100 || velocity < -500) {
+ handleSwipe("left");
+ }
+ };
- if (!isLoaded || isLoading) {
- return (
-
-
-
- );
- }
+ if (!isLoaded || isLoading) {
+ return (
+
+
+
+ );
+ }
- return (
-
-
-
-
- {/* Sidebar Filters */}
-
-
-
Distance
-
-
- ✓
- All
-
-
-
- @ {userProfile?.university || 'your university'}
-
-
-
+ return (
+
+
+ {/* Sidebar Filters */}
+
+
+
+ Distance
+
+
+
+
+ ✓
+
+ All
+
+
+
+
+ @ {userProfile?.university || "your university"}
+
+
+
+
-
-
Prices
-
- {['All', '$0 to $50', '$50 to $100', '$100 to $300', '$300+'].map((price, idx) => (
-
-
- {idx === 0 && '✓'}
-
- {price}
-
- ))}
-
-
+
+
+ Prices
+
+
+ {["All", "$0 to $50", "$50 to $100", "$100 to $300", "$300+"].map(
+ (price, idx) => (
+
+
+ {idx === 0 && "✓"}
+
+
+ {price}
+
+
+ ),
+ )}
+
+
-
-
Condition
-
- {['Any', 'New', 'Like New', 'Good', 'Fair'].map((cond, idx) => (
-
-
- {idx === 0 && '✓'}
-
- {cond}
-
- ))}
-
-
+
+
+ Condition
+
+
+ {["Any", "New", "Like New", "Good", "Fair"].map((cond, idx) => (
+
+
+ {idx === 0 && "✓"}
+
+
+ {cond}
+
+
+ ))}
+
+
-
-
Buying Options
-
- {['Any', 'Meetup on campus', 'Self pickup'].map((opt, idx) => (
-
-
- {idx === 0 && '✓'}
-
- {opt}
-
- ))}
-
-
-
+
+
+ Buying Options
+
+
+ {["Any", "Meetup on campus", "Self pickup"].map((opt, idx) => (
+
+
+ {idx === 0 && "✓"}
+
+
+ {opt}
+
+
+ ))}
+
+
+
- {/* Swipe Deck Container */}
-
- {/* Title / Header */}
-
-
- Match Your Needs
-
-
Swipe right to save to your wishlist
-
+ {/* Swipe Deck Container */}
+
+ {/* Title / Header */}
+
+
+
+ Match Your Needs
+
+
+
+ Swipe right to save to your wishlist
+
+
-
-
- {activeListing ? (
- <>
- {/* NEXT CARD (Background) */}
- {nextListing && (
-
-
-
0 ? getImageUrl(nextListing.images[0].url) : "https://images.unsplash.com/photo-1584568694244-14fbdf83bd30?w=800&q=80"} alt={nextListing.title} className="absolute inset-0 h-full w-full object-cover"
- />
-
-
-
-
{nextListing.title}
- ${nextListing.price}
-
-
-
- )}
+
+
+ {activeListing ? (
+ <>
+ {/* NEXT CARD (Background) */}
+ {nextListing && (
+
+
+
0
+ ? getImageUrl(nextListing.images[0].url)
+ : "https://images.unsplash.com/photo-1584568694244-14fbdf83bd30?w=800&q=80"
+ }
+ alt={nextListing.title}
+ className="absolute inset-0 h-full w-full object-cover"
+ />
+
+
+
+
+ {nextListing.title}
+
+
+ ${nextListing.price}
+
+
+
+
+ )}
- {/* ACTIVE CARD (Foreground) */}
- 0 ? 300 : -300, opacity: 0, transition: { duration: 0.3 } }}
- >
-
-
- {/* LIKE STAMP */}
-
-
- LIKE
-
-
+ {/* ACTIVE CARD (Foreground) */}
+
0 ? 300 : -300,
+ opacity: 0,
+ transition: { duration: 0.3 },
+ }}
+ >
+
+
+ {/* LIKE STAMP */}
+
+
+ LIKE
+
+
- {/* NOPE STAMP */}
-
-
- NOPE
-
-
+ {/* NOPE STAMP */}
+
+
+ NOPE
+
+
- {/* Card Image */}
-
- {activeListing.images && activeListing.images.length > 0 ? (
-
- ) : (
-
-
-
- )}
- {/* Category Badge overlaying the image */}
-
-
- {activeListing.category}
-
-
- {/* Bottom gradient so text is readable if image is bright */}
-
-
- {/* Card Details */}
-
-
-
{activeListing.title}
- ${activeListing.price}
-
-
- {activeListing.description}
-
-
-
-
-
- {activeListing.seller?.name?.[0]?.toUpperCase() || "U"}
-
-
-
- {activeListing.seller?.name || "Anonymous Seller"}
-
- UIC Verified
-
-
-
-
-
-
-
- >
- ) : (
-
-
- You're all caught up!
- Check back later for more listings from the UIC community.
- window.location.reload()}
- >
- Refresh Feed
-
-
- )}
-
-
+ {/* Card Image */}
+
+ {activeListing.images &&
+ activeListing.images.length > 0 ? (
+
+ ) : (
+
+
+
+ )}
+ {/* Category Badge overlaying the image */}
+
+
+ {activeListing.category}
+
+
+ {/* Bottom gradient so text is readable if image is bright */}
+
+
+ {/* Card Details */}
+
+
+
+ {activeListing.title}
+
+
+ ${activeListing.price}
+
+
+
+ {activeListing.description}
+
+
+
+
+
+ {activeListing.seller?.name?.[0]?.toUpperCase() ||
+ "U"}
+
+
+
+
+ {activeListing.seller?.name ||
+ "Anonymous Seller"}
+
+
+ UIC Verified
+
+
+
+
+
+
+
+ >
+ ) : (
+
+
+
+ You're all caught up!
+
+
+ Check back later for more listings from the UIC community.
+
+ window.location.reload()}
+ >
+ Refresh Feed
+
+
+ )}
+
+
- {/* Action Buttons */}
-
-
- handleSwipe("left")}
- disabled={!activeListing}
- className="h-16 w-16 rounded-full border-2 border-rose-200 text-rose-500 bg-card hover:bg-rose-50 hover:text-rose-600 shadow-xl shadow-rose-500/10 transition-colors"
- >
-
-
-
-
- handleSwipe("right")}
- disabled={!activeListing}
- className="h-16 w-16 rounded-full border-2 border-emerald-200 text-emerald-500 bg-card hover:bg-emerald-50 hover:text-emerald-600 shadow-xl shadow-emerald-500/10 transition-colors"
- >
-
-
-
-
-
-
-
-
- );
+ {/* Action Buttons */}
+
+
+ handleSwipe("left")}
+ disabled={!activeListing}
+ className="h-16 w-16 rounded-full border-2 border-rose-200 text-rose-500 bg-card hover:bg-rose-50 hover:text-rose-600 shadow-xl shadow-rose-500/10 transition-colors"
+ >
+
+
+
+
+ handleSwipe("right")}
+ disabled={!activeListing}
+ className="h-16 w-16 rounded-full border-2 border-emerald-200 text-emerald-500 bg-card hover:bg-emerald-50 hover:text-emerald-600 shadow-xl shadow-emerald-500/10 transition-colors"
+ >
+
+
+
+
+
+
+
+ );
}
diff --git a/frontend/app/wishlist/page.tsx b/frontend/app/wishlist/page.tsx
index 750ffef..6a8543c 100644
--- a/frontend/app/wishlist/page.tsx
+++ b/frontend/app/wishlist/page.tsx
@@ -1,130 +1,142 @@
-'use client';
+"use client";
-import { useState, useEffect } from 'react';
-import { useAuth } from '@clerk/nextjs';
-import { motion } from 'framer-motion';
-import axios from 'axios';
-import { Loader2, Heart, Tag, ArrowLeft } from 'lucide-react';
-import Link from 'next/link';
+import { useState, useEffect } from "react";
+import { useAuth } from "@clerk/nextjs";
+import { motion } from "framer-motion";
+import axios from "axios";
+import { Loader2, Heart, Tag, ArrowLeft } from "lucide-react";
+import Link from "next/link";
const getImageUrl = (url?: string) => {
- if (!url) return "";
- if (url.startsWith("http")) return url;
- return `http://127.0.0.1:3000${url}`;
+ if (!url) return "";
+ if (url.startsWith("http")) return url;
+ return `http://127.0.0.1:3000${url}`;
};
export default function WishlistPage() {
- const { getToken, isLoaded, isSignedIn } = useAuth();
- const [isLoading, setIsLoading] = useState(true);
- const [wishlist, setWishlist] = useState([]);
+ const { getToken, isLoaded, isSignedIn } = useAuth();
+ const [isLoading, setIsLoading] = useState(true);
+ const [wishlist, setWishlist] = useState([]);
- useEffect(() => {
- const fetchWishlist = async () => {
- if (!isLoaded || !isSignedIn) return;
- try {
- const token = await getToken();
- const res = await axios.get(`http://127.0.0.1:3000/listings/wishlist`, {
- headers: { Authorization: `Bearer ${token}` }
- });
- setWishlist(res.data);
- } catch (err) {
- console.error('Failed to fetch wishlist', err);
- } finally {
- setIsLoading(false);
- }
- };
- fetchWishlist();
- }, [isLoaded, isSignedIn, getToken]);
+ useEffect(() => {
+ const fetchWishlist = async () => {
+ if (!isLoaded || !isSignedIn) return;
+ try {
+ const token = await getToken();
+ const res = await axios.get(`http://127.0.0.1:3000/listings/wishlist`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ setWishlist(res.data);
+ } catch (err) {
+ console.error("Failed to fetch wishlist", err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchWishlist();
+ }, [isLoaded, isSignedIn, getToken]);
- if (!isLoaded || isLoading) {
- return (
-
-
-
- );
- }
+ if (!isLoaded || isLoading) {
+ return (
+
+
+
+ );
+ }
- return (
-
+ return (
+
+
+ {/* Header */}
+
+
+
+ Back to Marketplace
+
+
+
+
+
+
+ Your Wishlist
+
+
+
-
- {/* Header */}
-
-
-
- Back to Marketplace
-
-
-
-
- {/* Wishlist Grid */}
- {wishlist && wishlist.length > 0 ? (
-
- {wishlist.map((listing: any, i: number) => (
-
-
-
- {/* Image */}
-
- {listing.images && listing.images.length > 0 ? (
-
- ) : (
-
- )}
- {/* Category Badge */}
-
-
- {listing.category}
-
-
-
- {/* Content */}
-
-
- ${Number(listing.price).toFixed(2)}
-
-
- {listing.title}
-
-
-
-
-
- ))}
-
- ) : (
-
-
-
-
-
Your Wishlist is Empty
-
- Items you swipe right on or save will appear here so you can easily find them later.
-
-
-
- Start Swiping
-
-
-
- )}
-
-
-
- );
+ {/* Wishlist Grid */}
+ {wishlist && wishlist.length > 0 ? (
+
+ {wishlist.map((listing: any, i: number) => (
+
+
+
+ {/* Image */}
+
+ {listing.images && listing.images.length > 0 ? (
+
+ ) : (
+
+ )}
+ {/* Category Badge */}
+
+
+ {listing.category}
+
+
+
+ {/* Content */}
+
+
+ ${Number(listing.price).toFixed(2)}
+
+
+ {listing.title}
+
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
+
+ Your Wishlist is Empty
+
+
+ Items you swipe right on or save will appear here so you can
+ easily find them later.
+
+
+
+ Start Swiping
+
+
+
+ )}
+
+
+ );
}
diff --git a/frontend/components/GlobalNav.tsx b/frontend/components/GlobalNav.tsx
index 17ab8a9..c20f033 100644
--- a/frontend/components/GlobalNav.tsx
+++ b/frontend/components/GlobalNav.tsx
@@ -17,6 +17,8 @@ import {
} from "@/components/ui/dropdown-menu";
import React from "react";
+import { NotificationsMenu } from "@/components/NotificationsMenu";
+
export function GlobalNav({ navActions }: { navActions?: React.ReactNode }) {
const pathname = usePathname() || "";
@@ -49,15 +51,30 @@ export function GlobalNav({ navActions }: { navActions?: React.ReactNode }) {
{/* Dropdown Menu */}
- {pathname.startsWith('/community') ? 'Community' : 'Marketplace'}
+ {pathname.startsWith("/community")
+ ? "Community"
+ : "Marketplace"}
-
+
- Marketplace
+
+ Marketplace
+
- Community
+
+ Community
+
@@ -109,6 +126,7 @@ export function GlobalNav({ navActions }: { navActions?: React.ReactNode }) {
{navActions}
+
diff --git a/frontend/components/MiniChatWidget.tsx b/frontend/components/MiniChatWidget.tsx
new file mode 100644
index 0000000..2ab3c35
--- /dev/null
+++ b/frontend/components/MiniChatWidget.tsx
@@ -0,0 +1,376 @@
+"use client";
+
+import React, { useState, useEffect, useRef } from "react";
+import { MessageSquare, X, Send, ArrowLeft, Loader2, ExternalLink } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import axios from "axios";
+import { useAuth } from "@clerk/nextjs";
+import { io, Socket } from "socket.io-client";
+import { toast } from "sonner";
+import { usePathname } from "next/navigation";
+
+export function MiniChatWidget() {
+ const { getToken, userId } = useAuth();
+ const pathname = usePathname();
+
+ const [isOpen, setIsOpen] = useState(false);
+ const [conversations, setConversations] = useState([]);
+ const [activeConversation, setActiveConversation] = useState(
+ null,
+ );
+ const [messages, setMessages] = useState([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [newMessage, setNewMessage] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [socket, setSocket] = useState(null);
+ const messagesEndRef = useRef(null);
+
+ // Check if we are on the full chat page
+
+ useEffect(() => {
+ let newSocket: Socket | null = null;
+
+ if (userId) {
+ const initSocket = async () => {
+ const token = await getToken();
+
+ // Fetch initial unread count
+ try {
+ const res = await axios.get(`http://127.0.0.1:3000/chat/unread-count`, {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ setUnreadCount(res.data.count || 0);
+ } catch (e) { console.error(e); }
+
+ newSocket = io("http://127.0.0.1:3000", {
+ auth: { token },
+ query: { userId },
+ });
+
+ newSocket.on("connect", () => {
+ newSocket!.emit("authenticate", { token });
+ });
+
+ newSocket.on("receive_message", (msg: any) => {
+ // If it's the active conversation, append it
+ setMessages((prev) => {
+ if (activeConversation && msg.conversationId === activeConversation) {
+ return [...prev, msg];
+ }
+ return prev;
+ });
+
+ // If it's not us and not active/open, increment unread count
+ if (msg.senderId !== userId && (!isOpen || msg.conversationId !== activeConversation)) {
+ setUnreadCount(prev => prev + 1);
+ }
+
+ // If we are viewing it, tell backend we read it
+ if (msg.senderId !== userId && isOpen && activeConversation && msg.conversationId === activeConversation) {
+ newSocket!.emit("mark_read", { conversationId: activeConversation });
+ }
+
+ // Always show toast if it's not our own message and we're not actively looking at it
+ if (
+ msg.senderId !== userId &&
+ (!isOpen || msg.conversationId !== activeConversation)
+ ) {
+ const senderName =
+ msg.sender?.name || msg.sender?.username || "Someone";
+ toast(`New message from ${senderName}`, {
+ description:
+ msg.content.length > 40
+ ? msg.content.substring(0, 40) + "..."
+ : msg.content,
+ duration: 5000,
+ });
+ }
+ });
+
+ newSocket.on("messages_read", (payload: { conversationId: string }) => {
+ if (activeConversation && payload.conversationId === activeConversation) {
+ setMessages(prev => prev.map(m => m.senderId === userId ? { ...m, isRead: true } : m));
+ }
+ });
+
+ setSocket(newSocket);
+ };
+
+ initSocket();
+
+ return () => {
+ newSocket?.disconnect();
+ };
+ }
+ }, [userId, activeConversation, isOpen]);
+
+ const loadInbox = async () => {
+ try {
+ setIsLoading(true);
+ const token = await getToken();
+ const res = await axios.get(`http://127.0.0.1:3000/chat/inbox`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ setConversations(res.data);
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const loadConversation = async (convId: string) => {
+ try {
+ setIsLoading(true);
+ setActiveConversation(convId);
+ const token = await getToken();
+ const res = await axios.get(
+ `http://127.0.0.1:3000/chat/inbox/${convId}`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ // The API returns an array directly, not an object with a messages property
+ setMessages(Array.isArray(res.data) ? res.data : res.data.messages || []);
+
+ // Notify backend to mark as read and manually refresh unread count
+ socket?.emit("mark_read", { conversationId: convId });
+
+ const unreadRes = await axios.get(`http://127.0.0.1:3000/chat/unread-count`, {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ setUnreadCount(unreadRes.data.count || 0);
+
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setIsLoading(false);
+ setTimeout(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, 100);
+ }
+ };
+
+ const handleOpen = () => {
+ setIsOpen(!isOpen);
+ if (!isOpen && !activeConversation) {
+ loadInbox();
+ }
+ };
+
+ const handleSendMessage = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!newMessage.trim() || !activeConversation || !socket) return;
+
+ socket.emit("send_message", {
+ conversationId: activeConversation,
+ content: newMessage,
+ });
+
+ setNewMessage("");
+ };
+
+ if (!userId || pathname?.startsWith("/chat")) return null;
+
+ // Render header logic
+ const activeConvDetails = activeConversation ? conversations.find(c => c.id === activeConversation) : null;
+ const activeOtherMember = activeConvDetails?.members.find((m: any) => m.user.clerkUserId !== userId)?.user;
+
+ return (
+
+ {/* Chat Window */}
+ {isOpen && (
+
+ {/* Header */}
+
+
+
+ {activeConversation ? (
+
{
+ setActiveConversation(null);
+ loadInbox();
+ }}
+ >
+
+
+ ) : (
+
+ )}
+
+ {activeConversation ? activeOtherMember?.name || activeOtherMember?.username || "Chat" : "Messages"}
+
+
+
+ {activeConversation && (
+ window.location.href = `/chat?id=${activeConversation}`}
+ title="Open in full chat"
+ >
+
+
+ )}
+ setIsOpen(false)}
+ >
+
+
+
+
+ {/* Context Buttons Row */}
+ {activeConversation && (
+
+ window.location.href = `/chat?id=${activeConversation}`}
+ className="bg-white/20 hover:bg-white/30 h-6 text-[10px] px-2 rounded-full text-white border-none"
+ >
+ Verify Meetup
+
+
+ )}
+
+
+ {/* Body */}
+
+ {isLoading ? (
+
+
+
+ ) : !activeConversation ? (
+
+ {conversations.length === 0 ? (
+
+ No conversations yet
+
+ ) : (
+
+ {conversations.map((conv) => {
+ const otherMember = conv.members.find(
+ (m: any) => m.user.clerkUserId !== userId,
+ )?.user;
+ const lastMessage = conv.messages[0];
+ return (
+
loadConversation(conv.id)}
+ className="p-3 border-b border-border hover:bg-secondary/50 cursor-pointer flex gap-3 items-center transition-colors"
+ >
+
+
+
+ {otherMember?.name?.[0] || "U"}
+
+
+
+
+ {otherMember?.name || otherMember?.username}
+
+
+ {lastMessage?.content || "No messages"}
+
+
+
+ );
+ })}
+
+ )}
+
+ ) : (
+ <>
+
+
+ {messages.map((msg, idx) => {
+ const isMe = msg.sender.clerkUserId === userId;
+ return (
+
+ {msg.listing && (
+
window.location.href = `/listings/${msg.listing.id}`}>
+ {msg.listing.images?.[0]?.url ? (
+
+ ) : (
+
+ )}
+
+
{msg.listing.title}
+
${msg.listing.price}
+
+
+ )}
+
+ {msg.content}
+
+ {isMe && msg.isRead && (
+
+ • Seen
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+ setNewMessage(e.target.value)}
+ placeholder="Type a message..."
+ className="flex-1 h-9 bg-secondary border-none text-[13px] rounded-full px-4"
+ />
+
+
+
+
+
+ >
+ )}
+
+
+ )}
+
+ {/* Floating Button */}
+
+ {isOpen ? (
+
+ ) : (
+
+
+ {unreadCount > 0 && (
+
+ {unreadCount > 99 ? '99+' : unreadCount}
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/components/NotificationsMenu.tsx b/frontend/components/NotificationsMenu.tsx
new file mode 100644
index 0000000..67071d3
--- /dev/null
+++ b/frontend/components/NotificationsMenu.tsx
@@ -0,0 +1,260 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Bell, Check, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ DropdownMenuSeparator,
+} from "@/components/ui/dropdown-menu";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import axios from "axios";
+import { useAuth } from "@clerk/nextjs";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import Link from "next/link";
+import { io, Socket } from "socket.io-client";
+import { toast } from "sonner";
+
+type NotificationType =
+ | "FOLLOW"
+ | "LIKE"
+ | "COMMENT"
+ | "PURCHASE"
+ | "OFFER"
+ | "ALL";
+
+interface Notification {
+ id: string;
+ type: string;
+ title: string;
+ content: string | null;
+ isRead: boolean;
+ linkUrl: string | null;
+ createdAt: string;
+ actor: {
+ name: string | null;
+ username: string | null;
+ avatarUrl: string | null;
+ } | null;
+}
+
+export function NotificationsMenu() {
+ const { getToken, userId } = useAuth();
+ const [notifications, setNotifications] = useState([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [filter, setFilter] = useState("ALL");
+ const [isLoading, setIsLoading] = useState(false);
+
+ const fetchNotifications = async (currentFilter: NotificationType) => {
+ try {
+ setIsLoading(true);
+ const token = await getToken();
+ const res = await axios.get(`http://127.0.0.1:3000/notifications`, {
+ params: { filter: currentFilter },
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ setNotifications(res.data);
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const fetchUnreadCount = async () => {
+ try {
+ const token = await getToken();
+ const res = await axios.get(
+ `http://127.0.0.1:3000/notifications/unread-count`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ setUnreadCount(res.data.unreadCount);
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ useEffect(() => {
+ if (userId) {
+ fetchNotifications(filter);
+ fetchUnreadCount();
+
+ let socket: Socket | null = null;
+
+ const initSocket = async () => {
+ const token = await getToken();
+ socket = io("http://127.0.0.1:3000", {
+ auth: { token },
+ query: { userId },
+ });
+
+ socket.on("connect", () => {
+ socket!.emit("authenticate", { token });
+ });
+
+ socket.on("new_notification", (newNotif: Notification) => {
+ setUnreadCount((prev) => prev + 1);
+ if (filter === "ALL" || filter === newNotif.type) {
+ setNotifications((prev) => [newNotif, ...prev]);
+ }
+ toast(newNotif.title, {
+ description: newNotif.content || "",
+ });
+ });
+ };
+
+ initSocket();
+
+ return () => {
+ socket?.disconnect();
+ };
+ }
+ }, [userId, filter, getToken]);
+
+ const markAsRead = async (id: string, linkUrl: string | null) => {
+ try {
+ const token = await getToken();
+ await axios.patch(
+ `http://127.0.0.1:3000/notifications/${id}/read`,
+ {},
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ setNotifications((prev) =>
+ prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)),
+ );
+ setUnreadCount((prev) => Math.max(0, prev - 1));
+
+ if (linkUrl) {
+ window.location.href = linkUrl;
+ }
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ const markAllAsRead = async () => {
+ try {
+ const token = await getToken();
+ await axios.patch(
+ `http://127.0.0.1:3000/notifications/read-all`,
+ {},
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ );
+ setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
+ setUnreadCount(0);
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ return (
+
+
+
+ {unreadCount > 0 && (
+
+ )}
+
+
+
+
Notifications
+ {unreadCount > 0 && (
+
+ Mark all as read
+
+ )}
+
+
+ {/* Filters */}
+
+ {["ALL", "FOLLOW", "LIKE", "COMMENT", "PURCHASE"].map((f) => (
+ setFilter(f as NotificationType)}
+ className={`px-2 py-1 rounded-full text-[10px] font-bold whitespace-nowrap transition-colors ${filter === f ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground hover:text-foreground"}`}
+ >
+ {f === "ALL" ? "All" : f.charAt(0) + f.slice(1).toLowerCase()}
+
+ ))}
+
+
+
+ {isLoading ? (
+
+
+
+ ) : notifications.length === 0 ? (
+
+ No notifications yet
+
+ ) : (
+
+ {notifications.map((notif) => (
+
markAsRead(notif.id, notif.linkUrl)}
+ className={`p-3 flex gap-3 cursor-pointer hover:bg-secondary/50 transition-colors border-b border-border last:border-0 ${!notif.isRead ? "bg-[#3252DF]/5" : ""}`}
+ >
+
+
+
+ {(notif.actor?.name ||
+ notif.actor?.username ||
+ "U")[0].toUpperCase()}
+
+
+
+
+
+ {notif.actor?.name ||
+ notif.actor?.username ||
+ "Someone"}
+ {" "}
+
+ {notif.title === "New Follower"
+ ? "started following you"
+ : notif.title === "New Like"
+ ? "liked your post"
+ : notif.title === "New Comment"
+ ? "commented on your post"
+ : notif.title === "New Reservation"
+ ? "purchased an item"
+ : ""}
+
+
+ {notif.content && notif.type === "COMMENT" && (
+
+ {notif.content
+ .split('commented: "')[1]
+ ?.replace('"', "") || notif.content}
+
+ )}
+
+ {new Date(notif.createdAt).toLocaleDateString()}
+
+
+ {!notif.isRead && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/components/ui/scroll-area.tsx b/frontend/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..84c1e9f
--- /dev/null
+++ b/frontend/components/ui/scroll-area.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
+
+import { cn } from "@/lib/utils"
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: ScrollAreaPrimitive.Root.Props) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: ScrollAreaPrimitive.Scrollbar.Props) {
+ return (
+
+
+
+ )
+}
+
+export { ScrollArea, ScrollBar }