mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
Thumbs.db
|
||||
.claude
|
||||
|
@@ -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,
|
||||
|
@@ -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,
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -79,7 +79,7 @@ const getShopItemsColumns = (
|
||||
case 'medium':
|
||||
return 2;
|
||||
case 'large':
|
||||
return 3;
|
||||
return 4;
|
||||
case 'xlarge':
|
||||
return 5;
|
||||
default:
|
||||
@@ -138,7 +138,7 @@ export const getAssetShortHeadersToDisplay = (
|
||||
return assetShortHeaders;
|
||||
};
|
||||
|
||||
const cellSpacing = 8;
|
||||
const cellSpacing = 10;
|
||||
const styles = {
|
||||
grid: {
|
||||
margin: '0 2px', // Remove the default margin of the grid but keep the horizontal padding for focus outline.
|
||||
@@ -469,7 +469,6 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
|
||||
// Don't show assets if filtering on asset packs.)
|
||||
if (hasAssetPackFiltersApplied && !openedAssetPack) return [];
|
||||
const assetSize = getAssetSize(windowSize);
|
||||
const margin = cellSpacing / 2;
|
||||
|
||||
return getAssetShortHeadersToDisplay(
|
||||
assetShortHeaders,
|
||||
@@ -481,7 +480,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
|
||||
onOpenDetails={() => onOpenDetails(assetShortHeader)}
|
||||
size={assetSize}
|
||||
key={assetShortHeader.id}
|
||||
margin={margin}
|
||||
margin={cellSpacing / 2}
|
||||
hideShortDescription={!!hideDetails}
|
||||
/>
|
||||
));
|
||||
@@ -749,7 +748,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
|
||||
cols={getShopItemsColumns(windowSize, isLandscape)}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
>
|
||||
{gameTemplateTiles}
|
||||
</GridList>
|
||||
@@ -765,7 +764,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
|
||||
cols={getShopItemsColumns(windowSize, isLandscape)}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
>
|
||||
{allBundlePackTiles}
|
||||
</GridList>
|
||||
@@ -781,7 +780,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
|
||||
cols={getShopItemsColumns(windowSize, isLandscape)}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
>
|
||||
{allStandAlonePackTiles}
|
||||
</GridList>
|
||||
@@ -945,12 +944,12 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
|
||||
</Column>
|
||||
) : null}
|
||||
{openedAssetPack && folderTiles.length ? (
|
||||
<Column justifyContent="center">
|
||||
<Column justifyContent="center" noMargin>
|
||||
<GridList
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
cols={getAssetFoldersColumns(windowSize, isLandscape)}
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
>
|
||||
{folderTiles}
|
||||
</GridList>
|
||||
|
@@ -22,10 +22,18 @@ import {
|
||||
isLinkedToStartingPointExampleShortHeader,
|
||||
isStartingPointExampleShortHeader,
|
||||
} from '../../ProjectCreation/EmptyAndStartingPointProjects';
|
||||
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
|
||||
import {
|
||||
useResponsiveWindowSize,
|
||||
type WindowSizeType,
|
||||
} from '../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
|
||||
|
||||
const ITEMS_SPACING = 5;
|
||||
const styles = {
|
||||
grid: {
|
||||
margin: 0,
|
||||
textAlign: 'center',
|
||||
width: `calc(100% + ${2 * ITEMS_SPACING}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
|
||||
// Remove the scroll capability of the grid, the scroll view handles it.
|
||||
overflow: 'unset',
|
||||
},
|
||||
@@ -59,12 +67,13 @@ type Props = {|
|
||||
i18n: I18nType,
|
||||
onlyShowGames?: boolean,
|
||||
hideStartingPoints?: boolean,
|
||||
columnsCount: number,
|
||||
rowToInsert?: {|
|
||||
row: number,
|
||||
element: React.Node,
|
||||
|},
|
||||
getColumnsFromWindowSize: (
|
||||
windowSize: WindowSizeType,
|
||||
isLandscape: boolean
|
||||
) => number,
|
||||
hideSearch?: boolean,
|
||||
limitRowsTo?: number,
|
||||
hidePremiumTemplates?: boolean,
|
||||
|};
|
||||
|
||||
const ExampleStore = ({
|
||||
@@ -73,10 +82,15 @@ const ExampleStore = ({
|
||||
i18n,
|
||||
onlyShowGames,
|
||||
hideStartingPoints,
|
||||
columnsCount,
|
||||
rowToInsert,
|
||||
getColumnsFromWindowSize,
|
||||
hideSearch,
|
||||
limitRowsTo,
|
||||
hidePremiumTemplates,
|
||||
}: Props) => {
|
||||
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
|
||||
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
|
||||
const { windowSize, isLandscape } = useResponsiveWindowSize();
|
||||
|
||||
const { receivedGameTemplates } = React.useContext(AuthenticatedUserContext);
|
||||
const {
|
||||
exampleShortHeaders: allExampleShortHeaders,
|
||||
@@ -95,6 +109,8 @@ const ExampleStore = ({
|
||||
const [localSearchText, setLocalSearchText] = React.useState(
|
||||
exampleStoreSearchText
|
||||
);
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
const columnsCount = getColumnsFromWindowSize(windowSize, isLandscape);
|
||||
|
||||
const shouldAutofocusSearchbar = useShouldAutofocusInput();
|
||||
const searchBarRef = React.useRef<?SearchBarInterface>(null);
|
||||
@@ -143,14 +159,15 @@ const ExampleStore = ({
|
||||
() => {
|
||||
return getExampleAndTemplateTiles({
|
||||
receivedGameTemplates,
|
||||
privateGameTemplateListingDatas: privateGameTemplateListingDatasSearchResults
|
||||
? privateGameTemplateListingDatasSearchResults
|
||||
.map(({ item }) => item)
|
||||
.filter(
|
||||
privateGameTemplateListingData =>
|
||||
!onlyShowGames || gameFilter(privateGameTemplateListingData)
|
||||
)
|
||||
: [],
|
||||
privateGameTemplateListingDatas:
|
||||
privateGameTemplateListingDatasSearchResults && !hidePremiumTemplates
|
||||
? privateGameTemplateListingDatasSearchResults
|
||||
.map(({ item }) => item)
|
||||
.filter(
|
||||
privateGameTemplateListingData =>
|
||||
!onlyShowGames || gameFilter(privateGameTemplateListingData)
|
||||
)
|
||||
: [],
|
||||
exampleShortHeaders: exampleShortHeadersSearchResults
|
||||
? exampleShortHeadersSearchResults
|
||||
.map(({ item }) => item)
|
||||
@@ -183,9 +200,10 @@ const ExampleStore = ({
|
||||
onSelectExampleShortHeader(exampleShortHeader);
|
||||
},
|
||||
i18n,
|
||||
gdevelopTheme,
|
||||
privateGameTemplatesPeriodicity: 1,
|
||||
showOwnedGameTemplatesFirst: true,
|
||||
}).allGridItems;
|
||||
});
|
||||
},
|
||||
[
|
||||
receivedGameTemplates,
|
||||
@@ -194,55 +212,14 @@ const ExampleStore = ({
|
||||
onSelectPrivateGameTemplateListingData,
|
||||
onSelectExampleShortHeader,
|
||||
i18n,
|
||||
gdevelopTheme,
|
||||
onlyShowGames,
|
||||
hideStartingPoints,
|
||||
allExampleShortHeaders,
|
||||
hidePremiumTemplates,
|
||||
]
|
||||
);
|
||||
|
||||
const nodesToDisplay: React.Node[] = React.useMemo(
|
||||
() => {
|
||||
const numberOfTilesToDisplayUntilRowToInsert = rowToInsert
|
||||
? rowToInsert.row * columnsCount
|
||||
: 0;
|
||||
const firstTiles = resultTiles.slice(
|
||||
0,
|
||||
numberOfTilesToDisplayUntilRowToInsert
|
||||
);
|
||||
const lastTiles = resultTiles.slice(
|
||||
numberOfTilesToDisplayUntilRowToInsert
|
||||
);
|
||||
return [
|
||||
firstTiles.length > 0 ? (
|
||||
<GridList
|
||||
cols={columnsCount}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={2}
|
||||
key="first-tiles"
|
||||
>
|
||||
{firstTiles}
|
||||
</GridList>
|
||||
) : null,
|
||||
rowToInsert ? (
|
||||
<Line key="inserted-row">{rowToInsert.element}</Line>
|
||||
) : null,
|
||||
lastTiles.length > 0 ? (
|
||||
<GridList
|
||||
cols={columnsCount}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={2}
|
||||
key="last-tiles"
|
||||
>
|
||||
{lastTiles}
|
||||
</GridList>
|
||||
) : null,
|
||||
].filter(Boolean);
|
||||
},
|
||||
[columnsCount, rowToInsert, resultTiles]
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Column expand noMargin>
|
||||
@@ -267,11 +244,24 @@ const ExampleStore = ({
|
||||
No results returned for your search. Try something else!
|
||||
</Trans>
|
||||
</BackgroundText>
|
||||
{rowToInsert && <Line>{rowToInsert.element}</Line>}
|
||||
</Column>
|
||||
) : (
|
||||
<ColumnStackLayout noMargin expand>
|
||||
{nodesToDisplay}
|
||||
<GridList
|
||||
cols={columnsCount}
|
||||
style={{
|
||||
...styles.grid,
|
||||
// Avoid tiles taking too much space on large screens.
|
||||
maxWidth: MAX_SECTION_WIDTH,
|
||||
}}
|
||||
cellHeight="auto"
|
||||
spacing={ITEMS_SPACING * 2}
|
||||
>
|
||||
{resultTiles.slice(
|
||||
0,
|
||||
limitRowsTo ? limitRowsTo * columnsCount : Infinity
|
||||
)}
|
||||
</GridList>
|
||||
</ColumnStackLayout>
|
||||
)}
|
||||
</Column>
|
||||
|
@@ -64,8 +64,9 @@ import Window from '../../Utils/Window';
|
||||
import RaisedButton from '../../UI/RaisedButton';
|
||||
import PrivateAssetPackPurchaseDialog from './PrivateAssetPackPurchaseDialog';
|
||||
import PublicProfileContext from '../../Profile/PublicProfileContext';
|
||||
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
|
||||
|
||||
const cellSpacing = 8;
|
||||
const cellSpacing = 10;
|
||||
|
||||
const getPackColumns = (windowSize: WindowSizeType, isLandscape: boolean) => {
|
||||
switch (windowSize) {
|
||||
@@ -76,7 +77,7 @@ const getPackColumns = (windowSize: WindowSizeType, isLandscape: boolean) => {
|
||||
case 'large':
|
||||
return 4;
|
||||
case 'xlarge':
|
||||
return 5;
|
||||
return 6;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
@@ -108,11 +109,16 @@ const contentTypeToMessageDescriptor: {
|
||||
audio: t`audios`,
|
||||
};
|
||||
|
||||
const MAX_COLUMNS = getPackColumns('xlarge', true);
|
||||
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
|
||||
const styles = {
|
||||
disabledText: { opacity: 0.6 },
|
||||
scrollview: { overflowX: 'hidden' },
|
||||
grid: {
|
||||
margin: '0 2px', // Remove the default margin of the grid but keep the horizontal padding for focus outline.
|
||||
// Avoid tiles taking too much space on large screens.
|
||||
maxWidth: MAX_SECTION_WIDTH,
|
||||
overflow: 'hidden',
|
||||
width: `calc(100% + ${cellSpacing}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
|
||||
},
|
||||
leftColumnContainer: {
|
||||
flex: 3,
|
||||
@@ -211,10 +217,9 @@ const PrivateAssetPackInformationPage = ({
|
||||
sellerPublicProfile,
|
||||
setSellerPublicProfile,
|
||||
] = React.useState<?UserPublicProfile>(null);
|
||||
const [
|
||||
displayPasswordPrompt,
|
||||
setDisplayPasswordPrompt,
|
||||
] = React.useState<boolean>(false);
|
||||
const [displayPasswordPrompt, setDisplayPasswordPrompt] = React.useState<
|
||||
'redeem' | 'credits' | null
|
||||
>(null);
|
||||
const [password, setPassword] = React.useState<string>('');
|
||||
const [errorText, setErrorText] = React.useState<?React.Node>(null);
|
||||
const { isLandscape, isMediumScreen, windowSize } = useResponsiveWindowSize();
|
||||
@@ -297,10 +302,16 @@ const PrivateAssetPackInformationPage = ({
|
||||
|
||||
const onWillRedeemAssetPack = () => {
|
||||
// Password is required in dev environment only so that one cannot freely claim asset packs.
|
||||
if (Window.isDev()) setDisplayPasswordPrompt(true);
|
||||
if (Window.isDev()) setDisplayPasswordPrompt('redeem');
|
||||
else onRedeemAssetPack();
|
||||
};
|
||||
|
||||
const onWillBuyWithCredits = () => {
|
||||
// Password is required in dev environment only so that one cannot freely claim asset packs.
|
||||
if (Window.isDev()) setDisplayPasswordPrompt('credits');
|
||||
else onClickBuyWithCredits();
|
||||
};
|
||||
|
||||
const onRedeemAssetPack = React.useCallback(
|
||||
async () => {
|
||||
if (!profile || isRedeemingProduct) return;
|
||||
@@ -704,7 +715,7 @@ const PrivateAssetPackInformationPage = ({
|
||||
simulateAppStoreProduct={simulateAppStoreProduct}
|
||||
isAlreadyReceived={isAlreadyReceived}
|
||||
onClickBuy={onClickBuy}
|
||||
onClickBuyWithCredits={onClickBuyWithCredits}
|
||||
onClickBuyWithCredits={onWillBuyWithCredits}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -764,7 +775,7 @@ const PrivateAssetPackInformationPage = ({
|
||||
<GridList
|
||||
cols={getPackColumns(windowSize, isLandscape)}
|
||||
cellHeight="auto"
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
style={styles.grid}
|
||||
>
|
||||
{packsIncludedInBundleTiles}
|
||||
@@ -784,7 +795,7 @@ const PrivateAssetPackInformationPage = ({
|
||||
<GridList
|
||||
cols={getPackColumns(windowSize, isLandscape)}
|
||||
cellHeight="auto"
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
style={styles.grid}
|
||||
>
|
||||
{otherPacksFromTheSameAuthorTiles}
|
||||
@@ -797,8 +808,12 @@ const PrivateAssetPackInformationPage = ({
|
||||
) : null}
|
||||
{displayPasswordPrompt && (
|
||||
<PasswordPromptDialog
|
||||
onApply={onRedeemAssetPack}
|
||||
onClose={() => setDisplayPasswordPrompt(false)}
|
||||
onApply={
|
||||
displayPasswordPrompt === 'redeem'
|
||||
? onWillRedeemAssetPack
|
||||
: onClickBuyWithCredits
|
||||
}
|
||||
onClose={() => setDisplayPasswordPrompt(null)}
|
||||
passwordValue={password}
|
||||
setPasswordValue={setPassword}
|
||||
/>
|
||||
|
@@ -59,8 +59,9 @@ import Play from '../../UI/CustomSvgIcons/Play';
|
||||
import PrivateGameTemplatePurchaseDialog from './PrivateGameTemplatePurchaseDialog';
|
||||
import PasswordPromptDialog from '../PasswordPromptDialog';
|
||||
import PublicProfileContext from '../../Profile/PublicProfileContext';
|
||||
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
|
||||
|
||||
const cellSpacing = 8;
|
||||
const cellSpacing = 10;
|
||||
|
||||
const getTemplateColumns = (
|
||||
windowSize: WindowSizeType,
|
||||
@@ -74,17 +75,21 @@ const getTemplateColumns = (
|
||||
case 'large':
|
||||
return 4;
|
||||
case 'xlarge':
|
||||
return 5;
|
||||
return 6;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
const MAX_COLUMNS = getTemplateColumns('xlarge', true);
|
||||
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
|
||||
const styles = {
|
||||
disabledText: { opacity: 0.6 },
|
||||
scrollview: { overflowX: 'hidden' },
|
||||
grid: {
|
||||
margin: '0 2px', // Remove the default margin of the grid but keep the horizontal padding for focus outline.
|
||||
// Avoid tiles taking too much space on large screens.
|
||||
maxWidth: MAX_SECTION_WIDTH,
|
||||
overflow: 'hidden',
|
||||
width: `calc(100% + ${cellSpacing}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
|
||||
},
|
||||
leftColumnContainer: {
|
||||
flex: 3,
|
||||
@@ -558,7 +563,7 @@ const PrivateGameTemplateInformationPage = ({
|
||||
simulateAppStoreProduct={simulateAppStoreProduct}
|
||||
isAlreadyReceived={isAlreadyReceived}
|
||||
onClickBuy={onClickBuy}
|
||||
onClickBuyWithCredits={onClickBuyWithCredits}
|
||||
onClickBuyWithCredits={onWillBuyWithCredits}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -605,7 +610,7 @@ const PrivateGameTemplateInformationPage = ({
|
||||
<GridList
|
||||
cols={getTemplateColumns(windowSize, isLandscape)}
|
||||
cellHeight="auto"
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
style={styles.grid}
|
||||
>
|
||||
{templatesIncludedInBundleTiles}
|
||||
@@ -625,7 +630,7 @@ const PrivateGameTemplateInformationPage = ({
|
||||
<GridList
|
||||
cols={getTemplateColumns(windowSize, isLandscape)}
|
||||
cellHeight="auto"
|
||||
spacing={cellSpacing / 2}
|
||||
spacing={cellSpacing}
|
||||
style={styles.grid}
|
||||
>
|
||||
{otherTemplatesFromTheSameAuthorTiles}
|
||||
@@ -638,7 +643,7 @@ const PrivateGameTemplateInformationPage = ({
|
||||
) : null}
|
||||
{displayPasswordPrompt && (
|
||||
<PasswordPromptDialog
|
||||
onApply={onWillBuyWithCredits}
|
||||
onApply={onClickBuyWithCredits}
|
||||
onClose={() => setDisplayPasswordPrompt(false)}
|
||||
passwordValue={password}
|
||||
setPasswordValue={setPassword}
|
||||
|
@@ -4,6 +4,7 @@ import { type I18n as I18nType } from '@lingui/core';
|
||||
import {
|
||||
type PrivateAssetPackListingData,
|
||||
type PrivateGameTemplateListingData,
|
||||
type CourseListingData,
|
||||
type Purchase,
|
||||
} from '../Utils/GDevelopServices/Shop';
|
||||
import {
|
||||
@@ -283,7 +284,10 @@ export const getUserProductPurchaseUsageType = <
|
||||
};
|
||||
|
||||
export const PurchaseProductButtons = <
|
||||
T: PrivateAssetPackListingData | PrivateGameTemplateListingData
|
||||
T:
|
||||
| PrivateAssetPackListingData
|
||||
| PrivateGameTemplateListingData
|
||||
| CourseListingData
|
||||
>({
|
||||
productListingData,
|
||||
selectedUsageType,
|
||||
@@ -300,8 +304,8 @@ export const PurchaseProductButtons = <
|
||||
simulateAppStoreProduct?: boolean,
|
||||
i18n: I18nType,
|
||||
isAlreadyReceived: boolean,
|
||||
onClickBuy: () => Promise<void>,
|
||||
onClickBuyWithCredits: () => Promise<void>,
|
||||
onClickBuy: () => void | Promise<void>,
|
||||
onClickBuyWithCredits: () => void | Promise<void>,
|
||||
|}) => {
|
||||
const { authenticated } = React.useContext(AuthenticatedUserContext);
|
||||
const shouldUseOrSimulateAppStoreProduct =
|
||||
|
@@ -8,6 +8,7 @@ import {
|
||||
type PrivateAssetPackListingData,
|
||||
type PrivateGameTemplateListingData,
|
||||
type CreditsPackageListingData,
|
||||
type CourseListingData,
|
||||
} from '../Utils/GDevelopServices/Shop';
|
||||
import {
|
||||
shouldUseAppStoreProduct,
|
||||
@@ -16,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,
|
||||
|
@@ -12,9 +12,6 @@ import {
|
||||
} from '../Utils/GDevelopServices/Shop';
|
||||
import type { ExampleShortHeader } from '../Utils/GDevelopServices/Example';
|
||||
import GridListTile from '@material-ui/core/GridListTile';
|
||||
import createStyles from '@material-ui/core/styles/createStyles';
|
||||
import makeStyles from '@material-ui/core/styles/makeStyles';
|
||||
import { shouldValidate } from '../UI/KeyboardShortcuts/InteractionKeys';
|
||||
import { CorsAwareImage } from '../UI/CorsAwareImage';
|
||||
import { textEllipsisStyle } from '../UI/TextEllipsis';
|
||||
import { Column, Line, Spacer } from '../UI/Grid';
|
||||
@@ -30,6 +27,7 @@ import { ResponsiveLineStackLayout } from '../UI/Layout';
|
||||
import Skeleton from '@material-ui/lab/Skeleton';
|
||||
import EmptyMessage from '../UI/EmptyMessage';
|
||||
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { CardWidget } from '../MainFrame/EditorContainers/HomePage/CardWidget';
|
||||
|
||||
const styles = {
|
||||
priceTagContainer: {
|
||||
@@ -81,11 +79,6 @@ const styles = {
|
||||
minWidth: 200,
|
||||
margin: 4,
|
||||
},
|
||||
paper: {
|
||||
margin: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
packTitle: {
|
||||
...textEllipsisStyle,
|
||||
overflowWrap: 'break-word',
|
||||
@@ -112,20 +105,6 @@ const styles = {
|
||||
},
|
||||
};
|
||||
|
||||
const useStylesForGridListItem = ({ disabled }: {| disabled?: boolean |}) =>
|
||||
makeStyles(theme =>
|
||||
createStyles({
|
||||
tile: !disabled
|
||||
? {
|
||||
transition: 'transform 0.3s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.02)',
|
||||
},
|
||||
}
|
||||
: {},
|
||||
})
|
||||
)();
|
||||
|
||||
export const AssetCardTile = ({
|
||||
assetShortHeader,
|
||||
onOpenDetails,
|
||||
@@ -141,30 +120,21 @@ export const AssetCardTile = ({
|
||||
hideShortDescription?: boolean,
|
||||
disabled?: boolean,
|
||||
|}) => {
|
||||
const classesForGridListItem = useStylesForGridListItem({
|
||||
disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<GridListTile
|
||||
classes={classesForGridListItem}
|
||||
tabIndex={0}
|
||||
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
|
||||
if (shouldValidate(event) && !disabled) {
|
||||
onOpenDetails();
|
||||
}
|
||||
}}
|
||||
onClick={!disabled ? onOpenDetails : undefined}
|
||||
style={{
|
||||
margin,
|
||||
}}
|
||||
>
|
||||
<AssetCard
|
||||
id={`asset-card-${assetShortHeader.name.replace(/\s/g, '-')}`}
|
||||
assetShortHeader={assetShortHeader}
|
||||
size={size}
|
||||
hideShortDescription={hideShortDescription}
|
||||
/>
|
||||
<GridListTile style={{ margin }}>
|
||||
<CardWidget
|
||||
onClick={!disabled ? onOpenDetails : undefined}
|
||||
size="large"
|
||||
disabled={disabled}
|
||||
noBorder
|
||||
>
|
||||
<AssetCard
|
||||
id={`asset-card-${assetShortHeader.name.replace(/\s/g, '-')}`}
|
||||
assetShortHeader={assetShortHeader}
|
||||
size={size}
|
||||
hideShortDescription={hideShortDescription}
|
||||
/>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
);
|
||||
};
|
||||
@@ -181,29 +151,27 @@ export const AssetFolderTile = ({
|
||||
style?: any,
|
||||
disabled?: boolean,
|
||||
|}) => {
|
||||
const classesForGridListItem = useStylesForGridListItem({
|
||||
disabled,
|
||||
});
|
||||
return (
|
||||
<GridListTile
|
||||
classes={classesForGridListItem}
|
||||
tabIndex={0}
|
||||
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
|
||||
if (shouldValidate(event) && !disabled) {
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
style={style}
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
>
|
||||
<Column noMargin id={`asset-folder-${tag.replace(/\s/g, '-')}`}>
|
||||
<Line alignItems="center">
|
||||
<FolderIcon style={styles.folderIcon} />
|
||||
<Text noMargin style={styles.folderTitle} size="sub-title">
|
||||
{capitalize(tag)}
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
<GridListTile style={style}>
|
||||
<CardWidget
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
size="large"
|
||||
disabled={disabled}
|
||||
noBorder
|
||||
>
|
||||
<Column
|
||||
noMargin
|
||||
id={`asset-folder-${tag.replace(/\s/g, '-')}`}
|
||||
noOverflowParent
|
||||
>
|
||||
<Line alignItems="center">
|
||||
<FolderIcon style={styles.folderIcon} />
|
||||
<Text noMargin style={styles.folderTitle} size="sub-title">
|
||||
{capitalize(tag)}
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
);
|
||||
};
|
||||
@@ -220,46 +188,46 @@ export const PublicAssetPackTile = ({
|
||||
style?: any,
|
||||
disabled?: boolean,
|
||||
|}) => {
|
||||
const classesForGridListItem = useStylesForGridListItem({
|
||||
disabled,
|
||||
});
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
return (
|
||||
<GridListTile
|
||||
classes={classesForGridListItem}
|
||||
tabIndex={0}
|
||||
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
|
||||
if (shouldValidate(event) && !disabled) {
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
style={style}
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
>
|
||||
<div
|
||||
id={`asset-pack-${assetPack.tag.replace(/\s/g, '-')}`}
|
||||
style={styles.paper}
|
||||
<GridListTile style={style}>
|
||||
<CardWidget
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
size="large"
|
||||
disabled={disabled}
|
||||
noBorder
|
||||
>
|
||||
<CorsAwareImage
|
||||
key={assetPack.name}
|
||||
style={styles.previewImage}
|
||||
src={assetPack.thumbnailUrl}
|
||||
alt={`Preview image of asset pack ${assetPack.name}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<Column>
|
||||
<Line justifyContent="space-between" noMargin>
|
||||
<Text style={styles.packTitle} size="body2">
|
||||
{assetPack.name}
|
||||
</Text>
|
||||
<Text style={styles.packTitle} color="primary" size="body2">
|
||||
<Trans>{assetPack.assetsCount} Assets</Trans>
|
||||
{assetPack.userFriendlyPrice
|
||||
? ' - ' + assetPack.userFriendlyPrice
|
||||
: null}
|
||||
</Text>
|
||||
</Line>
|
||||
<Column
|
||||
noMargin
|
||||
expand
|
||||
id={`asset-pack-${assetPack.tag.replace(/\s/g, '-')}`}
|
||||
noOverflowParent
|
||||
>
|
||||
<CorsAwareImage
|
||||
key={assetPack.name}
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
background: gdevelopTheme.paper.backgroundColor.light,
|
||||
}}
|
||||
src={assetPack.thumbnailUrl}
|
||||
alt={`Preview image of asset pack ${assetPack.name}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<Column>
|
||||
<Line justifyContent="space-between" noMargin>
|
||||
<Text style={styles.packTitle} size="body2">
|
||||
{assetPack.name}
|
||||
</Text>
|
||||
<Text style={styles.packTitle} color="primary" size="body2">
|
||||
<Trans>{assetPack.assetsCount} Assets</Trans>
|
||||
{assetPack.userFriendlyPrice
|
||||
? ' - ' + assetPack.userFriendlyPrice
|
||||
: null}
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
</Column>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
);
|
||||
};
|
||||
@@ -278,61 +246,59 @@ export const PrivateAssetPackTile = ({
|
||||
owned: boolean,
|
||||
disabled?: boolean,
|
||||
|}) => {
|
||||
const classesForGridListItem = useStylesForGridListItem({
|
||||
disabled,
|
||||
});
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
return (
|
||||
<GridListTile
|
||||
classes={classesForGridListItem}
|
||||
tabIndex={0}
|
||||
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
|
||||
if (shouldValidate(event) && !disabled) {
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
style={style}
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.thumbnailContainer}>
|
||||
<CorsAwareImage
|
||||
key={assetPackListingData.name}
|
||||
style={styles.previewImage}
|
||||
src={assetPackListingData.thumbnailUrls[0]}
|
||||
alt={`Preview image of asset pack ${assetPackListingData.name}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
{assetPackListingData.redeemConditions && !owned && (
|
||||
<div style={styles.redeemableContainer}>
|
||||
<img
|
||||
src="res/small-diamond.svg"
|
||||
style={styles.redeemableDiamondIcon}
|
||||
alt="diamond"
|
||||
/>
|
||||
<Text color="inherit" noMargin>
|
||||
<Trans>Claim this pack</Trans>
|
||||
<GridListTile style={style}>
|
||||
<CardWidget
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
size="large"
|
||||
disabled={disabled}
|
||||
noBorder
|
||||
>
|
||||
<Column noMargin expand noOverflowParent>
|
||||
<div style={styles.thumbnailContainer}>
|
||||
<CorsAwareImage
|
||||
key={assetPackListingData.name}
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
background: gdevelopTheme.paper.backgroundColor.light,
|
||||
}}
|
||||
src={assetPackListingData.thumbnailUrls[0]}
|
||||
alt={`Preview image of asset pack ${assetPackListingData.name}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
{assetPackListingData.redeemConditions && !owned && (
|
||||
<div style={styles.redeemableContainer}>
|
||||
<img
|
||||
src="res/small-diamond.svg"
|
||||
style={styles.redeemableDiamondIcon}
|
||||
alt="diamond"
|
||||
/>
|
||||
<Text color="inherit" noMargin>
|
||||
<Trans>Claim this pack</Trans>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={styles.priceTagContainer}>
|
||||
<ProductPriceTag
|
||||
productListingData={assetPackListingData}
|
||||
withOverlay
|
||||
owned={owned}
|
||||
/>
|
||||
</div>
|
||||
<Column>
|
||||
<Line justifyContent="space-between" noMargin>
|
||||
<Text style={styles.packTitle} size="body2">
|
||||
{assetPackListingData.name}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={styles.priceTagContainer}>
|
||||
<ProductPriceTag
|
||||
productListingData={assetPackListingData}
|
||||
withOverlay
|
||||
owned={owned}
|
||||
/>
|
||||
</div>
|
||||
<Column>
|
||||
<Line justifyContent="space-between" noMargin>
|
||||
<Text style={styles.packTitle} size="body2">
|
||||
{assetPackListingData.name}
|
||||
</Text>
|
||||
<Text style={styles.packTitle} color="primary" size="body2">
|
||||
{assetPackListingData.description}
|
||||
</Text>
|
||||
</Line>
|
||||
<Text style={styles.packTitle} color="primary" size="body2">
|
||||
{assetPackListingData.description}
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
</Column>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
);
|
||||
};
|
||||
@@ -455,40 +421,34 @@ export const CategoryTile = ({
|
||||
style?: any,
|
||||
disabled?: boolean,
|
||||
|}) => {
|
||||
const classesForGridListItem = useStylesForGridListItem({
|
||||
disabled,
|
||||
});
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
return (
|
||||
<GridListTile
|
||||
classes={classesForGridListItem}
|
||||
tabIndex={0}
|
||||
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
|
||||
if (shouldValidate(event) && !disabled) {
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
style={style}
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
>
|
||||
<div id={id} style={styles.paper}>
|
||||
<CorsAwareImage
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
background: gdevelopTheme.palette.primary,
|
||||
}}
|
||||
src={imageSource}
|
||||
alt={imageAlt}
|
||||
// No lazy loading because categories are the first seen tiles in the shop.
|
||||
/>
|
||||
<Column>
|
||||
<Line justifyContent="center" noMargin>
|
||||
<Text style={styles.packTitle} size="sub-title">
|
||||
{title}
|
||||
</Text>
|
||||
</Line>
|
||||
<GridListTile style={style}>
|
||||
<CardWidget
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
size="large"
|
||||
disabled={disabled}
|
||||
noBorder
|
||||
>
|
||||
<Column noMargin alignItems="center" expand id={id} noOverflowParent>
|
||||
<CorsAwareImage
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
background: gdevelopTheme.palette.primary,
|
||||
}}
|
||||
src={imageSource}
|
||||
alt={imageAlt}
|
||||
// No lazy loading because categories are the first seen tiles in the shop.
|
||||
/>
|
||||
<Column>
|
||||
<Line justifyContent="center" noMargin>
|
||||
<Text style={styles.packTitle} size="sub-title">
|
||||
{title}
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
</Column>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
);
|
||||
};
|
||||
@@ -508,47 +468,45 @@ export const PrivateGameTemplateTile = ({
|
||||
disabled?: boolean,
|
||||
|}) => {
|
||||
const { isMobile } = useResponsiveWindowSize();
|
||||
const classesForGridListItem = useStylesForGridListItem({
|
||||
disabled,
|
||||
});
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
return (
|
||||
<GridListTile
|
||||
classes={classesForGridListItem}
|
||||
tabIndex={0}
|
||||
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
|
||||
if (shouldValidate(event) && !disabled) {
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
style={style}
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
>
|
||||
<div style={styles.paper}>
|
||||
<CorsAwareImage
|
||||
key={privateGameTemplateListingData.name}
|
||||
style={styles.previewImage}
|
||||
src={privateGameTemplateListingData.thumbnailUrls[0]}
|
||||
alt={`Preview image of game template ${
|
||||
privateGameTemplateListingData.name
|
||||
}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div style={styles.priceTagContainer}>
|
||||
<ProductPriceTag
|
||||
productListingData={privateGameTemplateListingData}
|
||||
withOverlay
|
||||
owned={owned}
|
||||
<GridListTile style={style}>
|
||||
<CardWidget
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
size="large"
|
||||
disabled={disabled}
|
||||
noBorder
|
||||
>
|
||||
<Column noMargin expand noOverflowParent>
|
||||
<CorsAwareImage
|
||||
key={privateGameTemplateListingData.name}
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
background: gdevelopTheme.paper.backgroundColor.light,
|
||||
}}
|
||||
src={privateGameTemplateListingData.thumbnailUrls[0]}
|
||||
alt={`Preview image of game template ${
|
||||
privateGameTemplateListingData.name
|
||||
}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<Column>
|
||||
{isMobile && <Spacer />}
|
||||
<Line justifyContent="flex-start" noMargin>
|
||||
<Text style={styles.packTitle} size="body2" noMargin={isMobile}>
|
||||
{privateGameTemplateListingData.name}
|
||||
</Text>
|
||||
</Line>
|
||||
<div style={styles.priceTagContainer}>
|
||||
<ProductPriceTag
|
||||
productListingData={privateGameTemplateListingData}
|
||||
withOverlay
|
||||
owned={owned}
|
||||
/>
|
||||
</div>
|
||||
<Column>
|
||||
{isMobile && <Spacer />}
|
||||
<Line justifyContent="flex-start" noMargin>
|
||||
<Text style={styles.packTitle} size="body2" noMargin={isMobile}>
|
||||
{privateGameTemplateListingData.name}
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
</Column>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
);
|
||||
};
|
||||
@@ -572,6 +530,7 @@ export const ExampleTile = ({
|
||||
disabled?: boolean,
|
||||
|}) => {
|
||||
const { isMobile } = useResponsiveWindowSize();
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
const thumbnailImgUrl = React.useMemo(
|
||||
() => {
|
||||
if (!exampleShortHeader) return '';
|
||||
@@ -587,66 +546,68 @@ export const ExampleTile = ({
|
||||
[exampleShortHeader, useQuickCustomizationThumbnail]
|
||||
);
|
||||
|
||||
const classesForGridListItem = useStylesForGridListItem({ disabled });
|
||||
return (
|
||||
<GridListTile
|
||||
classes={classesForGridListItem}
|
||||
tabIndex={0}
|
||||
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
|
||||
if (shouldValidate(event) && !disabled) {
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
style={style}
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
>
|
||||
<div style={styles.paper}>
|
||||
{exampleShortHeader ? (
|
||||
thumbnailImgUrl ? (
|
||||
<CorsAwareImage
|
||||
key={exampleShortHeader.name}
|
||||
style={styles.previewImage}
|
||||
src={thumbnailImgUrl}
|
||||
alt={`Preview image of example ${exampleShortHeader.name}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<GridListTile style={style}>
|
||||
<CardWidget
|
||||
onClick={!disabled ? onSelect : undefined}
|
||||
size="large"
|
||||
disabled={disabled}
|
||||
noBorder
|
||||
>
|
||||
<Column noMargin expand noOverflowParent>
|
||||
{exampleShortHeader ? (
|
||||
thumbnailImgUrl ? (
|
||||
<CorsAwareImage
|
||||
key={exampleShortHeader.name}
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
background: gdevelopTheme.paper.backgroundColor.light,
|
||||
}}
|
||||
src={thumbnailImgUrl}
|
||||
alt={`Preview image of example ${exampleShortHeader.name}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<EmptyMessage
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{exampleShortHeader.name}
|
||||
</EmptyMessage>
|
||||
)
|
||||
) : (
|
||||
<EmptyMessage
|
||||
style={{
|
||||
...styles.previewImage,
|
||||
display: 'flex',
|
||||
}}
|
||||
<Skeleton
|
||||
variant="rect"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={styles.dataLoadingSkeleton}
|
||||
/>
|
||||
)}
|
||||
<Column>
|
||||
{isMobile && <Spacer />}
|
||||
<Line
|
||||
justifyContent={centerTitle ? 'center' : 'flex-start'}
|
||||
noMargin
|
||||
>
|
||||
{exampleShortHeader.name}
|
||||
</EmptyMessage>
|
||||
)
|
||||
) : (
|
||||
<Skeleton
|
||||
variant="rect"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={styles.dataLoadingSkeleton}
|
||||
/>
|
||||
)}
|
||||
<Column>
|
||||
{isMobile && <Spacer />}
|
||||
<Line justifyContent={centerTitle ? 'center' : 'flex-start'} noMargin>
|
||||
<Text
|
||||
style={styles.packTitle}
|
||||
size="body2"
|
||||
hidden={!exampleShortHeader}
|
||||
noMargin={isMobile}
|
||||
>
|
||||
{customTitle
|
||||
? customTitle
|
||||
: exampleShortHeader
|
||||
? exampleShortHeader.name
|
||||
: // Use some placeholder text to avoid layout shift while loading content.
|
||||
'Abcdef123'}
|
||||
</Text>
|
||||
</Line>
|
||||
<Text
|
||||
style={styles.packTitle}
|
||||
size="body2"
|
||||
hidden={!exampleShortHeader}
|
||||
noMargin={isMobile}
|
||||
>
|
||||
{customTitle
|
||||
? customTitle
|
||||
: exampleShortHeader
|
||||
? exampleShortHeader.name
|
||||
: // Use some placeholder text to avoid layout shift while loading content.
|
||||
'Abcdef123'}
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
</Column>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
);
|
||||
};
|
||||
|
@@ -3,13 +3,15 @@
|
||||
import * as React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import type { CourseChapter } from '../Utils/GDevelopServices/Asset';
|
||||
import type { CourseChapter, Course } from '../Utils/GDevelopServices/Asset';
|
||||
import Text from '../UI/Text';
|
||||
import { LineStackLayout } from '../UI/Layout';
|
||||
import CheckCircle from '../UI/CustomSvgIcons/CheckCircle';
|
||||
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
|
||||
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import type { CourseChapterCompletion } from '../MainFrame/EditorContainers/HomePage/UseCourses';
|
||||
import Chip from '../UI/Chip';
|
||||
import { freeChipStyle } from './CoursePreviewBanner';
|
||||
|
||||
const styles = {
|
||||
titleContainer: {
|
||||
@@ -24,13 +26,14 @@ const styles = {
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
course: Course,
|
||||
chapterIndex: number,
|
||||
courseChapter: CourseChapter,
|
||||
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
|
||||
|};
|
||||
|
||||
const CourseChapterTitle = React.forwardRef<Props, HTMLDivElement>(
|
||||
({ chapterIndex, courseChapter, getChapterCompletion }, ref) => {
|
||||
({ course, chapterIndex, courseChapter, getChapterCompletion }, ref) => {
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
const { isMobile, isLandscape } = useResponsiveWindowSize();
|
||||
const isMobilePortrait = isMobile && !isLandscape;
|
||||
@@ -64,25 +67,30 @@ const CourseChapterTitle = React.forwardRef<Props, HTMLDivElement>(
|
||||
</div>
|
||||
)}
|
||||
</LineStackLayout>
|
||||
{isFinished ? (
|
||||
<div
|
||||
style={{
|
||||
...styles.statusContainer,
|
||||
color: gdevelopTheme.statusIndicator.success,
|
||||
}}
|
||||
>
|
||||
{isMobilePortrait && <CheckCircle />}
|
||||
<Text color="inherit" noMargin>
|
||||
<Trans>Finished</Trans>
|
||||
<LineStackLayout noMargin alignItems="center">
|
||||
{course.isLocked && courseChapter.isFree && (
|
||||
<Chip style={freeChipStyle} label={<Trans>Free!</Trans>} />
|
||||
)}
|
||||
{isFinished ? (
|
||||
<div
|
||||
style={{
|
||||
...styles.statusContainer,
|
||||
color: gdevelopTheme.statusIndicator.success,
|
||||
}}
|
||||
>
|
||||
{isMobilePortrait && <CheckCircle />}
|
||||
<Text color="inherit" noMargin>
|
||||
<Trans>Finished</Trans>
|
||||
</Text>
|
||||
</div>
|
||||
) : completion ? (
|
||||
<Text color="secondary" noMargin>
|
||||
<Trans>
|
||||
{completion.completedTasks} of {completion.tasks} completed
|
||||
</Trans>
|
||||
</Text>
|
||||
</div>
|
||||
) : completion ? (
|
||||
<Text color="secondary" noMargin>
|
||||
<Trans>
|
||||
{completion.completedTasks} of {completion.tasks} completed
|
||||
</Trans>
|
||||
</Text>
|
||||
) : null}
|
||||
) : null}
|
||||
</LineStackLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -34,6 +34,12 @@ import EmptyBadge from '../UI/CustomSvgIcons/EmptyBadge';
|
||||
import Skeleton from '@material-ui/lab/Skeleton';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
|
||||
export const freeChipStyle = {
|
||||
height: 20,
|
||||
backgroundColor: '#8BE7C4',
|
||||
color: '#1D1D26',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
container: { padding: 16, display: 'flex', borderRadius: 8 },
|
||||
mobileContainer: { padding: 8, display: 'flex', borderRadius: 8 },
|
||||
@@ -48,16 +54,6 @@ const styles = {
|
||||
},
|
||||
progress: { borderRadius: 4, height: 5 },
|
||||
chip: { height: 24 },
|
||||
freeChip: {
|
||||
height: 20,
|
||||
borderRadius: 32,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
backgroundColor: '#8BE7C4',
|
||||
color: '#1D1D26',
|
||||
},
|
||||
gdevelopAvatar: { width: 20, height: 20 },
|
||||
thumbnail: { borderRadius: 4, aspectRatio: '16 / 9', maxWidth: '100%' },
|
||||
statusContainer: {
|
||||
@@ -79,12 +75,14 @@ const styles = {
|
||||
};
|
||||
|
||||
const ChapterTile = ({
|
||||
course,
|
||||
chapter,
|
||||
chapterIndex,
|
||||
isComplete,
|
||||
gdevelopTheme,
|
||||
}: {|
|
||||
chapter: ?CourseChapter,
|
||||
course: Course,
|
||||
chapter: CourseChapter,
|
||||
isComplete: boolean,
|
||||
chapterIndex: number,
|
||||
gdevelopTheme: GDevelopTheme,
|
||||
@@ -92,14 +90,14 @@ const ChapterTile = ({
|
||||
return (
|
||||
<Column expand>
|
||||
<Spacer />
|
||||
{chapter && chapter.isLocked ? (
|
||||
{chapter.isLocked ? (
|
||||
<Paper background="light" style={{ padding: 4 }}>
|
||||
<LineStackLayout noMargin alignItems="center" justifyContent="center">
|
||||
<div style={styles.statusContainer}>
|
||||
<Lock fontSize="inherit" color="secondary" />
|
||||
</div>
|
||||
<Text color="secondary" noMargin>
|
||||
<Trans>Unlock with {chapter.priceInCredits} credits</Trans>
|
||||
<Trans>Unlock with the full course</Trans>
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
</Paper>
|
||||
@@ -112,13 +110,9 @@ const ChapterTile = ({
|
||||
>
|
||||
<CheckCircle fontSize="inherit" />
|
||||
</div>
|
||||
) : chapter && chapter.isFree ? (
|
||||
) : course.isLocked && chapter.isFree ? (
|
||||
<Line noMargin>
|
||||
<div style={styles.freeChip}>
|
||||
<Text noMargin color="inherit" size="body-small">
|
||||
<Trans>Free</Trans>
|
||||
</Text>
|
||||
</div>
|
||||
<Chip style={freeChipStyle} label={<Trans>Free!</Trans>} />
|
||||
</Line>
|
||||
) : (
|
||||
<div style={styles.statusIconOnly}>
|
||||
@@ -136,21 +130,13 @@ const ChapterTile = ({
|
||||
<Trans>Chapter</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
{chapter ? (
|
||||
<Text
|
||||
size="sub-title"
|
||||
noMargin
|
||||
color={chapter.isLocked ? 'secondary' : 'primary'}
|
||||
>
|
||||
{chapter.title}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
<i>
|
||||
<Trans>Coming soon</Trans>
|
||||
</i>
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
size="sub-title"
|
||||
noMargin
|
||||
color={chapter.isLocked ? 'secondary' : 'primary'}
|
||||
>
|
||||
{chapter.title}
|
||||
</Text>
|
||||
<LargeSpacer />
|
||||
</Column>
|
||||
);
|
||||
@@ -258,6 +244,7 @@ const CoursePreviewBanner = ({
|
||||
))}
|
||||
{index > 0 && <Spacer />}
|
||||
<ChapterTile
|
||||
course={course}
|
||||
chapter={chapter}
|
||||
chapterIndex={chapterIndex}
|
||||
gdevelopTheme={gdevelopTheme}
|
||||
|
@@ -5,28 +5,47 @@ import * as React from 'react';
|
||||
import {
|
||||
listListedCourseChapters,
|
||||
type CourseChapterListingData,
|
||||
listListedCourses,
|
||||
type CourseListingData,
|
||||
} from '../Utils/GDevelopServices/Shop';
|
||||
import { COURSE_CHAPTERS_FETCH_TIMEOUT } from '../Utils/GlobalFetchTimeouts';
|
||||
|
||||
type CourseChapterStoreState = {|
|
||||
listedCourses: CourseListingData[] | null,
|
||||
listedCourseChapters: CourseChapterListingData[] | null,
|
||||
|};
|
||||
|
||||
const initialCourseChapterStoreState: CourseChapterStoreState = {
|
||||
const initialCourseStoreState: CourseChapterStoreState = {
|
||||
listedCourses: null,
|
||||
listedCourseChapters: null,
|
||||
};
|
||||
|
||||
const CourseChapterStoreContext = React.createContext<CourseChapterStoreState>(
|
||||
initialCourseChapterStoreState
|
||||
const CourseStoreContext = React.createContext<CourseChapterStoreState>(
|
||||
initialCourseStoreState
|
||||
);
|
||||
|
||||
type Props = {| children: React.Node |};
|
||||
|
||||
export const CourseChapterStoreStateProvider = (props: Props) => {
|
||||
export const CourseStoreStateProvider = (props: Props) => {
|
||||
const [listedCourses, setListedCourses] = React.useState<
|
||||
CourseListingData[] | null
|
||||
>(null);
|
||||
const [listedCourseChapters, setListedCourseChapters] = React.useState<
|
||||
CourseChapterListingData[] | null
|
||||
>(null);
|
||||
|
||||
const loadCourses = React.useCallback(async () => {
|
||||
try {
|
||||
const fetchedListedCourses = await listListedCourses();
|
||||
setListedCourses(fetchedListedCourses);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'An error occurred when fetching courses in Shop API:',
|
||||
error
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadCourseChapters = React.useCallback(async () => {
|
||||
try {
|
||||
const fetchedListedCourseChapters = await listListedCourseChapters();
|
||||
@@ -41,24 +60,27 @@ export const CourseChapterStoreStateProvider = (props: Props) => {
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
console.info('Pre-fetching courses...');
|
||||
loadCourses();
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.info('Pre-fetching course chapters...');
|
||||
loadCourseChapters();
|
||||
}, COURSE_CHAPTERS_FETCH_TIMEOUT);
|
||||
return () => clearTimeout(timeoutId);
|
||||
},
|
||||
[loadCourseChapters]
|
||||
[loadCourseChapters, loadCourses]
|
||||
);
|
||||
|
||||
return (
|
||||
<CourseChapterStoreContext.Provider
|
||||
<CourseStoreContext.Provider
|
||||
value={{
|
||||
listedCourses,
|
||||
listedCourseChapters,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</CourseChapterStoreContext.Provider>
|
||||
</CourseStoreContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseChapterStoreContext;
|
||||
export default CourseStoreContext;
|
@@ -4,24 +4,17 @@ import * as React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import type {
|
||||
CourseChapter,
|
||||
Course,
|
||||
LockedVideoBasedCourseChapter,
|
||||
LockedTextBasedCourseChapter,
|
||||
} from '../Utils/GDevelopServices/Asset';
|
||||
import Text from '../UI/Text';
|
||||
import { ColumnStackLayout, ResponsiveLineStackLayout } from '../UI/Layout';
|
||||
import { ColumnStackLayout } from '../UI/Layout';
|
||||
import Paper from '../UI/Paper';
|
||||
import RaisedButton from '../UI/RaisedButton';
|
||||
import FlatButton from '../UI/FlatButton';
|
||||
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { SubscriptionSuggestionContext } from '../Profile/Subscription/SubscriptionSuggestionContext';
|
||||
import GoldCompact from '../Profile/Subscription/Icons/GoldCompact';
|
||||
import Coin from '../Credits/Icons/Coin';
|
||||
import Lock from '../UI/CustomSvgIcons/Lock';
|
||||
import Window from '../Utils/Window';
|
||||
import PasswordPromptDialog from '../AssetStore/PasswordPromptDialog';
|
||||
import { getYoutubeVideoIdFromUrl } from '../Utils/Youtube';
|
||||
import AlertMessage from '../UI/AlertMessage';
|
||||
|
||||
const styles = {
|
||||
videoAndMaterialsContainer: {
|
||||
@@ -76,58 +69,17 @@ const LockedOverlay = () => (
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
course: Course,
|
||||
courseChapter: LockedVideoBasedCourseChapter | LockedTextBasedCourseChapter,
|
||||
onBuyWithCredits: (CourseChapter, string) => Promise<void>,
|
||||
onClickUnlock: () => void,
|
||||
|};
|
||||
|
||||
const LockedCourseChapterPreview = React.forwardRef<Props, HTMLDivElement>(
|
||||
({ courseChapter, onBuyWithCredits }, ref) => {
|
||||
const { openSubscriptionDialog } = React.useContext(
|
||||
SubscriptionSuggestionContext
|
||||
);
|
||||
const [error, setError] = React.useState<React.Node>(null);
|
||||
const [
|
||||
displayPasswordPrompt,
|
||||
setDisplayPasswordPrompt,
|
||||
] = React.useState<boolean>(false);
|
||||
({ course, courseChapter, onClickUnlock }, ref) => {
|
||||
const { windowSize } = useResponsiveWindowSize();
|
||||
const [password, setPassword] = React.useState<string>('');
|
||||
const youtubeVideoId = courseChapter.videoUrl
|
||||
? getYoutubeVideoIdFromUrl(courseChapter.videoUrl)
|
||||
: null;
|
||||
const [isPurchasing, setIsPurchasing] = React.useState<boolean>(false);
|
||||
|
||||
const onClickBuyWithCredits = React.useCallback(
|
||||
async () => {
|
||||
if (!courseChapter.isLocked) return;
|
||||
setError(null);
|
||||
setDisplayPasswordPrompt(false);
|
||||
setIsPurchasing(true);
|
||||
try {
|
||||
await onBuyWithCredits(courseChapter, password);
|
||||
} catch (error) {
|
||||
console.error('An error occurred while buying this chapter', error);
|
||||
setError(
|
||||
<Trans>
|
||||
An error occurred while buying this chapter. Please try again
|
||||
later.
|
||||
</Trans>
|
||||
);
|
||||
} finally {
|
||||
setIsPurchasing(false);
|
||||
}
|
||||
},
|
||||
[courseChapter, onBuyWithCredits, password]
|
||||
);
|
||||
|
||||
const onWillBuyWithCredits = React.useCallback(
|
||||
async () => {
|
||||
// Password is required in dev environment only so that one cannot freely claim asset packs.
|
||||
if (Window.isDev()) setDisplayPasswordPrompt(true);
|
||||
else onClickBuyWithCredits();
|
||||
},
|
||||
[onClickBuyWithCredits]
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={styles.videoAndMaterialsContainer}>
|
||||
@@ -152,57 +104,15 @@ const LockedCourseChapterPreview = React.forwardRef<Props, HTMLDivElement>(
|
||||
<Text noMargin size="sub-title">
|
||||
<Trans>Unlock this lesson to finish the course</Trans>
|
||||
</Text>
|
||||
<Text noMargin>
|
||||
<Trans>
|
||||
Use your GDevelop credits to purchase lessons in this course;
|
||||
or get a subscription to get them for free.
|
||||
</Trans>
|
||||
</Text>
|
||||
<ResponsiveLineStackLayout
|
||||
noMargin
|
||||
noColumnMargin
|
||||
forceMobileLayout={windowSize === 'medium'}
|
||||
>
|
||||
<RaisedButton
|
||||
primary
|
||||
fullWidth
|
||||
icon={<GoldCompact fontSize="small" />}
|
||||
disabled={isPurchasing}
|
||||
label={<Trans>Get a subscription</Trans>}
|
||||
onClick={() =>
|
||||
openSubscriptionDialog({
|
||||
analyticsMetadata: {
|
||||
reason: 'Unlock course chapter',
|
||||
recommendedPlanId: 'gdevelop_silver',
|
||||
placementId: 'unlock-course-chapter',
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
{courseChapter.priceInCredits && (
|
||||
<FlatButton
|
||||
fullWidth
|
||||
leftIcon={<Coin fontSize="small" />}
|
||||
disabled={isPurchasing}
|
||||
label={
|
||||
<Trans>Pay {courseChapter.priceInCredits} credits</Trans>
|
||||
}
|
||||
onClick={onWillBuyWithCredits}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveLineStackLayout>
|
||||
{error && <AlertMessage kind="error">{error}</AlertMessage>}
|
||||
<RaisedButton
|
||||
primary
|
||||
fullWidth
|
||||
label={<Trans>Unlock the whole course</Trans>}
|
||||
onClick={onClickUnlock}
|
||||
/>
|
||||
</ColumnStackLayout>
|
||||
</Paper>
|
||||
</div>
|
||||
{displayPasswordPrompt && (
|
||||
<PasswordPromptDialog
|
||||
onApply={onClickBuyWithCredits}
|
||||
onClose={() => setDisplayPasswordPrompt(false)}
|
||||
passwordValue={password}
|
||||
setPasswordValue={setPassword}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ import { Trans } from '@lingui/macro';
|
||||
|
||||
import type {
|
||||
TextBasedCourseChapter,
|
||||
CourseChapter,
|
||||
Course,
|
||||
} from '../Utils/GDevelopServices/Asset';
|
||||
import Text from '../UI/Text';
|
||||
import { ColumnStackLayout } from '../UI/Layout';
|
||||
@@ -41,6 +41,7 @@ const styles = {
|
||||
|
||||
type Props = {|
|
||||
chapterIndex: number,
|
||||
course: Course,
|
||||
courseChapter: TextBasedCourseChapter,
|
||||
onOpenTemplate: (templateId?: string) => void,
|
||||
onCompleteTask: (
|
||||
@@ -50,7 +51,7 @@ type Props = {|
|
||||
) => void,
|
||||
isTaskCompleted: (chapterId: string, taskIndex: number) => boolean,
|
||||
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
|
||||
onBuyWithCredits: (CourseChapter, string) => Promise<void>,
|
||||
onClickUnlock: () => void,
|
||||
|};
|
||||
|
||||
const TextBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
|
||||
@@ -58,17 +59,19 @@ const TextBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
|
||||
{
|
||||
chapterIndex,
|
||||
courseChapter,
|
||||
course,
|
||||
onOpenTemplate,
|
||||
onCompleteTask,
|
||||
isTaskCompleted,
|
||||
getChapterCompletion,
|
||||
onBuyWithCredits,
|
||||
onClickUnlock,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<ColumnStackLayout expand noMargin>
|
||||
<CourseChapterTitle
|
||||
course={course}
|
||||
chapterIndex={chapterIndex}
|
||||
courseChapter={courseChapter}
|
||||
getChapterCompletion={getChapterCompletion}
|
||||
@@ -76,8 +79,9 @@ const TextBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
|
||||
/>
|
||||
{courseChapter.isLocked ? (
|
||||
<LockedCourseChapterPreview
|
||||
onBuyWithCredits={onBuyWithCredits}
|
||||
course={course}
|
||||
courseChapter={courseChapter}
|
||||
onClickUnlock={onClickUnlock}
|
||||
/>
|
||||
) : courseChapter.templates.length > 0 ? (
|
||||
<div style={styles.videoAndMaterialsContainer}>
|
||||
@@ -88,7 +92,7 @@ const TextBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
|
||||
<Paper background="medium" style={styles.sideBar}>
|
||||
<ColumnStackLayout noMargin>
|
||||
{courseChapter.templates.map(template => (
|
||||
<Line noMargin alignItems="center">
|
||||
<Line noMargin alignItems="center" key={template.id}>
|
||||
<Text noMargin>{rankLabel[chapterIndex + 1]}</Text>
|
||||
|
||||
<Text noMargin>
|
||||
|
@@ -5,7 +5,7 @@ import { Trans } from '@lingui/macro';
|
||||
|
||||
import type {
|
||||
VideoBasedCourseChapter,
|
||||
CourseChapter,
|
||||
Course,
|
||||
} from '../Utils/GDevelopServices/Asset';
|
||||
import Text from '../UI/Text';
|
||||
import { ColumnStackLayout } from '../UI/Layout';
|
||||
@@ -60,6 +60,7 @@ const styles = {
|
||||
|
||||
type Props = {|
|
||||
chapterIndex: number,
|
||||
course: Course,
|
||||
courseChapter: VideoBasedCourseChapter,
|
||||
onOpenTemplate: () => void,
|
||||
onCompleteTask: (
|
||||
@@ -69,19 +70,20 @@ type Props = {|
|
||||
) => void,
|
||||
isTaskCompleted: (chapterId: string, taskIndex: number) => boolean,
|
||||
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
|
||||
onBuyWithCredits: (CourseChapter, string) => Promise<void>,
|
||||
onClickUnlock: () => void,
|
||||
|};
|
||||
|
||||
const VideoBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
|
||||
(
|
||||
{
|
||||
chapterIndex,
|
||||
course,
|
||||
courseChapter,
|
||||
onOpenTemplate,
|
||||
onCompleteTask,
|
||||
isTaskCompleted,
|
||||
getChapterCompletion,
|
||||
onBuyWithCredits,
|
||||
onClickUnlock,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -97,6 +99,7 @@ const VideoBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
|
||||
return (
|
||||
<ColumnStackLayout expand noMargin>
|
||||
<CourseChapterTitle
|
||||
course={course}
|
||||
chapterIndex={chapterIndex}
|
||||
courseChapter={courseChapter}
|
||||
getChapterCompletion={getChapterCompletion}
|
||||
@@ -104,8 +107,9 @@ const VideoBasedCourseChapterView = React.forwardRef<Props, HTMLDivElement>(
|
||||
/>
|
||||
{courseChapter.isLocked ? (
|
||||
<LockedCourseChapterPreview
|
||||
onBuyWithCredits={onBuyWithCredits}
|
||||
course={course}
|
||||
courseChapter={courseChapter}
|
||||
onClickUnlock={onClickUnlock}
|
||||
/>
|
||||
) : (
|
||||
<div style={styles.videoAndMaterialsContainer}>
|
||||
|
@@ -93,8 +93,6 @@ const parseVariablePath = (
|
||||
segments.push({ type: 'property', value: currentSegment.trim() });
|
||||
}
|
||||
|
||||
console.log('segments', segments);
|
||||
|
||||
return segments;
|
||||
};
|
||||
|
||||
|
@@ -140,5 +140,5 @@ export const onlineCordovaIosExporter = {
|
||||
key: 'onlinecordovaiosexport',
|
||||
tabName: <Trans>iOS</Trans>,
|
||||
name: <Trans>iOS</Trans>,
|
||||
helpPage: '/publishing/android_and_ios',
|
||||
helpPage: '/publishing/ios',
|
||||
};
|
||||
|
@@ -1,24 +1,24 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import Text from '../../../../UI/Text';
|
||||
import Text from '../../UI/Text';
|
||||
import {
|
||||
ColumnStackLayout,
|
||||
LineStackLayout,
|
||||
ResponsiveLineStackLayout,
|
||||
} from '../../../../UI/Layout';
|
||||
} from '../../UI/Layout';
|
||||
import {
|
||||
type Badge,
|
||||
type Achievement,
|
||||
} from '../../../../Utils/GDevelopServices/Badge';
|
||||
import { Column } from '../../../../UI/Grid';
|
||||
import Window from '../../../../Utils/Window';
|
||||
import Coin from '../../../../Credits/Icons/Coin';
|
||||
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
|
||||
} from '../../Utils/GDevelopServices/Badge';
|
||||
import { Column } from '../../UI/Grid';
|
||||
import Window from '../../Utils/Window';
|
||||
import Coin from '../../Credits/Icons/Coin';
|
||||
import { selectMessageByLocale } from '../../Utils/i18n/MessageByLocale';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import TextButton from '../../../../UI/TextButton';
|
||||
import RouterContext from '../../../RouterContext';
|
||||
import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import TextButton from '../../UI/TextButton';
|
||||
import RouterContext from '../../MainFrame/RouterContext';
|
||||
|
||||
type CreditItemType = 'badge' | 'feedback';
|
||||
type BadgeInfo = {|
|
@@ -7,7 +7,7 @@ import DashboardWidget, {
|
||||
import { ColumnStackLayout } from '../../UI/Layout';
|
||||
import Coin from '../../Credits/Icons/Coin';
|
||||
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
|
||||
import { EarnCredits } from '../../MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits';
|
||||
import { EarnCredits } from './EarnCredits';
|
||||
import TextButton from '../../UI/TextButton';
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
|
@@ -26,6 +26,9 @@ export type EditorContainerExtraProps = {|
|
||||
|
||||
// Homepage
|
||||
storageProviders?: Array<StorageProvider>,
|
||||
|
||||
// Ask AI
|
||||
mode?: 'chat' | 'agent',
|
||||
|};
|
||||
|
||||
export type SceneEventsOutsideEditorChanges = {|
|
||||
@@ -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>,
|
||||
|
@@ -1,220 +0,0 @@
|
||||
// @flow
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import * as React from 'react';
|
||||
import Dialog, { DialogPrimaryButton } from '../../../UI/Dialog';
|
||||
import Text from '../../../UI/Text';
|
||||
import { ColumnStackLayout, LineStackLayout } from '../../../UI/Layout';
|
||||
import FlatButton from '../../../UI/FlatButton';
|
||||
import TextField from '../../../UI/TextField';
|
||||
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
|
||||
import useAlertDialog from '../../../UI/Alert/useAlertDialog';
|
||||
import Paper from '../../../UI/Paper';
|
||||
import { Column, Line } from '../../../UI/Grid';
|
||||
import CreateProfile from '../../../Profile/CreateProfile';
|
||||
import LeftLoader from '../../../UI/LeftLoader';
|
||||
import { submitQuestion } from '../../../Utils/GDevelopServices/Question';
|
||||
import AlertMessage from '../../../UI/AlertMessage';
|
||||
import Window from '../../../Utils/Window';
|
||||
import Link from '../../../UI/Link';
|
||||
import SquaredInfo from '../../../UI/CustomSvgIcons/SquaredInfo';
|
||||
|
||||
type Props = {|
|
||||
onClose: () => void,
|
||||
|};
|
||||
|
||||
const styles = {
|
||||
infoIcon: { width: 24, height: 24 },
|
||||
};
|
||||
|
||||
const maxLength = 500;
|
||||
const minLength = 20;
|
||||
|
||||
const AnyQuestionDialog = ({ onClose }: Props) => {
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [questionText, setQuestionText] = React.useState('');
|
||||
const [error, setError] = React.useState<?Error>(null);
|
||||
const [wasQuestionSubmitted, setWasQuestionSubmitted] = React.useState(false);
|
||||
const { showAlert } = useAlertDialog();
|
||||
const authenticatedUser = React.useContext(AuthenticatedUserContext);
|
||||
const { limits } = authenticatedUser;
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async () => {
|
||||
if (!questionText || wasQuestionSubmitted) return;
|
||||
|
||||
const { profile, getAuthorizationHeader } = authenticatedUser;
|
||||
if (!profile) return;
|
||||
|
||||
if (questionText.length < minLength) {
|
||||
showAlert({
|
||||
title: t`Your question is too short`,
|
||||
message: t`Your question must be at least ${minLength} characters long.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
limits &&
|
||||
limits.quotas['ask-question'] &&
|
||||
limits.quotas['ask-question'].limitReached
|
||||
) {
|
||||
showAlert({
|
||||
title: t`You've reached your limit`,
|
||||
message: t`You've reached your limit of questions. Wait a bit and try again tomorrow, or get a subscription to unlock more questions!`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
await submitQuestion(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
questionText,
|
||||
});
|
||||
setWasQuestionSubmitted(true);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[questionText, wasQuestionSubmitted, authenticatedUser, showAlert, limits]
|
||||
);
|
||||
|
||||
const actions = [
|
||||
<LeftLoader isLoading={isLoading}>
|
||||
<FlatButton
|
||||
key="cancel"
|
||||
label={<Trans>Close</Trans>}
|
||||
primary={false}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</LeftLoader>,
|
||||
<DialogPrimaryButton
|
||||
key="send"
|
||||
label={<Trans>Send the question</Trans>}
|
||||
color="success"
|
||||
onClick={onSubmit}
|
||||
disabled={isLoading || wasQuestionSubmitted}
|
||||
/>,
|
||||
];
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<Dialog
|
||||
title={<Trans>Ask any question</Trans>}
|
||||
open
|
||||
actions={actions}
|
||||
onRequestClose={onClose}
|
||||
onApply={onSubmit}
|
||||
maxWidth="sm"
|
||||
>
|
||||
<ColumnStackLayout noMargin>
|
||||
<Text size="body">
|
||||
<Trans>
|
||||
Write your question as precisely as possible. Tell where you're
|
||||
blocked or what you want to do.
|
||||
</Trans>
|
||||
</Text>
|
||||
{!authenticatedUser.authenticated && (
|
||||
<Paper background="dark" variant="outlined">
|
||||
<Line justifyContent="center">
|
||||
<CreateProfile
|
||||
onOpenLoginDialog={authenticatedUser.onOpenLoginDialog}
|
||||
onOpenCreateAccountDialog={
|
||||
authenticatedUser.onOpenCreateAccountDialog
|
||||
}
|
||||
message={
|
||||
<Trans>
|
||||
Create an account first to send your question.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</Line>
|
||||
</Paper>
|
||||
)}
|
||||
<TextField
|
||||
value={questionText}
|
||||
multiline
|
||||
rows={3}
|
||||
floatingLabelText={<Trans>Your question</Trans>}
|
||||
onChange={(e, value) => {
|
||||
setQuestionText(value);
|
||||
}}
|
||||
fullWidth
|
||||
disabled={isLoading || wasQuestionSubmitted}
|
||||
maxLength={maxLength}
|
||||
helperMarkdownText={i18n._(
|
||||
t`**${
|
||||
questionText.length
|
||||
}**/${maxLength} characters. You'll receive your answer by email.`
|
||||
)}
|
||||
/>
|
||||
{error ? (
|
||||
<AlertMessage kind="error">
|
||||
<Trans>
|
||||
An error happened while sending your question. Please verify
|
||||
your internet connection or try again later.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
) : wasQuestionSubmitted ? (
|
||||
<AlertMessage kind="info">
|
||||
<Trans>
|
||||
Your question has been sent! You'll receive your answer by
|
||||
email.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
) : null}
|
||||
<Paper background="light" variant="outlined">
|
||||
<Column expand>
|
||||
<LineStackLayout expand alignItems="center">
|
||||
<SquaredInfo style={styles.infoIcon} />
|
||||
<Text size="body-small">
|
||||
<Trans>
|
||||
You can also ask your question on{' '}
|
||||
<Link
|
||||
onClick={() =>
|
||||
Window.openExternalURL('https://forum.gdevelop.io')
|
||||
}
|
||||
href="#"
|
||||
>
|
||||
the forum
|
||||
</Link>
|
||||
, on{' '}
|
||||
<Link
|
||||
onClick={() =>
|
||||
Window.openExternalURL('https://discord.gg/gdevelop')
|
||||
}
|
||||
href="#"
|
||||
>
|
||||
the GDevelop Discord server
|
||||
</Link>{' '}
|
||||
or{' '}
|
||||
<Link
|
||||
onClick={() =>
|
||||
Window.openExternalURL(
|
||||
'https://gdevelop.io/pricing/support'
|
||||
)
|
||||
}
|
||||
href="#"
|
||||
>
|
||||
book fast professional support
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
</Column>
|
||||
</Paper>
|
||||
</ColumnStackLayout>
|
||||
</Dialog>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnyQuestionDialog;
|
@@ -23,22 +23,21 @@ const styles = {
|
||||
// Styles to give the impression of pressing an element.
|
||||
const useStylesForWidget = ({
|
||||
useDefaultDisabledStyle,
|
||||
noBorder,
|
||||
disableHoverAndFocusEffects,
|
||||
}: {|
|
||||
useDefaultDisabledStyle?: boolean,
|
||||
noBorder?: boolean,
|
||||
disableHoverAndFocusEffects?: boolean,
|
||||
|}) =>
|
||||
makeStyles(theme => {
|
||||
const rootStyles = {
|
||||
boxSizing: 'border-box',
|
||||
border: `1px solid ${theme.palette.text.primary}`,
|
||||
borderBottom: `6px solid ${theme.palette.text.primary}`,
|
||||
border: !noBorder ? `1px solid ${theme.palette.text.secondary}` : 'none',
|
||||
transition: 'background-color 100ms ease',
|
||||
'&:disabled': useDefaultDisabledStyle
|
||||
? {
|
||||
opacity: theme.palette.action.disabledOpacity,
|
||||
border: `1px solid ${theme.palette.text.secondary}`,
|
||||
borderBottom: `6px solid ${theme.palette.text.secondary}`,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
@@ -66,6 +65,7 @@ type Props = {|
|
||||
size: 'small' | 'large' | 'banner',
|
||||
disabled?: boolean,
|
||||
useDefaultDisabledStyle?: boolean,
|
||||
noBorder?: boolean,
|
||||
|};
|
||||
|
||||
export const CardWidget = ({
|
||||
@@ -74,9 +74,11 @@ export const CardWidget = ({
|
||||
size,
|
||||
disabled,
|
||||
useDefaultDisabledStyle,
|
||||
noBorder,
|
||||
}: Props) => {
|
||||
const classes = useStylesForWidget({
|
||||
useDefaultDisabledStyle,
|
||||
noBorder,
|
||||
disableHoverAndFocusEffects: !onClick,
|
||||
});
|
||||
const { isMobile } = useResponsiveWindowSize();
|
||||
|
@@ -587,10 +587,7 @@ const CreateSection = ({
|
||||
onSelectPrivateGameTemplateListingData
|
||||
}
|
||||
i18n={i18n}
|
||||
columnsCount={getExampleItemsColumns(
|
||||
windowSize,
|
||||
isLandscape
|
||||
)}
|
||||
getColumnsFromWindowSize={getExampleItemsColumns}
|
||||
hideSearch
|
||||
onlyShowGames
|
||||
/>
|
||||
|
@@ -9,12 +9,10 @@ import {
|
||||
type StorageProvider,
|
||||
} from '../../../../ProjectsStorage';
|
||||
import { marginsSize } from '../../../../UI/Grid';
|
||||
import { sendGameTemplateInformationOpened } from '../../../../Utils/Analytics/EventSender';
|
||||
import { getProductPriceOrOwnedLabel } from '../../../../AssetStore/ProductPriceTag';
|
||||
import { type PrivateGameTemplateListingData } from '../../../../Utils/GDevelopServices/Shop';
|
||||
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
|
||||
import { type PrivateGameTemplate } from '../../../../Utils/GDevelopServices/Asset';
|
||||
import { type CarouselThumbnail } from '../../../../UI/Carousel';
|
||||
import { type GDevelopTheme } from '../../../../UI/Theme';
|
||||
import {
|
||||
ExampleTile,
|
||||
PrivateGameTemplateTile,
|
||||
@@ -155,87 +153,6 @@ export const transformCloudProjectsIntoFileMetadataWithStorageProviderName = (
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const formatGameTemplateListingDataForCarousel = ({
|
||||
gameTemplateListingData,
|
||||
onSelectGameTemplate,
|
||||
i18n,
|
||||
receivedGameTemplates,
|
||||
}: {|
|
||||
gameTemplateListingData: PrivateGameTemplateListingData,
|
||||
onSelectGameTemplate: PrivateGameTemplateListingData => void,
|
||||
i18n: I18nType,
|
||||
receivedGameTemplates: ?Array<PrivateGameTemplate>,
|
||||
|}): CarouselThumbnail => {
|
||||
const isTemplateOwned =
|
||||
!!receivedGameTemplates &&
|
||||
!!receivedGameTemplates.find(
|
||||
receivedGameTemplate =>
|
||||
receivedGameTemplate.id === gameTemplateListingData.id
|
||||
);
|
||||
return {
|
||||
id: gameTemplateListingData.id,
|
||||
title: gameTemplateListingData.name,
|
||||
thumbnailUrl: gameTemplateListingData.thumbnailUrls[0],
|
||||
onClick: () => {
|
||||
sendGameTemplateInformationOpened({
|
||||
gameTemplateName: gameTemplateListingData.name,
|
||||
gameTemplateId: gameTemplateListingData.id,
|
||||
source: 'homepage',
|
||||
});
|
||||
onSelectGameTemplate(gameTemplateListingData);
|
||||
},
|
||||
overlayText: getProductPriceOrOwnedLabel({
|
||||
i18n,
|
||||
productListingData: gameTemplateListingData,
|
||||
owned: isTemplateOwned,
|
||||
}),
|
||||
overlayTextPosition: 'topLeft',
|
||||
};
|
||||
};
|
||||
|
||||
const formatExampleShortHeaderForCarousel = ({
|
||||
exampleShortHeader,
|
||||
onSelectExample,
|
||||
}: {|
|
||||
exampleShortHeader: ExampleShortHeader,
|
||||
onSelectExample: ExampleShortHeader => void,
|
||||
|}) => {
|
||||
return {
|
||||
id: exampleShortHeader.id,
|
||||
title: exampleShortHeader.name,
|
||||
onClick: () => onSelectExample(exampleShortHeader),
|
||||
thumbnailUrl: exampleShortHeader.previewImageUrls[0],
|
||||
};
|
||||
};
|
||||
|
||||
const formatItemForCarousel = ({
|
||||
item,
|
||||
onSelectGameTemplate,
|
||||
onSelectExample,
|
||||
i18n,
|
||||
receivedGameTemplates,
|
||||
}: {
|
||||
item: PrivateGameTemplateListingData | ExampleShortHeader,
|
||||
onSelectGameTemplate: PrivateGameTemplateListingData => void,
|
||||
onSelectExample: ExampleShortHeader => void,
|
||||
i18n: I18nType,
|
||||
receivedGameTemplates: ?Array<PrivateGameTemplate>,
|
||||
}): CarouselThumbnail => {
|
||||
if (item.previewImageUrls) {
|
||||
return formatExampleShortHeaderForCarousel({
|
||||
exampleShortHeader: item,
|
||||
onSelectExample: onSelectExample,
|
||||
});
|
||||
} else {
|
||||
return formatGameTemplateListingDataForCarousel({
|
||||
i18n,
|
||||
onSelectGameTemplate: onSelectGameTemplate,
|
||||
gameTemplateListingData: item,
|
||||
receivedGameTemplates: receivedGameTemplates,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatItemForGrid = ({
|
||||
item,
|
||||
onSelectGameTemplate,
|
||||
@@ -288,6 +205,7 @@ export const getExampleAndTemplateTiles = ({
|
||||
onSelectPrivateGameTemplateListingData,
|
||||
onSelectExampleShortHeader,
|
||||
i18n,
|
||||
gdevelopTheme,
|
||||
numberOfItemsExclusivelyInCarousel = 0,
|
||||
numberOfItemsInCarousel = 0,
|
||||
privateGameTemplatesPeriodicity,
|
||||
@@ -301,21 +219,14 @@ export const getExampleAndTemplateTiles = ({
|
||||
) => void,
|
||||
onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void,
|
||||
i18n: I18nType,
|
||||
gdevelopTheme: GDevelopTheme,
|
||||
numberOfItemsExclusivelyInCarousel?: number,
|
||||
numberOfItemsInCarousel?: number,
|
||||
privateGameTemplatesPeriodicity: number,
|
||||
showOwnedGameTemplatesFirst?: boolean,
|
||||
|}): {|
|
||||
carouselThumbnailItems: Array<CarouselThumbnail>,
|
||||
gridItemsCompletingCarousel: Array<React.Node>,
|
||||
allGridItems: Array<React.Node>,
|
||||
|} => {
|
||||
|}): Array<React.Node> => {
|
||||
if (!exampleShortHeaders || !privateGameTemplateListingDatas) {
|
||||
return {
|
||||
carouselThumbnailItems: [],
|
||||
gridItemsCompletingCarousel: [],
|
||||
allGridItems: [],
|
||||
};
|
||||
return [];
|
||||
}
|
||||
const exampleShortHeadersWithThumbnails = exampleShortHeaders.filter(
|
||||
exampleShortHeader =>
|
||||
@@ -328,12 +239,6 @@ export const getExampleAndTemplateTiles = ({
|
||||
!exampleShortHeader.previewImageUrls[0]
|
||||
);
|
||||
|
||||
const carouselItems: Array<
|
||||
PrivateGameTemplateListingData | ExampleShortHeader
|
||||
> = [];
|
||||
const itemsCompletingCarousel: Array<
|
||||
PrivateGameTemplateListingData | ExampleShortHeader
|
||||
> = [];
|
||||
const allItems: Array<
|
||||
PrivateGameTemplateListingData | ExampleShortHeader
|
||||
> = [];
|
||||
@@ -363,29 +268,13 @@ export const getExampleAndTemplateTiles = ({
|
||||
|
||||
// First handle example.
|
||||
if (exampleShortHeader) {
|
||||
// Handle carousel.
|
||||
if (carouselItems.length < numberOfItemsInCarousel) {
|
||||
carouselItems.push(exampleShortHeader);
|
||||
}
|
||||
// Handle grid.
|
||||
allItems.push(exampleShortHeader);
|
||||
if (carouselItems.length > numberOfItemsExclusivelyInCarousel) {
|
||||
itemsCompletingCarousel.push(exampleShortHeader);
|
||||
}
|
||||
}
|
||||
|
||||
// Then handle private game template if in the right periodicity.
|
||||
if (shouldAddPrivateGameTemplate && privateGameTemplateListingData) {
|
||||
// Handle carousel.
|
||||
if (carouselItems.length < numberOfItemsInCarousel) {
|
||||
carouselItems.push(privateGameTemplateListingData);
|
||||
}
|
||||
// Handle grid.
|
||||
if (privateGameTemplateListingData) {
|
||||
allItems.push(privateGameTemplateListingData);
|
||||
if (carouselItems.length > numberOfItemsExclusivelyInCarousel) {
|
||||
itemsCompletingCarousel.push(privateGameTemplateListingData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,26 +290,6 @@ export const getExampleAndTemplateTiles = ({
|
||||
allItems.push(exampleShortHeader);
|
||||
});
|
||||
|
||||
const carouselThumbnailItems = carouselItems.map(item =>
|
||||
formatItemForCarousel({
|
||||
item,
|
||||
onSelectGameTemplate: onSelectPrivateGameTemplateListingData,
|
||||
onSelectExample: onSelectExampleShortHeader,
|
||||
i18n,
|
||||
receivedGameTemplates,
|
||||
})
|
||||
);
|
||||
|
||||
const gridItemsCompletingCarousel = itemsCompletingCarousel.map(item =>
|
||||
formatItemForGrid({
|
||||
item,
|
||||
onSelectGameTemplate: onSelectPrivateGameTemplateListingData,
|
||||
onSelectExample: onSelectExampleShortHeader,
|
||||
i18n,
|
||||
receivedGameTemplates,
|
||||
})
|
||||
);
|
||||
|
||||
const allGridItems = allItems
|
||||
.sort((item1, item2) => {
|
||||
if (showOwnedGameTemplatesFirst) {
|
||||
@@ -452,5 +321,5 @@ export const getExampleAndTemplateTiles = ({
|
||||
})
|
||||
);
|
||||
|
||||
return { carouselThumbnailItems, gridItemsCompletingCarousel, allGridItems };
|
||||
return allGridItems;
|
||||
};
|
||||
|
@@ -1,360 +0,0 @@
|
||||
// @flow
|
||||
|
||||
import * as React from 'react';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
import GridList from '@material-ui/core/GridList';
|
||||
import GridListTile from '@material-ui/core/GridListTile';
|
||||
import { type AuthenticatedUser } from '../../../../Profile/AuthenticatedUserContext';
|
||||
import { TutorialContext } from '../../../../Tutorial/TutorialContext';
|
||||
import { SectionRow } from '../SectionContainer';
|
||||
import GuidedLessons from '../InAppTutorials/GuidedLessons';
|
||||
import { formatTutorialToImageTileComponent } from '../LearnSection';
|
||||
import ImageTileRow from '../../../../UI/ImageTileRow';
|
||||
import {
|
||||
useResponsiveWindowSize,
|
||||
type WindowSizeType,
|
||||
} from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import Text from '../../../../UI/Text';
|
||||
import { Column, Line, Spacer } from '../../../../UI/Grid';
|
||||
import { type Tutorial } from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
import { CardWidget } from '../CardWidget';
|
||||
import Window from '../../../../Utils/Window';
|
||||
import { ColumnStackLayout } from '../../../../UI/Layout';
|
||||
import { type GuidedLessonsRecommendation } from '../../../../Utils/GDevelopServices/User';
|
||||
import PreferencesContext from '../../../Preferences/PreferencesContext';
|
||||
import { SurveyCard } from './SurveyCard';
|
||||
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
|
||||
import PromotionsSlideshow from '../../../../Promotions/PromotionsSlideshow';
|
||||
import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutorials/PrivateTutorialViewDialog';
|
||||
import FlatButton from '../../../../UI/FlatButton';
|
||||
import InAppTutorialContext from '../../../../InAppTutorial/InAppTutorialContext';
|
||||
import { type NewProjectSetup } from '../../../../ProjectCreation/NewProjectSetupDialog';
|
||||
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
|
||||
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
|
||||
|
||||
const styles = {
|
||||
textTutorialContent: {
|
||||
padding: 20,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
},
|
||||
};
|
||||
|
||||
const useStyles = makeStyles({
|
||||
tile: {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
const getTextTutorialsColumnsFromWidth = (
|
||||
windowSize: WindowSizeType,
|
||||
isLandscape: boolean
|
||||
) => {
|
||||
switch (windowSize) {
|
||||
case 'small':
|
||||
return isLandscape ? 4 : 2;
|
||||
case 'medium':
|
||||
return 2;
|
||||
case 'large':
|
||||
return 4;
|
||||
case 'xlarge':
|
||||
return 5;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
const getVideoTutorialsColumnsFromWidth = (
|
||||
windowSize: WindowSizeType,
|
||||
isLandscape: boolean
|
||||
) => {
|
||||
switch (windowSize) {
|
||||
case 'small':
|
||||
return isLandscape ? 5 : 2;
|
||||
case 'medium':
|
||||
return 3;
|
||||
case 'large':
|
||||
return 5;
|
||||
case 'xlarge':
|
||||
return 6;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
const getTutorialsLimitsFromWidth = (
|
||||
windowSize: WindowSizeType,
|
||||
isLandscape: boolean
|
||||
) => {
|
||||
switch (windowSize) {
|
||||
case 'small':
|
||||
return isLandscape ? 5 : 3;
|
||||
case 'medium':
|
||||
return 3;
|
||||
case 'large':
|
||||
return 5;
|
||||
case 'xlarge':
|
||||
return 5;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
type TextTutorialsRowProps = {|
|
||||
tutorials: Array<Tutorial>,
|
||||
i18n: I18nType,
|
||||
|};
|
||||
|
||||
const TextTutorialsRow = ({ tutorials, i18n }: TextTutorialsRowProps) => {
|
||||
const classes = useStyles();
|
||||
const { isLandscape, windowSize } = useResponsiveWindowSize();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Column noMargin>
|
||||
<Text size="section-title" noMargin>
|
||||
<Trans>Read</Trans>
|
||||
</Text>
|
||||
<Text>
|
||||
<Trans>
|
||||
Text-based content directly from GDevelop’s site and Wiki.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
<GridList
|
||||
cols={getTextTutorialsColumnsFromWidth(windowSize, isLandscape)}
|
||||
cellHeight="auto"
|
||||
spacing={10}
|
||||
>
|
||||
{tutorials.map(tutorial => (
|
||||
<GridListTile key={tutorial.id} classes={{ tile: classes.tile }}>
|
||||
<CardWidget
|
||||
onClick={() =>
|
||||
Window.openExternalURL(
|
||||
selectMessageByLocale(i18n, tutorial.linkByLocale)
|
||||
)
|
||||
}
|
||||
size="large"
|
||||
>
|
||||
<div style={styles.textTutorialContent}>
|
||||
<ColumnStackLayout expand justifyContent="center" useFullHeight>
|
||||
<Text noMargin size="block-title">
|
||||
{selectMessageByLocale(i18n, tutorial.titleByLocale)}
|
||||
</Text>
|
||||
<Text noMargin size="body" color="secondary">
|
||||
{selectMessageByLocale(i18n, tutorial.descriptionByLocale)}
|
||||
</Text>
|
||||
</ColumnStackLayout>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
))}
|
||||
</GridList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
authenticatedUser: AuthenticatedUser,
|
||||
selectInAppTutorial: (tutorialId: string) => void,
|
||||
onStartSurvey: null | (() => void),
|
||||
hasFilledSurveyAlready: boolean,
|
||||
onOpenProfile: () => void,
|
||||
onCreateProjectFromExample: (
|
||||
exampleShortHeader: ExampleShortHeader,
|
||||
newProjectSetup: NewProjectSetup,
|
||||
i18n: I18nType,
|
||||
isQuickCustomization?: boolean
|
||||
) => Promise<void>,
|
||||
askToCloseProject: () => Promise<boolean>,
|
||||
|};
|
||||
|
||||
const RecommendationList = ({
|
||||
authenticatedUser,
|
||||
selectInAppTutorial,
|
||||
onStartSurvey,
|
||||
hasFilledSurveyAlready,
|
||||
onOpenProfile,
|
||||
onCreateProjectFromExample,
|
||||
askToCloseProject,
|
||||
}: Props) => {
|
||||
const { recommendations, limits } = authenticatedUser;
|
||||
const { tutorials } = React.useContext(TutorialContext);
|
||||
const {
|
||||
getTutorialProgress,
|
||||
values: { showInAppTutorialDeveloperMode },
|
||||
} = React.useContext(PreferencesContext);
|
||||
const { onLoadInAppTutorialFromLocalFile } = React.useContext(
|
||||
InAppTutorialContext
|
||||
);
|
||||
|
||||
const [
|
||||
selectedTutorial,
|
||||
setSelectedTutorial,
|
||||
] = React.useState<Tutorial | null>(null);
|
||||
|
||||
if (!recommendations) return <PlaceholderLoader />;
|
||||
|
||||
const recommendedTutorials = tutorials
|
||||
? recommendations
|
||||
.map(recommendation =>
|
||||
recommendation.type === 'gdevelop-tutorial'
|
||||
? tutorials.find(tutorial => tutorial.id === recommendation.id)
|
||||
: null
|
||||
)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const recommendedVideoTutorials = recommendedTutorials.filter(
|
||||
tutorial => tutorial.type === 'video'
|
||||
);
|
||||
const recommendedTextTutorials = recommendedTutorials.filter(
|
||||
tutorial => tutorial.type === 'text'
|
||||
);
|
||||
|
||||
// $FlowIgnore
|
||||
const guidedLessonsRecommendation: ?GuidedLessonsRecommendation = recommendations.find(
|
||||
recommendation => recommendation.type === 'guided-lessons'
|
||||
);
|
||||
const guidedLessonsIds = guidedLessonsRecommendation
|
||||
? guidedLessonsRecommendation.lessonsIds
|
||||
: null;
|
||||
|
||||
const getInAppTutorialPartProgress = ({
|
||||
tutorialId,
|
||||
}: {
|
||||
tutorialId: string,
|
||||
}) => {
|
||||
const tutorialProgress = getTutorialProgress({
|
||||
tutorialId,
|
||||
userId: authenticatedUser.profile
|
||||
? authenticatedUser.profile.id
|
||||
: undefined,
|
||||
});
|
||||
if (!tutorialProgress || !tutorialProgress.progress) return 0;
|
||||
return tutorialProgress.progress[0]; // guided lessons only have one part.
|
||||
};
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => {
|
||||
const items = [];
|
||||
|
||||
if (onStartSurvey && !hasFilledSurveyAlready)
|
||||
items.push(
|
||||
<SectionRow key="start-survey">
|
||||
<SurveyCard
|
||||
onStartSurvey={onStartSurvey}
|
||||
hasFilledSurveyAlready={false}
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
|
||||
if (guidedLessonsRecommendation) {
|
||||
const displayTextAfterGuidedLessons = guidedLessonsIds
|
||||
? guidedLessonsIds
|
||||
.map(tutorialId => getInAppTutorialPartProgress({ tutorialId }))
|
||||
.every(progress => progress === 100)
|
||||
: false;
|
||||
|
||||
items.push(
|
||||
<SectionRow key="guided-lessons">
|
||||
<Line justifyContent="space-between" noMargin alignItems="center">
|
||||
<Text size="section-title" noMargin>
|
||||
<Trans>Build game mechanics</Trans>
|
||||
</Text>
|
||||
{showInAppTutorialDeveloperMode && (
|
||||
<FlatButton
|
||||
label={<Trans>Load local lesson</Trans>}
|
||||
onClick={onLoadInAppTutorialFromLocalFile}
|
||||
/>
|
||||
)}
|
||||
</Line>
|
||||
<GuidedLessons
|
||||
selectInAppTutorial={selectInAppTutorial}
|
||||
lessonsIds={guidedLessonsIds}
|
||||
/>
|
||||
{displayTextAfterGuidedLessons && (
|
||||
<Text>
|
||||
<Trans>
|
||||
Congratulations on completing this selection of guided
|
||||
lessons! Find all lessons in the Learn section.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</SectionRow>
|
||||
);
|
||||
}
|
||||
|
||||
if (recommendedVideoTutorials.length) {
|
||||
items.push(
|
||||
<SectionRow key="videos">
|
||||
<ImageTileRow
|
||||
title={<Trans>Get started with game creation</Trans>}
|
||||
margin="dense"
|
||||
items={recommendedVideoTutorials.map(tutorial =>
|
||||
formatTutorialToImageTileComponent({
|
||||
i18n,
|
||||
limits,
|
||||
tutorial,
|
||||
onSelectTutorial: setSelectedTutorial,
|
||||
})
|
||||
)}
|
||||
getColumnsFromWindowSize={getVideoTutorialsColumnsFromWidth}
|
||||
getLimitFromWindowSize={getTutorialsLimitsFromWidth}
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
}
|
||||
|
||||
if (onStartSurvey && hasFilledSurveyAlready)
|
||||
items.push(
|
||||
<SectionRow key="start-survey">
|
||||
<SurveyCard
|
||||
onStartSurvey={onStartSurvey}
|
||||
hasFilledSurveyAlready
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
|
||||
items.push(
|
||||
<SectionRow key="promotions">
|
||||
<Text size="section-title" noMargin>
|
||||
<Trans>Discover the ecosystem</Trans>
|
||||
</Text>
|
||||
<Spacer />
|
||||
<PromotionsSlideshow />
|
||||
</SectionRow>
|
||||
);
|
||||
|
||||
if (recommendedTextTutorials.length) {
|
||||
items.push(
|
||||
<SectionRow key="texts">
|
||||
<TextTutorialsRow
|
||||
tutorials={recommendedTextTutorials}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{items}
|
||||
{selectedTutorial && (
|
||||
<PrivateTutorialViewDialog
|
||||
tutorial={selectedTutorial}
|
||||
onClose={() => setSelectedTutorial(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecommendationList;
|
@@ -1,293 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import Text from '../../../../UI/Text';
|
||||
import { ColumnStackLayout } from '../../../../UI/Layout';
|
||||
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
|
||||
import { useOnlineStatus } from '../../../../Utils/OnlineStatus';
|
||||
import TreeLeaves from '../../../../UI/CustomSvgIcons/TreeLeaves';
|
||||
import SectionContainer from '../SectionContainer';
|
||||
import RaisedButton from '../../../../UI/RaisedButton';
|
||||
import useForceUpdate from '../../../../Utils/UseForceUpdate';
|
||||
import { LargeSpacer, Line } from '../../../../UI/Grid';
|
||||
import CircularProgress from '../../../../UI/CircularProgress';
|
||||
import { type UserSurvey as UserSurveyType } from '../../../../Utils/GDevelopServices/User';
|
||||
import UserSurvey from './UserSurvey';
|
||||
import {
|
||||
clearUserSurveyPersistedState,
|
||||
hasStartedUserSurvey,
|
||||
} from './UserSurveyStorage';
|
||||
import LinearProgress from '../../../../UI/LinearProgress';
|
||||
import PreferencesContext from '../../../Preferences/PreferencesContext';
|
||||
import RecommendationList from './RecommendationList';
|
||||
import ErrorBoundary from '../../../../UI/ErrorBoundary';
|
||||
import { delay } from '../../../../Utils/Delay';
|
||||
import Checkbox from '../../../../UI/Checkbox';
|
||||
import { sendUserSurveyCompleted } from '../../../../Utils/Analytics/EventSender';
|
||||
import { type NewProjectSetup } from '../../../../ProjectCreation/NewProjectSetupDialog';
|
||||
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
|
||||
|
||||
const styles = {
|
||||
icon: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
margin: 20,
|
||||
},
|
||||
middlePageButtonContainer: {
|
||||
width: '100%',
|
||||
maxWidth: 300, // Make buttons larger but not too much.
|
||||
marginBottom: '15%', // Used to display the content of the section higher than at the center.
|
||||
},
|
||||
bottomPageButtonContainer: {
|
||||
width: '100%',
|
||||
maxWidth: 300, // Make buttons larger but not too much.
|
||||
marginBottom: 30, // Used to giver some space between the buttons and the screen bottom border.
|
||||
},
|
||||
linearProgress: { width: 200 },
|
||||
getFormContainerStyle: (isMobile: boolean) => ({
|
||||
marginTop: 20,
|
||||
// Take full width on mobile.
|
||||
width: isMobile ? '95%' : 300,
|
||||
}),
|
||||
questionnaireFinishedImage: { aspectRatio: '263 / 154' },
|
||||
};
|
||||
|
||||
const questionnaireFinishedImageSource = 'res/questionnaire/welcome-back.svg';
|
||||
|
||||
type Props = {|
|
||||
onUserSurveyStarted: () => void,
|
||||
onUserSurveyHidden: () => void,
|
||||
selectInAppTutorial: (tutorialId: string) => void,
|
||||
onOpenProfile: () => void,
|
||||
onCreateProjectFromExample: (
|
||||
exampleShortHeader: ExampleShortHeader,
|
||||
newProjectSetup: NewProjectSetup,
|
||||
i18n: I18nType,
|
||||
isQuickCustomization?: boolean
|
||||
) => Promise<void>,
|
||||
askToCloseProject: () => Promise<boolean>,
|
||||
|};
|
||||
|
||||
const GetStartedSection = ({
|
||||
selectInAppTutorial,
|
||||
onUserSurveyStarted,
|
||||
onUserSurveyHidden,
|
||||
onOpenProfile,
|
||||
onCreateProjectFromExample,
|
||||
askToCloseProject,
|
||||
}: Props) => {
|
||||
const isFillingOutSurvey = hasStartedUserSurvey();
|
||||
const isOnline = useOnlineStatus();
|
||||
const authenticatedUser = React.useContext(AuthenticatedUserContext);
|
||||
const {
|
||||
profile,
|
||||
creatingOrLoggingInAccount,
|
||||
onEditProfile,
|
||||
loginState,
|
||||
} = authenticatedUser;
|
||||
const {
|
||||
values: preferences,
|
||||
setShowGetStartedSectionByDefault,
|
||||
} = React.useContext(PreferencesContext);
|
||||
const recommendationsGettingDelayPromise = React.useRef<?Promise<void>>(null);
|
||||
const forceUpdate = useForceUpdate();
|
||||
const [step, setStep] = React.useState<
|
||||
'survey' | 'surveyFinished' | 'recommendations'
|
||||
>(isFillingOutSurvey ? 'survey' : 'recommendations');
|
||||
|
||||
const [errorSendingSurvey, setErrorSendingSurvey] = React.useState<boolean>(
|
||||
false
|
||||
);
|
||||
|
||||
const onSurveyFinished = async (survey: UserSurveyType) => {
|
||||
try {
|
||||
setStep('surveyFinished');
|
||||
// Artificial delay to build up expectations.
|
||||
recommendationsGettingDelayPromise.current = delay(2500);
|
||||
await Promise.all([
|
||||
onEditProfile({ survey }, preferences),
|
||||
recommendationsGettingDelayPromise.current,
|
||||
]);
|
||||
sendUserSurveyCompleted();
|
||||
clearUserSurveyPersistedState();
|
||||
} catch (error) {
|
||||
console.error('An error occurred when sending survey:', error);
|
||||
setErrorSendingSurvey(true);
|
||||
} finally {
|
||||
recommendationsGettingDelayPromise.current = null;
|
||||
setStep('recommendations');
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (!authenticatedUser.authenticated) clearUserSurveyPersistedState();
|
||||
},
|
||||
[authenticatedUser.authenticated]
|
||||
);
|
||||
|
||||
if (
|
||||
(creatingOrLoggingInAccount || loginState === 'loggingIn') &&
|
||||
// Do not display loader if the user is already seeing the recommendations.
|
||||
// It can happen when the user profile is refreshed while the recommendations
|
||||
// are displayed. This way, the loader is not displayed unnecessarily.
|
||||
step !== 'recommendations' &&
|
||||
!recommendationsGettingDelayPromise.current
|
||||
) {
|
||||
return (
|
||||
<SectionContainer flexBody>
|
||||
<ColumnStackLayout
|
||||
noMargin
|
||||
expand
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<ColumnStackLayout
|
||||
noMargin
|
||||
expand
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<CircularProgress size={40} />
|
||||
</ColumnStackLayout>
|
||||
</ColumnStackLayout>
|
||||
</SectionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isOnline || errorSendingSurvey) {
|
||||
return (
|
||||
<SectionContainer flexBody>
|
||||
<ColumnStackLayout
|
||||
noMargin
|
||||
expand
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
{errorSendingSurvey ? (
|
||||
<>
|
||||
<Text size="title" align="center">
|
||||
<Trans>Error when sending survey.</Trans>
|
||||
</Text>
|
||||
<TreeLeaves style={styles.icon} />
|
||||
<Text size="body2" noMargin align="center">
|
||||
<Trans>
|
||||
Verify your internet connection and try again later.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="title" align="center">
|
||||
<Trans>You seem to be offline</Trans>
|
||||
</Text>
|
||||
<TreeLeaves style={styles.icon} />
|
||||
<Text size="body2" noMargin align="center">
|
||||
<Trans>
|
||||
Verify your internet connection to access your personalized
|
||||
content.
|
||||
</Trans>
|
||||
</Text>
|
||||
<div style={styles.middlePageButtonContainer}>
|
||||
<Line expand>
|
||||
<RaisedButton
|
||||
primary
|
||||
label={<Trans>Refresh</Trans>}
|
||||
onClick={forceUpdate}
|
||||
fullWidth
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
</SectionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'surveyFinished') {
|
||||
return (
|
||||
<SectionContainer flexBody>
|
||||
<ColumnStackLayout
|
||||
noMargin
|
||||
expand
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Text size="title" align="center">
|
||||
<Trans>Alright let's see what we have for you...</Trans>
|
||||
</Text>
|
||||
<img
|
||||
src={questionnaireFinishedImageSource}
|
||||
alt="You as the red hero coming back to life"
|
||||
style={styles.questionnaireFinishedImage}
|
||||
/>
|
||||
<Text size="body2" noMargin align="center">
|
||||
<Trans>Just one second please...</Trans>
|
||||
</Text>
|
||||
<Line>
|
||||
<LinearProgress
|
||||
variant="indeterminate"
|
||||
style={styles.linearProgress}
|
||||
/>
|
||||
</Line>
|
||||
</ColumnStackLayout>
|
||||
</SectionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'recommendations') {
|
||||
return (
|
||||
<>
|
||||
<SectionContainer flexBody showUrgentAnnouncements>
|
||||
<RecommendationList
|
||||
authenticatedUser={authenticatedUser}
|
||||
selectInAppTutorial={selectInAppTutorial}
|
||||
onOpenProfile={onOpenProfile}
|
||||
onStartSurvey={
|
||||
profile
|
||||
? () => {
|
||||
setStep('survey');
|
||||
}
|
||||
: null
|
||||
}
|
||||
hasFilledSurveyAlready={profile ? !!profile.survey : false}
|
||||
onCreateProjectFromExample={onCreateProjectFromExample}
|
||||
askToCloseProject={askToCloseProject}
|
||||
/>
|
||||
{authenticatedUser.recommendations && (
|
||||
<Line justifyContent="center" alignItems="center">
|
||||
<Checkbox
|
||||
label={<Trans>Don't show this screen on next startup</Trans>}
|
||||
checked={!preferences.showGetStartedSectionByDefault}
|
||||
onCheck={(e, checked) =>
|
||||
setShowGetStartedSectionByDefault(!checked)
|
||||
}
|
||||
/>
|
||||
</Line>
|
||||
)}
|
||||
<LargeSpacer />
|
||||
</SectionContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UserSurvey
|
||||
onCompleted={onSurveyFinished}
|
||||
onStarted={onUserSurveyStarted}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const GetStartedSectionWithErrorBoundary = (props: Props) => (
|
||||
<ErrorBoundary
|
||||
componentTitle={<Trans>Get started section</Trans>}
|
||||
scope="start-page-get-started"
|
||||
>
|
||||
<GetStartedSection {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
export default GetStartedSectionWithErrorBoundary;
|
@@ -10,7 +10,6 @@ import HammerIcon from '../../../UI/CustomSvgIcons/Hammer';
|
||||
import SchoolIcon from '../../../UI/CustomSvgIcons/School';
|
||||
import ControllerIcon from '../../../UI/CustomSvgIcons/Controller';
|
||||
import BookLeafIcon from '../../../UI/CustomSvgIcons/BookLeaf';
|
||||
import SunIcon from '../../../UI/CustomSvgIcons/Sun';
|
||||
import StoreIcon from '../../../UI/CustomSvgIcons/Store';
|
||||
import Preferences from '../../../UI/CustomSvgIcons/Preferences';
|
||||
import GDevelopGLogo from '../../../UI/CustomSvgIcons/GDevelopGLogo';
|
||||
@@ -43,13 +42,7 @@ export const styles = {
|
||||
},
|
||||
};
|
||||
|
||||
export type HomeTab =
|
||||
| 'get-started'
|
||||
| 'create'
|
||||
| 'learn'
|
||||
| 'play'
|
||||
| 'shop'
|
||||
| 'team-view';
|
||||
export type HomeTab = 'create' | 'learn' | 'play' | 'shop' | 'team-view';
|
||||
|
||||
export type GetIconFunction = ({
|
||||
color: string,
|
||||
@@ -64,14 +57,6 @@ export type HomePageMenuTab = {|
|
||||
|};
|
||||
|
||||
const homePageMenuTabs: { [tab: HomeTab]: HomePageMenuTab } = {
|
||||
'get-started': {
|
||||
label: <Trans>Start</Trans>,
|
||||
tab: 'get-started',
|
||||
id: 'home-get-started-tab',
|
||||
getIcon: ({ color, fontSize }) => (
|
||||
<SunIcon fontSize={fontSize} color={color} />
|
||||
),
|
||||
},
|
||||
create: {
|
||||
label: <Trans>Create</Trans>,
|
||||
tab: 'create',
|
||||
@@ -131,15 +116,14 @@ export const getTabsToDisplay = ({
|
||||
limits.capabilities.classrooms &&
|
||||
limits.capabilities.classrooms.hidePremiumProducts
|
||||
);
|
||||
const displayTeachTab =
|
||||
!shouldHideClassroomTab(limits) && !isNativeMobileApp();
|
||||
const tabs: HomeTab[] = [
|
||||
'get-started',
|
||||
'create',
|
||||
!shouldHideClassroomTab(limits) && !isNativeMobileApp()
|
||||
? 'team-view'
|
||||
: null,
|
||||
displayShopTab ? 'shop' : null,
|
||||
'learn',
|
||||
'create',
|
||||
displayPlayTab ? 'play' : null,
|
||||
displayShopTab ? 'shop' : null,
|
||||
displayTeachTab ? 'team-view' : null,
|
||||
].filter(Boolean);
|
||||
return tabs.map(tab => homePageMenuTabs[tab]);
|
||||
};
|
||||
|
@@ -0,0 +1,60 @@
|
||||
// @flow
|
||||
import { getTabsToDisplay } from './HomePageMenu';
|
||||
import { isNativeMobileApp } from '../../../Utils/Platform';
|
||||
import { limitsForStudentUser } from '../../../fixtures/GDevelopServicesTestData';
|
||||
|
||||
jest.mock('../../../Utils/Platform');
|
||||
const mockFn = (fn: Function): JestMockFn<any, any> => fn;
|
||||
|
||||
describe('HomePageMenu', () => {
|
||||
describe('getTabsToDisplay', () => {
|
||||
beforeEach(() => {
|
||||
mockFn(isNativeMobileApp).mockReset();
|
||||
});
|
||||
|
||||
test('Default desktop user', () => {
|
||||
mockFn(isNativeMobileApp).mockReturnValue(false);
|
||||
|
||||
const tabs = getTabsToDisplay({ limits: null });
|
||||
|
||||
expect(tabs.map(tab => tab.tab)).toEqual([
|
||||
'learn',
|
||||
'create',
|
||||
'play',
|
||||
'shop',
|
||||
'team-view',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Desktop user student', () => {
|
||||
mockFn(isNativeMobileApp).mockReturnValue(false);
|
||||
|
||||
const tabs = getTabsToDisplay({
|
||||
limits: limitsForStudentUser,
|
||||
});
|
||||
|
||||
expect(tabs.map(tab => tab.tab)).toEqual(['learn', 'create']);
|
||||
});
|
||||
|
||||
test('Default mobile user', () => {
|
||||
mockFn(isNativeMobileApp).mockReturnValue(true);
|
||||
|
||||
const tabs = getTabsToDisplay({ limits: null });
|
||||
|
||||
expect(tabs.map(tab => tab.tab)).toEqual([
|
||||
'learn',
|
||||
'create',
|
||||
'play',
|
||||
'shop',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Mobile student user', () => {
|
||||
mockFn(isNativeMobileApp).mockReturnValue(true);
|
||||
|
||||
const tabs = getTabsToDisplay({ limits: limitsForStudentUser });
|
||||
|
||||
expect(tabs.map(tab => tab.tab)).toEqual(['learn', 'create']);
|
||||
});
|
||||
});
|
||||
});
|
@@ -1,7 +1,6 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import GridList from '@material-ui/core/GridList';
|
||||
import GridListTile from '@material-ui/core/GridListTile';
|
||||
import {
|
||||
useResponsiveWindowSize,
|
||||
@@ -44,6 +43,8 @@ import TopDownRPGMovement from './Icons/TopDownRPGMovement';
|
||||
import FireABullet from './Icons/FireAbullet';
|
||||
import CoopPlatformer from './Icons/CoopPlatformer';
|
||||
import TilemapPlatformer from './Icons/TilemapPlatformer';
|
||||
import Carousel from '../../../../UI/Carousel';
|
||||
import { GridList } from '@material-ui/core';
|
||||
|
||||
const getColumnsFromWindowSize = (
|
||||
windowSize: WindowSizeType,
|
||||
@@ -63,6 +64,7 @@ const getColumnsFromWindowSize = (
|
||||
}
|
||||
};
|
||||
|
||||
const NUMBER_OF_SCROLLS = 2; // Number of times the carousel can be scrolled to see all items.
|
||||
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
|
||||
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
|
||||
const ITEMS_SPACING = 5;
|
||||
@@ -80,13 +82,109 @@ const styles = {
|
||||
},
|
||||
};
|
||||
|
||||
export const allInAppTutorialsCards = [
|
||||
{
|
||||
id: KNIGHT_PLATFORMER_IN_APP_TUTORIAL_ID,
|
||||
title: t`Platformer`,
|
||||
description: t`Make a knight jump and run in this platformer game.`,
|
||||
shortDescription: t`Make a knight jump and run.`,
|
||||
durationInMinutes: 1,
|
||||
renderImage: (props: any) => <Platformer {...props} />,
|
||||
},
|
||||
{
|
||||
id: CAMERA_PARALLAX_IN_APP_TUTORIAL_ID,
|
||||
title: t`Background and cameras`,
|
||||
shortDescription: t`Follow a character with scrolling background.`,
|
||||
description: t`Follow this Castlevania-type character with the camera, while the background scrolls.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: (props: any) => <Parallax {...props} />,
|
||||
},
|
||||
{
|
||||
id: TOP_DOWN_RPG_MOVEMENT_ID,
|
||||
title: t`Top-Down RPG Pixel Perfect`,
|
||||
shortDescription: t`Move like in retro Pokemon games.`,
|
||||
description: t`Make a character move like in a retro Pokemon game.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: (props: any) => <TopDownRPGMovement {...props} />,
|
||||
},
|
||||
{
|
||||
id: JOYSTICK_IN_APP_TUTORIAL_ID,
|
||||
title: t`Joystick controls`,
|
||||
shortDescription: t`Control a spaceship with a joystick.`,
|
||||
description: t`Control your spaceship with a joystick, while avoiding asteroids.`,
|
||||
durationInMinutes: 1,
|
||||
renderImage: (props: any) => <Joystick {...props} />,
|
||||
},
|
||||
{
|
||||
id: OBJECT_3D_IN_APP_TUTORIAL_ID,
|
||||
title: t`3D platforms`,
|
||||
shortDescription: t`Place 3D platforms in a 2D game.`,
|
||||
description: t`Place 3D platforms in this 2D platformer, creating a path to the end.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: (props: any) => <Object3D {...props} />,
|
||||
},
|
||||
{
|
||||
id: HEALTH_BAR_IN_APP_TUTORIAL_ID,
|
||||
title: t`Health bar`,
|
||||
shortDescription: t`Add a health bar for handle damage.`,
|
||||
description: t`Add a health bar to this jumping character, losing health when hitting spikes.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: (props: any) => <HealthBar {...props} />,
|
||||
},
|
||||
{
|
||||
id: TILEMAP_PLATFORMER_ID,
|
||||
title: t`Paint a Level with Tiles`,
|
||||
shortDescription: t`Build dynamic levels with tiles.`,
|
||||
description: t`Use a Tilemap to build a level and change it dynamically during the game.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: (props: any) => <TilemapPlatformer {...props} />,
|
||||
},
|
||||
{
|
||||
id: FIRE_A_BULLET_ID,
|
||||
title: t`Fire a Bullet`,
|
||||
shortDescription: t`Fire bullets in an Asteroids game.`,
|
||||
description: t`Fire bullets in this Asteroids game. Get ready for a Star Wars show.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: (props: any) => <FireABullet {...props} />,
|
||||
},
|
||||
{
|
||||
id: TIMER_IN_APP_TUTORIAL_ID,
|
||||
title: t`Time score`,
|
||||
shortDescription: t`Add a time attack mode.`,
|
||||
description: t`Add a time attack mode, where you have to reach the end as fast as possible.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: (props: any) => <Timer {...props} />,
|
||||
},
|
||||
{
|
||||
id: COOP_PLATFORMER_ID,
|
||||
title: t`Co-op Multiplayer`,
|
||||
shortDescription: t`Transform a game into a multiplayer experience.`,
|
||||
description: t`Transform this platformer into a co-op game, where two players can play together.`,
|
||||
durationInMinutes: 3,
|
||||
renderImage: (props: any) => <CoopPlatformer {...props} />,
|
||||
},
|
||||
{
|
||||
id: PLINKO_MULTIPLIER_IN_APP_TUTORIAL_ID,
|
||||
title: t`Score multiplier`,
|
||||
shortDescription: t`Multiply scores with collectibles.`,
|
||||
description: t`Transform this Plinko game with collectibles that multiply your score.`,
|
||||
durationInMinutes: 3,
|
||||
renderImage: (props: any) => <MultiplierScore {...props} />,
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {|
|
||||
selectInAppTutorial: (tutorialId: string) => void,
|
||||
/** To use to restrict the lessons that are displayed. */
|
||||
lessonsIds?: ?Array<string>,
|
||||
displayAsCarousel?: boolean,
|
||||
|};
|
||||
|
||||
const GuidedLessons = ({ selectInAppTutorial, lessonsIds }: Props) => {
|
||||
const GuidedLessons = ({
|
||||
selectInAppTutorial,
|
||||
lessonsIds,
|
||||
displayAsCarousel,
|
||||
}: Props) => {
|
||||
const isOnline = useOnlineStatus();
|
||||
const {
|
||||
inAppTutorialShortHeaders,
|
||||
@@ -103,6 +201,12 @@ const GuidedLessons = ({ selectInAppTutorial, lessonsIds }: Props) => {
|
||||
}: {|
|
||||
tutorialId: string,
|
||||
|}) => {
|
||||
if (authenticatedUser.loginState === 'loggingIn') {
|
||||
// Wait for login to finish to avoid showing an offline progress before the
|
||||
// actual user's progress.
|
||||
return 0;
|
||||
}
|
||||
|
||||
const tutorialProgress = getTutorialProgress({
|
||||
tutorialId,
|
||||
userId: authenticatedUser.profile
|
||||
@@ -113,109 +217,34 @@ const GuidedLessons = ({ selectInAppTutorial, lessonsIds }: Props) => {
|
||||
return tutorialProgress.progress[0]; // guided lessons only have one part.
|
||||
};
|
||||
|
||||
const displayedGuidedLessonsIds = lessonsIds || guidedLessonsIds;
|
||||
|
||||
const lessonsCompleted = displayedGuidedLessonsIds.reduce(
|
||||
(acc, tutorialId) => {
|
||||
const tutorialProgress = getTutorialPartProgress({ tutorialId }) || 0;
|
||||
return tutorialProgress === 100 ? acc + 1 : acc;
|
||||
},
|
||||
0
|
||||
);
|
||||
const lessonsCompleted = guidedLessonsIds.reduce((acc, tutorialId) => {
|
||||
const tutorialProgress = getTutorialPartProgress({ tutorialId }) || 0;
|
||||
return tutorialProgress === 100 ? acc + 1 : acc;
|
||||
}, 0);
|
||||
const lessonsProgress = Math.round(
|
||||
(lessonsCompleted / displayedGuidedLessonsIds.length) * 100
|
||||
(lessonsCompleted / guidedLessonsIds.length) * 100
|
||||
);
|
||||
const numberOfColumns = getColumnsFromWindowSize(windowSize, isLandscape);
|
||||
|
||||
const guidedLessonCards = [
|
||||
{
|
||||
id: KNIGHT_PLATFORMER_IN_APP_TUTORIAL_ID,
|
||||
title: t`Platformer`,
|
||||
description: t`Make a knight jump and run in this platformer game.`,
|
||||
shortDescription: t`Make a knight jump and run.`,
|
||||
durationInMinutes: 1,
|
||||
renderImage: props => <Platformer {...props} />,
|
||||
const guidedLessonCards = React.useMemo(
|
||||
() => {
|
||||
let displayedGuidedLessonsIds = guidedLessonsIds;
|
||||
if (lessonsIds) {
|
||||
displayedGuidedLessonsIds = lessonsIds;
|
||||
}
|
||||
if (displayAsCarousel) {
|
||||
displayedGuidedLessonsIds = displayedGuidedLessonsIds.slice(
|
||||
0,
|
||||
numberOfColumns * (NUMBER_OF_SCROLLS + 1)
|
||||
);
|
||||
}
|
||||
|
||||
return allInAppTutorialsCards.filter(item =>
|
||||
displayedGuidedLessonsIds.includes(item.id)
|
||||
);
|
||||
},
|
||||
{
|
||||
id: CAMERA_PARALLAX_IN_APP_TUTORIAL_ID,
|
||||
title: t`Background and cameras`,
|
||||
shortDescription: t`Follow a character with scrolling background.`,
|
||||
description: t`Follow this Castlevania-type character with the camera, while the background scrolls.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: props => <Parallax {...props} />,
|
||||
},
|
||||
{
|
||||
id: TOP_DOWN_RPG_MOVEMENT_ID,
|
||||
title: t`Top-Down RPG Pixel Perfect`,
|
||||
shortDescription: t`Move like in retro Pokemon games.`,
|
||||
description: t`Make a character move like in a retro Pokemon game.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: props => <TopDownRPGMovement {...props} />,
|
||||
},
|
||||
{
|
||||
id: JOYSTICK_IN_APP_TUTORIAL_ID,
|
||||
title: t`Joystick controls`,
|
||||
shortDescription: t`Control a spaceship with a joystick.`,
|
||||
description: t`Control your spaceship with a joystick, while avoiding asteroids.`,
|
||||
durationInMinutes: 1,
|
||||
renderImage: props => <Joystick {...props} />,
|
||||
},
|
||||
{
|
||||
id: OBJECT_3D_IN_APP_TUTORIAL_ID,
|
||||
title: t`3D platforms`,
|
||||
shortDescription: t`Place 3D platforms in a 2D game.`,
|
||||
description: t`Place 3D platforms in this 2D platformer, creating a path to the end.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: props => <Object3D {...props} />,
|
||||
},
|
||||
{
|
||||
id: HEALTH_BAR_IN_APP_TUTORIAL_ID,
|
||||
title: t`Health bar`,
|
||||
shortDescription: t`Add a health bar for handle damage.`,
|
||||
description: t`Add a health bar to this jumping character, losing health when hitting spikes.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: props => <HealthBar {...props} />,
|
||||
},
|
||||
{
|
||||
id: TILEMAP_PLATFORMER_ID,
|
||||
title: t`Paint a Level with Tiles`,
|
||||
shortDescription: t`Build dynamic levels with tiles.`,
|
||||
description: t`Use a Tilemap to build a level and change it dynamically during the game.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: props => <TilemapPlatformer {...props} />,
|
||||
},
|
||||
{
|
||||
id: FIRE_A_BULLET_ID,
|
||||
title: t`Fire a Bullet`,
|
||||
shortDescription: t`Fire bullets in an Asteroids game.`,
|
||||
description: t`Fire bullets in this Asteroids game. Get ready for a Star Wars show.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: props => <FireABullet {...props} />,
|
||||
},
|
||||
{
|
||||
id: TIMER_IN_APP_TUTORIAL_ID,
|
||||
title: t`Time score`,
|
||||
shortDescription: t`Add a time attack mode.`,
|
||||
description: t`Add a time attack mode, where you have to reach the end as fast as possible.`,
|
||||
durationInMinutes: 2,
|
||||
renderImage: props => <Timer {...props} />,
|
||||
},
|
||||
{
|
||||
id: COOP_PLATFORMER_ID,
|
||||
title: t`Co-op Multiplayer`,
|
||||
shortDescription: t`Transform a game into a multiplayer experience.`,
|
||||
description: t`Transform this platformer into a co-op game, where two players can play together.`,
|
||||
durationInMinutes: 3,
|
||||
renderImage: props => <CoopPlatformer {...props} />,
|
||||
},
|
||||
{
|
||||
id: PLINKO_MULTIPLIER_IN_APP_TUTORIAL_ID,
|
||||
title: t`Score multiplier`,
|
||||
shortDescription: t`Multiply scores with collectibles.`,
|
||||
description: t`Transform this Plinko game with collectibles that multiply your score.`,
|
||||
durationInMinutes: 3,
|
||||
renderImage: props => <MultiplierScore {...props} />,
|
||||
},
|
||||
].filter(item => displayedGuidedLessonsIds.includes(item.id));
|
||||
[lessonsIds, displayAsCarousel, numberOfColumns]
|
||||
);
|
||||
|
||||
return (
|
||||
<Line>
|
||||
@@ -241,30 +270,58 @@ const GuidedLessons = ({ selectInAppTutorial, lessonsIds }: Props) => {
|
||||
<ColoredLinearProgress value={lessonsProgress} />
|
||||
</LineStackLayout>
|
||||
</Column>
|
||||
<GridList
|
||||
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={ITEMS_SPACING * 2}
|
||||
>
|
||||
{guidedLessonCards.map(item => (
|
||||
<GridListTile key={item.id}>
|
||||
<InAppTutorialPhaseCard
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
shortDescription={item.shortDescription}
|
||||
durationInMinutes={item.durationInMinutes}
|
||||
renderImage={item.renderImage}
|
||||
progress={getTutorialPartProgress({ tutorialId: item.id })}
|
||||
onClick={() => selectInAppTutorial(item.id)}
|
||||
// Phase is disabled if there's a running tutorial or if offline,
|
||||
// because we cannot fetch the tutorial.
|
||||
disabled={!!currentlyRunningInAppTutorial || !isOnline}
|
||||
loading={!inAppTutorialShortHeaders}
|
||||
/>
|
||||
</GridListTile>
|
||||
))}
|
||||
</GridList>
|
||||
{displayAsCarousel ? (
|
||||
<Carousel
|
||||
items={guidedLessonCards.map(item => ({
|
||||
renderItem: () => (
|
||||
<GridListTile key={item.id}>
|
||||
<InAppTutorialPhaseCard
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
shortDescription={item.shortDescription}
|
||||
durationInMinutes={item.durationInMinutes}
|
||||
renderImage={item.renderImage}
|
||||
progress={getTutorialPartProgress({
|
||||
tutorialId: item.id,
|
||||
})}
|
||||
onClick={() => selectInAppTutorial(item.id)}
|
||||
// Phase is disabled if there's a running tutorial or if offline,
|
||||
// because we cannot fetch the tutorial.
|
||||
disabled={!!currentlyRunningInAppTutorial || !isOnline}
|
||||
loading={!inAppTutorialShortHeaders}
|
||||
/>
|
||||
</GridListTile>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<GridList
|
||||
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={ITEMS_SPACING * 2}
|
||||
>
|
||||
{guidedLessonCards.map(item => (
|
||||
<GridListTile key={item.id}>
|
||||
<InAppTutorialPhaseCard
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
shortDescription={item.shortDescription}
|
||||
durationInMinutes={item.durationInMinutes}
|
||||
renderImage={item.renderImage}
|
||||
progress={getTutorialPartProgress({
|
||||
tutorialId: item.id,
|
||||
})}
|
||||
onClick={() => selectInAppTutorial(item.id)}
|
||||
// Phase is disabled if there's a running tutorial or if offline,
|
||||
// because we cannot fetch the tutorial.
|
||||
disabled={!!currentlyRunningInAppTutorial || !isOnline}
|
||||
loading={!inAppTutorialShortHeaders}
|
||||
/>
|
||||
</GridListTile>
|
||||
))}
|
||||
</GridList>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
)}
|
||||
</div>
|
||||
|
@@ -15,6 +15,18 @@ import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
|
||||
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
const getChipColorFromDuration = (durationInMinutes: number) => {
|
||||
if (durationInMinutes <= 2) return '#3BF7F4';
|
||||
if (durationInMinutes <= 5) return '#FFBC57';
|
||||
return '#FF8569';
|
||||
};
|
||||
|
||||
const getChipTextFromDuration = (durationInMinutes: number) => {
|
||||
if (durationInMinutes <= 2) return <Trans>Beginner</Trans>;
|
||||
if (durationInMinutes <= 5) return <Trans>Intermediate</Trans>;
|
||||
return <Trans>Advanced</Trans>;
|
||||
};
|
||||
|
||||
const getImageSize = ({ isMobile }: { isMobile: boolean }) =>
|
||||
isMobile ? 90 : 130;
|
||||
|
||||
@@ -23,7 +35,7 @@ const styles = {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '10px 10px 15px 10px',
|
||||
padding: '8px 16px',
|
||||
},
|
||||
lockerImage: { height: 80, width: 80 },
|
||||
imageContainer: {
|
||||
@@ -37,6 +49,9 @@ const styles = {
|
||||
textAlign: 'left',
|
||||
overflowWrap: 'anywhere',
|
||||
},
|
||||
chip: {
|
||||
height: 24,
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
@@ -118,87 +133,107 @@ const InAppTutorialPhaseCard = ({
|
||||
}}
|
||||
>
|
||||
<ColumnStackLayout
|
||||
noMargin
|
||||
expand
|
||||
justifyContent="flex-start"
|
||||
justifyContent="space-between"
|
||||
useFullHeight
|
||||
noOverflowParent
|
||||
>
|
||||
{progress && progress > 0 ? (
|
||||
progress !== 100 ? (
|
||||
<LineStackLayout alignItems="center" noMargin>
|
||||
<Text displayInlineAsSpan noMargin size="body2">
|
||||
{progress}%
|
||||
</Text>
|
||||
<ColoredLinearProgress value={progress} />
|
||||
</LineStackLayout>
|
||||
) : (
|
||||
<ColumnStackLayout
|
||||
noMargin
|
||||
expand
|
||||
justifyContent="flex-start"
|
||||
useFullHeight
|
||||
noOverflowParent
|
||||
>
|
||||
<Text
|
||||
size="sub-title"
|
||||
noMargin
|
||||
color="primary"
|
||||
align="left"
|
||||
style={getTextStyle(shouldTextBeDisabled)}
|
||||
>
|
||||
{i18n._(title)}
|
||||
</Text>
|
||||
<Text noMargin align="left" color="secondary">
|
||||
{isMobile ? i18n._(shortDescription) : i18n._(description)}
|
||||
</Text>
|
||||
{keyPoints && <Divider />}
|
||||
{keyPoints && (
|
||||
<Column
|
||||
noMargin
|
||||
alignItems="flex-start"
|
||||
justifyContent="flex-start"
|
||||
expand
|
||||
>
|
||||
<ul style={styles.keyPointsList}>
|
||||
{keyPoints.map((keyPoint, index) => (
|
||||
<Text
|
||||
key={`key-point-${index}`}
|
||||
size="body2"
|
||||
noMargin
|
||||
style={getTextStyle(shouldTextBeDisabled)}
|
||||
color="secondary"
|
||||
displayAsListItem
|
||||
>
|
||||
{i18n._(keyPoint)}
|
||||
</Text>
|
||||
))}
|
||||
</ul>
|
||||
</Column>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
<Line justifyContent="space-between" alignItems="flex-end">
|
||||
<Chip
|
||||
style={{
|
||||
...styles.chip,
|
||||
border: `1px solid ${getChipColorFromDuration(
|
||||
durationInMinutes || 0
|
||||
)}`,
|
||||
}}
|
||||
label={getChipTextFromDuration(durationInMinutes || 0)}
|
||||
variant="outlined"
|
||||
/>
|
||||
{progress && progress > 0 ? (
|
||||
progress !== 100 ? (
|
||||
<LineStackLayout alignItems="center" noMargin>
|
||||
<Text displayInlineAsSpan noMargin size="body2">
|
||||
{progress}%
|
||||
</Text>
|
||||
<ColoredLinearProgress value={progress} />
|
||||
</LineStackLayout>
|
||||
) : (
|
||||
<Line noMargin justifyContent="center">
|
||||
<Chip
|
||||
size="small"
|
||||
label={<Trans>Finished</Trans>}
|
||||
style={{
|
||||
backgroundColor:
|
||||
gdevelopTheme.statusIndicator.success,
|
||||
color: '#111111',
|
||||
}}
|
||||
/>
|
||||
</Line>
|
||||
)
|
||||
) : durationInMinutes ? (
|
||||
<Line noMargin justifyContent="center">
|
||||
<Chip
|
||||
size="small"
|
||||
label={<Trans>Finished</Trans>}
|
||||
style={{
|
||||
backgroundColor:
|
||||
gdevelopTheme.statusIndicator.success,
|
||||
color: '#111111',
|
||||
}}
|
||||
label={
|
||||
isMobile ? (
|
||||
<Trans>{durationInMinutes} min.</Trans>
|
||||
) : durationInMinutes === 1 ? (
|
||||
<Trans>1 minute</Trans>
|
||||
) : (
|
||||
<Trans>{durationInMinutes} minutes</Trans>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Line>
|
||||
)
|
||||
) : durationInMinutes ? (
|
||||
<Line noMargin justifyContent="center">
|
||||
<Chip
|
||||
size="small"
|
||||
label={
|
||||
durationInMinutes === 1 ? (
|
||||
<Trans>1 minute</Trans>
|
||||
) : (
|
||||
<Trans>{durationInMinutes} minutes</Trans>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Line>
|
||||
) : (
|
||||
<Spacer />
|
||||
)}
|
||||
<Text
|
||||
size="block-title"
|
||||
noMargin
|
||||
style={getTextStyle(shouldTextBeDisabled)}
|
||||
color="primary"
|
||||
>
|
||||
{i18n._(title)}
|
||||
</Text>
|
||||
<Text
|
||||
size="body"
|
||||
noMargin
|
||||
style={getTextStyle(shouldTextBeDisabled)}
|
||||
color="secondary"
|
||||
>
|
||||
{isMobile ? i18n._(shortDescription) : i18n._(description)}
|
||||
</Text>
|
||||
{keyPoints && <Divider />}
|
||||
{keyPoints && (
|
||||
<Column
|
||||
noMargin
|
||||
alignItems="flex-start"
|
||||
justifyContent="flex-start"
|
||||
expand
|
||||
>
|
||||
<ul style={styles.keyPointsList}>
|
||||
{keyPoints.map((keyPoint, index) => (
|
||||
<Text
|
||||
key={`key-point-${index}`}
|
||||
size="body2"
|
||||
noMargin
|
||||
style={getTextStyle(shouldTextBeDisabled)}
|
||||
color="secondary"
|
||||
displayAsListItem
|
||||
>
|
||||
{i18n._(keyPoint)}
|
||||
</Text>
|
||||
))}
|
||||
</ul>
|
||||
</Column>
|
||||
)}
|
||||
) : (
|
||||
<Spacer />
|
||||
)}
|
||||
</Line>
|
||||
</ColumnStackLayout>
|
||||
</div>
|
||||
</Column>
|
||||
|
@@ -5,14 +5,24 @@ import { I18n } from '@lingui/react';
|
||||
import { ColumnStackLayout, LineStackLayout } from '../../../../UI/Layout';
|
||||
import Text from '../../../../UI/Text';
|
||||
import { CardWidget } from '../CardWidget';
|
||||
import { Column, Line, Spacer } from '../../../../UI/Grid';
|
||||
import { Column, Line } from '../../../../UI/Grid';
|
||||
import ColoredLinearProgress from '../../../../UI/ColoredLinearProgress';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
|
||||
import { type CourseCompletion } from '../UseCourses';
|
||||
import { type Course } from '../../../../Utils/GDevelopServices/Asset';
|
||||
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
|
||||
import { textEllipsisStyle } from '../../../../UI/TextEllipsis';
|
||||
import Skeleton from '@material-ui/lab/Skeleton';
|
||||
import { getProductPriceOrOwnedLabel } from '../../../../AssetStore/ProductPriceTag';
|
||||
import Chip from '../../../../UI/Chip';
|
||||
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
|
||||
|
||||
export const getChipColorFromEnglishLevel = (englishLevel: string) => {
|
||||
if (englishLevel.toLowerCase().includes('advanced')) return '#FF8569';
|
||||
if (englishLevel.toLowerCase().includes('intermediate')) return '#FFBC57';
|
||||
return '#3BF7F4';
|
||||
};
|
||||
|
||||
const styles = {
|
||||
cardTextContainer: {
|
||||
@@ -20,8 +30,11 @@ const styles = {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '8px 16px 8px 16px',
|
||||
// Fix the height so that the card has a consistent height,
|
||||
// When owned or not.
|
||||
minHeight: 220,
|
||||
},
|
||||
image: { width: '100%' },
|
||||
image: { width: '100%', aspectRatio: '16 / 9' },
|
||||
specializationDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
@@ -39,7 +52,6 @@ const styles = {
|
||||
alignItems: 'stretch',
|
||||
aspectRatio: '16 / 9',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
},
|
||||
newLabel: {
|
||||
position: 'absolute',
|
||||
@@ -51,6 +63,9 @@ const styles = {
|
||||
color: 'white',
|
||||
padding: '2px 6px',
|
||||
},
|
||||
chip: {
|
||||
height: 24,
|
||||
},
|
||||
};
|
||||
|
||||
const specializationLabels = {
|
||||
@@ -64,10 +79,15 @@ const specializationColors = {
|
||||
marketing: '#FD3AE6',
|
||||
};
|
||||
|
||||
const getSpecializationConfig = (
|
||||
export const getSpecializationConfig = (
|
||||
specializationId: string
|
||||
): {| label: React.Node, color: string |} => {
|
||||
let label = specializationLabels[specializationId];
|
||||
let label =
|
||||
specializationId === 'loading' ? (
|
||||
<Trans>Loading</Trans>
|
||||
) : (
|
||||
specializationLabels[specializationId]
|
||||
);
|
||||
if (!label) {
|
||||
console.warn(
|
||||
`No label found for specializationId "${specializationId}". Using default label.`
|
||||
@@ -92,10 +112,17 @@ const getSpecializationConfig = (
|
||||
type Props = {|
|
||||
completion: ?CourseCompletion,
|
||||
course: ?Course,
|
||||
courseListingData: ?CourseListingData,
|
||||
onClick?: () => void,
|
||||
|};
|
||||
|
||||
const CourseCard = ({ completion, course, onClick }: Props) => {
|
||||
const CourseCard = ({
|
||||
completion,
|
||||
course,
|
||||
courseListingData,
|
||||
onClick,
|
||||
}: Props) => {
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
const specializationConfig = getSpecializationConfig(
|
||||
course ? course.specializationId : 'loading'
|
||||
);
|
||||
@@ -103,7 +130,7 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<CardWidget onClick={onClick} size={'large'}>
|
||||
{course && onClick ? (
|
||||
{course && courseListingData && onClick ? (
|
||||
<Column expand noMargin noOverflowParent>
|
||||
<div style={styles.imageContainer}>
|
||||
<img
|
||||
@@ -151,25 +178,29 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
|
||||
{specializationConfig.label}
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
{completion && (
|
||||
<LineStackLayout alignItems="center" noMargin>
|
||||
<ColoredLinearProgress
|
||||
value={
|
||||
(completion.completedChapters /
|
||||
completion.chapters) *
|
||||
100
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
displayInlineAsSpan
|
||||
size="body-small"
|
||||
noMargin
|
||||
color="secondary"
|
||||
>
|
||||
{completion.completedChapters}/{completion.chapters}
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
)}
|
||||
<LineStackLayout alignItems="center" noMargin>
|
||||
<ColoredLinearProgress
|
||||
value={
|
||||
completion
|
||||
? (completion.completedChapters /
|
||||
completion.chapters) *
|
||||
100
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
displayInlineAsSpan
|
||||
size="body-small"
|
||||
noMargin
|
||||
color="secondary"
|
||||
>
|
||||
{completion
|
||||
? `${completion.completedChapters}/${
|
||||
completion.chapters
|
||||
}`
|
||||
: '-/-'}
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
|
||||
<Text
|
||||
size="sub-title"
|
||||
@@ -186,18 +217,31 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
|
||||
)}
|
||||
</Text>
|
||||
</ColumnStackLayout>
|
||||
<Line justifyContent="space-between">
|
||||
<Text size="body-small" noMargin color="secondary">
|
||||
{selectMessageByLocale(i18n, course.levelByLocale)}
|
||||
</Text>
|
||||
<Text size="body-small" noMargin color="secondary">
|
||||
{course.durationInWeeks === 1 ? (
|
||||
<Trans>1 week</Trans>
|
||||
) : (
|
||||
<Trans>{course.durationInWeeks} weeks</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Line>
|
||||
<div style={{ color: gdevelopTheme.text.color.secondary }}>
|
||||
<Line justifyContent="space-between" alignItems="flex-end">
|
||||
<Chip
|
||||
style={{
|
||||
...styles.chip,
|
||||
border: `1px solid ${getChipColorFromEnglishLevel(
|
||||
course.levelByLocale.en
|
||||
)}`,
|
||||
}}
|
||||
label={selectMessageByLocale(
|
||||
i18n,
|
||||
course.levelByLocale
|
||||
)}
|
||||
variant="outlined"
|
||||
/>
|
||||
{getProductPriceOrOwnedLabel({
|
||||
i18n,
|
||||
gdevelopTheme,
|
||||
productListingData: courseListingData,
|
||||
usageType: 'default',
|
||||
showBothPrices: 'column',
|
||||
owned: !course.isLocked,
|
||||
})}
|
||||
</Line>
|
||||
</div>
|
||||
</ColumnStackLayout>
|
||||
</div>
|
||||
</Column>
|
||||
@@ -211,10 +255,8 @@ const CourseCard = ({ completion, course, onClick }: Props) => {
|
||||
<Skeleton height={20} />
|
||||
<Skeleton height={20} />
|
||||
<Skeleton height={30} />
|
||||
<Skeleton height={100} />
|
||||
<Skeleton height={50} />
|
||||
<Spacer />
|
||||
<Skeleton height={15} />
|
||||
<Spacer />
|
||||
</Column>
|
||||
</Line>
|
||||
</Column>
|
||||
|
@@ -0,0 +1,328 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
|
||||
import Dialog, { DialogPrimaryButton } from '../../../../UI/Dialog';
|
||||
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
|
||||
import CreateProfile from '../../../../Profile/CreateProfile';
|
||||
import Text from '../../../../UI/Text';
|
||||
import { useInterval } from '../../../../Utils/UseInterval';
|
||||
import { getPurchaseCheckoutUrl } from '../../../../Utils/GDevelopServices/Shop';
|
||||
import { type Course } from '../../../../Utils/GDevelopServices/Asset';
|
||||
import Window from '../../../../Utils/Window';
|
||||
import { Line, Spacer } from '../../../../UI/Grid';
|
||||
import CircularProgress from '../../../../UI/CircularProgress';
|
||||
import BackgroundText from '../../../../UI/BackgroundText';
|
||||
import Mark from '../../../../UI/CustomSvgIcons/Mark';
|
||||
import FlatButton from '../../../../UI/FlatButton';
|
||||
import { LineStackLayout } from '../../../../UI/Layout';
|
||||
import useAlertDialog from '../../../../UI/Alert/useAlertDialog';
|
||||
import { extractGDevelopApiErrorStatusAndCode } from '../../../../Utils/GDevelopServices/Errors';
|
||||
import PasswordPromptDialog from '../../../../AssetStore/PasswordPromptDialog';
|
||||
|
||||
type Props = {|
|
||||
course: Course,
|
||||
courseListingData: CourseListingData,
|
||||
onClose: () => void,
|
||||
|};
|
||||
|
||||
const CoursePurchaseDialog = ({
|
||||
course,
|
||||
courseListingData,
|
||||
onClose,
|
||||
}: Props) => {
|
||||
const {
|
||||
profile,
|
||||
onOpenLoginDialog,
|
||||
onOpenCreateAccountDialog,
|
||||
onPurchaseSuccessful,
|
||||
onRefreshCoursePurchases,
|
||||
coursePurchases,
|
||||
loginState,
|
||||
} = React.useContext(AuthenticatedUserContext);
|
||||
const [isPurchasing, setIsPurchasing] = React.useState(false);
|
||||
const [
|
||||
isCheckingPurchasesAfterLogin,
|
||||
setIsCheckingPurchasesAfterLogin,
|
||||
] = React.useState(loginState === 'loggingIn');
|
||||
const [purchaseSuccessful, setPurchaseSuccessful] = React.useState(false);
|
||||
const [
|
||||
displayPasswordPrompt,
|
||||
setDisplayPasswordPrompt,
|
||||
] = React.useState<boolean>(false);
|
||||
const [password, setPassword] = React.useState<string>('');
|
||||
const { showAlert } = useAlertDialog();
|
||||
|
||||
const onStartPurchase = async () => {
|
||||
if (!profile) return;
|
||||
setDisplayPasswordPrompt(false);
|
||||
|
||||
// Note: we don't handle purchasing a course through the App Store for now.
|
||||
|
||||
const price = courseListingData.prices.find(
|
||||
price => price.usageType === 'default'
|
||||
);
|
||||
if (!price) {
|
||||
console.error('Unable to find the price for the usage type default');
|
||||
await showAlert({
|
||||
title: t`An error happened`,
|
||||
message: t`Unable to find the price for this course. Please try again later.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsPurchasing(true);
|
||||
const checkoutUrl = getPurchaseCheckoutUrl({
|
||||
productId: courseListingData.id,
|
||||
priceName: price.name,
|
||||
userId: profile.id,
|
||||
userEmail: profile.email,
|
||||
...(password ? { password } : undefined),
|
||||
});
|
||||
Window.openExternalURL(checkoutUrl);
|
||||
} catch (error) {
|
||||
const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode(
|
||||
error
|
||||
);
|
||||
if (
|
||||
extractedStatusAndCode &&
|
||||
extractedStatusAndCode.status === 403 &&
|
||||
extractedStatusAndCode.code === 'auth/wrong-password'
|
||||
) {
|
||||
await showAlert({
|
||||
title: t`Operation not allowed`,
|
||||
message: t`The password you entered is incorrect. Please try again.`,
|
||||
});
|
||||
} else {
|
||||
console.error('Unable to get the checkout URL', error);
|
||||
await showAlert({
|
||||
title: t`An error happened`,
|
||||
message: t`Unable to get the checkout URL. Please try again later.`,
|
||||
});
|
||||
}
|
||||
setIsPurchasing(false);
|
||||
} finally {
|
||||
setPassword('');
|
||||
}
|
||||
};
|
||||
|
||||
const onWillPurchase = () => {
|
||||
// Password is required in dev environment only so that one cannot freely purchase courses.
|
||||
if (Window.isDev()) setDisplayPasswordPrompt(true);
|
||||
else onStartPurchase();
|
||||
};
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
onWillPurchase();
|
||||
},
|
||||
// Launch the start process directly when the dialog is opened, to avoid an extra click.
|
||||
// eslint-disable-next-line
|
||||
[]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
const checkIfPurchaseIsDone = async () => {
|
||||
if (
|
||||
isPurchasing &&
|
||||
coursePurchases &&
|
||||
coursePurchases.find(
|
||||
userPurchase => userPurchase.productId === courseListingData.id
|
||||
)
|
||||
) {
|
||||
// We found the purchase, the user has bought the course.
|
||||
// We do not close the dialog yet, as we need to trigger a refresh of the products received.
|
||||
await onPurchaseSuccessful();
|
||||
}
|
||||
};
|
||||
checkIfPurchaseIsDone();
|
||||
},
|
||||
[
|
||||
isPurchasing,
|
||||
coursePurchases,
|
||||
courseListingData,
|
||||
onPurchaseSuccessful,
|
||||
onRefreshCoursePurchases,
|
||||
]
|
||||
);
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
onRefreshCoursePurchases();
|
||||
},
|
||||
isPurchasing ? 3900 : null
|
||||
);
|
||||
|
||||
// Listen to the login state, to know when a user has just logged in and the courses are being fetched.
|
||||
// In this case, start a timeout to remove the loader and give some time for the courses to refresh.
|
||||
React.useEffect(
|
||||
() => {
|
||||
let timeoutId;
|
||||
(async () => {
|
||||
if (loginState === 'done') {
|
||||
timeoutId = setTimeout(
|
||||
() => setIsCheckingPurchasesAfterLogin(false),
|
||||
3000
|
||||
);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
},
|
||||
[loginState]
|
||||
);
|
||||
|
||||
// If the user has received this particular course, either:
|
||||
// - they just logged in, and already have it, so we close the dialog.
|
||||
// - they just bought it, we display the success message.
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (course) {
|
||||
if (!course.isLocked) {
|
||||
if (isPurchasing) {
|
||||
setIsPurchasing(false);
|
||||
setPurchaseSuccessful(true);
|
||||
} else if (!purchaseSuccessful) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[course, courseListingData, isPurchasing, onClose, purchaseSuccessful]
|
||||
);
|
||||
|
||||
const dialogContents = !profile
|
||||
? {
|
||||
subtitle: <Trans>Log-in to purchase this course</Trans>,
|
||||
content: (
|
||||
<CreateProfile
|
||||
onOpenLoginDialog={onOpenLoginDialog}
|
||||
onOpenCreateAccountDialog={onOpenCreateAccountDialog}
|
||||
message={
|
||||
<Trans>
|
||||
Courses will be linked to your user account and available
|
||||
indefinitely. Log in or sign up to purchase this course or
|
||||
restore a previous purchase.
|
||||
</Trans>
|
||||
}
|
||||
justifyContent="center"
|
||||
/>
|
||||
),
|
||||
}
|
||||
: purchaseSuccessful
|
||||
? {
|
||||
subtitle: <Trans>Your purchase has been processed!</Trans>,
|
||||
content: (
|
||||
<Line justifyContent="center" alignItems="center">
|
||||
<Text>
|
||||
<Trans>You can now go back to the course.</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
),
|
||||
}
|
||||
: isPurchasing
|
||||
? {
|
||||
subtitle: <Trans>Complete your payment on the web browser</Trans>,
|
||||
content: (
|
||||
<>
|
||||
<Line justifyContent="center" alignItems="center">
|
||||
<CircularProgress size={20} />
|
||||
<Spacer />
|
||||
<Text>
|
||||
<Trans>Waiting for the purchase confirmation...</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
<Spacer />
|
||||
<Line justifyContent="center">
|
||||
<BackgroundText>
|
||||
<Trans>
|
||||
Once you're done, come back to GDevelop and the assets will be
|
||||
added to your account automatically.
|
||||
</Trans>
|
||||
</BackgroundText>
|
||||
</Line>
|
||||
</>
|
||||
),
|
||||
}
|
||||
: isCheckingPurchasesAfterLogin
|
||||
? {
|
||||
subtitle: <Trans>Loading your profile...</Trans>,
|
||||
content: (
|
||||
<Line justifyContent="center" alignItems="center">
|
||||
<CircularProgress size={20} />
|
||||
</Line>
|
||||
),
|
||||
}
|
||||
: {
|
||||
subtitle: (
|
||||
<Trans>
|
||||
The course {courseListingData.name} will be linked to your account{' '}
|
||||
{profile.email}.
|
||||
</Trans>
|
||||
),
|
||||
content: (
|
||||
<Line justifyContent="center" alignItems="center">
|
||||
<Text>
|
||||
<Trans>
|
||||
A new secure window will open to complete the purchase.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
),
|
||||
};
|
||||
|
||||
const allowPurchase =
|
||||
profile &&
|
||||
!isPurchasing &&
|
||||
!purchaseSuccessful &&
|
||||
!isCheckingPurchasesAfterLogin;
|
||||
const dialogActions = [
|
||||
<FlatButton
|
||||
key="cancel"
|
||||
label={purchaseSuccessful ? <Trans>Close</Trans> : <Trans>Cancel</Trans>}
|
||||
onClick={onClose}
|
||||
/>,
|
||||
allowPurchase ? (
|
||||
<DialogPrimaryButton
|
||||
key="continue"
|
||||
primary
|
||||
label={<Trans>Continue</Trans>}
|
||||
onClick={onWillPurchase}
|
||||
/>
|
||||
) : null,
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
title={<Trans>{courseListingData.name}</Trans>}
|
||||
maxWidth="sm"
|
||||
open
|
||||
onRequestClose={onClose}
|
||||
actions={dialogActions}
|
||||
onApply={purchaseSuccessful ? onClose : onWillPurchase}
|
||||
cannotBeDismissed // Prevent the user from continuing by clicking outside.
|
||||
flexColumnBody
|
||||
>
|
||||
<LineStackLayout justifyContent="center" alignItems="center">
|
||||
{purchaseSuccessful && <Mark />}
|
||||
<Text size="sub-title">{dialogContents.subtitle}</Text>
|
||||
</LineStackLayout>
|
||||
{dialogContents.content}
|
||||
</Dialog>
|
||||
{displayPasswordPrompt && (
|
||||
<PasswordPromptDialog
|
||||
onApply={onStartPurchase}
|
||||
onClose={() => setDisplayPasswordPrompt(false)}
|
||||
passwordValue={password}
|
||||
setPasswordValue={setPassword}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursePurchaseDialog;
|
@@ -1,12 +1,14 @@
|
||||
// @flow
|
||||
|
||||
import * as React from 'react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import {
|
||||
type CourseChapter,
|
||||
type Course,
|
||||
} from '../../../../Utils/GDevelopServices/Asset';
|
||||
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
|
||||
import SectionContainer from '../SectionContainer';
|
||||
import VideoBasedCourseChapterView from '../../../../Course/VideoBasedCourseChapterView';
|
||||
import TextBasedCourseChapterView from '../../../../Course/TextBasedCourseChapterView';
|
||||
@@ -32,8 +34,7 @@ import {
|
||||
AccordionBody,
|
||||
AccordionHeader,
|
||||
} from '../../../../UI/Accordion';
|
||||
import AnyQuestionDialog from '../AnyQuestionDialog';
|
||||
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
|
||||
import CourseSectionHeader from './CourseSectionHeader';
|
||||
|
||||
const styles = {
|
||||
desktopContainer: { display: 'flex', gap: 16 },
|
||||
@@ -101,7 +102,20 @@ type Props = {|
|
||||
isTaskCompleted: (chapterId: string, taskIndex: number) => boolean,
|
||||
getChapterCompletion: (chapterId: string) => CourseChapterCompletion | null,
|
||||
getCourseCompletion: () => CourseCompletion | null,
|
||||
onBuyCourseChapterWithCredits: (CourseChapter, string) => Promise<void>,
|
||||
onBuyCourseWithCredits: (
|
||||
Course: Course,
|
||||
password: string,
|
||||
i18n: I18nType
|
||||
) => Promise<void>,
|
||||
onBuyCourse: (
|
||||
Course: Course,
|
||||
password: string,
|
||||
i18n: I18nType
|
||||
) => Promise<void>,
|
||||
purchasingCourseListingData: ?CourseListingData,
|
||||
setPurchasingCourseListingData: (CourseListingData | null) => void,
|
||||
simulateAppStoreProduct?: boolean,
|
||||
onOpenAskAi: (mode?: 'chat' | 'agent') => void,
|
||||
|};
|
||||
|
||||
const CourseSection = ({
|
||||
@@ -113,15 +127,17 @@ const CourseSection = ({
|
||||
isTaskCompleted,
|
||||
getChapterCompletion,
|
||||
getCourseCompletion,
|
||||
onBuyCourseChapterWithCredits,
|
||||
onBuyCourseWithCredits,
|
||||
onBuyCourse,
|
||||
purchasingCourseListingData,
|
||||
setPurchasingCourseListingData,
|
||||
simulateAppStoreProduct,
|
||||
onOpenAskAi,
|
||||
}: Props) => {
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
const { showAlertMessage, values } = React.useContext(PreferencesContext);
|
||||
const { isMobile, isLandscape } = useResponsiveWindowSize();
|
||||
const courseCompletion = getCourseCompletion();
|
||||
const [isAnyQuestionDialogOpen, setIsAnyQuestionDialogOpen] = React.useState(
|
||||
false
|
||||
);
|
||||
const firstIncompleteChapterIdRef = React.useRef<string | null>(
|
||||
courseChapters.reduce((alreadyFoundIncompleteChapterId, chapter, index) => {
|
||||
if (alreadyFoundIncompleteChapterId)
|
||||
@@ -275,6 +291,19 @@ const CourseSection = ({
|
||||
[scrollToChapter]
|
||||
);
|
||||
|
||||
const onClickUnlock = React.useCallback(
|
||||
() => {
|
||||
// Scroll to the top of the page, where the purchase button is.
|
||||
if (scrollingContainerRef.current) {
|
||||
scrollingContainerRef.current.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
},
|
||||
[scrollingContainerRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
@@ -283,11 +312,6 @@ const CourseSection = ({
|
||||
ref={scrollingContainerRef}
|
||||
applyTopSpacingAsMarginOnChildrenContainer
|
||||
backAction={onBack}
|
||||
title={selectMessageByLocale(i18n, course.titleByLocale)}
|
||||
subtitleText={selectMessageByLocale(
|
||||
i18n,
|
||||
course.shortDescriptionByLocale
|
||||
)}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
@@ -295,6 +319,16 @@ const CourseSection = ({
|
||||
}
|
||||
>
|
||||
<Column noOverflowParent noMargin>
|
||||
<CourseSectionHeader
|
||||
course={course}
|
||||
onBuyCourseWithCredits={onBuyCourseWithCredits}
|
||||
onBuyCourse={onBuyCourse}
|
||||
purchasingCourseListingData={purchasingCourseListingData}
|
||||
setPurchasingCourseListingData={
|
||||
setPurchasingCourseListingData
|
||||
}
|
||||
simulateAppStoreProduct={simulateAppStoreProduct}
|
||||
/>
|
||||
{!values.hiddenAlertMessages[alertMessageKey] && subtitleHint && (
|
||||
<Line>
|
||||
<AlertMessage
|
||||
@@ -310,6 +344,7 @@ const CourseSection = ({
|
||||
chapter.videoUrl ? (
|
||||
<VideoBasedCourseChapterView
|
||||
chapterIndex={index}
|
||||
course={course}
|
||||
courseChapter={chapter}
|
||||
onOpenTemplate={() => {
|
||||
onOpenTemplateFromCourseChapter(chapter);
|
||||
@@ -318,7 +353,7 @@ const CourseSection = ({
|
||||
isTaskCompleted={isTaskCompleted}
|
||||
getChapterCompletion={getChapterCompletion}
|
||||
key={chapter.id}
|
||||
onBuyWithCredits={onBuyCourseChapterWithCredits}
|
||||
onClickUnlock={onClickUnlock}
|
||||
ref={_ref => {
|
||||
if (_ref) {
|
||||
chapterTitleRefs.current[index] = {
|
||||
@@ -331,6 +366,7 @@ const CourseSection = ({
|
||||
) : (
|
||||
<TextBasedCourseChapterView
|
||||
chapterIndex={index}
|
||||
course={course}
|
||||
// $FlowIgnore - Flow does not conclude this chapter can only be text-based.
|
||||
courseChapter={chapter}
|
||||
onOpenTemplate={(templateId?: string) => {
|
||||
@@ -340,7 +376,7 @@ const CourseSection = ({
|
||||
isTaskCompleted={isTaskCompleted}
|
||||
getChapterCompletion={getChapterCompletion}
|
||||
key={chapter.id}
|
||||
onBuyWithCredits={onBuyCourseChapterWithCredits}
|
||||
onClickUnlock={onClickUnlock}
|
||||
ref={_ref => {
|
||||
if (_ref) {
|
||||
chapterTitleRefs.current[index] = {
|
||||
@@ -394,9 +430,9 @@ const CourseSection = ({
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
<RaisedButton
|
||||
label={<Trans>Ask a question</Trans>}
|
||||
primary
|
||||
onClick={() => setIsAnyQuestionDialogOpen(true)}
|
||||
label={<Trans>Ask the AI</Trans>}
|
||||
onClick={() => onOpenAskAi('chat')}
|
||||
/>
|
||||
</ColumnStackLayout>
|
||||
</Paper>
|
||||
@@ -405,11 +441,6 @@ const CourseSection = ({
|
||||
)}
|
||||
</div>
|
||||
</SectionContainer>
|
||||
{isAnyQuestionDialogOpen && (
|
||||
<AnyQuestionDialog
|
||||
onClose={() => setIsAnyQuestionDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{isMobile && !isLandscape && (
|
||||
<div
|
||||
style={{
|
||||
|
@@ -0,0 +1,316 @@
|
||||
// @flow
|
||||
|
||||
import * as React from 'react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { type Course } from '../../../../Utils/GDevelopServices/Asset';
|
||||
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
|
||||
import { SectionRow } from '../SectionContainer';
|
||||
import Paper from '../../../../UI/Paper';
|
||||
import Text from '../../../../UI/Text';
|
||||
import { textEllipsisStyle } from '../../../../UI/TextEllipsis';
|
||||
import { Column, Line } from '../../../../UI/Grid';
|
||||
import {
|
||||
ColumnStackLayout,
|
||||
LineStackLayout,
|
||||
ResponsiveLineStackLayout,
|
||||
} from '../../../../UI/Layout';
|
||||
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
|
||||
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
|
||||
import CoursePurchaseDialog from './CoursePurchaseDialog';
|
||||
import {
|
||||
getChipColorFromEnglishLevel,
|
||||
getSpecializationConfig,
|
||||
} from './CourseCard';
|
||||
import { renderProductPrice } from '../../../../AssetStore/ProductPriceTag';
|
||||
import CourseStoreContext from '../../../../Course/CourseStoreContext';
|
||||
import PasswordPromptDialog from '../../../../AssetStore/PasswordPromptDialog';
|
||||
import Window from '../../../../Utils/Window';
|
||||
import { PurchaseProductButtons } from '../../../../AssetStore/ProductPageHelper';
|
||||
import { shouldUseAppStoreProduct } from '../../../../Utils/AppStorePurchases';
|
||||
import { Divider } from '@material-ui/core';
|
||||
import classes from './CourseSectionHeader.module.css';
|
||||
import Gold from '../../../../Profile/Subscription/Icons/Gold';
|
||||
import Chip from '../../../../UI/Chip';
|
||||
|
||||
const styles = {
|
||||
title: { overflowWrap: 'anywhere', textWrap: 'wrap' },
|
||||
image: { width: 300, aspectRatio: '16 / 9' },
|
||||
specializationDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
},
|
||||
imageContainer: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
paper: {
|
||||
zIndex: 2, // Make sure the paper is above the background for the border effect.
|
||||
flex: 1,
|
||||
},
|
||||
diamondIcon: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
chip: {
|
||||
height: 24,
|
||||
},
|
||||
};
|
||||
|
||||
const ResponsiveDivider = () => {
|
||||
const { isMobile, isMediumScreen } = useResponsiveWindowSize();
|
||||
return isMobile || isMediumScreen ? (
|
||||
<Column noMargin>
|
||||
<Divider orientation="horizontal" />
|
||||
</Column>
|
||||
) : (
|
||||
<Line noMargin>
|
||||
<Divider orientation="vertical" />
|
||||
</Line>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
course: Course,
|
||||
onBuyCourseWithCredits: (
|
||||
Course: Course,
|
||||
password: string,
|
||||
i18n: I18nType
|
||||
) => Promise<void>,
|
||||
onBuyCourse: (
|
||||
Course: Course,
|
||||
password: string,
|
||||
i18n: I18nType
|
||||
) => Promise<void>,
|
||||
purchasingCourseListingData: ?CourseListingData,
|
||||
setPurchasingCourseListingData: (CourseListingData | null) => void,
|
||||
simulateAppStoreProduct?: boolean,
|
||||
|};
|
||||
|
||||
const CourseSectionHeader = ({
|
||||
course,
|
||||
onBuyCourseWithCredits,
|
||||
onBuyCourse,
|
||||
purchasingCourseListingData,
|
||||
setPurchasingCourseListingData,
|
||||
simulateAppStoreProduct,
|
||||
}: Props) => {
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
const { listedCourses } = React.useContext(CourseStoreContext);
|
||||
const { isMobile, isMediumScreen } = useResponsiveWindowSize();
|
||||
|
||||
const [
|
||||
displayPasswordPrompt,
|
||||
setDisplayPasswordPrompt,
|
||||
] = React.useState<boolean>(false);
|
||||
const [password, setPassword] = React.useState<string>('');
|
||||
|
||||
const onClickBuyWithCredits = React.useCallback(
|
||||
async (i18n: I18nType) => {
|
||||
if (!course.isLocked) return;
|
||||
setDisplayPasswordPrompt(false);
|
||||
|
||||
await onBuyCourseWithCredits(course, password, i18n);
|
||||
},
|
||||
[course, onBuyCourseWithCredits, password]
|
||||
);
|
||||
const onWillBuyWithCredits = React.useCallback(
|
||||
async (i18n: I18nType) => {
|
||||
// Password is required in dev environment only so that one cannot freely claim asset packs.
|
||||
if (Window.isDev()) setDisplayPasswordPrompt(true);
|
||||
else onClickBuyWithCredits(i18n);
|
||||
},
|
||||
[onClickBuyWithCredits]
|
||||
);
|
||||
|
||||
const specializationConfig = getSpecializationConfig(
|
||||
course ? course.specializationId : 'loading'
|
||||
);
|
||||
const courseListingData = listedCourses
|
||||
? listedCourses.find(listedCourse => listedCourse.id === course.id)
|
||||
: null;
|
||||
const shouldUseOrSimulateAppStoreProduct =
|
||||
shouldUseAppStoreProduct() || simulateAppStoreProduct;
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<>
|
||||
<SectionRow>
|
||||
<Paper background="dark" variant="outlined" style={{ padding: 16 }}>
|
||||
<ColumnStackLayout noMargin>
|
||||
<ResponsiveLineStackLayout
|
||||
noMargin
|
||||
alignItems="center"
|
||||
justifyContent="flex-start"
|
||||
forceMobileLayout={isMediumScreen}
|
||||
expand
|
||||
>
|
||||
<div style={styles.imageContainer}>
|
||||
<img
|
||||
src={selectMessageByLocale(i18n, course.imageUrlByLocale)}
|
||||
style={styles.image}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<ColumnStackLayout expand justifyContent="flex-start">
|
||||
{course.includedInSubscriptions.length && (
|
||||
<Line
|
||||
noMargin
|
||||
alignItems="center"
|
||||
justifyContent={
|
||||
isMobile || isMediumScreen ? 'flex-start' : 'flex-end'
|
||||
}
|
||||
>
|
||||
<div className={classes.premiumContainer}>
|
||||
<Paper style={styles.paper} background="medium">
|
||||
<Column>
|
||||
<Line expand alignItems="center" noMargin>
|
||||
<Gold style={styles.diamondIcon} />
|
||||
<Text>
|
||||
<Trans>
|
||||
Included with GDevelop subscriptions
|
||||
</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
</Paper>
|
||||
</div>
|
||||
</Line>
|
||||
)}
|
||||
<Text size="title" noMargin style={styles.title}>
|
||||
{selectMessageByLocale(i18n, course.titleByLocale)}
|
||||
</Text>
|
||||
<Line noMargin>
|
||||
<Text noMargin>
|
||||
{selectMessageByLocale(
|
||||
i18n,
|
||||
course.shortDescriptionByLocale
|
||||
)}
|
||||
</Text>
|
||||
</Line>
|
||||
</ColumnStackLayout>
|
||||
</ResponsiveLineStackLayout>
|
||||
<ResponsiveLineStackLayout
|
||||
expand
|
||||
justifyContent="space-between"
|
||||
forceMobileLayout={isMediumScreen}
|
||||
>
|
||||
<Column justifyContent="center" expand noMargin>
|
||||
<Text>
|
||||
<Trans>{course.chaptersTargetCount} chapters</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
<ResponsiveDivider />
|
||||
<Column
|
||||
justifyContent="center"
|
||||
alignItems="flex-start"
|
||||
expand
|
||||
noMargin
|
||||
>
|
||||
<Chip
|
||||
style={{
|
||||
...styles.chip,
|
||||
border: `1px solid ${getChipColorFromEnglishLevel(
|
||||
course.levelByLocale.en
|
||||
)}`,
|
||||
}}
|
||||
label={selectMessageByLocale(i18n, course.levelByLocale)}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Column>
|
||||
<ResponsiveDivider />
|
||||
<Column justifyContent="center" expand noMargin>
|
||||
<LineStackLayout alignItems="center" noMargin>
|
||||
<span
|
||||
style={{
|
||||
...styles.specializationDot,
|
||||
backgroundColor: specializationConfig.color,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
displayInlineAsSpan
|
||||
size="body-small"
|
||||
noMargin
|
||||
color="secondary"
|
||||
style={textEllipsisStyle}
|
||||
>
|
||||
{specializationConfig.label}
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
</Column>
|
||||
</ResponsiveLineStackLayout>
|
||||
{course.isLocked && (
|
||||
<Paper background="medium" style={{ padding: 16 }}>
|
||||
{!!courseListingData && (
|
||||
<ResponsiveLineStackLayout
|
||||
justifyContent="space-between"
|
||||
noMargin
|
||||
>
|
||||
{!isMobile && !isMediumScreen && (
|
||||
<Column noMargin justifyContent="center">
|
||||
<div
|
||||
style={{
|
||||
color: gdevelopTheme.text.color.secondary,
|
||||
}}
|
||||
>
|
||||
{renderProductPrice({
|
||||
i18n,
|
||||
productListingData: courseListingData,
|
||||
usageType: 'default',
|
||||
showBothPrices: 'line',
|
||||
})}
|
||||
</div>
|
||||
</Column>
|
||||
)}
|
||||
<PurchaseProductButtons
|
||||
i18n={i18n}
|
||||
productListingData={courseListingData}
|
||||
selectedUsageType="default"
|
||||
onUsageTypeChange={() => {}}
|
||||
simulateAppStoreProduct={
|
||||
shouldUseOrSimulateAppStoreProduct
|
||||
}
|
||||
isAlreadyReceived={!course.isLocked}
|
||||
onClickBuy={() => onBuyCourse(course, password, i18n)}
|
||||
onClickBuyWithCredits={() =>
|
||||
onWillBuyWithCredits(i18n)
|
||||
}
|
||||
/>
|
||||
</ResponsiveLineStackLayout>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
</Paper>
|
||||
</SectionRow>
|
||||
{!!purchasingCourseListingData && (
|
||||
<CoursePurchaseDialog
|
||||
course={course}
|
||||
courseListingData={purchasingCourseListingData}
|
||||
onClose={() => setPurchasingCourseListingData(null)}
|
||||
/>
|
||||
)}
|
||||
{displayPasswordPrompt && (
|
||||
<PasswordPromptDialog
|
||||
onApply={() => onClickBuyWithCredits(i18n)}
|
||||
onClose={() => setDisplayPasswordPrompt(false)}
|
||||
passwordValue={password}
|
||||
setPasswordValue={setPassword}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseSectionHeader;
|
@@ -0,0 +1,20 @@
|
||||
.premiumContainer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 2px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.premiumContainer::before {
|
||||
content: '';
|
||||
display: block;
|
||||
background: linear-gradient(90deg, var(--theme-premium-teal) 0%, var(--theme-premium-orange) 100%);
|
||||
width: 100%;
|
||||
padding-bottom: 100%;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
@@ -0,0 +1,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;
|
@@ -0,0 +1,62 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { I18n } from '@lingui/react';
|
||||
import SectionContainer, { SectionRow } from '../SectionContainer';
|
||||
import Text from '../../../../UI/Text';
|
||||
import { LargeSpacer, Line } from '../../../../UI/Grid';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import PreferencesContext from '../../../Preferences/PreferencesContext';
|
||||
import FlatButton from '../../../../UI/FlatButton';
|
||||
import InAppTutorialContext from '../../../../InAppTutorial/InAppTutorialContext';
|
||||
import GuidedLessons from '../InAppTutorials/GuidedLessons';
|
||||
import FlingGame from '../InAppTutorials/FlingGame';
|
||||
|
||||
type Props = {|
|
||||
onBack: () => void,
|
||||
selectInAppTutorial: (tutorialId: string) => void,
|
||||
|};
|
||||
|
||||
const InAppTutorialsPage = ({ onBack, selectInAppTutorial }: Props) => {
|
||||
const {
|
||||
values: { showInAppTutorialDeveloperMode },
|
||||
} = React.useContext(PreferencesContext);
|
||||
const { onLoadInAppTutorialFromLocalFile } = React.useContext(
|
||||
InAppTutorialContext
|
||||
);
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<SectionContainer backAction={onBack}>
|
||||
<SectionRow>
|
||||
<Line justifyContent="space-between" noMargin alignItems="center">
|
||||
<Text noMargin size="section-title">
|
||||
<Trans>In-app Tutorials</Trans>
|
||||
</Text>
|
||||
{showInAppTutorialDeveloperMode && (
|
||||
<FlatButton
|
||||
label={<Trans>Load local lesson</Trans>}
|
||||
onClick={onLoadInAppTutorialFromLocalFile}
|
||||
/>
|
||||
)}
|
||||
</Line>
|
||||
<GuidedLessons selectInAppTutorial={selectInAppTutorial} />
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<Text size="section-title">
|
||||
<Trans>Create and Publish a Fling game</Trans>
|
||||
</Text>
|
||||
<Text size="body" color="secondary" noMargin>
|
||||
<Trans>
|
||||
3-part tutorial to creating and publishing a game from scratch.
|
||||
</Trans>
|
||||
</Text>
|
||||
<LargeSpacer />
|
||||
<FlingGame selectInAppTutorial={selectInAppTutorial} />
|
||||
</SectionRow>
|
||||
</SectionContainer>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default InAppTutorialsPage;
|
@@ -8,10 +8,7 @@ import { Trans } from '@lingui/macro';
|
||||
import TranslateIcon from '@material-ui/icons/Translate';
|
||||
import { ColumnStackLayout, LineStackLayout } from '../../../../UI/Layout';
|
||||
import { type HomeTab } from '../HomePageMenu';
|
||||
import {
|
||||
type TutorialCategory,
|
||||
type Tutorial,
|
||||
} from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
import { type Tutorial } from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
import SectionContainer, { SectionRow } from '../SectionContainer';
|
||||
import type {
|
||||
Course,
|
||||
@@ -19,51 +16,30 @@ import type {
|
||||
} from '../../../../Utils/GDevelopServices/Asset';
|
||||
import type { CourseCompletion, CourseChapterCompletion } from '../UseCourses';
|
||||
import FlatButton from '../../../../UI/FlatButton';
|
||||
import {
|
||||
useResponsiveWindowSize,
|
||||
type WindowSizeType,
|
||||
} from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { CardWidget, LARGE_WIDGET_SIZE } from '../CardWidget';
|
||||
import GridList from '@material-ui/core/GridList';
|
||||
import GridListTile from '@material-ui/core/GridListTile';
|
||||
import ImageTileRow from '../../../../UI/ImageTileRow';
|
||||
import { formatTutorialToImageTileComponent, TUTORIAL_CATEGORY_TEXTS } from '.';
|
||||
import GuidedLessons from '../InAppTutorials/GuidedLessons';
|
||||
import ArrowRight from '../../../../UI/CustomSvgIcons/ArrowRight';
|
||||
import Upload from '../../../../UI/CustomSvgIcons/Upload';
|
||||
import FlingGame from '../InAppTutorials/FlingGame';
|
||||
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
|
||||
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
|
||||
import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutorials/PrivateTutorialViewDialog';
|
||||
import { EducationCard } from './EducationCard';
|
||||
import InAppTutorialContext from '../../../../InAppTutorial/InAppTutorialContext';
|
||||
import PreferencesContext from '../../../Preferences/PreferencesContext';
|
||||
import RaisedButton from '../../../../UI/RaisedButton';
|
||||
import Help from '../../../../UI/CustomSvgIcons/Help';
|
||||
import AnyQuestionDialog from '../AnyQuestionDialog';
|
||||
import Paper from '../../../../UI/Paper';
|
||||
import CoursePreviewBanner from '../../../../Course/CoursePreviewBanner';
|
||||
import CourseCard from './CourseCard';
|
||||
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
|
||||
import Link from '../../../../UI/Link';
|
||||
import CourseStoreContext from '../../../../Course/CourseStoreContext';
|
||||
import TutorialsRow from './TutorialsRow';
|
||||
import { getColumnsFromWindowSize, type LearnCategory } from './Utils';
|
||||
import ExampleStore from '../../../../AssetStore/ExampleStore';
|
||||
import { type PrivateGameTemplateListingData } from '../../../../Utils/GDevelopServices/Shop';
|
||||
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
|
||||
import Carousel from '../../../../UI/Carousel';
|
||||
|
||||
const getColumnsFromWindowSize = (
|
||||
windowSize: WindowSizeType,
|
||||
isLandscape: boolean
|
||||
) => {
|
||||
switch (windowSize) {
|
||||
case 'small':
|
||||
return isLandscape ? 4 : 2;
|
||||
case 'medium':
|
||||
return 3;
|
||||
case 'large':
|
||||
return 4;
|
||||
case 'xlarge':
|
||||
return 6;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
const NUMBER_OF_SCROLLS = 2; // Number of times the carousel can be scrolled to see all items.
|
||||
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
|
||||
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
|
||||
const ITEMS_SPACING = 5;
|
||||
@@ -82,83 +58,30 @@ const styles = {
|
||||
},
|
||||
};
|
||||
|
||||
const getTutorialsColumnsFromWidth = (
|
||||
windowSize: WindowSizeType,
|
||||
isLandscape: boolean
|
||||
) => {
|
||||
switch (windowSize) {
|
||||
case 'small':
|
||||
return isLandscape ? 5 : 2;
|
||||
case 'medium':
|
||||
return 3;
|
||||
case 'large':
|
||||
return 5;
|
||||
case 'xlarge':
|
||||
return 6;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
type TutorialsRowProps = {|
|
||||
limits: ?Limits,
|
||||
tutorials: Tutorial[],
|
||||
category: TutorialCategory,
|
||||
onSelectCategory: (TutorialCategory | null) => void,
|
||||
onSelectTutorial: (tutorial: Tutorial) => void,
|
||||
|};
|
||||
|
||||
export const TutorialsRow = ({
|
||||
limits,
|
||||
tutorials,
|
||||
category,
|
||||
onSelectCategory,
|
||||
onSelectTutorial,
|
||||
}: TutorialsRowProps) => (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<ImageTileRow
|
||||
title={TUTORIAL_CATEGORY_TEXTS[category].title}
|
||||
description={TUTORIAL_CATEGORY_TEXTS[category].description}
|
||||
items={tutorials
|
||||
.filter(tutorial => tutorial.category === category)
|
||||
.map(tutorial =>
|
||||
formatTutorialToImageTileComponent({
|
||||
i18n,
|
||||
limits,
|
||||
tutorial,
|
||||
onSelectTutorial,
|
||||
})
|
||||
)}
|
||||
onShowAll={() => onSelectCategory(category)}
|
||||
showAllIcon={<ArrowRight fontSize="small" />}
|
||||
getColumnsFromWindowSize={getTutorialsColumnsFromWidth}
|
||||
getLimitFromWindowSize={getTutorialsColumnsFromWidth}
|
||||
/>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
onTabChange: (tab: HomeTab) => void,
|
||||
onSelectCategory: (TutorialCategory | null) => void,
|
||||
tutorials: Array<Tutorial>,
|
||||
onSelectCategory: (category: LearnCategory) => void,
|
||||
selectInAppTutorial: (tutorialId: string) => void,
|
||||
previewedCourse: ?Course,
|
||||
courses: ?(Course[]),
|
||||
previewedCourseChapters: ?(CourseChapter[]),
|
||||
onSelectCourse: (courseId: string | null) => void,
|
||||
onSelectCourse: (courseId: string) => void,
|
||||
getCourseCompletion: (courseId: string) => CourseCompletion | null,
|
||||
getCourseChapterCompletion: (
|
||||
courseId: string,
|
||||
chapterId: string
|
||||
) => CourseChapterCompletion | null,
|
||||
onOpenAskAi: (mode?: 'chat' | 'agent') => void,
|
||||
onOpenNewProjectSetupDialog: () => void,
|
||||
onSelectPrivateGameTemplateListingData: (
|
||||
privateGameTemplateListingData: PrivateGameTemplateListingData
|
||||
) => void,
|
||||
onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void,
|
||||
|};
|
||||
|
||||
const MainPage = ({
|
||||
onTabChange,
|
||||
onSelectCategory,
|
||||
tutorials,
|
||||
selectInAppTutorial,
|
||||
previewedCourse,
|
||||
courses,
|
||||
@@ -166,21 +89,17 @@ const MainPage = ({
|
||||
onSelectCourse,
|
||||
getCourseCompletion,
|
||||
getCourseChapterCompletion,
|
||||
onOpenAskAi,
|
||||
onOpenNewProjectSetupDialog,
|
||||
onSelectPrivateGameTemplateListingData,
|
||||
onSelectExampleShortHeader,
|
||||
}: Props) => {
|
||||
const { limits } = React.useContext(AuthenticatedUserContext);
|
||||
const { onLoadInAppTutorialFromLocalFile } = React.useContext(
|
||||
InAppTutorialContext
|
||||
);
|
||||
const {
|
||||
palette: { type: paletteType },
|
||||
} = React.useContext(GDevelopThemeContext);
|
||||
|
||||
const [isAnyQuestionDialogOpen, setIsAnyQuestionDialogOpen] = React.useState(
|
||||
false
|
||||
);
|
||||
const {
|
||||
values: { showInAppTutorialDeveloperMode },
|
||||
} = React.useContext(PreferencesContext);
|
||||
const { listedCourses } = React.useContext(CourseStoreContext);
|
||||
const {
|
||||
windowSize,
|
||||
isMobile,
|
||||
@@ -188,6 +107,17 @@ const MainPage = ({
|
||||
isMediumScreen,
|
||||
} = useResponsiveWindowSize();
|
||||
|
||||
const displayedCourses = React.useMemo(
|
||||
() => {
|
||||
if (!courses) return null;
|
||||
const numberOfColumnsToScroll =
|
||||
getColumnsFromWindowSize(windowSize, isLandscape) *
|
||||
(NUMBER_OF_SCROLLS + 1);
|
||||
return courses.slice(0, numberOfColumnsToScroll);
|
||||
},
|
||||
[courses, windowSize, isLandscape]
|
||||
);
|
||||
|
||||
const [
|
||||
selectedTutorial,
|
||||
setSelectedTutorial,
|
||||
@@ -217,312 +147,296 @@ const MainPage = ({
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<SectionContainer
|
||||
title={<Trans>Your learning journey starts here</Trans>}
|
||||
customPaperStyle={{
|
||||
backgroundAttachment: 'local',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'top',
|
||||
backgroundSize: isMobile && !isLandscape ? 'contain' : 'auto',
|
||||
backgroundImage: `url('res/premium/premium_dialog_background.png'),${
|
||||
paletteType === 'dark'
|
||||
? 'linear-gradient(180deg, #322659 0px, #3F2458 20px, #1D1D26 200px, #1D1D26 100%)'
|
||||
: 'linear-gradient(180deg, #CBBAFF 0px, #DEBBFF 20px, #F5F5F7 200px, #F5F5F7 100%)'
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<SectionRow>
|
||||
<CoursePreviewBanner
|
||||
course={previewedCourse}
|
||||
courseChapters={previewedCourseChapters}
|
||||
getCourseCompletion={getCourseCompletion}
|
||||
getCourseChapterCompletion={getCourseChapterCompletion}
|
||||
onDisplayCourse={() => {
|
||||
if (!previewedCourse) return;
|
||||
onSelectCourse(previewedCourse.id);
|
||||
onSelectCategory('course');
|
||||
}}
|
||||
/>
|
||||
</SectionRow>
|
||||
const numberOfColumns = getColumnsFromWindowSize(windowSize, isLandscape);
|
||||
|
||||
<SectionRow>
|
||||
<Text size="title">
|
||||
<Trans>GameDev official specialization courses</Trans>
|
||||
</Text>
|
||||
<Line>
|
||||
<GridList
|
||||
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={ITEMS_SPACING * 2}
|
||||
>
|
||||
{courses
|
||||
? courses.map(course => {
|
||||
const completion = getCourseCompletion(course.id);
|
||||
return (
|
||||
<GridListTile key={course.id}>
|
||||
<CourseCard
|
||||
course={course}
|
||||
completion={completion}
|
||||
onClick={() => {
|
||||
onSelectCourse(course.id);
|
||||
onSelectCategory('course');
|
||||
}}
|
||||
/>
|
||||
</GridListTile>
|
||||
);
|
||||
})
|
||||
: new Array(5).fill(0).map((_, index) => (
|
||||
<GridListTile key={`skeleton-course-${index}`}>
|
||||
<CourseCard course={null} completion={null} />
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<SectionContainer
|
||||
chipText={<Trans>Start for free</Trans>}
|
||||
title={<Trans>Official Game Dev courses</Trans>}
|
||||
titleAdornment={
|
||||
<Line noMargin justifyContent="flex-end">
|
||||
<FlatButton
|
||||
onClick={() => onSelectCategory('all-courses')}
|
||||
label={<Trans>See all</Trans>}
|
||||
rightIcon={<ArrowRight fontSize="small" />}
|
||||
/>
|
||||
</Line>
|
||||
}
|
||||
subtitleText={
|
||||
<Trans>
|
||||
Break into the{' '}
|
||||
<Link
|
||||
href={'https://gdevelop.io/blog/indie-mobile-creators-2025'}
|
||||
onClick={() =>
|
||||
Window.openExternalURL(
|
||||
'https://gdevelop.io/blog/indie-mobile-creators-2025'
|
||||
)
|
||||
}
|
||||
>
|
||||
booming industry
|
||||
</Link>{' '}
|
||||
of casual gaming. Sharpen your skills and become a professional.
|
||||
Start for free:
|
||||
</Trans>
|
||||
}
|
||||
customPaperStyle={{
|
||||
backgroundAttachment: 'local',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'top',
|
||||
backgroundSize: isMobile && !isLandscape ? 'contain' : 'auto',
|
||||
backgroundImage: `url('res/premium/premium_dialog_background.png'),${
|
||||
paletteType === 'dark'
|
||||
? 'linear-gradient(180deg, #322659 0px, #3F2458 20px, #1D1D26 200px, #1D1D26 100%)'
|
||||
: 'linear-gradient(180deg, #CBBAFF 0px, #DEBBFF 20px, #F5F5F7 200px, #F5F5F7 100%)'
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<SectionRow>
|
||||
<Line>
|
||||
<Carousel
|
||||
items={
|
||||
displayedCourses && listedCourses
|
||||
? displayedCourses.map(course => {
|
||||
const completion = getCourseCompletion(course.id);
|
||||
const courseListingData = listedCourses.find(
|
||||
listedCourse => listedCourse.id === course.id
|
||||
);
|
||||
return {
|
||||
renderItem: () => (
|
||||
<GridListTile key={course.id}>
|
||||
<CourseCard
|
||||
course={course}
|
||||
courseListingData={courseListingData}
|
||||
completion={completion}
|
||||
onClick={() => {
|
||||
onSelectCourse(course.id);
|
||||
}}
|
||||
/>
|
||||
</GridListTile>
|
||||
),
|
||||
};
|
||||
})
|
||||
: new Array(6).fill(0).map((_, index) => ({
|
||||
renderItem: () => (
|
||||
<GridListTile key={`skeleton-course-${index}`}>
|
||||
<CourseCard
|
||||
course={null}
|
||||
courseListingData={null}
|
||||
completion={null}
|
||||
/>
|
||||
</GridListTile>
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Line>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<LineStackLayout
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
noMargin
|
||||
expand
|
||||
>
|
||||
<Column noMargin>
|
||||
<Text size="section-title">
|
||||
<Trans>In-app tutorials</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
<Column noMargin>
|
||||
<FlatButton
|
||||
onClick={() => onSelectCategory('in-app-tutorials')}
|
||||
label={<Trans>See all</Trans>}
|
||||
rightIcon={<ArrowRight fontSize="small" />}
|
||||
/>
|
||||
</Column>
|
||||
</LineStackLayout>
|
||||
<GuidedLessons
|
||||
selectInAppTutorial={selectInAppTutorial}
|
||||
displayAsCarousel
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="all-tutorials"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<LineStackLayout
|
||||
noMargin
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Column noMargin expand>
|
||||
<Text size="section-title">
|
||||
<Trans>Learn by dissecting ready-made games</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
<Column noMargin expand>
|
||||
<Line noMargin justifyContent="flex-end">
|
||||
<FlatButton
|
||||
onClick={onOpenNewProjectSetupDialog}
|
||||
label={<Trans>See all</Trans>}
|
||||
rightIcon={<ArrowRight fontSize="small" />}
|
||||
/>
|
||||
</Line>
|
||||
</Column>
|
||||
</LineStackLayout>
|
||||
<Spacer />
|
||||
<ExampleStore
|
||||
onSelectExampleShortHeader={onSelectExampleShortHeader}
|
||||
onSelectPrivateGameTemplateListingData={
|
||||
onSelectPrivateGameTemplateListingData
|
||||
}
|
||||
i18n={i18n}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
hideSearch
|
||||
onlyShowGames
|
||||
hidePremiumTemplates
|
||||
limitRowsTo={1}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="full-game"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<LineStackLayout
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
noMargin
|
||||
expand
|
||||
>
|
||||
<Column noMargin>
|
||||
<Text size="section-title">
|
||||
<Trans>Want to know more?</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
<LineStackLayout noMargin>
|
||||
{!isMobile && (
|
||||
<FlatButton
|
||||
onClick={() => {
|
||||
Window.openExternalURL(
|
||||
'https://github.com/GDevelopApp/GDevelop-examples/issues/new/choose'
|
||||
);
|
||||
}}
|
||||
primary
|
||||
leftIcon={<Upload />}
|
||||
label={
|
||||
isMediumScreen ? (
|
||||
<Trans>Submit an example</Trans>
|
||||
) : (
|
||||
<Trans>Submit your project as an example</Trans>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<FlatButton
|
||||
onClick={() => {
|
||||
Window.openExternalURL(
|
||||
'https://airtable.com/shrv295oHlsuS69el'
|
||||
);
|
||||
}}
|
||||
primary
|
||||
leftIcon={<TranslateIcon />}
|
||||
label={
|
||||
isMediumScreen ? (
|
||||
<Trans>Submit a tutorial</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Submit a tutorial translated in your language
|
||||
</Trans>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</LineStackLayout>
|
||||
</LineStackLayout>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<ColumnStackLayout noMargin expand>
|
||||
<Line noMargin>
|
||||
<GridList
|
||||
cols={numberOfColumns}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={ITEMS_SPACING * 2}
|
||||
>
|
||||
<GridListTile cols={1} style={{ background: 'transparent' }}>
|
||||
<Paper
|
||||
background="light"
|
||||
style={{ display: 'flex', height: '100%' }}
|
||||
>
|
||||
<Column expand>
|
||||
<Line expand alignItems="flex-start">
|
||||
<Help />
|
||||
<ColumnStackLayout expand alignItems="flex-start">
|
||||
<Text noMargin size="block-title" align="left">
|
||||
<Trans>Blocked on GDevelop?</Trans>
|
||||
</Text>
|
||||
<RaisedButton
|
||||
size="large"
|
||||
color="success"
|
||||
label={<Trans>Ask the AI</Trans>}
|
||||
rightIcon={<ArrowRight />}
|
||||
onClick={() => onOpenAskAi('chat')}
|
||||
/>
|
||||
</ColumnStackLayout>
|
||||
</Line>
|
||||
</Column>
|
||||
</Paper>
|
||||
</GridListTile>
|
||||
))}
|
||||
</GridList>
|
||||
</Line>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<Line justifyContent="space-between" noMargin alignItems="center">
|
||||
<Text noMargin size="title">
|
||||
<Trans>Guided lessons</Trans>
|
||||
</Text>
|
||||
{showInAppTutorialDeveloperMode && (
|
||||
<FlatButton
|
||||
label={<Trans>Load local lesson</Trans>}
|
||||
onClick={onLoadInAppTutorialFromLocalFile}
|
||||
{helpItems.map((helpItem, index) => (
|
||||
<GridListTile key={index}>
|
||||
<CardWidget
|
||||
onClick={helpItem.action}
|
||||
key={index}
|
||||
size="large"
|
||||
disabled={helpItem.disabled}
|
||||
useDefaultDisabledStyle
|
||||
>
|
||||
<div style={styles.helpItem}>
|
||||
<ColumnStackLayout
|
||||
expand
|
||||
justifyContent="center"
|
||||
useFullHeight
|
||||
>
|
||||
<Text noMargin size="block-title">
|
||||
{helpItem.title}
|
||||
</Text>
|
||||
<Text noMargin size="body" color="secondary">
|
||||
{helpItem.description}
|
||||
</Text>
|
||||
</ColumnStackLayout>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
))}
|
||||
</GridList>
|
||||
</Line>
|
||||
</ColumnStackLayout>
|
||||
</SectionRow>
|
||||
{selectedTutorial && (
|
||||
<PrivateTutorialViewDialog
|
||||
tutorial={selectedTutorial}
|
||||
onClose={() => setSelectedTutorial(null)}
|
||||
/>
|
||||
)}
|
||||
</Line>
|
||||
<GuidedLessons selectInAppTutorial={selectInAppTutorial} />
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<ColumnStackLayout noMargin expand>
|
||||
<Line noMargin>
|
||||
<GridList
|
||||
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
|
||||
style={styles.grid}
|
||||
cellHeight="auto"
|
||||
spacing={ITEMS_SPACING * 2}
|
||||
>
|
||||
{limits &&
|
||||
limits.quotas['ask-question'] &&
|
||||
limits.quotas['ask-question'].max > 0 ? (
|
||||
<GridListTile cols={2} style={{ background: 'transparent' }}>
|
||||
<Paper
|
||||
background="light"
|
||||
style={{ display: 'flex', height: '100%' }}
|
||||
>
|
||||
<Column expand>
|
||||
<Line expand alignItems="flex-start">
|
||||
<Help />
|
||||
<ColumnStackLayout expand alignItems="flex-start">
|
||||
<Text noMargin size="block-title">
|
||||
<Trans>Blocked on GDevelop?</Trans>
|
||||
</Text>
|
||||
<RaisedButton
|
||||
label={
|
||||
<Trans>Ask any question, get an answer</Trans>
|
||||
}
|
||||
size="medium"
|
||||
color="success"
|
||||
onClick={() => {
|
||||
setIsAnyQuestionDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
</ColumnStackLayout>
|
||||
</Line>
|
||||
</Column>
|
||||
</Paper>
|
||||
</GridListTile>
|
||||
) : null}
|
||||
{helpItems.map((helpItem, index) => (
|
||||
<GridListTile key={index}>
|
||||
<CardWidget
|
||||
onClick={helpItem.action}
|
||||
key={index}
|
||||
size="large"
|
||||
disabled={helpItem.disabled}
|
||||
useDefaultDisabledStyle
|
||||
>
|
||||
<div style={styles.helpItem}>
|
||||
<ColumnStackLayout
|
||||
expand
|
||||
justifyContent="center"
|
||||
useFullHeight
|
||||
>
|
||||
<Text noMargin size="block-title">
|
||||
{helpItem.title}
|
||||
</Text>
|
||||
<Text noMargin size="body" color="secondary">
|
||||
{helpItem.description}
|
||||
</Text>
|
||||
</ColumnStackLayout>
|
||||
</div>
|
||||
</CardWidget>
|
||||
</GridListTile>
|
||||
))}
|
||||
</GridList>
|
||||
</Line>
|
||||
</ColumnStackLayout>
|
||||
</SectionRow>
|
||||
<>
|
||||
<SectionRow>
|
||||
<Line noMargin>
|
||||
<Text size="title">
|
||||
<Trans>Courses</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
<Line noMargin>
|
||||
<Text noMargin>
|
||||
<Trans>Learn everything about GDevelop from the ground up</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
{limits &&
|
||||
limits.capabilities.classrooms &&
|
||||
limits.capabilities.classrooms.hideUpgradeNotice ? null : (
|
||||
<>
|
||||
<Spacer />
|
||||
<EducationCard
|
||||
onSeeResources={() => onSelectCategory('education-curriculum')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="official-beginner"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
tutorials={tutorials}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="official-intermediate"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
tutorials={tutorials}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="official-advanced"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
tutorials={tutorials}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<Text noMargin size="section-title">
|
||||
<Trans>Create and Publish a Fling game</Trans>
|
||||
</Text>
|
||||
<Text size="body" color="secondary" noMargin>
|
||||
<Trans>
|
||||
3-part tutorial to creating and publishing a game from scratch.
|
||||
</Trans>
|
||||
</Text>
|
||||
<FlingGame selectInAppTutorial={selectInAppTutorial} />
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<LineStackLayout
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
noMargin
|
||||
expand
|
||||
>
|
||||
<Column noMargin>
|
||||
<Text size="title">
|
||||
<Trans>Guides and tutorials</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
<LineStackLayout noMargin>
|
||||
{!isMobile && (
|
||||
<FlatButton
|
||||
onClick={() => {
|
||||
Window.openExternalURL(
|
||||
'https://github.com/GDevelopApp/GDevelop-examples/issues/new/choose'
|
||||
);
|
||||
}}
|
||||
primary
|
||||
leftIcon={<Upload />}
|
||||
label={
|
||||
isMediumScreen ? (
|
||||
<Trans>Submit an example</Trans>
|
||||
) : (
|
||||
<Trans>Submit your project as an example</Trans>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<FlatButton
|
||||
onClick={() => {
|
||||
Window.openExternalURL(
|
||||
'https://airtable.com/shrv295oHlsuS69el'
|
||||
);
|
||||
}}
|
||||
primary
|
||||
leftIcon={<TranslateIcon />}
|
||||
label={
|
||||
isMediumScreen ? (
|
||||
<Trans>Submit a tutorial</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Submit a tutorial translated in your language
|
||||
</Trans>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</LineStackLayout>
|
||||
</LineStackLayout>
|
||||
<Line noMargin>
|
||||
<Text noMargin>
|
||||
<Trans>Learn by doing</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="education-curriculum"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
tutorials={tutorials}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="full-game"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
tutorials={tutorials}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="game-mechanic"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
tutorials={tutorials}
|
||||
/>
|
||||
</SectionRow>
|
||||
{selectedTutorial && (
|
||||
<PrivateTutorialViewDialog
|
||||
tutorial={selectedTutorial}
|
||||
onClose={() => setSelectedTutorial(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{isAnyQuestionDialogOpen && (
|
||||
<AnyQuestionDialog onClose={() => setIsAnyQuestionDialogOpen(false)} />
|
||||
</SectionContainer>
|
||||
)}
|
||||
</SectionContainer>
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -8,7 +8,10 @@ import {
|
||||
type Tutorial,
|
||||
type TutorialCategory,
|
||||
} from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
import { formatTutorialToImageTileComponent, TUTORIAL_CATEGORY_TEXTS } from '.';
|
||||
import {
|
||||
formatTutorialToImageTileComponent,
|
||||
TUTORIAL_CATEGORY_TEXTS,
|
||||
} from './Utils';
|
||||
import ImageTileGrid from '../../../../UI/ImageTileGrid';
|
||||
import { type WindowSizeType } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
|
||||
@@ -16,6 +19,8 @@ import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutoria
|
||||
import EducationCurriculumLesson from './EducationCurriculumLesson';
|
||||
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
|
||||
import Text from '../../../../UI/Text';
|
||||
import { TutorialContext } from '../../../../Tutorial/TutorialContext';
|
||||
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
|
||||
|
||||
const styles = {
|
||||
educationCurriculumTutorialContainer: {
|
||||
@@ -126,7 +131,7 @@ const getColumnsFromWindowSize = (windowSize: WindowSizeType) => {
|
||||
case 'medium':
|
||||
return 3;
|
||||
case 'large':
|
||||
return 5;
|
||||
return 4;
|
||||
case 'xlarge':
|
||||
return 6;
|
||||
default:
|
||||
@@ -136,7 +141,6 @@ const getColumnsFromWindowSize = (windowSize: WindowSizeType) => {
|
||||
|
||||
type Props = {|
|
||||
onBack: () => void,
|
||||
tutorials: Array<Tutorial>,
|
||||
category: TutorialCategory,
|
||||
onOpenTemplateFromTutorial: string => Promise<void>,
|
||||
onSelectCourse: (courseId: string) => void,
|
||||
@@ -144,22 +148,32 @@ type Props = {|
|
||||
|
||||
const TutorialsCategoryPage = ({
|
||||
category,
|
||||
tutorials,
|
||||
onBack,
|
||||
onOpenTemplateFromTutorial,
|
||||
onSelectCourse,
|
||||
}: Props) => {
|
||||
const { limits } = React.useContext(AuthenticatedUserContext);
|
||||
const { tutorials } = React.useContext(TutorialContext);
|
||||
const texts = TUTORIAL_CATEGORY_TEXTS[category];
|
||||
const filteredTutorials = tutorials.filter(
|
||||
tutorial => tutorial.category === category
|
||||
);
|
||||
const filteredTutorials = tutorials
|
||||
? tutorials.filter(tutorial => tutorial.category === category)
|
||||
: null;
|
||||
|
||||
const [
|
||||
selectedTutorial,
|
||||
setSelectedTutorial,
|
||||
] = React.useState<Tutorial | null>(null);
|
||||
|
||||
if (!filteredTutorials) {
|
||||
return (
|
||||
<SectionContainer flexBody>
|
||||
<SectionRow expand>
|
||||
<PlaceholderLoader />
|
||||
</SectionRow>
|
||||
</SectionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
|
@@ -0,0 +1,92 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { I18n } from '@lingui/react';
|
||||
import SectionContainer, { SectionRow } from '../SectionContainer';
|
||||
import { type Tutorial } from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
|
||||
import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutorials/PrivateTutorialViewDialog';
|
||||
import TutorialsRow from './TutorialsRow';
|
||||
import { getColumnsFromWindowSize, type LearnCategory } from './Utils';
|
||||
|
||||
type Props = {|
|
||||
onSelectCategory: (category: LearnCategory) => void,
|
||||
|};
|
||||
|
||||
const TutorialsPage = ({ onSelectCategory }: Props) => {
|
||||
const { limits } = React.useContext(AuthenticatedUserContext);
|
||||
const [
|
||||
selectedTutorial,
|
||||
setSelectedTutorial,
|
||||
] = React.useState<Tutorial | null>(null);
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<SectionContainer backAction={() => onSelectCategory(null)}>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="official-beginner"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="official-intermediate"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="official-advanced"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="education-curriculum"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="full-game"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow>
|
||||
<TutorialsRow
|
||||
limits={limits}
|
||||
category="game-mechanic"
|
||||
onSelectCategory={onSelectCategory}
|
||||
onSelectTutorial={setSelectedTutorial}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
</SectionRow>
|
||||
{selectedTutorial && (
|
||||
<PrivateTutorialViewDialog
|
||||
tutorial={selectedTutorial}
|
||||
onClose={() => setSelectedTutorial(null)}
|
||||
/>
|
||||
)}
|
||||
</SectionContainer>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default TutorialsPage;
|
@@ -0,0 +1,130 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import {
|
||||
type TutorialCategory,
|
||||
type Tutorial,
|
||||
} from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
import { type WindowSizeType } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import ImageTileRow from '../../../../UI/ImageTileRow';
|
||||
import ArrowRight from '../../../../UI/CustomSvgIcons/ArrowRight';
|
||||
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
|
||||
import {
|
||||
formatTutorialToImageTileComponent,
|
||||
TUTORIAL_CATEGORY_TEXTS,
|
||||
type LearnCategory,
|
||||
} from './Utils';
|
||||
import { TutorialContext } from '../../../../Tutorial/TutorialContext';
|
||||
import Paper from '../../../../UI/Paper';
|
||||
import PlaceholderError from '../../../../UI/PlaceholderError';
|
||||
|
||||
const styles = {
|
||||
paper: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
},
|
||||
};
|
||||
|
||||
type TutorialsRowProps = {|
|
||||
limits: ?Limits,
|
||||
category: TutorialCategory | 'all-tutorials',
|
||||
onSelectCategory: (category: LearnCategory) => void,
|
||||
onSelectTutorial: (tutorial: Tutorial) => void,
|
||||
getColumnsFromWindowSize: (
|
||||
windowSize: WindowSizeType,
|
||||
isLandscape: boolean
|
||||
) => number,
|
||||
|};
|
||||
|
||||
export const TutorialsRow = ({
|
||||
limits,
|
||||
category,
|
||||
onSelectCategory,
|
||||
onSelectTutorial,
|
||||
getColumnsFromWindowSize,
|
||||
}: TutorialsRowProps) => {
|
||||
const {
|
||||
tutorials,
|
||||
error: tutorialLoadingError,
|
||||
fetchTutorials,
|
||||
} = React.useContext(TutorialContext);
|
||||
const title =
|
||||
category === 'all-tutorials' ? (
|
||||
<Trans>Public tutorials</Trans>
|
||||
) : (
|
||||
TUTORIAL_CATEGORY_TEXTS[category].title
|
||||
);
|
||||
const description =
|
||||
category === 'all-tutorials'
|
||||
? null
|
||||
: TUTORIAL_CATEGORY_TEXTS[category].description;
|
||||
|
||||
const getItems = (i18n: I18nType) => {
|
||||
if (!tutorials) return [];
|
||||
|
||||
let filteredTutorials = tutorials;
|
||||
// If category is all, show 2 of each official category.
|
||||
if (category === 'all-tutorials') {
|
||||
const officialBeginnerTutorials = tutorials
|
||||
.filter(tutorial => tutorial.category === 'official-beginner')
|
||||
.slice(0, 2);
|
||||
const officialIntermediateTutorials = tutorials
|
||||
.filter(tutorial => tutorial.category === 'official-intermediate')
|
||||
.slice(0, 2);
|
||||
const officialAdvancedTutorials = tutorials
|
||||
.filter(tutorial => tutorial.category === 'official-advanced')
|
||||
.slice(0, 2);
|
||||
filteredTutorials = [
|
||||
...officialBeginnerTutorials,
|
||||
...officialIntermediateTutorials,
|
||||
...officialAdvancedTutorials,
|
||||
];
|
||||
} else {
|
||||
filteredTutorials = tutorials.filter(
|
||||
tutorial => tutorial.category === category
|
||||
);
|
||||
}
|
||||
return filteredTutorials.map(tutorial =>
|
||||
formatTutorialToImageTileComponent({
|
||||
i18n,
|
||||
limits,
|
||||
tutorial,
|
||||
onSelectTutorial,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
if (tutorialLoadingError) {
|
||||
return (
|
||||
<Paper square style={styles.paper} background="dark">
|
||||
<PlaceholderError onRetry={fetchTutorials}>
|
||||
<Trans>
|
||||
Can't load the tutorials. Verify your internet connection or retry
|
||||
later.
|
||||
</Trans>
|
||||
</PlaceholderError>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<ImageTileRow
|
||||
title={title}
|
||||
description={description}
|
||||
items={getItems(i18n)}
|
||||
isLoading={!tutorials}
|
||||
onShowAll={() => onSelectCategory(category)}
|
||||
showAllIcon={<ArrowRight fontSize="small" />}
|
||||
getColumnsFromWindowSize={getColumnsFromWindowSize}
|
||||
getLimitFromWindowSize={getColumnsFromWindowSize}
|
||||
/>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default TutorialsRow;
|
@@ -0,0 +1,156 @@
|
||||
// @flow
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import * as React from 'react';
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import {
|
||||
canAccessTutorial,
|
||||
type TutorialCategory,
|
||||
type Tutorial,
|
||||
} from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
import { type WindowSizeType } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
|
||||
import { type ImageTileComponent } from '../../../../UI/ImageTileGrid';
|
||||
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
|
||||
import { sendTutorialOpened } from '../../../../Utils/Analytics/EventSender';
|
||||
import Window from '../../../../Utils/Window';
|
||||
import { formatDuration } from '../../../../Utils/Duration';
|
||||
|
||||
export type LearnCategory =
|
||||
| TutorialCategory
|
||||
| null
|
||||
| 'all-tutorials'
|
||||
| 'all-courses'
|
||||
| 'in-app-tutorials';
|
||||
|
||||
export const TUTORIAL_CATEGORY_TEXTS = {
|
||||
'full-game': {
|
||||
title: <Trans>Make an entire game</Trans>,
|
||||
description: <Trans>Make complete games step by step</Trans>,
|
||||
},
|
||||
'game-mechanic': {
|
||||
title: <Trans>Specific game mechanics</Trans>,
|
||||
description: (
|
||||
<Trans>
|
||||
Find how to implement the most common game mechanics and more
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
'official-beginner': {
|
||||
title: <Trans>Beginner course</Trans>,
|
||||
description: <Trans>Learn the fundamental principles of GDevelop</Trans>,
|
||||
},
|
||||
'official-intermediate': {
|
||||
title: <Trans>Intermediate course</Trans>,
|
||||
description: (
|
||||
<Trans>Learn all the game-building mechanics of GDevelop</Trans>
|
||||
),
|
||||
},
|
||||
'official-advanced': {
|
||||
title: <Trans>Advanced course</Trans>,
|
||||
description: <Trans>The icing on the cake</Trans>,
|
||||
},
|
||||
'education-curriculum': {
|
||||
title: <Trans>Education curriculum and resources</Trans>,
|
||||
description: (
|
||||
<Trans>
|
||||
For teachers and educators having the GDevelop Education subscription.
|
||||
Ready to use resources for teaching.
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
course: {
|
||||
title: <Trans>Loading</Trans>,
|
||||
description: <Trans>Loading course...</Trans>,
|
||||
},
|
||||
recommendations: {
|
||||
title: <Trans>Recommendations</Trans>,
|
||||
description: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const getColumnsFromWindowSize = (
|
||||
windowSize: WindowSizeType,
|
||||
isLandscape: boolean
|
||||
) => {
|
||||
switch (windowSize) {
|
||||
case 'small':
|
||||
return isLandscape ? 4 : 2;
|
||||
case 'medium':
|
||||
return 3;
|
||||
case 'large':
|
||||
return 4;
|
||||
case 'xlarge':
|
||||
return 6;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
export const getChipColorFromTutorialCategory = (
|
||||
category: TutorialCategory
|
||||
): string | null => {
|
||||
if (category === 'official-beginner') return '#3BF7F4';
|
||||
if (category === 'official-intermediate') return '#FFBC57';
|
||||
if (category === 'official-advanced') return '#FF8569';
|
||||
if (category === 'full-game') return '#FFBC57';
|
||||
if (category === 'game-mechanic') return '#FFBC57';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getChipTextFromTutorialCategory = (
|
||||
category: TutorialCategory,
|
||||
i18n: I18nType
|
||||
) => {
|
||||
if (category === 'official-beginner') return i18n._(t`Beginner`);
|
||||
if (category === 'official-intermediate') return i18n._(t`Intermediate`);
|
||||
if (category === 'official-advanced') return i18n._(t`Advanced`);
|
||||
if (category === 'full-game') return i18n._(t`Intermediate`);
|
||||
if (category === 'game-mechanic') return i18n._(t`Intermediate`);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
type FormatTutorialToImageTileComponentProps = {|
|
||||
i18n: I18nType,
|
||||
limits: ?Limits,
|
||||
tutorial: Tutorial,
|
||||
onSelectTutorial: (tutorial: Tutorial) => void,
|
||||
|};
|
||||
|
||||
export const formatTutorialToImageTileComponent = ({
|
||||
i18n,
|
||||
tutorial,
|
||||
limits,
|
||||
onSelectTutorial,
|
||||
}: FormatTutorialToImageTileComponentProps): ImageTileComponent => {
|
||||
const isLocked = !canAccessTutorial(
|
||||
tutorial,
|
||||
limits ? limits.capabilities : null
|
||||
);
|
||||
return {
|
||||
title:
|
||||
selectMessageByLocale(i18n, tutorial.titleByLocale) || tutorial.title,
|
||||
description:
|
||||
selectMessageByLocale(i18n, tutorial.descriptionByLocale) ||
|
||||
tutorial.description,
|
||||
isLocked,
|
||||
onClick: () => {
|
||||
if (tutorial.isPrivateTutorial) {
|
||||
onSelectTutorial(tutorial);
|
||||
return;
|
||||
}
|
||||
|
||||
sendTutorialOpened(tutorial.id);
|
||||
Window.openExternalURL(
|
||||
selectMessageByLocale(i18n, tutorial.linkByLocale)
|
||||
);
|
||||
},
|
||||
imageUrl: selectMessageByLocale(i18n, tutorial.thumbnailUrlByLocale),
|
||||
overlayText: tutorial.duration
|
||||
? formatDuration(tutorial.duration)
|
||||
: '\u{1F4D8}',
|
||||
overlayTextPosition: 'bottomRight',
|
||||
chipText: getChipTextFromTutorialCategory(tutorial.category, i18n),
|
||||
chipColor: getChipColorFromTutorialCategory(tutorial.category),
|
||||
};
|
||||
};
|
@@ -3,132 +3,34 @@ import { type I18n as I18nType } from '@lingui/core';
|
||||
import * as React from 'react';
|
||||
import { type HomeTab } from '../HomePageMenu';
|
||||
import {
|
||||
type TutorialCategory,
|
||||
type Tutorial,
|
||||
canAccessTutorial,
|
||||
} from '../../../../Utils/GDevelopServices/Tutorial';
|
||||
type CourseListingData,
|
||||
type PrivateGameTemplateListingData,
|
||||
} from '../../../../Utils/GDevelopServices/Shop';
|
||||
import MainPage from './MainPage';
|
||||
import TutorialsCategoryPage from './TutorialsCategoryPage';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { TutorialContext } from '../../../../Tutorial/TutorialContext';
|
||||
import PlaceholderError from '../../../../UI/PlaceholderError';
|
||||
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
|
||||
import { sendTutorialOpened } from '../../../../Utils/Analytics/EventSender';
|
||||
import Window from '../../../../Utils/Window';
|
||||
import { type ImageTileComponent } from '../../../../UI/ImageTileGrid';
|
||||
import Paper from '../../../../UI/Paper';
|
||||
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
|
||||
import ErrorBoundary from '../../../../UI/ErrorBoundary';
|
||||
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
|
||||
import { formatDuration } from '../../../../Utils/Duration';
|
||||
|
||||
import CourseSection from './CourseSection';
|
||||
import type {
|
||||
CourseChapter,
|
||||
Course,
|
||||
} from '../../../../Utils/GDevelopServices/Asset';
|
||||
import type { CourseChapterCompletion, CourseCompletion } from '../UseCourses';
|
||||
|
||||
export const TUTORIAL_CATEGORY_TEXTS = {
|
||||
'full-game': {
|
||||
title: <Trans>Entire games</Trans>,
|
||||
description: <Trans>Make complete games step by step</Trans>,
|
||||
},
|
||||
'game-mechanic': {
|
||||
title: <Trans>Specific game mechanics</Trans>,
|
||||
description: (
|
||||
<Trans>
|
||||
Find how to implement the most common game mechanics and more
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
'official-beginner': {
|
||||
title: <Trans>Beginner course</Trans>,
|
||||
description: <Trans>Learn the fundamental principles of GDevelop</Trans>,
|
||||
},
|
||||
'official-intermediate': {
|
||||
title: <Trans>Intermediate course</Trans>,
|
||||
description: (
|
||||
<Trans>Learn all the game-building mechanics of GDevelop</Trans>
|
||||
),
|
||||
},
|
||||
'official-advanced': {
|
||||
title: <Trans>Advanced course</Trans>,
|
||||
description: <Trans>The icing on the cake</Trans>,
|
||||
},
|
||||
'education-curriculum': {
|
||||
title: <Trans>Education curriculum and resources</Trans>,
|
||||
description: (
|
||||
<Trans>
|
||||
For teachers and educators having the GDevelop Education subscription.
|
||||
Ready to use resources for teaching.
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
course: {
|
||||
title: <Trans>Loading</Trans>,
|
||||
description: <Trans>Loading course...</Trans>,
|
||||
},
|
||||
recommendations: {
|
||||
title: <Trans>Recommendations</Trans>,
|
||||
description: null,
|
||||
},
|
||||
};
|
||||
|
||||
type FormatTutorialToImageTileComponentProps = {|
|
||||
i18n: I18nType,
|
||||
limits: ?Limits,
|
||||
tutorial: Tutorial,
|
||||
onSelectTutorial: (tutorial: Tutorial) => void,
|
||||
|};
|
||||
|
||||
export const formatTutorialToImageTileComponent = ({
|
||||
i18n,
|
||||
tutorial,
|
||||
limits,
|
||||
onSelectTutorial,
|
||||
}: FormatTutorialToImageTileComponentProps): ImageTileComponent => {
|
||||
const isLocked = !canAccessTutorial(
|
||||
tutorial,
|
||||
limits ? limits.capabilities : null
|
||||
);
|
||||
return {
|
||||
title:
|
||||
selectMessageByLocale(i18n, tutorial.titleByLocale) || tutorial.title,
|
||||
description:
|
||||
selectMessageByLocale(i18n, tutorial.descriptionByLocale) ||
|
||||
tutorial.description,
|
||||
isLocked,
|
||||
onClick: () => {
|
||||
if (tutorial.isPrivateTutorial) {
|
||||
onSelectTutorial(tutorial);
|
||||
return;
|
||||
}
|
||||
|
||||
sendTutorialOpened(tutorial.id);
|
||||
Window.openExternalURL(
|
||||
selectMessageByLocale(i18n, tutorial.linkByLocale)
|
||||
);
|
||||
},
|
||||
imageUrl: selectMessageByLocale(i18n, tutorial.thumbnailUrlByLocale),
|
||||
overlayText: tutorial.duration
|
||||
? formatDuration(tutorial.duration)
|
||||
: '\u{1F4D8}',
|
||||
overlayTextPosition: 'bottomRight',
|
||||
};
|
||||
};
|
||||
|
||||
const styles = {
|
||||
paper: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
},
|
||||
};
|
||||
import SectionContainer, { SectionRow } from '../SectionContainer';
|
||||
import TutorialsPage from './TutorialsPage';
|
||||
import InAppTutorialsPage from './InAppTutorialsPage';
|
||||
import CoursesPage from './CoursesPage';
|
||||
import { type LearnCategory } from './Utils';
|
||||
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
|
||||
|
||||
type Props = {|
|
||||
onTabChange: (tab: HomeTab) => void,
|
||||
selectInAppTutorial: (tutorialId: string) => void,
|
||||
selectedCategory: TutorialCategory | null,
|
||||
onSelectCategory: (TutorialCategory | null) => void,
|
||||
selectedCategory: LearnCategory,
|
||||
onSelectCategory: LearnCategory => void,
|
||||
onOpenTemplateFromTutorial: string => Promise<void>,
|
||||
onOpenTemplateFromCourseChapter: CourseChapter => Promise<void>,
|
||||
previewedCourse: ?Course,
|
||||
@@ -148,7 +50,24 @@ type Props = {|
|
||||
chapterId: string
|
||||
) => CourseChapterCompletion | null,
|
||||
getCourseCompletion: (courseId: string) => CourseCompletion | null,
|
||||
onBuyCourseChapterWithCredits: (CourseChapter, string) => Promise<void>,
|
||||
onBuyCourseWithCredits: (
|
||||
Course: Course,
|
||||
password: string,
|
||||
i18n: I18nType
|
||||
) => Promise<void>,
|
||||
onBuyCourse: (
|
||||
Course: Course,
|
||||
password: string,
|
||||
i18n: I18nType
|
||||
) => Promise<void>,
|
||||
purchasingCourseListingData: ?CourseListingData,
|
||||
setPurchasingCourseListingData: (CourseListingData | null) => void,
|
||||
onOpenAskAi: (mode?: 'chat' | 'agent') => void,
|
||||
onOpenNewProjectSetupDialog: () => void,
|
||||
onSelectPrivateGameTemplateListingData: (
|
||||
privateGameTemplateListingData: PrivateGameTemplateListingData
|
||||
) => void,
|
||||
onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void,
|
||||
|};
|
||||
|
||||
const LearnSection = ({
|
||||
@@ -168,13 +87,16 @@ const LearnSection = ({
|
||||
isCourseTaskCompleted,
|
||||
getCourseChapterCompletion,
|
||||
getCourseCompletion,
|
||||
onBuyCourseChapterWithCredits,
|
||||
onBuyCourseWithCredits,
|
||||
onBuyCourse,
|
||||
purchasingCourseListingData,
|
||||
setPurchasingCourseListingData,
|
||||
onOpenAskAi,
|
||||
onOpenNewProjectSetupDialog,
|
||||
onSelectPrivateGameTemplateListingData,
|
||||
onSelectExampleShortHeader,
|
||||
}: Props) => {
|
||||
const {
|
||||
tutorials,
|
||||
fetchTutorials,
|
||||
error: tutorialLoadingError,
|
||||
} = React.useContext(TutorialContext);
|
||||
const { fetchTutorials } = React.useContext(TutorialContext);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
@@ -183,13 +105,22 @@ const LearnSection = ({
|
||||
[fetchTutorials]
|
||||
);
|
||||
|
||||
if (selectedCategory === 'course' && courseChapters && course) {
|
||||
if (course) {
|
||||
if (!courseChapters) {
|
||||
return (
|
||||
<SectionContainer flexBody>
|
||||
<SectionRow expand>
|
||||
<PlaceholderLoader />
|
||||
</SectionRow>
|
||||
</SectionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CourseSection
|
||||
course={course}
|
||||
courseChapters={courseChapters}
|
||||
onBack={() => {
|
||||
onSelectCategory(null);
|
||||
onSelectCourse(null);
|
||||
}}
|
||||
onOpenTemplateFromCourseChapter={onOpenTemplateFromCourseChapter}
|
||||
@@ -199,30 +130,19 @@ const LearnSection = ({
|
||||
getCourseChapterCompletion(course.id, chapterId)
|
||||
}
|
||||
getCourseCompletion={() => getCourseCompletion(course.id)}
|
||||
onBuyCourseChapterWithCredits={onBuyCourseChapterWithCredits}
|
||||
onBuyCourseWithCredits={onBuyCourseWithCredits}
|
||||
onBuyCourse={onBuyCourse}
|
||||
purchasingCourseListingData={purchasingCourseListingData}
|
||||
setPurchasingCourseListingData={setPurchasingCourseListingData}
|
||||
onOpenAskAi={onOpenAskAi}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (tutorialLoadingError)
|
||||
return (
|
||||
<Paper square style={styles.paper} background="dark">
|
||||
<PlaceholderError onRetry={fetchTutorials}>
|
||||
<Trans>
|
||||
Can't load the tutorials. Verify your internet connection or retry
|
||||
later.
|
||||
</Trans>
|
||||
</PlaceholderError>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
if (!tutorials) return <PlaceholderLoader />;
|
||||
|
||||
return !selectedCategory ? (
|
||||
<MainPage
|
||||
onTabChange={onTabChange}
|
||||
onSelectCategory={onSelectCategory}
|
||||
tutorials={tutorials}
|
||||
selectInAppTutorial={selectInAppTutorial}
|
||||
courses={courses}
|
||||
onSelectCourse={onSelectCourse}
|
||||
@@ -230,17 +150,36 @@ const LearnSection = ({
|
||||
previewedCourseChapters={previewedCourseChapters}
|
||||
getCourseCompletion={getCourseCompletion}
|
||||
getCourseChapterCompletion={getCourseChapterCompletion}
|
||||
onOpenAskAi={onOpenAskAi}
|
||||
onOpenNewProjectSetupDialog={onOpenNewProjectSetupDialog}
|
||||
onSelectPrivateGameTemplateListingData={
|
||||
onSelectPrivateGameTemplateListingData
|
||||
}
|
||||
onSelectExampleShortHeader={onSelectExampleShortHeader}
|
||||
/>
|
||||
) : selectedCategory === 'all-tutorials' ? (
|
||||
<TutorialsPage onSelectCategory={onSelectCategory} />
|
||||
) : selectedCategory === 'in-app-tutorials' ? (
|
||||
<InAppTutorialsPage
|
||||
onBack={() => onSelectCategory(null)}
|
||||
selectInAppTutorial={selectInAppTutorial}
|
||||
/>
|
||||
) : selectedCategory === 'all-courses' ? (
|
||||
<CoursesPage
|
||||
onBack={() => onSelectCategory(null)}
|
||||
courses={courses}
|
||||
onSelectCourse={onSelectCourse}
|
||||
previewedCourse={previewedCourse}
|
||||
previewedCourseChapters={previewedCourseChapters}
|
||||
getCourseCompletion={getCourseCompletion}
|
||||
getCourseChapterCompletion={getCourseChapterCompletion}
|
||||
/>
|
||||
) : (
|
||||
<TutorialsCategoryPage
|
||||
onBack={() => onSelectCategory(null)}
|
||||
onBack={() => onSelectCategory('all-tutorials')}
|
||||
category={selectedCategory}
|
||||
tutorials={tutorials}
|
||||
onOpenTemplateFromTutorial={onOpenTemplateFromTutorial}
|
||||
onSelectCourse={(courseId: string) => {
|
||||
onSelectCourse(courseId);
|
||||
onSelectCategory('course');
|
||||
}}
|
||||
onSelectCourse={onSelectCourse}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@@ -7,10 +7,11 @@ import ArrowLeft from '../../../UI/CustomSvgIcons/ArrowLeft';
|
||||
import TextButton from '../../../UI/TextButton';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import Paper from '../../../UI/Paper';
|
||||
import { LineStackLayout } from '../../../UI/Layout';
|
||||
import { ColumnStackLayout, LineStackLayout } from '../../../UI/Layout';
|
||||
import { AnnouncementsFeed } from '../../../AnnouncementsFeed';
|
||||
import { AnnouncementsFeedContext } from '../../../AnnouncementsFeed/AnnouncementsFeedContext';
|
||||
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
|
||||
import Chip from '../../../UI/Chip';
|
||||
|
||||
export const SECTION_DESKTOP_SPACING = 20;
|
||||
const SECTION_MOBILE_SPACING_TOP = 10;
|
||||
@@ -53,13 +54,14 @@ const styles = {
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
},
|
||||
chip: { height: 24 },
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
chipText?: React.Node,
|
||||
title?: React.Node,
|
||||
titleAdornment?: React.Node,
|
||||
titleAction?: React.Node,
|
||||
subtitleText?: React.Node,
|
||||
customPaperStyle?: Object,
|
||||
renderSubtitle?: () => React.Node,
|
||||
@@ -75,9 +77,9 @@ const SectionContainer = React.forwardRef<Props, HTMLDivElement>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
chipText,
|
||||
title,
|
||||
titleAdornment,
|
||||
titleAction,
|
||||
subtitleText,
|
||||
customPaperStyle,
|
||||
renderSubtitle,
|
||||
@@ -156,14 +158,20 @@ const SectionContainer = React.forwardRef<Props, HTMLDivElement>(
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<LineStackLayout noMargin alignItems="center">
|
||||
<Text size="bold-title" noMargin style={styles.title}>
|
||||
<ColumnStackLayout noMargin expand>
|
||||
{chipText && (
|
||||
<Line noMargin>
|
||||
<Chip label={chipText} style={styles.chip} />
|
||||
</Line>
|
||||
)}
|
||||
<Text size="title" noMargin style={styles.title}>
|
||||
{title}
|
||||
</Text>
|
||||
{titleAction}
|
||||
</LineStackLayout>
|
||||
</ColumnStackLayout>
|
||||
{titleAdornment && (
|
||||
<Column noMargin>{titleAdornment}</Column>
|
||||
<Column expand noMargin>
|
||||
{titleAdornment}
|
||||
</Column>
|
||||
)}
|
||||
</LineStackLayout>
|
||||
)}
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import {
|
||||
listCourseChapters,
|
||||
listCourses,
|
||||
@@ -9,16 +10,19 @@ import {
|
||||
type CourseChapter,
|
||||
type UserCourseProgress,
|
||||
} from '../../../Utils/GDevelopServices/Asset';
|
||||
import { type CourseListingData } from '../../../Utils/GDevelopServices/Shop';
|
||||
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
|
||||
import {
|
||||
fetchUserCourseProgress,
|
||||
updateUserCourseProgress as doUpdateUserCourseProgress,
|
||||
} from '../../../Utils/GDevelopServices/User';
|
||||
import { useOptimisticState } from '../../../Utils/UseOptimisticState';
|
||||
import CourseChapterStoreContext from '../../../Course/CourseChapterStoreContext';
|
||||
import CourseStoreContext from '../../../Course/CourseStoreContext';
|
||||
import { CreditsPackageStoreContext } from '../../../AssetStore/CreditsPackages/CreditsPackageStoreContext';
|
||||
import { buyProductWithCredits } from '../../../Utils/GDevelopServices/Shop';
|
||||
import PreferencesContext from '../../Preferences/PreferencesContext';
|
||||
import { selectMessageByLocale } from '../../../Utils/i18n/MessageByLocale';
|
||||
import { sendCourseBuyClicked } from '../../../Utils/Analytics/EventSender';
|
||||
|
||||
export type CourseChapterCompletion = {|
|
||||
completedTasks: number,
|
||||
@@ -36,8 +40,10 @@ const useCourses = () => {
|
||||
profile,
|
||||
limits,
|
||||
subscription,
|
||||
coursePurchases,
|
||||
getAuthorizationHeader,
|
||||
onOpenLoginDialog,
|
||||
loginState,
|
||||
} = React.useContext(AuthenticatedUserContext);
|
||||
const {
|
||||
values: { language },
|
||||
@@ -45,7 +51,11 @@ const useCourses = () => {
|
||||
const userLanguage2LetterCode = language.split('_')[0].toLowerCase();
|
||||
|
||||
const [courses, setCourses] = React.useState<?(Course[])>(null);
|
||||
const { listedCourseChapters } = React.useContext(CourseChapterStoreContext);
|
||||
const { listedCourses } = React.useContext(CourseStoreContext);
|
||||
const [
|
||||
purchasingCourseListingData,
|
||||
setPurchasingCourseListingData,
|
||||
] = React.useState<?CourseListingData>(null);
|
||||
const { openCreditsPackageDialog, openCreditsUsageDialog } = React.useContext(
|
||||
CreditsPackageStoreContext
|
||||
);
|
||||
@@ -83,10 +93,15 @@ const useCourses = () => {
|
||||
|}>({});
|
||||
const userId = profile ? profile.id : null;
|
||||
|
||||
const fetchCourses = React.useCallback(async () => {
|
||||
const fetchedCourses = await listCourses();
|
||||
setCourses(fetchedCourses);
|
||||
}, []);
|
||||
const fetchCourses = React.useCallback(
|
||||
async () => {
|
||||
const fetchedCourses = await listCourses(getAuthorizationHeader, {
|
||||
userId,
|
||||
});
|
||||
setCourses(fetchedCourses);
|
||||
},
|
||||
[userId, getAuthorizationHeader]
|
||||
);
|
||||
|
||||
const onSelectCourse = React.useCallback(
|
||||
(courseId: string | null) => {
|
||||
@@ -138,11 +153,7 @@ const useCourses = () => {
|
||||
);
|
||||
}
|
||||
},
|
||||
// A subscription change will change the displayed chapters sent by the backend.
|
||||
// So the user subscription is added as a dependency to make sure the chapters are
|
||||
// up to date with the user subscription.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getAuthorizationHeader, userId, subscription, userLanguage2LetterCode]
|
||||
[getAuthorizationHeader, userId, userLanguage2LetterCode]
|
||||
);
|
||||
|
||||
const onCompleteTask = React.useCallback(
|
||||
@@ -287,14 +298,9 @@ const useCourses = () => {
|
||||
[userProgressByCourseId, chaptersByCourseId, courses]
|
||||
);
|
||||
|
||||
const onBuyCourseChapterWithCredits = React.useCallback(
|
||||
async (courseChapter: CourseChapter, password: string) => {
|
||||
if (
|
||||
!courseChapter.isLocked ||
|
||||
!courseChapter.priceInCredits ||
|
||||
!listedCourseChapters
|
||||
)
|
||||
return;
|
||||
const onBuyCourse = React.useCallback(
|
||||
async (course: Course, password: string, i18n: I18nType) => {
|
||||
if (!course.isLocked || !listedCourses) return;
|
||||
|
||||
if (!userId || !limits) {
|
||||
// User not logged in, suggest to log in.
|
||||
@@ -302,19 +308,68 @@ const useCourses = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCreditsAmount = limits.credits.userBalance.amount;
|
||||
const listedCourseChapter = listedCourseChapters.find(
|
||||
chapter => chapter.id === courseChapter.productId
|
||||
const listedCourse = listedCourses.find(
|
||||
listedCourse => listedCourse.id === course.id
|
||||
);
|
||||
if (!listedCourseChapter) {
|
||||
console.error(
|
||||
`Couldn't find course chapter with id ${
|
||||
courseChapter.productId
|
||||
} in Shop API.`
|
||||
);
|
||||
if (!listedCourse) {
|
||||
console.error(`Couldn't find course with id ${course.id} in Shop API.`);
|
||||
return;
|
||||
}
|
||||
const priceForUsageType = listedCourseChapter.creditPrices.find(
|
||||
const priceForUsageType = listedCourse.prices.find(
|
||||
price => price.usageType === 'default'
|
||||
);
|
||||
if (!priceForUsageType) {
|
||||
console.error('Unable to find the price for the default usage type');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sendCourseBuyClicked({
|
||||
courseId: course.id,
|
||||
courseName: course.titleByLocale.en,
|
||||
currency: priceForUsageType ? priceForUsageType.currency : undefined,
|
||||
usageType: 'default',
|
||||
});
|
||||
|
||||
setPurchasingCourseListingData(listedCourse);
|
||||
} catch (e) {
|
||||
console.warn('Unable to send event', e);
|
||||
}
|
||||
},
|
||||
[
|
||||
userId,
|
||||
limits,
|
||||
listedCourses,
|
||||
onOpenLoginDialog,
|
||||
setPurchasingCourseListingData,
|
||||
]
|
||||
);
|
||||
|
||||
const onBuyCourseWithCredits = React.useCallback(
|
||||
async (course: Course, password: string, i18n: I18nType) => {
|
||||
if (!course.isLocked || !listedCourses) return;
|
||||
|
||||
if (!userId || !limits) {
|
||||
// User not logged in, suggest to log in.
|
||||
onOpenLoginDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
sendCourseBuyClicked({
|
||||
courseId: course.id,
|
||||
courseName: course.titleByLocale.en,
|
||||
currency: 'CREDITS',
|
||||
usageType: 'default',
|
||||
});
|
||||
|
||||
const currentCreditsAmount = limits.credits.userBalance.amount;
|
||||
const listedCourse = listedCourses.find(
|
||||
listedCourse => listedCourse.id === course.id
|
||||
);
|
||||
if (!listedCourse) {
|
||||
console.error(`Couldn't find course with id ${course.id} in Shop API.`);
|
||||
return;
|
||||
}
|
||||
const priceForUsageType = listedCourse.creditPrices.find(
|
||||
price => price.usageType === 'default'
|
||||
);
|
||||
if (!priceForUsageType) {
|
||||
@@ -331,43 +386,48 @@ const useCourses = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const translatedCourseTitle = selectMessageByLocale(
|
||||
i18n,
|
||||
course.titleByLocale
|
||||
);
|
||||
|
||||
openCreditsUsageDialog({
|
||||
title: <Trans>Purchase {courseChapter.title}</Trans>,
|
||||
title: <Trans>Purchase {translatedCourseTitle}</Trans>,
|
||||
message: (
|
||||
<Trans>
|
||||
You are about to use {creditsAmount} credits to purchase the chapter
|
||||
"{courseChapter.title}". Continue?
|
||||
You are about to use {creditsAmount} credits to purchase the course
|
||||
"{translatedCourseTitle}". Continue?
|
||||
</Trans>
|
||||
),
|
||||
onConfirm: async () => {
|
||||
await buyProductWithCredits(getAuthorizationHeader, {
|
||||
productId: listedCourseChapter.id,
|
||||
productId: listedCourse.id,
|
||||
usageType: 'default',
|
||||
userId,
|
||||
password,
|
||||
});
|
||||
if (selectedCourseId) await fetchCourseChapters(selectedCourseId);
|
||||
await Promise.all([fetchCourses(), fetchCourseChapters(course.id)]);
|
||||
},
|
||||
successMessage: <Trans>🎉 You can now follow your new chapter!</Trans>,
|
||||
successMessage: <Trans>🎉 You can now follow your new course!</Trans>,
|
||||
});
|
||||
},
|
||||
[
|
||||
userId,
|
||||
limits,
|
||||
listedCourseChapters,
|
||||
listedCourses,
|
||||
openCreditsPackageDialog,
|
||||
openCreditsUsageDialog,
|
||||
getAuthorizationHeader,
|
||||
onOpenLoginDialog,
|
||||
fetchCourses,
|
||||
fetchCourseChapters,
|
||||
selectedCourseId,
|
||||
]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
(async () => {
|
||||
if (courses) {
|
||||
if (courses && loginState !== 'loggingIn') {
|
||||
await Promise.all(
|
||||
courses.map(course => fetchCourseChapters(course.id))
|
||||
);
|
||||
@@ -375,15 +435,32 @@ const useCourses = () => {
|
||||
}
|
||||
})();
|
||||
},
|
||||
// (Re)fetch course chapters when courses are defined and when fetchCourseChapters
|
||||
// changes (see its dependencies).
|
||||
[courses, fetchCourseChapters]
|
||||
// (Re)fetch course chapters when courses are refetched.
|
||||
[courses, fetchCourseChapters, loginState]
|
||||
);
|
||||
|
||||
const selectedCourse =
|
||||
selectedCourseId && courses && areChaptersReady
|
||||
? courses.find(course => course.id === selectedCourseId) || null
|
||||
: null;
|
||||
React.useEffect(
|
||||
() => {
|
||||
(async () => {
|
||||
if (subscription || coursePurchases) {
|
||||
// Just to trigger a re-fetch of the courses when the user subscription changes,
|
||||
// or when the user purchases a course.
|
||||
}
|
||||
if (loginState !== 'loggingIn') {
|
||||
await fetchCourses();
|
||||
}
|
||||
})();
|
||||
},
|
||||
[fetchCourses, subscription, coursePurchases, loginState]
|
||||
);
|
||||
|
||||
const selectedCourse = React.useMemo(
|
||||
() => {
|
||||
if (!selectedCourseId || !courses || !areChaptersReady) return null;
|
||||
return courses.find(course => course.id === selectedCourseId) || null;
|
||||
},
|
||||
[selectedCourseId, courses, areChaptersReady]
|
||||
);
|
||||
|
||||
return {
|
||||
courses,
|
||||
@@ -396,7 +473,10 @@ const useCourses = () => {
|
||||
isTaskCompleted,
|
||||
getChapterCompletion,
|
||||
getCourseCompletion,
|
||||
onBuyCourseChapterWithCredits,
|
||||
onBuyCourseWithCredits,
|
||||
onBuyCourse,
|
||||
purchasingCourseListingData,
|
||||
setPurchasingCourseListingData,
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -11,12 +11,11 @@ import {
|
||||
type FileMetadata,
|
||||
type StorageProvider,
|
||||
} from '../../../ProjectsStorage';
|
||||
import GetStartedSection from './GetStartedSection';
|
||||
import LearnSection from './LearnSection';
|
||||
import { type LearnCategory } from './LearnSection/Utils';
|
||||
import PlaySection from './PlaySection';
|
||||
import CreateSection from './CreateSection';
|
||||
import StoreSection from './StoreSection';
|
||||
import { type TutorialCategory } from '../../../Utils/GDevelopServices/Tutorial';
|
||||
import { TutorialContext } from '../../../Tutorial/TutorialContext';
|
||||
import { ExampleStoreContext } from '../../../AssetStore/ExampleStore/ExampleStoreContext';
|
||||
import { HomePageHeader } from './HomePageHeader';
|
||||
@@ -30,12 +29,6 @@ import TeamProvider from '../../../Profile/Team/TeamProvider';
|
||||
import { useResponsiveWindowSize } from '../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import { type PrivateGameTemplateListingData } from '../../../Utils/GDevelopServices/Shop';
|
||||
import { PrivateGameTemplateStoreContext } from '../../../AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext';
|
||||
import PreferencesContext from '../../Preferences/PreferencesContext';
|
||||
import { incrementGetStartedSectionViewCount } from '../../../Utils/Analytics/LocalStats';
|
||||
import {
|
||||
sendUserSurveyHidden,
|
||||
sendUserSurveyStarted,
|
||||
} from '../../../Utils/Analytics/EventSender';
|
||||
import RouterContext, { type RouteArguments } from '../../RouterContext';
|
||||
import { type GameDetailsTab } from '../../../GameDashboard';
|
||||
import { canUseClassroomFeature } from '../../../Utils/GDevelopServices/Usage';
|
||||
@@ -47,6 +40,7 @@ import { type GamesList } from '../../../GameDashboard/UseGamesList';
|
||||
import { type GamesPlatformFrameTools } from './PlaySection/UseGamesPlatformFrame';
|
||||
import { type CourseChapter } from '../../../Utils/GDevelopServices/Asset';
|
||||
import useCourses from './UseCourses';
|
||||
import PreferencesContext from '../../Preferences/PreferencesContext';
|
||||
|
||||
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) || []
|
||||
}
|
||||
|
@@ -23,6 +23,7 @@ type Props = {|
|
||||
name: string,
|
||||
dontFocusTab?: boolean,
|
||||
project?: ?gdProject,
|
||||
mode?: 'chat' | 'agent',
|
||||
|}) => EditorOpeningOptions,
|
||||
|};
|
||||
|
||||
|
@@ -46,7 +46,7 @@ export type MainMenuCallbacks = {|
|
||||
onOpenPreferences: (open?: boolean) => void,
|
||||
onOpenLanguage: (open?: boolean) => void,
|
||||
onOpenProfile: (open?: boolean) => void,
|
||||
onOpenAskAi: (open?: boolean) => void,
|
||||
onOpenAskAi: (mode?: 'chat' | 'agent') => void,
|
||||
setElectronUpdateStatus: ElectronUpdateStatus => void,
|
||||
|};
|
||||
|
||||
|
@@ -216,7 +216,7 @@ export type PreferencesValues = {|
|
||||
resourcesImporationBehavior: ResourceImportationBehavior,
|
||||
eventsSheetCancelInlineParameter: 'cancel' | 'apply',
|
||||
showCommunityExtensions: boolean,
|
||||
showGetStartedSectionByDefault: boolean,
|
||||
showCreateSectionByDefault: boolean,
|
||||
showInAppTutorialDeveloperMode: boolean,
|
||||
showDeprecatedInstructionWarning: boolean,
|
||||
openDiagnosticReportAutomatically: boolean,
|
||||
@@ -302,7 +302,7 @@ export type Preferences = {|
|
||||
setIsAlwaysOnTopInPreview: (enabled: boolean) => void,
|
||||
setEventsSheetCancelInlineParameter: (value: string) => void,
|
||||
setShowCommunityExtensions: (enabled: boolean) => void,
|
||||
setShowGetStartedSectionByDefault: (enabled: boolean) => void,
|
||||
setShowCreateSectionByDefault: (enabled: boolean) => void,
|
||||
setShowInAppTutorialDeveloperMode: (enabled: boolean) => void,
|
||||
setOpenDiagnosticReportAutomatically: (enabled: boolean) => void,
|
||||
getOpenDiagnosticReportAutomatically: () => boolean,
|
||||
@@ -381,7 +381,7 @@ export const initialPreferences = {
|
||||
resourcesImporationBehavior: 'ask',
|
||||
eventsSheetCancelInlineParameter: 'apply',
|
||||
showCommunityExtensions: false,
|
||||
showGetStartedSectionByDefault: true,
|
||||
showCreateSectionByDefault: false,
|
||||
showInAppTutorialDeveloperMode: false,
|
||||
openDiagnosticReportAutomatically: true,
|
||||
showDeprecatedInstructionWarning: false,
|
||||
@@ -449,7 +449,7 @@ export const initialPreferences = {
|
||||
setIsAlwaysOnTopInPreview: () => {},
|
||||
setEventsSheetCancelInlineParameter: () => {},
|
||||
setShowCommunityExtensions: () => {},
|
||||
setShowGetStartedSectionByDefault: (enabled: boolean) => {},
|
||||
setShowCreateSectionByDefault: (enabled: boolean) => {},
|
||||
setShowInAppTutorialDeveloperMode: (enabled: boolean) => {},
|
||||
setShowDeprecatedInstructionWarning: (enabled: boolean) => {},
|
||||
getOpenDiagnosticReportAutomatically: () => true,
|
||||
|
@@ -83,6 +83,7 @@ const PreferencesDialog = ({
|
||||
setPreviewCrashReportUploadLevel,
|
||||
setTakeScreenshotOnPreview,
|
||||
setShowAiAskButtonInTitleBar,
|
||||
setShowCreateSectionByDefault,
|
||||
} = React.useContext(PreferencesContext);
|
||||
|
||||
const initialUse3DEditor = React.useRef<boolean>(values.use3DEditor);
|
||||
@@ -392,6 +393,27 @@ const PreferencesDialog = ({
|
||||
<Trans>Advanced</Trans>
|
||||
</Text>
|
||||
<ColumnStackLayout>
|
||||
<Text size="sub-title">
|
||||
<Trans>At launch</Trans>
|
||||
</Text>
|
||||
<CompactToggleField
|
||||
labelColor="primary"
|
||||
hideTooltip
|
||||
onCheck={setShowCreateSectionByDefault}
|
||||
checked={values.showCreateSectionByDefault}
|
||||
label={i18n._(
|
||||
t`Show the "Create" section by default when opening GDevelop`
|
||||
)}
|
||||
/>
|
||||
<CompactToggleField
|
||||
labelColor="primary"
|
||||
hideTooltip
|
||||
onCheck={setAutoOpenMostRecentProject}
|
||||
checked={values.autoOpenMostRecentProject}
|
||||
label={i18n._(
|
||||
t`Automatically re-open the project edited during last session`
|
||||
)}
|
||||
/>
|
||||
<Text size="sub-title">
|
||||
<Trans>Previews</Trans>
|
||||
</Text>
|
||||
@@ -531,15 +553,6 @@ const PreferencesDialog = ({
|
||||
t`Display save reminder after significant changes in project`
|
||||
)}
|
||||
/>
|
||||
<CompactToggleField
|
||||
labelColor="primary"
|
||||
hideTooltip
|
||||
onCheck={setAutoOpenMostRecentProject}
|
||||
checked={values.autoOpenMostRecentProject}
|
||||
label={i18n._(
|
||||
t`Automatically re-open the project edited during last session`
|
||||
)}
|
||||
/>
|
||||
<CompactToggleField
|
||||
labelColor="primary"
|
||||
hideTooltip
|
||||
|
@@ -149,7 +149,7 @@ export default class PreferencesProvider extends React.Component<Props, State> {
|
||||
this
|
||||
),
|
||||
setShowCommunityExtensions: this._setShowCommunityExtensions.bind(this),
|
||||
setShowGetStartedSectionByDefault: this._setShowGetStartedSection.bind(
|
||||
setShowCreateSectionByDefault: this._setShowCreateSectionByDefault.bind(
|
||||
this
|
||||
),
|
||||
setShowInAppTutorialDeveloperMode: this._setShowInAppTutorialDeveloperMode.bind(
|
||||
@@ -353,12 +353,12 @@ export default class PreferencesProvider extends React.Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
_setShowGetStartedSection(showGetStartedSectionByDefault: boolean) {
|
||||
_setShowCreateSectionByDefault(showCreateSectionByDefault: boolean) {
|
||||
this.setState(
|
||||
state => ({
|
||||
values: {
|
||||
...state.values,
|
||||
showGetStartedSectionByDefault,
|
||||
showCreateSectionByDefault,
|
||||
},
|
||||
}),
|
||||
() => this._persistValuesToLocalStorage(this.state)
|
||||
|
@@ -40,7 +40,7 @@ import { Trans } from '@lingui/macro';
|
||||
import { CreditsPackageStoreStateProvider } from '../AssetStore/CreditsPackages/CreditsPackageStoreContext';
|
||||
import { ProductLicenseStoreStateProvider } from '../AssetStore/ProductLicense/ProductLicenseStoreContext';
|
||||
import { MarketingPlansStoreStateProvider } from '../MarketingPlans/MarketingPlansStoreContext';
|
||||
import { CourseChapterStoreStateProvider } from '../Course/CourseChapterStoreContext';
|
||||
import { CourseStoreStateProvider } from '../Course/CourseStoreContext';
|
||||
import { Resource3DPreviewProvider } from '../ResourcesList/ResourcePreview/Resource3DPreviewContext';
|
||||
|
||||
type Props = {|
|
||||
@@ -108,7 +108,7 @@ const Providers = ({
|
||||
<ExampleStoreStateProvider>
|
||||
<PrivateGameTemplateStoreStateProvider>
|
||||
<CreditsPackageStoreStateProvider>
|
||||
<CourseChapterStoreStateProvider>
|
||||
<CourseStoreStateProvider>
|
||||
<ProductLicenseStoreStateProvider>
|
||||
<MarketingPlansStoreStateProvider>
|
||||
<ExtensionStoreStateProvider
|
||||
@@ -134,7 +134,7 @@ const Providers = ({
|
||||
</ExtensionStoreStateProvider>
|
||||
</MarketingPlansStoreStateProvider>
|
||||
</ProductLicenseStoreStateProvider>
|
||||
</CourseChapterStoreStateProvider>
|
||||
</CourseStoreStateProvider>
|
||||
</CreditsPackageStoreStateProvider>
|
||||
</PrivateGameTemplateStoreStateProvider>
|
||||
</ExampleStoreStateProvider>
|
||||
|
@@ -12,8 +12,8 @@ export type Route =
|
||||
| 'build' // Old way of opening the build section
|
||||
| 'create' // New way of opening the build section
|
||||
| 'education'
|
||||
| 'play'
|
||||
| 'get-started';
|
||||
| 'learn'
|
||||
| 'play';
|
||||
type RouteKey =
|
||||
| 'initial-dialog'
|
||||
| 'game-id'
|
||||
|
@@ -53,7 +53,7 @@ type TabsTitlebarProps = {|
|
||||
onEditorTabClosing: () => void
|
||||
) => React.Node,
|
||||
hasAskAiOpened: boolean,
|
||||
onOpenAskAi: () => void,
|
||||
onOpenAskAi: (mode?: 'chat' | 'agent') => void,
|
||||
|};
|
||||
|
||||
const useIsAskAiIconAnimated = (shouldDisplayAskAi: boolean) => {
|
||||
@@ -227,7 +227,7 @@ export default function TabsTitlebar({
|
||||
<TextButton
|
||||
icon={<RobotIcon size={16} rotating={isAskAiIconAnimated} />}
|
||||
label={'Ask AI'}
|
||||
onClick={onOpenAskAi}
|
||||
onClick={() => onOpenAskAi('agent')}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
@@ -1,7 +1,18 @@
|
||||
// @flow
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterContext from './RouterContext';
|
||||
import RouterContext, { type Route } from './RouterContext';
|
||||
|
||||
const homePageRoutes: Route[] = [
|
||||
'play',
|
||||
'learn',
|
||||
'build',
|
||||
'create',
|
||||
'games-dashboard',
|
||||
'asset-store',
|
||||
'store',
|
||||
'education',
|
||||
];
|
||||
|
||||
/**
|
||||
* This hook is used to be able to use route arguments from anywhere to open the homepage.
|
||||
@@ -22,18 +33,7 @@ const useHomepageWitchForRouting = ({
|
||||
const initialDialog = routeArguments['initial-dialog'];
|
||||
if (!initialDialog) return;
|
||||
|
||||
if (
|
||||
[
|
||||
'games-dashboard',
|
||||
'asset-store',
|
||||
'store',
|
||||
'build',
|
||||
'education',
|
||||
'play',
|
||||
'community',
|
||||
'get-started',
|
||||
].includes(initialDialog)
|
||||
) {
|
||||
if (homePageRoutes.includes(initialDialog)) {
|
||||
closeDialogs();
|
||||
openHomePage();
|
||||
}
|
||||
|
@@ -30,7 +30,7 @@ type Props = {|
|
||||
privateGameTemplateListingData: PrivateGameTemplateListingData,
|
||||
newProjectSetup: NewProjectSetup
|
||||
) => Promise<void>,
|
||||
openAskAi: () => void,
|
||||
openAskAi: (mode?: 'chat' | 'agent') => void,
|
||||
storageProviders: Array<StorageProvider>,
|
||||
|};
|
||||
|
||||
@@ -179,7 +179,7 @@ const useNewProjectDialog = ({
|
||||
const onOpenAskAi = React.useCallback(
|
||||
() => {
|
||||
closeNewProjectDialog();
|
||||
openAskAi();
|
||||
openAskAi('agent');
|
||||
},
|
||||
[closeNewProjectDialog, openAskAi]
|
||||
);
|
||||
|
@@ -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, {
|
||||
|
@@ -42,6 +42,7 @@ export type AuthenticatedUser = {|
|
||||
receivedGameTemplates: ?Array<PrivateGameTemplate>,
|
||||
gameTemplatePurchases: ?Array<Purchase>,
|
||||
assetPackPurchases: ?Array<Purchase>,
|
||||
coursePurchases: ?Array<Purchase>,
|
||||
recommendations: ?Array<Recommendation>,
|
||||
notifications: ?Array<Notification>,
|
||||
userEarningsBalance: ?UserEarningsBalance,
|
||||
@@ -74,6 +75,7 @@ export type AuthenticatedUser = {|
|
||||
onRefreshLimits: () => Promise<void>,
|
||||
onRefreshGameTemplatePurchases: () => Promise<void>,
|
||||
onRefreshAssetPackPurchases: () => Promise<void>,
|
||||
onRefreshCoursePurchases: () => Promise<void>,
|
||||
onRefreshEarningsBalance: () => Promise<void>,
|
||||
onRefreshNotifications: () => Promise<void>,
|
||||
onPurchaseSuccessful: () => Promise<void>,
|
||||
@@ -105,6 +107,7 @@ export const initialAuthenticatedUser = {
|
||||
cloudProjectsFetchingErrorLabel: null,
|
||||
gameTemplatePurchases: null,
|
||||
assetPackPurchases: null,
|
||||
coursePurchases: null,
|
||||
recommendations: null,
|
||||
subscription: null,
|
||||
usages: null,
|
||||
@@ -131,6 +134,7 @@ export const initialAuthenticatedUser = {
|
||||
onRefreshLimits: async () => {},
|
||||
onRefreshGameTemplatePurchases: async () => {},
|
||||
onRefreshAssetPackPurchases: async () => {},
|
||||
onRefreshCoursePurchases: async () => {},
|
||||
onRefreshEarningsBalance: async () => {},
|
||||
onRefreshNotifications: async () => {},
|
||||
onPurchaseSuccessful: async () => {},
|
||||
|
@@ -219,6 +219,7 @@ export default class AuthenticatedUserProvider extends React.Component<
|
||||
onRefreshLimits: this._fetchUserLimits,
|
||||
onRefreshGameTemplatePurchases: this._fetchUserGameTemplatePurchases,
|
||||
onRefreshAssetPackPurchases: this._fetchUserAssetPackPurchases,
|
||||
onRefreshCoursePurchases: this._fetchUserCoursePurchases,
|
||||
onRefreshEarningsBalance: this._fetchEarningsBalance,
|
||||
onRefreshNotifications: this._fetchUserNotifications,
|
||||
onPurchaseSuccessful: this._fetchUserProducts,
|
||||
@@ -560,6 +561,22 @@ export default class AuthenticatedUserProvider extends React.Component<
|
||||
console.error('Error while loading asset pack purchases:', error);
|
||||
}
|
||||
);
|
||||
listUserPurchases(authentication.getAuthorizationHeader, {
|
||||
userId: firebaseUser.uid,
|
||||
productType: 'course',
|
||||
role: 'receiver',
|
||||
}).then(
|
||||
coursePurchases =>
|
||||
this.setState(({ authenticatedUser }) => ({
|
||||
authenticatedUser: {
|
||||
...authenticatedUser,
|
||||
coursePurchases,
|
||||
},
|
||||
})),
|
||||
error => {
|
||||
console.error('Error while loading course purchases:', error);
|
||||
}
|
||||
);
|
||||
this._fetchUserBadges();
|
||||
this._fetchAchievements();
|
||||
this._fetchUserNotifications();
|
||||
@@ -849,6 +866,32 @@ export default class AuthenticatedUserProvider extends React.Component<
|
||||
}
|
||||
};
|
||||
|
||||
_fetchUserCoursePurchases = async () => {
|
||||
const { authentication } = this.props;
|
||||
const firebaseUser = this.state.authenticatedUser.firebaseUser;
|
||||
if (!firebaseUser) return;
|
||||
|
||||
try {
|
||||
const coursePurchases = await listUserPurchases(
|
||||
authentication.getAuthorizationHeader,
|
||||
{
|
||||
userId: firebaseUser.uid,
|
||||
productType: 'course',
|
||||
role: 'receiver',
|
||||
}
|
||||
);
|
||||
|
||||
this.setState(({ authenticatedUser }) => ({
|
||||
authenticatedUser: {
|
||||
...authenticatedUser,
|
||||
coursePurchases,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error while loading course purchases:', error);
|
||||
}
|
||||
};
|
||||
|
||||
_fetchUserProducts = async () => {
|
||||
await Promise.all([
|
||||
this._fetchUserAssetPacks(),
|
||||
|
@@ -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}
|
||||
|
@@ -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
|
||||
/>
|
||||
</>
|
||||
|
@@ -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(
|
||||
|
@@ -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;
|
||||
|
8
newIDE/app/src/UI/Carousel.module.css
Normal file
8
newIDE/app/src/UI/Carousel.module.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.arrowContainer:hover {
|
||||
filter: brightness(130%);
|
||||
transition: 'filter 100ms ease';
|
||||
}
|
||||
|
||||
.arrowContainer path {
|
||||
cursor: pointer;
|
||||
}
|
@@ -35,7 +35,6 @@ type ErrorBoundaryScope =
|
||||
| 'app'
|
||||
| 'editor'
|
||||
| 'start-page'
|
||||
| 'start-page-get-started'
|
||||
| 'start-page-shop'
|
||||
| 'start-page-learn'
|
||||
| 'start-page-play'
|
||||
|
@@ -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>
|
||||
|
@@ -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}
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -12,7 +12,7 @@ const sizeThresholds = {
|
||||
smallHeight: 500,
|
||||
smallWidth: 600,
|
||||
mediumWidth: 1150,
|
||||
largeWidth: 1500,
|
||||
largeWidth: 1800,
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
|
@@ -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');
|
||||
};
|
||||
|
@@ -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);
|
||||
}
|
||||
};
|
||||
|
@@ -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;
|
||||
};
|
||||
|
@@ -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>> => {
|
||||
|
@@ -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 = {|
|
||||
|
@@ -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.
|
||||
|
@@ -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;
|
||||
|
@@ -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',
|
||||
|
@@ -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
|
||||
/>
|
||||
);
|
||||
|
@@ -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')()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@@ -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')()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@@ -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')()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -147,6 +147,7 @@ const WrappedHomePage = ({
|
||||
renderGamesPlatformFrame: () => null,
|
||||
}}
|
||||
onExtensionInstalled={action('onExtensionInstalled')}
|
||||
onOpenAskAi={() => action('onOpenAskAi')()}
|
||||
/>
|
||||
</TutorialStateProvider>
|
||||
</ExampleStoreStateProvider>
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user