Compare commits

...

53 Commits

Author SHA1 Message Date
Florian Rival
59c48d52f7 Change how bundle price is calculated to avoid API calls 2025-08-01 12:45:40 +02:00
github-actions[bot]
5f52d786c6 Update translations [skip ci] (#7749)
Co-authored-by: ClementPasteau <4895034+ClementPasteau@users.noreply.github.com>
2025-07-31 14:54:34 +02:00
Clément Pasteau
e0db597f9d A new Bundle to get started with GDevelop is now available (#7751)
* including multiple game templates and asset packs
* including a redemption code for a premium subscription
* including multiple official game dev courses
2025-07-31 14:18:05 +02:00
Florian Rival
41b0315ec6 Improve rating banner for course chapters 2025-07-30 18:49:27 +02:00
Florian Rival
a930a4085e Add basic button to rate premium course chapter 2025-07-30 14:56:46 +02:00
Florian Rival
d0dbbfac07 Add "StrReplaceOne" and "StrReplaceAll" expressions (#7750) 2025-07-30 10:10:29 +02:00
Florian Rival
3dc24b46f4 Fix warning
Don't show in changelog
2025-07-28 13:54:12 +02:00
Florian Rival
8e44a357b4 Fix Android build and player authentication sometimes not working (#7748)
- Player authentication window could not open if no action/condition related to player authentication was used
- Fix Android build by using an updated dependency for opening the authentication window
2025-07-28 12:55:58 +02:00
Florian Rival
dd462310cc Reduce network requests at startup by lazily loading course chapters when opened 2025-07-26 16:37:38 +02:00
Florian Rival
a1935fa0cd Reduce a bit more unnecessary fetches for course chapters
Don't show in changelog
2025-07-26 14:38:54 +02:00
Florian Rival
b45c57246b Add animation names to inspected object properties for AI 2025-07-26 14:21:44 +02:00
Florian Rival
c481ecd6b5 Bump newIDE version 2025-07-25 15:47:07 +02:00
github-actions[bot]
e0898dd9b0 Update translations [skip ci] (#7737)
Co-authored-by: 4ian <1280130+4ian@users.noreply.github.com>
2025-07-25 13:40:41 +02:00
Clément Pasteau
5561334efa Fix Send icon color (#7745) 2025-07-25 13:33:02 +02:00
Florian Rival
6c4bb4f79e Fix selection of face orientation in 3D Cube editor 2025-07-25 10:04:14 +02:00
D8H
8b2d2e2fe7 Fix "edit children" drop-down menu item activation (#7744)
- don't show in changelog
2025-07-24 14:04:25 +02:00
Florian Rival
49d128c964 Display "Ask AI" as a separate pane (or drawer on small screens) (#7738) 2025-07-24 13:20:52 +02:00
D8H
f24d1e0916 Add a deprecation message for custom objects using old "configuration overriding" (#7742) 2025-07-24 13:19:23 +02:00
D8H
9faa4c0c69 Fix button labels not refreshing when modified from the side panel (#7741) 2025-07-24 10:32:48 +02:00
D8H
a04b8f65db Allow to select a custom object variant in the properties panel (#7740)
* Also show a dialog to duplicate a variant before opening them if necessary.
2025-07-23 17:26:03 +02:00
D8H
e1cf7d23cd Various fixes for variants (#7739)
- Forbid to edit the default variant of published extensions
- Hide the children configuration from the side panel when a variant is used
- Fix the Z-order of nested custom objects in the editor
- Fix a memory crash when updating an extension where behaviors must be removed from child-objects
2025-07-22 19:06:33 +02:00
github-actions[bot]
b74b221844 Update translations [skip ci] (#7730)
Co-authored-by: D8H <2611977+D8H@users.noreply.github.com>
2025-07-22 11:43:45 +02:00
Clément Pasteau
38affc15b4 Fix making too many calls for courses (#7736)
Do not show in changelog
2025-07-22 11:43:21 +02:00
D8H
948488d92b [Top-down movement] Fix the legacy turning back mode (#7735) 2025-07-21 22:07:00 +02:00
Florian Rival
f5902d0346 Enable visibility of 3D cube backface by default 2025-07-21 10:52:41 +02:00
Clément Pasteau
f28dc8e88a Fix images pixelated because of border (#7732)
Do not show in changelog
2025-07-18 14:37:06 +02:00
Clément Pasteau
1f41749fa3 Fix carousel mobile (#7729)
Do not show in changelog
2025-07-17 15:49:36 +02:00
Florian Rival
a4908a4d42 Add spell check option for text input (disabled by default) (#7728) 2025-07-17 14:39:30 +02:00
Clément Pasteau
aa7754e658 Fixes responsive design and courses (#7726)
Do not show in changelog
2025-07-17 10:20:02 +02:00
github-actions[bot]
58ea9387aa Update extension translations [skip ci] (#7727)
Co-authored-by: ClementPasteau <4895034+ClementPasteau@users.noreply.github.com>
2025-07-17 10:17:37 +02:00
github-actions[bot]
775266c974 Update translations [skip ci] (#7722)
Co-authored-by: 4ian <1280130+4ian@users.noreply.github.com>
2025-07-17 10:15:12 +02:00
Florian Rival
eb9794cd1f Bump newIDE version 2025-07-17 09:42:20 +02:00
Florian Rival
130732adde Refactor to make choice between chat or agent explicit
Don't show in changelog
2025-07-17 00:32:05 +02:00
Florian Rival
7a98e73d61 Fix AI not drawing properly multiple instances 2025-07-17 00:08:25 +02:00
D8H
1f26b72b4b Fix Physics3D from also creating a solid when the character behavior is re-activated (#7723) 2025-07-16 19:52:38 +02:00
D8H
a15ffb5b47 Add missing onSceneObjectsDeleted for custom object tabs (#7724) 2025-07-16 19:51:41 +02:00
Florian Rival
1a5f72283a Update price tag design 2025-07-16 18:36:25 +02:00
github-actions[bot]
0460b283ba Update translations [skip ci] (#7703)
Co-authored-by: ClementPasteau <4895034+ClementPasteau@users.noreply.github.com>
2025-07-16 12:08:11 +02:00
Yaroslav Nazarenko
e212e7c780 Add an action to join a specific lobby using its identifier, and an expression to get the current lobby ID (#7694) 2025-07-16 12:07:32 +02:00
Clément Pasteau
84100fc7cf Introducing a new Learn page (#7705)
* The Get Started page has been removed and replaced by the Learn page as the first page displayed when GDevelop launches
  * The Learn page has been completely reworked to put forward the different resources a creator can use to improve their skills with Game Creation
  * A new option in the Preferences allows users to define the Create page as the default first page on launch
* Courses can now be purchased as a whole instead of per chapter, making it simpler to follow a full course
  * Bundles with multiple courses are coming up soon!
2025-07-16 11:59:56 +02:00
D8H
11a8682b07 Fix a crash of the scene editor when a custom object extension is updated (#7720) 2025-07-16 11:40:28 +02:00
Florian Rival
d3a0bbdfb1 Disable autorun of npm start when opening VSCode [skip ci] [ci skip] (#7719)
Only show in developer changelog
2025-07-16 10:56:09 +02:00
D8H
15f3a45d6a Fix 3D impulse and force toward a point actions (#7716) 2025-07-15 15:27:31 +02:00
D8H
f0a4f352cc Fix effect default values (#7706) 2025-07-15 13:56:20 +02:00
D8H
d16b3e8154 Fix multiplayer synchronization of custom object positions (#7715)
---------

Co-authored-by: Clément Pasteau <4895034+ClementPasteau@users.noreply.github.com>
2025-07-15 13:38:23 +02:00
D8H
614fb97288 Fix 3D physics behaviors activation and deactivation (#7710) 2025-07-15 13:35:54 +02:00
D8H
8a40d3645a Fix the check box to stop sounds at scene start up not being applied properly (#7714) 2025-07-15 10:38:18 +02:00
D8H
2b7dadf2a8 Fix text input displayed position when inside a custom object whose layer is moved (#7713) 2025-07-14 17:47:44 +02:00
D8H
c338e16e4f Fix the "cursor is on" condition when used on custom object parent object (#7712) 2025-07-14 17:46:11 +02:00
Florian Rival
aded08471d Adapt sentences displaying free AI requests
Don't show in changelog
2025-07-13 19:40:10 +02:00
Florian Rival
cccb59b1c5 Fix AI agent not working with games with a lot of extensions or that were too big 2025-07-12 16:50:38 +02:00
D8H
3592fb7e62 Fix hemisphere light orientation when the top is set on Z+ (#7708) 2025-07-10 12:09:18 +02:00
D8H
307c92991c Fix shadow casting and receiving that were inverted for 3D boxes (#7704) 2025-07-09 13:43:01 +02:00
352 changed files with 13279 additions and 5853 deletions

1
.gitignore vendored
View File

@@ -33,3 +33,4 @@
.Spotlight-V100
.Trashes
Thumbs.db
.claude

3
.vscode/tasks.json vendored
View File

@@ -38,8 +38,7 @@
"presentation": {
"reveal": "silent"
},
"isBackground": true,
"runOptions": { "instanceLimit": 1, "runOn": "folderOpen" }
"isBackground": true
},
{
"type": "npm",

View File

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

View File

@@ -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());

View File

@@ -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

View File

@@ -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);

View File

@@ -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();

View File

@@ -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.
*/

View File

@@ -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() {

View File

@@ -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();
}
})();

View File

@@ -1125,7 +1125,7 @@ module.exports = {
topFaceResourceName: '',
bottomFaceResourceName: '',
frontFaceVisible: true,
backFaceVisible: false,
backFaceVisible: true,
leftFaceVisible: true,
rightFaceVisible: true,
topFaceVisible: true,
@@ -1916,11 +1916,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')
@@ -1974,6 +1974,7 @@ module.exports = {
.setValue('1500')
.setLabel(_("Distance from layer's camera"))
.setType('number')
.setGroup(_('Shadows'))
.setAdvanced(true);
}
{
@@ -2006,11 +2007,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')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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(

View File

@@ -25,8 +25,6 @@ namespace gdjs {
export type PanelSpriteObjectData = ObjectData & PanelSpriteObjectDataType;
export type PanelSpriteNetworkSyncDataType = {
wid: number;
hei: number;
op: number;
color: string;
};
@@ -124,8 +122,6 @@ namespace gdjs {
getNetworkSyncData(): PanelSpriteNetworkSyncData {
return {
...super.getNetworkSyncData(),
wid: this.getWidth(),
hei: this.getHeight(),
op: this.getOpacity(),
color: this.getColor(),
};
@@ -138,12 +134,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);
}

View File

@@ -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',

View File

@@ -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)
);
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

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

View File

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

View File

@@ -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);
}

View File

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

View File

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

View File

@@ -54,6 +54,7 @@ namespace gdjs {
disabled: boolean;
readOnly: boolean;
// ---- Values can be undefined because of support for these feature was added in v5.5.222.
spellCheck?: boolean;
paddingX?: float;
paddingY?: float;
textAlign?: SupportedTextAlign;
@@ -64,8 +65,6 @@ namespace gdjs {
export type TextInputNetworkSyncDataType = {
opa: float;
wid: float;
hei: float;
txt: string;
frn: string;
fs: number;
@@ -79,6 +78,7 @@ namespace gdjs {
bw: float;
dis: boolean;
ro: boolean;
sc: boolean;
};
export type TextInputNetworkSyncData = ObjectNetworkSyncData &
@@ -118,6 +118,7 @@ namespace gdjs {
private _borderWidth: float;
private _disabled: boolean;
private _readOnly: boolean;
private _spellCheck: boolean;
private _isSubmitted: boolean;
_renderer: TextInputRuntimeObjectRenderer;
@@ -142,6 +143,10 @@ namespace gdjs {
this._borderWidth = objectData.content.borderWidth;
this._disabled = objectData.content.disabled;
this._readOnly = objectData.content.readOnly;
this._spellCheck =
objectData.content.spellCheck !== undefined
? objectData.content.spellCheck
: false;
this._textAlign = parseTextAlign(objectData.content.textAlign);
this._maxLength = objectData.content.maxLength || 0;
this._paddingX =
@@ -228,6 +233,12 @@ namespace gdjs {
if (oldObjectData.content.readOnly !== newObjectData.content.readOnly) {
this.setReadOnly(newObjectData.content.readOnly);
}
if (
newObjectData.content.spellCheck !== undefined &&
oldObjectData.content.spellCheck !== newObjectData.content.spellCheck
) {
this.setSpellCheck(newObjectData.content.spellCheck);
}
if (
newObjectData.content.maxLength !== undefined &&
oldObjectData.content.maxLength !== newObjectData.content.maxLength
@@ -260,8 +271,6 @@ namespace gdjs {
return {
...super.getNetworkSyncData(),
opa: this.getOpacity(),
wid: this.getWidth(),
hei: this.getHeight(),
txt: this.getText(),
frn: this.getFontResourceName(),
fs: this.getFontSize(),
@@ -275,6 +284,7 @@ namespace gdjs {
bw: this.getBorderWidth(),
dis: this.isDisabled(),
ro: this.isReadOnly(),
sc: this.isSpellCheckEnabled(),
};
}
@@ -282,8 +292,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);
@@ -297,6 +305,7 @@ namespace gdjs {
if (syncData.bw !== undefined) this.setBorderWidth(syncData.bw);
if (syncData.dis !== undefined) this.setDisabled(syncData.dis);
if (syncData.ro !== undefined) this.setReadOnly(syncData.ro);
if (syncData.sc !== undefined) this.setSpellCheck(syncData.sc);
}
updatePreRender(instanceContainer: RuntimeInstanceContainer): void {
@@ -569,6 +578,15 @@ namespace gdjs {
return this._readOnly;
}
setSpellCheck(value: boolean) {
this._spellCheck = value;
this._renderer.updateSpellCheck();
}
isSpellCheckEnabled(): boolean {
return this._spellCheck;
}
isFocused(): boolean {
return this._renderer.isFocused();
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -15,8 +15,6 @@ namespace gdjs {
export type TiledSpriteObjectData = ObjectData & TiledSpriteObjectDataType;
export type TiledSpriteNetworkSyncDataType = {
wid: number;
hei: number;
xo: number;
yo: number;
op: number;
@@ -83,8 +81,6 @@ namespace gdjs {
getNetworkSyncData(): TiledSpriteNetworkSyncData {
return {
...super.getNetworkSyncData(),
wid: this.getWidth(),
hei: this.getHeight(),
xo: this.getXOffset(),
yo: this.getYOffset(),
op: this.getOpacity(),
@@ -99,12 +95,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);
}

View File

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

View File

@@ -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();
}

View File

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

View File

@@ -17,6 +17,11 @@ namespace gdjs {
isInnerAreaFollowingParentSize: boolean;
};
export type CustomObjectNetworkSyncDataType = ObjectNetworkSyncData & {
ifx: boolean;
ify: boolean;
};
/**
* An object that contains other object.
*
@@ -216,6 +221,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
) {

View File

@@ -382,11 +382,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);
}
/**

View File

@@ -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
);
};

View File

@@ -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);
}
@@ -2721,11 +2729,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(

View File

@@ -52,6 +52,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. */

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
};
};

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

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

View File

@@ -6,6 +6,7 @@ import {
redeemPrivateAssetPack,
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
getCalloutToGetSubscriptionOrClaimAssetPack,
} from '../../Utils/GDevelopServices/Shop';
import type { MessageDescriptor } from '../../Utils/i18n/MessageDescriptor.flow';
@@ -64,8 +65,10 @@ 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';
import { BundleStoreContext } from '../Bundles/BundleStoreContext';
const cellSpacing = 8;
const cellSpacing = 10;
const getPackColumns = (windowSize: WindowSizeType, isLandscape: boolean) => {
switch (windowSize) {
@@ -76,7 +79,7 @@ const getPackColumns = (windowSize: WindowSizeType, isLandscape: boolean) => {
case 'large':
return 4;
case 'xlarge':
return 5;
return 6;
default:
return 3;
}
@@ -108,11 +111,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,
@@ -158,11 +166,9 @@ type Props = {|
|}
) => void,
onGameTemplateOpen: (
privateGameTemplateListingData: PrivateGameTemplateListingData,
options?: {|
forceProductPage?: boolean,
|}
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onBundleOpen: (bundleListingData: BundleListingData) => void,
simulateAppStoreProduct?: boolean,
|};
@@ -171,17 +177,21 @@ const PrivateAssetPackInformationPage = ({
privateAssetPackListingDatasFromSameCreator,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
simulateAppStoreProduct,
}: Props) => {
const { isMobile } = useResponsiveWindowSize();
const { id, name, sellerId } = privateAssetPackListingData;
const { privateAssetPackListingDatas } = React.useContext(AssetStoreContext);
const { bundleListingDatas } = React.useContext(BundleStoreContext);
const { showAlert } = useAlertDialog();
const {
receivedAssetPacks,
receivedBundles,
profile,
limits,
assetPackPurchases,
bundlePurchases,
getAuthorizationHeader,
onOpenLoginDialog,
subscription,
@@ -211,10 +221,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();
@@ -229,15 +238,27 @@ const PrivateAssetPackInformationPage = ({
productId: privateAssetPackListingData
? privateAssetPackListingData.id
: null,
receivedProducts: receivedAssetPacks,
productPurchases: assetPackPurchases,
allProductListingDatas: privateAssetPackListingDatas,
receivedProducts: [
...(receivedAssetPacks || []),
...(receivedBundles || []),
],
productPurchases: [
...(assetPackPurchases || []),
...(bundlePurchases || []),
],
allProductListingDatas: [
...(privateAssetPackListingDatas || []),
...(bundleListingDatas || []),
],
}),
[
assetPackPurchases,
bundlePurchases,
privateAssetPackListingData,
privateAssetPackListingDatas,
bundleListingDatas,
receivedAssetPacks,
receivedBundles,
]
);
const isAlreadyReceived = !!userAssetPackPurchaseUsageType;
@@ -246,17 +267,21 @@ const PrivateAssetPackInformationPage = ({
() =>
getProductsIncludedInBundleTiles({
product: assetPack,
productListingDatas: privateAssetPackListingDatas,
productListingDatas: [...(privateAssetPackListingDatas || [])],
productListingData: privateAssetPackListingData,
receivedProducts: receivedAssetPacks,
onProductOpen: product =>
receivedProducts: [...(receivedAssetPacks || [])],
onPrivateAssetPackOpen: product =>
onAssetPackOpen(product, { forceProductPage: true }),
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
}),
[
assetPack,
privateAssetPackListingDatas,
receivedAssetPacks,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
privateAssetPackListingData,
]
);
@@ -265,16 +290,30 @@ const PrivateAssetPackInformationPage = ({
() =>
getBundlesContainingProductTiles({
product: assetPack,
productListingDatas: privateAssetPackListingDatas,
receivedProducts: receivedAssetPacks,
onProductOpen: product =>
productListingData: privateAssetPackListingData,
productListingDatas: [
...(privateAssetPackListingDatas || []),
...(bundleListingDatas || []),
],
receivedProducts: [
...(receivedAssetPacks || []),
...(receivedBundles || []),
],
onPrivateAssetPackOpen: product =>
onAssetPackOpen(product, { forceProductPage: true }),
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
}),
[
assetPack,
privateAssetPackListingData,
privateAssetPackListingDatas,
bundleListingDatas,
receivedAssetPacks,
receivedBundles,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
]
);
@@ -297,10 +336,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 +749,7 @@ const PrivateAssetPackInformationPage = ({
simulateAppStoreProduct={simulateAppStoreProduct}
isAlreadyReceived={isAlreadyReceived}
onClickBuy={onClickBuy}
onClickBuyWithCredits={onClickBuyWithCredits}
onClickBuyWithCredits={onWillBuyWithCredits}
/>
)}
</>
@@ -764,7 +809,7 @@ const PrivateAssetPackInformationPage = ({
<GridList
cols={getPackColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
style={styles.grid}
>
{packsIncludedInBundleTiles}
@@ -784,7 +829,7 @@ const PrivateAssetPackInformationPage = ({
<GridList
cols={getPackColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
style={styles.grid}
>
{otherPacksFromTheSameAuthorTiles}
@@ -797,8 +842,12 @@ const PrivateAssetPackInformationPage = ({
) : null}
{displayPasswordPrompt && (
<PasswordPromptDialog
onApply={onRedeemAssetPack}
onClose={() => setDisplayPasswordPrompt(false)}
onApply={
displayPasswordPrompt === 'redeem'
? onWillRedeemAssetPack
: onClickBuyWithCredits
}
onClose={() => setDisplayPasswordPrompt(null)}
passwordValue={password}
setPasswordValue={setPassword}
/>

View File

@@ -5,6 +5,7 @@ import {
buyProductWithCredits,
type PrivateGameTemplateListingData,
type PrivateAssetPackListingData,
type BundleListingData,
} from '../../Utils/GDevelopServices/Shop';
import {
getPrivateGameTemplate,
@@ -59,8 +60,10 @@ 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';
import { BundleStoreContext } from '../Bundles/BundleStoreContext';
const cellSpacing = 8;
const cellSpacing = 10;
const getTemplateColumns = (
windowSize: WindowSizeType,
@@ -74,17 +77,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,
@@ -118,8 +125,16 @@ const styles = {
type Props = {|
privateGameTemplateListingData: PrivateGameTemplateListingData,
privateGameTemplateListingDatasFromSameCreator?: ?Array<PrivateGameTemplateListingData>,
onGameTemplateOpen: PrivateGameTemplateListingData => void,
onAssetPackOpen?: PrivateAssetPackListingData => void,
onAssetPackOpen?: (
privateAssetPackListingData: PrivateAssetPackListingData,
options?: {|
forceProductPage?: boolean,
|}
) => void,
onGameTemplateOpen: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onBundleOpen?: (bundleListingData: BundleListingData) => void,
onCreateWithGameTemplate?: PrivateGameTemplateListingData => void,
simulateAppStoreProduct?: boolean,
|};
@@ -129,6 +144,7 @@ const PrivateGameTemplateInformationPage = ({
privateGameTemplateListingDatasFromSameCreator,
onGameTemplateOpen,
onAssetPackOpen,
onBundleOpen,
onCreateWithGameTemplate,
simulateAppStoreProduct,
}: Props) => {
@@ -136,11 +152,14 @@ const PrivateGameTemplateInformationPage = ({
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
);
const { bundleListingDatas } = React.useContext(BundleStoreContext);
const {
receivedGameTemplates,
receivedBundles,
profile,
limits,
gameTemplatePurchases,
bundlePurchases,
getAuthorizationHeader,
onOpenLoginDialog,
} = React.useContext(AuthenticatedUserContext);
@@ -186,15 +205,27 @@ const PrivateGameTemplateInformationPage = ({
productId: privateGameTemplateListingData
? privateGameTemplateListingData.id
: null,
receivedProducts: receivedGameTemplates,
productPurchases: gameTemplatePurchases,
allProductListingDatas: privateGameTemplateListingDatas,
receivedProducts: [
...(receivedGameTemplates || []),
...(receivedBundles || []),
],
productPurchases: [
...(gameTemplatePurchases || []),
...(bundlePurchases || []),
],
allProductListingDatas: [
...(privateGameTemplateListingDatas || []),
...(bundleListingDatas || []),
],
}),
[
gameTemplatePurchases,
bundlePurchases,
privateGameTemplateListingData,
privateGameTemplateListingDatas,
bundleListingDatas,
receivedGameTemplates,
receivedBundles,
]
);
const isAlreadyReceived = !!userGameTemplatePurchaseUsageType;
@@ -203,33 +234,55 @@ const PrivateGameTemplateInformationPage = ({
() =>
getProductsIncludedInBundleTiles({
product: gameTemplate,
productListingDatas: privateGameTemplateListingDatas,
productListingDatas: [...(privateGameTemplateListingDatas || [])],
productListingData: privateGameTemplateListingData,
receivedProducts: receivedGameTemplates,
onProductOpen: onGameTemplateOpen,
receivedProducts: [...(receivedGameTemplates || [])],
onPrivateAssetPackOpen: onAssetPackOpen
? product => onAssetPackOpen(product, { forceProductPage: true })
: undefined,
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
}),
[
gameTemplate,
privateGameTemplateListingDatas,
receivedGameTemplates,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
privateGameTemplateListingData,
]
);
const bundlesContainingPackTiles = React.useMemo(
const bundlesContainingTemplateTiles = React.useMemo(
() =>
getBundlesContainingProductTiles({
product: gameTemplate,
productListingDatas: privateGameTemplateListingDatas,
receivedProducts: receivedGameTemplates,
onProductOpen: onGameTemplateOpen,
productListingData: privateGameTemplateListingData,
productListingDatas: [
...(privateGameTemplateListingDatas || []),
...(bundleListingDatas || []),
],
receivedProducts: [
...(receivedGameTemplates || []),
...(receivedBundles || []),
],
onPrivateAssetPackOpen: onAssetPackOpen
? product => onAssetPackOpen(product, { forceProductPage: true })
: undefined,
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
}),
[
gameTemplate,
privateGameTemplateListingData,
privateGameTemplateListingDatas,
bundleListingDatas,
receivedGameTemplates,
receivedBundles,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
]
);
@@ -558,7 +611,7 @@ const PrivateGameTemplateInformationPage = ({
simulateAppStoreProduct={simulateAppStoreProduct}
isAlreadyReceived={isAlreadyReceived}
onClickBuy={onClickBuy}
onClickBuyWithCredits={onClickBuyWithCredits}
onClickBuyWithCredits={onWillBuyWithCredits}
/>
)}
</>
@@ -584,12 +637,12 @@ const PrivateGameTemplateInformationPage = ({
/>
</Text>
</Column>
{bundlesContainingPackTiles &&
bundlesContainingPackTiles.length ? (
{bundlesContainingTemplateTiles &&
bundlesContainingTemplateTiles.length ? (
<>
<ColumnStackLayout noMargin>
<LargeSpacer />
{bundlesContainingPackTiles}
{bundlesContainingTemplateTiles}
<LargeSpacer />
</ColumnStackLayout>
</>
@@ -605,7 +658,7 @@ const PrivateGameTemplateInformationPage = ({
<GridList
cols={getTemplateColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
style={styles.grid}
>
{templatesIncludedInBundleTiles}
@@ -625,7 +678,7 @@ const PrivateGameTemplateInformationPage = ({
<GridList
cols={getTemplateColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
style={styles.grid}
>
{otherTemplatesFromTheSameAuthorTiles}
@@ -638,7 +691,7 @@ const PrivateGameTemplateInformationPage = ({
) : null}
{displayPasswordPrompt && (
<PasswordPromptDialog
onApply={onWillBuyWithCredits}
onApply={onClickBuyWithCredits}
onClose={() => setDisplayPasswordPrompt(false)}
passwordValue={password}
setPasswordValue={setPassword}

View File

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

View File

@@ -8,6 +8,8 @@ import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type CreditsPackageListingData,
type CourseListingData,
type BundleListingData,
} from '../Utils/GDevelopServices/Shop';
import {
shouldUseAppStoreProduct,
@@ -16,11 +18,25 @@ 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';
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,13 @@ type FormatProps = {|
productListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CreditsPackageListingData,
| CreditsPackageListingData
| CourseListingData
| BundleListingData,
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,11 +58,13 @@ export const renderProductPrice = ({
productListingData,
usageType,
plainText,
showBothPrices,
}: FormatProps): React.Node => {
// Only use the app store product if it's a credits package.
// For Credits packages & Bundles, on mobile, only show the app store product price.
if (
shouldUseAppStoreProduct() &&
productListingData.productType === 'CREDITS_PACKAGE'
(productListingData.productType === 'CREDITS_PACKAGE' ||
productListingData.productType === 'BUNDLE')
) {
const appStoreProduct = getAppStoreProduct(
productListingData.appStoreProductId
@@ -51,19 +72,15 @@ export const renderProductPrice = ({
return appStoreProduct ? appStoreProduct.price : '';
}
// If we're on mobile, only show credits prices for asset packs & game templates.
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;
const creditPrices = productListingData.creditPrices || [];
const creditPrice = usageType
? creditPrices.find(price => price.usageType === usageType)
: creditPrices.length > 0
? creditPrices[0]
: null;
// If we're on mobile, only show credits prices for other packages.
if (shouldUseAppStoreProduct()) {
if (!creditPrice) return '';
return plainText ? (
i18n._(t`${creditPrice.amount} credits`)
@@ -96,6 +113,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 +158,42 @@ type ProductPriceOrOwnedProps = {|
productListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CreditsPackageListingData,
| CreditsPackageListingData
| CourseListingData
| BundleListingData,
i18n: I18nType,
usageType?: string,
owned?: boolean,
showBothPrices?: 'column' | 'line',
|};
export const OwnedLabel = () => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<LineStackLayout noMargin alignItems="center">
<CheckCircle
style={{
color: gdevelopTheme.message.valid,
}}
/>
<Text noMargin size="sub-title" color="inherit">
<Trans>Owned</Trans>
</Text>
</LineStackLayout>
);
};
export const getProductPriceOrOwnedLabel = ({
i18n,
productListingData,
usageType,
owned,
showBothPrices,
}: ProductPriceOrOwnedProps): React.Node => {
return owned ? (
<LineStackLayout noMargin alignItems="center">
<Text noMargin size="sub-title">
</Text>
<Text noMargin size="sub-title" color="inherit">
<Trans>Owned</Trans>
</Text>
</LineStackLayout>
<OwnedLabel />
) : (
renderProductPrice({ i18n, productListingData, usageType })
renderProductPrice({ i18n, productListingData, usageType, showBothPrices })
);
};
@@ -137,7 +201,9 @@ type ProductPriceTagProps = {|
productListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CreditsPackageListingData,
| CreditsPackageListingData
| CourseListingData
| BundleListingData,
usageType?: string,
/**
* To be used when the component is over an element for which

View File

@@ -9,18 +9,20 @@ import {
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
type CourseListingData,
} from '../Utils/GDevelopServices/Shop';
import type { ExampleShortHeader } from '../Utils/GDevelopServices/Example';
import GridListTile from '@material-ui/core/GridListTile';
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';
import Text from '../UI/Text';
import { Trans } from '@lingui/macro';
import ProductPriceTag, { renderProductPrice } from './ProductPriceTag';
import ProductPriceTag, {
OwnedLabel,
renderProductPrice,
} from './ProductPriceTag';
import { AssetCard } from './AssetCard';
import FolderIcon from '../UI/CustomSvgIcons/Folder';
import FlatButton from '../UI/FlatButton';
@@ -30,6 +32,8 @@ 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';
import { selectMessageByLocale } from '../Utils/i18n/MessageByLocale';
const styles = {
priceTagContainer: {
@@ -41,7 +45,7 @@ const styles = {
previewImage: {
width: '100%',
display: 'block',
objectFit: 'cover',
objectFit: 'contain',
borderRadius: 8,
border: '1px solid lightgrey',
boxSizing: 'border-box', // Take border in account for sizing to avoid cumulative layout shift.
@@ -81,11 +85,6 @@ const styles = {
minWidth: 200,
margin: 4,
},
paper: {
margin: 4,
display: 'flex',
flexDirection: 'column',
},
packTitle: {
...textEllipsisStyle,
overflowWrap: 'break-word',
@@ -112,20 +111,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 +126,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 +157,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 +194,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,73 +252,78 @@ 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>
);
};
export const PromoBundleCard = ({
productListingData,
bundleProductListingData,
includedProductListingData,
onSelect,
owned,
}: {|
productListingData:
bundleProductListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData,
| PrivateGameTemplateListingData
| BundleListingData,
includedProductListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CourseListingData
| BundleListingData,
onSelect: () => void,
owned: boolean,
|}) => {
@@ -362,52 +341,44 @@ export const PromoBundleCard = ({
<ResponsiveLineStackLayout expand noMargin noResponsiveLandscape>
<div style={styles.promoImageContainer}>
<CorsAwareImage
key={productListingData.name}
key={bundleProductListingData.name}
style={{
...styles.previewImage,
...styles.promoImage,
}}
src={productListingData.thumbnailUrls[0]}
alt={`Preview image of bundle ${productListingData.name}`}
src={bundleProductListingData.thumbnailUrls[0]}
alt={`Preview image of bundle ${
bundleProductListingData.name
}`}
loading="lazy"
/>
</div>
<Column expand alignItems="flex-start" justifyContent="center">
<Text color="primary" size="section-title">
{!owned ? (
<Trans>Get {productListingData.description}!</Trans>
) : productListingData.productType === 'ASSET_PACK' ? (
<Trans>You already own this pack!</Trans>
<Trans>Get {bundleProductListingData.name}!</Trans>
) : (
<Trans>You already own this template!</Trans>
<Trans>
You already own {bundleProductListingData.name}!
</Trans>
)}
</Text>
<Text color="primary" size="body2">
{!owned ? (
productListingData.productType === 'ASSET_PACK' ? (
<Trans>
This pack is included in this bundle for{' '}
{renderProductPrice({
i18n,
productListingData,
plainText: true,
})}
!
</Trans>
) : (
<Trans>
This template is included in this bundle for{' '}
{renderProductPrice({
i18n,
productListingData,
plainText: true,
})}
!
</Trans>
)
<Trans>
{includedProductListingData.name} is included in this
bundle for{' '}
{renderProductPrice({
i18n,
productListingData: bundleProductListingData,
plainText: true,
})}
!
</Trans>
) : (
<Trans>
It is included in the bundle {productListingData.name}.
{includedProductListingData.name} is included in the
bundle {bundleProductListingData.name}.
</Trans>
)}
</Text>
@@ -455,40 +426,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,51 +473,179 @@ 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>
);
};
export const CourseTile = ({
courseListingData,
onSelect,
style,
owned,
disabled,
}: {|
courseListingData: CourseListingData,
onSelect: () => void,
/** Props needed so that GridList component can adjust tile size */
style?: any,
owned: boolean,
disabled?: boolean,
|}) => {
const { isMobile } = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column noMargin expand noOverflowParent>
<CorsAwareImage
key={courseListingData.name}
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={courseListingData.thumbnailUrls[0]}
alt={`Preview image of course ${courseListingData.name}`}
loading="lazy"
/>
<div style={styles.priceTagContainer}>
<ProductPriceTag
productListingData={courseListingData}
withOverlay
owned={owned}
/>
</div>
<Column>
{isMobile && <Spacer />}
<Line justifyContent="flex-start" noMargin>
<Text style={styles.packTitle} size="body2" noMargin={isMobile}>
{courseListingData.name}
</Text>
</Line>
</Column>
</Column>
</CardWidget>
</GridListTile>
);
};
export const BundleTile = ({
bundleListingData,
onSelect,
style,
owned,
disabled,
}: {|
bundleListingData: BundleListingData,
onSelect: () => void,
/** Props needed so that GridList component can adjust tile size */
style?: any,
owned: boolean,
disabled?: boolean,
|}) => {
const { isMobile } = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<I18n>
{({ i18n }) => (
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column noMargin expand noOverflowParent>
<CorsAwareImage
key={bundleListingData.name}
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={bundleListingData.thumbnailUrls[0]}
alt={`Preview image of bundle ${bundleListingData.name}`}
loading="lazy"
/>
<div style={styles.priceTagContainer}>
{bundleListingData ? (
<ProductPriceTag
productListingData={bundleListingData}
withOverlay
owned={owned}
/>
) : (
<OwnedLabel />
)}
</div>
<Column>
{isMobile && <Spacer />}
<Line justifyContent="flex-start" noMargin>
<Text
style={styles.packTitle}
size="body2"
noMargin={isMobile}
>
{bundleListingData.nameByLocale
? selectMessageByLocale(
i18n,
bundleListingData.nameByLocale
)
: bundleListingData.name}
</Text>
</Line>
</Column>
</Column>
</CardWidget>
</GridListTile>
)}
</I18n>
);
};
export const ExampleTile = ({
exampleShortHeader,
onSelect,
@@ -572,6 +665,7 @@ export const ExampleTile = ({
disabled?: boolean,
|}) => {
const { isMobile } = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const thumbnailImgUrl = React.useMemo(
() => {
if (!exampleShortHeader) return '';
@@ -587,66 +681,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>
);
};

View File

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

View File

@@ -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>
);
}

View File

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

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
&nbsp;
<Text noMargin>

View File

@@ -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}>

View File

@@ -93,8 +93,6 @@ const parseVariablePath = (
segments.push({ type: 'property', value: currentSegment.trim() });
}
console.log('segments', segments);
return segments;
};

View File

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

View File

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

View File

@@ -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,

View File

@@ -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'

View File

@@ -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',
};

View File

@@ -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 = {|

View File

@@ -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';

View File

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

View File

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

View File

@@ -107,6 +107,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.
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -587,10 +587,7 @@ const CreateSection = ({
onSelectPrivateGameTemplateListingData
}
i18n={i18n}
columnsCount={getExampleItemsColumns(
windowSize,
isLandscape
)}
getColumnsFromWindowSize={getExampleItemsColumns}
hideSearch
onlyShowGames
/>

View File

@@ -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;
};

View File

@@ -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 GDevelops 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;

View File

@@ -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;

View File

@@ -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]);
};

View File

@@ -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']);
});
});
});

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

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