feat: add Expo Push notifications for mobile and configure EXPO_ACCESS_TOKEN
- Backend: add expo_push_tokens table, expo-register/unregister endpoints, hybrid VAPID+Expo push sender - Mobile: register Expo push token on app launch, bell toggle on medicine/pharmacy detail screens - .env.example: document EXPO_ACCESS_TOKEN configuration - Dark mode hover fixes for search suggestions and recent buttons
This commit is contained in:
@@ -15,3 +15,7 @@ PG_PASSWORD=change-me
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:admin@example.com
|
||||
|
||||
# Expo Push Notifications (mobile). Get token from:
|
||||
# https://expo.dev/accounts/[username]/settings/access-tokens
|
||||
EXPO_ACCESS_TOKEN=
|
||||
|
||||
+239
-33
@@ -96,11 +96,12 @@ const geocodeLimiter = rateLimit({ windowMs: 60_000, max: 10, standardHeaders: t
|
||||
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || '';
|
||||
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || '';
|
||||
const VAPID_SUBJECT = process.env.VAPID_SUBJECT || 'mailto:admin@example.com';
|
||||
const EXPO_ACCESS_TOKEN = process.env.EXPO_ACCESS_TOKEN || '';
|
||||
const PUSH_ENABLED = Boolean(VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY);
|
||||
if (PUSH_ENABLED) {
|
||||
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
|
||||
} else if (process.env.NODE_ENV !== 'test') {
|
||||
console.warn('[push] VAPID keys not configured — push notifications disabled');
|
||||
console.warn('[push] VAPID keys not configured — web push disabled');
|
||||
}
|
||||
|
||||
// Database setup
|
||||
@@ -313,6 +314,18 @@ async function initDatabase() {
|
||||
`);
|
||||
await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm ON push_subscriptions_pharmacy(medicine_nregistro, pharmacy_id)`);
|
||||
await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm_user ON push_subscriptions_pharmacy(user_id)`);
|
||||
|
||||
// Expo Push tokens (mobile)
|
||||
await pgPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS expo_push_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expo_token TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, expo_token)
|
||||
)
|
||||
`);
|
||||
await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_expo_push_tokens_user ON expo_push_tokens(user_id)`);
|
||||
} else {
|
||||
await dbRun(`
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
@@ -342,6 +355,18 @@ async function initDatabase() {
|
||||
)
|
||||
`);
|
||||
await dbRun(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm ON push_subscriptions_pharmacy(medicine_nregistro, pharmacy_id)`);
|
||||
|
||||
// Expo Push tokens (mobile)
|
||||
await dbRun(`
|
||||
CREATE TABLE IF NOT EXISTS expo_push_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
expo_token TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, expo_token)
|
||||
)
|
||||
`);
|
||||
await dbRun(`CREATE INDEX IF NOT EXISTS idx_expo_push_tokens_user ON expo_push_tokens(user_id)`);
|
||||
}
|
||||
|
||||
if (!pgPool) {
|
||||
@@ -1919,6 +1944,101 @@ app.delete('/api/notifications/unsubscribe', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Expo Push tokens (mobile app)
|
||||
|
||||
app.post('/api/notifications/expo-register', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { token, medicine_nregistro, medicine_name, pharmacy_id } = req.body || {};
|
||||
const userId = req.session.userId;
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: 'expo token is required' });
|
||||
}
|
||||
|
||||
// Upsert the token for this user
|
||||
const existing = await userDbGet(
|
||||
'SELECT id FROM expo_push_tokens WHERE user_id = ? AND expo_token = ?',
|
||||
[userId, token]
|
||||
);
|
||||
if (!existing) {
|
||||
await userDbRun(
|
||||
'INSERT INTO expo_push_tokens (user_id, expo_token) VALUES (?, ?)',
|
||||
[userId, token]
|
||||
);
|
||||
}
|
||||
|
||||
// Also store the medicine subscription (reuse existing tables with expo prefix for endpoint)
|
||||
if (medicine_nregistro) {
|
||||
const expoEndpoint = `expo://${token}`;
|
||||
if (pharmacy_id) {
|
||||
const existingSub = await userDbGet(
|
||||
'SELECT id FROM push_subscriptions_pharmacy WHERE user_id = ? AND medicine_nregistro = ? AND pharmacy_id = ? AND endpoint = ?',
|
||||
[userId, medicine_nregistro, pharmacy_id, expoEndpoint]
|
||||
);
|
||||
if (!existingSub) {
|
||||
await userDbRun(
|
||||
'INSERT INTO push_subscriptions_pharmacy (user_id, medicine_nregistro, medicine_name, pharmacy_id, endpoint, p256dh, auth) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[userId, medicine_nregistro, medicine_name || null, pharmacy_id, expoEndpoint, '', '']
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const existingSub = await userDbGet(
|
||||
'SELECT id FROM push_subscriptions WHERE user_id = ? AND medicine_nregistro = ? AND endpoint = ?',
|
||||
[userId, medicine_nregistro, expoEndpoint]
|
||||
);
|
||||
if (!existingSub) {
|
||||
await userDbRun(
|
||||
'INSERT INTO push_subscriptions (user_id, medicine_nregistro, medicine_name, endpoint, p256dh, auth) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[userId, medicine_nregistro, medicine_name || null, expoEndpoint, '', '']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('Error registering Expo push token:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/notifications/expo-unregister', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { medicine_nregistro, pharmacy_id } = req.body || {};
|
||||
const userId = req.session.userId;
|
||||
|
||||
// Get all expo tokens for this user to find the endpoints
|
||||
const tokens = await userDbAll(
|
||||
'SELECT expo_token FROM expo_push_tokens WHERE user_id = ?',
|
||||
[userId]
|
||||
);
|
||||
|
||||
for (const t of tokens || []) {
|
||||
const expoEndpoint = `expo://${t.expo_token}`;
|
||||
if (pharmacy_id && medicine_nregistro) {
|
||||
await userDbRun(
|
||||
'DELETE FROM push_subscriptions_pharmacy WHERE user_id = ? AND medicine_nregistro = ? AND pharmacy_id = ? AND endpoint = ?',
|
||||
[userId, medicine_nregistro, pharmacy_id, expoEndpoint]
|
||||
);
|
||||
} else if (medicine_nregistro) {
|
||||
await userDbRun(
|
||||
'DELETE FROM push_subscriptions WHERE user_id = ? AND medicine_nregistro = ? AND endpoint = ?',
|
||||
[userId, medicine_nregistro, expoEndpoint]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If no medicine specified, remove the token entirely
|
||||
if (!medicine_nregistro) {
|
||||
await userDbRun('DELETE FROM expo_push_tokens WHERE user_id = ?', [userId]);
|
||||
}
|
||||
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
console.error('Error unregistering Expo push token:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// List the current user's saved notifications across all their devices
|
||||
app.get('/api/notifications/mine', requireAuth, async (req, res) => {
|
||||
try {
|
||||
@@ -1979,7 +2099,7 @@ app.delete('/api/notifications/mine', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
async function sendPushForMedicine({ medicine_nregistro, medicine_name, pharmacy }) {
|
||||
if (!PUSH_ENABLED) return { sent: 0, failed: 0, pruned: 0 };
|
||||
if (!PUSH_ENABLED && !EXPO_ACCESS_TOKEN) return { sent: 0, failed: 0, pruned: 0 };
|
||||
|
||||
const globalSubs = await userDbAll(
|
||||
'SELECT id, endpoint, p256dh, auth FROM push_subscriptions WHERE medicine_nregistro = ?',
|
||||
@@ -1990,7 +2110,7 @@ async function sendPushForMedicine({ medicine_nregistro, medicine_name, pharmacy
|
||||
[medicine_nregistro, pharmacy.id]
|
||||
) : [];
|
||||
|
||||
// Deduplicate by endpoint; pharmacy-specific overrides global so one notification per browser
|
||||
// Deduplicate by endpoint; pharmacy-specific overrides global so one notification per device
|
||||
const endpointMap = new Map();
|
||||
for (const sub of (globalSubs || [])) endpointMap.set(sub.endpoint, { ...sub, _pharmSub: false });
|
||||
for (const sub of (pharmacySubs || [])) endpointMap.set(sub.endpoint, { ...sub, _pharmSub: true });
|
||||
@@ -1998,43 +2118,129 @@ async function sendPushForMedicine({ medicine_nregistro, medicine_name, pharmacy
|
||||
|
||||
if (subs.length === 0) return { sent: 0, failed: 0, pruned: 0 };
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: 'Medicine available',
|
||||
body: `${medicine_name || medicine_nregistro} is now available at ${pharmacy?.name || 'a pharmacy near you'}`,
|
||||
url: `/?nregistro=${encodeURIComponent(medicine_nregistro)}`,
|
||||
medicine_nregistro,
|
||||
medicine_name: medicine_name || null,
|
||||
pharmacy_id: pharmacy?.id ?? null,
|
||||
pharmacy_name: pharmacy?.name ?? null,
|
||||
});
|
||||
// Split into web push (VAPID) and Expo push (mobile)
|
||||
const webSubs = [];
|
||||
const expoTokens = [];
|
||||
for (const sub of subs) {
|
||||
if (sub.endpoint?.startsWith('expo://')) {
|
||||
const token = sub.endpoint.replace('expo://', '');
|
||||
if (!expoTokens.includes(token)) expoTokens.push(token);
|
||||
} else {
|
||||
webSubs.push(sub);
|
||||
}
|
||||
}
|
||||
|
||||
const title = 'Medicamento disponible';
|
||||
const body = `${medicine_name || medicine_nregistro} está disponible en ${pharmacy?.name || 'una farmacia cercana'}`;
|
||||
const url = `/?nregistro=${encodeURIComponent(medicine_nregistro)}`;
|
||||
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
let pruned = 0;
|
||||
|
||||
await Promise.all(subs.map(async (sub) => {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
payload
|
||||
);
|
||||
sent++;
|
||||
} catch (err) {
|
||||
failed++;
|
||||
const status = err?.statusCode;
|
||||
if (status === 404 || status === 410) {
|
||||
try {
|
||||
const deleteQuery = sub._pharmSub
|
||||
? 'DELETE FROM push_subscriptions_pharmacy WHERE id = ?'
|
||||
: 'DELETE FROM push_subscriptions WHERE id = ?';
|
||||
await userDbRun(deleteQuery, [sub.id]);
|
||||
pruned++;
|
||||
} catch {}
|
||||
} else {
|
||||
console.error('[push] send failed:', status, err?.body || err?.message);
|
||||
// Web Push via VAPID
|
||||
if (PUSH_ENABLED && webSubs.length > 0) {
|
||||
const payload = JSON.stringify({
|
||||
title,
|
||||
body,
|
||||
url,
|
||||
medicine_nregistro,
|
||||
medicine_name: medicine_name || null,
|
||||
pharmacy_id: pharmacy?.id ?? null,
|
||||
pharmacy_name: pharmacy?.name ?? null,
|
||||
});
|
||||
|
||||
await Promise.all(webSubs.map(async (sub) => {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
payload
|
||||
);
|
||||
sent++;
|
||||
} catch (err) {
|
||||
failed++;
|
||||
const status = err?.statusCode;
|
||||
if (status === 404 || status === 410) {
|
||||
try {
|
||||
const deleteQuery = sub._pharmSub
|
||||
? 'DELETE FROM push_subscriptions_pharmacy WHERE id = ?'
|
||||
: 'DELETE FROM push_subscriptions WHERE id = ?';
|
||||
await userDbRun(deleteQuery, [sub.id]);
|
||||
pruned++;
|
||||
} catch {}
|
||||
} else {
|
||||
console.error('[push] web send failed:', status, err?.body || err?.message);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Expo Push (mobile)
|
||||
if (EXPO_ACCESS_TOKEN && expoTokens.length > 0) {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < expoTokens.length; i += 100) {
|
||||
chunks.push(expoTokens.slice(i, i + 100));
|
||||
}
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const messages = chunk.map((token) => ({
|
||||
to: token,
|
||||
sound: 'default',
|
||||
title,
|
||||
body,
|
||||
data: {
|
||||
url,
|
||||
medicine_nregistro,
|
||||
medicine_name: medicine_name || null,
|
||||
pharmacy_id: pharmacy?.id ?? null,
|
||||
pharmacy_name: pharmacy?.name ?? null,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await fetch('https://exp.host/--/api/v2/push/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${EXPO_ACCESS_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify(messages),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
// Handle individual ticket errors
|
||||
if (result.data) {
|
||||
for (const ticket of result.data) {
|
||||
if (ticket.status === 'ok') {
|
||||
sent++;
|
||||
} else {
|
||||
failed++;
|
||||
// Prune invalid tokens
|
||||
if (ticket.message?.includes('InvalidCredentials') || ticket.message?.includes('DeviceNotRegistered')) {
|
||||
const expoEndpoint = `expo://${ticket.id || ''}`;
|
||||
try {
|
||||
await userDbRun('DELETE FROM push_subscriptions WHERE endpoint = ?', [expoEndpoint]);
|
||||
await userDbRun('DELETE FROM push_subscriptions_pharmacy WHERE endpoint = ?', [expoEndpoint]);
|
||||
await userDbRun('DELETE FROM expo_push_tokens WHERE expo_token = ?', [ticket.id || '']);
|
||||
pruned++;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sent += chunk.length;
|
||||
}
|
||||
} else {
|
||||
failed += chunk.length;
|
||||
console.error('[push] expo send failed:', response.status);
|
||||
}
|
||||
} catch (err) {
|
||||
failed += chunk.length;
|
||||
console.error('[push] expo send error:', err.message);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return { sent, failed, pruned };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Location from 'expo-location';
|
||||
import { getMedicine, getMedicinePharmacies } from '../../services/medicines';
|
||||
import { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { StockBadge } from '../../components/StockBadge';
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
||||
import { useThemeContext } from '../../components/ThemeProvider';
|
||||
@@ -40,6 +42,7 @@ export default function MedicineDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const { colors } = useThemeContext();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [medicine, setMedicine] = useState<Medicine | null>(null);
|
||||
const [pharmacies, setPharmacies] = useState<PharmacyMedicine[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -47,6 +50,8 @@ export default function MedicineDetailScreen() {
|
||||
const [userPosition, setUserPosition] = useState<{ lat: number; lon: number } | null>(null);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [locationError, setLocationError] = useState<string | null>(null);
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const [togglingSub, setTogglingSub] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -96,6 +101,24 @@ export default function MedicineDetailScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleSubscription = async () => {
|
||||
if (!isAuthenticated || !id) return;
|
||||
setTogglingSub(true);
|
||||
try {
|
||||
if (isSubscribed) {
|
||||
await unsubscribeFromMedicine(id as string);
|
||||
setIsSubscribed(false);
|
||||
} else {
|
||||
await subscribeToMedicine(id as string, medicine?.name || null);
|
||||
setIsSubscribed(true);
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setTogglingSub(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedPharmacies = useMemo(() => {
|
||||
if (!sortByDistance || !userPosition) return pharmacies;
|
||||
|
||||
@@ -148,7 +171,22 @@ export default function MedicineDetailScreen() {
|
||||
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={[styles.header, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.name, { color: colors.text }]}>{medicine.name}</Text>
|
||||
{medicine.stock != null && <StockBadge stock={medicine.stock} />}
|
||||
<View style={styles.headerRight}>
|
||||
{medicine.stock != null && <StockBadge stock={medicine.stock} />}
|
||||
{isAuthenticated && (
|
||||
<TouchableOpacity
|
||||
style={[styles.bellButton, { backgroundColor: isSubscribed ? colors.primary : colors.surfaceVariant }]}
|
||||
onPress={handleToggleSubscription}
|
||||
disabled={togglingSub}
|
||||
>
|
||||
<Ionicons
|
||||
name={isSubscribed ? 'notifications' : 'notifications-outline'}
|
||||
size={20}
|
||||
color={isSubscribed ? '#fff' : colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.infoSection, { backgroundColor: colors.card }]}>
|
||||
@@ -277,12 +315,24 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-start',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
name: {
|
||||
flex: 1,
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
bellButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
infoSection: {
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.lg,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import MapView, { Marker } from 'react-native-maps';
|
||||
import { getPharmacy, getPharmacyMedicines } from '../../services/pharmacies';
|
||||
import { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
||||
import { useThemeContext } from '../../components/ThemeProvider';
|
||||
import { spacing, borderRadius } from '../../constants/theme';
|
||||
@@ -13,9 +15,11 @@ export default function PharmacyDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const { colors } = useThemeContext();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [pharmacy, setPharmacy] = useState<Pharmacy | null>(null);
|
||||
const [medicines, setMedicines] = useState<PharmacyMedicine[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [subscribedMeds, setSubscribedMeds] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -51,6 +55,27 @@ export default function PharmacyDetailScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleMedSubscription = async (nregistro: string, medicineName: string) => {
|
||||
if (!isAuthenticated) return;
|
||||
const key = nregistro;
|
||||
const isSub = subscribedMeds.has(key);
|
||||
try {
|
||||
if (isSub) {
|
||||
await unsubscribeFromMedicine(nregistro, Number(id));
|
||||
setSubscribedMeds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
await subscribeToMedicine(nregistro, medicineName, Number(id));
|
||||
setSubscribedMeds(prev => new Set(prev).add(key));
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message="Cargando farmacia..." />;
|
||||
}
|
||||
@@ -124,22 +149,41 @@ export default function PharmacyDetailScreen() {
|
||||
{medicines.length === 0 ? (
|
||||
<Text style={[styles.noMedicines, { color: colors.textSecondary }]}>No hay medicamentos disponibles</Text>
|
||||
) : (
|
||||
medicines.map((med) => (
|
||||
<TouchableOpacity
|
||||
key={med.id}
|
||||
style={[styles.medicineCard, { backgroundColor: colors.card }]}
|
||||
onPress={() => router.push(`/medicine/${med.medicine_nregistro}`)}
|
||||
>
|
||||
<View style={styles.medicineInfo}>
|
||||
<Text style={[styles.medicineName, { color: colors.text }]}>{med.medicine_name}</Text>
|
||||
<Text style={[styles.medicineNregistro, { color: colors.textSecondary }]}>Reg: {med.medicine_nregistro}</Text>
|
||||
medicines.map((med) => {
|
||||
const isSub = subscribedMeds.has(med.medicine_nregistro);
|
||||
return (
|
||||
<View key={med.id} style={[styles.medicineCard, { backgroundColor: colors.card }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.medicineCardContent}
|
||||
onPress={() => router.push(`/medicine/${med.medicine_nregistro}`)}
|
||||
>
|
||||
<View style={styles.medicineInfo}>
|
||||
<Text style={[styles.medicineName, { color: colors.text }]}>{med.medicine_name}</Text>
|
||||
<Text style={[styles.medicineNregistro, { color: colors.textSecondary }]}>Reg: {med.medicine_nregistro}</Text>
|
||||
</View>
|
||||
<View style={styles.medicineStock}>
|
||||
<Text style={[styles.price, { color: colors.primary }]}>{med.price.toFixed(2)} €</Text>
|
||||
<Text style={[styles.stock, { color: colors.textSecondary }]}>Stock: {med.stock}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{isAuthenticated && (
|
||||
<TouchableOpacity
|
||||
style={[styles.bellButton, { backgroundColor: isSub ? colors.primary : colors.surfaceVariant, borderTopColor: colors.border }]}
|
||||
onPress={() => handleToggleMedSubscription(med.medicine_nregistro, med.medicine_name)}
|
||||
>
|
||||
<Ionicons
|
||||
name={isSub ? 'notifications' : 'notifications-outline'}
|
||||
size={18}
|
||||
color={isSub ? '#fff' : colors.textSecondary}
|
||||
/>
|
||||
<Text style={[styles.bellText, { color: isSub ? '#fff' : colors.textSecondary }]}>
|
||||
{isSub ? 'Notificaciones activas' : 'Notificarme cuando haya stock'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.medicineStock}>
|
||||
<Text style={[styles.price, { color: colors.primary }]}>{med.price.toFixed(2)} €</Text>
|
||||
<Text style={[styles.stock, { color: colors.textSecondary }]}>Stock: {med.stock}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
@@ -207,11 +251,14 @@ const styles = StyleSheet.create({
|
||||
padding: spacing.xl,
|
||||
},
|
||||
medicineCard: {
|
||||
borderRadius: borderRadius.md,
|
||||
marginBottom: spacing.sm,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
medicineCardContent: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
medicineInfo: {
|
||||
flex: 1,
|
||||
@@ -235,6 +282,18 @@ const styles = StyleSheet.create({
|
||||
fontSize: 12,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
bellButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
bellText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as Notifications from 'expo-notifications';
|
||||
import * as Device from 'expo-device';
|
||||
import * as Constants from 'expo-constants';
|
||||
import { Platform } from 'react-native';
|
||||
import api from './api';
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
@@ -12,6 +13,8 @@ Notifications.setNotificationHandler({
|
||||
}),
|
||||
});
|
||||
|
||||
let _cachedToken: string | null = null;
|
||||
|
||||
export async function registerForPushNotifications() {
|
||||
if (!Device.isDevice) {
|
||||
console.log('Push notifications require a physical device');
|
||||
@@ -48,6 +51,11 @@ export async function registerForPushNotifications() {
|
||||
projectId,
|
||||
});
|
||||
|
||||
_cachedToken = token.data;
|
||||
|
||||
// Register token with backend (fire and forget)
|
||||
registerTokenWithBackend(token.data).catch(() => {});
|
||||
|
||||
return token.data;
|
||||
} catch (e) {
|
||||
console.log('Error getting push token:', e);
|
||||
@@ -55,6 +63,52 @@ export async function registerForPushNotifications() {
|
||||
}
|
||||
}
|
||||
|
||||
async function registerTokenWithBackend(token: string) {
|
||||
try {
|
||||
await api.post('/notifications/expo-register', { token });
|
||||
} catch (e) {
|
||||
console.log('Failed to register push token with backend:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribeToMedicine(
|
||||
medicineNregistro: string,
|
||||
medicineName: string | null,
|
||||
pharmacyId?: number | null
|
||||
) {
|
||||
const token = _cachedToken;
|
||||
if (!token) {
|
||||
console.log('No push token available for subscription');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post('/notifications/expo-register', {
|
||||
token,
|
||||
medicine_nregistro: medicineNregistro,
|
||||
medicine_name: medicineName,
|
||||
pharmacy_id: pharmacyId || null,
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('Failed to subscribe to medicine notifications:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function unsubscribeFromMedicine(
|
||||
medicineNregistro: string,
|
||||
pharmacyId?: number | null
|
||||
) {
|
||||
try {
|
||||
await api.delete('/notifications/expo-unregister', {
|
||||
data: {
|
||||
medicine_nregistro: medicineNregistro,
|
||||
pharmacy_id: pharmacyId || null,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('Failed to unsubscribe from medicine notifications:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleMedicineAvailabilityNotification(
|
||||
medicineName: string,
|
||||
pharmacyName: string
|
||||
|
||||
@@ -57,12 +57,20 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .suggestion-btn:hover {
|
||||
background: #07243d;
|
||||
}
|
||||
|
||||
.suggestion-btn--neutral-1 {
|
||||
background: var(--suggestion-1);
|
||||
color: var(--suggestion-text);
|
||||
border: 1px solid var(--suggestion-border-1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .suggestion-btn--neutral-1:hover {
|
||||
background: #1e2225;
|
||||
}
|
||||
|
||||
.suggestion-btn--neutral-2 {
|
||||
background: var(--suggestion-2);
|
||||
color: var(--suggestion-text);
|
||||
@@ -179,6 +187,10 @@
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .recent-btn {
|
||||
background: #053d13;
|
||||
}
|
||||
|
||||
.recent-btn:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user