Initial project structure with sails.js

This commit is contained in:
2023-11-21 21:57:32 -05:00
commit 523978e520
197 changed files with 76740 additions and 0 deletions

View File

@ -0,0 +1,93 @@
/**
* <account-notification-banner>
* -----------------------------------------------------------------------------
*
* @type {Component}
*
* -----------------------------------------------------------------------------
*/
parasails.registerComponent('account-notification-banner', {
// ╔═╗╦ ╦╔╗ ╦ ╦╔═╗ ╔═╗╦═╗╔═╗╔═╗╔═╗
// ╠═╝║ ║╠╩╗║ ║║ ╠═╝╠╦╝║ ║╠═╝╚═╗
// ╩ ╚═╝╚═╝╩═╝╩╚═╝ ╩ ╩╚═╚═╝╩ ╚═╝
props: [],
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╦╔╗╔╔╦╗╔═╗╦═╗╔╗╔╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ║║║║ ║ ║╣ ╠╦╝║║║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╩╝╚╝ ╩ ╚═╝╩╚═╝╚╝╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: function () {
return {
notificationText: '',
roomName: undefined,
};
},
// ╦ ╦╔╦╗╔╦╗╦
// ╠═╣ ║ ║║║║
// ╩ ╩ ╩ ╩ ╩╩═╝
template: `
<div>
<div class="container-fluid">
<div class="alert alert-warning mt-2 small" role="alert" v-if="notificationText">
{{notificationText}}
</div>
</div>
</div>
`,
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
mounted: async function() {
await Cloud.observeMySession();
// Listen for updates to the user's session
Cloud.on('session', (msg)=>{
if(msg.notificationText) {
this.notificationText = msg.notificationText;
} else {
this.notificationText = '';
}
});//œ
},
beforeDestroy: function() {
Cloud.off('session');
},
watch: {
loggedInUserId: function(unused) { throw new Error('Changes to `loggedInUserId` are not currently supported in <account-notification-banner>!'); },
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
// ╦╔╗╔╔╦╗╔═╗╦═╗╔╗╔╔═╗╦ ╔═╗╦ ╦╔═╗╔╗╔╔╦╗ ╦ ╦╔═╗╔╗╔╔╦╗╦ ╔═╗╦═╗╔═╗
// ║║║║ ║ ║╣ ╠╦╝║║║╠═╣║ ║╣ ╚╗╔╝║╣ ║║║ ║ ╠═╣╠═╣║║║ ║║║ ║╣ ╠╦╝╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╝╚╝╩ ╩╩═╝ ╚═╝ ╚╝ ╚═╝╝╚╝ ╩ ╩ ╩╩ ╩╝╚╝═╩╝╩═╝╚═╝╩╚═╚═╝
//…
// ╔═╗╦ ╦╔╗ ╦ ╦╔═╗ ╔╦╗╔═╗╔╦╗╦ ╦╔═╗╔╦╗╔═╗
// ╠═╝║ ║╠╩╗║ ║║ ║║║║╣ ║ ╠═╣║ ║ ║║╚═╗
// ╩ ╚═╝╚═╝╩═╝╩╚═╝ ╩ ╩╚═╝ ╩ ╩ ╩╚═╝═╩╝╚═╝
// > Public methods are rarely exposed by Vue components, but sometimes they
// > are an important escape hatch. They are callable via something like
// > `this.$refs.componentNameInCamelCase.doSomething())`, and, by convention,
// > are always prefixed with "do".
// N/A
// ╔═╗╦═╗╦╦ ╦╔═╗╔╦╗╔═╗ ╔╦╗╔═╗╔╦╗╦ ╦╔═╗╔╦╗╔═╗
// ╠═╝╠╦╝║╚╗╔╝╠═╣ ║ ║╣ ║║║║╣ ║ ╠═╣║ ║ ║║╚═╗
// ╩ ╩╚═╩ ╚╝ ╩ ╩ ╩ ╚═╝ ╩ ╩╚═╝ ╩ ╩ ╩╚═╝═╩╝╚═╝
//…
}
});

View File

@ -0,0 +1,69 @@
/**
* <ajax-button>
* -----------------------------------------------------------------------------
* A button with a built-in loading spinner.
*
* @type {Component}
*
* @event click [emitted when clicked]
* -----------------------------------------------------------------------------
*/
parasails.registerComponent('ajaxButton', {
// ╔═╗╦═╗╔═╗╔═╗╔═╗
// ╠═╝╠╦╝║ ║╠═╝╚═╗
// ╩ ╩╚═╚═╝╩ ╚═╝
props: [
'syncing'
],
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: function (){
return {
//…
};
},
// ╦ ╦╔╦╗╔╦╗╦
// ╠═╣ ║ ║║║║
// ╩ ╩ ╩ ╩ ╩╩═╝
template: `
<button @click="click()" type="submit" class="btn ajax-button" :class="[syncing ? 'syncing' : '']">
<span class="button-text" v-if="!syncing"><slot name="default">Submit</slot></span>
<span class="button-loader clearfix" v-if="syncing">
<slot name="syncing-state">
<span style="top: -4px; font-size: 12px; margin: 0 2px;" class="loading-dot dot1 position-relative"><small><span class="fa fa-circle"></span></small></span>
<span style="top: -4px; font-size: 12px; margin: 0 2px;" class="loading-dot dot2 position-relative"><small><span class="fa fa-circle"></span></small></span>
<span style="top: -4px; font-size: 12px; margin: 0 2px;" class="loading-dot dot3 position-relative"><small><span class="fa fa-circle"></span></small></span>
<span style="top: -4px; font-size: 12px; margin: 0 2px;" class="loading-dot dot4 position-relative"><small><span class="fa fa-circle"></span></small></span>
</slot>
</span>
</button>
`,
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function(){
//…
},
beforeDestroy: function() {
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
click: async function(){
this.$emit('click');
},
}
});

