@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,189 @@
1
+ import { StyleSheet, Text, View } from "react-native";
2
+ import { Image } from "expo-image";
3
+ import { useRecyclingInfo } from "../../hooks/useRecyclingInfo";
4
+ import { Theme } from "../../types/Theme";
5
+ import { useTheme } from "../../contexts/ThemeContext";
6
+ import { Title } from "../title/Title";
7
+ import { Paragraph } from "../parapraph/Paragraph";
8
+ import { getContrastColor } from "../../helpers/getContrastColor";
9
+ import { Button } from "../button/Button";
10
+ import { useState, type ReactNode } from "react";
11
+ import { ConsignesModal } from "../consignes-modal/ConsignesModal";
12
+ import { cardStyle, resolveTheme } from "../../helpers/themeStyles";
13
+ import { resolveApiAssetUrl } from "../../helpers/resolveApiAssetUrl";
14
+ import { useWasto } from "../../contexts/WastoContext";
15
+
16
+ export type RecyclingInfoProps = {};
17
+
18
+ export function RecyclingInfo({ }: RecyclingInfoProps): ReactNode {
19
+ const { recyclingInfos } = useRecyclingInfo();
20
+ const { theme } = useTheme();
21
+ const { apiUrl } = useWasto();
22
+ const t = resolveTheme(theme);
23
+ const themedStyles = createThemedStyles(theme);
24
+ const [selectedWasteTypeId, setSelectedWasteTypeId] = useState<string | null>(null);
25
+
26
+ const handleOnWasteTypeSelected = (wasteTypeId: string) => {
27
+ return () => {
28
+ setSelectedWasteTypeId(wasteTypeId);
29
+ }
30
+ }
31
+
32
+ return <>
33
+ <View style={styles.container}>
34
+ {recyclingInfos.map((recyclingInfo) => {
35
+ const color = recyclingInfo.color ?? theme.colors.primary;
36
+ const illustration =
37
+ resolveApiAssetUrl(apiUrl, recyclingInfo.imageUrl) ||
38
+ (recyclingInfo.icon?.trim().startsWith("http") ? recyclingInfo.icon.trim() : null);
39
+ return (
40
+ <View
41
+ key={recyclingInfo.id}
42
+ style={[
43
+ styles.recyclingInfo,
44
+ cardStyle(theme),
45
+ ]}
46
+ >
47
+ <View
48
+ style={[
49
+ styles.header,
50
+ { backgroundColor: color },
51
+ ]}
52
+ >
53
+ {illustration ? (
54
+ <Image
55
+ source={illustration}
56
+ style={styles.headerImage}
57
+ contentFit="contain"
58
+ />
59
+ ) : null}
60
+ <Title
61
+ text={recyclingInfo.name}
62
+ level="3"
63
+ style={{ color: getContrastColor(color), flex: 1 }}
64
+ />
65
+ </View>
66
+ <View style={styles.body}>
67
+ {recyclingInfo.description && (
68
+ <Paragraph text={recyclingInfo.description} style={themedStyles.cardText} />
69
+ )}
70
+ <View
71
+ style={[
72
+ styles.details,
73
+ {
74
+ backgroundColor: recyclingInfo.colorLight ?? theme.colors.primarySoft,
75
+ borderRadius: t.radii.md,
76
+ },
77
+ ]}
78
+ >
79
+ {recyclingInfo.details?.map((detail) => {
80
+ const detailImageUrl = resolveApiAssetUrl(
81
+ apiUrl,
82
+ detail.imageUrl,
83
+ );
84
+ return (
85
+ <View
86
+ key={detail.id}
87
+ style={[
88
+ styles.detail,
89
+ {
90
+ backgroundColor: "#ffffff55",
91
+ borderRadius: t.radii.sm,
92
+ },
93
+ ]}
94
+ >
95
+ {detailImageUrl ? (
96
+ <Image
97
+ source={detailImageUrl}
98
+ style={styles.detailImage}
99
+ contentFit="contain"
100
+ />
101
+ ) : null}
102
+ <View style={styles.detailText}>
103
+ <Text
104
+ style={[
105
+ styles.detailMaterial,
106
+ { color: getContrastColor(recyclingInfo.colorLight ?? "#FFFFFF") }
107
+ ]}
108
+ >
109
+ {detail.material}
110
+ </Text>
111
+ <Text
112
+ style={{ color: getContrastColor(recyclingInfo.colorLight ?? "#FFFFFF") }}
113
+ >
114
+ {detail.description}
115
+ </Text>
116
+ </View>
117
+ </View>
118
+ );
119
+ })}
120
+ </View>
121
+ <Button
122
+ style={{ backgroundColor: color }}
123
+ textStyle={{ color: getContrastColor(color) }}
124
+ onPress={handleOnWasteTypeSelected(recyclingInfo.wasteTypeId)}
125
+ >
126
+ Voir les details
127
+ </Button>
128
+ </View>
129
+ </View>
130
+ );
131
+ })}
132
+ </View>
133
+ <ConsignesModal
134
+ visible={!!selectedWasteTypeId}
135
+ wasteTypeId={selectedWasteTypeId!}
136
+ onClose={() => setSelectedWasteTypeId(null)}
137
+ />
138
+ </>
139
+ }
140
+
141
+ const createThemedStyles = (theme: Theme) => StyleSheet.create({
142
+ cardText: {
143
+ color: theme.colors.onSurface,
144
+ },
145
+ });
146
+
147
+ const styles = StyleSheet.create({
148
+ container: {
149
+ gap: 16,
150
+ },
151
+ recyclingInfo: {
152
+ overflow: "hidden",
153
+ },
154
+ header: {
155
+ padding: 16,
156
+ flexDirection: "row",
157
+ alignItems: "center",
158
+ gap: 12,
159
+ },
160
+ headerImage: {
161
+ width: 56,
162
+ height: 56,
163
+ },
164
+ body: {
165
+ padding: 16,
166
+ gap: 16,
167
+ },
168
+ details: {
169
+ padding: 12,
170
+ gap: 10,
171
+ },
172
+ detail: {
173
+ flexDirection: "row",
174
+ alignItems: "center",
175
+ gap: 12,
176
+ padding: 12,
177
+ },
178
+ detailImage: {
179
+ width: 48,
180
+ height: 48,
181
+ },
182
+ detailText: {
183
+ flex: 1,
184
+ gap: 4,
185
+ },
186
+ detailMaterial: {
187
+ fontWeight: "700"
188
+ },
189
+ });
@@ -0,0 +1,17 @@
1
+ import { useContext, type PropsWithChildren } from "react";
2
+ import {
3
+ SafeAreaInsetsContext,
4
+ SafeAreaProvider,
5
+ } from "react-native-safe-area-context";
6
+
7
+ /**
8
+ * Reuses a parent Expo Router / React Navigation `SafeAreaProvider`.
9
+ * Mounts one only when the host has none (standalone / no navigation).
10
+ */
11
+ export function OptionalSafeAreaProvider({ children }: PropsWithChildren) {
12
+ const parentInsets = useContext(SafeAreaInsetsContext);
13
+ if (parentInsets != null) {
14
+ return children;
15
+ }
16
+ return <SafeAreaProvider>{children}</SafeAreaProvider>;
17
+ }
@@ -0,0 +1,320 @@
1
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
2
+ import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
3
+ import { ChevronRight, Search as SearchIcon } from "lucide-react-native";
4
+ import { useTheme } from "../../contexts/ThemeContext";
5
+ import { Theme } from "../../types/Theme";
6
+ import { useRecyclingInfo } from "../../hooks/useRecyclingInfo";
7
+ import { useBlogPosts } from "../../hooks/useBlogPosts";
8
+ import { useLocations, LOCATION_CATEGORY_META } from "../../hooks/useLocations";
9
+ import { ConsignesModal } from "../consignes-modal/ConsignesModal";
10
+ import { ArticleReaderModal } from "../article/ArticleReaderModal";
11
+ import type { BlogPost } from "../../helpers/blogPost";
12
+ import { groupedSectionStyle, resolveTheme } from "../../helpers/themeStyles";
13
+
14
+ export type SearchType = "all" | "waste-type" | "collection-points" | "articles";
15
+
16
+ export type SearchProps = {
17
+ searchType: SearchType;
18
+ placeholder?: string;
19
+ };
20
+
21
+ function matchesQuery(haystack: string | undefined, query: string): boolean {
22
+ if (!haystack) return false;
23
+ return haystack.toLowerCase().includes(query);
24
+ }
25
+
26
+ type ResultRowProps = {
27
+ title: string;
28
+ meta?: string;
29
+ accent: string;
30
+ onPress?: () => void;
31
+ showDivider: boolean;
32
+ dividerInset: number;
33
+ };
34
+
35
+ function ResultRow({
36
+ title,
37
+ meta,
38
+ accent,
39
+ onPress,
40
+ showDivider,
41
+ dividerInset,
42
+ }: ResultRowProps) {
43
+ const { theme } = useTheme();
44
+ const t = resolveTheme(theme);
45
+ const content = (
46
+ <>
47
+ <View style={styles.rowInner}>
48
+ <View style={[styles.dot, { backgroundColor: accent }]} />
49
+ <View style={styles.rowText}>
50
+ <Text style={[styles.rowTitle, { color: theme.colors.onSurface }]}>
51
+ {title}
52
+ </Text>
53
+ {meta ? (
54
+ <Text style={[styles.rowMeta, { color: t.colors.muted }]} numberOfLines={2}>
55
+ {meta}
56
+ </Text>
57
+ ) : null}
58
+ </View>
59
+ {onPress ? (
60
+ <ChevronRight size={18} color={t.colors.muted} />
61
+ ) : null}
62
+ </View>
63
+ {showDivider ? (
64
+ <View
65
+ style={[
66
+ styles.rowDivider,
67
+ {
68
+ backgroundColor: theme.colors.border,
69
+ marginLeft: dividerInset,
70
+ },
71
+ ]}
72
+ />
73
+ ) : null}
74
+ </>
75
+ );
76
+
77
+ if (onPress) {
78
+ return (
79
+ <Pressable onPress={onPress} style={({ pressed }) => pressed && styles.pressed}>
80
+ {content}
81
+ </Pressable>
82
+ );
83
+ }
84
+ return <View>{content}</View>;
85
+ }
86
+
87
+ function ResultSection({
88
+ label,
89
+ children,
90
+ }: {
91
+ label?: string;
92
+ children: ReactNode;
93
+ }) {
94
+ const { theme } = useTheme();
95
+ const t = resolveTheme(theme);
96
+ return (
97
+ <View style={styles.section}>
98
+ {label ? (
99
+ <Text style={[styles.sectionLabel, { color: t.colors.muted }]}>{label}</Text>
100
+ ) : null}
101
+ <View style={groupedSectionStyle(theme)}>{children}</View>
102
+ </View>
103
+ );
104
+ }
105
+
106
+ export function Search({
107
+ searchType,
108
+ placeholder,
109
+ }: SearchProps): ReactNode {
110
+ const { theme } = useTheme();
111
+ const t = resolveTheme(theme);
112
+ const themedStyles = createThemedStyles(theme);
113
+ const { recyclingInfos } = useRecyclingInfo();
114
+ const { posts } = useBlogPosts();
115
+ const { locations } = useLocations();
116
+
117
+ const [draft, setDraft] = useState("");
118
+ const [query, setQuery] = useState("");
119
+ const [wasteTypeId, setWasteTypeId] = useState<string | null>(null);
120
+ const [article, setArticle] = useState<BlogPost | null>(null);
121
+
122
+ useEffect(() => {
123
+ const handle = setTimeout(() => setQuery(draft.trim().toLowerCase()), 250);
124
+ return () => clearTimeout(handle);
125
+ }, [draft]);
126
+
127
+ const wasteResults = useMemo(() => {
128
+ if (!query || (searchType !== "all" && searchType !== "waste-type")) return [];
129
+ return recyclingInfos.filter(
130
+ (info) => matchesQuery(info.name, query) || matchesQuery(info.description, query)
131
+ );
132
+ }, [query, recyclingInfos, searchType]);
133
+
134
+ const locationResults = useMemo(() => {
135
+ if (!query || (searchType !== "all" && searchType !== "collection-points")) return [];
136
+ return locations.filter(
137
+ (loc) => matchesQuery(loc.name, query) || matchesQuery(loc.address, query)
138
+ );
139
+ }, [locations, query, searchType]);
140
+
141
+ const articleResults = useMemo(() => {
142
+ if (!query || (searchType !== "all" && searchType !== "articles")) return [];
143
+ return posts.filter(
144
+ (post) => matchesQuery(post.title, query) || matchesQuery(post.content, query)
145
+ );
146
+ }, [posts, query, searchType]);
147
+
148
+ const hasQuery = query.length > 0;
149
+ const empty =
150
+ hasQuery &&
151
+ wasteResults.length === 0 &&
152
+ locationResults.length === 0 &&
153
+ articleResults.length === 0;
154
+
155
+ const dividerInset = 14 + 10 + 12; // padding + dot + gap
156
+
157
+ return (
158
+ <View style={styles.wrap}>
159
+ <View
160
+ style={[
161
+ styles.inputRow,
162
+ {
163
+ backgroundColor: theme.colors.surface,
164
+ borderRadius: t.radii.md,
165
+ borderWidth: 1,
166
+ borderColor: theme.colors.border,
167
+ },
168
+ ]}
169
+ >
170
+ <SearchIcon size={18} color={t.colors.muted} />
171
+ <TextInput
172
+ value={draft}
173
+ onChangeText={setDraft}
174
+ placeholder={placeholder || "Rechercher…"}
175
+ placeholderTextColor={t.colors.muted}
176
+ style={[styles.input, themedStyles.input]}
177
+ autoCorrect={false}
178
+ autoCapitalize="none"
179
+ />
180
+ </View>
181
+
182
+ {empty ? (
183
+ <Text style={[styles.empty, { color: t.colors.muted }]}>Aucun résultat</Text>
184
+ ) : null}
185
+
186
+ {wasteResults.length > 0 ? (
187
+ <ResultSection label={searchType === "all" ? "Consignes" : undefined}>
188
+ {wasteResults.map((info, index) => (
189
+ <ResultRow
190
+ key={info.id}
191
+ title={info.name}
192
+ meta={info.description}
193
+ accent={info.color ?? theme.colors.primary}
194
+ onPress={() => setWasteTypeId(info.wasteTypeId)}
195
+ showDivider={index < wasteResults.length - 1}
196
+ dividerInset={dividerInset}
197
+ />
198
+ ))}
199
+ </ResultSection>
200
+ ) : null}
201
+
202
+ {locationResults.length > 0 ? (
203
+ <ResultSection label={searchType === "all" ? "Points de collecte" : undefined}>
204
+ {locationResults.map((loc, index) => (
205
+ <ResultRow
206
+ key={loc.id}
207
+ title={loc.name}
208
+ meta={[
209
+ LOCATION_CATEGORY_META[loc.category].label,
210
+ loc.address,
211
+ ]
212
+ .filter(Boolean)
213
+ .join(" · ")}
214
+ accent={LOCATION_CATEGORY_META[loc.category].color}
215
+ showDivider={index < locationResults.length - 1}
216
+ dividerInset={dividerInset}
217
+ />
218
+ ))}
219
+ </ResultSection>
220
+ ) : null}
221
+
222
+ {articleResults.length > 0 ? (
223
+ <ResultSection label={searchType === "all" ? "Articles" : undefined}>
224
+ {articleResults.map((post, index) => (
225
+ <ResultRow
226
+ key={post.id}
227
+ title={post.title}
228
+ accent={theme.colors.primary}
229
+ onPress={() => setArticle(post)}
230
+ showDivider={index < articleResults.length - 1}
231
+ dividerInset={dividerInset}
232
+ />
233
+ ))}
234
+ </ResultSection>
235
+ ) : null}
236
+
237
+ <ConsignesModal
238
+ visible={!!wasteTypeId}
239
+ wasteTypeId={wasteTypeId ?? ""}
240
+ onClose={() => setWasteTypeId(null)}
241
+ />
242
+ <ArticleReaderModal
243
+ visible={!!article}
244
+ post={article}
245
+ onClose={() => setArticle(null)}
246
+ />
247
+ </View>
248
+ );
249
+ }
250
+
251
+ const createThemedStyles = (theme: Theme) =>
252
+ StyleSheet.create({
253
+ input: {
254
+ color: theme.colors.onSurface,
255
+ },
256
+ });
257
+
258
+ const styles = StyleSheet.create({
259
+ wrap: {
260
+ gap: 16,
261
+ },
262
+ inputRow: {
263
+ flexDirection: "row",
264
+ alignItems: "center",
265
+ gap: 10,
266
+ paddingHorizontal: 12,
267
+ paddingVertical: 10,
268
+ overflow: "hidden",
269
+ },
270
+ input: {
271
+ flex: 1,
272
+ fontSize: 16,
273
+ paddingVertical: 4,
274
+ },
275
+ empty: {
276
+ fontSize: 14,
277
+ textAlign: "center",
278
+ paddingVertical: 8,
279
+ },
280
+ section: {
281
+ gap: 8,
282
+ },
283
+ sectionLabel: {
284
+ fontSize: 12,
285
+ fontWeight: "600",
286
+ textTransform: "uppercase",
287
+ letterSpacing: 0.6,
288
+ paddingHorizontal: 4,
289
+ },
290
+ rowInner: {
291
+ flexDirection: "row",
292
+ alignItems: "center",
293
+ gap: 12,
294
+ paddingHorizontal: 14,
295
+ paddingVertical: 12,
296
+ },
297
+ dot: {
298
+ width: 10,
299
+ height: 10,
300
+ borderRadius: 5,
301
+ },
302
+ rowText: {
303
+ flex: 1,
304
+ gap: 2,
305
+ },
306
+ rowTitle: {
307
+ fontSize: 15,
308
+ fontWeight: "600",
309
+ },
310
+ rowMeta: {
311
+ fontSize: 13,
312
+ lineHeight: 18,
313
+ },
314
+ rowDivider: {
315
+ height: StyleSheet.hairlineWidth,
316
+ },
317
+ pressed: {
318
+ opacity: 0.85,
319
+ },
320
+ });