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

61
assets/.eslintrc Normal file
View File

@@ -0,0 +1,61 @@
{
// ╔═╗╔═╗╦ ╦╔╗╔╔╦╗┬─┐┌─┐ ┌─┐┬ ┬┌─┐┬─┐┬─┐┬┌┬┐┌─┐
// ║╣ ╚═╗║ ║║║║ ║ ├┬┘│ │ │└┐┌┘├┤ ├┬┘├┬┘│ ││├┤
// o╚═╝╚═╝╩═╝╩╝╚╝ ╩ ┴└─└─┘ └─┘ └┘ └─┘┴└─┴└─┴─┴┘└─┘
// ┌─ ┌─┐┌─┐┬─┐ ┌┐ ┬─┐┌─┐┬ ┬┌─┐┌─┐┬─┐ ┬┌─┐ ┌─┐┌─┐┌─┐┌─┐┌┬┐┌─┐ ─┐
// │ ├┤ │ │├┬┘ ├┴┐├┬┘│ ││││└─┐├┤ ├┬┘ │└─┐ ├─┤└─┐└─┐├┤ │ └─┐ │
// └─ └ └─┘┴└─ └─┘┴└─└─┘└┴┘└─┘└─┘┴└─ └┘└─┘ ┴ ┴└─┘└─┘└─┘ ┴ └─┘ ─┘
// > An .eslintrc configuration override for use in the `assets/` directory.
//
// This extends the top-level .eslintrc file, primarily to change the set of
// supported globals, as well as any other relevant settings. (Since JavaScript
// code in the `assets/` folder is intended for the browser habitat, a different
// set of globals is supported. For example, instead of Node.js/Sails globals
// like `sails` and `process`, you have access to browser globals like `window`.)
//
// (See .eslintrc in the root directory of this Sails app for more context.)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
"extends": [
"../.eslintrc"
],
"env": {
"browser": true,
"node": false
},
"parserOptions": {
"ecmaVersion": 8
//^ If you are not using a transpiler like Babel, change this to `5`.
},
"globals": {
// Allow any window globals you're relying on here; e.g.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
"SAILS_LOCALS": true,
"io": true,
"Cloud": true,
"parasails": true,
"$": true,
"_": true,
"bowser": true,
"StripeCheckout": true,
"Stripe": true,
"Vue": true,
"VueRouter": true,
"moment": true,
// "google": true,
// ...etc.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Make sure backend globals aren't indadvertently tolerated in our client-side JS:
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
"sails": false,
"User": false
// ...and any other backend globals (e.g. `"Organization": false`)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1987
assets/dependencies/cloud.js Normal file

File diff suppressed because it is too large Load Diff

2337
assets/dependencies/fontawesome.css vendored Normal file

File diff suppressed because it is too large Load Diff

4
assets/dependencies/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

12596
assets/dependencies/lodash.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

10979
assets/dependencies/vue.js Normal file

File diff suppressed because it is too large Load Diff

BIN
assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 B

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
assets/images/hero-ship.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
assets/images/hero-sky.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

19
assets/js/cloud.setup.js Normal file
View File

@@ -0,0 +1,19 @@
/**
* cloud.setup.js
*
* Configuration for this Sails app's generated browser SDK ("Cloud").
*
* Above all, the purpose of this file is to provide endpoint definitions,
* each of which corresponds with one particular route+action on the server.
*
* > This file was automatically generated.
* > (To regenerate, run `sails run rebuild-cloud-sdk`)
*/
Cloud.setup({
/* eslint-disable */
methods: {"confirmEmail":{"verb":"GET","url":"/email/confirm","args":["token"]},"logout":{"verb":"GET","url":"/api/v1/account/logout","args":[]},"updatePassword":{"verb":"PUT","url":"/api/v1/account/update-password","args":["password"]},"updateProfile":{"verb":"PUT","url":"/api/v1/account/update-profile","args":["fullName","emailAddress"]},"updateBillingCard":{"verb":"PUT","url":"/api/v1/account/update-billing-card","args":["stripeToken","billingCardLast4","billingCardBrand","billingCardExpMonth","billingCardExpYear"]},"login":{"verb":"PUT","url":"/api/v1/entrance/login","args":["emailAddress","password","rememberMe"]},"signup":{"verb":"POST","url":"/api/v1/entrance/signup","args":["emailAddress","password","fullName"]},"sendPasswordRecoveryEmail":{"verb":"POST","url":"/api/v1/entrance/send-password-recovery-email","args":["emailAddress"]},"updatePasswordAndLogin":{"verb":"POST","url":"/api/v1/entrance/update-password-and-login","args":["password","token"]},"deliverContactFormMessage":{"verb":"POST","url":"/api/v1/deliver-contact-form-message","args":["emailAddress","topic","fullName","message"]},"observeMySession":{"verb":"POST","url":"/api/v1/observe-my-session","args":[],"protocol":"io.socket"}}
/* eslint-enable */
});

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);
}
});//_∏_
}
}
});

View File