View File

@ -0,0 +1,379 @@
/**
* <ajax-form>
* -----------------------------------------------------------------------------
* A form that talks to the backend using AJAX.
* > For example usage, take a look at one of the forms generated in a new
* > Sails app when using the "Web app" template.
*
* @type {Component}
*
* @slot default [form contents]
*
* @event update:cloudError [:cloud-error.sync="…"]
* @event update:syncing [:syncing.sync="…"]
* @event update:formErrors [:form-errors.sync="…"]
* @event submitted [emitted after the server responds with a 2xx status code]
* @event rejected [emitted after the server responds with a non-2xx status code]
* -----------------------------------------------------------------------------
*/
parasails.registerComponent('ajaxForm', {
// ╔═╗╦═╗╔═╗╔═╗╔═╗
// ╠═╝╠╦╝║ ║╠═╝╚═╗
// ╩ ╩╚═╚═╝╩ ╚═╝
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Note:
// Some of these props rely on the `.sync` modifier re-introduced in Vue 2.3.x.
// For more info, see: https://vuejs.org/v2/guide/components.html#sync-Modifier
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
props: [
'syncing',// « 2-way bound (:syncing.sync="…")
'cloudError',// « 2-way bound (:cloud-error.sync="…")
'action',
'formErrors',// « 2-way bound (:form-errors.sync="…")
'formData',
'formRules',
'handleSubmitting',// « alternative for `action`
'handleParsing',// « alternative for `formRules`+`formData`+`formErrors`
],
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: function (){
return {
//…
};
},
// ╦ ╦╔╦╗╔╦╗╦
// ╠═╣ ║ ║║║║
// ╩ ╩ ╩ ╩ ╩╩═╝
template: `
<form class="ajax-form" @submit.prevent="submit()" @keydown.meta.enter="keydownMetaEnter()">
<slot name="default"></slot>
</form>
`,
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function (){
if (this.action === undefined && this.handleSubmitting === undefined) {
throw new Error('Neither `:action` nor `:handle-submitting` was passed in to <ajax-form>, but one or the other must be provided.');
} else if (this.action !== undefined && this.handleSubmitting !== undefined) {
throw new Error('Both `:action` AND `:handle-submitting` were passed in to <ajax-form>, but only one or the other should be provided.');
} else if (this.action !== undefined && (!_.isString(this.action) || !_.isFunction(Cloud[_.camelCase(this.action)]))) {
throw new Error('Invalid `action` in <ajax-form>. `action` should be the name of a method on the `Cloud` global. For example: `action="login"` would make this form communicate using `Cloud.login()`, which corresponds to the "login" action on the server.');
} else if (this.action !== undefined && !_.isFunction(Cloud[this.action])) {
throw new Error('Unrecognized `action` in <ajax-form>. Did you mean to type `action="'+_.camelCase(this.action)+'"`? (<ajax-form> expects `action` to be provided in camelCase format. In other words, to reference the action at "api/controllers/foo/bar/do-something", use `action="doSomething"`.)');
} else if (this.handleSubmitting !== undefined && !_.isFunction(this.handleSubmitting)) {
throw new Error('Invalid `:handle-submitting` function passed to <ajax-form>. (Any chance you forgot the ":" in front of the prop name?) For example: `:handle-submitting="handleSubmittingSomeForm"`. This function should be an `async function`, and it should either throw a special exit signal or return response data from the server. (If this custom `handleSubmitting` will be doing something more complex than a single request to a server, feel free to return whatever amalgamation of data you wish.)');
}
if (this.handleParsing === undefined && this.formData === undefined) {
throw new Error('Neither `:form-data` nor `:handle-parsing` was passed in to <ajax-form>, but one or the other must be provided.');
} else if (this.handleParsing !== undefined && this.formData !== undefined) {
throw new Error('Both `:form-data` AND `:handle-parsing` were passed in to <ajax-form>, but only one or the other should be provided.');
} else if (this.handleParsing !== undefined && !_.isFunction(this.handleParsing)) {
throw new Error('Invalid `:handle-parsing` function passed to <ajax-form>. (Any chance you forgot the ":" in front of the prop name?) For example: `:handle-parsing="handleParsingSomeForm"`. This function should return a dictionary (plain JavaScript object like `{}`) of parsed form data, ready to be sent in a request to the server.');
} else if (this.formData !== undefined && (!_.isObject(this.formData) || _.isFunction(this.formData) || _.isArray(this.formData))) {
throw new Error('Invalid `:form-data` passed to <ajax-form>. (Any chance you forgot the ":" in front of the prop name?) For example: `:form-data="someFormData"`. This should reference a dictionary (plain JavaScript object like `{}`). Specifically, `:form-data` should only be used in the case where the raw data from the form in the user interface happens to correspond **EXACTLY** with the names and format of the argins that should be sent in a request to the server. (For more nuanced behavior, use `handle-parsing` instead!)');
}
if (!this.formData && (this.formRules || this.formErrors)) {
throw new Error('If `:form-rules` or `:form-errors.sync` are in use, then `:form-data` must also be passed in. (If the AJAX request doesn\'t need form data, then use an empty dictionary, i.e. `:form-data="{}"`.)');
} else if (this.formRules && !this.formErrors) {
throw new Error('If `:form-rules` are provided, then `:form-errors.sync` must also be passed in.');
}
if (this.formRules) {
var SUPPORTED_RULES = [
'required', 'isEmail', 'isIn', 'is', 'minLength', 'maxLength',
'sameAs', 'isHalfwayDecentPassword', 'custom'
];
for (let fieldName in this.formRules) {
for (let ruleName in this.formRules[fieldName]) {
if (_.contains(SUPPORTED_RULES, ruleName)) {
// OK. Good enough.
// - - - - - - - - - - - - - - - - - - - - -
// FUTURE: move rule rhs checks out here
// (so error messages from bad usage are
// logged sooner)
// - - - - - - - - - - - - - - - - - - - - -
} else {
let kebabRules = _.map(_.clone(SUPPORTED_RULES), (ruleName)=>_.kebabCase(ruleName));
let lowerCaseRules = _.map(_.clone(SUPPORTED_RULES), (ruleName)=>ruleName.toLowerCase());
let ruleIdx = (
_.indexOf(kebabRules, ruleName) === -1 ?
_.indexOf(lowerCaseRules, ruleName.toLowerCase()) === -1 ?
-1
: _.indexOf(lowerCaseRules, ruleName.toLowerCase())
: _.indexOf(kebabRules, ruleName)
);
if (ruleIdx !== -1) {
throw new Error('Did you mean `'+SUPPORTED_RULES[ruleIdx]+'`? (note the capitalization)\nYou are seeing this error because <ajax-form> encountered an unsupported (but vaguely familiar-looking) client-side validation rule: `'+ruleName+'`.');
} else {
throw new Error('<ajax-form> does not support that client-side validation rule (`'+ruleName+'`).\n [?] If you\'re unsure, visit https://sailsjs.com/support');
}
}
}//∞
}//∞
}
// Focus our "focus-first" field, if relevant.
// (but not on mobile, because it can get weird)
if(typeof bowser !== 'undefined' && !bowser.mobile && this.$find('[focus-first]').length > 0) {
this.$focus('[focus-first]');
}
},
beforeDestroy: function() {
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
keydownMetaEnter: async function() {
await this._submit();
},
submit: async function () {
await this._submit();
},
// ╔═╗╦═╗╦╦ ╦╔═╗╔╦╗╔═╗ ╔╦╗╔═╗╔╦╗╦ ╦╔═╗╔╦╗╔═╗
// ╠═╝╠╦╝║╚╗╔╝╠═╣ ║ ║╣ ║║║║╣ ║ ╠═╣║ ║ ║║╚═╗
// ╩ ╩╚═╩ ╚╝ ╩ ╩ ╩ ╚═╝ ╩ ╩╚═╝ ╩ ╩ ╩╚═╝═╩╝╚═╝
_submit: async function () {
// Prevent double-posting.
if (this.syncing) {
return;
}//•
// Clear the userland "cloudError" prop.
this.$emit('update:cloudError', '');
// Determine the argins that will be sent to the server in our request.
var argins;
if (this.handleParsing) {
// Run the provided "handle-parsing" logic.
// > This should clear out any pre-existing error messages, perform any additional
// > client-side form validation checks, and do any necessary data transformations
// > to munge the form data into the format expected by the server.
argins = this.handleParsing();
if (argins === undefined) {
// If argins came back undefined, then avast.
// (This means that parsing the form failed. Submission will not be attempted.)
return;
} else if (!_.isObject(argins) || _.isArray(argins) || _.isFunction(argins)) {
throw new Error('Invalid data returned from custom form parsing logic. (Should return a dictionary of argins, like `{}`.)');
}//•
} else if (this.formData) {
// Or use the simpler, built-in absorbtion strategy.
// > This uses the provided form data as our argins, verbatim. Then it runs
// > built-in client-side validation, if configured to do so.
argins = this.formData;
let formData = this.formData;
let formErrors = {};
for (let fieldName in this.formRules) {
let fieldValue = formData[fieldName];
for (let ruleName in this.formRules[fieldName]) {
let ruleRhs = this.formRules[fieldName][ruleName];
let violation;
let isFieldValuePresent = (
fieldValue !== undefined &&
fieldValue !== '' &&
!_.isNull(fieldValue)
);
if (ruleName === 'required' && (ruleRhs === true || ruleRhs === false)) {
// ® Must be defined, non-null, and not the empty string
if (ruleRhs === false) {
violation = false;
} else {
violation = (
!isFieldValuePresent
);
}
} else if (!isFieldValuePresent) {
// Do nothing.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Note:
// In order to allow use with optional fields, all rules except for
// `required: true` are only actually checked when the field value
// is "present" -- i.e. some value other than `null`, `undefined`,
// or `''` (empty string).
//
// > Trying to figure out how to handle a conditionally-requiured
// > field that uses one of these validations? For example, a
// > "Confirm email" re-entry field for an optional email address?
// > Just make `required` validation rule dynamic, and everything
// > else will work as expected.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
} else if (ruleName === 'isEmail' && (ruleRhs === true || ruleRhs === false)) {
// ® Must be an email address (unless falsy)
if (ruleRhs === false) {
violation = false;
} else {
violation = (
!parasails.util.isValidEmailAddress(fieldValue)
);
}
} else if (ruleName === 'isIn' && _.isArray(ruleRhs)) {
// ® Must be one of these things
violation = (
!_.contains(ruleRhs, fieldValue)
);
} else if (ruleName === 'is') {
// ® Must be exactly this thing (useful for required checkboxes)
violation = (
ruleRhs !== fieldValue
);
} else if (ruleName === 'minLength' && _.isNumber(ruleRhs)) {
// ® Must consist of at least this many characters
violation = (
!_.isString(fieldValue) ||
fieldValue.length < ruleRhs
);
} else if (ruleName === 'maxLength' && _.isNumber(ruleRhs)) {
// ® Must consist of no more than this many characters
violation = (
!_.isString(fieldValue) ||
fieldValue.length > ruleRhs
);
} else if (ruleName === 'sameAs' && ruleRhs !== '' && _.isString(ruleRhs)) {
// ® Must match the value in another field
let otherFieldName = ruleRhs;
let otherFieldValue = formData[otherFieldName];
violation = (
otherFieldValue !== fieldValue
);
} else if (ruleName === 'isHalfwayDecentPassword' && (ruleRhs === true || ruleRhs === false)) {
// ® Must be a halfway-decent password
// > This is an arbitrary distinction, so change it if you want.
// > Just... please use common sense. And try to avoid engaging
// > in security theater.
if (ruleRhs === false) {
violation = false;
} else {
violation = (
(!_.isString(fieldValue) && !_.isNumber(fieldValue)) ||
fieldValue.length < 6
);
}
} else if (ruleName === 'custom' && _.isFunction(ruleRhs)) {
// ® Provided function must return truthy when invoked with the value.
try {
violation = (
!ruleRhs(fieldValue)
);
} catch (err) {
console.warn(err);
violation = true;
}
} else {
throw new Error('Cannot interpret client-side validation rule (`'+ruleName+'`) because the configuration provided for it is not recognized by <ajax-form>.\n [?] If you\'re unsure, visit https://sailsjs.com/support');
}
// If a rule violation was detected, then set it as a form error
// and break out of the `for` loop to continue on to the next field.
// (We only track one form error per field.)
if (violation) {
formErrors[fieldName] = ruleName;
break;
}//˚
}//∞
}//∞
// Whether there are any errors or not, update userland "formErrors" prop
// so that the markup reflects the new reality (i.e. inline validation errors
// either get rendered or go away.)
this.$emit('update:formErrors', formErrors);
// If there were any form errors, avast. (Submission will not be attempted.)
if (Object.keys(formErrors).length > 0) {
// In development mode, also log a warning
// (so that it's clear what's going on just in case validation
// states/messages are not hooked up in the HTML template)
if (this._environment !== 'production') {
console.warn(`<ajax-form> encountered ${Object.keys(formErrors).length} form error${Object.keys(formErrors).length !== 1 ? 's' : ''} when performing client-side validation of "form-data" versus "form-rules". (Note: This warning is only here to assist with debugging-- it will not be displayed in production. If you're unsure, check out https://sailsjs.com/support for more resources.)`, _.cloneDeep(formErrors));
}//fi
return;
}//•
}//fi (determining argins)
// Set syncing state to `true` on userland "syncing" prop.
this.$emit('update:syncing', true);
// Submit the form
var failedWithCloudExit;
var rawErrorFromCloudSDK;
var result;
if (this.handleSubmitting) {
try {
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// FUTURE: Consider cloning the argins ahead of time to prevent accidental mutation of form data.
// (but remember argins could contain File instances that might not be clone-able)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
result = await this.handleSubmitting(argins);
} catch (err) {
rawErrorFromCloudSDK = err;
if (_.isString(err) && err !== '') {
failedWithCloudExit = err;
} else if (_.isError(err) && err.exit) {
failedWithCloudExit = err.exit;
} else if (_.isObject(err) && !_.isError(err) && !_.isArray(err) && !_.isFunction(err) && Object.keys(err)[0] && _.isString(Object.keys(err)[0])) {
failedWithCloudExit = Object.keys(err)[0];
} else {
throw err;
}
}
} else {
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// FUTURE: Potentially filter unused data in argins here before proceeding
// (assuming cloudsdk has that information available)
// Or better yet, just have `Cloud.*.with()` take care of that automatically.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
result = await Cloud[this.action].with(argins)
.tolerate((err)=>{
rawErrorFromCloudSDK = err;
failedWithCloudExit = err.exit || 'error';
});
}
// When a cloud error occurs, tolerate it, but set the userland "cloudError"
// prop accordingly.
if (failedWithCloudExit) {
this.$emit('update:cloudError', failedWithCloudExit);
}
// Set syncing state to `false` on userland "syncing" prop.
this.$emit('update:syncing', false);
// If the server says we were successful, then emit the "submitted" event.
if (!failedWithCloudExit) {
this.$emit('submitted', result);
} else {
this.$emit('rejected', rawErrorFromCloudSDK);
}
},
}
});

