diff --git a/backend/src/models/cart-item.model.ts b/backend/src/models/cart-item.model.ts index f0737af..edbab2d 100644 --- a/backend/src/models/cart-item.model.ts +++ b/backend/src/models/cart-item.model.ts @@ -30,5 +30,10 @@ const cartItemSchema = new Schema( }, ) +cartItemSchema.index( + { user_id: 1, food_id: 1, status: 1 }, + { unique: true, partialFilterExpression: { status: "active" } }, +) + export type CartItemDocument = InferSchemaType export const CartItemModel = model("CartItem", cartItemSchema) diff --git a/backend/src/models/user-swipe.model.ts b/backend/src/models/user-swipe.model.ts index f77feaf..d37c195 100644 --- a/backend/src/models/user-swipe.model.ts +++ b/backend/src/models/user-swipe.model.ts @@ -29,5 +29,7 @@ const userSwipeSchema = new Schema( }, ) +userSwipeSchema.index({ user_id: 1, food_id: 1 }, { unique: true }) + export type UserSwipeDocument = InferSchemaType export const UserSwipeModel = model("UserSwipe", userSwipeSchema) diff --git a/backend/src/routes/swipe.routes.ts b/backend/src/routes/swipe.routes.ts index 36ad725..98f844c 100644 --- a/backend/src/routes/swipe.routes.ts +++ b/backend/src/routes/swipe.routes.ts @@ -51,20 +51,26 @@ swipeRouter.post("/", validateRequest({ body: swipeSchema }), async (req, res, n const userId = new Types.ObjectId(userIdRaw) const foodId = new Types.ObjectId(body.foodId) - const swipe = await UserSwipeModel.create({ - user_id: userId, - food_id: foodId, - action: body.action, - timestamp: new Date(), - }) + const swipe = await UserSwipeModel.findOneAndUpdate( + { user_id: userId, food_id: foodId }, + { action: body.action, timestamp: new Date() }, + { + upsert: true, + new: true, + setDefaultsOnInsert: true, + }, + ) if (body.action === "like") { - await CartItemModel.create({ - user_id: userId, - food_id: foodId, - quantity: 1, - status: "active", - }) + await CartItemModel.findOneAndUpdate( + { user_id: userId, food_id: foodId, status: "active" }, + { $setOnInsert: { quantity: 1, status: "active" } }, + { + upsert: true, + new: true, + setDefaultsOnInsert: true, + }, + ) } res.status(201).json({ diff --git a/backend/test/swipe.test.ts b/backend/test/swipe.test.ts index 87b332f..8e02d34 100644 --- a/backend/test/swipe.test.ts +++ b/backend/test/swipe.test.ts @@ -8,21 +8,23 @@ test("POST /api/swipe with action=pass stores swipe and does not create cart ite const app = createApp() const originalFindById = FoodItemModel.findById - const originalSwipeCreate = UserSwipeModel.create - const originalCartCreate = CartItemModel.create + const originalSwipeUpsert = UserSwipeModel.findOneAndUpdate + const originalCartUpsert = CartItemModel.findOneAndUpdate - let cartCreateCalls = 0 + let cartUpsertCalls = 0 ;(FoodItemModel.findById as unknown as (...args: unknown[]) => Promise) = async () => ({ _id: "660000000000000000000100", }) - ;(UserSwipeModel.create as unknown as (...args: unknown[]) => Promise) = async () => ({ - _id: "770000000000000000000001", - }) - ;(CartItemModel.create as unknown as (...args: unknown[]) => Promise) = async () => { - cartCreateCalls += 1 - return {} - } + ;(UserSwipeModel.findOneAndUpdate as unknown as (...args: unknown[]) => Promise) = + async () => ({ + _id: "770000000000000000000001", + }) + ;(CartItemModel.findOneAndUpdate as unknown as (...args: unknown[]) => Promise) = + async () => { + cartUpsertCalls += 1 + return {} + } const response = await request(app) .post("/api/swipe") @@ -31,32 +33,34 @@ test("POST /api/swipe with action=pass stores swipe and does not create cart ite assert.equal(response.status, 201) assert.equal(response.body.swipe.action, "pass") - assert.equal(cartCreateCalls, 0) + assert.equal(cartUpsertCalls, 0) FoodItemModel.findById = originalFindById - UserSwipeModel.create = originalSwipeCreate - CartItemModel.create = originalCartCreate + UserSwipeModel.findOneAndUpdate = originalSwipeUpsert + CartItemModel.findOneAndUpdate = originalCartUpsert }) test("POST /api/swipe with action=like stores swipe and creates cart item", async () => { const app = createApp() const originalFindById = FoodItemModel.findById - const originalSwipeCreate = UserSwipeModel.create - const originalCartCreate = CartItemModel.create + const originalSwipeUpsert = UserSwipeModel.findOneAndUpdate + const originalCartUpsert = CartItemModel.findOneAndUpdate - let cartCreateCalls = 0 + let cartUpsertCalls = 0 ;(FoodItemModel.findById as unknown as (...args: unknown[]) => Promise) = async () => ({ _id: "660000000000000000000100", }) - ;(UserSwipeModel.create as unknown as (...args: unknown[]) => Promise) = async () => ({ - _id: "770000000000000000000001", - }) - ;(CartItemModel.create as unknown as (...args: unknown[]) => Promise) = async () => { - cartCreateCalls += 1 - return {} - } + ;(UserSwipeModel.findOneAndUpdate as unknown as (...args: unknown[]) => Promise) = + async () => ({ + _id: "770000000000000000000001", + }) + ;(CartItemModel.findOneAndUpdate as unknown as (...args: unknown[]) => Promise) = + async () => { + cartUpsertCalls += 1 + return {} + } const response = await request(app) .post("/api/swipe") @@ -65,11 +69,11 @@ test("POST /api/swipe with action=like stores swipe and creates cart item", asyn assert.equal(response.status, 201) assert.equal(response.body.swipe.action, "like") - assert.equal(cartCreateCalls, 1) + assert.equal(cartUpsertCalls, 1) FoodItemModel.findById = originalFindById - UserSwipeModel.create = originalSwipeCreate - CartItemModel.create = originalCartCreate + UserSwipeModel.findOneAndUpdate = originalSwipeUpsert + CartItemModel.findOneAndUpdate = originalCartUpsert }) test("POST /api/swipe returns 404 when foodId does not exist", async () => { diff --git a/mobile/.env.example b/mobile/.env.example index 86e2e4a..25bfdc2 100644 --- a/mobile/.env.example +++ b/mobile/.env.example @@ -1 +1,2 @@ EXPO_PUBLIC_API_BASE_URL=http://localhost:5000 +EXPO_PUBLIC_DEMO_USER_ID=660000000000000000000001 diff --git a/mobile/README.md b/mobile/README.md index e5a9e72..b299233 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -27,6 +27,10 @@ npm run start - Deep link scheme is `discoverly`. - EAS profiles are defined in `eas.json`. +- Discovery tab pulls from `GET /api/foods/discover`. +- Swipe actions post to `POST /api/swipe`. +- Feed prefetch starts when only 3 cards remain. +- `expo-image` is used for image caching/prefetch to reduce flicker. - Do not hardcode secrets in source files. Keep sensitive values in EAS secrets or CI environment variables. - Only `EXPO_PUBLIC_*` variables should be exposed to client runtime code. diff --git a/mobile/app/(tabs)/discover.tsx b/mobile/app/(tabs)/discover.tsx index 80b1a2e..b1568e6 100644 --- a/mobile/app/(tabs)/discover.tsx +++ b/mobile/app/(tabs)/discover.tsx @@ -1,105 +1,179 @@ -import { useMemo, useRef, useState } from "react" -import { Animated, Pressable, StyleSheet, Text, View } from "react-native" -import * as Haptics from "expo-haptics" -import { useCartStore } from "../../src/store/useCartStore" - -type DiscoverCard = { - id: string - name: string - restaurant: string - price: number +import { useCallback, useEffect, useMemo, useState } from "react" +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native" +import { Image as ExpoImage } from "expo-image" +import { fetchDiscoverFeed, sendSwipe, type DiscoverItem } from "../../src/lib/api" + +const DISCOVERY_COORDINATES = { + longitude: -73.99, + latitude: 40.73, } -const mockCards: DiscoverCard[] = [ - { id: "food-1", name: "Spicy Smash Burger", restaurant: "Flare Grill", price: 13.5 }, - { id: "food-2", name: "Truffle Fries", restaurant: "North Bite", price: 7.25 }, - { id: "food-3", name: "Mango Chicken Bowl", restaurant: "Citrus Kitchen", price: 12.9 }, - { id: "food-4", name: "Pepperoni Fire Pizza", restaurant: "Stone Oven Co", price: 15.0 }, -] +const PREFETCH_THRESHOLD = 3 export default function DiscoverScreen() { - const [cards, setCards] = useState(mockCards) - const addItem = useCartStore((state) => state.addItem) - const overlayOpacity = useRef(new Animated.Value(0)).current - const overlayScale = useRef(new Animated.Value(0.9)).current - - const top = cards[0] - const remaining = cards.length - - const animateMatchOverlay = () => { - overlayOpacity.setValue(0) - overlayScale.setValue(0.9) - - Animated.sequence([ - Animated.parallel([ - Animated.timing(overlayOpacity, { - toValue: 1, - duration: 180, - useNativeDriver: true, - }), - Animated.spring(overlayScale, { - toValue: 1, - friction: 7, - tension: 100, - useNativeDriver: true, - }), - ]), - Animated.delay(900), - Animated.timing(overlayOpacity, { - toValue: 0, - duration: 250, - useNativeDriver: true, - }), - ]).start() - } + const [cards, setCards] = useState([]) + const [cursor, setCursor] = useState(null) + const [loading, setLoading] = useState(true) + const [prefetching, setPrefetching] = useState(false) + const [loadError, setLoadError] = useState(null) + + const prefetchImages = useCallback(async (items: DiscoverItem[]) => { + const urls = items.map((item) => item.imageUrl).filter(Boolean) + if (urls.length > 0) { + await ExpoImage.prefetch(urls) + } + }, []) - const onSwipe = async (action: "like" | "pass") => { - if (!top) return - setCards((prev) => prev.slice(1)) + const loadPage = useCallback( + async (nextCursor?: string | null, append = false) => { + const result = await fetchDiscoverFeed({ + ...DISCOVERY_COORDINATES, + cursor: nextCursor, + }) - if (action === "like") { - addItem({ id: top.id, name: top.name, price: top.price }) - animateMatchOverlay() - void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) + await prefetchImages(result.items) + + setCards((prev) => (append ? [...prev, ...result.items] : result.items)) + setCursor(result.cursor) + setLoadError(null) + }, + [prefetchImages], + ) + + const loadInitialPage = useCallback(async () => { + setLoading(true) + try { + await loadPage(null, false) + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load discover feed" + setLoadError(message) + setCards([]) + setCursor(null) + } finally { + setLoading(false) } + }, [loadPage]) + + useEffect(() => { + let active = true + + ;(async () => { + await loadInitialPage() + if (!active) { + return + } + })() + + return () => { + active = false + } + }, [loadInitialPage]) + + const maybePrefetchNext = useCallback( + async (remaining: number) => { + if (remaining > PREFETCH_THRESHOLD || !cursor || prefetching) { + return + } + + setPrefetching(true) + try { + await loadPage(cursor, true) + } catch { + // Keep current stack when prefetch fails; user can continue swiping. + } finally { + setPrefetching(false) + } + }, + [cursor, loadPage, prefetching], + ) + + const handleSwipe = useCallback( + async (action: "like" | "pass") => { + const top = cards[0] + if (!top) { + return + } + + setCards((prev) => prev.slice(1)) + void sendSwipe({ foodId: top.id, action }).catch(() => {}) + + const remaining = cards.length - 1 + await maybePrefetchNext(remaining) + }, + [cards, maybePrefetchNext], + ) + + const stack = useMemo(() => cards.slice(0, 3), [cards]) + + if (loading) { + return ( + + + Loading discovery feed... + + ) } - const subtitle = useMemo(() => { - if (!top) return "No more cards. Pull to refresh soon." - return `${top.restaurant} • $${top.price.toFixed(2)}` - }, [top]) + if (loadError) { + return ( + + Could not load discovery feed + {loadError} + void loadInitialPage()}> + Retry + + + ) + } + + if (cards.length === 0) { + return ( + + No more items nearby + Try again in a moment. + + ) + } return ( - - Matched! - Added to Cart - - - - {top?.name ?? "All Caught Up"} - {subtitle} + + {stack + .map((item, index) => ({ item, index })) + .reverse() + .map(({ item, index }) => ( + + + + {item.name} + {item.restaurantName} + + ${item.price.toFixed(2)} • {(item.distanceMeters / 1000).toFixed(1)}km + + + + ))} - void onSwipe("pass")} style={[styles.button, styles.passButton]}> + void handleSwipe("pass")}> Pass - void onSwipe("like")} style={[styles.button, styles.likeButton]}> + void handleSwipe("like")}> Like - {remaining} cards remaining + {prefetching ? Prefetching next cards... : null} ) } @@ -112,31 +186,53 @@ const styles = StyleSheet.create({ justifyContent: "center", padding: 20, }, + center: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 8, + }, + title: { + fontSize: 20, + fontWeight: "700", + }, + caption: { + color: "#666", + }, + stackWrap: { + width: "100%", + maxWidth: 380, + height: 520, + position: "relative", + marginBottom: 20, + }, card: { + position: "absolute", width: "100%", - maxWidth: 360, - minHeight: 220, + height: 500, borderRadius: 20, - backgroundColor: "#FFFFFF", - alignItems: "center", - justifyContent: "center", - paddingHorizontal: 20, + overflow: "hidden", + backgroundColor: "#fff", shadowColor: "#000", shadowOffset: { width: 0, height: 8 }, - shadowOpacity: 0.12, - shadowRadius: 14, - elevation: 5, - marginBottom: 18, - }, - cardTitle: { - fontSize: 26, - fontWeight: "800", - textAlign: "center", - }, - cardSubtitle: { - marginTop: 8, - color: "#666", - textAlign: "center", + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 6, + }, + image: { + width: "100%", + height: "78%", + }, + cardBody: { + padding: 14, + gap: 4, + }, + foodName: { + fontSize: 20, + fontWeight: "700", + }, + meta: { + color: "#555", }, actions: { flexDirection: "row", @@ -148,44 +244,18 @@ const styles = StyleSheet.create({ borderRadius: 14, alignItems: "center", }, - passButton: { + passBtn: { backgroundColor: "#E53935", }, - likeButton: { + likeBtn: { backgroundColor: "#2E7D32", }, + retryBtn: { + marginTop: 12, + backgroundColor: "#1D4ED8", + }, buttonText: { color: "#fff", fontWeight: "700", }, - remaining: { - marginTop: 16, - color: "#667085", - }, - matchOverlay: { - position: "absolute", - top: 130, - alignSelf: "center", - backgroundColor: "rgba(255, 255, 255, 0.96)", - borderRadius: 16, - paddingHorizontal: 22, - paddingVertical: 12, - zIndex: 10, - alignItems: "center", - shadowColor: "#000", - shadowOpacity: 0.14, - shadowRadius: 12, - shadowOffset: { width: 0, height: 6 }, - elevation: 6, - }, - matchTitle: { - fontSize: 18, - fontWeight: "800", - color: "#166534", - }, - matchSubtitle: { - marginTop: 2, - fontSize: 13, - color: "#14532D", - }, }) diff --git a/mobile/docs/discovery-feed.md b/mobile/docs/discovery-feed.md new file mode 100644 index 0000000..8e99d07 --- /dev/null +++ b/mobile/docs/discovery-feed.md @@ -0,0 +1,22 @@ +# Discovery Feed Integration (Issue 2.5) + +## APIs + +- `GET /api/foods/discover` +- `POST /api/swipe` + +## Prefetch Strategy + +- Maintain a local card queue. +- After each swipe, if remaining cards are `<= 3`, fetch the next cursor page in background. +- Append next-page cards without blocking user interaction. + +## Image Caching + +- Use `expo-image` and run `Image.prefetch(urls)` for each fetched page. +- Render card images via `expo-image` with `contentFit="cover"`. + +## Environment Variables + +- `EXPO_PUBLIC_API_BASE_URL` +- `EXPO_PUBLIC_DEMO_USER_ID` (temporary user header for current backend integration) diff --git a/mobile/package.json b/mobile/package.json index a7c1902..4f012af 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "expo": "^51.0.0", + "expo-image": "^1.12.15", "expo-haptics": "^13.0.1", "expo-router": "^3.5.18", "expo-secure-store": "^13.0.2", diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts new file mode 100644 index 0000000..3010b96 --- /dev/null +++ b/mobile/src/lib/api.ts @@ -0,0 +1,73 @@ +import { useAuthStore } from "../store/useAuthStore" + +const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? "http://localhost:5000" +const DEMO_USER_ID = process.env.EXPO_PUBLIC_DEMO_USER_ID ?? "660000000000000000000001" + +export type DiscoverItem = { + id: string + restaurantId: string + name: string + description: string + price: number + imageUrl: string + restaurantName: string + distanceMeters: number +} + +type DiscoverResponse = { + items: DiscoverItem[] + cursor: string | null +} + +function getAuthHeaders() { + const token = useAuthStore.getState().token + const headers: Record = {} + if (token) { + headers.Authorization = `Bearer ${token}` + } + return headers +} + +export async function fetchDiscoverFeed(params: { + longitude: number + latitude: number + cursor?: string | null +}) { + const query = new URLSearchParams({ + longitude: String(params.longitude), + latitude: String(params.latitude), + }) + + if (params.cursor) { + query.set("cursor", params.cursor) + } + + const response = await fetch(`${API_BASE_URL}/api/foods/discover?${query.toString()}`, { + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch discover feed: ${response.status}`) + } + + return (await response.json()) as DiscoverResponse +} + +export async function sendSwipe(params: { foodId: string; action: "like" | "pass" }) { + const response = await fetch(`${API_BASE_URL}/api/swipe`, { + method: "POST", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + "x-user-id": DEMO_USER_ID, + }, + body: JSON.stringify(params), + }) + + if (!response.ok) { + throw new Error(`Failed to send swipe: ${response.status}`) + } +} diff --git a/package-lock.json b/package-lock.json index 87ede98..aa0eb45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "dependencies": { "expo": "^51.0.0", "expo-haptics": "^13.0.1", + "expo-image": "^1.12.15", "expo-router": "^3.5.18", "expo-secure-store": "^13.0.2", "react": "18.2.0", @@ -9830,6 +9831,15 @@ "expo": "*" } }, + "node_modules/expo-image": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-1.13.0.tgz", + "integrity": "sha512-0NLDcFmEn4Nh1sXeRvNzDHT+Fl6FXtTol6ki6kYYH0/iDeSFWyIy/Fek6kzDDYAmhipSMR7buPf7VVoHseTbAA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-13.0.2.tgz",