Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71a349e54b | |||
| cea2c98c82 | |||
| f6ddf30bda | |||
| c4fd5bfc77 | |||
| 454da1416d | |||
| 4afffe46f0 | |||
| d22f1b5646 | |||
| 205af1a795 | |||
| b4c572ce17 | |||
| c02abe077a | |||
| 7b526e4aaa | |||
| 414d94c8ea | |||
| 11421ef956 | |||
| 6e58939723 | |||
| 29b9f84462 | |||
| 39331e2f6e | |||
| 47f63eacc7 | |||
| 57054efe76 | |||
| a5064f2172 | |||
| 259bdb1479 | |||
| 1b9fd16b9b | |||
| ea9bbaed04 | |||
| 0f27579ca0 | |||
| d9bf4f0d18 | |||
| c0c58556e7 | |||
| df0b114058 | |||
| 3b725df118 | |||
| 5b080c4134 | |||
| 0ebd2dc35c | |||
| 1bc34b1134 | |||
| 15e136f3d5 | |||
| 7606d118e4 | |||
| 15c1127b34 | |||
| 17cd5adaef | |||
| 4429ec4c7f | |||
| 71a34293a9 | |||
| 1b9494bce6 | |||
| 8578bac776 |
@@ -4,6 +4,7 @@ A web application to search for medicines from the official Spanish CIMA databas
|
|||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
|
### Web App (Desktop/PWA)
|
||||||
- 🔍 **Real-time medicine search** from CIMA API (Agencia Española de Medicamentos)
|
- 🔍 **Real-time medicine search** from CIMA API (Agencia Española de Medicamentos)
|
||||||
- 💾 **Redis caching** for improved performance
|
- 💾 **Redis caching** for improved performance
|
||||||
- 📍 View pharmacies that sell a specific medicine
|
- 📍 View pharmacies that sell a specific medicine
|
||||||
@@ -15,14 +16,38 @@ A web application to search for medicines from the official Spanish CIMA databas
|
|||||||
- Search medicines from CIMA database
|
- Search medicines from CIMA database
|
||||||
- Link medicines to pharmacies with prices and stock
|
- Link medicines to pharmacies with prices and stock
|
||||||
|
|
||||||
|
### Mobile App (React Native)
|
||||||
|
- 📱 **Native iOS/Android** experience with Expo
|
||||||
|
- 🔍 **Medicine search** with real-time results
|
||||||
|
- 🗺️ **Interactive map** with pharmacy markers
|
||||||
|
- 📷 **Barcode scanner** for quick medicine lookup
|
||||||
|
- 🔔 **Push notifications** for availability alerts
|
||||||
|
- 🔐 **Biometric authentication** (Face ID / Touch ID)
|
||||||
|
- 💾 **Offline cache** for favorite medicines
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
- **Frontend**: React + Vite
|
### Backend
|
||||||
- **Backend**: Node.js + Express
|
- **Runtime**: Node.js + Express
|
||||||
- **Database**: SQLite (for pharmacies and relationships)
|
- **Database**: SQLite (for pharmacies and relationships)
|
||||||
- **Cache**: Redis
|
- **Cache**: Redis
|
||||||
- **External API**: CIMA (Centro de Información online de Medicamentos de la AEMPS)
|
- **External API**: CIMA (Centro de Información online de Medicamentos de la AEMPS)
|
||||||
|
|
||||||
|
### Frontend (Web/PWA)
|
||||||
|
- **Framework**: React + Vite
|
||||||
|
- **Mobile wrapper**: Capacitor (for hybrid mobile builds)
|
||||||
|
|
||||||
|
### Frontend (Mobile - React Native)
|
||||||
|
- **Framework**: Expo SDK 57 + React Native
|
||||||
|
- **Navigation**: Expo Router v4
|
||||||
|
- **State**: Zustand
|
||||||
|
- **HTTP**: Axios + TanStack Query
|
||||||
|
- **Maps**: react-native-maps
|
||||||
|
- **Camera**: expo-camera (barcode scanning)
|
||||||
|
- **Auth**: expo-local-authentication (biometrics)
|
||||||
|
- **Notifications**: expo-notifications
|
||||||
|
- **Build**: EAS Build
|
||||||
|
|
||||||
## 📋 Prerequisites
|
## 📋 Prerequisites
|
||||||
|
|
||||||
- Node.js (v18 or higher)
|
- Node.js (v18 or higher)
|
||||||
@@ -200,7 +225,7 @@ FarmaFinder/
|
|||||||
│ ├── create-admin.js # Admin user creation script
|
│ ├── create-admin.js # Admin user creation script
|
||||||
│ ├── .env.example # Environment variable template
|
│ ├── .env.example # Environment variable template
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── frontend/
|
├── frontend/ # React + Vite (Desktop/PWA)
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ ├── nginx.conf # Nginx config (Docker): serves SPA + proxies /api
|
│ ├── nginx.conf # Nginx config (Docker): serves SPA + proxies /api
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
@@ -213,6 +238,27 @@ FarmaFinder/
|
|||||||
│ │ └── main.jsx # Entry point
|
│ │ └── main.jsx # Entry point
|
||||||
│ ├── index.html
|
│ ├── index.html
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
|
├── frontend-mobile/ # Expo + React Native (iOS/Android)
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── _layout.tsx # Root layout with providers
|
||||||
|
│ │ ├── (tabs)/ # Bottom tab navigation
|
||||||
|
│ │ │ ├── index.tsx # Home (medicine search)
|
||||||
|
│ │ │ ├── map.tsx # Pharmacy map
|
||||||
|
│ │ │ └── profile.tsx # User profile
|
||||||
|
│ │ ├── medicine/[id].tsx # Medicine detail
|
||||||
|
│ │ ├── pharmacy/[id].tsx # Pharmacy detail
|
||||||
|
│ │ ├── auth/ # Login/Register screens
|
||||||
|
│ │ └── scanner.tsx # Barcode scanner
|
||||||
|
│ ├── components/ # Reusable UI components
|
||||||
|
│ ├── services/ # API and business logic
|
||||||
|
│ ├── store/ # Zustand state management
|
||||||
|
│ ├── hooks/ # Custom React hooks
|
||||||
|
│ ├── constants/ # Theme and config
|
||||||
|
│ ├── types/ # TypeScript types
|
||||||
|
│ ├── eas.json # EAS Build configuration
|
||||||
|
│ └── package.json
|
||||||
|
├── android/ # Capacitor Android project
|
||||||
|
├── ios/ # Capacitor iOS project
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -289,6 +335,194 @@ The application now uses the **CIMA (Centro de Información online de Medicament
|
|||||||
- Results are cached in Redis for performance
|
- Results are cached in Redis for performance
|
||||||
- `pharmacy_medicines` now uses `medicine_nregistro` (CIMA registration number) instead of local `medicine_id`
|
- `pharmacy_medicines` now uses `medicine_nregistro` (CIMA registration number) instead of local `medicine_id`
|
||||||
|
|
||||||
|
## 📱 Mobile App Setup (React Native)
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js (v18 or higher)
|
||||||
|
- npm or yarn
|
||||||
|
- **Expo CLI**: `npm install -g expo-cli`
|
||||||
|
- **EAS CLI**: `npm install -g eas-cli`
|
||||||
|
- **iOS**: Xcode (Mac only) + CocoaPods
|
||||||
|
- **Android**: Android Studio + Android SDK
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install all dependencies (backend + web + mobile)
|
||||||
|
npm run install:all
|
||||||
|
|
||||||
|
# Start mobile development server
|
||||||
|
npm run dev:mobile
|
||||||
|
|
||||||
|
# Scan QR code with Expo Go app (iOS/Android)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Build
|
||||||
|
|
||||||
|
For native features (camera, biometrics, notifications), use a development build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install EAS CLI
|
||||||
|
npm install -g eas-cli
|
||||||
|
|
||||||
|
# Login to Expo
|
||||||
|
eas login
|
||||||
|
|
||||||
|
# Create development build
|
||||||
|
eas build --profile development --platform ios
|
||||||
|
eas build --profile development --platform android
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend-mobile/
|
||||||
|
├── app/ # Expo Router screens
|
||||||
|
│ ├── (tabs)/ # Bottom tab navigation
|
||||||
|
│ ├── auth/ # Login/Register
|
||||||
|
│ ├── medicine/ # Medicine detail
|
||||||
|
│ ├── pharmacy/ # Pharmacy detail
|
||||||
|
│ └── scanner.tsx # Barcode scanner
|
||||||
|
├── components/ # Reusable UI components
|
||||||
|
├── services/ # API and business logic
|
||||||
|
├── store/ # Zustand state management
|
||||||
|
├── hooks/ # Custom React hooks
|
||||||
|
├── constants/ # Theme and config
|
||||||
|
└── types/ # TypeScript types
|
||||||
|
```
|
||||||
|
|
||||||
|
### Native Features
|
||||||
|
|
||||||
|
| Feature | Implementation |
|
||||||
|
|---------|---------------|
|
||||||
|
| Barcode Scanner | `expo-camera` with `CameraView` |
|
||||||
|
| Push Notifications | `expo-notifications` |
|
||||||
|
| Biometrics | `expo-local-authentication` |
|
||||||
|
| Maps | `react-native-maps` |
|
||||||
|
| Secure Storage | `expo-secure-store` |
|
||||||
|
|
||||||
|
### EAS Build Profiles
|
||||||
|
|
||||||
|
| Profile | Platform | Build Type | Use Case |
|
||||||
|
|---------|----------|------------|----------|
|
||||||
|
| `development` | iOS | Simulator | Local testing on Mac |
|
||||||
|
| `development` | Android | APK | Local testing on device |
|
||||||
|
| `preview` | Android | APK | Internal testing & sharing |
|
||||||
|
| `production` | Android | AAB | Google Play Store submission |
|
||||||
|
|
||||||
|
**Note:** iOS builds require Apple Developer account ($99/year) and are configured separately.
|
||||||
|
|
||||||
|
### Environment Configuration
|
||||||
|
|
||||||
|
The mobile app uses the same backend API as the web app. Configure the API URL in:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend-mobile/constants/config.ts
|
||||||
|
const ENV = {
|
||||||
|
development: {
|
||||||
|
API_BASE_URL: 'http://localhost:3001/api',
|
||||||
|
},
|
||||||
|
production: {
|
||||||
|
API_BASE_URL: 'https://your-production-api.com/api',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Production Deployment (Mobile)
|
||||||
|
|
||||||
|
### Distribution Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Development → EAS Build → App Store / Google Play → User Device
|
||||||
|
```
|
||||||
|
|
||||||
|
The mobile app is a **native application** that runs directly on the device. No Docker or server needed - users download it from the app stores.
|
||||||
|
|
||||||
|
### Step 1: Setup Expo Account
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install EAS CLI
|
||||||
|
npm install -g eas-cli
|
||||||
|
|
||||||
|
# Create account at https://expo.dev
|
||||||
|
|
||||||
|
# Login
|
||||||
|
eas login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Initialize EAS Project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend-mobile
|
||||||
|
eas init
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates a `projectId` - add it to `app.json`:
|
||||||
|
```json
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "your-project-id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Configure Credentials
|
||||||
|
|
||||||
|
**Android (Google Play):**
|
||||||
|
1. Create developer account ($25 one-time fee)
|
||||||
|
2. Create project in Google Cloud Console
|
||||||
|
3. Enable Play Developer API
|
||||||
|
4. Download `google-service-account.json`
|
||||||
|
5. Place in `frontend-mobile/` directory
|
||||||
|
|
||||||
|
**iOS (App Store):**
|
||||||
|
1. Join Apple Developer Program ($99/year)
|
||||||
|
2. Create App ID in Apple Developer portal
|
||||||
|
3. Generate certificates and provisioning profiles
|
||||||
|
4. Update `eas.json` with your credentials
|
||||||
|
|
||||||
|
### Step 4: Build for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Android (Google Play)
|
||||||
|
eas build --profile production --platform android
|
||||||
|
|
||||||
|
# iOS (App Store)
|
||||||
|
eas build --profile production --platform ios
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Submit to Stores
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Submit to Google Play
|
||||||
|
eas submit --profile production --platform android
|
||||||
|
|
||||||
|
# Submit to App Store
|
||||||
|
eas submit --profile production --platform ios
|
||||||
|
```
|
||||||
|
|
||||||
|
### OTA Updates (Without App Store Review)
|
||||||
|
|
||||||
|
Push updates directly to users without going through store review:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install expo-updates
|
||||||
|
npx expo install expo-updates
|
||||||
|
|
||||||
|
# Send update
|
||||||
|
eas update --branch production --message "Fix: improved search"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Useful Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `eas build:list` | View previous builds |
|
||||||
|
| `eas build:cancel <id>` | Cancel a build |
|
||||||
|
| `eas submit:list` | View previous submissions |
|
||||||
|
| `eas update` | Send OTA update |
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
### Redis Connection Issues
|
### Redis Connection Issues
|
||||||
@@ -321,19 +555,52 @@ npm run create-admin
|
|||||||
|
|
||||||
## 📝 Development
|
## 📝 Development
|
||||||
|
|
||||||
**Backend development with auto-reload:**
|
### Backend
|
||||||
|
|
||||||
|
**Start with auto-reload:**
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
**Frontend development:**
|
### Frontend (Web/PWA)
|
||||||
|
|
||||||
|
**Start development server:**
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
**Clear Redis cache:**
|
### Frontend (Mobile - React Native)
|
||||||
|
|
||||||
|
**Install all dependencies:**
|
||||||
|
```bash
|
||||||
|
npm run install:all
|
||||||
|
```
|
||||||
|
|
||||||
|
**Start Expo dev server:**
|
||||||
|
```bash
|
||||||
|
npm run dev:mobile
|
||||||
|
```
|
||||||
|
|
||||||
|
**Start for specific platform:**
|
||||||
|
```bash
|
||||||
|
npm run dev:mobile:android # Android emulator
|
||||||
|
npm run dev:mobile:ios # iOS simulator
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build with EAS:**
|
||||||
|
```bash
|
||||||
|
npm run build:mobile # Production build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Submit to stores:**
|
||||||
|
```bash
|
||||||
|
npm run submit:android # Google Play
|
||||||
|
npm run submit:ios # App Store
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clear Redis cache
|
||||||
```bash
|
```bash
|
||||||
redis-cli FLUSHALL
|
redis-cli FLUSHALL
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "farma-finder-backend",
|
"name": "farma-clic-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "farma-finder-backend",
|
"name": "farma-clic-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -27,11 +27,13 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
|
"multer": "^2.2.0",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"pino-http": "^10.3.0",
|
"pino-http": "^10.3.0",
|
||||||
"redis": "^4.6.0",
|
"redis": "^4.6.0",
|
||||||
"sqlite3": "^5.1.6",
|
"sqlite3": "^5.1.6",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
"web-push": "^3.6.7"
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3727,6 +3729,11 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/append-field": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
|
||||||
|
},
|
||||||
"node_modules/aproba": {
|
"node_modules/aproba": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
||||||
@@ -4020,6 +4027,11 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bmp-js": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="
|
||||||
|
},
|
||||||
"node_modules/bn.js": {
|
"node_modules/bn.js": {
|
||||||
"version": "4.12.3",
|
"version": "4.12.3",
|
||||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||||
@@ -4151,9 +4163,19 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/busboy": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||||
|
"dependencies": {
|
||||||
|
"streamsearch": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -4426,6 +4448,20 @@
|
|||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/concat-stream": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
|
"engines": [
|
||||||
|
"node >= 6.0"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.0.2",
|
||||||
|
"typedarray": "^0.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/connect-pg-simple": {
|
"node_modules/connect-pg-simple": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz",
|
||||||
@@ -5682,6 +5718,11 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/idb-keyval": {
|
||||||
|
"version": "6.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.6.tgz",
|
||||||
|
"integrity": "sha512-FY64UEhw+5liMzMQ1R9Mw6AF0+wyBrg1CIA1z4CjI/EvT5ty/SvQcWZgd8s9sgaNhX10Y8UzScTh89tEAls5nA=="
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -5870,6 +5911,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-url": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -7056,6 +7102,24 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/multer": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"append-field": "^1.0.0",
|
||||||
|
"busboy": "^1.6.0",
|
||||||
|
"concat-stream": "^2.0.0",
|
||||||
|
"type-is": "^1.6.18"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/napi-build-utils": {
|
"node_modules/napi-build-utils": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||||
@@ -7285,6 +7349,14 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/opencollective-postinstall": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
|
||||||
|
"bin": {
|
||||||
|
"opencollective-postinstall": "index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/p-limit": {
|
"node_modules/p-limit": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
@@ -7915,6 +7987,11 @@
|
|||||||
"@redis/time-series": "1.1.0"
|
"@redis/time-series": "1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.13.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -8477,6 +8554,14 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/streamsearch": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
@@ -8718,6 +8803,28 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tesseract.js": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"bmp-js": "^0.1.0",
|
||||||
|
"idb-keyval": "^6.2.0",
|
||||||
|
"is-url": "^1.2.4",
|
||||||
|
"node-fetch": "^2.6.9",
|
||||||
|
"opencollective-postinstall": "^2.0.3",
|
||||||
|
"regenerator-runtime": "^0.13.3",
|
||||||
|
"tesseract.js-core": "^7.0.0",
|
||||||
|
"wasm-feature-detect": "^1.8.0",
|
||||||
|
"zlibjs": "^0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tesseract.js-core": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw=="
|
||||||
|
},
|
||||||
"node_modules/test-exclude": {
|
"node_modules/test-exclude": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
||||||
@@ -8824,6 +8931,11 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typedarray": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
|
||||||
|
},
|
||||||
"node_modules/uid-safe": {
|
"node_modules/uid-safe": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||||
@@ -8964,6 +9076,11 @@
|
|||||||
"makeerror": "1.0.12"
|
"makeerror": "1.0.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wasm-feature-detect": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ=="
|
||||||
|
},
|
||||||
"node_modules/web-push": {
|
"node_modules/web-push": {
|
||||||
"version": "3.6.7",
|
"version": "3.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
@@ -9168,6 +9285,14 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zlibjs": {
|
||||||
|
"version": "0.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
||||||
|
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,15 +32,17 @@
|
|||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"connect-pg-simple": "^10.0.0",
|
"connect-pg-simple": "^10.0.0",
|
||||||
"connect-sqlite3": "^0.9.16",
|
"connect-sqlite3": "^0.9.16",
|
||||||
"pg": "^8.13.0",
|
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
|
"multer": "^2.2.0",
|
||||||
|
"pg": "^8.13.0",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"pino-http": "^10.3.0",
|
"pino-http": "^10.3.0",
|
||||||
"redis": "^4.6.0",
|
"redis": "^4.6.0",
|
||||||
"sqlite3": "^5.1.6",
|
"sqlite3": "^5.1.6",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
"web-push": "^3.6.7"
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import rateLimit from 'express-rate-limit';
|
|||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
import pinoHttp from 'pino-http';
|
import pinoHttp from 'pino-http';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { createWorker } from 'tesseract.js';
|
||||||
import { searchMedicines, getMedicineDetails } from './cima-service.js';
|
import { searchMedicines, getMedicineDetails } from './cima-service.js';
|
||||||
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
|
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
|
||||||
import { fetchPharmaciesExternal } from '../API/index.js';
|
import { fetchPharmaciesExternal } from '../API/index.js';
|
||||||
@@ -65,7 +67,8 @@ const sessionConfig = {
|
|||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.COOKIE_SECURE === 'true',
|
||||||
|
sameSite: 'lax',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||||
}
|
}
|
||||||
@@ -138,6 +141,14 @@ async function userDbRun(sql, params = []) {
|
|||||||
return dbRun(sql.replace(/\s+RETURNING\s+\w+\s*$/i, ''), params);
|
return dbRun(sql.replace(/\s+RETURNING\s+\w+\s*$/i, ''), params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function userDbAll(sql, params = []) {
|
||||||
|
if (pgPool) {
|
||||||
|
const res = await pgPool.query(toPositional(sql), params);
|
||||||
|
return res.rows;
|
||||||
|
}
|
||||||
|
return dbAll(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
// Accept opening_hours as either an object or a JSON string from the client
|
// Accept opening_hours as either an object or a JSON string from the client
|
||||||
// and return a TEXT value (or null) safe to persist.
|
// and return a TEXT value (or null) safe to persist.
|
||||||
function serializeOpeningHours(value) {
|
function serializeOpeningHours(value) {
|
||||||
@@ -225,6 +236,22 @@ async function initDatabase() {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pgPool) {
|
||||||
|
await pgPool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_alerts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('reminder', 'availability', 'schedule')),
|
||||||
|
medicine_nregistro TEXT,
|
||||||
|
title TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
schedule TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
await dbRun(`
|
await dbRun(`
|
||||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -254,7 +281,7 @@ async function initDatabase() {
|
|||||||
`);
|
`);
|
||||||
await dbRun(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm ON push_subscriptions_pharmacy(medicine_nregistro, pharmacy_id)`);
|
await dbRun(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm ON push_subscriptions_pharmacy(medicine_nregistro, pharmacy_id)`);
|
||||||
|
|
||||||
if (!pgPool) {
|
if (!pgPool) {
|
||||||
// SQLite-only migrations for existing deployments
|
// SQLite-only migrations for existing deployments
|
||||||
try { await dbRun('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0'); } catch {}
|
try { await dbRun('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0'); } catch {}
|
||||||
try { await dbRun('ALTER TABLE users ADD COLUMN address TEXT'); } catch {}
|
try { await dbRun('ALTER TABLE users ADD COLUMN address TEXT'); } catch {}
|
||||||
@@ -262,13 +289,32 @@ async function initDatabase() {
|
|||||||
try { await dbRun('ALTER TABLE users ADD COLUMN longitude REAL'); } catch {}
|
try { await dbRun('ALTER TABLE users ADD COLUMN longitude REAL'); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user_id to push tables (SQLite — no cross-DB FK)
|
// Add user_id to push tables (SQLite — no cross-DB FK)
|
||||||
try { await dbRun('ALTER TABLE push_subscriptions ADD COLUMN user_id INTEGER'); } catch {}
|
try { await dbRun('ALTER TABLE push_subscriptions ADD COLUMN user_id INTEGER'); } catch {}
|
||||||
try { await dbRun('ALTER TABLE push_subscriptions_pharmacy ADD COLUMN user_id INTEGER'); } catch {}
|
try { await dbRun('ALTER TABLE push_subscriptions_pharmacy ADD COLUMN user_id INTEGER'); } catch {}
|
||||||
|
|
||||||
console.log('Database initialized successfully');
|
// Create alerts table for persistent Avisos data
|
||||||
} catch (error) {
|
try {
|
||||||
console.error('Error initializing database:', error);
|
await dbRun(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_alerts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('reminder', 'availability', 'schedule')),
|
||||||
|
medicine_nregistro TEXT,
|
||||||
|
title TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
schedule TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
if (!/table already exists/i.test(e.message)) throw e;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('initDatabase failed:', err);
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +386,75 @@ app.get('/api/medicines/:medicineId', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== AUTHENTICATION MIDDLEWARE ==========
|
||||||
|
|
||||||
|
// Middleware to check if user is authenticated
|
||||||
|
const requireAuth = (req, res, next) => {
|
||||||
|
if (req.session && req.session.userId) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Middleware to check if user is an admin
|
||||||
|
const requireAdmin = (req, res, next) => {
|
||||||
|
if (req.session && req.session.userId && req.session.isAdmin) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
if (req.session && req.session.userId) {
|
||||||
|
return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== RECENT SEARCHES (session-based) ==========
|
||||||
|
|
||||||
|
const MAX_RECENT = 10;
|
||||||
|
|
||||||
|
// Get recent searches for the logged-in user
|
||||||
|
app.get('/api/search/recent', requireAuth, (req, res) => {
|
||||||
|
res.json(req.session.recentSearches || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save a medicine to recent searches
|
||||||
|
app.post('/api/search/recent', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { medicine } = req.body;
|
||||||
|
if (!medicine || !medicine.id) {
|
||||||
|
return res.status(400).json({ error: 'Medicine data required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.session.recentSearches) {
|
||||||
|
req.session.recentSearches = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove duplicate if exists (by id)
|
||||||
|
req.session.recentSearches = req.session.recentSearches.filter(
|
||||||
|
(m) => m.id !== medicine.id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add to front with timestamp
|
||||||
|
req.session.recentSearches.unshift({
|
||||||
|
id: medicine.id,
|
||||||
|
name: medicine.name,
|
||||||
|
active_ingredient: medicine.active_ingredient,
|
||||||
|
dosage: medicine.dosage,
|
||||||
|
form: medicine.form,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep only last MAX_RECENT
|
||||||
|
if (req.session.recentSearches.length > MAX_RECENT) {
|
||||||
|
req.session.recentSearches = req.session.recentSearches.slice(0, MAX_RECENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving recent search:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get all pharmacies (for admin/debugging)
|
// Get all pharmacies (for admin/debugging)
|
||||||
app.get('/api/pharmacies', async (req, res) => {
|
app.get('/api/pharmacies', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -402,26 +517,56 @@ app.get('/api/tsi/:cip/prescriptions', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== AUTHENTICATION MIDDLEWARE ==========
|
// ========== TSI OCR: extract CIP from uploaded photo ==========
|
||||||
|
|
||||||
// Middleware to check if user is authenticated
|
const upload = multer({
|
||||||
const requireAuth = (req, res, next) => {
|
storage: multer.memoryStorage(),
|
||||||
if (req.session && req.session.userId) {
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB max
|
||||||
return next();
|
fileFilter: (_req, file, cb) => {
|
||||||
}
|
if (file.mimetype.startsWith('image/')) cb(null, true);
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
else cb(new Error('Only image files are accepted'));
|
||||||
};
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Middleware to check if user is an admin
|
// Extract CIP from OCR text — looks for a long alphanumeric token (16 chars typical for CIP)
|
||||||
const requireAdmin = (req, res, next) => {
|
function extractCip(text) {
|
||||||
if (req.session && req.session.userId && req.session.isAdmin) {
|
const cleaned = text.replace(/\s+/g, '');
|
||||||
return next();
|
// Try exact 16-char alphanumeric match first
|
||||||
|
const exact = cleaned.match(/[A-Z0-9]{16}/i);
|
||||||
|
if (exact) return exact[0].toUpperCase();
|
||||||
|
// Fallback: 8–30 alphanumeric chars
|
||||||
|
const loose = cleaned.match(/[A-Z0-9]{8,30}/i);
|
||||||
|
if (loose) return loose[0].toUpperCase();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/api/tsi/ocr', upload.single('photo'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No image file provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = await createWorker('spa+eng');
|
||||||
|
const { data } = await worker.recognize(req.file.buffer);
|
||||||
|
await worker.terminate();
|
||||||
|
|
||||||
|
console.log('[OCR] Raw text:', data.text);
|
||||||
|
const cip = extractCip(data.text);
|
||||||
|
console.log('[OCR] Extracted CIP:', cip);
|
||||||
|
|
||||||
|
if (!cip) {
|
||||||
|
return res.status(422).json({
|
||||||
|
error: 'No se pudo detectar un código CIP en la imagen',
|
||||||
|
ocrText: data.text.trim().slice(0, 500),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ cip });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('TSI OCR error:', error);
|
||||||
|
res.status(500).json({ error: 'Error procesando la imagen' });
|
||||||
}
|
}
|
||||||
if (req.session && req.session.userId) {
|
});
|
||||||
return res.status(403).json({ error: 'Admin access required' });
|
|
||||||
}
|
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========== AUTHENTICATION ROUTES ==========
|
// ========== AUTHENTICATION ROUTES ==========
|
||||||
|
|
||||||
@@ -630,6 +775,92 @@ app.put('/api/users/me', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== USER ALERTS API ==========
|
||||||
|
|
||||||
|
// Get all alerts for the current user
|
||||||
|
app.get('/api/alerts', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const rows = await userDbAll(
|
||||||
|
'SELECT id, type, medicine_nregistro, title, detail, schedule, created_at, updated_at FROM user_alerts WHERE user_id = ? ORDER BY created_at DESC',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching alerts:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new alert
|
||||||
|
app.post('/api/alerts', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const { type, medicine_nregistro, title, detail, schedule } = req.body || {};
|
||||||
|
|
||||||
|
if (!type || !['reminder', 'availability', 'schedule'].includes(type)) {
|
||||||
|
return res.status(400).json({ error: 'Valid type is required (reminder, availability, schedule)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await userDbRun(
|
||||||
|
'INSERT INTO user_alerts (user_id, type, medicine_nregistro, title, detail, schedule) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[userId, type, medicine_nregistro || null, title || null, detail || null, schedule || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
const newAlert = await userDbGet('SELECT * FROM user_alerts WHERE id = ?', [result.lastID]);
|
||||||
|
res.status(201).json(newAlert);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating alert:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update an existing alert
|
||||||
|
app.put('/api/alerts/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const alertId = parseInt(req.params.id);
|
||||||
|
const { medicine_nregistro, title, detail, schedule } = req.body || {};
|
||||||
|
|
||||||
|
const result = await userDbRun(
|
||||||
|
'UPDATE user_alerts SET medicine_nregistro = ?, title = ?, detail = ?, schedule = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?',
|
||||||
|
[medicine_nregistro || null, title || null, detail || null, schedule || null, alertId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Alert not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAlert = await userDbGet('SELECT * FROM user_alerts WHERE id = ?', [alertId]);
|
||||||
|
res.json(updatedAlert);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating alert:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete an alert
|
||||||
|
app.delete('/api/alerts/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const alertId = parseInt(req.params.id);
|
||||||
|
|
||||||
|
const result = await userDbRun(
|
||||||
|
'DELETE FROM user_alerts WHERE id = ? AND user_id = ?',
|
||||||
|
[alertId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Alert not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting alert:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Geocode for any authenticated user (rate-limited). Same impl as /api/admin/geocode.
|
// Geocode for any authenticated user (rate-limited). Same impl as /api/admin/geocode.
|
||||||
app.get('/api/geocode', requireAuth, geocodeLimiter, async (req, res) => {
|
app.get('/api/geocode', requireAuth, geocodeLimiter, async (req, res) => {
|
||||||
const q = (req.query.q || '').trim();
|
const q = (req.query.q || '').trim();
|
||||||
|
|||||||
@@ -7,18 +7,20 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: farmaclic
|
POSTGRES_DB: farmafinder
|
||||||
POSTGRES_USER: farmaclic
|
POSTGRES_USER: farmafinder
|
||||||
POSTGRES_PASSWORD: ${PG_PASSWORD:-change-me-in-production}
|
POSTGRES_PASSWORD: ${PG_PASSWORD:-change-me-in-production}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: git.hacecalor.net/ichitux/farmaclic-backend:latest
|
image: git.hacecalor.net/ichitux/farmafinder-backend:latest
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
environment:
|
environment:
|
||||||
@@ -31,13 +33,13 @@ services:
|
|||||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||||
DATABASE_PATH: /app/data/database.sqlite
|
DATABASE_PATH: /app/data/database.sqlite
|
||||||
FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-}
|
FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-}
|
||||||
PG_URL: postgresql://farmaclic:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmaclic
|
PG_URL: postgresql://farmafinder:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmafinder
|
||||||
# OpenTelemetry — exported via OTLP gRPC to the shared Alloy collector
|
# OpenTelemetry — exported via OTLP gRPC to the shared Alloy collector
|
||||||
OTEL_SERVICE_NAME: farmaclic-backend
|
OTEL_SERVICE_NAME: farmafinder-backend
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
|
||||||
OTEL_TRACES_EXPORTER: otlp
|
OTEL_TRACES_EXPORTER: otlp
|
||||||
OTEL_LOGS_EXPORTER: otlp
|
OTEL_LOGS_EXPORTER: otlp
|
||||||
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmaclic
|
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmafinder
|
||||||
volumes:
|
volumes:
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -45,12 +47,12 @@ services:
|
|||||||
- postgres
|
- postgres
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: git.hacecalor.net/ichitux/farmaclic-frontend:latest
|
image: git.hacecalor.net/ichitux/farmafinder-frontend:latest
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
args:
|
args:
|
||||||
VITE_FARO_ENDPOINT: ${VITE_FARO_ENDPOINT:-http://localhost:4318}
|
VITE_FARO_ENDPOINT: ${VITE_FARO_ENDPOINT:-http://localhost:4318}
|
||||||
VITE_FARO_APP_NAME: ${VITE_FARO_APP_NAME:-farmaclic-frontend}
|
VITE_FARO_APP_NAME: ${VITE_FARO_APP_NAME:-farmafinder-frontend}
|
||||||
VITE_FARO_ENV: ${VITE_FARO_ENV:-production}
|
VITE_FARO_ENV: ${VITE_FARO_ENV:-production}
|
||||||
VITE_FARO_APP_VERSION: ${VITE_FARO_APP_VERSION:-1.0.0}
|
VITE_FARO_APP_VERSION: ${VITE_FARO_APP_VERSION:-1.0.0}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Diseño: Acceso al Panel de Administración
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
Añadir un botón de acceso al panel de administración en la página de perfil (pestaña "Usuario") que solo sea visible para usuarios con permisos de administrador.
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Proporcionar una forma intuitiva y consistente para que los usuarios administradores accedan al panel de administración desde la interfaz principal.
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
1. El botón solo debe aparecer cuando el usuario tiene `is_admin: true`
|
||||||
|
2. El botón debe seguir el mismo estilo que los otros elementos del menú del perfil
|
||||||
|
3. Al hacer clic, debe navegar a la vista de administración existente
|
||||||
|
4. No debe afectar la experiencia de usuarios no administradores
|
||||||
|
|
||||||
|
## Diseño Detallado
|
||||||
|
|
||||||
|
### Ubicación
|
||||||
|
- **Página:** ProfileView (pestaña "Usuario" en BottomNav)
|
||||||
|
- **Sección:** Dentro de `.profile-menu`, después de "Configuración de Texto"
|
||||||
|
- **Posición:** Antes del botón "Cerrar Sesión"
|
||||||
|
|
||||||
|
### Componente
|
||||||
|
- **Tipo:** Botón con ícono, texto y chevron (igual que otros items del menú)
|
||||||
|
- **Ícono:** Engranaje (⚙️) con fondo de color primario
|
||||||
|
- **Texto:** "Panel de Administración"
|
||||||
|
- **Estilo:** Idéntico a `.profile-menu-item` existente
|
||||||
|
|
||||||
|
### Comportamiento
|
||||||
|
1. **Renderizado condicional:**
|
||||||
|
```jsx
|
||||||
|
{currentUser?.is_admin && (
|
||||||
|
<button className="profile-menu-item" onClick={onAdminClick}>
|
||||||
|
<div className="menu-item-icon menu-item-icon--primary">
|
||||||
|
<svg>...</svg>
|
||||||
|
</div>
|
||||||
|
<span className="menu-item-label">Panel de Administración</span>
|
||||||
|
<svg className="menu-item-chevron">...</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Navegación:**
|
||||||
|
- Recibir función `onAdminClick` como prop
|
||||||
|
- En `App.jsx`, implementar `handleAdminClick` que cambie `screen` a `'admin'`
|
||||||
|
|
||||||
|
### Cambios en Archivos
|
||||||
|
|
||||||
|
#### 1. `frontend/src/views/ProfileView.jsx`
|
||||||
|
- Añadir prop `onAdminClick`
|
||||||
|
- Renderizar botón condicionalmente basado en `currentUser?.is_admin`
|
||||||
|
|
||||||
|
#### 2. `frontend/src/App.jsx`
|
||||||
|
- Añadir función `handleAdminClick` que establezca `screen` en `'admin'`
|
||||||
|
- Pasar `onAdminClick={handleAdminClick}` a `ProfileView`
|
||||||
|
|
||||||
|
### Estilos
|
||||||
|
No se requieren cambios en CSS - se reutilizan las clases existentes:
|
||||||
|
- `.profile-menu-item`
|
||||||
|
- `.menu-item-icon--primary`
|
||||||
|
- `.menu-item-label`
|
||||||
|
- `.menu-item-chevron`
|
||||||
|
|
||||||
|
## Criterios de Aceptación
|
||||||
|
1. ✅ Botón visible solo para usuarios admin
|
||||||
|
2. ✅ Botón no visible para usuarios normales
|
||||||
|
3. ✅ Estilo consistente con otros elementos del menú
|
||||||
|
4. ✅ Al hacer clic, navega a la vista de administración
|
||||||
|
5. ✅ No se muestra en dispositivos móviles si el usuario no es admin
|
||||||
|
|
||||||
|
## Impacto
|
||||||
|
- **Archivos modificados:** 2 (`ProfileView.jsx`, `App.jsx`)
|
||||||
|
- **Complejidad:** Baja
|
||||||
|
- **Riesgo:** Mínimo - solo añade un elemento condicional
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
# FarmaFinder Mobile - Especificación de Diseño
|
||||||
|
|
||||||
|
**Fecha**: 2026-07-06
|
||||||
|
**Estado**: Aprobado
|
||||||
|
**Versión**: 1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Resumen
|
||||||
|
|
||||||
|
Crear un segundo frontend en **React Native (Expo)** para FarmaFinder, manteniendo el frontend actual (React + Vite) para Desktop/PWA. El objetivo es lograr una experiencia móvil nativa que permita publicar en App Store y Google Play con mejor rendimiento, UX y mantenibilidad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Arquitectura
|
||||||
|
|
||||||
|
### 2.1 Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
FarmaFinder/
|
||||||
|
├── frontend/ ← React + Vite (Desktop/PWA, mantiene Capacitor)
|
||||||
|
├── frontend-mobile/ ← Expo + React Native (NUEVO)
|
||||||
|
│ ├── app/ ← Expo Router (file-based routing)
|
||||||
|
│ │ ├── _layout.tsx ← Layout raíz
|
||||||
|
│ │ ├── (tabs)/ ← Bottom tabs
|
||||||
|
│ │ │ ├── _layout.tsx ← Tab navigator
|
||||||
|
│ │ │ ├── index.tsx ← Home (búsqueda)
|
||||||
|
│ │ │ ├── map.tsx ← Mapa de farmacias
|
||||||
|
│ │ │ └── profile.tsx ← Perfil de usuario
|
||||||
|
│ │ ├── medicine/[id].tsx ← Detalle de medicamento
|
||||||
|
│ │ ├── pharmacy/[id].tsx ← Detalle de farmacia
|
||||||
|
│ │ └── auth/ ← Auth flow
|
||||||
|
│ ├── components/ ← Componentes React Native
|
||||||
|
│ ├── services/ ← Lógica de negocio y llamadas API
|
||||||
|
│ ├── hooks/ ← Custom hooks
|
||||||
|
│ ├── constants/ ← Colores, tipos, config
|
||||||
|
│ └── assets/ ← Imágenes, fuentes
|
||||||
|
├── backend/ ← API compartida (sin cambios)
|
||||||
|
└── package.json ← Scripts para ambos frontends
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Stack Tecnológico
|
||||||
|
|
||||||
|
| Componente | Tecnología | Justificación |
|
||||||
|
|------------|------------|---------------|
|
||||||
|
| Framework | Expo SDK 52+ | Setup rápido, OTA updates, fácil publicación |
|
||||||
|
| Routing | Expo Router v4 | File-based routing, similar a Next.js |
|
||||||
|
| Navegación | React Navigation | Bottom tabs + stack navigation |
|
||||||
|
| State | Zustand | Ligero, sin boilerplate, fácil de usar |
|
||||||
|
| HTTP Client | Axios | Interceptors, cancelación de requests |
|
||||||
|
| Cache | TanStack Query | Cache automático, optimistic updates |
|
||||||
|
| Mapas | react-native-maps | Google Maps provider nativo |
|
||||||
|
| Barcode | expo-camera | Escaneo de código de barras nativo |
|
||||||
|
| Storage | AsyncStorage + expo-secure-store | Datos offline + tokens seguros |
|
||||||
|
| Animaciones | React Native Reanimated 3 | Animaciones fluidas en 60fps |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Estructura de Navegación
|
||||||
|
|
||||||
|
### 3.1 Navegación Principal
|
||||||
|
|
||||||
|
```
|
||||||
|
Bottom Tabs
|
||||||
|
├── Home (🔍) → Búsqueda de medicamentos
|
||||||
|
├── Mapa (📍) → Mapa de farmacias
|
||||||
|
└── Perfil (👤) → Perfil de usuario + Admin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Navegación Detallada
|
||||||
|
|
||||||
|
```
|
||||||
|
Home Tab (Stack)
|
||||||
|
├── index.tsx ← Lista de resultados de búsqueda
|
||||||
|
└── medicine/[id].tsx ← Detalle de medicamento
|
||||||
|
|
||||||
|
Mapa Tab (Stack)
|
||||||
|
├── map.tsx ← Mapa con marcadores de farmacias
|
||||||
|
└── pharmacy/[id].tsx ← Detalle de farmacia
|
||||||
|
|
||||||
|
Perfil Tab (Stack)
|
||||||
|
├── profile.tsx ← Info de usuario + opciones
|
||||||
|
├── auth/login.tsx ← Login (si no autenticado)
|
||||||
|
├── auth/register.tsx ← Registro
|
||||||
|
└── admin/ ← Panel admin (solo si auth + admin)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Flujos de Navegación
|
||||||
|
|
||||||
|
- **Auth Flow**: Si no hay token → redirigir a login
|
||||||
|
- **Admin Flow**: Si usuario es admin → mostrar acceso a admin
|
||||||
|
- **Deep Linking**: URLs como `farmafinder://medicine/123` abren pantalla específica
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Funcionalidades
|
||||||
|
|
||||||
|
### 4.1 MVP (Paridad con Web)
|
||||||
|
|
||||||
|
| Funcionalidad | Descripción |
|
||||||
|
|---------------|-------------|
|
||||||
|
| **Búsqueda** | Búsqueda en tiempo real de medicamentos vía API CIMA |
|
||||||
|
| **Resultados** | Lista de resultados con nombre, principio activo |
|
||||||
|
| **Detalle medicamento** | Precio, stock, farmacias que lo tienen |
|
||||||
|
| **Mapa farmacias** | Marcadores en mapa con ubicación |
|
||||||
|
| **Detalle farmacia** | Dirección, teléfono, medicamentos disponibles |
|
||||||
|
| **Auth** | Login/registro de usuarios |
|
||||||
|
|
||||||
|
### 4.2 Extras Nativos (NUEVO)
|
||||||
|
|
||||||
|
| Funcionalidad | Descripción |
|
||||||
|
|---------------|-------------|
|
||||||
|
| **Push Notifications** | Alertas de disponibilidad de medicamentos favoritos |
|
||||||
|
| **Escáner código barras** | Escanear medicamentos directamente con cámara |
|
||||||
|
| **Biometría** | Face ID / Touch ID para login rápido |
|
||||||
|
| **Deep Links** | Compartir medicamentos/farmacias via URL |
|
||||||
|
| **Offline Cache** | Medicamentos favoritos accesibles sin conexión |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Integración con la API
|
||||||
|
|
||||||
|
### 5.1 Cliente API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend-mobile/services/api.ts
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_BASE = __DEV__
|
||||||
|
? 'http://localhost:3001/api'
|
||||||
|
: 'https://tu-dominio.com/api';
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
timeout: 10000,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor para auth token
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = getToken();
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Endpoints
|
||||||
|
|
||||||
|
**Públicos:**
|
||||||
|
- `GET /api/medicines/search?q=<query>` - Búsqueda de medicamentos
|
||||||
|
- `GET /api/medicines/:nregistro` - Detalle de medicamento
|
||||||
|
- `GET /api/medicines/:nregistro/pharmacies` - Farmacias con medicamento
|
||||||
|
- `GET /api/pharmacies` - Todas las farmacias
|
||||||
|
|
||||||
|
**Autenticados:**
|
||||||
|
- `POST /api/auth/login` - Login
|
||||||
|
- `POST /api/auth/logout` - Logout
|
||||||
|
- `GET /api/auth/check` - Verificar auth
|
||||||
|
|
||||||
|
**Admin:**
|
||||||
|
- CRUD de farmacias
|
||||||
|
- Búsqueda de medicamentos para admin
|
||||||
|
- Vincular medicamentos a farmacias
|
||||||
|
|
||||||
|
### 5.3 Estrategia de Cache
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Con TanStack Query
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['medicines', searchQuery],
|
||||||
|
queryFn: () => searchMedicines(searchQuery),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutos
|
||||||
|
cacheTime: 30 * 60 * 1000, // 30 minutos
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Persistencia offline:**
|
||||||
|
- AsyncStorage para datos no sensibles
|
||||||
|
- expo-secure-store para tokens y credenciales
|
||||||
|
- React Query persistor para cache de queries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Diseño Visual
|
||||||
|
|
||||||
|
### 6.1 Design Tokens
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend-mobile/constants/theme.ts
|
||||||
|
export const colors = {
|
||||||
|
// Primary
|
||||||
|
primary: '#007AFF',
|
||||||
|
primaryLight: '#4DA3FF',
|
||||||
|
|
||||||
|
// Semantic
|
||||||
|
success: '#34C759', // Disponible
|
||||||
|
danger: '#FF3B30', // Sin stock
|
||||||
|
warning: '#FF9500', // Stock bajo
|
||||||
|
|
||||||
|
// Neutral
|
||||||
|
background: '#F2F2F7',
|
||||||
|
card: '#FFFFFF',
|
||||||
|
border: '#E5E5EA',
|
||||||
|
|
||||||
|
// Text
|
||||||
|
text: '#1C1C1E',
|
||||||
|
textSecondary: '#8E8E93',
|
||||||
|
textInverse: '#FFFFFF',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const spacing = {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 16,
|
||||||
|
lg: 24,
|
||||||
|
xl: 32,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const borderRadius = {
|
||||||
|
sm: 8,
|
||||||
|
md: 12,
|
||||||
|
lg: 16,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Componentes Principales
|
||||||
|
|
||||||
|
| Componente | Descripción |
|
||||||
|
|------------|-------------|
|
||||||
|
| **SearchBar** | Input con debounce (300ms) + dropdown de resultados |
|
||||||
|
| **MedicineCard** | Card con nombre, principio activo, precio, badge de stock |
|
||||||
|
| **PharmacyMarker** | Marcador personalizado en mapa con info window |
|
||||||
|
| **StockBadge** | Badge verde (disponible) / rojo (sin stock) |
|
||||||
|
| **LoadingSpinner** | Spinner nativo durante cargas |
|
||||||
|
| **ErrorToast** | Toast de error con retry |
|
||||||
|
|
||||||
|
### 6.3 Patrones de Diseño
|
||||||
|
|
||||||
|
- **Cards**: Sombra sutil, border-radius 12px
|
||||||
|
- **Tipografía**: Sistema nativo (SF Pro en iOS, Roboto en Android)
|
||||||
|
- **Espaciado**: Escala de 4px base (4, 8, 12, 16, 24, 32)
|
||||||
|
- **Animaciones**: Reanimated 3 para transiciones y gestos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Extras Nativos (Detalles)
|
||||||
|
|
||||||
|
### 7.1 Push Notifications
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Expo Notifications
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
|
||||||
|
// Registrar para notificaciones
|
||||||
|
const registerForPushNotifications = async () => {
|
||||||
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
|
if (status !== 'granted') return;
|
||||||
|
|
||||||
|
const token = await Notifications.getExpoPushToken();
|
||||||
|
return token.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Escuchar notificaciones
|
||||||
|
Notifications.addNotificationReceivedListener((notification) => {
|
||||||
|
// Mostrar alerta de disponibilidad
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Escáner de Código de Barras
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// expo-camera + barcode scanning
|
||||||
|
import { CameraView } from 'expo-camera';
|
||||||
|
|
||||||
|
<CameraView
|
||||||
|
onBarcodeScanned={(data) => {
|
||||||
|
// Buscar medicamento por código de barras
|
||||||
|
searchMedicineByBarcode(data.data);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Biometría
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// expo-local-authentication
|
||||||
|
import * as LocalAuthentication from 'expo-local-authentication';
|
||||||
|
|
||||||
|
const authenticateWithBiometrics = async () => {
|
||||||
|
const hasHardware = await LocalAuthentication.hasHardwareAsync();
|
||||||
|
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
||||||
|
|
||||||
|
if (hasHardware && isEnrolled) {
|
||||||
|
return await LocalAuthentication.authenticateAsync();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Workflow de Desarrollo
|
||||||
|
|
||||||
|
### 8.1 Scripts
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"dev:mobile": "cd frontend-mobile && npx expo start",
|
||||||
|
"dev:mobile:android": "cd frontend-mobile && npx expo start --android",
|
||||||
|
"dev:mobile:ios": "cd frontend-mobile && npx expo start --ios",
|
||||||
|
"build:mobile:dev": "cd frontend-mobile && eas build --profile development",
|
||||||
|
"build:mobile:preview": "cd frontend-mobile && eas build --profile preview",
|
||||||
|
"build:mobile:prod": "cd frontend-mobile && eas build --profile production",
|
||||||
|
"submit:android": "cd frontend-mobile && eas submit --platform android",
|
||||||
|
"submit:ios": "cd frontend-mobile && eas submit --platform ios"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 EAS Build Profiles
|
||||||
|
|
||||||
|
```json
|
||||||
|
// frontend-mobile/eas.json
|
||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 CI/CD
|
||||||
|
|
||||||
|
- **Desarrollo**: Expo Go + hot reload
|
||||||
|
- **Testing**: EAS Build (preview) → APK/IPA para testing
|
||||||
|
- **Producción**: EAS Build (production) → Submit a tiendas
|
||||||
|
- **OTA Updates**: Expo Updates para fixes sin pasar por tienda
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Dependencias Principales
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"expo": "~52.0.0",
|
||||||
|
"expo-router": "~4.0.0",
|
||||||
|
"expo-camera": "~16.0.0",
|
||||||
|
"expo-notifications": "~0.29.0",
|
||||||
|
"expo-local-authentication": "~15.0.0",
|
||||||
|
"expo-secure-store": "~14.0.0",
|
||||||
|
"react-native": "0.76.0",
|
||||||
|
"react-native-maps": "1.18.0",
|
||||||
|
"react-native-reanimated": "~3.16.0",
|
||||||
|
"react-native-gesture-handler": "~2.20.0",
|
||||||
|
"@react-navigation/native": "^7.0.0",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||||
|
"zustand": "^5.0.0",
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"@tanstack/react-query": "^5.0.0",
|
||||||
|
"@react-native-async-storage/async-storage": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Criterios de Aceptación
|
||||||
|
|
||||||
|
### MVP
|
||||||
|
- [ ] Búsqueda de medicamentos funcional
|
||||||
|
- [ ] Mapa con farmacias mostrando marcadores
|
||||||
|
- [ ] Detalle de medicamento con precio y stock
|
||||||
|
- [ ] Detalle de farmacia con ubicación
|
||||||
|
- [ ] Login/registro funcional
|
||||||
|
- [ ] Navegación entre pantallas fluida
|
||||||
|
- [ ] Build de desarrollo funcionando en Expo Go
|
||||||
|
|
||||||
|
### Extras Nativos
|
||||||
|
- [ ] Push notifications configuradas
|
||||||
|
- [ ] Escáner de código de barras funcional
|
||||||
|
- [ ] Biometría para login rápido
|
||||||
|
- [ ] Deep links configurados
|
||||||
|
- [ ] Offline cache para favoritos
|
||||||
|
|
||||||
|
### Publicación
|
||||||
|
- [ ] Build de producción optimizado
|
||||||
|
- [ ] App Store listing configurado
|
||||||
|
- [ ] Google Play listing configurado
|
||||||
|
- [ ] Screenshots y descripción preparados
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Riesgos y Mitigaciones
|
||||||
|
|
||||||
|
| Riesgo | Mitigación |
|
||||||
|
|--------|------------|
|
||||||
|
| API del backend no preparada para móvil | Revisar endpoints, agregar si es necesario |
|
||||||
|
| Performance en dispositivos antiguos | Testing en dispositivos低端, optimizar renders |
|
||||||
|
| Diferencias iOS/Android | Usar componentes nativos, testing en ambas plataformas |
|
||||||
|
| Mantenimiento de dos frontends | Documentar bien, compartir types cuando sea posible |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Próximos Pasos
|
||||||
|
|
||||||
|
1. Crear estructura del proyecto Expo
|
||||||
|
2. Configurar routing con Expo Router
|
||||||
|
3. Implementar cliente API
|
||||||
|
4. Crear pantallas MVP
|
||||||
|
5. Implementar extras nativos
|
||||||
|
6. Testing y optimización
|
||||||
|
7. Build y publicación en tiendas
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"expo@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# FarmaFinder Mobile - Environment Variables
|
||||||
|
|
||||||
|
# API Configuration
|
||||||
|
# Change this to your production API URL
|
||||||
|
EXPO_PUBLIC_API_URL=http://localhost:3001/api
|
||||||
|
|
||||||
|
# For production builds, update this to:
|
||||||
|
# EXPO_PUBLIC_API_URL=https://api.yourdomain.com/api
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
|
||||||
|
# Native
|
||||||
|
ios/
|
||||||
|
android/
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
*.orig.*
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# EAS
|
||||||
|
eas-cli.json
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.ipa
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Expo HAS CHANGED
|
||||||
|
|
||||||
|
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text>Open up App.tsx to start working on your app!</Text>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "FarmaFinder",
|
||||||
|
"slug": "farmafinder",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "light",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#007AFF"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.farmafinder.app",
|
||||||
|
"config": {
|
||||||
|
"usesNonExemptEncryption": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#007AFF"
|
||||||
|
},
|
||||||
|
"package": "com.farmafinder.app",
|
||||||
|
"googleServicesFile": "./google-services.json"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
["expo-camera", {"cameraPermission": "Allow FarmaFinder to access your camera for scanning barcodes"}],
|
||||||
|
["expo-notifications", {"icon": "./assets/notification-icon.png", "color": "#007AFF"}]
|
||||||
|
],
|
||||||
|
"scheme": "farmafinder",
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Tabs } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
tabBarActiveTintColor: '#007AFF',
|
||||||
|
tabBarInactiveTintColor: '#8E8E93',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: 'Buscar',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="search" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="map"
|
||||||
|
options={{
|
||||||
|
title: 'Mapa',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="map" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="profile"
|
||||||
|
options={{
|
||||||
|
title: 'Perfil',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="person" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { View, FlatList, StyleSheet, Text, TouchableOpacity } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { SearchBar } from '../../components/SearchBar';
|
||||||
|
import { MedicineCard } from '../../components/MedicineCard';
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
||||||
|
import { useDebounce } from '../../hooks/useDebounce';
|
||||||
|
import { searchMedicines } from '../../services/medicines';
|
||||||
|
import { colors, spacing, borderRadius } from '../../constants/theme';
|
||||||
|
import { Medicine } from '../../types';
|
||||||
|
import { config } from '../../constants/config';
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<Medicine[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const debouncedQuery = useDebounce(query, config.SEARCH_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedQuery.length < 2) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchResults = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await searchMedicines(debouncedQuery);
|
||||||
|
setResults(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al buscar medicamentos');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchResults();
|
||||||
|
}, [debouncedQuery]);
|
||||||
|
|
||||||
|
const handleSearch = (searchQuery: string) => {
|
||||||
|
setQuery(searchQuery);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.searchContainer}>
|
||||||
|
<SearchBar
|
||||||
|
onSearch={handleSearch}
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.scannerButton}
|
||||||
|
onPress={() => router.push('/scanner')}
|
||||||
|
>
|
||||||
|
<Ionicons name="scan" size={24} color={colors.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading && <LoadingSpinner message="Buscando medicamentos..." />}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && results.length === 0 && query.length >= 2 && (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>No se encontraron medicamentos</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={results}
|
||||||
|
keyExtractor={(item) => item.nregistro}
|
||||||
|
renderItem={({ item }) => <MedicineCard medicine={item} />}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
searchContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
scannerButton: {
|
||||||
|
marginRight: spacing.md,
|
||||||
|
padding: spacing.sm,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
paddingBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
padding: spacing.md,
|
||||||
|
marginHorizontal: spacing.md,
|
||||||
|
backgroundColor: '#F8D7DA',
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: '#721C24',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
padding: spacing.xl,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { View, StyleSheet, Text } from 'react-native';
|
||||||
|
import MapView, { Marker } from 'react-native-maps';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { getPharmacies } from '../../services/pharmacies';
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
||||||
|
import { colors, spacing } from '../../constants/theme';
|
||||||
|
import { Pharmacy } from '../../types';
|
||||||
|
|
||||||
|
export default function MapScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pharmacies, setPharmacies] = useState<Pharmacy[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [region, setRegion] = useState({
|
||||||
|
latitude: 40.4168, // Madrid default
|
||||||
|
longitude: -3.7038,
|
||||||
|
latitudeDelta: 0.0922,
|
||||||
|
longitudeDelta: 0.0421,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPharmacies = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getPharmacies();
|
||||||
|
setPharmacies(data);
|
||||||
|
|
||||||
|
// Center map on first pharmacy if available
|
||||||
|
if (data.length > 0) {
|
||||||
|
setRegion({
|
||||||
|
latitude: data[0].latitude,
|
||||||
|
longitude: data[0].longitude,
|
||||||
|
latitudeDelta: 0.0922,
|
||||||
|
longitudeDelta: 0.0421,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching pharmacies:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPharmacies();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSpinner message="Cargando farmacias..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<MapView
|
||||||
|
style={styles.map}
|
||||||
|
region={region}
|
||||||
|
onRegionChangeComplete={setRegion}
|
||||||
|
showsUserLocation={true}
|
||||||
|
showsMyLocationButton={true}
|
||||||
|
>
|
||||||
|
{pharmacies.map((pharmacy) => (
|
||||||
|
<Marker
|
||||||
|
key={pharmacy.id}
|
||||||
|
coordinate={{
|
||||||
|
latitude: pharmacy.latitude,
|
||||||
|
longitude: pharmacy.longitude,
|
||||||
|
}}
|
||||||
|
title={pharmacy.name}
|
||||||
|
description={pharmacy.address}
|
||||||
|
onCalloutPress={() => router.push(`/pharmacy/${pharmacy.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</MapView>
|
||||||
|
|
||||||
|
<View style={styles.legend}>
|
||||||
|
<Text style={styles.legendText}>
|
||||||
|
{pharmacies.length} farmacias en el mapa
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: spacing.lg,
|
||||||
|
left: spacing.md,
|
||||||
|
right: spacing.md,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: spacing.sm,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
legendText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
|
import { colors, spacing, borderRadius } from '../../constants/theme';
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Cerrar Sesión',
|
||||||
|
'¿Estás seguro que deseas cerrar sesión?',
|
||||||
|
[
|
||||||
|
{ text: 'Cancelar', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Cerrar Sesión',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await logout();
|
||||||
|
router.replace('/auth/login');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.loadingText}>Cargando...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.authPrompt}>
|
||||||
|
<Ionicons name="person-outline" size={64} color={colors.textSecondary} />
|
||||||
|
<Text style={styles.authTitle}>Inicia Sesión</Text>
|
||||||
|
<Text style={styles.authSubtitle}>
|
||||||
|
Inicia sesión para acceder a todas las funcionalidades
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.button}
|
||||||
|
onPress={() => router.push('/auth/login')}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Iniciar Sesión</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.linkButton}
|
||||||
|
onPress={() => router.push('/auth/register')}
|
||||||
|
>
|
||||||
|
<Text style={styles.linkText}>Crear cuenta nueva</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.avatar}>
|
||||||
|
<Ionicons name="person" size={40} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.username}>{user?.username}</Text>
|
||||||
|
{isAdmin && <Text style={styles.adminBadge}>Administrador</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.menuSection}>
|
||||||
|
{isAdmin && (
|
||||||
|
<TouchableOpacity style={styles.menuItem}>
|
||||||
|
<Ionicons name="settings" size={24} color={colors.text} />
|
||||||
|
<Text style={styles.menuText}>Panel Admin</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.menuItem}>
|
||||||
|
<Ionicons name="heart" size={24} color={colors.text} />
|
||||||
|
<Text style={styles.menuText}>Favoritos</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.menuItem}>
|
||||||
|
<Ionicons name="notifications" size={24} color={colors.text} />
|
||||||
|
<Text style={styles.menuText}>Notificaciones</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.menuItem}>
|
||||||
|
<Ionicons name="help-circle" size={24} color={colors.text} />
|
||||||
|
<Text style={styles.menuText}>Ayuda</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
||||||
|
<Ionicons name="log-out" size={20} color={colors.danger} />
|
||||||
|
<Text style={styles.logoutText}>Cerrar Sesión</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: spacing.xxl,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
authPrompt: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
authTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text,
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
},
|
||||||
|
authSubtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.xl,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
adminBadge: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.primary,
|
||||||
|
backgroundColor: '#E3F2FD',
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
menuSection: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
},
|
||||||
|
menuItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
|
menuText: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacing.md,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: colors.textInverse,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
linkButton: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
logoutButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
margin: spacing.xl,
|
||||||
|
padding: spacing.md,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.danger,
|
||||||
|
},
|
||||||
|
logoutText: {
|
||||||
|
marginLeft: spacing.sm,
|
||||||
|
color: colors.danger,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { registerForPushNotifications, addNotificationListener, addNotificationResponseListener } from '../services/notifications';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const { checkAuth } = useAuthStore();
|
||||||
|
const notificationListener = useRef<ReturnType<typeof addNotificationListener>>();
|
||||||
|
const responseListener = useRef<ReturnType<typeof addNotificationResponseListener>>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
|
||||||
|
registerForPushNotifications();
|
||||||
|
|
||||||
|
notificationListener.current = addNotificationListener((notification) => {
|
||||||
|
console.log('Notification received:', notification);
|
||||||
|
});
|
||||||
|
|
||||||
|
responseListener.current = addNotificationResponseListener((response) => {
|
||||||
|
console.log('Notification clicked:', response);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
notificationListener.current?.remove();
|
||||||
|
responseListener.current?.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="medicine/[id]"
|
||||||
|
options={{
|
||||||
|
title: 'Medicamento',
|
||||||
|
headerTintColor: '#007AFF',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="pharmacy/[id]"
|
||||||
|
options={{
|
||||||
|
title: 'Farmacia',
|
||||||
|
headerTintColor: '#007AFF',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="auth/login"
|
||||||
|
options={{
|
||||||
|
title: 'Iniciar Sesión',
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="auth/register"
|
||||||
|
options={{
|
||||||
|
title: 'Registrarse',
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Alert
|
||||||
|
} from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { colors, spacing, borderRadius } from '../../constants/theme';
|
||||||
|
import {
|
||||||
|
isBiometricsAvailable,
|
||||||
|
authenticateWithBiometrics,
|
||||||
|
saveBiometricCredentials,
|
||||||
|
getBiometricUsername
|
||||||
|
} from '../../services/biometrics';
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { login } = useAuthStore();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
|
||||||
|
const [biometricUsername, setBiometricUsername] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkBiometrics();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkBiometrics = async () => {
|
||||||
|
try {
|
||||||
|
const available = await isBiometricsAvailable();
|
||||||
|
setBiometricsAvailable(available);
|
||||||
|
if (available) {
|
||||||
|
const savedUsername = await getBiometricUsername();
|
||||||
|
setBiometricUsername(savedUsername);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking biometrics:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!username || !password) {
|
||||||
|
Alert.alert('Error', 'Por favor ingresa usuario y contraseña');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
// Save username for biometric login
|
||||||
|
if (biometricsAvailable) {
|
||||||
|
await saveBiometricCredentials(username);
|
||||||
|
}
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Error', 'Credenciales incorrectas');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBiometricLogin = async () => {
|
||||||
|
if (!biometricUsername) {
|
||||||
|
Alert.alert('Error', 'No hay credenciales biométricas guardadas');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const authenticated = await authenticateWithBiometrics();
|
||||||
|
if (authenticated) {
|
||||||
|
// Use saved username with empty password for biometric login
|
||||||
|
// The backend should handle biometric authentication differently
|
||||||
|
await login(biometricUsername, '');
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
} else {
|
||||||
|
Alert.alert('Error', 'Autenticación biométrica fallida');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Error', 'Error en la autenticación biométrica');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<View style={styles.form}>
|
||||||
|
<Text style={styles.title}>Iniciar Sesión</Text>
|
||||||
|
<Text style={styles.subtitle}>Ingresa tus credenciales para continuar</Text>
|
||||||
|
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.label}>Usuario</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={username}
|
||||||
|
onChangeText={setUsername}
|
||||||
|
placeholder="Tu usuario"
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.label}>Contraseña</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder="Tu contraseña"
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||||
|
onPress={handleLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>
|
||||||
|
{isLoading ? 'Ingresando...' : 'Iniciar Sesión'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{biometricsAvailable && biometricUsername && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.biometricButton, isLoading && styles.buttonDisabled]}
|
||||||
|
onPress={handleBiometricLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Ionicons name="finger-print" size={24} color={colors.primary} />
|
||||||
|
<Text style={styles.biometricText}>Iniciar con biometría</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.linkButton}
|
||||||
|
onPress={() => router.push('/auth/register')}
|
||||||
|
>
|
||||||
|
<Text style={styles.linkText}>¿No tienes cuenta? Regístrate</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
inputContainer: {
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: colors.textInverse,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
linkButton: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
biometricButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
biometricText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginLeft: spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Alert
|
||||||
|
} from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { register } from '../../services/auth';
|
||||||
|
import { colors, spacing, borderRadius } from '../../constants/theme';
|
||||||
|
|
||||||
|
export default function RegisterScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!username || !password || !confirmPassword) {
|
||||||
|
Alert.alert('Error', 'Por favor completa todos los campos');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
Alert.alert('Error', 'Las contraseñas no coinciden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await register(username, password);
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Error', 'No se pudo crear la cuenta');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<View style={styles.form}>
|
||||||
|
<Text style={styles.title}>Crear Cuenta</Text>
|
||||||
|
<Text style={styles.subtitle}>Regístrate para empezar</Text>
|
||||||
|
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.label}>Usuario</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={username}
|
||||||
|
onChangeText={setUsername}
|
||||||
|
placeholder="Elige un usuario"
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.label}>Contraseña</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder="Elige una contraseña"
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.label}>Confirmar Contraseña</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChangeText={setConfirmPassword}
|
||||||
|
placeholder="Repite la contraseña"
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||||
|
onPress={handleRegister}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>
|
||||||
|
{isLoading ? 'Creando cuenta...' : 'Crear Cuenta'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.linkButton}
|
||||||
|
onPress={() => router.back()}
|
||||||
|
>
|
||||||
|
<Text style={styles.linkText}>¿Ya tienes cuenta? Inicia sesión</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
inputContainer: {
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: colors.textInverse,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
linkButton: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { View, Text, ScrollView, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { getMedicine, getMedicinePharmacies } from '../../services/medicines';
|
||||||
|
import { StockBadge } from '../../components/StockBadge';
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
||||||
|
import { colors, spacing, borderRadius } from '../../constants/theme';
|
||||||
|
import { Medicine, PharmacyMedicine } from '../../types';
|
||||||
|
|
||||||
|
export default function MedicineDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const [medicine, setMedicine] = useState<Medicine | null>(null);
|
||||||
|
const [pharmacies, setPharmacies] = useState<PharmacyMedicine[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const fetchMedicine = async () => {
|
||||||
|
try {
|
||||||
|
const [medData, pharmData] = await Promise.all([
|
||||||
|
getMedicine(id),
|
||||||
|
getMedicinePharmacies(id),
|
||||||
|
]);
|
||||||
|
setMedicine(medData);
|
||||||
|
setPharmacies(pharmData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching medicine:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMedicine();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSpinner message="Cargando medicamento..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!medicine) {
|
||||||
|
return (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>Medicamento no encontrado</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.name}>{medicine.nombre}</Text>
|
||||||
|
<StockBadge stock={medicine.stock} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.infoSection}>
|
||||||
|
<InfoRow label="Principio activo" value={medicine.principioActivo} />
|
||||||
|
<InfoRow label="Laboratorio" value={medicine.laboratorio} />
|
||||||
|
<InfoRow label="Forma farmacéutica" value={medicine.formaFarmaceutica} />
|
||||||
|
<InfoRow
|
||||||
|
label="Precio"
|
||||||
|
value={medicine.precio ? `${medicine.precio.toFixed(2)} €` : 'No disponible'}
|
||||||
|
/>
|
||||||
|
<InfoRow label="Registro" value={medicine.nregistro} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.pharmaciesSection}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
Farmacias ({pharmacies.length})
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{pharmacies.length === 0 ? (
|
||||||
|
<Text style={styles.noPharmacies}>No hay farmacias con este medicamento</Text>
|
||||||
|
) : (
|
||||||
|
pharmacies.map((pharm) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={pharm.id}
|
||||||
|
style={styles.pharmacyCard}
|
||||||
|
onPress={() => router.push(`/pharmacy/${pharm.pharmacy_id}`)}
|
||||||
|
>
|
||||||
|
<View style={styles.pharmacyInfo}>
|
||||||
|
<Text style={styles.pharmacyName}>{pharm.pharmacy?.name}</Text>
|
||||||
|
<Text style={styles.pharmacyAddress}>{pharm.pharmacy?.address}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.pharmacyStock}>
|
||||||
|
<Text style={styles.price}>{pharm.price.toFixed(2)} €</Text>
|
||||||
|
<Text style={styles.stock}>Stock: {pharm.stock}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>{label}</Text>
|
||||||
|
<Text style={styles.infoValue}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
padding: spacing.md,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text,
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
|
infoSection: {
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
pharmaciesSection: {
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
noPharmacies: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
pharmacyCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
pharmacyInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
pharmacyName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
pharmacyAddress: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
pharmacyStock: {
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
stock: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Linking } from 'react-native';
|
||||||
|
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 { LoadingSpinner } from '../../components/LoadingSpinner';
|
||||||
|
import { colors, spacing, borderRadius } from '../../constants/theme';
|
||||||
|
import { Pharmacy, PharmacyMedicine } from '../../types';
|
||||||
|
|
||||||
|
export default function PharmacyDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const [pharmacy, setPharmacy] = useState<Pharmacy | null>(null);
|
||||||
|
const [medicines, setMedicines] = useState<PharmacyMedicine[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const fetchPharmacy = async () => {
|
||||||
|
try {
|
||||||
|
const [pharmData, medData] = await Promise.all([
|
||||||
|
getPharmacy(Number(id)),
|
||||||
|
getPharmacyMedicines(Number(id)),
|
||||||
|
]);
|
||||||
|
setPharmacy(pharmData);
|
||||||
|
setMedicines(medData as PharmacyMedicine[]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching pharmacy:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPharmacy();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleCall = () => {
|
||||||
|
if (pharmacy?.phone) {
|
||||||
|
Linking.openURL(`tel:${pharmacy.phone}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDirections = () => {
|
||||||
|
if (pharmacy) {
|
||||||
|
const url = `https://www.google.com/maps/dir/?api=1&destination=${pharmacy.latitude},${pharmacy.longitude}`;
|
||||||
|
Linking.openURL(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSpinner message="Cargando farmacia..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pharmacy) {
|
||||||
|
return (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>Farmacia no encontrada</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.name}>{pharmacy.name}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actionsRow}>
|
||||||
|
<TouchableOpacity style={styles.actionButton} onPress={handleCall}>
|
||||||
|
<Ionicons name="call" size={20} color={colors.primary} />
|
||||||
|
<Text style={styles.actionText}>Llamar</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.actionButton} onPress={handleDirections}>
|
||||||
|
<Ionicons name="navigate" size={20} color={colors.primary} />
|
||||||
|
<Text style={styles.actionText}>Cómo llegar</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.infoSection}>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Ionicons name="location" size={18} color={colors.textSecondary} />
|
||||||
|
<Text style={styles.infoText}>{pharmacy.address}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{pharmacy.phone && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Ionicons name="call" size={18} color={colors.textSecondary} />
|
||||||
|
<Text style={styles.infoText}>{pharmacy.phone}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.mapContainer}>
|
||||||
|
<MapView
|
||||||
|
style={styles.map}
|
||||||
|
initialRegion={{
|
||||||
|
latitude: pharmacy.latitude,
|
||||||
|
longitude: pharmacy.longitude,
|
||||||
|
latitudeDelta: 0.01,
|
||||||
|
longitudeDelta: 0.01,
|
||||||
|
}}
|
||||||
|
scrollEnabled={false}
|
||||||
|
>
|
||||||
|
<Marker
|
||||||
|
coordinate={{
|
||||||
|
latitude: pharmacy.latitude,
|
||||||
|
longitude: pharmacy.longitude,
|
||||||
|
}}
|
||||||
|
title={pharmacy.name}
|
||||||
|
/>
|
||||||
|
</MapView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.medicinesSection}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
Medicamentos ({medicines.length})
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{medicines.length === 0 ? (
|
||||||
|
<Text style={styles.noMedicines}>No hay medicamentos disponibles</Text>
|
||||||
|
) : (
|
||||||
|
medicines.map((med) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={med.id}
|
||||||
|
style={styles.medicineCard}
|
||||||
|
onPress={() => router.push(`/medicine/${med.medicine_nregistro}`)}
|
||||||
|
>
|
||||||
|
<View style={styles.medicineInfo}>
|
||||||
|
<Text style={styles.medicineName}>{med.medicine_name}</Text>
|
||||||
|
<Text style={styles.medicineNregistro}>Reg: {med.medicine_nregistro}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.medicineStock}>
|
||||||
|
<Text style={styles.price}>{med.price.toFixed(2)} €</Text>
|
||||||
|
<Text style={styles.stock}>Stock: {med.stock}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
padding: spacing.md,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
actionsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
padding: spacing.md,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.sm,
|
||||||
|
},
|
||||||
|
actionText: {
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
infoSection: {
|
||||||
|
padding: spacing.md,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
infoText: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacing.sm,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
mapContainer: {
|
||||||
|
height: 200,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
medicinesSection: {
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
noMedicines: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
medicineCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
medicineInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
medicineName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
medicineNregistro: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
medicineStock: {
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
stock: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, StyleSheet } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { BarcodeScanner } from '../components/BarcodeScanner';
|
||||||
|
import { searchMedicines } from '../services/medicines';
|
||||||
|
|
||||||
|
export default function ScannerScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
|
const handleBarcodeScanned = async (barcode: string) => {
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const results = await searchMedicines(barcode);
|
||||||
|
if (results.length > 0) {
|
||||||
|
router.push(`/medicine/${results[0].nregistro}`);
|
||||||
|
} else {
|
||||||
|
router.push(`/medicine/${barcode}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching medicine:', error);
|
||||||
|
router.push(`/medicine/${barcode}`);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<BarcodeScanner
|
||||||
|
onBarcodeScanned={handleBarcodeScanned}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 384 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = function(api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
plugins: ['react-native-reanimated/plugin'],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { colors, spacing, borderRadius } from '../constants/theme';
|
||||||
|
|
||||||
|
interface BarcodeScannerProps {
|
||||||
|
onBarcodeScanned: (barcode: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProps) {
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
const [scanned, setScanned] = useState(false);
|
||||||
|
|
||||||
|
if (!permission) {
|
||||||
|
return <View style={styles.container} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permission.granted) {
|
||||||
|
return (
|
||||||
|
<View style={styles.permissionContainer}>
|
||||||
|
<Ionicons name="camera" size={64} color={colors.textSecondary} />
|
||||||
|
<Text style={styles.permissionTitle}>Permiso de cámara requerido</Text>
|
||||||
|
<Text style={styles.permissionText}>
|
||||||
|
Necesitamos acceso a la cámara para escanear códigos de barras
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
|
||||||
|
<Text style={styles.permissionButtonText}>Conceder permiso</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.cancelButton} onPress={onClose}>
|
||||||
|
<Text style={styles.cancelButtonText}>Cancelar</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => {
|
||||||
|
if (scanned) return;
|
||||||
|
setScanned(true);
|
||||||
|
onBarcodeScanned(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<CameraView
|
||||||
|
style={StyleSheet.absoluteFillObject}
|
||||||
|
facing="back"
|
||||||
|
barcodeScannerSettings={{
|
||||||
|
barcodeTypes: ['ean13', 'ean8', 'upc_a', 'upc_e'],
|
||||||
|
}}
|
||||||
|
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<View style={styles.scannerFrame}>
|
||||||
|
<View style={[styles.corner, styles.topLeft]} />
|
||||||
|
<View style={[styles.corner, styles.topRight]} />
|
||||||
|
<View style={[styles.corner, styles.bottomLeft]} />
|
||||||
|
<View style={[styles.corner, styles.bottomRight]} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.instruction}>
|
||||||
|
Apunta la cámara al código de barras del medicamento
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||||
|
<Ionicons name="close" size={24} color={colors.textInverse} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{scanned && (
|
||||||
|
<View style={styles.scannedOverlay}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.scanAgainButton}
|
||||||
|
onPress={() => setScanned(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.scanAgainText}>Escanear de nuevo</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
permissionContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
permissionTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text,
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
},
|
||||||
|
permissionText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
permissionButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
},
|
||||||
|
permissionButtonText: {
|
||||||
|
color: colors.textInverse,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
scannerFrame: {
|
||||||
|
width: 250,
|
||||||
|
height: 250,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
corner: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
topLeft: {
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
borderTopWidth: 3,
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
},
|
||||||
|
topRight: {
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
borderTopWidth: 3,
|
||||||
|
borderRightWidth: 3,
|
||||||
|
},
|
||||||
|
bottomLeft: {
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
borderBottomWidth: 3,
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
},
|
||||||
|
bottomRight: {
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
borderBottomWidth: 3,
|
||||||
|
borderRightWidth: 3,
|
||||||
|
},
|
||||||
|
instruction: {
|
||||||
|
color: colors.textInverse,
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: spacing.xl,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: spacing.xl,
|
||||||
|
right: spacing.xl,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: spacing.sm,
|
||||||
|
},
|
||||||
|
scannedOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: spacing.xxl,
|
||||||
|
left: spacing.xl,
|
||||||
|
right: spacing.xl,
|
||||||
|
},
|
||||||
|
scanAgainButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
scanAgainText: {
|
||||||
|
color: colors.textInverse,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, ActivityIndicator, Text, StyleSheet } from 'react-native';
|
||||||
|
import { colors, spacing } from '../constants/theme';
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingSpinner({ message = 'Cargando...' }: LoadingSpinnerProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
<Text style={styles.message}>{message}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { colors, spacing, borderRadius } from '../constants/theme';
|
||||||
|
import { StockBadge } from './StockBadge';
|
||||||
|
import { Medicine } from '../types';
|
||||||
|
|
||||||
|
interface MedicineCardProps {
|
||||||
|
medicine: Medicine;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MedicineCard({ medicine }: MedicineCardProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
router.push(`/medicine/${medicine.nregistro}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.card} onPress={handlePress} activeOpacity={0.7}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.name} numberOfLines={2}>
|
||||||
|
{medicine.nombre}
|
||||||
|
</Text>
|
||||||
|
<StockBadge stock={medicine.stock} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.principioActivo} numberOfLines={1}>
|
||||||
|
{medicine.principioActivo}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<View style={styles.priceContainer}>
|
||||||
|
<Ionicons name="pricetag" size={14} color={colors.textSecondary} />
|
||||||
|
<Text style={styles.price}>
|
||||||
|
{medicine.precio ? `${medicine.precio.toFixed(2)} €` : 'Sin precio'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.labContainer}>
|
||||||
|
<Ionicons name="business" size={14} color={colors.textSecondary} />
|
||||||
|
<Text style={styles.laboratorio} numberOfLines={1}>
|
||||||
|
{medicine.laboratorio}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
marginHorizontal: spacing.md,
|
||||||
|
marginVertical: spacing.xs,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text,
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
|
principioActivo: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
priceContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
},
|
||||||
|
labContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
laboratorio: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
maxWidth: 120,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, TextInput, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { colors, spacing, borderRadius } from '../constants/theme';
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
placeholder?: string;
|
||||||
|
onSearch: (query: string) => void;
|
||||||
|
value?: string;
|
||||||
|
onChangeText?: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({
|
||||||
|
placeholder = 'Buscar medicamentos...',
|
||||||
|
onSearch,
|
||||||
|
value,
|
||||||
|
onChangeText
|
||||||
|
}: SearchBarProps) {
|
||||||
|
const [localValue, setLocalValue] = useState(value || '');
|
||||||
|
|
||||||
|
const handleChange = (text: string) => {
|
||||||
|
setLocalValue(text);
|
||||||
|
onChangeText?.(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
onSearch(localValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setLocalValue('');
|
||||||
|
onChangeText?.('');
|
||||||
|
onSearch('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Ionicons name="search" size={20} color={colors.textSecondary} style={styles.icon} />
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
value={localValue}
|
||||||
|
onChangeText={handleChange}
|
||||||
|
onSubmitEditing={handleSubmit}
|
||||||
|
returnKeyType="search"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
{localValue.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={handleClear} style={styles.clearButton}>
|
||||||
|
<Ionicons name="close-circle" size={20} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
marginHorizontal: spacing.md,
|
||||||
|
marginVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
},
|
||||||
|
clearButton: {
|
||||||
|
marginLeft: spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { colors, borderRadius, spacing } from '../constants/theme';
|
||||||
|
|
||||||
|
interface StockBadgeProps {
|
||||||
|
stock: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StockBadge({ stock }: StockBadgeProps) {
|
||||||
|
const getBadgeStyle = () => {
|
||||||
|
if (stock === 0) return styles.danger;
|
||||||
|
if (stock < 5) return styles.warning;
|
||||||
|
return styles.success;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getText = () => {
|
||||||
|
if (stock === 0) return 'Sin stock';
|
||||||
|
if (stock < 5) return `Bajo (${stock})`;
|
||||||
|
return `Disponible (${stock})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.badge, getBadgeStyle()]}>
|
||||||
|
<Text style={styles.text}>{getText()}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
badge: {
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
backgroundColor: '#D4EDDA',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
backgroundColor: '#FFF3CD',
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
backgroundColor: '#F8D7DA',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Constants from 'expo-constants';
|
||||||
|
|
||||||
|
const ENV = {
|
||||||
|
development: {
|
||||||
|
API_BASE_URL: 'http://localhost:3001/api',
|
||||||
|
},
|
||||||
|
production: {
|
||||||
|
API_BASE_URL: 'https://your-production-api.com/api',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const environment = Constants.expoConfig?.extra?.environment || (__DEV__ ? 'development' : 'production');
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
API_BASE_URL: ENV[environment as keyof typeof ENV]?.API_BASE_URL || ENV.development.API_BASE_URL,
|
||||||
|
SEARCH_DEBOUNCE_MS: 300,
|
||||||
|
CACHE_STALE_TIME: 5 * 60 * 1000, // 5 minutes
|
||||||
|
CACHE_CACHE_TIME: 30 * 60 * 1000, // 30 minutes
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
export const colors = {
|
||||||
|
// Primary
|
||||||
|
primary: '#007AFF',
|
||||||
|
primaryLight: '#4DA3FF',
|
||||||
|
primaryDark: '#0056CC',
|
||||||
|
|
||||||
|
// Semantic
|
||||||
|
success: '#34C759',
|
||||||
|
danger: '#FF3B30',
|
||||||
|
warning: '#FF9500',
|
||||||
|
|
||||||
|
// Neutral
|
||||||
|
background: '#F2F2F7',
|
||||||
|
card: '#FFFFFF',
|
||||||
|
border: '#E5E5EA',
|
||||||
|
separator: '#C6C6C8',
|
||||||
|
|
||||||
|
// Text
|
||||||
|
text: '#1C1C1E',
|
||||||
|
textSecondary: '#8E8E93',
|
||||||
|
textInverse: '#FFFFFF',
|
||||||
|
textLink: '#007AFF',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const spacing = {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 16,
|
||||||
|
lg: 24,
|
||||||
|
xl: 32,
|
||||||
|
xxl: 48,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const borderRadius = {
|
||||||
|
sm: 8,
|
||||||
|
md: 12,
|
||||||
|
lg: 16,
|
||||||
|
xl: 24,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const typography = {
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold' as const,
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'normal' as const,
|
||||||
|
},
|
||||||
|
caption: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'normal' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal",
|
||||||
|
"ios": {
|
||||||
|
"simulator": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"ios": {
|
||||||
|
"buildType": "preview"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"buildType": "release"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"buildType": "app-bundle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"appleId": "",
|
||||||
|
"ascAppId": "",
|
||||||
|
"appleTeamId": ""
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"serviceAccountKeyPath": "./google-service-account.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const { user, isLoading, isAuthenticated, login, logout, checkAuth } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
isAdmin: user?.is_admin ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedValue(value);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerRootComponent } from 'expo';
|
||||||
|
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||||
|
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||||
|
// the environment is set up appropriately
|
||||||
|
registerRootComponent(App);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend-mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@tanstack/react-query": "^5.101.2",
|
||||||
|
"axios": "^1.18.1",
|
||||||
|
"babel-preset-expo": "^57.0.1",
|
||||||
|
"expo": "~57.0.2",
|
||||||
|
"expo-camera": "~57.0.0",
|
||||||
|
"expo-constants": "~57.0.3",
|
||||||
|
"expo-dev-client": "~57.0.5",
|
||||||
|
"expo-device": "~7.0.2",
|
||||||
|
"expo-linking": "~57.0.1",
|
||||||
|
"expo-local-authentication": "~57.0.0",
|
||||||
|
"expo-notifications": "~57.0.3",
|
||||||
|
"expo-router": "~57.0.3",
|
||||||
|
"expo-secure-store": "~57.0.0",
|
||||||
|
"expo-status-bar": "~57.0.0",
|
||||||
|
"react": "19.2.7",
|
||||||
|
"react-native": "0.86.0",
|
||||||
|
"react-native-gesture-handler": "~2.32.0",
|
||||||
|
"react-native-maps": "^1.29.0",
|
||||||
|
"react-native-reanimated": "4.5.0",
|
||||||
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
|
"react-native-screens": "4.25.2",
|
||||||
|
"react-native-worklets": "0.10.0",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "~19.2.7",
|
||||||
|
"typescript": "~6.0.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { config } from '../constants/config';
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: config.API_BASE_URL,
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor - add auth token
|
||||||
|
api.interceptors.request.use(
|
||||||
|
async (axiosConfig) => {
|
||||||
|
const token = await SecureStore.getItemAsync('auth_token');
|
||||||
|
if (token) {
|
||||||
|
axiosConfig.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return axiosConfig;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor - handle errors
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
await SecureStore.deleteItemAsync('auth_token');
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import api from './api';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { AuthResponse, User } from '../types';
|
||||||
|
|
||||||
|
export async function login(username: string, password: string): Promise<AuthResponse> {
|
||||||
|
const response = await api.post('/auth/login', { username, password });
|
||||||
|
const { user, token } = response.data;
|
||||||
|
|
||||||
|
await SecureStore.setItemAsync('auth_token', token);
|
||||||
|
await SecureStore.setItemAsync('user', JSON.stringify(user));
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await api.post('/auth/logout');
|
||||||
|
await SecureStore.deleteItemAsync('auth_token');
|
||||||
|
await SecureStore.deleteItemAsync('user');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkAuth(): Promise<User | null> {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/auth/check');
|
||||||
|
return response.data.user;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStoredUser(): Promise<User | null> {
|
||||||
|
const userStr = await SecureStore.getItemAsync('user');
|
||||||
|
return userStr ? JSON.parse(userStr) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(username: string, password: string): Promise<AuthResponse> {
|
||||||
|
const response = await api.post('/auth/register', { username, password });
|
||||||
|
const { user, token } = response.data;
|
||||||
|
|
||||||
|
await SecureStore.setItemAsync('auth_token', token);
|
||||||
|
await SecureStore.setItemAsync('user', JSON.stringify(user));
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as LocalAuthentication from 'expo-local-authentication';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
|
export async function isBiometricsAvailable(): Promise<boolean> {
|
||||||
|
const compatible = await LocalAuthentication.hasHardwareAsync();
|
||||||
|
const enrolled = await LocalAuthentication.isEnrolledAsync();
|
||||||
|
return compatible && enrolled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticateWithBiometrics(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await LocalAuthentication.authenticateAsync({
|
||||||
|
promptMessage: 'Inicia sesión con biometría',
|
||||||
|
cancelLabel: 'Cancelar',
|
||||||
|
disableDeviceFallback: false,
|
||||||
|
});
|
||||||
|
return result.success;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Biometric authentication error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBiometricCredentials(username: string): Promise<void> {
|
||||||
|
await SecureStore.setItemAsync('biometric_username', username);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBiometricUsername(): Promise<string | null> {
|
||||||
|
return await SecureStore.getItemAsync('biometric_username');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearBiometricCredentials(): Promise<void> {
|
||||||
|
await SecureStore.deleteItemAsync('biometric_username');
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import api from './api';
|
||||||
|
import { Medicine, PharmacyMedicine } from '../types';
|
||||||
|
|
||||||
|
export async function searchMedicines(query: string): Promise<Medicine[]> {
|
||||||
|
const response = await api.get(`/medicines/search?q=${encodeURIComponent(query)}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMedicine(nregistro: string): Promise<Medicine> {
|
||||||
|
const response = await api.get(`/medicines/${nregistro}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMedicinePharmacies(nregistro: string): Promise<PharmacyMedicine[]> {
|
||||||
|
const response = await api.get(`/medicines/${nregistro}/pharmacies`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
import * as Device from 'expo-device';
|
||||||
|
import * as Constants from 'expo-constants';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function registerForPushNotifications() {
|
||||||
|
if (!Device.isDevice) {
|
||||||
|
console.log('Push notifications require a physical device');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||||||
|
let finalStatus = existingStatus;
|
||||||
|
|
||||||
|
if (existingStatus !== 'granted') {
|
||||||
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
|
finalStatus = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalStatus !== 'granted') {
|
||||||
|
console.log('Failed to get push token for push notification');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
await Notifications.setNotificationChannelAsync('default', {
|
||||||
|
name: 'default',
|
||||||
|
importance: Notifications.AndroidImportance.MAX,
|
||||||
|
vibrationPattern: [0, 250, 250, 250],
|
||||||
|
lightColor: '#007AFF',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectId =
|
||||||
|
Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId;
|
||||||
|
|
||||||
|
const token = await Notifications.getExpoPushTokenAsync({
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return token.data;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error getting push token:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scheduleMedicineAvailabilityNotification(
|
||||||
|
medicineName: string,
|
||||||
|
pharmacyName: string
|
||||||
|
) {
|
||||||
|
await Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: 'Medicamento disponible',
|
||||||
|
body: `${medicineName} está disponible en ${pharmacyName}`,
|
||||||
|
data: { type: 'medicine_availability' },
|
||||||
|
},
|
||||||
|
trigger: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addNotificationListener(
|
||||||
|
handler: (notification: Notifications.Notification) => void
|
||||||
|
): Notifications.EventSubscription {
|
||||||
|
return Notifications.addNotificationReceivedListener(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addNotificationResponseListener(
|
||||||
|
handler: (response: Notifications.NotificationResponse) => void
|
||||||
|
): Notifications.EventSubscription {
|
||||||
|
return Notifications.addNotificationResponseReceivedListener(handler);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import api from './api';
|
||||||
|
import { Pharmacy } from '../types';
|
||||||
|
|
||||||
|
export async function getPharmacies(): Promise<Pharmacy[]> {
|
||||||
|
const response = await api.get('/pharmacies');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPharmacy(id: number): Promise<Pharmacy> {
|
||||||
|
const response = await api.get(`/pharmacies/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPharmacyMedicines(id: number): Promise<any[]> {
|
||||||
|
const response = await api.get(`/pharmacies/${id}/medicines`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { User } from '../types';
|
||||||
|
import * as authService from '../services/auth';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
checkAuth: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
login: async (username: string, password: string) => {
|
||||||
|
const { user } = await authService.login(username, password);
|
||||||
|
set({ user, isAuthenticated: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
await authService.logout();
|
||||||
|
set({ user: null, isAuthenticated: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
checkAuth: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
const user = await authService.checkAuth() || await authService.getStoredUser();
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isLoading: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { Medicine } from '../types';
|
||||||
|
|
||||||
|
interface SearchState {
|
||||||
|
query: string;
|
||||||
|
results: Medicine[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
setQuery: (query: string) => void;
|
||||||
|
setResults: (results: Medicine[]) => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
clearResults: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSearchStore = create<SearchState>((set) => ({
|
||||||
|
query: '',
|
||||||
|
results: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
setQuery: (query) => set({ query }),
|
||||||
|
setResults: (results) => set({ results, isLoading: false }),
|
||||||
|
setLoading: (isLoading) => set({ isLoading }),
|
||||||
|
setError: (error) => set({ error, isLoading: false }),
|
||||||
|
clearResults: () => set({ results: [], query: '', error: null }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export interface Medicine {
|
||||||
|
nregistro: string;
|
||||||
|
nombre: string;
|
||||||
|
principioActivo: string;
|
||||||
|
laboratorio: string;
|
||||||
|
formaFarmaceutica: string;
|
||||||
|
precio: number | null;
|
||||||
|
stock: number;
|
||||||
|
disponibilidad: 'disponible' | 'sin_stock' | 'bajo_stock';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pharmacy {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
phone: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PharmacyMedicine {
|
||||||
|
id: number;
|
||||||
|
pharmacy_id: number;
|
||||||
|
medicine_nregistro: string;
|
||||||
|
medicine_name: string;
|
||||||
|
price: number;
|
||||||
|
stock: number;
|
||||||
|
pharmacy?: Pharmacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
user: User;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResponse {
|
||||||
|
medicines: Medicine[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
data: T;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#00450d" />
|
<meta name="theme-color" content="#7fbf8f" />
|
||||||
<title>FarmaClic</title>
|
<title>FarmaClic</title>
|
||||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "farma-finder-frontend",
|
"name": "farma-clic-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "farma-finder-frontend",
|
"name": "farma-clic-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor-mlkit/barcode-scanning": "^8.1.0",
|
"@capacitor-mlkit/barcode-scanning": "^8.1.0",
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
"@grafana/faro-transport-otlp-http": "^1.7.0",
|
"@grafana/faro-transport-otlp-http": "^1.7.0",
|
||||||
"@grafana/faro-web-sdk": "^1.7.0",
|
"@grafana/faro-web-sdk": "^1.7.0",
|
||||||
"@grafana/faro-web-tracing": "^1.7.0",
|
"@grafana/faro-web-tracing": "^1.7.0",
|
||||||
"barcode-detector": "^3.2.0",
|
"@zxing/browser": "^0.2.0",
|
||||||
|
"@zxing/library": "^0.22.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -3467,11 +3468,6 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/emscripten": {
|
|
||||||
"version": "1.41.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
|
||||||
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -3696,6 +3692,37 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@zxing/browser": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-+ORhrLva0vm6ck74NDCmvYNW3XLoAG81Mu90qfcssN1PBKJjQadxZGeMCcIk+BdJbD/zEAjjHDXOwEK1QCmRtw==",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@zxing/text-encoding": "^0.9.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@zxing/library": "^0.22.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@zxing/library": {
|
||||||
|
"version": "0.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.22.0.tgz",
|
||||||
|
"integrity": "sha512-BmInervZV7NwaZWX1LW64sZ4Lh4wxXYFZwGmj98ArPOkRXCtO9b8Gog0Xyh82dsYYGOeRxX+aAhLSq+hQ2XLZQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"ts-custom-error": "^3.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 24.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@zxing/text-encoding": "~0.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@zxing/text-encoding": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -3943,14 +3970,6 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/barcode-detector": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==",
|
|
||||||
"dependencies": {
|
|
||||||
"zxing-wasm": "3.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.11",
|
"version": "2.9.11",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
||||||
@@ -7490,17 +7509,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tagged-tag": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/temp-dir": {
|
"node_modules/temp-dir": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||||
@@ -7635,6 +7643,14 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-custom-error": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
@@ -8564,32 +8580,6 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/zxing-wasm": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-5+3V1wPRx4gvbeLH2jB7n2cKrYJ1q4i3QgjnBUtrDPeqxJSi6BdzKJg4y6aF6bgW8zfntnYJyrkqFMevDhL2NA==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/emscripten": "^1.41.5",
|
|
||||||
"type-fest": "^5.7.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/emscripten": ">=1.39.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zxing-wasm/node_modules/type-fest": {
|
|
||||||
"version": "5.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz",
|
|
||||||
"integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==",
|
|
||||||
"dependencies": {
|
|
||||||
"tagged-tag": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
"@grafana/faro-transport-otlp-http": "^1.7.0",
|
"@grafana/faro-transport-otlp-http": "^1.7.0",
|
||||||
"@grafana/faro-web-sdk": "^1.7.0",
|
"@grafana/faro-web-sdk": "^1.7.0",
|
||||||
"@grafana/faro-web-tracing": "^1.7.0",
|
"@grafana/faro-web-tracing": "^1.7.0",
|
||||||
"barcode-detector": "^3.2.0",
|
"@zxing/browser": "^0.2.0",
|
||||||
|
"@zxing/library": "^0.22.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 34 KiB |
@@ -9,21 +9,141 @@
|
|||||||
|
|
||||||
.app-content {
|
.app-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior-y: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile-specific styles */
|
||||||
|
.mobile-view {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop-specific styles */
|
||||||
|
.desktop-view {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 var(--margin-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile-specific view adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-content {
|
.app-logo-wrapper {
|
||||||
padding-bottom: calc(6rem + env(safe-area-inset-bottom));
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card--scan {
|
||||||
|
min-height: 4.25rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card-icon {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeInUp {
|
/* Tablet-specific styles */
|
||||||
from { opacity: 0; transform: translateY(14px); }
|
@media (min-width: 769px) and (max-width: 1024px) {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
.home-card {
|
||||||
|
min-height: 4rem;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
/* Desktop-specific styles */
|
||||||
to { transform: rotate(360deg); }
|
@media (min-width: 1025px) {
|
||||||
|
.app-content {
|
||||||
|
padding: 0 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card {
|
||||||
|
min-height: 3.5rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
font-size: calc(16px + 0.5vmin);
|
||||||
|
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||||
|
color: var(--on-surface);
|
||||||
|
background-color: var(--surface-container-lowest);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch target sizing for mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
a, button {
|
||||||
|
min-height: 4rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card styling improvements */
|
||||||
|
.home-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem;
|
||||||
|
min-height: fit-content;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 1px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fluid typography */
|
||||||
|
.home-desc {
|
||||||
|
font-size: clamp(1rem, 100vw, 1.5rem);
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card-label {
|
||||||
|
font-size: clamp(1rem, 100vw, 1.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global button styles using pastel variables */
|
||||||
|
button,
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
border: none;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--primary-shadow);
|
||||||
|
transition: background-color 160ms ease, transform 120ms ease, box-shadow 160ms ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover,
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus,
|
||||||
|
.btn:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 6px var(--primary-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outline / subtle buttons */
|
||||||
|
.btn--outline {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--on-secondary);
|
||||||
|
border: 1px solid var(--secondary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary {
|
||||||
|
background: var(--secondary);
|
||||||
|
color: var(--on-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--on-surface);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import ProfileView from './views/ProfileView';
|
|||||||
import AdminView from './views/AdminView';
|
import AdminView from './views/AdminView';
|
||||||
import LoginModal from './components/LoginModal';
|
import LoginModal from './components/LoginModal';
|
||||||
import SavedNotifications from './components/SavedNotifications';
|
import SavedNotifications from './components/SavedNotifications';
|
||||||
import TopBar from './components/TopBar';
|
|
||||||
import BottomNav from './components/BottomNav';
|
import BottomNav from './components/BottomNav';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -17,13 +16,33 @@ function App() {
|
|||||||
const [authChecked, setAuthChecked] = useState(false);
|
const [authChecked, setAuthChecked] = useState(false);
|
||||||
const [showLogin, setShowLogin] = useState(false);
|
const [showLogin, setShowLogin] = useState(false);
|
||||||
const [showSaved, setShowSaved] = useState(false);
|
const [showSaved, setShowSaved] = useState(false);
|
||||||
|
const [screenSize, setScreenSize] = useState({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
});
|
||||||
|
|
||||||
|
// Device detection
|
||||||
|
const isMobile = screenSize.width <= 768;
|
||||||
|
const isTablet = screenSize.width > 768 && screenSize.width <= 1024;
|
||||||
|
const isDesktop = screenSize.width > 1024;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Set initial screen size
|
||||||
|
const handleResize = () => {
|
||||||
|
setScreenSize({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
fetch('/api/auth/check')
|
fetch('/api/auth/check')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => { if (data.authenticated) setCurrentUser(data.user); })
|
.then(data => { if (data.authenticated) setCurrentUser(data.user); })
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setAuthChecked(true));
|
.finally(() => setAuthChecked(true));
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function handleLogin(user) {
|
function handleLogin(user) {
|
||||||
@@ -41,6 +60,10 @@ function App() {
|
|||||||
setCurrentUser(prev => ({ ...prev, ...updated }));
|
setCurrentUser(prev => ({ ...prev, ...updated }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAdminClick() {
|
||||||
|
setScreen('admin');
|
||||||
|
}
|
||||||
|
|
||||||
const isLoggedIn = Boolean(currentUser);
|
const isLoggedIn = Boolean(currentUser);
|
||||||
|
|
||||||
function handleNavChange(tab) {
|
function handleNavChange(tab) {
|
||||||
@@ -59,13 +82,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBack() {
|
|
||||||
setScreen('home');
|
|
||||||
}
|
|
||||||
|
|
||||||
let activeView;
|
let activeView;
|
||||||
let topBarTitle = 'FarmaClic';
|
let deviceClass = isMobile ? 'mobile-view' : 'desktop-view';
|
||||||
let showBack = false;
|
|
||||||
|
|
||||||
switch (screen) {
|
switch (screen) {
|
||||||
case 'profile':
|
case 'profile':
|
||||||
@@ -75,13 +93,12 @@ function App() {
|
|||||||
onProfileSaved={handleProfileSaved}
|
onProfileSaved={handleProfileSaved}
|
||||||
onShowSaved={() => setShowSaved(true)}
|
onShowSaved={() => setShowSaved(true)}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
onAdminClick={handleAdminClick}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
showBack = true;
|
|
||||||
break;
|
break;
|
||||||
case 'alerts':
|
case 'alerts':
|
||||||
activeView = <AlertsView />;
|
activeView = <AlertsView />;
|
||||||
showBack = true;
|
|
||||||
break;
|
break;
|
||||||
case 'search':
|
case 'search':
|
||||||
activeView = (
|
activeView = (
|
||||||
@@ -90,7 +107,6 @@ function App() {
|
|||||||
onLoginRequest={() => setShowLogin(true)}
|
onLoginRequest={() => setShowLogin(true)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
showBack = true;
|
|
||||||
break;
|
break;
|
||||||
case 'scan':
|
case 'scan':
|
||||||
activeView = (
|
activeView = (
|
||||||
@@ -101,12 +117,9 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
showBack = false;
|
|
||||||
break;
|
break;
|
||||||
case 'admin':
|
case 'admin':
|
||||||
activeView = <AdminView />;
|
activeView = <AdminView />;
|
||||||
topBarTitle = 'Admin';
|
|
||||||
showBack = true;
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
activeView = (
|
activeView = (
|
||||||
@@ -117,18 +130,11 @@ function App() {
|
|||||||
onSearchClick={() => setScreen('search')}
|
onSearchClick={() => setScreen('search')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
showBack = false;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className={`app ${deviceClass}`}>
|
||||||
<TopBar
|
|
||||||
title={topBarTitle}
|
|
||||||
showBack={showBack}
|
|
||||||
onBack={handleBack}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="app-content">
|
<div className="app-content">
|
||||||
{activeView}
|
{activeView}
|
||||||
</div>
|
</div>
|
||||||
@@ -136,7 +142,7 @@ function App() {
|
|||||||
<BottomNav
|
<BottomNav
|
||||||
activeTab={screen}
|
activeTab={screen}
|
||||||
onChange={handleNavChange}
|
onChange={handleNavChange}
|
||||||
isLoggedIn={isLoggedIn}
|
isLoggedIn={Boolean(currentUser)}
|
||||||
badgeCount={currentUser ? 2 : 0}
|
badgeCount={currentUser ? 2 : 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,123 +1,137 @@
|
|||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
display: none;
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 48rem;
|
||||||
|
z-index: 50;
|
||||||
|
padding: 0.25rem 0.5rem calc(0.5rem + env(safe-area-inset-bottom));
|
||||||
|
background: var(--surface-container-lowest);
|
||||||
|
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
height: 5.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
/* Desktop navigation - full width */
|
||||||
|
@media (min-width: 1025px) {
|
||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
display: flex;
|
max-width: 100%;
|
||||||
justify-content: space-around;
|
padding: 0.25rem 2rem calc(0.5rem + env(safe-area-inset-bottom));
|
||||||
align-items: flex-end;
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 50;
|
|
||||||
padding: 0.25rem 0.5rem calc(0.5rem + env(safe-area-inset-bottom));
|
|
||||||
background: var(--surface-container-lowest);
|
|
||||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
height: 5.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-nav-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.15rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--on-surface-variant);
|
|
||||||
font: inherit;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
transition: color 0.15s;
|
|
||||||
position: relative;
|
|
||||||
min-width: 3.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-nav-item.disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-icon-wrap {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.75rem;
|
|
||||||
height: 2.75rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
color: inherit;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-fab {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 3.5rem;
|
|
||||||
height: 3.5rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
background: var(--tertiary-container);
|
|
||||||
color: var(--on-tertiary);
|
|
||||||
box-shadow: 0 4px 14px rgba(70, 0, 173, 0.35);
|
|
||||||
margin-top: -1.5rem;
|
|
||||||
transition: transform 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-fab:active {
|
|
||||||
transform: scale(0.92);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: -2px;
|
|
||||||
width: 1.125rem;
|
|
||||||
height: 1.125rem;
|
|
||||||
background: var(--error);
|
|
||||||
color: var(--on-error);
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
border: 2px solid var(--surface-container-lowest);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-label {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--on-surface-variant);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-label--active {
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-indicator {
|
|
||||||
width: 0.375rem;
|
|
||||||
height: 0.375rem;
|
|
||||||
background: var(--primary);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-nav-item.active .nav-icon-wrap {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-nav-item.active .nav-icon-wrap svg {
|
|
||||||
stroke-width: 2.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-elevated.active .nav-label {
|
|
||||||
color: var(--tertiary);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom-nav-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
font: inherit;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: color 0.15s;
|
||||||
|
position: relative;
|
||||||
|
min-width: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-item.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
color: inherit;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-fab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
/* Stronger, brighter blue to make scan button easy to find */
|
||||||
|
background: #2b5bb5;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 8px 26px rgba(43, 91, 181, 0.32);
|
||||||
|
margin-top: -1.5rem;
|
||||||
|
transition: transform 0.12s, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-fab:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-fab:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 36px rgba(43, 91, 181, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-fab:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 6px rgba(43,91,181,0.12), 0 8px 26px rgba(43, 91, 181, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -2px;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
background: var(--error);
|
||||||
|
color: var(--on-error);
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 2px solid var(--surface-container-lowest);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label--active {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-indicator {
|
||||||
|
width: 0.375rem;
|
||||||
|
height: 0.375rem;
|
||||||
|
background: var(--primary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-item.active .nav-icon-wrap {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-item.active .nav-icon-wrap svg {
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-elevated.active .nav-label {
|
||||||
|
color: var(--tertiary);
|
||||||
|
}
|
||||||
@@ -123,16 +123,16 @@
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.2rem 0.55rem;
|
padding: 0.2rem 0.55rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(37, 99, 235, 0.08);
|
background: var(--secondary-container, #dbe7ff);
|
||||||
color: var(--primary, #2563eb);
|
color: var(--on-secondary, #06204a);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved-notifications-chip--pharmacy {
|
.saved-notifications-chip--pharmacy {
|
||||||
background: rgba(4, 120, 87, 0.1);
|
background: var(--primary-container, #cfead0);
|
||||||
color: var(--accent, #047857);
|
color: var(--on-primary, #09310a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved-notifications-address {
|
.saved-notifications-address {
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
.top-bar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.top-bar {
|
|
||||||
display: block;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 40;
|
|
||||||
background: var(--surface);
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-inner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: var(--touch-target-min);
|
|
||||||
padding: 0 var(--margin-main);
|
|
||||||
max-width: 48rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-back {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--primary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-back:hover {
|
|
||||||
background: var(--surface-container-high);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-title {
|
|
||||||
font-size: 1.15rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--primary);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-logo-img {
|
|
||||||
height: 2rem;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-spacer {
|
|
||||||
width: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import './TopBar.css';
|
|
||||||
|
|
||||||
function TopBar({ title, showBack, onBack, rightAction }) {
|
|
||||||
return (
|
|
||||||
<header className="top-bar">
|
|
||||||
<div className="topbar-inner">
|
|
||||||
{showBack ? (
|
|
||||||
<button className="topbar-back" onClick={onBack} aria-label="Volver">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M19 12H5" />
|
|
||||||
<polyline points="12 19 5 12 12 5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="topbar-spacer" />
|
|
||||||
)}
|
|
||||||
{showBack ? (
|
|
||||||
<h1 className="topbar-title">{title}</h1>
|
|
||||||
) : (
|
|
||||||
<img src="/logo.png" alt="FarmaClic" className="topbar-logo-img" />
|
|
||||||
)}
|
|
||||||
<div className="topbar-right">{rightAction || <div className="topbar-spacer" />}</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TopBar;
|
|
||||||
@@ -1,35 +1,36 @@
|
|||||||
:root {
|
:root {
|
||||||
--primary: #00450d;
|
/* Pastel, softer palette */
|
||||||
--on-primary: #ffffff;
|
--primary: #7fbf8f; /* soft green */
|
||||||
--primary-container: #1b5e20;
|
--on-primary: #09310a; /* dark accent for contrast */
|
||||||
--on-primary-container: #90d689;
|
--primary-container: #cfead0;
|
||||||
--secondary: #2b5bb5;
|
--on-primary-container: #0d2b12;
|
||||||
--on-secondary: #ffffff;
|
--secondary: #a3b8ff; /* soft blue */
|
||||||
--secondary-container: #759efd;
|
--on-secondary: #06204a;
|
||||||
--on-secondary-container: #00337c;
|
--secondary-container: #dbe7ff;
|
||||||
--tertiary: #4600ad;
|
--on-secondary-container: #0b2a66;
|
||||||
--on-tertiary: #ffffff;
|
--tertiary: #c9b3ff; /* soft lavender */
|
||||||
--tertiary-container: #6100e8;
|
--on-tertiary: #241342;
|
||||||
--on-tertiary-container: #cebbff;
|
--tertiary-container: #efe7ff;
|
||||||
--surface: #f8fafb;
|
--on-tertiary-container: #3b2a5a;
|
||||||
--on-surface: #191c1d;
|
--surface: #fbfbfb;
|
||||||
|
--on-surface: #111417;
|
||||||
--on-surface-variant: #41493e;
|
--on-surface-variant: #41493e;
|
||||||
--surface-container-lowest: #ffffff;
|
--surface-container-lowest: #ffffff;
|
||||||
--surface-container-low: #f2f4f5;
|
--surface-container-low: #f2f4f5;
|
||||||
--surface-container: #eceeef;
|
--surface-container: #eceeef;
|
||||||
--surface-container-high: #e6e8e9;
|
--surface-container-high: #e6e8e9;
|
||||||
--surface-container-highest: #e1e3e4;
|
--surface-container-highest: #e1e3e4;
|
||||||
--error: #ba1a1a;
|
--error: #b91c1c;
|
||||||
--on-error: #ffffff;
|
--on-error: #ffffff;
|
||||||
--error-container: #ffdad6;
|
--error-container: #feecec;
|
||||||
--on-error-container: #93000a;
|
--on-error-container: #610909;
|
||||||
--outline: #717a6d;
|
--outline: #717a6d;
|
||||||
--outline-variant: #c0c9bb;
|
--outline-variant: #c0c9bb;
|
||||||
--on-background: #191c1d;
|
--on-background: #191c1d;
|
||||||
--inverse-surface: #2e3132;
|
--inverse-surface: #2e3132;
|
||||||
--inverse-on-surface: #eff1f2;
|
--inverse-on-surface: #eff1f2;
|
||||||
--inverse-primary: #91d78a;
|
--inverse-primary: #91d78a;
|
||||||
--surface-tint: #2a6b2c;
|
--surface-tint: #8ccb8e;
|
||||||
--surface-dim: #d8dadb;
|
--surface-dim: #d8dadb;
|
||||||
--surface-bright: #f8fafb;
|
--surface-bright: #f8fafb;
|
||||||
--surface-variant: #e1e3e4;
|
--surface-variant: #e1e3e4;
|
||||||
@@ -64,12 +65,12 @@
|
|||||||
--glass-border: var(--outline-variant);
|
--glass-border: var(--outline-variant);
|
||||||
--glass-shadow: var(--shadow-soft);
|
--glass-shadow: var(--shadow-soft);
|
||||||
--accent: var(--primary);
|
--accent: var(--primary);
|
||||||
--accent-warm: #b45309;
|
--accent-warm: #f5a97a;
|
||||||
--primary-hover: #1a6b2e;
|
--primary-hover: #6fb47f; /* slightly darker than primary */
|
||||||
--primary-ring: rgba(0, 69, 13, 0.22);
|
--primary-ring: rgba(127, 191, 143, 0.22);
|
||||||
--primary-shadow: rgba(0, 69, 13, 0.18);
|
--primary-shadow: rgba(127, 191, 143, 0.12);
|
||||||
--primary-light: var(--primary-fixed);
|
--primary-light: #eaf7ec;
|
||||||
--primary-faint: rgba(0, 69, 13, 0.08);
|
--primary-faint: rgba(127, 191, 143, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -108,6 +109,14 @@ body::before {
|
|||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(255, 252, 245, 0.48);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export async function initNativeShell() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { StatusBar, Style } = await import('@capacitor/status-bar');
|
const { StatusBar, Style } = await import('@capacitor/status-bar');
|
||||||
await StatusBar.setBackgroundColor({ color: '#0f766e' }).catch(() => {});
|
await StatusBar.setBackgroundColor({ color: '#27633a' }).catch(() => {});
|
||||||
await StatusBar.setStyle({ style: Style.Light }).catch(() => {});
|
await StatusBar.setStyle({ style: Style.Light }).catch(() => {});
|
||||||
} catch {
|
} catch {
|
||||||
/* plugin not present — fine on web */
|
/* plugin not present — fine on web */
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function pushSupported() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getVapidPublicKey() {
|
export async function getVapidPublicKey() {
|
||||||
const res = await fetch('/api/notifications/vapid-public-key');
|
const res = await fetch('/api/notifications/vapid-public-key', { credentials: 'include' });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const message = res.status === 503
|
const message = res.status === 503
|
||||||
? 'Las notificaciones push no están configuradas en el servidor'
|
? 'Las notificaciones push no están configuradas en el servidor'
|
||||||
@@ -88,6 +88,7 @@ export async function subscribeToPush(medicineNregistro, medicineName, pharmacyI
|
|||||||
const subscription = await getOrCreateSubscription();
|
const subscription = await getOrCreateSubscription();
|
||||||
const res = await fetch('/api/notifications/subscribe', {
|
const res = await fetch('/api/notifications/subscribe', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
medicine_nregistro: medicineNregistro,
|
medicine_nregistro: medicineNregistro,
|
||||||
@@ -115,6 +116,7 @@ export async function unsubscribeFromPush(medicineNregistro, pharmacyId = null)
|
|||||||
if (endpoint) {
|
if (endpoint) {
|
||||||
await fetch('/api/notifications/unsubscribe', {
|
await fetch('/api/notifications/unsubscribe', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
medicine_nregistro: medicineNregistro,
|
medicine_nregistro: medicineNregistro,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { subscribeToPush } from './notifications.js';
|
||||||
|
|
||||||
|
describe('subscribeToPush', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the subscription request with credentials so the session is preserved', async () => {
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => ({ publicKey: 'test-public-key' }) })
|
||||||
|
.mockResolvedValueOnce({ ok: true, text: async () => '' });
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'isSecureContext', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'PushManager', {
|
||||||
|
configurable: true,
|
||||||
|
value: class PushManager {},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'Notification', {
|
||||||
|
configurable: true,
|
||||||
|
value: { permission: 'granted' },
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(navigator, 'serviceWorker', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
ready: Promise.resolve({
|
||||||
|
pushManager: {
|
||||||
|
getSubscription: vi.fn().mockResolvedValue(null),
|
||||||
|
subscribe: vi.fn().mockResolvedValue({ endpoint: 'https://example.test/push' }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await subscribeToPush('123456', 'Paracetamol');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fetchMock.mock.calls[1][1]).toMatchObject({
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,7 +44,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.alert-card--primary { border-color: rgba(0, 69, 13, 0.1); }
|
.alert-card--primary { border-color: rgba(0, 69, 13, 0.1); }
|
||||||
.alert-card--secondary { border-color: rgba(43, 91, 181, 0.1); }
|
|
||||||
.alert-card--tertiary { border-color: rgba(70, 0, 173, 0.1); }
|
.alert-card--tertiary { border-color: rgba(70, 0, 173, 0.1); }
|
||||||
|
|
||||||
.alert-card-top {
|
.alert-card-top {
|
||||||
@@ -68,11 +67,6 @@
|
|||||||
color: var(--on-primary-container);
|
color: var(--on-primary-container);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-icon--secondary {
|
|
||||||
background: var(--secondary-container);
|
|
||||||
color: var(--on-secondary-container);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-icon--tertiary {
|
.alert-icon--tertiary {
|
||||||
background: var(--tertiary-container);
|
background: var(--tertiary-container);
|
||||||
color: var(--on-tertiary);
|
color: var(--on-tertiary);
|
||||||
@@ -106,11 +100,6 @@
|
|||||||
color: var(--on-error-container);
|
color: var(--on-error-container);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-badge--secondary {
|
|
||||||
background: var(--secondary-fixed);
|
|
||||||
color: var(--on-secondary-fixed-variant);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-badge--tertiary {
|
.alert-badge--tertiary {
|
||||||
background: var(--tertiary-fixed);
|
background: var(--tertiary-fixed);
|
||||||
color: var(--on-tertiary-fixed-variant);
|
color: var(--on-tertiary-fixed-variant);
|
||||||
@@ -122,36 +111,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-btn {
|
|
||||||
flex: 1;
|
|
||||||
height: var(--touch-target-min);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-btn:active {
|
|
||||||
transform: scale(0.97);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-btn--primary {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--on-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-btn--secondary {
|
|
||||||
background: var(--secondary);
|
|
||||||
color: var(--on-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-btn--tertiary {
|
|
||||||
background: var(--tertiary-container);
|
|
||||||
color: var(--on-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-delete {
|
.alert-delete {
|
||||||
width: var(--touch-target-min);
|
width: var(--touch-target-min);
|
||||||
height: var(--touch-target-min);
|
height: var(--touch-target-min);
|
||||||
|
|||||||
@@ -1,36 +1,6 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import './AlertsView.css';
|
import './AlertsView.css';
|
||||||
|
|
||||||
const alerts = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
type: 'reminder',
|
|
||||||
title: 'Recordatorio: Comprar Paracetamol',
|
|
||||||
detail: 'Quedan 2 cajas',
|
|
||||||
color: 'primary',
|
|
||||||
icon: 'bell',
|
|
||||||
actions: [{ label: 'Ver Detalles', variant: 'secondary' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
type: 'availability',
|
|
||||||
title: 'Disponibilidad: Aspirina',
|
|
||||||
detail: 'Ya disponible en Farmacia Sol',
|
|
||||||
color: 'secondary',
|
|
||||||
icon: 'store',
|
|
||||||
actions: [{ label: 'Ir a Farmacia', variant: 'primary' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
type: 'dose',
|
|
||||||
title: 'Próxima Toma',
|
|
||||||
detail: '12:00 PM',
|
|
||||||
color: 'tertiary',
|
|
||||||
icon: 'schedule',
|
|
||||||
actions: [{ label: 'Confirmar Toma', variant: 'tertiary' }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
bell: (
|
bell: (
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -42,14 +12,84 @@ const iconMap = {
|
|||||||
<path d="M18.36 9l.6 3H5.04l.6-3h12.72M20 4H4v2h16V4zm0 3H4l-1 5v2h1v6h10v-6h4v6h2v-6h1v-2l-1-5zM6 18v-4h6v4H6z" />
|
<path d="M18.36 9l.6 3H5.04l.6-3h12.72M20 4H4v2h16V4zm0 3H4l-1 5v2h1v6h10v-6h4v6h2v-6h1v-2l-1-5zM6 18v-4h6v4H6z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
schedule: (
|
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function AlertsView({ onBack }) {
|
function AlertsView() {
|
||||||
|
const [availability, setAvailability] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const fetchAvailabilityNotifications = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const notifsRes = await fetch('/api/notifications/mine', { credentials: 'include' });
|
||||||
|
if (notifsRes.status === 401) {
|
||||||
|
setError('Por favor, inicia sesión para ver tus avisos.');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let notifsData = { global: [], pharmacy: [] };
|
||||||
|
if (notifsRes.ok) {
|
||||||
|
notifsData = await notifsRes.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedAvailability = [
|
||||||
|
...(notifsData.global || []).map(item => ({ ...item, scope: 'global' })),
|
||||||
|
...(notifsData.pharmacy || []).map(item => ({ ...item, scope: 'pharmacy' })),
|
||||||
|
].sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
|
||||||
|
|
||||||
|
setAvailability(mergedAvailability);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching availability notifications:', err);
|
||||||
|
setError('No se pudieron cargar los avisos.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAvailabilityNotifications();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteAvailability = async (item) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notifications/mine', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ scope: item.scope, id: item.id }),
|
||||||
|
});
|
||||||
|
if (res.ok || res.status === 204) {
|
||||||
|
setAvailability(prev => prev.filter(i => !(i.scope === item.scope && i.id === item.id)));
|
||||||
|
|
||||||
|
// Also update local storage if possible to reflect that we unsubscribed
|
||||||
|
try {
|
||||||
|
const STORAGE_KEY = 'farmaclic.subscriptions.v1';
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const keyToSearch = item.scope === 'pharmacy'
|
||||||
|
? `${item.medicine_nregistro}:${item.pharmacy_id}`
|
||||||
|
: item.medicine_nregistro;
|
||||||
|
const filtered = parsed.filter(k => k !== keyToSearch);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not update localStorage subscriptions:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Error deleting availability alert');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError('No se pudo eliminar la alerta de disponibilidad.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="alerts-view">
|
<div className="alerts-view">
|
||||||
<main className="alerts-main">
|
<main className="alerts-main">
|
||||||
@@ -58,42 +98,71 @@ function AlertsView({ onBack }) {
|
|||||||
<p className="alerts-subtitle">Mantente al día con tus medicamentos.</p>
|
<p className="alerts-subtitle">Mantente al día con tus medicamentos.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="alerts-list">
|
{loading && <p className="alerts-status" style={{ textAlign: 'center', padding: '2rem' }}>Cargando avisos...</p>}
|
||||||
{alerts.map((alert) => (
|
{!loading && error && <p className="alerts-error" style={{ color: 'var(--error)', textAlign: 'center', padding: '2rem' }}>{error}</p>}
|
||||||
<div key={alert.id} className={`alert-card alert-card--${alert.color}`}>
|
|
||||||
<div className="alert-card-top">
|
{!loading && !error && (
|
||||||
<div className={`alert-icon alert-icon--${alert.color}`}>
|
<>
|
||||||
{iconMap[alert.icon]}
|
{/* Availability Section */}
|
||||||
</div>
|
<section className="alerts-section">
|
||||||
<div className="alert-info">
|
<h3 className="alerts-section-title" style={{ marginBottom: '1rem' }}>Disponibilidad</h3>
|
||||||
<h3 className="alert-title">{alert.title}</h3>
|
<div className="alerts-list">
|
||||||
<span className={`alert-badge alert-badge--${alert.color}`}>
|
{availability.length === 0 ? (
|
||||||
{alert.detail}
|
<p className="alerts-empty" style={{ color: 'var(--on-surface-variant)', fontStyle: 'italic' }}>No tienes suscripciones de disponibilidad.</p>
|
||||||
</span>
|
) : (
|
||||||
</div>
|
availability.map((alert) => {
|
||||||
|
const cardColor = alert.scope === 'pharmacy' ? 'primary' : 'tertiary';
|
||||||
|
const cardIcon = alert.scope === 'pharmacy' ? 'store' : 'bell';
|
||||||
|
const key = `${alert.scope}:${alert.id}`;
|
||||||
|
return (
|
||||||
|
<div key={key} className={`alert-card alert-card--${cardColor}`}>
|
||||||
|
<div className="alert-card-top">
|
||||||
|
<div className={`alert-icon alert-icon--${cardColor}`}>
|
||||||
|
{iconMap[cardIcon]}
|
||||||
|
</div>
|
||||||
|
<div className="alert-info">
|
||||||
|
<h3 className="alert-title">{alert.medicine_name || alert.medicine_nregistro}</h3>
|
||||||
|
{alert.scope === 'pharmacy' ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', marginTop: '0.25rem' }}>
|
||||||
|
<span className={`alert-badge alert-badge--${cardColor}`} style={{ width: 'fit-content' }}>
|
||||||
|
🏥 {alert.pharmacy_name || `Farmacia #${alert.pharmacy_id}`}
|
||||||
|
</span>
|
||||||
|
{alert.pharmacy_address && (
|
||||||
|
<p className="alert-address-text" style={{ fontSize: '0.9rem', color: 'var(--on-surface-variant)', margin: 0 }}>
|
||||||
|
📍 {alert.pharmacy_address}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className={`alert-badge alert-badge--${cardColor}`} style={{ width: 'fit-content', marginTop: '0.25rem' }}>
|
||||||
|
🔔 Notificarme cuando esté disponible
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="alert-actions" style={{ justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
className="alert-delete"
|
||||||
|
aria-label="Eliminar"
|
||||||
|
onClick={() => handleDeleteAvailability(alert)}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="alert-actions">
|
</section>
|
||||||
{alert.actions.map((action, i) => (
|
</>
|
||||||
<button
|
)}
|
||||||
key={i}
|
|
||||||
className={`alert-btn alert-btn--${action.variant}`}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button className="alert-delete" aria-label="Eliminar">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polyline points="3 6 5 6 21 6" />
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AlertsView;
|
export default AlertsView;
|
||||||
@@ -3,13 +3,113 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
overflow: hidden;
|
max-width: 100%;
|
||||||
padding: 1rem var(--margin-main);
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
animation: fadeInUp 0.6s ease-out;
|
animation: fadeInUp 0.6s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile optimizations */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.home-view {
|
||||||
|
padding: 0.75rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-logo-wrapper {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-logo {
|
||||||
|
width: min(12rem, 80vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-desc {
|
||||||
|
font-size: clamp(0.9rem, 5vw, 1.125rem);
|
||||||
|
max-width: 90%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-cards {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card {
|
||||||
|
min-height: 4rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card-icon {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card-label {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet optimizations */
|
||||||
|
@media (min-width: 769px) and (max-width: 1024px) {
|
||||||
|
.home-view {
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card {
|
||||||
|
min-height: 4.25rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card-icon {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop optimizations */
|
||||||
|
@media (min-width: 1025px) {
|
||||||
|
.home-view {
|
||||||
|
padding: 1rem var(--margin-main);
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero {
|
||||||
|
text-align: left;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-logo-wrapper {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-desc {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
max-width: 30rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-cards {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
max-width: 80rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card {
|
||||||
|
min-height: 5rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.home-hero {
|
.home-hero {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -19,7 +119,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-logo-wrapper {
|
.home-logo-wrapper {
|
||||||
display: flex;
|
display: contents;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -33,11 +133,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-brand-name {
|
.home-brand-name {
|
||||||
font-size: 2rem;
|
height: 2rem;
|
||||||
font-weight: 800;
|
width: auto;
|
||||||
color: var(--primary);
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-height: 700px) {
|
@media (max-height: 700px) {
|
||||||
@@ -87,15 +184,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-card--search {
|
.home-card--search {
|
||||||
background: var(--primary);
|
/* Make suggestion/search buttons softer and more harmonious */
|
||||||
color: var(--on-primary);
|
background: var(--primary-container);
|
||||||
box-shadow: 0 4px 16px rgba(0, 69, 13, 0.3);
|
color: var(--on-primary-container);
|
||||||
|
box-shadow: 0 6px 18px rgba(13, 43, 18, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-card--scan {
|
.home-card--scan {
|
||||||
background: var(--secondary);
|
/* Keep the scan button vivid and easy to find (intentionally prominent) */
|
||||||
color: var(--on-secondary);
|
background: #2b5bb5; /* vivid blue to stand out */
|
||||||
box-shadow: 0 4px 16px rgba(43, 91, 181, 0.3);
|
color: #ffffff;
|
||||||
|
box-shadow: 0 8px 28px rgba(43, 91, 181, 0.28);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card--scan:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card--scan:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 6px rgba(43,91,181,0.12), 0 8px 28px rgba(43,91,181,0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-card-icon {
|
.home-card-icon {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ function HomeView({ onScanClick, onSearchClick }) {
|
|||||||
<div className="home-view">
|
<div className="home-view">
|
||||||
<div className="home-hero">
|
<div className="home-hero">
|
||||||
<div className="home-logo-wrapper">
|
<div className="home-logo-wrapper">
|
||||||
<img src="/logo.png" alt="FarmaClic" className="home-logo" />
|
<img src="/farmaclic_logo.png" alt="FarmaClic" className="home-logo" />
|
||||||
<h1 className="home-brand-name">FarmaClic</h1>
|
<img src="/farmaclic_text.png" alt="FarmaClic" className="home-brand-name" />
|
||||||
</div>
|
</div>
|
||||||
<p className="home-desc">Encuentra tus medicamentos en farmacias cercanas</p>
|
<p className="home-desc">Encuentra tus medicamentos en farmacias cercanas</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -300,7 +300,22 @@
|
|||||||
background: rgba(186, 26, 26, 0.12);
|
background: rgba(186, 26, 26, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 400px) {
|
@media (max-width: 480px) {
|
||||||
|
.profile-view {
|
||||||
|
padding: 1rem var(--margin-main) 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-menu-item {
|
||||||
|
height: auto;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
min-height: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-icon {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-field-row {
|
.profile-field-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
@@ -310,4 +325,23 @@
|
|||||||
width: 6rem;
|
width: 6rem;
|
||||||
height: 6rem;
|
height: 6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-logout-btn {
|
||||||
|
height: auto;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
min-height: 4rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import './ProfileView.css';
|
import './ProfileView.css';
|
||||||
import { getUserPosition } from '../utils/geo';
|
import { getUserPosition } from '../utils/geo';
|
||||||
|
|
||||||
function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
|
function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdminClick }) {
|
||||||
const [address, setAddress] = useState(currentUser?.address || '');
|
const [address, setAddress] = useState(currentUser?.address || '');
|
||||||
const [latitude, setLatitude] = useState(
|
const [latitude, setLatitude] = useState(
|
||||||
currentUser?.latitude != null ? String(currentUser.latitude) : ''
|
currentUser?.latitude != null ? String(currentUser.latitude) : ''
|
||||||
@@ -158,6 +158,20 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
|
|||||||
<polyline points="9 18 15 12 9 6" />
|
<polyline points="9 18 15 12 9 6" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{currentUser?.is_admin && (
|
||||||
|
<button className="profile-menu-item" onClick={onAdminClick}>
|
||||||
|
<div className="menu-item-icon menu-item-icon--primary">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="menu-item-label">Panel de Administración</span>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="menu-item-chevron">
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="profile-location-section">
|
<div className="profile-location-section">
|
||||||
|
|||||||
@@ -358,3 +358,87 @@
|
|||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Photo upload section */
|
||||||
|
.upload-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-option {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1.25rem 0.75rem;
|
||||||
|
background: var(--surface-container-lowest);
|
||||||
|
border: 2px solid var(--outline-variant);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--on-surface);
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-option:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--surface-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-option:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-option svg {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Photo preview */
|
||||||
|
.photo-preview-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-preview-container {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
max-height: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-preview-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
max-height: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-preview-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { useState, useCallback, useRef } from 'react';
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
import { BarcodeScanner, BarcodeFormat } from '@capacitor-mlkit/barcode-scanning';
|
import { BarcodeScanner, BarcodeFormat } from '@capacitor-mlkit/barcode-scanning';
|
||||||
import { Capacitor } from '@capacitor/core';
|
import { Capacitor } from '@capacitor/core';
|
||||||
import 'barcode-detector/polyfill';
|
import { BrowserMultiFormatReader } from '@zxing/browser';
|
||||||
|
import { BarcodeFormat as ZxingFormat, DecodeHintType } from '@zxing/library';
|
||||||
import './ScannerView.css';
|
import './ScannerView.css';
|
||||||
|
|
||||||
const CIP_REGEX = /^[A-Z0-9]{16}$/i;
|
const CIP_REGEX = /^[A-Z0-9]{8,30}$/i;
|
||||||
|
|
||||||
function playBeep() {
|
function playBeep() {
|
||||||
try {
|
try {
|
||||||
@@ -29,6 +30,12 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
|||||||
const rafRef = useRef(null);
|
const rafRef = useRef(null);
|
||||||
const detectorRef = useRef(null);
|
const detectorRef = useRef(null);
|
||||||
|
|
||||||
|
// Log de diagnóstico al montar
|
||||||
|
React.useEffect(() => {
|
||||||
|
console.log('[Scanner] BarcodeDetector global:', typeof BarcodeDetector);
|
||||||
|
console.log('[Scanner] Navegador:', navigator.userAgent);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [phase, setPhase] = useState('idle');
|
const [phase, setPhase] = useState('idle');
|
||||||
const [manualCip, setManualCip] = useState('');
|
const [manualCip, setManualCip] = useState('');
|
||||||
const [prescriptions, setPrescriptions] = useState([]);
|
const [prescriptions, setPrescriptions] = useState([]);
|
||||||
@@ -36,6 +43,12 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
|||||||
const [loadingPrescriptions, setLoadingPrescriptions] = useState(false);
|
const [loadingPrescriptions, setLoadingPrescriptions] = useState(false);
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
|
// Photo upload state
|
||||||
|
const [previewUrl, setPreviewUrl] = useState(null);
|
||||||
|
const [ocrLoading, setOcrLoading] = useState(false);
|
||||||
|
const cameraInputRef = useRef(null);
|
||||||
|
const galleryInputRef = useRef(null);
|
||||||
|
|
||||||
const isNative = Capacitor.isNativePlatform();
|
const isNative = Capacitor.isNativePlatform();
|
||||||
|
|
||||||
const fetchPrescriptions = useCallback(async (cip) => {
|
const fetchPrescriptions = useCallback(async (cip) => {
|
||||||
@@ -112,6 +125,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
|||||||
|
|
||||||
async function handleWebScan() {
|
async function handleWebScan() {
|
||||||
setErrorMsg('');
|
setErrorMsg('');
|
||||||
|
console.log('[Scanner] Iniciando escaneo web...');
|
||||||
try {
|
try {
|
||||||
if (!navigator.mediaDevices?.getUserMedia) {
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
setErrorMsg('Cámara no disponible en este navegador.');
|
setErrorMsg('Cámara no disponible en este navegador.');
|
||||||
@@ -119,58 +133,77 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let detector;
|
|
||||||
try {
|
|
||||||
detector = new BarcodeDetector({ formats: ['pdf417', 'code_128', 'qr_code'] });
|
|
||||||
} catch {
|
|
||||||
setErrorMsg('La detección de códigos no está soportada en este navegador.');
|
|
||||||
setPhase('error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
|
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||||
});
|
});
|
||||||
|
console.log('[Scanner] Stream de cámara obtenido');
|
||||||
|
|
||||||
streamRef.current = stream;
|
streamRef.current = stream;
|
||||||
detectorRef.current = detector;
|
|
||||||
setPhase('scanning');
|
setPhase('scanning');
|
||||||
|
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
requestAnimationFrame(() => {
|
const waitForVideo = () => {
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
videoRef.current.srcObject = stream;
|
videoRef.current.srcObject = stream;
|
||||||
videoRef.current.play().then(resolve).catch(resolve);
|
videoRef.current.play().then(() => {
|
||||||
|
console.log('[Scanner] Video reproduciéndose');
|
||||||
|
console.log('[Scanner] Video dims:', videoRef.current.videoWidth, 'x', videoRef.current.videoHeight);
|
||||||
|
resolve();
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error('[Scanner] Error al reproducir video:', e);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
resolve();
|
requestAnimationFrame(waitForVideo);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
requestAnimationFrame(waitForVideo);
|
||||||
});
|
});
|
||||||
|
|
||||||
let found = false;
|
console.log('[Scanner] Usando zxing local (@zxing/browser)...');
|
||||||
async function scanFrame() {
|
const hints = new Map();
|
||||||
if (found || !videoRef.current || !detectorRef.current) return;
|
hints.set(DecodeHintType.POSSIBLE_FORMATS, [
|
||||||
try {
|
ZxingFormat.CODE_128,
|
||||||
const barcodes = await detectorRef.current.detect(videoRef.current);
|
ZxingFormat.CODE_39,
|
||||||
if (barcodes.length > 0) {
|
ZxingFormat.CODE_93,
|
||||||
const rawValue = barcodes[0].rawValue;
|
ZxingFormat.PDF_417,
|
||||||
if (rawValue && CIP_REGEX.test(rawValue)) {
|
ZxingFormat.QR_CODE,
|
||||||
found = true;
|
ZxingFormat.EAN_13,
|
||||||
stopCamera();
|
ZxingFormat.EAN_8,
|
||||||
playBeep();
|
ZxingFormat.UPC_A,
|
||||||
setScannedCip(rawValue);
|
ZxingFormat.UPC_E,
|
||||||
setPhase('prescriptions');
|
ZxingFormat.ITF,
|
||||||
fetchPrescriptions(rawValue);
|
]);
|
||||||
return;
|
hints.set(DecodeHintType.TRY_HARDER, true);
|
||||||
}
|
const zxingReader = new BrowserMultiFormatReader(hints);
|
||||||
|
detectorRef.current = zxingReader;
|
||||||
|
|
||||||
|
const formatNames = { 0:'AZTEC', 1:'CODABAR', 2:'CODE_39', 3:'CODE_93', 4:'CODE_128', 5:'DATA_MATRIX', 6:'EAN_8', 7:'EAN_13', 8:'ITF', 10:'PDF_417', 11:'QR_CODE', 12:'RSS_14', 14:'UPC_A', 15:'UPC_E' };
|
||||||
|
|
||||||
|
console.log('[Scanner] Iniciando decodeFromVideoElement...');
|
||||||
|
const controls = await zxingReader.decodeFromVideoElement(videoRef.current, (result, error) => {
|
||||||
|
if (result) {
|
||||||
|
const rawValue = result.getText();
|
||||||
|
const cleaned = rawValue.replace(/[^A-Z0-9]/gi, '');
|
||||||
|
const format = result.getBarcodeFormat();
|
||||||
|
console.log('[Scanner] zxing detectó! raw:', rawValue, '| limpio:', cleaned, '| formato:', formatNames[format] || format);
|
||||||
|
if (cleaned && CIP_REGEX.test(cleaned)) {
|
||||||
|
console.log('[Scanner] CIP válido ✓');
|
||||||
|
controls.stop();
|
||||||
|
stopCamera();
|
||||||
|
playBeep();
|
||||||
|
setScannedCip(cleaned);
|
||||||
|
setPhase('prescriptions');
|
||||||
|
fetchPrescriptions(cleaned);
|
||||||
|
} else if (rawValue) {
|
||||||
|
console.log('[Scanner] Código no cumple regex:', cleaned);
|
||||||
}
|
}
|
||||||
} catch { }
|
|
||||||
if (!found) {
|
|
||||||
rafRef.current = requestAnimationFrame(scanFrame);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
scanFrame();
|
console.log('[Scanner] Escaneando...');
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('[Scanner] Error general:', err);
|
||||||
stopCamera();
|
stopCamera();
|
||||||
if (err.name === 'NotAllowedError') {
|
if (err.name === 'NotAllowedError') {
|
||||||
setErrorMsg('Permiso de cámara denegado. Permite el acceso e intenta de nuevo.');
|
setErrorMsg('Permiso de cámara denegado. Permite el acceso e intenta de nuevo.');
|
||||||
@@ -210,6 +243,61 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
|||||||
fetchPrescriptions(cip);
|
fetchPrescriptions(cip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Photo upload handlers
|
||||||
|
function handlePhotoSelect(e) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setErrorMsg('');
|
||||||
|
setPreviewUrl(URL.createObjectURL(file));
|
||||||
|
setPhase('photo-preview');
|
||||||
|
// Reset input so selecting the same file again triggers onChange
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePhotoOcr() {
|
||||||
|
if (!previewUrl) return;
|
||||||
|
setOcrLoading(true);
|
||||||
|
setErrorMsg('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(previewUrl);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('photo', blob, 'tsi.jpg');
|
||||||
|
|
||||||
|
const ocrRes = await fetch('/api/tsi/ocr', { method: 'POST', body: formData });
|
||||||
|
const data = await ocrRes.json();
|
||||||
|
|
||||||
|
if (!ocrRes.ok) {
|
||||||
|
setErrorMsg(data.error || 'No se pudo leer la imagen. Intenta con otra foto.');
|
||||||
|
setPhase('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cip = data.cip;
|
||||||
|
if (!CIP_REGEX.test(cip)) {
|
||||||
|
setErrorMsg(`CIP detectado "${cip}" no tiene formato válido. Introduce el código manualmente.`);
|
||||||
|
setPhase('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playBeep();
|
||||||
|
setScannedCip(cip);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setPhase('prescriptions');
|
||||||
|
fetchPrescriptions(cip);
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMsg(`Error al procesar la imagen: ${err.message || 'Error desconocido'}`);
|
||||||
|
setPhase('error');
|
||||||
|
} finally {
|
||||||
|
setOcrLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePhotoDiscard() {
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setPhase('idle');
|
||||||
|
}
|
||||||
|
|
||||||
function handlePickPrescription(rx) {
|
function handlePickPrescription(rx) {
|
||||||
stopCamera();
|
stopCamera();
|
||||||
onSelectMedicine(rx.name);
|
onSelectMedicine(rx.name);
|
||||||
@@ -221,6 +309,9 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
|||||||
setPhase('idle');
|
setPhase('idle');
|
||||||
setPrescriptions([]);
|
setPrescriptions([]);
|
||||||
setScannedCip(null);
|
setScannedCip(null);
|
||||||
|
} else if (phase === 'photo-preview') {
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setPhase('idle');
|
||||||
} else {
|
} else {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
@@ -258,13 +349,51 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'idle' && (
|
{phase === 'idle' && (
|
||||||
<button className="scan-btn scan-btn--primary scan-btn--start" onClick={handleStartScan}>
|
<>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<button className="scan-btn scan-btn--primary scan-btn--start" onClick={handleStartScan}>
|
||||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<circle cx="12" cy="13" r="4" />
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||||
</svg>
|
<circle cx="12" cy="13" r="4" />
|
||||||
Abrir cámara
|
</svg>
|
||||||
</button>
|
Abrir cámara
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="upload-section">
|
||||||
|
<label className="upload-label">O sube una foto de tu TSI</label>
|
||||||
|
<div className="upload-row">
|
||||||
|
<button className="upload-option" onClick={() => cameraInputRef.current?.click()}>
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||||
|
<circle cx="12" cy="13" r="4" />
|
||||||
|
</svg>
|
||||||
|
<span>Hacer foto</span>
|
||||||
|
</button>
|
||||||
|
<button className="upload-option" onClick={() => galleryInputRef.current?.click()}>
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<polyline points="21 15 16 10 5 21" />
|
||||||
|
</svg>
|
||||||
|
<span>Subir de galería</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={cameraInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={handlePhotoSelect}
|
||||||
|
className="upload-hidden-input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={galleryInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handlePhotoSelect}
|
||||||
|
className="upload-hidden-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'scanning' && !isNative && (
|
{phase === 'scanning' && !isNative && (
|
||||||
@@ -287,6 +416,38 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{phase === 'photo-preview' && (
|
||||||
|
<div className="photo-preview-panel">
|
||||||
|
<div className="photo-preview-container">
|
||||||
|
<img src={previewUrl} alt="TSI capturada" className="photo-preview-img" />
|
||||||
|
</div>
|
||||||
|
{ocrLoading ? (
|
||||||
|
<div className="scanning-active">
|
||||||
|
<div className="scanner-spinner" />
|
||||||
|
<p>Procesando imagen…</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="photo-preview-actions">
|
||||||
|
<button className="scan-btn scan-btn--primary scan-btn--start" onClick={handlePhotoOcr}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="4 7 4 4 20 4 20 7" />
|
||||||
|
<line x1="9" y1="20" x2="15" y2="20" />
|
||||||
|
<line x1="12" y1="4" x2="12" y2="20" />
|
||||||
|
</svg>
|
||||||
|
Escanear imagen
|
||||||
|
</button>
|
||||||
|
<button className="scan-btn scan-btn--ghost" onClick={handlePhotoDiscard}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
Descartar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form className="cip-form" onSubmit={handleManualSubmit}>
|
<form className="cip-form" onSubmit={handleManualSubmit}>
|
||||||
<label className="cip-label" htmlFor="cip-input">O introduce el código CIP manualmente</label>
|
<label className="cip-label" htmlFor="cip-input">O introduce el código CIP manualmente</label>
|
||||||
<div className="cip-row">
|
<div className="cip-row">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import SearchBar from '../components/SearchBar';
|
import SearchBar from '../components/SearchBar';
|
||||||
import MedicineResults from '../components/MedicineResults';
|
import MedicineResults from '../components/MedicineResults';
|
||||||
import PharmacyList from '../components/PharmacyList';
|
import PharmacyList from '../components/PharmacyList';
|
||||||
@@ -13,11 +13,6 @@ const suggestions = [
|
|||||||
{ name: 'Omeprazol', icon: 'emergency_home', color: 'tint' },
|
{ name: 'Omeprazol', icon: 'emergency_home', color: 'tint' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const recentResults = [
|
|
||||||
{ name: 'Amoxicilina', detail: '500mg • 24 Cápsulas', tag: 'Antibiótico', distance: '300m - Farmacia Central' },
|
|
||||||
{ name: 'Losartán', detail: '50mg • 30 Comprimidos', tag: 'Hipertensión', distance: '1.2km - Farmacia del Sol' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function SearchView({ currentUser, onLoginRequest }) {
|
function SearchView({ currentUser, onLoginRequest }) {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [medicines, setMedicines] = useState([]);
|
const [medicines, setMedicines] = useState([]);
|
||||||
@@ -29,6 +24,44 @@ function SearchView({ currentUser, onLoginRequest }) {
|
|||||||
const [sortByDistance, setSortByDistance] = useState(false);
|
const [sortByDistance, setSortByDistance] = useState(false);
|
||||||
const [locating, setLocating] = useState(false);
|
const [locating, setLocating] = useState(false);
|
||||||
const [locationError, setLocationError] = useState('');
|
const [locationError, setLocationError] = useState('');
|
||||||
|
const [recentSearches, setRecentSearches] = useState([]);
|
||||||
|
|
||||||
|
// Fetch recent searches when user is logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser) {
|
||||||
|
setRecentSearches([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/api/search/recent', { credentials: 'include' })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setRecentSearches(Array.isArray(data) ? data : []))
|
||||||
|
.catch(() => setRecentSearches([]));
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
const saveToRecent = useCallback((medicine) => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
fetch('/api/search/recent', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ medicine }),
|
||||||
|
}).catch(() => {});
|
||||||
|
// Update local state immediately
|
||||||
|
setRecentSearches((prev) => {
|
||||||
|
const filtered = prev.filter((m) => m.id !== medicine.id);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: medicine.id,
|
||||||
|
name: medicine.name,
|
||||||
|
active_ingredient: medicine.active_ingredient,
|
||||||
|
dosage: medicine.dosage,
|
||||||
|
form: medicine.form,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
...filtered,
|
||||||
|
].slice(0, 10);
|
||||||
|
});
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const searchMedicines = async () => {
|
const searchMedicines = async () => {
|
||||||
@@ -157,43 +190,58 @@ function SearchView({ currentUser, onLoginRequest }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="recent-section">
|
{currentUser && recentSearches.length > 0 && (
|
||||||
<h2 className="section-title">Resultados Recientes</h2>
|
<section className="recent-section">
|
||||||
<div className="recent-list">
|
<h2 className="section-title">Resultados Recientes</h2>
|
||||||
{recentResults.map((r, i) => (
|
<div className="recent-list">
|
||||||
<div key={i} className="recent-card">
|
{recentSearches.map((r) => (
|
||||||
<div className="recent-card-top">
|
<div
|
||||||
<div>
|
key={r.id}
|
||||||
<h3 className="recent-name">{r.name}</h3>
|
className="recent-card"
|
||||||
<p className="recent-detail">{r.detail}</p>
|
onClick={() => setSelectedMedicine(r)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div className="recent-card-top">
|
||||||
|
<div>
|
||||||
|
<h3 className="recent-name">{r.name}</h3>
|
||||||
|
<p className="recent-detail">
|
||||||
|
{r.dosage && `${r.dosage}`}
|
||||||
|
{r.dosage && r.form && ' \u2022 '}
|
||||||
|
{r.form}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{r.active_ingredient && (
|
||||||
|
<span className="recent-tag">{r.active_ingredient}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="recent-tag">{r.tag}</span>
|
<button
|
||||||
|
className="recent-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedMedicine(r);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
Encontrar cerca
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="recent-distance">
|
))}
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
</div>
|
||||||
<circle cx="12" cy="12" r="10" />
|
</section>
|
||||||
<polyline points="12 6 12 12 16 14" />
|
)}
|
||||||
</svg>
|
|
||||||
<span>{r.distance}</span>
|
|
||||||
</div>
|
|
||||||
<button className="recent-btn">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<polyline points="12 6 12 12 16 14" />
|
|
||||||
</svg>
|
|
||||||
Encontrar cerca
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{searchQuery && !selectedMedicine && (
|
{searchQuery && !selectedMedicine && (
|
||||||
<MedicineResults
|
<MedicineResults
|
||||||
medicines={medicines}
|
medicines={medicines}
|
||||||
onSelect={setSelectedMedicine}
|
onSelect={(m) => {
|
||||||
|
saveToRecent(m);
|
||||||
|
setSelectedMedicine(m);
|
||||||
|
}}
|
||||||
query={searchQuery}
|
query={searchQuery}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
onLoginRequest={onLoginRequest}
|
onLoginRequest={onLoginRequest}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default defineConfig({
|
|||||||
name: 'FarmaClic',
|
name: 'FarmaClic',
|
||||||
short_name: 'FarmaClic',
|
short_name: 'FarmaClic',
|
||||||
description: 'Encuentra medicamentos en farmacias cercanas',
|
description: 'Encuentra medicamentos en farmacias cercanas',
|
||||||
theme_color: '#00450d',
|
theme_color: '#7fbf8f',
|
||||||
background_color: '#f8fafb',
|
background_color: '#f8fafb',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
|
|||||||
@@ -7,11 +7,17 @@
|
|||||||
"dev": "npm-run-all --parallel dev:backend dev:frontend",
|
"dev": "npm-run-all --parallel dev:backend dev:frontend",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
"dev:frontend": "npm run dev --prefix frontend",
|
"dev:frontend": "npm run dev --prefix frontend",
|
||||||
|
"dev:mobile": "cd frontend-mobile && npx expo start",
|
||||||
|
"dev:mobile:android": "cd frontend-mobile && npx expo start --android",
|
||||||
|
"dev:mobile:ios": "cd frontend-mobile && npx expo start --ios",
|
||||||
"start": "npm-run-all --parallel start:backend start:frontend",
|
"start": "npm-run-all --parallel start:backend start:frontend",
|
||||||
"start:backend": "npm start --prefix backend",
|
"start:backend": "npm start --prefix backend",
|
||||||
"start:frontend": "npm run preview --prefix frontend",
|
"start:frontend": "npm run preview --prefix frontend",
|
||||||
"install:all": "npm install && npm install --prefix backend && npm install --prefix frontend",
|
"install:all": "npm install && npm install --prefix backend && npm install --prefix frontend && npm install --prefix frontend-mobile",
|
||||||
"build:web": "npm run build --prefix frontend",
|
"build:web": "npm run build --prefix frontend",
|
||||||
|
"build:mobile": "cd frontend-mobile && eas build",
|
||||||
|
"submit:android": "cd frontend-mobile && eas submit --platform android",
|
||||||
|
"submit:ios": "cd frontend-mobile && eas submit --platform ios",
|
||||||
"cap:sync": "npm run build:web && cap sync",
|
"cap:sync": "npm run build:web && cap sync",
|
||||||
"cap:copy": "npm run build:web && cap copy",
|
"cap:copy": "npm run build:web && cap copy",
|
||||||
"cap:open:android": "cap open android",
|
"cap:open:android": "cap open android",
|
||||||
|
|||||||