Compare commits

...

31 Commits

Author SHA1 Message Date
Florian Rival
59c48d52f7 Change how bundle price is calculated to avoid API calls 2025-08-01 12:45:40 +02:00
github-actions[bot]
5f52d786c6 Update translations [skip ci] (#7749)
Co-authored-by: ClementPasteau <4895034+ClementPasteau@users.noreply.github.com>
2025-07-31 14:54:34 +02:00
Clément Pasteau
e0db597f9d A new Bundle to get started with GDevelop is now available (#7751)
* including multiple game templates and asset packs
* including a redemption code for a premium subscription
* including multiple official game dev courses
2025-07-31 14:18:05 +02:00
Florian Rival
41b0315ec6 Improve rating banner for course chapters 2025-07-30 18:49:27 +02:00
Florian Rival
a930a4085e Add basic button to rate premium course chapter 2025-07-30 14:56:46 +02:00
Florian Rival
d0dbbfac07 Add "StrReplaceOne" and "StrReplaceAll" expressions (#7750) 2025-07-30 10:10:29 +02:00
Florian Rival
3dc24b46f4 Fix warning
Don't show in changelog
2025-07-28 13:54:12 +02:00
Florian Rival
8e44a357b4 Fix Android build and player authentication sometimes not working (#7748)
- Player authentication window could not open if no action/condition related to player authentication was used
- Fix Android build by using an updated dependency for opening the authentication window
2025-07-28 12:55:58 +02:00
Florian Rival
dd462310cc Reduce network requests at startup by lazily loading course chapters when opened 2025-07-26 16:37:38 +02:00
Florian Rival
a1935fa0cd Reduce a bit more unnecessary fetches for course chapters
Don't show in changelog
2025-07-26 14:38:54 +02:00
Florian Rival
b45c57246b Add animation names to inspected object properties for AI 2025-07-26 14:21:44 +02:00
Florian Rival
c481ecd6b5 Bump newIDE version 2025-07-25 15:47:07 +02:00
github-actions[bot]
e0898dd9b0 Update translations [skip ci] (#7737)
Co-authored-by: 4ian <1280130+4ian@users.noreply.github.com>
2025-07-25 13:40:41 +02:00
Clément Pasteau
5561334efa Fix Send icon color (#7745) 2025-07-25 13:33:02 +02:00
Florian Rival
6c4bb4f79e Fix selection of face orientation in 3D Cube editor 2025-07-25 10:04:14 +02:00
D8H
8b2d2e2fe7 Fix "edit children" drop-down menu item activation (#7744)
- don't show in changelog
2025-07-24 14:04:25 +02:00
Florian Rival
49d128c964 Display "Ask AI" as a separate pane (or drawer on small screens) (#7738) 2025-07-24 13:20:52 +02:00
D8H
f24d1e0916 Add a deprecation message for custom objects using old "configuration overriding" (#7742) 2025-07-24 13:19:23 +02:00
D8H
9faa4c0c69 Fix button labels not refreshing when modified from the side panel (#7741) 2025-07-24 10:32:48 +02:00
D8H
a04b8f65db Allow to select a custom object variant in the properties panel (#7740)
* Also show a dialog to duplicate a variant before opening them if necessary.
2025-07-23 17:26:03 +02:00
D8H
e1cf7d23cd Various fixes for variants (#7739)
- Forbid to edit the default variant of published extensions
- Hide the children configuration from the side panel when a variant is used
- Fix the Z-order of nested custom objects in the editor
- Fix a memory crash when updating an extension where behaviors must be removed from child-objects
2025-07-22 19:06:33 +02:00
github-actions[bot]
b74b221844 Update translations [skip ci] (#7730)
Co-authored-by: D8H <2611977+D8H@users.noreply.github.com>
2025-07-22 11:43:45 +02:00
Clément Pasteau
38affc15b4 Fix making too many calls for courses (#7736)
Do not show in changelog
2025-07-22 11:43:21 +02:00
D8H
948488d92b [Top-down movement] Fix the legacy turning back mode (#7735) 2025-07-21 22:07:00 +02:00
Florian Rival
f5902d0346 Enable visibility of 3D cube backface by default 2025-07-21 10:52:41 +02:00
Clément Pasteau
f28dc8e88a Fix images pixelated because of border (#7732)
Do not show in changelog
2025-07-18 14:37:06 +02:00
Clément Pasteau
1f41749fa3 Fix carousel mobile (#7729)
Do not show in changelog
2025-07-17 15:49:36 +02:00
Florian Rival
a4908a4d42 Add spell check option for text input (disabled by default) (#7728) 2025-07-17 14:39:30 +02:00
Clément Pasteau
aa7754e658 Fixes responsive design and courses (#7726)
Do not show in changelog
2025-07-17 10:20:02 +02:00
github-actions[bot]
58ea9387aa Update extension translations [skip ci] (#7727)
Co-authored-by: ClementPasteau <4895034+ClementPasteau@users.noreply.github.com>
2025-07-17 10:17:37 +02:00
github-actions[bot]
775266c974 Update translations [skip ci] (#7722)
Co-authored-by: 4ian <1280130+4ian@users.noreply.github.com>
2025-07-17 10:15:12 +02:00
268 changed files with 8921 additions and 2026 deletions

View File

@@ -63,7 +63,6 @@ void EventsBasedObjectVariantHelper::ComplyVariantsToEventsBasedObject(
}
// Copy missing behaviors
auto &behaviors = object.GetAllBehaviorContents();
for (const auto &pair : defaultBehaviors) {
const auto &behaviorName = pair.first;
const auto &defaultBehavior = pair.second;
@@ -82,11 +81,9 @@ void EventsBasedObjectVariantHelper::ComplyVariantsToEventsBasedObject(
}
}
// Delete extra behaviors
for (auto it = behaviors.begin(); it != behaviors.end(); ++it) {
const auto &behaviorName = it->first;
for (auto &behaviorName : object.GetAllBehaviorNames()) {
if (!defaultObject->HasBehaviorNamed(behaviorName)) {
object.RemoveBehavior(behaviorName);
--it;
}
}

View File

@@ -1125,7 +1125,7 @@ module.exports = {
topFaceResourceName: '',
bottomFaceResourceName: '',
frontFaceVisible: true,
backFaceVisible: false,
backFaceVisible: true,
leftFaceVisible: true,
rightFaceVisible: true,
topFaceVisible: true,

View File

@@ -508,7 +508,7 @@ module.exports = {
associatedObjectConfiguration,
pixiContainer,
pixiResourcesLoader,
propertyOverridings
getPropertyOverridings
) {
super(
project,
@@ -516,7 +516,7 @@ module.exports = {
associatedObjectConfiguration,
pixiContainer,
pixiResourcesLoader,
propertyOverridings
getPropertyOverridings
);
const bbTextStyles = {
@@ -555,9 +555,11 @@ module.exports = {
gd.ObjectJsImplementation
);
const rawText = this._propertyOverridings.has('Text')
? this._propertyOverridings.get('Text')
: object.content.text;
const propertyOverridings = this.getPropertyOverridings();
const rawText =
propertyOverridings && propertyOverridings.has('Text')
? propertyOverridings.get('Text')
: object.content.text;
if (rawText !== this._pixiObject.text) {
this._pixiObject.text = rawText;
}

View File

@@ -631,7 +631,7 @@ module.exports = {
associatedObjectConfiguration,
pixiContainer,
pixiResourcesLoader,
propertyOverridings
getPropertyOverridings
) {
super(
project,
@@ -639,7 +639,7 @@ module.exports = {
associatedObjectConfiguration,
pixiContainer,
pixiResourcesLoader,
propertyOverridings
getPropertyOverridings
);
// We'll track changes of the font to trigger the loading of the new font.
@@ -665,9 +665,11 @@ module.exports = {
// Update the rendered text properties (note: Pixi is only
// applying changes if there were changed).
this._pixiObject.text = this._propertyOverridings.has('Text')
? this._propertyOverridings.get('Text')
: object.content.text;
const propertyOverridings = this.getPropertyOverridings();
this._pixiObject.text =
propertyOverridings && propertyOverridings.has('Text')
? propertyOverridings.get('Text')
: object.content.text;
const align = object.content.align;
this._pixiObject.align = align;

View File

@@ -27,7 +27,7 @@ class RenderedInstance {
associatedObjectConfiguration: gdObjectConfiguration,
pixiContainer: PIXI.Container,
pixiResourcesLoader: Class<PixiResourcesLoader>,
propertyOverridings: Map<string, string> = new Map<string, string>()
getPropertyOverridings: (() => Map<string, string>) | null = null
);
/**
@@ -80,6 +80,8 @@ class RenderedInstance {
getDefaultHeight(): number;
getDefaultDepth(): number;
getPropertyOverridings(): Map<string, string> | null;
}
/**
@@ -107,7 +109,8 @@ class Rendered3DInstance {
associatedObjectConfiguration: gdObjectConfiguration,
pixiContainer: PIXI.Container,
threeGroup: THREE.Group,
pixiResourcesLoader: Class<PixiResourcesLoader>
pixiResourcesLoader: Class<PixiResourcesLoader>,
getPropertyOverridings: (() => Map<string, string>) | null = null
);
/**
@@ -174,6 +177,8 @@ class Rendered3DInstance {
* Return the depth of the instance when the instance doesn't have a custom size.
*/
getDefaultDepth(): number;
getPropertyOverridings(): Map<string, string> | null;
}
declare type ObjectsRenderingService = {

View File

@@ -30,6 +30,12 @@ module.exports = {
.addInstructionOrExpressionGroupMetadata(_('Leaderboards'))
.setIcon('JsPlatform/Extensions/leaderboard.svg');
extension
.addDependency()
.setName('Safari View Controller Cordova plugin')
.setDependencyType('cordova')
.setExportName('@gdevelop/cordova-plugin-safariviewcontroller');
extension
.addAction(
'SavePlayerScore',

View File

@@ -31,6 +31,12 @@ module.exports = {
.addInstructionOrExpressionGroupMetadata(_('Multiplayer'))
.setIcon('JsPlatform/Extensions/multiplayer.svg');
extension
.addDependency()
.setName('Safari View Controller Cordova plugin')
.setDependencyType('cordova')
.setExportName('@gdevelop/cordova-plugin-safariviewcontroller');
extension
.addStrExpression(
'CurrentLobbyID',

View File

@@ -35,7 +35,7 @@ module.exports = {
.addDependency()
.setName('Safari View Controller Cordova plugin')
.setDependencyType('cordova')
.setExportName('cordova-plugin-safariviewcontroller');
.setExportName('@gdevelop/cordova-plugin-safariviewcontroller');
extension
.addAction(

View File

@@ -834,34 +834,51 @@ namespace gdjs {
authWindowOptions,
});
if (typeof SafariViewController === 'undefined') {
logger.error(
'Cordova plugin SafariViewController is not installed.'
);
resolve('errored');
return;
}
SafariViewController.isAvailable(function (available: boolean) {
if (available) {
SafariViewController.show(
{
url: targetUrl,
hidden: false,
animated: true,
transition: 'slide',
enterReaderModeIfAvailable: false,
barColor: '#000000',
tintColor: '#ffffff',
controlTintColor: '#ffffff',
},
function (result: any) {
// Other events are `opened` and `loaded`.
if (result.event === 'closed') {
resolve('dismissed');
}
},
function (error: any) {
logger.log('Error opening webview: ' + JSON.stringify(error));
resolve('errored');
}
if (!available) {
logger.error(
'Cordova plugin SafariViewController is installed but not available'
);
} else {
logger.error('Plugin SafariViewController is not available');
resolve('errored');
return;
}
logger.info(
'Opening authentication window for Cordova with SafariViewController.'
);
SafariViewController.show(
{
url: targetUrl,
hidden: false,
animated: true,
transition: 'slide',
enterReaderModeIfAvailable: false,
barColor: '#000000',
tintColor: '#ffffff',
controlTintColor: '#ffffff',
},
function (result: any) {
// Other events are `opened` and `loaded`.
if (result.event === 'closed') {
resolve('dismissed');
}
},
function (error: any) {
logger.log(
'Error opening authentication window: ' +
JSON.stringify(error)
);
resolve('errored');
}
);
});
}
);

View File

@@ -78,6 +78,9 @@ module.exports = {
} else if (propertyName === 'disabled') {
objectContent.disabled = newValue === '1';
return true;
} else if (propertyName === 'spellCheck') {
objectContent.spellCheck = newValue === '1';
return true;
} else if (propertyName === 'maxLength') {
objectContent.maxLength = newValue;
return true;
@@ -160,6 +163,13 @@ module.exports = {
.setLabel(_('Disabled'))
.setGroup(_('Field'));
objectProperties
.getOrCreate('spellCheck')
.setValue(objectContent.spellCheck ? 'true' : 'false')
.setType('boolean')
.setLabel(_('Enable spell check'))
.setGroup(_('Field'));
objectProperties
.getOrCreate('textColor')
.setValue(objectContent.textColor || '0;0;0')
@@ -272,6 +282,7 @@ module.exports = {
borderWidth: 1,
readOnly: false,
disabled: false,
spellCheck: false,
paddingX: 2,
paddingY: 1,
textAlign: 'left',
@@ -592,6 +603,21 @@ module.exports = {
.setFunctionName('setDisabled')
.setGetter('isDisabled');
object
.addExpressionAndConditionAndAction(
'boolean',
'SpellCheck',
_('Spell check enabled'),
_('spell check is enabled'),
_('spell check enabled'),
'',
'res/conditions/text24_black.png'
)
.addParameter('object', _('Text input'), 'TextInputObject', false)
.useStandardParameters('boolean', gd.ParameterOptions.makeNewOptions())
.setFunctionName('setSpellCheck')
.setGetter('isSpellCheckEnabled');
// Other expressions/conditions/actions:
// Deprecated

View File

@@ -106,6 +106,7 @@ namespace gdjs {
this.updateBorderWidth();
this.updateDisabled();
this.updateReadOnly();
this.updateSpellCheck();
this.updateTextAlign();
this.updateMaxLength();
this.updatePadding();
@@ -342,6 +343,12 @@ namespace gdjs {
this._input.readOnly = this._object.isReadOnly();
}
updateSpellCheck() {
if (!this._input) return;
this._input.spellcheck = this._object.isSpellCheckEnabled();
}
updateMaxLength() {
const input = this._input;
if (!input) return;

View File

@@ -54,6 +54,7 @@ namespace gdjs {
disabled: boolean;
readOnly: boolean;
// ---- Values can be undefined because of support for these feature was added in v5.5.222.
spellCheck?: boolean;
paddingX?: float;
paddingY?: float;
textAlign?: SupportedTextAlign;
@@ -77,6 +78,7 @@ namespace gdjs {
bw: float;
dis: boolean;
ro: boolean;
sc: boolean;
};
export type TextInputNetworkSyncData = ObjectNetworkSyncData &
@@ -116,6 +118,7 @@ namespace gdjs {
private _borderWidth: float;
private _disabled: boolean;
private _readOnly: boolean;
private _spellCheck: boolean;
private _isSubmitted: boolean;
_renderer: TextInputRuntimeObjectRenderer;
@@ -140,6 +143,10 @@ namespace gdjs {
this._borderWidth = objectData.content.borderWidth;
this._disabled = objectData.content.disabled;
this._readOnly = objectData.content.readOnly;
this._spellCheck =
objectData.content.spellCheck !== undefined
? objectData.content.spellCheck
: false;
this._textAlign = parseTextAlign(objectData.content.textAlign);
this._maxLength = objectData.content.maxLength || 0;
this._paddingX =
@@ -226,6 +233,12 @@ namespace gdjs {
if (oldObjectData.content.readOnly !== newObjectData.content.readOnly) {
this.setReadOnly(newObjectData.content.readOnly);
}
if (
newObjectData.content.spellCheck !== undefined &&
oldObjectData.content.spellCheck !== newObjectData.content.spellCheck
) {
this.setSpellCheck(newObjectData.content.spellCheck);
}
if (
newObjectData.content.maxLength !== undefined &&
oldObjectData.content.maxLength !== newObjectData.content.maxLength
@@ -271,6 +284,7 @@ namespace gdjs {
bw: this.getBorderWidth(),
dis: this.isDisabled(),
ro: this.isReadOnly(),
sc: this.isSpellCheckEnabled(),
};
}
@@ -291,6 +305,7 @@ namespace gdjs {
if (syncData.bw !== undefined) this.setBorderWidth(syncData.bw);
if (syncData.dis !== undefined) this.setDisabled(syncData.dis);
if (syncData.ro !== undefined) this.setReadOnly(syncData.ro);
if (syncData.sc !== undefined) this.setSpellCheck(syncData.sc);
}
updatePreRender(instanceContainer: RuntimeInstanceContainer): void {
@@ -563,6 +578,15 @@ namespace gdjs {
return this._readOnly;
}
setSpellCheck(value: boolean) {
this._spellCheck = value;
this._renderer.updateSpellCheck();
}
isSpellCheckEnabled(): boolean {
return this._spellCheck;
}
isFocused(): boolean {
return this._renderer.isFocused();
}

View File

@@ -507,10 +507,6 @@ namespace gdjs {
cos = 0;
}
const getAcceleratedSpeed = this._useLegacyTurnBack
? TopDownMovementRuntimeBehavior.getLegacyAcceleratedSpeed
: TopDownMovementRuntimeBehavior.getAcceleratedSpeed;
let currentSpeed = Math.hypot(this._xVelocity, this._yVelocity);
const dotProduct = this._xVelocity * cos + this._yVelocity * sin;
if (dotProduct < 0) {
@@ -518,13 +514,14 @@ namespace gdjs {
// Keep the negative velocity projected on the new direction.
currentSpeed = dotProduct;
}
const speed = getAcceleratedSpeed(
const speed = TopDownMovementRuntimeBehavior.getAcceleratedSpeed(
currentSpeed,
targetedSpeed,
this._maxSpeed,
this._acceleration,
this._deceleration,
timeDelta
timeDelta,
this._useLegacyTurnBack
);
this._xVelocity = speed * cos;
this._yVelocity = speed * sin;
@@ -599,10 +596,13 @@ namespace gdjs {
speedMax: float,
acceleration: float,
deceleration: float,
timeDelta: float
timeDelta: float,
useLegacyTurnBack: boolean = false
): float {
let newSpeed = currentSpeed;
const turningBackAcceleration = Math.max(acceleration, deceleration);
const turningBackAcceleration = useLegacyTurnBack
? acceleration
: Math.max(acceleration, deceleration);
if (targetedSpeed < 0) {
if (currentSpeed <= targetedSpeed) {
// Reduce the speed to match the stick force.
@@ -652,62 +652,6 @@ namespace gdjs {
return newSpeed;
}
private static getLegacyAcceleratedSpeed(
currentSpeed: float,
targetedSpeed: float,
speedMax: float,
acceleration: float,
deceleration: float,
timeDelta: float
): float {
let newSpeed = currentSpeed;
if (targetedSpeed < 0) {
if (currentSpeed <= targetedSpeed) {
// Reduce the speed to match the stick force.
newSpeed = Math.min(
targetedSpeed,
currentSpeed + deceleration * timeDelta
);
} else if (currentSpeed <= 0) {
// Accelerate
newSpeed -= Math.max(-speedMax, acceleration * timeDelta);
} else {
newSpeed = Math.max(
targetedSpeed,
currentSpeed - deceleration * timeDelta
);
}
} else if (targetedSpeed > 0) {
if (currentSpeed >= targetedSpeed) {
// Reduce the speed to match the stick force.
newSpeed = Math.max(
targetedSpeed,
currentSpeed - deceleration * timeDelta
);
} else if (currentSpeed >= 0) {
// Accelerate
newSpeed = Math.min(
speedMax,
currentSpeed + acceleration * timeDelta
);
} else {
newSpeed = Math.min(
targetedSpeed,
currentSpeed + deceleration * timeDelta
);
}
} else {
// Decelerate and stop.
if (currentSpeed < 0) {
newSpeed = Math.min(currentSpeed + deceleration * timeDelta, 0);
}
if (currentSpeed > 0) {
newSpeed = Math.max(currentSpeed - deceleration * timeDelta, 0);
}
}
return newSpeed;
}
simulateControl(input: string) {
if (input === 'Left') {
this._leftKey = true;

View File

@@ -43,6 +43,10 @@ StringInstructionsExtension::StringInstructionsExtension() {
"gdjs.evtTools.string.strFindLastFrom");
GetAllExpressions()["StrFindLastFrom"].SetFunctionName(
"gdjs.evtTools.string.strFindLastFrom");
GetAllStrExpressions()["StrReplaceOne"].SetFunctionName(
"gdjs.evtTools.string.strReplaceOne");
GetAllStrExpressions()["StrReplaceAll"].SetFunctionName(
"gdjs.evtTools.string.strReplaceAll");
StripUnimplementedInstructionsAndExpressions();
}

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@ import { ChatBubble } from './ChatBubble';
import { Line } from '../../UI/Grid';
import { ChatMarkdownText } from './ChatMarkdownText';
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
import { getFunctionCallToFunctionCallOutputMap } from './AiRequestUtils';
import { getFunctionCallToFunctionCallOutputMap } from '../AiRequestUtils';
import { FunctionCallRow } from './FunctionCallRow';
import IconButton from '../../UI/IconButton';
import Like from '../../UI/CustomSvgIcons/Like';

View File

@@ -29,7 +29,7 @@ import { getHelpLink } from '../../Utils/HelpLink';
import Window from '../../Utils/Window';
import { type EditorFunctionCallResult } from '../../EditorFunctions/EditorFunctionCallRunner';
import { type EditorCallbacks } from '../../EditorFunctions';
import { getFunctionCallsToProcess } from './AiRequestUtils';
import { getFunctionCallsToProcess } from '../AiRequestUtils';
import CircularProgress from '../../UI/CircularProgress';
import TwoStatesButton from '../../UI/TwoStatesButton';
import Help from '../../UI/CustomSvgIcons/Help';
@@ -68,6 +68,7 @@ type Props = {
|}) => void,
onSendMessage: (options: {|
userMessage: string,
createdSceneNames?: Array<string>,
|}) => Promise<void>,
onSendFeedback: (
aiRequestId: string,
@@ -79,7 +80,10 @@ type Props = {
hasOpenedProject: boolean,
isAutoProcessingFunctionCalls: boolean,
setAutoProcessFunctionCalls: boolean => void,
onStartNewChat: (mode: 'chat' | 'agent') => void,
onStartOrOpenChat: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
|}) => void,
initialMode?: 'chat' | 'agent',
onProcessFunctionCalls: (
@@ -123,12 +127,17 @@ const getQuotaOrCreditsText = ({
<>
{increaseQuotaOffering === 'subscribe' ? (
<Trans>
Get GDevelop premium to get more free requests every month.
Get GDevelop premium to get more free requests every day.
</Trans>
) : quota.period === '30days' ? (
<Trans>
These are parts of your GDevelop premium membership ({quota.max}{' '}
free requests per month).
</Trans>
) : (
<Trans>
These are parts of your GDevelop premium membership ({quota.max}{' '}
free requests per month).
free requests per day).
</Trans>
)}{' '}
<Trans>Free requests do not consume credits on your account.</Trans>
@@ -248,7 +257,7 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
onStartNewAiRequest,
onSendMessage,
onSendFeedback,
onStartNewChat,
onStartOrOpenChat,
initialMode,
quota,
increaseQuotaOffering,
@@ -426,7 +435,7 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
<RobotIcon rotating size={40} />
</Line>
<Column noMargin alignItems="center">
<Text size="bold-title">
<Text size="bold-title" align="center">
{newAiRequestMode === 'agent' ? (
<Trans>What do you want to make?</Trans>
) : (
@@ -439,7 +448,7 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
value={newAiRequestMode}
leftButton={{
icon: <Hammer fontSize="small" />,
label: <Trans>Build for me (beta)</Trans>,
label: <Trans>Build for me</Trans>,
value: 'agent',
}}
rightButton={{
@@ -546,6 +555,7 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
</>
) : null}
</ColumnStackLayout>
<Spacer />
<Column justifyContent="center">
{newAiRequestMode === 'agent' ? (
<Text size="body-small" color="secondary" align="center" noMargin>
@@ -647,7 +657,12 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
This request is for another project.{' '}
<Link
href="#"
onClick={() => onStartNewChat(aiRequest.mode || 'chat')}
onClick={() =>
onStartOrOpenChat({
mode: aiRequest.mode || 'chat',
aiRequestId: null,
})
}
>
Start a new chat
</Link>{' '}

View File

@@ -0,0 +1,229 @@
// @flow
import * as React from 'react';
import {
getAiRequest,
type AiRequest,
} from '../Utils/GDevelopServices/Generation';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import { type EditorFunctionCallResult } from '../EditorFunctions/EditorFunctionCallRunner';
type EditorFunctionCallResultsStorage = {|
getEditorFunctionCallResults: (
aiRequestId: string
) => Array<EditorFunctionCallResult> | null,
addEditorFunctionCallResults: (
aiRequestId: string,
editorFunctionCallResults: EditorFunctionCallResult[]
) => void,
clearEditorFunctionCallResults: (aiRequestId: string) => void,
|};
const useEditorFunctionCallResultsStorage = (): EditorFunctionCallResultsStorage => {
const [
editorFunctionCallResultsPerRequest,
setEditorFunctionCallResultsPerRequest,
] = React.useState<{
[aiRequestId: string]: Array<EditorFunctionCallResult>,
}>({});
return {
getEditorFunctionCallResults: React.useCallback(
(aiRequestId: string): Array<EditorFunctionCallResult> | null =>
editorFunctionCallResultsPerRequest[aiRequestId] || null,
[editorFunctionCallResultsPerRequest]
),
addEditorFunctionCallResults: React.useCallback(
(
aiRequestId: string,
editorFunctionCallResults: EditorFunctionCallResult[]
) => {
setEditorFunctionCallResultsPerRequest(
editorFunctionCallResultsPerRequest => {
const existingEditorFunctionCallResults = (
editorFunctionCallResultsPerRequest[aiRequestId] || []
).filter(existingEditorFunctionCallResult => {
return !editorFunctionCallResults.some(
editorFunctionCallResult => {
return (
editorFunctionCallResult.call_id ===
existingEditorFunctionCallResult.call_id
);
}
);
});
return {
...editorFunctionCallResultsPerRequest,
[aiRequestId]: [
...existingEditorFunctionCallResults,
...editorFunctionCallResults,
],
};
}
);
},
[]
),
clearEditorFunctionCallResults: React.useCallback((aiRequestId: string) => {
setEditorFunctionCallResultsPerRequest(
editorFunctionCallResultsPerRequest => ({
...editorFunctionCallResultsPerRequest,
[aiRequestId]: null,
})
);
}, []),
};
};
type AiRequestStorage = {|
aiRequests: { [string]: AiRequest },
updateAiRequest: (aiRequestId: string, aiRequest: AiRequest) => void,
refreshAiRequest: (aiRequestId: string) => Promise<void>,
isSendingAiRequest: (aiRequestId: string | null) => boolean,
getLastSendError: (aiRequestId: string | null) => ?Error,
setSendingAiRequest: (aiRequestId: string | null, isSending: boolean) => void,
setLastSendError: (aiRequestId: string | null, lastSendError: ?Error) => void,
|};
type AiRequestSendState = {|
isSending: boolean,
lastSendError: ?Error,
|};
export const useAiRequestsStorage = (): AiRequestStorage => {
const { profile, getAuthorizationHeader } = React.useContext(
AuthenticatedUserContext
);
const [aiRequests, setAiRequests] = React.useState<{ [string]: AiRequest }>(
{}
);
const updateAiRequest = React.useCallback(
(aiRequestId: string, aiRequest: AiRequest) => {
setAiRequests(aiRequests => ({
...aiRequests,
[aiRequestId]: aiRequest,
}));
},
[]
);
const refreshAiRequest = React.useCallback(
async (aiRequestId: string) => {
if (!profile) return;
try {
const updatedAiRequest = await getAiRequest(getAuthorizationHeader, {
userId: profile.id,
aiRequestId: aiRequestId,
});
updateAiRequest(updatedAiRequest.id, updatedAiRequest);
} catch (error) {
console.error(
'Error while background refreshing AI request - ignoring:',
error
);
}
},
[getAuthorizationHeader, profile, updateAiRequest]
);
const [aiRequestSendStates, setAiRequestSendStates] = React.useState<{
[string]: AiRequestSendState,
}>({});
const isSendingAiRequest = React.useCallback(
(aiRequestId: string | null) =>
!!aiRequestSendStates[aiRequestId || ''] &&
aiRequestSendStates[aiRequestId || ''].isSending,
[aiRequestSendStates]
);
const getLastSendError = React.useCallback(
(aiRequestId: string | null) =>
(aiRequestSendStates[aiRequestId || ''] &&
aiRequestSendStates[aiRequestId || ''].lastSendError) ||
null,
[aiRequestSendStates]
);
const setSendingAiRequest = React.useCallback(
(aiRequestId: string | null, isSending: boolean) => {
const aiRequestIdToSet: string = aiRequestId || '';
setAiRequestSendStates(aiRequestSendStates => ({
...aiRequestSendStates,
[aiRequestIdToSet]: {
isSending,
lastSendError: null,
},
}));
},
[setAiRequestSendStates]
);
const setLastSendError = React.useCallback(
(aiRequestId: string | null, lastSendError: ?Error) => {
const aiRequestIdToSet: string = aiRequestId || '';
setAiRequestSendStates(aiRequestSendStates => ({
...aiRequestSendStates,
[aiRequestIdToSet]: {
isSending: false,
lastSendError,
},
}));
},
[setAiRequestSendStates]
);
return {
aiRequests,
updateAiRequest,
refreshAiRequest,
isSendingAiRequest,
setSendingAiRequest,
setLastSendError,
getLastSendError,
};
};
type AiRequestContextState = {|
aiRequestStorage: AiRequestStorage,
editorFunctionCallResultsStorage: EditorFunctionCallResultsStorage,
|};
export const AiRequestContext = React.createContext<AiRequestContextState>({
aiRequestStorage: {
aiRequests: {},
updateAiRequest: () => {},
refreshAiRequest: async () => {},
isSendingAiRequest: () => false,
getLastSendError: () => null,
setSendingAiRequest: () => {},
setLastSendError: () => {},
},
editorFunctionCallResultsStorage: {
getEditorFunctionCallResults: () => [],
addEditorFunctionCallResults: () => {},
clearEditorFunctionCallResults: () => {},
},
});
type AiRequestProviderProps = {|
children: React.Node,
|};
export const AiRequestProvider = ({ children }: AiRequestProviderProps) => {
const editorFunctionCallResultsStorage = useEditorFunctionCallResultsStorage();
const aiRequestStorage = useAiRequestsStorage();
const state = React.useMemo(
() => ({
aiRequestStorage,
editorFunctionCallResultsStorage,
}),
[aiRequestStorage, editorFunctionCallResultsStorage]
);
return (
<AiRequestContext.Provider value={state}>
{children}
</AiRequestContext.Provider>
);
};

View File

@@ -3,8 +3,8 @@ import {
type AiRequest,
type AiRequestMessageAssistantFunctionCall,
type AiRequestFunctionCallOutput,
} from '../../Utils/GDevelopServices/Generation';
import { type EditorFunctionCallResult } from '../../EditorFunctions/EditorFunctionCallRunner';
} from '../Utils/GDevelopServices/Generation';
import { type EditorFunctionCallResult } from '../EditorFunctions/EditorFunctionCallRunner';
export const getFunctionCallToFunctionCallOutputMap = ({
aiRequest,

View File

@@ -36,7 +36,7 @@ import { type EditorCallbacks } from '../EditorFunctions';
import {
getFunctionCallOutputsFromEditorFunctionCallResults,
getFunctionCallsToProcess,
} from './AiRequestChat/AiRequestUtils';
} from './AiRequestUtils';
import { useStableUpToDateRef } from '../Utils/UseStableUpToDateCallback';
import { useTriggerAtNextRender } from '../Utils/useTriggerAtNextRender';
import { type NewProjectSetup } from '../ProjectCreation/NewProjectSetupDialog';
@@ -52,66 +52,10 @@ import {
import { useCreateAiProjectDialog } from './UseCreateAiProjectDialog';
import { type ExampleShortHeader } from '../Utils/GDevelopServices/Example';
import { prepareAiUserContent } from './PrepareAiUserContent';
import { AiRequestContext } from './AiRequestContext';
const gd: libGDevelop = global.gd;
const useEditorFunctionCallResultsPerRequest = () => {
const [
editorFunctionCallResultsPerRequest,
setEditorFunctionCallResultsPerRequest,
] = React.useState<{
[aiRequestId: string]: Array<EditorFunctionCallResult>,
}>({});
return {
getEditorFunctionCallResults: React.useCallback(
(aiRequestId: string): Array<EditorFunctionCallResult> | null =>
editorFunctionCallResultsPerRequest[aiRequestId] || null,
[editorFunctionCallResultsPerRequest]
),
addEditorFunctionCallResults: React.useCallback(
(
aiRequestId: string,
editorFunctionCallResults: EditorFunctionCallResult[]
) => {
setEditorFunctionCallResultsPerRequest(
editorFunctionCallResultsPerRequest => {
const existingEditorFunctionCallResults = (
editorFunctionCallResultsPerRequest[aiRequestId] || []
).filter(existingEditorFunctionCallResult => {
return !editorFunctionCallResults.some(
editorFunctionCallResult => {
return (
editorFunctionCallResult.call_id ===
existingEditorFunctionCallResult.call_id
);
}
);
});
return {
...editorFunctionCallResultsPerRequest,
[aiRequestId]: [
...existingEditorFunctionCallResults,
...editorFunctionCallResults,
],
};
}
);
},
[]
),
clearEditorFunctionCallResults: React.useCallback((aiRequestId: string) => {
setEditorFunctionCallResultsPerRequest(
editorFunctionCallResultsPerRequest => ({
...editorFunctionCallResultsPerRequest,
[aiRequestId]: null,
})
);
}, []),
};
};
const useProcessFunctionCalls = ({
i18n,
project,
@@ -129,7 +73,9 @@ const useProcessFunctionCalls = ({
resourceManagementProps: ResourceManagementProps,
editorCallbacks: EditorCallbacks,
selectedAiRequest: ?AiRequest,
onSendEditorFunctionCallResults: () => Promise<void>,
onSendEditorFunctionCallResults: (
options: null | {| createdSceneNames: Array<string> |}
) => Promise<void>,
getEditorFunctionCallResults: string => Array<EditorFunctionCallResult> | null,
addEditorFunctionCallResults: (
string,
@@ -196,7 +142,7 @@ const useProcessFunctionCalls = ({
}))
);
const editorFunctionCallResults = await processEditorFunctionCalls({
const { results, createdSceneNames } = await processEditorFunctionCalls({
project,
editorCallbacks,
functionCalls: functionCalls.map(functionCall => ({
@@ -216,14 +162,13 @@ const useProcessFunctionCalls = ({
searchAndInstallAsset,
});
addEditorFunctionCallResults(
selectedAiRequest.id,
editorFunctionCallResults
);
addEditorFunctionCallResults(selectedAiRequest.id, results);
// We may have processed everything, so try to send the results
// to the backend.
triggerSendEditorFunctionCallResults();
triggerSendEditorFunctionCallResults({
createdSceneNames,
});
},
[
project,
@@ -280,52 +225,20 @@ const useProcessFunctionCalls = ({
};
};
type AiRequestSendState = {|
isSending: boolean,
lastSendError: ?Error,
|};
export const useAiRequests = () => {
export const useSelectedAiRequest = ({
initialAiRequestId,
}: {|
initialAiRequestId: string | null,
|}) => {
const { profile, getAuthorizationHeader } = React.useContext(
AuthenticatedUserContext
);
const { aiRequestStorage } = React.useContext(AiRequestContext);
const { aiRequests, updateAiRequest } = aiRequestStorage;
const [aiRequests, setAiRequests] = React.useState<{ [string]: AiRequest }>(
{}
);
const [selectedAiRequestId, setSelectedAiRequestId] = React.useState<
string | null
>(null);
const updateAiRequest = React.useCallback(
(aiRequestId: string, aiRequest: AiRequest) => {
setAiRequests(aiRequests => ({
...aiRequests,
[aiRequestId]: aiRequest,
}));
},
[]
);
const refreshAiRequest = React.useCallback(
async (aiRequestId: string) => {
if (!profile) return;
try {
const updatedAiRequest = await getAiRequest(getAuthorizationHeader, {
userId: profile.id,
aiRequestId: aiRequestId,
});
updateAiRequest(updatedAiRequest.id, updatedAiRequest);
} catch (error) {
console.error(
'Error while background refreshing AI request - ignoring:',
error
);
}
},
[getAuthorizationHeader, profile, updateAiRequest]
);
>(initialAiRequestId);
const selectedAiRequest =
(selectedAiRequestId && aiRequests[selectedAiRequestId]) || null;
@@ -375,59 +288,10 @@ export const useAiRequests = () => {
]
);
const [aiRequestSendStates, setAiRequestSendStates] = React.useState<{
[string]: AiRequestSendState,
}>({});
const isSendingAiRequest = React.useCallback(
(aiRequestId: string | null) =>
!!aiRequestSendStates[aiRequestId || ''] &&
aiRequestSendStates[aiRequestId || ''].isSending,
[aiRequestSendStates]
);
const getLastSendError = React.useCallback(
(aiRequestId: string | null) =>
(aiRequestSendStates[aiRequestId || ''] &&
aiRequestSendStates[aiRequestId || ''].lastSendError) ||
null,
[aiRequestSendStates]
);
const setSendingAiRequest = React.useCallback(
(aiRequestId: string | null, isSending: boolean) => {
const aiRequestIdToSet: string = aiRequestId || '';
setAiRequestSendStates(aiRequestSendStates => ({
...aiRequestSendStates,
[aiRequestIdToSet]: {
isSending,
lastSendError: null,
},
}));
},
[setAiRequestSendStates]
);
const setLastSendError = React.useCallback(
(aiRequestId: string | null, lastSendError: ?Error) => {
const aiRequestIdToSet: string = aiRequestId || '';
setAiRequestSendStates(aiRequestSendStates => ({
...aiRequestSendStates,
[aiRequestIdToSet]: {
isSending: false,
lastSendError,
},
}));
},
[setAiRequestSendStates]
);
return {
selectedAiRequest,
selectedAiRequestId,
setSelectedAiRequestId,
updateAiRequest,
refreshAiRequest,
isSendingAiRequest,
setSendingAiRequest,
setLastSendError,
getLastSendError,
};
};
@@ -438,6 +302,8 @@ const styles = {
flexDirection: 'column',
alignItems: 'center',
minWidth: 0,
overflowY: 'scroll',
overflowX: 'hidden',
},
chatContainer: {
flex: 1,
@@ -483,7 +349,13 @@ type Props = {|
changes: SceneEventsOutsideEditorChanges
) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
mode?: 'chat' | 'agent' | null,
initialMode: 'chat' | 'agent' | null,
initialAiRequestId: string | null,
onOpenAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
|};
export type AskAiEditorInterface = {|
@@ -499,7 +371,10 @@ export type AskAiEditorInterface = {|
onSceneEventsModifiedOutsideEditor: (
changes: SceneEventsOutsideEditorChanges
) => void,
startNewChat: (mode: 'chat' | 'agent') => void,
startOrOpenChat: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
|}) => void,
|};
export type NewAiRequestOptions = {|
@@ -525,7 +400,9 @@ export const AskAiEditor = React.memo<Props>(
onOpenLayout,
onSceneEventsModifiedOutsideEditor,
onExtensionInstalled,
mode,
initialMode,
initialAiRequestId,
onOpenAskAi,
}: Props,
ref
) => {
@@ -540,13 +417,9 @@ export const AskAiEditor = React.memo<Props>(
selectedAiRequest,
selectedAiRequestId,
setSelectedAiRequestId,
updateAiRequest,
refreshAiRequest,
setSendingAiRequest,
isSendingAiRequest,
getLastSendError,
setLastSendError,
} = useAiRequests();
} = useSelectedAiRequest({
initialAiRequestId,
});
const upToDateSelectedAiRequestId = useStableUpToDateRef(
selectedAiRequestId
);
@@ -558,26 +431,30 @@ export const AskAiEditor = React.memo<Props>(
const [isHistoryOpen, setIsHistoryOpen] = React.useState<boolean>(false);
const [newChatMode, setNewChatMode] = React.useState<'chat' | 'agent'>(
mode || 'agent'
initialMode || 'agent'
);
// Update newChatMode when mode prop changes
React.useEffect(
() => {
if (mode) {
setNewChatMode(mode);
if (initialMode) {
setNewChatMode(initialMode);
}
},
[mode]
[initialMode]
);
const canStartNewChat = !!selectedAiRequestId;
const onStartNewChat = React.useCallback(
(mode: 'chat' | 'agent') => {
setSelectedAiRequestId(null);
if (mode) {
setNewChatMode(mode);
}
const onStartOrOpenChat = React.useCallback(
({
mode,
aiRequestId,
}: {|
mode: 'chat' | 'agent',
aiRequestId: string | null,
|}) => {
setSelectedAiRequestId(aiRequestId);
setNewChatMode(mode);
},
[setSelectedAiRequestId]
);
@@ -590,11 +467,23 @@ export const AskAiEditor = React.memo<Props>(
setIsHistoryOpen(false);
}, []);
const {
aiRequestStorage,
editorFunctionCallResultsStorage,
} = React.useContext(AiRequestContext);
const {
getEditorFunctionCallResults,
addEditorFunctionCallResults,
clearEditorFunctionCallResults,
} = useEditorFunctionCallResultsPerRequest();
} = editorFunctionCallResultsStorage;
const {
updateAiRequest,
refreshAiRequest,
isSendingAiRequest,
getLastSendError,
setSendingAiRequest,
setLastSendError,
} = aiRequestStorage;
const {
createAiProject,
@@ -606,14 +495,14 @@ export const AskAiEditor = React.memo<Props>(
if (setToolbar) {
setToolbar(
<Toolbar
onStartNewChat={onStartNewChat}
onStartOrOpenChat={onStartOrOpenChat}
canStartNewChat={canStartNewChat}
onOpenHistory={onOpenHistory}
/>
);
}
},
[setToolbar, onStartNewChat, canStartNewChat, onOpenHistory]
[setToolbar, onStartOrOpenChat, canStartNewChat, onOpenHistory]
);
React.useEffect(updateToolbar, [updateToolbar]);
@@ -626,7 +515,7 @@ export const AskAiEditor = React.memo<Props>(
onSceneObjectEdited: noop,
onSceneObjectsDeleted: noop,
onSceneEventsModifiedOutsideEditor: noop,
startNewChat: onStartNewChat,
startOrOpenChat: onStartOrOpenChat,
}));
const aiRequestChatRef = React.useRef<AiRequestChatInterface | null>(
@@ -820,6 +709,7 @@ export const AskAiEditor = React.memo<Props>(
updateAiRequest,
createAiProject,
newAiRequestOptions,
onOpenAskAi,
]
);
@@ -838,7 +728,13 @@ export const AskAiEditor = React.memo<Props>(
// Send the results of the function call outputs, if any, and the user message (if any).
const onSendMessage = React.useCallback(
async ({ userMessage }: {| userMessage: string |}) => {
async ({
userMessage,
createdSceneNames,
}: {|
userMessage: string,
createdSceneNames?: Array<string>,
|}) => {
if (
!profile ||
!selectedAiRequestId ||
@@ -950,9 +846,25 @@ export const AskAiEditor = React.memo<Props>(
// Ignore limits refresh error.
}
}
if (selectedAiRequest && createdSceneNames) {
onOpenAskAi({
mode: selectedAiRequest.mode || 'agent',
aiRequestId: selectedAiRequestId,
paneIdentifier: 'right',
});
createdSceneNames.forEach(sceneName => {
onOpenLayout(sceneName, {
openEventsEditor: true,
openSceneEditor: true,
focusWhenOpened: 'scene',
});
});
}
},
[
profile,
selectedAiRequest,
selectedAiRequestId,
isSendingAiRequest,
getEditorFunctionCallResults,
@@ -968,12 +880,15 @@ export const AskAiEditor = React.memo<Props>(
onRefreshLimits,
project,
hasFunctionsCallsToProcess,
onOpenAskAi,
onOpenLayout,
]
);
const onSendEditorFunctionCallResults = React.useCallback(
() =>
(options: null | {| createdSceneNames: Array<string> |}) =>
onSendMessage({
userMessage: '',
createdSceneNames: options ? options.createdSceneNames : [],
}),
[onSendMessage]
);
@@ -1067,7 +982,7 @@ export const AskAiEditor = React.memo<Props>(
}}
i18n={i18n}
editorCallbacks={editorCallbacks}
onStartNewChat={onStartNewChat}
onStartOrOpenChat={onStartOrOpenChat}
/>
</div>
</Paper>
@@ -1116,7 +1031,13 @@ export const renderAskAiEditorContainer = (
props.onSceneEventsModifiedOutsideEditor
}
onExtensionInstalled={props.onExtensionInstalled}
mode={props.extraEditorProps && props.extraEditorProps.mode}
initialMode={
(props.extraEditorProps && props.extraEditorProps.mode) || null
}
initialAiRequestId={
(props.extraEditorProps && props.extraEditorProps.aiRequestId) || null
}
onOpenAskAi={props.onOpenAskAi}
/>
)}
</I18n>

View File

@@ -247,7 +247,7 @@ export const AskAiHistory = ({
return (
<Drawer
open={open}
anchor="left"
anchor="right"
onClose={onClose}
PaperProps={{
style: styles.drawer,
@@ -260,6 +260,7 @@ export const AskAiHistory = ({
<ColumnStackLayout expand noMargin>
<DrawerTopBar
title={<Trans>AI Chat History</Trans>}
drawerAnchor="right"
id="ai-chat-history-drawer-top-bar"
onClose={onClose}
/>

View File

@@ -3,18 +3,21 @@ import { Trans, t } from '@lingui/macro';
import * as React from 'react';
import { ToolbarGroup } from '../UI/Toolbar';
import AddIcon from '../UI/CustomSvgIcons/Add';
import HistoryIcon from '../UI/CustomSvgIcons/History';
import RaisedButton from '../UI/RaisedButton';
import ChatBubblesIcon from '../UI/CustomSvgIcons/ChatBubbles';
import ResponsiveRaisedButton from '../UI/ResponsiveRaisedButton';
import IconButton from '../UI/IconButton';
type Props = {|
onStartNewChat: (mode: 'chat' | 'agent') => void,
onStartOrOpenChat: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
|}) => void,
canStartNewChat: boolean,
onOpenHistory: () => void,
|};
export const Toolbar = ({
onStartNewChat,
onStartOrOpenChat,
canStartNewChat,
onOpenHistory,
}: Props) => {
@@ -27,16 +30,24 @@ export const Toolbar = ({
tooltip={t`View history`}
onClick={onOpenHistory}
>
<HistoryIcon />
<ChatBubblesIcon />
</IconButton>
</ToolbarGroup>
<ToolbarGroup lastChild>
<RaisedButton
<ResponsiveRaisedButton
primary
onClick={() => onStartNewChat('agent')}
onClick={() =>
onStartOrOpenChat({
mode: 'agent',
aiRequestId: null,
})
}
icon={<AddIcon />}
label={<Trans>Start a new chat</Trans>}
disabled={!canStartNewChat}
style={{
flexShrink: 0,
}}
/>
</ToolbarGroup>
</>

View File

@@ -9,6 +9,7 @@ import {
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
} from '../Utils/GDevelopServices/Shop';
export type AssetStorePageState = {|
@@ -17,6 +18,7 @@ export type AssetStorePageState = {|
openedAssetShortHeader: ?AssetShortHeader,
openedPrivateAssetPackListingData: ?PrivateAssetPackListingData,
openedPrivateGameTemplateListingData: ?PrivateGameTemplateListingData,
openedBundleListingData: ?BundleListingData,
selectedFolders: Array<string>,
filtersState: FiltersState,
pageBreakIndex?: ?number,
@@ -53,6 +55,11 @@ export type NavigationState = {|
storeSearchText: boolean,
clearSearchText: boolean,
|}) => void,
openBundleInformationPage: ({|
bundleListingData: BundleListingData,
storeSearchText: boolean,
clearSearchText: boolean,
|}) => void,
openAssetDetailPage: ({|
assetShortHeader: AssetShortHeader,
storeSearchText: boolean,
@@ -76,6 +83,7 @@ export const assetStoreHomePageState: AssetStorePageState = {
openedAssetPack: null,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: null,
selectedFolders: [],
filtersState: noFilter,
displayAssets: false,
@@ -87,6 +95,7 @@ const searchPageState: AssetStorePageState = {
openedAssetPack: null,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: null,
selectedFolders: [],
filtersState: noFilter,
displayAssets: true,
@@ -99,6 +108,7 @@ export const isHomePage = (pageState: AssetStorePageState) => {
!pageState.openedPrivateAssetPackListingData &&
!pageState.openedPrivateGameTemplateListingData &&
!pageState.openedAssetPack &&
!pageState.openedBundleListingData &&
pageState.filtersState === noFilter &&
!pageState.displayAssets)
);
@@ -109,7 +119,8 @@ export const isSearchResultPage = (pageState: AssetStorePageState) => {
!isHomePage(pageState) &&
!pageState.openedAssetShortHeader &&
!pageState.openedPrivateAssetPackListingData &&
!pageState.openedPrivateGameTemplateListingData
!pageState.openedPrivateGameTemplateListingData &&
!pageState.openedBundleListingData
);
};
@@ -133,6 +144,7 @@ export const AssetStoreNavigatorContext = React.createContext<NavigationState>({
openPackPage: () => {},
openPrivateAssetPackInformationPage: () => {},
openPrivateGameTemplateInformationPage: () => {},
openBundleInformationPage: () => {},
openAssetDetailPage: () => {},
navigateInsideFolder: string => {},
goBackToFolderIndex: number => {},
@@ -169,7 +181,12 @@ export const AssetStoreNavigatorStateProvider = (
});
return newCurrentPage;
}
return previousPages[0];
if (previousPages.length === 1) {
// If we are already on the root page, do nothing.
return previousPages[0];
}
// If there are no previous pages, return the home page state.
return assetStoreHomePageState;
},
openHome: () => {
setHistory({ previousPages: [assetStoreHomePageState] });
@@ -244,6 +261,7 @@ export const AssetStoreNavigatorStateProvider = (
openedAssetPack: null,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: null,
displayAssets: true,
filtersState: {
chosenCategory: {
@@ -271,6 +289,7 @@ export const AssetStoreNavigatorStateProvider = (
openedAssetPack: null,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: null,
filtersState: noFilter,
displayAssets: false,
selectedFolders: [],
@@ -315,6 +334,7 @@ export const AssetStoreNavigatorStateProvider = (
openedAssetPack: assetPack,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: null,
displayAssets: true,
filtersState: {
chosenCategory: {
@@ -373,6 +393,7 @@ export const AssetStoreNavigatorStateProvider = (
openedAssetPack: null,
openedPrivateAssetPackListingData: privateAssetPackListingData,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: null,
filtersState: noFilter,
displayAssets: false,
selectedFolders: [],
@@ -418,6 +439,7 @@ export const AssetStoreNavigatorStateProvider = (
openedAssetPack: null,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: null,
filtersState: noFilter,
displayAssets: false,
selectedFolders: [],
@@ -463,6 +485,53 @@ export const AssetStoreNavigatorStateProvider = (
openedAssetPack: null,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: privateGameTemplateListingData,
openedBundleListingData: null,
filtersState: noFilter,
displayAssets: false,
selectedFolders: [],
},
],
};
});
if (clearSearchText) setSearchText('');
},
openBundleInformationPage: ({
bundleListingData,
storeSearchText,
clearSearchText,
}: {|
bundleListingData: BundleListingData,
storeSearchText: boolean,
clearSearchText: boolean,
|}) => {
setHistory(previousHistory => {
const currentPage =
previousHistory.previousPages[
previousHistory.previousPages.length - 1
];
const currentPageWithSearchText = {
...currentPage,
searchText: storeSearchText ? searchText : '',
};
const previousPagesWithoutCurrentPage = previousHistory.previousPages.slice(
0,
previousHistory.previousPages.length - 1
);
const previousPages = [
...previousPagesWithoutCurrentPage,
currentPageWithSearchText,
];
return {
...previousHistory,
previousPages: [
...previousPages,
{
openedAssetShortHeader: null,
openedShopCategory: null,
openedAssetPack: null,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: bundleListingData,
filtersState: noFilter,
displayAssets: false,
selectedFolders: [],

View File

@@ -7,6 +7,7 @@ import {
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
} from '../Utils/GDevelopServices/Shop';
/**
@@ -119,3 +120,19 @@ export const getPrivateGameTemplateListingDataFromUserFriendlySlug = ({
return null;
};
export const getBundleListingDataFromUserFriendlySlug = ({
bundleListingDatas,
userFriendlySlug,
}: {|
bundleListingDatas: Array<BundleListingData>,
userFriendlySlug: string,
|}): ?BundleListingData => {
const bundleId = getIdFromPrivateProductUserFriendlySlug(userFriendlySlug);
const bundleListingData = bundleListingDatas.find(
bundle => bundleId === bundle.id
);
if (bundleListingData) return bundleListingData;
return null;
};

View File

@@ -191,7 +191,7 @@ function AssetSwappingDialog({
>
<AssetStore
ref={assetStore}
hideGameTemplates
onlyShowAssets
assetSwappedObject={object}
minimalUI={minimalUI}
/>

View File

@@ -10,6 +10,10 @@ import type {
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
getArchivedBundleListingData,
getArchivedPrivateGameTemplateListingData,
getArchivedPrivateAssetPackListingData,
} from '../Utils/GDevelopServices/Shop';
import { Line, Column } from '../UI/Grid';
import ScrollView, { type ScrollViewInterface } from '../UI/ScrollView';
@@ -24,6 +28,7 @@ import {
PrivateAssetPackTile,
PublicAssetPackTile,
PrivateGameTemplateTile,
BundleTile,
} from './ShopTiles';
import { useDebounce } from '../Utils/UseDebounce';
import PromotionsSlideshow from '../Promotions/PromotionsSlideshow';
@@ -59,7 +64,7 @@ const getShopItemsColumns = (
case 'small':
return isLandscape ? 3 : 2;
case 'medium':
return 2;
return 3;
case 'large':
return 4;
case 'xlarge':
@@ -161,12 +166,14 @@ type Props = {|
publicAssetPacks: PublicAssetPacks,
privateAssetPackListingDatas: Array<PrivateAssetPackListingData>,
privateGameTemplateListingDatas: Array<PrivateGameTemplateListingData>,
bundleListingDatas: Array<BundleListingData>,
onPublicAssetPackSelection: PublicAssetPack => void,
onPrivateAssetPackSelection: PrivateAssetPackListingData => void,
onPrivateGameTemplateSelection: PrivateGameTemplateListingData => void,
onBundleSelection: BundleListingData => void,
onCategorySelection: string => void,
openedShopCategory: string | null,
hideGameTemplates?: boolean,
onlyShowAssets?: boolean,
displayPromotions?: boolean,
onOpenProfile?: () => void,
|};
@@ -177,12 +184,14 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
publicAssetPacks: { starterPacks },
privateAssetPackListingDatas,
privateGameTemplateListingDatas,
bundleListingDatas,
onPublicAssetPackSelection,
onPrivateAssetPackSelection,
onPrivateGameTemplateSelection,
onBundleSelection,
onCategorySelection,
openedShopCategory,
hideGameTemplates,
onlyShowAssets,
displayPromotions,
onOpenProfile,
}: Props,
@@ -192,6 +201,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
const {
receivedAssetPacks,
receivedGameTemplates,
receivedBundles,
badges,
achievements,
} = React.useContext(AuthenticatedUserContext);
@@ -220,7 +230,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
Object.entries(shopCategories).map(
// $FlowExpectedError - Object.entries does not infer well the type of the value.
([id, { title, imageSource, imageAlt }]) =>
hideGameTemplates && id === 'game-template' ? null : (
onlyShowAssets && id === 'game-template' ? null : (
<CategoryTile
// This id would be more appropriate if it was shop-category-...
// but it is kept as is to avoid breaking some guided lessons using this
@@ -236,7 +246,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
/>
)
),
[onCategorySelection, hideGameTemplates]
[onCategorySelection, onlyShowAssets]
);
const openedShopCategoryTitle = openedShopCategory
@@ -257,7 +267,10 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
/>
));
const { allStandAloneTiles, allBundleTiles } = React.useMemo(
const {
allAssetPackStandAloneTiles,
allAssetPackBundleTiles,
} = React.useMemo(
() => {
const privateAssetPackStandAloneTiles: Array<React.Node> = [];
const privateOwnedAssetPackStandAloneTiles: Array<React.Node> = [];
@@ -304,13 +317,55 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
}
});
const allBundleTiles = [
const archivedOwnedAssetPackStandAloneTiles: Array<React.Node> = [];
const archivedOwnedAssetPackBundleTiles: Array<React.Node> = [];
// Some asset pack products can be archived, meaning the listing data
// is not available anymore, but the user still owns the asset pack.
// We look at the remaining receivedAssetPacks to display them.
(receivedAssetPacks || [])
.filter(
assetPack =>
!privateAssetPackListingDatas.find(
privateAssetPackListingData =>
privateAssetPackListingData.id === assetPack.id
)
)
.forEach(assetPack => {
const archivedAssetPackListingData = getArchivedPrivateAssetPackListingData(
{
assetPack,
}
);
const tile = (
<PrivateAssetPackTile
assetPackListingData={archivedAssetPackListingData}
onSelect={() => {
onPrivateAssetPackSelection(archivedAssetPackListingData);
}}
owned={true}
key={assetPack.id}
/>
);
if (
archivedAssetPackListingData.includedListableProductIds &&
!!archivedAssetPackListingData.includedListableProductIds.length
) {
archivedOwnedAssetPackBundleTiles.push(tile);
} else {
archivedOwnedAssetPackStandAloneTiles.push(tile);
}
});
const allAssetPackBundleTiles = [
...privateOwnedAssetPackBundleTiles, // Display owned bundles first.
...archivedOwnedAssetPackBundleTiles,
...privateAssetPackBundleTiles,
];
const allStandAloneTiles = [
const allAssetPackStandAloneTiles = [
...privateOwnedAssetPackStandAloneTiles, // Display owned packs first.
...archivedOwnedAssetPackStandAloneTiles,
...mergeArraysPerGroup(
privateAssetPackStandAloneTiles,
starterPacksTiles,
@@ -319,7 +374,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
),
];
return { allStandAloneTiles, allBundleTiles };
return { allAssetPackStandAloneTiles, allAssetPackBundleTiles };
},
[
privateAssetPackListingDatas,
@@ -330,29 +385,153 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
]
);
const allBundleTiles = React.useMemo(
() => {
const bundleTiles: Array<React.Node> = [];
const ownedBundleTiles: Array<React.Node> = [];
bundleListingDatas
.filter(
bundleListingData =>
!openedShopCategory ||
bundleListingData.categories.includes(openedShopCategory)
)
.forEach(bundleListingData => {
const isBundleOwned =
!!receivedBundles &&
!!receivedBundles.find(
bundle => bundle.id === bundleListingData.id
);
const tile = (
<BundleTile
bundleListingData={bundleListingData}
onSelect={() => {
onBundleSelection(bundleListingData);
}}
owned={isBundleOwned}
key={bundleListingData.id}
/>
);
if (isBundleOwned) {
ownedBundleTiles.push(tile);
} else {
bundleTiles.push(tile);
}
});
// Some bundle products can be archived, meaning the listing data
// is not available anymore, but the user still owns the bundle.
// We look at the remaining receivedBundles to display them.
const archivedOwnedBundleTiles = (receivedBundles || [])
.filter(
bundle =>
!bundleListingDatas.find(
bundleListingData => bundleListingData.id === bundle.id
)
)
.map(bundle => {
const archivedBundleListingData = getArchivedBundleListingData({
bundle,
});
return (
<BundleTile
bundleListingData={archivedBundleListingData}
onSelect={() => {
onBundleSelection(archivedBundleListingData);
}}
owned={true}
key={bundle.id}
/>
);
});
return [
...ownedBundleTiles, // Display owned bundles first.
...archivedOwnedBundleTiles,
...bundleTiles,
];
},
[
bundleListingDatas,
openedShopCategory,
onBundleSelection,
receivedBundles,
]
);
const gameTemplateTiles = React.useMemo(
() => {
const gameTemplateTiles: Array<React.Node> = [];
const ownedGameTemplateTiles: Array<React.Node> = [];
// Only show game templates if the category is not set or is set to "game-template".
return privateGameTemplateListingDatas
privateGameTemplateListingDatas
.filter(
privateGameTemplateListingData =>
!openedShopCategory || openedShopCategory === 'game-template'
)
.map((privateGameTemplateListingData, index) => (
<PrivateGameTemplateTile
privateGameTemplateListingData={privateGameTemplateListingData}
onSelect={() => {
onPrivateGameTemplateSelection(privateGameTemplateListingData);
}}
owned={
!!receivedGameTemplates &&
!!receivedGameTemplates.find(
pack => pack.id === privateGameTemplateListingData.id
)
.forEach(privateGameTemplateListingData => {
const isGameTemplateOwned =
!!receivedGameTemplates &&
!!receivedGameTemplates.find(
pack => pack.id === privateGameTemplateListingData.id
);
const tile = (
<PrivateGameTemplateTile
privateGameTemplateListingData={privateGameTemplateListingData}
onSelect={() => {
onPrivateGameTemplateSelection(
privateGameTemplateListingData
);
}}
owned={isGameTemplateOwned}
key={privateGameTemplateListingData.id}
/>
);
if (isGameTemplateOwned) {
ownedGameTemplateTiles.push(tile);
} else {
gameTemplateTiles.push(tile);
}
});
// Some game template products can be archived, meaning the listing data
// is not available anymore, but the user still owns the game template.
// We look at the remaining receivedGameTemplates to display them.
const archivedOwnedGameTemplateTiles = (receivedGameTemplates || [])
.filter(
gameTemplate =>
!privateGameTemplateListingDatas.find(
privateGameTemplateListingData =>
privateGameTemplateListingData.id === gameTemplate.id
)
)
.map(gameTemplate => {
const archivedGameTemplateListingData = getArchivedPrivateGameTemplateListingData(
{
gameTemplate,
}
key={privateGameTemplateListingData.id}
/>
));
);
return (
<PrivateGameTemplateTile
privateGameTemplateListingData={archivedGameTemplateListingData}
onSelect={() => {
onPrivateGameTemplateSelection(
archivedGameTemplateListingData
);
}}
owned={true}
key={gameTemplate.id}
/>
);
});
return [
...ownedGameTemplateTiles, // Display owned game templates first.
...archivedOwnedGameTemplateTiles,
...gameTemplateTiles,
];
},
[
privateGameTemplateListingDatas,
@@ -363,10 +542,10 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
);
const {
displayedList: displayedStandAloneTiles,
displayedList: displayedAssetPackStandAloneTiles,
onShowMore: onShowMoreStandAloneTiles,
} = useProgressiveReveal({
list: allStandAloneTiles,
list: allAssetPackStandAloneTiles,
numberPerPage: 25,
});
@@ -400,7 +579,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
</GridList>
</>
)}
{displayPromotions ? (
{displayPromotions && !openedShopCategory ? (
<ColumnStackLayout noMargin>
<Text size="block-title">
<Trans>Promotions + Earn credits</Trans>
@@ -416,12 +595,12 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
)}
</ColumnStackLayout>
) : null}
{allBundleTiles.length ? (
{allBundleTiles.length && !onlyShowAssets ? (
<>
<Column noMargin>
<Line>
<Text size="block-title">
<Trans>Bundles</Trans>
<Trans>GDevelop Bundles</Trans>
</Text>
</Line>
</Column>
@@ -435,6 +614,25 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
</GridList>
</>
) : null}
{allAssetPackBundleTiles.length ? (
<>
<Column noMargin>
<Line>
<Text size="block-title">
<Trans>Asset pack bundles</Trans>
</Text>
</Line>
</Column>
<GridList
cols={getShopItemsColumns(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing}
>
{allAssetPackBundleTiles}
</GridList>
</>
) : null}
{openedShopCategoryTitle && (
<Column noMargin>
<Line>
@@ -442,7 +640,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
</Line>
</Column>
)}
{!hideGameTemplates && (
{!onlyShowAssets && (
<>
{!openedShopCategoryTitle && (
<Column noMargin>
@@ -478,7 +676,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
cellHeight="auto"
spacing={cellSpacing}
>
{displayedStandAloneTiles}
{displayedAssetPackStandAloneTiles}
</GridList>
</ScrollView>
);

View File

@@ -13,6 +13,7 @@ import {
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
} from '../Utils/GDevelopServices/Shop';
import { NoResultPlaceholder } from './NoResultPlaceholder';
import GridList from '@material-ui/core/GridList';
@@ -31,6 +32,7 @@ import {
PrivateAssetPackTile,
PrivateGameTemplateTile,
PublicAssetPackTile,
BundleTile,
} from './ShopTiles';
import PrivateAssetPackAudioFilesDownloadButton from './PrivateAssets/PrivateAssetPackAudioFilesDownloadButton';
import { CorsAwareImage } from '../UI/CorsAwareImage';
@@ -52,6 +54,7 @@ import HelpIcon from '../UI/HelpIcon';
import { OwnedProductLicense } from './ProductLicense/ProductLicenseOptions';
import { getUserProductPurchaseUsageType } from './ProductPageHelper';
import PublicProfileContext from '../Profile/PublicProfileContext';
import { BundleStoreContext } from './Bundles/BundleStoreContext';
const ASSETS_DISPLAY_LIMIT = 60;
@@ -229,6 +232,7 @@ type Props = {|
assetShortHeaders: ?Array<AssetShortHeader>,
privateAssetPackListingDatas?: ?Array<PrivateAssetPackListingData>,
privateGameTemplateListingDatas?: ?Array<PrivateGameTemplateListingData>,
bundleListingDatas?: ?Array<BundleListingData>,
publicAssetPacks?: ?Array<PublicAssetPack>,
onOpenDetails: (assetShortHeader: AssetShortHeader) => void,
noResultsPlaceHolder?: React.Node,
@@ -243,6 +247,7 @@ type Props = {|
onPrivateGameTemplateSelection?: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onBundleSelection?: (bundleListingData: BundleListingData) => void,
onFolderSelection?: (folderTag: string) => void,
onGoBackToFolderIndex?: (folderIndex: number) => void,
noScroll?: boolean,
@@ -251,7 +256,7 @@ type Props = {|
// Or it can display arbitrary content, like the list of assets in a pack, or similar assets,
// then currentPage is null.
currentPage?: AssetStorePageState,
hideGameTemplates?: boolean,
onlyShowAssets?: boolean,
hideDetails?: boolean,
|};
@@ -263,15 +268,17 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
noResultsPlaceHolder,
privateAssetPackListingDatas,
privateGameTemplateListingDatas,
bundleListingDatas,
publicAssetPacks,
onPrivateAssetPackSelection,
onPublicAssetPackSelection,
onPrivateGameTemplateSelection,
onBundleSelection,
onFolderSelection,
onGoBackToFolderIndex,
noScroll,
currentPage,
hideGameTemplates,
onlyShowAssets,
hideDetails,
}: Props,
ref
@@ -290,10 +297,15 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
error: gameTemplateStoreError,
fetchGameTemplates,
} = React.useContext(PrivateGameTemplateStoreContext);
const { error: bundleStoreError, fetchBundles } = React.useContext(
BundleStoreContext
);
const {
receivedAssetPacks,
receivedGameTemplates,
receivedBundles,
assetPackPurchases,
bundlePurchases,
} = React.useContext(AuthenticatedUserContext);
const [
authorPublicProfile,
@@ -343,11 +355,13 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
() => {
fetchAssetsAndFilters();
fetchGameTemplates();
fetchBundles();
},
[fetchAssetsAndFilters, fetchGameTemplates]
[fetchAssetsAndFilters, fetchGameTemplates, fetchBundles]
);
const shopError = assetStoreError || gameTemplateStoreError;
const shopError =
assetStoreError || gameTemplateStoreError || bundleStoreError;
const hasAssetPackFiltersApplied = React.useMemo(
// When a pack is opened, the asset pack filters are not hidden, but not relevant either.
@@ -517,7 +531,10 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
[publicAssetPacks, onPublicAssetPackSelection, hasAssetFiltersApplied]
);
const { allStandAlonePackTiles, allBundlePackTiles } = React.useMemo(
const {
allAssetPackStandAloneTiles,
allAssetPackBundleTiles,
} = React.useMemo(
() => {
const privateAssetPackStandAloneTiles: Array<React.Node> = [];
const privateOwnedAssetPackStandAloneTiles: Array<React.Node> = [];
@@ -531,8 +548,8 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
hasAssetFiltersApplied
) {
return {
allStandAlonePackTiles: [],
allBundlePackTiles: [],
allAssetPackStandAloneTiles: [],
allAssetPackBundleTiles: [],
};
}
@@ -571,12 +588,12 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
}
});
const allBundlePackTiles = [
const allAssetPackBundleTiles = [
...privateOwnedAssetPackBundleTiles, // Display owned bundles first.
...privateAssetPackBundleTiles,
];
const allStandAlonePackTiles = [
const allAssetPackStandAloneTiles = [
...privateOwnedAssetPackStandAloneTiles, // Display owned packs first.
...mergeArraysPerGroup(
privateAssetPackStandAloneTiles,
@@ -586,7 +603,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
),
];
return { allStandAlonePackTiles, allBundlePackTiles };
return { allAssetPackStandAloneTiles, allAssetPackBundleTiles };
},
[
privateAssetPackListingDatas,
@@ -606,27 +623,45 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
hasAssetFiltersApplied ||
// Don't show private game templates if filtering on asset packs.
hasAssetPackFiltersApplied ||
hideGameTemplates
onlyShowAssets
)
return [];
return privateGameTemplateListingDatas.map(
(privateGameTemplateListingData, index) => (
<PrivateGameTemplateTile
privateGameTemplateListingData={privateGameTemplateListingData}
onSelect={() => {
onPrivateGameTemplateSelection(privateGameTemplateListingData);
}}
owned={
!!receivedGameTemplates &&
!!receivedGameTemplates.find(
pack => pack.id === privateGameTemplateListingData.id
)
}
key={privateGameTemplateListingData.id}
/>
)
const notOwnedGameTemplateTiles: Array<React.Node> = [];
const ownedGameTemplateTiles: Array<React.Node> = [];
privateGameTemplateListingDatas.forEach(
privateGameTemplateListingData => {
const isGameTemplateOwned =
!!receivedGameTemplates &&
!!receivedGameTemplates.find(
pack => pack.id === privateGameTemplateListingData.id
);
const tile = (
<PrivateGameTemplateTile
privateGameTemplateListingData={privateGameTemplateListingData}
onSelect={() => {
onPrivateGameTemplateSelection(
privateGameTemplateListingData
);
}}
owned={isGameTemplateOwned}
key={privateGameTemplateListingData.id}
/>
);
if (isGameTemplateOwned) {
ownedGameTemplateTiles.push(tile);
} else {
notOwnedGameTemplateTiles.push(tile);
}
}
);
return [
...ownedGameTemplateTiles, // Display owned game templates first.
...notOwnedGameTemplateTiles,
];
},
[
privateGameTemplateListingDatas,
@@ -634,7 +669,55 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
receivedGameTemplates,
hasAssetFiltersApplied,
hasAssetPackFiltersApplied,
hideGameTemplates,
onlyShowAssets,
]
);
const bundleTiles = React.useMemo(
() => {
if (
!bundleListingDatas ||
!onBundleSelection ||
// Don't show bundles if filtering on assets.
hasAssetFiltersApplied
)
return [];
const notOwnedBundleTiles: Array<React.Node> = [];
const ownedBundleTiles: Array<React.Node> = [];
bundleListingDatas.forEach(bundleListingData => {
const isBundleOwned =
!!receivedBundles &&
!!receivedBundles.find(pack => pack.id === bundleListingData.id);
const tile = (
<BundleTile
bundleListingData={bundleListingData}
onSelect={() => {
onBundleSelection(bundleListingData);
}}
owned={isBundleOwned}
key={bundleListingData.id}
/>
);
if (isBundleOwned) {
ownedBundleTiles.push(tile);
} else {
notOwnedBundleTiles.push(tile);
}
});
return [
...ownedBundleTiles, // Display owned bundles first.
...notOwnedBundleTiles,
];
},
[
bundleListingDatas,
onBundleSelection,
receivedBundles,
hasAssetFiltersApplied,
]
);
@@ -697,15 +780,27 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
getUserProductPurchaseUsageType({
productId:
openedAssetPack && openedAssetPack.id ? openedAssetPack.id : null,
receivedProducts: receivedAssetPacks,
productPurchases: assetPackPurchases,
allProductListingDatas: allPrivateAssetPackListingDatas,
receivedProducts: [
...(receivedAssetPacks || []),
...(receivedBundles || []),
],
productPurchases: [
...(assetPackPurchases || []),
...(bundlePurchases || []),
],
allProductListingDatas: [
...(allPrivateAssetPackListingDatas || []),
...(bundleListingDatas || []),
],
}),
[
assetPackPurchases,
bundlePurchases,
openedAssetPack,
allPrivateAssetPackListingDatas,
bundleListingDatas,
receivedAssetPacks,
receivedBundles,
]
);
@@ -739,6 +834,20 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
scrollView={scrollView.current}
/>
)}
{!openedAssetPack && bundleTiles.length && pageBreakIndex === 0 ? (
<Line>
<Column noMargin expand>
<GridList
cols={getShopItemsColumns(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing}
>
{bundleTiles}
</GridList>
</Column>
</Line>
) : null}
{!openedAssetPack &&
gameTemplateTiles.length &&
pageBreakIndex === 0 ? (
@@ -756,7 +865,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
</Line>
) : null}
{!openedAssetPack &&
allBundlePackTiles.length &&
allAssetPackBundleTiles.length &&
pageBreakIndex === 0 ? (
<Line>
<Column noMargin expand>
@@ -766,13 +875,13 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
cellHeight="auto"
spacing={cellSpacing}
>
{allBundlePackTiles}
{allAssetPackBundleTiles}
</GridList>
</Column>
</Line>
) : null}
{!openedAssetPack &&
allStandAlonePackTiles.length &&
allAssetPackStandAloneTiles.length &&
pageBreakIndex === 0 ? (
<Line>
<Column noMargin expand>
@@ -782,7 +891,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
cellHeight="auto"
spacing={cellSpacing}
>
{allStandAlonePackTiles}
{allAssetPackStandAloneTiles}
</GridList>
</Column>
</Line>
@@ -974,9 +1083,11 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
// No assets to show.
!assetTiles.length &&
// No bundles to show.
!allBundlePackTiles.length &&
!bundleTiles.length &&
// No asset pack bundles to show.
!allAssetPackBundleTiles.length &&
// No packs to show.
!allStandAlonePackTiles.length &&
!allAssetPackStandAloneTiles.length &&
// no templates to show.
!gameTemplateTiles.length &&
(!openedAssetPack ||

View File

@@ -0,0 +1,664 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import {
type BundleListingData,
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type CourseListingData,
} from '../../Utils/GDevelopServices/Shop';
import {
getBundle,
type Bundle,
type Course,
} from '../../Utils/GDevelopServices/Asset';
import Text from '../../UI/Text';
import { Trans } from '@lingui/macro';
import AlertMessage from '../../UI/AlertMessage';
import PlaceholderLoader from '../../UI/PlaceholderLoader';
import FlatButton from '../../UI/FlatButton';
import {
ResponsiveLineStackLayout,
LineStackLayout,
ColumnStackLayout,
} from '../../UI/Layout';
import { Column, LargeSpacer, Line, Spacer } from '../../UI/Grid';
import {
getUserPublicProfile,
type UserPublicProfile,
} from '../../Utils/GDevelopServices/User';
import Link from '../../UI/Link';
import ResponsiveMediaGallery from '../../UI/ResponsiveMediaGallery';
import {
useResponsiveWindowSize,
type WindowSizeType,
} from '../../UI/Responsive/ResponsiveWindowMeasurer';
import { sendBundleBuyClicked } from '../../Utils/Analytics/EventSender';
import { MarkdownText } from '../../UI/MarkdownText';
import ScrollView from '../../UI/ScrollView';
import { shouldUseAppStoreProduct } from '../../Utils/AppStorePurchases';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
import { extractGDevelopApiErrorStatusAndCode } from '../../Utils/GDevelopServices/Errors';
import Avatar from '@material-ui/core/Avatar';
import GridList from '@material-ui/core/GridList';
import { BundleStoreContext } from './BundleStoreContext';
import {
getBundlesContainingProductTiles,
getOtherProductsFromSameAuthorTiles,
getProductMediaItems,
getProductsIncludedInBundle,
getProductsIncludedInBundleTiles,
getUserProductPurchaseUsageType,
PurchaseProductButtons,
} from '../ProductPageHelper';
import SecureCheckout from '../SecureCheckout/SecureCheckout';
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
import BundlePurchaseDialog from './BundlePurchaseDialog';
import PublicProfileContext from '../../Profile/PublicProfileContext';
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
import { PrivateGameTemplateStoreContext } from '../PrivateGameTemplates/PrivateGameTemplateStoreContext';
import { AssetStoreContext } from '../AssetStoreContext';
import CourseStoreContext from '../../Course/CourseStoreContext';
import { getCreditsAmountFromId } from '../CreditsPackages/CreditsPackageStoreContext';
import Coin from '../../Credits/Icons/Coin';
import {
getPlanIcon,
getPlanInferredNameFromId,
} from '../../Profile/Subscription/PlanCard';
import RedemptionCodesDialog from '../../RedemptionCode/RedemptionCodesDialog';
import { selectMessageByLocale } from '../../Utils/i18n/MessageByLocale';
import { formatDurationOfRedemptionCode } from '../../RedemptionCode/Utils';
const cellSpacing = 10;
const getTemplateColumns = (
windowSize: WindowSizeType,
isLandscape: boolean
) => {
switch (windowSize) {
case 'small':
return isLandscape ? 4 : 2;
case 'medium':
return 3;
case 'large':
return 4;
case 'xlarge':
return 6;
default:
return 3;
}
};
const MAX_COLUMNS = getTemplateColumns('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const styles = {
disabledText: { opacity: 0.6 },
scrollview: { overflowX: 'hidden' },
grid: {
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
overflow: 'hidden',
width: `calc(100% + ${cellSpacing}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
},
leftColumnContainer: {
flex: 1,
minWidth: 0, // This is needed for the container to take the right size.
},
rightColumnContainer: {
flex: 2,
},
leftColumnContainerMobile: {
flex: 1,
minWidth: 0, // This is needed for the container to take the right size.
},
rightColumnContainerMobile: {
flex: 1,
},
avatar: {
width: 20,
height: 20,
},
ownedTag: {
padding: '4px 8px',
borderRadius: 4,
color: 'black',
},
playIcon: {
width: 20,
height: 20,
},
coinIcon: {
width: 13,
height: 13,
position: 'relative',
top: -1,
},
};
type Props = {|
bundleListingData: BundleListingData,
bundleListingDatasFromSameCreator?: ?Array<BundleListingData>,
receivedCourses: ?Array<Course>,
onBundleOpen: BundleListingData => void,
onGameTemplateOpen: PrivateGameTemplateListingData => void,
onAssetPackOpen: (
privateAssetPackListingData: PrivateAssetPackListingData,
options?: {|
forceProductPage?: boolean,
|}
) => void,
onCourseOpen: CourseListingData => void,
simulateAppStoreProduct?: boolean,
|};
const BundleInformationPage = ({
bundleListingData,
bundleListingDatasFromSameCreator,
receivedCourses,
onBundleOpen,
onGameTemplateOpen,
onAssetPackOpen,
onCourseOpen,
simulateAppStoreProduct,
}: Props) => {
const { id, name, sellerId } = bundleListingData;
const { bundleListingDatas } = React.useContext(BundleStoreContext);
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
);
const { privateAssetPackListingDatas } = React.useContext(AssetStoreContext);
const { listedCourses } = React.useContext(CourseStoreContext);
const {
receivedBundles,
bundlePurchases,
receivedGameTemplates,
receivedAssetPacks,
} = React.useContext(AuthenticatedUserContext);
const [bundle, setBundle] = React.useState<?Bundle>(null);
const [
purchasingBundleListingData,
setPurchasingBundleListingData,
] = React.useState<?BundleListingData>(null);
const [isFetching, setIsFetching] = React.useState<boolean>(false);
const { openUserPublicProfile } = React.useContext(PublicProfileContext);
const [
sellerPublicProfile,
setSellerPublicProfile,
] = React.useState<?UserPublicProfile>(null);
const [errorText, setErrorText] = React.useState<?React.Node>(null);
const {
windowSize,
isLandscape,
isMediumScreen,
isMobile,
} = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const [
isRedemptionCodesDialogOpen,
setIsRedemptionCodesDialogOpen,
] = React.useState<boolean>(false);
const shouldUseOrSimulateAppStoreProduct =
shouldUseAppStoreProduct() || simulateAppStoreProduct;
const userBundlePurchaseUsageType = React.useMemo(
() =>
getUserProductPurchaseUsageType({
productId: bundleListingData ? bundleListingData.id : null,
receivedProducts: receivedBundles,
productPurchases: bundlePurchases,
allProductListingDatas: bundleListingDatas,
}),
[bundlePurchases, bundleListingData, bundleListingDatas, receivedBundles]
);
const isAlreadyReceived = !!userBundlePurchaseUsageType;
const additionalProductThumbnailsIncludedInBundle: string[] = React.useMemo(
() => {
const productsIncludedInBundle = getProductsIncludedInBundle({
productListingDatas: [
...(bundleListingDatas || []),
...(privateGameTemplateListingDatas || []),
...(privateAssetPackListingDatas || []),
...(listedCourses || []),
],
productListingData: bundleListingData,
});
if (!productsIncludedInBundle) return [];
const additionalThumbnails = productsIncludedInBundle
.map(product => (product.thumbnailUrls || []).slice(0, 2))
.reduce((acc, thumbnails) => acc.concat(thumbnails), []);
return additionalThumbnails;
},
[
bundleListingDatas,
privateGameTemplateListingDatas,
privateAssetPackListingDatas,
listedCourses,
bundleListingData,
]
);
const productsIncludedInBundleTiles = React.useMemo(
() =>
getProductsIncludedInBundleTiles({
product: bundle,
productListingDatas: [
...(bundleListingDatas || []),
...(privateGameTemplateListingDatas || []),
...(privateAssetPackListingDatas || []),
...(listedCourses || []),
],
productListingData: bundleListingData,
receivedProducts: [
...(receivedBundles || []),
...(receivedGameTemplates || []),
...(receivedAssetPacks || []),
...(receivedCourses || []),
],
onPrivateAssetPackOpen: product =>
onAssetPackOpen(product, { forceProductPage: true }),
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
onCourseOpen,
}),
[
bundle,
bundleListingDatas,
privateGameTemplateListingDatas,
privateAssetPackListingDatas,
listedCourses,
receivedBundles,
receivedGameTemplates,
receivedAssetPacks,
receivedCourses,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
onCourseOpen,
bundleListingData,
]
);
const bundlesContainingBundleTiles = React.useMemo(
() =>
getBundlesContainingProductTiles({
product: bundle,
productListingData: bundleListingData,
productListingDatas: bundleListingDatas,
receivedProducts: receivedBundles,
onPrivateAssetPackOpen: product =>
onAssetPackOpen(product, { forceProductPage: true }),
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
}),
[
bundle,
bundleListingData,
bundleListingDatas,
receivedBundles,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
]
);
const otherBundlesFromTheSameAuthorTiles = React.useMemo(
() =>
getOtherProductsFromSameAuthorTiles({
otherProductListingDatasFromSameCreator: bundleListingDatasFromSameCreator,
currentProductListingData: bundleListingData,
receivedProducts: receivedBundles,
onProductOpen: onBundleOpen,
}),
[
bundleListingDatasFromSameCreator,
bundleListingData,
receivedBundles,
onBundleOpen,
]
);
React.useEffect(
() => {
(async () => {
setIsFetching(true);
try {
const [bundle, profile] = await Promise.all([
getBundle(id),
getUserPublicProfile(sellerId),
]);
setBundle(bundle);
setSellerPublicProfile(profile);
} catch (error) {
const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode(
error
);
if (extractedStatusAndCode && extractedStatusAndCode.status === 404) {
setErrorText(
<Trans>
Bundle not found - An error occurred, please try again later.
</Trans>
);
} else {
setErrorText(
<Trans>An error occurred, please try again later.</Trans>
);
}
} finally {
setIsFetching(false);
}
})();
},
[id, sellerId]
);
const onClickBuy = React.useCallback(
async () => {
if (!bundle) return;
if (isAlreadyReceived) {
onBundleOpen(bundleListingData);
return;
}
try {
const price = bundleListingData.prices.find(
price => price.usageType === 'default'
);
sendBundleBuyClicked({
bundleId: bundle.id,
bundleName: bundle.name,
bundleTag: bundle.tag,
currency: price ? price.currency : undefined,
usageType: 'default',
});
setPurchasingBundleListingData(bundleListingData);
} catch (e) {
console.warn('Unable to send event', e);
}
},
[bundle, bundleListingData, isAlreadyReceived, onBundleOpen]
);
const mediaItems = React.useMemo(
() =>
getProductMediaItems({
product: bundle,
productListingData: bundleListingData,
shouldSimulateAppStoreProduct: simulateAppStoreProduct,
additionalThumbnails: additionalProductThumbnailsIncludedInBundle,
}),
[
bundle,
bundleListingData,
simulateAppStoreProduct,
additionalProductThumbnailsIncludedInBundle,
]
);
const includedCreditsAmount = React.useMemo(
() =>
(bundleListingData.includedListableProducts || [])
.filter(product => product.productType === 'CREDIT_PACKAGE')
.reduce(
(total, product) => total + getCreditsAmountFromId(product.productId),
0
),
[bundleListingData]
);
const includedRedemptionCodes = React.useMemo(
() => bundleListingData.includedRedemptionCodes || [],
[bundleListingData]
);
return (
<I18n>
{({ i18n }) => (
<>
{errorText ? (
<Line alignItems="center" justifyContent="center" expand>
<AlertMessage kind="error">{errorText}</AlertMessage>
</Line>
) : isFetching ? (
<Column expand alignItems="center" justifyContent="center">
<PlaceholderLoader />
</Column>
) : bundle && sellerPublicProfile ? (
<Column noOverflowParent expand noMargin>
<ScrollView autoHideScrollbar style={styles.scrollview}>
<ResponsiveLineStackLayout
noColumnMargin
noMargin
// Force the columns to wrap on tablets and small screens.
forceMobileLayout={isMediumScreen}
// Prevent it to wrap when in landscape mode on small screens.
noResponsiveLandscape
useLargeSpacer
>
<div
style={
isMobile
? styles.leftColumnContainerMobile
: styles.leftColumnContainer
}
>
<ResponsiveMediaGallery
mediaItems={mediaItems}
altTextTemplate={`Bundle ${name} preview image {mediaIndex}`}
horizontalOuterMarginToEatOnMobile={8}
/>
</div>
<div
style={
isMobile
? styles.rightColumnContainerMobile
: styles.rightColumnContainer
}
>
<ColumnStackLayout noMargin>
<LineStackLayout
noMargin
alignItems="center"
justifyContent="space-between"
>
<Text noMargin size="title">
{selectMessageByLocale(i18n, bundle.nameByLocale)}
</Text>
{isAlreadyReceived && (
<div
style={{
...styles.ownedTag,
backgroundColor:
gdevelopTheme.statusIndicator.success,
}}
>
<Text color="inherit" noMargin>
<Trans>OWNED</Trans>
</Text>
</div>
)}
</LineStackLayout>
<LineStackLayout noMargin alignItems="center">
<Avatar
src={sellerPublicProfile.iconUrl}
style={styles.avatar}
/>
<Text displayInlineAsSpan size="sub-title">
<Link
onClick={() =>
openUserPublicProfile({
userId: sellerPublicProfile.id,
callbacks: {
onAssetPackOpen,
onGameTemplateOpen,
},
})
}
href="#"
>
{sellerPublicProfile.username || ''}
</Link>
</Text>
</LineStackLayout>
<Spacer />
{!isAlreadyReceived ? (
<>
{!shouldUseOrSimulateAppStoreProduct && (
<SecureCheckout />
)}
{!errorText && (
<PurchaseProductButtons
i18n={i18n}
productListingData={bundleListingData}
selectedUsageType="default"
onUsageTypeChange={() => {}}
simulateAppStoreProduct={simulateAppStoreProduct}
isAlreadyReceived={isAlreadyReceived}
onClickBuy={onClickBuy}
onClickBuyWithCredits={() => {}}
/>
)}
</>
) : null}
<Text size="body2" displayInlineAsSpan>
<MarkdownText
source={selectMessageByLocale(
i18n,
bundle.longDescriptionByLocale
)}
allowParagraphs
/>
</Text>
{includedRedemptionCodes.length > 0 && (
<ColumnStackLayout noMargin>
{includedRedemptionCodes.map(
(includedRedemptionCode, index) => (
<LineStackLayout
noMargin
alignItems="center"
key={`${
includedRedemptionCode.givenSubscriptionPlanId
}-${index}`}
>
{getPlanIcon({
planId:
includedRedemptionCode.givenSubscriptionPlanId,
logoSize: 20,
})}
<Text>
<Trans>
{formatDurationOfRedemptionCode(
includedRedemptionCode.durationInDays
)}{' '}
of
{getPlanInferredNameFromId(
includedRedemptionCode.givenSubscriptionPlanId
)}
subscription included
</Trans>
</Text>
</LineStackLayout>
)
)}
{isAlreadyReceived && (
<Line noMargin>
<FlatButton
primary
label={<Trans>See my codes</Trans>}
onClick={() =>
setIsRedemptionCodesDialogOpen(true)
}
/>
</Line>
)}
</ColumnStackLayout>
)}
{includedCreditsAmount > 0 && (
<LineStackLayout noMargin alignItems="center">
<Coin style={styles.coinIcon} />
<Text>
<Trans>
{includedCreditsAmount} credits included
</Trans>
</Text>
</LineStackLayout>
)}
</ColumnStackLayout>
</div>
</ResponsiveLineStackLayout>
{bundlesContainingBundleTiles &&
bundlesContainingBundleTiles.length ? (
<>
<ColumnStackLayout noMargin>
<LargeSpacer />
{bundlesContainingBundleTiles}
<LargeSpacer />
</ColumnStackLayout>
</>
) : null}
{productsIncludedInBundleTiles && (
<>
<Line>
<Text size="block-title">
<Trans>Included in this bundle</Trans>
</Text>
</Line>
<Line>
<GridList
cols={getTemplateColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing}
style={styles.grid}
>
{productsIncludedInBundleTiles}
</GridList>
</Line>
</>
)}
{otherBundlesFromTheSameAuthorTiles &&
otherBundlesFromTheSameAuthorTiles.length > 0 && (
<>
<Line>
<Text size="block-title">
<Trans>Similar bundles</Trans>
</Text>
</Line>
<Line>
<GridList
cols={getTemplateColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing}
style={styles.grid}
>
{otherBundlesFromTheSameAuthorTiles}
</GridList>
</Line>
</>
)}
</ScrollView>
</Column>
) : null}
{!!purchasingBundleListingData && (
<BundlePurchaseDialog
bundleListingData={purchasingBundleListingData}
usageType="default"
onClose={() => setPurchasingBundleListingData(null)}
/>
)}
{isRedemptionCodesDialogOpen && (
<RedemptionCodesDialog
onClose={() => setIsRedemptionCodesDialogOpen(false)}
/>
)}
</>
)}
</I18n>
);
};
export default BundleInformationPage;

View File

@@ -0,0 +1,533 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import { Trans } from '@lingui/macro';
import Divider from '@material-ui/core/Divider';
import {
ColumnStackLayout,
LineStackLayout,
ResponsiveLineStackLayout,
} from '../../UI/Layout';
import Chip from '../../UI/Chip';
import Text from '../../UI/Text';
import { selectMessageByLocale } from '../../Utils/i18n/MessageByLocale';
import { Column, LargeSpacer, Line, Spacer } from '../../UI/Grid';
import {
useResponsiveWindowSize,
type WindowSizeType,
} from '../../UI/Responsive/ResponsiveWindowMeasurer';
import Paper from '../../UI/Paper';
import RaisedButton from '../../UI/RaisedButton';
import Skeleton from '@material-ui/lab/Skeleton';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
import {
getArchivedBundleListingData,
type BundleListingData,
type CourseListingData,
} from '../../Utils/GDevelopServices/Shop';
import { type SubscriptionPlanWithPricingSystems } from '../../Utils/GDevelopServices/Usage';
import {
getProductsIncludedInBundle,
getUserProductPurchaseUsageType,
} from '../ProductPageHelper';
import { PrivateGameTemplateStoreContext } from '../PrivateGameTemplates/PrivateGameTemplateStoreContext';
import { AssetStoreContext } from '../AssetStoreContext';
import { BundleStoreContext } from './BundleStoreContext';
import CourseStoreContext from '../../Course/CourseStoreContext';
import { renderProductPrice } from '../ProductPriceTag';
import {
CreditsPackageStoreContext,
getCreditsAmountFromId,
} from '../CreditsPackages/CreditsPackageStoreContext';
import {
getPlanIcon,
getPlanInferredNameFromId,
} from '../../Profile/Subscription/PlanCard';
import Store from '../../UI/CustomSvgIcons/Store';
import Hammer from '../../UI/CustomSvgIcons/Hammer';
import School from '../../UI/CustomSvgIcons/School';
import Coin from '../../Credits/Icons/Coin';
import Sparkle from '../../UI/CustomSvgIcons/Sparkle';
import { renderEstimatedTotalPriceFormatted } from './Utils';
import { formatDurationOfRedemptionCode } from '../../RedemptionCode/Utils';
const highlightColor = '#6CF9F7';
const mobilePadding = 8;
const desktopPadding = 16;
const styles = {
container: { display: 'flex', borderRadius: 8 },
leftColumn: {
display: 'flex',
flex: 1,
flexDirection: 'column',
gap: 8,
justifyContent: 'space-between',
},
bundlePreviewContainer: {
flex: 1,
background:
'linear-gradient(90deg, rgba(59, 247, 244, 0.1) 0%, rgba(255, 188, 87, 0.1) 100%)',
borderLeft: `2px solid ${highlightColor}`,
},
discountChip: { height: 24, backgroundColor: '#F03F18', color: 'white' },
ownedChip: { height: 24, backgroundColor: '#8BE7C4', color: 'black' },
imageContainer: {
display: 'flex',
alignItems: 'center',
position: 'relative',
borderRadius: 8,
overflow: 'hidden',
},
image: { width: '100%', aspectRatio: '16 / 9' },
discountedPrice: { textDecoration: 'line-through', opacity: 0.7 },
};
const CourseTile = ({
courseListingData,
isAlreadyReceived,
}: {|
courseListingData: CourseListingData,
isAlreadyReceived: boolean,
|}) => {
return (
<I18n>
{({ i18n }) => (
<ColumnStackLayout expand>
<div style={styles.imageContainer}>
<img
src={courseListingData.thumbnailUrls[0]}
style={styles.image}
alt={courseListingData.name}
/>
</div>
<Line noMargin>
<Text noMargin>{courseListingData.name}</Text>
</Line>
{!isAlreadyReceived && (
<Line noMargin>
<Text noMargin color="secondary">
<span style={styles.discountedPrice}>
{renderProductPrice({
i18n,
productListingData: courseListingData,
usageType: 'default',
plainText: true,
})}
</span>
</Text>
</Line>
)}
</ColumnStackLayout>
)}
</I18n>
);
};
const BundlePreviewTile = ({
bundleListingData,
}: {
bundleListingData: ?BundleListingData,
}) => {
const { isMobile } = useResponsiveWindowSize();
if (!bundleListingData) return null;
const includedListableProducts =
bundleListingData.includedListableProducts || [];
const includedRedemptionCodes =
bundleListingData.includedRedemptionCodes || [];
const numberOfAssetPacks = includedListableProducts.filter(
product => product.productType === 'ASSET_PACK'
).length;
const numberOfGameTemplates = includedListableProducts.filter(
product => product.productType === 'GAME_TEMPLATE'
).length;
const numberOfCourses = includedListableProducts.filter(
product => product.productType === 'COURSE'
).length;
const totalCredits = includedListableProducts
.filter(product => product.productType === 'CREDITS_PACKAGE')
.reduce(
(total, product) => total + getCreditsAmountFromId(product.productId),
0
);
return (
<div
style={{
...styles.bundlePreviewContainer,
padding: isMobile ? mobilePadding : desktopPadding,
margin: isMobile ? -mobilePadding : -desktopPadding,
marginLeft: isMobile ? -mobilePadding : 0,
marginTop: isMobile ? 0 : -desktopPadding,
}}
>
<ColumnStackLayout expand>
<LineStackLayout noMargin alignItems="center">
<Sparkle style={{ color: highlightColor }} />
<Text noMargin size="block-title">
<Trans>This bundle includes:</Trans>
</Text>
</LineStackLayout>
<Spacer />
{numberOfAssetPacks > 0 && (
<LineStackLayout noMargin alignItems="center">
<Store />
<Text noMargin>
{numberOfAssetPacks === 1 ? (
<Trans>{numberOfAssetPacks} Asset Pack</Trans>
) : (
<Trans>{numberOfAssetPacks} Asset Packs</Trans>
)}
</Text>
</LineStackLayout>
)}
{numberOfGameTemplates > 0 && (
<LineStackLayout noMargin alignItems="center">
<Hammer />
<Text noMargin>
{numberOfGameTemplates === 1 ? (
<Trans>{numberOfGameTemplates} Game Template</Trans>
) : (
<Trans>{numberOfGameTemplates} Game Templates</Trans>
)}
</Text>
</LineStackLayout>
)}
{numberOfCourses > 0 && (
<LineStackLayout noMargin alignItems="center">
<School />
<Text noMargin>
{numberOfCourses === 1 ? (
<Trans>{numberOfCourses} Course</Trans>
) : (
<Trans>{numberOfCourses} Courses</Trans>
)}
</Text>
</LineStackLayout>
)}
{totalCredits > 0 && (
<LineStackLayout noMargin alignItems="center">
<Coin />
<Text noMargin>
<Trans>{totalCredits} Credits</Trans>
</Text>
</LineStackLayout>
)}
{includedRedemptionCodes.length > 0 &&
includedRedemptionCodes.map((redemptionCode, index) => (
<Line
noMargin
alignItems="center"
key={`${redemptionCode.givenSubscriptionPlanId}-${index}`}
>
{getPlanIcon({
planId: redemptionCode.givenSubscriptionPlanId,
logoSize: 15,
})}
<Text>
<Trans>
{formatDurationOfRedemptionCode(
redemptionCode.durationInDays
)}{' '}
of
{getPlanInferredNameFromId(
redemptionCode.givenSubscriptionPlanId
)}
subscription
</Trans>
</Text>
</Line>
))}
</ColumnStackLayout>
</div>
);
};
const getColumnsFromWindowSize = (windowSize: WindowSizeType) => {
if (windowSize === 'xlarge') return 6;
if (windowSize === 'large') return 4;
if (windowSize === 'medium') return 3;
return 2;
};
type Props = {|
onDisplayBundle: (bundleListingData: BundleListingData) => void,
|};
const BundlePreviewBanner = ({ onDisplayBundle }: Props) => {
const { isMobile, isLandscape, windowSize } = useResponsiveWindowSize();
const numberOfTilesToDisplay = getColumnsFromWindowSize(windowSize) - 1; // Reserve one tile for the bundle preview.
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
);
const { creditsPackageListingDatas } = React.useContext(
CreditsPackageStoreContext
);
const { bundleListingDatas } = React.useContext(BundleStoreContext);
const { privateAssetPackListingDatas } = React.useContext(AssetStoreContext);
const { listedCourses } = React.useContext(CourseStoreContext);
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const { bundlePurchases, receivedBundles } = authenticatedUser;
// For the moment, we either display:
// - the first bundle in the list if none are owned.
// - the first owned bundle (as a listing data if still listed, or as an archived listing data otherwise)
// TODO: improve that logic when we'll have more bundles.
const bundleListingData: BundleListingData | null = React.useMemo(
() => {
if (!bundleListingDatas || !receivedBundles) return null;
if (receivedBundles.length === 0) {
return bundleListingDatas[0]; // Display the first bundle if none are owned.
}
const receivedBundle = receivedBundles[0];
const bundleListingData = bundleListingDatas.find(
bundleListingData => bundleListingData.id === receivedBundle.id
);
if (bundleListingData) {
return bundleListingData; // Display the first owned bundle that is still listed.
}
// If this bundle is not listed anymore, get an archived listing data for that bundle.
return getArchivedBundleListingData({
bundle: receivedBundle,
});
},
[bundleListingDatas, receivedBundles]
);
const userBundlePurchaseUsageType = React.useMemo(
() => {
if (!bundleListingData) return null;
return getUserProductPurchaseUsageType({
productId: bundleListingData.id,
receivedProducts: receivedBundles,
productPurchases: bundlePurchases,
allProductListingDatas: bundleListingDatas,
});
},
[bundlePurchases, bundleListingData, bundleListingDatas, receivedBundles]
);
const isAlreadyReceived = !!userBundlePurchaseUsageType;
const productListingDatasIncludedInBundle = React.useMemo(
() =>
bundleListingData
? getProductsIncludedInBundle({
productListingDatas: [
...(bundleListingDatas || []),
...(privateGameTemplateListingDatas || []),
...(privateAssetPackListingDatas || []),
...(listedCourses || []),
...(creditsPackageListingDatas || []),
],
productListingData: bundleListingData,
})
: null,
[
bundleListingData,
bundleListingDatas,
privateGameTemplateListingDatas,
privateAssetPackListingDatas,
listedCourses,
creditsPackageListingDatas,
]
);
const redemptionCodesIncludedInBundle = React.useMemo(
() =>
bundleListingData
? bundleListingData.includedRedemptionCodes || []
: null,
[bundleListingData]
);
const courseTiles = React.useMemo(
() => {
if (isMobile && !isLandscape) {
return null; // Don't display course tiles on mobile, they take too much space.
}
if (!productListingDatasIncludedInBundle) {
return new Array(numberOfTilesToDisplay).fill(0).map((_, index) => (
<React.Fragment key={`skeleton-${index}`}>
{index > 0 &&
(isMobile && !isLandscape ? (
<Column noMargin>
<Divider orientation="horizontal" />
</Column>
) : (
<Line noMargin>
<Divider orientation="vertical" />
</Line>
))}
{index > 0 && <Spacer />}
<Column expand>
<Skeleton height={140} />
<Skeleton height={20} />
<Skeleton height={20} />
<LargeSpacer />
</Column>
</React.Fragment>
));
}
const coursesIncludedInBundle = productListingDatasIncludedInBundle.filter(
productListingData => productListingData.productType === 'COURSE'
);
return new Array(numberOfTilesToDisplay).fill(0).map((_, index) => {
const courseListingData: ?CourseListingData =
// $FlowFixMe
coursesIncludedInBundle[index];
if (!courseListingData) {
return <div style={{ flex: 1 }} />;
}
return (
<React.Fragment key={`course-${courseListingData.id}`}>
{index > 0 && (
<Line noMargin>
<Divider orientation="vertical" />
</Line>
)}
{index > 0 && <Spacer />}
<CourseTile
courseListingData={courseListingData}
isAlreadyReceived={isAlreadyReceived}
/>
</React.Fragment>
);
});
},
[
isMobile,
isLandscape,
numberOfTilesToDisplay,
productListingDatasIncludedInBundle,
isAlreadyReceived,
]
);
return (
<I18n>
{({ i18n }) => (
<Paper
background="medium"
variant="outlined"
style={{
...styles.container,
padding: isMobile ? mobilePadding : desktopPadding,
}}
>
<Column expand noMargin>
<ResponsiveLineStackLayout
noResponsiveLandscape
noMargin
noColumnMargin
noOverflowParent
>
<div
style={{
...styles.leftColumn,
width: isMobile && !isLandscape ? '100%' : undefined,
}}
>
<ColumnStackLayout noMargin>
<Line noMargin>
{bundleListingData ? (
<Chip
label={
isAlreadyReceived ? (
<Trans>Owned</Trans>
) : (
<Trans>Discount</Trans>
)
}
style={
isAlreadyReceived
? styles.ownedChip
: styles.discountChip
}
/>
) : (
<Skeleton variant="rect" height={20} />
)}
</Line>
<Spacer />
{bundleListingData ? (
<Text noMargin size="block-title">
{bundleListingData.nameByLocale
? selectMessageByLocale(
i18n,
bundleListingData.nameByLocale
)
: bundleListingData.name}
</Text>
) : (
<Skeleton height={30} />
)}
{bundleListingData ? (
<Text noMargin>
{bundleListingData.descriptionByLocale
? selectMessageByLocale(
i18n,
bundleListingData.descriptionByLocale
)
: bundleListingData.description}
</Text>
) : (
<Skeleton height={30} />
)}
</ColumnStackLayout>
{bundleListingData ? (
<ColumnStackLayout noMargin>
{!isAlreadyReceived && (
<Text noMargin color="secondary">
<span style={styles.discountedPrice}>
{renderEstimatedTotalPriceFormatted({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
})}
</span>
</Text>
)}
<RaisedButton
primary
label={
isAlreadyReceived ? (
<Trans>Browse bundle</Trans>
) : (
<Trans>
Buy for{' '}
{renderProductPrice({
i18n,
productListingData: bundleListingData,
usageType: 'default',
plainText: true,
})}
</Trans>
)
}
onClick={() => onDisplayBundle(bundleListingData)}
/>
</ColumnStackLayout>
) : (
<ColumnStackLayout noMargin>
<Skeleton variant="rect" height={40} />
</ColumnStackLayout>
)}
</div>
{courseTiles}
<BundlePreviewTile bundleListingData={bundleListingData} />
</ResponsiveLineStackLayout>
</Column>
</Paper>
)}
</I18n>
);
};
export default BundlePreviewBanner;

View File

@@ -0,0 +1,373 @@
// @flow
import * as React from 'react';
import { t, Trans } from '@lingui/macro';
import { type BundleListingData } from '../../Utils/GDevelopServices/Shop';
import Dialog, { DialogPrimaryButton } from '../../UI/Dialog';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
import CreateProfile from '../../Profile/CreateProfile';
import Text from '../../UI/Text';
import { useInterval } from '../../Utils/UseInterval';
import { getPurchaseCheckoutUrl } from '../../Utils/GDevelopServices/Shop';
import Window from '../../Utils/Window';
import { Line, Spacer } from '../../UI/Grid';
import CircularProgress from '../../UI/CircularProgress';
import BackgroundText from '../../UI/BackgroundText';
import Mark from '../../UI/CustomSvgIcons/Mark';
import FlatButton from '../../UI/FlatButton';
import { ColumnStackLayout, LineStackLayout } from '../../UI/Layout';
import useAlertDialog from '../../UI/Alert/useAlertDialog';
import {
shouldUseAppStoreProduct,
purchaseAppStoreProduct,
} from '../../Utils/AppStorePurchases';
import { extractGDevelopApiErrorStatusAndCode } from '../../Utils/GDevelopServices/Errors';
import PasswordPromptDialog from '../PasswordPromptDialog';
type Props = {|
bundleListingData: BundleListingData,
usageType: string,
onClose: () => void,
simulateAppStoreProduct?: boolean,
|};
const BundlePurchaseDialog = ({
bundleListingData,
usageType,
onClose,
simulateAppStoreProduct,
}: Props) => {
const {
profile,
onOpenLoginDialog,
onOpenCreateAccountDialog,
receivedBundles,
onPurchaseSuccessful,
onRefreshBundlePurchases,
bundlePurchases,
} = React.useContext(AuthenticatedUserContext);
const [isPurchasing, setIsPurchasing] = React.useState(false);
const [
isCheckingPurchasesAfterLogin,
setIsCheckingPurchasesAfterLogin,
] = React.useState(!receivedBundles);
const [purchaseSuccessful, setPurchaseSuccessful] = React.useState(false);
const [
displayPasswordPrompt,
setDisplayPasswordPrompt,
] = React.useState<boolean>(false);
const [password, setPassword] = React.useState<string>('');
const { showAlert } = useAlertDialog();
const shouldUseOrSimulateAppStoreProduct =
shouldUseAppStoreProduct() || simulateAppStoreProduct;
const onStartPurchase = async () => {
if (!profile) return;
setDisplayPasswordPrompt(false);
// Purchase with the App Store.
if (shouldUseOrSimulateAppStoreProduct) {
try {
setIsPurchasing(true);
await purchaseAppStoreProduct(bundleListingData.appStoreProductId);
} finally {
setIsPurchasing(false);
}
return;
}
const price = bundleListingData.prices.find(
price => price.usageType === usageType
);
if (!price) {
console.error('Unable to find the price for the usage type', usageType);
await showAlert({
title: t`An error happened`,
message: t`Unable to find the price for this bundle. Please try again later.`,
});
return;
}
// Purchase with web.
try {
setIsPurchasing(true);
const checkoutUrl = getPurchaseCheckoutUrl({
productId: bundleListingData.id,
priceName: price.name,
userId: profile.id,
userEmail: profile.email,
...(password ? { password } : undefined),
});
Window.openExternalURL(checkoutUrl);
} catch (error) {
const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode(
error
);
if (
extractedStatusAndCode &&
extractedStatusAndCode.status === 403 &&
extractedStatusAndCode.code === 'auth/wrong-password'
) {
await showAlert({
title: t`Operation not allowed`,
message: t`The password you entered is incorrect. Please try again.`,
});
} else {
console.error('Unable to get the checkout URL', error);
await showAlert({
title: t`An error happened`,
message: t`Unable to get the checkout URL. Please try again later.`,
});
}
setIsPurchasing(false);
} finally {
setPassword('');
}
};
const onWillPurchase = () => {
// Password is required in dev environment only so that one cannot freely purchase bundles.
if (Window.isDev()) setDisplayPasswordPrompt(true);
else onStartPurchase();
};
React.useEffect(
() => {
onWillPurchase();
},
// Launch the start process directly when the dialog is opened, to avoid an extra click.
// eslint-disable-next-line
[]
);
// This effect will be triggered when the bundle purchases change,
// to check if the user has just bought the product.
React.useEffect(
() => {
const checkIfPurchaseIsDone = async () => {
if (
isPurchasing &&
bundlePurchases &&
bundlePurchases.find(
userPurchase => userPurchase.productId === bundleListingData.id
)
) {
// We found the purchase, the user has bought the bundle.
// We do not close the dialog yet, as we need to trigger a refresh of the products received.
await onPurchaseSuccessful();
}
};
checkIfPurchaseIsDone();
},
[
isPurchasing,
bundlePurchases,
bundleListingData,
onPurchaseSuccessful,
onRefreshBundlePurchases,
]
);
useInterval(
() => {
onRefreshBundlePurchases();
},
isPurchasing ? 3900 : null
);
// Listen to the received bundle, to know when a user has just logged in and the received bundles have been loaded.
// In this case, start a timeout to remove the loader and give some time for the store to refresh.
React.useEffect(
() => {
let timeoutId;
(async () => {
if (receivedBundles) {
timeoutId = setTimeout(
() => setIsCheckingPurchasesAfterLogin(false),
3000
);
}
})();
return () => {
clearTimeout(timeoutId);
};
},
[receivedBundles]
);
// If the user has received this particular bundle, either:
// - they just logged in, and already have it, so we close the dialog.
// - they just bought it, we display the success message.
React.useEffect(
() => {
if (receivedBundles) {
const receivedBundle = receivedBundles.find(
bundle => bundle.id === bundleListingData.id
);
if (receivedBundle) {
if (isPurchasing) {
setIsPurchasing(false);
setPurchaseSuccessful(true);
} else if (!purchaseSuccessful) {
onClose();
}
}
}
},
[
receivedBundles,
bundleListingData,
isPurchasing,
onClose,
isCheckingPurchasesAfterLogin,
purchaseSuccessful,
]
);
const dialogContents = !profile
? {
subtitle: <Trans>Log-in to purchase this item</Trans>,
content: (
<CreateProfile
onOpenLoginDialog={onOpenLoginDialog}
onOpenCreateAccountDialog={onOpenCreateAccountDialog}
message={
<Trans>
Bundles and their content will be linked to your user account
and available for all your projects. Log-in or sign-up to
purchase this bundle. (or restore your existing purchase).
</Trans>
}
justifyContent="center"
/>
),
}
: purchaseSuccessful
? {
subtitle: <Trans>Your purchase has been processed!</Trans>,
content: (
<Line justifyContent="center" alignItems="center">
<Text>
<Trans>You can now go back to use your new bundle.</Trans>
</Text>
</Line>
),
}
: isPurchasing
? {
subtitle: shouldUseOrSimulateAppStoreProduct ? (
<Trans>Complete your purchase with the app store.</Trans>
) : (
<Trans>Complete your payment on the web browser</Trans>
),
content: shouldUseOrSimulateAppStoreProduct ? (
<>
<ColumnStackLayout justifyContent="center" alignItems="center">
<CircularProgress size={40} />
<Text>
<Trans>
The purchase will be linked to your account once done.
</Trans>
</Text>
</ColumnStackLayout>
</>
) : (
<>
<Line justifyContent="center" alignItems="center">
<CircularProgress size={20} />
<Spacer />
<Text>
<Trans>Waiting for the purchase confirmation...</Trans>
</Text>
</Line>
<Spacer />
<Line justifyContent="center">
<BackgroundText>
<Trans>
Once you're done, come back to GDevelop and the bundle will be
added to your account automatically.
</Trans>
</BackgroundText>
</Line>
</>
),
}
: isCheckingPurchasesAfterLogin
? {
subtitle: <Trans>Loading your profile...</Trans>,
content: (
<Line justifyContent="center" alignItems="center">
<CircularProgress size={20} />
</Line>
),
}
: {
subtitle: (
<Trans>
The bundle {bundleListingData.name} will be linked to your account{' '}
{profile.email}.
</Trans>
),
content: shouldUseOrSimulateAppStoreProduct ? null : (
<Line justifyContent="center" alignItems="center">
<Text>
<Trans>
A new secure window will open to complete the purchase.
</Trans>
</Text>
</Line>
),
};
const allowPurchase =
profile &&
!isPurchasing &&
!purchaseSuccessful &&
!isCheckingPurchasesAfterLogin;
const dialogActions = [
<FlatButton
key="cancel"
label={purchaseSuccessful ? <Trans>Close</Trans> : <Trans>Cancel</Trans>}
onClick={onClose}
/>,
allowPurchase ? (
<DialogPrimaryButton
key="continue"
primary
label={<Trans>Continue</Trans>}
onClick={onWillPurchase}
/>
) : null,
];
return (
<>
<Dialog
title={<Trans>{bundleListingData.name}</Trans>}
maxWidth="sm"
open
onRequestClose={onClose}
actions={dialogActions}
onApply={purchaseSuccessful ? onClose : onWillPurchase}
cannotBeDismissed // Prevent the user from continuing by clicking outside.
flexColumnBody
>
<LineStackLayout justifyContent="center" alignItems="center">
{purchaseSuccessful && <Mark />}
<Text size="sub-title">{dialogContents.subtitle}</Text>
</LineStackLayout>
{dialogContents.content}
</Dialog>
{displayPasswordPrompt && (
<PasswordPromptDialog
onApply={onStartPurchase}
onClose={() => setDisplayPasswordPrompt(false)}
passwordValue={password}
setPasswordValue={setPassword}
/>
)}
</>
);
};
export default BundlePurchaseDialog;

View File

@@ -0,0 +1,265 @@
// @flow
import * as React from 'react';
import { type FiltersState } from '../../UI/Search/FiltersChooser';
import { type Filters } from '../../Utils/GDevelopServices/Filters';
import { useSearchItem } from '../../UI/Search/UseSearchItem';
import {
listListedBundles,
type BundleListingData,
} from '../../Utils/GDevelopServices/Shop';
import { capitalize } from 'lodash';
import { AssetStoreNavigatorContext } from '../AssetStoreNavigator';
import { getBundleListingDataFromUserFriendlySlug } from '../AssetStoreUtils';
import useAlertDialog from '../../UI/Alert/useAlertDialog';
import { t } from '@lingui/macro';
import { sendBundleInformationOpened } from '../../Utils/Analytics/EventSender';
import { BUNDLES_FETCH_TIMEOUT } from '../../Utils/GlobalFetchTimeouts';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
const getBundleListingDataSearchTerms = (bundle: BundleListingData) =>
bundle.name + '\n' + bundle.description + '\n' + bundle.categories.join('\n');
type BundleStoreState = {|
bundleFilters: ?Filters,
fetchBundles: () => void,
bundleListingDatas: ?Array<BundleListingData>,
error: ?Error,
shop: {
bundleListingDatasSearchResults: ?Array<BundleListingData>,
filtersState: FiltersState,
setInitialBundleUserFriendlySlug: string => void,
},
|};
export const initialBundleStoreState: BundleStoreState = {
bundleFilters: null,
fetchBundles: () => {},
bundleListingDatas: null,
error: null,
shop: {
bundleListingDatasSearchResults: null,
filtersState: {
chosenFilters: new Set(),
addFilter: () => {},
removeFilter: () => {},
chosenCategory: null,
setChosenCategory: () => {},
},
setInitialBundleUserFriendlySlug: (
initialBundleUserFriendlySlug: string
) => {},
},
};
export const BundleStoreContext = React.createContext<BundleStoreState>(
initialBundleStoreState
);
type BundleStoreStateProviderProps = {|
children: React.Node,
|};
export const BundleStoreStateProvider = ({
children,
}: BundleStoreStateProviderProps) => {
const shopNavigationState = React.useContext(AssetStoreNavigatorContext);
const {
searchText: shopSearchText,
setSearchText: setShopSearchText,
} = shopNavigationState;
const { limits } = React.useContext(AuthenticatedUserContext);
const [bundleFilters, setBundleFilters] = React.useState<?Filters>(null);
const [error, setError] = React.useState<?Error>(null);
const [
bundleListingDatas,
setBundleListingDatas,
] = React.useState<?Array<BundleListingData>>(null);
const [
initialBundleUserFriendlySlug,
setInitialBundleUserFriendlySlug,
] = React.useState<?string>(null);
const initialBundleOpened = React.useRef<boolean>(false);
const isLoading = React.useRef<boolean>(false);
const { showAlert } = useAlertDialog();
const hidePremiumProducts =
!!limits &&
!!limits.capabilities.classrooms &&
limits.capabilities.classrooms.hidePremiumProducts;
const fetchBundles = React.useCallback(
() => {
// If the bundles are already loaded, don't load them again.
if (isLoading.current || bundleListingDatas) return;
(async () => {
setError(null);
isLoading.current = true;
try {
const fetchedBundleListingDatas = await listListedBundles();
console.info(
`Loaded ${
fetchedBundleListingDatas ? fetchedBundleListingDatas.length : 0
} bundles from the store.`
);
setBundleListingDatas(fetchedBundleListingDatas);
const defaultTags = fetchedBundleListingDatas.reduce(
(allCategories, bundleListingData) => {
return allCategories.concat(
bundleListingData.categories.map(category =>
capitalize(category)
)
);
},
[]
);
const uniqueDefaultTags = Array.from(new Set(defaultTags));
const bundleFilters: Filters = {
allTags: [],
defaultTags: uniqueDefaultTags,
tagsTree: [],
};
setBundleFilters(bundleFilters);
} catch (error) {
console.error(`Unable to load the bundles from the store:`, error);
setError(error);
}
isLoading.current = false;
})();
},
[bundleListingDatas]
);
// When the bundles are loaded,
// open the bundle with the slug that was asked to be initially loaded.
React.useEffect(
() => {
if (!initialBundleUserFriendlySlug || initialBundleOpened.current) {
// If there is no initial bundle or
// if the bundle was already opened, don't re-open it again even
// if the effect run again.
return;
}
if (bundleListingDatas && initialBundleUserFriendlySlug) {
initialBundleOpened.current = true;
// Open the information page of a the bundle.
const bundleListingData = getBundleListingDataFromUserFriendlySlug({
bundleListingDatas,
userFriendlySlug: initialBundleUserFriendlySlug,
});
if (bundleListingData) {
sendBundleInformationOpened({
bundleName: bundleListingData.name,
bundleId: bundleListingData.id,
source: 'web-link',
});
shopNavigationState.openBundleInformationPage({
bundleListingData,
storeSearchText: true,
clearSearchText: false,
});
initialBundleOpened.current = false; // Allow to open the bundle again if the effect run again.
setInitialBundleUserFriendlySlug(null);
return;
}
showAlert({
title: t`Bundle not found`,
message: t`The link to the bundle you've followed seems outdated. Why not take a look at the other bundles in the store?`,
});
}
},
[
bundleListingDatas,
shopNavigationState,
showAlert,
initialBundleUserFriendlySlug,
]
);
React.useEffect(
() => {
if (isLoading.current) return;
const timeoutId = setTimeout(() => {
console.info('Pre-fetching bundles from the store...');
fetchBundles();
}, BUNDLES_FETCH_TIMEOUT);
return () => clearTimeout(timeoutId);
},
[fetchBundles]
);
const bundleListingDatasById = React.useMemo(
() => {
if (!bundleListingDatas) {
return null;
}
const bundleListingDatasById = {};
if (hidePremiumProducts) return bundleListingDatasById;
bundleListingDatas.forEach(bundleListingData => {
const id = bundleListingData.id;
if (bundleListingDatasById[id]) {
console.warn(`Multiple bundles with the same id: ${id}`);
}
bundleListingDatasById[id] = bundleListingData;
});
return bundleListingDatasById;
},
[bundleListingDatas, hidePremiumProducts]
);
const currentPage = shopNavigationState.getCurrentPage();
const bundleListingDatasSearchResultsForShop: ?Array<BundleListingData> = useSearchItem(
bundleListingDatasById,
getBundleListingDataSearchTerms,
shopSearchText,
currentPage.filtersState.chosenCategory,
currentPage.filtersState.chosenFilters
);
const BundleStoreState = React.useMemo(
() => ({
bundleListingDatas: hidePremiumProducts ? [] : bundleListingDatas,
error,
bundleFilters,
fetchBundles,
shop: {
bundleListingDatasSearchResults: hidePremiumProducts
? []
: bundleListingDatasSearchResultsForShop,
searchText: shopSearchText,
setSearchText: setShopSearchText,
filtersState: currentPage.filtersState,
setInitialBundleUserFriendlySlug,
},
}),
[
hidePremiumProducts,
bundleListingDatas,
error,
bundleFilters,
fetchBundles,
bundleListingDatasSearchResultsForShop,
shopSearchText,
setShopSearchText,
currentPage.filtersState,
]
);
return (
<BundleStoreContext.Provider value={BundleStoreState}>
{children}
</BundleStoreContext.Provider>
);
};

View File

@@ -0,0 +1,142 @@
// @flow
import { type I18n as I18nType } from '@lingui/core';
import {
type BundleListingData,
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type CourseListingData,
type CreditsPackageListingData,
type IncludedRedemptionCode,
} from '../../Utils/GDevelopServices/Shop';
export const renderEstimatedTotalPriceFormatted = ({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
}: {
i18n: I18nType,
bundleListingData: ?BundleListingData,
productListingDatasIncludedInBundle: ?Array<
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CourseListingData
| CreditsPackageListingData
>,
redemptionCodesIncludedInBundle: ?Array<IncludedRedemptionCode>,
}): ?string => {
let totalPrice = 0;
if (
!bundleListingData ||
!productListingDatasIncludedInBundle ||
!redemptionCodesIncludedInBundle
)
return null;
const productPrices = bundleListingData.prices;
const bundlePrice = productPrices.find(
price => price.usageType === 'default'
);
const currencyCode = bundlePrice ? bundlePrice.currency : 'USD';
const currencySymbol = currencyCode === 'USD' ? '$' : '€';
for (const product of bundleListingData.includedListableProducts || []) {
if (product.productType === 'ASSET_PACK') {
const listedAssetPack =
productListingDatasIncludedInBundle.find(
assetPack => assetPack.id === product.productId
) || null;
if (listedAssetPack) {
const price = listedAssetPack.prices.find(
price => price.usageType === product.usageType
);
totalPrice += price ? price.value : 0;
}
} else if (product.productType === 'GAME_TEMPLATE') {
const listedGameTemplate =
productListingDatasIncludedInBundle.find(
gameTemplate => gameTemplate.id === product.productId
) || null;
if (listedGameTemplate) {
const price = listedGameTemplate.prices.find(
price => price.usageType === product.usageType
);
totalPrice += price ? price.value : 0;
}
} else if (product.productType === 'COURSE') {
const listedCourse = productListingDatasIncludedInBundle.find(
course => course.id === product.productId
);
if (listedCourse) {
const price = listedCourse.prices.find(
price => price.usageType === product.usageType
);
totalPrice += price ? price.value : 0;
}
} else if (product.productType === 'BUNDLE') {
const listedBundle = productListingDatasIncludedInBundle.find(
bundle => bundle.id === product.productId
);
if (listedBundle) {
const price = listedBundle.prices.find(
price => price.usageType === product.usageType
);
totalPrice += price ? price.value : 0;
}
} else if (product.productType === 'CREDITS_PACKAGE') {
const listedCreditsPackage =
productListingDatasIncludedInBundle.find(
creditsPackage => creditsPackage.id === product.productId
) || null;
if (listedCreditsPackage) {
const price = listedCreditsPackage.prices.find(
price => price.usageType === product.usageType
);
totalPrice += price ? price.value : 0;
}
}
}
if (redemptionCodesIncludedInBundle.length > 0) {
for (const redemptionCode of redemptionCodesIncludedInBundle) {
const planId = redemptionCode.givenSubscriptionPlanId;
if (planId) {
let estimatedAmountInCents = null;
if (redemptionCode.estimatedPrices) {
const estimatedPrice = redemptionCode.estimatedPrices.find(
price => price.currency === currencyCode
);
if (estimatedPrice) {
estimatedAmountInCents = estimatedPrice.value;
}
}
// If no estimated price is provided, guess a mostly correct value
// for backward compatibility.
if (estimatedAmountInCents === null) {
const monthlyEstimatedAmountInCents =
planId === 'gdevelop_silver'
? 599
: planId === 'gdevelop_gold'
? 1099
: planId === 'gdevelop_startup'
? 3499
: 0;
estimatedAmountInCents =
monthlyEstimatedAmountInCents *
Math.max(1, Math.round(redemptionCode.durationInDays / 30));
}
totalPrice += estimatedAmountInCents || 0;
}
}
}
return `${currencySymbol} ${i18n
.number(totalPrice / 100, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
.replace(/\D00$/, '')}`;
};

View File

@@ -640,7 +640,7 @@ function NewObjectDialog({
}
>
{currentTab === 'asset-store' && (
<AssetStore ref={assetStore} hideGameTemplates />
<AssetStore ref={assetStore} onlyShowAssets />
)}
{currentTab === 'new-object' &&
(selectedCustomObjectEnumeratedMetadata &&

View File

@@ -6,6 +6,7 @@ import {
redeemPrivateAssetPack,
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
getCalloutToGetSubscriptionOrClaimAssetPack,
} from '../../Utils/GDevelopServices/Shop';
import type { MessageDescriptor } from '../../Utils/i18n/MessageDescriptor.flow';
@@ -65,6 +66,7 @@ import RaisedButton from '../../UI/RaisedButton';
import PrivateAssetPackPurchaseDialog from './PrivateAssetPackPurchaseDialog';
import PublicProfileContext from '../../Profile/PublicProfileContext';
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
import { BundleStoreContext } from '../Bundles/BundleStoreContext';
const cellSpacing = 10;
@@ -164,11 +166,9 @@ type Props = {|
|}
) => void,
onGameTemplateOpen: (
privateGameTemplateListingData: PrivateGameTemplateListingData,
options?: {|
forceProductPage?: boolean,
|}
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onBundleOpen: (bundleListingData: BundleListingData) => void,
simulateAppStoreProduct?: boolean,
|};
@@ -177,17 +177,21 @@ const PrivateAssetPackInformationPage = ({
privateAssetPackListingDatasFromSameCreator,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
simulateAppStoreProduct,
}: Props) => {
const { isMobile } = useResponsiveWindowSize();
const { id, name, sellerId } = privateAssetPackListingData;
const { privateAssetPackListingDatas } = React.useContext(AssetStoreContext);
const { bundleListingDatas } = React.useContext(BundleStoreContext);
const { showAlert } = useAlertDialog();
const {
receivedAssetPacks,
receivedBundles,
profile,
limits,
assetPackPurchases,
bundlePurchases,
getAuthorizationHeader,
onOpenLoginDialog,
subscription,
@@ -234,15 +238,27 @@ const PrivateAssetPackInformationPage = ({
productId: privateAssetPackListingData
? privateAssetPackListingData.id
: null,
receivedProducts: receivedAssetPacks,
productPurchases: assetPackPurchases,
allProductListingDatas: privateAssetPackListingDatas,
receivedProducts: [
...(receivedAssetPacks || []),
...(receivedBundles || []),
],
productPurchases: [
...(assetPackPurchases || []),
...(bundlePurchases || []),
],
allProductListingDatas: [
...(privateAssetPackListingDatas || []),
...(bundleListingDatas || []),
],
}),
[
assetPackPurchases,
bundlePurchases,
privateAssetPackListingData,
privateAssetPackListingDatas,
bundleListingDatas,
receivedAssetPacks,
receivedBundles,
]
);
const isAlreadyReceived = !!userAssetPackPurchaseUsageType;
@@ -251,17 +267,21 @@ const PrivateAssetPackInformationPage = ({
() =>
getProductsIncludedInBundleTiles({
product: assetPack,
productListingDatas: privateAssetPackListingDatas,
productListingDatas: [...(privateAssetPackListingDatas || [])],
productListingData: privateAssetPackListingData,
receivedProducts: receivedAssetPacks,
onProductOpen: product =>
receivedProducts: [...(receivedAssetPacks || [])],
onPrivateAssetPackOpen: product =>
onAssetPackOpen(product, { forceProductPage: true }),
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
}),
[
assetPack,
privateAssetPackListingDatas,
receivedAssetPacks,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
privateAssetPackListingData,
]
);
@@ -270,16 +290,30 @@ const PrivateAssetPackInformationPage = ({
() =>
getBundlesContainingProductTiles({
product: assetPack,
productListingDatas: privateAssetPackListingDatas,
receivedProducts: receivedAssetPacks,
onProductOpen: product =>
productListingData: privateAssetPackListingData,
productListingDatas: [
...(privateAssetPackListingDatas || []),
...(bundleListingDatas || []),
],
receivedProducts: [
...(receivedAssetPacks || []),
...(receivedBundles || []),
],
onPrivateAssetPackOpen: product =>
onAssetPackOpen(product, { forceProductPage: true }),
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
}),
[
assetPack,
privateAssetPackListingData,
privateAssetPackListingDatas,
bundleListingDatas,
receivedAssetPacks,
receivedBundles,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
]
);

View File

@@ -5,6 +5,7 @@ import {
buyProductWithCredits,
type PrivateGameTemplateListingData,
type PrivateAssetPackListingData,
type BundleListingData,
} from '../../Utils/GDevelopServices/Shop';
import {
getPrivateGameTemplate,
@@ -60,6 +61,7 @@ import PrivateGameTemplatePurchaseDialog from './PrivateGameTemplatePurchaseDial
import PasswordPromptDialog from '../PasswordPromptDialog';
import PublicProfileContext from '../../Profile/PublicProfileContext';
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
import { BundleStoreContext } from '../Bundles/BundleStoreContext';
const cellSpacing = 10;
@@ -123,8 +125,16 @@ const styles = {
type Props = {|
privateGameTemplateListingData: PrivateGameTemplateListingData,
privateGameTemplateListingDatasFromSameCreator?: ?Array<PrivateGameTemplateListingData>,
onGameTemplateOpen: PrivateGameTemplateListingData => void,
onAssetPackOpen?: PrivateAssetPackListingData => void,
onAssetPackOpen?: (
privateAssetPackListingData: PrivateAssetPackListingData,
options?: {|
forceProductPage?: boolean,
|}
) => void,
onGameTemplateOpen: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onBundleOpen?: (bundleListingData: BundleListingData) => void,
onCreateWithGameTemplate?: PrivateGameTemplateListingData => void,
simulateAppStoreProduct?: boolean,
|};
@@ -134,6 +144,7 @@ const PrivateGameTemplateInformationPage = ({
privateGameTemplateListingDatasFromSameCreator,
onGameTemplateOpen,
onAssetPackOpen,
onBundleOpen,
onCreateWithGameTemplate,
simulateAppStoreProduct,
}: Props) => {
@@ -141,11 +152,14 @@ const PrivateGameTemplateInformationPage = ({
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
);
const { bundleListingDatas } = React.useContext(BundleStoreContext);
const {
receivedGameTemplates,
receivedBundles,
profile,
limits,
gameTemplatePurchases,
bundlePurchases,
getAuthorizationHeader,
onOpenLoginDialog,
} = React.useContext(AuthenticatedUserContext);
@@ -191,15 +205,27 @@ const PrivateGameTemplateInformationPage = ({
productId: privateGameTemplateListingData
? privateGameTemplateListingData.id
: null,
receivedProducts: receivedGameTemplates,
productPurchases: gameTemplatePurchases,
allProductListingDatas: privateGameTemplateListingDatas,
receivedProducts: [
...(receivedGameTemplates || []),
...(receivedBundles || []),
],
productPurchases: [
...(gameTemplatePurchases || []),
...(bundlePurchases || []),
],
allProductListingDatas: [
...(privateGameTemplateListingDatas || []),
...(bundleListingDatas || []),
],
}),
[
gameTemplatePurchases,
bundlePurchases,
privateGameTemplateListingData,
privateGameTemplateListingDatas,
bundleListingDatas,
receivedGameTemplates,
receivedBundles,
]
);
const isAlreadyReceived = !!userGameTemplatePurchaseUsageType;
@@ -208,33 +234,55 @@ const PrivateGameTemplateInformationPage = ({
() =>
getProductsIncludedInBundleTiles({
product: gameTemplate,
productListingDatas: privateGameTemplateListingDatas,
productListingDatas: [...(privateGameTemplateListingDatas || [])],
productListingData: privateGameTemplateListingData,
receivedProducts: receivedGameTemplates,
onProductOpen: onGameTemplateOpen,
receivedProducts: [...(receivedGameTemplates || [])],
onPrivateAssetPackOpen: onAssetPackOpen
? product => onAssetPackOpen(product, { forceProductPage: true })
: undefined,
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
}),
[
gameTemplate,
privateGameTemplateListingDatas,
receivedGameTemplates,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
privateGameTemplateListingData,
]
);
const bundlesContainingPackTiles = React.useMemo(
const bundlesContainingTemplateTiles = React.useMemo(
() =>
getBundlesContainingProductTiles({
product: gameTemplate,
productListingDatas: privateGameTemplateListingDatas,
receivedProducts: receivedGameTemplates,
onProductOpen: onGameTemplateOpen,
productListingData: privateGameTemplateListingData,
productListingDatas: [
...(privateGameTemplateListingDatas || []),
...(bundleListingDatas || []),
],
receivedProducts: [
...(receivedGameTemplates || []),
...(receivedBundles || []),
],
onPrivateAssetPackOpen: onAssetPackOpen
? product => onAssetPackOpen(product, { forceProductPage: true })
: undefined,
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
}),
[
gameTemplate,
privateGameTemplateListingData,
privateGameTemplateListingDatas,
bundleListingDatas,
receivedGameTemplates,
receivedBundles,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
]
);
@@ -589,12 +637,12 @@ const PrivateGameTemplateInformationPage = ({
/>
</Text>
</Column>
{bundlesContainingPackTiles &&
bundlesContainingPackTiles.length ? (
{bundlesContainingTemplateTiles &&
bundlesContainingTemplateTiles.length ? (
<>
<ColumnStackLayout noMargin>
<LargeSpacer />
{bundlesContainingPackTiles}
{bundlesContainingTemplateTiles}
<LargeSpacer />
</ColumnStackLayout>
</>

View File

@@ -4,17 +4,23 @@ import { type I18n as I18nType } from '@lingui/core';
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
type CourseListingData,
type CreditsPackageListingData,
type Purchase,
} from '../Utils/GDevelopServices/Shop';
import {
type PrivateAssetPack,
type PrivateGameTemplate,
type Bundle,
type Course,
} from '../Utils/GDevelopServices/Asset';
import {
PrivateAssetPackTile,
PrivateGameTemplateTile,
BundleTile,
PromoBundleCard,
CourseTile,
} from './ShopTiles';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import { shouldUseAppStoreProduct } from '../Utils/AppStorePurchases';
@@ -27,10 +33,14 @@ import { renderProductPrice } from './ProductPriceTag';
import { Trans } from '@lingui/macro';
import FlatButton from '../UI/FlatButton';
import { Column } from '../UI/Grid';
import { type MediaItem } from '../UI/ResponsiveMediaGallery';
export const getOtherProductsFromSameAuthorTiles = <
T: PrivateAssetPackListingData | PrivateGameTemplateListingData,
U: PrivateAssetPack | PrivateGameTemplate
T:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData,
U: PrivateAssetPack | PrivateGameTemplate | Bundle
>({
otherProductListingDatasFromSameCreator,
currentProductListingData,
@@ -83,6 +93,16 @@ export const getOtherProductsFromSameAuthorTiles = <
/>
);
}
if (productListingDataFromSameCreator.productType === 'BUNDLE') {
return (
<BundleTile
bundleListingData={productListingDataFromSameCreator}
key={productListingDataFromSameCreator.id}
onSelect={() => onProductOpen(productListingDataFromSameCreator)}
owned={isProductOwned}
/>
);
}
console.error(
'Unexpected product type:',
@@ -93,27 +113,81 @@ export const getOtherProductsFromSameAuthorTiles = <
.filter(Boolean);
};
export const getBundlesContainingProductTiles = <
T: PrivateAssetPackListingData | PrivateGameTemplateListingData,
U: PrivateAssetPack | PrivateGameTemplate
export const getBundlesContainingProduct = <
T:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CourseListingData,
U: PrivateAssetPack | PrivateGameTemplate | Bundle | Course
>({
product,
productListingDatas,
}: {|
product: U,
productListingDatas: Array<T>,
|}): T[] => {
// A bundle can either be:
// - an ASSET_PACK or GAME_TEMPLATE that includes other product ids
// - a BUNDLE that includes other products
const bundlesContainingProduct = productListingDatas
.filter(
productListingData =>
(productListingData.includedListableProductIds &&
productListingData.includedListableProductIds.includes(product.id)) ||
(productListingData.productType === 'BUNDLE' &&
productListingData.includedListableProducts &&
productListingData.includedListableProducts.some(
includedProduct => includedProduct.productId === product.id
))
)
// Show types 'BUNDLE' first.
.sort((a, b) => {
if (a.productType === 'BUNDLE' && b.productType !== 'BUNDLE') {
return -1;
}
if (a.productType !== 'BUNDLE' && b.productType === 'BUNDLE') {
return 1;
}
return 0;
});
return bundlesContainingProduct;
};
export const getBundlesContainingProductTiles = <
T:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData,
U: PrivateAssetPack | PrivateGameTemplate | Bundle
>({
product,
productListingData,
productListingDatas,
receivedProducts,
onProductOpen,
onPrivateAssetPackOpen,
onPrivateGameTemplateOpen,
onBundleOpen,
}: {|
product: ?U,
productListingData: T,
productListingDatas: ?Array<T>,
receivedProducts: ?Array<U>,
onProductOpen: (product: T) => void,
onPrivateAssetPackOpen?: (
assetPackListingData: PrivateAssetPackListingData
) => void,
onPrivateGameTemplateOpen?: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onBundleOpen?: (bundleListingData: BundleListingData) => void,
|}): ?Array<React.Node> => {
if (!product || !productListingDatas) return null;
const bundlesContainingProduct = productListingDatas.filter(
productListingData =>
productListingData.includedListableProductIds &&
productListingData.includedListableProductIds.includes(product.id)
);
const bundlesContainingProduct = getBundlesContainingProduct({
product,
productListingDatas,
});
if (!bundlesContainingProduct.length) return null;
@@ -132,65 +206,157 @@ export const getBundlesContainingProductTiles = <
)
);
const allTiles = ownedBundlesContainingProduct
.map(bundleContainingProduct => {
return (
<PromoBundleCard
productListingData={bundleContainingProduct}
onSelect={() => onProductOpen(bundleContainingProduct)}
owned
key={bundleContainingProduct.id}
/>
);
})
.concat(
notOwnedBundlesContainingProduct.map(bundleContainingProduct => {
const allProductsWithOwnedStatus = [
...ownedBundlesContainingProduct.map(bundleContainingProduct => ({
product: bundleContainingProduct,
owned: true,
})),
...notOwnedBundlesContainingProduct.map(bundleContainingProduct => ({
product: bundleContainingProduct,
owned: false,
})),
];
return allProductsWithOwnedStatus.map(
({ product: bundleContainingProduct, owned }) => {
if (bundleContainingProduct.productType === 'ASSET_PACK') {
if (!onPrivateAssetPackOpen) {
console.error(
'Trying to render a promo ASSET_PACK tile without onPrivateAssetPackOpen handler.'
);
return null;
}
return (
<PromoBundleCard
productListingData={bundleContainingProduct}
onSelect={() => onProductOpen(bundleContainingProduct)}
owned={false}
bundleProductListingData={bundleContainingProduct}
includedProductListingData={productListingData}
onSelect={() => onPrivateAssetPackOpen(bundleContainingProduct)}
owned={owned}
key={bundleContainingProduct.id}
/>
);
})
);
}
return allTiles;
if (bundleContainingProduct.productType === 'GAME_TEMPLATE') {
if (!onPrivateGameTemplateOpen) {
console.error(
'Trying to render a promo GAME_TEMPLATE tile without onPrivateGameTemplateOpen handler.'
);
return null;
}
return (
<PromoBundleCard
bundleProductListingData={bundleContainingProduct}
includedProductListingData={productListingData}
onSelect={() => onPrivateGameTemplateOpen(bundleContainingProduct)}
owned={owned}
key={bundleContainingProduct.id}
/>
);
}
if (bundleContainingProduct.productType === 'BUNDLE') {
if (!onBundleOpen) {
console.error(
'Trying to render a promo BUNDLE tile without onBundleOpen handler.'
);
return null;
}
return (
<PromoBundleCard
bundleProductListingData={bundleContainingProduct}
includedProductListingData={productListingData}
onSelect={() => onBundleOpen(bundleContainingProduct)}
owned={owned}
key={bundleContainingProduct.id}
/>
);
}
console.error(
'Unexpected product type for Promo Tile:',
bundleContainingProduct.productType
);
return null;
}
);
};
export const getProductsIncludedInBundleTiles = <
T: PrivateAssetPackListingData | PrivateGameTemplateListingData,
U: PrivateAssetPack | PrivateGameTemplate
export const getProductsIncludedInBundle = <
T:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CourseListingData
| CreditsPackageListingData
>({
productListingData,
productListingDatas,
}: {|
productListingDatas: Array<T>,
productListingData: T,
|}): ?(T[]) => {
const includedProductIds =
productListingData.includedListableProductIds ||
(productListingData.productType === 'BUNDLE' &&
productListingData.includedListableProducts &&
productListingData.includedListableProducts.map(
includedProduct => includedProduct.productId
));
if (!includedProductIds) return null;
return productListingDatas.filter(productListingData =>
includedProductIds.includes(productListingData.id)
);
};
export const getProductsIncludedInBundleTiles = ({
product,
productListingDatas,
productListingData,
receivedProducts,
onProductOpen,
onPrivateAssetPackOpen,
onPrivateGameTemplateOpen,
onBundleOpen,
onCourseOpen,
}: {|
product: ?U,
productListingDatas: ?Array<T>,
productListingData: T,
receivedProducts: ?Array<U>,
onProductOpen: (product: T) => void,
product: ?PrivateAssetPack | PrivateGameTemplate | Bundle | Course,
productListingDatas: ?Array<
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CourseListingData
>,
productListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CourseListingData,
receivedProducts: ?Array<
PrivateAssetPack | PrivateGameTemplate | Bundle | Course
>,
onPrivateAssetPackOpen?: (
assetPackListingData: PrivateAssetPackListingData
) => void,
onPrivateGameTemplateOpen?: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onBundleOpen?: (bundleListingData: BundleListingData) => void,
onCourseOpen?: (courseListingData: CourseListingData) => void,
|}): ?Array<React.Node> => {
if (!product || !productListingDatas) return null;
const includedProductIds = productListingData.includedListableProductIds;
if (!includedProductIds) return null;
const productsIncludedInBundle = getProductsIncludedInBundle({
productListingData,
productListingDatas,
});
return includedProductIds
.map(includedProductId => {
const includedProductListingData = productListingDatas.find(
privateProductListingData =>
privateProductListingData.id === includedProductId
);
if (!includedProductListingData) {
console.warn(`Included product ${includedProductId} not found`);
return null;
}
if (!productsIncludedInBundle || !productsIncludedInBundle.length) {
return null;
}
return productsIncludedInBundle
.map(includedProductListingData => {
const isProductOwned =
!!receivedProducts &&
!!receivedProducts.find(
@@ -198,22 +364,70 @@ export const getProductsIncludedInBundleTiles = <
);
if (includedProductListingData.productType === 'GAME_TEMPLATE') {
if (!onPrivateGameTemplateOpen) {
console.error(
'Trying to render a GAME_TEMPLATE tile without onPrivateGameTemplateOpen handler.'
);
return null;
}
return (
<PrivateGameTemplateTile
privateGameTemplateListingData={includedProductListingData}
key={includedProductListingData.id}
onSelect={() => onProductOpen(includedProductListingData)}
onSelect={() =>
onPrivateGameTemplateOpen(includedProductListingData)
}
owned={isProductOwned}
/>
);
}
if (includedProductListingData.productType === 'ASSET_PACK') {
if (!onPrivateAssetPackOpen) {
console.error(
'Trying to render an ASSET_PACK tile without onPrivateAssetPackOpen handler.'
);
return null;
}
return (
<PrivateAssetPackTile
assetPackListingData={includedProductListingData}
key={includedProductListingData.id}
onSelect={() => onProductOpen(includedProductListingData)}
onSelect={() => onPrivateAssetPackOpen(includedProductListingData)}
owned={isProductOwned}
/>
);
}
if (includedProductListingData.productType === 'BUNDLE') {
if (!onBundleOpen) {
console.error(
'Trying to render a BUNDLE tile without onBundleOpen handler.'
);
return null;
}
return (
<BundleTile
bundleListingData={includedProductListingData}
key={includedProductListingData.id}
onSelect={() => onBundleOpen(includedProductListingData)}
owned={isProductOwned}
/>
);
}
if (includedProductListingData.productType === 'COURSE') {
if (!onCourseOpen) {
console.error(
'Trying to render a COURSE tile without onCourseOpen handler.'
);
return null;
}
return (
<CourseTile
courseListingData={includedProductListingData}
key={includedProductListingData.id}
onSelect={() => onCourseOpen(includedProductListingData)}
owned={isProductOwned}
/>
);
@@ -234,8 +448,11 @@ export const getProductsIncludedInBundleTiles = <
// In case the user has both, we consider the product purchase as the
// most important one.
export const getUserProductPurchaseUsageType = <
T: PrivateAssetPackListingData | PrivateGameTemplateListingData,
U: PrivateAssetPack | PrivateGameTemplate
T:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData,
U: PrivateAssetPack | PrivateGameTemplate | Bundle
>({
productId,
receivedProducts,
@@ -262,22 +479,36 @@ export const getUserProductPurchaseUsageType = <
);
if (!productPurchase) {
// It is possible the user has the product as part of a bundle.
const productBundleListingData = allProductListingDatas.find(
productListingData =>
productListingData.includedListableProductIds &&
productListingData.includedListableProductIds.includes(productId)
const bundlesIncludingProduct = getBundlesContainingProduct({
product: currentReceivedProduct,
productListingDatas: allProductListingDatas,
});
if (!bundlesIncludingProduct.length) return null;
// We look at all the purchases of the bundles that include the product.
const receivedProductBundlePurchases = productPurchases.filter(
productPurchase =>
bundlesIncludingProduct.some(
bundleListingData =>
bundleListingData.id === productPurchase.productId
)
);
if (productBundleListingData) {
const receivedProductBundlePurchase = productPurchases.find(
productPurchase =>
productPurchase.productId === productBundleListingData.id
);
if (receivedProductBundlePurchase) {
return receivedProductBundlePurchase.usageType;
}
if (!receivedProductBundlePurchases.length) {
return null;
}
return null;
// We don't really know which usage type to return, so we look at the first purchase.
if (bundlesIncludingProduct[0].productType === 'BUNDLE') {
// In a bundle, we look for the usage type of the included product.
const includedProduct = (
bundlesIncludingProduct[0].includedListableProducts || []
).find(includedProduct => includedProduct.productId === productId);
return includedProduct ? includedProduct.usageType : null;
}
// Otherwise, we return the usage type of the purchase. (when included in an ASSET_PACK or GAME_TEMPLATE)
return receivedProductBundlePurchases[0].usageType;
}
return productPurchase.usageType;
@@ -288,6 +519,7 @@ export const PurchaseProductButtons = <
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CourseListingData
| BundleListingData
>({
productListingData,
selectedUsageType,
@@ -305,17 +537,20 @@ export const PurchaseProductButtons = <
i18n: I18nType,
isAlreadyReceived: boolean,
onClickBuy: () => void | Promise<void>,
onClickBuyWithCredits: () => void | Promise<void>,
onClickBuyWithCredits?: () => void | Promise<void>,
|}) => {
const { authenticated } = React.useContext(AuthenticatedUserContext);
const shouldUseOrSimulateAppStoreProduct =
simulateAppStoreProduct || shouldUseAppStoreProduct();
const productType = productListingData.productType.toLowerCase();
let creditPrice = productListingData.creditPrices.find(
price => price.usageType === selectedUsageType
);
if (!creditPrice) {
let creditPrice =
productListingData.productType !== 'BUNDLE'
? productListingData.creditPrices.find(
price => price.usageType === selectedUsageType
)
: null;
if (!creditPrice && productListingData.productType !== 'BUNDLE') {
// We're probably switching from one product to another, and the usage type is not available.
// Let's reset it.
onUsageTypeChange(productListingData.prices[0].usageType);
@@ -339,7 +574,7 @@ export const PurchaseProductButtons = <
plainText: true,
});
return shouldUseOrSimulateAppStoreProduct ? (
return shouldUseOrSimulateAppStoreProduct && creditPrice ? (
<LineStackLayout>
<RaisedButton
primary
@@ -358,13 +593,15 @@ export const PurchaseProductButtons = <
</LineStackLayout>
) : (
<LineStackLayout>
<FlatButton
primary
label={<Trans>Buy for {creditPrice.amount} credits</Trans>}
onClick={onClickBuyWithCredits}
id={`buy-${productType}-with-credits`}
leftIcon={<Coin fontSize="small" />}
/>
{creditPrice && (
<FlatButton
primary
label={<Trans>Buy for {creditPrice.amount} credits</Trans>}
onClick={onClickBuyWithCredits}
id={`buy-${productType}-with-credits`}
leftIcon={<Coin fontSize="small" />}
/>
)}
<RaisedButton
primary
label={<Trans>Buy for {formattedProductPriceText}</Trans>}
@@ -408,39 +645,45 @@ export const OpenProductButton = <
};
export const getProductMediaItems = <
T: PrivateAssetPackListingData | PrivateGameTemplateListingData,
U: PrivateAssetPack | PrivateGameTemplate
T:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData,
U: PrivateAssetPack | PrivateGameTemplate | Bundle
>({
productListingData,
product,
additionalThumbnails,
shouldSimulateAppStoreProduct,
}: {|
productListingData: T,
product: ?U,
additionalThumbnails?: string[],
shouldSimulateAppStoreProduct?: boolean,
|}) => {
|}): MediaItem[] => {
if (!product) return [];
const shouldUseOrSimulateAppStoreProduct =
shouldSimulateAppStoreProduct || shouldUseAppStoreProduct();
const mediaItems = [
{
kind: 'image',
url:
(shouldUseOrSimulateAppStoreProduct &&
productListingData.appStoreThumbnailUrls &&
productListingData.appStoreThumbnailUrls[0]) ||
// Deduplicate in case we have the same image in the thumbnailUrls and previewImageUrls.
const uniqueImageUrls: string[] = [
...new Set([
(shouldUseOrSimulateAppStoreProduct &&
productListingData.appStoreThumbnailUrls &&
productListingData.appStoreThumbnailUrls[0]) ||
productListingData.thumbnailUrls[0],
},
...product.previewImageUrls.map(url => ({
kind: 'image',
url,
})),
...product.previewImageUrls,
...(additionalThumbnails || []),
]),
];
const uniqueMediaItems: MediaItem[] = uniqueImageUrls.map((url: string) => ({
kind: 'image',
url,
}));
if (product.previewSoundUrls) {
mediaItems.push(
uniqueMediaItems.push(
...product.previewSoundUrls.map(url => ({
kind: 'audio',
url,
@@ -448,5 +691,5 @@ export const getProductMediaItems = <
);
}
return mediaItems;
return uniqueMediaItems;
};

View File

@@ -9,6 +9,7 @@ import {
type PrivateGameTemplateListingData,
type CreditsPackageListingData,
type CourseListingData,
type BundleListingData,
} from '../Utils/GDevelopServices/Shop';
import {
shouldUseAppStoreProduct,
@@ -20,7 +21,6 @@ import Text from '../UI/Text';
import { Column } from '../UI/Grid';
import CheckCircle from '../UI/CustomSvgIcons/CheckCircle';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import type { GDevelopTheme } from '../UI/Theme';
const styles = {
icon: {
@@ -45,7 +45,8 @@ type FormatProps = {|
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CreditsPackageListingData
| CourseListingData,
| CourseListingData
| BundleListingData,
i18n: I18nType,
usageType?: string,
plainText?: boolean,
@@ -59,10 +60,11 @@ export const renderProductPrice = ({
plainText,
showBothPrices,
}: FormatProps): React.Node => {
// Only use the app store product if it's a credits package.
// For Credits packages & Bundles, on mobile, only show the app store product price.
if (
shouldUseAppStoreProduct() &&
productListingData.productType === 'CREDITS_PACKAGE'
(productListingData.productType === 'CREDITS_PACKAGE' ||
productListingData.productType === 'BUNDLE')
) {
const appStoreProduct = getAppStoreProduct(
productListingData.appStoreProductId
@@ -70,21 +72,15 @@ export const renderProductPrice = ({
return appStoreProduct ? appStoreProduct.price : '';
}
const creditPrices =
productListingData.productType !== 'CREDITS_PACKAGE'
? productListingData.creditPrices || []
: [];
const creditPrices = productListingData.creditPrices || [];
const creditPrice = usageType
? creditPrices.find(price => price.usageType === usageType)
: creditPrices.length > 0
? creditPrices[0]
: null;
// If we're on mobile, only show credits prices for non-credits packages.
if (
shouldUseAppStoreProduct() &&
productListingData.productType !== 'CREDITS_PACKAGE'
) {
// If we're on mobile, only show credits prices for other packages.
if (shouldUseAppStoreProduct()) {
if (!creditPrice) return '';
return plainText ? (
i18n._(t`${creditPrice.amount} credits`)
@@ -163,23 +159,17 @@ type ProductPriceOrOwnedProps = {|
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CreditsPackageListingData
| CourseListingData,
| CourseListingData
| BundleListingData,
i18n: I18nType,
gdevelopTheme: GDevelopTheme,
usageType?: string,
owned?: boolean,
showBothPrices?: 'column' | 'line',
|};
export const getProductPriceOrOwnedLabel = ({
i18n,
gdevelopTheme,
productListingData,
usageType,
owned,
showBothPrices,
}: ProductPriceOrOwnedProps): React.Node => {
return owned ? (
export const OwnedLabel = () => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<LineStackLayout noMargin alignItems="center">
<CheckCircle
style={{
@@ -190,6 +180,18 @@ export const getProductPriceOrOwnedLabel = ({
<Trans>Owned</Trans>
</Text>
</LineStackLayout>
);
};
export const getProductPriceOrOwnedLabel = ({
i18n,
productListingData,
usageType,
owned,
showBothPrices,
}: ProductPriceOrOwnedProps): React.Node => {
return owned ? (
<OwnedLabel />
) : (
renderProductPrice({ i18n, productListingData, usageType, showBothPrices })
);
@@ -200,7 +202,8 @@ type ProductPriceTagProps = {|
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CreditsPackageListingData
| CourseListingData,
| CourseListingData
| BundleListingData,
usageType?: string,
/**
* To be used when the component is over an element for which
@@ -216,13 +219,11 @@ const ProductPriceTag = ({
withOverlay,
owned,
}: ProductPriceTagProps) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<I18n>
{({ i18n }) => {
const label = getProductPriceOrOwnedLabel({
i18n,
gdevelopTheme,
productListingData,
usageType,
owned,

View File

@@ -9,6 +9,8 @@ import {
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
type CourseListingData,
} from '../Utils/GDevelopServices/Shop';
import type { ExampleShortHeader } from '../Utils/GDevelopServices/Example';
import GridListTile from '@material-ui/core/GridListTile';
@@ -17,7 +19,10 @@ import { textEllipsisStyle } from '../UI/TextEllipsis';
import { Column, Line, Spacer } from '../UI/Grid';
import Text from '../UI/Text';
import { Trans } from '@lingui/macro';
import ProductPriceTag, { renderProductPrice } from './ProductPriceTag';
import ProductPriceTag, {
OwnedLabel,
renderProductPrice,
} from './ProductPriceTag';
import { AssetCard } from './AssetCard';
import FolderIcon from '../UI/CustomSvgIcons/Folder';
import FlatButton from '../UI/FlatButton';
@@ -28,6 +33,7 @@ import Skeleton from '@material-ui/lab/Skeleton';
import EmptyMessage from '../UI/EmptyMessage';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import { CardWidget } from '../MainFrame/EditorContainers/HomePage/CardWidget';
import { selectMessageByLocale } from '../Utils/i18n/MessageByLocale';
const styles = {
priceTagContainer: {
@@ -39,7 +45,7 @@ const styles = {
previewImage: {
width: '100%',
display: 'block',
objectFit: 'cover',
objectFit: 'contain',
borderRadius: 8,
border: '1px solid lightgrey',
boxSizing: 'border-box', // Take border in account for sizing to avoid cumulative layout shift.
@@ -304,13 +310,20 @@ export const PrivateAssetPackTile = ({
};
export const PromoBundleCard = ({
productListingData,
bundleProductListingData,
includedProductListingData,
onSelect,
owned,
}: {|
productListingData:
bundleProductListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData,
| PrivateGameTemplateListingData
| BundleListingData,
includedProductListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CourseListingData
| BundleListingData,
onSelect: () => void,
owned: boolean,
|}) => {
@@ -328,52 +341,44 @@ export const PromoBundleCard = ({
<ResponsiveLineStackLayout expand noMargin noResponsiveLandscape>
<div style={styles.promoImageContainer}>
<CorsAwareImage
key={productListingData.name}
key={bundleProductListingData.name}
style={{
...styles.previewImage,
...styles.promoImage,
}}
src={productListingData.thumbnailUrls[0]}
alt={`Preview image of bundle ${productListingData.name}`}
src={bundleProductListingData.thumbnailUrls[0]}
alt={`Preview image of bundle ${
bundleProductListingData.name
}`}
loading="lazy"
/>
</div>
<Column expand alignItems="flex-start" justifyContent="center">
<Text color="primary" size="section-title">
{!owned ? (
<Trans>Get {productListingData.description}!</Trans>
) : productListingData.productType === 'ASSET_PACK' ? (
<Trans>You already own this pack!</Trans>
<Trans>Get {bundleProductListingData.name}!</Trans>
) : (
<Trans>You already own this template!</Trans>
<Trans>
You already own {bundleProductListingData.name}!
</Trans>
)}
</Text>
<Text color="primary" size="body2">
{!owned ? (
productListingData.productType === 'ASSET_PACK' ? (
<Trans>
This pack is included in this bundle for{' '}
{renderProductPrice({
i18n,
productListingData,
plainText: true,
})}
!
</Trans>
) : (
<Trans>
This template is included in this bundle for{' '}
{renderProductPrice({
i18n,
productListingData,
plainText: true,
})}
!
</Trans>
)
<Trans>
{includedProductListingData.name} is included in this
bundle for{' '}
{renderProductPrice({
i18n,
productListingData: bundleProductListingData,
plainText: true,
})}
!
</Trans>
) : (
<Trans>
It is included in the bundle {productListingData.name}.
{includedProductListingData.name} is included in the
bundle {bundleProductListingData.name}.
</Trans>
)}
</Text>
@@ -511,6 +516,136 @@ export const PrivateGameTemplateTile = ({
);
};
export const CourseTile = ({
courseListingData,
onSelect,
style,
owned,
disabled,
}: {|
courseListingData: CourseListingData,
onSelect: () => void,
/** Props needed so that GridList component can adjust tile size */
style?: any,
owned: boolean,
disabled?: boolean,
|}) => {
const { isMobile } = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column noMargin expand noOverflowParent>
<CorsAwareImage
key={courseListingData.name}
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={courseListingData.thumbnailUrls[0]}
alt={`Preview image of course ${courseListingData.name}`}
loading="lazy"
/>
<div style={styles.priceTagContainer}>
<ProductPriceTag
productListingData={courseListingData}
withOverlay
owned={owned}
/>
</div>
<Column>
{isMobile && <Spacer />}
<Line justifyContent="flex-start" noMargin>
<Text style={styles.packTitle} size="body2" noMargin={isMobile}>
{courseListingData.name}
</Text>
</Line>
</Column>
</Column>
</CardWidget>
</GridListTile>
);
};
export const BundleTile = ({
bundleListingData,
onSelect,
style,
owned,
disabled,
}: {|
bundleListingData: BundleListingData,
onSelect: () => void,
/** Props needed so that GridList component can adjust tile size */
style?: any,
owned: boolean,
disabled?: boolean,
|}) => {
const { isMobile } = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<I18n>
{({ i18n }) => (
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column noMargin expand noOverflowParent>
<CorsAwareImage
key={bundleListingData.name}
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={bundleListingData.thumbnailUrls[0]}
alt={`Preview image of bundle ${bundleListingData.name}`}
loading="lazy"
/>
<div style={styles.priceTagContainer}>
{bundleListingData ? (
<ProductPriceTag
productListingData={bundleListingData}
withOverlay
owned={owned}
/>
) : (
<OwnedLabel />
)}
</div>
<Column>
{isMobile && <Spacer />}
<Line justifyContent="flex-start" noMargin>
<Text
style={styles.packTitle}
size="body2"
noMargin={isMobile}
>
{bundleListingData.nameByLocale
? selectMessageByLocale(
i18n,
bundleListingData.nameByLocale
)
: bundleListingData.name}
</Text>
</Line>
</Column>
</Column>
</CardWidget>
</GridListTile>
)}
</I18n>
);
};
export const ExampleTile = ({
exampleShortHeader,
onSelect,

View File

@@ -11,6 +11,8 @@ import {
sendAssetOpened,
sendAssetPackInformationOpened,
sendAssetPackOpened,
sendBundleInformationOpened,
sendCourseInformationOpened,
sendGameTemplateInformationOpened,
} from '../Utils/Analytics/EventSender';
import {
@@ -18,12 +20,15 @@ import {
type PublicAssetPack,
type PublicAssetPacks,
type PrivateAssetPack,
type Course,
doesAssetPackContainAudio,
isAssetPackAudioOnly,
} from '../Utils/GDevelopServices/Asset';
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
type CourseListingData,
} from '../Utils/GDevelopServices/Shop';
import { type SearchBarInterface } from '../UI/SearchBar';
import { AssetStoreFilterPanel } from './AssetStoreFilterPanel';
@@ -61,14 +66,18 @@ import PrivateGameTemplateInformationPage from './PrivateGameTemplates/PrivateGa
import { PrivateGameTemplateStoreContext } from './PrivateGameTemplates/PrivateGameTemplateStoreContext';
import { AssetSwappingAssetStoreSearchFilter } from './AssetStoreSearchFilter';
import { delay } from '../Utils/Delay';
import { BundleStoreContext } from './Bundles/BundleStoreContext';
import BundleInformationPage from './Bundles/BundleInformationPage';
type Props = {|
hideGameTemplates?: boolean, // TODO: if we add more options, use an array instead.
onlyShowAssets?: boolean, // TODO: if we add more options, use an array instead.
displayPromotions?: boolean,
onOpenPrivateGameTemplateListingData?: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onOpenProfile?: () => void,
receivedCourses?: ?Array<Course>,
onCourseOpen?: (courseId: string) => void,
assetSwappedObject?: ?gdObject,
minimalUI?: boolean,
|};
@@ -105,10 +114,12 @@ const identifyAssetPackKind = ({
export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
(
{
hideGameTemplates,
onlyShowAssets,
displayPromotions,
onOpenPrivateGameTemplateListingData,
onOpenProfile,
receivedCourses,
onCourseOpen,
assetSwappedObject,
minimalUI,
}: Props,
@@ -175,6 +186,13 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
fetchGameTemplates,
shop: { privateGameTemplateListingDatasSearchResults },
} = React.useContext(PrivateGameTemplateStoreContext);
const {
bundleListingDatas,
error: bundleStoreError,
fetchBundles,
shop: { bundleListingDatasSearchResults },
} = React.useContext(BundleStoreContext);
const currentPage = shopNavigationState.getCurrentPage();
const {
openedAssetPack,
@@ -182,6 +200,7 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
openedShopCategory,
openedPrivateAssetPackListingData,
openedPrivateGameTemplateListingData,
openedBundleListingData,
filtersState,
} = currentPage;
const isOnHomePage = isHomePage(currentPage);
@@ -235,11 +254,13 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
() => {
fetchAssetsAndFilters();
fetchGameTemplates();
fetchBundles();
},
[fetchAssetsAndFilters, fetchGameTemplates]
[fetchAssetsAndFilters, fetchGameTemplates, fetchBundles]
);
const storeError = assetStoreError || privateGameTemplateStoreError;
const storeError =
assetStoreError || privateGameTemplateStoreError || bundleStoreError;
const reApplySearchTextIfNeeded = React.useCallback(
(page: AssetStorePageState): boolean => {
@@ -257,6 +278,7 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
!openedAssetShortHeader && // Don't show filters on asset page.
!openedPrivateAssetPackListingData && // Don't show filters on private asset pack information page.
!openedPrivateGameTemplateListingData && // Don't show filters on private game template information page.
!openedBundleListingData && // Don't show filters on bundle information page.
!(
openedAssetPack &&
openedAssetPack.content &&
@@ -473,6 +495,35 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
[saveScrollPosition, shopNavigationState]
);
const selectBundle = React.useCallback(
(bundleListingData: BundleListingData) => {
sendBundleInformationOpened({
bundleName: bundleListingData.name,
bundleId: bundleListingData.id,
source: 'store',
});
saveScrollPosition();
shopNavigationState.openBundleInformationPage({
bundleListingData,
storeSearchText: true,
clearSearchText: true,
});
},
[saveScrollPosition, shopNavigationState]
);
const selectCourse = React.useCallback(
(courseListingData: CourseListingData) => {
sendCourseInformationOpened({
courseName: courseListingData.name,
courseId: courseListingData.id,
source: 'store',
});
if (onCourseOpen) onCourseOpen(courseListingData.id);
},
[onCourseOpen]
);
const selectShopCategory = React.useCallback(
(category: string) => {
saveScrollPosition();
@@ -535,9 +586,10 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
React.useEffect(
() => {
if (
hideGameTemplates &&
onlyShowAssets &&
(openedShopCategory === gameTemplatesCategoryId ||
openedPrivateGameTemplateListingData)
openedPrivateGameTemplateListingData ||
openedBundleListingData)
) {
shopNavigationState.openHome();
}
@@ -545,7 +597,8 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
[
openedShopCategory,
openedPrivateGameTemplateListingData,
hideGameTemplates,
openedBundleListingData,
onlyShowAssets,
shopNavigationState,
]
);
@@ -633,7 +686,7 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
<Column expand useFullHeight noMargin>
<SearchBar
placeholder={
hideGameTemplates ? t`Search assets` : t`Search the shop`
onlyShowAssets ? t`Search assets` : t`Search the shop`
}
value={searchText}
onChange={(newValue: string) => {
@@ -762,7 +815,8 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
</PlaceholderError>
) : publicAssetPacks &&
privateAssetPackListingDatas &&
privateGameTemplateListingDatas ? (
privateGameTemplateListingDatas &&
bundleListingDatas ? (
<AssetsHome
ref={assetsHome}
publicAssetPacks={publicAssetPacks}
@@ -770,12 +824,14 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
privateGameTemplateListingDatas={
privateGameTemplateListingDatas
}
bundleListingDatas={bundleListingDatas}
onPublicAssetPackSelection={selectPublicAssetPack}
onPrivateAssetPackSelection={selectPrivateAssetPack}
onPrivateGameTemplateSelection={selectPrivateGameTemplate}
onBundleSelection={selectBundle}
onCategorySelection={selectShopCategory}
openedShopCategory={openedShopCategory}
hideGameTemplates={hideGameTemplates}
onlyShowAssets={onlyShowAssets}
displayPromotions={displayPromotions}
onOpenProfile={onOpenProfile}
/>
@@ -797,6 +853,9 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
? []
: privateGameTemplateListingDatasSearchResults
}
bundleListingDatas={
assetSwappedObject ? [] : bundleListingDatasSearchResults
}
assetShortHeaders={assetShortHeadersSearchResults}
ref={assetsList}
error={storeError}
@@ -804,10 +863,11 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
onPrivateAssetPackSelection={selectPrivateAssetPack}
onPublicAssetPackSelection={selectPublicAssetPack}
onPrivateGameTemplateSelection={selectPrivateGameTemplate}
onBundleSelection={selectBundle}
onFolderSelection={selectFolder}
onGoBackToFolderIndex={goBackToFolderIndex}
currentPage={shopNavigationState.getCurrentPage()}
hideGameTemplates={hideGameTemplates}
onlyShowAssets={onlyShowAssets}
hideDetails={!!assetSwappedObject && !!minimalUI}
/>
) : // Do not show the asset details if we're swapping an asset.
@@ -826,6 +886,7 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
privateAssetPackListingData={openedPrivateAssetPackListingData}
onAssetPackOpen={selectPrivateAssetPack}
onGameTemplateOpen={selectPrivateGameTemplate}
onBundleOpen={selectBundle}
privateAssetPackListingDatasFromSameCreator={
privateAssetPackListingDatasFromSameCreator
}
@@ -843,10 +904,20 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
}}
onAssetPackOpen={selectPrivateAssetPack}
onGameTemplateOpen={selectPrivateGameTemplate}
onBundleOpen={selectBundle}
privateGameTemplateListingDatasFromSameCreator={
privateGameTemplateListingDatasFromSameCreator
}
/>
) : !!openedBundleListingData ? (
<BundleInformationPage
bundleListingData={openedBundleListingData}
receivedCourses={receivedCourses}
onBundleOpen={selectBundle}
onGameTemplateOpen={selectPrivateGameTemplate}
onAssetPackOpen={selectPrivateAssetPack}
onCourseOpen={selectCourse}
/>
) : null}
{canShowFiltersPanel && (
<ResponsivePaperOrDrawer

View File

@@ -144,18 +144,18 @@ const ChapterTile = ({
type Props = {|
course: ?Course,
courseChapters: ?(CourseChapter[]),
getCourseChapters: (courseId: string) => ?Array<CourseChapter>,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
getCourseChapterCompletion: (
courseId: string,
chapterId: string
) => CourseChapterCompletion | null,
onDisplayCourse: boolean => void,
onDisplayCourse: () => void,
|};
const CoursePreviewBanner = ({
course,
courseChapters,
getCourseChapters,
getCourseCompletion,
getCourseChapterCompletion,
onDisplayCourse,
@@ -168,6 +168,7 @@ const CoursePreviewBanner = ({
const chapterTiles = React.useMemo(
() => {
const courseChapters = course ? getCourseChapters(course.id) : null;
if (!course || !courseChapters) {
return new Array(numberOfTilesToDisplay).fill(0).map((_, index) => {
return (
@@ -256,7 +257,7 @@ const CoursePreviewBanner = ({
},
[
course,
courseChapters,
getCourseChapters,
getCourseChapterCompletion,
numberOfTilesToDisplay,
gdevelopTheme,
@@ -315,7 +316,7 @@ const CoursePreviewBanner = ({
{({ i18n }) => (
<Paper background="medium" variant="outlined">
<ButtonBase
onClick={() => onDisplayCourse(true)}
onClick={onDisplayCourse}
component="div"
style={
isMobile && !isLandscape
@@ -457,7 +458,7 @@ const CoursePreviewBanner = ({
</LineStackLayout>
<RaisedButton
primary
disabled={!course || !courseChapters}
disabled={!course}
label={
!courseCompletion ||
courseCompletion.percentage === 0 ? (
@@ -468,7 +469,7 @@ const CoursePreviewBanner = ({
<Trans>Keep learning</Trans>
)
}
onClick={() => onDisplayCourse(true)}
onClick={onDisplayCourse}
/>
</ResponsiveLineStackLayout>
</Paper>

View File

@@ -56,10 +56,12 @@ export const processEditorFunctionCalls = async ({
ignore,
ensureExtensionInstalled,
searchAndInstallAsset,
}: ProcessEditorFunctionCallsOptions): Promise<
Array<EditorFunctionCallResult>
> => {
}: ProcessEditorFunctionCallsOptions): Promise<{|
results: Array<EditorFunctionCallResult>,
createdSceneNames: Array<string>,
|}> => {
const results: Array<EditorFunctionCallResult> = [];
const createdSceneNames: Array<string> = [];
for (const functionCall of functionCalls) {
const call_id = functionCall.call_id;
@@ -147,12 +149,8 @@ export const processEditorFunctionCalls = async ({
});
if (success && args) {
if (typeof args.scene_name === 'string') {
editorCallbacks.onOpenLayout(args.scene_name, {
openEventsEditor: true,
openSceneEditor: true,
focusWhenOpened: 'none',
});
if (name === 'create_scene' && typeof args.scene_name === 'string') {
createdSceneNames.push(args.scene_name);
}
}
} catch (error) {
@@ -165,5 +163,5 @@ export const processEditorFunctionCalls = async ({
}
}
return results;
return { results, createdSceneNames };
};

View File

@@ -59,6 +59,7 @@ export type EditorFunctionGenericOutput = {|
sharedProperties?: any,
instances?: any,
behaviors?: Array<SimplifiedBehavior>,
animationNames?: string,
generatedEventsErrorDiagnostics?: string,
aiGeneratedEventId?: string,
|};
@@ -515,6 +516,7 @@ const inspectObjectProperties: EditorFunction = {
})
.filter(Boolean);
// Also include information about behaviors:
const behaviors = object
.getAllBehaviorNames()
.toJSArray()
@@ -526,12 +528,29 @@ const inspectObjectProperties: EditorFunction = {
};
});
return {
// Also include information about animations:
const animationNames = mapFor(
0,
objectConfiguration.getAnimationsCount(),
i => {
return (
objectConfiguration.getAnimationName(i) ||
`(animation without name, animation index is: ${i})`
);
}
);
const output: EditorFunctionGenericOutput = {
success: true,
objectName: object_name,
properties,
behaviors,
};
if (animationNames.length > 0) {
output.animationNames = animationNames.join(', ');
}
return output;
},
};

View File

@@ -3,8 +3,10 @@
import * as React from 'react';
import InAppTutorialOrchestrator from './InAppTutorialOrchestrator';
import { type EditorIdentifier } from '../Utils/GDevelopServices/InAppTutorial';
import { type EditorTabsState } from '../MainFrame/EditorTabs/EditorTabsHandler';
import { getCurrentTab } from '../MainFrame/EditorTabs/EditorTabsHandler';
import {
getCurrentTabForPane,
type EditorTabsState,
} from '../MainFrame/EditorTabs/EditorTabsHandler';
type Props = {|
editorTabs: EditorTabsState,
@@ -21,7 +23,7 @@ const useInAppTutorialOrchestrator = ({ editorTabs }: Props) => {
React.useEffect(
() => {
const currentTab = getCurrentTab(editorTabs);
const currentTab = getCurrentTabForPane(editorTabs, 'center');
if (!currentTab) {
setCurrentEditor(null);
return;

View File

@@ -29,6 +29,7 @@ export type EditorContainerExtraProps = {|
// Ask AI
mode?: 'chat' | 'agent',
aiRequestId?: string | null,
|};
export type SceneEventsOutsideEditorChanges = {|
@@ -43,7 +44,7 @@ export type RenderEditorContainerProps = {|
fileMetadata: ?FileMetadata,
storageProvider: StorageProvider,
setToolbar: (?React.Node) => void,
hideTabsTitleBarAndEditorToolbar: (hidden: boolean) => void,
setGamesPlatformFrameShown: ({| shown: boolean, isMobile: boolean |}) => void,
// Some optional extra props to pass to the rendered editor
extraEditorProps: ?EditorContainerExtraProps,
@@ -86,7 +87,11 @@ export type RenderEditorContainerProps = {|
variantName: string
) => void,
openObjectEvents: (extensionName: string, objectName: string) => void,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
onOpenAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
// Events function management:
onLoadEventsFunctionsExtensions: () => Promise<void>,

View File

@@ -0,0 +1,303 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import SectionContainer, { SectionRow } from '../SectionContainer';
import { Column, Line } from '../../../../UI/Grid';
import BundlePageHeader from './BundlePageHeader';
import { BundleStoreContext } from '../../../../AssetStore/Bundles/BundleStoreContext';
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
import type { CourseCompletion } from '../UseCourses';
import {
getBundle,
type Bundle,
type Course,
} from '../../../../Utils/GDevelopServices/Asset';
import {
type PrivateAssetPackListingData,
type BundleListingData,
type PrivateGameTemplateListingData,
type CourseListingData,
} from '../../../../Utils/GDevelopServices/Shop';
import { type SubscriptionPlanWithPricingSystems } from '../../../../Utils/GDevelopServices/Usage';
import { extractGDevelopApiErrorStatusAndCode } from '../../../../Utils/GDevelopServices/Errors';
import { Trans } from '@lingui/macro';
import AlertMessage from '../../../../UI/AlertMessage';
import {
getProductsIncludedInBundle,
getProductsIncludedInBundleTiles,
} from '../../../../AssetStore/ProductPageHelper';
import { PrivateGameTemplateStoreContext } from '../../../../AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext';
import { AssetStoreContext } from '../../../../AssetStore/AssetStoreContext';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
import { GridList, GridListTile } from '@material-ui/core';
import { LARGE_WIDGET_SIZE } from '../CardWidget';
import {
useResponsiveWindowSize,
type WindowSizeType,
} from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import Text from '../../../../UI/Text';
import CourseStoreContext from '../../../../Course/CourseStoreContext';
import CourseCard from './CourseCard';
const getColumns = (windowSize: WindowSizeType, isLandscape: boolean) => {
switch (windowSize) {
case 'small':
return isLandscape ? 4 : 2;
case 'medium':
return 3;
case 'large':
return 4;
case 'xlarge':
return 6;
default:
return 3;
}
};
const cellSpacing = 10;
const MAX_COLUMNS = getColumns('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const styles = {
grid: {
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
overflow: 'hidden',
width: `calc(100% + ${cellSpacing}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
},
};
type Props = {|
bundleListingData: BundleListingData,
onBack: () => void,
getSubscriptionPlansWithPricingSystems: () => Array<SubscriptionPlanWithPricingSystems> | null,
onBundleOpen: BundleListingData => void,
onGameTemplateOpen: PrivateGameTemplateListingData => void,
onAssetPackOpen: (
privateAssetPackListingData: PrivateAssetPackListingData
) => void,
onCourseOpen: CourseListingData => void,
courses: ?Array<Course>,
receivedCourses: ?Array<Course>,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
|};
const BundlePage = ({
bundleListingData,
onBack,
getSubscriptionPlansWithPricingSystems,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
onCourseOpen,
courses,
receivedCourses,
getCourseCompletion,
}: Props) => {
const { windowSize, isLandscape } = useResponsiveWindowSize();
const { bundleListingDatas } = React.useContext(BundleStoreContext); // If archived, should use the one passed.
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
);
const { privateAssetPackListingDatas } = React.useContext(AssetStoreContext);
const { listedCourses } = React.useContext(CourseStoreContext);
const {
receivedBundles,
receivedGameTemplates,
receivedAssetPacks,
} = React.useContext(AuthenticatedUserContext);
const [bundle, setBundle] = React.useState<?Bundle>(null);
const [errorText, setErrorText] = React.useState<?React.Node>(null);
const courseAndTheirListingDataIncludedInBundle = React.useMemo(
(): Array<{|
course: Course,
courseListingData: CourseListingData,
|}> | null => {
if (!bundle || !bundleListingData || !courses) return null;
const productListingDatasInBundle = getProductsIncludedInBundle({
productListingData: bundleListingData,
productListingDatas: [...(listedCourses || [])],
});
if (!productListingDatasInBundle) return null;
// $FlowIgnore - Flow doesn't understand that we have filtered the products to only include courses.
const courseListingDatasInBundle: CourseListingData[] = productListingDatasInBundle.filter(
productListingData => productListingData.productType === 'COURSE'
);
return (courseListingDatasInBundle || [])
.map(courseListingData => {
const course = courses.find(
course => course.id === courseListingData.id
);
if (!course) return null;
return {
course,
courseListingData,
};
})
.filter(Boolean);
},
[bundle, bundleListingData, listedCourses, courses]
);
const productsExceptCoursesIncludedInBundleTiles = React.useMemo(
() =>
bundle && bundleListingData
? getProductsIncludedInBundleTiles({
product: bundle,
productListingDatas: [
...(bundleListingDatas || []),
...(privateGameTemplateListingDatas || []),
...(privateAssetPackListingDatas || []),
],
productListingData: bundleListingData,
receivedProducts: [
...(receivedBundles || []),
...(receivedGameTemplates || []),
...(receivedAssetPacks || []),
],
onPrivateAssetPackOpen: onAssetPackOpen,
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
onCourseOpen,
})
: null,
[
bundle,
bundleListingDatas,
privateGameTemplateListingDatas,
privateAssetPackListingDatas,
receivedBundles,
receivedGameTemplates,
receivedAssetPacks,
bundleListingData,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
onCourseOpen,
]
);
React.useEffect(
() => {
(async () => {
try {
const bundle = await getBundle(bundleListingData.id);
setBundle(bundle);
} catch (error) {
const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode(
error
);
if (extractedStatusAndCode && extractedStatusAndCode.status === 404) {
setErrorText(
<Trans>
Bundle not found - An error occurred, please try again later.
</Trans>
);
} else {
setErrorText(
<Trans>An error occurred, please try again later.</Trans>
);
}
}
})();
},
[bundleListingData.id]
);
if (errorText) {
return (
<SectionContainer flexBody backAction={onBack}>
<SectionRow expand>
<Line alignItems="center" justifyContent="center" expand>
<AlertMessage kind="error">{errorText}</AlertMessage>
</Line>
</SectionRow>
</SectionContainer>
);
}
if (!bundleListingData || !bundle) {
return (
<SectionContainer flexBody>
<SectionRow expand>
<PlaceholderLoader />
</SectionRow>
</SectionContainer>
);
}
return (
<I18n>
{({ i18n }) => (
<SectionContainer
applyTopSpacingAsMarginOnChildrenContainer
backAction={onBack}
>
<Column noOverflowParent noMargin>
<BundlePageHeader
bundleListingData={bundleListingData}
bundle={bundle}
getSubscriptionPlansWithPricingSystems={
getSubscriptionPlansWithPricingSystems
}
/>
</Column>
{courseAndTheirListingDataIncludedInBundle &&
courseAndTheirListingDataIncludedInBundle.length > 0 && (
<Line>
<GridList
cols={getColumns(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing}
>
{courseAndTheirListingDataIncludedInBundle.map(
({ course, courseListingData }) => {
const completion = getCourseCompletion(course.id);
return (
<GridListTile key={course.id}>
<CourseCard
course={course}
courseListingData={courseListingData}
completion={completion}
onClick={() => {
onCourseOpen(courseListingData);
}}
/>
</GridListTile>
);
}
)}
</GridList>
</Line>
)}
{productsExceptCoursesIncludedInBundleTiles && (
<>
<Line>
<Text size="block-title">
<Trans>Also included in this bundle</Trans>
</Text>
</Line>
<Line>
<GridList
cols={getColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing}
style={styles.grid}
>
{productsExceptCoursesIncludedInBundleTiles}
</GridList>
</Line>
</>
)}
</SectionContainer>
)}
</I18n>
);
};
export default BundlePage;

View File

@@ -0,0 +1,388 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import { I18n } from '@lingui/react';
import { type Bundle } from '../../../../Utils/GDevelopServices/Asset';
import { type BundleListingData } from '../../../../Utils/GDevelopServices/Shop';
import { SectionRow } from '../SectionContainer';
import Paper from '../../../../UI/Paper';
import Text from '../../../../UI/Text';
import { Column, Line } from '../../../../UI/Grid';
import {
ColumnStackLayout,
LineStackLayout,
ResponsiveLineStackLayout,
} from '../../../../UI/Layout';
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
import { renderProductPrice } from '../../../../AssetStore/ProductPriceTag';
import {
getProductsIncludedInBundle,
getUserProductPurchaseUsageType,
PurchaseProductButtons,
} from '../../../../AssetStore/ProductPageHelper';
import { shouldUseAppStoreProduct } from '../../../../Utils/AppStorePurchases';
import { Divider } from '@material-ui/core';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
import { BundleStoreContext } from '../../../../AssetStore/Bundles/BundleStoreContext';
import { sendBundleBuyClicked } from '../../../../Utils/Analytics/EventSender';
import BundlePurchaseDialog from '../../../../AssetStore/Bundles/BundlePurchaseDialog';
import RedemptionCodesDialog from '../../../../RedemptionCode/RedemptionCodesDialog';
import { renderEstimatedTotalPriceFormatted } from '../../../../AssetStore/Bundles/Utils';
import { PrivateGameTemplateStoreContext } from '../../../../AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext';
import {
CreditsPackageStoreContext,
getCreditsAmountFromId,
} from '../../../../AssetStore/CreditsPackages/CreditsPackageStoreContext';
import { AssetStoreContext } from '../../../../AssetStore/AssetStoreContext';
import CourseStoreContext from '../../../../Course/CourseStoreContext';
import SecureCheckout from '../../../../AssetStore/SecureCheckout/SecureCheckout';
import {
getPlanIcon,
getPlanInferredNameFromId,
} from '../../../../Profile/Subscription/PlanCard';
import FlatButton from '../../../../UI/FlatButton';
import Coin from '../../../../Credits/Icons/Coin';
import { type SubscriptionPlanWithPricingSystems } from '../../../../Utils/GDevelopServices/Usage';
import { formatDurationOfRedemptionCode } from '../../../../RedemptionCode/Utils';
const styles = {
title: { overflowWrap: 'anywhere', textWrap: 'wrap' },
image: { width: 300, aspectRatio: '16 / 9' },
imageContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
borderRadius: 8,
overflow: 'hidden',
},
discountedPrice: { textDecoration: 'line-through', opacity: 0.7 },
coinIcon: {
width: 13,
height: 13,
position: 'relative',
top: -1,
},
};
const ResponsiveDivider = () => {
const { isMobile, isMediumScreen } = useResponsiveWindowSize();
return isMobile || isMediumScreen ? (
<Column noMargin>
<Divider orientation="horizontal" />
</Column>
) : (
<Line noMargin>
<Divider orientation="vertical" />
</Line>
);
};
type Props = {|
bundleListingData: BundleListingData,
bundle: Bundle,
getSubscriptionPlansWithPricingSystems: () => Array<SubscriptionPlanWithPricingSystems> | null,
simulateAppStoreProduct?: boolean,
|};
const BundlePageHeader = ({
bundle,
bundleListingData,
getSubscriptionPlansWithPricingSystems,
simulateAppStoreProduct,
}: Props) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
);
const { creditsPackageListingDatas } = React.useContext(
CreditsPackageStoreContext
);
const { bundleListingDatas } = React.useContext(BundleStoreContext);
const { privateAssetPackListingDatas } = React.useContext(AssetStoreContext);
const { listedCourses } = React.useContext(CourseStoreContext);
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const { receivedBundles, bundlePurchases } = authenticatedUser;
const [
purchasingBundleListingData,
setPurchasingBundleListingData,
] = React.useState<?BundleListingData>(null);
const { isMobile, isMediumScreen } = useResponsiveWindowSize();
const [
isRedemptionCodesDialogOpen,
setIsRedemptionCodesDialogOpen,
] = React.useState<boolean>(false);
const shouldUseOrSimulateAppStoreProduct =
shouldUseAppStoreProduct() || simulateAppStoreProduct;
const userBundlePurchaseUsageType = React.useMemo(
() =>
getUserProductPurchaseUsageType({
productId: bundleListingData ? bundleListingData.id : null,
receivedProducts: receivedBundles,
productPurchases: bundlePurchases,
allProductListingDatas: bundleListingDatas,
}),
[bundlePurchases, bundleListingData, bundleListingDatas, receivedBundles]
);
const isAlreadyReceived = !!userBundlePurchaseUsageType;
const productListingDatasIncludedInBundle = React.useMemo(
() =>
bundleListingData &&
bundleListingDatas &&
privateGameTemplateListingDatas &&
privateAssetPackListingDatas &&
listedCourses &&
creditsPackageListingDatas
? getProductsIncludedInBundle({
productListingDatas: [
...bundleListingDatas,
...privateGameTemplateListingDatas,
...privateAssetPackListingDatas,
...listedCourses,
...creditsPackageListingDatas,
],
productListingData: bundleListingData,
})
: null,
[
bundleListingData,
bundleListingDatas,
privateGameTemplateListingDatas,
privateAssetPackListingDatas,
listedCourses,
creditsPackageListingDatas,
]
);
const subscriptionPlansWithPricingSystems = getSubscriptionPlansWithPricingSystems();
const redemptionCodesIncludedInBundle = React.useMemo(
() =>
bundleListingData
? bundleListingData.includedRedemptionCodes || []
: null,
[bundleListingData]
);
const includedCreditsAmount = React.useMemo(
() =>
(bundleListingData.includedListableProducts || [])
.filter(product => product.productType === 'CREDIT_PACKAGE')
.reduce(
(total, product) => total + getCreditsAmountFromId(product.productId),
0
),
[bundleListingData]
);
const onClickBuy = React.useCallback(
async () => {
if (!bundle) return;
if (isAlreadyReceived) {
return;
}
try {
const price = bundleListingData.prices.find(
price => price.usageType === 'default'
);
sendBundleBuyClicked({
bundleId: bundle.id,
bundleName: bundle.name,
bundleTag: bundle.tag,
currency: price ? price.currency : undefined,
usageType: 'default',
});
setPurchasingBundleListingData(bundleListingData);
} catch (e) {
console.warn('Unable to send event', e);
}
},
[bundle, bundleListingData, isAlreadyReceived]
);
return (
<I18n>
{({ i18n }) => (
<>
<SectionRow>
<Paper background="dark" variant="outlined" style={{ padding: 16 }}>
<ColumnStackLayout noMargin>
<ResponsiveLineStackLayout
noMargin
alignItems="center"
justifyContent="flex-start"
forceMobileLayout={isMediumScreen}
expand
>
<div style={styles.imageContainer}>
<img
src={bundle.previewImageUrls[0]}
style={styles.image}
alt=""
/>
</div>
<ColumnStackLayout expand justifyContent="flex-start">
<Text size="title" noMargin style={styles.title}>
{selectMessageByLocale(i18n, bundle.nameByLocale)}
</Text>
<Line noMargin>
<Text noMargin>
{selectMessageByLocale(
i18n,
bundle.longDescriptionByLocale
)}
</Text>
</Line>
</ColumnStackLayout>
</ResponsiveLineStackLayout>
<ResponsiveLineStackLayout
expand
justifyContent="space-between"
forceMobileLayout={isMediumScreen}
>
{redemptionCodesIncludedInBundle &&
redemptionCodesIncludedInBundle.length > 0 && (
<ColumnStackLayout noMargin expand>
{redemptionCodesIncludedInBundle.map(
(includedRedemptionCode, index) => (
<LineStackLayout
noMargin
alignItems="center"
key={`${
includedRedemptionCode.givenSubscriptionPlanId
}-${index}`}
>
{getPlanIcon({
planId:
includedRedemptionCode.givenSubscriptionPlanId,
logoSize: 20,
})}
<Text>
<Trans>
{formatDurationOfRedemptionCode(
includedRedemptionCode.durationInDays
)}{' '}
of
{getPlanInferredNameFromId(
includedRedemptionCode.givenSubscriptionPlanId
)}
subscription included
</Trans>
</Text>
</LineStackLayout>
)
)}
{isAlreadyReceived && (
<Line noMargin>
<FlatButton
primary
label={<Trans>See my codes</Trans>}
onClick={() =>
setIsRedemptionCodesDialogOpen(true)
}
/>
</Line>
)}
</ColumnStackLayout>
)}
{includedCreditsAmount > 0 && (
<Column justifyContent="center" expand noMargin>
<LineStackLayout noMargin alignItems="center">
<Coin style={styles.coinIcon} />
<Text>
<Trans>
{includedCreditsAmount} credits included
</Trans>
</Text>
</LineStackLayout>
</Column>
)}
<ResponsiveDivider />
</ResponsiveLineStackLayout>
{!isAlreadyReceived && (
<Paper background="medium" style={{ padding: 16 }}>
{!!bundleListingData && (
<ResponsiveLineStackLayout
justifyContent="space-between"
noMargin
>
{!isMobile && !isMediumScreen && (
<Column noMargin justifyContent="center">
<LineStackLayout noMargin>
<Text noMargin color="secondary">
<span style={styles.discountedPrice}>
{renderEstimatedTotalPriceFormatted({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
subscriptionPlansWithPricingSystems,
})}
</span>
</Text>
<div
style={{
color: gdevelopTheme.text.color.secondary,
}}
>
{renderProductPrice({
i18n,
productListingData: bundleListingData,
usageType: 'default',
})}
</div>
</LineStackLayout>
</Column>
)}
<ResponsiveLineStackLayout
noMargin
forceMobileLayout={isMediumScreen}
>
{!shouldUseOrSimulateAppStoreProduct && (
<SecureCheckout />
)}
<PurchaseProductButtons
i18n={i18n}
productListingData={bundleListingData}
selectedUsageType="default"
onUsageTypeChange={() => {}}
simulateAppStoreProduct={simulateAppStoreProduct}
isAlreadyReceived={isAlreadyReceived}
onClickBuy={onClickBuy}
onClickBuyWithCredits={() => {}}
/>
</ResponsiveLineStackLayout>
</ResponsiveLineStackLayout>
)}
</Paper>
)}
</ColumnStackLayout>
</Paper>
</SectionRow>
{!!purchasingBundleListingData && (
<BundlePurchaseDialog
bundleListingData={purchasingBundleListingData}
usageType="default"
onClose={() => setPurchasingBundleListingData(null)}
/>
)}
{isRedemptionCodesDialogOpen && (
<RedemptionCodesDialog
onClose={() => setIsRedemptionCodesDialogOpen(false)}
/>
)}
</>
)}
</I18n>
);
};
export default BundlePageHeader;

View File

@@ -234,7 +234,6 @@ const CourseCard = ({
/>
{getProductPriceOrOwnedLabel({
i18n,
gdevelopTheme,
productListingData: courseListingData,
usageType: 'default',
showBothPrices: 'column',

View File

@@ -7,6 +7,7 @@ import { I18n } from '@lingui/react';
import {
type CourseChapter,
type Course,
getCourseChapterRatingUrl,
} from '../../../../Utils/GDevelopServices/Asset';
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
import SectionContainer from '../SectionContainer';
@@ -15,7 +16,7 @@ import TextBasedCourseChapterView from '../../../../Course/TextBasedCourseChapte
import Paper from '../../../../UI/Paper';
import Text from '../../../../UI/Text';
import { textEllipsisStyle } from '../../../../UI/TextEllipsis';
import { Column, Line, Spacer } from '../../../../UI/Grid';
import { Column, LargeSpacer, Line, Spacer } from '../../../../UI/Grid';
import Lock from '../../../../UI/CustomSvgIcons/Lock';
import { ColumnStackLayout, LineStackLayout } from '../../../../UI/Layout';
import Help from '../../../../UI/CustomSvgIcons/Help';
@@ -34,7 +35,10 @@ import {
AccordionBody,
AccordionHeader,
} from '../../../../UI/Accordion';
import CourseSectionHeader from './CourseSectionHeader';
import CoursePageHeader from './CoursePageHeader';
import Window from '../../../../Utils/Window';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
import { RatingBanner } from './RatingBanner';
const styles = {
desktopContainer: { display: 'flex', gap: 16 },
@@ -115,10 +119,14 @@ type Props = {|
purchasingCourseListingData: ?CourseListingData,
setPurchasingCourseListingData: (CourseListingData | null) => void,
simulateAppStoreProduct?: boolean,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
onOpenAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
|};
const CourseSection = ({
const CoursePage = ({
course,
courseChapters,
onOpenTemplateFromCourseChapter,
@@ -134,6 +142,11 @@ const CourseSection = ({
simulateAppStoreProduct,
onOpenAskAi,
}: Props) => {
const { profile } = React.useContext(AuthenticatedUserContext);
const userId = (profile && profile.id) || null;
const {
values: { language },
} = React.useContext(PreferencesContext);
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const { showAlertMessage, values } = React.useContext(PreferencesContext);
const { isMobile, isLandscape } = useResponsiveWindowSize();
@@ -319,7 +332,7 @@ const CourseSection = ({
}
>
<Column noOverflowParent noMargin>
<CourseSectionHeader
<CoursePageHeader
course={course}
onBuyCourseWithCredits={onBuyCourseWithCredits}
onBuyCourse={onBuyCourse}
@@ -340,54 +353,73 @@ const CourseSection = ({
</AlertMessage>
</Line>
)}
{courseChapters.map((chapter: CourseChapter, index) =>
chapter.videoUrl ? (
<VideoBasedCourseChapterView
chapterIndex={index}
course={course}
courseChapter={chapter}
onOpenTemplate={() => {
onOpenTemplateFromCourseChapter(chapter);
}}
onCompleteTask={onCompleteTask}
isTaskCompleted={isTaskCompleted}
getChapterCompletion={getChapterCompletion}
key={chapter.id}
onClickUnlock={onClickUnlock}
ref={_ref => {
if (_ref) {
chapterTitleRefs.current[index] = {
{courseChapters.map((chapter: CourseChapter, index) => (
<ColumnStackLayout expand noOverflowParent noMargin>
{chapter.videoUrl ? (
<VideoBasedCourseChapterView
chapterIndex={index}
course={course}
courseChapter={chapter}
onOpenTemplate={() => {
onOpenTemplateFromCourseChapter(chapter);
}}
onCompleteTask={onCompleteTask}
isTaskCompleted={isTaskCompleted}
getChapterCompletion={getChapterCompletion}
key={chapter.id}
onClickUnlock={onClickUnlock}
ref={_ref => {
if (_ref) {
chapterTitleRefs.current[index] = {
chapterId: chapter.id,
ref: _ref,
};
}
}}
/>
) : (
<TextBasedCourseChapterView
chapterIndex={index}
course={course}
// $FlowIgnore - Flow does not conclude this chapter can only be text-based.
courseChapter={chapter}
onOpenTemplate={(templateId?: string) => {
onOpenTemplateFromCourseChapter(chapter, templateId);
}}
onCompleteTask={onCompleteTask}
isTaskCompleted={isTaskCompleted}
getChapterCompletion={getChapterCompletion}
key={chapter.id}
onClickUnlock={onClickUnlock}
ref={_ref => {
if (_ref) {
chapterTitleRefs.current[index] = {
chapterId: chapter.id,
ref: _ref,
};
}
}}
/>
)}
{!chapter.isLocked && (
<RatingBanner
disabled={!userId}
onClick={() => {
if (!userId) return;
const url = getCourseChapterRatingUrl({
userId,
courseId: course.id,
chapterId: chapter.id,
ref: _ref,
};
}
}}
/>
) : (
<TextBasedCourseChapterView
chapterIndex={index}
course={course}
// $FlowIgnore - Flow does not conclude this chapter can only be text-based.
courseChapter={chapter}
onOpenTemplate={(templateId?: string) => {
onOpenTemplateFromCourseChapter(chapter, templateId);
}}
onCompleteTask={onCompleteTask}
isTaskCompleted={isTaskCompleted}
getChapterCompletion={getChapterCompletion}
key={chapter.id}
onClickUnlock={onClickUnlock}
ref={_ref => {
if (_ref) {
chapterTitleRefs.current[index] = {
chapterId: chapter.id,
ref: _ref,
};
}
}}
/>
)
)}
language,
});
Window.openExternalURL(url);
}}
/>
)}
<LargeSpacer />
</ColumnStackLayout>
))}
<div style={styles.footer} />
</Column>
{isMobile && !isLandscape ? null : (
@@ -432,7 +464,13 @@ const CourseSection = ({
<RaisedButton
primary
label={<Trans>Ask the AI</Trans>}
onClick={() => onOpenAskAi('chat')}
onClick={() =>
onOpenAskAi({
mode: 'chat',
aiRequestId: null,
paneIdentifier: 'right',
})
}
/>
</ColumnStackLayout>
</Paper>
@@ -483,4 +521,4 @@ const CourseSection = ({
);
};
export default CourseSection;
export default CoursePage;

View File

@@ -31,9 +31,10 @@ import Window from '../../../../Utils/Window';
import { PurchaseProductButtons } from '../../../../AssetStore/ProductPageHelper';
import { shouldUseAppStoreProduct } from '../../../../Utils/AppStorePurchases';
import { Divider } from '@material-ui/core';
import classes from './CourseSectionHeader.module.css';
import classes from './CoursePageHeader.module.css';
import Gold from '../../../../Profile/Subscription/Icons/Gold';
import Chip from '../../../../UI/Chip';
import SecureCheckout from '../../../../AssetStore/SecureCheckout/SecureCheckout';
const styles = {
title: { overflowWrap: 'anywhere', textWrap: 'wrap' },
@@ -95,7 +96,7 @@ type Props = {|
simulateAppStoreProduct?: boolean,
|};
const CourseSectionHeader = ({
const CoursePageHeader = ({
course,
onBuyCourseWithCredits,
onBuyCourse,
@@ -271,20 +272,30 @@ const CourseSectionHeader = ({
</div>
</Column>
)}
<PurchaseProductButtons
i18n={i18n}
productListingData={courseListingData}
selectedUsageType="default"
onUsageTypeChange={() => {}}
simulateAppStoreProduct={
shouldUseOrSimulateAppStoreProduct
}
isAlreadyReceived={!course.isLocked}
onClickBuy={() => onBuyCourse(course, password, i18n)}
onClickBuyWithCredits={() =>
onWillBuyWithCredits(i18n)
}
/>
<ResponsiveLineStackLayout
noMargin
forceMobileLayout={isMediumScreen}
>
{!shouldUseOrSimulateAppStoreProduct && (
<SecureCheckout />
)}
<PurchaseProductButtons
i18n={i18n}
productListingData={courseListingData}
selectedUsageType="default"
onUsageTypeChange={() => {}}
simulateAppStoreProduct={
shouldUseOrSimulateAppStoreProduct
}
isAlreadyReceived={!course.isLocked}
onClickBuy={() =>
onBuyCourse(course, password, i18n)
}
onClickBuyWithCredits={() =>
onWillBuyWithCredits(i18n)
}
/>
</ResponsiveLineStackLayout>
</ResponsiveLineStackLayout>
)}
</Paper>
@@ -313,4 +324,4 @@ const CourseSectionHeader = ({
);
};
export default CourseSectionHeader;
export default CoursePageHeader;

View File

@@ -7,16 +7,18 @@ import {
type Course,
type CourseChapter,
} from '../../../../Utils/GDevelopServices/Asset';
import type { BundleListingData } from '../../../../Utils/GDevelopServices/Shop';
import CoursePreviewBanner from '../../../../Course/CoursePreviewBanner';
import type { CourseCompletion, CourseChapterCompletion } from '../UseCourses';
import { Line } from '../../../../UI/Grid';
import { GridList, GridListTile } from '@material-ui/core';
import CourseStoreContext from '../../../../Course/CourseStoreContext';
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import { LARGE_WIDGET_SIZE } from '../CardWidget';
import CourseCard from './CourseCard';
import { getColumnsFromWindowSize } from './Utils';
import BundlePreviewBanner from '../../../../AssetStore/Bundles/BundlePreviewBanner';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
@@ -35,8 +37,9 @@ type Props = {|
onBack: () => void,
courses: ?Array<Course>,
onSelectCourse: (courseId: string) => void,
onSelectBundle: (bundleListingData: BundleListingData) => void,
previewedCourse: ?Course,
previewedCourseChapters: ?Array<CourseChapter>,
getCourseChapters: (courseId: string) => ?Array<CourseChapter>,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
getCourseChapterCompletion: (
courseId: string,
@@ -48,23 +51,23 @@ const CoursesPage = ({
onBack,
courses,
onSelectCourse,
onSelectBundle,
previewedCourse,
previewedCourseChapters,
getCourseChapters,
getCourseChapterCompletion,
getCourseCompletion,
}: Props) => {
const { listedCourses } = React.useContext(CourseStoreContext);
const { windowSize, isLandscape } = useResponsiveWindowSize();
if (!courses || !listedCourses) {
return (
<SectionContainer flexBody>
<SectionRow expand>
<PlaceholderLoader />
</SectionRow>
</SectionContainer>
);
}
const numberOfItemsOnOneRow = getColumnsFromWindowSize(
windowSize,
isLandscape
);
const { limits } = React.useContext(AuthenticatedUserContext);
const hidePremiumProducts =
!!limits &&
!!limits.capabilities.classrooms &&
limits.capabilities.classrooms.hidePremiumProducts;
return (
<I18n>
@@ -76,7 +79,7 @@ const CoursesPage = ({
<SectionRow>
<CoursePreviewBanner
course={previewedCourse}
courseChapters={previewedCourseChapters}
getCourseChapters={getCourseChapters}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
onDisplayCourse={() => {
@@ -88,13 +91,13 @@ const CoursesPage = ({
<SectionRow>
<Line>
<GridList
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
cols={numberOfItemsOnOneRow}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{courses && listedCourses
? courses.map(course => {
? courses.slice(0, numberOfItemsOnOneRow).map(course => {
const completion = getCourseCompletion(course.id);
const courseListingData = listedCourses.find(
listedCourse => listedCourse.id === course.id
@@ -112,7 +115,7 @@ const CoursesPage = ({
</GridListTile>
);
})
: new Array(5).fill(0).map((_, index) => (
: new Array(6).fill(0).map((_, index) => (
<GridListTile key={`skeleton-course-${index}`}>
<CourseCard
course={null}
@@ -124,6 +127,42 @@ const CoursesPage = ({
</GridList>
</Line>
</SectionRow>
{!hidePremiumProducts && (
<SectionRow>
<BundlePreviewBanner onDisplayBundle={onSelectBundle} />
</SectionRow>
)}
{courses && listedCourses && courses.length > numberOfItemsOnOneRow && (
<SectionRow>
<Line>
<GridList
cols={numberOfItemsOnOneRow}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{courses.slice(numberOfItemsOnOneRow).map(course => {
const completion = getCourseCompletion(course.id);
const courseListingData = listedCourses.find(
listedCourse => listedCourse.id === course.id
);
return (
<GridListTile key={course.id}>
<CourseCard
course={course}
courseListingData={courseListingData}
completion={completion}
onClick={() => {
onSelectCourse(course.id);
}}
/>
</GridListTile>
);
})}
</GridList>
</Line>
</SectionRow>
)}
</SectionContainer>
)}
</I18n>

View File

@@ -30,7 +30,7 @@ const styles = {
container: { maxWidth: 850 },
thumbnail: {
display: 'block', // Display as a block to prevent cumulative layout shift.
objectFit: 'cover',
objectFit: 'contain',
verticalAlign: 'middle',
borderRadius: 8,
width: '100%',

View File

@@ -7,13 +7,9 @@ import Window from '../../../../Utils/Window';
import { Trans } from '@lingui/macro';
import TranslateIcon from '@material-ui/icons/Translate';
import { ColumnStackLayout, LineStackLayout } from '../../../../UI/Layout';
import { type HomeTab } from '../HomePageMenu';
import { type Tutorial } from '../../../../Utils/GDevelopServices/Tutorial';
import SectionContainer, { SectionRow } from '../SectionContainer';
import type {
Course,
CourseChapter,
} from '../../../../Utils/GDevelopServices/Asset';
import type { Course } from '../../../../Utils/GDevelopServices/Asset';
import type { CourseCompletion, CourseChapterCompletion } from '../UseCourses';
import FlatButton from '../../../../UI/FlatButton';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
@@ -35,9 +31,14 @@ import CourseStoreContext from '../../../../Course/CourseStoreContext';
import TutorialsRow from './TutorialsRow';
import { getColumnsFromWindowSize, type LearnCategory } from './Utils';
import ExampleStore from '../../../../AssetStore/ExampleStore';
import { type PrivateGameTemplateListingData } from '../../../../Utils/GDevelopServices/Shop';
import {
type PrivateGameTemplateListingData,
type BundleListingData,
} from '../../../../Utils/GDevelopServices/Shop';
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
import { type SubscriptionPlanWithPricingSystems } from '../../../../Utils/GDevelopServices/Usage';
import Carousel from '../../../../UI/Carousel';
import BundlePreviewBanner from '../../../../AssetStore/Bundles/BundlePreviewBanner';
const NUMBER_OF_SCROLLS = 2; // Number of times the carousel can be scrolled to see all items.
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
@@ -59,19 +60,21 @@ const styles = {
};
type Props = {|
onTabChange: (tab: HomeTab) => void,
onSelectCategory: (category: LearnCategory) => void,
selectInAppTutorial: (tutorialId: string) => void,
previewedCourse: ?Course,
courses: ?(Course[]),
previewedCourseChapters: ?(CourseChapter[]),
onSelectCourse: (courseId: string) => void,
onSelectBundle: (bundleListingData: BundleListingData) => void,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
getCourseChapterCompletion: (
courseId: string,
chapterId: string
) => CourseChapterCompletion | null,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
onOpenAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
onOpenNewProjectSetupDialog: () => void,
onSelectPrivateGameTemplateListingData: (
privateGameTemplateListingData: PrivateGameTemplateListingData
@@ -80,13 +83,11 @@ type Props = {|
|};
const MainPage = ({
onTabChange,
onSelectCategory,
selectInAppTutorial,
previewedCourse,
courses,
previewedCourseChapters,
onSelectCourse,
onSelectBundle,
getCourseCompletion,
getCourseChapterCompletion,
onOpenAskAi,
@@ -107,6 +108,11 @@ const MainPage = ({
isMediumScreen,
} = useResponsiveWindowSize();
const hidePremiumProducts =
!!limits &&
!!limits.capabilities.classrooms &&
limits.capabilities.classrooms.hidePremiumProducts;
const displayedCourses = React.useMemo(
() => {
if (!courses) return null;
@@ -233,6 +239,11 @@ const MainPage = ({
/>
</Line>
</SectionRow>
{!hidePremiumProducts && (
<SectionRow>
<BundlePreviewBanner onDisplayBundle={onSelectBundle} />
</SectionRow>
)}
<SectionRow>
<LineStackLayout
justifyContent="space-between"
@@ -391,7 +402,13 @@ const MainPage = ({
color="success"
label={<Trans>Ask the AI</Trans>}
rightIcon={<ArrowRight />}
onClick={() => onOpenAskAi('chat')}
onClick={() =>
onOpenAskAi({
mode: 'chat',
aiRequestId: null,
paneIdentifier: 'center',
})
}
/>
</ColumnStackLayout>
</Line>

View File

@@ -0,0 +1,44 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import {
ColumnStackLayout,
ResponsiveLineStackLayout,
} from '../../../../UI/Layout';
import Text from '../../../../UI/Text';
import classes from './RatingBanner.module.css';
import FlatButton from '../../../../UI/FlatButton';
import StarForButton from '../../../../UI/CustomSvgIcons/StarForButton';
type Props = {|
onClick: () => void,
disabled: boolean,
|};
export const RatingBanner = ({ onClick, disabled }: Props) => {
return (
<div className={classes.container}>
<ResponsiveLineStackLayout
expand
justifyContent="space-between"
alignItems="center"
>
<ColumnStackLayout noMargin>
<Text size="block-title" noMargin>
<Trans>Help us improve our learning content</Trans>
</Text>
<Text size="body2" noMargin>
<Trans>How would you rate this chapter?</Trans>
</Text>
</ColumnStackLayout>
<FlatButton
primary
label={<Trans>Rate chapter</Trans>}
leftIcon={<StarForButton />}
disabled={disabled}
onClick={onClick}
/>
</ResponsiveLineStackLayout>
</div>
);
};

View File

@@ -0,0 +1,10 @@
.container {
border-radius: 5px;
background: linear-gradient(
0deg,
var(--theme-primary-light) -80%,
transparent
);
display: flex;
padding: 8px 20px;
}

View File

@@ -1,10 +1,10 @@
// @flow
import { type I18n as I18nType } from '@lingui/core';
import * as React from 'react';
import { type HomeTab } from '../HomePageMenu';
import {
type CourseListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
} from '../../../../Utils/GDevelopServices/Shop';
import MainPage from './MainPage';
import TutorialsCategoryPage from './TutorialsCategoryPage';
@@ -13,7 +13,7 @@ import { TutorialContext } from '../../../../Tutorial/TutorialContext';
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
import ErrorBoundary from '../../../../UI/ErrorBoundary';
import CourseSection from './CourseSection';
import CoursePage from './CoursePage';
import type {
CourseChapter,
Course,
@@ -25,20 +25,26 @@ import InAppTutorialsPage from './InAppTutorialsPage';
import CoursesPage from './CoursesPage';
import { type LearnCategory } from './Utils';
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
import { type SubscriptionPlanWithPricingSystems } from '../../../../Utils/GDevelopServices/Usage';
import BundlePage from './BundlePage';
import RouterContext from '../../../RouterContext';
import {
sendBundleInformationOpened,
sendCourseInformationOpened,
} from '../../../../Utils/Analytics/EventSender';
import { BundleStoreContext } from '../../../../AssetStore/Bundles/BundleStoreContext';
type Props = {|
onTabChange: (tab: HomeTab) => void,
selectInAppTutorial: (tutorialId: string) => void,
selectedCategory: LearnCategory,
onSelectCategory: LearnCategory => void,
onOpenTemplateFromTutorial: string => Promise<void>,
onOpenTemplateFromCourseChapter: CourseChapter => Promise<void>,
previewedCourse: ?Course,
previewedCourseChapters: ?(CourseChapter[]),
onSelectCourse: (courseId: string | null) => void,
course: ?Course,
courses: ?(Course[]),
courseChapters: ?(CourseChapter[]),
getCourseChapters: (courseId: string) => ?Array<CourseChapter>,
onCompleteCourseTask: (
chapterId: string,
taskIndex: number,
@@ -62,26 +68,30 @@ type Props = {|
) => Promise<void>,
purchasingCourseListingData: ?CourseListingData,
setPurchasingCourseListingData: (CourseListingData | null) => void,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
onOpenAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
onOpenNewProjectSetupDialog: () => void,
onSelectPrivateGameTemplateListingData: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void,
getSubscriptionPlansWithPricingSystems: () => Array<SubscriptionPlanWithPricingSystems> | null,
receivedCourses: ?Array<Course>,
|};
const LearnSection = ({
onTabChange,
selectInAppTutorial,
selectedCategory,
onSelectCategory,
onOpenTemplateFromTutorial,
onOpenTemplateFromCourseChapter,
previewedCourse,
previewedCourseChapters,
onSelectCourse,
course,
courseChapters,
getCourseChapters,
courses,
onCompleteCourseTask,
isCourseTaskCompleted,
@@ -95,17 +105,61 @@ const LearnSection = ({
onOpenNewProjectSetupDialog,
onSelectPrivateGameTemplateListingData,
onSelectExampleShortHeader,
getSubscriptionPlansWithPricingSystems,
receivedCourses,
}: Props) => {
const { fetchTutorials } = React.useContext(TutorialContext);
const { fetchBundles } = React.useContext(BundleStoreContext);
const { navigateToRoute } = React.useContext(RouterContext);
const [
selectedBundleListingData,
setSelectedBundleListingData,
] = React.useState<?BundleListingData>(null);
const onOpenBundle = React.useCallback(
(bundleListingData: BundleListingData) => {
sendBundleInformationOpened({
bundleName: bundleListingData.name,
bundleId: bundleListingData.id,
source: 'learn',
});
setSelectedBundleListingData(bundleListingData);
},
[setSelectedBundleListingData]
);
const onOpenCourse = React.useCallback(
(courseId: string | null) => {
if (courseId && courses) {
const course = courses.find(c => c.id === courseId);
if (course && course.isLocked) {
// Only send the event if the course is not owned.
sendCourseInformationOpened({
courseName: course.titleByLocale['en'],
courseId: courseId,
source: 'learn',
});
}
}
onSelectCourse(courseId);
},
[onSelectCourse, courses]
);
React.useEffect(
() => {
fetchTutorials();
fetchBundles();
},
[fetchTutorials]
// Fetch tutorials and bundles only once when the component mounts.
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
if (course) {
const courseChapters = getCourseChapters(course.id);
if (!courseChapters) {
return (
<SectionContainer flexBody>
@@ -117,7 +171,7 @@ const LearnSection = ({
}
return (
<CourseSection
<CoursePage
course={course}
courseChapters={courseChapters}
onBack={() => {
@@ -139,15 +193,40 @@ const LearnSection = ({
);
}
if (selectedBundleListingData) {
return (
<BundlePage
bundleListingData={selectedBundleListingData}
onBack={() => setSelectedBundleListingData(null)}
getSubscriptionPlansWithPricingSystems={
getSubscriptionPlansWithPricingSystems
}
onAssetPackOpen={privateAssetPackListingData => {
// Ideally we would open it in the Learn Section,
// but asset packs are not supported in the Learn Section yet.
navigateToRoute('store', {
'asset-pack': `product-${privateAssetPackListingData.id}`,
});
}}
onGameTemplateOpen={onSelectPrivateGameTemplateListingData}
onBundleOpen={onOpenBundle}
onCourseOpen={courseListingData => {
onOpenCourse(courseListingData.id);
}}
courses={courses}
receivedCourses={receivedCourses}
getCourseCompletion={getCourseCompletion}
/>
);
}
return !selectedCategory ? (
<MainPage
onTabChange={onTabChange}
onSelectCategory={onSelectCategory}
selectInAppTutorial={selectInAppTutorial}
courses={courses}
onSelectCourse={onSelectCourse}
previewedCourse={previewedCourse}
previewedCourseChapters={previewedCourseChapters}
onSelectCourse={onOpenCourse}
onSelectBundle={onOpenBundle}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
onOpenAskAi={onOpenAskAi}
@@ -168,9 +247,10 @@ const LearnSection = ({
<CoursesPage
onBack={() => onSelectCategory(null)}
courses={courses}
onSelectCourse={onSelectCourse}
onSelectCourse={onOpenCourse}
onSelectBundle={onOpenBundle}
previewedCourse={previewedCourse}
previewedCourseChapters={previewedCourseChapters}
getCourseChapters={getCourseChapters}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
/>
@@ -179,7 +259,7 @@ const LearnSection = ({
onBack={() => onSelectCategory('all-tutorials')}
category={selectedCategory}
onOpenTemplateFromTutorial={onOpenTemplateFromTutorial}
onSelectCourse={onSelectCourse}
onSelectCourse={onOpenCourse}
/>
);
};

View File

@@ -1,13 +1,9 @@
// @flow
import * as React from 'react';
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import {
homepageDesktopMenuBarWidth,
homepageMediumMenuBarWidth,
homepageMobileMenuHeight,
} from '../HomePageMenuBar';
import { homepageMobileMenuHeight } from '../HomePageMenuBar';
import Paper from '../../../../UI/Paper';
import { type IframePosition } from './UseGamesPlatformFrame';
export const GAMES_PLATFORM_IFRAME_ID = 'games-platform-frame';
@@ -33,12 +29,17 @@ type Props = {|
loaded: boolean,
visible: boolean,
initialGameId: ?string,
iframePosition: ?IframePosition,
|};
const GamesPlatformFrame = ({ initialGameId, loaded, visible }: Props) => {
const GamesPlatformFrame = ({
initialGameId,
loaded,
visible,
iframePosition,
}: Props) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const paletteType = gdevelopTheme.palette.type;
const { isMobile, isMediumScreen } = useResponsiveWindowSize();
// Use a ref to store the initial game id, as we don't want to trigger a re-render
// when the game id changes.
@@ -64,19 +65,19 @@ const GamesPlatformFrame = ({ initialGameId, loaded, visible }: Props) => {
[loaded, initialGameId]
);
const titleBarAndToolbarHeight = isMobile ? 0 : 37 + 40;
const containerTop = isMobile
? 0 // Always top of the screen on small screens.
: `calc(${titleBarAndToolbarHeight}px + var(--safe-area-inset-top))`;
const containerBottom = isMobile ? homepageMobileMenuHeight : 0;
const containerLeft = isMobile
? 0
: isMediumScreen
? homepageMediumMenuBarWidth
: homepageDesktopMenuBarWidth;
const containerWidth = `calc(100% - ${containerLeft}px`;
const containerHeight = `calc(100% - ${titleBarAndToolbarHeight +
containerBottom}px - ${isMobile ? '0px' : 'var(--safe-area-inset-top)'})`;
// In this component, do not use useResponsiveWindowSize. The position
// of the iframe is read from iframePosition - which is set by the component
// responsible for positioning the iframe (typically, the "Play section" component).
// The iframe position can change according to the size of the pane containing it -
// which is not always the same as the size of the top level window.
const containerTop =
iframePosition && !iframePosition.isMobile ? iframePosition.top : 0;
const containerLeft = iframePosition ? iframePosition.left : 0;
const containerWidth = iframePosition ? iframePosition.width : '100%';
const containerHeight =
iframePosition && !iframePosition.isMobile
? iframePosition.height
: `calc(100% - ${homepageMobileMenuHeight}px)`;
// We wrap the iframe in a paper, as its content has a transparent background,
// and we don't want what's behind the iframe to be visible.

View File

@@ -163,12 +163,21 @@ const sendSoftKeyboardOffsetToFrame = async (offset: number) => {
}
};
export type IframePosition = {|
isMobile: boolean,
top: number,
left: number,
width: number,
height: number,
|};
export type GamesPlatformFrameTools = {|
startTimeoutToUnloadIframe: () => void,
loadIframeOrRemoveTimeout: () => void,
iframeLoaded: boolean,
iframeVisible: boolean,
iframeErrored: boolean,
updateIframePosition: (position: IframePosition) => void,
renderGamesPlatformFrame: () => React.Node,
|};
@@ -185,6 +194,9 @@ const useGamesPlatformFrame = ({
const [iframeVisible, setIframeVisible] = React.useState(false);
const [iframeLoaded, setIframeLoaded] = React.useState(false);
const [iframeErrored, setIframeErrored] = React.useState(false);
const [iframePosition, setIframePosition] = React.useState<?IframePosition>(
null
);
const [lastGameId, setLastGameId] = React.useState<?string>(null);
const timeoutToUnloadIframe = React.useRef<?TimeoutID>(null);
const { openUserPublicProfile } = React.useContext(PublicProfileContext);
@@ -504,9 +516,26 @@ const useGamesPlatformFrame = ({
initialGameId={lastGameId}
loaded={loadIframeInDOM}
visible={iframeVisible}
iframePosition={iframePosition}
/>
),
[lastGameId, loadIframeInDOM, iframeVisible]
[lastGameId, loadIframeInDOM, iframeVisible, iframePosition]
);
const updateIframePosition = React.useCallback(
(position: IframePosition) => {
if (
!iframePosition ||
iframePosition.isMobile !== position.isMobile ||
iframePosition.top !== position.top ||
iframePosition.left !== position.left ||
iframePosition.width !== position.width ||
iframePosition.height !== position.height
) {
setIframePosition(position);
}
},
[setIframePosition, iframePosition]
);
const gamesPlatformFrameTools = React.useMemo(
@@ -516,6 +545,7 @@ const useGamesPlatformFrame = ({
iframeLoaded,
iframeVisible,
iframeErrored,
updateIframePosition,
renderGamesPlatformFrame,
}),
[
@@ -524,6 +554,7 @@ const useGamesPlatformFrame = ({
iframeLoaded,
iframeVisible,
iframeErrored,
updateIframePosition,
renderGamesPlatformFrame,
]
);

View File

@@ -7,24 +7,51 @@ import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
import PlaceholderError from '../../../../UI/PlaceholderError';
import { sendPlaySectionOpened } from '../../../../Utils/Analytics/EventSender';
import { type GamesPlatformFrameTools } from './UseGamesPlatformFrame';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
type Props = {|
gamesPlatformFrameTools: GamesPlatformFrameTools,
|};
const styles = {
iframeTarget: { position: 'absolute', inset: 0, pointerEvents: 'none' },
};
const PlaySection = ({ gamesPlatformFrameTools }: Props) => {
const { isMobile } = useResponsiveWindowSize();
const {
iframeLoaded,
iframeErrored,
loadIframeOrRemoveTimeout,
updateIframePosition,
} = gamesPlatformFrameTools;
// At each render, communicate the iframe position to the games frame.
// If on a "mobile" screen (either a real mobile, or a desktop that has a small
// window, or an editor tab that is too narrow), communicate this so that the
// iframe can be shown from the top of the screen.
const iframePositionRef = React.useRef<?HTMLDivElement>(null);
React.useLayoutEffect(() => {
if (iframePositionRef.current) {
const rect = iframePositionRef.current.getBoundingClientRect();
updateIframePosition({
isMobile,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
});
}
});
React.useEffect(() => {
sendPlaySectionOpened();
}, []);
// Iframe will be displayed here if loaded.
return iframeLoaded ? null : (
return iframeLoaded ? (
<div style={styles.iframeTarget} ref={iframePositionRef} />
) : (
<SectionContainer flexBody>
<SectionRow expand>
{iframeErrored ? (

View File

@@ -10,6 +10,7 @@ import { AssetStoreContext } from '../../../../AssetStore/AssetStoreContext';
import AssetPackInstallDialog from '../../../../AssetStore/AssetPackInstallDialog';
import { enumerateAssetStoreIds } from '../../../../AssetStore/EnumerateAssetStoreIds';
import { type PrivateGameTemplateListingData } from '../../../../Utils/GDevelopServices/Shop';
import { type Course } from '../../../../Utils/GDevelopServices/Asset';
import ErrorBoundary from '../../../../UI/ErrorBoundary';
import { getAssetShortHeadersToDisplay } from '../../../../AssetStore/AssetsList';
import { AssetStoreNavigatorContext } from '../../../../AssetStore/AssetStoreNavigator';
@@ -22,6 +23,8 @@ type Props = {|
) => void,
onOpenProfile: () => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
onCourseOpen: (courseId: string) => void,
receivedCourses?: ?Array<Course>,
|};
const StoreSection = ({
@@ -30,6 +33,8 @@ const StoreSection = ({
onOpenPrivateGameTemplateListingData,
onOpenProfile,
onExtensionInstalled,
onCourseOpen,
receivedCourses,
}: Props) => {
const [
isAssetPackDialogInstallOpen,
@@ -84,6 +89,8 @@ const StoreSection = ({
}
displayPromotions
onOpenProfile={onOpenProfile}
receivedCourses={receivedCourses}
onCourseOpen={onCourseOpen}
/>
{(openedAssetPack || openedAssetShortHeader) && (
<Line justifyContent="flex-end">

View File

@@ -614,7 +614,7 @@ const ManageEducationAccountDialog = ({ onClose }: Props) => {
>
<LineStackLayout noMargin alignItems="center">
{getPlanIcon({
subscriptionPlan: userSubscriptionPlanWithPricingSystems,
planId: userSubscriptionPlanWithPricingSystems.id,
logoSize: 20,
})}
<Text noMargin>

View File

@@ -35,20 +35,75 @@ export type CourseCompletion = {|
chapters: number,
|};
const useCourses = () => {
type ReadyUser = {|
userStatus: 'ready' | 'not-ready',
userId: string | null,
userSubscriptionPlanId: string | null,
userCoursePurchasesCount: number | null,
userBundlePurchaseCount: number | null,
|};
/**
* Help to reduce the number of re-fetches of the courses by
* debouncing the user changes.
*/
const useReadyUser = () => {
const {
profile,
limits,
subscription,
coursePurchases,
bundlePurchases,
loginState,
} = React.useContext(AuthenticatedUserContext);
const [readyUser, setReadyUser] = React.useState<ReadyUser>({
userStatus: 'not-ready',
userId: null,
userSubscriptionPlanId: null,
userCoursePurchasesCount: null,
userBundlePurchaseCount: null,
});
React.useEffect(
() => {
if (loginState !== 'done') {
return;
}
setReadyUser({
userStatus: 'ready',
userId: profile ? profile.id : null,
userSubscriptionPlanId: subscription ? subscription.planId : null,
userCoursePurchasesCount: coursePurchases ? coursePurchases.length : 0,
userBundlePurchaseCount: bundlePurchases ? bundlePurchases.length : 0,
});
},
[profile, loginState, subscription, coursePurchases, bundlePurchases]
);
return readyUser;
};
const noCourseChapters: {
[courseId: string]: CourseChapter[],
} = {};
const useCourses = () => {
const {
userStatus,
userId,
userSubscriptionPlanId,
userCoursePurchasesCount,
userBundlePurchaseCount,
} = useReadyUser();
const {
limits,
getAuthorizationHeader,
onOpenLoginDialog,
loginState,
} = React.useContext(AuthenticatedUserContext);
const {
values: { language },
} = React.useContext(PreferencesContext);
const userLanguage2LetterCode = language.split('_')[0].toLowerCase();
const [courses, setCourses] = React.useState<?(Course[])>(null);
const { listedCourses } = React.useContext(CourseStoreContext);
@@ -84,23 +139,36 @@ const useCourses = () => {
updateUserCourseProgress
);
const [areChaptersReady, setAreChaptersReady] = React.useState<boolean>(
const [areCoursesFetched, setAreCoursesFetched] = React.useState<boolean>(
false
);
const [chaptersByCourseId, setChaptersByCourseId] = React.useState<{|
[courseId: string]: CourseChapter[],
|}>({});
const userId = profile ? profile.id : null;
const [
chaptersByCourseIdByUserId,
setChaptersByCourseIdByUserId,
] = React.useState<{
[userId: string]: { [courseId: string]: CourseChapter[] },
}>({
'': noCourseChapters,
});
const hidePremiumProducts =
!!limits &&
!!limits.capabilities.classrooms &&
limits.capabilities.classrooms.hidePremiumProducts;
const fetchCourses = React.useCallback(
async () => {
async (): Promise<Array<Course>> => {
const fetchedCourses = await listCourses(getAuthorizationHeader, {
userId,
});
setCourses(fetchedCourses);
const displayedCourses = fetchedCourses.filter(
course => !hidePremiumProducts || !course.isLocked
);
setCourses(displayedCourses);
return displayedCourses;
},
[userId, getAuthorizationHeader]
[userId, getAuthorizationHeader, hidePremiumProducts]
);
const onSelectCourse = React.useCallback(
@@ -119,11 +187,15 @@ const useCourses = () => {
const fetchCourseChapters = React.useCallback(
async (courseId: string) => {
try {
console.info(
`Fetching chapters for course ${courseId} for userId=${userId ||
'null'}.`
);
const [fetchedChapters, userProgress] = await Promise.all([
listCourseChapters(getAuthorizationHeader, {
courseId,
userId,
lang: userLanguage2LetterCode,
language,
}),
(async () => {
if (userId) {
@@ -142,9 +214,13 @@ const useCourses = () => {
...currentProgressByCourseId,
[courseId]: userProgress,
}));
setChaptersByCourseId(currentChaptersByCourseId => ({
...currentChaptersByCourseId,
[courseId]: fetchedChapters,
const userIdOrEmpty: string = userId || '';
setChaptersByCourseIdByUserId(currentChaptersByCourseIdByUserId => ({
...currentChaptersByCourseIdByUserId,
[userIdOrEmpty]: {
...currentChaptersByCourseIdByUserId[userIdOrEmpty],
[courseId]: fetchedChapters,
},
}));
} catch (error) {
console.error(
@@ -153,7 +229,7 @@ const useCourses = () => {
);
}
},
[getAuthorizationHeader, userId, userLanguage2LetterCode]
[getAuthorizationHeader, userId, language]
);
const onCompleteTask = React.useCallback(
@@ -223,9 +299,12 @@ const useCourses = () => {
[userCourseProgress]
);
const chaptersByCourseId =
chaptersByCourseIdByUserId[userId || ''] || noCourseChapters;
const getChapterCompletion = React.useCallback(
(courseId: string, chapterId: string): CourseChapterCompletion | null => {
const chapters = chaptersByCourseId[courseId];
const chapters = chaptersByCourseId ? chaptersByCourseId[courseId] : null;
if (!chapters) return null;
const chapter = chapters.find(chapter => chapter.id === chapterId);
@@ -257,14 +336,14 @@ const useCourses = () => {
const course = courses.find(course => course.id === courseId);
if (!course) return null;
const chapters = chaptersByCourseId[courseId];
if (!chapters) return null;
const chaptersCount = course.chaptersTargetCount;
const courseProgress = userProgressByCourseId[courseId];
if (!courseProgress)
return { percentage: 0, completedChapters: 0, chapters: chaptersCount };
const chapters = chaptersByCourseId ? chaptersByCourseId[courseId] : null;
if (!chapters) return null;
let completion = 0;
let completedChapters = 0;
const chapterProportion = 1 / chaptersCount;
@@ -427,39 +506,67 @@ const useCourses = () => {
React.useEffect(
() => {
(async () => {
if (courses && loginState !== 'loggingIn') {
await Promise.all(
courses.map(course => fetchCourseChapters(course.id))
);
setAreChaptersReady(true);
if (userStatus !== 'ready') {
return;
}
console.info(`Fetching all courses for userId=${userId || 'null'}.`);
if (
userSubscriptionPlanId ||
userCoursePurchasesCount ||
userBundlePurchaseCount
) {
// Trigger a re-fetch of the courses when the user subscription changes,
// or when the user purchases a course or when the user logs in/out.
}
if (userId) {
const userIdOrEmpty: string = userId || '';
// we empty the chapters fetched for the user to ensure they are re-fetched
// and up-to-date (notably in case subscription changed or purchase count changed).
setChaptersByCourseIdByUserId(currentChaptersByCourseIdByUserId => ({
...currentChaptersByCourseIdByUserId,
[userIdOrEmpty]: noCourseChapters,
}));
}
await fetchCourses();
setAreCoursesFetched(true);
})();
},
// (Re)fetch course chapters when courses are refetched.
[courses, fetchCourseChapters, loginState]
[
fetchCourses,
userSubscriptionPlanId,
userCoursePurchasesCount,
userBundlePurchaseCount,
userId,
userStatus,
]
);
React.useEffect(
() => {
(async () => {
if (subscription || coursePurchases) {
// Just to trigger a re-fetch of the courses when the user subscription changes,
// or when the user purchases a course.
}
if (loginState !== 'loggingIn') {
await fetchCourses();
}
})();
// This callback will change (triggering re-renders)
// anytime the chapters are fetched for a course for a user.
const getCourseChapters = React.useCallback(
(courseId: string) => {
if (chaptersByCourseId[courseId] !== undefined) {
return chaptersByCourseId[courseId];
}
// Chapter is not loaded yet, fetch it. Note that we could store
// the promise to avoid fetching it multiple times.
fetchCourseChapters(courseId);
return null;
},
[fetchCourses, subscription, coursePurchases, loginState]
[chaptersByCourseId, fetchCourseChapters]
);
const selectedCourse = React.useMemo(
() => {
if (!selectedCourseId || !courses || !areChaptersReady) return null;
if (!selectedCourseId || !courses) return null;
return courses.find(course => course.id === selectedCourseId) || null;
},
[selectedCourseId, courses, areChaptersReady]
[selectedCourseId, courses]
);
return {
@@ -467,8 +574,8 @@ const useCourses = () => {
fetchCourses,
onSelectCourse,
selectedCourse,
courseChaptersByCourseId: chaptersByCourseId,
areChaptersReady,
getCourseChapters,
areCoursesFetched,
onCompleteTask,
isTaskCompleted,
getChapterCompletion,

View File

@@ -41,6 +41,8 @@ import { type GamesPlatformFrameTools } from './PlaySection/UseGamesPlatformFram
import { type CourseChapter } from '../../../Utils/GDevelopServices/Asset';
import useCourses from './UseCourses';
import PreferencesContext from '../../Preferences/PreferencesContext';
import useSubscriptionPlans from '../../../Utils/UseSubscriptionPlans';
import { BundleStoreContext } from '../../../AssetStore/Bundles/BundleStoreContext';
const getRequestedTab = (routeArguments: RouteArguments): HomeTab | null => {
if (
@@ -85,6 +87,7 @@ const styles = {
},
scrollableContainer: {
display: 'flex',
position: 'relative',
marginLeft: 0,
marginRight: 0,
flexDirection: 'column',
@@ -104,7 +107,7 @@ type Props = {|
projectItemName: ?string,
project: ?gdProject,
setToolbar: (?React.Node) => void,
hideTabsTitleBarAndEditorToolbar: (hidden: boolean) => void,
setGamesPlatformFrameShown: ({| shown: boolean, isMobile: boolean |}) => void,
storageProviders: Array<StorageProvider>,
// Games
@@ -132,7 +135,11 @@ type Props = {|
selectInAppTutorial: (tutorialId: string) => void,
onOpenPreferences: () => void,
onOpenAbout: () => void,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
onOpenAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
// Project creation
onOpenNewProjectSetupDialog: () => void,
@@ -191,7 +198,7 @@ export const HomePage = React.memo<Props>(
onOpenProfile,
onCreateProjectFromExample,
setToolbar,
hideTabsTitleBarAndEditorToolbar,
setGamesPlatformFrameShown,
selectInAppTutorial,
onOpenPreferences,
onOpenAbout,
@@ -253,9 +260,9 @@ export const HomePage = React.memo<Props>(
const {
courses,
selectedCourse,
courseChaptersByCourseId,
getCourseChapters,
onSelectCourse,
areChaptersReady,
areCoursesFetched,
onCompleteTask,
isTaskCompleted,
getChapterCompletion,
@@ -268,6 +275,10 @@ export const HomePage = React.memo<Props>(
const [learnCategory, setLearnCategory] = React.useState<LearnCategory>(
null
);
const { getSubscriptionPlansWithPricingSystems } = useSubscriptionPlans({
authenticatedUser,
includeLegacy: false,
});
const { isMobile } = useResponsiveWindowSize();
const {
@@ -287,6 +298,10 @@ export const HomePage = React.memo<Props>(
const { setInitialPackUserFriendlySlug } = React.useContext(
AssetStoreContext
);
const {
fetchBundles,
shop: { setInitialBundleUserFriendlySlug },
} = React.useContext(BundleStoreContext);
const openedGame = React.useMemo(
() =>
!openedGameId || !games
@@ -314,8 +329,11 @@ export const HomePage = React.memo<Props>(
routeArguments['game-template']
);
}
if (routeArguments['bundle']) {
setInitialBundleUserFriendlySlug(routeArguments['bundle']);
}
// Remove the arguments so that the asset store is not opened again.
removeRouteArguments(['asset-pack', 'game-template']);
removeRouteArguments(['asset-pack', 'game-template', 'bundle']);
} else if (requestedTab === 'manage') {
const gameId = routeArguments['game-id'];
if (gameId) {
@@ -332,7 +350,7 @@ export const HomePage = React.memo<Props>(
}
} else if (requestedTab === 'learn') {
const courseId = routeArguments['course-id'];
if (!areChaptersReady) {
if (!areCoursesFetched) {
// Do not process requested tab before courses are ready.
return;
}
@@ -348,8 +366,9 @@ export const HomePage = React.memo<Props>(
removeRouteArguments,
setInitialPackUserFriendlySlug,
setInitialGameTemplateUserFriendlySlug,
setInitialBundleUserFriendlySlug,
games,
areChaptersReady,
areCoursesFetched,
]
);
@@ -359,8 +378,14 @@ export const HomePage = React.memo<Props>(
fetchExamplesAndFilters();
fetchGameTemplates();
fetchTutorials();
fetchBundles();
},
[fetchExamplesAndFilters, fetchTutorials, fetchGameTemplates]
[
fetchExamplesAndFilters,
fetchTutorials,
fetchGameTemplates,
fetchBundles,
]
);
// Fetch user cloud projects when home page becomes active
@@ -420,19 +445,19 @@ export const HomePage = React.memo<Props>(
React.useLayoutEffect(
() => {
// Hide the toolbars when on mobile in the "play" tab.
if (activeTab === 'play' && isMobile) {
hideTabsTitleBarAndEditorToolbar(true);
if (activeTab === 'play') {
setGamesPlatformFrameShown({ shown: true, isMobile });
} else {
hideTabsTitleBarAndEditorToolbar(false);
setGamesPlatformFrameShown({ shown: false, isMobile });
updateToolbar();
}
// Ensure we show it again when the tab changes.
return () => {
hideTabsTitleBarAndEditorToolbar(false);
setGamesPlatformFrameShown({ shown: false, isMobile });
};
},
[updateToolbar, activeTab, hideTabsTitleBarAndEditorToolbar, isMobile]
[updateToolbar, activeTab, setGamesPlatformFrameShown, isMobile]
);
const forceUpdateEditor = React.useCallback(() => {
@@ -542,7 +567,6 @@ export const HomePage = React.memo<Props>(
)}
{activeTab === 'learn' && (
<LearnSection
onTabChange={setActiveTab}
selectInAppTutorial={selectInAppTutorial}
onOpenTemplateFromTutorial={onOpenTemplateFromTutorial}
onOpenTemplateFromCourseChapter={
@@ -553,17 +577,8 @@ export const HomePage = React.memo<Props>(
onSelectCourse={onSelectCourse}
courses={courses}
previewedCourse={premiumCourse}
previewedCourseChapters={
premiumCourse
? courseChaptersByCourseId[premiumCourse.id]
: null
}
course={selectedCourse}
courseChapters={
selectedCourse
? courseChaptersByCourseId[selectedCourse.id]
: null
}
getCourseChapters={getCourseChapters}
onCompleteCourseTask={onCompleteTask}
isCourseTaskCompleted={isTaskCompleted}
getCourseChapterCompletion={getChapterCompletion}
@@ -580,6 +595,14 @@ export const HomePage = React.memo<Props>(
onSelectPrivateGameTemplateListingData
}
onSelectExampleShortHeader={onSelectExampleShortHeader}
getSubscriptionPlansWithPricingSystems={
getSubscriptionPlansWithPricingSystems
}
receivedCourses={
courses
? courses.filter(course => !course.isLocked)
: undefined
}
/>
)}
{activeTab === 'play' && (
@@ -596,6 +619,15 @@ export const HomePage = React.memo<Props>(
}
onOpenProfile={onOpenProfile}
onExtensionInstalled={onExtensionInstalled}
onCourseOpen={(courseId: string) => {
onSelectCourse(courseId);
setActiveTab('learn');
}}
receivedCourses={
courses
? courses.filter(course => !course.isLocked)
: undefined
}
/>
)}
{activeTab === 'team-view' &&
@@ -650,7 +682,7 @@ export const renderHomePageContainer = (
isActive={props.isActive}
projectItemName={props.projectItemName}
setToolbar={props.setToolbar}
hideTabsTitleBarAndEditorToolbar={props.hideTabsTitleBarAndEditorToolbar}
setGamesPlatformFrameShown={props.setGamesPlatformFrameShown}
canOpen={props.canOpen}
onChooseProject={props.onChooseProject}
onOpenRecentFile={props.onOpenRecentFile}

View File

@@ -4,13 +4,7 @@ import * as React from 'react';
import { makeDragSourceAndDropTarget } from '../../UI/DragAndDrop/DragSourceAndDropTarget';
import { ScreenTypeMeasurer } from '../../UI/Responsive/ScreenTypeMeasurer';
import { ColumnDropIndicator } from './DropIndicator';
import {
type EditorTabsState,
type EditorTab,
getEditors,
getCurrentTabIndex,
getCurrentTab,
} from './EditorTabsHandler';
import { type EditorTab } from './EditorTabsHandler';
import {
ClosableTabs,
ClosableTab,
@@ -26,7 +20,8 @@ const DragSourceAndDropTarget = makeDragSourceAndDropTarget<EditorTab>(
type DraggableEditorTabsProps = {|
hideLabels?: boolean,
editorTabs: EditorTabsState,
editors: Array<EditorTab>,
currentTab: EditorTab | null,
onClickTab: (index: number) => void,
onCloseTab: (editor: EditorTab) => void,
onCloseOtherTabs: (editor: EditorTab) => void,
@@ -46,7 +41,8 @@ const homeTabApproximateWidth = 35;
export function DraggableEditorTabs({
hideLabels,
editorTabs,
editors,
currentTab,
onClickTab,
onCloseTab,
onCloseOtherTabs,
@@ -61,8 +57,6 @@ export function DraggableEditorTabs({
useOnResize(useForceUpdate());
const { windowSize } = useResponsiveWindowSize();
const currentTab = getCurrentTab(editorTabs);
React.useEffect(
() => {
if (!currentTab) return;
@@ -83,9 +77,11 @@ export function DraggableEditorTabs({
<ClosableTabs
hideLabels={hideLabels}
renderTabs={({ containerWidth }) => {
const editors = getEditors(editorTabs);
const hasHomeTab =
editors.length > 0 && editors[0].key === 'start page';
return editors.map((editorTab, id) => {
const isCurrentTab = getCurrentTabIndex(editorTabs) === id;
const isCurrentTab = currentTab === editorTab;
// Maximum width of a tab is the width so that all tabs can fit it,
// unless on a small screen, where we want to avoid compressing tabs too much
@@ -94,7 +90,10 @@ export function DraggableEditorTabs({
const maxWidth = Math.max(
minimumMaxWidth,
// The home tab is special because it's just an icon.
(containerWidth - homeTabApproximateWidth) / (editors.length - 1)
hasHomeTab
? (containerWidth - homeTabApproximateWidth) /
(editors.length - 1)
: containerWidth / editors.length
);
return (

View File

@@ -53,8 +53,12 @@ export type EditorTab = {|
|};
export type EditorTabsState = {|
editors: Array<EditorTab>,
currentTab: number,
panes: {
[paneIdentifier: string]: {|
editors: Array<EditorTab>,
currentTab: number,
|},
},
|};
export type EditorKind =
@@ -82,6 +86,7 @@ export type EditorTabsPersistedState = {|
|};
export type EditorOpeningOptions = {|
paneIdentifier: string,
label?: string,
icon?: React.Node,
renderCustomIcon?: ?(brightness: number) => React.Node,
@@ -126,8 +131,20 @@ export const getEditorTabMetadata = (
export const getEditorTabsInitialState = (): EditorTabsState => {
return {
editors: [],
currentTab: 0,
panes: {
left: {
editors: [],
currentTab: 0,
},
center: {
editors: [],
currentTab: 0,
},
right: {
editors: [],
currentTab: 0,
},
},
};
};
@@ -144,17 +161,28 @@ export const openEditorTab = (
extraEditorProps,
dontFocusTab,
closable,
paneIdentifier,
}: EditorOpeningOptions
): EditorTabsState => {
const existingEditorId = findIndex(
state.editors,
editor => editor.key === key
);
if (existingEditorId !== -1) {
return {
...state,
currentTab: dontFocusTab ? state.currentTab : existingEditorId,
};
for (const paneIdentifier in state.panes) {
const pane = state.panes[paneIdentifier];
const existingEditorId = findIndex(
pane.editors,
editor => editor.key === key
);
if (existingEditorId !== -1) {
return {
...state,
panes: {
...state.panes,
[paneIdentifier]: {
...pane,
currentTab: dontFocusTab ? pane.currentTab : existingEditorId,
},
},
};
}
}
const editorTab: EditorTab = {
@@ -170,46 +198,82 @@ export const openEditorTab = (
closable: typeof closable === 'undefined' ? true : !!closable,
};
const pane = state.panes[paneIdentifier];
if (!pane) {
throw new Error(`Pane with identifier "${paneIdentifier}" is not valid.`);
}
return {
...state,
editors:
// Make sure the home page is always the first tab.
key === 'start page'
? [editorTab, ...state.editors]
: [...state.editors, editorTab],
currentTab: dontFocusTab ? state.currentTab : state.editors.length,
panes: {
...state.panes,
[paneIdentifier]: {
...pane,
editors:
// Make sure the home page is always the first tab.
key === 'start page'
? [editorTab, ...pane.editors]
: [...pane.editors, editorTab],
currentTab: dontFocusTab ? pane.currentTab : pane.editors.length,
},
},
};
};
export const changeCurrentTab = (
state: EditorTabsState,
paneIdentifier: string,
newTabId: number
): EditorTabsState => {
const pane = state.panes[paneIdentifier];
if (!pane) {
throw new Error(`Pane with identifier "${paneIdentifier}" is not valid.`);
}
return {
...state,
currentTab: Math.max(0, Math.min(newTabId, state.editors.length - 1)),
panes: {
...state.panes,
[paneIdentifier]: {
...pane,
currentTab: Math.max(0, Math.min(newTabId, pane.editors.length - 1)),
},
},
};
};
export const isStartPageTabPresent = (state: EditorTabsState): boolean => {
return state.editors.some(editor => editor.key === 'start page');
return hasEditorTabOpenedWithKey(state, 'start page');
};
export const closeTabsExceptIf = (
state: EditorTabsState,
keepPredicate: (editorTab: EditorTab) => boolean
) => {
const currentEditorTab = getCurrentTab(state);
const remainingEditors = state.editors.filter(keepPredicate);
return changeCurrentTab(
{
...state,
editors: remainingEditors,
},
// Keep the focus on the current editor tab, or if it was closed
// go back to the first tab.
remainingEditors.indexOf(currentEditorTab) || 0
);
let newState = { ...state };
for (const paneIdentifier in state.panes) {
const pane = state.panes[paneIdentifier];
if (!pane) {
throw new Error(`Pane with identifier "${paneIdentifier}" is not valid.`);
}
const currentEditorTab = pane.editors[pane.currentTab] || null;
const paneRemainingEditors = pane.editors.filter(keepPredicate);
const currentEditorTabNewIndex = paneRemainingEditors.indexOf(
currentEditorTab
);
newState.panes[paneIdentifier] = {
...pane,
editors: paneRemainingEditors,
// Keep the focus on the current editor tab, or if it was closed
// go back to the first tab.
currentTab:
currentEditorTabNewIndex === -1 ? 0 : currentEditorTabNewIndex,
};
}
return newState;
};
export const closeAllEditorTabs = (state: EditorTabsState): EditorTabsState => {
@@ -233,16 +297,27 @@ export const closeOtherEditorTabs = (
);
};
export const getEditors = (state: EditorTabsState): Array<EditorTab> => {
return state.editors;
export const getEditorsForPane = (
state: EditorTabsState,
paneIdentifier: string
): Array<EditorTab> => {
return state.panes[paneIdentifier].editors || [];
};
export const getCurrentTabIndex = (state: EditorTabsState): number => {
return state.currentTab;
export const getCurrentTabIndexForPane = (
state: EditorTabsState,
paneIdentifier: string
): number => {
const pane = state.panes[paneIdentifier];
return pane.currentTab || 0;
};
export const getCurrentTab = (state: EditorTabsState): EditorTab => {
return state.editors[state.currentTab];
export const getCurrentTabForPane = (
state: EditorTabsState,
paneIdentifier: string
): EditorTab | null => {
const pane = state.panes[paneIdentifier];
return pane.editors[pane.currentTab] || null;
};
export const closeProjectTabs = (
@@ -261,15 +336,22 @@ export const closeProjectTabs = (
* to the project.
*/
export const saveUiSettings = (state: EditorTabsState) => {
state.editors.forEach(editorTab => {
if (
editorTab.editorRef &&
(editorTab.editorRef instanceof SceneEditorContainer ||
editorTab.editorRef instanceof ExternalLayoutEditorContainer)
) {
editorTab.editorRef.saveUiSettings();
for (const paneIdentifier in state.panes) {
const pane = state.panes[paneIdentifier];
if (!pane) {
continue;
}
});
pane.editors.forEach(editorTab => {
if (
editorTab.editorRef &&
(editorTab.editorRef instanceof SceneEditorContainer ||
editorTab.editorRef instanceof ExternalLayoutEditorContainer)
) {
editorTab.editorRef.saveUiSettings();
}
});
}
};
/**
@@ -277,13 +359,20 @@ export const saveUiSettings = (state: EditorTabsState) => {
* to editors with changes to commit them (like modified extensions).
*/
export const notifyPreviewOrExportWillStart = (state: EditorTabsState) => {
state.editors.forEach(editorTab => {
const editor = editorTab.editorRef;
if (editor instanceof EventsFunctionsExtensionEditorContainer) {
editor.previewOrExportWillStart();
for (const paneIdentifier in state.panes) {
const pane = state.panes[paneIdentifier];
if (!pane) {
continue;
}
});
pane.editors.forEach(editorTab => {
const editor = editorTab.editorRef;
if (editor instanceof EventsFunctionsExtensionEditorContainer) {
editor.previewOrExportWillStart();
}
});
}
};
export const closeLayoutTabs = (state: EditorTabsState, layout: gdLayout) => {
@@ -405,14 +494,21 @@ export const closeEventsBasedObjectVariantTab = (
export const getEventsFunctionsExtensionEditor = (
state: EditorTabsState,
eventsFunctionsExtension: gdEventsFunctionsExtension
): ?{| editor: EventsFunctionsExtensionEditorContainer, tabIndex: number |} => {
for (let tabIndex = 0; tabIndex < state.editors.length; ++tabIndex) {
const editor = state.editors[tabIndex].editorRef;
if (
editor instanceof EventsFunctionsExtensionEditorContainer &&
editor.getEventsFunctionsExtension() === eventsFunctionsExtension
) {
return { editor, tabIndex };
): ?{|
editor: EventsFunctionsExtensionEditorContainer,
paneIdentifier: string,
tabIndex: number,
|} => {
for (const paneIdentifier in state.panes) {
const pane = state.panes[paneIdentifier];
for (let tabIndex = 0; tabIndex < pane.editors.length; ++tabIndex) {
const editor = pane.editors[tabIndex].editorRef;
if (
editor instanceof EventsFunctionsExtensionEditorContainer &&
editor.getEventsFunctionsExtension() === eventsFunctionsExtension
) {
return { editor, paneIdentifier, tabIndex };
}
}
}
@@ -424,16 +520,23 @@ export const getCustomObjectEditor = (
eventsFunctionsExtension: gdEventsFunctionsExtension,
eventsBasedObject: gdEventsBasedObject,
variantName: string
): ?{| editor: CustomObjectEditorContainer, tabIndex: number |} => {
for (let tabIndex = 0; tabIndex < state.editors.length; ++tabIndex) {
const editor = state.editors[tabIndex].editorRef;
if (
editor instanceof CustomObjectEditorContainer &&
editor.getEventsFunctionsExtension() === eventsFunctionsExtension &&
editor.getEventsBasedObject() === eventsBasedObject &&
editor.getVariantName() === variantName
) {
return { editor, tabIndex };
): ?{|
editor: CustomObjectEditorContainer,
paneIdentifier: string,
tabIndex: number,
|} => {
for (const paneIdentifier in state.panes) {
const pane = state.panes[paneIdentifier];
for (let tabIndex = 0; tabIndex < pane.editors.length; ++tabIndex) {
const editor = pane.editors[tabIndex].editorRef;
if (
editor instanceof CustomObjectEditorContainer &&
editor.getEventsFunctionsExtension() === eventsFunctionsExtension &&
editor.getEventsBasedObject() === eventsBasedObject &&
editor.getVariantName() === variantName
) {
return { editor, paneIdentifier, tabIndex };
}
}
}
@@ -442,6 +545,7 @@ export const getCustomObjectEditor = (
export const moveTabToTheRightOfHoveredTab = (
editorTabsState: EditorTabsState,
paneIdentifier: string,
movingTabIndex: number,
hoveredTabIndex: number
): EditorTabsState => {
@@ -450,21 +554,32 @@ export const moveTabToTheRightOfHoveredTab = (
const destinationIndex =
movingTabIndex > hoveredTabIndex ? hoveredTabIndex + 1 : hoveredTabIndex;
return moveTabToPosition(editorTabsState, movingTabIndex, destinationIndex);
return moveTabToPosition(
editorTabsState,
paneIdentifier,
movingTabIndex,
destinationIndex
);
};
export const moveTabToPosition = (
editorTabsState: EditorTabsState,
paneIdentifier: string,
fromIndex: number,
toIndex: number
): EditorTabsState => {
const currentEditorTabs = [...getEditors(editorTabsState)];
const movingTab = currentEditorTabs[fromIndex];
currentEditorTabs.splice(fromIndex, 1);
currentEditorTabs.splice(toIndex, 0, movingTab);
const paneNewEditorTabs = [
...getEditorsForPane(editorTabsState, paneIdentifier),
];
const movingTab = paneNewEditorTabs[fromIndex];
paneNewEditorTabs.splice(fromIndex, 1);
paneNewEditorTabs.splice(toIndex, 0, movingTab);
let currentTabIndex = getCurrentTabIndex(editorTabsState);
let currentTabNewIndex = currentTabIndex;
let currentTabIndex = getCurrentTabIndexForPane(
editorTabsState,
paneIdentifier
);
let paneNewCurrentTab = currentTabIndex;
const movingTabIsCurrentTab = fromIndex === currentTabIndex;
const tabIsMovedFromLeftToRightOfCurrentTab =
@@ -472,25 +587,86 @@ export const moveTabToPosition = (
const tabIsMovedFromRightToLeftOfCurrentTab =
fromIndex > currentTabIndex && toIndex <= currentTabIndex;
if (movingTabIsCurrentTab) currentTabNewIndex = toIndex;
else if (tabIsMovedFromLeftToRightOfCurrentTab) currentTabNewIndex -= 1;
else if (tabIsMovedFromRightToLeftOfCurrentTab) currentTabNewIndex += 1;
if (movingTabIsCurrentTab) paneNewCurrentTab = toIndex;
else if (tabIsMovedFromLeftToRightOfCurrentTab) paneNewCurrentTab -= 1;
else if (tabIsMovedFromRightToLeftOfCurrentTab) paneNewCurrentTab += 1;
return { editors: currentEditorTabs, currentTab: currentTabNewIndex };
return {
...editorTabsState,
panes: {
...editorTabsState.panes,
[paneIdentifier]: {
...editorTabsState.panes[paneIdentifier],
editors: paneNewEditorTabs,
currentTab: paneNewCurrentTab,
},
},
};
};
export const hasEditorTabOpenedWithKey = (
export const getEditorTabOpenedWithKey = (
editorTabsState: EditorTabsState,
key: string
) => {
return !!editorTabsState.editors.find(editor => editor.key === key);
): {|
paneIdentifier: string,
editorTab: EditorTab,
|} | null => {
for (const paneIdentifier in editorTabsState.panes) {
const pane = editorTabsState.panes[paneIdentifier];
const editorTab = pane && pane.editors.find(editor => editor.key === key);
if (editorTab) {
return { editorTab, paneIdentifier };
}
}
return null;
};
const hasEditorTabOpenedWithKey = (
editorTabsState: EditorTabsState,
key: string
): boolean => {
return getEditorTabOpenedWithKey(editorTabsState, key) !== null;
};
export const getOpenedAskAiEditor = (
state: EditorTabsState
): AskAiEditorInterface | null => {
const editor = state.editors.find(editor => editor.key === 'ask-ai');
): null | {|
askAiEditor: AskAiEditorInterface,
editorTab: EditorTab,
paneIdentifier: string,
|} => {
const currentEditorTabAndPaneIdentifier = getEditorTabOpenedWithKey(
state,
'ask-ai'
);
if (!currentEditorTabAndPaneIdentifier) return null;
// $FlowFixMe - the key ensures that the editor is an AskAiEditorInterface.
return (editor && editor.editorRef) || null;
return {
// $FlowFixMe - the key ensures that the editor is an AskAiEditorInterface.
askAiEditor: currentEditorTabAndPaneIdentifier.editorTab.editorRef,
editorTab: currentEditorTabAndPaneIdentifier.editorTab,
paneIdentifier: currentEditorTabAndPaneIdentifier.paneIdentifier,
};
};
export const getAllEditorTabs = (state: EditorTabsState): Array<EditorTab> => {
const allEditors = [];
for (const paneIdentifier in state.panes) {
const pane = state.panes[paneIdentifier];
allEditors.push(...pane.editors);
}
return allEditors;
};
export const hasEditorsInPane = (
state: EditorTabsState,
paneIdentifier: string
): boolean => {
const pane = state.panes[paneIdentifier];
if (!pane) {
return false;
}
return pane.editors.length > 0;
};

View File

@@ -23,7 +23,9 @@ type Props = {|
name: string,
dontFocusTab?: boolean,
project?: ?gdProject,
paneIdentifier?: 'left' | 'center' | 'right' | null,
mode?: 'chat' | 'agent',
aiRequestId?: string | null,
|}) => EditorOpeningOptions,
|};
@@ -77,11 +79,12 @@ const useEditorTabsStateSaving = ({
} = React.useContext(PreferencesContext);
const saveEditorState = React.useCallback(
() => {
// TODO: adapt for saving multiple panes.
// Do not save the state if the user is on the start page
if (!currentProjectId || editorTabs.currentTab === 0) return;
if (!currentProjectId || editorTabs.panes.center.currentTab === 0) return;
const editorState = {
currentTab: editorTabs.currentTab,
editors: editorTabs.editors
currentTab: editorTabs.panes.center.currentTab,
editors: editorTabs.panes.center.editors
.filter(editor => editor.key !== 'start page')
.map(getEditorTabMetadata),
};
@@ -175,9 +178,10 @@ const useEditorTabsStateSaving = ({
}
newEditorTabs = changeCurrentTab(
newEditorTabs,
'center',
shouldOpenSavedCurrentTab
? editorState.editorTabs.currentTab
: newEditorTabs.editors.length >= 1
: newEditorTabs.panes.center.editors.length >= 1
? 1
: 0
);

View File

@@ -0,0 +1,699 @@
// @flow
import * as React from 'react';
import { type I18n as I18nType } from '@lingui/core';
import TabsTitlebar from './TabsTitlebar';
import Toolbar, { type ToolbarInterface } from './Toolbar';
import { TabContentContainer } from '../UI/ClosableTabs';
import { DraggableEditorTabs } from './EditorTabs/DraggableEditorTabs';
import CommandsContextScopedProvider from '../CommandPalette/CommandsScopedContext';
import ErrorBoundary, {
getEditorErrorBoundaryProps,
} from '../UI/ErrorBoundary';
import {
getEditorsForPane,
getCurrentTabIndexForPane,
getCurrentTabForPane,
type EditorTabsState,
type EditorTab,
getEditorTabOpenedWithKey,
changeCurrentTab,
closeEditorTab,
closeOtherEditorTabs,
closeAllEditorTabs,
moveTabToTheRightOfHoveredTab,
saveUiSettings,
} from './EditorTabs/EditorTabsHandler';
import { type PreviewState } from './PreviewState';
import { type SceneEventsOutsideEditorChanges } from './EditorContainers/BaseEditor';
import { type ResourceManagementProps } from '../ResourcesList/ResourceSource';
import { type HotReloadPreviewButtonProps } from '../HotReload/HotReloadPreviewButton';
import { type GamesList } from '../GameDashboard/UseGamesList';
import { type GamesPlatformFrameTools } from './EditorContainers/HomePage/PlaySection/UseGamesPlatformFrame';
import {
type FileMetadata,
type FileMetadataAndStorageProviderName,
} from '../ProjectsStorage';
import UnsavedChangesContext from './UnsavedChangesContext';
import { type OpenedVersionStatus } from '../VersionHistory';
import { type StorageProvider } from '../ProjectsStorage';
import { type ExampleShortHeader } from '../Utils/GDevelopServices/Example';
import { type PrivateGameTemplateListingData } from '../Utils/GDevelopServices/Shop';
import { type CourseChapter } from '../Utils/GDevelopServices/Asset';
import { type NewProjectSetup } from '../ProjectCreation/NewProjectSetupDialog';
import { type EventsFunctionsExtensionsState } from '../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
import { type ObjectWithContext } from '../ObjectsList/EnumerateObjects';
import { type ShareTab } from '../ExportAndShare/ShareDialog';
import { SpecificDimensionsWindowSizeProvider } from '../UI/Responsive/ResponsiveWindowMeasurer';
import { MuiThemeOnlyProvider } from '../UI/Theme/FullThemeProvider';
import useForceUpdate from '../Utils/UseForceUpdate';
import useOnResize from '../Utils/UseOnResize';
import DrawerTopBar from '../UI/DrawerTopBar';
import { type FloatingPaneState } from './PanesContainer';
const styles = {
container: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
minWidth: 0,
},
};
export type EditorTabsPaneCommonProps = {|
editorTabs: EditorTabsState,
currentProject: ?gdProject,
currentFileMetadata: ?FileMetadata,
canSave: boolean,
isSavingProject: boolean,
isSharingEnabled: boolean,
hasPreviewsRunning: boolean,
previewState: PreviewState,
checkedOutVersionStatus: ?OpenedVersionStatus,
canDoNetworkPreview: boolean,
gamesPlatformFrameTools: GamesPlatformFrameTools,
// Callbacks from MainFrame
toggleProjectManager: () => void,
saveProject: () => Promise<void>,
openShareDialog: (tab?: ShareTab) => void,
launchDebuggerAndPreview: () => void,
launchNewPreview: (?{ numberOfWindows: number }) => Promise<void>,
launchNetworkPreview: () => Promise<void>,
launchHotReloadPreview: () => Promise<void>,
launchPreviewWithDiagnosticReport: () => Promise<void>,
setPreviewOverride: (override: {|
isPreviewOverriden: boolean,
overridenPreviewLayoutName: ?string,
overridenPreviewExternalLayoutName: ?string,
|}) => void,
openVersionHistoryPanel: () => void,
onQuitVersionHistory: () => Promise<void>,
onOpenAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
getStorageProvider: () => StorageProvider,
setPreviewedLayout: (layoutName: ?string) => void,
openExternalEvents: (name: string) => void,
openLayout: (
name: string,
options?: {|
openEventsEditor: boolean,
openSceneEditor: boolean,
focusWhenOpened:
| 'scene-or-events-otherwise'
| 'scene'
| 'events'
| 'none',
|}
) => void,
openTemplateFromTutorial: (tutorialId: string) => Promise<void>,
openTemplateFromCourseChapter: (
courseChapter: CourseChapter,
templateId?: string
) => Promise<void>,
previewDebuggerServer: ?any,
hotReloadPreviewButtonProps: HotReloadPreviewButtonProps,
resourceManagementProps: ResourceManagementProps,
onCreateEventsFunction: (
extensionName: string,
eventsFunction: gdEventsFunction,
editorIdentifier:
| 'scene-events-editor'
| 'extension-events-editor'
| 'external-events-editor'
) => void,
openInstructionOrExpression: (
extension: gdPlatformExtension,
type: string
) => void,
onOpenCustomObjectEditor: (
eventsFunctionsExtension: gdEventsFunctionsExtension,
eventsBasedObject: gdEventsBasedObject,
variantName: string
) => void,
onRenamedEventsBasedObject: (
eventsFunctionsExtension: gdEventsFunctionsExtension,
oldName: string,
newName: string
) => void,
onDeletedEventsBasedObject: (
eventsFunctionsExtension: gdEventsFunctionsExtension,
name: string
) => void,
openObjectEvents: (extensionName: string, objectName: string) => void,
canOpen: boolean,
openOpenFromStorageProviderDialog: () => void,
openFromFileMetadataWithStorageProvider: (
file: FileMetadataAndStorageProviderName
) => Promise<void>,
openNewProjectDialog: () => void,
openProjectManager: (open: boolean) => void,
askToCloseProject: () => Promise<boolean>,
closeProject: () => Promise<void>,
onSelectExampleShortHeader: ({|
exampleShortHeader: ?ExampleShortHeader,
preventBackHome?: boolean,
|}) => void,
onSelectPrivateGameTemplateListingData: ({|
privateGameTemplateListingData: ?PrivateGameTemplateListingData,
preventBackHome?: boolean,
|}) => void,
createEmptyProject: (newProjectSetup: NewProjectSetup) => Promise<void>,
createProjectFromExample: (
exampleShortHeader: ExampleShortHeader,
newProjectSetup: NewProjectSetup,
i18n: I18nType,
isQuickCustomization?: boolean
) => Promise<void>,
onOpenProfileDialog: () => void,
openLanguageDialog: (open: boolean) => void,
openPreferencesDialog: (open: boolean) => void,
openAboutDialog: (open: boolean) => void,
selectInAppTutorial: (tutorialId: string) => void,
eventsFunctionsExtensionsState: EventsFunctionsExtensionsState,
isProjectClosedSoAvoidReloadingExtensions: boolean,
renameResourcesInProject: (
project: gdProject,
renames: { [string]: string }
) => void,
openBehaviorEvents: (extensionName: string, behaviorName: string) => void,
onExtractAsExternalLayout: (name: string) => void,
onOpenEventBasedObjectEditor: (
extensionName: string,
eventsBasedObjectName: string
) => void,
onOpenEventBasedObjectVariantEditor: (
extensionName: string,
eventsBasedObjectName: string,
variantName: string
) => void,
deleteEventsBasedObjectVariant: (
eventsFunctionsExtension: gdEventsFunctionsExtension,
eventsBasedObject: gdEventsBasedObject,
variant: gdEventsBasedObjectVariant
) => void,
onEventsBasedObjectChildrenEdited: (
eventsBasedObject: gdEventsBasedObject
) => void,
onSceneObjectEdited: (
scene: gdLayout,
objectWithContext: ObjectWithContext
) => void,
onSceneObjectsDeleted: (scene: gdLayout) => void,
onSceneEventsModifiedOutsideEditor: (
changes: SceneEventsOutsideEditorChanges
) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
gamesList: GamesList,
setEditorTabs: (editorTabs: EditorTabsState) => void,
|};
type Props = {|
...EditorTabsPaneCommonProps,
onSetPointerEventsNone: (enablePointerEventsNone: boolean) => void,
paneIdentifier: string,
isLeftMostPane: boolean,
isRightMostPane: boolean,
isDrawer: boolean,
areSidePanesDrawers: boolean,
onSetPaneDrawerState: (
paneIdentifier: string,
newState: FloatingPaneState
) => void,
|};
const EditorTabsPane = React.forwardRef<Props, {||}>((props, ref) => {
const {
editorTabs,
currentProject,
currentFileMetadata,
canSave,
isSavingProject,
isSharingEnabled,
hasPreviewsRunning,
previewState,
checkedOutVersionStatus,
canDoNetworkPreview,
gamesPlatformFrameTools,
toggleProjectManager,
saveProject,
openShareDialog,
launchDebuggerAndPreview,
launchNewPreview,
launchNetworkPreview,
launchHotReloadPreview,
launchPreviewWithDiagnosticReport,
setPreviewOverride,
openVersionHistoryPanel,
onQuitVersionHistory,
onOpenAskAi,
getStorageProvider,
setPreviewedLayout,
openExternalEvents,
openLayout,
openTemplateFromTutorial,
openTemplateFromCourseChapter,
previewDebuggerServer,
hotReloadPreviewButtonProps,
resourceManagementProps,
onCreateEventsFunction,
openInstructionOrExpression,
onOpenCustomObjectEditor,
onRenamedEventsBasedObject,
onDeletedEventsBasedObject,
openObjectEvents,
canOpen,
openOpenFromStorageProviderDialog,
openFromFileMetadataWithStorageProvider,
openNewProjectDialog,
openProjectManager,
askToCloseProject,
closeProject,
onSelectExampleShortHeader,
onSelectPrivateGameTemplateListingData,
createEmptyProject,
createProjectFromExample,
onOpenProfileDialog,
openLanguageDialog,
openPreferencesDialog,
openAboutDialog,
selectInAppTutorial,
eventsFunctionsExtensionsState,
isProjectClosedSoAvoidReloadingExtensions,
renameResourcesInProject,
openBehaviorEvents,
onExtractAsExternalLayout,
onOpenEventBasedObjectEditor,
onOpenEventBasedObjectVariantEditor,
deleteEventsBasedObjectVariant,
onEventsBasedObjectChildrenEdited,
onSceneObjectEdited,
onSceneObjectsDeleted,
onSceneEventsModifiedOutsideEditor,
onExtensionInstalled,
gamesList,
setEditorTabs,
onSetPointerEventsNone,
paneIdentifier,
isLeftMostPane,
isRightMostPane,
isDrawer,
onSetPaneDrawerState,
areSidePanesDrawers,
} = props;
const toolbarRef = React.useRef<?ToolbarInterface>(null);
const unsavedChanges = React.useContext(UnsavedChangesContext);
const askAiPaneIdentifier = getEditorTabOpenedWithKey(editorTabs, 'ask-ai');
const containerRef = React.useRef<?HTMLDivElement>(null);
const [
tabsTitleBarAndEditorToolbarHidden,
setTabsTitleBarAndEditorToolbarHidden,
] = React.useState(false);
const onSetGamesPlatformFrameShown = React.useCallback(
({ shown, isMobile }: {| shown: boolean, isMobile: boolean |}) => {
onSetPointerEventsNone(shown);
setTabsTitleBarAndEditorToolbarHidden(shown && isMobile);
},
[onSetPointerEventsNone]
);
// Internal editor toolbar management
const setEditorToolbar = React.useCallback(
(editorToolbar: ?React.Node, isCurrentTab: boolean = true) => {
if (!toolbarRef.current || !isCurrentTab) return;
toolbarRef.current.setEditorToolbar(editorToolbar || null);
},
[]
);
const updateToolbar = React.useCallback(
() => {
const editorTab = getCurrentTabForPane(editorTabs, paneIdentifier);
if (!editorTab || !editorTab.editorRef) {
setEditorToolbar(null);
return;
}
editorTab.editorRef.updateToolbar();
},
[editorTabs, setEditorToolbar, paneIdentifier]
);
React.useEffect(
() => {
updateToolbar();
},
[updateToolbar]
);
// Tab management functions
const onEditorTabActivated = React.useCallback(
(editorTab: EditorTab) => {
updateToolbar();
// Ensure the editors shown on the screen are updated. This is for
// example useful if global objects have been updated in another editor.
if (editorTab.editorRef) {
editorTab.editorRef.forceUpdateEditor();
}
},
[updateToolbar]
);
const onChangeEditorTab = React.useCallback(
(value: number) => {
const newEditorTabs = changeCurrentTab(editorTabs, paneIdentifier, value);
setEditorTabs(newEditorTabs);
const newCurrentTab = getCurrentTabForPane(newEditorTabs, paneIdentifier);
if (newCurrentTab) {
onEditorTabActivated(newCurrentTab);
}
},
[editorTabs, setEditorTabs, onEditorTabActivated, paneIdentifier]
);
const onCloseEditorTab = React.useCallback(
(editorTab: EditorTab) => {
saveUiSettings(editorTabs);
setEditorTabs(closeEditorTab(editorTabs, editorTab));
},
[editorTabs, setEditorTabs]
);
const onCloseOtherEditorTabs = React.useCallback(
(editorTab: EditorTab) => {
saveUiSettings(editorTabs);
setEditorTabs(closeOtherEditorTabs(editorTabs, editorTab));
},
[editorTabs, setEditorTabs]
);
const onCloseAllEditorTabs = React.useCallback(
() => {
saveUiSettings(editorTabs);
setEditorTabs(closeAllEditorTabs(editorTabs));
},
[editorTabs, setEditorTabs]
);
const onDropEditorTab = React.useCallback(
(fromIndex: number, toHoveredIndex: number) => {
setEditorTabs(
moveTabToTheRightOfHoveredTab(
editorTabs,
paneIdentifier,
fromIndex,
toHoveredIndex
)
);
},
[editorTabs, paneIdentifier, setEditorTabs]
);
const paneEditorTabs = getEditorsForPane(editorTabs, paneIdentifier);
const currentTab = getCurrentTabForPane(editorTabs, paneIdentifier);
// Use a layout effect to read the pane width and height, which is then used
// to communicate to children editors the dimensions of their "window" (the pane).
// The layout effect ensures that we get the pane width and height after the pane has been rendered
// but before the browser repaints the component.
const [paneWidth, setPaneWidth] = React.useState<number | null>(null);
const [paneHeight, setPaneHeight] = React.useState<number | null>(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useLayoutEffect(() => {
const container = containerRef.current;
if (container) {
if (
container.clientWidth !== paneWidth ||
container.clientHeight !== paneHeight
) {
setPaneWidth(container.clientWidth);
setPaneHeight(container.clientHeight);
}
}
});
useOnResize(useForceUpdate()); // Ensure the pane is re-rendered when the window is resized.
const onOpenAskAiFromTitlebar = React.useCallback(
() => {
if (
askAiPaneIdentifier &&
askAiPaneIdentifier.paneIdentifier === 'right'
) {
onSetPaneDrawerState('right', 'open');
return;
}
onOpenAskAi({
mode: 'agent',
aiRequestId: null,
paneIdentifier: currentProject ? 'right' : 'center',
});
},
[askAiPaneIdentifier, onOpenAskAi, currentProject, onSetPaneDrawerState]
);
return (
<div style={styles.container} ref={containerRef}>
{isDrawer ? (
<DrawerTopBar
drawerAnchor={isRightMostPane ? 'right' : 'left'}
title={'Ask AI'}
id={paneIdentifier + '-top-bar'}
onClose={() => onSetPaneDrawerState(paneIdentifier, 'closed')}
/>
) : (
<TabsTitlebar
isLeftMostPane={isLeftMostPane}
isRightMostPane={isRightMostPane}
displayMenuIcon={paneIdentifier === 'center'}
hidden={tabsTitleBarAndEditorToolbarHidden}
toggleProjectManager={toggleProjectManager}
renderTabs={(onEditorTabHovered, onEditorTabClosing) => (
<DraggableEditorTabs
hideLabels={false}
editors={paneEditorTabs}
currentTab={currentTab}
onClickTab={onChangeEditorTab}
onCloseTab={(editorTab: EditorTab) => {
// Call onEditorTabClosing before to ensure any tooltip is removed before the tab is closed.
onEditorTabClosing();
onCloseEditorTab(editorTab);
}}
onCloseOtherTabs={(editorTab: EditorTab) => {
// Call onEditorTabClosing before to ensure any tooltip is removed before the tab is closed.
onEditorTabClosing();
onCloseOtherEditorTabs(editorTab);
}}
onCloseAll={() => {
// Call onEditorTabClosing before to ensure any tooltip is removed before the tab is closed.
onEditorTabClosing();
onCloseAllEditorTabs();
}}
onTabActivated={onEditorTabActivated}
onDropTab={onDropEditorTab}
onHoverTab={onEditorTabHovered}
/>
)}
displayAskAi={
!askAiPaneIdentifier
? // If Ask AI is closed, display the button on the right most part of the window.
isRightMostPane
: // If it's open, only show it if it's in a drawer pane.
areSidePanesDrawers &&
askAiPaneIdentifier.paneIdentifier !== 'center'
}
onAskAiClicked={onOpenAskAiFromTitlebar}
/>
)}
<Toolbar
ref={toolbarRef}
hidden={tabsTitleBarAndEditorToolbarHidden}
showProjectButtons={
!['start page', 'debugger', 'ask-ai', null].includes(
currentTab ? currentTab.key : null
)
}
canSave={canSave}
onSave={saveProject}
openShareDialog={() =>
openShareDialog(/* leave the dialog decide which tab to open */)
}
isSharingEnabled={isSharingEnabled}
onOpenDebugger={launchDebuggerAndPreview}
hasPreviewsRunning={hasPreviewsRunning}
onPreviewWithoutHotReload={launchNewPreview}
onNetworkPreview={launchNetworkPreview}
onHotReloadPreview={launchHotReloadPreview}
onLaunchPreviewWithDiagnosticReport={launchPreviewWithDiagnosticReport}
canDoNetworkPreview={canDoNetworkPreview}
setPreviewOverride={setPreviewOverride}
isPreviewEnabled={
!!currentProject && currentProject.getLayoutsCount() > 0
}
previewState={previewState}
onOpenVersionHistory={openVersionHistoryPanel}
checkedOutVersionStatus={checkedOutVersionStatus}
onQuitVersionHistory={onQuitVersionHistory}
canQuitVersionHistory={!isSavingProject}
/>
<SpecificDimensionsWindowSizeProvider
innerWidth={paneWidth}
innerHeight={paneHeight}
>
<MuiThemeOnlyProvider>
{paneEditorTabs.map((editorTab, id) => {
const isCurrentTab =
getCurrentTabIndexForPane(editorTabs, paneIdentifier) === id;
const errorBoundaryProps = getEditorErrorBoundaryProps(
editorTab.key
);
return (
<TabContentContainer key={editorTab.key} active={isCurrentTab}>
<CommandsContextScopedProvider active={isCurrentTab}>
<ErrorBoundary
componentTitle={errorBoundaryProps.componentTitle}
scope={errorBoundaryProps.scope}
>
{editorTab.renderEditorContainer({
isActive: isCurrentTab,
extraEditorProps: editorTab.extraEditorProps,
project: currentProject,
fileMetadata: currentFileMetadata,
storageProvider: getStorageProvider(),
ref: editorRef => (editorTab.editorRef = editorRef),
setToolbar: editorToolbar =>
setEditorToolbar(editorToolbar, isCurrentTab),
setGamesPlatformFrameShown: onSetGamesPlatformFrameShown,
projectItemName: editorTab.projectItemName,
setPreviewedLayout,
onOpenAskAi,
onOpenExternalEvents: openExternalEvents,
onOpenEvents: (sceneName: string) => {
openLayout(sceneName, {
openEventsEditor: true,
openSceneEditor: false,
focusWhenOpened: 'events',
});
},
onOpenLayout: openLayout,
onOpenTemplateFromTutorial: openTemplateFromTutorial,
onOpenTemplateFromCourseChapter: openTemplateFromCourseChapter,
previewDebuggerServer,
hotReloadPreviewButtonProps,
resourceManagementProps,
onSave: saveProject,
canSave,
onCreateEventsFunction,
openInstructionOrExpression,
onOpenCustomObjectEditor: onOpenCustomObjectEditor,
onRenamedEventsBasedObject: onRenamedEventsBasedObject,
onDeletedEventsBasedObject: onDeletedEventsBasedObject,
openObjectEvents,
unsavedChanges: unsavedChanges,
canOpen,
onChooseProject: () =>
openOpenFromStorageProviderDialog(),
onOpenRecentFile: openFromFileMetadataWithStorageProvider,
onOpenNewProjectSetupDialog: openNewProjectDialog,
onOpenProjectManager: () => openProjectManager(true),
onOpenVersionHistory: openVersionHistoryPanel,
askToCloseProject,
closeProject,
onSelectExampleShortHeader: exampleShortHeader => {
onSelectExampleShortHeader({
exampleShortHeader,
preventBackHome: true,
});
},
onSelectPrivateGameTemplateListingData: privateGameTemplateListingData => {
onSelectPrivateGameTemplateListingData({
privateGameTemplateListingData,
preventBackHome: true,
});
},
onOpenPrivateGameTemplateListingData: privateGameTemplateListingData => {
onSelectPrivateGameTemplateListingData({
privateGameTemplateListingData,
preventBackHome: true,
});
},
onCreateEmptyProject: createEmptyProject,
onCreateProjectFromExample: createProjectFromExample,
onOpenProfile: onOpenProfileDialog,
onOpenLanguageDialog: () => openLanguageDialog(true),
onOpenPreferences: () => openPreferencesDialog(true),
onOpenAbout: () => openAboutDialog(true),
selectInAppTutorial: selectInAppTutorial,
onLoadEventsFunctionsExtensions: async () => {
if (isProjectClosedSoAvoidReloadingExtensions) {
return;
}
return eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions(
currentProject
);
},
onReloadEventsFunctionsExtensionMetadata: extension => {
if (isProjectClosedSoAvoidReloadingExtensions) {
return;
}
eventsFunctionsExtensionsState.reloadProjectEventsFunctionsExtensionMetadata(
currentProject,
extension
);
},
onDeleteResource: (
resource: gdResource,
cb: boolean => void
) => {
// TODO: Project wide refactoring of objects/events using the resource
cb(true);
},
onRenameResource: (
resource: gdResource,
newName: string,
cb: boolean => void
) => {
if (currentProject)
renameResourcesInProject(currentProject, {
[resource.getName()]: newName,
});
cb(true);
},
openBehaviorEvents: openBehaviorEvents,
onExtractAsExternalLayout: onExtractAsExternalLayout,
onExtractAsEventBasedObject: onOpenEventBasedObjectEditor,
onOpenEventBasedObjectEditor: onOpenEventBasedObjectEditor,
onOpenEventBasedObjectVariantEditor: onOpenEventBasedObjectVariantEditor,
onDeleteEventsBasedObjectVariant: deleteEventsBasedObjectVariant,
onEventsBasedObjectChildrenEdited: onEventsBasedObjectChildrenEdited,
onSceneObjectEdited: onSceneObjectEdited,
onSceneObjectsDeleted: onSceneObjectsDeleted,
onSceneEventsModifiedOutsideEditor: onSceneEventsModifiedOutsideEditor,
onExtensionInstalled: onExtensionInstalled,
gamesList,
gamesPlatformFrameTools,
})}
</ErrorBoundary>
</CommandsContextScopedProvider>
</TabContentContainer>
);
})}
</MuiThemeOnlyProvider>
</SpecificDimensionsWindowSizeProvider>
</div>
);
});
export default EditorTabsPane;

View File

@@ -28,6 +28,10 @@ html {
-webkit-touch-callout: none;
}
body {
overflow: hidden;
}
#root {
height: 100%;
display: flex;

View File

@@ -46,7 +46,7 @@ export type MainMenuCallbacks = {|
onOpenPreferences: (open?: boolean) => void,
onOpenLanguage: (open?: boolean) => void,
onOpenProfile: (open?: boolean) => void,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
onOpenAskAi: () => void,
setElectronUpdateStatus: ElectronUpdateStatus => void,
|};

View File

@@ -0,0 +1,105 @@
.container {
display: flex;
width: 100%;
height: 100%;
pointer-events: none;
}
.pane {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
pointer-events: all;
}
.leftPane {
flex-basis: 300px;
flex-shrink: 0;
z-index: 1; /* Create a new stacking context. */
}
.rightPane {
flex-basis: 300px;
flex-shrink: 0;
z-index: 1; /* Create a new stacking context. */
}
@keyframes slide-in-right {
from {
transform: translateX(40px);
}
to {
transform: translateX(0);
}
}
@keyframes slide-in-left {
from {
transform: translateX(-40px);
}
to {
transform: translateX(0);
}
}
/* Pane opening (drawer or not) */
.rightPane:not(.hidden) {
animation: slide-in-right 0.15s ease-out;
}
.leftPane:not(.hidden) {
animation: slide-in-left 0.15s ease-out;
}
/* Pane as drawer */
.pane.drawer {
width: min(calc(100vw - 82px), 320px);
will-change: transform;
box-shadow: 0px 8px 10px -5px rgba(0, 0, 0, 0.2),
0px 16px 24px 2px rgba(0, 0, 0, 0.14), 0px 6px 30px 5px rgba(0, 0, 0, 0.12);
}
.rightPane.drawer {
position: absolute;
right: 0;
top: 0;
bottom: 0;
}
.leftPane.drawer {
position: absolute;
left: 0;
top: 0;
bottom: 0;
}
/* Pane, as drawer, closed. */
.rightPane:not(.hidden).closedDrawer {
transform: translateX(100vw);
}
.leftPane:not(.hidden).closedDrawer {
transform: translateX(-100vw);
}
.centerPane {
flex: 1;
}
.hidden {
display: none;
}
.resizer {
z-index: 1; /* To allow the shadow to be visible. */
width: 2px;
cursor: ew-resize;
background-color: transparent;
flex-shrink: 0;
pointer-events: all;
}
.resizer:hover,
.resizer:focus {
outline: none;
}

View File

@@ -0,0 +1,474 @@
// @flow
import * as React from 'react';
import classes from './PanesContainer.module.css';
import classNames from 'classnames';
import useForceUpdate from '../../Utils/UseForceUpdate';
import { useDebounce } from '../../Utils/UseDebounce';
import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer';
const styles = {
pointerEventsNone: {
pointerEvents: 'none',
},
pointerEventsAll: {
pointerEvents: 'all',
},
};
export type FloatingPaneState = 'open' | 'closed';
type Props = {|
renderPane: ({
paneIdentifier: string,
isLeftMostPane: boolean,
isRightMostPane: boolean,
isDrawer: boolean,
areSidePanesDrawers: boolean,
onSetPointerEventsNone: (enablePointerEventsNone: boolean) => void,
onSetPaneDrawerState: (
paneIdentifier: string,
newState: FloatingPaneState
) => void,
}) => React.Node,
hasEditorsInLeftPane: boolean,
hasEditorsInRightPane: boolean,
|};
type DraggingState = {|
paneIdentifier: 'left' | 'right',
startClientX: number,
startWidth: number,
|};
const paneWidthMin = 300;
/** Allow a pane to be moved and closed with a swipe on touchscreens. */
const useSwipeableDrawer = ({
enabled,
paneRef,
direction,
onClose,
}: {|
enabled: boolean,
paneRef: {| current: HTMLDivElement | null |},
direction: 'left' | 'right',
onClose: () => void,
|}) => {
React.useEffect(
() => {
const drawer = paneRef.current;
if (!drawer) return;
if (!enabled) return;
let startX = 0;
let currentX = 0;
let isDragging = false;
const minDistanceForAction = 80;
const minDistanceForMovement = 30;
const onTouchStart = (e: TouchEvent) => {
startX = e.touches[0].clientX;
currentX = e.touches[0].clientX;
isDragging = true;
drawer.style.transition = 'none';
};
const onTouchMove = (e: TouchEvent) => {
if (!isDragging) return;
currentX = e.touches[0].clientX;
const deltaX = currentX - startX;
const hasMovedEnough = Math.abs(deltaX) > minDistanceForMovement;
const translate = !hasMovedEnough
? 0
: direction === 'left'
? Math.max(0, deltaX)
: Math.min(0, deltaX); // Prevent dragging in wrong direction
drawer.style.transform = `translateX(${translate}px)`;
};
const onTouchEnd = () => {
if (!isDragging) return;
isDragging = false;
const deltaX = currentX - startX;
const shouldClose =
direction === 'left'
? deltaX > minDistanceForAction
: deltaX < -minDistanceForAction;
// Animate the drawer either to close or fully open position,
const animationTimeInMs = 200;
drawer.style.transition = `transform 0.${animationTimeInMs}s ease-out`;
if (shouldClose) {
// Animate to close position.
const closeTransform = direction === 'left' ? '100vw' : '-100vw';
drawer.style.transform = `translateX(${closeTransform})`;
// Delay to match animation time.
setTimeout(() => {
onClose(); // This is responsible for ensuring the drawer will stay closed.
drawer.style.transform = ''; // Reset for future swipes
}, 250);
} else {
// Snap back to open.
drawer.style.transform = 'translateX(0)';
// Delay to match animation time.
setTimeout(() => {
drawer.style.transform = ''; // Reset for future swipes
}, 250);
}
};
drawer.addEventListener('touchstart', onTouchStart);
drawer.addEventListener('touchmove', onTouchMove);
drawer.addEventListener('touchend', onTouchEnd);
return () => {
drawer.removeEventListener('touchstart', onTouchStart);
drawer.removeEventListener('touchmove', onTouchMove);
drawer.removeEventListener('touchend', onTouchEnd);
drawer.style.transform = '';
};
},
[enabled, paneRef, direction, onClose]
);
};
export const PanesContainer = ({
renderPane,
hasEditorsInLeftPane,
hasEditorsInRightPane,
}: Props) => {
const { isMobile } = useResponsiveWindowSize();
const forceUpdate = useForceUpdate();
const debouncedForceUpdate = useDebounce(forceUpdate, 200);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const leftResizerRef = React.useRef<HTMLDivElement | null>(null);
const leftPaneRef = React.useRef<HTMLDivElement | null>(null);
const centerPaneRef = React.useRef<HTMLDivElement | null>(null);
const rightPaneRef = React.useRef<HTMLDivElement | null>(null);
const rightResizerRef = React.useRef<HTMLDivElement | null>(null);
const areSidePanesDrawers = isMobile;
const [panesDrawerState, setPanesDrawerState] = React.useState<{
[string]: FloatingPaneState,
}>({
left: 'open',
right: 'open',
});
const setPaneDrawerState = React.useCallback(
(paneIdentifier: string, newState: FloatingPaneState) => {
setPanesDrawerState(panesDrawerState => ({
...panesDrawerState,
[paneIdentifier]: newState,
}));
},
[]
);
React.useEffect(
() => {
if (areSidePanesDrawers) {
// Just switched to mobile view: any drawer is closed.
setPanesDrawerState({
left: 'closed',
right: 'closed',
});
} else {
// Just switched to non-mobile view: always consider the pane drawers as open.
setPanesDrawerState({
left: 'open',
right: 'open',
});
}
},
[areSidePanesDrawers]
);
React.useEffect(
() => {
if (hasEditorsInRightPane) {
// Just opened the first editor in the right pane: ensure the drawer
// pane is shown.
// Note that is the screen is big enough so that drawers are not shown,
// the state is "open" anyway, this is fine.
setPaneDrawerState('right', 'open');
}
},
[setPaneDrawerState, hasEditorsInRightPane]
);
React.useEffect(
() => {
if (hasEditorsInLeftPane) {
// Just opened the first editor in the left pane: ensure the drawer
// pane is shown.
// Note that is the screen is big enough so that drawers are not shown,
// the state is "open" anyway, this is fine.
setPaneDrawerState('left', 'open');
}
},
[setPaneDrawerState, hasEditorsInLeftPane]
);
const onCloseLeftPane = React.useCallback(
() => setPaneDrawerState('left', 'closed'),
[setPaneDrawerState]
);
useSwipeableDrawer({
enabled: areSidePanesDrawers,
paneRef: leftPaneRef,
direction: 'left',
onClose: onCloseLeftPane,
});
const onCloseRightPane = React.useCallback(
() => setPaneDrawerState('right', 'closed'),
[setPaneDrawerState]
);
useSwipeableDrawer({
enabled: areSidePanesDrawers,
paneRef: rightPaneRef,
direction: 'left',
onClose: onCloseRightPane,
});
const draggingStateRef = React.useRef<DraggingState | null>(null);
React.useEffect(
() => {
const onPointerMove = (event: PointerEvent) => {
const leftPane = leftPaneRef.current;
const rightPane = rightPaneRef.current;
const draggingState = draggingStateRef.current;
if (!draggingState || !containerRef.current || !leftPane || !rightPane)
return;
const containerRect = containerRef.current.getBoundingClientRect();
const newWidth =
draggingState.paneIdentifier === 'left'
? draggingState.startWidth +
(event.clientX - draggingState.startClientX)
: draggingState.startWidth -
(event.clientX - draggingState.startClientX);
const min = paneWidthMin;
const max = containerRect.width - paneWidthMin;
const clampedWidth = Math.max(min, Math.min(max, newWidth));
if (draggingState.paneIdentifier === 'left') {
leftPane.style.flexBasis = `${clampedWidth}px`;
} else {
rightPane.style.flexBasis = `${clampedWidth}px`;
}
// Only trigger a React re-render after the user has stopped dragging,
// to avoid re-rendering the panes too often.
debouncedForceUpdate();
};
const onPointerUp = () => {
draggingStateRef.current = null;
forceUpdate();
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
};
const onLeftResizerPointerDown = (event: PointerEvent) => {
event.preventDefault();
const leftPane = leftPaneRef.current;
if (!leftPane) return;
draggingStateRef.current = {
paneIdentifier: 'left',
startClientX: event.clientX,
startWidth: leftPane.getBoundingClientRect().width,
};
forceUpdate();
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
};
const onRightResizerPointerDown = (event: PointerEvent) => {
event.preventDefault();
const rightPane = rightPaneRef.current;
if (!rightPane) return;
draggingStateRef.current = {
paneIdentifier: 'right',
startClientX: event.clientX,
startWidth: rightPane.getBoundingClientRect().width,
};
forceUpdate();
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
};
const leftResizer = leftResizerRef.current;
if (leftResizer) {
leftResizer.addEventListener('pointerdown', onLeftResizerPointerDown);
}
const rightResizer = rightResizerRef.current;
if (rightResizer) {
rightResizer.addEventListener('pointerdown', onRightResizerPointerDown);
}
return () => {
if (leftResizer) {
leftResizer.removeEventListener(
'pointerdown',
onLeftResizerPointerDown
);
}
if (rightResizer) {
rightResizer.removeEventListener(
'pointerdown',
onRightResizerPointerDown
);
}
};
},
[debouncedForceUpdate, forceUpdate]
);
const [
leftPanePointerEventsNone,
setLeftPanePointerEventsNone,
] = React.useState(false);
const [
centerPanePointerEventsNone,
setCenterPanePointerEventsNone,
] = React.useState(false);
const [
rightPanePointerEventsNone,
setRightPanePointerEventsNone,
] = React.useState(false);
const isDragging = draggingStateRef.current !== null;
return (
<div
ref={containerRef}
className={classes.container}
role="group"
aria-label="Resizable split pane"
style={isDragging ? styles.pointerEventsAll : undefined}
>
<div
ref={leftPaneRef}
className={classNames({
[classes.pane]: true,
[classes.leftPane]: true,
[classes.drawer]: areSidePanesDrawers,
[classes.closedDrawer]:
areSidePanesDrawers && panesDrawerState['left'] === 'closed',
[classes.hidden]: !hasEditorsInLeftPane,
})}
style={
leftPanePointerEventsNone && !isDragging
? styles.pointerEventsNone
: undefined
}
id="pane-left"
>
{renderPane({
paneIdentifier: 'left',
isLeftMostPane: true,
isRightMostPane: false,
isDrawer: areSidePanesDrawers,
areSidePanesDrawers,
onSetPaneDrawerState: setPaneDrawerState,
onSetPointerEventsNone: setLeftPanePointerEventsNone,
})}
</div>
<div
className={classNames({
[classes.resizer]: true,
[classes.hidden]: !hasEditorsInLeftPane || areSidePanesDrawers,
})}
role="separator"
aria-orientation="vertical"
aria-controls="pane-left pane-center"
tabIndex={0}
ref={leftResizerRef}
/>
<div
ref={centerPaneRef}
className={classNames({
[classes.pane]: true,
[classes.centerPane]: true,
})}
style={
centerPanePointerEventsNone && !isDragging
? styles.pointerEventsNone
: undefined
}
id="pane-center"
>
{renderPane({
paneIdentifier: 'center',
isLeftMostPane: areSidePanesDrawers || !hasEditorsInLeftPane,
isRightMostPane: areSidePanesDrawers || !hasEditorsInRightPane,
isDrawer: false,
areSidePanesDrawers,
onSetPaneDrawerState: setPaneDrawerState,
onSetPointerEventsNone: setCenterPanePointerEventsNone,
})}
</div>
<div
className={classNames({
[classes.resizer]: true,
[classes.hidden]: !hasEditorsInRightPane || areSidePanesDrawers,
})}
role="separator"
aria-orientation="vertical"
aria-controls="pane-center pane-right"
tabIndex={0}
ref={rightResizerRef}
/>
<div
ref={rightPaneRef}
className={classNames({
[classes.pane]: true,
[classes.rightPane]: true,
[classes.drawer]: areSidePanesDrawers,
[classes.closedDrawer]:
areSidePanesDrawers && panesDrawerState['right'] === 'closed',
[classes.hidden]: !hasEditorsInRightPane,
})}
style={
rightPanePointerEventsNone && !isDragging
? styles.pointerEventsNone
: undefined
}
id="pane-right"
>
{renderPane({
paneIdentifier: 'right',
isLeftMostPane: false,
isRightMostPane: true,
isDrawer: areSidePanesDrawers,
areSidePanesDrawers,
onSetPaneDrawerState: setPaneDrawerState,
onSetPointerEventsNone: setRightPanePointerEventsNone,
})}
</div>
</div>
);
};

View File

@@ -53,13 +53,15 @@ const ProjectTitlebar = React.memo<Props>(
.join(' - ');
Window.setTitle(title);
Window.setTitleBarColor(gdevelopTheme.titlebar.backgroundColor);
Window.setWindowBackgroundColor(
gdevelopTheme.surface.window.backgroundColor
);
},
[
projectIdentifier,
suffix,
hasUnsavedChanges,
gdevelopTheme.titlebar.backgroundColor,
gdevelopTheme.surface.window.backgroundColor,
storageProviderName,
]
);

View File

@@ -24,6 +24,7 @@ import { AssetStoreStateProvider } from '../AssetStore/AssetStoreContext';
import { ResourceStoreStateProvider } from '../AssetStore/ResourceStore/ResourceStoreContext';
import { ExampleStoreStateProvider } from '../AssetStore/ExampleStore/ExampleStoreContext';
import { PrivateGameTemplateStoreStateProvider } from '../AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext';
import { BundleStoreStateProvider } from '../AssetStore/Bundles/BundleStoreContext';
import { ExtensionStoreStateProvider } from '../AssetStore/ExtensionStore/ExtensionStoreContext';
import { BehaviorStoreStateProvider } from '../AssetStore/BehaviorStore/BehaviorStoreContext';
import { TutorialStateProvider } from '../Tutorial/TutorialContext';
@@ -42,6 +43,7 @@ import { ProductLicenseStoreStateProvider } from '../AssetStore/ProductLicense/P
import { MarketingPlansStoreStateProvider } from '../MarketingPlans/MarketingPlansStoreContext';
import { CourseStoreStateProvider } from '../Course/CourseStoreContext';
import { Resource3DPreviewProvider } from '../ResourcesList/ResourcePreview/Resource3DPreviewContext';
import { AiRequestProvider } from '../AiGeneration/AiRequestContext';
type Props = {|
authentication: Authentication,
@@ -107,35 +109,39 @@ const Providers = ({
<ResourceStoreStateProvider>
<ExampleStoreStateProvider>
<PrivateGameTemplateStoreStateProvider>
<CreditsPackageStoreStateProvider>
<CourseStoreStateProvider>
<ProductLicenseStoreStateProvider>
<MarketingPlansStoreStateProvider>
<ExtensionStoreStateProvider
i18n={i18n}
>
<BehaviorStoreStateProvider
<BundleStoreStateProvider>
<CreditsPackageStoreStateProvider>
<CourseStoreStateProvider>
<ProductLicenseStoreStateProvider>
<MarketingPlansStoreStateProvider>
<ExtensionStoreStateProvider
i18n={i18n}
>
<TutorialStateProvider>
<AnnouncementsFeedStateProvider>
<PrivateAssetsAuthorizationProvider>
<Resource3DPreviewProvider>
{children(
{
i18n,
}
)}
</Resource3DPreviewProvider>
</PrivateAssetsAuthorizationProvider>
</AnnouncementsFeedStateProvider>
</TutorialStateProvider>
</BehaviorStoreStateProvider>
</ExtensionStoreStateProvider>
</MarketingPlansStoreStateProvider>
</ProductLicenseStoreStateProvider>
</CourseStoreStateProvider>
</CreditsPackageStoreStateProvider>
<BehaviorStoreStateProvider
i18n={i18n}
>
<TutorialStateProvider>
<AnnouncementsFeedStateProvider>
<PrivateAssetsAuthorizationProvider>
<Resource3DPreviewProvider>
<AiRequestProvider>
{children(
{
i18n,
}
)}
</AiRequestProvider>
</Resource3DPreviewProvider>
</PrivateAssetsAuthorizationProvider>
</AnnouncementsFeedStateProvider>
</TutorialStateProvider>
</BehaviorStoreStateProvider>
</ExtensionStoreStateProvider>
</MarketingPlansStoreStateProvider>
</ProductLicenseStoreStateProvider>
</CourseStoreStateProvider>
</CreditsPackageStoreStateProvider>
</BundleStoreStateProvider>
</PrivateGameTemplateStoreStateProvider>
</ExampleStoreStateProvider>
</ResourceStoreStateProvider>

View File

@@ -20,6 +20,7 @@ type RouteKey =
| 'games-dashboard-tab'
| 'asset-pack'
| 'game-template'
| 'bundle'
| 'tutorial-id'
| 'course-id'
| 'create-from-example'

View File

@@ -3,8 +3,6 @@ import * as React from 'react';
import MenuIcon from '../UI/CustomSvgIcons/Menu';
import IconButton from '../UI/IconButton';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import Window from '../Utils/Window';
import {
TitleBarLeftSafeMargins,
TitleBarRightSafeMargins,
@@ -39,6 +37,7 @@ const styles = {
height: 34,
},
askAiContainer: {
zIndex: 0, // Create a stacking context to avoid the AI icon z-indexed element to display above other panes or UI elements.
marginBottom: 4,
marginRight: 1,
marginLeft: 2,
@@ -52,8 +51,12 @@ type TabsTitlebarProps = {|
onEditorTabHovered: (?EditorTab, {| isLabelTruncated: boolean |}) => void,
onEditorTabClosing: () => void
) => React.Node,
hasAskAiOpened: boolean,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
isLeftMostPane: boolean,
isRightMostPane: boolean,
displayMenuIcon: boolean,
displayAskAi: boolean,
onAskAiClicked: () => void,
|};
const useIsAskAiIconAnimated = (shouldDisplayAskAi: boolean) => {
@@ -103,12 +106,13 @@ export default function TabsTitlebar({
toggleProjectManager,
hidden,
renderTabs,
hasAskAiOpened,
onOpenAskAi,
isLeftMostPane,
isRightMostPane,
displayMenuIcon,
displayAskAi,
onAskAiClicked,
}: TabsTitlebarProps) {
const isTouchscreen = useScreenType() === 'touch';
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const backgroundColor = gdevelopTheme.titlebar.backgroundColor;
const preferences = React.useContext(PreferencesContext);
const { limits } = React.useContext(AuthenticatedUserContext);
const [tooltipData, setTooltipData] = React.useState<?{|
@@ -117,13 +121,6 @@ export default function TabsTitlebar({
|}>(null);
const tooltipTimeoutId = React.useRef<?TimeoutID>(null);
React.useEffect(
() => {
Window.setTitleBarColor(backgroundColor);
},
[backgroundColor]
);
const onEditorTabHovered = React.useCallback(
(
editorTab: ?EditorTab,
@@ -189,35 +186,36 @@ export default function TabsTitlebar({
limits.capabilities.classrooms.hideAskAi;
const shouldDisplayAskAi =
preferences.values.showAiAskButtonInTitleBar &&
!hasAskAiOpened &&
!hideAskAi;
preferences.values.showAiAskButtonInTitleBar && displayAskAi && !hideAskAi;
const isAskAiIconAnimated = useIsAskAiIconAnimated(shouldDisplayAskAi);
return (
<div
style={{
...styles.container,
backgroundColor,
backgroundColor: 'transparent',
// Hiding the titlebar should still keep its position in the layout to avoid layout shifts:
visibility: hidden ? 'hidden' : 'visible',
pointerEvents: hidden ? undefined : 'all',
}}
className={WINDOW_DRAGGABLE_PART_CLASS_NAME}
>
<TitleBarLeftSafeMargins />
<IconButton
size="small"
// Even if not in the toolbar, keep this ID for backward compatibility for tutorials.
id="main-toolbar-project-manager-button"
// The whole bar is draggable, so prevent the icon to be draggable,
// as it can affect the ability to open the menu.
className={WINDOW_NON_DRAGGABLE_PART_CLASS_NAME}
style={styles.menuIcon}
color="default"
onClick={toggleProjectManager}
>
<MenuIcon />
</IconButton>
{isLeftMostPane && <TitleBarLeftSafeMargins />}
{displayMenuIcon && (
<IconButton
size="small"
// Even if not in the toolbar, keep this ID for backward compatibility for tutorials.
id="main-toolbar-project-manager-button"
// The whole bar is draggable, so prevent the icon to be draggable,
// as it can affect the ability to open the menu.
className={WINDOW_NON_DRAGGABLE_PART_CLASS_NAME}
style={styles.menuIcon}
color="default"
onClick={toggleProjectManager}
>
<MenuIcon />
</IconButton>
)}
{renderTabs(onEditorTabHovered, onEditorTabClosing)}
{shouldDisplayAskAi ? (
<div
@@ -227,11 +225,11 @@ export default function TabsTitlebar({
<TextButton
icon={<RobotIcon size={16} rotating={isAskAiIconAnimated} />}
label={'Ask AI'}
onClick={() => onOpenAskAi('agent')}
onClick={onAskAiClicked}
/>
</div>
) : null}
<TitleBarRightSafeMargins />
{isRightMostPane && <TitleBarRightSafeMargins />}
{tooltipData && (
<TabsTitlebarTooltip
anchorElement={tooltipData.element}

View File

@@ -30,7 +30,11 @@ type Props = {|
privateGameTemplateListingData: PrivateGameTemplateListingData,
newProjectSetup: NewProjectSetup
) => Promise<void>,
openAskAi: (mode: 'chat' | 'agent') => void,
openAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
storageProviders: Array<StorageProvider>,
|};
@@ -179,7 +183,11 @@ const useNewProjectDialog = ({
const onOpenAskAi = React.useCallback(
() => {
closeNewProjectDialog();
openAskAi('agent');
openAskAi({
mode: 'agent',
aiRequestId: null,
paneIdentifier: 'center',
});
},
[closeNewProjectDialog, openAskAi]
);

View File

@@ -11,7 +11,6 @@ import EventsIcon from '../UI/CustomSvgIcons/Events';
import ExternalEventsIcon from '../UI/CustomSvgIcons/ExternalEvents';
import ExternalLayoutIcon from '../UI/CustomSvgIcons/ExternalLayout';
import ExtensionIcon from '../UI/CustomSvgIcons/Extension';
import Toolbar, { type ToolbarInterface } from './Toolbar';
import ProjectTitlebar from './ProjectTitlebar';
import PreferencesDialog from './Preferences/PreferencesDialog';
import AboutDialog from './AboutDialog';
@@ -21,18 +20,13 @@ import CloseConfirmDialog from '../UI/CloseConfirmDialog';
import ProfileDialog from '../Profile/ProfileDialog';
import Window from '../Utils/Window';
import { showErrorBox } from '../UI/Messages/MessageBox';
import { TabContentContainer } from '../UI/ClosableTabs';
import { DraggableEditorTabs } from './EditorTabs/DraggableEditorTabs';
import EditorTabsPane, {
type EditorTabsPaneCommonProps,
} from './EditorTabsPane';
import {
getEditorTabsInitialState,
openEditorTab,
closeEditorTab,
closeOtherEditorTabs,
closeAllEditorTabs,
changeCurrentTab,
getEditors,
getCurrentTabIndex,
getCurrentTab,
closeProjectTabs,
closeLayoutTabs,
closeExternalLayoutTabs,
@@ -42,14 +36,14 @@ import {
closeEventsBasedObjectVariantTab,
saveUiSettings,
type EditorTabsState,
type EditorTab,
type EditorKind,
getEventsFunctionsExtensionEditor,
notifyPreviewOrExportWillStart,
moveTabToTheRightOfHoveredTab,
getCustomObjectEditor,
hasEditorTabOpenedWithKey,
getOpenedAskAiEditor,
changeCurrentTab,
getAllEditorTabs,
hasEditorsInPane,
} from './EditorTabs/EditorTabsHandler';
import { renderDebuggerEditorContainer } from './EditorContainers/DebuggerEditorContainer';
import { renderEventsEditorContainer } from './EditorContainers/EventsEditorContainer';
@@ -65,9 +59,6 @@ import {
type RenderEditorContainerPropsWithRef,
type SceneEventsOutsideEditorChanges,
} from './EditorContainers/BaseEditor';
import ErrorBoundary, {
getEditorErrorBoundaryProps,
} from '../UI/ErrorBoundary';
import { type Exporter } from '../ExportAndShare/ShareDialog';
import ResourcesLoader from '../ResourcesLoader/index';
import {
@@ -132,7 +123,6 @@ import {
CommandPaletteWithAlgoliaSearch,
type CommandPaletteInterface,
} from '../CommandPalette/CommandPalette';
import CommandsContextScopedProvider from '../CommandPalette/CommandsScopedContext';
import { isExtensionNameTaken } from '../ProjectManager/EventFunctionExtensionNameVerifier';
import {
type PreviewState,
@@ -174,7 +164,6 @@ import InAppTutorialContext from '../InAppTutorial/InAppTutorialContext';
import useOpenInitialDialog from '../Utils/UseOpenInitialDialog';
import { type InAppTutorialOrchestratorInterface } from '../InAppTutorial/InAppTutorialOrchestrator';
import useInAppTutorialOrchestrator from '../InAppTutorial/useInAppTutorialOrchestrator';
import TabsTitlebar from './TabsTitlebar';
import {
useStableUpToDateCallback,
useStableUpToDateRef,
@@ -208,6 +197,7 @@ import RobotIcon from '../ProjectCreation/RobotIcon';
import PublicProfileContext from '../Profile/PublicProfileContext';
import { useGamesPlatformFrame } from './EditorContainers/HomePage/PlaySection/UseGamesPlatformFrame';
import { useExtensionLoadErrorDialog } from '../Utils/UseExtensionLoadErrorDialog';
import { PanesContainer } from './PanesContainer';
const GD_STARTUP_TIMES = global.GD_STARTUP_TIMES || [];
@@ -354,11 +344,6 @@ const MainFrame = (props: Props) => {
gdjsDevelopmentWatcherEnabled: false,
}: State)
);
const toolbar = React.useRef<?ToolbarInterface>(null);
const [
tabsTitleBarAndEditorToolbarHidden,
setTabsTitleBarAndEditorToolbarHidden,
] = React.useState(false);
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const [
cloudProjectFileMetadataToRecover,
@@ -583,13 +568,17 @@ const MainFrame = (props: Props) => {
name,
dontFocusTab,
project,
paneIdentifier,
mode,
aiRequestId,
}: {
kind: EditorKind,
name: string,
dontFocusTab?: boolean,
project?: ?gdProject,
paneIdentifier?: 'left' | 'center' | 'right' | null,
mode?: 'chat' | 'agent',
aiRequestId?: string | null,
}) => {
const label =
kind === 'resources'
@@ -663,7 +652,7 @@ const MainFrame = (props: Props) => {
kind === 'start page'
? { storageProviders: props.storageProviders }
: kind === 'ask-ai'
? { mode }
? { mode, aiRequestId }
: undefined;
return {
icon,
@@ -684,6 +673,7 @@ const MainFrame = (props: Props) => {
extraEditorProps,
key,
dontFocusTab,
paneIdentifier: paneIdentifier || 'center',
};
},
[i18n, props.storageProviders]
@@ -777,26 +767,6 @@ const MainFrame = (props: Props) => {
});
};
const updateToolbar = React.useCallback(
(newEditorTabs = state.editorTabs) => {
const editorTab = getCurrentTab(newEditorTabs);
if (!editorTab || !editorTab.editorRef) {
setEditorToolbar(null);
return;
}
editorTab.editorRef.updateToolbar();
},
[state.editorTabs]
);
React.useEffect(
() => {
updateToolbar();
},
[updateToolbar]
);
const _languageDidChange = () => {
// A change in the language will automatically be applied
// on all React components, as it's handled by GDI18nProvider.
@@ -1233,20 +1203,48 @@ const MainFrame = (props: Props) => {
});
const openAskAi = React.useCallback(
(mode: 'chat' | 'agent') => {
({
mode,
aiRequestId,
paneIdentifier,
}: {|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => {
setState(state => {
const askAiEditor = getOpenedAskAiEditor(state.editorTabs);
if (askAiEditor) {
askAiEditor.startNewChat(mode);
const openedEditor = getOpenedAskAiEditor(state.editorTabs);
let newEditorTabs = state.editorTabs;
if (openedEditor) {
if (
!paneIdentifier ||
openedEditor.paneIdentifier === paneIdentifier
) {
// The editor is opened, and at the right position.
openedEditor.askAiEditor.startOrOpenChat({ mode, aiRequestId });
return state;
}
// The editor is opened, but not in the right pane.
// Close it and it will re-open in the right pane.
newEditorTabs = closeEditorTab(newEditorTabs, openedEditor.editorTab);
}
// Open or focus the AI editor.
// Open, or focus if already opened, the editor.
newEditorTabs = openEditorTab(
newEditorTabs,
getEditorOpeningOptions({
kind: 'ask-ai',
name: '',
mode,
aiRequestId,
paneIdentifier,
})
);
return {
...state,
editorTabs: openEditorTab(
state.editorTabs,
getEditorOpeningOptions({ kind: 'ask-ai', name: '', mode })
),
editorTabs: newEditorTabs,
};
});
},
@@ -1288,18 +1286,11 @@ const MainFrame = (props: Props) => {
const toggleProjectManager = React.useCallback(
() => {
if (toolbar.current)
openProjectManager(projectManagerOpen => !projectManagerOpen);
openProjectManager(projectManagerOpen => !projectManagerOpen);
},
[openProjectManager]
);
const setEditorToolbar = (editorToolbar: any, isCurrentTab = true) => {
if (!toolbar.current || !isCurrentTab) return;
toolbar.current.setEditorToolbar(editorToolbar);
};
const deleteLayout = (layout: gdLayout) => {
const { currentProject } = state;
const { i18n } = props;
@@ -2172,7 +2163,11 @@ const MainFrame = (props: Props) => {
);
setState(state => ({
...state,
editorTabs: changeCurrentTab(editorTabs, foundTab.tabIndex),
editorTabs: changeCurrentTab(
editorTabs,
foundTab.paneIdentifier,
foundTab.tabIndex
),
}));
} else {
// Open a new editor for the extension and the given function
@@ -2209,7 +2204,11 @@ const MainFrame = (props: Props) => {
if (foundTab) {
setState(state => ({
...state,
editorTabs: changeCurrentTab(editorTabs, foundTab.tabIndex),
editorTabs: changeCurrentTab(
editorTabs,
foundTab.paneIdentifier,
foundTab.tabIndex
),
}));
} else {
// Open a new editor for the extension and the given function
@@ -2253,7 +2252,11 @@ const MainFrame = (props: Props) => {
foundTab.editor.selectEventsBasedBehaviorByName(objectName);
setState(state => ({
...state,
editorTabs: changeCurrentTab(editorTabs, foundTab.tabIndex),
editorTabs: changeCurrentTab(
editorTabs,
foundTab.paneIdentifier,
foundTab.tabIndex
),
}));
} else {
// Open a new editor for the extension and the given function
@@ -2286,7 +2289,11 @@ const MainFrame = (props: Props) => {
foundTab.editor.selectEventsBasedBehaviorByName(behaviorName);
setState(state => ({
...state,
editorTabs: changeCurrentTab(editorTabs, foundTab.tabIndex),
editorTabs: changeCurrentTab(
editorTabs,
foundTab.paneIdentifier,
foundTab.tabIndex
),
}));
} else {
// Open a new editor for the extension and the given function
@@ -2378,7 +2385,7 @@ const MainFrame = (props: Props) => {
eventsBasedObject
);
for (const editor of state.editorTabs.editors) {
for (const editor of getAllEditorTabs(state.editorTabs)) {
const { editorRef } = editor;
if (editorRef) {
editorRef.onEventsBasedObjectChildrenEdited();
@@ -2390,7 +2397,7 @@ const MainFrame = (props: Props) => {
const onSceneObjectEdited = React.useCallback(
(scene: gdLayout, objectWithContext: ObjectWithContext) => {
for (const editor of state.editorTabs.editors) {
for (const editor of getAllEditorTabs(state.editorTabs)) {
const { editorRef } = editor;
if (editorRef) {
editorRef.onSceneObjectEdited(scene, objectWithContext);
@@ -2402,7 +2409,7 @@ const MainFrame = (props: Props) => {
const onSceneObjectsDeleted = React.useCallback(
(scene: gdLayout) => {
for (const editor of state.editorTabs.editors) {
for (const editor of getAllEditorTabs(state.editorTabs)) {
const { editorRef } = editor;
if (editorRef) {
editorRef.onSceneObjectsDeleted(scene);
@@ -2414,7 +2421,7 @@ const MainFrame = (props: Props) => {
const onSceneEventsModifiedOutsideEditor = React.useCallback(
(changes: SceneEventsOutsideEditorChanges) => {
for (const editor of state.editorTabs.editors) {
for (const editor of getAllEditorTabs(state.editorTabs)) {
const { editorRef } = editor;
if (editorRef) {
editorRef.onSceneEventsModifiedOutsideEditor(changes);
@@ -3253,62 +3260,6 @@ const MainFrame = (props: Props) => {
[currentProject, hasUnsavedChanges, i18n, closeProject]
);
const _onChangeEditorTab = (value: number) => {
setState(state => ({
...state,
editorTabs: changeCurrentTab(state.editorTabs, value),
})).then(state =>
_onEditorTabActivated(getCurrentTab(state.editorTabs), state)
);
};
const _onEditorTabActivated = (
editorTab: EditorTab,
newState: State = state
) => {
updateToolbar(newState.editorTabs);
// Ensure the editors shown on the screen are updated. This is for
// example useful if global objects have been updated in another editor.
if (editorTab.editorRef) {
editorTab.editorRef.forceUpdateEditor();
}
};
const _onCloseEditorTab = (editorTab: EditorTab) => {
saveUiSettings(state.editorTabs);
setState(state => ({
...state,
editorTabs: closeEditorTab(state.editorTabs, editorTab),
}));
};
const _onCloseOtherEditorTabs = (editorTab: EditorTab) => {
saveUiSettings(state.editorTabs);
setState(state => ({
...state,
editorTabs: closeOtherEditorTabs(state.editorTabs, editorTab),
}));
};
const _onCloseAllEditorTabs = () => {
saveUiSettings(state.editorTabs);
setState(state => ({
...state,
editorTabs: closeAllEditorTabs(state.editorTabs),
}));
};
const onDropEditorTab = (fromIndex: number, toHoveredIndex: number) => {
setState(state => ({
...state,
editorTabs: moveTabToTheRightOfHoveredTab(
state.editorTabs,
fromIndex,
toHoveredIndex
),
}));
};
const endTutorial = React.useCallback(
async (shouldCloseProject?: boolean) => {
if (shouldCloseProject) {
@@ -3809,6 +3760,16 @@ const MainFrame = (props: Props) => {
isApplicationTopLevelMenu: false,
hideAskAi,
};
const onOpenAskAiFromMainMenu = React.useCallback(
() => {
openAskAi({
mode: 'agent',
aiRequestId: null,
paneIdentifier: currentProject ? 'right' : 'center',
});
},
[openAskAi, currentProject]
);
const mainMenuCallbacks = {
onChooseProject: () => openOpenFromStorageProviderDialog(),
onOpenRecentFile: openFromFileMetadataWithStorageProvider,
@@ -3827,7 +3788,7 @@ const MainFrame = (props: Props) => {
onOpenPreferences: () => openPreferencesDialog(true),
onOpenLanguage: () => openLanguageDialog(true),
onOpenProfile: onOpenProfileDialog,
onOpenAskAi: openAskAi,
onOpenAskAi: onOpenAskAiFromMainMenu,
setElectronUpdateStatus: setElectronUpdateStatus,
};
@@ -3837,7 +3798,86 @@ const MainFrame = (props: Props) => {
!!state.currentProject &&
!isSavingProject &&
(!currentFileMetadata || !isProjectOwnedBySomeoneElse);
const hasAskAiOpened = hasEditorTabOpenedWithKey(state.editorTabs, 'ask-ai');
const editorTabsPaneProps: EditorTabsPaneCommonProps = {
editorTabs: state.editorTabs,
currentProject: currentProject,
currentFileMetadata: currentFileMetadata,
canSave: canSave,
isSavingProject: isSavingProject,
isSharingEnabled:
!checkedOutVersionStatus && !cloudProjectRecoveryOpenedVersionId,
hasPreviewsRunning: hasPreviewsRunning,
previewState: previewState,
checkedOutVersionStatus: checkedOutVersionStatus,
canDoNetworkPreview:
!!_previewLauncher.current &&
_previewLauncher.current.canDoNetworkPreview(),
gamesPlatformFrameTools: gamesPlatformFrameTools,
toggleProjectManager: toggleProjectManager,
setEditorTabs: setEditorTabs,
saveProject: saveProject,
openShareDialog: openShareDialog,
launchDebuggerAndPreview: launchDebuggerAndPreview,
launchNewPreview: launchNewPreview,
launchNetworkPreview: launchNetworkPreview,
launchHotReloadPreview: launchHotReloadPreview,
launchPreviewWithDiagnosticReport: launchPreviewWithDiagnosticReport,
setPreviewOverride: setPreviewOverride,
openVersionHistoryPanel: openVersionHistoryPanel,
onQuitVersionHistory: onQuitVersionHistory,
onOpenAskAi: openAskAi,
getStorageProvider: getStorageProvider,
setPreviewedLayout: setPreviewedLayout,
openExternalEvents: openExternalEvents,
openLayout: openLayout,
openTemplateFromTutorial: openTemplateFromTutorial,
openTemplateFromCourseChapter: openTemplateFromCourseChapter,
previewDebuggerServer: previewDebuggerServer,
hotReloadPreviewButtonProps: hotReloadPreviewButtonProps,
resourceManagementProps: resourceManagementProps,
onCreateEventsFunction: onCreateEventsFunction,
openInstructionOrExpression: openInstructionOrExpression,
onOpenCustomObjectEditor: openCustomObjectEditor,
onRenamedEventsBasedObject: onRenamedEventsBasedObject,
onDeletedEventsBasedObject: onDeletedEventsBasedObject,
openObjectEvents: openObjectEvents,
canOpen: !!props.storageProviders.filter(
({ hiddenInOpenDialog }) => !hiddenInOpenDialog
).length,
openOpenFromStorageProviderDialog: openOpenFromStorageProviderDialog,
openFromFileMetadataWithStorageProvider: openFromFileMetadataWithStorageProvider,
openNewProjectDialog: openNewProjectDialog,
openProjectManager: openProjectManager,
askToCloseProject: askToCloseProject,
closeProject: closeProject,
onSelectExampleShortHeader: onSelectExampleShortHeader,
onSelectPrivateGameTemplateListingData: onSelectPrivateGameTemplateListingData,
createEmptyProject: createEmptyProject,
createProjectFromExample: createProjectFromExample,
onOpenProfileDialog: onOpenProfileDialog,
openLanguageDialog: openLanguageDialog,
openPreferencesDialog: openPreferencesDialog,
openAboutDialog: openAboutDialog,
selectInAppTutorial: selectInAppTutorial,
eventsFunctionsExtensionsState: eventsFunctionsExtensionsState,
isProjectClosedSoAvoidReloadingExtensions: isProjectClosedSoAvoidReloadingExtensions,
renameResourcesInProject: renameResourcesInProject,
openBehaviorEvents: openBehaviorEvents,
onExtractAsExternalLayout: onExtractAsExternalLayout,
onOpenEventBasedObjectEditor: onOpenEventBasedObjectEditor,
onOpenEventBasedObjectVariantEditor: onOpenEventBasedObjectVariantEditor,
deleteEventsBasedObjectVariant: deleteEventsBasedObjectVariant,
onEventsBasedObjectChildrenEdited: onEventsBasedObjectChildrenEdited,
onSceneObjectEdited: onSceneObjectEdited,
onSceneObjectsDeleted: onSceneObjectsDeleted,
onSceneEventsModifiedOutsideEditor: onSceneEventsModifiedOutsideEditor,
onExtensionInstalled: onExtensionInstalled,
gamesList: gamesList,
};
const hasEditorsInLeftPane = hasEditorsInPane(state.editorTabs, 'left');
const hasEditorsInRightPane = hasEditorsInPane(state.editorTabs, 'right');
return (
<div
@@ -3909,80 +3949,6 @@ const MainFrame = (props: Props) => {
buildMainMenuProps={buildMainMenuProps}
/>
</ProjectManagerDrawer>
<TabsTitlebar
hidden={tabsTitleBarAndEditorToolbarHidden}
toggleProjectManager={toggleProjectManager}
renderTabs={(onEditorTabHovered, onEditorTabClosing) => (
<DraggableEditorTabs
hideLabels={false}
editorTabs={state.editorTabs}
onClickTab={(id: number) => _onChangeEditorTab(id)}
onCloseTab={(editorTab: EditorTab) => {
// Call onEditorTabClosing before to ensure any tooltip is removed before the tab is closed.
onEditorTabClosing();
_onCloseEditorTab(editorTab);
}}
onCloseOtherTabs={(editorTab: EditorTab) => {
// Call onEditorTabClosing before to ensure any tooltip is removed before the tab is closed.
onEditorTabClosing();
_onCloseOtherEditorTabs(editorTab);
}}
onCloseAll={() => {
// Call onEditorTabClosing before to ensure any tooltip is removed before the tab is closed.
onEditorTabClosing();
_onCloseAllEditorTabs();
}}
onTabActivated={(editorTab: EditorTab) =>
_onEditorTabActivated(editorTab)
}
onDropTab={onDropEditorTab}
onHoverTab={(
editorTab: ?EditorTab,
options: {| isLabelTruncated: boolean |}
) => onEditorTabHovered(editorTab, options)}
/>
)}
hasAskAiOpened={hasAskAiOpened}
onOpenAskAi={openAskAi}
/>
<Toolbar
ref={toolbar}
hidden={tabsTitleBarAndEditorToolbarHidden}
showProjectButtons={
!['start page', 'debugger', 'ask-ai', null].includes(
getCurrentTab(state.editorTabs)
? getCurrentTab(state.editorTabs).key
: null
)
}
canSave={canSave}
onSave={saveProject}
openShareDialog={() =>
openShareDialog(/* leave the dialog decide which tab to open */)
}
isSharingEnabled={
!checkedOutVersionStatus && !cloudProjectRecoveryOpenedVersionId
}
onOpenDebugger={launchDebuggerAndPreview}
hasPreviewsRunning={hasPreviewsRunning}
onPreviewWithoutHotReload={launchNewPreview}
onNetworkPreview={launchNetworkPreview}
onHotReloadPreview={launchHotReloadPreview}
onLaunchPreviewWithDiagnosticReport={launchPreviewWithDiagnosticReport}
canDoNetworkPreview={
!!_previewLauncher.current &&
_previewLauncher.current.canDoNetworkPreview()
}
setPreviewOverride={setPreviewOverride}
isPreviewEnabled={
!!currentProject && currentProject.getLayoutsCount() > 0
}
previewState={previewState}
onOpenVersionHistory={openVersionHistoryPanel}
checkedOutVersionStatus={checkedOutVersionStatus}
onQuitVersionHistory={onQuitVersionHistory}
canQuitVersionHistory={!isSavingProject}
/>
{// Render games platform frame before the editors, so the editor have priority
// in what to display (ex: Loader of play section)
gamesPlatformFrameTools.renderGamesPlatformFrame()}
@@ -3991,149 +3957,30 @@ const MainFrame = (props: Props) => {
state.currentProject ? state.currentProject.getProjectUuid() : ''
}
>
{getEditors(state.editorTabs).map((editorTab, id) => {
const isCurrentTab = getCurrentTabIndex(state.editorTabs) === id;
const errorBoundaryProps = getEditorErrorBoundaryProps(editorTab.key);
return (
<TabContentContainer
key={editorTab.key}
active={isCurrentTab}
// Deactivate pointer events when the play tab is active, so the iframe
// can be interacted with.
removePointerEvents={gamesPlatformFrameTools.iframeVisible}
>
<CommandsContextScopedProvider active={isCurrentTab}>
<ErrorBoundary
componentTitle={errorBoundaryProps.componentTitle}
scope={errorBoundaryProps.scope}
>
{editorTab.renderEditorContainer({
isActive: isCurrentTab,
extraEditorProps: editorTab.extraEditorProps,
project: currentProject,
fileMetadata: currentFileMetadata,
storageProvider: getStorageProvider(),
ref: editorRef => (editorTab.editorRef = editorRef),
setToolbar: editorToolbar =>
setEditorToolbar(editorToolbar, isCurrentTab),
hideTabsTitleBarAndEditorToolbar: setTabsTitleBarAndEditorToolbarHidden,
projectItemName: editorTab.projectItemName,
setPreviewedLayout,
onOpenAskAi: openAskAi,
onOpenExternalEvents: openExternalEvents,
onOpenEvents: (sceneName: string) => {
openLayout(sceneName, {
openEventsEditor: true,
openSceneEditor: false,
focusWhenOpened: 'events',
});
},
onOpenLayout: openLayout,
onOpenTemplateFromTutorial: openTemplateFromTutorial,
onOpenTemplateFromCourseChapter: openTemplateFromCourseChapter,
previewDebuggerServer,
hotReloadPreviewButtonProps,
resourceManagementProps,
onSave: saveProject,
canSave,
onCreateEventsFunction,
openInstructionOrExpression,
onOpenCustomObjectEditor: openCustomObjectEditor,
onRenamedEventsBasedObject: onRenamedEventsBasedObject,
onDeletedEventsBasedObject: onDeletedEventsBasedObject,
openObjectEvents,
unsavedChanges: unsavedChanges,
canOpen: !!props.storageProviders.filter(
({ hiddenInOpenDialog }) => !hiddenInOpenDialog
).length,
onChooseProject: () => openOpenFromStorageProviderDialog(),
onOpenRecentFile: openFromFileMetadataWithStorageProvider,
onOpenNewProjectSetupDialog: openNewProjectDialog,
onOpenProjectManager: () => openProjectManager(true),
onOpenVersionHistory: openVersionHistoryPanel,
askToCloseProject,
closeProject,
onSelectExampleShortHeader: exampleShortHeader => {
onSelectExampleShortHeader({
exampleShortHeader,
preventBackHome: true,
});
},
onSelectPrivateGameTemplateListingData: privateGameTemplateListingData => {
onSelectPrivateGameTemplateListingData({
privateGameTemplateListingData,
preventBackHome: true,
});
},
onOpenPrivateGameTemplateListingData: privateGameTemplateListingData => {
onSelectPrivateGameTemplateListingData({
privateGameTemplateListingData,
preventBackHome: true,
});
},
onCreateEmptyProject: createEmptyProject,
onCreateProjectFromExample: createProjectFromExample,
onOpenProfile: onOpenProfileDialog,
onOpenLanguageDialog: () => openLanguageDialog(true),
onOpenPreferences: () => openPreferencesDialog(true),
onOpenAbout: () => openAboutDialog(true),
selectInAppTutorial: selectInAppTutorial,
onLoadEventsFunctionsExtensions: async () => {
if (isProjectClosedSoAvoidReloadingExtensions) {
return;
}
return eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions(
currentProject
);
},
onReloadEventsFunctionsExtensionMetadata: extension => {
if (isProjectClosedSoAvoidReloadingExtensions) {
return;
}
eventsFunctionsExtensionsState.reloadProjectEventsFunctionsExtensionMetadata(
currentProject,
extension
);
},
onDeleteResource: (
resource: gdResource,
cb: boolean => void
) => {
// TODO: Project wide refactoring of objects/events using the resource
cb(true);
},
onRenameResource: (
resource: gdResource,
newName: string,
cb: boolean => void
) => {
if (currentProject)
renameResourcesInProject(currentProject, {
[resource.getName()]: newName,
});
cb(true);
},
openBehaviorEvents: openBehaviorEvents,
onExtractAsExternalLayout: onExtractAsExternalLayout,
onExtractAsEventBasedObject: onOpenEventBasedObjectEditor,
onOpenEventBasedObjectEditor: onOpenEventBasedObjectEditor,
onOpenEventBasedObjectVariantEditor: onOpenEventBasedObjectVariantEditor,
onDeleteEventsBasedObjectVariant: deleteEventsBasedObjectVariant,
onEventsBasedObjectChildrenEdited: onEventsBasedObjectChildrenEdited,
onSceneObjectEdited: onSceneObjectEdited,
onSceneObjectsDeleted: onSceneObjectsDeleted,
onSceneEventsModifiedOutsideEditor: onSceneEventsModifiedOutsideEditor,
onExtensionInstalled: onExtensionInstalled,
gamesList,
gamesPlatformFrameTools,
})}
</ErrorBoundary>
</CommandsContextScopedProvider>
</TabContentContainer>
);
})}
<PanesContainer
hasEditorsInLeftPane={hasEditorsInLeftPane}
hasEditorsInRightPane={hasEditorsInRightPane}
renderPane={({
paneIdentifier,
isLeftMostPane,
isRightMostPane,
isDrawer,
areSidePanesDrawers,
onSetPointerEventsNone,
onSetPaneDrawerState,
}) => (
<EditorTabsPane
{...editorTabsPaneProps}
paneIdentifier={paneIdentifier}
isLeftMostPane={isLeftMostPane}
isRightMostPane={isRightMostPane}
isDrawer={isDrawer}
areSidePanesDrawers={areSidePanesDrawers}
onSetPointerEventsNone={onSetPointerEventsNone}
onSetPaneDrawerState={onSetPaneDrawerState}
/>
)}
/>
</LeaderboardProvider>
<CommandPaletteWithAlgoliaSearch ref={commandPaletteRef} />
<LoaderModal

View File

@@ -19,7 +19,6 @@ type Props = {|
onRefreshAllFields: () => void,
resourceManagementProps: ResourceManagementProps,
unsavedChanges?: ?UnsavedChanges,
eventsBasedObject: gdEventsBasedObject,
customObjectConfiguration: gdCustomObjectConfiguration,
childObject: gdObject,
onEditObject: () => void,
@@ -30,7 +29,6 @@ export const ChildObjectPropertiesEditor = ({
onRefreshAllFields,
resourceManagementProps,
unsavedChanges,
eventsBasedObject,
customObjectConfiguration,
childObject,
onEditObject,

View File

@@ -33,6 +33,8 @@ import ChevronArrowBottom from '../../UI/CustomSvgIcons/ChevronArrowBottom';
import ChevronArrowDownWithRoundedBorder from '../../UI/CustomSvgIcons/ChevronArrowDownWithRoundedBorder';
import ChevronArrowRightWithRoundedBorder from '../../UI/CustomSvgIcons/ChevronArrowRightWithRoundedBorder';
import Add from '../../UI/CustomSvgIcons/Add';
import Trash from '../../UI/CustomSvgIcons/Trash';
import Edit from '../../UI/CustomSvgIcons/ShareExternal';
import { useManageObjectBehaviors } from '../../BehaviorsEditor';
import Object3d from '../../UI/CustomSvgIcons/Object3d';
import Object2d from '../../UI/CustomSvgIcons/Object2d';
@@ -53,6 +55,15 @@ import Window from '../../Utils/Window';
import CompactTextField from '../../UI/CompactTextField';
import { textEllipsisStyle } from '../../UI/TextEllipsis';
import Link from '../../UI/Link';
import {
getVariantName,
isVariantEditable,
duplicateVariant,
deleteVariant,
ChildrenOverridingDepreciationAlert,
} from '../Editors/CustomObjectPropertiesEditor';
import NewVariantDialog from '../Editors/CustomObjectPropertiesEditor/NewVariantDialog';
import useAlertDialog from '../../UI/Alert/useAlertDialog';
const gd: libGDevelop = global.gd;
@@ -215,6 +226,16 @@ type Props = {|
objects: Array<gdObject>,
onEditObject: (object: gdObject, initialTab: ?ObjectEditorTab) => void,
onOpenEventBasedObjectVariantEditor: (
extensionName: string,
eventsBasedObjectName: string,
variantName: string
) => void,
onDeleteEventsBasedObjectVariant: (
eventsFunctionsExtension: gdEventsFunctionsExtension,
eventBasedObject: gdEventsBasedObject,
variant: gdEventsBasedObjectVariant
) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
isVariableListLocked: boolean,
isBehaviorListLocked: boolean,
@@ -235,6 +256,8 @@ export const CompactObjectPropertiesEditor = ({
historyHandler,
objects,
onEditObject,
onOpenEventBasedObjectVariantEditor,
onDeleteEventsBasedObjectVariant,
onExtensionInstalled,
isVariableListLocked,
isBehaviorListLocked,
@@ -248,7 +271,13 @@ export const CompactObjectPropertiesEditor = ({
const [isBehaviorsFolded, setIsBehaviorsFolded] = React.useState(false);
const [isVariablesFolded, setIsVariablesFolded] = React.useState(false);
const [isEffectsFolded, setIsEffectsFolded] = React.useState(false);
const [newVariantDialogOpen, setNewVariantDialogOpen] = React.useState(false);
const [
duplicateAndEditVariantDialogOpen,
setDuplicateAndEditVariantDialogOpen,
] = React.useState(false);
const [schemaRecomputeTrigger, forceRecomputeSchema] = useForceRecompute();
const { showDeleteConfirmation } = useAlertDialog();
const variablesListRef = React.useRef<?VariablesListInterface>(null);
const object = objects[0];
const objectConfiguration = object.getConfiguration();
@@ -367,19 +396,37 @@ export const CompactObjectPropertiesEditor = ({
});
// Events based object children:
const eventsBasedObject = project.hasEventsBasedObject(
const customObjectEventsBasedObject = project.hasEventsBasedObject(
objectConfiguration.getType()
)
? project.getEventsBasedObject(objectConfiguration.getType())
: null;
const customObjectConfiguration = eventsBasedObject
const customObjectConfiguration = customObjectEventsBasedObject
? gd.asCustomObjectConfiguration(objectConfiguration)
: null;
const variantName = customObjectConfiguration
? getVariantName(customObjectEventsBasedObject, customObjectConfiguration)
: '';
const customObjectExtensionName = customObjectConfiguration
? gd.PlatformExtension.getExtensionFromFullObjectType(
customObjectConfiguration.getType()
)
: null;
const customObjectExtension =
customObjectExtensionName &&
project.hasEventsFunctionsExtensionNamed(customObjectExtensionName)
? project.getEventsFunctionsExtension(customObjectExtensionName)
: null;
const shouldDisplayEventsBasedObjectChildren =
customObjectConfiguration &&
(customObjectConfiguration.isForcedToOverrideEventsBasedObjectChildrenConfiguration() ||
customObjectConfiguration.isMarkedAsOverridingEventsBasedObjectChildrenConfiguration());
(!variantName &&
customObjectConfiguration.isMarkedAsOverridingEventsBasedObjectChildrenConfiguration()));
const shouldDisplayVariant = customObjectConfiguration
? !customObjectConfiguration.isForcedToOverrideEventsBasedObjectChildrenConfiguration()
: false;
const helpLink = getHelpLink(objectMetadata.getHelpPath());
@@ -388,6 +435,111 @@ export const CompactObjectPropertiesEditor = ({
[object, onEditObject]
);
const editVariant = React.useCallback(
() => {
if (
!isVariantEditable(
customObjectConfiguration,
customObjectEventsBasedObject,
customObjectExtension
)
) {
setDuplicateAndEditVariantDialogOpen(true);
return;
}
customObjectExtension &&
customObjectEventsBasedObject &&
customObjectConfiguration &&
onOpenEventBasedObjectVariantEditor &&
onOpenEventBasedObjectVariantEditor(
customObjectExtension.getName(),
customObjectEventsBasedObject.getName(),
customObjectConfiguration.getVariantName()
);
},
[
customObjectConfiguration,
onOpenEventBasedObjectVariantEditor,
customObjectExtension,
customObjectEventsBasedObject,
]
);
const doDuplicateVariant = React.useCallback(
(i18n: I18nType, newName: string) => {
duplicateVariant(
newName,
customObjectConfiguration,
customObjectEventsBasedObject,
customObjectExtension,
project,
i18n
);
setNewVariantDialogOpen(false);
forceUpdate();
},
[
customObjectConfiguration,
customObjectEventsBasedObject,
customObjectExtension,
forceUpdate,
project,
]
);
const duplicateAndEditVariant = React.useCallback(
(i18n: I18nType, newName: string) => {
duplicateVariant(
newName,
customObjectConfiguration,
customObjectEventsBasedObject,
customObjectExtension,
project,
i18n
);
setDuplicateAndEditVariantDialogOpen(false);
forceUpdate();
editVariant();
},
[
customObjectConfiguration,
customObjectEventsBasedObject,
customObjectExtension,
forceUpdate,
project,
editVariant,
]
);
const doDeleteVariant = React.useCallback(
async () => {
const hasConfirmedDeletion = await showDeleteConfirmation({
title: t`Remove variant`,
message: t`Are you sure you want to remove this variant from your project? This can't be undone.`,
});
if (!hasConfirmedDeletion) {
return;
}
deleteVariant(
customObjectConfiguration,
customObjectEventsBasedObject,
customObjectExtension,
project,
onDeleteEventsBasedObjectVariant
);
forceUpdate();
},
[
customObjectConfiguration,
customObjectEventsBasedObject,
forceUpdate,
onDeleteEventsBasedObjectVariant,
project,
customObjectExtension,
showDeleteConfirmation,
]
);
return (
<ErrorBoundary
componentTitle={<Trans>Object properties</Trans>}
@@ -496,14 +648,91 @@ export const CompactObjectPropertiesEditor = ({
}}
/>
)}
{eventsBasedObject &&
{shouldDisplayVariant && (
<ColumnStackLayout noMargin noOverflowParent>
<LineStackLayout noMargin justifyContent="space-between">
<Text size="body" noMargin>
<Trans>Variant</Trans>
</Text>
<LineStackLayout noMargin>
<IconButton
key={'delete-variant'}
size="small"
onClick={doDeleteVariant}
disabled={!variantName}
>
<Trash style={styles.icon} />
</IconButton>
<IconButton
key={'duplicate-variant'}
size="small"
onClick={() => setNewVariantDialogOpen(true)}
>
<Add style={styles.icon} />
</IconButton>
<IconButton
key={'edit-variant'}
size="small"
onClick={editVariant}
>
<Edit style={styles.icon} />
</IconButton>
</LineStackLayout>
</LineStackLayout>
<CompactSelectField
key={'variant-name'}
value={variantName}
onChange={(newValue: string) => {
customObjectConfiguration &&
customObjectConfiguration.setVariantName(newValue);
forceUpdate();
}}
>
<SelectOption
key="default-variant"
value=""
label={t`Default`}
/>
{customObjectEventsBasedObject &&
mapFor(
0,
customObjectEventsBasedObject
.getVariants()
.getVariantsCount(),
i => {
if (!customObjectEventsBasedObject) {
return null;
}
const variant = customObjectEventsBasedObject
.getVariants()
.getVariantAt(i);
return (
<SelectOption
key={'variant-' + variant.getName()}
value={variant.getName()}
label={variant.getName()}
/>
);
}
)}
</CompactSelectField>
</ColumnStackLayout>
)}
{shouldDisplayEventsBasedObjectChildren &&
customObjectConfiguration &&
!customObjectConfiguration.isForcedToOverrideEventsBasedObjectChildrenConfiguration() && (
<ChildrenOverridingDepreciationAlert />
)}
{customObjectEventsBasedObject &&
customObjectConfiguration &&
shouldDisplayEventsBasedObjectChildren &&
mapFor(
0,
eventsBasedObject.getObjects().getObjectsCount(),
customObjectEventsBasedObject
.getObjects()
.getObjectsCount(),
i => {
const childObject = eventsBasedObject
const childObject = customObjectEventsBasedObject
.getObjects()
.getObjectAt(i);
const childObjectName = childObject.getName();
@@ -519,7 +748,6 @@ export const CompactObjectPropertiesEditor = ({
project={project}
resourceManagementProps={resourceManagementProps}
unsavedChanges={unsavedChanges}
eventsBasedObject={eventsBasedObject}
customObjectConfiguration={
customObjectConfiguration
}
@@ -773,6 +1001,25 @@ export const CompactObjectPropertiesEditor = ({
</Column>
</ScrollView>
{newBehaviorDialog}
{newVariantDialogOpen && customObjectEventsBasedObject && (
<NewVariantDialog
initialName={variantName || i18n._(t`New variant`)}
onApply={name => doDuplicateVariant(i18n, name)}
onCancel={() => {
setNewVariantDialogOpen(false);
}}
/>
)}
{duplicateAndEditVariantDialogOpen && customObjectEventsBasedObject && (
<NewVariantDialog
isDuplicationBeforeEdition
initialName={variantName || i18n._(t`New variant`)}
onApply={name => duplicateAndEditVariant(i18n, name)}
onCancel={() => {
setDuplicateAndEditVariantDialogOpen(false);
}}
/>
)}
</ErrorBoundary>
);
};

View File

@@ -22,6 +22,7 @@ import { getMeasurementUnitShortLabel } from '../../PropertiesEditor/PropertiesM
import AlertMessage from '../../UI/AlertMessage';
import { hasLight } from './Model3DEditor';
import { PropertyCheckbox } from './PropertyFields';
import { mapVector } from '../../Utils/MapFor';
const facesProperties = [
{
@@ -95,14 +96,10 @@ const Cube3DEditor = ({
const facesOrientationChoices = properties
.get('facesOrientation')
.getExtraInfo()
.toJSArray()
.map(value => ({ value, label: value }));
.getChoices();
const backFaceUpThroughWhichAxisRotationChoices = properties
.get('backFaceUpThroughWhichAxisRotation')
.getExtraInfo()
.toJSArray()
.map(value => ({ value, label: value }));
.getChoices();
return (
<ColumnStackLayout noMargin>
@@ -181,11 +178,11 @@ const Cube3DEditor = ({
onChangeProperty('facesOrientation', newValue);
}}
>
{facesOrientationChoices.map(choice => (
{mapVector(facesOrientationChoices, choice => (
<SelectOption
label={choice.label}
value={choice.value}
key={choice.value}
label={choice.getLabel()}
value={choice.getValue()}
key={choice.getValue()}
/>
))}
</SelectField>
@@ -203,11 +200,11 @@ const Cube3DEditor = ({
onChangeProperty('backFaceUpThroughWhichAxisRotation', newValue);
}}
>
{backFaceUpThroughWhichAxisRotationChoices.map(choice => (
{mapVector(backFaceUpThroughWhichAxisRotationChoices, choice => (
<SelectOption
label={choice.label}
value={choice.value}
key={choice.value}
label={choice.getLabel()}
value={choice.getValue()}
key={choice.getValue()}
/>
))}
</SelectField>

View File

@@ -5,14 +5,22 @@ import FlatButton from '../../../UI/FlatButton';
import Dialog, { DialogPrimaryButton } from '../../../UI/Dialog';
import SemiControlledTextField from '../../../UI/SemiControlledTextField';
import HelpButton from '../../../UI/HelpButton';
import { ColumnStackLayout } from '../../../UI/Layout';
import Text from '../../../UI/Text';
type Props = {|
initialName: string,
onApply: (variantName: string) => void,
onCancel: () => void,
isDuplicationBeforeEdition?: boolean,
|};
const NewVariantDialog = ({ initialName, onApply, onCancel }: Props) => {
const NewVariantDialog = ({
initialName,
onApply,
onCancel,
isDuplicationBeforeEdition,
}: Props) => {
const [variantName, setVariantName] = React.useState<string>(initialName);
const apply = React.useCallback(
@@ -50,17 +58,27 @@ const NewVariantDialog = ({ initialName, onApply, onCancel }: Props) => {
open
maxWidth="sm"
>
<SemiControlledTextField
fullWidth
id="variant-name"
commitOnBlur
floatingLabelText={<Trans>Variant name</Trans>}
floatingLabelFixed
value={variantName}
translatableHintText={t`Variant name`}
onChange={setVariantName}
autoFocus="desktop"
/>
<ColumnStackLayout noMargin>
{isDuplicationBeforeEdition && (
<Text>
<Trans>
This variant can't be modified directly. It must be duplicated
first.
</Trans>
</Text>
)}
<SemiControlledTextField
fullWidth
id="variant-name"
commitOnBlur
floatingLabelText={<Trans>Variant name</Trans>}
floatingLabelFixed
value={variantName}
translatableHintText={t`Variant name`}
onChange={setVariantName}
autoFocus="desktop"
/>
</ColumnStackLayout>
</Dialog>
);
};

View File

@@ -61,6 +61,8 @@ import {
serializeToJSObject,
unserializeFromJSObject,
} from '../../../Utils/Serializer';
import useAlertDialog from '../../../UI/Alert/useAlertDialog';
import { MarkdownText } from '../../../UI/MarkdownText';
const gd: libGDevelop = global.gd;
@@ -68,7 +70,18 @@ const styles = {
icon: { width: 16, height: 16 },
};
const getVariantName = (
export const ChildrenOverridingDepreciationAlert = () => {
return (
<AlertMessage kind="warning">
<MarkdownText
translatableSource={t`Children configurations are deprecated. This [migration documentation](https://wiki.gdevelop.io/gdevelop5/objects/custom-objects-prefab-template/migrate-to-variants/) can help you use variants instead.`}
isStandaloneText
/>
</AlertMessage>
);
};
export const getVariantName = (
eventBasedObject: gdEventsBasedObject | null,
customObjectConfiguration: gdCustomObjectConfiguration
): string =>
@@ -93,6 +106,101 @@ export const getVariant = (
: eventBasedObject.getDefaultVariant();
};
/** Avoid to lose user changes by forcing them to duplicate these variants. */
export const isVariantEditable = (
customObjectConfiguration: gdCustomObjectConfiguration | null,
eventsBasedObject: gdEventsBasedObject | null,
eventsFunctionsExtension: gdEventsFunctionsExtension | null
): boolean => {
if (
!customObjectConfiguration ||
!eventsBasedObject ||
!eventsFunctionsExtension
) {
return false;
}
// Variants from the asset store are reset when creating a new object with
// the same asset.
return (
getVariant(
eventsBasedObject,
customObjectConfiguration
).getAssetStoreAssetId() === '' &&
// The default variant is reset when updating the extension.
(!!getVariantName(eventsBasedObject, customObjectConfiguration) ||
eventsFunctionsExtension.getOriginName() !== 'gdevelop-extension-store')
);
};
export const duplicateVariant = (
newName: string,
customObjectConfiguration: gdCustomObjectConfiguration | null,
eventsBasedObject: gdEventsBasedObject | null,
eventsFunctionsExtension: gdEventsFunctionsExtension | null,
project: gdProject,
i18n: I18nType
): void => {
if (!eventsBasedObject || !customObjectConfiguration) {
return;
}
const variants = eventsBasedObject.getVariants();
// TODO Forbid name with `::`
const uniqueNewName = newNameGenerator(
newName || i18n._(t`New variant`),
tentativeNewName => variants.hasVariantNamed(tentativeNewName)
);
const oldVariantName = getVariantName(
eventsBasedObject,
customObjectConfiguration
);
const oldVariant = oldVariantName
? variants.getVariant(oldVariantName)
: eventsBasedObject.getDefaultVariant();
const newVariant = variants.insertNewVariant(uniqueNewName, 0);
unserializeFromJSObject(
newVariant,
serializeToJSObject(oldVariant),
'unserializeFrom',
project
);
newVariant.setName(uniqueNewName);
newVariant.setAssetStoreAssetId('');
newVariant.setAssetStoreOriginalName('');
customObjectConfiguration.setVariantName(uniqueNewName);
};
export const deleteVariant = (
customObjectConfiguration: gdCustomObjectConfiguration | null,
eventsBasedObject: gdEventsBasedObject | null,
eventsFunctionsExtension: gdEventsFunctionsExtension | null,
project: gdProject,
onDeleteEventsBasedObjectVariant: ?(
eventsFunctionsExtension: gdEventsFunctionsExtension,
eventBasedObject: gdEventsBasedObject,
variant: gdEventsBasedObjectVariant
) => void
): void => {
if (
!customObjectConfiguration ||
!eventsBasedObject ||
!eventsFunctionsExtension ||
!onDeleteEventsBasedObjectVariant
) {
return;
}
const variants = eventsBasedObject.getVariants();
const selectedVariantName = customObjectConfiguration.getVariantName();
if (!variants.hasVariantNamed(selectedVariantName)) {
return;
}
customObjectConfiguration.setVariantName('');
onDeleteEventsBasedObjectVariant(
eventsFunctionsExtension,
eventsBasedObject,
variants.getVariant(selectedVariantName)
);
};
type Props = EditorProps;
const CustomObjectPropertiesEditor = (props: Props) => {
@@ -133,10 +241,19 @@ const CustomObjectPropertiesEditor = (props: Props) => {
customObjectConfiguration.getType()
];
const { showDeleteConfirmation } = useAlertDialog();
const { values } = React.useContext(PreferencesContext);
const tutorialIds = getObjectTutorialIds(customObjectConfiguration.getType());
const eventBasedObject = project.hasEventsBasedObject(
const customObjectExtensionName = gd.PlatformExtension.getExtensionFromFullObjectType(
customObjectConfiguration.getType()
);
const customObjectExtension = project.hasEventsFunctionsExtensionNamed(
customObjectExtensionName
)
? project.getEventsFunctionsExtension(customObjectExtensionName)
: null;
const customObjectEventsBasedObject = project.hasEventsBasedObject(
customObjectConfiguration.getType()
)
? project.getEventsBasedObject(customObjectConfiguration.getType())
@@ -206,93 +323,120 @@ const CustomObjectPropertiesEditor = (props: Props) => {
setCollisionMasksEditorOpen,
] = React.useState(false);
const [newVariantDialogOpen, setNewVariantDialogOpen] = React.useState(false);
const [
duplicateAndEditVariantDialogOpen,
setDuplicateAndEditVariantDialogOpen,
] = React.useState(false);
const editVariant = React.useCallback(
() => {
onOpenEventBasedObjectVariantEditor &&
if (
!isVariantEditable(
customObjectConfiguration,
customObjectEventsBasedObject,
customObjectExtension
)
) {
setDuplicateAndEditVariantDialogOpen(true);
return;
}
customObjectExtension &&
customObjectEventsBasedObject &&
onOpenEventBasedObjectVariantEditor &&
onOpenEventBasedObjectVariantEditor(
gd.PlatformExtension.getExtensionFromFullObjectType(
customObjectConfiguration.getType()
),
gd.PlatformExtension.getObjectNameFromFullObjectType(
customObjectConfiguration.getType()
),
customObjectExtension.getName(),
customObjectEventsBasedObject.getName(),
customObjectConfiguration.getVariantName()
);
},
[customObjectConfiguration, onOpenEventBasedObjectVariantEditor]
[
customObjectConfiguration,
onOpenEventBasedObjectVariantEditor,
customObjectExtension,
customObjectEventsBasedObject,
]
);
const duplicateVariant = React.useCallback(
const doDuplicateVariant = React.useCallback(
(i18n: I18nType, newName: string) => {
if (!eventBasedObject) {
return;
}
const variants = eventBasedObject.getVariants();
// TODO Forbid name with `::`
const uniqueNewName = newNameGenerator(
newName || i18n._(t`New variant`),
tentativeNewName => variants.hasVariantNamed(tentativeNewName)
duplicateVariant(
newName,
customObjectConfiguration,
customObjectEventsBasedObject,
customObjectExtension,
project,
i18n
);
const oldVariantName = getVariantName(
eventBasedObject,
customObjectConfiguration
);
const oldVariant = oldVariantName
? variants.getVariant(oldVariantName)
: eventBasedObject.getDefaultVariant();
const newVariant = variants.insertNewVariant(uniqueNewName, 0);
unserializeFromJSObject(
newVariant,
serializeToJSObject(oldVariant),
'unserializeFrom',
project
);
newVariant.setName(uniqueNewName);
newVariant.setAssetStoreAssetId('');
newVariant.setAssetStoreOriginalName('');
customObjectConfiguration.setVariantName(uniqueNewName);
setNewVariantDialogOpen(false);
forceUpdate();
},
[customObjectConfiguration, eventBasedObject, forceUpdate, project]
[
customObjectConfiguration,
customObjectEventsBasedObject,
customObjectExtension,
forceUpdate,
project,
]
);
const deleteVariant = React.useCallback(
() => {
if (!eventBasedObject || !onDeleteEventsBasedObjectVariant) {
return;
}
const variants = eventBasedObject.getVariants();
const selectedVariantName = customObjectConfiguration.getVariantName();
if (variants.hasVariantNamed(selectedVariantName)) {
customObjectConfiguration.setVariantName('');
const extensionName = gd.PlatformExtension.getExtensionFromFullObjectType(
customObjectConfiguration.getType()
);
if (!project.hasEventsFunctionsExtensionNamed(extensionName)) {
return;
}
const eventBasedExtension = project.getEventsFunctionsExtension(
extensionName
);
onDeleteEventsBasedObjectVariant(
eventBasedExtension,
eventBasedObject,
variants.getVariant(selectedVariantName)
);
forceUpdate();
}
const duplicateAndEditVariant = React.useCallback(
(i18n: I18nType, newName: string) => {
duplicateVariant(
newName,
customObjectConfiguration,
customObjectEventsBasedObject,
customObjectExtension,
project,
i18n
);
setDuplicateAndEditVariantDialogOpen(false);
forceUpdate();
editVariant();
},
[
customObjectConfiguration,
eventBasedObject,
customObjectEventsBasedObject,
customObjectExtension,
forceUpdate,
project,
editVariant,
]
);
const doDeleteVariant = React.useCallback(
async () => {
const hasConfirmedDeletion = await showDeleteConfirmation({
title: t`Remove variant`,
message: t`Are you sure you want to remove this variant from your project? This can't be undone.`,
});
if (!hasConfirmedDeletion) {
return;
}
deleteVariant(
customObjectConfiguration,
customObjectEventsBasedObject,
customObjectExtension,
project,
onDeleteEventsBasedObjectVariant
);
forceUpdate();
},
[
customObjectConfiguration,
customObjectEventsBasedObject,
forceUpdate,
onDeleteEventsBasedObjectVariant,
project,
customObjectExtension,
showDeleteConfirmation,
]
);
const variantName = getVariantName(
customObjectEventsBasedObject,
customObjectConfiguration
);
return (
<I18n>
{({ i18n }) => (
@@ -307,9 +451,9 @@ const CustomObjectPropertiesEditor = (props: Props) => {
/>
))}
{propertiesSchema.length ||
(eventBasedObject &&
(eventBasedObject.getObjects().getObjectsCount() ||
eventBasedObject.isAnimatable())) ? (
(customObjectEventsBasedObject &&
(customObjectEventsBasedObject.getObjects().getObjectsCount() ||
customObjectEventsBasedObject.isAnimatable())) ? (
<React.Fragment>
{extraInformation ? (
<Line>
@@ -331,42 +475,41 @@ const CustomObjectPropertiesEditor = (props: Props) => {
/>
{!customObjectConfiguration.isForcedToOverrideEventsBasedObjectChildrenConfiguration() && (
<>
<Line>
<Column expand noMargin>
<Text size="block-title">Variant</Text>
<LineStackLayout
noMargin
justifyContent="space-between"
alignItems="center"
>
<Text size="block-title">Variant</Text>
<Column>
<LineStackLayout>
<FlatButton
key={'delete-variant'}
label={<Trans>Delete</Trans>}
leftIcon={<Trash />}
onClick={doDeleteVariant}
disabled={!variantName}
/>
<FlatButton
key={'duplicate-variant'}
label={<Trans>Duplicate</Trans>}
leftIcon={<Add />}
onClick={() => setNewVariantDialogOpen(true)}
/>
<FlatButton
key={'edit-variant'}
label={<Trans>Edit</Trans>}
leftIcon={<Edit />}
onClick={editVariant}
/>
</LineStackLayout>
</Column>
</Line>
</LineStackLayout>
<ColumnStackLayout expand noMargin>
<LineStackLayout>
<FlatButton
label={<Trans>Edit</Trans>}
leftIcon={<Edit />}
onClick={editVariant}
disabled={
!eventBasedObject ||
getVariant(
eventBasedObject,
customObjectConfiguration
).getAssetStoreAssetId() !== ''
}
/>
<FlatButton
label={<Trans>Duplicate</Trans>}
leftIcon={<Add />}
onClick={() => setNewVariantDialogOpen(true)}
/>
<FlatButton
label={<Trans>Delete</Trans>}
leftIcon={<Trash />}
onClick={deleteVariant}
/>
</LineStackLayout>
<SelectField
id={'variant-name'}
floatingLabelText={<Trans>Variant</Trans>}
value={getVariantName(
eventBasedObject,
customObjectConfiguration
)}
value={variantName}
onChange={(e, i, value: string) => {
customObjectConfiguration.setVariantName(value);
forceUpdate();
@@ -377,15 +520,17 @@ const CustomObjectPropertiesEditor = (props: Props) => {
value=""
label={t`Default`}
/>
{eventBasedObject &&
{customObjectEventsBasedObject &&
mapFor(
0,
eventBasedObject.getVariants().getVariantsCount(),
customObjectEventsBasedObject
.getVariants()
.getVariantsCount(),
i => {
if (!eventBasedObject) {
if (!customObjectEventsBasedObject) {
return null;
}
const variant = eventBasedObject
const variant = customObjectEventsBasedObject
.getVariants()
.getVariantAt(i);
return (
@@ -401,37 +546,11 @@ const CustomObjectPropertiesEditor = (props: Props) => {
</ColumnStackLayout>
</>
)}
{(!getVariantName(
eventBasedObject,
customObjectConfiguration
) ||
{(!variantName ||
customObjectConfiguration.isForcedToOverrideEventsBasedObjectChildrenConfiguration()) &&
(eventBasedObject &&
(customObjectEventsBasedObject &&
(!customObjectConfiguration.isForcedToOverrideEventsBasedObjectChildrenConfiguration() &&
!customObjectConfiguration.isMarkedAsOverridingEventsBasedObjectChildrenConfiguration() ? (
<Line alignItems="center">
<Column expand noMargin>
<Text size="block-title">Children objects</Text>
</Column>
<Column alignItems="right">
<FlatButton
label={
<Trans>Override children configuration</Trans>
}
onClick={() => {
customObjectConfiguration.setMarkedAsOverridingEventsBasedObjectChildrenConfiguration(
true
);
customObjectConfiguration.clearChildrenConfiguration();
if (onObjectUpdated) {
onObjectUpdated();
}
forceUpdate();
}}
/>
</Column>
</Line>
) : (
!customObjectConfiguration.isMarkedAsOverridingEventsBasedObjectChildrenConfiguration() ? null : (
<>
<Line alignItems="center">
<Column expand noMargin>
@@ -460,11 +579,16 @@ const CustomObjectPropertiesEditor = (props: Props) => {
</Column>
)}
</Line>
{!customObjectConfiguration.isForcedToOverrideEventsBasedObjectChildrenConfiguration() && (
<ChildrenOverridingDepreciationAlert />
)}
{mapFor(
0,
eventBasedObject.getObjects().getObjectsCount(),
customObjectEventsBasedObject
.getObjects()
.getObjectsCount(),
i => {
const childObject = eventBasedObject
const childObject = customObjectEventsBasedObject
.getObjects()
.getObjectAt(i);
const childObjectConfiguration = customObjectConfiguration.getChildObjectConfiguration(
@@ -563,31 +687,32 @@ const CustomObjectPropertiesEditor = (props: Props) => {
)}
</>
)))}
{eventBasedObject && eventBasedObject.isAnimatable() && (
<Column expand>
<Text size="block-title">
<Trans>Animations</Trans>
</Text>
<AnimationList
ref={animationList}
animations={animations}
project={project}
layout={layout}
eventsFunctionsExtension={eventsFunctionsExtension}
eventsBasedObject={eventsBasedObject}
object={object}
objectName={objectName}
resourceManagementProps={resourceManagementProps}
onSizeUpdated={onSizeUpdated}
onObjectUpdated={onObjectUpdated}
isAnimationListLocked={false}
scrollView={scrollView}
onCreateMatchingSpriteCollisionMask={
onCreateMatchingSpriteCollisionMask
}
/>
</Column>
)}
{customObjectEventsBasedObject &&
customObjectEventsBasedObject.isAnimatable() && (
<Column expand>
<Text size="block-title">
<Trans>Animations</Trans>
</Text>
<AnimationList
ref={animationList}
animations={animations}
project={project}
layout={layout}
eventsFunctionsExtension={eventsFunctionsExtension}
eventsBasedObject={eventsBasedObject}
object={object}
objectName={objectName}
resourceManagementProps={resourceManagementProps}
onSizeUpdated={onSizeUpdated}
onObjectUpdated={onObjectUpdated}
isAnimationListLocked={false}
scrollView={scrollView}
onCreateMatchingSpriteCollisionMask={
onCreateMatchingSpriteCollisionMask
}
/>
</Column>
)}
</React.Fragment>
) : (
<EmptyMessage>
@@ -599,8 +724,8 @@ const CustomObjectPropertiesEditor = (props: Props) => {
)}
</ColumnStackLayout>
</ScrollView>
{eventBasedObject &&
eventBasedObject.isAnimatable() &&
{customObjectEventsBasedObject &&
customObjectEventsBasedObject.isAnimatable() &&
!isChildObject && (
<Column noMargin>
<ResponsiveLineStackLayout
@@ -736,18 +861,25 @@ const CustomObjectPropertiesEditor = (props: Props) => {
/>
</Dialog>
)}
{newVariantDialogOpen && eventBasedObject && (
{newVariantDialogOpen && customObjectEventsBasedObject && (
<NewVariantDialog
initialName={
getVariantName(eventBasedObject, customObjectConfiguration) ||
i18n._(t`New variant`)
}
onApply={name => duplicateVariant(i18n, name)}
initialName={variantName || i18n._(t`New variant`)}
onApply={name => doDuplicateVariant(i18n, name)}
onCancel={() => {
setNewVariantDialogOpen(false);
}}
/>
)}
{duplicateAndEditVariantDialogOpen && customObjectEventsBasedObject && (
<NewVariantDialog
isDuplicationBeforeEdition
initialName={variantName || i18n._(t`New variant`)}
onApply={name => duplicateAndEditVariant(i18n, name)}
onCancel={() => {
setDuplicateAndEditVariantDialogOpen(false);
}}
/>
)}
</>
)}
</I18n>

View File

@@ -20,7 +20,7 @@ import {
import { type ObjectEditorTab } from '../ObjectEditor/ObjectEditorDialog';
import type { ObjectWithContext } from '../ObjectsList/EnumerateObjects';
import { type HTMLDataset } from '../Utils/HTMLDataset';
import { getVariant } from '../ObjectEditor/Editors/CustomObjectPropertiesEditor';
import { isVariantEditable } from '../ObjectEditor/Editors/CustomObjectPropertiesEditor';
const gd: libGDevelop = global.gd;
@@ -313,6 +313,14 @@ export class ObjectTreeViewItemContent implements TreeViewItemContent {
project.getCurrentPlatform(),
object.getType()
);
const objectExtensionName = gd.PlatformExtension.getExtensionFromFullObjectType(
object.getType()
);
const customObjectExtension = project.hasEventsFunctionsExtensionNamed(
objectExtensionName
)
? project.getEventsFunctionsExtension(objectExtensionName)
: null;
return [
{
label: i18n._(t`Copy`),
@@ -372,11 +380,11 @@ export class ObjectTreeViewItemContent implements TreeViewItemContent {
project.hasEventsBasedObject(object.getType())
? {
label: i18n._(t`Edit children`),
enabled:
getVariant(
project.getEventsBasedObject(object.getType()),
gd.asCustomObjectConfiguration(object.getConfiguration())
).getAssetStoreAssetId() === '',
enabled: isVariantEditable(
gd.asCustomObjectConfiguration(object.getConfiguration()),
project.getEventsBasedObject(object.getType()),
customObjectExtension
),
click: () => {
const customObjectConfiguration = gd.asCustomObjectConfiguration(
object.getConfiguration()

View File

@@ -84,7 +84,7 @@ const ObjectsRenderingService = {
associatedObjectConfiguration: gdObjectConfiguration,
pixiContainer: PIXI.Container,
threeGroup: THREE.Group | null,
propertyOverridings: Map<string, string> = new Map<string, string>()
getPropertyOverridings: (() => Map<string, string>) | null = null
): RenderedInstance | Rendered3DInstance {
const objectType = associatedObjectConfiguration.getType();
if (threeGroup && this.renderers3D.hasOwnProperty(objectType)) {
@@ -103,7 +103,7 @@ const ObjectsRenderingService = {
associatedObjectConfiguration,
pixiContainer,
PixiResourcesLoader,
propertyOverridings
getPropertyOverridings
);
else {
if (project.hasEventsBasedObject(objectType)) {
@@ -138,7 +138,7 @@ const ObjectsRenderingService = {
pixiContainer,
threeGroup,
PixiResourcesLoader,
propertyOverridings
getPropertyOverridings
);
}
}

View File

@@ -19,7 +19,7 @@ export default class Rendered3DInstance {
_threeObject: THREE.Object3D | null;
wasUsed: boolean;
_wasDestroyed: boolean;
_propertyOverridings: Map<string, string>;
_getPropertyOverridings: (() => Map<string, string>) | null;
constructor(
project: gdProject,
@@ -28,7 +28,7 @@ export default class Rendered3DInstance {
pixiContainer: PIXI.Container,
threeGroup: THREE.Group,
pixiResourcesLoader: Class<PixiResourcesLoader>,
propertyOverridings: Map<string, string> = new Map<string, string>()
getPropertyOverridings: (() => Map<string, string>) | null = null
) {
this._pixiObject = null;
this._threeObject = null;
@@ -38,7 +38,7 @@ export default class Rendered3DInstance {
this._threeGroup = threeGroup;
this._project = project;
this._pixiResourcesLoader = pixiResourcesLoader;
this._propertyOverridings = propertyOverridings;
this._getPropertyOverridings = getPropertyOverridings;
this.wasUsed = true; //Used by InstancesRenderer to track rendered instance that are not used anymore.
this._wasDestroyed = false;
}
@@ -175,4 +175,8 @@ export default class Rendered3DInstance {
getDefaultDepth() {
return 32;
}
getPropertyOverridings(): Map<string, string> | null {
return this._getPropertyOverridings && this._getPropertyOverridings();
}
}

View File

@@ -119,7 +119,7 @@ export default class RenderedCustomObjectInstance extends Rendered3DInstance
pixiContainer: PIXI.Container,
threeGroup: THREE.Group,
pixiResourcesLoader: Class<PixiResourcesLoader>,
propertyOverridings: Map<string, string>
getPropertyOverridings: (() => Map<string, string>) | null = null
) {
super(
project,
@@ -128,7 +128,7 @@ export default class RenderedCustomObjectInstance extends Rendered3DInstance
pixiContainer,
threeGroup,
pixiResourcesLoader,
propertyOverridings
getPropertyOverridings
);
// Setup the PIXI object:
@@ -178,7 +178,7 @@ export default class RenderedCustomObjectInstance extends Rendered3DInstance
const pixiObject: PIXI.DisplayObject | null = renderedInstance.getPixiObject();
if (pixiObject) {
if (renderedInstance instanceof Rendered3DInstance) {
if (renderedInstance.isRenderedIn3D()) {
pixiObject.zOrder = instance.getZ() + renderedInstance.getDepth();
} else {
pixiObject.zOrder = instance.getZOrder();
@@ -256,26 +256,31 @@ export default class RenderedCustomObjectInstance extends Rendered3DInstance
this._associatedObjectConfiguration
);
// Apply property mapping rules on the child instance.
const childPropertyOverridings = new Map<string, string>();
const customObjectProperties = customObjectConfiguration.getProperties();
for (const propertyMappingRule of this._propertyMappingRules) {
if (propertyMappingRule.targetChild !== instance.getObjectName()) {
continue;
const getChildPropertyOverridings = () => {
const childPropertyOverridings = new Map<string, string>();
const propertyOverridings = this.getPropertyOverridings();
const customObjectProperties = customObjectConfiguration.getProperties();
for (const propertyMappingRule of this._propertyMappingRules) {
if (propertyMappingRule.targetChild !== instance.getObjectName()) {
continue;
}
const sourceValue =
propertyOverridings &&
propertyOverridings.has(propertyMappingRule.sourceProperty)
? propertyOverridings.get(propertyMappingRule.sourceProperty)
: customObjectProperties
.get(propertyMappingRule.sourceProperty)
.getValue();
if (sourceValue !== undefined) {
childPropertyOverridings.set(
propertyMappingRule.targetProperty,
sourceValue
);
}
}
const sourceValue = this._propertyOverridings.has(
propertyMappingRule.sourceProperty
)
? this._propertyOverridings.get(propertyMappingRule.sourceProperty)
: customObjectProperties
.get(propertyMappingRule.sourceProperty)
.getValue();
if (sourceValue !== undefined) {
childPropertyOverridings.set(
propertyMappingRule.targetProperty,
sourceValue
);
}
}
return childPropertyOverridings;
};
//...so let's create a renderer.
const childObjectConfiguration = this._getChildObjectConfiguration(
instance.getObjectName()
@@ -287,7 +292,7 @@ export default class RenderedCustomObjectInstance extends Rendered3DInstance
childObjectConfiguration,
this._pixiObject,
this._threeObject,
childPropertyOverridings
getChildPropertyOverridings
)
: new RenderedUnknownInstance(
this._project,

View File

@@ -15,7 +15,7 @@ export default class RenderedInstance {
_pixiObject: PIXI.DisplayObject;
wasUsed: boolean;
_wasDestroyed: boolean;
_propertyOverridings: Map<string, string>;
_getPropertyOverridings: (() => Map<string, string>) | null;
constructor(
project: gdProject,
@@ -23,7 +23,7 @@ export default class RenderedInstance {
associatedObjectConfiguration: gdObjectConfiguration,
pixiContainer: PIXI.Container,
pixiResourcesLoader: Class<PixiResourcesLoader>,
propertyOverridings: Map<string, string> = new Map<string, string>()
getPropertyOverridings: (() => Map<string, string>) | null = null
) {
this._pixiObject = null;
this._instance = instance;
@@ -31,7 +31,7 @@ export default class RenderedInstance {
this._pixiContainer = pixiContainer;
this._project = project;
this._pixiResourcesLoader = pixiResourcesLoader;
this._propertyOverridings = propertyOverridings;
this._getPropertyOverridings = getPropertyOverridings;
this.wasUsed = true; //Used by InstancesRenderer to track rendered instance that are not used anymore.
this._wasDestroyed = false;
}
@@ -134,4 +134,8 @@ export default class RenderedInstance {
getDefaultDepth(): number {
return 0;
}
getPropertyOverridings(): Map<string, string> | null {
return this._getPropertyOverridings && this._getPropertyOverridings();
}
}

View File

@@ -39,7 +39,7 @@ export default class RenderedTextInstance extends RenderedInstance {
associatedObjectConfiguration: gdObjectConfiguration,
pixiContainer: PIXI.Container,
pixiResourcesLoader: Class<PixiResourcesLoader>,
propertyOverridings: Map<string, string>
getPropertyOverridings: (() => Map<string, string>) | null
) {
super(
project,
@@ -47,7 +47,7 @@ export default class RenderedTextInstance extends RenderedInstance {
associatedObjectConfiguration,
pixiContainer,
pixiResourcesLoader,
propertyOverridings
getPropertyOverridings
);
const style = new PIXI.TextStyle({
@@ -86,9 +86,11 @@ export default class RenderedTextInstance extends RenderedInstance {
const textObjectConfiguration = gd.asTextObjectConfiguration(
this._associatedObjectConfiguration
);
this._pixiObject.text = this._propertyOverridings.has('Text')
? this._propertyOverridings.get('Text')
: textObjectConfiguration.getText();
const propertyOverridings = this.getPropertyOverridings();
this._pixiObject.text =
propertyOverridings && propertyOverridings.has('Text')
? propertyOverridings.get('Text')
: textObjectConfiguration.getText();
//Update style, only if needed to avoid destroying text rendering performances
if (

View File

@@ -25,6 +25,7 @@ import {
type AssetShortHeader,
type PrivateAssetPack,
type PrivateGameTemplate,
type Bundle,
} from '../Utils/GDevelopServices/Asset';
import { type Purchase } from '../Utils/GDevelopServices/Shop';
@@ -40,9 +41,11 @@ export type AuthenticatedUser = {|
receivedAssetPacks: ?Array<PrivateAssetPack>,
receivedAssetShortHeaders: ?Array<AssetShortHeader>,
receivedGameTemplates: ?Array<PrivateGameTemplate>,
receivedBundles: ?Array<Bundle>,
gameTemplatePurchases: ?Array<Purchase>,
assetPackPurchases: ?Array<Purchase>,
coursePurchases: ?Array<Purchase>,
bundlePurchases: ?Array<Purchase>,
recommendations: ?Array<Recommendation>,
notifications: ?Array<Notification>,
userEarningsBalance: ?UserEarningsBalance,
@@ -76,6 +79,7 @@ export type AuthenticatedUser = {|
onRefreshGameTemplatePurchases: () => Promise<void>,
onRefreshAssetPackPurchases: () => Promise<void>,
onRefreshCoursePurchases: () => Promise<void>,
onRefreshBundlePurchases: () => Promise<void>,
onRefreshEarningsBalance: () => Promise<void>,
onRefreshNotifications: () => Promise<void>,
onPurchaseSuccessful: () => Promise<void>,
@@ -94,6 +98,7 @@ export const authenticatedUserPropertiesLoadingState = {
receivedAssetPacks: null,
receivedAssetShortHeaders: null,
receivedGameTemplates: null,
receivedBundles: null,
badges: null,
notifications: null,
};
@@ -108,6 +113,7 @@ export const initialAuthenticatedUser = {
gameTemplatePurchases: null,
assetPackPurchases: null,
coursePurchases: null,
bundlePurchases: null,
recommendations: null,
subscription: null,
usages: null,
@@ -135,6 +141,7 @@ export const initialAuthenticatedUser = {
onRefreshGameTemplatePurchases: async () => {},
onRefreshAssetPackPurchases: async () => {},
onRefreshCoursePurchases: async () => {},
onRefreshBundlePurchases: async () => {},
onRefreshEarningsBalance: async () => {},
onRefreshNotifications: async () => {},
onPurchaseSuccessful: async () => {},
@@ -158,6 +165,7 @@ export const authenticatedUserLoggedOutAttributes = {
receivedAssetPacks: [], // Initialize to empty array to indicate that the loading is done.
receivedAssetShortHeaders: [], // Initialize to empty array to indicate that the loading is done.
receivedGameTemplates: [], // Initialize to empty array to indicate that the loading is done.
receivedBundles: [], // Initialize to empty array to indicate that the loading is done.
subscription: null,
userEarningsBalance: null,
usages: null,

View File

@@ -51,6 +51,7 @@ import {
listReceivedAssetShortHeaders,
listReceivedAssetPacks,
listReceivedGameTemplates,
listReceivedBundles,
} from '../Utils/GDevelopServices/Asset';
import { Trans } from '@lingui/macro';
import Snackbar from '@material-ui/core/Snackbar';
@@ -220,6 +221,7 @@ export default class AuthenticatedUserProvider extends React.Component<
onRefreshGameTemplatePurchases: this._fetchUserGameTemplatePurchases,
onRefreshAssetPackPurchases: this._fetchUserAssetPackPurchases,
onRefreshCoursePurchases: this._fetchUserCoursePurchases,
onRefreshBundlePurchases: this._fetchUserBundlePurchases,
onRefreshEarningsBalance: this._fetchEarningsBalance,
onRefreshNotifications: this._fetchUserNotifications,
onPurchaseSuccessful: this._fetchUserProducts,
@@ -529,6 +531,20 @@ export default class AuthenticatedUserProvider extends React.Component<
console.error('Error while loading received game templates:', error);
}
);
listReceivedBundles(authentication.getAuthorizationHeader, {
userId: firebaseUser.uid,
}).then(
receivedBundles =>
this.setState(({ authenticatedUser }) => ({
authenticatedUser: {
...authenticatedUser,
receivedBundles,
},
})),
error => {
console.error('Error while loading received bundles:', error);
}
);
listUserPurchases(authentication.getAuthorizationHeader, {
userId: firebaseUser.uid,
productType: 'game-template',
@@ -577,6 +593,22 @@ export default class AuthenticatedUserProvider extends React.Component<
console.error('Error while loading course purchases:', error);
}
);
listUserPurchases(authentication.getAuthorizationHeader, {
userId: firebaseUser.uid,
productType: 'bundle',
role: 'receiver',
}).then(
bundlePurchases =>
this.setState(({ authenticatedUser }) => ({
authenticatedUser: {
...authenticatedUser,
bundlePurchases,
},
})),
error => {
console.error('Error while loading bundle purchases:', error);
}
);
this._fetchUserBadges();
this._fetchAchievements();
this._fetchUserNotifications();
@@ -814,6 +846,30 @@ export default class AuthenticatedUserProvider extends React.Component<
}
};
_fetchUserBundles = async () => {
const { authentication } = this.props;
const firebaseUser = this.state.authenticatedUser.firebaseUser;
if (!firebaseUser) return;
try {
const receivedBundles = await listReceivedBundles(
authentication.getAuthorizationHeader,
{
userId: firebaseUser.uid,
}
);
this.setState(({ authenticatedUser }) => ({
authenticatedUser: {
...authenticatedUser,
receivedBundles,
},
}));
} catch (error) {
console.error('Error while loading received bundles:', error);
}
};
_fetchUserGameTemplatePurchases = async () => {
const { authentication } = this.props;
const firebaseUser = this.state.authenticatedUser.firebaseUser;
@@ -892,11 +948,38 @@ export default class AuthenticatedUserProvider extends React.Component<
}
};
_fetchUserBundlePurchases = async () => {
const { authentication } = this.props;
const firebaseUser = this.state.authenticatedUser.firebaseUser;
if (!firebaseUser) return;
try {
const bundlePurchases = await listUserPurchases(
authentication.getAuthorizationHeader,
{
userId: firebaseUser.uid,
productType: 'bundle',
role: 'receiver',
}
);
this.setState(({ authenticatedUser }) => ({
authenticatedUser: {
...authenticatedUser,
bundlePurchases,
},
}));
} catch (error) {
console.error('Error while loading bundle purchases:', error);
}
};
_fetchUserProducts = async () => {
await Promise.all([
this._fetchUserAssetPacks(),
this._fetchUserAssetShortHeaders(),
this._fetchUserGameTemplates(),
this._fetchUserBundles(),
]);
};

View File

@@ -3,7 +3,6 @@ import { Trans, t } from '@lingui/macro';
import * as React from 'react';
import { I18n } from '@lingui/react';
import { type I18n as I18nType } from '@lingui/core';
import { type MessageDescriptor } from '../Utils/i18n/MessageDescriptor.flow';
import FlatButton from '../UI/FlatButton';
import Dialog, { DialogPrimaryButton } from '../UI/Dialog';
@@ -387,7 +386,7 @@ const EditProfileDialog = ({
const canDelete = !actionInProgress;
const onDeleteAccount = React.useCallback(
async (i18n: I18nType) => {
async () => {
if (!canDelete || isStudentAccount) {
return;
}

View File

@@ -2,7 +2,6 @@
import * as React from 'react';
import { I18n } from '@lingui/react';
import {
type SubscriptionPlan,
type SubscriptionPlanWithPricingSystems,
type SubscriptionPlanPricingSystem,
} from '../../Utils/GDevelopServices/Usage';
@@ -208,11 +207,30 @@ export const getPlanPrices = ({
);
};
// Helper in case we only have access to the plan ID and not the full plan object.
export const getPlanInferredNameFromId = (planId: string): string => {
switch (planId) {
case 'gdevelop_silver':
case 'gdevelop_indie': // legacy
return 'Silver';
case 'gdevelop_gold':
case 'gdevelop_pro': // legacy
return 'Gold';
case 'gdevelop_education':
return 'Education';
case 'gdevelop_startup':
case 'gdevelop_enterprise':
return 'Pro';
default:
return 'GDevelop';
}
};
export const getPlanIcon = ({
subscriptionPlan,
planId,
logoSize,
}: {
subscriptionPlan: SubscriptionPlan | SubscriptionPlanWithPricingSystems,
planId: string,
logoSize: number,
}): React.Node => {
const GDEVELOP_LOGO_PADDING = 10;
@@ -220,7 +238,7 @@ export const getPlanIcon = ({
// so we increase the size.
const PLAN_LOGO_SIZE = logoSize + 2 * GDEVELOP_LOGO_PADDING;
switch (subscriptionPlan.id) {
switch (planId) {
case 'gdevelop_silver':
case 'gdevelop_indie': // legacy
return (
@@ -318,7 +336,7 @@ const PlanCard = (props: Props) => {
const { isMobile } = useResponsiveWindowSize();
const planIcon = getPlanIcon({
subscriptionPlan: props.subscriptionPlanWithPricingSystems,
planId: props.subscriptionPlanWithPricingSystems.id,
logoSize: 25,
});
const mainPricingSystem = props.subscriptionPlanWithPricingSystems.pricingSystems.find(

View File

@@ -38,7 +38,7 @@ const PlanSmallCard = (props: Props) => {
const { isMobile } = useResponsiveWindowSize();
const planIcon = getPlanIcon({
subscriptionPlan: props.subscriptionPlanWithPricingSystems,
planId: props.subscriptionPlanWithPricingSystems.id,
logoSize: 40,
});

View File

@@ -133,7 +133,7 @@ const PromotionSubscriptionPlan = ({
const [period, setPeriod] = React.useState<'year' | 'month'>('year');
const planIcon = getPlanIcon({
subscriptionPlan: subscriptionPlanWithPricingSystems,
planId: subscriptionPlanWithPricingSystems.id,
logoSize: 12,
});

View File

@@ -135,7 +135,7 @@ const SubscriptionOptions = ({
<Column expand noMargin>
<LineStackLayout noMargin alignItems="center">
{getPlanIcon({
subscriptionPlan: subscriptionPlanWithPricingSystems,
planId: subscriptionPlanWithPricingSystems.id,
logoSize: 12,
})}
<Text

View File

@@ -470,7 +470,7 @@ export default function SubscriptionDialog({
>
<Line alignItems="center" noMargin>
{getPlanIcon({
subscriptionPlan: userSubscriptionPlanWithPricingSystems,
planId: userSubscriptionPlanWithPricingSystems.id,
logoSize: 20,
})}
<Text size="block-title">

View File

@@ -57,6 +57,7 @@ import RaisedButton from '../UI/RaisedButton';
import ArrowRight from '../UI/CustomSvgIcons/ArrowRight';
import Chip from '../UI/Chip';
import { LineStackLayout } from '../UI/Layout';
import { BundleStoreContext } from '../AssetStore/Bundles/BundleStoreContext';
const electron = optionalRequire('electron');
const remote = optionalRequire('@electron/remote');
@@ -108,7 +109,11 @@ type Props = {|
newProjectSetup: NewProjectSetup,
i18n: I18nType
) => Promise<void>,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
onOpenAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
selectedExampleShortHeader: ?ExampleShortHeader,
onSelectExampleShortHeader: (exampleShortHeader: ?ExampleShortHeader) => void,
selectedPrivateGameTemplateListingData: ?PrivateGameTemplateListingData,
@@ -141,7 +146,9 @@ const NewProjectSetupDialog = ({
onOpenLoginDialog,
onOpenCreateAccountDialog,
receivedGameTemplates,
receivedBundles,
gameTemplatePurchases,
bundlePurchases,
} = authenticatedUser;
const [
emptyProjectSelected,
@@ -155,6 +162,7 @@ const NewProjectSetupDialog = ({
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
);
const { bundleListingDatas } = React.useContext(BundleStoreContext);
const isOnline = useOnlineStatus();
const { values, setNewProjectsDefaultStorageProviderName } = React.useContext(
PreferencesContext
@@ -340,15 +348,27 @@ const NewProjectSetupDialog = ({
productId: selectedPrivateGameTemplateListingData
? selectedPrivateGameTemplateListingData.id
: null,
receivedProducts: receivedGameTemplates,
productPurchases: gameTemplatePurchases,
allProductListingDatas: privateGameTemplateListingDatas,
receivedProducts: [
...(receivedGameTemplates || []),
...(receivedBundles || []),
],
productPurchases: [
...(gameTemplatePurchases || []),
...(bundlePurchases || []),
],
allProductListingDatas: [
...(privateGameTemplateListingDatas || []),
...(bundleListingDatas || []),
],
}),
[
gameTemplatePurchases,
bundlePurchases,
selectedPrivateGameTemplateListingData,
privateGameTemplateListingDatas,
bundleListingDatas,
receivedGameTemplates,
receivedBundles,
]
);
const noGameTemplateSelectedOrSelectedAndOwned =
@@ -606,7 +626,13 @@ const NewProjectSetupDialog = ({
label={<Trans>Try the AI agent</Trans>}
rightIcon={<ArrowRight />}
style={styles.tryAIAgentButton}
onClick={() => onOpenAskAi('agent')}
onClick={() =>
onOpenAskAi({
mode: 'agent',
aiRequestId: null,
paneIdentifier: 'center',
})
}
/>
</ResponsiveLineStackLayout>
{isOnline ? (

View File

@@ -16,9 +16,7 @@ const useClasses = (rotating, size) =>
'&::before': {
content: "''",
display: 'block',
background: `conic-gradient(${
theme.palette.type === 'light' ? '#f0f0f0' : '#262231'
}, #b07bf7)`,
background: `conic-gradient(from -18deg at 59.52% 50%, #FFBC57 0deg, #F6945B 157.5deg, #DFA9E4 257.88461208343506deg, rgba(201, 182, 252, 0.00) 360deg)`,
width: 'calc(100% * 1.41421356237)',
paddingBottom: 'calc(100% * 1.41421356237)',
position: 'absolute',

View File

@@ -0,0 +1,163 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import { LineStackLayout } from '../UI/Layout';
import RedemptionCodeIcon from '../UI/CustomSvgIcons/RedemptionCode';
import Text from '../UI/Text';
import Dialog from '../UI/Dialog';
import FlatButton from '../UI/FlatButton';
import {
getRedemptionCodes,
type RedemptionCode,
} from '../Utils/GDevelopServices/Usage';
import PlaceholderLoader from '../UI/PlaceholderLoader';
import {
Table,
TableBody,
TableHeader,
TableHeaderColumn,
TableRow,
TableRowColumn,
} from '../UI/Table';
import {
getPlanIcon,
getPlanInferredNameFromId,
} from '../Profile/Subscription/PlanCard';
import AlertMessage from '../UI/AlertMessage';
import { formatDurationOfRedemptionCode } from './Utils';
type Props = {|
onClose: () => void,
|};
const RedemptionCodesDialog = ({ onClose }: Props) => {
const { profile, getAuthorizationHeader } = React.useContext(
AuthenticatedUserContext
);
const [
redemptionCodes,
setRedemptionCodes,
] = React.useState<?Array<RedemptionCode>>(null);
React.useEffect(
() => {
if (!profile) {
return;
}
const fetchRedemptionCodes = async () => {
const codes = await getRedemptionCodes(
getAuthorizationHeader,
profile.id
);
setRedemptionCodes(codes);
};
fetchRedemptionCodes();
},
[profile, getAuthorizationHeader]
);
if (!profile) {
return null; // Should not be able to open this dialog without a profile.
}
return (
<Dialog
title={<Trans>Redemption Codes</Trans>}
maxWidth="md"
open
onRequestClose={onClose}
actions={[
<FlatButton
key="close"
label={<Trans>Close</Trans>}
onClick={onClose}
id="close-button"
fullWidth
primary
/>,
]}
flexColumnBody
>
<AlertMessage kind="info">
<Trans>
To use a code, go to your profile in the subscription section!
</Trans>
</AlertMessage>
<LineStackLayout>
{<RedemptionCodeIcon />}
<Text size="sub-title">
<Trans>Here are your redemption codes:</Trans>
</Text>
</LineStackLayout>
{!redemptionCodes ? (
<PlaceholderLoader />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHeaderColumn>
<Trans>Code</Trans>
</TableHeaderColumn>
<TableHeaderColumn>
<Trans>Subscription Plan</Trans>
</TableHeaderColumn>
<TableHeaderColumn>
<Trans>Duration</Trans>
</TableHeaderColumn>
<TableHeaderColumn>
<Trans>Remaining usage</Trans>
</TableHeaderColumn>
</TableRow>
</TableHeader>
<TableBody>
{redemptionCodes.map(code => {
const planId = code.givenSubscriptionPlanId;
return (
<TableRow key={code.code}>
<TableRowColumn>
<Text noMargin allowSelection>
{code.code}
</Text>
</TableRowColumn>
<TableRowColumn>
{planId && (
<LineStackLayout noMargin alignItems="center">
{getPlanIcon({
planId,
logoSize: 20,
})}
<Text>{getPlanInferredNameFromId(planId)}</Text>
</LineStackLayout>
)}
</TableRowColumn>
<TableRowColumn>
<Text>
{formatDurationOfRedemptionCode(code.durationInDays)}
</Text>
</TableRowColumn>
<TableRowColumn>
<Text>
{code.remainingUsageCount ? (
code.remainingUsageCount === 1 ? (
<Trans>{code.remainingUsageCount} use left</Trans>
) : (
<Trans>{code.remainingUsageCount} uses left</Trans>
)
) : (
<Trans>No uses left</Trans>
)}
</Text>
</TableRowColumn>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</Dialog>
);
};
export default RedemptionCodesDialog;

View File

@@ -0,0 +1,16 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
export const formatDurationOfRedemptionCode = (
durationInDays: number
): React.Node => {
if (durationInDays < 30) {
return <Trans>{durationInDays} days</Trans>;
}
const roundedMonths = Math.round(durationInDays / 30);
if (roundedMonths === 1) {
return <Trans>1 month</Trans>;
}
return <Trans>{roundedMonths} months</Trans>;
};

Some files were not shown because too many files have changed in this diff Show More