@@ -0,0 +1,106 @@
parasails.registerPage('account-overview', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
isBillingEnabled: false,
hasBillingCard: false,
// Syncing/loading states for this page.
syncingOpenCheckout: false,
syncingUpdateCard: false,
syncingRemoveCard: false,
// For <ajax-form>
formData: { /* … */ },
formRules: { /* … */ },
formErrors: { /* … */ },
cloudError: '',
syncing: '',
// For <modal>:
modal: '',
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function (){
_.extend(this, window.SAILS_LOCALS);
this.isBillingEnabled = !!this.stripePublishableKey;
// Determine whether there is billing info for this user.
this.me.hasBillingCard = (
this.me.billingCardBrand &&
this.me.billingCardLast4 &&
this.me.billingCardExpMonth &&
this.me.billingCardExpYear
);
},
mounted: async function() {
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
clickUpdateBillingCardButton: function() {
this.modal = 'update-billing-card';
this.formData = { newPaymentSource: undefined };
this.formRules = { newPaymentSource: {required: true}};
},
closeModal: async function() {
// Dismiss modal
this.modal = '';
await this._resetForms();
},
handleSubmittingUpdateBillingCard: async function(argins) {
var newPaymentSource = argins.newPaymentSource;
await Cloud.updateBillingCard.with(newPaymentSource);
},
submittedUpdateBillingCard: async function() {
Object.assign(this.me, _.pick(this.formData.newPaymentSource, ['billingCardLast4', 'billingCardBrand', 'billingCardExpMonth', 'billingCardExpYear']));
this.me.hasBillingCard = true;
// Dismiss modal
this.modal = '';
await this._resetForms();
},
_resetForms: async function() {
this.cloudError = '';
this.formData = {};
this.formRules = {};
this.formErrors = {};
await this.forceRender();
},
clickRemoveCardButton: async function() {
this.modal = 'remove-billing-card';
this.formData.stripeToken = '';
},
submittedRemoveCardForm: async function() {
// Update billing info on success.
this.me.billingCardLast4 = undefined;
this.me.billingCardBrand = undefined;
this.me.billingCardExpMonth = undefined;
this.me.billingCardExpYear = undefined;
this.me.hasBillingCard = false;
// Close the modal and clear it out.
this.closeModal();
},
}
});

View File

@@ -0,0 +1,50 @@
parasails.registerPage('edit-password', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
// Main syncing/loading state for this page.
syncing: false,
// Form data
formData: { /* … */ },
// For tracking client-side validation errors in our form.
// > Has property set to `true` for each invalid property in `formData`.
formErrors: { /* … */ },
// Form rules
formRules: {
password: {required: true},
confirmPassword: {required: true, sameAs: 'password'},
},
// Server error state for the form
cloudError: '',
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function() {
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
submittedForm: async function() {
// Redirect to a different web page on success.
// > (Note that we re-enable the syncing state here. This is on purpose--
// > to make sure the spinner stays there until the page navigation finishes.)
this.syncing = true;
window.location = '/account';
},
}
});

View File

@@ -0,0 +1,52 @@
parasails.registerPage('edit-profile', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
// Main syncing/loading state for this page.
syncing: false,
// Form data
formData: { /* … */ },
// For tracking client-side validation errors in our form.
// > Has property set to `true` for each invalid property in `formData`.
formErrors: { /* … */ },
// Form rules
formRules: {
fullName: {required: true},
emailAddress: {required: true, isEmail: true},
},
// Server error state for the form
cloudError: '',
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
// Set the form data.
this.formData.fullName = this.me.fullName;
this.formData.emailAddress = this.me.emailChangeCandidate ? this.me.emailChangeCandidate : this.me.emailAddress;
},
mounted: async function() {
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
submittedForm: async function() {
// Redirect to the account page on success.
// > (Note that we re-enable the syncing state here. This is on purpose--
// > to make sure the spinner stays there until the page navigation finishes.)
this.syncing = true;
window.location = '/account';
},
}
});

View File

@@ -0,0 +1,54 @@
parasails.registerPage('contact', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
// Main syncing/loading state for this page.
syncing: false,
// Form data
formData: { /* … */ },
// For tracking client-side validation errors in our form.
// > Has property set to `true` for each invalid property in `formData`.
formErrors: { /* … */ },
// Form rules
formRules: {
emailAddress: {isEmail: true, required: true},
fullName: {required: true},
topic: {required: true},
message: {required: true},
},
// Server error state for the form
cloudError: '',
// Success state when form has been submitted
cloudSuccess: false,
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function() {
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
submittedForm: async function() {
// Show the success message.
this.cloudSuccess = true;
},
}
});

View File

@@ -0,0 +1,59 @@
parasails.registerPage('welcome', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
modal: '',
pageLoadedAt: Date.now()
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function() {
//…
},
// ╦ ╦╦╦═╗╔╦╗╦ ╦╔═╗╦ ╔═╗╔═╗╔═╗╔═╗╔═╗
// ╚╗╔╝║╠╦╝ ║ ║ ║╠═╣║ ╠═╝╠═╣║ ╦║╣ ╚═╗
// ╚╝ ╩╩╚═ ╩ ╚═╝╩ ╩╩═╝ ╩ ╩ ╩╚═╝╚═╝╚═╝
// Configure deep-linking (aka client-side routing)
virtualPagesRegExp: /^\/welcome\/?([^\/]+)?\/?/,
afterNavigate: async function(virtualPageSlug){
// `virtualPageSlug` is determined by the regular expression above, which
// corresponds with `:unused?` in the server-side route for this page.
switch (virtualPageSlug) {
case 'hello':
this.modal = 'example';
break;
default:
this.modal = '';
}
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
clickOpenExampleModalButton: async function() {
this.goto('/welcome/hello');
// Or, without deep links, instead do:
// ```
// this.modal = 'example';
// ```
},
closeExampleModal: async function() {
this.goto('/welcome');
// Or, without deep links, instead do:
// ```
// this.modal = '';
// ```
},
}
});

