@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,621 +1,636 @@
1
- import {
2
- Dimensions,
3
- Platform,
4
- Pressable,
5
- StyleSheet,
6
- Text,
7
- View,
8
- type NativeScrollEvent,
9
- type NativeSyntheticEvent,
10
- } from "react-native";
11
- import { Image } from "expo-image";
12
- import Animated, {
13
- Extrapolation,
14
- FadeInRight,
15
- FadeOutLeft,
16
- interpolate,
17
- interpolateColor,
18
- runOnJS,
19
- type SharedValue,
20
- useAnimatedReaction,
21
- useAnimatedScrollHandler,
22
- useAnimatedStyle,
23
- useSharedValue,
24
- } from "react-native-reanimated";
25
- import { useNextCollection } from "../../hooks/useNextCollection";
26
- import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react-native";
27
- import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
28
- import { ConsignesModal } from "../consignes-modal/ConsignesModal";
29
- import { NavButton } from "../nav-button/NavButton";
30
- import { Button } from "../button/Button";
31
- import { ContentInset } from "../content-inset/ContentInset";
32
- import { useTheme } from "../../contexts/ThemeContext";
33
- import { cardStyle, resolveTheme } from "../../helpers/themeStyles";
34
- import { hapticSelection } from "../../helpers/haptics";
35
- import { resolveApiAssetUrl } from "../../helpers/resolveApiAssetUrl";
36
- import { useWasto } from "../../contexts/WastoContext";
37
- import { SPACE } from "../../helpers/layout";
38
- import type { Theme } from "../../types/Theme";
39
- import { useAppNavigation } from "../../navigation/NavigationContext";
40
-
41
- export type NextCollectionProps = {
42
- /** Route / screen name for the calendar CTA. Defaults to trying common tab paths. */
43
- calendarHref?: string;
44
- /** Show calendar CTA under the slider (home mockup). Default true. */
45
- showCalendarCta?: boolean;
46
- /**
47
- * Initial day offset relative to today (0 = today, 1 = tomorrow, -1 = yesterday).
48
- * User can still navigate with prev/next.
49
- */
50
- dayOffset?: number;
51
- };
52
-
53
- /** Maquette: side cards ≈ 174/196 of active height */
54
- const SIDE_SCALE = 174 / 196;
55
- const CARD_GAP = 12;
56
- /** Side inset so current card is centered with a peek of the next (~maquette). */
57
- const SIDE_INSET = 20;
58
- const CARD_ASPECT = 196 / 286;
59
- /** White contour around the light-grey panel (screenshot). */
60
- const FRAME_PAD = 10;
61
- /**
62
- * Figma Ellipse 4 on card 286×196 — only the top of a huge circle is visible
63
- * (~52px), as a white bump on the bottom border.
64
- */
65
- const BUMP = {
66
- w: 813 / 286,
67
- /** Visible bump height / card height (196 − 144) / 196 */
68
- visible: 52 / 196,
69
- } as const;
70
-
71
- type CollectionItem = {
72
- id: string;
73
- name: string;
74
- color?: string | null;
75
- imageUrl?: string | null;
76
- icon?: string;
77
- };
78
-
79
- function CollectionSlide({
80
- item,
81
- index,
82
- scrollX,
83
- cardWidth,
84
- cardHeight,
85
- snapInterval,
86
- theme,
87
- imageUri,
88
- onPress,
89
- isLast,
90
- }: {
91
- item: CollectionItem;
92
- index: number;
93
- scrollX: SharedValue<number>;
94
- cardWidth: number;
95
- cardHeight: number;
96
- snapInterval: number;
97
- theme: Theme;
98
- imageUri: string | null;
99
- onPress: () => void;
100
- isLast: boolean;
101
- }) {
102
- const t = resolveTheme(theme);
103
- const accent = item.color ?? theme.colors.primary;
104
- const imageSize = Math.round(cardHeight * 0.55);
105
- const panelW = cardWidth - FRAME_PAD * 2;
106
- // True circle (square + radius/2). A non-square + borderRadius 9999
107
- // renders as a rounded rectangle / pill on RN web.
108
- const bumpSize = cardWidth * BUMP.w;
109
- const bumpVisible = cardHeight * BUMP.visible;
110
-
111
- const animatedStyle = useAnimatedStyle(() => {
112
- const center = index * snapInterval;
113
- const scale = interpolate(
114
- scrollX.value,
115
- [center - snapInterval, center, center + snapInterval],
116
- [SIDE_SCALE, 1, SIDE_SCALE],
117
- Extrapolation.CLAMP,
118
- );
119
- return {
120
- transform: [{ scale }],
121
- };
122
- }, [index, snapInterval]);
123
-
124
- return (
125
- <Animated.View
126
- style={[
127
- styles.slide,
128
- {
129
- width: cardWidth,
130
- height: cardHeight,
131
- marginRight: isLast ? 0 : CARD_GAP,
132
- ...(Platform.OS === "web"
133
- ? ({ scrollSnapAlign: "center", scrollSnapStop: "always" } as object)
134
- : null),
135
- },
136
- animatedStyle,
137
- ]}
138
- >
139
- <Pressable
140
- onPress={onPress}
141
- style={[
142
- styles.cardFrame,
143
- {
144
- borderRadius: t.radii.lg,
145
- backgroundColor: "#FFFFFF",
146
- borderWidth: 1,
147
- borderColor: theme.colors.border,
148
- padding: FRAME_PAD,
149
- ...t.shadow.card,
150
- },
151
- ]}
152
- >
153
- {/* Light-grey interior — white bump is the frame swelling up */}
154
- <View
155
- style={[
156
- styles.cardPanel,
157
- {
158
- backgroundColor: "#F0F0F0",
159
- borderRadius: Math.max(10, t.radii.lg - 6),
160
- },
161
- ]}
162
- >
163
- <View
164
- pointerEvents="none"
165
- style={{
166
- position: "absolute",
167
- width: bumpSize,
168
- height: bumpSize,
169
- borderRadius: bumpSize / 2,
170
- left: (panelW - bumpSize) / 2,
171
- bottom: -(bumpSize - bumpVisible),
172
- backgroundColor: "#FFFFFF",
173
- }}
174
- />
175
-
176
- <View style={styles.cardContent}>
177
- <Text
178
- style={[styles.wasteName, { color: theme.colors.onSurface }]}
179
- numberOfLines={2}
180
- >
181
- {item.name}
182
- </Text>
183
- {imageUri ? (
184
- <Image
185
- source={imageUri}
186
- style={{ width: imageSize, height: imageSize }}
187
- contentFit="contain"
188
- />
189
- ) : (
190
- <View
191
- style={{
192
- width: imageSize,
193
- height: imageSize,
194
- backgroundColor: `${accent}22`,
195
- borderRadius: t.radii.md,
196
- }}
197
- />
198
- )}
199
- </View>
200
- </View>
201
- </Pressable>
202
- </Animated.View>
203
- );
204
- }
205
-
206
- /** Animated dots driven by scrollX. Maquette: pill 16×6 / dot 6×6. */
207
- function CollectionPagination({
208
- count,
209
- scrollX,
210
- snapInterval,
211
- activeColor,
212
- inactiveColor,
213
- pageLabelColor,
214
- }: {
215
- count: number;
216
- scrollX: SharedValue<number>;
217
- snapInterval: number;
218
- activeColor: string;
219
- inactiveColor: string;
220
- pageLabelColor: string;
221
- }) {
222
- const [pageIndex, setPageIndex] = useState(0);
223
-
224
- useAnimatedReaction(
225
- () => Math.round(scrollX.value / Math.max(snapInterval, 1)),
226
- (current, previous) => {
227
- if (current !== previous) {
228
- const clamped = Math.max(0, Math.min(current, count - 1));
229
- runOnJS(setPageIndex)(clamped);
230
- }
231
- },
232
- [snapInterval, count],
233
- );
234
-
235
- return (
236
- <View style={styles.pagination}>
237
- {Array.from({ length: count }, (_, idx) => (
238
- <PaginationDot
239
- key={idx}
240
- index={idx}
241
- scrollX={scrollX}
242
- snapInterval={snapInterval}
243
- activeColor={activeColor}
244
- inactiveColor={inactiveColor}
245
- />
246
- ))}
247
- <Text style={[styles.pageCount, { color: pageLabelColor }]}>
248
- {pageIndex + 1}/{count}
249
- </Text>
250
- </View>
251
- );
252
- }
253
-
254
- function PaginationDot({
255
- index,
256
- scrollX,
257
- snapInterval,
258
- activeColor,
259
- inactiveColor,
260
- }: {
261
- index: number;
262
- scrollX: SharedValue<number>;
263
- snapInterval: number;
264
- activeColor: string;
265
- inactiveColor: string;
266
- }) {
267
- const animatedStyle = useAnimatedStyle(() => {
268
- const inputRange = [
269
- (index - 1) * snapInterval,
270
- index * snapInterval,
271
- (index + 1) * snapInterval,
272
- ];
273
- return {
274
- width: interpolate(
275
- scrollX.value,
276
- inputRange,
277
- [6, 16, 6],
278
- Extrapolation.CLAMP,
279
- ),
280
- opacity: interpolate(
281
- scrollX.value,
282
- inputRange,
283
- [0.4, 1, 0.4],
284
- Extrapolation.CLAMP,
285
- ),
286
- backgroundColor: interpolateColor(scrollX.value, inputRange, [
287
- inactiveColor,
288
- activeColor,
289
- inactiveColor,
290
- ]),
291
- };
292
- }, [index, snapInterval, activeColor, inactiveColor]);
293
-
294
- return <Animated.View style={[styles.dotBase, animatedStyle]} />;
295
- }
296
-
297
- export function NextCollection({
298
- calendarHref,
299
- showCalendarCta = true,
300
- dayOffset = 0,
301
- }: NextCollectionProps): ReactNode {
302
- const { theme } = useTheme();
303
- const { apiUrl } = useWasto();
304
- const { push } = useAppNavigation();
305
- const t = resolveTheme(theme);
306
- const [offset, setOffset] = useState(() => Math.trunc(dayOffset));
307
- const { collectionsForDay, dateLabel } = useNextCollection({
308
- dateOffset: offset,
309
- });
310
-
311
- useEffect(() => {
312
- setOffset(Math.trunc(dayOffset));
313
- }, [dayOffset]);
314
-
315
- const [selectedWasteTypeId, setSelectedWasteTypeId] = useState<string | null>(null);
316
- const listRef = useRef<Animated.FlatList<CollectionItem>>(null);
317
- const scrollX = useSharedValue(0);
318
- const lastSnappedIndex = useRef(0);
319
-
320
- const screenWidth = Dimensions.get("window").width;
321
- // Near full-bleed with SIDE_INSET peek — matches maquette presence (286 on 402 ≈ side 16–58)
322
- const cardWidth = Math.round(screenWidth - SIDE_INSET * 2);
323
- const cardHeight = Math.round(cardWidth * CARD_ASPECT);
324
- const sidePad = SIDE_INSET;
325
- const snapInterval = cardWidth + CARD_GAP;
326
-
327
- const snapOffsets = useMemo(
328
- () => collectionsForDay.map((_, i) => i * snapInterval),
329
- [collectionsForDay, snapInterval],
330
- );
331
-
332
- const imageSource = (item: CollectionItem) => {
333
- const fromUrl = resolveApiAssetUrl(apiUrl, item.imageUrl);
334
- if (fromUrl) return fromUrl;
335
- if (
336
- typeof item.icon === "string" &&
337
- item.icon.trim() &&
338
- /^https?:\/\//i.test(item.icon)
339
- ) {
340
- return item.icon.trim();
341
- }
342
- return null;
343
- };
344
-
345
- const scrollHandler = useAnimatedScrollHandler(
346
- {
347
- onScroll: (e) => {
348
- scrollX.value = e.contentOffset.x;
349
- },
350
- },
351
- [],
352
- );
353
-
354
- const snapToNearest = useCallback(
355
- (x: number) => {
356
- if (collectionsForDay.length === 0) return;
357
- const index = Math.round(x / snapInterval);
358
- const clamped = Math.max(0, Math.min(index, collectionsForDay.length - 1));
359
- const target = clamped * snapInterval;
360
- if (clamped !== lastSnappedIndex.current) {
361
- lastSnappedIndex.current = clamped;
362
- void hapticSelection();
363
- }
364
- if (Math.abs(x - target) > 1) {
365
- listRef.current?.scrollToOffset({ offset: target, animated: true });
366
- }
367
- scrollX.value = target;
368
- },
369
- [collectionsForDay.length, snapInterval, scrollX],
370
- );
371
-
372
- const onScrollSettle = useCallback(
373
- (
374
- e:
375
- | NativeSyntheticEvent<NativeScrollEvent>
376
- | { nativeEvent: { contentOffset: { x: number } } },
377
- ) => {
378
- snapToNearest(e.nativeEvent.contentOffset.x);
379
- },
380
- [snapToNearest],
381
- );
382
-
383
- const changeDate = async (delta: number) => {
384
- await hapticSelection();
385
- setOffset((o) => o + delta);
386
- lastSnappedIndex.current = 0;
387
- scrollX.value = 0;
388
- requestAnimationFrame(() => {
389
- listRef.current?.scrollToOffset({ offset: 0, animated: true });
390
- });
391
- };
392
-
393
- const openCalendar = () => {
394
- const normalizeHref = (href: string) =>
395
- href.startsWith("/") ? href : `/${href}`;
396
- const candidates = [
397
- calendarHref,
398
- "/(tabs)/calendrier",
399
- "/(tabs)/calendar",
400
- "/calendrier",
401
- "/calendar",
402
- ]
403
- .filter(Boolean)
404
- .map((href) => normalizeHref(href as string));
405
-
406
- for (const href of candidates) {
407
- try {
408
- push(href);
409
- return;
410
- } catch {
411
- // try next
412
- }
413
- }
414
- };
415
-
416
- return (
417
- <>
418
- <View style={styles.section}>
419
- <ContentInset>
420
- <View style={styles.header}>
421
- <Animated.Text
422
- key={dateLabel}
423
- entering={FadeInRight.duration(220)}
424
- exiting={FadeOutLeft.duration(160)}
425
- style={[styles.sectionLabel, { color: t.colors.muted }]}
426
- >
427
- {dateLabel.toUpperCase()}
428
- </Animated.Text>
429
- <View style={styles.navRow}>
430
- <NavButton
431
- icon={ChevronLeftIcon}
432
- onPress={() => changeDate(-1)}
433
- style={styles.navCircle}
434
- />
435
- <NavButton
436
- icon={ChevronRightIcon}
437
- onPress={() => changeDate(1)}
438
- style={styles.navCircle}
439
- />
440
- </View>
441
- </View>
442
- </ContentInset>
443
-
444
- {collectionsForDay.length === 0 ? (
445
- <ContentInset>
446
- <View
447
- style={[
448
- styles.emptyCard,
449
- cardStyle(theme),
450
- {
451
- borderRadius: t.radii.lg,
452
- minHeight: cardHeight,
453
- },
454
- ]}
455
- >
456
- <Text style={[styles.emptyText, { color: t.colors.muted }]}>
457
- Rien pour aujourd'hui
458
- </Text>
459
- </View>
460
- </ContentInset>
461
- ) : (
462
- <View key={offset}>
463
- <Animated.FlatList
464
- ref={listRef}
465
- data={collectionsForDay as CollectionItem[]}
466
- keyExtractor={(item) => item.id}
467
- horizontal
468
- showsHorizontalScrollIndicator={false}
469
- decelerationRate="fast"
470
- snapToInterval={Platform.OS === "web" ? undefined : snapInterval}
471
- snapToOffsets={Platform.OS === "web" ? undefined : snapOffsets}
472
- snapToAlignment="center"
473
- disableIntervalMomentum
474
- bounces={false}
475
- scrollEventThrottle={16}
476
- onScroll={scrollHandler}
477
- onScrollEndDrag={onScrollSettle}
478
- onMomentumScrollEnd={onScrollSettle}
479
- contentContainerStyle={{
480
- paddingHorizontal: sidePad,
481
- alignItems: "center",
482
- }}
483
- style={[
484
- { height: cardHeight },
485
- Platform.OS === "web"
486
- ? ({
487
- scrollSnapType: "x mandatory",
488
- WebkitOverflowScrolling: "touch",
489
- } as object)
490
- : null,
491
- ]}
492
- renderItem={({ item, index }) => (
493
- <CollectionSlide
494
- item={item}
495
- index={index}
496
- scrollX={scrollX}
497
- cardWidth={cardWidth}
498
- cardHeight={cardHeight}
499
- snapInterval={snapInterval}
500
- theme={theme}
501
- imageUri={imageSource(item)}
502
- isLast={index === collectionsForDay.length - 1}
503
- onPress={() => setSelectedWasteTypeId(item.id)}
504
- />
505
- )}
506
- />
507
-
508
- <CollectionPagination
509
- count={collectionsForDay.length}
510
- scrollX={scrollX}
511
- snapInterval={snapInterval}
512
- activeColor={theme.colors.primary}
513
- inactiveColor={theme.colors.border}
514
- pageLabelColor={t.colors.muted}
515
- />
516
- </View>
517
- )}
518
-
519
- {showCalendarCta ? (
520
- <ContentInset>
521
- <Button
522
- onPress={openCalendar}
523
- style={[styles.cta, { borderRadius: t.radii.md }]}
524
- >
525
- Afficher le calendrier
526
- </Button>
527
- </ContentInset>
528
- ) : null}
529
- </View>
530
-
531
- <ConsignesModal
532
- visible={!!selectedWasteTypeId}
533
- wasteTypeId={selectedWasteTypeId!}
534
- onClose={() => setSelectedWasteTypeId(null)}
535
- />
536
- </>
537
- );
538
- }
539
-
540
- const styles = StyleSheet.create({
541
- section: {
542
- gap: 14,
543
- },
544
- header: {
545
- flexDirection: "row",
546
- alignItems: "center",
547
- justifyContent: "space-between",
548
- },
549
- sectionLabel: {
550
- fontSize: 13,
551
- fontWeight: "700",
552
- letterSpacing: 0.6,
553
- },
554
- navRow: {
555
- flexDirection: "row",
556
- gap: 8,
557
- },
558
- navCircle: {
559
- width: SPACE.iconHit,
560
- height: SPACE.iconHit,
561
- },
562
- slide: {
563
- justifyContent: "center",
564
- },
565
- cardFrame: {
566
- flex: 1,
567
- overflow: "hidden",
568
- },
569
- cardPanel: {
570
- flex: 1,
571
- overflow: "hidden",
572
- position: "relative",
573
- },
574
- cardContent: {
575
- flex: 1,
576
- zIndex: 1,
577
- flexDirection: "row",
578
- alignItems: "center",
579
- justifyContent: "space-between",
580
- gap: 12,
581
- paddingLeft: 14,
582
- paddingRight: 12,
583
- paddingVertical: 14,
584
- },
585
- wasteName: {
586
- flex: 1,
587
- fontSize: 22,
588
- fontWeight: "800",
589
- lineHeight: 28,
590
- },
591
- pagination: {
592
- flexDirection: "row",
593
- alignItems: "center",
594
- justifyContent: "center",
595
- width: "100%",
596
- gap: 6,
597
- marginTop: 10,
598
- paddingHorizontal: SPACE.pageX,
599
- },
600
- dotBase: {
601
- height: 6,
602
- borderRadius: 3,
603
- },
604
- pageCount: {
605
- marginLeft: 4,
606
- fontSize: 12,
607
- fontWeight: "600",
608
- },
609
- emptyCard: {
610
- padding: 24,
611
- alignItems: "center",
612
- justifyContent: "center",
613
- },
614
- emptyText: {
615
- fontSize: 14,
616
- fontWeight: "600",
617
- },
618
- cta: {
619
- height: SPACE.buttonH,
620
- },
621
- });
1
+ import {
2
+ Dimensions,
3
+ Platform,
4
+ Pressable,
5
+ StyleSheet,
6
+ Text,
7
+ View,
8
+ type NativeScrollEvent,
9
+ type NativeSyntheticEvent,
10
+ } from "react-native";
11
+ import { Image } from "expo-image";
12
+ import Animated, {
13
+ Extrapolation,
14
+ FadeInRight,
15
+ FadeOutLeft,
16
+ interpolate,
17
+ interpolateColor,
18
+ runOnJS,
19
+ type SharedValue,
20
+ useAnimatedReaction,
21
+ useAnimatedScrollHandler,
22
+ useAnimatedStyle,
23
+ useSharedValue,
24
+ } from "react-native-reanimated";
25
+ import { useNextCollection } from "../../hooks/useNextCollection";
26
+ import { NEXT_EVENTS_DAY_RADIUS } from "../../contexts/AppDataContext";
27
+ import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react-native";
28
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
29
+ import { ConsignesModal } from "../consignes-modal/ConsignesModal";
30
+ import { NavButton } from "../nav-button/NavButton";
31
+ import { Button } from "../button/Button";
32
+ import { ContentInset } from "../content-inset/ContentInset";
33
+ import { useTheme } from "../../contexts/ThemeContext";
34
+ import { cardStyle, resolveTheme } from "../../helpers/themeStyles";
35
+ import { hapticSelection } from "../../helpers/haptics";
36
+ import { resolveApiAssetUrl } from "../../helpers/resolveApiAssetUrl";
37
+ import { useWasto } from "../../contexts/WastoContext";
38
+ import { SPACE } from "../../helpers/layout";
39
+ import type { Theme } from "../../types/Theme";
40
+ import { useAppNavigation } from "../../navigation/NavigationContext";
41
+
42
+ export type NextCollectionProps = {
43
+ /** Route / screen name for the calendar CTA. Defaults to trying common tab paths. */
44
+ calendarHref?: string;
45
+ /** Show calendar CTA under the slider (home mockup). Default true. */
46
+ showCalendarCta?: boolean;
47
+ /**
48
+ * Initial day offset relative to today (0 = today, 1 = tomorrow, -1 = yesterday).
49
+ * User can still navigate with prev/next.
50
+ */
51
+ dayOffset?: number;
52
+ };
53
+
54
+ /** Maquette: side cards ≈ 174/196 of active height */
55
+ const SIDE_SCALE = 174 / 196;
56
+ const CARD_GAP = 12;
57
+ /** Side inset so current card is centered with a peek of the next (~maquette). */
58
+ const SIDE_INSET = 20;
59
+ const CARD_ASPECT = 196 / 286;
60
+ /** White contour around the light-grey panel (screenshot). */
61
+ const FRAME_PAD = 10;
62
+ /**
63
+ * Figma Ellipse 4 on card 286×196 — only the top of a huge circle is visible
64
+ * (~52px), as a white bump on the bottom border.
65
+ */
66
+ const BUMP = {
67
+ w: 813 / 286,
68
+ /** Visible bump height / card height (196 − 144) / 196 */
69
+ visible: 52 / 196,
70
+ } as const;
71
+
72
+ type CollectionItem = {
73
+ id: string;
74
+ name: string;
75
+ color?: string | null;
76
+ imageUrl?: string | null;
77
+ icon?: string;
78
+ };
79
+
80
+ function CollectionSlide({
81
+ item,
82
+ index,
83
+ scrollX,
84
+ cardWidth,
85
+ cardHeight,
86
+ snapInterval,
87
+ theme,
88
+ imageUri,
89
+ onPress,
90
+ isLast,
91
+ }: {
92
+ item: CollectionItem;
93
+ index: number;
94
+ scrollX: SharedValue<number>;
95
+ cardWidth: number;
96
+ cardHeight: number;
97
+ snapInterval: number;
98
+ theme: Theme;
99
+ imageUri: string | null;
100
+ onPress: () => void;
101
+ isLast: boolean;
102
+ }) {
103
+ const t = resolveTheme(theme);
104
+ const accent = item.color ?? theme.colors.primary;
105
+ const imageSize = Math.round(cardHeight * 0.55);
106
+ const panelW = cardWidth - FRAME_PAD * 2;
107
+ // True circle (square + radius/2). A non-square + borderRadius 9999
108
+ // renders as a rounded rectangle / pill on RN web.
109
+ const bumpSize = cardWidth * BUMP.w;
110
+ const bumpVisible = cardHeight * BUMP.visible;
111
+
112
+ const animatedStyle = useAnimatedStyle(() => {
113
+ const center = index * snapInterval;
114
+ const scale = interpolate(
115
+ scrollX.value,
116
+ [center - snapInterval, center, center + snapInterval],
117
+ [SIDE_SCALE, 1, SIDE_SCALE],
118
+ Extrapolation.CLAMP,
119
+ );
120
+ return {
121
+ transform: [{ scale }],
122
+ };
123
+ }, [index, snapInterval]);
124
+
125
+ return (
126
+ <Animated.View
127
+ style={[
128
+ styles.slide,
129
+ {
130
+ width: cardWidth,
131
+ height: cardHeight,
132
+ marginRight: isLast ? 0 : CARD_GAP,
133
+ ...(Platform.OS === "web"
134
+ ? ({ scrollSnapAlign: "center", scrollSnapStop: "always" } as object)
135
+ : null),
136
+ },
137
+ animatedStyle,
138
+ ]}
139
+ >
140
+ <Pressable
141
+ onPress={onPress}
142
+ style={[
143
+ styles.cardFrame,
144
+ {
145
+ borderRadius: t.radii.lg,
146
+ backgroundColor: "#FFFFFF",
147
+ borderWidth: 1,
148
+ borderColor: theme.colors.border,
149
+ padding: FRAME_PAD,
150
+ ...t.shadow.card,
151
+ },
152
+ ]}
153
+ >
154
+ {/* Light-grey interior — white bump is the frame swelling up */}
155
+ <View
156
+ style={[
157
+ styles.cardPanel,
158
+ {
159
+ backgroundColor: "#F0F0F0",
160
+ borderRadius: Math.max(10, t.radii.lg - 6),
161
+ },
162
+ ]}
163
+ >
164
+ <View
165
+ pointerEvents="none"
166
+ style={{
167
+ position: "absolute",
168
+ width: bumpSize,
169
+ height: bumpSize,
170
+ borderRadius: bumpSize / 2,
171
+ left: (panelW - bumpSize) / 2,
172
+ bottom: -(bumpSize - bumpVisible),
173
+ backgroundColor: "#FFFFFF",
174
+ }}
175
+ />
176
+
177
+ <View style={styles.cardContent}>
178
+ <Text
179
+ style={[styles.wasteName, { color: theme.colors.onSurface }]}
180
+ numberOfLines={2}
181
+ >
182
+ {item.name}
183
+ </Text>
184
+ {imageUri ? (
185
+ <Image
186
+ source={imageUri}
187
+ style={{ width: imageSize, height: imageSize }}
188
+ contentFit="contain"
189
+ />
190
+ ) : (
191
+ <View
192
+ style={{
193
+ width: imageSize,
194
+ height: imageSize,
195
+ backgroundColor: `${accent}22`,
196
+ borderRadius: t.radii.md,
197
+ }}
198
+ />
199
+ )}
200
+ </View>
201
+ </View>
202
+ </Pressable>
203
+ </Animated.View>
204
+ );
205
+ }
206
+
207
+ /** Animated dots driven by scrollX. Maquette: pill 16×6 / dot 6×6. */
208
+ function CollectionPagination({
209
+ count,
210
+ scrollX,
211
+ snapInterval,
212
+ activeColor,
213
+ inactiveColor,
214
+ pageLabelColor,
215
+ }: {
216
+ count: number;
217
+ scrollX: SharedValue<number>;
218
+ snapInterval: number;
219
+ activeColor: string;
220
+ inactiveColor: string;
221
+ pageLabelColor: string;
222
+ }) {
223
+ const [pageIndex, setPageIndex] = useState(0);
224
+
225
+ useAnimatedReaction(
226
+ () => Math.round(scrollX.value / Math.max(snapInterval, 1)),
227
+ (current, previous) => {
228
+ if (current !== previous) {
229
+ const clamped = Math.max(0, Math.min(current, count - 1));
230
+ runOnJS(setPageIndex)(clamped);
231
+ }
232
+ },
233
+ [snapInterval, count],
234
+ );
235
+
236
+ return (
237
+ <View style={styles.pagination}>
238
+ {Array.from({ length: count }, (_, idx) => (
239
+ <PaginationDot
240
+ key={idx}
241
+ index={idx}
242
+ scrollX={scrollX}
243
+ snapInterval={snapInterval}
244
+ activeColor={activeColor}
245
+ inactiveColor={inactiveColor}
246
+ />
247
+ ))}
248
+ <Text style={[styles.pageCount, { color: pageLabelColor }]}>
249
+ {pageIndex + 1}/{count}
250
+ </Text>
251
+ </View>
252
+ );
253
+ }
254
+
255
+ function PaginationDot({
256
+ index,
257
+ scrollX,
258
+ snapInterval,
259
+ activeColor,
260
+ inactiveColor,
261
+ }: {
262
+ index: number;
263
+ scrollX: SharedValue<number>;
264
+ snapInterval: number;
265
+ activeColor: string;
266
+ inactiveColor: string;
267
+ }) {
268
+ const animatedStyle = useAnimatedStyle(() => {
269
+ const inputRange = [
270
+ (index - 1) * snapInterval,
271
+ index * snapInterval,
272
+ (index + 1) * snapInterval,
273
+ ];
274
+ return {
275
+ width: interpolate(
276
+ scrollX.value,
277
+ inputRange,
278
+ [6, 16, 6],
279
+ Extrapolation.CLAMP,
280
+ ),
281
+ opacity: interpolate(
282
+ scrollX.value,
283
+ inputRange,
284
+ [0.4, 1, 0.4],
285
+ Extrapolation.CLAMP,
286
+ ),
287
+ backgroundColor: interpolateColor(scrollX.value, inputRange, [
288
+ inactiveColor,
289
+ activeColor,
290
+ inactiveColor,
291
+ ]),
292
+ };
293
+ }, [index, snapInterval, activeColor, inactiveColor]);
294
+
295
+ return <Animated.View style={[styles.dotBase, animatedStyle]} />;
296
+ }
297
+
298
+ export function NextCollection({
299
+ calendarHref,
300
+ showCalendarCta = true,
301
+ dayOffset = 0,
302
+ }: NextCollectionProps): ReactNode {
303
+ const { theme } = useTheme();
304
+ const { apiUrl } = useWasto();
305
+ const { push } = useAppNavigation();
306
+ const t = resolveTheme(theme);
307
+ const [offset, setOffset] = useState(() => Math.trunc(dayOffset));
308
+ const { collectionsForDay, dateLabel } = useNextCollection({
309
+ dateOffset: offset,
310
+ });
311
+
312
+ useEffect(() => {
313
+ setOffset(Math.trunc(dayOffset));
314
+ }, [dayOffset]);
315
+
316
+ const [selectedWasteTypeId, setSelectedWasteTypeId] = useState<string | null>(null);
317
+ const listRef = useRef<Animated.FlatList<CollectionItem>>(null);
318
+ const scrollX = useSharedValue(0);
319
+ const lastSnappedIndex = useRef(0);
320
+
321
+ const screenWidth = Dimensions.get("window").width;
322
+ // Near full-bleed with SIDE_INSET peek — matches maquette presence (286 on 402 ≈ side 16–58)
323
+ const cardWidth = Math.round(screenWidth - SIDE_INSET * 2);
324
+ const cardHeight = Math.round(cardWidth * CARD_ASPECT);
325
+ const sidePad = SIDE_INSET;
326
+ const snapInterval = cardWidth + CARD_GAP;
327
+
328
+ const snapOffsets = useMemo(
329
+ () => collectionsForDay.map((_, i) => i * snapInterval),
330
+ [collectionsForDay, snapInterval],
331
+ );
332
+
333
+ const imageSource = (item: CollectionItem) => {
334
+ const fromUrl = resolveApiAssetUrl(apiUrl, item.imageUrl);
335
+ if (fromUrl) return fromUrl;
336
+ if (
337
+ typeof item.icon === "string" &&
338
+ item.icon.trim() &&
339
+ /^https?:\/\//i.test(item.icon)
340
+ ) {
341
+ return item.icon.trim();
342
+ }
343
+ return null;
344
+ };
345
+
346
+ const scrollHandler = useAnimatedScrollHandler(
347
+ {
348
+ onScroll: (e) => {
349
+ scrollX.value = e.contentOffset.x;
350
+ },
351
+ },
352
+ [],
353
+ );
354
+
355
+ const snapToNearest = useCallback(
356
+ (x: number) => {
357
+ if (collectionsForDay.length === 0) return;
358
+ const index = Math.round(x / snapInterval);
359
+ const clamped = Math.max(0, Math.min(index, collectionsForDay.length - 1));
360
+ const target = clamped * snapInterval;
361
+ if (clamped !== lastSnappedIndex.current) {
362
+ lastSnappedIndex.current = clamped;
363
+ void hapticSelection();
364
+ }
365
+ if (Math.abs(x - target) > 1) {
366
+ listRef.current?.scrollToOffset({ offset: target, animated: true });
367
+ }
368
+ scrollX.value = target;
369
+ },
370
+ [collectionsForDay.length, snapInterval, scrollX],
371
+ );
372
+
373
+ const onScrollSettle = useCallback(
374
+ (
375
+ e:
376
+ | NativeSyntheticEvent<NativeScrollEvent>
377
+ | { nativeEvent: { contentOffset: { x: number } } },
378
+ ) => {
379
+ snapToNearest(e.nativeEvent.contentOffset.x);
380
+ },
381
+ [snapToNearest],
382
+ );
383
+
384
+ const changeDate = async (delta: number) => {
385
+ await hapticSelection();
386
+ setOffset((o) => {
387
+ const next = o + delta;
388
+ return Math.max(-NEXT_EVENTS_DAY_RADIUS, Math.min(NEXT_EVENTS_DAY_RADIUS, next));
389
+ });
390
+ lastSnappedIndex.current = 0;
391
+ scrollX.value = 0;
392
+ requestAnimationFrame(() => {
393
+ listRef.current?.scrollToOffset({ offset: 0, animated: true });
394
+ });
395
+ };
396
+
397
+ const openCalendar = () => {
398
+ const normalizeHref = (href: string) =>
399
+ href.startsWith("/") ? href : `/${href}`;
400
+ const candidates = [
401
+ calendarHref,
402
+ "/(tabs)/calendrier",
403
+ "/(tabs)/calendar",
404
+ "/calendrier",
405
+ "/calendar",
406
+ ]
407
+ .filter(Boolean)
408
+ .map((href) => normalizeHref(href as string));
409
+
410
+ for (const href of candidates) {
411
+ try {
412
+ push(href);
413
+ return;
414
+ } catch {
415
+ // try next
416
+ }
417
+ }
418
+ };
419
+
420
+ return (
421
+ <>
422
+ <View style={styles.section}>
423
+ <ContentInset>
424
+ <View style={styles.header}>
425
+ <Animated.Text
426
+ key={dateLabel}
427
+ entering={FadeInRight.duration(220)}
428
+ exiting={FadeOutLeft.duration(160)}
429
+ style={[styles.sectionLabel, { color: t.colors.muted }]}
430
+ >
431
+ {dateLabel.toUpperCase()}
432
+ </Animated.Text>
433
+ <View style={styles.navRow}>
434
+ <NavButton
435
+ icon={ChevronLeftIcon}
436
+ onPress={() => changeDate(-1)}
437
+ disabled={offset <= -NEXT_EVENTS_DAY_RADIUS}
438
+ style={[
439
+ styles.navCircle,
440
+ offset <= -NEXT_EVENTS_DAY_RADIUS ? styles.navDisabled : null,
441
+ ]}
442
+ />
443
+ <NavButton
444
+ icon={ChevronRightIcon}
445
+ onPress={() => changeDate(1)}
446
+ disabled={offset >= NEXT_EVENTS_DAY_RADIUS}
447
+ style={[
448
+ styles.navCircle,
449
+ offset >= NEXT_EVENTS_DAY_RADIUS ? styles.navDisabled : null,
450
+ ]}
451
+ />
452
+ </View>
453
+ </View>
454
+ </ContentInset>
455
+
456
+ {collectionsForDay.length === 0 ? (
457
+ <ContentInset>
458
+ <View
459
+ style={[
460
+ styles.emptyCard,
461
+ cardStyle(theme),
462
+ {
463
+ borderRadius: t.radii.lg,
464
+ minHeight: cardHeight,
465
+ },
466
+ ]}
467
+ >
468
+ <Text style={[styles.emptyText, { color: t.colors.muted }]}>
469
+ Rien pour aujourd'hui
470
+ </Text>
471
+ </View>
472
+ </ContentInset>
473
+ ) : (
474
+ <View key={offset}>
475
+ <Animated.FlatList
476
+ ref={listRef}
477
+ data={collectionsForDay as CollectionItem[]}
478
+ keyExtractor={(item) => item.id}
479
+ horizontal
480
+ showsHorizontalScrollIndicator={false}
481
+ decelerationRate="fast"
482
+ snapToInterval={Platform.OS === "web" ? undefined : snapInterval}
483
+ snapToOffsets={Platform.OS === "web" ? undefined : snapOffsets}
484
+ snapToAlignment="center"
485
+ disableIntervalMomentum
486
+ bounces={false}
487
+ scrollEventThrottle={16}
488
+ onScroll={scrollHandler}
489
+ onScrollEndDrag={onScrollSettle}
490
+ onMomentumScrollEnd={onScrollSettle}
491
+ contentContainerStyle={{
492
+ paddingHorizontal: sidePad,
493
+ alignItems: "center",
494
+ }}
495
+ style={[
496
+ { height: cardHeight },
497
+ Platform.OS === "web"
498
+ ? ({
499
+ scrollSnapType: "x mandatory",
500
+ WebkitOverflowScrolling: "touch",
501
+ } as object)
502
+ : null,
503
+ ]}
504
+ renderItem={({ item, index }) => (
505
+ <CollectionSlide
506
+ item={item}
507
+ index={index}
508
+ scrollX={scrollX}
509
+ cardWidth={cardWidth}
510
+ cardHeight={cardHeight}
511
+ snapInterval={snapInterval}
512
+ theme={theme}
513
+ imageUri={imageSource(item)}
514
+ isLast={index === collectionsForDay.length - 1}
515
+ onPress={() => setSelectedWasteTypeId(item.id)}
516
+ />
517
+ )}
518
+ />
519
+
520
+ <CollectionPagination
521
+ count={collectionsForDay.length}
522
+ scrollX={scrollX}
523
+ snapInterval={snapInterval}
524
+ activeColor={theme.colors.primary}
525
+ inactiveColor={theme.colors.border}
526
+ pageLabelColor={t.colors.muted}
527
+ />
528
+ </View>
529
+ )}
530
+
531
+ {showCalendarCta ? (
532
+ <ContentInset>
533
+ <Button
534
+ onPress={openCalendar}
535
+ style={[styles.cta, { borderRadius: t.radii.md }]}
536
+ >
537
+ Afficher le calendrier
538
+ </Button>
539
+ </ContentInset>
540
+ ) : null}
541
+ </View>
542
+
543
+ <ConsignesModal
544
+ visible={!!selectedWasteTypeId}
545
+ wasteTypeId={selectedWasteTypeId!}
546
+ onClose={() => setSelectedWasteTypeId(null)}
547
+ />
548
+ </>
549
+ );
550
+ }
551
+
552
+ const styles = StyleSheet.create({
553
+ section: {
554
+ gap: 14,
555
+ },
556
+ header: {
557
+ flexDirection: "row",
558
+ alignItems: "center",
559
+ justifyContent: "space-between",
560
+ },
561
+ sectionLabel: {
562
+ fontSize: 13,
563
+ fontWeight: "700",
564
+ letterSpacing: 0.6,
565
+ },
566
+ navRow: {
567
+ flexDirection: "row",
568
+ gap: 8,
569
+ },
570
+ navCircle: {
571
+ width: SPACE.iconHit,
572
+ height: SPACE.iconHit,
573
+ },
574
+ navDisabled: {
575
+ opacity: 0.35,
576
+ },
577
+ slide: {
578
+ justifyContent: "center",
579
+ },
580
+ cardFrame: {
581
+ flex: 1,
582
+ overflow: "hidden",
583
+ },
584
+ cardPanel: {
585
+ flex: 1,
586
+ overflow: "hidden",
587
+ position: "relative",
588
+ },
589
+ cardContent: {
590
+ flex: 1,
591
+ zIndex: 1,
592
+ flexDirection: "row",
593
+ alignItems: "center",
594
+ justifyContent: "space-between",
595
+ gap: 12,
596
+ paddingLeft: 14,
597
+ paddingRight: 12,
598
+ paddingVertical: 14,
599
+ },
600
+ wasteName: {
601
+ flex: 1,
602
+ fontSize: 22,
603
+ fontWeight: "800",
604
+ lineHeight: 28,
605
+ },
606
+ pagination: {
607
+ flexDirection: "row",
608
+ alignItems: "center",
609
+ justifyContent: "center",
610
+ width: "100%",
611
+ gap: 6,
612
+ marginTop: 10,
613
+ paddingHorizontal: SPACE.pageX,
614
+ },
615
+ dotBase: {
616
+ height: 6,
617
+ borderRadius: 3,
618
+ },
619
+ pageCount: {
620
+ marginLeft: 4,
621
+ fontSize: 12,
622
+ fontWeight: "600",
623
+ },
624
+ emptyCard: {
625
+ padding: 24,
626
+ alignItems: "center",
627
+ justifyContent: "center",
628
+ },
629
+ emptyText: {
630
+ fontSize: 14,
631
+ fontWeight: "600",
632
+ },
633
+ cta: {
634
+ height: SPACE.buttonH,
635
+ },
636
+ });