@wasto/cms-components 1.5.18 → 1.5.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wasto/cms-components",
3
- "version": "1.5.18",
3
+ "version": "1.5.19",
4
4
  "description": "Wast'O CMS React Native components",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -92,7 +92,7 @@
92
92
  "react-native-reanimated": "~4.1.1",
93
93
  "tsup": "^8.5.1",
94
94
  "typescript": "^6.0.2",
95
- "@wasto/sdk": "1.0.5"
95
+ "@wasto/sdk": "1.0.6"
96
96
  },
97
97
  "keywords": [],
98
98
  "author": "",
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
- import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
2
+ import { Image, Modal, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
3
3
  import { X } from "lucide-react-native";
4
4
  import { useAnnouncements } from "../../contexts/AnnouncementsContext";
5
5
  import { useTheme } from "../../contexts/ThemeContext";
@@ -9,6 +9,7 @@ import { getContrastColor } from "../../helpers/getContrastColor";
9
9
  import { Button } from "../button/Button";
10
10
  import { hapticLight } from "../../helpers/haptics";
11
11
  import { SPACE } from "../../helpers/layout";
12
+ import { useOptionalAppNavigation } from "../../navigation/NavigationContext";
12
13
 
13
14
  /** Oversized circle (relative to sheet width) → gentle convex arc. */
14
15
  const CIRCLE_WIDTH_PCT = "500%";
@@ -22,16 +23,25 @@ export function AnnouncementModal(): ReactNode {
22
23
  const t = resolveTheme(theme);
23
24
  const topInset = useSafeAreaPadding(["top"]);
24
25
  const bottomInset = useSafeAreaPadding(["bottom"]);
26
+ const navigation = useOptionalAppNavigation();
25
27
 
26
28
  const visible = forceVisible && !!announcement;
27
29
  const contrast = getContrastColor(theme.colors.primary);
28
30
  const sheetColor = theme.colors.surface;
31
+ const redirectHref = announcement?.redirectHref?.trim() || null;
29
32
 
30
33
  const handleDismiss = async () => {
31
34
  await hapticLight();
32
35
  await dismiss();
33
36
  };
34
37
 
38
+ const handleOpen = async () => {
39
+ if (!redirectHref) return;
40
+ await hapticLight();
41
+ navigation?.push(redirectHref);
42
+ await dismiss();
43
+ };
44
+
35
45
  return (
36
46
  <Modal visible={visible} animationType="slide" onRequestClose={handleDismiss}>
37
47
  <View style={[styles.root, { backgroundColor: theme.colors.primary }]}>
@@ -93,16 +103,35 @@ export function AnnouncementModal(): ReactNode {
93
103
  bounces={false}
94
104
  showsVerticalScrollIndicator={false}
95
105
  >
106
+ {announcement?.imageUrl ? (
107
+ <Image
108
+ source={{ uri: announcement.imageUrl }}
109
+ style={styles.image}
110
+ resizeMode="cover"
111
+ accessibilityLabel="Illustration de l'annonce"
112
+ />
113
+ ) : null}
96
114
  <Text style={[styles.bodyText, { color: t.colors.muted }]}>
97
115
  {announcement?.body}
98
116
  </Text>
99
117
  </ScrollView>
100
- <Button
101
- onPress={handleDismiss}
102
- style={[styles.cta, { borderRadius: t.radii.pill }]}
103
- >
104
- C'est compris !
105
- </Button>
118
+ <View style={styles.actions}>
119
+ {redirectHref ? (
120
+ <Button
121
+ onPress={handleOpen}
122
+ style={[styles.cta, { borderRadius: t.radii.pill }]}
123
+ >
124
+ Ouvrir
125
+ </Button>
126
+ ) : null}
127
+ <Button
128
+ onPress={handleDismiss}
129
+ variant={redirectHref ? "outline" : "primary"}
130
+ style={[styles.cta, { borderRadius: t.radii.pill }]}
131
+ >
132
+ C'est compris !
133
+ </Button>
134
+ </View>
106
135
  </View>
107
136
  </View>
108
137
  </View>
@@ -172,10 +201,20 @@ const styles = StyleSheet.create({
172
201
  bodyScrollContent: {
173
202
  paddingBottom: 8,
174
203
  },
204
+ image: {
205
+ width: "100%",
206
+ aspectRatio: 16 / 9,
207
+ borderRadius: 12,
208
+ marginBottom: 16,
209
+ },
175
210
  bodyText: {
176
211
  fontSize: 16,
177
212
  lineHeight: 24,
178
213
  },
214
+ actions: {
215
+ gap: 12,
216
+ alignSelf: "stretch",
217
+ },
179
218
  cta: {
180
219
  height: 52,
181
220
  alignSelf: "stretch",
@@ -1,6 +1,10 @@
1
1
  import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react-native";
2
- import { useCollectionCalendar, type WasteTypeInfoForDay } from "../../hooks/useCollectionCalendar";
3
- import { StyleSheet, Text, View } from "react-native";
2
+ import {
3
+ useCollectionCalendar,
4
+ type CalendarDayCell,
5
+ type WasteTypeInfoForDay,
6
+ } from "../../hooks/useCollectionCalendar";
7
+ import { Pressable, StyleSheet, Text, View } from "react-native";
4
8
  import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
5
9
  import { NavButton } from "../nav-button/NavButton";
6
10
  import { ContentInset } from "../content-inset/ContentInset";
@@ -11,6 +15,7 @@ import { NextCollection } from "../next-collection/NextCollection";
11
15
  import { hapticSelection } from "../../helpers/haptics";
12
16
  import { SPACE } from "../../helpers/layout";
13
17
  import { useState, type ReactNode } from "react";
18
+ import { CollectionDayModal } from "./CollectionDayModal";
14
19
 
15
20
  export type CollectionCalendarProps = {};
16
21
 
@@ -47,6 +52,7 @@ export function CollectionCalendar({}: CollectionCalendarProps): ReactNode {
47
52
  weekDays,
48
53
  weeks,
49
54
  monthLabel,
55
+ wasteTypes,
50
56
  goToNextMonth,
51
57
  goToPrevMonth,
52
58
  } = useCollectionCalendar();
@@ -54,6 +60,7 @@ export function CollectionCalendar({}: CollectionCalendarProps): ReactNode {
54
60
  const t = resolveTheme(theme);
55
61
  const themedStyles = createThemedStyles(theme);
56
62
  const [monthKey, setMonthKey] = useState(0);
63
+ const [selectedDay, setSelectedDay] = useState<CalendarDayCell | null>(null);
57
64
  const today = new Date();
58
65
  const todayDay = today.getDate();
59
66
  const todayMonth = today.getMonth();
@@ -62,14 +69,20 @@ export function CollectionCalendar({}: CollectionCalendarProps): ReactNode {
62
69
 
63
70
  const prev = async () => {
64
71
  await hapticSelection();
72
+ setSelectedDay(null);
65
73
  goToPrevMonth();
66
74
  setMonthKey((k) => k - 1);
67
75
  };
68
76
  const next = async () => {
69
77
  await hapticSelection();
78
+ setSelectedDay(null);
70
79
  goToNextMonth();
71
80
  setMonthKey((k) => k + 1);
72
81
  };
82
+ const openDay = async (day: CalendarDayCell) => {
83
+ await hapticSelection();
84
+ setSelectedDay(day);
85
+ };
73
86
 
74
87
  return (
75
88
  <View style={styles.collectionCalendar}>
@@ -128,9 +141,19 @@ export function CollectionCalendar({}: CollectionCalendarProps): ReactNode {
128
141
  day.year === todayYear;
129
142
  const isException = day.exceptions.length > 0;
130
143
 
144
+ const collectionCount = day.wasteTypesForDay.length;
145
+ const dayLabel = `${day.day} ${monthLabel}${
146
+ collectionCount > 0
147
+ ? `, ${collectionCount} collecte${collectionCount > 1 ? "s" : ""}`
148
+ : ", aucune collecte"
149
+ }`;
150
+
131
151
  return (
132
- <View
152
+ <Pressable
133
153
  key={cellIndex}
154
+ onPress={() => void openDay(day)}
155
+ accessibilityRole="button"
156
+ accessibilityLabel={dayLabel}
134
157
  style={[
135
158
  styles.calendarBodyCell,
136
159
  !day.inCurrentMonth && styles.notCurrentMonth,
@@ -169,7 +192,7 @@ export function CollectionCalendar({}: CollectionCalendarProps): ReactNode {
169
192
  {day.day}
170
193
  </Text>
171
194
  <WasteTypePins types={day.wasteTypesForDay} />
172
- </View>
195
+ </Pressable>
173
196
  );
174
197
  })}
175
198
  </View>
@@ -178,6 +201,11 @@ export function CollectionCalendar({}: CollectionCalendarProps): ReactNode {
178
201
  </Animated.View>
179
202
 
180
203
  <NextCollection showCalendarCta={false} />
204
+ <CollectionDayModal
205
+ day={selectedDay}
206
+ wasteTypes={wasteTypes}
207
+ onClose={() => setSelectedDay(null)}
208
+ />
181
209
  </View>
182
210
  );
183
211
  }
@@ -0,0 +1,286 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
3
+ import { Image } from "expo-image";
4
+ import { X } from "lucide-react-native";
5
+ import type { CalendarDayCell } from "../../hooks/useCollectionCalendar";
6
+ import type { WasteType } from "../../helpers/nextCollection";
7
+ import { useTheme } from "../../contexts/ThemeContext";
8
+ import { useWasto } from "../../contexts/WastoContext";
9
+ import { cardStyle, resolveTheme } from "../../helpers/themeStyles";
10
+ import { resolveApiAssetUrl } from "../../helpers/resolveApiAssetUrl";
11
+ import { hapticLight, hapticSelection } from "../../helpers/haptics";
12
+ import { SPACE } from "../../helpers/layout";
13
+ import { useSafeAreaPadding } from "../../helpers/useSafeAreaPadding";
14
+ import { ConsignesModal } from "../consignes-modal/ConsignesModal";
15
+
16
+ export type CollectionDayModalProps = {
17
+ day: CalendarDayCell | null;
18
+ wasteTypes: WasteType[];
19
+ onClose: () => void;
20
+ };
21
+
22
+ function formatDayTitle(year: number, month: number, day: number): string {
23
+ const label = new Date(year, month, day).toLocaleDateString("fr-FR", {
24
+ weekday: "long",
25
+ day: "numeric",
26
+ month: "long",
27
+ });
28
+ return label.charAt(0).toUpperCase() + label.slice(1);
29
+ }
30
+
31
+ export function CollectionDayModal({
32
+ day,
33
+ wasteTypes,
34
+ onClose,
35
+ }: CollectionDayModalProps): ReactNode {
36
+ const { theme } = useTheme();
37
+ const t = resolveTheme(theme);
38
+ const { apiUrl } = useWasto();
39
+ const bottomInset = useSafeAreaPadding(["bottom"]);
40
+ const [consignesId, setConsignesId] = useState<string | null>(null);
41
+
42
+ const close = async () => {
43
+ await hapticLight();
44
+ setConsignesId(null);
45
+ onClose();
46
+ };
47
+
48
+ const rows = (day?.wasteTypesForDay ?? []).map((info) => {
49
+ const wasteType = wasteTypes.find((wt) => wt.id === info.wasteTypeId);
50
+ return {
51
+ id: info.wasteTypeId,
52
+ name: wasteType?.name?.trim() || "Collecte",
53
+ color: info.color,
54
+ imageUrl: resolveApiAssetUrl(apiUrl, wasteType?.imageUrl),
55
+ isException: Boolean(info.isException),
56
+ };
57
+ });
58
+ const hasException = (day?.exceptions.length ?? 0) > 0;
59
+
60
+ return (
61
+ <>
62
+ <Modal
63
+ visible={day != null && !consignesId}
64
+ transparent
65
+ animationType="fade"
66
+ onRequestClose={() => {
67
+ void close();
68
+ }}
69
+ >
70
+ <View style={styles.root}>
71
+ <Pressable
72
+ style={styles.backdrop}
73
+ onPress={() => {
74
+ void close();
75
+ }}
76
+ accessibilityLabel="Fermer le détail du jour"
77
+ />
78
+ <View
79
+ style={[
80
+ styles.sheet,
81
+ cardStyle(theme),
82
+ { borderRadius: t.radii.lg, backgroundColor: theme.colors.surface },
83
+ bottomInset,
84
+ ]}
85
+ >
86
+ <View style={styles.head}>
87
+ <View style={styles.headText}>
88
+ <Text style={[styles.eyebrow, { color: t.colors.muted }]}>
89
+ COLLECTE
90
+ </Text>
91
+ <Text style={[styles.title, { color: theme.colors.onSurface }]}>
92
+ {day
93
+ ? formatDayTitle(day.year, day.month, day.day)
94
+ : ""}
95
+ </Text>
96
+ </View>
97
+ <Pressable
98
+ onPress={() => {
99
+ void close();
100
+ }}
101
+ style={[
102
+ styles.closeBtn,
103
+ {
104
+ backgroundColor: theme.colors.background,
105
+ borderRadius: t.radii.pill,
106
+ },
107
+ ]}
108
+ accessibilityRole="button"
109
+ accessibilityLabel="Fermer"
110
+ >
111
+ <X size={18} color={theme.colors.onSurface} />
112
+ </Pressable>
113
+ </View>
114
+
115
+ {hasException ? (
116
+ <Text style={[styles.exception, { color: theme.colors.error }]}>
117
+ Une exception de collecte s’applique à cette date.
118
+ </Text>
119
+ ) : null}
120
+
121
+ {rows.length === 0 ? (
122
+ <Text style={[styles.empty, { color: t.colors.muted }]}>
123
+ Aucune collecte prévue ce jour.
124
+ </Text>
125
+ ) : (
126
+ <ScrollView
127
+ style={styles.list}
128
+ contentContainerStyle={styles.listContent}
129
+ bounces={false}
130
+ >
131
+ {rows.map((row) => (
132
+ <Pressable
133
+ key={row.id}
134
+ onPress={() => {
135
+ void (async () => {
136
+ await hapticSelection();
137
+ setConsignesId(row.id);
138
+ })();
139
+ }}
140
+ style={[
141
+ styles.row,
142
+ {
143
+ backgroundColor: theme.colors.background,
144
+ borderRadius: t.radii.md,
145
+ },
146
+ ]}
147
+ accessibilityRole="button"
148
+ accessibilityLabel={row.name}
149
+ >
150
+ {row.imageUrl ? (
151
+ <Image
152
+ source={row.imageUrl}
153
+ style={styles.thumb}
154
+ contentFit="contain"
155
+ />
156
+ ) : (
157
+ <View
158
+ style={[
159
+ styles.swatch,
160
+ { backgroundColor: row.color },
161
+ ]}
162
+ />
163
+ )}
164
+ <View style={styles.rowText}>
165
+ <Text
166
+ style={[
167
+ styles.rowTitle,
168
+ { color: theme.colors.onSurface },
169
+ ]}
170
+ >
171
+ {row.name}
172
+ </Text>
173
+ <Text style={[styles.rowHint, { color: t.colors.muted }]}>
174
+ {row.isException
175
+ ? "Reprise après exception"
176
+ : "Voir les consignes"}
177
+ </Text>
178
+ </View>
179
+ <View
180
+ style={[styles.pin, { backgroundColor: row.color }]}
181
+ />
182
+ </Pressable>
183
+ ))}
184
+ </ScrollView>
185
+ )}
186
+ </View>
187
+ </View>
188
+ </Modal>
189
+ <ConsignesModal
190
+ visible={!!consignesId}
191
+ wasteTypeId={consignesId ?? ""}
192
+ onClose={() => setConsignesId(null)}
193
+ />
194
+ </>
195
+ );
196
+ }
197
+
198
+ const styles = StyleSheet.create({
199
+ root: {
200
+ flex: 1,
201
+ justifyContent: "flex-end",
202
+ },
203
+ backdrop: {
204
+ ...StyleSheet.absoluteFillObject,
205
+ backgroundColor: "rgba(15, 23, 42, 0.45)",
206
+ },
207
+ sheet: {
208
+ marginHorizontal: SPACE.pageX,
209
+ marginBottom: SPACE.sectionY,
210
+ padding: SPACE.cardPad,
211
+ gap: 12,
212
+ maxHeight: "70%",
213
+ },
214
+ head: {
215
+ flexDirection: "row",
216
+ alignItems: "flex-start",
217
+ gap: 12,
218
+ },
219
+ headText: {
220
+ flex: 1,
221
+ gap: 2,
222
+ },
223
+ eyebrow: {
224
+ fontSize: 12,
225
+ fontWeight: "700",
226
+ letterSpacing: 0.6,
227
+ },
228
+ title: {
229
+ fontSize: 20,
230
+ fontWeight: "700",
231
+ },
232
+ closeBtn: {
233
+ width: SPACE.iconHit,
234
+ height: SPACE.iconHit,
235
+ alignItems: "center",
236
+ justifyContent: "center",
237
+ },
238
+ exception: {
239
+ fontSize: 14,
240
+ fontWeight: "600",
241
+ lineHeight: 20,
242
+ },
243
+ empty: {
244
+ fontSize: 15,
245
+ lineHeight: 22,
246
+ paddingBottom: 8,
247
+ },
248
+ list: {
249
+ flexGrow: 0,
250
+ },
251
+ listContent: {
252
+ gap: 8,
253
+ paddingBottom: 4,
254
+ },
255
+ row: {
256
+ flexDirection: "row",
257
+ alignItems: "center",
258
+ gap: 12,
259
+ padding: 12,
260
+ },
261
+ thumb: {
262
+ width: 40,
263
+ height: 40,
264
+ },
265
+ swatch: {
266
+ width: 40,
267
+ height: 40,
268
+ borderRadius: 12,
269
+ },
270
+ rowText: {
271
+ flex: 1,
272
+ gap: 2,
273
+ },
274
+ rowTitle: {
275
+ fontSize: 16,
276
+ fontWeight: "700",
277
+ },
278
+ rowHint: {
279
+ fontSize: 13,
280
+ },
281
+ pin: {
282
+ width: 10,
283
+ height: 10,
284
+ borderRadius: 5,
285
+ },
286
+ });
@@ -100,8 +100,18 @@ export type HeaderProps = {
100
100
  * il faut l’inset). Passer `false` seulement si le parent inset déjà.
101
101
  */
102
102
  readonly safeTop?: boolean;
103
+ /**
104
+ * Logo + titre de la collectivité. Défaut : masqué sur les écrans
105
+ * notifications et profil.
106
+ */
107
+ readonly showCityIdentity?: boolean;
103
108
  };
104
109
 
110
+ function isIdentityFreeRoute(name: string): boolean {
111
+ const leaf = name.split("/").pop()?.toLowerCase() ?? "";
112
+ return leaf === "notifications" || leaf === "profile";
113
+ }
114
+
105
115
  function resolveLogoUri(
106
116
  apiUrl: string,
107
117
  el: AppHeaderLogoElement,
@@ -194,6 +204,7 @@ export function Header({
194
204
  overlay = false,
195
205
  headerConfig,
196
206
  safeTop,
207
+ showCityIdentity,
197
208
  }: HeaderProps): ReactNode {
198
209
  const { theme } = useTheme();
199
210
  const t = resolveTheme(theme);
@@ -209,6 +220,8 @@ export function Header({
209
220
  () => options.title ?? route.name,
210
221
  [options.title, route.name],
211
222
  );
223
+ const showIdentity =
224
+ showCityIdentity ?? !isIdentityFreeRoute(route.name);
212
225
 
213
226
  /** Config explicite → navbar (logo/actions), jamais le fallback titre d’écran. */
214
227
  const hasExplicitConfig = headerConfig != null;
@@ -350,7 +363,9 @@ export function Header({
350
363
  </HeaderActionButton>
351
364
  ) : null}
352
365
  {bySlot.left.map(renderElement)}
353
- {!hasResolvableLogo ? <CityLogoFallback /> : null}
366
+ {showIdentity && !hasResolvableLogo ? (
367
+ <CityLogoFallback />
368
+ ) : null}
354
369
  </View>
355
370
  <View
356
371
  style={styles.slotCenter}
@@ -379,8 +394,12 @@ export function Header({
379
394
  />
380
395
  </HeaderActionButton>
381
396
  ) : null}
382
- <CityLogoFallback />
383
- <Title text={screenTitle} level="1" />
397
+ {showIdentity ? (
398
+ <>
399
+ <CityLogoFallback />
400
+ <Title text={screenTitle} level="1" />
401
+ </>
402
+ ) : null}
384
403
  </View>
385
404
  <View style={styles.actions}>{defaultActions}</View>
386
405
  </>
@@ -267,7 +267,7 @@ export function LocationsMapShell({
267
267
  </View>
268
268
 
269
269
  <View style={styles.topOverlay} pointerEvents="box-none">
270
- {overlayHeader ? (
270
+ {overlayHeader === true ? (
271
271
  <Header
272
272
  overlay
273
273
  options={{ headerBackVisible: false }}