diff --git a/apps/mobile/__tests__/notifications-screen.test.tsx b/apps/mobile/__tests__/notifications-screen.test.tsx new file mode 100644 index 0000000..9493865 --- /dev/null +++ b/apps/mobile/__tests__/notifications-screen.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render } from "@testing-library/react-native"; + +import { + NotificationsScreen, + type PriceAlert, +} from "../features/notifications/NotificationsScreen"; + +describe("NotificationsScreen", () => { + it("shows example price-drop alerts and lets the user toggle an alert locally", async () => { + const screen = await render(); + + expect(screen.getByText("Nintendo Switch 2")).toBeTruthy(); + expect( + screen.getByText("Price dropped from $499.99 to $449.99"), + ).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("shows an empty state when no price alerts are available", async () => { + const alerts: PriceAlert[] = []; + const screen = await render(); + + expect(screen.getByText("No price alerts yet")).toBeTruthy(); + expect( + screen.getByText( + "Watch an item and set a target price to see alerts here.", + ), + ).toBeTruthy(); + }); + + it("lets the user toggle notification settings locally", async () => { + const screen = await render(); + const priceDropToggle = screen.getByRole("switch", { + name: "Price drop notifications", + }); + + expect(priceDropToggle.props.value).toBe(true); + await fireEvent(priceDropToggle, "valueChange", false); + + expect( + screen.getByRole("switch", { name: "Price drop notifications" }).props + .value, + ).toBe(false); + }); +}); diff --git a/apps/mobile/app/home.tsx b/apps/mobile/app/home.tsx index 338d82b..0f4a7d8 100644 --- a/apps/mobile/app/home.tsx +++ b/apps/mobile/app/home.tsx @@ -5,5 +5,10 @@ import { HomeScreen } from "../features/home/HomeScreen"; export default function HomeRoute() { const router = useRouter(); - return router.replace("/")} />; + return ( + router.replace("/")} + onOpenNotifications={() => router.push("/notifications")} + /> + ); } diff --git a/apps/mobile/app/notifications.tsx b/apps/mobile/app/notifications.tsx new file mode 100644 index 0000000..6ae35ba --- /dev/null +++ b/apps/mobile/app/notifications.tsx @@ -0,0 +1,5 @@ +import { NotificationsScreen } from "../features/notifications/NotificationsScreen"; + +export default function NotificationsRoute() { + return ; +} diff --git a/apps/mobile/features/home/HomeScreen.tsx b/apps/mobile/features/home/HomeScreen.tsx index c2fda01..dca7774 100644 --- a/apps/mobile/features/home/HomeScreen.tsx +++ b/apps/mobile/features/home/HomeScreen.tsx @@ -1,23 +1,332 @@ -import { Pressable, SafeAreaView, Text, View } from "react-native"; +import { useState } from "react"; -import { authStyles } from "../auth/styles"; +import { + Pressable, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; type HomeScreenProps = { onLogout: () => void; + onOpenNotifications: () => void; + onOpenWatchedItems?: () => void; }; -export function HomeScreen({ onLogout }: HomeScreenProps) { +export function HomeScreen({ + onLogout, + onOpenNotifications, + onOpenWatchedItems = () => undefined, +}: HomeScreenProps) { + const [menuOpen, setMenuOpen] = useState(false); + const [categoriesOpen, setCategoriesOpen] = useState(false); + const [storesOpen, setStoresOpen] = useState(false); + return ( - - - - Log out - - + + + {/* 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 + + + )} + + {/* Home */} + Home + + {/* Watched Items */} + + Watched Items + + + + You are not watching any items yet. + + Add or manage watched items + + + + {/* Recommended */} + + Recommended + + + + + + + + + {/* Popular */} + + Popular + + + + + + + + ); } + +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, + backgroundColor: "#000", + }, + + content: { + 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, + fontWeight: "700", + marginBottom: 28, + }, + + section: { + marginBottom: 32, + }, + + sectionTitle: { + color: "#fff", + fontSize: 21, + fontWeight: "600", + marginBottom: 14, + }, + + emptyCard: { + backgroundColor: "#161616", + borderWidth: 1, + borderColor: "#2b2b2b", + borderRadius: 12, + padding: 22, + }, + + emptyText: { + color: "#aaa", + fontSize: 15, + }, + + emptyAction: { + color: "#79DCA0", + fontSize: 14, + fontWeight: "600", + marginTop: 10, + }, + + productRow: { + 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/notifications/NotificationsScreen.tsx b/apps/mobile/features/notifications/NotificationsScreen.tsx new file mode 100644 index 0000000..68fdc00 --- /dev/null +++ b/apps/mobile/features/notifications/NotificationsScreen.tsx @@ -0,0 +1,234 @@ +import { useState } from "react"; + +import { ScrollView, StyleSheet, Switch, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +export type PriceAlert = { + id: string; + name: string; + retailer: string; + previousPrice: string; + currentPrice: string; + targetPrice: string; + enabled: boolean; +}; + +const exampleAlerts: PriceAlert[] = [ + { + id: "switch-2", + name: "Nintendo Switch 2", + retailer: "Target", + previousPrice: "$499.99", + currentPrice: "$449.99", + targetPrice: "$450.00", + enabled: true, + }, + { + id: "headphones", + name: "Sony WH-1000XM6", + retailer: "Amazon", + previousPrice: "$449.99", + currentPrice: "$398.00", + targetPrice: "$400.00", + enabled: true, + }, +]; + +type NotificationsScreenProps = { + alerts?: PriceAlert[]; +}; + +export function NotificationsScreen({ + alerts = exampleAlerts, +}: NotificationsScreenProps) { + const [priceAlerts, setPriceAlerts] = useState(alerts); + const [priceDropNotificationsEnabled, setPriceDropNotificationsEnabled] = + useState(true); + const [backInStockNotificationsEnabled, setBackInStockNotificationsEnabled] = + useState(true); + + function setAlertEnabled(id: string, enabled: boolean) { + setPriceAlerts((currentAlerts) => + currentAlerts.map((alert) => + alert.id === id ? { ...alert, enabled } : alert, + ), + ); + } + + return ( + + + + Price alerts + + + Track price drops for the items you are watching. + + + + + Recent price drops + + + {priceAlerts.length === 0 ? ( + + No price alerts yet + + Watch an item and set a target price to see alerts here. + + + ) : ( + priceAlerts.map((alert) => ( + + + {alert.name} + {alert.retailer} + + Price dropped from {alert.previousPrice} to{" "} + {alert.currentPrice} + + + Your target: {alert.targetPrice} + + + + setAlertEnabled(alert.id, enabled) + } + thumbColor={alert.enabled ? "#FFFFFF" : "#B8B8B8"} + trackColor={{ false: "#4A4A4A", true: "#2E8B57" }} + value={alert.enabled} + /> + + )) + )} + + + + + Notification settings + + + + + + + + These settings are shown for preview only and are not saved yet. + + + + + ); +} + +type SettingRowProps = { + description: string; + label: string; + onValueChange: (value: boolean) => void; + value: boolean; +}; + +function SettingRow({ + description, + label, + onValueChange, + value, +}: SettingRowProps) { + return ( + + + {label} + {description} + + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: "#000" }, + content: { padding: 20, paddingBottom: 40 }, + title: { color: "#fff", fontSize: 30, fontWeight: "700", marginBottom: 8 }, + subtitle: { color: "#aaa", fontSize: 16, lineHeight: 22, marginBottom: 32 }, + section: { marginBottom: 32 }, + sectionTitle: { + color: "#fff", + fontSize: 21, + fontWeight: "600", + marginBottom: 14, + }, + alertCard: { + alignItems: "center", + backgroundColor: "#161616", + borderColor: "#2b2b2b", + borderRadius: 12, + borderWidth: 1, + flexDirection: "row", + marginBottom: 12, + padding: 16, + }, + alertContent: { flex: 1, paddingRight: 12 }, + productName: { + color: "#fff", + fontSize: 17, + fontWeight: "600", + marginBottom: 4, + }, + retailer: { color: "#aaa", fontSize: 14, marginBottom: 10 }, + priceChange: { color: "#fff", fontSize: 15, lineHeight: 21, marginBottom: 4 }, + targetPrice: { color: "#79DCA0", 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 }, + settingsCard: { + backgroundColor: "#161616", + borderColor: "#2b2b2b", + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 16, + }, + settingRow: { + alignItems: "center", + flexDirection: "row", + paddingVertical: 16, + }, + settingContent: { flex: 1, paddingRight: 12 }, + settingLabel: { + color: "#fff", + fontSize: 16, + fontWeight: "600", + marginBottom: 4, + }, + settingDescription: { color: "#aaa", fontSize: 14, lineHeight: 20 }, + divider: { backgroundColor: "#2b2b2b", height: 1 }, + helperText: { color: "#888", fontSize: 13, lineHeight: 19, marginTop: 10 }, +}); diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index fc3e146..186ee5f 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -3,13 +3,8 @@ "compilerOptions": { "strict": true, "paths": { - "@/*": [ - "./*" - ] + "@/*": ["./*"] } }, - "include": [ - "**/*.ts", - "**/*.tsx" - ] + "include": ["**/*.ts", "**/*.tsx"] }