Added Integrations as a subpage of Settings

- Restructured Admin so that Integrations becomes a subpage of Settings.
This commit is contained in:
Peter Zimon 2021-10-18 14:46:29 +02:00
parent b8d1dc8deb
commit 737db37175
39 changed files with 2323 additions and 5 deletions

View File

@ -5,6 +5,7 @@ import {task} from 'ember-concurrency';
export default ModalComponent.extend({
router: service(),
feature: service(),
notifications: service(),
integration: alias('model'),
actions: {
@ -15,7 +16,11 @@ export default ModalComponent.extend({
deleteIntegration: task(function* () {
try {
yield this.confirm();
this.router.transitionTo('integrations');
if (this.feature.get('offers')) {
this.router.transitionTo('settings.integrations');
} else {
this.router.transitionTo('integrations');
}
} catch (error) {
this.notifications.showAPIError(error, {key: 'integration.delete.failed'});
} finally {

View File

@ -8,6 +8,7 @@ import {task} from 'ember-concurrency';
export default ModalComponent.extend({
router: service(),
feature: service(),
errorMessage: null,
@ -29,7 +30,11 @@ export default ModalComponent.extend({
createIntegration: task(function* () {
try {
let integration = yield this.confirm();
this.router.transitionTo('integration', integration);
if (this.feature.get('offers')) {
this.router.transitionTo('settings.integration', integration);
} else {
this.router.transitionTo('integration', integration);
}
} catch (error) {
// TODO: server-side validation errors should be serialized
// properly so that errors are added to model.errors automatically

View File

@ -8,6 +8,7 @@ import {task} from 'ember-concurrency';
export default ModalComponent.extend({
router: service(),
feature: service(),
availableEvents: null,
error: null,
@ -48,7 +49,11 @@ export default ModalComponent.extend({
try {
let webhook = yield this.confirm();
let integration = yield webhook.get('integration');
this.router.transitionTo('integration', integration);
if (this.feature.get('offers')) {
this.router.transitionTo('settings.integration', integration);
} else {
this.router.transitionTo('integration', integration);
}
} catch (e) {
// TODO: server-side validation errors should be serialized
// properly so that errors are added to model.errors automatically

View File

@ -0,0 +1,186 @@
import Controller from '@ember/controller';
import config from 'ghost-admin/config/environment';
import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard';
import {
IMAGE_EXTENSIONS,
IMAGE_MIME_TYPES
} from 'ghost-admin/components/gh-image-uploader';
import {alias} from '@ember/object/computed';
import {computed} from '@ember/object';
import {htmlSafe} from '@ember/template';
import {inject as service} from '@ember/service';
import {task, timeout} from 'ember-concurrency';
export default Controller.extend({
config: service(),
ghostPaths: service(),
imageExtensions: IMAGE_EXTENSIONS,
imageMimeTypes: IMAGE_MIME_TYPES,
showRegenerateKeyModal: false,
selectedApiKey: null,
isApiKeyRegenerated: false,
init() {
this._super(...arguments);
if (this.isTesting === undefined) {
this.isTesting = config.environment === 'test';
}
},
integration: alias('model'),
apiUrl: computed(function () {
let origin = window.location.origin;
let subdir = this.ghostPaths.subdir;
let url = this.ghostPaths.url.join(origin, subdir);
return url.replace(/\/$/, '');
}),
regeneratedKeyType: computed('isApiKeyRegenerated', 'selectedApiKey', function () {
if (this.isApiKeyRegenerated) {
return this.get('selectedApiKey.type');
}
return null;
}),
allWebhooks: computed(function () {
return this.store.peekAll('webhook');
}),
filteredWebhooks: computed('integration.id', 'allWebhooks.@each.{isNew,isDeleted}', function () {
return this.allWebhooks.filter((webhook) => {
let matchesIntegration = webhook.belongsTo('integration').id() === this.integration.id;
return matchesIntegration
&& !webhook.isNew
&& !webhook.isDeleted;
});
}),
iconImageStyle: computed('integration.iconImage', function () {
let url = this.integration.iconImage;
if (url) {
let styles = [
`background-image: url(${url})`,
'background-size: 50%',
'background-position: 50%',
'background-repeat: no-repeat'
];
return htmlSafe(styles.join('; '));
}
return htmlSafe('');
}),
actions: {
triggerIconFileDialog() {
let input = document.querySelector('input[type="file"][name="iconImage"]');
input.click();
},
setIconImage([image]) {
this.integration.set('iconImage', image.url);
},
save() {
return this.save.perform();
},
toggleUnsavedChangesModal(transition) {
let leaveTransition = this.leaveScreenTransition;
if (!transition && this.showUnsavedChangesModal) {
this.set('leaveScreenTransition', null);
this.set('showUnsavedChangesModal', false);
return;
}
if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
this.set('leaveScreenTransition', transition);
// if a save is running, wait for it to finish then transition
if (this.save.isRunning) {
return this.save.last.then(() => {
transition.retry();
});
}
// we genuinely have unsaved data, show the modal
this.set('showUnsavedChangesModal', true);
}
},
leaveScreen() {
let transition = this.leaveScreenTransition;
if (!transition) {
this.notifications.showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return;
}
// roll back changes on model props
this.integration.rollbackAttributes();
return transition.retry();
},
deleteIntegration() {
this.integration.destroyRecord();
},
confirmIntegrationDeletion() {
this.set('showDeleteIntegrationModal', true);
},
cancelIntegrationDeletion() {
this.set('showDeleteIntegrationModal', false);
},
confirmRegenerateKeyModal(apiKey) {
this.set('showRegenerateKeyModal', true);
this.set('isApiKeyRegenerated', false);
this.set('selectedApiKey', apiKey);
},
cancelRegenerateKeyModal() {
this.set('showRegenerateKeyModal', false);
},
regenerateKey() {
this.set('isApiKeyRegenerated', true);
},
confirmWebhookDeletion(webhook) {
this.set('webhookToDelete', webhook);
},
cancelWebhookDeletion() {
this.set('webhookToDelete', null);
},
deleteWebhook() {
return this.webhookToDelete.destroyRecord();
}
},
save: task(function* () {
return yield this.integration.save();
}),
copyContentKey: task(function* () {
copyTextToClipboard(this.integration.contentKey.secret);
yield timeout(this.isTesting ? 50 : 3000);
}),
copyAdminKey: task(function* () {
copyTextToClipboard(this.integration.adminKey.secret);
yield timeout(this.isTesting ? 50 : 3000);
}),
copyApiUrl: task(function* () {
copyTextToClipboard(this.apiUrl);
yield timeout(this.isTesting ? 50 : 3000);
})
});

View File

@ -0,0 +1,24 @@
import Controller from '@ember/controller';
import {alias} from '@ember/object/computed';
export default Controller.extend({
webhook: alias('model'),
actions: {
save() {
return this.webhook.save();
},
cancel() {
// 'new' route's dectivate hook takes care of rollback
return this.webhook.get('integration').then((integration) => {
this.transitionToRoute('integration', integration);
});
}
},
reset() {
this.webhook.rollbackAttributes();
this.webhook.errors.clear();
}
});

View File

@ -0,0 +1,19 @@
import Controller from '@ember/controller';
import {alias} from '@ember/object/computed';
export default Controller.extend({
webhook: alias('model'),
actions: {
save() {
return this.webhook.save();
},
cancel() {
// 'new' route's dectivate hook takes care of rollback
return this.webhook.get('integration').then((integration) => {
this.transitionToRoute('settings.integration', integration);
});
}
}
});

View File

@ -0,0 +1,57 @@
/* eslint-disable ghost/ember/alias-model-in-controller */
import Controller from '@ember/controller';
import {computed} from '@ember/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default Controller.extend({
settings: service(),
store: service(),
config: service(),
_allIntegrations: null,
init() {
this._super(...arguments);
this._allIntegrations = this.store.peekAll('integration');
},
zapierDisabled: computed('config.hostSettings.limits', function () {
return this.config.get('hostSettings.limits.customIntegrations.disabled');
}),
// filter over the live query so that the list is automatically updated
// as integrations are added/removed
integrations: computed('_allIntegrations.@each.{isNew,type}', function () {
return this._allIntegrations.reject((integration) => {
return integration.isNew || integration.type !== 'custom';
});
}),
// use ember-concurrency so that we can use the derived state to show
// a spinner only in the integrations list and avoid delaying the whole
// screen display
fetchIntegrations: task(function* () {
return yield this.store.findAll('integration');
}),
// used by individual integration routes' `model` hooks
integrationModelHook(prop, value, route, transition) {
let preloadedIntegration = this.store.peekAll('integration').findBy(prop, value);
if (preloadedIntegration) {
return preloadedIntegration;
}
return this.fetchIntegrations.perform().then((integrations) => {
let integration = integrations.findBy(prop, value);
if (!integration) {
let path = transition.intent.url.replace(/^\//, '');
return route.replaceWith('error404', {path, status: 404});
}
return integration;
});
}
});

View File

@ -0,0 +1,70 @@
/* eslint-disable ghost/ember/alias-model-in-controller */
import Controller from '@ember/controller';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default Controller.extend({
notifications: service(),
settings: service(),
leaveSettingsTransition: null,
actions: {
update(value) {
this.settings.set('amp', value);
},
save() {
this.save.perform();
},
toggleLeaveSettingsModal(transition) {
let leaveTransition = this.leaveSettingsTransition;
if (!transition && this.showLeaveSettingsModal) {
this.set('leaveSettingsTransition', null);
this.set('showLeaveSettingsModal', false);
return;
}
if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
this.set('leaveSettingsTransition', transition);
// if a save is running, wait for it to finish then transition
if (this.save.isRunning) {
return this.save.last.then(() => {
transition.retry();
});
}
// we genuinely have unsaved data, show the modal
this.set('showLeaveSettingsModal', true);
}
},
leaveSettings() {
let transition = this.leaveSettingsTransition;
let settings = this.settings;
if (!transition) {
this.notifications.showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return;
}
// roll back changes on settings model
settings.rollbackAttributes();
return transition.retry();
}
},
save: task(function* () {
try {
yield this.settings.validate();
return yield this.settings.save();
} catch (error) {
this.notifications.showAPIError(error);
throw error;
}
}).drop()
});

View File

@ -0,0 +1,70 @@
/* eslint-disable ghost/ember/alias-model-in-controller */
import Controller from '@ember/controller';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default Controller.extend({
notifications: service(),
settings: service(),
leaveSettingsTransition: null,
actions: {
update(value) {
this.settings.set('firstpromoter', value);
},
save() {
this.save.perform();
},
toggleLeaveSettingsModal(transition) {
let leaveTransition = this.leaveSettingsTransition;
if (!transition && this.showLeaveSettingsModal) {
this.set('leaveSettingsTransition', null);
this.set('showLeaveSettingsModal', false);
return;
}
if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
this.set('leaveSettingsTransition', transition);
// if a save is running, wait for it to finish then transition
if (this.save.isRunning) {
return this.save.last.then(() => {
transition.retry();
});
}
// we genuinely have unsaved data, show the modal
this.set('showLeaveSettingsModal', true);
}
},
leaveSettings() {
let transition = this.leaveSettingsTransition;
let settings = this.settings;
if (!transition) {
this.notifications.showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return;
}
// roll back changes on settings model
settings.rollbackAttributes();
return transition.retry();
}
},
save: task(function* () {
try {
yield this.settings.validate();
return yield this.settings.save();
} catch (error) {
this.notifications.showAPIError(error);
throw error;
}
}).drop()
});

View File

@ -0,0 +1,27 @@
import Controller from '@ember/controller';
import {alias} from '@ember/object/computed';
import {computed} from '@ember/object';
export default Controller.extend({
integration: alias('model.integration'),
hostLimitError: alias('model.hostLimitError'),
showUpgradeModal: computed('hostLimitError', function () {
if (this.hostLimitError) {
return true;
}
return false;
}),
actions: {
save() {
return this.integration.save();
},
cancel() {
// 'new' route's dectivate hook takes care of rollback
this.transitionToRoute('settings.integrations');
}
}
});

View File

@ -0,0 +1,133 @@
/* eslint-disable ghost/ember/alias-model-in-controller */
import Controller from '@ember/controller';
import boundOneWay from 'ghost-admin/utils/bound-one-way';
import {empty} from '@ember/object/computed';
import {isInvalidError} from 'ember-ajax/errors';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default Controller.extend({
ghostPaths: service(),
ajax: service(),
notifications: service(),
settings: service(),
leaveSettingsTransition: null,
slackArray: null,
init() {
this._super(...arguments);
this.slackArray = [];
},
slackSettings: boundOneWay('settings.slack.firstObject'),
testNotificationDisabled: empty('slackSettings.url'),
actions: {
save() {
this.save.perform();
},
updateURL(value) {
value = typeof value === 'string' ? value.trim() : value;
this.set('slackSettings.url', value);
this.get('slackSettings.errors').clear();
},
updateUsername(value) {
value = typeof value === 'string' ? value.trimLeft() : value;
this.set('slackSettings.username', value);
this.get('slackSettings.errors').clear();
},
triggerDirtyState() {
let slack = this.slackSettings;
let slackArray = this.slackArray;
let settings = this.settings;
// Hack to trigger the `isDirty` state on the settings model by setting a new Array
// for slack rather that replacing the existing one which would still point to the
// same reference and therfore not setting the model into a dirty state
slackArray.clear().pushObject(slack);
settings.set('slack', slackArray);
},
toggleLeaveSettingsModal(transition) {
let leaveTransition = this.leaveSettingsTransition;
if (!transition && this.showLeaveSettingsModal) {
this.set('leaveSettingsTransition', null);
this.set('showLeaveSettingsModal', false);
return;
}
if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
this.set('leaveSettingsTransition', transition);
// if a save is running, wait for it to finish then transition
if (this.save.isRunning) {
return this.save.last.then(() => {
transition.retry();
});
}
// we genuinely have unsaved data, show the modal
this.set('showLeaveSettingsModal', true);
}
},
leaveSettings() {
let transition = this.leaveSettingsTransition;
let settings = this.settings;
let slackArray = this.slackArray;
if (!transition) {
this.notifications.showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return;
}
// roll back changes on model props
settings.rollbackAttributes();
slackArray.clear();
return transition.retry();
}
},
save: task(function* () {
let slack = this.slackSettings;
let settings = this.settings;
let slackArray = this.slackArray;
try {
yield slack.validate();
// clear existing objects in slackArray to make sure we only push the validated one
slackArray.clear().pushObject(slack);
yield settings.set('slack', slackArray);
return yield settings.save();
} catch (error) {
if (error) {
this.notifications.showAPIError(error);
throw error;
}
}
}).drop(),
sendTestNotification: task(function* () {
let notifications = this.notifications;
let slackApi = this.get('ghostPaths.url').api('slack', 'test');
try {
yield this.save.perform();
yield this.ajax.post(slackApi);
notifications.showNotification('Test notification sent', {type: 'info', key: 'slack-test.send.success', description: 'Check your Slack channel for the test message'});
return true;
} catch (error) {
notifications.showAPIError(error, {key: 'slack-test:send'});
if (!isInvalidError(error)) {
throw error;
}
}
}).drop()
});

View File

@ -0,0 +1,70 @@
/* eslint-disable ghost/ember/alias-model-in-controller */
import Controller from '@ember/controller';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default Controller.extend({
notifications: service(),
settings: service(),
leaveSettingsTransition: null,
actions: {
update(value) {
this.settings.set('unsplash', value);
},
save() {
this.save.perform();
},
toggleLeaveSettingsModal(transition) {
let leaveTransition = this.leaveSettingsTransition;
if (!transition && this.showLeaveSettingsModal) {
this.set('leaveSettingsTransition', null);
this.set('showLeaveSettingsModal', false);
return;
}
if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
this.set('leaveSettingsTransition', transition);
// if a save is running, wait for it to finish then transition
if (this.save.isRunning) {
return this.save.last.then(() => {
transition.retry();
});
}
// we genuinely have unsaved data, show the modal
this.set('showLeaveSettingsModal', true);
}
},
leaveSettings() {
let transition = this.leaveSettingsTransition;
let settings = this.settings;
if (!transition) {
this.notifications.showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
return;
}
// roll back changes on settings model
settings.rollbackAttributes();
return transition.retry();
}
},
save: task(function* () {
try {
yield this.settings.validate();
return yield this.settings.save();
} catch (error) {
this.notifications.showAPIError(error);
throw error;
}
}).drop()
});

View File

@ -0,0 +1,65 @@
/* eslint-disable ghost/ember/alias-model-in-controller */
import Controller from '@ember/controller';
import config from 'ghost-admin/config/environment';
import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard';
import {alias} from '@ember/object/computed';
import {computed} from '@ember/object';
import {inject as service} from '@ember/service';
import {task, timeout} from 'ember-concurrency';
export default Controller.extend({
ghostPaths: service(),
selectedApiKey: null,
isApiKeyRegenerated: false,
init() {
this._super(...arguments);
if (this.isTesting === undefined) {
this.isTesting = config.environment === 'test';
}
},
integration: alias('model'),
apiUrl: computed(function () {
let origin = window.location.origin;
let subdir = this.ghostPaths.subdir;
let url = this.ghostPaths.url.join(origin, subdir);
return url.replace(/\/$/, '');
}),
regeneratedKeyType: computed('isApiKeyRegenerated', 'selectedApiKey', function () {
if (this.isApiKeyRegenerated) {
return this.get('selectedApiKey.type');
}
return null;
}),
actions: {
confirmRegenerateKeyModal(apiKey) {
this.set('showRegenerateKeyModal', true);
this.set('isApiKeyRegenerated', false);
this.set('selectedApiKey', apiKey);
},
cancelRegenerateKeyModal() {
this.set('showRegenerateKeyModal', false);
},
regenerateKey() {
this.set('isApiKeyRegenerated', true);
}
},
copyAdminKey: task(function* () {
copyTextToClipboard(this.integration.adminKey.secret);
yield timeout(this.isTesting ? 50 : 3000);
}),
copyApiUrl: task(function* () {
copyTextToClipboard(this.apiUrl);
yield timeout(this.isTesting ? 50 : 3000);
})
});

View File

@ -58,6 +58,19 @@ Router.map(function () {
this.route('user', {path: ':user_slug'});
});
this.route('settings.integrations', {path: '/settings/integrations'}, function () {
this.route('new');
});
this.route('settings.integration', {path: '/settings/integrations/:integration_id'}, function () {
this.route('webhooks.new', {path: 'webhooks/new'});
this.route('webhooks.edit', {path: 'webhooks/:webhook_id'});
});
this.route('settings.integrations.slack', {path: '/settings/integrations/slack'});
this.route('settings.integrations.amp', {path: '/settings/integrations/amp'});
this.route('settings.integrations.firstpromoter', {path: '/settings/integrations/firstpromoter'});
this.route('settings.integrations.unsplash', {path: '/settings/integrations/unsplash'});
this.route('settings.integrations.zapier', {path: '/settings/integrations/zapier'});
this.route('settings.theme', {path: '/settings/theme'}, function () {
this.route('uploadtheme');
this.route('install');

View File

@ -6,6 +6,7 @@ import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend(CurrentUserSettings, {
router: service(),
config: service(),
feature: service(),
init() {
this._super(...arguments);
@ -40,7 +41,11 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, {
transitionDisabled() {
if (this.get('disabled')) {
this.transitionTo('integrations');
if (this.feature.get('offers')) {
this.transitionTo('settings.integrations');
} else {
this.transitionTo('integrations');
}
}
},

View File

@ -0,0 +1,73 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings';
import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend(CurrentUserSettings, {
router: service(),
init() {
this._super(...arguments);
this.router.on('routeWillChange', (transition) => {
this.showUnsavedChangesModal(transition);
if (this.controller) {
this.controller.set('selectedApiKey', null);
this.controller.set('isApiKeyRegenerated', false);
}
});
},
beforeModel() {
this._super(...arguments);
this.transitionAuthor(this.session.user);
this.transitionEditor(this.session.user);
},
model(params, transition) {
// use the integrations controller to fetch all integrations and pick
// out the one we want. Allows navigation back to integrations screen
// without a loading state
return this
.controllerFor('settings.integrations')
.integrationModelHook('id', params.integration_id, this, transition);
},
deactivate() {
this._super(...arguments);
this.controller.set('leaveScreenTransition', null);
this.controller.set('showUnsavedChangesModal', false);
},
actions: {
save() {
this.controller.send('save');
}
},
showUnsavedChangesModal(transition) {
if (transition.from && transition.from.name.match(/^settings\.integration\./) && transition.targetName) {
let {controller} = this;
// check to see if we're navigating away from the custom integration
// route - we want to allow editing webhooks without showing the
// "unsaved changes" confirmation modal
let isExternalRoute =
// allow sub-routes of integration
!(transition.targetName || '').match(/^integration\./)
// do not allow changes in integration
// .to will be the index, so use .to.parent to get the route with the params
|| transition.to.parent.params.integration_id !== controller.integration.id;
if (isExternalRoute && !controller.integration.isDeleted && controller.integration.hasDirtyAttributes) {
transition.abort();
controller.send('toggleUnsavedChangesModal', transition);
return;
}
}
},
buildRouteInfoMetadata() {
return {
titleToken: 'Settings - Integrations'
};
}
});

View File

@ -0,0 +1,14 @@
import Route from '@ember/routing/route';
export default Route.extend({
model(params) {
let integration = this.modelFor('settings.integration');
let webhook = integration.webhooks.findBy('id', params.webhook_id);
return webhook;
},
deactivate() {
this._super(...arguments);
this.controller.reset();
}
});

View File

@ -0,0 +1,13 @@
import Route from '@ember/routing/route';
export default Route.extend({
model() {
let integration = this.modelFor('settings.integration');
return this.store.createRecord('webhook', {integration});
},
deactivate() {
this._super(...arguments);
this.controller.webhook.rollbackAttributes();
}
});

View File

@ -0,0 +1,25 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings';
import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend(CurrentUserSettings, {
settings: service(),
beforeModel() {
this._super(...arguments);
this.transitionAuthor(this.session.user);
this.transitionEditor(this.session.user);
},
setupController(controller) {
// kick off the background fetch of integrations so that we can
// show the screen immediately
controller.fetchIntegrations.perform();
},
buildRouteInfoMetadata() {
return {
titleToken: 'Settings - Integrations'
};
}
});

View File

@ -0,0 +1,39 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings';
import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend(CurrentUserSettings, {
settings: service(),
beforeModel() {
this._super(...arguments);
this.transitionAuthor(this.session.user);
this.transitionEditor(this.session.user);
return this.settings.reload();
},
actions: {
save() {
this.controller.send('save');
},
willTransition(transition) {
let controller = this.controller;
let modelIsDirty = this.settings.get('hasDirtyAttributes');
if (modelIsDirty) {
transition.abort();
controller.send('toggleLeaveSettingsModal', transition);
return;
}
}
},
buildRouteInfoMetadata() {
return {
titleToken: 'AMP'
};
}
});

View File

@ -0,0 +1,39 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings';
import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend(CurrentUserSettings, {
settings: service(),
beforeModel() {
this._super(...arguments);
this.transitionAuthor(this.session.user);
this.transitionEditor(this.session.user);
return this.settings.reload();
},
actions: {
save() {
this.controller.send('save');
},
willTransition(transition) {
let controller = this.controller;
let modelIsDirty = this.settings.get('hasDirtyAttributes');
if (modelIsDirty) {
transition.abort();
controller.send('toggleLeaveSettingsModal', transition);
return;
}
}
},
buildRouteInfoMetadata() {
return {
titleToken: 'FirstPromoter'
};
}
});

View File

@ -0,0 +1,31 @@
import RSVP from 'rsvp';
import Route from '@ember/routing/route';
import {inject as service} from '@ember/service';
export default Route.extend({
limit: service(),
model() {
if (this.limit.limiter
&& this.limit.limiter.isLimited('customIntegrations')) {
return RSVP.hash({
integration: this.store.createRecord('integration'),
hostLimitError: this.limit.limiter.errorIfWouldGoOverLimit('customIntegrations')
.then(() => null)
.catch((error) => {
return error;
})
});
} else {
return RSVP.hash({
integration: this.store.createRecord('integration'),
hostLimitError: null
});
}
},
deactivate() {
this._super(...arguments);
this.controller.integration.rollbackAttributes();
}
});

View File

@ -0,0 +1,39 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings';
import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend(CurrentUserSettings, {
settings: service(),
beforeModel() {
this._super(...arguments);
this.transitionAuthor(this.session.user);
this.transitionEditor(this.session.user);
return this.settings.reload();
},
actions: {
save() {
this.controller.send('save');
},
willTransition(transition) {
let controller = this.controller;
let settings = this.settings;
let modelIsDirty = settings.get('hasDirtyAttributes');
if (modelIsDirty) {
transition.abort();
controller.send('toggleLeaveSettingsModal', transition);
return;
}
}
},
buildRouteInfoMetadata() {
return {
titleToken: 'Slack'
};
}
});

View File

@ -0,0 +1,38 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings';
import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend(CurrentUserSettings, {
settings: service(),
beforeModel() {
this._super(...arguments);
this.transitionAuthor(this.session.user);
this.transitionEditor(this.session.user);
return this.settings.reload();
},
actions: {
save() {
this.controller.send('save');
},
willTransition(transition) {
let controller = this.controller;
let modelIsDirty = this.settings.get('hasDirtyAttributes');
if (modelIsDirty) {
transition.abort();
controller.send('toggleLeaveSettingsModal', transition);
return;
}
}
},
buildRouteInfoMetadata() {
return {
titleToken: 'Unsplash'
};
}
});

View File

@ -0,0 +1,52 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings';
import {computed} from '@ember/object';
import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend(CurrentUserSettings, {
router: service(),
config: service(),
init() {
this._super(...arguments);
this.router.on('routeWillChange', () => {
if (this.controller) {
this.controller.set('selectedApiKey', null);
this.controller.set('isApiKeyRegenerated', false);
}
});
},
disabled: computed('config.hostSettings.limits', function () {
return this.config.get('hostSettings.limits.customIntegrations.disabled');
}),
beforeModel() {
this._super(...arguments);
this.transitionDisabled();
this.transitionAuthor(this.session.user);
this.transitionEditor(this.session.user);
},
model(params, transition) {
// use the integrations controller to fetch all integrations and pick
// out the one we want. Allows navigation back to integrations screen
// without a loading state
return this
.controllerFor('integrations')
.integrationModelHook('slug', 'zapier', this, transition);
},
transitionDisabled() {
if (this.get('disabled')) {
this.transitionTo('settings.integrations');
}
},
buildRouteInfoMetadata() {
return {
titleToken: 'Zapier'
};
}
});

View File

@ -79,7 +79,7 @@
<div class="gh-setting-header">Advanced</div>
<div class="gh-settings-main-grid">
{{#if (feature "offers")}}
<LinkTo class="gh-setting-group" @route="integrations" data-test-nav="navigation">
<LinkTo class="gh-setting-group" @route="settings.integrations" data-test-nav="navigation">
<span class="yellow">{{svg-jar "module"}}</span>
<div>
<h4>Integrations</h4>

View File

@ -0,0 +1,285 @@
<section class="gh-canvas">
<form {{action (perform "save") on="submit"}}>
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
<LinkTo @route="settings">Settings</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
<LinkTo @route="settings.integrations" data-test-link="integrations-back">Integrations</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
{{this.integration.name}}
</h2>
<section class="view-actions">
<GhTaskButton @task={{this.save}} @class="gh-btn gh-btn-primary gh-btn-icon" data-test-button="save" />
</section>
</GhCanvasHeader>
<div class="gh-main-section">
<h4 class="gh-main-section-header small bn">Configuration</h4>
<section class="gh-main-section-block">
<div class="gh-main-section-content padding-top-s grey">
<div class="flex">
<div class="flex flex-column items-start app-custom-icon-container">
<label class="mb1">Icon</label>
<figure class="app-custom-icon hide-child" style={{this.iconImageStyle}}>
<AspectRatioBox @class="flex items-center h-100 justify-center" @ratio="1/1" @base="height">
{{#unless this.integration.iconImage}}
{{svg-jar "integration" class="w11 h11"}}
{{/unless}}
</AspectRatioBox>
<GhUploader
@extensions={{this.imageExtensions}}
@onComplete={{action "setIconImage"}}
as |uploader|
>
{{#if uploader.isUploading}}
<div class="absolute top-0 left-0 w-100 h-100 br4 bg-black-70 flex items-center">
{{uploader.progressBar}}
</div>
{{else}}
<button
type="button"
class="child app-custom-icon-uploadlabel"
{{action "triggerIconFileDialog"}}
>
Upload
</button>
{{/if}}
<div style="display:none">
<GhFileInput
@name="iconImage"
@multiple={{false}}
@action={{uploader.setFiles}}
@accept={{this.imageMimeTypes}} data-test-file-input="icon" />
</div>
</GhUploader>
</figure>
</div>
<div class="flex-auto">
<GhValidationStatusContainer
@class="flex flex-column w-100 mr3"
@errors={{this.integration.errors}}
@hasValidated={{this.integration.hasValidated}}
@property="name"
>
<label for="integration_name">Name</label>
<GhTextInput
@id="integration_name"
@class="gh-input mt1 mb1"
@type="text"
@value={{readonly this.integration.name}}
@input={{action (mut this.integration.name) value="target.value"}}
@focus-out={{action "validate" "name" target=this.integration}}
data-test-input="name"
/>
<GhErrorMessage @errors={{this.integration.errors}} @property="name" data-test-error="name" class="ma0" />
</GhValidationStatusContainer>
<GhValidationStatusContainer
@class="flex flex-column w-100 mr3"
@errors={{this.integration.errors}}
@hasValidated={{this.integration.hasValidated}}
@property="decription"
>
<label for="integration_description" class="mt3">Description</label>
<GhTextInput
@id="integration_description"
@class="gh-input mt1"
@type="text"
@value={{readonly this.integration.description}}
@input={{action (mut this.integration.description) value="target.value"}}
@focus-out={{action "validate" "description" target=this.integration}}
data-test-input="description"
/>
<GhErrorMessage @errors={{this.integration.errors}} @property="description" data-test-error="description" class="ma0" />
</GhValidationStatusContainer>
<table class="app-custom-api-table list" style="table-layout: fixed">
<tbody>
<tr>
<td class="data-label">Content API key</td>
<td class="data highlight-hover">
<div class="relative flex items-center {{unless this.copyContentKey.isRunning "hide-child-instant"}}">
<span class="truncate" data-test-text="content-key">
{{this.integration.contentKey.secret}}
</span>
<div class="app-api-buttons child">
<button type="button" {{action "confirmRegenerateKeyModal" this.integration.contentKey}} class="app-button-regenerate" data-tooltip="Regenerate">
{{svg-jar "reload" class="w4 h4 stroke-midgrey"}}
</button>
<button type="button" {{action (perform this.copyContentKey)}} class="app-button-copy">
{{#if this.copyContentKey.isRunning}}
{{svg-jar "check-circle" class="w3 v-mid mr2 stroke-white"}} Copied
{{else}}
Copy
{{/if}}
</button>
</div>
</div>
{{#if (eq this.regeneratedKeyType this.integration.contentKey.type)}}
<div class="green nt3 mb2"> Content API Key was successfully regenerated </div>
{{/if}}
</td>
</tr>
<tr>
<td class="data-label">Admin API key</td>
<td class="data highlight-hover">
<div class="relative flex items-center {{unless this.copyAdminKey.isRunning "hide-child-instant"}}">
<span class="truncate" data-test-text="admin-key">
{{this.integration.adminKey.secret}}
</span>
<div class="app-api-buttons child">
<button type="button" {{action "confirmRegenerateKeyModal" this.integration.adminKey}} class="app-button-regenerate" data-tooltip="Regenerate">
{{svg-jar "reload" class="w4 h4 stroke-midgrey"}}
</button>
<button type="button" {{action (perform this.copyAdminKey)}} class="app-button-copy">
{{#if this.copyAdminKey.isRunning}}
{{svg-jar "check-circle" class="w3 v-mid mr2 stroke-white"}} Copied
{{else}}
Copy
{{/if}}
</button>
</div>
</div>
{{#if (eq this.regeneratedKeyType this.integration.adminKey.type)}}
<div class="green"> Admin API key was successfully regenerated </div>
{{/if}}
</td>
</tr>
<tr>
<td class="data-label">API URL</td>
<td class="data highlight-hover truncate">
<div class="relative flex items-center {{unless this.copyApiUrl.isRunning "hide-child-instant"}}">
<span data-test-text="api-url">
{{this.apiUrl}}
</span>
<div class="app-api-buttons child">
<button type="button" {{action (perform this.copyApiUrl)}} class="app-button-copy">
{{#if this.copyApiUrl.isRunning}}
{{svg-jar "check-circle" class="w3 v-mid mr2 stroke-white"}} Copied
{{else}}
Copy
{{/if}}
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</form>
<section class="gh-main-section">
<h4 class="gh-main-section-header small">Webhooks</h4>
<div class="gh-main-section-block p0">
<table class="gh-list small">
<thead>
<tr class="gh-list-row header">
<th class="gh-list-header">Name</th>
<th class="gh-list-header">Event</th>
<th class="gh-list-header">URL</th>
<th class="gh-list-header">Last triggered</th>
<th class="gh-list-header"></th>
</tr>
</thead>
<tbody>
{{#each this.filteredWebhooks as |webhook|}}
<tr class="gh-list-row hide-child" data-test-webhook-row="{{webhook.id}}">
<td class="gh-list-data" data-test-text="name">{{webhook.name}}</td>
<td class="gh-list-data" data-test-text="event">{{event-name webhook.event}}</td>
<td class="gh-list-data" data-test-text="targetUrl">{{webhook.targetUrl}}</td>
<td class="gh-list-data" data-test-text="last-triggered">{{or webhook.lastTriggeredAtUTC "Not triggered"}}</td>
<td class="w1 gh-list-data nowrap">
<div class="child flex items-center">
<LinkTo @route="settings.integration.webhooks.edit" @models={{array this.integration webhook}} data-test-link="edit-webhook">
{{svg-jar "pen" class="w6 h6 fill-midgrey pa1 mr1"}}
</LinkTo>
<button {{action "confirmWebhookDeletion" webhook}} data-test-button="delete-webhook">
{{svg-jar "trash" class="w6 fill-red pa1"}}
</button>
</div>
</td>
</tr>
{{else}}
<tr class="bt b--whitegrey" data-test-webhooks-blank-slate>
<td colspan="5" class="pa5 pt15 pb15 tc midgrey f7">
<div class="flex flex-column items-center">
<p class="ma0 pa0 tc midgrey lh-title mt2">
No webhooks configured
</p>
<LinkTo @route="settings.integration.webhooks.new" @model={{this.integration}} @classNames="flex items-center" data-test-link="add-webhook">
<div class="flex items-center pa2 pt1">
{{svg-jar "add" class="w3 h3 fill-green-d1"}}
<span class="ml1 green">Add webhook</span>
</div>
</LinkTo>
</div>
</td>
</tr>
{{/each}}
</tbody>
{{#if this.filteredWebhooks}}
<tfoot>
<tr class="gh-list-row new-webhook-cell">
<td colspan="5" class="gh-list-data">
<LinkTo @route="settings.integration.webhooks.new" @model={{this.integration}} @classNames="flex items-center" data-test-link="add-webhook">
<div class="pt1 pb1 f7">
{{svg-jar "add" class="w3 h3 fill-green-d1"}}
<span class="ml1 green">Add webhook</span>
</div>
</LinkTo>
</td>
</tr>
</tfoot>
{{/if}}
</table>
</div>
</section>
<section class="gh-main-section">
<div class="gh-main-section-block">
<button class="gh-btn gh-btn-red gh-btn-icon" {{action "confirmIntegrationDeletion"}}>
<span> Delete integration </span>
</button>
</div>
</section>
</section>
{{#if this.showUnsavedChangesModal}}
<GhFullscreenModal @modal="leave-settings"
@confirm={{action "leaveScreen"}}
@close={{action "toggleUnsavedChangesModal"}}
@modifier="action wide" />
{{/if}}
{{#if this.showRegenerateKeyModal}}
<GhFullscreenModal @modal="regenerate-key"
@model={{hash
apiKey=this.selectedApiKey
integration=this.integration
}}
@confirm={{action "regenerateKey"}}
@close={{action "cancelRegenerateKeyModal"}}
@modifier="action wide" />
{{/if}}
{{#if this.showDeleteIntegrationModal}}
<GhFullscreenModal @modal="delete-integration"
@confirm={{action "deleteIntegration"}}
@close={{action "cancelIntegrationDeletion"}}
@modifier="action wide" />
{{/if}}
{{#if this.webhookToDelete}}
<GhFullscreenModal @modal="delete-webhook"
@confirm={{action "deleteWebhook"}}
@close={{action "cancelWebhookDeletion"}}
@modifier="action wide" />
{{/if}}
{{outlet}}

View File

@ -0,0 +1,5 @@
<GhFullscreenModal @modal="webhook-form"
@model={{this.webhook}}
@confirm={{action "save"}}
@close={{action "cancel"}}
@modifier="action wide" />

View File

@ -0,0 +1,5 @@
<GhFullscreenModal @modal="webhook-form"
@model={{this.webhook}}
@confirm={{action "save"}}
@close={{action "cancel"}}
@modifier="action wide" />

View File

@ -0,0 +1,249 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
<LinkTo @route="settings">Settings</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
Integrations
</h2>
</GhCanvasHeader>
<div class="gh-main-section">
<div class="integrations-directory">
<a class="id-item" href="https://ghost.org/integrations/disqus/" target="_blank" rel="noopener noreferrer">
<div class="id-item-logo id-disqus">
<img class="w-100 h-100" style="object-fit:contain;" src="assets/img/disqus.svg" alt="Disqus Icon" />
</div>
<div class="f8 mt1">Disqus</div>
</a>
<a class="id-item" href="https://ghost.org/integrations/google/" target="_blank" rel="noopener noreferrer">
<div class="id-item-logo id-analytics">
<img class="w-100 h-100" style="object-fit:contain;padding:1px 0;" src="assets/img/google-analytics.png" alt="Google Analytics Icon" />
</div>
<div class="f8 mt1">Analytics</div>
</a>
<a class="id-item" href="https://ghost.org/integrations/ulysses/" target="_blank" rel="noopener noreferrer">
<div class="id-item-logo id-ulysses">
<img class="w-100 h-100" style="object-fit:contain;" src="assets/img/ulysses.png" alt="Ulysses Icon" />
</div>
<div class="f8 mt1">Ulysses</div>
</a>
<a class="id-item" href="https://ghost.org/integrations/typeform/" target="_blank" rel="noopener noreferrer">
<div class="id-item-logo id-typeform">
<img class="w-100 h-100" style="object-fit:contain;" src="assets/img/typeform.svg" alt="Typeform Icon" />
</div>
<div class="f8 mt1">Typeform</div>
</a>
<a class="id-item" href="https://ghost.org/integrations/buffer/" target="_blank" rel="noopener noreferrer">
<div class="id-item-logo id-buffer">
<img class="w-100 h-100" style="object-fit:contain;padding:1px 0;" src="assets/img/buffer.png" alt="Buffer Icon" />
</div>
<div class="f8 mt1">Buffer</div>
</a>
<a class="id-item" href="https://ghost.org/integrations/plausible/" target="_blank" rel="noopener noreferrer">
<div class="id-item-logo id-plausible">
<img class="w-100 h-100" style="object-fit:contain;padding:1px 0;" src="assets/img/plausible.png" alt="Plausible Icon" />
</div>
<div class="f8 mt1">Plausible</div>
</a>
<a class="id-item" href="https://ghost.org/integrations/github/" target="_blank" rel="noopener noreferrer">
<div class="id-item-logo id-github">
<img class="w-100 h-100" style="object-fit:contain;" src="assets/img/github.svg" alt="GitHub Icon" />
</div>
<div class="f8 mt1">GitHub</div>
</a>
<a class="id-item" href="https://ghost.org/integrations/" target="_blank" rel="noopener noreferrer">
<div class="id-item-logo id-more">
{{!-- <img class="w-100 h-100" style="object-fit:contain;padding:0 8px;" src="assets/img/more.png" alt="Three dots" /> --}}
{{svg-jar "circle-ellipsis" class="w5"}}
</div>
<div class="f8 mt1">More</div>
</a>
</div>
</div>
<section class="gh-main-section">
<h4 class="gh-main-section-header small">Built-in integrations</h4>
<div class="apps-grid">
{{#unless this.zapierDisabled}}
<div class="apps-grid-cell" data-test-app="zapier">
<LinkTo @route="settings.integrations.zapier" data-test-link="zapier">
<article class="apps-card-app">
<div class="apps-card-left">
<figure class="apps-card-app-icon" style="background-image:url(assets/img/zapier.svg);background-size:36px;"></figure>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">Zapier</h3>
<p class="apps-card-app-desc">Automation for your favorite apps</p>
</div>
</div>
<div class="gh-card-right">
<div class="apps-configured">
<span data-test-app-status>Configure</span>
{{svg-jar "arrow-right"}}
</div>
</div>
</article>
</LinkTo>
</div>
{{/unless}}
<div class="apps-grid-cell" data-test-app="slack">
<LinkTo @route="settings.integrations.slack" data-test-link="slack">
<article class="apps-card-app">
<div class="apps-card-left">
<figure class="apps-card-app-icon" style="background-image:url(assets/img/slackicon.png); background-size: 36px;"></figure>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">Slack</h3>
<p class="apps-card-app-desc">A messaging app for teams</p>
</div>
</div>
<div class="gh-card-right">
<div class="apps-configured">
{{#if this.settings.slack.isActive}}
<span class="gh-badge" data-test-app-status>Active</span>
{{else}}
<span data-test-app-status>Configure</span>
{{/if}}
{{svg-jar "arrow-right"}}
</div>
</div>
</article>
</LinkTo>
</div>
<div class="apps-grid-cell" data-test-app="amp">
<LinkTo @route="settings.integrations.amp" data-test-link="amp">
<article class="apps-card-app">
<div class="apps-card-left">
<figure class="apps-card-app-icon" style="background-image:url(assets/img/amp.svg); background-size: 36px;"></figure>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">AMP</h3>
<p class="apps-card-app-desc">Google Accelerated Mobile Pages</p>
</div>
</div>
<div class="gh-card-right">
<div class="apps-configured">
{{#if this.settings.amp}}
<span class="gh-badge" data-test-app-status>Active</span>
{{else}}
<span data-test-app-status>Configure</span>
{{/if}}
{{svg-jar "arrow-right"}}
</div>
</div>
</article>
</LinkTo>
</div>
<div class="apps-grid-cell" data-test-app="unsplash">
<LinkTo @route="settings.integrations.unsplash" data-test-link="unsplash">
<article class="apps-card-app">
<div class="apps-card-left">
<figure class="apps-card-app-icon id-unsplash" style="background-image:url(assets/icons/unsplash.svg); background-size:30px;"></figure>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">Unsplash</h3>
<p class="apps-card-app-desc">Beautiful, free photos</p>
</div>
</div>
<div class="gh-card-right">
<div class="apps-configured">
{{#if this.settings.unsplash}}
<span class="gh-badge" data-test-app-status>Active</span>
{{else}}
<span data-test-app-status>Configure</span>
{{/if}}
{{svg-jar "arrow-right"}}
</div>
</div>
</article>
</LinkTo>
</div>
<div class="apps-grid-cell" data-test-app="firstpromoter">
<LinkTo @route="settings.integrations.firstpromoter" data-test-link="firstpromoter">
<article class="apps-card-app">
<div class="apps-card-left">
<figure class="apps-card-app-icon id-unsplash" style="background-image:url(assets/icons/firstpromoter.png); background-size:30px;"></figure>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">FirstPromoter</h3>
<p class="apps-card-app-desc">Launch your member referral program</p>
</div>
</div>
<div class="gh-card-right">
<div class="apps-configured">
{{#if this.settings.firstpromoter}}
<span class="gh-badge" data-test-app-status>Active</span>
{{else}}
<span data-test-app-status>Configure</span>
{{/if}}
{{svg-jar "arrow-right"}}
</div>
</div>
</article>
</LinkTo>
</div>
</div>
</section>
<section class="gh-main-section">
<h4 class="gh-main-section-header small">Custom integrations</h4>
<div class="apps-grid">
{{#each this.integrations as |integration|}}
<div class="apps-grid-cell" data-test-custom-integration>
<LinkTo @route="settings.integration" @model={{integration}} data-test-integration={{integration.id}}>
<article class="apps-card-app">
<div class="apps-card-left">
<figure class="apps-card-app-icon flex items-center" style={{integration-icon-style integration}}>
{{#unless integration.iconImage}}
{{svg-jar "integration" class="nudge-left--6 w9 stroke-darkgrey"}}
{{/unless}}
</figure>
<div class="apps-card-meta">
<h3 class="apps-card-app-title" data-test-text="name">
{{integration.name}}
</h3>
<p class="apps-card-app-desc" data-test-text="description">
{{integration.description}}
</p>
</div>
</div>
<div class="gh-card-right">
<div class="apps-configured">
<span>Configure</span>
{{svg-jar "arrow-right"}}
</div>
</div>
</article>
</LinkTo>
</div>
{{else}}
<div class="flex flex-column justify-center items-center mih30 miw-100 bb b--whitegrey" data-test-blank="custom-integrations">
{{#if this.fetchIntegrations.isRunning}}
<div class="gh-loading-spinner"></div>
{{else}}
<div class="flex flex-column items-center pa5 pt15 pb15">
<p class="ma0 pa0 tc midgrey lh-title mt2 mb4">
Create your own custom Ghost integrations with dedicated API keys & webhooks
</p>
<LinkTo @route="settings.integrations.new" class="gh-btn gh-btn-green gh-btn-icon" data-test-button="new-integration">
<span>{{svg-jar "add" class="w3 h3"}} Add custom integration</span>
</LinkTo>
</div>
{{/if}}
</div>
{{/each}}
</div>
{{#if this.integrations}}
{{!-- <div class="apps-grid-cell new-integration-cell"> --}}
<div class="mt5">
<LinkTo @route="settings.integrations.new" class="gh-btn gh-btn-green gh-btn-icon" data-test-button="new-integration">
<span>{{svg-jar "add" class="w3 h3"}} Add custom integration</span>
</LinkTo>
</div>
{{!-- </div> --}}
{{/if}}
</section>
</section>
{{outlet}}

View File

@ -0,0 +1,16 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
<LinkTo @route="settings">Settings</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
<LinkTo @route="settings.integrations">Integrations</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
AMP
</h2>
<section class="view-actions"></section>
</GhCanvasHeader>
<div class="gh-content">
<GhLoadingSpinner />
</div>
</section>

View File

@ -0,0 +1,87 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
<LinkTo @route="settings">Settings</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
<LinkTo @route="settings.integrations" data-test-link="integrations-back">Integrations</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
AMP
</h2>
<section class="view-actions">
<GhTaskButton @task={{this.save}} @class="gh-btn gh-btn-primary gh-btn-icon" data-test-save-button={{true}} />
</section>
</GhCanvasHeader>
{{#if this.showLeaveSettingsModal}}
<GhFullscreenModal @modal="leave-settings"
@confirm={{action "leaveSettings"}}
@close={{action "toggleLeaveSettingsModal"}}
@modifier="action wide" />
{{/if}}
<section class="view-container">
<section class="gh-main-section bt app-grid">
<div class="gh-main-section-block app-detail-heading app-grid">
<div class="app-cell">
<img class="app-icon" src="assets/img/amp.svg" />
</div>
<div class="app-cell">
<h3>AMP</h3>
<p>Accelerated Mobile Pages</p>
</div>
</div>
</section>
<div class="gh-main-section">
<h4 class="gh-main-section-header small bn">AMP configuration</h4>
<section class="gh-main-section-block">
<div class="gh-main-section-content grey">
<div>
<div class="gh-setting-first {{unless this.settings.amp "gh-setting-last"}}">
<div class="gh-setting-content">
<div class="gh-setting-title">Enable AMP</div>
<div class="gh-setting-desc mb0">Enable <a href="https://ampproject.org" target="_blank">Google Accelerated Mobile Pages</a> for your posts</div>
</div>
<div class="gh-setting-action">
<div class="for-checkbox">
<label for="amp" class="checkbox">
<input
type="checkbox"
checked={{this.settings.amp}}
id="amp"
name="amp"
onclick={{action "update" value="target.checked"}}
data-test-amp-checkbox
>
<span class="input-toggle-component"></span>
</label>
</div>
</div>
</div>
{{#liquid-if this.settings.amp class=""}}
<div class="gh-setting-last gh-setting-amp-liquid">
<div class="gh-setting-content gh-setting-content--no-action">
<div class="gh-setting-title">Google Analytics Tracking ID</div>
<div class="gh-setting-desc">Tracks AMP traffic in Google Analytics, find your ID <a href="https://ghost.org/help/how-to-find-your-google-analytics-tracking-id/">here</a></div>
<div class="gh-setting-content-extended">
<GhFormGroup @class="no-margin" @errors={{this.settings.errors}} @hasValidated={{this.settings.hasValidated}} @property="ampGtagId">
<GhTextInput
@placeholder="UA-XXXXXXX-X"
@name="amp_gtag_id"
@value={{this.settings.ampGtagId}}
@keyEvents={{hash
Enter=(action "save")
}}
/>
<GhErrorMessage @errors={{this.settings.errors}} @property="ampGtagId"/>
</GhFormGroup>
</div>
</div>
</div>
{{/liquid-if}}
</div>
</div>
</section>
</div>
</section>
</section>

View File

@ -0,0 +1,87 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
<LinkTo @route="settings">Settings</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
<LinkTo @route="settings.integrations" data-test-link="integrations-back">Integrations</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
FirstPromoter
</h2>
<section class="view-actions">
<GhTaskButton @task={{this.save}} @class="gh-btn gh-btn-primary gh-btn-icon" data-test-save-button={{true}} />
</section>
</GhCanvasHeader>
{{#if this.showLeaveSettingsModal}}
<GhFullscreenModal @modal="leave-settings"
@confirm={{action "leaveSettings"}}
@close={{action "toggleLeaveSettingsModal"}}
@modifier="action wide" />
{{/if}}
<section class="view-container">
<section class="gh-main-section bt app-grid">
<div class="gh-main-section-block app-detail-heading app-grid">
<div class="app-cell">
<img class="app-icon" src="assets/img/firstpromoter.png" />
</div>
<div class="app-cell">
<h3>FirstPromoter</h3>
<p>Launch your own member referral program</p>
</div>
</div>
</section>
<div class="gh-main-section">
<h4 class="gh-main-section-header small bn">FirstPromoter configuration</h4>
<section class="gh-main-section-block">
<div class="gh-main-section-content grey">
<div>
<div class="gh-setting-first {{unless this.settings.firstpromoter "gh-setting-last"}}">
<div class="gh-setting-content">
<div class="gh-setting-title">Enable FirstPromoter</div>
<div class="gh-setting-desc mb0">Enable <a href="https://firstpromoter.com/?fpr=ghost&fp_sid=admin" target="_blank">FirstPromoter</a> for tracking referrals</div>
</div>
<div class="gh-setting-action">
<div class="for-checkbox">
<label for="firstpromoter" class="checkbox">
<input
type="checkbox"
checked={{this.settings.firstpromoter}}
id="firstpromoter"
name="firstpromoter"
onclick={{action "update" value="target.checked"}}
data-test-firstpromoter-checkbox
>
<span class="input-toggle-component"></span>
</label>
</div>
</div>
</div>
{{#liquid-if this.settings.firstpromoter class=""}}
<div class="gh-setting-last gh-setting-firstpromoter-liquid">
<div class="gh-setting-content gh-setting-content--no-action">
<div class="gh-setting-title">FirstPromoter Account ID</div>
<div class="gh-setting-desc"> Affiliate and referral tracking, find your ID <a href="https://ghost.org/help/firstpromoter-id/">here</a></div>
<div class="gh-setting-content-extended">
<GhFormGroup @class="no-margin" @errors={{this.settings.errors}} @hasValidated={{this.settings.hasValidated}} @property="firstpromoterId">
<GhTextInput
@placeholder="XXXXXXXX"
@name="firstpromoter_id"
@value={{this.settings.firstpromoterId}}
@keyEvents={{hash
Enter=(action "save")
}}
/>
<GhErrorMessage @errors={{this.settings.errors}} @property="firstpromoterId"/>
</GhFormGroup>
</div>
</div>
</div>
{{/liquid-if}}
</div>
</div>
</section>
</div>
</section>
</section>

View File

@ -0,0 +1,14 @@
{{#if showUpgradeModal}}
<GhFullscreenModal @modal="upgrade-custom-integrations-host-limit"
@model={{hash
message=this.hostLimitError.message
}}
@close={{action "cancel"}}
@modifier="action wide" />
{{else}}
<GhFullscreenModal @modal="new-integration"
@model={{this.integration}}
@confirm={{action "save"}}
@close={{action "cancel"}}
@modifier="action wide" />
{{/if}}

View File

@ -0,0 +1,16 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
<LinkTo @route="settings">Settings</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
<LinkTo @route="settings.integrations">Integrations</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
Slack
</h2>
<section class="view-actions"></section>
</GhCanvasHeader>
<div class="gh-content">
<GhLoadingSpinner />
</div>
</section>

View File

@ -0,0 +1,99 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
<LinkTo @route="settings">Settings</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
<LinkTo @route="settings.integrations" data-test-link="integrations-back">Integrations</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
Slack
</h2>
<section class="view-actions">
<GhTaskButton @task={{this.save}} @class="gh-btn gh-btn-primary gh-btn-icon" data-test-save-button={{true}} />
</section>
</GhCanvasHeader>
{{#if this.showLeaveSettingsModal}}
<GhFullscreenModal @modal="leave-settings"
@confirm={{action "leaveSettings"}}
@close={{action "toggleLeaveSettingsModal"}}
@modifier="action wide" />
{{/if}}
<section class="view-container">
<section class="gh-main-section bt app-grid">
<div class="gh-main-section-block app-detail-heading app-grid">
<div class="app-cell">
<img class="app-icon" src="assets/img/slackicon.png" />
</div>
<div class="app-cell">
<h3>Slack</h3>
<p>A messaging app for teams</p>
</div>
</div>
</section>
<section class="gh-main-section">
<h4 class="gh-main-section-header small bn">Slack configuration</h4>
<div class="gh-main-section-block">
<div class="gh-main-section-content grey">
<form class="app-config-form" id="slack-settings" novalidate="novalidate" {{action "save" on="submit"}}>
<div class="gh-main-section-block">
<div class="gh-setting-first">
<div class="gh-setting-content gh-setting-content--no-action">
<div class="gh-setting-title">Webhook URL</div>
<div class="gh-setting-desc">Automatically send newly published posts to a channel in Slack or any Slack-compatible service like Discord or Mattermost.</div>
<div class="gh-setting-content-extended">
<GhFormGroup @errors={{this.slackSettings.errors}} @hasValidated={{this.slackSettings.hasValidated}} @property="url">
<GhTextInput
@placeholder="https://hooks.slack.com/services/..."
@name="slack[url]"
@value={{readonly this.slackSettings.url}}
@input={{action "updateURL" value="target.value"}}
@keyEvents={{hash
Enter=(action "save")
}}
@focus-out={{action "triggerDirtyState"}}
data-test-slack-url-input={{true}}
/>
{{#unless this.slackSettings.errors.url}}
<p>Set up a new incoming webhook <a href="https://my.slack.com/apps/new/A0F7XDUAZ-incoming-webhooks" target="_blank">here</a>, and grab the URL.</p>
{{else}}
<GhErrorMessage @errors={{this.slackSettings.errors}} @property="url" data-test-error="slack-url" />
{{/unless}}
</GhFormGroup>
</div>
</div>
</div>
<div class="gh-setting-last">
<div class="gh-setting-content gh-setting-content--no-action">
<div class="gh-setting-title">Username</div>
<div class="gh-setting-desc">The username to display messages from</div>
<div class="gh-setting-content-extended">
<GhFormGroup @errors={{this.slackSettings.errors}} @hasValidated={{this.slackSettings.hasValidated}} @property="username">
<GhTextInput
@placeholder="Ghost"
@name="slack[username]"
@value={{readonly this.slackSettings.username}}
@input={{action "updateUsername" value="target.value"}}
@keyEvents={{hash
Enter=(action "save")
}}
@focus-out={{action "triggerDirtyState"}}
data-test-slack-username-input={{true}}
/>
{{#if this.slackSettings.errors.username}}
<GhErrorMessage @errors={{this.slackSettings.errors}} @property="username" />
{{/if}}
</GhFormGroup>
</div>
<GhTaskButton @buttonText="Send test notification" @task={{this.sendTestNotification}} @successText="Sent" @class="gh-btn gh-btn-icon" @disabled={{this.testNotificationDisabled}} data-test-send-notification-button="true" />
</div>
</div>
</div>
</form>
</div>
</div>
</section>
</section>
</section>

View File

@ -0,0 +1,16 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
<LinkTo @route="settings">Settings</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
<LinkTo @route="settings.integrations">Integrations</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
Unsplash
</h2>
<section class="view-actions"></section>
</GhCanvasHeader>
<div class="gh-content">
<GhLoadingSpinner />
</div>
</section>

View File

@ -0,0 +1,66 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
<LinkTo @route="settings">Settings</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
<LinkTo @route="settings.integrations" data-test-link="integrations-back">Integrations</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
Unsplash
</h2>
<section class="view-actions">
<GhTaskButton @task={{this.save}} @class="gh-btn gh-btn-primary gh-btn-icon" data-test-save-button={{true}} />
</section>
</GhCanvasHeader>
{{#if this.showLeaveSettingsModal}}
<GhFullscreenModal @modal="leave-settings"
@confirm={{action "leaveSettings"}}
@close={{action "toggleLeaveSettingsModal"}}
@modifier="action wide" />
{{/if}}
<section class="view-container">
<section class="gh-main-section bt app-grid">
<div class="gh-main-section-block app-detail-heading app-grid">
<div class="app-cell">
<img class="app-icon pa2 id-unsplash" src="assets/icons/unsplash.svg" />
</div>
<div class="app-cell">
<h3>Unsplash</h3>
<p>Beautiful, free photos</p>
</div>
</div>
</section>
<section class="gh-main-section">
<h4 class="gh-main-section-header small bn">Unsplash configuration</h4>
<div class="gh-main-section-block">
<div class="gh-main-section-content grey">
<div class="gh-setting-first gh-setting-last" id="unsplash-toggle">
<div class="gh-setting-content">
<div class="gh-setting-title">Enable Unsplash</div>
<div class="gh-setting-desc mb0">Enable <a href="https://unsplash.com" target="_blank">Unsplash</a> image integration for your posts</div>
</div>
<div class="gh-setting-action">
<div class="form-group right gh-setting-unsplash-checkbox">
<div class="for-checkbox">
<label for="unsplash" class="checkbox">
<input
type="checkbox"
checked={{this.settings.unsplash}}
id="unsplash"
name="unsplash"
onclick={{action "update" value="target.checked"}}
data-test-unsplash-checkbox
>
<span class="input-toggle-component"></span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</section>
</section>

View File

@ -0,0 +1,256 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
<LinkTo @route="settings">Settings</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
<LinkTo @route="settings.integrations" data-test-link="integrations-back">Integrations</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
Zapier
</h2>
</GhCanvasHeader>
<section class="view-container">
<section class="gh-main-section no-heading">
<div class="gh-main-section-block overflow-hidden">
<div class="gh-main-section-content app-detail-heading app-grid">
<div class="app-cell">
<img class="app-icon" src="assets/img/zapier.svg" />
</div>
<div class="app-cell overflow-hidden">
<h3>Zapier</h3>
<p>Automation for your favorite apps</p>
<div class="gh-zapier-data-container">
<div class="gh-zapier-data">
<div class="data-label">Admin API key</div>
<div class="data highlight-hover">
<div class="relative flex items-center {{unless this.copyAdminKey.isRunning "hide-child-instant"}}">
<span class="admin-key" data-test-text="admin-key">
{{this.integration.adminKey.secret}}
</span>
<div class="app-api-buttons child">
<button type="button" {{action "confirmRegenerateKeyModal" this.integration.adminKey}} class="app-button-regenerate">
{{svg-jar "reload" class="w4 h4 stroke-midgrey"}}
</button>
<button type="button" {{action (perform this.copyAdminKey)}} class="app-button-copy">
{{#if this.copyAdminKey.isRunning}}
{{svg-jar "check-circle" class="w3 v-mid mr2 stroke-white"}} Copied
{{else}}
Copy
{{/if}}
</button>
</div>
</div>
{{#if (eq this.regeneratedKeyType this.integration.adminKey.type)}}
<div class="green"> Admin API Key was successfully regenerated </div>
{{/if}}
</div>
</div>
<div class="gh-zapier-data">
<div class="data-label">API URL</div>
<div class="data highlight-hover">
<div class="relative flex items-center {{unless this.copyApiUrl.isRunning "hide-child-instant"}}">
<span class="api-url" data-test-text="api-url">
{{this.apiUrl}}
</span>
<div class="app-api-buttons child">
<button type="button" {{action (perform this.copyApiUrl)}} class="app-button-copy">
{{#if this.copyApiUrl.isRunning}}
{{svg-jar "check-circle" class="w3 v-mid mr2"}} Copied
{{else}}
Copy
{{/if}}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="zapier-toggle" class="gh-main-section">
<h4 class="gh-main-section-header large bn">Zapier templates</h4>
<p class="gh-main-section-description">Explore pre-built templates for common automation tasks</p>
<div class="gh-setting-content gh-setting-content--no-action">
<div class="gh-setting-content-extended">
<div class="apps-grid gh-settings-zapier-templates">
<div class="apps-grid-cell">
<article class="apps-card-app">
<div class="apps-card-left">
<div class="flex items-center">
<figure class="apps-card-app-orb" style="background-image:url(assets/img/logos/orb-black-1.png);background-size:32px;"></figure>
{{svg-jar "arrow-right" class="fill-midgrey w4 ml2"}}
<figure class="apps-card-app-icon" style="background-image:url(assets/img/twitter.svg);background-size:32px;"></figure>
</div>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">Share new posts to Twitter</h3>
</div>
</div>
<div class="gh-card-right">
<a href="https://zapier.com/app/editor/template/50909" target="_blank" rel="noopener" class="gh-btn gh-btn-outline zapier-template-link"><span>Use this Zap</span></a>
</div>
</article>
</div>
<div class="apps-grid-cell">
<article class="apps-card-app">
<div class="apps-card-left">
<div class="flex items-center">
<figure class="apps-card-app-orb" style="background-image:url(assets/img/logos/orb-black-2.png);background-size:32px;"></figure>
{{svg-jar "arrow-right" class="fill-midgrey w4 ml2"}}
<figure class="apps-card-app-icon" style="background-image:url(assets/img/slackicon.png);background-size:30px;"></figure>
</div>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">Share scheduled posts with your team in Slack</h3>
</div>
</div>
<div class="gh-card-right">
<a href="https://zapier.com/app/editor/template/359499" target="_blank" rel="noopener" class="gh-btn gh-btn-outline zapier-template-link"><span>Use this Zap</span></a>
</div>
</article>
</div>
<div class="apps-grid-cell">
<article class="apps-card-app">
<div class="apps-card-left">
<div class="flex items-center">
<figure class="apps-card-app-orb" style="background-image:url(assets/img/logos/orb-black-3.png);background-size:32px;"></figure>
{{svg-jar "arrow-right" class="fill-midgrey w4 ml2"}}
<figure class="apps-card-app-icon" style="background-image:url(assets/img/patreon.svg);background-size:28px;"></figure>
</div>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">Connect Patreon to your Ghost membership site</h3>
</div>
</div>
<div class="gh-card-right">
<a href="https://zapier.com/app/editor/template/75801" target="_blank" rel="noopener" class="gh-btn gh-btn-outline zapier-template-link"><span>Use this Zap</span></a>
</div>
</article>
</div>
<div class="apps-grid-cell">
<article class="apps-card-app">
<div class="apps-card-left">
<div class="flex items-center">
<figure class="apps-card-app-orb" style="background-image:url(assets/img/logos/orb-black-4.png);background-size:32px;"></figure>
{{svg-jar "arrow-right" class="fill-midgrey w4 ml2"}}
<figure class="apps-card-app-icon" style="background-image:url(assets/img/zero-bounce.png);background-size:30px;"></figure>
</div>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">Protect email delivery with email verification</h3>
</div>
</div>
<div class="gh-card-right">
<a href="https://zapier.com/app/editor/template/359415" target="_blank" rel="noopener" class="gh-btn gh-btn-outline zapier-template-link"><span>Use this Zap</span></a>
</div>
</article>
</div>
<div class="apps-grid-cell">
<article class="apps-card-app">
<div class="apps-card-left">
<div class="flex items-center">
<figure class="apps-card-app-orb" style="background-image:url(assets/img/logos/orb-black-5.png);background-size:32px;"></figure>
{{svg-jar "arrow-right" class="fill-midgrey w4 ml2"}}
<figure class="apps-card-app-icon" style="background-image:url(assets/img/paypal.svg);background-size:26px;"></figure>
</div>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">Add members for successful sales in PayPal</h3>
</div>
</div>
<div class="gh-card-right">
<a href="https://zapier.com/app/editor/template/184423" target="_blank" rel="noopener" class="gh-btn gh-btn-outline zapier-template-link"><span>Use this Zap</span></a>
</div>
</article>
</div>
<div class="apps-grid-cell">
<article class="apps-card-app">
<div class="apps-card-left">
<div class="flex items-center">
<figure class="apps-card-app-orb rot-1" style="background-image:url(assets/img/logos/orb-black-3.png);background-size:32px;"></figure>
{{svg-jar "arrow-right" class="fill-midgrey w4 ml2"}}
<figure class="apps-card-app-icon" style="background-image:url(assets/img/paypal.svg);background-size:26px;"></figure>
</div>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">Unsubscribe members who cancel a subscription in PayPal</h3>
</div>
</div>
<div class="gh-card-right">
<a href="https://zapier.com/app/editor/template/359348" target="_blank" rel="noopener" class="gh-btn gh-btn-outline zapier-template-link"><span>Use this Zap</span></a>
</div>
</article>
</div>
<div class="apps-grid-cell">
<article class="apps-card-app">
<div class="apps-card-left">
<div class="flex items-center">
<figure class="apps-card-app-orb rot-2" style="background-image:url(assets/img/logos/orb-black-1.png);background-size:32px;"></figure>
{{svg-jar "arrow-right" class="fill-midgrey w4 ml2"}}
<figure class="apps-card-app-icon" style="background-image:url(assets/img/google-docs.svg);background-size:22px;"></figure>
</div>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">Send new post drafts from Google Docs to Ghost</h3>
</div>
</div>
<div class="gh-card-right">
<a href="https://zapier.com/app/editor/template/50924" target="_blank" rel="noopener" class="gh-btn gh-btn-outline zapier-template-link"><span>Use this Zap</span></a>
</div>
</article>
</div>
<div class="apps-grid-cell">
<article class="apps-card-app">
<div class="apps-card-left">
<div class="flex items-center">
<figure class="apps-card-app-orb rot-3" style="background-image:url(assets/img/logos/orb-black-4.png);background-size:32px;"></figure>
{{svg-jar "arrow-right" class="fill-midgrey w4 ml2"}}
<figure class="apps-card-app-icon id-typeform" style="background-image:url(assets/img/typeform.svg);background-size:32px;"></figure>
</div>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">Survey new members using Typeform</h3>
</div>
</div>
<div class="gh-card-right">
<a href="https://zapier.com/app/editor/template/359407" target="_blank" rel="noopener" class="gh-btn gh-btn-outline zapier-template-link"><span>Use this Zap</span></a>
</div>
</article>
</div>
<div class="apps-grid-cell">
<article class="apps-card-app">
<div class="apps-card-left">
<div class="flex items-center">
<figure class="apps-card-app-orb rot-3" style="background-image:url(assets/img/logos/orb-black-1.png);background-size:32px;"></figure>
{{svg-jar "arrow-right" class="fill-midgrey w4 ml2"}}
<figure class="apps-card-app-icon" style="background-image:url(assets/img/mailchimp.svg);background-size:32px;"></figure>
</div>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">Sync email subscribers in Ghost + Mailchimp</h3>
</div>
</div>
<div class="gh-card-right">
<a href="https://zapier.com/app/editor/template/359342" target="_blank" rel="noopener" class="gh-btn gh-btn-outline zapier-template-link"><span>Use this Zap</span></a>
</div>
</article>
</div>
</div>
<div class="zapier-footer">
<a href="https://zapier.com/apps/ghost/integrations?utm_medium=partner_api&utm_source=widget&utm_campaign=Widget" target="_blank" rel="noopener"><span>See more Ghost integrations powered by</span> <figure style="background-image:url(assets/img/zapier-logo.svg); background-size: 42px;"></figure></a>
</div>
</div>
</div>
</section>
</section>
</section>
{{#if this.showRegenerateKeyModal}}
<GhFullscreenModal @modal="regenerate-key"
@model={{hash
apiKey=this.selectedApiKey
integration=this.integration
internalIntegration="zapier"
}}
@confirm={{action "regenerateKey"}}
@close={{action "cancelRegenerateKeyModal"}}
@modifier="action wide" />
{{/if}}