@wasto/cms-components 1.5.10
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/README.md +47 -0
- package/dist/index.d.mts +465 -0
- package/dist/index.d.ts +465 -0
- package/dist/index.js +10445 -0
- package/dist/index.mjs +10553 -0
- package/package.json +90 -0
- package/src/components/announcement-modal/AnnouncementModal.tsx +183 -0
- package/src/components/article/Article.tsx +22 -0
- package/src/components/article/ArticleCard.tsx +81 -0
- package/src/components/article/ArticleExcerpts.tsx +80 -0
- package/src/components/article/ArticleList.tsx +114 -0
- package/src/components/article/ArticleMarkdown.tsx +170 -0
- package/src/components/article/ArticleReaderModal.tsx +93 -0
- package/src/components/article/ArticleSingle.tsx +57 -0
- package/src/components/bottom-tab/BottomTab.tsx +392 -0
- package/src/components/button/Button.tsx +154 -0
- package/src/components/challenge/Badges.tsx +84 -0
- package/src/components/challenge/BadgesModal.tsx +166 -0
- package/src/components/challenge/Challenge.tsx +380 -0
- package/src/components/challenge/ChallengeModal.tsx +231 -0
- package/src/components/challenge/ChallengeVignette.tsx +249 -0
- package/src/components/challenge/ChallengesModal.tsx +243 -0
- package/src/components/challenge/QuizModal.tsx +381 -0
- package/src/components/challenge/ResultModal.tsx +144 -0
- package/src/components/chat/Chat.tsx +928 -0
- package/src/components/collection-calendar/CollectionCalendar.tsx +274 -0
- package/src/components/columns/Columns.tsx +34 -0
- package/src/components/consignes-modal/ConsignesModal.tsx +267 -0
- package/src/components/content-inset/ContentInset.tsx +33 -0
- package/src/components/header/Header.tsx +391 -0
- package/src/components/image/Image.tsx +16 -0
- package/src/components/image/ImageFrame.tsx +102 -0
- package/src/components/image/ImageMedia.tsx +31 -0
- package/src/components/image/ImageUrl.tsx +30 -0
- package/src/components/locations-map/LocationSheet.tsx +417 -0
- package/src/components/locations-map/LocationsMap.native.tsx +260 -0
- package/src/components/locations-map/LocationsMap.tsx +13 -0
- package/src/components/locations-map/LocationsMap.web.tsx +10 -0
- package/src/components/locations-map/LocationsMapShell.tsx +527 -0
- package/src/components/locations-map/LocationsMapView.tsx +108 -0
- package/src/components/locations-map/LocationsMapView.web.tsx +331 -0
- package/src/components/nav-button/NavButton.tsx +59 -0
- package/src/components/next-collection/NextCollection.tsx +621 -0
- package/src/components/notifications/NotificationsScreen.tsx +247 -0
- package/src/components/page/Page.tsx +64 -0
- package/src/components/parapraph/Paragraph.tsx +67 -0
- package/src/components/profile/ProfileScreen.tsx +236 -0
- package/src/components/recycling-info/RecyclingInfo.tsx +189 -0
- package/src/components/safe-area/OptionalSafeAreaProvider.tsx +17 -0
- package/src/components/search/Search.tsx +320 -0
- package/src/components/select-street-modal/SelectStreetModal.tsx +475 -0
- package/src/components/title/Title.tsx +100 -0
- package/src/components/vision/Vision.tsx +447 -0
- package/src/components/vision/VisionActionCard.tsx +117 -0
- package/src/components/vision/VisionCapture.tsx +30 -0
- package/src/components/vision/VisionTapOverlay.tsx +122 -0
- package/src/contexts/AnnouncementsContext.tsx +171 -0
- package/src/contexts/ChallengesContext.tsx +182 -0
- package/src/contexts/LocalContext.tsx +227 -0
- package/src/contexts/ThemeContext.tsx +37 -0
- package/src/contexts/WastoContext.tsx +127 -0
- package/src/helpers/blogPost.ts +72 -0
- package/src/helpers/generateQuizQuestions.ts +247 -0
- package/src/helpers/getContrastColor.ts +19 -0
- package/src/helpers/haptics.ts +20 -0
- package/src/helpers/hoursSchedule.ts +230 -0
- package/src/helpers/htmlContentToMarkdown.ts +114 -0
- package/src/helpers/layout.ts +54 -0
- package/src/helpers/loadLinearGradient.ts +22 -0
- package/src/helpers/maquetteTheme.ts +76 -0
- package/src/helpers/markdownUnderlinePlugin.ts +32 -0
- package/src/helpers/normalizeWasteTypeRecyclingInfo.ts +80 -0
- package/src/helpers/publicMediaUrl.ts +8 -0
- package/src/helpers/resolveApiAssetUrl.ts +21 -0
- package/src/helpers/systemRoutes.ts +7 -0
- package/src/helpers/themeStyles.ts +114 -0
- package/src/helpers/useSafeAreaPadding.ts +14 -0
- package/src/hooks/useBadges.ts +100 -0
- package/src/hooks/useBlogPosts.ts +105 -0
- package/src/hooks/useChallenges.ts +110 -0
- package/src/hooks/useCities.ts +56 -0
- package/src/hooks/useCityAnnouncements.ts +118 -0
- package/src/hooks/useCollectionCalendar.ts +421 -0
- package/src/hooks/useColumns.ts +20 -0
- package/src/hooks/useConsignes.ts +62 -0
- package/src/hooks/useLocations.ts +248 -0
- package/src/hooks/useNextCollection.ts +238 -0
- package/src/hooks/useRecyclingInfo.ts +90 -0
- package/src/hooks/useWastoClient.ts +16 -0
- package/src/index.ts +142 -0
- package/src/navigation/NavigationContext.tsx +56 -0
- package/src/sentry/initSentry.ts +120 -0
- package/src/types/Theme.ts +72 -0
- package/src/types/css-modules.d.ts +1 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { MAX_PAGE_SIZE } from "@wasto/sdk";
|
|
3
|
+
import { useLocal } from "../contexts/LocalContext";
|
|
4
|
+
import {
|
|
5
|
+
buildHoursSchedule,
|
|
6
|
+
type HoursDayRow,
|
|
7
|
+
type HoursStatus,
|
|
8
|
+
} from "../helpers/hoursSchedule";
|
|
9
|
+
import { useWastoClient } from "./useWastoClient";
|
|
10
|
+
|
|
11
|
+
export type LocationCategory = "recycling" | "decheterie" | "collection_place" | "sorting_center";
|
|
12
|
+
|
|
13
|
+
export type MapLocation = {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
category: LocationCategory;
|
|
17
|
+
address?: string;
|
|
18
|
+
phone?: string;
|
|
19
|
+
hoursDisplay?: string;
|
|
20
|
+
hoursRows?: HoursDayRow[];
|
|
21
|
+
hoursStatus?: HoursStatus | null;
|
|
22
|
+
lat: number;
|
|
23
|
+
lon: number;
|
|
24
|
+
badges?: string[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const LOCATION_CATEGORY_META: Record<
|
|
28
|
+
LocationCategory,
|
|
29
|
+
{ label: string; color: string }
|
|
30
|
+
> = {
|
|
31
|
+
recycling: { label: "Points de collecte", color: "#00C2EE" },
|
|
32
|
+
decheterie: { label: "Déchèteries", color: "#ea580c" },
|
|
33
|
+
collection_place: { label: "Lieux de ramassage", color: "#7c3aed" },
|
|
34
|
+
sorting_center: { label: "Centres de tri", color: "#8B00D8" },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const REUNION_CENTER = { lat: -21.115, lon: 55.53 };
|
|
38
|
+
const PAGE_SIZE = MAX_PAGE_SIZE;
|
|
39
|
+
|
|
40
|
+
/** GeoJSON order is [lon, lat]. Rejects NaN / out-of-range. */
|
|
41
|
+
export function isValidLatLon(lat: number, lon: number): boolean {
|
|
42
|
+
return (
|
|
43
|
+
Number.isFinite(lat) &&
|
|
44
|
+
Number.isFinite(lon) &&
|
|
45
|
+
lat >= -90 &&
|
|
46
|
+
lat <= 90 &&
|
|
47
|
+
lon >= -180 &&
|
|
48
|
+
lon <= 180
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toNumber(value: unknown): number | null {
|
|
53
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
54
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
55
|
+
const n = Number(value);
|
|
56
|
+
return Number.isFinite(n) ? n : null;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Named lon/lat fields — no SVG conversion. */
|
|
62
|
+
function parseLngLat(
|
|
63
|
+
lonCandidate: unknown,
|
|
64
|
+
latCandidate: unknown,
|
|
65
|
+
): { lat: number; lon: number } | null {
|
|
66
|
+
const lon = toNumber(lonCandidate);
|
|
67
|
+
const lat = toNumber(latCandidate);
|
|
68
|
+
if (lon == null || lat == null || !isValidLatLon(lat, lon)) return null;
|
|
69
|
+
return { lat, lon };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Prefer WGS84 latitude/longitude. Legacy coordinateX/Y are aliases
|
|
74
|
+
* (X = longitude, Y = latitude) already in WGS84 after API migration.
|
|
75
|
+
*/
|
|
76
|
+
function parseRecyclingPointCoords(p: Record<string, unknown>): { lat: number; lon: number } | null {
|
|
77
|
+
const details =
|
|
78
|
+
p.details != null && typeof p.details === "object"
|
|
79
|
+
? (p.details as Record<string, unknown>)
|
|
80
|
+
: null;
|
|
81
|
+
|
|
82
|
+
const named = parseLngLat(
|
|
83
|
+
p.longitude ?? p.lon ?? details?.longitude ?? details?.lon,
|
|
84
|
+
p.latitude ?? p.lat ?? details?.latitude ?? details?.lat,
|
|
85
|
+
);
|
|
86
|
+
if (named) return named;
|
|
87
|
+
|
|
88
|
+
// Legacy aliases: coordinateX = longitude, coordinateY = latitude
|
|
89
|
+
return parseLngLat(p.coordinateX, p.coordinateY);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function categorizeRecyclingType(type?: string): LocationCategory {
|
|
93
|
+
const t = (type ?? "").toLowerCase();
|
|
94
|
+
if (t.includes("déchèterie") || t.includes("decheterie") || t.includes("dechetterie")) {
|
|
95
|
+
return "decheterie";
|
|
96
|
+
}
|
|
97
|
+
if (t.includes("tri") || t.includes("sorting")) {
|
|
98
|
+
return "sorting_center";
|
|
99
|
+
}
|
|
100
|
+
return "recycling";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function asArray(res: unknown): unknown[] {
|
|
104
|
+
if (Array.isArray(res)) return res;
|
|
105
|
+
const data = (res as { data?: unknown })?.data;
|
|
106
|
+
return Array.isArray(data) ? data : [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function totalPagesFrom(res: unknown): number {
|
|
110
|
+
const meta = (res as { meta?: { totalPages?: number } })?.meta;
|
|
111
|
+
const totalPages = meta?.totalPages;
|
|
112
|
+
return typeof totalPages === "number" && totalPages >= 1 ? totalPages : 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type PaginatedFetcher = (params: { page: number; pageSize: number }) => Promise<unknown>;
|
|
116
|
+
|
|
117
|
+
async function fetchAllPages(fetchPage: PaginatedFetcher): Promise<unknown[]> {
|
|
118
|
+
const first = await fetchPage({ page: 1, pageSize: PAGE_SIZE });
|
|
119
|
+
const items = [...asArray(first)];
|
|
120
|
+
const totalPages = totalPagesFrom(first);
|
|
121
|
+
|
|
122
|
+
for (let page = 2; page <= totalPages; page++) {
|
|
123
|
+
const res = await fetchPage({ page, pageSize: PAGE_SIZE });
|
|
124
|
+
items.push(...asArray(res));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return items;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type UseLocationsResult = {
|
|
131
|
+
locations: MapLocation[];
|
|
132
|
+
isLoading: boolean;
|
|
133
|
+
error: string | null;
|
|
134
|
+
center: { lat: number; lon: number };
|
|
135
|
+
refetch: () => Promise<void>;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export function useLocations(): UseLocationsResult {
|
|
139
|
+
const { selectedCityId } = useLocal();
|
|
140
|
+
const client = useWastoClient();
|
|
141
|
+
|
|
142
|
+
const [locations, setLocations] = useState<MapLocation[]>([]);
|
|
143
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
144
|
+
const [error, setError] = useState<string | null>(null);
|
|
145
|
+
|
|
146
|
+
const fetchLocations = useCallback(async () => {
|
|
147
|
+
if (!selectedCityId) {
|
|
148
|
+
setLocations([]);
|
|
149
|
+
setIsLoading(false);
|
|
150
|
+
setError(null);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
setIsLoading(true);
|
|
156
|
+
setError(null);
|
|
157
|
+
const [points, places] = await Promise.all([
|
|
158
|
+
fetchAllPages((params) =>
|
|
159
|
+
client.public.cities.getRecyclingPoints(selectedCityId, params),
|
|
160
|
+
),
|
|
161
|
+
fetchAllPages((params) =>
|
|
162
|
+
client.public.cities.getWasteCollectionPlaces(selectedCityId, params),
|
|
163
|
+
),
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
const out: MapLocation[] = [];
|
|
167
|
+
|
|
168
|
+
for (const raw of points) {
|
|
169
|
+
const p = raw as Record<string, unknown>;
|
|
170
|
+
const coords = parseRecyclingPointCoords(p);
|
|
171
|
+
if (!coords) continue;
|
|
172
|
+
const id = String(p.id ?? "");
|
|
173
|
+
if (!id) continue;
|
|
174
|
+
const hoursSource = {
|
|
175
|
+
hours: typeof p.hours === "string" ? p.hours : null,
|
|
176
|
+
details:
|
|
177
|
+
p.details != null && typeof p.details === "object"
|
|
178
|
+
? (p.details as Record<string, unknown>)
|
|
179
|
+
: null,
|
|
180
|
+
};
|
|
181
|
+
const schedule = buildHoursSchedule(hoursSource);
|
|
182
|
+
out.push({
|
|
183
|
+
id: `rp-${id}`,
|
|
184
|
+
name: String(p.name ?? "Point de collecte"),
|
|
185
|
+
category: categorizeRecyclingType(typeof p.type === "string" ? p.type : undefined),
|
|
186
|
+
address: typeof p.address === "string" ? p.address : undefined,
|
|
187
|
+
phone: typeof p.phone === "string" ? p.phone : undefined,
|
|
188
|
+
hoursDisplay: schedule.summary ?? undefined,
|
|
189
|
+
hoursRows: schedule.rows.length ? schedule.rows : undefined,
|
|
190
|
+
hoursStatus: schedule.status,
|
|
191
|
+
lat: coords.lat,
|
|
192
|
+
lon: coords.lon,
|
|
193
|
+
badges: schedule.status ? [schedule.status.text] : schedule.summary ? [schedule.summary] : undefined,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const raw of places) {
|
|
198
|
+
const place = raw as Record<string, unknown>;
|
|
199
|
+
const locs = Array.isArray(place.attachedLocations) ? place.attachedLocations : [];
|
|
200
|
+
const loc = locs.find((item) => {
|
|
201
|
+
const l = item as Record<string, unknown>;
|
|
202
|
+
return l.latitude != null && l.longitude != null;
|
|
203
|
+
}) as Record<string, unknown> | undefined;
|
|
204
|
+
if (!loc) continue;
|
|
205
|
+
const coords = parseLngLat(loc.longitude, loc.latitude);
|
|
206
|
+
if (!coords) continue;
|
|
207
|
+
const id = String(place.id ?? loc.id ?? "");
|
|
208
|
+
if (!id) continue;
|
|
209
|
+
out.push({
|
|
210
|
+
id: `cp-${id}`,
|
|
211
|
+
name: String(place.name ?? loc.name ?? "Lieu de ramassage"),
|
|
212
|
+
category: "collection_place",
|
|
213
|
+
address: typeof loc.reference === "string" ? loc.reference : undefined,
|
|
214
|
+
lat: coords.lat,
|
|
215
|
+
lon: coords.lon,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
setLocations(out);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
setError(err instanceof Error ? err.message : "Impossible de charger les lieux");
|
|
222
|
+
setLocations([]);
|
|
223
|
+
} finally {
|
|
224
|
+
setIsLoading(false);
|
|
225
|
+
}
|
|
226
|
+
}, [client.public.cities, selectedCityId]);
|
|
227
|
+
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
void fetchLocations();
|
|
230
|
+
}, [fetchLocations]);
|
|
231
|
+
|
|
232
|
+
const center = useMemo(() => {
|
|
233
|
+
const valid = locations.filter((l) => isValidLatLon(l.lat, l.lon));
|
|
234
|
+
if (valid.length === 0) return REUNION_CENTER;
|
|
235
|
+
const lat = valid.reduce((sum, l) => sum + l.lat, 0) / valid.length;
|
|
236
|
+
const lon = valid.reduce((sum, l) => sum + l.lon, 0) / valid.length;
|
|
237
|
+
if (!isValidLatLon(lat, lon)) return REUNION_CENTER;
|
|
238
|
+
return { lat, lon };
|
|
239
|
+
}, [locations]);
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
locations,
|
|
243
|
+
isLoading,
|
|
244
|
+
error,
|
|
245
|
+
center,
|
|
246
|
+
refetch: fetchLocations,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { useLocal } from "../contexts/LocalContext";
|
|
3
|
+
import { getContrastColor } from "../helpers/getContrastColor";
|
|
4
|
+
import { useWastoClient } from "./useWastoClient";
|
|
5
|
+
|
|
6
|
+
export type City = {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
status?: "NON_ACTIF" | "INCOMPLET" | "ACTIF" | "MAINTENANCE";
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type WasteType = {
|
|
14
|
+
id: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
icon?: string;
|
|
17
|
+
imageUrl?: string | null;
|
|
18
|
+
color?: string;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type WasteCollectionPlace = {
|
|
23
|
+
id: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
collectionDays?: Array<{
|
|
26
|
+
/** Ex: "Lundi", "Mardi", ... (mapping lundi=0 … dimanche=6) */
|
|
27
|
+
name: string;
|
|
28
|
+
wasteTypeId: string;
|
|
29
|
+
biweekly?: boolean;
|
|
30
|
+
startDate?: string;
|
|
31
|
+
}>;
|
|
32
|
+
/** Périodes d’exception (pas de collecte) */
|
|
33
|
+
exceptions?: Array<{ dateFrom: string; dateTo: string }>;
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type UseNextCollectionOptions = {
|
|
38
|
+
/** Si omis, on prend `selectedCityId` depuis `LocalContext`. */
|
|
39
|
+
cityId?: string | null;
|
|
40
|
+
/** Décalage (en jours) par rapport à aujourd’hui. 0 = aujourd’hui, 1 = demain, etc. */
|
|
41
|
+
dateOffset?: number;
|
|
42
|
+
/** Lieu de collecte sélectionné. Si omis : 1er lieu disponible. */
|
|
43
|
+
selectedPlaceId?: string | null;
|
|
44
|
+
pageSize?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type CollectionForDay = {
|
|
48
|
+
id: string;
|
|
49
|
+
name: string;
|
|
50
|
+
icon?: string;
|
|
51
|
+
imageUrl?: string | null;
|
|
52
|
+
color?: string;
|
|
53
|
+
contrastColor?: "#000000" | "#FFFFFF";
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type UseNextCollectionResult = {
|
|
57
|
+
cityId: string | null;
|
|
58
|
+
city: City | null;
|
|
59
|
+
wasteTypes: WasteType[];
|
|
60
|
+
wasteCollectionPlaces: WasteCollectionPlace[];
|
|
61
|
+
/** Décalage (en jours) utilisé pour calculer `collectionsForDay`. */
|
|
62
|
+
dateOffset: number;
|
|
63
|
+
/** Libellé “HIER / AUJOURD’HUI / DEMAIN / Dans N jour(s)” */
|
|
64
|
+
dateLabel: string;
|
|
65
|
+
/** Types de déchets collectés pour ce jour (vide si aucune collecte) */
|
|
66
|
+
collectionsForDay: CollectionForDay[];
|
|
67
|
+
isLoading: boolean;
|
|
68
|
+
error: string | null;
|
|
69
|
+
refetch: () => Promise<void>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const DAY_MAPPING: Record<string, number> = {
|
|
73
|
+
Dimanche: 6,
|
|
74
|
+
Lundi: 0,
|
|
75
|
+
Mardi: 1,
|
|
76
|
+
Mercredi: 2,
|
|
77
|
+
Jeudi: 3,
|
|
78
|
+
Vendredi: 4,
|
|
79
|
+
Samedi: 5,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function getDateLabel(offset: number): string {
|
|
83
|
+
if (offset === -1) return "hier";
|
|
84
|
+
if (offset === 0) return "Aujourd'hui";
|
|
85
|
+
if (offset === 1) return "Demain";
|
|
86
|
+
return offset < 0 ? `Il y a ${Math.abs(offset)} jours` : `Dans ${offset} jours`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getCollectionsForDay(
|
|
90
|
+
wasteTypes: WasteType[],
|
|
91
|
+
wasteCollectionPlaces: WasteCollectionPlace[],
|
|
92
|
+
dateOffset: number,
|
|
93
|
+
selectedPlaceId?: string | null
|
|
94
|
+
): CollectionForDay[] {
|
|
95
|
+
if (!wasteCollectionPlaces?.length) return [];
|
|
96
|
+
|
|
97
|
+
const place = selectedPlaceId
|
|
98
|
+
? wasteCollectionPlaces.find((p) => p.id === selectedPlaceId) ?? wasteCollectionPlaces[0]
|
|
99
|
+
: wasteCollectionPlaces[0];
|
|
100
|
+
|
|
101
|
+
const schedule = place?.collectionDays ?? [];
|
|
102
|
+
const exceptions = place?.exceptions ?? [];
|
|
103
|
+
|
|
104
|
+
const targetDate = new Date();
|
|
105
|
+
targetDate.setDate(targetDate.getDate() + dateOffset);
|
|
106
|
+
const dayOfWeek = targetDate.getDay();
|
|
107
|
+
const mondayBasedDay = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
|
108
|
+
|
|
109
|
+
const results: CollectionForDay[] = [];
|
|
110
|
+
const seen = new Set<string>();
|
|
111
|
+
|
|
112
|
+
for (const day of schedule) {
|
|
113
|
+
if (DAY_MAPPING[day.name] !== mondayBasedDay) continue;
|
|
114
|
+
|
|
115
|
+
const hasException = exceptions.some((exc) => {
|
|
116
|
+
const from = new Date(exc.dateFrom + "T00:00:00");
|
|
117
|
+
const to = new Date(exc.dateTo + "T00:00:00");
|
|
118
|
+
return targetDate.getTime() >= from.getTime() && targetDate.getTime() < to.getTime();
|
|
119
|
+
});
|
|
120
|
+
if (hasException) continue;
|
|
121
|
+
|
|
122
|
+
if (day.biweekly && day.startDate) {
|
|
123
|
+
const start = new Date(day.startDate);
|
|
124
|
+
const diffDays = Math.floor(
|
|
125
|
+
(targetDate.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)
|
|
126
|
+
);
|
|
127
|
+
const weeks = Math.floor(diffDays / 7);
|
|
128
|
+
if (weeks < 0 || weeks % 2 !== 0) continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const wt = wasteTypes.find((w) => w.id === day.wasteTypeId);
|
|
132
|
+
if (!wt) continue;
|
|
133
|
+
|
|
134
|
+
if (seen.has(wt.id)) continue;
|
|
135
|
+
seen.add(wt.id);
|
|
136
|
+
|
|
137
|
+
results.push({
|
|
138
|
+
id: wt.id,
|
|
139
|
+
name: wt.name ?? "",
|
|
140
|
+
icon: typeof wt.icon === "string" ? wt.icon : undefined,
|
|
141
|
+
imageUrl: typeof wt.imageUrl === "string" ? wt.imageUrl : undefined,
|
|
142
|
+
color: typeof wt.color === "string" ? wt.color : undefined,
|
|
143
|
+
contrastColor:
|
|
144
|
+
typeof wt.color === "string" && wt.color.trim()
|
|
145
|
+
? getContrastColor(wt.color.trim())
|
|
146
|
+
: undefined,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return results;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Récupère les données nécessaires pour afficher le bloc "Prochaine collecte":
|
|
155
|
+
* - ville
|
|
156
|
+
* - types de déchets
|
|
157
|
+
* - lieux de collecte
|
|
158
|
+
*
|
|
159
|
+
* Le calcul exact de la "prochaine collecte" dépend du modèle de calendrier côté API
|
|
160
|
+
* et sera fait par le composant (ou un util) à partir des `wasteTypes`/`wasteCollectionPlaces`.
|
|
161
|
+
*/
|
|
162
|
+
export function useNextCollection(options: UseNextCollectionOptions = {}): UseNextCollectionResult {
|
|
163
|
+
const { selectedCityId, selectedPlaceId: localPlaceId } = useLocal();
|
|
164
|
+
|
|
165
|
+
const resolvedCityId = options.cityId ?? selectedCityId ?? null;
|
|
166
|
+
const pageSize = options.pageSize ?? 100;
|
|
167
|
+
const dateOffset = options.dateOffset ?? 0;
|
|
168
|
+
const selectedPlaceId = options.selectedPlaceId ?? localPlaceId ?? null;
|
|
169
|
+
|
|
170
|
+
const client = useWastoClient();
|
|
171
|
+
|
|
172
|
+
const [city, setCity] = useState<City | null>(null);
|
|
173
|
+
const [wasteTypes, setWasteTypes] = useState<WasteType[]>([]);
|
|
174
|
+
const [wasteCollectionPlaces, setWasteCollectionPlaces] = useState<WasteCollectionPlace[]>([]);
|
|
175
|
+
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
176
|
+
const [error, setError] = useState<string | null>(null);
|
|
177
|
+
|
|
178
|
+
const fetchData = useCallback(async () => {
|
|
179
|
+
if (!resolvedCityId) {
|
|
180
|
+
setCity(null);
|
|
181
|
+
setWasteTypes([]);
|
|
182
|
+
setWasteCollectionPlaces([]);
|
|
183
|
+
setIsLoading(false);
|
|
184
|
+
setError(null);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
setIsLoading(true);
|
|
190
|
+
setError(null);
|
|
191
|
+
|
|
192
|
+
const [cityRes, typesRes, placesRes] = await Promise.all([
|
|
193
|
+
client.public.cities.getById(resolvedCityId),
|
|
194
|
+
client.public.cities.getWasteTypes(resolvedCityId, { pageSize }),
|
|
195
|
+
client.public.cities.getWasteCollectionPlaces(resolvedCityId, { pageSize }),
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
setCity((cityRes ?? null) as City | null);
|
|
199
|
+
|
|
200
|
+
const types = (typesRes as { data?: unknown })?.data;
|
|
201
|
+
setWasteTypes(Array.isArray(types) ? (types as WasteType[]) : []);
|
|
202
|
+
|
|
203
|
+
const places = (placesRes as { data?: unknown })?.data;
|
|
204
|
+
setWasteCollectionPlaces(Array.isArray(places) ? (places as WasteCollectionPlace[]) : []);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
const message =
|
|
207
|
+
err instanceof Error ? err.message : typeof err === "string" ? err : "Impossible de charger les données";
|
|
208
|
+
setError(message);
|
|
209
|
+
setCity(null);
|
|
210
|
+
setWasteTypes([]);
|
|
211
|
+
setWasteCollectionPlaces([]);
|
|
212
|
+
} finally {
|
|
213
|
+
setIsLoading(false);
|
|
214
|
+
}
|
|
215
|
+
}, [client.public.cities, pageSize, resolvedCityId]);
|
|
216
|
+
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
void fetchData();
|
|
219
|
+
}, [fetchData]);
|
|
220
|
+
|
|
221
|
+
const collectionsForDay = useMemo(
|
|
222
|
+
() => getCollectionsForDay(wasteTypes, wasteCollectionPlaces, dateOffset, selectedPlaceId),
|
|
223
|
+
[wasteTypes, wasteCollectionPlaces, dateOffset, selectedPlaceId]
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
cityId: resolvedCityId,
|
|
228
|
+
city,
|
|
229
|
+
wasteTypes,
|
|
230
|
+
wasteCollectionPlaces,
|
|
231
|
+
dateOffset,
|
|
232
|
+
dateLabel: getDateLabel(dateOffset),
|
|
233
|
+
collectionsForDay,
|
|
234
|
+
isLoading,
|
|
235
|
+
error,
|
|
236
|
+
refetch: fetchData,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { useLocal } from "../contexts/LocalContext";
|
|
3
|
+
import {
|
|
4
|
+
normalizeWasteTypeToRecyclingInfo,
|
|
5
|
+
type RecyclingInfoItem,
|
|
6
|
+
} from "../helpers/normalizeWasteTypeRecyclingInfo";
|
|
7
|
+
import { useNextCollection, type City, type WasteType } from "./useNextCollection";
|
|
8
|
+
|
|
9
|
+
export type { RecyclingInfoItem };
|
|
10
|
+
|
|
11
|
+
export type UseRecyclingInfoOptions = {
|
|
12
|
+
/** Si omis, utilise `LocalContext.selectedCityId`. */
|
|
13
|
+
cityId?: string | null;
|
|
14
|
+
/**
|
|
15
|
+
* Si renseigné : ne renvoie que ce type de déchet.
|
|
16
|
+
* Sinon : renvoie la liste complète des types pour la ville.
|
|
17
|
+
*/
|
|
18
|
+
wasteTypeId?: string | null;
|
|
19
|
+
pageSize?: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type UseRecyclingInfoResult = {
|
|
23
|
+
cityId: string | null;
|
|
24
|
+
city: City | null;
|
|
25
|
+
wasteTypes: WasteType[];
|
|
26
|
+
/** Liste filtrée selon `wasteTypeId` (ou `wasteTypes` si pas de filtre). */
|
|
27
|
+
displayWasteTypes: WasteType[];
|
|
28
|
+
/**
|
|
29
|
+
* Infos de tri / recyclage normalisées (règles, détails, etc.) pour chaque type affiché.
|
|
30
|
+
* Préférer ce tableau pour l’UI plutôt que `displayWasteTypes` seul.
|
|
31
|
+
*/
|
|
32
|
+
recyclingInfos: RecyclingInfoItem[];
|
|
33
|
+
isLoading: boolean;
|
|
34
|
+
error: string | null;
|
|
35
|
+
refetch: () => Promise<void>;
|
|
36
|
+
getWasteTypeById: (id: string) => WasteType | undefined;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Hook utilitaire pour le composant `RecyclingInfo`.
|
|
41
|
+
* Il s’appuie sur `useNextCollection` pour récupérer la ville et les wasteTypes,
|
|
42
|
+
* puis applique un filtre optionnel par `wasteTypeId`.
|
|
43
|
+
*/
|
|
44
|
+
export function useRecyclingInfo(options: UseRecyclingInfoOptions = {}): UseRecyclingInfoResult {
|
|
45
|
+
const { selectedCityId } = useLocal();
|
|
46
|
+
const resolvedCityId = options.cityId ?? selectedCityId ?? null;
|
|
47
|
+
|
|
48
|
+
const {
|
|
49
|
+
city,
|
|
50
|
+
wasteTypes,
|
|
51
|
+
isLoading,
|
|
52
|
+
error,
|
|
53
|
+
refetch,
|
|
54
|
+
cityId,
|
|
55
|
+
} = useNextCollection({
|
|
56
|
+
cityId: resolvedCityId,
|
|
57
|
+
pageSize: options.pageSize,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const wasteTypesList = useMemo(() => (wasteTypes ?? []) as WasteType[], [wasteTypes]);
|
|
61
|
+
const filterId = (options.wasteTypeId ?? "").trim();
|
|
62
|
+
|
|
63
|
+
const displayWasteTypes = useMemo(() => {
|
|
64
|
+
if (!filterId) return wasteTypesList;
|
|
65
|
+
return wasteTypesList.filter((w) => w.id === filterId);
|
|
66
|
+
}, [wasteTypesList, filterId]);
|
|
67
|
+
|
|
68
|
+
const recyclingInfos = useMemo(() => {
|
|
69
|
+
const list: RecyclingInfoItem[] = [];
|
|
70
|
+
for (const w of displayWasteTypes) {
|
|
71
|
+
const item = normalizeWasteTypeToRecyclingInfo(w);
|
|
72
|
+
if (item) list.push(item);
|
|
73
|
+
}
|
|
74
|
+
return list;
|
|
75
|
+
}, [displayWasteTypes]);
|
|
76
|
+
|
|
77
|
+
const getWasteTypeById = (id: string) => wasteTypesList.find((w) => w.id === id);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
cityId,
|
|
81
|
+
city,
|
|
82
|
+
wasteTypes: wasteTypesList,
|
|
83
|
+
displayWasteTypes,
|
|
84
|
+
recyclingInfos,
|
|
85
|
+
isLoading,
|
|
86
|
+
error,
|
|
87
|
+
refetch,
|
|
88
|
+
getWasteTypeById,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { createWastoClient, type WastoClient } from "@wasto/sdk";
|
|
3
|
+
import { useWasto } from "../contexts/WastoContext";
|
|
4
|
+
|
|
5
|
+
/** Shared Wasto HTTP client wired with optional API error reporting. */
|
|
6
|
+
export function useWastoClient(): WastoClient {
|
|
7
|
+
const { apiUrl, onRequestError } = useWasto();
|
|
8
|
+
return useMemo(
|
|
9
|
+
() =>
|
|
10
|
+
createWastoClient({
|
|
11
|
+
baseUrl: apiUrl,
|
|
12
|
+
onRequestError,
|
|
13
|
+
}),
|
|
14
|
+
[apiUrl, onRequestError],
|
|
15
|
+
);
|
|
16
|
+
}
|