View File

@ -0,0 +1,93 @@
/**
* <cloud-error>
* -----------------------------------------------------------------------------
*
* @type {Component}
*
* --- SLOTS: ---
* @slot default
*
* --- EVENTS EMITTED: ---
* N/A
*
* -----------------------------------------------------------------------------
*/
parasails.registerComponent('cloud-error', {
// ╔═╗╦ ╦╔╗ ╦ ╦╔═╗ ╔═╗╦═╗╔═╗╔═╗╔═╗
// ╠═╝║ ║╠╩╗║ ║║ ╠═╝╠╦╝║ ║╠═╝╚═╗
// ╩ ╚═╝╚═╝╩═╝╩╚═╝ ╩ ╩╚═╚═╝╩ ╚═╝
props: [
'withoutMargins'
],
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╦╔╗╔╔╦╗╔═╗╦═╗╔╗╔╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ║║║║ ║ ║╣ ╠╦╝║║║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╩╝╚╝ ╩ ╚═╝╩╚═╝╚╝╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: function () {
return {
beWithoutMargins: undefined,
};
},
beforeMount: function() {
if (this.withoutMargins !== undefined && typeof this.withoutMargins !== 'boolean') {
throw new Error('<cloud-error> received an invalid `withoutMargins`. If provided, this prop should be precisely true or false.');
}
this.beWithoutMargins = this.withoutMargins||false;
},
// ╦ ╦╔╦╗╔╦╗╦
// ╠═╣ ║ ║║║║
// ╩ ╩ ╩ ╩ ╩╩═╝
template: `
<div>
<p :class="{ 'm-0': beWithoutMargins }" class="text-danger"><slot name="default">An error occured while processing your request. Please check your information and try again, or <a href="/contact">contact support</a> if the error persists.</slot></p>
</div>
`,
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
mounted: async function () {
},
watch: {
withoutMargins: function(unused) { throw new Error('Changes to `withoutMargins` are not currently supported in <cloud-error>!'); },
},
beforeDestroy: function() {
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
// ╦╔╗╔╔╦╗╔═╗╦═╗╔╗╔╔═╗╦ ╔═╗╦ ╦╔═╗╔╗╔╔╦╗ ╦ ╦╔═╗╔╗╔╔╦╗╦ ╔═╗╦═╗╔═╗
// ║║║║ ║ ║╣ ╠╦╝║║║╠═╣║ ║╣ ╚╗╔╝║╣ ║║║ ║ ╠═╣╠═╣║║║ ║║║ ║╣ ╠╦╝╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╝╚╝╩ ╩╩═╝ ╚═╝ ╚╝ ╚═╝╝╚╝ ╩ ╩ ╩╩ ╩╝╚╝═╩╝╩═╝╚═╝╩╚═╚═╝
//…
// ╔═╗╦ ╦╔╗ ╦ ╦╔═╗ ╔╦╗╔═╗╔╦╗╦ ╦╔═╗╔╦╗╔═╗
// ╠═╝║ ║╠╩╗║ ║║ ║║║║╣ ║ ╠═╣║ ║ ║║╚═╗
// ╩ ╚═╝╚═╝╩═╝╩╚═╝ ╩ ╩╚═╝ ╩ ╩ ╩╚═╝═╩╝╚═╝
// > Public methods are rarely exposed by Vue components, but sometimes they
// > are an important escape hatch. They are callable via something like
// > `this.$refs.componentNameInCamelCase.doSomething())`, and, by convention,
// > are always prefixed with "do".
// N/A
// ╔═╗╦═╗╦╦ ╦╔═╗╔╦╗╔═╗ ╔╦╗╔═╗╔╦╗╦ ╦╔═╗╔╦╗╔═╗
// ╠═╝╠╦╝║╚╗╔╝╠═╣ ║ ║╣ ║║║║╣ ║ ╠═╣║ ║ ║║╚═╗
// ╩ ╩╚═╩ ╚╝ ╩ ╩ ╩ ╚═╝ ╩ ╩╚═╝ ╩ ╩ ╩╚═╝═╩╝╚═╝
//…
}
});

