diff --git a/app.json b/app.json index 45133ca..808957f 100644 --- a/app.json +++ b/app.json @@ -82,7 +82,8 @@ ] }, "web": { - "bundler": "metro" + "bundler": "metro", + "favicon": "./assets/favicon.png" }, "plugins": [ "react-native-compressor", diff --git a/assets/favicon.png b/assets/favicon.png new file mode 100644 index 0000000..3d55754 Binary files /dev/null and b/assets/favicon.png differ diff --git a/src/components/MobileCoinBadge.js b/src/components/MobileCoinBadge.js index b17b31f..49aa69f 100644 --- a/src/components/MobileCoinBadge.js +++ b/src/components/MobileCoinBadge.js @@ -25,7 +25,7 @@ const MobileCoinBadge = ({ style, textStyle, iconSize = 20, iconPosition = 'left }, [currentUserData?.coins]) const handlePress = useCallback(() => { - navigate(Routes.Payments) + navigate(Routes.Payments, { pack: 'packs' }) }, []) if (!currentUID || Platform.OS === 'web') { diff --git a/src/components/modal/PlaylistModal.js b/src/components/modal/PlaylistModal.js index 438d146..aa4e20e 100644 --- a/src/components/modal/PlaylistModal.js +++ b/src/components/modal/PlaylistModal.js @@ -1,9 +1,10 @@ -import React, { useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Dimensions, FlatList, Image, Pressable, Text, TextInput, View } from 'react-native' import { SheetManager } from 'react-native-actions-sheet' import useMinuit from 'react-native-minuit/src/hooks/useMinuit' import SwiperFlatList from 'react-native-swiper-flatlist' import { icons } from '../../assets' +import usePlaylistCreationGuard from '../../hooks/usePlaylistCreationGuard' import { useUserData } from '../../providers/UserDataProvider' import { addMusicToPlaylist, createPlaylist } from '../../screens/Library/Playlists/playlist' import { Palette } from '../../styles' @@ -20,8 +21,9 @@ const PlaylistModal = (props) => { const [selectedId, setSelectedId] = useState('') const [playlist, setPlaylist] = useState('') const [isCreating, setIsCreating] = useState(false) - const { currentUID, userPlaylists = [] } = useUserData() + const { currentUID, userPlaylists = [], hasActiveSubscription } = useUserData() const startAtCreate = props?.payload?.startAtCreate || false + const ensureCanCreatePlaylist = usePlaylistCreationGuard() const { setTooltip } = useMinuit() const sanitizedPlaylists = useMemo( @@ -35,9 +37,19 @@ const PlaylistModal = (props) => { return sanitizedPlaylists.some((p) => (p?.name || '').trim().toLowerCase() === lower) }, [playlist, sanitizedPlaylists]) - const hideSheet = () => { + const hideSheet = useCallback(() => { SheetManager.hide('Playlist') - } + }, []) + + useEffect(() => { + if (!startAtCreate || hasActiveSubscription) { + return + } + + if (!ensureCanCreatePlaylist()) { + hideSheet() + } + }, [ensureCanCreatePlaylist, hasActiveSubscription, hideSheet, startAtCreate]) return ( @@ -45,7 +57,7 @@ const PlaylistModal = (props) => { style={{ width, left: -14 }} ref={scrollRef} disableGesture - index={startAtCreate ? 1 : 0} + index={startAtCreate && hasActiveSubscription ? 1 : 0} > @@ -70,12 +82,17 @@ const PlaylistModal = (props) => { ...Style.containerRow, gap: 10, }} - onPress={() => + onPress={() => { + if (!ensureCanCreatePlaylist()) { + hideSheet() + return + } + scrollRef.current?.scrollToIndex({ index: 1, animated: true, }) - } + }} > { onPress={async () => { if (!playlist?.trim()?.length || !currentUID) return try { + if (!ensureCanCreatePlaylist()) { + hideSheet() + return + } + setIsCreating(true) const name = playlist.trim() const lower = name.toLowerCase() @@ -232,7 +254,7 @@ const PlaylistModal = (props) => { musics: props?.payload?.projectId ? [props.payload.projectId] : [], createdAt: new Date(), updatedAt: new Date(), - }) + }, { hasActiveSubscription }) setTooltip({ type: 'success', diff --git a/src/components/modal/PlaylistModal.web.js b/src/components/modal/PlaylistModal.web.js index 2c03191..1062fd2 100644 --- a/src/components/modal/PlaylistModal.web.js +++ b/src/components/modal/PlaylistModal.web.js @@ -1,11 +1,12 @@ import { Portal } from '@gorhom/portal' import { BlurView } from 'expo-blur' -import React, { useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { FlatList, Image, Pressable, StyleSheet, Text, TextInput, View } from 'react-native' import { SheetManager, useProviderContext } from 'react-native-actions-sheet' import { actionSheetEventManager } from 'react-native-actions-sheet/dist/src/eventmanager' import useMinuit from 'react-native-minuit/src/hooks/useMinuit' import { icons } from '../../assets' +import usePlaylistCreationGuard from '../../hooks/usePlaylistCreationGuard' import { useUserData } from '../../providers/UserDataProvider' import { addMusicToPlaylist, createPlaylist } from '../../screens/Library/Playlists/playlist' import { Palette } from '../../styles' @@ -18,16 +19,17 @@ const PlaylistModal = (props) => { const [selectedId, setSelectedId] = useState('') const [playlist, setPlaylist] = useState('') const [isCreating, setIsCreating] = useState(false) - const { currentUID, userPlaylists = [] } = useUserData() + const { currentUID, userPlaylists = [], hasActiveSubscription } = useUserData() + const ensureCanCreatePlaylist = usePlaylistCreationGuard() const { setTooltip } = useMinuit() const startAtCreate = props?.payload?.startAtCreate || false - const [mode, setMode] = useState(startAtCreate ? 'create' : 'select') + const [mode, setMode] = useState(startAtCreate && hasActiveSubscription ? 'create' : 'select') const [visible, setVisible] = useState(true) const providerContext = useProviderContext() useEffect(() => { - setMode(startAtCreate ? 'create' : 'select') - }, [startAtCreate]) + setMode(startAtCreate && hasActiveSubscription ? 'create' : 'select') + }, [hasActiveSubscription, startAtCreate]) const sanitizedPlaylists = useMemo( () => (Array.isArray(userPlaylists) ? userPlaylists : []), @@ -44,7 +46,7 @@ const PlaylistModal = (props) => { setVisible(true) }, [props?.sheetId, props?.payload]) - const hideModal = () => { + const hideModal = useCallback(() => { setVisible(false) props?.payload?.onClose?.() @@ -56,7 +58,17 @@ const PlaylistModal = (props) => { // close event manually to let the SheetProvider unmount it. actionSheetEventManager.publish(`onclose_${sheetId}`, undefined, providerContext) } - } + }, [props?.payload, props?.sheetId, providerContext]) + + useEffect(() => { + if (!startAtCreate || hasActiveSubscription) { + return + } + + if (!ensureCanCreatePlaylist()) { + hideModal() + } + }, [ensureCanCreatePlaylist, hasActiveSubscription, hideModal, startAtCreate]) const handleValidateSelection = async () => { if (!selectedId) return @@ -84,6 +96,10 @@ const PlaylistModal = (props) => { const handleCreatePlaylist = async () => { if (!playlist?.trim()?.length || !currentUID) return + if (!ensureCanCreatePlaylist()) { + hideModal() + return + } try { setIsCreating(true) const name = playlist.trim() @@ -103,7 +119,7 @@ const PlaylistModal = (props) => { musics: props?.payload?.projectId ? [props.payload.projectId] : [], createdAt: new Date(), updatedAt: new Date(), - }) + }, { hasActiveSubscription }) setTooltip({ type: 'success', @@ -152,6 +168,10 @@ const PlaylistModal = (props) => { alignSelf: 'center', }} onPress={() => { + if (!ensureCanCreatePlaylist()) { + hideModal() + return + } setPlaylist('') setMode('create') }} diff --git a/src/hooks/usePlaylistCreationGuard.js b/src/hooks/usePlaylistCreationGuard.js new file mode 100644 index 0000000..9295e9a --- /dev/null +++ b/src/hooks/usePlaylistCreationGuard.js @@ -0,0 +1,29 @@ +import { useCallback } from 'react' +import useMinuit from 'react-native-minuit/src/hooks/useMinuit' +import { Routes } from '../navigation' +import { navigate } from '../navigation/NavigationService' +import { useUserData } from '../providers/UserDataProvider' +import { + canCreatePlaylist, + PLAYLIST_CREATION_SUBSCRIPTION_MESSAGE, +} from '../utils/playlistCreationAccess' + +const usePlaylistCreationGuard = () => { + const { hasActiveSubscription } = useUserData() || {} + const { setTooltip } = useMinuit() + + return useCallback(() => { + if (canCreatePlaylist(hasActiveSubscription)) { + return true + } + + setTooltip?.({ + type: 'error', + text: PLAYLIST_CREATION_SUBSCRIPTION_MESSAGE, + }) + navigate(Routes.Payments) + return false + }, [hasActiveSubscription, setTooltip]) +} + +export default usePlaylistCreationGuard diff --git a/src/layouts/Page.js b/src/layouts/Page.js index d9381a8..5f301c7 100644 --- a/src/layouts/Page.js +++ b/src/layouts/Page.js @@ -122,7 +122,7 @@ export default ({ const handleCoinPress = React.useCallback(() => { if (!showCoinBadge) return - navigate(Routes.Payments) + navigate(Routes.Payments, { pack: 'packs' }) }, [showCoinBadge]) const handleCloseCoinModal = React.useCallback(() => { diff --git a/src/screens/Home/Home.js b/src/screens/Home/Home.js index a7c8b9d..0c29949 100644 --- a/src/screens/Home/Home.js +++ b/src/screens/Home/Home.js @@ -434,7 +434,7 @@ const Home = ({ navigation, route }) => { }, []) const handleOpenCoinModal = useCallback(() => { - navigate(Routes.Payments) + navigate(Routes.Payments, { pack: 'packs' }) }, []) const stageCardContainerStyle = isWeb ? styles.cardsGrid : styles.cardsStack diff --git a/src/screens/Library/AllMyPlaylist.js b/src/screens/Library/AllMyPlaylist.js index d907526..27cd006 100644 --- a/src/screens/Library/AllMyPlaylist.js +++ b/src/screens/Library/AllMyPlaylist.js @@ -3,6 +3,7 @@ import React from 'react' import { Image, Platform, Pressable, Text, View } from 'react-native' import { SheetManager } from 'react-native-actions-sheet' import { background, icons } from '../../assets' +import usePlaylistCreationGuard from '../../hooks/usePlaylistCreationGuard' import Page from '../../layouts/Page' import { Routes } from '../../navigation' import { navigate } from '../../navigation/NavigationService' @@ -12,15 +13,23 @@ import { FONT_FAMILY } from '../../styles/Fonts' import { size } from '../../styles/Style' const AllMyPlaylist = () => { const { userPlaylists = [] } = useUser() + const ensureCanCreatePlaylist = usePlaylistCreationGuard() + + const handleCreatePlaylistPress = () => { + if (!ensureCanCreatePlaylist()) { + return + } + + SheetManager.show('Playlist', { payload: { startAtCreate: true } }) + } + return ( ( - SheetManager.show('Playlist', { payload: { startAtCreate: true } })} - > + )} diff --git a/src/screens/Library/AllMyPlaylist.web.js b/src/screens/Library/AllMyPlaylist.web.js index 9d82d00..47b941d 100644 --- a/src/screens/Library/AllMyPlaylist.web.js +++ b/src/screens/Library/AllMyPlaylist.web.js @@ -7,6 +7,7 @@ import { background, icons } from '../../assets' import MoreMenu from '../../components/MoreMenu' import { playlistsRef, projectsRef } from '../../config/firebase' import useDataFromArrayDocId from '../../hooks/useDataFromArrayId' +import usePlaylistCreationGuard from '../../hooks/usePlaylistCreationGuard' import useDataFromRef from '../../hooks/useDataFromRef' import useNavigateToMusicDetails from '../../hooks/useNavigateToMusicDetails' import usePlayer from '../../hooks/usePlayer' @@ -22,6 +23,7 @@ const AllMyPlaylist = ({ route }) => { const { playListId } = route?.params || {} const [, setTooltip] = useGlobal('_tooltip') const { userPlaylists = [] } = useUser() + const ensureCanCreatePlaylist = usePlaylistCreationGuard() const [selectedPlaylistId, setSelectedPlaylistId] = useState(playListId || null) const { data: playlist } = useDataFromRef({ ref: selectedPlaylistId ? playlistsRef.doc(selectedPlaylistId) : null, @@ -140,6 +142,16 @@ const AllMyPlaylist = ({ route }) => { }) }, [playlist?.musics, selectedPlaylistId]) + const handleCreatePlaylistPress = useCallback(() => { + if (!ensureCanCreatePlaylist()) { + return + } + + SheetManager.show('Playlist', { + payload: { startAtCreate: true }, + }) + }, [ensureCanCreatePlaylist]) + return ( { ) })} - - SheetManager.show('Playlist', { - payload: { startAtCreate: true }, - }) - } - > + { +export const createPlaylist = async (payload = {}, options = {}) => { try { + assertCanCreatePlaylist(options?.hasActiveSubscription) + const createdBy = payload?.createdBy const name = payload?.name?.trim?.() const musics = Array.isArray(payload?.musics) ? payload.musics : [] diff --git a/src/screens/Library/components/MyPlaylist.js b/src/screens/Library/components/MyPlaylist.js index dc18cde..223ab8d 100644 --- a/src/screens/Library/components/MyPlaylist.js +++ b/src/screens/Library/components/MyPlaylist.js @@ -5,6 +5,7 @@ import { SheetManager } from 'react-native-actions-sheet' import { icons } from '../../../assets' import GradientButton from '../../../components/GradientButton' import useLayoutType from '../../../hooks/useLayoutType' +import usePlaylistCreationGuard from '../../../hooks/usePlaylistCreationGuard' import { Routes } from '../../../navigation' import { navigate } from '../../../navigation/NavigationService' import { useUser } from '../../../providers/UserDataProvider' @@ -16,16 +17,24 @@ const MyPlaylist = () => { const { userPlaylists = [] } = useUser() const playlists = Array.isArray(userPlaylists) ? userPlaylists.slice(0, 2) : [] const { isWeb } = useLayoutType() + const ensureCanCreatePlaylist = usePlaylistCreationGuard() const hasPlaylists = playlists.length > 0 + + const handleCreatePlaylistPress = () => { + if (!ensureCanCreatePlaylist()) { + return + } + + SheetManager.show('Playlist', { + payload: { startAtCreate: true }, + }) + } + return ( navigate(Routes.AllMyPlaylist)} - onPressPlus={() => - SheetManager.show('Playlist', { - payload: { startAtCreate: true }, - }) - } + onPressPlus={handleCreatePlaylistPress} > {hasPlaylists ? ( @@ -91,11 +100,7 @@ const MyPlaylist = () => { - SheetManager.show('Playlist', { - payload: { startAtCreate: true }, - }) - } + onPress={handleCreatePlaylistPress} title=" Créer une playlist" /> diff --git a/src/screens/Subscriptions.js b/src/screens/Subscriptions.js index 8ce636b..03dc195 100644 --- a/src/screens/Subscriptions.js +++ b/src/screens/Subscriptions.js @@ -183,6 +183,55 @@ function SubscriptionCard({ plan, selected, onSelect, isAnnual }) { ) } +function CoinPackCard({ pack, onBuy, isProcessing }) { + const rawAmount = pack?.unitAmount ?? pack?.amount ?? pack?.price ?? 0 + const formattedPrice = formatCurrency(rawAmount, pack?.currency) + const coins = Number(pack?.coinAmount ?? pack?.metadata?.coins ?? 0) + const packKey = getCoinPackKey(pack) + const details = packKey ? COIN_PACK_DETAILS[packKey] : null + const displayName = details?.label || pack?.name || 'Pack' + const creditsText = details?.creditsDisplay + const displayPrice = details?.priceDisplay || formattedPrice || `${rawAmount / 100}€` + const isPopular = details?.popular === true + + return ( + + + + + {displayName} + {creditsText ? ( + {creditsText} + ) : ( + + )} + {details?.disclaimer ? ( + {details.disclaimer} + ) : null} + + + + {displayPrice ? {displayPrice} : null} + + + + {isPopular ? ( + + Le plus populaire ! + + ) : null} + + onBuy(pack)} + disabled={isProcessing} + gradientStyle={styles.coinPackButtonGradient} + /> + + + ) +} + const PRICE_PRIORITY_BY_PERIOD = { monthly: [ 'price_1SPgitCzf2o5bDRdbnhLFx6f', // Starter @@ -248,6 +297,7 @@ export default function Subscriptions() { const rawPack = route?.params?.pack return typeof rawPack === 'string' ? rawPack.toLowerCase() : null }, [route?.params?.pack]) + const isCoinPackView = isMobile && initialPack === 'packs' const initialSubscriptionPack = initialPack && initialPack !== 'packs' ? initialPack : null @@ -285,6 +335,7 @@ export default function Subscriptions() { const hasAnyPlan = availablePeriods.length > 0 const isLoadingPlans = isCatalogLoading && !hasAnyPlan const combinedErrorMessage = errorMessage || catalogError + const screenTitle = isCoinPackView ? COIN_PACK_SECTION_TITLE : 'Rejoignez le club MusicLand' React.useEffect(() => { initialPackHandledRef.current = false @@ -410,9 +461,9 @@ export default function Subscriptions() { const mobileListContentInset = React.useMemo( () => ({ - paddingBottom: mobileActionSafePadding + gutters * 3, + paddingBottom: isCoinPackView ? gutters * 2 : mobileActionSafePadding + gutters * 3, }), - [mobileActionSafePadding] + [isCoinPackView, mobileActionSafePadding] ) const handleBackPress = React.useCallback(() => { goBack() @@ -455,7 +506,7 @@ export default function Subscriptions() { return ( <> - Rejoignez le club MusicLand + {screenTitle} Votre solde actuel : @@ -464,9 +515,13 @@ export default function Subscriptions() { {combinedErrorMessage ? {combinedErrorMessage} : null} ) - }, [combinedErrorMessage]) + }, [combinedErrorMessage, currentUserData?.coins, screenTitle]) const renderSegmentedControl = React.useCallback(() => { + if (isCoinPackView) { + return null + } + const segments = [ { key: 'monthly', label: 'Mensuel', plans: normalizedPlansByPeriod?.monthly }, { key: 'annual', label: 'Annuel', plans: normalizedPlansByPeriod?.annual }, @@ -504,7 +559,7 @@ export default function Subscriptions() { })} ) - }, [handlePeriodChange, isMobile, normalizedPlansByPeriod, selectedPeriodKey]) + }, [handlePeriodChange, isCoinPackView, isMobile, normalizedPlansByPeriod, selectedPeriodKey]) const renderMobilePlanItem = React.useCallback( ({ item }) => ( @@ -520,30 +575,51 @@ export default function Subscriptions() { [handleSelect, selectedPeriodKey, selectedPriceId] ) + const renderMobileCoinPackItem = React.useCallback( + ({ item }) => ( + + + + ), + [handleBuyCredits, processingPriceId] + ) + const renderMobileEmptyComponent = React.useCallback(() => { return ( - {isLoadingPlans ? ( + {(isCoinPackView ? isCatalogLoading && !coinPacks.length : isLoadingPlans) ? ( ) : ( - Aucun abonnement Stripe disponible pour le moment. + + {isCoinPackView + ? "Aucun pack de crédits n'est disponible pour le moment." + : 'Aucun abonnement Stripe disponible pour le moment.'} + )} ) - }, [isLoadingPlans]) + }, [coinPacks.length, isCatalogLoading, isCoinPackView, isLoadingPlans]) const renderMobileFooterComponent = React.useCallback(() => { return ( - {isLoadingPlans && currentPlanCount > 0 ? ( + {(isCoinPackView ? isCatalogLoading && coinPacks.length > 0 : isLoadingPlans && currentPlanCount > 0) ? ( ) : null} - {SUBSCRIPTION_DISCLAIMER} + + {isCoinPackView + ? 'Les crédits sont ajoutés dès que le paiement Stripe est validé.' + : SUBSCRIPTION_DISCLAIMER} + ) - }, [currentPlanCount, isLoadingPlans]) + }, [coinPacks.length, currentPlanCount, isCatalogLoading, isCoinPackView, isLoadingPlans]) const renderMobileTopSticky = React.useCallback(() => { return ( @@ -566,6 +642,7 @@ export default function Subscriptions() { ) }, [actionButtonTitle, handleCheckout, isActionDisabled, mobileActionSafePadding]) + const renderWebBackButton = React.useCallback(() => { if (isMobile) return null return ( @@ -586,7 +663,7 @@ export default function Subscriptions() { {isMobile ? ( plan.priceId} - renderItem={renderMobilePlanItem} + data={isCoinPackView ? coinPacks : currentPlans} + keyExtractor={(item) => item.productId || item.priceId} + renderItem={isCoinPackView ? renderMobileCoinPackItem : renderMobilePlanItem} style={styles.mobileList} contentContainerStyle={[styles.mobileListContent, mobileListContentInset]} ListEmptyComponent={renderMobileEmptyComponent} @@ -716,7 +793,7 @@ export default function Subscriptions() { - {isMobile ? renderMobileBottomActions() : null} + {isMobile && !isCoinPackView ? renderMobileBottomActions() : null} ) } @@ -1162,6 +1239,17 @@ const styles = StyleSheet.create({ fontSize: isWeb ? 22 : 20, color: Palette.white, }, + coinPackAmount: { + fontFamily: FONT_FAMILY.InterBold, + fontSize: 16, + color: Palette.white, + }, + coinPackAmountValue: { + fontSize: 20, + }, + coinPackButtonGradient: { + width: '100%', + }, period: { fontFamily: FONT_FAMILY.InterRegular, fontSize: 14, diff --git a/src/utils/playlistCreationAccess.js b/src/utils/playlistCreationAccess.js new file mode 100644 index 0000000..7b9cc46 --- /dev/null +++ b/src/utils/playlistCreationAccess.js @@ -0,0 +1,10 @@ +export const PLAYLIST_CREATION_SUBSCRIPTION_MESSAGE = + 'La création de playlist est réservée aux abonnés.' + +export const canCreatePlaylist = (hasActiveSubscription = false) => hasActiveSubscription === true + +export const assertCanCreatePlaylist = (hasActiveSubscription = false) => { + if (!canCreatePlaylist(hasActiveSubscription)) { + throw new Error(PLAYLIST_CREATION_SUBSCRIPTION_MESSAGE) + } +}