@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.
Files changed (94) hide show
  1. package/README.md +47 -0
  2. package/dist/index.d.mts +465 -0
  3. package/dist/index.d.ts +465 -0
  4. package/dist/index.js +10445 -0
  5. package/dist/index.mjs +10553 -0
  6. package/package.json +90 -0
  7. package/src/components/announcement-modal/AnnouncementModal.tsx +183 -0
  8. package/src/components/article/Article.tsx +22 -0
  9. package/src/components/article/ArticleCard.tsx +81 -0
  10. package/src/components/article/ArticleExcerpts.tsx +80 -0
  11. package/src/components/article/ArticleList.tsx +114 -0
  12. package/src/components/article/ArticleMarkdown.tsx +170 -0
  13. package/src/components/article/ArticleReaderModal.tsx +93 -0
  14. package/src/components/article/ArticleSingle.tsx +57 -0
  15. package/src/components/bottom-tab/BottomTab.tsx +392 -0
  16. package/src/components/button/Button.tsx +154 -0
  17. package/src/components/challenge/Badges.tsx +84 -0
  18. package/src/components/challenge/BadgesModal.tsx +166 -0
  19. package/src/components/challenge/Challenge.tsx +380 -0
  20. package/src/components/challenge/ChallengeModal.tsx +231 -0
  21. package/src/components/challenge/ChallengeVignette.tsx +249 -0
  22. package/src/components/challenge/ChallengesModal.tsx +243 -0
  23. package/src/components/challenge/QuizModal.tsx +381 -0
  24. package/src/components/challenge/ResultModal.tsx +144 -0
  25. package/src/components/chat/Chat.tsx +928 -0
  26. package/src/components/collection-calendar/CollectionCalendar.tsx +274 -0
  27. package/src/components/columns/Columns.tsx +34 -0
  28. package/src/components/consignes-modal/ConsignesModal.tsx +267 -0
  29. package/src/components/content-inset/ContentInset.tsx +33 -0
  30. package/src/components/header/Header.tsx +391 -0
  31. package/src/components/image/Image.tsx +16 -0
  32. package/src/components/image/ImageFrame.tsx +102 -0
  33. package/src/components/image/ImageMedia.tsx +31 -0
  34. package/src/components/image/ImageUrl.tsx +30 -0
  35. package/src/components/locations-map/LocationSheet.tsx +417 -0
  36. package/src/components/locations-map/LocationsMap.native.tsx +260 -0
  37. package/src/components/locations-map/LocationsMap.tsx +13 -0
  38. package/src/components/locations-map/LocationsMap.web.tsx +10 -0
  39. package/src/components/locations-map/LocationsMapShell.tsx +527 -0
  40. package/src/components/locations-map/LocationsMapView.tsx +108 -0
  41. package/src/components/locations-map/LocationsMapView.web.tsx +331 -0
  42. package/src/components/nav-button/NavButton.tsx +59 -0
  43. package/src/components/next-collection/NextCollection.tsx +621 -0
  44. package/src/components/notifications/NotificationsScreen.tsx +247 -0
  45. package/src/components/page/Page.tsx +64 -0
  46. package/src/components/parapraph/Paragraph.tsx +67 -0
  47. package/src/components/profile/ProfileScreen.tsx +236 -0
  48. package/src/components/recycling-info/RecyclingInfo.tsx +189 -0
  49. package/src/components/safe-area/OptionalSafeAreaProvider.tsx +17 -0
  50. package/src/components/search/Search.tsx +320 -0
  51. package/src/components/select-street-modal/SelectStreetModal.tsx +475 -0
  52. package/src/components/title/Title.tsx +100 -0
  53. package/src/components/vision/Vision.tsx +447 -0
  54. package/src/components/vision/VisionActionCard.tsx +117 -0
  55. package/src/components/vision/VisionCapture.tsx +30 -0
  56. package/src/components/vision/VisionTapOverlay.tsx +122 -0
  57. package/src/contexts/AnnouncementsContext.tsx +171 -0
  58. package/src/contexts/ChallengesContext.tsx +182 -0
  59. package/src/contexts/LocalContext.tsx +227 -0
  60. package/src/contexts/ThemeContext.tsx +37 -0
  61. package/src/contexts/WastoContext.tsx +127 -0
  62. package/src/helpers/blogPost.ts +72 -0
  63. package/src/helpers/generateQuizQuestions.ts +247 -0
  64. package/src/helpers/getContrastColor.ts +19 -0
  65. package/src/helpers/haptics.ts +20 -0
  66. package/src/helpers/hoursSchedule.ts +230 -0
  67. package/src/helpers/htmlContentToMarkdown.ts +114 -0
  68. package/src/helpers/layout.ts +54 -0
  69. package/src/helpers/loadLinearGradient.ts +22 -0
  70. package/src/helpers/maquetteTheme.ts +76 -0
  71. package/src/helpers/markdownUnderlinePlugin.ts +32 -0
  72. package/src/helpers/normalizeWasteTypeRecyclingInfo.ts +80 -0
  73. package/src/helpers/publicMediaUrl.ts +8 -0
  74. package/src/helpers/resolveApiAssetUrl.ts +21 -0
  75. package/src/helpers/systemRoutes.ts +7 -0
  76. package/src/helpers/themeStyles.ts +114 -0
  77. package/src/helpers/useSafeAreaPadding.ts +14 -0
  78. package/src/hooks/useBadges.ts +100 -0
  79. package/src/hooks/useBlogPosts.ts +105 -0
  80. package/src/hooks/useChallenges.ts +110 -0
  81. package/src/hooks/useCities.ts +56 -0
  82. package/src/hooks/useCityAnnouncements.ts +118 -0
  83. package/src/hooks/useCollectionCalendar.ts +421 -0
  84. package/src/hooks/useColumns.ts +20 -0
  85. package/src/hooks/useConsignes.ts +62 -0
  86. package/src/hooks/useLocations.ts +248 -0
  87. package/src/hooks/useNextCollection.ts +238 -0
  88. package/src/hooks/useRecyclingInfo.ts +90 -0
  89. package/src/hooks/useWastoClient.ts +16 -0
  90. package/src/index.ts +142 -0
  91. package/src/navigation/NavigationContext.tsx +56 -0
  92. package/src/sentry/initSentry.ts +120 -0
  93. package/src/types/Theme.ts +72 -0
  94. package/src/types/css-modules.d.ts +1 -0
@@ -0,0 +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
+ });