From f7903b470789d6aa11ca0af108c778410f1a3451 Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Fri, 19 Nov 2021 17:56:52 -0600 Subject: [PATCH 01/16] prefetch premium avatar listing --- .vscode/settings.json | 2 +- src/react-components/media-browser.js | 56 +++++++++++++++++++++++-- src/react-components/room/MediaTiles.js | 15 ++++++- 3 files changed, 66 insertions(+), 7 deletions(-) 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/src/react-components/media-browser.js b/src/react-components/media-browser.js index 306dfde538..3928e3d35b 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,38 @@ 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 + */ + 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()); + } + }); + }; + getPremiumPage(); + } componentWillUnmount() { this.props.mediaSearchStore.removeEventListener("statechanged", this.storeUpdated); @@ -185,7 +225,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"; @@ -426,7 +470,10 @@ class MediaBrowserContainer extends Component { ); } - + if (this.state.premiumsFetched < PREMIUM_LEVELS) { + // 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 +599,7 @@ class MediaBrowserContainer extends Component { onEdit={onEdit} onShowSimilar={onShowSimilar} onCopy={onCopy} + premium={this.state.premiums[entry.id]} /> ); })} diff --git a/src/react-components/room/MediaTiles.js b/src/react-components/room/MediaTiles.js index 4d88d7edcd..27effad33f 100644 --- a/src/react-components/room/MediaTiles.js +++ b/src/react-components/room/MediaTiles.js @@ -128,7 +128,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 = @@ -280,5 +290,6 @@ MediaTile.propTypes = { onEdit: PropTypes.func, onShowSimilar: PropTypes.func, onCopy: PropTypes.func, - onInfo: PropTypes.func + onInfo: PropTypes.func, + premium: PropTypes.number }; From dfc416771a1be57459c615b19f155db8dc01e156 Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Tue, 23 Nov 2021 21:53:11 -0600 Subject: [PATCH 02/16] always show name & avatar picker --- src/storage/store.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/storage/store.js b/src/storage/store.js index 82001c0fba..f31e7a8b6e 100644 --- a/src/storage/store.js +++ b/src/storage/store.js @@ -227,7 +227,11 @@ export default class Store extends EventTarget { }); this.update({ - activity: {}, + activity: { + // always show name & avatar picker + hasAcceptedProfile: false, + hasChangedName: false + }, settings: {}, credentials: {}, profile: {}, From 3f02b37f0125f4d65582b61fbf0305468b1f31ac Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Tue, 23 Nov 2021 22:02:34 -0600 Subject: [PATCH 03/16] auto open avatar picker --- src/react-components/profile-entry-panel.js | 4 ++++ 1 file changed, 4 insertions(+) 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) { From 5605d183cc9b33da6e8c5e4f36e1051271a461bc Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Tue, 23 Nov 2021 22:28:55 -0600 Subject: [PATCH 04/16] price display and placeholder for payment flow --- src/react-components/media-browser.js | 6 ++++++ src/react-components/room/MediaTiles.js | 3 +++ src/react-components/room/MediaTiles.scss | 12 ++++++++++++ 3 files changed, 21 insertions(+) diff --git a/src/react-components/media-browser.js b/src/react-components/media-browser.js index 3928e3d35b..56f64b0e75 100644 --- a/src/react-components/media-browser.js +++ b/src/react-components/media-browser.js @@ -270,6 +270,12 @@ class MediaBrowserContainer extends Component { handleEntryClicked = (evt, entry) => { evt.preventDefault(); + if (this.state.premiums[entry.id]) { + if (!window.confirm("Pay for avatar?")) { + return; + } + } + if (!entry.lucky_query) { this.selectEntry(entry); } else { diff --git a/src/react-components/room/MediaTiles.js b/src/react-components/room/MediaTiles.js index 27effad33f..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( () => { @@ -212,6 +214,7 @@ export function MediaTile({ {entry.member_count} )} +
{premium &&
${prices[premium]}
}
{entry.type === "avatar" && ( * { margin-bottom: 4px; } From f0a841e12dc80e621652adab9d447b905b4d8132 Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Fri, 26 Nov 2021 10:11:56 -0600 Subject: [PATCH 05/16] add error handling and timeouts to premium fetch to avoid ui hangs --- src/react-components/media-browser.js | 38 ++++++++++++++++----------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/react-components/media-browser.js b/src/react-components/media-browser.js index 56f64b0e75..c6f22a4c8c 100644 --- a/src/react-components/media-browser.js +++ b/src/react-components/media-browser.js @@ -167,6 +167,7 @@ class MediaBrowserContainer extends Component { * 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({ @@ -176,20 +177,27 @@ class MediaBrowserContainer extends Component { 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()); - } - }); + 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(); } @@ -476,7 +484,7 @@ class MediaBrowserContainer extends Component { ); } - if (this.state.premiumsFetched < PREMIUM_LEVELS) { + 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; } From d896839645fde707d5a599066764ff5fc519f644 Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Fri, 26 Nov 2021 10:12:41 -0600 Subject: [PATCH 06/16] change price button styles - theme variables not working, using literal values --- src/react-components/room/MediaTiles.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/react-components/room/MediaTiles.scss b/src/react-components/room/MediaTiles.scss index 4ad7950cfd..4135fee8a7 100644 --- a/src/react-components/room/MediaTiles.scss +++ b/src/react-components/room/MediaTiles.scss @@ -71,7 +71,9 @@ flex-direction: column; pointer-events: none; div { - background: theme.$accent3-color; + background: #27235e; + color: white; + font-weight: bold; } } From a1225d96138965a32e70ab47c17a9e425edbc783 Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Fri, 26 Nov 2021 12:31:55 -0600 Subject: [PATCH 07/16] add microtransactions API route env inputs --- .defaults.env | 6 ++++++ src/utils/configs.js | 4 +++- webpack.config.js | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.defaults.env b/.defaults.env index debda9f08b..9d94b373ef 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/src/utils/configs.js b/src/utils/configs.js index 5c7342c229..9f62a2d4d7 100644 --- a/src/utils/configs.js +++ b/src/utils/configs.js @@ -16,7 +16,9 @@ let isAdmin = false; "SENTRY_DSN", "GA_TRACKING_ID", "SHORTLINK_DOMAIN", - "BASE_ASSETS_PATH" + "BASE_ASSETS_PATH", + "STRIPE_CHECKOUT_SESSION_URL", + "STRIPE_VERIFY_RECEIPT_URL" ].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 0893c868b2..d44e7bc12a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -632,6 +632,8 @@ module.exports = async (env, argv) => { SENTRY_DSN: process.env.SENTRY_DSN, GA_TRACKING_ID: process.env.GA_TRACKING_ID, POSTGREST_SERVER: process.env.POSTGREST_SERVER, + STRIPE_CHECKOUT_SESSION_URL: process.env.STRIPE_CHECKOUT_SESSION_URL, + STRIPE_VERIFY_RECEIPT_URL: process.env.STRIPE_VERIFY_RECEIPT_URL, APP_CONFIG: appConfig }) }) From a05750eb4b2ec37e023730d598a3c4859f706f06 Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Fri, 26 Nov 2021 12:32:19 -0600 Subject: [PATCH 08/16] basic checkout popup flow --- src/index.js | 14 ++++ src/react-components/media-browser.js | 94 ++++++++++++++++++++++++++- src/systems/exit-on-blur.js | 3 +- 3 files changed, 108 insertions(+), 3 deletions(-) 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 c6f22a4c8c..2ddfa95b96 100644 --- a/src/react-components/media-browser.js +++ b/src/react-components/media-browser.js @@ -275,13 +275,103 @@ class MediaBrowserContainer extends Component { this.setState({ query }); }; - handleEntryClicked = (evt, entry) => { + handleEntryClicked = async (evt, entry) => { evt.preventDefault(); if (this.state.premiums[entry.id]) { - if (!window.confirm("Pay for avatar?")) { + let checkoutSessionURL; + try { + const checkoutSessionResult = await window + .fetch(configs.STRIPE_CHECKOUT_SESSION_URL, { + method: "POST", + body: JSON.stringify({ + id: entry.id, + name: entry.name, + premium: this.state.premiums[entry.id] + }), + headers: { + "Content-Type": "application/json" + } + }) + .then(res => res.json()); + if (!checkoutSessionResult.url) { + throw new Error("No checkout redirect url returned"); + } + checkoutSessionURL = checkoutSessionResult.url; + } catch (err) { + console.error(`Error setuping up checkout session ${err.message}`); + window.alert("Unable to process transaction. Please try again"); + return; + } + // center the popup + 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(checkoutSessionURL, "stripeCheckoutPopup", features); + if (!popup) { + alert("Could not open login window. Please check if popup was blocked and allow it"); + return; + } else { + // 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?.type !== "stripeCheckoutPopup") { + return; + } + if (data.error) { + console.log(`Payment aborted`); + return resolve(false); + } + window.removeEventListener("message", handler); + // have to close the popup in this thread because, in chrome, having the popup close itself crashes the browser + popup?.close(); + resolve(data.receiptId); + }; + window.addEventListener("message", handler); + }); + if (!receiptId) { + // payment was cancelled before completion + console.log("Avatar payment not completed"); + return; + } + // TODO save receipt to store + let purchasedEntryId; + try { + const receiptVerifyResult = await window + .fetch(configs.STRIPE_CHECKOUT_SESSION_URL, { + method: "POST", + body: JSON.stringify({ + receiptId + }), + headers: { + "Content-Type": "application/json" + } + }) + .then(res => res.json()); + if (!receiptVerifyResult.entryId) { + throw new Error("Invalid receipt"); + } + purchasedEntryId = receiptVerifyResult.entryId; + if (!purchasedEntryId === entry.id) { + throw new Error("Receipt does not match selected avatar"); + } + } catch (err) { + console.error(`Receipt verification failed: ${err.message}`); + window.alert("Unable to process transaction. Please try again"); return; } + // TODO: mixpanel event + return this.selectEntry(entry); } if (!entry.lucky_query) { 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); } From fc481d92187400f523b025b7d85610f670a140b3 Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Fri, 26 Nov 2021 13:17:48 -0600 Subject: [PATCH 09/16] save receipts and check if already purchased avatar when selecting --- src/react-components/media-browser.js | 54 +++++++++++++++------------ src/storage/store.js | 3 +- 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/react-components/media-browser.js b/src/react-components/media-browser.js index 2ddfa95b96..f3b2409926 100644 --- a/src/react-components/media-browser.js +++ b/src/react-components/media-browser.js @@ -279,6 +279,11 @@ class MediaBrowserContainer extends Component { evt.preventDefault(); if (this.state.premiums[entry.id]) { + // check if already purchased + const purchasedAvatars = this.props.store.credentials.purchasedAvatars || {}; + if (purchasedAvatars[entry.id] && (await this.verifyReceipt(purchasedAvatars[entry.id], entry.id))) { + return this.selectEntry(entry); + } let checkoutSessionURL; try { const checkoutSessionResult = await window @@ -344,29 +349,9 @@ class MediaBrowserContainer extends Component { console.log("Avatar payment not completed"); return; } - // TODO save receipt to store - let purchasedEntryId; - try { - const receiptVerifyResult = await window - .fetch(configs.STRIPE_CHECKOUT_SESSION_URL, { - method: "POST", - body: JSON.stringify({ - receiptId - }), - headers: { - "Content-Type": "application/json" - } - }) - .then(res => res.json()); - if (!receiptVerifyResult.entryId) { - throw new Error("Invalid receipt"); - } - purchasedEntryId = receiptVerifyResult.entryId; - if (!purchasedEntryId === entry.id) { - throw new Error("Receipt does not match selected avatar"); - } - } catch (err) { - console.error(`Receipt verification failed: ${err.message}`); + 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; } @@ -386,6 +371,29 @@ class MediaBrowserContainer extends Component { } }; + verifyReceipt = async (receiptId, entryId) => { + try { + const receiptVerifyResult = await window + .fetch(configs.STRIPE_CHECKOUT_SESSION_URL, { + method: "POST", + body: JSON.stringify({ + receiptId + }), + headers: { + "Content-Type": "application/json" + } + }) + .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 } }); }; diff --git a/src/storage/store.js b/src/storage/store.js index f31e7a8b6e..2af67afeb9 100644 --- a/src/storage/store.js +++ b/src/storage/store.js @@ -57,7 +57,8 @@ export const SCHEMA = { additionalProperties: false, properties: { token: { type: ["null", "string"] }, - email: { type: ["null", "string"] } + email: { type: ["null", "string"] }, + purchasedAvatars: { type: ["null", "object"] } } }, From 1f948b7f8a92da3a147b9aeba0f4967380c098a8 Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Fri, 26 Nov 2021 13:36:45 -0600 Subject: [PATCH 10/16] Add mixpanel tracking to completed purchase --- src/react-components/media-browser.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/react-components/media-browser.js b/src/react-components/media-browser.js index f3b2409926..19c21c8da0 100644 --- a/src/react-components/media-browser.js +++ b/src/react-components/media-browser.js @@ -355,7 +355,13 @@ class MediaBrowserContainer extends Component { window.alert("Unable to process transaction. Please try again"); return; } - // TODO: mixpanel event + if (window.mixpanel) { + window.mixpanel.track("AvatarPurchase", { + id: entry.id, + name: entry.name, + premium: this.state.premiums[entry.id] + }); + } return this.selectEntry(entry); } From 63009093e88bea60142efa778e567f70d8f454ec Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Fri, 26 Nov 2021 13:37:54 -0600 Subject: [PATCH 11/16] mixpanel tracking of avatar purchases --- src/react-components/media-browser.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/react-components/media-browser.js b/src/react-components/media-browser.js index 19c21c8da0..8792fc10b9 100644 --- a/src/react-components/media-browser.js +++ b/src/react-components/media-browser.js @@ -308,6 +308,13 @@ class MediaBrowserContainer extends Component { 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] + }); + } // center the popup const width = 730; const height = 785; @@ -356,7 +363,7 @@ class MediaBrowserContainer extends Component { return; } if (window.mixpanel) { - window.mixpanel.track("AvatarPurchase", { + window.mixpanel.track("AvatarPurchaseComplete", { id: entry.id, name: entry.name, premium: this.state.premiums[entry.id] From 3afc5e970ffe803b43d8c3bf5e0d7df9dcb915fc Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Sun, 28 Nov 2021 10:14:09 -0600 Subject: [PATCH 12/16] fix store access, factor out optional chains as they aren't covered by hubs standard babel config --- src/react-components/media-browser.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/react-components/media-browser.js b/src/react-components/media-browser.js index 8792fc10b9..4913b41f55 100644 --- a/src/react-components/media-browser.js +++ b/src/react-components/media-browser.js @@ -280,7 +280,7 @@ class MediaBrowserContainer extends Component { if (this.state.premiums[entry.id]) { // check if already purchased - const purchasedAvatars = this.props.store.credentials.purchasedAvatars || {}; + const purchasedAvatars = this.props.store.state.credentials.purchasedAvatars || {}; if (purchasedAvatars[entry.id] && (await this.verifyReceipt(purchasedAvatars[entry.id], entry.id))) { return this.selectEntry(entry); } @@ -337,16 +337,18 @@ class MediaBrowserContainer extends Component { } const receiptId = await new Promise(resolve => { const handler = ({ data }) => { - if (data?.type !== "stripeCheckoutPopup") { + 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); } - window.removeEventListener("message", handler); - // have to close the popup in this thread because, in chrome, having the popup close itself crashes the browser - popup?.close(); resolve(data.receiptId); }; window.addEventListener("message", handler); From 6e2456ce469a8366c5cd396b07e3b81c6421b63f Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Sun, 28 Nov 2021 23:07:39 -0600 Subject: [PATCH 13/16] add step to api calls so one lambda method can do both --- src/react-components/media-browser.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/react-components/media-browser.js b/src/react-components/media-browser.js index 4913b41f55..48f0e20831 100644 --- a/src/react-components/media-browser.js +++ b/src/react-components/media-browser.js @@ -290,6 +290,7 @@ class MediaBrowserContainer extends Component { .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] @@ -389,9 +390,10 @@ class MediaBrowserContainer extends Component { verifyReceipt = async (receiptId, entryId) => { try { const receiptVerifyResult = await window - .fetch(configs.STRIPE_CHECKOUT_SESSION_URL, { + .fetch(configs.STRIPE_VERIFY_RECEIPT_URL, { method: "POST", body: JSON.stringify({ + step: "receipt", receiptId }), headers: { From 78e79ce52f68f56c90026b50658f1645b8f698cf Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Mon, 29 Nov 2021 22:09:49 -0600 Subject: [PATCH 14/16] open popup before awaits so that firefox will not block it --- src/react-components/media-browser.js | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/react-components/media-browser.js b/src/react-components/media-browser.js index 48f0e20831..0e96f068db 100644 --- a/src/react-components/media-browser.js +++ b/src/react-components/media-browser.js @@ -279,9 +279,19 @@ class MediaBrowserContainer extends Component { 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; @@ -316,17 +326,12 @@ class MediaBrowserContainer extends Component { premium: this.state.premiums[entry.id] }); } - // center the popup - 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(checkoutSessionURL, "stripeCheckoutPopup", features); + // checkoutSessionURL if (!popup) { - alert("Could not open login window. Please check if popup was blocked and allow it"); + 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(() => { From 500119e885e987594706ea0090f027b677318349 Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Mon, 29 Nov 2021 22:38:10 -0600 Subject: [PATCH 15/16] send API key --- src/react-components/media-browser.js | 9 +++++++-- src/utils/configs.js | 3 ++- webpack.config.js | 1 + 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/react-components/media-browser.js b/src/react-components/media-browser.js index 0e96f068db..4bc56af80e 100644 --- a/src/react-components/media-browser.js +++ b/src/react-components/media-browser.js @@ -306,7 +306,8 @@ class MediaBrowserContainer extends Component { premium: this.state.premiums[entry.id] }), headers: { - "Content-Type": "application/json" + "Content-Type": "application/json", + "X-Api-Key": configs.PAYMENTS_API_KEY } }) .then(res => res.json()); @@ -315,6 +316,9 @@ class MediaBrowserContainer extends Component { } 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; @@ -402,7 +406,8 @@ class MediaBrowserContainer extends Component { receiptId }), headers: { - "Content-Type": "application/json" + "Content-Type": "application/json", + "X-Api-Key": configs.PAYMENTS_API_KEY } }) .then(res => res.json()); diff --git a/src/utils/configs.js b/src/utils/configs.js index 9f62a2d4d7..b8ad44baf1 100644 --- a/src/utils/configs.js +++ b/src/utils/configs.js @@ -18,7 +18,8 @@ let isAdmin = false; "SHORTLINK_DOMAIN", "BASE_ASSETS_PATH", "STRIPE_CHECKOUT_SESSION_URL", - "STRIPE_VERIFY_RECEIPT_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 d44e7bc12a..71df4d89e0 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -634,6 +634,7 @@ module.exports = async (env, argv) => { POSTGREST_SERVER: process.env.POSTGREST_SERVER, 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 }) }) From d1d258331377f339056aa5cb3efd66a779b1582e Mon Sep 17 00:00:00 2001 From: Will Murphy Date: Mon, 29 Nov 2021 22:46:03 -0600 Subject: [PATCH 16/16] docs --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) 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)