@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.
- package/README.md +47 -0
- package/dist/index.d.mts +465 -0
- package/dist/index.d.ts +465 -0
- package/dist/index.js +10445 -0
- package/dist/index.mjs +10553 -0
- package/package.json +90 -0
- package/src/components/announcement-modal/AnnouncementModal.tsx +183 -0
- package/src/components/article/Article.tsx +22 -0
- package/src/components/article/ArticleCard.tsx +81 -0
- package/src/components/article/ArticleExcerpts.tsx +80 -0
- package/src/components/article/ArticleList.tsx +114 -0
- package/src/components/article/ArticleMarkdown.tsx +170 -0
- package/src/components/article/ArticleReaderModal.tsx +93 -0
- package/src/components/article/ArticleSingle.tsx +57 -0
- package/src/components/bottom-tab/BottomTab.tsx +392 -0
- package/src/components/button/Button.tsx +154 -0
- package/src/components/challenge/Badges.tsx +84 -0
- package/src/components/challenge/BadgesModal.tsx +166 -0
- package/src/components/challenge/Challenge.tsx +380 -0
- package/src/components/challenge/ChallengeModal.tsx +231 -0
- package/src/components/challenge/ChallengeVignette.tsx +249 -0
- package/src/components/challenge/ChallengesModal.tsx +243 -0
- package/src/components/challenge/QuizModal.tsx +381 -0
- package/src/components/challenge/ResultModal.tsx +144 -0
- package/src/components/chat/Chat.tsx +928 -0
- package/src/components/collection-calendar/CollectionCalendar.tsx +274 -0
- package/src/components/columns/Columns.tsx +34 -0
- package/src/components/consignes-modal/ConsignesModal.tsx +267 -0
- package/src/components/content-inset/ContentInset.tsx +33 -0
- package/src/components/header/Header.tsx +391 -0
- package/src/components/image/Image.tsx +16 -0
- package/src/components/image/ImageFrame.tsx +102 -0
- package/src/components/image/ImageMedia.tsx +31 -0
- package/src/components/image/ImageUrl.tsx +30 -0
- package/src/components/locations-map/LocationSheet.tsx +417 -0
- package/src/components/locations-map/LocationsMap.native.tsx +260 -0
- package/src/components/locations-map/LocationsMap.tsx +13 -0
- package/src/components/locations-map/LocationsMap.web.tsx +10 -0
- package/src/components/locations-map/LocationsMapShell.tsx +527 -0
- package/src/components/locations-map/LocationsMapView.tsx +108 -0
- package/src/components/locations-map/LocationsMapView.web.tsx +331 -0
- package/src/components/nav-button/NavButton.tsx +59 -0
- package/src/components/next-collection/NextCollection.tsx +621 -0
- package/src/components/notifications/NotificationsScreen.tsx +247 -0
- package/src/components/page/Page.tsx +64 -0
- package/src/components/parapraph/Paragraph.tsx +67 -0
- package/src/components/profile/ProfileScreen.tsx +236 -0
- package/src/components/recycling-info/RecyclingInfo.tsx +189 -0
- package/src/components/safe-area/OptionalSafeAreaProvider.tsx +17 -0
- package/src/components/search/Search.tsx +320 -0
- package/src/components/select-street-modal/SelectStreetModal.tsx +475 -0
- package/src/components/title/Title.tsx +100 -0
- package/src/components/vision/Vision.tsx +447 -0
- package/src/components/vision/VisionActionCard.tsx +117 -0
- package/src/components/vision/VisionCapture.tsx +30 -0
- package/src/components/vision/VisionTapOverlay.tsx +122 -0
- package/src/contexts/AnnouncementsContext.tsx +171 -0
- package/src/contexts/ChallengesContext.tsx +182 -0
- package/src/contexts/LocalContext.tsx +227 -0
- package/src/contexts/ThemeContext.tsx +37 -0
- package/src/contexts/WastoContext.tsx +127 -0
- package/src/helpers/blogPost.ts +72 -0
- package/src/helpers/generateQuizQuestions.ts +247 -0
- package/src/helpers/getContrastColor.ts +19 -0
- package/src/helpers/haptics.ts +20 -0
- package/src/helpers/hoursSchedule.ts +230 -0
- package/src/helpers/htmlContentToMarkdown.ts +114 -0
- package/src/helpers/layout.ts +54 -0
- package/src/helpers/loadLinearGradient.ts +22 -0
- package/src/helpers/maquetteTheme.ts +76 -0
- package/src/helpers/markdownUnderlinePlugin.ts +32 -0
- package/src/helpers/normalizeWasteTypeRecyclingInfo.ts +80 -0
- package/src/helpers/publicMediaUrl.ts +8 -0
- package/src/helpers/resolveApiAssetUrl.ts +21 -0
- package/src/helpers/systemRoutes.ts +7 -0
- package/src/helpers/themeStyles.ts +114 -0
- package/src/helpers/useSafeAreaPadding.ts +14 -0
- package/src/hooks/useBadges.ts +100 -0
- package/src/hooks/useBlogPosts.ts +105 -0
- package/src/hooks/useChallenges.ts +110 -0
- package/src/hooks/useCities.ts +56 -0
- package/src/hooks/useCityAnnouncements.ts +118 -0
- package/src/hooks/useCollectionCalendar.ts +421 -0
- package/src/hooks/useColumns.ts +20 -0
- package/src/hooks/useConsignes.ts +62 -0
- package/src/hooks/useLocations.ts +248 -0
- package/src/hooks/useNextCollection.ts +238 -0
- package/src/hooks/useRecyclingInfo.ts +90 -0
- package/src/hooks/useWastoClient.ts +16 -0
- package/src/index.ts +142 -0
- package/src/navigation/NavigationContext.tsx +56 -0
- package/src/sentry/initSentry.ts +120 -0
- package/src/types/Theme.ts +72 -0
- package/src/types/css-modules.d.ts +1 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
LayoutChangeEvent,
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
Text,
|
|
7
|
+
View,
|
|
8
|
+
} from "react-native";
|
|
9
|
+
import { Image } from "expo-image";
|
|
10
|
+
import { Button } from "../button/Button";
|
|
11
|
+
import { useTheme } from "../../contexts/ThemeContext";
|
|
12
|
+
import { resolveTheme } from "../../helpers/themeStyles";
|
|
13
|
+
import { SPACE } from "../../helpers/layout";
|
|
14
|
+
|
|
15
|
+
export type VisionTapOverlayProps = {
|
|
16
|
+
imageUri: string;
|
|
17
|
+
tap: { x: number; y: number } | null;
|
|
18
|
+
onTap: (tap: { x: number; y: number }) => void;
|
|
19
|
+
onAnalyzeFull: () => void;
|
|
20
|
+
onConfirmTap: () => void;
|
|
21
|
+
onRetake: () => void;
|
|
22
|
+
busy?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function VisionTapOverlay({
|
|
26
|
+
imageUri,
|
|
27
|
+
tap,
|
|
28
|
+
onTap,
|
|
29
|
+
onAnalyzeFull,
|
|
30
|
+
onConfirmTap,
|
|
31
|
+
onRetake,
|
|
32
|
+
busy,
|
|
33
|
+
}: VisionTapOverlayProps) {
|
|
34
|
+
const { theme } = useTheme();
|
|
35
|
+
const t = resolveTheme(theme);
|
|
36
|
+
const [size, setSize] = useState({ w: 1, h: 1 });
|
|
37
|
+
|
|
38
|
+
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
|
39
|
+
const { width, height } = e.nativeEvent.layout;
|
|
40
|
+
setSize({ w: Math.max(width, 1), h: Math.max(height, 1) });
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<View style={styles.root}>
|
|
45
|
+
<Text style={[styles.hint, { color: t.colors.muted }]}>
|
|
46
|
+
Touchez le déchet sur la photo, ou analysez toute l’image.
|
|
47
|
+
</Text>
|
|
48
|
+
<Pressable
|
|
49
|
+
onLayout={onLayout}
|
|
50
|
+
onPress={(ev) => {
|
|
51
|
+
const { locationX, locationY } = ev.nativeEvent;
|
|
52
|
+
onTap({
|
|
53
|
+
x: Math.min(1, Math.max(0, locationX / size.w)),
|
|
54
|
+
y: Math.min(1, Math.max(0, locationY / size.h)),
|
|
55
|
+
});
|
|
56
|
+
}}
|
|
57
|
+
style={[
|
|
58
|
+
styles.imageWrap,
|
|
59
|
+
{ borderRadius: t.radii.md, backgroundColor: t.colors.onSurface },
|
|
60
|
+
]}
|
|
61
|
+
>
|
|
62
|
+
<Image source={{ uri: imageUri }} style={styles.image} contentFit="contain" />
|
|
63
|
+
{tap ? (
|
|
64
|
+
<View
|
|
65
|
+
pointerEvents="none"
|
|
66
|
+
style={[
|
|
67
|
+
styles.halo,
|
|
68
|
+
{
|
|
69
|
+
left: tap.x * size.w - 18,
|
|
70
|
+
top: tap.y * size.h - 18,
|
|
71
|
+
borderColor: t.colors.primary,
|
|
72
|
+
},
|
|
73
|
+
]}
|
|
74
|
+
/>
|
|
75
|
+
) : null}
|
|
76
|
+
</Pressable>
|
|
77
|
+
|
|
78
|
+
<View style={styles.row}>
|
|
79
|
+
<Button variant="outline" onPress={onRetake} disabled={busy} style={styles.flexBtn}>
|
|
80
|
+
Reprendre
|
|
81
|
+
</Button>
|
|
82
|
+
<Button
|
|
83
|
+
variant="secondary"
|
|
84
|
+
onPress={onAnalyzeFull}
|
|
85
|
+
disabled={busy}
|
|
86
|
+
style={styles.flexBtn}
|
|
87
|
+
>
|
|
88
|
+
Toute l’image
|
|
89
|
+
</Button>
|
|
90
|
+
<Button
|
|
91
|
+
onPress={onConfirmTap}
|
|
92
|
+
disabled={!tap || busy}
|
|
93
|
+
loading={busy}
|
|
94
|
+
style={styles.flexBtn}
|
|
95
|
+
>
|
|
96
|
+
Analyser
|
|
97
|
+
</Button>
|
|
98
|
+
</View>
|
|
99
|
+
</View>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const styles = StyleSheet.create({
|
|
104
|
+
root: { gap: SPACE.stack, flex: 1 },
|
|
105
|
+
hint: { fontSize: 14, lineHeight: 20 },
|
|
106
|
+
imageWrap: {
|
|
107
|
+
flex: 1,
|
|
108
|
+
minHeight: 280,
|
|
109
|
+
overflow: "hidden",
|
|
110
|
+
},
|
|
111
|
+
image: { width: "100%", height: "100%" },
|
|
112
|
+
halo: {
|
|
113
|
+
position: "absolute",
|
|
114
|
+
width: 36,
|
|
115
|
+
height: 36,
|
|
116
|
+
borderRadius: 18,
|
|
117
|
+
borderWidth: 3,
|
|
118
|
+
backgroundColor: "rgba(255,255,255,0.25)",
|
|
119
|
+
},
|
|
120
|
+
row: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
|
121
|
+
flexBtn: { flexGrow: 1, minWidth: 100 },
|
|
122
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
PropsWithChildren,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { useLocal } from "./LocalContext";
|
|
13
|
+
import { useWastoClient } from "../hooks/useWastoClient";
|
|
14
|
+
|
|
15
|
+
export type PublicAnnouncement = {
|
|
16
|
+
id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
body: string;
|
|
19
|
+
imageUrl?: string | null;
|
|
20
|
+
sentAt?: string | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type AnnouncementsContextType = {
|
|
24
|
+
announcement: PublicAnnouncement | null;
|
|
25
|
+
hasUnread: boolean;
|
|
26
|
+
dismiss: () => Promise<void>;
|
|
27
|
+
/** Mark one announcement as read (inbox) and refresh unread badge. */
|
|
28
|
+
markAsRead: (id: string) => Promise<void>;
|
|
29
|
+
/** Open the shared AnnouncementModal for a specific item (inbox). */
|
|
30
|
+
openAnnouncement: (item: PublicAnnouncement) => void;
|
|
31
|
+
openFromBell: () => Promise<void>;
|
|
32
|
+
forceVisible: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const DISMISSED_KEY = "dismissed_announcements";
|
|
36
|
+
|
|
37
|
+
const AnnouncementsContext = createContext<AnnouncementsContextType | undefined>(undefined);
|
|
38
|
+
|
|
39
|
+
async function getDismissedIds(): Promise<Set<string>> {
|
|
40
|
+
try {
|
|
41
|
+
const raw = await AsyncStorage.getItem(DISMISSED_KEY);
|
|
42
|
+
if (!raw) return new Set();
|
|
43
|
+
const parsed = JSON.parse(raw) as string[];
|
|
44
|
+
return new Set(Array.isArray(parsed) ? parsed : []);
|
|
45
|
+
} catch {
|
|
46
|
+
return new Set();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function markDismissed(id: string): Promise<void> {
|
|
51
|
+
const set = await getDismissedIds();
|
|
52
|
+
set.add(id);
|
|
53
|
+
await AsyncStorage.setItem(DISMISSED_KEY, JSON.stringify([...set]));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveImageUrl(apiUrl: string, url?: string | null): string | null {
|
|
57
|
+
if (!url) return null;
|
|
58
|
+
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
|
59
|
+
const base = apiUrl.replace(/\/$/, "");
|
|
60
|
+
const path = url.startsWith("/") ? url : `/${url}`;
|
|
61
|
+
return `${base}${path}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type AnnouncementsProviderProps = PropsWithChildren & {
|
|
65
|
+
apiUrl: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function AnnouncementsProvider({ children, apiUrl }: AnnouncementsProviderProps): ReactNode {
|
|
69
|
+
const { selectedCityId, hasPlaceSelected } = useLocal();
|
|
70
|
+
const client = useWastoClient();
|
|
71
|
+
const [announcement, setAnnouncement] = useState<PublicAnnouncement | null>(null);
|
|
72
|
+
const [hasUnread, setHasUnread] = useState(false);
|
|
73
|
+
const [forceVisible, setForceVisible] = useState(false);
|
|
74
|
+
const [queue, setQueue] = useState<PublicAnnouncement[]>([]);
|
|
75
|
+
|
|
76
|
+
const load = useCallback(async (): Promise<PublicAnnouncement[]> => {
|
|
77
|
+
if (!selectedCityId || !hasPlaceSelected) {
|
|
78
|
+
setAnnouncement(null);
|
|
79
|
+
setHasUnread(false);
|
|
80
|
+
setQueue([]);
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const list = await client.public.announcements.listForCity(selectedCityId);
|
|
85
|
+
const dismissed = await getDismissedIds();
|
|
86
|
+
const pending = (list ?? [])
|
|
87
|
+
.filter((a) => a?.id && !dismissed.has(a.id))
|
|
88
|
+
.map((a) => ({
|
|
89
|
+
...a,
|
|
90
|
+
imageUrl: resolveImageUrl(apiUrl, a.imageUrl),
|
|
91
|
+
}));
|
|
92
|
+
setQueue(pending);
|
|
93
|
+
setHasUnread(pending.length > 0);
|
|
94
|
+
setAnnouncement(pending[0] ?? null);
|
|
95
|
+
return pending;
|
|
96
|
+
} catch {
|
|
97
|
+
setAnnouncement(null);
|
|
98
|
+
setHasUnread(false);
|
|
99
|
+
setQueue([]);
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}, [apiUrl, client.public.announcements, hasPlaceSelected, selectedCityId]);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
void load().then((pending) => {
|
|
106
|
+
if (pending.length > 0) setForceVisible(true);
|
|
107
|
+
});
|
|
108
|
+
}, [load]);
|
|
109
|
+
|
|
110
|
+
const markAsRead = useCallback(async (id: string) => {
|
|
111
|
+
await markDismissed(id);
|
|
112
|
+
setQueue((prev) => {
|
|
113
|
+
const next = prev.filter((a) => a.id !== id);
|
|
114
|
+
setHasUnread(next.length > 0);
|
|
115
|
+
return next;
|
|
116
|
+
});
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
const dismiss = useCallback(async () => {
|
|
120
|
+
if (!announcement) {
|
|
121
|
+
setForceVisible(false);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const id = announcement.id;
|
|
125
|
+
await markDismissed(id);
|
|
126
|
+
const nextQueue = queue.filter((a) => a.id !== id);
|
|
127
|
+
setQueue(nextQueue);
|
|
128
|
+
setHasUnread(nextQueue.length > 0);
|
|
129
|
+
setAnnouncement(nextQueue[0] ?? null);
|
|
130
|
+
setForceVisible(nextQueue.length > 0);
|
|
131
|
+
}, [announcement, queue]);
|
|
132
|
+
|
|
133
|
+
const openAnnouncement = useCallback((item: PublicAnnouncement) => {
|
|
134
|
+
setAnnouncement(item);
|
|
135
|
+
setForceVisible(true);
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
const openFromBell = useCallback(async () => {
|
|
139
|
+
const pending = queue.length > 0 ? queue : await load();
|
|
140
|
+
if (pending.length === 0) return;
|
|
141
|
+
setAnnouncement(pending[0] ?? null);
|
|
142
|
+
setForceVisible(true);
|
|
143
|
+
}, [load, queue]);
|
|
144
|
+
|
|
145
|
+
const value = useMemo(
|
|
146
|
+
() => ({
|
|
147
|
+
announcement,
|
|
148
|
+
hasUnread,
|
|
149
|
+
dismiss,
|
|
150
|
+
markAsRead,
|
|
151
|
+
openAnnouncement,
|
|
152
|
+
openFromBell,
|
|
153
|
+
forceVisible,
|
|
154
|
+
}),
|
|
155
|
+
[announcement, dismiss, forceVisible, hasUnread, markAsRead, openAnnouncement, openFromBell],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<AnnouncementsContext value={value}>
|
|
160
|
+
{children}
|
|
161
|
+
</AnnouncementsContext>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function useAnnouncements() {
|
|
166
|
+
const ctx = useContext(AnnouncementsContext);
|
|
167
|
+
if (!ctx) {
|
|
168
|
+
throw new Error("useAnnouncements must be used within AnnouncementsProvider.");
|
|
169
|
+
}
|
|
170
|
+
return ctx;
|
|
171
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
PropsWithChildren,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { useLocal } from "./LocalContext";
|
|
12
|
+
import { useWastoClient } from "../hooks/useWastoClient";
|
|
13
|
+
import type {
|
|
14
|
+
AcquiredBadge,
|
|
15
|
+
Challenge,
|
|
16
|
+
ChallengeWithProgress,
|
|
17
|
+
UseChallengesResult,
|
|
18
|
+
UserProgress,
|
|
19
|
+
} from "../hooks/useChallenges";
|
|
20
|
+
|
|
21
|
+
const ChallengesContext = createContext<UseChallengesResult | undefined>(undefined);
|
|
22
|
+
|
|
23
|
+
export type ChallengesProviderProps = PropsWithChildren & {
|
|
24
|
+
pageSize?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Shared challenges + progress store so Challenge, ChallengeVignette and QuizModal
|
|
29
|
+
* all see the same data after a quiz completion refetch.
|
|
30
|
+
*/
|
|
31
|
+
export function ChallengesProvider({
|
|
32
|
+
children,
|
|
33
|
+
pageSize = 100,
|
|
34
|
+
}: ChallengesProviderProps): ReactNode {
|
|
35
|
+
const { selectedCityId, deviceId } = useLocal();
|
|
36
|
+
const client = useWastoClient();
|
|
37
|
+
|
|
38
|
+
const [rawChallenges, setRawChallenges] = useState<Challenge[]>([]);
|
|
39
|
+
const [userProgress, setUserProgress] = useState<UserProgress[]>([]);
|
|
40
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
41
|
+
const [error, setError] = useState<string | null>(null);
|
|
42
|
+
|
|
43
|
+
const fetchChallenges = useCallback(async () => {
|
|
44
|
+
if (!selectedCityId) {
|
|
45
|
+
setRawChallenges([]);
|
|
46
|
+
setUserProgress([]);
|
|
47
|
+
setIsLoading(false);
|
|
48
|
+
setError(null);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
setIsLoading(true);
|
|
54
|
+
setError(null);
|
|
55
|
+
const [challengesRes, progressRes] = await Promise.all([
|
|
56
|
+
client.public.cities.getChallenges(selectedCityId, { pageSize }),
|
|
57
|
+
deviceId ? client.public.challenges.getProgress(deviceId) : Promise.resolve(null),
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const data = (challengesRes as { data?: unknown })?.data;
|
|
61
|
+
setRawChallenges(Array.isArray(data) ? (data as Challenge[]) : []);
|
|
62
|
+
|
|
63
|
+
if (deviceId) {
|
|
64
|
+
setUserProgress(Array.isArray(progressRes) ? (progressRes as UserProgress[]) : []);
|
|
65
|
+
} else {
|
|
66
|
+
setUserProgress([]);
|
|
67
|
+
}
|
|
68
|
+
} catch (err) {
|
|
69
|
+
const message =
|
|
70
|
+
err instanceof Error
|
|
71
|
+
? err.message
|
|
72
|
+
: typeof err === "string"
|
|
73
|
+
? err
|
|
74
|
+
: "Impossible de charger les challenges";
|
|
75
|
+
setError(message);
|
|
76
|
+
setRawChallenges([]);
|
|
77
|
+
setUserProgress([]);
|
|
78
|
+
} finally {
|
|
79
|
+
setIsLoading(false);
|
|
80
|
+
}
|
|
81
|
+
}, [client.public.challenges, client.public.cities, deviceId, pageSize, selectedCityId]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
void fetchChallenges();
|
|
85
|
+
}, [fetchChallenges]);
|
|
86
|
+
|
|
87
|
+
const acquiredBadges = useMemo<AcquiredBadge[]>(() => {
|
|
88
|
+
const list: AcquiredBadge[] = [];
|
|
89
|
+
for (const p of userProgress) {
|
|
90
|
+
const badge = p.badge ?? undefined;
|
|
91
|
+
const badgeId = (badge as { id?: string } | undefined)?.id;
|
|
92
|
+
if (typeof badgeId !== "string" || !badgeId.trim()) continue;
|
|
93
|
+
list.push({
|
|
94
|
+
badgeId,
|
|
95
|
+
challengeId: p.challengeId,
|
|
96
|
+
earnedAt: p.completedAt,
|
|
97
|
+
badge,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return list;
|
|
101
|
+
}, [userProgress]);
|
|
102
|
+
|
|
103
|
+
const progressByChallengeId = useMemo<Record<string, UserProgress>>(() => {
|
|
104
|
+
const map: Record<string, UserProgress> = {};
|
|
105
|
+
for (const p of userProgress) {
|
|
106
|
+
if (p?.challengeId) {
|
|
107
|
+
map[p.challengeId] = p;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return map;
|
|
111
|
+
}, [userProgress]);
|
|
112
|
+
|
|
113
|
+
const getProgressByChallengeId = useCallback(
|
|
114
|
+
(challengeId: string) => progressByChallengeId[challengeId],
|
|
115
|
+
[progressByChallengeId],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const challenges = useMemo<ChallengeWithProgress[]>(() => {
|
|
119
|
+
return rawChallenges.map((c) => {
|
|
120
|
+
const progression = progressByChallengeId[c.id];
|
|
121
|
+
const seriesProgress = Array.isArray(progression?.series)
|
|
122
|
+
? progression.series
|
|
123
|
+
: [];
|
|
124
|
+
const seriesById = new Map(
|
|
125
|
+
seriesProgress.map((s) => [s.seriesId, s]),
|
|
126
|
+
);
|
|
127
|
+
const series = c.series?.map((s) => ({
|
|
128
|
+
...s,
|
|
129
|
+
progression: seriesById.get(s.id),
|
|
130
|
+
}));
|
|
131
|
+
return {
|
|
132
|
+
...c,
|
|
133
|
+
progression,
|
|
134
|
+
series,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
}, [progressByChallengeId, rawChallenges]);
|
|
138
|
+
|
|
139
|
+
const getChallengeById = useCallback(
|
|
140
|
+
(id: string) => challenges.find((c) => c.id === id),
|
|
141
|
+
[challenges],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const value = useMemo<UseChallengesResult>(
|
|
145
|
+
() => ({
|
|
146
|
+
cityId: selectedCityId,
|
|
147
|
+
challenges,
|
|
148
|
+
userProgress,
|
|
149
|
+
progressByChallengeId,
|
|
150
|
+
getProgressByChallengeId,
|
|
151
|
+
acquiredBadges,
|
|
152
|
+
isLoading,
|
|
153
|
+
error,
|
|
154
|
+
refetch: fetchChallenges,
|
|
155
|
+
getChallengeById,
|
|
156
|
+
}),
|
|
157
|
+
[
|
|
158
|
+
acquiredBadges,
|
|
159
|
+
challenges,
|
|
160
|
+
error,
|
|
161
|
+
fetchChallenges,
|
|
162
|
+
getChallengeById,
|
|
163
|
+
getProgressByChallengeId,
|
|
164
|
+
isLoading,
|
|
165
|
+
progressByChallengeId,
|
|
166
|
+
selectedCityId,
|
|
167
|
+
userProgress,
|
|
168
|
+
],
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<ChallengesContext.Provider value={value}>{children}</ChallengesContext.Provider>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function useChallengesContext(): UseChallengesResult {
|
|
177
|
+
const context = useContext(ChallengesContext);
|
|
178
|
+
if (!context) {
|
|
179
|
+
throw new Error("useChallenges must be used within a ChallengesProvider.");
|
|
180
|
+
}
|
|
181
|
+
return context;
|
|
182
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
PropsWithChildren,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { Platform } from "react-native";
|
|
13
|
+
import { useWastoClient } from "../hooks/useWastoClient";
|
|
14
|
+
|
|
15
|
+
const STORAGE_CITY_KEY = "selected_city_id";
|
|
16
|
+
const STORAGE_PLACE_KEY = "selected_place_id";
|
|
17
|
+
const STORAGE_PLACE_META_KEY = "selected_place_meta";
|
|
18
|
+
const STORAGE_DEVICE_KEY = "wasto_device_id";
|
|
19
|
+
const STORAGE_DISMISSED_ANNOUNCEMENTS_KEY = "dismissed_announcements";
|
|
20
|
+
|
|
21
|
+
/** Isolate Studio preview storage from the real PWA device profile. */
|
|
22
|
+
const PREVIEW_PREFIX = "wasto_preview_";
|
|
23
|
+
|
|
24
|
+
export type PlaceSelectionMeta = {
|
|
25
|
+
cityName?: string | null;
|
|
26
|
+
streetName?: string | null;
|
|
27
|
+
sectorName?: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type LocalContextType = {
|
|
31
|
+
isLoading: boolean;
|
|
32
|
+
/** True when both city and collection place (sector) are selected. */
|
|
33
|
+
hasPlaceSelected: boolean;
|
|
34
|
+
/** @deprecated Prefer `hasPlaceSelected`. Kept for transitional callers. */
|
|
35
|
+
hasCitySelected: boolean;
|
|
36
|
+
selectedCityId: string | null;
|
|
37
|
+
selectedPlaceId: string | null;
|
|
38
|
+
placeMeta: PlaceSelectionMeta | null;
|
|
39
|
+
deviceId: string | null;
|
|
40
|
+
registerDevice: (
|
|
41
|
+
cityId: string,
|
|
42
|
+
placeId: string,
|
|
43
|
+
meta?: PlaceSelectionMeta,
|
|
44
|
+
) => Promise<string | null>;
|
|
45
|
+
/** Clears city/place selection (keeps deviceId) — reopen sector picker. */
|
|
46
|
+
clearPlaceSelection: () => Promise<void>;
|
|
47
|
+
/** Wipes local profile data including deviceId and dismissed announcements. */
|
|
48
|
+
clearLocalData: () => Promise<void>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const LocalContext = createContext<LocalContextType | undefined>(undefined);
|
|
52
|
+
|
|
53
|
+
export type LocalContextProps = PropsWithChildren & {
|
|
54
|
+
previewMode?: boolean;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function storageKey(base: string, previewMode: boolean) {
|
|
58
|
+
return previewMode ? `${PREVIEW_PREFIX}${base}` : base;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function LocalProvider({
|
|
62
|
+
children,
|
|
63
|
+
previewMode = false,
|
|
64
|
+
}: LocalContextProps): ReactNode {
|
|
65
|
+
const keys = useMemo(
|
|
66
|
+
() => ({
|
|
67
|
+
city: storageKey(STORAGE_CITY_KEY, previewMode),
|
|
68
|
+
place: storageKey(STORAGE_PLACE_KEY, previewMode),
|
|
69
|
+
placeMeta: storageKey(STORAGE_PLACE_META_KEY, previewMode),
|
|
70
|
+
device: storageKey(STORAGE_DEVICE_KEY, previewMode),
|
|
71
|
+
dismissed: storageKey(STORAGE_DISMISSED_ANNOUNCEMENTS_KEY, previewMode),
|
|
72
|
+
}),
|
|
73
|
+
[previewMode],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
77
|
+
const [hasPlaceSelected, setHasPlaceSelected] = useState(false);
|
|
78
|
+
const [selectedCityId, setSelectedCityId] = useState<string | null>(null);
|
|
79
|
+
const [selectedPlaceId, setSelectedPlaceId] = useState<string | null>(null);
|
|
80
|
+
const [placeMeta, setPlaceMeta] = useState<PlaceSelectionMeta | null>(null);
|
|
81
|
+
const [deviceId, setDeviceId] = useState<string | null>(null);
|
|
82
|
+
|
|
83
|
+
const client = useWastoClient();
|
|
84
|
+
|
|
85
|
+
const registerDevice = useCallback(
|
|
86
|
+
async (cityId: string, placeId: string, meta?: PlaceSelectionMeta) => {
|
|
87
|
+
setIsLoading(true);
|
|
88
|
+
const nextMeta: PlaceSelectionMeta = {
|
|
89
|
+
cityName: meta?.cityName?.trim() || null,
|
|
90
|
+
streetName: meta?.streetName?.trim() || null,
|
|
91
|
+
sectorName: meta?.sectorName?.trim() || null,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
let resolvedDeviceId: string | null = null;
|
|
95
|
+
if (!previewMode) {
|
|
96
|
+
const newDeviceId = generateDeviceId();
|
|
97
|
+
await client.public.analytics.track({
|
|
98
|
+
id: newDeviceId,
|
|
99
|
+
cityId: cityId,
|
|
100
|
+
platform: Platform.OS,
|
|
101
|
+
osVersion: null,
|
|
102
|
+
appVersion: null,
|
|
103
|
+
deviceModel: null,
|
|
104
|
+
});
|
|
105
|
+
await AsyncStorage.setItem(keys.device, newDeviceId);
|
|
106
|
+
setDeviceId(newDeviceId);
|
|
107
|
+
resolvedDeviceId = newDeviceId;
|
|
108
|
+
} else {
|
|
109
|
+
// Studio preview: never create analytics devices / persist device id.
|
|
110
|
+
setDeviceId(null);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await AsyncStorage.setItem(keys.city, cityId);
|
|
114
|
+
await AsyncStorage.setItem(keys.place, placeId);
|
|
115
|
+
await AsyncStorage.setItem(keys.placeMeta, JSON.stringify(nextMeta));
|
|
116
|
+
|
|
117
|
+
setHasPlaceSelected(true);
|
|
118
|
+
setSelectedCityId(cityId);
|
|
119
|
+
setSelectedPlaceId(placeId);
|
|
120
|
+
setPlaceMeta(nextMeta);
|
|
121
|
+
setIsLoading(false);
|
|
122
|
+
return resolvedDeviceId;
|
|
123
|
+
},
|
|
124
|
+
[client.public.analytics, keys, previewMode],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const clearPlaceSelection = useCallback(async () => {
|
|
128
|
+
await AsyncStorage.multiRemove([keys.city, keys.place, keys.placeMeta]);
|
|
129
|
+
setHasPlaceSelected(false);
|
|
130
|
+
setSelectedCityId(null);
|
|
131
|
+
setSelectedPlaceId(null);
|
|
132
|
+
setPlaceMeta(null);
|
|
133
|
+
}, [keys]);
|
|
134
|
+
|
|
135
|
+
const clearLocalData = useCallback(async () => {
|
|
136
|
+
await AsyncStorage.multiRemove([
|
|
137
|
+
keys.city,
|
|
138
|
+
keys.place,
|
|
139
|
+
keys.placeMeta,
|
|
140
|
+
keys.device,
|
|
141
|
+
keys.dismissed,
|
|
142
|
+
]);
|
|
143
|
+
setHasPlaceSelected(false);
|
|
144
|
+
setSelectedCityId(null);
|
|
145
|
+
setSelectedPlaceId(null);
|
|
146
|
+
setPlaceMeta(null);
|
|
147
|
+
setDeviceId(null);
|
|
148
|
+
}, [keys]);
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
const loadLocalData = async () => {
|
|
152
|
+
setIsLoading(true);
|
|
153
|
+
|
|
154
|
+
const [
|
|
155
|
+
localSelectedCityId,
|
|
156
|
+
localSelectedPlaceId,
|
|
157
|
+
localPlaceMetaRaw,
|
|
158
|
+
localDeviceId,
|
|
159
|
+
] = await Promise.all([
|
|
160
|
+
AsyncStorage.getItem(keys.city),
|
|
161
|
+
AsyncStorage.getItem(keys.place),
|
|
162
|
+
AsyncStorage.getItem(keys.placeMeta),
|
|
163
|
+
previewMode ? Promise.resolve(null) : AsyncStorage.getItem(keys.device),
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
let localPlaceMeta: PlaceSelectionMeta | null = null;
|
|
167
|
+
if (localPlaceMetaRaw) {
|
|
168
|
+
try {
|
|
169
|
+
const parsed = JSON.parse(localPlaceMetaRaw) as PlaceSelectionMeta;
|
|
170
|
+
localPlaceMeta = {
|
|
171
|
+
cityName: parsed.cityName ?? null,
|
|
172
|
+
streetName: parsed.streetName ?? null,
|
|
173
|
+
sectorName: parsed.sectorName ?? null,
|
|
174
|
+
};
|
|
175
|
+
} catch {
|
|
176
|
+
localPlaceMeta = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const ready = Boolean(localSelectedCityId && localSelectedPlaceId);
|
|
181
|
+
setHasPlaceSelected(ready);
|
|
182
|
+
setSelectedCityId(localSelectedCityId);
|
|
183
|
+
setSelectedPlaceId(localSelectedPlaceId);
|
|
184
|
+
setPlaceMeta(localPlaceMeta);
|
|
185
|
+
setDeviceId(previewMode ? null : localDeviceId);
|
|
186
|
+
|
|
187
|
+
setIsLoading(false);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
void loadLocalData();
|
|
191
|
+
}, [keys, previewMode]);
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<LocalContext
|
|
195
|
+
value={{
|
|
196
|
+
isLoading,
|
|
197
|
+
hasPlaceSelected,
|
|
198
|
+
hasCitySelected: hasPlaceSelected,
|
|
199
|
+
selectedCityId,
|
|
200
|
+
selectedPlaceId,
|
|
201
|
+
placeMeta,
|
|
202
|
+
deviceId,
|
|
203
|
+
registerDevice,
|
|
204
|
+
clearPlaceSelection,
|
|
205
|
+
clearLocalData,
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
{children}
|
|
209
|
+
</LocalContext>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function useLocal() {
|
|
214
|
+
const context = useContext(LocalContext);
|
|
215
|
+
if (!context) {
|
|
216
|
+
throw new Error("useLocal must be within a LocalProvider.");
|
|
217
|
+
}
|
|
218
|
+
return context;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function generateDeviceId() {
|
|
222
|
+
const randomCharacters = Array.from({ length: 8 }, () =>
|
|
223
|
+
Math.floor(Math.random() * 10),
|
|
224
|
+
).join("");
|
|
225
|
+
|
|
226
|
+
return `device--${Date.now()}--${Platform.OS}--${randomCharacters}`;
|
|
227
|
+
}
|