Yaltopia-Tickets-App/components/Toast.tsx
2026-06-05 13:39:37 +03:00

176 lines
4.7 KiB
TypeScript

import React, { useEffect } from "react";
import { View, Dimensions, Pressable } from "react-native";
import { Text } from "@/components/ui/text";
import { useToast, ToastType } from "@/lib/toast-store";
import {
CheckCircle2,
AlertCircle,
AlertTriangle,
Lightbulb,
X,
} from "@/lib/icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.35;
const TOAST_VARIANTS: Record<
ToastType,
{
accent: string;
iconBg: string;
icon: React.ReactNode;
}
> = {
success: {
accent: "#16a34a",
iconBg: "#16a34a15",
icon: <CheckCircle2 size={20} color="#16a34a" strokeWidth={2.5} />,
},
info: {
accent: "#E46212",
iconBg: "#E4621215",
icon: <Lightbulb size={20} color="#E46212" strokeWidth={2.5} />,
},
warning: {
accent: "#d97706",
iconBg: "#d9770615",
icon: <AlertTriangle size={20} color="#d97706" strokeWidth={2.5} />,
},
error: {
accent: "#dc2626",
iconBg: "#dc262615",
icon: <AlertCircle size={20} color="#dc2626" strokeWidth={2.5} />,
},
};
export function Toast() {
const { visible, type, title, message, hide, duration } = useToast();
const insets = useSafeAreaInsets();
const opacity = useSharedValue(0);
const scale = useSharedValue(0.85);
const translateY = useSharedValue(-60);
const translateX = useSharedValue(0);
useEffect(() => {
if (visible) {
opacity.value = withTiming(1, { duration: 200 });
scale.value = withSpring(1, { damping: 14, stiffness: 160 });
translateY.value = withSpring(0, { damping: 16, stiffness: 140 });
translateX.value = 0;
const timer = setTimeout(handleHide, duration);
return () => clearTimeout(timer);
}
}, [visible]);
const handleHide = () => {
opacity.value = withTiming(0, { duration: 180 });
scale.value = withTiming(0.92, { duration: 180 });
translateY.value = withTiming(-40, { duration: 180 }, () => {
runOnJS(hide)();
});
};
const swipeGesture = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX;
})
.onEnd((event) => {
if (Math.abs(event.translationX) > SWIPE_THRESHOLD) {
translateX.value = withTiming(
event.translationX > 0 ? SCREEN_WIDTH : -SCREEN_WIDTH,
{ duration: 200 },
() => runOnJS(handleHide)(),
);
} else {
translateX.value = withSpring(0);
}
});
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [
{ translateY: translateY.value },
{ translateX: translateX.value },
{ scale: scale.value },
],
}));
if (!visible) return null;
const variant = TOAST_VARIANTS[type];
return (
<GestureDetector gesture={swipeGesture}>
<Animated.View
style={[
{
position: "absolute",
left: 16,
right: 16,
top: insets.top + 12,
zIndex: 9999,
shadowColor: variant.accent,
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.15,
shadowRadius: 16,
elevation: 8,
overflow: "hidden",
},
animatedStyle,
]}
className="bg-white dark:bg-[#1C1C1C] border border-border rounded-[14px]"
>
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: variant.accent,
}}
/>
<View className="flex-row items-start pt-4 pb-3.5 px-4">
<View
className="h-8 w-8 rounded-full items-center justify-center mr-3 mt-0.5"
style={{ backgroundColor: variant.iconBg }}
>
{variant.icon}
</View>
<View className="flex-1 pr-1">
<Text className="text-foreground text-[14px] font-sans-black leading-[18px]">
{title}
</Text>
{message ? (
<Text className="text-muted-foreground text-[12px] font-sans-medium leading-[16px] mt-1">
{message}
</Text>
) : null}
</View>
<Pressable
onPress={handleHide}
hitSlop={8}
className="h-6 w-6 rounded-full items-center justify-center -mr-1 mt-0.5"
>
<X size={14} color="#9ca3af" strokeWidth={2.5} />
</Pressable>
</View>
</Animated.View>
</GestureDetector>
);
}