Merge pull request 'feat: add Expo Push notifications for mobile and configure EXPO_ACCESS_TOKEN' (#30) from feat/expo-push-notifications into main
Build & Push Docker Images / Detect Changes (push) Successful in 8s
Build & Push Docker Images / Backend Tests (push) Successful in 1m57s
Build & Push Docker Images / Frontend Tests (push) Successful in 1m55s
Build & Push Docker Images / Build Backend (push) Successful in 27s
Build & Push Docker Images / Build Frontend (push) Successful in 39s
Build & Push Docker Images / Deploy (push) Successful in 22s

Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
2026-07-13 10:28:46 +00:00
6 changed files with 436 additions and 51 deletions
+4
View File
@@ -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
View File
@@ -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 };
}
+51 -1
View File
@@ -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,
+76 -17
View File
@@ -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
+12
View File
@@ -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;
}