@salesforce/webapp-template-app-react-sample-b2e-experimental 1.82.0 → 1.83.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/.a4drules/skills/webapp-csp-trusted-sites/SKILL.md +90 -0
- package/dist/.a4drules/skills/webapp-csp-trusted-sites/implementation/metadata-format.md +281 -0
- package/dist/.a4drules/skills/{webapp-add-react-component → webapp-react-add-component}/SKILL.md +1 -1
- package/dist/.a4drules/skills/webapp-react-data-visualization/SKILL.md +72 -0
- package/dist/.a4drules/skills/webapp-react-data-visualization/implementation/dashboard-layout.md +189 -0
- package/dist/.a4drules/skills/webapp-react-data-visualization/implementation/donut-chart.md +181 -0
- package/dist/.a4drules/skills/webapp-react-data-visualization/implementation/stat-card.md +150 -0
- package/dist/.a4drules/skills/webapp-react-interactive-map/SKILL.md +92 -0
- package/dist/.a4drules/skills/webapp-react-interactive-map/implementation/geocoding.md +245 -0
- package/dist/.a4drules/skills/webapp-react-interactive-map/implementation/leaflet-map.md +279 -0
- package/dist/.a4drules/skills/webapp-react-weather-widget/SKILL.md +65 -0
- package/dist/.a4drules/skills/webapp-react-weather-widget/implementation/weather-hook.md +258 -0
- package/dist/.a4drules/skills/webapp-react-weather-widget/implementation/weather-ui.md +216 -0
- package/dist/.a4drules/skills/webapp-ui-ux/SKILL.md +268 -0
- package/dist/.a4drules/skills/webapp-ui-ux/data/charts.csv +26 -0
- package/dist/.a4drules/skills/webapp-ui-ux/data/colors.csv +97 -0
- package/dist/.a4drules/skills/webapp-ui-ux/data/icons.csv +101 -0
- package/dist/.a4drules/skills/webapp-ui-ux/data/landing.csv +31 -0
- package/dist/.a4drules/skills/webapp-ui-ux/data/products.csv +97 -0
- package/dist/.a4drules/skills/webapp-ui-ux/data/react-performance.csv +45 -0
- package/dist/.a4drules/skills/webapp-ui-ux/data/stacks/html-tailwind.csv +56 -0
- package/dist/.a4drules/skills/webapp-ui-ux/data/stacks/react.csv +54 -0
- package/dist/.a4drules/skills/webapp-ui-ux/data/stacks/shadcn.csv +61 -0
- package/dist/.a4drules/skills/webapp-ui-ux/data/styles.csv +68 -0
- package/dist/.a4drules/skills/webapp-ui-ux/data/typography.csv +58 -0
- package/dist/.a4drules/skills/webapp-ui-ux/data/ui-reasoning.csv +101 -0
- package/dist/.a4drules/skills/webapp-ui-ux/data/ux-guidelines.csv +100 -0
- package/dist/.a4drules/skills/webapp-ui-ux/data/web-interface.csv +31 -0
- package/dist/.a4drules/skills/webapp-ui-ux/scripts/core.js +255 -0
- package/dist/.a4drules/skills/webapp-ui-ux/scripts/design_system.js +861 -0
- package/dist/.a4drules/skills/webapp-ui-ux/scripts/search.js +98 -0
- package/dist/.a4drules/skills/webapp-unsplash-images/SKILL.md +71 -0
- package/dist/.a4drules/skills/webapp-unsplash-images/implementation/usage.md +159 -0
- package/dist/.a4drules/webapp-no-node-e.md +54 -15
- package/dist/.a4drules/webapp-react.md +9 -10
- package/dist/.a4drules/webapp-skills-first.md +26 -0
- package/dist/.a4drules/webapp.md +8 -0
- package/dist/CHANGELOG.md +11 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2e/package.json +4 -4
- package/dist/package.json +1 -1
- package/package.json +3 -3
- package/dist/.a4drules/webapp-images.md +0 -15
- /package/dist/.a4drules/skills/{webapp-add-react-component → webapp-react-add-component}/implementation/component.md +0 -0
- /package/dist/.a4drules/skills/{webapp-add-react-component → webapp-react-add-component}/implementation/header-footer.md +0 -0
- /package/dist/.a4drules/skills/{webapp-add-react-component → webapp-react-add-component}/implementation/page.md +0 -0
- /package/dist/.a4drules/{webapp-code-quality.md → webapp-react-code-quality.md} +0 -0
- /package/dist/.a4drules/{webapp-typescript.md → webapp-react-typescript.md} +0 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# Donut / Pie Chart — Implementation Guide
|
|
2
|
+
|
|
3
|
+
Requires **recharts** (install from the web app directory; see SKILL.md Step 2).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Data structure
|
|
8
|
+
|
|
9
|
+
Charts expect an array of objects with `name`, `value`, and `color`:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
interface ChartData {
|
|
13
|
+
name: string;
|
|
14
|
+
value: number;
|
|
15
|
+
color: string;
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Donut chart component
|
|
22
|
+
|
|
23
|
+
Create at `components/DonutChart.tsx`:
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import React from "react";
|
|
27
|
+
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
|
|
28
|
+
import { Card } from "@/components/ui/card";
|
|
29
|
+
|
|
30
|
+
interface ChartData {
|
|
31
|
+
name: string;
|
|
32
|
+
value: number;
|
|
33
|
+
color: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface DonutChartProps {
|
|
37
|
+
title: string;
|
|
38
|
+
data: ChartData[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const DonutChart: React.FC<DonutChartProps> = ({ title, data }) => {
|
|
42
|
+
const total = data.reduce((sum, item) => sum + item.value, 0);
|
|
43
|
+
const mainPercentage = total > 0 ? Math.round((data[0]?.value / total) * 100) : 0;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Card className="p-4 border-gray-200 shadow-sm flex flex-col">
|
|
47
|
+
<h3 className="text-sm font-medium text-primary mb-2 uppercase tracking-wide">
|
|
48
|
+
{title}
|
|
49
|
+
</h3>
|
|
50
|
+
|
|
51
|
+
<div className="relative flex items-center justify-center">
|
|
52
|
+
<ResponsiveContainer width="100%" height={300}>
|
|
53
|
+
<PieChart>
|
|
54
|
+
<Pie
|
|
55
|
+
data={data}
|
|
56
|
+
cx="50%"
|
|
57
|
+
cy="50%"
|
|
58
|
+
innerRadius={70}
|
|
59
|
+
outerRadius={110}
|
|
60
|
+
paddingAngle={2}
|
|
61
|
+
dataKey="value"
|
|
62
|
+
>
|
|
63
|
+
{data.map((entry, index) => (
|
|
64
|
+
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
65
|
+
))}
|
|
66
|
+
</Pie>
|
|
67
|
+
</PieChart>
|
|
68
|
+
</ResponsiveContainer>
|
|
69
|
+
|
|
70
|
+
{/* Center label */}
|
|
71
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
72
|
+
<div className="text-center">
|
|
73
|
+
<div className="text-5xl font-bold text-primary">{mainPercentage}%</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{/* Legend */}
|
|
79
|
+
<div className="mt-6 grid grid-cols-2 gap-3">
|
|
80
|
+
{data.map((item, index) => (
|
|
81
|
+
<div key={index} className="flex items-center gap-2">
|
|
82
|
+
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: item.color }} />
|
|
83
|
+
<span className="text-sm text-gray-700">{item.name}</span>
|
|
84
|
+
</div>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
</Card>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Key Recharts concepts
|
|
95
|
+
|
|
96
|
+
| Component | Purpose |
|
|
97
|
+
|-----------|---------|
|
|
98
|
+
| `ResponsiveContainer` | Wraps chart to make it fill its parent's width |
|
|
99
|
+
| `PieChart` | Chart container for pie/donut |
|
|
100
|
+
| `Pie` | The data ring; `innerRadius` > 0 makes it a donut |
|
|
101
|
+
| `Cell` | Individual segment; accepts `fill` color |
|
|
102
|
+
| `paddingAngle` | Gap between segments (degrees) |
|
|
103
|
+
|
|
104
|
+
### Donut vs Pie
|
|
105
|
+
|
|
106
|
+
| Property | Donut | Pie |
|
|
107
|
+
|----------|-------|-----|
|
|
108
|
+
| `innerRadius` | `> 0` (e.g. `70`) | `0` |
|
|
109
|
+
| Center label | Yes, positioned absolutely | Not typical |
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Preparing chart data from raw records
|
|
114
|
+
|
|
115
|
+
Transform API data into the `ChartData[]` format before passing to the chart:
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
const CATEGORIES = ["Plumbing", "HVAC", "Electrical"] as const;
|
|
119
|
+
const OTHER_LABEL = "Other";
|
|
120
|
+
const COLORS = ["#7C3AED", "#EC4899", "#14B8A6", "#06B6D4"];
|
|
121
|
+
|
|
122
|
+
const chartData = useMemo(() => {
|
|
123
|
+
const counts: Record<string, number> = {};
|
|
124
|
+
CATEGORIES.forEach((c) => (counts[c] = 0));
|
|
125
|
+
counts[OTHER_LABEL] = 0;
|
|
126
|
+
|
|
127
|
+
records.forEach((record) => {
|
|
128
|
+
const type = record.category;
|
|
129
|
+
if (CATEGORIES.includes(type as (typeof CATEGORIES)[number])) {
|
|
130
|
+
counts[type]++;
|
|
131
|
+
} else {
|
|
132
|
+
counts[OTHER_LABEL]++;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return [
|
|
137
|
+
...CATEGORIES.map((name, i) => ({ name, value: counts[name], color: COLORS[i] })),
|
|
138
|
+
{ name: OTHER_LABEL, value: counts[OTHER_LABEL], color: COLORS[CATEGORIES.length] },
|
|
139
|
+
];
|
|
140
|
+
}, [records]);
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Color palette recommendations
|
|
146
|
+
|
|
147
|
+
| Use case | Colors |
|
|
148
|
+
|----------|--------|
|
|
149
|
+
| Categorical (4 items) | `#7C3AED` `#EC4899` `#14B8A6` `#06B6D4` |
|
|
150
|
+
| Status (3 items) | `#22C55E` `#F59E0B` `#EF4444` (green/amber/red) |
|
|
151
|
+
| Sequential | Use opacity variants of one hue: `#7C3AED` at 100%, 75%, 50%, 25% |
|
|
152
|
+
|
|
153
|
+
Keep chart colors consistent with the app's design system. Define them as constants, not inline values.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Other chart types
|
|
158
|
+
|
|
159
|
+
For **bar charts** and **line charts**, use the `AnalyticsChart` component from `feature-react-chart` instead of raw Recharts. See the **`analytics-charts`** skill for usage.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Accessibility
|
|
164
|
+
|
|
165
|
+
- Always include a text legend (not just colors).
|
|
166
|
+
- Chart should be wrapped in a section with a visible heading.
|
|
167
|
+
- For critical data, provide a text summary or table alternative.
|
|
168
|
+
- Use sufficient color contrast between segments.
|
|
169
|
+
- Consider `prefers-reduced-motion` for chart animations.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Common mistakes
|
|
174
|
+
|
|
175
|
+
| Mistake | Fix |
|
|
176
|
+
|---------|-----|
|
|
177
|
+
| Missing `ResponsiveContainer` | Chart won't resize; always wrap in `ResponsiveContainer` |
|
|
178
|
+
| Fixed width/height on `PieChart` | Let `ResponsiveContainer` control sizing |
|
|
179
|
+
| No legend | Add a grid legend below the chart |
|
|
180
|
+
| Inline colors | Extract to constants for consistency |
|
|
181
|
+
| No fallback for empty data | Show "No data" message when `data` is empty |
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Stat Card — Implementation Guide
|
|
2
|
+
|
|
3
|
+
## What is a stat card
|
|
4
|
+
|
|
5
|
+
A stat card displays a single KPI metric with an optional trend indicator. Used on dashboards to show at-a-glance numbers like "Total Properties: 42 (+10%)".
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Component interface
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
interface StatCardProps {
|
|
13
|
+
title: string;
|
|
14
|
+
value: number | string;
|
|
15
|
+
trend?: {
|
|
16
|
+
value: number;
|
|
17
|
+
isPositive: boolean;
|
|
18
|
+
};
|
|
19
|
+
subtitle?: string;
|
|
20
|
+
onClick?: () => void;
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## StatCard component
|
|
27
|
+
|
|
28
|
+
Create at `components/StatCard.tsx`:
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import React from "react";
|
|
32
|
+
import { Card } from "@/components/ui/card";
|
|
33
|
+
import { TrendingUp, TrendingDown } from "lucide-react";
|
|
34
|
+
|
|
35
|
+
interface StatCardProps {
|
|
36
|
+
title: string;
|
|
37
|
+
value: number | string;
|
|
38
|
+
trend?: {
|
|
39
|
+
value: number;
|
|
40
|
+
isPositive: boolean;
|
|
41
|
+
};
|
|
42
|
+
subtitle?: string;
|
|
43
|
+
onClick?: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const StatCard: React.FC<StatCardProps> = ({ title, value, trend, subtitle, onClick }) => {
|
|
47
|
+
return (
|
|
48
|
+
<Card
|
|
49
|
+
className={`p-4 border-gray-200 shadow-sm relative ${
|
|
50
|
+
onClick ? "cursor-pointer hover:shadow-lg transition-shadow" : ""
|
|
51
|
+
}`}
|
|
52
|
+
onClick={onClick}
|
|
53
|
+
>
|
|
54
|
+
<div className="space-y-1">
|
|
55
|
+
<p className="text-sm font-medium text-muted-foreground uppercase tracking-wide">{title}</p>
|
|
56
|
+
<div className="flex items-baseline gap-3">
|
|
57
|
+
<p className="text-4xl font-bold text-primary">{value}</p>
|
|
58
|
+
{trend && (
|
|
59
|
+
<span
|
|
60
|
+
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-sm font-medium ${
|
|
61
|
+
trend.isPositive
|
|
62
|
+
? "bg-emerald-100 text-emerald-800"
|
|
63
|
+
: "bg-pink-100 text-pink-800"
|
|
64
|
+
}`}
|
|
65
|
+
>
|
|
66
|
+
{trend.isPositive ? (
|
|
67
|
+
<TrendingUp className="w-4 h-4" />
|
|
68
|
+
) : (
|
|
69
|
+
<TrendingDown className="w-4 h-4" />
|
|
70
|
+
)}
|
|
71
|
+
{Math.abs(trend.value)}%
|
|
72
|
+
</span>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
{subtitle && <p className="text-sm text-muted-foreground mt-1">{subtitle}</p>}
|
|
76
|
+
</div>
|
|
77
|
+
</Card>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This version uses Lucide icons (`TrendingUp`/`TrendingDown`) instead of custom SVGs for portability across projects.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Layout: stat card grid
|
|
87
|
+
|
|
88
|
+
Display stat cards in a responsive grid:
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
92
|
+
<StatCard
|
|
93
|
+
title="Total Properties"
|
|
94
|
+
value={metrics.totalProperties}
|
|
95
|
+
trend={{ value: 10, isPositive: true }}
|
|
96
|
+
subtitle="Last month total 38"
|
|
97
|
+
/>
|
|
98
|
+
<StatCard
|
|
99
|
+
title="Units Available"
|
|
100
|
+
value={metrics.unitsAvailable}
|
|
101
|
+
trend={{ value: 5, isPositive: false }}
|
|
102
|
+
subtitle="Last month total 12/42"
|
|
103
|
+
/>
|
|
104
|
+
<StatCard
|
|
105
|
+
title="Occupied Units"
|
|
106
|
+
value={metrics.occupiedUnits}
|
|
107
|
+
trend={{ value: 8, isPositive: true }}
|
|
108
|
+
subtitle="Last month total 27"
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Computing trend values
|
|
116
|
+
|
|
117
|
+
Calculate trends from current vs previous period:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
const trends = useMemo(() => {
|
|
121
|
+
const previousTotal = metrics.totalProperties - Math.round(metrics.totalProperties * 0.1);
|
|
122
|
+
const trendPercent = previousTotal > 0
|
|
123
|
+
? Math.round(((metrics.totalProperties - previousTotal) / previousTotal) * 100)
|
|
124
|
+
: 0;
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
value: Math.abs(trendPercent),
|
|
128
|
+
isPositive: trendPercent >= 0,
|
|
129
|
+
};
|
|
130
|
+
}, [metrics]);
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Trend badge color conventions
|
|
136
|
+
|
|
137
|
+
| Trend | Background | Text | Meaning |
|
|
138
|
+
|-------|------------|------|---------|
|
|
139
|
+
| Positive (up) | `bg-emerald-100` | `text-emerald-800` | Growth, improvement |
|
|
140
|
+
| Negative (down) | `bg-pink-100` | `text-pink-800` | Decline, concern |
|
|
141
|
+
| Neutral | `bg-gray-100` | `text-gray-600` | No change |
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Accessibility
|
|
146
|
+
|
|
147
|
+
- Card uses `cursor-pointer` and `hover:shadow-lg` only when `onClick` is provided.
|
|
148
|
+
- Trend icons have implicit meaning from color + direction icon.
|
|
149
|
+
- Stat values use large, bold text for visibility.
|
|
150
|
+
- Title uses `uppercase tracking-wide` for visual hierarchy without heading tags (appropriate in a card grid).
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: webapp-react-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.
|
|
@@ -0,0 +1,245 @@
|
|
|
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.
|