@wasto/cms-components 1.5.10 → 1.5.11

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.
Files changed (98) hide show
  1. package/README.md +47 -47
  2. package/dist/index.d.mts +103 -2
  3. package/dist/index.d.ts +103 -2
  4. package/dist/index.js +2861 -2442
  5. package/dist/index.mjs +3651 -3227
  6. package/package.json +2 -2
  7. package/src/components/announcement-modal/AnnouncementModal.tsx +183 -183
  8. package/src/components/article/Article.tsx +21 -21
  9. package/src/components/article/ArticleCard.tsx +81 -81
  10. package/src/components/article/ArticleExcerpts.tsx +80 -80
  11. package/src/components/article/ArticleList.tsx +114 -114
  12. package/src/components/article/ArticleMarkdown.tsx +170 -170
  13. package/src/components/article/ArticleReaderModal.tsx +93 -93
  14. package/src/components/article/ArticleSingle.tsx +57 -57
  15. package/src/components/bottom-tab/BottomTab.tsx +392 -392
  16. package/src/components/button/Button.tsx +154 -154
  17. package/src/components/challenge/Badges.tsx +84 -84
  18. package/src/components/challenge/BadgesModal.tsx +166 -166
  19. package/src/components/challenge/Challenge.tsx +380 -380
  20. package/src/components/challenge/ChallengeModal.tsx +231 -231
  21. package/src/components/challenge/ChallengeVignette.tsx +249 -249
  22. package/src/components/challenge/ChallengesModal.tsx +243 -243
  23. package/src/components/challenge/QuizModal.tsx +381 -381
  24. package/src/components/challenge/ResultModal.tsx +144 -144
  25. package/src/components/chat/Chat.tsx +928 -928
  26. package/src/components/collection-calendar/CollectionCalendar.tsx +274 -274
  27. package/src/components/columns/Columns.tsx +33 -33
  28. package/src/components/consignes-modal/ConsignesModal.tsx +267 -267
  29. package/src/components/content-inset/ContentInset.tsx +33 -33
  30. package/src/components/header/Header.tsx +391 -391
  31. package/src/components/image/Image.tsx +15 -15
  32. package/src/components/image/ImageFrame.tsx +102 -102
  33. package/src/components/image/ImageMedia.tsx +31 -31
  34. package/src/components/image/ImageUrl.tsx +30 -30
  35. package/src/components/locations-map/LocationSheet.tsx +417 -417
  36. package/src/components/locations-map/LocationsMap.native.tsx +260 -260
  37. package/src/components/locations-map/LocationsMap.tsx +13 -13
  38. package/src/components/locations-map/LocationsMap.web.tsx +10 -10
  39. package/src/components/locations-map/LocationsMapShell.tsx +527 -527
  40. package/src/components/locations-map/LocationsMapView.tsx +108 -108
  41. package/src/components/locations-map/LocationsMapView.web.tsx +331 -331
  42. package/src/components/nav-button/NavButton.tsx +59 -59
  43. package/src/components/next-collection/NextCollection.tsx +636 -621
  44. package/src/components/notifications/NotificationsScreen.tsx +247 -247
  45. package/src/components/page/Page.tsx +102 -64
  46. package/src/components/parapraph/Paragraph.tsx +67 -67
  47. package/src/components/profile/ProfileScreen.tsx +236 -236
  48. package/src/components/recycling-info/RecyclingInfo.tsx +189 -189
  49. package/src/components/safe-area/OptionalSafeAreaProvider.tsx +17 -17
  50. package/src/components/search/Search.tsx +320 -320
  51. package/src/components/select-street-modal/SelectStreetModal.tsx +475 -475
  52. package/src/components/title/Title.tsx +100 -100
  53. package/src/components/vision/Vision.tsx +447 -447
  54. package/src/components/vision/VisionActionCard.tsx +117 -117
  55. package/src/components/vision/VisionCapture.tsx +30 -30
  56. package/src/components/vision/VisionTapOverlay.tsx +122 -122
  57. package/src/contexts/AnnouncementsContext.tsx +171 -171
  58. package/src/contexts/AppDataContext.tsx +458 -0
  59. package/src/contexts/ChallengesContext.tsx +182 -182
  60. package/src/contexts/LocalContext.tsx +227 -227
  61. package/src/contexts/ThemeContext.tsx +37 -37
  62. package/src/contexts/WastoContext.tsx +130 -127
  63. package/src/helpers/blogPost.ts +72 -72
  64. package/src/helpers/generateQuizQuestions.ts +247 -247
  65. package/src/helpers/getContrastColor.ts +18 -18
  66. package/src/helpers/haptics.ts +20 -20
  67. package/src/helpers/hoursSchedule.ts +230 -230
  68. package/src/helpers/htmlContentToMarkdown.ts +114 -114
  69. package/src/helpers/layout.ts +54 -54
  70. package/src/helpers/loadLinearGradient.ts +22 -22
  71. package/src/helpers/mapLocations.ts +170 -0
  72. package/src/helpers/maquetteTheme.ts +76 -76
  73. package/src/helpers/markdownUnderlinePlugin.ts +32 -32
  74. package/src/helpers/nextCollection.ts +122 -0
  75. package/src/helpers/normalizeWasteTypeRecyclingInfo.ts +80 -80
  76. package/src/helpers/publicMediaUrl.ts +8 -8
  77. package/src/helpers/resolveApiAssetUrl.ts +21 -21
  78. package/src/helpers/systemRoutes.ts +7 -7
  79. package/src/helpers/themeStyles.ts +114 -114
  80. package/src/helpers/ttlCache.ts +41 -0
  81. package/src/helpers/useSafeAreaPadding.ts +14 -14
  82. package/src/hooks/useBadges.ts +100 -100
  83. package/src/hooks/useBlogPosts.ts +109 -105
  84. package/src/hooks/useChallenges.ts +110 -110
  85. package/src/hooks/useCities.ts +56 -56
  86. package/src/hooks/useCityAnnouncements.ts +118 -118
  87. package/src/hooks/useCollectionCalendar.ts +421 -421
  88. package/src/hooks/useColumns.ts +19 -19
  89. package/src/hooks/useConsignes.ts +62 -62
  90. package/src/hooks/useLocations.ts +120 -248
  91. package/src/hooks/useNextCollection.ts +163 -238
  92. package/src/hooks/useRecyclingInfo.ts +90 -90
  93. package/src/hooks/useWastoClient.ts +16 -16
  94. package/src/index.ts +149 -142
  95. package/src/navigation/NavigationContext.tsx +56 -56
  96. package/src/sentry/initSentry.ts +120 -120
  97. package/src/types/Theme.ts +72 -72
  98. package/src/types/css-modules.d.ts +1 -1