View File

@@ -0,0 +1,25 @@
parasails.registerPage('confirmed-email', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
//…
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function(){
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
//…
}
});

View File

@@ -0,0 +1,49 @@
parasails.registerPage('forgot-password', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
// Main syncing/loading state for this page.
syncing: false,
// Form data
formData: { /* … */ },
// For tracking client-side validation errors in our form.
// > Has property set to `true` for each invalid property in `formData`.
formErrors: { /* … */ },
// Form rules
formRules: {
emailAddress: {required: true, isEmail: true},
},
// Server error state for the form
cloudError: '',
// Success state when form has been submitted
cloudSuccess: false,
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function() {
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
submittedForm: async function() {
// If it worked, show the success message.
this.cloudSuccess = true;
},
}
});

View File

@@ -0,0 +1,53 @@
parasails.registerPage('login', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
// Main syncing/loading state for this page.
syncing: false,
// Form data
formData: {
rememberMe: true,
},
// For tracking client-side validation errors in our form.
// > Has property set to `true` for each invalid property in `formData`.
formErrors: { /* … */ },
// A set of validation rules for our form.
// > The form will not be submitted if these are invalid.
formRules: {
emailAddress: { required: true, isEmail: true },
password: { required: true },
},
// Server error state for the form
cloudError: '',
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function() {
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
submittedForm: async function() {
// Redirect to the logged-in dashboard on success.
// > (Note that we re-enable the syncing state here. This is on purpose--
// > to make sure the spinner stays there until the page navigation finishes.)
this.syncing = true;
window.location = '/';
},
}
});

View File

@@ -0,0 +1,52 @@
parasails.registerPage('new-password', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
// Main syncing/loading state for this page.
syncing: false,
// Form data
formData: { /* … */ },
// For tracking client-side validation errors in our form.
// > Has property set to `true` for each invalid property in `formData`.
formErrors: { /* … */ },
// Form rules
formRules: {
password: {required: true},
confirmPassword: {required: true, sameAs: 'password'},
},
// Server error state for the form
cloudError: '',
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function() {
this.formData.token = this.token;
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
submittedForm: async function() {
// Redirect to the logged-in dashboard on success.
// > (Note that we re-enable the syncing state here. This is on purpose--
// > to make sure the spinner stays there until the page navigation finishes.)
this.syncing = true;
window.location = '/';
},
}
});

View File

@@ -0,0 +1,62 @@
parasails.registerPage('signup', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
// Form data
formData: { /* … */ },
// For tracking client-side validation errors in our form.
// > Has property set to `true` for each invalid property in `formData`.
formErrors: { /* … */ },
// Form rules
formRules: {
fullName: {required: true},
emailAddress: {required: true, isEmail: true},
password: {required: true},
confirmPassword: {required: true, sameAs: 'password'},
agreed: {required: true},
},
// Syncing / loading state
syncing: false,
// Server error state
cloudError: '',
// Success state when form has been submitted
cloudSuccess: false,
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function() {
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
submittedForm: async function() {
if(this.isEmailVerificationRequired) {
// If email confirmation is enabled, show the success message.
this.cloudSuccess = true;
}
else {
// Otherwise, redirect to the logged-in dashboard.
// > (Note that we re-enable the syncing state here. This is on purpose--
// > to make sure the spinner stays there until the page navigation finishes.)
this.syncing = true;
window.location = '/';
}
},
}
});

View File

@@ -0,0 +1,25 @@
parasails.registerPage('faq', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
//…
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function(){
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
//…
}
});

View File

@@ -0,0 +1,42 @@
parasails.registerPage('homepage', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
//…
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function(){
this._setHeroHeight();
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
clickHeroButton: async function() {
// Scroll to the 'get started' section:
$('html, body').animate({
scrollTop: this.$find('[purpose="scroll-destination"]').offset().top
}, 500);
},
// Private methods not tied to a particular DOM event are prefixed with _
_setHeroHeight: function() {
var $hero = this.$find('[purpose="full-page-hero"]');
var headerHeight = $('[purpose="page-header"]').outerHeight();
var heightToSet = $(window).height();
heightToSet = Math.max(heightToSet, 500);//« ensure min height of 500px - header height
heightToSet = Math.min(heightToSet, 1000);//« ensure max height of 1000px - header height
$hero.css('min-height', heightToSet - headerHeight+'px');
},
}
});

View File

@@ -0,0 +1,25 @@
parasails.registerPage('privacy', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
//…
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function(){
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
//…
}
});

View File

@@ -0,0 +1,25 @@
parasails.registerPage('terms', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
//…
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function(){
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
//…
}
});

View File

