@wasto/cms-components 1.5.10 → 1.5.12
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 -47
- package/dist/index.d.mts +181 -51
- package/dist/index.d.ts +181 -51
- package/dist/index.js +3614 -3015
- package/dist/index.mjs +3021 -2418
- package/package.json +12 -2
- package/src/components/announcement-modal/AnnouncementModal.tsx +183 -183
- package/src/components/article/Article.tsx +21 -21
- package/src/components/article/ArticleCard.tsx +81 -81
- package/src/components/article/ArticleExcerpts.tsx +80 -80
- package/src/components/article/ArticleList.tsx +114 -114
- package/src/components/article/ArticleMarkdown.tsx +170 -170
- package/src/components/article/ArticleReaderModal.tsx +93 -93
- package/src/components/article/ArticleSingle.tsx +57 -57
- package/src/components/bottom-tab/BottomTab.tsx +392 -392
- package/src/components/button/Button.tsx +154 -154
- package/src/components/challenge/Badges.tsx +84 -84
- package/src/components/challenge/BadgesModal.tsx +166 -166
- package/src/components/challenge/Challenge.tsx +380 -380
- package/src/components/challenge/ChallengeModal.tsx +231 -231
- package/src/components/challenge/ChallengeVignette.tsx +249 -249
- package/src/components/challenge/ChallengesModal.tsx +243 -243
- package/src/components/challenge/QuizModal.tsx +381 -381
- package/src/components/challenge/ResultModal.tsx +144 -144
- package/src/components/chat/Chat.tsx +928 -928
- package/src/components/collection-calendar/CollectionCalendar.tsx +274 -274
- package/src/components/columns/Columns.tsx +33 -33
- package/src/components/consignes-modal/ConsignesModal.tsx +267 -267
- package/src/components/content-inset/ContentInset.tsx +33 -33
- package/src/components/header/Header.tsx +415 -391
- package/src/components/image/Image.tsx +15 -15
- package/src/components/image/ImageFrame.tsx +102 -102
- package/src/components/image/ImageMedia.tsx +31 -31
- package/src/components/image/ImageUrl.tsx +30 -30
- package/src/components/locations-map/LocationSheet.tsx +417 -417
- package/src/components/locations-map/LocationsMap.native.tsx +260 -260
- package/src/components/locations-map/LocationsMap.tsx +13 -13
- package/src/components/locations-map/LocationsMap.web.tsx +10 -10
- package/src/components/locations-map/LocationsMapShell.tsx +546 -527
- package/src/components/locations-map/LocationsMapView.tsx +108 -108
- package/src/components/locations-map/LocationsMapView.web.tsx +331 -331
- package/src/components/nav-button/NavButton.tsx +59 -59
- package/src/components/next-collection/NextCollection.tsx +636 -621
- package/src/components/notifications/NotificationsScreen.tsx +247 -247
- package/src/components/page/Page.tsx +102 -64
- package/src/components/parapraph/Paragraph.tsx +67 -67
- package/src/components/profile/ProfileScreen.tsx +236 -236
- package/src/components/push/PushTokenRegistrar.tsx +47 -0
- package/src/components/recycling-info/RecyclingInfo.tsx +189 -189
- package/src/components/safe-area/OptionalSafeAreaProvider.tsx +17 -17
- package/src/components/search/Search.tsx +320 -320
- package/src/components/select-street-modal/SelectStreetModal.tsx +475 -475
- package/src/components/title/Title.tsx +100 -100
- package/src/components/vision/Vision.tsx +447 -447
- package/src/components/vision/VisionActionCard.tsx +117 -117
- package/src/components/vision/VisionCapture.tsx +30 -30
- package/src/components/vision/VisionTapOverlay.tsx +122 -122
- package/src/contexts/AnnouncementsContext.tsx +171 -171
- package/src/contexts/AppDataContext.tsx +458 -0
- package/src/contexts/ChallengesContext.tsx +182 -182
- package/src/contexts/HeaderConfigContext.tsx +29 -0
- package/src/contexts/LocalContext.tsx +227 -227
- package/src/contexts/ThemeContext.tsx +37 -37
- package/src/contexts/WastoContext.tsx +132 -127
- package/src/helpers/blogPost.ts +72 -72
- package/src/helpers/generateQuizQuestions.ts +247 -247
- package/src/helpers/getContrastColor.ts +18 -18
- package/src/helpers/haptics.ts +20 -20
- package/src/helpers/hoursSchedule.ts +230 -230
- package/src/helpers/htmlContentToMarkdown.ts +114 -114
- package/src/helpers/layout.ts +54 -54
- package/src/helpers/loadLinearGradient.ts +22 -22
- package/src/helpers/mapLocations.ts +170 -0
- package/src/helpers/maquetteTheme.ts +76 -76
- package/src/helpers/markdownUnderlinePlugin.ts +32 -32
- package/src/helpers/nextCollection.ts +122 -0
- package/src/helpers/normalizeWasteTypeRecyclingInfo.ts +80 -80
- package/src/helpers/publicMediaUrl.ts +8 -8
- package/src/helpers/resolveApiAssetUrl.ts +21 -21
- package/src/helpers/systemRoutes.ts +7 -7
- package/src/helpers/themeStyles.ts +114 -114
- package/src/helpers/ttlCache.ts +41 -0
- package/src/helpers/useSafeAreaPadding.ts +14 -14
- package/src/hooks/useBadges.ts +100 -100
- package/src/hooks/useBlogPosts.ts +109 -105
- package/src/hooks/useChallenges.ts +110 -110
- package/src/hooks/useCities.ts +56 -56
- package/src/hooks/useCityAnnouncements.ts +118 -118
- package/src/hooks/useCollectionCalendar.ts +421 -421
- package/src/hooks/useColumns.ts +19 -19
- package/src/hooks/useConsignes.ts +62 -62
- package/src/hooks/useLocations.ts +120 -248
- package/src/hooks/useNextCollection.ts +163 -238
- package/src/hooks/useRecyclingInfo.ts +90 -90
- package/src/hooks/useWastoClient.ts +16 -16
- package/src/index.ts +161 -142
- package/src/navigation/NavigationContext.tsx +56 -56
- package/src/push/registerExpoPushToken.ts +133 -0
- package/src/sentry/initSentry.ts +120 -120
- package/src/types/Theme.ts +72 -72
- package/src/types/css-modules.d.ts +1 -1
|
@@ -1,247 +1,247 @@
|
|
|
1
|
-
export type QuizQuestionKind = "waste_bin" | "ab_image" | "mcq";
|
|
2
|
-
|
|
3
|
-
export type QuizAnswerOption = {
|
|
4
|
-
id: string;
|
|
5
|
-
text: string;
|
|
6
|
-
wasteTypeId?: string;
|
|
7
|
-
imageUrl?: string;
|
|
8
|
-
type: "correct" | "wrong";
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export type GeneratedQuizQuestion = {
|
|
12
|
-
kind: QuizQuestionKind;
|
|
13
|
-
id: string;
|
|
14
|
-
question: string;
|
|
15
|
-
correctAnswer: string;
|
|
16
|
-
/** Identifiant de la bonne option (wasteTypeId ou choice id). */
|
|
17
|
-
correctOptionId: string;
|
|
18
|
-
/** @deprecated Alias de correctOptionId pour waste_bin. */
|
|
19
|
-
correctWasteTypeId: string;
|
|
20
|
-
answers: QuizAnswerOption[];
|
|
21
|
-
material?: string;
|
|
22
|
-
imageUrl?: string;
|
|
23
|
-
/** Explication affichée après validation de la réponse. */
|
|
24
|
-
explanation?: string;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
type QuizQuestionSource = {
|
|
28
|
-
id: string;
|
|
29
|
-
question?: string;
|
|
30
|
-
correctWasteTypeId?: string | null;
|
|
31
|
-
order?: number;
|
|
32
|
-
material?: string;
|
|
33
|
-
imageUrl?: string | null;
|
|
34
|
-
kind?: string | null;
|
|
35
|
-
options?: unknown;
|
|
36
|
-
explanationText?: string | null;
|
|
37
|
-
explanationDetailId?: string | null;
|
|
38
|
-
explanationDetail?: {
|
|
39
|
-
material?: string | null;
|
|
40
|
-
description?: string | null;
|
|
41
|
-
instructions?: string | null;
|
|
42
|
-
} | null;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export function resolveQuizExplanation(source: {
|
|
46
|
-
explanationText?: string | null;
|
|
47
|
-
explanationDetail?: {
|
|
48
|
-
material?: string | null;
|
|
49
|
-
description?: string | null;
|
|
50
|
-
instructions?: string | null;
|
|
51
|
-
} | null;
|
|
52
|
-
}): string | undefined {
|
|
53
|
-
const free = typeof source.explanationText === "string"
|
|
54
|
-
? source.explanationText.trim()
|
|
55
|
-
: "";
|
|
56
|
-
if (free) return free;
|
|
57
|
-
const detail = source.explanationDetail;
|
|
58
|
-
if (!detail) return undefined;
|
|
59
|
-
const fromDetail = (detail.instructions || detail.description || "").trim();
|
|
60
|
-
if (!fromDetail) return undefined;
|
|
61
|
-
const material = detail.material?.trim();
|
|
62
|
-
return material ? `${material} : ${fromDetail}` : fromDetail;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function asAbImageOptions(options: unknown): {
|
|
66
|
-
choices: Array<{ id: string; imageUrl: string; label?: string }>;
|
|
67
|
-
correctChoiceId: string;
|
|
68
|
-
} | null {
|
|
69
|
-
if (!options || typeof options !== "object") return null;
|
|
70
|
-
const raw = options as {
|
|
71
|
-
choices?: Array<{ id?: string; imageUrl?: string; label?: string }>;
|
|
72
|
-
correctChoiceId?: string;
|
|
73
|
-
};
|
|
74
|
-
const choices = Array.isArray(raw.choices)
|
|
75
|
-
? raw.choices
|
|
76
|
-
.map((c, i) => ({
|
|
77
|
-
id: String(c?.id ?? (i === 0 ? "a" : "b")),
|
|
78
|
-
imageUrl: String(c?.imageUrl ?? "").trim(),
|
|
79
|
-
label: c?.label ? String(c.label) : undefined,
|
|
80
|
-
}))
|
|
81
|
-
.filter((c) => c.imageUrl)
|
|
82
|
-
: [];
|
|
83
|
-
if (choices.length < 2) return null;
|
|
84
|
-
const correctChoiceId = String(raw.correctChoiceId ?? "").trim();
|
|
85
|
-
if (!choices.some((c) => c.id === correctChoiceId)) return null;
|
|
86
|
-
return { choices: choices.slice(0, 2), correctChoiceId };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function asMcqOptions(options: unknown): {
|
|
90
|
-
choices: Array<{ id: string; text: string }>;
|
|
91
|
-
correctChoiceId: string;
|
|
92
|
-
} | null {
|
|
93
|
-
if (!options || typeof options !== "object") return null;
|
|
94
|
-
const raw = options as {
|
|
95
|
-
choices?: Array<{ id?: string; text?: string }>;
|
|
96
|
-
correctChoiceId?: string;
|
|
97
|
-
};
|
|
98
|
-
const choices = Array.isArray(raw.choices)
|
|
99
|
-
? raw.choices
|
|
100
|
-
.map((c, i) => ({
|
|
101
|
-
id: String(c?.id ?? `c${i}`),
|
|
102
|
-
text: String(c?.text ?? "").trim(),
|
|
103
|
-
}))
|
|
104
|
-
.filter((c) => c.text)
|
|
105
|
-
: [];
|
|
106
|
-
if (choices.length < 2) return null;
|
|
107
|
-
const correctChoiceId = String(raw.correctChoiceId ?? "").trim();
|
|
108
|
-
if (!choices.some((c) => c.id === correctChoiceId)) return null;
|
|
109
|
-
return { choices, correctChoiceId };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function generateQuizQuestions(
|
|
113
|
-
wasteTypes: Array<{ id: string; name?: string }>,
|
|
114
|
-
serie?: {
|
|
115
|
-
id: string;
|
|
116
|
-
quizQuestions?: QuizQuestionSource[];
|
|
117
|
-
} | null
|
|
118
|
-
): GeneratedQuizQuestion[] {
|
|
119
|
-
const questionsToUse = [...(serie?.quizQuestions ?? [])].sort(
|
|
120
|
-
(a, b) => (a.order ?? 0) - (b.order ?? 0)
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
if (questionsToUse.length === 0) {
|
|
124
|
-
return [];
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return questionsToUse
|
|
128
|
-
.map((quizItem): GeneratedQuizQuestion | null => {
|
|
129
|
-
const kind = (quizItem.kind as QuizQuestionKind) || "waste_bin";
|
|
130
|
-
|
|
131
|
-
if (kind === "ab_image") {
|
|
132
|
-
const opts = asAbImageOptions(quizItem.options);
|
|
133
|
-
if (!opts) return null;
|
|
134
|
-
const correct = opts.choices.find((c) => c.id === opts.correctChoiceId);
|
|
135
|
-
return {
|
|
136
|
-
kind: "ab_image",
|
|
137
|
-
id: quizItem.id,
|
|
138
|
-
question: quizItem.question || "Quelle image est la bonne ?",
|
|
139
|
-
correctAnswer: correct?.label || "Image correcte",
|
|
140
|
-
correctOptionId: opts.correctChoiceId,
|
|
141
|
-
correctWasteTypeId: opts.correctChoiceId,
|
|
142
|
-
answers: opts.choices.map((choice) => ({
|
|
143
|
-
id: choice.id,
|
|
144
|
-
text: choice.label || (choice.id === "a" ? "A" : "B"),
|
|
145
|
-
imageUrl: choice.imageUrl,
|
|
146
|
-
type: choice.id === opts.correctChoiceId ? "correct" : "wrong",
|
|
147
|
-
})),
|
|
148
|
-
material: quizItem.material,
|
|
149
|
-
explanation: resolveQuizExplanation(quizItem),
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (kind === "mcq") {
|
|
154
|
-
const opts = asMcqOptions(quizItem.options);
|
|
155
|
-
if (!opts) return null;
|
|
156
|
-
const correct = opts.choices.find((c) => c.id === opts.correctChoiceId);
|
|
157
|
-
const shuffled = [...opts.choices].sort(() => Math.random() - 0.5);
|
|
158
|
-
return {
|
|
159
|
-
kind: "mcq",
|
|
160
|
-
id: quizItem.id,
|
|
161
|
-
question: quizItem.question || "Choisis la bonne réponse",
|
|
162
|
-
correctAnswer: correct?.text || "Réponse correcte",
|
|
163
|
-
correctOptionId: opts.correctChoiceId,
|
|
164
|
-
correctWasteTypeId: opts.correctChoiceId,
|
|
165
|
-
answers: shuffled.map((choice) => ({
|
|
166
|
-
id: choice.id,
|
|
167
|
-
text: choice.text,
|
|
168
|
-
type: choice.id === opts.correctChoiceId ? "correct" : "wrong",
|
|
169
|
-
})),
|
|
170
|
-
material: quizItem.material,
|
|
171
|
-
imageUrl:
|
|
172
|
-
typeof quizItem.imageUrl === "string" && quizItem.imageUrl.trim()
|
|
173
|
-
? quizItem.imageUrl.trim()
|
|
174
|
-
: undefined,
|
|
175
|
-
explanation: resolveQuizExplanation(quizItem),
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// waste_bin (legacy default)
|
|
180
|
-
if (wasteTypes.length === 0) return null;
|
|
181
|
-
const correctWasteType = wasteTypes.find(
|
|
182
|
-
(wt) => wt.id === quizItem.correctWasteTypeId
|
|
183
|
-
);
|
|
184
|
-
const otherWasteTypes = wasteTypes.filter(
|
|
185
|
-
(wt) => wt.id !== quizItem.correctWasteTypeId
|
|
186
|
-
);
|
|
187
|
-
const correctId = quizItem.correctWasteTypeId || "";
|
|
188
|
-
|
|
189
|
-
const answers: QuizAnswerOption[] = [
|
|
190
|
-
{
|
|
191
|
-
id: correctId,
|
|
192
|
-
text: correctWasteType?.name || "Poubelle incorrecte",
|
|
193
|
-
wasteTypeId: correctId,
|
|
194
|
-
type: "correct",
|
|
195
|
-
},
|
|
196
|
-
{
|
|
197
|
-
id: otherWasteTypes[0]?.id || "wrong-0",
|
|
198
|
-
text: otherWasteTypes[0]?.name || "Poubelle incorrecte",
|
|
199
|
-
wasteTypeId: otherWasteTypes[0]?.id || "",
|
|
200
|
-
type: "wrong",
|
|
201
|
-
},
|
|
202
|
-
{
|
|
203
|
-
id: otherWasteTypes[1]?.id || "wrong-1",
|
|
204
|
-
text: otherWasteTypes[1]?.name || "Poubelle incorrecte",
|
|
205
|
-
wasteTypeId: otherWasteTypes[1]?.id || "",
|
|
206
|
-
type: "wrong",
|
|
207
|
-
},
|
|
208
|
-
];
|
|
209
|
-
|
|
210
|
-
const shuffledAnswers = [...answers].sort(() => Math.random() - 0.5);
|
|
211
|
-
|
|
212
|
-
return {
|
|
213
|
-
kind: "waste_bin",
|
|
214
|
-
id: quizItem.id,
|
|
215
|
-
question: quizItem.question || "Où jeter cet objet ?",
|
|
216
|
-
correctAnswer: correctWasteType?.name || "Poubelle",
|
|
217
|
-
correctOptionId: correctId,
|
|
218
|
-
correctWasteTypeId: correctId,
|
|
219
|
-
answers: shuffledAnswers,
|
|
220
|
-
material: quizItem.material,
|
|
221
|
-
imageUrl:
|
|
222
|
-
typeof quizItem.imageUrl === "string" && quizItem.imageUrl.trim()
|
|
223
|
-
? quizItem.imageUrl.trim()
|
|
224
|
-
: undefined,
|
|
225
|
-
explanation: resolveQuizExplanation(quizItem),
|
|
226
|
-
};
|
|
227
|
-
})
|
|
228
|
-
.filter((q): q is GeneratedQuizQuestion => q !== null);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export function quizStarCount(correct: number, total: number): number {
|
|
232
|
-
if (total === 0) return 0;
|
|
233
|
-
const percentage = (correct / total) * 100;
|
|
234
|
-
if (percentage === 100) return 5;
|
|
235
|
-
if (percentage >= 80) return 4;
|
|
236
|
-
if (percentage >= 60) return 3;
|
|
237
|
-
if (percentage >= 40) return 2;
|
|
238
|
-
return 1;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
export function quizResultMessage(correct: number, total: number): string {
|
|
242
|
-
if (total === 0) return "Aucune question pour cette série.";
|
|
243
|
-
const percentage = (correct / total) * 100;
|
|
244
|
-
if (percentage >= 80) return "Excellent ! Tu es un vrai champion du tri !";
|
|
245
|
-
if (percentage >= 50) return "Super, encore un petit effort !";
|
|
246
|
-
return "Continue d'apprendre, tu vas y arriver !";
|
|
247
|
-
}
|
|
1
|
+
export type QuizQuestionKind = "waste_bin" | "ab_image" | "mcq";
|
|
2
|
+
|
|
3
|
+
export type QuizAnswerOption = {
|
|
4
|
+
id: string;
|
|
5
|
+
text: string;
|
|
6
|
+
wasteTypeId?: string;
|
|
7
|
+
imageUrl?: string;
|
|
8
|
+
type: "correct" | "wrong";
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type GeneratedQuizQuestion = {
|
|
12
|
+
kind: QuizQuestionKind;
|
|
13
|
+
id: string;
|
|
14
|
+
question: string;
|
|
15
|
+
correctAnswer: string;
|
|
16
|
+
/** Identifiant de la bonne option (wasteTypeId ou choice id). */
|
|
17
|
+
correctOptionId: string;
|
|
18
|
+
/** @deprecated Alias de correctOptionId pour waste_bin. */
|
|
19
|
+
correctWasteTypeId: string;
|
|
20
|
+
answers: QuizAnswerOption[];
|
|
21
|
+
material?: string;
|
|
22
|
+
imageUrl?: string;
|
|
23
|
+
/** Explication affichée après validation de la réponse. */
|
|
24
|
+
explanation?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type QuizQuestionSource = {
|
|
28
|
+
id: string;
|
|
29
|
+
question?: string;
|
|
30
|
+
correctWasteTypeId?: string | null;
|
|
31
|
+
order?: number;
|
|
32
|
+
material?: string;
|
|
33
|
+
imageUrl?: string | null;
|
|
34
|
+
kind?: string | null;
|
|
35
|
+
options?: unknown;
|
|
36
|
+
explanationText?: string | null;
|
|
37
|
+
explanationDetailId?: string | null;
|
|
38
|
+
explanationDetail?: {
|
|
39
|
+
material?: string | null;
|
|
40
|
+
description?: string | null;
|
|
41
|
+
instructions?: string | null;
|
|
42
|
+
} | null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function resolveQuizExplanation(source: {
|
|
46
|
+
explanationText?: string | null;
|
|
47
|
+
explanationDetail?: {
|
|
48
|
+
material?: string | null;
|
|
49
|
+
description?: string | null;
|
|
50
|
+
instructions?: string | null;
|
|
51
|
+
} | null;
|
|
52
|
+
}): string | undefined {
|
|
53
|
+
const free = typeof source.explanationText === "string"
|
|
54
|
+
? source.explanationText.trim()
|
|
55
|
+
: "";
|
|
56
|
+
if (free) return free;
|
|
57
|
+
const detail = source.explanationDetail;
|
|
58
|
+
if (!detail) return undefined;
|
|
59
|
+
const fromDetail = (detail.instructions || detail.description || "").trim();
|
|
60
|
+
if (!fromDetail) return undefined;
|
|
61
|
+
const material = detail.material?.trim();
|
|
62
|
+
return material ? `${material} : ${fromDetail}` : fromDetail;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function asAbImageOptions(options: unknown): {
|
|
66
|
+
choices: Array<{ id: string; imageUrl: string; label?: string }>;
|
|
67
|
+
correctChoiceId: string;
|
|
68
|
+
} | null {
|
|
69
|
+
if (!options || typeof options !== "object") return null;
|
|
70
|
+
const raw = options as {
|
|
71
|
+
choices?: Array<{ id?: string; imageUrl?: string; label?: string }>;
|
|
72
|
+
correctChoiceId?: string;
|
|
73
|
+
};
|
|
74
|
+
const choices = Array.isArray(raw.choices)
|
|
75
|
+
? raw.choices
|
|
76
|
+
.map((c, i) => ({
|
|
77
|
+
id: String(c?.id ?? (i === 0 ? "a" : "b")),
|
|
78
|
+
imageUrl: String(c?.imageUrl ?? "").trim(),
|
|
79
|
+
label: c?.label ? String(c.label) : undefined,
|
|
80
|
+
}))
|
|
81
|
+
.filter((c) => c.imageUrl)
|
|
82
|
+
: [];
|
|
83
|
+
if (choices.length < 2) return null;
|
|
84
|
+
const correctChoiceId = String(raw.correctChoiceId ?? "").trim();
|
|
85
|
+
if (!choices.some((c) => c.id === correctChoiceId)) return null;
|
|
86
|
+
return { choices: choices.slice(0, 2), correctChoiceId };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function asMcqOptions(options: unknown): {
|
|
90
|
+
choices: Array<{ id: string; text: string }>;
|
|
91
|
+
correctChoiceId: string;
|
|
92
|
+
} | null {
|
|
93
|
+
if (!options || typeof options !== "object") return null;
|
|
94
|
+
const raw = options as {
|
|
95
|
+
choices?: Array<{ id?: string; text?: string }>;
|
|
96
|
+
correctChoiceId?: string;
|
|
97
|
+
};
|
|
98
|
+
const choices = Array.isArray(raw.choices)
|
|
99
|
+
? raw.choices
|
|
100
|
+
.map((c, i) => ({
|
|
101
|
+
id: String(c?.id ?? `c${i}`),
|
|
102
|
+
text: String(c?.text ?? "").trim(),
|
|
103
|
+
}))
|
|
104
|
+
.filter((c) => c.text)
|
|
105
|
+
: [];
|
|
106
|
+
if (choices.length < 2) return null;
|
|
107
|
+
const correctChoiceId = String(raw.correctChoiceId ?? "").trim();
|
|
108
|
+
if (!choices.some((c) => c.id === correctChoiceId)) return null;
|
|
109
|
+
return { choices, correctChoiceId };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function generateQuizQuestions(
|
|
113
|
+
wasteTypes: Array<{ id: string; name?: string }>,
|
|
114
|
+
serie?: {
|
|
115
|
+
id: string;
|
|
116
|
+
quizQuestions?: QuizQuestionSource[];
|
|
117
|
+
} | null
|
|
118
|
+
): GeneratedQuizQuestion[] {
|
|
119
|
+
const questionsToUse = [...(serie?.quizQuestions ?? [])].sort(
|
|
120
|
+
(a, b) => (a.order ?? 0) - (b.order ?? 0)
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (questionsToUse.length === 0) {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return questionsToUse
|
|
128
|
+
.map((quizItem): GeneratedQuizQuestion | null => {
|
|
129
|
+
const kind = (quizItem.kind as QuizQuestionKind) || "waste_bin";
|
|
130
|
+
|
|
131
|
+
if (kind === "ab_image") {
|
|
132
|
+
const opts = asAbImageOptions(quizItem.options);
|
|
133
|
+
if (!opts) return null;
|
|
134
|
+
const correct = opts.choices.find((c) => c.id === opts.correctChoiceId);
|
|
135
|
+
return {
|
|
136
|
+
kind: "ab_image",
|
|
137
|
+
id: quizItem.id,
|
|
138
|
+
question: quizItem.question || "Quelle image est la bonne ?",
|
|
139
|
+
correctAnswer: correct?.label || "Image correcte",
|
|
140
|
+
correctOptionId: opts.correctChoiceId,
|
|
141
|
+
correctWasteTypeId: opts.correctChoiceId,
|
|
142
|
+
answers: opts.choices.map((choice) => ({
|
|
143
|
+
id: choice.id,
|
|
144
|
+
text: choice.label || (choice.id === "a" ? "A" : "B"),
|
|
145
|
+
imageUrl: choice.imageUrl,
|
|
146
|
+
type: choice.id === opts.correctChoiceId ? "correct" : "wrong",
|
|
147
|
+
})),
|
|
148
|
+
material: quizItem.material,
|
|
149
|
+
explanation: resolveQuizExplanation(quizItem),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (kind === "mcq") {
|
|
154
|
+
const opts = asMcqOptions(quizItem.options);
|
|
155
|
+
if (!opts) return null;
|
|
156
|
+
const correct = opts.choices.find((c) => c.id === opts.correctChoiceId);
|
|
157
|
+
const shuffled = [...opts.choices].sort(() => Math.random() - 0.5);
|
|
158
|
+
return {
|
|
159
|
+
kind: "mcq",
|
|
160
|
+
id: quizItem.id,
|
|
161
|
+
question: quizItem.question || "Choisis la bonne réponse",
|
|
162
|
+
correctAnswer: correct?.text || "Réponse correcte",
|
|
163
|
+
correctOptionId: opts.correctChoiceId,
|
|
164
|
+
correctWasteTypeId: opts.correctChoiceId,
|
|
165
|
+
answers: shuffled.map((choice) => ({
|
|
166
|
+
id: choice.id,
|
|
167
|
+
text: choice.text,
|
|
168
|
+
type: choice.id === opts.correctChoiceId ? "correct" : "wrong",
|
|
169
|
+
})),
|
|
170
|
+
material: quizItem.material,
|
|
171
|
+
imageUrl:
|
|
172
|
+
typeof quizItem.imageUrl === "string" && quizItem.imageUrl.trim()
|
|
173
|
+
? quizItem.imageUrl.trim()
|
|
174
|
+
: undefined,
|
|
175
|
+
explanation: resolveQuizExplanation(quizItem),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// waste_bin (legacy default)
|
|
180
|
+
if (wasteTypes.length === 0) return null;
|
|
181
|
+
const correctWasteType = wasteTypes.find(
|
|
182
|
+
(wt) => wt.id === quizItem.correctWasteTypeId
|
|
183
|
+
);
|
|
184
|
+
const otherWasteTypes = wasteTypes.filter(
|
|
185
|
+
(wt) => wt.id !== quizItem.correctWasteTypeId
|
|
186
|
+
);
|
|
187
|
+
const correctId = quizItem.correctWasteTypeId || "";
|
|
188
|
+
|
|
189
|
+
const answers: QuizAnswerOption[] = [
|
|
190
|
+
{
|
|
191
|
+
id: correctId,
|
|
192
|
+
text: correctWasteType?.name || "Poubelle incorrecte",
|
|
193
|
+
wasteTypeId: correctId,
|
|
194
|
+
type: "correct",
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: otherWasteTypes[0]?.id || "wrong-0",
|
|
198
|
+
text: otherWasteTypes[0]?.name || "Poubelle incorrecte",
|
|
199
|
+
wasteTypeId: otherWasteTypes[0]?.id || "",
|
|
200
|
+
type: "wrong",
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: otherWasteTypes[1]?.id || "wrong-1",
|
|
204
|
+
text: otherWasteTypes[1]?.name || "Poubelle incorrecte",
|
|
205
|
+
wasteTypeId: otherWasteTypes[1]?.id || "",
|
|
206
|
+
type: "wrong",
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
const shuffledAnswers = [...answers].sort(() => Math.random() - 0.5);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
kind: "waste_bin",
|
|
214
|
+
id: quizItem.id,
|
|
215
|
+
question: quizItem.question || "Où jeter cet objet ?",
|
|
216
|
+
correctAnswer: correctWasteType?.name || "Poubelle",
|
|
217
|
+
correctOptionId: correctId,
|
|
218
|
+
correctWasteTypeId: correctId,
|
|
219
|
+
answers: shuffledAnswers,
|
|
220
|
+
material: quizItem.material,
|
|
221
|
+
imageUrl:
|
|
222
|
+
typeof quizItem.imageUrl === "string" && quizItem.imageUrl.trim()
|
|
223
|
+
? quizItem.imageUrl.trim()
|
|
224
|
+
: undefined,
|
|
225
|
+
explanation: resolveQuizExplanation(quizItem),
|
|
226
|
+
};
|
|
227
|
+
})
|
|
228
|
+
.filter((q): q is GeneratedQuizQuestion => q !== null);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function quizStarCount(correct: number, total: number): number {
|
|
232
|
+
if (total === 0) return 0;
|
|
233
|
+
const percentage = (correct / total) * 100;
|
|
234
|
+
if (percentage === 100) return 5;
|
|
235
|
+
if (percentage >= 80) return 4;
|
|
236
|
+
if (percentage >= 60) return 3;
|
|
237
|
+
if (percentage >= 40) return 2;
|
|
238
|
+
return 1;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function quizResultMessage(correct: number, total: number): string {
|
|
242
|
+
if (total === 0) return "Aucune question pour cette série.";
|
|
243
|
+
const percentage = (correct / total) * 100;
|
|
244
|
+
if (percentage >= 80) return "Excellent ! Tu es un vrai champion du tri !";
|
|
245
|
+
if (percentage >= 50) return "Super, encore un petit effort !";
|
|
246
|
+
return "Continue d'apprendre, tu vas y arriver !";
|
|
247
|
+
}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Détermine si le texte doit être noir ou blanc en fonction de la couleur de fond
|
|
3
|
-
* @param hex Color au format #RRGGBB ou #RGB
|
|
4
|
-
* @returns '#000000' ou '#FFFFFF'
|
|
5
|
-
*/
|
|
6
|
-
export function getContrastColor(hex: string): '#000000' | '#FFFFFF' {
|
|
7
|
-
let cleanHex = hex.replace('#', '');
|
|
8
|
-
if (cleanHex.length === 3) {
|
|
9
|
-
cleanHex = cleanHex.split('').map(char => char + char).join('');
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const r = parseInt(cleanHex.substring(0, 2), 16);
|
|
13
|
-
const g = parseInt(cleanHex.substring(2, 4), 16);
|
|
14
|
-
const b = parseInt(cleanHex.substring(4, 6), 16);
|
|
15
|
-
|
|
16
|
-
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
|
17
|
-
|
|
18
|
-
return yiq >= 128 ? '#000000' : '#FFFFFF';
|
|
1
|
+
/**
|
|
2
|
+
* Détermine si le texte doit être noir ou blanc en fonction de la couleur de fond
|
|
3
|
+
* @param hex Color au format #RRGGBB ou #RGB
|
|
4
|
+
* @returns '#000000' ou '#FFFFFF'
|
|
5
|
+
*/
|
|
6
|
+
export function getContrastColor(hex: string): '#000000' | '#FFFFFF' {
|
|
7
|
+
let cleanHex = hex.replace('#', '');
|
|
8
|
+
if (cleanHex.length === 3) {
|
|
9
|
+
cleanHex = cleanHex.split('').map(char => char + char).join('');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const r = parseInt(cleanHex.substring(0, 2), 16);
|
|
13
|
+
const g = parseInt(cleanHex.substring(2, 4), 16);
|
|
14
|
+
const b = parseInt(cleanHex.substring(4, 6), 16);
|
|
15
|
+
|
|
16
|
+
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
|
17
|
+
|
|
18
|
+
return yiq >= 128 ? '#000000' : '#FFFFFF';
|
|
19
19
|
}
|
package/src/helpers/haptics.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import * as Haptics from "expo-haptics";
|
|
2
|
-
import { Platform } from "react-native";
|
|
3
|
-
|
|
4
|
-
export async function hapticSelection() {
|
|
5
|
-
if (Platform.OS === "web") return;
|
|
6
|
-
try {
|
|
7
|
-
await Haptics.selectionAsync();
|
|
8
|
-
} catch {
|
|
9
|
-
// Haptics unavailable on simulator / unsupported devices
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export async function hapticLight() {
|
|
14
|
-
if (Platform.OS === "web") return;
|
|
15
|
-
try {
|
|
16
|
-
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
17
|
-
} catch {
|
|
18
|
-
// ignore
|
|
19
|
-
}
|
|
20
|
-
}
|
|
1
|
+
import * as Haptics from "expo-haptics";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
|
|
4
|
+
export async function hapticSelection() {
|
|
5
|
+
if (Platform.OS === "web") return;
|
|
6
|
+
try {
|
|
7
|
+
await Haptics.selectionAsync();
|
|
8
|
+
} catch {
|
|
9
|
+
// Haptics unavailable on simulator / unsupported devices
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function hapticLight() {
|
|
14
|
+
if (Platform.OS === "web") return;
|
|
15
|
+
try {
|
|
16
|
+
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
17
|
+
} catch {
|
|
18
|
+
// ignore
|
|
19
|
+
}
|
|
20
|
+
}
|