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!
This commit is contained in:
Clément Pasteau
2025-07-16 11:59:56 +02:00
committed by GitHub
parent 11a8682b07
commit 84100fc7cf
88 changed files with 3509 additions and 3684 deletions

1
.gitignore vendored
View File

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

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

@@ -80,6 +80,7 @@ type Props = {
isAutoProcessingFunctionCalls: boolean,
setAutoProcessFunctionCalls: boolean => void,
onStartNewChat: () => void,
initialMode?: 'chat' | 'agent',
onProcessFunctionCalls: (
functionCalls: Array<AiRequestMessageAssistantFunctionCall>,
@@ -248,6 +249,7 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
onSendMessage,
onSendFeedback,
onStartNewChat,
initialMode,
quota,
increaseQuotaOffering,
lastSendError,
@@ -266,7 +268,17 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
// TODO: store the default mode in the user preferences?
const [newAiRequestMode, setNewAiRequestMode] = React.useState<
'chat' | 'agent'
>('agent');
>(initialMode || 'agent');
// Update the mode when initialMode changes
React.useEffect(
() => {
if (initialMode) {
setNewAiRequestMode(initialMode);
}
},
[initialMode]
);
const aiRequestId: string = aiRequest ? aiRequest.id : '';
const [
userRequestTextPerAiRequestId,

View File

@@ -483,6 +483,7 @@ type Props = {|
changes: SceneEventsOutsideEditorChanges
) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
mode?: 'chat' | 'agent' | null,
|};
export type AskAiEditorInterface = {|
@@ -498,7 +499,7 @@ export type AskAiEditorInterface = {|
onSceneEventsModifiedOutsideEditor: (
changes: SceneEventsOutsideEditorChanges
) => void,
startNewChat: () => void,
startNewChat: (mode?: 'chat' | 'agent') => void,
|};
export type NewAiRequestOptions = {|
@@ -524,6 +525,7 @@ export const AskAiEditor = React.memo<Props>(
onOpenLayout,
onSceneEventsModifiedOutsideEditor,
onExtensionInstalled,
mode,
}: Props,
ref
) => {
@@ -555,11 +557,27 @@ export const AskAiEditor = React.memo<Props>(
] = React.useState<NewAiRequestOptions | null>(null);
const [isHistoryOpen, setIsHistoryOpen] = React.useState<boolean>(false);
const [newChatMode, setNewChatMode] = React.useState<'chat' | 'agent'>(
mode || 'agent'
);
// Update newChatMode when mode prop changes
React.useEffect(
() => {
if (mode) {
setNewChatMode(mode);
}
},
[mode]
);
const canStartNewChat = !!selectedAiRequestId;
const onStartNewChat = React.useCallback(
() => {
(mode?: 'chat' | 'agent') => {
setSelectedAiRequestId(null);
if (mode) {
setNewChatMode(mode);
}
},
[setSelectedAiRequestId]
);
@@ -1039,6 +1057,7 @@ export const AskAiEditor = React.memo<Props>(
? isAutoProcessingFunctionCalls(selectedAiRequest.id)
: false
}
initialMode={newChatMode}
setAutoProcessFunctionCalls={shouldAutoProcess => {
if (!selectedAiRequest) return;
setAutoProcessFunctionCalls(
@@ -1097,6 +1116,7 @@ export const renderAskAiEditorContainer = (
props.onSceneEventsModifiedOutsideEditor
}
onExtensionInstalled={props.onExtensionInstalled}
mode={props.extraEditorProps && props.extraEditorProps.mode}
/>
)}
</I18n>

View File

@@ -28,9 +28,10 @@ import {
import { useDebounce } from '../Utils/UseDebounce';
import PromotionsSlideshow from '../Promotions/PromotionsSlideshow';
import { ColumnStackLayout } from '../UI/Layout';
import { EarnCredits } from '../MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits';
import { EarnCredits } from '../GameDashboard/Wallet/EarnCredits';
import { LARGE_WIDGET_SIZE } from '../MainFrame/EditorContainers/HomePage/CardWidget';
const cellSpacing = 2;
const cellSpacing = 10;
const getCategoryColumns = (
windowSize: WindowSizeType,
@@ -42,9 +43,9 @@ const getCategoryColumns = (
case 'medium':
return 3;
case 'large':
return 4;
return 5;
case 'xlarge':
return 6;
return 7;
default:
return 3;
}
@@ -60,9 +61,9 @@ const getShopItemsColumns = (
case 'medium':
return 2;
case 'large':
return 3;
return 4;
case 'xlarge':
return 5;
return 6;
default:
return 2;
}
@@ -118,9 +119,13 @@ export const shopCategories = {
},
};
const MAX_COLUMNS = getShopItemsColumns('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const styles = {
grid: {
margin: '0 10px',
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
width: `calc(100% + ${cellSpacing}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
// Remove the scroll capability of the grid, the scroll view handles it.
overflow: 'unset',
},
@@ -378,7 +383,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
>
{openedShopCategory ? null : (
<>
<Column>
<Column noMargin>
<Line>
<Text size="block-title">
<Trans>Explore by category</Trans>
@@ -396,7 +401,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
</>
)}
{displayPromotions ? (
<ColumnStackLayout>
<ColumnStackLayout noMargin>
<Text size="block-title">
<Trans>Promotions + Earn credits</Trans>
</Text>
@@ -413,7 +418,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
) : null}
{allBundleTiles.length ? (
<>
<Column>
<Column noMargin>
<Line>
<Text size="block-title">
<Trans>Bundles</Trans>
@@ -431,7 +436,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
</>
) : null}
{openedShopCategoryTitle && (
<Column>
<Column noMargin>
<Line>
<Text size="block-title">{openedShopCategoryTitle}</Text>
</Line>
@@ -440,7 +445,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
{!hideGameTemplates && (
<>
{!openedShopCategoryTitle && (
<Column>
<Column noMargin>
<Line>
<Text size="block-title">
<Trans>All game templates</Trans>
@@ -459,7 +464,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
</>
)}
{!openedShopCategoryTitle && (
<Column>
<Column noMargin>
<Line>
<Text size="block-title">
<Trans>All asset packs</Trans>

View File

@@ -79,7 +79,7 @@ const getShopItemsColumns = (
case 'medium':
return 2;
case 'large':
return 3;
return 4;
case 'xlarge':
return 5;
default:
@@ -138,7 +138,7 @@ export const getAssetShortHeadersToDisplay = (
return assetShortHeaders;
};
const cellSpacing = 8;
const cellSpacing = 10;
const styles = {
grid: {
margin: '0 2px', // Remove the default margin of the grid but keep the horizontal padding for focus outline.
@@ -469,7 +469,6 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
// Don't show assets if filtering on asset packs.)
if (hasAssetPackFiltersApplied && !openedAssetPack) return [];
const assetSize = getAssetSize(windowSize);
const margin = cellSpacing / 2;
return getAssetShortHeadersToDisplay(
assetShortHeaders,
@@ -481,7 +480,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
onOpenDetails={() => onOpenDetails(assetShortHeader)}
size={assetSize}
key={assetShortHeader.id}
margin={margin}
margin={cellSpacing / 2}
hideShortDescription={!!hideDetails}
/>
));
@@ -749,7 +748,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
cols={getShopItemsColumns(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
>
{gameTemplateTiles}
</GridList>
@@ -765,7 +764,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
cols={getShopItemsColumns(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
>
{allBundlePackTiles}
</GridList>
@@ -781,7 +780,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
cols={getShopItemsColumns(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
>
{allStandAlonePackTiles}
</GridList>
@@ -945,12 +944,12 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
</Column>
) : null}
{openedAssetPack && folderTiles.length ? (
<Column justifyContent="center">
<Column justifyContent="center" noMargin>
<GridList
style={styles.grid}
cellHeight="auto"
cols={getAssetFoldersColumns(windowSize, isLandscape)}
spacing={cellSpacing / 2}
spacing={cellSpacing}
>
{folderTiles}
</GridList>

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

@@ -64,8 +64,9 @@ import Window from '../../Utils/Window';
import RaisedButton from '../../UI/RaisedButton';
import PrivateAssetPackPurchaseDialog from './PrivateAssetPackPurchaseDialog';
import PublicProfileContext from '../../Profile/PublicProfileContext';
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
const cellSpacing = 8;
const cellSpacing = 10;
const getPackColumns = (windowSize: WindowSizeType, isLandscape: boolean) => {
switch (windowSize) {
@@ -76,7 +77,7 @@ const getPackColumns = (windowSize: WindowSizeType, isLandscape: boolean) => {
case 'large':
return 4;
case 'xlarge':
return 5;
return 6;
default:
return 3;
}
@@ -108,11 +109,16 @@ const contentTypeToMessageDescriptor: {
audio: t`audios`,
};
const MAX_COLUMNS = getPackColumns('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const styles = {
disabledText: { opacity: 0.6 },
scrollview: { overflowX: 'hidden' },
grid: {
margin: '0 2px', // Remove the default margin of the grid but keep the horizontal padding for focus outline.
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
overflow: 'hidden',
width: `calc(100% + ${cellSpacing}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
},
leftColumnContainer: {
flex: 3,
@@ -211,10 +217,9 @@ const PrivateAssetPackInformationPage = ({
sellerPublicProfile,
setSellerPublicProfile,
] = React.useState<?UserPublicProfile>(null);
const [
displayPasswordPrompt,
setDisplayPasswordPrompt,
] = React.useState<boolean>(false);
const [displayPasswordPrompt, setDisplayPasswordPrompt] = React.useState<
'redeem' | 'credits' | null
>(null);
const [password, setPassword] = React.useState<string>('');
const [errorText, setErrorText] = React.useState<?React.Node>(null);
const { isLandscape, isMediumScreen, windowSize } = useResponsiveWindowSize();
@@ -297,10 +302,16 @@ const PrivateAssetPackInformationPage = ({
const onWillRedeemAssetPack = () => {
// Password is required in dev environment only so that one cannot freely claim asset packs.
if (Window.isDev()) setDisplayPasswordPrompt(true);
if (Window.isDev()) setDisplayPasswordPrompt('redeem');
else onRedeemAssetPack();
};
const onWillBuyWithCredits = () => {
// Password is required in dev environment only so that one cannot freely claim asset packs.
if (Window.isDev()) setDisplayPasswordPrompt('credits');
else onClickBuyWithCredits();
};
const onRedeemAssetPack = React.useCallback(
async () => {
if (!profile || isRedeemingProduct) return;
@@ -704,7 +715,7 @@ const PrivateAssetPackInformationPage = ({
simulateAppStoreProduct={simulateAppStoreProduct}
isAlreadyReceived={isAlreadyReceived}
onClickBuy={onClickBuy}
onClickBuyWithCredits={onClickBuyWithCredits}
onClickBuyWithCredits={onWillBuyWithCredits}
/>
)}
</>
@@ -764,7 +775,7 @@ const PrivateAssetPackInformationPage = ({
<GridList
cols={getPackColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
style={styles.grid}
>
{packsIncludedInBundleTiles}
@@ -784,7 +795,7 @@ const PrivateAssetPackInformationPage = ({
<GridList
cols={getPackColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
style={styles.grid}
>
{otherPacksFromTheSameAuthorTiles}
@@ -797,8 +808,12 @@ const PrivateAssetPackInformationPage = ({
) : null}
{displayPasswordPrompt && (
<PasswordPromptDialog
onApply={onRedeemAssetPack}
onClose={() => setDisplayPasswordPrompt(false)}
onApply={
displayPasswordPrompt === 'redeem'
? onWillRedeemAssetPack
: onClickBuyWithCredits
}
onClose={() => setDisplayPasswordPrompt(null)}
passwordValue={password}
setPasswordValue={setPassword}
/>

View File

@@ -59,8 +59,9 @@ import Play from '../../UI/CustomSvgIcons/Play';
import PrivateGameTemplatePurchaseDialog from './PrivateGameTemplatePurchaseDialog';
import PasswordPromptDialog from '../PasswordPromptDialog';
import PublicProfileContext from '../../Profile/PublicProfileContext';
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
const cellSpacing = 8;
const cellSpacing = 10;
const getTemplateColumns = (
windowSize: WindowSizeType,
@@ -74,17 +75,21 @@ const getTemplateColumns = (
case 'large':
return 4;
case 'xlarge':
return 5;
return 6;
default:
return 3;
}
};
const MAX_COLUMNS = getTemplateColumns('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const styles = {
disabledText: { opacity: 0.6 },
scrollview: { overflowX: 'hidden' },
grid: {
margin: '0 2px', // Remove the default margin of the grid but keep the horizontal padding for focus outline.
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
overflow: 'hidden',
width: `calc(100% + ${cellSpacing}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
},
leftColumnContainer: {
flex: 3,
@@ -558,7 +563,7 @@ const PrivateGameTemplateInformationPage = ({
simulateAppStoreProduct={simulateAppStoreProduct}
isAlreadyReceived={isAlreadyReceived}
onClickBuy={onClickBuy}
onClickBuyWithCredits={onClickBuyWithCredits}
onClickBuyWithCredits={onWillBuyWithCredits}
/>
)}
</>
@@ -605,7 +610,7 @@ const PrivateGameTemplateInformationPage = ({
<GridList
cols={getTemplateColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
style={styles.grid}
>
{templatesIncludedInBundleTiles}
@@ -625,7 +630,7 @@ const PrivateGameTemplateInformationPage = ({
<GridList
cols={getTemplateColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing / 2}
spacing={cellSpacing}
style={styles.grid}
>
{otherTemplatesFromTheSameAuthorTiles}
@@ -638,7 +643,7 @@ const PrivateGameTemplateInformationPage = ({
) : null}
{displayPasswordPrompt && (
<PasswordPromptDialog
onApply={onWillBuyWithCredits}
onApply={onClickBuyWithCredits}
onClose={() => setDisplayPasswordPrompt(false)}
passwordValue={password}
setPasswordValue={setPassword}

View File

@@ -4,6 +4,7 @@ import { type I18n as I18nType } from '@lingui/core';
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type CourseListingData,
type Purchase,
} from '../Utils/GDevelopServices/Shop';
import {
@@ -283,7 +284,10 @@ export const getUserProductPurchaseUsageType = <
};
export const PurchaseProductButtons = <
T: PrivateAssetPackListingData | PrivateGameTemplateListingData
T:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CourseListingData
>({
productListingData,
selectedUsageType,
@@ -300,8 +304,8 @@ export const PurchaseProductButtons = <
simulateAppStoreProduct?: boolean,
i18n: I18nType,
isAlreadyReceived: boolean,
onClickBuy: () => Promise<void>,
onClickBuyWithCredits: () => Promise<void>,
onClickBuy: () => void | Promise<void>,
onClickBuyWithCredits: () => void | Promise<void>,
|}) => {
const { authenticated } = React.useContext(AuthenticatedUserContext);
const shouldUseOrSimulateAppStoreProduct =

View File

@@ -8,6 +8,7 @@ import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type CreditsPackageListingData,
type CourseListingData,
} from '../Utils/GDevelopServices/Shop';
import {
shouldUseAppStoreProduct,
@@ -16,6 +17,10 @@ import {
import Coin from '../Credits/Icons/Coin';
import { LineStackLayout } from '../UI/Layout';
import Text from '../UI/Text';
import { Column } from '../UI/Grid';
import CheckCircle from '../UI/CustomSvgIcons/CheckCircle';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import type { GDevelopTheme } from '../UI/Theme';
const styles = {
icon: {
@@ -28,10 +33,12 @@ type FormatProps = {|
productListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CreditsPackageListingData,
| CreditsPackageListingData
| CourseListingData,
i18n: I18nType,
usageType?: string,
plainText?: boolean,
showBothPrices?: 'column' | 'line', // If defined, will show both the credits price and the product price.
|};
export const renderProductPrice = ({
@@ -39,6 +46,7 @@ export const renderProductPrice = ({
productListingData,
usageType,
plainText,
showBothPrices,
}: FormatProps): React.Node => {
// Only use the app store product if it's a credits package.
if (
@@ -51,19 +59,21 @@ export const renderProductPrice = ({
return appStoreProduct ? appStoreProduct.price : '';
}
// If we're on mobile, only show credits prices for asset packs & game templates.
const creditPrices =
productListingData.productType !== 'CREDITS_PACKAGE'
? productListingData.creditPrices || []
: [];
const creditPrice = usageType
? creditPrices.find(price => price.usageType === usageType)
: creditPrices.length > 0
? creditPrices[0]
: null;
// If we're on mobile, only show credits prices for non-credits packages.
if (
shouldUseAppStoreProduct() &&
productListingData.productType !== 'CREDITS_PACKAGE'
) {
const creditPrices = productListingData.creditPrices;
if (!creditPrices) return '';
const creditPrice = usageType
? creditPrices.find(price => price.usageType === usageType)
: creditPrices.length > 0
? creditPrices[0]
: null;
if (!creditPrice) return '';
return plainText ? (
i18n._(t`${creditPrice.amount} credits`)
@@ -96,6 +106,34 @@ export const renderProductPrice = ({
return plainText ? (
formattedPrice
) : showBothPrices && creditPrice ? (
showBothPrices === 'column' ? (
<Column alignItems="flex-end">
<LineStackLayout noMargin alignItems="center">
<Coin style={styles.icon} />
<Text noMargin size="sub-title" color="inherit">
{creditPrice.amount}
</Text>
</LineStackLayout>
<Trans>or</Trans>
<Text noMargin size="sub-title" color="primary">
{formattedPrice}
</Text>
</Column>
) : (
<LineStackLayout noMargin>
<LineStackLayout noMargin alignItems="center">
<Coin style={styles.icon} />
<Text noMargin size="sub-title" color="inherit">
{creditPrice.amount}
</Text>
</LineStackLayout>
<Trans>or</Trans>
<Text noMargin size="sub-title" color="primary">
{formattedPrice}
</Text>
</LineStackLayout>
)
) : (
<Text noMargin size="sub-title" color="inherit">
{formattedPrice}
@@ -107,29 +145,36 @@ type ProductPriceOrOwnedProps = {|
productListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CreditsPackageListingData,
| CreditsPackageListingData
| CourseListingData,
i18n: I18nType,
gdevelopTheme: GDevelopTheme,
usageType?: string,
owned?: boolean,
showBothPrices?: 'column' | 'line',
|};
export const getProductPriceOrOwnedLabel = ({
i18n,
gdevelopTheme,
productListingData,
usageType,
owned,
showBothPrices,
}: ProductPriceOrOwnedProps): React.Node => {
return owned ? (
<LineStackLayout noMargin alignItems="center">
<Text noMargin size="sub-title">
</Text>
<CheckCircle
style={{
color: gdevelopTheme.message.valid,
}}
/>
<Text noMargin size="sub-title" color="inherit">
<Trans>Owned</Trans>
</Text>
</LineStackLayout>
) : (
renderProductPrice({ i18n, productListingData, usageType })
renderProductPrice({ i18n, productListingData, usageType, showBothPrices })
);
};
@@ -137,7 +182,8 @@ type ProductPriceTagProps = {|
productListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CreditsPackageListingData,
| CreditsPackageListingData
| CourseListingData,
usageType?: string,
/**
* To be used when the component is over an element for which
@@ -153,11 +199,13 @@ const ProductPriceTag = ({
withOverlay,
owned,
}: ProductPriceTagProps) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<I18n>
{({ i18n }) => {
const label = getProductPriceOrOwnedLabel({
i18n,
gdevelopTheme,
productListingData,
usageType,
owned,

View File

@@ -12,9 +12,6 @@ import {
} from '../Utils/GDevelopServices/Shop';
import type { ExampleShortHeader } from '../Utils/GDevelopServices/Example';
import GridListTile from '@material-ui/core/GridListTile';
import createStyles from '@material-ui/core/styles/createStyles';
import makeStyles from '@material-ui/core/styles/makeStyles';
import { shouldValidate } from '../UI/KeyboardShortcuts/InteractionKeys';
import { CorsAwareImage } from '../UI/CorsAwareImage';
import { textEllipsisStyle } from '../UI/TextEllipsis';
import { Column, Line, Spacer } from '../UI/Grid';
@@ -30,6 +27,7 @@ import { ResponsiveLineStackLayout } from '../UI/Layout';
import Skeleton from '@material-ui/lab/Skeleton';
import EmptyMessage from '../UI/EmptyMessage';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import { CardWidget } from '../MainFrame/EditorContainers/HomePage/CardWidget';
const styles = {
priceTagContainer: {
@@ -81,11 +79,6 @@ const styles = {
minWidth: 200,
margin: 4,
},
paper: {
margin: 4,
display: 'flex',
flexDirection: 'column',
},
packTitle: {
...textEllipsisStyle,
overflowWrap: 'break-word',
@@ -112,20 +105,6 @@ const styles = {
},
};
const useStylesForGridListItem = ({ disabled }: {| disabled?: boolean |}) =>
makeStyles(theme =>
createStyles({
tile: !disabled
? {
transition: 'transform 0.3s ease-in-out',
'&:hover': {
transform: 'scale(1.02)',
},
}
: {},
})
)();
export const AssetCardTile = ({
assetShortHeader,
onOpenDetails,
@@ -141,30 +120,21 @@ export const AssetCardTile = ({
hideShortDescription?: boolean,
disabled?: boolean,
|}) => {
const classesForGridListItem = useStylesForGridListItem({
disabled,
});
return (
<GridListTile
classes={classesForGridListItem}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event) && !disabled) {
onOpenDetails();
}
}}
onClick={!disabled ? onOpenDetails : undefined}
style={{
margin,
}}
>
<AssetCard
id={`asset-card-${assetShortHeader.name.replace(/\s/g, '-')}`}
assetShortHeader={assetShortHeader}
size={size}
hideShortDescription={hideShortDescription}
/>
<GridListTile style={{ margin }}>
<CardWidget
onClick={!disabled ? onOpenDetails : undefined}
size="large"
disabled={disabled}
noBorder
>
<AssetCard
id={`asset-card-${assetShortHeader.name.replace(/\s/g, '-')}`}
assetShortHeader={assetShortHeader}
size={size}
hideShortDescription={hideShortDescription}
/>
</CardWidget>
</GridListTile>
);
};
@@ -181,29 +151,27 @@ export const AssetFolderTile = ({
style?: any,
disabled?: boolean,
|}) => {
const classesForGridListItem = useStylesForGridListItem({
disabled,
});
return (
<GridListTile
classes={classesForGridListItem}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event) && !disabled) {
onSelect();
}
}}
style={style}
onClick={!disabled ? onSelect : undefined}
>
<Column noMargin id={`asset-folder-${tag.replace(/\s/g, '-')}`}>
<Line alignItems="center">
<FolderIcon style={styles.folderIcon} />
<Text noMargin style={styles.folderTitle} size="sub-title">
{capitalize(tag)}
</Text>
</Line>
</Column>
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column
noMargin
id={`asset-folder-${tag.replace(/\s/g, '-')}`}
noOverflowParent
>
<Line alignItems="center">
<FolderIcon style={styles.folderIcon} />
<Text noMargin style={styles.folderTitle} size="sub-title">
{capitalize(tag)}
</Text>
</Line>
</Column>
</CardWidget>
</GridListTile>
);
};
@@ -220,46 +188,46 @@ export const PublicAssetPackTile = ({
style?: any,
disabled?: boolean,
|}) => {
const classesForGridListItem = useStylesForGridListItem({
disabled,
});
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<GridListTile
classes={classesForGridListItem}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event) && !disabled) {
onSelect();
}
}}
style={style}
onClick={!disabled ? onSelect : undefined}
>
<div
id={`asset-pack-${assetPack.tag.replace(/\s/g, '-')}`}
style={styles.paper}
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<CorsAwareImage
key={assetPack.name}
style={styles.previewImage}
src={assetPack.thumbnailUrl}
alt={`Preview image of asset pack ${assetPack.name}`}
loading="lazy"
/>
<Column>
<Line justifyContent="space-between" noMargin>
<Text style={styles.packTitle} size="body2">
{assetPack.name}
</Text>
<Text style={styles.packTitle} color="primary" size="body2">
<Trans>{assetPack.assetsCount} Assets</Trans>
{assetPack.userFriendlyPrice
? ' - ' + assetPack.userFriendlyPrice
: null}
</Text>
</Line>
<Column
noMargin
expand
id={`asset-pack-${assetPack.tag.replace(/\s/g, '-')}`}
noOverflowParent
>
<CorsAwareImage
key={assetPack.name}
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={assetPack.thumbnailUrl}
alt={`Preview image of asset pack ${assetPack.name}`}
loading="lazy"
/>
<Column>
<Line justifyContent="space-between" noMargin>
<Text style={styles.packTitle} size="body2">
{assetPack.name}
</Text>
<Text style={styles.packTitle} color="primary" size="body2">
<Trans>{assetPack.assetsCount} Assets</Trans>
{assetPack.userFriendlyPrice
? ' - ' + assetPack.userFriendlyPrice
: null}
</Text>
</Line>
</Column>
</Column>
</div>
</CardWidget>
</GridListTile>
);
};
@@ -278,61 +246,59 @@ export const PrivateAssetPackTile = ({
owned: boolean,
disabled?: boolean,
|}) => {
const classesForGridListItem = useStylesForGridListItem({
disabled,
});
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<GridListTile
classes={classesForGridListItem}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event) && !disabled) {
onSelect();
}
}}
style={style}
onClick={!disabled ? onSelect : undefined}
>
<div style={styles.paper}>
<div style={styles.thumbnailContainer}>
<CorsAwareImage
key={assetPackListingData.name}
style={styles.previewImage}
src={assetPackListingData.thumbnailUrls[0]}
alt={`Preview image of asset pack ${assetPackListingData.name}`}
loading="lazy"
/>
{assetPackListingData.redeemConditions && !owned && (
<div style={styles.redeemableContainer}>
<img
src="res/small-diamond.svg"
style={styles.redeemableDiamondIcon}
alt="diamond"
/>
<Text color="inherit" noMargin>
<Trans>Claim this pack</Trans>
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column noMargin expand noOverflowParent>
<div style={styles.thumbnailContainer}>
<CorsAwareImage
key={assetPackListingData.name}
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={assetPackListingData.thumbnailUrls[0]}
alt={`Preview image of asset pack ${assetPackListingData.name}`}
loading="lazy"
/>
{assetPackListingData.redeemConditions && !owned && (
<div style={styles.redeemableContainer}>
<img
src="res/small-diamond.svg"
style={styles.redeemableDiamondIcon}
alt="diamond"
/>
<Text color="inherit" noMargin>
<Trans>Claim this pack</Trans>
</Text>
</div>
)}
</div>
<div style={styles.priceTagContainer}>
<ProductPriceTag
productListingData={assetPackListingData}
withOverlay
owned={owned}
/>
</div>
<Column>
<Line justifyContent="space-between" noMargin>
<Text style={styles.packTitle} size="body2">
{assetPackListingData.name}
</Text>
</div>
)}
</div>
<div style={styles.priceTagContainer}>
<ProductPriceTag
productListingData={assetPackListingData}
withOverlay
owned={owned}
/>
</div>
<Column>
<Line justifyContent="space-between" noMargin>
<Text style={styles.packTitle} size="body2">
{assetPackListingData.name}
</Text>
<Text style={styles.packTitle} color="primary" size="body2">
{assetPackListingData.description}
</Text>
</Line>
<Text style={styles.packTitle} color="primary" size="body2">
{assetPackListingData.description}
</Text>
</Line>
</Column>
</Column>
</div>
</CardWidget>
</GridListTile>
);
};
@@ -455,40 +421,34 @@ export const CategoryTile = ({
style?: any,
disabled?: boolean,
|}) => {
const classesForGridListItem = useStylesForGridListItem({
disabled,
});
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<GridListTile
classes={classesForGridListItem}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event) && !disabled) {
onSelect();
}
}}
style={style}
onClick={!disabled ? onSelect : undefined}
>
<div id={id} style={styles.paper}>
<CorsAwareImage
style={{
...styles.previewImage,
background: gdevelopTheme.palette.primary,
}}
src={imageSource}
alt={imageAlt}
// No lazy loading because categories are the first seen tiles in the shop.
/>
<Column>
<Line justifyContent="center" noMargin>
<Text style={styles.packTitle} size="sub-title">
{title}
</Text>
</Line>
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column noMargin alignItems="center" expand id={id} noOverflowParent>
<CorsAwareImage
style={{
...styles.previewImage,
background: gdevelopTheme.palette.primary,
}}
src={imageSource}
alt={imageAlt}
// No lazy loading because categories are the first seen tiles in the shop.
/>
<Column>
<Line justifyContent="center" noMargin>
<Text style={styles.packTitle} size="sub-title">
{title}
</Text>
</Line>
</Column>
</Column>
</div>
</CardWidget>
</GridListTile>
);
};
@@ -508,47 +468,45 @@ export const PrivateGameTemplateTile = ({
disabled?: boolean,
|}) => {
const { isMobile } = useResponsiveWindowSize();
const classesForGridListItem = useStylesForGridListItem({
disabled,
});
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<GridListTile
classes={classesForGridListItem}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event) && !disabled) {
onSelect();
}
}}
style={style}
onClick={!disabled ? onSelect : undefined}
>
<div style={styles.paper}>
<CorsAwareImage
key={privateGameTemplateListingData.name}
style={styles.previewImage}
src={privateGameTemplateListingData.thumbnailUrls[0]}
alt={`Preview image of game template ${
privateGameTemplateListingData.name
}`}
loading="lazy"
/>
<div style={styles.priceTagContainer}>
<ProductPriceTag
productListingData={privateGameTemplateListingData}
withOverlay
owned={owned}
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column noMargin expand noOverflowParent>
<CorsAwareImage
key={privateGameTemplateListingData.name}
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={privateGameTemplateListingData.thumbnailUrls[0]}
alt={`Preview image of game template ${
privateGameTemplateListingData.name
}`}
loading="lazy"
/>
</div>
<Column>
{isMobile && <Spacer />}
<Line justifyContent="flex-start" noMargin>
<Text style={styles.packTitle} size="body2" noMargin={isMobile}>
{privateGameTemplateListingData.name}
</Text>
</Line>
<div style={styles.priceTagContainer}>
<ProductPriceTag
productListingData={privateGameTemplateListingData}
withOverlay
owned={owned}
/>
</div>
<Column>
{isMobile && <Spacer />}
<Line justifyContent="flex-start" noMargin>
<Text style={styles.packTitle} size="body2" noMargin={isMobile}>
{privateGameTemplateListingData.name}
</Text>
</Line>
</Column>
</Column>
</div>
</CardWidget>
</GridListTile>
);
};
@@ -572,6 +530,7 @@ export const ExampleTile = ({
disabled?: boolean,
|}) => {
const { isMobile } = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const thumbnailImgUrl = React.useMemo(
() => {
if (!exampleShortHeader) return '';
@@ -587,66 +546,68 @@ export const ExampleTile = ({
[exampleShortHeader, useQuickCustomizationThumbnail]
);
const classesForGridListItem = useStylesForGridListItem({ disabled });
return (
<GridListTile
classes={classesForGridListItem}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event) && !disabled) {
onSelect();
}
}}
style={style}
onClick={!disabled ? onSelect : undefined}
>
<div style={styles.paper}>
{exampleShortHeader ? (
thumbnailImgUrl ? (
<CorsAwareImage
key={exampleShortHeader.name}
style={styles.previewImage}
src={thumbnailImgUrl}
alt={`Preview image of example ${exampleShortHeader.name}`}
loading="lazy"
/>
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column noMargin expand noOverflowParent>
{exampleShortHeader ? (
thumbnailImgUrl ? (
<CorsAwareImage
key={exampleShortHeader.name}
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={thumbnailImgUrl}
alt={`Preview image of example ${exampleShortHeader.name}`}
loading="lazy"
/>
) : (
<EmptyMessage
style={{
...styles.previewImage,
display: 'flex',
}}
>
{exampleShortHeader.name}
</EmptyMessage>
)
) : (
<EmptyMessage
style={{
...styles.previewImage,
display: 'flex',
}}
<Skeleton
variant="rect"
width="100%"
height="100%"
style={styles.dataLoadingSkeleton}
/>
)}
<Column>
{isMobile && <Spacer />}
<Line
justifyContent={centerTitle ? 'center' : 'flex-start'}
noMargin
>
{exampleShortHeader.name}
</EmptyMessage>
)
) : (
<Skeleton
variant="rect"
width="100%"
height="100%"
style={styles.dataLoadingSkeleton}
/>
)}
<Column>
{isMobile && <Spacer />}
<Line justifyContent={centerTitle ? 'center' : 'flex-start'} noMargin>
<Text
style={styles.packTitle}
size="body2"
hidden={!exampleShortHeader}
noMargin={isMobile}
>
{customTitle
? customTitle
: exampleShortHeader
? exampleShortHeader.name
: // Use some placeholder text to avoid layout shift while loading content.
'Abcdef123'}
</Text>
</Line>
<Text
style={styles.packTitle}
size="body2"
hidden={!exampleShortHeader}
noMargin={isMobile}
>
{customTitle
? customTitle
: exampleShortHeader
? exampleShortHeader.name
: // Use some placeholder text to avoid layout shift while loading content.
'Abcdef123'}
</Text>
</Line>
</Column>
</Column>
</div>
</CardWidget>
</GridListTile>
);
};

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>
);
@@ -258,6 +244,7 @@ const CoursePreviewBanner = ({
))}
{index > 0 && <Spacer />}
<ChapterTile
course={course}
chapter={chapter}
chapterIndex={chapterIndex}
gdevelopTheme={gdevelopTheme}

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

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

@@ -26,6 +26,9 @@ export type EditorContainerExtraProps = {|
// Homepage
storageProviders?: Array<StorageProvider>,
// Ask AI
mode?: 'chat' | 'agent',
|};
export type SceneEventsOutsideEditorChanges = {|
@@ -83,6 +86,7 @@ export type RenderEditorContainerProps = {|
variantName: string
) => void,
openObjectEvents: (extensionName: string, objectName: string) => void,
onOpenAskAi: (mode?: 'chat' | 'agent') => void,
// Events function management:
onLoadEventsFunctionsExtensions: () => Promise<void>,

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

@@ -5,14 +5,24 @@ import { I18n } from '@lingui/react';
import { ColumnStackLayout, LineStackLayout } from '../../../../UI/Layout';
import Text from '../../../../UI/Text';
import { CardWidget } from '../CardWidget';
import { Column, Line, Spacer } from '../../../../UI/Grid';
import { Column, Line } from '../../../../UI/Grid';
import ColoredLinearProgress from '../../../../UI/ColoredLinearProgress';
import { Trans } from '@lingui/macro';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
import { type CourseCompletion } from '../UseCourses';
import { type Course } from '../../../../Utils/GDevelopServices/Asset';
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
import { textEllipsisStyle } from '../../../../UI/TextEllipsis';
import Skeleton from '@material-ui/lab/Skeleton';
import { getProductPriceOrOwnedLabel } from '../../../../AssetStore/ProductPriceTag';
import Chip from '../../../../UI/Chip';
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
export const getChipColorFromEnglishLevel = (englishLevel: string) => {
if (englishLevel.toLowerCase().includes('advanced')) return '#FF8569';
if (englishLevel.toLowerCase().includes('intermediate')) return '#FFBC57';
return '#3BF7F4';
};
const styles = {
cardTextContainer: {
@@ -20,8 +30,11 @@ const styles = {
display: 'flex',
justifyContent: 'center',
padding: '8px 16px 8px 16px',
// Fix the height so that the card has a consistent height,
// When owned or not.
minHeight: 220,
},
image: { width: '100%' },
image: { width: '100%', aspectRatio: '16 / 9' },
specializationDot: {
width: 8,
height: 8,
@@ -39,7 +52,6 @@ const styles = {
alignItems: 'stretch',
aspectRatio: '16 / 9',
flexDirection: 'column',
flex: 1,
},
newLabel: {
position: 'absolute',
@@ -51,6 +63,9 @@ const styles = {
color: 'white',
padding: '2px 6px',
},
chip: {
height: 24,
},
};
const specializationLabels = {
@@ -64,10 +79,15 @@ const specializationColors = {
marketing: '#FD3AE6',
};
const getSpecializationConfig = (
export const getSpecializationConfig = (
specializationId: string
): {| label: React.Node, color: string |} => {
let label = specializationLabels[specializationId];
let label =
specializationId === 'loading' ? (
<Trans>Loading</Trans>
) : (
specializationLabels[specializationId]
);
if (!label) {
console.warn(
`No label found for specializationId "${specializationId}". Using default label.`
@@ -92,10 +112,17 @@ const getSpecializationConfig = (
type Props = {|
completion: ?CourseCompletion,
course: ?Course,
courseListingData: ?CourseListingData,
onClick?: () => void,
|};
const CourseCard = ({ completion, course, onClick }: Props) => {
const CourseCard = ({
completion,
course,
courseListingData,
onClick,
}: Props) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const specializationConfig = getSpecializationConfig(
course ? course.specializationId : 'loading'
);
@@ -103,7 +130,7 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
<I18n>
{({ i18n }) => (
<CardWidget onClick={onClick} size={'large'}>
{course && onClick ? (
{course && courseListingData && onClick ? (
<Column expand noMargin noOverflowParent>
<div style={styles.imageContainer}>
<img
@@ -151,25 +178,29 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
{specializationConfig.label}
</Text>
</LineStackLayout>
{completion && (
<LineStackLayout alignItems="center" noMargin>
<ColoredLinearProgress
value={
(completion.completedChapters /
completion.chapters) *
100
}
/>
<Text
displayInlineAsSpan
size="body-small"
noMargin
color="secondary"
>
{completion.completedChapters}/{completion.chapters}
</Text>
</LineStackLayout>
)}
<LineStackLayout alignItems="center" noMargin>
<ColoredLinearProgress
value={
completion
? (completion.completedChapters /
completion.chapters) *
100
: 0
}
/>
<Text
displayInlineAsSpan
size="body-small"
noMargin
color="secondary"
>
{completion
? `${completion.completedChapters}/${
completion.chapters
}`
: '-/-'}
</Text>
</LineStackLayout>
<Text
size="sub-title"
@@ -186,18 +217,31 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
)}
</Text>
</ColumnStackLayout>
<Line justifyContent="space-between">
<Text size="body-small" noMargin color="secondary">
{selectMessageByLocale(i18n, course.levelByLocale)}
</Text>
<Text size="body-small" noMargin color="secondary">
{course.durationInWeeks === 1 ? (
<Trans>1 week</Trans>
) : (
<Trans>{course.durationInWeeks} weeks</Trans>
)}
</Text>
</Line>
<div style={{ color: gdevelopTheme.text.color.secondary }}>
<Line justifyContent="space-between" alignItems="flex-end">
<Chip
style={{
...styles.chip,
border: `1px solid ${getChipColorFromEnglishLevel(
course.levelByLocale.en
)}`,
}}
label={selectMessageByLocale(
i18n,
course.levelByLocale
)}
variant="outlined"
/>
{getProductPriceOrOwnedLabel({
i18n,
gdevelopTheme,
productListingData: courseListingData,
usageType: 'default',
showBothPrices: 'column',
owned: !course.isLocked,
})}
</Line>
</div>
</ColumnStackLayout>
</div>
</Column>
@@ -211,10 +255,8 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
<Skeleton height={20} />
<Skeleton height={20} />
<Skeleton height={30} />
<Skeleton height={100} />
<Skeleton height={50} />
<Spacer />
<Skeleton height={15} />
<Spacer />
</Column>
</Line>
</Column>

View File

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

View File

@@ -1,12 +1,14 @@
// @flow
import * as React from 'react';
import { type I18n as I18nType } from '@lingui/core';
import { Trans } from '@lingui/macro';
import { I18n } from '@lingui/react';
import {
type CourseChapter,
type Course,
} from '../../../../Utils/GDevelopServices/Asset';
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
import SectionContainer from '../SectionContainer';
import VideoBasedCourseChapterView from '../../../../Course/VideoBasedCourseChapterView';
import TextBasedCourseChapterView from '../../../../Course/TextBasedCourseChapterView';
@@ -32,8 +34,7 @@ import {
AccordionBody,
AccordionHeader,
} from '../../../../UI/Accordion';
import AnyQuestionDialog from '../AnyQuestionDialog';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
import CourseSectionHeader from './CourseSectionHeader';
const styles = {
desktopContainer: { display: 'flex', gap: 16 },
@@ -101,7 +102,20 @@ type Props = {|
isTaskCompleted: (chapterId: string, taskIndex: number) => boolean,
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
getCourseCompletion: () => CourseCompletion | null,
onBuyCourseChapterWithCredits: (CourseChapter, string) => Promise<void>,
onBuyCourseWithCredits: (
Course: Course,
password: string,
i18n: I18nType
) => Promise<void>,
onBuyCourse: (
Course: Course,
password: string,
i18n: I18nType
) => Promise<void>,
purchasingCourseListingData: ?CourseListingData,
setPurchasingCourseListingData: (CourseListingData | null) => void,
simulateAppStoreProduct?: boolean,
onOpenAskAi: (mode?: 'chat' | 'agent') => void,
|};
const CourseSection = ({
@@ -113,15 +127,17 @@ const CourseSection = ({
isTaskCompleted,
getChapterCompletion,
getCourseCompletion,
onBuyCourseChapterWithCredits,
onBuyCourseWithCredits,
onBuyCourse,
purchasingCourseListingData,
setPurchasingCourseListingData,
simulateAppStoreProduct,
onOpenAskAi,
}: Props) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const { showAlertMessage, values } = React.useContext(PreferencesContext);
const { isMobile, isLandscape } = useResponsiveWindowSize();
const courseCompletion = getCourseCompletion();
const [isAnyQuestionDialogOpen, setIsAnyQuestionDialogOpen] = React.useState(
false
);
const firstIncompleteChapterIdRef = React.useRef<string | null>(
courseChapters.reduce((alreadyFoundIncompleteChapterId, chapter, index) => {
if (alreadyFoundIncompleteChapterId)
@@ -275,6 +291,19 @@ const CourseSection = ({
[scrollToChapter]
);
const onClickUnlock = React.useCallback(
() => {
// Scroll to the top of the page, where the purchase button is.
if (scrollingContainerRef.current) {
scrollingContainerRef.current.scrollTo({
top: 0,
behavior: 'smooth',
});
}
},
[scrollingContainerRef]
);
return (
<I18n>
{({ i18n }) => (
@@ -283,11 +312,6 @@ const CourseSection = ({
ref={scrollingContainerRef}
applyTopSpacingAsMarginOnChildrenContainer
backAction={onBack}
title={selectMessageByLocale(i18n, course.titleByLocale)}
subtitleText={selectMessageByLocale(
i18n,
course.shortDescriptionByLocale
)}
>
<div
style={
@@ -295,6 +319,16 @@ const CourseSection = ({
}
>
<Column noOverflowParent noMargin>
<CourseSectionHeader
course={course}
onBuyCourseWithCredits={onBuyCourseWithCredits}
onBuyCourse={onBuyCourse}
purchasingCourseListingData={purchasingCourseListingData}
setPurchasingCourseListingData={
setPurchasingCourseListingData
}
simulateAppStoreProduct={simulateAppStoreProduct}
/>
{!values.hiddenAlertMessages[alertMessageKey] && subtitleHint && (
<Line>
<AlertMessage
@@ -310,6 +344,7 @@ const CourseSection = ({
chapter.videoUrl ? (
<VideoBasedCourseChapterView
chapterIndex={index}
course={course}
courseChapter={chapter}
onOpenTemplate={() => {
onOpenTemplateFromCourseChapter(chapter);
@@ -318,7 +353,7 @@ const CourseSection = ({
isTaskCompleted={isTaskCompleted}
getChapterCompletion={getChapterCompletion}
key={chapter.id}
onBuyWithCredits={onBuyCourseChapterWithCredits}
onClickUnlock={onClickUnlock}
ref={_ref => {
if (_ref) {
chapterTitleRefs.current[index] = {
@@ -331,6 +366,7 @@ const CourseSection = ({
) : (
<TextBasedCourseChapterView
chapterIndex={index}
course={course}
// $FlowIgnore - Flow does not conclude this chapter can only be text-based.
courseChapter={chapter}
onOpenTemplate={(templateId?: string) => {
@@ -340,7 +376,7 @@ const CourseSection = ({
isTaskCompleted={isTaskCompleted}
getChapterCompletion={getChapterCompletion}
key={chapter.id}
onBuyWithCredits={onBuyCourseChapterWithCredits}
onClickUnlock={onClickUnlock}
ref={_ref => {
if (_ref) {
chapterTitleRefs.current[index] = {
@@ -394,9 +430,9 @@ const CourseSection = ({
</Text>
</LineStackLayout>
<RaisedButton
label={<Trans>Ask a question</Trans>}
primary
onClick={() => setIsAnyQuestionDialogOpen(true)}
label={<Trans>Ask the AI</Trans>}
onClick={() => onOpenAskAi('chat')}
/>
</ColumnStackLayout>
</Paper>
@@ -405,11 +441,6 @@ const CourseSection = ({
)}
</div>
</SectionContainer>
{isAnyQuestionDialogOpen && (
<AnyQuestionDialog
onClose={() => setIsAnyQuestionDialogOpen(false)}
/>
)}
{isMobile && !isLandscape && (
<div
style={{

View File

@@ -0,0 +1,316 @@
// @flow
import * as React from 'react';
import { type I18n as I18nType } from '@lingui/core';
import { Trans } from '@lingui/macro';
import { I18n } from '@lingui/react';
import { type Course } from '../../../../Utils/GDevelopServices/Asset';
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
import { SectionRow } from '../SectionContainer';
import Paper from '../../../../UI/Paper';
import Text from '../../../../UI/Text';
import { textEllipsisStyle } from '../../../../UI/TextEllipsis';
import { Column, Line } from '../../../../UI/Grid';
import {
ColumnStackLayout,
LineStackLayout,
ResponsiveLineStackLayout,
} from '../../../../UI/Layout';
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
import CoursePurchaseDialog from './CoursePurchaseDialog';
import {
getChipColorFromEnglishLevel,
getSpecializationConfig,
} from './CourseCard';
import { renderProductPrice } from '../../../../AssetStore/ProductPriceTag';
import CourseStoreContext from '../../../../Course/CourseStoreContext';
import PasswordPromptDialog from '../../../../AssetStore/PasswordPromptDialog';
import Window from '../../../../Utils/Window';
import { PurchaseProductButtons } from '../../../../AssetStore/ProductPageHelper';
import { shouldUseAppStoreProduct } from '../../../../Utils/AppStorePurchases';
import { Divider } from '@material-ui/core';
import classes from './CourseSectionHeader.module.css';
import Gold from '../../../../Profile/Subscription/Icons/Gold';
import Chip from '../../../../UI/Chip';
const styles = {
title: { overflowWrap: 'anywhere', textWrap: 'wrap' },
image: { width: 300, aspectRatio: '16 / 9' },
specializationDot: {
width: 8,
height: 8,
borderRadius: '50%',
flexShrink: 0,
},
imageContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
borderRadius: 8,
overflow: 'hidden',
},
paper: {
zIndex: 2, // Make sure the paper is above the background for the border effect.
flex: 1,
},
diamondIcon: {
width: 20,
height: 20,
},
chip: {
height: 24,
},
};
const ResponsiveDivider = () => {
const { isMobile, isMediumScreen } = useResponsiveWindowSize();
return isMobile || isMediumScreen ? (
<Column noMargin>
<Divider orientation="horizontal" />
</Column>
) : (
<Line noMargin>
<Divider orientation="vertical" />
</Line>
);
};
type Props = {|
course: Course,
onBuyCourseWithCredits: (
Course: Course,
password: string,
i18n: I18nType
) => Promise<void>,
onBuyCourse: (
Course: Course,
password: string,
i18n: I18nType
) => Promise<void>,
purchasingCourseListingData: ?CourseListingData,
setPurchasingCourseListingData: (CourseListingData | null) => void,
simulateAppStoreProduct?: boolean,
|};
const CourseSectionHeader = ({
course,
onBuyCourseWithCredits,
onBuyCourse,
purchasingCourseListingData,
setPurchasingCourseListingData,
simulateAppStoreProduct,
}: Props) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const { listedCourses } = React.useContext(CourseStoreContext);
const { isMobile, isMediumScreen } = useResponsiveWindowSize();
const [
displayPasswordPrompt,
setDisplayPasswordPrompt,
] = React.useState<boolean>(false);
const [password, setPassword] = React.useState<string>('');
const onClickBuyWithCredits = React.useCallback(
async (i18n: I18nType) => {
if (!course.isLocked) return;
setDisplayPasswordPrompt(false);
await onBuyCourseWithCredits(course, password, i18n);
},
[course, onBuyCourseWithCredits, password]
);
const onWillBuyWithCredits = React.useCallback(
async (i18n: I18nType) => {
// Password is required in dev environment only so that one cannot freely claim asset packs.
if (Window.isDev()) setDisplayPasswordPrompt(true);
else onClickBuyWithCredits(i18n);
},
[onClickBuyWithCredits]
);
const specializationConfig = getSpecializationConfig(
course ? course.specializationId : 'loading'
);
const courseListingData = listedCourses
? listedCourses.find(listedCourse => listedCourse.id === course.id)
: null;
const shouldUseOrSimulateAppStoreProduct =
shouldUseAppStoreProduct() || simulateAppStoreProduct;
return (
<I18n>
{({ i18n }) => (
<>
<SectionRow>
<Paper background="dark" variant="outlined" style={{ padding: 16 }}>
<ColumnStackLayout noMargin>
<ResponsiveLineStackLayout
noMargin
alignItems="center"
justifyContent="flex-start"
forceMobileLayout={isMediumScreen}
expand
>
<div style={styles.imageContainer}>
<img
src={selectMessageByLocale(i18n, course.imageUrlByLocale)}
style={styles.image}
alt=""
/>
</div>
<ColumnStackLayout expand justifyContent="flex-start">
{course.includedInSubscriptions.length && (
<Line
noMargin
alignItems="center"
justifyContent={
isMobile || isMediumScreen ? 'flex-start' : 'flex-end'
}
>
<div className={classes.premiumContainer}>
<Paper style={styles.paper} background="medium">
<Column>
<Line expand alignItems="center" noMargin>
<Gold style={styles.diamondIcon} />
<Text>
<Trans>
Included with GDevelop subscriptions
</Trans>
</Text>
</Line>
</Column>
</Paper>
</div>
</Line>
)}
<Text size="title" noMargin style={styles.title}>
{selectMessageByLocale(i18n, course.titleByLocale)}
</Text>
<Line noMargin>
<Text noMargin>
{selectMessageByLocale(
i18n,
course.shortDescriptionByLocale
)}
</Text>
</Line>
</ColumnStackLayout>
</ResponsiveLineStackLayout>
<ResponsiveLineStackLayout
expand
justifyContent="space-between"
forceMobileLayout={isMediumScreen}
>
<Column justifyContent="center" expand noMargin>
<Text>
<Trans>{course.chaptersTargetCount} chapters</Trans>
</Text>
</Column>
<ResponsiveDivider />
<Column
justifyContent="center"
alignItems="flex-start"
expand
noMargin
>
<Chip
style={{
...styles.chip,
border: `1px solid ${getChipColorFromEnglishLevel(
course.levelByLocale.en
)}`,
}}
label={selectMessageByLocale(i18n, course.levelByLocale)}
variant="outlined"
/>
</Column>
<ResponsiveDivider />
<Column justifyContent="center" expand noMargin>
<LineStackLayout alignItems="center" noMargin>
<span
style={{
...styles.specializationDot,
backgroundColor: specializationConfig.color,
}}
/>
<Text
displayInlineAsSpan
size="body-small"
noMargin
color="secondary"
style={textEllipsisStyle}
>
{specializationConfig.label}
</Text>
</LineStackLayout>
</Column>
</ResponsiveLineStackLayout>
{course.isLocked && (
<Paper background="medium" style={{ padding: 16 }}>
{!!courseListingData && (
<ResponsiveLineStackLayout
justifyContent="space-between"
noMargin
>
{!isMobile && !isMediumScreen && (
<Column noMargin justifyContent="center">
<div
style={{
color: gdevelopTheme.text.color.secondary,
}}
>
{renderProductPrice({
i18n,
productListingData: courseListingData,
usageType: 'default',
showBothPrices: 'line',
})}
</div>
</Column>
)}
<PurchaseProductButtons
i18n={i18n}
productListingData={courseListingData}
selectedUsageType="default"
onUsageTypeChange={() => {}}
simulateAppStoreProduct={
shouldUseOrSimulateAppStoreProduct
}
isAlreadyReceived={!course.isLocked}
onClickBuy={() => onBuyCourse(course, password, i18n)}
onClickBuyWithCredits={() =>
onWillBuyWithCredits(i18n)
}
/>
</ResponsiveLineStackLayout>
)}
</Paper>
)}
</ColumnStackLayout>
</Paper>
</SectionRow>
{!!purchasingCourseListingData && (
<CoursePurchaseDialog
course={course}
courseListingData={purchasingCourseListingData}
onClose={() => setPurchasingCourseListingData(null)}
/>
)}
{displayPasswordPrompt && (
<PasswordPromptDialog
onApply={() => onClickBuyWithCredits(i18n)}
onClose={() => setDisplayPasswordPrompt(false)}
passwordValue={password}
setPasswordValue={setPassword}
/>
)}
</>
)}
</I18n>
);
};
export default CourseSectionHeader;

View File

@@ -0,0 +1,20 @@
.premiumContainer {
position: relative;
overflow: hidden;
padding: 2px;
border-radius: 10px;
display: flex;
flex-shrink: 0;
}
.premiumContainer::before {
content: '';
display: block;
background: linear-gradient(90deg, var(--theme-premium-teal) 0%, var(--theme-premium-orange) 100%);
width: 100%;
padding-bottom: 100%;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}

View File

@@ -0,0 +1,133 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import SectionContainer, { SectionRow } from '../SectionContainer';
import { Trans } from '@lingui/macro';
import {
type Course,
type CourseChapter,
} from '../../../../Utils/GDevelopServices/Asset';
import CoursePreviewBanner from '../../../../Course/CoursePreviewBanner';
import type { CourseCompletion, CourseChapterCompletion } from '../UseCourses';
import { Line } from '../../../../UI/Grid';
import { GridList, GridListTile } from '@material-ui/core';
import CourseStoreContext from '../../../../Course/CourseStoreContext';
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import { LARGE_WIDGET_SIZE } from '../CardWidget';
import CourseCard from './CourseCard';
import { getColumnsFromWindowSize } from './Utils';
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const ITEMS_SPACING = 5;
const styles = {
grid: {
textAlign: 'center',
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
overflow: 'hidden',
width: `calc(100% + ${2 * ITEMS_SPACING}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
},
};
type Props = {|
onBack: () => void,
courses: ?Array<Course>,
onSelectCourse: (courseId: string) => void,
previewedCourse: ?Course,
previewedCourseChapters: ?Array<CourseChapter>,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
getCourseChapterCompletion: (
courseId: string,
chapterId: string
) => CourseChapterCompletion | null,
|};
const CoursesPage = ({
onBack,
courses,
onSelectCourse,
previewedCourse,
previewedCourseChapters,
getCourseChapterCompletion,
getCourseCompletion,
}: Props) => {
const { listedCourses } = React.useContext(CourseStoreContext);
const { windowSize, isLandscape } = useResponsiveWindowSize();
if (!courses || !listedCourses) {
return (
<SectionContainer flexBody>
<SectionRow expand>
<PlaceholderLoader />
</SectionRow>
</SectionContainer>
);
}
return (
<I18n>
{({ i18n }) => (
<SectionContainer
title={<Trans>Official Game Dev courses</Trans>}
backAction={onBack}
>
<SectionRow>
<CoursePreviewBanner
course={previewedCourse}
courseChapters={previewedCourseChapters}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
onDisplayCourse={() => {
if (!previewedCourse) return;
onSelectCourse(previewedCourse.id);
}}
/>
</SectionRow>
<SectionRow>
<Line>
<GridList
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{courses && listedCourses
? courses.map(course => {
const completion = getCourseCompletion(course.id);
const courseListingData = listedCourses.find(
listedCourse => listedCourse.id === course.id
);
return (
<GridListTile key={course.id}>
<CourseCard
course={course}
courseListingData={courseListingData}
completion={completion}
onClick={() => {
onSelectCourse(course.id);
}}
/>
</GridListTile>
);
})
: new Array(5).fill(0).map((_, index) => (
<GridListTile key={`skeleton-course-${index}`}>
<CourseCard
course={null}
courseListingData={null}
completion={null}
/>
</GridListTile>
))}
</GridList>
</Line>
</SectionRow>
</SectionContainer>
)}
</I18n>
);
};
export default CoursesPage;

View File

@@ -0,0 +1,62 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import SectionContainer, { SectionRow } from '../SectionContainer';
import Text from '../../../../UI/Text';
import { LargeSpacer, Line } from '../../../../UI/Grid';
import { Trans } from '@lingui/macro';
import PreferencesContext from '../../../Preferences/PreferencesContext';
import FlatButton from '../../../../UI/FlatButton';
import InAppTutorialContext from '../../../../InAppTutorial/InAppTutorialContext';
import GuidedLessons from '../InAppTutorials/GuidedLessons';
import FlingGame from '../InAppTutorials/FlingGame';
type Props = {|
onBack: () => void,
selectInAppTutorial: (tutorialId: string) => void,
|};
const InAppTutorialsPage = ({ onBack, selectInAppTutorial }: Props) => {
const {
values: { showInAppTutorialDeveloperMode },
} = React.useContext(PreferencesContext);
const { onLoadInAppTutorialFromLocalFile } = React.useContext(
InAppTutorialContext
);
return (
<I18n>
{({ i18n }) => (
<SectionContainer backAction={onBack}>
<SectionRow>
<Line justifyContent="space-between" noMargin alignItems="center">
<Text noMargin size="section-title">
<Trans>In-app Tutorials</Trans>
</Text>
{showInAppTutorialDeveloperMode && (
<FlatButton
label={<Trans>Load local lesson</Trans>}
onClick={onLoadInAppTutorialFromLocalFile}
/>
)}
</Line>
<GuidedLessons selectInAppTutorial={selectInAppTutorial} />
</SectionRow>
<SectionRow>
<Text size="section-title">
<Trans>Create and Publish a Fling game</Trans>
</Text>
<Text size="body" color="secondary" noMargin>
<Trans>
3-part tutorial to creating and publishing a game from scratch.
</Trans>
</Text>
<LargeSpacer />
<FlingGame selectInAppTutorial={selectInAppTutorial} />
</SectionRow>
</SectionContainer>
)}
</I18n>
);
};
export default InAppTutorialsPage;

View File

@@ -8,10 +8,7 @@ import { Trans } from '@lingui/macro';
import TranslateIcon from '@material-ui/icons/Translate';
import { ColumnStackLayout, LineStackLayout } from '../../../../UI/Layout';
import { type HomeTab } from '../HomePageMenu';
import {
type TutorialCategory,
type Tutorial,
} from '../../../../Utils/GDevelopServices/Tutorial';
import { type Tutorial } from '../../../../Utils/GDevelopServices/Tutorial';
import SectionContainer, { SectionRow } from '../SectionContainer';
import type {
Course,
@@ -19,51 +16,30 @@ import type {
} from '../../../../Utils/GDevelopServices/Asset';
import type { CourseCompletion, CourseChapterCompletion } from '../UseCourses';
import FlatButton from '../../../../UI/FlatButton';
import {
useResponsiveWindowSize,
type WindowSizeType,
} from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import { CardWidget, LARGE_WIDGET_SIZE } from '../CardWidget';
import GridList from '@material-ui/core/GridList';
import GridListTile from '@material-ui/core/GridListTile';
import ImageTileRow from '../../../../UI/ImageTileRow';
import { formatTutorialToImageTileComponent, TUTORIAL_CATEGORY_TEXTS } from '.';
import GuidedLessons from '../InAppTutorials/GuidedLessons';
import ArrowRight from '../../../../UI/CustomSvgIcons/ArrowRight';
import Upload from '../../../../UI/CustomSvgIcons/Upload';
import FlingGame from '../InAppTutorials/FlingGame';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutorials/PrivateTutorialViewDialog';
import { EducationCard } from './EducationCard';
import InAppTutorialContext from '../../../../InAppTutorial/InAppTutorialContext';
import PreferencesContext from '../../../Preferences/PreferencesContext';
import RaisedButton from '../../../../UI/RaisedButton';
import Help from '../../../../UI/CustomSvgIcons/Help';
import AnyQuestionDialog from '../AnyQuestionDialog';
import Paper from '../../../../UI/Paper';
import CoursePreviewBanner from '../../../../Course/CoursePreviewBanner';
import CourseCard from './CourseCard';
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
import Link from '../../../../UI/Link';
import CourseStoreContext from '../../../../Course/CourseStoreContext';
import TutorialsRow from './TutorialsRow';
import { getColumnsFromWindowSize, type LearnCategory } from './Utils';
import ExampleStore from '../../../../AssetStore/ExampleStore';
import { type PrivateGameTemplateListingData } from '../../../../Utils/GDevelopServices/Shop';
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
import Carousel from '../../../../UI/Carousel';
const getColumnsFromWindowSize = (
windowSize: WindowSizeType,
isLandscape: boolean
) => {
switch (windowSize) {
case 'small':
return isLandscape ? 4 : 2;
case 'medium':
return 3;
case 'large':
return 4;
case 'xlarge':
return 6;
default:
return 3;
}
};
const NUMBER_OF_SCROLLS = 2; // Number of times the carousel can be scrolled to see all items.
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const ITEMS_SPACING = 5;
@@ -82,83 +58,30 @@ const styles = {
},
};
const getTutorialsColumnsFromWidth = (
windowSize: WindowSizeType,
isLandscape: boolean
) => {
switch (windowSize) {
case 'small':
return isLandscape ? 5 : 2;
case 'medium':
return 3;
case 'large':
return 5;
case 'xlarge':
return 6;
default:
return 3;
}
};
type TutorialsRowProps = {|
limits: ?Limits,
tutorials: Tutorial[],
category: TutorialCategory,
onSelectCategory: (TutorialCategory | null) => void,
onSelectTutorial: (tutorial: Tutorial) => void,
|};
export const TutorialsRow = ({
limits,
tutorials,
category,
onSelectCategory,
onSelectTutorial,
}: TutorialsRowProps) => (
<I18n>
{({ i18n }) => (
<ImageTileRow
title={TUTORIAL_CATEGORY_TEXTS[category].title}
description={TUTORIAL_CATEGORY_TEXTS[category].description}
items={tutorials
.filter(tutorial => tutorial.category === category)
.map(tutorial =>
formatTutorialToImageTileComponent({
i18n,
limits,
tutorial,
onSelectTutorial,
})
)}
onShowAll={() => onSelectCategory(category)}
showAllIcon={<ArrowRight fontSize="small" />}
getColumnsFromWindowSize={getTutorialsColumnsFromWidth}
getLimitFromWindowSize={getTutorialsColumnsFromWidth}
/>
)}
</I18n>
);
type Props = {|
onTabChange: (tab: HomeTab) => void,
onSelectCategory: (TutorialCategory | null) => void,
tutorials: Array<Tutorial>,
onSelectCategory: (category: LearnCategory) => void,
selectInAppTutorial: (tutorialId: string) => void,
previewedCourse: ?Course,
courses: ?(Course[]),
previewedCourseChapters: ?(CourseChapter[]),
onSelectCourse: (courseId: string | null) => void,
onSelectCourse: (courseId: string) => void,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
getCourseChapterCompletion: (
courseId: string,
chapterId: string
) => CourseChapterCompletion | null,
onOpenAskAi: (mode?: 'chat' | 'agent') => void,
onOpenNewProjectSetupDialog: () => void,
onSelectPrivateGameTemplateListingData: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void,
|};
const MainPage = ({
onTabChange,
onSelectCategory,
tutorials,
selectInAppTutorial,
previewedCourse,
courses,
@@ -166,21 +89,17 @@ const MainPage = ({
onSelectCourse,
getCourseCompletion,
getCourseChapterCompletion,
onOpenAskAi,
onOpenNewProjectSetupDialog,
onSelectPrivateGameTemplateListingData,
onSelectExampleShortHeader,
}: Props) => {
const { limits } = React.useContext(AuthenticatedUserContext);
const { onLoadInAppTutorialFromLocalFile } = React.useContext(
InAppTutorialContext
);
const {
palette: { type: paletteType },
} = React.useContext(GDevelopThemeContext);
const [isAnyQuestionDialogOpen, setIsAnyQuestionDialogOpen] = React.useState(
false
);
const {
values: { showInAppTutorialDeveloperMode },
} = React.useContext(PreferencesContext);
const { listedCourses } = React.useContext(CourseStoreContext);
const {
windowSize,
isMobile,
@@ -188,6 +107,17 @@ const MainPage = ({
isMediumScreen,
} = useResponsiveWindowSize();
const displayedCourses = React.useMemo(
() => {
if (!courses) return null;
const numberOfColumnsToScroll =
getColumnsFromWindowSize(windowSize, isLandscape) *
(NUMBER_OF_SCROLLS + 1);
return courses.slice(0, numberOfColumnsToScroll);
},
[courses, windowSize, isLandscape]
);
const [
selectedTutorial,
setSelectedTutorial,
@@ -217,312 +147,296 @@ const MainPage = ({
},
].filter(Boolean);
return (
<SectionContainer
title={<Trans>Your learning journey starts here</Trans>}
customPaperStyle={{
backgroundAttachment: 'local',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'top',
backgroundSize: isMobile && !isLandscape ? 'contain' : 'auto',
backgroundImage: `url('res/premium/premium_dialog_background.png'),${
paletteType === 'dark'
? 'linear-gradient(180deg, #322659 0px, #3F2458 20px, #1D1D26 200px, #1D1D26 100%)'
: 'linear-gradient(180deg, #CBBAFF 0px, #DEBBFF 20px, #F5F5F7 200px, #F5F5F7 100%)'
}`,
}}
>
<SectionRow>
<CoursePreviewBanner
course={previewedCourse}
courseChapters={previewedCourseChapters}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
onDisplayCourse={() => {
if (!previewedCourse) return;
onSelectCourse(previewedCourse.id);
onSelectCategory('course');
}}
/>
</SectionRow>
const numberOfColumns = getColumnsFromWindowSize(windowSize, isLandscape);
<SectionRow>
<Text size="title">
<Trans>GameDev official specialization courses</Trans>
</Text>
<Line>
<GridList
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{courses
? courses.map(course => {
const completion = getCourseCompletion(course.id);
return (
<GridListTile key={course.id}>
<CourseCard
course={course}
completion={completion}
onClick={() => {
onSelectCourse(course.id);
onSelectCategory('course');
}}
/>
</GridListTile>
);
})
: new Array(5).fill(0).map((_, index) => (
<GridListTile key={`skeleton-course-${index}`}>
<CourseCard course={null} completion={null} />
return (
<I18n>
{({ i18n }) => (
<SectionContainer
chipText={<Trans>Start for free</Trans>}
title={<Trans>Official Game Dev courses</Trans>}
titleAdornment={
<Line noMargin justifyContent="flex-end">
<FlatButton
onClick={() => onSelectCategory('all-courses')}
label={<Trans>See all</Trans>}
rightIcon={<ArrowRight fontSize="small" />}
/>
</Line>
}
subtitleText={
<Trans>
Break into the{' '}
<Link
href={'https://gdevelop.io/blog/indie-mobile-creators-2025'}
onClick={() =>
Window.openExternalURL(
'https://gdevelop.io/blog/indie-mobile-creators-2025'
)
}
>
booming industry
</Link>{' '}
of casual gaming. Sharpen your skills and become a professional.
Start for free:
</Trans>
}
customPaperStyle={{
backgroundAttachment: 'local',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'top',
backgroundSize: isMobile && !isLandscape ? 'contain' : 'auto',
backgroundImage: `url('res/premium/premium_dialog_background.png'),${
paletteType === 'dark'
? 'linear-gradient(180deg, #322659 0px, #3F2458 20px, #1D1D26 200px, #1D1D26 100%)'
: 'linear-gradient(180deg, #CBBAFF 0px, #DEBBFF 20px, #F5F5F7 200px, #F5F5F7 100%)'
}`,
}}
>
<SectionRow>
<Line>
<Carousel
items={
displayedCourses && listedCourses
? displayedCourses.map(course => {
const completion = getCourseCompletion(course.id);
const courseListingData = listedCourses.find(
listedCourse => listedCourse.id === course.id
);
return {
renderItem: () => (
<GridListTile key={course.id}>
<CourseCard
course={course}
courseListingData={courseListingData}
completion={completion}
onClick={() => {
onSelectCourse(course.id);
}}
/>
</GridListTile>
),
};
})
: new Array(6).fill(0).map((_, index) => ({
renderItem: () => (
<GridListTile key={`skeleton-course-${index}`}>
<CourseCard
course={null}
courseListingData={null}
completion={null}
/>
</GridListTile>
),
}))
}
/>
</Line>
</SectionRow>
<SectionRow>
<LineStackLayout
justifyContent="space-between"
alignItems="center"
noMargin
expand
>
<Column noMargin>
<Text size="section-title">
<Trans>In-app tutorials</Trans>
</Text>
</Column>
<Column noMargin>
<FlatButton
onClick={() => onSelectCategory('in-app-tutorials')}
label={<Trans>See all</Trans>}
rightIcon={<ArrowRight fontSize="small" />}
/>
</Column>
</LineStackLayout>
<GuidedLessons
selectInAppTutorial={selectInAppTutorial}
displayAsCarousel
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="all-tutorials"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
<SectionRow>
<LineStackLayout
noMargin
alignItems="center"
justifyContent="space-between"
>
<Column noMargin expand>
<Text size="section-title">
<Trans>Learn by dissecting ready-made games</Trans>
</Text>
</Column>
<Column noMargin expand>
<Line noMargin justifyContent="flex-end">
<FlatButton
onClick={onOpenNewProjectSetupDialog}
label={<Trans>See all</Trans>}
rightIcon={<ArrowRight fontSize="small" />}
/>
</Line>
</Column>
</LineStackLayout>
<Spacer />
<ExampleStore
onSelectExampleShortHeader={onSelectExampleShortHeader}
onSelectPrivateGameTemplateListingData={
onSelectPrivateGameTemplateListingData
}
i18n={i18n}
getColumnsFromWindowSize={getColumnsFromWindowSize}
hideSearch
onlyShowGames
hidePremiumTemplates
limitRowsTo={1}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="full-game"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
<SectionRow>
<LineStackLayout
justifyContent="space-between"
alignItems="center"
noMargin
expand
>
<Column noMargin>
<Text size="section-title">
<Trans>Want to know more?</Trans>
</Text>
</Column>
<LineStackLayout noMargin>
{!isMobile && (
<FlatButton
onClick={() => {
Window.openExternalURL(
'https://github.com/GDevelopApp/GDevelop-examples/issues/new/choose'
);
}}
primary
leftIcon={<Upload />}
label={
isMediumScreen ? (
<Trans>Submit an example</Trans>
) : (
<Trans>Submit your project as an example</Trans>
)
}
/>
)}
{!isMobile && (
<FlatButton
onClick={() => {
Window.openExternalURL(
'https://airtable.com/shrv295oHlsuS69el'
);
}}
primary
leftIcon={<TranslateIcon />}
label={
isMediumScreen ? (
<Trans>Submit a tutorial</Trans>
) : (
<Trans>
Submit a tutorial translated in your language
</Trans>
)
}
/>
)}
</LineStackLayout>
</LineStackLayout>
</SectionRow>
<SectionRow>
<ColumnStackLayout noMargin expand>
<Line noMargin>
<GridList
cols={numberOfColumns}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
<GridListTile cols={1} style={{ background: 'transparent' }}>
<Paper
background="light"
style={{ display: 'flex', height: '100%' }}
>
<Column expand>
<Line expand alignItems="flex-start">
<Help />
<ColumnStackLayout expand alignItems="flex-start">
<Text noMargin size="block-title" align="left">
<Trans>Blocked on GDevelop?</Trans>
</Text>
<RaisedButton
size="large"
color="success"
label={<Trans>Ask the AI</Trans>}
rightIcon={<ArrowRight />}
onClick={() => onOpenAskAi('chat')}
/>
</ColumnStackLayout>
</Line>
</Column>
</Paper>
</GridListTile>
))}
</GridList>
</Line>
</SectionRow>
<SectionRow>
<Line justifyContent="space-between" noMargin alignItems="center">
<Text noMargin size="title">
<Trans>Guided lessons</Trans>
</Text>
{showInAppTutorialDeveloperMode && (
<FlatButton
label={<Trans>Load local lesson</Trans>}
onClick={onLoadInAppTutorialFromLocalFile}
{helpItems.map((helpItem, index) => (
<GridListTile key={index}>
<CardWidget
onClick={helpItem.action}
key={index}
size="large"
disabled={helpItem.disabled}
useDefaultDisabledStyle
>
<div style={styles.helpItem}>
<ColumnStackLayout
expand
justifyContent="center"
useFullHeight
>
<Text noMargin size="block-title">
{helpItem.title}
</Text>
<Text noMargin size="body" color="secondary">
{helpItem.description}
</Text>
</ColumnStackLayout>
</div>
</CardWidget>
</GridListTile>
))}
</GridList>
</Line>
</ColumnStackLayout>
</SectionRow>
{selectedTutorial && (
<PrivateTutorialViewDialog
tutorial={selectedTutorial}
onClose={() => setSelectedTutorial(null)}
/>
)}
</Line>
<GuidedLessons selectInAppTutorial={selectInAppTutorial} />
</SectionRow>
<SectionRow>
<ColumnStackLayout noMargin expand>
<Line noMargin>
<GridList
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{limits &&
limits.quotas['ask-question'] &&
limits.quotas['ask-question'].max > 0 ? (
<GridListTile cols={2} style={{ background: 'transparent' }}>
<Paper
background="light"
style={{ display: 'flex', height: '100%' }}
>
<Column expand>
<Line expand alignItems="flex-start">
<Help />
<ColumnStackLayout expand alignItems="flex-start">
<Text noMargin size="block-title">
<Trans>Blocked on GDevelop?</Trans>
</Text>
<RaisedButton
label={
<Trans>Ask any question, get an answer</Trans>
}
size="medium"
color="success"
onClick={() => {
setIsAnyQuestionDialogOpen(true);
}}
/>
</ColumnStackLayout>
</Line>
</Column>
</Paper>
</GridListTile>
) : null}
{helpItems.map((helpItem, index) => (
<GridListTile key={index}>
<CardWidget
onClick={helpItem.action}
key={index}
size="large"
disabled={helpItem.disabled}
useDefaultDisabledStyle
>
<div style={styles.helpItem}>
<ColumnStackLayout
expand
justifyContent="center"
useFullHeight
>
<Text noMargin size="block-title">
{helpItem.title}
</Text>
<Text noMargin size="body" color="secondary">
{helpItem.description}
</Text>
</ColumnStackLayout>
</div>
</CardWidget>
</GridListTile>
))}
</GridList>
</Line>
</ColumnStackLayout>
</SectionRow>
<>
<SectionRow>
<Line noMargin>
<Text size="title">
<Trans>Courses</Trans>
</Text>
</Line>
<Line noMargin>
<Text noMargin>
<Trans>Learn everything about GDevelop from the ground up</Trans>
</Text>
</Line>
{limits &&
limits.capabilities.classrooms &&
limits.capabilities.classrooms.hideUpgradeNotice ? null : (
<>
<Spacer />
<EducationCard
onSeeResources={() => onSelectCategory('education-curriculum')}
/>
</>
)}
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="official-beginner"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
tutorials={tutorials}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="official-intermediate"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
tutorials={tutorials}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="official-advanced"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
tutorials={tutorials}
/>
</SectionRow>
<SectionRow>
<Text noMargin size="section-title">
<Trans>Create and Publish a Fling game</Trans>
</Text>
<Text size="body" color="secondary" noMargin>
<Trans>
3-part tutorial to creating and publishing a game from scratch.
</Trans>
</Text>
<FlingGame selectInAppTutorial={selectInAppTutorial} />
</SectionRow>
<SectionRow>
<LineStackLayout
justifyContent="space-between"
alignItems="center"
noMargin
expand
>
<Column noMargin>
<Text size="title">
<Trans>Guides and tutorials</Trans>
</Text>
</Column>
<LineStackLayout noMargin>
{!isMobile && (
<FlatButton
onClick={() => {
Window.openExternalURL(
'https://github.com/GDevelopApp/GDevelop-examples/issues/new/choose'
);
}}
primary
leftIcon={<Upload />}
label={
isMediumScreen ? (
<Trans>Submit an example</Trans>
) : (
<Trans>Submit your project as an example</Trans>
)
}
/>
)}
{!isMobile && (
<FlatButton
onClick={() => {
Window.openExternalURL(
'https://airtable.com/shrv295oHlsuS69el'
);
}}
primary
leftIcon={<TranslateIcon />}
label={
isMediumScreen ? (
<Trans>Submit a tutorial</Trans>
) : (
<Trans>
Submit a tutorial translated in your language
</Trans>
)
}
/>
)}
</LineStackLayout>
</LineStackLayout>
<Line noMargin>
<Text noMargin>
<Trans>Learn by doing</Trans>
</Text>
</Line>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="education-curriculum"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
tutorials={tutorials}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="full-game"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
tutorials={tutorials}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="game-mechanic"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
tutorials={tutorials}
/>
</SectionRow>
{selectedTutorial && (
<PrivateTutorialViewDialog
tutorial={selectedTutorial}
onClose={() => setSelectedTutorial(null)}
/>
)}
</>
{isAnyQuestionDialogOpen && (
<AnyQuestionDialog onClose={() => setIsAnyQuestionDialogOpen(false)} />
</SectionContainer>
)}
</SectionContainer>
</I18n>
);
};

View File

@@ -8,7 +8,10 @@ import {
type Tutorial,
type TutorialCategory,
} from '../../../../Utils/GDevelopServices/Tutorial';
import { formatTutorialToImageTileComponent, TUTORIAL_CATEGORY_TEXTS } from '.';
import {
formatTutorialToImageTileComponent,
TUTORIAL_CATEGORY_TEXTS,
} from './Utils';
import ImageTileGrid from '../../../../UI/ImageTileGrid';
import { type WindowSizeType } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
@@ -16,6 +19,8 @@ import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutoria
import EducationCurriculumLesson from './EducationCurriculumLesson';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
import Text from '../../../../UI/Text';
import { TutorialContext } from '../../../../Tutorial/TutorialContext';
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
const styles = {
educationCurriculumTutorialContainer: {
@@ -126,7 +131,7 @@ const getColumnsFromWindowSize = (windowSize: WindowSizeType) => {
case 'medium':
return 3;
case 'large':
return 5;
return 4;
case 'xlarge':
return 6;
default:
@@ -136,7 +141,6 @@ const getColumnsFromWindowSize = (windowSize: WindowSizeType) => {
type Props = {|
onBack: () => void,
tutorials: Array<Tutorial>,
category: TutorialCategory,
onOpenTemplateFromTutorial: string => Promise<void>,
onSelectCourse: (courseId: string) => void,
@@ -144,22 +148,32 @@ type Props = {|
const TutorialsCategoryPage = ({
category,
tutorials,
onBack,
onOpenTemplateFromTutorial,
onSelectCourse,
}: Props) => {
const { limits } = React.useContext(AuthenticatedUserContext);
const { tutorials } = React.useContext(TutorialContext);
const texts = TUTORIAL_CATEGORY_TEXTS[category];
const filteredTutorials = tutorials.filter(
tutorial => tutorial.category === category
);
const filteredTutorials = tutorials
? tutorials.filter(tutorial => tutorial.category === category)
: null;
const [
selectedTutorial,
setSelectedTutorial,
] = React.useState<Tutorial | null>(null);
if (!filteredTutorials) {
return (
<SectionContainer flexBody>
<SectionRow expand>
<PlaceholderLoader />
</SectionRow>
</SectionContainer>
);
}
return (
<I18n>
{({ i18n }) => (

View File

@@ -0,0 +1,92 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import SectionContainer, { SectionRow } from '../SectionContainer';
import { type Tutorial } from '../../../../Utils/GDevelopServices/Tutorial';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutorials/PrivateTutorialViewDialog';
import TutorialsRow from './TutorialsRow';
import { getColumnsFromWindowSize, type LearnCategory } from './Utils';
type Props = {|
onSelectCategory: (category: LearnCategory) => void,
|};
const TutorialsPage = ({ onSelectCategory }: Props) => {
const { limits } = React.useContext(AuthenticatedUserContext);
const [
selectedTutorial,
setSelectedTutorial,
] = React.useState<Tutorial | null>(null);
return (
<I18n>
{({ i18n }) => (
<SectionContainer backAction={() => onSelectCategory(null)}>
<SectionRow>
<TutorialsRow
limits={limits}
category="official-beginner"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="official-intermediate"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="official-advanced"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="education-curriculum"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="full-game"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
<SectionRow>
<TutorialsRow
limits={limits}
category="game-mechanic"
onSelectCategory={onSelectCategory}
onSelectTutorial={setSelectedTutorial}
getColumnsFromWindowSize={getColumnsFromWindowSize}
/>
</SectionRow>
{selectedTutorial && (
<PrivateTutorialViewDialog
tutorial={selectedTutorial}
onClose={() => setSelectedTutorial(null)}
/>
)}
</SectionContainer>
)}
</I18n>
);
};
export default TutorialsPage;

View File

@@ -0,0 +1,130 @@
// @flow
import * as React from 'react';
import { type I18n as I18nType } from '@lingui/core';
import { I18n } from '@lingui/react';
import { Trans } from '@lingui/macro';
import {
type TutorialCategory,
type Tutorial,
} from '../../../../Utils/GDevelopServices/Tutorial';
import { type WindowSizeType } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import ImageTileRow from '../../../../UI/ImageTileRow';
import ArrowRight from '../../../../UI/CustomSvgIcons/ArrowRight';
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
import {
formatTutorialToImageTileComponent,
TUTORIAL_CATEGORY_TEXTS,
type LearnCategory,
} from './Utils';
import { TutorialContext } from '../../../../Tutorial/TutorialContext';
import Paper from '../../../../UI/Paper';
import PlaceholderError from '../../../../UI/PlaceholderError';
const styles = {
paper: {
flex: 1,
display: 'flex',
},
};
type TutorialsRowProps = {|
limits: ?Limits,
category: TutorialCategory | 'all-tutorials',
onSelectCategory: (category: LearnCategory) => void,
onSelectTutorial: (tutorial: Tutorial) => void,
getColumnsFromWindowSize: (
windowSize: WindowSizeType,
isLandscape: boolean
) => number,
|};
export const TutorialsRow = ({
limits,
category,
onSelectCategory,
onSelectTutorial,
getColumnsFromWindowSize,
}: TutorialsRowProps) => {
const {
tutorials,
error: tutorialLoadingError,
fetchTutorials,
} = React.useContext(TutorialContext);
const title =
category === 'all-tutorials' ? (
<Trans>Public tutorials</Trans>
) : (
TUTORIAL_CATEGORY_TEXTS[category].title
);
const description =
category === 'all-tutorials'
? null
: TUTORIAL_CATEGORY_TEXTS[category].description;
const getItems = (i18n: I18nType) => {
if (!tutorials) return [];
let filteredTutorials = tutorials;
// If category is all, show 2 of each official category.
if (category === 'all-tutorials') {
const officialBeginnerTutorials = tutorials
.filter(tutorial => tutorial.category === 'official-beginner')
.slice(0, 2);
const officialIntermediateTutorials = tutorials
.filter(tutorial => tutorial.category === 'official-intermediate')
.slice(0, 2);
const officialAdvancedTutorials = tutorials
.filter(tutorial => tutorial.category === 'official-advanced')
.slice(0, 2);
filteredTutorials = [
...officialBeginnerTutorials,
...officialIntermediateTutorials,
...officialAdvancedTutorials,
];
} else {
filteredTutorials = tutorials.filter(
tutorial => tutorial.category === category
);
}
return filteredTutorials.map(tutorial =>
formatTutorialToImageTileComponent({
i18n,
limits,
tutorial,
onSelectTutorial,
})
);
};
if (tutorialLoadingError) {
return (
<Paper square style={styles.paper} background="dark">
<PlaceholderError onRetry={fetchTutorials}>
<Trans>
Can't load the tutorials. Verify your internet connection or retry
later.
</Trans>
</PlaceholderError>
</Paper>
);
}
return (
<I18n>
{({ i18n }) => (
<ImageTileRow
title={title}
description={description}
items={getItems(i18n)}
isLoading={!tutorials}
onShowAll={() => onSelectCategory(category)}
showAllIcon={<ArrowRight fontSize="small" />}
getColumnsFromWindowSize={getColumnsFromWindowSize}
getLimitFromWindowSize={getColumnsFromWindowSize}
/>
)}
</I18n>
);
};
export default TutorialsRow;

View File

@@ -0,0 +1,156 @@
// @flow
import { type I18n as I18nType } from '@lingui/core';
import * as React from 'react';
import { t, Trans } from '@lingui/macro';
import {
canAccessTutorial,
type TutorialCategory,
type Tutorial,
} from '../../../../Utils/GDevelopServices/Tutorial';
import { type WindowSizeType } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
import { type ImageTileComponent } from '../../../../UI/ImageTileGrid';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
import { sendTutorialOpened } from '../../../../Utils/Analytics/EventSender';
import Window from '../../../../Utils/Window';
import { formatDuration } from '../../../../Utils/Duration';
export type LearnCategory =
| TutorialCategory
| null
| 'all-tutorials'
| 'all-courses'
| 'in-app-tutorials';
export const TUTORIAL_CATEGORY_TEXTS = {
'full-game': {
title: <Trans>Make an entire game</Trans>,
description: <Trans>Make complete games step by step</Trans>,
},
'game-mechanic': {
title: <Trans>Specific game mechanics</Trans>,
description: (
<Trans>
Find how to implement the most common game mechanics and more
</Trans>
),
},
'official-beginner': {
title: <Trans>Beginner course</Trans>,
description: <Trans>Learn the fundamental principles of GDevelop</Trans>,
},
'official-intermediate': {
title: <Trans>Intermediate course</Trans>,
description: (
<Trans>Learn all the game-building mechanics of GDevelop</Trans>
),
},
'official-advanced': {
title: <Trans>Advanced course</Trans>,
description: <Trans>The icing on the cake</Trans>,
},
'education-curriculum': {
title: <Trans>Education curriculum and resources</Trans>,
description: (
<Trans>
For teachers and educators having the GDevelop Education subscription.
Ready to use resources for teaching.
</Trans>
),
},
course: {
title: <Trans>Loading</Trans>,
description: <Trans>Loading course...</Trans>,
},
recommendations: {
title: <Trans>Recommendations</Trans>,
description: null,
},
};
export const getColumnsFromWindowSize = (
windowSize: WindowSizeType,
isLandscape: boolean
) => {
switch (windowSize) {
case 'small':
return isLandscape ? 4 : 2;
case 'medium':
return 3;
case 'large':
return 4;
case 'xlarge':
return 6;
default:
return 3;
}
};
export const getChipColorFromTutorialCategory = (
category: TutorialCategory
): string | null => {
if (category === 'official-beginner') return '#3BF7F4';
if (category === 'official-intermediate') return '#FFBC57';
if (category === 'official-advanced') return '#FF8569';
if (category === 'full-game') return '#FFBC57';
if (category === 'game-mechanic') return '#FFBC57';
return null;
};
export const getChipTextFromTutorialCategory = (
category: TutorialCategory,
i18n: I18nType
) => {
if (category === 'official-beginner') return i18n._(t`Beginner`);
if (category === 'official-intermediate') return i18n._(t`Intermediate`);
if (category === 'official-advanced') return i18n._(t`Advanced`);
if (category === 'full-game') return i18n._(t`Intermediate`);
if (category === 'game-mechanic') return i18n._(t`Intermediate`);
return null;
};
type FormatTutorialToImageTileComponentProps = {|
i18n: I18nType,
limits: ?Limits,
tutorial: Tutorial,
onSelectTutorial: (tutorial: Tutorial) => void,
|};
export const formatTutorialToImageTileComponent = ({
i18n,
tutorial,
limits,
onSelectTutorial,
}: FormatTutorialToImageTileComponentProps): ImageTileComponent => {
const isLocked = !canAccessTutorial(
tutorial,
limits ? limits.capabilities : null
);
return {
title:
selectMessageByLocale(i18n, tutorial.titleByLocale) || tutorial.title,
description:
selectMessageByLocale(i18n, tutorial.descriptionByLocale) ||
tutorial.description,
isLocked,
onClick: () => {
if (tutorial.isPrivateTutorial) {
onSelectTutorial(tutorial);
return;
}
sendTutorialOpened(tutorial.id);
Window.openExternalURL(
selectMessageByLocale(i18n, tutorial.linkByLocale)
);
},
imageUrl: selectMessageByLocale(i18n, tutorial.thumbnailUrlByLocale),
overlayText: tutorial.duration
? formatDuration(tutorial.duration)
: '\u{1F4D8}',
overlayTextPosition: 'bottomRight',
chipText: getChipTextFromTutorialCategory(tutorial.category, i18n),
chipColor: getChipColorFromTutorialCategory(tutorial.category),
};
};

View File

@@ -3,132 +3,34 @@ import { type I18n as I18nType } from '@lingui/core';
import * as React from 'react';
import { type HomeTab } from '../HomePageMenu';
import {
type TutorialCategory,
type Tutorial,
canAccessTutorial,
} from '../../../../Utils/GDevelopServices/Tutorial';
type CourseListingData,
type PrivateGameTemplateListingData,
} from '../../../../Utils/GDevelopServices/Shop';
import MainPage from './MainPage';
import TutorialsCategoryPage from './TutorialsCategoryPage';
import { Trans } from '@lingui/macro';
import { TutorialContext } from '../../../../Tutorial/TutorialContext';
import PlaceholderError from '../../../../UI/PlaceholderError';
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
import { sendTutorialOpened } from '../../../../Utils/Analytics/EventSender';
import Window from '../../../../Utils/Window';
import { type ImageTileComponent } from '../../../../UI/ImageTileGrid';
import Paper from '../../../../UI/Paper';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
import ErrorBoundary from '../../../../UI/ErrorBoundary';
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
import { formatDuration } from '../../../../Utils/Duration';
import CourseSection from './CourseSection';
import type {
CourseChapter,
Course,
} from '../../../../Utils/GDevelopServices/Asset';
import type { CourseChapterCompletion, CourseCompletion } from '../UseCourses';
export const TUTORIAL_CATEGORY_TEXTS = {
'full-game': {
title: <Trans>Entire games</Trans>,
description: <Trans>Make complete games step by step</Trans>,
},
'game-mechanic': {
title: <Trans>Specific game mechanics</Trans>,
description: (
<Trans>
Find how to implement the most common game mechanics and more
</Trans>
),
},
'official-beginner': {
title: <Trans>Beginner course</Trans>,
description: <Trans>Learn the fundamental principles of GDevelop</Trans>,
},
'official-intermediate': {
title: <Trans>Intermediate course</Trans>,
description: (
<Trans>Learn all the game-building mechanics of GDevelop</Trans>
),
},
'official-advanced': {
title: <Trans>Advanced course</Trans>,
description: <Trans>The icing on the cake</Trans>,
},
'education-curriculum': {
title: <Trans>Education curriculum and resources</Trans>,
description: (
<Trans>
For teachers and educators having the GDevelop Education subscription.
Ready to use resources for teaching.
</Trans>
),
},
course: {
title: <Trans>Loading</Trans>,
description: <Trans>Loading course...</Trans>,
},
recommendations: {
title: <Trans>Recommendations</Trans>,
description: null,
},
};
type FormatTutorialToImageTileComponentProps = {|
i18n: I18nType,
limits: ?Limits,
tutorial: Tutorial,
onSelectTutorial: (tutorial: Tutorial) => void,
|};
export const formatTutorialToImageTileComponent = ({
i18n,
tutorial,
limits,
onSelectTutorial,
}: FormatTutorialToImageTileComponentProps): ImageTileComponent => {
const isLocked = !canAccessTutorial(
tutorial,
limits ? limits.capabilities : null
);
return {
title:
selectMessageByLocale(i18n, tutorial.titleByLocale) || tutorial.title,
description:
selectMessageByLocale(i18n, tutorial.descriptionByLocale) ||
tutorial.description,
isLocked,
onClick: () => {
if (tutorial.isPrivateTutorial) {
onSelectTutorial(tutorial);
return;
}
sendTutorialOpened(tutorial.id);
Window.openExternalURL(
selectMessageByLocale(i18n, tutorial.linkByLocale)
);
},
imageUrl: selectMessageByLocale(i18n, tutorial.thumbnailUrlByLocale),
overlayText: tutorial.duration
? formatDuration(tutorial.duration)
: '\u{1F4D8}',
overlayTextPosition: 'bottomRight',
};
};
const styles = {
paper: {
flex: 1,
display: 'flex',
},
};
import SectionContainer, { SectionRow } from '../SectionContainer';
import TutorialsPage from './TutorialsPage';
import InAppTutorialsPage from './InAppTutorialsPage';
import CoursesPage from './CoursesPage';
import { type LearnCategory } from './Utils';
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
type Props = {|
onTabChange: (tab: HomeTab) => void,
selectInAppTutorial: (tutorialId: string) => void,
selectedCategory: TutorialCategory | null,
onSelectCategory: (TutorialCategory | null) => void,
selectedCategory: LearnCategory,
onSelectCategory: LearnCategory => void,
onOpenTemplateFromTutorial: string => Promise<void>,
onOpenTemplateFromCourseChapter: CourseChapter => Promise<void>,
previewedCourse: ?Course,
@@ -148,7 +50,24 @@ type Props = {|
chapterId: string
) => CourseChapterCompletion | null,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
onBuyCourseChapterWithCredits: (CourseChapter, string) => Promise<void>,
onBuyCourseWithCredits: (
Course: Course,
password: string,
i18n: I18nType
) => Promise<void>,
onBuyCourse: (
Course: Course,
password: string,
i18n: I18nType
) => Promise<void>,
purchasingCourseListingData: ?CourseListingData,
setPurchasingCourseListingData: (CourseListingData | null) => void,
onOpenAskAi: (mode?: 'chat' | 'agent') => void,
onOpenNewProjectSetupDialog: () => void,
onSelectPrivateGameTemplateListingData: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void,
|};
const LearnSection = ({
@@ -168,13 +87,16 @@ const LearnSection = ({
isCourseTaskCompleted,
getCourseChapterCompletion,
getCourseCompletion,
onBuyCourseChapterWithCredits,
onBuyCourseWithCredits,
onBuyCourse,
purchasingCourseListingData,
setPurchasingCourseListingData,
onOpenAskAi,
onOpenNewProjectSetupDialog,
onSelectPrivateGameTemplateListingData,
onSelectExampleShortHeader,
}: Props) => {
const {
tutorials,
fetchTutorials,
error: tutorialLoadingError,
} = React.useContext(TutorialContext);
const { fetchTutorials } = React.useContext(TutorialContext);
React.useEffect(
() => {
@@ -183,13 +105,22 @@ const LearnSection = ({
[fetchTutorials]
);
if (selectedCategory === 'course' && courseChapters && course) {
if (course) {
if (!courseChapters) {
return (
<SectionContainer flexBody>
<SectionRow expand>
<PlaceholderLoader />
</SectionRow>
</SectionContainer>
);
}
return (
<CourseSection
course={course}
courseChapters={courseChapters}
onBack={() => {
onSelectCategory(null);
onSelectCourse(null);
}}
onOpenTemplateFromCourseChapter={onOpenTemplateFromCourseChapter}
@@ -199,30 +130,19 @@ const LearnSection = ({
getCourseChapterCompletion(course.id, chapterId)
}
getCourseCompletion={() => getCourseCompletion(course.id)}
onBuyCourseChapterWithCredits={onBuyCourseChapterWithCredits}
onBuyCourseWithCredits={onBuyCourseWithCredits}
onBuyCourse={onBuyCourse}
purchasingCourseListingData={purchasingCourseListingData}
setPurchasingCourseListingData={setPurchasingCourseListingData}
onOpenAskAi={onOpenAskAi}
/>
);
}
if (tutorialLoadingError)
return (
<Paper square style={styles.paper} background="dark">
<PlaceholderError onRetry={fetchTutorials}>
<Trans>
Can't load the tutorials. Verify your internet connection or retry
later.
</Trans>
</PlaceholderError>
</Paper>
);
if (!tutorials) return <PlaceholderLoader />;
return !selectedCategory ? (
<MainPage
onTabChange={onTabChange}
onSelectCategory={onSelectCategory}
tutorials={tutorials}
selectInAppTutorial={selectInAppTutorial}
courses={courses}
onSelectCourse={onSelectCourse}
@@ -230,17 +150,36 @@ const LearnSection = ({
previewedCourseChapters={previewedCourseChapters}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
onOpenAskAi={onOpenAskAi}
onOpenNewProjectSetupDialog={onOpenNewProjectSetupDialog}
onSelectPrivateGameTemplateListingData={
onSelectPrivateGameTemplateListingData
}
onSelectExampleShortHeader={onSelectExampleShortHeader}
/>
) : selectedCategory === 'all-tutorials' ? (
<TutorialsPage onSelectCategory={onSelectCategory} />
) : selectedCategory === 'in-app-tutorials' ? (
<InAppTutorialsPage
onBack={() => onSelectCategory(null)}
selectInAppTutorial={selectInAppTutorial}
/>
) : selectedCategory === 'all-courses' ? (
<CoursesPage
onBack={() => onSelectCategory(null)}
courses={courses}
onSelectCourse={onSelectCourse}
previewedCourse={previewedCourse}
previewedCourseChapters={previewedCourseChapters}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
/>
) : (
<TutorialsCategoryPage
onBack={() => onSelectCategory(null)}
onBack={() => onSelectCategory('all-tutorials')}
category={selectedCategory}
tutorials={tutorials}
onOpenTemplateFromTutorial={onOpenTemplateFromTutorial}
onSelectCourse={(courseId: string) => {
onSelectCourse(courseId);
onSelectCategory('course');
}}
onSelectCourse={onSelectCourse}
/>
);
};

View File

@@ -7,10 +7,11 @@ import ArrowLeft from '../../../UI/CustomSvgIcons/ArrowLeft';
import TextButton from '../../../UI/TextButton';
import { Trans } from '@lingui/macro';
import Paper from '../../../UI/Paper';
import { LineStackLayout } from '../../../UI/Layout';
import { ColumnStackLayout, LineStackLayout } from '../../../UI/Layout';
import { AnnouncementsFeed } from '../../../AnnouncementsFeed';
import { AnnouncementsFeedContext } from '../../../AnnouncementsFeed/AnnouncementsFeedContext';
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
import Chip from '../../../UI/Chip';
export const SECTION_DESKTOP_SPACING = 20;
const SECTION_MOBILE_SPACING_TOP = 10;
@@ -53,13 +54,14 @@ const styles = {
minWidth: 0,
flex: 1,
},
chip: { height: 24 },
};
type Props = {|
children: React.Node,
chipText?: React.Node,
title?: React.Node,
titleAdornment?: React.Node,
titleAction?: React.Node,
subtitleText?: React.Node,
customPaperStyle?: Object,
renderSubtitle?: () => React.Node,
@@ -75,9 +77,9 @@ const SectionContainer = React.forwardRef<Props, HTMLDivElement>(
(
{
children,
chipText,
title,
titleAdornment,
titleAction,
subtitleText,
customPaperStyle,
renderSubtitle,
@@ -156,14 +158,20 @@ const SectionContainer = React.forwardRef<Props, HTMLDivElement>(
alignItems="center"
justifyContent="space-between"
>
<LineStackLayout noMargin alignItems="center">
<Text size="bold-title" noMargin style={styles.title}>
<ColumnStackLayout noMargin expand>
{chipText && (
<Line noMargin>
<Chip label={chipText} style={styles.chip} />
</Line>
)}
<Text size="title" noMargin style={styles.title}>
{title}
</Text>
{titleAction}
</LineStackLayout>
</ColumnStackLayout>
{titleAdornment && (
<Column noMargin>{titleAdornment}</Column>
<Column expand noMargin>
{titleAdornment}
</Column>
)}
</LineStackLayout>
)}

View File

@@ -2,6 +2,7 @@
import * as React from 'react';
import { Trans } from '@lingui/macro';
import { type I18n as I18nType } from '@lingui/core';
import {
listCourseChapters,
listCourses,
@@ -9,16 +10,19 @@ import {
type CourseChapter,
type UserCourseProgress,
} from '../../../Utils/GDevelopServices/Asset';
import { type CourseListingData } from '../../../Utils/GDevelopServices/Shop';
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
import {
fetchUserCourseProgress,
updateUserCourseProgress as doUpdateUserCourseProgress,
} from '../../../Utils/GDevelopServices/User';
import { useOptimisticState } from '../../../Utils/UseOptimisticState';
import CourseChapterStoreContext from '../../../Course/CourseChapterStoreContext';
import CourseStoreContext from '../../../Course/CourseStoreContext';
import { CreditsPackageStoreContext } from '../../../AssetStore/CreditsPackages/CreditsPackageStoreContext';
import { buyProductWithCredits } from '../../../Utils/GDevelopServices/Shop';
import PreferencesContext from '../../Preferences/PreferencesContext';
import { selectMessageByLocale } from '../../../Utils/i18n/MessageByLocale';
import { sendCourseBuyClicked } from '../../../Utils/Analytics/EventSender';
export type CourseChapterCompletion = {|
completedTasks: number,
@@ -36,8 +40,10 @@ const useCourses = () => {
profile,
limits,
subscription,
coursePurchases,
getAuthorizationHeader,
onOpenLoginDialog,
loginState,
} = React.useContext(AuthenticatedUserContext);
const {
values: { language },
@@ -45,7 +51,11 @@ const useCourses = () => {
const userLanguage2LetterCode = language.split('_')[0].toLowerCase();
const [courses, setCourses] = React.useState<?(Course[])>(null);
const { listedCourseChapters } = React.useContext(CourseChapterStoreContext);
const { listedCourses } = React.useContext(CourseStoreContext);
const [
purchasingCourseListingData,
setPurchasingCourseListingData,
] = React.useState<?CourseListingData>(null);
const { openCreditsPackageDialog, openCreditsUsageDialog } = React.useContext(
CreditsPackageStoreContext
);
@@ -83,10 +93,15 @@ const useCourses = () => {
|}>({});
const userId = profile ? profile.id : null;
const fetchCourses = React.useCallback(async () => {
const fetchedCourses = await listCourses();
setCourses(fetchedCourses);
}, []);
const fetchCourses = React.useCallback(
async () => {
const fetchedCourses = await listCourses(getAuthorizationHeader, {
userId,
});
setCourses(fetchedCourses);
},
[userId, getAuthorizationHeader]
);
const onSelectCourse = React.useCallback(
(courseId: string | null) => {
@@ -138,11 +153,7 @@ const useCourses = () => {
);
}
},
// A subscription change will change the displayed chapters sent by the backend.
// So the user subscription is added as a dependency to make sure the chapters are
// up to date with the user subscription.
// eslint-disable-next-line react-hooks/exhaustive-deps
[getAuthorizationHeader, userId, subscription, userLanguage2LetterCode]
[getAuthorizationHeader, userId, userLanguage2LetterCode]
);
const onCompleteTask = React.useCallback(
@@ -287,14 +298,9 @@ const useCourses = () => {
[userProgressByCourseId, chaptersByCourseId, courses]
);
const onBuyCourseChapterWithCredits = React.useCallback(
async (courseChapter: CourseChapter, password: string) => {
if (
!courseChapter.isLocked ||
!courseChapter.priceInCredits ||
!listedCourseChapters
)
return;
const onBuyCourse = React.useCallback(
async (course: Course, password: string, i18n: I18nType) => {
if (!course.isLocked || !listedCourses) return;
if (!userId || !limits) {
// User not logged in, suggest to log in.
@@ -302,19 +308,68 @@ const useCourses = () => {
return;
}
const currentCreditsAmount = limits.credits.userBalance.amount;
const listedCourseChapter = listedCourseChapters.find(
chapter => chapter.id === courseChapter.productId
const listedCourse = listedCourses.find(
listedCourse => listedCourse.id === course.id
);
if (!listedCourseChapter) {
console.error(
`Couldn't find course chapter with id ${
courseChapter.productId
} in Shop API.`
);
if (!listedCourse) {
console.error(`Couldn't find course with id ${course.id} in Shop API.`);
return;
}
const priceForUsageType = listedCourseChapter.creditPrices.find(
const priceForUsageType = listedCourse.prices.find(
price => price.usageType === 'default'
);
if (!priceForUsageType) {
console.error('Unable to find the price for the default usage type');
return;
}
try {
sendCourseBuyClicked({
courseId: course.id,
courseName: course.titleByLocale.en,
currency: priceForUsageType ? priceForUsageType.currency : undefined,
usageType: 'default',
});
setPurchasingCourseListingData(listedCourse);
} catch (e) {
console.warn('Unable to send event', e);
}
},
[
userId,
limits,
listedCourses,
onOpenLoginDialog,
setPurchasingCourseListingData,
]
);
const onBuyCourseWithCredits = React.useCallback(
async (course: Course, password: string, i18n: I18nType) => {
if (!course.isLocked || !listedCourses) return;
if (!userId || !limits) {
// User not logged in, suggest to log in.
onOpenLoginDialog();
return;
}
sendCourseBuyClicked({
courseId: course.id,
courseName: course.titleByLocale.en,
currency: 'CREDITS',
usageType: 'default',
});
const currentCreditsAmount = limits.credits.userBalance.amount;
const listedCourse = listedCourses.find(
listedCourse => listedCourse.id === course.id
);
if (!listedCourse) {
console.error(`Couldn't find course with id ${course.id} in Shop API.`);
return;
}
const priceForUsageType = listedCourse.creditPrices.find(
price => price.usageType === 'default'
);
if (!priceForUsageType) {
@@ -331,43 +386,48 @@ const useCourses = () => {
return;
}
const translatedCourseTitle = selectMessageByLocale(
i18n,
course.titleByLocale
);
openCreditsUsageDialog({
title: <Trans>Purchase {courseChapter.title}</Trans>,
title: <Trans>Purchase {translatedCourseTitle}</Trans>,
message: (
<Trans>
You are about to use {creditsAmount} credits to purchase the chapter
"{courseChapter.title}". Continue?
You are about to use {creditsAmount} credits to purchase the course
"{translatedCourseTitle}". Continue?
</Trans>
),
onConfirm: async () => {
await buyProductWithCredits(getAuthorizationHeader, {
productId: listedCourseChapter.id,
productId: listedCourse.id,
usageType: 'default',
userId,
password,
});
if (selectedCourseId) await fetchCourseChapters(selectedCourseId);
await Promise.all([fetchCourses(), fetchCourseChapters(course.id)]);
},
successMessage: <Trans>🎉 You can now follow your new chapter!</Trans>,
successMessage: <Trans>🎉 You can now follow your new course!</Trans>,
});
},
[
userId,
limits,
listedCourseChapters,
listedCourses,
openCreditsPackageDialog,
openCreditsUsageDialog,
getAuthorizationHeader,
onOpenLoginDialog,
fetchCourses,
fetchCourseChapters,
selectedCourseId,
]
);
React.useEffect(
() => {
(async () => {
if (courses) {
if (courses && loginState !== 'loggingIn') {
await Promise.all(
courses.map(course => fetchCourseChapters(course.id))
);
@@ -375,15 +435,32 @@ const useCourses = () => {
}
})();
},
// (Re)fetch course chapters when courses are defined and when fetchCourseChapters
// changes (see its dependencies).
[courses, fetchCourseChapters]
// (Re)fetch course chapters when courses are refetched.
[courses, fetchCourseChapters, loginState]
);
const selectedCourse =
selectedCourseId && courses && areChaptersReady
? courses.find(course => course.id === selectedCourseId) || null
: null;
React.useEffect(
() => {
(async () => {
if (subscription || coursePurchases) {
// Just to trigger a re-fetch of the courses when the user subscription changes,
// or when the user purchases a course.
}
if (loginState !== 'loggingIn') {
await fetchCourses();
}
})();
},
[fetchCourses, subscription, coursePurchases, loginState]
);
const selectedCourse = React.useMemo(
() => {
if (!selectedCourseId || !courses || !areChaptersReady) return null;
return courses.find(course => course.id === selectedCourseId) || null;
},
[selectedCourseId, courses, areChaptersReady]
);
return {
courses,
@@ -396,7 +473,10 @@ const useCourses = () => {
isTaskCompleted,
getChapterCompletion,
getCourseCompletion,
onBuyCourseChapterWithCredits,
onBuyCourseWithCredits,
onBuyCourse,
purchasingCourseListingData,
setPurchasingCourseListingData,
};
};

View File

@@ -11,12 +11,11 @@ import {
type FileMetadata,
type StorageProvider,
} from '../../../ProjectsStorage';
import GetStartedSection from './GetStartedSection';
import LearnSection from './LearnSection';
import { type LearnCategory } from './LearnSection/Utils';
import PlaySection from './PlaySection';
import CreateSection from './CreateSection';
import StoreSection from './StoreSection';
import { type TutorialCategory } from '../../../Utils/GDevelopServices/Tutorial';
import { TutorialContext } from '../../../Tutorial/TutorialContext';
import { ExampleStoreContext } from '../../../AssetStore/ExampleStore/ExampleStoreContext';
import { HomePageHeader } from './HomePageHeader';
@@ -30,12 +29,6 @@ import TeamProvider from '../../../Profile/Team/TeamProvider';
import { useResponsiveWindowSize } from '../../../UI/Responsive/ResponsiveWindowMeasurer';
import { type PrivateGameTemplateListingData } from '../../../Utils/GDevelopServices/Shop';
import { PrivateGameTemplateStoreContext } from '../../../AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext';
import PreferencesContext from '../../Preferences/PreferencesContext';
import { incrementGetStartedSectionViewCount } from '../../../Utils/Analytics/LocalStats';
import {
sendUserSurveyHidden,
sendUserSurveyStarted,
} from '../../../Utils/Analytics/EventSender';
import RouterContext, { type RouteArguments } from '../../RouterContext';
import { type GameDetailsTab } from '../../../GameDashboard';
import { canUseClassroomFeature } from '../../../Utils/GDevelopServices/Usage';
@@ -47,6 +40,7 @@ import { type GamesList } from '../../../GameDashboard/UseGamesList';
import { type GamesPlatformFrameTools } from './PlaySection/UseGamesPlatformFrame';
import { type CourseChapter } from '../../../Utils/GDevelopServices/Asset';
import useCourses from './UseCourses';
import PreferencesContext from '../../Preferences/PreferencesContext';
const getRequestedTab = (routeArguments: RouteArguments): HomeTab | null => {
if (
@@ -68,8 +62,6 @@ const getRequestedTab = (routeArguments: RouteArguments): HomeTab | null => {
return 'play';
} else if (routeArguments['initial-dialog'] === 'learn') {
return 'learn';
} else if (routeArguments['initial-dialog'] === 'get-started') {
return 'get-started';
}
return null;
@@ -140,6 +132,7 @@ type Props = {|
selectInAppTutorial: (tutorialId: string) => void,
onOpenPreferences: () => void,
onOpenAbout: () => void,
onOpenAskAi: (mode?: 'chat' | 'agent') => void,
// Project creation
onOpenNewProjectSetupDialog: () => void,
@@ -202,6 +195,7 @@ export const HomePage = React.memo<Props>(
selectInAppTutorial,
onOpenPreferences,
onOpenAbout,
onOpenAskAi,
isActive,
storageProviders,
onSave,
@@ -228,8 +222,6 @@ export const HomePage = React.memo<Props>(
startTimeoutToUnloadIframe,
loadIframeOrRemoveTimeout,
} = gamesPlatformFrameTools;
const userSurveyStartedRef = React.useRef<boolean>(false);
const userSurveyHiddenRef = React.useRef<boolean>(false);
const { fetchTutorials } = React.useContext(TutorialContext);
const { fetchExamplesAndFilters } = React.useContext(ExampleStoreContext);
const {
@@ -263,31 +255,32 @@ export const HomePage = React.memo<Props>(
selectedCourse,
courseChaptersByCourseId,
onSelectCourse,
fetchCourses,
areChaptersReady,
onCompleteTask,
isTaskCompleted,
getChapterCompletion,
getCourseCompletion,
onBuyCourseChapterWithCredits,
onBuyCourseWithCredits,
onBuyCourse,
purchasingCourseListingData,
setPurchasingCourseListingData,
} = useCourses();
const [
learnCategory,
setLearnCategory,
] = React.useState<TutorialCategory | null>(null);
const [learnCategory, setLearnCategory] = React.useState<LearnCategory>(
null
);
const { isMobile } = useResponsiveWindowSize();
const {
values: { showGetStartedSectionByDefault },
values: { showCreateSectionByDefault },
} = React.useContext(PreferencesContext);
const tabRequestedAtOpening = React.useRef<HomeTab | null>(
getRequestedTab(routeArguments)
);
const initialTab = tabRequestedAtOpening.current
? tabRequestedAtOpening.current
: showGetStartedSectionByDefault
? 'get-started'
: 'create';
: showCreateSectionByDefault
? 'create'
: 'learn';
const [activeTab, setActiveTab] = React.useState<HomeTab>(initialTab);
@@ -344,7 +337,6 @@ export const HomePage = React.memo<Props>(
return;
}
onSelectCourse(courseId);
setLearnCategory('course');
removeRouteArguments(['course-id']);
}
@@ -361,15 +353,6 @@ export const HomePage = React.memo<Props>(
]
);
React.useEffect(
() => {
if (initialTab === 'get-started') {
incrementGetStartedSectionViewCount();
}
},
[initialTab]
);
// Load everything when the user opens the home page, to avoid future loading times.
React.useEffect(
() => {
@@ -380,28 +363,6 @@ export const HomePage = React.memo<Props>(
[fetchExamplesAndFilters, fetchTutorials, fetchGameTemplates]
);
// Only fetch games if the user decides to open the games dashboard tab
// or the build tab to enable the context menu on project list items that
// redirects to the games dashboard.
React.useEffect(
() => {
if (activeTab === 'create' && !games) {
fetchGames();
}
},
[fetchGames, activeTab, games]
);
// Only fetch courses if the user decides to open the Learn section.
React.useEffect(
() => {
if (activeTab === 'learn' && !courses) {
fetchCourses();
}
},
[fetchCourses, activeTab, courses]
);
// Fetch user cloud projects when home page becomes active
React.useEffect(
() => {
@@ -510,28 +471,6 @@ export const HomePage = React.memo<Props>(
onSceneEventsModifiedOutsideEditor,
}));
const onUserSurveyStarted = React.useCallback(() => {
if (userSurveyStartedRef.current) return;
sendUserSurveyStarted();
userSurveyStartedRef.current = true;
}, []);
const onUserSurveyHidden = React.useCallback(() => {
if (userSurveyHiddenRef.current) return;
sendUserSurveyHidden();
userSurveyHiddenRef.current = true;
}, []);
React.useEffect(
() => {
if (!authenticated) {
userSurveyStartedRef.current = false;
userSurveyHiddenRef.current = false;
}
},
// Reset flag that prevents multiple send of the same event on user change.
[authenticated]
);
// As the homepage is never unmounted, we need to ensure the games platform
// iframe is unloaded & loaded from here,
// allowing to handle when the user navigates to another tab.
@@ -601,16 +540,6 @@ export const HomePage = React.memo<Props>(
canSaveProject={canSave}
/>
)}
{activeTab === 'get-started' && (
<GetStartedSection
selectInAppTutorial={selectInAppTutorial}
onUserSurveyStarted={onUserSurveyStarted}
onUserSurveyHidden={onUserSurveyHidden}
onOpenProfile={onOpenProfile}
onCreateProjectFromExample={onCreateProjectFromExample}
askToCloseProject={askToCloseProject}
/>
)}
{activeTab === 'learn' && (
<LearnSection
onTabChange={setActiveTab}
@@ -639,9 +568,18 @@ export const HomePage = React.memo<Props>(
isCourseTaskCompleted={isTaskCompleted}
getCourseChapterCompletion={getChapterCompletion}
getCourseCompletion={getCourseCompletion}
onBuyCourseChapterWithCredits={
onBuyCourseChapterWithCredits
onBuyCourseWithCredits={onBuyCourseWithCredits}
onBuyCourse={onBuyCourse}
purchasingCourseListingData={purchasingCourseListingData}
setPurchasingCourseListingData={
setPurchasingCourseListingData
}
onOpenAskAi={onOpenAskAi}
onOpenNewProjectSetupDialog={onOpenNewProjectSetupDialog}
onSelectPrivateGameTemplateListingData={
onSelectPrivateGameTemplateListingData
}
onSelectExampleShortHeader={onSelectExampleShortHeader}
/>
)}
{activeTab === 'play' && (
@@ -735,6 +673,7 @@ export const renderHomePageContainer = (
selectInAppTutorial={props.selectInAppTutorial}
onOpenPreferences={props.onOpenPreferences}
onOpenAbout={props.onOpenAbout}
onOpenAskAi={props.onOpenAskAi}
storageProviders={
(props.extraEditorProps && props.extraEditorProps.storageProviders) || []
}

View File

@@ -23,6 +23,7 @@ type Props = {|
name: string,
dontFocusTab?: boolean,
project?: ?gdProject,
mode?: 'chat' | 'agent',
|}) => EditorOpeningOptions,
|};

View File

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

View File

@@ -216,7 +216,7 @@ export type PreferencesValues = {|
resourcesImporationBehavior: ResourceImportationBehavior,
eventsSheetCancelInlineParameter: 'cancel' | 'apply',
showCommunityExtensions: boolean,
showGetStartedSectionByDefault: boolean,
showCreateSectionByDefault: boolean,
showInAppTutorialDeveloperMode: boolean,
showDeprecatedInstructionWarning: boolean,
openDiagnosticReportAutomatically: boolean,
@@ -302,7 +302,7 @@ export type Preferences = {|
setIsAlwaysOnTopInPreview: (enabled: boolean) => void,
setEventsSheetCancelInlineParameter: (value: string) => void,
setShowCommunityExtensions: (enabled: boolean) => void,
setShowGetStartedSectionByDefault: (enabled: boolean) => void,
setShowCreateSectionByDefault: (enabled: boolean) => void,
setShowInAppTutorialDeveloperMode: (enabled: boolean) => void,
setOpenDiagnosticReportAutomatically: (enabled: boolean) => void,
getOpenDiagnosticReportAutomatically: () => boolean,
@@ -381,7 +381,7 @@ export const initialPreferences = {
resourcesImporationBehavior: 'ask',
eventsSheetCancelInlineParameter: 'apply',
showCommunityExtensions: false,
showGetStartedSectionByDefault: true,
showCreateSectionByDefault: false,
showInAppTutorialDeveloperMode: false,
openDiagnosticReportAutomatically: true,
showDeprecatedInstructionWarning: false,
@@ -449,7 +449,7 @@ export const initialPreferences = {
setIsAlwaysOnTopInPreview: () => {},
setEventsSheetCancelInlineParameter: () => {},
setShowCommunityExtensions: () => {},
setShowGetStartedSectionByDefault: (enabled: boolean) => {},
setShowCreateSectionByDefault: (enabled: boolean) => {},
setShowInAppTutorialDeveloperMode: (enabled: boolean) => {},
setShowDeprecatedInstructionWarning: (enabled: boolean) => {},
getOpenDiagnosticReportAutomatically: () => true,

View File

@@ -83,6 +83,7 @@ const PreferencesDialog = ({
setPreviewCrashReportUploadLevel,
setTakeScreenshotOnPreview,
setShowAiAskButtonInTitleBar,
setShowCreateSectionByDefault,
} = React.useContext(PreferencesContext);
const initialUse3DEditor = React.useRef<boolean>(values.use3DEditor);
@@ -392,6 +393,27 @@ const PreferencesDialog = ({
<Trans>Advanced</Trans>
</Text>
<ColumnStackLayout>
<Text size="sub-title">
<Trans>At launch</Trans>
</Text>
<CompactToggleField
labelColor="primary"
hideTooltip
onCheck={setShowCreateSectionByDefault}
checked={values.showCreateSectionByDefault}
label={i18n._(
t`Show the "Create" section by default when opening GDevelop`
)}
/>
<CompactToggleField
labelColor="primary"
hideTooltip
onCheck={setAutoOpenMostRecentProject}
checked={values.autoOpenMostRecentProject}
label={i18n._(
t`Automatically re-open the project edited during last session`
)}
/>
<Text size="sub-title">
<Trans>Previews</Trans>
</Text>
@@ -531,15 +553,6 @@ const PreferencesDialog = ({
t`Display save reminder after significant changes in project`
)}
/>
<CompactToggleField
labelColor="primary"
hideTooltip
onCheck={setAutoOpenMostRecentProject}
checked={values.autoOpenMostRecentProject}
label={i18n._(
t`Automatically re-open the project edited during last session`
)}
/>
<CompactToggleField
labelColor="primary"
hideTooltip

View File

@@ -149,7 +149,7 @@ export default class PreferencesProvider extends React.Component<Props, State> {
this
),
setShowCommunityExtensions: this._setShowCommunityExtensions.bind(this),
setShowGetStartedSectionByDefault: this._setShowGetStartedSection.bind(
setShowCreateSectionByDefault: this._setShowCreateSectionByDefault.bind(
this
),
setShowInAppTutorialDeveloperMode: this._setShowInAppTutorialDeveloperMode.bind(
@@ -353,12 +353,12 @@ export default class PreferencesProvider extends React.Component<Props, State> {
);
}
_setShowGetStartedSection(showGetStartedSectionByDefault: boolean) {
_setShowCreateSectionByDefault(showCreateSectionByDefault: boolean) {
this.setState(
state => ({
values: {
...state.values,
showGetStartedSectionByDefault,
showCreateSectionByDefault,
},
}),
() => this._persistValuesToLocalStorage(this.state)

View File

@@ -40,7 +40,7 @@ import { Trans } from '@lingui/macro';
import { CreditsPackageStoreStateProvider } from '../AssetStore/CreditsPackages/CreditsPackageStoreContext';
import { ProductLicenseStoreStateProvider } from '../AssetStore/ProductLicense/ProductLicenseStoreContext';
import { MarketingPlansStoreStateProvider } from '../MarketingPlans/MarketingPlansStoreContext';
import { CourseChapterStoreStateProvider } from '../Course/CourseChapterStoreContext';
import { CourseStoreStateProvider } from '../Course/CourseStoreContext';
import { Resource3DPreviewProvider } from '../ResourcesList/ResourcePreview/Resource3DPreviewContext';
type Props = {|
@@ -108,7 +108,7 @@ const Providers = ({
<ExampleStoreStateProvider>
<PrivateGameTemplateStoreStateProvider>
<CreditsPackageStoreStateProvider>
<CourseChapterStoreStateProvider>
<CourseStoreStateProvider>
<ProductLicenseStoreStateProvider>
<MarketingPlansStoreStateProvider>
<ExtensionStoreStateProvider
@@ -134,7 +134,7 @@ const Providers = ({
</ExtensionStoreStateProvider>
</MarketingPlansStoreStateProvider>
</ProductLicenseStoreStateProvider>
</CourseChapterStoreStateProvider>
</CourseStoreStateProvider>
</CreditsPackageStoreStateProvider>
</PrivateGameTemplateStoreStateProvider>
</ExampleStoreStateProvider>

View File

@@ -12,8 +12,8 @@ export type Route =
| 'build' // Old way of opening the build section
| 'create' // New way of opening the build section
| 'education'
| 'play'
| 'get-started';
| 'learn'
| 'play';
type RouteKey =
| 'initial-dialog'
| 'game-id'

View File

@@ -53,7 +53,7 @@ type TabsTitlebarProps = {|
onEditorTabClosing: () => void
) => React.Node,
hasAskAiOpened: boolean,
onOpenAskAi: () => void,
onOpenAskAi: (mode?: 'chat' | 'agent') => void,
|};
const useIsAskAiIconAnimated = (shouldDisplayAskAi: boolean) => {
@@ -227,7 +227,7 @@ export default function TabsTitlebar({
<TextButton
icon={<RobotIcon size={16} rotating={isAskAiIconAnimated} />}
label={'Ask AI'}
onClick={onOpenAskAi}
onClick={() => onOpenAskAi('agent')}
/>
</div>
) : null}

View File

@@ -1,7 +1,18 @@
// @flow
import * as React from 'react';
import RouterContext from './RouterContext';
import RouterContext, { type Route } from './RouterContext';
const homePageRoutes: Route[] = [
'play',
'learn',
'build',
'create',
'games-dashboard',
'asset-store',
'store',
'education',
];
/**
* This hook is used to be able to use route arguments from anywhere to open the homepage.
@@ -22,18 +33,7 @@ const useHomepageWitchForRouting = ({
const initialDialog = routeArguments['initial-dialog'];
if (!initialDialog) return;
if (
[
'games-dashboard',
'asset-store',
'store',
'build',
'education',
'play',
'community',
'get-started',
].includes(initialDialog)
) {
if (homePageRoutes.includes(initialDialog)) {
closeDialogs();
openHomePage();
}

View File

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

View File

@@ -583,11 +583,13 @@ const MainFrame = (props: Props) => {
name,
dontFocusTab,
project,
mode,
}: {
kind: EditorKind,
name: string,
dontFocusTab?: boolean,
project?: ?gdProject,
mode?: 'chat' | 'agent',
}) => {
const label =
kind === 'resources'
@@ -660,6 +662,8 @@ const MainFrame = (props: Props) => {
const extraEditorProps =
kind === 'start page'
? { storageProviders: props.storageProviders }
: kind === 'ask-ai'
? { mode }
: undefined;
return {
icon,
@@ -1229,11 +1233,11 @@ const MainFrame = (props: Props) => {
});
const openAskAi = React.useCallback(
() => {
(mode?: 'chat' | 'agent') => {
setState(state => {
const askAiEditor = getOpenedAskAiEditor(state.editorTabs);
if (askAiEditor) {
askAiEditor.startNewChat();
askAiEditor.startNewChat(mode);
}
// Open or focus the AI editor.
@@ -1241,7 +1245,7 @@ const MainFrame = (props: Props) => {
...state,
editorTabs: openEditorTab(
state.editorTabs,
getEditorOpeningOptions({ kind: 'ask-ai', name: '' })
getEditorOpeningOptions({ kind: 'ask-ai', name: '', mode })
),
};
});
@@ -4016,6 +4020,7 @@ const MainFrame = (props: Props) => {
hideTabsTitleBarAndEditorToolbar: setTabsTitleBarAndEditorToolbarHidden,
projectItemName: editorTab.projectItemName,
setPreviewedLayout,
onOpenAskAi: openAskAi,
onOpenExternalEvents: openExternalEvents,
onOpenEvents: (sceneName: string) => {
openLayout(sceneName, {

View File

@@ -42,6 +42,7 @@ export type AuthenticatedUser = {|
receivedGameTemplates: ?Array<PrivateGameTemplate>,
gameTemplatePurchases: ?Array<Purchase>,
assetPackPurchases: ?Array<Purchase>,
coursePurchases: ?Array<Purchase>,
recommendations: ?Array<Recommendation>,
notifications: ?Array<Notification>,
userEarningsBalance: ?UserEarningsBalance,
@@ -74,6 +75,7 @@ export type AuthenticatedUser = {|
onRefreshLimits: () => Promise<void>,
onRefreshGameTemplatePurchases: () => Promise<void>,
onRefreshAssetPackPurchases: () => Promise<void>,
onRefreshCoursePurchases: () => Promise<void>,
onRefreshEarningsBalance: () => Promise<void>,
onRefreshNotifications: () => Promise<void>,
onPurchaseSuccessful: () => Promise<void>,
@@ -105,6 +107,7 @@ export const initialAuthenticatedUser = {
cloudProjectsFetchingErrorLabel: null,
gameTemplatePurchases: null,
assetPackPurchases: null,
coursePurchases: null,
recommendations: null,
subscription: null,
usages: null,
@@ -131,6 +134,7 @@ export const initialAuthenticatedUser = {
onRefreshLimits: async () => {},
onRefreshGameTemplatePurchases: async () => {},
onRefreshAssetPackPurchases: async () => {},
onRefreshCoursePurchases: async () => {},
onRefreshEarningsBalance: async () => {},
onRefreshNotifications: async () => {},
onPurchaseSuccessful: async () => {},

View File

@@ -219,6 +219,7 @@ export default class AuthenticatedUserProvider extends React.Component<
onRefreshLimits: this._fetchUserLimits,
onRefreshGameTemplatePurchases: this._fetchUserGameTemplatePurchases,
onRefreshAssetPackPurchases: this._fetchUserAssetPackPurchases,
onRefreshCoursePurchases: this._fetchUserCoursePurchases,
onRefreshEarningsBalance: this._fetchEarningsBalance,
onRefreshNotifications: this._fetchUserNotifications,
onPurchaseSuccessful: this._fetchUserProducts,
@@ -560,6 +561,22 @@ export default class AuthenticatedUserProvider extends React.Component<
console.error('Error while loading asset pack purchases:', error);
}
);
listUserPurchases(authentication.getAuthorizationHeader, {
userId: firebaseUser.uid,
productType: 'course',
role: 'receiver',
}).then(
coursePurchases =>
this.setState(({ authenticatedUser }) => ({
authenticatedUser: {
...authenticatedUser,
coursePurchases,
},
})),
error => {
console.error('Error while loading course purchases:', error);
}
);
this._fetchUserBadges();
this._fetchAchievements();
this._fetchUserNotifications();
@@ -849,6 +866,32 @@ export default class AuthenticatedUserProvider extends React.Component<
}
};
_fetchUserCoursePurchases = async () => {
const { authentication } = this.props;
const firebaseUser = this.state.authenticatedUser.firebaseUser;
if (!firebaseUser) return;
try {
const coursePurchases = await listUserPurchases(
authentication.getAuthorizationHeader,
{
userId: firebaseUser.uid,
productType: 'course',
role: 'receiver',
}
);
this.setState(({ authenticatedUser }) => ({
authenticatedUser: {
...authenticatedUser,
coursePurchases,
},
}));
} catch (error) {
console.error('Error while loading course purchases:', error);
}
};
_fetchUserProducts = async () => {
await Promise.all([
this._fetchUserAssetPacks(),

View File

@@ -17,13 +17,13 @@ import { shouldValidate } from '../UI/KeyboardShortcuts/InteractionKeys';
import classes from './EmptyAndStartingPointProjects.module.css';
import { getItemsColumns } from './NewProjectSetupDialog';
const ITEMS_SPACING = 5;
const getStyles = (theme: GDevelopTheme) => ({
grid: {
margin: 0,
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',
},
cellSpacing: 2,
});
type EmptyProjectTileProps = {|
@@ -130,7 +130,7 @@ const EmptyAndStartingPointProjects = ({
cols={columnsCount}
style={styles.grid}
cellHeight="auto"
spacing={styles.cellSpacing}
spacing={ITEMS_SPACING * 2}
>
<EmptyProjectTile
onSelectEmptyProject={onSelectEmptyProject}

View File

@@ -46,10 +46,7 @@ import ExampleInformationPage from '../AssetStore/ExampleStore/ExampleInformatio
import PrivateGameTemplateInformationPage from '../AssetStore/PrivateGameTemplates/PrivateGameTemplateInformationPage';
import ExampleStore from '../AssetStore/ExampleStore';
import Text from '../UI/Text';
import {
useResponsiveWindowSize,
type WindowSizeType,
} from '../UI/Responsive/ResponsiveWindowMeasurer';
import { type WindowSizeType } from '../UI/Responsive/ResponsiveWindowMeasurer';
import { PrivateGameTemplateStoreContext } from '../AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext';
import { getUserProductPurchaseUsageType } from '../AssetStore/ProductPageHelper';
import { useOnlineStatus } from '../Utils/OnlineStatus';
@@ -111,7 +108,7 @@ type Props = {|
newProjectSetup: NewProjectSetup,
i18n: I18nType
) => Promise<void>,
onOpenAskAi: () => void,
onOpenAskAi: (mode?: 'chat' | 'agent') => void,
selectedExampleShortHeader: ?ExampleShortHeader,
onSelectExampleShortHeader: (exampleShortHeader: ?ExampleShortHeader) => void,
selectedPrivateGameTemplateListingData: ?PrivateGameTemplateListingData,
@@ -139,7 +136,6 @@ const NewProjectSetupDialog = ({
preventBackHome,
}: Props): React.Node => {
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const { windowSize, isLandscape } = useResponsiveWindowSize();
const {
authenticated,
onOpenLoginDialog,
@@ -608,9 +604,9 @@ const NewProjectSetupDialog = ({
size="large"
color="success"
label={<Trans>Try the AI agent</Trans>}
icon={<ArrowRight />}
rightIcon={<ArrowRight />}
style={styles.tryAIAgentButton}
onClick={onOpenAskAi}
onClick={() => onOpenAskAi('agent')}
/>
</ResponsiveLineStackLayout>
{isOnline ? (
@@ -628,7 +624,7 @@ const NewProjectSetupDialog = ({
);
}}
i18n={i18n}
columnsCount={getItemsColumns(windowSize, isLandscape)}
getColumnsFromWindowSize={getItemsColumns}
hideStartingPoints
/>
</>

View File

@@ -8,15 +8,7 @@ import { type ExampleShortHeader } from '../Utils/GDevelopServices/Example';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import { type QuickCustomizationRecommendation } from '../Utils/GDevelopServices/User';
import { selectMessageByLocale } from '../Utils/i18n/MessageByLocale';
const styles = {
grid: {
margin: 0,
// Remove the scroll capability of the grid, the scroll view handles it.
overflow: 'unset',
},
cellSpacing: 2,
};
import { LARGE_WIDGET_SIZE } from '../MainFrame/EditorContainers/HomePage/CardWidget';
const getColumnsCount = (windowSize: string, isLandscape: boolean) => {
if (windowSize === 'small') {
@@ -30,6 +22,19 @@ const getColumnsCount = (windowSize: string, isLandscape: boolean) => {
}
};
const MAX_COLUMNS = getColumnsCount('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const ITEMS_SPACING = 5;
const styles = {
grid: {
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
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',
},
};
type Props = {|
onSelectExampleShortHeader: (
exampleShortHeader: ExampleShortHeader
@@ -79,7 +84,7 @@ export const QuickCustomizationGameTiles = ({
cols={columnsCount}
style={styles.grid}
cellHeight="auto"
spacing={styles.cellSpacing}
spacing={ITEMS_SPACING * 2}
>
{displayedExampleShortHeaders
? displayedExampleShortHeaders.map(

View File

@@ -1,615 +1,227 @@
// @flow
import * as React from 'react';
import { makeStyles, createStyles } from '@material-ui/styles';
import GridList from '@material-ui/core/GridList';
import GridListTile from '@material-ui/core/GridListTile';
import Skeleton from '@material-ui/lab/Skeleton';
import Window from '../Utils/Window';
import Text from './Text';
import { Line, Spacer } from './Grid';
import { useResponsiveWindowSize } from './Responsive/ResponsiveWindowMeasurer';
import FlatButton from './FlatButton';
import { shouldValidate } from './KeyboardShortcuts/InteractionKeys';
import AlertMessage from './AlertMessage';
import { Trans } from '@lingui/macro';
import { CorsAwareImage } from './CorsAwareImage';
import { useIsMounted } from '../Utils/UseIsMounted';
import useForceUpdate from '../Utils/UseForceUpdate';
import {
useResponsiveWindowSize,
type WindowSizeType,
} from './Responsive/ResponsiveWindowMeasurer';
import ChevronArrowLeft from './CustomSvgIcons/ChevronArrowLeft';
import ChevronArrowRight from './CustomSvgIcons/ChevronArrowRight';
import GDevelopThemeContext from './Theme/GDevelopThemeContext';
import { ColumnStackLayout } from './Layout';
import { LARGE_WIDGET_SIZE } from '../MainFrame/EditorContainers/HomePage/CardWidget';
import { getColumnsFromWindowSize } from '../MainFrame/EditorContainers/HomePage/LearnSection/Utils';
import classes from './Carousel.module.css';
type OverlayTextPosition =
| 'topLeft'
| 'topRight'
| 'bottomLeft'
| 'bottomRight';
export type CarouselThumbnail = {
id: string,
title: string,
thumbnailUrl: string,
overlayText?: React.Node,
overlayTextPosition?: OverlayTextPosition,
+link?: string,
+onClick?: () => void,
export type CarouselItem = {
renderItem: () => React.Node,
};
type SkeletonThumbnail = {
...CarouselThumbnail,
skeleton: boolean,
};
type Props<ThumbnailType> = {|
title: React.Node,
items: ?Array<ThumbnailType>,
additionalAction?: React.Node,
onBrowseAllClick?: () => void,
browseAllLink?: string,
browseAllLabel: React.Node,
browseAllIcon: React.Node,
displayItemTitles?: boolean,
error?: React.Node,
roundedImages?: boolean,
displayArrowsOnDesktop?: boolean,
type Props = {|
items: Array<CarouselItem>,
|};
const referenceSizesByWindowSize = {
imageHeight: {
small: 80,
medium: 130,
large: 150,
xlarge: 170,
},
export const getCarouselColumnsFromWindowSize = (
windowSize: WindowSizeType,
isLandscape: boolean
) => {
switch (windowSize) {
case 'small':
return isLandscape ? 2 : 1;
case 'medium':
return 2;
case 'large':
return 4;
case 'xlarge':
return 6;
default:
return 3;
}
};
const cellSpacing = 12;
const titleHeight = 24;
const spacerSize = 4;
const focusItemBorderWidth = 2;
const skeletonNumber = 6;
const randomNumbers = Array(skeletonNumber)
.fill(0)
.map(e => Math.random());
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const ITEMS_SPACING = 5;
const styles = {
itemTitle: {
textOverflow: 'ellipsis',
overflow: 'hidden',
overflowWrap: 'break-word',
whiteSpace: 'nowrap',
},
gridList: { position: 'relative' },
image: {
display: 'block',
objectFit: 'cover',
border: '1px solid lightgrey',
boxSizing: 'border-box', // Take border in account for sizing to avoid cumulative layout shift.
// Prevent cumulative layout shift by enforcing
// the 16:9 ratio.
aspectRatio: '16 / 9',
transition: 'opacity 0.3s ease-in-out',
},
error: { display: 'flex', justifyContent: 'center', alignItems: 'center' },
itemTitleContainer: { height: titleHeight },
arrowContainer: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
borderRadius: 4,
},
container: { display: 'flex', position: 'relative' },
overlay: {
position: 'absolute',
borderRadius: 4,
padding: '2px 4px',
backdropFilter: 'brightness(40%)',
color: 'white', // Same color for all themes.
},
};
const useStylesForArrowButtons = () =>
makeStyles(theme =>
createStyles({
root: {
'&:hover': {
filter:
theme.palette.type === 'dark'
? 'brightness(130%)'
: 'brightness(90%)',
},
transition: 'filter 100ms ease',
},
})
)();
const useStylesForGridList = makeStyles({
root: {
gridList: {
textAlign: 'center',
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
width: `calc(100% + ${2 * ITEMS_SPACING}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
overflowX: 'scroll',
overflowY: 'hidden',
flexWrap: 'nowrap',
scrollbarWidth: 'none' /* For modern browsers */,
'-ms-overflow-style': 'none' /* For Internet Explorer and Edge */,
msOverflowStyle: 'none' /* For Internet Explorer and Edge */,
'&::-webkit-scrollbar': {
height: 0 /* For old hrome, Safari, and Opera */,
},
},
});
container: { display: 'flex', position: 'relative', width: '100%' },
arrowsContainer: {
width: `calc(100% + ${2 * ITEMS_SPACING}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
left: -ITEMS_SPACING,
justifyContent: 'space-between',
position: 'absolute',
top: 0,
bottom: 0,
pointerEvents: 'none',
padding: 0,
maxWidth: MAX_SECTION_WIDTH,
zIndex: 12, // Above text.
transition: 'opacity 0.2s ease-in-out',
},
arrowContainer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: 50,
cursor: 'pointer',
transition: 'all 0.2s ease-in-out',
},
arrow: {
fontSize: 50,
cursor: 'pointer',
},
};
const useStylesForGridListItem = makeStyles(theme =>
createStyles({
root: {
width: 'unset !important',
transition: 'transform 0.3s ease-in-out',
'&:hover': {
transform: 'scale(1.02)',
},
'&:focus': {
transform: 'scale(1.02)',
outline: 'none',
},
},
tile: {
display: 'flex',
flexDirection: 'column',
},
})
);
const getAdditionalColumnToDisplayFromNumberOfColumns = (
numberOfColumns: number
) => (numberOfColumns < 2 ? 0.6 : numberOfColumns < 4 ? 0.9 : 0.25);
const Carousel = ({ items }: Props) => {
const [canScrollLeft, setCanScrollLeft] = React.useState<boolean>(false);
const [canScrollRight, setCanScrollRight] = React.useState<boolean>(false);
const [isHoveringContainer, setIsHoveringContainer] = React.useState<boolean>(
false
);
const { windowSize, isMobile, isLandscape } = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const scrollView = React.useRef<?HTMLUListElement>(null);
const initialColumnsToDisplay = React.useMemo(
() => getCarouselColumnsFromWindowSize(windowSize, isLandscape),
[windowSize, isLandscape]
);
const actualColumnsToDisplay = React.useMemo(
// Show a bit more to see the next item partially.
() =>
initialColumnsToDisplay +
(items.length > initialColumnsToDisplay
? getAdditionalColumnToDisplayFromNumberOfColumns(
initialColumnsToDisplay
)
: 0),
[items.length, initialColumnsToDisplay]
);
const checkScrollability = React.useCallback(() => {
if (!scrollView.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollView.current;
setCanScrollLeft(scrollLeft > 0);
setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 10); // Small buffer for rounding errors
}, []);
React.useEffect(
() => {
const element = scrollView.current;
if (!element) {
return;
}
checkScrollability();
element.addEventListener('scroll', checkScrollability);
// Create a ref to the resize handler to be used in cleanup
const handleResize = () => {
checkScrollability();
};
window.addEventListener('resize', handleResize);
return () => {
if (element) {
element.removeEventListener('scroll', checkScrollability);
window.removeEventListener('resize', handleResize);
}
};
},
[checkScrollability]
);
const onScroll = React.useCallback(
(direction: 'left' | 'right') => {
const scrollViewRef = scrollView.current;
if (!scrollViewRef) return;
// Calculate actual item width based on visible items
const firstItem = scrollViewRef.children[0];
const itemWidth = firstItem.offsetWidth;
// Calculate scroll to show full items
const scrollAmount = initialColumnsToDisplay * itemWidth;
const currentScroll = scrollViewRef.scrollLeft;
const newScroll =
direction === 'left'
? currentScroll - scrollAmount
: currentScroll + scrollAmount;
scrollViewRef.scrollTo({
left: newScroll,
behavior: 'smooth',
});
},
[initialColumnsToDisplay]
);
const ImageOverlay = ({
content,
position,
}: {|
content: React.Node,
position: OverlayTextPosition,
|}) => {
const positionStyles = {
top: position === 'topLeft' || position === 'topRight' ? 8 : undefined,
bottom:
position === 'bottomLeft' || position === 'bottomRight' ? 8 : undefined,
left: position === 'topLeft' || position === 'bottomLeft' ? 8 : undefined,
right:
position === 'topRight' || position === 'bottomRight' ? 8 : undefined,
};
return (
<div
style={{
...styles.overlay,
...positionStyles,
}}
style={styles.container}
onMouseEnter={() => setIsHoveringContainer(true)}
onMouseLeave={() => setIsHoveringContainer(false)}
>
{content}
<div
style={{
...styles.arrowsContainer,
display: isMobile ? 'hidden' : 'flex',
opacity: isHoveringContainer ? 1 : 0,
}}
>
<div
style={{
...styles.arrowContainer,
backgroundColor: gdevelopTheme.paper.backgroundColor.medium,
pointerEvents: canScrollLeft ? 'auto' : 'none',
opacity: canScrollLeft ? 0.8 : 0,
}}
className={classes.arrowContainer}
onClick={() => onScroll('left')}
>
<ChevronArrowLeft style={styles.arrow} />
</div>
<div
style={{
...styles.arrowContainer,
backgroundColor: gdevelopTheme.paper.backgroundColor.medium,
pointerEvents: canScrollRight ? 'auto' : 'none',
opacity: canScrollRight ? 0.8 : 0,
}}
className={classes.arrowContainer}
onClick={() => onScroll('right')}
>
<ChevronArrowRight style={styles.arrow} />
</div>
</div>
<GridList
cols={actualColumnsToDisplay}
style={styles.gridList}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
ref={scrollView}
>
{items.map(item => item.renderItem())}
</GridList>
</div>
);
};
const Carousel = <ThumbnailType: CarouselThumbnail>({
title,
items,
additionalAction,
browseAllLink,
onBrowseAllClick,
browseAllLabel,
browseAllIcon,
error,
displayItemTitles = true,
roundedImages = false,
displayArrowsOnDesktop = false,
}: Props<ThumbnailType>) => {
const [
shouldDisplayLeftArrow,
setShouldDisplayLeftArrow,
] = React.useState<boolean>(false);
const [
shouldDisplayRightArrow,
setShouldDisplayRightArrow,
] = React.useState<boolean>(displayArrowsOnDesktop);
const [
isMouseOverContainer,
setIsMouseOverContainer,
] = React.useState<boolean>(false);
const { windowSize, isMobile } = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const classesForArrowButtons = useStylesForArrowButtons();
const classesForGridList = useStylesForGridList();
const classesForGridListItem = useStylesForGridListItem();
const scrollView = React.useRef<?HTMLUListElement>(null);
const [hoveredElement, setHoveredElement] = React.useState<?HTMLElement>(
null
);
const areItemsSet = items && items.length > 0;
const itemsToDisplay =
items && items.length > 0
? items
: Array(skeletonNumber)
.fill({
skeleton: true,
title: '',
thumbnail: '',
})
.map((item, index) => ({ ...item, id: `skeleton${index}` }));
const isMounted = useIsMounted();
const forceUpdate = useForceUpdate();
const loadedImageUrls = React.useRef<Set<string>>(new Set<string>());
const setImageLoaded = React.useCallback(
(loadedImageUrl: string) => {
// Give a bit of time to an image to fully render before revealing it.
setTimeout(() => {
if (!isMounted) return; // Avoid warnings if the component was removed in the meantime.
loadedImageUrls.current.add(loadedImageUrl);
forceUpdate();
}, 50);
},
[forceUpdate, isMounted]
);
const imageHeight = referenceSizesByWindowSize.imageHeight[windowSize];
const arrowWidth = 30;
const cellWidth = (16 / 9) * imageHeight;
const widthUnit = cellWidth + cellSpacing;
const cellHeight =
imageHeight +
(displayItemTitles ? titleHeight + spacerSize : 2 * focusItemBorderWidth); // Take focus border into account to make sure it is not cut (box-sizing: content-box not working)
const renderImage = React.useCallback(
(item: ThumbnailType | SkeletonThumbnail): React.Node => (
<CorsAwareImage
src={item.thumbnailUrl}
style={{
...styles.image,
// Once ready, animate the image display.
opacity: loadedImageUrls.current.has(item.thumbnailUrl) ? 1 : 0,
height: imageHeight,
minHeight: imageHeight,
width: cellWidth,
borderRadius: roundedImages ? 8 : 0,
}}
alt={item.title}
title={item.title}
onLoad={() => setImageLoaded(item.thumbnailUrl)}
/>
),
[cellWidth, imageHeight, roundedImages, setImageLoaded]
);
const openLinkCallback = (link: string): (() => void) => (): void => {
Window.openExternalURL(link);
};
const renderThumbnail = React.useCallback(
(item: ThumbnailType | SkeletonThumbnail): ?React.Node => {
if (!item.skeleton && !item.link && !item.thumbnailUrl) return null;
if (item.thumbnailUrl || item.link) {
return renderImage(item);
}
if (item.skeleton) {
return (
<Skeleton
variant="rect"
height={imageHeight}
width={cellWidth}
style={{
borderRadius: roundedImages ? 8 : 0,
}}
/>
);
}
},
[renderImage, cellWidth, imageHeight, roundedImages]
);
const renderItemTitle = React.useCallback(
(item: ThumbnailType | SkeletonThumbnail, index: number): ?React.Node => {
if (!displayItemTitles) return null;
return (
<>
<Spacer />
{item.title ? (
<div style={{ ...styles.itemTitleContainer, width: cellWidth }}>
<Text noMargin style={styles.itemTitle}>
{item.title}
</Text>
</div>
) : (
<Skeleton
variant="rect"
height={titleHeight}
width={(cellWidth / 3) * (1 + 2 * randomNumbers[index])} // Make rectangles of different lengths so that the UI feels more "alive".
/>
)}
</>
);
},
[cellWidth, displayItemTitles]
);
const roundScroll = React.useCallback(
(value: number): number => {
return Math.round(value / widthUnit) * widthUnit;
},
[widthUnit]
);
const getVisibleThumbnailsCount = React.useCallback(
(element: HTMLElement): number =>
Math.max(Math.floor(element.offsetWidth / widthUnit), 1),
[widthUnit]
);
const computeScroll = React.useCallback(
(
direction: 'left' | 'right',
scrollViewElement: HTMLUListElement
): number => {
const visibleThumbnailsCount = getVisibleThumbnailsCount(
scrollViewElement
);
const scale = visibleThumbnailsCount * widthUnit;
const currentScroll = scrollViewElement.scrollLeft;
const currentFirstVisibleItemIndex = Math.round(
currentScroll / widthUnit
);
if (
direction === 'right' &&
currentFirstVisibleItemIndex >
itemsToDisplay.length - visibleThumbnailsCount - 1
)
return 0;
return roundScroll(
scrollViewElement.scrollLeft + scale * (direction === 'left' ? -1 : 1)
);
},
[widthUnit, itemsToDisplay, roundScroll, getVisibleThumbnailsCount]
);
const onClickArrow = React.useCallback(
(direction: 'left' | 'right'): void => {
const scrollViewElement = scrollView.current;
if (!scrollViewElement) return;
const newScrollPosition = computeScroll(direction, scrollViewElement);
scrollViewElement.scrollTo({
left: newScrollPosition,
behavior: 'smooth',
});
},
[computeScroll]
);
const handleScroll = React.useCallback(
(): void => {
const scrollViewElement = scrollView.current;
if (!scrollViewElement) return;
if (!displayArrowsOnDesktop) return;
const isScrollAtStart = scrollViewElement.scrollLeft === 0;
const isScrollAtEnd =
scrollViewElement.scrollLeft >=
scrollViewElement.scrollWidth -
scrollViewElement.clientWidth -
// margin to avoid having the arrow flickering when the tile is scaling on hover.
5;
const shouldToggleLeftArrowVisibility =
isScrollAtStart === shouldDisplayLeftArrow;
const shouldToggleRightArrowVisibility =
isScrollAtEnd === shouldDisplayRightArrow;
if (shouldToggleLeftArrowVisibility)
setShouldDisplayLeftArrow(!shouldDisplayLeftArrow);
if (shouldToggleRightArrowVisibility)
setShouldDisplayRightArrow(!shouldDisplayRightArrow);
},
[shouldDisplayLeftArrow, shouldDisplayRightArrow, displayArrowsOnDesktop]
);
const handleScrollEnd = React.useCallback(
(): void => {
const scrollViewElement = scrollView.current;
if (!scrollViewElement) return;
scrollViewElement.scrollTo({
left: roundScroll(scrollViewElement.scrollLeft),
behavior: 'smooth',
});
},
[roundScroll]
);
const onFocusItem = React.useCallback(
(event: SyntheticFocusEvent<HTMLLIElement>, index: number): void => {
// Clicked element receives focus before click event is triggered.
// If a scroll occurs before onmouseup event and the element is scrolled out
// of the cursor, the click of the user is logically but wrongly ignored.
if (event.currentTarget !== hoveredElement) {
const element = event.currentTarget;
const parent = element.offsetParent;
if (!parent || !(parent instanceof HTMLElement)) return;
const visibleThumbnailsCount = getVisibleThumbnailsCount(parent);
// Browsers handle differently a focus on an out-of-sight element.
// To ensure the behavior is the same across all browsers, we compute
// the scroll value to reach to make the tab navigation pleasant.
const elementBoundingRect = element.getBoundingClientRect();
const parentBoundingRect = parent.getBoundingClientRect();
const isHiddenLeft =
Math.round(elementBoundingRect.left - parentBoundingRect.left) < 0;
const isHiddenRight =
Math.round(elementBoundingRect.right - parentBoundingRect.right) >= 0;
if (isHiddenLeft)
parent.scroll({
left: element.offsetLeft,
});
else if (isHiddenRight)
parent.scroll({
left: widthUnit * (index - visibleThumbnailsCount + 1),
});
}
},
[getVisibleThumbnailsCount, hoveredElement, widthUnit]
);
React.useEffect(
() => {
const scrollViewElement = scrollView.current;
if (!scrollViewElement) return;
// Add event listeners on component mount. There is no need to
// remove them with a cleanup function because scrollview element
// does not change and they will be destroyed when the element is
// removed from the DOM.
scrollViewElement.addEventListener('scroll', handleScroll);
scrollViewElement.addEventListener('touchend', handleScrollEnd);
scrollViewElement.addEventListener('touchleave', handleScrollEnd);
},
[handleScroll, handleScrollEnd]
);
return (
<ColumnStackLayout noMargin>
<Line justifyContent="space-between" alignItems="center" noMargin>
<Text size="section-title" noMargin>
{title}
</Text>
<Line noMargin>
{additionalAction && (
<>
{additionalAction}
<Spacer />
</>
)}
<FlatButton
onClick={
onBrowseAllClick ||
(browseAllLink ? openLinkCallback(browseAllLink) : () => {})
}
label={
isMobile ? (
<Trans>Browse</Trans> // Short label on mobile.
) : (
browseAllLabel || <Trans>Browse all</Trans>
)
}
leftIcon={browseAllIcon}
/>
</Line>
</Line>
<div
style={styles.container}
onMouseEnter={() => setIsMouseOverContainer(true)}
onMouseLeave={() => setIsMouseOverContainer(false)}
>
{displayArrowsOnDesktop &&
isMouseOverContainer &&
!isMobile &&
shouldDisplayLeftArrow &&
areItemsSet && (
<div
className={classesForArrowButtons.root}
style={{
...styles.arrowContainer,
backgroundColor: gdevelopTheme.paper.backgroundColor.light,
width: arrowWidth,
height: arrowWidth,
left: 5,
zIndex: 1,
top: `calc(50% - ${Math.floor(arrowWidth / 2)}px)`,
}}
onClick={() => onClickArrow('left')}
>
<ChevronArrowLeft />
</div>
)}
<div
style={{
width: '100%',
}}
>
{error ? (
<div style={{ ...styles.error, height: cellHeight }}>
<AlertMessage kind="warning">{error}</AlertMessage>
</div>
) : (
<GridList
classes={classesForGridList}
cols={itemsToDisplay.length}
cellHeight={cellHeight}
spacing={cellSpacing}
style={styles.gridList}
ref={scrollView}
>
{itemsToDisplay.map((item, index) => (
<GridListTile
classes={classesForGridListItem}
key={item.id}
tabIndex={0}
onFocus={event => onFocusItem(event, index)}
onMouseEnter={event => setHoveredElement(event.currentTarget)}
onMouseLeave={() => setHoveredElement(null)}
onKeyPress={(
event: SyntheticKeyboardEvent<HTMLLIElement>
): void => {
if (shouldValidate(event)) {
if (item.link) openLinkCallback(item.link)();
if (item.onClick) item.onClick();
}
}}
onClick={
item.link
? openLinkCallback(item.link)
: item.onClick
? item.onClick
: null
}
>
{renderThumbnail(item)}
{item.overlayText &&
loadedImageUrls.current.has(item.thumbnailUrl) && (
<ImageOverlay
content={item.overlayText}
position={item.overlayTextPosition || 'bottomRight'}
/>
)}
{renderItemTitle(item, index)}
</GridListTile>
))}
</GridList>
)}
</div>
{displayArrowsOnDesktop &&
isMouseOverContainer &&
!isMobile &&
shouldDisplayRightArrow &&
areItemsSet && (
<div
className={classesForArrowButtons.root}
style={{
...styles.arrowContainer,
backgroundColor: gdevelopTheme.paper.backgroundColor.light,
width: arrowWidth,
height: arrowWidth,
right: 0,
top: `calc(50% - ${Math.floor(arrowWidth / 2)}px)`,
}}
onClick={() => onClickArrow('right')}
>
<ChevronArrowRight />
</div>
)}
</div>
</ColumnStackLayout>
);
};
export default Carousel;

View File

@@ -0,0 +1,8 @@
.arrowContainer:hover {
filter: brightness(130%);
transition: 'filter 100ms ease';
}
.arrowContainer path {
cursor: pointer;
}

View File

@@ -35,7 +35,6 @@ type ErrorBoundaryScope =
| 'app'
| 'editor'
| 'start-page'
| 'start-page-get-started'
| 'start-page-shop'
| 'start-page-learn'
| 'start-page-play'

View File

@@ -1,7 +1,5 @@
// @flow
import * as React from 'react';
import { createStyles, makeStyles } from '@material-ui/core/styles';
import ButtonBase from '@material-ui/core/ButtonBase';
import GridList from '@material-ui/core/GridList';
import GridListTile from '@material-ui/core/GridListTile';
import { Column, Line } from './Grid';
@@ -17,9 +15,14 @@ import { shortenString } from '../Utils/StringHelpers';
import useForceUpdate from '../Utils/UseForceUpdate';
import { useIsMounted } from '../Utils/UseIsMounted';
import Lock from '../UI/CustomSvgIcons/Lock';
import {
CardWidget,
LARGE_WIDGET_SIZE,
} from '../MainFrame/EditorContainers/HomePage/CardWidget';
import { ColumnStackLayout } from './Layout';
import Chip from './Chip';
const MAX_TILE_SIZE = 300;
const SPACING = 8;
const SPACING = 5;
const styles = {
buttonStyle: {
@@ -41,11 +44,6 @@ const styles = {
marginTop: 0,
marginBottom: 0,
},
titleContainerWithMinHeight: {
// Fix min height to ensure the content stays aligned.
// 2 line heights (20) + 2 text paddings (6)
minHeight: 2 * 20 + 2 * 6,
},
thumbnailImageWithDescription: {
display: 'block', // Display as a block to prevent cumulative layout shift.
objectFit: 'cover',
@@ -80,20 +78,17 @@ const styles = {
justifyContent: 'center',
alignItems: 'center',
},
textContainer: {
flex: 1,
display: 'flex',
justifyContent: 'center',
padding: '8px 16px',
},
chip: {
height: 24,
},
};
// Styles to give a visible hover for the mouse cursor.
const useStylesForTileHover = makeStyles(theme =>
createStyles({
tile: {
transition: 'transform 0.3s ease-in-out',
'&:hover': {
transform: 'scale(1.02)',
},
},
})
);
type OverlayTextPosition =
| 'topLeft'
| 'topRight'
@@ -143,6 +138,8 @@ export type ImageTileComponent = {|
description?: string,
overlayText?: string | React.Node,
overlayTextPosition?: OverlayTextPosition,
chipText?: string | null,
chipColor?: string | null, // Color for the chip border.
|};
type ImageTileGridProps = {|
@@ -165,7 +162,6 @@ const ImageTileGrid = ({
getLimitFromWindowSize,
}: ImageTileGridProps) => {
const { windowSize, isLandscape } = useResponsiveWindowSize();
const tileClasses = useStylesForTileHover();
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', isLandscape);
const limit = getLimitFromWindowSize
? getLimitFromWindowSize(windowSize, isLandscape)
@@ -196,7 +192,7 @@ const ImageTileGrid = ({
cols={columns}
style={{
flex: 1,
maxWidth: (MAX_TILE_SIZE + 2 * SPACING) * MAX_COLUMNS, // Avoid tiles taking too much space on large screens.
maxWidth: (LARGE_WIDGET_SIZE + 2 * SPACING) * MAX_COLUMNS, // Avoid tiles taking too much space on large screens.
}}
cellHeight="auto"
spacing={SPACING * 2}
@@ -204,7 +200,7 @@ const ImageTileGrid = ({
{isLoading
? new Array(columns).fill(0).map((_, index) => (
// Display tiles but with skeletons while the data is loading.
<GridListTile key={index} classes={tileClasses}>
<GridListTile key={index}>
<Skeleton
variant="rect"
width="100%"
@@ -214,13 +210,8 @@ const ImageTileGrid = ({
</GridListTile>
))
: itemsToDisplay.map((item, index) => (
<GridListTile key={index} classes={tileClasses}>
<ButtonBase
style={styles.buttonStyle}
onClick={item.onClick}
tabIndex={0}
focusRipple
>
<GridListTile key={index}>
<CardWidget onClick={item.onClick} size="large">
<Column expand noMargin>
<div style={styles.imageContainer}>
{!loadedImageUrls.current.has(item.imageUrl) ? (
@@ -252,24 +243,57 @@ const ImageTileGrid = ({
)}
{item.isLocked && <LockedOverlay />}
</div>
{item.title && (
<div
style={
columns === 1
? undefined
: styles.titleContainerWithMinHeight
}
<div style={styles.textContainer}>
<ColumnStackLayout
noMargin
expand
justifyContent="space-between"
useFullHeight
noOverflowParent
>
<Text size="sub-title">{item.title}</Text>
</div>
)}
{item.description && (
<Text size="body" color="secondary">
{shortenString(item.description, 120)}
</Text>
)}
<ColumnStackLayout
noMargin
expand
justifyContent="flex-start"
useFullHeight
noOverflowParent
>
{item.title && (
<Text size="sub-title" noMargin align="left">
{item.title}
</Text>
)}
{item.description && (
<Text
size="body"
color="secondary"
noMargin
align="left"
>
{shortenString(item.description, 115)}
</Text>
)}
</ColumnStackLayout>
{item.chipText && (
<Line
justifyContent="space-between"
alignItems="flex-end"
>
<Chip
style={{
...styles.chip,
border: `1px solid ${item.chipColor ||
'#3BF7F4'}`,
}}
label={item.chipText}
variant="outlined"
/>
</Line>
)}
</ColumnStackLayout>
</div>
</Column>
</ButtonBase>
</CardWidget>
</GridListTile>
))}
</GridList>

View File

@@ -1,15 +1,12 @@
// @flow
import * as React from 'react';
import { Column, LargeSpacer, Line } from './Grid';
import { Column, Line, Spacer } from './Grid';
import Text from './Text';
import { LineStackLayout } from './Layout';
import FlatButton from './FlatButton';
import { Trans } from '@lingui/macro';
import ImageTileGrid, { type ImageTileComponent } from './ImageTileGrid';
import {
useResponsiveWindowSize,
type WindowSizeType,
} from './Responsive/ResponsiveWindowMeasurer';
import { type WindowSizeType } from './Responsive/ResponsiveWindowMeasurer';
type ImageTileRowProps = {|
title: React.Node,
@@ -42,8 +39,6 @@ const ImageTileRow = ({
seeAllLabel,
margin,
}: ImageTileRowProps) => {
const { isMobile } = useResponsiveWindowSize();
return (
<>
<LineStackLayout
@@ -59,13 +54,7 @@ const ImageTileRow = ({
<Column noMargin>
<FlatButton
onClick={onShowAll}
label={
isMobile ? (
<Trans>Browse</Trans> // Short label on mobile.
) : (
seeAllLabel || <Trans>See all</Trans>
)
}
label={seeAllLabel || <Trans>See all</Trans>}
rightIcon={showAllIcon}
/>
</Column>
@@ -76,7 +65,7 @@ const ImageTileRow = ({
<Text noMargin>{description}</Text>
</Line>
)}
{margin === 'dense' ? null : <LargeSpacer />}
{margin === 'dense' ? null : <Spacer />}
<ImageTileGrid
items={items}
isLoading={isLoading}

View File

@@ -17,6 +17,7 @@ export type RaisedButtonPropsWithoutOnClick = {|
keyboardFocused?: boolean,
fullWidth?: boolean,
icon?: React.Node,
rightIcon?: React.Node,
style?: {|
marginTop?: number,
marginBottom?: number,
@@ -44,6 +45,7 @@ const RaisedButton = React.forwardRef<RaisedButtonProps, ButtonInterface>(
color,
size,
icon,
rightIcon,
disabled,
keyboardFocused,
style,
@@ -88,6 +90,8 @@ const RaisedButton = React.forwardRef<RaisedButtonProps, ButtonInterface>(
{!!icon && !!label && <Spacer />}
{/* span element is required to prevent browser auto translators to crash the app - See https://github.com/4ian/GDevelop/issues/3453 */}
{label ? <span>{label}</span> : null}
{!!rightIcon && !!label && <Spacer />}
{rightIcon}
</Button>
);
}

View File

@@ -12,7 +12,7 @@ const sizeThresholds = {
smallHeight: 500,
smallWidth: 600,
mediumWidth: 1150,
largeWidth: 1500,
largeWidth: 1800,
};
type Props = {|

View File

@@ -308,6 +308,15 @@ export const sendGameTemplateInformationOpened = (options: {|
recordEvent('game_template_information_opened', options);
};
export const sendCourseBuyClicked = (options: {|
courseId: string,
courseName: string,
currency?: string,
usageType: string,
|}) => {
recordEvent('course_buy_clicked', options);
};
export const sendUserSurveyStarted = () => {
recordEvent('user_survey_started');
};

View File

@@ -26,29 +26,3 @@ export const incrementProgramOpeningCount = () => {
console.warn('Unable to store program opening count', e);
}
};
export const getGetStartedSectionViewCount = (): number => {
try {
const count = localStorage.getItem(
`${localStorageLocalStatsPrefix}-get-started-section-view-count`
);
if (count !== null) return parseInt(count, 10);
} catch (e) {
console.warn('Unable to load stored get started section view count', e);
}
return 0;
};
export const incrementGetStartedSectionViewCount = () => {
const count = getGetStartedSectionViewCount() + 1;
try {
localStorage.setItem(
`${localStorageLocalStatsPrefix}-get-started-section-view-count`,
'' + count
);
} catch (e) {
console.warn('Unable to store get started section view count', e);
}
};

View File

@@ -299,6 +299,9 @@ export type Course = {|
titleByLocale: MessageByLocale,
shortDescriptionByLocale: MessageByLocale,
levelByLocale: MessageByLocale,
isLocked?: boolean,
includedInSubscriptions: string[],
|};
export type UserCourseProgress = {|
@@ -639,7 +642,27 @@ export const extractDecodedFilenameWithExtensionFromPublicAssetResourceUrl = (
return decodedFilenameWithExtension;
};
export const listCourses = async (): Promise<Array<Course>> => {
export const listCourses = async (
getAuthorizationHeader: () => Promise<string>,
{
userId,
}: {|
userId: ?string,
|}
): Promise<Array<Course>> => {
if (userId) {
const authorizationHeader = await getAuthorizationHeader();
const response = await client.get(`/course`, {
params: {
userId,
},
headers: {
Authorization: authorizationHeader,
},
});
return response.data;
}
const response = await client.get(`/course`);
return response.data;
};

View File

@@ -30,8 +30,18 @@ type ProductListingData = {|
id: string,
sellerId: string,
isSellerGDevelop: boolean,
productType: 'ASSET_PACK' | 'GAME_TEMPLATE',
listing: 'ASSET_PACK' | 'GAME_TEMPLATE',
productType:
| 'ASSET_PACK'
| 'GAME_TEMPLATE'
| 'CREDITS_PACKAGE'
| 'COURSE_CHAPTER'
| 'COURSE',
listing:
| 'ASSET_PACK'
| 'GAME_TEMPLATE'
| 'CREDITS_PACKAGE'
| 'COURSE_CHAPTER'
| 'COURSE',
name: string,
description: string,
categories: Array<string>,
@@ -101,6 +111,14 @@ export type CourseChapterListingData = {|
listing: 'COURSE_CHAPTER',
|};
export type CourseListingData = {|
...ProductListingData,
...StripeAndPaypalSellableAttributes,
...CreditsClaimableAttributes,
productType: 'COURSE',
listing: 'COURSE',
|};
export type Purchase = {|
id: string,
usageType: string,
@@ -116,10 +134,15 @@ export type Purchase = {|
paypalOrderId?: string,
manualGiftReason?: string,
creditsAmount?: number,
productType: 'ASSET_PACK' | 'GAME_TEMPLATE' | 'CREDITS_PACKAGE',
productType:
| 'ASSET_PACK'
| 'GAME_TEMPLATE'
| 'CREDITS_PACKAGE'
| 'COURSE'
| 'COURSE_CHAPTER',
|};
type ProductLicenseType = 'personal' | 'commercial' | 'unlimited';
type ProductLicenseType = 'personal' | 'commercial' | 'unlimited' | 'default';
export type ProductLicense = {|
id: ProductLicenseType,
nameByLocale: MessageByLocale,
@@ -174,6 +197,18 @@ export const listListedCourseChapters = async (): Promise<
return courseChapters;
};
export const listListedCourses = async (): Promise<
Array<CourseListingData>
> => {
const response = await client.get('/course');
const courses = response.data;
if (!Array.isArray(courses)) {
throw new Error('Invalid response from the courses API');
}
return courses;
};
export const listSellerAssetPacks = async ({
sellerId,
}: {|
@@ -208,7 +243,7 @@ export const listUserPurchases = async (
role,
}: {|
userId: string,
productType: 'asset-pack' | 'game-template' | 'credits-package',
productType: 'asset-pack' | 'game-template' | 'credits-package' | 'course',
role: 'receiver' | 'buyer',
|}
): Promise<Array<Purchase>> => {

View File

@@ -11,11 +11,6 @@ export type TutorialCategory =
| 'official-intermediate'
| 'official-advanced'
| 'recommendations'
// 'course' is not a tutorial category per se (it's not included in the
// tutorials database) but it's convenient having it as a category to
// make sure the Learn section handles one variable only to know if it
// should display the main page, the course or a tutorial's category page.
| 'course'
| 'education-curriculum';
export type Tutorial = {|

View File

@@ -12,6 +12,7 @@
export const ANNOUNCEMENTS_FETCH_TIMEOUT = 1000;
export const IN_APP_TUTORIALS_FETCH_TIMEOUT = 1000;
// COURSES_FETCH - No timeout. Loaded on the homepage.
export const COURSE_CHAPTERS_FETCH_TIMEOUT = 3000;
export const PRIVATE_GAME_TEMPLATES_FETCH_TIMEOUT = 5000; // But called right away because it's used in the homepage.

View File

@@ -8,7 +8,7 @@ import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
type Props = {|
openInAppTutorialDialog: (tutorialId: string) => void,
openProfileDialog: () => void,
openAskAi: () => void,
openAskAi: (mode?: 'agent' | 'chat') => void,
|};
/**
@@ -76,7 +76,7 @@ const useOpenInitialDialog = ({
// in the manage tab. So the homepage handles the route arguments itself.
break;
case 'ask-ai':
openAskAi();
openAskAi('agent');
break;
default:
break;

View File

@@ -859,6 +859,7 @@ export const defaultAuthenticatedUserWithNoSubscription: AuthenticatedUser = {
receivedAssetShortHeaders: [],
gameTemplatePurchases: [],
assetPackPurchases: [],
coursePurchases: [],
onLogin: async () => {},
onLoginWithProvider: async () => {},
onCancelLoginOrSignUp: () => {},
@@ -888,6 +889,9 @@ export const defaultAuthenticatedUserWithNoSubscription: AuthenticatedUser = {
onRefreshAssetPackPurchases: async () => {
console.info('This should refresh the asset pack purchases');
},
onRefreshCoursePurchases: async () => {
console.info('This should refresh the courses purchases');
},
onRefreshEarningsBalance: async () => {
console.info('This should refresh the user earnings balance');
},
@@ -3103,6 +3107,7 @@ export const premiumCourse: Course = {
'uk-UA': 'Початковий рівень',
'zh-CN': '初级水平',
},
includedInSubscriptions: ['gdevelop_silver', 'gdevelop_gold'],
chaptersTargetCount: 15,
durationInWeeks: 2,
specializationId: 'game-development',

View File

@@ -1,12 +1,11 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import { action } from '@storybook/addon-actions';
import paperDecorator from '../PaperDecorator';
import Carousel from '../../UI/Carousel';
import ChevronArrowRight from '../../UI/CustomSvgIcons/ChevronArrowRight';
import { GridListTile } from '@material-ui/core';
import FixedHeightFlexContainer from '../FixedHeightFlexContainer';
export default {
title: 'Carousel',
@@ -14,189 +13,21 @@ export default {
decorators: [paperDecorator],
};
export const LoadingWithoutTitleSkeleton = () => (
<Carousel
title={<Trans>Learn game making</Trans>}
items={null}
displayItemTitles={false}
browseAllLabel={<Trans>Browse all</Trans>}
browseAllIcon={<ChevronArrowRight fontSize="small" />}
displayArrowsOnDesktop
/>
);
export const OnlyFewSquareImages = () => {
const items = [
{
id: 'tutorial1',
title: 'Tutorial 1',
thumbnailUrl: 'https://img.youtube.com/vi/va9GqIbK_SA/mqdefault.jpg',
link: 'https://www.youtube.com/watch?v=va9GqIbK_SA',
},
{
id: 'tutorial2',
title: 'Tutorial 2',
thumbnailUrl: 'https://img.youtube.com/vi/KpLAYMSgoDI/mqdefault.jpg',
link: 'https://www.youtube.com/watch?v=KpLAYMSgoDI',
},
{
id: 'tutorial3',
title: 'Tutorial 3',
thumbnailUrl: 'https://img.youtube.com/vi/bR2BjT7JG0k/mqdefault.jpg',
link: 'https://www.youtube.com/watch?v=bR2BjT7JG0k',
},
{
id: 'tutorial4',
title: 'Tutorial 4',
thumbnailUrl: 'https://img.youtube.com/vi/1RpH9VQjwNY/mqdefault.jpg',
link: 'https://www.youtube.com/watch?v=1RpH9VQjwNY',
},
{
id: 'tutorial5',
title: 'Tutorial 5',
thumbnailUrl: 'https://img.youtube.com/vi/Q7e3gAWkLZI/mqdefault.jpg',
link: 'https://www.youtube.com/watch?v=Q7e3gAWkLZI',
},
];
return (
<Carousel
title={<Trans>Learn game making</Trans>}
browseAllLabel={<Trans>Browse all</Trans>}
browseAllIcon={<ChevronArrowRight fontSize="small" />}
items={items}
displayItemTitles={false}
browseAllLink="https://www.youtube.com/c/GDevelopApp/videos"
displayArrowsOnDesktop
/>
);
};
export const LoadingWithTitleSkeleton = () => (
<Carousel
title={<Trans>Showcase</Trans>}
items={null}
browseAllLabel={<Trans>Browse all</Trans>}
browseAllIcon={<ChevronArrowRight fontSize="small" />}
/>
const getRandomItem = (id: string) => (
<GridListTile key={id}>
<FixedHeightFlexContainer
height={400}
alignItems="center"
justifyContent="center"
>
Test item {id}
</FixedHeightFlexContainer>
</GridListTile>
);
const showcaseItems = [
{
id: 'lil-bub-s-hello-earth',
title: "Lil BUB's HELLO EARTH",
onClick: () => action('Clicked on lil-bub-s-hello-earth'),
thumbnailUrl:
'https://resources.gdevelop-app.com/games-showcase/images/bub-animated-logo.gif',
overlayText: 'New',
overlayTextPosition: 'topLeft',
},
{
id: 'vai-juliette',
title: 'Vai Juliette!',
onClick: () => action('Clicked on vai-juliette'),
thumbnailUrl:
'https://cdn6.aptoide.com/imgs/0/d/3/0d3926b58fd241dc9ecccc1661f187ec_fgraphic.png',
},
{
id: 'alanna-the-princess-of-puzzles',
title: 'Alanna The Princess Of Puzzles',
onClick: () => action('Clicked on alanna-the-princess-of-puzzles'),
thumbnailUrl: 'https://i.ytimg.com/vi/PguDpz7TC7g/hqdefault.jpg',
overlayText: 'New',
overlayTextPosition: 'topLeft',
},
{
id: 'miko-adventures-puffball',
title: 'Miko Adventures Puffball',
onClick: () => action('Clicked on miko-adventures-puffball'),
thumbnailUrl:
'https://resources.gdevelop-app.com/games-showcase/images/miko-adventures-puffball/Miko_adventures_puffball_header_logo.png',
overlayText: 'Old',
overlayTextPosition: 'topLeft',
},
{
id: 'swamp',
title: 'Swamp',
onClick: () => action('Clicked on swamp'),
thumbnailUrl:
'https://resources.gdevelop-app.com/games-showcase/images/swamp/swamp-banner.jpg',
},
{
id: 'the-mighty-rune-in-development',
title: 'The Mighty Rune (in development)',
onClick: () => action('Clicked on the-mighty-rune-in-development'),
thumbnailUrl:
'https://resources.gdevelop-app.com/games-showcase/images/mighty-rune/mighty-rune-banner.jpg',
},
{
id: 'hyperspace-dogfights',
title: 'Hyperspace Dogfights',
onClick: () => action('Clicked on hyperspace-dogfights'),
thumbnailUrl:
'https://resources.gdevelop-app.com/games-showcase/images/hyperspace-dogfights/hdog_screenshot_dodge.png',
},
{
id: 'uphill-climb-racing-neon',
title: 'Uphill Climb Racing Neon',
onClick: () => action('Clicked on uphill-climb-racing-neon'),
thumbnailUrl:
'https://resources.gdevelop-app.com/games-showcase/images/uphill-climb-racing-neon/uphill-climb-racing-neon-banner.jpg',
},
{
id: 'karambola',
title: 'Karambola',
onClick: () => action('Clicked on karambola'),
thumbnailUrl:
'https://resources.gdevelop-app.com/games-showcase/images/karambola-banner.png',
},
{
id: 'the-research-age',
title: 'The Research Age',
onClick: () => action('Clicked on the-research-age'),
thumbnailUrl:
'https://forums.androidcentral.com/attachments/android-games/333598d1618909711t-game-research-age-senzanome2.jpg',
},
{
id: 'a-pixel-adventure-legion',
title: 'A Pixel Adventure Legion',
onClick: () => action('Clicked on a-pixel-adventure-legion'),
thumbnailUrl:
'https://resources.gdevelop-app.com/games-showcase/images/apx-legion/apx-legion-banner-min.jpg',
},
];
export const RoundedImagesWithOverlay = () => {
return (
<Carousel
title={<Trans>Showcase</Trans>}
items={showcaseItems}
browseAllLabel={<Trans>Browse all</Trans>}
onBrowseAllClick={() => action('Browse all button clicked')}
browseAllIcon={<ChevronArrowRight fontSize="small" />}
roundedImages
displayArrowsOnDesktop
/>
);
export const Default = () => {
const items = new Array(15).fill(null).map((_, index) => ({
renderItem: () => getRandomItem(`item-${index}`),
}));
return <Carousel items={items} />;
};
export const WithoutArrows = () => {
return (
<Carousel
title={<Trans>Showcase</Trans>}
items={showcaseItems}
browseAllLabel={<Trans>Browse all</Trans>}
onBrowseAllClick={() => action('Browse all button clicked')}
browseAllIcon={<ChevronArrowRight fontSize="small" />}
roundedImages
/>
);
};
export const WithError = () => (
<Carousel
title={<Trans>Showcase</Trans>}
items={null}
browseAllLabel={<Trans>Browse all</Trans>}
error={<Trans>Unexpected error</Trans>}
browseAllIcon={<ChevronArrowRight fontSize="small" />}
displayArrowsOnDesktop
/>
);

View File

@@ -4,7 +4,10 @@ import { action } from '@storybook/addon-actions';
import LockedCourseChapterPreview from '../../../Course/LockedCourseChapterPreview';
import paperDecorator from '../../PaperDecorator';
import { lockedCourseChapter } from '../../../fixtures/GDevelopServicesTestData';
import {
lockedCourseChapter,
premiumCourse,
} from '../../../fixtures/GDevelopServicesTestData';
export default {
title: 'Course/LockedCourseChapterPreview',
@@ -15,11 +18,9 @@ export default {
export const Default = () => {
return (
<LockedCourseChapterPreview
course={premiumCourse}
courseChapter={lockedCourseChapter}
onBuyWithCredits={async () => {
action('onBuyWithCredits')();
throw new Error('Error buying product with credits.');
}}
onClickUnlock={() => action('onClickUnlock')()}
/>
);
};

View File

@@ -4,7 +4,10 @@ import { action } from '@storybook/addon-actions';
import TextBasedCourseChapterView from '../../../Course/TextBasedCourseChapterView';
import paperDecorator from '../../PaperDecorator';
import { textBasedCourseChapter } from '../../../fixtures/GDevelopServicesTestData';
import {
premiumCourse,
textBasedCourseChapter,
} from '../../../fixtures/GDevelopServicesTestData';
export default {
title: 'Course/TextBasedCourseChapterView',
@@ -15,13 +18,14 @@ export default {
export const Chapter1 = () => {
return (
<TextBasedCourseChapterView
course={premiumCourse}
courseChapter={textBasedCourseChapter}
onOpenTemplate={action('open template')}
onCompleteTask={action('onCompleteTask')}
isTaskCompleted={action('isTaskCompleted')}
getChapterCompletion={action('getChapterCompletion')}
chapterIndex={0}
onBuyWithCredits={action('onBuyWithCredits')}
onClickUnlock={() => action('onClickUnlock')()}
/>
);
};

View File

@@ -7,6 +7,7 @@ import paperDecorator from '../../PaperDecorator';
import {
videoBasedCourseChapter,
lockedCourseChapter,
premiumCourse,
} from '../../../fixtures/GDevelopServicesTestData';
export default {
@@ -18,13 +19,14 @@ export default {
export const Default = () => {
return (
<VideoBasedCourseChapterView
course={premiumCourse}
courseChapter={videoBasedCourseChapter}
onOpenTemplate={action('open template')}
onCompleteTask={action('onCompleteTask')}
isTaskCompleted={action('isTaskCompleted')}
getChapterCompletion={action('getChapterCompletion')}
chapterIndex={0}
onBuyWithCredits={action('onBuyWithCredits')}
onClickUnlock={() => action('onClickUnlock')()}
/>
);
};
@@ -32,13 +34,14 @@ export const Default = () => {
export const Locked = () => {
return (
<VideoBasedCourseChapterView
course={premiumCourse}
courseChapter={lockedCourseChapter}
onOpenTemplate={action('open template')}
onCompleteTask={action('onCompleteTask')}
isTaskCompleted={action('isTaskCompleted')}
getChapterCompletion={action('getChapterCompletion')}
chapterIndex={0}
onBuyWithCredits={action('onBuyWithCredits')}
onClickUnlock={() => action('onClickUnlock')()}
/>
);
};

View File

@@ -1,67 +0,0 @@
// @flow
import * as React from 'react';
import MockAdapter from 'axios-mock-adapter';
import axios from 'axios';
import { action } from '@storybook/addon-actions';
import paperDecorator from '../../PaperDecorator';
import alertDecorator from '../../AlertDecorator';
import AnyQuestionDialog from '../../../MainFrame/EditorContainers/HomePage/AnyQuestionDialog';
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
import {
fakeAuthenticatedUserWithEducationPlan,
fakeAuthenticatedUserWithNoSubscription,
fakeAuthenticatedUserWithQuestionsQuotaReached,
fakeNotAuthenticatedUser,
} from '../../../fixtures/GDevelopServicesTestData';
import i18nProviderDecorator from '../../I18nProviderDecorator';
export default {
title: 'HomePage/AnyQuestionDialog',
component: AnyQuestionDialog,
decorators: [paperDecorator, alertDecorator, i18nProviderDecorator],
};
export const Default = () => {
const axiosMock = new MockAdapter(axios, { delayResponse: 500 });
axiosMock.onAny().reply(200);
return (
<AuthenticatedUserContext.Provider
value={fakeAuthenticatedUserWithNoSubscription}
>
<AnyQuestionDialog onClose={action('onClose')} />
</AuthenticatedUserContext.Provider>
);
};
export const LimitReached = () => {
const axiosMock = new MockAdapter(axios, { delayResponse: 500 });
axiosMock.onAny().reply(500);
return (
<AuthenticatedUserContext.Provider
value={fakeAuthenticatedUserWithQuestionsQuotaReached}
>
<AnyQuestionDialog onClose={action('onClose')} />
</AuthenticatedUserContext.Provider>
);
};
export const NotAuthenticated = () => (
<AuthenticatedUserContext.Provider value={fakeNotAuthenticatedUser}>
<AnyQuestionDialog onClose={action('onClose')} />
</AuthenticatedUserContext.Provider>
);
export const Erroring = () => {
const axiosMock = new MockAdapter(axios, { delayResponse: 500 });
axiosMock.onAny().reply(500);
return (
<AuthenticatedUserContext.Provider
value={fakeAuthenticatedUserWithEducationPlan}
>
<AnyQuestionDialog onClose={action('onClose')} />
</AuthenticatedUserContext.Provider>
);
};

View File

@@ -1,69 +0,0 @@
// @flow
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import paperDecorator from '../../../PaperDecorator';
import { fakeAuthenticatedUserWithNoSubscription } from '../../../../fixtures/GDevelopServicesTestData';
import RecommendationList from '../../../../MainFrame/EditorContainers/HomePage/GetStartedSection/RecommendationList';
import PreferencesContext, {
initialPreferences,
} from '../../../../MainFrame/Preferences/PreferencesContext';
import inAppTutorialDecorator from '../../../InAppTutorialDecorator';
import { TutorialStateProvider } from '../../../../Tutorial/TutorialContext';
export default {
title: 'HomePage/GetStartedSectionSection/RecommendationList',
component: RecommendationList,
decorators: [paperDecorator, inAppTutorialDecorator],
};
export const Default = () => {
<PreferencesContext.Provider value={initialPreferences}>
<TutorialStateProvider>
<RecommendationList
onOpenProfile={action('onOpenProfile')}
authenticatedUser={fakeAuthenticatedUserWithNoSubscription}
selectInAppTutorial={action('selectInAppTutorial')}
onStartSurvey={null}
hasFilledSurveyAlready={false}
askToCloseProject={async () => true}
onCreateProjectFromExample={action('onCreateProjectFromExample')}
/>
</TutorialStateProvider>
</PreferencesContext.Provider>;
};
export const WithSurvey = () => {
return (
<PreferencesContext.Provider value={initialPreferences}>
<TutorialStateProvider>
<RecommendationList
onOpenProfile={action('onOpenProfile')}
authenticatedUser={fakeAuthenticatedUserWithNoSubscription}
selectInAppTutorial={action('selectInAppTutorial')}
onStartSurvey={action('onStartSurvey')}
hasFilledSurveyAlready={false}
askToCloseProject={async () => true}
onCreateProjectFromExample={action('onCreateProjectFromExample')}
/>
</TutorialStateProvider>
</PreferencesContext.Provider>
);
};
export const WithSurveyAlreadyFilled = () => {
return (
<PreferencesContext.Provider value={initialPreferences}>
<TutorialStateProvider>
<RecommendationList
onOpenProfile={action('onOpenProfile')}
authenticatedUser={fakeAuthenticatedUserWithNoSubscription}
selectInAppTutorial={action('selectInAppTutorial')}
onStartSurvey={action('onStartSurvey')}
hasFilledSurveyAlready={true}
askToCloseProject={async () => true}
onCreateProjectFromExample={action('onCreateProjectFromExample')}
/>
</TutorialStateProvider>
</PreferencesContext.Provider>
);
};

View File

@@ -147,6 +147,7 @@ const WrappedHomePage = ({
renderGamesPlatformFrame: () => null,
}}
onExtensionInstalled={action('onExtensionInstalled')}
onOpenAskAi={() => action('onOpenAskAi')()}
/>
</TutorialStateProvider>
</ExampleStoreStateProvider>

View File

@@ -67,9 +67,16 @@ export const Default = () => (
onOpenTemplateFromCourseChapter={action(
'onOpenTemplateFromCourseChapter'
)}
onBuyCourseChapterWithCredits={action(
'onBuyCourseChapterWithCredits'
onBuyCourseWithCredits={action('onBuyCourseWithCredits')}
onBuyCourse={action('onBuyCourse')}
purchasingCourseListingData={null}
setPurchasingCourseListingData={() => {}}
onOpenAskAi={() => action('onOpenAskAi')()}
onOpenNewProjectSetupDialog={action('onOpenNewProjectSetupDialog')}
onSelectPrivateGameTemplateListingData={action(
'onSelectPrivateGameTemplateListingData'
)}
onSelectExampleShortHeader={action('onSelectExampleShortHeader')}
/>
</TutorialContext.Provider>
</PreferencesContext.Provider>
@@ -108,9 +115,16 @@ export const NotAuthenticated = () => (
onOpenTemplateFromCourseChapter={action(
'onOpenTemplateFromCourseChapter'
)}
onBuyCourseChapterWithCredits={action(
'onBuyCourseChapterWithCredits'
onBuyCourseWithCredits={action('onBuyCourseWithCredits')}
onBuyCourse={action('onBuyCourse')}
purchasingCourseListingData={null}
setPurchasingCourseListingData={() => {}}
onOpenAskAi={() => action('onOpenAskAi')()}
onOpenNewProjectSetupDialog={action('onOpenNewProjectSetupDialog')}
onSelectPrivateGameTemplateListingData={action(
'onSelectPrivateGameTemplateListingData'
)}
onSelectExampleShortHeader={action('onSelectExampleShortHeader')}
/>
</TutorialContext.Provider>
</PreferencesContext.Provider>
@@ -151,9 +165,16 @@ export const EducationSubscriber = () => (
onOpenTemplateFromCourseChapter={action(
'onOpenTemplateFromCourseChapter'
)}
onBuyCourseChapterWithCredits={action(
'onBuyCourseChapterWithCredits'
onBuyCourseWithCredits={action('onBuyCourseWithCredits')}
onBuyCourse={action('onBuyCourse')}
purchasingCourseListingData={null}
setPurchasingCourseListingData={() => {}}
onOpenAskAi={() => action('onOpenAskAi')()}
onOpenNewProjectSetupDialog={action('onOpenNewProjectSetupDialog')}
onSelectPrivateGameTemplateListingData={action(
'onSelectPrivateGameTemplateListingData'
)}
onSelectExampleShortHeader={action('onSelectExampleShortHeader')}
/>
</TutorialContext.Provider>
</PreferencesContext.Provider>
@@ -194,9 +215,16 @@ export const EducationTeacher = () => (
onOpenTemplateFromCourseChapter={action(
'onOpenTemplateFromCourseChapter'
)}
onBuyCourseChapterWithCredits={action(
'onBuyCourseChapterWithCredits'
onBuyCourseWithCredits={action('onBuyCourseWithCredits')}
onBuyCourse={action('onBuyCourse')}
purchasingCourseListingData={null}
setPurchasingCourseListingData={() => {}}
onOpenAskAi={() => action('onOpenAskAi')()}
onOpenNewProjectSetupDialog={action('onOpenNewProjectSetupDialog')}
onSelectPrivateGameTemplateListingData={action(
'onSelectPrivateGameTemplateListingData'
)}
onSelectExampleShortHeader={action('onSelectExampleShortHeader')}
/>
</TutorialContext.Provider>
</PreferencesContext.Provider>
@@ -231,7 +259,16 @@ export const LoadingTutorials = () => (
onOpenTemplateFromCourseChapter={action(
'onOpenTemplateFromCourseChapter'
)}
onBuyCourseChapterWithCredits={action('onBuyCourseChapterWithCredits')}
onBuyCourseWithCredits={action('onBuyCourseWithCredits')}
onBuyCourse={action('onBuyCourse')}
purchasingCourseListingData={null}
setPurchasingCourseListingData={() => {}}
onOpenAskAi={() => action('onOpenAskAi')()}
onOpenNewProjectSetupDialog={action('onOpenNewProjectSetupDialog')}
onSelectPrivateGameTemplateListingData={action(
'onSelectPrivateGameTemplateListingData'
)}
onSelectExampleShortHeader={action('onSelectExampleShortHeader')}
/>
</TutorialContext.Provider>
</PreferencesContext.Provider>
@@ -265,7 +302,16 @@ export const LoadingCourses = () => (
onOpenTemplateFromCourseChapter={action(
'onOpenTemplateFromCourseChapter'
)}
onBuyCourseChapterWithCredits={action('onBuyCourseChapterWithCredits')}
onBuyCourseWithCredits={action('onBuyCourseWithCredits')}
onBuyCourse={action('onBuyCourse')}
purchasingCourseListingData={null}
setPurchasingCourseListingData={() => {}}
onOpenAskAi={() => action('onOpenAskAi')()}
onOpenNewProjectSetupDialog={action('onOpenNewProjectSetupDialog')}
onSelectPrivateGameTemplateListingData={action(
'onSelectPrivateGameTemplateListingData'
)}
onSelectExampleShortHeader={action('onSelectExampleShortHeader')}
/>
</TutorialContext.Provider>
</PreferencesContext.Provider>