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>
This commit is contained in:
Yanis 2026-04-09 03:19:39 +02:00
parent fe70005a86
commit 720dd34fdd
101 changed files with 7265 additions and 1639 deletions

276
PROJECT_SUMMARY.md Normal file
View 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

View file

@ -1,15 +1,38 @@
import 'react-native-gesture-handler';
import './global.css'; import './global.css';
import { useEffect } from 'react';
import { Platform } from 'react-native';
import { StatusBar } from 'expo-status-bar'; 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 { SafeAreaProvider } from 'react-native-safe-area-context';
import { Toaster } from 'sonner-native';
import { PortalHost } from '@rn-primitives/portal'; import { PortalHost } from '@rn-primitives/portal';
import { NetworkProvider } from '@/contexts/NetworkContext';
import { NetworkToastWatcher } from '@/contexts/ToastContext';
import RootNavigator from '@/navigation/RootNavigator'; import RootNavigator from '@/navigation/RootNavigator';
export default function App() { export default function App() {
useEffect(() => {
if (Platform.OS === 'android') {
NavigationBar.setBackgroundColorAsync('transparent');
NavigationBar.setPositionAsync('absolute');
NavigationBar.setButtonStyleAsync('dark');
}
}, []);
return ( return (
<SafeAreaProvider> <GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="dark" /> <SafeAreaProvider>
<RootNavigator /> <NetworkProvider>
<PortalHost /> <NetworkToastWatcher>
</SafeAreaProvider> <StatusBar style="dark" translucent backgroundColor="transparent" />
<RootNavigator />
<PortalHost />
<Toaster position="bottom-center" offset={120} />
</NetworkToastWatcher>
</NetworkProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
); );
} }

View file

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.1.1", "@expo/vector-icons": "^15.1.1",
"@react-native-async-storage/async-storage": "2.2.0", "@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/bottom-tabs": "^7.15.9",
"@react-navigation/native": "^7.2.2", "@react-navigation/native": "^7.2.2",
"@react-navigation/native-stack": "^7.14.10", "@react-navigation/native-stack": "^7.14.10",
@ -21,10 +22,13 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-camera": "~17.0.10", "expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
"expo-haptics": "~15.0.8", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
"expo-linear-gradient": "~15.0.8", "expo-linear-gradient": "~15.0.8",
"expo-localization": "~17.0.8", "expo-localization": "~17.0.8",
"expo-navigation-bar": "~5.0.10",
"expo-network": "~8.0.8",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"i18next": "^26.0.1", "i18next": "^26.0.1",
"lottie-react-native": "^7.3.6", "lottie-react-native": "^7.3.6",
@ -35,12 +39,14 @@
"react-i18next": "^17.0.1", "react-i18next": "^17.0.1",
"react-lucid": "^0.0.1", "react-lucid": "^0.0.1",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-svg": "^15.12.1", "react-native-svg": "^15.12.1",
"react-native-web": "^0.21.2", "react-native-web": "^0.21.2",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"sonner-native": "^0.24.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "3.4.17" "tailwindcss": "3.4.17"
}, },

View file

@ -14,6 +14,9 @@ importers:
'@react-native-async-storage/async-storage': '@react-native-async-storage/async-storage':
specifier: 2.2.0 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)) 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': '@react-navigation/bottom-tabs':
specifier: ^7.15.9 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) 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: expo-camera:
specifier: ~17.0.10 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) 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: expo-haptics:
specifier: ~15.0.8 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)) 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: expo-localization:
specifier: ~17.0.8 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) 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: expo-status-bar:
specifier: ~3.0.9 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) 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: react-native:
specifier: 0.81.5 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) 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: react-native-reanimated:
specifier: ~4.1.1 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) 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: react-native-worklets:
specifier: 0.5.1 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) 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: tailwind-merge:
specifier: ^3.5.0 specifier: ^3.5.0
version: 3.5.0 version: 3.5.0
@ -638,6 +656,10 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} 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': '@expo/cli@54.0.23':
resolution: {integrity: sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==} resolution: {integrity: sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==}
hasBin: true hasBin: true
@ -863,6 +885,11 @@ packages:
peerDependencies: peerDependencies:
react-native: ^0.0.0-0 || >=0.65 <1.0 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': '@react-native/assets-registry@0.81.5':
resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==} resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==}
engines: {node: '>= 20.19.4'} engines: {node: '>= 20.19.4'}
@ -1074,6 +1101,9 @@ packages:
'@types/graceful-fs@4.1.9': '@types/graceful-fs@4.1.9':
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
'@types/hammerjs@2.0.46':
resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==}
'@types/istanbul-lib-coverage@2.0.6': '@types/istanbul-lib-coverage@2.0.6':
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
@ -1714,6 +1744,19 @@ packages:
react: '*' react: '*'
react-native: '*' 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: expo-server@1.0.5:
resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==} resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==}
engines: {node: '>=20.16.0'} engines: {node: '>=20.16.0'}
@ -1881,6 +1924,9 @@ packages:
hermes-parser@0.33.3: hermes-parser@0.33.3:
resolution: {integrity: sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==} resolution: {integrity: sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==}
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
hosted-git-info@7.0.2: hosted-git-info@7.0.2:
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
engines: {node: ^16.14.0 || >=18.0.0} engines: {node: ^16.14.0 || >=18.0.0}
@ -2771,6 +2817,9 @@ packages:
typescript: typescript:
optional: true optional: true
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@18.3.1: react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
@ -2796,6 +2845,12 @@ packages:
react-native-svg: react-native-svg:
optional: true 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: react-native-is-edge-to-edge@1.3.1:
resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==} resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==}
peerDependencies: peerDependencies:
@ -3015,6 +3070,17 @@ packages:
resolution: {integrity: sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==} resolution: {integrity: sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==}
engines: {node: '>=8.0.0'} 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: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -4020,6 +4086,10 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@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))': '@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: dependencies:
'@0no-co/graphql.web': 1.2.0 '@0no-co/graphql.web': 1.2.0
@ -4441,6 +4511,10 @@ snapshots:
merge-options: 3.0.4 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: 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/assets-registry@0.81.5': {}
'@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)': '@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)':
@ -4785,6 +4859,8 @@ snapshots:
dependencies: dependencies:
'@types/node': 25.5.0 '@types/node': 25.5.0
'@types/hammerjs@2.0.46': {}
'@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-coverage@2.0.6': {}
'@types/istanbul-lib-report@3.0.3': '@types/istanbul-lib-report@3.0.3':
@ -5443,6 +5519,22 @@ snapshots:
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: 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-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): 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: dependencies:
hermes-estree: 0.33.3 hermes-estree: 0.33.3
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
hosted-git-info@7.0.2: hosted-git-info@7.0.2:
dependencies: dependencies:
lru-cache: 10.4.3 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) 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 typescript: 5.9.3
react-is@16.13.1: {}
react-is@18.3.1: {} react-is@18.3.1: {}
react-is@19.2.4: {} react-is@19.2.4: {}
@ -6675,6 +6773,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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): 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: dependencies:
react: 19.1.0 react: 19.1.0
@ -6941,6 +7047,16 @@ snapshots:
slugify@1.6.8: {} 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-js@1.2.1: {}
source-map-support@0.5.21: source-map-support@0.5.21:

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -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

View 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",
},
});

View 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",
},
});

View 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,
},
});

View 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,
},
});

View file

@ -1,72 +1,40 @@
import { View, FlatList, TouchableOpacity, StyleSheet, Platform } from "react-native"; import { View, FlatList, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text"; import SmallDiseaseCard from "@/components/ui/SmallDiseaseCard";
import { colors } from "@/theme/colors"; import { CarouselCardSkeleton } from "@/components/ui/Skeleton";
import { VINE_DISEASES } from "@/data/diseases";
import type { Disease } from "@/data/diseases"; import type { Disease } from "@/data/diseases";
const DISEASE_TYPE_KEYS: Record<Disease["type"], string> = { interface FrequentDiseasesProps {
fungal: "diseases.types.fungal", diseases: Disease[];
bacterial: "diseases.types.bacterial", isLoading?: boolean;
pest: "diseases.types.pest", }
abiotic: "diseases.types.abiotic",
};
const SEVERITY_LEVELS: Record<Disease["severity"], { color: string; label: string }> = { export default function FrequentDiseases({ diseases, isLoading }: FrequentDiseasesProps) {
high: { color: "#EF4444", label: "high" }, if (isLoading && diseases.length === 0) {
medium: { color: "#F59E0B", label: "medium" }, return (
low: { color: "#10B981", label: "low" }, <View style={styles.skeletonRow}>
}; <CarouselCardSkeleton />
<CarouselCardSkeleton />
export default function FrequentDiseases() { <CarouselCardSkeleton />
const { t } = useTranslation(); </View>
);
}
return ( return (
<FlatList <FlatList
data={VINE_DISEASES} data={diseases}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.listContainer} contentContainerStyle={styles.listContainer}
renderItem={({ item }) => { renderItem={({ item, index }) => (
const severity = SEVERITY_LEVELS[item.severity]; <SmallDiseaseCard
disease={item}
return ( onPress={() => {}}
<TouchableOpacity index={index}
activeOpacity={0.8} size="carousel"
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>
);
}}
/> />
); );
} }
@ -77,73 +45,9 @@ const styles = StyleSheet.create({
paddingVertical: 10, paddingVertical: 10,
gap: 16, gap: 16,
}, },
card: { skeletonRow: {
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: {
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", paddingHorizontal: 20,
alignItems: "flex-start", gap: 16,
marginBottom: 12,
}, },
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],
}
});

View file

@ -67,17 +67,17 @@ export default function HeroScanner() {
{/* Main CTA button */} {/* Main CTA button */}
<TouchableOpacity <TouchableOpacity
activeOpacity={0.9} activeOpacity={0.9}
onPress={() => navigation.navigate("Scanner")} onPress={() => navigation.navigate("Main", { screen: "Scanner" })}
style={styles.mainButton} style={styles.mainButton}
> >
<Text style={styles.buttonText}>{t("home.scanButton")}</Text> <Text style={styles.buttonText}>{t("home.scanButton")}</Text>
<View style={styles.buttonIconWrapper}> {/* <View style={styles.buttonIconWrapper}>
<MaterialIcons <MaterialIcons
name="arrow-forward" name="arrow-forward"
size={18} size={18}
color={colors.primary[800]} color={colors.primary[800]}
/> />
</View> </View> */}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );

View file

@ -1,112 +1,63 @@
import { View, TouchableOpacity, StyleSheet, Platform } from "react-native"; import { View, StyleSheet, Platform } from "react-native";
import { useTranslation } from "react-i18next"; import { useNavigation } from "@react-navigation/native";
import { Ionicons } from "@expo/vector-icons"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { Text } from "@/components/ui/text"; import GuideListItem from "@/components/ui/GuideListItem";
import { colors } from "@/theme/colors"; import { GuideListItemSkeleton } from "@/components/ui/Skeleton";
import { PRACTICAL_GUIDES } from "@/data/guides"; import type { Guide } from "@/data/guides";
import type { RootStackParamList } from "@/types/navigation";
export default function PracticalGuides() { type Nav = NativeStackNavigationProp<RootStackParamList>;
const { t } = useTranslation();
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 ( return (
<View style={styles.container}> <View style={styles.card}>
{PRACTICAL_GUIDES.map((guide) => ( {items.map((guide, index) => (
<TouchableOpacity <GuideListItem
key={guide.id} key={guide.id}
activeOpacity={0.6} guide={guide}
style={styles.card} onPress={() => navigation.navigate("GuideDetail", { guideId: guide.id })}
> showSeparator={index < items.length - 1}
{/* Icône avec fond translucide assorti */} index={index}
<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>
))} ))}
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: {
gap: 12,
paddingHorizontal: 4, // Pour ne pas couper l'ombre
},
card: { card: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#FFFFFF", backgroundColor: "#FFFFFF",
borderRadius: 24, // Arrondi plus prononcé style "Bento" borderRadius: 16,
padding: 14, overflow: "hidden",
borderWidth: 1, borderWidth: 1,
borderColor: "#F1F1F1", borderColor: "#F0F0F0",
...Platform.select({ ...Platform.select({
ios: { ios: {
shadowColor: "#000", shadowColor: "#000",
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.04, shadowOpacity: 0.04,
shadowRadius: 8, shadowRadius: 8,
}, },
android: { android: { elevation: 2 },
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,
},
});

View 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,
},
});

View 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',
},
});

