From 2116ce9feaa975925b2c558eb4df008c87e1476d Mon Sep 17 00:00:00 2001 From: kiet08hogit Date: Thu, 9 Jul 2026 00:05:53 -0400 Subject: [PATCH 1/2] mobile mvp --- backend/prisma/schema.prisma | 24 + backend/src/app.module.ts | 2 + backend/src/modules/chat/chat.module.ts | 2 +- .../modules/offers/dto/create-offer.dto.ts | 11 + .../src/modules/offers/offers.controller.ts | 53 + backend/src/modules/offers/offers.module.ts | 12 + backend/src/modules/offers/offers.service.ts | 244 +++ .../src/modules/payments/payments.service.ts | 12 +- .../transactions/transactions.service.ts | 13 +- frontend/app/listings/[id]/page.tsx | 129 ++ frontend/app/purchase-history/page.tsx | 177 +- mobile/app/(tabs)/_layout.tsx | 36 +- mobile/app/(tabs)/chat.tsx | 69 +- mobile/app/(tabs)/community.tsx | 519 ++++++ mobile/app/(tabs)/home.tsx | 285 ++++ mobile/app/(tabs)/listings.tsx | 141 -- mobile/app/(tabs)/profile.tsx | 240 ++- mobile/app/(tabs)/swipe.tsx | 82 +- mobile/app/(tabs)/wishlist.tsx | 51 - mobile/app/_layout.tsx | 116 +- mobile/app/add-product.tsx | 150 +- mobile/app/admin/index.tsx | 125 ++ mobile/app/admin/reports.tsx | 122 ++ mobile/app/admin/users.tsx | 135 ++ mobile/app/chat/[conversationId].tsx | 118 +- mobile/app/checkout/[id].tsx | 88 +- mobile/app/index.tsx | 783 ++++++--- mobile/app/listings/[id].tsx | 223 --- mobile/app/listings/[id]/edit.tsx | 167 ++ mobile/app/listings/[id]/index.tsx | 485 ++++++ mobile/app/listings/index.tsx | 144 ++ mobile/app/notifications.tsx | 169 ++ mobile/app/offers.tsx | 122 ++ mobile/app/onboarding.tsx | 133 ++ mobile/app/profile/[id].tsx | 168 +- mobile/app/purchase-history.tsx | 102 ++ mobile/app/settings.tsx | 157 ++ mobile/app/sign-in.tsx | 141 +- mobile/app/sign-up.tsx | 163 ++ mobile/app/wishlist.tsx | 56 + mobile/assets/images/landing/accessories.jpg | Bin 0 -> 1941957 bytes mobile/assets/images/landing/clothing.jpg | Bin 0 -> 2757398 bytes mobile/assets/images/landing/dorm.jpg | Bin 0 -> 2768135 bytes mobile/assets/images/landing/electronics.jpg | Bin 0 -> 2366299 bytes mobile/assets/images/landing/goods.jpg | Bin 0 -> 8264519 bytes mobile/assets/images/landing/rave.jpg | Bin 0 -> 1147046 bytes .../assets/images/landing/school-supplies.jpg | Bin 0 -> 3040494 bytes mobile/assets/images/landing/services.png | Bin 0 -> 1876 bytes mobile/assets/images/landing/sublease.jpg | Bin 0 -> 6305865 bytes mobile/assets/images/landing/uic.webp | Bin 0 -> 73172 bytes mobile/assets/images/landing/uiuc.jpg | Bin 0 -> 250797 bytes mobile/components/CampusMap.tsx | 162 ++ mobile/components/CategoryRail.tsx | 85 +- mobile/components/GlobalHeader.tsx | 171 ++ mobile/components/ListingCard.tsx | 142 +- mobile/components/TransactionRow.tsx | 103 ++ mobile/components/magicui/Globe.tsx | 159 ++ mobile/components/magicui/OrbitingCircles.tsx | 122 ++ mobile/components/ui/Button.tsx | 24 +- mobile/components/ui/Pill.tsx | 68 +- mobile/data/mock.ts | 188 --- mobile/data/uicBuildings.ts | 163 ++ mobile/hooks/useConversations.ts | 52 - mobile/hooks/useListings.ts | 61 - mobile/hooks/useWarmUpBrowser.ts | 11 + mobile/lib/api.ts | 265 ++- mobile/lib/auth.ts | 3 + mobile/lib/socket.ts | 12 +- mobile/lib/types.ts | 227 ++- mobile/package-lock.json | 1435 ++++++++++++++++- mobile/package.json | 1 + mobile/test-clerk.js | 2 + mobile/theme/colors.ts | 93 +- mobile/theme/index.ts | 4 +- 74 files changed, 7990 insertions(+), 1532 deletions(-) create mode 100644 backend/src/modules/offers/dto/create-offer.dto.ts create mode 100644 backend/src/modules/offers/offers.controller.ts create mode 100644 backend/src/modules/offers/offers.module.ts create mode 100644 backend/src/modules/offers/offers.service.ts create mode 100644 mobile/app/(tabs)/community.tsx create mode 100644 mobile/app/(tabs)/home.tsx delete mode 100644 mobile/app/(tabs)/listings.tsx delete mode 100644 mobile/app/(tabs)/wishlist.tsx create mode 100644 mobile/app/admin/index.tsx create mode 100644 mobile/app/admin/reports.tsx create mode 100644 mobile/app/admin/users.tsx delete mode 100644 mobile/app/listings/[id].tsx create mode 100644 mobile/app/listings/[id]/edit.tsx create mode 100644 mobile/app/listings/[id]/index.tsx create mode 100644 mobile/app/listings/index.tsx create mode 100644 mobile/app/notifications.tsx create mode 100644 mobile/app/offers.tsx create mode 100644 mobile/app/onboarding.tsx create mode 100644 mobile/app/purchase-history.tsx create mode 100644 mobile/app/settings.tsx create mode 100644 mobile/app/sign-up.tsx create mode 100644 mobile/app/wishlist.tsx create mode 100644 mobile/assets/images/landing/accessories.jpg create mode 100644 mobile/assets/images/landing/clothing.jpg create mode 100644 mobile/assets/images/landing/dorm.jpg create mode 100644 mobile/assets/images/landing/electronics.jpg create mode 100644 mobile/assets/images/landing/goods.jpg create mode 100644 mobile/assets/images/landing/rave.jpg create mode 100644 mobile/assets/images/landing/school-supplies.jpg create mode 100644 mobile/assets/images/landing/services.png create mode 100644 mobile/assets/images/landing/sublease.jpg create mode 100644 mobile/assets/images/landing/uic.webp create mode 100644 mobile/assets/images/landing/uiuc.jpg create mode 100644 mobile/components/CampusMap.tsx create mode 100644 mobile/components/GlobalHeader.tsx create mode 100644 mobile/components/TransactionRow.tsx create mode 100644 mobile/components/magicui/Globe.tsx create mode 100644 mobile/components/magicui/OrbitingCircles.tsx delete mode 100644 mobile/data/mock.ts create mode 100644 mobile/data/uicBuildings.ts delete mode 100644 mobile/hooks/useConversations.ts delete mode 100644 mobile/hooks/useListings.ts create mode 100644 mobile/hooks/useWarmUpBrowser.ts create mode 100644 mobile/test-clerk.js diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 474a377..24a257c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -41,6 +41,8 @@ model User { followers User[] @relation("UserFollows") following User[] @relation("UserFollows") + offersMade Offer[] @relation("OffersMade") + notificationsReceived Notification[] @relation("NotificationsReceived") notificationsSent Notification[] @relation("NotificationsSent") @@ -91,6 +93,7 @@ model Listing { messages Message[] reports Report[] transactions Transaction[] + offers Offer[] } model ListingImage { @@ -324,6 +327,13 @@ enum MeetupStatus { CANCELLED } +enum OfferStatus { + PENDING + ACCEPTED + REJECTED + CANCELLED +} + enum NotificationType { FOLLOW LIKE @@ -354,4 +364,18 @@ model Notification { enum Role { USER ADMIN + MODERATOR +} + +model Offer { + id String @id @default(uuid()) + price Float + status OfferStatus @default(PENDING) + buyerId String + listingId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + buyer User @relation("OffersMade", fields: [buyerId], references: [id]) + listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade) } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 62be542..ca1bb36 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -22,6 +22,7 @@ import { ReportsModule } from './modules/reports/reports.module'; import { PaymentsModule } from './modules/payments/payments.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { ReviewsModule } from './modules/reviews/reviews.module'; +import { OffersModule } from './modules/offers/offers.module'; import { AdminModule } from './modules/admin/admin.module'; import { APP_INTERCEPTOR } from '@nestjs/core'; import { S3PresignInterceptor } from './common/interceptors/s3-presign.interceptor'; @@ -49,6 +50,7 @@ import { S3PresignInterceptor } from './common/interceptors/s3-presign.intercept NotificationsModule, ReviewsModule, AdminModule, + OffersModule, CacheModule.registerAsync({ isGlobal: true, imports: [ConfigModule], diff --git a/backend/src/modules/chat/chat.module.ts b/backend/src/modules/chat/chat.module.ts index d1d193c..1d22928 100644 --- a/backend/src/modules/chat/chat.module.ts +++ b/backend/src/modules/chat/chat.module.ts @@ -12,6 +12,6 @@ import { ChatGateway } from './chat.gateway'; ], providers: [ChatService, ChatGateway], controllers: [ChatController], - exports: [ChatGateway], + exports: [ChatGateway, ChatService], }) export class ChatModule {} diff --git a/backend/src/modules/offers/dto/create-offer.dto.ts b/backend/src/modules/offers/dto/create-offer.dto.ts new file mode 100644 index 0000000..966e98a --- /dev/null +++ b/backend/src/modules/offers/dto/create-offer.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; + +export class CreateOfferDto { + @IsString() + @IsNotEmpty() + listingId: string; + + @IsNumber() + @Min(1) + price: number; +} diff --git a/backend/src/modules/offers/offers.controller.ts b/backend/src/modules/offers/offers.controller.ts new file mode 100644 index 0000000..b5ccd34 --- /dev/null +++ b/backend/src/modules/offers/offers.controller.ts @@ -0,0 +1,53 @@ +import { Controller, Post, Get, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common'; +import { OffersService } from './offers.service'; +import { CreateOfferDto } from './dto/create-offer.dto'; +import { ClerkAuthGuard } from '../../common/guards/clerk-auth.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; + +@Controller('offers') +@UseGuards(ClerkAuthGuard) +export class OffersController { + constructor(private readonly offersService: OffersService) { } + + @Post() + createOffer( + @CurrentUser() user: any, + @Body() createOfferDto: CreateOfferDto + ) { + return this.offersService.createOffer(user.clerkUserId, createOfferDto); + } + + @Get('me/sent') + getOffersSent(@CurrentUser() user: any) { + return this.offersService.getOffersSent(user.clerkUserId); + } + + @Get('me/received') + getOffersReceived(@CurrentUser() user: any) { + return this.offersService.getOffersReceived(user.clerkUserId); + } + + @Patch(':id/accept') + acceptOffer( + @CurrentUser() user: any, + @Param('id') id: string + ) { + return this.offersService.acceptOffer(user.clerkUserId, id); + } + + @Patch(':id/reject') + rejectOffer( + @CurrentUser() user: any, + @Param('id') id: string + ) { + return this.offersService.rejectOffer(user.clerkUserId, id); + } + + @Delete(':id') + cancelOffer( + @CurrentUser() user: any, + @Param('id') id: string + ) { + return this.offersService.cancelOffer(user.clerkUserId, id); + } +} diff --git a/backend/src/modules/offers/offers.module.ts b/backend/src/modules/offers/offers.module.ts new file mode 100644 index 0000000..bfc4846 --- /dev/null +++ b/backend/src/modules/offers/offers.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { OffersService } from './offers.service'; +import { OffersController } from './offers.controller'; +import { ChatModule } from '../chat/chat.module'; + +@Module({ + imports: [ChatModule], + providers: [OffersService], + controllers: [OffersController], + exports: [OffersService], +}) +export class OffersModule {} diff --git a/backend/src/modules/offers/offers.service.ts b/backend/src/modules/offers/offers.service.ts new file mode 100644 index 0000000..1628420 --- /dev/null +++ b/backend/src/modules/offers/offers.service.ts @@ -0,0 +1,244 @@ +import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common'; +import { PrismaService } from '../../database/prisma.service'; +import { CreateOfferDto } from './dto/create-offer.dto'; +import { OfferStatus, ListingStatus } from '@prisma/client'; +import { ChatService } from '../chat/chat.service'; + +@Injectable() +export class OffersService { + constructor( + private prisma: PrismaService, + private chatService: ChatService, + ) { } + + async createOffer(clerkUserId: string, createOfferDto: CreateOfferDto) { + // Find user + const dbUser = await this.prisma.user.findUnique({ + where: { clerkUserId } + }); + if (!dbUser) throw new NotFoundException('User not found'); + + // Find listing + const listing = await this.prisma.listing.findUnique({ + where: { id: createOfferDto.listingId } + }); + if (!listing) throw new NotFoundException('Listing not found'); + + if (listing.status !== ListingStatus.ACTIVE) { + throw new BadRequestException('Listing is no longer active'); + } + + if (listing.sellerId === dbUser.id) { + throw new BadRequestException('Cannot make an offer on your own listing'); + } + + if (createOfferDto.price > listing.price) { + throw new BadRequestException('Offer cannot be higher than listing price'); + } + + // Check if there is already a pending offer from this user + const existingOffer = await this.prisma.offer.findFirst({ + where: { + buyerId: dbUser.id, + listingId: listing.id, + status: OfferStatus.PENDING, + } + }); + + if (existingOffer) { + throw new BadRequestException('You already have a pending offer for this listing'); + } + + // Create the offer + const offer = await this.prisma.offer.create({ + data: { + price: createOfferDto.price, + listingId: listing.id, + buyerId: dbUser.id, + status: OfferStatus.PENDING, + }, + include: { + listing: { select: { title: true, sellerId: true } } + } + }); + + // Chat integration: Find or create conversation and send system message + try { + // Find existing conversation between buyer and seller for this listing + let conversation = await this.prisma.conversation.findFirst({ + where: { + listingId: listing.id, + members: { + every: { + userId: { in: [dbUser.id, listing.sellerId] } + } + } + } + }); + + if (!conversation) { + conversation = await this.prisma.conversation.create({ + data: { + listingId: listing.id, + members: { + create: [ + { userId: dbUser.id }, + { userId: listing.sellerId } + ] + } + } + }); + } + + // Send a system message in the chat + await this.chatService.createMessage( + clerkUserId, + conversation.id, + `I have made an offer of $${offer.price} for this item.` + ); + } catch (error) { + console.error('Failed to send offer chat message:', error); + } + + return offer; + } + + async getOffersSent(clerkUserId: string) { + const dbUser = await this.prisma.user.findUnique({ + where: { clerkUserId } + }); + if (!dbUser) throw new NotFoundException('User not found'); + + return this.prisma.offer.findMany({ + where: { buyerId: dbUser.id }, + include: { + listing: { + include: { images: true } + } + }, + orderBy: { createdAt: 'desc' } + }); + } + + async getOffersReceived(clerkUserId: string) { + const dbUser = await this.prisma.user.findUnique({ + where: { clerkUserId } + }); + if (!dbUser) throw new NotFoundException('User not found'); + + return this.prisma.offer.findMany({ + where: { + listing: { sellerId: dbUser.id } + }, + include: { + listing: { + include: { images: true } + }, + buyer: { + select: { id: true, name: true, username: true, avatarUrl: true } + } + }, + orderBy: { createdAt: 'desc' } + }); + } + + async acceptOffer(clerkUserId: string, offerId: string) { + const dbUser = await this.prisma.user.findUnique({ + where: { clerkUserId } + }); + if (!dbUser) throw new NotFoundException('User not found'); + + const offer = await this.prisma.offer.findUnique({ + where: { id: offerId }, + include: { listing: true } + }); + + if (!offer) throw new NotFoundException('Offer not found'); + + if (offer.listing.sellerId !== dbUser.id) { + throw new ForbiddenException('You do not have permission to accept this offer'); + } + + if (offer.status !== OfferStatus.PENDING) { + throw new BadRequestException('Offer is no longer pending'); + } + + // Use transaction to accept this offer and reject all other pending offers for this listing + await this.prisma.$transaction(async (prisma) => { + // Accept the target offer + await prisma.offer.update({ + where: { id: offerId }, + data: { status: OfferStatus.ACCEPTED } + }); + + // Reject all other pending offers for the same listing + await prisma.offer.updateMany({ + where: { + listingId: offer.listingId, + status: OfferStatus.PENDING, + id: { not: offerId } + }, + data: { status: OfferStatus.REJECTED } + }); + }); + + return { success: true, message: 'Offer accepted successfully' }; + } + + async rejectOffer(clerkUserId: string, offerId: string) { + const dbUser = await this.prisma.user.findUnique({ + where: { clerkUserId } + }); + if (!dbUser) throw new NotFoundException('User not found'); + + const offer = await this.prisma.offer.findUnique({ + where: { id: offerId }, + include: { listing: true } + }); + + if (!offer) throw new NotFoundException('Offer not found'); + + if (offer.listing.sellerId !== dbUser.id) { + throw new ForbiddenException('You do not have permission to reject this offer'); + } + + if (offer.status !== OfferStatus.PENDING) { + throw new BadRequestException('Offer is no longer pending'); + } + + const updatedOffer = await this.prisma.offer.update({ + where: { id: offerId }, + data: { status: OfferStatus.REJECTED } + }); + + return updatedOffer; + } + + async cancelOffer(clerkUserId: string, offerId: string) { + const dbUser = await this.prisma.user.findUnique({ + where: { clerkUserId } + }); + if (!dbUser) throw new NotFoundException('User not found'); + + const offer = await this.prisma.offer.findUnique({ + where: { id: offerId } + }); + + if (!offer) throw new NotFoundException('Offer not found'); + + if (offer.buyerId !== dbUser.id) { + throw new ForbiddenException('You do not have permission to cancel this offer'); + } + + if (offer.status !== OfferStatus.PENDING) { + throw new BadRequestException('Only pending offers can be cancelled'); + } + + const updatedOffer = await this.prisma.offer.update({ + where: { id: offerId }, + data: { status: OfferStatus.CANCELLED } + }); + + return updatedOffer; + } +} diff --git a/backend/src/modules/payments/payments.service.ts b/backend/src/modules/payments/payments.service.ts index c9295c2..eaea045 100644 --- a/backend/src/modules/payments/payments.service.ts +++ b/backend/src/modules/payments/payments.service.ts @@ -103,7 +103,17 @@ export class PaymentsService { throw new BadRequestException('Seller has not connected their bank account yet'); } - const amountCents = Math.round(listing.price * 100); + // Check for an accepted offer + const acceptedOffer = await this.prisma.offer.findFirst({ + where: { + listingId: listing.id, + buyerId: buyer.id, + status: 'ACCEPTED' + } + }); + + const finalPrice = acceptedOffer ? acceptedOffer.price : listing.price; + const amountCents = Math.round(finalPrice * 100); const paymentIntent = await this.stripe.paymentIntents.create({ amount: amountCents, diff --git a/backend/src/modules/transactions/transactions.service.ts b/backend/src/modules/transactions/transactions.service.ts index f41098d..6c1997d 100644 --- a/backend/src/modules/transactions/transactions.service.ts +++ b/backend/src/modules/transactions/transactions.service.ts @@ -35,6 +35,17 @@ export class TransactionsService { data: { status: 'RESERVED' } }); + // Check for an accepted offer + const acceptedOffer = await this.prisma.offer.findFirst({ + where: { + listingId: listing.id, + buyerId: buyer.id, + status: 'ACCEPTED' + } + }); + + const finalPrice = acceptedOffer ? acceptedOffer.price : listing.price; + const transaction = await this.prisma.transaction.create({ data: { listingId: listing.id, @@ -43,7 +54,7 @@ export class TransactionsService { paymentMethod: 'DIRECT', paymentStatus: 'UNPAID_EXTERNAL', orderStatus: 'PENDING_MEETUP', - amount: Math.round(listing.price * 100), + amount: Math.round(finalPrice * 100), } }); diff --git a/frontend/app/listings/[id]/page.tsx b/frontend/app/listings/[id]/page.tsx index e579996..99ce025 100644 --- a/frontend/app/listings/[id]/page.tsx +++ b/frontend/app/listings/[id]/page.tsx @@ -107,6 +107,10 @@ export default function ListingDetailPage() { const [showPaymentModal, setShowPaymentModal] = useState(false); const [isReserving, setIsReserving] = useState(false); + const [showOfferModal, setShowOfferModal] = useState(false); + const [offerPrice, setOfferPrice] = useState(""); + const [isSubmittingOffer, setIsSubmittingOffer] = useState(false); + const [existingOffer, setExistingOffer] = useState(null); useEffect(() => { if (!isLoaded) return; @@ -147,6 +151,16 @@ export default function ListingDetailPage() { (item: any) => item.id === params.id, ); setIsWishlisted(isLiked); + // Fetch user's existing offer + try { + const offersRes = await axios.get(`http://127.0.0.1:3000/offers/me/sent`, { headers }); + const offer = offersRes.data.find((o: any) => o.listingId === params.id && o.status !== 'CANCELLED'); + if (offer) { + setExistingOffer(offer); + } + } catch (err) { + console.error("Failed to fetch existing offer", err); + } } } catch (err: any) { setError(err.message || "Failed to fetch listing"); @@ -248,6 +262,45 @@ export default function ListingDetailPage() { } }; + const handleMakeOffer = async () => { + if (!listing || !offerPrice || isNaN(Number(offerPrice)) || Number(offerPrice) <= 0) { + toast.error("Please enter a valid offer price"); + return; + } + if (Number(offerPrice) >= listing.price) { + toast.error("Offer must be less than the asking price"); + return; + } + + setIsSubmittingOffer(true); + try { + const token = await getToken(); + const res = await axios.post(`http://127.0.0.1:3000/offers`, { + listingId: listing.id, + price: Number(offerPrice) + }, { headers: { Authorization: `Bearer ${token}` } }); + toast.success("Offer submitted successfully! The seller has been notified."); + setExistingOffer(res.data); + setShowOfferModal(false); + } catch (err: any) { + toast.error(err.response?.data?.message || "Failed to submit offer"); + } finally { + setIsSubmittingOffer(false); + } + }; + + const handleCancelOffer = async () => { + if (!existingOffer) return; + try { + const token = await getToken(); + await axios.delete(`http://127.0.0.1:3000/offers/${existingOffer.id}`, { headers: { Authorization: `Bearer ${token}` } }); + toast.success("Offer cancelled."); + setExistingOffer(null); + } catch (err: any) { + toast.error("Failed to cancel offer"); + } + }; + const handleProtectedReservation = () => { if (!isSignedIn) return router.push("/sign-in"); toast("Redirecting to Secure Checkout...", { @@ -669,6 +722,46 @@ export default function ListingDetailPage() { {isReserving ? "Reserving..." : "Reserve Your Order"} + {existingOffer ? ( +
+
+ Your Offer: ${existingOffer.price} + + {existingOffer.status} + +
+

+ {existingOffer.status === 'PENDING' ? "Waiting for the seller to respond." : + existingOffer.status === 'ACCEPTED' ? "Your offer was accepted! You can now reserve your order at this price." : + "Your offer was rejected."} +

+ {existingOffer.status === 'PENDING' && ( + + )} +
+ ) : ( + + )} +
+ + + +
); } diff --git a/frontend/app/purchase-history/page.tsx b/frontend/app/purchase-history/page.tsx index 5c28c95..bc23836 100644 --- a/frontend/app/purchase-history/page.tsx +++ b/frontend/app/purchase-history/page.tsx @@ -16,6 +16,8 @@ export default function PurchaseHistoryPage() { const [activeFilter, setActiveFilter] = useState("All"); const [loading, setLoading] = useState(true); const [history, setHistory] = useState({ buying: [], selling: [] }); + const [offers, setOffers] = useState({ sent: [], received: [] }); + const [activeActionOffer, setActiveActionOffer] = useState(null); const filters = ["All", "Completed", "Cancelled", "Reserved"]; @@ -24,15 +26,18 @@ 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}` }, - }, - ); - setHistory(res.data); + const [historyRes, offersSentRes, offersReceivedRes] = await Promise.all([ + axios.get(`http://127.0.0.1:3000/transactions/history`, { headers: { Authorization: `Bearer ${token}` } }), + axios.get(`http://127.0.0.1:3000/offers/me/sent`, { headers: { Authorization: `Bearer ${token}` } }), + axios.get(`http://127.0.0.1:3000/offers/me/received`, { headers: { Authorization: `Bearer ${token}` } }), + ]); + setHistory(historyRes.data); + setOffers({ + sent: offersSentRes.data, + received: offersReceivedRes.data, + }); } catch (error) { - console.error("Failed to fetch history", error); + console.error("Failed to fetch data", error); } finally { setLoading(false); } @@ -50,6 +55,12 @@ export default function PurchaseHistoryPage() { } else if (type === "selling") { title = "No sales history yet."; description = "Once you finalize a sale, it will be recorded here."; + } else if (type === "offers-sent") { + title = "No offers sent."; + description = "When you make an offer on an item, it will appear here."; + } else if (type === "offers-received") { + title = "No offers received."; + description = "When someone makes an offer on your listings, it will appear here."; } return ( @@ -137,6 +148,126 @@ export default function PurchaseHistoryPage() { ); }; + const handleOfferAction = async (offerId: string, action: 'accept' | 'reject' | 'cancel') => { + setActiveActionOffer(offerId); + try { + const token = await getToken(); + if (action === 'cancel') { + await axios.delete(`http://127.0.0.1:3000/offers/${offerId}`, { headers: { Authorization: `Bearer ${token}` } }); + } else { + await axios.patch(`http://127.0.0.1:3000/offers/${offerId}/${action}`, {}, { headers: { Authorization: `Bearer ${token}` } }); + } + + // Refresh offers + const [offersSentRes, offersReceivedRes] = await Promise.all([ + axios.get(`http://127.0.0.1:3000/offers/me/sent`, { headers: { Authorization: `Bearer ${token}` } }), + axios.get(`http://127.0.0.1:3000/offers/me/received`, { headers: { Authorization: `Bearer ${token}` } }), + ]); + setOffers({ + sent: offersSentRes.data, + received: offersReceivedRes.data, + }); + } catch (error) { + console.error(`Failed to ${action} offer`, error); + } finally { + setActiveActionOffer(null); + } + }; + + const renderOffers = (offersList: any[], type: 'sent' | 'received') => { + if (offersList.length === 0) return renderEmptyState(`offers-${type}`); + + return ( +
+ {offersList.map((offer) => ( +
+
+ + {offer.listing.images?.[0]?.url && ( + {offer.listing.title} + )} + +
+ +

{offer.listing.title}

+ + ${offer.price} +
+
+ +
+
+ +

{offer.listing.title}

+ +
+ + Offer: ${offer.price} + + + {offer.status} + +
+ {type === 'received' && ( +

+ From: {offer.buyer.name || offer.buyer.username} +

+ )} +
+ + {offer.status === 'PENDING' && ( +
+ {type === 'received' ? ( + <> + + + + ) : ( + + )} +
+ )} +
+
+ ))} +
+ ); + }; + return (
@@ -149,10 +280,10 @@ export default function PurchaseHistoryPage() {

- Purchase History + Transactions & Offers

- Review your completed, reserved, and cancelled orders. + Review your completed orders, active reservations, and offers.

@@ -172,19 +303,31 @@ export default function PurchaseHistoryPage() { className="w-full" onValueChange={setActiveTab} > - + Buying History Selling History + + Offers Sent + + + Offers Received + {/* Filters */} @@ -222,6 +365,14 @@ export default function PurchaseHistoryPage() { {renderTransactions(history.selling)} + + + {renderOffers(offers.sent, 'sent')} + + + + {renderOffers(offers.received, 'received')} + )} diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index ec01460..b00f593 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { Platform, StyleSheet, Text, View } from 'react-native'; +import { Platform, StyleSheet, Text } from 'react-native'; import { Tabs } from 'expo-router'; -import { Compass, Heart, MessageCircle, Sparkles, User } from 'lucide-react-native'; +import { Home, MessageCircle, Sparkles, User, Users } from 'lucide-react-native'; import { palette, spacing, type } from '@/theme'; function TabBarLabel({ label, focused }: { label: string; focused: boolean }) { @@ -30,43 +30,43 @@ export default function TabsLayout() { }} > ( - + ), - tabBarLabel: ({ focused }) => , + tabBarLabel: ({ focused }) => , }} /> ( - + ), - tabBarLabel: ({ focused }) => , + tabBarLabel: ({ focused }) => , }} /> ( - + ), - tabBarLabel: ({ focused }) => , + tabBarLabel: ({ focused }) => , }} /> ( - + ), - tabBarLabel: ({ focused }) => , + tabBarLabel: ({ focused }) => , }} /> ([]); + const [loading, setLoading] = useState(true); + + const load = useCallback(() => { + chatApi + .inbox() + .then(setData) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + useFocusEffect( + useCallback(() => { + load(); + }, [load]), + ); + + useEffect(() => { + const socket = getSocket(); + if (!socket) return; + const onMessage = () => load(); + socket.on('receive_message', onMessage); + return () => { + socket.off('receive_message', onMessage); + }; + }, [load]); + + const myClerkId = user?.id; return ( @@ -37,6 +67,7 @@ export default function ChatTab() { renderItem={({ item }) => ( router.push(`/chat/${item.id}` as any)} /> )} @@ -46,25 +77,39 @@ export default function ChatTab() { ); } +function otherMember(conversation: Conversation, myClerkId?: string): User | undefined { + const members = conversation.members ?? []; + const other = members.find((m) => m.user.clerkUserId !== myClerkId); + return (other ?? members[0])?.user; +} + function ConversationRow({ conversation, + myClerkId, onPress, }: { conversation: Conversation; + myClerkId?: string; onPress: () => void; }) { - const other = conversation.participants[1] ?? conversation.participants[0]; - const last = conversation.lastMessage; + const other = otherMember(conversation, myClerkId); + const last: Message | undefined = conversation.messages?.[0]; + const unread = !!last && !last.isRead && last.sender?.clerkUserId !== myClerkId; + return ( [styles.row, pressed && { backgroundColor: palette.card }]} > - + - {other.name} + {other?.name ?? other?.username ?? 'Unknown'} {formatRelative(conversation.updatedAt)} @@ -74,13 +119,13 @@ function ConversationRow({ - {last?.content ?? 'No messages yet'} + {last?.content || (last?.imageUrls?.length ? 'Sent a photo' : 'No messages yet')} - {conversation.unread ? : null} + {unread ? : null} diff --git a/mobile/app/(tabs)/community.tsx b/mobile/app/(tabs)/community.tsx new file mode 100644 index 0000000..0070d83 --- /dev/null +++ b/mobile/app/(tabs)/community.tsx @@ -0,0 +1,519 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { Image } from 'expo-image'; +import * as ImagePicker from 'expo-image-picker'; +import { useFocusEffect, useRouter } from 'expo-router'; +import { useUser } from '@clerk/clerk-expo'; +import { Heart, ImagePlus, MessageCircle, Send, Trash2, X } from 'lucide-react-native'; +import { palette, radius, spacing, type } from '@/theme'; +import { Screen, Avatar, EmptyState, Input, Pill } from '@/components/ui'; +import { postsApi, getImageUrl } from '@/lib/api'; +import { getSocket } from '@/lib/socket'; +import { formatRelative } from '@/lib/format'; +import type { Post, PostComment, PostType } from '@/lib/types'; + +const POST_TYPES: { key: PostType | 'ALL'; label: string }[] = [ + { key: 'ALL', label: 'All' }, + { key: 'DISCUSSION', label: 'Discussion' }, + { key: 'EVENT', label: 'Event' }, + { key: 'CHECK_IN', label: 'Check-in' }, + { key: 'LOOKING_FOR', label: 'Looking for' }, +]; + +export default function CommunityTab() { + const { user } = useUser(); + const [posts, setPosts] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [filter, setFilter] = useState('ALL'); + + const load = useCallback(async () => { + try { + const data = await postsApi.list(filter === 'ALL' ? undefined : filter); + setPosts(data); + } catch { + // keep whatever we have + } + }, [filter]); + + useFocusEffect( + useCallback(() => { + load().finally(() => setLoading(false)); + }, [load]), + ); + + useEffect(() => { + const socket = getSocket(); + if (!socket) return; + const onLike = ({ postId, likeCount }: { postId: string; likeCount: number }) => { + setPosts((prev) => + prev.map((p) => (p.id === postId ? { ...p, _count: { ...p._count, likes: likeCount } } : p)), + ); + }; + const onComment = ({ postId, commentCount }: { postId: string; commentCount: number }) => { + setPosts((prev) => + prev.map((p) => + p.id === postId ? { ...p, _count: { ...p._count, comments: commentCount } } : p, + ), + ); + }; + socket.on('post_like_update', onLike); + socket.on('post_comment_added', onComment); + return () => { + socket.off('post_like_update', onLike); + socket.off('post_comment_added', onComment); + }; + }, []); + + const onRefresh = async () => { + setRefreshing(true); + await load(); + setRefreshing(false); + }; + + return ( + + + CAMPUS FEED + Community + + + + {POST_TYPES.map((t) => ( + setFilter(t.key)} + /> + ))} + + + {loading ? ( + + + + ) : ( + p.id} + contentContainerStyle={{ paddingBottom: spacing.xxl }} + refreshControl={ + + } + ListHeaderComponent={} + ListEmptyComponent={ + + } + ItemSeparatorComponent={() => } + renderItem={({ item }) => ( + + )} + /> + )} + + ); +} + +function Composer({ onPosted }: { onPosted: () => void }) { + const [content, setContent] = useState(''); + const [postType, setPostType] = useState('DISCUSSION'); + const [images, setImages] = useState([]); + const [busy, setBusy] = useState(false); + + const pickImages = async () => { + const res = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsMultipleSelection: true, + selectionLimit: 4 - images.length, + quality: 0.8, + }); + if (!res.canceled) { + setImages((prev) => [...prev, ...res.assets.map((a) => a.uri)].slice(0, 4)); + } + }; + + const submit = async () => { + const text = content.trim(); + if (!text || busy) return; + setBusy(true); + try { + const form = new FormData(); + form.append('content', text); + form.append('postType', postType); + images.forEach((uri, i) => { + form.append('images', { + uri, + name: `post-${i}.jpg`, + type: 'image/jpeg', + } as unknown as Blob); + }); + await postsApi.create(form); + setContent(''); + setImages([]); + onPosted(); + } catch { + // surface via input state; keep draft + } finally { + setBusy(false); + } + }; + + return ( + + + {images.length > 0 ? ( + + {images.map((uri) => ( + + + setImages((prev) => prev.filter((u) => u !== uri))} + style={styles.composerImageRemove} + hitSlop={8} + > + + + + ))} + + ) : null} + + + + {POST_TYPES.filter((t) => t.key !== 'ALL').map((t) => ( + setPostType(t.key as PostType)} + /> + ))} + + + + + + + + [ + styles.postBtn, + (!content.trim() || busy) && { opacity: 0.4 }, + pressed && { opacity: 0.8 }, + ]} + > + {busy ? ( + + ) : ( + Post + )} + + + + ); +} + +function PostCard({ + post, + myClerkId, + onChanged, +}: { + post: Post; + myClerkId?: string; + onChanged: () => void; +}) { + const router = useRouter(); + const [liked, setLiked] = useState( + (post.likes ?? []).length > 0, + ); + const [likeCount, setLikeCount] = useState(post._count.likes); + const [showComments, setShowComments] = useState(false); + const [comments, setComments] = useState([]); + const [commentDraft, setCommentDraft] = useState(''); + const [commentCount, setCommentCount] = useState(post._count.comments); + + useEffect(() => { + setLikeCount(post._count.likes); + setCommentCount(post._count.comments); + }, [post]); + + const mine = post.author?.clerkUserId === myClerkId; + const typeMeta = POST_TYPES.find((t) => t.key === post.postType); + + const toggleLike = async () => { + // optimistic + setLiked((v) => !v); + setLikeCount((n) => (liked ? n - 1 : n + 1)); + try { + const res = await postsApi.toggleLike(post.id); + setLiked(res.liked); + setLikeCount(res.likeCount); + } catch { + setLiked((v) => !v); + setLikeCount(post._count.likes); + } + }; + + const loadComments = async () => { + if (!showComments) { + try { + setComments(await postsApi.comments(post.id)); + } catch { + // ignore + } + } + setShowComments((v) => !v); + }; + + const addComment = async () => { + const text = commentDraft.trim(); + if (!text) return; + setCommentDraft(''); + try { + const res = await postsApi.addComment(post.id, text); + setComments((prev) => [...prev, res.comment]); + setCommentCount(res.commentCount); + } catch { + setCommentDraft(text); + } + }; + + const remove = async () => { + try { + await postsApi.delete(post.id); + onChanged(); + } catch { + // ignore + } + }; + + return ( + + + + post.author?.clerkUserId && + router.push(`/profile/${post.author.clerkUserId}` as any) + } + > + + + + {post.author?.name ?? 'Anonymous'} + + + {typeMeta?.label ?? post.postType} · {formatRelative(post.createdAt)} + + + + {mine ? ( + + + + ) : null} + + + + {post.content} + + + {post.imageUrls && post.imageUrls.length > 0 ? ( + + {post.imageUrls.map((url) => ( + + ))} + + ) : null} + + + + + + {likeCount} + + + + + {commentCount} + + + + {showComments ? ( + + {comments.map((c) => ( + + + + + {c.author?.name ?? 'Anonymous'} · {formatRelative(c.createdAt)} + + + {c.content} + + + + ))} + + + + + } + /> + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + header: { + paddingHorizontal: spacing.base, + paddingTop: spacing.sm, + paddingBottom: spacing.sm, + }, + filterRow: { + flexDirection: 'row', + gap: spacing.xs, + paddingHorizontal: spacing.base, + paddingBottom: spacing.sm, + }, + loading: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + separator: { height: 1, backgroundColor: palette.hairlineSoft }, + composer: { + padding: spacing.base, + borderTopWidth: 1, + borderBottomWidth: 1, + borderColor: palette.hairline, + backgroundColor: palette.card, + }, + composerRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.sm, + }, + composerActions: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: spacing.sm, + }, + composerImageWrap: { position: 'relative', marginRight: spacing.xs }, + composerImage: { + width: 72, + height: 72, + borderRadius: radius.md, + backgroundColor: palette.surfaceElevated, + }, + composerImageRemove: { + position: 'absolute', + top: 4, + right: 4, + width: 20, + height: 20, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: palette.scrimStrong, + }, + postBtn: { + backgroundColor: palette.accent, + borderRadius: radius.md, + paddingHorizontal: spacing.md, + height: 36, + alignItems: 'center', + justifyContent: 'center', + }, + post: { + paddingHorizontal: spacing.base, + paddingVertical: spacing.base, + }, + postHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + postAuthor: { flexDirection: 'row', alignItems: 'center', flex: 1 }, + postImage: { + width: 180, + height: 180, + borderRadius: radius.md, + marginRight: spacing.xs, + backgroundColor: palette.surfaceElevated, + }, + postActions: { + flexDirection: 'row', + gap: spacing.lg, + marginTop: spacing.sm, + }, + postAction: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xxs, + }, + commentsWrap: { + marginTop: spacing.sm, + paddingTop: spacing.sm, + borderTopWidth: 1, + borderTopColor: palette.hairlineSoft, + gap: spacing.sm, + }, + commentRow: { flexDirection: 'row', alignItems: 'flex-start' }, + commentComposer: { flexDirection: 'row', marginTop: spacing.xs }, +}); diff --git a/mobile/app/(tabs)/home.tsx b/mobile/app/(tabs)/home.tsx new file mode 100644 index 0000000..bae2136 --- /dev/null +++ b/mobile/app/(tabs)/home.tsx @@ -0,0 +1,285 @@ +import React, { useCallback, useState } from 'react'; +import { + ActivityIndicator, + Image, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { useFocusEffect, useRouter } from 'expo-router'; +import { ChevronRight, Eye, Flame } from 'lucide-react-native'; +import { palette, radius, spacing, type } from '@/theme'; +import { Screen } from '@/components/ui'; +import { GlobalHeader } from '@/components/GlobalHeader'; +import { CategoryRail } from '@/components/CategoryRail'; +import { ListingCard } from '@/components/ListingCard'; +import { listingsApi } from '@/lib/api'; +import type { Listing } from '@/lib/types'; + +interface Sections { + recommended: Listing[]; + hot: Listing[]; + viewed: Listing[]; + latest: Listing[]; +} + +export default function HomeTab() { + const router = useRouter(); + const [sections, setSections] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const load = useCallback(async () => { + const [recommended, hot, viewed, latest] = await Promise.all([ + listingsApi.recommended().catch(() => [] as Listing[]), + listingsApi.hot().catch(() => [] as Listing[]), + listingsApi.viewed().catch(() => [] as Listing[]), + listingsApi.all().catch(() => [] as Listing[]), + ]); + setSections({ recommended, hot, viewed, latest }); + }, []); + + useFocusEffect( + useCallback(() => { + load().finally(() => setLoading(false)); + }, [load]), + ); + + const onRefresh = async () => { + setRefreshing(true); + await load(); + setRefreshing(false); + }; + + const newListings = [...(sections?.latest ?? [])].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + return ( + + + + + {loading ? ( + + + + ) : ( + + } + > + {/* Hero banner — mirrors web /home */} + + + Trying to pass down your items? + + Sell your items quickly and safely to other students on campus. + + router.push('/add-product')} + style={({ pressed }) => [styles.sellBtn, pressed && { opacity: 0.9 }]} + > + Sell now + + + + + + + + For you ! + + } + listings={ + (sections?.recommended?.length ?? 0) > 0 + ? sections!.recommended + : sections?.latest ?? [] + } + onViewMore={() => router.push('/listings' as any)} + /> + + Hot @ UIC + + + } + listings={sections?.hot ?? []} + onViewMore={() => router.push('/listings' as any)} + /> + + You've viewed + + + } + listings={sections?.viewed ?? []} + /> + New Listings} + listings={newListings} + onViewMore={() => router.push('/listings' as any)} + /> + + + )} + + ); +} + +function ProductSection({ + title, + listings, + onViewMore, +}: { + title: React.ReactNode; + listings: Listing[]; + onViewMore?: () => void; +}) { + if (listings.length === 0) return null; + return ( + + + {typeof title === 'string' ? ( + {title} + ) : ( + title + )} + {onViewMore ? ( + + View More + + + ) : null} + + + {listings.map((l) => ( + + + + ))} + + {onViewMore ? ( + + View More + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + loading: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + heroBanner: { + marginHorizontal: spacing.base, + marginTop: spacing.base, + marginBottom: spacing.lg, + borderRadius: 20, + overflow: 'hidden', + borderWidth: 1, + borderColor: palette.border, + backgroundColor: palette.heroBanner, + }, + heroLeft: { + padding: spacing.lg, + }, + heroHeadline: { + ...type.titleMd, + fontSize: 24, + lineHeight: 30, + fontFamily: type.titleMd.fontFamily, + color: palette.foreground, + marginBottom: spacing.sm, + }, + heroBody: { + ...type.body, + color: palette.mutedForeground, + marginBottom: spacing.lg, + maxWidth: 320, + }, + sellBtn: { + alignSelf: 'flex-start', + backgroundColor: palette.foreground, + paddingHorizontal: spacing.lg, + paddingVertical: 10, + borderRadius: radius.lg, + }, + sellBtnLabel: { + ...type.button, + color: palette.background, + fontFamily: type.titleMd.fontFamily, + }, + heroImage: { + width: '100%', + height: 180, + }, + main: { + paddingHorizontal: spacing.base, + gap: spacing.section, + }, + section: { marginBottom: spacing.lg }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.base, + }, + sectionTitle: { + ...type.displaySm, + color: palette.foreground, + letterSpacing: -0.4, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + }, + viewMore: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + }, + viewMoreText: { + ...type.body, + color: palette.link, + }, + rail: { + gap: spacing.base, + paddingBottom: spacing.sm, + }, + viewMoreMobile: { + marginTop: spacing.base, + paddingVertical: spacing.sm, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: palette.card, + borderWidth: 1, + borderColor: palette.border, + borderRadius: 14, + }, + viewMoreMobileText: { + ...type.body, + color: palette.foreground, + }, +}); diff --git a/mobile/app/(tabs)/listings.tsx b/mobile/app/(tabs)/listings.tsx deleted file mode 100644 index b6e3c2f..0000000 --- a/mobile/app/(tabs)/listings.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; -import { useRouter } from 'expo-router'; -import { Plus, Search } from 'lucide-react-native'; -import { palette, spacing, type } from '@/theme'; -import { Screen, Input, EmptyState } from '@/components/ui'; -import { ListingCard } from '@/components/ListingCard'; -import { CategoryRail } from '@/components/CategoryRail'; -import { useListings } from '@/hooks/useListings'; -import type { ListingCategory } from '@/lib/types'; - -export default function ListingsTab() { - const router = useRouter(); - const { data, loading, error } = useListings(); - const [category, setCategory] = useState('ALL'); - const [query, setQuery] = useState(''); - - const filtered = useMemo(() => { - return data.filter((l) => { - if (category !== 'ALL' && l.category !== category) return false; - if (!query) return true; - const q = query.toLowerCase(); - return ( - l.title.toLowerCase().includes(q) || - l.description.toLowerCase().includes(q) - ); - }); - }, [data, category, query]); - - return ( - - {/* Header */} - - - - ORBIT MARKETPLACE - - Find it on campus - - - router.push('/add-product')} - accessibilityLabel="Add a new listing" - accessibilityRole="button" - style={({ pressed }) => [styles.addBtn, pressed && { transform: [{ scale: 0.95 }] }]} - > - - - - - } - /> - - - - - {loading ? ( - - - - ) : filtered.length === 0 ? ( - { - setCategory('ALL'); - setQuery(''); - }} - /> - ) : ( - item.id} - numColumns={2} - columnWrapperStyle={styles.column} - contentContainerStyle={styles.list} - renderItem={({ item }) => ( - - - - )} - ListFooterComponent={ - error ? ( - - Showing demo listings — couldn’t reach Orbit ({error}) - - ) : null - } - /> - )} - - ); -} - -const styles = StyleSheet.create({ - headerWrap: { - paddingTop: spacing.sm, - borderBottomWidth: 1, - borderBottomColor: palette.hairline, - }, - headerRow: { - flexDirection: 'row', - alignItems: 'flex-end', - justifyContent: 'space-between', - paddingHorizontal: spacing.base, - paddingTop: spacing.xs, - }, - addBtn: { - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: palette.accent, - alignItems: 'center', - justifyContent: 'center', - }, - searchWrap: { - paddingHorizontal: spacing.base, - paddingTop: spacing.base, - }, - list: { - padding: spacing.base, - paddingBottom: spacing.xxl, - gap: spacing.base, - }, - column: { gap: spacing.base }, - cell: { flex: 1 }, - loadingWrap: { flex: 1, alignItems: 'center', justifyContent: 'center' }, - errorNote: { - color: palette.muted, - textAlign: 'center', - marginTop: spacing.lg, - }, -}); diff --git a/mobile/app/(tabs)/profile.tsx b/mobile/app/(tabs)/profile.tsx index 6b403f0..83ed32e 100644 --- a/mobile/app/(tabs)/profile.tsx +++ b/mobile/app/(tabs)/profile.tsx @@ -1,76 +1,101 @@ -import React from 'react'; -import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; -import { useRouter } from 'expo-router'; -import { ChevronRight, ExternalLink, LogOut, Settings, Wallet } from 'lucide-react-native'; -import { useAuth, useUser } from '@clerk/clerk-expo'; +import React, { useCallback, useState } from 'react'; +import { Alert, Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useFocusEffect, useRouter } from 'expo-router'; +import { + Bell, + Check, + ChevronRight, + Clock, + Heart, + LogOut, + Settings, + Shield, + Tag, + Wallet, +} from 'lucide-react-native'; +import { useAuth } from '@clerk/clerk-expo'; import { palette, spacing, type } from '@/theme'; import { Screen, Avatar, Card, Button, Divider } from '@/components/ui'; -import { mockUser, mockListings } from '@/data/mock'; import { ListingCard } from '@/components/ListingCard'; -import { clerkPublishableKey } from '@/lib/auth'; +import { listingsApi, paymentsApi, reviewsApi, usersApi, getImageUrl } from '@/lib/api'; +import { disconnectSocket } from '@/lib/socket'; +import type { Listing, ReviewsResponse, User } from '@/lib/types'; -const CLERK_ENABLED = !!clerkPublishableKey; - -interface Identity { - name: string; - email: string; - avatarUri?: string; - signOut: () => Promise; -} - -function useClerkIdentity(): Identity { - const { user } = useUser(); +export default function ProfileTab() { + const router = useRouter(); const { signOut } = useAuth(); - return { - name: user?.fullName ?? mockUser.name ?? 'You', - email: user?.primaryEmailAddress?.emailAddress ?? '', - avatarUri: user?.imageUrl ?? undefined, - signOut: async () => { - try { await signOut(); } catch {} - }, - }; -} + const [me, setMe] = useState(null); + const [myListings, setMyListings] = useState([]); + const [reviews, setReviews] = useState(null); + const [stripeLinked, setStripeLinked] = useState(false); + const [connecting, setConnecting] = useState(false); -function useMockIdentity(): Identity { - return { - name: mockUser.name ?? 'You', - email: 'demo@orbit.app', - avatarUri: undefined, - signOut: async () => {}, - }; -} + useFocusEffect( + useCallback(() => { + usersApi + .me() + .then((user) => { + setMe(user); + if (user.id) { + reviewsApi.forUser(user.id).then(setReviews).catch(() => {}); + } + }) + .catch(() => {}); + listingsApi.myListings().then(setMyListings).catch(() => {}); + paymentsApi + .connectStatus() + .then((s) => setStripeLinked(s.linked)) + .catch(() => {}); + }, []), + ); -const useIdentity = CLERK_ENABLED ? useClerkIdentity : useMockIdentity; + const startStripeConnect = async () => { + setConnecting(true); + try { + const { url } = await paymentsApi.startConnect(); + await Linking.openURL(url); + } catch (e) { + Alert.alert('Could not open Stripe', e instanceof Error ? e.message : 'Try again.'); + } finally { + setConnecting(false); + } + }; -export default function ProfileTab() { - const router = useRouter(); - const identity = useIdentity(); - const { name: displayName, email: displayEmail, avatarUri } = identity; - const myListings = mockListings.slice(0, 3); + const soldCount = myListings.filter((l) => l.status === 'SOLD').length; + const displayName = me?.name ?? me?.username ?? 'You'; return ( {/* Identity card */} - + YOUR ORBIT {displayName} - {displayEmail ? ( - - {displayEmail} - - ) : null} + + {me?.username ? `@${me.username}` : me?.email ?? ''} + + {me?.clerkUserId ? ( + router.push(`/profile/${me.clerkUserId}` as any)} + hitSlop={8} + > + View public + + ) : null} - - - + + + 0 ? reviews.averageRating.toFixed(1) : '—'} + /> @@ -79,61 +104,113 @@ export default function ProfileTab() { PAYMENTS - + {stripeLinked ? ( + + ) : ( + + )} - Connect Stripe to accept protected payments + {stripeLinked + ? 'Stripe connected — protected payments enabled' + : 'Connect Stripe to accept protected payments'} Buyers pay through Orbit; funds release once they confirm pickup. -