feat: slide-to-confirm on all payment screens; pay button on dashboard invoice rows
This commit is contained in:
109
components/SlideToConfirm.tsx
Normal file
109
components/SlideToConfirm.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { View, Text, PanResponder, Animated, StyleSheet } from 'react-native';
|
||||
|
||||
const TRACK_HEIGHT = 58;
|
||||
const HANDLE_SIZE = 46;
|
||||
const PADDING = 6;
|
||||
const THRESHOLD = 0.85; // 85% of track = confirmed
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
color?: string;
|
||||
onConfirm: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SlideToConfirm({ label = 'Slide to confirm', color = '#059669', onConfirm, disabled = false }: Props) {
|
||||
const pan = useRef(new Animated.Value(0)).current;
|
||||
const [done, setDone] = useState(false);
|
||||
const [trackW, setTrackW] = useState(0);
|
||||
|
||||
const maxX = trackW - HANDLE_SIZE - PADDING * 2;
|
||||
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => !disabled && !done,
|
||||
onMoveShouldSetPanResponder: () => !disabled && !done,
|
||||
onPanResponderMove: (_, gs) => {
|
||||
const x = Math.max(0, Math.min(gs.dx, maxX));
|
||||
pan.setValue(x);
|
||||
},
|
||||
onPanResponderRelease: (_, gs) => {
|
||||
const x = Math.max(0, Math.min(gs.dx, maxX));
|
||||
if (maxX > 0 && x / maxX >= THRESHOLD) {
|
||||
// Snap to end + confirm
|
||||
Animated.timing(pan, { toValue: maxX, duration: 120, useNativeDriver: false }).start(() => {
|
||||
setDone(true);
|
||||
onConfirm();
|
||||
});
|
||||
} else {
|
||||
// Snap back
|
||||
Animated.spring(pan, { toValue: 0, useNativeDriver: false, speed: 20 }).start();
|
||||
}
|
||||
},
|
||||
})
|
||||
).current;
|
||||
|
||||
// Interpolate opacity of the label as handle moves right
|
||||
const labelOpacity = pan.interpolate({
|
||||
inputRange: [0, maxX * 0.5],
|
||||
outputRange: [1, 0],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
return (
|
||||
<View
|
||||
onLayout={e => setTrackW(e.nativeEvent.layout.width)}
|
||||
style={[styles.track, { backgroundColor: done ? color : '#F1F5F9', borderColor: done ? color : '#E2E8F0' }]}
|
||||
>
|
||||
{/* Label */}
|
||||
<Animated.Text style={[styles.label, { opacity: disabled ? 0.4 : labelOpacity, color: done ? '#fff' : '#64748B' }]}>
|
||||
{done ? '✓ Confirmed!' : label}
|
||||
</Animated.Text>
|
||||
|
||||
{/* Handle */}
|
||||
{!done && (
|
||||
<Animated.View
|
||||
{...(disabled ? {} : panResponder.panHandlers)}
|
||||
style={[
|
||||
styles.handle,
|
||||
{ backgroundColor: disabled ? '#CBD5E1' : color, left: PADDING, transform: [{ translateX: pan }] },
|
||||
]}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontSize: 20, fontWeight: '700' }}>{'›'}</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
track: {
|
||||
height: TRACK_HEIGHT,
|
||||
borderRadius: TRACK_HEIGHT / 2,
|
||||
borderWidth: 1.5,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
label: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
handle: {
|
||||
position: 'absolute',
|
||||
top: PADDING,
|
||||
width: HANDLE_SIZE,
|
||||
height: HANDLE_SIZE,
|
||||
borderRadius: HANDLE_SIZE / 2,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 3,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user