From 38e630fc02cba8a3dce55528164ded7899e0872c Mon Sep 17 00:00:00 2001 From: Jakob Lee Date: Wed, 21 Oct 2026 12:45:10 -0700 Subject: [PATCH 1/6] Add Popular, Recommended, and Watched Items screens with shared navigation --- apps/mobile/__tests__/auth-flow.test.tsx | 21 ++ .../__tests__/authenticated-header.test.tsx | 57 +++ apps/mobile/__tests__/home-screen.test.tsx | 44 +++ apps/mobile/__tests__/navigation.test.tsx | 83 +++++ .../popular-products-navigation.test.tsx | 26 ++ .../popular-products-screen.test.tsx | 36 ++ .../recommended-products-screen.test.tsx | 29 ++ .../__tests__/watched-items-screen.test.tsx | 64 ++++ apps/mobile/app/_layout.tsx | 7 +- apps/mobile/app/home.tsx | 11 +- apps/mobile/app/home/popular-products.tsx | 18 + apps/mobile/app/notifications.tsx | 15 +- apps/mobile/app/recommended.tsx | 18 + apps/mobile/app/watched-items.tsx | 18 + .../features/auth/CreateAccountScreen.tsx | 1 - apps/mobile/features/home/HomeScreen.tsx | 189 +++------- .../navigation/AuthenticatedHeader.tsx | 139 ++++++++ .../notifications/NotificationsScreen.tsx | 10 + .../PopularProductsScreen.tsx | 208 +++++++++++ apps/mobile/features/products/ProductCard.tsx | 52 +++ .../RecommendedProductsScreen.tsx | 134 +++++++ .../watched-items/WatchedItemsScreen.tsx | 329 ++++++++++++++++++ apps/mobile/tsconfig.json | 1 + 23 files changed, 1355 insertions(+), 155 deletions(-) create mode 100644 apps/mobile/__tests__/authenticated-header.test.tsx create mode 100644 apps/mobile/__tests__/home-screen.test.tsx create mode 100644 apps/mobile/__tests__/navigation.test.tsx create mode 100644 apps/mobile/__tests__/popular-products-navigation.test.tsx create mode 100644 apps/mobile/__tests__/popular-products-screen.test.tsx create mode 100644 apps/mobile/__tests__/recommended-products-screen.test.tsx create mode 100644 apps/mobile/__tests__/watched-items-screen.test.tsx create mode 100644 apps/mobile/app/home/popular-products.tsx create mode 100644 apps/mobile/app/recommended.tsx create mode 100644 apps/mobile/app/watched-items.tsx create mode 100644 apps/mobile/features/navigation/AuthenticatedHeader.tsx create mode 100644 apps/mobile/features/popular-products/PopularProductsScreen.tsx create mode 100644 apps/mobile/features/products/ProductCard.tsx create mode 100644 apps/mobile/features/recommended-products/RecommendedProductsScreen.tsx create mode 100644 apps/mobile/features/watched-items/WatchedItemsScreen.tsx diff --git a/apps/mobile/__tests__/auth-flow.test.tsx b/apps/mobile/__tests__/auth-flow.test.tsx index 1a0c54a..4589108 100644 --- a/apps/mobile/__tests__/auth-flow.test.tsx +++ b/apps/mobile/__tests__/auth-flow.test.tsx @@ -150,6 +150,27 @@ describe("create account", () => { }); }); + it("does not log the Supabase signup response", async () => { + const consoleLog = jest.spyOn(console, "log").mockImplementation(() => {}); + mockSupabase.auth.signUp.mockResolvedValue({ + data: { + session: { + access_token: "access-token", + refresh_token: "refresh-token", + }, + }, + error: null, + }); + + try { + await submitSignUp(); + + await waitFor(() => expect(consoleLog).not.toHaveBeenCalled()); + } finally { + consoleLog.mockRestore(); + } + }); + it("rejects a taken username without creating an account", async () => { mockSupabase.rpc.mockResolvedValue({ data: false, error: null }); diff --git a/apps/mobile/__tests__/authenticated-header.test.tsx b/apps/mobile/__tests__/authenticated-header.test.tsx new file mode 100644 index 0000000..d900d8b --- /dev/null +++ b/apps/mobile/__tests__/authenticated-header.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render } from "@testing-library/react-native"; + +import { AuthenticatedHeader } from "../features/navigation/AuthenticatedHeader"; + +describe("AuthenticatedHeader", () => { + it("navigates to every existing menu route and disables unfinished routes", async () => { + const replace = jest.fn(); + const screen = await render( + replace("/")} + />, + ); + + await fireEvent.press(screen.getByRole("button", { name: "Open menu" })); + + for (const route of [ + ["Home", "/home"], + ["Watched Items", "/watched-items"], + ["Recommended", "/recommended"], + ["Popular", "/home/popular-products"], + ["Notifications", "/notifications"], + ]) { + await fireEvent.press(screen.getByRole("button", { name: route[0] })); + expect(replace).toHaveBeenLastCalledWith(route[1]); + await fireEvent.press(screen.getByRole("button", { name: "Open menu" })); + } + + expect( + screen.getByRole("button", { name: "Categories, Coming Soon" }).props + .accessibilityState.disabled, + ).toBe(true); + expect( + screen.getByRole("button", { name: "Stores, Coming Soon" }).props + .accessibilityState.disabled, + ).toBe(true); + expect( + screen.getByRole("button", { name: "Settings, Coming Soon" }).props + .accessibilityState.disabled, + ).toBe(true); + }); + + it("signs out and navigates to login", async () => { + const replace = jest.fn(); + const screen = await render( + replace("/")} + />, + ); + + await fireEvent.press(screen.getByRole("button", { name: "Open menu" })); + await fireEvent.press(screen.getByRole("button", { name: "Log out" })); + + expect(replace).toHaveBeenCalledWith("/"); + }); +}); diff --git a/apps/mobile/__tests__/home-screen.test.tsx b/apps/mobile/__tests__/home-screen.test.tsx new file mode 100644 index 0000000..3a8711c --- /dev/null +++ b/apps/mobile/__tests__/home-screen.test.tsx @@ -0,0 +1,44 @@ +jest.mock("../lib/supabase", () => ({ + supabase: { auth: { signOut: jest.fn() } }, +})); + +import { fireEvent, render } from "@testing-library/react-native"; + +import { HomeScreen } from "../features/home/HomeScreen"; + +describe("HomeScreen", () => { + it("opens Watched Items from the homepage section", async () => { + const onOpenWatchedItems = jest.fn(); + const screen = await render( + , + ); + + await fireEvent.press( + screen.getByRole("button", { name: "View watched items" }), + ); + + expect(onOpenWatchedItems).toHaveBeenCalledTimes(1); + }); + + it("opens Watched Items from the menu", async () => { + const onOpenWatchedItems = jest.fn(); + const screen = await render( + , + ); + + await fireEvent.press(screen.getByRole("button", { name: "Open menu" })); + await fireEvent.press( + screen.getByRole("button", { name: "Watched Items" }), + ); + + expect(onOpenWatchedItems).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/__tests__/navigation.test.tsx b/apps/mobile/__tests__/navigation.test.tsx new file mode 100644 index 0000000..5dcd44b --- /dev/null +++ b/apps/mobile/__tests__/navigation.test.tsx @@ -0,0 +1,83 @@ +import { act, render, waitFor } from "@testing-library/react-native"; + +const mockReplace = jest.fn(); +const mockUseAuth = jest.fn(); +const mockUseSegments = jest.fn(); + +jest.mock("expo-router", () => ({ + Stack: () => null, + useRouter: () => ({ replace: mockReplace }), + useSegments: () => mockUseSegments(), +})); + +jest.mock("expo-status-bar", () => ({ + StatusBar: () => null, +})); + +jest.mock("../features/auth/AuthProvider", () => ({ + AuthProvider: ({ children }: { children: React.ReactNode }) => children, + useAuth: () => mockUseAuth(), +})); + +import RootLayout from "../app/_layout"; + +describe("authenticated route access", () => { + beforeEach(() => { + mockReplace.mockReset(); + mockUseAuth.mockReturnValue({ + session: { user: { id: "user-1" } }, + loading: false, + }); + }); + + it.each([ + ["home", ["home"]], + ["watched items", ["watched-items"]], + ["notifications", ["notifications"]], + ["popular products", ["home", "popular-products"]], + ["recommended products", ["recommended"]], + ])("allows logged-in users to access %s", async (_name, segments) => { + mockUseSegments.mockReturnValue(segments); + + await act(async () => { + render(); + }); + + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("redirects logged-in users from the login route to home", async () => { + mockUseSegments.mockReturnValue([]); + + await act(async () => { + render(); + }); + + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith("/home")); + }); +}); + +describe("unauthenticated route access", () => { + beforeEach(() => { + mockReplace.mockReset(); + mockUseAuth.mockReturnValue({ session: null, loading: false }); + }); + + it.each([ + ["watched items", ["watched-items"]], + ["notifications", ["notifications"]], + ["popular products", ["home", "popular-products"]], + ["recommended products", ["recommended"]], + ])( + "redirects unauthenticated users from %s to login", + async (_name, segments) => { + mockUseSegments.mockReturnValue(segments); + + await act(async () => { + render(); + }); + + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith("/")); + }, + ); +}); diff --git a/apps/mobile/__tests__/popular-products-navigation.test.tsx b/apps/mobile/__tests__/popular-products-navigation.test.tsx new file mode 100644 index 0000000..944c1be --- /dev/null +++ b/apps/mobile/__tests__/popular-products-navigation.test.tsx @@ -0,0 +1,26 @@ +jest.mock("../lib/supabase", () => ({ + supabase: { auth: { signOut: jest.fn() } }, +})); + +import { fireEvent, render } from "@testing-library/react-native"; + +import { HomeScreen } from "../features/home/HomeScreen"; + +describe("HomeScreen popular products navigation", () => { + it("opens Popular Products from the homepage section", async () => { + const onOpenPopularProducts = jest.fn(); + const screen = await render( + , + ); + + await fireEvent.press( + screen.getByRole("button", { name: "View popular products" }), + ); + + expect(onOpenPopularProducts).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/__tests__/popular-products-screen.test.tsx b/apps/mobile/__tests__/popular-products-screen.test.tsx new file mode 100644 index 0000000..e107d3c --- /dev/null +++ b/apps/mobile/__tests__/popular-products-screen.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render } from "@testing-library/react-native"; + +import { PopularProductsScreen } from "../features/popular-products/PopularProductsScreen"; + +describe("PopularProductsScreen", () => { + it("shows placeholder products with their stores and prices", async () => { + const screen = await render(); + + expect(screen.getByText("Nintendo Switch 2")).toBeTruthy(); + expect(screen.getByText("Target")).toBeTruthy(); + expect(screen.getByText("$449.99")).toBeTruthy(); + }); + + it("filters products by search text", async () => { + const screen = await render(); + + await fireEvent.changeText( + screen.getByLabelText("Search popular products"), + "sony", + ); + + expect(screen.getByText("Sony WH-1000XM6")).toBeTruthy(); + expect(screen.queryByText("Nintendo Switch 2")).toBeNull(); + }); + + it("filters products by category", async () => { + const screen = await render(); + + await fireEvent.press(screen.getByRole("button", { name: "Cards" })); + + expect( + screen.getByText("Pokémon TCG: Prismatic Evolutions ETB"), + ).toBeTruthy(); + expect(screen.queryByText("Sony WH-1000XM6")).toBeNull(); + }); +}); diff --git a/apps/mobile/__tests__/recommended-products-screen.test.tsx b/apps/mobile/__tests__/recommended-products-screen.test.tsx new file mode 100644 index 0000000..dd74ce3 --- /dev/null +++ b/apps/mobile/__tests__/recommended-products-screen.test.tsx @@ -0,0 +1,29 @@ +import { fireEvent, render } from "@testing-library/react-native"; + +import { RecommendedProductsScreen } from "../features/recommended-products/RecommendedProductsScreen"; + +describe("RecommendedProductsScreen", () => { + it("shows clearly labeled sample products with stores and prices", async () => { + const screen = await render(); + + expect(screen.getByText("Recommended Products")).toBeTruthy(); + expect( + screen.getByText(/Sample recommendations for preview only/), + ).toBeTruthy(); + expect(screen.getByText("SteelSeries Arctis Nova 7")).toBeTruthy(); + expect(screen.getByText("Amazon")).toBeTruthy(); + expect(screen.getByText("$179.99")).toBeTruthy(); + }); + + it("filters recommendations by product or store", async () => { + const screen = await render(); + + await fireEvent.changeText( + screen.getByLabelText("Search recommended products"), + "target", + ); + + expect(screen.getByText("Pokémon TCG 9-Pocket Binder")).toBeTruthy(); + expect(screen.queryByText("SteelSeries Arctis Nova 7")).toBeNull(); + }); +}); diff --git a/apps/mobile/__tests__/watched-items-screen.test.tsx b/apps/mobile/__tests__/watched-items-screen.test.tsx new file mode 100644 index 0000000..ed39ebd --- /dev/null +++ b/apps/mobile/__tests__/watched-items-screen.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render } from "@testing-library/react-native"; + +import { + WatchedItemsScreen, + type WatchedItem, +} from "../features/watched-items/WatchedItemsScreen"; + +describe("WatchedItemsScreen", () => { + it("shows placeholder watched products and lets the user toggle an alert locally", async () => { + const screen = await render(); + + expect(screen.getByText("Nintendo Switch 2")).toBeTruthy(); + expect(screen.getByText("Target")).toBeTruthy(); + expect(screen.getByText("Current price: $449.99")).toBeTruthy(); + expect(screen.getByText("Target price: $450.00")).toBeTruthy(); + + const alertToggle = screen.getByRole("switch", { + name: "Nintendo Switch 2 price alert", + }); + expect(alertToggle.props.value).toBe(true); + + await fireEvent(alertToggle, "valueChange", false); + + expect( + screen.getByRole("switch", { name: "Nintendo Switch 2 price alert" }) + .props.value, + ).toBe(false); + }); + + it("adds an item with a target price and removes it locally", async () => { + const screen = await render(); + + expect(screen.getByText("No watched items yet")).toBeTruthy(); + + await fireEvent.press(screen.getByRole("button", { name: "Add item" })); + await fireEvent.changeText( + screen.getByLabelText("Product name"), + "LEGO Star Wars Set", + ); + await fireEvent.changeText(screen.getByLabelText("Store"), "Amazon"); + await fireEvent.changeText(screen.getByLabelText("Current price"), "69.99"); + await fireEvent.changeText(screen.getByLabelText("Target price"), "59.99"); + await fireEvent.press(screen.getByRole("button", { name: "Watch item" })); + + expect(screen.getByText("LEGO Star Wars Set")).toBeTruthy(); + expect(screen.getByText("Target price: $59.99")).toBeTruthy(); + + await fireEvent.press( + screen.getByRole("button", { name: "Unwatch LEGO Star Wars Set" }), + ); + + expect(screen.getByText("No watched items yet")).toBeTruthy(); + }); + + it("shows an empty state when no watched items are available", async () => { + const items: WatchedItem[] = []; + const screen = await render(); + + expect(screen.getByText("No watched items yet")).toBeTruthy(); + expect( + screen.getByText("Add an item to start tracking its price locally."), + ).toBeTruthy(); + }); +}); diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 0ed90e5..3a3a7eb 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -12,11 +12,12 @@ function RootNavigator() { useEffect(() => { if (loading) return; - const onHome = segments[0] === "home"; + const onPublicRoute = + segments[0] === undefined || segments[0] === "create-account"; - if (session && !onHome) { + if (session && onPublicRoute) { router.replace("/home"); - } else if (!session && onHome) { + } else if (!session && !onPublicRoute) { router.replace("/"); } }, [session, loading, segments]); diff --git a/apps/mobile/app/home.tsx b/apps/mobile/app/home.tsx index 0f4a7d8..1ba33f5 100644 --- a/apps/mobile/app/home.tsx +++ b/apps/mobile/app/home.tsx @@ -1,14 +1,21 @@ import { useRouter } from "expo-router"; import { HomeScreen } from "../features/home/HomeScreen"; +import { supabase } from "../lib/supabase"; export default function HomeRoute() { const router = useRouter(); return ( router.replace("/")} - onOpenNotifications={() => router.push("/notifications")} + navigation={{ replace: (route) => router.replace(route as never) }} + onLogout={async () => { + await supabase.auth.signOut(); + router.replace("/"); + }} + onOpenNotifications={() => router.replace("/notifications")} + onOpenPopularProducts={() => router.replace("/home/popular-products")} + onOpenWatchedItems={() => router.replace("/watched-items")} /> ); } diff --git a/apps/mobile/app/home/popular-products.tsx b/apps/mobile/app/home/popular-products.tsx new file mode 100644 index 0000000..61e6bdb --- /dev/null +++ b/apps/mobile/app/home/popular-products.tsx @@ -0,0 +1,18 @@ +import { useRouter } from "expo-router"; + +import { PopularProductsScreen } from "../../features/popular-products/PopularProductsScreen"; +import { supabase } from "../../lib/supabase"; + +export default function PopularProductsRoute() { + const router = useRouter(); + + return ( + router.replace(route as never) }} + onLogout={async () => { + await supabase.auth.signOut(); + router.replace("/"); + }} + /> + ); +} diff --git a/apps/mobile/app/notifications.tsx b/apps/mobile/app/notifications.tsx index 6ae35ba..330e5e2 100644 --- a/apps/mobile/app/notifications.tsx +++ b/apps/mobile/app/notifications.tsx @@ -1,5 +1,18 @@ +import { useRouter } from "expo-router"; + import { NotificationsScreen } from "../features/notifications/NotificationsScreen"; +import { supabase } from "../lib/supabase"; export default function NotificationsRoute() { - return ; + const router = useRouter(); + + return ( + router.replace(route as never) }} + onLogout={async () => { + await supabase.auth.signOut(); + router.replace("/"); + }} + /> + ); } diff --git a/apps/mobile/app/recommended.tsx b/apps/mobile/app/recommended.tsx new file mode 100644 index 0000000..09b466c --- /dev/null +++ b/apps/mobile/app/recommended.tsx @@ -0,0 +1,18 @@ +import { useRouter } from "expo-router"; + +import { RecommendedProductsScreen } from "../features/recommended-products/RecommendedProductsScreen"; +import { supabase } from "../lib/supabase"; + +export default function RecommendedProductsRoute() { + const router = useRouter(); + + return ( + router.replace(route as never) }} + onLogout={async () => { + await supabase.auth.signOut(); + router.replace("/"); + }} + /> + ); +} diff --git a/apps/mobile/app/watched-items.tsx b/apps/mobile/app/watched-items.tsx new file mode 100644 index 0000000..bc43cc1 --- /dev/null +++ b/apps/mobile/app/watched-items.tsx @@ -0,0 +1,18 @@ +import { useRouter } from "expo-router"; + +import { WatchedItemsScreen } from "../features/watched-items/WatchedItemsScreen"; +import { supabase } from "../lib/supabase"; + +export default function WatchedItemsRoute() { + const router = useRouter(); + + return ( + router.replace(route as never) }} + onLogout={async () => { + await supabase.auth.signOut(); + router.replace("/"); + }} + /> + ); +} diff --git a/apps/mobile/features/auth/CreateAccountScreen.tsx b/apps/mobile/features/auth/CreateAccountScreen.tsx index 7d3af24..b95a5e6 100644 --- a/apps/mobile/features/auth/CreateAccountScreen.tsx +++ b/apps/mobile/features/auth/CreateAccountScreen.tsx @@ -89,7 +89,6 @@ export function CreateAccountScreen({ }, }, }); - console.log("signUp result:", { data, error: signUpError }); setLoading(false); if (signUpError) { diff --git a/apps/mobile/features/home/HomeScreen.tsx b/apps/mobile/features/home/HomeScreen.tsx index 331e9b8..c020570 100644 --- a/apps/mobile/features/home/HomeScreen.tsx +++ b/apps/mobile/features/home/HomeScreen.tsx @@ -1,5 +1,3 @@ -import { useState } from "react"; - import { Pressable, SafeAreaView, @@ -9,23 +7,27 @@ import { View, } from "react-native"; +import { + AuthenticatedHeader, + type AuthenticatedNavigation, +} from "../navigation/AuthenticatedHeader"; import { supabase } from "../../lib/supabase"; type HomeScreenProps = { + navigation?: AuthenticatedNavigation; onLogout: () => void; onOpenNotifications: () => void; onOpenWatchedItems?: () => void; + onOpenPopularProducts?: () => void; }; export function HomeScreen({ + navigation, onLogout, onOpenNotifications, onOpenWatchedItems = () => undefined, + onOpenPopularProducts = () => undefined, }: HomeScreenProps) { - const [menuOpen, setMenuOpen] = useState(false); - const [categoriesOpen, setCategoriesOpen] = useState(false); - const [storesOpen, setStoresOpen] = useState(false); - async function handleLogout() { await supabase.auth.signOut(); onLogout(); @@ -34,89 +36,17 @@ export function HomeScreen({ return ( - {/* Header */} - - setMenuOpen(!menuOpen)} - > - ☰ - - - QuickCop - - - ⌕ - - - - {/* Hamburger Menu */} - {menuOpen && ( - - - Home - - - - Watched Items - - - - Price alerts - - - - Recommended - - - - Popular - - - {/* Categories */} - setCategoriesOpen(!categoriesOpen)}> - - Categories {categoriesOpen ? "▲" : "▼"} - - - - {categoriesOpen && ( - - Tickets - Cards - Lego - See All - - )} - - {/* Stores */} - setStoresOpen(!storesOpen)}> - - Stores {storesOpen ? "▲" : "▼"} - - - - {storesOpen && ( - - Amazon - Target - Walmart - See All - - )} - - {/* Settings */} - - Settings - - - - Log out - - - )} + { + if (route === "/watched-items") onOpenWatchedItems(); + else if (route === "/home/popular-products") + onOpenPopularProducts(); + else if (route === "/notifications") onOpenNotifications(); + else navigation?.replace(route); + }} + /> {/* Home */} Home @@ -151,7 +81,16 @@ export function HomeScreen({ {/* Popular */} - Popular + + Popular + + See all + + @@ -194,64 +133,6 @@ const styles = StyleSheet.create({ padding: 20, }, - header: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - marginBottom: 20, - }, - - logo: { - color: "#fff", - fontSize: 24, - fontWeight: "700", - }, - - iconButton: { - width: 44, - height: 44, - alignItems: "center", - justifyContent: "center", - }, - - iconText: { - color: "#fff", - fontSize: 26, - }, - - menu: { - backgroundColor: "#161616", - borderWidth: 1, - borderColor: "#333", - borderRadius: 12, - padding: 18, - marginBottom: 25, - }, - - menuItem: { - color: "#fff", - fontSize: 18, - paddingVertical: 10, - }, - - subMenu: { - marginLeft: 20, - marginBottom: 6, - }, - - subMenuItem: { - color: "#aaa", - fontSize: 16, - paddingVertical: 7, - }, - - logoutMenuItem: { - color: "#ff7777", - fontSize: 18, - paddingVertical: 10, - marginTop: 8, - }, - title: { color: "#fff", fontSize: 30, @@ -267,9 +148,21 @@ const styles = StyleSheet.create({ color: "#fff", fontSize: 21, fontWeight: "600", + }, + + sectionHeader: { + alignItems: "center", + flexDirection: "row", + justifyContent: "space-between", marginBottom: 14, }, + sectionAction: { + color: "#79DCA0", + fontSize: 14, + fontWeight: "600", + }, + emptyCard: { backgroundColor: "#161616", borderWidth: 1, diff --git a/apps/mobile/features/navigation/AuthenticatedHeader.tsx b/apps/mobile/features/navigation/AuthenticatedHeader.tsx new file mode 100644 index 0000000..4d6ce93 --- /dev/null +++ b/apps/mobile/features/navigation/AuthenticatedHeader.tsx @@ -0,0 +1,139 @@ +import { useState } from "react"; + +import { Pressable, StyleSheet, Text, View } from "react-native"; + +export type AuthenticatedNavigation = { + replace: (route: string) => void; +}; + +type AuthenticatedHeaderProps = { + navigation?: AuthenticatedNavigation; + onLogout?: () => void | Promise; + onNavigate?: (route: string) => void; +}; + +const menuItems = [ + { label: "Home", route: "/home" }, + { label: "Watched Items", route: "/watched-items" }, + { label: "Recommended", route: "/recommended" }, + { label: "Popular", route: "/home/popular-products" }, + { label: "Notifications", route: "/notifications" }, +]; + +const comingSoonItems = ["Categories", "Stores", "Settings"]; + +export function AuthenticatedHeader({ + navigation, + onLogout, + onNavigate, +}: AuthenticatedHeaderProps) { + const [menuOpen, setMenuOpen] = useState(false); + + function navigate(route: string) { + setMenuOpen(false); + if (onNavigate) { + onNavigate(route); + } else { + navigation?.replace(route); + } + } + + async function handleLogout() { + setMenuOpen(false); + if (onLogout) { + await onLogout(); + } + } + + return ( + + + + setMenuOpen((isOpen) => !isOpen)} + style={styles.iconButton} + > + ☰ + + QuickCop + + + + {menuOpen && ( + + {menuItems.map((item) => ( + navigate(item.route)} + style={styles.menuButton} + > + {item.label} + + ))} + + {comingSoonItems.map((item) => ( + + + {item} Coming Soon + + + ))} + + + Log out + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { marginBottom: 20 }, + header: { alignItems: "center", flexDirection: "row" }, + brandGroup: { alignItems: "center", flexDirection: "row" }, + logo: { color: "#fff", fontSize: 24, fontWeight: "700" }, + iconButton: { + alignItems: "center", + height: 44, + justifyContent: "center", + marginRight: 8, + width: 44, + }, + iconText: { color: "#fff", fontSize: 26 }, + menu: { + backgroundColor: "#161616", + borderColor: "#333", + borderRadius: 12, + borderWidth: 1, + marginTop: 10, + padding: 12, + }, + menuButton: { borderRadius: 8 }, + menuItem: { color: "#fff", fontSize: 18, paddingVertical: 10 }, + disabledMenuItem: { color: "#666", fontSize: 18, paddingVertical: 10 }, + comingSoon: { color: "#888", fontSize: 14 }, + logoutMenuItem: { + color: "#ff7777", + fontSize: 18, + marginTop: 8, + paddingVertical: 10, + }, +}); diff --git a/apps/mobile/features/notifications/NotificationsScreen.tsx b/apps/mobile/features/notifications/NotificationsScreen.tsx index 68fdc00..b001890 100644 --- a/apps/mobile/features/notifications/NotificationsScreen.tsx +++ b/apps/mobile/features/notifications/NotificationsScreen.tsx @@ -3,6 +3,11 @@ import { useState } from "react"; import { ScrollView, StyleSheet, Switch, Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +import { + AuthenticatedHeader, + type AuthenticatedNavigation, +} from "../navigation/AuthenticatedHeader"; + export type PriceAlert = { id: string; name: string; @@ -36,10 +41,14 @@ const exampleAlerts: PriceAlert[] = [ type NotificationsScreenProps = { alerts?: PriceAlert[]; + navigation?: AuthenticatedNavigation; + onLogout?: () => void | Promise; }; export function NotificationsScreen({ alerts = exampleAlerts, + navigation, + onLogout, }: NotificationsScreenProps) { const [priceAlerts, setPriceAlerts] = useState(alerts); const [priceDropNotificationsEnabled, setPriceDropNotificationsEnabled] = @@ -58,6 +67,7 @@ export function NotificationsScreen({ return ( + Price alerts diff --git a/apps/mobile/features/popular-products/PopularProductsScreen.tsx b/apps/mobile/features/popular-products/PopularProductsScreen.tsx new file mode 100644 index 0000000..ed57256 --- /dev/null +++ b/apps/mobile/features/popular-products/PopularProductsScreen.tsx @@ -0,0 +1,208 @@ +import { useMemo, useState } from "react"; + +import { + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import { + AuthenticatedHeader, + type AuthenticatedNavigation, +} from "../navigation/AuthenticatedHeader"; +import { ProductCard } from "../products/ProductCard"; + +type Category = "All" | "Electronics" | "Cards" | "Collectibles"; + +type PopularProduct = { + id: string; + name: string; + store: string; + price: string; + category: Exclude; +}; + +const categories: Category[] = ["All", "Electronics", "Cards", "Collectibles"]; + +const products: PopularProduct[] = [ + { + id: "switch-2", + name: "Nintendo Switch 2", + store: "Target", + price: "$449.99", + category: "Electronics", + }, + { + id: "headphones", + name: "Sony WH-1000XM6", + store: "Amazon", + price: "$398.00", + category: "Electronics", + }, + { + id: "pokemon-etb", + name: "Pokémon TCG: Prismatic Evolutions ETB", + store: "Walmart", + price: "$54.97", + category: "Cards", + }, + { + id: "lego-f1", + name: "LEGO Technic Ferrari SF-24 F1 Car", + store: "LEGO", + price: "$229.99", + category: "Collectibles", + }, +]; + +type PopularProductsScreenProps = { + navigation?: AuthenticatedNavigation; + onLogout?: () => void | Promise; +}; + +export function PopularProductsScreen({ + navigation, + onLogout, +}: PopularProductsScreenProps) { + const [search, setSearch] = useState(""); + const [selectedCategory, setSelectedCategory] = useState("All"); + + const filteredProducts = useMemo(() => { + const searchTerm = search.trim().toLowerCase(); + + return products.filter((product) => { + const inCategory = + selectedCategory === "All" || product.category === selectedCategory; + const matchesSearch = + !searchTerm || + product.name.toLowerCase().includes(searchTerm) || + product.store.toLowerCase().includes(searchTerm); + + return inCategory && matchesSearch; + }); + }, [search, selectedCategory]); + + return ( + + + + + Popular Products + + + Browse placeholder deals from stores shoppers watch most. + + + + + + {categories.map((category) => { + const selected = category === selectedCategory; + + return ( + setSelectedCategory(category)} + style={[ + styles.categoryButton, + selected && styles.categoryButtonSelected, + ]} + > + + {category} + + + ); + })} + + + + {filteredProducts.length === 0 ? ( + + No products found + + Try another search or category. + + + ) : ( + filteredProducts.map((product) => ( + + )) + )} + + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: "#000" }, + content: { padding: 20, paddingBottom: 32 }, + title: { color: "#fff", fontSize: 30, fontWeight: "700" }, + subtitle: { color: "#aaa", fontSize: 15, lineHeight: 22, marginTop: 8 }, + searchInput: { + backgroundColor: "#161616", + borderColor: "#3a3a3a", + borderRadius: 10, + borderWidth: 1, + color: "#fff", + fontSize: 16, + marginTop: 24, + paddingHorizontal: 14, + paddingVertical: 13, + }, + categoryList: { gap: 10, paddingVertical: 18 }, + categoryButton: { + borderColor: "#454545", + borderRadius: 18, + borderWidth: 1, + paddingHorizontal: 14, + paddingVertical: 8, + }, + categoryButtonSelected: { + backgroundColor: "#79DCA0", + borderColor: "#79DCA0", + }, + categoryButtonText: { color: "#ddd", fontSize: 14, fontWeight: "600" }, + categoryButtonTextSelected: { color: "#092313" }, + productList: { gap: 12 }, + emptyCard: { + alignItems: "center", + backgroundColor: "#161616", + borderColor: "#2b2b2b", + borderRadius: 12, + borderWidth: 1, + padding: 24, + }, + emptyTitle: { color: "#fff", fontSize: 17, fontWeight: "600" }, + emptyText: { color: "#aaa", fontSize: 14, marginTop: 6 }, +}); diff --git a/apps/mobile/features/products/ProductCard.tsx b/apps/mobile/features/products/ProductCard.tsx new file mode 100644 index 0000000..8cc648f --- /dev/null +++ b/apps/mobile/features/products/ProductCard.tsx @@ -0,0 +1,52 @@ +import { StyleSheet, Text, View } from "react-native"; + +type ProductCardProps = { + name: string; + store: string; + price: string; +}; + +export function ProductCard({ name, store, price }: ProductCardProps) { + return ( + + + IMG + + + {name} + {store} + {price} + + + ); +} + +const styles = StyleSheet.create({ + productCard: { + alignItems: "center", + backgroundColor: "#161616", + borderColor: "#2b2b2b", + borderRadius: 12, + borderWidth: 1, + flexDirection: "row", + padding: 14, + }, + imagePlaceholder: { + alignItems: "center", + backgroundColor: "#252525", + borderRadius: 8, + height: 72, + justifyContent: "center", + width: 72, + }, + imagePlaceholderText: { color: "#777", fontSize: 12, fontWeight: "600" }, + productDetails: { flex: 1, marginLeft: 14 }, + productName: { + color: "#fff", + fontSize: 16, + fontWeight: "600", + lineHeight: 22, + }, + store: { color: "#aaa", fontSize: 14, marginTop: 4 }, + price: { color: "#79DCA0", fontSize: 17, fontWeight: "700", marginTop: 7 }, +}); diff --git a/apps/mobile/features/recommended-products/RecommendedProductsScreen.tsx b/apps/mobile/features/recommended-products/RecommendedProductsScreen.tsx new file mode 100644 index 0000000..1be8206 --- /dev/null +++ b/apps/mobile/features/recommended-products/RecommendedProductsScreen.tsx @@ -0,0 +1,134 @@ +import { useMemo, useState } from "react"; + +import { ScrollView, StyleSheet, Text, TextInput, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import { + AuthenticatedHeader, + type AuthenticatedNavigation, +} from "../navigation/AuthenticatedHeader"; +import { ProductCard } from "../products/ProductCard"; + +type RecommendedProduct = { + id: string; + name: string; + store: string; + price: string; +}; + +const sampleRecommendations: RecommendedProduct[] = [ + { + id: "gaming-headset", + name: "SteelSeries Arctis Nova 7", + store: "Amazon", + price: "$179.99", + }, + { + id: "pokemon-binder", + name: "Pokémon TCG 9-Pocket Binder", + store: "Target", + price: "$14.99", + }, + { + id: "lego-space-rover", + name: "LEGO City Space Explorer Rover", + store: "Walmart", + price: "$49.99", + }, +]; + +type RecommendedProductsScreenProps = { + navigation?: AuthenticatedNavigation; + onLogout?: () => void | Promise; +}; + +export function RecommendedProductsScreen({ + navigation, + onLogout, +}: RecommendedProductsScreenProps) { + const [search, setSearch] = useState(""); + const filteredRecommendations = useMemo(() => { + const searchTerm = search.trim().toLowerCase(); + + if (!searchTerm) return sampleRecommendations; + + return sampleRecommendations.filter( + (product) => + product.name.toLowerCase().includes(searchTerm) || + product.store.toLowerCase().includes(searchTerm), + ); + }, [search]); + + return ( + + + + + Recommended Products + + + Sample recommendations for preview only. These are not personalized + yet. + + + + + + {filteredRecommendations.length === 0 ? ( + + No recommendations found + Try another search. + + ) : ( + filteredRecommendations.map((product) => ( + + )) + )} + + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: "#000" }, + content: { padding: 20, paddingBottom: 32 }, + title: { color: "#fff", fontSize: 30, fontWeight: "700" }, + subtitle: { color: "#aaa", fontSize: 15, lineHeight: 22, marginTop: 8 }, + searchInput: { + backgroundColor: "#161616", + borderColor: "#3a3a3a", + borderRadius: 10, + borderWidth: 1, + color: "#fff", + fontSize: 16, + marginTop: 24, + paddingHorizontal: 14, + paddingVertical: 13, + }, + productList: { gap: 12, marginTop: 18 }, + emptyCard: { + alignItems: "center", + backgroundColor: "#161616", + borderColor: "#2b2b2b", + borderRadius: 12, + borderWidth: 1, + padding: 24, + }, + emptyTitle: { color: "#fff", fontSize: 17, fontWeight: "600" }, + emptyText: { color: "#aaa", fontSize: 14, marginTop: 6 }, +}); diff --git a/apps/mobile/features/watched-items/WatchedItemsScreen.tsx b/apps/mobile/features/watched-items/WatchedItemsScreen.tsx new file mode 100644 index 0000000..aa43cbf --- /dev/null +++ b/apps/mobile/features/watched-items/WatchedItemsScreen.tsx @@ -0,0 +1,329 @@ +import { useState } from "react"; + +import { + Pressable, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import { + AuthenticatedHeader, + type AuthenticatedNavigation, +} from "../navigation/AuthenticatedHeader"; + +export type WatchedItem = { + id: string; + name: string; + store: string; + currentPrice: string; + targetPrice: string; + alertEnabled: boolean; +}; + +const placeholderItems: WatchedItem[] = [ + { + id: "switch-2", + name: "Nintendo Switch 2", + store: "Target", + currentPrice: "$449.99", + targetPrice: "$450.00", + alertEnabled: true, + }, + { + id: "headphones", + name: "Sony WH-1000XM6", + store: "Amazon", + currentPrice: "$398.00", + targetPrice: "$400.00", + alertEnabled: false, + }, +]; + +type WatchedItemsScreenProps = { + initialItems?: WatchedItem[]; + navigation?: AuthenticatedNavigation; + onLogout?: () => void | Promise; +}; + +export function WatchedItemsScreen({ + initialItems = placeholderItems, + navigation, + onLogout, +}: WatchedItemsScreenProps) { + const [items, setItems] = useState(initialItems); + const [isAddingItem, setIsAddingItem] = useState(false); + const [name, setName] = useState(""); + const [store, setStore] = useState(""); + const [currentPrice, setCurrentPrice] = useState(""); + const [targetPrice, setTargetPrice] = useState(""); + + function formatPrice(value: string) { + return value.startsWith("$") ? value : `$${value}`; + } + + function addItem() { + if ( + !name.trim() || + !store.trim() || + !currentPrice.trim() || + !targetPrice.trim() + ) { + return; + } + + setItems((currentItems) => [ + ...currentItems, + { + id: `${name.trim()}-${Date.now()}`, + name: name.trim(), + store: store.trim(), + currentPrice: formatPrice(currentPrice.trim()), + targetPrice: formatPrice(targetPrice.trim()), + alertEnabled: true, + }, + ]); + setName(""); + setStore(""); + setCurrentPrice(""); + setTargetPrice(""); + setIsAddingItem(false); + } + + function removeItem(id: string) { + setItems((currentItems) => currentItems.filter((item) => item.id !== id)); + } + + function setAlertEnabled(id: string, alertEnabled: boolean) { + setItems((currentItems) => + currentItems.map((item) => + item.id === id ? { ...item, alertEnabled } : item, + ), + ); + } + + return ( + + + + + + + Watched Items + + + Track the prices you care about. Changes stay on this device for + now. + + + setIsAddingItem(true)} + style={styles.addButton} + > + Add Item + + + + {isAddingItem && ( + + + Watch an item + + + + + + + setIsAddingItem(false)} + style={styles.cancelButton} + > + Cancel + + + Watch item + + + + )} + + {items.length === 0 ? ( + + No watched items yet + + Add an item to start tracking its price locally. + + + ) : ( + + {items.map((item) => ( + + + + {item.name} + {item.store} + + + setAlertEnabled(item.id, enabled) + } + thumbColor={item.alertEnabled ? "#FFFFFF" : "#B8B8B8"} + trackColor={{ false: "#4A4A4A", true: "#2E8B57" }} + value={item.alertEnabled} + /> + + + + Current price: {item.currentPrice} + + + Target price: {item.targetPrice} + + + removeItem(item.id)} + style={styles.unwatchButton} + > + Unwatch + + + ))} + + )} + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: "#000" }, + content: { padding: 20, paddingBottom: 40 }, + headingRow: { flexDirection: "row", gap: 12, marginBottom: 28 }, + headingContent: { flex: 1 }, + title: { color: "#fff", fontSize: 30, fontWeight: "700", marginBottom: 8 }, + subtitle: { color: "#aaa", fontSize: 16, lineHeight: 22 }, + addButton: { + alignSelf: "flex-start", + backgroundColor: "#2E8B57", + borderRadius: 8, + paddingHorizontal: 14, + paddingVertical: 12, + }, + addButtonText: { color: "#fff", fontSize: 14, fontWeight: "700" }, + formCard: { + backgroundColor: "#161616", + borderColor: "#2b2b2b", + borderRadius: 12, + borderWidth: 1, + marginBottom: 20, + padding: 16, + }, + formTitle: { + color: "#fff", + fontSize: 18, + fontWeight: "600", + marginBottom: 14, + }, + input: { + borderColor: "#444", + borderRadius: 8, + borderWidth: 1, + color: "#fff", + fontSize: 16, + marginBottom: 12, + paddingHorizontal: 12, + paddingVertical: 11, + }, + formActions: { flexDirection: "row", gap: 12, justifyContent: "flex-end" }, + cancelButton: { paddingHorizontal: 14, paddingVertical: 12 }, + cancelButtonText: { color: "#aaa", fontSize: 15, fontWeight: "600" }, + watchButton: { + backgroundColor: "#2E8B57", + borderRadius: 8, + paddingHorizontal: 14, + paddingVertical: 12, + }, + watchButtonText: { color: "#fff", fontSize: 15, fontWeight: "700" }, + items: { gap: 12 }, + itemCard: { + backgroundColor: "#161616", + borderColor: "#2b2b2b", + borderRadius: 12, + borderWidth: 1, + padding: 16, + }, + itemHeader: { alignItems: "center", flexDirection: "row" }, + itemContent: { flex: 1, paddingRight: 12 }, + productName: { + color: "#fff", + fontSize: 17, + fontWeight: "600", + marginBottom: 4, + }, + store: { color: "#aaa", fontSize: 14 }, + priceRow: { flexDirection: "row", flexWrap: "wrap", gap: 12, marginTop: 14 }, + currentPrice: { color: "#fff", fontSize: 15 }, + targetPrice: { color: "#79DCA0", fontSize: 15, fontWeight: "600" }, + unwatchButton: { alignSelf: "flex-start", marginTop: 16 }, + unwatchButtonText: { color: "#ff7777", fontSize: 14, fontWeight: "600" }, + emptyCard: { + backgroundColor: "#161616", + borderColor: "#2b2b2b", + borderRadius: 12, + borderWidth: 1, + padding: 22, + }, + emptyTitle: { + color: "#fff", + fontSize: 17, + fontWeight: "600", + marginBottom: 6, + }, + emptyText: { color: "#aaa", fontSize: 15, lineHeight: 21 }, +}); diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 186ee5f..178b601 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -2,6 +2,7 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, + "types": ["jest"], "paths": { "@/*": ["./*"] } From 8bdf7d69ac460eff44003bfa5770f6769dc3d922 Mon Sep 17 00:00:00 2001 From: Jakob Lee Date: Wed, 21 Oct 2026 17:05:24 -0700 Subject: [PATCH 2/6] Use shared ProductCard and clean up HomeScreen --- apps/mobile/__tests__/auth-flow.test.tsx | 4 +- apps/mobile/__tests__/home-screen.test.tsx | 16 +++ apps/mobile/features/home/HomeScreen.tsx | 103 +++++------------- apps/mobile/features/products/ProductCard.tsx | 43 ++++++-- 4 files changed, 82 insertions(+), 84 deletions(-) diff --git a/apps/mobile/__tests__/auth-flow.test.tsx b/apps/mobile/__tests__/auth-flow.test.tsx index 4589108..028b544 100644 --- a/apps/mobile/__tests__/auth-flow.test.tsx +++ b/apps/mobile/__tests__/auth-flow.test.tsx @@ -203,7 +203,7 @@ describe("create account", () => { }); describe("logout", () => { - it("signs out and returns to login from the home menu", async () => { + it("delegates logout from the home menu to the route", async () => { const onLogout = jest.fn(); const screen = await render( , @@ -214,6 +214,6 @@ describe("logout", () => { await fireEvent.press(screen.getByRole("button", { name: "Log out" })); await waitFor(() => expect(onLogout).toHaveBeenCalledTimes(1)); - expect(mockSupabase.auth.signOut).toHaveBeenCalledTimes(1); + expect(mockSupabase.auth.signOut).not.toHaveBeenCalled(); }); }); diff --git a/apps/mobile/__tests__/home-screen.test.tsx b/apps/mobile/__tests__/home-screen.test.tsx index 3a8711c..d035436 100644 --- a/apps/mobile/__tests__/home-screen.test.tsx +++ b/apps/mobile/__tests__/home-screen.test.tsx @@ -2,11 +2,27 @@ jest.mock("../lib/supabase", () => ({ supabase: { auth: { signOut: jest.fn() } }, })); +jest.mock("../features/products/ProductCard", () => ({ + ProductCard: ({ name }: { name: string }) => { + const { Text: MockText } = require("react-native"); + return {name}; + }, +})); + import { fireEvent, render } from "@testing-library/react-native"; +import { Text } from "react-native"; import { HomeScreen } from "../features/home/HomeScreen"; describe("HomeScreen", () => { + it("renders homepage products with the shared ProductCard", async () => { + const screen = await render( + , + ); + + expect(screen.getAllByTestId("shared-product-card")).toHaveLength(4); + }); + it("opens Watched Items from the homepage section", async () => { const onOpenWatchedItems = jest.fn(); const screen = await render( diff --git a/apps/mobile/features/home/HomeScreen.tsx b/apps/mobile/features/home/HomeScreen.tsx index c020570..5e35cd0 100644 --- a/apps/mobile/features/home/HomeScreen.tsx +++ b/apps/mobile/features/home/HomeScreen.tsx @@ -11,7 +11,7 @@ import { AuthenticatedHeader, type AuthenticatedNavigation, } from "../navigation/AuthenticatedHeader"; -import { supabase } from "../../lib/supabase"; +import { ProductCard } from "../products/ProductCard"; type HomeScreenProps = { navigation?: AuthenticatedNavigation; @@ -28,17 +28,12 @@ export function HomeScreen({ onOpenWatchedItems = () => undefined, onOpenPopularProducts = () => undefined, }: HomeScreenProps) { - async function handleLogout() { - await supabase.auth.signOut(); - onLogout(); - } - return ( { if (route === "/watched-items") onOpenWatchedItems(); else if (route === "/home/popular-products") @@ -73,9 +68,19 @@ export function HomeScreen({ Recommended - - - + + + @@ -93,9 +98,19 @@ export function HomeScreen({ - - - + + + @@ -103,26 +118,6 @@ export function HomeScreen({ ); } -type ProductCardProps = { - name: string; - store: string; - price: string; -}; - -function ProductCard({ name, store, price }: ProductCardProps) { - return ( - - - IMG - - - {name} - {store} - {price} - - ); -} - const styles = StyleSheet.create({ screen: { flex: 1, @@ -187,46 +182,4 @@ const styles = StyleSheet.create({ flexDirection: "row", gap: 14, }, - - productCard: { - flex: 1, - backgroundColor: "#161616", - borderWidth: 1, - borderColor: "#2b2b2b", - borderRadius: 12, - padding: 12, - }, - - productImage: { - height: 110, - backgroundColor: "#252525", - borderRadius: 8, - alignItems: "center", - justifyContent: "center", - marginBottom: 12, - }, - - imageText: { - color: "#777", - fontSize: 14, - }, - - productName: { - color: "#fff", - fontSize: 16, - fontWeight: "600", - marginBottom: 4, - }, - - productStore: { - color: "#888", - fontSize: 13, - marginBottom: 6, - }, - - productPrice: { - color: "#fff", - fontSize: 17, - fontWeight: "700", - }, }); diff --git a/apps/mobile/features/products/ProductCard.tsx b/apps/mobile/features/products/ProductCard.tsx index 8cc648f..b48318e 100644 --- a/apps/mobile/features/products/ProductCard.tsx +++ b/apps/mobile/features/products/ProductCard.tsx @@ -1,21 +1,35 @@ import { StyleSheet, Text, View } from "react-native"; type ProductCardProps = { + layout?: "row" | "grid"; name: string; store: string; price: string; }; -export function ProductCard({ name, store, price }: ProductCardProps) { +export function ProductCard({ + layout = "row", + name, + store, + price, +}: ProductCardProps) { + const isGrid = layout === "grid"; + return ( - - + + IMG - - {name} - {store} - {price} + + + {name} + + {store} + {price} ); @@ -31,6 +45,12 @@ const styles = StyleSheet.create({ flexDirection: "row", padding: 14, }, + gridProductCard: { + alignItems: "stretch", + flex: 1, + flexDirection: "column", + padding: 12, + }, imagePlaceholder: { alignItems: "center", backgroundColor: "#252525", @@ -39,14 +59,23 @@ const styles = StyleSheet.create({ justifyContent: "center", width: 72, }, + gridImagePlaceholder: { + height: 110, + marginBottom: 12, + width: "100%", + }, imagePlaceholderText: { color: "#777", fontSize: 12, fontWeight: "600" }, productDetails: { flex: 1, marginLeft: 14 }, + gridProductDetails: { marginLeft: 0 }, productName: { color: "#fff", fontSize: 16, fontWeight: "600", lineHeight: 22, }, + gridProductName: { marginBottom: 4 }, store: { color: "#aaa", fontSize: 14, marginTop: 4 }, + gridStore: { color: "#888", fontSize: 13, marginBottom: 6, marginTop: 0 }, price: { color: "#79DCA0", fontSize: 17, fontWeight: "700", marginTop: 7 }, + gridPrice: { color: "#fff", marginTop: 0 }, }); From db3c2dcd4ad5baf100a6e816bbe73504eacf1438 Mon Sep 17 00:00:00 2001 From: Jakob Lee Date: Wed, 21 Oct 2026 17:12:24 -0700 Subject: [PATCH 3/6] Remove outdated navigation test --- apps/mobile/__tests__/navigation.test.tsx | 83 ----------------------- 1 file changed, 83 deletions(-) delete mode 100644 apps/mobile/__tests__/navigation.test.tsx diff --git a/apps/mobile/__tests__/navigation.test.tsx b/apps/mobile/__tests__/navigation.test.tsx deleted file mode 100644 index 5dcd44b..0000000 --- a/apps/mobile/__tests__/navigation.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { act, render, waitFor } from "@testing-library/react-native"; - -const mockReplace = jest.fn(); -const mockUseAuth = jest.fn(); -const mockUseSegments = jest.fn(); - -jest.mock("expo-router", () => ({ - Stack: () => null, - useRouter: () => ({ replace: mockReplace }), - useSegments: () => mockUseSegments(), -})); - -jest.mock("expo-status-bar", () => ({ - StatusBar: () => null, -})); - -jest.mock("../features/auth/AuthProvider", () => ({ - AuthProvider: ({ children }: { children: React.ReactNode }) => children, - useAuth: () => mockUseAuth(), -})); - -import RootLayout from "../app/_layout"; - -describe("authenticated route access", () => { - beforeEach(() => { - mockReplace.mockReset(); - mockUseAuth.mockReturnValue({ - session: { user: { id: "user-1" } }, - loading: false, - }); - }); - - it.each([ - ["home", ["home"]], - ["watched items", ["watched-items"]], - ["notifications", ["notifications"]], - ["popular products", ["home", "popular-products"]], - ["recommended products", ["recommended"]], - ])("allows logged-in users to access %s", async (_name, segments) => { - mockUseSegments.mockReturnValue(segments); - - await act(async () => { - render(); - }); - - expect(mockReplace).not.toHaveBeenCalled(); - }); - - it("redirects logged-in users from the login route to home", async () => { - mockUseSegments.mockReturnValue([]); - - await act(async () => { - render(); - }); - - await waitFor(() => expect(mockReplace).toHaveBeenCalledWith("/home")); - }); -}); - -describe("unauthenticated route access", () => { - beforeEach(() => { - mockReplace.mockReset(); - mockUseAuth.mockReturnValue({ session: null, loading: false }); - }); - - it.each([ - ["watched items", ["watched-items"]], - ["notifications", ["notifications"]], - ["popular products", ["home", "popular-products"]], - ["recommended products", ["recommended"]], - ])( - "redirects unauthenticated users from %s to login", - async (_name, segments) => { - mockUseSegments.mockReturnValue(segments); - - await act(async () => { - render(); - }); - - await waitFor(() => expect(mockReplace).toHaveBeenCalledWith("/")); - }, - ); -}); From be4d3fae70a37139d9e1bdc68ad6c4289e471889 Mon Sep 17 00:00:00 2001 From: Jakob Lee Date: Wed, 28 Oct 2026 11:42:15 -0700 Subject: [PATCH 4/6] Add Quick Checkout simulation and cross-platform storage support --- .../__tests__/authenticated-header.test.tsx | 1 + .../__tests__/quick-checkout-screen.test.tsx | 81 ++++++ .../mobile/app/(protected)/quick-checkout.tsx | 27 ++ .../navigation/AuthenticatedHeader.tsx | 1 + .../quick-checkout/QuickCheckoutScreen.tsx | 274 ++++++++++++++++++ apps/mobile/lib/secureStorage.ts | 28 +- 6 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/__tests__/quick-checkout-screen.test.tsx create mode 100644 apps/mobile/app/(protected)/quick-checkout.tsx create mode 100644 apps/mobile/features/quick-checkout/QuickCheckoutScreen.tsx diff --git a/apps/mobile/__tests__/authenticated-header.test.tsx b/apps/mobile/__tests__/authenticated-header.test.tsx index d900d8b..b6cddd1 100644 --- a/apps/mobile/__tests__/authenticated-header.test.tsx +++ b/apps/mobile/__tests__/authenticated-header.test.tsx @@ -20,6 +20,7 @@ describe("AuthenticatedHeader", () => { ["Recommended", "/recommended"], ["Popular", "/home/popular-products"], ["Notifications", "/notifications"], + ["Quick Checkout", "/quick-checkout"], ]) { await fireEvent.press(screen.getByRole("button", { name: route[0] })); expect(replace).toHaveBeenLastCalledWith(route[1]); diff --git a/apps/mobile/__tests__/quick-checkout-screen.test.tsx b/apps/mobile/__tests__/quick-checkout-screen.test.tsx new file mode 100644 index 0000000..cd1ffde --- /dev/null +++ b/apps/mobile/__tests__/quick-checkout-screen.test.tsx @@ -0,0 +1,81 @@ +import { fireEvent, render } from "@testing-library/react-native"; + +import { QuickCheckoutScreen } from "../features/quick-checkout/QuickCheckoutScreen"; + +describe("QuickCheckoutScreen", () => { + it("requires checkout information and a valid spending limit", async () => { + const screen = await render(); + + await fireEvent.press( + screen.getByRole("button", { name: "Simulate Checkout" }), + ); + + expect(screen.getByText("Enter your checkout information.")).toBeTruthy(); + + await fireEvent( + screen.getByRole("switch", { name: "Auto-fill saved information" }), + "valueChange", + true, + ); + await fireEvent.press( + screen.getByRole("button", { name: "Simulate Checkout" }), + ); + expect(screen.getByText("Enter a valid maximum price.")).toBeTruthy(); + }); + + it("auto-fills saved information and toggles auto-buy", async () => { + const screen = await render(); + + const autoFill = screen.getByRole("switch", { + name: "Auto-fill saved information", + }); + await fireEvent( + screen.getByRole("switch", { name: "Auto-fill saved information" }), + "valueChange", + true, + ); + await fireEvent( + screen.getByRole("switch", { name: "Auto-buy simulation" }), + "valueChange", + true, + ); + + expect(autoFill.props.value).toBe(true); + expect(screen.getByDisplayValue("Alex Shopper")).toBeTruthy(); + expect(screen.getByDisplayValue("123 Sample Street")).toBeTruthy(); + expect( + screen.getByRole("switch", { name: "Auto-buy simulation" }).props.value, + ).toBe(true); + }); + + it("succeeds when the product is within the spending limit and fails when it is not", async () => { + const screen = await render(); + + await fireEvent( + screen.getByRole("switch", { name: "Auto-fill saved information" }), + "valueChange", + true, + ); + await fireEvent.changeText( + screen.getByLabelText("Maximum purchase price"), + "60", + ); + await fireEvent.press( + screen.getByRole("button", { name: "Simulate Checkout" }), + ); + expect(screen.getByText("Checkout simulation successful.")).toBeTruthy(); + + await fireEvent.changeText( + screen.getByLabelText("Maximum purchase price"), + "40", + ); + await fireEvent.press( + screen.getByRole("button", { name: "Simulate Checkout" }), + ); + expect( + screen.getByText( + "Checkout simulation stopped: the product exceeds your spending limit.", + ), + ).toBeTruthy(); + }); +}); diff --git a/apps/mobile/app/(protected)/quick-checkout.tsx b/apps/mobile/app/(protected)/quick-checkout.tsx new file mode 100644 index 0000000..09f9915 --- /dev/null +++ b/apps/mobile/app/(protected)/quick-checkout.tsx @@ -0,0 +1,27 @@ +import { useRouter } from "expo-router"; + +import { useAuth } from "../../features/auth/AuthProvider"; +import { QuickCheckoutScreen } from "../../features/quick-checkout/QuickCheckoutScreen"; + +export default function QuickCheckoutRoute() { + const router = useRouter(); + const { logout } = useAuth(); + + return ( + router.replace(route as never) }} + onLogout={async () => { + let result = { ok: false }; + try { + result = await logout(); + } catch { + // Keep the local route transition recoverable if logout unexpectedly throws. + } + router.replace({ + pathname: "/(public)", + params: result.ok ? undefined : { logout: "failed" }, + }); + }} + /> + ); +} diff --git a/apps/mobile/features/navigation/AuthenticatedHeader.tsx b/apps/mobile/features/navigation/AuthenticatedHeader.tsx index 4aa909d..f2caa93 100644 --- a/apps/mobile/features/navigation/AuthenticatedHeader.tsx +++ b/apps/mobile/features/navigation/AuthenticatedHeader.tsx @@ -18,6 +18,7 @@ const menuItems = [ { label: "Recommended", route: "/recommended" }, { label: "Popular", route: "/home/popular-products" }, { label: "Notifications", route: "/notifications" }, + { label: "Quick Checkout", route: "/quick-checkout" }, { label: "Account", route: "/account" }, ]; diff --git a/apps/mobile/features/quick-checkout/QuickCheckoutScreen.tsx b/apps/mobile/features/quick-checkout/QuickCheckoutScreen.tsx new file mode 100644 index 0000000..b21b45b --- /dev/null +++ b/apps/mobile/features/quick-checkout/QuickCheckoutScreen.tsx @@ -0,0 +1,274 @@ +import { useState } from "react"; + +import { + Pressable, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import { + AuthenticatedHeader, + type AuthenticatedNavigation, +} from "../navigation/AuthenticatedHeader"; + +const product = { + name: "QuickCop Sample Headphones", + store: "Sample Store", + price: 49.99, +}; + +const savedInformation = { + name: "Alex Shopper", + address: "123 Sample Street", + city: "Sampletown", + state: "CA", + zip: "90210", +}; + +type QuickCheckoutScreenProps = { + navigation?: AuthenticatedNavigation; + onLogout?: () => void | Promise; +}; + +export function QuickCheckoutScreen({ + navigation, + onLogout, +}: QuickCheckoutScreenProps) { + const [name, setName] = useState(""); + const [address, setAddress] = useState(""); + const [city, setCity] = useState(""); + const [state, setState] = useState(""); + const [zip, setZip] = useState(""); + const [maximumPrice, setMaximumPrice] = useState(""); + const [autoFill, setAutoFill] = useState(false); + const [autoBuy, setAutoBuy] = useState(false); + const [message, setMessage] = useState(null); + + function toggleAutoFill(enabled: boolean) { + setAutoFill(enabled); + if (enabled) { + setName(savedInformation.name); + setAddress(savedInformation.address); + setCity(savedInformation.city); + setState(savedInformation.state); + setZip(savedInformation.zip); + } + } + + function simulateCheckout() { + const hasInformation = [name, address, city, state, zip].every((value) => + value.trim(), + ); + const limit = Number.parseFloat(maximumPrice); + + if (!hasInformation) { + setMessage("Enter your checkout information."); + return; + } + + if (!Number.isFinite(limit) || limit < 0) { + setMessage("Enter a valid maximum price."); + return; + } + + if (product.price > limit) { + setMessage( + "Checkout simulation stopped: the product exceeds your spending limit.", + ); + return; + } + + setMessage( + autoBuy + ? "Checkout simulation successful. Auto-buy would place this sample order." + : "Checkout simulation successful.", + ); + } + + return ( + + + + + Quick Checkout + + + Practice a faster checkout with placeholder information. No purchase + or payment will be made. + + + + Sample product + {product.name} + {product.store} + ${product.price.toFixed(2)} + + + + Checkout information + + + + + + + + + Auto-fill + + Use saved placeholder information. + + + + + + + + Auto-buy simulation + + Show what an automatic purchase would do. + + + + + + + + Simulate Checkout + + {message && ( + + {message} + + )} + + + + ); +} + +const styles = StyleSheet.create({ + screen: { backgroundColor: "#000", flex: 1 }, + content: { padding: 20, paddingBottom: 40 }, + title: { color: "#fff", fontSize: 30, fontWeight: "700", marginBottom: 8 }, + subtitle: { color: "#aaa", fontSize: 16, lineHeight: 22, marginBottom: 20 }, + productCard: { + backgroundColor: "#161616", + borderColor: "#2b2b2b", + borderRadius: 12, + borderWidth: 1, + marginBottom: 16, + padding: 16, + }, + cardLabel: { color: "#79DCA0", fontSize: 13, fontWeight: "700" }, + productName: { color: "#fff", fontSize: 20, fontWeight: "600", marginTop: 8 }, + store: { color: "#aaa", fontSize: 14, marginTop: 4 }, + price: { color: "#fff", fontSize: 24, fontWeight: "700", marginTop: 12 }, + formCard: { + backgroundColor: "#161616", + borderColor: "#2b2b2b", + borderRadius: 12, + borderWidth: 1, + padding: 16, + }, + sectionTitle: { + color: "#fff", + fontSize: 19, + fontWeight: "600", + marginBottom: 14, + }, + input: { + borderColor: "#444", + borderRadius: 8, + borderWidth: 1, + color: "#fff", + fontSize: 16, + marginBottom: 12, + paddingHorizontal: 12, + paddingVertical: 11, + }, + toggleRow: { + alignItems: "center", + borderTopColor: "#2b2b2b", + borderTopWidth: 1, + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: 14, + }, + toggleCopy: { flex: 1, paddingRight: 12 }, + toggleTitle: { color: "#fff", fontSize: 16, fontWeight: "600" }, + toggleDescription: { color: "#888", fontSize: 13, marginTop: 3 }, + simulateButton: { + alignItems: "center", + backgroundColor: "#2E8B57", + borderRadius: 8, + marginTop: 4, + paddingVertical: 14, + }, + simulateButtonText: { color: "#fff", fontSize: 16, fontWeight: "700" }, + message: { color: "#79DCA0", fontSize: 15, lineHeight: 21, marginTop: 14 }, +}); diff --git a/apps/mobile/lib/secureStorage.ts b/apps/mobile/lib/secureStorage.ts index 82746c9..af54200 100644 --- a/apps/mobile/lib/secureStorage.ts +++ b/apps/mobile/lib/secureStorage.ts @@ -1,7 +1,27 @@ import * as SecureStore from "expo-secure-store"; +import { Platform } from "react-native"; export const secureStorage = { - getItem: (key: string) => SecureStore.getItemAsync(key), - setItem: (key: string, value: string) => SecureStore.setItemAsync(key, value), - removeItem: (key: string) => SecureStore.deleteItemAsync(key), -}; + getItem: (key: string): Promise => { + if (Platform.OS === "web") { + return Promise.resolve(localStorage.getItem(key)); + } + return SecureStore.getItemAsync(key); + }, + + setItem: (key: string, value: string): Promise => { + if (Platform.OS === "web") { + localStorage.setItem(key, value); + return Promise.resolve(); + } + return SecureStore.setItemAsync(key, value); + }, + + removeItem: (key: string): Promise => { + if (Platform.OS === "web") { + localStorage.removeItem(key); + return Promise.resolve(); + } + return SecureStore.deleteItemAsync(key); + }, +}; \ No newline at end of file From 20dcd6bca31909f4e25d83d2b1559ddab7324fb6 Mon Sep 17 00:00:00 2001 From: Jakob Lee Date: Wed, 28 Oct 2026 12:13:49 -0700 Subject: [PATCH 5/6] Fix homepage and secure storage formatting --- apps/mobile/features/home/HomeScreen.tsx | 2 +- apps/mobile/lib/secureStorage.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/features/home/HomeScreen.tsx b/apps/mobile/features/home/HomeScreen.tsx index f8f34a8..dcdad35 100644 --- a/apps/mobile/features/home/HomeScreen.tsx +++ b/apps/mobile/features/home/HomeScreen.tsx @@ -179,4 +179,4 @@ const styles = StyleSheet.create({ flexDirection: "row", gap: 14, }, -}); \ No newline at end of file +}); diff --git a/apps/mobile/lib/secureStorage.ts b/apps/mobile/lib/secureStorage.ts index af54200..f0fd3aa 100644 --- a/apps/mobile/lib/secureStorage.ts +++ b/apps/mobile/lib/secureStorage.ts @@ -24,4 +24,4 @@ export const secureStorage = { } return SecureStore.deleteItemAsync(key); }, -}; \ No newline at end of file +}; From 5b85987724945dda17bf9b485f4014e8f0f1c436 Mon Sep 17 00:00:00 2001 From: Jakob Lee Date: Wed, 28 Oct 2026 12:17:28 -0700 Subject: [PATCH 6/6] Fix homepage test ESLint errors --- apps/mobile/__tests__/home-screen.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/mobile/__tests__/home-screen.test.tsx b/apps/mobile/__tests__/home-screen.test.tsx index d035436..2f3833f 100644 --- a/apps/mobile/__tests__/home-screen.test.tsx +++ b/apps/mobile/__tests__/home-screen.test.tsx @@ -4,13 +4,12 @@ jest.mock("../lib/supabase", () => ({ jest.mock("../features/products/ProductCard", () => ({ ProductCard: ({ name }: { name: string }) => { - const { Text: MockText } = require("react-native"); return {name}; }, })); import { fireEvent, render } from "@testing-library/react-native"; -import { Text } from "react-native"; +import { Text as MockText } from "react-native"; import { HomeScreen } from "../features/home/HomeScreen";