Initial project structure with sails.js
61
assets/.eslintrc
Normal 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`)
|
||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
}
|
||||
|
||||
}
|
6461
assets/dependencies/bootstrap-4/bootstrap-4.bundle.js
vendored
Normal file
9030
assets/dependencies/bootstrap-4/bootstrap-4.css
vendored
Normal file
1987
assets/dependencies/cloud.js
Normal file
2337
assets/dependencies/fontawesome.css
vendored
Normal file
4
assets/dependencies/jquery.min.js
vendored
Normal file
12596
assets/dependencies/lodash.js
Normal file
4535
assets/dependencies/moment.js
Normal file
1221
assets/dependencies/parasails.js
Normal file
1739
assets/dependencies/sails.io.js
Normal file
2513
assets/dependencies/vue-router.js
Normal file
10979
assets/dependencies/vue.js
Normal file
BIN
assets/favicon.ico
Normal file
After Width: | Height: | Size: 920 B |
BIN
assets/fonts/fontawesome-webfont.eot
Normal file
2671
assets/fonts/fontawesome-webfont.svg
Normal file
After Width: | Height: | Size: 434 KiB |
BIN
assets/fonts/fontawesome-webfont.ttf
Normal file
BIN
assets/fonts/fontawesome-webfont.woff
Normal file
BIN
assets/fonts/fontawesome-webfont.woff2
Normal file
BIN
assets/images/hero-cloud.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
assets/images/hero-ship.png
Normal file
After Width: | Height: | Size: 36 KiB |
BIN
assets/images/hero-sky.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
BIN
assets/images/hero-water.png
Normal file
After Width: | Height: | Size: 439 B |
BIN
assets/images/icon-close.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/images/logo.png
Normal file
After Width: | Height: | Size: 3.0 KiB |
BIN
assets/images/setup-customize.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
assets/images/setup-email.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
assets/images/setup-payment.png
Normal file
After Width: | Height: | Size: 10 KiB |
19
assets/js/cloud.setup.js
Normal 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 */
|
||||
|
||||
});
|
@@ -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
|
||||
|
||||
// ╔═╗╦═╗╦╦ ╦╔═╗╔╦╗╔═╗ ╔╦╗╔═╗╔╦╗╦ ╦╔═╗╔╦╗╔═╗
|
||||
// ╠═╝╠╦╝║╚╗╔╝╠═╣ ║ ║╣ ║║║║╣ ║ ╠═╣║ ║ ║║╚═╗
|
||||
// ╩ ╩╚═╩ ╚╝ ╩ ╩ ╩ ╚═╝ ╩ ╩╚═╝ ╩ ╩ ╩╚═╝═╩╝╚═╝
|
||||
|
||||
//…
|
||||
|
||||
}
|
||||
|
||||
});
|
69
assets/js/components/ajax-button.component.js
Normal 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');
|
||||
},
|
||||
|
||||
}
|
||||
});
|
379
assets/js/components/ajax-form.component.js
Normal 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);
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
}
|
||||
});
|
93
assets/js/components/cloud-error.component.js
Normal 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
|
||||
|
||||
// ╔═╗╦═╗╦╦ ╦╔═╗╔╦╗╔═╗ ╔╦╗╔═╗╔╦╗╦ ╦╔═╗╔╦╗╔═╗
|
||||
// ╠═╝╠╦╝║╚╗╔╝╠═╣ ║ ║╣ ║║║║╣ ║ ╠═╣║ ║ ║║╚═╗
|
||||
// ╩ ╩╚═╩ ╚╝ ╩ ╩ ╩ ╚═╝ ╩ ╩╚═╝ ╩ ╩ ╩╚═╝═╩╝╚═╝
|
||||
|
||||
//…
|
||||
|
||||
}
|
||||
|
||||
});
|
130
assets/js/components/js-timestamp.component.js
Normal 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' : ''));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
});
|
226
assets/js/components/modal.component.js
Normal 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">×</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();
|
||||
});//_∏_
|
||||
|
||||
},
|
||||
|
||||
}
|
||||
});
|
192
assets/js/components/stripe-card-element.component.js
Normal 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);
|
||||
}
|
||||
});//_∏_
|
||||
}
|
||||
}
|
||||
});
|
106
assets/js/pages/account/account-overview.page.js
Normal 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();
|
||||
|
||||
},
|
||||
|
||||
}
|
||||
});
|
50
assets/js/pages/account/edit-password.page.js
Normal 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';
|
||||
},
|
||||
|
||||
}
|
||||
});
|
52
assets/js/pages/account/edit-profile.page.js
Normal 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';
|
||||
},
|
||||
|
||||
}
|
||||
});
|
54
assets/js/pages/contact.page.js
Normal 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;
|
||||
|
||||
},
|
||||
|
||||
}
|
||||
});
|
59
assets/js/pages/dashboard/welcome.page.js
Normal 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 = '';
|
||||
// ```
|
||||
},
|
||||
|
||||
}
|
||||
});
|
25
assets/js/pages/entrance/confirmed-email.page.js
Normal file
@@ -0,0 +1,25 @@
|
||||
parasails.registerPage('confirmed-email', {
|
||||
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
|
||||
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
|
||||
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
|
||||
data: {
|
||||
//…
|
||||
},
|
||||
|
||||
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
|
||||
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
|
||||
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
|
||||
beforeMount: function() {
|
||||
//…
|
||||
},
|
||||
mounted: async function(){
|
||||
//…
|
||||
},
|
||||
|
||||
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
|
||||
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
|
||||
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
|
||||
methods: {
|
||||
//…
|
||||
}
|
||||
});
|
49
assets/js/pages/entrance/forgot-password.page.js
Normal 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;
|
||||
},
|
||||
|
||||
}
|
||||
});
|
53
assets/js/pages/entrance/login.page.js
Normal 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 = '/';
|
||||
},
|
||||
|
||||
}
|
||||
});
|
52
assets/js/pages/entrance/new-password.page.js
Normal 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 = '/';
|
||||
},
|
||||
|
||||
}
|
||||
});
|
62
assets/js/pages/entrance/signup.page.js
Normal 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 = '/';
|
||||
}
|
||||
},
|
||||
|
||||
}
|
||||
});
|
25
assets/js/pages/faq.page.js
Normal file
@@ -0,0 +1,25 @@
|
||||
parasails.registerPage('faq', {
|
||||
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
|
||||
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
|
||||
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
|
||||
data: {
|
||||
//…
|
||||
},
|
||||
|
||||
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
|
||||
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
|
||||
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
|
||||
beforeMount: function() {
|
||||
//…
|
||||
},
|
||||
mounted: async function(){
|
||||
//…
|
||||
},
|
||||
|
||||
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
|
||||
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
|
||||
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
|
||||
methods: {
|
||||
//…
|
||||
}
|
||||
});
|
42
assets/js/pages/homepage.page.js
Normal 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');
|
||||
},
|
||||
|
||||
}
|
||||
});
|
25
assets/js/pages/legal/privacy.page.js
Normal file
@@ -0,0 +1,25 @@
|
||||
parasails.registerPage('privacy', {
|
||||
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
|
||||
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
|
||||
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
|
||||
data: {
|
||||
//…
|
||||
},
|
||||
|
||||
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
|
||||
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
|
||||
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
|
||||
beforeMount: function() {
|
||||
//…
|
||||
},
|
||||
mounted: async function(){
|
||||
//…
|
||||
},
|
||||
|
||||
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
|
||||
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
|
||||
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
|
||||
methods: {
|
||||
//…
|
||||
}
|
||||
});
|
25
assets/js/pages/legal/terms.page.js
Normal file
@@ -0,0 +1,25 @@
|
||||
parasails.registerPage('terms', {
|
||||
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
|
||||
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
|
||||
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
|
||||
data: {
|
||||
//…
|
||||
},
|
||||
|
||||
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
|
||||
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
|
||||
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
|
||||
beforeMount: function() {
|
||||
//…
|
||||
},
|
||||
mounted: async function(){
|
||||
//…
|
||||
},
|
||||
|
||||
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
|
||||
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
|
||||
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
|
||||
methods: {
|
||||
//…
|
||||
}
|
||||
});
|
98
assets/js/utilities/open-stripe-checkout.js
Normal 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
@@ -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;
|
||||
}
|
41
assets/styles/components/ajax-button.component.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
9
assets/styles/components/cloud-error.component.less
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* <cloud-error>
|
||||
*
|
||||
* App-wide styles for our cloud-errors.
|
||||
*/
|
||||
|
||||
[parasails-component='cloud-error'] {
|
||||
// ...
|
||||
}
|
92
assets/styles/components/modal.component.less
Normal 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;
|
||||
}
|
||||
}
|
65
assets/styles/components/stripe-card-element.component.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
42
assets/styles/importer.less
Normal 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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
270
assets/styles/mixins-and-variables/animations.less
Normal 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
|
||||
}
|
||||
}
|
||||
|
13
assets/styles/mixins-and-variables/buttons.less
Normal 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;
|
||||
}
|
||||
}
|
17
assets/styles/mixins-and-variables/colors.less
Normal 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;
|
13
assets/styles/mixins-and-variables/containers.less
Normal 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;
|
||||
}
|
6
assets/styles/mixins-and-variables/index.less
Normal file
@@ -0,0 +1,6 @@
|
||||
@import 'colors.less';
|
||||
@import 'typography.less';
|
||||
@import 'buttons.less';
|
||||
@import 'animations.less';
|
||||
@import 'truncate.less';
|
||||
@import 'containers.less';
|
5
assets/styles/mixins-and-variables/truncate.less
Normal file
@@ -0,0 +1,5 @@
|
||||
.truncate() {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
7
assets/styles/mixins-and-variables/typography.less
Normal file
@@ -0,0 +1,7 @@
|
||||
// Font families:
|
||||
@main-font: 'Lato', sans-serif;
|
||||
@header-font: 'Lato', sans-serif;
|
||||
|
||||
// Font weights:
|
||||
@bold: 700;
|
||||
@normal: 400;
|
5
assets/styles/pages/404.less
Normal file
@@ -0,0 +1,5 @@
|
||||
[id='404'] {
|
||||
|
||||
//…
|
||||
|
||||
}
|
5
assets/styles/pages/498.less
Normal file
@@ -0,0 +1,5 @@
|
||||
[id='498'] {
|
||||
|
||||
//…
|
||||
|
||||
}
|
5
assets/styles/pages/500.less
Normal file
@@ -0,0 +1,5 @@
|
||||
[id='500'] {
|
||||
|
||||
//…
|
||||
|
||||
}
|
9
assets/styles/pages/account/account-overview.less
Normal file
@@ -0,0 +1,9 @@
|
||||
#account-overview {
|
||||
|
||||
[purpose='remove-button'] {
|
||||
color: @brand;
|
||||
&:hover {
|
||||
color: @text-normal;
|
||||
}
|
||||
}
|
||||
}
|
5
assets/styles/pages/account/edit-password.less
Normal file
@@ -0,0 +1,5 @@
|
||||
#edit-password {
|
||||
|
||||
//…
|
||||
|
||||
}
|
5
assets/styles/pages/account/edit-profile.less
Normal file
@@ -0,0 +1,5 @@
|
||||
#edit-profile {
|
||||
|
||||
//…
|
||||
|
||||
}
|
5
assets/styles/pages/contact.less
Normal file
@@ -0,0 +1,5 @@
|
||||
#contact {
|
||||
|
||||
//…
|
||||
|
||||
}
|
5
assets/styles/pages/dashboard/welcome.less
Normal file
@@ -0,0 +1,5 @@
|
||||
#welcome {
|
||||
|
||||
//…
|
||||
|
||||
}
|
5
assets/styles/pages/entrance/confirmed-email.less
Normal file
@@ -0,0 +1,5 @@
|
||||
#confirmed-email {
|
||||
|
||||
//…
|
||||
|
||||
}
|
5
assets/styles/pages/entrance/forgot-password.less
Normal file
@@ -0,0 +1,5 @@
|
||||
#forgot-password {
|
||||
|
||||
//…
|
||||
|
||||
}
|
5
assets/styles/pages/entrance/login.less
Normal file
@@ -0,0 +1,5 @@
|
||||
#login {
|
||||
|
||||
//…
|
||||
|
||||
}
|
5
assets/styles/pages/entrance/new-password.less
Normal file
@@ -0,0 +1,5 @@
|
||||
#new-password {
|
||||
|
||||
//…
|
||||
|
||||
}
|
5
assets/styles/pages/entrance/signup.less
Normal file
@@ -0,0 +1,5 @@
|
||||
#signup {
|
||||
|
||||
//…
|
||||
|
||||
}
|
11
assets/styles/pages/faq.less
Normal file
@@ -0,0 +1,11 @@
|
||||
#faq {
|
||||
|
||||
@media (max-width: 500px) {
|
||||
code {
|
||||
word-break: break-all;
|
||||
}
|
||||
[purpose='placeholder'] {
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
27
assets/styles/pages/homepage.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
5
assets/styles/pages/legal/privacy.less
Normal file
@@ -0,0 +1,5 @@
|
||||
#privacy {
|
||||
|
||||
//…
|
||||
|
||||
}
|
5
assets/styles/pages/legal/terms.less
Normal file
@@ -0,0 +1,5 @@
|
||||
#terms {
|
||||
|
||||
//…
|
||||
|
||||
}
|