add MyPlantsScreen + ScanDetailScreen + enriched admin + API mobile + project summary
Mobile: - Replace LibraryScreen with MyPlantsScreen (date-grouped scan list, swipe actions, search, pull-to-refresh) - Add ScanDetailScreen (immersive hero, confidence bar, cepage card, share/delete) - Add DiseaseDetailScreen + GuideDetailScreen (hero pattern, animated entry) - Add useScanDetail, useHistory (useCallback fix), dateGrouping utility - Connect diseases/guides to admin API with cache + offline fallback - Add NetworkContext, ToastContext, Skeleton loading components - Extend ScanRecord type (isFavorite, location) - Full i18n FR/EN for all new screens Admin (vineye-admin): - Enrich Disease/Guide Prisma schema (timeline, conditions, actions, sections) - Enriched disease-form (7 sections) + guide-form (structured sections editor) - Add mobile public API endpoints (diseases, guides by slug) - Add Prisma migration + enriched seed data - UI polish: sidebar, login, layout updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
276
PROJECT_SUMMARY.md
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
# VinEye - Project Summary
|
||||
|
||||
> Plateforme de detection de maladies de la vigne par IA
|
||||
> Derniere mise a jour : 2026-04-09
|
||||
|
||||
---
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
VinEye est une plateforme complete de detection de maladies de la vigne composee de :
|
||||
|
||||
- **Application mobile** (VinEye) : React Native / Expo, scan camera avec inference IA embarquee
|
||||
- **Dashboard admin** (vineye-admin) : Next.js, gestion du contenu et suivi des utilisateurs
|
||||
- **Pipeline ML** : CNN TensorFlow entraine sur 9 027 images de feuilles de vigne
|
||||
|
||||
Public cible : amateurs de vin, viticulteurs, jardiniers.
|
||||
|
||||
---
|
||||
|
||||
## Stack technique
|
||||
|
||||
| Couche | Mobile (VinEye) | Admin (vineye-admin) | ML |
|
||||
|--------|----------------|---------------------|-----|
|
||||
| Framework | React Native 0.81 + Expo SDK 54 | Next.js 16.2 | TensorFlow / Keras |
|
||||
| Langage | TypeScript strict | TypeScript strict | Python 3 |
|
||||
| Styling | NativeWind (Tailwind 3.4) + StyleSheet | Tailwind 4 + shadcn/ui | — |
|
||||
| Navigation | React Navigation v7 | App Router | — |
|
||||
| Images | expo-image | next/image | — |
|
||||
| Gradients | expo-linear-gradient | — | — |
|
||||
| Animations | react-native-reanimated 4.1 | — | — |
|
||||
| Gestures | react-native-gesture-handler 2.28 | — | — |
|
||||
| Edge-to-edge | expo-navigation-bar + expo-status-bar | — | — |
|
||||
| Icones | lucide-react-native | lucide-react | — |
|
||||
| Cache | AsyncStorage + cacheManager (TTL) | — | — |
|
||||
| Reseau | expo-network (connectivite) | — | — |
|
||||
| Toast | sonner-native | Sonner | — |
|
||||
| Base de donnees | AsyncStorage (local) | PostgreSQL via Prisma 7.6 | — |
|
||||
| Auth | — (local only) | Better Auth (JWT + sessions) | — |
|
||||
| Forms | — | Zod validation | — |
|
||||
| IA | TFLite (mock actuel) | — | CNN 4 blocs conv, 3.8M params |
|
||||
|
||||
---
|
||||
|
||||
## Avancement global
|
||||
|
||||
### Application mobile — 95% complete
|
||||
|
||||
| Feature | Status | Detail |
|
||||
|---------|--------|--------|
|
||||
| Navigation (14 ecrans) | Done | Bottom tabs + FAB scanner + stack screens + 4 ecrans detail |
|
||||
| HomeScreen | Done | Header, search, CTA, carousel maladies, alertes, guides |
|
||||
| Scanner (camera) | Done | Capture photo, overlay detection, confidence meter |
|
||||
| Resultats scan | Done | Affichage cepage, confiance, tags, grille infos |
|
||||
| MyPlants (ex-Library) | Done | Liste scans groupes par date (today/yesterday/week/month/older), swipe-to-action (favori+suppr), recherche, pull-to-refresh, empty state CTA |
|
||||
| ScanDetailScreen | Done | Hero immersif 380px, carte resultat + barre confiance animee, carte cepage conditionnelle, meta card (date+XP), location card (stub), share/delete actions |
|
||||
| Profil utilisateur | Done | Avatar, stats Bento 2x2, XP, niveau |
|
||||
| Parametres | Done | Theme, langue, notifications, referral |
|
||||
| Guides maladies | Done | Connecte a l'API backend, SmallDiseaseCard en grille sur l'ecran Guides, icones Lucide par maladie, dot severite, degrade bordure |
|
||||
| Guides pratiques | Done | 3 guides avec contenu structure, GuideListItem iOS-style avec icones par categorie, stagger animations |
|
||||
| Pages de detail | Done | DiseaseDetailScreen, GuideDetailScreen — hero immersif, edge-to-edge, animation d'entree |
|
||||
| Edge-to-edge | Done | Contenu defile derriere status bar, navigation bar transparente (Android), useSafeAreaInsets |
|
||||
| Transitions navigation | Done | Fade 250ms par defaut, slide_from_bottom pour modal Result, gesture back active |
|
||||
| API mobile | Done | Service API centralise (apiGet), cache AsyncStorage avec TTL, detection IP automatique Expo |
|
||||
| Connectivite reseau | Done | Hook useNetworkStatus (expo-network), NetworkContext, detection online/offline temps reel |
|
||||
| Toast | Done | sonner-native, toasts auto offline/online, variantes success/error/warning/info |
|
||||
| Sync maladies | Done | useDiseases/useDiseaseDetail : API → cache → fallback local, pull-to-refresh |
|
||||
| Sync guides | Done | useGuides/useGuideDetail : meme strategie 3 niveaux |
|
||||
| Cards maladies | Done | SmallDiseaseCard reutilisable, icones Lucide (Droplets, Snowflake, Skull...), dot severite anime, degrade bordure |
|
||||
| List items guides | Done | GuideListItem iOS-style, icones par categorie (Leaf, Calendar, Grape), stagger animations |
|
||||
| Skeleton loading | Done | Composants shimmer pour cards maladies, list items guides, carousel |
|
||||
| Gamification | Done | 7 niveaux, 7 badges, XP, streaks, decouvertes |
|
||||
| i18n (FR + EN) | Done | Toutes les cles traduites (maladies enrichies + guides sections + tips) |
|
||||
| Notifications | Partiel | UI uniquement, pas de push notifs |
|
||||
| Carte/Map | Partiel | Placeholder, geoloc non implementee |
|
||||
| Inference IA reelle | A faire | Mock actuellement (weighted random) |
|
||||
|
||||
### Dashboard admin — 95% complete
|
||||
|
||||
| Feature | Status | Detail |
|
||||
|---------|--------|--------|
|
||||
| Authentification | Done | Login email/password, sessions 7j, middleware |
|
||||
| Dashboard stats | Done | Compteurs, scans recents, top maladies (Recharts) |
|
||||
| CRUD Maladies enrichi | Done | Formulaire 7 sections : infos, symptomes, timeline visuelle, details techniques (accordeon), images par URL, apparence, publication |
|
||||
| CRUD Guides enrichi | Done | Formulaire 5 sections : infos, contenu ancien (deprecie), editeur sections structurees (reorder, delete, tip, image), apparence, publication |
|
||||
| Gestion alertes | Done | Alertes saisonnieres, region, dates, toggle actif |
|
||||
| Gestion utilisateurs | Done | Liste paginee, stats, ban/unban, roles |
|
||||
| API REST admin | Done | Endpoints complets pour diseases, guides, alerts, scans, users |
|
||||
| API mobile publique | Done | 4 endpoints GET sans auth (/api/mobile/diseases, guides + detail par slug), CORS, pagination, cache headers |
|
||||
| Schema DB enrichi | Done | Disease : +timeline, +conditions[], +actions[], +impactedParts[], +DiseaseImage[]. Guide : +readTime, +coverImage, +GuideSection[] |
|
||||
|
||||
### Pipeline ML — 60% complete
|
||||
|
||||
| Feature | Status | Detail |
|
||||
|---------|--------|--------|
|
||||
| Architecture CNN | Done | 4 blocs conv + classification, 3.8M params |
|
||||
| Dataset | Done | 9 027 images, 4 classes (Black Rot, ESCA, Healthy, Leaf Blight) |
|
||||
| Entrainement | Done | 100 epochs, Adam lr=0.001, augmentation |
|
||||
| Precision modele | A ameliorer | ~30% (surapprentissage probable vers ESCA) |
|
||||
| Export TFLite | A faire | Conversion pour inference mobile |
|
||||
| Integration mobile | A faire | Remplacer le mock dans `services/tflite/model.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Points critiques
|
||||
|
||||
### 1. Inference IA — BLOQUANT
|
||||
|
||||
Le coeur du projet (la detection de maladie) est actuellement **mocke**. Le fichier `VinEye/src/services/tflite/model.ts` retourne des resultats aleatoires ponderes (70% vigne, 20% incertain, 10% non-vigne).
|
||||
|
||||
**Actions requises :**
|
||||
- Ameliorer la precision du modele (actuellement ~30%)
|
||||
- Exporter le modele en TFLite
|
||||
- Integrer les poids reels dans l'app mobile
|
||||
- Tester la performance sur device (latence, memoire)
|
||||
- Eventuellement : quantization / pruning pour optimiser
|
||||
|
||||
### 2. Stockage images — PARTIELLEMENT RESOLU
|
||||
|
||||
Les DiseaseImage et GuideSection.image existent en DB avec des URLs. Il manque un systeme d'upload reel (S3/Cloudinary). Les images sont ajoutees par URL manuelle dans les formulaires admin.
|
||||
|
||||
### 3. Synchronisation mobile <-> serveur — PARTIELLEMENT RESOLU
|
||||
|
||||
Les maladies et guides se synchronisent via l'API mobile publique avec cache local et fallback offline. Il reste a implementer : auth mobile, sync des scans, sync des alertes.
|
||||
|
||||
### 4. Precision du modele ML
|
||||
|
||||
~30% de precision est insuffisant pour un usage reel. Le modele semble surfit vers la classe ESCA.
|
||||
|
||||
**Pistes :**
|
||||
- Augmenter et equilibrer le dataset
|
||||
- Experimenter d'autres architectures (ResNet, EfficientNet transfer learning)
|
||||
- Cross-validation + early stopping
|
||||
- Analyser la matrice de confusion par classe
|
||||
|
||||
### 5. Absence de tests
|
||||
|
||||
Aucun test unitaire ou d'integration dans aucune partie du projet (mobile, admin, ML).
|
||||
|
||||
### 6. Images placeholder
|
||||
|
||||
Les images des maladies et cepages utilisent des URLs Unsplash generiques (vignobles, raisins, feuilles). A remplacer par de vraies photos agronomiques specifiques a chaque maladie (stockage local ou distant).
|
||||
|
||||
### 7. Formulaires admin enrichis — DONE
|
||||
|
||||
Les formulaires CRUD pour maladies et guides reflètent tous les champs du schema enrichi (timeline, conditions, actions, sections structurees, images). Validation Zod côte serveur.
|
||||
|
||||
---
|
||||
|
||||
## Features detaillees
|
||||
|
||||
### Gamification (mobile)
|
||||
|
||||
| Element | Detail |
|
||||
|---------|--------|
|
||||
| Niveaux | 7 : Bourgeon → Vendangeur → Amateur → Sommelier → Expert → Maitre → Maitre de Chai |
|
||||
| XP | +10 scan, +25 nouveau cepage, +5 streak, +5 confiance >90% |
|
||||
| Badges | 7 : Premier Scan, Connaisseur, En Feu, Oeil Affute, Explorateur, Perfectionniste, Maitre |
|
||||
| Streaks | Scans quotidiens consecutifs |
|
||||
|
||||
### Base de maladies (7)
|
||||
|
||||
| Maladie | Type | Severite |
|
||||
|---------|------|----------|
|
||||
| Mildiou | Fongique | Haute |
|
||||
| Oidium | Fongique | Haute |
|
||||
| Black Rot | Fongique | Haute |
|
||||
| ESCA | Fongique | Moyenne |
|
||||
| Botrytis | Fongique | Moyenne |
|
||||
| Flavescence Doree | Bacterien | Haute |
|
||||
| Chlorose | Abiotique | Basse |
|
||||
|
||||
### Base de cepages (~15)
|
||||
|
||||
Varietes francaises avec : nom FR/EN, couleur, regions, caracteristiques. Utilises pour l'affichage post-scan (mock actuel attribue un cepage aleatoire quand result=vine).
|
||||
|
||||
### Schema base de donnees (admin)
|
||||
|
||||
```
|
||||
User (better-auth + role, xp, level, banned)
|
||||
├── Session
|
||||
├── Account
|
||||
└── Scan* ──→ Disease?
|
||||
|
||||
Disease (slug, name FR/EN, type, severity, symptoms, treatment, published)
|
||||
|
||||
Guide (slug, title FR/EN, content, category, order, published)
|
||||
|
||||
SeasonAlert (title FR/EN, type, region, active, dateRange)
|
||||
```
|
||||
|
||||
### API REST (admin)
|
||||
|
||||
| Methode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| GET/POST | `/api/diseases` | Liste / creation |
|
||||
| GET/PUT/DELETE | `/api/diseases/[id]` | Detail / edition / suppression |
|
||||
| GET/POST | `/api/guides` | Liste / creation |
|
||||
| GET/PUT/DELETE | `/api/guides/[id]` | Detail / edition / suppression |
|
||||
| GET/POST | `/api/alerts` | Liste / creation |
|
||||
| GET/PUT/DELETE | `/api/alerts/[id]` | Detail / edition / suppression |
|
||||
| GET/POST | `/api/scans` | Liste / creation |
|
||||
| GET/PUT/DELETE | `/api/scans/[id]` | Detail / edition / suppression |
|
||||
| GET | `/api/users` | Liste utilisateurs |
|
||||
| GET | `/api/users/[id]` | Detail utilisateur |
|
||||
|
||||
---
|
||||
|
||||
## Design System (mobile)
|
||||
|
||||
| Token | Valeur |
|
||||
|-------|--------|
|
||||
| Fond app | `#F8F9FB` |
|
||||
| Cards | `#FFFFFF`, radius 24-32, border `#F0F0F0` |
|
||||
| Primary (vert vigne) | `#2D6A4F` (800), `#1B4332` (900) |
|
||||
| Accent (violet raisin) | `#8E24AA` (700), `#6A0572` (800) |
|
||||
| Texte principal | `#1A1A1A` |
|
||||
| Texte secondaire | `#8E8E93` |
|
||||
| Style | Bento Box minimaliste, espaces genereux |
|
||||
|
||||
---
|
||||
|
||||
## Arborescence du projet
|
||||
|
||||
```
|
||||
Grapevine_Disease_Detection/
|
||||
├── VinEye/ # App mobile React Native + Expo
|
||||
│ ├── App.tsx # Point d'entree
|
||||
│ ├── src/
|
||||
│ │ ├── screens/ # 14 ecrans (+ DiseaseDetail, GuideDetail, ScanDetail, MyPlants)
|
||||
│ │ ├── components/ # UI (ui/, home/, scanner/, gamification/, history/, guides/, my-plants/, disease/)
|
||||
│ │ ├── config/ # api.ts (base URL dynamique Expo)
|
||||
│ │ ├── contexts/ # NetworkContext, ToastContext
|
||||
│ │ ├── services/ # tflite, storage, haptics
|
||||
│ │ ├── services/api/ # client.ts, diseases.ts, guides.ts, mappers.ts
|
||||
│ │ ├── services/cache/ # cacheManager.ts (AsyncStorage TTL)
|
||||
│ │ ├── hooks/ # useDetection, useGameProgress, useHistory, useScanDetail, useDiseases, useGuides, useNetworkStatus
|
||||
│ │ ├── navigation/ # RootNavigator, BottomTabNavigator
|
||||
│ │ ├── i18n/ # fr.json, en.json
|
||||
│ │ ├── theme/ # colors, typography, spacing
|
||||
│ │ ├── data/ # diseases.ts, guides.ts
|
||||
│ │ ├── types/ # detection, gamification, navigation
|
||||
│ │ └── utils/ # cepages, achievements, dateGrouping, diseaseIcons, guideIcons
|
||||
│ ├── app.json # Config Expo
|
||||
│ └── package.json
|
||||
│
|
||||
├── vineye-admin/ # Dashboard admin Next.js
|
||||
│ ├── app/
|
||||
│ │ ├── (admin)/ # Routes protegees (dashboard, diseases, guides, alerts, users)
|
||||
│ │ ├── (auth)/login/ # Page de connexion
|
||||
│ │ └── api/ # Routes API REST
|
||||
│ ├── components/ # UI admin (shadcn/ui)
|
||||
│ ├── components/admin/ # disease-form.tsx, guide-form.tsx (formulaires enrichis)
|
||||
│ ├── app/api/mobile/ # Endpoints publics pour l'app mobile
|
||||
│ │ ├── diseases/ # route.ts, [slug]/route.ts
|
||||
│ │ └── guides/ # route.ts, [slug]/route.ts
|
||||
│ ├── lib/ # auth.ts, prisma.ts, utils.ts, validations.ts
|
||||
│ ├── prisma/schema.prisma # Schema DB enrichi (Disease, Guide, DiseaseImage, GuideSection)
|
||||
│ └── package.json
|
||||
│
|
||||
├── docs/images/ # Documentation dataset
|
||||
├── venv/ # Environnement Python (training ML)
|
||||
└── PROJECT_SUMMARY.md # Ce fichier
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prochaines etapes prioritaires
|
||||
|
||||
1. **Ameliorer le modele ML** — Passer de ~30% a >80% de precision (transfer learning, dataset equilibre)
|
||||
2. **Integrer TFLite dans l'app** — Remplacer le mock par le vrai modele
|
||||
3. **Stockage images** — S3 ou Cloudinary pour les photos (remplacer les URLs manuelles)
|
||||
4. **Auth mobile + sync scans** — Connecter les scans a l'API (auth mobile, queue offline)
|
||||
5. **Sync alertes** — Connecter les alertes saisonnieres au backend
|
||||
6. **Push notifications** — Firebase Cloud Messaging
|
||||
7. **Tests** — Ajouter des tests unitaires et d'integration
|
||||
8. **Carte des maladies** — Implementer la feature Map avec donnees geoloc reelles
|
||||
|
|
@ -1,15 +1,38 @@
|
|||
import 'react-native-gesture-handler';
|
||||
import './global.css';
|
||||
import { useEffect } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import * as NavigationBar from 'expo-navigation-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { Toaster } from 'sonner-native';
|
||||
import { PortalHost } from '@rn-primitives/portal';
|
||||
import { NetworkProvider } from '@/contexts/NetworkContext';
|
||||
import { NetworkToastWatcher } from '@/contexts/ToastContext';
|
||||
import RootNavigator from '@/navigation/RootNavigator';
|
||||
|
||||
export default function App() {
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'android') {
|
||||
NavigationBar.setBackgroundColorAsync('transparent');
|
||||
NavigationBar.setPositionAsync('absolute');
|
||||
NavigationBar.setButtonStyleAsync('dark');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<StatusBar style="dark" />
|
||||
<RootNavigator />
|
||||
<PortalHost />
|
||||
</SafeAreaProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<NetworkProvider>
|
||||
<NetworkToastWatcher>
|
||||
<StatusBar style="dark" translucent backgroundColor="transparent" />
|
||||
<RootNavigator />
|
||||
<PortalHost />
|
||||
<Toaster position="bottom-center" offset={120} />
|
||||
</NetworkToastWatcher>
|
||||
</NetworkProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-navigation/bottom-tabs": "^7.15.9",
|
||||
"@react-navigation/native": "^7.2.2",
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
|
|
@ -21,10 +22,13 @@
|
|||
"clsx": "^2.1.1",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-linear-gradient": "~15.0.8",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-navigation-bar": "~5.0.10",
|
||||
"expo-network": "~8.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"i18next": "^26.0.1",
|
||||
"lottie-react-native": "^7.3.6",
|
||||
|
|
@ -35,12 +39,14 @@
|
|||
"react-i18next": "^17.0.1",
|
||||
"react-lucid": "^0.0.1",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.12.1",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"sonner-native": "^0.24.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "3.4.17"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ importers:
|
|||
'@react-native-async-storage/async-storage':
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))
|
||||
'@react-native-community/netinfo':
|
||||
specifier: 11.4.1
|
||||
version: 11.4.1(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))
|
||||
'@react-navigation/bottom-tabs':
|
||||
specifier: ^7.15.9
|
||||
version: 7.15.9(@react-navigation/native@7.2.2(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
|
|
@ -44,6 +47,9 @@ importers:
|
|||
expo-camera:
|
||||
specifier: ~17.0.10
|
||||
version: 17.0.10(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
expo-constants:
|
||||
specifier: ~18.0.13
|
||||
version: 18.0.13(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))
|
||||
expo-haptics:
|
||||
specifier: ~15.0.8
|
||||
version: 15.0.8(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))
|
||||
|
|
@ -56,6 +62,12 @@ importers:
|
|||
expo-localization:
|
||||
specifier: ~17.0.8
|
||||
version: 17.0.8(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react@19.1.0)
|
||||
expo-navigation-bar:
|
||||
specifier: ~5.0.10
|
||||
version: 5.0.10(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
expo-network:
|
||||
specifier: ~8.0.8
|
||||
version: 8.0.8(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react@19.1.0)
|
||||
expo-status-bar:
|
||||
specifier: ~3.0.9
|
||||
version: 3.0.9(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
|
|
@ -86,6 +98,9 @@ importers:
|
|||
react-native:
|
||||
specifier: 0.81.5
|
||||
version: 0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)
|
||||
react-native-gesture-handler:
|
||||
specifier: ~2.28.0
|
||||
version: 2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react-native-reanimated:
|
||||
specifier: ~4.1.1
|
||||
version: 4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
|
|
@ -104,6 +119,9 @@ importers:
|
|||
react-native-worklets:
|
||||
specifier: 0.5.1
|
||||
version: 0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
sonner-native:
|
||||
specifier: ^0.24.0
|
||||
version: 0.24.0(3394c02ff2c050721b9b17cc1a65ed8b)
|
||||
tailwind-merge:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
|
|
@ -638,6 +656,10 @@ packages:
|
|||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@egjs/hammerjs@2.0.17':
|
||||
resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
|
||||
'@expo/cli@54.0.23':
|
||||
resolution: {integrity: sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==}
|
||||
hasBin: true
|
||||
|
|
@ -863,6 +885,11 @@ packages:
|
|||
peerDependencies:
|
||||
react-native: ^0.0.0-0 || >=0.65 <1.0
|
||||
|
||||
'@react-native-community/netinfo@11.4.1':
|
||||
resolution: {integrity: sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==}
|
||||
peerDependencies:
|
||||
react-native: '>=0.59'
|
||||
|
||||
'@react-native/assets-registry@0.81.5':
|
||||
resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==}
|
||||
engines: {node: '>= 20.19.4'}
|
||||
|
|
@ -1074,6 +1101,9 @@ packages:
|
|||
'@types/graceful-fs@4.1.9':
|
||||
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
|
||||
|
||||
'@types/hammerjs@2.0.46':
|
||||
resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==}
|
||||
|
||||
'@types/istanbul-lib-coverage@2.0.6':
|
||||
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
|
||||
|
||||
|
|
@ -1714,6 +1744,19 @@ packages:
|
|||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
expo-navigation-bar@5.0.10:
|
||||
resolution: {integrity: sha512-r9rdLw8mY6GPMQmVVOY/r1NBBw74DZefXHF60HxhRsdNI2kjc1wLdfWfR2rk4JVdOvdMDujnGrc9HQmqM3n8Jg==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
expo-network@8.0.8:
|
||||
resolution: {integrity: sha512-dgrL8UHAmWofqeY4UEjWskCl/RoQAM0DG6PZR8xz2WZt+6aQEboQgFRXowCfhbKZ71d16sNuKXtwBEsp2DtdNw==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
react: '*'
|
||||
|
||||
expo-server@1.0.5:
|
||||
resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==}
|
||||
engines: {node: '>=20.16.0'}
|
||||
|
|
@ -1881,6 +1924,9 @@ packages:
|
|||
hermes-parser@0.33.3:
|
||||
resolution: {integrity: sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
|
||||
hosted-git-info@7.0.2:
|
||||
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
|
||||
engines: {node: ^16.14.0 || >=18.0.0}
|
||||
|
|
@ -2771,6 +2817,9 @@ packages:
|
|||
typescript:
|
||||
optional: true
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-is@18.3.1:
|
||||
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
||||
|
||||
|
|
@ -2796,6 +2845,12 @@ packages:
|
|||
react-native-svg:
|
||||
optional: true
|
||||
|
||||
react-native-gesture-handler@2.28.0:
|
||||
resolution: {integrity: sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
react-native-is-edge-to-edge@1.3.1:
|
||||
resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==}
|
||||
peerDependencies:
|
||||
|
|
@ -3015,6 +3070,17 @@ packages:
|
|||
resolution: {integrity: sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
sonner-native@0.24.0:
|
||||
resolution: {integrity: sha512-E94/NyGrL3HBd0E4BZHkYEJpMfrF/on2E2b+GckHTatSSZOyCWZnDVAALGZ7NfOl0Xl/P3IHchIa6Umb9Yqf/g==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
react-native-gesture-handler: '>=2.16.1'
|
||||
react-native-reanimated: '>=3.10.1'
|
||||
react-native-safe-area-context: '>=4.10.5'
|
||||
react-native-screens: '>=3.31.1'
|
||||
react-native-svg: '>=15.6.0'
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -4020,6 +4086,10 @@ snapshots:
|
|||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@egjs/hammerjs@2.0.17':
|
||||
dependencies:
|
||||
'@types/hammerjs': 2.0.46
|
||||
|
||||
'@expo/cli@54.0.23(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))':
|
||||
dependencies:
|
||||
'@0no-co/graphql.web': 1.2.0
|
||||
|
|
@ -4441,6 +4511,10 @@ snapshots:
|
|||
merge-options: 3.0.4
|
||||
react-native: 0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
'@react-native-community/netinfo@11.4.1(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))':
|
||||
dependencies:
|
||||
react-native: 0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
'@react-native/assets-registry@0.81.5': {}
|
||||
|
||||
'@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)':
|
||||
|
|
@ -4785,6 +4859,8 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/node': 25.5.0
|
||||
|
||||
'@types/hammerjs@2.0.46': {}
|
||||
|
||||
'@types/istanbul-lib-coverage@2.0.6': {}
|
||||
|
||||
'@types/istanbul-lib-report@3.0.3':
|
||||
|
|
@ -5443,6 +5519,22 @@ snapshots:
|
|||
react: 19.1.0
|
||||
react-native: 0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
expo-navigation-bar@5.0.10(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@react-native/normalize-colors': 0.81.5
|
||||
debug: 4.4.3
|
||||
expo: 54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)
|
||||
react-native-is-edge-to-edge: 1.3.1(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
expo-network@8.0.8(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
expo: 54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
|
||||
expo-server@1.0.5: {}
|
||||
|
||||
expo-status-bar@3.0.9(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
|
||||
|
|
@ -5621,6 +5713,10 @@ snapshots:
|
|||
dependencies:
|
||||
hermes-estree: 0.33.3
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
dependencies:
|
||||
react-is: 16.13.1
|
||||
|
||||
hosted-git-info@7.0.2:
|
||||
dependencies:
|
||||
lru-cache: 10.4.3
|
||||
|
|
@ -6651,6 +6747,8 @@ snapshots:
|
|||
react-native: 0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)
|
||||
typescript: 5.9.3
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-is@18.3.1: {}
|
||||
|
||||
react-is@19.2.4: {}
|
||||
|
|
@ -6675,6 +6773,14 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@egjs/hammerjs': 2.0.17
|
||||
hoist-non-react-statics: 3.3.2
|
||||
invariant: 2.2.4
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
react-native-is-edge-to-edge@1.3.1(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
|
|
@ -6941,6 +7047,16 @@ snapshots:
|
|||
|
||||
slugify@1.6.8: {}
|
||||
|
||||
sonner-native@0.24.0(3394c02ff2c050721b9b17cc1a65ed8b):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)
|
||||
react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react-native-reanimated: 4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react-native-svg: 15.12.1(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
|
|
|
|||
BIN
VinEye/src/assets/images/logo-2.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
VinEye/src/assets/images/logo-dark.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
|
|
@ -1,41 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
|
||||
<defs>
|
||||
<linearGradient id="leafGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2D6A4F;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#6A0572;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="eyeGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#40916C;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8E24AA;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Feuille de vigne stylisée formant un œil -->
|
||||
<!-- Corps de la feuille (forme ovale penchée = paupières de l'œil) -->
|
||||
<ellipse cx="100" cy="95" rx="75" ry="45" fill="none" stroke="url(#leafGrad)" stroke-width="6"
|
||||
transform="rotate(-15, 100, 95)" />
|
||||
|
||||
<!-- Iris/pupille centrale -->
|
||||
<circle cx="100" cy="95" r="22" fill="url(#eyeGrad)" />
|
||||
<circle cx="100" cy="95" r="10" fill="#1B4332" />
|
||||
<circle cx="107" cy="88" r="4" fill="white" opacity="0.8" />
|
||||
|
||||
<!-- Lobes de la feuille de vigne (3 lobes comme une vraie feuille) -->
|
||||
<!-- Lobe haut gauche -->
|
||||
<path d="M 55 70 Q 40 45 60 35 Q 70 55 55 70 Z" fill="url(#leafGrad)" opacity="0.85" />
|
||||
<!-- Lobe haut droit -->
|
||||
<path d="M 145 70 Q 160 45 140 35 Q 130 55 145 70 Z" fill="url(#leafGrad)" opacity="0.85" />
|
||||
<!-- Lobe haut centre -->
|
||||
<path d="M 100 50 Q 100 25 100 20 Q 108 35 100 50 Z" fill="url(#leafGrad)" opacity="0.9" />
|
||||
|
||||
<!-- Pétiole (queue de la feuille) = pupille / tige centrale -->
|
||||
<line x1="100" y1="140" x2="100" y2="170" stroke="url(#leafGrad)" stroke-width="5" stroke-linecap="round" />
|
||||
|
||||
<!-- Vrilles (spirales décoratives) -->
|
||||
<path d="M 100 165 Q 115 160 120 150 Q 125 140 115 138" fill="none" stroke="url(#eyeGrad)" stroke-width="2.5" stroke-linecap="round" />
|
||||
<path d="M 100 165 Q 85 160 80 150 Q 75 140 85 138" fill="none" stroke="url(#eyeGrad)" stroke-width="2.5" stroke-linecap="round" />
|
||||
|
||||
<!-- Nervures de la feuille -->
|
||||
<line x1="100" y1="95" x2="60" y2="70" stroke="url(#leafGrad)" stroke-width="1.5" opacity="0.4" stroke-linecap="round" />
|
||||
<line x1="100" y1="95" x2="140" y2="70" stroke="url(#leafGrad)" stroke-width="1.5" opacity="0.4" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
131
VinEye/src/components/disease/SeasonTimeline.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { View, StyleSheet } from "react-native";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
interface SeasonTimelineProps {
|
||||
startMonth: number;
|
||||
endMonth: number;
|
||||
peakMonth: number;
|
||||
activeColor: string;
|
||||
}
|
||||
|
||||
const MONTHS = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"];
|
||||
|
||||
export default function SeasonTimeline({
|
||||
startMonth,
|
||||
endMonth,
|
||||
peakMonth,
|
||||
activeColor,
|
||||
}: SeasonTimelineProps) {
|
||||
// Convert 1-indexed months to 0-indexed for display
|
||||
const start = startMonth - 1;
|
||||
const end = endMonth - 1;
|
||||
const peak = peakMonth - 1;
|
||||
|
||||
function isActive(index: number): boolean {
|
||||
if (start <= end) {
|
||||
return index >= start && index <= end;
|
||||
}
|
||||
// Wraps around year (e.g. Nov → Feb)
|
||||
return index >= start || index <= end;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Bar */}
|
||||
<View style={styles.track}>
|
||||
{MONTHS.map((_, index) => {
|
||||
const active = isActive(index);
|
||||
const isPeak = index === peak;
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.segment,
|
||||
active && { backgroundColor: activeColor },
|
||||
isPeak && styles.peakSegment,
|
||||
isPeak && { backgroundColor: activeColor },
|
||||
index === 0 && styles.segmentFirst,
|
||||
index === 11 && styles.segmentLast,
|
||||
]}
|
||||
>
|
||||
{isPeak && <View style={[styles.peakDot, { backgroundColor: "#FFFFFF" }]} />}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Month labels */}
|
||||
<View style={styles.labels}>
|
||||
{MONTHS.map((label, index) => {
|
||||
const active = isActive(index);
|
||||
return (
|
||||
<Text
|
||||
key={index}
|
||||
style={[
|
||||
styles.monthLabel,
|
||||
active && styles.monthLabelActive,
|
||||
index === peak && styles.monthLabelPeak,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 8,
|
||||
},
|
||||
track: {
|
||||
flexDirection: "row",
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: "#F0F0F0",
|
||||
overflow: "hidden",
|
||||
},
|
||||
segment: {
|
||||
flex: 1,
|
||||
backgroundColor: "transparent",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
segmentFirst: {
|
||||
borderTopLeftRadius: 14,
|
||||
borderBottomLeftRadius: 14,
|
||||
},
|
||||
segmentLast: {
|
||||
borderTopRightRadius: 14,
|
||||
borderBottomRightRadius: 14,
|
||||
},
|
||||
peakSegment: {
|
||||
opacity: 1,
|
||||
},
|
||||
peakDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
labels: {
|
||||
flexDirection: "row",
|
||||
marginTop: 6,
|
||||
},
|
||||
monthLabel: {
|
||||
flex: 1,
|
||||
fontSize: 11,
|
||||
fontWeight: "400",
|
||||
color: "#BDBDBD",
|
||||
textAlign: "center",
|
||||
},
|
||||
monthLabelActive: {
|
||||
color: "#6B6B6B",
|
||||
fontWeight: "500",
|
||||
},
|
||||
monthLabelPeak: {
|
||||
fontWeight: "700",
|
||||
color: "#1A1A1A",
|
||||
},
|
||||
});
|
||||
115
VinEye/src/components/guides/AnimatedSegmentedControl.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { useEffect } from "react";
|
||||
import { View, TouchableOpacity, StyleSheet, Platform } from "react-native";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
Easing,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface AnimatedSegmentedControlProps {
|
||||
tabs: string[];
|
||||
activeIndex: number;
|
||||
onTabChange: (index: number) => void;
|
||||
}
|
||||
|
||||
const INDICATOR_PADDING = 3;
|
||||
|
||||
export default function AnimatedSegmentedControl({
|
||||
tabs,
|
||||
activeIndex,
|
||||
onTabChange,
|
||||
}: AnimatedSegmentedControlProps) {
|
||||
const translateX = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
translateX.value = withTiming(activeIndex, {
|
||||
duration: 300,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
||||
});
|
||||
}, [activeIndex]);
|
||||
|
||||
const indicatorStyle = useAnimatedStyle(() => {
|
||||
const tabWidth = 100 / tabs.length;
|
||||
return {
|
||||
width: `${tabWidth}%` as any,
|
||||
left: `${translateX.value * tabWidth}%` as any,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.track}>
|
||||
{/* Animated indicator */}
|
||||
<Animated.View style={[styles.indicator, indicatorStyle]} />
|
||||
|
||||
{/* Tab labels */}
|
||||
{tabs.map((tab, index) => (
|
||||
<TouchableOpacity
|
||||
key={tab}
|
||||
style={styles.tab}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => onTabChange(index)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeIndex === index && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
{tab}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 20,
|
||||
paddingTop: 4,
|
||||
},
|
||||
track: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: "rgba(120, 120, 128, 0.12)",
|
||||
borderRadius: 12,
|
||||
padding: INDICATOR_PADDING,
|
||||
position: "relative",
|
||||
},
|
||||
indicator: {
|
||||
position: "absolute",
|
||||
top: INDICATOR_PADDING,
|
||||
bottom: INDICATOR_PADDING,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 10,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
android: { elevation: 2 },
|
||||
}),
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 15,
|
||||
fontWeight: "500",
|
||||
color: "#8E8E93",
|
||||
},
|
||||
tabTextActive: {
|
||||
color: "#1A1A1A",
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
142
VinEye/src/components/guides/DiseaseCard.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { useState } from "react";
|
||||
import { View, TouchableOpacity, StyleSheet, Platform } from "react-native";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { Disease } from "@/data/diseases";
|
||||
|
||||
interface DiseaseCardProps {
|
||||
disease: Disease;
|
||||
onPress: () => void;
|
||||
style?: object;
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<
|
||||
Disease["severity"],
|
||||
{ bg: string; label: string }
|
||||
> = {
|
||||
high: { bg: "rgba(239, 68, 68, 0.9)", label: "guides.severity.critical" },
|
||||
medium: { bg: "rgba(245, 158, 11, 0.9)", label: "guides.severity.moderate" },
|
||||
low: { bg: "rgba(34, 197, 94, 0.9)", label: "guides.severity.low" },
|
||||
};
|
||||
|
||||
export default function DiseaseCard({ disease, onPress, style }: DiseaseCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const severity = SEVERITY_COLORS[disease.severity];
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const hasImage = disease.images.length > 0 && !imageError;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
onPress={onPress}
|
||||
style={[styles.card, style]}
|
||||
>
|
||||
{/* Background: image or fallback color + icon */}
|
||||
{hasImage ? (
|
||||
<Image
|
||||
source={{ uri: disease.images[0] }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
contentFit="cover"
|
||||
transition={300}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
styles.fallback,
|
||||
{ backgroundColor: disease.bgColor },
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={disease.icon as any}
|
||||
size={56}
|
||||
color={disease.iconColor}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Gradient overlay */}
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.75)"]}
|
||||
start={{ x: 0, y: 0.3 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
|
||||
{/* Severity badge */}
|
||||
<View style={[styles.severityBadge, { backgroundColor: severity.bg }]}>
|
||||
<Text style={styles.severityText}>{t(severity.label)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Text content at bottom */}
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.name} numberOfLines={2}>
|
||||
{t(disease.name)}
|
||||
</Text>
|
||||
<Text style={styles.season} numberOfLines={1}>
|
||||
{t(disease.season)}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
height: 220,
|
||||
borderRadius: 20,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
backgroundColor: "#E0E0E0",
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
},
|
||||
android: { elevation: 4 },
|
||||
}),
|
||||
},
|
||||
fallback: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
severityBadge: {
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
right: 12,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 20,
|
||||
},
|
||||
severityText: {
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
content: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 16,
|
||||
},
|
||||
name: {
|
||||
fontSize: 17,
|
||||
fontWeight: "700",
|
||||
color: "#FFFFFF",
|
||||
lineHeight: 22,
|
||||
},
|
||||
season: {
|
||||
fontSize: 12,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255, 255, 255, 0.8)",
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
138
VinEye/src/components/guides/GuideCard.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { useState } from "react";
|
||||
import { View, TouchableOpacity, StyleSheet, Platform } from "react-native";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { Guide } from "@/data/guides";
|
||||
|
||||
interface GuideCardProps {
|
||||
guide: Guide;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export default function GuideCard({ guide, onPress }: GuideCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const hasImage = !!guide.image && !imageError;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
onPress={onPress}
|
||||
style={styles.card}
|
||||
>
|
||||
{/* Background: image or fallback color + icon */}
|
||||
{hasImage ? (
|
||||
<Image
|
||||
source={{ uri: guide.image }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
contentFit="cover"
|
||||
transition={300}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
styles.fallback,
|
||||
{ backgroundColor: guide.bgColor },
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={guide.icon as any}
|
||||
size={48}
|
||||
color={guide.iconColor}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Gradient overlay */}
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.7)"]}
|
||||
start={{ x: 0, y: 0.25 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
|
||||
{/* Read time badge */}
|
||||
<View style={styles.readTimeBadge}>
|
||||
<Ionicons name="time-outline" size={12} color="#FFFFFF" />
|
||||
<Text style={styles.readTimeText}>
|
||||
{t("common.readTime", { min: guide.readTime })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Text content at bottom */}
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{t(guide.title)}
|
||||
</Text>
|
||||
<Text style={styles.subtitle} numberOfLines={1}>
|
||||
{t(guide.subtitle)}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
height: 180,
|
||||
borderRadius: 20,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
backgroundColor: "#E0E0E0",
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
},
|
||||
android: { elevation: 4 },
|
||||
}),
|
||||
},
|
||||
fallback: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
readTimeBadge: {
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
right: 12,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 20,
|
||||
},
|
||||
readTimeText: {
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
content: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
color: "#FFFFFF",
|
||||
lineHeight: 24,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255, 255, 255, 0.8)",
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,72 +1,40 @@
|
|||
import { View, FlatList, TouchableOpacity, StyleSheet, Platform } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { View, FlatList, StyleSheet } from "react-native";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { colors } from "@/theme/colors";
|
||||
import { VINE_DISEASES } from "@/data/diseases";
|
||||
import SmallDiseaseCard from "@/components/ui/SmallDiseaseCard";
|
||||
import { CarouselCardSkeleton } from "@/components/ui/Skeleton";
|
||||
import type { Disease } from "@/data/diseases";
|
||||
|
||||
const DISEASE_TYPE_KEYS: Record<Disease["type"], string> = {
|
||||
fungal: "diseases.types.fungal",
|
||||
bacterial: "diseases.types.bacterial",
|
||||
pest: "diseases.types.pest",
|
||||
abiotic: "diseases.types.abiotic",
|
||||
};
|
||||
interface FrequentDiseasesProps {
|
||||
diseases: Disease[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const SEVERITY_LEVELS: Record<Disease["severity"], { color: string; label: string }> = {
|
||||
high: { color: "#EF4444", label: "high" },
|
||||
medium: { color: "#F59E0B", label: "medium" },
|
||||
low: { color: "#10B981", label: "low" },
|
||||
};
|
||||
|
||||
export default function FrequentDiseases() {
|
||||
const { t } = useTranslation();
|
||||
export default function FrequentDiseases({ diseases, isLoading }: FrequentDiseasesProps) {
|
||||
if (isLoading && diseases.length === 0) {
|
||||
return (
|
||||
<View style={styles.skeletonRow}>
|
||||
<CarouselCardSkeleton />
|
||||
<CarouselCardSkeleton />
|
||||
<CarouselCardSkeleton />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={VINE_DISEASES}
|
||||
data={diseases}
|
||||
keyExtractor={(item) => item.id}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.listContainer}
|
||||
renderItem={({ item }) => {
|
||||
const severity = SEVERITY_LEVELS[item.severity];
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
style={[styles.card, { shadowColor: item.iconColor }]}
|
||||
>
|
||||
{/* Header: Icon & Severity Badge */}
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={[styles.iconWrapper, { backgroundColor: `${item.iconColor}15` }]}>
|
||||
<Ionicons name={item.icon as any} size={24} color={item.iconColor} />
|
||||
</View>
|
||||
|
||||
<View style={[styles.severityBadge, { backgroundColor: `${severity.color}15` }]}>
|
||||
<View style={[styles.dot, { backgroundColor: severity.color }]} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.cardBody}>
|
||||
<Text style={styles.typeText}>
|
||||
{t(DISEASE_TYPE_KEYS[item.type]).toUpperCase()}
|
||||
</Text>
|
||||
<Text numberOfLines={2} style={styles.nameText}>
|
||||
{t(item.name)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Footer: Action hint */}
|
||||
<View style={styles.cardFooter}>
|
||||
<Text style={styles.moreInfo}>{t("common.details")}</Text>
|
||||
<Ionicons name="chevron-forward" size={12} color={colors.neutral[400]} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
renderItem={({ item, index }) => (
|
||||
<SmallDiseaseCard
|
||||
disease={item}
|
||||
onPress={() => {}}
|
||||
index={index}
|
||||
size="carousel"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -77,73 +45,9 @@ const styles = StyleSheet.create({
|
|||
paddingVertical: 10,
|
||||
gap: 16,
|
||||
},
|
||||
card: {
|
||||
width: 160,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 24,
|
||||
padding: 16,
|
||||
justifyContent: "space-between",
|
||||
// Shadow logic
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 12,
|
||||
},
|
||||
android: {
|
||||
elevation: 6,
|
||||
},
|
||||
}),
|
||||
},
|
||||
cardHeader: {
|
||||
skeletonRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 20,
|
||||
gap: 16,
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 14,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
severityBadge: {
|
||||
padding: 6,
|
||||
borderRadius: 10,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
cardBody: {
|
||||
flex: 1,
|
||||
},
|
||||
typeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: "800",
|
||||
color: colors.neutral[400],
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 4,
|
||||
},
|
||||
nameText: {
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
color: colors.neutral[900],
|
||||
lineHeight: 20,
|
||||
},
|
||||
cardFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginTop: 12,
|
||||
gap: 4,
|
||||
},
|
||||
moreInfo: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
color: colors.neutral[400],
|
||||
}
|
||||
});
|
||||
|
|
@ -67,17 +67,17 @@ export default function HeroScanner() {
|
|||
{/* Main CTA button */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={() => navigation.navigate("Scanner")}
|
||||
onPress={() => navigation.navigate("Main", { screen: "Scanner" })}
|
||||
style={styles.mainButton}
|
||||
>
|
||||
<Text style={styles.buttonText}>{t("home.scanButton")}</Text>
|
||||
<View style={styles.buttonIconWrapper}>
|
||||
{/* <View style={styles.buttonIconWrapper}>
|
||||
<MaterialIcons
|
||||
name="arrow-forward"
|
||||
size={18}
|
||||
color={colors.primary[800]}
|
||||
/>
|
||||
</View>
|
||||
</View> */}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,112 +1,63 @@
|
|||
import { View, TouchableOpacity, StyleSheet, Platform } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { View, StyleSheet, Platform } from "react-native";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { colors } from "@/theme/colors";
|
||||
import { PRACTICAL_GUIDES } from "@/data/guides";
|
||||
import GuideListItem from "@/components/ui/GuideListItem";
|
||||
import { GuideListItemSkeleton } from "@/components/ui/Skeleton";
|
||||
import type { Guide } from "@/data/guides";
|
||||
import type { RootStackParamList } from "@/types/navigation";
|
||||
|
||||
export default function PracticalGuides() {
|
||||
const { t } = useTranslation();
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
interface PracticalGuidesProps {
|
||||
guides: Guide[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function PracticalGuides({ guides, isLoading }: PracticalGuidesProps) {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const items = guides.slice(0, 3);
|
||||
|
||||
if (isLoading && items.length === 0) {
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<GuideListItemSkeleton />
|
||||
<GuideListItemSkeleton />
|
||||
<GuideListItemSkeleton />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{PRACTICAL_GUIDES.map((guide) => (
|
||||
<TouchableOpacity
|
||||
<View style={styles.card}>
|
||||
{items.map((guide, index) => (
|
||||
<GuideListItem
|
||||
key={guide.id}
|
||||
activeOpacity={0.6}
|
||||
style={styles.card}
|
||||
>
|
||||
{/* Icône avec fond translucide assorti */}
|
||||
<View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
{ backgroundColor: `${guide.iconColor}12` } // 12 = ~7% d'opacité
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={guide.icon as any}
|
||||
size={24}
|
||||
color={guide.iconColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Textes */}
|
||||
<View style={styles.textStack}>
|
||||
<Text numberOfLines={1} style={styles.title}>
|
||||
{t(guide.title)}
|
||||
</Text>
|
||||
<Text numberOfLines={1} style={styles.subtitle}>
|
||||
{t(guide.subtitle)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Indicateur d'action discret */}
|
||||
<View style={styles.chevronWrapper}>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={16}
|
||||
color={colors.neutral[300]}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
guide={guide}
|
||||
onPress={() => navigation.navigate("GuideDetail", { guideId: guide.id })}
|
||||
showSeparator={index < items.length - 1}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 12,
|
||||
paddingHorizontal: 4, // Pour ne pas couper l'ombre
|
||||
},
|
||||
card: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 24, // Arrondi plus prononcé style "Bento"
|
||||
padding: 14,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
borderWidth: 1,
|
||||
borderColor: "#F1F1F1",
|
||||
borderColor: "#F0F0F0",
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
android: {
|
||||
elevation: 2,
|
||||
},
|
||||
android: { elevation: 2 },
|
||||
}),
|
||||
},
|
||||
iconContainer: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 18,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
textStack: {
|
||||
flex: 1,
|
||||
marginLeft: 14,
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
color: colors.neutral[900],
|
||||
letterSpacing: -0.3,
|
||||
marginBottom: 2,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: "500",
|
||||
color: colors.neutral[500],
|
||||
},
|
||||
chevronWrapper: {
|
||||
marginLeft: 8,
|
||||
backgroundColor: "#F8F9FA",
|
||||
padding: 6,
|
||||
borderRadius: 12,
|
||||
},
|
||||
});
|
||||
113
VinEye/src/components/my-plants/DateGroupAccordion.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { useEffect } from 'react';
|
||||
import { View, TouchableOpacity, Text, StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown } from 'lucide-react-native';
|
||||
|
||||
import { ScanListItem } from './ScanListItem';
|
||||
import type { ScanRecord } from '@/types/detection';
|
||||
import type { DateGroupKey } from '@/utils/dateGrouping';
|
||||
|
||||
interface DateGroupAccordionProps {
|
||||
groupKey: DateGroupKey;
|
||||
label: string;
|
||||
scans: ScanRecord[];
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
onPressScan: (scan: ScanRecord) => void;
|
||||
onToggleFavorite: (scanId: string) => void;
|
||||
onDeleteScan: (scanId: string) => void;
|
||||
}
|
||||
|
||||
export function DateGroupAccordion({
|
||||
label,
|
||||
scans,
|
||||
isOpen,
|
||||
onToggle,
|
||||
onPressScan,
|
||||
onToggleFavorite,
|
||||
onDeleteScan,
|
||||
}: DateGroupAccordionProps) {
|
||||
const { t } = useTranslation();
|
||||
const rotation = useSharedValue(isOpen ? 0 : -90);
|
||||
|
||||
useEffect(() => {
|
||||
rotation.value = withTiming(isOpen ? 0 : -90, { duration: 250 });
|
||||
}, [isOpen, rotation]);
|
||||
|
||||
const chevronStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${rotation.value}deg` }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
{/* Header */}
|
||||
<TouchableOpacity
|
||||
style={styles.header}
|
||||
onPress={onToggle}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text style={styles.title}>{t(label)}</Text>
|
||||
<Text style={styles.count}>({scans.length})</Text>
|
||||
</View>
|
||||
<Animated.View style={chevronStyle}>
|
||||
<ChevronDown size={18} color="#8E8E93" />
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Content */}
|
||||
{isOpen && (
|
||||
<View style={styles.content}>
|
||||
{scans.map((scan) => (
|
||||
<ScanListItem
|
||||
key={scan.id}
|
||||
scan={scan}
|
||||
onPress={() => onPressScan(scan)}
|
||||
onToggleFavorite={() => onToggleFavorite(scan.id)}
|
||||
onDelete={() => onDeleteScan(scan.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F0F0F0',
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#1A1A1A',
|
||||
},
|
||||
count: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: '#8E8E93',
|
||||
},
|
||||
content: {
|
||||
paddingTop: 4,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
});
|
||||
226
VinEye/src/components/my-plants/ScanListItem.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { useRef } from 'react';
|
||||
import { View, Text, TouchableOpacity, Alert, StyleSheet } from 'react-native';
|
||||
import { Swipeable } from 'react-native-gesture-handler';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image } from 'expo-image';
|
||||
import { Star, StarOff, Trash2 } from 'lucide-react-native';
|
||||
import { toast } from 'sonner-native';
|
||||
|
||||
import { getCepageById } from '@/utils/cepages';
|
||||
import { hapticLight, hapticSuccess } from '@/services/haptics';
|
||||
import { colors } from '@/theme/colors';
|
||||
import type { ScanRecord } from '@/types/detection';
|
||||
|
||||
interface ScanListItemProps {
|
||||
scan: ScanRecord;
|
||||
onPress: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function getPlantName(scan: ScanRecord, t: (key: string) => string): string {
|
||||
if (scan.detection.cepageId) {
|
||||
const c = getCepageById(scan.detection.cepageId);
|
||||
if (c) return c.name.fr;
|
||||
}
|
||||
if (scan.detection.result === 'vine') return t('result.vineDetected');
|
||||
if (scan.detection.result === 'uncertain') return t('result.uncertain');
|
||||
return t('result.notVine');
|
||||
}
|
||||
|
||||
function getStatusLabel(scan: ScanRecord, t: (key: string) => string): string {
|
||||
if (scan.detection.result === 'vine') return t('result.vineDetected');
|
||||
if (scan.detection.result === 'uncertain') return t('result.uncertain');
|
||||
return t('result.notVine');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const FALLBACK_IMAGE = require('../../../assets/logo.png');
|
||||
|
||||
export function ScanListItem({ scan, onPress, onToggleFavorite, onDelete }: ScanListItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const swipeableRef = useRef<Swipeable>(null);
|
||||
const isFav = scan.isFavorite === true;
|
||||
|
||||
function handleFavorite() {
|
||||
onToggleFavorite();
|
||||
hapticSuccess();
|
||||
toast.success(isFav ? t('myPlants.toasts.unfavorited') : t('myPlants.toasts.favorited'));
|
||||
swipeableRef.current?.close();
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
Alert.alert(
|
||||
t('myPlants.actions.deleteConfirmTitle'),
|
||||
t('myPlants.actions.deleteConfirmMessage'),
|
||||
[
|
||||
{ text: t('myPlants.actions.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('myPlants.actions.delete'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
onDelete();
|
||||
hapticSuccess();
|
||||
toast.success(t('myPlants.toasts.deleted'));
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
swipeableRef.current?.close();
|
||||
}
|
||||
|
||||
function renderRightActions() {
|
||||
return (
|
||||
<View style={styles.actionsRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, styles.favoriteBtn]}
|
||||
onPress={handleFavorite}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isFav ? (
|
||||
<StarOff size={20} color="#FFFFFF" />
|
||||
) : (
|
||||
<Star size={20} color="#FFFFFF" />
|
||||
)}
|
||||
<Text style={styles.actionLabel}>
|
||||
{isFav ? t('myPlants.actions.unfavorite') : t('myPlants.actions.favorite')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, styles.deleteBtn]}
|
||||
onPress={handleDelete}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Trash2 size={20} color="#FFFFFF" />
|
||||
<Text style={styles.actionLabel}>{t('myPlants.actions.delete')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Swipeable
|
||||
ref={swipeableRef}
|
||||
renderRightActions={renderRightActions}
|
||||
overshootRight={false}
|
||||
friction={2}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.container}
|
||||
onPress={() => {
|
||||
hapticLight();
|
||||
onPress();
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{/* Image */}
|
||||
<View style={styles.imageWrapper}>
|
||||
<Image
|
||||
source={scan.detection.imageUri ? { uri: scan.detection.imageUri } : FALLBACK_IMAGE}
|
||||
style={styles.image}
|
||||
contentFit={scan.detection.imageUri ? 'cover' : 'contain'}
|
||||
transition={200}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.plantName} numberOfLines={1}>
|
||||
{getPlantName(scan, t)}
|
||||
</Text>
|
||||
<Text style={styles.status} numberOfLines={1}>
|
||||
{getStatusLabel(scan, t)}
|
||||
</Text>
|
||||
<Text style={styles.time}>{formatTime(scan.createdAt)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Favorite star */}
|
||||
{isFav && (
|
||||
<View style={styles.starWrapper}>
|
||||
<Star size={18} color="#FFB800" fill="#FFB800" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Swipeable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: '#F0F0F0',
|
||||
},
|
||||
imageWrapper: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#F8F9FB',
|
||||
},
|
||||
image: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
gap: 2,
|
||||
},
|
||||
plantName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#1A1A1A',
|
||||
},
|
||||
status: {
|
||||
fontSize: 13,
|
||||
color: '#8E8E93',
|
||||
},
|
||||
time: {
|
||||
fontSize: 12,
|
||||
color: '#8E8E93',
|
||||
},
|
||||
starWrapper: {
|
||||
paddingLeft: 8,
|
||||
},
|
||||
// Swipe actions
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: 6,
|
||||
marginRight: 16,
|
||||
},
|
||||
actionBtn: {
|
||||
width: 72,
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
favoriteBtn: {
|
||||
backgroundColor: '#FFB800',
|
||||
borderTopLeftRadius: 12,
|
||||
borderBottomLeftRadius: 12,
|
||||
},
|
||||
deleteBtn: {
|
||||
backgroundColor: '#E63946',
|
||||
borderTopRightRadius: 12,
|
||||
borderBottomRightRadius: 12,
|
||||
},
|
||||
actionLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
112
VinEye/src/components/ui/DiseaseIconBadge.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { useEffect } from "react";
|
||||
import { StyleSheet } from "react-native";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
withSequence,
|
||||
withTiming,
|
||||
withDelay,
|
||||
Easing,
|
||||
} from "react-native-reanimated";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
import { getDiseaseVisual } from "@/utils/diseaseIcons";
|
||||
|
||||
interface DiseaseIconBadgeProps {
|
||||
diseaseId: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
staggerIndex?: number;
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: { container: 44, icon: 24, radius: 14 },
|
||||
md: { container: 56, icon: 26, radius: 16 },
|
||||
lg: { container: 72, icon: 32, radius: 20 },
|
||||
};
|
||||
|
||||
export default function DiseaseIconBadge({
|
||||
diseaseId,
|
||||
size = "md",
|
||||
staggerIndex = 0,
|
||||
}: DiseaseIconBadgeProps) {
|
||||
const visual = getDiseaseVisual(diseaseId);
|
||||
const IconComponent = visual.icon;
|
||||
const dim = SIZES[size];
|
||||
|
||||
const scale = useSharedValue(0);
|
||||
const opacity = useSharedValue(0);
|
||||
|
||||
// Mount animation with stagger
|
||||
useEffect(() => {
|
||||
const delay = staggerIndex * 100;
|
||||
scale.value = withDelay(
|
||||
delay,
|
||||
withSpring(1, { damping: 12, stiffness: 200 }),
|
||||
);
|
||||
opacity.value = withDelay(
|
||||
delay,
|
||||
withTiming(1, { duration: 300 }),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const animStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
function handlePressIn() {
|
||||
scale.value = withSpring(0.85, { damping: 10, stiffness: 200 });
|
||||
}
|
||||
|
||||
function handlePressOut() {
|
||||
scale.value = withSequence(
|
||||
withSpring(1.1, { damping: 10, stiffness: 200 }),
|
||||
withSpring(1, { damping: 12, stiffness: 200 }),
|
||||
);
|
||||
}
|
||||
|
||||
function handleLongPress() {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
scale.value = withSequence(
|
||||
withTiming(0.9, { duration: 80 }),
|
||||
withTiming(1, { duration: 80 }),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[animStyle, { width: dim.container, height: dim.container }]}
|
||||
onTouchStart={handlePressIn}
|
||||
onTouchEnd={handlePressOut}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[visual.bgGradientStart, visual.bgGradientEnd]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[
|
||||
styles.gradient,
|
||||
{
|
||||
width: dim.container,
|
||||
height: dim.container,
|
||||
borderRadius: dim.radius,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<IconComponent
|
||||
size={dim.icon}
|
||||
color={visual.iconColor}
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gradient: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
123
VinEye/src/components/ui/GuideListItem.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { useEffect } from "react";
|
||||
import { View, TouchableOpacity, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withDelay,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { getGuideVisual } from "@/utils/guideIcons";
|
||||
import type { Guide } from "@/data/guides";
|
||||
|
||||
interface GuideListItemProps {
|
||||
guide: Guide;
|
||||
onPress: () => void;
|
||||
showSeparator?: boolean;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export default function GuideListItem({
|
||||
guide,
|
||||
onPress,
|
||||
showSeparator = true,
|
||||
index = 0,
|
||||
}: GuideListItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const visual = getGuideVisual(guide.category);
|
||||
const IconComponent = visual.icon;
|
||||
|
||||
// Stagger animation
|
||||
const opacity = useSharedValue(0);
|
||||
const translateX = useSharedValue(20);
|
||||
|
||||
useEffect(() => {
|
||||
const delay = index * 50;
|
||||
opacity.value = withDelay(delay, withTiming(1, { duration: 200 }));
|
||||
translateX.value = withDelay(delay, withTiming(0, { duration: 200 }));
|
||||
}, []);
|
||||
|
||||
const animStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
|
||||
// Build subtitle
|
||||
const subtitle = t(guide.subtitle);
|
||||
const readTimeLabel = guide.readTime ? `${guide.readTime} min` : "";
|
||||
const subtitleText = subtitle && readTimeLabel
|
||||
? `${subtitle} · ${readTimeLabel}`
|
||||
: subtitle || readTimeLabel;
|
||||
|
||||
return (
|
||||
<Animated.View style={animStyle}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.6}
|
||||
onPress={onPress}
|
||||
style={styles.container}
|
||||
>
|
||||
{/* Icon */}
|
||||
<View style={[styles.iconCircle, { backgroundColor: visual.bgColor }]}>
|
||||
<IconComponent size={22} color={visual.iconColor} strokeWidth={1.8} />
|
||||
</View>
|
||||
|
||||
{/* Text */}
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{t(guide.title)}
|
||||
</Text>
|
||||
{subtitleText ? (
|
||||
<Text style={styles.subtitle} numberOfLines={1}>
|
||||
{subtitleText}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRight size={18} color="#C7C7CC" strokeWidth={2} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Separator (indented to align with text) */}
|
||||
{showSeparator && <View style={styles.separator} />}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
gap: 14,
|
||||
},
|
||||
iconCircle: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#1A1A1A",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: "400",
|
||||
color: "#8E8E93",
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: "#F0F0F0",
|
||||
marginLeft: 74, // 16 padding + 44 icon + 14 gap = indented to text start
|
||||
},
|
||||
});
|
||||
89
VinEye/src/components/ui/Skeleton.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { useEffect } from "react";
|
||||
import { View, StyleSheet } from "react-native";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface SkeletonProps {
|
||||
width: number | string;
|
||||
height: number;
|
||||
borderRadius?: number;
|
||||
style?: object;
|
||||
}
|
||||
|
||||
export default function Skeleton({ width, height, borderRadius = 12, style }: SkeletonProps) {
|
||||
const opacity = useSharedValue(0.4);
|
||||
|
||||
useEffect(() => {
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: 700 }),
|
||||
withTiming(0.4, { duration: 700 }),
|
||||
),
|
||||
-1,
|
||||
false,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const animStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.base,
|
||||
{ width: width as number, height, borderRadius },
|
||||
animStyle,
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiseaseCardSkeleton({ style }: { style?: object }) {
|
||||
return <Skeleton width="100%" height={220} borderRadius={20} style={style} />;
|
||||
}
|
||||
|
||||
export function GuideCardSkeleton() {
|
||||
return <Skeleton width="100%" height={180} borderRadius={20} style={{ marginBottom: 12 }} />;
|
||||
}
|
||||
|
||||
export function CarouselCardSkeleton() {
|
||||
return <Skeleton width={160} height={140} borderRadius={24} />;
|
||||
}
|
||||
|
||||
export function GuideListItemSkeleton() {
|
||||
return (
|
||||
<View style={skeletonStyles.listItem}>
|
||||
<Skeleton width={44} height={44} borderRadius={22} />
|
||||
<View style={skeletonStyles.listItemText}>
|
||||
<Skeleton width="60%" height={16} borderRadius={6} />
|
||||
<Skeleton width="40%" height={13} borderRadius={5} style={{ marginTop: 4 }} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const skeletonStyles = StyleSheet.create({
|
||||
listItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
gap: 14,
|
||||
},
|
||||
listItemText: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
backgroundColor: "#E5E7EB",
|
||||
},
|
||||
});
|
||||
195
VinEye/src/components/ui/SmallDiseaseCard.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { useEffect } from "react";
|
||||
import { View, TouchableOpacity, StyleSheet, Platform } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withDelay,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import DiseaseIconBadge from "@/components/ui/DiseaseIconBadge";
|
||||
import { colors } from "@/theme/colors";
|
||||
import { getDiseaseVisual, getSeverityColor } from "@/utils/diseaseIcons";
|
||||
import type { Disease } from "@/data/diseases";
|
||||
|
||||
interface SmallDiseaseCardProps {
|
||||
disease: Disease;
|
||||
onPress: () => void;
|
||||
index?: number;
|
||||
size?: "carousel" | "grid";
|
||||
}
|
||||
|
||||
const DISEASE_TYPE_KEYS: Record<Disease["type"], string> = {
|
||||
fungal: "diseases.types.fungal",
|
||||
bacterial: "diseases.types.bacterial",
|
||||
pest: "diseases.types.pest",
|
||||
abiotic: "diseases.types.abiotic",
|
||||
};
|
||||
|
||||
export default function SmallDiseaseCard({
|
||||
disease,
|
||||
onPress,
|
||||
index = 0,
|
||||
size = "carousel",
|
||||
}: SmallDiseaseCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const visual = getDiseaseVisual(disease.id);
|
||||
const severityColor = getSeverityColor(disease.severity);
|
||||
|
||||
// Stagger fade-in
|
||||
const cardOpacity = useSharedValue(0);
|
||||
const cardY = useSharedValue(20);
|
||||
|
||||
useEffect(() => {
|
||||
const delay = index * 80;
|
||||
cardOpacity.value = withDelay(delay, withTiming(1, { duration: 350 }));
|
||||
cardY.value = withDelay(delay, withSpring(0, { damping: 14, stiffness: 150 }));
|
||||
}, []);
|
||||
|
||||
const cardAnim = useAnimatedStyle(() => ({
|
||||
opacity: cardOpacity.value,
|
||||
transform: [{ translateY: cardY.value }],
|
||||
}));
|
||||
|
||||
// Severity dot pulse
|
||||
const dotScale = useSharedValue(0);
|
||||
useEffect(() => {
|
||||
dotScale.value = withDelay(
|
||||
index * 80 + 200,
|
||||
withSpring(1, { damping: 8, stiffness: 300 }),
|
||||
);
|
||||
}, []);
|
||||
const dotAnim = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: dotScale.value }],
|
||||
}));
|
||||
|
||||
const isGrid = size === "grid";
|
||||
|
||||
return (
|
||||
<Animated.View style={[cardAnim, isGrid && styles.gridWrapper]}>
|
||||
<LinearGradient
|
||||
colors={[visual.borderGradientStart, visual.borderGradientEnd]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.borderGradient}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.card,
|
||||
{ shadowColor: disease.iconColor },
|
||||
isGrid && styles.cardGrid,
|
||||
]}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.cardHeader}>
|
||||
<DiseaseIconBadge
|
||||
diseaseId={disease.id}
|
||||
size="sm"
|
||||
staggerIndex={index}
|
||||
/>
|
||||
<View style={[styles.severityBadge, { backgroundColor: `${severityColor}15` }]}>
|
||||
<Animated.View
|
||||
style={[styles.dot, { backgroundColor: severityColor }, dotAnim]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.cardBody}>
|
||||
<Text style={styles.typeText}>
|
||||
{t(DISEASE_TYPE_KEYS[disease.type]).toUpperCase()}
|
||||
</Text>
|
||||
<Text numberOfLines={1} style={styles.nameText}>
|
||||
{t(disease.name)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.cardFooter}>
|
||||
<Text style={styles.moreInfo}>{t("common.details")}</Text>
|
||||
<ChevronRight size={12} color={colors.neutral[400]} strokeWidth={2} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gridWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
borderGradient: {
|
||||
borderRadius: 26,
|
||||
padding: 1.5,
|
||||
},
|
||||
card: {
|
||||
width: 160,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 24,
|
||||
padding: 16,
|
||||
justifyContent: "space-between",
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 12,
|
||||
},
|
||||
android: { elevation: 6 },
|
||||
}),
|
||||
},
|
||||
cardGrid: {
|
||||
width: "auto" as unknown as number,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 12,
|
||||
},
|
||||
severityBadge: {
|
||||
padding: 6,
|
||||
borderRadius: 10,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
cardBody: {
|
||||
flex: 1,
|
||||
},
|
||||
typeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: "800",
|
||||
color: colors.neutral[400],
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 4,
|
||||
},
|
||||
nameText: {
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
color: colors.neutral[900],
|
||||
lineHeight: 20,
|
||||
},
|
||||
cardFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginTop: 12,
|
||||
gap: 4,
|
||||
},
|
||||
moreInfo: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
color: colors.neutral[400],
|
||||
},
|
||||
});
|
||||
180
VinEye/src/components/ui/Toast.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { useEffect } from "react";
|
||||
import { View, StyleSheet, Platform } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
WifiOff,
|
||||
} from "lucide-react-native";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withSpring,
|
||||
runOnJS,
|
||||
} from "react-native-reanimated";
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
} from "react-native-gesture-handler";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export type ToastType = "success" | "error" | "warning" | "info" | "offline";
|
||||
|
||||
export interface ToastConfig {
|
||||
type: ToastType;
|
||||
message: string;
|
||||
subtitle?: string;
|
||||
duration?: number;
|
||||
persistent?: boolean;
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
config: ToastConfig | null;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
// Palette Luxueuse : Fond sombre, accents subtils
|
||||
const THEME: Record<ToastType, { iconColor: string }> = {
|
||||
success: { iconColor: "#10B981" }, // Emeraude subtil
|
||||
error: { iconColor: "#EF4444" }, // Rouge soft
|
||||
warning: { iconColor: "#F59E0B" }, // Ambre
|
||||
info: { iconColor: "#60A5FA" }, // Bleu ciel
|
||||
offline: { iconColor: "#94A3B8" }, // Ardoise
|
||||
};
|
||||
|
||||
const ICONS = {
|
||||
success: CheckCircle2,
|
||||
error: XCircle,
|
||||
warning: AlertCircle,
|
||||
info: Info,
|
||||
offline: WifiOff
|
||||
};
|
||||
|
||||
const TAB_BAR_HEIGHT = 70;
|
||||
|
||||
export default function Toast({ config, onDismiss }: ToastProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const translateY = useSharedValue(100);
|
||||
const opacity = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) {
|
||||
translateY.value = withTiming(40, { duration: 250 });
|
||||
opacity.value = withTiming(0, { duration: 200 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Entrée élégante
|
||||
translateY.value = withSpring(0, { damping: 20, stiffness: 120 });
|
||||
opacity.value = withTiming(1, { duration: 400 });
|
||||
|
||||
if (!config.persistent) {
|
||||
const timeout = setTimeout(() => {
|
||||
runOnJS(onDismiss)();
|
||||
}, config.duration ?? 3500);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const containerStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
const swipeGesture = Gesture.Pan()
|
||||
.onEnd((e) => {
|
||||
if (e.translationY > 20 || Math.abs(e.translationX) > 50) {
|
||||
runOnJS(onDismiss)();
|
||||
}
|
||||
});
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const theme = THEME[config.type];
|
||||
const IconComponent = ICONS[config.type];
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.wrapper, { bottom: TAB_BAR_HEIGHT + insets.bottom + 20 }]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<GestureDetector gesture={swipeGesture}>
|
||||
<Animated.View style={[styles.container, containerStyle]}>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={styles.message}>
|
||||
{config.message}
|
||||
</Text>
|
||||
{config.subtitle && (
|
||||
<Text style={styles.subtitle}>
|
||||
{config.subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.iconWrapper}>
|
||||
<IconComponent size={20} color={theme.iconColor} strokeWidth={2.5} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
position: "absolute",
|
||||
left: 20,
|
||||
right: 20,
|
||||
zIndex: 9999,
|
||||
alignItems: "center",
|
||||
},
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
maxWidth: 500,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 20, // Plus arrondi pour le côté organique/moderne
|
||||
backgroundColor: "rgba(23, 23, 23, 0.95)", // Noir profond légèrement transparent
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)", // Bordure fine "glass"
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 20,
|
||||
},
|
||||
android: { elevation: 10 },
|
||||
}),
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
message: {
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: "400",
|
||||
color: "#A1A1AA", // Gris neutre (Zinc 400)
|
||||
marginTop: 2,
|
||||
},
|
||||
iconWrapper: {
|
||||
height: 32,
|
||||
width: 32,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
borderRadius: 10,
|
||||
},
|
||||
});
|
||||
37
VinEye/src/config/api.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// NOTE: Pour que le mobile accede au backend en dev, lancer Next.js avec :
|
||||
// pnpm dev -H 0.0.0.0
|
||||
// Cela rend le serveur accessible sur le reseau local (pas uniquement localhost).
|
||||
|
||||
import Constants from "expo-constants";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
function getBaseUrl(): string {
|
||||
if (!__DEV__) {
|
||||
return "https://vineye-api.example.com/api/mobile";
|
||||
}
|
||||
|
||||
// Expo expose l'IP du PC dev via hostUri (ex: "192.168.1.42:8081")
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const manifest2 = (Constants as any).manifest2;
|
||||
const debuggerHost: string | undefined =
|
||||
Constants.expoConfig?.hostUri ??
|
||||
manifest2?.extra?.expoGo?.debuggerHost;
|
||||
|
||||
if (debuggerHost) {
|
||||
const ip = debuggerHost.split(":")[0];
|
||||
return `http://${ip}:3000/api/mobile`;
|
||||
}
|
||||
|
||||
// Fallback par plateforme
|
||||
if (Platform.OS === "android") {
|
||||
return "http://10.0.2.2:3000/api/mobile";
|
||||
}
|
||||
|
||||
return "http://localhost:3000/api/mobile";
|
||||
}
|
||||
|
||||
export const API_CONFIG = {
|
||||
baseUrl: getBaseUrl(),
|
||||
timeout: 10000,
|
||||
cacheTTL: 3600000,
|
||||
} as const;
|
||||
36
VinEye/src/contexts/NetworkContext.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { createContext, useContext, useEffect, useRef } from "react";
|
||||
import { useNetworkStatus, type NetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
|
||||
interface NetworkContextValue extends NetworkStatus {
|
||||
wasOffline: boolean;
|
||||
}
|
||||
|
||||
const NetworkContext = createContext<NetworkContextValue>({
|
||||
isConnected: true,
|
||||
isInternetReachable: null,
|
||||
connectionType: "unknown",
|
||||
wasOffline: false,
|
||||
});
|
||||
|
||||
export function NetworkProvider({ children }: { children: React.ReactNode }) {
|
||||
const status = useNetworkStatus();
|
||||
const wasOfflineRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!status.isConnected) {
|
||||
wasOfflineRef.current = true;
|
||||
}
|
||||
}, [status.isConnected]);
|
||||
|
||||
return (
|
||||
<NetworkContext.Provider
|
||||
value={{ ...status, wasOffline: wasOfflineRef.current }}
|
||||
>
|
||||
{children}
|
||||
</NetworkContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNetwork(): NetworkContextValue {
|
||||
return useContext(NetworkContext);
|
||||
}
|
||||
25
VinEye/src/contexts/ToastContext.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner-native";
|
||||
import { useNetwork } from "@/contexts/NetworkContext";
|
||||
|
||||
// Auto-show offline/online toasts based on network status
|
||||
export function NetworkToastWatcher({ children }: { children: React.ReactNode }) {
|
||||
const { isConnected } = useNetwork();
|
||||
const prevConnected = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected && prevConnected.current) {
|
||||
toast.error("Mode hors-ligne", {
|
||||
description: "Les donnees en cache seront utilisees",
|
||||
duration: Infinity,
|
||||
id: "offline",
|
||||
});
|
||||
} else if (isConnected && !prevConnected.current) {
|
||||
toast.dismiss("offline");
|
||||
toast.success("Connexion retablie", { duration: 2500 });
|
||||
}
|
||||
prevConnected.current = isConnected;
|
||||
}, [isConnected]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -1,3 +1,9 @@
|
|||
export interface DiseaseTimeline {
|
||||
startMonth: number;
|
||||
endMonth: number;
|
||||
peakMonth: number;
|
||||
}
|
||||
|
||||
export interface Disease {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -10,6 +16,14 @@ export interface Disease {
|
|||
symptoms: string[];
|
||||
treatment: string;
|
||||
season: string;
|
||||
// Detail fields
|
||||
images: string[];
|
||||
timeline: DiseaseTimeline;
|
||||
conditions: string[];
|
||||
preventiveActions: string[];
|
||||
curativeActions: string[];
|
||||
impactedParts: string[];
|
||||
spreadMethod: string;
|
||||
}
|
||||
|
||||
export const VINE_DISEASES: Disease[] = [
|
||||
|
|
@ -29,6 +43,31 @@ export const VINE_DISEASES: Disease[] = [
|
|||
],
|
||||
treatment: "diseases.mildiou.treatment",
|
||||
season: "diseases.mildiou.season",
|
||||
images: [
|
||||
"https://images.unsplash.com/photo-1596142780450-01a1f79c400c?w=800&h=600&fit=crop",
|
||||
"https://images.unsplash.com/photo-1504279577054-acfeccf8fc52?w=800&h=600&fit=crop",
|
||||
],
|
||||
timeline: { startMonth: 5, endMonth: 8, peakMonth: 6 },
|
||||
conditions: [
|
||||
"diseases.mildiou.condition1",
|
||||
"diseases.mildiou.condition2",
|
||||
"diseases.mildiou.condition3",
|
||||
],
|
||||
preventiveActions: [
|
||||
"diseases.mildiou.preventive1",
|
||||
"diseases.mildiou.preventive2",
|
||||
"diseases.mildiou.preventive3",
|
||||
],
|
||||
curativeActions: [
|
||||
"diseases.mildiou.curative1",
|
||||
"diseases.mildiou.curative2",
|
||||
],
|
||||
impactedParts: [
|
||||
"diseases.mildiou.part1",
|
||||
"diseases.mildiou.part2",
|
||||
"diseases.mildiou.part3",
|
||||
],
|
||||
spreadMethod: "diseases.mildiou.spread",
|
||||
},
|
||||
{
|
||||
id: "oidium",
|
||||
|
|
@ -45,6 +84,31 @@ export const VINE_DISEASES: Disease[] = [
|
|||
],
|
||||
treatment: "diseases.oidium.treatment",
|
||||
season: "diseases.oidium.season",
|
||||
images: [
|
||||
"https://images.unsplash.com/photo-1507434965515-61970f2bd7c6?w=800&h=600&fit=crop",
|
||||
"https://images.unsplash.com/photo-1560493676-04071c5f467b?w=800&h=600&fit=crop",
|
||||
],
|
||||
timeline: { startMonth: 4, endMonth: 9, peakMonth: 7 },
|
||||
conditions: [
|
||||
"diseases.oidium.condition1",
|
||||
"diseases.oidium.condition2",
|
||||
"diseases.oidium.condition3",
|
||||
],
|
||||
preventiveActions: [
|
||||
"diseases.oidium.preventive1",
|
||||
"diseases.oidium.preventive2",
|
||||
"diseases.oidium.preventive3",
|
||||
],
|
||||
curativeActions: [
|
||||
"diseases.oidium.curative1",
|
||||
"diseases.oidium.curative2",
|
||||
],
|
||||
impactedParts: [
|
||||
"diseases.oidium.part1",
|
||||
"diseases.oidium.part2",
|
||||
"diseases.oidium.part3",
|
||||
],
|
||||
spreadMethod: "diseases.oidium.spread",
|
||||
},
|
||||
{
|
||||
id: "black_rot",
|
||||
|
|
@ -61,6 +125,31 @@ export const VINE_DISEASES: Disease[] = [
|
|||
],
|
||||
treatment: "diseases.blackRot.treatment",
|
||||
season: "diseases.blackRot.season",
|
||||
images: [
|
||||
"https://images.unsplash.com/photo-1516594915697-87eb3b1c14ea?w=800&h=600&fit=crop",
|
||||
"https://images.unsplash.com/photo-1567306301408-9b74779a11af?w=800&h=600&fit=crop",
|
||||
],
|
||||
timeline: { startMonth: 5, endMonth: 8, peakMonth: 6 },
|
||||
conditions: [
|
||||
"diseases.blackRot.condition1",
|
||||
"diseases.blackRot.condition2",
|
||||
"diseases.blackRot.condition3",
|
||||
],
|
||||
preventiveActions: [
|
||||
"diseases.blackRot.preventive1",
|
||||
"diseases.blackRot.preventive2",
|
||||
"diseases.blackRot.preventive3",
|
||||
],
|
||||
curativeActions: [
|
||||
"diseases.blackRot.curative1",
|
||||
"diseases.blackRot.curative2",
|
||||
],
|
||||
impactedParts: [
|
||||
"diseases.blackRot.part1",
|
||||
"diseases.blackRot.part2",
|
||||
"diseases.blackRot.part3",
|
||||
],
|
||||
spreadMethod: "diseases.blackRot.spread",
|
||||
},
|
||||
{
|
||||
id: "esca",
|
||||
|
|
@ -77,6 +166,32 @@ export const VINE_DISEASES: Disease[] = [
|
|||
],
|
||||
treatment: "diseases.esca.treatment",
|
||||
season: "diseases.esca.season",
|
||||
images: [
|
||||
"https://images.unsplash.com/photo-1506377247377-2a5b3b417ebb?w=800&h=600&fit=crop",
|
||||
"https://images.unsplash.com/photo-1573062337052-54e2d025e7a1?w=800&h=600&fit=crop",
|
||||
],
|
||||
timeline: { startMonth: 6, endMonth: 9, peakMonth: 7 },
|
||||
conditions: [
|
||||
"diseases.esca.condition1",
|
||||
"diseases.esca.condition2",
|
||||
"diseases.esca.condition3",
|
||||
],
|
||||
preventiveActions: [
|
||||
"diseases.esca.preventive1",
|
||||
"diseases.esca.preventive2",
|
||||
"diseases.esca.preventive3",
|
||||
],
|
||||
curativeActions: [
|
||||
"diseases.esca.curative1",
|
||||
"diseases.esca.curative2",
|
||||
"diseases.esca.curative3",
|
||||
],
|
||||
impactedParts: [
|
||||
"diseases.esca.part1",
|
||||
"diseases.esca.part2",
|
||||
"diseases.esca.part3",
|
||||
],
|
||||
spreadMethod: "diseases.esca.spread",
|
||||
},
|
||||
{
|
||||
id: "botrytis",
|
||||
|
|
@ -93,6 +208,30 @@ export const VINE_DISEASES: Disease[] = [
|
|||
],
|
||||
treatment: "diseases.botrytis.treatment",
|
||||
season: "diseases.botrytis.season",
|
||||
images: [
|
||||
"https://images.unsplash.com/photo-1474722883778-792e7990302f?w=800&h=600&fit=crop",
|
||||
"https://images.unsplash.com/photo-1508472697919-afcacb6e1bcc?w=800&h=600&fit=crop",
|
||||
],
|
||||
timeline: { startMonth: 7, endMonth: 10, peakMonth: 9 },
|
||||
conditions: [
|
||||
"diseases.botrytis.condition1",
|
||||
"diseases.botrytis.condition2",
|
||||
"diseases.botrytis.condition3",
|
||||
],
|
||||
preventiveActions: [
|
||||
"diseases.botrytis.preventive1",
|
||||
"diseases.botrytis.preventive2",
|
||||
"diseases.botrytis.preventive3",
|
||||
],
|
||||
curativeActions: [
|
||||
"diseases.botrytis.curative1",
|
||||
"diseases.botrytis.curative2",
|
||||
],
|
||||
impactedParts: [
|
||||
"diseases.botrytis.part1",
|
||||
"diseases.botrytis.part2",
|
||||
],
|
||||
spreadMethod: "diseases.botrytis.spread",
|
||||
},
|
||||
{
|
||||
id: "flavescence_doree",
|
||||
|
|
@ -109,6 +248,31 @@ export const VINE_DISEASES: Disease[] = [
|
|||
],
|
||||
treatment: "diseases.flavescence.treatment",
|
||||
season: "diseases.flavescence.season",
|
||||
images: [
|
||||
"https://images.unsplash.com/photo-1510812431401-41d2bd2722f3?w=800&h=600&fit=crop",
|
||||
"https://images.unsplash.com/photo-1566903451935-7bc0ddd0e8e6?w=800&h=600&fit=crop",
|
||||
],
|
||||
timeline: { startMonth: 6, endMonth: 10, peakMonth: 8 },
|
||||
conditions: [
|
||||
"diseases.flavescence.condition1",
|
||||
"diseases.flavescence.condition2",
|
||||
"diseases.flavescence.condition3",
|
||||
],
|
||||
preventiveActions: [
|
||||
"diseases.flavescence.preventive1",
|
||||
"diseases.flavescence.preventive2",
|
||||
"diseases.flavescence.preventive3",
|
||||
],
|
||||
curativeActions: [
|
||||
"diseases.flavescence.curative1",
|
||||
"diseases.flavescence.curative2",
|
||||
],
|
||||
impactedParts: [
|
||||
"diseases.flavescence.part1",
|
||||
"diseases.flavescence.part2",
|
||||
"diseases.flavescence.part3",
|
||||
],
|
||||
spreadMethod: "diseases.flavescence.spread",
|
||||
},
|
||||
{
|
||||
id: "chlorose",
|
||||
|
|
@ -125,5 +289,32 @@ export const VINE_DISEASES: Disease[] = [
|
|||
],
|
||||
treatment: "diseases.chlorose.treatment",
|
||||
season: "diseases.chlorose.season",
|
||||
images: [
|
||||
"https://images.unsplash.com/photo-1597916829826-02e5bb4a54e0?w=800&h=600&fit=crop",
|
||||
"https://images.unsplash.com/photo-1563514227147-6d2ff665a6a0?w=800&h=600&fit=crop",
|
||||
],
|
||||
timeline: { startMonth: 4, endMonth: 7, peakMonth: 5 },
|
||||
conditions: [
|
||||
"diseases.chlorose.condition1",
|
||||
"diseases.chlorose.condition2",
|
||||
"diseases.chlorose.condition3",
|
||||
],
|
||||
preventiveActions: [
|
||||
"diseases.chlorose.preventive1",
|
||||
"diseases.chlorose.preventive2",
|
||||
"diseases.chlorose.preventive3",
|
||||
],
|
||||
curativeActions: [
|
||||
"diseases.chlorose.curative1",
|
||||
"diseases.chlorose.curative2",
|
||||
],
|
||||
impactedParts: [
|
||||
"diseases.chlorose.part1",
|
||||
],
|
||||
spreadMethod: "diseases.chlorose.spread",
|
||||
},
|
||||
];
|
||||
|
||||
export function getDiseaseById(id: string): Disease | undefined {
|
||||
return VINE_DISEASES.find((d) => d.id === id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
export interface GuideSection {
|
||||
title: string;
|
||||
body: string;
|
||||
image?: string;
|
||||
tip?: string;
|
||||
}
|
||||
|
||||
export interface Guide {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -5,6 +12,11 @@ export interface Guide {
|
|||
icon: string;
|
||||
iconColor: string;
|
||||
bgColor: string;
|
||||
// Detail fields
|
||||
category: "beginner" | "treatment" | "varieties" | "seasonal";
|
||||
readTime: number;
|
||||
image: string;
|
||||
content: GuideSection[];
|
||||
}
|
||||
|
||||
export const PRACTICAL_GUIDES: Guide[] = [
|
||||
|
|
@ -15,6 +27,26 @@ export const PRACTICAL_GUIDES: Guide[] = [
|
|||
icon: "happy-outline",
|
||||
iconColor: "#1D9E75",
|
||||
bgColor: "#E1F5EE",
|
||||
category: "beginner",
|
||||
readTime: 5,
|
||||
image: "https://images.unsplash.com/photo-1596142780450-01a1f79c400c?w=800&h=600&fit=crop",
|
||||
content: [
|
||||
{
|
||||
title: "guides.healthyLeaf.sections.colorTexture.title",
|
||||
body: "guides.healthyLeaf.sections.colorTexture.body",
|
||||
image: "https://images.unsplash.com/photo-1504279577054-acfeccf8fc52?w=800&h=600&fit=crop",
|
||||
tip: "guides.healthyLeaf.sections.colorTexture.tip",
|
||||
},
|
||||
{
|
||||
title: "guides.healthyLeaf.sections.shape.title",
|
||||
body: "guides.healthyLeaf.sections.shape.body",
|
||||
},
|
||||
{
|
||||
title: "guides.healthyLeaf.sections.warning.title",
|
||||
body: "guides.healthyLeaf.sections.warning.body",
|
||||
tip: "guides.healthyLeaf.sections.warning.tip",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "treatment_calendar",
|
||||
|
|
@ -23,6 +55,29 @@ export const PRACTICAL_GUIDES: Guide[] = [
|
|||
icon: "book-outline",
|
||||
iconColor: "#185FA5",
|
||||
bgColor: "#E6F1FB",
|
||||
category: "treatment",
|
||||
readTime: 8,
|
||||
image: "https://images.unsplash.com/photo-1560493676-04071c5f467b?w=800&h=600&fit=crop",
|
||||
content: [
|
||||
{
|
||||
title: "guides.treatmentCalendar.sections.winter.title",
|
||||
body: "guides.treatmentCalendar.sections.winter.body",
|
||||
},
|
||||
{
|
||||
title: "guides.treatmentCalendar.sections.spring.title",
|
||||
body: "guides.treatmentCalendar.sections.spring.body",
|
||||
tip: "guides.treatmentCalendar.sections.spring.tip",
|
||||
},
|
||||
{
|
||||
title: "guides.treatmentCalendar.sections.summer.title",
|
||||
body: "guides.treatmentCalendar.sections.summer.body",
|
||||
tip: "guides.treatmentCalendar.sections.summer.tip",
|
||||
},
|
||||
{
|
||||
title: "guides.treatmentCalendar.sections.autumn.title",
|
||||
body: "guides.treatmentCalendar.sections.autumn.body",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "grape_varieties",
|
||||
|
|
@ -31,5 +86,27 @@ export const PRACTICAL_GUIDES: Guide[] = [
|
|||
icon: "wine-outline",
|
||||
iconColor: "#534AB7",
|
||||
bgColor: "#EEEDFE",
|
||||
category: "varieties",
|
||||
readTime: 6,
|
||||
image: "https://images.unsplash.com/photo-1567306301408-9b74779a11af?w=800&h=600&fit=crop",
|
||||
content: [
|
||||
{
|
||||
title: "guides.grapeVarieties.sections.reds.title",
|
||||
body: "guides.grapeVarieties.sections.reds.body",
|
||||
},
|
||||
{
|
||||
title: "guides.grapeVarieties.sections.whites.title",
|
||||
body: "guides.grapeVarieties.sections.whites.body",
|
||||
},
|
||||
{
|
||||
title: "guides.grapeVarieties.sections.choosing.title",
|
||||
body: "guides.grapeVarieties.sections.choosing.body",
|
||||
tip: "guides.grapeVarieties.sections.choosing.tip",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getGuideById(id: string): Guide | undefined {
|
||||
return PRACTICAL_GUIDES.find((g) => g.id === id);
|
||||
}
|
||||
|
|
|
|||
134
VinEye/src/hooks/useCachedApiData.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { toast } from "sonner-native";
|
||||
import type { ApiResponse } from "@/services/api/client";
|
||||
import { cacheGet, cacheSet } from "@/services/cache/cacheManager";
|
||||
|
||||
type DataSource = "api" | "cache" | "local";
|
||||
|
||||
interface UseCachedApiDataResult<T> {
|
||||
data: T[];
|
||||
isLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
error: string | null;
|
||||
source: DataSource;
|
||||
refresh: () => Promise<void>;
|
||||
lastSyncedAt: Date | null;
|
||||
}
|
||||
|
||||
interface UseCachedApiDataConfig<TApi, TLocal> {
|
||||
cacheKey: string;
|
||||
fetchFn: () => Promise<ApiResponse<TApi[]>>;
|
||||
mapFn: (item: TApi) => TLocal;
|
||||
fallbackData: TLocal[];
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export function useCachedApiData<TApi, TLocal>({
|
||||
cacheKey,
|
||||
fetchFn,
|
||||
mapFn,
|
||||
fallbackData,
|
||||
ttl,
|
||||
}: UseCachedApiDataConfig<TApi, TLocal>): UseCachedApiDataResult<TLocal> {
|
||||
const [data, setData] = useState<TLocal[]>(fallbackData);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [source, setSource] = useState<DataSource>("local");
|
||||
const [lastSyncedAt, setLastSyncedAt] = useState<Date | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const initialLoadDone = useRef(false);
|
||||
|
||||
const syncKey = `${cacheKey}_lastSync`;
|
||||
|
||||
const loadData = useCallback(
|
||||
async (forceRefresh = false) => {
|
||||
if (__DEV__) console.log(`[useCachedApiData] ${cacheKey} — loading (force: ${forceRefresh})`);
|
||||
if (forceRefresh) setIsRefreshing(true);
|
||||
|
||||
try {
|
||||
// 1. Check cache first (unless force refresh)
|
||||
if (!forceRefresh) {
|
||||
const cached = await cacheGet<TLocal[]>(cacheKey);
|
||||
if (cached) {
|
||||
if (mountedRef.current) {
|
||||
setData(cached);
|
||||
setSource("cache");
|
||||
setIsLoading(false);
|
||||
|
||||
const syncTs = await cacheGet<number>(syncKey);
|
||||
if (syncTs) setLastSyncedAt(new Date(syncTs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch from API
|
||||
const result = await fetchFn();
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
if (result.success) {
|
||||
const mapped = result.data.map(mapFn);
|
||||
setData(mapped);
|
||||
setSource("api");
|
||||
setError(null);
|
||||
setLastSyncedAt(new Date());
|
||||
|
||||
await cacheSet(cacheKey, mapped, ttl);
|
||||
await cacheSet(syncKey, Date.now(), ttl);
|
||||
|
||||
if (forceRefresh) {
|
||||
toast.success("Donnees mises a jour");
|
||||
}
|
||||
} else {
|
||||
// API failed — use cache fallback (even expired)
|
||||
if (source !== "cache") {
|
||||
const expired = await cacheGet<TLocal[]>(cacheKey);
|
||||
if (expired && mountedRef.current) {
|
||||
setData(expired);
|
||||
setSource("cache");
|
||||
if (initialLoadDone.current) {
|
||||
toast("Donnees hors-ligne", {
|
||||
description: "Derniere version en cache utilisee",
|
||||
});
|
||||
}
|
||||
} else if (mountedRef.current) {
|
||||
setData(fallbackData);
|
||||
setSource("local");
|
||||
if (initialLoadDone.current) {
|
||||
toast.warning("Mode hors-ligne", {
|
||||
description: "Donnees locales utilisees",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
setError(result.error.message);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
if (__DEV__) console.log(`[useCachedApiData] ${cacheKey} — done, source: ${source}`);
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
initialLoadDone.current = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
[cacheKey, fetchFn, mapFn, fallbackData, ttl, syncKey, source],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
loadData();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const refresh = useCallback(() => loadData(true), [loadData]);
|
||||
|
||||
return { data, isLoading, isRefreshing, error, source, refresh, lastSyncedAt };
|
||||
}
|
||||
66
VinEye/src/hooks/useDiseaseDetail.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { fetchDiseaseBySlug } from "@/services/api/diseases";
|
||||
import { mapApiDiseaseToLocal } from "@/services/api/mappers";
|
||||
import { cacheGet, cacheSet } from "@/services/cache/cacheManager";
|
||||
import { getDiseaseById, type Disease } from "@/data/diseases";
|
||||
|
||||
type DataSource = "api" | "cache" | "local";
|
||||
|
||||
interface UseDiseaseDetailResult {
|
||||
disease: Disease | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
source: DataSource;
|
||||
}
|
||||
|
||||
export function useDiseaseDetail(diseaseId: string): UseDiseaseDetailResult {
|
||||
const [disease, setDisease] = useState<Disease | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [source, setSource] = useState<DataSource>("local");
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const cacheKey = `diseases_${diseaseId}`;
|
||||
|
||||
async function load() {
|
||||
// 1. Check cache
|
||||
const cached = await cacheGet<Disease>(cacheKey);
|
||||
if (cached && mountedRef.current) {
|
||||
setDisease(cached);
|
||||
setSource("cache");
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
// 2. Fetch from API
|
||||
const slug = diseaseId.replace(/_/g, "-");
|
||||
const result = await fetchDiseaseBySlug(slug);
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
if (result.success) {
|
||||
const mapped = mapApiDiseaseToLocal(result.data);
|
||||
setDisease(mapped);
|
||||
setSource("api");
|
||||
setError(null);
|
||||
await cacheSet(cacheKey, mapped);
|
||||
} else if (!cached) {
|
||||
// No API, no cache → fallback to local data
|
||||
const local = getDiseaseById(diseaseId);
|
||||
if (local && mountedRef.current) {
|
||||
setDisease(local);
|
||||
setSource("local");
|
||||
}
|
||||
setError(result.error.message);
|
||||
}
|
||||
|
||||
if (mountedRef.current) setIsLoading(false);
|
||||
}
|
||||
|
||||
load();
|
||||
return () => { mountedRef.current = false; };
|
||||
}, [diseaseId]);
|
||||
|
||||
return { disease, isLoading, error, source };
|
||||
}
|
||||
25
VinEye/src/hooks/useDiseases.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { useMemo } from "react";
|
||||
import { fetchDiseases } from "@/services/api/diseases";
|
||||
import { mapApiDiseaseToLocal } from "@/services/api/mappers";
|
||||
import { VINE_DISEASES } from "@/data/diseases";
|
||||
import { useCachedApiData } from "./useCachedApiData";
|
||||
|
||||
interface DiseaseQueryParams {
|
||||
severity?: string;
|
||||
type?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export function useDiseases(params?: DiseaseQueryParams) {
|
||||
const fetchFn = useMemo(
|
||||
() => () => fetchDiseases(params),
|
||||
[params?.severity, params?.type, params?.search],
|
||||
);
|
||||
|
||||
return useCachedApiData({
|
||||
cacheKey: "diseases_list",
|
||||
fetchFn,
|
||||
mapFn: mapApiDiseaseToLocal,
|
||||
fallbackData: VINE_DISEASES,
|
||||
});
|
||||
}
|
||||
66
VinEye/src/hooks/useGuideDetail.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { fetchGuideBySlug } from "@/services/api/guides";
|
||||
import { mapApiGuideToLocal } from "@/services/api/mappers";
|
||||
import { cacheGet, cacheSet } from "@/services/cache/cacheManager";
|
||||
import { getGuideById, type Guide } from "@/data/guides";
|
||||
|
||||
type DataSource = "api" | "cache" | "local";
|
||||
|
||||
interface UseGuideDetailResult {
|
||||
guide: Guide | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
source: DataSource;
|
||||
}
|
||||
|
||||
export function useGuideDetail(guideId: string): UseGuideDetailResult {
|
||||
const [guide, setGuide] = useState<Guide | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [source, setSource] = useState<DataSource>("local");
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const cacheKey = `guides_${guideId}`;
|
||||
|
||||
async function load() {
|
||||
// 1. Check cache
|
||||
const cached = await cacheGet<Guide>(cacheKey);
|
||||
if (cached && mountedRef.current) {
|
||||
setGuide(cached);
|
||||
setSource("cache");
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
// 2. Fetch from API
|
||||
const slug = guideId.replace(/_/g, "-");
|
||||
const result = await fetchGuideBySlug(slug);
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
if (result.success) {
|
||||
const mapped = mapApiGuideToLocal(result.data);
|
||||
setGuide(mapped);
|
||||
setSource("api");
|
||||
setError(null);
|
||||
await cacheSet(cacheKey, mapped);
|
||||
} else if (!cached) {
|
||||
// No API, no cache → fallback to local data
|
||||
const local = getGuideById(guideId);
|
||||
if (local && mountedRef.current) {
|
||||
setGuide(local);
|
||||
setSource("local");
|
||||
}
|
||||
setError(result.error.message);
|
||||
}
|
||||
|
||||
if (mountedRef.current) setIsLoading(false);
|
||||
}
|
||||
|
||||
load();
|
||||
return () => { mountedRef.current = false; };
|
||||
}, [guideId]);
|
||||
|
||||
return { guide, isLoading, error, source };
|
||||
}
|
||||
24
VinEye/src/hooks/useGuides.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useMemo } from "react";
|
||||
import { fetchGuides } from "@/services/api/guides";
|
||||
import { mapApiGuideToLocal } from "@/services/api/mappers";
|
||||
import { PRACTICAL_GUIDES } from "@/data/guides";
|
||||
import { useCachedApiData } from "./useCachedApiData";
|
||||
|
||||
interface GuideQueryParams {
|
||||
category?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export function useGuides(params?: GuideQueryParams) {
|
||||
const fetchFn = useMemo(
|
||||
() => () => fetchGuides(params),
|
||||
[params?.category, params?.search],
|
||||
);
|
||||
|
||||
return useCachedApiData({
|
||||
cacheKey: "guides_list",
|
||||
fetchFn,
|
||||
mapFn: mapApiGuideToLocal,
|
||||
fallbackData: PRACTICAL_GUIDES,
|
||||
});
|
||||
}
|
||||
|
|
@ -6,16 +6,16 @@ export function useHistory() {
|
|||
const [history, setHistory] = useState<ScanRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, []);
|
||||
|
||||
async function loadHistory() {
|
||||
const loadHistory = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
const saved = await storage.get<ScanRecord[]>(storage.KEYS.SCAN_HISTORY);
|
||||
setHistory(saved ?? []);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, [loadHistory]);
|
||||
|
||||
const addScan = useCallback(async (record: ScanRecord) => {
|
||||
setHistory((prev) => {
|
||||
|
|
@ -33,10 +33,20 @@ export function useHistory() {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const toggleFavorite = useCallback(async (id: string) => {
|
||||
setHistory((prev) => {
|
||||
const updated = prev.map((r) =>
|
||||
r.id === id ? { ...r, isFavorite: !r.isFavorite } : r
|
||||
);
|
||||
storage.set(storage.KEYS.SCAN_HISTORY, updated);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearHistory = useCallback(async () => {
|
||||
await storage.remove(storage.KEYS.SCAN_HISTORY);
|
||||
setHistory([]);
|
||||
}, []);
|
||||
|
||||
return { history, isLoading, addScan, deleteScan, clearHistory, reload: loadHistory };
|
||||
return { history, isLoading, addScan, deleteScan, toggleFavorite, clearHistory, reload: loadHistory };
|
||||
}
|
||||
|
|
|
|||
43
VinEye/src/hooks/useNetworkStatus.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import * as Network from "expo-network";
|
||||
|
||||
export interface NetworkStatus {
|
||||
isConnected: boolean;
|
||||
isInternetReachable: boolean | null;
|
||||
connectionType: string;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL = 5000;
|
||||
|
||||
export function useNetworkStatus(): NetworkStatus {
|
||||
const [status, setStatus] = useState<NetworkStatus>({
|
||||
isConnected: true,
|
||||
isInternetReachable: null,
|
||||
connectionType: "unknown",
|
||||
});
|
||||
|
||||
const checkNetwork = useCallback(async () => {
|
||||
try {
|
||||
const state = await Network.getNetworkStateAsync();
|
||||
setStatus({
|
||||
isConnected: state.isConnected ?? false,
|
||||
isInternetReachable: state.isInternetReachable ?? null,
|
||||
connectionType: state.type ?? "unknown",
|
||||
});
|
||||
} catch {
|
||||
setStatus({
|
||||
isConnected: false,
|
||||
isInternetReachable: false,
|
||||
connectionType: "unknown",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkNetwork();
|
||||
const interval = setInterval(checkNetwork, POLL_INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [checkNetwork]);
|
||||
|
||||
return status;
|
||||
}
|
||||
42
VinEye/src/hooks/useScanDetail.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { storage } from '@/services/storage';
|
||||
import type { ScanRecord } from '@/types/detection';
|
||||
|
||||
export function useScanDetail(scanId: string) {
|
||||
const [scan, setScan] = useState<ScanRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const all = await storage.get<ScanRecord[]>(storage.KEYS.SCAN_HISTORY);
|
||||
const found = all?.find((s) => s.id === scanId) ?? null;
|
||||
if (!found) setError('Scan not found');
|
||||
setScan(found);
|
||||
setLoading(false);
|
||||
}, [scanId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const toggleFavorite = useCallback(async () => {
|
||||
const all = await storage.get<ScanRecord[]>(storage.KEYS.SCAN_HISTORY);
|
||||
if (!all) return;
|
||||
const updated = all.map((s) =>
|
||||
s.id === scanId ? { ...s, isFavorite: !s.isFavorite } : s,
|
||||
);
|
||||
await storage.set(storage.KEYS.SCAN_HISTORY, updated);
|
||||
setScan((prev) => (prev ? { ...prev, isFavorite: !prev.isFavorite } : prev));
|
||||
}, [scanId]);
|
||||
|
||||
const deleteScan = useCallback(async () => {
|
||||
const all = await storage.get<ScanRecord[]>(storage.KEYS.SCAN_HISTORY);
|
||||
if (!all) return;
|
||||
const updated = all.filter((s) => s.id !== scanId);
|
||||
await storage.set(storage.KEYS.SCAN_HISTORY, updated);
|
||||
}, [scanId]);
|
||||
|
||||
return { scan, loading, error, toggleFavorite, deleteScan, refetch: load };
|
||||
}
|
||||
|
|
@ -13,7 +13,14 @@
|
|||
"map": "Map",
|
||||
"notifications": "Notifications",
|
||||
"settings": "Settings",
|
||||
"details": "Details"
|
||||
"details": "Details",
|
||||
"readTime": "{{min}} min read",
|
||||
"conditions": "Favorable conditions",
|
||||
"prevention": "Prevention",
|
||||
"curativeActions": "Curative actions",
|
||||
"impactedParts": "Affected parts",
|
||||
"spreadMethod": "Spread method",
|
||||
"timeline": "Active period"
|
||||
},
|
||||
"home": {
|
||||
"greeting": "Hello, Winemaker!",
|
||||
|
|
@ -52,7 +59,19 @@
|
|||
"symptom2": "White cottony down on the underside",
|
||||
"symptom3": "Drying and premature leaf drop",
|
||||
"treatment": "Preventive copper-based treatment (Bordeaux mixture). Apply before rain, renew every 10-14 days.",
|
||||
"season": "May to August — favored by heat and humidity"
|
||||
"season": "May to August — favored by heat and humidity",
|
||||
"condition1": "Humidity above 80%",
|
||||
"condition2": "Temperatures between 18 and 25°C",
|
||||
"condition3": "Frequent spring rains",
|
||||
"preventive1": "Preventive copper treatment (Bordeaux mixture)",
|
||||
"preventive2": "Improve air circulation through shoot thinning",
|
||||
"preventive3": "Avoid excess nitrogen fertilization",
|
||||
"curative1": "Apply a registered systemic fungicide",
|
||||
"curative2": "Remove severely affected leaves",
|
||||
"part1": "Leaves",
|
||||
"part2": "Clusters",
|
||||
"part3": "Shoots",
|
||||
"spread": "Spores dispersed by wind and rain splash"
|
||||
},
|
||||
"oidium": {
|
||||
"name": "Powdery mildew",
|
||||
|
|
@ -60,7 +79,19 @@
|
|||
"symptom1": "White-grey powder on leaves and clusters",
|
||||
"symptom2": "Berries that crack or dry out",
|
||||
"treatment": "Sulfur dusting or spraying. Preventive treatments from bud break.",
|
||||
"season": "April to September — favored by warm, dry weather"
|
||||
"season": "April to September — favored by warm, dry weather",
|
||||
"condition1": "Hot and dry weather",
|
||||
"condition2": "Temperatures between 25 and 30°C",
|
||||
"condition3": "High day/night temperature difference",
|
||||
"preventive1": "Preventive sulfur treatment",
|
||||
"preventive2": "Promote cluster ventilation",
|
||||
"preventive3": "Moderate leaf removal",
|
||||
"curative1": "Apply an anti-powdery mildew fungicide",
|
||||
"curative2": "Wettable sulfur as curative treatment",
|
||||
"part1": "Leaves",
|
||||
"part2": "Clusters",
|
||||
"part3": "Young shoots",
|
||||
"spread": "Spores carried by wind"
|
||||
},
|
||||
"blackRot": {
|
||||
"name": "Black rot",
|
||||
|
|
@ -68,23 +99,59 @@
|
|||
"symptom1": "Circular brown spots bordered with black on leaves",
|
||||
"symptom2": "Mummified, black and wrinkled berries",
|
||||
"treatment": "Remove mummified berries. Preventive fungicide treatments in spring.",
|
||||
"season": "May to July — favored by spring rains"
|
||||
"season": "May to July — favored by spring rains",
|
||||
"condition1": "Spring rainfall",
|
||||
"condition2": "Temperatures between 20 and 30°C",
|
||||
"condition3": "Presence of mummified berries from the previous year",
|
||||
"preventive1": "Remove mummies (dried clusters) in winter",
|
||||
"preventive2": "Preventive fungicide treatments from flowering",
|
||||
"preventive3": "Maintain good air circulation",
|
||||
"curative1": "No effective curative treatment",
|
||||
"curative2": "Remove and destroy affected organs",
|
||||
"part1": "Leaves",
|
||||
"part2": "Clusters",
|
||||
"part3": "Tendrils",
|
||||
"spread": "Spores released from mummies by rain"
|
||||
},
|
||||
"esca": {
|
||||
"name": "Esca",
|
||||
"description": "Esca is a complex of wood diseases caused by several fungi. A chronic disease that can kill the vine.",
|
||||
"symptom1": "Discoloration between leaf veins (striped appearance)",
|
||||
"symptom1": "Discoloration between leaf veins (tiger stripe pattern)",
|
||||
"symptom2": "Sudden drying of foliage (apoplexy)",
|
||||
"treatment": "No curative treatment. Cutting back affected vine. Protect pruning wounds.",
|
||||
"season": "Symptoms visible in summer — June to September"
|
||||
"season": "Symptoms visible in summer — June to September",
|
||||
"condition1": "Old vines (over 10 years)",
|
||||
"condition2": "Water stress",
|
||||
"condition3": "Poorly healed pruning wounds",
|
||||
"preventive1": "Protect pruning wounds with sealant paste",
|
||||
"preventive2": "Prune late in the season",
|
||||
"preventive3": "Avoid large cuts",
|
||||
"curative1": "No registered curative treatment",
|
||||
"curative2": "Wood curettage (experimental technique)",
|
||||
"curative3": "Trunk renewal if the vine is not too affected",
|
||||
"part1": "Leaves",
|
||||
"part2": "Wood (trunk, arms)",
|
||||
"part3": "Clusters (apoplexy)",
|
||||
"spread": "Fungi enter through pruning wounds"
|
||||
},
|
||||
"botrytis": {
|
||||
"name": "Botrytis",
|
||||
"description": "Grey rot is caused by Botrytis cinerea. It attacks clusters at maturity.",
|
||||
"name": "Botrytis (Grey mold)",
|
||||
"description": "Grey mold is caused by Botrytis cinerea. It attacks clusters at maturity.",
|
||||
"symptom1": "Soft grey rot on berries",
|
||||
"symptom2": "Characteristic grey felt on clusters",
|
||||
"treatment": "Promote cluster aeration. Leaf removal. Anti-botrytis treatments before cluster closure.",
|
||||
"season": "August to harvest — favored by humidity"
|
||||
"season": "August to harvest — favored by humidity",
|
||||
"condition1": "Prolonged high humidity",
|
||||
"condition2": "Temperatures between 15 and 25°C",
|
||||
"condition3": "Compact, tight clusters",
|
||||
"preventive1": "Leaf removal around clusters",
|
||||
"preventive2": "Choose varieties with loose clusters",
|
||||
"preventive3": "Limit vine vigor",
|
||||
"curative1": "Apply a registered anti-botrytis product",
|
||||
"curative2": "Harvest affected parts quickly",
|
||||
"part1": "Clusters (berries)",
|
||||
"part2": "Leaves (rare)",
|
||||
"spread": "Airborne spores, favored by berry wounds"
|
||||
},
|
||||
"flavescence": {
|
||||
"name": "Flavescence dorée",
|
||||
|
|
@ -92,7 +159,19 @@
|
|||
"symptom1": "Leaf rolling with yellow or red coloration depending on variety",
|
||||
"symptom2": "Non-lignification of shoots (remain rubbery)",
|
||||
"treatment": "Mandatory uprooting of contaminated vines. Insecticide treatment against the vector leafhopper.",
|
||||
"season": "Symptoms visible from July"
|
||||
"season": "Symptoms visible from July",
|
||||
"condition1": "Presence of the leafhopper Scaphoideus titanus",
|
||||
"condition2": "Vineyards not treated against the vector",
|
||||
"condition3": "Contaminated areas nearby",
|
||||
"preventive1": "Mandatory insecticide treatment against the leafhopper",
|
||||
"preventive2": "Prospection and uprooting of affected vines",
|
||||
"preventive3": "Use certified plant material",
|
||||
"curative1": "No curative treatment",
|
||||
"curative2": "Mandatory uprooting of contaminated vines",
|
||||
"part1": "Leaves (rolling, discoloration)",
|
||||
"part2": "Shoots (absent lignification)",
|
||||
"part3": "Clusters (desiccation)",
|
||||
"spread": "Transmitted by the leafhopper Scaphoideus titanus"
|
||||
},
|
||||
"chlorose": {
|
||||
"name": "Iron chlorosis",
|
||||
|
|
@ -100,7 +179,17 @@
|
|||
"symptom1": "Yellowing between veins, veins remaining green",
|
||||
"symptom2": "General weakening of the vine",
|
||||
"treatment": "Iron chelate application. Choose rootstock adapted to calcareous soils.",
|
||||
"season": "Spring — especially on calcareous soils after heavy rain"
|
||||
"season": "Spring — especially on calcareous soils after heavy rain",
|
||||
"condition1": "Active calcareous soil",
|
||||
"condition2": "Compacted or waterlogged soil",
|
||||
"condition3": "Excess water in spring",
|
||||
"preventive1": "Choose rootstock adapted to calcareous soils",
|
||||
"preventive2": "Improve drainage",
|
||||
"preventive3": "Add organic matter",
|
||||
"curative1": "Foliar spray of iron chelate",
|
||||
"curative2": "Iron sulfate treatment",
|
||||
"part1": "Leaves (interveinal yellowing)",
|
||||
"spread": "Not contagious — nutritional deficiency linked to soil"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
|
|
@ -144,6 +233,60 @@
|
|||
"body": "Scan your first vine to start your collection!"
|
||||
}
|
||||
},
|
||||
"myPlants": {
|
||||
"title": "My Plants",
|
||||
"tabLabel": "My Plants",
|
||||
"searchPlaceholder": "Search a plant...",
|
||||
"groups": {
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"thisWeek": "This week",
|
||||
"thisMonth": "This month",
|
||||
"older": "Older"
|
||||
},
|
||||
"actions": {
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Remove",
|
||||
"delete": "Delete",
|
||||
"deleteConfirmTitle": "Delete this scan?",
|
||||
"deleteConfirmMessage": "This action cannot be undone.",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"toasts": {
|
||||
"favorited": "Added to favorites",
|
||||
"unfavorited": "Removed from favorites",
|
||||
"deleted": "Scan deleted"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No plants scanned yet",
|
||||
"subtitle": "Scan your first plant to start your collection",
|
||||
"cta": "Scan"
|
||||
},
|
||||
"detail": {
|
||||
"results": {
|
||||
"vine": "Vine identified",
|
||||
"uncertain": "Uncertain result",
|
||||
"notVine": "Not a vine",
|
||||
"unidentified": "Unidentified plant"
|
||||
},
|
||||
"confidence": "Confidence",
|
||||
"cepageSection": "Detected grape",
|
||||
"scannedOn": "Scanned on",
|
||||
"xpEarned": "XP earned",
|
||||
"location": "Location",
|
||||
"noLocation": "No location recorded",
|
||||
"addLocation": "Add my location",
|
||||
"share": "Share",
|
||||
"delete": "Delete",
|
||||
"shareConfirmTitle": "Share this scan?",
|
||||
"shareConfirmMessage": "The photo and scan information will be shared.",
|
||||
"shareAction": "Share",
|
||||
"shareText": "My VinEye scan",
|
||||
"shareError": "Could not share the scan",
|
||||
"notFound": "Scan not found",
|
||||
"goBack": "Go back"
|
||||
}
|
||||
},
|
||||
"guides": {
|
||||
"screenTitle": "Guides & Tips",
|
||||
"tabDiseases": "Diseases",
|
||||
|
|
@ -155,15 +298,66 @@
|
|||
},
|
||||
"healthyLeaf": {
|
||||
"title": "Recognizing a healthy leaf",
|
||||
"subtitle": "Basics for beginners"
|
||||
"subtitle": "Basics for beginners",
|
||||
"sections": {
|
||||
"colorTexture": {
|
||||
"title": "Color and texture",
|
||||
"body": "A healthy vine leaf shows a uniform, vibrant and bright green. The upper surface is smooth while the underside is slightly downy. Veins are clear, well-defined and a slightly lighter green than the blade. Run your finger along the leaf: it should feel firm, supple and free of abnormal roughness.",
|
||||
"tip": "A healthy leaf never has brown, yellow or powdery spots. If you see any, scan it immediately with VinEye."
|
||||
},
|
||||
"shape": {
|
||||
"title": "Shape and symmetry",
|
||||
"body": "Leaf shape varies by variety: 3 lobes (Merlot), 5 lobes (Cabernet Sauvignon) or nearly entire (Gamay). Regardless of variety, a healthy leaf is symmetrical with regular serrated edges. The sinuses (indentations between lobes) are clean. An asymmetric or deformed leaf may indicate a viral issue or herbicide damage."
|
||||
},
|
||||
"warning": {
|
||||
"title": "When to worry",
|
||||
"body": "Watch for these early signs: discoloration between veins (chlorosis), translucent oily spots (downy mildew), white powder (powdery mildew), downward leaf edge rolling (water stress or viral), brown necrosis (black rot). The earlier you act, the more effective the treatment.",
|
||||
"tip": "Photograph the suspicious leaf with VinEye at the first symptoms. Early detection is the key to successful treatment."
|
||||
}
|
||||
}
|
||||
},
|
||||
"treatmentCalendar": {
|
||||
"title": "Treatment calendar",
|
||||
"subtitle": "When and how to treat"
|
||||
"subtitle": "When and how to treat",
|
||||
"sections": {
|
||||
"winter": {
|
||||
"title": "Winter (December-February)",
|
||||
"body": "This is the dormant period. Use this time to prune the vine, remove and burn dead wood (a source of inoculum). Apply a winter treatment with dormant oil to eliminate scale insect eggs and overwintering pest forms. Clean pruning tools between each vine to prevent esca spread."
|
||||
},
|
||||
"spring": {
|
||||
"title": "Spring (March-May)",
|
||||
"body": "Bud break marks the beginning of the vigilance season. From the 2-3 unfolded leaves stage, begin preventive treatments: Bordeaux mixture against downy mildew, sulfur against powdery mildew. Renew after each rainfall exceeding 10 mm. Watch for chlorosis on calcareous soils. This is also the time to treat against the leafhopper vector of flavescence dorée (mandatory treatment in regulated zones).",
|
||||
"tip": "The first preventive treatment must be applied at the 2-3 unfolded leaves stage. Don't miss it — it's the most important of the season."
|
||||
},
|
||||
"summer": {
|
||||
"title": "Summer (June-August)",
|
||||
"body": "Active monitoring period. Downy mildew peaks in June, powdery mildew in July. Adapt your treatments to the weather: increase after rain for downy mildew, during heat waves for powdery mildew. Practice leaf removal to ventilate clusters and reduce botrytis risk. Watch for esca symptoms (tiger-striped leaves, sudden apoplexy).",
|
||||
"tip": "After each rainfall over 10 mm, inspect your vines within 48 hours. This is when downy mildew infections occur."
|
||||
},
|
||||
"autumn": {
|
||||
"title": "Autumn (September-November)",
|
||||
"body": "Harvest time and final treatments. Watch for botrytis on ripe clusters, especially if humidity is high. After harvest, a final copper treatment can protect remaining foliage and reduce inoculum for the following year. Prepare for winter by collecting fallen leaves and plant debris."
|
||||
}
|
||||
}
|
||||
},
|
||||
"grapeVarieties": {
|
||||
"title": "Bordeaux grape varieties",
|
||||
"subtitle": "Merlot, Cabernet, Sauvignon..."
|
||||
"subtitle": "Merlot, Cabernet, Sauvignon...",
|
||||
"sections": {
|
||||
"reds": {
|
||||
"title": "Iconic red varieties",
|
||||
"body": "Merlot is the most planted red grape in Bordeaux. Soft and fruity, it dominates the right bank (Saint-Émilion, Pomerol). It is susceptible to downy mildew and botrytis. Cabernet Sauvignon reigns on the left bank (Médoc, Graves). More tannic and structured, it resists diseases better but is susceptible to powdery mildew. Cabernet Franc, the third Bordeaux variety, offers aromas of bell pepper and violet. It is more resistant to grey rot than Merlot."
|
||||
},
|
||||
"whites": {
|
||||
"title": "White varieties",
|
||||
"body": "Sauvignon Blanc brings freshness and citrus aromas. A vigorous variety, it requires careful leaf removal to avoid botrytis. Sémillon is the great variety of Sauternes sweet wines. Paradoxically, its susceptibility to 'noble rot' (Botrytis cinerea under controlled conditions) is what makes these wines great. Muscadelle, rarer, completes the blend with its floral notes."
|
||||
},
|
||||
"choosing": {
|
||||
"title": "Choosing your variety",
|
||||
"body": "The choice of variety depends on the terroir: Merlot prefers cool clay soils, Cabernet Sauvignon well-drained gravel soils. Climate matters too: late-ripening varieties like Cabernet Sauvignon need heat to ripen. Also consider disease susceptibility: in humid areas, favor varieties resistant to downy mildew.",
|
||||
"tip": "Merlot is more tolerant of clay and cool soils, while Cabernet Sauvignon prefers well-drained, warm gravel. Match your choice to your terroir."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner": {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,14 @@
|
|||
"map": "Carte",
|
||||
"notifications": "Notifications",
|
||||
"settings": "Paramètres",
|
||||
"details": "Détails"
|
||||
"details": "Détails",
|
||||
"readTime": "{{min}} min de lecture",
|
||||
"conditions": "Conditions favorables",
|
||||
"prevention": "Prévention",
|
||||
"curativeActions": "Actions curatives",
|
||||
"impactedParts": "Parties touchées",
|
||||
"spreadMethod": "Propagation",
|
||||
"timeline": "Période d'activité"
|
||||
},
|
||||
"home": {
|
||||
"greeting": "Bonjour, Vigneron !",
|
||||
|
|
@ -52,7 +59,19 @@
|
|||
"symptom2": "Duvet blanc cotonneux sur la face inférieure",
|
||||
"symptom3": "Dessèchement et chute prématurée des feuilles",
|
||||
"treatment": "Traitement préventif à base de cuivre (bouillie bordelaise). Appliquer avant les pluies, renouveler tous les 10-14 jours.",
|
||||
"season": "Mai à août — favorisé par la chaleur et l'humidité"
|
||||
"season": "Mai à août — favorisé par la chaleur et l'humidité",
|
||||
"condition1": "Humidité supérieure à 80%",
|
||||
"condition2": "Températures entre 18 et 25°C",
|
||||
"condition3": "Pluies fréquentes au printemps",
|
||||
"preventive1": "Traitement cuivrique préventif (bouillie bordelaise)",
|
||||
"preventive2": "Aérer la végétation par ébourgeonnage",
|
||||
"preventive3": "Éviter l'excès d'azote",
|
||||
"curative1": "Appliquer un fongicide systémique homologué",
|
||||
"curative2": "Retirer les feuilles très atteintes",
|
||||
"part1": "Feuilles",
|
||||
"part2": "Grappes",
|
||||
"part3": "Rameaux",
|
||||
"spread": "Spores dispersées par le vent et les éclaboussures de pluie"
|
||||
},
|
||||
"oidium": {
|
||||
"name": "Oïdium",
|
||||
|
|
@ -60,7 +79,19 @@
|
|||
"symptom1": "Poudre blanche-grisâtre sur feuilles et grappes",
|
||||
"symptom2": "Baies qui éclatent ou se dessèchent",
|
||||
"treatment": "Soufre en poudrage ou pulvérisation. Traitements préventifs dès le débourrement.",
|
||||
"season": "Avril à septembre — favorisé par temps chaud et sec"
|
||||
"season": "Avril à septembre — favorisé par temps chaud et sec",
|
||||
"condition1": "Temps chaud et sec",
|
||||
"condition2": "Températures entre 25 et 30°C",
|
||||
"condition3": "Forte amplitude thermique jour/nuit",
|
||||
"preventive1": "Traitement soufré préventif",
|
||||
"preventive2": "Favoriser l'aération des grappes",
|
||||
"preventive3": "Effeuillage modéré",
|
||||
"curative1": "Appliquer un fongicide anti-oïdium",
|
||||
"curative2": "Soufre mouillable en curatif",
|
||||
"part1": "Feuilles",
|
||||
"part2": "Grappes",
|
||||
"part3": "Jeunes pousses",
|
||||
"spread": "Spores transportées par le vent"
|
||||
},
|
||||
"blackRot": {
|
||||
"name": "Black rot",
|
||||
|
|
@ -68,7 +99,19 @@
|
|||
"symptom1": "Taches brunes circulaires bordées de noir sur les feuilles",
|
||||
"symptom2": "Baies momifiées, noires et ridées",
|
||||
"treatment": "Éliminer les baies momifiées. Traitements fongicides préventifs au printemps.",
|
||||
"season": "Mai à juillet — favorisé par les pluies printanières"
|
||||
"season": "Mai à juillet — favorisé par les pluies printanières",
|
||||
"condition1": "Pluies au printemps",
|
||||
"condition2": "Températures entre 20 et 30°C",
|
||||
"condition3": "Présence de baies momifiées de l'année précédente",
|
||||
"preventive1": "Éliminer les momies (grappes séchées) en hiver",
|
||||
"preventive2": "Traitements fongicides préventifs dès la floraison",
|
||||
"preventive3": "Maintenir une bonne aération",
|
||||
"curative1": "Pas de traitement curatif efficace",
|
||||
"curative2": "Retirer et détruire les organes atteints",
|
||||
"part1": "Feuilles",
|
||||
"part2": "Grappes",
|
||||
"part3": "Vrilles",
|
||||
"spread": "Spores libérées par les momies sous l'effet de la pluie"
|
||||
},
|
||||
"esca": {
|
||||
"name": "Esca",
|
||||
|
|
@ -76,7 +119,20 @@
|
|||
"symptom1": "Décolorations entre les nervures des feuilles (aspect tigré)",
|
||||
"symptom2": "Dessèchement brutal du feuillage (apoplexie)",
|
||||
"treatment": "Aucun traitement curatif. Recépage du cep atteint. Protéger les plaies de taille.",
|
||||
"season": "Symptômes visibles en été — juin à septembre"
|
||||
"season": "Symptômes visibles en été — juin à septembre",
|
||||
"condition1": "Vignes âgées (plus de 10 ans)",
|
||||
"condition2": "Stress hydrique",
|
||||
"condition3": "Plaies de taille mal cicatrisées",
|
||||
"preventive1": "Protéger les plaies de taille avec un mastic",
|
||||
"preventive2": "Tailler tard en saison",
|
||||
"preventive3": "Éviter les grosses coupes",
|
||||
"curative1": "Aucun traitement curatif homologué",
|
||||
"curative2": "Curetage du bois (technique expérimentale)",
|
||||
"curative3": "Recépage si le cep n'est pas trop atteint",
|
||||
"part1": "Feuilles",
|
||||
"part2": "Bois (tronc, bras)",
|
||||
"part3": "Grappes (apoplexie)",
|
||||
"spread": "Champignons pénètrent par les plaies de taille"
|
||||
},
|
||||
"botrytis": {
|
||||
"name": "Botrytis",
|
||||
|
|
@ -84,7 +140,18 @@
|
|||
"symptom1": "Pourriture molle grise sur les baies",
|
||||
"symptom2": "Feutrage gris caractéristique sur les grappes",
|
||||
"treatment": "Favoriser l'aération des grappes. Effeuillage. Traitements anti-botrytis avant fermeture de la grappe.",
|
||||
"season": "Août à vendanges — favorisé par l'humidité"
|
||||
"season": "Août à vendanges — favorisé par l'humidité",
|
||||
"condition1": "Humidité élevée prolongée",
|
||||
"condition2": "Températures entre 15 et 25°C",
|
||||
"condition3": "Grappes compactes et serrées",
|
||||
"preventive1": "Effeuillage autour des grappes",
|
||||
"preventive2": "Choisir des cépages à grappes lâches",
|
||||
"preventive3": "Limiter la vigueur",
|
||||
"curative1": "Appliquer un anti-botrytis homologué",
|
||||
"curative2": "Vendanger les parties atteintes rapidement",
|
||||
"part1": "Grappes (baies)",
|
||||
"part2": "Feuilles (rare)",
|
||||
"spread": "Spores aériennes, favorisées par les blessures sur baies"
|
||||
},
|
||||
"flavescence": {
|
||||
"name": "Flavescence dorée",
|
||||
|
|
@ -92,7 +159,19 @@
|
|||
"symptom1": "Enroulement des feuilles avec coloration jaune ou rouge selon le cépage",
|
||||
"symptom2": "Non-aoûtement des rameaux (restent caoutchouteux)",
|
||||
"treatment": "Arrachage obligatoire des ceps contaminés. Traitement insecticide contre la cicadelle vectrice.",
|
||||
"season": "Symptômes visibles à partir de juillet"
|
||||
"season": "Symptômes visibles à partir de juillet",
|
||||
"condition1": "Présence de la cicadelle Scaphoideus titanus",
|
||||
"condition2": "Vignobles non traités contre le vecteur",
|
||||
"condition3": "Zones contaminées à proximité",
|
||||
"preventive1": "Traitement insecticide obligatoire contre la cicadelle",
|
||||
"preventive2": "Prospection et arrachage des ceps atteints",
|
||||
"preventive3": "Utiliser du matériel végétal certifié",
|
||||
"curative1": "Aucun traitement curatif",
|
||||
"curative2": "Arrachage obligatoire des ceps contaminés",
|
||||
"part1": "Feuilles (enroulement, décoloration)",
|
||||
"part2": "Rameaux (aoûtement absent)",
|
||||
"part3": "Grappes (dessèchement)",
|
||||
"spread": "Transmis par la cicadelle Scaphoideus titanus"
|
||||
},
|
||||
"chlorose": {
|
||||
"name": "Chlorose ferrique",
|
||||
|
|
@ -100,7 +179,17 @@
|
|||
"symptom1": "Jaunissement entre les nervures, nervures restant vertes",
|
||||
"symptom2": "Affaiblissement général de la vigne",
|
||||
"treatment": "Apport de chélates de fer. Choix d'un porte-greffe adapté aux sols calcaires.",
|
||||
"season": "Printemps — surtout sur sols calcaires après de fortes pluies"
|
||||
"season": "Printemps — surtout sur sols calcaires après de fortes pluies",
|
||||
"condition1": "Sol calcaire actif",
|
||||
"condition2": "Sol compacté ou asphyxiant",
|
||||
"condition3": "Excès d'eau au printemps",
|
||||
"preventive1": "Choisir un porte-greffe adapté aux sols calcaires",
|
||||
"preventive2": "Améliorer le drainage",
|
||||
"preventive3": "Apport de matière organique",
|
||||
"curative1": "Pulvérisation foliaire de chélate de fer",
|
||||
"curative2": "Traitement au sulfate de fer",
|
||||
"part1": "Feuilles (jaunissement internervaire)",
|
||||
"spread": "Non contagieux — carence nutritionnelle liée au sol"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
|
|
@ -144,6 +233,60 @@
|
|||
"body": "Scannez votre première vigne pour commencer votre collection !"
|
||||
}
|
||||
},
|
||||
"myPlants": {
|
||||
"title": "Mes plantes",
|
||||
"tabLabel": "Mes plantes",
|
||||
"searchPlaceholder": "Rechercher une plante...",
|
||||
"groups": {
|
||||
"today": "Aujourd'hui",
|
||||
"yesterday": "Hier",
|
||||
"thisWeek": "Cette semaine",
|
||||
"thisMonth": "Ce mois",
|
||||
"older": "Plus ancien"
|
||||
},
|
||||
"actions": {
|
||||
"favorite": "Favori",
|
||||
"unfavorite": "Retirer",
|
||||
"delete": "Supprimer",
|
||||
"deleteConfirmTitle": "Supprimer ce scan ?",
|
||||
"deleteConfirmMessage": "Cette action est irréversible.",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"toasts": {
|
||||
"favorited": "Ajouté aux favoris",
|
||||
"unfavorited": "Retiré des favoris",
|
||||
"deleted": "Scan supprimé"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aucune plante scannée",
|
||||
"subtitle": "Scannez votre première plante pour commencer votre collection",
|
||||
"cta": "Scanner"
|
||||
},
|
||||
"detail": {
|
||||
"results": {
|
||||
"vine": "Vigne identifiée",
|
||||
"uncertain": "Résultat incertain",
|
||||
"notVine": "Pas une vigne",
|
||||
"unidentified": "Plante non identifiée"
|
||||
},
|
||||
"confidence": "Confiance",
|
||||
"cepageSection": "Cépage détecté",
|
||||
"scannedOn": "Scan effectué le",
|
||||
"xpEarned": "XP gagnés",
|
||||
"location": "Localisation",
|
||||
"noLocation": "Aucune localisation enregistrée",
|
||||
"addLocation": "Ajouter ma position",
|
||||
"share": "Partager",
|
||||
"delete": "Supprimer",
|
||||
"shareConfirmTitle": "Partager ce scan ?",
|
||||
"shareConfirmMessage": "La photo et les informations du scan seront partagées.",
|
||||
"shareAction": "Partager",
|
||||
"shareText": "Mon scan VinEye",
|
||||
"shareError": "Impossible de partager le scan",
|
||||
"notFound": "Scan introuvable",
|
||||
"goBack": "Retour"
|
||||
}
|
||||
},
|
||||
"guides": {
|
||||
"screenTitle": "Guides & Conseils",
|
||||
"tabDiseases": "Maladies",
|
||||
|
|
@ -155,15 +298,66 @@
|
|||
},
|
||||
"healthyLeaf": {
|
||||
"title": "Reconnaître une feuille saine",
|
||||
"subtitle": "Les bases pour débutants"
|
||||
"subtitle": "Les bases pour débutants",
|
||||
"sections": {
|
||||
"colorTexture": {
|
||||
"title": "Couleur et texture",
|
||||
"body": "Une feuille de vigne saine présente un vert uniforme, vif et brillant. La texture est lisse sur la face supérieure et légèrement duveteuse dessous. Les nervures sont nettes, bien dessinées et d'un vert légèrement plus clair que le limbe. Passez votre doigt sur la feuille : elle doit être ferme, souple et sans rugosité anormale.",
|
||||
"tip": "Une feuille saine n'a jamais de taches brunes, jaunes ou poudreuses. Si vous en voyez, scannez-la immédiatement avec VinEye."
|
||||
},
|
||||
"shape": {
|
||||
"title": "Forme et symétrie",
|
||||
"body": "La forme de la feuille varie selon le cépage : 3 lobes (Merlot), 5 lobes (Cabernet Sauvignon) ou presque entière (Gamay). Quelle que soit la variété, une feuille saine est symétrique, avec des bords dentés réguliers. Les sinus (échancrures entre les lobes) sont nets. Une feuille asymétrique ou déformée peut indiquer un problème viral ou un dégât d'herbicide."
|
||||
},
|
||||
"warning": {
|
||||
"title": "Quand s'inquiéter",
|
||||
"body": "Surveillez ces premiers signes : décoloration entre les nervures (chlorose), taches huileuses translucides (mildiou), poudre blanche (oïdium), enroulement des bords vers le bas (stress hydrique ou viral), nécroses brunes (black rot). Plus vous agissez tôt, plus le traitement sera efficace.",
|
||||
"tip": "Photographiez la feuille suspecte avec VinEye dès les premiers symptômes. La détection précoce est la clé d'un traitement réussi."
|
||||
}
|
||||
}
|
||||
},
|
||||
"treatmentCalendar": {
|
||||
"title": "Calendrier de traitement",
|
||||
"subtitle": "Quand et comment traiter"
|
||||
"subtitle": "Quand et comment traiter",
|
||||
"sections": {
|
||||
"winter": {
|
||||
"title": "Hiver (décembre-février)",
|
||||
"body": "C'est la période de repos végétatif. Profitez-en pour tailler la vigne, retirer et brûler les bois morts (source d'inoculum). Appliquez un traitement d'hiver à base d'huile blanche pour éliminer les œufs de cochenilles et les formes hivernantes de parasites. Nettoyez le matériel de taille entre chaque cep pour éviter la propagation de l'esca."
|
||||
},
|
||||
"spring": {
|
||||
"title": "Printemps (mars-mai)",
|
||||
"body": "Le débourrement marque le début de la saison de vigilance. Dès le stade 2-3 feuilles étalées, commencez les traitements préventifs : bouillie bordelaise contre le mildiou, soufre contre l'oïdium. Renouvelez après chaque pluie supérieure à 10 mm. Surveillez l'apparition de la chlorose sur les sols calcaires. C'est aussi le moment de traiter contre la cicadelle vectrice de la flavescence dorée (traitement obligatoire en zone réglementée).",
|
||||
"tip": "Le premier traitement préventif doit intervenir au stade 2-3 feuilles étalées. Ne l'oubliez pas, c'est le plus important de la saison."
|
||||
},
|
||||
"summer": {
|
||||
"title": "Été (juin-août)",
|
||||
"body": "Période de surveillance active. Le mildiou est à son pic en juin, l'oïdium en juillet. Adaptez vos traitements à la météo : renforcez après les pluies pour le mildiou, lors des fortes chaleurs pour l'oïdium. Pratiquez l'effeuillage pour aérer les grappes et réduire le risque de botrytis. Surveillez l'apparition de l'esca (feuilles tigrées, apoplexie brutale).",
|
||||
"tip": "Après chaque pluie de plus de 10 mm, inspectez vos vignes dans les 48h. C'est le moment où les contaminations de mildiou se produisent."
|
||||
},
|
||||
"autumn": {
|
||||
"title": "Automne (septembre-novembre)",
|
||||
"body": "C'est la période des vendanges et des derniers traitements. Surveillez le botrytis sur les grappes mûres, surtout si l'humidité est élevée. Après les vendanges, un dernier traitement cuivrique peut protéger le feuillage restant et limiter l'inoculum pour l'année suivante. Préparez l'hiver en ramassant les feuilles tombées et les débris végétaux."
|
||||
}
|
||||
}
|
||||
},
|
||||
"grapeVarieties": {
|
||||
"title": "Les cépages bordelais",
|
||||
"subtitle": "Merlot, Cabernet, Sauvignon..."
|
||||
"subtitle": "Merlot, Cabernet, Sauvignon...",
|
||||
"sections": {
|
||||
"reds": {
|
||||
"title": "Les rouges emblématiques",
|
||||
"body": "Le Merlot est le cépage rouge le plus planté à Bordeaux. Souple et fruité, il domine sur la rive droite (Saint-Émilion, Pomerol). Il est sensible au mildiou et au botrytis. Le Cabernet Sauvignon règne sur la rive gauche (Médoc, Graves). Plus tannique et structuré, il résiste mieux aux maladies mais est sensible à l'oïdium. Le Cabernet Franc, troisième cépage bordelais, offre des arômes de poivron et de violette. Il est plus résistant à la pourriture grise que le Merlot."
|
||||
},
|
||||
"whites": {
|
||||
"title": "Les blancs",
|
||||
"body": "Le Sauvignon Blanc apporte fraîcheur et arômes d'agrumes. Cépage vigoureux, il nécessite un effeuillage soigneux pour éviter le botrytis. Le Sémillon est le grand cépage des liquoreux de Sauternes. Paradoxalement, c'est sa sensibilité à la « pourriture noble » (Botrytis cinerea en conditions contrôlées) qui fait la grandeur de ces vins. La Muscadelle, plus rare, complète l'assemblage avec ses notes florales."
|
||||
},
|
||||
"choosing": {
|
||||
"title": "Choisir son cépage",
|
||||
"body": "Le choix du cépage dépend du terroir : le Merlot préfère les sols argileux et frais, le Cabernet Sauvignon les sols de graves bien drainés. Le climat joue aussi : les cépages tardifs comme le Cabernet Sauvignon ont besoin de chaleur pour mûrir. Pensez aussi à la sensibilité aux maladies : en zone humide, privilégiez des cépages résistants au mildiou.",
|
||||
"tip": "Le Merlot est plus tolérant aux sols argileux et frais, le Cabernet Sauvignon préfère les graves bien drainés et chauds. Adaptez votre choix à votre terroir."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner": {
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
|||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { House, ScanLine, Map, BookOpen, Leaf } from "lucide-react-native";
|
||||
import { House, ScanLine, Map, BookOpen, Sprout } from "lucide-react-native";
|
||||
|
||||
import HomeScreen from "@/screens/HomeScreen";
|
||||
import ScannerScreen from "@/screens/ScannerScreen";
|
||||
import MapScreen from "@/screens/MapScreen";
|
||||
import GuidesScreen from "@/screens/GuidesScreen";
|
||||
import LibraryScreen from "@/screens/LibraryScreen";
|
||||
import MyPlantsScreen from "@/screens/MyPlantsScreen";
|
||||
import { colors } from "@/theme/colors";
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
|
@ -18,7 +18,7 @@ const Tab = createBottomTabNavigator();
|
|||
const TAB_ICONS: Record<string, any> = {
|
||||
Home: House,
|
||||
Guides: BookOpen,
|
||||
Library: Leaf,
|
||||
MyPlants: Sprout,
|
||||
Map: Map,
|
||||
};
|
||||
|
||||
|
|
@ -164,9 +164,9 @@ export default function BottomTabNavigator() {
|
|||
options={{ tabBarLabel: t("common.scan") }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Library"
|
||||
component={LibraryScreen}
|
||||
options={{ tabBarLabel: t("library.title") }}
|
||||
name="MyPlants"
|
||||
component={MyPlantsScreen}
|
||||
options={{ tabBarLabel: t("myPlants.tabLabel") }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Map"
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import ResultScreen from '@/screens/ResultScreen';
|
|||
import NotificationsScreen from '@/screens/NotificationsScreen';
|
||||
import ProfileScreen from '@/screens/ProfileScreen';
|
||||
import SettingsScreen from '@/screens/SettingsScreen';
|
||||
import GuidesScreen from '@/screens/GuidesScreen';
|
||||
import LibraryScreen from '@/screens/LibraryScreen';
|
||||
import DiseaseDetailScreen from '@/screens/DiseaseDetailScreen';
|
||||
import GuideDetailScreen from '@/screens/GuideDetailScreen';
|
||||
import ScanDetailScreen from '@/screens/ScanDetailScreen';
|
||||
import BottomTabNavigator from './BottomTabNavigator';
|
||||
import linking from './linking';
|
||||
import type { RootStackParamList } from '@/types/navigation';
|
||||
|
|
@ -19,7 +20,13 @@ export default function RootNavigator() {
|
|||
<NavigationContainer linking={linking}>
|
||||
<Stack.Navigator
|
||||
initialRouteName="Splash"
|
||||
screenOptions={{ headerShown: false, animation: 'fade' }}
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: 'fade',
|
||||
animationDuration: 250,
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="Splash" component={SplashScreen} />
|
||||
<Stack.Screen name="Main" component={BottomTabNavigator} />
|
||||
|
|
@ -28,31 +35,12 @@ export default function RootNavigator() {
|
|||
component={ResultScreen}
|
||||
options={{ animation: 'slide_from_bottom', presentation: 'modal' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Notifications"
|
||||
component={NotificationsScreen}
|
||||
options={{ animation: 'slide_from_right' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Profile"
|
||||
component={ProfileScreen}
|
||||
options={{ animation: 'slide_from_right' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{ animation: 'slide_from_right' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Guides"
|
||||
component={GuidesScreen}
|
||||
options={{ animation: 'slide_from_right' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Library"
|
||||
component={LibraryScreen}
|
||||
options={{ animation: 'slide_from_right' }}
|
||||
/>
|
||||
<Stack.Screen name="Notifications" component={NotificationsScreen} />
|
||||
<Stack.Screen name="Profile" component={ProfileScreen} />
|
||||
<Stack.Screen name="Settings" component={SettingsScreen} />
|
||||
<Stack.Screen name="DiseaseDetail" component={DiseaseDetailScreen} />
|
||||
<Stack.Screen name="GuideDetail" component={GuideDetailScreen} />
|
||||
<Stack.Screen name="ScanDetail" component={ScanDetailScreen} />
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ const linking: LinkingOptions<RootStackParamList> = {
|
|||
Main: {
|
||||
screens: {
|
||||
Home: 'home',
|
||||
Guides: 'guides',
|
||||
Scanner: 'scan',
|
||||
MyPlants: 'my-plants',
|
||||
Map: 'map',
|
||||
},
|
||||
},
|
||||
|
|
@ -17,8 +19,9 @@ const linking: LinkingOptions<RootStackParamList> = {
|
|||
Notifications: 'notifications',
|
||||
Profile: 'profile',
|
||||
Settings: 'settings',
|
||||
Guides: 'guides',
|
||||
Library: 'library',
|
||||
DiseaseDetail: 'disease/:diseaseId',
|
||||
GuideDetail: 'guide/:guideId',
|
||||
ScanDetail: 'scan/:scanId',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
522
VinEye/src/screens/DiseaseDetailScreen.tsx
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
import { useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
Easing,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import Skeleton from "@/components/ui/Skeleton";
|
||||
import SeasonTimeline from "@/components/disease/SeasonTimeline";
|
||||
import { useDiseaseDetail } from "@/hooks/useDiseaseDetail";
|
||||
import type { Disease } from "@/data/diseases";
|
||||
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "@/types/navigation";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "DiseaseDetail">;
|
||||
|
||||
const SEVERITY_COLORS: Record<Disease["severity"], { bg: string; label: string }> = {
|
||||
high: { bg: "rgba(239, 68, 68, 0.9)", label: "guides.severity.critical" },
|
||||
medium: { bg: "rgba(245, 158, 11, 0.9)", label: "guides.severity.moderate" },
|
||||
low: { bg: "rgba(34, 197, 94, 0.9)", label: "guides.severity.low" },
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
fungal: "leaf-outline",
|
||||
bacterial: "bug-outline",
|
||||
pest: "bug-outline",
|
||||
abiotic: "sunny-outline",
|
||||
};
|
||||
|
||||
export default function DiseaseDetailScreen({ route }: Props) {
|
||||
const { diseaseId } = route.params;
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { disease, isLoading } = useDiseaseDetail(diseaseId);
|
||||
|
||||
// Entry animation
|
||||
const contentY = useSharedValue(30);
|
||||
const contentOpacity = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (disease) {
|
||||
const timing = { duration: 400, easing: Easing.bezier(0.25, 0.1, 0.25, 1) };
|
||||
contentY.value = withTiming(0, timing);
|
||||
contentOpacity.value = withTiming(1, timing);
|
||||
}
|
||||
}, [disease]);
|
||||
|
||||
const contentAnim = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: contentY.value }],
|
||||
opacity: contentOpacity.value,
|
||||
}));
|
||||
|
||||
if (isLoading && !disease) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Skeleton width={200} height={24} borderRadius={8} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!disease) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={{ color: "#1A1A1A" }}>Maladie introuvable</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const severity = SEVERITY_COLORS[disease.severity];
|
||||
const heroImage = disease.images[0];
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 40 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* ── Hero Image ── */}
|
||||
<View style={styles.heroContainer}>
|
||||
{heroImage ? (
|
||||
<Image
|
||||
source={{ uri: heroImage }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
contentFit="cover"
|
||||
transition={300}
|
||||
/>
|
||||
) : (
|
||||
<View style={[StyleSheet.absoluteFillObject, { backgroundColor: disease.bgColor, alignItems: "center", justifyContent: "center" }]}>
|
||||
<Ionicons name={disease.icon as any} size={80} color={disease.iconColor} />
|
||||
</View>
|
||||
)}
|
||||
{/* Top gradient (status bar readability) */}
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,0.35)", "transparent"]}
|
||||
style={styles.gradientTop}
|
||||
/>
|
||||
{/* Bottom gradient (transition to content) */}
|
||||
<LinearGradient
|
||||
colors={["transparent", "#F8F9FB"]}
|
||||
style={styles.gradientBottom}
|
||||
/>
|
||||
|
||||
{/* Back button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.backBtn, { top: insets.top + 8 }]}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color="#1A1A1A" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Severity badge */}
|
||||
<View style={[styles.severityBadge, { top: insets.top + 8, backgroundColor: severity.bg }]}>
|
||||
<Text style={styles.severityText}>{t(severity.label)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<Animated.View style={contentAnim}>
|
||||
{/* Title */}
|
||||
<Text style={styles.title}>{t(disease.name)}</Text>
|
||||
|
||||
{/* Meta pills */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.metaRow}
|
||||
>
|
||||
<View style={styles.metaPill}>
|
||||
<Ionicons name={TYPE_ICONS[disease.type] as any} size={14} color="#2D6A4F" />
|
||||
<Text style={styles.metaPillText}>
|
||||
{t(`diseases.types.${disease.type}`)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaPill}>
|
||||
<Ionicons name="calendar-outline" size={14} color="#2D6A4F" />
|
||||
<Text style={styles.metaPillText} numberOfLines={1}>
|
||||
{t(disease.season)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaPill}>
|
||||
<Ionicons name="leaf-outline" size={14} color="#2D6A4F" />
|
||||
<Text style={styles.metaPillText}>
|
||||
{disease.impactedParts.map((p) => t(p)).join(", ")}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Description */}
|
||||
<SectionTitle icon="information-circle-outline" label={t("common.details")} />
|
||||
<Text style={styles.bodyText}>{t(disease.description)}</Text>
|
||||
|
||||
{/* Timeline */}
|
||||
<SectionTitle icon="time-outline" label={t("common.timeline")} />
|
||||
<View style={styles.sectionPad}>
|
||||
<SeasonTimeline
|
||||
startMonth={disease.timeline.startMonth}
|
||||
endMonth={disease.timeline.endMonth}
|
||||
peakMonth={disease.timeline.peakMonth}
|
||||
activeColor={disease.iconColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Conditions */}
|
||||
<SectionTitle icon="thermometer-outline" label={t("common.conditions")} />
|
||||
<View style={styles.tagsContainer}>
|
||||
{disease.conditions.map((c, i) => (
|
||||
<View key={i} style={styles.conditionTag}>
|
||||
<Text style={styles.conditionText}>{t(c)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Symptoms */}
|
||||
<SectionTitle icon="eye-outline" label="Symptômes" />
|
||||
<View style={styles.sectionPad}>
|
||||
{disease.symptoms.map((s, i) => (
|
||||
<View key={i} style={styles.bulletRow}>
|
||||
<View style={styles.bullet} />
|
||||
<Text style={styles.bulletText}>{t(s)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Preventive Actions */}
|
||||
<SectionTitle icon="shield-checkmark-outline" label={t("common.prevention")} />
|
||||
<View style={styles.sectionPad}>
|
||||
{disease.preventiveActions.map((a, i) => (
|
||||
<View key={i} style={styles.actionCard}>
|
||||
<View style={styles.actionNumber}>
|
||||
<Text style={styles.actionNumberText}>{i + 1}</Text>
|
||||
</View>
|
||||
<Text style={styles.actionText}>{t(a)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Curative Actions */}
|
||||
<SectionTitle icon="medkit-outline" label={t("common.curativeActions")} />
|
||||
<View style={styles.sectionPad}>
|
||||
{disease.curativeActions.map((a, i) => (
|
||||
<View key={i} style={styles.actionCardOrange}>
|
||||
<View style={styles.actionNumberOrange}>
|
||||
<Text style={styles.actionNumberOrangeText}>{i + 1}</Text>
|
||||
</View>
|
||||
<Text style={styles.actionText}>{t(a)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Spread Method */}
|
||||
<SectionTitle icon="swap-horizontal-outline" label={t("common.spreadMethod")} />
|
||||
<View style={styles.sectionPad}>
|
||||
<View style={styles.spreadCard}>
|
||||
<Ionicons name="warning-outline" size={20} color="#B91C1C" />
|
||||
<Text style={styles.spreadText}>{t(disease.spreadMethod)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Photo Gallery */}
|
||||
{disease.images.length > 1 && (
|
||||
<>
|
||||
<SectionTitle icon="images-outline" label="Photos" />
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.gallery}
|
||||
>
|
||||
{disease.images.map((uri, i) => (
|
||||
<View key={i} style={styles.galleryItem}>
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Inline SectionTitle ── */
|
||||
|
||||
function SectionTitle({ icon, label }: { icon: string; label: string }) {
|
||||
return (
|
||||
<View style={styles.sectionTitleRow}>
|
||||
<Ionicons name={icon as any} size={20} color="#2D6A4F" />
|
||||
<Text style={styles.sectionTitleText}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Styles ── */
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: "#F8F9FB",
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#F8F9FB",
|
||||
},
|
||||
|
||||
// Hero
|
||||
heroContainer: {
|
||||
height: 320,
|
||||
position: "relative",
|
||||
backgroundColor: "#E0E0E0",
|
||||
},
|
||||
gradientTop: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 100,
|
||||
},
|
||||
gradientBottom: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 80,
|
||||
},
|
||||
backBtn: {
|
||||
position: "absolute",
|
||||
left: 16,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
...Platform.select({
|
||||
ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4 },
|
||||
android: { elevation: 3 },
|
||||
}),
|
||||
},
|
||||
severityBadge: {
|
||||
position: "absolute",
|
||||
right: 16,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 20,
|
||||
},
|
||||
severityText: {
|
||||
fontSize: 12,
|
||||
fontWeight: "700",
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
|
||||
// Title + meta
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: "800",
|
||||
color: "#1A1A1A",
|
||||
paddingHorizontal: 20,
|
||||
marginTop: -8,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
metaPill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
backgroundColor: "rgba(45, 106, 79, 0.1)",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 7,
|
||||
borderRadius: 20,
|
||||
},
|
||||
metaPillText: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
color: "#2D6A4F",
|
||||
},
|
||||
|
||||
// Section titles
|
||||
sectionTitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 28,
|
||||
marginBottom: 12,
|
||||
},
|
||||
sectionTitleText: {
|
||||
fontSize: 20,
|
||||
fontWeight: "700",
|
||||
color: "#1A1A1A",
|
||||
},
|
||||
|
||||
// Body
|
||||
bodyText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
color: "#444444",
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
sectionPad: {
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
|
||||
// Conditions tags
|
||||
tagsContainer: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 8,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
conditionTag: {
|
||||
backgroundColor: "rgba(245, 158, 11, 0.1)",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 12,
|
||||
},
|
||||
conditionText: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
color: "#B45309",
|
||||
},
|
||||
|
||||
// Symptoms bullets
|
||||
bulletRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 10,
|
||||
gap: 12,
|
||||
},
|
||||
bullet: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: "#2D6A4F",
|
||||
marginTop: 8,
|
||||
},
|
||||
bulletText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
color: "#333333",
|
||||
},
|
||||
|
||||
// Action cards (preventive — green)
|
||||
actionCard: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 8,
|
||||
...Platform.select({
|
||||
ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 4 },
|
||||
android: { elevation: 1 },
|
||||
}),
|
||||
},
|
||||
actionNumber: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: "rgba(45, 106, 79, 0.1)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
actionNumberText: {
|
||||
fontSize: 13,
|
||||
fontWeight: "700",
|
||||
color: "#2D6A4F",
|
||||
},
|
||||
actionText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: "#333333",
|
||||
},
|
||||
|
||||
// Action cards (curative — orange)
|
||||
actionCardOrange: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 8,
|
||||
...Platform.select({
|
||||
ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 4 },
|
||||
android: { elevation: 1 },
|
||||
}),
|
||||
},
|
||||
actionNumberOrange: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: "rgba(245, 158, 11, 0.1)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
actionNumberOrangeText: {
|
||||
fontSize: 13,
|
||||
fontWeight: "700",
|
||||
color: "#B45309",
|
||||
},
|
||||
|
||||
// Spread
|
||||
spreadCard: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
backgroundColor: "rgba(239, 68, 68, 0.05)",
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
},
|
||||
spreadText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: "#7F1D1D",
|
||||
},
|
||||
|
||||
// Gallery
|
||||
gallery: {
|
||||
paddingHorizontal: 20,
|
||||
gap: 12,
|
||||
},
|
||||
galleryItem: {
|
||||
width: 160,
|
||||
height: 120,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#E0E0E0",
|
||||
},
|
||||
});
|
||||
343
VinEye/src/screens/GuideDetailScreen.tsx
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
import { useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
Easing,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import Skeleton from "@/components/ui/Skeleton";
|
||||
import { useGuideDetail } from "@/hooks/useGuideDetail";
|
||||
import type { GuideSection } from "@/data/guides";
|
||||
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "@/types/navigation";
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, "GuideDetail">;
|
||||
|
||||
const CATEGORY_LABELS: Record<string, { fr: string; en: string }> = {
|
||||
beginner: { fr: "Débutant", en: "Beginner" },
|
||||
treatment: { fr: "Traitement", en: "Treatment" },
|
||||
varieties: { fr: "Cépages", en: "Varieties" },
|
||||
seasonal: { fr: "Saisonnier", en: "Seasonal" },
|
||||
};
|
||||
|
||||
export default function GuideDetailScreen({ route }: Props) {
|
||||
const { guideId } = route.params;
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { guide, isLoading } = useGuideDetail(guideId);
|
||||
|
||||
// Entry animation
|
||||
const contentY = useSharedValue(30);
|
||||
const contentOpacity = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (guide) {
|
||||
const timing = { duration: 400, easing: Easing.bezier(0.25, 0.1, 0.25, 1) };
|
||||
contentY.value = withTiming(0, timing);
|
||||
contentOpacity.value = withTiming(1, timing);
|
||||
}
|
||||
}, [guide]);
|
||||
|
||||
const contentAnim = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: contentY.value }],
|
||||
opacity: contentOpacity.value,
|
||||
}));
|
||||
|
||||
if (isLoading && !guide) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Skeleton width={200} height={24} borderRadius={8} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!guide) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={{ color: "#1A1A1A" }}>Guide introuvable</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const lang = i18n.language === "en" ? "en" : "fr";
|
||||
const catLabel = CATEGORY_LABELS[guide.category]?.[lang] ?? guide.category;
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 40 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* ── Hero Image ── */}
|
||||
<View style={styles.heroContainer}>
|
||||
{guide.image ? (
|
||||
<Image
|
||||
source={{ uri: guide.image }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
contentFit="cover"
|
||||
transition={300}
|
||||
/>
|
||||
) : (
|
||||
<View style={[StyleSheet.absoluteFillObject, { backgroundColor: guide.bgColor, alignItems: "center", justifyContent: "center" }]}>
|
||||
<Ionicons name={guide.icon as any} size={80} color={guide.iconColor} />
|
||||
</View>
|
||||
)}
|
||||
<LinearGradient colors={["rgba(0,0,0,0.35)", "transparent"]} style={styles.gradientTop} />
|
||||
<LinearGradient colors={["transparent", "#F8F9FB"]} style={styles.gradientBottom} />
|
||||
|
||||
{/* Back button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.backBtn, { top: insets.top + 8 }]}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color="#1A1A1A" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Read time badge */}
|
||||
<View style={[styles.readTimeBadge, { top: insets.top + 8 }]}>
|
||||
<Ionicons name="time-outline" size={14} color="#1A1A1A" />
|
||||
<Text style={styles.readTimeText}>
|
||||
{t("common.readTime", { min: guide.readTime })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<Animated.View style={contentAnim}>
|
||||
{/* Title */}
|
||||
<Text style={styles.title}>{t(guide.title)}</Text>
|
||||
<Text style={styles.subtitle}>{t(guide.subtitle)}</Text>
|
||||
|
||||
{/* Category pill */}
|
||||
<View style={styles.categoryRow}>
|
||||
<View style={styles.categoryPill}>
|
||||
<Text style={styles.categoryText}>{catLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sections */}
|
||||
{guide.content.map((section, index) => (
|
||||
<View key={index}>
|
||||
{/* Separator (except before first) */}
|
||||
{index > 0 && <View style={styles.separator} />}
|
||||
|
||||
<SectionBlock section={section} t={t} />
|
||||
</View>
|
||||
))}
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Section Block ── */
|
||||
|
||||
function SectionBlock({ section, t }: { section: GuideSection; t: (key: string) => string }) {
|
||||
return (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>{t(section.title)}</Text>
|
||||
<Text style={styles.sectionBody}>{t(section.body)}</Text>
|
||||
|
||||
{/* Section image */}
|
||||
{section.image && (
|
||||
<View style={styles.sectionImageWrapper}>
|
||||
<Image
|
||||
source={{ uri: section.image }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Tip card */}
|
||||
{section.tip && (
|
||||
<View style={styles.tipCard}>
|
||||
<View style={styles.tipHeader}>
|
||||
<Ionicons name="bulb-outline" size={18} color="#2D6A4F" />
|
||||
<Text style={styles.tipLabel}>Conseil</Text>
|
||||
</View>
|
||||
<Text style={styles.tipText}>{t(section.tip)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Styles ── */
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: "#F8F9FB",
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#F8F9FB",
|
||||
},
|
||||
|
||||
// Hero
|
||||
heroContainer: {
|
||||
height: 280,
|
||||
position: "relative",
|
||||
backgroundColor: "#E0E0E0",
|
||||
},
|
||||
gradientTop: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 100,
|
||||
},
|
||||
gradientBottom: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 80,
|
||||
},
|
||||
backBtn: {
|
||||
position: "absolute",
|
||||
left: 16,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
...Platform.select({
|
||||
ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4 },
|
||||
android: { elevation: 3 },
|
||||
}),
|
||||
},
|
||||
readTimeBadge: {
|
||||
position: "absolute",
|
||||
right: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 20,
|
||||
...Platform.select({
|
||||
ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4 },
|
||||
android: { elevation: 3 },
|
||||
}),
|
||||
},
|
||||
readTimeText: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
color: "#1A1A1A",
|
||||
},
|
||||
|
||||
// Title
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: "800",
|
||||
color: "#1A1A1A",
|
||||
paddingHorizontal: 20,
|
||||
marginTop: -8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "400",
|
||||
color: "#8E8E93",
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 4,
|
||||
},
|
||||
categoryRow: {
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 12,
|
||||
},
|
||||
categoryPill: {
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "rgba(45, 106, 79, 0.1)",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 20,
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
color: "#2D6A4F",
|
||||
},
|
||||
|
||||
// Separator
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: "#F0F0F0",
|
||||
marginHorizontal: 20,
|
||||
marginTop: 32,
|
||||
},
|
||||
|
||||
// Sections
|
||||
sectionTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: "700",
|
||||
color: "#1A1A1A",
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 32,
|
||||
},
|
||||
sectionBody: {
|
||||
fontSize: 15,
|
||||
lineHeight: 26,
|
||||
color: "#444444",
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 10,
|
||||
},
|
||||
sectionImageWrapper: {
|
||||
height: 200,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
marginTop: 16,
|
||||
marginHorizontal: 20,
|
||||
backgroundColor: "#E0E0E0",
|
||||
},
|
||||
|
||||
// Tip
|
||||
tipCard: {
|
||||
backgroundColor: "rgba(45, 106, 79, 0.06)",
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginTop: 16,
|
||||
marginHorizontal: 20,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: "#2D6A4F",
|
||||
},
|
||||
tipHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
tipLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: "700",
|
||||
color: "#2D6A4F",
|
||||
},
|
||||
tipText: {
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
color: "#2D6A4F",
|
||||
},
|
||||
});
|
||||
|
|
@ -1,373 +1,155 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { View, ScrollView, RefreshControl, StyleSheet } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { colors } from "@/theme/colors";
|
||||
import { VINE_DISEASES } from "@/data/diseases";
|
||||
import { PRACTICAL_GUIDES } from "@/data/guides";
|
||||
import type { Disease } from "@/data/diseases";
|
||||
import type { Guide } from "@/data/guides";
|
||||
import SearchHeader from "@/components/home/SearchHeader";
|
||||
import SearchSection from "@/components/home/SearchSection";
|
||||
import AnimatedSegmentedControl from "@/components/guides/AnimatedSegmentedControl";
|
||||
import SmallDiseaseCard from "@/components/ui/SmallDiseaseCard";
|
||||
import GuideListItem from "@/components/ui/GuideListItem";
|
||||
import { DiseaseCardSkeleton, GuideListItemSkeleton } from "@/components/ui/Skeleton";
|
||||
import { useDiseases } from "@/hooks/useDiseases";
|
||||
import { useGuides } from "@/hooks/useGuides";
|
||||
import type { RootStackParamList } from "@/types/navigation";
|
||||
|
||||
type Tab = "diseases" | "guides";
|
||||
|
||||
const DISEASE_TYPE_KEYS: Record<Disease["type"], string> = {
|
||||
fungal: "diseases.types.fungal",
|
||||
bacterial: "diseases.types.bacterial",
|
||||
pest: "diseases.types.pest",
|
||||
abiotic: "diseases.types.abiotic",
|
||||
};
|
||||
|
||||
const SEVERITY_CONFIG: Record<
|
||||
Disease["severity"],
|
||||
{ label: string; color: string; bg: string }
|
||||
> = {
|
||||
high: { label: "guides.severity.critical", color: "#DC2626", bg: "#FEF2F2" },
|
||||
medium: { label: "guides.severity.moderate", color: "#F59E0B", bg: "#FFFBEB" },
|
||||
low: { label: "guides.severity.low", color: "#10B981", bg: "#ECFDF5" },
|
||||
};
|
||||
|
||||
function DiseaseCard({ item }: { item: Disease }) {
|
||||
const { t } = useTranslation();
|
||||
const severity = SEVERITY_CONFIG[item.severity];
|
||||
|
||||
return (
|
||||
<TouchableOpacity activeOpacity={0.7} style={styles.diseaseCard}>
|
||||
{/* Image placeholder */}
|
||||
<View style={[styles.diseaseBanner, { backgroundColor: item.bgColor }]}>
|
||||
<Ionicons name={item.icon as any} size={36} color={item.iconColor} />
|
||||
{/* Severity badge */}
|
||||
<View style={[styles.severityBadge, { backgroundColor: severity.bg }]}>
|
||||
<View style={[styles.severityDot, { backgroundColor: severity.color }]} />
|
||||
<Text style={[styles.severityText, { color: severity.color }]}>
|
||||
{t(severity.label)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.diseaseContent}>
|
||||
<View style={[styles.typePill, { backgroundColor: `${item.iconColor}12` }]}>
|
||||
<Text style={[styles.typeText, { color: item.iconColor }]}>
|
||||
{t(DISEASE_TYPE_KEYS[item.type])}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.diseaseName} numberOfLines={1}>
|
||||
{t(item.name)}
|
||||
</Text>
|
||||
<Text style={styles.diseaseSeason} numberOfLines={1}>
|
||||
{t(item.season)}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
function GuideCard({ item }: { item: Guide }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TouchableOpacity activeOpacity={0.7} style={styles.guideCard}>
|
||||
<View style={[styles.guideIcon, { backgroundColor: `${item.iconColor}12` }]}>
|
||||
<Ionicons name={item.icon as any} size={24} color={item.iconColor} />
|
||||
</View>
|
||||
<View style={styles.guideText}>
|
||||
<Text style={styles.guideTitle} numberOfLines={1}>
|
||||
{t(item.title)}
|
||||
</Text>
|
||||
<Text style={styles.guideSubtitle} numberOfLines={1}>
|
||||
{t(item.subtitle)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.chevronWrap}>
|
||||
<Ionicons name="chevron-forward" size={14} color="#D1D1D6" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
export default function GuidesScreen() {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("diseases");
|
||||
const navigation = useNavigation<Nav>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
function handleBack() {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack();
|
||||
} else {
|
||||
(navigation as any).navigate("Main");
|
||||
}
|
||||
const {
|
||||
data: diseases,
|
||||
isLoading: diseasesLoading,
|
||||
isRefreshing: diseasesRefreshing,
|
||||
refresh: refreshDiseases,
|
||||
} = useDiseases();
|
||||
|
||||
const {
|
||||
data: guides,
|
||||
isLoading: guidesLoading,
|
||||
isRefreshing: guidesRefreshing,
|
||||
refresh: refreshGuides,
|
||||
} = useGuides();
|
||||
|
||||
const tabs = [t("guides.tabDiseases"), t("guides.tabGuides")];
|
||||
|
||||
async function handleRefresh() {
|
||||
if (activeTab === 0) await refreshDiseases();
|
||||
else await refreshGuides();
|
||||
}
|
||||
|
||||
const isRefreshing = activeTab === 0 ? diseasesRefreshing : guidesRefreshing;
|
||||
const showDiseasesSkeleton = diseasesLoading && diseases.length === 0;
|
||||
const showGuidesSkeleton = guidesLoading && guides.length === 0;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={["top"]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={handleBack} style={styles.backBtn}>
|
||||
<Ionicons name="chevron-back" size={24} color="#1A1A1A" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>{t("guides.screenTitle")}</Text>
|
||||
<View style={{ width: 44 }} />
|
||||
</View>
|
||||
<View style={styles.root}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: insets.bottom + 100,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor="#2D6A4F"
|
||||
progressViewOffset={insets.top}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SearchHeader />
|
||||
<SearchSection />
|
||||
|
||||
{/* Segmented Control */}
|
||||
<View style={styles.tabContainer}>
|
||||
<View style={styles.tabBar}>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === "diseases" && styles.tabActive]}
|
||||
onPress={() => setActiveTab("diseases")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "diseases" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
{t("guides.tabDiseases")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === "guides" && styles.tabActive]}
|
||||
onPress={() => setActiveTab("guides")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "guides" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
{t("guides.tabGuides")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<AnimatedSegmentedControl
|
||||
tabs={tabs}
|
||||
activeIndex={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === "diseases" ? (
|
||||
<FlatList
|
||||
data={VINE_DISEASES}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => <DiseaseCard item={item} />}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
|
||||
/>
|
||||
) : (
|
||||
<FlatList
|
||||
data={PRACTICAL_GUIDES}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => <GuideCard item={item} />}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
{activeTab === 0 ? (
|
||||
<View style={styles.grid}>
|
||||
{showDiseasesSkeleton
|
||||
? Array.from({ length: 4 }).map((_, i) => (
|
||||
<View key={i} style={styles.gridItem}>
|
||||
<DiseaseCardSkeleton style={{ height: 160 }} />
|
||||
</View>
|
||||
))
|
||||
: diseases.map((disease, index) => (
|
||||
<View key={disease.id} style={styles.gridItem}>
|
||||
<SmallDiseaseCard
|
||||
disease={disease}
|
||||
onPress={() =>
|
||||
navigation.navigate("DiseaseDetail", { diseaseId: disease.id })
|
||||
}
|
||||
index={index}
|
||||
size="grid"
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.guidesSection}>
|
||||
<Text style={styles.sectionTitle}>{t("guides.tabGuides")}</Text>
|
||||
<View style={styles.guidesList}>
|
||||
{showGuidesSkeleton
|
||||
? Array.from({ length: 3 }).map((_, i) => <GuideListItemSkeleton key={i} />)
|
||||
: guides.map((guide, index) => (
|
||||
<GuideListItem
|
||||
key={guide.id}
|
||||
guide={guide}
|
||||
onPress={() =>
|
||||
navigation.navigate("GuideDetail", { guideId: guide.id })
|
||||
}
|
||||
showSeparator={index < guides.length - 1}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: {
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: "#F8F9FB",
|
||||
},
|
||||
|
||||
// Header
|
||||
header: {
|
||||
grid: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: "transparent",
|
||||
gap: 12,
|
||||
},
|
||||
backBtn: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 14,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#F0F0F0",
|
||||
gridItem: {
|
||||
width: "48%",
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
guidesSection: {
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: "700",
|
||||
color: "#1A1A1A",
|
||||
letterSpacing: -0.4,
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
|
||||
// Tabs
|
||||
tabContainer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
tabBar: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: "#EEEFF1",
|
||||
borderRadius: 14,
|
||||
padding: 4,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: "center",
|
||||
borderRadius: 11,
|
||||
},
|
||||
tabActive: {
|
||||
guidesList: {
|
||||
backgroundColor: "#FFFFFF",
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
android: { elevation: 2 },
|
||||
}),
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "#8E8E93",
|
||||
},
|
||||
tabTextActive: {
|
||||
color: "#1A1A1A",
|
||||
fontWeight: "600",
|
||||
},
|
||||
|
||||
// List
|
||||
listContent: {
|
||||
padding: 20,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
|
||||
// Disease card
|
||||
diseaseCard: {
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 24,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
borderWidth: 1,
|
||||
borderColor: "#F0F0F0",
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 10,
|
||||
},
|
||||
android: { elevation: 2 },
|
||||
}),
|
||||
},
|
||||
diseaseBanner: {
|
||||
height: 120,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
},
|
||||
severityBadge: {
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
right: 12,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 20,
|
||||
gap: 5,
|
||||
},
|
||||
severityDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
},
|
||||
severityText: {
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
},
|
||||
diseaseContent: {
|
||||
padding: 16,
|
||||
},
|
||||
typePill: {
|
||||
alignSelf: "flex-start",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
typeText: {
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
},
|
||||
diseaseName: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
color: "#1A1A1A",
|
||||
marginBottom: 4,
|
||||
},
|
||||
diseaseSeason: {
|
||||
fontSize: 12,
|
||||
fontWeight: "400",
|
||||
color: "#8E8E93",
|
||||
},
|
||||
|
||||
// Guide card
|
||||
guideCard: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 24,
|
||||
padding: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: "#F0F0F0",
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
android: { elevation: 2 },
|
||||
}),
|
||||
},
|
||||
guideIcon: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 18,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
guideText: {
|
||||
flex: 1,
|
||||
marginLeft: 14,
|
||||
},
|
||||
guideTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: "500",
|
||||
color: "#1A1A1A",
|
||||
marginBottom: 2,
|
||||
},
|
||||
guideSubtitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: "400",
|
||||
color: "#8E8E93",
|
||||
},
|
||||
chevronWrap: {
|
||||
marginLeft: 8,
|
||||
backgroundColor: "#F8F9FA",
|
||||
padding: 6,
|
||||
borderRadius: 12,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { ScanList } from "@/components/history/ScanList";
|
||||
import { useHistory } from "@/hooks/useHistory";
|
||||
import { getCepageById } from "@/utils/cepages";
|
||||
|
|
@ -78,10 +78,14 @@ export default function HistoryScreen() {
|
|||
return (
|
||||
<SafeAreaView style={styles.safe} edges={["top"]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("history.title")}</Text>
|
||||
{/* <Text style={styles.title}>{t("history.title")}</Text> */}
|
||||
|
||||
<View style={styles.header}>
|
||||
<Ionicons name="search-outline" size={22} color={colors.neutral[800]} />
|
||||
<Ionicons
|
||||
name="search-outline"
|
||||
size={22}
|
||||
color={colors.neutral[800]}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.search}
|
||||
placeholder={t("history.search")}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { RootStackParamList } from "@/types/navigation";
|
||||
import { useDiseases } from "@/hooks/useDiseases";
|
||||
import { useGuides } from "@/hooks/useGuides";
|
||||
import SearchHeader from "@/components/home/SearchHeader";
|
||||
import SearchSection from "@/components/home/SearchSection";
|
||||
import SectionHeader from "@/components/home/components/homeheader";
|
||||
|
|
@ -18,6 +20,8 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
|
|||
export default function HomeScreen() {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { data: diseases, isLoading: diseasesLoading } = useDiseases();
|
||||
const { data: guides } = useGuides();
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-[#FAFAFA]" edges={["top"]}>
|
||||
|
|
@ -37,10 +41,10 @@ export default function HomeScreen() {
|
|||
<View className="px-5">
|
||||
<SectionHeader
|
||||
title={t("home.frequentDiseases")}
|
||||
onViewAll={() => navigation.navigate("Guides")}
|
||||
onViewAll={() => navigation.navigate("Main", { screen: "Guides" })}
|
||||
/>
|
||||
</View>
|
||||
<FrequentDiseases />
|
||||
<FrequentDiseases diseases={diseases} isLoading={diseasesLoading} />
|
||||
</View>
|
||||
|
||||
{/* Season alert */}
|
||||
|
|
@ -50,9 +54,9 @@ export default function HomeScreen() {
|
|||
<View className="mx-5 mb-6 gap-3">
|
||||
<SectionHeader
|
||||
title={t("home.practicalGuides")}
|
||||
onViewAll={() => navigation.navigate("Guides")}
|
||||
onViewAll={() => navigation.navigate("Main", { screen: "Guides" })}
|
||||
/>
|
||||
<PracticalGuides />
|
||||
<PracticalGuides guides={guides} />
|
||||
</View>
|
||||
|
||||
<View className="h-8" />
|
||||
|
|
|
|||
|
|
@ -1,296 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
Dimensions,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { colors } from "@/theme/colors";
|
||||
import SearchHeader from "@/components/home/SearchHeader";
|
||||
import SearchSection from "@/components/home/SearchSection";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────
|
||||
|
||||
interface ScannedPlant {
|
||||
id: string;
|
||||
name: string;
|
||||
date: string;
|
||||
color: string;
|
||||
iconColor: string;
|
||||
favorite: boolean;
|
||||
}
|
||||
|
||||
// ─── Mock Data ───────────────────────────────────────────
|
||||
|
||||
const INITIAL_PLANTS: ScannedPlant[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Merlot",
|
||||
date: "2026-04-01",
|
||||
color: "#E9F5EC",
|
||||
iconColor: colors.primary[700],
|
||||
favorite: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Cabernet Sauvignon",
|
||||
date: "2026-03-28",
|
||||
color: "#EEEDFE",
|
||||
iconColor: "#534AB7",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Chardonnay",
|
||||
date: "2026-03-25",
|
||||
color: "#FAEEDA",
|
||||
iconColor: "#BA7517",
|
||||
favorite: true,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "Pinot Noir",
|
||||
date: "2026-03-20",
|
||||
color: "#FAECE7",
|
||||
iconColor: "#993C1D",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "Sauvignon Blanc",
|
||||
date: "2026-03-15",
|
||||
color: "#E6F1FB",
|
||||
iconColor: "#185FA5",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "Grenache",
|
||||
date: "2026-03-10",
|
||||
color: "#FCEBEB",
|
||||
iconColor: "#A32D2D",
|
||||
favorite: true,
|
||||
},
|
||||
];
|
||||
|
||||
const { width } = Dimensions.get("window");
|
||||
const CARD_WIDTH = (width - 56) / 2;
|
||||
|
||||
// ─── Component ───────────────────────────────────────────
|
||||
|
||||
export default function LibraryScreen() {
|
||||
const { t } = useTranslation();
|
||||
const [plants, setPlants] = useState(INITIAL_PLANTS);
|
||||
|
||||
function toggleFavorite(id: string) {
|
||||
setPlants((prev) =>
|
||||
prev.map((p) => (p.id === id ? { ...p, favorite: !p.favorite } : p)),
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
const renderItem = ({ item }: { item: ScannedPlant }) => (
|
||||
<View style={styles.card}>
|
||||
{/* Image placeholder */}
|
||||
<View style={[styles.imagePlaceholder, { backgroundColor: item.color }]}>
|
||||
<Ionicons name="leaf" size={32} color={item.iconColor} />
|
||||
<TouchableOpacity
|
||||
onPress={() => toggleFavorite(item.id)}
|
||||
style={styles.heartBtn}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name={item.favorite ? "heart" : "heart-outline"}
|
||||
size={18}
|
||||
color={item.favorite ? "#EF4444" : "#C7C7CC"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.cardInfo}>
|
||||
<Text style={styles.plantName} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text style={styles.plantDate}>{formatDate(item.date)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderEmpty = () => (
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyIcon}>
|
||||
<Ionicons name="leaf-outline" size={48} color={colors.neutral[300]} />
|
||||
</View>
|
||||
<Text style={styles.emptyTitle}>{t("library.empty.title")}</Text>
|
||||
<Text style={styles.emptyBody}>{t("library.empty.body")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={["top"]}>
|
||||
<FlatList
|
||||
data={plants}
|
||||
keyExtractor={(item) => item.id}
|
||||
numColumns={2}
|
||||
columnWrapperStyle={styles.row}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<SearchHeader />
|
||||
<SearchSection />
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.sectionTitle}>{t("library.title")}</Text>
|
||||
<Text style={styles.countText}>
|
||||
{plants.length} {t("library.plants")}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Styles ──────────────────────────────────────────────
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: {
|
||||
flex: 1,
|
||||
backgroundColor: "#F8F9FB",
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#1A1A1A",
|
||||
letterSpacing: -0.4,
|
||||
},
|
||||
countText: {
|
||||
fontSize: 13,
|
||||
fontWeight: "400",
|
||||
color: "#8E8E93",
|
||||
},
|
||||
row: {
|
||||
paddingHorizontal: 20,
|
||||
gap: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
|
||||
// Card
|
||||
card: {
|
||||
width: CARD_WIDTH,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
borderWidth: 1,
|
||||
borderColor: "#F0F0F0",
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 10,
|
||||
},
|
||||
android: { elevation: 2 },
|
||||
}),
|
||||
},
|
||||
imagePlaceholder: {
|
||||
width: "100%",
|
||||
aspectRatio: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
},
|
||||
heartBtn: {
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 10,
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "#FFFFFF",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
android: { elevation: 3 },
|
||||
}),
|
||||
},
|
||||
cardInfo: {
|
||||
padding: 14,
|
||||
},
|
||||
plantName: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "#1A1A1A",
|
||||
marginBottom: 2,
|
||||
},
|
||||
plantDate: {
|
||||
fontSize: 12,
|
||||
fontWeight: "400",
|
||||
color: "#8E8E93",
|
||||
},
|
||||
|
||||
// Empty state
|
||||
emptyContainer: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: 80,
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
emptyIcon: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: 32,
|
||||
backgroundColor: "#F0F0F0",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 24,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#1A1A1A",
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyBody: {
|
||||
fontSize: 14,
|
||||
fontWeight: "400",
|
||||
color: "#8E8E93",
|
||||
textAlign: "center",
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
297
VinEye/src/screens/MyPlantsScreen.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Search, ScanLine } from 'lucide-react-native';
|
||||
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { DateGroupAccordion } from '@/components/my-plants/DateGroupAccordion';
|
||||
import { useHistory } from '@/hooks/useHistory';
|
||||
import { getCepageById } from '@/utils/cepages';
|
||||
import { groupScansByDate } from '@/utils/dateGrouping';
|
||||
import type { DateGroupKey, DateGroup } from '@/utils/dateGrouping';
|
||||
import { colors } from '@/theme/colors';
|
||||
import type { RootStackParamList } from '@/types/navigation';
|
||||
import type { ScanRecord } from '@/types/detection';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const DEFAULT_OPEN: Set<DateGroupKey> = new Set(['today', 'yesterday', 'thisWeek']);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const EMPTY_IMAGE = require('../../assets/logo.png');
|
||||
|
||||
export default function MyPlantsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<Nav>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { history, isLoading, deleteScan, toggleFavorite, reload } = useHistory();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [openGroups, setOpenGroups] = useState<Set<DateGroupKey>>(
|
||||
new Set(DEFAULT_OPEN),
|
||||
);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Reload scans when screen regains focus
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
reload();
|
||||
}, [reload]),
|
||||
);
|
||||
|
||||
// Filter scans by search query
|
||||
const filteredScans = useMemo(() => {
|
||||
if (!searchQuery.trim()) return history;
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
return history.filter((scan) => {
|
||||
// Search by cepage name
|
||||
if (scan.detection.cepageId) {
|
||||
const c = getCepageById(scan.detection.cepageId);
|
||||
if (
|
||||
c?.name.fr.toLowerCase().includes(q) ||
|
||||
c?.name.en.toLowerCase().includes(q)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Search by result label
|
||||
const resultLabel =
|
||||
scan.detection.result === 'vine'
|
||||
? t('result.vineDetected')
|
||||
: scan.detection.result === 'uncertain'
|
||||
? t('result.uncertain')
|
||||
: t('result.notVine');
|
||||
return resultLabel.toLowerCase().includes(q);
|
||||
});
|
||||
}, [history, searchQuery, t]);
|
||||
|
||||
// Group filtered scans by date
|
||||
const groups = useMemo(() => groupScansByDate(filteredScans), [filteredScans]);
|
||||
|
||||
function toggleGroup(key: DateGroupKey) {
|
||||
setOpenGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handlePressScan(scan: ScanRecord) {
|
||||
navigation.navigate('ScanDetail', { scanId: scan.id });
|
||||
}
|
||||
|
||||
function handleDeleteScan(scanId: string) {
|
||||
Alert.alert(
|
||||
t('myPlants.actions.deleteConfirmTitle'),
|
||||
t('myPlants.actions.deleteConfirmMessage'),
|
||||
[
|
||||
{ text: t('myPlants.actions.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('myPlants.actions.delete'),
|
||||
style: 'destructive',
|
||||
onPress: () => deleteScan(scanId),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
setRefreshing(true);
|
||||
await reload();
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
function renderGroup({ item }: { item: DateGroup }) {
|
||||
return (
|
||||
<DateGroupAccordion
|
||||
groupKey={item.key}
|
||||
label={item.label}
|
||||
scans={item.scans}
|
||||
isOpen={openGroups.has(item.key)}
|
||||
onToggle={() => toggleGroup(item.key)}
|
||||
onPressScan={handlePressScan}
|
||||
onToggleFavorite={(id) => toggleFavorite(id)}
|
||||
onDeleteScan={handleDeleteScan}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = history.length === 0 && !isLoading;
|
||||
|
||||
return (
|
||||
<View style={[styles.root, { paddingTop: insets.top }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t('myPlants.title')}</Text>
|
||||
</View>
|
||||
|
||||
{/* Search bar */}
|
||||
<View style={styles.searchContainer}>
|
||||
<View style={styles.searchWrapper}>
|
||||
<Search size={20} color={colors.neutral[400]} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder={t('myPlants.searchPlaceholder')}
|
||||
placeholderTextColor={colors.neutral[400]}
|
||||
selectionColor={colors.primary[500]}
|
||||
autoCorrect={false}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setSearchQuery('')}
|
||||
style={styles.clearBtn}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="close-circle" size={18} color={colors.neutral[400]} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
{isEmpty ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyIconWrapper}>
|
||||
<Image
|
||||
source={EMPTY_IMAGE}
|
||||
style={styles.emptyImage}
|
||||
contentFit="contain"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.emptyTitle}>{t('myPlants.empty.title')}</Text>
|
||||
<Text style={styles.emptySubtitle}>{t('myPlants.empty.subtitle')}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.emptyCta}
|
||||
onPress={() => navigation.navigate('Main', { screen: 'Scanner' })}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ScanLine size={18} color="#FFFFFF" />
|
||||
<Text style={styles.emptyCtaText}>{t('myPlants.empty.cta')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={groups}
|
||||
keyExtractor={(item) => item.key}
|
||||
renderItem={renderGroup}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8F9FB',
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
color: '#1A1A1A',
|
||||
},
|
||||
// Search
|
||||
searchContainer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
searchWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F5F7F9',
|
||||
borderRadius: 100,
|
||||
paddingHorizontal: 16,
|
||||
height: 48,
|
||||
borderWidth: 1,
|
||||
borderColor: '#EAECEF',
|
||||
gap: 10,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
color: colors.neutral[900],
|
||||
paddingVertical: 0,
|
||||
height: '100%',
|
||||
},
|
||||
clearBtn: {
|
||||
padding: 4,
|
||||
},
|
||||
// List
|
||||
listContent: {
|
||||
paddingBottom: 100,
|
||||
},
|
||||
// Empty state
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 40,
|
||||
gap: 12,
|
||||
},
|
||||
emptyIconWrapper: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: 32,
|
||||
// backgroundColor: '#F0F0F0',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyImage: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#1A1A1A',
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '400',
|
||||
color: '#8E8E93',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
emptyCta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
backgroundColor: colors.primary[800],
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 100,
|
||||
marginTop: 8,
|
||||
},
|
||||
emptyCtaText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
411
VinEye/src/screens/ScanDetailScreen.tsx
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
import { useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
Share,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import {
|
||||
ChevronLeft,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
HelpCircle,
|
||||
XCircle,
|
||||
Calendar,
|
||||
Award,
|
||||
MapPin,
|
||||
Share2,
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
import { toast } from 'sonner-native';
|
||||
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { useScanDetail } from '@/hooks/useScanDetail';
|
||||
import { getCepageById } from '@/utils/cepages';
|
||||
import { hapticSuccess } from '@/services/haptics';
|
||||
import { colors } from '@/theme/colors';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '@/types/navigation';
|
||||
import type { DetectionResult } from '@/types/detection';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'ScanDetail'>;
|
||||
|
||||
const RESULT_STYLES: Record<DetectionResult, { bg: string; text: string; Icon: typeof CheckCircle2; labelKey: string }> = {
|
||||
vine: { bg: '#E8F5E9', text: '#2D6A4F', Icon: CheckCircle2, labelKey: 'myPlants.detail.results.vine' },
|
||||
uncertain: { bg: '#FFF4E5', text: '#E67E22', Icon: HelpCircle, labelKey: 'myPlants.detail.results.uncertain' },
|
||||
not_vine: { bg: '#FFEBEE', text: '#C62828', Icon: XCircle, labelKey: 'myPlants.detail.results.notVine' },
|
||||
};
|
||||
|
||||
const FALLBACK_IMAGE = require('../../assets/logo.png');
|
||||
|
||||
function formatDateLong(iso: string, locale: string): string {
|
||||
const d = new Date(iso);
|
||||
const dateStr = d.toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
const timeStr = d.toLocaleTimeString(locale === 'fr' ? 'fr-FR' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return locale === 'fr' ? `${dateStr} à ${timeStr}` : `${dateStr} at ${timeStr}`;
|
||||
}
|
||||
|
||||
export default function ScanDetailScreen({ route }: Props) {
|
||||
const { scanId } = route.params;
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { scan, loading, error, toggleFavorite, deleteScan } = useScanDetail(scanId);
|
||||
|
||||
// Entry animation
|
||||
const contentY = useSharedValue(30);
|
||||
const contentOpacity = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (scan) {
|
||||
const timing = { duration: 400, easing: Easing.bezier(0.25, 0.1, 0.25, 1) };
|
||||
contentY.value = withTiming(0, timing);
|
||||
contentOpacity.value = withTiming(1, timing);
|
||||
}
|
||||
}, [scan, contentY, contentOpacity]);
|
||||
|
||||
const contentAnim = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: contentY.value }],
|
||||
opacity: contentOpacity.value,
|
||||
}));
|
||||
|
||||
// Confidence bar animation
|
||||
const barWidth = useSharedValue(0);
|
||||
useEffect(() => {
|
||||
if (scan) {
|
||||
barWidth.value = withTiming(scan.detection.confidence, { duration: 600 });
|
||||
}
|
||||
}, [scan, barWidth]);
|
||||
|
||||
const barAnim = useAnimatedStyle(() => ({
|
||||
width: `${barWidth.value}%`,
|
||||
}));
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color={colors.primary[700]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Error / not found
|
||||
if (error || !scan) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<AlertCircle size={48} color={colors.neutral[400]} />
|
||||
<Text style={styles.errorText}>{t('myPlants.detail.notFound')}</Text>
|
||||
<TouchableOpacity style={styles.errorBtn} onPress={() => navigation.goBack()}>
|
||||
<Text style={styles.errorBtnText}>{t('myPlants.detail.goBack')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const { detection } = scan;
|
||||
const cepage = detection.cepageId ? getCepageById(detection.cepageId) : null;
|
||||
const resultStyle = RESULT_STYLES[detection.result];
|
||||
const isFav = scan.isFavorite === true;
|
||||
const hasImage = !!detection.imageUri;
|
||||
|
||||
const heroTitle = cepage
|
||||
? cepage.name.fr
|
||||
: detection.result === 'vine'
|
||||
? t('myPlants.detail.results.vine')
|
||||
: t('myPlants.detail.results.unidentified');
|
||||
|
||||
async function handleToggleFavorite() {
|
||||
await toggleFavorite();
|
||||
hapticSuccess();
|
||||
toast.success(isFav ? t('myPlants.toasts.unfavorited') : t('myPlants.toasts.favorited'));
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
Alert.alert(
|
||||
t('myPlants.actions.deleteConfirmTitle'),
|
||||
t('myPlants.actions.deleteConfirmMessage'),
|
||||
[
|
||||
{ text: t('myPlants.actions.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('myPlants.actions.delete'),
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await deleteScan();
|
||||
hapticSuccess();
|
||||
toast.success(t('myPlants.toasts.deleted'));
|
||||
navigation.goBack();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
Alert.alert(
|
||||
t('myPlants.detail.shareConfirmTitle'),
|
||||
t('myPlants.detail.shareConfirmMessage'),
|
||||
[
|
||||
{ text: t('myPlants.actions.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('myPlants.detail.shareAction'),
|
||||
onPress: async () => {
|
||||
if (!scan) return;
|
||||
const name = cepage?.name.fr ?? t('myPlants.detail.results.unidentified');
|
||||
const date = formatDateLong(scan.createdAt, i18n.language);
|
||||
const text = `${t('myPlants.detail.shareText')}\n\n${name}\n${t('myPlants.detail.confidence')} : ${detection.confidence}%\n${date}`;
|
||||
try {
|
||||
await Share.share({
|
||||
message: text,
|
||||
...(detection.imageUri ? { url: detection.imageUri } : {}),
|
||||
});
|
||||
} catch {
|
||||
toast.error(t('myPlants.detail.shareError'));
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const ResultIcon = resultStyle.Icon;
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 80 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* ── Hero ── */}
|
||||
<View style={styles.heroContainer}>
|
||||
<Image
|
||||
source={hasImage ? { uri: detection.imageUri } : FALLBACK_IMAGE}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
contentFit={hasImage ? 'cover' : 'contain'}
|
||||
transition={300}
|
||||
/>
|
||||
<LinearGradient colors={['rgba(0,0,0,0.35)', 'transparent']} style={styles.gradientTop} />
|
||||
<LinearGradient colors={['transparent', '#F8F9FB']} style={styles.gradientBottom} />
|
||||
<Text style={styles.heroTitle}>{heroTitle}</Text>
|
||||
</View>
|
||||
|
||||
{/* ── Floating buttons ── */}
|
||||
<TouchableOpacity
|
||||
style={[styles.floatingBtn, { top: insets.top + 8, left: 16 }]}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<ChevronLeft size={22} color="#1A1A1A" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.floatingBtn, { top: insets.top + 8, right: 16 }]}
|
||||
activeOpacity={0.8}
|
||||
onPress={handleToggleFavorite}
|
||||
>
|
||||
<Star size={20} color={isFav ? '#FFB800' : '#1A1A1A'} fill={isFav ? '#FFB800' : 'none'} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<Animated.View style={contentAnim}>
|
||||
{/* Result Card */}
|
||||
<View style={styles.resultCard}>
|
||||
<View style={[styles.badgePill, { backgroundColor: resultStyle.bg }]}>
|
||||
<ResultIcon size={16} color={resultStyle.text} />
|
||||
<Text style={[styles.badgeText, { color: resultStyle.text }]}>
|
||||
{t(resultStyle.labelKey)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.confidenceRow}>
|
||||
<Text style={styles.confidenceLabel}>{t('myPlants.detail.confidence')}</Text>
|
||||
<Text style={[styles.confidenceValue, { color: resultStyle.text }]}>
|
||||
{detection.confidence}%
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.barTrack}>
|
||||
<Animated.View style={[styles.barFill, { backgroundColor: resultStyle.text }, barAnim]} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Cepage Card */}
|
||||
{detection.result === 'vine' && cepage && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>{t('myPlants.detail.cepageSection')}</Text>
|
||||
<Text style={styles.cepageName}>{cepage.name.fr}</Text>
|
||||
<Text style={styles.cepageNameEn}>{cepage.name.en}</Text>
|
||||
<View style={styles.tagsRow}>
|
||||
<View style={[styles.tag, { backgroundColor: 'rgba(45,106,79,0.1)' }]}>
|
||||
<Text style={[styles.tagText, { color: '#2D6A4F' }]}>
|
||||
{cepage.color === 'rouge' ? '🍷 Rouge' : cepage.color === 'blanc' ? '🥂 Blanc' : '🌸 Rosé'}
|
||||
</Text>
|
||||
</View>
|
||||
{cepage.regions.slice(0, 2).map((r) => (
|
||||
<View key={r} style={[styles.tag, { backgroundColor: '#F0F0F0' }]}>
|
||||
<Text style={[styles.tagText, { color: '#444' }]}>{r}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<Text style={styles.cepageDesc}>{cepage.characteristics.fr}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Meta Card */}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.metaRow}>
|
||||
<Calendar size={18} color={colors.primary[700]} />
|
||||
<View style={styles.metaContent}>
|
||||
<Text style={styles.metaLabel}>{t('myPlants.detail.scannedOn')}</Text>
|
||||
<Text style={styles.metaValue}>{formatDateLong(scan.createdAt, i18n.language)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.metaDivider} />
|
||||
<View style={styles.metaRow}>
|
||||
<Award size={18} color={colors.primary[700]} />
|
||||
<View style={styles.metaContent}>
|
||||
<Text style={styles.metaLabel}>{t('myPlants.detail.xpEarned')}</Text>
|
||||
<Text style={styles.metaValue}>+{scan.xpEarned} XP</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Location Card */}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.metaRow}>
|
||||
<MapPin size={18} color={colors.primary[700]} />
|
||||
<Text style={styles.cardTitle}>{t('myPlants.detail.location')}</Text>
|
||||
</View>
|
||||
{scan.location ? (
|
||||
<View style={{ marginTop: 8 }}>
|
||||
<Text style={styles.locationName}>
|
||||
{scan.location.placeName ?? 'Lieu inconnu'}
|
||||
</Text>
|
||||
<Text style={styles.locationCoords}>
|
||||
{scan.location.latitude.toFixed(6)}, {scan.location.longitude.toFixed(6)}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginTop: 8 }}>
|
||||
<Text style={styles.noLocation}>{t('myPlants.detail.noLocation')}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.addLocationBtn}
|
||||
onPress={() => console.warn('[ScanDetail] add location — to be implemented in prompt 3')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MapPin size={14} color={colors.primary[700]} />
|
||||
<Text style={styles.addLocationText}>{t('myPlants.detail.addLocation')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
|
||||
{/* ── Bottom Action Bar ── */}
|
||||
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + 12 }]}>
|
||||
<TouchableOpacity style={styles.shareBtn} onPress={handleShare} activeOpacity={0.7}>
|
||||
<Share2 size={18} color="#1A1A1A" />
|
||||
<Text style={styles.shareBtnText}>{t('myPlants.detail.share')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.deleteBottomBtn} onPress={handleDelete} activeOpacity={0.7}>
|
||||
<Trash2 size={18} color="#C62828" />
|
||||
<Text style={styles.deleteBtnText}>{t('myPlants.detail.delete')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: '#F8F9FB' },
|
||||
centered: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#F8F9FB', gap: 16 },
|
||||
errorText: { fontSize: 16, fontWeight: '600', color: '#1A1A1A' },
|
||||
errorBtn: { paddingHorizontal: 24, paddingVertical: 12, backgroundColor: colors.primary[700], borderRadius: 100 },
|
||||
errorBtnText: { fontSize: 15, fontWeight: '600', color: '#FFFFFF' },
|
||||
|
||||
// Hero
|
||||
heroContainer: { height: 380, position: 'relative', backgroundColor: '#E0E0E0', borderBottomLeftRadius: 32, borderBottomRightRadius: 32, overflow: 'hidden' },
|
||||
gradientTop: { position: 'absolute', top: 0, left: 0, right: 0, height: 100 },
|
||||
gradientBottom: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 100 },
|
||||
heroTitle: { position: 'absolute', bottom: 24, left: 20, right: 20, fontSize: 28, fontWeight: '700', color: '#FFFFFF', textShadowColor: 'rgba(0,0,0,0.5)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 4 },
|
||||
|
||||
// Floating buttons
|
||||
floatingBtn: {
|
||||
position: 'absolute',
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...Platform.select({
|
||||
ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4 },
|
||||
android: { elevation: 3 },
|
||||
}),
|
||||
},
|
||||
|
||||
// Result card
|
||||
resultCard: { backgroundColor: '#FFFFFF', borderRadius: 24, padding: 20, marginHorizontal: 16, marginTop: -24, borderWidth: 1, borderColor: '#F0F0F0', gap: 12 },
|
||||
badgePill: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', gap: 6, paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20 },
|
||||
badgeText: { fontSize: 13, fontWeight: '600' },
|
||||
confidenceRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||
confidenceLabel: { fontSize: 14, color: '#8E8E93' },
|
||||
confidenceValue: { fontSize: 20, fontWeight: '700' },
|
||||
barTrack: { height: 8, borderRadius: 4, backgroundColor: '#F0F0F0', overflow: 'hidden' },
|
||||
barFill: { height: 8, borderRadius: 4 },
|
||||
|
||||
// Generic card
|
||||
card: { backgroundColor: '#FFFFFF', borderRadius: 24, padding: 20, marginHorizontal: 16, marginTop: 12, borderWidth: 1, borderColor: '#F0F0F0' },
|
||||
cardTitle: { fontSize: 16, fontWeight: '600', color: '#1A1A1A' },
|
||||
|
||||
// Cepage
|
||||
cepageName: { fontSize: 22, fontWeight: '700', color: '#1A1A1A', marginTop: 8 },
|
||||
cepageNameEn: { fontSize: 14, color: '#8E8E93', marginTop: 2 },
|
||||
tagsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 12 },
|
||||
tag: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16 },
|
||||
tagText: { fontSize: 13, fontWeight: '600' },
|
||||
cepageDesc: { fontSize: 14, lineHeight: 22, color: '#444444', marginTop: 12 },
|
||||
|
||||
// Meta
|
||||
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
||||
metaContent: { flex: 1, gap: 2 },
|
||||
metaLabel: { fontSize: 12, color: '#8E8E93' },
|
||||
metaValue: { fontSize: 15, fontWeight: '600', color: '#1A1A1A' },
|
||||
metaDivider: { height: 1, backgroundColor: '#F0F0F0', marginVertical: 14 },
|
||||
|
||||
// Location
|
||||
locationName: { fontSize: 15, fontWeight: '600', color: '#1A1A1A' },
|
||||
locationCoords: { fontSize: 12, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', color: '#8E8E93', marginTop: 4 },
|
||||
noLocation: { fontSize: 14, color: '#8E8E93' },
|
||||
addLocationBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 12, paddingVertical: 10, paddingHorizontal: 16, backgroundColor: 'rgba(45,106,79,0.08)', borderRadius: 12, alignSelf: 'flex-start' },
|
||||
addLocationText: { fontSize: 14, fontWeight: '600', color: colors.primary[700] },
|
||||
|
||||
// Bottom bar
|
||||
bottomBar: { position: 'absolute', bottom: 0, left: 0, right: 0, flexDirection: 'row', gap: 12, paddingTop: 12, paddingHorizontal: 16, backgroundColor: '#FFFFFF', borderTopWidth: 1, borderTopColor: '#F0F0F0' },
|
||||
shareBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, backgroundColor: '#F0F0F0', borderRadius: 16, paddingVertical: 14 },
|
||||
shareBtnText: { fontSize: 15, fontWeight: '600', color: '#1A1A1A' },
|
||||
deleteBottomBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, backgroundColor: '#FFEBEE', borderRadius: 16, paddingVertical: 14 },
|
||||
deleteBtnText: { fontSize: 15, fontWeight: '600', color: '#C62828' },
|
||||
});
|
||||
|
|
@ -13,7 +13,7 @@ export default function SplashScreen() {
|
|||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
navigation.replace('Main');
|
||||
navigation.replace('Main', { screen: 'Home' });
|
||||
}, 2800);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
|
|
|
|||
83
VinEye/src/services/api/client.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { API_CONFIG } from "@/config/api";
|
||||
|
||||
if (__DEV__) {
|
||||
console.log("[VinEye API] Base URL:", API_CONFIG.baseUrl);
|
||||
}
|
||||
|
||||
export type ApiError = {
|
||||
type: "NETWORK" | "TIMEOUT" | "SERVER" | "PARSE" | "UNKNOWN";
|
||||
message: string;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
export type ApiResponse<T> =
|
||||
| { success: true; data: T; pagination?: { page: number; limit: number; total: number; pages: number } }
|
||||
| { success: false; error: ApiError };
|
||||
|
||||
export async function apiGet<T>(
|
||||
endpoint: string,
|
||||
params?: Record<string, string>,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const url = new URL(`${API_CONFIG.baseUrl}${endpoint}`);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) url.searchParams.set(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), API_CONFIG.timeout);
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: "SERVER",
|
||||
message: `Server responded with ${res.status}`,
|
||||
status: res.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: json.data as T,
|
||||
pagination: json.pagination,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
return {
|
||||
success: false,
|
||||
error: { type: "TIMEOUT", message: "Request timed out" },
|
||||
};
|
||||
}
|
||||
|
||||
if (err instanceof TypeError) {
|
||||
return {
|
||||
success: false,
|
||||
error: { type: "NETWORK", message: "No network connection" },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: "UNKNOWN",
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
69
VinEye/src/services/api/diseases.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { apiGet, type ApiResponse } from "./client";
|
||||
|
||||
export interface ApiDiseaseImage {
|
||||
id: string;
|
||||
url: string;
|
||||
alt: string | null;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface ApiDisease {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
scientificName: string;
|
||||
type: "FUNGAL" | "BACTERIAL" | "PEST" | "ABIOTIC";
|
||||
severity: "LOW" | "MEDIUM" | "HIGH";
|
||||
description: string;
|
||||
descriptionEn: string;
|
||||
symptoms: string[];
|
||||
symptomsEn: string[];
|
||||
treatment: string;
|
||||
treatmentEn: string;
|
||||
season: string;
|
||||
seasonEn: string;
|
||||
iconName: string;
|
||||
iconColor: string;
|
||||
bgColor: string;
|
||||
imageUrl: string | null;
|
||||
createdAt: string;
|
||||
// Enriched fields
|
||||
startMonth: number | null;
|
||||
endMonth: number | null;
|
||||
peakMonth: number | null;
|
||||
conditions: string[];
|
||||
conditionsEn: string[];
|
||||
preventiveActions: string[];
|
||||
preventiveActionsEn: string[];
|
||||
curativeActions: string[];
|
||||
curativeActionsEn: string[];
|
||||
impactedParts: string[];
|
||||
impactedPartsEn: string[];
|
||||
spreadMethod: string | null;
|
||||
spreadMethodEn: string | null;
|
||||
images: ApiDiseaseImage[];
|
||||
}
|
||||
|
||||
interface FetchDiseasesParams {
|
||||
severity?: string;
|
||||
type?: string;
|
||||
search?: string;
|
||||
page?: string;
|
||||
limit?: string;
|
||||
}
|
||||
|
||||
export function fetchDiseases(
|
||||
params?: FetchDiseasesParams,
|
||||
): Promise<ApiResponse<ApiDisease[]>> {
|
||||
const clean = params
|
||||
? Object.fromEntries(Object.entries(params).filter(([, v]) => v !== undefined)) as Record<string, string>
|
||||
: undefined;
|
||||
return apiGet<ApiDisease[]>("/diseases", clean);
|
||||
}
|
||||
|
||||
export function fetchDiseaseBySlug(
|
||||
slug: string,
|
||||
): Promise<ApiResponse<ApiDisease>> {
|
||||
return apiGet<ApiDisease>(`/diseases/${slug}`);
|
||||
}
|
||||
56
VinEye/src/services/api/guides.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { apiGet, type ApiResponse } from "./client";
|
||||
|
||||
export interface ApiGuideSection {
|
||||
id: string;
|
||||
title: string;
|
||||
titleEn: string | null;
|
||||
body: string;
|
||||
bodyEn: string | null;
|
||||
image: string | null;
|
||||
tip: string | null;
|
||||
tipEn: string | null;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface ApiGuide {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEn: string;
|
||||
subtitle: string;
|
||||
subtitleEn: string;
|
||||
content: string;
|
||||
contentEn: string;
|
||||
category: string;
|
||||
iconName: string;
|
||||
iconColor: string;
|
||||
bgColor: string;
|
||||
order: number;
|
||||
createdAt: string;
|
||||
// Enriched fields
|
||||
readTime: number | null;
|
||||
coverImage: string | null;
|
||||
sections: ApiGuideSection[];
|
||||
}
|
||||
|
||||
interface FetchGuidesParams {
|
||||
category?: string;
|
||||
search?: string;
|
||||
page?: string;
|
||||
limit?: string;
|
||||
}
|
||||
|
||||
export function fetchGuides(
|
||||
params?: FetchGuidesParams,
|
||||
): Promise<ApiResponse<ApiGuide[]>> {
|
||||
const clean = params
|
||||
? Object.fromEntries(Object.entries(params).filter(([, v]) => v !== undefined)) as Record<string, string>
|
||||
: undefined;
|
||||
return apiGet<ApiGuide[]>("/guides", clean);
|
||||
}
|
||||
|
||||
export function fetchGuideBySlug(
|
||||
slug: string,
|
||||
): Promise<ApiResponse<ApiGuide>> {
|
||||
return apiGet<ApiGuide>(`/guides/${slug}`);
|
||||
}
|
||||
124
VinEye/src/services/api/mappers.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import type { ApiDisease } from "./diseases";
|
||||
import type { ApiGuide } from "./guides";
|
||||
import type { Disease } from "@/data/diseases";
|
||||
import type { Guide, GuideSection } from "@/data/guides";
|
||||
|
||||
// ── Slug → i18n key mapping ──
|
||||
|
||||
const DISEASE_SLUG_MAP: Record<string, string> = {
|
||||
mildiou: "mildiou",
|
||||
oidium: "oidium",
|
||||
"black-rot": "blackRot",
|
||||
esca: "esca",
|
||||
botrytis: "botrytis",
|
||||
"flavescence-doree": "flavescence",
|
||||
"chlorose-ferrique": "chlorose",
|
||||
};
|
||||
|
||||
const GUIDE_SLUG_MAP: Record<string, string> = {
|
||||
"reconnaitre-feuille-saine": "healthyLeaf",
|
||||
"calendrier-traitement": "treatmentCalendar",
|
||||
"cepages-bordelais": "grapeVarieties",
|
||||
};
|
||||
|
||||
function slugToCamel(slug: string): string {
|
||||
return slug.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// ── Disease mapper ──
|
||||
|
||||
export function mapApiDiseaseToLocal(api: ApiDisease): Disease {
|
||||
const key = DISEASE_SLUG_MAP[api.slug] ?? slugToCamel(api.slug);
|
||||
|
||||
// Build images array: prefer relation images, fallback to imageUrl
|
||||
const images = api.images.length > 0
|
||||
? api.images.sort((a, b) => a.order - b.order).map((i) => i.url)
|
||||
: api.imageUrl
|
||||
? [api.imageUrl]
|
||||
: [];
|
||||
|
||||
// Build symptom i18n keys
|
||||
const symptoms = api.symptoms.map((_, i) => `diseases.${key}.symptom${i + 1}`);
|
||||
|
||||
// Build detail array i18n keys
|
||||
const conditions = api.conditions.map((_, i) => `diseases.${key}.condition${i + 1}`);
|
||||
const preventiveActions = api.preventiveActions.map((_, i) => `diseases.${key}.preventive${i + 1}`);
|
||||
const curativeActions = api.curativeActions.map((_, i) => `diseases.${key}.curative${i + 1}`);
|
||||
const impactedParts = api.impactedParts.map((_, i) => `diseases.${key}.part${i + 1}`);
|
||||
|
||||
return {
|
||||
id: api.slug.replace(/-/g, "_"),
|
||||
name: `diseases.${key}.name`,
|
||||
type: api.type.toLowerCase() as Disease["type"],
|
||||
icon: mapIconName(api.iconName),
|
||||
iconColor: api.iconColor,
|
||||
bgColor: api.bgColor,
|
||||
severity: api.severity.toLowerCase() as Disease["severity"],
|
||||
description: `diseases.${key}.description`,
|
||||
symptoms,
|
||||
treatment: `diseases.${key}.treatment`,
|
||||
season: `diseases.${key}.season`,
|
||||
images,
|
||||
timeline: {
|
||||
startMonth: api.startMonth ?? 5,
|
||||
endMonth: api.endMonth ?? 9,
|
||||
peakMonth: api.peakMonth ?? 7,
|
||||
},
|
||||
conditions,
|
||||
preventiveActions,
|
||||
curativeActions,
|
||||
impactedParts,
|
||||
spreadMethod: `diseases.${key}.spread`,
|
||||
};
|
||||
}
|
||||
|
||||
// Map Lucide icon names (backend) to Ionicons (mobile)
|
||||
function mapIconName(name: string): string {
|
||||
const MAP: Record<string, string> = {
|
||||
droplets: "water-outline",
|
||||
wind: "snow-outline",
|
||||
circle: "ellipse",
|
||||
"tree-deciduous": "leaf-outline",
|
||||
"cloud-rain": "cloud-outline",
|
||||
bug: "warning-outline",
|
||||
leaf: "sunny-outline",
|
||||
};
|
||||
return MAP[name] ?? name;
|
||||
}
|
||||
|
||||
// ── Guide mapper ──
|
||||
|
||||
export function mapApiGuideToLocal(api: ApiGuide): Guide {
|
||||
const key = GUIDE_SLUG_MAP[api.slug] ?? slugToCamel(api.slug);
|
||||
|
||||
// Map sections from API (direct text, not i18n keys)
|
||||
const sections: GuideSection[] = api.sections
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s) => ({
|
||||
title: s.title,
|
||||
body: s.body,
|
||||
image: s.image ?? undefined,
|
||||
tip: s.tip ?? undefined,
|
||||
}));
|
||||
|
||||
// Map category to the local union type
|
||||
const CATEGORY_MAP: Record<string, Guide["category"]> = {
|
||||
diagnostic: "beginner",
|
||||
traitement: "treatment",
|
||||
cepages: "varieties",
|
||||
general: "beginner",
|
||||
};
|
||||
|
||||
return {
|
||||
id: api.slug.replace(/-/g, "_"),
|
||||
title: `guides.${key}.title`,
|
||||
subtitle: `guides.${key}.subtitle`,
|
||||
icon: mapIconName(api.iconName),
|
||||
iconColor: api.iconColor,
|
||||
bgColor: api.bgColor,
|
||||
category: CATEGORY_MAP[api.category] ?? "beginner",
|
||||
readTime: api.readTime ?? 5,
|
||||
image: api.coverImage ?? "",
|
||||
content: sections.length > 0 ? sections : [],
|
||||
};
|
||||
}
|
||||
59
VinEye/src/services/cache/cacheManager.ts
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { API_CONFIG } from "@/config/api";
|
||||
|
||||
const PREFIX = "vineye_cache_";
|
||||
|
||||
interface CachedEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
export async function cacheGet<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(PREFIX + key);
|
||||
if (!raw) return null;
|
||||
|
||||
const entry: CachedEntry<T> = JSON.parse(raw);
|
||||
if (Date.now() - entry.timestamp > entry.ttl) {
|
||||
// Expired — remove silently
|
||||
AsyncStorage.removeItem(PREFIX + key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cacheSet<T>(
|
||||
key: string,
|
||||
data: T,
|
||||
ttl: number = API_CONFIG.cacheTTL,
|
||||
): Promise<void> {
|
||||
const entry: CachedEntry<T> = { data, timestamp: Date.now(), ttl };
|
||||
await AsyncStorage.setItem(PREFIX + key, JSON.stringify(entry));
|
||||
}
|
||||
|
||||
export async function cacheIsValid(key: string): Promise<boolean> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(PREFIX + key);
|
||||
if (!raw) return false;
|
||||
|
||||
const entry: CachedEntry<unknown> = JSON.parse(raw);
|
||||
return Date.now() - entry.timestamp <= entry.ttl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cacheClear(keyPrefix?: string): Promise<void> {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const toRemove = keys.filter((k) =>
|
||||
keyPrefix ? k.startsWith(PREFIX + keyPrefix) : k.startsWith(PREFIX),
|
||||
);
|
||||
if (toRemove.length > 0) {
|
||||
await AsyncStorage.multiRemove(toRemove);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,4 +13,10 @@ export interface ScanRecord {
|
|||
detection: Detection;
|
||||
xpEarned: number;
|
||||
createdAt: string; // ISO date
|
||||
isFavorite?: boolean;
|
||||
location?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
placeName?: string;
|
||||
} | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
import type { NavigatorScreenParams } from '@react-navigation/native';
|
||||
import type { Detection } from './detection';
|
||||
|
||||
export type RootStackParamList = {
|
||||
Splash: undefined;
|
||||
Main: undefined;
|
||||
Result: { detection: Detection };
|
||||
Notifications: undefined;
|
||||
Profile: undefined;
|
||||
Settings: undefined;
|
||||
Guides: undefined;
|
||||
Library: undefined;
|
||||
};
|
||||
|
||||
export type BottomTabParamList = {
|
||||
Home: undefined;
|
||||
Guides: undefined;
|
||||
Scanner: undefined;
|
||||
Library: undefined;
|
||||
MyPlants: undefined;
|
||||
Map: undefined;
|
||||
};
|
||||
|
||||
export type RootStackParamList = {
|
||||
Splash: undefined;
|
||||
Main: NavigatorScreenParams<BottomTabParamList>;
|
||||
Result: { detection: Detection };
|
||||
Notifications: undefined;
|
||||
Profile: undefined;
|
||||
Settings: undefined;
|
||||
DiseaseDetail: { diseaseId: string };
|
||||
GuideDetail: { guideId: string };
|
||||
ScanDetail: { scanId: string };
|
||||
};
|
||||
|
|
|
|||
82
VinEye/src/utils/dateGrouping.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { ScanRecord } from '@/types/detection';
|
||||
|
||||
export type DateGroupKey = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth' | 'older';
|
||||
|
||||
export interface DateGroup {
|
||||
key: DateGroupKey;
|
||||
label: string;
|
||||
scans: ScanRecord[];
|
||||
}
|
||||
|
||||
function startOfDay(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function getMondayOfWeek(date: Date): Date {
|
||||
const d = startOfDay(date);
|
||||
const day = d.getDay();
|
||||
// getDay(): 0=Sun, 1=Mon... shift so Monday=0
|
||||
const diff = day === 0 ? 6 : day - 1;
|
||||
d.setDate(d.getDate() - diff);
|
||||
return d;
|
||||
}
|
||||
|
||||
function getDateGroupKey(scanDate: Date, now: Date): DateGroupKey {
|
||||
const todayStart = startOfDay(now);
|
||||
const scanStart = startOfDay(scanDate);
|
||||
const scanTime = scanStart.getTime();
|
||||
|
||||
// Today
|
||||
if (scanTime === todayStart.getTime()) return 'today';
|
||||
|
||||
// Yesterday
|
||||
const yesterdayStart = new Date(todayStart);
|
||||
yesterdayStart.setDate(yesterdayStart.getDate() - 1);
|
||||
if (scanTime === yesterdayStart.getTime()) return 'yesterday';
|
||||
|
||||
// This week (Monday to today)
|
||||
const mondayStart = getMondayOfWeek(now);
|
||||
if (scanTime >= mondayStart.getTime() && scanTime < yesterdayStart.getTime()) {
|
||||
return 'thisWeek';
|
||||
}
|
||||
|
||||
// This month (1st to Monday of this week)
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
if (scanTime >= monthStart.getTime() && scanTime < mondayStart.getTime()) {
|
||||
return 'thisMonth';
|
||||
}
|
||||
|
||||
return 'older';
|
||||
}
|
||||
|
||||
const GROUP_ORDER: DateGroupKey[] = ['today', 'yesterday', 'thisWeek', 'thisMonth', 'older'];
|
||||
|
||||
export function groupScansByDate(scans: ScanRecord[]): DateGroup[] {
|
||||
const now = new Date();
|
||||
const grouped = new Map<DateGroupKey, ScanRecord[]>();
|
||||
|
||||
for (const scan of scans) {
|
||||
const key = getDateGroupKey(new Date(scan.createdAt), now);
|
||||
const list = grouped.get(key) ?? [];
|
||||
list.push(scan);
|
||||
grouped.set(key, list);
|
||||
}
|
||||
|
||||
// Sort scans within each group by date desc
|
||||
for (const list of grouped.values()) {
|
||||
list.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
// Return only non-empty groups, in chronological order
|
||||
return GROUP_ORDER
|
||||
.filter((key) => grouped.has(key))
|
||||
.map((key) => ({
|
||||
key,
|
||||
label: `myPlants.groups.${key}`,
|
||||
scans: grouped.get(key)!,
|
||||
}));
|
||||
}
|
||||
105
VinEye/src/utils/diseaseIcons.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import {
|
||||
Droplets,
|
||||
Snowflake,
|
||||
CircleDot,
|
||||
Skull,
|
||||
CloudRain,
|
||||
Leaf,
|
||||
Sun,
|
||||
AlertTriangle,
|
||||
} from "lucide-react-native";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
|
||||
export interface DiseaseVisual {
|
||||
icon: LucideIcon;
|
||||
iconColor: string;
|
||||
bgGradientStart: string;
|
||||
bgGradientEnd: string;
|
||||
borderGradientStart: string;
|
||||
borderGradientEnd: string;
|
||||
}
|
||||
|
||||
const DISEASE_VISUALS: Record<string, DiseaseVisual> = {
|
||||
mildiou: {
|
||||
icon: Droplets,
|
||||
iconColor: "#E67E22",
|
||||
bgGradientStart: "#FFF5EB",
|
||||
bgGradientEnd: "#FFE8D1",
|
||||
borderGradientStart: "#FFD6A5",
|
||||
borderGradientEnd: "#FFBD73",
|
||||
},
|
||||
oidium: {
|
||||
icon: Snowflake,
|
||||
iconColor: "#3B82F6",
|
||||
bgGradientStart: "#EFF6FF",
|
||||
bgGradientEnd: "#DBEAFE",
|
||||
borderGradientStart: "#BFDBFE",
|
||||
borderGradientEnd: "#93C5FD",
|
||||
},
|
||||
black_rot: {
|
||||
icon: CircleDot,
|
||||
iconColor: "#1F2937",
|
||||
bgGradientStart: "#F3F4F6",
|
||||
bgGradientEnd: "#E5E7EB",
|
||||
borderGradientStart: "#D1D5DB",
|
||||
borderGradientEnd: "#9CA3AF",
|
||||
},
|
||||
esca: {
|
||||
icon: Skull,
|
||||
iconColor: "#8B5CF6",
|
||||
bgGradientStart: "#F5F3FF",
|
||||
bgGradientEnd: "#EDE9FE",
|
||||
borderGradientStart: "#DDD6FE",
|
||||
borderGradientEnd: "#C4B5FD",
|
||||
},
|
||||
botrytis: {
|
||||
icon: CloudRain,
|
||||
iconColor: "#6B7280",
|
||||
bgGradientStart: "#F9FAFB",
|
||||
bgGradientEnd: "#F3F4F6",
|
||||
borderGradientStart: "#E5E7EB",
|
||||
borderGradientEnd: "#D1D5DB",
|
||||
},
|
||||
flavescence_doree: {
|
||||
icon: Leaf,
|
||||
iconColor: "#F59E0B",
|
||||
bgGradientStart: "#FFFBEB",
|
||||
bgGradientEnd: "#FEF3C7",
|
||||
borderGradientStart: "#FDE68A",
|
||||
borderGradientEnd: "#FCD34D",
|
||||
},
|
||||
chlorose: {
|
||||
icon: Sun,
|
||||
iconColor: "#10B981",
|
||||
bgGradientStart: "#ECFDF5",
|
||||
bgGradientEnd: "#D1FAE5",
|
||||
borderGradientStart: "#A7F3D0",
|
||||
borderGradientEnd: "#6EE7B7",
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_VISUAL: DiseaseVisual = {
|
||||
icon: AlertTriangle,
|
||||
iconColor: "#6B7280",
|
||||
bgGradientStart: "#F9FAFB",
|
||||
bgGradientEnd: "#F3F4F6",
|
||||
borderGradientStart: "#E5E7EB",
|
||||
borderGradientEnd: "#D1D5DB",
|
||||
};
|
||||
|
||||
export function getDiseaseVisual(id: string): DiseaseVisual {
|
||||
return DISEASE_VISUALS[id] ?? DEFAULT_VISUAL;
|
||||
}
|
||||
|
||||
export function getSeverityColor(severity: string): string {
|
||||
switch (severity) {
|
||||
case "high":
|
||||
return "#EF4444";
|
||||
case "medium":
|
||||
return "#F59E0B";
|
||||
case "low":
|
||||
return "#22C55E";
|
||||
default:
|
||||
return "#6B7280";
|
||||
}
|
||||
}
|
||||
52
VinEye/src/utils/guideIcons.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
Leaf,
|
||||
Calendar,
|
||||
Grape,
|
||||
BookOpen,
|
||||
HelpCircle,
|
||||
} from "lucide-react-native";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
|
||||
export interface GuideVisual {
|
||||
icon: LucideIcon;
|
||||
iconColor: string;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
const GUIDE_VISUALS: Record<string, GuideVisual> = {
|
||||
beginner: {
|
||||
icon: Leaf,
|
||||
iconColor: "#059669",
|
||||
bgColor: "#D1FAE5",
|
||||
},
|
||||
treatment: {
|
||||
icon: Calendar,
|
||||
iconColor: "#2563EB",
|
||||
bgColor: "#DBEAFE",
|
||||
},
|
||||
varieties: {
|
||||
icon: Grape,
|
||||
iconColor: "#7C3AED",
|
||||
bgColor: "#F3E8FF",
|
||||
},
|
||||
seasonal: {
|
||||
icon: Calendar,
|
||||
iconColor: "#D97706",
|
||||
bgColor: "#FEF3C7",
|
||||
},
|
||||
general: {
|
||||
icon: BookOpen,
|
||||
iconColor: "#6B7280",
|
||||
bgColor: "#F3F4F6",
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_VISUAL: GuideVisual = {
|
||||
icon: HelpCircle,
|
||||
iconColor: "#6B7280",
|
||||
bgColor: "#F3F4F6",
|
||||
};
|
||||
|
||||
export function getGuideVisual(category: string): GuideVisual {
|
||||
return GUIDE_VISUALS[category?.toLowerCase()] ?? DEFAULT_VISUAL;
|
||||
}
|
||||
|
|
@ -49,12 +49,10 @@ export default function AlertsClient({ alerts }: { alerts: Alert[] }) {
|
|||
|
||||
async function handleToggleActive(id: string, active: boolean) {
|
||||
try {
|
||||
const alert = alerts.find((a) => a.id === id);
|
||||
if (!alert) return;
|
||||
const res = await fetch(`/api/alerts/${id}`, {
|
||||
method: "PUT",
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...alert, active }),
|
||||
body: JSON.stringify({ active }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
toast.success(active ? "Alerte activee" : "Alerte desactivee");
|
||||
|
|
@ -78,7 +76,7 @@ export default function AlertsClient({ alerts }: { alerts: Alert[] }) {
|
|||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-semibold tracking-tight text-cream">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-cream">
|
||||
Alertes saisonnieres
|
||||
</h1>
|
||||
<p className="text-sm text-stone-600 mt-1">{alerts.length} alertes</p>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export default function DashboardClient({ stats, recentScans, topDiseases }: Das
|
|||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-semibold tracking-tight text-cream">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-cream">
|
||||
Tableau de bord
|
||||
</h1>
|
||||
<p className="text-sm text-stone-600 mt-1 capitalize">{today}</p>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ export default async function EditDiseasePage({
|
|||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
const disease = await prisma.disease.findUnique({ where: { id } });
|
||||
const disease = await prisma.disease.findUnique({
|
||||
where: { id },
|
||||
include: { images: { orderBy: { order: "asc" } } },
|
||||
});
|
||||
if (!disease) notFound();
|
||||
|
||||
return (
|
||||
|
|
@ -35,6 +38,24 @@ export default async function EditDiseasePage({
|
|||
iconColor: disease.iconColor,
|
||||
bgColor: disease.bgColor,
|
||||
published: disease.published,
|
||||
startMonth: disease.startMonth,
|
||||
endMonth: disease.endMonth,
|
||||
peakMonth: disease.peakMonth,
|
||||
conditions: disease.conditions,
|
||||
conditionsEn: disease.conditionsEn,
|
||||
preventiveActions: disease.preventiveActions,
|
||||
preventiveActionsEn: disease.preventiveActionsEn,
|
||||
curativeActions: disease.curativeActions,
|
||||
curativeActionsEn: disease.curativeActionsEn,
|
||||
impactedParts: disease.impactedParts,
|
||||
impactedPartsEn: disease.impactedPartsEn,
|
||||
spreadMethod: disease.spreadMethod,
|
||||
spreadMethodEn: disease.spreadMethodEn,
|
||||
images: disease.images.map((img) => ({
|
||||
url: img.url,
|
||||
alt: img.alt ?? "",
|
||||
order: img.order,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -73,20 +73,10 @@ export default function DiseasesClient({ diseases }: { diseases: Disease[] }) {
|
|||
|
||||
async function handleTogglePublish(id: string, published: boolean) {
|
||||
try {
|
||||
const disease = diseases.find((d) => d.id === id);
|
||||
if (!disease) return;
|
||||
|
||||
const res = await fetch(`/api/diseases/${id}`, {
|
||||
method: "PUT",
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...disease,
|
||||
published,
|
||||
symptoms: [],
|
||||
description: "placeholder",
|
||||
treatment: "placeholder",
|
||||
season: "placeholder",
|
||||
}),
|
||||
body: JSON.stringify({ published }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error();
|
||||
|
|
@ -112,7 +102,7 @@ export default function DiseasesClient({ diseases }: { diseases: Disease[] }) {
|
|||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-semibold tracking-tight text-cream">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-cream">
|
||||
Maladies de la vigne
|
||||
</h1>
|
||||
<p className="text-sm text-stone-600 mt-1">{diseases.length} maladies repertoriees</p>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ export default async function EditGuidePage({
|
|||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
const guide = await prisma.guide.findUnique({ where: { id } });
|
||||
const guide = await prisma.guide.findUnique({
|
||||
where: { id },
|
||||
include: { sections: { orderBy: { order: "asc" } } },
|
||||
});
|
||||
if (!guide) notFound();
|
||||
|
||||
return (
|
||||
|
|
@ -29,6 +32,18 @@ export default async function EditGuidePage({
|
|||
bgColor: guide.bgColor,
|
||||
published: guide.published,
|
||||
order: guide.order,
|
||||
readTime: guide.readTime,
|
||||
coverImage: guide.coverImage,
|
||||
sections: guide.sections.map((s) => ({
|
||||
title: s.title,
|
||||
titleEn: s.titleEn ?? "",
|
||||
body: s.body,
|
||||
bodyEn: s.bodyEn ?? "",
|
||||
image: s.image ?? "",
|
||||
tip: s.tip ?? "",
|
||||
tipEn: s.tipEn ?? "",
|
||||
order: s.order,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export default function GuidesClient({ guides }: { guides: Guide[] }) {
|
|||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-semibold tracking-tight text-cream">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-cream">
|
||||
Guides
|
||||
</h1>
|
||||
<p className="text-sm text-stone-600 mt-1">{guides.length} guides</p>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||
|
||||
{/* Mobile sidebar */}
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetContent side="left" className="p-0 w-[260px] border-r border-[oklch(0.20_0.006_60)] bg-[oklch(0.11_0.005_60)]">
|
||||
<SheetContent side="left" showCloseButton={false} className="p-0 w-[260px] border-r border-[oklch(0.20_0.006_60)] bg-[oklch(0.11_0.005_60)]">
|
||||
<Sidebar userName={userName} userEmail={userEmail} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export default function UserDetailClient({ user }: UserDetailProps) {
|
|||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-cream">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-cream">
|
||||
Utilisateur
|
||||
</h1>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export default function UsersClient({ users }: { users: User[] }) {
|
|||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-semibold tracking-tight text-cream">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-cream">
|
||||
Utilisateurs
|
||||
</h1>
|
||||
<p className="text-sm text-stone-600 mt-1">{users.length} utilisateurs</p>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Grape, Loader2, Eye, EyeOff } from "lucide-react";
|
||||
import { Loader2, Eye, EyeOff } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { signIn } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -50,10 +51,10 @@ export default function LoginPage() {
|
|||
<div className="w-full max-w-[380px] mx-4">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="h-14 w-14 rounded-2xl bg-vine/10 flex items-center justify-center mb-5 glow-green-sm">
|
||||
<Grape className="h-7 w-7 text-vine" />
|
||||
<div className="h-16 w-16 rounded-2xl bg-vine/10 flex items-center justify-center mb-5 glow-green-sm overflow-hidden">
|
||||
<Image src="/logo.png" alt="VinEye" width={48} height={48} className="object-contain" />
|
||||
</div>
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-cream">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-cream">
|
||||
VinEye Admin
|
||||
</h1>
|
||||
<p className="text-sm text-stone-600 mt-1">
|
||||
|
|
|
|||
|
|
@ -50,6 +50,35 @@ export async function PUT(
|
|||
return Response.json({ data: alert });
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin();
|
||||
if ("error" in auth) {
|
||||
return Response.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.seasonAlert.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return Response.json({ error: "Alerte introuvable" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
if (typeof body.active !== "boolean") {
|
||||
return Response.json({ error: "Champ 'active' requis" }, { status: 400 });
|
||||
}
|
||||
|
||||
const alert = await prisma.seasonAlert.update({
|
||||
where: { id },
|
||||
data: { active: body.active },
|
||||
});
|
||||
|
||||
return Response.json({ data: alert });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ export async function GET(
|
|||
) {
|
||||
const { id } = await params;
|
||||
|
||||
const disease = await prisma.disease.findUnique({ where: { id } });
|
||||
const disease = await prisma.disease.findUnique({
|
||||
where: { id },
|
||||
include: { images: { orderBy: { order: "asc" } } },
|
||||
});
|
||||
if (!disease) {
|
||||
return Response.json({ error: "Maladie introuvable" }, { status: 404 });
|
||||
}
|
||||
|
|
@ -53,9 +56,56 @@ export async function PUT(
|
|||
return Response.json({ error: "Ce slug existe deja" }, { status: 409 });
|
||||
}
|
||||
|
||||
const { images, ...diseaseData } = data;
|
||||
|
||||
const disease = await prisma.disease.update({
|
||||
where: { id },
|
||||
data: { ...data, slug },
|
||||
data: { ...diseaseData, slug },
|
||||
});
|
||||
|
||||
// Handle images: delete old, create new
|
||||
if (images && images.length > 0) {
|
||||
await prisma.diseaseImage.deleteMany({ where: { diseaseId: id } });
|
||||
await Promise.all(
|
||||
images.map((img) =>
|
||||
prisma.diseaseImage.create({ data: { ...img, diseaseId: id } })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await prisma.disease.findUnique({
|
||||
where: { id },
|
||||
include: { images: { orderBy: { order: "asc" } } },
|
||||
});
|
||||
|
||||
return Response.json({ data: updated });
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin();
|
||||
if ("error" in auth) {
|
||||
return Response.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.disease.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return Response.json({ error: "Maladie introuvable" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Only allow toggling published
|
||||
if (typeof body.published !== "boolean") {
|
||||
return Response.json({ error: "Champ 'published' requis" }, { status: 400 });
|
||||
}
|
||||
|
||||
const disease = await prisma.disease.update({
|
||||
where: { id },
|
||||
data: { published: body.published },
|
||||
});
|
||||
|
||||
return Response.json({ data: disease });
|
||||
|
|
|
|||
|
|
@ -45,9 +45,24 @@ export async function POST(request: NextRequest) {
|
|||
return Response.json({ error: "Ce slug existe deja" }, { status: 409 });
|
||||
}
|
||||
|
||||
const { images, ...diseaseData } = data;
|
||||
|
||||
const disease = await prisma.disease.create({
|
||||
data: { ...data, slug },
|
||||
data: { ...diseaseData, slug },
|
||||
});
|
||||
|
||||
return Response.json({ data: disease }, { status: 201 });
|
||||
if (images && images.length > 0) {
|
||||
await Promise.all(
|
||||
images.map((img) =>
|
||||
prisma.diseaseImage.create({ data: { ...img, diseaseId: disease.id } })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const created = await prisma.disease.findUnique({
|
||||
where: { id: disease.id },
|
||||
include: { images: { orderBy: { order: "asc" } } },
|
||||
});
|
||||
|
||||
return Response.json({ data: created }, { status: 201 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ import { slugify } from "@/lib/utils";
|
|||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
const guide = await prisma.guide.findUnique({ where: { id } });
|
||||
const guide = await prisma.guide.findUnique({
|
||||
where: { id },
|
||||
include: { sections: { orderBy: { order: "asc" } } },
|
||||
});
|
||||
if (!guide) {
|
||||
return Response.json({ error: "Guide introuvable" }, { status: 404 });
|
||||
}
|
||||
|
|
@ -20,7 +23,7 @@ export async function GET(
|
|||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const auth = await requireAdmin();
|
||||
if ("error" in auth) {
|
||||
|
|
@ -39,12 +42,12 @@ export async function PUT(
|
|||
if (!result.success) {
|
||||
return Response.json(
|
||||
{ error: "Validation failed", details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
const slug = data.slug || slugify(data.title);
|
||||
const { sections, ...guideData } = result.data;
|
||||
const slug = guideData.slug || slugify(guideData.title);
|
||||
|
||||
const slugConflict = await prisma.guide.findFirst({
|
||||
where: { slug, id: { not: id } },
|
||||
|
|
@ -53,9 +56,55 @@ export async function PUT(
|
|||
return Response.json({ error: "Ce slug existe deja" }, { status: 409 });
|
||||
}
|
||||
|
||||
await prisma.guide.update({
|
||||
where: { id },
|
||||
data: { ...guideData, slug },
|
||||
});
|
||||
|
||||
// Replace sections
|
||||
if (sections) {
|
||||
await prisma.guideSection.deleteMany({ where: { guideId: id } });
|
||||
if (sections.length > 0) {
|
||||
await Promise.all(
|
||||
sections.map((s) =>
|
||||
prisma.guideSection.create({ data: { ...s, guideId: id } }),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.guide.findUnique({
|
||||
where: { id },
|
||||
include: { sections: { orderBy: { order: "asc" } } },
|
||||
});
|
||||
|
||||
return Response.json({ data: updated });
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const auth = await requireAdmin();
|
||||
if ("error" in auth) {
|
||||
return Response.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.guide.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return Response.json({ error: "Guide introuvable" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
if (typeof body.published !== "boolean") {
|
||||
return Response.json({ error: "Champ 'published' requis" }, { status: 400 });
|
||||
}
|
||||
|
||||
const guide = await prisma.guide.update({
|
||||
where: { id },
|
||||
data: { ...data, slug },
|
||||
data: { published: body.published },
|
||||
});
|
||||
|
||||
return Response.json({ data: guide });
|
||||
|
|
@ -63,7 +112,7 @@ export async function PUT(
|
|||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const auth = await requireAdmin();
|
||||
if ("error" in auth) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
const guides = await prisma.guide.findMany({
|
||||
where,
|
||||
include: { sections: { orderBy: { order: "asc" } } },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
|
|
@ -31,12 +32,12 @@ export async function POST(request: NextRequest) {
|
|||
if (!result.success) {
|
||||
return Response.json(
|
||||
{ error: "Validation failed", details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
const slug = data.slug || slugify(data.title);
|
||||
const { sections, ...guideData } = result.data;
|
||||
const slug = guideData.slug || slugify(guideData.title);
|
||||
|
||||
const existing = await prisma.guide.findUnique({ where: { slug } });
|
||||
if (existing) {
|
||||
|
|
@ -44,8 +45,21 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
|
||||
const guide = await prisma.guide.create({
|
||||
data: { ...data, slug },
|
||||
data: { ...guideData, slug },
|
||||
});
|
||||
|
||||
return Response.json({ data: guide }, { status: 201 });
|
||||
if (sections && sections.length > 0) {
|
||||
await Promise.all(
|
||||
sections.map((s) =>
|
||||
prisma.guideSection.create({ data: { ...s, guideId: guide.id } }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const created = await prisma.guide.findUnique({
|
||||
where: { id: guide.id },
|
||||
include: { sections: { orderBy: { order: "asc" } } },
|
||||
});
|
||||
|
||||
return Response.json({ data: created }, { status: 201 });
|
||||
}
|
||||
|
|
|
|||
70
vineye-admin/app/api/mobile/diseases/[slug]/route.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"X-API-Version": "1.0",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
};
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
const disease = await prisma.disease.findUnique({
|
||||
where: { slug },
|
||||
include: { images: true },
|
||||
});
|
||||
|
||||
if (!disease || !disease.published) {
|
||||
return Response.json(
|
||||
{ success: false, error: "Maladie introuvable" },
|
||||
{ status: 404, headers: CORS_HEADERS },
|
||||
);
|
||||
}
|
||||
|
||||
// Sort images by order
|
||||
disease.images.sort((a, b) => a.order - b.order);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
id: disease.id, slug: disease.slug,
|
||||
name: disease.name, nameEn: disease.nameEn,
|
||||
scientificName: disease.scientificName,
|
||||
type: disease.type, severity: disease.severity,
|
||||
description: disease.description, descriptionEn: disease.descriptionEn,
|
||||
symptoms: disease.symptoms, symptomsEn: disease.symptomsEn,
|
||||
treatment: disease.treatment, treatmentEn: disease.treatmentEn,
|
||||
season: disease.season, seasonEn: disease.seasonEn,
|
||||
iconName: disease.iconName, iconColor: disease.iconColor, bgColor: disease.bgColor,
|
||||
imageUrl: disease.imageUrl, createdAt: disease.createdAt,
|
||||
startMonth: disease.startMonth, endMonth: disease.endMonth, peakMonth: disease.peakMonth,
|
||||
conditions: disease.conditions, conditionsEn: disease.conditionsEn,
|
||||
preventiveActions: disease.preventiveActions, preventiveActionsEn: disease.preventiveActionsEn,
|
||||
curativeActions: disease.curativeActions, curativeActionsEn: disease.curativeActionsEn,
|
||||
impactedParts: disease.impactedParts, impactedPartsEn: disease.impactedPartsEn,
|
||||
spreadMethod: disease.spreadMethod, spreadMethodEn: disease.spreadMethodEn,
|
||||
images: disease.images.map(i => ({ id: i.id, url: i.url, alt: i.alt, order: i.order })),
|
||||
},
|
||||
},
|
||||
{ headers: CORS_HEADERS },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[API] Disease detail error:", error);
|
||||
return Response.json(
|
||||
{ success: false, error: "Erreur serveur" },
|
||||
{ status: 500, headers: CORS_HEADERS },
|
||||
);
|
||||
}
|
||||
}
|
||||
80
vineye-admin/app/api/mobile/diseases/route.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const SEVERITY_ORDER = { HIGH: 0, MEDIUM: 1, LOW: 2 } as const;
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"X-API-Version": "1.0",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
};
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const severity = searchParams.get("severity")?.toUpperCase();
|
||||
const type = searchParams.get("type")?.toUpperCase();
|
||||
const search = searchParams.get("search");
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1") || 1);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20") || 20));
|
||||
|
||||
const where: Record<string, unknown> = { published: true };
|
||||
if (severity && ["HIGH", "MEDIUM", "LOW"].includes(severity)) {
|
||||
where.severity = severity;
|
||||
}
|
||||
if (type && ["FUNGAL", "BACTERIAL", "PEST", "ABIOTIC"].includes(type)) {
|
||||
where.type = type;
|
||||
}
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: "insensitive" } },
|
||||
{ nameEn: { contains: search, mode: "insensitive" } },
|
||||
{ scientificName: { contains: search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
const [diseases, total] = await Promise.all([
|
||||
prisma.disease.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true, slug: true,
|
||||
name: true, nameEn: true, scientificName: true,
|
||||
type: true, severity: true,
|
||||
description: true, descriptionEn: true,
|
||||
symptoms: true, symptomsEn: true,
|
||||
treatment: true, treatmentEn: true,
|
||||
season: true, seasonEn: true,
|
||||
iconName: true, iconColor: true, bgColor: true,
|
||||
imageUrl: true, createdAt: true,
|
||||
startMonth: true, endMonth: true, peakMonth: true,
|
||||
conditions: true, conditionsEn: true,
|
||||
preventiveActions: true, preventiveActionsEn: true,
|
||||
curativeActions: true, curativeActionsEn: true,
|
||||
impactedParts: true, impactedPartsEn: true,
|
||||
spreadMethod: true, spreadMethodEn: true,
|
||||
images: { select: { id: true, url: true, alt: true, order: true }, orderBy: { order: "asc" } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.disease.count({ where }),
|
||||
]);
|
||||
|
||||
// Sort by severity (HIGH first)
|
||||
diseases.sort((a, b) => {
|
||||
const aOrder = SEVERITY_ORDER[a.severity as keyof typeof SEVERITY_ORDER] ?? 3;
|
||||
const bOrder = SEVERITY_ORDER[b.severity as keyof typeof SEVERITY_ORDER] ?? 3;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
|
||||
return Response.json(
|
||||
{ success: true, data: diseases, pagination: { page, limit, total, pages: Math.ceil(total / limit) } },
|
||||
{ headers: CORS_HEADERS },
|
||||
);
|
||||
}
|
||||
42
vineye-admin/app/api/mobile/guides/[slug]/route.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"X-API-Version": "1.0",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
};
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
const { slug } = await params;
|
||||
|
||||
const guide = await prisma.guide.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
sections: { orderBy: { order: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!guide || !guide.published) {
|
||||
return Response.json(
|
||||
{ success: false, error: "Guide introuvable" },
|
||||
{ status: 404, headers: CORS_HEADERS },
|
||||
);
|
||||
}
|
||||
|
||||
const { published: _, updatedAt: __, ...data } = guide;
|
||||
|
||||
return Response.json(
|
||||
{ success: true, data },
|
||||
{ headers: CORS_HEADERS },
|
||||
);
|
||||
}
|
||||
54
vineye-admin/app/api/mobile/guides/route.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"X-API-Version": "1.0",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
};
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const category = searchParams.get("category");
|
||||
const search = searchParams.get("search");
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1") || 1);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20") || 20));
|
||||
|
||||
const where: Record<string, unknown> = { published: true };
|
||||
if (category) {
|
||||
where.category = category;
|
||||
}
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: "insensitive" } },
|
||||
{ titleEn: { contains: search, mode: "insensitive" } },
|
||||
{ content: { contains: search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
const [guides, total] = await Promise.all([
|
||||
prisma.guide.findMany({
|
||||
where,
|
||||
include: {
|
||||
sections: { orderBy: { order: "asc" } },
|
||||
},
|
||||
orderBy: { order: "asc" },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.guide.count({ where }),
|
||||
]);
|
||||
|
||||
const data = guides.map(({ published: _, updatedAt: __, ...g }) => g);
|
||||
|
||||
return Response.json(
|
||||
{ success: true, data, pagination: { page, limit, total, pages: Math.ceil(total / limit) } },
|
||||
{ headers: CORS_HEADERS },
|
||||
);
|
||||
}
|
||||
|
|
@ -7,9 +7,8 @@
|
|||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-outfit);
|
||||
--font-sans: var(--font-roboto);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-display: var(--font-fraunces);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Fraunces, Outfit } from "next/font/google";
|
||||
import { Roboto } from "next/font/google";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const fraunces = Fraunces({
|
||||
variable: "--font-fraunces",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const outfit = Outfit({
|
||||
variable: "--font-outfit",
|
||||
const roboto = Roboto({
|
||||
variable: "--font-roboto",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
weight: ["300", "400", "500", "700"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -28,7 +23,7 @@ export default function RootLayout({
|
|||
return (
|
||||
<html
|
||||
lang="fr"
|
||||
className={`${fraunces.variable} ${outfit.variable} h-full antialiased`}
|
||||
className={`${roboto.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export default function DeleteDialog({ title, description, onConfirm }: DeleteDi
|
|||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="border-[oklch(0.22_0.006_60)] bg-card">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="font-display font-semibold text-cream">{title}</AlertDialogTitle>
|
||||
<AlertDialogTitle className="font-semibold text-cream">{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-stone-400">{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Plus, X, Loader2 } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft, Plus, X, Loader2, Calendar, Shield, Syringe,
|
||||
Bug, Leaf, ImagePlus, ChevronUp, ChevronDown, AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -21,17 +31,34 @@ import { toast } from "sonner";
|
|||
import { slugify } from "@/lib/utils";
|
||||
import type { DiseaseInput } from "@/lib/validations";
|
||||
|
||||
interface DiseaseImage {
|
||||
url: string;
|
||||
alt: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface DiseaseFormProps {
|
||||
initialData?: DiseaseInput & { id?: string; slug?: string };
|
||||
initialData?: Partial<DiseaseInput> & { id?: string; slug?: string; images?: DiseaseImage[] };
|
||||
mode: "create" | "edit";
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
{ value: 1, label: "Janvier" }, { value: 2, label: "Fevrier" }, { value: 3, label: "Mars" },
|
||||
{ value: 4, label: "Avril" }, { value: 5, label: "Mai" }, { value: 6, label: "Juin" },
|
||||
{ value: 7, label: "Juillet" }, { value: 8, label: "Aout" }, { value: 9, label: "Septembre" },
|
||||
{ value: 10, label: "Octobre" }, { value: 11, label: "Novembre" }, { value: 12, label: "Decembre" },
|
||||
];
|
||||
const MONTH_SHORT = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"];
|
||||
const IMPACTED_PARTS_OPTIONS = ["Feuilles", "Grappes", "Rameaux", "Tronc", "Racines", "Vrilles"];
|
||||
|
||||
export default function DiseaseForm({ initialData, mode }: DiseaseFormProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [slugCustom, setSlugCustom] = useState(!!initialData?.slug);
|
||||
|
||||
const [name, setName] = useState(initialData?.name ?? "");
|
||||
const [nameEn, setNameEn] = useState(initialData?.nameEn ?? "");
|
||||
const [slug, setSlug] = useState(initialData?.slug ?? "");
|
||||
const [scientificName, setScientificName] = useState(initialData?.scientificName ?? "");
|
||||
const [type, setType] = useState(initialData?.type ?? "FUNGAL");
|
||||
const [severity, setSeverity] = useState(initialData?.severity ?? "MEDIUM");
|
||||
|
|
@ -43,42 +70,79 @@ export default function DiseaseForm({ initialData, mode }: DiseaseFormProps) {
|
|||
const [treatmentEn, setTreatmentEn] = useState(initialData?.treatmentEn ?? "");
|
||||
const [season, setSeason] = useState(initialData?.season ?? "");
|
||||
const [seasonEn, setSeasonEn] = useState(initialData?.seasonEn ?? "");
|
||||
const [startMonth, setStartMonth] = useState<number | null>(initialData?.startMonth ?? null);
|
||||
const [endMonth, setEndMonth] = useState<number | null>(initialData?.endMonth ?? null);
|
||||
const [peakMonth, setPeakMonth] = useState<number | null>(initialData?.peakMonth ?? null);
|
||||
const [conditions, setConditions] = useState<string[]>(initialData?.conditions ?? []);
|
||||
const [conditionsEn, setConditionsEn] = useState<string[]>(initialData?.conditionsEn ?? []);
|
||||
const [preventiveActions, setPreventiveActions] = useState<string[]>(initialData?.preventiveActions ?? []);
|
||||
const [preventiveActionsEn, setPreventiveActionsEn] = useState<string[]>(initialData?.preventiveActionsEn ?? []);
|
||||
const [curativeActions, setCurativeActions] = useState<string[]>(initialData?.curativeActions ?? []);
|
||||
const [curativeActionsEn, setCurativeActionsEn] = useState<string[]>(initialData?.curativeActionsEn ?? []);
|
||||
const [impactedParts, setImpactedParts] = useState<string[]>(initialData?.impactedParts ?? []);
|
||||
const [spreadMethod, setSpreadMethod] = useState(initialData?.spreadMethod ?? "");
|
||||
const [spreadMethodEn, setSpreadMethodEn] = useState(initialData?.spreadMethodEn ?? "");
|
||||
const [iconName, setIconName] = useState(initialData?.iconName ?? "leaf");
|
||||
const [iconColor, setIconColor] = useState(initialData?.iconColor ?? "#1D9E75");
|
||||
const [bgColor, setBgColor] = useState(initialData?.bgColor ?? "#E1F5EE");
|
||||
const [published, setPublished] = useState(initialData?.published ?? true);
|
||||
const [images, setImages] = useState<DiseaseImage[]>(initialData?.images ?? []);
|
||||
const [newImageUrl, setNewImageUrl] = useState("");
|
||||
const [newImageAlt, setNewImageAlt] = useState("");
|
||||
|
||||
function addSymptom(lang: "fr" | "en") {
|
||||
if (lang === "fr") setSymptoms([...symptoms, ""]);
|
||||
else setSymptomsEn([...symptomsEn, ""]);
|
||||
const handleNameChange = useCallback((v: string) => {
|
||||
setName(v);
|
||||
if (!slugCustom) setSlug(slugify(v));
|
||||
}, [slugCustom]);
|
||||
|
||||
function addItem(setter: React.Dispatch<React.SetStateAction<string[]>>) {
|
||||
setter((prev) => [...prev, ""]);
|
||||
}
|
||||
function removeItem(setter: React.Dispatch<React.SetStateAction<string[]>>, i: number) {
|
||||
setter((prev) => prev.filter((_, idx) => idx !== i));
|
||||
}
|
||||
function updateItem(setter: React.Dispatch<React.SetStateAction<string[]>>, i: number, v: string) {
|
||||
setter((prev) => { const n = [...prev]; n[i] = v; return n; });
|
||||
}
|
||||
|
||||
function removeSymptom(lang: "fr" | "en", index: number) {
|
||||
if (lang === "fr") setSymptoms(symptoms.filter((_, i) => i !== index));
|
||||
else setSymptomsEn(symptomsEn.filter((_, i) => i !== index));
|
||||
function addImage() {
|
||||
if (!newImageUrl.trim()) return;
|
||||
setImages((prev) => [...prev, { url: newImageUrl.trim(), alt: newImageAlt.trim(), order: prev.length }]);
|
||||
setNewImageUrl("");
|
||||
setNewImageAlt("");
|
||||
}
|
||||
function removeImage(i: number) {
|
||||
setImages((prev) => prev.filter((_, idx) => idx !== i).map((img, idx) => ({ ...img, order: idx })));
|
||||
}
|
||||
function moveImage(i: number, dir: -1 | 1) {
|
||||
setImages((prev) => {
|
||||
const n = [...prev];
|
||||
const t = i + dir;
|
||||
if (t < 0 || t >= n.length) return n;
|
||||
[n[i], n[t]] = [n[t], n[i]];
|
||||
return n.map((img, idx) => ({ ...img, order: idx }));
|
||||
});
|
||||
}
|
||||
|
||||
function updateSymptom(lang: "fr" | "en", index: number, value: string) {
|
||||
if (lang === "fr") {
|
||||
const next = [...symptoms];
|
||||
next[index] = value;
|
||||
setSymptoms(next);
|
||||
} else {
|
||||
const next = [...symptomsEn];
|
||||
next[index] = value;
|
||||
setSymptomsEn(next);
|
||||
}
|
||||
function togglePart(part: string) {
|
||||
setImpactedParts((prev) =>
|
||||
prev.includes(part) ? prev.filter((p) => p !== part) : [...prev, part]
|
||||
);
|
||||
}
|
||||
|
||||
function isMonthActive(m: number) {
|
||||
if (!startMonth || !endMonth) return false;
|
||||
if (startMonth <= endMonth) return m >= startMonth && m <= endMonth;
|
||||
return m >= startMonth || m <= endMonth;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
|
||||
if (!name.trim() || !description.trim() || !treatment.trim() || !season.trim()) {
|
||||
toast.error("Veuillez remplir tous les champs obligatoires");
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredSymptoms = symptoms.filter((s) => s.trim());
|
||||
if (filteredSymptoms.length === 0) {
|
||||
toast.error("Ajoutez au moins un symptome");
|
||||
|
|
@ -86,59 +150,55 @@ export default function DiseaseForm({ initialData, mode }: DiseaseFormProps) {
|
|||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const body: DiseaseInput = {
|
||||
name: name.trim(),
|
||||
nameEn: nameEn.trim(),
|
||||
scientificName: scientificName.trim(),
|
||||
slug: slugify(name),
|
||||
type: type as DiseaseInput["type"],
|
||||
severity: severity as DiseaseInput["severity"],
|
||||
description: description.trim(),
|
||||
descriptionEn: descriptionEn.trim(),
|
||||
symptoms: filteredSymptoms,
|
||||
symptomsEn: symptomsEn.filter((s) => s.trim()),
|
||||
treatment: treatment.trim(),
|
||||
treatmentEn: treatmentEn.trim(),
|
||||
season: season.trim(),
|
||||
seasonEn: seasonEn.trim(),
|
||||
iconName,
|
||||
iconColor,
|
||||
bgColor,
|
||||
published,
|
||||
name: name.trim(), nameEn: nameEn.trim(), scientificName: scientificName.trim(),
|
||||
slug: slug || slugify(name),
|
||||
type: type as DiseaseInput["type"], severity: severity as DiseaseInput["severity"],
|
||||
description: description.trim(), descriptionEn: descriptionEn.trim(),
|
||||
symptoms: filteredSymptoms, symptomsEn: symptomsEn.filter((s) => s.trim()),
|
||||
treatment: treatment.trim(), treatmentEn: treatmentEn.trim(),
|
||||
season: season.trim(), seasonEn: seasonEn.trim(),
|
||||
iconName, iconColor, bgColor, published,
|
||||
startMonth, endMonth, peakMonth,
|
||||
conditions: conditions.filter((s) => s.trim()), conditionsEn: conditionsEn.filter((s) => s.trim()),
|
||||
preventiveActions: preventiveActions.filter((s) => s.trim()), preventiveActionsEn: preventiveActionsEn.filter((s) => s.trim()),
|
||||
curativeActions: curativeActions.filter((s) => s.trim()), curativeActionsEn: curativeActionsEn.filter((s) => s.trim()),
|
||||
impactedParts, impactedPartsEn: [],
|
||||
spreadMethod: spreadMethod.trim() || null, spreadMethodEn: spreadMethodEn.trim() || null,
|
||||
images,
|
||||
};
|
||||
|
||||
try {
|
||||
const url =
|
||||
mode === "create"
|
||||
? "/api/diseases"
|
||||
: `/api/diseases/${initialData?.id}`;
|
||||
const method = mode === "create" ? "POST" : "PUT";
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
toast.error(data.error || "Erreur");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = mode === "create" ? "/api/diseases" : `/api/diseases/${initialData?.id}`;
|
||||
const res = await fetch(url, { method: mode === "create" ? "POST" : "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
if (!res.ok) { const d = await res.json(); toast.error(d.error || "Erreur"); return; }
|
||||
toast.success(mode === "create" ? "Maladie creee" : "Maladie mise a jour");
|
||||
router.push("/diseases");
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error("Une erreur est survenue");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch { toast.error("Une erreur est survenue"); } finally { setLoading(false); }
|
||||
}
|
||||
|
||||
function StringListEditor({ label, items, setItems, placeholder }: {
|
||||
label: string; items: string[]; setItems: React.Dispatch<React.SetStateAction<string[]>>; placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium text-muted-foreground">{label}</Label>
|
||||
{items.map((s, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<Input value={s} onChange={(e) => updateItem(setItems, i, e.target.value)} className="rounded-xl" placeholder={placeholder} />
|
||||
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => removeItem(setItems, i)}><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="secondary" size="sm" className="rounded-lg" onClick={() => addItem(setItems)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="max-w-3xl mx-auto space-y-6 pb-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
|
@ -149,35 +209,33 @@ export default function DiseaseForm({ initialData, mode }: DiseaseFormProps) {
|
|||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* General info */}
|
||||
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
|
||||
{/* General */}
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Informations generales
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Informations generales</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Nom (FR) *</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} className="rounded-xl" required />
|
||||
<Input value={name} onChange={(e) => handleNameChange(e.target.value)} className="rounded-xl" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Nom (EN)</Label>
|
||||
<Input value={nameEn} onChange={(e) => setNameEn(e.target.value)} className="rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Nom scientifique</Label>
|
||||
<Input value={scientificName} onChange={(e) => setScientificName(e.target.value)} className="rounded-xl" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Slug</Label>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">{slugCustom ? "custom" : "auto"}</Badge>
|
||||
</div>
|
||||
<Input value={slug} onChange={(e) => { setSlugCustom(true); setSlug(e.target.value); }} className="rounded-xl font-mono text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Nom scientifique</Label>
|
||||
<Input value={scientificName} onChange={(e) => setScientificName(e.target.value)} className="rounded-xl italic" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Classification */}
|
||||
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Classification
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Type *</Label>
|
||||
|
|
@ -196,100 +254,45 @@ export default function DiseaseForm({ initialData, mode }: DiseaseFormProps) {
|
|||
<Select value={severity} onValueChange={(v) => v && setSeverity(v)}>
|
||||
<SelectTrigger className="rounded-xl"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HIGH">Critique</SelectItem>
|
||||
<SelectItem value="MEDIUM">Modere</SelectItem>
|
||||
<SelectItem value="LOW">Faible</SelectItem>
|
||||
<SelectItem value="HIGH"><span className="flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-red-500" /> Critique</span></SelectItem>
|
||||
<SelectItem value="MEDIUM"><span className="flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-amber-500" /> Modere</span></SelectItem>
|
||||
<SelectItem value="LOW"><span className="flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-emerald-500" /> Faible</span></SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Description (FR) *</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} className="rounded-xl" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Description (EN)</Label>
|
||||
<Textarea value={descriptionEn} onChange={(e) => setDescriptionEn(e.target.value)} rows={3} className="rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Description */}
|
||||
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
|
||||
{/* Symptoms & Treatment */}
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Description
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Description (FR) *</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} className="rounded-xl" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Description (EN)</Label>
|
||||
<Textarea value={descriptionEn} onChange={(e) => setDescriptionEn(e.target.value)} rows={3} className="rounded-xl" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Symptoms */}
|
||||
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Symptomes
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Symptomes (FR) *</Label>
|
||||
{symptoms.map((s, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<Input
|
||||
value={s}
|
||||
onChange={(e) => updateSymptom("fr", i, e.target.value)}
|
||||
className="rounded-xl"
|
||||
placeholder={`Symptome ${i + 1}`}
|
||||
/>
|
||||
{symptoms.length > 1 && (
|
||||
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => removeSymptom("fr", i)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="secondary" size="sm" className="rounded-lg" onClick={() => addSymptom("fr")}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> Ajouter
|
||||
</Button>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Symptomes & Traitement</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<StringListEditor label="Symptomes (FR) *" items={symptoms} setItems={setSymptoms} placeholder="Symptome..." />
|
||||
<StringListEditor label="Symptomes (EN)" items={symptomsEn} setItems={setSymptomsEn} placeholder="Symptom..." />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Symptomes (EN)</Label>
|
||||
{symptomsEn.map((s, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<Input
|
||||
value={s}
|
||||
onChange={(e) => updateSymptom("en", i, e.target.value)}
|
||||
className="rounded-xl"
|
||||
placeholder={`Symptom ${i + 1}`}
|
||||
/>
|
||||
{symptomsEn.length > 1 && (
|
||||
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => removeSymptom("en", i)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="secondary" size="sm" className="rounded-lg" onClick={() => addSymptom("en")}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> Ajouter
|
||||
</Button>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Traitement (FR) *</Label>
|
||||
<Textarea value={treatment} onChange={(e) => setTreatment(e.target.value)} rows={2} className="rounded-xl" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Traitement (EN)</Label>
|
||||
<Textarea value={treatmentEn} onChange={(e) => setTreatmentEn(e.target.value)} rows={2} className="rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Treatment + Season */}
|
||||
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Traitement & Saison
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Traitement (FR) *</Label>
|
||||
<Textarea value={treatment} onChange={(e) => setTreatment(e.target.value)} rows={2} className="rounded-xl" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Traitement (EN)</Label>
|
||||
<Textarea value={treatmentEn} onChange={(e) => setTreatmentEn(e.target.value)} rows={2} className="rounded-xl" />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Saison (FR) *</Label>
|
||||
|
|
@ -303,12 +306,139 @@ export default function DiseaseForm({ initialData, mode }: DiseaseFormProps) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Appearance */}
|
||||
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
|
||||
{/* Timeline */}
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Apparence
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-primary" />
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Timeline saisonniere</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{([["Debut", startMonth, setStartMonth], ["Pic", peakMonth, setPeakMonth], ["Fin", endMonth, setEndMonth]] as const).map(([label, val, setter]) => (
|
||||
<div key={label} className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">{label}</Label>
|
||||
<Select value={val?.toString() ?? ""} onValueChange={(v) => (setter as (v: number | null) => void)(v ? parseInt(v) : null)}>
|
||||
<SelectTrigger className="rounded-xl"><SelectValue placeholder="Mois" /></SelectTrigger>
|
||||
<SelectContent>{MONTHS.map((m) => <SelectItem key={m.value} value={m.value.toString()}>{m.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex rounded-lg overflow-hidden h-7">
|
||||
{MONTH_SHORT.map((label, i) => {
|
||||
const m = i + 1;
|
||||
const active = isMonthActive(m);
|
||||
const isPeak = m === peakMonth;
|
||||
return (
|
||||
<div key={i} className={`flex-1 flex items-center justify-center text-[10px] font-medium transition-colors ${isPeak ? "bg-primary text-primary-foreground" : active ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"}`}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Details accordion */}
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Details techniques</p>
|
||||
<Accordion multiple className="space-y-2">
|
||||
<AccordionItem value="conditions" className="border rounded-xl px-4">
|
||||
<AccordionTrigger className="text-sm font-medium py-3">
|
||||
<span className="flex items-center gap-2"><AlertTriangle className="h-4 w-4 text-amber-500" /> Conditions favorables</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<StringListEditor label="FR" items={conditions} setItems={setConditions} placeholder="Ex: Humidite > 80%" />
|
||||
<div className="mt-3"><StringListEditor label="EN" items={conditionsEn} setItems={setConditionsEn} placeholder="Ex: Humidity > 80%" /></div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="preventive" className="border rounded-xl px-4">
|
||||
<AccordionTrigger className="text-sm font-medium py-3">
|
||||
<span className="flex items-center gap-2"><Shield className="h-4 w-4 text-primary" /> Actions preventives</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<StringListEditor label="FR" items={preventiveActions} setItems={setPreventiveActions} placeholder="Ex: Traitement au cuivre" />
|
||||
<div className="mt-3"><StringListEditor label="EN" items={preventiveActionsEn} setItems={setPreventiveActionsEn} placeholder="Ex: Copper treatment" /></div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="curative" className="border rounded-xl px-4">
|
||||
<AccordionTrigger className="text-sm font-medium py-3">
|
||||
<span className="flex items-center gap-2"><Syringe className="h-4 w-4 text-amber-600" /> Actions curatives</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<StringListEditor label="FR" items={curativeActions} setItems={setCurativeActions} placeholder="Ex: Fongicide systemique" />
|
||||
<div className="mt-3"><StringListEditor label="EN" items={curativeActionsEn} setItems={setCurativeActionsEn} placeholder="Ex: Systemic fungicide" /></div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="parts" className="border rounded-xl px-4">
|
||||
<AccordionTrigger className="text-sm font-medium py-3">
|
||||
<span className="flex items-center gap-2"><Leaf className="h-4 w-4 text-primary" /> Parties impactees</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{IMPACTED_PARTS_OPTIONS.map((part) => (
|
||||
<button key={part} type="button" onClick={() => togglePart(part)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${impactedParts.includes(part) ? "bg-primary/10 text-primary border-primary/30" : "bg-muted text-muted-foreground border-transparent hover:border-border"}`}>
|
||||
{part}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2"><Bug className="h-4 w-4 text-red-500" /><Label className="text-xs font-medium text-muted-foreground">Propagation (FR)</Label></div>
|
||||
<Input value={spreadMethod} onChange={(e) => setSpreadMethod(e.target.value)} className="rounded-xl" placeholder="Ex: Spores par le vent" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Propagation (EN)</Label>
|
||||
<Input value={spreadMethodEn} onChange={(e) => setSpreadMethodEn(e.target.value)} className="rounded-xl" placeholder="Ex: Spores by wind" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Images */}
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<div className="flex items-center gap-2"><ImagePlus className="h-4 w-4 text-primary" /><p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Images</p></div>
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{images.map((img, i) => (
|
||||
<div key={i} className="relative group rounded-xl overflow-hidden border border-border/50">
|
||||
<img src={img.url} alt={img.alt} className="w-full h-28 object-cover" />
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
|
||||
{i > 0 && <Button type="button" size="icon" variant="secondary" className="h-7 w-7" onClick={() => moveImage(i, -1)}><ChevronUp className="h-3.5 w-3.5" /></Button>}
|
||||
{i < images.length - 1 && <Button type="button" size="icon" variant="secondary" className="h-7 w-7" onClick={() => moveImage(i, 1)}><ChevronDown className="h-3.5 w-3.5" /></Button>}
|
||||
<Button type="button" size="icon" variant="destructive" className="h-7 w-7" onClick={() => removeImage(i)}><X className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground truncate px-2 py-1">{img.alt || "Sans description"}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Input value={newImageUrl} onChange={(e) => setNewImageUrl(e.target.value)} className="rounded-xl flex-1" placeholder="URL de l'image" />
|
||||
<Input value={newImageAlt} onChange={(e) => setNewImageAlt(e.target.value)} className="rounded-xl w-40" placeholder="Alt text" />
|
||||
<Button type="button" variant="secondary" size="sm" className="rounded-lg shrink-0" onClick={addImage} disabled={!newImageUrl.trim()}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
{newImageUrl && (
|
||||
<div className="rounded-xl overflow-hidden border border-dashed border-border h-32">
|
||||
<img src={newImageUrl} alt="Preview" className="w-full h-full object-cover" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Appearance */}
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Apparence</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Icone</Label>
|
||||
|
|
@ -332,8 +462,8 @@ export default function DiseaseForm({ initialData, mode }: DiseaseFormProps) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Publish + Actions */}
|
||||
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
|
||||
{/* Publish */}
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
|
@ -346,9 +476,7 @@ export default function DiseaseForm({ initialData, mode }: DiseaseFormProps) {
|
|||
</Card>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button type="button" variant="ghost" onClick={() => router.back()} className="rounded-xl">
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => router.back()} className="rounded-xl">Annuler</Button>
|
||||
<Button type="submit" disabled={loading} className="rounded-xl">
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||
{mode === "create" ? "Creer" : "Enregistrer"}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,83 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft, Plus, X, Loader2, ChevronUp, ChevronDown,
|
||||
Lightbulb, ImagePlus, BookOpen, Clock,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { slugify } from "@/lib/utils";
|
||||
|
||||
interface GuideSection {
|
||||
title: string;
|
||||
titleEn: string;
|
||||
body: string;
|
||||
bodyEn: string;
|
||||
image: string;
|
||||
tip: string;
|
||||
tipEn: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface GuideFormData {
|
||||
id?: string;
|
||||
title: string;
|
||||
titleEn: string;
|
||||
subtitle: string;
|
||||
subtitleEn: string;
|
||||
content: string;
|
||||
contentEn: string;
|
||||
category: string;
|
||||
iconName: string;
|
||||
iconColor: string;
|
||||
bgColor: string;
|
||||
published: boolean;
|
||||
order: number;
|
||||
readTime: number | null;
|
||||
coverImage: string | null;
|
||||
sections: GuideSection[];
|
||||
}
|
||||
|
||||
interface GuideFormProps {
|
||||
initialData?: {
|
||||
id?: string;
|
||||
title: string;
|
||||
titleEn: string;
|
||||
subtitle: string;
|
||||
subtitleEn: string;
|
||||
content: string;
|
||||
contentEn: string;
|
||||
category: string;
|
||||
iconName: string;
|
||||
iconColor: string;
|
||||
bgColor: string;
|
||||
published: boolean;
|
||||
order: number;
|
||||
};
|
||||
initialData?: GuideFormData;
|
||||
mode: "create" | "edit";
|
||||
}
|
||||
|
||||
const EMPTY_SECTION: GuideSection = {
|
||||
title: "", titleEn: "", body: "", bodyEn: "",
|
||||
image: "", tip: "", tipEn: "", order: 0,
|
||||
};
|
||||
|
||||
export default function GuideForm({ initialData, mode }: GuideFormProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [slugCustom, setSlugCustom] = useState(!!initialData?.id);
|
||||
|
||||
const [title, setTitle] = useState(initialData?.title ?? "");
|
||||
const [titleEn, setTitleEn] = useState(initialData?.titleEn ?? "");
|
||||
const [slug, setSlug] = useState(initialData?.id ? "" : "");
|
||||
const [subtitle, setSubtitle] = useState(initialData?.subtitle ?? "");
|
||||
const [subtitleEn, setSubtitleEn] = useState(initialData?.subtitleEn ?? "");
|
||||
const [content, setContent] = useState(initialData?.content ?? "");
|
||||
|
|
@ -47,50 +88,84 @@ export default function GuideForm({ initialData, mode }: GuideFormProps) {
|
|||
const [bgColor, setBgColor] = useState(initialData?.bgColor ?? "#E6F1FB");
|
||||
const [published, setPublished] = useState(initialData?.published ?? true);
|
||||
const [order, setOrder] = useState(initialData?.order ?? 0);
|
||||
const [readTime, setReadTime] = useState<number | null>(initialData?.readTime ?? null);
|
||||
const [coverImage, setCoverImage] = useState(initialData?.coverImage ?? "");
|
||||
const [sections, setSections] = useState<GuideSection[]>(
|
||||
initialData?.sections ?? [],
|
||||
);
|
||||
|
||||
const handleTitleChange = useCallback((v: string) => {
|
||||
setTitle(v);
|
||||
if (!slugCustom) setSlug(slugify(v));
|
||||
}, [slugCustom]);
|
||||
|
||||
// Section helpers
|
||||
function addSection() {
|
||||
setSections((prev) => [...prev, { ...EMPTY_SECTION, order: prev.length }]);
|
||||
}
|
||||
function removeSection(i: number) {
|
||||
setSections((prev) =>
|
||||
prev.filter((_, idx) => idx !== i).map((s, idx) => ({ ...s, order: idx })),
|
||||
);
|
||||
}
|
||||
function moveSection(i: number, dir: -1 | 1) {
|
||||
setSections((prev) => {
|
||||
const n = [...prev];
|
||||
const t = i + dir;
|
||||
if (t < 0 || t >= n.length) return n;
|
||||
[n[i], n[t]] = [n[t], n[i]];
|
||||
return n.map((s, idx) => ({ ...s, order: idx }));
|
||||
});
|
||||
}
|
||||
function updateSection(i: number, field: keyof GuideSection, value: string | number) {
|
||||
setSections((prev) => {
|
||||
const n = [...prev];
|
||||
n[i] = { ...n[i], [field]: value };
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
|
||||
if (!title.trim() || !subtitle.trim() || !content.trim()) {
|
||||
toast.error("Veuillez remplir les champs obligatoires");
|
||||
if (!title.trim() || !subtitle.trim()) {
|
||||
toast.error("Veuillez remplir le titre et le sous-titre");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const body = {
|
||||
title: title.trim(),
|
||||
titleEn: titleEn.trim(),
|
||||
slug: slugify(title),
|
||||
subtitle: subtitle.trim(),
|
||||
subtitleEn: subtitleEn.trim(),
|
||||
content: content.trim(),
|
||||
contentEn: contentEn.trim(),
|
||||
category,
|
||||
iconName,
|
||||
iconColor,
|
||||
bgColor,
|
||||
published,
|
||||
order,
|
||||
title: title.trim(), titleEn: titleEn.trim(),
|
||||
slug: slug || slugify(title),
|
||||
subtitle: subtitle.trim(), subtitleEn: subtitleEn.trim(),
|
||||
content: content.trim(), contentEn: contentEn.trim(),
|
||||
category, iconName, iconColor, bgColor, published, order,
|
||||
readTime, coverImage: coverImage.trim() || null,
|
||||
sections: sections
|
||||
.filter((s) => s.title.trim() && s.body.trim())
|
||||
.map((s, i) => ({
|
||||
title: s.title.trim(), titleEn: s.titleEn.trim(),
|
||||
body: s.body.trim(), bodyEn: s.bodyEn.trim(),
|
||||
image: s.image.trim() || null,
|
||||
tip: s.tip.trim() || null, tipEn: s.tipEn.trim() || null,
|
||||
order: i,
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const url = mode === "create" ? "/api/guides" : `/api/guides/${initialData?.id}`;
|
||||
const method = mode === "create" ? "POST" : "PUT";
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
method: mode === "create" ? "POST" : "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
toast.error(data.error || "Erreur");
|
||||
const d = await res.json();
|
||||
toast.error(d.error || "Erreur");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(mode === "create" ? "Guide cree" : "Guide mis a jour");
|
||||
router.push("/guides");
|
||||
router.refresh();
|
||||
|
|
@ -102,7 +177,7 @@ export default function GuideForm({ initialData, mode }: GuideFormProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="max-w-3xl mx-auto space-y-6 pb-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
|
@ -113,13 +188,14 @@ export default function GuideForm({ initialData, mode }: GuideFormProps) {
|
|||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
|
||||
{/* ── General info ── */}
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Informations</p>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Informations generales</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Titre (FR) *</Label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} className="rounded-xl" required />
|
||||
<Input value={title} onChange={(e) => handleTitleChange(e.target.value)} className="rounded-xl" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Titre (EN)</Label>
|
||||
|
|
@ -136,36 +212,208 @@ export default function GuideForm({ initialData, mode }: GuideFormProps) {
|
|||
<Input value={subtitleEn} onChange={(e) => setSubtitleEn(e.target.value)} className="rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Slug</Label>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">{slugCustom ? "custom" : "auto"}</Badge>
|
||||
</div>
|
||||
<Input value={slug} onChange={(e) => { setSlugCustom(true); setSlug(e.target.value); }} className="rounded-xl font-mono text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Categorie</Label>
|
||||
<Input value={category} onChange={(e) => setCategory(e.target.value)} className="rounded-xl" />
|
||||
<Select value={category} onValueChange={(v) => v && setCategory(v)}>
|
||||
<SelectTrigger className="rounded-xl"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="general">General</SelectItem>
|
||||
<SelectItem value="diagnostic">Diagnostic</SelectItem>
|
||||
<SelectItem value="traitement">Traitement</SelectItem>
|
||||
<SelectItem value="cepages">Cepages</SelectItem>
|
||||
<SelectItem value="prevention">Prevention</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Ordre</Label>
|
||||
<Label className="text-xs font-medium text-muted-foreground">Ordre d'affichage</Label>
|
||||
<Input type="number" value={order} onChange={(e) => setOrder(parseInt(e.target.value) || 0)} className="rounded-xl" min={0} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Label className="text-xs font-medium text-muted-foreground">Temps de lecture (min)</Label>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={readTime ?? ""}
|
||||
onChange={(e) => setReadTime(e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="rounded-xl"
|
||||
min={1}
|
||||
placeholder="5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Cover image */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImagePlus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Label className="text-xs font-medium text-muted-foreground">Image de couverture (URL)</Label>
|
||||
</div>
|
||||
<Input value={coverImage} onChange={(e) => setCoverImage(e.target.value)} className="rounded-xl" placeholder="https://..." />
|
||||
{coverImage && (
|
||||
<div className="rounded-xl overflow-hidden border border-dashed border-border h-32 mt-2">
|
||||
<img src={coverImage} alt="Preview" className="w-full h-full object-cover" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Contenu</p>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Contenu (FR) *</Label>
|
||||
<Textarea value={content} onChange={(e) => setContent(e.target.value)} rows={6} className="rounded-xl" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Contenu (EN)</Label>
|
||||
<Textarea value={contentEn} onChange={(e) => setContentEn(e.target.value)} rows={6} className="rounded-xl" />
|
||||
</div>
|
||||
{/* ── Legacy content (collapsible) ── */}
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="p-5">
|
||||
<Accordion>
|
||||
<AccordionItem value="legacy" className="border-0">
|
||||
<AccordionTrigger className="text-sm font-medium py-0 hover:no-underline">
|
||||
<span className="flex items-center gap-2">
|
||||
Contenu brut
|
||||
<Badge variant="secondary" className="text-[10px] bg-amber-500/10 text-amber-600 border-0">Ancien format</Badge>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-4 space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ce champ est conserve pour compatibilite. Utilisez les sections ci-dessous pour le contenu structure.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Contenu (FR)</Label>
|
||||
<Textarea value={content} onChange={(e) => setContent(e.target.value)} rows={4} className="rounded-xl" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Contenu (EN)</Label>
|
||||
<Textarea value={contentEn} onChange={(e) => setContentEn(e.target.value)} rows={4} className="rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
|
||||
{/* ── Sections ── */}
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Apparence</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4 text-primary" />
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Sections du guide</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">{sections.length} section{sections.length !== 1 ? "s" : ""}</Badge>
|
||||
</div>
|
||||
|
||||
{sections.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="h-14 w-14 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
<BookOpen className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm font-medium mb-1">Aucune section</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">Ajoutez la premiere section de votre guide.</p>
|
||||
<Button type="button" variant="secondary" size="sm" className="rounded-lg" onClick={addSection}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> Ajouter la premiere section
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sections.map((section, i) => (
|
||||
<div key={i} className="rounded-xl border border-border/50 bg-muted/30 p-4 space-y-4">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">Section {i + 1}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{i > 0 && (
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" onClick={() => moveSection(i, -1)}>
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{i < sections.length - 1 && (
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" onClick={() => moveSection(i, 1)}>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={() => removeSection(i)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-medium text-muted-foreground">Titre (FR) *</Label>
|
||||
<Input value={section.title} onChange={(e) => updateSection(i, "title", e.target.value)} className="rounded-lg h-9 text-sm" placeholder="Titre de la section" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-medium text-muted-foreground">Titre (EN)</Label>
|
||||
<Input value={section.titleEn} onChange={(e) => updateSection(i, "titleEn", e.target.value)} className="rounded-lg h-9 text-sm" placeholder="Section title" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-medium text-muted-foreground">Contenu (FR) *</Label>
|
||||
<Textarea value={section.body} onChange={(e) => updateSection(i, "body", e.target.value)} rows={4} className="rounded-lg text-sm" placeholder="Contenu de la section..." />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-medium text-muted-foreground">Contenu (EN)</Label>
|
||||
<Textarea value={section.bodyEn} onChange={(e) => updateSection(i, "bodyEn", e.target.value)} rows={4} className="rounded-lg text-sm" placeholder="Section content..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tip */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Lightbulb className="h-3 w-3 text-amber-500" />
|
||||
<Label className="text-[11px] font-medium text-muted-foreground">Astuce (FR)</Label>
|
||||
</div>
|
||||
<Input value={section.tip} onChange={(e) => updateSection(i, "tip", e.target.value)} className="rounded-lg h-9 text-sm" placeholder="Conseil pratique..." />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-medium text-muted-foreground">Astuce (EN)</Label>
|
||||
<Input value={section.tipEn} onChange={(e) => updateSection(i, "tipEn", e.target.value)} className="rounded-lg h-9 text-sm" placeholder="Practical tip..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ImagePlus className="h-3 w-3 text-muted-foreground" />
|
||||
<Label className="text-[11px] font-medium text-muted-foreground">Image (URL)</Label>
|
||||
</div>
|
||||
<Input value={section.image} onChange={(e) => updateSection(i, "image", e.target.value)} className="rounded-lg h-9 text-sm" placeholder="https://..." />
|
||||
{section.image && (
|
||||
<div className="rounded-lg overflow-hidden border border-dashed border-border h-24 mt-1">
|
||||
<img src={section.image} alt="" className="w-full h-full object-cover" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button type="button" variant="secondary" size="sm" className="rounded-lg w-full" onClick={addSection}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> Ajouter une section
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Appearance ── */}
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Apparence</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">Icone</Label>
|
||||
|
|
@ -189,7 +437,8 @@ export default function GuideForm({ initialData, mode }: GuideFormProps) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
|
||||
{/* ── Publish ── */}
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -9,10 +9,9 @@ import {
|
|||
AlertTriangle,
|
||||
Users,
|
||||
LogOut,
|
||||
Grape,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { signOut } from "@/lib/auth-client";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
|
|
@ -63,45 +62,47 @@ export default function Sidebar({
|
|||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"relative flex flex-col h-full bg-[oklch(0.11_0.005_60)] border-r border-[oklch(0.20_0.006_60)] transition-all duration-300",
|
||||
"relative flex flex-col h-full bg-[oklch(0.11_0.005_60)] border-r border-[oklch(0.20_0.006_60)] transition-all duration-300 ease-in-out",
|
||||
collapsed ? "w-[68px]" : "w-[260px]"
|
||||
)}
|
||||
>
|
||||
{/* Logo area */}
|
||||
{/* Logo + collapse toggle */}
|
||||
<div className="flex items-center justify-between px-4 h-16 shrink-0">
|
||||
{!collapsed && (
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5 group">
|
||||
<div className="h-8 w-8 rounded-lg bg-vine/10 flex items-center justify-center group-hover:bg-vine/15 transition-colors">
|
||||
<Grape className="h-4.5 w-4.5 text-vine" />
|
||||
</div>
|
||||
<span className="font-display text-lg font-semibold tracking-tight text-cream">
|
||||
VinEye
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
{collapsed && (
|
||||
<Link href="/dashboard" className="mx-auto">
|
||||
<div className="h-8 w-8 rounded-lg bg-vine/10 flex items-center justify-center hover:bg-vine/15 transition-colors">
|
||||
<Grape className="h-4.5 w-4.5 text-vine" />
|
||||
</div>
|
||||
</Link>
|
||||
{!collapsed ? (
|
||||
<>
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5 group">
|
||||
<div className="h-8 w-8 rounded-lg bg-vine/10 flex items-center justify-center group-hover:bg-vine/15 transition-colors overflow-hidden">
|
||||
<Image src="/logo.png" alt="VinEye" width={24} height={24} className="object-contain" />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight text-cream">
|
||||
VinEye
|
||||
</span>
|
||||
</Link>
|
||||
{onCollapse && (
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
className="h-7 w-7 rounded-lg flex items-center justify-center text-stone-600 hover:text-cream hover:bg-[oklch(0.17_0.005_60)] transition-all duration-200"
|
||||
>
|
||||
<ChevronsLeft
|
||||
className="h-4 w-4 transition-transform duration-300"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
className="mx-auto h-8 w-8 rounded-lg bg-vine/10 flex items-center justify-center hover:bg-vine/15 transition-colors overflow-hidden"
|
||||
>
|
||||
<ChevronsLeft
|
||||
className="h-4 w-4 text-vine rotate-180 transition-transform duration-300"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
{onCollapse && (
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
className="absolute -right-3 top-[52px] z-10 h-6 w-6 rounded-full border border-[oklch(0.25_0.006_60)] bg-[oklch(0.15_0.005_60)] flex items-center justify-center text-stone-400 hover:text-vine hover:border-vine/30 transition-colors"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-3 h-px bg-gradient-to-r from-transparent via-[oklch(0.25_0.006_60)] to-transparent" />
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export default function StatCard({
|
|||
<p className="text-[11px] font-semibold text-stone-600 uppercase tracking-[0.08em] mb-2">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-[28px] font-display font-semibold tracking-tight text-cream leading-none">
|
||||
<p className="text-[28px] font-bold tracking-tight text-cream leading-none">
|
||||
{value.toLocaleString("fr-FR")}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
74
vineye-admin/components/ui/accordion.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"use client"
|
||||
|
||||
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn("flex w-full flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("not-last:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AccordionPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AccordionPrimitive.Panel.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Panel
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
|
|
@ -3,6 +3,7 @@ import { prismaAdapter } from "better-auth/adapters/prisma";
|
|||
import { prisma } from "./prisma";
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: process.env.BETTER_AUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "postgresql",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,25 @@ export const diseaseSchema = z.object({
|
|||
bgColor: z.string().trim().optional().default("#E1F5EE"),
|
||||
imageUrl: z.string().url().optional().nullable(),
|
||||
published: z.boolean().optional().default(true),
|
||||
// Enriched fields
|
||||
startMonth: z.number().int().min(1).max(12).optional().nullable(),
|
||||
endMonth: z.number().int().min(1).max(12).optional().nullable(),
|
||||
peakMonth: z.number().int().min(1).max(12).optional().nullable(),
|
||||
conditions: z.array(z.string().trim()).optional().default([]),
|
||||
conditionsEn: z.array(z.string().trim()).optional().default([]),
|
||||
preventiveActions: z.array(z.string().trim()).optional().default([]),
|
||||
preventiveActionsEn: z.array(z.string().trim()).optional().default([]),
|
||||
curativeActions: z.array(z.string().trim()).optional().default([]),
|
||||
curativeActionsEn: z.array(z.string().trim()).optional().default([]),
|
||||
impactedParts: z.array(z.string().trim()).optional().default([]),
|
||||
impactedPartsEn: z.array(z.string().trim()).optional().default([]),
|
||||
spreadMethod: z.string().trim().optional().nullable(),
|
||||
spreadMethodEn: z.string().trim().optional().nullable(),
|
||||
images: z.array(z.object({
|
||||
url: z.string().url(),
|
||||
alt: z.string().optional().default(""),
|
||||
order: z.number().int().optional().default(0),
|
||||
})).optional().default([]),
|
||||
});
|
||||
|
||||
export const guideSchema = z.object({
|
||||
|
|
@ -28,7 +47,7 @@ export const guideSchema = z.object({
|
|||
slug: z.string().max(100).trim().optional(),
|
||||
subtitle: z.string().min(1, "Sous-titre requis").max(500).trim(),
|
||||
subtitleEn: z.string().max(500).trim().optional().default(""),
|
||||
content: z.string().min(1, "Contenu requis").trim(),
|
||||
content: z.string().trim().optional().default(""),
|
||||
contentEn: z.string().trim().optional().default(""),
|
||||
category: z.string().trim().optional().default("general"),
|
||||
iconName: z.string().trim().optional().default("book"),
|
||||
|
|
@ -36,6 +55,18 @@ export const guideSchema = z.object({
|
|||
bgColor: z.string().trim().optional().default("#E6F1FB"),
|
||||
published: z.boolean().optional().default(true),
|
||||
order: z.number().int().min(0).optional().default(0),
|
||||
readTime: z.number().int().min(1).optional().nullable(),
|
||||
coverImage: z.string().trim().optional().nullable(),
|
||||
sections: z.array(z.object({
|
||||
title: z.string().min(1, "Titre de section requis").trim(),
|
||||
titleEn: z.string().trim().optional().default(""),
|
||||
body: z.string().min(1, "Contenu de section requis").trim(),
|
||||
bodyEn: z.string().trim().optional().default(""),
|
||||
image: z.string().trim().optional().nullable(),
|
||||
tip: z.string().trim().optional().nullable(),
|
||||
tipEn: z.string().trim().optional().nullable(),
|
||||
order: z.number().int().optional().default(0),
|
||||
})).optional().default([]),
|
||||
});
|
||||
|
||||
export const alertSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -1,8 +1,29 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, X-API-Version",
|
||||
"X-API-Version": "1.0",
|
||||
};
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// ── Mobile API routes: public, CORS enabled ──
|
||||
if (pathname.startsWith("/api/mobile")) {
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
const response = NextResponse.next();
|
||||
Object.entries(CORS_HEADERS).forEach(([key, value]) => {
|
||||
response.headers.set(key, value);
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
// ── Admin routes: require session ──
|
||||
if (
|
||||
pathname.startsWith("/dashboard") ||
|
||||
pathname.startsWith("/diseases") ||
|
||||
|
|
@ -21,6 +42,7 @@ export async function middleware(request: NextRequest) {
|
|||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/api/mobile/:path*",
|
||||
"/dashboard/:path*",
|
||||
"/diseases/:path*",
|
||||
"/guides/:path*",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ["localhost", "127.0.0.1", "10.0.2.2", "192.168.*.*"],
|
||||
turbopack: {
|
||||
root: ".",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export default defineConfig({
|
|||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
seed: "npx tsx prisma/seed.ts",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "diseases" ADD COLUMN "conditions" TEXT[],
|
||||
ADD COLUMN "conditionsEn" TEXT[],
|
||||
ADD COLUMN "curativeActions" TEXT[],
|
||||
ADD COLUMN "curativeActionsEn" TEXT[],
|
||||
ADD COLUMN "endMonth" INTEGER,
|
||||
ADD COLUMN "impactedParts" TEXT[],
|
||||
ADD COLUMN "impactedPartsEn" TEXT[],
|
||||
ADD COLUMN "peakMonth" INTEGER,
|
||||
ADD COLUMN "preventiveActions" TEXT[],
|
||||
ADD COLUMN "preventiveActionsEn" TEXT[],
|
||||
ADD COLUMN "spreadMethod" TEXT,
|
||||
ADD COLUMN "spreadMethodEn" TEXT,
|
||||
ADD COLUMN "startMonth" INTEGER;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "guides" ADD COLUMN "coverImage" TEXT,
|
||||
ADD COLUMN "readTime" INTEGER;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "disease_images" (
|
||||
"id" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"alt" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"diseaseId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "disease_images_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "guide_sections" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"titleEn" TEXT,
|
||||
"body" TEXT NOT NULL,
|
||||
"bodyEn" TEXT,
|
||||
"image" TEXT,
|
||||
"tip" TEXT,
|
||||
"tipEn" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"guideId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "guide_sections_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "disease_images_diseaseId_idx" ON "disease_images"("diseaseId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "guide_sections_guideId_idx" ON "guide_sections"("guideId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "disease_images" ADD CONSTRAINT "disease_images_diseaseId_fkey" FOREIGN KEY ("diseaseId") REFERENCES "diseases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "guide_sections" ADD CONSTRAINT "guide_sections_guideId_fkey" FOREIGN KEY ("guideId") REFERENCES "guides"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -123,6 +123,26 @@ model Disease {
|
|||
published Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Timeline (months 1-12)
|
||||
startMonth Int?
|
||||
endMonth Int?
|
||||
peakMonth Int?
|
||||
|
||||
// Detail arrays
|
||||
conditions String[]
|
||||
conditionsEn String[]
|
||||
preventiveActions String[]
|
||||
preventiveActionsEn String[]
|
||||
curativeActions String[]
|
||||
curativeActionsEn String[]
|
||||
impactedParts String[]
|
||||
impactedPartsEn String[]
|
||||
spreadMethod String?
|
||||
spreadMethodEn String?
|
||||
|
||||
// Relations
|
||||
images DiseaseImage[]
|
||||
scans Scan[]
|
||||
|
||||
@@index([type])
|
||||
|
|
@ -131,6 +151,19 @@ model Disease {
|
|||
@@map("diseases")
|
||||
}
|
||||
|
||||
model DiseaseImage {
|
||||
id String @id @default(cuid())
|
||||
url String
|
||||
alt String?
|
||||
order Int @default(0)
|
||||
diseaseId String
|
||||
disease Disease @relation(fields: [diseaseId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([diseaseId])
|
||||
@@map("disease_images")
|
||||
}
|
||||
|
||||
model Guide {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
|
|
@ -149,11 +182,36 @@ model Guide {
|
|||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// New fields
|
||||
readTime Int?
|
||||
coverImage String?
|
||||
|
||||
// Relations
|
||||
sections GuideSection[]
|
||||
|
||||
@@index([published])
|
||||
@@index([order])
|
||||
@@map("guides")
|
||||
}
|
||||
|
||||
model GuideSection {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
titleEn String?
|
||||
body String @db.Text
|
||||
bodyEn String? @db.Text
|
||||
image String?
|
||||
tip String? @db.Text
|
||||
tipEn String? @db.Text
|
||||
order Int @default(0)
|
||||
guideId String
|
||||
guide Guide @relation(fields: [guideId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([guideId])
|
||||
@@map("guide_sections")
|
||||
}
|
||||
|
||||
model Scan {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
|
|
|
|||
|
|
@ -9,396 +9,326 @@ const prisma = new PrismaClient({ adapter });
|
|||
async function main() {
|
||||
console.log("Seeding database...");
|
||||
|
||||
// 1. Admin user
|
||||
// ── 1. Users ──
|
||||
const passwordHash = await hashPassword("admin123456");
|
||||
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: "admin@vineye.app" },
|
||||
update: {},
|
||||
create: {
|
||||
id: "admin-001",
|
||||
name: "Admin VinEye",
|
||||
email: "admin@vineye.app",
|
||||
role: "ADMIN",
|
||||
emailVerified: true,
|
||||
xp: 5000,
|
||||
level: 10,
|
||||
},
|
||||
create: { id: "admin-001", name: "Admin VinEye", email: "admin@vineye.app", role: "ADMIN", emailVerified: true, xp: 5000, level: 10 },
|
||||
});
|
||||
|
||||
// Create account for Better Auth email/password login
|
||||
await prisma.account.upsert({
|
||||
where: { id: "account-admin-001" },
|
||||
update: {},
|
||||
create: {
|
||||
id: "account-admin-001",
|
||||
accountId: admin.id,
|
||||
providerId: "credential",
|
||||
userId: admin.id,
|
||||
password: passwordHash,
|
||||
},
|
||||
create: { id: "account-admin-001", accountId: admin.id, providerId: "credential", userId: admin.id, password: passwordHash },
|
||||
});
|
||||
|
||||
console.log(" Admin user created: admin@vineye.app / admin123456");
|
||||
|
||||
// 2. Test user
|
||||
const userPasswordHash = await hashPassword("user123456");
|
||||
|
||||
const testUser = await prisma.user.upsert({
|
||||
where: { email: "jean@vineye.app" },
|
||||
update: {},
|
||||
create: {
|
||||
id: "user-001",
|
||||
name: "Jean Dupont",
|
||||
email: "jean@vineye.app",
|
||||
role: "USER",
|
||||
emailVerified: true,
|
||||
xp: 1250,
|
||||
level: 4,
|
||||
},
|
||||
create: { id: "user-001", name: "Jean Dupont", email: "jean@vineye.app", role: "USER", emailVerified: true, xp: 1250, level: 4 },
|
||||
});
|
||||
|
||||
await prisma.account.upsert({
|
||||
where: { id: "account-user-001" },
|
||||
update: {},
|
||||
create: {
|
||||
id: "account-user-001",
|
||||
accountId: testUser.id,
|
||||
providerId: "credential",
|
||||
userId: testUser.id,
|
||||
password: userPasswordHash,
|
||||
},
|
||||
create: { id: "account-user-001", accountId: testUser.id, providerId: "credential", userId: testUser.id, password: userPasswordHash },
|
||||
});
|
||||
console.log(" Users seeded");
|
||||
|
||||
console.log(" Test user created: jean@vineye.app / user123456");
|
||||
|
||||
// 3. Diseases
|
||||
// ── 2. Diseases (enriched) ──
|
||||
const diseases = [
|
||||
{
|
||||
slug: "mildiou",
|
||||
name: "Mildiou",
|
||||
nameEn: "Downy Mildew",
|
||||
scientificName: "Plasmopara viticola",
|
||||
type: "FUNGAL" as const,
|
||||
severity: "HIGH" as const,
|
||||
description:
|
||||
"Le mildiou est cause par le champignon Plasmopara viticola. Il attaque toutes les parties vertes de la vigne, principalement les feuilles.",
|
||||
descriptionEn:
|
||||
"Downy mildew is caused by the fungus Plasmopara viticola. It attacks all green parts of the vine, mainly the leaves.",
|
||||
symptoms: [
|
||||
"Taches jaunes huileuses sur la face superieure des feuilles",
|
||||
"Duvet blanc cotonneux sur la face inferieure",
|
||||
"Dessechement et chute prematuree des feuilles",
|
||||
name: "Mildiou", nameEn: "Downy Mildew", scientificName: "Plasmopara viticola",
|
||||
type: "FUNGAL" as const, severity: "HIGH" as const,
|
||||
description: "Le mildiou est cause par le champignon Plasmopara viticola. Il attaque toutes les parties vertes de la vigne, principalement les feuilles.",
|
||||
descriptionEn: "Downy mildew is caused by the fungus Plasmopara viticola. It attacks all green parts of the vine, mainly the leaves.",
|
||||
symptoms: ["Taches jaunes huileuses sur la face superieure des feuilles", "Duvet blanc cotonneux sur la face inferieure", "Dessechement et chute prematuree des feuilles"],
|
||||
symptomsEn: ["Oily yellow spots on the upper surface of leaves", "White cottony down on the underside", "Drying and premature leaf drop"],
|
||||
treatment: "Traitement preventif a base de cuivre (bouillie bordelaise). Appliquer avant les pluies, renouveler tous les 10-14 jours.",
|
||||
treatmentEn: "Preventive copper-based treatment (Bordeaux mixture). Apply before rain, renew every 10-14 days.",
|
||||
season: "Mai a aout", seasonEn: "May to August",
|
||||
iconName: "droplets", iconColor: "#BA7517", bgColor: "#FAEEDA",
|
||||
startMonth: 5, endMonth: 8, peakMonth: 6,
|
||||
conditions: ["Humidite superieure a 80%", "Temperatures entre 18 et 25°C", "Pluies frequentes au printemps"],
|
||||
conditionsEn: ["Humidity above 80%", "Temperatures between 18 and 25°C", "Frequent spring rains"],
|
||||
preventiveActions: ["Traitement cuivrique preventif (bouillie bordelaise)", "Aerer la vegetation par ebourgeonnage", "Eviter l'exces d'azote"],
|
||||
preventiveActionsEn: ["Preventive copper treatment (Bordeaux mixture)", "Improve air circulation through shoot thinning", "Avoid excess nitrogen"],
|
||||
curativeActions: ["Appliquer un fongicide systemique homologue", "Retirer les feuilles tres atteintes"],
|
||||
curativeActionsEn: ["Apply a registered systemic fungicide", "Remove severely affected leaves"],
|
||||
impactedParts: ["Feuilles", "Grappes", "Rameaux"],
|
||||
impactedPartsEn: ["Leaves", "Clusters", "Shoots"],
|
||||
spreadMethod: "Spores dispersees par le vent et les eclaboussures de pluie",
|
||||
spreadMethodEn: "Spores dispersed by wind and rain splash",
|
||||
images: [
|
||||
{ url: "https://images.unsplash.com/photo-1596142780450-01a1f79c400c?w=800&h=600&fit=crop", alt: "Mildiou feuille", order: 0 },
|
||||
{ url: "https://images.unsplash.com/photo-1504279577054-acfeccf8fc52?w=800&h=600&fit=crop", alt: "Mildiou vigne", order: 1 },
|
||||
],
|
||||
symptomsEn: [
|
||||
"Oily yellow spots on the upper side of leaves",
|
||||
"White cottony fuzz on the underside",
|
||||
"Drying and premature leaf drop",
|
||||
],
|
||||
treatment:
|
||||
"Traitement preventif a base de cuivre (bouillie bordelaise). Appliquer avant les pluies, renouveler tous les 10-14 jours.",
|
||||
treatmentEn:
|
||||
"Preventive copper-based treatment (Bordeaux mixture). Apply before rain, repeat every 10-14 days.",
|
||||
season: "Mai a aout — favorise par la chaleur et l'humidite",
|
||||
seasonEn: "May to August — favored by heat and humidity",
|
||||
iconName: "droplets",
|
||||
iconColor: "#1D9E75",
|
||||
bgColor: "#E1F5EE",
|
||||
},
|
||||
{
|
||||
slug: "oidium",
|
||||
name: "Oidium",
|
||||
nameEn: "Powdery Mildew",
|
||||
scientificName: "Erysiphe necator",
|
||||
type: "FUNGAL" as const,
|
||||
severity: "HIGH" as const,
|
||||
description:
|
||||
"L'oidium est cause par Erysiphe necator. Il se developpe par temps chaud et sec, contrairement au mildiou.",
|
||||
descriptionEn:
|
||||
"Powdery mildew is caused by Erysiphe necator. It develops in hot, dry weather, unlike downy mildew.",
|
||||
symptoms: [
|
||||
"Poudre blanche-grisatre sur feuilles et grappes",
|
||||
"Baies qui eclatent ou se dessechent",
|
||||
name: "Oidium", nameEn: "Powdery Mildew", scientificName: "Erysiphe necator",
|
||||
type: "FUNGAL" as const, severity: "HIGH" as const,
|
||||
description: "L'oidium est cause par Erysiphe necator. Il se developpe par temps chaud et sec.",
|
||||
descriptionEn: "Powdery mildew is caused by Erysiphe necator. It develops in warm, dry weather.",
|
||||
symptoms: ["Poudre blanche-grisatre sur feuilles et grappes", "Baies qui eclatent ou se dessechent"],
|
||||
symptomsEn: ["White-grey powder on leaves and clusters", "Berries that crack or dry out"],
|
||||
treatment: "Soufre en poudrage ou pulverisation. Traitements preventifs des le debourrement.",
|
||||
treatmentEn: "Sulfur dusting or spraying. Preventive treatments from bud break.",
|
||||
season: "Avril a septembre", seasonEn: "April to September",
|
||||
iconName: "wind", iconColor: "#534AB7", bgColor: "#EEEDFE",
|
||||
startMonth: 4, endMonth: 9, peakMonth: 7,
|
||||
conditions: ["Temps chaud et sec", "Temperatures entre 25 et 30°C", "Forte amplitude thermique jour/nuit"],
|
||||
conditionsEn: ["Hot and dry weather", "Temperatures between 25 and 30°C", "High day/night temperature difference"],
|
||||
preventiveActions: ["Traitement soufre preventif", "Favoriser l'aeration des grappes", "Effeuillage modere"],
|
||||
preventiveActionsEn: ["Preventive sulfur treatment", "Promote cluster ventilation", "Moderate leaf removal"],
|
||||
curativeActions: ["Appliquer un fongicide anti-oidium", "Soufre mouillable en curatif"],
|
||||
curativeActionsEn: ["Apply an anti-powdery mildew fungicide", "Wettable sulfur as curative treatment"],
|
||||
impactedParts: ["Feuilles", "Grappes", "Jeunes pousses"],
|
||||
impactedPartsEn: ["Leaves", "Clusters", "Young shoots"],
|
||||
spreadMethod: "Spores transportees par le vent",
|
||||
spreadMethodEn: "Spores carried by wind",
|
||||
images: [
|
||||
{ url: "https://images.unsplash.com/photo-1507434965515-61970f2bd7c6?w=800&h=600&fit=crop", alt: "Oidium", order: 0 },
|
||||
{ url: "https://images.unsplash.com/photo-1560493676-04071c5f467b?w=800&h=600&fit=crop", alt: "Oidium vigne", order: 1 },
|
||||
],
|
||||
symptomsEn: [
|
||||
"White-grayish powder on leaves and clusters",
|
||||
"Berries that crack or dry out",
|
||||
],
|
||||
treatment:
|
||||
"Soufre en poudrage ou pulverisation. Traitements preventifs des le debourrement.",
|
||||
treatmentEn:
|
||||
"Sulfur dusting or spraying. Preventive treatments from bud break.",
|
||||
season: "Avril a septembre — favorise par temps chaud et sec",
|
||||
seasonEn: "April to September — favored by hot and dry weather",
|
||||
iconName: "wind",
|
||||
iconColor: "#8B5CF6",
|
||||
bgColor: "#F3EEFF",
|
||||
},
|
||||
{
|
||||
slug: "black-rot",
|
||||
name: "Black rot",
|
||||
nameEn: "Black Rot",
|
||||
scientificName: "Guignardia bidwellii",
|
||||
type: "FUNGAL" as const,
|
||||
severity: "HIGH" as const,
|
||||
description:
|
||||
"Le black rot est cause par Guignardia bidwellii. Il provoque des degats importants sur les baies.",
|
||||
descriptionEn:
|
||||
"Black rot is caused by Guignardia bidwellii. It causes significant damage to berries.",
|
||||
symptoms: [
|
||||
"Taches brunes circulaires bordees de noir sur les feuilles",
|
||||
"Baies momifiees, noires et ridees",
|
||||
name: "Black rot", nameEn: "Black Rot", scientificName: "Guignardia bidwellii",
|
||||
type: "FUNGAL" as const, severity: "HIGH" as const,
|
||||
description: "Le black rot est cause par Guignardia bidwellii. Il provoque des degats importants sur les baies.",
|
||||
descriptionEn: "Black rot is caused by Guignardia bidwellii. It causes significant damage to berries.",
|
||||
symptoms: ["Taches brunes circulaires bordees de noir sur les feuilles", "Baies momifiees, noires et ridees"],
|
||||
symptomsEn: ["Circular brown spots bordered with black on leaves", "Mummified, black and wrinkled berries"],
|
||||
treatment: "Eliminer les baies momifiees. Traitements fongicides preventifs au printemps.",
|
||||
treatmentEn: "Remove mummified berries. Preventive fungicide treatments in spring.",
|
||||
season: "Mai a juillet", seasonEn: "May to July",
|
||||
iconName: "circle", iconColor: "#5F5E5A", bgColor: "#F1EFE8",
|
||||
startMonth: 5, endMonth: 8, peakMonth: 6,
|
||||
conditions: ["Pluies au printemps", "Temperatures entre 20 et 30°C", "Presence de baies momifiees de l'annee precedente"],
|
||||
conditionsEn: ["Spring rainfall", "Temperatures between 20 and 30°C", "Presence of mummified berries from the previous year"],
|
||||
preventiveActions: ["Eliminer les momies en hiver", "Traitements fongicides preventifs des la floraison", "Maintenir une bonne aeration"],
|
||||
preventiveActionsEn: ["Remove mummies in winter", "Preventive fungicide treatments from flowering", "Maintain good air circulation"],
|
||||
curativeActions: ["Pas de traitement curatif efficace", "Retirer et detruire les organes atteints"],
|
||||
curativeActionsEn: ["No effective curative treatment", "Remove and destroy affected organs"],
|
||||
impactedParts: ["Feuilles", "Grappes", "Vrilles"],
|
||||
impactedPartsEn: ["Leaves", "Clusters", "Tendrils"],
|
||||
spreadMethod: "Spores liberees par les momies sous l'effet de la pluie",
|
||||
spreadMethodEn: "Spores released from mummies by rain",
|
||||
images: [
|
||||
{ url: "https://images.unsplash.com/photo-1516594915697-87eb3b1c14ea?w=800&h=600&fit=crop", alt: "Black rot", order: 0 },
|
||||
{ url: "https://images.unsplash.com/photo-1567306301408-9b74779a11af?w=800&h=600&fit=crop", alt: "Black rot vigne", order: 1 },
|
||||
],
|
||||
symptomsEn: [
|
||||
"Circular brown spots bordered in black on leaves",
|
||||
"Mummified, black and wrinkled berries",
|
||||
],
|
||||
treatment:
|
||||
"Eliminer les baies momifiees. Traitements fongicides preventifs au printemps.",
|
||||
treatmentEn:
|
||||
"Remove mummified berries. Preventive fungicide treatments in spring.",
|
||||
season: "Mai a juillet — favorise par les pluies printanieres",
|
||||
seasonEn: "May to July — favored by spring rains",
|
||||
iconName: "circle",
|
||||
iconColor: "#1A1A1A",
|
||||
bgColor: "#F0F0F0",
|
||||
},
|
||||
{
|
||||
slug: "esca",
|
||||
name: "Esca",
|
||||
nameEn: "Esca Disease",
|
||||
scientificName: "Phaeomoniella chlamydospora",
|
||||
type: "FUNGAL" as const,
|
||||
severity: "MEDIUM" as const,
|
||||
description:
|
||||
"L'esca est un complexe de maladies du bois cause par plusieurs champignons. Maladie chronique qui peut tuer le cep.",
|
||||
descriptionEn:
|
||||
"Esca is a complex of wood diseases caused by several fungi. A chronic disease that can kill the vine.",
|
||||
symptoms: [
|
||||
"Decolorations entre les nervures des feuilles (aspect tigre)",
|
||||
"Dessechement brutal du feuillage (apoplexie)",
|
||||
name: "Esca", nameEn: "Esca Disease", scientificName: "Phaeomoniella chlamydospora",
|
||||
type: "FUNGAL" as const, severity: "MEDIUM" as const,
|
||||
description: "L'esca est un complexe de maladies du bois cause par plusieurs champignons.",
|
||||
descriptionEn: "Esca is a complex of wood diseases caused by several fungi.",
|
||||
symptoms: ["Decolorations entre les nervures des feuilles (aspect tigre)", "Dessechement brutal du feuillage (apoplexie)"],
|
||||
symptomsEn: ["Discoloration between leaf veins (tiger stripe pattern)", "Sudden drying of foliage (apoplexy)"],
|
||||
treatment: "Aucun traitement curatif. Recepage du cep atteint. Proteger les plaies de taille.",
|
||||
treatmentEn: "No curative treatment. Cutting back affected vine. Protect pruning wounds.",
|
||||
season: "Juin a septembre", seasonEn: "June to September",
|
||||
iconName: "tree-deciduous", iconColor: "#993C1D", bgColor: "#FAECE7",
|
||||
startMonth: 6, endMonth: 9, peakMonth: 7,
|
||||
conditions: ["Vignes agees (plus de 10 ans)", "Stress hydrique", "Plaies de taille mal cicatrisees"],
|
||||
conditionsEn: ["Old vines (over 10 years)", "Water stress", "Poorly healed pruning wounds"],
|
||||
preventiveActions: ["Proteger les plaies de taille avec un mastic", "Tailler tard en saison", "Eviter les grosses coupes"],
|
||||
preventiveActionsEn: ["Protect pruning wounds with sealant paste", "Prune late in the season", "Avoid large cuts"],
|
||||
curativeActions: ["Aucun traitement curatif homologue", "Curetage du bois (experimental)", "Recepage si le cep n'est pas trop atteint"],
|
||||
curativeActionsEn: ["No registered curative treatment", "Wood curettage (experimental)", "Trunk renewal if not too affected"],
|
||||
impactedParts: ["Feuilles", "Bois (tronc, bras)", "Grappes (apoplexie)"],
|
||||
impactedPartsEn: ["Leaves", "Wood (trunk, arms)", "Clusters (apoplexy)"],
|
||||
spreadMethod: "Champignons penetrent par les plaies de taille",
|
||||
spreadMethodEn: "Fungi enter through pruning wounds",
|
||||
images: [
|
||||
{ url: "https://images.unsplash.com/photo-1506377247377-2a5b3b417ebb?w=800&h=600&fit=crop", alt: "Esca", order: 0 },
|
||||
{ url: "https://images.unsplash.com/photo-1573062337052-54e2d025e7a1?w=800&h=600&fit=crop", alt: "Esca vigne", order: 1 },
|
||||
],
|
||||
symptomsEn: [
|
||||
"Discoloration between leaf veins (tiger stripe pattern)",
|
||||
"Sudden foliage desiccation (apoplexy)",
|
||||
],
|
||||
treatment:
|
||||
"Aucun traitement curatif. Recepage du cep atteint. Proteger les plaies de taille.",
|
||||
treatmentEn:
|
||||
"No curative treatment. Trunk renewal of affected vines. Protect pruning wounds.",
|
||||
season: "Symptomes visibles en ete — juin a septembre",
|
||||
seasonEn: "Symptoms visible in summer — June to September",
|
||||
iconName: "tree-deciduous",
|
||||
iconColor: "#B45309",
|
||||
bgColor: "#FEF3C7",
|
||||
},
|
||||
{
|
||||
slug: "botrytis",
|
||||
name: "Botrytis",
|
||||
nameEn: "Botrytis (Gray Mold)",
|
||||
scientificName: "Botrytis cinerea",
|
||||
type: "FUNGAL" as const,
|
||||
severity: "MEDIUM" as const,
|
||||
description:
|
||||
"La pourriture grise est causee par Botrytis cinerea. Elle attaque les grappes a maturite.",
|
||||
descriptionEn:
|
||||
"Gray mold is caused by Botrytis cinerea. It attacks clusters at maturity.",
|
||||
symptoms: [
|
||||
"Pourriture molle grise sur les baies",
|
||||
"Feutrage gris caracteristique sur les grappes",
|
||||
name: "Botrytis", nameEn: "Botrytis (Grey Mold)", scientificName: "Botrytis cinerea",
|
||||
type: "FUNGAL" as const, severity: "MEDIUM" as const,
|
||||
description: "La pourriture grise est causee par Botrytis cinerea. Elle attaque les grappes a maturite.",
|
||||
descriptionEn: "Grey mold is caused by Botrytis cinerea. It attacks clusters at maturity.",
|
||||
symptoms: ["Pourriture molle grise sur les baies", "Feutrage gris caracteristique sur les grappes"],
|
||||
symptomsEn: ["Soft grey rot on berries", "Characteristic grey felt on clusters"],
|
||||
treatment: "Favoriser l'aeration des grappes. Effeuillage. Traitements anti-botrytis.",
|
||||
treatmentEn: "Promote cluster aeration. Leaf removal. Anti-botrytis treatments.",
|
||||
season: "Aout a vendanges", seasonEn: "August to harvest",
|
||||
iconName: "cloud-rain", iconColor: "#185FA5", bgColor: "#E6F1FB",
|
||||
startMonth: 7, endMonth: 10, peakMonth: 9,
|
||||
conditions: ["Humidite elevee prolongee", "Temperatures entre 15 et 25°C", "Grappes compactes et serrees"],
|
||||
conditionsEn: ["Prolonged high humidity", "Temperatures between 15 and 25°C", "Compact, tight clusters"],
|
||||
preventiveActions: ["Effeuillage autour des grappes", "Choisir des cepages a grappes laches", "Limiter la vigueur"],
|
||||
preventiveActionsEn: ["Leaf removal around clusters", "Choose varieties with loose clusters", "Limit vine vigor"],
|
||||
curativeActions: ["Appliquer un anti-botrytis homologue", "Vendanger les parties atteintes rapidement"],
|
||||
curativeActionsEn: ["Apply a registered anti-botrytis product", "Harvest affected parts quickly"],
|
||||
impactedParts: ["Grappes (baies)", "Feuilles (rare)"],
|
||||
impactedPartsEn: ["Clusters (berries)", "Leaves (rare)"],
|
||||
spreadMethod: "Spores aeriennes, favorisees par les blessures sur baies",
|
||||
spreadMethodEn: "Airborne spores, favored by berry wounds",
|
||||
images: [
|
||||
{ url: "https://images.unsplash.com/photo-1474722883778-792e7990302f?w=800&h=600&fit=crop", alt: "Botrytis", order: 0 },
|
||||
{ url: "https://images.unsplash.com/photo-1508472697919-afcacb6e1bcc?w=800&h=600&fit=crop", alt: "Botrytis raisin", order: 1 },
|
||||
],
|
||||
symptomsEn: [
|
||||
"Soft gray rot on berries",
|
||||
"Characteristic gray felt on clusters",
|
||||
],
|
||||
treatment:
|
||||
"Favoriser l'aeration des grappes. Effeuillage. Traitements anti-botrytis avant fermeture de la grappe.",
|
||||
treatmentEn:
|
||||
"Promote cluster ventilation. Leaf removal. Anti-botrytis treatments before cluster closure.",
|
||||
season: "Aout a vendanges — favorise par l'humidite",
|
||||
seasonEn: "August to harvest — favored by humidity",
|
||||
iconName: "cloud-rain",
|
||||
iconColor: "#6B7280",
|
||||
bgColor: "#F3F4F6",
|
||||
},
|
||||
{
|
||||
slug: "flavescence-doree",
|
||||
name: "Flavescence doree",
|
||||
nameEn: "Flavescence Doree",
|
||||
scientificName: "Phytoplasma vitis",
|
||||
type: "BACTERIAL" as const,
|
||||
severity: "HIGH" as const,
|
||||
description:
|
||||
"Maladie a phytoplasme transmise par la cicadelle Scaphoideus titanus. Maladie reglementee, declaration obligatoire.",
|
||||
descriptionEn:
|
||||
"Phytoplasma disease transmitted by the Scaphoideus titanus leafhopper. Regulated disease, mandatory reporting.",
|
||||
symptoms: [
|
||||
"Enroulement des feuilles avec coloration jaune ou rouge selon le cepage",
|
||||
"Non-aoutement des rameaux (restent caoutchouteux)",
|
||||
name: "Flavescence doree", nameEn: "Flavescence Doree", scientificName: "Phytoplasma vitis",
|
||||
type: "BACTERIAL" as const, severity: "HIGH" as const,
|
||||
description: "Maladie a phytoplasme transmise par la cicadelle Scaphoideus titanus. Declaration obligatoire.",
|
||||
descriptionEn: "Phytoplasma disease transmitted by the leafhopper Scaphoideus titanus. Mandatory reporting.",
|
||||
symptoms: ["Enroulement des feuilles avec coloration jaune ou rouge", "Non-aoutement des rameaux"],
|
||||
symptomsEn: ["Leaf rolling with yellow or red coloration", "Non-lignification of shoots"],
|
||||
treatment: "Arrachage obligatoire des ceps contamines. Traitement insecticide contre la cicadelle.",
|
||||
treatmentEn: "Mandatory uprooting of contaminated vines. Insecticide treatment against the vector.",
|
||||
season: "Juillet a octobre", seasonEn: "July to October",
|
||||
iconName: "bug", iconColor: "#A32D2D", bgColor: "#FCEBEB",
|
||||
startMonth: 6, endMonth: 10, peakMonth: 8,
|
||||
conditions: ["Presence de la cicadelle Scaphoideus titanus", "Vignobles non traites contre le vecteur", "Zones contaminees a proximite"],
|
||||
conditionsEn: ["Presence of leafhopper Scaphoideus titanus", "Vineyards not treated against the vector", "Contaminated areas nearby"],
|
||||
preventiveActions: ["Traitement insecticide obligatoire contre la cicadelle", "Prospection et arrachage des ceps atteints", "Materiel vegetal certifie"],
|
||||
preventiveActionsEn: ["Mandatory insecticide treatment against the leafhopper", "Prospection and uprooting of affected vines", "Use certified plant material"],
|
||||
curativeActions: ["Aucun traitement curatif", "Arrachage obligatoire des ceps contamines"],
|
||||
curativeActionsEn: ["No curative treatment", "Mandatory uprooting of contaminated vines"],
|
||||
impactedParts: ["Feuilles (enroulement)", "Rameaux (aoutement absent)", "Grappes (dessechement)"],
|
||||
impactedPartsEn: ["Leaves (rolling)", "Shoots (absent lignification)", "Clusters (desiccation)"],
|
||||
spreadMethod: "Transmis par la cicadelle Scaphoideus titanus",
|
||||
spreadMethodEn: "Transmitted by the leafhopper Scaphoideus titanus",
|
||||
images: [
|
||||
{ url: "https://images.unsplash.com/photo-1510812431401-41d2bd2722f3?w=800&h=600&fit=crop", alt: "Flavescence doree", order: 0 },
|
||||
{ url: "https://images.unsplash.com/photo-1566903451935-7bc0ddd0e8e6?w=800&h=600&fit=crop", alt: "Flavescence vigne", order: 1 },
|
||||
],
|
||||
symptomsEn: [
|
||||
"Leaf curling with yellow or red coloring depending on cultivar",
|
||||
"Non-lignification of shoots (remain rubbery)",
|
||||
],
|
||||
treatment:
|
||||
"Arrachage obligatoire des ceps contamines. Traitement insecticide contre la cicadelle vectrice.",
|
||||
treatmentEn:
|
||||
"Mandatory uprooting of contaminated vines. Insecticide treatment against the vector leafhopper.",
|
||||
season: "Symptomes visibles a partir de juillet",
|
||||
seasonEn: "Symptoms visible from July",
|
||||
iconName: "bug",
|
||||
iconColor: "#DC2626",
|
||||
bgColor: "#FEE2E2",
|
||||
},
|
||||
{
|
||||
slug: "chlorose-ferrique",
|
||||
name: "Chlorose ferrique",
|
||||
nameEn: "Iron Chlorosis",
|
||||
scientificName: "",
|
||||
type: "ABIOTIC" as const,
|
||||
severity: "LOW" as const,
|
||||
description:
|
||||
"Jaunissement des feuilles du a une carence en fer, souvent lie a un sol trop calcaire.",
|
||||
descriptionEn:
|
||||
"Yellowing of leaves due to iron deficiency, often linked to overly calcareous soil.",
|
||||
symptoms: [
|
||||
"Jaunissement entre les nervures, nervures restant vertes",
|
||||
"Affaiblissement general de la vigne",
|
||||
name: "Chlorose ferrique", nameEn: "Iron Chlorosis", scientificName: "",
|
||||
type: "ABIOTIC" as const, severity: "LOW" as const,
|
||||
description: "Jaunissement des feuilles du a une carence en fer, souvent lie a un sol trop calcaire.",
|
||||
descriptionEn: "Leaf yellowing due to iron deficiency, often linked to overly calcareous soil.",
|
||||
symptoms: ["Jaunissement entre les nervures, nervures restant vertes", "Affaiblissement general de la vigne"],
|
||||
symptomsEn: ["Yellowing between veins, veins remaining green", "General weakening of the vine"],
|
||||
treatment: "Apport de chelates de fer. Choix d'un porte-greffe adapte aux sols calcaires.",
|
||||
treatmentEn: "Iron chelate application. Choose rootstock adapted to calcareous soils.",
|
||||
season: "Printemps", seasonEn: "Spring",
|
||||
iconName: "leaf", iconColor: "#639922", bgColor: "#EAF3DE",
|
||||
startMonth: 4, endMonth: 7, peakMonth: 5,
|
||||
conditions: ["Sol calcaire actif", "Sol compacte ou asphyxiant", "Exces d'eau au printemps"],
|
||||
conditionsEn: ["Active calcareous soil", "Compacted or waterlogged soil", "Excess water in spring"],
|
||||
preventiveActions: ["Choisir un porte-greffe adapte aux sols calcaires", "Ameliorer le drainage", "Apport de matiere organique"],
|
||||
preventiveActionsEn: ["Choose rootstock adapted to calcareous soils", "Improve drainage", "Add organic matter"],
|
||||
curativeActions: ["Pulverisation foliaire de chelate de fer", "Traitement au sulfate de fer"],
|
||||
curativeActionsEn: ["Foliar spray of iron chelate", "Iron sulfate treatment"],
|
||||
impactedParts: ["Feuilles (jaunissement internervaire)"],
|
||||
impactedPartsEn: ["Leaves (interveinal yellowing)"],
|
||||
spreadMethod: "Non contagieux — carence nutritionnelle liee au sol",
|
||||
spreadMethodEn: "Not contagious — nutritional deficiency linked to soil",
|
||||
images: [
|
||||
{ url: "https://images.unsplash.com/photo-1597916829826-02e5bb4a54e0?w=800&h=600&fit=crop", alt: "Chlorose", order: 0 },
|
||||
{ url: "https://images.unsplash.com/photo-1563514227147-6d2ff665a6a0?w=800&h=600&fit=crop", alt: "Chlorose vigne", order: 1 },
|
||||
],
|
||||
symptomsEn: [
|
||||
"Yellowing between veins while veins remain green",
|
||||
"General weakening of the vine",
|
||||
],
|
||||
treatment:
|
||||
"Apport de chelates de fer. Choix d'un porte-greffe adapte aux sols calcaires.",
|
||||
treatmentEn:
|
||||
"Iron chelate application. Choice of rootstock adapted to calcareous soils.",
|
||||
season: "Printemps — surtout sur sols calcaires apres de fortes pluies",
|
||||
seasonEn:
|
||||
"Spring — especially on calcareous soils after heavy rain",
|
||||
iconName: "leaf",
|
||||
iconColor: "#EAB308",
|
||||
bgColor: "#FEF9C3",
|
||||
},
|
||||
];
|
||||
|
||||
for (const disease of diseases) {
|
||||
await prisma.disease.upsert({
|
||||
where: { slug: disease.slug },
|
||||
update: disease,
|
||||
create: disease,
|
||||
for (const { images, ...diseaseData } of diseases) {
|
||||
const disease = await prisma.disease.upsert({
|
||||
where: { slug: diseaseData.slug },
|
||||
update: diseaseData,
|
||||
create: diseaseData,
|
||||
});
|
||||
// Delete old images and re-create
|
||||
await prisma.diseaseImage.deleteMany({ where: { diseaseId: disease.id } });
|
||||
for (const img of images) {
|
||||
await prisma.diseaseImage.create({ data: { ...img, diseaseId: disease.id } });
|
||||
}
|
||||
}
|
||||
console.log(` ${diseases.length} diseases seeded`);
|
||||
console.log(` ${diseases.length} diseases seeded (enriched)`);
|
||||
|
||||
// 4. Guides
|
||||
// ── 3. Guides (enriched with sections) ──
|
||||
const guides = [
|
||||
{
|
||||
slug: "reconnaitre-feuille-saine",
|
||||
title: "Reconnaitre une feuille saine",
|
||||
titleEn: "Recognizing a Healthy Leaf",
|
||||
subtitle: "Les bases pour identifier une vigne en bonne sante",
|
||||
subtitleEn: "Basics for identifying a healthy vine",
|
||||
content:
|
||||
"Une feuille de vigne saine presente une couleur verte uniforme, sans taches ni decolorations. Les nervures sont nettes et la texture est ferme au toucher. Apprenez a reperer les premiers signes de stress pour agir rapidement.",
|
||||
contentEn:
|
||||
"A healthy vine leaf shows uniform green color, without spots or discolorations. Veins are clear and the texture is firm to the touch. Learn to spot the first signs of stress to act quickly.",
|
||||
category: "diagnostic",
|
||||
iconName: "leaf",
|
||||
iconColor: "#1D9E75",
|
||||
bgColor: "#E1F5EE",
|
||||
order: 0,
|
||||
title: "Reconnaitre une feuille saine", titleEn: "Recognizing a Healthy Leaf",
|
||||
subtitle: "Les bases pour identifier une vigne en bonne sante", subtitleEn: "Basics for identifying a healthy vine",
|
||||
content: "Guide complet pour reconnaitre une feuille de vigne saine.", contentEn: "Complete guide to recognizing a healthy vine leaf.",
|
||||
category: "diagnostic", iconName: "leaf", iconColor: "#1D9E75", bgColor: "#E1F5EE", order: 0,
|
||||
readTime: 5, coverImage: "https://images.unsplash.com/photo-1596142780450-01a1f79c400c?w=800&h=600&fit=crop",
|
||||
sections: [
|
||||
{ title: "Couleur et texture", titleEn: "Color and texture", body: "Une feuille de vigne saine presente un vert uniforme, vif et brillant. La texture est lisse sur la face superieure et legerement duveteuse dessous. Les nervures sont nettes, bien dessinees.", bodyEn: "A healthy vine leaf shows a uniform, vibrant and bright green. The upper surface is smooth while the underside is slightly downy.", tip: "Une feuille saine n'a jamais de taches brunes, jaunes ou poudreuses.", tipEn: "A healthy leaf never has brown, yellow or powdery spots.", image: "https://images.unsplash.com/photo-1504279577054-acfeccf8fc52?w=800&h=600&fit=crop", order: 0 },
|
||||
{ title: "Forme et symetrie", titleEn: "Shape and symmetry", body: "La forme de la feuille varie selon le cepage : 3 lobes (Merlot), 5 lobes (Cabernet Sauvignon) ou presque entiere (Gamay). Une feuille saine est symetrique, avec des bords dentes reguliers.", bodyEn: "Leaf shape varies by variety: 3 lobes (Merlot), 5 lobes (Cabernet Sauvignon) or nearly entire (Gamay). A healthy leaf is symmetrical with regular serrated edges.", order: 1 },
|
||||
{ title: "Quand s'inquieter", titleEn: "When to worry", body: "Surveillez ces premiers signes : decoloration entre les nervures, taches huileuses translucides, poudre blanche, enroulement des bords vers le bas, necroses brunes.", bodyEn: "Watch for these early signs: discoloration between veins, translucent oily spots, white powder, downward leaf edge rolling, brown necrosis.", tip: "Photographiez la feuille suspecte avec VinEye des les premiers symptomes.", tipEn: "Photograph the suspicious leaf with VinEye at the first symptoms.", order: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "calendrier-traitement",
|
||||
title: "Calendrier de traitement",
|
||||
titleEn: "Treatment Calendar",
|
||||
subtitle: "Quand et comment traiter tout au long de la saison",
|
||||
subtitleEn: "When and how to treat throughout the season",
|
||||
content:
|
||||
"Un calendrier detaille des traitements phytosanitaires pour la vigne, du debourrement aux vendanges. Inclut les periodes cles pour la prevention du mildiou, de l'oidium et du botrytis.",
|
||||
contentEn:
|
||||
"A detailed calendar of phytosanitary treatments for vines, from bud break to harvest. Includes key periods for prevention of downy mildew, powdery mildew and botrytis.",
|
||||
category: "traitement",
|
||||
iconName: "calendar",
|
||||
iconColor: "#185FA5",
|
||||
bgColor: "#E6F1FB",
|
||||
order: 1,
|
||||
title: "Calendrier de traitement", titleEn: "Treatment Calendar",
|
||||
subtitle: "Quand et comment traiter tout au long de la saison", subtitleEn: "When and how to treat throughout the season",
|
||||
content: "Calendrier detaille des traitements phytosanitaires pour la vigne.", contentEn: "Detailed calendar of phytosanitary treatments for vines.",
|
||||
category: "traitement", iconName: "calendar", iconColor: "#185FA5", bgColor: "#E6F1FB", order: 1,
|
||||
readTime: 8, coverImage: "https://images.unsplash.com/photo-1560493676-04071c5f467b?w=800&h=600&fit=crop",
|
||||
sections: [
|
||||
{ title: "Hiver (decembre-fevrier)", titleEn: "Winter (December-February)", body: "Periode de repos vegetatif. Taillez la vigne, retirez et brulez les bois morts. Appliquez un traitement d'hiver a base d'huile blanche.", bodyEn: "Dormant period. Prune the vine, remove and burn dead wood. Apply a winter treatment with dormant oil.", order: 0 },
|
||||
{ title: "Printemps (mars-mai)", titleEn: "Spring (March-May)", body: "Le debourrement marque le debut de la saison. Des le stade 2-3 feuilles etalees, commencez les traitements preventifs : bouillie bordelaise contre le mildiou, soufre contre l'oidium.", bodyEn: "Bud break marks the beginning of the season. From the 2-3 unfolded leaves stage, begin preventive treatments.", tip: "Le premier traitement preventif doit intervenir au stade 2-3 feuilles etalees.", tipEn: "The first preventive treatment must be applied at the 2-3 unfolded leaves stage.", order: 1 },
|
||||
{ title: "Ete (juin-aout)", titleEn: "Summer (June-August)", body: "Surveillance active. Le mildiou est a son pic en juin, l'oidium en juillet. Adaptez vos traitements a la meteo. Pratiquez l'effeuillage.", bodyEn: "Active monitoring period. Downy mildew peaks in June, powdery mildew in July. Adapt treatments to weather.", tip: "Apres chaque pluie de plus de 10mm, inspectez vos vignes dans les 48h.", tipEn: "After each rainfall over 10mm, inspect your vines within 48 hours.", order: 2 },
|
||||
{ title: "Automne (septembre-novembre)", titleEn: "Autumn (September-November)", body: "Vendanges et derniers traitements. Surveillez le botrytis sur les grappes mures. Un dernier traitement cuivrique peut proteger le feuillage restant.", bodyEn: "Harvest time and final treatments. Watch for botrytis on ripe clusters.", order: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "cepages-bordelais",
|
||||
title: "Les cepages bordelais",
|
||||
titleEn: "Bordeaux Grape Varieties",
|
||||
subtitle: "Guide des principales varietes de la region bordelaise",
|
||||
subtitleEn: "Guide to the main varieties of the Bordeaux region",
|
||||
content:
|
||||
"Decouvrez les principaux cepages bordelais : Merlot, Cabernet Sauvignon, Cabernet Franc, Petit Verdot, Malbec pour les rouges, et Sauvignon Blanc, Semillon, Muscadelle pour les blancs.",
|
||||
contentEn:
|
||||
"Discover the main Bordeaux grape varieties: Merlot, Cabernet Sauvignon, Cabernet Franc, Petit Verdot, Malbec for reds, and Sauvignon Blanc, Semillon, Muscadelle for whites.",
|
||||
category: "cepages",
|
||||
iconName: "grape",
|
||||
iconColor: "#7C3AED",
|
||||
bgColor: "#F3EEFF",
|
||||
order: 2,
|
||||
title: "Les cepages bordelais", titleEn: "Bordeaux Grape Varieties",
|
||||
subtitle: "Guide des principales varietes de la region bordelaise", subtitleEn: "Guide to the main varieties of the Bordeaux region",
|
||||
content: "Decouvrez les principaux cepages bordelais.", contentEn: "Discover the main Bordeaux grape varieties.",
|
||||
category: "cepages", iconName: "grape", iconColor: "#534AB7", bgColor: "#EEEDFE", order: 2,
|
||||
readTime: 6, coverImage: "https://images.unsplash.com/photo-1567306301408-9b74779a11af?w=800&h=600&fit=crop",
|
||||
sections: [
|
||||
{ title: "Les rouges emblematiques", titleEn: "Iconic red varieties", body: "Le Merlot est le cepage rouge le plus plante a Bordeaux. Le Cabernet Sauvignon regne sur la rive gauche. Le Cabernet Franc complete l'assemblage.", bodyEn: "Merlot is the most planted red grape in Bordeaux. Cabernet Sauvignon reigns on the left bank. Cabernet Franc completes the blend.", order: 0 },
|
||||
{ title: "Les blancs", titleEn: "White varieties", body: "Le Sauvignon Blanc apporte fraicheur et aromes d'agrumes. Le Semillon est le grand cepage des liquoreux de Sauternes. La Muscadelle complete avec ses notes florales.", bodyEn: "Sauvignon Blanc brings freshness and citrus aromas. Semillon is the great variety of Sauternes sweet wines.", order: 1 },
|
||||
{ title: "Choisir son cepage", titleEn: "Choosing your variety", body: "Le choix du cepage depend du terroir : le Merlot prefere les sols argileux, le Cabernet Sauvignon les sols de graves bien draines.", bodyEn: "The choice depends on the terroir: Merlot prefers clay soils, Cabernet Sauvignon well-drained gravel soils.", tip: "Le Merlot est plus tolerant aux sols argileux, le Cabernet Sauvignon prefere les graves.", tipEn: "Merlot is more tolerant of clay soils, Cabernet Sauvignon prefers gravel.", order: 2 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const guide of guides) {
|
||||
await prisma.guide.upsert({
|
||||
where: { slug: guide.slug },
|
||||
update: guide,
|
||||
create: guide,
|
||||
for (const { sections, ...guideData } of guides) {
|
||||
const guide = await prisma.guide.upsert({
|
||||
where: { slug: guideData.slug },
|
||||
update: guideData,
|
||||
create: guideData,
|
||||
});
|
||||
// Delete old sections and re-create
|
||||
await prisma.guideSection.deleteMany({ where: { guideId: guide.id } });
|
||||
for (const section of sections) {
|
||||
await prisma.guideSection.create({ data: { ...section, guideId: guide.id } });
|
||||
}
|
||||
}
|
||||
console.log(` ${guides.length} guides seeded`);
|
||||
console.log(` ${guides.length} guides seeded (with sections)`);
|
||||
|
||||
// 5. Season alerts
|
||||
// ── 4. Season alerts ──
|
||||
await prisma.seasonAlert.deleteMany();
|
||||
const alerts = [
|
||||
{
|
||||
title: "Risque mildiou eleve",
|
||||
titleEn: "High Downy Mildew Risk",
|
||||
message:
|
||||
"Les conditions meteo actuelles (chaleur + humidite) sont tres favorables au developpement du mildiou. Surveillez vos vignes et appliquez un traitement preventif si necessaire.",
|
||||
messageEn:
|
||||
"Current weather conditions (heat + humidity) are very favorable for downy mildew development. Monitor your vines and apply preventive treatment if necessary.",
|
||||
type: "WARNING" as const,
|
||||
region: "bordeaux",
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
title: "Debut de la saison de traitement",
|
||||
titleEn: "Start of Treatment Season",
|
||||
message:
|
||||
"La saison de traitement phytosanitaire debute. Consultez le calendrier de traitement pour planifier vos interventions.",
|
||||
messageEn:
|
||||
"The phytosanitary treatment season begins. Check the treatment calendar to plan your interventions.",
|
||||
type: "INFO" as const,
|
||||
region: "bordeaux",
|
||||
active: true,
|
||||
},
|
||||
{ title: "Risque mildiou eleve", titleEn: "High Downy Mildew Risk", message: "Les conditions meteo actuelles sont tres favorables au mildiou.", messageEn: "Current weather conditions are very favorable for downy mildew.", type: "WARNING" as const, region: "bordeaux", active: true },
|
||||
{ title: "Debut de la saison de traitement", titleEn: "Start of Treatment Season", message: "La saison de traitement phytosanitaire debute.", messageEn: "The phytosanitary treatment season begins.", type: "INFO" as const, region: "bordeaux", active: true },
|
||||
];
|
||||
|
||||
for (const alert of alerts) {
|
||||
await prisma.seasonAlert.create({ data: alert });
|
||||
}
|
||||
console.log(` ${alerts.length} season alerts seeded`);
|
||||
console.log(` ${alerts.length} alerts seeded`);
|
||||
|
||||
// 6. Mock scans
|
||||
// ── 5. Mock scans ──
|
||||
await prisma.scan.deleteMany();
|
||||
const allDiseases = await prisma.disease.findMany({ select: { id: true } });
|
||||
const now = new Date();
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const daysAgo = Math.floor(Math.random() * 30);
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
|
||||
const randomDisease =
|
||||
allDiseases[Math.floor(Math.random() * allDiseases.length)];
|
||||
|
||||
date.setDate(date.getDate() - Math.floor(Math.random() * 30));
|
||||
await prisma.scan.create({
|
||||
data: {
|
||||
userId: i < 7 ? testUser.id : admin.id,
|
||||
diseaseId: randomDisease.id,
|
||||
diseaseId: allDiseases[Math.floor(Math.random() * allDiseases.length)].id,
|
||||
confidence: 0.6 + Math.random() * 0.35,
|
||||
latitude: 44.8378 + (Math.random() - 0.5) * 0.1,
|
||||
longitude: -0.5792 + (Math.random() - 0.5) * 0.1,
|
||||
|
|
@ -412,10 +342,5 @@ async function main() {
|
|||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
.catch((e) => { console.error(e); process.exit(1); })
|
||||
.finally(async () => { await prisma.$disconnect(); });
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
|
|
@ -1 +0,0 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1 KiB |
BIN
vineye-admin/public/logo-2.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
vineye-admin/public/logo-dark.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
vineye-admin/public/logo.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |