Cart progress animation

Cart progress animation

import React, { useEffect, useRef, useState } from "react";
import { Animated, Easing } from "react-native";
import LinearGradient from "react-native-linear-gradient";
import { View, Text } from "@evlop/native-components";
import { Money } from "@evlop/shopify";

const MILESTONES = [
  { key: "m1", label: "5% off", amount: 30 },
  { key: "m2", label: "10% off", amount: 100 },
  { key: "m3", label: "15% off", amount: 200 }
];

const BAR_HEIGHT = 20;
const TICK_OUTER = 12; // outer circle size
const TICK_INNER = 8; // inner dot size

const AppBlock: NativeAppBlock = function ({ cart }) {
  const total = +(cart?.totalPrice?.amount ?? 0);

  const currency = cart?.totalPrice?.currencyCode ?? "USD";

  const max = MILESTONES[MILESTONES.length - 1].amount;
  const progress = Math.min(total / max, 1);

  const [containerWidth, setContainerWidth] = useState(0);
  const animatedWidth = useRef(new Animated.Value(0)).current;

  useEffect(() => {
    if (containerWidth <= 0) return;
    const toValue = progress * containerWidth;
    Animated.timing(animatedWidth, {
      toValue,
      duration: 650,
      easing: Easing.out(Easing.cubic),
      useNativeDriver: false
    }).start();
  }, [progress, containerWidth, animatedWidth]);

  const nextMilestone = MILESTONES.find(m => total < m.amount);

  const AnimatedGradient: any = Animated.createAnimatedComponent(LinearGradient as any);

  return (
    <View padding={18} backgroundColor="gray-50">
      <View
        borderRadius={16}
        padding={18}
        backgroundColor="white"
        borderWidth={1}
        borderColor="gray-200"
        shadowColor="gray-900"
      >
        <Text fontSize={16} fontWeight="700" marginBottom={14} color="gray-900">
          {nextMilestone ? (
            <>
              Add <Money fontWeight="700" amount={String(Math.max(0, (nextMilestone.amount - total).toFixed(2)))} currencyCode={currency} /> more to get {nextMilestone.label}
            </>
          ) : (
            "You reached the top reward 🎉"
          )}
        </Text>

        <View
          onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
          height={BAR_HEIGHT}
          borderRadius={BAR_HEIGHT / 2}
          backgroundColor="gray-100"
          borderWidth={1}
          borderColor="gray-200"
          position="relative"
          overflow="hidden"
        >
          {/* Gradient progress fill */}
          <AnimatedGradient
            colors={["#60A5FA", "#7C3AED"]}
            start={{ x: 0, y: 0 }}
            end={{ x: 1, y: 0 }}
            style={{ width: animatedWidth, height: BAR_HEIGHT, borderRadius: BAR_HEIGHT / 2, elevation: 3 }}
          />

          {/* subtle overlay shadow for depth */}
          <View
            position="absolute"
            left={0}
            right={0}
            top={0}
            height={BAR_HEIGHT}
            borderRadius={BAR_HEIGHT / 2}
            style={{
              shadowColor: "#000",
              shadowOffset: { width: 0, height: 1 },
              shadowOpacity: 0.06,
              shadowRadius: 4
            }}
          />

          {/* milestone ticks */}
          {containerWidth > 0 &&
            MILESTONES.map(m => {
              const rawLeft = (m.amount / max) * containerWidth;
              const left = Math.min(Math.max(rawLeft - TICK_OUTER / 2, 0), containerWidth - TICK_OUTER);
              const reached = total >= m.amount;

              return (
                <View
                  key={m.key}
                  position="absolute"
                  top={-(TICK_OUTER - BAR_HEIGHT) / 2}
                  width={TICK_OUTER}
                  height={TICK_OUTER}
                  alignItems="center"
                  justifyContent="center"
                  style={{ left }}
                >
                  <View
                    width={TICK_OUTER}
                    height={TICK_OUTER}
                    borderRadius={TICK_OUTER / 2}
                    borderWidth={2}
                    alignItems="center"
                    justifyContent="center"
                    borderColor={reached ? "primary-500" : "gray-200"}
                    backgroundColor={reached ? "rgba(124,58,237,0.08)" : "transparent"}
                    style={{
                      shadowColor: reached ? "#7C3AED" : "#000",
                      shadowOffset: { width: 0, height: 1 },
                      shadowOpacity: reached ? 0.12 : 0.04,
                      shadowRadius: 6
                    }}
                  >
                    <View
                      width={TICK_INNER}
                      height={TICK_INNER}
                      borderRadius={TICK_INNER / 2}
                      backgroundColor={reached ? "primary-500" : "white"}
                      style={{ borderWidth: reached ? 0 : 1, borderColor: "#E5E7EB" }}
                    />
                  </View>
                </View>
              );
            })}
        </View>

        <View marginTop={12} position="relative" minHeight={44}>
          {containerWidth > 0 &&
            MILESTONES.map(m => {
              const rawLeft = (m.amount / max) * containerWidth;
              const left = Math.min(Math.max(rawLeft - 48, 0), containerWidth - 96);
              const reached = total >= m.amount;

              return (
                <View key={m.key + "-label"} position="absolute" width={96} alignItems="center" style={{ left }}>
                  <Text fontSize={12} fontWeight="800" textAlign="center" color={reached ? "primary-600" : "gray-600"}>
                    {m.label}
                  </Text>
                  <Text fontSize={12} color="gray-500" textAlign="center" marginTop={4}>
                    <Money fontWeight="Bold" fontSize={12} color="gray-500" amount={String(m.amount)} currencyCode={currency} />
                  </Text>
                </View>
              );
            })}
        </View>

      </View>
    </View>
  );
};

export default AppBlock;
Loading...