diff --git a/frontend/.env.example b/frontend/.env.example index 9df9d4660..ff9347106 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -2,43 +2,29 @@ # These are read by app.config.js and injected into the app via expo-constants. # Copy this file to .env and adjust values for your environment. # -# Production defaults: +# Production defaults (no backend needed to test the UI): PROD_URL="https://uhsocial.in/api" SOCKET_PROD="https://uhsocial.in" CONTENT_CHECKER_PROD="https://uhsocial.in/content-intel" -# Local development examples (uncomment and edit as needed): +# Local development (uncomment and edit as needed): # PROD_URL="http://10.0.2.2:3000/api" # Android emulator -# PROD_URL="http://localhost:3000/api" # iOS simulator +# PROD_URL="http://localhost:3000/api" # iOS simulator / web # SOCKET_PROD="http://10.0.2.2:3000" # CONTENT_CHECKER_PROD="http://10.0.2.2:3000/content-intel" -FIREBASE_API_KEY = "FIREBASE_API_KEY" -FIREBASE_AUTH_DOMAIN ="FIREBASE_AUTH_DOMAIN" -FIREBASE_PROJECT_ID = "FIREBASE_PROJECT_ID" -FIREBASE_STORAGE_BUCKET = "FIREBASE_STORAGE_BUCKET" -FIREBASE_SENDER_ID = "FIREBASE_SENDER_ID" -FIREBASE_APP_ID = "FIREBASE_APP_ID" -# Gemini API Configuration (for AI-powered article summaries) +# --------------------------------------------------------------------------- +# Gemini API — for AI-powered article summaries & health chat +# --------------------------------------------------------------------------- # Get your free API key at: https://aistudio.google.com/app/apikey # Free tier: 1500 requests/day, no credit card required EXPO_PUBLIC_GEMINI_API_KEY="YOUR_GEMINI_API_KEY_HERE" -FIREBASE_MEASUREMENT_ID = "FIREBASE_MEASUREMENT_ID" -FIREBASE_DATABASE_URL = "FIREBASE_DATABASE_URL" -# Sentry Configuration -EXPO_PUBLIC_SENTRY_DSN = "YOUR_SENTRY_DSN" -EXPO_PUBLIC_APP_ENV = "development" -{ - "expo": { - "name": "UltimateHealth", - "extra": { - GEMINI_API_KEY=YOUR_GEMINI_API_KEY_HERE - } - } -} - -# Firebase Configuration +# --------------------------------------------------------------------------- +# Firebase Configuration — for push notifications (FCM) +# --------------------------------------------------------------------------- +# These are read by app.config.js and passed via Constants.expoConfig.extra. +# Required for push notifications. Leave empty to skip Firebase locally. FIREBASE_API_KEY_ANDROID="" FIREBASE_API_KEY_IOS="" FIREBASE_APP_ID_ANDROID="" @@ -50,7 +36,9 @@ FIREBASE_SENDER_ID="" FIREBASE_MEASUREMENT_ID="" FIREBASE_DATABASE_URL="" - -# Sentry Configuration +# --------------------------------------------------------------------------- +# Sentry Configuration — for error monitoring +# --------------------------------------------------------------------------- +# Set to empty to skip Sentry initialization in development. EXPO_PUBLIC_SENTRY_DSN="" EXPO_PUBLIC_APP_ENV="development" diff --git a/frontend/app.config.js b/frontend/app.config.js index 01664a5ca..6807ce61b 100644 --- a/frontend/app.config.js +++ b/frontend/app.config.js @@ -149,7 +149,7 @@ module.exports = ({ config }) => { // API URLs — read from environment variables with production fallbacks. // Override these in your local .env file (see .env.example). PROD_URL: - process.env.PROD_URL ?? "https://uhsocial.in/ap", + process.env.PROD_URL ?? "https://uhsocial.in/api", SOCKET_PROD: process.env.SOCKET_PROD ?? "https://uhsocial.in", CONTENT_CHECKER_PROD: diff --git a/frontend/index.js b/frontend/index.js index 21c32a6fa..8c0066af4 100644 --- a/frontend/index.js +++ b/frontend/index.js @@ -12,12 +12,15 @@ import { logger } from './src/services/monitoring/logger';// Firebase background // to AppContent.tsx's first useEffect so that expo-application metadata // (nativeApplicationVersion, nativeBuildVersion) is fully available before // Sentry initialises. -messaging().setBackgroundMessageHandler(async remoteMessage => { - // Only log notification payloads in development to avoid leaking user data - // or FCM tokens in production log aggregators. - +// Guard: @react-native-firebase/messaging is undefined on web. +const firebaseMessaging = messaging(); +if (firebaseMessaging) { + firebaseMessaging.setBackgroundMessageHandler(async remoteMessage => { + // Only log notification payloads in development to avoid leaking user data + // or FCM tokens in production log aggregators. logger.log('Background notification received:', remoteMessage); -}); + }); +} const AppWrapper = () => { return ( diff --git a/frontend/metro.config.js b/frontend/metro.config.js index fc6c4836b..da952293e 100644 --- a/frontend/metro.config.js +++ b/frontend/metro.config.js @@ -4,13 +4,23 @@ const path = require('path'); /** @type {import('expo/metro-config').MetroConfig} */ const config = getDefaultConfig(__dirname); -// Alias react-native-pager-view to a mock for web +// Alias native-only modules to web mocks +const WEB_MOCKS = { + 'react-native-pager-view': 'mocks/react-native-pager-view.js', + 'react-native-snackbar': 'mocks/react-native-snackbar.js', + 'react-native-share': 'mocks/react-native-share.js', + 'react-native-fs': 'mocks/react-native-fs.js', + 'react-native-html-to-pdf': 'mocks/react-native-html-to-pdf.js', + '@react-native-firebase/app': 'mocks/react-native-firebase-app.js', + '@react-native-firebase/messaging': 'mocks/react-native-firebase-messaging.js', +}; + const originalResolveRequest = config.resolver.resolveRequest; config.resolver.resolveRequest = (context, moduleName, platform) => { - if (platform === 'web' && moduleName === 'react-native-pager-view') { + if (platform === 'web' && WEB_MOCKS[moduleName]) { return { - filePath: path.resolve(__dirname, 'mocks/react-native-pager-view.js'), + filePath: path.resolve(__dirname, WEB_MOCKS[moduleName]), type: 'sourceFile', }; } diff --git a/frontend/mocks/react-native-firebase-app.js b/frontend/mocks/react-native-firebase-app.js new file mode 100644 index 000000000..0c5dd4a41 --- /dev/null +++ b/frontend/mocks/react-native-firebase-app.js @@ -0,0 +1,17 @@ +// Web mock for @react-native-firebase/app + +const firebase = { + apps: [], + initializeApp: () => ({ + name: '[DEFAULT]', + options: {}, + }), + app: () => ({ + name: '[DEFAULT]', + options: {}, + }), + messaging: () => null, + auth: () => null, +}; + +export default firebase; diff --git a/frontend/mocks/react-native-firebase-messaging.js b/frontend/mocks/react-native-firebase-messaging.js new file mode 100644 index 000000000..d357f3b85 --- /dev/null +++ b/frontend/mocks/react-native-firebase-messaging.js @@ -0,0 +1,18 @@ +// Web mock for @react-native-firebase/messaging +// Default export is a factory function: messaging() returns an instance. + +const messaging = () => ({ + setBackgroundMessageHandler: () => {}, + onMessage: () => () => {}, + onTokenRefresh: () => () => {}, + onNotificationOpenedApp: () => () => {}, + getInitialNotification: async () => null, + getToken: async () => null, + requestPermission: async () => {}, + registerForRemoteNotifications: async () => {}, + hasPermission: async () => 1, + subscribeToTopic: () => {}, + unsubscribeFromTopic: () => {}, +}); + +export default messaging; diff --git a/frontend/mocks/react-native-fs.js b/frontend/mocks/react-native-fs.js new file mode 100644 index 000000000..8e8bf47da --- /dev/null +++ b/frontend/mocks/react-native-fs.js @@ -0,0 +1,60 @@ +// Web mock for react-native-fs +// On web, RNFSFileTypeRegular and other native constants crash at import time. + +const RNFS = { + // Constants + RNFilesystemType: 'web', + MainBundlePath: '', + CachesDirectoryPath: '', + DocumentDirectoryPath: '', + ExternalDirectoryPath: '', + ExternalStorageDirectoryPath: '', + TemporaryDirectoryPath: '', + LibraryDirectoryPath: '', + PicturesDirectoryPath: '', + + // File read/write + readDir: async () => [], + readdir: async () => [], + stat: async () => ({ name: '', path: '', size: 0, mode: 0 }), + readFile: async () => '', + read: async () => '', + readFileAssets: async () => '', + exists: async () => false, + isResumable: async () => false, + + // File write + writeFile: async () => {}, + appendFile: async () => {}, + write: async () => {}, + unlink: async () => {}, + unlinkIfExists: async () => {}, + moveFile: async () => {}, + copyFile: async () => {}, + copyFileAssets: async () => {}, + existsAssets: async () => false, + mkdir: async () => {}, + touch: async () => {}, + + // Download + downloadFile: () => ({ + jobId: 0, + promise: Promise.resolve({ statusCode: 200, jobId: 0 }), + }), + stopDownload: () => {}, + + // Upload + uploadFiles: () => ({ + jobId: 0, + promise: Promise.resolve({ statusCode: 200, jobId: 0, body: '' }), + }), + stopUpload: () => {}, + + // Other + getFSInfo: async () => ({ totalSpace: 0, freeSpace: 0 }), + hash: async () => '', + getAllExternalFilesDirs: async () => [], + scanFile: async () => {}, +}; + +module.exports = RNFS; diff --git a/frontend/mocks/react-native-html-to-pdf.js b/frontend/mocks/react-native-html-to-pdf.js new file mode 100644 index 000000000..c41f705ef --- /dev/null +++ b/frontend/mocks/react-native-html-to-pdf.js @@ -0,0 +1,11 @@ +// Web mock for react-native-html-to-pdf + +const RNHTMLtoPDF = { + generate: async () => ({ filePath: '' }), + convert: async () => ({ filePath: '' }), +}; + +const generatePDF = RNHTMLtoPDF.generate; + +export { generatePDF }; +export default RNHTMLtoPDF; diff --git a/frontend/mocks/react-native-share.js b/frontend/mocks/react-native-share.js new file mode 100644 index 000000000..70ca1fb45 --- /dev/null +++ b/frontend/mocks/react-native-share.js @@ -0,0 +1,18 @@ +// Web mock for react-native-share +// On web, NativeModules.RNShare is undefined, which crashes at import time. + +const Share = { + open: async () => ({ success: true, message: 'Shared (mock)' }), + canShare: async () => true, + shareSingle: async () => ({ success: true }), + Social: { + FACEBOOK: 'facebook', + TWITTER: 'twitter', + WHATSAPP: 'whatsapp', + INSTAGRAM: 'instagram', + TELEGRAM: 'telegram', + EMAIL: 'email', + }, +}; + +export default Share; diff --git a/frontend/mocks/react-native-snackbar.js b/frontend/mocks/react-native-snackbar.js new file mode 100644 index 000000000..56924987a --- /dev/null +++ b/frontend/mocks/react-native-snackbar.js @@ -0,0 +1,19 @@ +// Web mock for react-native-snackbar +// On web, NativeModules.RNSnackbar is undefined, which crashes at import time. +// This mock provides a no-op implementation that doesn't access NativeModules. + +const Snackbar = { + LENGTH_SHORT: 0, + LENGTH_LONG: 1, + LENGTH_INDEFINITE: -1, + DISMISS_EVENT_SWIPE: 'swipe', + DISMISS_EVENT_ACTION: 'action', + DISMISS_EVENT_TIMEOUT: 'timeout', + DISMISS_EVENT_MANUAL: 'manual', + DISMISS_EVENT_CONSECUTIVE: 'consecutive', + SHOW_EVENT: 'show', + show: () => {}, + dismiss: () => {}, +}; + +export default Snackbar; diff --git a/frontend/package.json b/frontend/package.json index a6d5023c4..9972287bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,12 @@ "knip:exports": "knip --exports", "knip:fix": "knip --fix", "knip:check": "knip --no-progress", - "quality": "pnpm run type-check && pnpm run lint && pnpm run knip", + "quality": "yarn run type-check && yarn run lint && yarn run knip", + "preview": "expo export --platform web", + "preview:android": "expo export --platform android", + "preview:ios": "expo export --platform ios", + "export:web": "expo export --platform web", + "serve:preview": "node scripts/serve-preview.js", "postinstall": "patch-package" }, "dependencies": { diff --git a/frontend/scripts/serve-preview.js b/frontend/scripts/serve-preview.js new file mode 100644 index 000000000..e306b4262 --- /dev/null +++ b/frontend/scripts/serve-preview.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +/** + * Static preview server for exported Expo web builds. + * + * Usage: + * 1. Run `yarn export:web` (or `expo export --platform web`) to build dist/ + * 2. Run `yarn serve:preview` to serve dist/ locally + * + * Serves the `dist/` directory on http://localhost:5000 + */ + +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const PORT = process.env.PORT || 5000; +const DIST_DIR = path.resolve(__dirname, "..", "dist"); + +if (!fs.existsSync(DIST_DIR)) { + console.error( + `[serve-preview] dist/ directory not found at "${DIST_DIR}".\n` + + ` Run "yarn export:web" (or "expo export --platform web") first to generate the preview build.` + ); + process.exit(1); +} + +const MIME_TYPES = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".eot": "application/vnd.ms-fontobject", + ".txt": "text/plain", + ".map": "application/json", +}; + +const server = http.createServer((req, res) => { + let filePath = path.join(DIST_DIR, req.url === "/" ? "index.html" : req.url); + const ext = path.extname(filePath).toLowerCase(); + + fs.readFile(filePath, (err, data) => { + if (err) { + // Fallback to index.html for SPAs (client-side routing) + if (err.code === "ENOENT" && !ext) { + fs.readFile(path.join(DIST_DIR, "index.html"), (err2, data2) => { + if (err2) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + return; + } + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(data2); + }); + return; + } + + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + return; + } + + const contentType = MIME_TYPES[ext] || "application/octet-stream"; + res.writeHead(200, { "Content-Type": contentType }); + res.end(data); + }); +}); + +server.listen(PORT, () => { + const address = `http://localhost:${PORT}`; + console.log(`[serve-preview] Serving preview build at ${address}`); + console.log(`[serve-preview] Directory: ${DIST_DIR}`); + console.log(`[serve-preview] Press Ctrl+C to stop.`); +}); diff --git a/frontend/src/components/AppContent.tsx b/frontend/src/components/AppContent.tsx index c7145ff45..3d60fcf33 100644 --- a/frontend/src/components/AppContent.tsx +++ b/frontend/src/components/AppContent.tsx @@ -17,7 +17,7 @@ import { SocketProvider } from '../contexts/SocketContext'; import { PreferencesProvider } from '../contexts/PreferencesContext'; import config from '../../tamagui.config'; -import { initDeepLinking, navigateDeepLink, resolveNotificationTarget } from '../helper/DeepLinkService'; +import { initDeepLinking, navigateDeepLink, resolveNotificationTarget, resolveDeepLinkTarget } from '../helper/DeepLinkService'; import { firebaseInit } from '../helper/firebase'; import { cleanUpDownloads , KEYS, retrieveItem } from '../helper/Utils'; import { @@ -49,9 +49,6 @@ export default function AppContent() { (state: RootState) => state.user, ); - const { visible, storeUrl } = useVersionCheck(); - const dispatch = useDispatch(); - useNotificationListeners(); useEffect(() => { @@ -116,9 +113,9 @@ export default function AppContent() { useEffect(() => { if (!navigationRef.current || !pendingDeepLinkRef.current) return; - if (!isGuest && tokenRes === null && !user_token) return; + if (!isGuest && !user_token) return; - const isAuthenticated = Boolean(tokenRes?.isValid || user_token) && !isGuest; + const isAuthenticated = Boolean(user_token) && !isGuest; const target = resolveDeepLinkTarget(pendingDeepLinkRef.current); if (target) { @@ -126,7 +123,7 @@ export default function AppContent() { } pendingDeepLinkRef.current = null; - }, [tokenRes, user_token, isGuest]); + }, [user_token, isGuest]); useEffect(() => { const handleNotificationResponse = async ( diff --git a/frontend/src/components/ArticleCard.tsx b/frontend/src/components/ArticleCard.tsx index 1689f38e4..de25f4e3a 100644 --- a/frontend/src/components/ArticleCard.tsx +++ b/frontend/src/components/ArticleCard.tsx @@ -38,14 +38,10 @@ import ArticleFloatingMenu from './ArticleFloatingMenu'; import { generateArticleShareUrl, copyArticleShareLink } from '../helper/shareUtils'; import Entypo from '@expo/vector-icons/Entypo'; -import Share from 'react-native-share'; -import RNFS from 'react-native-fs'; -import {generatePDF} from 'react-native-html-to-pdf'; import {useSocket} from '../contexts/SocketContext'; import EditRequestModal from './EditRequestModal'; import {FontAwesome, FontAwesome6} from '@expo/vector-icons'; import LoadingSpinner from './LoadingSpinner'; -import Snackbar from 'react-native-snackbar'; import {useGetProfile} from '../hooks/useGetProfile'; import {useLikeArticle} from '../hooks/useLikeArticle'; import {useSaveArticle} from '../hooks/useSaveArticle'; @@ -55,6 +51,12 @@ import { ReadingDifficulty, getArticleDifficulty } from './ReadingDifficulty'; import {useDoubleTap} from '../hooks/useDoubleTap'; import { ImageFallback } from './ImageFallback'; +// Lazy getters for native-only modules (crash on web if imported eagerly). +const getShare = () => { try { return require('react-native-share'); } catch { return null; } }; +const getRNFS = () => { try { return require('react-native-fs'); } catch { return null; } }; +const getPDF = () => { try { return require('react-native-html-to-pdf'); } catch { return null; } }; +const getSnackbar = () => { try { return require('react-native-snackbar'); } catch { return null; } }; + const ArticleCard = ({ item, navigation, @@ -108,6 +110,7 @@ const ArticleCard = ({ score: 78, level: 'Beginner Friendly' as 'Beginner Friendly' | 'Intermediate' | 'Advanced', approved: true, + }; const heartScale = useSharedValue(0); const heartStyle = useAnimatedStyle(() => { @@ -174,16 +177,16 @@ const ArticleCard = ({ console.log('Like error', err); setIsLiked(previousIsLiked); setLikeCount(previousLikeCount); - Snackbar.show({ + getSnackbar()?.show({ text: 'something went wrong, try again!', - duration: Snackbar.LENGTH_SHORT, + duration: getSnackbar()?.LENGTH_SHORT, }); }, }); } else { - Snackbar.show({ + getSnackbar()?.show({ text: 'Please check your network connection', - duration: Snackbar.LENGTH_SHORT, + duration: getSnackbar()?.LENGTH_SHORT, }); } }; @@ -199,9 +202,9 @@ const ArticleCard = ({ recordId: item.pb_recordId, }); } else { - Snackbar.show({ + getSnackbar()?.show({ text: 'Please connect to the internet to view this article.', - duration: Snackbar.LENGTH_LONG, + duration: getSnackbar()?.LENGTH_LONG, }); Alert.alert( 'No Internet 🚫', @@ -219,6 +222,8 @@ const ArticleCard = ({ }; const handleShare = async () => { + const Share = getShare(); + if (!Share) return; try { const resolvedAuthorId = (item.authorId as any)?._id || item.authorId; const url = generateArticleShareUrl(item._id, resolvedAuthorId, item.pb_recordId); @@ -241,15 +246,15 @@ const ArticleCard = ({ try { const resolvedAuthorId = (item.authorId as any)?._id || item.authorId; copyArticleShareLink(item._id, resolvedAuthorId, item.pb_recordId); - Snackbar.show({ + getSnackbar()?.show({ text: 'Link copied', - duration: Snackbar.LENGTH_SHORT, + duration: getSnackbar()?.LENGTH_SHORT, }); } catch (error) { console.log('Error copying link:', error); - Snackbar.show({ + getSnackbar()?.show({ text: 'Failed to copy link', - duration: Snackbar.LENGTH_SHORT, + duration: getSnackbar()?.LENGTH_SHORT, }); } }; @@ -296,9 +301,9 @@ const ArticleCard = ({ return; } if (!isConnected) { - Snackbar.show({ + getSnackbar()?.show({ text: 'Please check your internet connection!', - duration: Snackbar.LENGTH_SHORT, + duration: getSnackbar()?.LENGTH_SHORT, }); return; } @@ -322,21 +327,24 @@ const ArticleCard = ({ }; const generatePDFData = async (title: string, htmlContent: string) => { + const rnfs = getRNFS(); + const { generatePDF } = getPDF() || {}; + if (!rnfs || !generatePDF) return; try { const safeTitle = title.substring(0, 15).replace(/[^a-zA-Z0-9]/g, '_'); const fileName = `${safeTitle}.pdf`; const customDirectory = Platform.OS === 'android' - ? RNFS.ExternalDirectoryPath - : RNFS.DocumentDirectoryPath; + ? rnfs.ExternalDirectoryPath + : rnfs.DocumentDirectoryPath; const filePath = `${customDirectory}/${fileName}`; - const directoryExists = await RNFS.exists(customDirectory); + const directoryExists = await rnfs.exists(customDirectory); if (!directoryExists) { - await RNFS.mkdir(customDirectory); + await rnfs.mkdir(customDirectory); } const options = { @@ -348,7 +356,7 @@ const ArticleCard = ({ const file = await generatePDF(options); - await RNFS.moveFile(file.filePath, filePath); + await rnfs.moveFile(file.filePath, filePath); Alert.alert('PDF created successfully!', `Saved at: ${filePath}`); } catch (error) { @@ -394,22 +402,22 @@ const ArticleCard = ({ } } - Snackbar.show({ + getSnackbar()?.show({ text: data.message, - duration: Snackbar.LENGTH_SHORT, + duration: getSnackbar()?.LENGTH_SHORT, }); }, onError: err => { - Snackbar.show({ + getSnackbar()?.show({ text: 'Something went wrong, try again!', - duration: Snackbar.LENGTH_SHORT, + duration: getSnackbar()?.LENGTH_SHORT, }); }, }); } else { - Snackbar.show({ + getSnackbar()?.show({ text: 'Please check your internet connection', - duration: Snackbar.LENGTH_SHORT, + duration: getSnackbar()?.LENGTH_SHORT, }); } }; @@ -479,9 +487,9 @@ const ArticleCard = ({ name: 'Request to improve this post', action: () => { if (!isConnected) { - Snackbar.show({ + getSnackbar()?.show({ text: 'Please check your internet connection', - duration: Snackbar.LENGTH_SHORT, + duration: getSnackbar()?.LENGTH_SHORT, }); return; } @@ -647,16 +655,16 @@ const ArticleCard = ({ // Rollback optimistic update setIsLiked(previousIsLiked); setLikeCount(previousLikeCount); - Snackbar.show({ + getSnackbar()?.show({ text: 'something went wrong, try again!', - duration: Snackbar.LENGTH_SHORT, + duration: getSnackbar()?.LENGTH_SHORT, }); }, }); } else { - Snackbar.show({ + getSnackbar()?.show({ text: 'Please check your network connection', - duration: Snackbar.LENGTH_SHORT, + duration: getSnackbar()?.LENGTH_SHORT, }); } }} @@ -769,24 +777,24 @@ const ArticleCard = ({ yValue.value = withTiming(100, {duration: 250}); saveMutation(undefined, { onSuccess: async data => { - Snackbar.show({ + getSnackbar()?.show({ text: data.message, - duration: Snackbar.LENGTH_SHORT, + duration: getSnackbar()?.LENGTH_SHORT, }); setSaved(!saved); }, onError: () => { - Snackbar.show({ + getSnackbar()?.show({ text: 'Something went wrong, try again!', - duration: Snackbar.LENGTH_SHORT, + duration: getSnackbar()?.LENGTH_SHORT, }); }, }); } else { - Snackbar.show({ + getSnackbar()?.show({ text: 'Please check your network connection', - duration: Snackbar.LENGTH_SHORT, + duration: getSnackbar()?.LENGTH_SHORT, }); } }} diff --git a/frontend/src/components/PodcastSkeletonCard.tsx b/frontend/src/components/PodcastSkeletonCard.tsx index 399e0a4c9..2fd0c4f1b 100644 --- a/frontend/src/components/PodcastSkeletonCard.tsx +++ b/frontend/src/components/PodcastSkeletonCard.tsx @@ -6,7 +6,7 @@ import {PODCAST_CARD} from '@/constants/podcastCard'; interface ShimmerBoxProps { style?: object | object[]; - shimmerX: Animated.AnimatedInterpolation; + shimmerX: any; highlightColor: string; baseColor: string; } diff --git a/frontend/src/contexts/PreferencesContext.tsx b/frontend/src/contexts/PreferencesContext.tsx index 4d72876eb..44b36028e 100644 --- a/frontend/src/contexts/PreferencesContext.tsx +++ b/frontend/src/contexts/PreferencesContext.tsx @@ -38,9 +38,7 @@ interface PreferencesProviderProps { export const PreferencesProvider: React.FC = ({ children, }) => { - const [preferredLanguages, setInternalPreferredLanguages] = useState - LanguageCode[] - >([]); + const [preferredLanguages, setInternalPreferredLanguages] = useState([]); const [isLoading, setIsLoading] = useState(true); // Load preferences on mount diff --git a/frontend/src/helper/APIUtils.ts b/frontend/src/helper/APIUtils.ts index 55bfe13d8..184f44859 100644 --- a/frontend/src/helper/APIUtils.ts +++ b/frontend/src/helper/APIUtils.ts @@ -117,6 +117,10 @@ const GET_NOTIFICATION_PREFERENCES = `${PROD_URL}/user/notification-preferences` const UPDATE_NOTIFICATION_PREFERENCES = `${PROD_URL}/user/notification-preferences`; const REGISTER_PUSH_TOKEN = `${PROD_URL}/user/register-device-token`; +/** Wellness Dashboard */ +const WELLNESS_LOG = `${PROD_URL}/wellness/log`; +const WELLNESS_WEEKLY = `${PROD_URL}/wellness/weekly`; + export { LOGIN_API, REGISTRATION_API, @@ -203,6 +207,8 @@ export { GET_CHARACTERS_API, GET_NOTIFICATION_PREFERENCES, UPDATE_NOTIFICATION_PREFERENCES, - GET_READ_HISTORY, - SHARE_BASE_URL, -}; + GET_READ_HISTORY, + SHARE_BASE_URL, + WELLNESS_LOG, + WELLNESS_WEEKLY, + }; diff --git a/frontend/src/helper/Utils.ts b/frontend/src/helper/Utils.ts index f07d92ac1..0f814c106 100644 --- a/frontend/src/helper/Utils.ts +++ b/frontend/src/helper/Utils.ts @@ -3,7 +3,15 @@ import {Category, CategoryType, PodcastData} from '../type'; import AsyncStorage from '@react-native-async-storage/async-storage'; import {GET_STORAGE_DATA} from './APIUtils'; import {Alert, Linking, Platform, PermissionsAndroid} from 'react-native'; -import RNFS from 'react-native-fs'; +// Lazy getter — react-native-fs is native-only and crashes on web if imported eagerly. +const getRNFS = () => { + try { + if (Platform.OS === 'web') return null; + return require('react-native-fs'); + } catch { + return null; + } +}; import {secureClearAllItems} from './SecureStorageUtils'; import { deleteItem as deletePodcastCache, @@ -240,16 +248,19 @@ const downloadFile = async (key: string, title: string) => { const fileName = `${safeTitle}_${Date.now()}.mp3`; const downloadUrl = `${GET_STORAGE_DATA}/${key}`; + const rnfs = getRNFS(); + if (!rnfs) return null; + const customDirectory = Platform.OS === 'android' - ? RNFS.ExternalDirectoryPath - : RNFS.DocumentDirectoryPath; + ? rnfs.ExternalDirectoryPath + : rnfs.DocumentDirectoryPath; const filePath = `${customDirectory}/${fileName}`; - const directoryExists = await RNFS.exists(customDirectory); - if (!directoryExists) await RNFS.mkdir(customDirectory); + const directoryExists = await rnfs.exists(customDirectory); + if (!directoryExists) await rnfs.mkdir(customDirectory); - const result = await RNFS.downloadFile({fromUrl: downloadUrl, toFile: filePath}).promise; + const result = await rnfs.downloadFile({fromUrl: downloadUrl, toFile: filePath}).promise; if (result.statusCode === 200) { console.log('Audio downloaded to:', filePath); @@ -261,6 +272,9 @@ const downloadFile = async (key: string, title: string) => { }; export const cleanUpDownloads = async () => { + const rnfs = getRNFS(); + if (!rnfs) return; + const existingPodcasts = await readDownloadedPodcasts(); if (!Array.isArray(existingPodcasts)) return; @@ -271,8 +285,8 @@ export const cleanUpDownloads = async () => { for (const item of existingPodcasts) { const age = now - item.downloadAt.getTime(); if (Number.isFinite(age) && age > THIRTY_DAYS_MS) { - if (item.filePath && (await RNFS.exists(item.filePath))) { - await RNFS.unlink(item.filePath); + if (item.filePath && (await rnfs.exists(item.filePath))) { + await rnfs.unlink(item.filePath); console.log('Deleted old file:', item.filePath); } } else { @@ -284,15 +298,18 @@ export const cleanUpDownloads = async () => { }; export const deleteFromDownloads = async (_podcast: PodcastData) => { + const rnfs = getRNFS(); + if (!rnfs) return false; + const existingPodcasts = await readDownloadedPodcasts(); - if (!Array.isArray(existingPodcasts)) return; + if (!Array.isArray(existingPodcasts)) return false; try { const freshPodcasts: PodcastDownloadRecord[] = []; for (const item of existingPodcasts) { if (item._id === _podcast._id) { - if (item.filePath && (await RNFS.exists(item.filePath))) { - await RNFS.unlink(item.filePath); + if (item.filePath && (await rnfs.exists(item.filePath))) { + await rnfs.unlink(item.filePath); console.log('Deleted old file:', item.filePath); } } else { diff --git a/frontend/src/hooks/useGetWeeklyWellness.ts b/frontend/src/hooks/useGetWeeklyWellness.ts new file mode 100644 index 000000000..214fb6f98 --- /dev/null +++ b/frontend/src/hooks/useGetWeeklyWellness.ts @@ -0,0 +1,19 @@ +import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import authAxios from '../helper/authAxios'; +import { WELLNESS_WEEKLY } from '../helper/APIUtils'; +import { WeeklyWellnessResponse } from '../type'; +type AxiosError = any; + +export const useGetWeeklyWellness = ( + isConnected: boolean, +): UseQueryResult => { + return useQuery({ + queryKey: ['get-weekly-wellness'], + queryFn: async () => { + const response = await authAxios.get(WELLNESS_WEEKLY); + return response.data as WeeklyWellnessResponse; + }, + enabled: !!isConnected, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +}; diff --git a/frontend/src/hooks/useLogWellnessMetric.ts b/frontend/src/hooks/useLogWellnessMetric.ts new file mode 100644 index 000000000..b6725d4b9 --- /dev/null +++ b/frontend/src/hooks/useLogWellnessMetric.ts @@ -0,0 +1,36 @@ +import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import authAxios from '../helper/authAxios'; +import { WELLNESS_LOG } from '../helper/APIUtils'; +import { WellnessLog } from '../type'; +type AxiosError = any; + +type LogWellnessParams = { + water: number; + calories: number; + mood: string; + date?: string; +}; + +export const useLogWellnessMetric = (): UseMutationResult< + WellnessLog, + AxiosError, + LogWellnessParams +> => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['log-wellness'], + mutationFn: async ({ water, calories, mood, date }: LogWellnessParams) => { + const res = await authAxios.post(WELLNESS_LOG, { + water, + calories, + mood, + date, + }); + return res.data as WellnessLog; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['get-weekly-wellness'] }); + }, + }); +}; diff --git a/frontend/src/schemas/wellnessSchemas.ts b/frontend/src/schemas/wellnessSchemas.ts new file mode 100644 index 000000000..74e89e15e --- /dev/null +++ b/frontend/src/schemas/wellnessSchemas.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const wellnessLogSchema = z.object({ + water: z.number().min(0, 'Water must be >= 0').max(10000, 'Water seems too high'), + calories: z.number().min(0, 'Calories must be >= 0').max(10000, 'Calories seems too high'), + mood: z.enum(['😔', '😐', '🙂', '😄', '🤩']), + date: z.string(), +}); + +export type WellnessLogFormData = z.infer; diff --git a/frontend/src/screens/HomeScreen.tsx b/frontend/src/screens/HomeScreen.tsx index 3580191e4..58bc4bed6 100644 --- a/frontend/src/screens/HomeScreen.tsx +++ b/frontend/src/screens/HomeScreen.tsx @@ -9,9 +9,6 @@ import { StyleSheet, ScrollView, } from 'react-native'; import { FlashList } from '@shopify/flash-list'; - FlatList , - ScrollView , - } from 'react-native'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {SafeAreaView} from 'react-native-safe-area-context'; import { diff --git a/frontend/src/screens/PodcastRecorder.tsx b/frontend/src/screens/PodcastRecorder.tsx index bfd469184..7c2fa9d54 100644 --- a/frontend/src/screens/PodcastRecorder.tsx +++ b/frontend/src/screens/PodcastRecorder.tsx @@ -69,7 +69,7 @@ const PodcastRecorder = ({navigation, route}: PodcastRecorderScreenProps) => { useEffect(() => { const subscription = AppState.addEventListener( 'change', - (nextState: AppStateStatus) => { + (nextState: string) => { if (nextState === 'active' && recording) { // Re-sync timer immediately on app foreground using actual tracked duration if (durationMillisRef.current > 0) { @@ -88,26 +88,6 @@ useEffect(() => { return () => subscription.remove(); }, [recording]); - useFocusEffect( - useCallback(() => { - handleUpload(); - - return () => { - stopTimer(); - - if (isRecordingRef.current) { - audioRecorder - .stop() - .catch(err => - console.warn('Error stopping recorder on screen exit:', err), - ); - - isRecordingRef.current = false; - } - }; - }, [audioRecorder, handleUpload]), -); - const record = async () => { try { await audioRecorder.prepareToRecordAsync(); @@ -264,6 +244,26 @@ const stopRecording = async () => { await unlinkFile(); }, [unlinkFile]); + useFocusEffect( + useCallback(() => { + handleUpload(); + + return () => { + stopTimer(); + + if (isRecordingRef.current) { + audioRecorder + .stop() + .catch(err => + console.warn('Error stopping recorder on screen exit:', err), + ); + + isRecordingRef.current = false; + } + }; + }, [audioRecorder, handleUpload]), + ); + // useEffect(() => { // // const stopSub = AudioModule.addListener('recStop', (data:any) => { // // console.log('File saved at:', data.filePath); diff --git a/frontend/src/screens/WellnessDashboard/ManualLogCard.tsx b/frontend/src/screens/WellnessDashboard/ManualLogCard.tsx index 52347975d..217bed1c6 100644 --- a/frontend/src/screens/WellnessDashboard/ManualLogCard.tsx +++ b/frontend/src/screens/WellnessDashboard/ManualLogCard.tsx @@ -1,9 +1,18 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { - View, Text, TouchableOpacity, StyleSheet + View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../store/ReduxStore'; +import { useLogWellnessMetric } from '../../hooks/useLogWellnessMetric'; +import { useGetWeeklyWellness } from '../../hooks/useGetWeeklyWellness'; +import { wellnessLogSchema } from '../../schemas/wellnessSchemas'; export default function ManualLogCard() { + const isConnected = useSelector((state: RootState) => state.network.isConnected); + const { data: weeklyData, isLoading: isWeeklyLoading } = useGetWeeklyWellness(isConnected); + const mutation = useLogWellnessMetric(); + const [water, setWater] = useState(0); const [calories, setCalories] = useState(0); const [mood, setMood] = useState(''); @@ -12,6 +21,52 @@ export default function ManualLogCard() { const waterGoal = 2500; const calGoal = 2000; + // Pre-fill form from today's existing data if available (only on initial mount) + const hasPrefilled = useRef(false); + useEffect(() => { + if (!weeklyData?.logs || hasPrefilled.current) return; + const todayStr = new Date().toISOString().split('T')[0]; + const todayLog = weeklyData.logs.find( + (log) => log.date && log.date.startsWith(todayStr), + ); + if (todayLog) { + setWater(todayLog.water ?? 0); + setCalories(todayLog.calories ?? 0); + setMood(todayLog.mood ?? ''); + hasPrefilled.current = true; + } + }, [weeklyData]); + + const handleSave = useCallback(async () => { + const validation = wellnessLogSchema.safeParse({ + water, + calories, + mood, + date: new Date().toISOString().split('T')[0], + }); + + if (!validation.success) { + const firstError = validation.error.issues[0]?.message ?? 'Invalid input'; + Alert.alert('Validation Error', firstError); + return; + } + + try { + await mutation.mutateAsync({ + water: validation.data.water, + calories: validation.data.calories, + mood: validation.data.mood, + date: validation.data.date, + }); + } catch (err: any) { + const message = + err?.response?.data?.message ?? + err?.message ?? + 'Failed to save wellness data'; + Alert.alert('Error', message); + } + }, [water, calories, mood, mutation]); + return ( Today's Log @@ -28,6 +83,7 @@ export default function ManualLogCard() { setWater(w => Math.min(w + 250, waterGoal))}> + Add 250 ml @@ -45,6 +101,7 @@ export default function ManualLogCard() { setCalories(c => Math.min(c + 300, calGoal))}> + Log Meal (300 kcal) @@ -55,7 +112,7 @@ export default function ManualLogCard() { 😊 Mood {moods.map((m) => ( - setMood(m)}> + setMood(m)} disabled={mutation.isPending}> {mood ? Selected: {mood} : null} + + {/* Save Button */} + + {mutation.isPending ? ( + + ) : ( + Save Today's Log + )} + ); } @@ -96,4 +167,20 @@ const styles = StyleSheet.create({ moodEmoji: { fontSize: 28, opacity: 0.4 }, moodSelected: { opacity: 1, transform: [{ scale: 1.2 }] }, moodText: { fontSize: 13, color: '#666', marginTop: 4 }, -}); \ No newline at end of file + saveButton: { + backgroundColor: '#378ADD', + borderRadius: 12, + paddingVertical: 14, + alignItems: 'center', + justifyContent: 'center', + marginTop: 4, + }, + saveButtonDisabled: { + backgroundColor: '#a0c4f0', + }, + saveButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/frontend/src/screens/WellnessDashboard/WeeklyChart.tsx b/frontend/src/screens/WellnessDashboard/WeeklyChart.tsx index 133778f35..e36936197 100644 --- a/frontend/src/screens/WellnessDashboard/WeeklyChart.tsx +++ b/frontend/src/screens/WellnessDashboard/WeeklyChart.tsx @@ -1,23 +1,106 @@ -import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; +import React, { useMemo } from 'react'; +import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'; import { BarChart } from 'react-native-gifted-charts'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../store/ReduxStore'; +import { useGetWeeklyWellness } from '../../hooks/useGetWeeklyWellness'; -const weekData = [ - { value: 4200, label: 'Mon', frontColor: '#4CAF50' }, - { value: 7800, label: 'Tue', frontColor: '#4CAF50' }, - { value: 5500, label: 'Wed', frontColor: '#4CAF50' }, - { value: 9100, label: 'Thu', frontColor: '#4CAF50' }, - { value: 6300, label: 'Fri', frontColor: '#4CAF50' }, - { value: 8200, label: 'Sat', frontColor: '#378ADD' }, // today highlight - { value: 0, label: 'Sun', frontColor: '#ccc' }, // future -]; +type ChartMetric = 'water' | 'steps'; + +const dayLabelMap: Record = { + '0': 'Sun', '1': 'Mon', '2': 'Tue', '3': 'Wed', + '4': 'Thu', '5': 'Fri', '6': 'Sat', +}; + +function getDayLabel(dateStr: string): string { + const d = new Date(dateStr); + return dayLabelMap[String(d.getDay())] ?? ''; +} export default function WeeklyChart() { + const isConnected = useSelector((state: RootState) => state.network.isConnected); + const { data, isLoading, isError } = useGetWeeklyWellness(isConnected); + const [metric, setMetric] = React.useState('water'); + + const chartData = useMemo(() => { + if (!data?.logs || data.logs.length === 0) return []; + + // Sort logs by date ascending (filter out logs with missing dates) + const sorted = [...data.logs] + .filter(log => log.date) + .sort((a, b) => new Date(a.date!).getTime() - new Date(b.date!).getTime()); + + return sorted.map((log, _idx) => { + const value = metric === 'steps' ? (log.steps ?? 0) : (log.water ?? 0); + const isToday = + log.date?.startsWith(new Date().toISOString().split('T')[0]); + return { + value, + label: getDayLabel(log.date), + frontColor: isToday ? '#378ADD' : '#4CAF50', + }; + }); + }, [data, metric]); + + const maxValue = useMemo(() => { + if (chartData.length === 0) return 100; + const max = Math.max(...chartData.map((d) => d.value), 1); + // Round up to nearest nice number + const magnitude = Math.pow(10, Math.floor(Math.log10(max))); + return Math.ceil(max / magnitude) * magnitude; + }, [chartData]); + + const goalValue = metric === 'steps' + ? (data?.goal?.steps ?? 10000) + : (data?.goal?.water ?? 2500); + + if (isLoading) { + return ( + + + + Loading weekly data... + + + ); + } + + if (isError || chartData.length === 0) { + return ( + + 📊 Weekly {metric === 'steps' ? 'Steps' : 'Water'} + + {isError ? 'Unable to load weekly data' : 'No data yet — start logging!'} + + + ); + } + return ( - 📊 Weekly Steps + {/* Title + Toggle */} + + + 📊 Weekly {metric === 'steps' ? 'Steps' : 'Water'} + + + setMetric('water')} + > + Water + + setMetric('steps')} + > + Steps + + + + - Goal: 10,000 steps + + Goal: {metric === 'steps' ? `${goalValue.toLocaleString()} steps` : `${goalValue.toLocaleString()} ml`} + ); @@ -46,7 +131,46 @@ const styles = StyleSheet.create({ borderColor: '#ddd', }, title: { fontSize: 15, fontWeight: '600', marginBottom: 16, color: '#111' }, + titleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + toggleRow: { flexDirection: 'row', gap: 8 }, + toggleOption: { + fontSize: 12, + color: '#999', + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + borderWidth: 0.5, + borderColor: '#ddd', + overflow: 'hidden', + }, + toggleActive: { + color: '#378ADD', + borderColor: '#378ADD', + backgroundColor: '#e8f0fe', + fontWeight: '600', + }, legend: { flexDirection: 'row', alignItems: 'center', marginTop: 12 }, dot: { width: 10, height: 10, borderRadius: 5, backgroundColor: '#378ADD', marginRight: 6 }, legendText: { fontSize: 12, color: '#666' }, -}); \ No newline at end of file + loadingContainer: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 32, + }, + loadingText: { + fontSize: 13, + color: '#999', + marginTop: 8, + }, + emptyText: { + fontSize: 13, + color: '#999', + textAlign: 'center', + paddingVertical: 24, + }, +}); diff --git a/frontend/src/screens/WellnessDashboard/WellnessDashboardScreen.tsx b/frontend/src/screens/WellnessDashboard/WellnessDashboardScreen.tsx index 44ebf12e6..993663838 100644 --- a/frontend/src/screens/WellnessDashboard/WellnessDashboardScreen.tsx +++ b/frontend/src/screens/WellnessDashboard/WellnessDashboardScreen.tsx @@ -1,11 +1,13 @@ import React from 'react'; -import { ScrollView , Text, StyleSheet, SafeAreaView, View - } from 'react-native'; +import { ScrollView, Text, StyleSheet, SafeAreaView, View } from 'react-native'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../store/ReduxStore'; import WearableSyncCard from './WearableSyncCard'; import ManualLogCard from './ManualLogCard'; import WeeklyChart from './WeeklyChart'; export default function WellnessDashboardScreen() { + const isConnected = useSelector((state: RootState) => state.network.isConnected); const today = new Date().toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long' }); @@ -17,6 +19,11 @@ export default function WellnessDashboardScreen() { 🌿 Wellness Dashboard {today} + {!isConnected && ( + + You're offline. Wellness data may not be available until you reconnect. + + )} {/* Wearable Sync */} @@ -43,4 +50,16 @@ const styles = StyleSheet.create({ }, heading: { fontSize: 22, fontWeight: '700', color: '#111' }, date: { fontSize: 13, color: '#888', marginTop: 2 }, -}); \ No newline at end of file + offlineBanner: { + backgroundColor: '#FFF3CD', + borderRadius: 8, + paddingVertical: 6, + paddingHorizontal: 12, + marginTop: 8, + }, + offlineText: { + fontSize: 12, + color: '#856404', + textAlign: 'center', + }, +}); diff --git a/frontend/src/screens/article/ArticleScreen.tsx b/frontend/src/screens/article/ArticleScreen.tsx index 05e6df2a3..e09313c8d 100644 --- a/frontend/src/screens/article/ArticleScreen.tsx +++ b/frontend/src/screens/article/ArticleScreen.tsx @@ -1100,11 +1100,11 @@ const ArticleScreen = ({navigation, route}: ArticleScreenProp) => { visible={shareModalVisible} onClose={() => setShareModalVisible(false)} article={{ - title: article.title, - authorName: article.authorName ?? '', - category: article.tags?.[0]?.name ?? 'Health', + title: article?.title ?? '', + authorName: article?.authorName ?? '', + category: article?.tags?.[0]?.name ?? 'Health', coverImageUrl: - article.imageUtils && article.imageUtils.length > 0 + article?.imageUtils && article.imageUtils.length > 0 ? article.imageUtils[0].startsWith('http') ? article.imageUtils[0] : `${GET_IMAGE}/${article.imageUtils[0]}` diff --git a/frontend/src/type.ts b/frontend/src/type.ts index 4d1165e25..62ed052e9 100644 --- a/frontend/src/type.ts +++ b/frontend/src/type.ts @@ -419,6 +419,7 @@ export type HomeScreenHeaderProps = { unreadCount: number; hasActiveFilters?: boolean; onFilterReset?: () => void; + searchText?: string; }; export type ArticleCardProps = { @@ -868,3 +869,25 @@ export type NotificationPreferencesResponse = { message: string; error?: string; }; + +export type WellnessLog = { + _id?: string; + water: number; + calories: number; + mood: string; + date: string; + steps?: number; + heartRate?: number; + sleep?: number; + userId?: string; + createdAt?: string; +}; + +export type WeeklyWellnessResponse = { + logs: WellnessLog[]; + goal?: { + water: number; + calories: number; + steps: number; + }; +};