33import { useCallback , useEffect , useState } from "react" ;
44import {
55 openMeteoUrl ,
6+ geocodeUrl ,
7+ firstGeocode ,
68 summarize ,
79 wateringAdvice ,
810 WaterLevel ,
@@ -27,33 +29,47 @@ const LEVEL_EMOJI: Record<WaterLevel, string> = {
2729 urgent : "🥵" ,
2830} ;
2931
32+ interface SavedGeo {
33+ lat : number ;
34+ lon : number ;
35+ label ?: string ;
36+ }
37+
3038type State =
3139 | { kind : "idle" }
3240 | { kind : "loading" }
33- | { kind : "ready" ; summary : ForecastSummary }
34- | { kind : "denied" }
41+ | { kind : "ready" ; summary : ForecastSummary ; label : string }
3542 | { kind : "error" ; message : string } ;
3643
37- function readSavedCoords ( ) : { lat : number ; lon : number } | null {
44+ function readSaved ( ) : SavedGeo | null {
3845 try {
3946 const raw = localStorage . getItem ( GEO_KEY ) ;
40- return raw ? JSON . parse ( raw ) : null ;
47+ return raw ? ( JSON . parse ( raw ) as SavedGeo ) : null ;
4148 } catch {
4249 return null ;
4350 }
4451}
4552
53+ function save ( geo : SavedGeo ) {
54+ try {
55+ localStorage . setItem ( GEO_KEY , JSON . stringify ( geo ) ) ;
56+ } catch {
57+ // ignore
58+ }
59+ }
60+
4661export default function WeatherAdvice ( ) {
4762 const [ state , setState ] = useState < State > ( { kind : "idle" } ) ;
63+ const [ city , setCity ] = useState ( "" ) ;
4864
49- const load = useCallback ( async ( lat : number , lon : number ) => {
65+ const load = useCallback ( async ( lat : number , lon : number , label : string ) => {
5066 setState ( { kind : "loading" } ) ;
5167 try {
5268 const res = await fetch ( openMeteoUrl ( lat , lon ) ) ;
5369 if ( ! res . ok ) throw new Error ( "Météo indisponible" ) ;
5470 const summary = summarize ( await res . json ( ) ) ;
5571 if ( ! summary ) throw new Error ( "Données météo incomplètes" ) ;
56- setState ( { kind : "ready" , summary } ) ;
72+ setState ( { kind : "ready" , summary, label } ) ;
5773 } catch ( e ) {
5874 setState ( {
5975 kind : "error" ,
@@ -62,6 +78,31 @@ export default function WeatherAdvice() {
6278 }
6379 } , [ ] ) ;
6480
81+ const searchCity = useCallback (
82+ async ( name : string ) => {
83+ const q = name . trim ( ) ;
84+ if ( ! q ) return ;
85+ setState ( { kind : "loading" } ) ;
86+ try {
87+ const res = await fetch ( geocodeUrl ( q ) ) ;
88+ if ( ! res . ok ) throw new Error ( "Recherche de ville impossible" ) ;
89+ const place = firstGeocode ( await res . json ( ) ) ;
90+ if ( ! place ) {
91+ setState ( { kind : "error" , message : `Ville « ${ q } » introuvable` } ) ;
92+ return ;
93+ }
94+ save ( { lat : place . lat , lon : place . lon , label : place . label } ) ;
95+ load ( place . lat , place . lon , place . label ) ;
96+ } catch ( e ) {
97+ setState ( {
98+ kind : "error" ,
99+ message : e instanceof Error ? e . message : String ( e ) ,
100+ } ) ;
101+ }
102+ } ,
103+ [ load ]
104+ ) ;
105+
65106 const locate = useCallback ( ( ) => {
66107 if ( typeof navigator === "undefined" || ! navigator . geolocation ) {
67108 setState ( { kind : "error" , message : "Géolocalisation non disponible" } ) ;
@@ -70,89 +111,98 @@ export default function WeatherAdvice() {
70111 setState ( { kind : "loading" } ) ;
71112 navigator . geolocation . getCurrentPosition (
72113 ( pos ) => {
73- const coords = {
114+ const geo : SavedGeo = {
74115 lat : pos . coords . latitude ,
75116 lon : pos . coords . longitude ,
117+ label : "Ma position" ,
76118 } ;
77- try {
78- localStorage . setItem ( GEO_KEY , JSON . stringify ( coords ) ) ;
79- } catch {
80- // ignore
81- }
82- load ( coords . lat , coords . lon ) ;
119+ save ( geo ) ;
120+ load ( geo . lat , geo . lon , geo . label ! ) ;
83121 } ,
84- ( ) => setState ( { kind : "denied " } ) ,
122+ ( ) => setState ( { kind : "error" , message : "Localisation refusée " } ) ,
85123 { maximumAge : 3_600_000 , timeout : 10_000 }
86124 ) ;
87125 } , [ load ] ) ;
88126
89127 useEffect ( ( ) => {
90- const saved = readSavedCoords ( ) ;
91- // Différé hors de la phase synchrone de l'effet (pas de setState direct).
92- if ( saved ) queueMicrotask ( ( ) => load ( saved . lat , saved . lon ) ) ;
128+ const saved = readSaved ( ) ;
129+ if ( saved ) {
130+ // Différé hors de la phase synchrone de l'effet.
131+ queueMicrotask ( ( ) =>
132+ load ( saved . lat , saved . lon , saved . label ?? "Position enregistrée" )
133+ ) ;
134+ }
93135 } , [ load ] ) ;
94136
95- if ( state . kind === "idle" || state . kind === "denied" ) {
96- return (
137+ const advice =
138+ state . kind === "ready" ? wateringAdvice ( state . summary ) : null ;
139+
140+ return (
141+ < div className = "space-y-2" >
142+ { /* Barre de localisation */ }
97143 < div className = "flex flex-wrap items-center gap-2 rounded-xl border border-emerald-100 bg-white p-3 text-sm dark:border-zinc-800 dark:bg-zinc-900" >
98144 < span className = "text-emerald-800/80 dark:text-emerald-100/80" >
99- ⛅ Conseils d'arrosage selon la météo locale
145+ ⛅ Arrosage selon la météo
100146 </ span >
147+ < form
148+ onSubmit = { ( e ) => {
149+ e . preventDefault ( ) ;
150+ searchCity ( city ) ;
151+ } }
152+ className = "flex items-center gap-1"
153+ >
154+ < input
155+ value = { city }
156+ onChange = { ( e ) => setCity ( e . target . value ) }
157+ placeholder = "Votre ville"
158+ className = "w-36 rounded-lg border border-emerald-200 px-2 py-1 text-sm outline-none focus:border-emerald-400 dark:border-zinc-700 dark:focus:border-emerald-500"
159+ />
160+ < button
161+ type = "submit"
162+ className = "rounded-full bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-700"
163+ >
164+ OK
165+ </ button >
166+ </ form >
101167 < button
102168 onClick = { locate }
103- className = "rounded-full bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-700"
169+ title = "Utiliser ma position"
170+ className = "rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-800 hover:bg-emerald-100 dark:bg-zinc-800 dark:text-emerald-100 dark:hover:bg-zinc-700"
104171 >
105- { state . kind === "denied"
106- ? "Réessayer la localisation"
107- : "Activer la localisation" }
172+ 📍 Ma position
108173 </ button >
109- { state . kind === "denied " && (
110- < span className = "text-xs text-rose-600 dark:text-rose -300" >
111- Localisation refusée.
174+ { state . kind === "ready " && (
175+ < span className = "text-xs text-emerald-700/70 dark:text-emerald -300/70 " >
176+ { state . label }
112177 </ span >
113178 ) }
114179 </ div >
115- ) ;
116- }
117180
118- if ( state . kind === "loading" ) {
119- return (
120- < p className = "text-sm text-emerald-700/60 dark:text-emerald-300/60" >
121- Météo en cours…
122- </ p >
123- ) ;
124- }
181+ { state . kind === "loading" && (
182+ < p className = "text-sm text-emerald-700/60 dark:text-emerald-300/60" >
183+ Météo en cours…
184+ </ p >
185+ ) }
125186
126- if ( state . kind === "error" ) {
127- return (
128- < div className = "flex flex-wrap items-center gap-2 rounded-xl border border-emerald-100 bg-white p-3 text-sm dark:border-zinc-800 dark:bg-zinc-900" >
129- < span className = "text-rose-600 dark:text-rose-300" >
130- Météo indisponible ({ state . message } ).
131- </ span >
132- < button
133- onClick = { locate }
134- className = "rounded-full bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-700"
135- >
136- Réessayer
137- </ button >
138- </ div >
139- ) ;
140- }
187+ { state . kind === "error" && (
188+ < p className = "text-sm text-rose-600 dark:text-rose-300" >
189+ { state . message } .
190+ </ p >
191+ ) }
141192
142- const advice = wateringAdvice ( state . summary ) ;
143- return (
144- < div
145- className = { `rounded-xl border p-4 ${ LEVEL_STYLE [ advice . level ] } ` }
146- >
147- < p className = "flex items-center gap-2 font-semibold" >
148- < span className = "text-lg" > { LEVEL_EMOJI [ advice . level ] } </ span >
149- { advice . title }
150- </ p >
151- < p className = "mt-1 text-sm opacity-90" > { advice . detail } </ p >
152- < p className = "mt-2 text-xs opacity-70" >
153- Prévision 48 h : { state . summary . precipitationMm . toFixed ( 1 ) } mm de pluie,
154- max { Math . round ( state . summary . tempMaxC ) } °C.
155- </ p >
193+ { advice && state . kind === "ready" && (
194+ < div className = { `rounded-xl border p-4 ${ LEVEL_STYLE [ advice . level ] } ` } >
195+ < p className = "flex items-center gap-2 font-semibold" >
196+ < span className = "text-lg" > { LEVEL_EMOJI [ advice . level ] } </ span >
197+ { advice . title }
198+ </ p >
199+ < p className = "mt-1 text-sm opacity-90" > { advice . detail } </ p >
200+ < p className = "mt-2 text-xs opacity-70" >
201+ Prévision 48 h : { state . summary . precipitationMm . toFixed ( 1 ) } mm de
202+ pluie, max { Math . round ( state . summary . tempMaxC ) } °C.
203+ </ p >
204+ </ div >
205+ ) }
156206 </ div >
157207 ) ;
158208}
0 commit comments