@@ -0,0 +1,98 @@
/**
* openStripeCheckout()
*
* Open the Stripe Checkout modal dialog and resolve when it is closed.
*
* -----------------------------------------------------------------
* @param {String} stripePublishableKey
* @param {String} billingEmailAddress
* @param {String} headingText (optional)
* @param {String} descriptionText (optional)
* @param {String} buttonText (optional)
* -----------------------------------------------------------------
* @returns {Dictionary?} (or undefined if the form was cancelled)
* e.g.
* {
* stripeToken: '…',
* billingCardLast4: '…',
* billingCardBrand: '…',
* billingCardExpMonth: '…',
* billingCardExpYear: '…'
* }
* -----------------------------------------------------------------
* Example usage:
* ```
* var billingInfo = await openStripeCheckout(
* 'pk_test_Qz5RfDmVV5IunTFAHtDqDWn4',
* 'foo@example.com'
* );
* ```
*/
parasails.registerUtility('openStripeCheckout', async function openStripeCheckout(stripePublishableKey, billingEmailAddress, headingText, descriptionText, buttonText) {
// Cache (& use cached) "checkout handler" globally on the page so that we
// don't end up configuring it more than once (i.e. so Stripe.js doesn't
// complain).
var CACHE_KEY = '_cachedStripeCheckoutHandler';
if (!window[CACHE_KEY]) {
window[CACHE_KEY] = StripeCheckout.configure({
key: stripePublishableKey,
});
}
var checkoutHandler = window[CACHE_KEY];
// Track whether the "token" callback was triggered.
// (If it has NOT at the time the "closed" callback is triggered, then we
// know the checkout form was cancelled.)
var hasTriggeredTokenCallback;
// 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 {
// Open Stripe checkout.
// (https://stripe.com/docs/checkout#integration-custom)
checkoutHandler.open({
name: headingText || 'NEW_APP_NAME',
description: descriptionText || 'Link your credit card.',
panelLabel: buttonText || 'Save card',
email: billingEmailAddress,//« So that Stripe doesn't prompt for an email address
locale: 'auto',
zipCode: false,
allowRememberMe: false,
closed: ()=>{
// If the Checkout dialog was cancelled, resolve undefined.
if (!hasTriggeredTokenCallback) {
resolve();
}
},
token: (stripeData)=>{
// After payment info has been successfully added, and a token
// was obtained...
hasTriggeredTokenCallback = true;
// Normalize token and billing card info from Stripe and resolve
// with that.
let stripeToken = stripeData.id;
let billingCardLast4 = stripeData.card.last4;
let billingCardBrand = stripeData.card.brand;
let billingCardExpMonth = String(stripeData.card.exp_month);
let billingCardExpYear = String(stripeData.card.exp_year);
resolve({
stripeToken,
billingCardLast4,
billingCardBrand,
billingCardExpMonth,
billingCardExpYear
});
}//Œ
});//_∏_
} catch (err) {
reject(err);
}
});//_∏_
});

37
assets/styles/bootstrap-overrides.less vendored Normal file
View File

@@ -0,0 +1,37 @@
/**
* This file is for overriding some default bootstrap styles.
*
* > NOTE THAT THIS FILE AFFECTS GLOBAL STYLES.
*/
// lesshint-disable
* {
box-sizing: border-box;
}
// lesshint-enable
img {
display: block;
}
// Get rid of weird background on <a>s with button styles
.btn, [type='button'] {
-webkit-appearance: none;
}
// Custom link styles within bodies of text
h1, h2, h3, h4, h5, h6, p, li, blockquote, label {
>a:not(.btn), small >a:not(.btn) {
color: @brand;
border-bottom: 1px solid @text-normal;
&:hover {
text-decoration: none;
color: @text-normal;
}
}
}
blockquote {
border-left: 3px solid @border-lt-gray;
padding-left: 20px;
}

View File

@@ -0,0 +1,41 @@
/**
* <ajax-button>
*
* App-wide styles for our ajax buttons.
*/
[parasails-component='ajax-button'] {
.button-loader, .button-loading {
display: none;
margin: auto;
.loading-dot {
opacity: 0;
display: inline;
.fade-in();
.animation-duration(1s);
.animation-iteration-count(infinite);
.animation-direction(linear);
&.dot1 {
.animation-delay(0.25s);
}
&.dot2 {
.animation-delay(0.5s);
}
&.dot3 {
.animation-delay(0.75s);
}
&.dot4 {
.animation-delay(1s);
}
}
}
&.syncing {
.button-loader, .button-loading {
display: inline-block;
}
.button-text {
display: none;
}
}
}

View File

@@ -0,0 +1,9 @@
/**
* <cloud-error>
*
* App-wide styles for our cloud-errors.
*/
[parasails-component='cloud-error'] {
// ...
}

View File