View File

@ -0,0 +1,130 @@
/**
* <js-timestamp>
* -----------------------------------------------------------------------------
* A human-readable, self-updating "timeago" timestamp, with some special rules:
*
* • Within 24 hours, displays in "timeago" format.
* • Within a month, displays month, day, and time of day.
* • Within a year, displays just the month and day.
* • Older/newer than that, displays the month and day with the full year.
*
* @type {Component}
* -----------------------------------------------------------------------------
*/
parasails.registerComponent('jsTimestamp', {
// ╔═╗╦═╗╔═╗╔═╗╔═╗
// ╠═╝╠╦╝║ ║╠═╝╚═╗
// ╩ ╩╚═╚═╝╩ ╚═╝
props: [
'at',// « The JS timestamp to format
'short',// « Whether to shorten the formatted date by not including the time of day (may only be used with timeago, and even then only applicable in certain situations)
'format',// « one of: 'calendar', 'timeago' (defaults to 'timeago'. Otherwise, the "calendar" format displays data as US-style calendar dates with a four-character year, separated by dashes. In other words: "MM-DD-YYYY")
],
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: function (){
return {
formatType: undefined,
formattedTimestamp: '',
interval: undefined
};
},
// ╦ ╦╔╦╗╔╦╗╦
// ╠═╣ ║ ║║║║
// ╩ ╩ ╩ ╩ ╩╩═╝
template: `
<span>{{formattedTimestamp}}</span>
`,
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
if (this.at === undefined) {
throw new Error('Incomplete usage of <js-timestamp>: Please specify `at` as a JS timestamp (i.e. epoch ms, a number). For example: `<js-timestamp :at="something.createdAt">`');
}
if(this.format === undefined) {
this.formatType = 'timeago';
} else {
if(!_.contains(['calendar', 'timeago'], this.format)) { throw new Error('Unsupported `format` ('+this.format+') passed in to the JS timestamp component! Must be either \'calendar\' or \'timeago\'.'); }
this.formatType = this.format;
}
// If timeago timestamp, update the timestamp every minute.
if(this.formatType === 'timeago') {
this._formatTimeago();
this.interval = setInterval(async()=>{
try {
this._formatTimeago();
await this.forceRender();
} catch (err) {
err.raw = err;
throw new Error('Encountered unexpected error while attempting to automatically re-render <js-timestamp> in the background, as the seconds tick by. '+err.message);
}
},60*1000);//œ
}
// If calendar timestamp, just set it the once.
// (Also don't allow usage with `short`.)
if(this.formatType === 'calendar') {
this.formattedTimestamp = moment(this.at).format('MM-DD-YYYY');
if (this.short) {
throw new Error('Invalid usage of <js-timestamp>: Cannot use `short="true"` at the same time as `format="calendar"`.');
}
}
},
beforeDestroy: function() {
if(this.formatType === 'timeago') {
clearInterval(this.interval);
}
},
watch: {
at: function() {
// Render to account for after-mount programmatic changes to `at`.
if(this.formatType === 'timeago') {
this._formatTimeago();
} else if(this.formatType === 'calendar') {
this.formattedTimestamp = moment(this.at).format('MM-DD-YYYY');
} else {
throw new Error();
}
}
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
_formatTimeago: function() {
var now = new Date().getTime();
var timeDifference = Math.abs(now - this.at);
// If the timestamp is less than a day old, format as time ago.
if(timeDifference < 1000*60*60*24) {
this.formattedTimestamp = moment(this.at).fromNow();
} else {
// If the timestamp is less than a month-ish old, we'll include the
// time of day in the formatted timestamp.
let includeTime = !this.short && timeDifference < 1000*60*60*24*31;
// If the timestamp is from a different year, we'll include the year
// in the formatted timestamp.
let includeYear = moment(now).format('YYYY') !== moment(this.at).format('YYYY');
this.formattedTimestamp = moment(this.at).format('MMMM DD'+(includeYear ? ', YYYY' : '')+(includeTime ? ' [at] h:mma' : ''));
}
}
}
});

