related-ui-components 1.8.3 → 1.8.5

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.
@@ -165,6 +165,14 @@ const RedeemedVoucherSheet: React.FC<Props> = (props) => {
165
165
  onCopyCode(code);
166
166
  }
167
167
  };
168
+ const closeBottomSheet = useCallback(
169
+ () => {
170
+ bottomSheetRef.current?.close();
171
+ },
172
+ [
173
+ /* onClose */
174
+ ]
175
+ );
168
176
 
169
177
  // Custom backdrop component
170
178
  const renderBackdrop = useCallback(
@@ -346,7 +354,10 @@ const RedeemedVoucherSheet: React.FC<Props> = (props) => {
346
354
  {onMyVouchersButtonPress && (
347
355
  <TouchableOpacity
348
356
  style={styles.vouchersButton}
349
- onPress={onMyVouchersButtonPress}
357
+ onPress={() => {
358
+ closeBottomSheet();
359
+ onMyVouchersButtonPress()
360
+ }}
350
361
  >
351
362
  <LinearGradient
352
363
  colors={primaryButtonColors as any}
@@ -1,4 +1,9 @@
1
- import React, { useState, useRef, useEffect } from "react";
1
+ import React, {
2
+ useState,
3
+ useRef,
4
+ useEffect,
5
+ useMemo, // Added useMemo for wheelPaths
6
+ } from "react";
2
7
  import {
3
8
  View,
4
9
  StyleSheet,
@@ -15,56 +20,46 @@ export interface SpinWheelItem {
15
20
  id: string | number;
16
21
  label: string;
17
22
  value?: any;
18
- color: string;
23
+ color: string; // Expect color to be provided, or handle default more explicitly
19
24
  textColor?: string;
20
25
  }
21
26
 
22
- //default random colors
23
- const colors = [
24
- "#FF0000", // Red
25
- "#FFA500", // Orange
26
- "#FFFF00", // Yellow
27
- "#008000", // Green
28
- "#0000FF", // Blue
29
- "#800080", // Purple
30
- "#FFC0CB", // Pink
31
- "#00FFFF", // Cyan
32
- "#FF00FF", // Magenta
33
- "#00FF00", // Lime
34
- "#4B0082", // Indigo
35
- "#EE82EE", // Violet
36
- "#40E0D0", // Turquoise
37
- "#FFD700", // Gold
38
- "#C0C0C0", // Silver
39
- "#FFDAB9", // Peach
40
- "#E6E6FA", // Lavender
41
- "#008080", // Teal
42
- "#FF7F50", // Coral
43
- "#DC143C", // Crimson
44
- "#87CEEB", // Sky Blue
45
- "#7FFF00", // Chartreuse
46
- "#CCCCFF", // Periwinkle
47
- "#FF6347", // Tomato
48
- "#FA8072" // Salmon
27
+ // Default random colors (can be used if item.color is not provided)
28
+ const defaultColors = [
29
+ "#FF0000",
30
+ "#FFA500",
31
+ "#FFFF00",
32
+ "#008000",
33
+ "#0000FF",
34
+ "#800080",
35
+ "#FFC0CB",
36
+ "#00FFFF",
37
+ "#FF00FF",
38
+ "#00FF00",
39
+ "#4B0082",
40
+ "#EE82EE",
41
+ "#40E0D0",
42
+ "#FFD700",
43
+ "#C0C0C0",
44
+ "#FFDAB9",
45
+ "#E6E6FA",
46
+ "#008080",
47
+ "#FF7F50",
48
+ "#DC143C",
49
+ "#87CEEB",
50
+ "#7FFF00",
51
+ "#CCCCFF",
52
+ "#FF6347",
53
+ "#FA8072",
49
54
  ];
50
55
 
51
56
  interface SpinWheelProps {
52
- // Data
53
57
  items: SpinWheelItem[];
54
-
55
- // Dimensions
56
58
  size?: number;
57
-
58
- // Behavior
59
59
  spinDuration?: number;
60
- friction?: number;
61
60
  enabled?: boolean;
62
-
63
- // Events
64
61
  onSpinStart?: () => void;
65
62
  onSpinEnd?: (item: SpinWheelItem) => void;
66
-
67
- // Styling
68
63
  containerStyle?: ViewStyle;
69
64
  centerStyle?: ViewStyle;
70
65
  spinButtonText?: string;
@@ -72,14 +67,10 @@ interface SpinWheelProps {
72
67
  knobStyle?: ViewStyle;
73
68
  actionButtonStyle?: ViewStyle;
74
69
  actionButtonTextStyle?: TextStyle;
75
-
76
- // Custom colors
77
- wheelBorderColor?: string;
78
70
  wheelTextColor?: string;
79
71
  knobColor?: string;
80
-
81
- // Custom components
82
72
  centerComponent?: React.ReactNode;
73
+ predeterminedWinnerId?: string | number | null; // New prop
83
74
  }
84
75
 
85
76
  const SpinWheel: React.FC<SpinWheelProps> = ({
@@ -98,127 +89,188 @@ const SpinWheel: React.FC<SpinWheelProps> = ({
98
89
  actionButtonStyle,
99
90
  actionButtonTextStyle,
100
91
  wheelTextColor = "#FFFFFF",
92
+ centerComponent,
93
+ predeterminedWinnerId = null, // Default to null
101
94
  }) => {
102
- const wheelItems = items.length > 0 ? items : [];
95
+ const wheelItems = useMemo(
96
+ () => (items && items.length > 0 ? items : []),
97
+ [items]
98
+ );
103
99
 
104
100
  const [spinning, setSpinning] = useState(false);
105
- const [_, setWinner] = useState<SpinWheelItem | null>(null);
101
+ const [_winnerState, setWinnerState] = useState<SpinWheelItem | null>(null); // Renamed to avoid conflict
106
102
  const rotateValue = useRef(new Animated.Value(0)).current;
103
+ const rotationRef = useRef(0); // Tracks cumulative rotation in "number of turns"
107
104
 
108
- // Track rotation manually for calculations
109
- const rotationRef = useRef(0);
110
-
111
- // Update tracked rotation when animation completes
112
105
  useEffect(() => {
113
- const listener = rotateValue.addListener(({ value }) => {
106
+ const listenerId = rotateValue.addListener(({ value }) => {
114
107
  rotationRef.current = value;
115
108
  });
116
-
117
109
  return () => {
118
- rotateValue.removeListener(listener);
110
+ rotateValue.removeListener(listenerId);
119
111
  };
120
112
  }, [rotateValue]);
121
113
 
122
- // Calculate angle for each segment
123
- const anglePerItem = 360 / wheelItems.length;
114
+ const anglePerItem =
115
+ wheelItems.length > 0 ? 360 / wheelItems.length : 0;
124
116
 
125
- // Create wheel segments
126
- const generateWheelPaths = () => {
117
+ const wheelPaths = useMemo(() => {
118
+ if (wheelItems.length === 0) return [];
127
119
  return wheelItems.map((item, index) => {
128
120
  const startAngle = index * anglePerItem;
129
121
  const endAngle = (index + 1) * anglePerItem;
130
-
131
- // Calculate path for segment
132
122
  const startRad = (startAngle * Math.PI) / 180;
133
123
  const endRad = (endAngle * Math.PI) / 180;
134
-
135
124
  const x1 = size / 2 + (size / 2) * Math.cos(startRad);
136
125
  const y1 = size / 2 + (size / 2) * Math.sin(startRad);
137
126
  const x2 = size / 2 + (size / 2) * Math.cos(endRad);
138
127
  const y2 = size / 2 + (size / 2) * Math.sin(endRad);
139
-
140
- const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
141
-
128
+ const largeArcFlag = anglePerItem <= 180 ? "0" : "1";
142
129
  const pathData = [
143
130
  `M ${size / 2} ${size / 2}`,
144
131
  `L ${x1} ${y1}`,
145
132
  `A ${size / 2} ${size / 2} 0 ${largeArcFlag} 1 ${x2} ${y2}`,
146
133
  "Z",
147
134
  ].join(" ");
148
-
149
- // Calculate coordinates for text and decoration
150
- const midRad = ((startAngle + endAngle) / 2) * (Math.PI / 180);
135
+ const midAngle = startAngle + anglePerItem / 2;
136
+ const midRad = (midAngle * Math.PI) / 180;
151
137
  const textX = size / 2 + size * 0.32 * Math.cos(midRad);
152
138
  const textY = size / 2 + size * 0.32 * Math.sin(midRad);
153
-
154
- const decorationX = size / 2 + size * 0.43 * Math.cos(midRad);
155
- const decorationY = size / 2 + size * 0.43 * Math.sin(midRad);
156
-
157
139
  return {
158
140
  path: pathData,
159
141
  item,
160
142
  textX,
161
143
  textY,
162
- decorationX,
163
- decorationY,
164
- angle: (startAngle + endAngle) / 2,
144
+ angle: midAngle,
145
+ color: item.color || defaultColors[index % defaultColors.length],
165
146
  };
166
147
  });
167
- };
168
-
169
- const wheelPaths = generateWheelPaths();
148
+ }, [wheelItems, anglePerItem, size]);
170
149
 
171
- // Handle spin button press
172
150
  const handleSpin = () => {
173
- if (spinning || !enabled) return;
151
+ if (spinning || !enabled || wheelItems.length === 0 || anglePerItem === 0) {
152
+ return;
153
+ }
174
154
 
175
155
  setSpinning(true);
176
156
  onSpinStart?.();
177
157
 
178
- // Random number of spins (3-5 full rotations) plus random angle
179
- const randomSpins = 3 + Math.random() * 2;
180
- const randomAngle = Math.random() * 360;
181
- const targetRotation = 360 * randomSpins + randomAngle;
158
+ let targetAnimationToValue: number;
159
+ let winnerToAnnounce: SpinWheelItem | null = null;
160
+
161
+ const currentWinnerId = predeterminedWinnerId; // Capture prop value at spin start
162
+ let targetWinnerItem: SpinWheelItem | null = null;
163
+
164
+ if (currentWinnerId != null) {
165
+ const foundItem = wheelItems.find((item) => item.id === currentWinnerId);
166
+ if (foundItem) {
167
+ targetWinnerItem = foundItem;
168
+ } else {
169
+ console.warn(
170
+ `SpinWheel: Predetermined winner with id "${currentWinnerId}" not found. Spinning randomly.`
171
+ );
172
+ }
173
+ }
174
+
175
+ if (targetWinnerItem) {
176
+ winnerToAnnounce = targetWinnerItem;
177
+ const winnerIndex = wheelItems.indexOf(targetWinnerItem);
178
+
179
+ // Calculate the middle angle of the target segment within the wheel's coordinate system
180
+ const targetSegmentMidAngle_deg = (winnerIndex + 0.5) * anglePerItem;
181
+
182
+ // The pointer is at 270 degrees (top). We want the targetSegmentMidAngle_deg
183
+ // to align with this pointer.
184
+ // If the wheel rotates by R, a point A on wheel is at (A+R)%360.
185
+ // So, (targetSegmentMidAngle_deg + R) % 360 = 270.
186
+ // R % 360 = (270 - targetSegmentMidAngle_deg + 360*k) % 360.
187
+ // This R % 360 is the target orientation for the wheel's 0-degree mark.
188
+ const targetWheelOrientation_deg =
189
+ (270 - targetSegmentMidAngle_deg + 360 * 10) % 360; // *10 to ensure positive
190
+
191
+ const currentWheelOrientation_deg = (rotationRef.current * 360) % 360;
192
+
193
+ // Additional rotation needed to reach the target orientation (shortest positive path)
194
+ const rotationToTarget_deg =
195
+ (targetWheelOrientation_deg - currentWheelOrientation_deg + 360) % 360;
196
+
197
+ const numFullRotations = 3 + Math.floor(Math.random() * 3); // 3 to 5 full spins
198
+ const totalAdditionalRotation_deg =
199
+ numFullRotations * 360 + rotationToTarget_deg;
200
+
201
+ targetAnimationToValue =
202
+ rotationRef.current + totalAdditionalRotation_deg / 360;
203
+ } else {
204
+ // Fallback to original random spin logic
205
+ const randomSpins = 3 + Math.random() * 2; // 3-5 full rotations
206
+ const randomAngleOffset = Math.random() * 360;
207
+ const totalRandomRotation_deg = 360 * randomSpins + randomAngleOffset;
208
+ targetAnimationToValue =
209
+ rotationRef.current + totalRandomRotation_deg / 360;
210
+ }
182
211
 
183
212
  Animated.timing(rotateValue, {
184
- toValue: rotationRef.current + targetRotation / 360,
213
+ toValue: targetAnimationToValue,
185
214
  duration: spinDuration,
186
215
  easing: Easing.out(Easing.cubic),
187
216
  useNativeDriver: true,
188
- }).start(() => handleSpinEnd());
189
- };
190
-
191
- // Handle spin completion
192
- const handleSpinEnd = () => {
193
- setSpinning(false);
194
-
195
- // Calculate winning segment based on final rotation with pointer at 270 degrees
196
- const normalizedAngle = (rotationRef.current * 360) % 360;
197
- // Adjust calculation to account for pointer at 270 degrees (instead of 0)
198
- const winningIndex = Math.floor(
199
- ((normalizedAngle - 270) % 360) / anglePerItem
200
- );
201
- const adjustedIndex =
202
- (wheelItems.length - 1 - winningIndex) % wheelItems.length;
203
- const winningItem =
204
- wheelItems[
205
- adjustedIndex >= 0 ? adjustedIndex : wheelItems.length + adjustedIndex
206
- ];
207
-
208
- setWinner(winningItem);
209
- onSpinEnd?.(winningItem);
217
+ }).start(() => {
218
+ setSpinning(false);
219
+
220
+ if (winnerToAnnounce) {
221
+ setWinnerState(winnerToAnnounce);
222
+ onSpinEnd?.(winnerToAnnounce);
223
+ } else {
224
+ // Calculate winner from random spin based on final rotation
225
+ const finalRotationDegrees = rotationRef.current * 360;
226
+ const pointerFixedAt_deg = 270; // Pointer is at the top
227
+
228
+ // Determine which original angle on the wheel is now under the pointer
229
+ const angleUnderPointer_orig =
230
+ (pointerFixedAt_deg - finalRotationDegrees + 360 * 10) % 360; // *10 for positive
231
+
232
+ let winningSegmentIndex = Math.floor(
233
+ angleUnderPointer_orig / anglePerItem
234
+ );
235
+ // Ensure index is within bounds
236
+ winningSegmentIndex = Math.max(
237
+ 0,
238
+ Math.min(wheelItems.length - 1, winningSegmentIndex)
239
+ );
240
+
241
+ const calculatedWinner = wheelItems[winningSegmentIndex];
242
+
243
+ if (calculatedWinner) {
244
+ setWinnerState(calculatedWinner);
245
+ onSpinEnd?.(calculatedWinner);
246
+ } else if (wheelItems.length > 0) {
247
+ // Fallback, should not happen if items exist and calculation is correct
248
+ console.error(
249
+ "SpinWheel: Could not determine winner from random spin. Falling back to first item."
250
+ );
251
+ setWinnerState(wheelItems[0]);
252
+ onSpinEnd?.(wheelItems[0]);
253
+ }
254
+ }
255
+ });
210
256
  };
211
257
 
212
- // Animation interpolation for rotation
213
- const rotate = rotateValue.interpolate({
258
+ const rotateStyle = rotateValue.interpolate({
214
259
  inputRange: [0, 1],
215
260
  outputRange: ["0deg", "360deg"],
216
261
  });
217
262
 
263
+ if (wheelItems.length === 0) {
264
+ return (
265
+ <View style={[styles.container, containerStyle, { height: size }]}>
266
+ <Text>No items to display in the wheel.</Text>
267
+ </View>
268
+ );
269
+ }
270
+
218
271
  return (
219
272
  <View style={[styles.container, containerStyle]}>
220
- <View style={{ width: size, height: size }}>
221
- {/* The wheel */}
273
+ <View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
222
274
  <Animated.View
223
275
  style={[
224
276
  styles.wheelContainer,
@@ -226,93 +278,66 @@ const SpinWheel: React.FC<SpinWheelProps> = ({
226
278
  width: size,
227
279
  height: size,
228
280
  borderRadius: size / 2,
229
- transform: [{ rotate }],
281
+ transform: [{ rotate: rotateStyle }],
230
282
  },
231
283
  ]}
232
284
  >
233
285
  <Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
234
286
  <G>
235
- {wheelPaths.map(
236
- (
237
- { path, item, textX, textY, decorationX, decorationY, angle },
238
- index
239
- ) => {
240
- return (
241
- <React.Fragment key={item.id}>
242
- <Path
243
- d={path}
244
- fill={item.color == "" || null ? colors[Math.floor(Math.random() * colors.length)] : item.color}
245
- stroke="#000000"
246
- strokeWidth={1}
247
- />
248
-
249
- {/* Fixed text rendering */}
250
- <SvgText
251
- x={textX}
252
- y={textY}
253
- fill={item.textColor || wheelTextColor}
254
- fontSize={wheelTextStyle?.fontSize || 14}
255
- fontWeight={wheelTextStyle?.fontWeight || "bold"}
256
- textAnchor="middle"
257
- alignmentBaseline="central"
258
- transform={`rotate(${angle + 180}, ${textX}, ${textY} )`}
259
- >
260
- {item.label}
261
- </SvgText>
262
- </React.Fragment>
263
- );
264
- }
265
- )}
287
+ {wheelPaths.map(({ path, item, textX, textY, angle, color }) => (
288
+ <React.Fragment key={item.id}>
289
+ <Path d={path} fill={color} stroke="#000000" strokeWidth={0.5} />
290
+ <SvgText
291
+ x={textX}
292
+ y={textY}
293
+ fill={item.textColor || wheelTextColor}
294
+ fontSize={wheelTextStyle?.fontSize || Math.max(8, size / 25)} // Dynamic font size
295
+ fontWeight={wheelTextStyle?.fontWeight || "bold"}
296
+ textAnchor="middle"
297
+ alignmentBaseline="central"
298
+ transform={`rotate(${angle + 90}, ${textX}, ${textY} )`} // Adjusted rotation for readability
299
+ >
300
+ {item.label}
301
+ </SvgText>
302
+ </React.Fragment>
303
+ ))}
266
304
  </G>
267
305
  </Svg>
268
306
  </Animated.View>
269
307
 
270
- {/* The center circle */}
271
- <View
272
- style={[
273
- styles.wheelCenter,
274
- {
275
- width: size / 5,
276
- height: size / 5,
277
- transform: [
278
- { translateX: -size / 10 },
279
- { translateY: -size / 10 },
280
- ],
281
- borderRadius: size / 5,
282
- },
283
- centerStyle
284
- ]}
285
- />
286
-
287
- {/* The pointer is a triangle on top */}
288
- <View style={styles.pointerPosition}>
289
- <View style={[styles.pointer, { borderBottomColor: knobColor }, knobStyle]} />
290
- </View>
291
-
292
- {/* Action Button */}
293
- <View
294
- style={{
295
- position: "absolute",
296
- width: "100%",
297
- alignItems: "center",
298
- justifyContent: "center",
299
- bottom: -70,
300
- zIndex: 2,
301
- }}
302
- >
303
- <TouchableOpacity
304
- onPress={handleSpin}
305
- disabled={spinning || !enabled}
306
- style={[styles.actionButton, actionButtonStyle]}
307
- >
308
- <Text
309
- style={[styles.actionButtonText, actionButtonTextStyle]}
310
- >
311
- {spinButtonText}
312
- </Text>
313
- </TouchableOpacity>
308
+ {centerComponent ? (
309
+ <View style={styles.centerComponentWrapper}>{centerComponent}</View>
310
+ ) : (
311
+ <View
312
+ style={[
313
+ styles.wheelCenter,
314
+ {
315
+ width: size / 5,
316
+ height: size / 5,
317
+ borderRadius: size / 10, // Should be half of width/height
318
+ // Centering is now handled by absolute positioning from parent
319
+ },
320
+ centerStyle,
321
+ ]}
322
+ />
323
+ )}
324
+
325
+ <View style={styles.pointerContainer}>
326
+ <View
327
+ style={[styles.pointer, { borderBottomColor: knobColor }, knobStyle]}
328
+ />
314
329
  </View>
315
330
  </View>
331
+
332
+ <TouchableOpacity
333
+ onPress={handleSpin}
334
+ disabled={spinning || !enabled}
335
+ style={[styles.actionButton, actionButtonStyle]}
336
+ >
337
+ <Text style={[styles.actionButtonText, actionButtonTextStyle]}>
338
+ {spinButtonText}
339
+ </Text>
340
+ </TouchableOpacity>
316
341
  </View>
317
342
  );
318
343
  };
@@ -321,52 +346,61 @@ const styles = StyleSheet.create({
321
346
  container: {
322
347
  alignItems: "center",
323
348
  justifyContent: "center",
324
- marginTop: 20,
325
- marginBottom: 70, // Space for the button
349
+ paddingVertical: 20, // Added padding
326
350
  },
327
351
  wheelContainer: {
328
352
  overflow: "hidden",
329
- backgroundColor: "transparent",
353
+ backgroundColor: "transparent", // Ensure SVG is visible
354
+ alignItems: "center",
355
+ justifyContent: "center",
330
356
  },
331
- wheelCenter: {
357
+ centerComponentWrapper: { // For custom center component
358
+ position: 'absolute',
359
+ zIndex: 2,
360
+ },
361
+ wheelCenter: { // Default center circle
332
362
  position: "absolute",
333
- top: "50%",
334
- left: "50%",
335
- backgroundColor: "#000000",
336
- borderWidth: 1,
337
- borderColor: "#333333",
338
- zIndex: 1,
363
+ backgroundColor: "#333333", // Darker center
364
+ borderWidth: 2,
365
+ borderColor: "#FFFFFF", // White border for center
366
+ zIndex: 2, // Above wheel paths, below pointer
339
367
  },
340
- pointerPosition: {
368
+ pointerContainer: { // Position the pointer correctly at the top center
341
369
  position: "absolute",
370
+ top: -5, // Adjust to make pointer sit nicely on the edge
342
371
  left: "50%",
343
- transform: [{ translateX: -10 }, { rotate: "180deg" }],
344
- zIndex: 2,
372
+ transform: [{ translateX: -10 }], // Half of pointer base width
373
+ zIndex: 3, // Above everything else on the wheel
345
374
  },
346
375
  pointer: {
347
376
  width: 0,
348
377
  height: 0,
349
378
  backgroundColor: "transparent",
350
379
  borderStyle: "solid",
351
- borderLeftWidth: 10,
352
- borderRightWidth: 10,
353
- borderBottomWidth: 15,
380
+ borderLeftWidth: 10, // Base of triangle
381
+ borderRightWidth: 10, // Base of triangle
382
+ borderBottomWidth: 20, // Height of triangle
354
383
  borderLeftColor: "transparent",
355
384
  borderRightColor: "transparent",
356
- borderBottomColor: "#D81E5B",
385
+ // borderBottomColor is set by knobColor prop
357
386
  },
358
387
  actionButton: {
388
+ marginTop: 30, // Space between wheel and button
359
389
  paddingHorizontal: 30,
360
390
  paddingVertical: 12,
361
391
  borderRadius: 25,
392
+ backgroundColor: "#4CAF50", // Example button color
362
393
  shadowColor: "#000",
363
- shadowRadius: 3,
364
- backgroundColor: "grey"
394
+ shadowOffset: { width: 0, height: 2 },
395
+ shadowOpacity: 0.25,
396
+ shadowRadius: 3.84,
397
+ elevation: 5,
365
398
  },
366
399
  actionButtonText: {
400
+ color: "#FFFFFF",
367
401
  fontWeight: "bold",
368
402
  fontSize: 16,
369
- }
403
+ },
370
404
  });
371
405
 
372
406
  export default SpinWheel;