Skip to content

Commit b90e4ce

Browse files
Florian Flahautclaude
andcommitted
Add seed buy links and city-based weather
- Buy links per plant (price comparator + retailers) on fiche and a compact 🛒 on cards; pure shopping.ts + tests - Weather: city search via Open-Meteo geocoding (no key) in addition to geolocation; shows resolved location label; geocode helpers + tests Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 20921c3 commit b90e4ce

8 files changed

Lines changed: 343 additions & 90 deletions

File tree

src/app/plantes/[id]/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import AddToGarden from "@/components/AddToGarden";
1111
import PlantCalendar from "@/components/PlantCalendar";
1212
import PlantAvatar from "@/components/PlantAvatar";
13+
import BuyLinksSection from "@/components/BuyLinksSection";
1314

1415
export function generateStaticParams() {
1516
return PLANTS.map((p) => ({ id: p.id }));
@@ -73,6 +74,9 @@ export default async function PlantePage(props: PageProps<"/plantes/[id]">) {
7374
<p className="text-sm text-amber-900/90 dark:text-amber-200/90">{plant.conseils}</p>
7475
</section>
7576

77+
{/* Où acheter */}
78+
<BuyLinksSection plant={plant} />
79+
7680
{/* Soins au fil de la saison */}
7781
{plant.soins.length > 0 && (
7882
<section className="rounded-xl border border-emerald-100 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4">

src/components/BuyLinksSection.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { Plant } from "@/lib/types";
2+
import { buyLinks } from "@/lib/shopping";
3+
4+
export default function BuyLinksSection({ plant }: { plant: Plant }) {
5+
const links = buyLinks(plant);
6+
return (
7+
<section className="rounded-xl border border-emerald-100 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
8+
<h3 className="mb-1 font-bold text-emerald-800 dark:text-emerald-100">
9+
🛒 Où acheter les {plant.category === "fruit" ? "plants" : "graines"}
10+
</h3>
11+
<p className="mb-3 text-sm text-emerald-800/80 dark:text-emerald-100/80">
12+
Comparez les prix pour trouver le moins cher.
13+
</p>
14+
<div className="flex flex-wrap gap-2">
15+
{links.map((l) => (
16+
<a
17+
key={l.url}
18+
href={l.url}
19+
target="_blank"
20+
rel="noopener noreferrer"
21+
className={`rounded-full px-3 py-1.5 text-sm font-medium transition ${
22+
l.compare
23+
? "bg-emerald-600 text-white hover:bg-emerald-700"
24+
: "bg-emerald-50 text-emerald-800 hover:bg-emerald-100 dark:bg-zinc-800 dark:text-emerald-100 dark:hover:bg-zinc-700"
25+
}`}
26+
>
27+
{l.compare ? "💶 " : ""}
28+
{l.label}
29+
</a>
30+
))}
31+
</div>
32+
</section>
33+
);
34+
}

src/components/PlantCard.tsx

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Link from "next/link";
22
import { Plant, CATEGORY_LABELS, DIFFICULTY_LABELS } from "@/lib/types";
33
import { ActionType } from "@/lib/types";
44
import { ACTION_COLORS } from "@/lib/calendar";
5+
import { buyLinks } from "@/lib/shopping";
56
import PlantAvatar from "./PlantAvatar";
67

78
const DIFFICULTY_STYLE: Record<string, string> = {
@@ -17,34 +18,45 @@ export default function PlantCard({
1718
plant: Plant;
1819
badge?: ActionType;
1920
}) {
21+
const compare = buyLinks(plant).find((l) => l.compare);
22+
2023
return (
21-
<Link
22-
href={`/plantes/${plant.id}`}
23-
className="group flex flex-col gap-2 rounded-xl border border-emerald-100 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4 shadow-sm transition hover:border-emerald-300 dark:hover:border-zinc-600 hover:shadow-md"
24-
>
25-
<div className="flex items-start justify-between">
26-
<PlantAvatar emoji={plant.emoji} category={plant.category} size="md" />
27-
{badge && (
24+
<div className="group flex flex-col gap-2 rounded-xl border border-emerald-100 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4 shadow-sm transition hover:border-emerald-300 dark:hover:border-zinc-600 hover:shadow-md">
25+
<Link href={`/plantes/${plant.id}`} className="flex flex-col gap-2">
26+
<div className="flex items-start justify-between">
27+
<PlantAvatar emoji={plant.emoji} category={plant.category} size="md" />
28+
{badge && (
29+
<span
30+
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${ACTION_COLORS[badge].bg} ${ACTION_COLORS[badge].text}`}
31+
>
32+
{ACTION_COLORS[badge].label}
33+
</span>
34+
)}
35+
</div>
36+
<h3 className="font-semibold text-emerald-900 dark:text-emerald-50 group-hover:text-emerald-700">
37+
{plant.nom}
38+
</h3>
39+
<div className="flex flex-wrap gap-1.5 text-xs">
40+
<span className="rounded-full bg-emerald-50 dark:bg-zinc-800 px-2 py-0.5 text-emerald-700 dark:text-emerald-300">
41+
{CATEGORY_LABELS[plant.category]}
42+
</span>
2843
<span
29-
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${ACTION_COLORS[badge].bg} ${ACTION_COLORS[badge].text}`}
44+
className={`rounded-full px-2 py-0.5 ${DIFFICULTY_STYLE[plant.difficulty]}`}
3045
>
31-
{ACTION_COLORS[badge].label}
46+
{DIFFICULTY_LABELS[plant.difficulty]}
3247
</span>
33-
)}
34-
</div>
35-
<h3 className="font-semibold text-emerald-900 dark:text-emerald-50 group-hover:text-emerald-700">
36-
{plant.nom}
37-
</h3>
38-
<div className="mt-auto flex flex-wrap gap-1.5 text-xs">
39-
<span className="rounded-full bg-emerald-50 dark:bg-zinc-800 px-2 py-0.5 text-emerald-700 dark:text-emerald-300">
40-
{CATEGORY_LABELS[plant.category]}
41-
</span>
42-
<span
43-
className={`rounded-full px-2 py-0.5 ${DIFFICULTY_STYLE[plant.difficulty]}`}
48+
</div>
49+
</Link>
50+
{compare && (
51+
<a
52+
href={compare.url}
53+
target="_blank"
54+
rel="noopener noreferrer"
55+
className="mt-auto inline-flex w-fit items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 transition hover:bg-emerald-100 dark:bg-zinc-800 dark:text-emerald-300 dark:hover:bg-zinc-700"
4456
>
45-
{DIFFICULTY_LABELS[plant.difficulty]}
46-
</span>
47-
</div>
48-
</Link>
57+
🛒 Acheter
58+
</a>
59+
)}
60+
</div>
4961
);
5062
}

src/components/WeatherAdvice.tsx

Lines changed: 115 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import { useCallback, useEffect, useState } from "react";
44
import {
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+
3038
type 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+
4661
export 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&apos;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

Comments
 (0)