diff --git a/apps/mobile/__tests__/authenticated-header.test.tsx b/apps/mobile/__tests__/authenticated-header.test.tsx new file mode 100644 index 0000000..b6cddd1 --- /dev/null +++ b/apps/mobile/__tests__/authenticated-header.test.tsx @@ -0,0 +1,58 @@ +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"], + ["Quick Checkout", "/quick-checkout"], + ]) { + 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..2f3833f --- /dev/null +++ b/apps/mobile/__tests__/home-screen.test.tsx @@ -0,0 +1,59 @@ +jest.mock("../lib/supabase", () => ({ + supabase: { auth: { signOut: jest.fn() } }, +})); + +jest.mock("../features/products/ProductCard", () => ({ + ProductCard: ({ name }: { name: string }) => { + return {name}; + }, +})); + +import { fireEvent, render } from "@testing-library/react-native"; +import { Text as MockText } 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( + , + ); + + 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__/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__/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/__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/(protected)/home.tsx b/apps/mobile/app/(protected)/home.tsx index 2bc6a84..1ddc226 100644 --- a/apps/mobile/app/(protected)/home.tsx +++ b/apps/mobile/app/(protected)/home.tsx @@ -9,6 +9,7 @@ export default function HomeRoute() { return ( router.replace(route as never) }} onAccountInfo={() => router.push("/(protected)/account")} onLogout={async () => { let result = { ok: false }; @@ -23,6 +24,10 @@ export default function HomeRoute() { }); }} onOpenNotifications={() => router.push("/(protected)/notifications")} + onOpenWatchedItems={() => router.push("/(protected)/watched-items")} + onOpenPopularProducts={() => + router.push("/(protected)/home/popular-products") + } /> ); } diff --git a/apps/mobile/app/(protected)/home/popular-products.tsx b/apps/mobile/app/(protected)/home/popular-products.tsx new file mode 100644 index 0000000..d2a70c9 --- /dev/null +++ b/apps/mobile/app/(protected)/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/(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/app/(protected)/recommended.tsx b/apps/mobile/app/(protected)/recommended.tsx new file mode 100644 index 0000000..50eb2e2 --- /dev/null +++ b/apps/mobile/app/(protected)/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/(protected)/watched-items.tsx b/apps/mobile/app/(protected)/watched-items.tsx new file mode 100644 index 0000000..4d83d65 --- /dev/null +++ b/apps/mobile/app/(protected)/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/home/HomeScreen.tsx b/apps/mobile/features/home/HomeScreen.tsx index 06fab4b..dcdad35 100644 --- a/apps/mobile/features/home/HomeScreen.tsx +++ b/apps/mobile/features/home/HomeScreen.tsx @@ -1,121 +1,44 @@ -import { useState } from "react"; - import { Pressable, ScrollView, StyleSheet, Text, 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 HomeScreenProps = { + navigation?: AuthenticatedNavigation; onAccountInfo?: () => void; onLogout: () => void | Promise; onOpenNotifications: () => void; onOpenWatchedItems?: () => void; + onOpenPopularProducts?: () => void; }; export function HomeScreen({ + navigation, onAccountInfo, 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 onLogout(); - } - return ( - {/* Header */} - - setMenuOpen(!menuOpen)} - > - ☰ - - - QuickCop - - - ⌕ - - - - {/* Hamburger Menu */} - {menuOpen && ( - - - Home - - - - Watched Items - - - - Price alerts - - - {onAccountInfo ? ( - - Account info - - ) : null} - - - 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 if (route === "/account" && onAccountInfo) onAccountInfo(); + else navigation?.replace(route); + }} + /> {/* Home */} Home @@ -142,20 +65,49 @@ export function HomeScreen({ Recommended - - - + + + {/* Popular */} - Popular + + Popular + + See all + + - - - + + + @@ -163,26 +115,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, @@ -193,64 +125,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, @@ -266,9 +140,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, @@ -293,46 +179,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/navigation/AuthenticatedHeader.tsx b/apps/mobile/features/navigation/AuthenticatedHeader.tsx new file mode 100644 index 0000000..f2caa93 --- /dev/null +++ b/apps/mobile/features/navigation/AuthenticatedHeader.tsx @@ -0,0 +1,141 @@ +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" }, + { label: "Quick Checkout", route: "/quick-checkout" }, + { label: "Account", route: "/account" }, +]; + +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/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..b48318e --- /dev/null +++ b/apps/mobile/features/products/ProductCard.tsx @@ -0,0 +1,81 @@ +import { StyleSheet, Text, View } from "react-native"; + +type ProductCardProps = { + layout?: "row" | "grid"; + name: string; + store: string; + price: string; +}; + +export function ProductCard({ + layout = "row", + name, + store, + price, +}: ProductCardProps) { + const isGrid = layout === "grid"; + + return ( + + + IMG + + + + {name} + + {store} + {price} + + + ); +} + +const styles = StyleSheet.create({ + productCard: { + alignItems: "center", + backgroundColor: "#161616", + borderColor: "#2b2b2b", + borderRadius: 12, + borderWidth: 1, + flexDirection: "row", + padding: 14, + }, + gridProductCard: { + alignItems: "stretch", + flex: 1, + flexDirection: "column", + padding: 12, + }, + imagePlaceholder: { + alignItems: "center", + backgroundColor: "#252525", + borderRadius: 8, + height: 72, + 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 }, +}); 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/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/lib/secureStorage.ts b/apps/mobile/lib/secureStorage.ts index 82746c9..f0fd3aa 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); + }, };