View 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",
},
});

View 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
},
});

View 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",
},
});

View 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],
},
});

View 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
View 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;

View 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);
}

View 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}</>;
}

View file

@ -1,3 +1,9 @@
export interface DiseaseTimeline {
startMonth: number;
endMonth: number;
peakMonth: number;
}
export interface Disease { export interface Disease {
id: string; id: string;
name: string; name: string;
@ -10,6 +16,14 @@ export interface Disease {
symptoms: string[]; symptoms: string[];
treatment: string; treatment: string;
season: string; season: string;
// Detail fields
images: string[];
timeline: DiseaseTimeline;
conditions: string[];
preventiveActions: string[];
curativeActions: string[];
impactedParts: string[];
spreadMethod: string;
} }
export const VINE_DISEASES: Disease[] = [ export const VINE_DISEASES: Disease[] = [
@ -29,6 +43,31 @@ export const VINE_DISEASES: Disease[] = [
], ],
treatment: "diseases.mildiou.treatment", treatment: "diseases.mildiou.treatment",
season: "diseases.mildiou.season", 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", id: "oidium",
@ -45,6 +84,31 @@ export const VINE_DISEASES: Disease[] = [
], ],
treatment: "diseases.oidium.treatment", treatment: "diseases.oidium.treatment",
season: "diseases.oidium.season", 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", id: "black_rot",
@ -61,6 +125,31 @@ export const VINE_DISEASES: Disease[] = [
], ],
treatment: "diseases.blackRot.treatment", treatment: "diseases.blackRot.treatment",
season: "diseases.blackRot.season", 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", id: "esca",
@ -77,6 +166,32 @@ export const VINE_DISEASES: Disease[] = [
], ],
treatment: "diseases.esca.treatment", treatment: "diseases.esca.treatment",
season: "diseases.esca.season", 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", id: "botrytis",
@ -93,6 +208,30 @@ export const VINE_DISEASES: Disease[] = [
], ],
treatment: "diseases.botrytis.treatment", treatment: "diseases.botrytis.treatment",
season: "diseases.botrytis.season", 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", id: "flavescence_doree",
@ -109,6 +248,31 @@ export const VINE_DISEASES: Disease[] = [
], ],
treatment: "diseases.flavescence.treatment", treatment: "diseases.flavescence.treatment",
season: "diseases.flavescence.season", 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", id: "chlorose",
@ -125,5 +289,32 @@ export const VINE_DISEASES: Disease[] = [
], ],
treatment: "diseases.chlorose.treatment", treatment: "diseases.chlorose.treatment",
season: "diseases.chlorose.season", 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);
}

View file

@ -1,3 +1,10 @@
export interface GuideSection {
title: string;
body: string;
image?: string;
tip?: string;
}
export interface Guide { export interface Guide {
id: string; id: string;
title: string; title: string;
@ -5,6 +12,11 @@ export interface Guide {
icon: string; icon: string;
iconColor: string; iconColor: string;
bgColor: string; bgColor: string;
// Detail fields
category: "beginner" | "treatment" | "varieties" | "seasonal";
readTime: number;
image: string;
content: GuideSection[];
} }
export const PRACTICAL_GUIDES: Guide[] = [ export const PRACTICAL_GUIDES: Guide[] = [
@ -15,6 +27,26 @@ export const PRACTICAL_GUIDES: Guide[] = [
icon: "happy-outline", icon: "happy-outline",
iconColor: "#1D9E75", iconColor: "#1D9E75",
bgColor: "#E1F5EE", 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", id: "treatment_calendar",
@ -23,6 +55,29 @@ export const PRACTICAL_GUIDES: Guide[] = [
icon: "book-outline", icon: "book-outline",
iconColor: "#185FA5", iconColor: "#185FA5",
bgColor: "#E6F1FB", 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", id: "grape_varieties",
@ -31,5 +86,27 @@ export const PRACTICAL_GUIDES: Guide[] = [
icon: "wine-outline", icon: "wine-outline",
iconColor: "#534AB7", iconColor: "#534AB7",
bgColor: "#EEEDFE", 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);
}

View 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 };
}

View 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 };
}

View 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,
});
}

View 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 };
}

View 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,
});
}

View file