@@ -0,0 +1,92 @@
/**
* <modal>
*
* App-wide styles for our modals.
*/
[parasails-component='modal'] {
-webkit-overflow-scrolling: touch;//« makes this actually scrollable on certain phones
[purpose='modal-dialog'] {
z-index: 100;
position: relative;
max-width: 700px;
[purpose='modal-content'] {
max-width: 700px;
[purpose='modal-close-button'] {
.btn-reset();
opacity: 0.6;
&:hover {
opacity: 1;
}
}
}
}
// Custom styles for the Bootstrap modal:
// (Want to use Bootstrap's default styles? Just comment out the rest of the rules below)
.petticoat {
position: fixed;
width: 100%;
height: 75px;// should cover topbar
z-index: 50;
left: 0px;
top: 0px;
background-color: @accent-white;
}
.modal-content {
border-radius: 0px;
border-color: @accent-white;
padding-top: 50px;
padding-bottom: 50px;
padding-left: 25px;
padding-right: 25px;
.modal-header {
border-bottom: none;
display: block;
position: relative;
text-align: center;
padding-bottom: 0px;
padding-left: 0px;
padding-right: 0px;
padding-top: 0px;
.modal-title {
font-weight: @bold;
}
.modal-intro {
margin-left: auto;
margin-right: auto;
color: @text-muted;
margin-bottom: 20px;
}
hr {
margin-top: 25px;
margin-left: auto;
margin-right: auto;
margin-bottom: 10px;
width: 100px;
height: 2px;
border-top: 2px solid @brand;
}
}
.modal-body {
padding-top: 10px;
padding-bottom: 10px;
padding-left: 0px;
padding-right: 0px;
}
.modal-footer {
padding-top: 25px;
padding-bottom: 0px;
padding-left: 0px;
padding-right: 0px;
margin-top: 10px;
}
}
}
// Modal backdrop styles are exposed globally here because this gets appended to the <body>
.modal-backdrop {
background-color: @accent-white;
&.show {
opacity: 0.95;
}
}

View File