@@ -1,928 +1,928 @@
1
- import React, { useCallback, useEffect, useRef, useState } from "react";
2
- import {
3
- ActivityIndicator,
4
- FlatList,
5
- Linking,
6
- Modal,
7
- Pressable,
8
- StyleSheet,
9
- Text,
10
- TextInput,
11
- View,
12
- } from "react-native";
13
- import { Image } from "expo-image";
14
- import { Download, MapPin, MessageCircle, Send, X } from "lucide-react-native";
15
- import type { CalendarPdfSector, StreetSectorMatch } from "@wasto/sdk";
16
- import { useTheme } from "../../contexts/ThemeContext";
17
- import { useLocal } from "../../contexts/LocalContext";
18
- import { useWastoClient } from "../../hooks/useWastoClient";
19
- import { resolveTheme } from "../../helpers/themeStyles";
20
- import { SPACE } from "../../helpers/layout";
21
- import { ConsignesModal } from "../consignes-modal/ConsignesModal";
22
- import { resolveApiAssetUrl } from "../../helpers/resolveApiAssetUrl";
23
- import { useWasto } from "../../contexts/WastoContext";
24
- import { getContrastColor } from "../../helpers/getContrastColor";
25
-
26
- export type ChatProps = {
27
- title?: string;
28
- cityId?: string;
29
- cityGroupId?: string;
30
- };
31
-
32
- type ChatWasteTypeCard = {
33
- id: string;
34
- name: string;
35
- description?: string | null;
36
- instructions?: string | null;
37
- color?: string | null;
38
- colorLight?: string | null;
39
- imageUrl?: string | null;
40
- matchedMaterial?: string;
41
- };
42
-
43
- type ChatLink = {
44
- id: string;
45
- label: string;
46
- url: string;
47
- };
48
-
49
- type ChatBubble = {
50
- id: string;
51
- role: "USER" | "ASSISTANT";
52
- content: string;
53
- wasteTypes?: ChatWasteTypeCard[];
54
- links?: ChatLink[];
55
- };
56
-
57
- /** Label compact (pas de hover natif). */
58
- function shortDownloadLabel(label: string): string {
59
- if (/calendrier|pdf/i.test(label)) return "Télécharger le PDF";
60
- if (label.length <= 22) return label;
61
- return `${label.slice(0, 20)}…`;
62
- }
63
-
64
- const DISCLAIMER =
65
- "Informations indicatives · règles de votre territoire. En cas de doute, contactez un agent.";
66
-
67
- const ASK_ADDRESS =
68
- "Pour vous répondre précisément (collectes, consignes locales), quelle est votre adresse ? Indiquez le nom de votre rue.";
69
-
70
- const WELCOME =
71
- "Bonjour ! Je suis Wast'O Agent. Posez-moi une question sur le tri, les collectes ou les déchetteries.";
72
-
73
- /** Texte court dans la vignette (intro + consignes), template générique. */
74
- function wasteTypeCardBody(
75
- fiche: Pick<
76
- ChatWasteTypeCard,
77
- "name" | "matchedMaterial" | "instructions" | "description"
78
- >,
79
- ): string {
80
- const material = fiche.matchedMaterial?.trim();
81
- const lines: string[] = [];
82
- if (material) {
83
- lines.push(
84
- `Pour « ${material} », le référentiel de votre collectivité indique le flux « ${fiche.name} ».`,
85
- );
86
- } else {
87
- lines.push(`Voici la fiche « ${fiche.name} » de votre collectivité.`);
88
- }
89
- const tip = fiche.instructions?.trim() || fiche.description?.trim();
90
- if (tip) {
91
- lines.push(tip);
92
- }
93
- return lines.join("\n\n");
94
- }
95
-
96
- export function Chat({ title, cityId, cityGroupId: cityGroupIdProp }: ChatProps) {
97
- const client = useWastoClient();
98
- const { theme } = useTheme();
99
- const { apiUrl, cityGroupId: contextCityGroupId } = useWasto();
100
- const resolved = resolveTheme(theme);
101
- const local = useLocal();
102
- const cityGroupId =
103
- cityGroupIdProp?.trim() || contextCityGroupId?.trim() || null;
104
- const resolvedCityId = cityId ?? local.selectedCityId ?? null;
105
- const resolvedPlaceId = local.selectedPlaceId ?? null;
106
- const deviceId = local.deviceId;
107
- const hasAddress = Boolean(resolvedCityId && resolvedPlaceId);
108
-
109
- const [open, setOpen] = useState(false);
110
- const [available, setAvailable] = useState<boolean | null>(null);
111
- const [unavailableReason, setUnavailableReason] = useState<string | null>(null);
112
- const [conversationId, setConversationId] = useState<string | null>(null);
113
- const [messages, setMessages] = useState<ChatBubble[]>([]);
114
- const [input, setInput] = useState("");
115
- const [loading, setLoading] = useState(false);
116
- const [initError, setInitError] = useState<string | null>(null);
117
- const [selectedWasteTypeId, setSelectedWasteTypeId] = useState<string | null>(
118
- null,
119
- );
120
- const [needsAddress, setNeedsAddress] = useState(!hasAddress);
121
- const [addressQuery, setAddressQuery] = useState("");
122
- const [addressResults, setAddressResults] = useState<StreetSectorMatch[]>([]);
123
- const [addressSearching, setAddressSearching] = useState(false);
124
- const [selectedStreet, setSelectedStreet] = useState<StreetSectorMatch | null>(
125
- null,
126
- );
127
- const [pendingSectors, setPendingSectors] = useState<CalendarPdfSector[]>([]);
128
- const listRef = useRef<FlatList<ChatBubble>>(null);
129
-
130
- useEffect(() => {
131
- setNeedsAddress(!hasAddress);
132
- }, [hasAddress]);
133
-
134
- useEffect(() => {
135
- let cancelled = false;
136
- (async () => {
137
- if (!cityGroupId && !resolvedCityId) {
138
- setAvailable(false);
139
- setUnavailableReason(
140
- "Configuration manquante : collectivité requise pour Wast'O Agent.",
141
- );
142
- return;
143
- }
144
- try {
145
- const res = await client.public.chat.getAvailability({
146
- cityGroupId: cityGroupId || undefined,
147
- cityId: cityGroupId ? undefined : resolvedCityId || undefined,
148
- });
149
- if (cancelled) return;
150
- setAvailable(Boolean(res?.enabled));
151
- setUnavailableReason(res?.enabled ? null : res?.reason ?? null);
152
- } catch {
153
- if (cancelled) return;
154
- setAvailable(true);
155
- setUnavailableReason(null);
156
- }
157
- })();
158
- return () => {
159
- cancelled = true;
160
- };
161
- }, [client, cityGroupId, resolvedCityId]);
162
-
163
- useEffect(() => {
164
- if (!needsAddress || !open) return;
165
- const trimmed = addressQuery.trim();
166
- if (trimmed.length < 2 || !cityGroupId) {
167
- setAddressResults([]);
168
- setAddressSearching(false);
169
- return;
170
- }
171
- setAddressSearching(true);
172
- const handle = setTimeout(async () => {
173
- try {
174
- const res = await client.public.cityGroups.searchStreetSectors(
175
- cityGroupId,
176
- { q: trimmed },
177
- );
178
- setAddressResults(res.data ?? []);
179
- } catch {
180
- setAddressResults([]);
181
- } finally {
182
- setAddressSearching(false);
183
- }
184
- }, 300);
185
- return () => clearTimeout(handle);
186
- }, [addressQuery, cityGroupId, client.public.cityGroups, needsAddress, open]);
187
-
188
- const ensureConversation = useCallback(async () => {
189
- if (conversationId) return conversationId;
190
- setInitError(null);
191
- const id =
192
- deviceId ??
193
- `anon-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
194
- try {
195
- const conversation = (await client.public.chat.createConversation({
196
- deviceId: id,
197
- cityId: resolvedCityId ?? undefined,
198
- cityGroupId: cityGroupId ?? undefined,
199
- })) as { id: string };
200
- setConversationId(conversation.id);
201
- return conversation.id;
202
- } catch (e: unknown) {
203
- const msg =
204
- e instanceof Error
205
- ? e.message
206
- : "Impossible d’ouvrir l’assistant pour le moment.";
207
- setInitError(msg);
208
- throw e;
209
- }
210
- }, [client, conversationId, deviceId, resolvedCityId, cityGroupId]);
211
-
212
- const handleOpen = async () => {
213
- setOpen(true);
214
- if (available === false) return;
215
-
216
- if (!hasAddress) {
217
- setNeedsAddress(true);
218
- setMessages([{ id: "ask-address", role: "ASSISTANT", content: ASK_ADDRESS }]);
219
- return;
220
- }
221
-
222
- if (!conversationId) {
223
- setLoading(true);
224
- try {
225
- await ensureConversation();
226
- const sector = local.placeMeta?.sectorName;
227
- const cityName = local.placeMeta?.cityName;
228
- const welcome = sector
229
- ? `Bonjour ! Je suis Wast'O Agent (secteur « ${sector} »${
230
- cityName ? `, ${cityName}` : ""
231
- }). Posez-moi une question sur le tri, les collectes ou les déchetteries.`
232
- : WELCOME;
233
- setMessages([{ id: "welcome", role: "ASSISTANT", content: welcome }]);
234
- } catch {
235
- // initError déjà posé
236
- } finally {
237
- setLoading(false);
238
- }
239
- }
240
- };
241
-
242
- const confirmAddress = async (
243
- cityIdNext: string,
244
- placeId: string,
245
- meta: {
246
- cityName?: string | null;
247
- streetName?: string | null;
248
- sectorName?: string | null;
249
- },
250
- userLabel: string,
251
- ) => {
252
- setLoading(true);
253
- try {
254
- const registeredDeviceId = await local.registerDevice(
255
- cityIdNext,
256
- placeId,
257
- meta,
258
- );
259
- setNeedsAddress(false);
260
- setPendingSectors([]);
261
- setSelectedStreet(null);
262
- setAddressQuery("");
263
- setMessages((prev) => [
264
- ...prev,
265
- { id: `u-addr-${Date.now()}`, role: "USER", content: userLabel },
266
- {
267
- id: `a-addr-${Date.now()}`,
268
- role: "ASSISTANT",
269
- content: meta.sectorName
270
- ? `Parfait — secteur « ${meta.sectorName} »${
271
- meta.cityName ? ` à ${meta.cityName}` : ""
272
- }. Posez-moi votre question.`
273
- : "Parfait. Posez-moi votre question.",
274
- },
275
- ]);
276
- const id =
277
- registeredDeviceId ??
278
- deviceId ??
279
- `anon-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
280
- if (conversationId) {
281
- await client.public.chat.setConversationLocation(conversationId, {
282
- cityId: cityIdNext,
283
- placeId,
284
- });
285
- } else {
286
- const conversation = (await client.public.chat.createConversation({
287
- deviceId: id,
288
- cityId: cityIdNext,
289
- cityGroupId: cityGroupId ?? undefined,
290
- })) as { id: string };
291
- setConversationId(conversation.id);
292
- }
293
- } catch (e: unknown) {
294
- setInitError(
295
- e instanceof Error ? e.message : "Impossible d’enregistrer l’adresse.",
296
- );
297
- } finally {
298
- setLoading(false);
299
- }
300
- };
301
-
302
- const onSelectStreet = (street: StreetSectorMatch) => {
303
- setSelectedStreet(street);
304
- const sectors = (street.sectors ?? []).filter(Boolean);
305
- if (sectors.length === 0) return;
306
- if (sectors.length === 1) {
307
- const sector = sectors[0]!;
308
- void confirmAddress(
309
- sector.cityId || street.cityId,
310
- sector.id,
311
- {
312
- cityName: sector.cityName || street.cityName,
313
- streetName: street.streetName,
314
- sectorName: sector.name,
315
- },
316
- `${street.streetName}${street.cityName ? ` (${street.cityName})` : ""}`,
317
- );
318
- return;
319
- }
320
- setPendingSectors(sectors);
321
- };
322
-
323
- const onSelectSector = (sector: CalendarPdfSector) => {
324
- if (!selectedStreet) return;
325
- void confirmAddress(
326
- sector.cityId || selectedStreet.cityId,
327
- sector.id,
328
- {
329
- cityName: sector.cityName || selectedStreet.cityName,
330
- streetName: selectedStreet.streetName,
331
- sectorName: sector.name,
332
- },
333
- `${selectedStreet.streetName} — ${sector.name}`,
334
- );
335
- };
336
-
337
- const handleSend = async () => {
338
- const content = input.trim();
339
- if (!content || loading || needsAddress) return;
340
- setInput("");
341
- setLoading(true);
342
- const tempId = `u-${Date.now()}`;
343
- setMessages((prev) => [...prev, { id: tempId, role: "USER", content }]);
344
- try {
345
- const id = await ensureConversation();
346
- const res = (await client.public.chat.sendMessage(id, {
347
- content,
348
- placeId: resolvedPlaceId || local.selectedPlaceId || undefined,
349
- })) as {
350
- response?: {
351
- type?: string;
352
- content?: string;
353
- message?: string;
354
- wasteTypes?: ChatWasteTypeCard[];
355
- links?: ChatLink[];
356
- };
357
- message?: { id?: string };
358
- };
359
- let reply = "Réponse reçue.";
360
- const wasteTypes =
361
- res?.response?.type === "text" && Array.isArray(res.response.wasteTypes)
362
- ? res.response.wasteTypes
363
- : undefined;
364
- const links =
365
- res?.response?.type === "text" && Array.isArray(res.response.links)
366
- ? res.response.links
367
- : undefined;
368
- if (res?.response?.type === "text") {
369
- reply = res.response.content ?? reply;
370
- } else if (res?.response?.message) {
371
- reply = res.response.message;
372
- }
373
- setMessages((prev) => [
374
- ...prev,
375
- {
376
- id: res?.message?.id ? `${res.message.id}-a` : `a-${Date.now()}`,
377
- role: "ASSISTANT",
378
- content: reply,
379
- wasteTypes,
380
- links,
381
- },
382
- ]);
383
- } catch (e: unknown) {
384
- setMessages((prev) => [
385
- ...prev,
386
- {
387
- id: `err-${Date.now()}`,
388
- role: "ASSISTANT",
389
- content:
390
- e instanceof Error
391
- ? e.message
392
- : "Désolé, une erreur s'est produite. Veuillez réessayer.",
393
- },
394
- ]);
395
- } finally {
396
- setLoading(false);
397
- requestAnimationFrame(() =>
398
- listRef.current?.scrollToEnd({ animated: true }),
399
- );
400
- }
401
- };
402
-
403
- if (available === false && !open) {
404
- return null;
405
- }
406
-
407
- const headerTitle = title?.trim() || "Wast'O Agent";
408
- const { colors } = resolved;
409
- const onPrimary = colors.onPrimary;
410
-
411
- return (
412
- <>
413
- {!open ? (
414
- <View style={styles.fabHost} pointerEvents="box-none">
415
- <Pressable
416
- accessibilityRole="button"
417
- accessibilityLabel="Ouvrir Wast'O Agent"
418
- onPress={handleOpen}
419
- style={[styles.fab, { backgroundColor: colors.primary }]}
420
- >
421
- <MessageCircle size={24} color={onPrimary} />
422
- </Pressable>
423
- </View>
424
- ) : null}
425
-
426
- <Modal
427
- visible={open}
428
- animationType="slide"
429
- presentationStyle="fullScreen"
430
- onRequestClose={() => setOpen(false)}
431
- >
432
- <View
433
- style={[
434
- styles.panel,
435
- {
436
- backgroundColor: colors.surface,
437
- },
438
- ]}
439
- >
440
- <View
441
- style={[styles.header, { backgroundColor: colors.primary }]}
442
- >
443
- <Text style={[styles.headerTitle, { color: onPrimary }]}>
444
- {headerTitle}
445
- </Text>
446
- <Pressable
447
- onPress={() => setOpen(false)}
448
- accessibilityLabel="Fermer"
449
- hitSlop={8}
450
- >
451
- <X size={22} color={onPrimary} />
452
- </Pressable>
453
- </View>
454
-
455
- <Text style={styles.disclaimer}>{DISCLAIMER}</Text>
456
-
457
- {initError || unavailableReason ? (
458
- <View style={styles.errorBox}>
459
- <Text style={styles.errorText}>
460
- {initError || unavailableReason}
461
- </Text>
462
- </View>
463
- ) : null}
464
-
465
- <FlatList
466
- ref={listRef}
467
- data={messages}
468
- keyExtractor={(item) => item.id}
469
- style={styles.list}
470
- contentContainerStyle={styles.listContent}
471
- onContentSizeChange={() =>
472
- listRef.current?.scrollToEnd({ animated: true })
473
- }
474
- ListEmptyComponent={
475
- loading ? (
476
- <ActivityIndicator
477
- color={colors.primary}
478
- style={{ marginTop: 24 }}
479
- />
480
- ) : null
481
- }
482
- renderItem={({ item }) => {
483
- const hasWasteCards =
484
- item.role === "ASSISTANT" &&
485
- Array.isArray(item.wasteTypes) &&
486
- item.wasteTypes.length > 0;
487
- const hasLinks =
488
- item.role === "ASSISTANT" &&
489
- Array.isArray(item.links) &&
490
- item.links.length > 0;
491
- return (
492
- <View
493
- style={[
494
- item.role === "USER"
495
- ? styles.bubbleUserWrap
496
- : styles.bubbleAssistantWrap,
497
- ]}
498
- >
499
- {!hasWasteCards ? (
500
- <View
501
- style={[
502
- styles.bubble,
503
- item.role === "USER"
504
- ? [
505
- styles.bubbleUser,
506
- { backgroundColor: colors.primary },
507
- ]
508
- : [
509
- styles.bubbleAssistant,
510
- { backgroundColor: colors.primarySoft },
511
- ],
512
- ]}
513
- >
514
- <Text
515
- style={{
516
- color:
517
- item.role === "USER"
518
- ? onPrimary
519
- : colors.onSurface,
520
- }}
521
- >
522
- {item.content}
523
- </Text>
524
- </View>
525
- ) : null}
526
- {hasWasteCards &&
527
- item.wasteTypes?.map((fiche) => {
528
- const accent = fiche.color || colors.primary;
529
- const img = resolveApiAssetUrl(apiUrl, fiche.imageUrl);
530
- const body = wasteTypeCardBody(fiche);
531
- return (
532
- <Pressable
533
- key={fiche.id}
534
- onPress={() => setSelectedWasteTypeId(fiche.id)}
535
- style={[styles.ficheCard, { borderColor: accent }]}
536
- >
537
- <View
538
- style={[
539
- styles.ficheHeader,
540
- { backgroundColor: accent },
541
- ]}
542
- >
543
- {img ? (
544
- <Image
545
- source={img}
546
- style={styles.ficheImage}
547
- contentFit="contain"
548
- />
549
- ) : null}
550
- <Text
551
- style={[
552
- styles.ficheTitle,
553
- { color: getContrastColor(accent) },
554
- ]}
555
- numberOfLines={2}
556
- >
557
- {fiche.name}
558
- </Text>
559
- </View>
560
- {fiche.matchedMaterial ? (
561
- <Text style={styles.ficheMeta}>
562
- Matériau : {fiche.matchedMaterial}
563
- </Text>
564
- ) : null}
565
- {body ? (
566
- <Text style={styles.ficheBody}>{body}</Text>
567
- ) : null}
568
- <Text style={[styles.ficheCta, { color: accent }]}>
569
- Voir la fiche complète
570
- </Text>
571
- </Pressable>
572
- );
573
- })}
574
- {hasLinks
575
- ? item.links?.map((link) => (
576
- <Pressable
577
- key={link.id}
578
- accessibilityRole="link"
579
- accessibilityLabel={link.label}
580
- onPress={() => {
581
- void Linking.openURL(link.url);
582
- }}
583
- style={[
584
- styles.downloadChip,
585
- { backgroundColor: colors.primary },
586
- ]}
587
- >
588
- <Download size={18} color={onPrimary} />
589
- <Text
590
- style={[
591
- styles.downloadChipLabel,
592
- { color: onPrimary },
593
- ]}
594
- numberOfLines={1}
595
- >
596
- {shortDownloadLabel(link.label)}
597
- </Text>
598
- </Pressable>
599
- ))
600
- : null}
601
- </View>
602
- );
603
- }}
604
- />
605
-
606
- {needsAddress ? (
607
- <View style={styles.addressBlock}>
608
- <View
609
- style={[
610
- styles.addressSearch,
611
- { borderColor: colors.border },
612
- ]}
613
- >
614
- <MapPin size={16} color={colors.muted} />
615
- <TextInput
616
- value={addressQuery}
617
- onChangeText={setAddressQuery}
618
- placeholder="Votre rue…"
619
- placeholderTextColor={colors.muted}
620
- style={[styles.addressInput, { color: colors.onSurface }]}
621
- autoCorrect={false}
622
- autoCapitalize="words"
623
- />
624
- </View>
625
- {!cityGroupId ? (
626
- <Text style={[styles.errorText, { marginTop: 8 }]}>
627
- cityGroupId requis pour la recherche d&apos;adresse.
628
- </Text>
629
- ) : null}
630
- {addressSearching ? (
631
- <ActivityIndicator
632
- color={colors.primary}
633
- style={{ marginTop: 8 }}
634
- />
635
- ) : null}
636
- {pendingSectors.length > 1 ? (
637
- <View style={{ gap: 6, marginTop: 8 }}>
638
- <Text style={{ fontSize: 12, color: colors.muted }}>
639
- Choisissez votre secteur :
640
- </Text>
641
- {pendingSectors.map((sector) => (
642
- <Pressable
643
- key={sector.id}
644
- onPress={() => onSelectSector(sector)}
645
- style={[
646
- styles.sectorBtn,
647
- { borderColor: colors.primary },
648
- ]}
649
- >
650
- <Text style={{ color: colors.primary, fontWeight: "600" }}>
651
- {sector.name}
652
- {sector.cityName ? ` · ${sector.cityName}` : ""}
653
- </Text>
654
- </Pressable>
655
- ))}
656
- </View>
657
- ) : (
658
- <FlatList
659
- data={addressResults}
660
- keyExtractor={(item) => item.streetId}
661
- keyboardShouldPersistTaps="handled"
662
- style={{ maxHeight: 160, marginTop: 8 }}
663
- renderItem={({ item }) => (
664
- <Pressable
665
- onPress={() => onSelectStreet(item)}
666
- style={styles.addressRow}
667
- >
668
- <Text
669
- style={{
670
- color: colors.onSurface,
671
- fontWeight: "600",
672
- }}
673
- >
674
- {item.streetName}
675
- </Text>
676
- <Text style={{ color: colors.muted, fontSize: 12 }}>
677
- {item.cityName}
678
- </Text>
679
- </Pressable>
680
- )}
681
- />
682
- )}
683
- </View>
684
- ) : (
685
- <View style={styles.inputRow}>
686
- <TextInput
687
- value={input}
688
- onChangeText={setInput}
689
- placeholder="Votre question…"
690
- placeholderTextColor={colors.muted}
691
- editable={!loading && available !== false}
692
- style={[
693
- styles.input,
694
- {
695
- borderColor: colors.border,
696
- color: colors.onSurface,
697
- },
698
- ]}
699
- onSubmitEditing={handleSend}
700
- returnKeyType="send"
701
- />
702
- <Pressable
703
- onPress={handleSend}
704
- disabled={loading || !input.trim() || available === false}
705
- style={[
706
- styles.sendBtn,
707
- {
708
- backgroundColor: colors.primary,
709
- opacity: loading || !input.trim() ? 0.5 : 1,
710
- },
711
- ]}
712
- accessibilityLabel="Envoyer"
713
- >
714
- {loading ? (
715
- <ActivityIndicator color={onPrimary} />
716
- ) : (
717
- <Send size={18} color={onPrimary} />
718
- )}
719
- </Pressable>
720
- </View>
721
- )}
722
- </View>
723
- </Modal>
724
-
725
- <ConsignesModal
726
- visible={!!selectedWasteTypeId}
727
- wasteTypeId={selectedWasteTypeId ?? ""}
728
- onClose={() => setSelectedWasteTypeId(null)}
729
- />
730
- </>
731
- );
732
- }
733
-
734
- const styles = StyleSheet.create({
735
- /** Couche plein écran hors scroll — le FAB flotte au-dessus de la page. */
736
- fabHost: {
737
- ...StyleSheet.absoluteFillObject,
738
- zIndex: 1000,
739
- elevation: 1000,
740
- },
741
- fab: {
742
- position: "absolute",
743
- right: SPACE.pageX,
744
- bottom: SPACE.sectionY,
745
- width: 56,
746
- height: 56,
747
- borderRadius: 28,
748
- alignItems: "center",
749
- justifyContent: "center",
750
- elevation: 6,
751
- shadowColor: "#000",
752
- shadowOffset: { width: 0, height: 2 },
753
- shadowOpacity: 0.2,
754
- shadowRadius: 4,
755
- },
756
- panel: {
757
- flex: 1,
758
- overflow: "hidden",
759
- },
760
- list: {
761
- flex: 1,
762
- },
763
- header: {
764
- flexDirection: "row",
765
- alignItems: "center",
766
- justifyContent: "space-between",
767
- paddingHorizontal: SPACE.pageX,
768
- paddingVertical: 14,
769
- },
770
- headerTitle: {
771
- fontSize: 17,
772
- fontWeight: "600",
773
- },
774
- disclaimer: {
775
- fontSize: 11,
776
- color: "#666",
777
- paddingHorizontal: 14,
778
- paddingVertical: 8,
779
- backgroundColor: "#f6f6f6",
780
- },
781
- errorBox: {
782
- margin: SPACE.stack,
783
- padding: 10,
784
- borderRadius: 8,
785
- backgroundColor: "#fff4f4",
786
- },
787
- errorText: {
788
- color: "#a33",
789
- fontSize: 13,
790
- },
791
- listContent: {
792
- padding: 14,
793
- gap: 8,
794
- paddingBottom: 8,
795
- },
796
- bubbleUserWrap: {
797
- alignItems: "flex-end",
798
- marginBottom: 8,
799
- },
800
- bubbleAssistantWrap: {
801
- alignItems: "flex-start",
802
- marginBottom: 8,
803
- gap: 8,
804
- maxWidth: "92%",
805
- },
806
- bubble: {
807
- maxWidth: "100%",
808
- paddingHorizontal: 12,
809
- paddingVertical: 10,
810
- borderRadius: 12,
811
- },
812
- bubbleUser: {
813
- alignSelf: "flex-end",
814
- },
815
- bubbleAssistant: {
816
- alignSelf: "flex-start",
817
- },
818
- ficheCard: {
819
- alignSelf: "stretch",
820
- borderWidth: 2,
821
- borderRadius: 12,
822
- overflow: "hidden",
823
- backgroundColor: "#fff",
824
- },
825
- ficheHeader: {
826
- flexDirection: "row",
827
- alignItems: "center",
828
- gap: 10,
829
- paddingHorizontal: 12,
830
- paddingVertical: 10,
831
- },
832
- ficheImage: {
833
- width: 36,
834
- height: 36,
835
- },
836
- ficheTitle: {
837
- flex: 1,
838
- fontSize: 15,
839
- fontWeight: "700",
840
- },
841
- ficheMeta: {
842
- paddingHorizontal: 12,
843
- paddingTop: 8,
844
- fontSize: 12,
845
- color: "#555",
846
- },
847
- ficheBody: {
848
- paddingHorizontal: 12,
849
- paddingTop: 8,
850
- fontSize: 13,
851
- lineHeight: 18,
852
- color: "#222",
853
- },
854
- ficheCta: {
855
- paddingHorizontal: 12,
856
- paddingVertical: 8,
857
- fontSize: 12,
858
- fontWeight: "600",
859
- },
860
- downloadChip: {
861
- flexDirection: "row",
862
- alignItems: "center",
863
- alignSelf: "flex-start",
864
- gap: 8,
865
- height: 40,
866
- maxWidth: "100%",
867
- paddingHorizontal: 12,
868
- borderRadius: 20,
869
- },
870
- downloadChipLabel: {
871
- fontSize: 13,
872
- fontWeight: "600",
873
- flexShrink: 1,
874
- },
875
- addressBlock: {
876
- padding: SPACE.pageXTight,
877
- borderTopWidth: StyleSheet.hairlineWidth,
878
- borderTopColor: "#e0e0e0",
879
- },
880
- addressSearch: {
881
- flexDirection: "row",
882
- alignItems: "center",
883
- gap: 8,
884
- borderWidth: 1,
885
- borderRadius: 20,
886
- paddingHorizontal: 12,
887
- height: 42,
888
- },
889
- addressInput: {
890
- flex: 1,
891
- fontSize: 15,
892
- paddingVertical: 0,
893
- },
894
- addressRow: {
895
- paddingVertical: 10,
896
- borderBottomWidth: StyleSheet.hairlineWidth,
897
- borderBottomColor: "#eee",
898
- },
899
- sectorBtn: {
900
- borderWidth: 1,
901
- borderRadius: 8,
902
- paddingHorizontal: 12,
903
- paddingVertical: 10,
904
- },
905
- inputRow: {
906
- flexDirection: "row",
907
- alignItems: "center",
908
- gap: 8,
909
- padding: SPACE.pageXTight,
910
- borderTopWidth: StyleSheet.hairlineWidth,
911
- borderTopColor: "#e0e0e0",
912
- },
913
- input: {
914
- flex: 1,
915
- borderWidth: 1,
916
- borderRadius: 20,
917
- paddingHorizontal: 14,
918
- paddingVertical: 10,
919
- fontSize: 15,
920
- },
921
- sendBtn: {
922
- width: 42,
923
- height: 42,
924
- borderRadius: 21,
925
- alignItems: "center",
926
- justifyContent: "center",
927
- },
928
- });
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import {
3
+ ActivityIndicator,
4
+ FlatList,
5
+ Linking,
6
+ Modal,
7
+ Pressable,
8
+ StyleSheet,
9
+ Text,
10
+ TextInput,
11
+ View,
12
+ } from "react-native";
13
+ import { Image } from "expo-image";
14
+ import { Download, MapPin, MessageCircle, Send, X } from "lucide-react-native";
15
+ import type { CalendarPdfSector, StreetSectorMatch } from "@wasto/sdk";
16
+ import { useTheme } from "../../contexts/ThemeContext";
17
+ import { useLocal } from "../../contexts/LocalContext";
18
+ import { useWastoClient } from "../../hooks/useWastoClient";
19
+ import { resolveTheme } from "../../helpers/themeStyles";
20
+ import { SPACE } from "../../helpers/layout";
21
+ import { ConsignesModal } from "../consignes-modal/ConsignesModal";
22
+ import { resolveApiAssetUrl } from "../../helpers/resolveApiAssetUrl";
23
+ import { useWasto } from "../../contexts/WastoContext";
24
+ import { getContrastColor } from "../../helpers/getContrastColor";
25
+
26
+ export type ChatProps = {
27
+ title?: string;
28
+ cityId?: string;
29
+ cityGroupId?: string;
30
+ };
31
+
32
+ type ChatWasteTypeCard = {
33
+ id: string;
34
+ name: string;
35
+ description?: string | null;
36
+ instructions?: string | null;
37
+ color?: string | null;
38
+ colorLight?: string | null;
39
+ imageUrl?: string | null;
40
+ matchedMaterial?: string;
41
+ };
42
+
43
+ type ChatLink = {
44
+ id: string;
45
+ label: string;
46
+ url: string;
47
+ };
48
+
49
+ type ChatBubble = {
50
+ id: string;
51
+ role: "USER" | "ASSISTANT";
52
+ content: string;
53
+ wasteTypes?: ChatWasteTypeCard[];
54
+ links?: ChatLink[];
55
+ };
56
+
57
+ /** Label compact (pas de hover natif). */
58
+ function shortDownloadLabel(label: string): string {
59
+ if (/calendrier|pdf/i.test(label)) return "Télécharger le PDF";
60
+ if (label.length <= 22) return label;
61
+ return `${label.slice(0, 20)}…`;
62
+ }
63
+
64
+ const DISCLAIMER =
65
+ "Informations indicatives · règles de votre territoire. En cas de doute, contactez un agent.";
66
+
67
+ const ASK_ADDRESS =
68
+ "Pour vous répondre précisément (collectes, consignes locales), quelle est votre adresse ? Indiquez le nom de votre rue.";
69
+
70
+ const WELCOME =
71
+ "Bonjour ! Je suis Wast'O Agent. Posez-moi une question sur le tri, les collectes ou les déchetteries.";
72
+
73
+ /** Texte court dans la vignette (intro + consignes), template générique. */
74
+ function wasteTypeCardBody(
75
+ fiche: Pick<
76
+ ChatWasteTypeCard,
77
+ "name" | "matchedMaterial" | "instructions" | "description"
78
+ >,
79
+ ): string {
80
+ const material = fiche.matchedMaterial?.trim();
81
+ const lines: string[] = [];
82
+ if (material) {
83
+ lines.push(
84
+ `Pour « ${material} », le référentiel de votre collectivité indique le flux « ${fiche.name} ».`,
85
+ );
86
+ } else {
87
+ lines.push(`Voici la fiche « ${fiche.name} » de votre collectivité.`);
88
+ }
89
+ const tip = fiche.instructions?.trim() || fiche.description?.trim();
90
+ if (tip) {
91
+ lines.push(tip);
92
+ }
93
+ return lines.join("\n\n");
94
+ }
95
+
96
+ export function Chat({ title, cityId, cityGroupId: cityGroupIdProp }: ChatProps) {
97
+ const client = useWastoClient();
98
+ const { theme } = useTheme();
99
+ const { apiUrl, cityGroupId: contextCityGroupId } = useWasto();
100
+ const resolved = resolveTheme(theme);
101
+ const local = useLocal();
102
+ const cityGroupId =
103
+ cityGroupIdProp?.trim() || contextCityGroupId?.trim() || null;
104
+ const resolvedCityId = cityId ?? local.selectedCityId ?? null;
105
+ const resolvedPlaceId = local.selectedPlaceId ?? null;
106
+ const deviceId = local.deviceId;
107
+ const hasAddress = Boolean(resolvedCityId && resolvedPlaceId);
108
+
109
+ const [open, setOpen] = useState(false);
110
+ const [available, setAvailable] = useState<boolean | null>(null);
111
+ const [unavailableReason, setUnavailableReason] = useState<string | null>(null);
112
+ const [conversationId, setConversationId] = useState<string | null>(null);
113
+ const [messages, setMessages] = useState<ChatBubble[]>([]);
114
+ const [input, setInput] = useState("");
115
+ const [loading, setLoading] = useState(false);
116
+ const [initError, setInitError] = useState<string | null>(null);
117
+ const [selectedWasteTypeId, setSelectedWasteTypeId] = useState<string | null>(
118
+ null,
119
+ );
120
+ const [needsAddress, setNeedsAddress] = useState(!hasAddress);
121
+ const [addressQuery, setAddressQuery] = useState("");
122
+ const [addressResults, setAddressResults] = useState<StreetSectorMatch[]>([]);
123
+ const [addressSearching, setAddressSearching] = useState(false);
124
+ const [selectedStreet, setSelectedStreet] = useState<StreetSectorMatch | null>(
125
+ null,
126
+ );
127
+ const [pendingSectors, setPendingSectors] = useState<CalendarPdfSector[]>([]);
128
+ const listRef = useRef<FlatList<ChatBubble>>(null);
129
+
130
+ useEffect(() => {
131
+ setNeedsAddress(!hasAddress);
132
+ }, [hasAddress]);
133
+
134
+ useEffect(() => {
135
+ let cancelled = false;
136
+ (async () => {
137
+ if (!cityGroupId && !resolvedCityId) {
138
+ setAvailable(false);
139
+ setUnavailableReason(
140
+ "Configuration manquante : collectivité requise pour Wast'O Agent.",
141
+ );
142
+ return;
143
+ }
144
+ try {
145
+ const res = await client.public.chat.getAvailability({
146
+ cityGroupId: cityGroupId || undefined,
147
+ cityId: cityGroupId ? undefined : resolvedCityId || undefined,
148
+ });
149
+ if (cancelled) return;
150
+ setAvailable(Boolean(res?.enabled));
151
+ setUnavailableReason(res?.enabled ? null : res?.reason ?? null);
152
+ } catch {
153
+ if (cancelled) return;
154
+ setAvailable(true);
155
+ setUnavailableReason(null);
156
+ }
157
+ })();
158
+ return () => {
159
+ cancelled = true;
160
+ };
161
+ }, [client, cityGroupId, resolvedCityId]);
162
+
163
+ useEffect(() => {
164
+ if (!needsAddress || !open) return;
165
+ const trimmed = addressQuery.trim();
166
+ if (trimmed.length < 2 || !cityGroupId) {
167
+ setAddressResults([]);
168
+ setAddressSearching(false);
169
+ return;
170
+ }
171
+ setAddressSearching(true);
172
+ const handle = setTimeout(async () => {
173
+ try {
174
+ const res = await client.public.cityGroups.searchStreetSectors(
175
+ cityGroupId,
176
+ { q: trimmed },
177
+ );
178
+ setAddressResults(res.data ?? []);
179
+ } catch {
180
+ setAddressResults([]);
181
+ } finally {
182
+ setAddressSearching(false);
183
+ }
184
+ }, 300);
185
+ return () => clearTimeout(handle);
186
+ }, [addressQuery, cityGroupId, client.public.cityGroups, needsAddress, open]);
187
+
188
+ const ensureConversation = useCallback(async () => {
189
+ if (conversationId) return conversationId;
190
+ setInitError(null);
191
+ const id =
192
+ deviceId ??
193
+ `anon-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
194
+ try {
195
+ const conversation = (await client.public.chat.createConversation({
196
+ deviceId: id,
197
+ cityId: resolvedCityId ?? undefined,
198
+ cityGroupId: cityGroupId ?? undefined,
199
+ })) as { id: string };
200
+ setConversationId(conversation.id);
201
+ return conversation.id;
202
+ } catch (e: unknown) {
203
+ const msg =
204
+ e instanceof Error
205
+ ? e.message
206
+ : "Impossible d’ouvrir l’assistant pour le moment.";
207
+ setInitError(msg);
208
+ throw e;
209
+ }
210
+ }, [client, conversationId, deviceId, resolvedCityId, cityGroupId]);
211
+
212
+ const handleOpen = async () => {
213
+ setOpen(true);
214
+ if (available === false) return;
215
+
216
+ if (!hasAddress) {
217
+ setNeedsAddress(true);
218
+ setMessages([{ id: "ask-address", role: "ASSISTANT", content: ASK_ADDRESS }]);
219
+ return;
220
+ }
221
+
222
+ if (!conversationId) {
223
+ setLoading(true);
224
+ try {
225
+ await ensureConversation();
226
+ const sector = local.placeMeta?.sectorName;
227
+ const cityName = local.placeMeta?.cityName;
228
+ const welcome = sector
229
+ ? `Bonjour ! Je suis Wast'O Agent (secteur « ${sector} »${
230
+ cityName ? `, ${cityName}` : ""
231
+ }). Posez-moi une question sur le tri, les collectes ou les déchetteries.`
232
+ : WELCOME;
233
+ setMessages([{ id: "welcome", role: "ASSISTANT", content: welcome }]);
234
+ } catch {
235
+ // initError déjà posé
236
+ } finally {
237
+ setLoading(false);
238
+ }
239
+ }
240
+ };
241
+
242
+ const confirmAddress = async (
243
+ cityIdNext: string,
244
+ placeId: string,
245
+ meta: {
246
+ cityName?: string | null;
247
+ streetName?: string | null;
248
+ sectorName?: string | null;
249
+ },
250
+ userLabel: string,
251
+ ) => {
252
+ setLoading(true);
253
+ try {
254
+ const registeredDeviceId = await local.registerDevice(
255
+ cityIdNext,
256
+ placeId,
257
+ meta,
258
+ );
259
+ setNeedsAddress(false);
260
+ setPendingSectors([]);
261
+ setSelectedStreet(null);
262
+ setAddressQuery("");
263
+ setMessages((prev) => [
264
+ ...prev,
265
+ { id: `u-addr-${Date.now()}`, role: "USER", content: userLabel },
266
+ {
267
+ id: `a-addr-${Date.now()}`,
268
+ role: "ASSISTANT",
269
+ content: meta.sectorName
270
+ ? `Parfait — secteur « ${meta.sectorName} »${
271
+ meta.cityName ? ` à ${meta.cityName}` : ""
272
+ }. Posez-moi votre question.`
273
+ : "Parfait. Posez-moi votre question.",
274
+ },
275
+ ]);
276
+ const id =
277
+ registeredDeviceId ??
278
+ deviceId ??
279
+ `anon-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
280
+ if (conversationId) {
281
+ await client.public.chat.setConversationLocation(conversationId, {
282
+ cityId: cityIdNext,
283
+ placeId,
284
+ });
285
+ } else {
286
+ const conversation = (await client.public.chat.createConversation({
287
+ deviceId: id,
288
+ cityId: cityIdNext,
289
+ cityGroupId: cityGroupId ?? undefined,
290
+ })) as { id: string };
291
+ setConversationId(conversation.id);
292
+ }
293
+ } catch (e: unknown) {
294
+ setInitError(
295
+ e instanceof Error ? e.message : "Impossible d’enregistrer l’adresse.",
296
+ );
297
+ } finally {
298
+ setLoading(false);
299
+ }
300
+ };
301
+
302
+ const onSelectStreet = (street: StreetSectorMatch) => {
303
+ setSelectedStreet(street);
304
+ const sectors = (street.sectors ?? []).filter(Boolean);
305
+ if (sectors.length === 0) return;
306
+ if (sectors.length === 1) {
307
+ const sector = sectors[0]!;
308
+ void confirmAddress(
309
+ sector.cityId || street.cityId,
310
+ sector.id,
311
+ {
312
+ cityName: sector.cityName || street.cityName,
313
+ streetName: street.streetName,
314
+ sectorName: sector.name,
315
+ },
316
+ `${street.streetName}${street.cityName ? ` (${street.cityName})` : ""}`,
317
+ );
318
+ return;
319
+ }
320
+ setPendingSectors(sectors);
321
+ };
322
+
323
+ const onSelectSector = (sector: CalendarPdfSector) => {
324
+ if (!selectedStreet) return;
325
+ void confirmAddress(
326
+ sector.cityId || selectedStreet.cityId,
327
+ sector.id,
328
+ {
329
+ cityName: sector.cityName || selectedStreet.cityName,
330
+ streetName: selectedStreet.streetName,
331
+ sectorName: sector.name,
332
+ },
333
+ `${selectedStreet.streetName} — ${sector.name}`,
334
+ );
335
+ };
336
+
337
+ const handleSend = async () => {
338
+ const content = input.trim();
339
+ if (!content || loading || needsAddress) return;
340
+ setInput("");
341
+ setLoading(true);
342
+ const tempId = `u-${Date.now()}`;
343
+ setMessages((prev) => [...prev, { id: tempId, role: "USER", content }]);
344
+ try {
345
+ const id = await ensureConversation();
346
+ const res = (await client.public.chat.sendMessage(id, {
347
+ content,
348
+ placeId: resolvedPlaceId || local.selectedPlaceId || undefined,
349
+ })) as {
350
+ response?: {
351
+ type?: string;
352
+ content?: string;
353
+ message?: string;
354
+ wasteTypes?: ChatWasteTypeCard[];
355
+ links?: ChatLink[];
356
+ };
357
+ message?: { id?: string };
358
+ };
359
+ let reply = "Réponse reçue.";
360
+ const wasteTypes =
361
+ res?.response?.type === "text" && Array.isArray(res.response.wasteTypes)
362
+ ? res.response.wasteTypes
363
+ : undefined;
364
+ const links =
365
+ res?.response?.type === "text" && Array.isArray(res.response.links)
366
+ ? res.response.links
367
+ : undefined;
368
+ if (res?.response?.type === "text") {
369
+ reply = res.response.content ?? reply;
370
+ } else if (res?.response?.message) {
371
+ reply = res.response.message;
372
+ }
373
+ setMessages((prev) => [
374
+ ...prev,
375
+ {
376
+ id: res?.message?.id ? `${res.message.id}-a` : `a-${Date.now()}`,
377
+ role: "ASSISTANT",
378
+ content: reply,
379
+ wasteTypes,
380
+ links,
381
+ },
382
+ ]);
383
+ } catch (e: unknown) {
384
+ setMessages((prev) => [
385
+ ...prev,
386
+ {
387
+ id: `err-${Date.now()}`,
388
+ role: "ASSISTANT",
389
+ content:
390
+ e instanceof Error
391
+ ? e.message
392
+ : "Désolé, une erreur s'est produite. Veuillez réessayer.",
393
+ },
394
+ ]);
395
+ } finally {
396
+ setLoading(false);
397
+ requestAnimationFrame(() =>
398
+ listRef.current?.scrollToEnd({ animated: true }),
399
+ );
400
+ }
401
+ };
402
+
403
+ if (available === false && !open) {
404
+ return null;
405
+ }
406
+
407
+ const headerTitle = title?.trim() || "Wast'O Agent";
408
+ const { colors } = resolved;
409
+ const onPrimary = colors.onPrimary;
410
+
411
+ return (
412
+ <>
413
+ {!open ? (
414
+ <View style={styles.fabHost} pointerEvents="box-none">
415
+ <Pressable
416
+ accessibilityRole="button"
417
+ accessibilityLabel="Ouvrir Wast'O Agent"
418
+ onPress={handleOpen}
419
+ style={[styles.fab, { backgroundColor: colors.primary }]}
420
+ >
421
+ <MessageCircle size={24} color={onPrimary} />
422
+ </Pressable>
423
+ </View>
424
+ ) : null}
425
+
426
+ <Modal
427
+ visible={open}
428
+ animationType="slide"
429
+ presentationStyle="fullScreen"
430
+ onRequestClose={() => setOpen(false)}
431
+ >
432
+ <View
433
+ style={[
434
+ styles.panel,
435
+ {
436
+ backgroundColor: colors.surface,
437
+ },
438
+ ]}
439
+ >
440
+ <View
441
+ style={[styles.header, { backgroundColor: colors.primary }]}
442
+ >
443
+ <Text style={[styles.headerTitle, { color: onPrimary }]}>
444
+ {headerTitle}
445
+ </Text>
446
+ <Pressable
447
+ onPress={() => setOpen(false)}
448
+ accessibilityLabel="Fermer"
449
+ hitSlop={8}
450
+ >
451
+ <X size={22} color={onPrimary} />
452
+ </Pressable>
453
+ </View>
454
+
455
+ <Text style={styles.disclaimer}>{DISCLAIMER}</Text>
456
+
457
+ {initError || unavailableReason ? (
458
+ <View style={styles.errorBox}>
459
+ <Text style={styles.errorText}>
460
+ {initError || unavailableReason}
461
+ </Text>
462
+ </View>
463
+ ) : null}
464
+
465
+ <FlatList
466
+ ref={listRef}
467
+ data={messages}
468
+ keyExtractor={(item) => item.id}
469
+ style={styles.list}
470
+ contentContainerStyle={styles.listContent}
471
+ onContentSizeChange={() =>
472
+ listRef.current?.scrollToEnd({ animated: true })
473
+ }
474
+ ListEmptyComponent={
475
+ loading ? (
476
+ <ActivityIndicator
477
+ color={colors.primary}
478
+ style={{ marginTop: 24 }}
479
+ />
480
+ ) : null
481
+ }
482
+ renderItem={({ item }) => {
483
+ const hasWasteCards =
484
+ item.role === "ASSISTANT" &&
485
+ Array.isArray(item.wasteTypes) &&
486
+ item.wasteTypes.length > 0;
487
+ const hasLinks =
488
+ item.role === "ASSISTANT" &&
489
+ Array.isArray(item.links) &&
490
+ item.links.length > 0;
491
+ return (
492
+ <View
493
+ style={[
494
+ item.role === "USER"
495
+ ? styles.bubbleUserWrap
496
+ : styles.bubbleAssistantWrap,
497
+ ]}
498
+ >
499
+ {!hasWasteCards ? (
500
+ <View
501
+ style={[
502
+ styles.bubble,
503
+ item.role === "USER"
504
+ ? [
505
+ styles.bubbleUser,
506
+ { backgroundColor: colors.primary },
507
+ ]
508
+ : [
509
+ styles.bubbleAssistant,
510
+ { backgroundColor: colors.primarySoft },
511
+ ],
512
+ ]}
513
+ >
514
+ <Text
515
+ style={{
516
+ color:
517
+ item.role === "USER"
518
+ ? onPrimary
519
+ : colors.onSurface,
520
+ }}
521
+ >
522
+ {item.content}
523
+ </Text>
524
+ </View>
525
+ ) : null}
526
+ {hasWasteCards &&
527
+ item.wasteTypes?.map((fiche) => {
528
+ const accent = fiche.color || colors.primary;
529
+ const img = resolveApiAssetUrl(apiUrl, fiche.imageUrl);
530
+ const body = wasteTypeCardBody(fiche);
531
+ return (
532
+ <Pressable
533
+ key={fiche.id}
534
+ onPress={() => setSelectedWasteTypeId(fiche.id)}
535
+ style={[styles.ficheCard, { borderColor: accent }]}
536
+ >
537
+ <View
538
+ style={[
539
+ styles.ficheHeader,
540
+ { backgroundColor: accent },
541
+ ]}
542
+ >
543
+ {img ? (
544
+ <Image
545
+ source={img}
546
+ style={styles.ficheImage}
547
+ contentFit="contain"
548
+ />
549
+ ) : null}
550
+ <Text
551
+ style={[
552
+ styles.ficheTitle,
553
+ { color: getContrastColor(accent) },
554
+ ]}
555
+ numberOfLines={2}
556
+ >
557
+ {fiche.name}
558
+ </Text>
559
+ </View>
560
+ {fiche.matchedMaterial ? (
561
+ <Text style={styles.ficheMeta}>
562
+ Matériau : {fiche.matchedMaterial}
563
+ </Text>
564
+ ) : null}
565
+ {body ? (
566
+ <Text style={styles.ficheBody}>{body}</Text>
567
+ ) : null}
568
+ <Text style={[styles.ficheCta, { color: accent }]}>
569
+ Voir la fiche complète
570
+ </Text>
571
+ </Pressable>
572
+ );
573
+ })}
574
+ {hasLinks
575
+ ? item.links?.map((link) => (
576
+ <Pressable
577
+ key={link.id}
578
+ accessibilityRole="link"
579
+ accessibilityLabel={link.label}
580
+ onPress={() => {
581
+ void Linking.openURL(link.url);
582
+ }}
583
+ style={[
584
+ styles.downloadChip,
585
+ { backgroundColor: colors.primary },
586
+ ]}
587
+ >
588
+ <Download size={18} color={onPrimary} />
589
+ <Text
590
+ style={[
591
+ styles.downloadChipLabel,
592
+ { color: onPrimary },
593
+ ]}
594
+ numberOfLines={1}
595
+ >
596
+ {shortDownloadLabel(link.label)}
597
+ </Text>
598
+ </Pressable>
599
+ ))
600
+ : null}
601
+ </View>
602
+ );
603
+ }}
604
+ />
605
+
606
+ {needsAddress ? (
607
+ <View style={styles.addressBlock}>
608
+ <View
609
+ style={[
610
+ styles.addressSearch,
611
+ { borderColor: colors.border },
612
+ ]}
613
+ >
614
+ <MapPin size={16} color={colors.muted} />
615
+ <TextInput
616
+ value={addressQuery}
617
+ onChangeText={setAddressQuery}
618
+ placeholder="Votre rue…"
619
+ placeholderTextColor={colors.muted}
620
+ style={[styles.addressInput, { color: colors.onSurface }]}
621
+ autoCorrect={false}
622
+ autoCapitalize="words"
623
+ />
624
+ </View>
625
+ {!cityGroupId ? (
626
+ <Text style={[styles.errorText, { marginTop: 8 }]}>
627
+ cityGroupId requis pour la recherche d&apos;adresse.
628
+ </Text>
629
+ ) : null}
630
+ {addressSearching ? (
631
+ <ActivityIndicator
632
+ color={colors.primary}
633
+ style={{ marginTop: 8 }}
634
+ />
635
+ ) : null}
636
+ {pendingSectors.length > 1 ? (
637
+ <View style={{ gap: 6, marginTop: 8 }}>
638
+ <Text style={{ fontSize: 12, color: colors.muted }}>
639
+ Choisissez votre secteur :
640
+ </Text>
641
+ {pendingSectors.map((sector) => (
642
+ <Pressable
643
+ key={sector.id}
644
+ onPress={() => onSelectSector(sector)}
645
+ style={[
646
+ styles.sectorBtn,
647
+ { borderColor: colors.primary },
648
+ ]}
649
+ >
650
+ <Text style={{ color: colors.primary, fontWeight: "600" }}>
651
+ {sector.name}
652
+ {sector.cityName ? ` · ${sector.cityName}` : ""}
653
+ </Text>
654
+ </Pressable>
655
+ ))}
656
+ </View>
657
+ ) : (
658
+ <FlatList
659
+ data={addressResults}
660
+ keyExtractor={(item) => item.streetId}
661
+ keyboardShouldPersistTaps="handled"
662
+ style={{ maxHeight: 160, marginTop: 8 }}
663
+ renderItem={({ item }) => (
664
+ <Pressable
665
+ onPress={() => onSelectStreet(item)}
666
+ style={styles.addressRow}
667
+ >
668
+ <Text
669
+ style={{
670
+ color: colors.onSurface,
671
+ fontWeight: "600",
672
+ }}
673
+ >
674
+ {item.streetName}
675
+ </Text>
676
+ <Text style={{ color: colors.muted, fontSize: 12 }}>
677
+ {item.cityName}
678
+ </Text>
679
+ </Pressable>
680
+ )}
681
+ />
682
+ )}
683
+ </View>
684
+ ) : (
685
+ <View style={styles.inputRow}>
686
+ <TextInput
687
+ value={input}
688
+ onChangeText={setInput}
689
+ placeholder="Votre question…"
690
+ placeholderTextColor={colors.muted}
691
+ editable={!loading && available !== false}
692
+ style={[
693
+ styles.input,
694
+ {
695
+ borderColor: colors.border,
696
+ color: colors.onSurface,
697
+ },
698
+ ]}
699
+ onSubmitEditing={handleSend}
700
+ returnKeyType="send"
701
+ />
702
+ <Pressable
703
+ onPress={handleSend}
704
+ disabled={loading || !input.trim() || available === false}
705
+ style={[
706
+ styles.sendBtn,
707
+ {
708
+ backgroundColor: colors.primary,
709
+ opacity: loading || !input.trim() ? 0.5 : 1,
710
+ },
711
+ ]}
712
+ accessibilityLabel="Envoyer"
713
+ >
714
+ {loading ? (
715
+ <ActivityIndicator color={onPrimary} />
716
+ ) : (
717
+ <Send size={18} color={onPrimary} />
718
+ )}
719
+ </Pressable>
720
+ </View>
721
+ )}
722
+ </View>
723
+ </Modal>
724
+
725
+ <ConsignesModal
726
+ visible={!!selectedWasteTypeId}
727
+ wasteTypeId={selectedWasteTypeId ?? ""}
728
+ onClose={() => setSelectedWasteTypeId(null)}
729
+ />
730
+ </>
731
+ );
732
+ }
733
+
734
+ const styles = StyleSheet.create({
735
+ /** Couche plein écran hors scroll — le FAB flotte au-dessus de la page. */
736
+ fabHost: {
737
+ ...StyleSheet.absoluteFillObject,
738
+ zIndex: 1000,
739
+ elevation: 1000,
740
+ },
741
+ fab: {
742
+ position: "absolute",
743
+ right: SPACE.pageX,
744
+ bottom: SPACE.sectionY,
745
+ width: 56,
746
+ height: 56,
747
+ borderRadius: 28,
748
+ alignItems: "center",
749
+ justifyContent: "center",
750
+ elevation: 6,
751
+ shadowColor: "#000",
752
+ shadowOffset: { width: 0, height: 2 },
753
+ shadowOpacity: 0.2,
754
+ shadowRadius: 4,
755
+ },
756
+ panel: {
757
+ flex: 1,
758
+ overflow: "hidden",
759
+ },
760
+ list: {
761
+ flex: 1,
762
+ },
763
+ header: {
764
+ flexDirection: "row",
765
+ alignItems: "center",
766
+ justifyContent: "space-between",
767
+ paddingHorizontal: SPACE.pageX,
768
+ paddingVertical: 14,
769
+ },
770
+ headerTitle: {
771
+ fontSize: 17,
772
+ fontWeight: "600",
773
+ },
774
+ disclaimer: {
775
+ fontSize: 11,
776
+ color: "#666",
777
+ paddingHorizontal: 14,
778
+ paddingVertical: 8,
779
+ backgroundColor: "#f6f6f6",
780
+ },
781
+ errorBox: {
782
+ margin: SPACE.stack,
783
+ padding: 10,
784
+ borderRadius: 8,
785
+ backgroundColor: "#fff4f4",
786
+ },
787
+ errorText: {
788
+ color: "#a33",
789
+ fontSize: 13,
790
+ },
791
+ listContent: {
792
+ padding: 14,
793
+ gap: 8,
794
+ paddingBottom: 8,
795
+ },
796
+ bubbleUserWrap: {
797
+ alignItems: "flex-end",
798
+ marginBottom: 8,
799
+ },
800
+ bubbleAssistantWrap: {
801
+ alignItems: "flex-start",
802
+ marginBottom: 8,
803
+ gap: 8,
804
+ maxWidth: "92%",
805
+ },
806
+ bubble: {
807
+ maxWidth: "100%",
808
+ paddingHorizontal: 12,
809
+ paddingVertical: 10,
810
+ borderRadius: 12,
811
+ },
812
+ bubbleUser: {
813
+ alignSelf: "flex-end",
814
+ },
815
+ bubbleAssistant: {
816
+ alignSelf: "flex-start",
817
+ },
818
+ ficheCard: {
819
+ alignSelf: "stretch",
820
+ borderWidth: 2,
821
+ borderRadius: 12,
822
+ overflow: "hidden",
823
+ backgroundColor: "#fff",
824
+ },
825
+ ficheHeader: {
826
+ flexDirection: "row",
827
+ alignItems: "center",
828
+ gap: 10,
829
+ paddingHorizontal: 12,
830
+ paddingVertical: 10,
831
+ },
832
+ ficheImage: {
833
+ width: 36,
834
+ height: 36,
835
+ },
836
+ ficheTitle: {
837
+ flex: 1,
838
+ fontSize: 15,
839
+ fontWeight: "700",
840
+ },
841
+ ficheMeta: {
842
+ paddingHorizontal: 12,
843
+ paddingTop: 8,
844
+ fontSize: 12,
845
+ color: "#555",
846
+ },
847
+ ficheBody: {
848
+ paddingHorizontal: 12,
849
+ paddingTop: 8,
850
+ fontSize: 13,
851
+ lineHeight: 18,
852
+ color: "#222",
853
+ },
854
+ ficheCta: {
855
+ paddingHorizontal: 12,
856
+ paddingVertical: 8,
857
+ fontSize: 12,
858
+ fontWeight: "600",
859
+ },
860
+ downloadChip: {
861
+ flexDirection: "row",
862
+ alignItems: "center",
863
+ alignSelf: "flex-start",
864
+ gap: 8,
865
+ height: 40,
866
+ maxWidth: "100%",
867
+ paddingHorizontal: 12,
868
+ borderRadius: 20,
869
+ },
870
+ downloadChipLabel: {
871
+ fontSize: 13,
872
+ fontWeight: "600",
873
+ flexShrink: 1,
874
+ },
875
+ addressBlock: {
876
+ padding: SPACE.pageXTight,
877
+ borderTopWidth: StyleSheet.hairlineWidth,
878
+ borderTopColor: "#e0e0e0",
879
+ },
880
+ addressSearch: {
881
+ flexDirection: "row",
882
+ alignItems: "center",
883
+ gap: 8,
884
+ borderWidth: 1,
885
+ borderRadius: 20,
886
+ paddingHorizontal: 12,
887
+ height: 42,
888
+ },
889
+ addressInput: {
890
+ flex: 1,
891
+ fontSize: 15,
892
+ paddingVertical: 0,
893
+ },
894
+ addressRow: {
895
+ paddingVertical: 10,
896
+ borderBottomWidth: StyleSheet.hairlineWidth,
897
+ borderBottomColor: "#eee",
898
+ },
899
+ sectorBtn: {
900
+ borderWidth: 1,
901
+ borderRadius: 8,
902
+ paddingHorizontal: 12,
903
+ paddingVertical: 10,
904
+ },
905
+ inputRow: {
906
+ flexDirection: "row",
907
+ alignItems: "center",
908
+ gap: 8,
909
+ padding: SPACE.pageXTight,
910
+ borderTopWidth: StyleSheet.hairlineWidth,
911
+ borderTopColor: "#e0e0e0",
912
+ },
913
+ input: {
914
+ flex: 1,
915
+ borderWidth: 1,
916
+ borderRadius: 20,
917
+ paddingHorizontal: 14,
918
+ paddingVertical: 10,
919
+ fontSize: 15,
920
+ },
921
+ sendBtn: {
922
+ width: 42,
923
+ height: 42,
924
+ borderRadius: 21,
925
+ alignItems: "center",
926
+ justifyContent: "center",
927
+ },
928
+ });