@xetwa/design-system 1.0.2

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 (168) hide show
  1. package/.github/workflows/publish.yml +30 -0
  2. package/.storybook/main.ts +17 -0
  3. package/.storybook/preview.tsx +65 -0
  4. package/eslint.config.js +22 -0
  5. package/index.html +13 -0
  6. package/package.json +55 -0
  7. package/public/favicon.svg +1 -0
  8. package/public/icons.svg +24 -0
  9. package/src/App.css +184 -0
  10. package/src/App.tsx +122 -0
  11. package/src/BordasSombras.mdx +343 -0
  12. package/src/Colors.mdx +393 -0
  13. package/src/GrelhaLayout.mdx +375 -0
  14. package/src/Icons.mdx +42 -0
  15. package/src/MovimentoBreakpoints.mdx +379 -0
  16. package/src/Spacing.mdx +231 -0
  17. package/src/Typography.mdx +321 -0
  18. package/src/assets/Mascots/vava/estaticos/vava-acenar.svg +3 -0
  19. package/src/assets/Mascots/vava/estaticos/vava-acertou.svg +3 -0
  20. package/src/assets/Mascots/vava/estaticos/vava-celebrar.svg +3 -0
  21. package/src/assets/Mascots/vava/estaticos/vava-com-autocolantes.svg +3 -0
  22. package/src/assets/Mascots/vava/estaticos/vava-dormir.svg +3 -0
  23. package/src/assets/Mascots/vava/estaticos/vava-errou.svg +3 -0
  24. package/src/assets/Mascots/vava/estaticos/vava-feliz.svg +3 -0
  25. package/src/assets/Mascots/vava/estaticos/vava-neutra.svg +3 -0
  26. package/src/assets/Mascots/vava/estaticos/vava-original.svg +3 -0
  27. package/src/assets/Mascots/vava/estaticos/vava-pensar.svg +3 -0
  28. package/src/assets/Mascots/vava/estaticos/vava-surpresa.svg +3 -0
  29. package/src/assets/Mascots/yaya/avatar/yaya-avatar-falar.svg +7 -0
  30. package/src/assets/Mascots/yaya/avatar/yaya-avatar-feliz.svg +7 -0
  31. package/src/assets/Mascots/yaya/avatar/yaya-avatar-transparente.svg +6 -0
  32. package/src/assets/Mascots/yaya/estaticos/yaya-acertou.svg +13 -0
  33. package/src/assets/Mascots/yaya/estaticos/yaya-celebrar.svg +13 -0
  34. package/src/assets/Mascots/yaya/estaticos/yaya-dormir.svg +13 -0
  35. package/src/assets/Mascots/yaya/estaticos/yaya-errou.svg +13 -0
  36. package/src/assets/Mascots/yaya/estaticos/yaya-falar.svg +13 -0
  37. package/src/assets/Mascots/yaya/estaticos/yaya-feliz.svg +13 -0
  38. package/src/assets/Mascots/yaya/estaticos/yaya-neutra.svg +13 -0
  39. package/src/assets/Mascots/yaya/estaticos/yaya-pensar.svg +13 -0
  40. package/src/assets/Mascots/yaya/estaticos/yaya-surpresa.svg +13 -0
  41. package/src/assets/hero.png +0 -0
  42. package/src/assets/react.svg +1 -0
  43. package/src/assets/vite.svg +1 -0
  44. package/src/components/Buttons/BackButton/BackButton.stories.tsx +86 -0
  45. package/src/components/Buttons/BackButton/BackButton.tsx +107 -0
  46. package/src/components/Buttons/FabButton/FabButton.stories.tsx +74 -0
  47. package/src/components/Buttons/FabButton/FabButton.tsx +161 -0
  48. package/src/components/Buttons/IconButton/IconButton.stories.tsx +99 -0
  49. package/src/components/Buttons/IconButton/IconButton.tsx +189 -0
  50. package/src/components/Buttons/JourneyButton/JourneyButton.stories.tsx +80 -0
  51. package/src/components/Buttons/JourneyButton/JourneyButton.tsx +194 -0
  52. package/src/components/Buttons/MainButton/MainButton.stories.tsx +144 -0
  53. package/src/components/Buttons/MainButton/MainButton.tsx +258 -0
  54. package/src/components/Buttons/WordChip/WordChip.stories.tsx +84 -0
  55. package/src/components/Buttons/WordChip/WordChip.tsx +157 -0
  56. package/src/components/Buttons/WordChip/index.ts +1 -0
  57. package/src/components/Cards/AnswerOptionCard/AnswerOptionCard.stories.tsx +181 -0
  58. package/src/components/Cards/AnswerOptionCard/AnswerOptionCard.tsx +201 -0
  59. package/src/components/Cards/BaseCard/BaseCard.stories.tsx +148 -0
  60. package/src/components/Cards/BaseCard/BaseCard.tsx +181 -0
  61. package/src/components/Cards/BoardingPassCard/BoardingPassCard.stories.tsx +205 -0
  62. package/src/components/Cards/BoardingPassCard/BoardingPassCard.tsx +384 -0
  63. package/src/components/Cards/BoardingPassCard/FlightIcon.tsx +21 -0
  64. package/src/components/Cards/BoardingPassCard/FlightIcon.web.tsx +18 -0
  65. package/src/components/Cards/CountryCard/CountryCard.stories.tsx +97 -0
  66. package/src/components/Cards/CountryCard/CountryCard.tsx +185 -0
  67. package/src/components/Cards/LevelProgressCard/LevelProgressCard.stories.tsx +61 -0
  68. package/src/components/Cards/LevelProgressCard/LevelProgressCard.tsx +143 -0
  69. package/src/components/Cards/ModuleCard/ModuleCard.stories.tsx +108 -0
  70. package/src/components/Cards/ModuleCard/ModuleCard.tsx +304 -0
  71. package/src/components/Cards/ModuleCompletionCard/ModuleCompletionCard.stories.tsx +53 -0
  72. package/src/components/Cards/ModuleCompletionCard/ModuleCompletionCard.tsx +167 -0
  73. package/src/components/Cards/Passport/PassportCover.tsx +114 -0
  74. package/src/components/Cards/Passport/PassportIDPage.tsx +217 -0
  75. package/src/components/Cards/Passport/PassportStampPage.tsx +253 -0
  76. package/src/components/Cards/Passport/PassportViewer.stories.tsx +95 -0
  77. package/src/components/Cards/Passport/PassportViewer.tsx +328 -0
  78. package/src/components/Cards/PlanCard/PlanCard.stories.tsx +159 -0
  79. package/src/components/Cards/PlanCard/PlanCard.tsx +317 -0
  80. package/src/components/Cards/PricingCard/PricingCard.stories.tsx +83 -0
  81. package/src/components/Cards/PricingCard/PricingCard.tsx +144 -0
  82. package/src/components/Cards/SelectionCard/SelectionCard.stories.tsx +77 -0
  83. package/src/components/Cards/SelectionCard/SelectionCard.tsx +111 -0
  84. package/src/components/Cards/TeacherCard/TeacherCard.stories.tsx +126 -0
  85. package/src/components/Cards/TeacherCard/TeacherCard.tsx +236 -0
  86. package/src/components/Charts/WeeklyProgressChart/WeeklyProgressChart.stories.tsx +89 -0
  87. package/src/components/Charts/WeeklyProgressChart/WeeklyProgressChart.tsx +179 -0
  88. package/src/components/Charts/WeeklyProgressChart/index.ts +1 -0
  89. package/src/components/DataDisplay/Avatar/Avatar.stories.tsx +85 -0
  90. package/src/components/DataDisplay/Avatar/Avatar.tsx +118 -0
  91. package/src/components/DataDisplay/Avatar/index.ts +1 -0
  92. package/src/components/DataDisplay/Badge/Badge.stories.tsx +62 -0
  93. package/src/components/DataDisplay/Badge/Badge.tsx +146 -0
  94. package/src/components/DataDisplay/Flag/Flag.stories.tsx +98 -0
  95. package/src/components/DataDisplay/Flag/Flag.tsx +347 -0
  96. package/src/components/DataDisplay/Flag/Flag.web.tsx +334 -0
  97. package/src/components/DataDisplay/InstructionBubble/InstructionBubble.stories.tsx +80 -0
  98. package/src/components/DataDisplay/InstructionBubble/InstructionBubble.tsx +86 -0
  99. package/src/components/DataDisplay/LevelBadge/LevelBadge.stories.tsx +99 -0
  100. package/src/components/DataDisplay/LevelBadge/LevelBadge.tsx +118 -0
  101. package/src/components/DataDisplay/ProgressBar/ProgressBar.stories.tsx +95 -0
  102. package/src/components/DataDisplay/ProgressBar/ProgressBar.tsx +117 -0
  103. package/src/components/DataDisplay/StatBadge/StatBadge.stories.tsx +82 -0
  104. package/src/components/DataDisplay/StatBadge/StatBadge.tsx +135 -0
  105. package/src/components/DataDisplay/StatBadge/index.ts +1 -0
  106. package/src/components/DataDisplay/Tag/Tag.stories.tsx +63 -0
  107. package/src/components/DataDisplay/Tag/Tag.tsx +148 -0
  108. package/src/components/Feedback/FeedbackBottomSheet/FeedbackBottomSheet.stories.tsx +74 -0
  109. package/src/components/Feedback/FeedbackBottomSheet/FeedbackBottomSheet.tsx +200 -0
  110. package/src/components/Forms/Checkbox/Checkbox.stories.tsx +60 -0
  111. package/src/components/Forms/Checkbox/Checkbox.tsx +127 -0
  112. package/src/components/Forms/Checkbox/index.ts +1 -0
  113. package/src/components/Forms/DaySelector/DaySelector.stories.tsx +62 -0
  114. package/src/components/Forms/DaySelector/DaySelector.tsx +133 -0
  115. package/src/components/Forms/Radio/Radio.stories.tsx +61 -0
  116. package/src/components/Forms/Radio/Radio.tsx +141 -0
  117. package/src/components/Forms/Radio/index.ts +1 -0
  118. package/src/components/Forms/SegmentedToggle/SegmentedToggle.stories.tsx +67 -0
  119. package/src/components/Forms/SegmentedToggle/SegmentedToggle.tsx +150 -0
  120. package/src/components/Forms/SegmentedToggle/index.ts +1 -0
  121. package/src/components/Forms/Select/Select.stories.tsx +59 -0
  122. package/src/components/Forms/Select/Select.tsx +255 -0
  123. package/src/components/Forms/Select/index.ts +1 -0
  124. package/src/components/Forms/Switch/Switch.stories.tsx +57 -0
  125. package/src/components/Forms/Switch/Switch.tsx +120 -0
  126. package/src/components/Forms/Switch/index.ts +1 -0
  127. package/src/components/Forms/TimeSlot/TimeSlot.stories.tsx +62 -0
  128. package/src/components/Forms/TimeSlot/TimeSlot.tsx +116 -0
  129. package/src/components/Headers/CountryHeader/CountryHeader.stories.tsx +114 -0
  130. package/src/components/Headers/CountryHeader/CountryHeader.tsx +147 -0
  131. package/src/components/Headers/ProfileHeader/ProfileHeader.stories.tsx +141 -0
  132. package/src/components/Headers/ProfileHeader/ProfileHeader.tsx +233 -0
  133. package/src/components/Inputs/FillInTheBlank/FillInTheBlank.stories.tsx +333 -0
  134. package/src/components/Inputs/FillInTheBlank/FillInTheBlank.tsx +287 -0
  135. package/src/components/Inputs/FillInTheBlank/index.ts +1 -0
  136. package/src/components/Inputs/TextInput/TextInput.stories.tsx +103 -0
  137. package/src/components/Inputs/TextInput/TextInput.tsx +187 -0
  138. package/src/components/Mascots/Vava/Vava.stories.tsx +114 -0
  139. package/src/components/Mascots/Vava/Vava.tsx +276 -0
  140. package/src/components/Mascots/Yaya/Yaya.stories.tsx +98 -0
  141. package/src/components/Mascots/Yaya/Yaya.tsx +233 -0
  142. package/src/components/Navigation/BottomTabBar/BottomTabBar.stories.tsx +68 -0
  143. package/src/components/Navigation/BottomTabBar/BottomTabBar.tsx +85 -0
  144. package/src/components/Navigation/BottomTabBar/BottomTabBarItem.tsx +68 -0
  145. package/src/components/Navigation/BottomTabBar/index.ts +2 -0
  146. package/src/components/Navigation/PaginationDots/PaginationDots.stories.tsx +62 -0
  147. package/src/components/Navigation/PaginationDots/PaginationDots.tsx +81 -0
  148. package/src/components/Navigation/PaginationDots/index.ts +1 -0
  149. package/src/components/Navigation/ProgressMap/ProgressMap.stories.tsx +116 -0
  150. package/src/components/Navigation/ProgressMap/ProgressMap.tsx +827 -0
  151. package/src/components/Navigation/WorldProgressMap/WorldProgressMap.stories.tsx +169 -0
  152. package/src/components/Navigation/WorldProgressMap/WorldProgressMap.tsx +790 -0
  153. package/src/components/Typography/Typography.stories.tsx +127 -0
  154. package/src/components/Typography/Typography.tsx +53 -0
  155. package/src/hooks/useResponsive.ts +41 -0
  156. package/src/hooks/useTheme.ts +32 -0
  157. package/src/index.css +355 -0
  158. package/src/index.ts +28 -0
  159. package/src/main.tsx +10 -0
  160. package/src/mocks/codegenNativeComponent.ts +5 -0
  161. package/src/styles/countryThemes.ts +79 -0
  162. package/src/styles/theme.ts +297 -0
  163. package/src/utils/iconMap.ts +23 -0
  164. package/tsconfig.app.json +25 -0
  165. package/tsconfig.json +7 -0
  166. package/tsconfig.node.json +24 -0
  167. package/vite.config.ts +51 -0
  168. package/vitest.shims.d.ts +1 -0
