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,
}
: {}),
...(bundleListingData.visibleUntil
...(bundleListingData.visibleUntil && !noPadding
? {
backgroundAttachment: 'local',
backgroundRepeat: 'no-repeat',

View File

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

View File

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

View File

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

View File

@@ -131,6 +131,7 @@ const CoursesPage = ({
<SectionRow>
<BundlePreviewBanner
onDisplayBundle={onSelectBundle}
category="starter"
i18n={i18n}
/>
</SectionRow>
@@ -144,28 +145,72 @@ const CoursesPage = ({
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{courses.slice(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>
);
})}
{courses
.slice(numberOfItemsOnOneRow, 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>
)}
{!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>
)}
</I18n>

View File

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

View File

@@ -162,6 +162,7 @@ export type Bundle = {|
// 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.
archivedAt?: string,
categories: string[],
longDescription: string,
longDescriptionByLocale: MessageByLocale,
previewImageUrls: Array<string>,