mirror of
https://github.com/vale981/Vulcan
synced 2025-03-08 19:11:38 -05:00
Merge branch 'devel' of https://github.com/TelescopeJS/Telescope into devel
This commit is contained in:
commit
a2f7592b94
18 changed files with 288 additions and 115 deletions
|
@ -8,6 +8,7 @@
|
||||||
"lint": "eslint --cache --ext .jsx,js packages"
|
"lint": "eslint --cache --ext .jsx,js packages"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"analytics-node": "^2.1.1",
|
||||||
"apollo-client": "^0.8.1",
|
"apollo-client": "^0.8.1",
|
||||||
"babel-runtime": "^6.18.0",
|
"babel-runtime": "^6.18.0",
|
||||||
"bcrypt": "^0.8.7",
|
"bcrypt": "^0.8.7",
|
||||||
|
|
|
@ -1,44 +1,104 @@
|
||||||
import { Components, registerComponent, withDocument, withCurrentUser } from 'meteor/nova:core';
|
import { Components, registerComponent, withDocument, withCurrentUser, getActions, withMutation } from 'meteor/nova:core';
|
||||||
import React from 'react';
|
|
||||||
import Posts from 'meteor/nova:posts';
|
import Posts from 'meteor/nova:posts';
|
||||||
|
import React, { Component, PropTypes } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { bindActionCreators } from 'redux';
|
||||||
|
|
||||||
const PostsPage = (props) => {
|
class PostsPage extends Component {
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.props.loading) {
|
||||||
|
|
||||||
|
return <div className="posts-page"><Components.Loading/></div>
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
const post = this.props.document;
|
||||||
|
|
||||||
if (props.loading) {
|
const htmlBody = {__html: post.htmlBody};
|
||||||
|
|
||||||
return <div className="posts-page"><Components.Loading/></div>
|
return (
|
||||||
|
<div className="posts-page">
|
||||||
|
<Components.HeadTags url={Posts.getLink(post)} title={post.title} image={post.thumbnailUrl} description={post.excerpt} />
|
||||||
|
|
||||||
|
<Components.PostsItem post={post} currentUser={this.props.currentUser} />
|
||||||
|
|
||||||
} else {
|
{post.htmlBody ? <div className="posts-page-body" dangerouslySetInnerHTML={htmlBody}></div> : null}
|
||||||
|
|
||||||
const post = props.document;
|
<Components.PostsCommentsThread terms={{postId: post._id}} />
|
||||||
|
|
||||||
const htmlBody = {__html: post.htmlBody};
|
</div>
|
||||||
|
);
|
||||||
return (
|
|
||||||
<div className="posts-page">
|
}
|
||||||
<Components.HeadTags url={Posts.getLink(post)} title={post.title} image={post.thumbnailUrl} description={post.excerpt} />
|
|
||||||
|
|
||||||
<Components.PostsItem post={post} currentUser={props.currentUser} />
|
|
||||||
|
|
||||||
{post.htmlBody ? <div className="posts-page-body" dangerouslySetInnerHTML={htmlBody}></div> : null}
|
|
||||||
|
|
||||||
<Components.PostsCommentsThread terms={{postId: post._id}} />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
// triggered after the component did mount on the client
|
||||||
|
async componentDidMount() {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// destructure the relevant props
|
||||||
|
const {
|
||||||
|
// from the parent component, used in withDocument, GraphQL HOC
|
||||||
|
documentId,
|
||||||
|
// from connect, Redux HOC
|
||||||
|
setViewed,
|
||||||
|
postsViewed,
|
||||||
|
// from withMutation, GraphQL HOC
|
||||||
|
increasePostViewCount,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
// a post id has been found & it's has not been seen yet on this client session
|
||||||
|
if (documentId && !postsViewed.includes(documentId)) {
|
||||||
|
|
||||||
|
// trigger the asynchronous mutation with postId as an argument
|
||||||
|
await increasePostViewCount({postId: documentId});
|
||||||
|
|
||||||
|
// once the mutation is done, update the redux store
|
||||||
|
setViewed(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch(error) {
|
||||||
|
console.log(error); // eslint-disable-line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PostsPage.displayName = "PostsPage";
|
PostsPage.displayName = "PostsPage";
|
||||||
|
|
||||||
PostsPage.propTypes = {
|
PostsPage.propTypes = {
|
||||||
document: React.PropTypes.object
|
documentId: PropTypes.string,
|
||||||
|
document: PropTypes.object,
|
||||||
|
postsViewed: PropTypes.array,
|
||||||
|
setViewed: PropTypes.func,
|
||||||
|
increasePostViewCount: PropTypes.func,
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = {
|
const queryOptions = {
|
||||||
collection: Posts,
|
collection: Posts,
|
||||||
queryName: 'postsSingleQuery',
|
queryName: 'postsSingleQuery',
|
||||||
fragmentName: 'PostsPage',
|
fragmentName: 'PostsPage',
|
||||||
};
|
};
|
||||||
|
|
||||||
registerComponent('PostsPage', PostsPage, withCurrentUser, [withDocument, options]);
|
const mutationOptions = {
|
||||||
|
name: 'increasePostViewCount',
|
||||||
|
args: {postId: 'String'},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({ postsViewed: state.postsViewed });
|
||||||
|
const mapDispatchToProps = dispatch => bindActionCreators(getActions().postsViewed, dispatch);
|
||||||
|
|
||||||
|
registerComponent(
|
||||||
|
// component name used by Nova
|
||||||
|
'PostsPage',
|
||||||
|
// React component
|
||||||
|
PostsPage,
|
||||||
|
// HOC to give access to the current user
|
||||||
|
withCurrentUser,
|
||||||
|
// HOC to load the data of the document, based on queryOptions & a documentId props
|
||||||
|
[withDocument, queryOptions],
|
||||||
|
// HOC to provide a single mutation, based on mutationOptions
|
||||||
|
withMutation(mutationOptions),
|
||||||
|
// HOC to give access to the redux store & related actions
|
||||||
|
connect(mapStateToProps, mapDispatchToProps)
|
||||||
|
);
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
import Categories from './modules.js';
|
import Categories, { getCategories, getCategoriesAsOptions } from './modules.js';
|
||||||
|
|
||||||
export default Categories;
|
export { getCategories, getCategoriesAsOptions };
|
||||||
|
export default Categories;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import Categories from './collection.js';
|
import Categories from './collection.js';
|
||||||
|
|
||||||
import './schema.js';
|
export { getCategories, getCategoriesAsOptions } from './schema.js';
|
||||||
import './helpers.js';
|
import './helpers.js';
|
||||||
import './callbacks.js';
|
import './callbacks.js';
|
||||||
import './parameters.js';
|
import './parameters.js';
|
||||||
|
@ -9,4 +9,4 @@ import './permissions.js';
|
||||||
import './resolvers.js';
|
import './resolvers.js';
|
||||||
import './mutations.js';
|
import './mutations.js';
|
||||||
|
|
||||||
export default Categories;
|
export default Categories;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import Categories from './modules.js';
|
import Categories, { getCategories, getCategoriesAsOptions } from './modules.js';
|
||||||
|
|
||||||
import './server/load_categories.js';
|
import './server/load_categories.js';
|
||||||
|
|
||||||
export default Categories;
|
export { getCategories, getCategoriesAsOptions };
|
||||||
|
export default Categories;
|
||||||
|
|
47
packages/nova-events/lib/callbacks.js
Normal file
47
packages/nova-events/lib/callbacks.js
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { addCallback, getSetting } from 'meteor/nova:core';
|
||||||
|
import { sendGoogleAnalyticsRequest, mutationAnalyticsAsync } from './helpers';
|
||||||
|
import Analytics from 'analytics-node';
|
||||||
|
|
||||||
|
// add client-side callback: log a ga request on page view
|
||||||
|
addCallback('router.onUpdate', sendGoogleAnalyticsRequest);
|
||||||
|
|
||||||
|
|
||||||
|
// get the segment write key from the settings
|
||||||
|
const useSegment = getSetting('useSegment');
|
||||||
|
const writeKey = getSetting('segmentWriteKey');
|
||||||
|
|
||||||
|
// the settings obviously tells to use segment
|
||||||
|
// and segment write key is defined & isn't the placeholder from sample_settings.json
|
||||||
|
if (useSegment && writeKey && writeKey !== '456bar') {
|
||||||
|
const analyticsInstance = new Analytics(writeKey);
|
||||||
|
|
||||||
|
// generate callbacks on collection ...
|
||||||
|
['users', 'posts', 'comments', 'categories'].map(collection => {
|
||||||
|
// ... for each common mutation
|
||||||
|
return ['new', 'edit', 'remove'].map(mutation => {
|
||||||
|
|
||||||
|
const hook = `${collection}.${mutation}`;
|
||||||
|
|
||||||
|
addCallback(`${hook}.async`, function AnalyticsTracking(...args) {
|
||||||
|
|
||||||
|
// a note on what's happenning below:
|
||||||
|
// the first argument is always the document we are interested in
|
||||||
|
// the second to last argument is always the current user
|
||||||
|
// on edit.async, the argument on index 1 is always the previous document
|
||||||
|
// see nova:lib/mutations.js for more informations
|
||||||
|
|
||||||
|
// remove unnecessary 'previousDocument' if operating on a collection.edit hook
|
||||||
|
if (hook.includes('edit')) {
|
||||||
|
args.splice(1,1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [document, currentUser, ...rest] = args; // eslint-disable-line no-unused-vars
|
||||||
|
|
||||||
|
return mutationAnalyticsAsync(analyticsInstance, hook, document, currentUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
// return the hook name, used for debug
|
||||||
|
return hook;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,5 +1,8 @@
|
||||||
import Events from './collection.js';
|
import Events from './collection.js';
|
||||||
|
import { initGoogleAnalytics } from './helpers.js';
|
||||||
|
import './callbacks.js';
|
||||||
|
|
||||||
import './client/analytics.js';
|
// init google analytics on the client module
|
||||||
|
initGoogleAnalytics();
|
||||||
|
|
||||||
export default Events;
|
export default Events;
|
||||||
|
|
|
@ -1,40 +0,0 @@
|
||||||
import Events from '../collection.js';
|
|
||||||
import { addCallback, getSetting } from 'meteor/nova:core';
|
|
||||||
|
|
||||||
Events.analyticsRequest = function() {
|
|
||||||
// Google Analytics
|
|
||||||
if (typeof window.ga !== 'undefined'){
|
|
||||||
window.ga('send', 'pageview', {
|
|
||||||
'page': window.location.pathname
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Events.analyticsInit = function() {
|
|
||||||
|
|
||||||
// Google Analytics
|
|
||||||
const googleAnalyticsId = getSetting("googleAnalyticsId");
|
|
||||||
if (googleAnalyticsId) {
|
|
||||||
|
|
||||||
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
|
||||||
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
|
|
||||||
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
|
||||||
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
|
|
||||||
|
|
||||||
var cookieDomain = document.domain === "localhost" ? "none" : "auto";
|
|
||||||
|
|
||||||
window.ga('create', googleAnalyticsId, cookieDomain);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// trigger first request once analytics are initialized
|
|
||||||
Events.analyticsRequest();
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
Events.analyticsInit();
|
|
||||||
|
|
||||||
function analyticsRequest () {
|
|
||||||
Events.analyticsRequest();
|
|
||||||
}
|
|
||||||
addCallback('router.onUpdate', analyticsRequest);
|
|
|
@ -28,41 +28,6 @@ Events.schema = new SimpleSchema({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Meteor.startup(function(){
|
|
||||||
// // needs to happen after every fields are added
|
|
||||||
// Events.internationalize();
|
|
||||||
// });
|
|
||||||
|
|
||||||
Events.attachSchema(Events.schema);
|
Events.attachSchema(Events.schema);
|
||||||
|
|
||||||
if (Meteor.isServer) {
|
|
||||||
Events.log = function (event) {
|
|
||||||
|
|
||||||
// if event is supposed to be unique, check if it has already been logged
|
|
||||||
if (!!event.unique && !!Events.findOne({name: event.name})) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.createdAt = new Date();
|
|
||||||
|
|
||||||
Events.insert(event);
|
|
||||||
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Events.track = function(event, properties){
|
|
||||||
// console.log('trackevent: ', event, properties);
|
|
||||||
properties = properties || {};
|
|
||||||
//TODO
|
|
||||||
// add event to an Events collection for logging and buffering purposes
|
|
||||||
// if(Meteor.isClient){
|
|
||||||
// if(typeof mixpanel !== 'undefined' && typeof mixpanel.track !== 'undefined'){
|
|
||||||
// mixpanel.track(event, properties);
|
|
||||||
// }
|
|
||||||
// if(typeof GoSquared !== 'undefined' && typeof GoSquared.DefaultTracker !== 'undefined'){
|
|
||||||
// GoSquared.DefaultTracker.TrackEvent(event, JSON.stringify(properties));
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Events;
|
export default Events;
|
||||||
|
|
86
packages/nova-events/lib/helpers.js
Normal file
86
packages/nova-events/lib/helpers.js
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
import { getSetting } from 'meteor/nova:core';
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
We provide a special support for Google Analytics.
|
||||||
|
|
||||||
|
If you want to enable GA page viewing / tracking, go to
|
||||||
|
your settings file and update the "public > googleAnalyticsId"
|
||||||
|
field with your GA unique identifier (UA-xxx...).
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const sendGoogleAnalyticsRequest = () => {
|
||||||
|
if (window && window.ga) {
|
||||||
|
window.ga('send', 'pageview', {
|
||||||
|
'page': window.location.pathname
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initGoogleAnalytics = () => {
|
||||||
|
|
||||||
|
// get the google analytics id from the settings
|
||||||
|
const googleAnalyticsId = getSetting("googleAnalyticsId");
|
||||||
|
|
||||||
|
// the google analytics id exists & isn't the placeholder from sample_settings.json
|
||||||
|
if (googleAnalyticsId && googleAnalyticsId !== "foo123") {
|
||||||
|
|
||||||
|
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
||||||
|
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
|
||||||
|
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
||||||
|
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
|
||||||
|
|
||||||
|
const cookieDomain = document.domain === "localhost" ? "none" : "auto";
|
||||||
|
|
||||||
|
window.ga('create', googleAnalyticsId, cookieDomain);
|
||||||
|
|
||||||
|
// trigger first request once analytics are initialized
|
||||||
|
sendGoogleAnalyticsRequest();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
We provide a special support for Segment, using analytics-node
|
||||||
|
See https://segment.com/docs/sources/server/node/
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const mutationAnalyticsAsync = (analytics, hook, document, user) => {
|
||||||
|
|
||||||
|
if (hook.includes('users')) {
|
||||||
|
// if the mutation is related to users, use analytics.identify
|
||||||
|
// see https://segment.com/docs/sources/server/node/#identify
|
||||||
|
|
||||||
|
// note: on users.new.async, user is undefined
|
||||||
|
const userId = user ? user._id : document._id;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
userId,
|
||||||
|
traits: document,
|
||||||
|
};
|
||||||
|
|
||||||
|
// uncomment for debug
|
||||||
|
// console.log(`// dispatching identify on "${hook}" (user ${userId})`);
|
||||||
|
// console.log(data);
|
||||||
|
|
||||||
|
analytics.identify(data);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// else use analytics.track
|
||||||
|
// see https://segment.com/docs/sources/server/node/#track
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
userId: user._id,
|
||||||
|
event: hook,
|
||||||
|
properties: document,
|
||||||
|
};
|
||||||
|
|
||||||
|
// uncomment for debug
|
||||||
|
// console.log(`// dispatching track on "${hook}"`);
|
||||||
|
// console.log(data);
|
||||||
|
|
||||||
|
analytics.track(data);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,19 @@
|
||||||
import Events from './collection.js';
|
import Events from './collection.js';
|
||||||
|
import './helpers';
|
||||||
|
import './callbacks.js';
|
||||||
|
|
||||||
export default Events;
|
// note: do we still need that?
|
||||||
|
Events.log = function (event) {
|
||||||
|
|
||||||
|
// if event is supposed to be unique, check if it has already been logged
|
||||||
|
if (!!event.unique && !!Events.findOne({name: event.name})) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.createdAt = new Date();
|
||||||
|
|
||||||
|
Events.insert(event);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Events;
|
||||||
|
|
|
@ -10,7 +10,7 @@ Package.onUse(function(api) {
|
||||||
api.versionsFrom("METEOR@1.0");
|
api.versionsFrom("METEOR@1.0");
|
||||||
|
|
||||||
api.use([
|
api.use([
|
||||||
'nova:lib@1.0.0'
|
'nova:core@1.0.0',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
api.mainModule("lib/server.js", "server");
|
api.mainModule("lib/server.js", "server");
|
||||||
|
|
|
@ -30,7 +30,7 @@ class FormComponent extends Component {
|
||||||
renderComponent() {
|
renderComponent() {
|
||||||
|
|
||||||
// see https://facebook.github.io/react/warnings/unknown-prop.html
|
// see https://facebook.github.io/react/warnings/unknown-prop.html
|
||||||
const { control, group, updateCurrentValues, document, ...rest } = this.props; // eslint-disable-line
|
const { control, group, updateCurrentValues, document, beforeComponent, afterComponent, ...rest } = this.props; // eslint-disable-line
|
||||||
|
|
||||||
const base = this.props.control === "function" ? this.props : rest;
|
const base = this.props.control === "function" ? this.props : rest;
|
||||||
|
|
||||||
|
|
|
@ -13,5 +13,6 @@ import './emails.js';
|
||||||
import './permissions.js';
|
import './permissions.js';
|
||||||
import './resolvers.js';
|
import './resolvers.js';
|
||||||
import './mutations.js';
|
import './mutations.js';
|
||||||
|
import './redux.js';
|
||||||
|
|
||||||
export default Posts;
|
export default Posts;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { newMutation, editMutation, removeMutation } from 'meteor/nova:core';
|
import { newMutation, editMutation, removeMutation, GraphQLSchema } from 'meteor/nova:core';
|
||||||
import Users from 'meteor/nova:users';
|
import Users from 'meteor/nova:users';
|
||||||
|
|
||||||
const performCheck = (mutation, user, document) => {
|
const performCheck = (mutation, user, document) => {
|
||||||
|
@ -85,4 +85,6 @@ const mutations = {
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default mutations;
|
GraphQLSchema.addMutation('increasePostViewCount(postId: String): Float');
|
||||||
|
|
||||||
|
export default mutations;
|
||||||
|
|
23
packages/nova-posts/lib/redux.js
Normal file
23
packages/nova-posts/lib/redux.js
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { addAction, addReducer } from 'meteor/nova:core';
|
||||||
|
|
||||||
|
addAction({
|
||||||
|
postsViewed: {
|
||||||
|
setViewed: (postId) => ({
|
||||||
|
type: 'SET_VIEWED',
|
||||||
|
postId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
addReducer({
|
||||||
|
postsViewed: (state = [], action) => {
|
||||||
|
if (action.type === 'SET_VIEWED') {
|
||||||
|
return [
|
||||||
|
...state,
|
||||||
|
action.postId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
});
|
|
@ -6,6 +6,11 @@ const specificResolvers = {
|
||||||
return context.Users.findOne({ _id: post.userId }, { fields: context.getViewableFields(context.currentUser, context.Users) });
|
return context.Users.findOne({ _id: post.userId }, { fields: context.getViewableFields(context.currentUser, context.Users) });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Mutation: {
|
||||||
|
increasePostViewCount(root, { postId }, context) {
|
||||||
|
return context.Posts.update({_id: postId}, { $inc: { viewCount: 1 }});
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
GraphQLSchema.addResolvers(specificResolvers);
|
GraphQLSchema.addResolvers(specificResolvers);
|
||||||
|
|
|
@ -27,7 +27,9 @@
|
||||||
"twitterAccount": "foo",
|
"twitterAccount": "foo",
|
||||||
"facebookPage": "http://facebook.com/foo",
|
"facebookPage": "http://facebook.com/foo",
|
||||||
|
|
||||||
"googleAnalyticsId":"123foo"
|
"googleAnalyticsId":"123foo",
|
||||||
|
"useSegment": false,
|
||||||
|
"segmentWriteKey": "456bar"
|
||||||
},
|
},
|
||||||
|
|
||||||
"defaultEmail": "hello@world.com",
|
"defaultEmail": "hello@world.com",
|
||||||
|
|
Loading…
Add table
Reference in a new issue