mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
Merge remote-tracking branch 'official/master' into move-instance-in-game-2
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
Thumbs.db
|
||||
.claude
|
||||
|
3
.vscode/tasks.json
vendored
3
.vscode/tasks.json
vendored
@@ -38,8 +38,7 @@
|
||||
"presentation": {
|
||||
"reveal": "silent"
|
||||
},
|
||||
"isBackground": true,
|
||||
"runOptions": { "instanceLimit": 1, "runOn": "folderOpen" }
|
||||
"isBackground": true
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
|
@@ -8,6 +8,8 @@
|
||||
#include "GDCore/Serialization/SerializerElement.h"
|
||||
|
||||
namespace gd {
|
||||
|
||||
gd::String Effect::badStringParameterValue;
|
||||
|
||||
void Effect::SerializeTo(SerializerElement& element) const {
|
||||
element.SetAttribute("name", GetName());
|
||||
|
@@ -34,39 +34,42 @@ class GD_CORE_API Effect {
|
||||
void SetFolded(bool fold = true) { folded = fold; }
|
||||
bool IsFolded() const { return folded; }
|
||||
|
||||
void SetDoubleParameter(const gd::String& name, double value) {
|
||||
void SetDoubleParameter(const gd::String &name, double value) {
|
||||
doubleParameters[name] = value;
|
||||
}
|
||||
|
||||
double GetDoubleParameter(const gd::String& name) {
|
||||
return doubleParameters[name];
|
||||
double GetDoubleParameter(const gd::String &name) const {
|
||||
auto itr = doubleParameters.find(name);
|
||||
return itr == doubleParameters.end() ? 0 : itr->second;
|
||||
}
|
||||
|
||||
bool HasDoubleParameter(const gd::String& name) {
|
||||
bool HasDoubleParameter(const gd::String &name) const {
|
||||
return doubleParameters.find(name) != doubleParameters.end();
|
||||
}
|
||||
|
||||
void SetStringParameter(const gd::String& name, const gd::String& value) {
|
||||
void SetStringParameter(const gd::String &name, const gd::String &value) {
|
||||
stringParameters[name] = value;
|
||||
}
|
||||
|
||||
const gd::String& GetStringParameter(const gd::String& name) {
|
||||
return stringParameters[name];
|
||||
const gd::String &GetStringParameter(const gd::String &name) const {
|
||||
auto itr = stringParameters.find(name);
|
||||
return itr == stringParameters.end() ? badStringParameterValue : itr->second;
|
||||
}
|
||||
|
||||
bool HasStringParameter(const gd::String& name) {
|
||||
bool HasStringParameter(const gd::String &name) const {
|
||||
return stringParameters.find(name) != stringParameters.end();
|
||||
}
|
||||
|
||||
void SetBooleanParameter(const gd::String& name, bool value) {
|
||||
void SetBooleanParameter(const gd::String &name, bool value) {
|
||||
booleanParameters[name] = value;
|
||||
}
|
||||
|
||||
bool GetBooleanParameter(const gd::String& name) {
|
||||
return booleanParameters[name];
|
||||
bool GetBooleanParameter(const gd::String &name) const {
|
||||
auto itr = booleanParameters.find(name);
|
||||
return itr == booleanParameters.end() ? false : itr->second;
|
||||
}
|
||||
|
||||
bool HasBooleanParameter(const gd::String& name) {
|
||||
bool HasBooleanParameter(const gd::String &name) const {
|
||||
return booleanParameters.find(name) != booleanParameters.end();
|
||||
}
|
||||
|
||||
@@ -105,6 +108,9 @@ class GD_CORE_API Effect {
|
||||
std::map<gd::String, double> doubleParameters; ///< Values of parameters being doubles, keyed by names.
|
||||
std::map<gd::String, gd::String> stringParameters; ///< Values of parameters being strings, keyed by names.
|
||||
std::map<gd::String, bool> booleanParameters; ///< Values of parameters being booleans, keyed by names.
|
||||
|
||||
static gd::String badStringParameterValue; ///< Empty string returned by
|
||||
///< GeStringParameter
|
||||
};
|
||||
|
||||
} // namespace gd
|
||||
|
@@ -5,8 +5,6 @@ namespace gdjs {
|
||||
type Object3DNetworkSyncDataType = {
|
||||
// z is position on the Z axis, different from zo, which is Z order
|
||||
z: number;
|
||||
w: number;
|
||||
h: number;
|
||||
d: number;
|
||||
rx: number;
|
||||
ry: number;
|
||||
@@ -116,8 +114,6 @@ namespace gdjs {
|
||||
return {
|
||||
...super.getNetworkSyncData(),
|
||||
z: this.getZ(),
|
||||
w: this.getWidth(),
|
||||
h: this.getHeight(),
|
||||
d: this.getDepth(),
|
||||
rx: this.getRotationX(),
|
||||
ry: this.getRotationY(),
|
||||
@@ -130,8 +126,6 @@ namespace gdjs {
|
||||
updateFromNetworkSyncData(networkSyncData: Object3DNetworkSyncData) {
|
||||
super.updateFromNetworkSyncData(networkSyncData);
|
||||
if (networkSyncData.z !== undefined) this.setZ(networkSyncData.z);
|
||||
if (networkSyncData.w !== undefined) this.setWidth(networkSyncData.w);
|
||||
if (networkSyncData.h !== undefined) this.setHeight(networkSyncData.h);
|
||||
if (networkSyncData.d !== undefined) this.setDepth(networkSyncData.d);
|
||||
if (networkSyncData.rx !== undefined)
|
||||
this.setRotationX(networkSyncData.rx);
|
||||
|
@@ -87,8 +87,8 @@ namespace gdjs {
|
||||
this._boxMesh = boxMesh;
|
||||
this._cube3DRuntimeObject = runtimeObject;
|
||||
|
||||
boxMesh.receiveShadow = this._cube3DRuntimeObject._isCastingShadow;
|
||||
boxMesh.castShadow = this._cube3DRuntimeObject._isReceivingShadow;
|
||||
boxMesh.receiveShadow = this._cube3DRuntimeObject._isReceivingShadow;
|
||||
boxMesh.castShadow = this._cube3DRuntimeObject._isCastingShadow;
|
||||
this.updateSize();
|
||||
this.updatePosition();
|
||||
this.updateRotation();
|
||||
|
@@ -1,4 +1,12 @@
|
||||
namespace gdjs {
|
||||
type CustomObject3DNetworkSyncDataType = CustomObjectNetworkSyncDataType & {
|
||||
z: float;
|
||||
d: float;
|
||||
rx: float;
|
||||
ry: float;
|
||||
ifz: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Base class for 3D custom objects.
|
||||
*/
|
||||
@@ -77,6 +85,30 @@ namespace gdjs {
|
||||
}
|
||||
}
|
||||
|
||||
getNetworkSyncData(): CustomObject3DNetworkSyncDataType {
|
||||
return {
|
||||
...super.getNetworkSyncData(),
|
||||
z: this.getZ(),
|
||||
d: this.getDepth(),
|
||||
rx: this.getRotationX(),
|
||||
ry: this.getRotationY(),
|
||||
ifz: this.isFlippedZ(),
|
||||
};
|
||||
}
|
||||
|
||||
updateFromNetworkSyncData(
|
||||
networkSyncData: CustomObject3DNetworkSyncDataType
|
||||
): void {
|
||||
super.updateFromNetworkSyncData(networkSyncData);
|
||||
if (networkSyncData.z !== undefined) this.setZ(networkSyncData.z);
|
||||
if (networkSyncData.d !== undefined) this.setDepth(networkSyncData.d);
|
||||
if (networkSyncData.rx !== undefined)
|
||||
this.setRotationX(networkSyncData.rx);
|
||||
if (networkSyncData.ry !== undefined)
|
||||
this.setRotationY(networkSyncData.ry);
|
||||
if (networkSyncData.ifz !== undefined) this.flipZ(networkSyncData.ifz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the object position on the Z axis.
|
||||
*/
|
||||
|
@@ -18,19 +18,18 @@ namespace gdjs {
|
||||
return new gdjs.PixiFiltersTools.EmptyFilter();
|
||||
}
|
||||
return new (class implements gdjs.PixiFiltersTools.Filter {
|
||||
private _light: THREE.DirectionalLight;
|
||||
private _isEnabled: boolean = false;
|
||||
private _top: string = 'Y-';
|
||||
private _top: string = 'Z+';
|
||||
private _elevation: float = 45;
|
||||
private _rotation: float = 0;
|
||||
|
||||
private _shadowMapDirty = true;
|
||||
private _shadowMapSize: float = 1024;
|
||||
private _minimumShadowBias: float = 0;
|
||||
|
||||
private _shadowCameraDirty = true;
|
||||
private _distanceFromCamera: float = 1500;
|
||||
private _frustumSize: float = 4000;
|
||||
|
||||
private _isEnabled: boolean = false;
|
||||
private _light: THREE.DirectionalLight;
|
||||
private _shadowMapDirty = true;
|
||||
private _shadowCameraDirty = true;
|
||||
private _shadowCameraHelper: THREE.CameraHelper | null;
|
||||
|
||||
constructor() {
|
||||
|
@@ -18,18 +18,15 @@ namespace gdjs {
|
||||
return new gdjs.PixiFiltersTools.EmptyFilter();
|
||||
}
|
||||
return new (class implements gdjs.PixiFiltersTools.Filter {
|
||||
light: THREE.HemisphereLight;
|
||||
rotationObject: THREE.Group;
|
||||
_top: string = 'Z+';
|
||||
_elevation: float = 90;
|
||||
_rotation: float = 0;
|
||||
|
||||
_isEnabled: boolean = false;
|
||||
top: string = 'Y-';
|
||||
elevation: float = 45;
|
||||
rotation: float = 0;
|
||||
_light: THREE.HemisphereLight;
|
||||
|
||||
constructor() {
|
||||
this.light = new THREE.HemisphereLight();
|
||||
this.light.position.set(1, 0, 0);
|
||||
this.rotationObject = new THREE.Group();
|
||||
this.rotationObject.add(this.light);
|
||||
this._light = new THREE.HemisphereLight();
|
||||
this.updateRotation();
|
||||
}
|
||||
|
||||
@@ -54,7 +51,7 @@ namespace gdjs {
|
||||
if (!scene) {
|
||||
return false;
|
||||
}
|
||||
scene.add(this.rotationObject);
|
||||
scene.add(this._light);
|
||||
this._isEnabled = true;
|
||||
return true;
|
||||
}
|
||||
@@ -66,96 +63,106 @@ namespace gdjs {
|
||||
if (!scene) {
|
||||
return false;
|
||||
}
|
||||
scene.remove(this.rotationObject);
|
||||
scene.remove(this._light);
|
||||
this._isEnabled = false;
|
||||
return true;
|
||||
}
|
||||
updatePreRender(target: gdjs.EffectsTarget): any {}
|
||||
updateDoubleParameter(parameterName: string, value: number): void {
|
||||
if (parameterName === 'intensity') {
|
||||
this.light.intensity = value;
|
||||
this._light.intensity = value;
|
||||
} else if (parameterName === 'elevation') {
|
||||
this.elevation = value;
|
||||
this._elevation = value;
|
||||
this.updateRotation();
|
||||
} else if (parameterName === 'rotation') {
|
||||
this.rotation = value;
|
||||
this._rotation = value;
|
||||
this.updateRotation();
|
||||
}
|
||||
}
|
||||
getDoubleParameter(parameterName: string): number {
|
||||
if (parameterName === 'intensity') {
|
||||
return this.light.intensity;
|
||||
return this._light.intensity;
|
||||
} else if (parameterName === 'elevation') {
|
||||
return this.elevation;
|
||||
return this._elevation;
|
||||
} else if (parameterName === 'rotation') {
|
||||
return this.rotation;
|
||||
return this._rotation;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
updateStringParameter(parameterName: string, value: string): void {
|
||||
if (parameterName === 'skyColor') {
|
||||
this.light.color = new THREE.Color(
|
||||
this._light.color = new THREE.Color(
|
||||
gdjs.rgbOrHexStringToNumber(value)
|
||||
);
|
||||
}
|
||||
if (parameterName === 'groundColor') {
|
||||
this.light.groundColor = new THREE.Color(
|
||||
this._light.groundColor = new THREE.Color(
|
||||
gdjs.rgbOrHexStringToNumber(value)
|
||||
);
|
||||
}
|
||||
if (parameterName === 'top') {
|
||||
this.top = value;
|
||||
this._top = value;
|
||||
this.updateRotation();
|
||||
}
|
||||
}
|
||||
updateColorParameter(parameterName: string, value: number): void {
|
||||
if (parameterName === 'skyColor') {
|
||||
this.light.color.setHex(value);
|
||||
this._light.color.setHex(value);
|
||||
}
|
||||
if (parameterName === 'groundColor') {
|
||||
this.light.groundColor.setHex(value);
|
||||
this._light.groundColor.setHex(value);
|
||||
}
|
||||
}
|
||||
getColorParameter(parameterName: string): number {
|
||||
if (parameterName === 'skyColor') {
|
||||
return this.light.color.getHex();
|
||||
return this._light.color.getHex();
|
||||
}
|
||||
if (parameterName === 'groundColor') {
|
||||
return this.light.groundColor.getHex();
|
||||
return this._light.groundColor.getHex();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
updateBooleanParameter(parameterName: string, value: boolean): void {}
|
||||
updateRotation() {
|
||||
if (this.top === 'Z+') {
|
||||
// 0° is a light from the right of the screen.
|
||||
this.rotationObject.rotation.z = gdjs.toRad(this.rotation);
|
||||
this.rotationObject.rotation.y = -gdjs.toRad(this.elevation);
|
||||
if (this._top === 'Y-') {
|
||||
// `rotation` at 0° becomes a light from Z+.
|
||||
this._light.position.set(
|
||||
Math.cos(gdjs.toRad(-this._rotation + 90)) *
|
||||
Math.cos(gdjs.toRad(this._elevation)),
|
||||
-Math.sin(gdjs.toRad(this._elevation)),
|
||||
Math.sin(gdjs.toRad(-this._rotation + 90)) *
|
||||
Math.cos(gdjs.toRad(this._elevation))
|
||||
);
|
||||
} else {
|
||||
// 0° becomes a light from Z+.
|
||||
this.rotationObject.rotation.y = gdjs.toRad(this.rotation - 90);
|
||||
this.rotationObject.rotation.z = -gdjs.toRad(this.elevation);
|
||||
// `rotation` at 0° is a light from the right of the screen.
|
||||
this._light.position.set(
|
||||
Math.cos(gdjs.toRad(this._rotation)) *
|
||||
Math.cos(gdjs.toRad(this._elevation)),
|
||||
Math.sin(gdjs.toRad(this._rotation)) *
|
||||
Math.cos(gdjs.toRad(this._elevation)),
|
||||
Math.sin(gdjs.toRad(this._elevation))
|
||||
);
|
||||
}
|
||||
}
|
||||
getNetworkSyncData(): HemisphereLightFilterNetworkSyncData {
|
||||
return {
|
||||
i: this.light.intensity,
|
||||
sc: this.light.color.getHex(),
|
||||
gc: this.light.groundColor.getHex(),
|
||||
e: this.elevation,
|
||||
r: this.rotation,
|
||||
t: this.top,
|
||||
i: this._light.intensity,
|
||||
sc: this._light.color.getHex(),
|
||||
gc: this._light.groundColor.getHex(),
|
||||
e: this._elevation,
|
||||
r: this._rotation,
|
||||
t: this._top,
|
||||
};
|
||||
}
|
||||
updateFromNetworkSyncData(
|
||||
syncData: HemisphereLightFilterNetworkSyncData
|
||||
): void {
|
||||
this.light.intensity = syncData.i;
|
||||
this.light.color.setHex(syncData.sc);
|
||||
this.light.groundColor.setHex(syncData.gc);
|
||||
this.elevation = syncData.e;
|
||||
this.rotation = syncData.r;
|
||||
this.top = syncData.t;
|
||||
this._light.intensity = syncData.i;
|
||||
this._light.color.setHex(syncData.sc);
|
||||
this._light.groundColor.setHex(syncData.gc);
|
||||
this._elevation = syncData.e;
|
||||
this._rotation = syncData.r;
|
||||
this._top = syncData.t;
|
||||
this.updateRotation();
|
||||
}
|
||||
})();
|
||||
|
@@ -1918,11 +1918,11 @@ module.exports = {
|
||||
.setType('number');
|
||||
properties
|
||||
.getOrCreate('top')
|
||||
.setValue('Y-')
|
||||
.setValue('Z+')
|
||||
.setLabel(_('3D world top'))
|
||||
.setType('choice')
|
||||
.addExtraInfo('Y-')
|
||||
.addExtraInfo('Z+')
|
||||
.addExtraInfo('Y-')
|
||||
.setGroup(_('Orientation'));
|
||||
properties
|
||||
.getOrCreate('elevation')
|
||||
@@ -1976,6 +1976,7 @@ module.exports = {
|
||||
.setValue('1500')
|
||||
.setLabel(_("Distance from layer's camera"))
|
||||
.setType('number')
|
||||
.setGroup(_('Shadows'))
|
||||
.setAdvanced(true);
|
||||
}
|
||||
{
|
||||
@@ -2008,11 +2009,11 @@ module.exports = {
|
||||
.setType('number');
|
||||
properties
|
||||
.getOrCreate('top')
|
||||
.setValue('Y-')
|
||||
.setValue('Z+')
|
||||
.setLabel(_('3D world top'))
|
||||
.setType('choice')
|
||||
.addExtraInfo('Y-')
|
||||
.addExtraInfo('Z+')
|
||||
.addExtraInfo('Y-')
|
||||
.setGroup(_('Orientation'));
|
||||
properties
|
||||
.getOrCreate('elevation')
|
||||
|
@@ -31,6 +31,73 @@ module.exports = {
|
||||
.addInstructionOrExpressionGroupMetadata(_('Multiplayer'))
|
||||
.setIcon('JsPlatform/Extensions/multiplayer.svg');
|
||||
|
||||
extension
|
||||
.addStrExpression(
|
||||
'CurrentLobbyID',
|
||||
_('Current lobby ID'),
|
||||
_('Returns current lobby ID.'),
|
||||
_('Lobbies'),
|
||||
'JsPlatform/Extensions/multiplayer.svg'
|
||||
)
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Multiplayer/peer.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/peerJsHelper.js')
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
|
||||
)
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
|
||||
)
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayercomponents.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/messageManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayerVariablesManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayertools.js')
|
||||
.setFunctionName('gdjs.multiplayer.getLobbyID');
|
||||
|
||||
extension
|
||||
.addAction(
|
||||
'QuickJoinWithLobbyID',
|
||||
_('Join a specific lobby by its ID'),
|
||||
_(
|
||||
'Join a specific lobby. The player will join the game instantly if this is possible.'
|
||||
),
|
||||
_('Join a specific lobby by its ID _PARAM1_'),
|
||||
_('Lobbies'),
|
||||
'JsPlatform/Extensions/multiplayer.svg',
|
||||
'JsPlatform/Extensions/multiplayer.svg'
|
||||
)
|
||||
.addCodeOnlyParameter('currentScene', '')
|
||||
.addParameter('string', _('Lobby ID'), '', false)
|
||||
.addParameter(
|
||||
'yesorno',
|
||||
_('Display loader while joining a lobby.'),
|
||||
'',
|
||||
true
|
||||
)
|
||||
.setDefaultValue('yes')
|
||||
.addParameter(
|
||||
'yesorno',
|
||||
_('Display game lobbies if unable to join a specific one.'),
|
||||
'',
|
||||
true
|
||||
)
|
||||
.setDefaultValue('yes')
|
||||
.setHelpPath('/all-features/multiplayer')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Multiplayer/peer.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/peerJsHelper.js')
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
|
||||
)
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
|
||||
)
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayercomponents.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/messageManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayerVariablesManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayertools.js')
|
||||
.setFunctionName('gdjs.multiplayer.authenticateAndQuickJoinWithLobbyID');
|
||||
|
||||
extension
|
||||
.addAction(
|
||||
'QuickJoinLobby',
|
||||
|
@@ -293,6 +293,8 @@ namespace gdjs {
|
||||
x: objectNetworkSyncData.x,
|
||||
y: objectNetworkSyncData.y,
|
||||
z: objectNetworkSyncData.z,
|
||||
w: objectNetworkSyncData.w,
|
||||
h: objectNetworkSyncData.h,
|
||||
zo: objectNetworkSyncData.zo,
|
||||
a: objectNetworkSyncData.a,
|
||||
hid: objectNetworkSyncData.hid,
|
||||
@@ -369,6 +371,9 @@ namespace gdjs {
|
||||
this._lastSentBasicObjectSyncData = {
|
||||
x: objectNetworkSyncData.x,
|
||||
y: objectNetworkSyncData.y,
|
||||
z: objectNetworkSyncData.z,
|
||||
w: objectNetworkSyncData.w,
|
||||
h: objectNetworkSyncData.h,
|
||||
zo: objectNetworkSyncData.zo,
|
||||
a: objectNetworkSyncData.a,
|
||||
hid: objectNetworkSyncData.hid,
|
||||
|
@@ -17,9 +17,29 @@ namespace gdjs {
|
||||
}[];
|
||||
};
|
||||
|
||||
type LobbyStatus =
|
||||
| 'waiting'
|
||||
| 'starting'
|
||||
| 'playing'
|
||||
| 'migrating'
|
||||
| 'migrated';
|
||||
type LobbyConnectionStatus = 'waiting' | 'ready' | 'connected';
|
||||
type InGamePlayerStatus = 'playing' | 'left';
|
||||
type PlayerStatus = LobbyConnectionStatus | InGamePlayerStatus;
|
||||
|
||||
type LobbyPlayer = {
|
||||
playerId: string;
|
||||
status: PlayerStatus;
|
||||
playerNumber: number;
|
||||
};
|
||||
|
||||
type Lobby = {
|
||||
id: string;
|
||||
status: 'waiting' | 'starting' | 'playing' | 'migrating' | 'migrated';
|
||||
minPlayers: number;
|
||||
maxPlayers: number;
|
||||
canJoinAfterStart: boolean;
|
||||
players: LobbyPlayer[];
|
||||
status: LobbyStatus;
|
||||
};
|
||||
|
||||
type QuickJoinLobbyResponse =
|
||||
@@ -105,6 +125,7 @@ namespace gdjs {
|
||||
let _quickJoinLobbyFailureReason:
|
||||
| 'FULL'
|
||||
| 'NOT_ENOUGH_PLAYERS'
|
||||
| 'DOES_NOT_EXIST'
|
||||
| 'UNKNOWN'
|
||||
| null = null;
|
||||
let _lobbyId: string | null = null;
|
||||
@@ -1697,11 +1718,87 @@ namespace gdjs {
|
||||
}
|
||||
};
|
||||
|
||||
export const authenticateAndQuickJoinLobby = async (
|
||||
export const getLobbyID = (): string => {
|
||||
return _lobbyId || '';
|
||||
};
|
||||
|
||||
const quickJoinWithLobbyID = async (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
lobbyID: string,
|
||||
displayLoader: boolean,
|
||||
openLobbiesPageIfFailure: boolean
|
||||
) => {
|
||||
if (_isQuickJoiningOrStartingAGame) return;
|
||||
const _gameId = gdjs.projectData.properties.projectUuid;
|
||||
if (!_gameId) {
|
||||
logger.error(
|
||||
'The game ID is missing, the quick join lobby action cannot continue.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_quickJoinLobbyFailureReason = null;
|
||||
_isQuickJoiningOrStartingAGame = true;
|
||||
if (displayLoader) {
|
||||
gdjs.multiplayerComponents.displayLoader(runtimeScene, true);
|
||||
}
|
||||
|
||||
const quickJoinWithLobbyIDRelativeUrl = `/play/game/${_gameId}/public-lobby/${lobbyID}`;
|
||||
|
||||
try {
|
||||
const lobby: Lobby = await gdjs.evtTools.network.retryIfFailed(
|
||||
{ times: 2 },
|
||||
() =>
|
||||
fetchAsPlayer({
|
||||
relativeUrl: quickJoinWithLobbyIDRelativeUrl,
|
||||
method: 'GET',
|
||||
dev: isUsingGDevelopDevelopmentEnvironment,
|
||||
})
|
||||
);
|
||||
|
||||
const isFull = lobby.players.length === lobby.maxPlayers;
|
||||
if (isFull) {
|
||||
logger.error('Lobby is full - cannot quick join it.');
|
||||
_quickJoinLobbyJustFailed = true;
|
||||
_quickJoinLobbyFailureReason = 'FULL';
|
||||
onLobbyQuickJoinFinished(runtimeScene);
|
||||
if (openLobbiesPageIfFailure) {
|
||||
openLobbiesWindow(runtimeScene);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (lobby.status === 'playing') {
|
||||
_actionAfterJoiningLobby = 'JOIN_GAME';
|
||||
} else if (lobby.status === 'waiting') {
|
||||
if (lobby.players.length === 0) {
|
||||
_actionAfterJoiningLobby = 'START_GAME';
|
||||
} else {
|
||||
_actionAfterJoiningLobby = 'OPEN_LOBBY_PAGE';
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Lobby in wrong status: ${lobby.status}`);
|
||||
}
|
||||
|
||||
handleJoinLobbyEvent(runtimeScene, lobbyID);
|
||||
} catch (error) {
|
||||
const errorCode = parseInt(error.message.match(/\d{3}/)?.[0]);
|
||||
if (errorCode === 404) {
|
||||
logger.error('Lobby does not exist.');
|
||||
_quickJoinLobbyFailureReason = 'DOES_NOT_EXIST';
|
||||
} else {
|
||||
logger.error('An error occurred while joining a lobby:', error);
|
||||
_quickJoinLobbyFailureReason = 'UNKNOWN';
|
||||
}
|
||||
_quickJoinLobbyJustFailed = true;
|
||||
onLobbyQuickJoinFinished(runtimeScene);
|
||||
if (openLobbiesPageIfFailure) {
|
||||
openLobbiesWindow(runtimeScene);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isQuickJoiningTooFast = () => {
|
||||
const requestDoneAt = Date.now();
|
||||
if (_lastQuickJoinRequestDoneAt) {
|
||||
if (requestDoneAt - _lastQuickJoinRequestDoneAt < 500) {
|
||||
@@ -1709,12 +1806,18 @@ namespace gdjs {
|
||||
logger.warn(
|
||||
'Last request to quick join a lobby was sent too little time ago. Ignoring this one.'
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
_lastQuickJoinRequestDoneAt = requestDoneAt;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const isNotCorrectlyAuthenticatedForQuickJoin = async (
|
||||
runtimeScene: RuntimeScene
|
||||
) => {
|
||||
const playerId = gdjs.playerAuthentication.getUserId();
|
||||
const playerToken = gdjs.playerAuthentication.getUserToken();
|
||||
if (!playerId || !playerToken) {
|
||||
@@ -1724,14 +1827,43 @@ namespace gdjs {
|
||||
.promise;
|
||||
_isWaitingForLogin = false;
|
||||
|
||||
if (status === 'logged') {
|
||||
await quickJoinLobby(
|
||||
runtimeScene,
|
||||
displayLoader,
|
||||
openLobbiesPageIfFailure
|
||||
);
|
||||
if (status !== 'logged') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const authenticateAndQuickJoinWithLobbyID = async (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
lobbyID: string,
|
||||
displayLoader: boolean,
|
||||
openLobbiesPageIfFailure: boolean
|
||||
) => {
|
||||
if (isQuickJoiningTooFast()) {
|
||||
return;
|
||||
}
|
||||
if (await isNotCorrectlyAuthenticatedForQuickJoin(runtimeScene)) {
|
||||
return;
|
||||
}
|
||||
await quickJoinWithLobbyID(
|
||||
runtimeScene,
|
||||
lobbyID,
|
||||
displayLoader,
|
||||
openLobbiesPageIfFailure
|
||||
);
|
||||
};
|
||||
|
||||
export const authenticateAndQuickJoinLobby = async (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
displayLoader: boolean,
|
||||
openLobbiesPageIfFailure: boolean
|
||||
) => {
|
||||
if (isQuickJoiningTooFast()) {
|
||||
return;
|
||||
}
|
||||
if (await isNotCorrectlyAuthenticatedForQuickJoin(runtimeScene)) {
|
||||
return;
|
||||
}
|
||||
await quickJoinLobby(
|
||||
|
@@ -25,8 +25,6 @@ namespace gdjs {
|
||||
export type PanelSpriteObjectData = ObjectData & PanelSpriteObjectDataType;
|
||||
|
||||
export type PanelSpriteNetworkSyncDataType = {
|
||||
wid: number;
|
||||
hei: number;
|
||||
op: number;
|
||||
color: string;
|
||||
};
|
||||
@@ -128,8 +126,6 @@ namespace gdjs {
|
||||
getNetworkSyncData(): PanelSpriteNetworkSyncData {
|
||||
return {
|
||||
...super.getNetworkSyncData(),
|
||||
wid: this.getWidth(),
|
||||
hei: this.getHeight(),
|
||||
op: this.getOpacity(),
|
||||
color: this.getColor(),
|
||||
};
|
||||
@@ -142,12 +138,6 @@ namespace gdjs {
|
||||
|
||||
// Texture is not synchronized, see if this is asked or not.
|
||||
|
||||
if (networkSyncData.wid !== undefined) {
|
||||
this.setWidth(networkSyncData.wid);
|
||||
}
|
||||
if (networkSyncData.hei !== undefined) {
|
||||
this.setHeight(networkSyncData.hei);
|
||||
}
|
||||
if (networkSyncData.op !== undefined) {
|
||||
this.setOpacity(networkSyncData.op);
|
||||
}
|
||||
|
@@ -927,6 +927,54 @@ module.exports = {
|
||||
.setFunctionName('setDensity')
|
||||
.setGetter('getDensity');
|
||||
|
||||
aut
|
||||
.addExpressionAndConditionAndAction(
|
||||
'number',
|
||||
'ShapeOffsetX',
|
||||
_('Shape offset X'),
|
||||
_('the object shape offset on X.'),
|
||||
_('the shape offset on X'),
|
||||
_('Body settings'),
|
||||
'JsPlatform/Extensions/physics3d.svg'
|
||||
)
|
||||
.addParameter('object', _('Object'), '', false)
|
||||
.addParameter('behavior', _('Behavior'), 'Physics3DBehavior')
|
||||
.useStandardParameters('number', gd.ParameterOptions.makeNewOptions())
|
||||
.setFunctionName('setShapeOffsetX')
|
||||
.setGetter('getShapeOffsetX');
|
||||
|
||||
aut
|
||||
.addExpressionAndConditionAndAction(
|
||||
'number',
|
||||
'ShapeOffsetY',
|
||||
_('Shape offset Y'),
|
||||
_('the object shape offset on Y.'),
|
||||
_('the shape offset on Y'),
|
||||
_('Body settings'),
|
||||
'JsPlatform/Extensions/physics3d.svg'
|
||||
)
|
||||
.addParameter('object', _('Object'), '', false)
|
||||
.addParameter('behavior', _('Behavior'), 'Physics3DBehavior')
|
||||
.useStandardParameters('number', gd.ParameterOptions.makeNewOptions())
|
||||
.setFunctionName('setShapeOffsetY')
|
||||
.setGetter('getShapeOffsetY');
|
||||
|
||||
aut
|
||||
.addExpressionAndConditionAndAction(
|
||||
'number',
|
||||
'ShapeOffsetZ',
|
||||
_('Shape offset Z'),
|
||||
_('the object shape offset on Z.'),
|
||||
_('the shape offset on Z'),
|
||||
_('Body settings'),
|
||||
'JsPlatform/Extensions/physics3d.svg'
|
||||
)
|
||||
.addParameter('object', _('Object'), '', false)
|
||||
.addParameter('behavior', _('Behavior'), 'Physics3DBehavior')
|
||||
.useStandardParameters('number', gd.ParameterOptions.makeNewOptions())
|
||||
.setFunctionName('setShapeOffsetZ')
|
||||
.setGetter('getShapeOffsetZ');
|
||||
|
||||
aut
|
||||
.addExpressionAndConditionAndAction(
|
||||
'number',
|
||||
|
@@ -630,24 +630,34 @@ namespace gdjs {
|
||||
|
||||
override onDeActivate() {
|
||||
this._sharedData.removeFromBehaviorsList(this);
|
||||
this._destroyBody();
|
||||
}
|
||||
|
||||
override onActivate() {
|
||||
this._sharedData.addToBehaviorsList(this);
|
||||
}
|
||||
|
||||
override onDestroy() {
|
||||
this._destroyedDuringFrameLogic = true;
|
||||
this.onDeActivate();
|
||||
}
|
||||
|
||||
_destroyBody() {
|
||||
this.bodyUpdater.destroyBody();
|
||||
this._contactsEndedThisFrame.length = 0;
|
||||
this._contactsStartedThisFrame.length = 0;
|
||||
this._currentContacts.length = 0;
|
||||
}
|
||||
|
||||
override onActivate() {
|
||||
this._sharedData.addToBehaviorsList(this);
|
||||
|
||||
this._contactsEndedThisFrame.length = 0;
|
||||
this._contactsStartedThisFrame.length = 0;
|
||||
this._currentContacts.length = 0;
|
||||
this.updateBodyFromObject();
|
||||
resetToDefaultBodyUpdater() {
|
||||
this.bodyUpdater = new gdjs.Physics3DRuntimeBehavior.DefaultBodyUpdater(
|
||||
this
|
||||
);
|
||||
}
|
||||
|
||||
override onDestroy() {
|
||||
this._destroyedDuringFrameLogic = true;
|
||||
this.onDeActivate();
|
||||
resetToDefaultCollisionChecker() {
|
||||
this.collisionChecker =
|
||||
new gdjs.Physics3DRuntimeBehavior.DefaultCollisionChecker(this);
|
||||
}
|
||||
|
||||
createShape(): Jolt.Shape {
|
||||
@@ -1176,6 +1186,33 @@ namespace gdjs {
|
||||
this._needToRecreateBody = true;
|
||||
}
|
||||
|
||||
getShapeOffsetX(): float {
|
||||
return this.shapeOffsetX;
|
||||
}
|
||||
|
||||
setShapeOffsetX(shapeOffsetX: float): void {
|
||||
this.shapeOffsetX = shapeOffsetX;
|
||||
this._needToRecreateShape = true;
|
||||
}
|
||||
|
||||
getShapeOffsetY(): float {
|
||||
return this.shapeOffsetY;
|
||||
}
|
||||
|
||||
setShapeOffsetY(shapeOffsetY: float): void {
|
||||
this.shapeOffsetY = shapeOffsetY;
|
||||
this._needToRecreateShape = true;
|
||||
}
|
||||
|
||||
getShapeOffsetZ(): float {
|
||||
return this.shapeOffsetZ;
|
||||
}
|
||||
|
||||
setShapeOffsetZ(shapeOffsetZ: float): void {
|
||||
this.shapeOffsetZ = shapeOffsetZ;
|
||||
this._needToRecreateShape = true;
|
||||
}
|
||||
|
||||
getFriction(): float {
|
||||
return this.friction;
|
||||
}
|
||||
@@ -1540,9 +1577,9 @@ namespace gdjs {
|
||||
}
|
||||
const body = this._body!;
|
||||
|
||||
const deltaX = towardX - body.GetPosition().GetX();
|
||||
const deltaY = towardY - body.GetPosition().GetY();
|
||||
const deltaZ = towardZ - body.GetPosition().GetZ();
|
||||
const deltaX = towardX - this.owner3D.getX();
|
||||
const deltaY = towardY - this.owner3D.getY();
|
||||
const deltaZ = towardZ - this.owner3D.getZ();
|
||||
const distanceSq = deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ;
|
||||
if (distanceSq === 0) {
|
||||
return;
|
||||
@@ -1600,19 +1637,16 @@ namespace gdjs {
|
||||
length: float,
|
||||
towardX: float,
|
||||
towardY: float,
|
||||
towardZ: float,
|
||||
originX: float,
|
||||
originY: float,
|
||||
originZ: float
|
||||
towardZ: float
|
||||
): void {
|
||||
if (this._body === null) {
|
||||
if (!this._createBody()) return;
|
||||
}
|
||||
const body = this._body!;
|
||||
|
||||
const deltaX = towardX - originX;
|
||||
const deltaY = towardY - originY;
|
||||
const deltaZ = towardZ - originZ;
|
||||
const deltaX = towardX - this.owner3D.getX();
|
||||
const deltaY = towardY - this.owner3D.getY();
|
||||
const deltaZ = towardZ - this.owner3D.getZ();
|
||||
const distanceSq = deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ;
|
||||
if (distanceSq === 0) {
|
||||
return;
|
||||
@@ -1621,12 +1655,7 @@ namespace gdjs {
|
||||
|
||||
this._sharedData.bodyInterface.AddImpulse(
|
||||
body.GetID(),
|
||||
this.getVec3(deltaX * ratio, deltaY * ratio, deltaZ * ratio),
|
||||
this.getRVec3(
|
||||
originX * this._sharedData.worldInvScale,
|
||||
originY * this._sharedData.worldInvScale,
|
||||
originZ * this._sharedData.worldInvScale
|
||||
)
|
||||
this.getVec3(deltaX * ratio, deltaY * ratio, deltaZ * ratio)
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -29,6 +29,7 @@ namespace gdjs {
|
||||
owner3D: gdjs.RuntimeObject3D;
|
||||
private _physics3DBehaviorName: string;
|
||||
private _physics3D: Physics3D | null = null;
|
||||
private _isHookedToPhysicsStep = false;
|
||||
_vehicleController: Jolt.WheeledVehicleController | null = null;
|
||||
_stepListener: Jolt.VehicleConstraintStepListener | null = null;
|
||||
_vehicleCollisionTester: Jolt.VehicleCollisionTesterCastCylinder | null =
|
||||
@@ -153,13 +154,19 @@ namespace gdjs {
|
||||
const behavior = this.owner.getBehavior(
|
||||
this._physics3DBehaviorName
|
||||
) as gdjs.Physics3DRuntimeBehavior;
|
||||
if (!behavior.activated()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sharedData = behavior._sharedData;
|
||||
|
||||
this._physics3D = {
|
||||
behavior,
|
||||
};
|
||||
sharedData.registerHook(this);
|
||||
if (!this._isHookedToPhysicsStep) {
|
||||
sharedData.registerHook(this);
|
||||
this._isHookedToPhysicsStep = true;
|
||||
}
|
||||
|
||||
behavior.bodyUpdater =
|
||||
new gdjs.PhysicsCar3DRuntimeBehavior.VehicleBodyUpdater(
|
||||
@@ -330,25 +337,33 @@ namespace gdjs {
|
||||
}
|
||||
|
||||
override onDeActivate() {
|
||||
if (this._stepListener) {
|
||||
this._sharedData.physicsSystem.RemoveStepListener(this._stepListener);
|
||||
if (!this._physics3D) {
|
||||
return;
|
||||
}
|
||||
this._destroyBody();
|
||||
}
|
||||
|
||||
override onActivate() {
|
||||
if (this._stepListener) {
|
||||
this._sharedData.physicsSystem.AddStepListener(this._stepListener);
|
||||
const behavior = this.owner.getBehavior(
|
||||
this._physics3DBehaviorName
|
||||
) as gdjs.Physics3DRuntimeBehavior;
|
||||
if (!behavior) {
|
||||
return;
|
||||
}
|
||||
behavior._destroyBody();
|
||||
}
|
||||
|
||||
override onDestroy() {
|
||||
this._destroyedDuringFrameLogic = true;
|
||||
this._destroyBody();
|
||||
}
|
||||
|
||||
_destroyBody() {
|
||||
if (!this._vehicleController) {
|
||||
return;
|
||||
}
|
||||
this._destroyedDuringFrameLogic = true;
|
||||
this.onDeActivate();
|
||||
if (this._stepListener) {
|
||||
// stepListener is removed by onDeActivate
|
||||
this._sharedData.physicsSystem.RemoveStepListener(this._stepListener);
|
||||
Jolt.destroy(this._stepListener);
|
||||
this._stepListener = null;
|
||||
}
|
||||
@@ -360,6 +375,8 @@ namespace gdjs {
|
||||
// It is destroyed with the constraint.
|
||||
this._vehicleCollisionTester = null;
|
||||
if (this._physics3D) {
|
||||
const { behavior } = this._physics3D;
|
||||
behavior.resetToDefaultBodyUpdater();
|
||||
this._physics3D = null;
|
||||
}
|
||||
}
|
||||
@@ -1110,7 +1127,7 @@ namespace gdjs {
|
||||
}
|
||||
|
||||
destroyBody() {
|
||||
this.carBehavior.onDestroy();
|
||||
this.carBehavior._destroyBody();
|
||||
this.physicsBodyUpdater.destroyBody();
|
||||
}
|
||||
}
|
||||
|
@@ -41,6 +41,7 @@ namespace gdjs {
|
||||
owner3D: gdjs.RuntimeObject3D;
|
||||
private _physics3DBehaviorName: string;
|
||||
private _physics3D: Physics3D | null = null;
|
||||
private _isHookedToPhysicsStep = false;
|
||||
character: Jolt.CharacterVirtual | null = null;
|
||||
/**
|
||||
* sharedData is a reference to the shared data of the scene, that registers
|
||||
@@ -169,10 +170,15 @@ namespace gdjs {
|
||||
if (this._physics3D) {
|
||||
return this._physics3D;
|
||||
}
|
||||
if (!this.activated()) {
|
||||
return null;
|
||||
}
|
||||
const behavior = this.owner.getBehavior(
|
||||
this._physics3DBehaviorName
|
||||
) as gdjs.Physics3DRuntimeBehavior;
|
||||
|
||||
if (!behavior.activated()) {
|
||||
return null;
|
||||
}
|
||||
const sharedData = behavior._sharedData;
|
||||
const jolt = sharedData.jolt;
|
||||
const extendedUpdateSettings = new Jolt.ExtendedUpdateSettings();
|
||||
@@ -196,7 +202,10 @@ namespace gdjs {
|
||||
shapeFilter,
|
||||
};
|
||||
this.setStairHeightMax(this._stairHeightMax);
|
||||
sharedData.registerHook(this);
|
||||
if (!this._isHookedToPhysicsStep) {
|
||||
sharedData.registerHook(this);
|
||||
this._isHookedToPhysicsStep = true;
|
||||
}
|
||||
|
||||
behavior.bodyUpdater =
|
||||
new gdjs.PhysicsCharacter3DRuntimeBehavior.CharacterBodyUpdater(this);
|
||||
@@ -390,36 +399,48 @@ namespace gdjs {
|
||||
}
|
||||
|
||||
override onDeActivate() {
|
||||
this.collisionChecker.clearContacts();
|
||||
if (!this._physics3D) {
|
||||
return;
|
||||
}
|
||||
this._destroyBody();
|
||||
}
|
||||
|
||||
override onActivate() {}
|
||||
override onActivate() {
|
||||
const behavior = this.owner.getBehavior(
|
||||
this._physics3DBehaviorName
|
||||
) as gdjs.Physics3DRuntimeBehavior;
|
||||
if (!behavior) {
|
||||
return;
|
||||
}
|
||||
behavior._destroyBody();
|
||||
}
|
||||
|
||||
override onDestroy() {
|
||||
this._destroyedDuringFrameLogic = true;
|
||||
this.onDeActivate();
|
||||
this._destroyCharacter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the character and its body from the physics engine.
|
||||
* This method is called when:
|
||||
* - The Physics3D behavior is deactivated
|
||||
* - This behavior is deactivated
|
||||
* - The object is destroyed
|
||||
*
|
||||
* Only deactivating the character behavior won't destroy the character.
|
||||
* Indeed, deactivated characters don't move as characters but still have collisions.
|
||||
*/
|
||||
_destroyCharacter() {
|
||||
_destroyBody() {
|
||||
if (this.character) {
|
||||
if (this._canBePushed) {
|
||||
this.charactersManager.removeCharacter(this.character);
|
||||
Jolt.destroy(this.character.GetListener());
|
||||
}
|
||||
this.collisionChecker.clearContacts();
|
||||
// The body is destroyed with the character.
|
||||
Jolt.destroy(this.character);
|
||||
this.character = null;
|
||||
if (this._physics3D) {
|
||||
const { behavior } = this._physics3D;
|
||||
behavior.resetToDefaultBodyUpdater();
|
||||
behavior.resetToDefaultCollisionChecker();
|
||||
this._physics3D.behavior._body = null;
|
||||
const {
|
||||
extendedUpdateSettings,
|
||||
@@ -1780,7 +1801,7 @@ namespace gdjs {
|
||||
}
|
||||
|
||||
destroyBody() {
|
||||
this.characterBehavior._destroyCharacter();
|
||||
this.characterBehavior._destroyBody();
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -14,8 +14,6 @@ namespace gdjs {
|
||||
|
||||
export type SpineNetworkSyncDataType = {
|
||||
opa: float;
|
||||
wid: float;
|
||||
hei: float;
|
||||
scaX: float;
|
||||
scaY: float;
|
||||
flipX: boolean;
|
||||
@@ -117,8 +115,6 @@ namespace gdjs {
|
||||
return {
|
||||
...super.getNetworkSyncData(),
|
||||
opa: this._opacity,
|
||||
wid: this.getWidth(),
|
||||
hei: this.getHeight(),
|
||||
scaX: this.getScaleX(),
|
||||
scaY: this.getScaleY(),
|
||||
flipX: this.isFlippedX(),
|
||||
@@ -137,12 +133,6 @@ namespace gdjs {
|
||||
if (syncData.opa !== undefined && syncData.opa !== this._opacity) {
|
||||
this.setOpacity(syncData.opa);
|
||||
}
|
||||
if (syncData.wid !== undefined && syncData.wid !== this.getWidth()) {
|
||||
this.setWidth(syncData.wid);
|
||||
}
|
||||
if (syncData.hei !== undefined && syncData.hei !== this.getHeight()) {
|
||||
this.setHeight(syncData.hei);
|
||||
}
|
||||
if (syncData.scaX !== undefined && syncData.scaX !== this.getScaleX()) {
|
||||
this.setScaleX(syncData.scaX);
|
||||
}
|
||||
|
@@ -64,8 +64,6 @@ namespace gdjs {
|
||||
|
||||
export type TextInputNetworkSyncDataType = {
|
||||
opa: float;
|
||||
wid: float;
|
||||
hei: float;
|
||||
txt: string;
|
||||
frn: string;
|
||||
fs: number;
|
||||
@@ -260,8 +258,6 @@ namespace gdjs {
|
||||
return {
|
||||
...super.getNetworkSyncData(),
|
||||
opa: this.getOpacity(),
|
||||
wid: this.getWidth(),
|
||||
hei: this.getHeight(),
|
||||
txt: this.getText(),
|
||||
frn: this.getFontResourceName(),
|
||||
fs: this.getFontSize(),
|
||||
@@ -282,8 +278,6 @@ namespace gdjs {
|
||||
super.updateFromNetworkSyncData(syncData);
|
||||
|
||||
if (syncData.opa !== undefined) this.setOpacity(syncData.opa);
|
||||
if (syncData.wid !== undefined) this.setWidth(syncData.wid);
|
||||
if (syncData.hei !== undefined) this.setHeight(syncData.hei);
|
||||
if (syncData.txt !== undefined) this.setText(syncData.txt);
|
||||
if (syncData.frn !== undefined) this.setFontResourceName(syncData.frn);
|
||||
if (syncData.fs !== undefined) this.setFontSize(syncData.fs);
|
||||
|
@@ -17,8 +17,6 @@ namespace gdjs {
|
||||
export type SimpleTileMapNetworkSyncDataType = {
|
||||
op: number;
|
||||
ai: string;
|
||||
wid: number;
|
||||
hei: number;
|
||||
// TODO: Support tilemap synchronization. Find an efficient way to send tiles changes.
|
||||
};
|
||||
|
||||
@@ -170,8 +168,6 @@ namespace gdjs {
|
||||
...super.getNetworkSyncData(),
|
||||
op: this._opacity,
|
||||
ai: this._atlasImage,
|
||||
wid: this.getWidth(),
|
||||
hei: this.getHeight(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,18 +182,6 @@ namespace gdjs {
|
||||
) {
|
||||
this.setOpacity(networkSyncData.op);
|
||||
}
|
||||
if (
|
||||
networkSyncData.wid !== undefined &&
|
||||
networkSyncData.wid !== this.getWidth()
|
||||
) {
|
||||
this.setWidth(networkSyncData.wid);
|
||||
}
|
||||
if (
|
||||
networkSyncData.hei !== undefined &&
|
||||
networkSyncData.hei !== this.getHeight()
|
||||
) {
|
||||
this.setHeight(networkSyncData.hei);
|
||||
}
|
||||
if (networkSyncData.ai !== undefined) {
|
||||
// TODO: support changing the atlas texture
|
||||
}
|
||||
|
@@ -26,8 +26,6 @@ namespace gdjs {
|
||||
os: float;
|
||||
fo: float;
|
||||
oo: float;
|
||||
wid: float;
|
||||
hei: float;
|
||||
};
|
||||
|
||||
export type TilemapCollisionMaskNetworkSyncData = ObjectNetworkSyncData &
|
||||
@@ -202,8 +200,6 @@ namespace gdjs {
|
||||
os: this.getOutlineSize(),
|
||||
fo: this.getFillOpacity(),
|
||||
oo: this.getOutlineOpacity(),
|
||||
wid: this.getWidth(),
|
||||
hei: this.getHeight(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -236,12 +232,6 @@ namespace gdjs {
|
||||
if (networkSyncData.oo !== undefined) {
|
||||
this.setOutlineOpacity(networkSyncData.oo);
|
||||
}
|
||||
if (networkSyncData.wid !== undefined) {
|
||||
this.setWidth(networkSyncData.wid);
|
||||
}
|
||||
if (networkSyncData.hei !== undefined) {
|
||||
this.setHeight(networkSyncData.hei);
|
||||
}
|
||||
}
|
||||
|
||||
extraInitializationFromInitialInstance(initialInstanceData): void {
|
||||
|
@@ -25,8 +25,6 @@ namespace gdjs {
|
||||
lai: number;
|
||||
lei: number;
|
||||
asps: number;
|
||||
wid: number;
|
||||
hei: number;
|
||||
};
|
||||
|
||||
export type TilemapNetworkSyncData = ObjectNetworkSyncData &
|
||||
@@ -158,8 +156,6 @@ namespace gdjs {
|
||||
lai: this._layerIndex,
|
||||
lei: this._levelIndex,
|
||||
asps: this._animationSpeedScale,
|
||||
wid: this.getWidth(),
|
||||
hei: this.getHeight(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,12 +186,6 @@ namespace gdjs {
|
||||
if (networkSyncData.asps !== undefined) {
|
||||
this.setAnimationSpeedScale(networkSyncData.asps);
|
||||
}
|
||||
if (networkSyncData.wid !== undefined) {
|
||||
this.setWidth(networkSyncData.wid);
|
||||
}
|
||||
if (networkSyncData.hei !== undefined) {
|
||||
this.setHeight(networkSyncData.hei);
|
||||
}
|
||||
}
|
||||
|
||||
extraInitializationFromInitialInstance(initialInstanceData): void {
|
||||
|
@@ -15,8 +15,6 @@ namespace gdjs {
|
||||
export type TiledSpriteObjectData = ObjectData & TiledSpriteObjectDataType;
|
||||
|
||||
export type TiledSpriteNetworkSyncDataType = {
|
||||
wid: number;
|
||||
hei: number;
|
||||
xo: number;
|
||||
yo: number;
|
||||
op: number;
|
||||
@@ -87,8 +85,6 @@ namespace gdjs {
|
||||
getNetworkSyncData(): TiledSpriteNetworkSyncData {
|
||||
return {
|
||||
...super.getNetworkSyncData(),
|
||||
wid: this.getWidth(),
|
||||
hei: this.getHeight(),
|
||||
xo: this.getXOffset(),
|
||||
yo: this.getYOffset(),
|
||||
op: this.getOpacity(),
|
||||
@@ -103,12 +99,6 @@ namespace gdjs {
|
||||
|
||||
// Texture is not synchronized, see if this is asked or not.
|
||||
|
||||
if (networkSyncData.wid !== undefined) {
|
||||
this.setWidth(networkSyncData.wid);
|
||||
}
|
||||
if (networkSyncData.hei !== undefined) {
|
||||
this.setHeight(networkSyncData.hei);
|
||||
}
|
||||
if (networkSyncData.xo !== undefined) {
|
||||
this.setXOffset(networkSyncData.xo);
|
||||
}
|
||||
|
@@ -18,8 +18,6 @@ namespace gdjs {
|
||||
|
||||
export type VideoNetworkSyncDataType = {
|
||||
op: float;
|
||||
wid: float;
|
||||
hei: float;
|
||||
// We don't sync volume, as it's probably a user setting?
|
||||
pla: boolean;
|
||||
loop: boolean;
|
||||
@@ -105,8 +103,6 @@ namespace gdjs {
|
||||
return {
|
||||
...super.getNetworkSyncData(),
|
||||
op: this._opacity,
|
||||
wid: this.getWidth(),
|
||||
hei: this.getHeight(),
|
||||
pla: this.isPlayed(),
|
||||
loop: this.isLooped(),
|
||||
ct: this.getCurrentTime(),
|
||||
@@ -120,12 +116,6 @@ namespace gdjs {
|
||||
if (this._opacity !== undefined && this._opacity && syncData.op) {
|
||||
this.setOpacity(syncData.op);
|
||||
}
|
||||
if (this.getWidth() !== undefined && this.getWidth() !== syncData.wid) {
|
||||
this.setWidth(syncData.wid);
|
||||
}
|
||||
if (this.getHeight() !== undefined && this.getHeight() !== syncData.hei) {
|
||||
this.setHeight(syncData.hei);
|
||||
}
|
||||
if (syncData.pla !== undefined && this.isPlayed() !== syncData.pla) {
|
||||
syncData.pla ? this.play() : this.pause();
|
||||
}
|
||||
|
@@ -17,6 +17,11 @@ namespace gdjs {
|
||||
isInnerAreaFollowingParentSize: boolean;
|
||||
};
|
||||
|
||||
export type CustomObjectNetworkSyncDataType = ObjectNetworkSyncData & {
|
||||
ifx: boolean;
|
||||
ify: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* An object that contains other object.
|
||||
*
|
||||
@@ -198,6 +203,26 @@ namespace gdjs {
|
||||
return true;
|
||||
}
|
||||
|
||||
getNetworkSyncData(): CustomObjectNetworkSyncDataType {
|
||||
return {
|
||||
...super.getNetworkSyncData(),
|
||||
ifx: this.isFlippedX(),
|
||||
ify: this.isFlippedY(),
|
||||
};
|
||||
}
|
||||
|
||||
updateFromNetworkSyncData(
|
||||
networkSyncData: CustomObjectNetworkSyncDataType
|
||||
) {
|
||||
super.updateFromNetworkSyncData(networkSyncData);
|
||||
if (networkSyncData.ifx !== undefined) {
|
||||
this.flipX(networkSyncData.ifx);
|
||||
}
|
||||
if (networkSyncData.ify !== undefined) {
|
||||
this.flipY(networkSyncData.ify);
|
||||
}
|
||||
}
|
||||
|
||||
override extraInitializationFromInitialInstance(
|
||||
initialInstanceData: InstanceData
|
||||
) {
|
||||
|
@@ -388,11 +388,9 @@ namespace gdjs {
|
||||
): FloatPoint {
|
||||
const position = result || [0, 0];
|
||||
this._customObject.applyObjectTransformation(sceneX, sceneY, position);
|
||||
return this._parent.convertInverseCoords(
|
||||
position[0],
|
||||
position[1],
|
||||
position
|
||||
);
|
||||
return this._parent
|
||||
.getLayer(this._customObject.getLayer())
|
||||
.convertInverseCoords(position[0], position[1], 0, position);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -380,11 +380,8 @@ namespace gdjs {
|
||||
.isMouseInsideCanvas();
|
||||
};
|
||||
|
||||
const _cursorIsOnObject = function (
|
||||
obj: gdjs.RuntimeObject,
|
||||
instanceContainer: gdjs.RuntimeInstanceContainer
|
||||
) {
|
||||
return obj.cursorOnObject(instanceContainer);
|
||||
const _cursorIsOnObject = function (obj: gdjs.RuntimeObject) {
|
||||
return obj.cursorOnObject();
|
||||
};
|
||||
|
||||
export const cursorOnObject = function (
|
||||
@@ -397,7 +394,7 @@ namespace gdjs {
|
||||
_cursorIsOnObject,
|
||||
objectsLists,
|
||||
inverted,
|
||||
instanceContainer
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -484,6 +484,8 @@ namespace gdjs {
|
||||
return {
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
w: this.getWidth(),
|
||||
h: this.getHeight(),
|
||||
zo: this.zOrder,
|
||||
a: this.angle,
|
||||
hid: this.hidden,
|
||||
@@ -512,6 +514,12 @@ namespace gdjs {
|
||||
if (networkSyncData.y !== undefined) {
|
||||
this.setY(networkSyncData.y);
|
||||
}
|
||||
if (networkSyncData.w !== undefined) {
|
||||
this.setWidth(networkSyncData.w);
|
||||
}
|
||||
if (networkSyncData.h !== undefined) {
|
||||
this.setHeight(networkSyncData.h);
|
||||
}
|
||||
if (networkSyncData.zo !== undefined) {
|
||||
this.setZOrder(networkSyncData.zo);
|
||||
}
|
||||
@@ -2737,11 +2745,12 @@ namespace gdjs {
|
||||
*
|
||||
* @return true if the cursor, or any touch, is on the object.
|
||||
*/
|
||||
cursorOnObject(instanceContainer: gdjs.RuntimeInstanceContainer): boolean {
|
||||
cursorOnObject(): boolean {
|
||||
const workingPoint: FloatPoint = gdjs.staticArray(
|
||||
RuntimeObject.prototype.cursorOnObject
|
||||
) as FloatPoint;
|
||||
workingPoint.length = 2;
|
||||
const instanceContainer = this.getInstanceContainer();
|
||||
const inputManager = instanceContainer.getGame().getInputManager();
|
||||
const layer = instanceContainer.getLayer(this.layer);
|
||||
const mousePos = layer.convertCoords(
|
||||
|
4
GDJS/Runtime/types/project-data.d.ts
vendored
4
GDJS/Runtime/types/project-data.d.ts
vendored
@@ -53,6 +53,10 @@ declare type BasicObjectNetworkSyncData = {
|
||||
y: number;
|
||||
/** The position of the instance on the Z axis. Defined only for 3D games */
|
||||
z?: number;
|
||||
/** The width of the instance */
|
||||
w: number;
|
||||
/** The height of the instance */
|
||||
h: number;
|
||||
/** Z order of the instance */
|
||||
zo: number;
|
||||
/** The angle of the instance. */
|
||||
|
@@ -1,2 +1,3 @@
|
||||
# Prettier has troubles handling the generic typing with flow in this file.
|
||||
src/Utils/UseOptimisticState.js
|
||||
src/Utils/UseOptimisticState.js
|
||||
src/Utils/Sha256.js
|
||||
|
@@ -438,7 +438,6 @@ export const useGetConceptMetadata = () => {
|
||||
}).current;
|
||||
const getActionMetadata = useRefWithInit(() => {
|
||||
return memoize((type: string) => {
|
||||
console.log(type);
|
||||
const {
|
||||
extensionShortHeader,
|
||||
eventsBasedBehavior,
|
||||
@@ -447,7 +446,6 @@ export const useGetConceptMetadata = () => {
|
||||
} = findEventsFunctionInExtensions(extensionShortHeadersByName, type);
|
||||
|
||||
if (extensionShortHeader && eventsFunction) {
|
||||
console.log(eventsFunction);
|
||||
return {
|
||||
kind: 'Action',
|
||||
name: eventsFunction.fullName,
|
||||
|
@@ -79,7 +79,8 @@ type Props = {
|
||||
hasOpenedProject: boolean,
|
||||
isAutoProcessingFunctionCalls: boolean,
|
||||
setAutoProcessFunctionCalls: boolean => void,
|
||||
onStartNewChat: () => void,
|
||||
onStartNewChat: (mode: 'chat' | 'agent') => void,
|
||||
initialMode?: 'chat' | 'agent',
|
||||
|
||||
onProcessFunctionCalls: (
|
||||
functionCalls: Array<AiRequestMessageAssistantFunctionCall>,
|
||||
@@ -136,12 +137,26 @@ const getQuotaOrCreditsText = ({
|
||||
>
|
||||
<div>
|
||||
{isMobile ? (
|
||||
<Trans>{quota.max - quota.current} free requests left</Trans>
|
||||
increaseQuotaOffering === 'subscribe' ? (
|
||||
<Trans>{quota.max - quota.current} trial requests left</Trans>
|
||||
) : (
|
||||
<Trans>{quota.max - quota.current} free requests left</Trans>
|
||||
)
|
||||
) : quota.period === '30days' ? (
|
||||
increaseQuotaOffering === 'subscribe' ? (
|
||||
<Trans>
|
||||
{quota.max - quota.current} free trial requests left this month
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
{quota.max - quota.current} of {quota.max} free requests left this
|
||||
month
|
||||
</Trans>
|
||||
)
|
||||
) : quota.period === '1day' ? (
|
||||
<Trans>{quota.max - quota.current} free requests left today</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
{quota.max - quota.current} of {quota.max} free requests left this
|
||||
month
|
||||
</Trans>
|
||||
<Trans>{quota.max - quota.current} free requests left</Trans>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
@@ -234,6 +249,7 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
|
||||
onSendMessage,
|
||||
onSendFeedback,
|
||||
onStartNewChat,
|
||||
initialMode,
|
||||
quota,
|
||||
increaseQuotaOffering,
|
||||
lastSendError,
|
||||
@@ -252,7 +268,17 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
|
||||
// TODO: store the default mode in the user preferences?
|
||||
const [newAiRequestMode, setNewAiRequestMode] = React.useState<
|
||||
'chat' | 'agent'
|
||||
>('agent');
|
||||
>(initialMode || 'agent');
|
||||
|
||||
// Update the mode when initialMode changes
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (initialMode) {
|
||||
setNewAiRequestMode(initialMode);
|
||||
}
|
||||
},
|
||||
[initialMode]
|
||||
);
|
||||
const aiRequestId: string = aiRequest ? aiRequest.id : '';
|
||||
const [
|
||||
userRequestTextPerAiRequestId,
|
||||
@@ -619,7 +645,10 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
|
||||
<Text size="body-small" color="secondary" align="center" noMargin>
|
||||
<Trans>
|
||||
This request is for another project.{' '}
|
||||
<Link href="#" onClick={onStartNewChat}>
|
||||
<Link
|
||||
href="#"
|
||||
onClick={() => onStartNewChat(aiRequest.mode || 'chat')}
|
||||
>
|
||||
Start a new chat
|
||||
</Link>{' '}
|
||||
to build on a new project.
|
||||
|
@@ -51,6 +51,7 @@ import {
|
||||
} from '../Utils/Analytics/EventSender';
|
||||
import { useCreateAiProjectDialog } from './UseCreateAiProjectDialog';
|
||||
import { type ExampleShortHeader } from '../Utils/GDevelopServices/Example';
|
||||
import { prepareAiUserContent } from './PrepareAiUserContent';
|
||||
import { setEditorHotReloadNeeded } from '../EmbeddedGame/EmbeddedGameFrame';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
@@ -483,6 +484,7 @@ type Props = {|
|
||||
changes: SceneEventsOutsideEditorChanges
|
||||
) => void,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
mode?: 'chat' | 'agent' | null,
|
||||
gameEditorMode: 'embedded-game' | 'instances-editor',
|
||||
|};
|
||||
|
||||
@@ -499,7 +501,7 @@ export type AskAiEditorInterface = {|
|
||||
onSceneEventsModifiedOutsideEditor: (
|
||||
changes: SceneEventsOutsideEditorChanges
|
||||
) => void,
|
||||
startNewChat: () => void,
|
||||
startNewChat: (mode: 'chat' | 'agent') => void,
|
||||
forceInGameEditorHotReload: ({|
|
||||
projectDataOnlyExport: boolean,
|
||||
shouldReloadResources: boolean,
|
||||
@@ -529,6 +531,7 @@ export const AskAiEditor = React.memo<Props>(
|
||||
onOpenLayout,
|
||||
onSceneEventsModifiedOutsideEditor,
|
||||
onExtensionInstalled,
|
||||
mode,
|
||||
gameEditorMode,
|
||||
}: Props,
|
||||
ref
|
||||
@@ -561,11 +564,27 @@ export const AskAiEditor = React.memo<Props>(
|
||||
] = React.useState<NewAiRequestOptions | null>(null);
|
||||
|
||||
const [isHistoryOpen, setIsHistoryOpen] = React.useState<boolean>(false);
|
||||
const [newChatMode, setNewChatMode] = React.useState<'chat' | 'agent'>(
|
||||
mode || 'agent'
|
||||
);
|
||||
|
||||
// Update newChatMode when mode prop changes
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (mode) {
|
||||
setNewChatMode(mode);
|
||||
}
|
||||
},
|
||||
[mode]
|
||||
);
|
||||
|
||||
const canStartNewChat = !!selectedAiRequestId;
|
||||
const onStartNewChat = React.useCallback(
|
||||
() => {
|
||||
(mode: 'chat' | 'agent') => {
|
||||
setSelectedAiRequestId(null);
|
||||
if (mode) {
|
||||
setNewChatMode(mode);
|
||||
}
|
||||
},
|
||||
[setSelectedAiRequestId]
|
||||
);
|
||||
@@ -743,11 +762,17 @@ export const AskAiEditor = React.memo<Props>(
|
||||
|
||||
setSendingAiRequest(null, true);
|
||||
|
||||
const preparedAiUserContent = await prepareAiUserContent({
|
||||
getAuthorizationHeader,
|
||||
userId: profile.id,
|
||||
simplifiedProjectJson,
|
||||
projectSpecificExtensionsSummaryJson,
|
||||
});
|
||||
|
||||
const aiRequest = await createAiRequest(getAuthorizationHeader, {
|
||||
userRequest: userRequest,
|
||||
userId: profile.id,
|
||||
gameProjectJson: simplifiedProjectJson,
|
||||
projectSpecificExtensionsSummaryJson,
|
||||
...preparedAiUserContent,
|
||||
payWithCredits,
|
||||
gameId: project ? project.getProjectUuid() : null,
|
||||
fileMetadata,
|
||||
@@ -892,13 +917,19 @@ export const AskAiEditor = React.memo<Props>(
|
||||
)
|
||||
: null;
|
||||
|
||||
const preparedAiUserContent = await prepareAiUserContent({
|
||||
getAuthorizationHeader,
|
||||
userId: profile.id,
|
||||
simplifiedProjectJson,
|
||||
projectSpecificExtensionsSummaryJson,
|
||||
});
|
||||
|
||||
const aiRequest: AiRequest = await retryIfFailed({ times: 2 }, () =>
|
||||
addMessageToAiRequest(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
aiRequestId: selectedAiRequestId,
|
||||
functionCallOutputs,
|
||||
gameProjectJson: simplifiedProjectJson,
|
||||
projectSpecificExtensionsSummaryJson,
|
||||
...preparedAiUserContent,
|
||||
payWithCredits,
|
||||
userMessage,
|
||||
})
|
||||
@@ -1046,6 +1077,7 @@ export const AskAiEditor = React.memo<Props>(
|
||||
? isAutoProcessingFunctionCalls(selectedAiRequest.id)
|
||||
: false
|
||||
}
|
||||
initialMode={newChatMode}
|
||||
setAutoProcessFunctionCalls={shouldAutoProcess => {
|
||||
if (!selectedAiRequest) return;
|
||||
setAutoProcessFunctionCalls(
|
||||
@@ -1104,6 +1136,7 @@ export const renderAskAiEditorContainer = (
|
||||
props.onSceneEventsModifiedOutsideEditor
|
||||
}
|
||||
onExtensionInstalled={props.onExtensionInstalled}
|
||||
mode={props.extraEditorProps && props.extraEditorProps.mode}
|
||||
gameEditorMode={props.gameEditorMode}
|
||||
/>
|
||||
)}
|
||||
|
233
newIDE/app/src/AiGeneration/PrepareAiUserContent.js
Normal file
233
newIDE/app/src/AiGeneration/PrepareAiUserContent.js
Normal file
@@ -0,0 +1,233 @@
|
||||
// @flow
|
||||
import axios from 'axios';
|
||||
import { retryIfFailed } from '../Utils/RetryIfFailed';
|
||||
import {
|
||||
createAiUserContentPresignedUrls,
|
||||
type AiUserContentPresignedUrlsResult,
|
||||
} from '../Utils/GDevelopServices/Generation';
|
||||
import jsSHA from '../Utils/Sha256';
|
||||
|
||||
type UploadInfo = {
|
||||
uploadedAt: number,
|
||||
userRelativeKey: string | null,
|
||||
};
|
||||
|
||||
const makeUploadCache = ({
|
||||
minimalContentLength,
|
||||
}: {|
|
||||
minimalContentLength: number | null,
|
||||
|}) => {
|
||||
const uploadCacheByHash: {
|
||||
[string]: UploadInfo,
|
||||
} = {};
|
||||
|
||||
return {
|
||||
getUserRelativeKey: (hash: string | null) => {
|
||||
if (!hash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
(uploadCacheByHash[hash] && uploadCacheByHash[hash].userRelativeKey) ||
|
||||
null
|
||||
);
|
||||
},
|
||||
storeUpload: (hash: string | null, uploadInfo: UploadInfo) => {
|
||||
if (!hash) return;
|
||||
uploadCacheByHash[hash] = uploadInfo;
|
||||
},
|
||||
shouldUpload: ({
|
||||
hash,
|
||||
contentLength,
|
||||
}: {|
|
||||
hash: string | null,
|
||||
contentLength: number,
|
||||
|}) => {
|
||||
if (!hash) {
|
||||
// No hash, so no content to upload.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (minimalContentLength && contentLength < minimalContentLength) {
|
||||
// The content is too small to be uploaded.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
uploadCacheByHash[hash] &&
|
||||
uploadCacheByHash[hash].uploadedAt > Date.now() - 1000 * 60 * 30
|
||||
) {
|
||||
// The content was already uploaded recently (and recently enough so that it has not expired in such a short time).
|
||||
// We don't need to upload it again.
|
||||
return false;
|
||||
}
|
||||
|
||||
// The content was not uploaded, or not recently: we'll upload it now.
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const projectSpecificExtensionsSummaryUploadCache = makeUploadCache({
|
||||
minimalContentLength: null, // Always upload the project specific extensions summary.
|
||||
});
|
||||
const gameProjectJsonUploadCache = makeUploadCache({
|
||||
minimalContentLength: 10 * 1000, // Roughly 10KB.
|
||||
});
|
||||
|
||||
const computeSha256 = (payload: string): string => {
|
||||
const shaObj = new jsSHA('SHA-256', 'TEXT', { encoding: 'UTF8' });
|
||||
shaObj.update(payload);
|
||||
return shaObj.getHash('HEX');
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepare the user content to be used by the AI.
|
||||
* It either uploads the content (and avoid uploading it again for a while)
|
||||
* so that the request will just refer to the key where it's stored, or
|
||||
* return the content so it's sent as part of the request itself (if it's small enough).
|
||||
*/
|
||||
export const prepareAiUserContent = async ({
|
||||
getAuthorizationHeader,
|
||||
userId,
|
||||
simplifiedProjectJson,
|
||||
projectSpecificExtensionsSummaryJson,
|
||||
}: {|
|
||||
getAuthorizationHeader: () => Promise<string>,
|
||||
userId: string,
|
||||
simplifiedProjectJson: string | null,
|
||||
projectSpecificExtensionsSummaryJson: string | null,
|
||||
|}) => {
|
||||
// Hash the contents, if provided, to then upload it only once (as long as the hash stays
|
||||
// the same, no need to re-upload it for a while).
|
||||
// If the content is not provided, no hash is computed because there is no content to upload.
|
||||
const startTime = Date.now();
|
||||
const gameProjectJsonHash = simplifiedProjectJson
|
||||
? computeSha256(simplifiedProjectJson)
|
||||
: null;
|
||||
const projectSpecificExtensionsSummaryJsonHash = projectSpecificExtensionsSummaryJson
|
||||
? computeSha256(projectSpecificExtensionsSummaryJson)
|
||||
: null;
|
||||
const endTime = Date.now();
|
||||
console.info(
|
||||
`Hash of simplified project json and project specific extensions summary json took ${(
|
||||
endTime - startTime
|
||||
).toFixed(2)}ms`
|
||||
);
|
||||
|
||||
const shouldUploadProjectSpecificExtensionsSummary = projectSpecificExtensionsSummaryUploadCache.shouldUpload(
|
||||
{
|
||||
hash: projectSpecificExtensionsSummaryJsonHash,
|
||||
contentLength: projectSpecificExtensionsSummaryJson
|
||||
? projectSpecificExtensionsSummaryJson.length
|
||||
: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const shouldUploadGameProjectJson = gameProjectJsonUploadCache.shouldUpload({
|
||||
hash: gameProjectJsonHash,
|
||||
contentLength: simplifiedProjectJson ? simplifiedProjectJson.length : 0,
|
||||
});
|
||||
|
||||
if (
|
||||
shouldUploadGameProjectJson ||
|
||||
shouldUploadProjectSpecificExtensionsSummary
|
||||
) {
|
||||
const startTime = Date.now();
|
||||
const {
|
||||
gameProjectJsonSignedUrl,
|
||||
gameProjectJsonUserRelativeKey,
|
||||
projectSpecificExtensionsSummaryJsonSignedUrl,
|
||||
projectSpecificExtensionsSummaryJsonUserRelativeKey,
|
||||
}: AiUserContentPresignedUrlsResult = await retryIfFailed(
|
||||
{ times: 3 },
|
||||
() =>
|
||||
createAiUserContentPresignedUrls(getAuthorizationHeader, {
|
||||
userId,
|
||||
gameProjectJsonHash: shouldUploadGameProjectJson
|
||||
? gameProjectJsonHash
|
||||
: null,
|
||||
projectSpecificExtensionsSummaryJsonHash: shouldUploadProjectSpecificExtensionsSummary
|
||||
? projectSpecificExtensionsSummaryJsonHash
|
||||
: null,
|
||||
})
|
||||
);
|
||||
|
||||
const uploadedAt = Date.now();
|
||||
|
||||
await Promise.all([
|
||||
gameProjectJsonSignedUrl
|
||||
? retryIfFailed({ times: 3 }, () =>
|
||||
axios.put(gameProjectJsonSignedUrl, simplifiedProjectJson, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Allow any arbitrary large file to be sent
|
||||
maxContentLength: Infinity,
|
||||
})
|
||||
).then(() => {
|
||||
gameProjectJsonUploadCache.storeUpload(gameProjectJsonHash, {
|
||||
uploadedAt,
|
||||
userRelativeKey: gameProjectJsonUserRelativeKey || null,
|
||||
});
|
||||
})
|
||||
: null,
|
||||
projectSpecificExtensionsSummaryJsonSignedUrl
|
||||
? retryIfFailed({ times: 3 }, () =>
|
||||
axios.put(
|
||||
projectSpecificExtensionsSummaryJsonSignedUrl,
|
||||
projectSpecificExtensionsSummaryJson,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Allow any arbitrary large file to be sent
|
||||
maxContentLength: Infinity,
|
||||
}
|
||||
)
|
||||
).then(() => {
|
||||
projectSpecificExtensionsSummaryUploadCache.storeUpload(
|
||||
projectSpecificExtensionsSummaryJsonHash,
|
||||
{
|
||||
uploadedAt,
|
||||
userRelativeKey:
|
||||
projectSpecificExtensionsSummaryJsonUserRelativeKey || null,
|
||||
}
|
||||
);
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
|
||||
const endTime = Date.now();
|
||||
console.info(
|
||||
`Upload of ${[
|
||||
shouldUploadGameProjectJson ? 'simplified project' : null,
|
||||
shouldUploadProjectSpecificExtensionsSummary
|
||||
? 'project specific extensions summary'
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ')} took ${(endTime - startTime).toFixed(2)}ms`
|
||||
);
|
||||
}
|
||||
|
||||
// Get the key at which the content was uploaded, if it was uploaded.
|
||||
// If not, the content will be sent as part of the request instead of the upload key.
|
||||
const gameProjectJsonUserRelativeKey = gameProjectJsonUploadCache.getUserRelativeKey(
|
||||
gameProjectJsonHash
|
||||
);
|
||||
const projectSpecificExtensionsSummaryJsonUserRelativeKey = projectSpecificExtensionsSummaryUploadCache.getUserRelativeKey(
|
||||
projectSpecificExtensionsSummaryJsonHash
|
||||
);
|
||||
|
||||
return {
|
||||
gameProjectJsonUserRelativeKey,
|
||||
gameProjectJson: gameProjectJsonUserRelativeKey
|
||||
? null
|
||||
: simplifiedProjectJson,
|
||||
projectSpecificExtensionsSummaryJsonUserRelativeKey,
|
||||
projectSpecificExtensionsSummaryJson: projectSpecificExtensionsSummaryJsonUserRelativeKey
|
||||
? null
|
||||
: projectSpecificExtensionsSummaryJson,
|
||||
};
|
||||
};
|
@@ -8,7 +8,7 @@ import RaisedButton from '../UI/RaisedButton';
|
||||
import IconButton from '../UI/IconButton';
|
||||
|
||||
type Props = {|
|
||||
onStartNewChat: () => void,
|
||||
onStartNewChat: (mode: 'chat' | 'agent') => void,
|
||||
canStartNewChat: boolean,
|
||||
onOpenHistory: () => void,
|
||||
|};
|
||||
@@ -33,7 +33,7 @@ export const Toolbar = ({
|
||||
<ToolbarGroup lastChild>
|
||||
<RaisedButton
|
||||
primary
|
||||
onClick={onStartNewChat}
|
||||
onClick={() => onStartNewChat('agent')}
|
||||
icon={<AddIcon />}
|
||||
label={<Trans>Start a new chat</Trans>}
|
||||
disabled={!canStartNewChat}
|
||||
|
@@ -10,6 +10,7 @@ import {
|
||||
|
||||
import { type EventsGenerationResult } from '../EditorFunctions';
|
||||
import { makeSimplifiedProjectBuilder } from '../EditorFunctions/SimplifiedProject/SimplifiedProject';
|
||||
import { prepareAiUserContent } from './PrepareAiUserContent';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
@@ -40,21 +41,24 @@ export const useGenerateEvents = ({ project }: {| project: ?gdProject |}) => {
|
||||
if (!profile) throw new Error('User should be authenticated.');
|
||||
|
||||
const simplifiedProjectBuilder = makeSimplifiedProjectBuilder(gd);
|
||||
const simplifiedProjectJson = JSON.stringify(
|
||||
simplifiedProjectBuilder.getSimplifiedProject(project, {})
|
||||
);
|
||||
const projectSpecificExtensionsSummaryJson = JSON.stringify(
|
||||
simplifiedProjectBuilder.getProjectSpecificExtensionsSummary(project)
|
||||
);
|
||||
|
||||
const preparedAiUserContent = await prepareAiUserContent({
|
||||
getAuthorizationHeader,
|
||||
userId: profile.id,
|
||||
simplifiedProjectJson,
|
||||
projectSpecificExtensionsSummaryJson,
|
||||
});
|
||||
|
||||
const createResult = await retryIfFailed({ times: 2 }, () =>
|
||||
createAiGeneratedEvent(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
partialGameProjectJson: JSON.stringify(
|
||||
simplifiedProjectBuilder.getSimplifiedProject(project, {
|
||||
scopeToScene: sceneName,
|
||||
}),
|
||||
null,
|
||||
2
|
||||
),
|
||||
projectSpecificExtensionsSummaryJson,
|
||||
...preparedAiUserContent,
|
||||
sceneName,
|
||||
eventsDescription,
|
||||
extensionNamesList,
|
||||
|
@@ -28,9 +28,10 @@ import {
|
||||
import { useDebounce } from '../Utils/UseDebounce';
|
||||
import PromotionsSlideshow from '../Promotions/PromotionsSlideshow';
|
||||
import { ColumnStackLayout } from '../UI/Layout';
|
||||
import { EarnCredits } from '../MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits';
|
||||
import { EarnCredits } from '../GameDashboard/Wallet/EarnCredits';
|
||||
import { LARGE_WIDGET_SIZE } from '../MainFrame/EditorContainers/HomePage/CardWidget';
|
||||
|
||||
const cellSpacing = 2;
|
||||
const cellSpacing = 10;
|
||||
|
||||
const getCategoryColumns = (
|
||||
windowSize: WindowSizeType,
|
||||
@@ -42,9 +43,9 @@ const getCategoryColumns = (
|
||||
case 'medium':
|
||||
return 3;
|
||||
case 'large':
|
||||
return 4;
|
||||
return 5;
|
||||
case 'xlarge':
|
||||
return 6;
|
||||
return 7;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
@@ -58,11 +59,11 @@ const getShopItemsColumns = (
|
||||
case 'small':
|
||||
return isLandscape ? 3 : 2;
|
||||
case 'medium':
|
||||
return 2;
|
||||
case 'large':
|
||||
return 3;
|
||||
case 'large':
|
||||
return 4;
|
||||
case 'xlarge':
|
||||
return 5;
|
||||
return 6;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
@@ -118,9 +119,13 @@ export const shopCategories = {
|
||||
},
|
||||
};
|
||||
|
||||
const MAX_COLUMNS = getShopItemsColumns('xlarge', true);
|
||||
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
|
||||
const styles = {
|
||||
grid: {
|
||||
margin: '0 10px',
|
||||
// Avoid tiles taking too much space on large screens.
|
||||
maxWidth: MAX_SECTION_WIDTH,
|
||||
width: `calc(100% + ${cellSpacing}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
|
||||
// Remove the scroll capability of the grid, the scroll view handles it.
|
||||
overflow: 'unset',
|
||||
},
|
||||
@@ -378,7 +383,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
|
||||
>
|
||||
{openedShopCategory ? null : (
|
||||
<>
|
||||
<Column>
|
||||
<Column noMargin>
|
||||
<Line>
|
||||
<Text size="block-title">
|
||||
<Trans>Explore by category</Trans>
|
||||
@@ -395,8 +400,8 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
|
||||
</GridList>
|
||||
</>
|
||||
)}
|
||||
{displayPromotions ? (
|
||||
<ColumnStackLayout>
|
||||
{displayPromotions && !openedShopCategory ? (
|
||||
<ColumnStackLayout noMargin>
|
||||
<Text size="block-title">
|
||||
<Trans>Promotions + Earn credits</Trans>
|
||||
</Text>
|
||||
@@ -413,7 +418,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
|
||||
) : null}
|
||||
{allBundleTiles.length ? (
|
||||
<>
|
||||
<Column>
|
||||
<Column noMargin>
|
||||
<Line>
|
||||
<Text size="block-title">
|
||||
<Trans>Bundles</Trans>
|
||||
@@ -431,7 +436,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
|
||||
</>
|
||||
) : null}
|
||||
{openedShopCategoryTitle && (
|
||||
<Column>
|
||||
<Column noMargin>
|
||||
<Line>
|
||||
<Text size="block-title">{openedShopCategoryTitle}</Text>
|
||||
</Line>
|
||||
@@ -440,7 +445,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
|
||||
{!hideGameTemplates && (
|
||||
<>
|
||||
{!openedShopCategoryTitle && (
|
||||
<Column>
|
||||
<Column noMargin>
|
||||
<Line>
|
||||
<Text size="block-title">
|
||||
<Trans>All game templates</Trans>
|
||||
@@ -459,7 +464,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
|
||||
</>
|
||||
)}
|
||||
{!openedShopCategoryTitle && (
|
||||
<Column>
|
||||
<Column noMargin>
|
||||
<Line>
|
||||
<Text size="block-title">
|
||||
<Trans>All asset packs</Trans>
|
||||
|
@@ -79,7 +79,7 @@ const getShopItemsColumns = (
|
||||
case 'medium':
|
||||
return 2;
|
||||
case 'large':
|
||||
return 3;
|
||||
return 4;
|
||||
case 'xlarge':
|
||||
return 5;
|
||||
default:
|
||||
@@ -138,7 +138,7 @@ export const getAssetShortHeadersToDisplay = (
|
||||
return assetShortHeaders;
|
||||
};
|
||||
|
||||
const cellSpacing = 8;
|
||||
const cellSpacing = 10;
|
||||
const styles = {
|
||||
grid: {
|
||||
margin: '0 2px', // Remove the default margin of the grid but keep the horizontal padding for focus outline.
|
||||
@@ -469,7 +469,6 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
|
||||
// Don't show assets if filtering on asset packs.)
|
||||
if (hasAssetPackFiltersApplied && !openedAssetPack) return [];
|
||||
const assetSize = getAssetSize(windowSize);
|
||||
const margin = cellSpacing / 2;
|
||||
|
||||
return getAssetShortHeadersToDisplay(
|
||||
assetShortHeaders,
|
||||
@@ -481,7 +480,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
|
||||
onOpenDetails={() => onOpenDetails(assetShortHeader)}
|
||||
size={assetSize}
|
||||
key={assetShortHeader.id}
|
||||
margin={margin}
|
||||
margin={cellSpacing / 2}
|
||||
hideShortDescription={!!hideDetails}
|
||||
/>
|
||||
));
|
||||
@@ -749,7 +748,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
|
||||
cols={getShopItemsColumns(windowSize, isLandscape)}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
>
|
||||
{gameTemplateTiles}
|
||||
</GridList>
|
||||
@@ -765,7 +764,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
|
||||
cols={getShopItemsColumns(windowSize, isLandscape)}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
>
|
||||
{allBundlePackTiles}
|
||||
</GridList>
|
||||
@@ -781,7 +780,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
|
||||
cols={getShopItemsColumns(windowSize, isLandscape)}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
>
|
||||
{allStandAlonePackTiles}
|
||||
</GridList>
|
||||
@@ -945,12 +944,12 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
|
||||
</Column>
|
||||
) : null}
|
||||
{openedAssetPack && folderTiles.length ? (
|
||||
<Column justifyContent="center">
|
||||
<Column justifyContent="center" noMargin>
|
||||
<GridList
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
cols={getAssetFoldersColumns(windowSize, isLandscape)}
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
>
|
||||
{folderTiles}
|
||||
</GridList>
|
||||
|
@@ -22,10 +22,18 @@ import {
|
||||
isLinkedToStartingPointExampleShortHeader,
|
||||
isStartingPointExampleShortHeader,
|
||||
} from '../../ProjectCreation/EmptyAndStartingPointProjects';
|
||||
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
|
||||
import {
|
||||
useResponsiveWindowSize,
|
||||
type WindowSizeType,
|
||||
} from '../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
|
||||
|
||||
const ITEMS_SPACING = 5;
|
||||
const styles = {
|
||||
grid: {
|
||||
margin: 0,
|
||||
textAlign: 'center',
|
||||
width: `calc(100% + ${2 * ITEMS_SPACING}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
|
||||
// Remove the scroll capability of the grid, the scroll view handles it.
|
||||
overflow: 'unset',
|
||||
},
|
||||
@@ -59,12 +67,13 @@ type Props = {|
|
||||
i18n: I18nType,
|
||||
onlyShowGames?: boolean,
|
||||
hideStartingPoints?: boolean,
|
||||
columnsCount: number,
|
||||
rowToInsert?: {|
|
||||
row: number,
|
||||
element: React.Node,
|
||||
|},
|
||||
getColumnsFromWindowSize: (
|
||||
windowSize: WindowSizeType,
|
||||
isLandscape: boolean
|
||||
) => number,
|
||||
hideSearch?: boolean,
|
||||
limitRowsTo?: number,
|
||||
hidePremiumTemplates?: boolean,
|
||||
|};
|
||||
|
||||
const ExampleStore = ({
|
||||
@@ -73,10 +82,15 @@ const ExampleStore = ({
|
||||
i18n,
|
||||
onlyShowGames,
|
||||
hideStartingPoints,
|
||||
columnsCount,
|
||||
rowToInsert,
|
||||
getColumnsFromWindowSize,
|
||||
hideSearch,
|
||||
limitRowsTo,
|
||||
hidePremiumTemplates,
|
||||
}: Props) => {
|
||||
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
|
||||
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
|
||||
const { windowSize, isLandscape } = useResponsiveWindowSize();
|
||||
|
||||
const { receivedGameTemplates } = React.useContext(AuthenticatedUserContext);
|
||||
const {
|
||||
exampleShortHeaders: allExampleShortHeaders,
|
||||
@@ -95,6 +109,8 @@ const ExampleStore = ({
|
||||
const [localSearchText, setLocalSearchText] = React.useState(
|
||||
exampleStoreSearchText
|
||||
);
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
const columnsCount = getColumnsFromWindowSize(windowSize, isLandscape);
|
||||
|
||||
const shouldAutofocusSearchbar = useShouldAutofocusInput();
|
||||
const searchBarRef = React.useRef<?SearchBarInterface>(null);
|
||||
@@ -143,14 +159,15 @@ const ExampleStore = ({
|
||||
() => {
|
||||
return getExampleAndTemplateTiles({
|
||||
receivedGameTemplates,
|
||||
privateGameTemplateListingDatas: privateGameTemplateListingDatasSearchResults
|
||||
? privateGameTemplateListingDatasSearchResults
|
||||
.map(({ item }) => item)
|
||||
.filter(
|
||||
privateGameTemplateListingData =>
|
||||
!onlyShowGames || gameFilter(privateGameTemplateListingData)
|
||||
)
|
||||
: [],
|
||||
privateGameTemplateListingDatas:
|
||||
privateGameTemplateListingDatasSearchResults && !hidePremiumTemplates
|
||||
? privateGameTemplateListingDatasSearchResults
|
||||
.map(({ item }) => item)
|
||||
.filter(
|
||||
privateGameTemplateListingData =>
|
||||
!onlyShowGames || gameFilter(privateGameTemplateListingData)
|
||||
)
|
||||
: [],
|
||||
exampleShortHeaders: exampleShortHeadersSearchResults
|
||||
? exampleShortHeadersSearchResults
|
||||
.map(({ item }) => item)
|
||||
@@ -183,9 +200,10 @@ const ExampleStore = ({
|
||||
onSelectExampleShortHeader(exampleShortHeader);
|
||||
},
|
||||
i18n,
|
||||
gdevelopTheme,
|
||||
privateGameTemplatesPeriodicity: 1,
|
||||
showOwnedGameTemplatesFirst: true,
|
||||
}).allGridItems;
|
||||
});
|
||||
},
|
||||
[
|
||||
receivedGameTemplates,
|
||||
@@ -194,55 +212,14 @@ const ExampleStore = ({
|
||||
onSelectPrivateGameTemplateListingData,
|
||||
onSelectExampleShortHeader,
|
||||
i18n,
|
||||
gdevelopTheme,
|
||||
onlyShowGames,
|
||||
hideStartingPoints,
|
||||
allExampleShortHeaders,
|
||||
hidePremiumTemplates,
|
||||
]
|
||||
);
|
||||
|
||||
const nodesToDisplay: React.Node[] = React.useMemo(
|
||||
() => {
|
||||
const numberOfTilesToDisplayUntilRowToInsert = rowToInsert
|
||||
? rowToInsert.row * columnsCount
|
||||
: 0;
|
||||
const firstTiles = resultTiles.slice(
|
||||
0,
|
||||
numberOfTilesToDisplayUntilRowToInsert
|
||||
);
|
||||
const lastTiles = resultTiles.slice(
|
||||
numberOfTilesToDisplayUntilRowToInsert
|
||||
);
|
||||
return [
|
||||
firstTiles.length > 0 ? (
|
||||
<GridList
|
||||
cols={columnsCount}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={2}
|
||||
key="first-tiles"
|
||||
>
|
||||
{firstTiles}
|
||||
</GridList>
|
||||
) : null,
|
||||
rowToInsert ? (
|
||||
<Line key="inserted-row">{rowToInsert.element}</Line>
|
||||
) : null,
|
||||
lastTiles.length > 0 ? (
|
||||
<GridList
|
||||
cols={columnsCount}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={2}
|
||||
key="last-tiles"
|
||||
>
|
||||
{lastTiles}
|
||||
</GridList>
|
||||
) : null,
|
||||
].filter(Boolean);
|
||||
},
|
||||
[columnsCount, rowToInsert, resultTiles]
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Column expand noMargin>
|
||||
@@ -267,11 +244,24 @@ const ExampleStore = ({
|
||||
No results returned for your search. Try something else!
|
||||
</Trans>
|
||||
</BackgroundText>
|
||||
{rowToInsert && <Line>{rowToInsert.element}</Line>}
|
||||
</Column>
|
||||
) : (
|
||||
<ColumnStackLayout noMargin expand>
|
||||
{nodesToDisplay}
|
||||
<GridList
|
||||
cols={columnsCount}
|
||||
style={{
|
||||
...styles.grid,
|
||||
// Avoid tiles taking too much space on large screens.
|
||||
maxWidth: MAX_SECTION_WIDTH,
|
||||
}}
|
||||
cellHeight="auto"
|
||||
spacing={ITEMS_SPACING * 2}
|
||||
>
|
||||
{resultTiles.slice(
|
||||
0,
|
||||
limitRowsTo ? limitRowsTo * columnsCount : Infinity
|
||||
)}
|
||||
</GridList>
|
||||
</ColumnStackLayout>
|
||||
)}
|
||||
</Column>
|
||||
|
@@ -64,8 +64,9 @@ import Window from '../../Utils/Window';
|
||||
import RaisedButton from '../../UI/RaisedButton';
|
||||
import PrivateAssetPackPurchaseDialog from './PrivateAssetPackPurchaseDialog';
|
||||
import PublicProfileContext from '../../Profile/PublicProfileContext';
|
||||
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
|
||||
|
||||
const cellSpacing = 8;
|
||||
const cellSpacing = 10;
|
||||
|
||||
const getPackColumns = (windowSize: WindowSizeType, isLandscape: boolean) => {
|
||||
switch (windowSize) {
|
||||
@@ -76,7 +77,7 @@ const getPackColumns = (windowSize: WindowSizeType, isLandscape: boolean) => {
|
||||
case 'large':
|
||||
return 4;
|
||||
case 'xlarge':
|
||||
return 5;
|
||||
return 6;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
@@ -108,11 +109,16 @@ const contentTypeToMessageDescriptor: {
|
||||
audio: t`audios`,
|
||||
};
|
||||
|
||||
const MAX_COLUMNS = getPackColumns('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: {
|
||||
margin: '0 2px', // Remove the default margin of the grid but keep the horizontal padding for focus outline.
|
||||
// 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: 3,
|
||||
@@ -211,10 +217,9 @@ const PrivateAssetPackInformationPage = ({
|
||||
sellerPublicProfile,
|
||||
setSellerPublicProfile,
|
||||
] = React.useState<?UserPublicProfile>(null);
|
||||
const [
|
||||
displayPasswordPrompt,
|
||||
setDisplayPasswordPrompt,
|
||||
] = React.useState<boolean>(false);
|
||||
const [displayPasswordPrompt, setDisplayPasswordPrompt] = React.useState<
|
||||
'redeem' | 'credits' | null
|
||||
>(null);
|
||||
const [password, setPassword] = React.useState<string>('');
|
||||
const [errorText, setErrorText] = React.useState<?React.Node>(null);
|
||||
const { isLandscape, isMediumScreen, windowSize } = useResponsiveWindowSize();
|
||||
@@ -297,10 +302,16 @@ const PrivateAssetPackInformationPage = ({
|
||||
|
||||
const onWillRedeemAssetPack = () => {
|
||||
// Password is required in dev environment only so that one cannot freely claim asset packs.
|
||||
if (Window.isDev()) setDisplayPasswordPrompt(true);
|
||||
if (Window.isDev()) setDisplayPasswordPrompt('redeem');
|
||||
else onRedeemAssetPack();
|
||||
};
|
||||
|
||||
const onWillBuyWithCredits = () => {
|
||||
// Password is required in dev environment only so that one cannot freely claim asset packs.
|
||||
if (Window.isDev()) setDisplayPasswordPrompt('credits');
|
||||
else onClickBuyWithCredits();
|
||||
};
|
||||
|
||||
const onRedeemAssetPack = React.useCallback(
|
||||
async () => {
|
||||
if (!profile || isRedeemingProduct) return;
|
||||
@@ -704,7 +715,7 @@ const PrivateAssetPackInformationPage = ({
|
||||
simulateAppStoreProduct={simulateAppStoreProduct}
|
||||
isAlreadyReceived={isAlreadyReceived}
|
||||
onClickBuy={onClickBuy}
|
||||
onClickBuyWithCredits={onClickBuyWithCredits}
|
||||
onClickBuyWithCredits={onWillBuyWithCredits}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -764,7 +775,7 @@ const PrivateAssetPackInformationPage = ({
|
||||
<GridList
|
||||
cols={getPackColumns(windowSize, isLandscape)}
|
||||
cellHeight="auto"
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
style={styles.grid}
|
||||
>
|
||||
{packsIncludedInBundleTiles}
|
||||
@@ -784,7 +795,7 @@ const PrivateAssetPackInformationPage = ({
|
||||
<GridList
|
||||
cols={getPackColumns(windowSize, isLandscape)}
|
||||
cellHeight="auto"
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
style={styles.grid}
|
||||
>
|
||||
{otherPacksFromTheSameAuthorTiles}
|
||||
@@ -797,8 +808,12 @@ const PrivateAssetPackInformationPage = ({
|
||||
) : null}
|
||||
{displayPasswordPrompt && (
|
||||
<PasswordPromptDialog
|
||||
onApply={onRedeemAssetPack}
|
||||
onClose={() => setDisplayPasswordPrompt(false)}
|
||||
onApply={
|
||||
displayPasswordPrompt === 'redeem'
|
||||
? onWillRedeemAssetPack
|
||||
: onClickBuyWithCredits
|
||||
}
|
||||
onClose={() => setDisplayPasswordPrompt(null)}
|
||||
passwordValue={password}
|
||||
setPasswordValue={setPassword}
|
||||
/>
|
||||
|
@@ -59,8 +59,9 @@ import Play from '../../UI/CustomSvgIcons/Play';
|
||||
import PrivateGameTemplatePurchaseDialog from './PrivateGameTemplatePurchaseDialog';
|
||||
import PasswordPromptDialog from '../PasswordPromptDialog';
|
||||
import PublicProfileContext from '../../Profile/PublicProfileContext';
|
||||
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
|
||||
|
||||
const cellSpacing = 8;
|
||||
const cellSpacing = 10;
|
||||
|
||||
const getTemplateColumns = (
|
||||
windowSize: WindowSizeType,
|
||||
@@ -74,17 +75,21 @@ const getTemplateColumns = (
|
||||
case 'large':
|
||||
return 4;
|
||||
case 'xlarge':
|
||||
return 5;
|
||||
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: {
|
||||
margin: '0 2px', // Remove the default margin of the grid but keep the horizontal padding for focus outline.
|
||||
// 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: 3,
|
||||
@@ -558,7 +563,7 @@ const PrivateGameTemplateInformationPage = ({
|
||||
simulateAppStoreProduct={simulateAppStoreProduct}
|
||||
isAlreadyReceived={isAlreadyReceived}
|
||||
onClickBuy={onClickBuy}
|
||||
onClickBuyWithCredits={onClickBuyWithCredits}
|
||||
onClickBuyWithCredits={onWillBuyWithCredits}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -605,7 +610,7 @@ const PrivateGameTemplateInformationPage = ({
|
||||
<GridList
|
||||
cols={getTemplateColumns(windowSize, isLandscape)}
|
||||
cellHeight="auto"
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
style={styles.grid}
|
||||
>
|
||||
{templatesIncludedInBundleTiles}
|
||||
@@ -625,7 +630,7 @@ const PrivateGameTemplateInformationPage = ({
|
||||
<GridList
|
||||
cols={getTemplateColumns(windowSize, isLandscape)}
|
||||
cellHeight="auto"
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
style={styles.grid}
|
||||
>
|
||||
{otherTemplatesFromTheSameAuthorTiles}
|
||||
@@ -638,7 +643,7 @@ const PrivateGameTemplateInformationPage = ({
|
||||
) : null}
|
||||
{displayPasswordPrompt && (
|
||||
<PasswordPromptDialog
|
||||
onApply={onWillBuyWithCredits}
|
||||
onApply={onClickBuyWithCredits}
|
||||
onClose={() => setDisplayPasswordPrompt(false)}
|
||||
passwordValue={password}
|
||||
setPasswordValue={setPassword}
|
||||
|
@@ -4,6 +4,7 @@ import { type I18n as I18nType } from '@lingui/core';
|
||||
import {
|
||||
type PrivateAssetPackListingData,
|
||||
type PrivateGameTemplateListingData,
|
||||
type CourseListingData,
|
||||
type Purchase,
|
||||
} from '../Utils/GDevelopServices/Shop';
|
||||
import {
|
||||
@@ -283,7 +284,10 @@ export const getUserProductPurchaseUsageType = <
|
||||
};
|
||||
|
||||
export const PurchaseProductButtons = <
|
||||
T: PrivateAssetPackListingData | PrivateGameTemplateListingData
|
||||
T:
|
||||
| PrivateAssetPackListingData
|
||||
| PrivateGameTemplateListingData
|
||||
| CourseListingData
|
||||
>({
|
||||
productListingData,
|
||||
selectedUsageType,
|
||||
@@ -300,8 +304,8 @@ export const PurchaseProductButtons = <
|
||||
simulateAppStoreProduct?: boolean,
|
||||
i18n: I18nType,
|
||||
isAlreadyReceived: boolean,
|
||||
onClickBuy: () => Promise<void>,
|
||||
onClickBuyWithCredits: () => Promise<void>,
|
||||
onClickBuy: () => void | Promise<void>,
|
||||
onClickBuyWithCredits: () => void | Promise<void>,
|
||||
|}) => {
|
||||
const { authenticated } = React.useContext(AuthenticatedUserContext);
|
||||
const shouldUseOrSimulateAppStoreProduct =
|
||||
|
@@ -8,6 +8,7 @@ import {
|
||||
type PrivateAssetPackListingData,
|
||||
type PrivateGameTemplateListingData,
|
||||
type CreditsPackageListingData,
|
||||
type CourseListingData,
|
||||
} from '../Utils/GDevelopServices/Shop';
|
||||
import {
|
||||
shouldUseAppStoreProduct,
|
||||
@@ -16,11 +17,26 @@ import {
|
||||
import Coin from '../Credits/Icons/Coin';
|
||||
import { LineStackLayout } from '../UI/Layout';
|
||||
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: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
width: 13,
|
||||
height: 13,
|
||||
position: 'relative',
|
||||
top: -1,
|
||||
},
|
||||
creditPriceContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
},
|
||||
columnOrSeparator: {
|
||||
marginTop: -3,
|
||||
marginBottom: -1,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,10 +44,12 @@ type FormatProps = {|
|
||||
productListingData:
|
||||
| PrivateAssetPackListingData
|
||||
| PrivateGameTemplateListingData
|
||||
| CreditsPackageListingData,
|
||||
| CreditsPackageListingData
|
||||
| CourseListingData,
|
||||
i18n: I18nType,
|
||||
usageType?: string,
|
||||
plainText?: boolean,
|
||||
showBothPrices?: 'column' | 'line', // If defined, will show both the credits price and the product price.
|
||||
|};
|
||||
|
||||
export const renderProductPrice = ({
|
||||
@@ -39,6 +57,7 @@ export const renderProductPrice = ({
|
||||
productListingData,
|
||||
usageType,
|
||||
plainText,
|
||||
showBothPrices,
|
||||
}: FormatProps): React.Node => {
|
||||
// Only use the app store product if it's a credits package.
|
||||
if (
|
||||
@@ -51,19 +70,21 @@ export const renderProductPrice = ({
|
||||
return appStoreProduct ? appStoreProduct.price : '';
|
||||
}
|
||||
|
||||
// If we're on mobile, only show credits prices for asset packs & game templates.
|
||||
const creditPrices =
|
||||
productListingData.productType !== 'CREDITS_PACKAGE'
|
||||
? 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'
|
||||
) {
|
||||
const creditPrices = productListingData.creditPrices;
|
||||
if (!creditPrices) return '';
|
||||
const creditPrice = usageType
|
||||
? creditPrices.find(price => price.usageType === usageType)
|
||||
: creditPrices.length > 0
|
||||
? creditPrices[0]
|
||||
: null;
|
||||
|
||||
if (!creditPrice) return '';
|
||||
return plainText ? (
|
||||
i18n._(t`${creditPrice.amount} credits`)
|
||||
@@ -96,6 +117,40 @@ export const renderProductPrice = ({
|
||||
|
||||
return plainText ? (
|
||||
formattedPrice
|
||||
) : showBothPrices && creditPrice ? (
|
||||
showBothPrices === 'column' ? (
|
||||
<Column alignItems="flex-end">
|
||||
<div style={styles.creditPriceContainer}>
|
||||
<Coin style={styles.icon} />
|
||||
<Text noMargin size="sub-title" color="inherit">
|
||||
{creditPrice.amount}
|
||||
</Text>
|
||||
</div>
|
||||
<span style={styles.columnOrSeparator}>
|
||||
<Text noMargin color="inherit">
|
||||
<Trans>or</Trans>
|
||||
</Text>
|
||||
</span>
|
||||
<Text noMargin size="sub-title" color="primary">
|
||||
{formattedPrice}
|
||||
</Text>
|
||||
</Column>
|
||||
) : (
|
||||
<LineStackLayout noMargin>
|
||||
<div style={styles.creditPriceContainer}>
|
||||
<Coin style={styles.icon} />
|
||||
<Text noMargin size="sub-title" color="inherit">
|
||||
{creditPrice.amount}
|
||||
</Text>
|
||||
</div>
|
||||
<Text noMargin color="inherit">
|
||||
<Trans>or</Trans>
|
||||
</Text>
|
||||
<Text noMargin size="sub-title" color="primary">
|
||||
{formattedPrice}
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
)
|
||||
) : (
|
||||
<Text noMargin size="sub-title" color="inherit">
|
||||
{formattedPrice}
|
||||
@@ -107,29 +162,36 @@ type ProductPriceOrOwnedProps = {|
|
||||
productListingData:
|
||||
| PrivateAssetPackListingData
|
||||
| PrivateGameTemplateListingData
|
||||
| CreditsPackageListingData,
|
||||
| CreditsPackageListingData
|
||||
| CourseListingData,
|
||||
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 ? (
|
||||
<LineStackLayout noMargin alignItems="center">
|
||||
<Text noMargin size="sub-title">
|
||||
✅
|
||||
</Text>
|
||||
<CheckCircle
|
||||
style={{
|
||||
color: gdevelopTheme.message.valid,
|
||||
}}
|
||||
/>
|
||||
<Text noMargin size="sub-title" color="inherit">
|
||||
<Trans>Owned</Trans>
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
) : (
|
||||
renderProductPrice({ i18n, productListingData, usageType })
|
||||
renderProductPrice({ i18n, productListingData, usageType, showBothPrices })
|
||||
);
|
||||
};
|
||||
|
||||
@@ -137,7 +199,8 @@ type ProductPriceTagProps = {|
|
||||
productListingData:
|
||||
| PrivateAssetPackListingData
|
||||
| PrivateGameTemplateListingData
|
||||
| CreditsPackageListingData,
|
||||
| CreditsPackageListingData
|
||||
| CourseListingData,
|
||||
usageType?: string,
|
||||
/**
|
||||
* To be used when the component is over an element for which
|
||||
@@ -153,11 +216,13 @@ const ProductPriceTag = ({
|
||||
withOverlay,
|
||||
owned,
|
||||
}: ProductPriceTagProps) => {
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => {
|
||||
const label = getProductPriceOrOwnedLabel({
|
||||
i18n,
|
||||
gdevelopTheme,
|
||||
productListingData,
|
||||
usageType,
|
||||
owned,
|
||||
|
@@ -12,9 +12,6 @@ import {
|
||||
} from '../Utils/GDevelopServices/Shop';
|
||||
import type { ExampleShortHeader } from '../Utils/GDevelopServices/Example';
|
||||
import GridListTile from '@material-ui/core/GridListTile';
|
||||
import createStyles from '@material-ui/core/styles/createStyles';
|
||||
import makeStyles from '@material-ui/core/styles/makeStyles';
|
||||
import { shouldValidate } from '../UI/KeyboardShortcuts/InteractionKeys';
|
||||
import { CorsAwareImage } from '../UI/CorsAwareImage';
|
||||
import { textEllipsisStyle } from '../UI/TextEllipsis';
|
||||
import { Column, Line, Spacer } from '../UI/Grid';
|
||||
@@ -30,6 +27,7 @@ import { ResponsiveLineStackLayout } from '../UI/Layout';
|
||||
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';
|
||||
|
||||
const styles = {
|
||||
priceTagContainer: {
|
||||
@@ -81,11 +79,6 @@ const styles = {
|
||||
minWidth: 200,
|
||||
margin: 4,
|
||||
},
|
||||
paper: {
|
||||
margin: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
packTitle: {
|
||||
...textEllipsisStyle,
|
||||
overflowWrap: 'break-word',
|
||||
@@ -112,20 +105,6 @@ const styles = {
|
||||
},
|
||||
};
|
||||
|
||||
const useStylesForGridListItem = ({ disabled }: {| disabled?: boolean |}) =>
|
||||
makeStyles(theme =>
|
||||
createStyles({
|
||||
tile: !disabled
|
||||
? {
|
||||
transition: 'transform 0.3s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.02)',
|
||||
},
|
||||
}
|
||||
: {},
|
||||
})
|
||||
)();
|
||||
|
||||
export const AssetCardTile = ({
|
||||
assetShortHeader,
|
||||
onOpenDetails,
|
||||
@@ -141,30 +120,21 @@ export const AssetCardTile = ({
|
||||
hideShortDescription?: boolean,
|
||||
disabled?: boolean,
|
||||
|}) => {
|
||||
const classesForGridListItem = useStylesForGridListItem({
|
||||
disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<GridListTile
|
||||
classes={classesForGridListItem}
|
||||
tabIndex={0}
|
||||
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
|
||||
if (shouldValidate(event) && !disabled) {
|
||||
onOpenDetails();
|
||||
}
|
||||
}}
|
||||
onClick={!disabled ? onOpenDetails : undefined}
|
||||
style={{
|
||||
margin,
|
||||
}}
|
||||
>
|
||||
<AssetCard
|
||||
id={`asset-card-${assetShortHeader.name.replace(/\s/g, '-')}`}
|
||||
assetShortHeader={assetShortHeader}
|
||||
size={size}
|
||||
hideShortDescription={hideShortDescription}
|
||||
/>
|
||||
<GridListTile style={{ margin }}>
|
||||
<CardWidget
|
||||
onClick={!disabled ? onOpenDetails : undefined}
|
||||
size="large"
|
||||
disabled={disabled}
|
||||
noBorder
|
||||
>
|
||||
<AssetCard
|
||||
id={`asset-card-${assetShortHeader.name.replace(/\s/g, '-')}`}
|
||||
assetShortHeader={assetShortHeader}
|
||||
size={size}
|
||||
hideShortDescription={hideShortDescription}
|
||||
/>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
);
|
||||
};
|
||||
@@ -181,29 +151,27 @@ export const AssetFolderTile = ({
|
||||
style?: any,
|
||||
disabled?: boolean,
|
||||
|}) => {
|
||||
const classesForGridListItem = useStylesForGridListItem({
|
||||
disabled,
|
||||
});
|
||||
return (
|
||||
<GridListTile
|
||||
classes={classesForGridListItem}
|
||||
tabIndex={0}
|
||||
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
|
||||
if (shouldValidate(event) && !disabled) {
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
style={style}
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
>
|
||||
<Column noMargin id={`asset-folder-${tag.replace(/\s/g, '-')}`}>
|
||||
<Line alignItems="center">
|
||||
<FolderIcon style={styles.folderIcon} />
|
||||
<Text noMargin style={styles.folderTitle} size="sub-title">
|
||||
{capitalize(tag)}
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
<GridListTile style={style}>
|
||||
<CardWidget
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
size="large"
|
||||
disabled={disabled}
|
||||
noBorder
|
||||
>
|
||||
<Column
|
||||
noMargin
|
||||
id={`asset-folder-${tag.replace(/\s/g, '-')}`}
|
||||
noOverflowParent
|
||||
>
|
||||
<Line alignItems="center">
|
||||
<FolderIcon style={styles.folderIcon} />
|
||||
<Text noMargin style={styles.folderTitle} size="sub-title">
|
||||
{capitalize(tag)}
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
);
|
||||
};
|
||||
@@ -220,46 +188,46 @@ export const PublicAssetPackTile = ({
|
||||
style?: any,
|
||||
disabled?: boolean,
|
||||
|}) => {
|
||||
const classesForGridListItem = useStylesForGridListItem({
|
||||
disabled,
|
||||
});
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
return (
|
||||
<GridListTile
|
||||
classes={classesForGridListItem}
|
||||
tabIndex={0}
|
||||
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
|
||||
if (shouldValidate(event) && !disabled) {
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
style={style}
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
>
|
||||
<div
|
||||
id={`asset-pack-${assetPack.tag.replace(/\s/g, '-')}`}
|
||||
style={styles.paper}
|
||||
<GridListTile style={style}>
|
||||
<CardWidget
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
size="large"
|
||||
disabled={disabled}
|
||||
noBorder
|
||||
>
|
||||
<CorsAwareImage
|
||||
key={assetPack.name}
|
||||
style={styles.previewImage}
|
||||
src={assetPack.thumbnailUrl}
|
||||
alt={`Preview image of asset pack ${assetPack.name}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<Column>
|
||||
<Line justifyContent="space-between" noMargin>
|
||||
<Text style={styles.packTitle} size="body2">
|
||||
{assetPack.name}
|
||||
</Text>
|
||||
<Text style={styles.packTitle} color="primary" size="body2">
|
||||
<Trans>{assetPack.assetsCount} Assets</Trans>
|
||||
{assetPack.userFriendlyPrice
|
||||
? ' - ' + assetPack.userFriendlyPrice
|
||||
: null}
|
||||
</Text>
|
||||
</Line>
|
||||
<Column
|
||||
noMargin
|
||||
expand
|
||||
id={`asset-pack-${assetPack.tag.replace(/\s/g, '-')}`}
|
||||
noOverflowParent
|
||||
>
|
||||
<CorsAwareImage
|
||||
key={assetPack.name}
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
background: gdevelopTheme.paper.backgroundColor.light,
|
||||
}}
|
||||
src={assetPack.thumbnailUrl}
|
||||
alt={`Preview image of asset pack ${assetPack.name}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<Column>
|
||||
<Line justifyContent="space-between" noMargin>
|
||||
<Text style={styles.packTitle} size="body2">
|
||||
{assetPack.name}
|
||||
</Text>
|
||||
<Text style={styles.packTitle} color="primary" size="body2">
|
||||
<Trans>{assetPack.assetsCount} Assets</Trans>
|
||||
{assetPack.userFriendlyPrice
|
||||
? ' - ' + assetPack.userFriendlyPrice
|
||||
: null}
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
</Column>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
);
|
||||
};
|
||||
@@ -278,61 +246,59 @@ export const PrivateAssetPackTile = ({
|
||||
owned: boolean,
|
||||
disabled?: boolean,
|
||||
|}) => {
|
||||
const classesForGridListItem = useStylesForGridListItem({
|
||||
disabled,
|
||||
});
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
return (
|
||||
<GridListTile
|
||||
classes={classesForGridListItem}
|
||||
tabIndex={0}
|
||||
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
|
||||
if (shouldValidate(event) && !disabled) {
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
style={style}
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.thumbnailContainer}>
|
||||
<CorsAwareImage
|
||||
key={assetPackListingData.name}
|
||||
style={styles.previewImage}
|
||||
src={assetPackListingData.thumbnailUrls[0]}
|
||||
alt={`Preview image of asset pack ${assetPackListingData.name}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
{assetPackListingData.redeemConditions && !owned && (
|
||||
<div style={styles.redeemableContainer}>
|
||||
<img
|
||||
src="res/small-diamond.svg"
|
||||
style={styles.redeemableDiamondIcon}
|
||||
alt="diamond"
|
||||
/>
|
||||
<Text color="inherit" noMargin>
|
||||
<Trans>Claim this pack</Trans>
|
||||
<GridListTile style={style}>
|
||||
<CardWidget
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
size="large"
|
||||
disabled={disabled}
|
||||
noBorder
|
||||
>
|
||||
<Column noMargin expand noOverflowParent>
|
||||
<div style={styles.thumbnailContainer}>
|
||||
<CorsAwareImage
|
||||
key={assetPackListingData.name}
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
background: gdevelopTheme.paper.backgroundColor.light,
|
||||
}}
|
||||
src={assetPackListingData.thumbnailUrls[0]}
|
||||
alt={`Preview image of asset pack ${assetPackListingData.name}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
{assetPackListingData.redeemConditions && !owned && (
|
||||
<div style={styles.redeemableContainer}>
|
||||
<img
|
||||
src="res/small-diamond.svg"
|
||||
style={styles.redeemableDiamondIcon}
|
||||
alt="diamond"
|
||||
/>
|
||||
<Text color="inherit" noMargin>
|
||||
<Trans>Claim this pack</Trans>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={styles.priceTagContainer}>
|
||||
<ProductPriceTag
|
||||
productListingData={assetPackListingData}
|
||||
withOverlay
|
||||
owned={owned}
|
||||
/>
|
||||
</div>
|
||||
<Column>
|
||||
<Line justifyContent="space-between" noMargin>
|
||||
<Text style={styles.packTitle} size="body2">
|
||||
{assetPackListingData.name}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={styles.priceTagContainer}>
|
||||
<ProductPriceTag
|
||||
productListingData={assetPackListingData}
|
||||
withOverlay
|
||||
owned={owned}
|
||||
/>
|
||||
</div>
|
||||
<Column>
|
||||
<Line justifyContent="space-between" noMargin>
|
||||
<Text style={styles.packTitle} size="body2">
|
||||
{assetPackListingData.name}
|
||||
</Text>
|
||||
<Text style={styles.packTitle} color="primary" size="body2">
|
||||
{assetPackListingData.description}
|
||||
</Text>
|
||||
</Line>
|
||||
<Text style={styles.packTitle} color="primary" size="body2">
|
||||
{assetPackListingData.description}
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
</Column>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
);
|
||||
};
|
||||
@@ -455,40 +421,34 @@ export const CategoryTile = ({
|
||||
style?: any,
|
||||
disabled?: boolean,
|
||||
|}) => {
|
||||
const classesForGridListItem = useStylesForGridListItem({
|
||||
disabled,
|
||||
});
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
return (
|
||||
<GridListTile
|
||||
classes={classesForGridListItem}
|
||||
tabIndex={0}
|
||||
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
|
||||
if (shouldValidate(event) && !disabled) {
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
style={style}
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
>
|
||||
<div id={id} style={styles.paper}>
|
||||
<CorsAwareImage
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
background: gdevelopTheme.palette.primary,
|
||||
}}
|
||||
src={imageSource}
|
||||
alt={imageAlt}
|
||||
// No lazy loading because categories are the first seen tiles in the shop.
|
||||
/>
|
||||
<Column>
|
||||
<Line justifyContent="center" noMargin>
|
||||
<Text style={styles.packTitle} size="sub-title">
|
||||
{title}
|
||||
</Text>
|
||||
</Line>
|
||||
<GridListTile style={style}>
|
||||
<CardWidget
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
size="large"
|
||||
disabled={disabled}
|
||||
noBorder
|
||||
>
|
||||
<Column noMargin alignItems="center" expand id={id} noOverflowParent>
|
||||
<CorsAwareImage
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
background: gdevelopTheme.palette.primary,
|
||||
}}
|
||||
src={imageSource}
|
||||
alt={imageAlt}
|
||||
// No lazy loading because categories are the first seen tiles in the shop.
|
||||
/>
|
||||
<Column>
|
||||
<Line justifyContent="center" noMargin>
|
||||
<Text style={styles.packTitle} size="sub-title">
|
||||
{title}
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
</Column>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
);
|
||||
};
|
||||
@@ -508,47 +468,45 @@ export const PrivateGameTemplateTile = ({
|
||||
disabled?: boolean,
|
||||
|}) => {
|
||||
const { isMobile } = useResponsiveWindowSize();
|
||||
const classesForGridListItem = useStylesForGridListItem({
|
||||
disabled,
|
||||
});
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
return (
|
||||
<GridListTile
|
||||
classes={classesForGridListItem}
|
||||
tabIndex={0}
|
||||
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
|
||||
if (shouldValidate(event) && !disabled) {
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
style={style}
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
>
|
||||
<div style={styles.paper}>
|
||||
<CorsAwareImage
|
||||
key={privateGameTemplateListingData.name}
|
||||
style={styles.previewImage}
|
||||
src={privateGameTemplateListingData.thumbnailUrls[0]}
|
||||
alt={`Preview image of game template ${
|
||||
privateGameTemplateListingData.name
|
||||
}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div style={styles.priceTagContainer}>
|
||||
<ProductPriceTag
|
||||
productListingData={privateGameTemplateListingData}
|
||||
withOverlay
|
||||
owned={owned}
|
||||
<GridListTile style={style}>
|
||||
<CardWidget
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
size="large"
|
||||
disabled={disabled}
|
||||
noBorder
|
||||
>
|
||||
<Column noMargin expand noOverflowParent>
|
||||
<CorsAwareImage
|
||||
key={privateGameTemplateListingData.name}
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
background: gdevelopTheme.paper.backgroundColor.light,
|
||||
}}
|
||||
src={privateGameTemplateListingData.thumbnailUrls[0]}
|
||||
alt={`Preview image of game template ${
|
||||
privateGameTemplateListingData.name
|
||||
}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<Column>
|
||||
{isMobile && <Spacer />}
|
||||
<Line justifyContent="flex-start" noMargin>
|
||||
<Text style={styles.packTitle} size="body2" noMargin={isMobile}>
|
||||
{privateGameTemplateListingData.name}
|
||||
</Text>
|
||||
</Line>
|
||||
<div style={styles.priceTagContainer}>
|
||||
<ProductPriceTag
|
||||
productListingData={privateGameTemplateListingData}
|
||||
withOverlay
|
||||
owned={owned}
|
||||
/>
|
||||
</div>
|
||||
<Column>
|
||||
{isMobile && <Spacer />}
|
||||
<Line justifyContent="flex-start" noMargin>
|
||||
<Text style={styles.packTitle} size="body2" noMargin={isMobile}>
|
||||
{privateGameTemplateListingData.name}
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
</Column>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
);
|
||||
};
|
||||
@@ -572,6 +530,7 @@ export const ExampleTile = ({
|
||||
disabled?: boolean,
|
||||
|}) => {
|
||||
const { isMobile } = useResponsiveWindowSize();
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
const thumbnailImgUrl = React.useMemo(
|
||||
() => {
|
||||
if (!exampleShortHeader) return '';
|
||||
@@ -587,66 +546,68 @@ export const ExampleTile = ({
|
||||
[exampleShortHeader, useQuickCustomizationThumbnail]
|
||||
);
|
||||
|
||||
const classesForGridListItem = useStylesForGridListItem({ disabled });
|
||||
return (
|
||||
<GridListTile
|
||||
classes={classesForGridListItem}
|
||||
tabIndex={0}
|
||||
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
|
||||
if (shouldValidate(event) && !disabled) {
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
style={style}
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
>
|
||||
<div style={styles.paper}>
|
||||
{exampleShortHeader ? (
|
||||
thumbnailImgUrl ? (
|
||||
<CorsAwareImage
|
||||
key={exampleShortHeader.name}
|
||||
style={styles.previewImage}
|
||||
src={thumbnailImgUrl}
|
||||
alt={`Preview image of example ${exampleShortHeader.name}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<GridListTile style={style}>
|
||||
<CardWidget
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
size="large"
|
||||
disabled={disabled}
|
||||
noBorder
|
||||
>
|
||||
<Column noMargin expand noOverflowParent>
|
||||
{exampleShortHeader ? (
|
||||
thumbnailImgUrl ? (
|
||||
<CorsAwareImage
|
||||
key={exampleShortHeader.name}
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
background: gdevelopTheme.paper.backgroundColor.light,
|
||||
}}
|
||||
src={thumbnailImgUrl}
|
||||
alt={`Preview image of example ${exampleShortHeader.name}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<EmptyMessage
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{exampleShortHeader.name}
|
||||
</EmptyMessage>
|
||||
)
|
||||
) : (
|
||||
<EmptyMessage
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
display: 'flex',
|
||||
}}
|
||||
<Skeleton
|
||||
variant="rect"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={styles.dataLoadingSkeleton}
|
||||
/>
|
||||
)}
|
||||
<Column>
|
||||
{isMobile && <Spacer />}
|
||||
<Line
|
||||
justifyContent={centerTitle ? 'center' : 'flex-start'}
|
||||
noMargin
|
||||
>
|
||||
{exampleShortHeader.name}
|
||||
</EmptyMessage>
|
||||
)
|
||||
) : (
|
||||
<Skeleton
|
||||
variant="rect"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={styles.dataLoadingSkeleton}
|
||||
/>
|
||||
)}
|
||||
<Column>
|
||||
{isMobile && <Spacer />}
|
||||
<Line justifyContent={centerTitle ? 'center' : 'flex-start'} noMargin>
|
||||
<Text
|
||||
style={styles.packTitle}
|
||||
size="body2"
|
||||
hidden={!exampleShortHeader}
|
||||
noMargin={isMobile}
|
||||
>
|
||||
{customTitle
|
||||
? customTitle
|
||||
: exampleShortHeader
|
||||
? exampleShortHeader.name
|
||||
: // Use some placeholder text to avoid layout shift while loading content.
|
||||
'Abcdef123'}
|
||||
</Text>
|
||||
</Line>
|
||||
<Text
|
||||
style={styles.packTitle}
|
||||
size="body2"
|
||||
hidden={!exampleShortHeader}
|
||||
noMargin={isMobile}
|
||||
>
|
||||
{customTitle
|
||||
? customTitle
|
||||
: exampleShortHeader
|
||||
? exampleShortHeader.name
|
||||
: // Use some placeholder text to avoid layout shift while loading content.
|
||||
'Abcdef123'}
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
</Column>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
);
|
||||
};
|
||||
|
@@ -3,13 +3,15 @@
|
||||
import * as React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import type { CourseChapter } from '../Utils/GDevelopServices/Asset';
|
||||
import type { CourseChapter, Course } from '../Utils/GDevelopServices/Asset';
|
||||
import Text from '../UI/Text';
|
||||
import { LineStackLayout } from '../UI/Layout';
|
||||
import CheckCircle from '../UI/CustomSvgIcons/CheckCircle';
|
||||
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
|
||||
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import type { CourseChapterCompletion } from '../MainFrame/EditorContainers/HomePage/UseCourses';
|
||||
import Chip from '../UI/Chip';
|
||||
import { freeChipStyle } from './CoursePreviewBanner';
|
||||
|
||||
const styles = {
|
||||
titleContainer: {
|
||||
@@ -24,13 +26,14 @@ const styles = {
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
course: Course,
|
||||
chapterIndex: number,
|
||||
courseChapter: CourseChapter,
|
||||
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
|
||||
|};
|
||||
|
||||
const CourseChapterTitle = React.forwardRef<Props, HTMLDivElement>(
|
||||
({ chapterIndex, courseChapter, getChapterCompletion }, ref) => {
|
||||
({ course, chapterIndex, courseChapter, getChapterCompletion }, ref) => {
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
const { isMobile, isLandscape } = useResponsiveWindowSize();
|
||||
const isMobilePortrait = isMobile && !isLandscape;
|
||||
@@ -64,25 +67,30 @@ const CourseChapterTitle = React.forwardRef<Props, HTMLDivElement>(
|
||||
</div>
|
||||
)}
|
||||
</LineStackLayout>
|
||||
{isFinished ? (
|
||||
<div
|
||||
style={{
|
||||
...styles.statusContainer,
|
||||
color: gdevelopTheme.statusIndicator.success,
|
||||
}}
|
||||
>
|
||||
{isMobilePortrait && <CheckCircle />}
|
||||
<Text color="inherit" noMargin>
|
||||
<Trans>Finished</Trans>
|
||||
<LineStackLayout noMargin alignItems="center">
|
||||
{course.isLocked && courseChapter.isFree && (
|
||||
<Chip style={freeChipStyle} label={<Trans>Free!</Trans>} />
|
||||
)}
|
||||
{isFinished ? (
|
||||
<div
|
||||
style={{
|
||||
...styles.statusContainer,
|
||||
color: gdevelopTheme.statusIndicator.success,
|
||||
}}
|
||||
>
|
||||
{isMobilePortrait && <CheckCircle />}
|
||||
<Text color="inherit" noMargin>
|
||||
<Trans>Finished</Trans>
|
||||
</Text>
|
||||
</div>
|
||||
) : completion ? (
|
||||
<Text color="secondary" noMargin>
|
||||
<Trans>
|
||||
{completion.completedTasks} of {completion.tasks} completed
|
||||
</Trans>
|
||||
</Text>
|
||||
</div>
|
||||
) : completion ? (
|
||||
<Text color="secondary" noMargin>
|
||||
<Trans>
|
||||
{completion.completedTasks} of {completion.tasks} completed
|
||||
</Trans>
|
||||
</Text>
|
||||
) : null}
|
||||
) : null}
|
||||
</LineStackLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -34,6 +34,12 @@ import EmptyBadge from '../UI/CustomSvgIcons/EmptyBadge';
|
||||
import Skeleton from '@material-ui/lab/Skeleton';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
|
||||
export const freeChipStyle = {
|
||||
height: 20,
|
||||
backgroundColor: '#8BE7C4',
|
||||
color: '#1D1D26',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
container: { padding: 16, display: 'flex', borderRadius: 8 },
|
||||
mobileContainer: { padding: 8, display: 'flex', borderRadius: 8 },
|
||||
@@ -48,16 +54,6 @@ const styles = {
|
||||
},
|
||||
progress: { borderRadius: 4, height: 5 },
|
||||
chip: { height: 24 },
|
||||
freeChip: {
|
||||
height: 20,
|
||||
borderRadius: 32,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
backgroundColor: '#8BE7C4',
|
||||
color: '#1D1D26',
|
||||
},
|
||||
gdevelopAvatar: { width: 20, height: 20 },
|
||||
thumbnail: { borderRadius: 4, aspectRatio: '16 / 9', maxWidth: '100%' },
|
||||
statusContainer: {
|
||||
@@ -79,12 +75,14 @@ const styles = {
|
||||
};
|
||||
|
||||
const ChapterTile = ({
|
||||
course,
|
||||
chapter,
|
||||
chapterIndex,
|
||||
isComplete,
|
||||
gdevelopTheme,
|
||||
}: {|
|
||||
chapter: ?CourseChapter,
|
||||
course: Course,
|
||||
chapter: CourseChapter,
|
||||
isComplete: boolean,
|
||||
chapterIndex: number,
|
||||
gdevelopTheme: GDevelopTheme,
|
||||
@@ -92,14 +90,14 @@ const ChapterTile = ({
|
||||
return (
|
||||
<Column expand>
|
||||
<Spacer />
|
||||
{chapter && chapter.isLocked ? (
|
||||
{chapter.isLocked ? (
|
||||
<Paper background="light" style={{ padding: 4 }}>
|
||||
<LineStackLayout noMargin alignItems="center" justifyContent="center">
|
||||
<div style={styles.statusContainer}>
|
||||
<Lock fontSize="inherit" color="secondary" />
|
||||
</div>
|
||||
<Text color="secondary" noMargin>
|
||||
<Trans>Unlock with {chapter.priceInCredits} credits</Trans>
|
||||
<Trans>Unlock with the full course</Trans>
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
</Paper>
|
||||
@@ -112,13 +110,9 @@ const ChapterTile = ({
|
||||
>
|
||||
<CheckCircle fontSize="inherit" />
|
||||
</div>
|
||||
) : chapter && chapter.isFree ? (
|
||||
) : course.isLocked && chapter.isFree ? (
|
||||
<Line noMargin>
|
||||
<div style={styles.freeChip}>
|
||||
<Text noMargin color="inherit" size="body-small">
|
||||
<Trans>Free</Trans>
|
||||
</Text>
|
||||
</div>
|
||||
<Chip style={freeChipStyle} label={<Trans>Free!</Trans>} />
|
||||
</Line>
|
||||
) : (
|
||||
<div style={styles.statusIconOnly}>
|
||||
@@ -136,21 +130,13 @@ const ChapterTile = ({
|
||||
<Trans>Chapter</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
{chapter ? (
|
||||
<Text
|
||||
size="sub-title"
|
||||
noMargin
|
||||
color={chapter.isLocked ? 'secondary' : 'primary'}
|
||||
>
|
||||
{chapter.title}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
<i>
|
||||
<Trans>Coming soon</Trans>
|
||||
</i>
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
size="sub-title"
|
||||
noMargin
|
||||
color={chapter.isLocked ? 'secondary' : 'primary'}
|
||||
>
|
||||
{chapter.title}
|
||||
</Text>
|
||||
<LargeSpacer />
|
||||
</Column>
|
||||
);
|
||||
@@ -258,6 +244,7 @@ const CoursePreviewBanner = ({
|
||||
))}
|
||||
{index > 0 && <Spacer />}
|
||||
<ChapterTile
|
||||
course={course}
|
||||
chapter={chapter}
|
||||
chapterIndex={chapterIndex}
|
||||
gdevelopTheme={gdevelopTheme}
|
||||
|
@@ -5,28 +5,47 @@ import * as React from 'react';
|
||||
import {
|
||||
listListedCourseChapters,
|
||||
type CourseChapterListingData,
|
||||
listListedCourses,
|
||||
type CourseListingData,
|
||||
} from '../Utils/GDevelopServices/Shop';
|
||||
import { COURSE_CHAPTERS_FETCH_TIMEOUT } from '../Utils/GlobalFetchTimeouts';
|
||||
|
||||
type CourseChapterStoreState = {|
|
||||
listedCourses: CourseListingData[] | null,
|
||||
listedCourseChapters: CourseChapterListingData[] | null,
|
||||
|};
|
||||
|
||||
const initialCourseChapterStoreState: CourseChapterStoreState = {
|
||||
const initialCourseStoreState: CourseChapterStoreState = {
|
||||
listedCourses: null,
|
||||
listedCourseChapters: null,
|
||||
};
|
||||
|
||||
const CourseChapterStoreContext = React.createContext<CourseChapterStoreState>(
|
||||
initialCourseChapterStoreState
|
||||
const CourseStoreContext = React.createContext<CourseChapterStoreState>(
|
||||
initialCourseStoreState
|
||||
);
|
||||
|
||||
type Props = {| children: React.Node |};
|
||||
|
||||
export const CourseChapterStoreStateProvider = (props: Props) => {
|
||||
export const CourseStoreStateProvider = (props: Props) => {
|
||||
const [listedCourses, setListedCourses] = React.useState<
|
||||
CourseListingData[] | null
|
||||
>(null);
|
||||
const [listedCourseChapters, setListedCourseChapters] = React.useState<
|
||||
CourseChapterListingData[] | null
|
||||
>(null);
|
||||
|
||||
const loadCourses = React.useCallback(async () => {
|
||||
try {
|
||||
const fetchedListedCourses = await listListedCourses();
|
||||
setListedCourses(fetchedListedCourses);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'An error occurred when fetching courses in Shop API:',
|
||||
error
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadCourseChapters = React.useCallback(async () => {
|
||||
try {
|
||||
const fetchedListedCourseChapters = await listListedCourseChapters();
|
||||
@@ -41,24 +60,27 @@ export const CourseChapterStoreStateProvider = (props: Props) => {
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
console.info('Pre-fetching courses...');
|
||||
loadCourses();
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.info('Pre-fetching course chapters...');
|
||||
loadCourseChapters();
|
||||
}, COURSE_CHAPTERS_FETCH_TIMEOUT);
|
||||
return () => clearTimeout(timeoutId);
|
||||
},
|
||||
[loadCourseChapters]
|
||||
[loadCourseChapters, loadCourses]
|
||||
);
|
||||
|
||||
return (
|
||||
<CourseChapterStoreContext.Provider
|
||||
<CourseStoreContext.Provider
|
||||
value={{
|
||||
listedCourses,
|
||||
listedCourseChapters,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</CourseChapterStoreContext.Provider>
|
||||
</CourseStoreContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseChapterStoreContext;
|
||||
export default CourseStoreContext;
|
@@ -4,24 +4,17 @@ import * as React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import type {
|
||||
CourseChapter,
|
||||
Course,
|
||||
LockedVideoBasedCourseChapter,
|
||||
LockedTextBasedCourseChapter,
|
||||
} from '../Utils/GDevelopServices/Asset';
|
||||
import Text from '../UI/Text';
|
||||
import { ColumnStackLayout, ResponsiveLineStackLayout } from '../UI/Layout';
|
||||
import { ColumnStackLayout } from '../UI/Layout';
|
||||
import Paper from '../UI/Paper';
|
||||
import RaisedButton from '../UI/RaisedButton';
|
||||
import FlatButton from '../UI/FlatButton';
|
||||
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { SubscriptionSuggestionContext } from '../Profile/Subscription/SubscriptionSuggestionContext';
|
||||
import GoldCompact from '../Profile/Subscription/Icons/GoldCompact';
|
||||
import Coin from '../Credits/Icons/Coin';
|
||||
import Lock from '../UI/CustomSvgIcons/Lock';
|
||||
import Window from '../Utils/Window';
|
||||
import PasswordPromptDialog from '../AssetStore/PasswordPromptDialog';
|
||||
import { getYoutubeVideoIdFromUrl } from '../Utils/Youtube';
|
||||
import AlertMessage from '../UI/AlertMessage';
|
||||
|
||||
const styles = {
|
||||
videoAndMaterialsContainer: {
|
||||
@@ -76,58 +69,17 @@ const LockedOverlay = () => (
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
course: Course,
|
||||
courseChapter: LockedVideoBasedCourseChapter | LockedTextBasedCourseChapter,
|
||||
onBuyWithCredits: (CourseChapter, string) => Promise<void>,
|
||||
onClickUnlock: () => void,
|
||||
|};
|
||||
|
||||
const LockedCourseChapterPreview = React.forwardRef<Props, HTMLDivElement>(
|
||||
({ courseChapter, onBuyWithCredits }, ref) => {
|
||||
const { openSubscriptionDialog } = React.useContext(
|
||||
SubscriptionSuggestionContext
|
||||
);
|
||||
const [error, setError] = React.useState<React.Node>(null);
|
||||
const [
|
||||
displayPasswordPrompt,
|
||||
setDisplayPasswordPrompt,
|
||||
] = React.useState<boolean>(false);
|
||||
({ course, courseChapter, onClickUnlock }, ref) => {
|
||||
const { windowSize } = useResponsiveWindowSize();
|
||||
const [password, setPassword] = React.useState<string>('');
|
||||
const youtubeVideoId = courseChapter.videoUrl
|
||||
? getYoutubeVideoIdFromUrl(courseChapter.videoUrl)
|
||||
: null;
|
||||
const [isPurchasing, setIsPurchasing] = React.useState<boolean>(false);
|
||||
|
||||
const onClickBuyWithCredits = React.useCallback(
|
||||
async () => {
|
||||
if (!courseChapter.isLocked) return;
|
||||
setError(null);
|
||||
setDisplayPasswordPrompt(false);
|
||||
setIsPurchasing(true);
|
||||
try {
|
||||
await onBuyWithCredits(courseChapter, password);
|
||||
} catch (error) {
|
||||
console.error('An error occurred while buying this chapter', error);
|
||||
setError(
|
||||
<Trans>
|
||||
An error occurred while buying this chapter. Please try again
|
||||
later.
|
||||
</Trans>
|
||||
);
|
||||
} finally {
|
||||
setIsPurchasing(false);
|
||||
}
|
||||
},
|
||||
[courseChapter, onBuyWithCredits, password]
|
||||
);
|
||||
|
||||
const onWillBuyWithCredits = React.useCallback(
|
||||
async () => {
|
||||
// Password is required in dev environment only so that one cannot freely claim asset packs.
|
||||
if (Window.isDev()) setDisplayPasswordPrompt(true);
|
||||
else onClickBuyWithCredits();
|
||||
},
|
||||
[onClickBuyWithCredits]
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={styles.videoAndMaterialsContainer}>
|
||||
@@ -152,57 +104,15 @@ const LockedCourseChapterPreview = React.forwardRef<Props, HTMLDivElement>(
|
||||
<Text noMargin size="sub-title">
|
||||
<Trans>Unlock this lesson to finish the course</Trans>
|
||||
</Text>
|
||||
<Text noMargin>
|
||||
<Trans>
|
||||
Use your GDevelop credits to purchase lessons in this course;
|
||||
or get a subscription to get them for free.
|
||||
</Trans>
|
||||
</Text>
|
||||
<ResponsiveLineStackLayout
|
||||
noMargin
|
||||
noColumnMargin
|
||||
forceMobileLayout={windowSize === 'medium'}
|
||||
>
|
||||
<RaisedButton
|
||||
primary
|
||||
fullWidth
|
||||
icon={<GoldCompact fontSize="small" />}
|
||||
disabled={isPurchasing}
|
||||
label={<Trans>Get a subscription</Trans>}
|
||||
onClick={() =>
|
||||
openSubscriptionDialog({
|
||||
analyticsMetadata: {
|
||||
reason: 'Unlock course chapter',
|
||||
recommendedPlanId: 'gdevelop_silver',
|
||||
placementId: 'unlock-course-chapter',
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
{courseChapter.priceInCredits && (
|
||||
<FlatButton
|
||||
fullWidth
|
||||
leftIcon={<Coin fontSize="small" />}
|
||||
disabled={isPurchasing}
|
||||
label={
|
||||
<Trans>Pay {courseChapter.priceInCredits} credits</Trans>
|
||||
}
|
||||
onClick={onWillBuyWithCredits}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveLineStackLayout>
|
||||
{error && <AlertMessage kind="error">{error}</AlertMessage>}
|
||||
<RaisedButton
|
||||
primary
|
||||
fullWidth
|
||||
label={<Trans>Unlock the whole course</Trans>}
|
||||
onClick={onClickUnlock}
|
||||
/>
|
||||
</ColumnStackLayout>
|
||||
</Paper>
|
||||
</div>
|
||||
{displayPasswordPrompt && (
|
||||
<PasswordPromptDialog
|
||||
onApply={onClickBuyWithCredits}
|
||||
onClose={() => setDisplayPasswordPrompt(false)}
|
||||
passwordValue={password}
|
||||
setPasswordValue={setPassword}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ import { Trans } from '@lingui/macro';
|
||||
|
||||
import type {
|
||||
TextBasedCourseChapter,
|
||||
CourseChapter,
|
||||
Course,
|
||||
} from '../Utils/GDevelopServices/Asset';
|
||||
import Text from '../UI/Text';
|
||||
import { ColumnStackLayout } from '../UI/Layout';
|
||||
@@ -41,6 +41,7 @@ const styles = {
|
||||
|
||||
type Props = {|
|
||||
chapterIndex: number,
|
||||
course: Course,
|
||||
courseChapter: TextBasedCourseChapter,
|
||||
onOpenTemplate: (templateId?: string) => void,
|
||||
onCompleteTask: (
|
||||
@@ -50,7 +51,7 @@ type Props = {|
|
||||
) => void,
|
||||
isTaskCompleted: (chapterId: string, taskIndex: number) => boolean,
|
||||
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
|
||||
onBuyWithCredits: (CourseChapter, string) => Promise<void>,
|
||||
onClickUnlock: () => void,
|
||||
|};
|
||||
|
||||
const TextBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
|
||||
@@ -58,17 +59,19 @@ const TextBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
|
||||
{
|
||||
chapterIndex,
|
||||
courseChapter,
|
||||
course,
|
||||
onOpenTemplate,
|
||||
onCompleteTask,
|
||||
isTaskCompleted,
|
||||
getChapterCompletion,
|
||||
onBuyWithCredits,
|
||||
onClickUnlock,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<ColumnStackLayout expand noMargin>
|
||||
<CourseChapterTitle
|
||||
course={course}
|
||||
chapterIndex={chapterIndex}
|
||||
courseChapter={courseChapter}
|
||||
getChapterCompletion={getChapterCompletion}
|
||||
@@ -76,8 +79,9 @@ const TextBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
|
||||
/>
|
||||
{courseChapter.isLocked ? (
|
||||
<LockedCourseChapterPreview
|
||||
onBuyWithCredits={onBuyWithCredits}
|
||||
course={course}
|
||||
courseChapter={courseChapter}
|
||||
onClickUnlock={onClickUnlock}
|
||||
/>
|
||||
) : courseChapter.templates.length > 0 ? (
|
||||
<div style={styles.videoAndMaterialsContainer}>
|
||||
@@ -88,7 +92,7 @@ const TextBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
|
||||
<Paper background="medium" style={styles.sideBar}>
|
||||
<ColumnStackLayout noMargin>
|
||||
{courseChapter.templates.map(template => (
|
||||
<Line noMargin alignItems="center">
|
||||
<Line noMargin alignItems="center" key={template.id}>
|
||||
<Text noMargin>{rankLabel[chapterIndex + 1]}</Text>
|
||||
|
||||
<Text noMargin>
|
||||
|
@@ -5,7 +5,7 @@ import { Trans } from '@lingui/macro';
|
||||
|
||||
import type {
|
||||
VideoBasedCourseChapter,
|
||||
CourseChapter,
|
||||
Course,
|
||||
} from '../Utils/GDevelopServices/Asset';
|
||||
import Text from '../UI/Text';
|
||||
import { ColumnStackLayout } from '../UI/Layout';
|
||||
@@ -60,6 +60,7 @@ const styles = {
|
||||
|
||||
type Props = {|
|
||||
chapterIndex: number,
|
||||
course: Course,
|
||||
courseChapter: VideoBasedCourseChapter,
|
||||
onOpenTemplate: () => void,
|
||||
onCompleteTask: (
|
||||
@@ -69,19 +70,20 @@ type Props = {|
|
||||
) => void,
|
||||
isTaskCompleted: (chapterId: string, taskIndex: number) => boolean,
|
||||
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
|
||||
onBuyWithCredits: (CourseChapter, string) => Promise<void>,
|
||||
onClickUnlock: () => void,
|
||||
|};
|
||||
|
||||
const VideoBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
|
||||
(
|
||||
{
|
||||
chapterIndex,
|
||||
course,
|
||||
courseChapter,
|
||||
onOpenTemplate,
|
||||
onCompleteTask,
|
||||
isTaskCompleted,
|
||||
getChapterCompletion,
|
||||
onBuyWithCredits,
|
||||
onClickUnlock,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -97,6 +99,7 @@ const VideoBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
|
||||
return (
|
||||
<ColumnStackLayout expand noMargin>
|
||||
<CourseChapterTitle
|
||||
course={course}
|
||||
chapterIndex={chapterIndex}
|
||||
courseChapter={courseChapter}
|
||||
getChapterCompletion={getChapterCompletion}
|
||||
@@ -104,8 +107,9 @@ const VideoBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
|
||||
/>
|
||||
{courseChapter.isLocked ? (
|
||||
<LockedCourseChapterPreview
|
||||
onBuyWithCredits={onBuyWithCredits}
|
||||
course={course}
|
||||
courseChapter={courseChapter}
|
||||
onClickUnlock={onClickUnlock}
|
||||
/>
|
||||
) : (
|
||||
<div style={styles.videoAndMaterialsContainer}>
|
||||
|
@@ -93,8 +93,6 @@ const parseVariablePath = (
|
||||
segments.push({ type: 'property', value: currentSegment.trim() });
|
||||
}
|
||||
|
||||
console.log('segments', segments);
|
||||
|
||||
return segments;
|
||||
};
|
||||
|
||||
|
@@ -373,9 +373,9 @@ const createObject: EditorFunction = {
|
||||
scene: layout,
|
||||
objectName: object_name,
|
||||
objectType: object_type,
|
||||
searchTerms: search_terms,
|
||||
description,
|
||||
twoDimensionalViewKind: two_dimensional_view_kind,
|
||||
searchTerms: search_terms || '',
|
||||
description: description || '',
|
||||
twoDimensionalViewKind: two_dimensional_view_kind || '',
|
||||
});
|
||||
|
||||
if (status === 'error') {
|
||||
@@ -1340,8 +1340,8 @@ const put2dInstances: EditorFunction = {
|
||||
args,
|
||||
'brush_position'
|
||||
);
|
||||
const brush_size = SafeExtractor.extractStringProperty(args, 'brush_size');
|
||||
const brush_end_position = SafeExtractor.extractNumberProperty(
|
||||
const brush_size = SafeExtractor.extractNumberProperty(args, 'brush_size');
|
||||
const brush_end_position = SafeExtractor.extractStringProperty(
|
||||
args,
|
||||
'brush_end_position'
|
||||
);
|
||||
@@ -1392,7 +1392,7 @@ const put2dInstances: EditorFunction = {
|
||||
const brushPosition: Array<number> | null = brush_position
|
||||
? brush_position.split(',').map(Number)
|
||||
: null;
|
||||
const brushSize = brush_size ? Number(brush_size) : 0;
|
||||
const brushSize = brush_size || 0;
|
||||
|
||||
// Iterate on existing instances and remove them, and/or those inside the brush radius.
|
||||
const instancesToDelete = new Set();
|
||||
@@ -1447,7 +1447,7 @@ const put2dInstances: EditorFunction = {
|
||||
project.getGameResolutionWidth() / 2,
|
||||
project.getGameResolutionHeight() / 2,
|
||||
];
|
||||
const brushSize = brush_size ? Number(brush_size) : 0;
|
||||
const brushSize = brush_size || 0;
|
||||
const brushEndPosition = brush_end_position
|
||||
? brush_end_position.split(',').map(Number)
|
||||
: null;
|
||||
@@ -1520,8 +1520,8 @@ const put2dInstances: EditorFunction = {
|
||||
(brushEndPosition[1] - brushPosition[1]) / gridColumnCount;
|
||||
|
||||
modifiedAndCreatedInstances.forEach((instance, i) => {
|
||||
const row = Math.floor(i / columnCount);
|
||||
const column = i % columnCount;
|
||||
const row = Math.floor(i / gridColumnCount);
|
||||
const column = i % gridColumnCount;
|
||||
|
||||
instance.setX(brushPosition[0] + column * gridColumnSize);
|
||||
instance.setY(brushPosition[1] + row * gridRowSize);
|
||||
@@ -2472,6 +2472,12 @@ const addOrEditVariable: EditorFunction = {
|
||||
}
|
||||
variablesContainer = project.getLayout(scene_name).getVariables();
|
||||
} else if (variable_scope === 'object') {
|
||||
if (!object_name) {
|
||||
return makeGenericFailure(
|
||||
`Missing "object_name" argument, required to edit an object variable.`
|
||||
);
|
||||
}
|
||||
|
||||
let objectsContainer;
|
||||
if (scene_name) {
|
||||
if (!project.hasLayoutNamed(scene_name)) {
|
||||
|
@@ -75,7 +75,7 @@ export const enumerateEffectsMetadata = (
|
||||
getValue: (effect: gdEffect) =>
|
||||
effect.hasBooleanParameter(parameterName)
|
||||
? effect.getBooleanParameter(parameterName)
|
||||
: defaultValue === '1',
|
||||
: defaultValue === 'true',
|
||||
setValue: (effect: gdEffect, newValue: boolean) =>
|
||||
effect.setBooleanParameter(parameterName, newValue),
|
||||
getLabel,
|
||||
|
@@ -211,6 +211,15 @@ const Effect = React.forwardRef(
|
||||
return advancedPropertiesSchema.some((field: Field) => {
|
||||
const name = field.valueType ? field.name : null;
|
||||
if (!name) return false;
|
||||
if (
|
||||
field.valueType === 'number'
|
||||
? !effect.hasDoubleParameter(name)
|
||||
: field.valueType === 'boolean'
|
||||
? !effect.hasBooleanParameter(name)
|
||||
: !effect.hasStringParameter(name)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const current =
|
||||
field.valueType === 'number'
|
||||
|
@@ -140,5 +140,5 @@ export const onlineCordovaIosExporter = {
|
||||
key: 'onlinecordovaiosexport',
|
||||
tabName: <Trans>iOS</Trans>,
|
||||
name: <Trans>iOS</Trans>,
|
||||
helpPage: '/publishing/android_and_ios',
|
||||
helpPage: '/publishing/ios',
|
||||
};
|
||||
|
@@ -1,24 +1,24 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import Text from '../../../../UI/Text';
|
||||
import Text from '../../UI/Text';
|
||||
import {
|
||||
ColumnStackLayout,
|
||||
LineStackLayout,
|
||||
ResponsiveLineStackLayout,
|
||||
} from '../../../../UI/Layout';
|
||||
} from '../../UI/Layout';
|
||||
import {
|
||||
type Badge,
|
||||
type Achievement,
|
||||
} from '../../../../Utils/GDevelopServices/Badge';
|
||||
import { Column } from '../../../../UI/Grid';
|
||||
import Window from '../../../../Utils/Window';
|
||||
import Coin from '../../../../Credits/Icons/Coin';
|
||||
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
|
||||
} from '../../Utils/GDevelopServices/Badge';
|
||||
import { Column } from '../../UI/Grid';
|
||||
import Window from '../../Utils/Window';
|
||||
import Coin from '../../Credits/Icons/Coin';
|
||||
import { selectMessageByLocale } from '../../Utils/i18n/MessageByLocale';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import TextButton from '../../../../UI/TextButton';
|
||||
import RouterContext from '../../../RouterContext';
|
||||
import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import TextButton from '../../UI/TextButton';
|
||||
import RouterContext from '../../MainFrame/RouterContext';
|
||||
|
||||
type CreditItemType = 'badge' | 'feedback';
|
||||
type BadgeInfo = {|
|
@@ -7,7 +7,7 @@ import DashboardWidget, {
|
||||
import { ColumnStackLayout } from '../../UI/Layout';
|
||||
import Coin from '../../Credits/Icons/Coin';
|
||||
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
|
||||
import { EarnCredits } from '../../MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits';
|
||||
import { EarnCredits } from './EarnCredits';
|
||||
import TextButton from '../../UI/TextButton';
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
|
@@ -26,6 +26,9 @@ export type EditorContainerExtraProps = {|
|
||||
|
||||
// Homepage
|
||||
storageProviders?: Array<StorageProvider>,
|
||||
|
||||
// Ask AI
|
||||
mode?: 'chat' | 'agent',
|
||||
|};
|
||||
|
||||
export type SceneEventsOutsideEditorChanges = {|
|
||||
@@ -88,6 +91,7 @@ export type RenderEditorContainerProps = {|
|
||||
variantName: string
|
||||
) => void,
|
||||
openObjectEvents: (extensionName: string, objectName: string) => void,
|
||||
onOpenAskAi: (mode: 'chat' | 'agent') => void,
|
||||
|
||||
// Events function management:
|
||||
onLoadEventsFunctionsExtensions: () => Promise<void>,
|
||||
|
@@ -165,6 +165,10 @@ export class CustomObjectEditorContainer extends React.Component<RenderEditorCon
|
||||
// No thing to be done.
|
||||
}
|
||||
|
||||
onSceneObjectsDeleted(scene: gdLayout) {
|
||||
// No thing to be done.
|
||||
}
|
||||
|
||||
onSceneEventsModifiedOutsideEditor(changes: SceneEventsOutsideEditorChanges) {
|
||||
// No thing to be done.
|
||||
}
|
||||
|
@@ -1,220 +0,0 @@
|
||||
// @flow
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import * as React from 'react';
|
||||
import Dialog, { DialogPrimaryButton } from '../../../UI/Dialog';
|
||||
import Text from '../../../UI/Text';
|
||||
import { ColumnStackLayout, LineStackLayout } from '../../../UI/Layout';
|
||||
import FlatButton from '../../../UI/FlatButton';
|
||||
import TextField from '../../../UI/TextField';
|
||||
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
|
||||
import useAlertDialog from '../../../UI/Alert/useAlertDialog';
|
||||
import Paper from '../../../UI/Paper';
|
||||
import { Column, Line } from '../../../UI/Grid';
|
||||
import CreateProfile from '../../../Profile/CreateProfile';
|
||||
import LeftLoader from '../../../UI/LeftLoader';
|
||||
import { submitQuestion } from '../../../Utils/GDevelopServices/Question';
|
||||
import AlertMessage from '../../../UI/AlertMessage';
|
||||
import Window from '../../../Utils/Window';
|
||||
import Link from '../../../UI/Link';
|
||||
import SquaredInfo from '../../../UI/CustomSvgIcons/SquaredInfo';
|
||||
|
||||
type Props = {|
|
||||
onClose: () => void,
|
||||
|};
|
||||
|
||||
const styles = {
|
||||
infoIcon: { width: 24, height: 24 },
|
||||
};
|
||||
|
||||
const maxLength = 500;
|
||||
const minLength = 20;
|
||||
|
||||
const AnyQuestionDialog = ({ onClose }: Props) => {
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [questionText, setQuestionText] = React.useState('');
|
||||
const [error, setError] = React.useState<?Error>(null);
|
||||
const [wasQuestionSubmitted, setWasQuestionSubmitted] = React.useState(false);
|
||||
const { showAlert } = useAlertDialog();
|
||||
const authenticatedUser = React.useContext(AuthenticatedUserContext);
|
||||
const { limits } = authenticatedUser;
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async () => {
|
||||
if (!questionText || wasQuestionSubmitted) return;
|
||||
|
||||
const { profile, getAuthorizationHeader } = authenticatedUser;
|
||||
if (!profile) return;
|
||||
|
||||
if (questionText.length < minLength) {
|
||||
showAlert({
|
||||
title: t`Your question is too short`,
|
||||
message: t`Your question must be at least ${minLength} characters long.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
limits &&
|
||||
limits.quotas['ask-question'] &&
|
||||
limits.quotas['ask-question'].limitReached
|
||||
) {
|
||||
showAlert({
|
||||
title: t`You've reached your limit`,
|
||||
message: t`You've reached your limit of questions. Wait a bit and try again tomorrow, or get a subscription to unlock more questions!`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
await submitQuestion(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
questionText,
|
||||
});
|
||||
setWasQuestionSubmitted(true);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[questionText, wasQuestionSubmitted, authenticatedUser, showAlert, limits]
|
||||
);
|
||||
|
||||
const actions = [
|
||||
<LeftLoader isLoading={isLoading}>
|
||||
<FlatButton
|
||||
key="cancel"
|
||||
label={<Trans>Close</Trans>}
|
||||
primary={false}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</LeftLoader>,
|
||||
<DialogPrimaryButton
|
||||
key="send"
|
||||
label={<Trans>Send the question</Trans>}
|
||||
color="success"
|
||||
onClick={onSubmit}
|
||||
disabled={isLoading || wasQuestionSubmitted}
|
||||
/>,
|
||||
];
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<Dialog
|
||||
title={<Trans>Ask any question</Trans>}
|
||||
open
|
||||
actions={actions}
|
||||
onRequestClose={onClose}
|
||||
onApply={onSubmit}
|
||||
maxWidth="sm"
|
||||
>
|
||||
<ColumnStackLayout noMargin>
|
||||
<Text size="body">
|
||||
<Trans>
|
||||
Write your question as precisely as possible. Tell where you're
|
||||
blocked or what you want to do.
|
||||
</Trans>
|
||||
</Text>
|
||||
{!authenticatedUser.authenticated && (
|
||||
<Paper background="dark" variant="outlined">
|
||||
<Line justifyContent="center">
|
||||
<CreateProfile
|
||||
onOpenLoginDialog={authenticatedUser.onOpenLoginDialog}
|
||||
onOpenCreateAccountDialog={
|
||||
authenticatedUser.onOpenCreateAccountDialog
|
||||
}
|
||||
message={
|
||||
<Trans>
|
||||
Create an account first to send your question.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</Line>
|
||||
</Paper>
|
||||
)}
|
||||
<TextField
|
||||
value={questionText}
|
||||
multiline
|
||||
rows={3}
|
||||
floatingLabelText={<Trans>Your question</Trans>}
|
||||
onChange={(e, value) => {
|
||||
setQuestionText(value);
|
||||
}}
|
||||
fullWidth
|
||||
disabled={isLoading || wasQuestionSubmitted}
|
||||
maxLength={maxLength}
|
||||
helperMarkdownText={i18n._(
|
||||
t`**${
|
||||
questionText.length
|
||||
}**/${maxLength} characters. You'll receive your answer by email.`
|
||||
)}
|
||||
/>
|
||||
{error ? (
|
||||
<AlertMessage kind="error">
|
||||
<Trans>
|
||||
An error happened while sending your question. Please verify
|
||||
your internet connection or try again later.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
) : wasQuestionSubmitted ? (
|
||||
<AlertMessage kind="info">
|
||||
<Trans>
|
||||
Your question has been sent! You'll receive your answer by
|
||||
email.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
) : null}
|
||||
<Paper background="light" variant="outlined">
|
||||
<Column expand>
|
||||
<LineStackLayout expand alignItems="center">
|
||||
<SquaredInfo style={styles.infoIcon} />
|
||||
<Text size="body-small">
|
||||
<Trans>
|
||||
You can also ask your question on{' '}
|
||||
<Link
|
||||
onClick={() =>
|
||||
Window.openExternalURL('https://forum.gdevelop.io')
|
||||
}
|
||||
href="#"
|
||||
>
|
||||
the forum
|
||||
</Link>
|
||||
, on{' '}
|
||||
<Link
|
||||
onClick={() =>
|
||||
Window.openExternalURL('https://discord.gg/gdevelop')
|
||||
}
|
||||
href="#"
|
||||
>
|
||||
the GDevelop Discord server
|
||||
</Link>{' '}
|
||||
or{' '}
|
||||
<Link
|
||||
onClick={() =>
|
||||
Window.openExternalURL(
|
||||
'https://gdevelop.io/pricing/support'
|
||||
)
|
||||
}
|
||||
href="#"
|
||||
>
|
||||
book fast professional support
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
</Column>
|
||||
</Paper>
|
||||
</ColumnStackLayout>
|
||||
</Dialog>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnyQuestionDialog;
|
@@ -23,22 +23,21 @@ const styles = {
|
||||
// Styles to give the impression of pressing an element.
|
||||
const useStylesForWidget = ({
|
||||
useDefaultDisabledStyle,
|
||||
noBorder,
|
||||
disableHoverAndFocusEffects,
|
||||
}: {|
|
||||
useDefaultDisabledStyle?: boolean,
|
||||
noBorder?: boolean,
|
||||
disableHoverAndFocusEffects?: boolean,
|
||||
|}) =>
|
||||
makeStyles(theme => {
|
||||
const rootStyles = {
|
||||
boxSizing: 'border-box',
|
||||
border: `1px solid ${theme.palette.text.primary}`,
|
||||
borderBottom: `6px solid ${theme.palette.text.primary}`,
|
||||
border: !noBorder ? `1px solid ${theme.palette.text.secondary}` : 'none',
|
||||
transition: 'background-color 100ms ease',
|
||||
'&:disabled': useDefaultDisabledStyle
|
||||
? {
|
||||
opacity: theme.palette.action.disabledOpacity,
|
||||
border: `1px solid ${theme.palette.text.secondary}`,
|
||||
borderBottom: `6px solid ${theme.palette.text.secondary}`,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
@@ -66,6 +65,7 @@ type Props = {|
|
||||
size: 'small' | 'large' | 'banner',
|
||||
disabled?: boolean,
|
||||
useDefaultDisabledStyle?: boolean,
|
||||
noBorder?: boolean,
|
||||
|};
|
||||
|
||||
export const CardWidget = ({
|
||||
@@ -74,9 +74,11 @@ export const CardWidget = ({
|
||||
size,
|
||||
disabled,
|
||||
useDefaultDisabledStyle,
|
||||
noBorder,
|
||||
}: Props) => {
|
||||
const classes = useStylesForWidget({
|
||||
useDefaultDisabledStyle,
|
||||
noBorder,
|
||||
disableHoverAndFocusEffects: !onClick,
|
||||
});
|
||||
const { isMobile } = useResponsiveWindowSize();
|
||||
|
@@ -587,10 +587,7 @@ const CreateSection = ({
|
||||
onSelectPrivateGameTemplateListingData
|
||||
}
|
||||
i18n={i18n}
|
||||
columnsCount={getExampleItemsColumns(
|
||||
windowSize,
|
||||
isLandscape
|
||||
)}
|
||||
getColumnsFromWindowSize={getExampleItemsColumns}
|
||||
hideSearch
|
||||
onlyShowGames
|
||||
/>
|
||||
|
@@ -9,12 +9,10 @@ import {
|
||||
type StorageProvider,
|
||||
} from '../../../../ProjectsStorage';
|
||||
import { marginsSize } from '../../../../UI/Grid';
|
||||
import { sendGameTemplateInformationOpened } from '../../../../Utils/Analytics/EventSender';
|
||||
import { getProductPriceOrOwnedLabel } from '../../../../AssetStore/ProductPriceTag';
|
||||
import { type PrivateGameTemplateListingData } from '../../../../Utils/GDevelopServices/Shop';
|
||||
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
|
||||
import { type PrivateGameTemplate } from '../../../../Utils/GDevelopServices/Asset';
|
||||
import { type CarouselThumbnail } from '../../../../UI/Carousel';
|
||||
import { type GDevelopTheme } from '../../../../UI/Theme';
|
||||
import {
|
||||
ExampleTile,
|
||||
PrivateGameTemplateTile,
|
||||
@@ -155,87 +153,6 @@ export const transformCloudProjectsIntoFileMetadataWithStorageProviderName = (
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const formatGameTemplateListingDataForCarousel = ({
|
||||
gameTemplateListingData,
|
||||
onSelectGameTemplate,
|
||||
i18n,
|
||||
receivedGameTemplates,
|
||||
}: {|
|
||||
gameTemplateListingData: PrivateGameTemplateListingData,
|
||||
onSelectGameTemplate: PrivateGameTemplateListingData => void,
|
||||
i18n: I18nType,
|
||||
receivedGameTemplates: ?Array<PrivateGameTemplate>,
|
||||
|}): CarouselThumbnail => {
|
||||
const isTemplateOwned =
|
||||
!!receivedGameTemplates &&
|
||||
!!receivedGameTemplates.find(
|
||||
receivedGameTemplate =>
|
||||
receivedGameTemplate.id === gameTemplateListingData.id
|
||||
);
|
||||
return {
|
||||
id: gameTemplateListingData.id,
|
||||
title: gameTemplateListingData.name,
|
||||
thumbnailUrl: gameTemplateListingData.thumbnailUrls[0],
|
||||
onClick: () => {
|
||||
sendGameTemplateInformationOpened({
|
||||
gameTemplateName: gameTemplateListingData.name,
|
||||
gameTemplateId: gameTemplateListingData.id,
|
||||
source: 'homepage',
|
||||
});
|
||||
onSelectGameTemplate(gameTemplateListingData);
|
||||
},
|
||||
overlayText: getProductPriceOrOwnedLabel({
|
||||
i18n,
|
||||
productListingData: gameTemplateListingData,
|
||||
owned: isTemplateOwned,
|
||||
}),
|
||||
overlayTextPosition: 'topLeft',
|
||||
};
|
||||
};
|
||||
|
||||
const formatExampleShortHeaderForCarousel = ({
|
||||
exampleShortHeader,
|
||||
onSelectExample,
|
||||
}: {|
|
||||
exampleShortHeader: ExampleShortHeader,
|
||||
onSelectExample: ExampleShortHeader => void,
|
||||
|}) => {
|
||||
return {
|
||||
id: exampleShortHeader.id,
|
||||
title: exampleShortHeader.name,
|
||||
onClick: () => onSelectExample(exampleShortHeader),
|
||||
thumbnailUrl: exampleShortHeader.previewImageUrls[0],
|
||||
};
|
||||
};
|
||||
|
||||
const formatItemForCarousel = ({
|
||||
item,
|
||||
onSelectGameTemplate,
|
||||
onSelectExample,
|
||||
i18n,
|
||||
receivedGameTemplates,
|
||||
}: {
|
||||
item: PrivateGameTemplateListingData | ExampleShortHeader,
|
||||
onSelectGameTemplate: PrivateGameTemplateListingData => void,
|
||||
onSelectExample: ExampleShortHeader => void,
|
||||
i18n: I18nType,
|
||||
receivedGameTemplates: ?Array<PrivateGameTemplate>,
|
||||
}): CarouselThumbnail => {
|
||||
if (item.previewImageUrls) {
|
||||
return formatExampleShortHeaderForCarousel({
|
||||
exampleShortHeader: item,
|
||||
onSelectExample: onSelectExample,
|
||||
});
|
||||
} else {
|
||||
return formatGameTemplateListingDataForCarousel({
|
||||
i18n,
|
||||
onSelectGameTemplate: onSelectGameTemplate,
|
||||
gameTemplateListingData: item,
|
||||
receivedGameTemplates: receivedGameTemplates,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatItemForGrid = ({
|
||||
item,
|
||||
onSelectGameTemplate,
|
||||
@@ -288,6 +205,7 @@ export const getExampleAndTemplateTiles = ({
|
||||
onSelectPrivateGameTemplateListingData,
|
||||
onSelectExampleShortHeader,
|
||||
i18n,
|
||||
gdevelopTheme,
|
||||
numberOfItemsExclusivelyInCarousel = 0,
|
||||
numberOfItemsInCarousel = 0,
|
||||
privateGameTemplatesPeriodicity,
|
||||
@@ -301,21 +219,14 @@ export const getExampleAndTemplateTiles = ({
|
||||
) => void,
|
||||
onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void,
|
||||
i18n: I18nType,
|
||||
gdevelopTheme: GDevelopTheme,
|
||||
numberOfItemsExclusivelyInCarousel?: number,
|
||||
numberOfItemsInCarousel?: number,
|
||||
privateGameTemplatesPeriodicity: number,
|
||||
showOwnedGameTemplatesFirst?: boolean,
|
||||
|}): {|
|
||||
carouselThumbnailItems: Array<CarouselThumbnail>,
|
||||
gridItemsCompletingCarousel: Array<React.Node>,
|
||||
allGridItems: Array<React.Node>,
|
||||
|} => {
|
||||
|}): Array<React.Node> => {
|
||||
if (!exampleShortHeaders || !privateGameTemplateListingDatas) {
|
||||
return {
|
||||
carouselThumbnailItems: [],
|
||||
gridItemsCompletingCarousel: [],
|
||||
allGridItems: [],
|
||||
};
|
||||
return [];
|
||||
}
|
||||
const exampleShortHeadersWithThumbnails = exampleShortHeaders.filter(
|
||||
exampleShortHeader =>
|
||||
@@ -328,12 +239,6 @@ export const getExampleAndTemplateTiles = ({
|
||||
!exampleShortHeader.previewImageUrls[0]
|
||||
);
|
||||
|
||||
const carouselItems: Array<
|
||||
PrivateGameTemplateListingData | ExampleShortHeader
|
||||
> = [];
|
||||
const itemsCompletingCarousel: Array<
|
||||
PrivateGameTemplateListingData | ExampleShortHeader
|
||||
> = [];
|
||||
const allItems: Array<
|
||||
PrivateGameTemplateListingData | ExampleShortHeader
|
||||
> = [];
|
||||
@@ -363,29 +268,13 @@ export const getExampleAndTemplateTiles = ({
|
||||
|
||||
// First handle example.
|
||||
if (exampleShortHeader) {
|
||||
// Handle carousel.
|
||||
if (carouselItems.length < numberOfItemsInCarousel) {
|
||||
carouselItems.push(exampleShortHeader);
|
||||
}
|
||||
// Handle grid.
|
||||
allItems.push(exampleShortHeader);
|
||||
if (carouselItems.length > numberOfItemsExclusivelyInCarousel) {
|
||||
itemsCompletingCarousel.push(exampleShortHeader);
|
||||
}
|
||||
}
|
||||
|
||||
// Then handle private game template if in the right periodicity.
|
||||
if (shouldAddPrivateGameTemplate && privateGameTemplateListingData) {
|
||||
// Handle carousel.
|
||||
if (carouselItems.length < numberOfItemsInCarousel) {
|
||||
carouselItems.push(privateGameTemplateListingData);
|
||||
}
|
||||
// Handle grid.
|
||||
if (privateGameTemplateListingData) {
|
||||
allItems.push(privateGameTemplateListingData);
|
||||
if (carouselItems.length > numberOfItemsExclusivelyInCarousel) {
|
||||
itemsCompletingCarousel.push(privateGameTemplateListingData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,26 +290,6 @@ export const getExampleAndTemplateTiles = ({
|
||||
allItems.push(exampleShortHeader);
|
||||
});
|
||||
|
||||
const carouselThumbnailItems = carouselItems.map(item =>
|
||||
formatItemForCarousel({
|
||||
item,
|
||||
onSelectGameTemplate: onSelectPrivateGameTemplateListingData,
|
||||
onSelectExample: onSelectExampleShortHeader,
|
||||
i18n,
|
||||
receivedGameTemplates,
|
||||
})
|
||||
);
|
||||
|
||||
const gridItemsCompletingCarousel = itemsCompletingCarousel.map(item =>
|
||||
formatItemForGrid({
|
||||
item,
|
||||
onSelectGameTemplate: onSelectPrivateGameTemplateListingData,
|
||||
onSelectExample: onSelectExampleShortHeader,
|
||||
i18n,
|
||||
receivedGameTemplates,
|
||||
})
|
||||
);
|
||||
|
||||
const allGridItems = allItems
|
||||
.sort((item1, item2) => {
|
||||
if (showOwnedGameTemplatesFirst) {
|
||||
@@ -452,5 +321,5 @@ export const getExampleAndTemplateTiles = ({
|
||||
})
|
||||
);
|
||||
|
||||
return { carouselThumbnailItems, gridItemsCompletingCarousel, allGridItems };
|
||||
return allGridItems;
|
||||
};
|
||||
|
@@ -1,360 +0,0 @@
|
||||
// @flow
|
||||
|
||||
import * as React from 'react';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
import GridList from '@material-ui/core/GridList';
|
||||
import GridListTile from '@material-ui/core/GridListTile';
|
||||
import { type AuthenticatedUser } from '../../../../Profile/AuthenticatedUserContext';
|
||||
import { TutorialContext } from '../../../../Tutorial/TutorialContext';
|
||||
import { SectionRow } from '../SectionContainer';
|
||||
import GuidedLessons from '../InAppTutorials/GuidedLessons';
|
||||
import { formatTutorialToImageTileComponent } from '../LearnSection';
|
||||
import ImageTileRow from '../../../../UI/ImageTileRow';
|
||||
import {
|
||||
useResponsiveWindowSize,
|
||||
type WindowSizeType,
|
||||
} from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import Text from '../../../../UI/Text';
|
||||
import { Column, Line, Spacer } from '../../../../UI/Grid';
|
||||
import { type Tutorial } from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
import { CardWidget } from '../CardWidget';
|
||||
import Window from '../../../../Utils/Window';
|
||||
import { ColumnStackLayout } from '../../../../UI/Layout';
|
||||
import { type GuidedLessonsRecommendation } from '../../../../Utils/GDevelopServices/User';
|
||||
import PreferencesContext from '../../../Preferences/PreferencesContext';
|
||||
import { SurveyCard } from './SurveyCard';
|
||||
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
|
||||
import PromotionsSlideshow from '../../../../Promotions/PromotionsSlideshow';
|
||||
import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutorials/PrivateTutorialViewDialog';
|
||||
import FlatButton from '../../../../UI/FlatButton';
|
||||
import InAppTutorialContext from '../../../../InAppTutorial/InAppTutorialContext';
|
||||
import { type NewProjectSetup } from '../../../../ProjectCreation/NewProjectSetupDialog';
|
||||
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
|
||||
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
|
||||
|
||||
const styles = {
|
||||
textTutorialContent: {
|
||||
padding: 20,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
},
|
||||
};
|
||||
|
||||
const useStyles = makeStyles({
|
||||
tile: {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
const getTextTutorialsColumnsFromWidth = (
|
||||
windowSize: WindowSizeType,
|
||||
isLandscape: boolean
|
||||
) => {
|
||||
switch (windowSize) {
|
||||
case 'small':
|
||||
return isLandscape ? 4 : 2;
|
||||
case 'medium':
|
||||
return 2;
|
||||
case 'large':
|
||||
return 4;
|
||||
case 'xlarge':
|
||||
return 5;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
const getVideoTutorialsColumnsFromWidth = (
|
||||
windowSize: WindowSizeType,
|
||||
isLandscape: boolean
|
||||
) => {
|
||||
switch (windowSize) {
|
||||
case 'small':
|
||||
return isLandscape ? 5 : 2;
|
||||
case 'medium':
|
||||
return 3;
|
||||
case 'large':
|
||||
return 5;
|
||||
case 'xlarge':
|
||||
return 6;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
const getTutorialsLimitsFromWidth = (
|
||||
windowSize: WindowSizeType,
|
||||
isLandscape: boolean
|
||||
) => {
|
||||
switch (windowSize) {
|
||||
case 'small':
|
||||
return isLandscape ? 5 : 3;
|
||||
case 'medium':
|
||||
return 3;
|
||||
case 'large':
|
||||
return 5;
|
||||
case 'xlarge':
|
||||
return 5;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
type TextTutorialsRowProps = {|
|
||||
tutorials: Array<Tutorial>,
|
||||
i18n: I18nType,
|
||||
|};
|
||||
|
||||
const TextTutorialsRow = ({ tutorials, i18n }: TextTutorialsRowProps) => {
|
||||
const classes = useStyles();
|
||||
const { isLandscape, windowSize } = useResponsiveWindowSize();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Column noMargin>
|
||||
<Text size="section-title" noMargin>
|
||||
<Trans>Read</Trans>
|
||||
</Text>
|
||||
<Text>
|
||||
<Trans>
|
||||
Text-based content directly from GDevelop’s site and Wiki.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
<GridList
|
||||
cols={getTextTutorialsColumnsFromWidth(windowSize, isLandscape)}
|
||||
cellHeight="auto"
|
||||
spacing={10}
|
||||
>
|
||||
{tutorials.map(tutorial => (
|
||||
<GridListTile key={tutorial.id} classes={{ tile: classes.tile }}>
|
||||
<CardWidget
|
||||
onClick={() =>
|
||||
Window.openExternalURL(
|
||||
selectMessageByLocale(i18n, tutorial.linkByLocale)
|
||||
)
|
||||
}
|
||||
size="large"
|
||||
>
|
||||
<div style={styles.textTutorialContent}>
|
||||
<ColumnStackLayout expand justifyContent="center" useFullHeight>
|
||||
<Text noMargin size="block-title">
|
||||
{selectMessageByLocale(i18n, tutorial.titleByLocale)}
|
||||
</Text>
|
||||
<Text noMargin size="body" color="secondary">
|
||||
{selectMessageByLocale(i18n, tutorial.descriptionByLocale)}
|
||||
</Text>
|
||||
</ColumnStackLayout>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
))}
|
||||
</GridList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
authenticatedUser: AuthenticatedUser,
|
||||
selectInAppTutorial: (tutorialId: string) => void,
|
||||
onStartSurvey: null | (() => void),
|
||||
hasFilledSurveyAlready: boolean,
|
||||
onOpenProfile: () => void,
|
||||
onCreateProjectFromExample: (
|
||||
exampleShortHeader: ExampleShortHeader,
|
||||
newProjectSetup: NewProjectSetup,
|
||||
i18n: I18nType,
|
||||
isQuickCustomization?: boolean
|
||||
) => Promise<void>,
|
||||
askToCloseProject: () => Promise<boolean>,
|
||||
|};
|
||||
|
||||
const RecommendationList = ({
|
||||
authenticatedUser,
|
||||
selectInAppTutorial,
|
||||
onStartSurvey,
|
||||
hasFilledSurveyAlready,
|
||||
onOpenProfile,
|
||||
onCreateProjectFromExample,
|
||||
askToCloseProject,
|
||||
}: Props) => {
|
||||
const { recommendations, limits } = authenticatedUser;
|
||||
const { tutorials } = React.useContext(TutorialContext);
|
||||
const {
|
||||
getTutorialProgress,
|
||||
values: { showInAppTutorialDeveloperMode },
|
||||
} = React.useContext(PreferencesContext);
|
||||
const { onLoadInAppTutorialFromLocalFile } = React.useContext(
|
||||
InAppTutorialContext
|
||||
);
|
||||
|
||||
const [
|
||||
selectedTutorial,
|
||||
setSelectedTutorial,
|
||||
] = React.useState<Tutorial | null>(null);
|
||||
|
||||
if (!recommendations) return <PlaceholderLoader />;
|
||||
|
||||
const recommendedTutorials = tutorials
|
||||
? recommendations
|
||||
.map(recommendation =>
|
||||
recommendation.type === 'gdevelop-tutorial'
|
||||
? tutorials.find(tutorial => tutorial.id === recommendation.id)
|
||||
: null
|
||||
)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const recommendedVideoTutorials = recommendedTutorials.filter(
|
||||
tutorial => tutorial.type === 'video'
|
||||
);
|
||||
const recommendedTextTutorials = recommendedTutorials.filter(
|
||||
tutorial => tutorial.type === 'text'
|
||||
);
|
||||
|
||||
// $FlowIgnore
|
||||
const guidedLessonsRecommendation: ?GuidedLessonsRecommendation = recommendations.find(
|
||||
recommendation => recommendation.type === 'guided-lessons'
|
||||
);
|
||||
const guidedLessonsIds = guidedLessonsRecommendation
|
||||
? guidedLessonsRecommendation.lessonsIds
|
||||
: null;
|
||||
|
||||
const getInAppTutorialPartProgress = ({
|
||||
tutorialId,
|
||||
}: {
|
||||
tutorialId: string,
|
||||
}) => {
|
||||
const tutorialProgress = getTutorialProgress({
|
||||
tutorialId,
|
||||
userId: authenticatedUser.profile
|
||||
? authenticatedUser.profile.id
|
||||
: undefined,
|
||||
});
|
||||
if (!tutorialProgress || !tutorialProgress.progress) return 0;
|
||||
return tutorialProgress.progress[0]; // guided lessons only have one part.
|
||||
};
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => {
|
||||
const items = [];
|
||||
|
||||
if (onStartSurvey && !hasFilledSurveyAlready)
|
||||
items.push(
|
||||
<SectionRow key="start-survey">
|
||||
<SurveyCard
|
||||
onStartSurvey={onStartSurvey}
|
||||
hasFilledSurveyAlready={false}
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
|
||||
if (guidedLessonsRecommendation) {
|
||||
const displayTextAfterGuidedLessons = guidedLessonsIds
|
||||
? guidedLessonsIds
|
||||
.map(tutorialId => getInAppTutorialPartProgress({ tutorialId }))
|
||||
.every(progress => progress === 100)
|
||||
: false;
|
||||
|
||||
items.push(
|
||||
<SectionRow key="guided-lessons">
|
||||
<Line justifyContent="space-between" noMargin alignItems="center">
|
||||
<Text size="section-title" noMargin>
|
||||
<Trans>Build game mechanics</Trans>
|
||||
</Text>
|
||||
{showInAppTutorialDeveloperMode && (
|
||||
<FlatButton
|
||||
label={<Trans>Load local lesson</Trans>}
|
||||
onClick={onLoadInAppTutorialFromLocalFile}
|
||||
/>
|
||||
)}
|
||||
</Line>
|
||||
<GuidedLessons
|
||||
selectInAppTutorial={selectInAppTutorial}
|
||||
lessonsIds={guidedLessonsIds}
|
||||
/>
|
||||
{displayTextAfterGuidedLessons && (
|
||||
<Text>
|
||||
<Trans>
|
||||
Congratulations on completing this selection of guided
|
||||
lessons! Find all lessons in the Learn section.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</SectionRow>
|
||||
);
|
||||
}
|
||||
|
||||
if (recommendedVideoTutorials.length) {
|
||||
items.push(
|
||||
<SectionRow key="videos">
|
||||
<ImageTileRow
|
||||
title={<Trans>Get started with game creation</Trans>}
|
||||
margin="dense"
|
||||
items={recommendedVideoTutorials.map(tutorial =>
|
||||
formatTutorialToImageTileComponent({
|
||||
i18n,
|
||||
limits,
|
||||
tutorial,
|
||||
onSelectTutorial: setSelectedTutorial,
|
||||
})
|
||||
)}
|
||||
getColumnsFromWindowSize={getVideoTutorialsColumnsFromWidth}
|
||||
getLimitFromWindowSize={getTutorialsLimitsFromWidth}
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
}
|
||||
|
||||
if (onStartSurvey && hasFilledSurveyAlready)
|
||||
items.push(
|
||||
<SectionRow key="start-survey">
|
||||
<SurveyCard
|
||||
onStartSurvey={onStartSurvey}
|
||||
hasFilledSurveyAlready
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
|
||||
items.push(
|
||||
<SectionRow key="promotions">
|
||||
<Text size="section-title" noMargin>
|
||||
<Trans>Discover the ecosystem</Trans>
|
||||
</Text>
|
||||
<Spacer />
|
||||
<PromotionsSlideshow />
|
||||
</SectionRow>
|
||||
);
|
||||
|
||||
if (recommendedTextTutorials.length) {
|
||||
items.push(
|
||||
<SectionRow key="texts">
|
||||
<TextTutorialsRow
|
||||
tutorials={recommendedTextTutorials}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{items}
|
||||
{selectedTutorial && (
|
||||
<PrivateTutorialViewDialog
|
||||
tutorial={selectedTutorial}
|
||||
onClose={() => setSelectedTutorial(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecommendationList;
|
@@ -1,293 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import Text from '../../../../UI/Text';
|
||||
import { ColumnStackLayout } from '../../../../UI/Layout';
|
||||
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
|
||||
import { useOnlineStatus } from '../../../../Utils/OnlineStatus';
|
||||
import TreeLeaves from '../../../../UI/CustomSvgIcons/TreeLeaves';
|
||||
import SectionContainer from '../SectionContainer';
|
||||
import RaisedButton from '../../../../UI/RaisedButton';
|
||||
import useForceUpdate from '../../../../Utils/UseForceUpdate';
|
||||
import { LargeSpacer, Line } from '../../../../UI/Grid';
|
||||
import CircularProgress from '../../../../UI/CircularProgress';
|
||||
import { type UserSurvey as UserSurveyType } from '../../../../Utils/GDevelopServices/User';
|
||||
import UserSurvey from './UserSurvey';
|
||||
import {
|
||||
clearUserSurveyPersistedState,
|
||||
hasStartedUserSurvey,
|
||||
} from './UserSurveyStorage';
|
||||
import LinearProgress from '../../../../UI/LinearProgress';
|
||||
import PreferencesContext from '../../../Preferences/PreferencesContext';
|
||||
import RecommendationList from './RecommendationList';
|
||||
import ErrorBoundary from '../../../../UI/ErrorBoundary';
|
||||
import { delay } from '../../../../Utils/Delay';
|
||||
import Checkbox from '../../../../UI/Checkbox';
|
||||
import { sendUserSurveyCompleted } from '../../../../Utils/Analytics/EventSender';
|
||||
import { type NewProjectSetup } from '../../../../ProjectCreation/NewProjectSetupDialog';
|
||||
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
|
||||
|
||||
const styles = {
|
||||
icon: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
margin: 20,
|
||||
},
|
||||
middlePageButtonContainer: {
|
||||
width: '100%',
|
||||
maxWidth: 300, // Make buttons larger but not too much.
|
||||
marginBottom: '15%', // Used to display the content of the section higher than at the center.
|
||||
},
|
||||
bottomPageButtonContainer: {
|
||||
width: '100%',
|
||||
maxWidth: 300, // Make buttons larger but not too much.
|
||||
marginBottom: 30, // Used to giver some space between the buttons and the screen bottom border.
|
||||
},
|
||||
linearProgress: { width: 200 },
|
||||
getFormContainerStyle: (isMobile: boolean) => ({
|
||||
marginTop: 20,
|
||||
// Take full width on mobile.
|
||||
width: isMobile ? '95%' : 300,
|
||||
}),
|
||||
questionnaireFinishedImage: { aspectRatio: '263 / 154' },
|
||||
};
|
||||
|
||||
const questionnaireFinishedImageSource = 'res/questionnaire/welcome-back.svg';
|
||||
|
||||
type Props = {|
|
||||
onUserSurveyStarted: () => void,
|
||||
onUserSurveyHidden: () => void,
|
||||
selectInAppTutorial: (tutorialId: string) => void,
|
||||
onOpenProfile: () => void,
|
||||
onCreateProjectFromExample: (
|
||||
exampleShortHeader: ExampleShortHeader,
|
||||
newProjectSetup: NewProjectSetup,
|
||||
i18n: I18nType,
|
||||
isQuickCustomization?: boolean
|
||||
) => Promise<void>,
|
||||
askToCloseProject: () => Promise<boolean>,
|
||||
|};
|
||||
|
||||
const GetStartedSection = ({
|
||||
selectInAppTutorial,
|
||||
onUserSurveyStarted,
|
||||
onUserSurveyHidden,
|
||||
onOpenProfile,
|
||||
onCreateProjectFromExample,
|
||||
askToCloseProject,
|
||||
}: Props) => {
|
||||
const isFillingOutSurvey = hasStartedUserSurvey();
|
||||
const isOnline = useOnlineStatus();
|
||||
const authenticatedUser = React.useContext(AuthenticatedUserContext);
|
||||
const {
|
||||
profile,
|
||||
creatingOrLoggingInAccount,
|
||||
onEditProfile,
|
||||
loginState,
|
||||
} = authenticatedUser;
|
||||
const {
|
||||
values: preferences,
|
||||
setShowGetStartedSectionByDefault,
|
||||
} = React.useContext(PreferencesContext);
|
||||
const recommendationsGettingDelayPromise = React.useRef<?Promise<void>>(null);
|
||||
const forceUpdate = useForceUpdate();
|
||||
const [step, setStep] = React.useState<
|
||||
'survey' | 'surveyFinished' | 'recommendations'
|
||||
>(isFillingOutSurvey ? 'survey' : 'recommendations');
|
||||
|
||||
const [errorSendingSurvey, setErrorSendingSurvey] = React.useState<boolean>(
|
||||
false
|
||||
);
|
||||
|
||||
const onSurveyFinished = async (survey: UserSurveyType) => {
|
||||
try {
|
||||
setStep('surveyFinished');
|
||||
// Artificial delay to build up expectations.
|
||||
recommendationsGettingDelayPromise.current = delay(2500);
|
||||
await Promise.all([
|
||||
onEditProfile({ survey }, preferences),
|
||||
recommendationsGettingDelayPromise.current,
|
||||
]);
|
||||
sendUserSurveyCompleted();
|
||||
clearUserSurveyPersistedState();
|
||||
} catch (error) {
|
||||
console.error('An error occurred when sending survey:', error);
|
||||
setErrorSendingSurvey(true);
|
||||
} finally {
|
||||
recommendationsGettingDelayPromise.current = null;
|
||||
setStep('recommendations');
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (!authenticatedUser.authenticated) clearUserSurveyPersistedState();
|
||||
},
|
||||
[authenticatedUser.authenticated]
|
||||
);
|
||||
|
||||
if (
|
||||
(creatingOrLoggingInAccount || loginState === 'loggingIn') &&
|
||||
// Do not display loader if the user is already seeing the recommendations.
|
||||
// It can happen when the user profile is refreshed while the recommendations
|
||||
// are displayed. This way, the loader is not displayed unnecessarily.
|
||||
step !== 'recommendations' &&
|
||||
!recommendationsGettingDelayPromise.current
|
||||
) {
|
||||
return (
|
||||
<SectionContainer flexBody>
|
||||
<ColumnStackLayout
|
||||
noMargin
|
||||
expand
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<ColumnStackLayout
|
||||
noMargin
|
||||
expand
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<CircularProgress size={40} />
|
||||
</ColumnStackLayout>
|
||||
</ColumnStackLayout>
|
||||
</SectionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isOnline || errorSendingSurvey) {
|
||||
return (
|
||||
<SectionContainer flexBody>
|
||||
<ColumnStackLayout
|
||||
noMargin
|
||||
expand
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
{errorSendingSurvey ? (
|
||||
<>
|
||||
<Text size="title" align="center">
|
||||
<Trans>Error when sending survey.</Trans>
|
||||
</Text>
|
||||
<TreeLeaves style={styles.icon} />
|
||||
<Text size="body2" noMargin align="center">
|
||||
<Trans>
|
||||
Verify your internet connection and try again later.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="title" align="center">
|
||||
<Trans>You seem to be offline</Trans>
|
||||
</Text>
|
||||
<TreeLeaves style={styles.icon} />
|
||||
<Text size="body2" noMargin align="center">
|
||||
<Trans>
|
||||
Verify your internet connection to access your personalized
|
||||
content.
|
||||
</Trans>
|
||||
</Text>
|
||||
<div style={styles.middlePageButtonContainer}>
|
||||
<Line expand>
|
||||
<RaisedButton
|
||||
primary
|
||||
label={<Trans>Refresh</Trans>}
|
||||
onClick={forceUpdate}
|
||||
fullWidth
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
</SectionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'surveyFinished') {
|
||||
return (
|
||||
<SectionContainer flexBody>
|
||||
<ColumnStackLayout
|
||||
noMargin
|
||||
expand
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Text size="title" align="center">
|
||||
<Trans>Alright let's see what we have for you...</Trans>
|
||||
</Text>
|
||||
<img
|
||||
src={questionnaireFinishedImageSource}
|
||||
alt="You as the red hero coming back to life"
|
||||
style={styles.questionnaireFinishedImage}
|
||||
/>
|
||||
<Text size="body2" noMargin align="center">
|
||||
<Trans>Just one second please...</Trans>
|
||||
</Text>
|
||||
<Line>
|
||||
<LinearProgress
|
||||
variant="indeterminate"
|
||||
style={styles.linearProgress}
|
||||
/>
|
||||
</Line>
|
||||
</ColumnStackLayout>
|
||||
</SectionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'recommendations') {
|
||||
return (
|
||||
<>
|
||||
<SectionContainer flexBody showUrgentAnnouncements>
|
||||
<RecommendationList
|
||||
authenticatedUser={authenticatedUser}
|
||||
selectInAppTutorial={selectInAppTutorial}
|
||||
onOpenProfile={onOpenProfile}
|
||||
onStartSurvey={
|
||||
profile
|
||||
? () => {
|
||||
setStep('survey');
|
||||
}
|
||||
: null
|
||||
}
|
||||
hasFilledSurveyAlready={profile ? !!profile.survey : false}
|
||||
onCreateProjectFromExample={onCreateProjectFromExample}
|
||||
askToCloseProject={askToCloseProject}
|
||||
/>
|
||||
{authenticatedUser.recommendations && (
|
||||
<Line justifyContent="center" alignItems="center">
|
||||
<Checkbox
|
||||
label={<Trans>Don't show this screen on next startup</Trans>}
|
||||
checked={!preferences.showGetStartedSectionByDefault}
|
||||
onCheck={(e, checked) =>
|
||||
setShowGetStartedSectionByDefault(!checked)
|
||||
}
|
||||
/>
|
||||
</Line>
|
||||
)}
|
||||
<LargeSpacer />
|
||||
</SectionContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UserSurvey
|
||||
onCompleted={onSurveyFinished}
|
||||
onStarted={onUserSurveyStarted}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const GetStartedSectionWithErrorBoundary = (props: Props) => (
|
||||
<ErrorBoundary
|
||||
componentTitle={<Trans>Get started section</Trans>}
|
||||
scope="start-page-get-started"
|
||||
>
|
||||
<GetStartedSection {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
export default GetStartedSectionWithErrorBoundary;
|
@@ -10,7 +10,6 @@ import HammerIcon from '../../../UI/CustomSvgIcons/Hammer';
|
||||
import SchoolIcon from '../../../UI/CustomSvgIcons/School';
|
||||
import ControllerIcon from '../../../UI/CustomSvgIcons/Controller';
|
||||
import BookLeafIcon from '../../../UI/CustomSvgIcons/BookLeaf';
|
||||
import SunIcon from '../../../UI/CustomSvgIcons/Sun';
|
||||
import StoreIcon from '../../../UI/CustomSvgIcons/Store';
|
||||
import Preferences from '../../../UI/CustomSvgIcons/Preferences';
|
||||
import GDevelopGLogo from '../../../UI/CustomSvgIcons/GDevelopGLogo';
|
||||
@@ -43,13 +42,7 @@ export const styles = {
|
||||
},
|
||||
};
|
||||
|
||||
export type HomeTab =
|
||||
| 'get-started'
|
||||
| 'create'
|
||||
| 'learn'
|
||||
| 'play'
|
||||
| 'shop'
|
||||
| 'team-view';
|
||||
export type HomeTab = 'create' | 'learn' | 'play' | 'shop' | 'team-view';
|
||||
|
||||
export type GetIconFunction = ({
|
||||
color: string,
|
||||
@@ -64,14 +57,6 @@ export type HomePageMenuTab = {|
|
||||
|};
|
||||
|
||||
const homePageMenuTabs: { [tab: HomeTab]: HomePageMenuTab } = {
|
||||
'get-started': {
|
||||
label: <Trans>Start</Trans>,
|
||||
tab: 'get-started',
|
||||
id: 'home-get-started-tab',
|
||||
getIcon: ({ color, fontSize }) => (
|
||||
<SunIcon fontSize={fontSize} color={color} />
|
||||
),
|
||||
},
|
||||
create: {
|
||||
label: <Trans>Create</Trans>,
|
||||
tab: 'create',
|
||||
@@ -131,15 +116,14 @@ export const getTabsToDisplay = ({
|
||||
limits.capabilities.classrooms &&
|
||||
limits.capabilities.classrooms.hidePremiumProducts
|
||||
);
|
||||
const displayTeachTab =
|
||||
!shouldHideClassroomTab(limits) && !isNativeMobileApp();
|
||||
const tabs: HomeTab[] = [
|
||||
'get-started',
|
||||
'create',
|
||||
!shouldHideClassroomTab(limits) && !isNativeMobileApp()
|
||||
? 'team-view'
|
||||
: null,
|
||||
displayShopTab ? 'shop' : null,
|
||||
'learn',
|
||||
'create',
|
||||
displayPlayTab ? 'play' : null,
|
||||
displayShopTab ? 'shop' : null,
|
||||
displayTeachTab ? 'team-view' : null,
|
||||
].filter(Boolean);
|
||||
return tabs.map(tab => homePageMenuTabs[tab]);
|
||||
};
|
||||
|
@@ -0,0 +1,60 @@
|
||||
// @flow
|
||||
import { getTabsToDisplay } from './HomePageMenu';
|
||||
import { isNativeMobileApp } from '../../../Utils/Platform';
|
||||
import { limitsForStudentUser } from '../../../fixtures/GDevelopServicesTestData';
|
||||
|
||||
jest.mock('../../../Utils/Platform');
|
||||
const mockFn = (fn: Function): JestMockFn<any, any> => fn;
|
||||
|
||||
describe('HomePageMenu', () => {
|
||||
describe('getTabsToDisplay', () => {
|
||||
beforeEach(() => {
|
||||
mockFn(isNativeMobileApp).mockReset();
|
||||
});
|
||||
|
||||
test('Default desktop user', () => {
|
||||
mockFn(isNativeMobileApp).mockReturnValue(false);
|
||||
|
||||
const tabs = getTabsToDisplay({ limits: null });
|
||||
|
||||
expect(tabs.map(tab => tab.tab)).toEqual([
|
||||
'learn',
|
||||
'create',
|
||||
'play',
|
||||
'shop',
|
||||
'team-view',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Desktop user student', () => {
|
||||
mockFn(isNativeMobileApp).mockReturnValue(false);
|
||||
|
||||
const tabs = getTabsToDisplay({
|
||||
limits: limitsForStudentUser,
|
||||
});
|
||||
|
||||
expect(tabs.map(tab => tab.tab)).toEqual(['learn', 'create']);
|
||||
});
|
||||
|
||||
test('Default mobile user', () => {
|
||||
mockFn(isNativeMobileApp).mockReturnValue(true);
|
||||
|
||||
const tabs = getTabsToDisplay({ limits: null });
|
||||
|
||||
expect(tabs.map(tab => tab.tab)).toEqual([
|
||||
'learn',
|
||||
'create',
|
||||
'play',
|
||||
'shop',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Mobile student user', () => {
|
||||
mockFn(isNativeMobileApp).mockReturnValue(true);
|
||||
|
||||
const tabs = getTabsToDisplay({ limits: limitsForStudentUser });
|
||||
|
||||
expect(tabs.map(tab => tab.tab)).toEqual(['learn', 'create']);
|
||||
});
|
||||
});
|
||||
});
|
@@ -1,7 +1,6 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import GridList from '@material-ui/core/GridList';
|
||||
import GridListTile from '@material-ui/core/GridListTile';
|
||||
import {
|
||||
useResponsiveWindowSize,
|
||||
@@ -44,6 +43,8 @@ import TopDownRPGMovement from './Icons/TopDownRPGMovement';
|
||||
import FireABullet from './Icons/FireAbullet';
|
||||
import CoopPlatformer from './Icons/CoopPlatformer';
|
||||
import TilemapPlatformer from './Icons/TilemapPlatformer';
|
||||
import Carousel from '../../../../UI/Carousel';
|
||||
import { GridList } from '@material-ui/core';
|
||||
|
||||
const getColumnsFromWindowSize = (
|
||||
windowSize: WindowSizeType,
|
||||
@@ -63,6 +64,7 @@ const getColumnsFromWindowSize = (
|
||||
}
|
||||
};
|
||||
|
||||
const NUMBER_OF_SCROLLS = 2; // Number of times the carousel can be scrolled to see all items.
|
||||
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
|
||||
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
|
||||
const ITEMS_SPACING = 5;
|
||||
@@ -80,13 +82,109 @@ const styles = {
|
||||
},
|
||||
};
|
||||
|
||||
export const allInAppTutorialsCards = [
|
||||
{
|
||||
id: KNIGHT_PLATFORMER_IN_APP_TUTORIAL_ID,
|
||||
title: t`Platformer`,
|
||||
description: t`Make a knight jump and run in this platformer game.`,
|
||||
shortDescription: t`Make a knight jump and run.`,
|
||||
durationInMinutes: 1,
|
||||
renderImage: (props: any) => <Platformer {...props} />,
|
||||
},
|
||||
{
|
||||
id: CAMERA_PARALLAX_IN_APP_TUTORIAL_ID,
|
||||
title: t`Background and cameras`,
|
||||
shortDescription: t`Follow a character with scrolling background.`,
|
||||
description: t`Follow this Castlevania-type character with the camera, while the background scrolls.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: (props: any) => <Parallax {...props} />,
|
||||
},
|
||||
{
|
||||
id: TOP_DOWN_RPG_MOVEMENT_ID,
|
||||
title: t`Top-Down RPG Pixel Perfect`,
|
||||
shortDescription: t`Move like in retro Pokemon games.`,
|
||||
description: t`Make a character move like in a retro Pokemon game.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: (props: any) => <TopDownRPGMovement {...props} />,
|
||||
},
|
||||
{
|
||||
id: JOYSTICK_IN_APP_TUTORIAL_ID,
|
||||
title: t`Joystick controls`,
|
||||
shortDescription: t`Control a spaceship with a joystick.`,
|
||||
description: t`Control your spaceship with a joystick, while avoiding asteroids.`,
|
||||
durationInMinutes: 1,
|
||||
renderImage: (props: any) => <Joystick {...props} />,
|
||||
},
|
||||
{
|
||||
id: OBJECT_3D_IN_APP_TUTORIAL_ID,
|
||||
title: t`3D platforms`,
|
||||
shortDescription: t`Place 3D platforms in a 2D game.`,
|
||||
description: t`Place 3D platforms in this 2D platformer, creating a path to the end.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: (props: any) => <Object3D {...props} />,
|
||||
},
|
||||
{
|
||||
id: HEALTH_BAR_IN_APP_TUTORIAL_ID,
|
||||
title: t`Health bar`,
|
||||
shortDescription: t`Add a health bar for handle damage.`,
|
||||
description: t`Add a health bar to this jumping character, losing health when hitting spikes.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: (props: any) => <HealthBar {...props} />,
|
||||
},
|
||||
{
|
||||
id: TILEMAP_PLATFORMER_ID,
|
||||
title: t`Paint a Level with Tiles`,
|
||||
shortDescription: t`Build dynamic levels with tiles.`,
|
||||
description: t`Use a Tilemap to build a level and change it dynamically during the game.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: (props: any) => <TilemapPlatformer {...props} />,
|
||||
},
|
||||
{
|
||||
id: FIRE_A_BULLET_ID,
|
||||
title: t`Fire a Bullet`,
|
||||
shortDescription: t`Fire bullets in an Asteroids game.`,
|
||||
description: t`Fire bullets in this Asteroids game. Get ready for a Star Wars show.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: (props: any) => <FireABullet {...props} />,
|
||||
},
|
||||
{
|
||||
id: TIMER_IN_APP_TUTORIAL_ID,
|
||||
title: t`Time score`,
|
||||
shortDescription: t`Add a time attack mode.`,
|
||||
description: t`Add a time attack mode, where you have to reach the end as fast as possible.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: (props: any) => <Timer {...props} />,
|
||||
},
|
||||
{
|
||||
id: COOP_PLATFORMER_ID,
|
||||
title: t`Co-op Multiplayer`,
|
||||
shortDescription: t`Transform a game into a multiplayer experience.`,
|
||||
description: t`Transform this platformer into a co-op game, where two players can play together.`,
|
||||
durationInMinutes: 3,
|
||||
renderImage: (props: any) => <CoopPlatformer {...props} />,
|
||||
},
|
||||
{
|
||||
id: PLINKO_MULTIPLIER_IN_APP_TUTORIAL_ID,
|
||||
title: t`Score multiplier`,
|
||||
shortDescription: t`Multiply scores with collectibles.`,
|
||||
description: t`Transform this Plinko game with collectibles that multiply your score.`,
|
||||
durationInMinutes: 3,
|
||||
renderImage: (props: any) => <MultiplierScore {...props} />,
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {|
|
||||
selectInAppTutorial: (tutorialId: string) => void,
|
||||
/** To use to restrict the lessons that are displayed. */
|
||||
lessonsIds?: ?Array<string>,
|
||||
displayAsCarousel?: boolean,
|
||||
|};
|
||||
|
||||
const GuidedLessons = ({ selectInAppTutorial, lessonsIds }: Props) => {
|
||||
const GuidedLessons = ({
|
||||
selectInAppTutorial,
|
||||
lessonsIds,
|
||||
displayAsCarousel,
|
||||
}: Props) => {
|
||||
const isOnline = useOnlineStatus();
|
||||
const {
|
||||
inAppTutorialShortHeaders,
|
||||
@@ -103,6 +201,12 @@ const GuidedLessons = ({ selectInAppTutorial, lessonsIds }: Props) => {
|
||||
}: {|
|
||||
tutorialId: string,
|
||||
|}) => {
|
||||
if (authenticatedUser.loginState === 'loggingIn') {
|
||||
// Wait for login to finish to avoid showing an offline progress before the
|
||||
// actual user's progress.
|
||||
return 0;
|
||||
}
|
||||
|
||||
const tutorialProgress = getTutorialProgress({
|
||||
tutorialId,
|
||||
userId: authenticatedUser.profile
|
||||
@@ -113,109 +217,34 @@ const GuidedLessons = ({ selectInAppTutorial, lessonsIds }: Props) => {
|
||||
return tutorialProgress.progress[0]; // guided lessons only have one part.
|
||||
};
|
||||
|
||||
const displayedGuidedLessonsIds = lessonsIds || guidedLessonsIds;
|
||||
|
||||
const lessonsCompleted = displayedGuidedLessonsIds.reduce(
|
||||
(acc, tutorialId) => {
|
||||
const tutorialProgress = getTutorialPartProgress({ tutorialId }) || 0;
|
||||
return tutorialProgress === 100 ? acc + 1 : acc;
|
||||
},
|
||||
0
|
||||
);
|
||||
const lessonsCompleted = guidedLessonsIds.reduce((acc, tutorialId) => {
|
||||
const tutorialProgress = getTutorialPartProgress({ tutorialId }) || 0;
|
||||
return tutorialProgress === 100 ? acc + 1 : acc;
|
||||
}, 0);
|
||||
const lessonsProgress = Math.round(
|
||||
(lessonsCompleted / displayedGuidedLessonsIds.length) * 100
|
||||
(lessonsCompleted / guidedLessonsIds.length) * 100
|
||||
);
|
||||
const numberOfColumns = getColumnsFromWindowSize(windowSize, isLandscape);
|
||||
|
||||
const guidedLessonCards = [
|
||||
{
|
||||
id: KNIGHT_PLATFORMER_IN_APP_TUTORIAL_ID,
|
||||
title: t`Platformer`,
|
||||
description: t`Make a knight jump and run in this platformer game.`,
|
||||
shortDescription: t`Make a knight jump and run.`,
|
||||
durationInMinutes: 1,
|
||||
renderImage: props => <Platformer {...props} />,
|
||||
const guidedLessonCards = React.useMemo(
|
||||
() => {
|
||||
let displayedGuidedLessonsIds = guidedLessonsIds;
|
||||
if (lessonsIds) {
|
||||
displayedGuidedLessonsIds = lessonsIds;
|
||||
}
|
||||
if (displayAsCarousel) {
|
||||
displayedGuidedLessonsIds = displayedGuidedLessonsIds.slice(
|
||||
0,
|
||||
numberOfColumns * (NUMBER_OF_SCROLLS + 1)
|
||||
);
|
||||
}
|
||||
|
||||
return allInAppTutorialsCards.filter(item =>
|
||||
displayedGuidedLessonsIds.includes(item.id)
|
||||
);
|
||||
},
|
||||
{
|
||||
id: CAMERA_PARALLAX_IN_APP_TUTORIAL_ID,
|
||||
title: t`Background and cameras`,
|
||||
shortDescription: t`Follow a character with scrolling background.`,
|
||||
description: t`Follow this Castlevania-type character with the camera, while the background scrolls.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: props => <Parallax {...props} />,
|
||||
},
|
||||
{
|
||||
id: TOP_DOWN_RPG_MOVEMENT_ID,
|
||||
title: t`Top-Down RPG Pixel Perfect`,
|
||||
shortDescription: t`Move like in retro Pokemon games.`,
|
||||
description: t`Make a character move like in a retro Pokemon game.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: props => <TopDownRPGMovement {...props} />,
|
||||
},
|
||||
{
|
||||
id: JOYSTICK_IN_APP_TUTORIAL_ID,
|
||||
title: t`Joystick controls`,
|
||||
shortDescription: t`Control a spaceship with a joystick.`,
|
||||
description: t`Control your spaceship with a joystick, while avoiding asteroids.`,
|
||||
durationInMinutes: 1,
|
||||
renderImage: props => <Joystick {...props} />,
|
||||
},
|
||||
{
|
||||
id: OBJECT_3D_IN_APP_TUTORIAL_ID,
|
||||
title: t`3D platforms`,
|
||||
shortDescription: t`Place 3D platforms in a 2D game.`,
|
||||
description: t`Place 3D platforms in this 2D platformer, creating a path to the end.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: props => <Object3D {...props} />,
|
||||
},
|
||||
{
|
||||
id: HEALTH_BAR_IN_APP_TUTORIAL_ID,
|
||||
title: t`Health bar`,
|
||||
shortDescription: t`Add a health bar for handle damage.`,
|
||||
description: t`Add a health bar to this jumping character, losing health when hitting spikes.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: props => <HealthBar {...props} />,
|
||||
},
|
||||
{
|
||||
id: TILEMAP_PLATFORMER_ID,
|
||||
title: t`Paint a Level with Tiles`,
|
||||
shortDescription: t`Build dynamic levels with tiles.`,
|
||||
description: t`Use a Tilemap to build a level and change it dynamically during the game.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: props => <TilemapPlatformer {...props} />,
|
||||
},
|
||||
{
|
||||
id: FIRE_A_BULLET_ID,
|
||||
title: t`Fire a Bullet`,
|
||||
shortDescription: t`Fire bullets in an Asteroids game.`,
|
||||
description: t`Fire bullets in this Asteroids game. Get ready for a Star Wars show.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: props => <FireABullet {...props} />,
|
||||
},
|
||||
{
|
||||
id: TIMER_IN_APP_TUTORIAL_ID,
|
||||
title: t`Time score`,
|
||||
shortDescription: t`Add a time attack mode.`,
|
||||
description: t`Add a time attack mode, where you have to reach the end as fast as possible.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: props => <Timer {...props} />,
|
||||
},
|
||||
{
|
||||
id: COOP_PLATFORMER_ID,
|
||||
title: t`Co-op Multiplayer`,
|
||||
shortDescription: t`Transform a game into a multiplayer experience.`,
|
||||
description: t`Transform this platformer into a co-op game, where two players can play together.`,
|
||||
durationInMinutes: 3,
|
||||
renderImage: props => <CoopPlatformer {...props} />,
|
||||
},
|
||||
{
|
||||
id: PLINKO_MULTIPLIER_IN_APP_TUTORIAL_ID,
|
||||
title: t`Score multiplier`,
|
||||
shortDescription: t`Multiply scores with collectibles.`,
|
||||
description: t`Transform this Plinko game with collectibles that multiply your score.`,
|
||||
durationInMinutes: 3,
|
||||
renderImage: props => <MultiplierScore {...props} />,
|
||||
},
|
||||
].filter(item => displayedGuidedLessonsIds.includes(item.id));
|
||||
[lessonsIds, displayAsCarousel, numberOfColumns]
|
||||
);
|
||||
|
||||
return (
|
||||
<Line>
|
||||
@@ -241,30 +270,58 @@ const GuidedLessons = ({ selectInAppTutorial, lessonsIds }: Props) => {
|
||||
<ColoredLinearProgress value={lessonsProgress} />
|
||||
</LineStackLayout>
|
||||
</Column>
|
||||
<GridList
|
||||
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={ITEMS_SPACING * 2}
|
||||
>
|
||||
{guidedLessonCards.map(item => (
|
||||
<GridListTile key={item.id}>
|
||||
<InAppTutorialPhaseCard
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
shortDescription={item.shortDescription}
|
||||
durationInMinutes={item.durationInMinutes}
|
||||
renderImage={item.renderImage}
|
||||
progress={getTutorialPartProgress({ tutorialId: item.id })}
|
||||
onClick={() => selectInAppTutorial(item.id)}
|
||||
// Phase is disabled if there's a running tutorial or if offline,
|
||||
// because we cannot fetch the tutorial.
|
||||
disabled={!!currentlyRunningInAppTutorial || !isOnline}
|
||||
loading={!inAppTutorialShortHeaders}
|
||||
/>
|
||||
</GridListTile>
|
||||
))}
|
||||
</GridList>
|
||||
{displayAsCarousel ? (
|
||||
<Carousel
|
||||
items={guidedLessonCards.map(item => ({
|
||||
renderItem: () => (
|
||||
<GridListTile key={item.id}>
|
||||
<InAppTutorialPhaseCard
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
shortDescription={item.shortDescription}
|
||||
durationInMinutes={item.durationInMinutes}
|
||||
renderImage={item.renderImage}
|
||||
progress={getTutorialPartProgress({
|
||||
tutorialId: item.id,
|
||||
})}
|
||||
onClick={() => selectInAppTutorial(item.id)}
|
||||
// Phase is disabled if there's a running tutorial or if offline,
|
||||
// because we cannot fetch the tutorial.
|
||||
disabled={!!currentlyRunningInAppTutorial || !isOnline}
|
||||
loading={!inAppTutorialShortHeaders}
|
||||
/>
|
||||
</GridListTile>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<GridList
|
||||
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={ITEMS_SPACING * 2}
|
||||
>
|
||||
{guidedLessonCards.map(item => (
|
||||
<GridListTile key={item.id}>
|
||||
<InAppTutorialPhaseCard
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
shortDescription={item.shortDescription}
|
||||
durationInMinutes={item.durationInMinutes}
|
||||
renderImage={item.renderImage}
|
||||
progress={getTutorialPartProgress({
|
||||
tutorialId: item.id,
|
||||
})}
|
||||
onClick={() => selectInAppTutorial(item.id)}
|
||||
// Phase is disabled if there's a running tutorial or if offline,
|
||||
// because we cannot fetch the tutorial.
|
||||
disabled={!!currentlyRunningInAppTutorial || !isOnline}
|
||||
loading={!inAppTutorialShortHeaders}
|
||||
/>
|
||||
</GridListTile>
|
||||
))}
|
||||
</GridList>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
)}
|
||||
</div>
|
||||
|
@@ -15,6 +15,18 @@ import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
|
||||
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
const getChipColorFromDuration = (durationInMinutes: number) => {
|
||||
if (durationInMinutes <= 2) return '#3BF7F4';
|
||||
if (durationInMinutes <= 5) return '#FFBC57';
|
||||
return '#FF8569';
|
||||
};
|
||||
|
||||
const getChipTextFromDuration = (durationInMinutes: number) => {
|
||||
if (durationInMinutes <= 2) return <Trans>Beginner</Trans>;
|
||||
if (durationInMinutes <= 5) return <Trans>Intermediate</Trans>;
|
||||
return <Trans>Advanced</Trans>;
|
||||
};
|
||||
|
||||
const getImageSize = ({ isMobile }: { isMobile: boolean }) =>
|
||||
isMobile ? 90 : 130;
|
||||
|
||||
@@ -23,7 +35,7 @@ const styles = {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '10px 10px 15px 10px',
|
||||
padding: '8px 16px',
|
||||
},
|
||||
lockerImage: { height: 80, width: 80 },
|
||||
imageContainer: {
|
||||
@@ -37,6 +49,9 @@ const styles = {
|
||||
textAlign: 'left',
|
||||
overflowWrap: 'anywhere',
|
||||
},
|
||||
chip: {
|
||||
height: 24,
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
@@ -118,87 +133,107 @@ const InAppTutorialPhaseCard = ({
|
||||
}}
|
||||
>
|
||||
<ColumnStackLayout
|
||||
noMargin
|
||||
expand
|
||||
justifyContent="flex-start"
|
||||
justifyContent="space-between"
|
||||
useFullHeight
|
||||
noOverflowParent
|
||||
>
|
||||
{progress && progress > 0 ? (
|
||||
progress !== 100 ? (
|
||||
<LineStackLayout alignItems="center" noMargin>
|
||||
<Text displayInlineAsSpan noMargin size="body2">
|
||||
{progress}%
|
||||
</Text>
|
||||
<ColoredLinearProgress value={progress} />
|
||||
</LineStackLayout>
|
||||
) : (
|
||||
<ColumnStackLayout
|
||||
noMargin
|
||||
expand
|
||||
justifyContent="flex-start"
|
||||
useFullHeight
|
||||
noOverflowParent
|
||||
>
|
||||
<Text
|
||||
size="sub-title"
|
||||
noMargin
|
||||
color="primary"
|
||||
align="left"
|
||||
style={getTextStyle(shouldTextBeDisabled)}
|
||||
>
|
||||
{i18n._(title)}
|
||||
</Text>
|
||||
<Text noMargin align="left" color="secondary">
|
||||
{isMobile ? i18n._(shortDescription) : i18n._(description)}
|
||||
</Text>
|
||||
{keyPoints && <Divider />}
|
||||
{keyPoints && (
|
||||
<Column
|
||||
noMargin
|
||||
alignItems="flex-start"
|
||||
justifyContent="flex-start"
|
||||
expand
|
||||
>
|
||||
<ul style={styles.keyPointsList}>
|
||||
{keyPoints.map((keyPoint, index) => (
|
||||
<Text
|
||||
key={`key-point-${index}`}
|
||||
size="body2"
|
||||
noMargin
|
||||
style={getTextStyle(shouldTextBeDisabled)}
|
||||
color="secondary"
|
||||
displayAsListItem
|
||||
>
|
||||
{i18n._(keyPoint)}
|
||||
</Text>
|
||||
))}
|
||||
</ul>
|
||||
</Column>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
<Line justifyContent="space-between" alignItems="flex-end">
|
||||
<Chip
|
||||
style={{
|
||||
...styles.chip,
|
||||
border: `1px solid ${getChipColorFromDuration(
|
||||
durationInMinutes || 0
|
||||
)}`,
|
||||
}}
|
||||
label={getChipTextFromDuration(durationInMinutes || 0)}
|
||||
variant="outlined"
|
||||
/>
|
||||
{progress && progress > 0 ? (
|
||||
progress !== 100 ? (
|
||||
<LineStackLayout alignItems="center" noMargin>
|
||||
<Text displayInlineAsSpan noMargin size="body2">
|
||||
{progress}%
|
||||
</Text>
|
||||
<ColoredLinearProgress value={progress} />
|
||||
</LineStackLayout>
|
||||
) : (
|
||||
<Line noMargin justifyContent="center">
|
||||
<Chip
|
||||
size="small"
|
||||
label={<Trans>Finished</Trans>}
|
||||
style={{
|
||||
backgroundColor:
|
||||
gdevelopTheme.statusIndicator.success,
|
||||
color: '#111111',
|
||||
}}
|
||||
/>
|
||||
</Line>
|
||||
)
|
||||
) : durationInMinutes ? (
|
||||
<Line noMargin justifyContent="center">
|
||||
<Chip
|
||||
size="small"
|
||||
label={<Trans>Finished</Trans>}
|
||||
style={{
|
||||
backgroundColor:
|
||||
gdevelopTheme.statusIndicator.success,
|
||||
color: '#111111',
|
||||
}}
|
||||
label={
|
||||
isMobile ? (
|
||||
<Trans>{durationInMinutes} min.</Trans>
|
||||
) : durationInMinutes === 1 ? (
|
||||
<Trans>1 minute</Trans>
|
||||
) : (
|
||||
<Trans>{durationInMinutes} minutes</Trans>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Line>
|
||||
)
|
||||
) : durationInMinutes ? (
|
||||
<Line noMargin justifyContent="center">
|
||||
<Chip
|
||||
size="small"
|
||||
label={
|
||||
durationInMinutes === 1 ? (
|
||||
<Trans>1 minute</Trans>
|
||||
) : (
|
||||
<Trans>{durationInMinutes} minutes</Trans>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Line>
|
||||
) : (
|
||||
<Spacer />
|
||||
)}
|
||||
<Text
|
||||
size="block-title"
|
||||
noMargin
|
||||
style={getTextStyle(shouldTextBeDisabled)}
|
||||
color="primary"
|
||||
>
|
||||
{i18n._(title)}
|
||||
</Text>
|
||||
<Text
|
||||
size="body"
|
||||
noMargin
|
||||
style={getTextStyle(shouldTextBeDisabled)}
|
||||
color="secondary"
|
||||
>
|
||||
{isMobile ? i18n._(shortDescription) : i18n._(description)}
|
||||
</Text>
|
||||
{keyPoints && <Divider />}
|
||||
{keyPoints && (
|
||||
<Column
|
||||
noMargin
|
||||
alignItems="flex-start"
|
||||
justifyContent="flex-start"
|
||||
expand
|
||||
>
|
||||
<ul style={styles.keyPointsList}>
|
||||
{keyPoints.map((keyPoint, index) => (
|
||||
<Text
|
||||
key={`key-point-${index}`}
|
||||
size="body2"
|
||||
noMargin
|
||||
style={getTextStyle(shouldTextBeDisabled)}
|
||||
color="secondary"
|
||||
displayAsListItem
|
||||
>
|
||||
{i18n._(keyPoint)}
|
||||
</Text>
|
||||
))}
|
||||
</ul>
|
||||
</Column>
|
||||
)}
|
||||
) : (
|
||||
<Spacer />
|
||||
)}
|
||||
</Line>
|
||||
</ColumnStackLayout>
|
||||
</div>
|
||||
</Column>
|
||||
|
@@ -5,14 +5,24 @@ import { I18n } from '@lingui/react';
|
||||
import { ColumnStackLayout, LineStackLayout } from '../../../../UI/Layout';
|
||||
import Text from '../../../../UI/Text';
|
||||
import { CardWidget } from '../CardWidget';
|
||||
import { Column, Line, Spacer } from '../../../../UI/Grid';
|
||||
import { Column, Line } from '../../../../UI/Grid';
|
||||
import ColoredLinearProgress from '../../../../UI/ColoredLinearProgress';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
|
||||
import { type CourseCompletion } from '../UseCourses';
|
||||
import { type Course } from '../../../../Utils/GDevelopServices/Asset';
|
||||
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
|
||||
import { textEllipsisStyle } from '../../../../UI/TextEllipsis';
|
||||
import Skeleton from '@material-ui/lab/Skeleton';
|
||||
import { getProductPriceOrOwnedLabel } from '../../../../AssetStore/ProductPriceTag';
|
||||
import Chip from '../../../../UI/Chip';
|
||||
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
|
||||
|
||||
export const getChipColorFromEnglishLevel = (englishLevel: string) => {
|
||||
if (englishLevel.toLowerCase().includes('advanced')) return '#FF8569';
|
||||
if (englishLevel.toLowerCase().includes('intermediate')) return '#FFBC57';
|
||||
return '#3BF7F4';
|
||||
};
|
||||
|
||||
const styles = {
|
||||
cardTextContainer: {
|
||||
@@ -20,8 +30,11 @@ const styles = {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '8px 16px 8px 16px',
|
||||
// Fix the height so that the card has a consistent height,
|
||||
// When owned or not.
|
||||
minHeight: 220,
|
||||
},
|
||||
image: { width: '100%' },
|
||||
image: { width: '100%', aspectRatio: '16 / 9' },
|
||||
specializationDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
@@ -39,7 +52,6 @@ const styles = {
|
||||
alignItems: 'stretch',
|
||||
aspectRatio: '16 / 9',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
},
|
||||
newLabel: {
|
||||
position: 'absolute',
|
||||
@@ -51,6 +63,9 @@ const styles = {
|
||||
color: 'white',
|
||||
padding: '2px 6px',
|
||||
},
|
||||
chip: {
|
||||
height: 24,
|
||||
},
|
||||
};
|
||||
|
||||
const specializationLabels = {
|
||||
@@ -64,10 +79,15 @@ const specializationColors = {
|
||||
marketing: '#FD3AE6',
|
||||
};
|
||||
|
||||
const getSpecializationConfig = (
|
||||
export const getSpecializationConfig = (
|
||||
specializationId: string
|
||||
): {| label: React.Node, color: string |} => {
|
||||
let label = specializationLabels[specializationId];
|
||||
let label =
|
||||
specializationId === 'loading' ? (
|
||||
<Trans>Loading</Trans>
|
||||
) : (
|
||||
specializationLabels[specializationId]
|
||||
);
|
||||
if (!label) {
|
||||
console.warn(
|
||||
`No label found for specializationId "${specializationId}". Using default label.`
|
||||
@@ -92,10 +112,17 @@ const getSpecializationConfig = (
|
||||
type Props = {|
|
||||
completion: ?CourseCompletion,
|
||||
course: ?Course,
|
||||
courseListingData: ?CourseListingData,
|
||||
onClick?: () => void,
|
||||
|};
|
||||
|
||||
const CourseCard = ({ completion, course, onClick }: Props) => {
|
||||
const CourseCard = ({
|
||||
completion,
|
||||
course,
|
||||
courseListingData,
|
||||
onClick,
|
||||
}: Props) => {
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
const specializationConfig = getSpecializationConfig(
|
||||
course ? course.specializationId : 'loading'
|
||||
);
|
||||
@@ -103,7 +130,7 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<CardWidget onClick={onClick} size={'large'}>
|
||||
{course && onClick ? (
|
||||
{course && courseListingData && onClick ? (
|
||||
<Column expand noMargin noOverflowParent>
|
||||
<div style={styles.imageContainer}>
|
||||
<img
|
||||
@@ -151,25 +178,29 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
|
||||
{specializationConfig.label}
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
{completion && (
|
||||
<LineStackLayout alignItems="center" noMargin>
|
||||
<ColoredLinearProgress
|
||||
value={
|
||||
(completion.completedChapters /
|
||||
completion.chapters) *
|
||||
100
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
displayInlineAsSpan
|
||||
size="body-small"
|
||||
noMargin
|
||||
color="secondary"
|
||||
>
|
||||
{completion.completedChapters}/{completion.chapters}
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
)}
|
||||
<LineStackLayout alignItems="center" noMargin>
|
||||
<ColoredLinearProgress
|
||||
value={
|
||||
completion
|
||||
? (completion.completedChapters /
|
||||
completion.chapters) *
|
||||
100
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
displayInlineAsSpan
|
||||
size="body-small"
|
||||
noMargin
|
||||
color="secondary"
|
||||
>
|
||||
{completion
|
||||
? `${completion.completedChapters}/${
|
||||
completion.chapters
|
||||
}`
|
||||
: '-/-'}
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
|
||||
<Text
|
||||
size="sub-title"
|
||||
@@ -186,18 +217,31 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
|
||||
)}
|
||||
</Text>
|
||||
</ColumnStackLayout>
|
||||
<Line justifyContent="space-between">
|
||||
<Text size="body-small" noMargin color="secondary">
|
||||
{selectMessageByLocale(i18n, course.levelByLocale)}
|
||||
</Text>
|
||||
<Text size="body-small" noMargin color="secondary">
|
||||
{course.durationInWeeks === 1 ? (
|
||||
<Trans>1 week</Trans>
|
||||
) : (
|
||||
<Trans>{course.durationInWeeks} weeks</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Line>
|
||||
<div style={{ color: gdevelopTheme.text.color.secondary }}>
|
||||
<Line justifyContent="space-between" alignItems="flex-end">
|
||||
<Chip
|
||||
style={{
|
||||
...styles.chip,
|
||||
border: `1px solid ${getChipColorFromEnglishLevel(
|
||||
course.levelByLocale.en
|
||||
)}`,
|
||||
}}
|
||||
label={selectMessageByLocale(
|
||||
i18n,
|
||||
course.levelByLocale
|
||||
)}
|
||||
variant="outlined"
|
||||
/>
|
||||
{getProductPriceOrOwnedLabel({
|
||||
i18n,
|
||||
gdevelopTheme,
|
||||
productListingData: courseListingData,
|
||||
usageType: 'default',
|
||||
showBothPrices: 'column',
|
||||
owned: !course.isLocked,
|
||||
})}
|
||||
</Line>
|
||||
</div>
|
||||
</ColumnStackLayout>
|
||||
</div>
|
||||
</Column>
|
||||
@@ -211,10 +255,8 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
|
||||
<Skeleton height={20} />
|
||||
<Skeleton height={20} />
|
||||
<Skeleton height={30} />
|
||||
<Skeleton height={100} />
|
||||
<Skeleton height={50} />
|
||||
<Spacer />
|
||||
<Skeleton height={15} />
|
||||
<Spacer />
|
||||
</Column>
|
||||
</Line>
|
||||
</Column>
|
||||
|
@@ -0,0 +1,328 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { type CourseListingData } 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 { type Course } from '../../../../Utils/GDevelopServices/Asset';
|
||||
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 { LineStackLayout } from '../../../../UI/Layout';
|
||||
import useAlertDialog from '../../../../UI/Alert/useAlertDialog';
|
||||
import { extractGDevelopApiErrorStatusAndCode } from '../../../../Utils/GDevelopServices/Errors';
|
||||
import PasswordPromptDialog from '../../../../AssetStore/PasswordPromptDialog';
|
||||
|
||||
type Props = {|
|
||||
course: Course,
|
||||
courseListingData: CourseListingData,
|
||||
onClose: () => void,
|
||||
|};
|
||||
|
||||
const CoursePurchaseDialog = ({
|
||||
course,
|
||||
courseListingData,
|
||||
onClose,
|
||||
}: Props) => {
|
||||
const {
|
||||
profile,
|
||||
onOpenLoginDialog,
|
||||
onOpenCreateAccountDialog,
|
||||
onPurchaseSuccessful,
|
||||
onRefreshCoursePurchases,
|
||||
coursePurchases,
|
||||
loginState,
|
||||
} = React.useContext(AuthenticatedUserContext);
|
||||
const [isPurchasing, setIsPurchasing] = React.useState(false);
|
||||
const [
|
||||
isCheckingPurchasesAfterLogin,
|
||||
setIsCheckingPurchasesAfterLogin,
|
||||
] = React.useState(loginState === 'loggingIn');
|
||||
const [purchaseSuccessful, setPurchaseSuccessful] = React.useState(false);
|
||||
const [
|
||||
displayPasswordPrompt,
|
||||
setDisplayPasswordPrompt,
|
||||
] = React.useState<boolean>(false);
|
||||
const [password, setPassword] = React.useState<string>('');
|
||||
const { showAlert } = useAlertDialog();
|
||||
|
||||
const onStartPurchase = async () => {
|
||||
if (!profile) return;
|
||||
setDisplayPasswordPrompt(false);
|
||||
|
||||
// Note: we don't handle purchasing a course through the App Store for now.
|
||||
|
||||
const price = courseListingData.prices.find(
|
||||
price => price.usageType === 'default'
|
||||
);
|
||||
if (!price) {
|
||||
console.error('Unable to find the price for the usage type default');
|
||||
await showAlert({
|
||||
title: t`An error happened`,
|
||||
message: t`Unable to find the price for this course. Please try again later.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsPurchasing(true);
|
||||
const checkoutUrl = getPurchaseCheckoutUrl({
|
||||
productId: courseListingData.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 courses.
|
||||
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
|
||||
[]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
const checkIfPurchaseIsDone = async () => {
|
||||
if (
|
||||
isPurchasing &&
|
||||
coursePurchases &&
|
||||
coursePurchases.find(
|
||||
userPurchase => userPurchase.productId === courseListingData.id
|
||||
)
|
||||
) {
|
||||
// We found the purchase, the user has bought the course.
|
||||
// We do not close the dialog yet, as we need to trigger a refresh of the products received.
|
||||
await onPurchaseSuccessful();
|
||||
}
|
||||
};
|
||||
checkIfPurchaseIsDone();
|
||||
},
|
||||
[
|
||||
isPurchasing,
|
||||
coursePurchases,
|
||||
courseListingData,
|
||||
onPurchaseSuccessful,
|
||||
onRefreshCoursePurchases,
|
||||
]
|
||||
);
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
onRefreshCoursePurchases();
|
||||
},
|
||||
isPurchasing ? 3900 : null
|
||||
);
|
||||
|
||||
// Listen to the login state, to know when a user has just logged in and the courses are being fetched.
|
||||
// In this case, start a timeout to remove the loader and give some time for the courses to refresh.
|
||||
React.useEffect(
|
||||
() => {
|
||||
let timeoutId;
|
||||
(async () => {
|
||||
if (loginState === 'done') {
|
||||
timeoutId = setTimeout(
|
||||
() => setIsCheckingPurchasesAfterLogin(false),
|
||||
3000
|
||||
);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
},
|
||||
[loginState]
|
||||
);
|
||||
|
||||
// If the user has received this particular course, 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 (course) {
|
||||
if (!course.isLocked) {
|
||||
if (isPurchasing) {
|
||||
setIsPurchasing(false);
|
||||
setPurchaseSuccessful(true);
|
||||
} else if (!purchaseSuccessful) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[course, courseListingData, isPurchasing, onClose, purchaseSuccessful]
|
||||
);
|
||||
|
||||
const dialogContents = !profile
|
||||
? {
|
||||
subtitle: <Trans>Log-in to purchase this course</Trans>,
|
||||
content: (
|
||||
<CreateProfile
|
||||
onOpenLoginDialog={onOpenLoginDialog}
|
||||
onOpenCreateAccountDialog={onOpenCreateAccountDialog}
|
||||
message={
|
||||
<Trans>
|
||||
Courses will be linked to your user account and available
|
||||
indefinitely. Log in or sign up to purchase this course or
|
||||
restore a previous 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 the course.</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
),
|
||||
}
|
||||
: isPurchasing
|
||||
? {
|
||||
subtitle: <Trans>Complete your payment on the web browser</Trans>,
|
||||
content: (
|
||||
<>
|
||||
<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 assets 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 course {courseListingData.name} will be linked to your account{' '}
|
||||
{profile.email}.
|
||||
</Trans>
|
||||
),
|
||||
content: (
|
||||
<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>{courseListingData.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 CoursePurchaseDialog;
|
@@ -1,12 +1,14 @@
|
||||
// @flow
|
||||
|
||||
import * as React from 'react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import {
|
||||
type CourseChapter,
|
||||
type Course,
|
||||
} from '../../../../Utils/GDevelopServices/Asset';
|
||||
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
|
||||
import SectionContainer from '../SectionContainer';
|
||||
import VideoBasedCourseChapterView from '../../../../Course/VideoBasedCourseChapterView';
|
||||
import TextBasedCourseChapterView from '../../../../Course/TextBasedCourseChapterView';
|
||||
@@ -32,8 +34,7 @@ import {
|
||||
AccordionBody,
|
||||
AccordionHeader,
|
||||
} from '../../../../UI/Accordion';
|
||||
import AnyQuestionDialog from '../AnyQuestionDialog';
|
||||
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
|
||||
import CourseSectionHeader from './CourseSectionHeader';
|
||||
|
||||
const styles = {
|
||||
desktopContainer: { display: 'flex', gap: 16 },
|
||||
@@ -101,7 +102,20 @@ type Props = {|
|
||||
isTaskCompleted: (chapterId: string, taskIndex: number) => boolean,
|
||||
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
|
||||
getCourseCompletion: () => CourseCompletion | null,
|
||||
onBuyCourseChapterWithCredits: (CourseChapter, string) => Promise<void>,
|
||||
onBuyCourseWithCredits: (
|
||||
Course: Course,
|
||||
password: string,
|
||||
i18n: I18nType
|
||||
) => Promise<void>,
|
||||
onBuyCourse: (
|
||||
Course: Course,
|
||||
password: string,
|
||||
i18n: I18nType
|
||||
) => Promise<void>,
|
||||
purchasingCourseListingData: ?CourseListingData,
|
||||
setPurchasingCourseListingData: (CourseListingData | null) => void,
|
||||
simulateAppStoreProduct?: boolean,
|
||||
onOpenAskAi: (mode: 'chat' | 'agent') => void,
|
||||
|};
|
||||
|
||||
const CourseSection = ({
|
||||
@@ -113,15 +127,17 @@ const CourseSection = ({
|
||||
isTaskCompleted,
|
||||
getChapterCompletion,
|
||||
getCourseCompletion,
|
||||
onBuyCourseChapterWithCredits,
|
||||
onBuyCourseWithCredits,
|
||||
onBuyCourse,
|
||||
purchasingCourseListingData,
|
||||
setPurchasingCourseListingData,
|
||||
simulateAppStoreProduct,
|
||||
onOpenAskAi,
|
||||
}: Props) => {
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
const { showAlertMessage, values } = React.useContext(PreferencesContext);
|
||||
const { isMobile, isLandscape } = useResponsiveWindowSize();
|
||||
const courseCompletion = getCourseCompletion();
|
||||
const [isAnyQuestionDialogOpen, setIsAnyQuestionDialogOpen] = React.useState(
|
||||
false
|
||||
);
|
||||
const firstIncompleteChapterIdRef = React.useRef<string | null>(
|
||||
courseChapters.reduce((alreadyFoundIncompleteChapterId, chapter, index) => {
|
||||
if (alreadyFoundIncompleteChapterId)
|
||||
@@ -275,6 +291,19 @@ const CourseSection = ({
|
||||
[scrollToChapter]
|
||||
);
|
||||
|
||||
const onClickUnlock = React.useCallback(
|
||||
() => {
|
||||
// Scroll to the top of the page, where the purchase button is.
|
||||
if (scrollingContainerRef.current) {
|
||||
scrollingContainerRef.current.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
},
|
||||
[scrollingContainerRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
@@ -283,11 +312,6 @@ const CourseSection = ({
|
||||
ref={scrollingContainerRef}
|
||||
applyTopSpacingAsMarginOnChildrenContainer
|
||||
backAction={onBack}
|
||||
title={selectMessageByLocale(i18n, course.titleByLocale)}
|
||||
subtitleText={selectMessageByLocale(
|
||||
i18n,
|
||||
course.shortDescriptionByLocale
|
||||
)}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
@@ -295,6 +319,16 @@ const CourseSection = ({
|
||||
}
|
||||
>
|
||||
<Column noOverflowParent noMargin>
|
||||
<CourseSectionHeader
|
||||
course={course}
|
||||
onBuyCourseWithCredits={onBuyCourseWithCredits}
|
||||
onBuyCourse={onBuyCourse}
|
||||
purchasingCourseListingData={purchasingCourseListingData}
|
||||
setPurchasingCourseListingData={
|
||||
setPurchasingCourseListingData
|
||||
}
|
||||
simulateAppStoreProduct={simulateAppStoreProduct}
|
||||
/>
|
||||
{!values.hiddenAlertMessages[alertMessageKey] && subtitleHint && (
|
||||
<Line>
|
||||
<AlertMessage
|
||||
@@ -310,6 +344,7 @@ const CourseSection = ({
|
||||
chapter.videoUrl ? (
|
||||
<VideoBasedCourseChapterView
|
||||
chapterIndex={index}
|
||||
course={course}
|
||||
courseChapter={chapter}
|
||||
onOpenTemplate={() => {
|
||||
onOpenTemplateFromCourseChapter(chapter);
|
||||
@@ -318,7 +353,7 @@ const CourseSection = ({
|
||||
isTaskCompleted={isTaskCompleted}
|
||||
getChapterCompletion={getChapterCompletion}
|
||||
key={chapter.id}
|
||||
onBuyWithCredits={onBuyCourseChapterWithCredits}
|
||||
onClickUnlock={onClickUnlock}
|
||||
ref={_ref => {
|
||||
if (_ref) {
|
||||
chapterTitleRefs.current[index] = {
|
||||
@@ -331,6 +366,7 @@ const CourseSection = ({
|
||||
) : (
|
||||
<TextBasedCourseChapterView
|
||||
chapterIndex={index}
|
||||
course={course}
|
||||
// $FlowIgnore - Flow does not conclude this chapter can only be text-based.
|
||||
courseChapter={chapter}
|
||||
onOpenTemplate={(templateId?: string) => {
|
||||
@@ -340,7 +376,7 @@ const CourseSection = ({
|
||||
isTaskCompleted={isTaskCompleted}
|
||||
getChapterCompletion={getChapterCompletion}
|
||||
key={chapter.id}
|
||||
onBuyWithCredits={onBuyCourseChapterWithCredits}
|
||||
onClickUnlock={onClickUnlock}
|
||||
ref={_ref => {
|
||||
if (_ref) {
|
||||
chapterTitleRefs.current[index] = {
|
||||
@@ -394,9 +430,9 @@ const CourseSection = ({
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
<RaisedButton
|
||||
label={<Trans>Ask a question</Trans>}
|
||||
primary
|
||||
onClick={() => setIsAnyQuestionDialogOpen(true)}
|
||||
label={<Trans>Ask the AI</Trans>}
|
||||
onClick={() => onOpenAskAi('chat')}
|
||||
/>
|
||||
</ColumnStackLayout>
|
||||
</Paper>
|
||||
@@ -405,11 +441,6 @@ const CourseSection = ({
|
||||
)}
|
||||
</div>
|
||||
</SectionContainer>
|
||||
{isAnyQuestionDialogOpen && (
|
||||
<AnyQuestionDialog
|
||||
onClose={() => setIsAnyQuestionDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{isMobile && !isLandscape && (
|
||||
<div
|
||||
style={{
|
||||
|
@@ -0,0 +1,316 @@
|
||||
// @flow
|
||||
|
||||
import * as React from 'react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { type Course } from '../../../../Utils/GDevelopServices/Asset';
|
||||
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
|
||||
import { SectionRow } from '../SectionContainer';
|
||||
import Paper from '../../../../UI/Paper';
|
||||
import Text from '../../../../UI/Text';
|
||||
import { textEllipsisStyle } from '../../../../UI/TextEllipsis';
|
||||
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 CoursePurchaseDialog from './CoursePurchaseDialog';
|
||||
import {
|
||||
getChipColorFromEnglishLevel,
|
||||
getSpecializationConfig,
|
||||
} from './CourseCard';
|
||||
import { renderProductPrice } from '../../../../AssetStore/ProductPriceTag';
|
||||
import CourseStoreContext from '../../../../Course/CourseStoreContext';
|
||||
import PasswordPromptDialog from '../../../../AssetStore/PasswordPromptDialog';
|
||||
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 Gold from '../../../../Profile/Subscription/Icons/Gold';
|
||||
import Chip from '../../../../UI/Chip';
|
||||
|
||||
const styles = {
|
||||
title: { overflowWrap: 'anywhere', textWrap: 'wrap' },
|
||||
image: { width: 300, aspectRatio: '16 / 9' },
|
||||
specializationDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
},
|
||||
imageContainer: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
paper: {
|
||||
zIndex: 2, // Make sure the paper is above the background for the border effect.
|
||||
flex: 1,
|
||||
},
|
||||
diamondIcon: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
chip: {
|
||||
height: 24,
|
||||
},
|
||||
};
|
||||
|
||||
const ResponsiveDivider = () => {
|
||||
const { isMobile, isMediumScreen } = useResponsiveWindowSize();
|
||||
return isMobile || isMediumScreen ? (
|
||||
<Column noMargin>
|
||||
<Divider orientation="horizontal" />
|
||||
</Column>
|
||||
) : (
|
||||
<Line noMargin>
|
||||
<Divider orientation="vertical" />
|
||||
</Line>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
course: Course,
|
||||
onBuyCourseWithCredits: (
|
||||
Course: Course,
|
||||
password: string,
|
||||
i18n: I18nType
|
||||
) => Promise<void>,
|
||||
onBuyCourse: (
|
||||
Course: Course,
|
||||
password: string,
|
||||
i18n: I18nType
|
||||
) => Promise<void>,
|
||||
purchasingCourseListingData: ?CourseListingData,
|
||||
setPurchasingCourseListingData: (CourseListingData | null) => void,
|
||||
simulateAppStoreProduct?: boolean,
|
||||
|};
|
||||
|
||||
const CourseSectionHeader = ({
|
||||
course,
|
||||
onBuyCourseWithCredits,
|
||||
onBuyCourse,
|
||||
purchasingCourseListingData,
|
||||
setPurchasingCourseListingData,
|
||||
simulateAppStoreProduct,
|
||||
}: Props) => {
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
const { listedCourses } = React.useContext(CourseStoreContext);
|
||||
const { isMobile, isMediumScreen } = useResponsiveWindowSize();
|
||||
|
||||
const [
|
||||
displayPasswordPrompt,
|
||||
setDisplayPasswordPrompt,
|
||||
] = React.useState<boolean>(false);
|
||||
const [password, setPassword] = React.useState<string>('');
|
||||
|
||||
const onClickBuyWithCredits = React.useCallback(
|
||||
async (i18n: I18nType) => {
|
||||
if (!course.isLocked) return;
|
||||
setDisplayPasswordPrompt(false);
|
||||
|
||||
await onBuyCourseWithCredits(course, password, i18n);
|
||||
},
|
||||
[course, onBuyCourseWithCredits, password]
|
||||
);
|
||||
const onWillBuyWithCredits = React.useCallback(
|
||||
async (i18n: I18nType) => {
|
||||
// Password is required in dev environment only so that one cannot freely claim asset packs.
|
||||
if (Window.isDev()) setDisplayPasswordPrompt(true);
|
||||
else onClickBuyWithCredits(i18n);
|
||||
},
|
||||
[onClickBuyWithCredits]
|
||||
);
|
||||
|
||||
const specializationConfig = getSpecializationConfig(
|
||||
course ? course.specializationId : 'loading'
|
||||
);
|
||||
const courseListingData = listedCourses
|
||||
? listedCourses.find(listedCourse => listedCourse.id === course.id)
|
||||
: null;
|
||||
const shouldUseOrSimulateAppStoreProduct =
|
||||
shouldUseAppStoreProduct() || simulateAppStoreProduct;
|
||||
|
||||
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={selectMessageByLocale(i18n, course.imageUrlByLocale)}
|
||||
style={styles.image}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<ColumnStackLayout expand justifyContent="flex-start">
|
||||
{course.includedInSubscriptions.length && (
|
||||
<Line
|
||||
noMargin
|
||||
alignItems="center"
|
||||
justifyContent={
|
||||
isMobile || isMediumScreen ? 'flex-start' : 'flex-end'
|
||||
}
|
||||
>
|
||||
<div className={classes.premiumContainer}>
|
||||
<Paper style={styles.paper} background="medium">
|
||||
<Column>
|
||||
<Line expand alignItems="center" noMargin>
|
||||
<Gold style={styles.diamondIcon} />
|
||||
<Text>
|
||||
<Trans>
|
||||
Included with GDevelop subscriptions
|
||||
</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
</Paper>
|
||||
</div>
|
||||
</Line>
|
||||
)}
|
||||
<Text size="title" noMargin style={styles.title}>
|
||||
{selectMessageByLocale(i18n, course.titleByLocale)}
|
||||
</Text>
|
||||
<Line noMargin>
|
||||
<Text noMargin>
|
||||
{selectMessageByLocale(
|
||||
i18n,
|
||||
course.shortDescriptionByLocale
|
||||
)}
|
||||
</Text>
|
||||
</Line>
|
||||
</ColumnStackLayout>
|
||||
</ResponsiveLineStackLayout>
|
||||
<ResponsiveLineStackLayout
|
||||
expand
|
||||
justifyContent="space-between"
|
||||
forceMobileLayout={isMediumScreen}
|
||||
>
|
||||
<Column justifyContent="center" expand noMargin>
|
||||
<Text>
|
||||
<Trans>{course.chaptersTargetCount} chapters</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
<ResponsiveDivider />
|
||||
<Column
|
||||
justifyContent="center"
|
||||
alignItems="flex-start"
|
||||
expand
|
||||
noMargin
|
||||
>
|
||||
<Chip
|
||||
style={{
|
||||
...styles.chip,
|
||||
border: `1px solid ${getChipColorFromEnglishLevel(
|
||||
course.levelByLocale.en
|
||||
)}`,
|
||||
}}
|
||||
label={selectMessageByLocale(i18n, course.levelByLocale)}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Column>
|
||||
<ResponsiveDivider />
|
||||
<Column justifyContent="center" expand noMargin>
|
||||
<LineStackLayout alignItems="center" noMargin>
|
||||
<span
|
||||
style={{
|
||||
...styles.specializationDot,
|
||||
backgroundColor: specializationConfig.color,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
displayInlineAsSpan
|
||||
size="body-small"
|
||||
noMargin
|
||||
color="secondary"
|
||||
style={textEllipsisStyle}
|
||||
>
|
||||
{specializationConfig.label}
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
</Column>
|
||||
</ResponsiveLineStackLayout>
|
||||
{course.isLocked && (
|
||||
<Paper background="medium" style={{ padding: 16 }}>
|
||||
{!!courseListingData && (
|
||||
<ResponsiveLineStackLayout
|
||||
justifyContent="space-between"
|
||||
noMargin
|
||||
>
|
||||
{!isMobile && !isMediumScreen && (
|
||||
<Column noMargin justifyContent="center">
|
||||
<div
|
||||
style={{
|
||||
color: gdevelopTheme.text.color.secondary,
|
||||
}}
|
||||
>
|
||||
{renderProductPrice({
|
||||
i18n,
|
||||
productListingData: courseListingData,
|
||||
usageType: 'default',
|
||||
showBothPrices: 'line',
|
||||
})}
|
||||
</div>
|
||||
</Column>
|
||||
)}
|
||||
<PurchaseProductButtons
|
||||
i18n={i18n}
|
||||
productListingData={courseListingData}
|
||||
selectedUsageType="default"
|
||||
onUsageTypeChange={() => {}}
|
||||
simulateAppStoreProduct={
|
||||
shouldUseOrSimulateAppStoreProduct
|
||||
}
|
||||
isAlreadyReceived={!course.isLocked}
|
||||
onClickBuy={() => onBuyCourse(course, password, i18n)}
|
||||
onClickBuyWithCredits={() =>
|
||||
onWillBuyWithCredits(i18n)
|
||||
}
|
||||
/>
|
||||
</ResponsiveLineStackLayout>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
</Paper>
|
||||
</SectionRow>
|
||||
{!!purchasingCourseListingData && (
|
||||
<CoursePurchaseDialog
|
||||
course={course}
|
||||
courseListingData={purchasingCourseListingData}
|
||||
onClose={() => setPurchasingCourseListingData(null)}
|
||||
/>
|
||||
)}
|
||||
{displayPasswordPrompt && (
|
||||
<PasswordPromptDialog
|
||||
onApply={() => onClickBuyWithCredits(i18n)}
|
||||
onClose={() => setDisplayPasswordPrompt(false)}
|
||||
passwordValue={password}
|
||||
setPasswordValue={setPassword}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseSectionHeader;
|
@@ -0,0 +1,20 @@
|
||||
.premiumContainer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 2px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.premiumContainer::before {
|
||||
content: '';
|
||||
display: block;
|
||||
background: linear-gradient(90deg, var(--theme-premium-teal) 0%, var(--theme-premium-orange) 100%);
|
||||
width: 100%;
|
||||
padding-bottom: 100%;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
@@ -0,0 +1,122 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { I18n } from '@lingui/react';
|
||||
import SectionContainer, { SectionRow } from '../SectionContainer';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import {
|
||||
type Course,
|
||||
type CourseChapter,
|
||||
} from '../../../../Utils/GDevelopServices/Asset';
|
||||
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 { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { LARGE_WIDGET_SIZE } from '../CardWidget';
|
||||
import CourseCard from './CourseCard';
|
||||
import { getColumnsFromWindowSize } from './Utils';
|
||||
|
||||
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
|
||||
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
|
||||
const ITEMS_SPACING = 5;
|
||||
const styles = {
|
||||
grid: {
|
||||
textAlign: 'center',
|
||||
// Avoid tiles taking too much space on large screens.
|
||||
maxWidth: MAX_SECTION_WIDTH,
|
||||
overflow: 'hidden',
|
||||
width: `calc(100% + ${2 * ITEMS_SPACING}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
onBack: () => void,
|
||||
courses: ?Array<Course>,
|
||||
onSelectCourse: (courseId: string) => void,
|
||||
previewedCourse: ?Course,
|
||||
previewedCourseChapters: ?Array<CourseChapter>,
|
||||
getCourseCompletion: (courseId: string) => CourseCompletion | null,
|
||||
getCourseChapterCompletion: (
|
||||
courseId: string,
|
||||
chapterId: string
|
||||
) => CourseChapterCompletion | null,
|
||||
|};
|
||||
|
||||
const CoursesPage = ({
|
||||
onBack,
|
||||
courses,
|
||||
onSelectCourse,
|
||||
previewedCourse,
|
||||
previewedCourseChapters,
|
||||
getCourseChapterCompletion,
|
||||
getCourseCompletion,
|
||||
}: Props) => {
|
||||
const { listedCourses } = React.useContext(CourseStoreContext);
|
||||
const { windowSize, isLandscape } = useResponsiveWindowSize();
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<SectionContainer
|
||||
title={<Trans>Official Game Dev courses</Trans>}
|
||||
backAction={onBack}
|
||||
>
|
||||
<SectionRow>
|
||||
<CoursePreviewBanner
|
||||
course={previewedCourse}
|
||||
courseChapters={previewedCourseChapters}
|
||||
getCourseCompletion={getCourseCompletion}
|
||||
getCourseChapterCompletion={getCourseChapterCompletion}
|
||||
onDisplayCourse={() => {
|
||||
if (!previewedCourse) return;
|
||||
onSelectCourse(previewedCourse.id);
|
||||
}}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<Line>
|
||||
<GridList
|
||||
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={ITEMS_SPACING * 2}
|
||||
>
|
||||
{courses && listedCourses
|
||||
? courses.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>
|
||||
);
|
||||
})
|
||||
: new Array(6).fill(0).map((_, index) => (
|
||||
<GridListTile key={`skeleton-course-${index}`}>
|
||||
<CourseCard
|
||||
course={null}
|
||||
courseListingData={null}
|
||||
completion={null}
|
||||
/>
|
||||
</GridListTile>
|
||||
))}
|
||||
</GridList>
|
||||
</Line>
|
||||
</SectionRow>
|
||||
</SectionContainer>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursesPage;
|
@@ -0,0 +1,62 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { I18n } from '@lingui/react';
|
||||
import SectionContainer, { SectionRow } from '../SectionContainer';
|
||||
import Text from '../../../../UI/Text';
|
||||
import { LargeSpacer, Line } from '../../../../UI/Grid';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import PreferencesContext from '../../../Preferences/PreferencesContext';
|
||||
import FlatButton from '../../../../UI/FlatButton';
|
||||
import InAppTutorialContext from '../../../../InAppTutorial/InAppTutorialContext';
|
||||
import GuidedLessons from '../InAppTutorials/GuidedLessons';
|
||||
import FlingGame from '../InAppTutorials/FlingGame';
|
||||
|
||||
type Props = {|
|
||||
onBack: () => void,
|
||||
selectInAppTutorial: (tutorialId: string) => void,
|
||||
|};
|
||||
|
||||
const InAppTutorialsPage = ({ onBack, selectInAppTutorial }: Props) => {
|
||||
const {
|
||||
values: { showInAppTutorialDeveloperMode },
|
||||
} = React.useContext(PreferencesContext);
|
||||
const { onLoadInAppTutorialFromLocalFile } = React.useContext(
|
||||
InAppTutorialContext
|
||||
);
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<SectionContainer backAction={onBack}>
|
||||
<SectionRow>
|
||||
<Line justifyContent="space-between" noMargin alignItems="center">
|
||||
<Text noMargin size="section-title">
|
||||
<Trans>In-app Tutorials</Trans>
|
||||
</Text>
|
||||
{showInAppTutorialDeveloperMode && (
|
||||
<FlatButton
|
||||
label={<Trans>Load local lesson</Trans>}
|
||||
onClick={onLoadInAppTutorialFromLocalFile}
|
||||
/>
|
||||
)}
|
||||
</Line>
|
||||
<GuidedLessons selectInAppTutorial={selectInAppTutorial} />
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<Text size="section-title">
|
||||
<Trans>Create and Publish a Fling game</Trans>
|
||||
</Text>
|
||||
<Text size="body" color="secondary" noMargin>
|
||||
<Trans>
|
||||
3-part tutorial to creating and publishing a game from scratch.
|
||||
</Trans>
|
||||
</Text>
|
||||
<LargeSpacer />
|
||||
<FlingGame selectInAppTutorial={selectInAppTutorial} />
|
||||
</SectionRow>
|
||||
</SectionContainer>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default InAppTutorialsPage;
|
@@ -8,10 +8,7 @@ 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 TutorialCategory,
|
||||
type Tutorial,
|
||||
} from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
import { type Tutorial } from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
import SectionContainer, { SectionRow } from '../SectionContainer';
|
||||
import type {
|
||||
Course,
|
||||
@@ -19,51 +16,30 @@ import type {
|
||||
} from '../../../../Utils/GDevelopServices/Asset';
|
||||
import type { CourseCompletion, CourseChapterCompletion } from '../UseCourses';
|
||||
import FlatButton from '../../../../UI/FlatButton';
|
||||
import {
|
||||
useResponsiveWindowSize,
|
||||
type WindowSizeType,
|
||||
} from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { CardWidget, LARGE_WIDGET_SIZE } from '../CardWidget';
|
||||
import GridList from '@material-ui/core/GridList';
|
||||
import GridListTile from '@material-ui/core/GridListTile';
|
||||
import ImageTileRow from '../../../../UI/ImageTileRow';
|
||||
import { formatTutorialToImageTileComponent, TUTORIAL_CATEGORY_TEXTS } from '.';
|
||||
import GuidedLessons from '../InAppTutorials/GuidedLessons';
|
||||
import ArrowRight from '../../../../UI/CustomSvgIcons/ArrowRight';
|
||||
import Upload from '../../../../UI/CustomSvgIcons/Upload';
|
||||
import FlingGame from '../InAppTutorials/FlingGame';
|
||||
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
|
||||
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
|
||||
import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutorials/PrivateTutorialViewDialog';
|
||||
import { EducationCard } from './EducationCard';
|
||||
import InAppTutorialContext from '../../../../InAppTutorial/InAppTutorialContext';
|
||||
import PreferencesContext from '../../../Preferences/PreferencesContext';
|
||||
import RaisedButton from '../../../../UI/RaisedButton';
|
||||
import Help from '../../../../UI/CustomSvgIcons/Help';
|
||||
import AnyQuestionDialog from '../AnyQuestionDialog';
|
||||
import Paper from '../../../../UI/Paper';
|
||||
import CoursePreviewBanner from '../../../../Course/CoursePreviewBanner';
|
||||
import CourseCard from './CourseCard';
|
||||
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
|
||||
import Link from '../../../../UI/Link';
|
||||
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 ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
|
||||
import Carousel from '../../../../UI/Carousel';
|
||||
|
||||
const getColumnsFromWindowSize = (
|
||||
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 NUMBER_OF_SCROLLS = 2; // Number of times the carousel can be scrolled to see all items.
|
||||
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
|
||||
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
|
||||
const ITEMS_SPACING = 5;
|
||||
@@ -82,83 +58,30 @@ const styles = {
|
||||
},
|
||||
};
|
||||
|
||||
const getTutorialsColumnsFromWidth = (
|
||||
windowSize: WindowSizeType,
|
||||
isLandscape: boolean
|
||||
) => {
|
||||
switch (windowSize) {
|
||||
case 'small':
|
||||
return isLandscape ? 5 : 2;
|
||||
case 'medium':
|
||||
return 3;
|
||||
case 'large':
|
||||
return 5;
|
||||
case 'xlarge':
|
||||
return 6;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
type TutorialsRowProps = {|
|
||||
limits: ?Limits,
|
||||
tutorials: Tutorial[],
|
||||
category: TutorialCategory,
|
||||
onSelectCategory: (TutorialCategory | null) => void,
|
||||
onSelectTutorial: (tutorial: Tutorial) => void,
|
||||
|};
|
||||
|
||||
export const TutorialsRow = ({
|
||||
limits,
|
||||
tutorials,
|
||||
category,
|
||||
onSelectCategory,
|
||||
onSelectTutorial,
|
||||
}: TutorialsRowProps) => (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<ImageTileRow
|
||||
title={TUTORIAL_CATEGORY_TEXTS[category].title}
|
||||
description={TUTORIAL_CATEGORY_TEXTS[category].description}
|
||||
items={tutorials
|
||||
.filter(tutorial => tutorial.category === category)
|
||||
.map(tutorial =>
|
||||
formatTutorialToImageTileComponent({
|
||||
i18n,
|
||||
limits,
|
||||
tutorial,
|
||||
onSelectTutorial,
|
||||
})
|
||||
)}
|
||||
onShowAll={() => onSelectCategory(category)}
|
||||
showAllIcon={<ArrowRight fontSize="small" />}
|
||||
getColumnsFromWindowSize={getTutorialsColumnsFromWidth}
|
||||
getLimitFromWindowSize={getTutorialsColumnsFromWidth}
|
||||
/>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
onTabChange: (tab: HomeTab) => void,
|
||||
onSelectCategory: (TutorialCategory | null) => void,
|
||||
tutorials: Array<Tutorial>,
|
||||
onSelectCategory: (category: LearnCategory) => void,
|
||||
selectInAppTutorial: (tutorialId: string) => void,
|
||||
previewedCourse: ?Course,
|
||||
courses: ?(Course[]),
|
||||
previewedCourseChapters: ?(CourseChapter[]),
|
||||
onSelectCourse: (courseId: string | null) => void,
|
||||
onSelectCourse: (courseId: string) => void,
|
||||
getCourseCompletion: (courseId: string) => CourseCompletion | null,
|
||||
getCourseChapterCompletion: (
|
||||
courseId: string,
|
||||
chapterId: string
|
||||
) => CourseChapterCompletion | null,
|
||||
onOpenAskAi: (mode: 'chat' | 'agent') => void,
|
||||
onOpenNewProjectSetupDialog: () => void,
|
||||
onSelectPrivateGameTemplateListingData: (
|
||||
privateGameTemplateListingData: PrivateGameTemplateListingData
|
||||
) => void,
|
||||
onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void,
|
||||
|};
|
||||
|
||||
const MainPage = ({
|
||||
onTabChange,
|
||||
onSelectCategory,
|
||||
tutorials,
|
||||
selectInAppTutorial,
|
||||
previewedCourse,
|
||||
courses,
|
||||
@@ -166,21 +89,17 @@ const MainPage = ({
|
||||
onSelectCourse,
|
||||
getCourseCompletion,
|
||||
getCourseChapterCompletion,
|
||||
onOpenAskAi,
|
||||
onOpenNewProjectSetupDialog,
|
||||
onSelectPrivateGameTemplateListingData,
|
||||
onSelectExampleShortHeader,
|
||||
}: Props) => {
|
||||
const { limits } = React.useContext(AuthenticatedUserContext);
|
||||
const { onLoadInAppTutorialFromLocalFile } = React.useContext(
|
||||
InAppTutorialContext
|
||||
);
|
||||
const {
|
||||
palette: { type: paletteType },
|
||||
} = React.useContext(GDevelopThemeContext);
|
||||
|
||||
const [isAnyQuestionDialogOpen, setIsAnyQuestionDialogOpen] = React.useState(
|
||||
false
|
||||
);
|
||||
const {
|
||||
values: { showInAppTutorialDeveloperMode },
|
||||
} = React.useContext(PreferencesContext);
|
||||
const { listedCourses } = React.useContext(CourseStoreContext);
|
||||
const {
|
||||
windowSize,
|
||||
isMobile,
|
||||
@@ -188,6 +107,17 @@ const MainPage = ({
|
||||
isMediumScreen,
|
||||
} = useResponsiveWindowSize();
|
||||
|
||||
const displayedCourses = React.useMemo(
|
||||
() => {
|
||||
if (!courses) return null;
|
||||
const numberOfColumnsToScroll =
|
||||
getColumnsFromWindowSize(windowSize, isLandscape) *
|
||||
(NUMBER_OF_SCROLLS + 1);
|
||||
return courses.slice(0, numberOfColumnsToScroll);
|
||||
},
|
||||
[courses, windowSize, isLandscape]
|
||||
);
|
||||
|
||||
const [
|
||||
selectedTutorial,
|
||||
setSelectedTutorial,
|
||||
@@ -217,312 +147,296 @@ const MainPage = ({
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<SectionContainer
|
||||
title={<Trans>Your learning journey starts here</Trans>}
|
||||
customPaperStyle={{
|
||||
backgroundAttachment: 'local',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'top',
|
||||
backgroundSize: isMobile && !isLandscape ? 'contain' : 'auto',
|
||||
backgroundImage: `url('res/premium/premium_dialog_background.png'),${
|
||||
paletteType === 'dark'
|
||||
? 'linear-gradient(180deg, #322659 0px, #3F2458 20px, #1D1D26 200px, #1D1D26 100%)'
|
||||
: 'linear-gradient(180deg, #CBBAFF 0px, #DEBBFF 20px, #F5F5F7 200px, #F5F5F7 100%)'
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<SectionRow>
|
||||
<CoursePreviewBanner
|
||||
course={previewedCourse}
|
||||
courseChapters={previewedCourseChapters}
|
||||
getCourseCompletion={getCourseCompletion}
|
||||
getCourseChapterCompletion={getCourseChapterCompletion}
|
||||
onDisplayCourse={() => {
|
||||
if (!previewedCourse) return;
|
||||
onSelectCourse(previewedCourse.id);
|
||||
onSelectCategory('course');
|
||||
}}
|
||||
/>
|
||||
</SectionRow>
|
||||
const numberOfColumns = getColumnsFromWindowSize(windowSize, isLandscape);
|
||||
|
||||
<SectionRow>
|
||||
<Text size="title">
|
||||
<Trans>GameDev official specialization courses</Trans>
|
||||
</Text>
|
||||
<Line>
|
||||
<GridList
|
||||
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={ITEMS_SPACING * 2}
|
||||
>
|
||||
{courses
|
||||
? courses.map(course => {
|
||||
const completion = getCourseCompletion(course.id);
|
||||
return (
|
||||
<GridListTile key={course.id}>
|
||||
<CourseCard
|
||||
course={course}
|
||||
completion={completion}
|
||||
onClick={() => {
|
||||
onSelectCourse(course.id);
|
||||
onSelectCategory('course');
|
||||
}}
|
||||
/>
|
||||
</GridListTile>
|
||||
);
|
||||
})
|
||||
: new Array(5).fill(0).map((_, index) => (
|
||||
<GridListTile key={`skeleton-course-${index}`}>
|
||||
<CourseCard course={null} completion={null} />
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<SectionContainer
|
||||
chipText={<Trans>Start for free</Trans>}
|
||||
title={<Trans>Official Game Dev courses</Trans>}
|
||||
titleAdornment={
|
||||
<Line noMargin justifyContent="flex-end">
|
||||
<FlatButton
|
||||
onClick={() => onSelectCategory('all-courses')}
|
||||
label={<Trans>See all</Trans>}
|
||||
rightIcon={<ArrowRight fontSize="small" />}
|
||||
/>
|
||||
</Line>
|
||||
}
|
||||
subtitleText={
|
||||
<Trans>
|
||||
Break into the{' '}
|
||||
<Link
|
||||
href={'https://gdevelop.io/blog/indie-mobile-creators-2025'}
|
||||
onClick={() =>
|
||||
Window.openExternalURL(
|
||||
'https://gdevelop.io/blog/indie-mobile-creators-2025'
|
||||
)
|
||||
}
|
||||
>
|
||||
booming industry
|
||||
</Link>{' '}
|
||||
of casual gaming. Sharpen your skills and become a professional.
|
||||
Start for free:
|
||||
</Trans>
|
||||
}
|
||||
customPaperStyle={{
|
||||
backgroundAttachment: 'local',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'top',
|
||||
backgroundSize: isMobile && !isLandscape ? 'contain' : 'auto',
|
||||
backgroundImage: `url('res/premium/premium_dialog_background.png'),${
|
||||
paletteType === 'dark'
|
||||
? 'linear-gradient(180deg, #322659 0px, #3F2458 20px, #1D1D26 200px, #1D1D26 100%)'
|
||||
: 'linear-gradient(180deg, #CBBAFF 0px, #DEBBFF 20px, #F5F5F7 200px, #F5F5F7 100%)'
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<SectionRow>
|
||||
<Line>
|
||||
<Carousel
|
||||
items={
|
||||
displayedCourses && listedCourses
|
||||
? displayedCourses.map(course => {
|
||||
const completion = getCourseCompletion(course.id);
|
||||
const courseListingData = listedCourses.find(
|
||||
listedCourse => listedCourse.id === course.id
|
||||
);
|
||||
return {
|
||||
renderItem: () => (
|
||||
<GridListTile key={course.id}>
|
||||
<CourseCard
|
||||
course={course}
|
||||
courseListingData={courseListingData}
|
||||
completion={completion}
|
||||
onClick={() => {
|
||||
onSelectCourse(course.id);
|
||||
}}
|
||||
/>
|
||||
</GridListTile>
|
||||
),
|
||||
};
|
||||
})
|
||||
: new Array(6).fill(0).map((_, index) => ({
|
||||
renderItem: () => (
|
||||
<GridListTile key={`skeleton-course-${index}`}>
|
||||
<CourseCard
|
||||
course={null}
|
||||
courseListingData={null}
|
||||
completion={null}
|
||||
/>
|
||||
</GridListTile>
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Line>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<LineStackLayout
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
noMargin
|
||||
expand
|
||||
>
|
||||
<Column noMargin>
|
||||
<Text size="section-title">
|
||||
<Trans>In-app tutorials</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
<Column noMargin>
|
||||
<FlatButton
|
||||
onClick={() => onSelectCategory('in-app-tutorials')}
|
||||
label={<Trans>See all</Trans>}
|
||||
rightIcon={<ArrowRight fontSize="small" />}
|
||||
/>
|
||||
</Column>
|
||||
</LineStackLayout>
|
||||
<GuidedLessons
|
||||
selectInAppTutorial={selectInAppTutorial}
|
||||
displayAsCarousel
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="all-tutorials"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<LineStackLayout
|
||||
noMargin
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Column noMargin expand>
|
||||
<Text size="section-title">
|
||||
<Trans>Learn by dissecting ready-made games</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
<Column noMargin expand>
|
||||
<Line noMargin justifyContent="flex-end">
|
||||
<FlatButton
|
||||
onClick={onOpenNewProjectSetupDialog}
|
||||
label={<Trans>See all</Trans>}
|
||||
rightIcon={<ArrowRight fontSize="small" />}
|
||||
/>
|
||||
</Line>
|
||||
</Column>
|
||||
</LineStackLayout>
|
||||
<Spacer />
|
||||
<ExampleStore
|
||||
onSelectExampleShortHeader={onSelectExampleShortHeader}
|
||||
onSelectPrivateGameTemplateListingData={
|
||||
onSelectPrivateGameTemplateListingData
|
||||
}
|
||||
i18n={i18n}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
hideSearch
|
||||
onlyShowGames
|
||||
hidePremiumTemplates
|
||||
limitRowsTo={1}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="full-game"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<LineStackLayout
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
noMargin
|
||||
expand
|
||||
>
|
||||
<Column noMargin>
|
||||
<Text size="section-title">
|
||||
<Trans>Want to know more?</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
<LineStackLayout noMargin>
|
||||
{!isMobile && (
|
||||
<FlatButton
|
||||
onClick={() => {
|
||||
Window.openExternalURL(
|
||||
'https://github.com/GDevelopApp/GDevelop-examples/issues/new/choose'
|
||||
);
|
||||
}}
|
||||
primary
|
||||
leftIcon={<Upload />}
|
||||
label={
|
||||
isMediumScreen ? (
|
||||
<Trans>Submit an example</Trans>
|
||||
) : (
|
||||
<Trans>Submit your project as an example</Trans>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<FlatButton
|
||||
onClick={() => {
|
||||
Window.openExternalURL(
|
||||
'https://airtable.com/shrv295oHlsuS69el'
|
||||
);
|
||||
}}
|
||||
primary
|
||||
leftIcon={<TranslateIcon />}
|
||||
label={
|
||||
isMediumScreen ? (
|
||||
<Trans>Submit a tutorial</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Submit a tutorial translated in your language
|
||||
</Trans>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</LineStackLayout>
|
||||
</LineStackLayout>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<ColumnStackLayout noMargin expand>
|
||||
<Line noMargin>
|
||||
<GridList
|
||||
cols={numberOfColumns}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={ITEMS_SPACING * 2}
|
||||
>
|
||||
<GridListTile cols={1} style={{ background: 'transparent' }}>
|
||||
<Paper
|
||||
background="light"
|
||||
style={{ display: 'flex', height: '100%' }}
|
||||
>
|
||||
<Column expand>
|
||||
<Line expand alignItems="flex-start">
|
||||
<Help />
|
||||
<ColumnStackLayout expand alignItems="flex-start">
|
||||
<Text noMargin size="block-title" align="left">
|
||||
<Trans>Blocked on GDevelop?</Trans>
|
||||
</Text>
|
||||
<RaisedButton
|
||||
size="large"
|
||||
color="success"
|
||||
label={<Trans>Ask the AI</Trans>}
|
||||
rightIcon={<ArrowRight />}
|
||||
onClick={() => onOpenAskAi('chat')}
|
||||
/>
|
||||
</ColumnStackLayout>
|
||||
</Line>
|
||||
</Column>
|
||||
</Paper>
|
||||
</GridListTile>
|
||||
))}
|
||||
</GridList>
|
||||
</Line>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<Line justifyContent="space-between" noMargin alignItems="center">
|
||||
<Text noMargin size="title">
|
||||
<Trans>Guided lessons</Trans>
|
||||
</Text>
|
||||
{showInAppTutorialDeveloperMode && (
|
||||
<FlatButton
|
||||
label={<Trans>Load local lesson</Trans>}
|
||||
onClick={onLoadInAppTutorialFromLocalFile}
|
||||
{helpItems.map((helpItem, index) => (
|
||||
<GridListTile key={index}>
|
||||
<CardWidget
|
||||
onClick={helpItem.action}
|
||||
key={index}
|
||||
size="large"
|
||||
disabled={helpItem.disabled}
|
||||
useDefaultDisabledStyle
|
||||
>
|
||||
<div style={styles.helpItem}>
|
||||
<ColumnStackLayout
|
||||
expand
|
||||
justifyContent="center"
|
||||
useFullHeight
|
||||
>
|
||||
<Text noMargin size="block-title">
|
||||
{helpItem.title}
|
||||
</Text>
|
||||
<Text noMargin size="body" color="secondary">
|
||||
{helpItem.description}
|
||||
</Text>
|
||||
</ColumnStackLayout>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
))}
|
||||
</GridList>
|
||||
</Line>
|
||||
</ColumnStackLayout>
|
||||
</SectionRow>
|
||||
{selectedTutorial && (
|
||||
<PrivateTutorialViewDialog
|
||||
tutorial={selectedTutorial}
|
||||
onClose={() => setSelectedTutorial(null)}
|
||||
/>
|
||||
)}
|
||||
</Line>
|
||||
<GuidedLessons selectInAppTutorial={selectInAppTutorial} />
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<ColumnStackLayout noMargin expand>
|
||||
<Line noMargin>
|
||||
<GridList
|
||||
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={ITEMS_SPACING * 2}
|
||||
>
|
||||
{limits &&
|
||||
limits.quotas['ask-question'] &&
|
||||
limits.quotas['ask-question'].max > 0 ? (
|
||||
<GridListTile cols={2} style={{ background: 'transparent' }}>
|
||||
<Paper
|
||||
background="light"
|
||||
style={{ display: 'flex', height: '100%' }}
|
||||
>
|
||||
<Column expand>
|
||||
<Line expand alignItems="flex-start">
|
||||
<Help />
|
||||
<ColumnStackLayout expand alignItems="flex-start">
|
||||
<Text noMargin size="block-title">
|
||||
<Trans>Blocked on GDevelop?</Trans>
|
||||
</Text>
|
||||
<RaisedButton
|
||||
label={
|
||||
<Trans>Ask any question, get an answer</Trans>
|
||||
}
|
||||
size="medium"
|
||||
color="success"
|
||||
onClick={() => {
|
||||
setIsAnyQuestionDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
</ColumnStackLayout>
|
||||
</Line>
|
||||
</Column>
|
||||
</Paper>
|
||||
</GridListTile>
|
||||
) : null}
|
||||
{helpItems.map((helpItem, index) => (
|
||||
<GridListTile key={index}>
|
||||
<CardWidget
|
||||
onClick={helpItem.action}
|
||||
key={index}
|
||||
size="large"
|
||||
disabled={helpItem.disabled}
|
||||
useDefaultDisabledStyle
|
||||
>
|
||||
<div style={styles.helpItem}>
|
||||
<ColumnStackLayout
|
||||
expand
|
||||
justifyContent="center"
|
||||
useFullHeight
|
||||
>
|
||||
<Text noMargin size="block-title">
|
||||
{helpItem.title}
|
||||
</Text>
|
||||
<Text noMargin size="body" color="secondary">
|
||||
{helpItem.description}
|
||||
</Text>
|
||||
</ColumnStackLayout>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
))}
|
||||
</GridList>
|
||||
</Line>
|
||||
</ColumnStackLayout>
|
||||
</SectionRow>
|
||||
<>
|
||||
<SectionRow>
|
||||
<Line noMargin>
|
||||
<Text size="title">
|
||||
<Trans>Courses</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
<Line noMargin>
|
||||
<Text noMargin>
|
||||
<Trans>Learn everything about GDevelop from the ground up</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
{limits &&
|
||||
limits.capabilities.classrooms &&
|
||||
limits.capabilities.classrooms.hideUpgradeNotice ? null : (
|
||||
<>
|
||||
<Spacer />
|
||||
<EducationCard
|
||||
onSeeResources={() => onSelectCategory('education-curriculum')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="official-beginner"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
tutorials={tutorials}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="official-intermediate"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
tutorials={tutorials}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="official-advanced"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
tutorials={tutorials}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<Text noMargin size="section-title">
|
||||
<Trans>Create and Publish a Fling game</Trans>
|
||||
</Text>
|
||||
<Text size="body" color="secondary" noMargin>
|
||||
<Trans>
|
||||
3-part tutorial to creating and publishing a game from scratch.
|
||||
</Trans>
|
||||
</Text>
|
||||
<FlingGame selectInAppTutorial={selectInAppTutorial} />
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<LineStackLayout
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
noMargin
|
||||
expand
|
||||
>
|
||||
<Column noMargin>
|
||||
<Text size="title">
|
||||
<Trans>Guides and tutorials</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
<LineStackLayout noMargin>
|
||||
{!isMobile && (
|
||||
<FlatButton
|
||||
onClick={() => {
|
||||
Window.openExternalURL(
|
||||
'https://github.com/GDevelopApp/GDevelop-examples/issues/new/choose'
|
||||
);
|
||||
}}
|
||||
primary
|
||||
leftIcon={<Upload />}
|
||||
label={
|
||||
isMediumScreen ? (
|
||||
<Trans>Submit an example</Trans>
|
||||
) : (
|
||||
<Trans>Submit your project as an example</Trans>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<FlatButton
|
||||
onClick={() => {
|
||||
Window.openExternalURL(
|
||||
'https://airtable.com/shrv295oHlsuS69el'
|
||||
);
|
||||
}}
|
||||
primary
|
||||
leftIcon={<TranslateIcon />}
|
||||
label={
|
||||
isMediumScreen ? (
|
||||
<Trans>Submit a tutorial</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Submit a tutorial translated in your language
|
||||
</Trans>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</LineStackLayout>
|
||||
</LineStackLayout>
|
||||
<Line noMargin>
|
||||
<Text noMargin>
|
||||
<Trans>Learn by doing</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="education-curriculum"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
tutorials={tutorials}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="full-game"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
tutorials={tutorials}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="game-mechanic"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
tutorials={tutorials}
|
||||
/>
|
||||
</SectionRow>
|
||||
{selectedTutorial && (
|
||||
<PrivateTutorialViewDialog
|
||||
tutorial={selectedTutorial}
|
||||
onClose={() => setSelectedTutorial(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{isAnyQuestionDialogOpen && (
|
||||
<AnyQuestionDialog onClose={() => setIsAnyQuestionDialogOpen(false)} />
|
||||
</SectionContainer>
|
||||
)}
|
||||
</SectionContainer>
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -8,7 +8,10 @@ import {
|
||||
type Tutorial,
|
||||
type TutorialCategory,
|
||||
} from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
import { formatTutorialToImageTileComponent, TUTORIAL_CATEGORY_TEXTS } from '.';
|
||||
import {
|
||||
formatTutorialToImageTileComponent,
|
||||
TUTORIAL_CATEGORY_TEXTS,
|
||||
} from './Utils';
|
||||
import ImageTileGrid from '../../../../UI/ImageTileGrid';
|
||||
import { type WindowSizeType } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
|
||||
@@ -16,6 +19,8 @@ import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutoria
|
||||
import EducationCurriculumLesson from './EducationCurriculumLesson';
|
||||
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
|
||||
import Text from '../../../../UI/Text';
|
||||
import { TutorialContext } from '../../../../Tutorial/TutorialContext';
|
||||
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
|
||||
|
||||
const styles = {
|
||||
educationCurriculumTutorialContainer: {
|
||||
@@ -126,7 +131,7 @@ const getColumnsFromWindowSize = (windowSize: WindowSizeType) => {
|
||||
case 'medium':
|
||||
return 3;
|
||||
case 'large':
|
||||
return 5;
|
||||
return 4;
|
||||
case 'xlarge':
|
||||
return 6;
|
||||
default:
|
||||
@@ -136,7 +141,6 @@ const getColumnsFromWindowSize = (windowSize: WindowSizeType) => {
|
||||
|
||||
type Props = {|
|
||||
onBack: () => void,
|
||||
tutorials: Array<Tutorial>,
|
||||
category: TutorialCategory,
|
||||
onOpenTemplateFromTutorial: string => Promise<void>,
|
||||
onSelectCourse: (courseId: string) => void,
|
||||
@@ -144,22 +148,32 @@ type Props = {|
|
||||
|
||||
const TutorialsCategoryPage = ({
|
||||
category,
|
||||
tutorials,
|
||||
onBack,
|
||||
onOpenTemplateFromTutorial,
|
||||
onSelectCourse,
|
||||
}: Props) => {
|
||||
const { limits } = React.useContext(AuthenticatedUserContext);
|
||||
const { tutorials } = React.useContext(TutorialContext);
|
||||
const texts = TUTORIAL_CATEGORY_TEXTS[category];
|
||||
const filteredTutorials = tutorials.filter(
|
||||
tutorial => tutorial.category === category
|
||||
);
|
||||
const filteredTutorials = tutorials
|
||||
? tutorials.filter(tutorial => tutorial.category === category)
|
||||
: null;
|
||||
|
||||
const [
|
||||
selectedTutorial,
|
||||
setSelectedTutorial,
|
||||
] = React.useState<Tutorial | null>(null);
|
||||
|
||||
if (!filteredTutorials) {
|
||||
return (
|
||||
<SectionContainer flexBody>
|
||||
<SectionRow expand>
|
||||
<PlaceholderLoader />
|
||||
</SectionRow>
|
||||
</SectionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
|
@@ -0,0 +1,92 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { I18n } from '@lingui/react';
|
||||
import SectionContainer, { SectionRow } from '../SectionContainer';
|
||||
import { type Tutorial } from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
|
||||
import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutorials/PrivateTutorialViewDialog';
|
||||
import TutorialsRow from './TutorialsRow';
|
||||
import { getColumnsFromWindowSize, type LearnCategory } from './Utils';
|
||||
|
||||
type Props = {|
|
||||
onSelectCategory: (category: LearnCategory) => void,
|
||||
|};
|
||||
|
||||
const TutorialsPage = ({ onSelectCategory }: Props) => {
|
||||
const { limits } = React.useContext(AuthenticatedUserContext);
|
||||
const [
|
||||
selectedTutorial,
|
||||
setSelectedTutorial,
|
||||
] = React.useState<Tutorial | null>(null);
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<SectionContainer backAction={() => onSelectCategory(null)}>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="official-beginner"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="official-intermediate"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="official-advanced"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="education-curriculum"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="full-game"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="game-mechanic"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
{selectedTutorial && (
|
||||
<PrivateTutorialViewDialog
|
||||
tutorial={selectedTutorial}
|
||||
onClose={() => setSelectedTutorial(null)}
|
||||
/>
|
||||
)}
|
||||
</SectionContainer>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default TutorialsPage;
|
@@ -0,0 +1,130 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import {
|
||||
type TutorialCategory,
|
||||
type Tutorial,
|
||||
} from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
import { type WindowSizeType } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import ImageTileRow from '../../../../UI/ImageTileRow';
|
||||
import ArrowRight from '../../../../UI/CustomSvgIcons/ArrowRight';
|
||||
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
|
||||
import {
|
||||
formatTutorialToImageTileComponent,
|
||||
TUTORIAL_CATEGORY_TEXTS,
|
||||
type LearnCategory,
|
||||
} from './Utils';
|
||||
import { TutorialContext } from '../../../../Tutorial/TutorialContext';
|
||||
import Paper from '../../../../UI/Paper';
|
||||
import PlaceholderError from '../../../../UI/PlaceholderError';
|
||||
|
||||
const styles = {
|
||||
paper: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
},
|
||||
};
|
||||
|
||||
type TutorialsRowProps = {|
|
||||
limits: ?Limits,
|
||||
category: TutorialCategory | 'all-tutorials',
|
||||
onSelectCategory: (category: LearnCategory) => void,
|
||||
onSelectTutorial: (tutorial: Tutorial) => void,
|
||||
getColumnsFromWindowSize: (
|
||||
windowSize: WindowSizeType,
|
||||
isLandscape: boolean
|
||||
) => number,
|
||||
|};
|
||||
|
||||
export const TutorialsRow = ({
|
||||
limits,
|
||||
category,
|
||||
onSelectCategory,
|
||||
onSelectTutorial,
|
||||
getColumnsFromWindowSize,
|
||||
}: TutorialsRowProps) => {
|
||||
const {
|
||||
tutorials,
|
||||
error: tutorialLoadingError,
|
||||
fetchTutorials,
|
||||
} = React.useContext(TutorialContext);
|
||||
const title =
|
||||
category === 'all-tutorials' ? (
|
||||
<Trans>Public tutorials</Trans>
|
||||
) : (
|
||||
TUTORIAL_CATEGORY_TEXTS[category].title
|
||||
);
|
||||
const description =
|
||||
category === 'all-tutorials'
|
||||
? null
|
||||
: TUTORIAL_CATEGORY_TEXTS[category].description;
|
||||
|
||||
const getItems = (i18n: I18nType) => {
|
||||
if (!tutorials) return [];
|
||||
|
||||
let filteredTutorials = tutorials;
|
||||
// If category is all, show 2 of each official category.
|
||||
if (category === 'all-tutorials') {
|
||||
const officialBeginnerTutorials = tutorials
|
||||
.filter(tutorial => tutorial.category === 'official-beginner')
|
||||
.slice(0, 2);
|
||||
const officialIntermediateTutorials = tutorials
|
||||
.filter(tutorial => tutorial.category === 'official-intermediate')
|
||||
.slice(0, 2);
|
||||
const officialAdvancedTutorials = tutorials
|
||||
.filter(tutorial => tutorial.category === 'official-advanced')
|
||||
.slice(0, 2);
|
||||
filteredTutorials = [
|
||||
...officialBeginnerTutorials,
|
||||
...officialIntermediateTutorials,
|
||||
...officialAdvancedTutorials,
|
||||
];
|
||||
} else {
|
||||
filteredTutorials = tutorials.filter(
|
||||
tutorial => tutorial.category === category
|
||||
);
|
||||
}
|
||||
return filteredTutorials.map(tutorial =>
|
||||
formatTutorialToImageTileComponent({
|
||||
i18n,
|
||||
limits,
|
||||
tutorial,
|
||||
onSelectTutorial,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
if (tutorialLoadingError) {
|
||||
return (
|
||||
<Paper square style={styles.paper} background="dark">
|
||||
<PlaceholderError onRetry={fetchTutorials}>
|
||||
<Trans>
|
||||
Can't load the tutorials. Verify your internet connection or retry
|
||||
later.
|
||||
</Trans>
|
||||
</PlaceholderError>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<ImageTileRow
|
||||
title={title}
|
||||
description={description}
|
||||
items={getItems(i18n)}
|
||||
isLoading={!tutorials}
|
||||
onShowAll={() => onSelectCategory(category)}
|
||||
showAllIcon={<ArrowRight fontSize="small" />}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
getLimitFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default TutorialsRow;
|
@@ -0,0 +1,156 @@
|
||||
// @flow
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import * as React from 'react';
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import {
|
||||
canAccessTutorial,
|
||||
type TutorialCategory,
|
||||
type Tutorial,
|
||||
} from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
import { type WindowSizeType } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
|
||||
import { type ImageTileComponent } from '../../../../UI/ImageTileGrid';
|
||||
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
|
||||
import { sendTutorialOpened } from '../../../../Utils/Analytics/EventSender';
|
||||
import Window from '../../../../Utils/Window';
|
||||
import { formatDuration } from '../../../../Utils/Duration';
|
||||
|
||||
export type LearnCategory =
|
||||
| TutorialCategory
|
||||
| null
|
||||
| 'all-tutorials'
|
||||
| 'all-courses'
|
||||
| 'in-app-tutorials';
|
||||
|
||||
export const TUTORIAL_CATEGORY_TEXTS = {
|
||||
'full-game': {
|
||||
title: <Trans>Make an entire game</Trans>,
|
||||
description: <Trans>Make complete games step by step</Trans>,
|
||||
},
|
||||
'game-mechanic': {
|
||||
title: <Trans>Specific game mechanics</Trans>,
|
||||
description: (
|
||||
<Trans>
|
||||
Find how to implement the most common game mechanics and more
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
'official-beginner': {
|
||||
title: <Trans>Beginner course</Trans>,
|
||||
description: <Trans>Learn the fundamental principles of GDevelop</Trans>,
|
||||
},
|
||||
'official-intermediate': {
|
||||
title: <Trans>Intermediate course</Trans>,
|
||||
description: (
|
||||
<Trans>Learn all the game-building mechanics of GDevelop</Trans>
|
||||
),
|
||||
},
|
||||
'official-advanced': {
|
||||
title: <Trans>Advanced course</Trans>,
|
||||
description: <Trans>The icing on the cake</Trans>,
|
||||
},
|
||||
'education-curriculum': {
|
||||
title: <Trans>Education curriculum and resources</Trans>,
|
||||
description: (
|
||||
<Trans>
|
||||
For teachers and educators having the GDevelop Education subscription.
|
||||
Ready to use resources for teaching.
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
course: {
|
||||
title: <Trans>Loading</Trans>,
|
||||
description: <Trans>Loading course...</Trans>,
|
||||
},
|
||||
recommendations: {
|
||||
title: <Trans>Recommendations</Trans>,
|
||||
description: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const getColumnsFromWindowSize = (
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
export const getChipColorFromTutorialCategory = (
|
||||
category: TutorialCategory
|
||||
): string | null => {
|
||||
if (category === 'official-beginner') return '#3BF7F4';
|
||||
if (category === 'official-intermediate') return '#FFBC57';
|
||||
if (category === 'official-advanced') return '#FF8569';
|
||||
if (category === 'full-game') return '#FFBC57';
|
||||
if (category === 'game-mechanic') return '#FFBC57';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getChipTextFromTutorialCategory = (
|
||||
category: TutorialCategory,
|
||||
i18n: I18nType
|
||||
) => {
|
||||
if (category === 'official-beginner') return i18n._(t`Beginner`);
|
||||
if (category === 'official-intermediate') return i18n._(t`Intermediate`);
|
||||
if (category === 'official-advanced') return i18n._(t`Advanced`);
|
||||
if (category === 'full-game') return i18n._(t`Intermediate`);
|
||||
if (category === 'game-mechanic') return i18n._(t`Intermediate`);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
type FormatTutorialToImageTileComponentProps = {|
|
||||
i18n: I18nType,
|
||||
limits: ?Limits,
|
||||
tutorial: Tutorial,
|
||||
onSelectTutorial: (tutorial: Tutorial) => void,
|
||||
|};
|
||||
|
||||
export const formatTutorialToImageTileComponent = ({
|
||||
i18n,
|
||||
tutorial,
|
||||
limits,
|
||||
onSelectTutorial,
|
||||
}: FormatTutorialToImageTileComponentProps): ImageTileComponent => {
|
||||
const isLocked = !canAccessTutorial(
|
||||
tutorial,
|
||||
limits ? limits.capabilities : null
|
||||
);
|
||||
return {
|
||||
title:
|
||||
selectMessageByLocale(i18n, tutorial.titleByLocale) || tutorial.title,
|
||||
description:
|
||||
selectMessageByLocale(i18n, tutorial.descriptionByLocale) ||
|
||||
tutorial.description,
|
||||
isLocked,
|
||||
onClick: () => {
|
||||
if (tutorial.isPrivateTutorial) {
|
||||
onSelectTutorial(tutorial);
|
||||
return;
|
||||
}
|
||||
|
||||
sendTutorialOpened(tutorial.id);
|
||||
Window.openExternalURL(
|
||||
selectMessageByLocale(i18n, tutorial.linkByLocale)
|
||||
);
|
||||
},
|
||||
imageUrl: selectMessageByLocale(i18n, tutorial.thumbnailUrlByLocale),
|
||||
overlayText: tutorial.duration
|
||||
? formatDuration(tutorial.duration)
|
||||
: '\u{1F4D8}',
|
||||
overlayTextPosition: 'bottomRight',
|
||||
chipText: getChipTextFromTutorialCategory(tutorial.category, i18n),
|
||||
chipColor: getChipColorFromTutorialCategory(tutorial.category),
|
||||
};
|
||||
};
|
@@ -3,132 +3,34 @@ import { type I18n as I18nType } from '@lingui/core';
|
||||
import * as React from 'react';
|
||||
import { type HomeTab } from '../HomePageMenu';
|
||||
import {
|
||||
type TutorialCategory,
|
||||
type Tutorial,
|
||||
canAccessTutorial,
|
||||
} from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
type CourseListingData,
|
||||
type PrivateGameTemplateListingData,
|
||||
} from '../../../../Utils/GDevelopServices/Shop';
|
||||
import MainPage from './MainPage';
|
||||
import TutorialsCategoryPage from './TutorialsCategoryPage';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { TutorialContext } from '../../../../Tutorial/TutorialContext';
|
||||
import PlaceholderError from '../../../../UI/PlaceholderError';
|
||||
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
|
||||
import { sendTutorialOpened } from '../../../../Utils/Analytics/EventSender';
|
||||
import Window from '../../../../Utils/Window';
|
||||
import { type ImageTileComponent } from '../../../../UI/ImageTileGrid';
|
||||
import Paper from '../../../../UI/Paper';
|
||||
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
|
||||
import ErrorBoundary from '../../../../UI/ErrorBoundary';
|
||||
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
|
||||
import { formatDuration } from '../../../../Utils/Duration';
|
||||
|
||||
import CourseSection from './CourseSection';
|
||||
import type {
|
||||
CourseChapter,
|
||||
Course,
|
||||
} from '../../../../Utils/GDevelopServices/Asset';
|
||||
import type { CourseChapterCompletion, CourseCompletion } from '../UseCourses';
|
||||
|
||||
export const TUTORIAL_CATEGORY_TEXTS = {
|
||||
'full-game': {
|
||||
title: <Trans>Entire games</Trans>,
|
||||
description: <Trans>Make complete games step by step</Trans>,
|
||||
},
|
||||
'game-mechanic': {
|
||||
title: <Trans>Specific game mechanics</Trans>,
|
||||
description: (
|
||||
<Trans>
|
||||
Find how to implement the most common game mechanics and more
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
'official-beginner': {
|
||||
title: <Trans>Beginner course</Trans>,
|
||||
description: <Trans>Learn the fundamental principles of GDevelop</Trans>,
|
||||
},
|
||||
'official-intermediate': {
|
||||
title: <Trans>Intermediate course</Trans>,
|
||||
description: (
|
||||
<Trans>Learn all the game-building mechanics of GDevelop</Trans>
|
||||
),
|
||||
},
|
||||
'official-advanced': {
|
||||
title: <Trans>Advanced course</Trans>,
|
||||
description: <Trans>The icing on the cake</Trans>,
|
||||
},
|
||||
'education-curriculum': {
|
||||
title: <Trans>Education curriculum and resources</Trans>,
|
||||
description: (
|
||||
<Trans>
|
||||
For teachers and educators having the GDevelop Education subscription.
|
||||
Ready to use resources for teaching.
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
course: {
|
||||
title: <Trans>Loading</Trans>,
|
||||
description: <Trans>Loading course...</Trans>,
|
||||
},
|
||||
recommendations: {
|
||||
title: <Trans>Recommendations</Trans>,
|
||||
description: null,
|
||||
},
|
||||
};
|
||||
|
||||
type FormatTutorialToImageTileComponentProps = {|
|
||||
i18n: I18nType,
|
||||
limits: ?Limits,
|
||||
tutorial: Tutorial,
|
||||
onSelectTutorial: (tutorial: Tutorial) => void,
|
||||
|};
|
||||
|
||||
export const formatTutorialToImageTileComponent = ({
|
||||
i18n,
|
||||
tutorial,
|
||||
limits,
|
||||
onSelectTutorial,
|
||||
}: FormatTutorialToImageTileComponentProps): ImageTileComponent => {
|
||||
const isLocked = !canAccessTutorial(
|
||||
tutorial,
|
||||
limits ? limits.capabilities : null
|
||||
);
|
||||
return {
|
||||
title:
|
||||
selectMessageByLocale(i18n, tutorial.titleByLocale) || tutorial.title,
|
||||
description:
|
||||
selectMessageByLocale(i18n, tutorial.descriptionByLocale) ||
|
||||
tutorial.description,
|
||||
isLocked,
|
||||
onClick: () => {
|
||||
if (tutorial.isPrivateTutorial) {
|
||||
onSelectTutorial(tutorial);
|
||||
return;
|
||||
}
|
||||
|
||||
sendTutorialOpened(tutorial.id);
|
||||
Window.openExternalURL(
|
||||
selectMessageByLocale(i18n, tutorial.linkByLocale)
|
||||
);
|
||||
},
|
||||
imageUrl: selectMessageByLocale(i18n, tutorial.thumbnailUrlByLocale),
|
||||
overlayText: tutorial.duration
|
||||
? formatDuration(tutorial.duration)
|
||||
: '\u{1F4D8}',
|
||||
overlayTextPosition: 'bottomRight',
|
||||
};
|
||||
};
|
||||
|
||||
const styles = {
|
||||
paper: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
},
|
||||
};
|
||||
import SectionContainer, { SectionRow } from '../SectionContainer';
|
||||
import TutorialsPage from './TutorialsPage';
|
||||
import InAppTutorialsPage from './InAppTutorialsPage';
|
||||
import CoursesPage from './CoursesPage';
|
||||
import { type LearnCategory } from './Utils';
|
||||
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
|
||||
|
||||
type Props = {|
|
||||
onTabChange: (tab: HomeTab) => void,
|
||||
selectInAppTutorial: (tutorialId: string) => void,
|
||||
selectedCategory: TutorialCategory | null,
|
||||
onSelectCategory: (TutorialCategory | null) => void,
|
||||
selectedCategory: LearnCategory,
|
||||
onSelectCategory: LearnCategory => void,
|
||||
onOpenTemplateFromTutorial: string => Promise<void>,
|
||||
onOpenTemplateFromCourseChapter: CourseChapter => Promise<void>,
|
||||
previewedCourse: ?Course,
|
||||
@@ -148,7 +50,24 @@ type Props = {|
|
||||
chapterId: string
|
||||
) => CourseChapterCompletion | null,
|
||||
getCourseCompletion: (courseId: string) => CourseCompletion | null,
|
||||
onBuyCourseChapterWithCredits: (CourseChapter, string) => Promise<void>,
|
||||
onBuyCourseWithCredits: (
|
||||
Course: Course,
|
||||
password: string,
|
||||
i18n: I18nType
|
||||
) => Promise<void>,
|
||||
onBuyCourse: (
|
||||
Course: Course,
|
||||
password: string,
|
||||
i18n: I18nType
|
||||
) => Promise<void>,
|
||||
purchasingCourseListingData: ?CourseListingData,
|
||||
setPurchasingCourseListingData: (CourseListingData | null) => void,
|
||||
onOpenAskAi: (mode: 'chat' | 'agent') => void,
|
||||
onOpenNewProjectSetupDialog: () => void,
|
||||
onSelectPrivateGameTemplateListingData: (
|
||||
privateGameTemplateListingData: PrivateGameTemplateListingData
|
||||
) => void,
|
||||
onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void,
|
||||
|};
|
||||
|
||||
const LearnSection = ({
|
||||
@@ -168,13 +87,16 @@ const LearnSection = ({
|
||||
isCourseTaskCompleted,
|
||||
getCourseChapterCompletion,
|
||||
getCourseCompletion,
|
||||
onBuyCourseChapterWithCredits,
|
||||
onBuyCourseWithCredits,
|
||||
onBuyCourse,
|
||||
purchasingCourseListingData,
|
||||
setPurchasingCourseListingData,
|
||||
onOpenAskAi,
|
||||
onOpenNewProjectSetupDialog,
|
||||
onSelectPrivateGameTemplateListingData,
|
||||
onSelectExampleShortHeader,
|
||||
}: Props) => {
|
||||
const {
|
||||
tutorials,
|
||||
fetchTutorials,
|
||||
error: tutorialLoadingError,
|
||||
} = React.useContext(TutorialContext);
|
||||
const { fetchTutorials } = React.useContext(TutorialContext);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
@@ -183,13 +105,22 @@ const LearnSection = ({
|
||||
[fetchTutorials]
|
||||
);
|
||||
|
||||
if (selectedCategory === 'course' && courseChapters && course) {
|
||||
if (course) {
|
||||
if (!courseChapters) {
|
||||
return (
|
||||
<SectionContainer flexBody>
|
||||
<SectionRow expand>
|
||||
<PlaceholderLoader />
|
||||
</SectionRow>
|
||||
</SectionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CourseSection
|
||||
course={course}
|
||||
courseChapters={courseChapters}
|
||||
onBack={() => {
|
||||
onSelectCategory(null);
|
||||
onSelectCourse(null);
|
||||
}}
|
||||
onOpenTemplateFromCourseChapter={onOpenTemplateFromCourseChapter}
|
||||
@@ -199,30 +130,19 @@ const LearnSection = ({
|
||||
getCourseChapterCompletion(course.id, chapterId)
|
||||
}
|
||||
getCourseCompletion={() => getCourseCompletion(course.id)}
|
||||
onBuyCourseChapterWithCredits={onBuyCourseChapterWithCredits}
|
||||
onBuyCourseWithCredits={onBuyCourseWithCredits}
|
||||
onBuyCourse={onBuyCourse}
|
||||
purchasingCourseListingData={purchasingCourseListingData}
|
||||
setPurchasingCourseListingData={setPurchasingCourseListingData}
|
||||
onOpenAskAi={onOpenAskAi}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (tutorialLoadingError)
|
||||
return (
|
||||
<Paper square style={styles.paper} background="dark">
|
||||
<PlaceholderError onRetry={fetchTutorials}>
|
||||
<Trans>
|
||||
Can't load the tutorials. Verify your internet connection or retry
|
||||
later.
|
||||
</Trans>
|
||||
</PlaceholderError>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
if (!tutorials) return <PlaceholderLoader />;
|
||||
|
||||
return !selectedCategory ? (
|
||||
<MainPage
|
||||
onTabChange={onTabChange}
|
||||
onSelectCategory={onSelectCategory}
|
||||
tutorials={tutorials}
|
||||
selectInAppTutorial={selectInAppTutorial}
|
||||
courses={courses}
|
||||
onSelectCourse={onSelectCourse}
|
||||
@@ -230,17 +150,36 @@ const LearnSection = ({
|
||||
previewedCourseChapters={previewedCourseChapters}
|
||||
getCourseCompletion={getCourseCompletion}
|
||||
getCourseChapterCompletion={getCourseChapterCompletion}
|
||||
onOpenAskAi={onOpenAskAi}
|
||||
onOpenNewProjectSetupDialog={onOpenNewProjectSetupDialog}
|
||||
onSelectPrivateGameTemplateListingData={
|
||||
onSelectPrivateGameTemplateListingData
|
||||
}
|
||||
onSelectExampleShortHeader={onSelectExampleShortHeader}
|
||||
/>
|
||||
) : selectedCategory === 'all-tutorials' ? (
|
||||
<TutorialsPage onSelectCategory={onSelectCategory} />
|
||||
) : selectedCategory === 'in-app-tutorials' ? (
|
||||
<InAppTutorialsPage
|
||||
onBack={() => onSelectCategory(null)}
|
||||
selectInAppTutorial={selectInAppTutorial}
|
||||
/>
|
||||
) : selectedCategory === 'all-courses' ? (
|
||||
<CoursesPage
|
||||
onBack={() => onSelectCategory(null)}
|
||||
courses={courses}
|
||||
onSelectCourse={onSelectCourse}
|
||||
previewedCourse={previewedCourse}
|
||||
previewedCourseChapters={previewedCourseChapters}
|
||||
getCourseCompletion={getCourseCompletion}
|
||||
getCourseChapterCompletion={getCourseChapterCompletion}
|
||||
/>
|
||||
) : (
|
||||
<TutorialsCategoryPage
|
||||
onBack={() => onSelectCategory(null)}
|
||||
onBack={() => onSelectCategory('all-tutorials')}
|
||||
category={selectedCategory}
|
||||
tutorials={tutorials}
|
||||
onOpenTemplateFromTutorial={onOpenTemplateFromTutorial}
|
||||
onSelectCourse={(courseId: string) => {
|
||||
onSelectCourse(courseId);
|
||||
onSelectCategory('course');
|
||||
}}
|
||||
onSelectCourse={onSelectCourse}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@@ -7,10 +7,11 @@ import ArrowLeft from '../../../UI/CustomSvgIcons/ArrowLeft';
|
||||
import TextButton from '../../../UI/TextButton';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import Paper from '../../../UI/Paper';
|
||||
import { LineStackLayout } from '../../../UI/Layout';
|
||||
import { ColumnStackLayout, LineStackLayout } from '../../../UI/Layout';
|
||||
import { AnnouncementsFeed } from '../../../AnnouncementsFeed';
|
||||
import { AnnouncementsFeedContext } from '../../../AnnouncementsFeed/AnnouncementsFeedContext';
|
||||
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
|
||||
import Chip from '../../../UI/Chip';
|
||||
|
||||
export const SECTION_DESKTOP_SPACING = 20;
|
||||
const SECTION_MOBILE_SPACING_TOP = 10;
|
||||
@@ -53,13 +54,14 @@ const styles = {
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
},
|
||||
chip: { height: 24 },
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
chipText?: React.Node,
|
||||
title?: React.Node,
|
||||
titleAdornment?: React.Node,
|
||||
titleAction?: React.Node,
|
||||
subtitleText?: React.Node,
|
||||
customPaperStyle?: Object,
|
||||
renderSubtitle?: () => React.Node,
|
||||
@@ -75,9 +77,9 @@ const SectionContainer = React.forwardRef<Props, HTMLDivElement>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
chipText,
|
||||
title,
|
||||
titleAdornment,
|
||||
titleAction,
|
||||
subtitleText,
|
||||
customPaperStyle,
|
||||
renderSubtitle,
|
||||
@@ -156,14 +158,20 @@ const SectionContainer = React.forwardRef<Props, HTMLDivElement>(
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<LineStackLayout noMargin alignItems="center">
|
||||
<Text size="bold-title" noMargin style={styles.title}>
|
||||
<ColumnStackLayout noMargin expand>
|
||||
{chipText && (
|
||||
<Line noMargin>
|
||||
<Chip label={chipText} style={styles.chip} />
|
||||
</Line>
|
||||
)}
|
||||
<Text size="title" noMargin style={styles.title}>
|
||||
{title}
|
||||
</Text>
|
||||
{titleAction}
|
||||
</LineStackLayout>
|
||||
</ColumnStackLayout>
|
||||
{titleAdornment && (
|
||||
<Column noMargin>{titleAdornment}</Column>
|
||||
<Column expand noMargin>
|
||||
{titleAdornment}
|
||||
</Column>
|
||||
)}
|
||||
</LineStackLayout>
|
||||
)}
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import {
|
||||
listCourseChapters,
|
||||
listCourses,
|
||||
@@ -9,16 +10,19 @@ import {
|
||||
type CourseChapter,
|
||||
type UserCourseProgress,
|
||||
} from '../../../Utils/GDevelopServices/Asset';
|
||||
import { type CourseListingData } from '../../../Utils/GDevelopServices/Shop';
|
||||
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
|
||||
import {
|
||||
fetchUserCourseProgress,
|
||||
updateUserCourseProgress as doUpdateUserCourseProgress,
|
||||
} from '../../../Utils/GDevelopServices/User';
|
||||
import { useOptimisticState } from '../../../Utils/UseOptimisticState';
|
||||
import CourseChapterStoreContext from '../../../Course/CourseChapterStoreContext';
|
||||
import CourseStoreContext from '../../../Course/CourseStoreContext';
|
||||
import { CreditsPackageStoreContext } from '../../../AssetStore/CreditsPackages/CreditsPackageStoreContext';
|
||||
import { buyProductWithCredits } from '../../../Utils/GDevelopServices/Shop';
|
||||
import PreferencesContext from '../../Preferences/PreferencesContext';
|
||||
import { selectMessageByLocale } from '../../../Utils/i18n/MessageByLocale';
|
||||
import { sendCourseBuyClicked } from '../../../Utils/Analytics/EventSender';
|
||||
|
||||
export type CourseChapterCompletion = {|
|
||||
completedTasks: number,
|
||||
@@ -36,8 +40,10 @@ const useCourses = () => {
|
||||
profile,
|
||||
limits,
|
||||
subscription,
|
||||
coursePurchases,
|
||||
getAuthorizationHeader,
|
||||
onOpenLoginDialog,
|
||||
loginState,
|
||||
} = React.useContext(AuthenticatedUserContext);
|
||||
const {
|
||||
values: { language },
|
||||
@@ -45,7 +51,11 @@ const useCourses = () => {
|
||||
const userLanguage2LetterCode = language.split('_')[0].toLowerCase();
|
||||
|
||||
const [courses, setCourses] = React.useState<?(Course[])>(null);
|
||||
const { listedCourseChapters } = React.useContext(CourseChapterStoreContext);
|
||||
const { listedCourses } = React.useContext(CourseStoreContext);
|
||||
const [
|
||||
purchasingCourseListingData,
|
||||
setPurchasingCourseListingData,
|
||||
] = React.useState<?CourseListingData>(null);
|
||||
const { openCreditsPackageDialog, openCreditsUsageDialog } = React.useContext(
|
||||
CreditsPackageStoreContext
|
||||
);
|
||||
@@ -83,10 +93,15 @@ const useCourses = () => {
|
||||
|}>({});
|
||||
const userId = profile ? profile.id : null;
|
||||
|
||||
const fetchCourses = React.useCallback(async () => {
|
||||
const fetchedCourses = await listCourses();
|
||||
setCourses(fetchedCourses);
|
||||
}, []);
|
||||
const fetchCourses = React.useCallback(
|
||||
async () => {
|
||||
const fetchedCourses = await listCourses(getAuthorizationHeader, {
|
||||
userId,
|
||||
});
|
||||
setCourses(fetchedCourses);
|
||||
},
|
||||
[userId, getAuthorizationHeader]
|
||||
);
|
||||
|
||||
const onSelectCourse = React.useCallback(
|
||||
(courseId: string | null) => {
|
||||
@@ -138,11 +153,7 @@ const useCourses = () => {
|
||||
);
|
||||
}
|
||||
},
|
||||
// A subscription change will change the displayed chapters sent by the backend.
|
||||
// So the user subscription is added as a dependency to make sure the chapters are
|
||||
// up to date with the user subscription.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getAuthorizationHeader, userId, subscription, userLanguage2LetterCode]
|
||||
[getAuthorizationHeader, userId, userLanguage2LetterCode]
|
||||
);
|
||||
|
||||
const onCompleteTask = React.useCallback(
|
||||
@@ -287,14 +298,9 @@ const useCourses = () => {
|
||||
[userProgressByCourseId, chaptersByCourseId, courses]
|
||||
);
|
||||
|
||||
const onBuyCourseChapterWithCredits = React.useCallback(
|
||||
async (courseChapter: CourseChapter, password: string) => {
|
||||
if (
|
||||
!courseChapter.isLocked ||
|
||||
!courseChapter.priceInCredits ||
|
||||
!listedCourseChapters
|
||||
)
|
||||
return;
|
||||
const onBuyCourse = React.useCallback(
|
||||
async (course: Course, password: string, i18n: I18nType) => {
|
||||
if (!course.isLocked || !listedCourses) return;
|
||||
|
||||
if (!userId || !limits) {
|
||||
// User not logged in, suggest to log in.
|
||||
@@ -302,19 +308,68 @@ const useCourses = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCreditsAmount = limits.credits.userBalance.amount;
|
||||
const listedCourseChapter = listedCourseChapters.find(
|
||||
chapter => chapter.id === courseChapter.productId
|
||||
const listedCourse = listedCourses.find(
|
||||
listedCourse => listedCourse.id === course.id
|
||||
);
|
||||
if (!listedCourseChapter) {
|
||||
console.error(
|
||||
`Couldn't find course chapter with id ${
|
||||
courseChapter.productId
|
||||
} in Shop API.`
|
||||
);
|
||||
if (!listedCourse) {
|
||||
console.error(`Couldn't find course with id ${course.id} in Shop API.`);
|
||||
return;
|
||||
}
|
||||
const priceForUsageType = listedCourseChapter.creditPrices.find(
|
||||
const priceForUsageType = listedCourse.prices.find(
|
||||
price => price.usageType === 'default'
|
||||
);
|
||||
if (!priceForUsageType) {
|
||||
console.error('Unable to find the price for the default usage type');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sendCourseBuyClicked({
|
||||
courseId: course.id,
|
||||
courseName: course.titleByLocale.en,
|
||||
currency: priceForUsageType ? priceForUsageType.currency : undefined,
|
||||
usageType: 'default',
|
||||
});
|
||||
|
||||
setPurchasingCourseListingData(listedCourse);
|
||||
} catch (e) {
|
||||
console.warn('Unable to send event', e);
|
||||
}
|
||||
},
|
||||
[
|
||||
userId,
|
||||
limits,
|
||||
listedCourses,
|
||||
onOpenLoginDialog,
|
||||
setPurchasingCourseListingData,
|
||||
]
|
||||
);
|
||||
|
||||
const onBuyCourseWithCredits = React.useCallback(
|
||||
async (course: Course, password: string, i18n: I18nType) => {
|
||||
if (!course.isLocked || !listedCourses) return;
|
||||
|
||||
if (!userId || !limits) {
|
||||
// User not logged in, suggest to log in.
|
||||
onOpenLoginDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
sendCourseBuyClicked({
|
||||
courseId: course.id,
|
||||
courseName: course.titleByLocale.en,
|
||||
currency: 'CREDITS',
|
||||
usageType: 'default',
|
||||
});
|
||||
|
||||
const currentCreditsAmount = limits.credits.userBalance.amount;
|
||||
const listedCourse = listedCourses.find(
|
||||
listedCourse => listedCourse.id === course.id
|
||||
);
|
||||
if (!listedCourse) {
|
||||
console.error(`Couldn't find course with id ${course.id} in Shop API.`);
|
||||
return;
|
||||
}
|
||||
const priceForUsageType = listedCourse.creditPrices.find(
|
||||
price => price.usageType === 'default'
|
||||
);
|
||||
if (!priceForUsageType) {
|
||||
@@ -331,43 +386,48 @@ const useCourses = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const translatedCourseTitle = selectMessageByLocale(
|
||||
i18n,
|
||||
course.titleByLocale
|
||||
);
|
||||
|
||||
openCreditsUsageDialog({
|
||||
title: <Trans>Purchase {courseChapter.title}</Trans>,
|
||||
title: <Trans>Purchase {translatedCourseTitle}</Trans>,
|
||||
message: (
|
||||
<Trans>
|
||||
You are about to use {creditsAmount} credits to purchase the chapter
|
||||
"{courseChapter.title}". Continue?
|
||||
You are about to use {creditsAmount} credits to purchase the course
|
||||
"{translatedCourseTitle}". Continue?
|
||||
</Trans>
|
||||
),
|
||||
onConfirm: async () => {
|
||||
await buyProductWithCredits(getAuthorizationHeader, {
|
||||
productId: listedCourseChapter.id,
|
||||
productId: listedCourse.id,
|
||||
usageType: 'default',
|
||||
userId,
|
||||
password,
|
||||
});
|
||||
if (selectedCourseId) await fetchCourseChapters(selectedCourseId);
|
||||
await Promise.all([fetchCourses(), fetchCourseChapters(course.id)]);
|
||||
},
|
||||
successMessage: <Trans>🎉 You can now follow your new chapter!</Trans>,
|
||||
successMessage: <Trans>🎉 You can now follow your new course!</Trans>,
|
||||
});
|
||||
},
|
||||
[
|
||||
userId,
|
||||
limits,
|
||||
listedCourseChapters,
|
||||
listedCourses,
|
||||
openCreditsPackageDialog,
|
||||
openCreditsUsageDialog,
|
||||
getAuthorizationHeader,
|
||||
onOpenLoginDialog,
|
||||
fetchCourses,
|
||||
fetchCourseChapters,
|
||||
selectedCourseId,
|
||||
]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
(async () => {
|
||||
if (courses) {
|
||||
if (courses && loginState !== 'loggingIn') {
|
||||
await Promise.all(
|
||||
courses.map(course => fetchCourseChapters(course.id))
|
||||
);
|
||||
@@ -375,15 +435,32 @@ const useCourses = () => {
|
||||
}
|
||||
})();
|
||||
},
|
||||
// (Re)fetch course chapters when courses are defined and when fetchCourseChapters
|
||||
// changes (see its dependencies).
|
||||
[courses, fetchCourseChapters]
|
||||
// (Re)fetch course chapters when courses are refetched.
|
||||
[courses, fetchCourseChapters, loginState]
|
||||
);
|
||||
|
||||
const selectedCourse =
|
||||
selectedCourseId && courses && areChaptersReady
|
||||
? courses.find(course => course.id === selectedCourseId) || null
|
||||
: null;
|
||||
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();
|
||||
}
|
||||
})();
|
||||
},
|
||||
[fetchCourses, subscription, coursePurchases, loginState]
|
||||
);
|
||||
|
||||
const selectedCourse = React.useMemo(
|
||||
() => {
|
||||
if (!selectedCourseId || !courses || !areChaptersReady) return null;
|
||||
return courses.find(course => course.id === selectedCourseId) || null;
|
||||
},
|
||||
[selectedCourseId, courses, areChaptersReady]
|
||||
);
|
||||
|
||||
return {
|
||||
courses,
|
||||
@@ -396,7 +473,10 @@ const useCourses = () => {
|
||||
isTaskCompleted,
|
||||
getChapterCompletion,
|
||||
getCourseCompletion,
|
||||
onBuyCourseChapterWithCredits,
|
||||
onBuyCourseWithCredits,
|
||||
onBuyCourse,
|
||||
purchasingCourseListingData,
|
||||
setPurchasingCourseListingData,
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -11,12 +11,11 @@ import {
|
||||
type FileMetadata,
|
||||
type StorageProvider,
|
||||
} from '../../../ProjectsStorage';
|
||||
import GetStartedSection from './GetStartedSection';
|
||||
import LearnSection from './LearnSection';
|
||||
import { type LearnCategory } from './LearnSection/Utils';
|
||||
import PlaySection from './PlaySection';
|
||||
import CreateSection from './CreateSection';
|
||||
import StoreSection from './StoreSection';
|
||||
import { type TutorialCategory } from '../../../Utils/GDevelopServices/Tutorial';
|
||||
import { TutorialContext } from '../../../Tutorial/TutorialContext';
|
||||
import { ExampleStoreContext } from '../../../AssetStore/ExampleStore/ExampleStoreContext';
|
||||
import { HomePageHeader } from './HomePageHeader';
|
||||
@@ -30,12 +29,6 @@ import TeamProvider from '../../../Profile/Team/TeamProvider';
|
||||
import { useResponsiveWindowSize } from '../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { type PrivateGameTemplateListingData } from '../../../Utils/GDevelopServices/Shop';
|
||||
import { PrivateGameTemplateStoreContext } from '../../../AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext';
|
||||
import PreferencesContext from '../../Preferences/PreferencesContext';
|
||||
import { incrementGetStartedSectionViewCount } from '../../../Utils/Analytics/LocalStats';
|
||||
import {
|
||||
sendUserSurveyHidden,
|
||||
sendUserSurveyStarted,
|
||||
} from '../../../Utils/Analytics/EventSender';
|
||||
import RouterContext, { type RouteArguments } from '../../RouterContext';
|
||||
import { type GameDetailsTab } from '../../../GameDashboard';
|
||||
import { canUseClassroomFeature } from '../../../Utils/GDevelopServices/Usage';
|
||||
@@ -47,6 +40,7 @@ import { type GamesList } from '../../../GameDashboard/UseGamesList';
|
||||
import { type GamesPlatformFrameTools } from './PlaySection/UseGamesPlatformFrame';
|
||||
import { type CourseChapter } from '../../../Utils/GDevelopServices/Asset';
|
||||
import useCourses from './UseCourses';
|
||||
import PreferencesContext from '../../Preferences/PreferencesContext';
|
||||
import { setEditorHotReloadNeeded } from '../../../EmbeddedGame/EmbeddedGameFrame';
|
||||
|
||||
const noop = () => {};
|
||||
@@ -71,8 +65,6 @@ const getRequestedTab = (routeArguments: RouteArguments): HomeTab | null => {
|
||||
return 'play';
|
||||
} else if (routeArguments['initial-dialog'] === 'learn') {
|
||||
return 'learn';
|
||||
} else if (routeArguments['initial-dialog'] === 'get-started') {
|
||||
return 'get-started';
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -143,6 +135,7 @@ type Props = {|
|
||||
selectInAppTutorial: (tutorialId: string) => void,
|
||||
onOpenPreferences: () => void,
|
||||
onOpenAbout: () => void,
|
||||
onOpenAskAi: (mode: 'chat' | 'agent') => void,
|
||||
|
||||
// Project creation
|
||||
onOpenNewProjectSetupDialog: () => void,
|
||||
@@ -211,6 +204,7 @@ export const HomePage = React.memo<Props>(
|
||||
selectInAppTutorial,
|
||||
onOpenPreferences,
|
||||
onOpenAbout,
|
||||
onOpenAskAi,
|
||||
isActive,
|
||||
storageProviders,
|
||||
onSave,
|
||||
@@ -238,8 +232,6 @@ export const HomePage = React.memo<Props>(
|
||||
startTimeoutToUnloadIframe,
|
||||
loadIframeOrRemoveTimeout,
|
||||
} = gamesPlatformFrameTools;
|
||||
const userSurveyStartedRef = React.useRef<boolean>(false);
|
||||
const userSurveyHiddenRef = React.useRef<boolean>(false);
|
||||
const { fetchTutorials } = React.useContext(TutorialContext);
|
||||
const { fetchExamplesAndFilters } = React.useContext(ExampleStoreContext);
|
||||
const {
|
||||
@@ -273,31 +265,32 @@ export const HomePage = React.memo<Props>(
|
||||
selectedCourse,
|
||||
courseChaptersByCourseId,
|
||||
onSelectCourse,
|
||||
fetchCourses,
|
||||
areChaptersReady,
|
||||
onCompleteTask,
|
||||
isTaskCompleted,
|
||||
getChapterCompletion,
|
||||
getCourseCompletion,
|
||||
onBuyCourseChapterWithCredits,
|
||||
onBuyCourseWithCredits,
|
||||
onBuyCourse,
|
||||
purchasingCourseListingData,
|
||||
setPurchasingCourseListingData,
|
||||
} = useCourses();
|
||||
const [
|
||||
learnCategory,
|
||||
setLearnCategory,
|
||||
] = React.useState<TutorialCategory | null>(null);
|
||||
const [learnCategory, setLearnCategory] = React.useState<LearnCategory>(
|
||||
null
|
||||
);
|
||||
|
||||
const { isMobile } = useResponsiveWindowSize();
|
||||
const {
|
||||
values: { showGetStartedSectionByDefault },
|
||||
values: { showCreateSectionByDefault },
|
||||
} = React.useContext(PreferencesContext);
|
||||
const tabRequestedAtOpening = React.useRef<HomeTab | null>(
|
||||
getRequestedTab(routeArguments)
|
||||
);
|
||||
const initialTab = tabRequestedAtOpening.current
|
||||
? tabRequestedAtOpening.current
|
||||
: showGetStartedSectionByDefault
|
||||
? 'get-started'
|
||||
: 'create';
|
||||
: showCreateSectionByDefault
|
||||
? 'create'
|
||||
: 'learn';
|
||||
|
||||
const [activeTab, setActiveTab] = React.useState<HomeTab>(initialTab);
|
||||
|
||||
@@ -354,7 +347,6 @@ export const HomePage = React.memo<Props>(
|
||||
return;
|
||||
}
|
||||
onSelectCourse(courseId);
|
||||
setLearnCategory('course');
|
||||
removeRouteArguments(['course-id']);
|
||||
}
|
||||
|
||||
@@ -371,15 +363,6 @@ export const HomePage = React.memo<Props>(
|
||||
]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (initialTab === 'get-started') {
|
||||
incrementGetStartedSectionViewCount();
|
||||
}
|
||||
},
|
||||
[initialTab]
|
||||
);
|
||||
|
||||
// Load everything when the user opens the home page, to avoid future loading times.
|
||||
React.useEffect(
|
||||
() => {
|
||||
@@ -390,28 +373,6 @@ export const HomePage = React.memo<Props>(
|
||||
[fetchExamplesAndFilters, fetchTutorials, fetchGameTemplates]
|
||||
);
|
||||
|
||||
// Only fetch games if the user decides to open the games dashboard tab
|
||||
// or the build tab to enable the context menu on project list items that
|
||||
// redirects to the games dashboard.
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (activeTab === 'create' && !games) {
|
||||
fetchGames();
|
||||
}
|
||||
},
|
||||
[fetchGames, activeTab, games]
|
||||
);
|
||||
|
||||
// Only fetch courses if the user decides to open the Learn section.
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (activeTab === 'learn' && !courses) {
|
||||
fetchCourses();
|
||||
}
|
||||
},
|
||||
[fetchCourses, activeTab, courses]
|
||||
);
|
||||
|
||||
// Fetch user cloud projects when home page becomes active
|
||||
React.useEffect(
|
||||
() => {
|
||||
@@ -507,28 +468,6 @@ export const HomePage = React.memo<Props>(
|
||||
forceInGameEditorHotReload,
|
||||
}));
|
||||
|
||||
const onUserSurveyStarted = React.useCallback(() => {
|
||||
if (userSurveyStartedRef.current) return;
|
||||
sendUserSurveyStarted();
|
||||
userSurveyStartedRef.current = true;
|
||||
}, []);
|
||||
const onUserSurveyHidden = React.useCallback(() => {
|
||||
if (userSurveyHiddenRef.current) return;
|
||||
sendUserSurveyHidden();
|
||||
userSurveyHiddenRef.current = true;
|
||||
}, []);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (!authenticated) {
|
||||
userSurveyStartedRef.current = false;
|
||||
userSurveyHiddenRef.current = false;
|
||||
}
|
||||
},
|
||||
// Reset flag that prevents multiple send of the same event on user change.
|
||||
[authenticated]
|
||||
);
|
||||
|
||||
// As the homepage is never unmounted, we need to ensure the games platform
|
||||
// iframe is unloaded & loaded from here,
|
||||
// allowing to handle when the user navigates to another tab.
|
||||
@@ -598,16 +537,6 @@ export const HomePage = React.memo<Props>(
|
||||
canSaveProject={canSave}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'get-started' && (
|
||||
<GetStartedSection
|
||||
selectInAppTutorial={selectInAppTutorial}
|
||||
onUserSurveyStarted={onUserSurveyStarted}
|
||||
onUserSurveyHidden={onUserSurveyHidden}
|
||||
onOpenProfile={onOpenProfile}
|
||||
onCreateProjectFromExample={onCreateProjectFromExample}
|
||||
askToCloseProject={askToCloseProject}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'learn' && (
|
||||
<LearnSection
|
||||
onTabChange={setActiveTab}
|
||||
@@ -636,9 +565,18 @@ export const HomePage = React.memo<Props>(
|
||||
isCourseTaskCompleted={isTaskCompleted}
|
||||
getCourseChapterCompletion={getChapterCompletion}
|
||||
getCourseCompletion={getCourseCompletion}
|
||||
onBuyCourseChapterWithCredits={
|
||||
onBuyCourseChapterWithCredits
|
||||
onBuyCourseWithCredits={onBuyCourseWithCredits}
|
||||
onBuyCourse={onBuyCourse}
|
||||
purchasingCourseListingData={purchasingCourseListingData}
|
||||
setPurchasingCourseListingData={
|
||||
setPurchasingCourseListingData
|
||||
}
|
||||
onOpenAskAi={onOpenAskAi}
|
||||
onOpenNewProjectSetupDialog={onOpenNewProjectSetupDialog}
|
||||
onSelectPrivateGameTemplateListingData={
|
||||
onSelectPrivateGameTemplateListingData
|
||||
}
|
||||
onSelectExampleShortHeader={onSelectExampleShortHeader}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'play' && (
|
||||
@@ -732,6 +670,7 @@ export const renderHomePageContainer = (
|
||||
selectInAppTutorial={props.selectInAppTutorial}
|
||||
onOpenPreferences={props.onOpenPreferences}
|
||||
onOpenAbout={props.onOpenAbout}
|
||||
onOpenAskAi={props.onOpenAskAi}
|
||||
storageProviders={
|
||||
(props.extraEditorProps && props.extraEditorProps.storageProviders) || []
|
||||
}
|
||||
|
@@ -22,6 +22,7 @@ type Props = {|
|
||||
name: string,
|
||||
dontFocusTab?: boolean,
|
||||
project?: ?gdProject,
|
||||
mode?: 'chat' | 'agent',
|
||||
|}) => EditorOpeningOptions,
|
||||
|};
|
||||
|
||||
|
@@ -46,7 +46,7 @@ export type MainMenuCallbacks = {|
|
||||
onOpenPreferences: (open?: boolean) => void,
|
||||
onOpenLanguage: (open?: boolean) => void,
|
||||
onOpenProfile: (open?: boolean) => void,
|
||||
onOpenAskAi: (open?: boolean) => void,
|
||||
onOpenAskAi: (mode: 'chat' | 'agent') => void,
|
||||
setElectronUpdateStatus: ElectronUpdateStatus => void,
|
||||
|};
|
||||
|
||||
|
@@ -216,7 +216,7 @@ export type PreferencesValues = {|
|
||||
resourcesImporationBehavior: ResourceImportationBehavior,
|
||||
eventsSheetCancelInlineParameter: 'cancel' | 'apply',
|
||||
showCommunityExtensions: boolean,
|
||||
showGetStartedSectionByDefault: boolean,
|
||||
showCreateSectionByDefault: boolean,
|
||||
showInAppTutorialDeveloperMode: boolean,
|
||||
showDeprecatedInstructionWarning: boolean,
|
||||
openDiagnosticReportAutomatically: boolean,
|
||||
@@ -302,7 +302,7 @@ export type Preferences = {|
|
||||
setIsAlwaysOnTopInPreview: (enabled: boolean) => void,
|
||||
setEventsSheetCancelInlineParameter: (value: string) => void,
|
||||
setShowCommunityExtensions: (enabled: boolean) => void,
|
||||
setShowGetStartedSectionByDefault: (enabled: boolean) => void,
|
||||
setShowCreateSectionByDefault: (enabled: boolean) => void,
|
||||
setShowInAppTutorialDeveloperMode: (enabled: boolean) => void,
|
||||
setOpenDiagnosticReportAutomatically: (enabled: boolean) => void,
|
||||
getOpenDiagnosticReportAutomatically: () => boolean,
|
||||
@@ -381,7 +381,7 @@ export const initialPreferences = {
|
||||
resourcesImporationBehavior: 'ask',
|
||||
eventsSheetCancelInlineParameter: 'apply',
|
||||
showCommunityExtensions: false,
|
||||
showGetStartedSectionByDefault: true,
|
||||
showCreateSectionByDefault: false,
|
||||
showInAppTutorialDeveloperMode: false,
|
||||
openDiagnosticReportAutomatically: true,
|
||||
showDeprecatedInstructionWarning: false,
|
||||
@@ -449,7 +449,7 @@ export const initialPreferences = {
|
||||
setIsAlwaysOnTopInPreview: () => {},
|
||||
setEventsSheetCancelInlineParameter: () => {},
|
||||
setShowCommunityExtensions: () => {},
|
||||
setShowGetStartedSectionByDefault: (enabled: boolean) => {},
|
||||
setShowCreateSectionByDefault: (enabled: boolean) => {},
|
||||
setShowInAppTutorialDeveloperMode: (enabled: boolean) => {},
|
||||
setShowDeprecatedInstructionWarning: (enabled: boolean) => {},
|
||||
getOpenDiagnosticReportAutomatically: () => true,
|
||||
|
@@ -83,6 +83,7 @@ const PreferencesDialog = ({
|
||||
setPreviewCrashReportUploadLevel,
|
||||
setTakeScreenshotOnPreview,
|
||||
setShowAiAskButtonInTitleBar,
|
||||
setShowCreateSectionByDefault,
|
||||
} = React.useContext(PreferencesContext);
|
||||
|
||||
const initialUse3DEditor = React.useRef<boolean>(values.use3DEditor);
|
||||
@@ -392,6 +393,27 @@ const PreferencesDialog = ({
|
||||
<Trans>Advanced</Trans>
|
||||
</Text>
|
||||
<ColumnStackLayout>
|
||||
<Text size="sub-title">
|
||||
<Trans>At launch</Trans>
|
||||
</Text>
|
||||
<CompactToggleField
|
||||
labelColor="primary"
|
||||
hideTooltip
|
||||
onCheck={setShowCreateSectionByDefault}
|
||||
checked={values.showCreateSectionByDefault}
|
||||
label={i18n._(
|
||||
t`Show the "Create" section by default when opening GDevelop`
|
||||
)}
|
||||
/>
|
||||
<CompactToggleField
|
||||
labelColor="primary"
|
||||
hideTooltip
|
||||
onCheck={setAutoOpenMostRecentProject}
|
||||
checked={values.autoOpenMostRecentProject}
|
||||
label={i18n._(
|
||||
t`Automatically re-open the project edited during last session`
|
||||
)}
|
||||
/>
|
||||
<Text size="sub-title">
|
||||
<Trans>Previews</Trans>
|
||||
</Text>
|
||||
@@ -531,15 +553,6 @@ const PreferencesDialog = ({
|
||||
t`Display save reminder after significant changes in project`
|
||||
)}
|
||||
/>
|
||||
<CompactToggleField
|
||||
labelColor="primary"
|
||||
hideTooltip
|
||||
onCheck={setAutoOpenMostRecentProject}
|
||||
checked={values.autoOpenMostRecentProject}
|
||||
label={i18n._(
|
||||
t`Automatically re-open the project edited during last session`
|
||||
)}
|
||||
/>
|
||||
<CompactToggleField
|
||||
labelColor="primary"
|
||||
hideTooltip
|
||||
|
@@ -149,7 +149,7 @@ export default class PreferencesProvider extends React.Component<Props, State> {
|
||||
this
|
||||
),
|
||||
setShowCommunityExtensions: this._setShowCommunityExtensions.bind(this),
|
||||
setShowGetStartedSectionByDefault: this._setShowGetStartedSection.bind(
|
||||
setShowCreateSectionByDefault: this._setShowCreateSectionByDefault.bind(
|
||||
this
|
||||
),
|
||||
setShowInAppTutorialDeveloperMode: this._setShowInAppTutorialDeveloperMode.bind(
|
||||
@@ -353,12 +353,12 @@ export default class PreferencesProvider extends React.Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
_setShowGetStartedSection(showGetStartedSectionByDefault: boolean) {
|
||||
_setShowCreateSectionByDefault(showCreateSectionByDefault: boolean) {
|
||||
this.setState(
|
||||
state => ({
|
||||
values: {
|
||||
...state.values,
|
||||
showGetStartedSectionByDefault,
|
||||
showCreateSectionByDefault,
|
||||
},
|
||||
}),
|
||||
() => this._persistValuesToLocalStorage(this.state)
|
||||
|
@@ -40,7 +40,7 @@ import { Trans } from '@lingui/macro';
|
||||
import { CreditsPackageStoreStateProvider } from '../AssetStore/CreditsPackages/CreditsPackageStoreContext';
|
||||
import { ProductLicenseStoreStateProvider } from '../AssetStore/ProductLicense/ProductLicenseStoreContext';
|
||||
import { MarketingPlansStoreStateProvider } from '../MarketingPlans/MarketingPlansStoreContext';
|
||||
import { CourseChapterStoreStateProvider } from '../Course/CourseChapterStoreContext';
|
||||
import { CourseStoreStateProvider } from '../Course/CourseStoreContext';
|
||||
import { Resource3DPreviewProvider } from '../ResourcesList/ResourcePreview/Resource3DPreviewContext';
|
||||
|
||||
type Props = {|
|
||||
@@ -108,7 +108,7 @@ const Providers = ({
|
||||
<ExampleStoreStateProvider>
|
||||
<PrivateGameTemplateStoreStateProvider>
|
||||
<CreditsPackageStoreStateProvider>
|
||||
<CourseChapterStoreStateProvider>
|
||||
<CourseStoreStateProvider>
|
||||
<ProductLicenseStoreStateProvider>
|
||||
<MarketingPlansStoreStateProvider>
|
||||
<ExtensionStoreStateProvider
|
||||
@@ -134,7 +134,7 @@ const Providers = ({
|
||||
</ExtensionStoreStateProvider>
|
||||
</MarketingPlansStoreStateProvider>
|
||||
</ProductLicenseStoreStateProvider>
|
||||
</CourseChapterStoreStateProvider>
|
||||
</CourseStoreStateProvider>
|
||||
</CreditsPackageStoreStateProvider>
|
||||
</PrivateGameTemplateStoreStateProvider>
|
||||
</ExampleStoreStateProvider>
|
||||
|
@@ -12,8 +12,8 @@ export type Route =
|
||||
| 'build' // Old way of opening the build section
|
||||
| 'create' // New way of opening the build section
|
||||
| 'education'
|
||||
| 'play'
|
||||
| 'get-started';
|
||||
| 'learn'
|
||||
| 'play';
|
||||
type RouteKey =
|
||||
| 'initial-dialog'
|
||||
| 'game-id'
|
||||
|
@@ -53,7 +53,7 @@ type TabsTitlebarProps = {|
|
||||
onEditorTabClosing: () => void
|
||||
) => React.Node,
|
||||
hasAskAiOpened: boolean,
|
||||
onOpenAskAi: () => void,
|
||||
onOpenAskAi: (mode: 'chat' | 'agent') => void,
|
||||
|};
|
||||
|
||||
const useIsAskAiIconAnimated = (shouldDisplayAskAi: boolean) => {
|
||||
@@ -227,7 +227,7 @@ export default function TabsTitlebar({
|
||||
<TextButton
|
||||
icon={<RobotIcon size={16} rotating={isAskAiIconAnimated} />}
|
||||
label={'Ask AI'}
|
||||
onClick={onOpenAskAi}
|
||||
onClick={() => onOpenAskAi('agent')}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
@@ -1,7 +1,18 @@
|
||||
// @flow
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterContext from './RouterContext';
|
||||
import RouterContext, { type Route } from './RouterContext';
|
||||
|
||||
const homePageRoutes: Route[] = [
|
||||
'play',
|
||||
'learn',
|
||||
'build',
|
||||
'create',
|
||||
'games-dashboard',
|
||||
'asset-store',
|
||||
'store',
|
||||
'education',
|
||||
];
|
||||
|
||||
/**
|
||||
* This hook is used to be able to use route arguments from anywhere to open the homepage.
|
||||
@@ -22,18 +33,7 @@ const useHomepageWitchForRouting = ({
|
||||
const initialDialog = routeArguments['initial-dialog'];
|
||||
if (!initialDialog) return;
|
||||
|
||||
if (
|
||||
[
|
||||
'games-dashboard',
|
||||
'asset-store',
|
||||
'store',
|
||||
'build',
|
||||
'education',
|
||||
'play',
|
||||
'community',
|
||||
'get-started',
|
||||
].includes(initialDialog)
|
||||
) {
|
||||
if (homePageRoutes.includes(initialDialog)) {
|
||||
closeDialogs();
|
||||
openHomePage();
|
||||
}
|
||||
|
@@ -30,7 +30,7 @@ type Props = {|
|
||||
privateGameTemplateListingData: PrivateGameTemplateListingData,
|
||||
newProjectSetup: NewProjectSetup
|
||||
) => Promise<void>,
|
||||
openAskAi: () => void,
|
||||
openAskAi: (mode: 'chat' | 'agent') => void,
|
||||
storageProviders: Array<StorageProvider>,
|
||||
|};
|
||||
|
||||
@@ -179,7 +179,7 @@ const useNewProjectDialog = ({
|
||||
const onOpenAskAi = React.useCallback(
|
||||
() => {
|
||||
closeNewProjectDialog();
|
||||
openAskAi();
|
||||
openAskAi('agent');
|
||||
},
|
||||
[closeNewProjectDialog, openAskAi]
|
||||
);
|
||||
|
@@ -616,11 +616,13 @@ const MainFrame = (props: Props) => {
|
||||
name,
|
||||
dontFocusTab,
|
||||
project,
|
||||
mode,
|
||||
}: {
|
||||
kind: EditorKind,
|
||||
name: string,
|
||||
dontFocusTab?: boolean,
|
||||
project?: ?gdProject,
|
||||
mode?: 'chat' | 'agent',
|
||||
}) => {
|
||||
const label =
|
||||
kind === 'resources'
|
||||
@@ -693,6 +695,8 @@ const MainFrame = (props: Props) => {
|
||||
const extraEditorProps =
|
||||
kind === 'start page'
|
||||
? { storageProviders: props.storageProviders }
|
||||
: kind === 'ask-ai'
|
||||
? { mode }
|
||||
: undefined;
|
||||
return {
|
||||
icon,
|
||||
@@ -1263,11 +1267,11 @@ const MainFrame = (props: Props) => {
|
||||
});
|
||||
|
||||
const openAskAi = React.useCallback(
|
||||
() => {
|
||||
(mode: 'chat' | 'agent') => {
|
||||
setState(state => {
|
||||
const askAiEditor = getOpenedAskAiEditor(state.editorTabs);
|
||||
if (askAiEditor) {
|
||||
askAiEditor.startNewChat();
|
||||
askAiEditor.startNewChat(mode);
|
||||
}
|
||||
|
||||
// Open or focus the AI editor.
|
||||
@@ -1275,7 +1279,7 @@ const MainFrame = (props: Props) => {
|
||||
...state,
|
||||
editorTabs: openEditorTab(
|
||||
state.editorTabs,
|
||||
getEditorOpeningOptions({ kind: 'ask-ai', name: '' })
|
||||
getEditorOpeningOptions({ kind: 'ask-ai', name: '', mode })
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -4291,6 +4295,7 @@ const MainFrame = (props: Props) => {
|
||||
hideTabsTitleBarAndEditorToolbar: setTabsTitleBarAndEditorToolbarHidden,
|
||||
projectItemName: editorTab.projectItemName,
|
||||
setPreviewedLayout,
|
||||
onOpenAskAi: openAskAi,
|
||||
onOpenExternalEvents: openExternalEvents,
|
||||
onOpenEvents: (sceneName: string) => {
|
||||
openLayout(sceneName, {
|
||||
|
@@ -15,6 +15,16 @@ import * as THREE from 'three';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
const getEventBasedObject = (
|
||||
project: gdProject,
|
||||
customObjectConfiguration: gdCustomObjectConfiguration
|
||||
): gdEventsBasedObject | null => {
|
||||
const type = customObjectConfiguration.getType();
|
||||
return project.hasEventsBasedObject(type)
|
||||
? project.getEventsBasedObject(type)
|
||||
: null;
|
||||
};
|
||||
|
||||
const getVariant = (
|
||||
eventBasedObject: gdEventsBasedObject,
|
||||
customObjectConfiguration: gdCustomObjectConfiguration
|
||||
@@ -93,7 +103,6 @@ const getPropertyMappingRules = (
|
||||
*/
|
||||
export default class RenderedCustomObjectInstance extends Rendered3DInstance
|
||||
implements LayoutedParent<RenderedInstance | Rendered3DInstance> {
|
||||
eventBasedObject: gdEventsBasedObject | null;
|
||||
_isRenderedIn3D = false;
|
||||
|
||||
/** Functor used to render an instance */
|
||||
@@ -137,14 +146,10 @@ export default class RenderedCustomObjectInstance extends Rendered3DInstance
|
||||
const customObjectConfiguration = gd.asCustomObjectConfiguration(
|
||||
associatedObjectConfiguration
|
||||
);
|
||||
|
||||
this.eventBasedObject = project.hasEventsBasedObject(
|
||||
customObjectConfiguration.getType()
|
||||
)
|
||||
? project.getEventsBasedObject(customObjectConfiguration.getType())
|
||||
: null;
|
||||
|
||||
const { eventBasedObject } = this;
|
||||
const eventBasedObject = getEventBasedObject(
|
||||
project,
|
||||
customObjectConfiguration
|
||||
);
|
||||
if (!eventBasedObject) {
|
||||
return;
|
||||
}
|
||||
@@ -218,13 +223,16 @@ export default class RenderedCustomObjectInstance extends Rendered3DInstance
|
||||
_getChildObjectConfiguration = (
|
||||
childObjectName: string
|
||||
): gdObjectConfiguration | null => {
|
||||
const eventBasedObject = this.eventBasedObject;
|
||||
if (!eventBasedObject) {
|
||||
return null;
|
||||
}
|
||||
const customObjectConfiguration = gd.asCustomObjectConfiguration(
|
||||
this._associatedObjectConfiguration
|
||||
);
|
||||
const eventBasedObject = getEventBasedObject(
|
||||
this._project,
|
||||
customObjectConfiguration
|
||||
);
|
||||
if (!eventBasedObject) {
|
||||
return null;
|
||||
}
|
||||
const variant = getVariant(eventBasedObject, customObjectConfiguration);
|
||||
if (!variant) {
|
||||
return null;
|
||||
@@ -363,12 +371,10 @@ export default class RenderedCustomObjectInstance extends Rendered3DInstance
|
||||
const customObjectConfiguration = gd.asCustomObjectConfiguration(
|
||||
objectConfiguration
|
||||
);
|
||||
|
||||
const eventBasedObject = project.hasEventsBasedObject(
|
||||
customObjectConfiguration.getType()
|
||||
)
|
||||
? project.getEventsBasedObject(customObjectConfiguration.getType())
|
||||
: null;
|
||||
const eventBasedObject = getEventBasedObject(
|
||||
project,
|
||||
customObjectConfiguration
|
||||
);
|
||||
if (!eventBasedObject) {
|
||||
return 'res/unknown32.png';
|
||||
}
|
||||
@@ -432,20 +438,32 @@ export default class RenderedCustomObjectInstance extends Rendered3DInstance
|
||||
}
|
||||
|
||||
getVariant(): gdEventsBasedObjectVariant | null {
|
||||
const { eventBasedObject } = this;
|
||||
if (!eventBasedObject) {
|
||||
return null;
|
||||
}
|
||||
const customObjectConfiguration = gd.asCustomObjectConfiguration(
|
||||
this._associatedObjectConfiguration
|
||||
);
|
||||
const eventBasedObject = getEventBasedObject(
|
||||
this._project,
|
||||
customObjectConfiguration
|
||||
);
|
||||
if (!eventBasedObject) {
|
||||
return null;
|
||||
}
|
||||
return getVariant(eventBasedObject, customObjectConfiguration);
|
||||
}
|
||||
|
||||
update() {
|
||||
const { eventBasedObject } = this;
|
||||
const variant = this.getVariant();
|
||||
if (!eventBasedObject || !variant) {
|
||||
const customObjectConfiguration = gd.asCustomObjectConfiguration(
|
||||
this._associatedObjectConfiguration
|
||||
);
|
||||
const eventBasedObject = getEventBasedObject(
|
||||
this._project,
|
||||
customObjectConfiguration
|
||||
);
|
||||
if (!eventBasedObject) {
|
||||
return;
|
||||
}
|
||||
const variant = getVariant(eventBasedObject, customObjectConfiguration);
|
||||
if (!variant) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@@ -42,6 +42,7 @@ export type AuthenticatedUser = {|
|
||||
receivedGameTemplates: ?Array<PrivateGameTemplate>,
|
||||
gameTemplatePurchases: ?Array<Purchase>,
|
||||
assetPackPurchases: ?Array<Purchase>,
|
||||
coursePurchases: ?Array<Purchase>,
|
||||
recommendations: ?Array<Recommendation>,
|
||||
notifications: ?Array<Notification>,
|
||||
userEarningsBalance: ?UserEarningsBalance,
|
||||
@@ -74,6 +75,7 @@ export type AuthenticatedUser = {|
|
||||
onRefreshLimits: () => Promise<void>,
|
||||
onRefreshGameTemplatePurchases: () => Promise<void>,
|
||||
onRefreshAssetPackPurchases: () => Promise<void>,
|
||||
onRefreshCoursePurchases: () => Promise<void>,
|
||||
onRefreshEarningsBalance: () => Promise<void>,
|
||||
onRefreshNotifications: () => Promise<void>,
|
||||
onPurchaseSuccessful: () => Promise<void>,
|
||||
@@ -105,6 +107,7 @@ export const initialAuthenticatedUser = {
|
||||
cloudProjectsFetchingErrorLabel: null,
|
||||
gameTemplatePurchases: null,
|
||||
assetPackPurchases: null,
|
||||
coursePurchases: null,
|
||||
recommendations: null,
|
||||
subscription: null,
|
||||
usages: null,
|
||||
@@ -131,6 +134,7 @@ export const initialAuthenticatedUser = {
|
||||
onRefreshLimits: async () => {},
|
||||
onRefreshGameTemplatePurchases: async () => {},
|
||||
onRefreshAssetPackPurchases: async () => {},
|
||||
onRefreshCoursePurchases: async () => {},
|
||||
onRefreshEarningsBalance: async () => {},
|
||||
onRefreshNotifications: async () => {},
|
||||
onPurchaseSuccessful: async () => {},
|
||||
|
@@ -219,6 +219,7 @@ export default class AuthenticatedUserProvider extends React.Component<
|
||||
onRefreshLimits: this._fetchUserLimits,
|
||||
onRefreshGameTemplatePurchases: this._fetchUserGameTemplatePurchases,
|
||||
onRefreshAssetPackPurchases: this._fetchUserAssetPackPurchases,
|
||||
onRefreshCoursePurchases: this._fetchUserCoursePurchases,
|
||||
onRefreshEarningsBalance: this._fetchEarningsBalance,
|
||||
onRefreshNotifications: this._fetchUserNotifications,
|
||||
onPurchaseSuccessful: this._fetchUserProducts,
|
||||
@@ -560,6 +561,22 @@ export default class AuthenticatedUserProvider extends React.Component<
|
||||
console.error('Error while loading asset pack purchases:', error);
|
||||
}
|
||||
);
|
||||
listUserPurchases(authentication.getAuthorizationHeader, {
|
||||
userId: firebaseUser.uid,
|
||||
productType: 'course',
|
||||
role: 'receiver',
|
||||
}).then(
|
||||
coursePurchases =>
|
||||
this.setState(({ authenticatedUser }) => ({
|
||||
authenticatedUser: {
|
||||
...authenticatedUser,
|
||||
coursePurchases,
|
||||
},
|
||||
})),
|
||||
error => {
|
||||
console.error('Error while loading course purchases:', error);
|
||||
}
|
||||
);
|
||||
this._fetchUserBadges();
|
||||
this._fetchAchievements();
|
||||
this._fetchUserNotifications();
|
||||
@@ -849,6 +866,32 @@ export default class AuthenticatedUserProvider extends React.Component<
|
||||
}
|
||||
};
|
||||
|
||||
_fetchUserCoursePurchases = async () => {
|
||||
const { authentication } = this.props;
|
||||
const firebaseUser = this.state.authenticatedUser.firebaseUser;
|
||||
if (!firebaseUser) return;
|
||||
|
||||
try {
|
||||
const coursePurchases = await listUserPurchases(
|
||||
authentication.getAuthorizationHeader,
|
||||
{
|
||||
userId: firebaseUser.uid,
|
||||
productType: 'course',
|
||||
role: 'receiver',
|
||||
}
|
||||
);
|
||||
|
||||
this.setState(({ authenticatedUser }) => ({
|
||||
authenticatedUser: {
|
||||
...authenticatedUser,
|
||||
coursePurchases,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error while loading course purchases:', error);
|
||||
}
|
||||
};
|
||||
|
||||
_fetchUserProducts = async () => {
|
||||
await Promise.all([
|
||||
this._fetchUserAssetPacks(),
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user