@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,392 @@
1
+ import {
2
+ useCallback,
3
+ useLayoutEffect,
4
+ useRef,
5
+ useState,
6
+ type MutableRefObject,
7
+ type ReactNode,
8
+ } from "react";
9
+ import type { BottomTabBarProps } from "@react-navigation/bottom-tabs";
10
+ import { Pressable, StyleSheet, View } from "react-native";
11
+ import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
12
+ import Animated, {
13
+ cancelAnimation,
14
+ Extrapolation,
15
+ interpolate,
16
+ runOnJS,
17
+ type SharedValue,
18
+ useAnimatedStyle,
19
+ useSharedValue,
20
+ withSpring,
21
+ } from "react-native-reanimated";
22
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
23
+ import { useTheme } from "../../contexts/ThemeContext";
24
+ import { Theme } from "../../types/Theme";
25
+ import { resolveTheme } from "../../helpers/themeStyles";
26
+ import { hapticSelection } from "../../helpers/haptics";
27
+
28
+ export type BottomTabProps = BottomTabBarProps;
29
+
30
+ /** Maquette Frame 25: 64×44 squircle behind the active icon. */
31
+ const PILL_W = 64;
32
+ const PILL_H = 44;
33
+ const ICON_SIZE = 24;
34
+ const BAR_PAD_X = 24;
35
+ const PILL_TOP = 12;
36
+ const SPRING = { damping: 18, stiffness: 220, mass: 0.85 };
37
+ /** Au-delà, un flick ne change qu’une tab (évite de sauter à la dernière). */
38
+ const FLING_VX = 650;
39
+
40
+ function xForIndex(index: number, tabW: number, padX: number) {
41
+ "worklet";
42
+ return padX + index * tabW + (tabW - PILL_W) / 2;
43
+ }
44
+
45
+ function nearestIndex(x: number, tabW: number, padX: number, count: number) {
46
+ "worklet";
47
+ if (tabW <= 0 || count <= 0) return 0;
48
+ const i = Math.round((x - padX - (tabW - PILL_W) / 2) / tabW);
49
+ return Math.max(0, Math.min(count - 1, i));
50
+ }
51
+
52
+ function tabProgress(pillX: number, index: number, tabW: number) {
53
+ "worklet";
54
+ if (tabW <= 0) return 0;
55
+ const tabCenter = index * tabW + tabW / 2;
56
+ const pillCenter = pillX + PILL_W / 2;
57
+ return interpolate(
58
+ Math.abs(pillCenter - tabCenter),
59
+ [0, tabW * 0.52],
60
+ [1, 0],
61
+ Extrapolation.CLAMP,
62
+ );
63
+ }
64
+
65
+ function rubberband(overshoot: number, dim: number) {
66
+ "worklet";
67
+ const c = 0.55;
68
+ return (1 - 1 / (overshoot * c / Math.max(dim, 1) + 1)) * dim;
69
+ }
70
+
71
+ function TabItem({
72
+ index,
73
+ label,
74
+ icon,
75
+ onPress,
76
+ selected,
77
+ pillX,
78
+ tabW,
79
+ primary,
80
+ muted,
81
+ onPrimary,
82
+ dragging,
83
+ }: {
84
+ index: number;
85
+ label: string;
86
+ icon: (color: string) => ReactNode;
87
+ onPress: () => void;
88
+ selected: boolean;
89
+ pillX: SharedValue<number>;
90
+ tabW: number;
91
+ primary: string;
92
+ muted: string;
93
+ onPrimary: string;
94
+ dragging: MutableRefObject<boolean>;
95
+ }) {
96
+ const idleIconStyle = useAnimatedStyle(() => ({
97
+ opacity: 1 - tabProgress(pillX.value, index, tabW),
98
+ }), [index, tabW]);
99
+
100
+ const activeIconStyle = useAnimatedStyle(() => ({
101
+ opacity: tabProgress(pillX.value, index, tabW),
102
+ }), [index, tabW]);
103
+
104
+ const idleLabelStyle = useAnimatedStyle(() => ({
105
+ opacity: 1 - tabProgress(pillX.value, index, tabW),
106
+ }), [index, tabW]);
107
+
108
+ const activeLabelStyle = useAnimatedStyle(() => ({
109
+ opacity: tabProgress(pillX.value, index, tabW),
110
+ }), [index, tabW]);
111
+
112
+ return (
113
+ <Pressable
114
+ onPress={() => {
115
+ if (dragging.current) return;
116
+ onPress();
117
+ }}
118
+ style={styles.tabItem}
119
+ accessibilityRole="tab"
120
+ accessibilityState={{ selected }}
121
+ accessibilityLabel={label}
122
+ >
123
+ <View style={styles.iconSlot}>
124
+ <Animated.View style={[styles.iconLayer, idleIconStyle]}>
125
+ {icon(muted)}
126
+ </Animated.View>
127
+ <Animated.View style={[styles.iconLayer, activeIconStyle]}>
128
+ {icon(onPrimary)}
129
+ </Animated.View>
130
+ </View>
131
+ <View style={styles.labelSlot}>
132
+ <Animated.Text
133
+ style={[styles.label, styles.labelIdle, { color: muted }, idleLabelStyle]}
134
+ numberOfLines={1}
135
+ >
136
+ {label}
137
+ </Animated.Text>
138
+ <Animated.Text
139
+ style={[styles.label, styles.labelActive, { color: primary }, activeLabelStyle]}
140
+ numberOfLines={1}
141
+ >
142
+ {label}
143
+ </Animated.Text>
144
+ </View>
145
+ </Pressable>
146
+ );
147
+ }
148
+
149
+ export function BottomTab({
150
+ state,
151
+ descriptors,
152
+ navigation,
153
+ }: BottomTabProps): ReactNode {
154
+ const { theme } = useTheme();
155
+ const t = resolveTheme(theme);
156
+ const insets = useSafeAreaInsets();
157
+ const themedStyles = createThemedStyles(theme);
158
+ const muted = t.colors.muted ?? theme.colors.onSurface;
159
+ const count = state.routes.length;
160
+
161
+ const [rowWidth, setRowWidth] = useState(0);
162
+ const tabW = count > 0 && rowWidth > 0 ? rowWidth / count : 0;
163
+ const pillX = useSharedValue(0);
164
+ const tabWSV = useSharedValue(0);
165
+ const countSV = useSharedValue(count);
166
+ const dragStartX = useSharedValue(0);
167
+ const dragStartIndex = useSharedValue(state.index);
168
+ const pressed = useSharedValue(0);
169
+ const lastTick = useSharedValue(state.index);
170
+ const dragging = useRef(false);
171
+ const routesRef = useRef(state.routes);
172
+ const indexRef = useRef(state.index);
173
+ routesRef.current = state.routes;
174
+ indexRef.current = state.index;
175
+ countSV.value = count;
176
+
177
+ useLayoutEffect(() => {
178
+ tabWSV.value = tabW;
179
+ if (tabW <= 0) return;
180
+ pillX.value = withSpring(xForIndex(state.index, tabW, 0), SPRING);
181
+ }, [pillX, state.index, tabW, tabWSV]);
182
+
183
+ const selectIndex = useCallback(
184
+ (index: number) => {
185
+ const route = routesRef.current[index];
186
+ if (!route) return;
187
+ const width = tabWSV.value;
188
+ if (width > 0) {
189
+ pillX.value = withSpring(xForIndex(index, width, 0), SPRING);
190
+ }
191
+ const event = navigation.emit({
192
+ type: "tabPress",
193
+ target: route.key,
194
+ canPreventDefault: true,
195
+ });
196
+ if (index === indexRef.current || event.defaultPrevented) return;
197
+ void hapticSelection();
198
+ navigation.navigate(route.name);
199
+ },
200
+ [navigation, pillX, tabWSV],
201
+ );
202
+
203
+ const setDragging = useCallback((value: boolean) => {
204
+ dragging.current = value;
205
+ }, []);
206
+
207
+ const pan = Gesture.Pan()
208
+ .enabled(count > 1 && tabW > 0)
209
+ .activeOffsetX([-12, 12])
210
+ .failOffsetY([-18, 18])
211
+ .onStart(() => {
212
+ cancelAnimation(pillX);
213
+ dragStartX.value = pillX.value;
214
+ dragStartIndex.value = nearestIndex(
215
+ pillX.value,
216
+ tabWSV.value,
217
+ 0,
218
+ countSV.value,
219
+ );
220
+ pressed.value = withSpring(1, SPRING);
221
+ lastTick.value = dragStartIndex.value;
222
+ runOnJS(setDragging)(true);
223
+ })
224
+ .onUpdate((e) => {
225
+ const width = tabWSV.value;
226
+ const n = countSV.value;
227
+ const min = xForIndex(0, width, 0);
228
+ const max = xForIndex(n - 1, width, 0);
229
+ let next = dragStartX.value + e.translationX;
230
+ if (next < min) next = min - rubberband(min - next, PILL_W);
231
+ else if (next > max) next = max + rubberband(next - max, PILL_W);
232
+ pillX.value = next;
233
+ const idx = nearestIndex(next, width, 0, n);
234
+ if (idx !== lastTick.value) {
235
+ lastTick.value = idx;
236
+ runOnJS(hapticSelection)();
237
+ }
238
+ })
239
+ .onEnd((e) => {
240
+ const width = tabWSV.value;
241
+ const n = countSV.value;
242
+ const from = dragStartIndex.value;
243
+ const nearest = nearestIndex(pillX.value, width, 0, n);
244
+ // Flick rapide : ±1 tab depuis le départ (pas de projection libre).
245
+ // Relâchement lent : snap sur la tab sous le doigt (drag multi-tabs OK).
246
+ let idx =
247
+ Math.abs(e.velocityX) > FLING_VX
248
+ ? from + (e.velocityX > 0 ? 1 : -1)
249
+ : nearest;
250
+ idx = Math.max(0, Math.min(n - 1, idx));
251
+ pillX.value = withSpring(xForIndex(idx, width, 0), SPRING);
252
+ pressed.value = withSpring(0, SPRING);
253
+ runOnJS(selectIndex)(idx);
254
+ })
255
+ .onFinalize(() => {
256
+ pressed.value = withSpring(0, SPRING);
257
+ runOnJS(setDragging)(false);
258
+ });
259
+
260
+ const pillStyle = useAnimatedStyle(() => ({
261
+ left: pillX.value,
262
+ transform: [{ scale: interpolate(pressed.value, [0, 1], [1, 0.96]) }],
263
+ }), []);
264
+
265
+ return (
266
+ <GestureHandlerRootView style={styles.gestureRoot}>
267
+ <View
268
+ style={[
269
+ styles.bottomTab,
270
+ themedStyles.bottomTab,
271
+ { paddingBottom: Math.max(insets.bottom, 12) },
272
+ ]}
273
+ >
274
+ <GestureDetector gesture={pan}>
275
+ <View
276
+ collapsable={false}
277
+ style={styles.row}
278
+ onLayout={(e) => setRowWidth(e.nativeEvent.layout.width)}
279
+ >
280
+ <Animated.View
281
+ pointerEvents="none"
282
+ style={[
283
+ styles.pill,
284
+ {
285
+ backgroundColor: theme.colors.primary,
286
+ borderRadius: t.radii.md,
287
+ },
288
+ pillStyle,
289
+ ]}
290
+ />
291
+ {state.routes.map((route, index) => {
292
+ const { options } = descriptors[route.key];
293
+ const label =
294
+ typeof options.title === "string"
295
+ ? options.title
296
+ : route.name;
297
+
298
+ return (
299
+ <TabItem
300
+ key={route.key}
301
+ index={index}
302
+ label={label}
303
+ selected={state.index === index}
304
+ pillX={pillX}
305
+ tabW={tabW}
306
+ primary={theme.colors.primary}
307
+ muted={muted}
308
+ onPrimary={theme.colors.onPrimary}
309
+ dragging={dragging}
310
+ onPress={() => selectIndex(index)}
311
+ icon={(color) =>
312
+ options.tabBarIcon?.({
313
+ focused: index === state.index,
314
+ color,
315
+ size: ICON_SIZE,
316
+ })
317
+ }
318
+ />
319
+ );
320
+ })}
321
+ </View>
322
+ </GestureDetector>
323
+ </View>
324
+ </GestureHandlerRootView>
325
+ );
326
+ }
327
+
328
+ const createThemedStyles = (theme: Theme) =>
329
+ StyleSheet.create({
330
+ bottomTab: {
331
+ backgroundColor: theme.colors.surface,
332
+ },
333
+ });
334
+
335
+ const styles = StyleSheet.create({
336
+ gestureRoot: {
337
+ width: "100%",
338
+ },
339
+ bottomTab: {
340
+ width: "100%",
341
+ paddingHorizontal: BAR_PAD_X,
342
+ paddingTop: PILL_TOP,
343
+ },
344
+ row: {
345
+ position: "relative",
346
+ flexDirection: "row",
347
+ alignItems: "flex-start",
348
+ width: "100%",
349
+ overflow: "visible",
350
+ },
351
+ pill: {
352
+ position: "absolute",
353
+ top: 0,
354
+ width: PILL_W,
355
+ height: PILL_H,
356
+ zIndex: 0,
357
+ },
358
+ tabItem: {
359
+ flex: 1,
360
+ alignItems: "center",
361
+ zIndex: 1,
362
+ gap: 4,
363
+ },
364
+ iconSlot: {
365
+ width: PILL_W,
366
+ height: PILL_H,
367
+ alignItems: "center",
368
+ justifyContent: "center",
369
+ },
370
+ iconLayer: {
371
+ ...StyleSheet.absoluteFillObject,
372
+ alignItems: "center",
373
+ justifyContent: "center",
374
+ },
375
+ labelSlot: {
376
+ height: 16,
377
+ alignItems: "center",
378
+ justifyContent: "center",
379
+ alignSelf: "stretch",
380
+ },
381
+ label: {
382
+ fontSize: 13,
383
+ textAlign: "center",
384
+ },
385
+ labelIdle: {
386
+ fontWeight: "500",
387
+ },
388
+ labelActive: {
389
+ position: "absolute",
390
+ fontWeight: "700",
391
+ },
392
+ });
@@ -0,0 +1,154 @@
1
+ import type { ReactNode } from "react";
2
+ import { LucideIcon } from "lucide-react-native";
3
+ import {
4
+ ActivityIndicator,
5
+ Pressable,
6
+ PressableProps,
7
+ StyleProp,
8
+ StyleSheet,
9
+ Text,
10
+ TextStyle,
11
+ View,
12
+ ViewStyle,
13
+ } from "react-native";
14
+ import { Theme } from "../../types/Theme";
15
+ import { useTheme } from "../../contexts/ThemeContext";
16
+ import { resolveTheme } from "../../helpers/themeStyles";
17
+ import { SPACE } from "../../helpers/layout";
18
+
19
+ export type ButtonVariant = "primary" | "ghost" | "secondary" | "outline";
20
+
21
+ export type ButtonProps = Omit<PressableProps, "children" | "style"> & {
22
+ variant?: ButtonVariant;
23
+ disabled?: boolean;
24
+ loading?: boolean;
25
+ leftIcon?: LucideIcon;
26
+ rightIcon?: LucideIcon;
27
+ children: string;
28
+ style?: StyleProp<ViewStyle>;
29
+ textStyle?: StyleProp<TextStyle>;
30
+ };
31
+
32
+ export function Button({
33
+ variant = "primary",
34
+ disabled = false,
35
+ loading = false,
36
+ leftIcon: LeftIcon,
37
+ rightIcon: RightIcon,
38
+ children,
39
+ style,
40
+ textStyle,
41
+ ...props
42
+ }: ButtonProps): ReactNode {
43
+ const { theme } = useTheme();
44
+ const resolved = resolveTheme(theme);
45
+ const variantStyles = createThemedVariantsStyles(theme)[variant];
46
+ const isDisabled = disabled || loading;
47
+
48
+ const iconColor =
49
+ (textStyle && typeof textStyle === "object" && !Array.isArray(textStyle) && "color" in textStyle
50
+ ? (textStyle.color as string)
51
+ : undefined) ??
52
+ (variantStyles.text.color as string);
53
+
54
+ const content = loading ? (
55
+ <ActivityIndicator size="small" color={iconColor} />
56
+ ) : (
57
+ <View style={styles.content}>
58
+ {LeftIcon ? <LeftIcon size={18} color={iconColor} /> : null}
59
+ <Text style={[styles.text, variantStyles.text, textStyle]}>
60
+ {children}
61
+ </Text>
62
+ {RightIcon ? <RightIcon size={18} color={iconColor} /> : null}
63
+ </View>
64
+ );
65
+
66
+ return (
67
+ <Pressable
68
+ {...props}
69
+ disabled={isDisabled}
70
+ style={({ pressed }) => [
71
+ styles.base,
72
+ { borderRadius: resolved.radii.md },
73
+ variantStyles.base,
74
+ style,
75
+ isDisabled && styles.disabled,
76
+ pressed && !isDisabled && styles.pressed,
77
+ ]}
78
+ >
79
+ {content}
80
+ </Pressable>
81
+ );
82
+ }
83
+
84
+ const createThemedVariantsStyles = (theme: Theme): Record<
85
+ ButtonVariant,
86
+ { base: ViewStyle; text: TextStyle }
87
+ > => {
88
+ const t = resolveTheme(theme);
89
+ return {
90
+ primary: StyleSheet.create({
91
+ base: {
92
+ backgroundColor: t.colors.primary,
93
+ ...t.shadow.soft,
94
+ },
95
+ text: {
96
+ color: t.colors.onPrimary,
97
+ },
98
+ }),
99
+ secondary: StyleSheet.create({
100
+ base: {
101
+ backgroundColor: t.colors.surface,
102
+ borderColor: t.colors.border,
103
+ borderWidth: 1,
104
+ },
105
+ text: {
106
+ color: t.colors.primary,
107
+ },
108
+ }),
109
+ outline: StyleSheet.create({
110
+ base: {
111
+ backgroundColor: "transparent",
112
+ borderWidth: 1,
113
+ borderColor: t.colors.border,
114
+ },
115
+ text: {
116
+ color: t.colors.onSurface,
117
+ },
118
+ }),
119
+ ghost: StyleSheet.create({
120
+ base: {
121
+ backgroundColor: "transparent",
122
+ },
123
+ text: {
124
+ color: t.colors.onBackground,
125
+ },
126
+ }),
127
+ };
128
+ };
129
+
130
+ const styles = StyleSheet.create({
131
+ base: {
132
+ height: SPACE.buttonH,
133
+ justifyContent: "center",
134
+ alignItems: "center",
135
+ overflow: "hidden",
136
+ },
137
+ content: {
138
+ flexDirection: "row",
139
+ alignItems: "center",
140
+ justifyContent: "center",
141
+ gap: 8,
142
+ paddingHorizontal: SPACE.pageX,
143
+ },
144
+ disabled: {
145
+ opacity: 0.5,
146
+ },
147
+ pressed: {
148
+ opacity: 0.9,
149
+ },
150
+ text: {
151
+ fontSize: 17,
152
+ fontWeight: "600",
153
+ },
154
+ });
@@ -0,0 +1,84 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { Image } from "expo-image";
3
+ import { AcquiredBadge } from "../../hooks/useChallenges";
4
+ import { Theme } from "../../types/Theme";
5
+ import { useTheme } from "../../contexts/ThemeContext";
6
+ import { useMemo, useState, type ReactNode } from "react";
7
+ import { NavButton } from "../nav-button/NavButton";
8
+ import { PlusIcon } from "lucide-react-native";
9
+ import { BadgesModal } from "./BadgesModal";
10
+
11
+ export type BadgesProps = {
12
+ badges: AcquiredBadge[];
13
+ };
14
+
15
+ export function Badges({
16
+ badges,
17
+ }: BadgesProps): ReactNode {
18
+ const { theme } = useTheme();
19
+ const themedStyles = createThemedStyles(theme);
20
+ const [displayAllBadges, setDisplayAllBadges] = useState(false);
21
+
22
+ const badgesDisplay = useMemo<(AcquiredBadge | null)[]>(() => {
23
+ const activeBadges = badges.slice(0, 5);
24
+ return Array.from({ length: 5 }, (_, i) => activeBadges[i] ?? null);
25
+ }, [badges]);
26
+
27
+ return (
28
+ <>
29
+ <View style={styles.badges}>
30
+ {badgesDisplay.map((badge, index) =>
31
+ badge ? (
32
+ <View
33
+ key={badge.badgeId}
34
+ style={[styles.badge, themedStyles.badge]}
35
+ >
36
+ {badge.badge?.imageUrl ? (
37
+ <Image
38
+ source={badge.badge.imageUrl}
39
+ style={styles.badgeImage}
40
+ contentFit="cover"
41
+ />
42
+ ) : null}
43
+ </View>
44
+ ) : (
45
+ <View
46
+ key={index.toString()}
47
+ style={[styles.badge, themedStyles.badge]}
48
+ />
49
+ )
50
+ )}
51
+ </View>
52
+ <BadgesModal
53
+ visible={displayAllBadges}
54
+ acquiredBadges={badges}
55
+ onClose={() => setDisplayAllBadges(false)}
56
+ />
57
+ </>
58
+ );
59
+ }
60
+
61
+ const createThemedStyles = (theme: Theme) =>
62
+ StyleSheet.create({
63
+ badge: {
64
+ backgroundColor: theme.colors.surface,
65
+ borderRadius: theme.radius,
66
+ },
67
+ });
68
+
69
+ const styles = StyleSheet.create({
70
+ badges: {
71
+ flexDirection: "row",
72
+ gap: 10,
73
+ alignItems: "center",
74
+ },
75
+ badge: {
76
+ flex: 1,
77
+ aspectRatio: 3 / 4,
78
+ overflow: "hidden",
79
+ },
80
+ badgeImage: {
81
+ width: "100%",
82
+ height: "100%",
83
+ },
84
+ });