View File

@ -0,0 +1,226 @@
/**
* <modal>
* -----------------------------------------------------------------------------
* A modal dialog pop-up.
*
* > Be careful adding other Vue.js lifecycle callbacks in this file! The
* > finnicky combination of Vue transitions and bootstrap modal animations used
* > herein work, and are very well-tested in practical applications. But any
* > changes to that specific cocktail could be unpredictable, with unsavory
* > consequences.
*
* @type {Component}
*
* @event close [emitted when the closing process begins]
* @event opened [emitted when the opening process is completely done]
* -----------------------------------------------------------------------------
*/
parasails.registerComponent('modal', {
// ╔═╗╦═╗╔═╗╔═╗╔═╗
// ╠═╝╠╦╝║ ║╠═╝╚═╗
// ╩ ╩╚═╚═╝╩ ╚═╝
props: [
'hideCloseButton'//« removes the default "x" button
],
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: function (){
return {
// Spinlock used for preventing trying to close the bootstrap modal more than once.
// (in practice it doesn't seem to hurt anything if it tries to close more than once,
// but still.... better safe than sorry!)
_bsModalIsAnimatingOut: false,
isMobileSafari: false,//« more on this below
originalScrollPosition: undefined,//« more on this below
};
},
// ╦ ╦╔╦╗╔╦╗╦
// ╠═╣ ║ ║║║║
// ╩ ╩ ╩ ╩ ╩╩═╝
template: `
<transition name="modal" v-on:leave="leave" v-bind:css="false">
<div class="modal fade" tabindex="-1" role="dialog">
<div class="petticoat"></div>
<div class="modal-dialog custom-width position-relative" role="document" purpose="modal-dialog">
<div class="modal-content" purpose="modal-content">
<button type="button" style="top: 5px; right: 0; font-size: 28px; line-height: 1;" class="py-2 px-3 position-absolute" data-dismiss="modal" aria-label="Close" purpose="modal-close-button" v-if="!hideCloseButton">&times;</button>
<slot></slot>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</transition>
`,
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
// If this is mobile safari, make note of it.
this.isMobileSafari = (typeof bowser !== 'undefined') && bowser.mobile && bowser.safari;
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// ^^So there's a bug in mobile safari that misplaces the caret when the keyboard opening
// causes the page to scroll, so we need to do some special tricks to keep it from getting ugly.
// It's only in iOS 11... we think. Hopefully it will be fixed.
// In the mean time, we have to get wacky.
//
// > More info about the bug here:
// > https://github.com/twbs/bootstrap/issues/24835#issuecomment-345974819
// > https://stackoverflow.com/questions/46567233/how-to-fix-the-ios-11-input-element-in-fixed-modals-bug?rq=1
//
// FUTURE: maybe the bug will be fixed and we can remove this someday?
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
if(this.isMobileSafari) {
// Get our original scroll position before opening the modal and save it for later.
this.originalScrollPosition = $(window).scrollTop();
}
},
mounted: function(){
// ^^ Note that this is not an `async function`.
// This is just to be safe, since the timing here is a little tricky w/ the
// animations and the fact that we're integrating with Bootstrap's modal.
// (That said, it might work fine-- just hasn't been extensively tested.)
// Immediately call out to the Bootstrap modal and tell it to show itself.
$(this.$el).modal({
// Set the modal backdrop to the 'static' option, which means it doesn't close the modal
// when clicked.
backdrop: 'static',
show: true
});
// Attach listener for underlying custom modal closing event,
// and when that happens, have Vue emit a custom "close" event.
// (Note: This isn't just for convenience-- it's crucial that
// the parent logic can use this event to update its scope.)
$(this.$el).on('hide.bs.modal', ()=>{
// Undo any mobile safari workarounds we may have added.
// (i.e. shed the wackiness)
if(this.isMobileSafari) {
// Remove style overrides on our modal dialog.
$(this.$el).css({
'overflow-y': '',
'position': '',
'left': '',
'top': '',
});
// Beckon to our siblings so they come out of hiding
this.$get().parent().children().not(this.$el).css({
'display': ''
});
// Scroll to our original position when the modal was summoned.
window.scrollTo(0, this.originalScrollPosition);
}//fi
this._bsModalIsAnimatingOut = true;
this.$emit('close');
});//œ
// Attach listener for underlying custom modal "opened" event,
// and when that happens, have Vue emit our own custom "opened" event.
// This is so we know when the entry animation has completed, allows
// us to do cool things like auto-focus the first input in a form modal.
$(this.$el).on('shown.bs.modal', ()=>{
// If this is mobile safari, let's get wacky.
if(this.isMobileSafari) {
// Scroll to the top of the page.
window.scrollTo(0, 0);
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// ^^FUTURE: Don't actually do this -- instead, try setting `top` of the
// modal to whatever the original scrollTop of our window was. This
// eliminates the need for auto-scrolling to the top and ripping you out
// of the context you were in before the modal opens. It would also allow
// us to keep the nice animation when opening/closing modals on iOS.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Hide siblings to lop off any extra space at the bottom.
this.$get().parent().children().not(this.$el).css({
'display': 'none'
});
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// ^^FUTURE: Instead of just hiding siblings, which isn't perfect and won't
// always work for everyone, try grabbing outerHeight of the modal element
// and using that to set an explicit height for the body.
// (but also be sure to handle the case where the body is short!)
// But for now, this should work as long as we have sticky footer styles.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Hard code some style overrides on our modal dialog.
// Without these, it gets weird.
$(this.$el).css({
'overflow-y': 'auto!important',
'position': 'absolute',
'left': '0',
'top': '0',
});
}//fi
// Focus our "focus-first" field, if relevant.
// (but not on mobile, because it can get weird)
if(typeof bowser !== 'undefined' && !bowser.mobile && this.$find('[focus-first]').length > 0) {
this.$focus('[focus-first]');
}
this.$emit('opened');
$(this.$el).off('shown.bs.modal');
});//ƒ
},
// ^Note that there is no `beforeDestroy()` lifecycle callback in this
// component. This is on purpose, since the timing vs. `leave()` gets tricky.
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
leave: function (el, done) {
// > Note: This function signature comes from Vue.js's transition system.
// > It will likely be replaced with `async function (el){…}` in a future
// > release of Vue/Sails.js (i.e. no callback argument).
// If this shutting down was spawned by the bootstrap modal's built-in logic,
// then we'll have already begun animating the modal shut. So we check our
// spinlock to make sure. If it turns out that we HAVEN'T started that process
// yet, then we go ahead and start it now.
if (!this._bsModalIsAnimatingOut) {
$(this.$el).modal('hide');
}//fi
// When the bootstrap modal finishes animating into nothingness, unbind all
// the DOM events used by bootstrap, and then call `done()`, which passes
// control back to Vue and lets it finish the job (i.e. afterLeave).
//
// > Note that the other lifecycle events like `destroyed` were actually
// > already fired at this point.
// >
// > Also note that, since we're potentially long past the `destroyed` point
// > of the lifecycle here, we can't call `.$emit()` anymore either. So,
// > for example, we wouldn't be able to emit a "fullyClosed" event --
// > because by the time it'd be appropriate to emit the Vue event, our
// > context for triggering it (i.e. the relevant instance of this component)
// > will no longer be capable of emitting custom Vue events (because by then,
// > it is no longer "reactive").
// >
// > For more info, see:
// > https://github.com/vuejs/vue-router/issues/1302#issuecomment-291207073
$(this.$el).on('hidden.bs.modal', ()=>{
$(this.$el).off('hide.bs.modal');
$(this.$el).off('hidden.bs.modal');
$(this.$el).off('shown.bs.modal');
done();
});//_∏_
},
}
});

