diff --git a/pages/purchase/finished.tsx b/pages/purchase/finished.tsx new file mode 100644 index 00000000..37e83921 --- /dev/null +++ b/pages/purchase/finished.tsx @@ -0,0 +1,27 @@ +import { GetStaticProps } from 'next' +import { useTranslation } from 'next-i18next' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import { NextSeo } from 'next-seo' +import Main from '../../src/components/layout/Main' + +export default function Purchase({ providers }) { + const { t } = useTranslation() + + return ( +
+ +
+

{t('thank-you-for-your-purchase')}

+ {t('safe-to-close-page')} +
+
+ ) +} + +export const getStaticProps: GetStaticProps = async ({ locale }) => { + return { + props: { + ...(await serverSideTranslations(locale, ['common'])), + }, + } +} \ No newline at end of file diff --git a/pages/purchase/index.tsx b/pages/purchase/index.tsx new file mode 100644 index 00000000..5a415d0b --- /dev/null +++ b/pages/purchase/index.tsx @@ -0,0 +1,97 @@ +import { GetStaticProps } from 'next' +import { useTranslation } from 'next-i18next' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import { NextSeo } from 'next-seo' +import { useRouter } from 'next/router' +import { ReactElement, useEffect, useState } from 'react' +import { toast } from 'react-toastify' +import ApplicationCollection from '../../src/components/application/Collection' +import Main from '../../src/components/layout/Main' +import Spinner from '../../src/components/Spinner' +import { generateTokens } from '../../src/context/actions' +import { APP_DETAILS } from '../../src/env' + +const PERMITTED_REDIRECTS = [/^http:\/\/localhost:\d+\/$/, /^http:\/\/127.0.0.1:\d+\/$/]; + +export default function Purchase() { + const { t } = useTranslation(); + const [waiting, setWaiting] = useState(false); + const [token, setToken] = useState(''); + const [missingApps, setMissingApps] = useState([]); + + const router = useRouter(); + + useEffect(() => { + if (!router.isReady) return; + + let redirect = router.query.return.toString(); + if (!PERMITTED_REDIRECTS.some(r => r.test(redirect))) { + toast.error(t('incorrect-redirect')); + return; + } + + let refs = router.query.refs.toString().split(";"); + /* We get refs in the form app///, we just want the app ID part */ + let appIDs = refs.map(ref => ref.split("/")[1]); + + setWaiting(true); + generateTokens(setToken, appIDs) + .then(result => { + if (result.token) { + setToken(result.token); + } else if (result.detail == "not_logged_in") { + router.push('/login'); + } else if (result.detail == "purchase_necessary") { + Promise.all( + result.missing_appids.map(id => fetch(`${APP_DETAILS(id)}`)) + ) + .then(responses => Promise.all(responses.map(res => res.json()))) + .then(apps => setMissingApps(apps.filter(app => app != null))); + } else { + throw 'network-error-try-again'; + } + }) + .catch(err => toast.error(t(err))) + .finally(() => setWaiting(false)); + }, [router]); + + useEffect(() => { + if (token) { + fetch(router.query.return.toString() + "success?token=" + encodeURIComponent(token)) + .then(() => { + router.push('/purchase/finished'); + }) + .catch(() => toast.error(t('app-install-error-try-again'))); + } + }, [token]); + + let content: ReactElement = null; + + if (waiting) + content = ; + else if (missingApps.length) { + content = + ; + } + + return ( +
+ +
+ {content} +
+
+ ) +} + +// Need available login providers to show options on page +export const getStaticProps: GetStaticProps = async ({ locale }) => { + return { + props: { + ...(await serverSideTranslations(locale, ['common'])), + } + } +} diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 8c9f4b4c..b37a5b91 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -121,6 +121,11 @@ "install": "Install", "screenshot": "Screenshot", "donate": "Donate", + "purchase-apps-title": "Purchase Apps", + "thank-you-for-your-purchase": "Thank you for your purchase!", + "app-install-error-try-again": "Could not install the app. Try going back to the app store and installing again.", + "safe-to-close-page": "It is safe to close this page.", + "incorrect-redirect": "Incorrect redirect specified.", "other-apps-by-developer": "Other apps by {{developer}}", "loading": "Loading…", "could-not-find-match-for-search": "Could not find a match for search.", diff --git a/src/context/actions.ts b/src/context/actions.ts index 8832ccb5..0790e6dc 100644 --- a/src/context/actions.ts +++ b/src/context/actions.ts @@ -1,7 +1,7 @@ import { ParsedUrlQuery } from "querystring"; import { Dispatch } from "react"; import { - LOGIN_PROVIDERS_URL, LOGOUT_URL, USER_DELETION_URL, USER_INFO_URL + LOGIN_PROVIDERS_URL, LOGOUT_URL, TOKEN_GENERATION_URL, USER_DELETION_URL, USER_INFO_URL } from "../env"; import { UserStateAction } from "../types/Login"; @@ -152,3 +152,30 @@ export async function deleteAccount( throw 'network-error-try-again' } } + + +/** + * Generates a token to download a set of apps. + * @param token Function to set the token when finished + * @param waiting Function to set the async state of the component + * @param error Function for displaying errors (usually component state) + * @param appids A list of app IDs to generate tokens for + */ + export async function generateTokens( + token: Dispatch, + appids: string[], +) { + let res: Response + try { + res = await fetch(TOKEN_GENERATION_URL, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(appids) + }) + } catch { + throw 'network-error-try-again'; + } + + return await res.json(); +} diff --git a/src/env.ts b/src/env.ts index 0ff3119a..1e3082bf 100644 --- a/src/env.ts +++ b/src/env.ts @@ -38,6 +38,7 @@ export const LOGIN_PROVIDERS_URL: string = `${BASE_URI}/auth/login` export const USER_INFO_URL: string = `${BASE_URI}/auth/userinfo` export const LOGOUT_URL: string = `${BASE_URI}/auth/logout` export const USER_DELETION_URL: string = `${BASE_URI}/auth/deleteuser` +export const TOKEN_GENERATION_URL: string = `${BASE_URI}/generate-download-token` export const IS_PRODUCTION: boolean = process.env.NEXT_PUBLIC_IS_PRODUCTION === 'true' diff --git a/src/types/Purchase.ts b/src/types/Purchase.ts new file mode 100644 index 00000000..d09113de --- /dev/null +++ b/src/types/Purchase.ts @@ -0,0 +1,8 @@ +export interface Transaction { + token?: string; + appids: string[]; +} + +export interface TransactionStateAction { + token: string; +} \ No newline at end of file