Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(EmbedContributionFlow): add channel messaging #10880

Merged
merged 1 commit into from
Dec 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions components/contribution-flow/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ class ContributionFlow extends React.Component {
LoggedInUser: PropTypes.object,
createCollective: PropTypes.func.isRequired, // from mutation
router: PropTypes.object,
onStepChange: PropTypes.func,
onSuccess: PropTypes.func,
};

constructor(props) {
Expand Down Expand Up @@ -444,6 +446,7 @@ class ContributionFlow extends React.Component {
this.setState({ isSubmitted: true, isSubmitting: false });
this.props.refetchLoggedInUser(); // to update memberships
const queryParams = this.getQueryParams();
this.props.onSuccess?.(order);
if (isValidExternalRedirect(queryParams.redirect)) {
followOrderRedirectUrl(this.props.router, this.props.collective, order, queryParams.redirect, {
shouldRedirectParent: queryParams.shouldRedirectParent,
Expand Down Expand Up @@ -658,6 +661,7 @@ class ContributionFlow extends React.Component {

if (!this.state.error) {
await this.pushStepRoute(step.name);
this.props.onStepChange?.(step.name);
}
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { graphql } from '@apollo/client/react/hoc';
import { get } from 'lodash';
import { get, omit, pick } from 'lodash';
import { withRouter } from 'next/router';
import { injectIntl } from 'react-intl';

import { GQLV2_SUPPORTED_PAYMENT_METHOD_TYPES } from '../../lib/constants/payment-methods';
import { generateNotFoundError, getErrorFromGraphqlException } from '../../lib/errors';
import { API_V2_CONTEXT } from '../../lib/graphql/helpers';
import { PaymentMethodLegacyType } from '../../lib/graphql/types/v2/schema';
import { addParentToURLIfMissing } from '../../lib/url-helpers';

import CollectiveThemeProvider from '../../components/CollectiveThemeProvider';
Expand All @@ -24,7 +23,17 @@
import { withStripeLoader } from '../../components/StripeProvider';
import { withUser } from '../../components/UserProvider';

class EmbedContributionFlowPage extends React.Component {
class EmbedContributionFlowPage extends React.Component<{
router: any;
collectiveSlug: string;
tierId: number;
error: string;
queryParams: Record<string, unknown>;
loadStripe: () => void;
intl: any;
LoggedInUser: any;
data: Record<string, any>;
}> {
static getInitialProps({ query, res }) {
if (res) {
res.removeHeader('X-Frame-Options');
Expand All @@ -40,34 +49,15 @@
};
}

static propTypes = {
collectiveSlug: PropTypes.string.isRequired,
tierId: PropTypes.number,
error: PropTypes.string,
data: PropTypes.shape({
loading: PropTypes.bool,
error: PropTypes.any,
account: PropTypes.object,
tier: PropTypes.object,
}), // from withData
intl: PropTypes.object,
loadStripe: PropTypes.func,
LoggedInUser: PropTypes.object,
loadingLoggedInUser: PropTypes.bool,
router: PropTypes.object,
queryParams: PropTypes.shape({
useTheme: PropTypes.bool,
backgroundColor: PropTypes.string,
}),
};

componentDidMount() {
this.loadExternalScripts();
const { router, data } = this.props;
const account = data?.account;
const path = router.asPath;
const rawPath = path.replace(new RegExp(`^/embed/${account?.slug}/`), '/');
addParentToURLIfMissing(router, account, rawPath, undefined, { prefix: '/embed' });
this.postMessage('initialized');
window.addEventListener('resize', this.onResize);
}

componentDidUpdate(prevProps) {
Expand All @@ -77,9 +67,34 @@
}
}

componentWillUnmount(): void {
window.removeEventListener('resize', this.onResize);
}

onResize = () => {
this.postMessage('resized');
};

postMessage(event, payload = null) {
if (window.parent) {
try {
const size = { height: document.body.scrollHeight, width: document.body.scrollWidth };
const message = { event, size };
if (payload) {
message['payload'] = payload;
}

window.parent.postMessage(message, '*');
Dismissed Show dismissed Hide dismissed
} catch (e) {
// eslint-disable-next-line no-console
console.error('Post message failed', e);
}
}
}

loadExternalScripts() {
const supportedPaymentMethods = get(this.props.data, 'account.host.supportedPaymentMethods', []);
if (supportedPaymentMethods.includes(GQLV2_SUPPORTED_PAYMENT_METHOD_TYPES.CREDIT_CARD)) {
if (supportedPaymentMethods.includes(PaymentMethodLegacyType.CREDIT_CARD)) {
this.props.loadStripe();
}
}
Expand All @@ -93,10 +108,10 @@
}

renderPageContent() {
const { data = {}, LoggedInUser } = this.props;
const { account, tier } = data;
const { data, LoggedInUser } = this.props;
const { account, tier, loading } = data || {};

if (data.loading) {
if (loading) {
return (
<Container py={[5, 6]}>
<Loading />
Expand All @@ -116,6 +131,28 @@
host={account.host}
tier={tier}
error={this.props.error}
onStepChange={step =>
this.postMessage('stepChange', {
step,
height: document.body.scrollHeight,
width: document.body.scrollWidth,
})
}
onSuccess={order =>
this.postMessage('success', {
order: {
id: order.id,
legacyId: order.legacyId,
status: order.status,
frequency: order.frequency,
amount: omit(order.amount, ['__typename']),
platformTipAmount: omit(order.platformTipAmount, ['__typename']),
tier: order.tier ? pick(order.tier, ['id']) : null,
fromAccount: pick(order.fromAccount, ['id']),
toAccount: pick(order.toAccount, ['id']),
},
})
}
/>
</Box>
);
Expand All @@ -141,7 +178,7 @@
}

const addContributionFlowData = graphql(contributionFlowAccountQuery, {
options: props => ({
options: (props: { collectiveSlug: string; tierId: number }) => ({
variables: { collectiveSlug: props.collectiveSlug, tierId: props.tierId, includeTier: Boolean(props.tierId) },
context: API_V2_CONTEXT,
}),
Expand Down
159 changes: 159 additions & 0 deletions test/embed/contribution-flow.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<!-- A simple page for testing the embed contribution flow. Simply open it in your preferred browser. -->

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenCollective Donation Embed Test</title>
<style>
body {
max-width: 80%;
margin: 20px auto;
padding: 0 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
background-color: #f5f5f5;
}

#contributeIframe {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background-color: white;
}

#messages {
margin-top: 20px;
padding: 15px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

#messages p {
margin: 10px 0;
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
font-family: monospace;
white-space: pre-wrap;
}

.size-controls {
margin-bottom: 20px;
}

.size-controls button {
padding: 8px 16px;
margin-right: 10px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
}

.size-controls button:hover {
background-color: #f0f0f0;
border-color: #bbb;
}

.size-controls button:active {
background-color: #e8e8e8;
transform: translateY(1px);
}

iframe {
resize: both;
}

.messages-title {
margin: 0 0 15px 0;
font-size: 1.2em;
font-weight: bold;
}

.no-messages {
color: #666;
font-style: italic;
}

#messagesList li {
animation: fadeIn 0.2s ease-in;
}

@keyframes fadeIn {
from {
opacity: 0;
transform: translateX(10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
</style>
</head>
<body>
<div class="size-controls">
<button onclick="setSize('mobile')">Mobile (320px)</button>
<button onclick="setSize('tablet')">Tablet (768px)</button>
<button onclick="setSize('desktop')">Desktop (100%)</button>
</div>

<iframe
id="contributeIframe"
src="http://localhost:3000/embed/apex/donate"
width="100%"
height="800px"
frameborder="0"
></iframe>

<div id="messages">
<h2 class="messages-title">Channel messaging</h2>
<p class="no-messages">No messages exchanged yet</p>
<ul id="messagesList"></ul>
</div>

<script>
function addMessage(message) {
const messagesDiv = document.getElementById('messages');
const noMessages = messagesDiv.querySelector('.no-messages');
if (noMessages) {
noMessages.remove();
}

const messagesList = document.getElementById('messagesList');
const messageElement = document.createElement('li');
messageElement.textContent = message;
messagesList.appendChild(messageElement);
}

function setSize(device) {
const iframe = document.getElementById('contributeIframe');
switch (device) {
case 'mobile':
iframe.style.width = '320px';
break;
case 'tablet':
iframe.style.width = '768px';
break;
case 'desktop':
iframe.style.width = '100%';
break;
}
}

window.addEventListener('message', event => {
if (event.origin !== 'http://localhost:3000') return;
addMessage(`Message received: ${JSON.stringify(event.data)}`);
});

function sendMessageToIframe(message) {
const iframe = document.getElementById('contributeIframe');
iframe.contentWindow.postMessage(message, 'http://localhost:3000');
addMessage(`Message sent: ${JSON.stringify(message)}`);
}
</script>
</body>
</html>
Loading