41 Commits

Author SHA1 Message Date
Ichitux f66cafbbc3 Merge pull request 'new_react_frontend' (#9) from new_react_frontend into main
Build & Push Docker Images / test-backend (push) Successful in 34s
Build & Push Docker Images / test-frontend (push) Successful in 31s
Build & Push Docker Images / deploy (push) Successful in 7s
Build & Push Docker Images / build-backend (push) Successful in 18s
Build & Push Docker Images / build-frontend (push) Successful in 16s
Run Tests on Branches / Frontend Tests (push) Has been cancelled
Run Tests on Branches / Backend Tests (push) Has been cancelled
Reviewed-on: #9
2026-07-06 13:10:10 +00:00
Antoni Nuñez Romeu 71a349e54b fix: add missing asset files (adaptive-icon, splash, notification-icon)
Run Tests on Branches / Backend Tests (push) Successful in 3m32s
Run Tests on Branches / Frontend Tests (push) Successful in 3m27s
2026-07-06 13:12:29 +02:00
Antoni Nuñez Romeu cea2c98c82 fix: update react version to resolve peer dependency conflict 2026-07-06 13:02:52 +02:00
Antoni Nuñez Romeu f6ddf30bda docs: add production deployment guide for mobile app 2026-07-06 12:54:55 +02:00
Antoni Nuñez Romeu c4fd5bfc77 feat: configure EAS Build for production and add env example 2026-07-06 12:09:41 +02:00
Antoni Nuñez Romeu 454da1416d docs: update README with React Native mobile app documentation 2026-07-06 11:42:14 +02:00
Antoni Nuñez Romeu 4afffe46f0 feat: add biometric authentication support 2026-07-06 11:36:01 +02:00
Antoni Nuñez Romeu d22f1b5646 feat: add barcode scanner for medicine lookup 2026-07-06 11:33:09 +02:00
Antoni Nuñez Romeu 205af1a795 feat: add push notifications support 2026-07-06 11:29:25 +02:00
Antoni Nuñez Romeu b4c572ce17 feat: configure EAS Build and add mobile scripts 2026-07-06 11:25:20 +02:00
Antoni Nuñez Romeu c02abe077a feat: add auth check on app startup 2026-07-06 11:23:20 +02:00
Antoni Nuñez Romeu 7b526e4aaa feat: implement auth screens (login, register) and profile screen 2026-07-06 11:20:51 +02:00
Antoni Nuñez Romeu 414d94c8ea feat: implement pharmacy detail screen with map and medicines 2026-07-06 11:17:22 +02:00
Antoni Nuñez Romeu 11421ef956 feat: implement map screen with pharmacy markers 2026-07-06 11:14:49 +02:00
Antoni Nuñez Romeu 6e58939723 feat: implement medicine detail screen with pharmacies list 2026-07-06 11:12:57 +02:00
Antoni Nuñez Romeu 29b9f84462 feat: implement home screen with medicine search 2026-07-06 11:10:19 +02:00
Antoni Nuñez Romeu 39331e2f6e feat: add UI components (SearchBar, MedicineCard, StockBadge, LoadingSpinner) 2026-07-06 11:08:40 +02:00
Antoni Nuñez Romeu 47f63eacc7 feat: add theme constants (colors, spacing, typography) 2026-07-06 11:05:56 +02:00
Antoni Nuñez Romeu 57054efe76 feat: add Zustand stores and custom hooks 2026-07-06 11:03:55 +02:00
Antoni Nuñez Romeu a5064f2172 feat: add API client and services for medicines, pharmacies, auth 2026-07-06 11:00:44 +02:00
Antoni Nuñez Romeu 259bdb1479 feat: add TypeScript types for mobile app 2026-07-06 10:58:00 +02:00
Antoni Nuñez Romeu 1b9fd16b9b feat: setup navigation structure with Expo Router 2026-07-06 10:53:52 +02:00
Antoni Nuñez Romeu ea9bbaed04 feat: initialize Expo project for mobile frontend 2026-07-06 10:48:45 +02:00
Antoni Nuñez Romeu 0f27579ca0 docs: add FarmaFinder Mobile implementation plan 2026-07-06 10:35:34 +02:00
Antoni Nuñez Romeu d9bf4f0d18 docs: add FarmaFinder Mobile design specification 2026-07-06 10:16:51 +02:00
Ichitux 792e6dd5b1 Merge pull request 'New icon for PWA. CSS corrections.' (#8) from design_fixes into main
Build & Push Docker Images / test-backend (push) Successful in 35s
Build & Push Docker Images / test-frontend (push) Successful in 30s
Build & Push Docker Images / deploy (push) Successful in 7s
Build & Push Docker Images / build-backend (push) Successful in 17s
Build & Push Docker Images / build-frontend (push) Successful in 32s
Reviewed-on: #8
2026-07-06 08:11:57 +00:00
Antoni Nuñez Romeu 7550978bc8 New icon for PWA. CSS corrections.
Run Tests on Branches / Backend Tests (push) Successful in 3m32s
Run Tests on Branches / Frontend Tests (push) Successful in 3m27s
2026-07-06 10:01:47 +02:00
Ichitux c0c58556e7 Merge pull request 'Logos & scanner changes' (#7) from tsi_uploads into main
Build & Push Docker Images / test-backend (push) Successful in 38s
Build & Push Docker Images / deploy (push) Successful in 20s
Build & Push Docker Images / build-backend (push) Successful in 2m21s
Build & Push Docker Images / build-frontend (push) Successful in 39s
Build & Push Docker Images / test-frontend (push) Successful in 30s
Reviewed-on: #7
2026-07-06 07:20:01 +00:00
Antoni Nuñez Romeu df0b114058 Logos & scanner changes
Run Tests on Branches / Backend Tests (push) Successful in 3m41s
Run Tests on Branches / Frontend Tests (push) Successful in 3m27s
2026-07-04 14:46:42 +02:00
Ichitux 3b725df118 Merge pull request 'feat: replace barcode-detector polyfill with local @zxing/browser for cross-browser barcode scanning' (#6) from fixing_barcode into main
Build & Push Docker Images / test-backend (push) Successful in 33s
Build & Push Docker Images / test-frontend (push) Successful in 31s
Build & Push Docker Images / deploy (push) Successful in 7s
Build & Push Docker Images / build-backend (push) Successful in 22s
Build & Push Docker Images / build-frontend (push) Successful in 54s
Reviewed-on: #6
2026-07-03 10:10:03 +00:00
Antoni Nuñez Romeu 5b080c4134 feat: replace barcode-detector polyfill with local @zxing/browser for cross-browser barcode scanning
Run Tests on Branches / Backend Tests (push) Successful in 3m35s
Run Tests on Branches / Frontend Tests (push) Successful in 3m33s
- Remove barcode-detector polyfill (made remote CDN calls to download zxing-wasm)
- Use @zxing/library@0.22.0 + @zxing/browser@0.2.0 locally
- Support PDF417, Code128, Code39, Code93, QR, EAN, UPC, ITF formats
- Clean special characters from scanned results before CIP validation
- Fix video ref timing issue with waitForVideo loop
2026-07-03 11:58:04 +02:00
Ichitux 0ebd2dc35c Merge pull request 'Fixes on docker compose & notify' (#5) from search_improvements into main
Build & Push Docker Images / test-backend (push) Successful in 32s
Build & Push Docker Images / test-frontend (push) Successful in 29s
Build & Push Docker Images / deploy (push) Successful in 19s
Build & Push Docker Images / build-backend (push) Successful in 19s
Build & Push Docker Images / build-frontend (push) Successful in 31s
Reviewed-on: #5
2026-07-02 15:49:06 +00:00
Antoni Nuñez Romeu 1bc34b1134 Fixes on docker compose & notify
Run Tests on Branches / Backend Tests (push) Successful in 3m28s
Run Tests on Branches / Frontend Tests (push) Successful in 3m27s
2026-07-02 17:09:15 +02:00
Ichitux 15e136f3d5 Merge pull request 'Profile_Fixes' (#4) from Profile_Fixes into main
Build & Push Docker Images / test-backend (push) Successful in 36s
Build & Push Docker Images / deploy (push) Successful in 7s
Build & Push Docker Images / build-backend (push) Successful in 22s
Build & Push Docker Images / build-frontend (push) Successful in 32s
Build & Push Docker Images / test-frontend (push) Successful in 31s
Reviewed-on: #4
2026-07-02 10:52:07 +00:00
Antoni Nuñez Romeu 7606d118e4 Design hotfixes and improvements.
Run Tests on Branches / Backend Tests (push) Successful in 3m32s
Run Tests on Branches / Frontend Tests (push) Successful in 3m29s
2026-07-02 12:40:09 +02:00
Antoni Nuñez Romeu 15c1127b34 Add admin access button in profile menu for admin users
- Add onAdminClick prop to ProfileView component
- Add conditional admin button visible only for is_admin users
- Add handleAdminClick function in App.jsx to navigate to admin view
- Button styled consistently with existing menu items
2026-07-02 11:23:57 +02:00
Antoni Nuñez Romeu 17cd5adaef Add design spec for admin access button in profile menu 2026-07-02 11:20:56 +02:00
Ichitux 4429ec4c7f Merge pull request 'Frontend_Fixes' (#3) from Frontend_Fixes into main
Build & Push Docker Images / test-backend (push) Successful in 33s
Build & Push Docker Images / test-frontend (push) Successful in 28s
Build & Push Docker Images / deploy (push) Successful in 18s
Build & Push Docker Images / build-backend (push) Successful in 18s
Build & Push Docker Images / build-frontend (push) Successful in 31s
Reviewed-on: #3
2026-07-01 15:29:25 +00:00
Antoni Nuñez Romeu 71a34293a9 Views in frontend changed
Run Tests on Branches / Backend Tests (push) Successful in 3m28s
Run Tests on Branches / Frontend Tests (push) Successful in 3m26s
2026-07-01 17:08:15 +02:00
Ichitux 1b9494bce6 Merge pull request 'Testing steps to avoid wrong implementation' (#2) from testing into main
Build & Push Docker Images / test-backend (push) Successful in 32s
Build & Push Docker Images / test-frontend (push) Successful in 30s
Build & Push Docker Images / deploy (push) Successful in 6s
Build & Push Docker Images / build-backend (push) Successful in 19s
Build & Push Docker Images / build-frontend (push) Successful in 18s
Reviewed-on: #2
2026-07-01 14:40:47 +00:00
Ichitux 8578bac776 Merge pull request 'Rebranding app and naming' (#1) from Rebranding into main
Build & Push Docker Images / test-backend (push) Successful in 35s
Build & Push Docker Images / deploy (push) Successful in 7s
Build & Push Docker Images / build-backend (push) Successful in 2m10s
Build & Push Docker Images / build-frontend (push) Successful in 53s
Build & Push Docker Images / test-frontend (push) Successful in 30s
Reviewed-on: #1
2026-07-01 13:57:19 +00:00
96 changed files with 16003 additions and 648 deletions
+25 -45
View File
@@ -3,65 +3,45 @@ name: Run Tests on Branches
on: on:
push: push:
branches-ignore: branches-ignore:
- 'main' - 'main'
jobs: jobs:
test-backend: test-backend:
name: Backend Tests name: Backend Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: '20' node-version: '20'
cache: 'npm' cache: 'npm'
cache-dependency-path: backend/package-lock.json cache-dependency-path: backend/package-lock.json
- name: Install Backend Dependencies - name: Install Backend Dependencies
run: cd backend && npm ci run: cd backend && npm ci
- name: Run Backend Tests - name: Run Backend Tests
run: cd backend && npm test -- --ci run: cd backend && npm test -- --ci
test-frontend: test-frontend:
name: Frontend Tests name: Frontend Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: '20' node-version: '20'
cache: 'npm' cache: 'npm'
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Install Frontend Dependencies - name: Install Frontend Dependencies
run: cd frontend && npm ci run: cd frontend && npm ci
- name: Run Frontend Tests - name: Run Frontend Tests
run: cd frontend && npm test -- --run --reporter=basic run: cd frontend && npm test -- --run --reporter=basic
test-pip:
name: PIP Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: pip-platform/package-lock.json
- name: Install PIP dependences
run: cd pip-platform && npm ci
- name: Run PIP Tests
run: cd pip-platform && npm test -- --run --reporter=basic
+273 -6
View File
@@ -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
``` ```
+128 -3
View File
@@ -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": "*"
}
} }
} }
} }
+3 -1
View File
@@ -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": {
+254 -23
View File
@@ -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: 830 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();
+10 -8
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
+8
View File
@@ -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
+42
View File
@@ -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
+3
View File
@@ -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.
+20
View File
@@ -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',
},
});
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -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.
+42
View File
@@ -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": ""
}
}
}
}
+41
View File
@@ -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>
);
}
+127
View File
@@ -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,
},
});
+108
View File
@@ -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,
},
});
+219
View File
@@ -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',
},
});
+73
View File
@@ -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>
);
}
+237
View File
@@ -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,
},
});
+172
View File
@@ -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,
},
});
+205
View File
@@ -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,
},
});
+260
View File
@@ -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,
},
});
+46
View File
@@ -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,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

