Skip to content

Commit

Permalink
payments onboard and extension purchasing flow completed
Browse files Browse the repository at this point in the history
  • Loading branch information
roncodes committed Jul 16, 2024
1 parent b8b082a commit 8414f17
Show file tree
Hide file tree
Showing 50 changed files with 1,189 additions and 43 deletions.
10 changes: 10 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,18 @@

# misc
/coverage/
/scripts/
!.*
.*/

# ember-try
/.node_modules.ember-try/
/bower.json.ember-try
/npm-shrinkwrap.json.ember-try
/package.json.ember-try
/package-lock.json.ember-try
/yarn.lock.ember-try

#server
/server
/server_vendor
15 changes: 14 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

# dependencies
/node_modules/
/scripts/node_modules/

# misc
/.env*
Expand All @@ -13,7 +14,6 @@
/npm-debug.log*
/testem.log
/yarn-error.log
/.php-cs-fixer.cache

# ember-try
/.node_modules.ember-try/
Expand All @@ -24,3 +24,16 @@

# broccoli-debug
/DEBUG/

# server
.idea/*
.idea/codeStyleSettings.xml
composer.lock
/server_vendor
/server/vendor/
.phpunit.result.cache
.php_cs.cache
.php-cs-fixer.cache
*.swp
*.swo
.DS_Store
4 changes: 4 additions & 0 deletions .stylelintignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@

# addons
/.node_modules.ember-try/

# server
/server/
/server_vendor/
8 changes: 7 additions & 1 deletion addon/components/extension-card.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
<button type="button" class="rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm w-40 hover:opacity-50 {{@buttonClass}}" ...attributes {{on "click" this.onClick}}>
<button
type="button"
class="rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm w-40 hover:opacity-50 {{@buttonClass}}"
...attributes
{{on "click" this.onClick}}
{{did-update this.onExtensionUpdated @extension}}
>
<div class="flex items-center justify-center rounded-t-lg w-full h-36 {{@iconWrapperClass}}" {{background-url @extension.icon_url overlay=true}}>
<Image src={{@extension.icon_url}} class="w-full h-36 rounded-t-lg {{@iconClass}}" alt={{@extension.name}} @fallbackSrc={{config "defaultValues.extensionIcon"}} />
</div>
Expand Down
157 changes: 154 additions & 3 deletions addon/components/extension-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,60 @@ import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { later } from '@ember/runloop';
import formatCurrency from '@fleetbase/ember-ui/utils/format-currency';
import isModel from '@fleetbase/ember-core/utils/is-model';

function removeParamFromCurrentUrl(paramToRemove) {
const url = new URL(window.location.href);
url.searchParams.delete(paramToRemove);
window.history.pushState({ path: url.href }, '', url.href);
}

function addParamToCurrentUrl(paramName, paramValue) {
const url = new URL(window.location.href);
url.searchParams.set(paramName, paramValue);
window.history.pushState({ path: url.href }, '', url.href);
}

export default class ExtensionCardComponent extends Component {
@service modalsManager;
@service notifications;
@service currentUser;
@service socket;
@service fetch;
@service stripe;
@service urlSearchParams;
@tracked extension;

constructor(owner, { extension }) {
super(...arguments);
this.extension = extension;
this.checkForCheckoutSession();
}

@action onClick() {
@action onExtenstionUpdated(el, [extension]) {
this.extension = extension;
}

@action onClick(options = {}) {
const installChannel = `install.${this.currentUser.companyId}.${this.extension.id}`;
const isAlreadyPurchased = this.extension.is_purchased === true;
const isAlreadyInstalled = this.extension.is_installed === true;
const isPaymentRequired = this.extension.payment_required === true && isAlreadyPurchased === false;

if (typeof this.args.onClick === 'function') {
this.args.onClick(this.extension);
}

addParamToCurrentUrl('extension_id', this.extension.id);
this.modalsManager.show('modals/extension-details', {
titleComponent: 'extension-modal-title',
modalClass: 'flb--extension-modal modal-lg',
modalHeaderClass: 'flb--extension-modal-header',
acceptButtonText: 'Install',
acceptButtonIcon: 'download',
acceptButtonText: isPaymentRequired ? `Purchase for ${formatCurrency(this.extension.price, this.extension.currency)}` : isAlreadyInstalled ? 'Installed' : 'Install',
acceptButtonIcon: isPaymentRequired ? 'credit-card' : isAlreadyInstalled ? 'check' : 'download',
acceptButtonDisabled: isAlreadyInstalled,
acceptButtonScheme: isPaymentRequired ? 'success' : 'primary',
declineButtonText: 'Done',
process: null,
step: null,
Expand All @@ -38,6 +66,11 @@ export default class ExtensionCardComponent extends Component {
confirm: async (modal) => {
modal.startLoading();

// Handle purchase flow
if (isPaymentRequired) {
return this.startCheckoutSession();
}

// Listen for install progress
this.socket.listen(installChannel, ({ process, step, progress }) => {
let stepDescription;
Expand Down Expand Up @@ -74,11 +107,129 @@ export default class ExtensionCardComponent extends Component {
},
1200
);
removeParamFromCurrentUrl('extension_id');
modal.done();
} catch (error) {
this.notifications.serverError(error);
}
},
decline: (modal) => {
modal.done();
removeParamFromCurrentUrl('extension_id');
},
...options,
});
}

async startCheckoutSession() {
const checkout = await this.stripe.initEmbeddedCheckout({
fetchClientSecret: this.fetchClientSecret.bind(this),
});

await this.modalsManager.done();
later(
this,
() => {
this.modalsManager.show('modals/extension-purchase-form', {
title: `Purchase the '${this.extension.name}' Extension`,
modalClass: 'stripe-extension-purchase',
modalFooterClass: 'hidden-i',
extension: this.extension,
checkoutElementInserted: (el) => {
checkout.mount(el);
},
decline: async (modal) => {
checkout.destroy();
await modal.done();
later(
this,
() => {
this.onClick();
},
100
);
},
});
},
100
);
}

async fetchClientSecret() {
try {
const { clientSecret } = await this.fetch.post(
'payments/create-checkout-session',
{ extension: this.extension.id, uri: window.location.pathname },
{ namespace: '~registry/v1' }
);

return clientSecret;
} catch (error) {
this.notifications.serverError(error);
}
}

async checkForCheckoutSession() {
later(
this,
async () => {
const checkoutSessionId = this.urlSearchParams.get('checkout_session_id');
const extensionId = this.urlSearchParams.get('extension_id');

if (!checkoutSessionId && this.extension.id === extensionId) {
return this.onClick();
}

if (checkoutSessionId && this.extension.id === extensionId) {
this.modalsManager.show('modals/confirm-extension-purchase', {
title: 'Finalizing Purchase',
modalClass: 'finalize-extension-purchase',
loadingMessage: 'Completing purchase do not refresh or exit window...',
modalFooterClass: 'hidden-i',
backdropClose: false,
});

try {
const { status, extension } = await this.fetch.post(
'payments/get-checkout-session',
{ checkout_session_id: checkoutSessionId, extension: this.extension.id },
{ namespace: '~registry/v1' }
);

// Update this extension
const extensionModel = this.fetch.jsonToModel(extension, 'registry-extension');
if (isModel(extensionModel)) {
this.extension = extensionModel;
}

// Fire a callback
if (typeof this.args.onCheckoutCompleted === 'function') {
this.args.onCheckoutCompleted(this.extension, status);
}

if (status === 'complete' || status === 'purchase_complete') {
// remove checkout session id
removeParamFromCurrentUrl('checkout_session_id');

// close confirmation dialog and notify payment completed
await this.modalsManager.done();
if (status === 'complete') {
this.notifications.success('Payment Completed.');
}
later(
this,
() => {
this.onClick();
},
100
);
}
} catch (error) {
this.notifications.serverError(error);
}
}
},
300
);
}
}
5 changes: 5 additions & 0 deletions addon/components/modals/confirm-extension-purchase.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<Modal::Default @modalIsOpened={{@modalIsOpened}} @options={{@options}} @confirm={{@onConfirm}} @decline={{@onDecline}}>
<div class="pt-4 pb-6 px-4">
<Spinner @loadingMessage={{@options.loadingMessage}} @loadingMessageClass="ml-2 text-black dark:text-white" @wrapperClass="flex flex-row items-center" />
</div>
</Modal::Default>
3 changes: 3 additions & 0 deletions addon/components/modals/confirm-extension-purchase.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Component from '@glimmer/component';

export default class ModalsConfirmExtensionPurchaseComponent extends Component {}
5 changes: 5 additions & 0 deletions addon/components/modals/extension-purchase-form.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<Modal::Default @modalIsOpened={{@modalIsOpened}} @options={{@options}} @confirm={{@onConfirm}} @decline={{@onDecline}}>
<div class="p-1">
<div id="checkout" {{did-insert @options.checkoutElementInserted}}></div>
</div>
</Modal::Default>
3 changes: 3 additions & 0 deletions addon/components/modals/extension-purchase-form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Component from '@glimmer/component';

export default class ModalsExtensionPurchaseFormComponent extends Component {}
4 changes: 3 additions & 1 deletion addon/controllers/developers/extensions/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ export default class DevelopersExtensionsEditController extends Controller {
@service notifications;
@service intl;
@tracked isReady = false;
@tracked isReadyMessage = null;

@task *save() {
try {
yield this.model.save();
this.notifications.success('Extension details saved.');
const isReady = this.validateExtensionForReview();
if (isReady === true) {
this.isReady = isReady;
} else if (isArray(isReady) && isReady.length) {
this.notifications.warning(isReady[0]);
this.isReadyMessage = isReady[0];
}
} catch (error) {
this.notifications.warning(error.message);
Expand Down
6 changes: 6 additions & 0 deletions addon/controllers/developers/payments/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';

export default class DevelopersPaymentsIndexController extends Controller {
@tracked hasStripeConnectAccount = true;
}
67 changes: 67 additions & 0 deletions addon/controllers/developers/payments/onboard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
import { loadConnectAndInitialize } from '@stripe/connect-js';
import config from '../config/environment';

export default class DevelopersPaymentsOnboardController extends Controller {
@service fetch;
@service notifications;
@tracked connectedAccountId;
@tracked onboardInProgress = false;
@tracked onboardCompleted = false;

@task *startOnboard() {
try {
const { account } = yield this.fetch.post('payments/account', {}, { namespace: '~registry/v1' });

this.connectedAccountId = account;
this.onboardInProgress = true;

const instance = loadConnectAndInitialize({
publishableKey: config.stripe.publishableKey,
fetchClientSecret: this.fetchClientSecret.bind(this),
appearance: {
overlays: 'dialog',
variables: {
colorPrimary: '#635BFF',
},
},
});

const container = this.getTrackedElement('embeddedOnboardingContainer');
const embeddedOnboardingComponent = instance.create('account-onboarding');
embeddedOnboardingComponent.setOnExit(() => {
this.onboardInProgress = false;
this.onboardCompleted = true;
});
container.appendChild(embeddedOnboardingComponent);
} catch (error) {
this.notifications.serverError(error);
}
}

async fetchClientSecret() {
try {
const { clientSecret } = await this.fetch.post('payments/account-session', { account: this.connectedAccountId }, { namespace: '~registry/v1' });

return clientSecret;
} catch (error) {
this.notifications.serverError(error);
}
}

@action createTrackedElement(name, el) {
this[name] = el;
}

getTrackedElement(name) {
if (this[name] instanceof HTMLElement) {
return this[name];
}

return null;
}
}
Loading

0 comments on commit 8414f17

Please sign in to comment.