@wasto/cms-components 1.5.16 → 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/dist/index.d.mts +11 -4
- package/dist/index.d.ts +11 -4
- package/dist/index.js +1766 -1269
- package/dist/index.mjs +1497 -999
- package/package.json +2 -2
- package/src/components/announcement-modal/AnnouncementModal.tsx +46 -7
- package/src/components/collection-calendar/CollectionCalendar.tsx +32 -4
- package/src/components/collection-calendar/CollectionDayModal.tsx +286 -0
- package/src/components/header/Header.tsx +52 -12
- package/src/components/locations-map/LocationsMapShell.tsx +1 -1
- package/src/components/push/PushNotificationNavigator.tsx +133 -0
- package/src/components/push/PushTokenRegistrar.tsx +5 -3
- package/src/contexts/AnnouncementsContext.tsx +72 -8
- package/src/contexts/LocalContext.tsx +9 -5
- package/src/contexts/WastoContext.tsx +4 -2
- package/src/hooks/useCityAnnouncements.ts +4 -4
- package/src/hooks/useCollectionCalendar.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wasto/cms-components",
|
|
3
|
-
"version": "1.5.
|
|
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.
|
|
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
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 {
|
|
3
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
+
});
|
|
@@ -27,9 +27,11 @@ export type AppHeaderLogoElement = {
|
|
|
27
27
|
id: string;
|
|
28
28
|
type: "logo";
|
|
29
29
|
slot: AppHeaderSlot;
|
|
30
|
-
sourceType: "url" | "media";
|
|
30
|
+
sourceType: "url" | "media" | "bundled";
|
|
31
31
|
url?: string;
|
|
32
32
|
mediaId?: string;
|
|
33
|
+
/** `require(...)` d’un asset embarqué à l’export (ImageSource). */
|
|
34
|
+
module?: number;
|
|
33
35
|
};
|
|
34
36
|
|
|
35
37
|
export type AppHeaderTitleElement = {
|
|
@@ -94,16 +96,27 @@ export type HeaderProps = {
|
|
|
94
96
|
*/
|
|
95
97
|
readonly headerConfig?: AppHeaderConfig | null;
|
|
96
98
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
+
* Padding safe-area top. Défaut `true` (header custom RN = edge-to-edge,
|
|
100
|
+
* il faut l’inset). Passer `false` seulement si le parent inset déjà.
|
|
99
101
|
*/
|
|
100
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;
|
|
101
108
|
};
|
|
102
109
|
|
|
110
|
+
function isIdentityFreeRoute(name: string): boolean {
|
|
111
|
+
const leaf = name.split("/").pop()?.toLowerCase() ?? "";
|
|
112
|
+
return leaf === "notifications" || leaf === "profile";
|
|
113
|
+
}
|
|
114
|
+
|
|
103
115
|
function resolveLogoUri(
|
|
104
116
|
apiUrl: string,
|
|
105
117
|
el: AppHeaderLogoElement,
|
|
106
118
|
): string | null {
|
|
119
|
+
if (el.sourceType === "bundled") return null;
|
|
107
120
|
const url = (el.url || "").trim();
|
|
108
121
|
if (url) return url;
|
|
109
122
|
// Fallback média (export parfois laisse mediaId si rewrite manqué)
|
|
@@ -130,6 +143,16 @@ function HeaderLogo({
|
|
|
130
143
|
readonly element: AppHeaderLogoElement;
|
|
131
144
|
}): ReactNode {
|
|
132
145
|
const { apiUrl } = useWasto();
|
|
146
|
+
if (element.sourceType === "bundled" && element.module != null) {
|
|
147
|
+
return (
|
|
148
|
+
<Image
|
|
149
|
+
source={element.module}
|
|
150
|
+
style={styles.logo as ImageStyle}
|
|
151
|
+
resizeMode="contain"
|
|
152
|
+
accessibilityLabel="Logo"
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
133
156
|
const uri = resolveLogoUri(apiUrl, element);
|
|
134
157
|
if (!uri) return null;
|
|
135
158
|
return <HeaderLogoImage uri={uri} />;
|
|
@@ -143,8 +166,11 @@ function CityLogoFallback(): ReactNode {
|
|
|
143
166
|
? appData.city.logoMediaId
|
|
144
167
|
: null;
|
|
145
168
|
const uri = publicMediaUrl(apiUrl, logoMediaId);
|
|
146
|
-
if (
|
|
147
|
-
|
|
169
|
+
if (uri) return <HeaderLogoImage uri={uri} />;
|
|
170
|
+
const cityName =
|
|
171
|
+
typeof appData?.city?.name === "string" ? appData.city.name.trim() : "";
|
|
172
|
+
if (cityName) return <Title text={cityName} level="1" />;
|
|
173
|
+
return null;
|
|
148
174
|
}
|
|
149
175
|
|
|
150
176
|
function HeaderActionButton({
|
|
@@ -178,21 +204,24 @@ export function Header({
|
|
|
178
204
|
overlay = false,
|
|
179
205
|
headerConfig,
|
|
180
206
|
safeTop,
|
|
207
|
+
showCityIdentity,
|
|
181
208
|
}: HeaderProps): ReactNode {
|
|
182
209
|
const { theme } = useTheme();
|
|
183
210
|
const t = resolveTheme(theme);
|
|
184
211
|
const themedStyles = createThemedStyles(theme);
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
const applyTopInset = safeTop ?? overlay;
|
|
212
|
+
// Header custom = plein écran sous le notch → inset requis (Page ne le re-ajoute plus).
|
|
213
|
+
const applyTopInset = safeTop ?? true;
|
|
188
214
|
const topInset = useSafeAreaPadding(applyTopInset ? ["top"] : []);
|
|
189
215
|
const { hasUnread } = useAnnouncements();
|
|
190
216
|
const { push } = useAppNavigation();
|
|
217
|
+
const { apiUrl } = useWasto();
|
|
191
218
|
|
|
192
219
|
const screenTitle = useMemo(
|
|
193
220
|
() => options.title ?? route.name,
|
|
194
221
|
[options.title, route.name],
|
|
195
222
|
);
|
|
223
|
+
const showIdentity =
|
|
224
|
+
showCityIdentity ?? !isIdentityFreeRoute(route.name);
|
|
196
225
|
|
|
197
226
|
/** Config explicite → navbar (logo/actions), jamais le fallback titre d’écran. */
|
|
198
227
|
const hasExplicitConfig = headerConfig != null;
|
|
@@ -202,7 +231,12 @@ export function Header({
|
|
|
202
231
|
return raw.filter((el) => el.type !== "title");
|
|
203
232
|
}, [headerConfig]);
|
|
204
233
|
const hasCustomActions = elements.some((el) => el.type === "action");
|
|
205
|
-
|
|
234
|
+
// Logo CMS sans URI / sans module bundlé → fallback ville.
|
|
235
|
+
const hasResolvableLogo = elements.some((el) => {
|
|
236
|
+
if (el.type !== "logo") return false;
|
|
237
|
+
if (el.sourceType === "bundled" && el.module != null) return true;
|
|
238
|
+
return resolveLogoUri(apiUrl, el) != null;
|
|
239
|
+
});
|
|
206
240
|
|
|
207
241
|
const bySlot = useMemo(() => {
|
|
208
242
|
const left: AppHeaderElement[] = [];
|
|
@@ -329,7 +363,9 @@ export function Header({
|
|
|
329
363
|
</HeaderActionButton>
|
|
330
364
|
) : null}
|
|
331
365
|
{bySlot.left.map(renderElement)}
|
|
332
|
-
{!
|
|
366
|
+
{showIdentity && !hasResolvableLogo ? (
|
|
367
|
+
<CityLogoFallback />
|
|
368
|
+
) : null}
|
|
333
369
|
</View>
|
|
334
370
|
<View
|
|
335
371
|
style={styles.slotCenter}
|
|
@@ -358,8 +394,12 @@ export function Header({
|
|
|
358
394
|
/>
|
|
359
395
|
</HeaderActionButton>
|
|
360
396
|
) : null}
|
|
361
|
-
|
|
362
|
-
|
|
397
|
+
{showIdentity ? (
|
|
398
|
+
<>
|
|
399
|
+
<CityLogoFallback />
|
|
400
|
+
<Title text={screenTitle} level="1" />
|
|
401
|
+
</>
|
|
402
|
+
) : null}
|
|
363
403
|
</View>
|
|
364
404
|
<View style={styles.actions}>{defaultActions}</View>
|
|
365
405
|
</>
|