diff --git a/package-lock.json b/package-lock.json index aa1a182..64e45d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "react-native-reanimated": "^3.16.2", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", + "react-native-sse": "^1.2.1", "react-native-svg": "^15.11.2", "react-redux": "^9.2.0", "socket.io-client": "^4.8.1", @@ -14548,6 +14549,11 @@ "react-native": "*" } }, + "node_modules/react-native-sse": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/react-native-sse/-/react-native-sse-1.2.1.tgz", + "integrity": "sha512-zejanlScF+IB9tYnbdry0MT34qjBXbiV/E72qGz33W/tX1bx8MXsbB4lxiuPETc9v/008vYZ60yjIstW22VlVg==" + }, "node_modules/react-native-svg": { "version": "15.11.2", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.11.2.tgz", diff --git a/package.json b/package.json index 1cd1ff6..e85ed37 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "react-native-reanimated": "^3.16.2", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", + "react-native-sse": "^1.2.1", "react-native-svg": "^15.11.2", "react-redux": "^9.2.0", "socket.io-client": "^4.8.1", diff --git a/src/assets/images/notifications/e.jpg b/src/assets/images/notifications/e.jpg deleted file mode 100644 index 5627eb7..0000000 Binary files a/src/assets/images/notifications/e.jpg and /dev/null differ diff --git a/src/assets/images/notifications/e.svg b/src/assets/images/notifications/e.svg new file mode 100644 index 0000000..56e4734 --- /dev/null +++ b/src/assets/images/notifications/e.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/notifications/f.jpg b/src/assets/images/notifications/f.jpg deleted file mode 100644 index a6d4bdd..0000000 Binary files a/src/assets/images/notifications/f.jpg and /dev/null differ diff --git a/src/assets/images/notifications/f.svg b/src/assets/images/notifications/f.svg new file mode 100644 index 0000000..aa3a765 --- /dev/null +++ b/src/assets/images/notifications/f.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/notifications/image.jpg b/src/assets/images/notifications/image.jpg deleted file mode 100644 index 1c86b1c..0000000 Binary files a/src/assets/images/notifications/image.jpg and /dev/null differ diff --git a/src/assets/images/notifications/image.svg b/src/assets/images/notifications/image.svg new file mode 100644 index 0000000..2b74403 --- /dev/null +++ b/src/assets/images/notifications/image.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/notifications/m.jpg b/src/assets/images/notifications/m.jpg deleted file mode 100644 index 392082c..0000000 Binary files a/src/assets/images/notifications/m.jpg and /dev/null differ diff --git a/src/assets/images/notifications/m.svg b/src/assets/images/notifications/m.svg new file mode 100644 index 0000000..a5b7463 --- /dev/null +++ b/src/assets/images/notifications/m.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/notifications/r.jpg b/src/assets/images/notifications/r.jpg deleted file mode 100644 index fb23635..0000000 Binary files a/src/assets/images/notifications/r.jpg and /dev/null differ diff --git a/src/assets/images/notifications/r.svg b/src/assets/images/notifications/r.svg new file mode 100644 index 0000000..abb5ee3 --- /dev/null +++ b/src/assets/images/notifications/r.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/notifications/w.jpg b/src/assets/images/notifications/w.jpg deleted file mode 100644 index 6e581e7..0000000 Binary files a/src/assets/images/notifications/w.jpg and /dev/null differ diff --git a/src/assets/images/notifications/w.svg b/src/assets/images/notifications/w.svg new file mode 100644 index 0000000..85e6f67 --- /dev/null +++ b/src/assets/images/notifications/w.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index c12b78b..e4a4ae0 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -23,8 +23,7 @@ const TopBar = () => { useState(false); const router = useRouter(); const { cartItems } = useCart(); - const { notifications } = useNotifications(); - const unreadCount = notifications.filter((n) => !n.isRead).length; + const { unreadCount } = useNotifications(); // Calculate the total quantity of items in the cart const totalCartQuantity = cartItems.reduce( diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index d3a0832..9e93ba5 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -1,5 +1,3 @@ -// src/context/NotificationsContext.ts - import React, { createContext, useContext, @@ -7,16 +5,21 @@ import React, { useState, ReactNode, useCallback, + useMemo, } from 'react'; import { NotificationService } from '../services/notifications'; import { NotificationResponse } from '@pharmatech/sdk'; import { ServiceResponse } from '../types/api'; -import { getUserIdFromSecureStore } from '../helper/jwtHelper'; +import EventSource from 'react-native-sse'; +import * as SecureStore from 'expo-secure-store'; +import { api } from '../lib/sdkConfig'; export type Notification = NotificationResponse & { isRead: boolean }; export type NotificationsContextType = { notifications: Notification[]; + totalCount: number; + unreadCount: number; markAsRead: (notificationId: string) => Promise; markAsUnread: (notificationId: string) => Promise; refreshNotifications: () => Promise; @@ -31,10 +34,6 @@ export function NotificationsProvider(props: { children: ReactNode }) { const refreshNotifications = useCallback(async (): Promise => { try { - const token = await getUserIdFromSecureStore(); - if (!token) - throw new Error('No se pudo obtener el token de autenticación.'); - const response: ServiceResponse< NotificationResponse | NotificationResponse[] > = await NotificationService.getNotifications(); @@ -44,12 +43,12 @@ export function NotificationsProvider(props: { children: ReactNode }) { ? response.data : [response.data]; - setNotifications( - rawArray.map((n: NotificationResponse) => ({ - ...n, - isRead: Boolean(n.isRead), - })), - ); + const mapped = rawArray.map((n) => ({ + ...n, + isRead: Boolean(n.isRead), + })); + console.log('Notificaciones recibidas:', mapped); + setNotifications(mapped); } else { console.error('Error cargando notificaciones:', response); } @@ -58,27 +57,10 @@ export function NotificationsProvider(props: { children: ReactNode }) { } }, []); - // carga inicial - useEffect(() => { - void refreshNotifications(); - }, [refreshNotifications]); - - // polling automático cada 30 segundos - useEffect(() => { - const interval = setInterval(() => { - void refreshNotifications(); - }, 1200_000); // ajusta intervalo según necesidad - - return () => clearInterval(interval); - }, [refreshNotifications]); - const markAsRead = useCallback(async (notificationId: string) => { try { - const token = await getUserIdFromSecureStore(); - if (!token) - throw new Error('No se pudo obtener el token de autenticación.'); - - await NotificationService.markAsRead(notificationId, token); + await NotificationService.markAsRead(notificationId); + // optimismo: actualizamos localmente setNotifications((prev) => prev.map((n) => (n.id === notificationId ? { ...n, isRead: true } : n)), ); @@ -91,25 +73,75 @@ export function NotificationsProvider(props: { children: ReactNode }) { }, []); const markAsUnread = useCallback(async (notificationId: string) => { - try { - // Si la API soporta 'unread', aquí iría NotificationService.markAsUnread(...) - setNotifications((prev) => - prev.map((n) => - n.id === notificationId ? { ...n, isRead: false } : n, - ), - ); - } catch (err) { - console.error( - `Error marcando notificación ${notificationId} como no leída:`, - err, - ); - } + // si tu API no ofrece un endpoint "unread", lo simulamos localmente + setNotifications((prev) => + prev.map((n) => (n.id === notificationId ? { ...n, isRead: false } : n)), + ); }, []); + // Conteos derivados + const totalCount = notifications.length; + const unreadCount = useMemo( + () => notifications.filter((n) => !n.isRead).length, + [notifications], + ); + + useEffect(() => { + let es: EventSource | null = null; + + async function setupSSE() { + // 1) carga inicial + await refreshNotifications(); + + // 2) stream SSE + const token = await SecureStore.getItemAsync('auth_token'); + if (!token) return; + + const axiosClient = api.client['client']; + const baseURL: string | undefined = axiosClient.getUri + ? axiosClient.getUri({ url: '' }) + : axiosClient.defaults.baseURL; + if (!baseURL) return; + + const url = `${baseURL.replace(/\/$/, '')}/notification/stream`; + + es = new EventSource(url, { + headers: { Authorization: `Bearer ${token}` }, + lineEndingCharacter: '\n', + }); + + // Cada vez que llegue un mensaje, recargamos TODO el listado: + es.addEventListener('message', () => { + console.log('[SSE] mensaje recibido → refrescando notificaciones'); + void refreshNotifications(); + }); + + es.addEventListener('error', (err) => { + console.error('[SSE] error:', err); + }); + } + + setupSSE(); + + return () => { + if (es) { + es.removeAllEventListeners(); + es.close(); + } + }; + }, [refreshNotifications]); + return React.createElement( NotificationsContext.Provider, { - value: { notifications, markAsRead, markAsUnread, refreshNotifications }, + value: { + notifications, + totalCount, + unreadCount, + markAsRead, + markAsUnread, + refreshNotifications, + }, }, props.children, ); diff --git a/src/screens/NotificationsScreen.tsx b/src/screens/NotificationsScreen.tsx index 382b5f6..f644e49 100644 --- a/src/screens/NotificationsScreen.tsx +++ b/src/screens/NotificationsScreen.tsx @@ -4,12 +4,10 @@ import { View, StyleSheet, ScrollView, - Image, ActivityIndicator, - ImageSourcePropType, TouchableOpacity, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { formatDistanceToNow, parseISO, @@ -21,91 +19,88 @@ import { ChevronLeftIcon } from 'react-native-heroicons/solid'; import TopBar from '../components/TopBar'; import PoppinsText from '../components/PoppinsText'; import Alert from '../components/Alerts'; -// Importamos y casteamos explicitamente el ícono por defecto -import NotificationIconAsset from '../assets/images/favicon.png'; import { Colors, FontSizes } from '../styles/theme'; import { NotificationService } from '../services/notifications'; -import { getUserIdFromSecureStore } from '../helper/jwtHelper'; +import { SvgProps } from 'react-native-svg'; -import completedImg from '../assets/images/notifications/f.jpg'; -import inProgressImg from '../assets/images/notifications/r.jpg'; -import approvedImg from '../assets/images/notifications/image.jpg'; -import canceledImg from '../assets/images/notifications/w.jpg'; -import readyForPickupImg from '../assets/images/notifications/e.jpg'; -import deliveryImg from '../assets/images/notifications/m.jpg'; +// Importa tus SVG como componentes React +import CompletedSvg from '../assets/images/notifications/f.svg'; +import InProgressSvg from '../assets/images/notifications/r.svg'; +import ApprovedSvg from '../assets/images/notifications/image.svg'; +import CanceledSvg from '../assets/images/notifications/w.svg'; +import ReadyForPickupSvg from '../assets/images/notifications/e.svg'; +import DeliverySvg from '../assets/images/notifications/m.svg'; -// Mapeo de iconos por estado -const notificationIcons: Record = { - completed: completedImg as unknown as ImageSourcePropType, - in_progress: inProgressImg as unknown as ImageSourcePropType, - approved: approvedImg as unknown as ImageSourcePropType, - canceled: canceledImg as unknown as ImageSourcePropType, - ready_for_pickup: readyForPickupImg as unknown as ImageSourcePropType, - delivery: deliveryImg as unknown as ImageSourcePropType, +type NotificationItem = { + id: string; + title: string; + message: string; + createdAt: string; + isRead: boolean; + orderId: string; + status: string; }; -// Ícono por defecto, casteado a ImageSourcePropType -const NotificationIcon = - NotificationIconAsset as unknown as ImageSourcePropType; +type NotificationResponse = { + id: string; + title: string; + message: string; + createdAt: string; + isRead: boolean; + order?: { + id: string; + status: string; + }; +}; -// Función auxiliar para extraer orderId del mensaje -const extractOrderIdFromMessage = (message: string): string | null => { - const match = message.match(/The order (\S+) has been updated to/i); - return match ? match[1] : null; +// Mapeo de status → componente SVG +const notificationIcons: Record> = { + completed: CompletedSvg, + in_progress: InProgressSvg, + approved: ApprovedSvg, + canceled: CanceledSvg, + ready_for_pickup: ReadyForPickupSvg, + delivery: DeliverySvg, }; export default function NotificationsScreen() { const navigation = useNavigation(); const [notificationsList, setNotificationsList] = useState< - Array<{ - id: string; - title: string; - message: string; - createdAt: string; - isRead: boolean; - orderId: string; - }> + NotificationItem[] >([]); const [loading, setLoading] = useState(true); const [showErrorAlert, setShowErrorAlert] = useState(false); const [errorMessage, setErrorMessage] = useState(''); + // Carga inicial de notificaciones useEffect(() => { const fetchNotifications = async () => { setLoading(true); setShowErrorAlert(false); try { - const token = await getUserIdFromSecureStore(); - if (!token) throw new Error('No se pudo obtener el token de usuario.'); - const res = await NotificationService.getNotifications(); - if (res.success && Array.isArray(res.data)) { - console.log('Raw notifications payload:', res.data); + if (!res.success || !Array.isArray(res.data)) { + const errorMsg = + !res.success && 'error' in res && res.error + ? res.error + : 'Respuesta inesperada del servidor'; + throw new Error(errorMsg); + } - setNotificationsList( - res.data.map((nt) => { - const rawOrderId = - nt.orderId || - (nt as { order_id?: string }).order_id || // Specify type for `order_id` - (nt.data && - ((nt.data as { orderId?: string }).orderId || // Specify type for `data.orderId` - (nt.data as { order_id?: string }).order_id)) || // Specify type for `data.order_id` - extractOrderIdFromMessage(nt.message) || - ''; + const items = res.data.map((nt: NotificationResponse) => { + const order = nt.order || { id: '', status: '' }; + return { + id: nt.id, + title: nt.title, + message: nt.message.trim(), + createdAt: nt.createdAt, + isRead: !!nt.isRead, + orderId: order.id, + status: order.status, + }; + }); - return { - id: nt.id, - title: nt.title, - message: nt.message, - createdAt: nt.createdAt, - isRead: !!nt.isRead, - orderId: rawOrderId, - }; - }), - ); - } else if (!res.success) { - throw new Error(res.error || 'Respuesta inesperada del servidor'); - } + setNotificationsList(items); } catch (err: unknown) { setErrorMessage( err instanceof Error ? err.message : 'Error desconocido', @@ -115,37 +110,33 @@ export default function NotificationsScreen() { setLoading(false); } }; + fetchNotifications(); }, []); - const handlePressNotification = useCallback( - async (nt: (typeof notificationsList)[0]) => { - console.log(`order ID: ${nt.orderId}`, nt); + // Marca todas como leídas al entrar o salir de la pantalla + useFocusEffect( + useCallback(() => { + const markAllRead = async () => { + try { + const toMark = notificationsList.filter((n) => !n.isRead); + if (toMark.length === 0) return; - if (nt.isRead) return; - - try { - const token = await getUserIdFromSecureStore(); - if (!token) throw new Error('No se pudo obtener el token de usuario.'); - if (nt.orderId) { - await NotificationService.markAsRead(nt.orderId, token); + await Promise.all( + toMark.map((n) => NotificationService.markAsRead(n.orderId)), + ); setNotificationsList((prev) => - prev.map((n) => (n.id === nt.id ? { ...n, isRead: true } : n)), + prev.map((n) => ({ ...n, isRead: true })), + ); + } catch (e) { + console.warn( + 'Error al marcar todas las notificaciones como leídas:', + e, ); - } else { - throw new Error('ID de notificación no válido.'); } - } catch (err) { - const msg = - err instanceof Error - ? err.message - : 'Error desconocido al marcar la notificación como leída.'; - console.error(`Error marcando notificación ${nt.id}:`, msg); - setErrorMessage(msg); - setShowErrorAlert(true); - } - }, - [], + }; + markAllRead(); + }, [notificationsList]), ); const formatRelativeDate = (dateString: string) => { @@ -160,48 +151,25 @@ export default function NotificationsScreen() { return format(date, 'yyyy-MM-dd', { locale: es }); }; - const extractStatusKey = (message: string): string | null => { - const match = message.match( - /The order \S+ has been updated to ([\w_]+)\.?/i, - ); - return match ? match[1].toLowerCase() : null; - }; - - const translateMessage = (message: string) => { - const match = message.match( - /The order (\S+) has been updated to ([\w_]+)\.?/i, + const renderNotification = (nt: NotificationItem) => { + const Icon = notificationIcons[nt.status]; + return ( + + + + + {nt.title} + + {formatRelativeDate(nt.createdAt)} + + + {nt.message} + + ); - if (!match) return message; - const [, orderId, rawStatus] = match; - const key = rawStatus.replace(/_/g, ' ').toLowerCase(); - const map: Record = { - completed: 'completada', - 'in progress': 'en progreso', - approved: 'aprobada', - canceled: 'cancelada', - 'ready for pickup': 'lista para recoger', - delivery: 'en entrega', - }; - return `La orden ${orderId} ha sido actualizada a ${map[key] || key}`; - }; - - const getTranslatedTitle = (nt: { title: string; message: string }) => { - const key = extractStatusKey(nt.message); - if (!key) return nt.title; - const map: Record = { - completed: 'Pedido Completado', - in_progress: 'Pedido en Progreso', - approved: 'Orden Aprobada', - canceled: 'Orden Cancelada', - ready_for_pickup: 'Pedido Listo para Recoger', - delivery: 'Pedido en Entrega', - }; - return map[key] || nt.title; - }; - - const getNotificationIcon = (message: string): ImageSourcePropType => { - const key = extractStatusKey(message); - return (key && notificationIcons[key]) || NotificationIcon; }; return ( @@ -209,27 +177,15 @@ export default function NotificationsScreen() { navigation.goBack()} - style={{ - paddingHorizontal: 10, - marginBottom: -4, - flexDirection: 'row', - alignSelf: 'flex-start', - }} + style={styles.backButton} > - + Volver @@ -262,35 +218,7 @@ export default function NotificationsScreen() { ) : ( - {notificationsList.map((nt) => ( - handlePressNotification(nt)} - activeOpacity={0.7} - > - - - - - - {getTranslatedTitle(nt)} - - - {formatRelativeDate(nt.createdAt)} - - - - {translateMessage(nt.message)} - - - - - ))} + {notificationsList.map(renderNotification)} )} @@ -302,6 +230,18 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: Colors.bgColor, }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 10, + marginTop: 8, + marginBottom: -4, + }, + backText: { + fontSize: FontSizes.b1.size, + lineHeight: FontSizes.b1.lineHeight, + color: Colors.primary, + }, alertContainer: { position: 'absolute', top: 20, @@ -313,19 +253,23 @@ const styles = StyleSheet.create({ header: { flexDirection: 'row', paddingTop: 16, + paddingHorizontal: 20, }, title: { fontSize: FontSizes.s1.size, color: Colors.primary, - marginLeft: 20, }, - loader: { marginTop: 40 }, + loader: { + marginTop: 40, + }, emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', }, - listContainer: { paddingVertical: 10 }, + listContainer: { + paddingVertical: 10, + }, item: { flexDirection: 'row', alignItems: 'flex-start', @@ -338,15 +282,9 @@ const styles = StyleSheet.create({ unreadBackground: { backgroundColor: '#FFFFFF', }, - icon: { - width: 32, - height: 32, - marginRight: 12, - marginTop: 4, - borderRadius: 16, - }, textContainer: { flex: 1, + marginLeft: 12, }, itemHeader: { flexDirection: 'row', @@ -360,7 +298,6 @@ const styles = StyleSheet.create({ message: { color: Colors.textLowContrast, fontSize: FontSizes.c1.size, - marginRight: 80, flexWrap: 'wrap', }, }); diff --git a/src/screens/tab/HomeScreen.tsx b/src/screens/tab/HomeScreen.tsx index 92a711f..af1aa33 100644 --- a/src/screens/tab/HomeScreen.tsx +++ b/src/screens/tab/HomeScreen.tsx @@ -1,5 +1,11 @@ import React, { useState, useEffect } from 'react'; -import { View, StyleSheet, ScrollView, ActivityIndicator } from 'react-native'; +import { + View, + StyleSheet, + ScrollView, + ActivityIndicator, + RefreshControl, +} from 'react-native'; import { useRouter, useLocalSearchParams } from 'expo-router'; import * as SecureStore from 'expo-secure-store'; import { useCart } from '../../hooks/useCart'; @@ -11,6 +17,7 @@ import { Product } from '../../types/Product'; import type { Promo } from '@pharmatech/sdk'; import EmailVerificationModal from './EmailVerificationModal'; import { decodeJWT } from '../../helper/jwtHelper'; +import { useNotifications } from '../../hooks/useNotifications'; export default function HomeScreen() { const [products, setProducts] = useState([]); @@ -18,9 +25,11 @@ export default function HomeScreen() { const [loading, setLoading] = useState(true); const [loadingRecommendations, setLoadingRecommendations] = useState(true); const [showEmailVerification, setShowEmailVerification] = useState(false); + const [refreshing, setRefreshing] = useState(false); const router = useRouter(); const { showEmailVerification: showEmailVerificationParam } = useLocalSearchParams(); + const { refreshNotifications } = useNotifications(); const { cartItems, updateCartQuantity, setCartUserId } = useCart(); const getItemQuantity = (productId: string) => { @@ -151,9 +160,27 @@ export default function HomeScreen() { } }; + const onRefresh = async () => { + setRefreshing(true); + await Promise.all([ + obtainProducts(), + obtainRecommendedProducts(), + refreshNotifications(), // <-- refresca notificaciones también + ]); + setRefreshing(false); + }; + return ( - + + } + > Ofertas especiales diff --git a/src/services/notifications.ts b/src/services/notifications.ts index ec305fc..a85d05b 100644 --- a/src/services/notifications.ts +++ b/src/services/notifications.ts @@ -24,9 +24,14 @@ export const NotificationService = { }; } }, - markAsRead: async (orderId: string, jwt: string): Promise => { + markAsRead: async (orderId: string): Promise => { try { - await api.notification.markAsRead(orderId, jwt); + const token = await SecureStore.getItemAsync('auth_token'); + if (!token) { + throw new Error('No se encontró el token de autenticación'); + } + + await api.notification.markAsRead(orderId, token); } catch (error) { throw new Error(extractErrorMessage(error)); }