+7
View File
@@ -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,
},
});
+110
View File
@@ -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,
},
});
+81
View File
@@ -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,
},
});
+48
View File
@@ -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',
},
});
+19
View File
@@ -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
};
+58
View File
@@ -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,
},
};
+46
View File
@@ -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"
}
}
}
}
+19
View File
@@ -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,
};
}
+17
View File
@@ -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;
}
+8
View File
@@ -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);
+7546
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -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
}
+36
View File
@@ -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;
+43
View File
@@ -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;
}
+34
View File
@@ -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');
}
+17
View File
@@ -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;
}
+82
View File
@@ -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);
}
+17
View File
@@ -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;
}
+38
View File
@@ -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
});
},
}));
+27
View File
@@ -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 }),
}));
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx"
]
}
+51
View File
@@ -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;
}
+2 -2
View File
@@ -3,11 +3,11 @@
<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" />
<link rel="apple-touch-icon" href="/logo.png" /> <link rel="apple-touch-icon" href="/farmaclic_logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
+43 -53
View File
@@ -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"
}
} }
} }
} }
+2 -1
View File
@@ -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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 34 KiB

+128 -8
View File
@@ -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);
} }
+28 -22
View File
@@ -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}
/> />
+136 -116
View File
@@ -1,123 +1,143 @@
.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;
}
.bottom-nav-item:hover {
color: var(--primary);
background: transparent;
font-weight: 700;
}
.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 {
-63
View File
@@ -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;
}
}
-28
View File
@@ -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;
+33 -24
View File
@@ -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;
+1 -1
View File
@@ -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 */
+3 -1
View File
@@ -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,
+52
View File
@@ -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',
});
});
});
-41
View File
@@ -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);
+139 -70
View File
@@ -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;
+124 -15
View File
@@ -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 {
+2 -2
View File
@@ -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>
+35 -1
View File
@@ -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;
}
} }
+15 -1
View File
@@ -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">
+84
View File
@@ -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;
}
+205 -44
View File
@@ -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">
+23 -13
View File
@@ -44,31 +44,41 @@
transform: scale(0.96); transform: scale(0.96);
} }
.suggestion-btn--primary { .suggestion-btn:hover {
background: var(--primary); background: #83afd6;
color: var(--on-primary); font-weight: 700;
} }
.suggestion-btn--secondary { .suggestion-btn--neutral-1 {
background: var(--secondary); background: #ffffff;
color: var(--on-secondary); color: #1e293b;
border: 1px solid #e2e8f0;
} }
.suggestion-btn--tertiary { .suggestion-btn--neutral-2 {
background: var(--tertiary-container); background: #f0f7ff;
color: var(--on-tertiary); color: #1e293b;
border: 1px solid #dbeafe;
} }
.suggestion-btn--tint { .suggestion-btn--neutral-3 {
background: var(--surface-tint); background: #e0f0ff;
color: var(--on-primary); color: #1e293b;
border: 1px solid #bfdbfe;
}
.suggestion-btn--neutral-4 {
background: #d4ebff;
color: #1e293b;
border: 1px solid #93c5fd;
} }
.suggestion-icon { .suggestion-icon {
width: 2.5rem; width: 2.5rem;
height: 2.5rem; height: 2.5rem;
border-radius: var(--radius); border-radius: var(--radius);
background: rgba(255, 255, 255, 0.2); background: rgba(59, 130, 246, 0.08);
color: #3b82f6;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
+87 -39
View File
@@ -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';
@@ -7,15 +7,10 @@ import { haversineKm, getUserPosition } from '../utils/geo';
import './SearchView.css'; import './SearchView.css';
const suggestions = [ const suggestions = [
{ name: 'Paracetamol', icon: 'medication', color: 'primary' }, { name: 'Paracetamol', icon: 'medication', color: 'neutral-1' },
{ name: 'Ibuprofeno', icon: 'pill', color: 'secondary' }, { name: 'Ibuprofeno', icon: 'pill', color: 'neutral-2' },
{ name: 'Aspirina', icon: 'vaccines', color: 'tertiary' }, { name: 'Aspirina', icon: 'vaccines', color: 'neutral-3' },
{ name: 'Omeprazol', icon: 'emergency_home', color: 'tint' }, { name: 'Omeprazol', icon: 'emergency_home', color: 'neutral-4' },
];
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 }) {
@@ -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}
+2 -4
View File
@@ -20,14 +20,12 @@ 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: '/',
icons: [ icons: [
{ src: '/logo-square-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, { src: '/farmaclic_logo.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
{ src: '/logo-square-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/logo.png', sizes: '512x279', type: 'image/png', purpose: 'any' },
], ],
}, },
}), }),
+7 -1
View File
@@ -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",
+1 -1
View File
@@ -15,4 +15,4 @@ COPY . .
EXPOSE 8000 EXPOSE 8000
CMD ["python", "entrypoint.py"] CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--loop", "uvloop", "--http", "httptools"]
@@ -19,8 +19,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
return await call_next(request) return await call_next(request)
path = request.url.path path = request.url.path
exempt_prefixes = ("/docs", "/redoc", "/openapi", "/metrics", "/api/v1/system/health") if path.startswith("/docs") or path.startswith("/redoc") or path.startswith("/openapi") or path.startswith("/metrics"):
if any(path.startswith(p) for p in exempt_prefixes):
return await call_next(request) return await call_next(request)
client_key = self._get_client_key(request) client_key = self._get_client_key(request)
@@ -73,10 +73,8 @@ async def test_api_key_read_access():
r2 = await c.get("/api/v1/medications", headers=api_h) r2 = await c.get("/api/v1/medications", headers=api_h)
assert r2.status_code == 200 assert r2.status_code == 200
if r.json()["total"] > 0: r3 = await c.get("/api/v1/inventory", headers=api_h)
pharm_id = r.json()["items"][0]["id"] assert r3.status_code == 200
r3 = await c.get(f"/api/v1/pharmacies/{pharm_id}/inventory", headers=api_h)
assert r3.status_code == 200
print(" PASS: test_api_key_read_access") print(" PASS: test_api_key_read_access")
@@ -75,8 +75,19 @@ async def test_rate_limit_exempt_paths():
async def test_rate_limit_per_client(): async def test_rate_limit_per_client():
async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as c: async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as c:
token = await get_admin_token(c)
h = bearer_headers(token)
r0 = await c.get("/api/v1/pharmacies", headers=h)
limit = int(r0.headers["x-ratelimit-limit"])
for _ in range(limit + 5):
r = await c.get("/api/v1/pharmacies", headers=h)
if r.status_code == 429:
break
r2 = await c.get("/api/v1/system/health") r2 = await c.get("/api/v1/system/health")
assert r2.status_code == 200, "Health endpoint should work regardless of rate limit on other paths (it is exempt)" assert r2.status_code == 200, "Health endpoint should work regardless of rate limit on other paths"
print(" PASS: test_rate_limit_per_client") print(" PASS: test_rate_limit_per_client")