@@ -0,0 +1,827 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { View, StyleSheet, ScrollView, TouchableOpacity, Animated, Easing, Platform } from 'react-native';
3
+ import type { ViewStyle } from 'react-native';
4
+ import Svg, { Circle, Line, Path } from 'react-native-svg';
5
+ import { theme } from '../../../styles/theme';
6
+ import { useResponsive } from '../../../hooks/useResponsive';
7
+ import { Flag } from '../../DataDisplay/Flag/Flag';
8
+ import { BackButton } from '../../Buttons/BackButton/BackButton';
9
+ import { JourneyButton } from '../../Buttons/JourneyButton/JourneyButton';
10
+ import { FlightIcon } from '../../Cards/BoardingPassCard/FlightIcon';
11
+ import { Typography } from '../../Typography/Typography';
12
+
13
+ export interface MapStation {
14
+ id: number;
15
+ label: string;
16
+ levelCode: 'A1' | 'A2' | 'B1' | 'B2' | 'C1';
17
+ countryCode: string; // E.g., 'FR', 'BE', 'CH', 'CA', 'SN', 'CI', etc.
18
+ status: 'visited' | 'current' | 'locked';
19
+ x?: number; // Optional custom coordinates (percentage, 0-100)
20
+ y?: number; // Optional custom coordinate (pixels)
21
+ }
22
+
23
+ export interface ProgressMapProps {
24
+ title?: string;
25
+ subtitle?: string;
26
+ stations: MapStation[];
27
+ activeStationId: number;
28
+ onStationPress?: (station: MapStation) => void;
29
+ onContinuePress?: (currentStation: MapStation) => void;
30
+ onBackPress?: () => void;
31
+ onFlightPress?: () => void;
32
+ size?: 'sm' | 'md' | 'lg';
33
+ height?: number | string; // Custom height constraint
34
+ style?: ViewStyle | ViewStyle[];
35
+ }
36
+
37
+ export const ProgressMap = ({
38
+ title = 'A ROTA DA FRANCOFONIA',
39
+ subtitle,
40
+ stations = [],
41
+ activeStationId,
42
+ onStationPress,
43
+ onContinuePress,
44
+ onBackPress,
45
+ onFlightPress,
46
+ size = 'md',
47
+ height,
48
+ style,
49
+ }: ProgressMapProps) => {
50
+ const { scale, scaleText, width, height: windowHeight } = useResponsive();
51
+ const [containerWidth, setContainerWidth] = useState(Platform.OS === 'web' ? 390 : width);
52
+ const [scrollReady, setScrollReady] = useState(false);
53
+ const scrollViewRef = useRef<ScrollView>(null);
54
+
55
+ // Responsive configurations
56
+ const canvasWidth = containerWidth;
57
+ let nodeSize = 56;
58
+ let autoSpacing = 160;
59
+ let headerTitleSize = 14;
60
+ let headerSubtitleSize = 20;
61
+
62
+ if (size === 'sm') {
63
+ nodeSize = 46;
64
+ autoSpacing = 130;
65
+ headerTitleSize = 12;
66
+ headerSubtitleSize = 16;
67
+ } else if (size === 'lg') {
68
+ nodeSize = 66;
69
+ autoSpacing = 200;
70
+ headerTitleSize = 16;
71
+ headerSubtitleSize = 24;
72
+ }
73
+
74
+ // Adjust spacing dynamically if screen height is very short
75
+ if (windowHeight < 680) {
76
+ autoSpacing = Math.round(autoSpacing * 0.85);
77
+ }
78
+
79
+ const paddingVal = scale(60);
80
+ // Add scale(100) extra padding at the bottom so the last items are not hidden behind the floating footer CTA button
81
+ const totalHeight = stations.length * scale(autoSpacing) + paddingVal * 2 + scale(100);
82
+
83
+ // Animations
84
+ const pathAnim = useRef(new Animated.Value(0)).current;
85
+ const pulseAnim = useRef(new Animated.Value(0)).current;
86
+ const bobAnim = useRef(new Animated.Value(0)).current;
87
+
88
+ useEffect(() => {
89
+ // 1. Path drawing animation
90
+ pathAnim.setValue(0);
91
+ Animated.timing(pathAnim, {
92
+ toValue: 1,
93
+ duration: 1200,
94
+ easing: Easing.out(Easing.quad),
95
+ useNativeDriver: false, // SVG dash offset cannot use native driver easily
96
+ }).start();
97
+
98
+ // 2. Pulse looping animation
99
+ Animated.loop(
100
+ Animated.sequence([
101
+ Animated.timing(pulseAnim, {
102
+ toValue: 1,
103
+ duration: 1800,
104
+ easing: Easing.out(Easing.ease),
105
+ useNativeDriver: false,
106
+ }),
107
+ Animated.timing(pulseAnim, {
108
+ toValue: 0,
109
+ duration: 0,
110
+ useNativeDriver: false,
111
+ }),
112
+ ])
113
+ ).start();
114
+
115
+ // 3. Airplane bobbing animation
116
+ Animated.loop(
117
+ Animated.sequence([
118
+ Animated.timing(bobAnim, {
119
+ toValue: 1,
120
+ duration: 1200,
121
+ easing: Easing.inOut(Easing.ease),
122
+ useNativeDriver: true,
123
+ }),
124
+ Animated.timing(bobAnim, {
125
+ toValue: 0,
126
+ duration: 1200,
127
+ easing: Easing.inOut(Easing.ease),
128
+ useNativeDriver: true,
129
+ }),
130
+ ])
131
+ ).start();
132
+ }, [stations, pathAnim, pulseAnim, bobAnim]);
133
+
134
+ // Layout positions resolver
135
+ const resolveCoordinates = () => {
136
+ return stations.map((station, index) => {
137
+ // Automatic snake zig-zag layout
138
+ let x = canvasWidth / 2;
139
+ let y = paddingVal + index * scale(autoSpacing);
140
+
141
+ // Alternate horizontal position for snake trail
142
+ if (station.x !== undefined) {
143
+ x = (station.x / 100) * canvasWidth;
144
+ } else {
145
+ const offset = canvasWidth * 0.22;
146
+ if (index % 4 === 1) {
147
+ x = canvasWidth / 2 + offset;
148
+ } else if (index % 4 === 3) {
149
+ x = canvasWidth / 2 - offset;
150
+ }
151
+ }
152
+
153
+ if (station.y !== undefined) {
154
+ y = station.y;
155
+ }
156
+
157
+ return {
158
+ ...station,
159
+ x,
160
+ y,
161
+ };
162
+ });
163
+ };
164
+
165
+ const points = resolveCoordinates();
166
+ const activeStation = stations.find((s) => s.id === activeStationId) || stations[0];
167
+ const activeIndex = points.findIndex((p) => p.id === activeStationId);
168
+ const activePoint = points[activeIndex] || points[0];
169
+
170
+ // Auto-scroll to active station on load
171
+ useEffect(() => {
172
+ if (activePoint && scrollViewRef.current && scrollReady) {
173
+ setTimeout(() => {
174
+ scrollViewRef.current?.scrollTo({
175
+ y: Math.max(0, activePoint.y - scale(250)),
176
+ animated: true,
177
+ });
178
+ }, 300);
179
+ }
180
+ }, [activePoint, scrollReady]);
181
+
182
+ // Dynamic curve string builder
183
+ const buildCurveD = () => {
184
+ if (points.length < 2) return '';
185
+ let d = `M ${points[0].x} ${points[0].y}`;
186
+ for (let i = 0; i < points.length - 1; i++) {
187
+ const p0 = points[i];
188
+ const p1 = points[i + 1];
189
+ const cp1x = p0.x;
190
+ const cp1y = (p0.y + p1.y) / 2;
191
+ const cp2x = p1.x;
192
+ const cp2y = (p0.y + p1.y) / 2;
193
+ d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p1.x} ${p1.y}`;
194
+ }
195
+ return d;
196
+ };
197
+
198
+ const pathD = buildCurveD();
199
+
200
+ // Animation values interpolation
201
+ const pulseScale = pulseAnim.interpolate({
202
+ inputRange: [0, 1],
203
+ outputRange: [1, 1.7],
204
+ });
205
+
206
+ const pulseOpacity = pulseAnim.interpolate({
207
+ inputRange: [0, 1],
208
+ outputRange: [0.6, 0],
209
+ });
210
+
211
+ const bobTranslateY = bobAnim.interpolate({
212
+ inputRange: [0, 1],
213
+ outputRange: [0, -6],
214
+ });
215
+
216
+ // Calculate plane placement & rotation angle
217
+ let planeX = canvasWidth / 2;
218
+ let planeY = totalHeight / 2;
219
+ let planeAngle = -45;
220
+ let showPlane = false;
221
+
222
+ if (activeIndex < points.length - 1 && points[activeIndex + 1]) {
223
+ const curr = activePoint;
224
+ const next = points[activeIndex + 1];
225
+ // Position halfway along the straight projection between nodes
226
+ planeX = (curr.x + next.x) / 2;
227
+ planeY = (curr.y + next.y) / 2;
228
+ // Calculate rotation angle (facing next country)
229
+ const dx = next.x - curr.x;
230
+ const dy = next.y - curr.y;
231
+ planeAngle = Math.atan2(dy, dx) * (180 / Math.PI) + 90; // Align forward
232
+ showPlane = true;
233
+ } else if (activeIndex > 0 && points[activeIndex - 1]) {
234
+ // Fallback if we are already at the final station (no next station)
235
+ const prev = points[activeIndex - 1];
236
+ const curr = activePoint;
237
+ planeX = (prev.x + curr.x) / 2;
238
+ planeY = (prev.y + curr.y) / 2;
239
+ const dx = curr.x - prev.x;
240
+ const dy = curr.y - prev.y;
241
+ planeAngle = Math.atan2(dy, dx) * (180 / Math.PI) + 90;
242
+ showPlane = true;
243
+ }
244
+
245
+ // Draw grid lines
246
+ const renderGrid = () => {
247
+ const lines = [];
248
+ const step = scale(48);
249
+ for (let i = 0; i < totalHeight; i += step) {
250
+ lines.push(
251
+ <Line
252
+ key={`h-grid-${i}`}
253
+ x1={0}
254
+ y1={i}
255
+ x2={canvasWidth}
256
+ y2={i}
257
+ stroke={theme.colors.brown[100]}
258
+ strokeWidth={1}
259
+ strokeDasharray="4, 4"
260
+ opacity={0.45}
261
+ />
262
+ );
263
+ }
264
+ for (let i = 0; i < canvasWidth; i += step) {
265
+ lines.push(
266
+ <Line
267
+ key={`v-grid-${i}`}
268
+ x1={i}
269
+ y1={0}
270
+ x2={i}
271
+ y2={totalHeight}
272
+ stroke={theme.colors.brown[100]}
273
+ strokeWidth={1}
274
+ strokeDasharray="4, 4"
275
+ opacity={0.45}
276
+ />
277
+ );
278
+ }
279
+ return lines;
280
+ };
281
+
282
+ return (
283
+ <View
284
+ onLayout={(e) => {
285
+ const w = e.nativeEvent.layout.width;
286
+ if (w > 0) setContainerWidth(w);
287
+ }}
288
+ style={[
289
+ styles.outerContainer,
290
+ {
291
+ width: '100%',
292
+ },
293
+ height !== undefined ? { height: height as any } : { flex: 1 },
294
+ style,
295
+ ]}
296
+ >
297
+ {/* Header Container */}
298
+ <View style={styles.header}>
299
+ <View style={styles.headerLeft}>
300
+ <BackButton onPress={onBackPress} variant="default" />
301
+ <View style={styles.headerText}>
302
+ <Typography style={[styles.headerTitle, { fontSize: scaleText(headerTitleSize) }]}>
303
+ {title.toUpperCase()}
304
+ </Typography>
305
+ <Typography style={[styles.headerSubtitle, { fontSize: scaleText(headerSubtitleSize) }]}>
306
+ {subtitle ?? `${stations.length} paragens · A1 → C1`}
307
+ </Typography>
308
+ </View>
309
+ </View>
310
+ <TouchableOpacity
311
+ activeOpacity={0.85}
312
+ onPress={onFlightPress}
313
+ style={[styles.flightBtn, { borderRadius: scale(14), paddingHorizontal: scale(16), height: scale(40) }]}
314
+ >
315
+ <FlightIcon color="#ffffff" size={scale(16)} />
316
+ <Typography style={[styles.flightBtnText, { fontSize: scaleText(12) }]}>VOO</Typography>
317
+ </TouchableOpacity>
318
+ </View>
319
+
320
+ {/* Main Canvas Scroll Area */}
321
+ <View style={styles.canvasWrapper}>
322
+ <ScrollView
323
+ ref={scrollViewRef}
324
+ style={styles.scrollView}
325
+ showsVerticalScrollIndicator={false}
326
+ contentContainerStyle={[styles.scrollContent, { height: totalHeight }]}
327
+ onLayout={() => setScrollReady(true)}
328
+ >
329
+ {/* SVG Map Canvas Layer */}
330
+ <Svg style={StyleSheet.absoluteFill} width={canvasWidth} height={totalHeight}>
331
+ {/* Grid Overlay */}
332
+ {renderGrid()}
333
+
334
+ {/* Continent Shadows/Outlines (Beige Outlines) */}
335
+ {points.map((p, idx) => (
336
+ <React.Fragment key={`land-sh-${idx}`}>
337
+ <Circle cx={p.x} cy={p.y} r={scale(nodeSize * 1.25)} fill={theme.colors.brown[100]} />
338
+ {idx < points.length - 1 && (
339
+ <Line
340
+ x1={p.x}
341
+ y1={p.y}
342
+ x2={points[idx + 1].x}
343
+ y2={points[idx + 1].y}
344
+ stroke={theme.colors.brown[100]}
345
+ strokeWidth={scale(nodeSize * 1.5)}
346
+ strokeLinecap="round"
347
+ />
348
+ )}
349
+ </React.Fragment>
350
+ ))}
351
+
352
+ {/* Continent Surface Fills (Inner Warm Cream) */}
353
+ {points.map((p, idx) => (
354
+ <React.Fragment key={`land-surf-${idx}`}>
355
+ <Circle
356
+ cx={p.x}
357
+ cy={p.y}
358
+ r={scale(nodeSize * 1.25) - 3}
359
+ fill={theme.colors.brown[50]}
360
+ />
361
+ {idx < points.length - 1 && (
362
+ <Line
363
+ x1={p.x}
364
+ y1={p.y}
365
+ x2={points[idx + 1].x}
366
+ y2={points[idx + 1].y}
367
+ stroke={theme.colors.brown[50]}
368
+ strokeWidth={scale(nodeSize * 1.5) - 6}
369
+ strokeLinecap="round"
370
+ />
371
+ )}
372
+ </React.Fragment>
373
+ ))}
374
+
375
+ {/* Small archipelago decor islands */}
376
+ {points.map((p, idx) => {
377
+ if (idx % 2 === 0) {
378
+ return (
379
+ <React.Fragment key={`decor-${idx}`}>
380
+ <Circle
381
+ cx={p.x + scale(nodeSize * 1.5)}
382
+ cy={p.y - scale(nodeSize * 0.4)}
383
+ r={scale(10)}
384
+ fill={theme.colors.brown[50]}
385
+ stroke={theme.colors.brown[100]}
386
+ strokeWidth={2}
387
+ />
388
+ <Circle
389
+ cx={p.x - scale(nodeSize * 1.4)}
390
+ cy={p.y + scale(nodeSize * 0.8)}
391
+ r={scale(6)}
392
+ fill={theme.colors.brown[50]}
393
+ stroke={theme.colors.brown[100]}
394
+ strokeWidth={1.5}
395
+ />
396
+ </React.Fragment>
397
+ );
398
+ }
399
+ return null;
400
+ })}
401
+
402
+ {/* Dotted path connector */}
403
+ {pathD !== '' && (
404
+ <Path
405
+ d={pathD}
406
+ fill="none"
407
+ stroke={theme.colors.primary}
408
+ strokeWidth={scale(4)}
409
+ strokeDasharray="6, 6"
410
+ strokeLinecap="round"
411
+ />
412
+ )}
413
+ </Svg>
414
+
415
+ {/* Canvas Interactive Overlays */}
416
+ {/* Active Node Pulse Ring */}
417
+ <Animated.View
418
+ style={[
419
+ styles.pulseRing,
420
+ {
421
+ left: activePoint.x - scale((nodeSize * 1.4) / 2),
422
+ top: activePoint.y - scale((nodeSize * 1.4) / 2),
423
+ width: scale(nodeSize * 1.4),
424
+ height: scale(nodeSize * 1.4),
425
+ borderRadius: scale((nodeSize * 1.4) / 2),
426
+ borderColor: theme.colors.primary,
427
+ borderWidth: scale(3.5),
428
+ transform: [{ scale: pulseScale }],
429
+ opacity: pulseOpacity,
430
+ },
431
+ ]}
432
+ />
433
+
434
+ {/* Moving Airplane Bobbing Overlay */}
435
+ {showPlane && (
436
+ <Animated.View
437
+ style={[
438
+ styles.planeWrapper,
439
+ {
440
+ left: planeX - scale(14),
441
+ top: planeY - scale(14),
442
+ transform: [{ rotate: `${planeAngle}deg` }, { translateY: bobTranslateY }],
443
+ },
444
+ ]}
445
+ >
446
+ <FlightIcon color={theme.colors.brown[800]} size={scale(28)} />
447
+ </Animated.View>
448
+ )}
449
+
450
+ {/* Station Node Anchors */}
451
+ {points.map((p, idx) => {
452
+ const isVisited = p.status === 'visited';
453
+ const isCurrent = p.status === 'current';
454
+ const isLocked = p.status === 'locked';
455
+
456
+ let nodeBorderColor = theme.colors.brown[100];
457
+ let badgeBgColor = theme.colors.brown[400];
458
+ let shadowStyle = styles.nodeLockedShadow;
459
+
460
+ if (isVisited) {
461
+ nodeBorderColor = theme.colors.success;
462
+ badgeBgColor = theme.colors.success;
463
+ shadowStyle = styles.nodeVisitedShadow;
464
+ } else if (isCurrent) {
465
+ nodeBorderColor = theme.colors.primary;
466
+ badgeBgColor = theme.colors.primary;
467
+ shadowStyle = styles.nodeCurrentShadow;
468
+ }
469
+
470
+ return (
471
+ <View
472
+ key={`node-${p.id}`}
473
+ style={[
474
+ styles.nodeContainer,
475
+ {
476
+ left: p.x - scale(nodeSize / 2),
477
+ top: p.y - scale(nodeSize / 2),
478
+ width: scale(nodeSize),
479
+ height: scale(nodeSize),
480
+ },
481
+ ]}
482
+ >
483
+ <TouchableOpacity
484
+ activeOpacity={0.8}
485
+ onPress={() => onStationPress?.(p)}
486
+ disabled={isLocked}
487
+ style={[
488
+ styles.nodeCircle,
489
+ shadowStyle,
490
+ {
491
+ width: scale(nodeSize),
492
+ height: scale(nodeSize),
493
+ borderRadius: scale(nodeSize / 2),
494
+ borderWidth: scale(3),
495
+ borderColor: nodeBorderColor,
496
+ opacity: isLocked ? 0.65 : 1,
497
+ },
498
+ ]}
499
+ >
500
+ <Flag countryCode={p.countryCode} size={scale(nodeSize - 6)} />
501
+ </TouchableOpacity>
502
+
503
+ {/* Step Index Number Badge */}
504
+ <View
505
+ style={[
506
+ styles.indexBadge,
507
+ {
508
+ backgroundColor: badgeBgColor,
509
+ width: scale(18),
510
+ height: scale(18),
511
+ borderRadius: scale(9),
512
+ top: -scale(3),
513
+ right: -scale(3),
514
+ },
515
+ ]}
516
+ >
517
+ <Typography style={[styles.indexBadgeText, { fontSize: scaleText(9) }]}>
518
+ {idx + 1}
519
+ </Typography>
520
+ </View>
521
+
522
+ {/* Node Text Label Card */}
523
+ <View
524
+ style={[
525
+ isCurrent ? styles.activeLabelBox : styles.labelBox,
526
+ {
527
+ top: scale(nodeSize + 4),
528
+ borderRadius: scale(10),
529
+ paddingHorizontal: scale(10),
530
+ paddingVertical: scale(4),
531
+ },
532
+ ]}
533
+ >
534
+ <Typography
535
+ numberOfLines={1}
536
+ style={[
537
+ isCurrent ? styles.activeLabelText : styles.labelText,
538
+ { fontSize: scaleText(11.5) },
539
+ ]}
540
+ >
541
+ {p.label}{' '}
542
+ <Typography style={{ opacity: isCurrent ? 0.9 : 0.6, fontSize: scaleText(10) }}>
543
+ {p.levelCode}
544
+ </Typography>
545
+ {isCurrent && ' · AQUI'}
546
+ </Typography>
547
+ </View>
548
+ </View>
549
+ );
550
+ })}
551
+ </ScrollView>
552
+
553
+ {/* Floating Legend Panel */}
554
+ <View
555
+ style={[
556
+ styles.legendCard,
557
+ {
558
+ borderRadius: scale(16),
559
+ padding: scale(12),
560
+ bottom: scale(112),
561
+ },
562
+ ]}
563
+ >
564
+ <View style={styles.legendRow}>
565
+ <View style={[styles.legendDot, { backgroundColor: theme.colors.success }]} />
566
+ <Typography style={[styles.legendText, { fontSize: scaleText(11) }]}>visitado · rever</Typography>
567
+ </View>
568
+ <View style={styles.legendRow}>
569
+ <View style={[styles.legendDot, { backgroundColor: theme.colors.primary }]} />
570
+ <Typography style={[styles.legendText, { fontSize: scaleText(11) }]}>estás aqui</Typography>
571
+ </View>
572
+ <View style={styles.legendRow}>
573
+ <View style={[styles.legendDot, { backgroundColor: theme.colors.brown[300] }]} />
574
+ <Typography style={[styles.legendText, { fontSize: scaleText(11) }]}>bloqueado</Typography>
575
+ </View>
576
+ </View>
577
+
578
+ {/* Floating Continue CTA Button */}
579
+ <View
580
+ style={[
581
+ styles.floatingFooter,
582
+ {
583
+ left: scale(16),
584
+ right: scale(16),
585
+ bottom: scale(16),
586
+ },
587
+ ]}
588
+ >
589
+ <JourneyButton
590
+ title="Continuar a viagem"
591
+ subtitle={`ESTÁS EM ${activeStation.label} · ${activeStation.levelCode}`}
592
+ iconName="Play"
593
+ variant="primary"
594
+ onPress={() => onContinuePress?.(activeStation)}
595
+ />
596
+ </View>
597
+ </View>
598
+ </View>
599
+ );
600
+ };
601
+
602
+ const styles = StyleSheet.create({
603
+ outerContainer: {
604
+ backgroundColor: '#fbf6f0', // Soft cream background
605
+ // ✅ FIX: removed height: '100%' — flex: 1 is applied inline and
606
+ // height: '100%' was conflicting with it, causing the component
607
+ // to overflow past the parent container (e.g. tab navigation).
608
+ flexDirection: 'column',
609
+ alignSelf: 'center',
610
+ overflow: 'hidden',
611
+ },
612
+ header: {
613
+ backgroundColor: '#fbf6f0',
614
+ flexDirection: 'row',
615
+ alignItems: 'center',
616
+ justifyContent: 'space-between',
617
+ paddingHorizontal: 20,
618
+ paddingTop: 16,
619
+ paddingBottom: 16,
620
+ borderBottomWidth: 1.5,
621
+ borderBottomColor: '#efe2d6',
622
+ },
623
+ headerLeft: {
624
+ flexDirection: 'row',
625
+ alignItems: 'center',
626
+ gap: 12,
627
+ },
628
+ headerText: {
629
+ justifyContent: 'center',
630
+ },
631
+ headerTitle: {
632
+
633
+ color: theme.colors.brown[500],
634
+ letterSpacing: 0.6,
635
+ },
636
+ headerSubtitle: {
637
+
638
+ color: theme.colors.brown[800],
639
+ },
640
+ flightBtn: {
641
+ flexDirection: 'row',
642
+ alignItems: 'center',
643
+ justifyContent: 'center',
644
+ gap: 8,
645
+ backgroundColor: theme.colors.brown[800],
646
+ ...Platform.select({
647
+ web: { boxShadow: `0px 4px 0px ${theme.colors.brown[900]}` },
648
+ default: {
649
+ shadowColor: theme.colors.brown[900],
650
+ shadowOffset: { width: 0, height: 4 },
651
+ shadowOpacity: 1,
652
+ shadowRadius: 0,
653
+ elevation: 3,
654
+ },
655
+ }),
656
+ },
657
+ flightBtnText: {
658
+
659
+ color: '#ffffff',
660
+ letterSpacing: 0.5,
661
+ },
662
+ canvasWrapper: {
663
+ flex: 1,
664
+ position: 'relative',
665
+ overflow: 'hidden',
666
+ },
667
+ scrollContent: {
668
+ position: 'relative',
669
+ width: '100%',
670
+ },
671
+ pulseRing: {
672
+ position: 'absolute',
673
+ alignItems: 'center',
674
+ justifyContent: 'center',
675
+ },
676
+ planeWrapper: {
677
+ position: 'absolute',
678
+ zIndex: 10,
679
+ },
680
+ nodeContainer: {
681
+ position: 'absolute',
682
+ alignItems: 'center',
683
+ justifyContent: 'center',
684
+ zIndex: 5,
685
+ },
686
+ nodeCircle: {
687
+ alignItems: 'center',
688
+ justifyContent: 'center',
689
+ backgroundColor: '#ffffff',
690
+ ...Platform.select({
691
+ web: { cursor: 'pointer' },
692
+ }),
693
+ },
694
+ nodeVisitedShadow: {
695
+ ...Platform.select({
696
+ web: { boxShadow: `0px 4px 0px ${theme.colors.success}` },
697
+ default: {
698
+ shadowColor: theme.colors.success,
699
+ shadowOffset: { width: 0, height: 4 },
700
+ shadowOpacity: 1,
701
+ shadowRadius: 0,
702
+ elevation: 3,
703
+ },
704
+ }),
705
+ },
706
+ nodeCurrentShadow: {
707
+ ...Platform.select({
708
+ web: { boxShadow: `0px 4px 0px ${theme.colors.primary}` },
709
+ default: {
710
+ shadowColor: theme.colors.primary,
711
+ shadowOffset: { width: 0, height: 4 },
712
+ shadowOpacity: 1,
713
+ shadowRadius: 0,
714
+ elevation: 4,
715
+ },
716
+ }),
717
+ },
718
+ nodeLockedShadow: {
719
+ ...Platform.select({
720
+ web: { boxShadow: `0px 4px 0px ${theme.colors.brown[100]}` },
721
+ default: {
722
+ shadowColor: theme.colors.brown[100],
723
+ shadowOffset: { width: 0, height: 4 },
724
+ shadowOpacity: 1,
725
+ shadowRadius: 0,
726
+ elevation: 1,
727
+ },
728
+ }),
729
+ },
730
+ indexBadge: {
731
+ position: 'absolute',
732
+ alignItems: 'center',
733
+ justifyContent: 'center',
734
+ zIndex: 8,
735
+ },
736
+ indexBadgeText: {
737
+
738
+ color: '#ffffff',
739
+ textAlign: 'center',
740
+ },
741
+ labelBox: {
742
+ position: 'absolute',
743
+ backgroundColor: '#ffffff',
744
+ borderWidth: 1.5,
745
+ borderColor: '#efe2d6',
746
+ alignSelf: 'center',
747
+ alignItems: 'center',
748
+ justifyContent: 'center',
749
+ ...Platform.select({
750
+ web: { boxShadow: '0px 2px 4px rgba(81, 51, 45, 0.06)' },
751
+ default: {
752
+ shadowColor: '#51332d',
753
+ shadowOffset: { width: 0, height: 2 },
754
+ shadowOpacity: 0.06,
755
+ shadowRadius: 4,
756
+ elevation: 1,
757
+ },
758
+ }),
759
+ },
760
+ labelText: {
761
+
762
+ color: theme.colors.brown[600],
763
+ textAlign: 'center',
764
+ },
765
+ activeLabelBox: {
766
+ position: 'absolute',
767
+ backgroundColor: theme.colors.brown[800],
768
+ alignSelf: 'center',
769
+ alignItems: 'center',
770
+ justifyContent: 'center',
771
+ },
772
+ activeLabelText: {
773
+
774
+ color: '#ffffff',
775
+ textAlign: 'center',
776
+ },
777
+ legendCard: {
778
+ position: 'absolute',
779
+ left: 16,
780
+ bottom: 16,
781
+ backgroundColor: 'rgba(255, 255, 255, 0.9)',
782
+ borderWidth: 1.5,
783
+ borderColor: '#efe2d6',
784
+ zIndex: 10,
785
+ gap: 8,
786
+ ...Platform.select({
787
+ web: { boxShadow: '0px 4px 12px rgba(81, 51, 45, 0.08)' },
788
+ default: {
789
+ shadowColor: '#51332d',
790
+ shadowOffset: { width: 0, height: 4 },
791
+ shadowOpacity: 0.08,
792
+ shadowRadius: 12,
793
+ elevation: 2,
794
+ },
795
+ }),
796
+ },
797
+ legendRow: {
798
+ flexDirection: 'row',
799
+ alignItems: 'center',
800
+ gap: 8,
801
+ },
802
+ legendDot: {
803
+ width: 8,
804
+ height: 8,
805
+ borderRadius: 4,
806
+ },
807
+ legendText: {
808
+
809
+ color: theme.colors.brown[600],
810
+ },
811
+ footer: {
812
+ backgroundColor: '#fbf6f0',
813
+ borderTopWidth: 1.5,
814
+ borderTopColor: '#efe2d6',
815
+ padding: 16,
816
+ zIndex: 12,
817
+ },
818
+ scrollView: {
819
+ flex: 1,
820
+ width: '100%',
821
+ },
822
+ floatingFooter: {
823
+ position: 'absolute',
824
+ backgroundColor: 'transparent',
825
+ zIndex: 12,
826
+ },
827
+ });