@@ -0,0 +1,65 @@
/**
* <stripe-card-element>
*/
[parasails-component='stripe-card-element'] {
.card-element-wrapper {
position: relative;
.card-element {
padding-top: 0;
padding-bottom: 0;
padding-right: 30px;
&.pseudofocused {
// These should mimic your normal form inputs' :focus styles:
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
}
.status-indicator {
font-size: 15px;
position: absolute;
right: 14px;
top: 8px;
&.hidden {
display: none;
}
&.syncing {
-webkit-animation: fa-spinner-rotate 1.5s infinite linear;
animation: fa-spinner-rotate 1.5s infinite linear;
@-webkit-keyframes fa-spinner-rotate {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@keyframes rotate-clockwise {
0% {
-ms-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-ms-transform: rotate(360deg);
transform: rotate(360deg);
}
}
}
}
&.secret-card-element-wrapper {
opacity: 0;
height: 1px;
}
}
@media screen and (max-width: 450px) {
.card-element-wrapper {
.card-element {
padding-right: 20px;
}
.status-indicator {
right: 9px;
}
}
}
}

View File

@@ -0,0 +1,42 @@
/**
* importer.less
*
* By default, new Sails projects are configured to compile this file
* from LESS to CSS. Unlike CSS files, LESS files are not compiled and
* included automatically unless they are imported below.
*
* For more information see:
* https://sailsjs.com/anatomy/assets/styles/importer-less
*/
// Mixins and variables (LESS mixins/variables only, no global selectors)
@import 'mixins-and-variables/index.less';
// Overall layout (contains global selectors)
@import 'bootstrap-overrides.less';
@import 'layout.less';
// Per-component styles
@import 'components/stripe-card-element.component.less';
@import 'components/ajax-button.component.less';
@import 'components/modal.component.less';
@import 'components/cloud-error.component.less';
// Per-page styles
@import 'pages/homepage.less';
@import 'pages/dashboard/welcome.less';
@import 'pages/entrance/signup.less';
@import 'pages/entrance/confirmed-email.less';
@import 'pages/entrance/login.less';
@import 'pages/entrance/forgot-password.less';
@import 'pages/entrance/new-password.less';
@import 'pages/account/account-overview.less';
@import 'pages/account/edit-password.less';
@import 'pages/account/edit-profile.less';
@import 'pages/legal/terms.less';
@import 'pages/legal/privacy.less';
@import 'pages/faq.less';
@import 'pages/contact.less';
@import 'pages/404.less';
@import 'pages/500.less';
@import 'pages/498.less';

59
assets/styles/layout.less Normal file
View File

@@ -0,0 +1,59 @@
@footer-height: 40px;
@container-md-max-width: 1100px;
[v-cloak] { display: none; }
html, body {
height: 100%;
margin: 0;
}
[purpose='page-wrap'] {
height: 100%;
/* lesshint-disable */height: auto !important;/* lesshint-enable */
// ^^The above is to disable "importantRule" and "duplicateProperty" rules.
min-height: 100%;
position: relative;
padding-bottom: @footer-height;
}
[purpose='page-footer'] {
border-top: 1px solid rgba(0, 0, 0, 0.1);
height: @footer-height;
width: 100%;
position: absolute;
left: 0px;
bottom: 0px;
}
body.detected-mobile {
// Above and beyond the media queries below, this selector (which relies on
// `parasails` automatically attaching this class, if appropriate) contains
// styles intended to be activated specifically when loaded from a recognized
// mobile device, regardless of viewport dimensions. This includes tablet
// devices (like the iPad) as well as handset devices (like the iPhone).
// …
}
@media (max-width: 800px) {
[purpose='page-wrap'] {
padding-bottom: 75px;
[purpose='page-footer'] {
height: 75px;
[purpose='footer-copy'], [purpose='footer-nav'] {
width: 100%;
display: block;
text-align: center;
}
}
}
}
@media (max-width: 575px) {
[purpose='page-wrap'] {
padding-bottom: 100px;
[purpose='page-footer'] {
height: 100px;
}
}
}

View File

@@ -0,0 +1,270 @@
.animation-delay(@delay) {
-moz-animation-delay: @delay;
-webkit-animation-delay: @delay;
-ms-animation-delay: @delay;
-o-animation-delay: @delay;
animation-delay: @delay;
}
.animation-name(@name) {
-moz-animation-name: @name;
-webkit-animation-name: @name;
-ms-animation-name: @name;
-o-animation-name: @name;
animation-name: @name;
}
.animation-duration(@duration) {
-moz-animation-duration: @duration;
-webkit-animation-duration: @duration;
-ms-animation-duration: @duration;
-o-animation-duration: @duration;
animation-duration: @duration;
}
.animation-iteration-count(@iteration-count) {
-moz-animation-iteration-count: @iteration-count;
-webkit-animation-iteration-count: @iteration-count;
-ms-animation-iteration-count: @iteration-count;
-o-animation-iteration-count: @iteration-count;
animation-iteration-count: @iteration-count;
}
.animation-direction(@direction) {
-moz-animation-direction: @direction;
-webkit-animation-direction: @direction;
-ms-animation-direction: @direction;
-o-animation-direction: @direction;
animation-direction: @direction;
}
.animation-timing-function(@timingFunction) {
-moz-animation-timing-function: @timingFunction;
-webkit-animation-timing-function: @timingFunction;
-ms-animation-timing-function: @timingFunction;
-o-animation-timing-function: @timingFunction;
animation-timing-function: @timingFunction;
}
.transition (@transition) {
-webkit-transition: @transition;
-moz-transition : @transition;
-ms-transition : @transition;
-o-transition : @transition;
}
.translate (@x, @y:0) {
-webkit-transform: translate(@x, @y);
-moz-transform : translate(@x, @y);
-ms-transform : translate(@x, @y);
-o-transform : translate(@x, @y);
transform : translate(@x, @y);
}
//Animations
.fade-in() {
.animation-name(fade-in);
@-webkit-keyframes fade-in {
0% {opacity: 0;}// lesshint spaceBeforeBrace: false
100% {opacity: 1;}// lesshint spaceBeforeBrace: false
}
@-moz-keyframes fade-in {
0% {opacity: 0;}// lesshint spaceBeforeBrace: false
100% {opacity: 1;}// lesshint spaceBeforeBrace: false
}
@-o-keyframes fade-in {
0% {opacity: 0;}// lesshint spaceBeforeBrace: false
100% {opacity: 1;}// lesshint spaceBeforeBrace: false
}
@keyframes fade-in {
0% {opacity: 0;}// lesshint spaceBeforeBrace: false
100% {opacity: 1;}// lesshint spaceBeforeBrace: false
}
}
.loader(@dot-color: @accent-white) {
display: inline-block;
margin: auto;
.loading-dot {
border-radius: 50%;
background-color: @dot-color;
float: left;
opacity: 0;
width: 16px;
height: 16px;
margin: 5px;
.fade-in();
.animation-duration(1s);
.animation-iteration-count(infinite);
.animation-direction(linear);
&.dot1 {
.animation-delay(0.25s);
}
&.dot2 {
.animation-delay(0.5s);
}
&.dot3 {
.animation-delay(0.75s);
}
&.dot4 {
.animation-delay(1s);
}
}
}
// Special rotation animation for a font awesome-based spinner:
.fa-spinner-rotation() {
-webkit-animation: fa-spinner-rotate 1.5s infinite linear;
animation: fa-spinner-rotate 1.5s infinite linear;
@-webkit-keyframes fa-spinner-rotate {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@keyframes rotate-clockwise {
0% {
-ms-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-ms-transform: rotate(360deg);
transform: rotate(360deg);
}
}
}
.skid() {
.animation-name(skid);
.animation-duration(2.5s);
.animation-iteration-count(infinite);
.animation-timing-function(linear);
@-webkit-keyframes skid {
0% {-webkit-transform: translate(0px, 0px);}// lesshint spaceBeforeBrace: false
10% {-webkit-transform: translate(-1px, -1px);}// lesshint spaceBeforeBrace: false
20% {-webkit-transform: translate(-2px, -2px);}// lesshint spaceBeforeBrace: false
30% {-webkit-transform: translate(-3px, -2px);}// lesshint spaceBeforeBrace: false
40% {-webkit-transform: translate(-4px, -1px);}// lesshint spaceBeforeBrace: false
50% {-webkit-transform: translate(-5px, 0px);}// lesshint spaceBeforeBrace: false
60% {-webkit-transform: translate(-4px, 1px);}// lesshint spaceBeforeBrace: false
70% {-webkit-transform: translate(-3px, 2px);}// lesshint spaceBeforeBrace: false
80% {-webkit-transform: translate(-2px, 2px);}// lesshint spaceBeforeBrace: false
90% {-webkit-transform: translate(-1px, 1px);}// lesshint spaceBeforeBrace: false
100% {-webkit-transform: translate(0, 0px);}// lesshint spaceBeforeBrace: false
}
@-moz-keyframes skid {
0% {-moz-transform: translate(0px, 0px);}// lesshint spaceBeforeBrace: false
10% {-moz-transform: translate(-1px, -1px);}// lesshint spaceBeforeBrace: false
20% {-moz-transform: translate(-2px, -2px);}// lesshint spaceBeforeBrace: false
30% {-moz-transform: translate(-3px, -2px);}// lesshint spaceBeforeBrace: false
40% {-moz-transform: translate(-4px, -1px);}// lesshint spaceBeforeBrace: false
50% {-moz-transform: translate(-5px, 0px);}// lesshint spaceBeforeBrace: false
60% {-moz-transform: translate(-4px, 1px);}// lesshint spaceBeforeBrace: false
70% {-moz-transform: translate(-3px, 2px);}// lesshint spaceBeforeBrace: false
80% {-moz-transform: translate(-2px, 2px);}// lesshint spaceBeforeBrace: false
90% {-moz-transform: translate(-1px, 1px);}// lesshint spaceBeforeBrace: false
100% {-moz-transform: translate(0, 0px);}// lesshint spaceBeforeBrace: false
}
@-o-keyframes skid {
0% {-o-transform: translate(0px, 0px);}// lesshint spaceBeforeBrace: false
10% {-o-transform: translate(-1px, -1px);}// lesshint spaceBeforeBrace: false
20% {-o-transform: translate(-2px, -2px);}// lesshint spaceBeforeBrace: false
30% {-o-transform: translate(-3px, -2px);}// lesshint spaceBeforeBrace: false
40% {-o-transform: translate(-4px, -1px);}// lesshint spaceBeforeBrace: false
50% {-o-transform: translate(-5px, 0px);}// lesshint spaceBeforeBrace: false
60% {-o-transform: translate(-4px, 1px);}// lesshint spaceBeforeBrace: false
70% {-o-transform: translate(-3px, 2px);}// lesshint spaceBeforeBrace: false
80% {-o-transform: translate(-2px, 2px);}// lesshint spaceBeforeBrace: false
90% {-o-transform: translate(-1px, 1px);}// lesshint spaceBeforeBrace: false
100% {-o-transform: translate(0, 0px);}// lesshint spaceBeforeBrace: false
}
@keyframes skid {
0% {transform: translate(0px, 0px);}// lesshint spaceBeforeBrace: false
10% {transform: translate(-1px, -1px);}// lesshint spaceBeforeBrace: false
20% {transform: translate(-2px, -2px);}// lesshint spaceBeforeBrace: false
30% {transform: translate(-3px, -2px);}// lesshint spaceBeforeBrace: false
40% {transform: translate(-4px, -1px);}// lesshint spaceBeforeBrace: false
50% {transform: translate(-5px, 0px);}// lesshint spaceBeforeBrace: false
60% {transform: translate(-4px, 1px);}// lesshint spaceBeforeBrace: false
70% {transform: translate(-3px, 2px);}// lesshint spaceBeforeBrace: false
80% {transform: translate(-2px, 2px);}// lesshint spaceBeforeBrace: false
90% {transform: translate(-1px, 1px);}// lesshint spaceBeforeBrace: false
100% {transform: translate(0, 0px);}// lesshint spaceBeforeBrace: false
}
}
.fly-fade() {
.animation-name(flyfade);
.animation-duration(7s);
.animation-iteration-count(infinite);
.animation-timing-function(linear);
@-webkit-keyframes flyfade {
0% {-webkit-transform: translate(0px, 0px); opacity: 0;}// lesshint spaceBeforeBrace: false
25% { opacity: 1;}// lesshint spaceBeforeBrace: false
50% {-webkit-transform: translate(110px, 0px);}// lesshint spaceBeforeBrace: false
75% { opacity: 1;}// lesshint spaceBeforeBrace: false
100% {-webkit-transform: translate(220px, 0); opacity: 0;}// lesshint spaceBeforeBrace: false
}
@-moz-keyframes flyfade {
0% {-moz-transform: translate(0, 0px); opacity: 0;}// lesshint spaceBeforeBrace: false
25% { opacity: 1;}// lesshint spaceBeforeBrace: false
50% {-moz-transform: translate(110px, 0px); opacity: 1;}// lesshint spaceBeforeBrace: false
75% { opacity: 1;}// lesshint spaceBeforeBrace: false
100% {-moz-transform: translate(220px, 0); opacity: 0;}// lesshint spaceBeforeBrace: false
}
@-o-keyframes flyfade {
0% {-o-transform: translate(0, 0px); opacity: 0;}// lesshint spaceBeforeBrace: false
25% { opacity: 1;}// lesshint spaceBeforeBrace: false
50% {-o-transform: translate(110px, 0px); opacity: 1;}// lesshint spaceBeforeBrace: false
75% { opacity: 1;}// lesshint spaceBeforeBrace: false
100% {-o-transform: translate(220px, 0); opacity: 0;}// lesshint spaceBeforeBrace: false
}
@keyframes flyfade {
0% {transform: translate(0, 0px); opacity: 0;}// lesshint spaceBeforeBrace: false
25% { opacity: 1;}// lesshint spaceBeforeBrace: false
50% {transform: translate(110px, 0px); opacity: 1;}// lesshint spaceBeforeBrace: false
75% { opacity: 1;}// lesshint spaceBeforeBrace: false
100% {transform: translate(220px, 0); opacity: 0;}// lesshint spaceBeforeBrace: false
}
}
.bob() {
.animation-name(bob);
.animation-duration(3.2s);
.animation-iteration-count(infinite);
.animation-timing-function(ease-in-out);
@-webkit-keyframes bob {
0% {-webkit-transform: translate(0px);}// lesshint spaceBeforeBrace: false
50% {-webkit-transform: translatey(-7px);}// lesshint spaceBeforeBrace: false
100% {-webkit-transform: translatey(0px);}// lesshint spaceBeforeBrace: false
}
@-moz-keyframes bob {
0% {-moz-transform: translatey(0px);}// lesshint spaceBeforeBrace: false
50% {-moz-transform: translatey(-7px);}// lesshint spaceBeforeBrace: false
100% {-moz-transform: translatey(0px);}// lesshint spaceBeforeBrace: false
}
@-o-keyframes bob {
0% {-o-transform: translatey(0px);}// lesshint spaceBeforeBrace: false
50% {-o-transform: translatey(-7px);}// lesshint spaceBeforeBrace: false
100% {-o-transform: translatey(0px);}// lesshint spaceBeforeBrace: false
}
@keyframes bob {
0% {transform: translatey(0px);}// lesshint spaceBeforeBrace: false
50% {transform: translatey(-7px);}// lesshint spaceBeforeBrace: false
100% {transform: translatey(0px);}// lesshint spaceBeforeBrace: false
}
}

View File

@@ -0,0 +1,13 @@
.btn-reset() {
border-top: none;
border-bottom: none;
border-left: none;
border-right: none;
background: transparent;
font-family: inherit;
cursor: pointer;
&:focus {
border-image: none;
outline: none;
}
}

View File

@@ -0,0 +1,17 @@
/**
* Color Variables
*/
@brand: #14acc2;
@error: #B53A03;
@text-normal: #000;
@text-muted: lighten(@text-normal, 60%);
@bg-lt-gray: #f1f1f1;
@border-lt-gray: darken(@bg-lt-gray, 5%);
@accent-lt-gray: darken(#fff, 5%);
@accent-md-gray: darken(#fff, 25%);
@accent-white: #fff;

View File

@@ -0,0 +1,13 @@
.container-sm() {
width: 100%;
max-width: 450px;
margin-left: auto;
margin-right: auto;
}
.container-md() {
width: 100%;
max-width: 650px;
margin-left: auto;
margin-right: auto;
}

View File

@@ -0,0 +1,6 @@
@import 'colors.less';
@import 'typography.less';
@import 'buttons.less';
@import 'animations.less';
@import 'truncate.less';
@import 'containers.less';

View File

@@ -0,0 +1,5 @@
.truncate() {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,7 @@
// Font families:
@main-font: 'Lato', sans-serif;
@header-font: 'Lato', sans-serif;
// Font weights:
@bold: 700;
@normal: 400;

View File

@@ -0,0 +1,5 @@
[id='404'] {
//…
}

View File

@@ -0,0 +1,5 @@
[id='498'] {
//…
}

View File

@@ -0,0 +1,5 @@
[id='500'] {
//…
}

View File

@@ -0,0 +1,9 @@
#account-overview {
[purpose='remove-button'] {
color: @brand;
&:hover {
color: @text-normal;
}
}
}

View File

@@ -0,0 +1,5 @@
#edit-password {
//…
}

View File

@@ -0,0 +1,5 @@
#edit-profile {
//…
}

View File

@@ -0,0 +1,5 @@
#contact {
//…
}

View File

@@ -0,0 +1,5 @@
#welcome {
//…
}

View File

@@ -0,0 +1,5 @@
#confirmed-email {
//…
}

View File

@@ -0,0 +1,5 @@
#forgot-password {
//…
}

View File

@@ -0,0 +1,5 @@
#login {
//…
}

View File

@@ -0,0 +1,5 @@
#new-password {
//…
}

View File

@@ -0,0 +1,5 @@
#signup {
//…
}

View File

@@ -0,0 +1,11 @@
#faq {
@media (max-width: 500px) {
code {
word-break: break-all;
}
[purpose='placeholder'] {
word-break: break-all;
}
}
}

View File

@@ -0,0 +1,27 @@
#homepage {
[purpose='cloud-1'] {
.fly-fade();
opacity: 0;
}
[purpose='cloud-2'] {
.fly-fade();
.animation-delay(3.5s);
opacity: 0;
}
[purpose='ship'] {
.skid();
}
[purpose='more-info-text'] {
.bob();
}
[purpose='setup-step'] {
padding-left: 240px;
}
@media (max-width: 991px) {
[purpose='setup-step'] {
padding-left: 0px;
}
}
}

View File

@@ -0,0 +1,5 @@
#privacy {
//…
}

View File

@@ -0,0 +1,5 @@
#terms {
//…
}

View File