View File

@ -0,0 +1,192 @@
/**
* <stripe-card-element>
* -----------------------------------------------------------------------------
* A wrapper for the Stripe Elements "card" component (https://stripe.com/elements)
*
* @type {Component}
*
* @event update:busy [:busy.sync="…"]
* @event input [emitted when the stripe token changes (supports v-model)]
* @event invalidated [emitted when the field had been changed to include an invalid value]
* @event validated [emitted when the field had been changed to include a valid value]
* -----------------------------------------------------------------------------
*/
parasails.registerComponent('stripeCardElement', {
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔═╗╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠╣ ╠═╣║ ║╣
// ╩╝╚╝ ╩ ╚═╝╩╚═╚ ╩ ╩╚═╝╚═╝
props: [
'stripePublishableKey',
'isErrored',
'errorMessage',//« optional custom error message to display
'value',//« the v-model passed in.
'busy',
'showExisting',// « whether to show the existing card info passed into `v-model`
],
// ╔╦╗╔═╗╦═╗╦╔═╦ ╦╔═╗
// ║║║╠═╣╠╦╝╠╩╗║ ║╠═╝
// ╩ ╩╩ ╩╩╚═╩ ╩╚═╝╩
template: `
<div>
<div v-if="existingCardData">
<span class="existing-card">{{existingCardData.billingCardBrand}} ending in <strong>{{existingCardData.billingCardLast4}}</strong></span>
<small class="new-card-text d-inline-block ml-2">(Want to use a different card ? <a class="text-primary change-card-button" type="button" @click="clickChangeExistingCard()">Click here</a>.)</small>
</div>
<div class="card-element-wrapper" :class="[existingCardData ? 'secret-card-element-wrapper' : '', isErrored ? 'is-invalid' : '']" :aria-hidden="existingCardData ? true : false">
<div class="card-element form-control" :class="isErrored ? 'is-invalid' : ''" card-element></div>
<span class="status-indicator syncing text-primary" :class="[isSyncing ? '' : 'hidden']"><span class="fa fa-spinner"></span></span>
<span class="status-indicator text-primary" :class="[isValidated ? '' : 'hidden']"><span class="fa fa-check-circle"></span></span>
<div class="invalid-feedback" v-if="!isValidated && isErrored">{{ errorMessage || 'Please check your card info.'}}</div>
</div>
</div>
`,
// ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ╚═╗ ║ ╠═╣ ║ ║╣
// ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: function (){
return {
isSyncing: false,
isValidated: false,
existingCardData: undefined,
// The underlying Stripe instance
_stripe: undefined,
// The underlying Stripe elements instance
_elements: undefined,
// The underlying Stripe element instance this component creates as it mounts.
_element: undefined,
};
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
// Initialize an instance of Stripe "elements", which we'll pass into
// our <stripe-element> component instances.
// (We also save an instance of `stripe` for use below.)
this._stripe = Stripe(this.stripePublishableKey);
this._elements = this._stripe.elements();
},
mounted: function (){
if(this.showExisting && _.isObject(this.value) && this.value.stripeToken && this.value.billingCardBrand && this.value.billingCardLast4) {
this.existingCardData = _.extend({}, this.value);
}
this._element = this._elements.create('card', {
// Classes
// > https://stripe.com/docs/js/elements_object/create_element?type=card#elements_create-options-classes
classes: {
// When the iframe is focused, attach the "pseudofocused" class
// to our wrapper <div>.
focus: 'pseudofocused'
},
// iframe styles:
// > https://stripe.com/docs/js/appendix/style?type=card
// You can update this section to match your website's theme
style: {
base: {
lineHeight: '36px',
fontSize: '16px',
color: '#495057',
iconColor: '#14acc2',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
'::placeholder': {
color: '#6c757d',
},
},
invalid: {
color: '#dc3545',
},
},
});
this._element.mount($(this.$el).find('[card-element]')[0]);
// When a change occurs, immediately clear the token by
// emitting an input event with '', show a loading spinner, and start
// fetching a new token. Then in userland, the validation error for a missing
// card becomes something reasonable that implies that we may not have finished
// getting it yet, so hold your horses.
this._element.on('change', (stripeEvent)=> {
// If there is an error, set the v-model to be empty.
if(stripeEvent.error) {
this.$emit('input', '');
} else if(stripeEvent.complete) {
// If the field is complete, (aka valid), fetch a token and set that on the v-model
// (first clearing out the v-model, so this won't be considered valid yet e.g. if it was just changed.
if(this.isSyncing) { return; }
this.$emit('');
this.isSyncing = true;
this.$emit('update:busy', true);
this.isValidated = false;
this._fetchNewToken();
} else {
// FUTURE: possibly handle other events, if necessary.
}//fi
});//œ
},
beforeDestroy: function (){
// Note: There isn't any documented way to tear down a `stripe` instance.
// Same thing for the `elements` instance. Only individual "element" instances
// can be cleaned up after, using `.unmount()`.
this._element.unmount();
},
// ╔═╗╦ ╦╔═╗╔╗╔╔╦╗╔═╗
// ║╣ ╚╗╔╝║╣ ║║║ ║ ╚═╗
// ╚═╝ ╚╝ ╚═╝╝╚╝ ╩ ╚═╝
methods: {
clickChangeExistingCard: function() {
this.existingCardData = undefined;
this.$emit('input', '');
},
// Public method for fetching a fresh token (e.g. if card is declined)
doGetNewToken: function() {
this.isSyncing = true;
this.$emit('update:busy', true);
this.isValidated = false;
this.$emit('input', '');
this._fetchNewToken();
},
_fetchNewToken: function() {
this._getStripeTokenFromCardElement(this._stripe, this._element)
.then((paymentSourceInfo)=>{
this.isSyncing = false;
this.$emit('update:busy', false);
this.isValidated = true;
this.$emit('input', paymentSourceInfo);
}).catch((err)=>{
this.isSyncing = false;
this.$emit('update:busy', false);
this.isValidated = false;
// This error is only relevant if something COMPLETELY unexpected goes wrong,
// in which case we want to actually know about that.
throw err;
});//_∏_
},
_getStripeTokenFromCardElement: function(stripeInstance, stripeElement) {
// Build a Promise & send it back as our "thenable" (AsyncFunction's return value).
// (this is necessary b/c we're wrapping an api that isn't `await`-compatible)
return new Promise((resolve, reject)=>{
try {
// Create a stripe token using the Stripe "element".
stripeInstance.createToken(stripeElement)
.then((result)=>{
// Silently ignore the case where the field is empty, or if there are
// card validation issues.
if(!result || result.error) {
resolve();
return;
}
// Send back the token & payment info.
resolve({
stripeToken: result.token.id,
billingCardBrand: result.token.card.brand,
billingCardLast4: result.token.card.last4,
billingCardExpMonth: result.token.card.exp_month,
billingCardExpYear: result.token.card.exp_year
});
});
} catch (err) {
console.error('Could not obtain Stripe token:', err);
reject(err);
}
});//_∏_
}
}
});