diff --git a/mobile/app/admin/index.tsx b/mobile/app/admin/index.tsx index 1f0d0b3..634fb70 100644 --- a/mobile/app/admin/index.tsx +++ b/mobile/app/admin/index.tsx @@ -1,9 +1,10 @@ import React, { useCallback, useState } from 'react'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View, Dimensions } from 'react-native'; import { useFocusEffect, useRouter } from 'expo-router'; import { ChevronRight, Flag, Package, ShoppingCart, Users } from 'lucide-react-native'; import { palette, radius, spacing, type } from '@/theme'; import { Screen, AppHeader, Card } from '@/components/ui'; +import { LineChart, BarChart } from 'react-native-chart-kit'; import { adminApi } from '@/lib/api'; import type { AdminStats } from '@/lib/types'; @@ -64,6 +65,52 @@ export default function AdminDashboard() { /> + + User Growth (Last 6 Months) + palette.accent, + labelColor: (opacity = 1) => palette.muted, + propsForDots: { r: '4', strokeWidth: '2', stroke: palette.accent }, + }} + bezier + style={{ borderRadius: radius.md, marginVertical: spacing.xs }} + /> + + + + Listings by Category + palette.warning, + labelColor: (opacity = 1) => palette.muted, + }} + style={{ borderRadius: radius.md, marginVertical: spacing.xs }} + /> + + router.push('/admin/users' as any)} /> diff --git a/mobile/app/chat/[conversationId].tsx b/mobile/app/chat/[conversationId].tsx index 2d319db..7101a01 100644 --- a/mobile/app/chat/[conversationId].tsx +++ b/mobile/app/chat/[conversationId].tsx @@ -14,13 +14,18 @@ import * as ImagePicker from 'expo-image-picker'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useUser } from '@clerk/clerk-expo'; -import { ImagePlus, Send } from 'lucide-react-native'; +import { ImagePlus, Send, MoreVertical, ChevronLeft } from 'lucide-react-native'; import { palette, radius, spacing, type } from '@/theme'; import { AppHeader, Input, Avatar } from '@/components/ui'; +import { ImageViewerModal } from '@/components/ImageViewerModal'; import { chatApi, getImageUrl } from '@/lib/api'; import { formatRelative } from '@/lib/format'; import { getSocket } from '@/lib/socket'; import type { Message, User } from '@/lib/types'; +import { LinkPreview } from '@/components/LinkPreview'; +import { SharedMediaModal } from '@/components/SharedMediaModal'; +import { SellerVerificationModal } from '@/components/SellerVerificationModal'; +import { BuyerPurchasesModal } from '@/components/BuyerPurchasesModal'; export default function Thread() { const { conversationId } = useLocalSearchParams<{ conversationId: string }>(); @@ -32,6 +37,11 @@ export default function Thread() { const [other, setOther] = useState(); const [draft, setDraft] = useState(''); const [busy, setBusy] = useState(false); + const [showSharedMedia, setShowSharedMedia] = useState(false); + const [showSellerModal, setShowSellerModal] = useState(false); + const [showBuyerModal, setShowBuyerModal] = useState(false); + const [enlargedImage, setEnlargedImage] = useState(null); + const listRef = useRef>(null); const load = useCallback(async () => { @@ -104,23 +114,47 @@ export default function Thread() { } }; + // Determine active listing and roles based on the latest message that references a listing + const activeListingMessage = [...messages].reverse().find(m => m.listingId && m.listing); + const activeListingId = activeListingMessage?.listingId; + const isSeller = activeListingMessage && activeListingMessage.listing?.sellerId === user?.id; + const isBuyer = activeListingMessage && activeListingMessage.listing?.sellerId !== user?.id; + return ( - router.push(`/profile/${other.clerkUserId}` as any)}> - + + router.back()} style={styles.backBtn} hitSlop={8}> + + + other && router.push(`/profile/${other.clerkUserId}` as any)} + style={styles.headerProfile} + > + {other && } + + {other?.name ?? other?.username ?? 'Conversation'} + + + + {isSeller && ( + setShowSellerModal(true)} style={styles.actionBtn}> + Verify - ) : null - } - /> + )} + {isBuyer && ( + setShowBuyerModal(true)} style={[styles.actionBtn, { backgroundColor: palette.surfaceElevated, borderWidth: 1, borderColor: palette.hairline }]}> + Purchases + + )} + setShowSharedMedia(true)} hitSlop={8}> + + + + {loading ? ( @@ -131,7 +165,9 @@ export default function Thread() { data={messages} keyExtractor={(m) => m.id} contentContainerStyle={styles.list} - renderItem={({ item }) => } + renderItem={({ item }) => ( + + )} ItemSeparatorComponent={() => } onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })} /> @@ -161,11 +197,32 @@ export default function Thread() { /> + + setShowSharedMedia(false)} + messages={messages} + /> + setShowSellerModal(false)} + buyerId={other?.id} + /> + setShowBuyerModal(false)} + sellerId={other?.id} + /> + setEnlargedImage(null)} + imageUrl={enlargedImage ? getImageUrl(enlargedImage) : null} + /> ); } -function Bubble({ message, myClerkId }: { message: Message; myClerkId?: string }) { +function Bubble({ message, myClerkId, onImagePress }: { message: Message; myClerkId?: string; onImagePress: (url: string) => void }) { const mine = message.sender?.clerkUserId === myClerkId; return ( @@ -191,12 +248,13 @@ function Bubble({ message, myClerkId }: { message: Message; myClerkId?: string } {message.imageUrls && message.imageUrls.length > 0 ? ( {message.imageUrls.map(url => ( - + onImagePress(url)}> + + ))} ) : null} @@ -206,6 +264,11 @@ function Bubble({ message, myClerkId }: { message: Message; myClerkId?: string } {message.content} ) : null} + + {message.content ? (() => { + const match = message.content.match(/https?:\/\/[^\s]+/); + return match ? : null; + })() : null} (); @@ -33,6 +34,7 @@ export default function ListingDetail() { const [saved, setSaved] = useState(false); const [busy, setBusy] = useState(false); const [existingOffer, setExistingOffer] = useState(null); + const [enlargedImage, setEnlargedImage] = useState(null); const load = useCallback(async () => { if (!id) return; @@ -249,12 +251,13 @@ export default function ListingDetail() { style={{ height: width * 0.88 }} > {(images.length > 0 ? images : [{ url: '' }]).map((img, i) => ( - + setEnlargedImage(img.url)}> + + ))} @@ -452,6 +455,12 @@ export default function ListingDetail() { ) : null} + + setEnlargedImage(null)} + imageUrl={enlargedImage ? getImageUrl(enlargedImage) : null} + /> ); } diff --git a/mobile/components/BuyerPurchasesModal.tsx b/mobile/components/BuyerPurchasesModal.tsx new file mode 100644 index 0000000..3c18ed3 --- /dev/null +++ b/mobile/components/BuyerPurchasesModal.tsx @@ -0,0 +1,220 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Modal, View, Text, StyleSheet, Pressable, FlatList, ActivityIndicator } from 'react-native'; +import { Image } from 'expo-image'; +import { X, AlertTriangle } from 'lucide-react-native'; +import { palette, radius, spacing, type } from '@/theme'; +import { transactionsApi, getImageUrl } from '@/lib/api'; +import type { Transaction } from '@/lib/types'; + +interface BuyerPurchasesModalProps { + visible: boolean; + onClose: () => void; + sellerId: string | undefined; +} + +export function BuyerPurchasesModal({ visible, onClose, sellerId }: BuyerPurchasesModalProps) { + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(false); + const [activeCode, setActiveCode] = useState(null); + + const fetchTransactions = useCallback(async () => { + if (!visible || !sellerId) return; + setLoading(true); + try { + const data = await transactionsApi.activeAsBuyer(); + const filtered = data.filter((t) => t.sellerId === sellerId); + setTransactions(filtered); + + if (filtered.length > 0) { + // Just fetch the active code for the first listing in the context + const codeRes = await transactionsApi.activeMeetupCode(filtered[0].listingId, sellerId); + if (codeRes && codeRes.activeCode) { + setActiveCode(codeRes.activeCode); + } + } + } catch (e) { + console.warn('Failed to fetch buyer transactions', e); + } finally { + setLoading(false); + } + }, [visible, sellerId]); + + useEffect(() => { + fetchTransactions(); + }, [fetchTransactions]); + + return ( + + + + + + My Purchases + Manage your active purchases from this seller. + + + + + + + {loading ? ( + + + + ) : transactions.length === 0 ? ( + + + No Active Purchases + + You don't have any pending purchases with this seller. + + + ) : ( + item.id} + contentContainerStyle={styles.list} + ItemSeparatorComponent={() => } + renderItem={({ item }) => ( + + + + + + {item.listing?.title || 'Unknown Item'} + + + ${(item.amount / 100).toFixed(2)} + + + + {item.paymentMethod} + + + {item.orderStatus.replace(/_/g, ' ')} + + + + + + {item.paymentMethod === 'STRIPE' && item.orderStatus !== 'COMPLETED_BY_SELLER' && ( + + + Show this 6-digit code to the seller: + + + {activeCode || '------'} + + + )} + {item.paymentMethod === 'DIRECT' && item.orderStatus !== 'COMPLETED_BY_SELLER' && ( + + + Direct Payment + + + Pay the seller directly (Cash, Zelle, etc.) when you meet. The seller will mark the item as sold. + + + )} + + )} + /> + )} + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end', + }, + content: { + backgroundColor: palette.background, + borderTopLeftRadius: radius.xl, + borderTopRightRadius: radius.xl, + height: '70%', + }, + header: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + padding: spacing.base, + borderBottomWidth: 1, + borderBottomColor: palette.hairline, + }, + center: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: spacing.xl, + }, + list: { + padding: spacing.base, + paddingBottom: spacing.xxl, + }, + card: { + backgroundColor: palette.surfaceElevated, + borderRadius: radius.lg, + padding: spacing.sm, + borderWidth: 1, + borderColor: palette.hairline, + }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + image: { + width: 60, + height: 60, + borderRadius: radius.md, + backgroundColor: palette.card, + }, + info: { + flex: 1, + }, + badge: { + backgroundColor: palette.card, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: palette.hairline, + }, + badgeText: { + fontSize: 10, + fontWeight: '800', + color: palette.muted, + }, + codeContainer: { + marginTop: spacing.md, + padding: spacing.sm, + backgroundColor: palette.accent + '10', + borderRadius: radius.md, + alignItems: 'center', + }, + codeText: { + fontSize: 32, + fontWeight: '900', + color: palette.accent, + letterSpacing: 8, + marginTop: 4, + }, + directContainer: { + marginTop: spacing.md, + padding: spacing.sm, + backgroundColor: palette.surface, + borderRadius: radius.md, + borderWidth: 1, + borderColor: palette.hairlineSoft, + } +}); diff --git a/mobile/components/ImageViewerModal.tsx b/mobile/components/ImageViewerModal.tsx new file mode 100644 index 0000000..4145ad1 --- /dev/null +++ b/mobile/components/ImageViewerModal.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { Modal, View, StyleSheet, Pressable, ScrollView, Dimensions, Platform } from 'react-native'; +import { Image } from 'expo-image'; +import { X } from 'lucide-react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { palette, radius, hitSlop } from '@/theme'; + +interface ImageViewerModalProps { + visible: boolean; + onClose: () => void; + imageUrl: string | null; +} + +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); + +export function ImageViewerModal({ visible, onClose, imageUrl }: ImageViewerModalProps) { + const insets = useSafeAreaInsets(); + + if (!visible || !imageUrl) return null; + + return ( + + + + + + + + + + + {Platform.OS === 'ios' ? ( + + + + ) : ( + + + + )} + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.9)', + }, + header: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 10, + flexDirection: 'row', + justifyContent: 'flex-end', + paddingHorizontal: 20, + paddingBottom: 20, + }, + closeBtn: { + padding: 8, + }, + closeBtnBg: { + backgroundColor: 'rgba(255,255,255,0.2)', + borderRadius: 20, + width: 40, + height: 40, + justifyContent: 'center', + alignItems: 'center', + }, + scrollContainer: { + flex: 1, + width: SCREEN_WIDTH, + height: SCREEN_HEIGHT, + }, + scrollContent: { + flexGrow: 1, + justifyContent: 'center', + alignItems: 'center', + }, + image: { + width: SCREEN_WIDTH, + height: SCREEN_HEIGHT, + }, +}); diff --git a/mobile/components/LinkPreview.tsx b/mobile/components/LinkPreview.tsx new file mode 100644 index 0000000..fe45046 --- /dev/null +++ b/mobile/components/LinkPreview.tsx @@ -0,0 +1,84 @@ +import React, { useEffect, useState } from 'react'; +import { View, Text, StyleSheet, Pressable, Linking } from 'react-native'; +import { Image } from 'expo-image'; +import { getLinkPreview } from 'link-preview-js'; +import { palette, radius, spacing, type } from '@/theme'; + +interface LinkPreviewProps { + url: string; +} + +export function LinkPreview({ url }: LinkPreviewProps) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let mounted = true; + getLinkPreview(url) + .then((preview) => { + if (mounted) { + setData(preview); + setLoading(false); + } + }) + .catch((e) => { + console.warn('Link preview failed:', e); + if (mounted) setLoading(false); + }); + + return () => { + mounted = false; + }; + }, [url]); + + if (loading || !data) { + return null; // Fail silently if it can't fetch metadata + } + + const handlePress = () => { + Linking.openURL(url).catch(() => {}); + }; + + const imageUrl = data.images?.[0] || data.favicons?.[0]; + + return ( + + {imageUrl && ( + + )} + + + {data.title || data.siteName || new URL(url).hostname} + + {data.description && ( + + {data.description} + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + marginTop: spacing.xs, + backgroundColor: palette.surfaceElevated, + borderRadius: radius.md, + overflow: 'hidden', + borderWidth: 1, + borderColor: palette.hairline, + }, + image: { + width: '100%', + height: 120, + backgroundColor: palette.card, + }, + content: { + padding: spacing.sm, + }, +}); diff --git a/mobile/components/SellerVerificationModal.tsx b/mobile/components/SellerVerificationModal.tsx new file mode 100644 index 0000000..ccffac0 --- /dev/null +++ b/mobile/components/SellerVerificationModal.tsx @@ -0,0 +1,245 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Modal, View, Text, StyleSheet, Pressable, FlatList, ActivityIndicator, TextInput, KeyboardAvoidingView, Platform } from 'react-native'; +import { Image } from 'expo-image'; +import { X, AlertTriangle } from 'lucide-react-native'; +import { palette, radius, spacing, type } from '@/theme'; +import { transactionsApi, getImageUrl } from '@/lib/api'; +import type { Transaction } from '@/lib/types'; +import { Button } from '@/components/ui'; + +interface SellerVerificationModalProps { + visible: boolean; + onClose: () => void; + buyerId: string | undefined; +} + +export function SellerVerificationModal({ visible, onClose, buyerId }: SellerVerificationModalProps) { + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(false); + const [actionLoading, setActionLoading] = useState>({}); + const [codes, setCodes] = useState>({}); + + const fetchTransactions = useCallback(async () => { + if (!visible || !buyerId) return; + setLoading(true); + try { + const data = await transactionsApi.activeAsSeller(); + // Filter only transactions with this specific buyer + const filtered = data.filter((t) => t.buyerId === buyerId); + setTransactions(filtered); + } catch (e) { + console.warn('Failed to fetch seller transactions', e); + } finally { + setLoading(false); + } + }, [visible, buyerId]); + + useEffect(() => { + fetchTransactions(); + }, [fetchTransactions]); + + const handleMarkAsSold = async (id: string) => { + setActionLoading((prev) => ({ ...prev, [id]: true })); + try { + await transactionsApi.markAsSold(id); + await fetchTransactions(); // Refresh + } catch (e) { + console.warn('Failed to mark as sold', e); + } finally { + setActionLoading((prev) => ({ ...prev, [id]: false })); + } + }; + + const handleVerifyCode = async (id: string) => { + const code = codes[id]; + if (!code || code.length !== 6) return; + setActionLoading((prev) => ({ ...prev, [id]: true })); + try { + await transactionsApi.verifyMeetupCode(id, code); + await fetchTransactions(); // Refresh + } catch (e) { + console.warn('Failed to verify code', e); + } finally { + setActionLoading((prev) => ({ ...prev, [id]: false })); + } + }; + + return ( + + + + + + Verify Meetups + Manage your active transactions with this buyer. + + + + + + + {loading ? ( + + + + ) : transactions.length === 0 ? ( + + + No Active Meetups + + You don't have any pending transactions with this buyer. + + + ) : ( + item.id} + contentContainerStyle={styles.list} + ItemSeparatorComponent={() => } + renderItem={({ item }) => ( + + + + + + {item.listing?.title || 'Unknown Item'} + + + ${(item.amount / 100).toFixed(2)} + + + + {item.paymentMethod} + + + {item.orderStatus.replace(/_/g, ' ')} + + + + + + + {item.paymentMethod === 'DIRECT' && item.orderStatus !== 'COMPLETED_BY_SELLER' && ( +