@salesforce/webapp-template-feature-react-agentforce-conversation-client-experimental 1.109.8 → 1.110.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CHANGELOG.md +16 -0
- package/dist/force-app/main/default/webapplications/feature-react-agentforce-conversation-client/package.json +3 -3
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/package.json +2 -2
- package/dist/.a4drules/skills/building-interactive-map/SKILL.md +0 -92
- package/dist/.a4drules/skills/building-interactive-map/implementation/geocoding.md +0 -245
- package/dist/.a4drules/skills/building-interactive-map/implementation/leaflet-map.md +0 -279
- package/dist/.a4drules/skills/building-weather-widget/SKILL.md +0 -65
- package/dist/.a4drules/skills/building-weather-widget/implementation/weather-hook.md +0 -258
- package/dist/.a4drules/skills/building-weather-widget/implementation/weather-ui.md +0 -216
package/dist/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [1.110.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.109.9...v1.110.0) (2026-03-19)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## [1.109.9](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.109.8...v1.109.9) (2026-03-19)
|
|
15
|
+
|
|
16
|
+
**Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
6
22
|
## [1.109.8](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.109.7...v1.109.8) (2026-03-19)
|
|
7
23
|
|
|
8
24
|
**Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"graphql:schema": "node scripts/get-graphql-schema.mjs"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@salesforce/sdk-data": "^1.
|
|
19
|
-
"@salesforce/webapp-experimental": "^1.
|
|
18
|
+
"@salesforce/sdk-data": "^1.110.0",
|
|
19
|
+
"@salesforce/webapp-experimental": "^1.110.0",
|
|
20
20
|
"@tailwindcss/vite": "^4.1.17",
|
|
21
21
|
"class-variance-authority": "^0.7.1",
|
|
22
22
|
"clsx": "^2.1.1",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"@graphql-eslint/eslint-plugin": "^4.1.0",
|
|
43
43
|
"@graphql-tools/utils": "^11.0.0",
|
|
44
44
|
"@playwright/test": "^1.49.0",
|
|
45
|
-
"@salesforce/vite-plugin-webapp-experimental": "^1.
|
|
45
|
+
"@salesforce/vite-plugin-webapp-experimental": "^1.110.0",
|
|
46
46
|
"@testing-library/jest-dom": "^6.6.3",
|
|
47
47
|
"@testing-library/react": "^16.1.0",
|
|
48
48
|
"@testing-library/user-event": "^14.5.2",
|
package/dist/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/webapp-template-base-sfdx-project-experimental",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.110.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@salesforce/webapp-template-base-sfdx-project-experimental",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.110.0",
|
|
10
10
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
11
11
|
"devDependencies": {
|
|
12
12
|
"@lwc/eslint-plugin-lwc": "^3.3.0",
|
package/dist/package.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/webapp-template-feature-react-agentforce-conversation-client-experimental",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.110.0",
|
|
4
4
|
"description": "Embedded Agentforce conversation client feature for web applications",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
6
|
"author": "",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"clean": "rm -rf dist"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@salesforce/agentforce-conversation-client": "^1.
|
|
30
|
+
"@salesforce/agentforce-conversation-client": "^1.110.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/react": "^19.2.7",
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: building-interactive-map
|
|
3
|
-
description: Adds interactive Leaflet maps with geocoded markers to React pages. Use when the user asks to add a map, show locations on a map, display property pins, add a map view, or integrate mapping into the web application.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Interactive Map
|
|
7
|
-
|
|
8
|
-
## When to Use
|
|
9
|
-
|
|
10
|
-
Use this skill when:
|
|
11
|
-
- Adding an interactive map to a page (property search, store locator, location detail)
|
|
12
|
-
- Displaying one or more markers/pins on a map
|
|
13
|
-
- Converting addresses to map coordinates (geocoding)
|
|
14
|
-
- Building a split-panel layout with map + list
|
|
15
|
-
|
|
16
|
-
---
|
|
17
|
-
|
|
18
|
-
## Step 1 — Determine the map use case
|
|
19
|
-
|
|
20
|
-
Identify the scenario:
|
|
21
|
-
|
|
22
|
-
- **Multi-marker search** — map shows multiple pins alongside a scrollable list (e.g. property search, store locator)
|
|
23
|
-
- **Single-location detail** — map shows one pin for a specific address (e.g. property detail, contact page)
|
|
24
|
-
- **Static overview** — map centered on a region with no interactive markers
|
|
25
|
-
|
|
26
|
-
If unclear, ask:
|
|
27
|
-
|
|
28
|
-
> "Should the map show a single location, multiple markers from a list, or just a general area overview?"
|
|
29
|
-
|
|
30
|
-
---
|
|
31
|
-
|
|
32
|
-
## Step 2 — Install dependencies
|
|
33
|
-
|
|
34
|
-
The map requires `leaflet` and `react-leaflet`. Read `implementation/leaflet-map.md` for the exact dependency setup.
|
|
35
|
-
|
|
36
|
-
---
|
|
37
|
-
|
|
38
|
-
## Step 3 — Choose implementation path
|
|
39
|
-
|
|
40
|
-
Read the corresponding guide:
|
|
41
|
-
|
|
42
|
-
- **Map component** — read `implementation/leaflet-map.md` for building the reusable `<MapComponent>`.
|
|
43
|
-
- **Geocoding** — read `implementation/geocoding.md` for converting addresses to lat/lng coordinates.
|
|
44
|
-
|
|
45
|
-
For a multi-marker search page, you will need both.
|
|
46
|
-
|
|
47
|
-
---
|
|
48
|
-
|
|
49
|
-
## Step 4 — Wire the map into the page
|
|
50
|
-
|
|
51
|
-
Depending on the use case:
|
|
52
|
-
|
|
53
|
-
### Multi-marker search layout
|
|
54
|
-
|
|
55
|
-
```
|
|
56
|
-
┌──────────────────────────────────────┐
|
|
57
|
-
│ Search bar / filters │
|
|
58
|
-
├────────────────────┬─────────────────┤
|
|
59
|
-
│ │ Scrollable │
|
|
60
|
-
│ Map (2/3) │ list (1/3) │
|
|
61
|
-
│ │ │
|
|
62
|
-
└────────────────────┴─────────────────┘
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
- Map takes `~2/3` width on desktop, full width on mobile stacked above the list.
|
|
66
|
-
- List is scrollable with `overflow-y-auto`.
|
|
67
|
-
- Markers are geocoded from addresses in the list.
|
|
68
|
-
|
|
69
|
-
### Single-location detail
|
|
70
|
-
|
|
71
|
-
- Place the map below the hero image or address section.
|
|
72
|
-
- Geocode the address on mount, render one marker.
|
|
73
|
-
- Show the map only after coordinates resolve (conditional render).
|
|
74
|
-
|
|
75
|
-
---
|
|
76
|
-
|
|
77
|
-
## Verification
|
|
78
|
-
|
|
79
|
-
Before completing:
|
|
80
|
-
|
|
81
|
-
1. Map renders with visible tiles (no gray boxes).
|
|
82
|
-
2. Markers appear at correct locations.
|
|
83
|
-
3. Map is responsive (works on mobile widths).
|
|
84
|
-
4. SSR-safe — no `window is not defined` errors during build.
|
|
85
|
-
5. Run from the web app directory:
|
|
86
|
-
|
|
87
|
-
```bash
|
|
88
|
-
cd force-app/main/default/webapplications/<appName> && npm run lint && npm run build
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
- **Lint:** MUST result in 0 errors.
|
|
92
|
-
- **Build:** MUST succeed.
|
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
# Geocoding — Implementation Guide
|
|
2
|
-
|
|
3
|
-
## What is geocoding
|
|
4
|
-
|
|
5
|
-
Geocoding converts a human-readable address (e.g. "123 Main St, San Francisco, CA") into latitude/longitude coordinates for map display.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## Recommended service: OpenStreetMap Nominatim
|
|
10
|
-
|
|
11
|
-
| Property | Value |
|
|
12
|
-
|----------|-------|
|
|
13
|
-
| Endpoint | `https://nominatim.openstreetmap.org/search` |
|
|
14
|
-
| Cost | Free |
|
|
15
|
-
| API key | None |
|
|
16
|
-
| Rate limit | 1 request/second (enforced by Nominatim usage policy) |
|
|
17
|
-
| Terms | Must set a meaningful `User-Agent` header |
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
## Geocode utility with caching and concurrency control
|
|
22
|
-
|
|
23
|
-
Create at `utils/geocode.ts`:
|
|
24
|
-
|
|
25
|
-
```ts
|
|
26
|
-
const CACHE = new Map<string, { lat: number; lng: number }>();
|
|
27
|
-
const MAX_CONCURRENT = 6;
|
|
28
|
-
let inFlight = 0;
|
|
29
|
-
const queue: Array<() => void> = [];
|
|
30
|
-
|
|
31
|
-
function acquire(): Promise<void> {
|
|
32
|
-
if (inFlight < MAX_CONCURRENT) {
|
|
33
|
-
inFlight += 1;
|
|
34
|
-
return Promise.resolve();
|
|
35
|
-
}
|
|
36
|
-
return new Promise<void>((resolve) => {
|
|
37
|
-
queue.push(() => {
|
|
38
|
-
inFlight += 1;
|
|
39
|
-
resolve();
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function release(): void {
|
|
45
|
-
inFlight -= 1;
|
|
46
|
-
const next = queue.shift();
|
|
47
|
-
if (next) next();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface GeocodeResult {
|
|
51
|
-
lat: number;
|
|
52
|
-
lng: number;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export async function geocodeAddress(address: string): Promise<GeocodeResult | null> {
|
|
56
|
-
const key = address.trim().replace(/\s+/g, " ").toLowerCase();
|
|
57
|
-
if (!key) return null;
|
|
58
|
-
const cached = CACHE.get(key);
|
|
59
|
-
if (cached) return cached;
|
|
60
|
-
|
|
61
|
-
await acquire();
|
|
62
|
-
try {
|
|
63
|
-
const url = new URL("https://nominatim.openstreetmap.org/search");
|
|
64
|
-
url.searchParams.set("q", address.trim());
|
|
65
|
-
url.searchParams.set("format", "json");
|
|
66
|
-
url.searchParams.set("limit", "1");
|
|
67
|
-
const res = await fetch(url.toString(), {
|
|
68
|
-
headers: { "User-Agent": "MyApp/1.0 (contact@example.com)" },
|
|
69
|
-
});
|
|
70
|
-
if (!res.ok) return null;
|
|
71
|
-
const data = (await res.json()) as Array<{ lat?: string; lon?: string }>;
|
|
72
|
-
const first = data?.[0];
|
|
73
|
-
if (!first?.lat || !first?.lon) return null;
|
|
74
|
-
const lat = Number(first.lat);
|
|
75
|
-
const lng = Number(first.lon);
|
|
76
|
-
if (Number.isNaN(lat) || Number.isNaN(lng)) return null;
|
|
77
|
-
const result = { lat, lng };
|
|
78
|
-
CACHE.set(key, result);
|
|
79
|
-
return result;
|
|
80
|
-
} catch {
|
|
81
|
-
return null;
|
|
82
|
-
} finally {
|
|
83
|
-
release();
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
### Design decisions
|
|
89
|
-
|
|
90
|
-
| Decision | Rationale |
|
|
91
|
-
|----------|-----------|
|
|
92
|
-
| In-memory cache | Avoids repeated API calls for the same address within a session |
|
|
93
|
-
| Concurrency limiter (6) | Prevents flooding Nominatim when geocoding many addresses in parallel |
|
|
94
|
-
| Semaphore queue | Requests beyond the limit wait in FIFO order |
|
|
95
|
-
| Null return on failure | Callers decide how to handle missing coordinates (skip marker, show fallback) |
|
|
96
|
-
| `User-Agent` header | Required by Nominatim usage policy; set to your app name and contact |
|
|
97
|
-
|
|
98
|
-
---
|
|
99
|
-
|
|
100
|
-
## React hook: useGeocode
|
|
101
|
-
|
|
102
|
-
For single-address geocoding (e.g. detail pages):
|
|
103
|
-
|
|
104
|
-
```ts
|
|
105
|
-
import { useState, useEffect } from "react";
|
|
106
|
-
import { geocodeAddress, type GeocodeResult } from "@/utils/geocode";
|
|
107
|
-
|
|
108
|
-
export function useGeocode(address: string | null | undefined): {
|
|
109
|
-
coords: GeocodeResult | null;
|
|
110
|
-
loading: boolean;
|
|
111
|
-
} {
|
|
112
|
-
const [coords, setCoords] = useState<GeocodeResult | null>(null);
|
|
113
|
-
const [loading, setLoading] = useState(false);
|
|
114
|
-
|
|
115
|
-
useEffect(() => {
|
|
116
|
-
if (!address?.trim()) {
|
|
117
|
-
setCoords(null);
|
|
118
|
-
setLoading(false);
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
let cancelled = false;
|
|
122
|
-
setLoading(true);
|
|
123
|
-
geocodeAddress(address)
|
|
124
|
-
.then((result) => {
|
|
125
|
-
if (!cancelled) setCoords(result);
|
|
126
|
-
})
|
|
127
|
-
.catch(() => {
|
|
128
|
-
if (!cancelled) setCoords(null);
|
|
129
|
-
})
|
|
130
|
-
.finally(() => {
|
|
131
|
-
if (!cancelled) setLoading(false);
|
|
132
|
-
});
|
|
133
|
-
return () => {
|
|
134
|
-
cancelled = true;
|
|
135
|
-
};
|
|
136
|
-
}, [address?.trim() ?? ""]);
|
|
137
|
-
|
|
138
|
-
return { coords, loading };
|
|
139
|
-
}
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
### Usage in a component
|
|
143
|
-
|
|
144
|
-
```tsx
|
|
145
|
-
const { coords } = useGeocode("123 Main St, San Francisco, CA");
|
|
146
|
-
|
|
147
|
-
{coords && (
|
|
148
|
-
<MapView
|
|
149
|
-
center={[coords.lat, coords.lng]}
|
|
150
|
-
zoom={15}
|
|
151
|
-
markers={[{ lat: coords.lat, lng: coords.lng, label: "Location" }]}
|
|
152
|
-
/>
|
|
153
|
-
)}
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
---
|
|
157
|
-
|
|
158
|
-
## Batch geocoding for lists
|
|
159
|
-
|
|
160
|
-
When geocoding multiple addresses (e.g. search results → map markers), use `Promise.all` with the built-in concurrency limiter:
|
|
161
|
-
|
|
162
|
-
```ts
|
|
163
|
-
const results = await Promise.all(
|
|
164
|
-
addresses.map(({ id, address }) =>
|
|
165
|
-
geocodeAddress(address).then((coords) =>
|
|
166
|
-
coords ? { id, lat: coords.lat, lng: coords.lng } : null
|
|
167
|
-
)
|
|
168
|
-
)
|
|
169
|
-
);
|
|
170
|
-
const markers = results.filter(Boolean);
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
The `MAX_CONCURRENT` semaphore in the utility ensures no more than 6 requests are in flight, even if you pass 50 addresses.
|
|
174
|
-
|
|
175
|
-
---
|
|
176
|
-
|
|
177
|
-
## Hook for multi-marker geocoding
|
|
178
|
-
|
|
179
|
-
```ts
|
|
180
|
-
import { useState, useEffect } from "react";
|
|
181
|
-
import { geocodeAddress } from "@/utils/geocode";
|
|
182
|
-
import type { MapMarker } from "@/components/MapView";
|
|
183
|
-
|
|
184
|
-
export function useMapMarkers(
|
|
185
|
-
items: Array<{ id: string; address: string; label?: string }>
|
|
186
|
-
): { markers: MapMarker[]; loading: boolean } {
|
|
187
|
-
const [markers, setMarkers] = useState<MapMarker[]>([]);
|
|
188
|
-
const [loading, setLoading] = useState(false);
|
|
189
|
-
|
|
190
|
-
const key = items.map((i) => i.id).join(",");
|
|
191
|
-
|
|
192
|
-
useEffect(() => {
|
|
193
|
-
if (items.length === 0) {
|
|
194
|
-
setMarkers([]);
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
let cancelled = false;
|
|
198
|
-
setLoading(true);
|
|
199
|
-
Promise.all(
|
|
200
|
-
items.map((item) =>
|
|
201
|
-
geocodeAddress(item.address).then((coords) =>
|
|
202
|
-
coords ? { lat: coords.lat, lng: coords.lng, label: item.label ?? item.address } : null
|
|
203
|
-
)
|
|
204
|
-
)
|
|
205
|
-
)
|
|
206
|
-
.then((results) => {
|
|
207
|
-
if (!cancelled) setMarkers(results.filter(Boolean) as MapMarker[]);
|
|
208
|
-
})
|
|
209
|
-
.catch(() => {
|
|
210
|
-
if (!cancelled) setMarkers([]);
|
|
211
|
-
})
|
|
212
|
-
.finally(() => {
|
|
213
|
-
if (!cancelled) setLoading(false);
|
|
214
|
-
});
|
|
215
|
-
return () => {
|
|
216
|
-
cancelled = true;
|
|
217
|
-
};
|
|
218
|
-
}, [key]);
|
|
219
|
-
|
|
220
|
-
return { markers, loading };
|
|
221
|
-
}
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
---
|
|
225
|
-
|
|
226
|
-
## CSP considerations
|
|
227
|
-
|
|
228
|
-
If CSP is enforced, add Nominatim to `connect-src`:
|
|
229
|
-
|
|
230
|
-
```
|
|
231
|
-
connect-src 'self' https://nominatim.openstreetmap.org;
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
---
|
|
235
|
-
|
|
236
|
-
## Alternative geocoding providers
|
|
237
|
-
|
|
238
|
-
| Provider | Free tier | API key | Notes |
|
|
239
|
-
|----------|-----------|---------|-------|
|
|
240
|
-
| Nominatim (OSM) | Unlimited (rate-limited) | No | Best for prototypes and low-traffic apps |
|
|
241
|
-
| Google Geocoding API | 200 USD/month credit | Yes | Most accurate; requires billing account |
|
|
242
|
-
| Mapbox Geocoding | 100K req/month | Yes | Good accuracy; JS SDK available |
|
|
243
|
-
| LocationIQ | 5K req/day | Yes | Nominatim-compatible API |
|
|
244
|
-
|
|
245
|
-
For production apps with high traffic, consider Google or Mapbox with an API key and server-side geocoding.
|
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
# Leaflet Map — Implementation Guide
|
|
2
|
-
|
|
3
|
-
## Dependencies
|
|
4
|
-
|
|
5
|
-
Add to the project:
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm install leaflet react-leaflet
|
|
9
|
-
npm install -D @types/leaflet
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
Import the Leaflet CSS in the component file (not in global CSS — keeps it co-located):
|
|
13
|
-
|
|
14
|
-
```tsx
|
|
15
|
-
import "leaflet/dist/leaflet.css";
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
---
|
|
19
|
-
|
|
20
|
-
## TypeScript declaration (if @types/leaflet is unavailable)
|
|
21
|
-
|
|
22
|
-
If the project cannot install `@types/leaflet`, create a declaration file:
|
|
23
|
-
|
|
24
|
-
```ts
|
|
25
|
-
// types/leaflet.d.ts
|
|
26
|
-
declare module "leaflet" {
|
|
27
|
-
const L: unknown;
|
|
28
|
-
export default L;
|
|
29
|
-
}
|
|
30
|
-
declare module "leaflet/dist/leaflet.css";
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
---
|
|
34
|
-
|
|
35
|
-
## Reusable map component
|
|
36
|
-
|
|
37
|
-
Create a generic map component at `components/MapView.tsx` (or similar):
|
|
38
|
-
|
|
39
|
-
```tsx
|
|
40
|
-
import { useMemo, useState, useEffect } from "react";
|
|
41
|
-
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
|
|
42
|
-
import L from "leaflet";
|
|
43
|
-
import "leaflet/dist/leaflet.css";
|
|
44
|
-
|
|
45
|
-
const Leaflet = L as {
|
|
46
|
-
divIcon: (opts: {
|
|
47
|
-
className?: string;
|
|
48
|
-
html?: string;
|
|
49
|
-
iconSize?: [number, number];
|
|
50
|
-
iconAnchor?: [number, number];
|
|
51
|
-
popupAnchor?: [number, number];
|
|
52
|
-
}) => unknown;
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const pinIcon = Leaflet.divIcon({
|
|
56
|
-
className: "map-pin",
|
|
57
|
-
html: `<span class="map-pin-shape" aria-hidden="true"></span>`,
|
|
58
|
-
iconSize: [28, 40],
|
|
59
|
-
iconAnchor: [14, 40],
|
|
60
|
-
popupAnchor: [0, -40],
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
export interface MapMarker {
|
|
64
|
-
lat: number;
|
|
65
|
-
lng: number;
|
|
66
|
-
label?: string;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
interface MapViewProps {
|
|
70
|
-
center: [number, number];
|
|
71
|
-
zoom?: number;
|
|
72
|
-
markers?: MapMarker[];
|
|
73
|
-
className?: string;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function MapCenterUpdater({ center, zoom = 13 }: { center: [number, number]; zoom?: number }) {
|
|
77
|
-
const map = useMap() as { setView: (center: [number, number], zoom: number) => void };
|
|
78
|
-
useEffect(() => {
|
|
79
|
-
map.setView(center, zoom);
|
|
80
|
-
}, [map, center[0], center[1], zoom]);
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export default function MapView({
|
|
85
|
-
center,
|
|
86
|
-
zoom = 13,
|
|
87
|
-
markers = [],
|
|
88
|
-
className = "h-[400px] w-full rounded-xl overflow-hidden",
|
|
89
|
-
}: MapViewProps) {
|
|
90
|
-
const [mounted, setMounted] = useState(false);
|
|
91
|
-
useEffect(() => {
|
|
92
|
-
setMounted(true);
|
|
93
|
-
}, []);
|
|
94
|
-
|
|
95
|
-
const effectiveCenter = useMemo((): [number, number] => {
|
|
96
|
-
if (markers.length > 0) {
|
|
97
|
-
const sum = markers.reduce((acc, m) => [acc[0] + m.lat, acc[1] + m.lng], [0, 0]);
|
|
98
|
-
return [sum[0] / markers.length, sum[1] / markers.length];
|
|
99
|
-
}
|
|
100
|
-
return center;
|
|
101
|
-
}, [center, markers]);
|
|
102
|
-
|
|
103
|
-
if (!mounted || typeof window === "undefined") {
|
|
104
|
-
return (
|
|
105
|
-
<div
|
|
106
|
-
className={className + " bg-muted flex items-center justify-center text-muted-foreground text-sm"}
|
|
107
|
-
aria-hidden
|
|
108
|
-
>
|
|
109
|
-
Loading map…
|
|
110
|
-
</div>
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return (
|
|
115
|
-
<div className={className} aria-label="Map">
|
|
116
|
-
<MapContainer
|
|
117
|
-
center={effectiveCenter}
|
|
118
|
-
zoom={zoom}
|
|
119
|
-
scrollWheelZoom
|
|
120
|
-
className="h-full w-full"
|
|
121
|
-
style={{ minHeight: 200 }}
|
|
122
|
-
>
|
|
123
|
-
<MapCenterUpdater center={effectiveCenter} zoom={zoom} />
|
|
124
|
-
<TileLayer
|
|
125
|
-
attribution='Data by © <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
126
|
-
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
127
|
-
maxZoom={19}
|
|
128
|
-
/>
|
|
129
|
-
{markers.map((m, i) => (
|
|
130
|
-
<Marker key={`${m.lat}-${m.lng}-${i}`} position={[m.lat, m.lng]} icon={pinIcon}>
|
|
131
|
-
<Popup>{m.label ?? "Location"}</Popup>
|
|
132
|
-
</Marker>
|
|
133
|
-
))}
|
|
134
|
-
</MapContainer>
|
|
135
|
-
</div>
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
---
|
|
141
|
-
|
|
142
|
-
## Tile provider: OpenStreetMap
|
|
143
|
-
|
|
144
|
-
The default tile URL is free and requires no API key:
|
|
145
|
-
|
|
146
|
-
```
|
|
147
|
-
https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
| Property | Value |
|
|
151
|
-
|----------|-------|
|
|
152
|
-
| Cost | Free |
|
|
153
|
-
| API key | None |
|
|
154
|
-
| Max zoom | 19 |
|
|
155
|
-
| Attribution | Required — `© OpenStreetMap` |
|
|
156
|
-
|
|
157
|
-
CSP `img-src` must include `https://tile.openstreetmap.org` if CSP is enforced.
|
|
158
|
-
|
|
159
|
-
---
|
|
160
|
-
|
|
161
|
-
## Custom pin icon via CSS
|
|
162
|
-
|
|
163
|
-
The `divIcon` approach avoids external marker image dependencies. Add these styles to your global CSS or a co-located CSS file:
|
|
164
|
-
|
|
165
|
-
```css
|
|
166
|
-
.map-pin {
|
|
167
|
-
background: transparent !important;
|
|
168
|
-
border: none !important;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
.map-pin-shape {
|
|
172
|
-
display: block;
|
|
173
|
-
width: 28px;
|
|
174
|
-
height: 40px;
|
|
175
|
-
background: hsl(var(--primary));
|
|
176
|
-
border-radius: 50% 50% 50% 0;
|
|
177
|
-
transform: rotate(-45deg);
|
|
178
|
-
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
.map-pin-shape::after {
|
|
182
|
-
content: "";
|
|
183
|
-
display: block;
|
|
184
|
-
width: 12px;
|
|
185
|
-
height: 12px;
|
|
186
|
-
background: white;
|
|
187
|
-
border-radius: 50%;
|
|
188
|
-
position: absolute;
|
|
189
|
-
top: 50%;
|
|
190
|
-
left: 50%;
|
|
191
|
-
transform: translate(-50%, -50%);
|
|
192
|
-
}
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
---
|
|
196
|
-
|
|
197
|
-
## SSR safety
|
|
198
|
-
|
|
199
|
-
Leaflet requires `window` and `document`. The component guards against SSR with:
|
|
200
|
-
|
|
201
|
-
1. A `mounted` state that only becomes `true` in `useEffect` (client-side).
|
|
202
|
-
2. A `typeof window === "undefined"` check.
|
|
203
|
-
3. A loading placeholder rendered during SSR / before mount.
|
|
204
|
-
|
|
205
|
-
This prevents "window is not defined" errors in SSR or static builds.
|
|
206
|
-
|
|
207
|
-
---
|
|
208
|
-
|
|
209
|
-
## MapCenterUpdater pattern
|
|
210
|
-
|
|
211
|
-
`react-leaflet`'s `MapContainer` does not re-center when the `center` prop changes after initial render. The `MapCenterUpdater` child component uses `useMap()` to call `map.setView()` reactively:
|
|
212
|
-
|
|
213
|
-
```tsx
|
|
214
|
-
function MapCenterUpdater({ center, zoom = 13 }: { center: [number, number]; zoom?: number }) {
|
|
215
|
-
const map = useMap();
|
|
216
|
-
useEffect(() => {
|
|
217
|
-
map.setView(center, zoom);
|
|
218
|
-
}, [map, center[0], center[1], zoom]);
|
|
219
|
-
return null;
|
|
220
|
-
}
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
Place it as a direct child of `<MapContainer>`.
|
|
224
|
-
|
|
225
|
-
---
|
|
226
|
-
|
|
227
|
-
## Multi-marker center calculation
|
|
228
|
-
|
|
229
|
-
When rendering multiple markers, auto-center the map on their centroid:
|
|
230
|
-
|
|
231
|
-
```tsx
|
|
232
|
-
const effectiveCenter = useMemo((): [number, number] => {
|
|
233
|
-
if (markers.length > 0) {
|
|
234
|
-
const sum = markers.reduce((acc, m) => [acc[0] + m.lat, acc[1] + m.lng], [0, 0]);
|
|
235
|
-
return [sum[0] / markers.length, sum[1] / markers.length];
|
|
236
|
-
}
|
|
237
|
-
return fallbackCenter;
|
|
238
|
-
}, [markers, fallbackCenter]);
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
---
|
|
242
|
-
|
|
243
|
-
## Split-panel layout (map + list)
|
|
244
|
-
|
|
245
|
-
For a search page with map on the left and scrollable results on the right:
|
|
246
|
-
|
|
247
|
-
```tsx
|
|
248
|
-
<div className="flex h-[calc(100vh-4rem)] min-h-[500px] flex-col">
|
|
249
|
-
{/* Search bar */}
|
|
250
|
-
<div className="shrink-0 border-b bg-background px-4 py-3">
|
|
251
|
-
{/* search input */}
|
|
252
|
-
</div>
|
|
253
|
-
{/* Map + List */}
|
|
254
|
-
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
|
|
255
|
-
<div className="h-64 shrink-0 lg:h-full lg:min-h-0 lg:w-2/3" aria-label="Map">
|
|
256
|
-
<MapView center={center} markers={markers} className="h-full w-full" />
|
|
257
|
-
</div>
|
|
258
|
-
<aside className="flex w-full flex-col border-t lg:w-1/3 lg:border-l lg:border-t-0">
|
|
259
|
-
<div className="flex-1 overflow-y-auto p-4">
|
|
260
|
-
{/* list items */}
|
|
261
|
-
</div>
|
|
262
|
-
</aside>
|
|
263
|
-
</div>
|
|
264
|
-
</div>
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
Key points:
|
|
268
|
-
- `h-[calc(100vh-4rem)]` fills viewport minus header.
|
|
269
|
-
- `lg:flex-row` stacks vertically on mobile, side-by-side on desktop.
|
|
270
|
-
- List panel uses `overflow-y-auto` for independent scrolling.
|
|
271
|
-
|
|
272
|
-
---
|
|
273
|
-
|
|
274
|
-
## Accessibility
|
|
275
|
-
|
|
276
|
-
- Wrap the map `<div>` with `aria-label="Map"` or a descriptive label.
|
|
277
|
-
- Pin icon inner HTML uses `aria-hidden="true"`.
|
|
278
|
-
- Popups contain text labels for each marker.
|
|
279
|
-
- Provide a text-based alternative (address list) for screen readers.
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: building-weather-widget
|
|
3
|
-
description: Adds a weather widget to React pages using the free Open-Meteo API. Use when the user asks to add weather, show a forecast, display current conditions, add a weather card, or integrate weather data into the web application.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Weather Widget
|
|
7
|
-
|
|
8
|
-
## When to Use
|
|
9
|
-
|
|
10
|
-
Use this skill when:
|
|
11
|
-
- Adding current weather conditions to a dashboard or sidebar
|
|
12
|
-
- Displaying an hourly or daily forecast
|
|
13
|
-
- Showing location-based weather for a property, event, or destination
|
|
14
|
-
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
## Step 1 — Determine widget scope
|
|
18
|
-
|
|
19
|
-
Identify what weather data the user needs:
|
|
20
|
-
|
|
21
|
-
- **Current conditions only** — temperature, description, wind, humidity
|
|
22
|
-
- **Current + hourly forecast** — conditions + next 6–12 hours
|
|
23
|
-
- **Current + daily forecast** — conditions + multi-day outlook
|
|
24
|
-
|
|
25
|
-
If unclear, ask:
|
|
26
|
-
|
|
27
|
-
> "Should the weather widget show just current conditions, or include an hourly/daily forecast too?"
|
|
28
|
-
|
|
29
|
-
---
|
|
30
|
-
|
|
31
|
-
## Step 2 — Determine location source
|
|
32
|
-
|
|
33
|
-
The widget needs a latitude/longitude. Identify where this comes from:
|
|
34
|
-
|
|
35
|
-
- **Fixed default** — hardcoded city (e.g. San Francisco: `37.7749, -122.4194`)
|
|
36
|
-
- **User's location** — browser Geolocation API
|
|
37
|
-
- **Address-based** — geocode an address to lat/lng (see the `building-interactive-map` skill for geocoding)
|
|
38
|
-
- **Prop-driven** — parent component passes lat/lng
|
|
39
|
-
|
|
40
|
-
---
|
|
41
|
-
|
|
42
|
-
## Step 3 — Implementation
|
|
43
|
-
|
|
44
|
-
Read the corresponding guides:
|
|
45
|
-
|
|
46
|
-
- **Weather data hook** — read `implementation/weather-hook.md` for the `useWeather` custom hook and Open-Meteo API integration.
|
|
47
|
-
- **Weather UI component** — read `implementation/weather-ui.md` for rendering weather data in a card.
|
|
48
|
-
|
|
49
|
-
---
|
|
50
|
-
|
|
51
|
-
## Verification
|
|
52
|
-
|
|
53
|
-
Before completing:
|
|
54
|
-
|
|
55
|
-
1. Widget shows real weather data (not mocked).
|
|
56
|
-
2. Loading and error states are handled gracefully.
|
|
57
|
-
3. Temperature displays in the correct unit (°F or °C).
|
|
58
|
-
4. Run from the web app directory:
|
|
59
|
-
|
|
60
|
-
```bash
|
|
61
|
-
cd force-app/main/default/webapplications/<appName> && npm run lint && npm run build
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
- **Lint:** MUST result in 0 errors.
|
|
65
|
-
- **Build:** MUST succeed.
|
|
@@ -1,258 +0,0 @@
|
|
|
1
|
-
# Weather Hook — Implementation Guide
|
|
2
|
-
|
|
3
|
-
## Recommended API: Open-Meteo
|
|
4
|
-
|
|
5
|
-
| Property | Value |
|
|
6
|
-
|----------|-------|
|
|
7
|
-
| Endpoint | `https://api.open-meteo.com/v1/forecast` |
|
|
8
|
-
| Cost | Free for non-commercial use; no signup |
|
|
9
|
-
| API key | **None required** |
|
|
10
|
-
| Rate limit | 10,000 requests/day |
|
|
11
|
-
| Data | Current conditions, hourly, daily, historical |
|
|
12
|
-
| Weather codes | WMO standard codes |
|
|
13
|
-
|
|
14
|
-
### Why Open-Meteo over alternatives
|
|
15
|
-
|
|
16
|
-
| Provider | API key | Free tier | Notes |
|
|
17
|
-
|----------|---------|-----------|-------|
|
|
18
|
-
| **Open-Meteo** | No | 10K/day | Best for prototypes and low-traffic; no auth required |
|
|
19
|
-
| OpenWeatherMap | Yes | 1K calls/day | Popular but requires signup and API key |
|
|
20
|
-
| WeatherAPI.com | Yes | 1M calls/month | Good free tier but requires key management |
|
|
21
|
-
| Visual Crossing | Yes | 1K calls/day | Historical data strength |
|
|
22
|
-
|
|
23
|
-
Open-Meteo is the default choice because it requires zero configuration.
|
|
24
|
-
|
|
25
|
-
---
|
|
26
|
-
|
|
27
|
-
## WMO weather codes
|
|
28
|
-
|
|
29
|
-
Open-Meteo returns standard WMO weather codes. Map them to human-readable labels:
|
|
30
|
-
|
|
31
|
-
```ts
|
|
32
|
-
const WEATHER_LABELS: Record<number, string> = {
|
|
33
|
-
0: "Clear",
|
|
34
|
-
1: "Mainly clear",
|
|
35
|
-
2: "Partly cloudy",
|
|
36
|
-
3: "Overcast",
|
|
37
|
-
45: "Foggy",
|
|
38
|
-
48: "Depositing rime fog",
|
|
39
|
-
51: "Light drizzle",
|
|
40
|
-
53: "Drizzle",
|
|
41
|
-
55: "Dense drizzle",
|
|
42
|
-
61: "Slight rain",
|
|
43
|
-
63: "Rain",
|
|
44
|
-
65: "Heavy rain",
|
|
45
|
-
71: "Slight snow",
|
|
46
|
-
73: "Snow",
|
|
47
|
-
75: "Heavy snow",
|
|
48
|
-
77: "Snow grains",
|
|
49
|
-
80: "Slight rain showers",
|
|
50
|
-
81: "Rain showers",
|
|
51
|
-
82: "Heavy rain showers",
|
|
52
|
-
85: "Slight snow showers",
|
|
53
|
-
86: "Heavy snow showers",
|
|
54
|
-
95: "Thunderstorm",
|
|
55
|
-
96: "Thunderstorm + hail",
|
|
56
|
-
99: "Thunderstorm + heavy hail",
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
function weatherLabel(code: number): string {
|
|
60
|
-
return WEATHER_LABELS[code] ?? "Unknown";
|
|
61
|
-
}
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
---
|
|
65
|
-
|
|
66
|
-
## Temperature conversion
|
|
67
|
-
|
|
68
|
-
Open-Meteo returns Celsius by default. Convert to Fahrenheit when needed:
|
|
69
|
-
|
|
70
|
-
```ts
|
|
71
|
-
function celsiusToFahrenheit(c: number): number {
|
|
72
|
-
return Math.round((c * 9) / 5 + 32);
|
|
73
|
-
}
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Alternatively, request Fahrenheit directly via `&temperature_unit=fahrenheit` in the query string.
|
|
77
|
-
|
|
78
|
-
---
|
|
79
|
-
|
|
80
|
-
## TypeScript interfaces
|
|
81
|
-
|
|
82
|
-
```ts
|
|
83
|
-
export interface WeatherCurrent {
|
|
84
|
-
description: string;
|
|
85
|
-
tempF: number;
|
|
86
|
-
humidity: number;
|
|
87
|
-
windSpeedKmh: number;
|
|
88
|
-
windSpeedMph: number;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export interface WeatherHour {
|
|
92
|
-
time: string;
|
|
93
|
-
tempF: number;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export interface WeatherData {
|
|
97
|
-
current: WeatherCurrent;
|
|
98
|
-
hourly: WeatherHour[];
|
|
99
|
-
timezone: string;
|
|
100
|
-
}
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
---
|
|
104
|
-
|
|
105
|
-
## The useWeather hook
|
|
106
|
-
|
|
107
|
-
Create at `hooks/useWeather.ts`:
|
|
108
|
-
|
|
109
|
-
```ts
|
|
110
|
-
import { useState, useEffect } from "react";
|
|
111
|
-
|
|
112
|
-
const DEFAULT_LAT = 37.7749;
|
|
113
|
-
const DEFAULT_LNG = -122.4194;
|
|
114
|
-
|
|
115
|
-
async function fetchWeather(lat: number, lng: number): Promise<WeatherData> {
|
|
116
|
-
const url = new URL("https://api.open-meteo.com/v1/forecast");
|
|
117
|
-
url.searchParams.set("latitude", String(lat));
|
|
118
|
-
url.searchParams.set("longitude", String(lng));
|
|
119
|
-
url.searchParams.set("current", "temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m");
|
|
120
|
-
url.searchParams.set("hourly", "temperature_2m");
|
|
121
|
-
url.searchParams.set("timezone", "auto");
|
|
122
|
-
url.searchParams.set("forecast_days", "1");
|
|
123
|
-
|
|
124
|
-
const res = await fetch(url.toString());
|
|
125
|
-
if (!res.ok) throw new Error(`Weather API error: ${res.status}`);
|
|
126
|
-
const data = await res.json();
|
|
127
|
-
|
|
128
|
-
const cur = data.current ?? {};
|
|
129
|
-
const tempC = cur.temperature_2m ?? 0;
|
|
130
|
-
const humidity = cur.relative_humidity_2m ?? 0;
|
|
131
|
-
const windKmh = cur.wind_speed_10m ?? 0;
|
|
132
|
-
const windMph = Math.round(windKmh * 0.621371 * 10) / 10;
|
|
133
|
-
const code = cur.weather_code ?? 0;
|
|
134
|
-
|
|
135
|
-
const hourly: WeatherHour[] = [];
|
|
136
|
-
const times: string[] = data.hourly?.time ?? [];
|
|
137
|
-
const temps: (number | null)[] = data.hourly?.temperature_2m ?? [];
|
|
138
|
-
const now = new Date();
|
|
139
|
-
|
|
140
|
-
for (let i = 0; i < times.length; i++) {
|
|
141
|
-
const t = times[i];
|
|
142
|
-
const temp = temps[i];
|
|
143
|
-
if (t && temp != null) {
|
|
144
|
-
const d = new Date(t);
|
|
145
|
-
if (d >= now) {
|
|
146
|
-
hourly.push({
|
|
147
|
-
time: d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true }),
|
|
148
|
-
tempF: celsiusToFahrenheit(temp),
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
if (hourly.length >= 6) break;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return {
|
|
156
|
-
current: {
|
|
157
|
-
description: weatherLabel(code),
|
|
158
|
-
tempF: celsiusToFahrenheit(tempC),
|
|
159
|
-
humidity,
|
|
160
|
-
windSpeedKmh: windKmh,
|
|
161
|
-
windSpeedMph: windMph,
|
|
162
|
-
},
|
|
163
|
-
hourly,
|
|
164
|
-
timezone: data.timezone ?? "auto",
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export function useWeather(lat?: number | null, lng?: number | null) {
|
|
169
|
-
const latitude = lat ?? DEFAULT_LAT;
|
|
170
|
-
const longitude = lng ?? DEFAULT_LNG;
|
|
171
|
-
|
|
172
|
-
const [data, setData] = useState<WeatherData | null>(null);
|
|
173
|
-
const [loading, setLoading] = useState(true);
|
|
174
|
-
const [error, setError] = useState<string | null>(null);
|
|
175
|
-
|
|
176
|
-
useEffect(() => {
|
|
177
|
-
let cancelled = false;
|
|
178
|
-
setLoading(true);
|
|
179
|
-
setError(null);
|
|
180
|
-
fetchWeather(latitude, longitude)
|
|
181
|
-
.then((d) => {
|
|
182
|
-
if (!cancelled) setData(d);
|
|
183
|
-
})
|
|
184
|
-
.catch((e) => {
|
|
185
|
-
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load weather");
|
|
186
|
-
})
|
|
187
|
-
.finally(() => {
|
|
188
|
-
if (!cancelled) setLoading(false);
|
|
189
|
-
});
|
|
190
|
-
return () => {
|
|
191
|
-
cancelled = true;
|
|
192
|
-
};
|
|
193
|
-
}, [latitude, longitude]);
|
|
194
|
-
|
|
195
|
-
return { data, loading, error };
|
|
196
|
-
}
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
### Hook design patterns
|
|
200
|
-
|
|
201
|
-
| Pattern | Why |
|
|
202
|
-
|---------|-----|
|
|
203
|
-
| Cancellation flag (`cancelled`) | Prevents state updates on unmounted components |
|
|
204
|
-
| Default coordinates | Widget works immediately without user location |
|
|
205
|
-
| Dependency array `[latitude, longitude]` | Re-fetches only when location changes |
|
|
206
|
-
| Separate `loading` and `error` states | Enables distinct UI for each state |
|
|
207
|
-
|
|
208
|
-
---
|
|
209
|
-
|
|
210
|
-
## Open-Meteo API parameters reference
|
|
211
|
-
|
|
212
|
-
### Current weather variables
|
|
213
|
-
|
|
214
|
-
| Variable | Description |
|
|
215
|
-
|----------|-------------|
|
|
216
|
-
| `temperature_2m` | Air temperature at 2m height (°C) |
|
|
217
|
-
| `relative_humidity_2m` | Relative humidity (%) |
|
|
218
|
-
| `weather_code` | WMO weather code |
|
|
219
|
-
| `wind_speed_10m` | Wind speed at 10m height (km/h) |
|
|
220
|
-
| `apparent_temperature` | Feels-like temperature (°C) |
|
|
221
|
-
| `precipitation` | Precipitation sum (mm) |
|
|
222
|
-
|
|
223
|
-
### Hourly variables
|
|
224
|
-
|
|
225
|
-
| Variable | Description |
|
|
226
|
-
|----------|-------------|
|
|
227
|
-
| `temperature_2m` | Temperature each hour |
|
|
228
|
-
| `precipitation_probability` | Chance of rain (%) |
|
|
229
|
-
| `weather_code` | Condition each hour |
|
|
230
|
-
|
|
231
|
-
### Daily variables
|
|
232
|
-
|
|
233
|
-
| Variable | Description |
|
|
234
|
-
|----------|-------------|
|
|
235
|
-
| `temperature_2m_max` | Daily high |
|
|
236
|
-
| `temperature_2m_min` | Daily low |
|
|
237
|
-
| `weather_code` | Dominant condition |
|
|
238
|
-
| `sunrise` | Sunrise time (ISO) |
|
|
239
|
-
| `sunset` | Sunset time (ISO) |
|
|
240
|
-
|
|
241
|
-
### Useful query parameters
|
|
242
|
-
|
|
243
|
-
| Param | Example | Purpose |
|
|
244
|
-
|-------|---------|---------|
|
|
245
|
-
| `timezone` | `auto` or `America/Los_Angeles` | Localizes times |
|
|
246
|
-
| `forecast_days` | `1`–`16` | Number of days |
|
|
247
|
-
| `temperature_unit` | `fahrenheit` | Direct °F responses |
|
|
248
|
-
| `wind_speed_unit` | `mph` | Direct mph responses |
|
|
249
|
-
|
|
250
|
-
---
|
|
251
|
-
|
|
252
|
-
## CSP considerations
|
|
253
|
-
|
|
254
|
-
If CSP is enforced, add Open-Meteo to `connect-src`:
|
|
255
|
-
|
|
256
|
-
```
|
|
257
|
-
connect-src 'self' https://api.open-meteo.com;
|
|
258
|
-
```
|
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
# Weather UI — Implementation Guide
|
|
2
|
-
|
|
3
|
-
## Weather card component
|
|
4
|
-
|
|
5
|
-
Render the weather data inside a Card component:
|
|
6
|
-
|
|
7
|
-
```tsx
|
|
8
|
-
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
|
9
|
-
import { useWeather } from "@/hooks/useWeather";
|
|
10
|
-
|
|
11
|
-
interface WeatherCardProps {
|
|
12
|
-
lat?: number;
|
|
13
|
-
lng?: number;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export default function WeatherCard({ lat, lng }: WeatherCardProps) {
|
|
17
|
-
const { data: weather, loading, error } = useWeather(lat, lng);
|
|
18
|
-
|
|
19
|
-
return (
|
|
20
|
-
<Card className="rounded-2xl shadow-md">
|
|
21
|
-
<CardHeader>
|
|
22
|
-
<CardTitle className="text-primary">Weather</CardTitle>
|
|
23
|
-
<p className="text-sm text-muted-foreground">
|
|
24
|
-
{new Date().toLocaleDateString("en-US", {
|
|
25
|
-
weekday: "short",
|
|
26
|
-
month: "short",
|
|
27
|
-
day: "numeric",
|
|
28
|
-
year: "numeric",
|
|
29
|
-
})}
|
|
30
|
-
</p>
|
|
31
|
-
</CardHeader>
|
|
32
|
-
<CardContent className="space-y-4">
|
|
33
|
-
{loading && <p className="text-sm text-muted-foreground">Loading weather…</p>}
|
|
34
|
-
{error && (
|
|
35
|
-
<p className="text-sm text-destructive" role="alert">
|
|
36
|
-
{error}
|
|
37
|
-
</p>
|
|
38
|
-
)}
|
|
39
|
-
{!loading && !error && weather && (
|
|
40
|
-
<>
|
|
41
|
-
{/* Current conditions */}
|
|
42
|
-
<p className="text-base text-foreground">{weather.current.description}</p>
|
|
43
|
-
<p className="text-4xl font-bold text-foreground">{weather.current.tempF}°F</p>
|
|
44
|
-
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
|
45
|
-
<span>{weather.current.windSpeedMph} mph Wind</span>
|
|
46
|
-
<span>{weather.current.humidity}% Humidity</span>
|
|
47
|
-
</div>
|
|
48
|
-
|
|
49
|
-
{/* Tab indicator */}
|
|
50
|
-
<div className="flex gap-2 border-b border-border pb-2">
|
|
51
|
-
<span className="border-b-2 border-primary pb-1 text-sm font-semibold text-primary">
|
|
52
|
-
Today
|
|
53
|
-
</span>
|
|
54
|
-
</div>
|
|
55
|
-
|
|
56
|
-
{/* Hourly forecast */}
|
|
57
|
-
{weather.hourly.length > 0 && (
|
|
58
|
-
<div className="flex flex-wrap gap-4">
|
|
59
|
-
{weather.hourly.map((h) => (
|
|
60
|
-
<div key={h.time} className="min-w-[60px] rounded-xl bg-muted/50 p-2 text-center">
|
|
61
|
-
<p className="text-xs text-muted-foreground">{h.time}</p>
|
|
62
|
-
<p className="text-base font-semibold text-foreground">{h.tempF}°</p>
|
|
63
|
-
</div>
|
|
64
|
-
))}
|
|
65
|
-
</div>
|
|
66
|
-
)}
|
|
67
|
-
</>
|
|
68
|
-
)}
|
|
69
|
-
</CardContent>
|
|
70
|
-
</Card>
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
---
|
|
76
|
-
|
|
77
|
-
## Layout placement
|
|
78
|
-
|
|
79
|
-
### Dashboard sidebar
|
|
80
|
-
|
|
81
|
-
Place the weather card in a two-column grid alongside other dashboard content:
|
|
82
|
-
|
|
83
|
-
```tsx
|
|
84
|
-
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
85
|
-
<div className="space-y-6">
|
|
86
|
-
{/* Primary dashboard content */}
|
|
87
|
-
</div>
|
|
88
|
-
<div>
|
|
89
|
-
<WeatherCard />
|
|
90
|
-
</div>
|
|
91
|
-
</div>
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
### Full-width widget
|
|
95
|
-
|
|
96
|
-
For a standalone weather section:
|
|
97
|
-
|
|
98
|
-
```tsx
|
|
99
|
-
<section className="mx-auto max-w-md">
|
|
100
|
-
<WeatherCard />
|
|
101
|
-
</section>
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
---
|
|
105
|
-
|
|
106
|
-
## Loading state
|
|
107
|
-
|
|
108
|
-
Show a text indicator while data is fetching:
|
|
109
|
-
|
|
110
|
-
```tsx
|
|
111
|
-
{loading && <p className="text-sm text-muted-foreground">Loading weather…</p>}
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
For a richer skeleton:
|
|
115
|
-
|
|
116
|
-
```tsx
|
|
117
|
-
{loading && (
|
|
118
|
-
<div className="space-y-3">
|
|
119
|
-
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
|
120
|
-
<div className="h-10 w-16 animate-pulse rounded bg-muted" />
|
|
121
|
-
<div className="flex gap-4">
|
|
122
|
-
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
|
|
123
|
-
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
|
|
124
|
-
</div>
|
|
125
|
-
</div>
|
|
126
|
-
)}
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
---
|
|
130
|
-
|
|
131
|
-
## Error state
|
|
132
|
-
|
|
133
|
-
Show error messages with proper ARIA:
|
|
134
|
-
|
|
135
|
-
```tsx
|
|
136
|
-
{error && (
|
|
137
|
-
<p className="text-sm text-destructive" role="alert">
|
|
138
|
-
{error}
|
|
139
|
-
</p>
|
|
140
|
-
)}
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
---
|
|
144
|
-
|
|
145
|
-
## Extending to daily forecast
|
|
146
|
-
|
|
147
|
-
To show a multi-day forecast, update the `fetchWeather` function to request daily data:
|
|
148
|
-
|
|
149
|
-
```ts
|
|
150
|
-
url.searchParams.set("daily", "weather_code,temperature_2m_max,temperature_2m_min");
|
|
151
|
-
url.searchParams.set("forecast_days", "7");
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
Then render each day:
|
|
155
|
-
|
|
156
|
-
```tsx
|
|
157
|
-
{weather.daily.map((day) => (
|
|
158
|
-
<div key={day.date} className="flex items-center justify-between rounded-lg bg-muted/50 px-3 py-2">
|
|
159
|
-
<span className="text-sm text-muted-foreground">{day.dayName}</span>
|
|
160
|
-
<span className="text-sm font-medium">{day.highF}° / {day.lowF}°</span>
|
|
161
|
-
<span className="text-xs text-muted-foreground">{day.description}</span>
|
|
162
|
-
</div>
|
|
163
|
-
))}
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
---
|
|
167
|
-
|
|
168
|
-
## Using browser geolocation
|
|
169
|
-
|
|
170
|
-
To auto-detect the user's location:
|
|
171
|
-
|
|
172
|
-
```ts
|
|
173
|
-
import { useState, useEffect } from "react";
|
|
174
|
-
|
|
175
|
-
export function useUserLocation(): { lat: number | null; lng: number | null; error: string | null } {
|
|
176
|
-
const [lat, setLat] = useState<number | null>(null);
|
|
177
|
-
const [lng, setLng] = useState<number | null>(null);
|
|
178
|
-
const [error, setError] = useState<string | null>(null);
|
|
179
|
-
|
|
180
|
-
useEffect(() => {
|
|
181
|
-
if (!navigator.geolocation) {
|
|
182
|
-
setError("Geolocation not supported");
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
navigator.geolocation.getCurrentPosition(
|
|
186
|
-
(pos) => {
|
|
187
|
-
setLat(pos.coords.latitude);
|
|
188
|
-
setLng(pos.coords.longitude);
|
|
189
|
-
},
|
|
190
|
-
(err) => {
|
|
191
|
-
setError(err.message);
|
|
192
|
-
}
|
|
193
|
-
);
|
|
194
|
-
}, []);
|
|
195
|
-
|
|
196
|
-
return { lat, lng, error };
|
|
197
|
-
}
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
Combine with the weather hook:
|
|
201
|
-
|
|
202
|
-
```tsx
|
|
203
|
-
const { lat, lng } = useUserLocation();
|
|
204
|
-
const { data: weather } = useWeather(lat, lng);
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
Falls back to the default location if geolocation is denied or unavailable.
|
|
208
|
-
|
|
209
|
-
---
|
|
210
|
-
|
|
211
|
-
## Accessibility
|
|
212
|
-
|
|
213
|
-
- Error messages use `role="alert"` for screen reader announcement.
|
|
214
|
-
- Temperature values include the unit symbol (`°F` or `°C`) in the text.
|
|
215
|
-
- Hourly forecast items are visually distinct with background color and spacing.
|
|
216
|
-
- Loading state provides text feedback, not just spinners.
|