Additional bundles are added to the Learn & Shop sections (#7805)

* A premium bundle, including multiple courses, asset packs, templates and a gold subscription
* A curated platformer-specific bundle, including everything needed to learn & create a platformer game
This commit is contained in:
Clément Pasteau
2025-09-03 10:30:24 +02:00
committed by GitHub
parent 86db08ac3f
commit 5fcd67d77b
7 changed files with 127 additions and 71 deletions

View File

@@ -259,7 +259,7 @@ const BundleInformationPage = ({
padding: 0, padding: 0,
} }
: {}), : {}),
...(bundleListingData.visibleUntil ...(bundleListingData.visibleUntil && !noPadding
? { ? {
backgroundAttachment: 'local', backgroundAttachment: 'local',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',

View File

@@ -389,41 +389,39 @@ const BundlePageHeader = ({
</SectionRow> </SectionRow>
)} )}
<SectionRow> <SectionRow>
<Paper background="dark" variant="outlined" style={{ padding: 16 }}> <ResponsiveLineStackLayout
<ResponsiveLineStackLayout noMargin
noMargin alignItems="center"
alignItems="center" justifyContent="flex-start"
justifyContent="flex-start" forceMobileLayout={isMediumScreen}
forceMobileLayout={isMediumScreen} expand
>
<div style={styles.imageContainer}>
<img
src={bundle.previewImageUrls[0]}
style={styles.image}
alt=""
/>
</div>
<ColumnStackLayout
expand expand
justifyContent="flex-start"
noMargin={isMobile}
> >
<div style={styles.imageContainer}> <Text size="title" noMargin style={styles.title}>
<img {selectMessageByLocale(i18n, bundle.nameByLocale)}
src={bundle.previewImageUrls[0]} </Text>
style={styles.image} <Line noMargin>
alt="" <Text noMargin>
/> {selectMessageByLocale(
</div> i18n,
<ColumnStackLayout bundle.longDescriptionByLocale
expand )}
justifyContent="flex-start"
noMargin={isMobile}
>
<Text size="title" noMargin style={styles.title}>
{selectMessageByLocale(i18n, bundle.nameByLocale)}
</Text> </Text>
<Line noMargin> </Line>
<Text noMargin> {summaryLines}
{selectMessageByLocale( </ColumnStackLayout>
i18n, </ResponsiveLineStackLayout>
bundle.longDescriptionByLocale
)}
</Text>
</Line>
{summaryLines}
</ColumnStackLayout>
</ResponsiveLineStackLayout>
</Paper>
</SectionRow> </SectionRow>
{!isAlreadyReceived && isMobileOrMediumScreen && bundleListingData && ( {!isAlreadyReceived && isMobileOrMediumScreen && bundleListingData && (
<SectionRow> <SectionRow>

View File

@@ -260,9 +260,10 @@ const getColumnsFromWindowSize = (windowSize: WindowSizeType) => {
type Props = {| type Props = {|
onDisplayBundle: (bundleListingData: BundleListingData) => void, onDisplayBundle: (bundleListingData: BundleListingData) => void,
i18n: I18nType, i18n: I18nType,
category: string,
|}; |};
const BundlePreviewBanner = ({ onDisplayBundle, i18n }: Props) => { const BundlePreviewBanner = ({ onDisplayBundle, i18n, category }: Props) => {
const { isMobile, isLandscape, windowSize } = useResponsiveWindowSize(); const { isMobile, isLandscape, windowSize } = useResponsiveWindowSize();
const numberOfTilesToDisplay = getColumnsFromWindowSize(windowSize) - 1; // Reserve one tile for the bundle preview. const numberOfTilesToDisplay = getColumnsFromWindowSize(windowSize) - 1; // Reserve one tile for the bundle preview.
const { privateGameTemplateListingDatas } = React.useContext( const { privateGameTemplateListingDatas } = React.useContext(
@@ -278,28 +279,34 @@ const BundlePreviewBanner = ({ onDisplayBundle, i18n }: Props) => {
const { bundlePurchases, receivedBundles } = authenticatedUser; const { bundlePurchases, receivedBundles } = authenticatedUser;
// For the moment, we either display: // For the moment, we either display:
// - the first bundle in the list if none are owned. // - the first bundle of that category if none are owned.
// - the first owned bundle (as a listing data if still listed, or as an archived listing data otherwise) // - the first owned bundle of that category (as a listing data if still listed, or as an archived listing data otherwise)
// TODO: improve that logic when we'll have more bundles.
const bundleListingData: BundleListingData | null = React.useMemo( const bundleListingData: BundleListingData | null = React.useMemo(
() => { () => {
if (!bundleListingDatas || !receivedBundles) return null; if (!bundleListingDatas || !receivedBundles) return null;
if (receivedBundles.length === 0) { const bundleListingDataOfCategory = bundleListingDatas.filter(bundle =>
return bundleListingDatas[0]; // Display the first bundle if none are owned. bundle.categories.includes(category)
)[0];
const receivedBundleOfCategory = receivedBundles.filter(bundle =>
bundle.categories.includes(category)
)[0];
if (!receivedBundleOfCategory) {
// Display the first bundle if none are found with that category.
return bundleListingDataOfCategory || bundleListingDatas[0];
} }
const receivedBundle = receivedBundles[0]; const bundleListingDataMatchingOwnedBundle = bundleListingDatas.find(
const bundleListingData = bundleListingDatas.find( bundleListingData =>
bundleListingData => bundleListingData.id === receivedBundle.id bundleListingData.id === receivedBundleOfCategory.id
); );
if (bundleListingData) {
return bundleListingData; // Display the first owned bundle that is still listed.
}
// If this bundle is not listed anymore, get an archived listing data for that bundle. // If this bundle is not listed anymore, get an archived listing data for that bundle.
return getArchivedBundleListingData({ return (
bundle: receivedBundle, bundleListingDataMatchingOwnedBundle ||
}); getArchivedBundleListingData({
bundle: receivedBundleOfCategory,
})
);
}, },
[bundleListingDatas, receivedBundles] [bundleListingDatas, receivedBundles, category]
); );
const userBundlePurchaseUsageType = React.useMemo( const userBundlePurchaseUsageType = React.useMemo(

View File

@@ -13,7 +13,7 @@ import * as React from 'react';
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import { I18n } from '@lingui/react'; import { I18n } from '@lingui/react';
import Text from '../../UI/Text'; import Text from '../../UI/Text';
import { Column } from '../../UI/Grid'; import { Column, Line, Spacer } from '../../UI/Grid';
import { LineStackLayout, ResponsiveLineStackLayout } from '../../UI/Layout'; import { LineStackLayout, ResponsiveLineStackLayout } from '../../UI/Layout';
import { import {
getPlanIcon, getPlanIcon,
@@ -677,9 +677,13 @@ export const getSummaryLines = ({
summaryLineItems.forEach((item, index) => { summaryLineItems.forEach((item, index) => {
if (index !== 0) { if (index !== 0) {
desktopLineItems.push( desktopLineItems.push(
<Column key={`divider-${index}`}> <Line noMargin key={`divider-${index}`}>
<Divider orientation="vertical" /> <Spacer />
</Column> <Column>
<Divider orientation="vertical" />
</Column>
<Spacer />
</Line>
); );
if (index % 2 === 1) { if (index % 2 === 1) {
mobileLineItems.push( mobileLineItems.push(

View File

@@ -131,6 +131,7 @@ const CoursesPage = ({
<SectionRow> <SectionRow>
<BundlePreviewBanner <BundlePreviewBanner
onDisplayBundle={onSelectBundle} onDisplayBundle={onSelectBundle}
category="starter"
i18n={i18n} i18n={i18n}
/> />
</SectionRow> </SectionRow>
@@ -144,28 +145,72 @@ const CoursesPage = ({
cellHeight="auto" cellHeight="auto"
spacing={ITEMS_SPACING * 2} spacing={ITEMS_SPACING * 2}
> >
{courses.slice(numberOfItemsOnOneRow).map(course => { {courses
const completion = getCourseCompletion(course.id); .slice(numberOfItemsOnOneRow, 2 * numberOfItemsOnOneRow)
const courseListingData = listedCourses.find( .map(course => {
listedCourse => listedCourse.id === course.id const completion = getCourseCompletion(course.id);
); const courseListingData = listedCourses.find(
return ( listedCourse => listedCourse.id === course.id
<GridListTile key={course.id}> );
<CourseCard return (
course={course} <GridListTile key={course.id}>
courseListingData={courseListingData} <CourseCard
completion={completion} course={course}
onClick={() => { courseListingData={courseListingData}
onSelectCourse(course.id); completion={completion}
}} onClick={() => {
/> onSelectCourse(course.id);
</GridListTile> }}
); />
})} </GridListTile>
);
})}
</GridList> </GridList>
</Line> </Line>
</SectionRow> </SectionRow>
)} )}
{!hidePremiumProducts && (
<SectionRow>
<BundlePreviewBanner
onDisplayBundle={onSelectBundle}
category="premium"
i18n={i18n}
/>
</SectionRow>
)}
{courses &&
listedCourses &&
courses.length > 2 * numberOfItemsOnOneRow && (
<SectionRow>
<Line>
<GridList
cols={numberOfItemsOnOneRow}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{courses.slice(2 * numberOfItemsOnOneRow).map(course => {
const completion = getCourseCompletion(course.id);
const courseListingData = listedCourses.find(
listedCourse => listedCourse.id === course.id
);
return (
<GridListTile key={course.id}>
<CourseCard
course={course}
courseListingData={courseListingData}
completion={completion}
onClick={() => {
onSelectCourse(course.id);
}}
/>
</GridListTile>
);
})}
</GridList>
</Line>
</SectionRow>
)}
</SectionContainer> </SectionContainer>
)} )}
</I18n> </I18n>

View File

@@ -242,6 +242,7 @@ const MainPage = ({
<SectionRow> <SectionRow>
<BundlePreviewBanner <BundlePreviewBanner
onDisplayBundle={onSelectBundle} onDisplayBundle={onSelectBundle}
category="starter"
i18n={i18n} i18n={i18n}
/> />
</SectionRow> </SectionRow>

View File

@@ -162,6 +162,7 @@ export type Bundle = {|
// If the bundle is archived, it will not be available for purchase anymore. // If the bundle is archived, it will not be available for purchase anymore.
// But it will still be available for users who already purchased it. // But it will still be available for users who already purchased it.
archivedAt?: string, archivedAt?: string,
categories: string[],
longDescription: string, longDescription: string,
longDescriptionByLocale: MessageByLocale, longDescriptionByLocale: MessageByLocale,
previewImageUrls: Array<string>, previewImageUrls: Array<string>,