diff --git a/src/components/FeatureCarousel/FeatureCarousel.js b/src/components/FeatureCarousel/FeatureCarousel.js
index e9579be..10c13e6 100644
--- a/src/components/FeatureCarousel/FeatureCarousel.js
+++ b/src/components/FeatureCarousel/FeatureCarousel.js
@@ -14,7 +14,9 @@ import {
View,
useWindowDimensions,
} from "react-native";
+import { responsiveHeight } from "../../actions/responsiveSizes";
import { ai } from "../../assets";
+import { gutters } from "../../styles";
import { getCreationStageStates } from "../../utils/projectStages";
import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot";
import PersonaCard from "../cards/PersonaCard/PersonaCard";
@@ -422,6 +424,8 @@ const styles = StyleSheet.create({
flex: 1,
width: "100%",
flexDirection: "row",
+ paddingBottom: responsiveHeight(5),
+ paddingHorizontal: gutters,
},
containerWeb: {
paddingRight: 56,
diff --git a/src/components/FeatureCarousel/FeatureCarousel.web.js b/src/components/FeatureCarousel/FeatureCarousel.web.js
new file mode 100644
index 0000000..688eb85
--- /dev/null
+++ b/src/components/FeatureCarousel/FeatureCarousel.web.js
@@ -0,0 +1,529 @@
+import { BlurView } from "expo-blur";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ Animated,
+ Image,
+ Platform,
+ StyleSheet,
+ View,
+ useWindowDimensions,
+} from "react-native";
+import { ai } from "../../assets";
+import { getCreationStageStates } from "../../utils/projectStages";
+import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot";
+import PersonaCard from "../cards/PersonaCard/PersonaCard";
+import { LinearGradient } from "../LinearGradient/LinearGradient";
+
+const STAGE_CARD_CONTENT = [
+ {
+ key: "songwriter",
+ title: "Nathalie",
+ description: "Let’s write lyrics together !",
+ image: ai.leftIcon,
+ },
+ {
+ key: "beatmaker",
+ title: "Theo",
+ description: "Come back, when you'll have lyrics!",
+ image: ai.rightIcon,
+ },
+ {
+ key: "designer",
+ title: "Bena",
+ description: "Come back when you want to generate cover!",
+ image: ai.leftIcon,
+ },
+ {
+ key: "director",
+ title: "John",
+ description: "Come back when your audio is ready!",
+ image: ai.rightIcon,
+ },
+];
+
+const WEB_SCROLL_INACTIVE_DELTA = 0.05;
+const HOME_BACKGROUND_COLOR = "#425B87"; // Derived from the bottom color of the home hero image
+const AnimatedImage = Animated.createAnimatedComponent(Image);
+
+const FeatureCarousel = ({
+ style,
+ selectedProject,
+ stageStates: stageStatesProp,
+ activeIndex,
+ onActiveIndexChange,
+ backgroundImage,
+}) => {
+ const stageStates = useMemo(() => {
+ if (stageStatesProp) {
+ return stageStatesProp;
+ }
+ return getCreationStageStates(selectedProject);
+ }, [stageStatesProp, selectedProject]);
+
+ const stageStatesByKey = useMemo(() => {
+ if (!Array.isArray(stageStates)) {
+ return {};
+ }
+ return stageStates.reduce((acc, stage) => {
+ if (stage?.key) {
+ acc[stage.key] = stage;
+ }
+ return acc;
+ }, {});
+ }, [stageStates]);
+
+ const carouselItems = useMemo(
+ () =>
+ STAGE_CARD_CONTENT.map((item) => {
+ const state = stageStatesByKey[item.key];
+ return {
+ ...item,
+ isLocked: state?.isLocked ?? true,
+ description: state?.description ?? item.description,
+ };
+ }),
+ [stageStatesByKey]
+ );
+
+ const { height: windowHeight } = useWindowDimensions();
+ const isWeb = Platform.OS === "web";
+
+ const [viewportHeight, setViewportHeight] = useState(() =>
+ Math.max(windowHeight, 1)
+ );
+
+ const updateSnapHeight = useCallback((height) => {
+ if (!height || Number.isNaN(height)) {
+ return;
+ }
+ setViewportHeight((prev) => {
+ if (prev == null || Math.abs(prev - height) > 0.5) {
+ return height;
+ }
+ return prev;
+ });
+ }, []);
+
+ useEffect(() => {
+ updateSnapHeight(Math.max(windowHeight, 1));
+ }, [updateSnapHeight, windowHeight]);
+
+ const itemHeight = Math.max(viewportHeight, 1);
+
+ const scrollViewRef = useRef(null);
+ const alignTimeoutRef = useRef(null);
+ const activeIndexRef = useRef(
+ typeof activeIndex === "number" ? activeIndex : 0
+ );
+ const onActiveIndexChangeRef = useRef(onActiveIndexChange);
+ const scrollY = useRef(new Animated.Value(0)).current;
+ const parallaxTranslateY = useMemo(() => {
+ if (!itemHeight) {
+ return null;
+ }
+ return scrollY.interpolate({
+ inputRange: [0, itemHeight],
+ outputRange: [0, -itemHeight * 0.25],
+ extrapolate: "clamp",
+ });
+ }, [itemHeight, scrollY]);
+
+ useEffect(() => {
+ onActiveIndexChangeRef.current = onActiveIndexChange;
+ }, [onActiveIndexChange]);
+
+ useEffect(() => {
+ if (typeof activeIndex === "number") {
+ activeIndexRef.current = activeIndex;
+ }
+ }, [activeIndex]);
+
+ const clampIndex = useCallback(
+ (index) => {
+ if (!carouselItems.length) {
+ return 0;
+ }
+ if (index < 0) {
+ return 0;
+ }
+ if (index >= carouselItems.length) {
+ return carouselItems.length - 1;
+ }
+ return index;
+ },
+ [carouselItems.length]
+ );
+
+ const clearPendingAlignment = useCallback(() => {
+ if (alignTimeoutRef.current != null) {
+ globalThis.clearTimeout(alignTimeoutRef.current);
+ alignTimeoutRef.current = null;
+ }
+ }, []);
+
+ useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment]);
+
+ const getScrollNode = useCallback(() => {
+ const node = scrollViewRef.current;
+ if (!node) {
+ return null;
+ }
+ if (typeof node.scrollTo === "function") {
+ return node;
+ }
+ if (typeof node.getNode === "function") {
+ return node.getNode();
+ }
+ return null;
+ }, []);
+
+ const scrollToIndex = useCallback(
+ (index, animated = true, heightOverride) => {
+ const target = getScrollNode();
+ if (!target) {
+ return;
+ }
+
+ const clamped = clampIndex(index);
+ const height =
+ heightOverride && heightOverride > 0 ? heightOverride : itemHeight;
+
+ if (!height) {
+ return;
+ }
+
+ updateSnapHeight(height);
+ const offset = clamped * height;
+
+ try {
+ if (typeof target.scrollTo === "function") {
+ target.scrollTo({ y: offset, animated });
+ } else if (typeof target.scrollToOffset === "function") {
+ target.scrollToOffset({ offset, animated });
+ }
+ } catch (_error) {
+ // ScrollView not ready yet, ignore.
+ }
+ activeIndexRef.current = clamped;
+ },
+ [clampIndex, getScrollNode, itemHeight, updateSnapHeight]
+ );
+
+ useEffect(() => {
+ if (
+ scrollViewRef.current == null ||
+ typeof activeIndex !== "number" ||
+ activeIndex < 0 ||
+ activeIndex >= carouselItems.length ||
+ (isWeb && !itemHeight)
+ ) {
+ return;
+ }
+ scrollToIndex(activeIndex);
+ }, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex]);
+
+ useEffect(() => {
+ if (scrollViewRef.current == null || (isWeb && !itemHeight)) {
+ return;
+ }
+ scrollToIndex(activeIndexRef.current, false);
+ }, [carouselItems.length, isWeb, itemHeight, scrollToIndex]);
+
+ const alignToOffset = useCallback(
+ (offset, layoutHeight, options = {}) => {
+ const { forceSnap = false } = options || {};
+ const height =
+ layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight;
+ if (!height) {
+ return;
+ }
+
+ updateSnapHeight(height);
+
+ const currentIndex = activeIndexRef.current;
+ const rawIndex = height ? offset / height : currentIndex;
+
+ let nextIndex = currentIndex;
+ if (isWeb) {
+ const delta = rawIndex - currentIndex;
+ if (Math.abs(delta) > WEB_SCROLL_INACTIVE_DELTA) {
+ if (Math.abs(delta) <= 1) {
+ nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1));
+ } else {
+ nextIndex = clampIndex(currentIndex + Math.round(delta));
+ }
+ }
+ } else {
+ nextIndex = clampIndex(Math.round(rawIndex));
+ }
+
+ const hasChanged = nextIndex !== activeIndexRef.current;
+
+ if (hasChanged) {
+ activeIndexRef.current = nextIndex;
+ const callback = onActiveIndexChangeRef.current;
+ if (callback) {
+ callback(nextIndex);
+ }
+ }
+
+ if (forceSnap || hasChanged) {
+ scrollToIndex(nextIndex, true, height);
+ }
+ },
+ [clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight]
+ );
+
+ const handleScrollEnd = useCallback(
+ (event) => {
+ clearPendingAlignment();
+ const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
+ const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
+ alignToOffset(offsetY, layoutHeight, { forceSnap: true });
+ },
+ [alignToOffset, clearPendingAlignment]
+ );
+
+ const handleScroll = useCallback(
+ (event) => {
+ if (!isWeb) {
+ return;
+ }
+ const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
+ const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
+ clearPendingAlignment();
+ alignTimeoutRef.current = globalThis.setTimeout(() => {
+ alignToOffset(offsetY, layoutHeight, { forceSnap: false });
+ alignTimeoutRef.current = null;
+ }, 80);
+ },
+ [alignToOffset, clearPendingAlignment, isWeb]
+ );
+
+ const animatedScrollHandler = useMemo(
+ () =>
+ Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
+ useNativeDriver: false,
+ listener: isWeb ? handleScroll : undefined,
+ }),
+ [handleScroll, isWeb, scrollY]
+ );
+
+ const handleLayout = useCallback(
+ (event) => {
+ const layoutHeight = event?.nativeEvent?.layout?.height ?? 0;
+ if (layoutHeight > 0) {
+ updateSnapHeight(layoutHeight);
+ }
+ },
+ [updateSnapHeight]
+ );
+
+ const renderContent = useMemo(
+ () =>
+ carouselItems.map((item, index) => {
+ const card = (
+
+
+
+ );
+
+ const containerStyle = [styles.backgroundItem, { height: itemHeight }];
+ const isFirstItem = index === 0 && backgroundImage;
+
+ if (isFirstItem) {
+ const parallaxStyles = [
+ styles.parallaxImage,
+ itemHeight ? { height: itemHeight * 1.2 } : null,
+ itemHeight ? { top: -itemHeight * 0.1 } : null,
+ { width: "100%" },
+ ].filter(Boolean);
+ if (parallaxTranslateY) {
+ parallaxStyles.push({
+ transform: [{ translateY: parallaxTranslateY }],
+ });
+ }
+ return (
+
+
+
+ {card}
+
+ );
+ }
+
+ return (
+
+ {card}
+
+ );
+ }),
+ [backgroundImage, carouselItems, itemHeight, parallaxTranslateY]
+ );
+
+ const snapOffsets = useMemo(() => {
+ if (!itemHeight || !isWeb) {
+ return undefined;
+ }
+ return carouselItems.map((_, index) => index * itemHeight);
+ }, [carouselItems, isWeb, itemHeight]);
+
+ const blurIntensity = isWeb ? 80 : 30;
+ const dotsWrapperStyle = useMemo(
+ () => [
+ styles.dotsWrapperBase,
+ isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative,
+ ],
+ [isWeb]
+ );
+
+ return (
+
+
+ {renderContent}
+
+
+
+
+
+ );
+};
+
+export default FeatureCarousel;
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ width: "100%",
+ flexDirection: "row",
+ backgroundColor: HOME_BACKGROUND_COLOR,
+ },
+ containerWeb: {
+ // paddingRight: 56,
+ },
+ list: {
+ flex: 1,
+ },
+ scrollContent: {
+ flexGrow: 1,
+ },
+ backgroundItem: {
+ width: "100%",
+ justifyContent: "flex-start",
+ },
+ parallaxContainer: {
+ overflow: "hidden",
+ position: "relative",
+ backgroundColor: HOME_BACKGROUND_COLOR,
+ },
+ parallaxImage: {
+ position: "absolute",
+ left: 0,
+ right: 0,
+ },
+ backgroundGradient: {
+ ...StyleSheet.absoluteFillObject,
+ },
+ plainBackground: {
+ backgroundColor: HOME_BACKGROUND_COLOR,
+ },
+ cardWrapper: {
+ flex: 1,
+ width: "100%",
+ justifyContent: "flex-start",
+ zIndex: 1,
+ },
+ dotsWrapperBase: {
+ justifyContent: "center",
+ alignItems: "center",
+ borderRadius: 16,
+ paddingVertical: 12,
+ paddingHorizontal: 8,
+ overflow: "hidden",
+ backgroundColor: "rgba(18, 18, 18, 0.2)",
+ },
+ dotsWrapperWeb: {
+ position: "absolute",
+ right: 12,
+ top: 0,
+ bottom: 0,
+ maxHeight: "75%",
+ alignSelf: "center",
+ },
+ dotsWrapperNative: {
+ marginLeft: 12,
+ alignSelf: "center",
+ },
+ dotsContainer: {
+ flexDirection: "column",
+ },
+ dot: {
+ width: 8,
+ height: 8,
+ marginHorizontal: 0,
+ marginVertical: 6,
+ borderRadius: 999,
+ backgroundColor: "rgba(255,255,255,0.4)",
+ },
+});
diff --git a/src/components/cards/PersonaCard/PersonaCard.js b/src/components/cards/PersonaCard/PersonaCard.js
index 149e955..33a9e81 100644
--- a/src/components/cards/PersonaCard/PersonaCard.js
+++ b/src/components/cards/PersonaCard/PersonaCard.js
@@ -18,6 +18,7 @@ export default function PersonaCard({ item, index, isLock = false, height }) {
justifyContent: "center",
alignItems: "center",
height: cardHeight,
+ width: "100%",
}}
>
{
const continueDisabled = !hasActiveProject || stageLocked || !stageRoute;
const startDisabled = !songwriterAction?.route;
return (
-
+
{
stageStates={stageStates}
activeIndex={activeStageIndex}
onActiveIndexChange={setActiveStageIndex}
+ backgroundImage={background.homeBGWeb}
/>