From 5b6fd7bacd0a2c095a083cf553d4346463911887 Mon Sep 17 00:00:00 2001 From: devdeen213 Date: Thu, 5 Mar 2026 17:18:09 +0100 Subject: [PATCH 1/4] feat(mobile): add discover and swipe api client with expo-image dependency --- mobile/.env.example | 1 + mobile/package.json | 1 + mobile/src/lib/api.ts | 69 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 mobile/src/lib/api.ts 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/package.json b/mobile/package.json index 5fd4e06..95123bc 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "expo": "^51.0.0", + "expo-image": "^1.12.15", "expo-router": "^3.5.18", "expo-secure-store": "^13.0.2", "react": "18.2.0", diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts new file mode 100644 index 0000000..35b0e94 --- /dev/null +++ b/mobile/src/lib/api.ts @@ -0,0 +1,69 @@ +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 + return token ? { Authorization: `Bearer ${token}` } : {} +} + +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}`) + } +} From 33ca02715b4106e0b65e87712cd7b3758e4a12a0 Mon Sep 17 00:00:00 2001 From: devdeen213 Date: Fri, 6 Mar 2026 14:06:37 +0100 Subject: [PATCH 2/4] feat(mobile): integrate discover feed, background swipe posts, and prefetch trigger --- mobile/app/(tabs)/discover.tsx | 221 ++++++++++++++++++++++++++++++++- 1 file changed, 218 insertions(+), 3 deletions(-) diff --git a/mobile/app/(tabs)/discover.tsx b/mobile/app/(tabs)/discover.tsx index ea9ccc5..7511c09 100644 --- a/mobile/app/(tabs)/discover.tsx +++ b/mobile/app/(tabs)/discover.tsx @@ -1,9 +1,224 @@ -import { Text, View } from "react-native" +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 PREFETCH_THRESHOLD = 3 export default function DiscoverScreen() { + const [cards, setCards] = useState([]) + const [cursor, setCursor] = useState(null) + const [loading, setLoading] = useState(true) + const [prefetching, setPrefetching] = useState(false) + + const prefetchImages = useCallback(async (items: DiscoverItem[]) => { + const urls = items.map((item) => item.imageUrl).filter(Boolean) + if (urls.length > 0) { + await ExpoImage.prefetch(urls) + } + }, []) + + const loadPage = useCallback( + async (nextCursor?: string | null, append = false) => { + const result = await fetchDiscoverFeed({ + ...DISCOVERY_COORDINATES, + cursor: nextCursor, + }) + + await prefetchImages(result.items) + + setCards((prev) => (append ? [...prev, ...result.items] : result.items)) + setCursor(result.cursor) + }, + [prefetchImages], + ) + + useEffect(() => { + let active = true + ;(async () => { + try { + await loadPage(null, false) + } finally { + if (active) setLoading(false) + } + })() + + return () => { + active = false + } + }, [loadPage]) + + const maybePrefetchNext = useCallback( + async (remaining: number) => { + if (remaining > PREFETCH_THRESHOLD || !cursor || prefetching) { + return + } + setPrefetching(true) + try { + await loadPage(cursor, true) + } 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... + + ) + } + + if (cards.length === 0) { + return ( + + No more items nearby + Try again in a moment. + + ) + } + return ( - - Swipe Stack Loading... + + + {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 handleSwipe("pass")}> + Pass + + void handleSwipe("like")}> + Like + + + + {prefetching ? Prefetching next cards... : null} ) } + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#F8F9FA", + alignItems: "center", + 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%", + height: 500, + borderRadius: 20, + overflow: "hidden", + backgroundColor: "#fff", + shadowColor: "#000", + shadowOffset: { width: 0, height: 8 }, + 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", + gap: 12, + }, + button: { + minWidth: 130, + paddingVertical: 14, + borderRadius: 14, + alignItems: "center", + }, + passBtn: { + backgroundColor: "#E53935", + }, + likeBtn: { + backgroundColor: "#2E7D32", + }, + buttonText: { + color: "#fff", + fontWeight: "700", + }, +}) From a70bf7c002a66ede24c873ef58241aed93d002e1 Mon Sep 17 00:00:00 2001 From: devdeen213 Date: Sat, 7 Mar 2026 10:57:04 +0100 Subject: [PATCH 3/4] docs(mobile): document discovery api integration and prefetch strategy --- mobile/README.md | 4 ++++ mobile/docs/discovery-feed.md | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 mobile/docs/discovery-feed.md diff --git a/mobile/README.md b/mobile/README.md index 038d2ab..0bbfb17 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -27,3 +27,7 @@ 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. 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) From 2418c2ace5164d3d8b84f7a2da38a1aa8db8e737 Mon Sep 17 00:00:00 2001 From: devdeen213 Date: Sat, 7 Mar 2026 15:21:49 +0100 Subject: [PATCH 4/4] feat: enhance discovery feed with error handling and prefetch improvements feat: enhance discovery feed with error handling and prefetch improvements --- backend/src/models/cart-item.model.ts | 5 +++ backend/src/models/user-swipe.model.ts | 2 + backend/src/routes/swipe.routes.ts | 30 ++++++++------ backend/test/swipe.test.ts | 56 ++++++++++++++------------ mobile/app/(tabs)/discover.tsx | 45 +++++++++++++++++---- 5 files changed, 93 insertions(+), 45 deletions(-) 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/app/(tabs)/discover.tsx b/mobile/app/(tabs)/discover.tsx index 9657aa9..b1568e6 100644 --- a/mobile/app/(tabs)/discover.tsx +++ b/mobile/app/(tabs)/discover.tsx @@ -15,6 +15,7 @@ export default function DiscoverScreen() { 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) @@ -34,27 +35,39 @@ export default function DiscoverScreen() { 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 () => { - try { - await loadPage(null, false) - } finally { - if (active) { - setLoading(false) - } + await loadInitialPage() + if (!active) { + return } })() return () => { active = false } - }, [loadPage]) + }, [loadInitialPage]) const maybePrefetchNext = useCallback( async (remaining: number) => { @@ -65,6 +78,8 @@ export default function DiscoverScreen() { setPrefetching(true) try { await loadPage(cursor, true) + } catch { + // Keep current stack when prefetch fails; user can continue swiping. } finally { setPrefetching(false) } @@ -99,6 +114,18 @@ export default function DiscoverScreen() { ) } + if (loadError) { + return ( + + Could not load discovery feed + {loadError} + void loadInitialPage()}> + Retry + + + ) + } + if (cards.length === 0) { return ( @@ -223,6 +250,10 @@ const styles = StyleSheet.create({ likeBtn: { backgroundColor: "#2E7D32", }, + retryBtn: { + marginTop: 12, + backgroundColor: "#1D4ED8", + }, buttonText: { color: "#fff", fontWeight: "700",