Conversation
Signed-off-by: saksh2110 <139182976+saksh2110@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This pull request adds Insights functionality to DiscountMate, providing analytical views for savings, category trends, and retailer performance. However, it also includes an unrelated profile component and contains several critical issues that need to be addressed.
Changes:
- Implemented three Insights pages (Savings Overview, Category Trends, Retailer Performance) with supporting utilities
- Added reusable UI components for displaying insights statistics and bar charts
- Integrated insights navigation through homepage cards with category routing
- Added profile management screen with image upload functionality (unrelated to Insights)
- Updated package dependencies (automatic lockfile updates)
Reviewed changes
Copilot reviewed 14 out of 17 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| Frontend/src/screens/profiles/profile.tsx | New profile screen with image upload (unrelated to PR scope, contains critical import path errors) |
| Frontend/lib/insightsdata.ts | Duplicate insights data utilities (should be removed) |
| Frontend/lib/insightsData.ts | Core insights data fetching and calculation utilities with mock fallback |
| Frontend/components/insights/InsightStatCard.tsx | Reusable stat card component for displaying key metrics |
| Frontend/components/insights/InsightBarRow.tsx | Reusable bar chart row component for comparative visualizations |
| Frontend/components/home/TrendingInsightsSection.tsx | Updated homepage section with category navigation |
| Frontend/app/insights/index.tsx | Insights landing page with navigation cards |
| Frontend/app/insights/savings.tsx | Savings overview analysis page |
| Frontend/app/insights/categories.tsx | Category trends analysis page |
| Frontend/app/insights/retailers.tsx | Retailer performance comparison page |
| Frontend/package-lock.json | Automatic dependency resolution updates |
| Backend/yarn.lock | Minor dependency addition (fsevents) |
| Backend/package-lock.json | Dependency peer marking updates |
Files not reviewed (2)
- Backend/package-lock.json: Language not supported
- Frontend/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // IMPORTANT: Replace with your machine IP | ||
| const API_URL = "http://172.16.11.120:3000"; |
There was a problem hiding this comment.
The hardcoded IP address should not be committed to the repository. This IP address (172.16.11.120:3000) appears to be a local development machine address. Use an environment variable instead (e.g., process.env.EXPO_PUBLIC_API_BASE_URL or similar) to allow configuration across different environments without code changes.
| // IMPORTANT: Replace with your machine IP | |
| const API_URL = "http://172.16.11.120:3000"; | |
| // API base URL is configurable via environment variable | |
| const API_URL = process.env.EXPO_PUBLIC_API_BASE_URL || "http://localhost:3000"; |
| import { useAuth } from "../context/AuthContext"; | ||
| import Entypo from "react-native-vector-icons/Entypo"; | ||
|
|
||
| const defaultImage = require("../assets/images/defaultprofileimage.png"); |
There was a problem hiding this comment.
The import path "../assets/images/defaultprofileimage.png" appears incorrect. Based on the file location being Frontend/src/screens/profiles/profile.tsx, the path should likely be "../../assets/images/defaultprofileimage.png" (two levels up) or use an absolute import path. This will cause a runtime error when the image cannot be found.
| const defaultImage = require("../assets/images/defaultprofileimage.png"); | |
| const defaultImage = require("../../assets/images/defaultprofileimage.png"); |
| import axios from "axios"; | ||
| import AsyncStorage from "@react-native-async-storage/async-storage"; | ||
| import * as ImagePicker from "expo-image-picker"; | ||
| import { useAuth } from "../context/AuthContext"; |
There was a problem hiding this comment.
The import path "../context/AuthContext" appears incorrect. Since this file is at Frontend/src/screens/profiles/profile.tsx, the correct path should be "../../context/AuthContext" (two levels up to reach src/) or use an absolute import. This will cause a module resolution error.
| import { useAuth } from "../context/AuthContext"; | |
| import { useAuth } from "../../context/AuthContext"; |
| console.log("Profile fetch error:", err); | ||
| } | ||
| }; | ||
|
|
||
| useEffect(() => { | ||
| fetchProfile(); | ||
| }, []); | ||
|
|
||
| const pickImage = async () => { | ||
| const result = await ImagePicker.launchImageLibraryAsync({ | ||
| mediaTypes: ImagePicker.MediaTypeOptions.Images, | ||
| allowsEditing: true, | ||
| base64: true, | ||
| quality: 1, | ||
| }); | ||
|
|
||
| if (!result.canceled) { | ||
| const file = result.assets[0]; | ||
| setImage(file.uri); | ||
| await uploadImage(file); | ||
| setModalVisible(true); | ||
| } | ||
| }; | ||
|
|
||
| const uploadImage = async (file: any) => { | ||
| const token = await AsyncStorage.getItem("authToken"); | ||
| if (!token) return; | ||
|
|
||
| const formData = new FormData(); | ||
| formData.append("image", { | ||
| uri: file.uri, | ||
| type: file.mimeType || "image/jpeg", | ||
| name: file.fileName || "profile.jpg", | ||
| }); | ||
|
|
||
| try { | ||
| await axios.post(`${API_URL}/api/users/upload-profile-image`, formData, { | ||
| headers: { | ||
| Authorization: `Bearer ${token}`, | ||
| "Content-Type": "multipart/form-data", | ||
| }, | ||
| }); | ||
|
|
||
| fetchProfile(); // refresh UI | ||
| } catch (err) { | ||
| console.log("Upload error:", err); |
There was a problem hiding this comment.
The error handling using console.log is inconsistent with the empty catch blocks elsewhere. Consider using a consistent error handling pattern across the file, such as a dedicated error logging utility or at least console.error for error messages.
| export type Retailer = { | ||
| name: string; | ||
| price: string; | ||
| originalPrice?: string; | ||
| isCheapest?: boolean; | ||
| }; | ||
|
|
||
| export type InsightsProduct = { | ||
| id: string; | ||
| name: string; | ||
| subtitle?: string; | ||
| category?: string; | ||
| retailers: Retailer[]; | ||
| }; | ||
|
|
||
| export function parseMoney(input?: string): number | null { | ||
| if (!input) return null; | ||
| const numeric = parseFloat(String(input).replace(/[^0-9.]/g, "")); | ||
| return Number.isFinite(numeric) ? numeric : null; | ||
| } | ||
|
|
||
| export function computeRetailerRow(ret: Retailer) { | ||
| const current = parseMoney(ret.price); | ||
| const original = parseMoney(ret.originalPrice); | ||
| const savings = | ||
| current != null && original != null && original > current ? original - current : 0; | ||
|
|
||
| const discountPct = | ||
| current != null && original != null && original > 0 && original > current | ||
| ? (savings / original) * 100 | ||
| : 0; | ||
|
|
||
| return { current, original, savings, discountPct }; | ||
| } | ||
|
|
||
| export function normalizeCheapest(retailers: Retailer[]): Retailer[] { | ||
| if (!retailers || retailers.length === 0) return []; | ||
| const hasExplicit = retailers.some((r) => r.isCheapest); | ||
| if (hasExplicit) return retailers; | ||
|
|
||
| const scored = retailers.map((r) => { | ||
| const current = parseMoney(r.price); | ||
| return { r, current: current ?? Number.POSITIVE_INFINITY }; | ||
| }); | ||
|
|
||
| const min = scored.reduce((a, b) => (b.current < a.current ? b : a)); | ||
| return retailers.map((r) => (r === min.r ? { ...r, isCheapest: true } : r)); | ||
| } | ||
|
|
||
| export async function fetchProductsForInsights(): Promise<InsightsProduct[]> { | ||
| const base = process.env.EXPO_PUBLIC_API_BASE_URL; | ||
|
|
||
| if (!base) return mockProducts(); | ||
|
|
||
| const candidates = [ | ||
| `${base}/products`, | ||
| `${base}/api/products`, | ||
| `${base}/specials`, | ||
| `${base}/api/specials`, | ||
| ]; | ||
|
|
||
| for (const url of candidates) { | ||
| try { | ||
| const res = await fetch(url); | ||
| if (!res.ok) continue; | ||
| const json = await res.json(); | ||
|
|
||
| const arr: any[] = | ||
| Array.isArray(json) ? json : Array.isArray(json?.products) ? json.products : []; | ||
|
|
||
| const cleaned: InsightsProduct[] = arr | ||
| .filter((p) => p && p.id && p.name && Array.isArray(p.retailers)) | ||
| .map((p) => ({ | ||
| id: String(p.id), | ||
| name: String(p.name), | ||
| subtitle: p.subtitle ? String(p.subtitle) : "", | ||
| category: p.category ? String(p.category) : p.subtitle ? String(p.subtitle) : "Other", | ||
| retailers: (p.retailers || []).map((r: any) => ({ | ||
| name: String(r.name ?? r.retailer ?? "Unknown"), | ||
| price: String(r.price ?? ""), | ||
| originalPrice: r.originalPrice ? String(r.originalPrice) : undefined, | ||
| isCheapest: Boolean(r.isCheapest), | ||
| })), | ||
| })); | ||
|
|
||
| if (cleaned.length) return cleaned; | ||
| } catch {} | ||
| } | ||
|
|
||
| return mockProducts(); | ||
| } | ||
|
|
||
| function mockProducts(): InsightsProduct[] { | ||
| return [ | ||
| { | ||
| id: "p1", | ||
| name: "Greek Yogurt 1kg", | ||
| category: "Dairy, Eggs & Fridge", | ||
| retailers: normalizeCheapest([ | ||
| { name: "Coles", price: "$6.00", originalPrice: "$7.50" }, | ||
| { name: "Woolworths", price: "$6.50", originalPrice: "$7.50" }, | ||
| { name: "Aldi", price: "$5.80", originalPrice: "$7.00" }, | ||
| ]), | ||
| }, | ||
| { | ||
| id: "p2", | ||
| name: "Dishwashing Liquid 750ml", | ||
| category: "Household", | ||
| retailers: normalizeCheapest([ | ||
| { name: "Coles", price: "$4.50", originalPrice: "$6.00" }, | ||
| { name: "Woolworths", price: "$4.20", originalPrice: "$6.00" }, | ||
| { name: "Aldi", price: "$4.80", originalPrice: "$5.50" }, | ||
| ]), | ||
| }, | ||
| { | ||
| id: "p3", | ||
| name: "Bananas 1kg", | ||
| category: "Fruit & Vegetables", | ||
| retailers: normalizeCheapest([ | ||
| { name: "Coles", price: "$3.40", originalPrice: "$4.20" }, | ||
| { name: "Woolworths", price: "$3.60", originalPrice: "$4.20" }, | ||
| { name: "Aldi", price: "$3.20", originalPrice: "$4.00" }, | ||
| ]), | ||
| }, | ||
| ]; | ||
| } |
There was a problem hiding this comment.
Two nearly identical files exist with different casing: insightsdata.ts and insightsData.ts. This creates confusion and potential issues on case-sensitive filesystems (Linux/Unix). Only one version should exist. Based on JavaScript/TypeScript conventions, insightsData.ts (with capital D) is the preferred naming. The insightsdata.ts file should be removed.
| })); | ||
|
|
||
| if (cleaned.length) return cleaned; | ||
| } catch {} |
There was a problem hiding this comment.
Empty catch block silently swallows errors, making debugging difficult. At minimum, log the error to help diagnose API connection issues during development and production troubleshooting.
| } catch {} | |
| } catch (err) { | |
| console.error("Failed to fetch insights products from", url, err); | |
| } |
| })); | ||
|
|
||
| if (cleaned.length) return cleaned; | ||
| } catch { |
There was a problem hiding this comment.
Empty catch block silently swallows errors, making debugging difficult. At minimum, log the error to help diagnose API connection issues during development and production troubleshooting.
| } catch { | |
| } catch (error) { | |
| console.error("Failed to fetch insights products from URL:", url, error); |
| import React, { useEffect, useState } from "react"; | ||
| import { | ||
| View, | ||
| Text, | ||
| StyleSheet, | ||
| Image, | ||
| TouchableOpacity, | ||
| ScrollView, | ||
| Modal, | ||
| } from "react-native"; | ||
| import axios from "axios"; | ||
| import AsyncStorage from "@react-native-async-storage/async-storage"; | ||
| import * as ImagePicker from "expo-image-picker"; | ||
| import { useAuth } from "../context/AuthContext"; | ||
| import Entypo from "react-native-vector-icons/Entypo"; | ||
|
|
||
| const defaultImage = require("../assets/images/defaultprofileimage.png"); | ||
|
|
||
| interface Profile { | ||
| user_fname: string; | ||
| user_lname: string; | ||
| email: string; | ||
| phone_number: string; | ||
| address: string; | ||
| profile_image?: { | ||
| mime: string; | ||
| content: string; | ||
| } | null; | ||
| } | ||
|
|
||
| export default function Profile() { | ||
| const { logout } = useAuth(); | ||
| const [profile, setProfile] = useState<Profile | null>(null); | ||
| const [image, setImage] = useState<string | null>(null); | ||
| const [modalVisible, setModalVisible] = useState(false); | ||
|
|
||
| // IMPORTANT: Replace with your machine IP | ||
| const API_URL = "http://172.16.11.120:3000"; | ||
|
|
||
| const formatImage = (img: any) => { | ||
| if (!img) return null; | ||
| return `data:${img.mime};base64,${img.content}`; | ||
| }; | ||
|
|
||
| const fetchProfile = async () => { | ||
| try { | ||
| const token = await AsyncStorage.getItem("authToken"); | ||
| if (!token) return; | ||
|
|
||
| const response = await axios.get(`${API_URL}/api/users/profile`, { | ||
| headers: { Authorization: `Bearer ${token}` }, | ||
| }); | ||
|
|
||
| setProfile(response.data); | ||
|
|
||
| if (response.data.profile_image) { | ||
| setImage(formatImage(response.data.profile_image)); | ||
| } | ||
| } catch (err) { | ||
| console.log("Profile fetch error:", err); | ||
| } | ||
| }; | ||
|
|
||
| useEffect(() => { | ||
| fetchProfile(); | ||
| }, []); | ||
|
|
||
| const pickImage = async () => { | ||
| const result = await ImagePicker.launchImageLibraryAsync({ | ||
| mediaTypes: ImagePicker.MediaTypeOptions.Images, | ||
| allowsEditing: true, | ||
| base64: true, | ||
| quality: 1, | ||
| }); | ||
|
|
||
| if (!result.canceled) { | ||
| const file = result.assets[0]; | ||
| setImage(file.uri); | ||
| await uploadImage(file); | ||
| setModalVisible(true); | ||
| } | ||
| }; | ||
|
|
||
| const uploadImage = async (file: any) => { | ||
| const token = await AsyncStorage.getItem("authToken"); | ||
| if (!token) return; | ||
|
|
||
| const formData = new FormData(); | ||
| formData.append("image", { | ||
| uri: file.uri, | ||
| type: file.mimeType || "image/jpeg", | ||
| name: file.fileName || "profile.jpg", | ||
| }); | ||
|
|
||
| try { | ||
| await axios.post(`${API_URL}/api/users/upload-profile-image`, formData, { | ||
| headers: { | ||
| Authorization: `Bearer ${token}`, | ||
| "Content-Type": "multipart/form-data", | ||
| }, | ||
| }); | ||
|
|
||
| fetchProfile(); // refresh UI | ||
| } catch (err) { | ||
| console.log("Upload error:", err); | ||
| } | ||
| }; | ||
|
|
||
| const signOut = () => { | ||
| logout(); | ||
| setProfile(null); | ||
| }; | ||
|
|
||
| return ( | ||
| <ScrollView style={styles.container}> | ||
| <View style={styles.card}> | ||
| <Image | ||
| source={image ? { uri: image } : defaultImage} | ||
| style={styles.profileImage} | ||
| /> | ||
|
|
||
| <Text style={styles.name}> | ||
| {profile?.user_fname} {profile?.user_lname} | ||
| </Text> | ||
|
|
||
| <Text style={styles.text}>{profile?.email}</Text> | ||
| <Text style={styles.text}>{profile?.phone_number}</Text> | ||
| <Text style={styles.text}>{profile?.address}</Text> | ||
|
|
||
| <TouchableOpacity style={styles.button} onPress={pickImage}> | ||
| <Entypo name="camera" size={20} color="#fff" /> | ||
| <Text style={styles.buttonText}>Change Profile Picture</Text> | ||
| </TouchableOpacity> | ||
|
|
||
| <TouchableOpacity style={styles.logoutButton} onPress={signOut}> | ||
| <Text style={styles.logoutButtonText}>Logout</Text> | ||
| </TouchableOpacity> | ||
| </View> | ||
|
|
||
| {/* Success Modal */} | ||
| <Modal transparent visible={modalVisible}> | ||
| <View style={styles.modalOverlay}> | ||
| <View style={styles.modal}> | ||
| <Text style={styles.modalText}>Profile Updated!</Text> | ||
| <TouchableOpacity | ||
| style={styles.closeBtn} | ||
| onPress={() => setModalVisible(false)} | ||
| > | ||
| <Text style={styles.closeTxt}>OK</Text> | ||
| </TouchableOpacity> | ||
| </View> | ||
| </View> | ||
| </Modal> | ||
| </ScrollView> | ||
| ); | ||
| } | ||
|
|
||
| const styles = StyleSheet.create({ | ||
| container: { | ||
| backgroundColor: "#f5f5f5", | ||
| flex: 1, | ||
| padding: 20, | ||
| }, | ||
| card: { | ||
| backgroundColor: "#fff", | ||
| padding: 25, | ||
| borderRadius: 15, | ||
| alignItems: "center", | ||
| elevation: 5, | ||
| }, | ||
| profileImage: { | ||
| width: 140, | ||
| height: 140, | ||
| borderRadius: 80, | ||
| marginBottom: 20, | ||
| }, | ||
| name: { | ||
| fontSize: 22, | ||
| fontWeight: "600", | ||
| marginBottom: 10, | ||
| }, | ||
| text: { | ||
| fontSize: 16, | ||
| marginBottom: 4, | ||
| color: "#444", | ||
| }, | ||
| button: { | ||
| flexDirection: "row", | ||
| backgroundColor: "#0c8", | ||
| padding: 10, | ||
| borderRadius: 8, | ||
| marginVertical: 20, | ||
| }, | ||
| buttonText: { | ||
| color: "#fff", | ||
| marginLeft: 8, | ||
| fontSize: 16, | ||
| }, | ||
| logoutButton: { | ||
| backgroundColor: "#d9534f", | ||
| padding: 12, | ||
| borderRadius: 8, | ||
| }, | ||
| logoutButtonText: { | ||
| color: "#fff", | ||
| fontSize: 16, | ||
| }, | ||
| modalOverlay: { | ||
| flex: 1, | ||
| justifyContent: "center", | ||
| alignItems: "center", | ||
| backgroundColor: "rgba(0,0,0,0.4)", | ||
| }, | ||
| modal: { | ||
| backgroundColor: "#fff", | ||
| padding: 25, | ||
| borderRadius: 10, | ||
| }, | ||
| modalText: { | ||
| fontSize: 18, | ||
| marginBottom: 20, | ||
| textAlign: "center", | ||
| }, | ||
| closeBtn: { | ||
| backgroundColor: "#0c8", | ||
| borderRadius: 6, | ||
| padding: 10, | ||
| }, | ||
| closeTxt: { | ||
| color: "#fff", | ||
| textAlign: "center", | ||
| }, | ||
| }); |
There was a problem hiding this comment.
The profile screen is added to Frontend/src/screens/profiles/profile.tsx but the PR description focuses on Insights functionality. This profile component appears unrelated to the stated purpose of implementing Insights pages. Consider moving profile-related changes to a separate PR to maintain focused, single-purpose pull requests.
| <Text className="text-sm text-primary_green font-semibold"> | ||
| View all dairy deals | ||
| </Text> | ||
| <FontAwesome6 name="arrow-right" size={14} color="#16A34A" /> |
There was a problem hiding this comment.
Hardcoded color value "#16A34A" should use the theme color variable "text-primary_green" className or a defined color constant to maintain consistency with the design system. The same applies to "#F97316" and "#2563EB" on lines 92 and 129.
This pull request adds fully implemented Insights functionality to DiscountMate, replacing placeholder content with working pages and reusable logic.
Key updates include:
Implemented complete Insights pages for:
Savings Overview
Category Trends
Retailer Performance
Added shared insights data utilities for:
Price parsing and savings calculations
Retailer comparison and cheapest-price normalization
Integrated insights navigation with homepage cards and routing
Ensured pages render with mock data fallback while backend integration is finalised
Resolved merge conflicts and synced fork with upstream main
These changes improve analytical depth, support user decision-making, and align the Insights section with the overall DiscountMate product vision.