import React from 'react';
import { View, Text, Image, Actionable, Flexbox } from '@evlop/native-components';
import { useProduct, PriceDisplay } from '@evlop/shopify';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withRepeat,
Easing,
interpolate,
} from 'react-native-reanimated';
import { StyleSheet, Pressable } from 'react-native';
interface ProductImage {
id: string;
originalSrc: string;
altText: string;
}
interface Product {
id: string;
title: string;
handle: string;
images: ProductImage[];
price?: string;
}
interface Annotation {
id: string;
x: number;
y: number;
width: number;
height: number;
product: Product;
}
interface ImageData {
url: string;
aspectRatio: number;
annotations: Annotation[];
}
interface AppBlockProps {
data: {
images: ImageData[];
};
}
const HOTSPOT_SIZE = 20;
const IMAGE_SIZE = 60;
const RING_COUNT = 3;
const ACTIVE_COLOR = '#4CD964'; // Green accent for active hotspot
const NAV_BUTTON_SIZE = 44;
const IMAGE_TRANSITION_MS = 400; // Duration for image crossfade
// Animated ring component for active hotspot highlight
interface PulseRingProps {
delay: number;
isActive: boolean;
}
const PulseRing: React.FC<PulseRingProps> = ({ delay, isActive }) => {
const progress = useSharedValue(0);
React.useEffect(() => {
if (isActive) {
// Use setTimeout for the delay instead of withDelay
const timer = setTimeout(() => {
progress.value = 0;
progress.value = withRepeat(
withTiming(1, { duration: 2000, easing: Easing.out(Easing.quad) }),
-1,
false
);
}, delay);
return () => clearTimeout(timer);
} else {
progress.value = withTiming(0, { duration: 300, easing: Easing.out(Easing.quad) });
}
}, [isActive, delay]);
const ringStyle = useAnimatedStyle(() => {
const scale = interpolate(progress.value, [0, 1], [1, 3.5]);
const opacity = interpolate(progress.value, [0, 0.3, 0.7, 1], [0.7, 0.5, 0.2, 0]);
return {
transform: [{ scale }],
opacity,
};
});
return (
<Animated.View style={[styles.pulseRing, ringStyle]} />
);
};
interface HotspotWithCardProps {
annotation: Annotation;
index: number;
imageWidth: number;
imageHeight: number;
isActive: boolean;
onHotspotPress: (index: number) => void;
}
const HotspotWithCard: React.FC<HotspotWithCardProps> = ({
annotation,
index,
imageWidth,
imageHeight,
isActive,
onHotspotPress,
}) => {
// Track if product should be visible (stays true during exit animation)
const [showProduct, setShowProduct] = React.useState(false);
// Load actual product data using handle or id
const product = useProduct(
annotation.product.handle
? { productHandle: annotation.product.handle }
: { productId: annotation.product.id }
);
const contentX = (annotation.x + annotation.width / 2) * imageWidth;
const contentY = (annotation.y + annotation.height / 2) * imageHeight;
// Annotation dimensions in pixels
const annotationWidth = annotation.width * imageWidth;
const annotationHeight = annotation.height * imageHeight;
// Smart positioning - check all edges
const leftSpace = contentX;
const rightSpace = imageWidth - contentX;
const isLeft = leftSpace > rightSpace;
const gap = 8; // Gap between annotation edge and product
// Distance from hotspot center to annotation edge
const halfAnnotationWidth = annotationWidth / 2;
// Position product outside the annotation area
const productX = isLeft
? -(halfAnnotationWidth + gap + IMAGE_SIZE) // Left: outside left edge of annotation
: halfAnnotationWidth + gap; // Right: outside right edge of annotation
// Align center of product image with center of hotspot
const productY = -IMAGE_SIZE / 2;
// Subtle idle pulse for all hotspots
const idlePulse = useSharedValue(0);
React.useEffect(() => {
idlePulse.value = withRepeat(
withTiming(1, { duration: 1500, easing: Easing.inOut(Easing.quad) }),
-1,
true // reverse to create pulse effect
);
}, []);
// Active state animation
const activeScale = useSharedValue(1);
const activeGlow = useSharedValue(0);
React.useEffect(() => {
if (isActive) {
activeScale.value = withTiming(1.2, { duration: 300, easing: Easing.out(Easing.quad) });
activeGlow.value = withTiming(1, { duration: 400, easing: Easing.out(Easing.quad) });
} else {
activeScale.value = withTiming(1, { duration: 250, easing: Easing.out(Easing.quad) });
activeGlow.value = withTiming(0, { duration: 300, easing: Easing.out(Easing.quad) });
}
}, [isActive]);
const hotspotOuterStyle = useAnimatedStyle(() => {
const idleScale = interpolate(idlePulse.value, [0, 1], [1, 1.08]);
const combinedScale = idleScale * activeScale.value;
return {
transform: [{ scale: combinedScale }],
};
});
const hotspotInnerStyle = useAnimatedStyle(() => {
const glowScale = interpolate(activeGlow.value, [0, 1], [1, 1.1]);
return {
transform: [{ scale: glowScale }],
};
});
const hotspotGlowStyle = useAnimatedStyle(() => {
const glowOpacity = interpolate(activeGlow.value, [0, 1], [0, 0.4]);
return {
opacity: glowOpacity,
transform: [{ scale: interpolate(activeGlow.value, [0, 1], [0.8, 1]) }],
};
});
// Line animation - delayed after hotspot glow
const lineProgress = useSharedValue(0);
// Product animation - delayed after line draws
const productProgress = useSharedValue(0);
React.useEffect(() => {
if (isActive) {
// Show elements immediately (for mounting)
setShowProduct(true);
// Reset animations
lineProgress.value = 0;
productProgress.value = 0;
// Sequence: hotspot glow (0-300ms) → line draws (300-550ms) → product shows (550-950ms)
const lineTimer = setTimeout(() => {
lineProgress.value = withTiming(1, { duration: 250, easing: Easing.out(Easing.quad) });
}, 300); // Start after hotspot glow
const productTimer = setTimeout(() => {
productProgress.value = withTiming(1, { duration: 400, easing: Easing.out(Easing.quad) });
}, 550); // Start after line finishes
return () => {
clearTimeout(lineTimer);
clearTimeout(productTimer);
};
} else {
// Exit: product fades first, then line retracts
productProgress.value = withTiming(0, { duration: 200, easing: Easing.inOut(Easing.quad) });
const lineTimer = setTimeout(() => {
lineProgress.value = withTiming(0, { duration: 180, easing: Easing.inOut(Easing.quad) });
}, 100); // Line starts retracting slightly after product starts fading
// Hide after all animations complete
const hideTimer = setTimeout(() => {
setShowProduct(false);
}, 300);
return () => {
clearTimeout(lineTimer);
clearTimeout(hideTimer);
};
}
}, [isActive]);
const productAnimStyle = useAnimatedStyle(() => {
// Scale: 0.3 (exit) → 1 (visible)
const productScale = interpolate(productProgress.value, [0, 1], [0.3, 1]);
// Fade from 0 to 1
const productOpacity = interpolate(productProgress.value, [0, 0.3, 1], [0, 0.4, 1]);
return {
opacity: productOpacity,
transform: [
{ scale: productScale }
],
};
});
// Connection line animation - draws from hotspot towards product
const lineStyle = useAnimatedStyle(() => {
// Scale from 0 to full width (drawing effect)
const scaleX = interpolate(lineProgress.value, [0, 1], [0, 1]);
const lineOpacity = interpolate(lineProgress.value, [0, 0.2, 1], [0, 1, 1]);
return {
opacity: lineOpacity,
transform: [
{ scaleX }
],
};
});
return (
<View
style={[
styles.overlayContainer,
{
left: contentX,
top: contentY,
},
]}
pointerEvents="box-none"
>
{/* Hotspot */}
<Pressable
onPress={() => onHotspotPress(index)}
style={styles.hotspotPressable}
>
<View style={styles.hotspotWrapper}>
{/* Multi-layer pulse rings for active state */}
<View style={styles.ringsContainer}>
{[...Array(RING_COUNT)].map((_, i) => (
<PulseRing
key={i}
delay={i * 400}
isActive={isActive}
/>
))}
</View>
{/* Active glow backdrop */}
<Animated.View style={[styles.hotspotGlow, hotspotGlowStyle]} />
<Animated.View style={[
styles.hotspotOuter,
hotspotOuterStyle,
isActive && styles.hotspotOuterActive
]}>
<Animated.View style={[
styles.hotspotInner,
hotspotInnerStyle,
isActive && styles.hotspotInnerActive
]} />
</Animated.View>
</View>
</Pressable>
{/* Connection line */}
{showProduct && (
<Animated.View
style={[
styles.connectionLine,
lineStyle,
{
width: halfAnnotationWidth + gap,
left: isLeft ? -(halfAnnotationWidth + gap) : 0,
// Transform origin: line draws from hotspot (right side if left, left side if right)
transformOrigin: isLeft ? 'right center' : 'left center',
}
]}
/>
)}
{/* Product Info */}
{showProduct && (
<Animated.View
style={[
styles.productContainer,
productAnimStyle,
{
left: productX,
top: productY,
}
]}
>
<Actionable action={product?.actions?.openDetailsPage} hapticFeedback="impactLight" pressEffect="pop">
<Flexbox flexDirection="column" alignItems="center" gap={5}>
<Image
src={product?.images?.[0]}
style={styles.productImage}
resizeMode="cover"
/>
{product?.priceRange?.minVariantPrice && (
<View bg="gray-1100/600" px="4xs" py="6xs" borderRadius={10}>
<PriceDisplay adjustsFontSizeToFit numberOfLines={1} color="gray-0" fontSize="2xs" price={product.priceRange.minVariantPrice} />
</View>
)}
</Flexbox>
</Actionable>
</Animated.View>
)}
</View>
);
};
// Navigation Arrow Button
interface NavButtonProps {
direction: 'prev' | 'next';
onPress: () => void;
disabled?: boolean;
}
const NavButton: React.FC<NavButtonProps> = ({ direction, onPress, disabled }) => {
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = () => {
scale.value = withTiming(0.9, { duration: 100 });
};
const handlePressOut = () => {
scale.value = withTiming(1, { duration: 100 });
};
return (
<Pressable
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
style={[styles.navButton, disabled && styles.navButtonDisabled]}
>
<Animated.View style={[styles.navButtonInner, animatedStyle]}>
<Text style={styles.navButtonText}>
{direction === 'prev' ? '‹' : '›'}
</Text>
</Animated.View>
</Pressable>
);
};
// Animated Look Image component for smooth transitions
interface LookImageProps {
image: ImageData;
imageIndex: number;
layout: { width: number; height: number };
activeHotspot: number;
showCard: boolean;
onHotspotPress: (index: number) => void;
opacity: Animated.SharedValue<number>;
isVisible: boolean;
isOverlay?: boolean; // If true, renders as absolute overlay
}
const LookImage: React.FC<LookImageProps> = ({
image,
imageIndex,
layout,
activeHotspot,
showCard,
onHotspotPress,
opacity,
isVisible,
isOverlay = false,
}) => {
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
if (!isVisible) return null;
return (
<Animated.View style={[isOverlay ? styles.lookImageOverlay : styles.lookImageBase, animatedStyle]}>
<Image
source={{ uri: image.url }}
style={{ width: '100%', aspectRatio: image.aspectRatio }}
resizeMode="cover"
/>
{/* Subtle vignette overlay */}
<View style={styles.vignetteOverlay} />
{/* Hotspots */}
{layout.width > 0 && image.annotations?.map((annotation, index) => (
<HotspotWithCard
key={`${imageIndex}-${annotation.id}`}
annotation={annotation}
index={index}
imageWidth={layout.width}
imageHeight={layout.height}
isActive={index === activeHotspot && showCard}
onHotspotPress={onHotspotPress}
/>
))}
</Animated.View>
);
};
const AppBlock: React.FC<AppBlockProps> = ({ data }) => {
const { images } = data || {};
if (!images || images.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No images available</Text>
</View>
);
}
const [currentImageIndex, setCurrentImageIndex] = React.useState(0);
const [nextImageIndex, setNextImageIndex] = React.useState<number | null>(null);
const [containerWidth, setContainerWidth] = React.useState(0);
const [activeHotspot, setActiveHotspot] = React.useState(0);
const [showCard, setShowCard] = React.useState(true);
const [isTransitioning, setIsTransitioning] = React.useState(false);
// Animated opacity values for crossfade
const currentOpacity = useSharedValue(1);
const nextOpacity = useSharedValue(0);
// Animated height for smooth height transitions
const animatedHeight = useSharedValue(0);
const currentImage = images[currentImageIndex];
const nextImage = nextImageIndex !== null ? images[nextImageIndex] : null;
const totalImages = images.length;
const totalHotspots = currentImage?.annotations?.length || 0;
// Calculate height from width and aspect ratio
const getHeightForImage = (image: ImageData, width: number) => {
if (!width || !image?.aspectRatio) return 0;
return width / image.aspectRatio;
};
// Initialize height when container width is set
React.useEffect(() => {
if (containerWidth > 0 && currentImage) {
const initialHeight = getHeightForImage(currentImage, containerWidth);
animatedHeight.value = initialHeight;
}
}, [containerWidth, currentImage?.aspectRatio]);
// Animated style for container height
const containerHeightStyle = useAnimatedStyle(() => ({
height: animatedHeight.value,
}));
// Layout object derived from containerWidth and animated height
const layout = React.useMemo(() => ({
width: containerWidth,
height: containerWidth > 0 && currentImage ? getHeightForImage(currentImage, containerWidth) : 0,
}), [containerWidth, currentImage?.aspectRatio]);
const DISPLAY_MS = 3500; // dwell time for each hotspot
const TRANSITION_MS = 300; // smooth transition gap for hotspots
const cycleRef = React.useRef<NodeJS.Timeout | null>(null);
const transitionRef = React.useRef<NodeJS.Timeout | null>(null);
const clearTimers = () => {
if (cycleRef.current) clearTimeout(cycleRef.current);
if (transitionRef.current) clearTimeout(transitionRef.current);
};
const startCycle = React.useCallback(() => {
clearTimers();
cycleRef.current = setTimeout(() => {
setShowCard(false);
transitionRef.current = setTimeout(() => {
setActiveHotspot((prev) => (prev + 1) % totalHotspots);
setShowCard(true);
startCycle();
}, TRANSITION_MS);
}, DISPLAY_MS);
}, [totalHotspots]);
React.useEffect(() => {
if (!totalHotspots || isTransitioning) return;
startCycle();
return () => clearTimers();
}, [totalHotspots, startCycle, currentImageIndex, isTransitioning]);
// Handle hotspot press - switch to selected hotspot
const handleHotspotPress = React.useCallback((index: number) => {
if (index === activeHotspot || isTransitioning) return;
clearTimers();
setShowCard(false);
transitionRef.current = setTimeout(() => {
setActiveHotspot(index);
setShowCard(true);
startCycle();
}, TRANSITION_MS);
}, [activeHotspot, startCycle, isTransitioning]);
// Smooth image transition
const transitionToImage = React.useCallback((targetIndex: number) => {
if (isTransitioning || targetIndex === currentImageIndex) return;
if (targetIndex < 0 || targetIndex >= totalImages) return;
clearTimers();
setShowCard(false);
setIsTransitioning(true);
setNextImageIndex(targetIndex);
const targetImage = images[targetIndex];
const targetHeight = getHeightForImage(targetImage, containerWidth);
// Fade in the next image on top (current stays visible underneath)
// This avoids the flicker from opacity swapping
currentOpacity.value = 1; // Keep current visible as background
nextOpacity.value = 0;
nextOpacity.value = withTiming(1, {
duration: IMAGE_TRANSITION_MS,
easing: Easing.inOut(Easing.quad)
});
// Animate height change
animatedHeight.value = withTiming(targetHeight, {
duration: IMAGE_TRANSITION_MS,
easing: Easing.inOut(Easing.quad),
});
// After transition completes, update state in stages to avoid flicker
const completeTimer = setTimeout(() => {
// Stage 1: Update the current image index
// The next image is still visible as overlay, covering the transition
setCurrentImageIndex(targetIndex);
setActiveHotspot(0);
setShowCard(true);
setIsTransitioning(false);
}, IMAGE_TRANSITION_MS);
// Stage 2: Clear the overlay after the state has settled
// This delay ensures React has fully rendered the new current image
const clearOverlayTimer = setTimeout(() => {
setNextImageIndex(null);
nextOpacity.value = 0;
}, IMAGE_TRANSITION_MS + 100); // Extra 100ms to ensure render is complete
return () => {
clearTimeout(completeTimer);
clearTimeout(clearOverlayTimer);
};
}, [currentImageIndex, totalImages, isTransitioning, containerWidth, images]);
// Navigate to previous image (loops to last when at first)
const handlePrevImage = React.useCallback(() => {
const prevIndex = currentImageIndex === 0 ? totalImages - 1 : currentImageIndex - 1;
transitionToImage(prevIndex);
}, [currentImageIndex, totalImages, transitionToImage]);
// Navigate to next image (loops to first when at last)
const handleNextImage = React.useCallback(() => {
const nextIndex = currentImageIndex === totalImages - 1 ? 0 : currentImageIndex + 1;
transitionToImage(nextIndex);
}, [currentImageIndex, totalImages, transitionToImage]);
// Handle container layout to get width
const handleLayout = React.useCallback((e: any) => {
const { width } = e.nativeEvent.layout;
if (width !== containerWidth) {
setContainerWidth(width);
// Set initial height immediately
if (currentImage && animatedHeight.value === 0) {
animatedHeight.value = getHeightForImage(currentImage, width);
}
}
}, [containerWidth, currentImage]);
return (
<View style={styles.container}>
<Animated.View
style={[styles.imageWrapper, containerHeightStyle]}
onLayout={handleLayout}
>
{/* Current image (fades out during transition) */}
<LookImage
image={currentImage}
imageIndex={currentImageIndex}
layout={layout}
activeHotspot={activeHotspot}
showCard={showCard && !isTransitioning}
onHotspotPress={handleHotspotPress}
opacity={currentOpacity}
isVisible={true}
isOverlay={false}
/>
{/* Next image (fades in during transition, rendered as overlay) */}
{nextImage && (
<LookImage
image={nextImage}
imageIndex={nextImageIndex!}
layout={{ width: containerWidth, height: getHeightForImage(nextImage, containerWidth) }}
activeHotspot={0}
showCard={false}
onHotspotPress={() => {}}
opacity={nextOpacity}
isVisible={true}
isOverlay={true}
/>
)}
{/* Navigation Controls */}
{totalImages > 1 && (
<>
{/* Previous Button */}
<View style={styles.navButtonLeft}>
<NavButton
direction="prev"
onPress={handlePrevImage}
disabled={isTransitioning}
/>
</View>
{/* Next Button */}
<View style={styles.navButtonRight}>
<NavButton
direction="next"
onPress={handleNextImage}
disabled={isTransitioning}
/>
</View>
</>
)}
</Animated.View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'transparent',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
backgroundColor: 'transparent',
},
emptyText: {
fontSize: 14,
color: '#666',
textAlign: 'center',
},
imageWrapper: {
width: '100%',
position: 'relative',
zIndex: 1,
overflow: 'hidden',
},
lookImageBase: {
position: 'relative',
width: '100%',
},
lookImageOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
width: '100%',
},
vignetteOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'transparent',
// Subtle edge darkening for depth
borderWidth: 0,
},
overlayContainer: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
overflow: 'visible',
},
ringsContainer: {
position: 'absolute',
width: HOTSPOT_SIZE,
height: HOTSPOT_SIZE,
justifyContent: 'center',
alignItems: 'center',
zIndex: 5,
},
pulseRing: {
position: 'absolute',
width: HOTSPOT_SIZE,
height: HOTSPOT_SIZE,
borderRadius: HOTSPOT_SIZE / 2,
borderWidth: 2,
borderColor: ACTIVE_COLOR,
backgroundColor: 'transparent',
},
hotspotPressable: {
width: HOTSPOT_SIZE * 2,
height: HOTSPOT_SIZE * 2,
marginLeft: -HOTSPOT_SIZE,
marginTop: -HOTSPOT_SIZE,
justifyContent: 'center',
alignItems: 'center',
zIndex: 15,
},
hotspotWrapper: {
width: HOTSPOT_SIZE,
height: HOTSPOT_SIZE,
justifyContent: 'center',
alignItems: 'center',
},
hotspotGlow: {
position: 'absolute',
width: HOTSPOT_SIZE * 2,
height: HOTSPOT_SIZE * 2,
borderRadius: HOTSPOT_SIZE,
backgroundColor: ACTIVE_COLOR,
},
hotspotOuter: {
width: HOTSPOT_SIZE,
height: HOTSPOT_SIZE,
borderRadius: HOTSPOT_SIZE / 2,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1.5,
borderColor: 'rgba(255, 255, 255, 0.6)',
// Soft shadow
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 4,
},
hotspotInner: {
width: HOTSPOT_SIZE * 0.45,
height: HOTSPOT_SIZE * 0.45,
borderRadius: HOTSPOT_SIZE * 0.225,
backgroundColor: '#fff',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
elevation: 2,
},
hotspotOuterActive: {
borderColor: ACTIVE_COLOR,
backgroundColor: 'rgba(76, 217, 100, 0.15)',
},
hotspotInnerActive: {
backgroundColor: ACTIVE_COLOR,
},
connectionLine: {
position: 'absolute',
height: 2,
backgroundColor: ACTIVE_COLOR,
borderRadius: 1,
top: -1,
zIndex: 12,
},
productContainer: {
position: 'absolute',
width: IMAGE_SIZE,
alignItems: 'center',
zIndex: 20,
},
productImage: {
width: IMAGE_SIZE,
height: IMAGE_SIZE,
borderRadius: IMAGE_SIZE / 2,
borderWidth: 2,
borderColor: '#fff',
backgroundColor: '#f0f0f0',
// Shadow for the circular image
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 6,
elevation: 8,
},
// Navigation styles
navButtonLeft: {
position: 'absolute',
left: 12,
top: '50%',
marginTop: -NAV_BUTTON_SIZE / 2,
zIndex: 30,
},
navButtonRight: {
position: 'absolute',
right: 12,
top: '50%',
marginTop: -NAV_BUTTON_SIZE / 2,
zIndex: 30,
},
navButton: {
width: NAV_BUTTON_SIZE,
height: NAV_BUTTON_SIZE,
borderRadius: NAV_BUTTON_SIZE / 2,
justifyContent: 'center',
alignItems: 'center',
},
navButtonDisabled: {
opacity: 0.3,
},
navButtonInner: {
width: NAV_BUTTON_SIZE,
height: NAV_BUTTON_SIZE,
borderRadius: NAV_BUTTON_SIZE / 2,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
navButtonText: {
color: '#fff',
fontSize: 28,
fontWeight: '300',
marginTop: -2,
},
});
export default AppBlock;