Allow buying a bundle without an account (#7873)

Do not show in changelog
This commit is contained in:
Clément Pasteau
2025-10-07 12:30:13 +02:00
committed by GitHub
parent 3c0bb83032
commit ad24acd72f
31 changed files with 2211 additions and 247 deletions

View File

@@ -88,8 +88,8 @@ type Props = {|
getCourseCompletion: (courseId: string) => CourseCompletion | null,
noPadding?: boolean,
noActions?: boolean,
simpleCheckout?: boolean,
onPurchaseDone?: () => void,
fastCheckout?: boolean,
onCloseAfterPurchaseDone?: () => void,
|};
const BundleInformationPage = ({
@@ -104,8 +104,8 @@ const BundleInformationPage = ({
getCourseCompletion,
noPadding,
noActions,
simpleCheckout,
onPurchaseDone,
fastCheckout,
onCloseAfterPurchaseDone,
}: Props) => {
const { windowSize, isLandscape, isMobile } = useResponsiveWindowSize();
const { bundleListingDatas } = React.useContext(BundleStoreContext); // If archived, should use the one passed.
@@ -317,8 +317,8 @@ const BundleInformationPage = ({
bundleListingData={bundleListingData}
bundle={bundle}
i18n={i18n}
simpleCheckout={simpleCheckout}
onPurchaseDone={onPurchaseDone}
fastCheckout={fastCheckout}
onCloseAfterPurchaseDone={onCloseAfterPurchaseDone}
/>
<Line noMargin>
<Text size="section-title">

View File

@@ -68,8 +68,8 @@ type Props = {|
bundle: Bundle,
simulateAppStoreProduct?: boolean,
i18n: I18nType,
simpleCheckout?: boolean,
onPurchaseDone?: () => void,
fastCheckout?: boolean,
onCloseAfterPurchaseDone?: () => void,
|};
const BundlePageHeader = ({
@@ -77,8 +77,8 @@ const BundlePageHeader = ({
bundleListingData,
simulateAppStoreProduct,
i18n,
simpleCheckout,
onPurchaseDone,
fastCheckout,
onCloseAfterPurchaseDone,
}: Props) => {
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
@@ -298,7 +298,7 @@ const BundlePageHeader = ({
<ProductLimitedTimeOffer
visibleUntil={bundleListingData.visibleUntil}
/>
) : estimatedSavingsFormatted ? (
) : estimatedTotalPriceFormatted ? (
<Column
noMargin
alignItems="flex-start"
@@ -310,9 +310,8 @@ const BundlePageHeader = ({
/>
<Text color="secondary">
<Trans>
Get{' '}
{estimatedSavingsFormatted.savingsPriceFormatted}{' '}
worth of value for less!
Get {estimatedTotalPriceFormatted} worth of value
for less!
</Trans>
</Text>
</Column>
@@ -507,8 +506,8 @@ const BundlePageHeader = ({
bundleListingData={purchasingBundleListingData}
usageType="default"
onClose={() => setPurchasingBundleListingData(null)}
simpleCheckout={simpleCheckout}
onPurchaseDone={onPurchaseDone}
fastCheckout={fastCheckout}
onCloseAfterPurchaseDone={onCloseAfterPurchaseDone}
/>
)}
{isRedemptionCodesDialogOpen && (

View File

@@ -25,14 +25,15 @@ import {
} from '../../Utils/AppStorePurchases';
import { extractGDevelopApiErrorStatusAndCode } from '../../Utils/GDevelopServices/Errors';
import PasswordPromptDialog from '../PasswordPromptDialog';
import { getUserUUID } from '../../Utils/Analytics/UserUUID';
type Props = {|
bundleListingData: BundleListingData,
usageType: string,
onClose: () => void,
simulateAppStoreProduct?: boolean,
simpleCheckout?: boolean,
onPurchaseDone?: () => void,
fastCheckout?: boolean,
onCloseAfterPurchaseDone?: () => void,
|};
const BundlePurchaseDialog = ({
@@ -40,8 +41,8 @@ const BundlePurchaseDialog = ({
usageType,
onClose,
simulateAppStoreProduct,
simpleCheckout,
onPurchaseDone,
fastCheckout,
onCloseAfterPurchaseDone,
}: Props) => {
const {
profile,
@@ -64,16 +65,22 @@ const BundlePurchaseDialog = ({
] = React.useState<boolean>(false);
const [password, setPassword] = React.useState<string>('');
const { showAlert } = useAlertDialog();
const [isOpeningUrl, setIsOpeningUrl] = React.useState(false);
const shouldUseOrSimulateAppStoreProduct =
shouldUseAppStoreProduct() || simulateAppStoreProduct;
const willReceiveAnEmailForThePurchase = !profile && fastCheckout;
const onStartPurchase = async () => {
if (!profile) return;
setDisplayPasswordPrompt(false);
// Purchase with the App Store.
if (shouldUseOrSimulateAppStoreProduct) {
if (!profile) {
return;
}
try {
setIsPurchasing(true);
await purchaseAppStoreProduct(bundleListingData.appStoreProductId);
@@ -98,21 +105,35 @@ const BundlePurchaseDialog = ({
// Purchase with web.
try {
setIsPurchasing(true);
const checkoutUrl = simpleCheckout
? getStripeCheckoutUrl({
productId: bundleListingData.id,
priceName: price.name,
userId: profile.id,
userEmail: profile.email,
...(password ? { password } : undefined),
})
: getPurchaseCheckoutUrl({
productId: bundleListingData.id,
priceName: price.name,
userId: profile.id,
userEmail: profile.email,
...(password ? { password } : undefined),
});
if (fastCheckout) {
const checkoutUrl = getStripeCheckoutUrl({
productId: bundleListingData.id,
priceName: price.name,
userId: profile ? profile.id : undefined,
userEmail: profile ? profile.email : undefined,
userUuid: profile ? undefined : getUserUUID(),
password: password || undefined,
});
// Mark the Url as opening if opening in the same tab, as it can take some time to load.
setIsOpeningUrl(willReceiveAnEmailForThePurchase);
Window.openExternalURL(checkoutUrl, {
shouldOpenInSameTabIfPossible: willReceiveAnEmailForThePurchase,
});
return;
}
// Cannot continue without a profile if not doing fast checkout.
if (!profile) {
return;
}
const checkoutUrl = getPurchaseCheckoutUrl({
productId: bundleListingData.id,
priceName: price.name,
userId: profile.id,
userEmail: profile.email,
password: password || undefined,
});
Window.openExternalURL(checkoutUrl);
} catch (error) {
const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode(
@@ -155,6 +176,16 @@ const BundlePurchaseDialog = ({
[]
);
const onCloseDialog = React.useCallback(
() => {
if (onCloseAfterPurchaseDone && purchaseSuccessful) {
onCloseAfterPurchaseDone();
}
onClose();
},
[onCloseAfterPurchaseDone, purchaseSuccessful, onClose]
);
// This effect will be triggered when the bundle purchases change,
// to check if the user has just bought the product.
React.useEffect(
@@ -170,7 +201,6 @@ const BundlePurchaseDialog = ({
// We found the purchase, the user has bought the bundle.
// We do not close the dialog yet, as we need to trigger a refresh of the products received.
await onPurchaseSuccessful();
if (onPurchaseDone) onPurchaseDone();
}
};
checkIfPurchaseIsDone();
@@ -180,7 +210,7 @@ const BundlePurchaseDialog = ({
bundlePurchases,
bundleListingData,
onPurchaseSuccessful,
onPurchaseDone,
onCloseAfterPurchaseDone,
onRefreshBundlePurchases,
]
);
@@ -189,7 +219,7 @@ const BundlePurchaseDialog = ({
() => {
onRefreshBundlePurchases();
},
isPurchasing ? 3900 : null
isPurchasing && !willReceiveAnEmailForThePurchase ? 3900 : null
);
// Listen to the received bundle, to know when a user has just logged in and the received bundles have been loaded.
@@ -226,7 +256,7 @@ const BundlePurchaseDialog = ({
setIsPurchasing(false);
setPurchaseSuccessful(true);
} else if (!purchaseSuccessful) {
onClose();
onCloseDialog();
}
}
}
@@ -235,106 +265,121 @@ const BundlePurchaseDialog = ({
receivedBundles,
bundleListingData,
isPurchasing,
onClose,
onCloseDialog,
isCheckingPurchasesAfterLogin,
purchaseSuccessful,
]
);
const dialogContents = !profile
? {
subtitle: <Trans>Log-in to purchase this item</Trans>,
content: (
<CreateProfile
onOpenLoginDialog={onOpenLoginDialog}
onOpenCreateAccountDialog={onOpenCreateAccountDialog}
message={
<Trans>
Bundles and their content will be linked to your user account
and available for all your projects. Log-in or sign-up to
purchase this bundle. (or restore your existing purchase).
</Trans>
}
justifyContent="center"
/>
),
}
: purchaseSuccessful
? {
subtitle: <Trans>Your purchase has been processed!</Trans>,
content: (
<Line justifyContent="center" alignItems="center">
<Text>
<Trans>You can now go back to use your new bundle.</Trans>
</Text>
</Line>
),
}
: isPurchasing
? {
subtitle: shouldUseOrSimulateAppStoreProduct ? (
<Trans>Complete your purchase with the app store.</Trans>
) : (
<Trans>Complete your payment on the web browser</Trans>
),
content: shouldUseOrSimulateAppStoreProduct ? (
<>
<ColumnStackLayout justifyContent="center" alignItems="center">
<CircularProgress size={40} />
<Text>
const dialogContents =
!profile && !fastCheckout
? {
subtitle: <Trans>Log-in to purchase this item</Trans>,
content: (
<CreateProfile
onOpenLoginDialog={onOpenLoginDialog}
onOpenCreateAccountDialog={onOpenCreateAccountDialog}
message={
<Trans>
The purchase will be linked to your account once done.
Bundles and their content will be linked to your user account
and available for all your projects. Log-in or sign-up to
purchase this bundle. (or restore your existing purchase).
</Trans>
}
justifyContent="center"
/>
),
}
: purchaseSuccessful
? {
subtitle: <Trans>Your purchase has been processed!</Trans>,
content: (
<Line justifyContent="center" alignItems="center">
<Text>
<Trans>You can now go back to use your new bundle.</Trans>
</Text>
</ColumnStackLayout>
</>
) : (
<>
</Line>
),
}
: isPurchasing
? {
subtitle: shouldUseOrSimulateAppStoreProduct ? (
<Trans>Complete your purchase with the app store.</Trans>
) : (
<Trans>Complete your payment on the web browser</Trans>
),
content: shouldUseOrSimulateAppStoreProduct ? (
<>
<ColumnStackLayout justifyContent="center" alignItems="center">
<CircularProgress size={40} />
<Text>
<Trans>
The purchase will be linked to your account once done.
</Trans>
</Text>
</ColumnStackLayout>
</>
) : (
<>
{!willReceiveAnEmailForThePurchase && (
<Line justifyContent="center" alignItems="center">
<CircularProgress size={20} />
<Spacer />
<Text>
<Trans>Waiting for the purchase confirmation...</Trans>
</Text>
</Line>
)}
<Spacer />
<Line justifyContent="center">
<BackgroundText>
{!willReceiveAnEmailForThePurchase ? (
<Trans>
Once you're done, come back to GDevelop and the bundle
will be added to your account automatically.
</Trans>
) : (
<Trans>
Once you're done, you will receive an email confirmation
so that you can link the bundle to your account.
</Trans>
)}
</BackgroundText>
</Line>
</>
),
}
: isCheckingPurchasesAfterLogin
? {
subtitle: <Trans>Loading your profile...</Trans>,
content: (
<Line justifyContent="center" alignItems="center">
<CircularProgress size={20} />
<Spacer />
</Line>
),
}
: {
subtitle: profile ? (
<Trans>
The bundle {bundleListingData.name} will be linked to your account{' '}
{profile.email}.
</Trans>
) : (
<Trans>
The bundle {bundleListingData.name} will be sent to the email
address provided in the checkout.
</Trans>
),
content: shouldUseOrSimulateAppStoreProduct ? null : (
<Line justifyContent="center" alignItems="center">
<Text>
<Trans>Waiting for the purchase confirmation...</Trans>
<Trans>
A new secure window will open to complete the purchase.
</Trans>
</Text>
</Line>
<Spacer />
<Line justifyContent="center">
<BackgroundText>
<Trans>
Once you're done, come back to GDevelop and the bundle will be
added to your account automatically.
</Trans>
</BackgroundText>
</Line>
</>
),
}
: isCheckingPurchasesAfterLogin
? {
subtitle: <Trans>Loading your profile...</Trans>,
content: (
<Line justifyContent="center" alignItems="center">
<CircularProgress size={20} />
</Line>
),
}
: {
subtitle: (
<Trans>
The bundle {bundleListingData.name} will be linked to your account{' '}
{profile.email}.
</Trans>
),
content: shouldUseOrSimulateAppStoreProduct ? null : (
<Line justifyContent="center" alignItems="center">
<Text>
<Trans>
A new secure window will open to complete the purchase.
</Trans>
</Text>
</Line>
),
};
),
};
const allowPurchase =
profile &&
@@ -344,8 +389,17 @@ const BundlePurchaseDialog = ({
const dialogActions = [
<FlatButton
key="cancel"
label={purchaseSuccessful ? <Trans>Close</Trans> : <Trans>Cancel</Trans>}
onClick={onClose}
label={
isOpeningUrl ? (
<Trans>Loading...</Trans>
) : purchaseSuccessful || willReceiveAnEmailForThePurchase ? (
<Trans>Close</Trans>
) : (
<Trans>Cancel</Trans>
)
}
onClick={onCloseDialog}
disabled={isOpeningUrl}
/>,
allowPurchase ? (
<DialogPrimaryButton
@@ -363,9 +417,9 @@ const BundlePurchaseDialog = ({
title={<Trans>{bundleListingData.name}</Trans>}
maxWidth="sm"
open
onRequestClose={onClose}
onRequestClose={onCloseDialog}
actions={dialogActions}
onApply={purchaseSuccessful ? onClose : onWillPurchase}
onApply={purchaseSuccessful ? onCloseDialog : onWillPurchase}
cannotBeDismissed // Prevent the user from continuing by clicking outside.
flexColumnBody
>

View File

@@ -30,15 +30,17 @@ type CreditsPackageStoreState = {|
openCreditsUsageDialog: CreditsUsageDialogOptions => void,
|};
export const initialCreditsPackageStoreState: CreditsPackageStoreState = {
fetchCreditsPackages: () => {},
creditsPackageListingDatas: null,
error: null,
openCreditsPackageDialog: () => {},
closeCreditsPackageDialog: () => {},
openCreditsUsageDialog: () => {},
};
export const CreditsPackageStoreContext = React.createContext<CreditsPackageStoreState>(
{
fetchCreditsPackages: () => {},
creditsPackageListingDatas: null,
error: null,
openCreditsPackageDialog: () => {},
closeCreditsPackageDialog: () => {},
openCreditsUsageDialog: () => {},
}
initialCreditsPackageStoreState
);
// Ids are in the form "amount_credits" (e.g: "500_credits").

View File

@@ -15,7 +15,7 @@ type CourseChapterStoreState = {|
listedCourseChapters: CourseChapterListingData[] | null,
|};
const initialCourseStoreState: CourseChapterStoreState = {
export const initialCourseStoreState: CourseChapterStoreState = {
listedCourses: null,
listedCourseChapters: null,
};

View File

@@ -5,6 +5,7 @@ import Window from '../Utils/Window';
export type Route =
| 'onboarding' // For compatibility when there was only one tutorial.
| 'guided-lesson' // New way of opening a tutorial.
| 'signup'
| 'subscription'
| 'games-dashboard'
| 'asset-store' // For compatibility when there was only asset packs.
@@ -14,7 +15,8 @@ export type Route =
| 'education'
| 'learn'
| 'play'
| 'standalone';
| 'standalone'
| 'ask-ai';
type RouteKey =
| 'initial-dialog'
| 'game-id'
@@ -27,7 +29,9 @@ type RouteKey =
| 'course-id'
| 'create-from-example'
| 'recommended-plan-id'
| 'playable-game-id';
| 'playable-game-id'
| 'purchase-id'
| 'claimable-token';
export type RouteArguments = { [RouteKey]: string };
export type Router = {|
@@ -37,14 +41,14 @@ export type Router = {|
navigateToRoute: (route: Route, additionalArgument?: RouteArguments) => void,
|};
const initialState: Router = {
export const initialRouterState: Router = {
routeArguments: {},
removeRouteArguments: () => {},
addRouteArguments: () => {},
navigateToRoute: () => {},
};
const RouterContext = React.createContext<Router>(initialState);
const RouterContext = React.createContext<Router>(initialRouterState);
export default RouterContext;

View File

@@ -98,8 +98,8 @@ const StandaloneDialog = ({ onClose }: Props) => {
courses={courses}
getCourseCompletion={getCourseCompletion}
noActions
simpleCheckout
onPurchaseDone={() => {
fastCheckout
onCloseAfterPurchaseDone={() => {
navigateToRoute('learn', {
bundle: selectedBundleListingData.id,
});
@@ -112,7 +112,10 @@ const StandaloneDialog = ({ onClose }: Props) => {
};
const StandaloneDialogWithErrorBoundary = (props: Props) => (
<ErrorBoundary componentTitle={<Trans>About dialog</Trans>} scope="about">
<ErrorBoundary
componentTitle={<Trans>Standalone dialog</Trans>}
scope="standalone"
>
<StandaloneDialog {...props} />
</ErrorBoundary>
);

View File

@@ -194,7 +194,7 @@ import { QuickCustomizationDialog } from '../QuickCustomization/QuickCustomizati
import { type ObjectWithContext } from '../ObjectsList/EnumerateObjects';
import useGamesList from '../GameDashboard/UseGamesList';
import useCapturesManager from './UseCapturesManager';
import useOpenPageForRouting from './useOpenPageForRouting';
import useHomePageSwitch from './useHomePageSwitch';
import RobotIcon from '../ProjectCreation/RobotIcon';
import PublicProfileContext from '../Profile/PublicProfileContext';
import { useGamesPlatformFrame } from './EditorContainers/HomePage/PlaySection/UseGamesPlatformFrame';
@@ -2098,9 +2098,8 @@ const MainFrame = (props: Props) => {
[setStandaloneDialogOpen]
);
const { navigateToRoute } = useOpenPageForRouting({
const { navigateToRoute } = useHomePageSwitch({
openHomePage,
openStandaloneDialog,
closeDialogs: closeDialogsToOpenHomePage,
});
@@ -3318,6 +3317,7 @@ const MainFrame = (props: Props) => {
openInAppTutorialDialog: selectInAppTutorial,
openProfileDialog: onOpenProfileDialog,
openAskAi,
openStandaloneDialog,
});
const onChangeProjectName = async (newName: string): Promise<void> => {

View File

@@ -14,19 +14,15 @@ const homePageRoutes: Route[] = [
'education',
];
const standaloneRoute = 'standalone';
/**
* This hook is used to be able to use route arguments from anywhere to open the homepage.
* It should close dialogs that prevent the navigation to the homepage.
*/
const useOpenPageForRouting = ({
const useHomePageSwitch = ({
openHomePage,
openStandaloneDialog,
closeDialogs,
}: {|
openHomePage: () => void,
openStandaloneDialog: () => void,
closeDialogs: () => void,
|}) => {
const {
@@ -45,24 +41,12 @@ const useOpenPageForRouting = ({
closeDialogs();
openHomePage();
}
if (initialDialog === standaloneRoute) {
closeDialogs();
openStandaloneDialog();
removeRouteArguments(['initial-dialog']);
}
},
[
routeArguments,
openHomePage,
closeDialogs,
openStandaloneDialog,
removeRouteArguments,
]
[routeArguments, openHomePage, closeDialogs, removeRouteArguments]
);
return {
navigateToRoute,
};
};
export default useOpenPageForRouting;
export default useHomePageSwitch;

View File

@@ -30,6 +30,7 @@ import {
type Bundle,
} from '../Utils/GDevelopServices/Asset';
import { type Purchase } from '../Utils/GDevelopServices/Shop';
import { type ClaimedProductOptions } from './PurchaseClaimDialog';
export type AuthenticatedUser = {|
authenticated: boolean,
@@ -69,9 +70,18 @@ export type AuthenticatedUser = {|
) => Promise<void>,
onResetPassword: ForgotPasswordForm => Promise<void>,
onOpenLoginDialog: () => void,
onOpenLoginWithPurchaseClaimDialog: (
claimedProductOptions: ClaimedProductOptions
) => void,
onOpenEditProfileDialog: () => void,
onOpenChangeEmailDialog: () => void,
onOpenCreateAccountDialog: () => void,
onOpenCreateAccountWithPurchaseClaimDialog: (
claimedProductOptions: ClaimedProductOptions
) => void,
onOpenPurchaseClaimDialog: (
claimedProductOptions: ClaimedProductOptions
) => void,
onBadgesChanged: () => Promise<void>,
onCloudProjectsChanged: () => Promise<void>,
onRefreshUserProfile: () => Promise<void>,
@@ -131,9 +141,12 @@ export const initialAuthenticatedUser = {
onEditProfile: async () => {},
onResetPassword: async () => {},
onOpenLoginDialog: () => {},
onOpenLoginWithPurchaseClaimDialog: () => {},
onOpenEditProfileDialog: () => {},
onOpenChangeEmailDialog: () => {},
onOpenCreateAccountDialog: () => {},
onOpenCreateAccountWithPurchaseClaimDialog: () => {},
onOpenPurchaseClaimDialog: () => {},
onBadgesChanged: async () => {},
onCloudProjectsChanged: async () => {},
onRefreshUserProfile: async () => {},

View File

@@ -63,6 +63,11 @@ import { showErrorBox } from '../UI/Messages/MessageBox';
import { userCancellationErrorName } from '../LoginProvider/Utils';
import { listUserPurchases } from '../Utils/GDevelopServices/Shop';
import { listNotifications } from '../Utils/GDevelopServices/Notification';
import LoginWithPurchaseClaimDialog from './LoginWithPurchaseClaimDialog';
import CreateAccountWithPurchaseClaimDialog from './CreateAccountWithPurchaseClaimDialog';
import PurchaseClaimDialog, {
type ClaimedProductOptions,
} from './PurchaseClaimDialog';
type Props = {|
authentication: Authentication,
@@ -73,7 +78,9 @@ type Props = {|
type State = {|
authenticatedUser: AuthenticatedUser,
loginDialogOpen: boolean,
loginWithPurchaseClaimDialogOpen: boolean,
createAccountDialogOpen: boolean,
createAccountWithPurchaseClaimDialogOpen: boolean,
loginInProgress: boolean,
createAccountInProgress: boolean,
editProfileDialogOpen: boolean,
@@ -90,6 +97,7 @@ type State = {|
changeEmailDialogOpen: boolean,
changeEmailInProgress: boolean,
userSnackbarMessage: ?React.Node,
claimedProductOptions: ?ClaimedProductOptions,
|};
const cleanUserTracesOnDevice = async () => {
@@ -109,7 +117,9 @@ export default class AuthenticatedUserProvider extends React.Component<
state = {
authenticatedUser: initialAuthenticatedUser,
loginDialogOpen: false,
loginWithPurchaseClaimDialogOpen: false,
createAccountDialogOpen: false,
createAccountWithPurchaseClaimDialogOpen: false,
loginInProgress: false,
createAccountInProgress: false,
editProfileDialogOpen: false,
@@ -126,6 +136,7 @@ export default class AuthenticatedUserProvider extends React.Component<
changeEmailDialogOpen: false,
changeEmailInProgress: false,
userSnackbarMessage: null,
claimedProductOptions: null,
};
_automaticallyUpdateUserProfile = true;
_hasNotifiedUserAboutEmailVerification = false;
@@ -210,9 +221,22 @@ export default class AuthenticatedUserProvider extends React.Component<
onBadgesChanged: this._fetchUserBadges,
onCloudProjectsChanged: this._fetchUserCloudProjects,
onOpenLoginDialog: () => this.openLoginDialog(true),
onOpenLoginWithPurchaseClaimDialog: (
claimedProductOptions: ClaimedProductOptions
) => this.openLoginWithPurchaseClaimDialog(true, claimedProductOptions),
onOpenEditProfileDialog: () => this.openEditProfileDialog(true),
onOpenChangeEmailDialog: () => this.openChangeEmailDialog(true),
onOpenCreateAccountDialog: () => this.openCreateAccountDialog(true),
onOpenCreateAccountWithPurchaseClaimDialog: (
claimedProductOptions: ClaimedProductOptions
) =>
this.openCreateAccountWithPurchaseClaimDialog(
true,
claimedProductOptions
),
onOpenPurchaseClaimDialog: (
claimedProductOptions: ClaimedProductOptions
) => this.openPurchaseClaimDialog(claimedProductOptions),
onRefreshUserProfile: this._fetchUserProfile,
onRefreshFirebaseProfile: async () => {
await this._reloadFirebaseProfile();
@@ -1132,7 +1156,15 @@ export default class AuthenticatedUserProvider extends React.Component<
});
await this._fetchUserProfileWithoutThrowingErrors({ resetState: true });
this.openLoginDialog(false);
this.openLoginWithPurchaseClaimDialog(
false,
this.state.claimedProductOptions
);
this.openCreateAccountDialog(false);
this.openCreateAccountWithPurchaseClaimDialog(
false,
this.state.claimedProductOptions
);
this._showLoginSnackbar(this.state.authenticatedUser);
} catch (apiCallError) {
if (apiCallError.name !== userCancellationErrorName) {
@@ -1187,6 +1219,10 @@ export default class AuthenticatedUserProvider extends React.Component<
await authentication.login(form);
await this._fetchUserProfileWithoutThrowingErrors({ resetState: true });
this.openLoginDialog(false);
this.openLoginWithPurchaseClaimDialog(
false,
this.state.claimedProductOptions
);
this._showLoginSnackbar(this.state.authenticatedUser);
} catch (apiCallError) {
this.setState({
@@ -1289,6 +1325,10 @@ export default class AuthenticatedUserProvider extends React.Component<
resetState: true,
});
this.openCreateAccountDialog(false);
this.openCreateAccountWithPurchaseClaimDialog(
false,
this.state.claimedProductOptions
);
sendSignupDone(form.email);
const firebaseUser = this.state.authenticatedUser.firebaseUser;
aliasUserForAnalyticsAfterSignUp(firebaseUser);
@@ -1452,6 +1492,18 @@ export default class AuthenticatedUserProvider extends React.Component<
});
};
openLoginWithPurchaseClaimDialog = (
open: boolean = true,
claimedProductOptions: ?ClaimedProductOptions = null
) => {
this.setState({
loginWithPurchaseClaimDialogOpen: open,
createAccountWithPurchaseClaimDialogOpen: false,
apiCallError: null,
claimedProductOptions,
});
};
showUserSnackbar = ({ message }: {| message: ?React.Node |}) => {
this.setState({
// The message is wrapped here to prevent crashes when Google Translate
@@ -1475,6 +1527,26 @@ export default class AuthenticatedUserProvider extends React.Component<
});
};
openCreateAccountWithPurchaseClaimDialog = (
open: boolean = true,
claimedProductOptions: ?ClaimedProductOptions = null
) => {
this.setState({
loginWithPurchaseClaimDialogOpen: false,
createAccountWithPurchaseClaimDialogOpen: open,
apiCallError: null,
claimedProductOptions,
});
};
openPurchaseClaimDialog = (
claimedProductOptions: ?ClaimedProductOptions = null
) => {
this.setState({
claimedProductOptions,
});
};
openChangeEmailDialog = (open: boolean = true) => {
this.setState({
changeEmailDialogOpen: open,
@@ -1614,13 +1686,33 @@ export default class AuthenticatedUserProvider extends React.Component<
}}
onGoToCreateAccount={() => this.openCreateAccountDialog(true)}
onLogin={this._doLogin}
onLogout={this._doLogout}
onLoginWithProvider={this._doLoginWithProvider}
loginInProgress={this.state.loginInProgress}
error={this.state.apiCallError}
onForgotPassword={this._doForgotPassword}
/>
)}
{this.state.loginWithPurchaseClaimDialogOpen &&
this.state.claimedProductOptions && (
<LoginWithPurchaseClaimDialog
onClose={() => {
this._cancelLoginOrSignUp();
this.openLoginWithPurchaseClaimDialog(false);
}}
onGoToCreateAccount={() =>
this.openCreateAccountWithPurchaseClaimDialog(
true,
this.state.claimedProductOptions
)
}
onLogin={this._doLogin}
onLoginWithProvider={this._doLoginWithProvider}
loginInProgress={this.state.loginInProgress}
error={this.state.apiCallError}
onForgotPassword={this._doForgotPassword}
claimedProductOptions={this.state.claimedProductOptions}
/>
)}
{this.state.authenticatedUser.profile &&
this.state.editProfileDialogOpen && (
<EditProfileDialog
@@ -1694,6 +1786,28 @@ export default class AuthenticatedUserProvider extends React.Component<
error={this.state.apiCallError}
/>
)}
{this.state.createAccountWithPurchaseClaimDialogOpen &&
this.state.claimedProductOptions && (
<CreateAccountWithPurchaseClaimDialog
onClose={() => {
this._cancelLoginOrSignUp();
this.openCreateAccountWithPurchaseClaimDialog(false);
}}
onGoToLogin={() =>
this.openLoginWithPurchaseClaimDialog(
true,
this.state.claimedProductOptions
)
}
onCreateAccount={form =>
this._doCreateAccount(form, this.props.preferencesValues)
}
onLoginWithProvider={this._doLoginWithProvider}
createAccountInProgress={this.state.createAccountInProgress}
error={this.state.apiCallError}
claimedProductOptions={this.state.claimedProductOptions}
/>
)}
{this.state.emailVerificationDialogOpen && (
<EmailVerificationDialog
authenticatedUser={this.state.authenticatedUser}
@@ -1711,6 +1825,12 @@ export default class AuthenticatedUserProvider extends React.Component<
onSendEmail={this._doSendEmailVerification}
/>
)}
{this.state.claimedProductOptions && (
<PurchaseClaimDialog
claimedProductOptions={this.state.claimedProductOptions}
onClose={() => this.openPurchaseClaimDialog(null)}
/>
)}
<Snackbar
open={!!this.state.userSnackbarMessage}
autoHideDuration={3000}

View File

@@ -1,5 +1,5 @@
// @flow
import React from 'react';
import * as React from 'react';
import { Trans } from '@lingui/macro';
import FlatButton from '../UI/FlatButton';
@@ -189,10 +189,18 @@ const CreateAccountDialog = ({
justifyContent="center"
alignItems="center"
>
<GDevelopGLogo fontSize="large" />
<Text size="section-title" align="center" noMargin>
<Trans>Welcome to GDevelop!</Trans>
</Text>
{
<ColumnStackLayout
noMargin
justifyContent="center"
alignItems="center"
>
<GDevelopGLogo fontSize="large" />
<Text size="section-title" align="center" noMargin>
<Trans>Welcome to GDevelop!</Trans>
</Text>
</ColumnStackLayout>
}
<div style={styles.formContainer}>
<CreateAccountForm
onCreateAccount={createAccount}

View File

@@ -0,0 +1,185 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import FlatButton from '../UI/FlatButton';
import Dialog, { DialogPrimaryButton } from '../UI/Dialog';
import {
type RegisterForm,
type AuthError,
type IdentityProvider,
} from '../Utils/GDevelopServices/Authentication';
import { type UsernameAvailability } from '../Utils/GDevelopServices/User';
import LeftLoader from '../UI/LeftLoader';
import { ColumnStackLayout } from '../UI/Layout';
import { isUsernameValid } from './UsernameField';
import HelpButton from '../UI/HelpButton';
import Text from '../UI/Text';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import CreateAccountForm from './CreateAccountForm';
import { CorsAwareImage } from '../UI/CorsAwareImage';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import { type ClaimedProductOptions } from './PurchaseClaimDialog';
const getStyles = ({ isMobile }) => {
return {
formContainer: {
display: 'flex',
width: isMobile ? '95%' : '90%',
marginTop: 10,
flexDirection: 'column',
},
previewImage: {
width: '100%',
maxWidth: 400,
display: 'block',
objectFit: 'contain',
borderRadius: 8,
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',
position: 'relative',
},
};
};
type Props = {|
onClose: () => void,
onGoToLogin: () => void,
onCreateAccount: (form: RegisterForm) => Promise<void>,
onLoginWithProvider: (provider: IdentityProvider) => Promise<void>,
createAccountInProgress: boolean,
error: ?AuthError,
claimedProductOptions: ClaimedProductOptions,
|};
const CreateAccountWithPurchaseClaimDialog = ({
onClose,
onGoToLogin,
onCreateAccount,
onLoginWithProvider,
createAccountInProgress,
error,
claimedProductOptions: { productListingData: claimedProduct },
}: Props) => {
const { isMobile } = useResponsiveWindowSize();
const styles = getStyles({ isMobile });
const [email, setEmail] = React.useState<string>('');
const [password, setPassword] = React.useState<string>('');
const [username, setUsername] = React.useState<string>('');
const [getNewsletterEmail, setGetNewsletterEmail] = React.useState<boolean>(
false
);
const [
usernameAvailability,
setUsernameAvailability,
] = React.useState<?UsernameAvailability>(null);
const [
isValidatingUsername,
setIsValidatingUsername,
] = React.useState<boolean>(false);
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const canCreateAccount =
!createAccountInProgress &&
isUsernameValid(username, { allowEmpty: true }) &&
!isValidatingUsername &&
(!usernameAvailability || usernameAvailability.isAvailable);
const createAccount = async () => {
if (!canCreateAccount) return;
try {
await onCreateAccount({
email: email.trim(),
password,
username,
getNewsletterEmail,
});
} catch (error) {
console.error('Error while creating account', error);
}
};
return (
<Dialog
title={null} // This dialog has a custom design to be more welcoming, the title is set in the content.
id="create-account-with-purchase-claim-dialog"
actions={[
<FlatButton
label={<Trans>Cancel</Trans>}
key="close"
primary={false}
onClick={onClose}
/>,
<LeftLoader isLoading={createAccountInProgress} key="create-account">
<DialogPrimaryButton
label={<Trans>Create account</Trans>}
id="create-account-button"
primary
disabled={!canCreateAccount}
onClick={createAccount}
/>
</LeftLoader>,
]}
secondaryActions={[
<HelpButton key="help" helpPagePath={'/interface/profile'} />,
]}
cannotBeDismissed={createAccountInProgress}
onApply={createAccount}
onRequestClose={() => {
if (!createAccountInProgress) onClose();
}}
maxWidth="md"
open
flexColumnBody
>
<ColumnStackLayout
noMargin
expand
justifyContent="center"
alignItems="center"
>
<ColumnStackLayout justifyContent="center" alignItems="center" noMargin>
{claimedProduct.productType === 'BUNDLE' && (
<CorsAwareImage
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={claimedProduct.thumbnailUrls[0]}
alt={`Preview image of bundle ${claimedProduct.name}`}
/>
)}
<Text size="section-title" align="center" noMargin>
<Trans>Create an account to activate your purchase!</Trans>
</Text>
</ColumnStackLayout>
<div style={styles.formContainer}>
<CreateAccountForm
onCreateAccount={createAccount}
onLoginWithProvider={onLoginWithProvider}
email={email}
onChangeEmail={setEmail}
password={password}
onChangePassword={setPassword}
username={username}
onChangeUsername={setUsername}
optInNewsletterEmail={getNewsletterEmail}
onChangeOptInNewsletterEmail={setGetNewsletterEmail}
createAccountInProgress={createAccountInProgress}
error={error}
onChangeUsernameAvailability={setUsernameAvailability}
isValidatingUsername={isValidatingUsername}
onChangeIsValidatingUsername={setIsValidatingUsername}
onGoToLogin={onGoToLogin}
/>
</div>
</ColumnStackLayout>
</Dialog>
);
};
export default CreateAccountWithPurchaseClaimDialog;

View File

@@ -1,7 +1,7 @@
// @flow
import { Trans } from '@lingui/macro';
import React from 'react';
import * as React from 'react';
import Dialog, { DialogPrimaryButton } from '../UI/Dialog';
import {
type LoginForm as LoginFormType,
@@ -33,7 +33,6 @@ type Props = {|
onClose: () => void,
onGoToCreateAccount: () => void,
onLogin: (form: LoginFormType) => Promise<void>,
onLogout: () => Promise<void>,
onLoginWithProvider: (provider: IdentityProvider) => Promise<void>,
onForgotPassword: (form: ForgotPasswordForm) => Promise<void>,
loginInProgress: boolean,
@@ -44,7 +43,6 @@ const LoginDialog = ({
onClose,
onGoToCreateAccount,
onLogin,
onLogout,
onLoginWithProvider,
onForgotPassword,
loginInProgress,
@@ -93,10 +91,14 @@ const LoginDialog = ({
justifyContent="center"
alignItems="center"
>
<GDevelopGLogo fontSize="large" />
<Text noMargin size="section-title" align="center">
<Trans>Log in to your account</Trans>
</Text>
{
<ColumnStackLayout noMargin justifyContent="center" alignItems="center">
<GDevelopGLogo fontSize="large" />
<Text noMargin size="section-title" align="center">
<Trans>Log in to your account</Trans>
</Text>
</ColumnStackLayout>
}
<div style={styles.formContainer}>
<LoginForm
onLogin={doLogin}

View File

@@ -0,0 +1,163 @@
// @flow
import { Trans } from '@lingui/macro';
import * as React from 'react';
import Dialog, { DialogPrimaryButton } from '../UI/Dialog';
import {
type LoginForm as LoginFormType,
type ForgotPasswordForm,
type AuthError,
type IdentityProvider,
} from '../Utils/GDevelopServices/Authentication';
import LoginForm from './LoginForm';
import LeftLoader from '../UI/LeftLoader';
import Text from '../UI/Text';
import { ColumnStackLayout } from '../UI/Layout';
import HelpButton from '../UI/HelpButton';
import FlatButton from '../UI/FlatButton';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import { CorsAwareImage } from '../UI/CorsAwareImage';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import { type ClaimedProductOptions } from './PurchaseClaimDialog';
const getStyles = ({ isMobile }) => {
return {
formContainer: {
display: 'flex',
width: isMobile ? '95%' : '90%',
marginTop: 10,
flexDirection: 'column',
},
previewImage: {
width: '100%',
maxWidth: 400,
display: 'block',
objectFit: 'contain',
borderRadius: 8,
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',
position: 'relative',
},
};
};
type Props = {|
onClose: () => void,
onGoToCreateAccount: () => void,
onLogin: (form: LoginFormType) => Promise<void>,
onLoginWithProvider: (provider: IdentityProvider) => Promise<void>,
onForgotPassword: (form: ForgotPasswordForm) => Promise<void>,
loginInProgress: boolean,
error: ?AuthError,
claimedProductOptions: ClaimedProductOptions,
|};
const LoginWithPurchaseClaimDialog = ({
onClose,
onGoToCreateAccount,
onLogin,
onLoginWithProvider,
onForgotPassword,
loginInProgress,
error,
claimedProductOptions: { productListingData: claimedProduct },
}: Props) => {
const { isMobile } = useResponsiveWindowSize();
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const styles = getStyles({ isMobile });
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const doLogin = () => {
if (loginInProgress) return;
onLogin({
email: email.trim(),
password,
});
};
const actions = [
<FlatButton
label={<Trans>Cancel</Trans>}
key="cancel"
primary={false}
onClick={onClose}
/>,
<LeftLoader isLoading={loginInProgress} key="login">
<DialogPrimaryButton
id="login-button"
label={<Trans>Login</Trans>}
primary
onClick={doLogin}
disabled={loginInProgress}
/>
</LeftLoader>,
];
const secondaryActions = [
<HelpButton key="help" helpPagePath={'/interface/profile'} />,
];
const dialogContent = (
<ColumnStackLayout
noMargin
expand
justifyContent="center"
alignItems="center"
>
<ColumnStackLayout justifyContent="center" alignItems="center" noMargin>
{claimedProduct.productType === 'BUNDLE' && (
<CorsAwareImage
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={claimedProduct.thumbnailUrls[0]}
alt={`Preview image of bundle ${claimedProduct.name}`}
/>
)}
<Text size="section-title" align="center" noMargin>
<Trans>Log in to your account to activate your purchase!</Trans>
</Text>
</ColumnStackLayout>
<div style={styles.formContainer}>
<LoginForm
onLogin={doLogin}
onLoginWithProvider={onLoginWithProvider}
email={email}
onChangeEmail={setEmail}
password={password}
onChangePassword={setPassword}
onForgotPassword={onForgotPassword}
loginInProgress={loginInProgress}
error={error}
onGoToCreateAccount={onGoToCreateAccount}
/>
</div>
</ColumnStackLayout>
);
return (
<Dialog
title={null} // This dialog has a custom design to be more welcoming, the title is set in the content.
id="login-dialog"
actions={actions}
secondaryActions={secondaryActions}
cannotBeDismissed={loginInProgress}
onRequestClose={onClose}
onApply={doLogin}
maxWidth="md"
open
flexColumnBody
>
{dialogContent}
</Dialog>
);
};
export default LoginWithPurchaseClaimDialog;

View File

@@ -0,0 +1,257 @@
// @flow
import * as React from 'react';
import { t, Trans } from '@lingui/macro';
import {
claimPurchase,
type BundleListingData,
} from '../Utils/GDevelopServices/Shop';
import Dialog, { DialogPrimaryButton } from '../UI/Dialog';
import AuthenticatedUserContext from './AuthenticatedUserContext';
import Text from '../UI/Text';
import { Column, Line } from '../UI/Grid';
import Mark from '../UI/CustomSvgIcons/Mark';
import FlatButton from '../UI/FlatButton';
import { LineStackLayout, ResponsiveLineStackLayout } from '../UI/Layout';
import useAlertDialog from '../UI/Alert/useAlertDialog';
import { extractGDevelopApiErrorStatusAndCode } from '../Utils/GDevelopServices/Errors';
import { CorsAwareImage } from '../UI/CorsAwareImage';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import RouterContext from '../MainFrame/RouterContext';
export type ClaimedProductOptions = {|
productListingData: BundleListingData, // Add more product types in the future.
purchaseId: string,
claimableToken: string,
|};
const styles = {
previewImage: {
width: '100%',
maxWidth: 400,
display: 'block',
objectFit: 'contain',
borderRadius: 8,
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',
position: 'relative',
},
};
type Props = {|
claimedProductOptions: ClaimedProductOptions,
onClose: () => void,
|};
const PurchaseClaimDialog = ({
claimedProductOptions: { productListingData, purchaseId, claimableToken },
onClose,
}: Props) => {
const {
getAuthorizationHeader,
profile,
onPurchaseSuccessful,
onRefreshBundlePurchases,
} = React.useContext(AuthenticatedUserContext);
const [isActivating, setIsActivating] = React.useState(false);
const [isPurchaseActivated, setIsPurchaseActivated] = React.useState(false);
const { showAlert } = useAlertDialog();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const { navigateToRoute } = React.useContext(RouterContext);
const shouldClaimOnOpen = React.useRef(!profile);
const activatePurchase = React.useCallback(
async () => {
if (!profile || isActivating || isPurchaseActivated) return;
setIsActivating(true);
let updatedPurchase = null;
try {
updatedPurchase = await claimPurchase({
getAuthorizationHeader,
purchaseId,
claimableToken,
userId: profile.id,
});
} catch (error) {
const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode(
error
);
let message = t`An error occurred while activating your purchase. Please contact support if the problem persists.`;
if (
extractedStatusAndCode &&
extractedStatusAndCode.status === 400 &&
extractedStatusAndCode.code === 'purchase/already-claimed'
) {
message = t`This purchase has already been activated.`;
} else if (
extractedStatusAndCode &&
extractedStatusAndCode.status === 403
) {
if (extractedStatusAndCode.code === 'purchase/cannot-claim') {
message = t`This purchase cannot be claimed.`;
} else if (extractedStatusAndCode.code === 'purchase/invalid-token') {
message = t`The token used to claim this purchase is invalid.`;
}
} else if (
extractedStatusAndCode &&
extractedStatusAndCode.status === 404
) {
message = t`This purchase could not be found. Please contact support for more information.`;
} else if (
extractedStatusAndCode &&
extractedStatusAndCode.status === 409
) {
if (extractedStatusAndCode.code === 'purchase/already-owned') {
message = t`This account already owns this product, you cannot activate it again.`;
}
} else if (
extractedStatusAndCode &&
extractedStatusAndCode.status === 500
) {
message = t`The server is currently unavailable. Please try again later.`;
}
showAlert({
title: t`Could not activate purchase`,
message,
});
setIsActivating(false);
onClose();
return;
}
try {
if (updatedPurchase.productType === 'BUNDLE') {
await onRefreshBundlePurchases();
navigateToRoute('learn', {
bundle: updatedPurchase.productId,
});
}
onPurchaseSuccessful();
} finally {
setIsActivating(false);
setIsPurchaseActivated(true);
}
},
[
profile,
isActivating,
getAuthorizationHeader,
purchaseId,
claimableToken,
showAlert,
onRefreshBundlePurchases,
onPurchaseSuccessful,
onClose,
isPurchaseActivated,
navigateToRoute,
]
);
React.useEffect(
() => {
if (profile && shouldClaimOnOpen.current) {
// The user has just logged in or signed up, so we can proceed with the
// claim.
activatePurchase();
shouldClaimOnOpen.current = false; // Ensure we don't try to claim again if profile changes.
}
},
[profile, activatePurchase]
);
if (!profile) {
// The dialog is meant to be displayed as soon as the user is authenticated.
// If they're not, they can be in the middle of logging in or signing up, so
// we don't show anything, until they're logged in.
return null;
}
const productType = productListingData
? productListingData.productType
: 'product';
const productName = productListingData ? productListingData.name : '';
const dialogContents = isPurchaseActivated
? {
subtitle: <Trans>Your {productType} has been activated!</Trans>,
content: (
<Line justifyContent="center" alignItems="center" noMargin>
<Text>
<Trans>You can now go back to use your new product.</Trans>
</Text>
</Line>
),
}
: {
subtitle: (
<Trans>
The {productType} {productName} will be linked to your account{' '}
{profile.email}
</Trans>
),
content: null,
};
const dialogActions = [
<FlatButton
key="cancel"
label={isPurchaseActivated ? <Trans>Close</Trans> : <Trans>Cancel</Trans>}
onClick={onClose}
disabled={isActivating}
/>,
!isPurchaseActivated ? (
<DialogPrimaryButton
key="activate"
primary
label={
isActivating ? <Trans>Activating...</Trans> : <Trans>Activate</Trans>
}
onClick={activatePurchase}
disabled={isActivating}
/>
) : null,
];
return (
<Dialog
title={<Trans>Activate {productName}</Trans>}
maxWidth="md"
open
onRequestClose={onClose}
actions={dialogActions}
onApply={isPurchaseActivated ? onClose : activatePurchase}
cannotBeDismissed // Prevent the user from continuing by clicking outside.
flexColumnBody
>
<ResponsiveLineStackLayout noMargin alignItems="center">
{productListingData && (
<CorsAwareImage
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={productListingData.thumbnailUrls[0]}
alt={`Preview image of product ${productListingData.name}`}
/>
)}
<Column>
<LineStackLayout
justifyContent="flex-start"
alignItems="center"
noMargin
>
{isPurchaseActivated && <Mark />}
<Text size="sub-title">{dialogContents.subtitle}</Text>
</LineStackLayout>
{dialogContents.content}
</Column>
</ResponsiveLineStackLayout>
</Dialog>
);
};
export default PurchaseClaimDialog;

View File

@@ -844,7 +844,9 @@ export default function SubscriptionDialog({
key="create-account"
label={<Trans>Create my account</Trans>}
primary
onClick={authenticatedUser.onOpenCreateAccountDialog}
onClick={() =>
authenticatedUser.onOpenCreateAccountDialog()
}
/>,
]}
>

View File

@@ -278,28 +278,28 @@ export const QuickPublish = ({
primary
icon={<Google />}
label={<Trans>Google</Trans>}
onClick={onOpenCreateAccountDialog}
onClick={() => onOpenCreateAccountDialog()}
fullWidth
/>
<RaisedButton
primary
icon={<GitHub />}
label={<Trans>Github</Trans>}
onClick={onOpenCreateAccountDialog}
onClick={() => onOpenCreateAccountDialog()}
fullWidth
/>
<RaisedButton
primary
icon={<Apple />}
label={<Trans>Apple</Trans>}
onClick={onOpenCreateAccountDialog}
onClick={() => onOpenCreateAccountDialog()}
fullWidth
/>
</ResponsiveLineStackLayout>
<FlatButton
primary
label={<Trans>Use your email</Trans>}
onClick={onOpenCreateAccountDialog}
onClick={() => onOpenCreateAccountDialog()}
/>
</ColumnStackLayout>
</div>

View File

@@ -70,7 +70,8 @@ type ErrorBoundaryScope =
| 'box-search-result'
| 'list-search-result'
| 'custom-object-editor-canvas'
| 'project-file-list';
| 'project-file-list'
| 'standalone';
export const getEditorErrorBoundaryProps = (
editorKey: string

View File

@@ -104,7 +104,7 @@ const UserChip = ({ onOpenProfile }: Props) => {
<Trans>Sign up</Trans>
</span>
}
onClick={onOpenCreateAccountDialog}
onClick={() => onOpenCreateAccountDialog()}
primary
/>
</LineStackLayout>

View File

@@ -317,6 +317,22 @@ export const listListedBundles = async (): Promise<
return bundles;
};
export const getListedBundle = async ({
bundleId,
visibility,
}: {|
bundleId: string,
visibility?: 'all',
|}): Promise<?BundleListingData> => {
const response = await client.get(`/bundle/${bundleId}`, {
params: {
visibility,
},
});
return response.data;
};
export const listSellerAssetPacks = async ({
sellerId,
}: {|
@@ -476,15 +492,17 @@ export const getPurchaseCheckoutUrl = ({
export const getStripeCheckoutUrl = ({
userId,
userUuid,
productId,
priceName,
userEmail,
password,
}: {|
userId: string,
userId?: string,
userUuid?: string,
productId: string,
priceName: string,
userEmail: string,
userEmail?: string,
password?: string,
|}) => {
const url = new URL(
@@ -493,8 +511,9 @@ export const getStripeCheckoutUrl = ({
url.searchParams.set('productId', productId);
url.searchParams.set('priceName', priceName);
url.searchParams.set('userId', userId);
url.searchParams.set('customerEmail', userEmail);
if (userUuid) url.searchParams.set('userUuid', userUuid);
if (userId) url.searchParams.set('userId', userId);
if (userEmail) url.searchParams.set('customerEmail', userEmail);
if (password) url.searchParams.set('password', password);
return url.toString();
@@ -730,3 +749,29 @@ export const redeemPrivateAssetPack = async ({
}
);
};
export const claimPurchase = async ({
getAuthorizationHeader,
userId,
purchaseId,
claimableToken,
}: {|
getAuthorizationHeader: () => Promise<string>,
userId: string,
purchaseId: string,
claimableToken: string,
|}): Promise<Purchase> => {
const authorizationHeader = await getAuthorizationHeader();
const result = await client.post(
`/purchase/${purchaseId}/action/claim`,
{ claimableToken },
{
params: {
userId,
},
headers: { Authorization: authorizationHeader },
}
);
return result.data;
};

View File

@@ -4,6 +4,10 @@ import RouterContext from '../MainFrame/RouterContext';
import { SubscriptionSuggestionContext } from '../Profile/Subscription/SubscriptionSuggestionContext';
import { FLING_GAME_IN_APP_TUTORIAL_ID } from './GDevelopServices/InAppTutorial';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import { t } from '@lingui/macro';
import { getListedBundle } from './GDevelopServices/Shop';
import useAlertDialog from '../UI/Alert/useAlertDialog';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
type Props = {|
openInAppTutorialDialog: (tutorialId: string) => void,
@@ -13,6 +17,7 @@ type Props = {|
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
openStandaloneDialog: () => void,
|};
/**
@@ -23,6 +28,7 @@ const useOpenInitialDialog = ({
openInAppTutorialDialog,
openProfileDialog,
openAskAi,
openStandaloneDialog,
}: Props) => {
const { routeArguments, removeRouteArguments } = React.useContext(
RouterContext
@@ -32,75 +38,143 @@ const useOpenInitialDialog = ({
);
const {
onOpenCreateAccountDialog,
onOpenCreateAccountWithPurchaseClaimDialog,
onOpenPurchaseClaimDialog,
onOpenLoginDialog,
authenticated,
loginState,
} = React.useContext(AuthenticatedUserContext);
const { showAlert } = useAlertDialog();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
React.useEffect(
() => {
switch (routeArguments['initial-dialog']) {
case 'subscription':
let recommendedPlanId =
routeArguments['recommended-plan-id'] || 'gdevelop_silver';
async function openCorrespondingDialog() {
switch (routeArguments['initial-dialog']) {
case 'subscription':
const recommendedPlanId =
routeArguments['recommended-plan-id'] || 'gdevelop_silver';
openSubscriptionDialog({
analyticsMetadata: {
reason: 'Landing dialog at opening',
recommendedPlanId,
placementId: 'opening-from-link',
},
});
removeRouteArguments(['initial-dialog', 'recommended-plan-id']);
break;
case 'signup':
// Add timeout to give time to the app to sign in with Firebase
// to make sure the most relevant dialog is opened.
const signupTimeoutId = setTimeout(() => {
if (authenticated) {
openProfileDialog();
} else {
onOpenCreateAccountDialog();
openSubscriptionDialog({
analyticsMetadata: {
reason: 'Landing dialog at opening',
recommendedPlanId,
placementId: 'opening-from-link',
},
});
removeRouteArguments(['initial-dialog', 'recommended-plan-id']);
break;
case 'signup':
if (loginState !== 'done') {
// Wait for the login state to be done (user is authenticated or not) before opening the dialog.
return;
}
try {
const claimableToken = routeArguments['claimable-token'];
const purchaseId = routeArguments['purchase-id'];
if (purchaseId && claimableToken) {
const bundleId = routeArguments['bundle'];
let claimedProduct = null;
if (bundleId) {
const listedBundle = await getListedBundle({
bundleId,
visibility: 'all',
});
claimedProduct = listedBundle;
}
if (!claimedProduct) {
console.error(
`The bundle with id ${bundleId} does not exist. Cannot claim.`
);
await showAlert({
title: t`Unknown bundle`,
message: t`The bundle you are trying to claim does not exist anymore. Please contact support if you think this is an error.`,
});
return;
}
const claimedProductOptions = {
productListingData: claimedProduct,
purchaseId,
claimableToken,
};
if (authenticated) {
onOpenPurchaseClaimDialog(claimedProductOptions);
} else {
onOpenCreateAccountWithPurchaseClaimDialog(
claimedProductOptions
);
}
return;
}
if (authenticated) {
openProfileDialog();
} else {
onOpenCreateAccountDialog();
}
} finally {
removeRouteArguments([
'initial-dialog',
'purchase-id',
'claimable-token',
'bundle',
]);
}
break;
case 'onboarding':
case 'guided-lesson':
const tutorialId = routeArguments['tutorial-id'];
if (tutorialId) {
openInAppTutorialDialog(tutorialId);
} else {
// backward compatibility, open the fling game tutorial.
openInAppTutorialDialog(FLING_GAME_IN_APP_TUTORIAL_ID);
}
removeRouteArguments(['initial-dialog', 'tutorial-id']);
break;
case 'games-dashboard':
// Do nothing as it should open the games dashboard on the homepage
// in the manage tab. So the homepage handles the route arguments itself.
break;
case 'ask-ai':
openAskAi({
mode: 'agent',
aiRequestId: null,
paneIdentifier: 'center',
});
removeRouteArguments(['initial-dialog']);
}, 2000);
return () => clearTimeout(signupTimeoutId);
case 'onboarding':
case 'guided-lesson':
const tutorialId = routeArguments['tutorial-id'];
if (tutorialId) {
openInAppTutorialDialog(tutorialId);
} else {
// backward compatibility, open the fling game tutorial.
openInAppTutorialDialog(FLING_GAME_IN_APP_TUTORIAL_ID);
}
removeRouteArguments(['initial-dialog', 'tutorial-id']);
break;
case 'games-dashboard':
// Do nothing as it should open the games dashboard on the homepage
// in the manage tab. So the homepage handles the route arguments itself.
break;
case 'ask-ai':
openAskAi({
mode: 'agent',
aiRequestId: null,
paneIdentifier: 'center',
});
removeRouteArguments(['initial-dialog']);
break;
default:
break;
break;
case 'standalone':
openStandaloneDialog();
removeRouteArguments(['initial-dialog']);
break;
default:
break;
}
}
openCorrespondingDialog();
},
[
routeArguments,
openInAppTutorialDialog,
openProfileDialog,
openStandaloneDialog,
removeRouteArguments,
openSubscriptionDialog,
authenticated,
onOpenCreateAccountDialog,
onOpenCreateAccountWithPurchaseClaimDialog,
onOpenLoginDialog,
openAskAi,
loginState,
showAlert,
gdevelopTheme,
onOpenPurchaseClaimDialog,
]
);
};

View File

@@ -351,7 +351,14 @@ export default class Window {
}
}
static openExternalURL(url: string) {
static openExternalURL(
url: string,
{
shouldOpenInSameTabIfPossible,
}: {|
shouldOpenInSameTabIfPossible?: boolean,
|} = {}
) {
if (!url) return;
if (electron) {
@@ -359,7 +366,7 @@ export default class Window {
return;
}
window.open(url, '_blank');
window.open(url, shouldOpenInSameTabIfPossible ? '_self' : '_blank');
}
static getOrientation(): 'portrait' | 'landscape' {

View File

@@ -29,6 +29,7 @@ import {
type AssetShortHeader,
type Asset,
type PublicAssetPacks,
type Bundle,
} from '../../Utils/GDevelopServices/Asset';
import { formatISO, subDays } from 'date-fns';
import { type Comment } from '../../Utils/GDevelopServices/Play';
@@ -36,7 +37,11 @@ import {
type Announcement,
type Promotion,
} from '../../Utils/GDevelopServices/Announcement';
import { type PrivateGameTemplateListingData } from '../../Utils/GDevelopServices/Shop';
import {
type PrivateGameTemplateListingData,
type PrivateAssetPackListingData,
type BundleListingData,
} from '../../Utils/GDevelopServices/Shop';
import { fakeAchievements } from './FakeAchievements';
import { type FileMetadataAndStorageProviderName } from '../../ProjectsStorage';
import {
@@ -894,9 +899,12 @@ export const defaultAuthenticatedUserWithNoSubscription: AuthenticatedUser = {
onResetPassword: async () => {},
onEditProfile: async () => {},
onOpenLoginDialog: () => {},
onOpenLoginWithPurchaseClaimDialog: () => {},
onOpenEditProfileDialog: () => {},
onOpenChangeEmailDialog: () => {},
onOpenCreateAccountDialog: () => {},
onOpenCreateAccountWithPurchaseClaimDialog: () => {},
onOpenPurchaseClaimDialog: () => {},
onOpenEmailVerificationDialog: () => {},
onBadgesChanged: async () => {},
onCloudProjectsChanged: async () => {},
@@ -2190,8 +2198,18 @@ export const fakePrivateGameTemplateListingData: PrivateGameTemplateListingData
categories: ['adventure'],
updatedAt: '2020-01-01',
createdAt: '2020-01-01',
thumbnailUrls: [],
prices: [],
thumbnailUrls: [
'https://resources.gdevelop-app.com/staging/private-assets/French Food/thumbnail1.png',
],
prices: [
{
name: 'commercial_USD',
value: 499,
currency: 'USD',
usageType: 'commercial',
stripePriceId: 'price_1JHhXYZfakeStripePriceId',
},
],
creditPrices: [],
appStoreProductId: 'fake-app-store-product-id',
includedListableProductIds: [],
@@ -2336,6 +2354,161 @@ export const fakeAssetPacks: PublicAssetPacks = {
],
};
export const fakePrivateAssetPack1ListingData: PrivateAssetPackListingData = {
id: '56a50a9e-57ef-4d1d-a3f2-c918d593a6e2',
sellerId: 'tVUYpNMz1AfsbzJtxUEpPTuu4Mn1',
isSellerGDevelop: false,
productType: 'ASSET_PACK',
thumbnailUrls: [
'https://resources.gdevelop-app.com/staging/private-assets/French Food/thumbnail1.png',
],
updatedAt: '2022-09-14T12:43:51.329Z',
createdAt: '2022-09-14T12:43:51.329Z',
listing: 'ASSET_PACK',
description: '5 assets',
name: 'French Food',
categories: ['props'],
prices: [
{
value: 1500,
name: 'commercial_USD',
stripePriceId: 'stripePriceId',
currency: 'USD',
usageType: 'commercial',
},
],
creditPrices: [
{
amount: 1500,
usageType: 'commercial',
},
],
appStoreProductId: null,
sellerStripeAccountId: 'sellerStripeProductId',
stripeProductId: 'stripeProductId',
};
export const fakePrivateAssetPack2ListingData: PrivateAssetPackListingData = {
id: '56a50a9e-57ef-4d1d-a3f2-c918d568ef234',
sellerId: 'tVUYpNMz1AfsbzJtxUEpPTuu4Mn1',
isSellerGDevelop: false,
productType: 'ASSET_PACK',
thumbnailUrls: [
'https://resources.gdevelop-app.com/staging/private-assets/French Sounds/thumbnail0.png',
],
updatedAt: '2022-09-14T12:43:51.329Z',
createdAt: '2022-09-14T12:43:51.329Z',
listing: 'ASSET_PACK',
description: '8 assets',
name: 'French Sounds',
categories: ['sounds'],
prices: [
{
value: 1000,
usageType: 'commercial',
stripePriceId: 'stripePriceId',
currency: 'USD',
name: 'commercial_USD',
},
],
creditPrices: [
{
amount: 1000,
usageType: 'commercial',
},
],
appStoreProductId: 'fake.product.id',
sellerStripeAccountId: 'sellerStripeProductId',
stripeProductId: 'stripeProductId',
};
export const fakeBundleListingData: BundleListingData = {
id: 'bundle-123',
sellerId: 'tVUYpNMz1AfsbzJtxUEpPTuu4Mn1',
isSellerGDevelop: false,
productType: 'BUNDLE',
thumbnailUrls: [
'https://resources.gdevelop-app.com/staging/private-assets/French Food/thumbnail1.png',
'https://resources.gdevelop-app.com/staging/private-assets/French Sounds/thumbnail0.png',
],
updatedAt: '2022-09-14T12:43:51.329Z',
createdAt: '2022-09-14T12:43:51.329Z',
listing: 'BUNDLE',
description: 'The ultimate French bundle with food and sounds',
name: 'French Complete Bundle',
categories: ['starter'],
prices: [
{
value: 2000,
name: 'default_USD',
stripePriceId: 'stripePriceId',
currency: 'USD',
usageType: 'default',
},
],
appStoreProductId: null,
sellerStripeAccountId: 'sellerStripeProductId',
stripeProductId: 'stripeProductId',
includedListableProducts: [
{
productId: fakePrivateAssetPack1ListingData.id,
productType: 'ASSET_PACK',
usageType: 'commercial',
},
{
productId: fakePrivateAssetPack2ListingData.id,
productType: 'ASSET_PACK',
usageType: 'commercial',
},
{
productId: fakePrivateGameTemplateListingData.id,
productType: 'GAME_TEMPLATE',
usageType: 'commercial',
},
],
};
export const fakeBundle: Bundle = {
id: 'bundle-123',
name: 'French Complete Bundle',
nameByLocale: {
en: 'French Complete Bundle',
},
categories: ['starter'],
previewImageUrls: [
'https://resources.gdevelop-app.com/assets/Packs/gdevelop platformer.png',
'https://resources.gdevelop-app.com/assets/Packs/space shooter.png',
'https://resources.gdevelop-app.com/assets/Packs/particles emitter.png',
],
updatedAt: '2022-09-15T08:17:59.977Z',
createdAt: '2022-09-14T12:27:27.173Z',
tag: 'french bundle',
longDescription:
'This is the best bundle about french food and sounds. It includes everything you need to create a French-themed game.',
longDescriptionByLocale: {
en:
'This is the best bundle about french food and sounds. It includes everything you need to create a French-themed game.',
},
includedProducts: [
{
productId: fakePrivateAssetPack1ListingData.id,
productType: 'ASSET_PACK',
usageType: 'commercial',
},
{
productId: fakePrivateAssetPack2ListingData.id,
productType: 'ASSET_PACK',
usageType: 'commercial',
},
{
productId: fakePrivateGameTemplateListingData.id,
productType: 'GAME_TEMPLATE',
usageType: 'commercial',
},
],
includedRedemptionCodes: [],
};
export const commentUnprocessed: Comment = {
id: 'comment-unprocessed-id',
type: 'FEEDBACK',

View File

@@ -0,0 +1,233 @@
// @flow
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import MockAdapter from 'axios-mock-adapter';
import paperDecorator from '../../../PaperDecorator';
import BundleInformationPage from '../../../../AssetStore/Bundles/BundleInformationPage';
import {
client as assetApiAxiosClient,
type Bundle,
type Course,
} from '../../../../Utils/GDevelopServices/Asset';
import { client as userApiAxiosClient } from '../../../../Utils/GDevelopServices/User';
import { client as shopApiAxiosClient } from '../../../../Utils/GDevelopServices/Shop';
import { type BundleListingData } from '../../../../Utils/GDevelopServices/Shop';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
import {
fakeAssetPackLicenses,
fakeBundle,
fakeBundleListingData,
fakePrivateAssetPack1ListingData,
fakePrivateAssetPack2ListingData,
fakePrivateGameTemplateListingData,
fakeSilverAuthenticatedUserWithCloudProjects,
} from '../../../../fixtures/GDevelopServicesTestData';
import {
BundleStoreContext,
initialBundleStoreState,
} from '../../../../AssetStore/Bundles/BundleStoreContext';
import {
initialPrivateGameTemplateStoreState,
PrivateGameTemplateStoreContext,
} from '../../../../AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext';
import {
AssetStoreContext,
initialAssetStoreState,
} from '../../../../AssetStore/AssetStoreContext';
import { ProductLicenseStoreStateProvider } from '../../../../AssetStore/ProductLicense/ProductLicenseStoreContext';
import { SubscriptionSuggestionProvider } from '../../../../Profile/Subscription/SubscriptionSuggestionContext';
import { type AuthenticatedUser } from '../../../../Profile/AuthenticatedUserContext';
import CourseStoreContext, {
initialCourseStoreState,
} from '../../../../Course/CourseStoreContext';
import {
CreditsPackageStoreContext,
initialCreditsPackageStoreState,
} from '../../../../AssetStore/CreditsPackages/CreditsPackageStoreContext';
export default {
title: 'AssetStore/AssetStore/BundleInformationPage',
component: BundleInformationPage,
decorators: [paperDecorator],
};
const sellerPublicProfile = {
id: 'tVUYpNMz1AfsbzJtxUEpPTuu4Mn1',
username: 'CreatorUserName',
description: 'I create bundles for GDevelop.',
};
const allBundleListingData = [fakeBundleListingData];
const allPrivateAssetPackListingData = [
fakePrivateAssetPack1ListingData,
fakePrivateAssetPack2ListingData,
];
const allPrivateGameTemplateListingData = [fakePrivateGameTemplateListingData];
const mockCourses: Array<Course> = [];
const BundleInformationPageStory = ({
bundleListingDataToDisplay,
receivedBundles = [],
authenticatedUser = fakeSilverAuthenticatedUserWithCloudProjects,
delayResponse = 0,
errorCode,
errorMessage,
}: {
bundleListingDataToDisplay: BundleListingData,
authenticatedUser?: AuthenticatedUser,
receivedBundles?: Array<Bundle>,
delayResponse?: number,
errorCode?: number,
errorMessage?: string,
}) => {
const userServiceMock = new MockAdapter(userApiAxiosClient, {
delayResponse,
});
userServiceMock
.onGet(`/user-public-profile/${bundleListingDataToDisplay.sellerId}`)
.reply(200, sellerPublicProfile)
.onGet(`/user/${bundleListingDataToDisplay.sellerId}/badge`)
.reply(200, [])
.onGet(`/achievement`)
.reply(200, []);
const assetServiceMock = new MockAdapter(assetApiAxiosClient, {
delayResponse,
});
assetServiceMock
.onGet(`/bundle/${bundleListingDataToDisplay.id}`)
.reply(errorCode || 200, errorCode ? errorMessage || null : fakeBundle)
.onAny()
.reply(config => {
console.error(`Unexpected call to ${config.url} (${config.method})`);
return [504, null];
});
const shopServiceMock = new MockAdapter(shopApiAxiosClient, {
delayResponse,
});
shopServiceMock
.onGet('/product-license')
.reply(200, fakeAssetPackLicenses)
.onPost(`/product/${bundleListingDataToDisplay.id}/action/redeem`)
.reply(config => {
action('Claim bundle')();
return [200, 'OK'];
})
.onAny()
.reply(config => {
console.error(`Unexpected call to ${config.url} (${config.method})`);
return [504, null];
});
return (
<BundleStoreContext.Provider
value={{
...initialBundleStoreState,
bundleListingDatas: allBundleListingData,
}}
>
<PrivateGameTemplateStoreContext.Provider
value={{
...initialPrivateGameTemplateStoreState,
privateGameTemplateListingDatas: allPrivateGameTemplateListingData,
}}
>
<AssetStoreContext.Provider
value={{
...initialAssetStoreState,
privateAssetPackListingDatas: allPrivateAssetPackListingData,
}}
>
<AuthenticatedUserContext.Provider
value={{
...authenticatedUser,
receivedBundles,
bundlePurchases: receivedBundles.map(receivedBundle => ({
id: 'purchase-id',
productType: 'BUNDLE',
usageType: 'commercial',
productId: receivedBundle.id,
buyerId: authenticatedUser.profile
? authenticatedUser.profile.id
: 'userId',
receiverId: authenticatedUser.profile
? authenticatedUser.profile.id
: 'userId',
createdAt: new Date(1707519600000).toString(),
})),
}}
>
<CourseStoreContext.Provider
value={{
...initialCourseStoreState,
listedCourses: [],
listedCourseChapters: [],
}}
>
<CreditsPackageStoreContext.Provider
value={{
...initialCreditsPackageStoreState,
creditsPackageListingDatas: [],
}}
>
<SubscriptionSuggestionProvider>
<ProductLicenseStoreStateProvider>
<BundleInformationPage
bundleListingData={bundleListingDataToDisplay}
onAssetPackOpen={() => action('open asset pack')()}
onGameTemplateOpen={() => action('open game template')()}
onBundleOpen={() => action('open bundle')()}
onCourseOpen={() => action('open course')()}
getSubscriptionPlansWithPricingSystems={() => []}
courses={mockCourses}
getCourseCompletion={() => null}
/>
</ProductLicenseStoreStateProvider>
</SubscriptionSuggestionProvider>
</CreditsPackageStoreContext.Provider>
</CourseStoreContext.Provider>
</AuthenticatedUserContext.Provider>
</AssetStoreContext.Provider>
</PrivateGameTemplateStoreContext.Provider>
</BundleStoreContext.Provider>
);
};
export const Default = () => (
<BundleInformationPageStory
bundleListingDataToDisplay={fakeBundleListingData}
/>
);
export const ForAlreadyPurchasedBundle = () => (
<BundleInformationPageStory
bundleListingDataToDisplay={fakeBundleListingData}
receivedBundles={[fakeBundle]}
/>
);
export const Loading = () => (
<BundleInformationPageStory
bundleListingDataToDisplay={fakeBundleListingData}
delayResponse={10000}
/>
);
export const With404 = () => (
<BundleInformationPageStory
bundleListingDataToDisplay={fakeBundleListingData}
errorCode={404}
/>
);
export const WithUnknownError = () => (
<BundleInformationPageStory
bundleListingDataToDisplay={fakeBundleListingData}
errorCode={500}
errorMessage={'Internal server error'}
/>
);

View File

@@ -0,0 +1,60 @@
// @flow
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import paperDecorator from '../../../PaperDecorator';
import BundlePurchaseDialog from '../../../../AssetStore/Bundles/BundlePurchaseDialog';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
import {
fakeSilverAuthenticatedUser,
fakeNotAuthenticatedUser,
fakeBundleListingData,
} from '../../../../fixtures/GDevelopServicesTestData';
export default {
title: 'AssetStore/AssetStore/BundlePurchaseDialog',
component: BundlePurchaseDialog,
decorators: [paperDecorator],
parameters: {
initialState: {
isBuying: true,
},
},
};
export const NotLoggedIn = () => {
return (
<AuthenticatedUserContext.Provider value={fakeNotAuthenticatedUser}>
<BundlePurchaseDialog
bundleListingData={fakeBundleListingData}
usageType="default"
onClose={() => action('close')()}
/>
</AuthenticatedUserContext.Provider>
);
};
export const LoggedIn = () => {
return (
<AuthenticatedUserContext.Provider value={fakeSilverAuthenticatedUser}>
<BundlePurchaseDialog
bundleListingData={fakeBundleListingData}
usageType="default"
onClose={() => action('close')()}
/>
</AuthenticatedUserContext.Provider>
);
};
export const FastCheckout = () => {
return (
<AuthenticatedUserContext.Provider value={fakeNotAuthenticatedUser}>
<BundlePurchaseDialog
bundleListingData={fakeBundleListingData}
usageType="default"
onClose={() => action('close')()}
fastCheckout
/>
</AuthenticatedUserContext.Provider>
);
};

View File

@@ -0,0 +1,60 @@
// @flow
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import paperDecorator from '../../PaperDecorator';
import CreateAccountWithPurchaseClaimDialog from '../../../Profile/CreateAccountWithPurchaseClaimDialog';
import { fakeBundleListingData } from '../../../fixtures/GDevelopServicesTestData';
export default {
title: 'Profile/CreateAccountWithPurchaseClaimDialog',
component: CreateAccountWithPurchaseClaimDialog,
decorators: [paperDecorator],
};
const claimedProductOptions = {
productListingData: fakeBundleListingData,
purchaseId: 'purchase-123',
claimableToken: 'token-123',
};
const defaultProps = {
onClose: () => action('onClose')(),
onGoToLogin: action('onGoToLogin'),
onCreateAccount: action('onCreateAccount'),
onLoginWithProvider: action('onLoginWithProvider'),
createAccountInProgress: false,
error: null,
claimedProductOptions,
};
export const Default = () => (
<CreateAccountWithPurchaseClaimDialog {...defaultProps} />
);
export const PasswordErrorFromBackend = () => (
<CreateAccountWithPurchaseClaimDialog
{...defaultProps}
error={{ code: 'auth/weak-password' }}
/>
);
export const EmailErrorFromBackend = () => (
<CreateAccountWithPurchaseClaimDialog
{...defaultProps}
error={{ code: 'auth/invalid-email' }}
/>
);
export const AccountExists = () => (
<CreateAccountWithPurchaseClaimDialog
{...defaultProps}
error={{ code: 'auth/account-exists-with-different-credential' }}
/>
);
export const Submitting = () => (
<CreateAccountWithPurchaseClaimDialog
{...defaultProps}
createAccountInProgress
/>
);

View File

@@ -14,7 +14,6 @@ const defaultProps = {
onClose: action('onClose'),
onForgotPassword: action('onForgotPassword'),
onLogin: action('onLogin'),
onLogout: action('onLogout'),
onLoginWithProvider: action('onLoginWithProvider'),
onGoToCreateAccount: action('onGoToCreateAccount'),
loginInProgress: false,

View File

@@ -0,0 +1,62 @@
// @flow
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import paperDecorator from '../../PaperDecorator';
import LoginWithPurchaseClaimDialog from '../../../Profile/LoginWithPurchaseClaimDialog';
import { fakeBundleListingData } from '../../../fixtures/GDevelopServicesTestData';
export default {
title: 'Profile/LoginWithPurchaseClaimDialog',
component: LoginWithPurchaseClaimDialog,
decorators: [paperDecorator],
};
const claimedProductOptions = {
productListingData: fakeBundleListingData,
purchaseId: 'purchase-123',
claimableToken: 'token-123',
};
const defaultProps = {
onClose: action('onClose'),
onForgotPassword: action('onForgotPassword'),
onLogin: action('onLogin'),
onLoginWithProvider: action('onLoginWithProvider'),
onGoToCreateAccount: action('onGoToCreateAccount'),
loginInProgress: false,
error: null,
claimedProductOptions,
};
export const Default = () => <LoginWithPurchaseClaimDialog {...defaultProps} />;
export const WeakPasswordErrorFromBackend = () => (
<LoginWithPurchaseClaimDialog
{...defaultProps}
error={{
code: 'auth/weak-password',
}}
/>
);
export const InvalidEmailErrorFromBackend = () => (
<LoginWithPurchaseClaimDialog
{...defaultProps}
error={{
code: 'auth/invalid-email',
}}
/>
);
export const AccountExistsWithDifferentCredentialErrorFromBackend = () => (
<LoginWithPurchaseClaimDialog
{...defaultProps}
error={{
code: 'auth/account-exists-with-different-credential',
}}
/>
);
export const Submitting = () => (
<LoginWithPurchaseClaimDialog {...defaultProps} loginInProgress />
);

View File

@@ -0,0 +1,243 @@
// @flow
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import MockAdapter from 'axios-mock-adapter';
import paperDecorator from '../../PaperDecorator';
import PurchaseClaimDialog from '../../../Profile/PurchaseClaimDialog';
import { client as shopApiAxiosClient } from '../../../Utils/GDevelopServices/Shop';
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
import {
fakeSilverAuthenticatedUser,
fakeNotAuthenticatedUser,
fakeBundleListingData,
} from '../../../fixtures/GDevelopServicesTestData';
import RouterContext, {
initialRouterState,
} from '../../../MainFrame/RouterContext';
import AlertProvider from '../../../UI/Alert/AlertProvider';
export default {
title: 'PurchaseClaimDialog',
component: PurchaseClaimDialog,
decorators: [paperDecorator],
};
const purchaseId = 'purchase-id-123';
const claimableToken = 'claimable-token-123';
export const ReadyToActivate = () => {
const shopServiceMock = new MockAdapter(shopApiAxiosClient, {
delayResponse: 0,
});
shopServiceMock
.onPost(`/purchase/${purchaseId}/action/claim`)
.reply(config => {
action('Claim purchase')();
return [
200,
{
id: purchaseId,
productId: fakeBundleListingData.id,
productType: 'BUNDLE',
usageType: 'default',
buyerId: 'buyer-id',
receiverId: 'receiver-id',
createdAt: new Date(1707519600000).toString(),
},
];
})
.onAny()
.reply(config => {
console.error(`Unexpected call to ${config.url} (${config.method})`);
return [504, null];
});
return (
<RouterContext.Provider
value={{
...initialRouterState,
navigateToRoute: (routeName, args) => {
action('Navigate to route')(routeName, args);
},
}}
>
<AlertProvider>
<AuthenticatedUserContext.Provider value={fakeSilverAuthenticatedUser}>
<PurchaseClaimDialog
claimedProductOptions={{
productListingData: fakeBundleListingData,
purchaseId,
claimableToken,
}}
onClose={() => action('close')()}
/>
</AuthenticatedUserContext.Provider>
</AlertProvider>
</RouterContext.Provider>
);
};
export const NotLoggedIn = () => {
return (
<RouterContext.Provider
value={{
...initialRouterState,
navigateToRoute: (routeName, args) => {
action('Navigate to route')(routeName, args);
},
}}
>
<AlertProvider>
<AuthenticatedUserContext.Provider value={fakeNotAuthenticatedUser}>
<PurchaseClaimDialog
claimedProductOptions={{
productListingData: fakeBundleListingData,
purchaseId,
claimableToken,
}}
onClose={() => action('close')()}
/>
</AuthenticatedUserContext.Provider>
</AlertProvider>
</RouterContext.Provider>
);
};
export const AlreadyClaimed = () => {
const shopServiceMock = new MockAdapter(shopApiAxiosClient, {
delayResponse: 0,
});
shopServiceMock
.onPost(`/purchase/${purchaseId}/action/claim`)
.reply(() => {
return [
400,
{
code: 'purchase/already-claimed',
message: 'This purchase has already been claimed.',
},
];
})
.onAny()
.reply(config => {
console.error(`Unexpected call to ${config.url} (${config.method})`);
return [504, null];
});
return (
<RouterContext.Provider
value={{
...initialRouterState,
navigateToRoute: (routeName, args) => {
action('Navigate to route')(routeName, args);
},
}}
>
<AlertProvider>
<AuthenticatedUserContext.Provider value={fakeSilverAuthenticatedUser}>
<PurchaseClaimDialog
claimedProductOptions={{
productListingData: fakeBundleListingData,
purchaseId,
claimableToken,
}}
onClose={() => action('close')()}
/>
</AuthenticatedUserContext.Provider>
</AlertProvider>
</RouterContext.Provider>
);
};
export const AlreadyOwned = () => {
const shopServiceMock = new MockAdapter(shopApiAxiosClient, {
delayResponse: 0,
});
shopServiceMock
.onPost(`/purchase/${purchaseId}/action/claim`)
.reply(() => {
return [
409,
{
code: 'purchase/already-owned',
message: 'This account already owns this product.',
},
];
})
.onAny()
.reply(config => {
console.error(`Unexpected call to ${config.url} (${config.method})`);
return [504, null];
});
return (
<RouterContext.Provider
value={{
...initialRouterState,
navigateToRoute: (routeName, args) => {
action('Navigate to route')(routeName, args);
},
}}
>
<AlertProvider>
<AuthenticatedUserContext.Provider value={fakeSilverAuthenticatedUser}>
<PurchaseClaimDialog
claimedProductOptions={{
productListingData: fakeBundleListingData,
purchaseId,
claimableToken,
}}
onClose={() => action('close')()}
/>
</AuthenticatedUserContext.Provider>
</AlertProvider>
</RouterContext.Provider>
);
};
export const InvalidToken = () => {
const shopServiceMock = new MockAdapter(shopApiAxiosClient, {
delayResponse: 0,
});
shopServiceMock
.onPost(`/purchase/${purchaseId}/action/claim`)
.reply(() => {
return [
403,
{
code: 'purchase/invalid-token',
message: 'The token is invalid.',
},
];
})
.onAny()
.reply(config => {
console.error(`Unexpected call to ${config.url} (${config.method})`);
return [504, null];
});
return (
<RouterContext.Provider
value={{
...initialRouterState,
navigateToRoute: (routeName, args) => {
action('Navigate to route')(routeName, args);
},
}}
>
<AlertProvider>
<AuthenticatedUserContext.Provider value={fakeSilverAuthenticatedUser}>
<PurchaseClaimDialog
claimedProductOptions={{
productListingData: fakeBundleListingData,
purchaseId,
claimableToken,
}}
onClose={() => action('close')()}
/>
</AuthenticatedUserContext.Provider>
</AlertProvider>
</RouterContext.Provider>
);
};

View File

@@ -0,0 +1,211 @@
// @flow
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import MockAdapter from 'axios-mock-adapter';
import paperDecorator from '../PaperDecorator';
import StandAloneDialog from '../../MainFrame/StandAloneDialog';
import {
client as assetApiAxiosClient,
type Bundle,
} from '../../Utils/GDevelopServices/Asset';
import { client as userApiAxiosClient } from '../../Utils/GDevelopServices/User';
import { client as shopApiAxiosClient } from '../../Utils/GDevelopServices/Shop';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
import {
fakeAssetPackLicenses,
fakeBundle,
fakeBundleListingData,
fakePrivateAssetPack1ListingData,
fakePrivateAssetPack2ListingData,
fakePrivateGameTemplateListingData,
fakeSilverAuthenticatedUserWithCloudProjects,
} from '../../fixtures/GDevelopServicesTestData';
import {
BundleStoreContext,
initialBundleStoreState,
} from '../../AssetStore/Bundles/BundleStoreContext';
import {
initialPrivateGameTemplateStoreState,
PrivateGameTemplateStoreContext,
} from '../../AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext';
import {
AssetStoreContext,
initialAssetStoreState,
} from '../../AssetStore/AssetStoreContext';
import { ProductLicenseStoreStateProvider } from '../../AssetStore/ProductLicense/ProductLicenseStoreContext';
import { SubscriptionSuggestionProvider } from '../../Profile/Subscription/SubscriptionSuggestionContext';
import { type AuthenticatedUser } from '../../Profile/AuthenticatedUserContext';
import CourseStoreContext, {
initialCourseStoreState,
} from '../../Course/CourseStoreContext';
import {
CreditsPackageStoreContext,
initialCreditsPackageStoreState,
} from '../../AssetStore/CreditsPackages/CreditsPackageStoreContext';
import RouterContext, {
initialRouterState,
} from '../../MainFrame/RouterContext';
export default {
title: 'StandAloneDialog',
component: StandAloneDialog,
decorators: [paperDecorator],
};
const sellerPublicProfile = {
id: 'tVUYpNMz1AfsbzJtxUEpPTuu4Mn1',
username: 'CreatorUserName',
description: 'I create bundles for GDevelop.',
};
const allBundleListingData = [fakeBundleListingData];
const allPrivateAssetPackListingData = [
fakePrivateAssetPack1ListingData,
fakePrivateAssetPack2ListingData,
];
const allPrivateGameTemplateListingData = [fakePrivateGameTemplateListingData];
const mockCourses = [];
const StandAloneDialogStory = ({
bundleCategory,
receivedBundles = [],
authenticatedUser = fakeSilverAuthenticatedUserWithCloudProjects,
delayResponse = 0,
errorCode,
errorMessage,
}: {
bundleCategory: string,
authenticatedUser?: AuthenticatedUser,
receivedBundles?: Array<Bundle>,
delayResponse?: number,
errorCode?: number,
errorMessage?: string,
}) => {
const userServiceMock = new MockAdapter(userApiAxiosClient, {
delayResponse,
});
userServiceMock
.onGet(`/user-public-profile/${fakeBundleListingData.sellerId}`)
.reply(200, sellerPublicProfile)
.onGet(`/user/${fakeBundleListingData.sellerId}/badge`)
.reply(200, [])
.onGet(`/achievement`)
.reply(200, []);
const assetServiceMock = new MockAdapter(assetApiAxiosClient, {
delayResponse,
});
assetServiceMock
.onGet(`/bundle/${fakeBundleListingData.id}`)
.reply(errorCode || 200, errorCode ? errorMessage || null : fakeBundle)
.onGet(`/course`)
.reply(200, mockCourses)
.onAny()
.reply(config => {
console.error(`Unexpected call to ${config.url} (${config.method})`);
return [504, null];
});
const shopServiceMock = new MockAdapter(shopApiAxiosClient, {
delayResponse,
});
shopServiceMock
.onGet('/product-license')
.reply(200, fakeAssetPackLicenses)
.onPost(`/product/${fakeBundleListingData.id}/action/redeem`)
.reply(config => {
action('Claim bundle')();
return [200, 'OK'];
})
.onAny()
.reply(config => {
console.error(`Unexpected call to ${config.url} (${config.method})`);
return [504, null];
});
return (
<RouterContext.Provider
value={{
...initialRouterState,
routeArguments: {
'bundle-category': bundleCategory,
},
removeRouteArguments: () => {},
navigateToRoute: (routeName, args) => {
action('Navigate to route')(routeName, args);
},
}}
>
<BundleStoreContext.Provider
value={{
...initialBundleStoreState,
bundleListingDatas: allBundleListingData,
}}
>
<PrivateGameTemplateStoreContext.Provider
value={{
...initialPrivateGameTemplateStoreState,
privateGameTemplateListingDatas: allPrivateGameTemplateListingData,
}}
>
<AssetStoreContext.Provider
value={{
...initialAssetStoreState,
privateAssetPackListingDatas: allPrivateAssetPackListingData,
}}
>
<AuthenticatedUserContext.Provider
value={{
...authenticatedUser,
receivedBundles,
bundlePurchases: receivedBundles.map(receivedBundle => ({
id: 'purchase-id',
productType: 'BUNDLE',
usageType: 'commercial',
productId: receivedBundle.id,
buyerId: authenticatedUser.profile
? authenticatedUser.profile.id
: 'userId',
receiverId: authenticatedUser.profile
? authenticatedUser.profile.id
: 'userId',
createdAt: new Date(1707519600000).toString(),
})),
}}
>
<CourseStoreContext.Provider
value={{
...initialCourseStoreState,
listedCourses: [],
}}
>
<CreditsPackageStoreContext.Provider
value={{
...initialCreditsPackageStoreState,
creditsPackageListingDatas: [],
}}
>
<SubscriptionSuggestionProvider>
<ProductLicenseStoreStateProvider>
<StandAloneDialog onClose={() => action('close')()} />
</ProductLicenseStoreStateProvider>
</SubscriptionSuggestionProvider>
</CreditsPackageStoreContext.Provider>
</CourseStoreContext.Provider>
</AuthenticatedUserContext.Provider>
</AssetStoreContext.Provider>
</PrivateGameTemplateStoreContext.Provider>
</BundleStoreContext.Provider>
</RouterContext.Provider>
);
};
export const Default = () => <StandAloneDialogStory bundleCategory="starter" />;
export const Loading = () => (
<StandAloneDialogStory bundleCategory="starter" delayResponse={10000} />
);