@ -6,16 +6,16 @@ export function useHistory() {
const [history, setHistory] = useState<ScanRecord[]>([]); const [history, setHistory] = useState<ScanRecord[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { const loadHistory = useCallback(async () => {
loadHistory();
}, []);
async function loadHistory() {
setIsLoading(true); setIsLoading(true);
const saved = await storage.get<ScanRecord[]>(storage.KEYS.SCAN_HISTORY); const saved = await storage.get<ScanRecord[]>(storage.KEYS.SCAN_HISTORY);
setHistory(saved ?? []); setHistory(saved ?? []);
setIsLoading(false); setIsLoading(false);
} }, []);
useEffect(() => {
loadHistory();
}, [loadHistory]);
const addScan = useCallback(async (record: ScanRecord) => { const addScan = useCallback(async (record: ScanRecord) => {
setHistory((prev) => { 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 () => { const clearHistory = useCallback(async () => {
await storage.remove(storage.KEYS.SCAN_HISTORY); await storage.remove(storage.KEYS.SCAN_HISTORY);
setHistory([]); setHistory([]);
}, []); }, []);
return { history, isLoading, addScan, deleteScan, clearHistory, reload: loadHistory }; return { history, isLoading, addScan, deleteScan, toggleFavorite, clearHistory, reload: loadHistory };
} }

View 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;
}

View 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 };
}

View file

@ -13,7 +13,14 @@
"map": "Map", "map": "Map",
"notifications": "Notifications", "notifications": "Notifications",
"settings": "Settings", "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": { "home": {
"greeting": "Hello, Winemaker!", "greeting": "Hello, Winemaker!",
@ -52,7 +59,19 @@
"symptom2": "White cottony down on the underside", "symptom2": "White cottony down on the underside",
"symptom3": "Drying and premature leaf drop", "symptom3": "Drying and premature leaf drop",
"treatment": "Preventive copper-based treatment (Bordeaux mixture). Apply before rain, renew every 10-14 days.", "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": { "oidium": {
"name": "Powdery mildew", "name": "Powdery mildew",
@ -60,7 +79,19 @@
"symptom1": "White-grey powder on leaves and clusters", "symptom1": "White-grey powder on leaves and clusters",
"symptom2": "Berries that crack or dry out", "symptom2": "Berries that crack or dry out",
"treatment": "Sulfur dusting or spraying. Preventive treatments from bud break.", "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": { "blackRot": {
"name": "Black rot", "name": "Black rot",
@ -68,23 +99,59 @@
"symptom1": "Circular brown spots bordered with black on leaves", "symptom1": "Circular brown spots bordered with black on leaves",
"symptom2": "Mummified, black and wrinkled berries", "symptom2": "Mummified, black and wrinkled berries",
"treatment": "Remove mummified berries. Preventive fungicide treatments in spring.", "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": { "esca": {
"name": "Esca", "name": "Esca",
"description": "Esca is a complex of wood diseases caused by several fungi. A chronic disease that can kill the vine.", "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)", "symptom2": "Sudden drying of foliage (apoplexy)",
"treatment": "No curative treatment. Cutting back affected vine. Protect pruning wounds.", "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": { "botrytis": {
"name": "Botrytis", "name": "Botrytis (Grey mold)",
"description": "Grey rot is caused by Botrytis cinerea. It attacks clusters at maturity.", "description": "Grey mold is caused by Botrytis cinerea. It attacks clusters at maturity.",
"symptom1": "Soft grey rot on berries", "symptom1": "Soft grey rot on berries",
"symptom2": "Characteristic grey felt on clusters", "symptom2": "Characteristic grey felt on clusters",
"treatment": "Promote cluster aeration. Leaf removal. Anti-botrytis treatments before cluster closure.", "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": { "flavescence": {
"name": "Flavescence dorée", "name": "Flavescence dorée",
@ -92,7 +159,19 @@
"symptom1": "Leaf rolling with yellow or red coloration depending on variety", "symptom1": "Leaf rolling with yellow or red coloration depending on variety",
"symptom2": "Non-lignification of shoots (remain rubbery)", "symptom2": "Non-lignification of shoots (remain rubbery)",
"treatment": "Mandatory uprooting of contaminated vines. Insecticide treatment against the vector leafhopper.", "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": { "chlorose": {
"name": "Iron chlorosis", "name": "Iron chlorosis",
@ -100,7 +179,17 @@
"symptom1": "Yellowing between veins, veins remaining green", "symptom1": "Yellowing between veins, veins remaining green",
"symptom2": "General weakening of the vine", "symptom2": "General weakening of the vine",
"treatment": "Iron chelate application. Choose rootstock adapted to calcareous soils.", "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": { "notifications": {
@ -144,6 +233,60 @@
"body": "Scan your first vine to start your collection!" "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": { "guides": {
"screenTitle": "Guides & Tips", "screenTitle": "Guides & Tips",
"tabDiseases": "Diseases", "tabDiseases": "Diseases",
@ -155,15 +298,66 @@
}, },
"healthyLeaf": { "healthyLeaf": {
"title": "Recognizing a healthy leaf", "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": { "treatmentCalendar": {
"title": "Treatment calendar", "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": { "grapeVarieties": {
"title": "Bordeaux grape varieties", "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": { "scanner": {

View file

@ -13,7 +13,14 @@
"map": "Carte", "map": "Carte",
"notifications": "Notifications", "notifications": "Notifications",
"settings": "Paramètres", "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": { "home": {
"greeting": "Bonjour, Vigneron !", "greeting": "Bonjour, Vigneron !",
@ -52,7 +59,19 @@
"symptom2": "Duvet blanc cotonneux sur la face inférieure", "symptom2": "Duvet blanc cotonneux sur la face inférieure",
"symptom3": "Dessèchement et chute prématurée des feuilles", "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.", "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": { "oidium": {
"name": "Oïdium", "name": "Oïdium",
@ -60,7 +79,19 @@
"symptom1": "Poudre blanche-grisâtre sur feuilles et grappes", "symptom1": "Poudre blanche-grisâtre sur feuilles et grappes",
"symptom2": "Baies qui éclatent ou se dessèchent", "symptom2": "Baies qui éclatent ou se dessèchent",
"treatment": "Soufre en poudrage ou pulvérisation. Traitements préventifs dès le débourrement.", "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": { "blackRot": {
"name": "Black rot", "name": "Black rot",
@ -68,7 +99,19 @@
"symptom1": "Taches brunes circulaires bordées de noir sur les feuilles", "symptom1": "Taches brunes circulaires bordées de noir sur les feuilles",
"symptom2": "Baies momifiées, noires et ridées", "symptom2": "Baies momifiées, noires et ridées",
"treatment": "Éliminer les baies momifiées. Traitements fongicides préventifs au printemps.", "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": { "esca": {
"name": "Esca", "name": "Esca",
@ -76,7 +119,20 @@
"symptom1": "Décolorations entre les nervures des feuilles (aspect tigré)", "symptom1": "Décolorations entre les nervures des feuilles (aspect tigré)",
"symptom2": "Dessèchement brutal du feuillage (apoplexie)", "symptom2": "Dessèchement brutal du feuillage (apoplexie)",
"treatment": "Aucun traitement curatif. Recépage du cep atteint. Protéger les plaies de taille.", "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": { "botrytis": {
"name": "Botrytis", "name": "Botrytis",
@ -84,7 +140,18 @@
"symptom1": "Pourriture molle grise sur les baies", "symptom1": "Pourriture molle grise sur les baies",
"symptom2": "Feutrage gris caractéristique sur les grappes", "symptom2": "Feutrage gris caractéristique sur les grappes",
"treatment": "Favoriser l'aération des grappes. Effeuillage. Traitements anti-botrytis avant fermeture de la grappe.", "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": { "flavescence": {
"name": "Flavescence dorée", "name": "Flavescence dorée",
@ -92,7 +159,19 @@
"symptom1": "Enroulement des feuilles avec coloration jaune ou rouge selon le cépage", "symptom1": "Enroulement des feuilles avec coloration jaune ou rouge selon le cépage",
"symptom2": "Non-aoûtement des rameaux (restent caoutchouteux)", "symptom2": "Non-aoûtement des rameaux (restent caoutchouteux)",
"treatment": "Arrachage obligatoire des ceps contaminés. Traitement insecticide contre la cicadelle vectrice.", "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": { "chlorose": {
"name": "Chlorose ferrique", "name": "Chlorose ferrique",
@ -100,7 +179,17 @@
"symptom1": "Jaunissement entre les nervures, nervures restant vertes", "symptom1": "Jaunissement entre les nervures, nervures restant vertes",
"symptom2": "Affaiblissement général de la vigne", "symptom2": "Affaiblissement général de la vigne",
"treatment": "Apport de chélates de fer. Choix d'un porte-greffe adapté aux sols calcaires.", "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": { "notifications": {
@ -144,6 +233,60 @@
"body": "Scannez votre première vigne pour commencer votre collection !" "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": { "guides": {
"screenTitle": "Guides & Conseils", "screenTitle": "Guides & Conseils",
"tabDiseases": "Maladies", "tabDiseases": "Maladies",
@ -155,15 +298,66 @@
}, },
"healthyLeaf": { "healthyLeaf": {
"title": "Reconnaître une feuille saine", "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": { "treatmentCalendar": {
"title": "Calendrier de traitement", "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": { "grapeVarieties": {
"title": "Les cépages bordelais", "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": { "scanner": {

View file

@ -4,13 +4,13 @@ import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as Haptics from "expo-haptics"; 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 HomeScreen from "@/screens/HomeScreen";
import ScannerScreen from "@/screens/ScannerScreen"; import ScannerScreen from "@/screens/ScannerScreen";
import MapScreen from "@/screens/MapScreen"; import MapScreen from "@/screens/MapScreen";
import GuidesScreen from "@/screens/GuidesScreen"; import GuidesScreen from "@/screens/GuidesScreen";
import LibraryScreen from "@/screens/LibraryScreen"; import MyPlantsScreen from "@/screens/MyPlantsScreen";
import { colors } from "@/theme/colors"; import { colors } from "@/theme/colors";
const Tab = createBottomTabNavigator(); const Tab = createBottomTabNavigator();
@ -18,7 +18,7 @@ const Tab = createBottomTabNavigator();
const TAB_ICONS: Record<string, any> = { const TAB_ICONS: Record<string, any> = {
Home: House, Home: House,
Guides: BookOpen, Guides: BookOpen,
Library: Leaf, MyPlants: Sprout,
Map: Map, Map: Map,
}; };
@ -164,9 +164,9 @@ export default function BottomTabNavigator() {
options={{ tabBarLabel: t("common.scan") }} options={{ tabBarLabel: t("common.scan") }}
/> />
<Tab.Screen <Tab.Screen
name="Library" name="MyPlants"
component={LibraryScreen} component={MyPlantsScreen}
options={{ tabBarLabel: t("library.title") }} options={{ tabBarLabel: t("myPlants.tabLabel") }}
/> />
<Tab.Screen <Tab.Screen
name="Map" name="Map"

View file

@ -6,8 +6,9 @@ import ResultScreen from '@/screens/ResultScreen';
import NotificationsScreen from '@/screens/NotificationsScreen'; import NotificationsScreen from '@/screens/NotificationsScreen';
import ProfileScreen from '@/screens/ProfileScreen'; import ProfileScreen from '@/screens/ProfileScreen';
import SettingsScreen from '@/screens/SettingsScreen'; import SettingsScreen from '@/screens/SettingsScreen';
import GuidesScreen from '@/screens/GuidesScreen'; import DiseaseDetailScreen from '@/screens/DiseaseDetailScreen';
import LibraryScreen from '@/screens/LibraryScreen'; import GuideDetailScreen from '@/screens/GuideDetailScreen';
import ScanDetailScreen from '@/screens/ScanDetailScreen';
import BottomTabNavigator from './BottomTabNavigator'; import BottomTabNavigator from './BottomTabNavigator';
import linking from './linking'; import linking from './linking';
import type { RootStackParamList } from '@/types/navigation'; import type { RootStackParamList } from '@/types/navigation';
@ -19,7 +20,13 @@ export default function RootNavigator() {
<NavigationContainer linking={linking}> <NavigationContainer linking={linking}>
<Stack.Navigator <Stack.Navigator
initialRouteName="Splash" 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="Splash" component={SplashScreen} />
<Stack.Screen name="Main" component={BottomTabNavigator} /> <Stack.Screen name="Main" component={BottomTabNavigator} />
@ -28,31 +35,12 @@ export default function RootNavigator() {
component={ResultScreen} component={ResultScreen}
options={{ animation: 'slide_from_bottom', presentation: 'modal' }} options={{ animation: 'slide_from_bottom', presentation: 'modal' }}
/> />
<Stack.Screen <Stack.Screen name="Notifications" component={NotificationsScreen} />
name="Notifications" <Stack.Screen name="Profile" component={ProfileScreen} />
component={NotificationsScreen} <Stack.Screen name="Settings" component={SettingsScreen} />
options={{ animation: 'slide_from_right' }} <Stack.Screen name="DiseaseDetail" component={DiseaseDetailScreen} />
/> <Stack.Screen name="GuideDetail" component={GuideDetailScreen} />
<Stack.Screen <Stack.Screen name="ScanDetail" component={ScanDetailScreen} />
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.Navigator> </Stack.Navigator>
</NavigationContainer> </NavigationContainer>
); );

View file

@ -9,7 +9,9 @@ const linking: LinkingOptions<RootStackParamList> = {
Main: { Main: {
screens: { screens: {
Home: 'home', Home: 'home',
Guides: 'guides',
Scanner: 'scan', Scanner: 'scan',
MyPlants: 'my-plants',
Map: 'map', Map: 'map',
}, },
}, },
@ -17,8 +19,9 @@ const linking: LinkingOptions<RootStackParamList> = {
Notifications: 'notifications', Notifications: 'notifications',
Profile: 'profile', Profile: 'profile',
Settings: 'settings', Settings: 'settings',
Guides: 'guides', DiseaseDetail: 'disease/:diseaseId',
Library: 'library', GuideDetail: 'guide/:guideId',
ScanDetail: 'scan/:scanId',
}, },
}, },
}; };

View 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",
},
});

View 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",
},
});

View file

@ -1,373 +1,155 @@
import { useState } from "react"; import { useState } from "react";
import { import { View, ScrollView, RefreshControl, StyleSheet } from "react-native";
View, import { useSafeAreaInsets } from "react-native-safe-area-context";
FlatList,
TouchableOpacity,
StyleSheet,
Platform,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors"; import SearchHeader from "@/components/home/SearchHeader";
import { VINE_DISEASES } from "@/data/diseases"; import SearchSection from "@/components/home/SearchSection";
import { PRACTICAL_GUIDES } from "@/data/guides"; import AnimatedSegmentedControl from "@/components/guides/AnimatedSegmentedControl";
import type { Disease } from "@/data/diseases"; import SmallDiseaseCard from "@/components/ui/SmallDiseaseCard";
import type { Guide } from "@/data/guides"; 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"; type Nav = NativeStackNavigationProp<RootStackParamList>;
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>
);
}
export default function GuidesScreen() { export default function GuidesScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = useNavigation(); const navigation = useNavigation<Nav>();
const [activeTab, setActiveTab] = useState<Tab>("diseases"); const insets = useSafeAreaInsets();
const [activeTab, setActiveTab] = useState(0);
function handleBack() { const {
if (navigation.canGoBack()) { data: diseases,
navigation.goBack(); isLoading: diseasesLoading,
} else { isRefreshing: diseasesRefreshing,
(navigation as any).navigate("Main"); 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 ( return (
<SafeAreaView style={styles.safe} edges={["top"]}> <View style={styles.root}>
{/* Header */} <ScrollView
<View style={styles.header}> contentContainerStyle={{
<TouchableOpacity onPress={handleBack} style={styles.backBtn}> paddingTop: insets.top,
<Ionicons name="chevron-back" size={24} color="#1A1A1A" /> paddingBottom: insets.bottom + 100,
</TouchableOpacity> }}
<Text style={styles.headerTitle}>{t("guides.screenTitle")}</Text> showsVerticalScrollIndicator={false}
<View style={{ width: 44 }} /> refreshControl={
</View> <RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor="#2D6A4F"
progressViewOffset={insets.top}
/>
}
>
<SearchHeader />
<SearchSection />
{/* Segmented Control */} <AnimatedSegmentedControl
<View style={styles.tabContainer}> tabs={tabs}
<View style={styles.tabBar}> activeIndex={activeTab}
<TouchableOpacity onTabChange={setActiveTab}
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>
{/* Content */} {activeTab === 0 ? (
{activeTab === "diseases" ? ( <View style={styles.grid}>
<FlatList {showDiseasesSkeleton
data={VINE_DISEASES} ? Array.from({ length: 4 }).map((_, i) => (
keyExtractor={(item) => item.id} <View key={i} style={styles.gridItem}>
renderItem={({ item }) => <DiseaseCard item={item} />} <DiseaseCardSkeleton style={{ height: 160 }} />
contentContainerStyle={styles.listContent} </View>
showsVerticalScrollIndicator={false} ))
ItemSeparatorComponent={() => <View style={{ height: 12 }} />} : diseases.map((disease, index) => (
/> <View key={disease.id} style={styles.gridItem}>
) : ( <SmallDiseaseCard
<FlatList disease={disease}
data={PRACTICAL_GUIDES} onPress={() =>
keyExtractor={(item) => item.id} navigation.navigate("DiseaseDetail", { diseaseId: disease.id })
renderItem={({ item }) => <GuideCard item={item} />} }
contentContainerStyle={styles.listContent} index={index}
showsVerticalScrollIndicator={false} size="grid"
ItemSeparatorComponent={() => <View style={{ height: 12 }} />} />
/> </View>
)} ))}
</SafeAreaView> </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({ const styles = StyleSheet.create({
safe: { root: {
flex: 1, flex: 1,
backgroundColor: "#F8F9FB", backgroundColor: "#F8F9FB",
}, },
grid: {
// Header
header: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", flexWrap: "wrap",
justifyContent: "space-between",
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 10, gap: 12,
backgroundColor: "transparent",
}, },
backBtn: { gridItem: {
width: 44, width: "48%",
height: 44,
alignItems: "center",
justifyContent: "center",
borderRadius: 14,
backgroundColor: "#FFFFFF",
borderWidth: 1,
borderColor: "#F0F0F0",
}, },
headerTitle: { guidesSection: {
fontSize: 18, paddingHorizontal: 16,
fontWeight: "600", },
sectionTitle: {
fontSize: 22,
fontWeight: "700",
color: "#1A1A1A", color: "#1A1A1A",
letterSpacing: -0.4, marginBottom: 16,
paddingHorizontal: 4,
}, },
guidesList: {
// 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: {
backgroundColor: "#FFFFFF", backgroundColor: "#FFFFFF",
...Platform.select({ borderRadius: 16,
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,
overflow: "hidden", overflow: "hidden",
borderWidth: 1, borderWidth: 1,
borderColor: "#F0F0F0", 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,
}, },
}); });

View file

@ -9,7 +9,7 @@ import {
} from "react-native"; } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from "@expo/vector-icons";
import { ScanList } from "@/components/history/ScanList"; import { ScanList } from "@/components/history/ScanList";
import { useHistory } from "@/hooks/useHistory"; import { useHistory } from "@/hooks/useHistory";
import { getCepageById } from "@/utils/cepages"; import { getCepageById } from "@/utils/cepages";
@ -78,10 +78,14 @@ export default function HistoryScreen() {
return ( return (
<SafeAreaView style={styles.safe} edges={["top"]}> <SafeAreaView style={styles.safe} edges={["top"]}>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>{t("history.title")}</Text> {/* <Text style={styles.title}>{t("history.title")}</Text> */}
<View style={styles.header}> <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 <TextInput
style={styles.search} style={styles.search}
placeholder={t("history.search")} placeholder={t("history.search")}

View file

@ -5,6 +5,8 @@ import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { RootStackParamList } from "@/types/navigation"; import type { RootStackParamList } from "@/types/navigation";
import { useDiseases } from "@/hooks/useDiseases";
import { useGuides } from "@/hooks/useGuides";
import SearchHeader from "@/components/home/SearchHeader"; import SearchHeader from "@/components/home/SearchHeader";
import SearchSection from "@/components/home/SearchSection"; import SearchSection from "@/components/home/SearchSection";
import SectionHeader from "@/components/home/components/homeheader"; import SectionHeader from "@/components/home/components/homeheader";
@ -18,6 +20,8 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
export default function HomeScreen() { export default function HomeScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = useNavigation<Nav>(); const navigation = useNavigation<Nav>();
const { data: diseases, isLoading: diseasesLoading } = useDiseases();
const { data: guides } = useGuides();
return ( return (
<SafeAreaView className="flex-1 bg-[#FAFAFA]" edges={["top"]}> <SafeAreaView className="flex-1 bg-[#FAFAFA]" edges={["top"]}>
@ -37,10 +41,10 @@ export default function HomeScreen() {
<View className="px-5"> <View className="px-5">
<SectionHeader <SectionHeader
title={t("home.frequentDiseases")} title={t("home.frequentDiseases")}
onViewAll={() => navigation.navigate("Guides")} onViewAll={() => navigation.navigate("Main", { screen: "Guides" })}
/> />
</View> </View>
<FrequentDiseases /> <FrequentDiseases diseases={diseases} isLoading={diseasesLoading} />
</View> </View>
{/* Season alert */} {/* Season alert */}
@ -50,9 +54,9 @@ export default function HomeScreen() {
<View className="mx-5 mb-6 gap-3"> <View className="mx-5 mb-6 gap-3">
<SectionHeader <SectionHeader
title={t("home.practicalGuides")} title={t("home.practicalGuides")}
onViewAll={() => navigation.navigate("Guides")} onViewAll={() => navigation.navigate("Main", { screen: "Guides" })}
/> />
<PracticalGuides /> <PracticalGuides guides={guides} />
</View> </View>
<View className="h-8" /> <View className="h-8" />

View file

@ -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,
},
});

View 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',
},
});

View 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' },
});

View file

@ -13,7 +13,7 @@ export default function SplashScreen() {
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
navigation.replace('Main'); navigation.replace('Main', { screen: 'Home' });
}, 2800); }, 2800);
return () => clearTimeout(timer); return () => clearTimeout(timer);

View 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",
},
};
}
}

View 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}`);
}

View 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}`);
}

View 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 : [],
};
}

View 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);
}
}

View file

@ -13,4 +13,10 @@ export interface ScanRecord {
detection: Detection; detection: Detection;
xpEarned: number; xpEarned: number;
createdAt: string; // ISO date createdAt: string; // ISO date
isFavorite?: boolean;
location?: {
latitude: number;
longitude: number;
placeName?: string;
} | null;
} }

View file

@ -1,20 +1,22 @@
import type { NavigatorScreenParams } from '@react-navigation/native';
import type { Detection } from './detection'; 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 = { export type BottomTabParamList = {
Home: undefined; Home: undefined;
Guides: undefined; Guides: undefined;
Scanner: undefined; Scanner: undefined;
Library: undefined; MyPlants: undefined;
Map: 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 };
};

View 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)!,
}));
}

View 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";
}
}

View 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;
}

View file

@ -49,12 +49,10 @@ export default function AlertsClient({ alerts }: { alerts: Alert[] }) {
async function handleToggleActive(id: string, active: boolean) { async function handleToggleActive(id: string, active: boolean) {
try { try {
const alert = alerts.find((a) => a.id === id);
if (!alert) return;
const res = await fetch(`/api/alerts/${id}`, { const res = await fetch(`/api/alerts/${id}`, {
method: "PUT", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...alert, active }), body: JSON.stringify({ active }),
}); });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
toast.success(active ? "Alerte activee" : "Alerte desactivee"); 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="max-w-7xl mx-auto space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <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 Alertes saisonnieres
</h1> </h1>
<p className="text-sm text-stone-600 mt-1">{alerts.length} alertes</p> <p className="text-sm text-stone-600 mt-1">{alerts.length} alertes</p>

View file

@ -31,7 +31,7 @@ export default function DashboardClient({ stats, recentScans, topDiseases }: Das
<div className="max-w-7xl mx-auto space-y-8"> <div className="max-w-7xl mx-auto space-y-8">
{/* Header */} {/* Header */}
<div> <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 Tableau de bord
</h1> </h1>
<p className="text-sm text-stone-600 mt-1 capitalize">{today}</p> <p className="text-sm text-stone-600 mt-1 capitalize">{today}</p>

View file

@ -9,7 +9,10 @@ export default async function EditDiseasePage({
}) { }) {
const { id } = await params; 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(); if (!disease) notFound();
return ( return (
@ -35,6 +38,24 @@ export default async function EditDiseasePage({
iconColor: disease.iconColor, iconColor: disease.iconColor,
bgColor: disease.bgColor, bgColor: disease.bgColor,
published: disease.published, 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,
})),
}} }}
/> />
); );

View file

@ -73,20 +73,10 @@ export default function DiseasesClient({ diseases }: { diseases: Disease[] }) {
async function handleTogglePublish(id: string, published: boolean) { async function handleTogglePublish(id: string, published: boolean) {
try { try {
const disease = diseases.find((d) => d.id === id);
if (!disease) return;
const res = await fetch(`/api/diseases/${id}`, { const res = await fetch(`/api/diseases/${id}`, {
method: "PUT", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({ published }),
...disease,
published,
symptoms: [],
description: "placeholder",
treatment: "placeholder",
season: "placeholder",
}),
}); });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
@ -112,7 +102,7 @@ export default function DiseasesClient({ diseases }: { diseases: Disease[] }) {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <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 Maladies de la vigne
</h1> </h1>
<p className="text-sm text-stone-600 mt-1">{diseases.length} maladies repertoriees</p> <p className="text-sm text-stone-600 mt-1">{diseases.length} maladies repertoriees</p>

View file

@ -9,7 +9,10 @@ export default async function EditGuidePage({
}) { }) {
const { id } = await params; 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(); if (!guide) notFound();
return ( return (
@ -29,6 +32,18 @@ export default async function EditGuidePage({
bgColor: guide.bgColor, bgColor: guide.bgColor,
published: guide.published, published: guide.published,
order: guide.order, 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,
})),
}} }}
/> />
); );

View file

@ -62,7 +62,7 @@ export default function GuidesClient({ guides }: { guides: Guide[] }) {
<div className="max-w-7xl mx-auto space-y-6"> <div className="max-w-7xl mx-auto space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="font-display text-3xl font-semibold tracking-tight text-cream"> <h1 className="text-3xl font-bold tracking-tight text-cream">
Guides Guides
</h1> </h1>
<p className="text-sm text-stone-600 mt-1">{guides.length} guides</p> <p className="text-sm text-stone-600 mt-1">{guides.length} guides</p>

View file

@ -28,7 +28,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{/* Mobile sidebar */} {/* Mobile sidebar */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}> <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} /> <Sidebar userName={userName} userEmail={userEmail} />
</SheetContent> </SheetContent>
</Sheet> </Sheet>

View file

@ -79,7 +79,7 @@ export default function UserDetailClient({ user }: UserDetailProps) {
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<h1 className="font-display text-2xl font-semibold tracking-tight text-cream"> <h1 className="text-2xl font-bold tracking-tight text-cream">
Utilisateur Utilisateur
</h1> </h1>
</div> </div>

View file

@ -45,7 +45,7 @@ export default function UsersClient({ users }: { users: User[] }) {
return ( return (
<div className="max-w-7xl mx-auto space-y-6"> <div className="max-w-7xl mx-auto space-y-6">
<div> <div>
<h1 className="font-display text-3xl font-semibold tracking-tight text-cream"> <h1 className="text-3xl font-bold tracking-tight text-cream">
Utilisateurs Utilisateurs
</h1> </h1>
<p className="text-sm text-stone-600 mt-1">{users.length} utilisateurs</p> <p className="text-sm text-stone-600 mt-1">{users.length} utilisateurs</p>

View file

@ -2,7 +2,8 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; 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 { signIn } from "@/lib/auth-client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -50,10 +51,10 @@ export default function LoginPage() {
<div className="w-full max-w-[380px] mx-4"> <div className="w-full max-w-[380px] mx-4">
{/* Logo */} {/* Logo */}
<div className="flex flex-col items-center mb-8"> <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"> <div className="h-16 w-16 rounded-2xl bg-vine/10 flex items-center justify-center mb-5 glow-green-sm overflow-hidden">
<Grape className="h-7 w-7 text-vine" /> <Image src="/logo.png" alt="VinEye" width={48} height={48} className="object-contain" />
</div> </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 VinEye Admin
</h1> </h1>
<p className="text-sm text-stone-600 mt-1"> <p className="text-sm text-stone-600 mt-1">

View file

@ -50,6 +50,35 @@ export async function PUT(
return Response.json({ data: alert }); 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( export async function DELETE(
_request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }

View file

@ -10,7 +10,10 @@ export async function GET(
) { ) {
const { id } = await params; 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) { if (!disease) {
return Response.json({ error: "Maladie introuvable" }, { status: 404 }); 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 }); return Response.json({ error: "Ce slug existe deja" }, { status: 409 });
} }
const { images, ...diseaseData } = data;
const disease = await prisma.disease.update({ const disease = await prisma.disease.update({
where: { id }, 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 }); return Response.json({ data: disease });

View file

@ -45,9 +45,24 @@ export async function POST(request: NextRequest) {
return Response.json({ error: "Ce slug existe deja" }, { status: 409 }); return Response.json({ error: "Ce slug existe deja" }, { status: 409 });
} }
const { images, ...diseaseData } = data;
const disease = await prisma.disease.create({ 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 });
} }

View file

@ -6,11 +6,14 @@ import { slugify } from "@/lib/utils";
export async function GET( export async function GET(
_request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
const { id } = await params; 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) { if (!guide) {
return Response.json({ error: "Guide introuvable" }, { status: 404 }); return Response.json({ error: "Guide introuvable" }, { status: 404 });
} }
@ -20,7 +23,7 @@ export async function GET(
export async function PUT( export async function PUT(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
const auth = await requireAdmin(); const auth = await requireAdmin();
if ("error" in auth) { if ("error" in auth) {
@ -39,12 +42,12 @@ export async function PUT(
if (!result.success) { if (!result.success) {
return Response.json( return Response.json(
{ error: "Validation failed", details: result.error.flatten() }, { error: "Validation failed", details: result.error.flatten() },
{ status: 400 } { status: 400 },
); );
} }
const data = result.data; const { sections, ...guideData } = result.data;
const slug = data.slug || slugify(data.title); const slug = guideData.slug || slugify(guideData.title);
const slugConflict = await prisma.guide.findFirst({ const slugConflict = await prisma.guide.findFirst({
where: { slug, id: { not: id } }, where: { slug, id: { not: id } },
@ -53,9 +56,55 @@ export async function PUT(
return Response.json({ error: "Ce slug existe deja" }, { status: 409 }); 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({ const guide = await prisma.guide.update({
where: { id }, where: { id },
data: { ...data, slug }, data: { published: body.published },
}); });
return Response.json({ data: guide }); return Response.json({ data: guide });
@ -63,7 +112,7 @@ export async function PUT(
export async function DELETE( export async function DELETE(
_request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
const auth = await requireAdmin(); const auth = await requireAdmin();
if ("error" in auth) { if ("error" in auth) {

View file

@ -13,6 +13,7 @@ export async function GET(request: NextRequest) {
const guides = await prisma.guide.findMany({ const guides = await prisma.guide.findMany({
where, where,
include: { sections: { orderBy: { order: "asc" } } },
orderBy: { order: "asc" }, orderBy: { order: "asc" },
}); });
@ -31,12 +32,12 @@ export async function POST(request: NextRequest) {
if (!result.success) { if (!result.success) {
return Response.json( return Response.json(
{ error: "Validation failed", details: result.error.flatten() }, { error: "Validation failed", details: result.error.flatten() },
{ status: 400 } { status: 400 },
); );
} }
const data = result.data; const { sections, ...guideData } = result.data;
const slug = data.slug || slugify(data.title); const slug = guideData.slug || slugify(guideData.title);
const existing = await prisma.guide.findUnique({ where: { slug } }); const existing = await prisma.guide.findUnique({ where: { slug } });
if (existing) { if (existing) {
@ -44,8 +45,21 @@ export async function POST(request: NextRequest) {
} }
const guide = await prisma.guide.create({ 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 });
} }

View 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 },
);
}
}

View 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 },
);
}

View 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 },
);
}

View 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 },
);
}

View file

@ -7,9 +7,8 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-outfit); --font-sans: var(--font-roboto);
--font-mono: var(--font-mono); --font-mono: var(--font-mono);
--font-display: var(--font-fraunces);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);

View file

@ -1,18 +1,13 @@
import type { Metadata } from "next"; 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 { Toaster } from "@/components/ui/sonner";
import "./globals.css"; import "./globals.css";
const fraunces = Fraunces({ const roboto = Roboto({
variable: "--font-fraunces", variable: "--font-roboto",
subsets: ["latin"],
display: "swap",
});
const outfit = Outfit({
variable: "--font-outfit",
subsets: ["latin"], subsets: ["latin"],
display: "swap", display: "swap",
weight: ["300", "400", "500", "700"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
@ -28,7 +23,7 @@ export default function RootLayout({
return ( return (
<html <html
lang="fr" lang="fr"
className={`${fraunces.variable} ${outfit.variable} h-full antialiased`} className={`${roboto.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
{children} {children}

View file

@ -48,7 +48,7 @@ export default function DeleteDialog({ title, description, onConfirm }: DeleteDi
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent className="border-[oklch(0.22_0.006_60)] bg-card"> <AlertDialogContent className="border-[oklch(0.22_0.006_60)] bg-card">
<AlertDialogHeader> <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> <AlertDialogDescription className="text-stone-400">{description}</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>

View file

@ -1,15 +1,25 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useCallback } from "react";
import { useRouter } from "next/navigation"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -21,17 +31,34 @@ import { toast } from "sonner";
import { slugify } from "@/lib/utils"; import { slugify } from "@/lib/utils";
import type { DiseaseInput } from "@/lib/validations"; import type { DiseaseInput } from "@/lib/validations";
interface DiseaseImage {
url: string;
alt: string;
order: number;
}
interface DiseaseFormProps { interface DiseaseFormProps {
initialData?: DiseaseInput & { id?: string; slug?: string }; initialData?: Partial<DiseaseInput> & { id?: string; slug?: string; images?: DiseaseImage[] };
mode: "create" | "edit"; 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) { export default function DiseaseForm({ initialData, mode }: DiseaseFormProps) {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [slugCustom, setSlugCustom] = useState(!!initialData?.slug);
const [name, setName] = useState(initialData?.name ?? ""); const [name, setName] = useState(initialData?.name ?? "");
const [nameEn, setNameEn] = useState(initialData?.nameEn ?? ""); const [nameEn, setNameEn] = useState(initialData?.nameEn ?? "");
const [slug, setSlug] = useState(initialData?.slug ?? "");
const [scientificName, setScientificName] = useState(initialData?.scientificName ?? ""); const [scientificName, setScientificName] = useState(initialData?.scientificName ?? "");
const [type, setType] = useState(initialData?.type ?? "FUNGAL"); const [type, setType] = useState(initialData?.type ?? "FUNGAL");
const [severity, setSeverity] = useState(initialData?.severity ?? "MEDIUM"); 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 [treatmentEn, setTreatmentEn] = useState(initialData?.treatmentEn ?? "");
const [season, setSeason] = useState(initialData?.season ?? ""); const [season, setSeason] = useState(initialData?.season ?? "");
const [seasonEn, setSeasonEn] = useState(initialData?.seasonEn ?? ""); 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 [iconName, setIconName] = useState(initialData?.iconName ?? "leaf");
const [iconColor, setIconColor] = useState(initialData?.iconColor ?? "#1D9E75"); const [iconColor, setIconColor] = useState(initialData?.iconColor ?? "#1D9E75");
const [bgColor, setBgColor] = useState(initialData?.bgColor ?? "#E1F5EE"); const [bgColor, setBgColor] = useState(initialData?.bgColor ?? "#E1F5EE");
const [published, setPublished] = useState(initialData?.published ?? true); 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") { const handleNameChange = useCallback((v: string) => {
if (lang === "fr") setSymptoms([...symptoms, ""]); setName(v);
else setSymptomsEn([...symptomsEn, ""]); 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) { function addImage() {
if (lang === "fr") setSymptoms(symptoms.filter((_, i) => i !== index)); if (!newImageUrl.trim()) return;
else setSymptomsEn(symptomsEn.filter((_, i) => i !== index)); 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) { function togglePart(part: string) {
if (lang === "fr") { setImpactedParts((prev) =>
const next = [...symptoms]; prev.includes(part) ? prev.filter((p) => p !== part) : [...prev, part]
next[index] = value; );
setSymptoms(next); }
} else {
const next = [...symptomsEn]; function isMonthActive(m: number) {
next[index] = value; if (!startMonth || !endMonth) return false;
setSymptomsEn(next); if (startMonth <= endMonth) return m >= startMonth && m <= endMonth;
} return m >= startMonth || m <= endMonth;
} }
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (loading) return; if (loading) return;
if (!name.trim() || !description.trim() || !treatment.trim() || !season.trim()) { if (!name.trim() || !description.trim() || !treatment.trim() || !season.trim()) {
toast.error("Veuillez remplir tous les champs obligatoires"); toast.error("Veuillez remplir tous les champs obligatoires");
return; return;
} }
const filteredSymptoms = symptoms.filter((s) => s.trim()); const filteredSymptoms = symptoms.filter((s) => s.trim());
if (filteredSymptoms.length === 0) { if (filteredSymptoms.length === 0) {
toast.error("Ajoutez au moins un symptome"); toast.error("Ajoutez au moins un symptome");
@ -86,59 +150,55 @@ export default function DiseaseForm({ initialData, mode }: DiseaseFormProps) {
} }
setLoading(true); setLoading(true);
const body: DiseaseInput = { const body: DiseaseInput = {
name: name.trim(), name: name.trim(), nameEn: nameEn.trim(), scientificName: scientificName.trim(),
nameEn: nameEn.trim(), slug: slug || slugify(name),
scientificName: scientificName.trim(), type: type as DiseaseInput["type"], severity: severity as DiseaseInput["severity"],
slug: slugify(name), description: description.trim(), descriptionEn: descriptionEn.trim(),
type: type as DiseaseInput["type"], symptoms: filteredSymptoms, symptomsEn: symptomsEn.filter((s) => s.trim()),
severity: severity as DiseaseInput["severity"], treatment: treatment.trim(), treatmentEn: treatmentEn.trim(),
description: description.trim(), season: season.trim(), seasonEn: seasonEn.trim(),
descriptionEn: descriptionEn.trim(), iconName, iconColor, bgColor, published,
symptoms: filteredSymptoms, startMonth, endMonth, peakMonth,
symptomsEn: symptomsEn.filter((s) => s.trim()), conditions: conditions.filter((s) => s.trim()), conditionsEn: conditionsEn.filter((s) => s.trim()),
treatment: treatment.trim(), preventiveActions: preventiveActions.filter((s) => s.trim()), preventiveActionsEn: preventiveActionsEn.filter((s) => s.trim()),
treatmentEn: treatmentEn.trim(), curativeActions: curativeActions.filter((s) => s.trim()), curativeActionsEn: curativeActionsEn.filter((s) => s.trim()),
season: season.trim(), impactedParts, impactedPartsEn: [],
seasonEn: seasonEn.trim(), spreadMethod: spreadMethod.trim() || null, spreadMethodEn: spreadMethodEn.trim() || null,
iconName, images,
iconColor,
bgColor,
published,
}; };
try { try {
const url = const url = mode === "create" ? "/api/diseases" : `/api/diseases/${initialData?.id}`;
mode === "create" const res = await fetch(url, { method: mode === "create" ? "POST" : "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
? "/api/diseases" if (!res.ok) { const d = await res.json(); toast.error(d.error || "Erreur"); return; }
: `/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;
}
toast.success(mode === "create" ? "Maladie creee" : "Maladie mise a jour"); toast.success(mode === "create" ? "Maladie creee" : "Maladie mise a jour");
router.push("/diseases"); router.push("/diseases");
router.refresh(); router.refresh();
} catch { } catch { toast.error("Une erreur est survenue"); } finally { setLoading(false); }
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 ( 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"> <div className="flex items-center gap-3">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => router.back()}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@ -149,35 +209,33 @@ export default function DiseaseForm({ initialData, mode }: DiseaseFormProps) {
</div> </div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* General info */} {/* General */}
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]"> <Card className="border-border/50">
<CardContent className="p-5 space-y-4"> <CardContent className="p-5 space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Informations generales</p>
Informations generales
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Nom (FR) *</Label> <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>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Nom (EN)</Label> <Label className="text-xs font-medium text-muted-foreground">Nom (EN)</Label>
<Input value={nameEn} onChange={(e) => setNameEn(e.target.value)} className="rounded-xl" /> <Input value={nameEn} onChange={(e) => setNameEn(e.target.value)} className="rounded-xl" />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Label className="text-xs font-medium text-muted-foreground">Nom scientifique</Label> <div className="space-y-2">
<Input value={scientificName} onChange={(e) => setScientificName(e.target.value)} className="rounded-xl" /> <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> </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="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Type *</Label> <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)}> <Select value={severity} onValueChange={(v) => v && setSeverity(v)}>
<SelectTrigger className="rounded-xl"><SelectValue /></SelectTrigger> <SelectTrigger className="rounded-xl"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="HIGH">Critique</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">Modere</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">Faible</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> </SelectContent>
</Select> </Select>
</div> </div>
</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> </CardContent>
</Card> </Card>
{/* Description */} {/* Symptoms & Treatment */}
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]"> <Card className="border-border/50">
<CardContent className="p-5 space-y-4"> <CardContent className="p-5 space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Symptomes & Traitement</p>
Description <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
</p> <StringListEditor label="Symptomes (FR) *" items={symptoms} setItems={setSymptoms} placeholder="Symptome..." />
<div className="space-y-2"> <StringListEditor label="Symptomes (EN)" items={symptomsEn} setItems={setSymptomsEn} placeholder="Symptom..." />
<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>
</div> </div>
<Separator /> <Separator />
<div className="space-y-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Label className="text-xs font-medium text-muted-foreground">Symptomes (EN)</Label> <div className="space-y-2">
{symptomsEn.map((s, i) => ( <Label className="text-xs font-medium text-muted-foreground">Traitement (FR) *</Label>
<div key={i} className="flex gap-2"> <Textarea value={treatment} onChange={(e) => setTreatment(e.target.value)} rows={2} className="rounded-xl" required />
<Input </div>
value={s} <div className="space-y-2">
onChange={(e) => updateSymptom("en", i, e.target.value)} <Label className="text-xs font-medium text-muted-foreground">Traitement (EN)</Label>
className="rounded-xl" <Textarea value={treatmentEn} onChange={(e) => setTreatmentEn(e.target.value)} rows={2} className="rounded-xl" />
placeholder={`Symptom ${i + 1}`} </div>
/>
{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> </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="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Saison (FR) *</Label> <Label className="text-xs font-medium text-muted-foreground">Saison (FR) *</Label>
@ -303,12 +306,139 @@ export default function DiseaseForm({ initialData, mode }: DiseaseFormProps) {
</CardContent> </CardContent>
</Card> </Card>
{/* Appearance */} {/* Timeline */}
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]"> <Card className="border-border/50">
<CardContent className="p-5 space-y-4"> <CardContent className="p-5 space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <div className="flex items-center gap-2">
Apparence <Calendar className="h-4 w-4 text-primary" />
</p> <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="grid grid-cols-3 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Icone</Label> <Label className="text-xs font-medium text-muted-foreground">Icone</Label>
@ -332,8 +462,8 @@ export default function DiseaseForm({ initialData, mode }: DiseaseFormProps) {
</CardContent> </CardContent>
</Card> </Card>
{/* Publish + Actions */} {/* Publish */}
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]"> <Card className="border-border/50">
<CardContent className="p-5"> <CardContent className="p-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@ -346,9 +476,7 @@ export default function DiseaseForm({ initialData, mode }: DiseaseFormProps) {
</Card> </Card>
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<Button type="button" variant="ghost" onClick={() => router.back()} className="rounded-xl"> <Button type="button" variant="ghost" onClick={() => router.back()} className="rounded-xl">Annuler</Button>
Annuler
</Button>
<Button type="submit" disabled={loading} className="rounded-xl"> <Button type="submit" disabled={loading} className="rounded-xl">
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} {loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
{mode === "create" ? "Creer" : "Enregistrer"} {mode === "create" ? "Creer" : "Enregistrer"}

View file

@ -1,42 +1,83 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useCallback } from "react";
import { useRouter } from "next/navigation"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Card, CardContent } from "@/components/ui/card"; 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 { toast } from "sonner";
import { slugify } from "@/lib/utils"; 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 { interface GuideFormProps {
initialData?: { initialData?: 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;
};
mode: "create" | "edit"; mode: "create" | "edit";
} }
const EMPTY_SECTION: GuideSection = {
title: "", titleEn: "", body: "", bodyEn: "",
image: "", tip: "", tipEn: "", order: 0,
};
export default function GuideForm({ initialData, mode }: GuideFormProps) { export default function GuideForm({ initialData, mode }: GuideFormProps) {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [slugCustom, setSlugCustom] = useState(!!initialData?.id);
const [title, setTitle] = useState(initialData?.title ?? ""); const [title, setTitle] = useState(initialData?.title ?? "");
const [titleEn, setTitleEn] = useState(initialData?.titleEn ?? ""); const [titleEn, setTitleEn] = useState(initialData?.titleEn ?? "");
const [slug, setSlug] = useState(initialData?.id ? "" : "");
const [subtitle, setSubtitle] = useState(initialData?.subtitle ?? ""); const [subtitle, setSubtitle] = useState(initialData?.subtitle ?? "");
const [subtitleEn, setSubtitleEn] = useState(initialData?.subtitleEn ?? ""); const [subtitleEn, setSubtitleEn] = useState(initialData?.subtitleEn ?? "");
const [content, setContent] = useState(initialData?.content ?? ""); 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 [bgColor, setBgColor] = useState(initialData?.bgColor ?? "#E6F1FB");
const [published, setPublished] = useState(initialData?.published ?? true); const [published, setPublished] = useState(initialData?.published ?? true);
const [order, setOrder] = useState(initialData?.order ?? 0); 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) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (loading) return; if (loading) return;
if (!title.trim() || !subtitle.trim() || !content.trim()) { if (!title.trim() || !subtitle.trim()) {
toast.error("Veuillez remplir les champs obligatoires"); toast.error("Veuillez remplir le titre et le sous-titre");
return; return;
} }
setLoading(true); setLoading(true);
const body = { const body = {
title: title.trim(), title: title.trim(), titleEn: titleEn.trim(),
titleEn: titleEn.trim(), slug: slug || slugify(title),
slug: slugify(title), subtitle: subtitle.trim(), subtitleEn: subtitleEn.trim(),
subtitle: subtitle.trim(), content: content.trim(), contentEn: contentEn.trim(),
subtitleEn: subtitleEn.trim(), category, iconName, iconColor, bgColor, published, order,
content: content.trim(), readTime, coverImage: coverImage.trim() || null,
contentEn: contentEn.trim(), sections: sections
category, .filter((s) => s.title.trim() && s.body.trim())
iconName, .map((s, i) => ({
iconColor, title: s.title.trim(), titleEn: s.titleEn.trim(),
bgColor, body: s.body.trim(), bodyEn: s.bodyEn.trim(),
published, image: s.image.trim() || null,
order, tip: s.tip.trim() || null, tipEn: s.tipEn.trim() || null,
order: i,
})),
}; };
try { try {
const url = mode === "create" ? "/api/guides" : `/api/guides/${initialData?.id}`; const url = mode === "create" ? "/api/guides" : `/api/guides/${initialData?.id}`;
const method = mode === "create" ? "POST" : "PUT";
const res = await fetch(url, { const res = await fetch(url, {
method, method: mode === "create" ? "POST" : "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const d = await res.json();
toast.error(data.error || "Erreur"); toast.error(d.error || "Erreur");
return; return;
} }
toast.success(mode === "create" ? "Guide cree" : "Guide mis a jour"); toast.success(mode === "create" ? "Guide cree" : "Guide mis a jour");
router.push("/guides"); router.push("/guides");
router.refresh(); router.refresh();
@ -102,7 +177,7 @@ export default function GuideForm({ initialData, mode }: GuideFormProps) {
} }
return ( 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"> <div className="flex items-center gap-3">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => router.back()}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@ -113,13 +188,14 @@ export default function GuideForm({ initialData, mode }: GuideFormProps) {
</div> </div>
<form onSubmit={handleSubmit} className="space-y-6"> <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"> <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="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Titre (FR) *</Label> <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>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Titre (EN)</Label> <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" /> <Input value={subtitleEn} onChange={(e) => setSubtitleEn(e.target.value)} className="rounded-xl" />
</div> </div>
</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"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Categorie</Label> <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>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <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&apos;affichage</Label>
<Input type="number" value={order} onChange={(e) => setOrder(parseInt(e.target.value) || 0)} className="rounded-xl" min={0} /> <Input type="number" value={order} onChange={(e) => setOrder(parseInt(e.target.value) || 0)} className="rounded-xl" min={0} />
</div> </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> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]"> {/* ── Legacy content (collapsible) ── */}
<CardContent className="p-5 space-y-4"> <Card className="border-border/50">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Contenu</p> <CardContent className="p-5">
<div className="space-y-2"> <Accordion>
<Label className="text-xs font-medium text-muted-foreground">Contenu (FR) *</Label> <AccordionItem value="legacy" className="border-0">
<Textarea value={content} onChange={(e) => setContent(e.target.value)} rows={6} className="rounded-xl" required /> <AccordionTrigger className="text-sm font-medium py-0 hover:no-underline">
</div> <span className="flex items-center gap-2">
<div className="space-y-2"> Contenu brut
<Label className="text-xs font-medium text-muted-foreground">Contenu (EN)</Label> <Badge variant="secondary" className="text-[10px] bg-amber-500/10 text-amber-600 border-0">Ancien format</Badge>
<Textarea value={contentEn} onChange={(e) => setContentEn(e.target.value)} rows={6} className="rounded-xl" /> </span>
</div> </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> </CardContent>
</Card> </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"> <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="grid grid-cols-3 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Icone</Label> <Label className="text-xs font-medium text-muted-foreground">Icone</Label>
@ -189,7 +437,8 @@ export default function GuideForm({ initialData, mode }: GuideFormProps) {
</CardContent> </CardContent>
</Card> </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"> <CardContent className="p-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>

View file

@ -9,10 +9,9 @@ import {
AlertTriangle, AlertTriangle,
Users, Users,
LogOut, LogOut,
Grape, ChevronsLeft,
ChevronLeft,
ChevronRight,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { signOut } from "@/lib/auth-client"; import { signOut } from "@/lib/auth-client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@ -63,45 +62,47 @@ export default function Sidebar({
return ( return (
<aside <aside
className={cn( 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]" collapsed ? "w-[68px]" : "w-[260px]"
)} )}
> >
{/* Logo area */} {/* Logo + collapse toggle */}
<div className="flex items-center justify-between px-4 h-16 shrink-0"> <div className="flex items-center justify-between px-4 h-16 shrink-0">
{!collapsed && ( {!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"> <Link href="/dashboard" className="flex items-center gap-2.5 group">
<Grape className="h-4.5 w-4.5 text-vine" /> <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">
</div> <Image src="/logo.png" alt="VinEye" width={24} height={24} className="object-contain" />
<span className="font-display text-lg font-semibold tracking-tight text-cream"> </div>
VinEye <span className="text-lg font-bold tracking-tight text-cream">
</span> VinEye
</Link> </span>
)} </Link>
{collapsed && ( {onCollapse && (
<Link href="/dashboard" className="mx-auto"> <button
<div className="h-8 w-8 rounded-lg bg-vine/10 flex items-center justify-center hover:bg-vine/15 transition-colors"> onClick={onCollapse}
<Grape className="h-4.5 w-4.5 text-vine" /> 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"
</div> >
</Link> <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> </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 */} {/* Divider */}
<div className="mx-3 h-px bg-gradient-to-r from-transparent via-[oklch(0.25_0.006_60)] to-transparent" /> <div className="mx-3 h-px bg-gradient-to-r from-transparent via-[oklch(0.25_0.006_60)] to-transparent" />

View file

@ -25,7 +25,7 @@ export default function StatCard({
<p className="text-[11px] font-semibold text-stone-600 uppercase tracking-[0.08em] mb-2"> <p className="text-[11px] font-semibold text-stone-600 uppercase tracking-[0.08em] mb-2">
{title} {title}
</p> </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")} {value.toLocaleString("fr-FR")}
</p> </p>
</div> </div>

View 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 }

View file

@ -3,6 +3,7 @@ import { prismaAdapter } from "better-auth/adapters/prisma";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
export const auth = betterAuth({ export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
database: prismaAdapter(prisma, { database: prismaAdapter(prisma, {
provider: "postgresql", provider: "postgresql",
}), }),

View file

@ -20,6 +20,25 @@ export const diseaseSchema = z.object({
bgColor: z.string().trim().optional().default("#E1F5EE"), bgColor: z.string().trim().optional().default("#E1F5EE"),
imageUrl: z.string().url().optional().nullable(), imageUrl: z.string().url().optional().nullable(),
published: z.boolean().optional().default(true), 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({ export const guideSchema = z.object({
@ -28,7 +47,7 @@ export const guideSchema = z.object({
slug: z.string().max(100).trim().optional(), slug: z.string().max(100).trim().optional(),
subtitle: z.string().min(1, "Sous-titre requis").max(500).trim(), subtitle: z.string().min(1, "Sous-titre requis").max(500).trim(),
subtitleEn: z.string().max(500).trim().optional().default(""), 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(""), contentEn: z.string().trim().optional().default(""),
category: z.string().trim().optional().default("general"), category: z.string().trim().optional().default("general"),
iconName: z.string().trim().optional().default("book"), iconName: z.string().trim().optional().default("book"),
@ -36,6 +55,18 @@ export const guideSchema = z.object({
bgColor: z.string().trim().optional().default("#E6F1FB"), bgColor: z.string().trim().optional().default("#E6F1FB"),
published: z.boolean().optional().default(true), published: z.boolean().optional().default(true),
order: z.number().int().min(0).optional().default(0), 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({ export const alertSchema = z.object({

View file

@ -1,8 +1,29 @@
import { NextRequest, NextResponse } from "next/server"; 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) { export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; 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 ( if (
pathname.startsWith("/dashboard") || pathname.startsWith("/dashboard") ||
pathname.startsWith("/diseases") || pathname.startsWith("/diseases") ||
@ -21,6 +42,7 @@ export async function middleware(request: NextRequest) {
export const config = { export const config = {
matcher: [ matcher: [
"/api/mobile/:path*",
"/dashboard/:path*", "/dashboard/:path*",
"/diseases/:path*", "/diseases/:path*",
"/guides/:path*", "/guides/:path*",

View file

@ -1,6 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
allowedDevOrigins: ["localhost", "127.0.0.1", "10.0.2.2", "192.168.*.*"],
turbopack: { turbopack: {
root: ".", root: ".",
}, },

View file

@ -5,6 +5,7 @@ export default defineConfig({
schema: "prisma/schema.prisma", schema: "prisma/schema.prisma",
migrations: { migrations: {
path: "prisma/migrations", path: "prisma/migrations",
seed: "npx tsx prisma/seed.ts",
}, },
datasource: { datasource: {
url: process.env["DATABASE_URL"], url: process.env["DATABASE_URL"],

View file

@ -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;

View file

@ -123,6 +123,26 @@ model Disease {
published Boolean @default(true) published Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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[] scans Scan[]
@@index([type]) @@index([type])
@ -131,6 +151,19 @@ model Disease {
@@map("diseases") @@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 { model Guide {
id String @id @default(cuid()) id String @id @default(cuid())
slug String @unique slug String @unique
@ -149,11 +182,36 @@ model Guide {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// New fields
readTime Int?
coverImage String?
// Relations
sections GuideSection[]
@@index([published]) @@index([published])
@@index([order]) @@index([order])
@@map("guides") @@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 { model Scan {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String

View file

@ -9,396 +9,326 @@ const prisma = new PrismaClient({ adapter });
async function main() { async function main() {
console.log("Seeding database..."); console.log("Seeding database...");
// 1. Admin user // ── 1. Users ──
const passwordHash = await hashPassword("admin123456"); const passwordHash = await hashPassword("admin123456");
const admin = await prisma.user.upsert({ const admin = await prisma.user.upsert({
where: { email: "admin@vineye.app" }, where: { email: "admin@vineye.app" },
update: {}, update: {},
create: { create: { id: "admin-001", name: "Admin VinEye", email: "admin@vineye.app", role: "ADMIN", emailVerified: true, xp: 5000, level: 10 },
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({ await prisma.account.upsert({
where: { id: "account-admin-001" }, where: { id: "account-admin-001" },
update: {}, update: {},
create: { create: { id: "account-admin-001", accountId: admin.id, providerId: "credential", userId: admin.id, password: passwordHash },
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 userPasswordHash = await hashPassword("user123456");
const testUser = await prisma.user.upsert({ const testUser = await prisma.user.upsert({
where: { email: "jean@vineye.app" }, where: { email: "jean@vineye.app" },
update: {}, update: {},
create: { create: { id: "user-001", name: "Jean Dupont", email: "jean@vineye.app", role: "USER", emailVerified: true, xp: 1250, level: 4 },
id: "user-001",
name: "Jean Dupont",
email: "jean@vineye.app",
role: "USER",
emailVerified: true,
xp: 1250,
level: 4,
},
}); });
await prisma.account.upsert({ await prisma.account.upsert({
where: { id: "account-user-001" }, where: { id: "account-user-001" },
update: {}, update: {},
create: { create: { id: "account-user-001", accountId: testUser.id, providerId: "credential", userId: testUser.id, password: userPasswordHash },
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"); // ── 2. Diseases (enriched) ──
// 3. Diseases
const diseases = [ const diseases = [
{ {
slug: "mildiou", slug: "mildiou",
name: "Mildiou", name: "Mildiou", nameEn: "Downy Mildew", scientificName: "Plasmopara viticola",
nameEn: "Downy Mildew", type: "FUNGAL" as const, severity: "HIGH" as const,
scientificName: "Plasmopara viticola", description: "Le mildiou est cause par le champignon Plasmopara viticola. Il attaque toutes les parties vertes de la vigne, principalement les feuilles.",
type: "FUNGAL" as const, descriptionEn: "Downy mildew is caused by the fungus Plasmopara viticola. It attacks all green parts of the vine, mainly the leaves.",
severity: "HIGH" as const, symptoms: ["Taches jaunes huileuses sur la face superieure des feuilles", "Duvet blanc cotonneux sur la face inferieure", "Dessechement et chute prematuree des feuilles"],
description: symptomsEn: ["Oily yellow spots on the upper surface of leaves", "White cottony down on the underside", "Drying and premature leaf drop"],
"Le mildiou est cause par le champignon Plasmopara viticola. Il attaque toutes les parties vertes de la vigne, principalement les feuilles.", treatment: "Traitement preventif a base de cuivre (bouillie bordelaise). Appliquer avant les pluies, renouveler tous les 10-14 jours.",
descriptionEn: treatmentEn: "Preventive copper-based treatment (Bordeaux mixture). Apply before rain, renew every 10-14 days.",
"Downy mildew is caused by the fungus Plasmopara viticola. It attacks all green parts of the vine, mainly the leaves.", season: "Mai a aout", seasonEn: "May to August",
symptoms: [ iconName: "droplets", iconColor: "#BA7517", bgColor: "#FAEEDA",
"Taches jaunes huileuses sur la face superieure des feuilles", startMonth: 5, endMonth: 8, peakMonth: 6,
"Duvet blanc cotonneux sur la face inferieure", conditions: ["Humidite superieure a 80%", "Temperatures entre 18 et 25°C", "Pluies frequentes au printemps"],
"Dessechement et chute prematuree des feuilles", 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", slug: "oidium",
name: "Oidium", name: "Oidium", nameEn: "Powdery Mildew", scientificName: "Erysiphe necator",
nameEn: "Powdery Mildew", type: "FUNGAL" as const, severity: "HIGH" as const,
scientificName: "Erysiphe necator", description: "L'oidium est cause par Erysiphe necator. Il se developpe par temps chaud et sec.",
type: "FUNGAL" as const, descriptionEn: "Powdery mildew is caused by Erysiphe necator. It develops in warm, dry weather.",
severity: "HIGH" as const, symptoms: ["Poudre blanche-grisatre sur feuilles et grappes", "Baies qui eclatent ou se dessechent"],
description: symptomsEn: ["White-grey powder on leaves and clusters", "Berries that crack or dry out"],
"L'oidium est cause par Erysiphe necator. Il se developpe par temps chaud et sec, contrairement au mildiou.", treatment: "Soufre en poudrage ou pulverisation. Traitements preventifs des le debourrement.",
descriptionEn: treatmentEn: "Sulfur dusting or spraying. Preventive treatments from bud break.",
"Powdery mildew is caused by Erysiphe necator. It develops in hot, dry weather, unlike downy mildew.", season: "Avril a septembre", seasonEn: "April to September",
symptoms: [ iconName: "wind", iconColor: "#534AB7", bgColor: "#EEEDFE",
"Poudre blanche-grisatre sur feuilles et grappes", startMonth: 4, endMonth: 9, peakMonth: 7,
"Baies qui eclatent ou se dessechent", 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", slug: "black-rot",
name: "Black rot", name: "Black rot", nameEn: "Black Rot", scientificName: "Guignardia bidwellii",
nameEn: "Black Rot", type: "FUNGAL" as const, severity: "HIGH" as const,
scientificName: "Guignardia bidwellii", description: "Le black rot est cause par Guignardia bidwellii. Il provoque des degats importants sur les baies.",
type: "FUNGAL" as const, descriptionEn: "Black rot is caused by Guignardia bidwellii. It causes significant damage to berries.",
severity: "HIGH" as const, symptoms: ["Taches brunes circulaires bordees de noir sur les feuilles", "Baies momifiees, noires et ridees"],
description: symptomsEn: ["Circular brown spots bordered with black on leaves", "Mummified, black and wrinkled berries"],
"Le black rot est cause par Guignardia bidwellii. Il provoque des degats importants sur les baies.", treatment: "Eliminer les baies momifiees. Traitements fongicides preventifs au printemps.",
descriptionEn: treatmentEn: "Remove mummified berries. Preventive fungicide treatments in spring.",
"Black rot is caused by Guignardia bidwellii. It causes significant damage to berries.", season: "Mai a juillet", seasonEn: "May to July",
symptoms: [ iconName: "circle", iconColor: "#5F5E5A", bgColor: "#F1EFE8",
"Taches brunes circulaires bordees de noir sur les feuilles", startMonth: 5, endMonth: 8, peakMonth: 6,
"Baies momifiees, noires et ridees", 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", slug: "esca",
name: "Esca", name: "Esca", nameEn: "Esca Disease", scientificName: "Phaeomoniella chlamydospora",
nameEn: "Esca Disease", type: "FUNGAL" as const, severity: "MEDIUM" as const,
scientificName: "Phaeomoniella chlamydospora", description: "L'esca est un complexe de maladies du bois cause par plusieurs champignons.",
type: "FUNGAL" as const, descriptionEn: "Esca is a complex of wood diseases caused by several fungi.",
severity: "MEDIUM" as const, symptoms: ["Decolorations entre les nervures des feuilles (aspect tigre)", "Dessechement brutal du feuillage (apoplexie)"],
description: symptomsEn: ["Discoloration between leaf veins (tiger stripe pattern)", "Sudden drying of foliage (apoplexy)"],
"L'esca est un complexe de maladies du bois cause par plusieurs champignons. Maladie chronique qui peut tuer le cep.", treatment: "Aucun traitement curatif. Recepage du cep atteint. Proteger les plaies de taille.",
descriptionEn: treatmentEn: "No curative treatment. Cutting back affected vine. Protect pruning wounds.",
"Esca is a complex of wood diseases caused by several fungi. A chronic disease that can kill the vine.", season: "Juin a septembre", seasonEn: "June to September",
symptoms: [ iconName: "tree-deciduous", iconColor: "#993C1D", bgColor: "#FAECE7",
"Decolorations entre les nervures des feuilles (aspect tigre)", startMonth: 6, endMonth: 9, peakMonth: 7,
"Dessechement brutal du feuillage (apoplexie)", 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", slug: "botrytis",
name: "Botrytis", name: "Botrytis", nameEn: "Botrytis (Grey Mold)", scientificName: "Botrytis cinerea",
nameEn: "Botrytis (Gray Mold)", type: "FUNGAL" as const, severity: "MEDIUM" as const,
scientificName: "Botrytis cinerea", description: "La pourriture grise est causee par Botrytis cinerea. Elle attaque les grappes a maturite.",
type: "FUNGAL" as const, descriptionEn: "Grey mold is caused by Botrytis cinerea. It attacks clusters at maturity.",
severity: "MEDIUM" as const, symptoms: ["Pourriture molle grise sur les baies", "Feutrage gris caracteristique sur les grappes"],
description: symptomsEn: ["Soft grey rot on berries", "Characteristic grey felt on clusters"],
"La pourriture grise est causee par Botrytis cinerea. Elle attaque les grappes a maturite.", treatment: "Favoriser l'aeration des grappes. Effeuillage. Traitements anti-botrytis.",
descriptionEn: treatmentEn: "Promote cluster aeration. Leaf removal. Anti-botrytis treatments.",
"Gray mold is caused by Botrytis cinerea. It attacks clusters at maturity.", season: "Aout a vendanges", seasonEn: "August to harvest",
symptoms: [ iconName: "cloud-rain", iconColor: "#185FA5", bgColor: "#E6F1FB",
"Pourriture molle grise sur les baies", startMonth: 7, endMonth: 10, peakMonth: 9,
"Feutrage gris caracteristique sur les grappes", 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", slug: "flavescence-doree",
name: "Flavescence doree", name: "Flavescence doree", nameEn: "Flavescence Doree", scientificName: "Phytoplasma vitis",
nameEn: "Flavescence Doree", type: "BACTERIAL" as const, severity: "HIGH" as const,
scientificName: "Phytoplasma vitis", description: "Maladie a phytoplasme transmise par la cicadelle Scaphoideus titanus. Declaration obligatoire.",
type: "BACTERIAL" as const, descriptionEn: "Phytoplasma disease transmitted by the leafhopper Scaphoideus titanus. Mandatory reporting.",
severity: "HIGH" as const, symptoms: ["Enroulement des feuilles avec coloration jaune ou rouge", "Non-aoutement des rameaux"],
description: symptomsEn: ["Leaf rolling with yellow or red coloration", "Non-lignification of shoots"],
"Maladie a phytoplasme transmise par la cicadelle Scaphoideus titanus. Maladie reglementee, declaration obligatoire.", treatment: "Arrachage obligatoire des ceps contamines. Traitement insecticide contre la cicadelle.",
descriptionEn: treatmentEn: "Mandatory uprooting of contaminated vines. Insecticide treatment against the vector.",
"Phytoplasma disease transmitted by the Scaphoideus titanus leafhopper. Regulated disease, mandatory reporting.", season: "Juillet a octobre", seasonEn: "July to October",
symptoms: [ iconName: "bug", iconColor: "#A32D2D", bgColor: "#FCEBEB",
"Enroulement des feuilles avec coloration jaune ou rouge selon le cepage", startMonth: 6, endMonth: 10, peakMonth: 8,
"Non-aoutement des rameaux (restent caoutchouteux)", 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", slug: "chlorose-ferrique",
name: "Chlorose ferrique", name: "Chlorose ferrique", nameEn: "Iron Chlorosis", scientificName: "",
nameEn: "Iron Chlorosis", type: "ABIOTIC" as const, severity: "LOW" as const,
scientificName: "", description: "Jaunissement des feuilles du a une carence en fer, souvent lie a un sol trop calcaire.",
type: "ABIOTIC" as const, descriptionEn: "Leaf yellowing due to iron deficiency, often linked to overly calcareous soil.",
severity: "LOW" as const, symptoms: ["Jaunissement entre les nervures, nervures restant vertes", "Affaiblissement general de la vigne"],
description: symptomsEn: ["Yellowing between veins, veins remaining green", "General weakening of the vine"],
"Jaunissement des feuilles du a une carence en fer, souvent lie a un sol trop calcaire.", treatment: "Apport de chelates de fer. Choix d'un porte-greffe adapte aux sols calcaires.",
descriptionEn: treatmentEn: "Iron chelate application. Choose rootstock adapted to calcareous soils.",
"Yellowing of leaves due to iron deficiency, often linked to overly calcareous soil.", season: "Printemps", seasonEn: "Spring",
symptoms: [ iconName: "leaf", iconColor: "#639922", bgColor: "#EAF3DE",
"Jaunissement entre les nervures, nervures restant vertes", startMonth: 4, endMonth: 7, peakMonth: 5,
"Affaiblissement general de la vigne", 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) { for (const { images, ...diseaseData } of diseases) {
await prisma.disease.upsert({ const disease = await prisma.disease.upsert({
where: { slug: disease.slug }, where: { slug: diseaseData.slug },
update: disease, update: diseaseData,
create: disease, 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 = [ const guides = [
{ {
slug: "reconnaitre-feuille-saine", slug: "reconnaitre-feuille-saine",
title: "Reconnaitre une feuille saine", title: "Reconnaitre une feuille saine", titleEn: "Recognizing a Healthy Leaf",
titleEn: "Recognizing a Healthy Leaf", subtitle: "Les bases pour identifier une vigne en bonne sante", subtitleEn: "Basics for identifying a healthy vine",
subtitle: "Les bases pour identifier une vigne en bonne sante", content: "Guide complet pour reconnaitre une feuille de vigne saine.", contentEn: "Complete guide to recognizing a healthy vine leaf.",
subtitleEn: "Basics for identifying a healthy vine", category: "diagnostic", iconName: "leaf", iconColor: "#1D9E75", bgColor: "#E1F5EE", order: 0,
content: readTime: 5, coverImage: "https://images.unsplash.com/photo-1596142780450-01a1f79c400c?w=800&h=600&fit=crop",
"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.", sections: [
contentEn: { 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 },
"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.", { 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 },
category: "diagnostic", { 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 },
iconName: "leaf", ],
iconColor: "#1D9E75",
bgColor: "#E1F5EE",
order: 0,
}, },
{ {
slug: "calendrier-traitement", slug: "calendrier-traitement",
title: "Calendrier de traitement", title: "Calendrier de traitement", titleEn: "Treatment Calendar",
titleEn: "Treatment Calendar", subtitle: "Quand et comment traiter tout au long de la saison", subtitleEn: "When and how to treat throughout the season",
subtitle: "Quand et comment traiter tout au long de la saison", content: "Calendrier detaille des traitements phytosanitaires pour la vigne.", contentEn: "Detailed calendar of phytosanitary treatments for vines.",
subtitleEn: "When and how to treat throughout the season", category: "traitement", iconName: "calendar", iconColor: "#185FA5", bgColor: "#E6F1FB", order: 1,
content: readTime: 8, coverImage: "https://images.unsplash.com/photo-1560493676-04071c5f467b?w=800&h=600&fit=crop",
"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.", sections: [
contentEn: { 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 },
"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.", { 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 },
category: "traitement", { 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 },
iconName: "calendar", { 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 },
iconColor: "#185FA5", ],
bgColor: "#E6F1FB",
order: 1,
}, },
{ {
slug: "cepages-bordelais", slug: "cepages-bordelais",
title: "Les cepages bordelais", title: "Les cepages bordelais", titleEn: "Bordeaux Grape Varieties",
titleEn: "Bordeaux Grape Varieties", subtitle: "Guide des principales varietes de la region bordelaise", subtitleEn: "Guide to the main varieties of the Bordeaux region",
subtitle: "Guide des principales varietes de la region bordelaise", content: "Decouvrez les principaux cepages bordelais.", contentEn: "Discover the main Bordeaux grape varieties.",
subtitleEn: "Guide to the main varieties of the Bordeaux region", category: "cepages", iconName: "grape", iconColor: "#534AB7", bgColor: "#EEEDFE", order: 2,
content: readTime: 6, coverImage: "https://images.unsplash.com/photo-1567306301408-9b74779a11af?w=800&h=600&fit=crop",
"Decouvrez les principaux cepages bordelais : Merlot, Cabernet Sauvignon, Cabernet Franc, Petit Verdot, Malbec pour les rouges, et Sauvignon Blanc, Semillon, Muscadelle pour les blancs.", sections: [
contentEn: { 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 },
"Discover the main Bordeaux grape varieties: Merlot, Cabernet Sauvignon, Cabernet Franc, Petit Verdot, Malbec for reds, and Sauvignon Blanc, Semillon, Muscadelle for whites.", { 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 },
category: "cepages", { 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 },
iconName: "grape", ],
iconColor: "#7C3AED",
bgColor: "#F3EEFF",
order: 2,
}, },
]; ];
for (const guide of guides) { for (const { sections, ...guideData } of guides) {
await prisma.guide.upsert({ const guide = await prisma.guide.upsert({
where: { slug: guide.slug }, where: { slug: guideData.slug },
update: guide, update: guideData,
create: guide, 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 = [ const alerts = [
{ { 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: "Risque mildiou eleve", { 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 },
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,
},
]; ];
for (const alert of alerts) { for (const alert of alerts) {
await prisma.seasonAlert.create({ data: alert }); 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 allDiseases = await prisma.disease.findMany({ select: { id: true } });
const now = new Date(); const now = new Date();
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const daysAgo = Math.floor(Math.random() * 30);
const date = new Date(now); const date = new Date(now);
date.setDate(date.getDate() - daysAgo); date.setDate(date.getDate() - Math.floor(Math.random() * 30));
const randomDisease =
allDiseases[Math.floor(Math.random() * allDiseases.length)];
await prisma.scan.create({ await prisma.scan.create({
data: { data: {
userId: i < 7 ? testUser.id : admin.id, 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, confidence: 0.6 + Math.random() * 0.35,
latitude: 44.8378 + (Math.random() - 0.5) * 0.1, latitude: 44.8378 + (Math.random() - 0.5) * 0.1,
longitude: -0.5792 + (Math.random() - 0.5) * 0.1, longitude: -0.5792 + (Math.random() - 0.5) * 0.1,
@ -412,10 +342,5 @@ async function main() {
} }
main() main()
.catch((e) => { .catch((e) => { console.error(e); process.exit(1); })
console.error(e); .finally(async () => { await prisma.$disconnect(); });
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View file

@ -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

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View file

@ -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

View file

@ -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

Some files were not shown because too many files have changed in this diff Show more