diff --git a/.defaults.env b/.defaults.env index 6d48ac13b7..72f1a0a98b 100644 --- a/.defaults.env +++ b/.defaults.env @@ -29,3 +29,9 @@ DEFAULT_SCENE_SID="JGLt8DP" # Uncomment to load the app config from the reticulum server in development. # Useful when testing the admin panel. # LOAD_APP_CONFIG=true + +# microtransactions API +# API that takes {id, name, premium}, creates a Stripe session, and returns { url } to prebuilt checkout page +STRIPE_CHECKOUT_SESSION_URL= +# API that takes {receiptId} (stripe session id) and returns { entryId } of purchased avatar +STRIPE_VERIFY_RECEIPT_URL= diff --git a/.vscode/settings.json b/.vscode/settings.json index a722e4fd69..3ffacc0594 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { // Format on save for Prettier - "editor.formatOnSave": true, + "editor.formatOnSave": false, // Disable html formatting for now "html.format.enable": false, // Disable the default javascript formatter diff --git a/README.md b/README.md index bc41de9e3d..c9be93b534 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,32 @@ +# Avatar Payments Fork + +This Hubs fork enables setting certain avatars as "premium" and requiring users +to purchase them before use using a popup window checkout flow. + +Required configuration + +These can be added to the `.env` file prior to build/deploy or they can be set +in the Hubs Cloud Admin by adding meta tags to the Extra Room Header HTML advanced setting + + +.env: + +``` +STRIPE_CHECKOUT_SESSION_URL= +STRIPE_VERIFY_RECEIPT_URL= +PAYMENTS_API_KEY= +``` + +header HTML + +```html + + + +``` + +The two API URLS may be the same url, requests are differntiate with `step` value 'checkout' or 'receipt' + # [Mozilla Hubs](https://hubs.mozilla.com/) [![License: MPL 2.0](https://img.shields.io/badge/License-MPL%202.0-brightgreen.svg)](https://opensource.org/licenses/MPL-2.0) [![Build Status](https://travis-ci.org/mozilla/hubs.svg?branch=master)](https://travis-ci.org/mozilla/hubs) [![Discord](https://img.shields.io/discord/498741086295031808)](https://discord.gg/CzAbuGu) diff --git a/src/index.js b/src/index.js index 7d2c6f0d8e..423cfb0fac 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,20 @@ import { AuthContextProvider } from "./react-components/auth/AuthContext"; import "./react-components/styles/global.scss"; import { ThemeProvider } from "./react-components/styles/theme"; +const params = new URLSearchParams(window.location.search); +if (params.has("stripe_session_id") && window.opener) { + // pass receipt ID back to main window for verification + window.opener.postMessage({ + type: "stripeCheckoutPopup", + receiptId: params.get("stripe_session_id") + }); +} else if (params.has("stripe_session_error") && window.opener) { + window.opener.postMessage({ + type: "stripeCheckoutPopup", + error: params.get("stripe_session_error") + }); +} + registerTelemetry("/home", "Hubs Home Page"); const store = new Store(); diff --git a/src/react-components/media-browser.js b/src/react-components/media-browser.js index 306dfde538..4bc56af80e 100644 --- a/src/react-components/media-browser.js +++ b/src/react-components/media-browser.js @@ -129,6 +129,7 @@ const emptyMessages = defineMessages({ } }); +const PREMIUM_LEVELS = 3; // TODO: Migrate to use MediaGrid and media specific components like RoomTile class MediaBrowserContainer extends Component { static propTypes = { @@ -143,7 +144,15 @@ class MediaBrowserContainer extends Component { store: PropTypes.object.isRequired }; - state = { query: "", facets: [], showNav: true, selectNextResult: false, clearStashedQueryOnClose: false }; + state = { + query: "", + facets: [], + showNav: true, + selectNextResult: false, + clearStashedQueryOnClose: false, + premiumsFetched: 0, + premiums: {} + }; constructor(props) { super(props); @@ -152,7 +161,46 @@ class MediaBrowserContainer extends Component { this.props.mediaSearchStore.addEventListener("sourcechanged", this.sourceChanged); } - componentDidMount() {} + componentDidMount() { + /** + * reticulum does not include tags in search results, + * but it does let you query by tag, so we prefetch a list of + * which avatars are premium to compare with avatars shown in the browser + */ + this.premiumFetchStarted = Date.now(); + const getPremiumPage = next => { + const premiumLevel = this.state.premiumsFetched + 1; + const search = new URLSearchParams({ + source: "avatar_listings", + filter: `premium_${premiumLevel}` + }); + if (next) { + search.set("cursor", next); + } + fetchReticulumAuthenticated(`/api/v1/media/search?${search.toString()}`) + .then(results => { + if (results.entries.length) { + const premiums = { ...this.state.premiums }; + results.entries.forEach(entry => { + premiums[entry.id] = premiumLevel; + }); + this.setState({ premiums }); + } + if (results.meta && results.meta.next_cursor) { + getPremiumPage(results.meta.next_cursor); + } else if (this.state.premiumsFetched < PREMIUM_LEVELS) { + this.setState({ premiumsFetched: premiumLevel }, () => getPremiumPage()); + } + }) + .catch(err => { + console.warn(`Error looking up premium avatars: ${err.message}`); + if (this.state.premiumsFetched < PREMIUM_LEVELS) { + this.setState({ premiumsFetched: premiumLevel }, () => getPremiumPage()); + } + }); + }; + getPremiumPage(); + } componentWillUnmount() { this.props.mediaSearchStore.removeEventListener("statechanged", this.storeUpdated); @@ -185,7 +233,11 @@ class MediaBrowserContainer extends Component { const searchParams = new URLSearchParams(props.history.location.search); const result = props.mediaSearchStore.result; - const newState = { result, query: this.state.query || searchParams.get("q") || "" }; + const newState = { + ...this.state, + result, + query: this.state.query || searchParams.get("q") || "" + }; const urlSource = this.getUrlSource(searchParams); newState.showNav = !!(searchParams.get("media_nav") !== "false"); newState.selectAction = searchParams.get("selectAction") || "spawn"; @@ -223,9 +275,115 @@ class MediaBrowserContainer extends Component { this.setState({ query }); }; - handleEntryClicked = (evt, entry) => { + handleEntryClicked = async (evt, entry) => { evt.preventDefault(); + if (this.state.premiums[entry.id]) { + const width = 730; + const height = 785; + const left = (window.innerWidth - width) / 2 + window.screenLeft; + const top = (window.innerHeight - height) / 2 + window.screenTop; + const features = `toolbar=no, menubar=no, width=${width}, height=${height}, top=${top}, left=${left}`; + const popup = window.open("about:blank", "stripeCheckoutPopup", features); + + // check if already purchased + const purchasedAvatars = this.props.store.state.credentials.purchasedAvatars || {}; + if (purchasedAvatars[entry.id] && (await this.verifyReceipt(purchasedAvatars[entry.id], entry.id))) { + if (popup) { + popup.close(); + } + return this.selectEntry(entry); + } + let checkoutSessionURL; + try { + const checkoutSessionResult = await window + .fetch(configs.STRIPE_CHECKOUT_SESSION_URL, { + method: "POST", + body: JSON.stringify({ + step: "checkout", + id: entry.id, + name: entry.name, + premium: this.state.premiums[entry.id] + }), + headers: { + "Content-Type": "application/json", + "X-Api-Key": configs.PAYMENTS_API_KEY + } + }) + .then(res => res.json()); + if (!checkoutSessionResult.url) { + throw new Error("No checkout redirect url returned"); + } + checkoutSessionURL = checkoutSessionResult.url; + } catch (err) { + if (popup) { + popup.close(); + } + console.error(`Error setuping up checkout session ${err.message}`); + window.alert("Unable to process transaction. Please try again"); + return; + } + if (window.mixpanel) { + window.mixpanel.track("AvatarPurchaseStart", { + id: entry.id, + name: entry.name, + premium: this.state.premiums[entry.id] + }); + } + // checkoutSessionURL + if (!popup) { + alert("Could not open checkout window. Please check if popup was blocked and allow it"); + return; + } else { + popup.location = checkoutSessionURL; + // disable disconnect timeouts while popup is open + this.props.scene.addState("payment-authorizing"); + const closedCheckInterval = window.setInterval(() => { + if (popup.closed) { + window.clearInterval(closedCheckInterval); + this.props.scene.removeState("payment-authorizing"); + } + }, 100); + } + const receiptId = await new Promise(resolve => { + const handler = ({ data }) => { + if (data && data.type !== "stripeCheckoutPopup") { + return; + } + window.removeEventListener("message", handler); + // have to close the popup in this thread because, in chrome, having the popup close itself crashes the browser + if (popup) { + popup.close(); + } + if (data.error) { + console.log(`Payment aborted`); + return resolve(false); + } + resolve(data.receiptId); + }; + window.addEventListener("message", handler); + }); + if (!receiptId) { + // payment was cancelled before completion + console.log("Avatar payment not completed"); + return; + } + purchasedAvatars[entry.id] = receiptId; + this.props.store.update({ credentials: { purchasedAvatars } }); + if (!(await this.verifyReceipt(receiptId, entry.id))) { + window.alert("Unable to process transaction. Please try again"); + return; + } + if (window.mixpanel) { + window.mixpanel.track("AvatarPurchaseComplete", { + id: entry.id, + name: entry.name, + premium: this.state.premiums[entry.id] + }); + } + return this.selectEntry(entry); + } + if (!entry.lucky_query) { this.selectEntry(entry); } else { @@ -238,6 +396,31 @@ class MediaBrowserContainer extends Component { } }; + verifyReceipt = async (receiptId, entryId) => { + try { + const receiptVerifyResult = await window + .fetch(configs.STRIPE_VERIFY_RECEIPT_URL, { + method: "POST", + body: JSON.stringify({ + step: "receipt", + receiptId + }), + headers: { + "Content-Type": "application/json", + "X-Api-Key": configs.PAYMENTS_API_KEY + } + }) + .then(res => res.json()); + if (receiptVerifyResult.avatarId !== entryId) { + throw new Error("Invalid receipt"); + } + return entryId; + } catch (err) { + console.error(`Receipt verification failed: ${err.message}`); + return false; + } + }; + onShowSimilar = (id, name) => { this.handleFacetClicked({ params: { similar_to: id, similar_name: name } }); }; @@ -426,7 +609,10 @@ class MediaBrowserContainer extends Component { ); } - + if (this.state.premiumsFetched < PREMIUM_LEVELS && Date.now() - this.premiumFetchStarted < 5000) { + // don't render until premium avatars identified so you can't get one free by being fast + return false; + } return ( (this.browserDiv = r)} @@ -552,6 +738,7 @@ class MediaBrowserContainer extends Component { onEdit={onEdit} onShowSimilar={onShowSimilar} onCopy={onCopy} + premium={this.state.premiums[entry.id]} /> ); })} diff --git a/src/react-components/profile-entry-panel.js b/src/react-components/profile-entry-panel.js index 242009809e..2968d72c54 100644 --- a/src/react-components/profile-entry-panel.js +++ b/src/react-components/profile-entry-panel.js @@ -96,6 +96,10 @@ export default class ProfileEntryPanel extends Component { this.scene.addEventListener("action_avatar_saved", this.refetchAvatar); this.refetchAvatar(); + // show avatar picker immediately in entry flow + if (this.props.containerType !== "sidebar") { + this.props.mediaSearchStore.sourceNavigateWithNoNav("avatars", "use"); + } } componentDidUpdate(_prevProps, prevState) { diff --git a/src/react-components/room/MediaTiles.js b/src/react-components/room/MediaTiles.js index 4d88d7edcd..eba92a1f7f 100644 --- a/src/react-components/room/MediaTiles.js +++ b/src/react-components/room/MediaTiles.js @@ -17,6 +17,8 @@ const PUBLISHER_FOR_ENTRY_TYPE = { twitch_stream: "Twitch" }; +const prices = [0, 1, 3, 5]; + function useThumbnailSize(isImage, isAvatar, imageAspect) { return useMemo( () => { @@ -128,7 +130,17 @@ CreateTile.propTypes = { type: PropTypes.string }; -export function MediaTile({ entry, processThumbnailUrl, onClick, onEdit, onShowSimilar, onCopy, onInfo, ...rest }) { +export function MediaTile({ + entry, + processThumbnailUrl, + onClick, + onEdit, + onShowSimilar, + onCopy, + onInfo, + premium, + ...rest +}) { const intl = useIntl(); const creator = entry.attributions && entry.attributions.creator; const publisherName = @@ -202,6 +214,7 @@ export function MediaTile({ entry, processThumbnailUrl, onClick, onEdit, onShowS {entry.member_count} )} +
{premium &&
${prices[premium]}
}
{entry.type === "avatar" && ( * { margin-bottom: 4px; } diff --git a/src/storage/store.js b/src/storage/store.js index e4f92a4454..1ff0d77b8b 100644 --- a/src/storage/store.js +++ b/src/storage/store.js @@ -56,7 +56,8 @@ export const SCHEMA = { additionalProperties: false, properties: { token: { type: ["null", "string"] }, - email: { type: ["null", "string"] } + email: { type: ["null", "string"] }, + purchasedAvatars: { type: ["null", "object"] } } }, @@ -231,7 +232,11 @@ export default class Store extends EventTarget { }); this.update({ - activity: {}, + activity: { + // always show name & avatar picker + hasAcceptedProfile: false, + hasChangedName: false + }, settings: {}, credentials: {}, profile: {}, diff --git a/src/systems/exit-on-blur.js b/src/systems/exit-on-blur.js index c224055387..a83461ffa0 100644 --- a/src/systems/exit-on-blur.js +++ b/src/systems/exit-on-blur.js @@ -29,6 +29,7 @@ AFRAME.registerSystem("exit-on-blur", { if ( this.isOculusBrowser && this.enteredVR && + !this.el.is("payment-authorizing") && (this.lastTimeoutCheck === 0 || t - this.lastTimeoutCheck >= 1000.0) // Don't do this clear every frame, slow. ) { this.lastTimeoutCheck = t; @@ -42,7 +43,7 @@ AFRAME.registerSystem("exit-on-blur", { }, onBlur() { - if (this.el.isMobile) { + if (this.el.isMobile && !this.el.is("payment-authorizing")) { clearTimeout(this.exitTimeout); this.exitTimeout = setTimeout(this.onTimeout, 30 * 1000); } diff --git a/src/utils/configs.js b/src/utils/configs.js index cec4bf3572..f3363f3f5e 100644 --- a/src/utils/configs.js +++ b/src/utils/configs.js @@ -17,7 +17,10 @@ let isAdmin = false; "GA_TRACKING_ID", "SHORTLINK_DOMAIN", "BASE_ASSETS_PATH", - "UPLOADS_HOST" + "UPLOADS_HOST", + "STRIPE_CHECKOUT_SESSION_URL", + "STRIPE_VERIFY_RECEIPT_URL", + "PAYMENTS_API_KEY" ].forEach(x => { const el = document.querySelector(`meta[name='env:${x.toLowerCase()}']`); configs[x] = el ? el.getAttribute("content") : process.env[x]; diff --git a/webpack.config.js b/webpack.config.js index e77c8a6409..68796535ae 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -646,6 +646,9 @@ module.exports = async (env, argv) => { POSTGREST_SERVER: process.env.POSTGREST_SERVER, UPLOADS_HOST: process.env.UPLOADS_HOST, BASE_ASSETS_PATH: process.env.BASE_ASSETS_PATH, + STRIPE_CHECKOUT_SESSION_URL: process.env.STRIPE_CHECKOUT_SESSION_URL, + STRIPE_VERIFY_RECEIPT_URL: process.env.STRIPE_VERIFY_RECEIPT_URL, + PAYMENTS_API_KEY: process.env.PAYMENTS_API_KEY, APP_CONFIG: appConfig }) })