From f7ff6fee1bb843a7281ce6e6a3933dd1e0dad064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:20:45 +0100 Subject: [PATCH] Wip earnings in gamedashboard. Data is not accurate, needs modification in the services side --- .../src/GameDashboard/GameAnalyticsCharts.js | 56 ++- .../GameDashboard/GameAnalyticsEvaluator.js | 353 +++++++++++++++--- .../src/GameDashboard/GameAnalyticsPanel.js | 109 ++++-- .../Monetization/UserEarningsWidget.js | 85 ++--- .../GameDashboard/Widgets/AnalyticsWidget.js | 164 ++++---- .../GameDashboard/Widgets/ServicesWidget.js | 2 +- newIDE/app/src/GameDashboard/index.js | 42 ++- .../src/Utils/GDevelopServices/Analytics.js | 4 +- .../src/Utils/GDevelopServices/ApiConfigs.js | 2 +- .../app/src/Utils/GDevelopServices/Usage.js | 44 +++ 10 files changed, 636 insertions(+), 225 deletions(-) diff --git a/newIDE/app/src/GameDashboard/GameAnalyticsCharts.js b/newIDE/app/src/GameDashboard/GameAnalyticsCharts.js index ec48a174ea38..8c1dddd6f57b 100644 --- a/newIDE/app/src/GameDashboard/GameAnalyticsCharts.js +++ b/newIDE/app/src/GameDashboard/GameAnalyticsCharts.js @@ -57,10 +57,12 @@ const CustomTooltip = ({ payload, label, customStyle, + decimals = 2, }: {| payload: ?Array, label: string, customStyle: Object, + decimals?: number, |}) => payload ? ( @@ -79,7 +81,9 @@ const CustomTooltip = ({ index ) => ( {`${name}: ${ - Number.isInteger(value) ? value.toString() : value.toFixed(2) + Number.isInteger(value) + ? value.toString() + : value.toFixed(decimals) }${unit ? ` ${unit}` : ''}`} ) )} @@ -397,3 +401,53 @@ export const PlayersDurationPerDayChart = ({ ); }; + +export const GameAdEarningsChart = ({ + i18n, + chartData, + height, + fontSize, +}: ChartProps) => { + const gdevelopTheme = React.useContext(GDevelopThemeContext); + const styles = getChartsStyleFromTheme(gdevelopTheme); + + return ( + + + + + + + + CustomTooltip({ + ...props, + customStyle: styles.tooltipContent, + }) + } + /> + + + ); +}; diff --git a/newIDE/app/src/GameDashboard/GameAnalyticsEvaluator.js b/newIDE/app/src/GameDashboard/GameAnalyticsEvaluator.js index 604ca46bdf83..ad5a968a1698 100644 --- a/newIDE/app/src/GameDashboard/GameAnalyticsEvaluator.js +++ b/newIDE/app/src/GameDashboard/GameAnalyticsEvaluator.js @@ -7,13 +7,31 @@ import { addDays, } from 'date-fns'; import { type GameMetrics } from '../Utils/GDevelopServices/Analytics'; +import { + type GameAdEarning, + type Usage, +} from '../Utils/GDevelopServices/Usage'; -export type MergedGameMetrics = GameMetrics & { +export type MergedGameMetrics = {| + ...GameMetrics, /** * The start date is not defined when only one day is merged. */ startDate: string | null, -}; +|}; + +export type MergedGameAdEarnings = {| + ...GameAdEarning, + /** + * The start date is not defined when only one day is merged. + */ + startDate: string | null, +|}; + +export type CashOuts = {| + date: string, + amountInCredits: number, +|}; /** * It's divisible by 7. @@ -46,6 +64,8 @@ export type ChartData = {| playersPercent: number, durationInMinutes: number, |}, + + totalEarningsInUSDs: number, |}, /** * Metrics for each day of a month or each week of a year. @@ -92,6 +112,14 @@ export type ChartData = {| * A funnel of the remaining players after a given played duration. */ overPlayedDuration: {| duration: number, playersCount: number |}[], + /** + * Accumulated earnings from ads each day. + */ + adsEarnings: {| + date: string, + accumulatedEarningsInCredits: number, + accumulatedEarningsInUSDs: number, + |}[], |}; const emptyChartData: ChartData = { @@ -110,9 +138,11 @@ const emptyChartData: ChartData = { playersPercent: 0, durationInMinutes: 0, }, + totalEarningsInUSDs: 0, }, overTime: [], overPlayedDuration: [], + adsEarnings: [], }; const durationIndexes: { [string]: number } = { @@ -124,9 +154,9 @@ const durationIndexes: { [string]: number } = { }; export const durationValues = [1, 3, 5, 10, 15]; -const createZeroesMetric = (date: Date): GameMetrics => { +const createZeroesGameMetric = (date: Date): GameMetrics => { return { - date: formatISO(date), + date: formatISO(date, { representation: 'date' }), sessions: { d0Sessions: 0, @@ -150,10 +180,15 @@ const createZeroesMetric = (date: Date): GameMetrics => { * @param gameMetrics concise game metrics from the backend (today first) * @returns game metrics with a metric for each 364 past days (today first). */ -const fillMissingDays = ( +const fillMissingGameMetricsDays = ({ + gameMetrics, + todayDate, + totalDays, +}: { gameMetrics: Array, - todayDate: Date -): Array => { + todayDate: Date, + totalDays: number, +}): Array => { const filledGameMetrics = []; // TODO In some timezones, it might start the wrong day. let previousMetricDate = addDays(todayDate, 1); @@ -164,21 +199,77 @@ const fillMissingDays = ( differenceInCalendarDays(parseISO(metric.date), previousMetricDate) < -1 ) { const addedMetricDate = subDays(previousMetricDate, 1); - filledGameMetrics.push(createZeroesMetric(addedMetricDate)); + filledGameMetrics.push(createZeroesGameMetric(addedMetricDate)); previousMetricDate = addedMetricDate; } filledGameMetrics.push(metric); previousMetricDate = metricDate; } - // Fill to one year - while (filledGameMetrics.length < daysShownForYear) { + // Fill to total days + while (filledGameMetrics.length < totalDays) { const addedMetricDate = subDays(previousMetricDate, 1); - filledGameMetrics.push(createZeroesMetric(addedMetricDate)); + filledGameMetrics.push(createZeroesGameMetric(addedMetricDate)); previousMetricDate = addedMetricDate; } return filledGameMetrics; }; +const createZeroesGameAdEarning = ( + gameId: string, + date: Date +): GameAdEarning => { + return { + gameId, + date: formatISO(date, { representation: 'date' }), + adEarningsInCredits: 0, + adEarningsInMilliUSDs: 0, + updatedAt: date.getTime(), + }; +}; + +const fillMissingGameAdEarningsDays = ({ + gameAdEarnings, + todayDate, + totalDays, + gameId, +}: { + gameAdEarnings: Array, + todayDate: Date, + totalDays: number, + gameId: string, +}): Array => { + const filledGameAdEarnings = []; + let previousEarningDate = addDays(todayDate, 1); + for (const earning of gameAdEarnings) { + const earningDate = parseISO(earning.date); + // Fill holes + while ( + differenceInCalendarDays(parseISO(earning.date), previousEarningDate) < -1 + ) { + const addedEarningDate = subDays(previousEarningDate, 1); + filledGameAdEarnings.push( + createZeroesGameAdEarning(gameId, addedEarningDate) + ); + previousEarningDate = addedEarningDate; + } + filledGameAdEarnings.push(earning); + previousEarningDate = earningDate; + } + + // Fill to total days + while (filledGameAdEarnings.length < totalDays) { + const addedEarningDate = subDays(previousEarningDate, 1); + filledGameAdEarnings.push( + createZeroesGameAdEarning(gameId, addedEarningDate) + ); + previousEarningDate = addedEarningDate; + } + + console.log('filledGameAdEarnings', filledGameAdEarnings); + + return filledGameAdEarnings; +}; + /** * Sum each metric or `undefined` when one side is `undefined`. * When one metric is `undefined`, the value of the other is not used because @@ -192,8 +283,8 @@ const fillMissingDays = ( * @returns the sum for each metric or `undefined` when one side is `undefined` */ const mergeGameMetrics = ( - a: GameMetrics, - b: GameMetrics + a: GameMetrics | MergedGameMetrics, + b: GameMetrics | MergedGameMetrics ): MergedGameMetrics => { return { date: a.date, @@ -269,6 +360,44 @@ const mergeGameMetricsByWeek = ( return mergedGameMetrics; }; +const mergeGameAdEarnings = ( + a: GameAdEarning | MergedGameAdEarnings, + b: GameAdEarning | MergedGameAdEarnings +): MergedGameAdEarnings => { + return { + date: a.date, + startDate: b.date, + + gameId: a.gameId, + adEarningsInCredits: a.adEarningsInCredits + b.adEarningsInCredits, + adEarningsInMilliUSDs: a.adEarningsInMilliUSDs + b.adEarningsInMilliUSDs, + updatedAt: a.updatedAt, + }; +}; + +const mergeGameAdEarningsByWeek = ( + gameAdEarnings: GameAdEarning[] +): MergedGameAdEarnings[] => { + const mergedGameAdEarnings: Array = []; + for (let weekIndex = 0; weekIndex < gameAdEarnings.length; weekIndex += 7) { + let mergedGameAdEarning = gameAdEarnings[weekIndex]; + for ( + let index = weekIndex + 1; + index < weekIndex + 7 && index < gameAdEarnings.length; + index++ + ) { + mergedGameAdEarning = mergeGameAdEarnings( + mergedGameAdEarning, + gameAdEarnings[index] + ); + } + mergedGameAdEarnings.push( + ((mergedGameAdEarning: any): MergedGameAdEarnings) + ); + } + return mergedGameAdEarnings; +}; + /** * @param playersBelowSums * @param playersCount @@ -350,13 +479,36 @@ const subtract = (a: ?number, b: ?number): number => { * @returns enriched game metrics that are ready to be used in a chart * (today first). */ -const evaluateChartData = (metrics: MergedGameMetrics[]): ChartData => { +const evaluateChartData = ({ + allMergedGameMetrics, + allMergedGameAdEarnings, + cashOuts, + period, +}: { + allMergedGameMetrics: MergedGameMetrics[], + allMergedGameAdEarnings: MergedGameAdEarnings[], + cashOuts: CashOuts[], + period: 'week' | 'month' | 'year', +}): ChartData => { let playersBelowSums = [0, 0, 0, 0, 0]; let playersSum = 0; let onlyFullyDefinedPlayersSum = 0; let playedDurationSumInMinutes = 0; - metrics.forEach(metric => { + const numberOfItemsInPeriod = + period === 'week' + ? 7 + : period === 'month' + ? 30 + : // merged by week + 52; + + const gameMetricsForPeriod = allMergedGameMetrics.slice( + 0, + numberOfItemsInPeriod + ); + + gameMetricsForPeriod.forEach(metric => { const d0SessionsDurationTotal = metric.sessions && metric.sessions.d0SessionsDurationTotal !== null ? metric.sessions.d0SessionsDurationTotal @@ -412,9 +564,9 @@ const evaluateChartData = (metrics: MergedGameMetrics[]): ChartData => { const dateFormatOptions = { month: 'short', day: 'numeric' }; const noMonthDateFormatOptions = { day: 'numeric' }; - const formatDate = (metric: MergedGameMetrics) => { - const startIsoDate = metric.startDate; - const endDate = parseISO(metric.date); + const formatDate = (chartItem: MergedGameMetrics | MergedGameAdEarnings) => { + const startIsoDate = chartItem.startDate; + const endDate = parseISO(chartItem.date); const formattedDate = endDate.toLocaleDateString( undefined, dateFormatOptions @@ -435,6 +587,56 @@ const evaluateChartData = (metrics: MergedGameMetrics[]): ChartData => { ); }; + const sortedGameAdEarnings = allMergedGameAdEarnings.sort( + (a, b) => parseISO(a.date).getTime() - parseISO(b.date).getTime() + ); + console.log('sortedGameAdEarnings', sortedGameAdEarnings); + let accumulatedEarningsInCredits = 0; + let accumulatedEarningsInMilliUSDs = 0; + const sortedAndAccumulatedGameAdEarnings = sortedGameAdEarnings + .map((earning, index) => { + let accumulatedEarningsInCreditsToThatDay = + earning.adEarningsInCredits + accumulatedEarningsInCredits; + let accumulatedEarningsInMilliUSDsToThatDay = + earning.adEarningsInMilliUSDs + accumulatedEarningsInMilliUSDs; + + const cashOutsOnThatPeriod = cashOuts.filter( + cashOut => + cashOut.date === earning.date || + (!!earning.startDate && + cashOut.date >= earning.startDate && + cashOut.date <= earning.date) + ); + if (cashOutsOnThatPeriod.length) { + cashOutsOnThatPeriod.forEach(cashOut => { + console.log(accumulatedEarningsInCreditsToThatDay); + const estimatedCreditToMilliUSDsRatio = + accumulatedEarningsInMilliUSDsToThatDay / + accumulatedEarningsInCreditsToThatDay; + const cashOutInMilliUSDs = Math.floor( + cashOut.amountInCredits * estimatedCreditToMilliUSDsRatio + ); + + accumulatedEarningsInCreditsToThatDay -= cashOut.amountInCredits; + accumulatedEarningsInMilliUSDsToThatDay -= cashOutInMilliUSDs; + console.log( + cashOut, + estimatedCreditToMilliUSDsRatio, + cashOutInMilliUSDs + ); + }); + } + accumulatedEarningsInCredits = accumulatedEarningsInCreditsToThatDay; + accumulatedEarningsInMilliUSDs = accumulatedEarningsInMilliUSDsToThatDay; + return { + date: formatDate(earning), + accumulatedEarningsInCredits: accumulatedEarningsInCreditsToThatDay, + accumulatedEarningsInUSDs: + Math.floor(accumulatedEarningsInMilliUSDsToThatDay / 10) / 100, + }; + }) + .slice(allMergedGameAdEarnings.length - numberOfItemsInPeriod); + return { overview: { // Players from before the migration are shown as viewers @@ -470,8 +672,10 @@ const evaluateChartData = (metrics: MergedGameMetrics[]): ChartData => { : 0, durationInMinutes: durationValues[greaterDurationPlayerIndex], }, + totalEarningsInUSDs: + Math.floor(accumulatedEarningsInMilliUSDs / 10) / 100, }, - overTime: metrics + overTime: gameMetricsForPeriod .map(metric => { const d0SessionsDurationTotal = metric.sessions && metric.sessions.d0SessionsDurationTotal !== null @@ -609,6 +813,7 @@ const evaluateChartData = (metrics: MergedGameMetrics[]): ChartData => { }; }) ), + adsEarnings: sortedAndAccumulatedGameAdEarnings, }; }; @@ -617,55 +822,87 @@ const evaluateChartData = (metrics: MergedGameMetrics[]): ChartData => { * @returns enriched game metrics that are ready to be used in a chart * (today at last). */ -export const buildChartData = ( +export const buildChartData = ({ + gameId, + gameMetrics, + gameAdEarnings, + usages, + todayDate = new Date(), +}: { + gameId: string, gameMetrics: ?Array, - todayDate: Date = new Date() -): { yearChartData: ChartData, monthChartData: ChartData } => { - if (!gameMetrics) { + gameAdEarnings: ?Array, + usages: ?Array, + todayDate?: Date, +}): {| + yearChartData: ChartData, + monthChartData: ChartData, + weekChartData: ChartData, +|} => { + if (!gameMetrics || !gameAdEarnings || !usages) { return { yearChartData: emptyChartData, monthChartData: emptyChartData, + weekChartData: emptyChartData, }; } - const filledGameRollingMetrics = fillMissingDays( - gameMetrics.sort( + + const filledGameMetrics = fillMissingGameMetricsDays({ + gameMetrics: gameMetrics.sort( (a, b) => parseISO(b.date).getTime() - parseISO(a.date).getTime() ), - todayDate - ); - return { - yearChartData: evaluateChartData( - mergeGameMetricsByWeek(filledGameRollingMetrics) - ), - monthChartData: evaluateChartData( - filledGameRollingMetrics - .slice(0, 30) - .map(metric => ({ ...metric, startDate: null }: MergedGameMetrics)) - ), - }; -}; - -/** - * @param gameMetrics concise game metrics from the backend (today first) - * @returns enriched game metrics that are ready to be used in a chart - * (today at last). - */ -export const buildLastWeekChartData = ( - gameMetrics: ?Array, - todayDate: Date = new Date() -): ChartData => { - if (!gameMetrics) { - return emptyChartData; - } - const filledGameRollingMetrics = fillMissingDays( - gameMetrics.sort( + todayDate, + totalDays: daysShownForYear, + }); + const filledGameAdEarnings = fillMissingGameAdEarningsDays({ + gameAdEarnings: gameAdEarnings.sort( (a, b) => parseISO(b.date).getTime() - parseISO(a.date).getTime() ), - todayDate + todayDate, + totalDays: daysShownForYear, + gameId, + }); + const gameMetricsMergedByWeek = mergeGameMetricsByWeek(filledGameMetrics); + const gameAdEarningsMergedByWeek = mergeGameAdEarningsByWeek( + filledGameAdEarnings + ); + + const gameMetricsMergedByDay: MergedGameMetrics[] = filledGameMetrics.map( + metric => ({ ...metric, startDate: null }) ); - return evaluateChartData( - filledGameRollingMetrics - .slice(0, 7) - .map(metric => ({ ...metric, startDate: null }: MergedGameMetrics)) + const gameAdEarningsMergedByDay: MergedGameAdEarnings[] = filledGameAdEarnings.map( + earning => ({ ...earning, startDate: null }) ); + + const cashOuts = usages + .filter( + usage => + usage.type === 'change-balance' && + usage.description === 'Cash out of game earnings' + ) + .map(usage => ({ + date: formatISO(usage.createdAt, { representation: 'date' }), + amountInCredits: -(usage.creditsPaid || 0), + })); + + return { + yearChartData: evaluateChartData({ + allMergedGameMetrics: gameMetricsMergedByWeek, + allMergedGameAdEarnings: gameAdEarningsMergedByWeek, + cashOuts, + period: 'year', + }), + monthChartData: evaluateChartData({ + allMergedGameMetrics: gameMetricsMergedByDay, + allMergedGameAdEarnings: gameAdEarningsMergedByDay, + cashOuts, + period: 'month', + }), + weekChartData: evaluateChartData({ + allMergedGameMetrics: gameMetricsMergedByDay, + allMergedGameAdEarnings: gameAdEarningsMergedByDay, + cashOuts, + period: 'week', + }), + }; }; diff --git a/newIDE/app/src/GameDashboard/GameAnalyticsPanel.js b/newIDE/app/src/GameDashboard/GameAnalyticsPanel.js index fb1e627b8077..7d3309f343c5 100644 --- a/newIDE/app/src/GameDashboard/GameAnalyticsPanel.js +++ b/newIDE/app/src/GameDashboard/GameAnalyticsPanel.js @@ -28,8 +28,13 @@ import { PlayersRepartitionPerDurationChart, PlayersDurationPerDayChart, SessionsChart, + GameAdEarningsChart, } from './GameAnalyticsCharts'; import MarketingPlanSingleDisplay from '../MarketingPlans/MarketingPlanSingleDisplay'; +import { + getGameAdEarnings, + type GameAdEarning, +} from '../Utils/GDevelopServices/Usage'; const chartHeight = 300; @@ -46,62 +51,79 @@ export const GameAnalyticsPanel = ({ gameFeaturings, fetchGameFeaturings, }: Props) => { - const { getAuthorizationHeader, profile } = React.useContext( + const { getAuthorizationHeader, profile, usages } = React.useContext( AuthenticatedUserContext ); - const [gameRollingMetrics, setGameMetrics] = React.useState( - null - ); + const [gameMetrics, setGameMetrics] = React.useState(null); + const [ + gameAdEarnings, + setGameAdEarnings, + ] = React.useState(null); const { yearChartData, monthChartData } = React.useMemo( - () => buildChartData(gameRollingMetrics), - [gameRollingMetrics] + () => + buildChartData({ gameMetrics, gameAdEarnings, usages, gameId: game.id }), + [gameMetrics, gameAdEarnings, usages, game.id] ); const [dataPeriod, setDataPeriod] = React.useState('month'); const chartData = dataPeriod === 'year' ? yearChartData : monthChartData; - const [gameRollingMetricsError, setGameMetricsError] = React.useState( - null - ); - const [isGameMetricsLoading, setIsGameMetricsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); // TODO In some timezones, it might ask one less or extra day. - const lastYearIsoDate = formatISO(subDays(new Date(), daysShownForYear), { - representation: 'date', - }); - const loadGameMetrics = React.useCallback( + const loadGameAnalytics = React.useCallback( async () => { if (!profile) return; const { id } = profile; - setIsGameMetricsLoading(true); - setGameMetricsError(null); + setIsLoading(true); + setError(null); + + const lastYearIsoDate = formatISO(subDays(new Date(), daysShownForYear), { + representation: 'date', + }); + const gameCreatioDateIsoDate = formatISO(new Date(game.createdAt), { + representation: 'date', + }); + const todayIsoDate = formatISO(new Date(), { + representation: 'date', + }); + try { - const gameRollingMetrics = await getGameMetricsFrom( - getAuthorizationHeader, - id, - game.id, - lastYearIsoDate - ); + const [gameRollingMetrics, gameAdEarnings] = await Promise.all([ + getGameMetricsFrom( + getAuthorizationHeader, + id, + game.id, + lastYearIsoDate + ), + getGameAdEarnings(getAuthorizationHeader, id, { + gameId: game.id, + startIsoDate: gameCreatioDateIsoDate, + endIsoDate: todayIsoDate, + }), + ]); setGameMetrics(gameRollingMetrics); + setGameAdEarnings(gameAdEarnings); } catch (err) { console.error(`Unable to load game rolling metrics:`, err); - setGameMetricsError(err); + setError(err); } - setIsGameMetricsLoading(false); + setIsLoading(false); }, - [getAuthorizationHeader, profile, game, lastYearIsoDate] + [getAuthorizationHeader, profile, game] ); React.useEffect( () => { - loadGameMetrics(); + loadGameAnalytics(); }, - [loadGameMetrics] + [loadGameAnalytics] ); - if (isGameMetricsLoading) return ; + if (isLoading) return ; const displaySuggestedMarketingPlan = recommendedMarketingPlan && gameFeaturings && fetchGameFeaturings; @@ -109,10 +131,10 @@ export const GameAnalyticsPanel = ({ return ( {({ i18n }) => - gameRollingMetricsError ? ( + error ? ( { - loadGameMetrics(); + loadGameAnalytics(); }} > There was an issue getting the game analytics.{' '} @@ -136,8 +158,28 @@ export const GameAnalyticsPanel = ({ + + + + USD {chartData.overview.totalEarningsInUSDs} in Ads + earnings + + + + + + @@ -150,10 +192,11 @@ export const GameAnalyticsPanel = ({ /> + {recommendedMarketingPlan && gameFeaturings && fetchGameFeaturings && ( - + { const [earningsInMilliUsd, setEarningsInMilliUsd] = React.useState(0); const [earningsInCredits, setEarningsInCredits] = React.useState(0); - const [error, setError] = React.useState(null); const intervalValuesUpdate = React.useRef(null); const [selectedCashOutType, setSelectedCashOutType] = React.useState< @@ -58,43 +56,43 @@ const UserEarningsWidget = ({ size }: Props) => { const animateEarnings = React.useCallback( async () => { - if (!userEarningsBalance) return; - - try { - // Create an animation to show the earnings increasing. - const targetMilliUsd = userEarningsBalance.amountInMilliUSDs; - const targetCredits = userEarningsBalance.amountInCredits; - - const duration = 500; - const steps = 30; - const intervalTime = duration / steps; - - const milliUsdIncrement = targetMilliUsd / steps; - const creditsIncrement = targetCredits / steps; - - let currentMilliUsd = 0; - let currentCredits = 0; - let step = 0; - - intervalValuesUpdate.current = setInterval(() => { - step++; - currentMilliUsd += milliUsdIncrement; - currentCredits += creditsIncrement; - - setEarningsInMilliUsd(currentMilliUsd); - setEarningsInCredits(currentCredits); - - if (step >= steps) { - clearInterval(intervalValuesUpdate.current); - // Ensure final values are exactly the target values - setEarningsInMilliUsd(targetMilliUsd); - setEarningsInCredits(targetCredits); - } - }, intervalTime); - } catch (error) { - console.error('Unable to get user earnings balance:', error); - setError(error); + if (!userEarningsBalance) { + // In case the user logs out, reset the earnings. + setEarningsInMilliUsd(0); + setEarningsInCredits(0); + return; } + + // Create an animation to show the earnings increasing. + const targetMilliUsd = userEarningsBalance.amountInMilliUSDs; + const targetCredits = userEarningsBalance.amountInCredits; + + const duration = 500; + const steps = 30; + const intervalTime = duration / steps; + + const milliUsdIncrement = targetMilliUsd / steps; + const creditsIncrement = targetCredits / steps; + + let currentMilliUsd = 0; + let currentCredits = 0; + let step = 0; + + intervalValuesUpdate.current = setInterval(() => { + step++; + currentMilliUsd += milliUsdIncrement; + currentCredits += creditsIncrement; + + setEarningsInMilliUsd(currentMilliUsd); + setEarningsInCredits(currentCredits); + + if (step >= steps) { + clearInterval(intervalValuesUpdate.current); + // Ensure final values are exactly the target values + setEarningsInMilliUsd(targetMilliUsd); + setEarningsInCredits(targetCredits); + } + }, intervalTime); }, [userEarningsBalance] ); @@ -127,16 +125,7 @@ const UserEarningsWidget = ({ size }: Props) => { userEarningsBalance && earningsInMilliUsd >= userEarningsBalance.minAmountToCashoutInMilliUSDs; - const content = error ? ( - - - - Can't load your game earnings. Verify your internet connection or try - again later. - - - - ) : ( + const content = ( void, gameMetrics: ?Array, + gameAdEarnings: ?(GameAdEarning[]), gameUrl: ?string, |}; -const AnalyticsWidget = ({ game, onSeeAll, gameMetrics, gameUrl }: Props) => { +const AnalyticsWidget = ({ + game, + onSeeAll, + gameMetrics, + gameAdEarnings, + gameUrl, +}: Props) => { const hasNoSession = gameMetrics && gameMetrics.length === 0; const { isMobile } = useResponsiveWindowSize(); - const oneWeekAgoIsoDate = new Date( - new Date().setHours(0, 0, 0, 0) - 7 * 24 * 3600 * 1000 - ).toISOString(); const [ marketingPlansDialogOpen, setMarketingPlansDialogOpen, ] = React.useState(false); + const { usages } = React.useContext(AuthenticatedUserContext); - const chartData = React.useMemo( - () => { - const lastWeekGameMetrics = gameMetrics - ? gameMetrics.filter(metrics => metrics.date > oneWeekAgoIsoDate) - : null; - - return buildLastWeekChartData(lastWeekGameMetrics); - }, - [gameMetrics, oneWeekAgoIsoDate] + const { weekChartData } = React.useMemo( + () => + buildChartData({ gameMetrics, gameAdEarnings, usages, gameId: game.id }), + [gameMetrics, gameAdEarnings, usages, game.id] ); return ( @@ -74,69 +76,89 @@ const AnalyticsWidget = ({ game, onSeeAll, gameMetrics, gameUrl }: Props) => { widgetName="analytics" > - {!gameMetrics ? ( -
- ) : hasNoSession ? ( - gameUrl ? ( - - - - - No data to show yet. Share your game creator profile - with more people to get more players! - - - - + + + + Ads earnings + + + {!gameAdEarnings ? ( +
) : ( - - - - - - Window.openExternalURL(publishingHelpLink) - } - > - Share your game - {' '} - and start collecting data from your players to better - understand them. - - - - ) - ) : ( - - - - Sessions - - } - label={Get more players} - onClick={() => setMarketingPlansDialogOpen(true)} + + - + + )} + + + + + Sessions + + } + label={Get more players} + onClick={() => setMarketingPlansDialogOpen(true)} + disabled={!gameMetrics} + /> + + {!gameMetrics ? ( +
+ ) : hasNoSession ? ( + gameUrl ? ( + + + + + No data to show yet. Share your game creator profile + with more people to get more players! + + + + + ) : ( + + + + + + Window.openExternalURL(publishingHelpLink) + } + > + Share your game + {' '} + and start collecting data from your players to better + understand them. + + + + ) + ) : ( - - )} + )} + )} diff --git a/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js b/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js index 7b161d7c10d6..9f0e028fb087 100644 --- a/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js @@ -39,7 +39,7 @@ const ServicesWidget = ({ ); return ( Player services} widgetName="services" > diff --git a/newIDE/app/src/GameDashboard/index.js b/newIDE/app/src/GameDashboard/index.js index ee848a8e900c..4c337cf791ce 100644 --- a/newIDE/app/src/GameDashboard/index.js +++ b/newIDE/app/src/GameDashboard/index.js @@ -60,6 +60,10 @@ import ProjectsWidget from './Widgets/ProjectsWidget'; import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer'; import { formatISO, subDays } from 'date-fns'; import { daysShownForYear } from './GameAnalyticsEvaluator'; +import { + getGameAdEarnings, + type GameAdEarning, +} from '../Utils/GDevelopServices/Usage'; const styles = { mobileFooter: { @@ -140,9 +144,7 @@ const GameDashboard = ({ const [feedbacks, setFeedbacks] = React.useState>(null); const [builds, setBuilds] = React.useState>(null); const [publicGame, setPublicGame] = React.useState(null); - const [gameRollingMetrics, setGameMetrics] = React.useState( - null - ); + const [gameMetrics, setGameMetrics] = React.useState(null); const [ recommendedMarketingPlan, setRecommendedMarketingPlan, @@ -154,9 +156,10 @@ const GameDashboard = ({ const [leaderboards, setLeaderboards] = React.useState>( null ); - const lastYearIsoDate = formatISO(subDays(new Date(), daysShownForYear), { - representation: 'date', - }); + const [ + gameAdEarnings, + setGameAdEarnings, + ] = React.useState>(null); const webBuilds = builds ? builds.filter(build => build.type === 'web-build') @@ -457,13 +460,25 @@ const GameDashboard = ({ setLeaderboards(null); setGameFeaturings(null); setRecommendedMarketingPlan(null); + setGameAdEarnings(null); return; } + const lastYearIsoDate = formatISO(subDays(new Date(), daysShownForYear), { + representation: 'date', + }); + const gameCreatioDateIsoDate = formatISO(new Date(game.createdAt), { + representation: 'date', + }); + const todayIsoDate = formatISO(new Date(), { + representation: 'date', + }); + const [ feedbacks, builds, - gameRollingMetrics, + gameMetrics, + gameAdEarnings, leaderboards, recommendedMarketingPlan, ] = await Promise.all([ @@ -478,6 +493,11 @@ const GameDashboard = ({ game.id, lastYearIsoDate ), + getGameAdEarnings(getAuthorizationHeader, profile.id, { + gameId: game.id, + startIsoDate: gameCreatioDateIsoDate, + endIsoDate: todayIsoDate, + }), listGameActiveLeaderboards(getAuthorizationHeader, profile.id, game.id), getRecommendedMarketingPlan(getAuthorizationHeader, { gameId: game.id, @@ -487,16 +507,17 @@ const GameDashboard = ({ ]); setFeedbacks(feedbacks); setBuilds(builds); - setGameMetrics(gameRollingMetrics); + setGameMetrics(gameMetrics); setLeaderboards(leaderboards); setRecommendedMarketingPlan(recommendedMarketingPlan); + setGameAdEarnings(gameAdEarnings); }, [ fetchGameFeaturings, game.id, getAuthorizationHeader, profile, - lastYearIsoDate, + game.createdAt, ] ); @@ -582,7 +603,8 @@ const GameDashboard = ({ setCurrentView('analytics')} - gameMetrics={gameRollingMetrics} + gameMetrics={gameMetrics} + gameAdEarnings={gameAdEarnings} game={game} gameUrl={gameUrl} /> diff --git a/newIDE/app/src/Utils/GDevelopServices/Analytics.js b/newIDE/app/src/Utils/GDevelopServices/Analytics.js index ae13778aca90..cca4e15092d9 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Analytics.js +++ b/newIDE/app/src/Utils/GDevelopServices/Analytics.js @@ -2,7 +2,7 @@ import axios from 'axios'; import { GDevelopAnalyticsApi } from './ApiConfigs'; -export type GameMetrics = { +export type GameMetrics = {| date: string, sessions: ?{ @@ -43,7 +43,7 @@ export type GameMetrics = { /** Day 7 retained players (number of players who played this day, and were new players 7 days earlier). */ d7RetainedPlayers: number, }, -}; +|}; export const client = axios.create({ baseURL: GDevelopAnalyticsApi.baseUrl, diff --git a/newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js b/newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js index b0895dbf58b9..e42f384dfc6c 100644 --- a/newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js +++ b/newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js @@ -1,7 +1,7 @@ // @flow import Window from '../Window'; -const isDev = Window.isDev(); +const isDev = false; export const GDevelopGamePreviews = { baseUrl: `https://game-previews.gdevelop.io/`, diff --git a/newIDE/app/src/Utils/GDevelopServices/Usage.js b/newIDE/app/src/Utils/GDevelopServices/Usage.js index 33b84232af8d..88b595cff0a0 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Usage.js +++ b/newIDE/app/src/Utils/GDevelopServices/Usage.js @@ -11,6 +11,8 @@ export type Usage = { userId: string, type: string, createdAt: number, + description?: string, + creditsPaid?: number, }; export type Usages = Array; @@ -192,6 +194,14 @@ export type SubscriptionPlanWithPricingSystems = {| pricingSystems: SubscriptionPlanPricingSystem[], |}; +export type GameAdEarning = {| + gameId: string, + date: string, + adEarningsInMilliUSDs: number, + adEarningsInCredits: number, + updatedAt: number, +|}; + export interface UserEarningsBalance { userId: string; amountInMilliUSDs: number; @@ -313,6 +323,40 @@ export const getUserUsages = async ( return response.data; }; +export const getGameAdEarnings = async ( + getAuthorizationHeader: () => Promise, + userId: string, + { + gameId, + startIsoDate, + endIsoDate, + }: {| + gameId: string, + startIsoDate: string, + endIsoDate: string, + |} +): Promise => { + const authorizationHeader = await getAuthorizationHeader(); + + const response = await apiClient.get(`/game-ad-earning`, { + params: { + userId, + gameId, + startIsoDate, + endIsoDate, + }, + headers: { + Authorization: authorizationHeader, + }, + }); + const gameAdEarnings = response.data; + if (!Array.isArray(gameAdEarnings)) { + throw new Error('Invalid response from the game ad earnings API'); + } + + return gameAdEarnings; +}; + export const getUserEarningsBalance = async ( getAuthorizationHeader: () => Promise, userId: string