Initial Spark Install

This commit is contained in:
Deon George
2017-11-03 16:26:07 +11:00
commit b1a5807eb3
766 changed files with 128896 additions and 0 deletions

View File

@@ -0,0 +1,135 @@
module.exports = {
/**
* Load mixins for the component.
*/
mixins: [
require('./../mixins/braintree'),
require('./../mixins/plans'),
require('./../mixins/register')
],
/**
* The component's data.
*/
data() {
return {
query: null,
coupon: null,
invalidCoupon: false,
registerForm: $.extend(true, new SparkForm({
braintree_type: '',
braintree_token: '',
plan: '',
team: '',
team_slug: '',
name: '',
email: '',
password: '',
password_confirmation: '',
terms: false,
coupon: null,
invitation: null
}), Spark.forms.register)
};
},
watch: {
/**
* Watch the team name for changes.
*/
'registerForm.team': function (val, oldVal) {
if (this.registerForm.team_slug == '' ||
this.registerForm.team_slug == oldVal.toLowerCase().replace(/[\s\W-]+/g, '-')
) {
this.registerForm.team_slug = val.toLowerCase().replace(/[\s\W-]+/g, '-');
}
}
},
/**
* The component has been created by Vue.
*/
created() {
this.getPlans();
this.query = URI(document.URL).query(true);
if (this.query.coupon) {
this.getCoupon();
this.registerForm.coupon = this.query.coupon;
}
if (this.query.invitation) {
this.getInvitation();
this.registerForm.invitation = this.query.invitation;
}
},
/**
* Prepare the component.
*/
mounted() {
this.configureBraintree();
},
methods: {
configureBraintree() {
if ( ! Spark.cardUpFront) {
return;
}
this.braintree('braintree-container', response => {
this.registerForm.braintree_type = response.type;
this.registerForm.braintree_token = response.nonce;
this.register();
});
},
/**
* Get the coupon specified in the query string.
*/
getCoupon() {
axios.get('/coupon/' + this.query.coupon)
.then(response => {
this.coupon = response.data;
})
.catch(response => {
this.invalidCoupon = true;
});
},
/**
* Attempt to register with the application.
*/
register() {
Spark.post('/register', this.registerForm)
.then(response => {
window.location = response.redirect;
});
}
},
computed: {
/**
* Get the displayable discount for the coupon.
*/
discount() {
if (this.coupon) {
return Vue.filter('currency')(this.coupon.amount_off);
}
}
}
};

View File

@@ -0,0 +1,249 @@
module.exports = {
/**
* Load mixins for the component.
*/
mixins: [
require('./../mixins/register'),
require('./../mixins/plans'),
require('./../mixins/vat')
],
/**
* The component's data.
*/
data() {
return {
query: null,
coupon: null,
invalidCoupon: false,
country: null,
taxRate: 0,
registerForm: $.extend(true, new SparkForm({
stripe_token: '',
plan: '',
team: '',
team_slug: '',
name: '',
email: '',
password: '',
password_confirmation: '',
address: '',
address_line_2: '',
city: '',
state: '',
zip: '',
country: 'US',
vat_id: '',
terms: false,
coupon: null,
invitation: null
}), Spark.forms.register),
cardForm: new SparkForm({
name: '',
number: '',
cvc: '',
month: '',
year: '',
})
};
},
watch: {
/**
* Watch for changes on the entire billing address.
*/
'currentBillingAddress': function (value) {
if ( ! Spark.collectsEuropeanVat) {
return;
}
this.refreshTaxRate(this.registerForm);
},
/**
* Watch the team name for changes.
*/
'registerForm.team': function (val, oldVal) {
if (this.registerForm.team_slug == '' ||
this.registerForm.team_slug == oldVal.toLowerCase().replace(/[\s\W-]+/g, '-')
) {
this.registerForm.team_slug = val.toLowerCase().replace(/[\s\W-]+/g, '-');
}
}
},
/**
* The component has been created by Vue.
*/
created() {
Stripe.setPublishableKey(Spark.stripeKey);
this.getPlans();
this.guessCountry();
this.query = URI(document.URL).query(true);
if (this.query.coupon) {
this.getCoupon();
this.registerForm.coupon = this.query.coupon;
}
if (this.query.invitation) {
this.getInvitation();
this.registerForm.invitation = this.query.invitation;
}
},
/**
* Prepare the component.
*/
mounted() {
//
},
methods: {
/**
* Attempt to guess the user's country.
*/
guessCountry() {
axios.get('/geocode/country')
.then(response => {
if (response.data != 'ZZ') {
this.registerForm.country = response.data;
}
})
.catch (response => {
//
})
.finally(function () {
this.refreshStatesAndProvinces();
});
},
/**
* Get the coupon specified in the query string.
*/
getCoupon() {
axios.get('/coupon/' + this.query.coupon)
.then(response => {
this.coupon = response.data;
})
.catch(response => {
this.invalidCoupon = true;
});
},
/**
* Attempt to register with the application.
*/
register() {
this.cardForm.errors.forget();
this.registerForm.busy = true;
this.registerForm.errors.forget();
if ( ! Spark.cardUpFront || this.selectedPlan.price == 0) {
return this.sendRegistration();
}
Stripe.card.createToken(this.stripePayload(), (status, response) => {
if (response.error) {
this.cardForm.errors.set({number: [response.error.message]})
this.registerForm.busy = false;
} else {
this.registerForm.stripe_token = response.id;
this.sendRegistration();
}
});
},
/**
* Build the Stripe payload based on the form input.
*/
stripePayload() {
// Here we will build out the payload to send to Stripe to obtain a card token so
// we can create the actual subscription. We will build out this data that has
// this credit card number, CVC, etc. and exchange it for a secure token ID.
return {
name: this.cardForm.name,
number: this.cardForm.number,
cvc: this.cardForm.cvc,
exp_month: this.cardForm.month,
exp_year: this.cardForm.year,
address_line1: this.registerForm.address,
address_line2: this.registerForm.address_line_2,
address_city: this.registerForm.city,
address_state: this.registerForm.state,
address_zip: this.registerForm.zip,
address_country: this.registerForm.country,
};
},
/*
* After obtaining the Stripe token, send the registration to Spark.
*/
sendRegistration() {
Spark.post('/register', this.registerForm)
.then(response => {
window.location = response.redirect;
});
}
},
computed: {
/**
* Determine if the selected country collects European VAT.
*/
countryCollectsVat() {
return this.collectsVat(this.registerForm.country);
},
/**
* Get the displayable discount for the coupon.
*/
discount() {
if (this.coupon) {
if (this.coupon.percent_off) {
return this.coupon.percent_off + '%';
} else {
return Vue.filter('currency')(this.coupon.amount_off / 100);
}
}
},
/**
* Get the current billing address from the register form.
*
* This used primarily for wathcing.
*/
currentBillingAddress() {
return this.registerForm.address +
this.registerForm.address_line_2 +
this.registerForm.city +
this.registerForm.state +
this.registerForm.zip +
this.registerForm.country +
this.registerForm.vat_id;
}
}
};

69
spark/resources/assets/js/filters.js vendored Normal file
View File

@@ -0,0 +1,69 @@
/**
* Format the given date.
*/
Vue.filter('date', value => {
return moment.utc(value).local().format('MMMM Do, YYYY')
});
/**
* Format the given date as a timestamp.
*/
Vue.filter('datetime', value => {
return moment.utc(value).local().format('MMMM Do, YYYY h:mm A');
});
/**
* Format the given date into a relative time.
*/
Vue.filter('relative', value => {
return moment.utc(value).local().locale('en-short').fromNow();
});
/**
* Convert the first character to upper case.
*
* Source: https://github.com/vuejs/vue/blob/1.0/src/filters/index.js#L37
*/
Vue.filter('capitalize', value => {
if (! value && value !== 0) {
return '';
}
return value.toString().charAt(0).toUpperCase()
+ value.slice(1);
});
/**
* Format the given money value.
*
* Source: https://github.com/vuejs/vue/blob/1.0/src/filters/index.js#L70
*/
Vue.filter('currency', value => {
value = parseFloat(value);
if (! isFinite(value) || (! value && value !== 0)){
return '';
}
var stringified = Math.abs(value).toFixed(2);
var _int = stringified.slice(0, -1 - 2);
var i = _int.length % 3;
var head = i > 0
? (_int.slice(0, i) + (_int.length > 3 ? ',' : ''))
: '';
var _float = stringified.slice(-1 - 2);
var sign = value < 0 ? '-' : '';
return sign + window.Spark.currencySymbol + head +
_int.slice(i).replace(/(\d{3})(?=\d)/g, '$1,') +
_float;
});

View File

@@ -0,0 +1,23 @@
/**
* Initialize the Spark form extension points.
*/
Spark.forms = {
register: {},
updateContactInformation: {},
updateTeamMember: {}
};
/**
* Load the SparkForm helper class.
*/
require('./form');
/**
* Define the SparkFormError collection class.
*/
require('./errors');
/**
* Add additional HTTP / form helpers to the Spark object.
*/
$.extend(Spark, require('./http'));

View File

@@ -0,0 +1,71 @@
/**
* Spark form error collection class.
*/
window.SparkFormErrors = function () {
this.errors = {};
/**
* Determine if the collection has any errors.
*/
this.hasErrors = function () {
return ! _.isEmpty(this.errors);
};
/**
* Determine if the collection has errors for a given field.
*/
this.has = function (field) {
return _.indexOf(_.keys(this.errors), field) > -1;
};
/**
* Get all of the raw errors for the collection.
*/
this.all = function () {
return this.errors;
};
/**
* Get all of the errors for the collection in a flat array.
*/
this.flatten = function () {
return _.flatten(_.toArray(this.errors));
};
/**
* Get the first error message for a given field.
*/
this.get = function (field) {
if (this.has(field)) {
return this.errors[field][0];
}
};
/**
* Set the raw errors for the collection.
*/
this.set = function (errors) {
if (typeof errors === 'object') {
this.errors = errors;
} else {
this.errors = {'form': ['Something went wrong. Please try again or contact customer support.']};
}
};
/**
* Remove errors from the collection.
*/
this.forget = function (field) {
if (typeof field === 'undefined') {
this.errors = {};
} else {
Vue.delete(this.errors, field);
}
};
};

51
spark/resources/assets/js/forms/form.js vendored Normal file
View File

@@ -0,0 +1,51 @@
/**
* SparkForm helper class. Used to set common properties on all forms.
*/
window.SparkForm = function (data) {
var form = this;
$.extend(this, data);
/**
* Create the form error helper instance.
*/
this.errors = new SparkFormErrors();
this.busy = false;
this.successful = false;
/**
* Start processing the form.
*/
this.startProcessing = function () {
form.errors.forget();
form.busy = true;
form.successful = false;
};
/**
* Finish processing the form.
*/
this.finishProcessing = function () {
form.busy = false;
form.successful = true;
};
/**
* Reset the errors and other state for the form.
*/
this.resetStatus = function () {
form.errors.forget();
form.busy = false;
form.successful = false;
};
/**
* Set the errors on the form.
*/
this.setErrors = function (errors) {
form.busy = false;
form.errors.set(errors);
};
};

56
spark/resources/assets/js/forms/http.js vendored Normal file
View File

@@ -0,0 +1,56 @@
module.exports = {
/**
* Helper method for making POST HTTP requests.
*/
post(uri, form) {
return Spark.sendForm('post', uri, form);
},
/**
* Helper method for making PUT HTTP requests.
*/
put(uri, form) {
return Spark.sendForm('put', uri, form);
},
/**
* Helper method for making PATCH HTTP requests.
*/
patch(uri, form) {
return Spark.sendForm('patch', uri, form);
},
/**
* Helper method for making DELETE HTTP requests.
*/
delete(uri, form) {
return Spark.sendForm('delete', uri, form);
},
/**
* Send the form to the back-end server.
*
* This function will clear old errors, update "busy" status, etc.
*/
sendForm(method, uri, form) {
return new Promise((resolve, reject) => {
form.startProcessing();
axios[method](uri, JSON.parse(JSON.stringify(form)))
.then(response => {
form.finishProcessing();
resolve(response.data);
})
.catch(errors => {
form.setErrors(errors.response.data.errors);
reject(errors.response.data);
});
});
}
};

View File

@@ -0,0 +1,27 @@
module.exports = (request, next) => {
if (Cookies.get('XSRF-TOKEN') !== undefined) {
request.headers.set('X-XSRF-TOKEN', Cookies.get('XSRF-TOKEN'));
}
request.headers.set('X-CSRF-TOKEN', Spark.csrfToken);
/**
* Intercept the incoming responses.
*
* Handle any unexpected HTTP errors and pop up modals, etc.
*/
next(response => {
switch (response.status) {
case 401:
Vue.http.get('/logout');
$('#modal-session-expired').modal('show');
break;
case 402:
window.location = '/settings#/subscription';
break;
}
});
};

View File

@@ -0,0 +1,65 @@
function kioskAddDiscountForm () {
return {
type: 'amount',
value: null,
duration: 'once',
months: null
};
}
module.exports = {
mixins: [require('./../mixins/discounts')],
/**
* The component's data.
*/
data() {
return {
loadingCurrentDiscount: false,
currentDiscount: null,
discountingUser: null,
form: new SparkForm(kioskAddDiscountForm())
};
},
/**
* The component has been created by Vue.
*/
created() {
var self = this;
Bus.$on('addDiscount', function (user) {
self.form = new SparkForm(kioskAddDiscountForm());
self.setUser(user);
$('#modal-add-discount').modal('show');
});
},
methods: {
/**
* Set the user receiving teh discount.
*/
setUser(user) {
this.discountingUser = user;
this.getCurrentDiscountForUser(user);
},
/**
* Apply the discount to the user.
*/
applyDiscount() {
Spark.post('/spark/kiosk/users/discount/' + this.discountingUser.id, this.form)
.then(() => {
$('#modal-add-discount').modal('hide');
});
},
}
};

View File

@@ -0,0 +1,116 @@
var announcementsCreateForm = function () {
return {
body: '',
action_text: '',
action_url: ''
};
};
module.exports = {
/**
* The component's data.
*/
data() {
return {
announcements: [],
updatingAnnouncement: null,
deletingAnnouncement: null,
createForm: new SparkForm(announcementsCreateForm()),
updateForm: new SparkForm(announcementsCreateForm()),
deleteForm: new SparkForm({})
};
},
/**
* The component has been created by Vue.
*/
created() {
var self = this;
Bus.$on('sparkHashChanged', function (hash, parameters) {
if (hash == 'announcements' && self.announcements.length === 0) {
self.getAnnouncements();
}
});
},
methods: {
/**
* Get all of the announcements.
*/
getAnnouncements() {
axios.get('/spark/kiosk/announcements')
.then(response => {
this.announcements = response.data;
});
},
/**
* Create a new announcement.
*/
create() {
Spark.post('/spark/kiosk/announcements', this.createForm)
.then(() => {
this.createForm = new SparkForm(announcementsCreateForm());
this.getAnnouncements();
});
},
/**
* Edit the given announcement.
*/
editAnnouncement(announcement) {
this.updatingAnnouncement = announcement;
this.updateForm.icon = announcement.icon;
this.updateForm.body = announcement.body;
this.updateForm.action_text = announcement.action_text;
this.updateForm.action_url = announcement.action_url;
$('#modal-update-announcement').modal('show');
},
/**
* Update the specified announcement.
*/
update() {
Spark.put('/spark/kiosk/announcements/' + this.updatingAnnouncement.id, this.updateForm)
.then(() => {
this.getAnnouncements();
$('#modal-update-announcement').modal('hide');
});
},
/**
* Show the approval dialog for deleting an announcement.
*/
approveAnnouncementDelete(announcement) {
this.deletingAnnouncement = announcement;
$('#modal-delete-announcement').modal('show');
},
/**
* Delete the specified announcement.
*/
deleteAnnouncement() {
Spark.delete('/spark/kiosk/announcements/' + this.deletingAnnouncement.id, this.deleteForm)
.then(() => {
this.getAnnouncements();
$('#modal-delete-announcement').modal('hide');
});
}
}
};

View File

@@ -0,0 +1,33 @@
module.exports = {
props: ['user'],
/**
* Load mixins for the component.
*/
mixins: [require('./../mixins/tab-state')],
/**
* Prepare the component.
*/
mounted() {
this.usePushStateForTabs('.spark-settings-tabs');
},
/**
* The component has been created by Vue.
*/
created() {
Bus.$on('sparkHashChanged', function (hash, parameters) {
if (hash == 'users') {
setTimeout(() => {
$('#kiosk-users-search').focus();
}, 150);
}
return true;
});
}
};

View File

@@ -0,0 +1,294 @@
module.exports = {
props: ['user'],
/**
* The component's data.
*/
data() {
return {
monthlyRecurringRevenue: 0,
yearlyRecurringRevenue: 0,
totalVolume: 0,
genericTrialUsers: 0,
indicators: [],
lastMonthsIndicators: null,
lastYearsIndicators: null,
plans: []
};
},
/**
* The component has been created by Vue.
*/
created() {
var self = this;
Bus.$on('sparkHashChanged', function (hash, parameters) {
if (hash == 'metrics' && self.yearlyRecurringRevenue === 0) {
self.getRevenue();
self.getPlans();
self.getTrialUsers();
self.getPerformanceIndicators();
}
});
},
methods: {
/**
* Get the revenue information for the application.
*/
getRevenue() {
axios.get('/spark/kiosk/performance-indicators/revenue')
.then(response => {
this.yearlyRecurringRevenue = response.data.yearlyRecurringRevenue;
this.monthlyRecurringRevenue = response.data.monthlyRecurringRevenue;
this.totalVolume = response.data.totalVolume;
});
},
/**
* Get the subscriber information for the application.
*/
getPlans() {
axios.get('/spark/kiosk/performance-indicators/plans')
.then(response => {
this.plans = response.data;
});
},
/**
* Get the number of users that are on a generic trial.
*/
getTrialUsers() {
axios.get('/spark/kiosk/performance-indicators/trialing')
.then(response => {
this.genericTrialUsers = parseInt(response.data);
});
},
/**
* Get the performance indicators for the application.
*/
getPerformanceIndicators() {
axios.get('/spark/kiosk/performance-indicators')
.then(response => {
this.indicators = response.data.indicators;
this.lastMonthsIndicators = response.data.last_month;
this.lastYearsIndicators = response.data.last_year;
Vue.nextTick(() => {
this.drawCharts();
});
});
},
/**
* Draw the performance indicator charts.
*/
drawCharts() {
this.drawMonthlyRecurringRevenueChart();
this.drawYearlyRecurringRevenueChart();
this.drawDailyVolumeChart();
this.drawNewUsersChart();
},
/**
* Draw the monthly recurring revenue chart.
*/
drawMonthlyRecurringRevenueChart() {
return this.drawCurrencyChart(
'monthlyRecurringRevenueChart', 30, indicator => indicator.monthly_recurring_revenue
);
},
/**
* Draw the yearly recurring revenue chart.
*/
drawYearlyRecurringRevenueChart() {
return this.drawCurrencyChart(
'yearlyRecurringRevenueChart', 30, indicator => indicator.yearly_recurring_revenue
);
},
/**
* Draw the daily volume chart.
*/
drawDailyVolumeChart() {
return this.drawCurrencyChart(
'dailyVolumeChart', 14, indicator => indicator.daily_volume
);
},
/**
* Draw the daily new users chart.
*/
drawNewUsersChart() {
return this.drawChart(
'newUsersChart', 14, indicator => indicator.new_users
);
},
/**
* Draw a chart with currency formatting on the Y-Axis.
*/
drawCurrencyChart(id, days, dataGatherer) {
return this.drawChart(id, days, dataGatherer, value =>
Vue.filter('currency')(value.value)
);
},
/**
* Draw a chart with the given parameters.
*/
drawChart(id, days, dataGatherer, scaleLabelFormatter) {
var dataset = JSON.parse(JSON.stringify(this.baseChartDataSet));
dataset.data = _.map(_.last(this.indicators, days), dataGatherer);
// Here we will build out the dataset for the chart. This will contain the dates and data
// points for the chart. Each chart on the Kiosk only gets one dataset so we only need
// to add it a single element to this array here. But, charts could have more later.
var data = {
labels: _.last(this.availableChartDates, days),
datasets: [dataset]
};
var options = { responsive: true };
// If a scale label formatter was passed, we will hand that to this chart library to fill
// out the Y-Axis labels. This is particularly useful when we want to format them as a
// currency as we do on all of our revenue charts that we display on the Kiosk here.
if (arguments.length === 4) {
options.scaleLabel = scaleLabelFormatter;
}
var chart = new Chart(document.getElementById(id).getContext('2d'), {
type: 'line',
data: data,
options: options
});
},
/**
* Calculate the percent change between two numbers.
*/
percentChange(current, previous) {
var change = Math.round(((current - previous) / previous) * 100);
return change > 0 ? '+' + change.toFixed(0) : change.toFixed(0);
}
},
computed: {
/**
* Calculate the monthly change in monthly recurring revenue.
*/
monthlyChangeInMonthlyRecurringRevenue() {
if ( ! this.lastMonthsIndicators || ! this.indicators) {
return false;
}
return this.percentChange(
_.last(this.indicators).monthly_recurring_revenue,
this.lastMonthsIndicators.monthly_recurring_revenue
);
},
/**
* Calculate the yearly change in monthly recurring revenue.
*/
yearlyChangeInMonthlyRecurringRevenue() {
if ( ! this.lastYearsIndicators || ! this.indicators) {
return false;
}
return this.percentChange(
_.last(this.indicators).monthly_recurring_revenue,
this.lastYearsIndicators.monthly_recurring_revenue
);
},
/**
* Calculate the monthly change in yearly recurring revenue.
*/
monthlyChangeInYearlyRecurringRevenue() {
if ( ! this.lastMonthsIndicators || ! this.indicators) {
return false;
}
return this.percentChange(
_.last(this.indicators).yearly_recurring_revenue,
this.lastMonthsIndicators.yearly_recurring_revenue
);
},
/**
* Calculate the yearly change in yearly recurring revenue.
*/
yearlyChangeInYearlyRecurringRevenue() {
if ( ! this.lastYearsIndicators || ! this.indicators) {
return false;
}
;
return this.percentChange(
_.last(this.indicators).yearly_recurring_revenue,
this.lastYearsIndicators.yearly_recurring_revenue
);
},
/**
* Get the total number of users trialing.
*/
totalTrialUsers() {
return this.genericTrialUsers + _.reduce(this.plans, (memo, plan) => {
return memo + plan.trialing;
}, 0);
},
/**
* Get the available, formatted chart dates for the current indicators.
*/
availableChartDates() {
return _.map(this.indicators, indicator => {
return moment(indicator.created_at).format('M/D');
});
},
/**
* Get the base chart data set.
*/
baseChartDataSet() {
return {
label: "Dataset",
fillColor: "rgba(151,187,205,0.2)",
strokeColor: "rgba(151,187,205,1)",
pointColor: "rgba(151,187,205,1)",
pointStrokeColor: "#fff",
pointHighlightFill: "#fff",
pointHighlightStroke: "rgba(151,187,205,1)",
};
}
}
};

View File

@@ -0,0 +1,148 @@
module.exports = {
props: ['user', 'plans'],
/**
* The component's data.
*/
data() {
return {
loading: false,
profile: null,
revenue: 0
};
},
/**
* The component has been created by Vue.
*/
created() {
var self = this;
this.$parent.$on('showUserProfile', function(id) {
self.getUserProfile(id);
});
},
/**
* Prepare the component.
*/
mounted() {
Mousetrap.bind('esc', e => this.showSearch());
},
methods: {
/**
* Get the profile user.
*/
getUserProfile(id) {
this.loading = true;
axios.get('/spark/kiosk/users/' + id + '/profile')
.then(response => {
this.profile = response.data.user;
this.revenue = response.data.revenue;
this.loading = false;
});
},
/**
* Impersonate the given user.
*/
impersonate(user) {
window.location = '/spark/kiosk/users/impersonate/' + user.id;
},
/**
* Show the discount modal for the given user.
*/
addDiscount(user) {
Bus.$emit('addDiscount', user);
},
/**
* Get the plan the user is actively subscribed to.
*/
activePlan(billable) {
if (this.activeSubscription(billable)) {
var activeSubscription = this.activeSubscription(billable);
return _.find(this.plans, (plan) => {
return plan.id == activeSubscription.provider_plan;
});
}
},
/**
* Get the active, valid subscription for the user.
*/
activeSubscription(billable) {
var subscription = this.subscription(billable);
if ( ! subscription ||
(subscription.ends_at &&
moment.utc().isAfter(moment.utc(subscription.ends_at)))) {
return;
}
return subscription;
},
/**
* Get the active subscription instance.
*/
subscription(billable) {
if ( ! billable) {
return;
}
const subscription = _.find(
billable.subscriptions,
subscription => subscription.name == 'default'
);
if (typeof subscription !== 'undefined') {
return subscription;
}
},
/**
* Get the customer URL on the billing provider's website.
*/
customerUrlOnBillingProvider(billable) {
if (! billable) {
return;
}
if (this.spark.usesStripe) {
return 'https://dashboard.stripe.com/customers/' + billable.stripe_id;
} else {
var domain = Spark.env == 'production' ? '' : 'sandbox.';
return 'https://' + domain + 'braintreegateway.com/merchants/' +
Spark.braintreeMerchantId +
'/customers/' +
billable.braintree_id;
}
},
/**
* Show the search results and hide the user profile.
*/
showSearch() {
this.$parent.$emit('showSearch');
this.profile = null;
}
}
};

123
spark/resources/assets/js/kiosk/users.js vendored Normal file
View File

@@ -0,0 +1,123 @@
module.exports = {
props: ['user'],
/**
* The component's data.
*/
data() {
return {
plans: [],
searchForm: new SparkForm({
query: ''
}),
searching: false,
noSearchResults: false,
searchResults: [],
showingUserProfile: false
};
},
/**
* The component has been created by Vue.
*/
created() {
var self = this;
this.getPlans();
this.$on('showSearch', function(){
self.navigateToSearch();
});
Bus.$on('sparkHashChanged', function (hash, parameters) {
if (hash != 'users') {
return true;
}
if (parameters && parameters.length > 0) {
self.loadProfile({ id: parameters[0] });
} else {
self.showSearch();
}
return true;
});
},
methods: {
/**
* Get all of the available subscription plans.
*/
getPlans() {
axios.get('/spark/plans')
.then(response => {
this.plans = response.data;
});
},
/**
* Perform a search for the given query.
*/
search() {
this.searching = true;
this.noSearchResults = false;
axios.post('/spark/kiosk/users/search', this.searchForm)
.then(response => {
this.searchResults = response.data;
this.noSearchResults = this.searchResults.length === 0;
this.searching = false;
});
},
/**
* Show the search results and update the browser history.
*/
navigateToSearch() {
history.pushState(null, null, '#/users');
this.showSearch();
},
/**
* Show the search results.
*/
showSearch() {
this.showingUserProfile = false;
Vue.nextTick(function () {
$('#kiosk-users-search').focus();
});
},
/**
* Show the user profile for the given user.
*/
showUserProfile(user) {
history.pushState(null, null, '#/users/' + user.id);
this.loadProfile(user);
},
/**
* Load the user profile for the given user.
*/
loadProfile(user) {
this.$emit('showUserProfile', user.id);
this.showingUserProfile = true;
}
}
};

30
spark/resources/assets/js/mixin.js vendored Normal file
View File

@@ -0,0 +1,30 @@
module.exports = {
computed: {
/**
* Get the billable entity.
*/
billable() {
if (this.billableType) {
return this.billableType == 'user' ? this.user : this.team;
} else {
return this.user;
}
},
/**
* Determine if the current billable entity is a user.
*/
billingUser() {
return this.billableType && this.billableType == 'user';
},
/**
* Access the global Spark object.
*/
spark() {
return window.Spark;
}
}
};

View File

@@ -0,0 +1,38 @@
window.braintreeCheckout = [];
module.exports = {
methods: {
/**
* Configure the Braintree container.
*/
braintree(containerName, callback) {
braintree.setup(Spark.braintreeToken, 'dropin', {
container: containerName,
paypal: {
singleUse: false,
locale: 'en_us',
enableShippingAddress: false
},
dataCollector: {
paypal: true
},
onReady(checkout) {
window.braintreeCheckout[containerName] = checkout;
},
onPaymentMethodReceived: callback
});
},
/**
* Reset the Braintree container.
*/
resetBraintree(containerName, callback) {
window.braintreeCheckout[containerName].teardown(() => {
window.braintreeCheckout[containerName] = null;
this.braintree(containerName, callback);
});
}
}
};

View File

@@ -0,0 +1,96 @@
module.exports = {
methods: {
/**
* Get the current discount for the given billable entity.
*/
getCurrentDiscountForBillable(type, billable) {
if (type === 'user') {
return this.getCurrentDiscountForUser(billable);
} else {
return this.getCurrentDiscountForTeam(billable);
}
},
/**
* Get the current discount for the user.
*/
getCurrentDiscountForUser(user) {
this.currentDiscount = null;
this.loadingCurrentDiscount = true;
axios.get(`/coupon/user/${user.id}`)
.then(response => {
if (response.status == 200) {
this.currentDiscount = response.data;
}
this.loadingCurrentDiscount = false;
});
},
/**
* Get the current discount for the team.
*/
getCurrentDiscountForTeam(team) {
this.currentDiscount = null;
this.loadingCurrentDiscount = true;
axios.get(`/coupon/${Spark.teamString}/${team.id}`)
.then(response => {
if (response.status == 200) {
this.currentDiscount = response.data;
}
this.loadingCurrentDiscount = false;
});
},
/**
* Get the formatted discount amount for the given discount.
*/
formattedDiscount(discount) {
if ( ! discount) {
return
}
if (discount.percent_off) {
return `${discount.percent_off}%`;
} else {
return Vue.filter('currency')(
this.calculateAmountOff(discount.amount_off)
);
}
},
/**
* Calculate the amount off for the given discount amount.
*/
calculateAmountOff(amount) {
return amount / 100;
},
/**
* Get the formatted discount duration for the given discount.
*/
formattedDiscountDuration(discount) {
if ( ! discount) {
return;
}
switch (discount.duration) {
case 'forever':
return 'all future invoices';
case 'once':
return 'a single invoice';
case 'repeating':
return `all invoices during the next ${discount.duration_in_months} months`;
}
}
}
};

View File

@@ -0,0 +1,140 @@
/*
* This mixin is primarily used to share code for the "interval" selector
* on the registration and subscription screens, which is used to show
* all of the various subscription plans offered by the application.
*/
module.exports = {
data() {
return {
selectedPlan: null,
detailingPlan: null,
showingMonthlyPlans: true,
showingYearlyPlans: false
};
},
methods: {
/**
* Switch to showing monthly plans.
*/
showMonthlyPlans() {
this.showingMonthlyPlans = true;
this.showingYearlyPlans = false;
},
/**
* Switch to showing yearly plans.
*/
showYearlyPlans() {
this.showingMonthlyPlans = false;
this.showingYearlyPlans = true;
},
/**
* Show the plan details for the given plan.
*/
showPlanDetails(plan) {
this.detailingPlan = plan;
$('#modal-plan-details').modal('show');
}
},
computed: {
/**
* Get the active "interval" being displayed.
*/
activeInterval() {
return this.showingMonthlyPlans ? 'monthly' : 'yearly';
},
/**
* Get all of the plans for the active interval.
*/
plansForActiveInterval() {
return _.filter(this.plans, plan => {
return plan.active && (plan.price == 0 || plan.interval == this.activeInterval);
});
},
/**
* Get all of the paid plans.
*/
paidPlans() {
return _.filter(this.plans, plan => {
return plan.active && plan.price > 0;
});
},
/**
* Get all of the paid plans for the active interval.
*/
paidPlansForActiveInterval() {
return _.filter(this.plansForActiveInterval, plan => {
return plan.active && plan.price > 0;
});
},
/**
* Determine if both monthly and yearly plans are available.
*/
hasMonthlyAndYearlyPlans() {
return this.monthlyPlans.length > 0 && this.yearlyPlans.length > 0;
},
/**
* Determine if both monthly and yearly plans are available.
*/
hasMonthlyAndYearlyPaidPlans() {
return _.where(this.paidPlans, {interval: 'monthly'}).length > 0 &&
_.where(this.paidPlans, {interval: 'yearly'}).length > 0;
},
/**
* Determine if only yearly plans are available.
*/
onlyHasYearlyPlans() {
return this.monthlyPlans.length == 0 && this.yearlyPlans.length > 0;
},
/**
* Determine if both monthly and yearly plans are available.
*/
onlyHasYearlyPaidPlans() {
return _.where(this.paidPlans, {interval: 'monthly'}).length == 0 &&
_.where(this.paidPlans, {interval: 'yearly'}).length > 0;
},
/**
* Get all of the monthly plans.
*/
monthlyPlans() {
return _.filter(this.plans, plan => {
return plan.active && plan.interval == 'monthly';
});
},
/**
* Get all of the yearly plans.
*/
yearlyPlans() {
return _.filter(this.plans, plan => {
return plan.active && plan.interval == 'yearly';
});
}
}
};

View File

@@ -0,0 +1,138 @@
module.exports = {
/**
* The mixin's data.
*/
data() {
return {
plans: [],
selectedPlan: null,
invitation: null,
invalidInvitation: false
};
},
methods: {
/**
* Get the active plans for the application.
*/
getPlans() {
if ( ! Spark.cardUpFront) {
return;
}
axios.get('/spark/plans')
.then(response => {
var plans = response.data;
this.plans = _.where(plans, {type: "user"}).length > 0
? _.where(plans, {type: "user"})
: _.where(plans, {type: "team"});
this.selectAppropriateDefaultPlan();
});
},
/**
* Get the invitation specified in the query string.
*/
getInvitation() {
axios.get(`/invitations/${this.query.invitation}`)
.then(response => {
this.invitation = response.data;
})
.catch(response => {
this.invalidInvitation = true;
});
},
/**
* Select the appropriate default plan for registration.
*/
selectAppropriateDefaultPlan() {
if (this.query.plan) {
this.selectPlanById(this.query.plan) || this.selectPlanByName(this.query.plan);
} else if (this.query.invitation) {
this.selectFreePlan();
} else if (this.paidPlansForActiveInterval.length > 0) {
this.selectPlan(this.paidPlansForActiveInterval[0]);
} else {
this.selectFreePlan();
}
if (this.shouldShowYearlyPlans()) {
this.showYearlyPlans();
}
},
/**
* Select the free plan.
*/
selectFreePlan() {
const plan = _.find(this.plans, plan => plan.price === 0);
if (typeof plan !== 'undefined') {
this.selectPlan(plan);
}
},
/**
* Select the plan with the given id.
*/
selectPlanById(id) {
_.each(this.plans, plan => {
if (plan.id == id) {
this.selectPlan(plan);
}
});
return this.selectedPlan;
},
/**
* Select the plan with the given name.
*/
selectPlanByName(name) {
_.each(this.plans, plan => {
if (plan.name == name) {
this.selectPlan(plan);
}
});
return this.selectedPlan;
},
/**
* Determine if the given plan is selected.
*/
isSelected(plan) {
return this.selectedPlan && plan.id == this.selectedPlan.id;
},
/**
* Select the given plan.
*/
selectPlan(plan) {
this.selectedPlan = plan;
this.registerForm.plan = plan.id;
},
/**
* Determine if we should show the yearly plans.
*/
shouldShowYearlyPlans(){
return (this.monthlyPlans.length == 0 && this.yearlyPlans.length > 0) ||
this.selectedPlan.interval == 'yearly'
}
}
};

View File

@@ -0,0 +1,179 @@
/*
* This mixin is used by most of the subscription related screens to select plans
* and send subscription plan changes to the server. This contains helpers for
* the active subscription, trial information and other convenience helpers.
*/
module.exports = {
/**
* The mixin's data.
*/
data() {
return {
selectingPlan: null,
planForm: new SparkForm({})
}
},
methods: {
/**
* Update the subscription to the given plan.
*
* Used when updating or resuming the subscription plan.
*/
updateSubscription(plan) {
this.selectingPlan = plan;
this.planForm.errors.forget();
// Here we will send the request to the server to update the subscription plan and
// update the user and team once the request is complete. This method gets used
// for both updating subscriptions plus resuming any cancelled subscriptions.
axios.put(this.urlForPlanUpdate, {"plan": plan.id})
.then(() => {
Bus.$emit('updateUser');
Bus.$emit('updateTeam');
})
.catch(errors => {
if (errors.response.status == 422) {
this.planForm.errors.set(errors.response.data);
} else {
this.planForm.errors.set({plan: ["We were unable to update your subscription. Please contact customer support."]});
}
})
.finally(() => {
this.selectingPlan = null;
});
},
/**
* Determine if the given plan is selected.
*/
isActivePlan(plan) {
return this.activeSubscription &&
this.activeSubscription.provider_plan == plan.id;
}
},
computed: {
/**
* Get the active plan instance.
*/
activePlan() {
if (this.activeSubscription) {
return _.find(this.plans, (plan) => {
return plan.id == this.activeSubscription.provider_plan;
});
}
},
/**
* Determine if the active plan is a monthly plan.
*/
activePlanIsMonthly() {
return this.activePlan && this.activePlan.interval == 'monthly';
},
/**
* Get the active subscription instance.
*/
activeSubscription() {
if ( ! this.billable) {
return;
}
const subscription = _.find(
this.billable.subscriptions,
subscription => subscription.name == 'default'
);
if (typeof subscription !== 'undefined') {
return subscription;
}
},
/**
* Determine if the current subscription is active.
*/
subscriptionIsActive() {
return this.activeSubscription && ! this.activeSubscription.ends_at;
},
/**
* Determine if the billable entity is on a generic trial.
*/
onGenericTrial() {
return this.billable.trial_ends_at &&
moment.utc(this.billable.trial_ends_at).isAfter(moment.utc());
},
/**
* Determine if the current subscription is active.
*/
subscriptionIsOnTrial() {
if (this.onGenericTrial) {
return true;
}
return this.activeSubscription &&
this.activeSubscription.trial_ends_at &&
moment.utc().isBefore(moment.utc(this.activeSubscription.trial_ends_at));
},
/**
* Get the formatted trial ending date.
*/
trialEndsAt() {
if ( ! this.subscriptionIsOnTrial) {
return;
}
if (this.onGenericTrial) {
return moment.utc(this.billable.trial_ends_at)
.local().format('MMMM Do, YYYY');
}
return moment.utc(this.activeSubscription.trial_ends_at)
.local().format('MMMM Do, YYYY');
},
/**
* Determine if the current subscription is active.
*/
subscriptionIsOnGracePeriod() {
return this.activeSubscription &&
this.activeSubscription.ends_at &&
moment.utc().isBefore(moment.utc(this.activeSubscription.ends_at));
},
/**
* Determine if the billable entity has no active subscription.
*/
needsSubscription() {
return ! this.activeSubscription ||
(this.activeSubscription.ends_at &&
moment.utc().isAfter(moment.utc(this.activeSubscription.ends_at)));
},
/**
* Get the URL for the subscription plan update.
*/
urlForPlanUpdate() {
return this.billingUser
? '/settings/subscription'
: `/settings/${Spark.pluralTeamString}/${this.team.id}/subscription`;
}
}
};

View File

@@ -0,0 +1,91 @@
module.exports = {
pushStateSelector: null,
methods: {
/**
* Initialize push state handling for tabs.
*/
usePushStateForTabs(selector) {
this.pushStateSelector = selector;
this.registerTabClickHandler();
window.addEventListener('popstate', e => {
this.activateTabForCurrentHash();
});
if (window.location.hash) {
this.activateTabForCurrentHash();
} else {
this.activateFirstTab();
}
},
/**
* Register the click handler for all of the tabs.
*/
registerTabClickHandler() {
const self = this;
$(`${this.pushStateSelector} a[data-toggle="tab"]`).on('click', function(e) {
self.removeActiveClassFromTabs();
history.pushState(null, null, '#/' + $(this).attr('href').substring(1));
self.broadcastTabChange($(this).attr('href').substring(1));
});
},
/**
* Activate the tab for the current hash in the URL.
*/
activateTabForCurrentHash() {
var hash = window.location.hash.substring(2);
var parameters = hash.split('/');
hash = parameters.shift();
this.removeActiveClassFromTabs();
const tab = $(`${this.pushStateSelector} a[href="#${hash}"][data-toggle="tab"]`);
if (tab.length > 0) {
tab.tab('show');
}
this.broadcastTabChange(hash, parameters);
},
/**
* Activate the first tab in a list.
*/
activateFirstTab() {
const tab = $(`${this.pushStateSelector} a[data-toggle="tab"]`).first();
tab.tab('show');
this.broadcastTabChange(tab.attr('href').substring(1));
},
/**
* Remove the active class from the tabs.
*/
removeActiveClassFromTabs() {
$(`${this.pushStateSelector} li`).removeClass('active');
},
/**
* Broadcast that a tab change happened.
*/
broadcastTabChange(hash, parameters) {
Bus.$emit('sparkHashChanged', hash, parameters);
}
}
};

44
spark/resources/assets/js/mixins/vat.js vendored Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
methods: {
/**
* Determine if the given country collects European VAT.
*/
collectsVat(country) {
return Spark.collectsEuropeanVat ? _.contains([
'BE', 'BG', 'CZ', 'DK', 'DE',
'EE', 'IE', 'GR', 'ES', 'FR',
'HR', 'IT', 'CY', 'LV', 'LT',
'LU', 'HU', 'MT', 'NL', 'AT',
'PL', 'PT', 'RO', 'SI', 'SK',
'FI', 'SE', 'GB',
], country) : false;
},
/**
* Refresh the tax rate using the given form input.
*/
refreshTaxRate(form) {
axios.post('/tax-rate', JSON.parse(JSON.stringify(form)))
.then(response => {
this.taxRate = response.data.rate;
});
},
/**
* Get the tax amount for the selected plan.
*/
taxAmount(plan) {
return plan.price * (this.taxRate / 100);
},
/**
* Get the total plan price including the applicable tax.
*/
priceWithTax(plan) {
return plan.price + this.taxAmount(plan);
}
}
};

View File

@@ -0,0 +1,24 @@
module.exports = {
props: [
'user', 'teams', 'currentTeam',
'hasUnreadNotifications', 'hasUnreadAnnouncements'
],
methods: {
/**
* Show the user's notifications.
*/
showNotifications() {
Bus.$emit('showNotifications');
},
/**
* Show the customer support e-mail form.
*/
showSupportForm() {
Bus.$emit('showSupportForm');
}
}
};

View File

@@ -0,0 +1,80 @@
module.exports = {
props: ['notifications', 'hasUnreadAnnouncements', 'loadingNotifications'],
/**
* The component's data.
*/
data() {
return {
showingNotifications: true,
showingAnnouncements: false
}
},
methods: {
/**
* Show the user notifications.
*/
showNotifications() {
this.showingNotifications = true;
this.showingAnnouncements = false;
},
/**
* Show the product announcements.
*/
showAnnouncements() {
this.showingNotifications = false;
this.showingAnnouncements = true;
this.updateLastReadAnnouncementsTimestamp();
},
/**
* Update the last read announcements timestamp.
*/
updateLastReadAnnouncementsTimestamp() {
axios.put('/user/last-read-announcements-at')
.then(() => {
Bus.$emit('updateUser');
});
}
},
computed: {
/**
* Get the active notifications or announcements.
*/
activeNotifications() {
if ( ! this.notifications) {
return [];
}
if (this.showingNotifications) {
return this.notifications.notifications;
} else {
return this.notifications.announcements;
}
},
/**
* Determine if the user has any notifications.
*/
hasNotifications() {
return this.notifications && this.notifications.notifications.length > 0;
},
/**
* Determine if the user has any announcements.
*/
hasAnnouncements() {
return this.notifications && this.notifications.announcements.length > 0;
}
}
};

View File

@@ -0,0 +1,52 @@
module.exports = {
/**
* The component's data.
*/
data() {
return {
tokens: [],
availableAbilities: []
};
},
/**
* Prepare the component.
*/
mounted() {
this.getTokens();
this.getAvailableAbilities();
},
/**
* The component has been created by Vue.
*/
created() {
var self = this;
this.$on('updateTokens', function(){
self.getTokens();
});
},
methods: {
/**
* Get the current API tokens for the user.
*/
getTokens() {
axios.get('/settings/api/tokens')
.then(response => this.tokens = response.data);
},
/**
* Get all of the available token abilities.
*/
getAvailableAbilities() {
axios.get('/settings/api/token/abilities')
.then(response => this.availableAbilities = response.data);
}
}
};

View File

@@ -0,0 +1,139 @@
module.exports = {
props: ['availableAbilities'],
/**
* The component's data.
*/
data() {
return {
showingToken: null,
allAbilitiesAssigned: false,
form: new SparkForm({
name: '',
abilities: []
})
};
},
computed: {
copyCommandSupported() {
return document.queryCommandSupported('copy');
}
},
watch: {
/**
* Watch the available abilities for changes.
*/
availableAbilities() {
if (this.availableAbilities.length > 0) {
this.assignDefaultAbilities();
}
}
},
methods: {
/**
* Assign all of the default abilities.
*/
assignDefaultAbilities() {
var defaults = _.filter(this.availableAbilities, a => a.default);
this.form.abilities = _.pluck(defaults, 'value');
},
/**
* Enable all the available abilities for the given token.
*/
assignAllAbilities() {
this.allAbilitiesAssigned = true;
this.form.abilities = _.pluck(this.availableAbilities, 'value');
},
/**
* Remove all of the abilities from the token.
*/
removeAllAbilities() {
this.allAbilitiesAssigned = false;
this.form.abilities = [];
},
/**
* Toggle the given ability in the list of assigned abilities.
*/
toggleAbility(ability) {
if (this.abilityIsAssigned(ability)) {
this.form.abilities = _.reject(this.form.abilities, a => a == ability);
} else {
this.form.abilities.push(ability);
}
},
/**
* Determine if the given ability has been assigned to the token.
*/
abilityIsAssigned(ability) {
return _.contains(this.form.abilities, ability);
},
/**
* Create a new API token.
*/
create() {
Spark.post('/settings/api/token', this.form)
.then(response => {
this.showToken(response.token);
this.resetForm();
this.$parent.$emit('updateTokens');
});
},
/**
* Display the token to the user.
*/
showToken(token) {
this.showingToken = token;
$('#modal-show-token').modal('show');
},
/**
* Select the token and copy to Clipboard.
*/
selectToken() {
$('#api-token').select();
if (this.copyCommandSupported) {
document.execCommand("copy");
}
},
/**
* Reset the token form back to its default state.
*/
resetForm() {
this.form.name = '';
this.assignDefaultAbilities();
this.allAbilitiesAssigned = false;
}
}
};

View File

@@ -0,0 +1,103 @@
module.exports = {
props: ['tokens', 'availableAbilities'],
/**
* The component's data.
*/
data() {
return {
updatingToken: null,
deletingToken: null,
updateTokenForm: new SparkForm({
name: '',
abilities: []
}),
deleteTokenForm: new SparkForm({})
}
},
methods: {
/**
* Show the edit token modal.
*/
editToken(token) {
this.updatingToken = token;
this.initializeUpdateFormWith(token);
$('#modal-update-token').modal('show');
},
/**
* Initialize the edit form with the given token.
*/
initializeUpdateFormWith(token) {
this.updateTokenForm.name = token.name;
this.updateTokenForm.abilities = token.metadata.abilities;
},
/**
* Update the token being edited.
*/
updateToken() {
Spark.put(`/settings/api/token/${this.updatingToken.id}`, this.updateTokenForm)
.then(response => {
this.$parent.$emit('updateTokens');
$('#modal-update-token').modal('hide');
})
},
/**
* Toggle the ability on the current token being edited.
*/
toggleAbility(ability) {
if (this.abilityIsAssigned(ability)) {
this.updateTokenForm.abilities = _.reject(
this.updateTokenForm.abilities, a => a == ability
);
} else {
this.updateTokenForm.abilities.push(ability);
}
},
/**
* Determine if the ability has been assigned to the token being edited.
*/
abilityIsAssigned(ability) {
return _.contains(this.updateTokenForm.abilities, ability);
},
/**
* Get user confirmation that the token should be deleted.
*/
approveTokenDelete(token) {
this.deletingToken = token;
$('#modal-delete-token').modal('show');
},
/**
* Delete the specified token.
*/
deleteToken() {
Spark.delete(`/settings/api/token/${this.deletingToken.id}`, this.deleteTokenForm)
.then(() => {
this.$parent.$emit('updateTokens');
$('#modal-delete-token').modal('hide');
});
}
}
};

View File

@@ -0,0 +1,47 @@
module.exports = {
props: ['user', 'team', 'billableType'],
/**
* The component's data.
*/
data() {
return {
invoices: []
};
},
/**
* Prepare the component.
*/
mounted() {
this.getInvoices();
},
methods: {
/**
* Get the user's billing invoices
*/
getInvoices() {
axios.get(this.urlForInvoices)
.then(response => {
this.invoices = _.filter(response.data, invoice => {
return invoice.total != '$0.00';
});
});
}
},
computed: {
/**
* Get the URL for retrieving the invoices.
*/
urlForInvoices() {
return this.billingUser
? '/settings/invoices'
: `/settings/${Spark.pluralTeamString}/${this.team.id}/invoices`;
}
}
};

View File

@@ -0,0 +1,15 @@
module.exports = {
props: ['user', 'team', 'invoices', 'billableType'],
methods: {
/**
* Get the URL for downloading a given invoice.
*/
downloadUrlFor(invoice) {
return this.billingUser
? `/settings/invoice/${invoice.id}`
: `/settings/${Spark.pluralTeamString}/${this.team.id}/invoice/${invoice.id}`;
}
}
};

View File

@@ -0,0 +1,45 @@
module.exports = {
props: ['user', 'team', 'billableType'],
/**
* The component's data.
*/
data() {
return {
form: new SparkForm({
information: ''
})
};
},
/**
* Prepare the component.
*/
mounted() {
this.form.information = this.billable.extra_billing_information;
},
methods: {
/**
* Update the extra billing information.
*/
update() {
Spark.put(this.urlForUpdate, this.form);
}
},
computed: {
/**
* Get the URL for the extra billing information method update.
*/
urlForUpdate() {
return this.billingUser
? '/settings/extra-billing-information'
: `/settings/${Spark.pluralTeamString}/${this.team.id}/extra-billing-information`;
}
}
};

View File

@@ -0,0 +1,77 @@
module.exports = {
props: ['user', 'team', 'billableType'],
/**
* Load mixins for the component.
*/
mixins: [
require('./../mixins/discounts')
],
/**
* The componetn's data.
*/
data() {
return {
currentDiscount: null,
loadingCurrentDiscount: false
};
},
/**
* The component has been created by Vue.
*/
created() {
var self = this;
this.$on('updateDiscount', function(){
self.getCurrentDiscountForBillable(self.billableType, self.billable);
return true;
})
},
/**
* Prepare the component.
*/
mounted() {
this.getCurrentDiscountForBillable(this.billableType, this.billable);
},
methods: {
/**
* Calculate the amount off for the given discount amount.
*/
calculateAmountOff(amount) {
return amount;
},
/**
* Get the formatted discount duration for the given discount.
*/
formattedDiscountDuration(discount) {
if ( ! discount) {
return;
}
switch (discount.duration) {
case 'forever':
return 'for all future invoices';
case 'once':
return 'a single invoice';
case 'repeating':
if (discount.duration_in_months === 1) {
return 'all invoices during the next billing cycle';
} else {
return `all invoices during the next ${discount.duration_in_months} billing cycles`;
}
}
}
}
};

View File

@@ -0,0 +1,43 @@
module.exports = {
props: ['user', 'team', 'billableType'],
/**
* Load mixins for the component.
*/
mixins: [
require('./../mixins/discounts')
],
/**
* The componetn's data.
*/
data() {
return {
currentDiscount: null,
loadingCurrentDiscount: false
};
},
/**
* The component has been created by Vue.
*/
created() {
var self = this;
this.$on('updateDiscount', function(){
self.getCurrentDiscountForBillable(self.billableType, self.billable);
return true;
})
},
/**
* Prepare the component.
*/
mounted() {
this.getCurrentDiscountForBillable(this.billableType, this.billable);
},
};

View File

@@ -0,0 +1,41 @@
module.exports = {
props: ['user', 'team', 'billableType'],
/**
* The component's data.
*/
data() {
return {
form: new SparkForm({
coupon: ''
})
};
},
methods: {
/**
* Redeem the given coupon code.
*/
redeem() {
Spark.post(this.urlForRedemption, this.form)
.then(() => {
this.form.coupon = '';
this.$parent.$emit('updateDiscount');
});
}
},
computed: {
/**
* Get the URL for redeeming a coupon.
*/
urlForRedemption() {
return this.billingUser
? '/settings/payment-method/coupon'
: `/settings/${Spark.pluralTeamString}/${this.team.id}/payment-method/coupon`;
}
}
};

View File

@@ -0,0 +1,104 @@
module.exports = {
props: ['user', 'team', 'billableType'],
/**
* Load mixins for the component.
*/
mixins: [
require('./../../mixins/braintree')
],
/**
* The component's data.
*/
data() {
return {
form: new SparkForm({
braintree_type: '',
braintree_token: '',
})
};
},
/**
* Prepare the component.
*/
mounted() {
this.braintree(
'braintree-payment-method-container',
this.braintreeCallback
);
},
methods: {
/**
* Update the entity's card information.
*/
update() {
Spark.put(this.urlForUpdate, this.form)
.then(() => {
Bus.$emit('updateUser');
Bus.$emit('updateTeam');
this.resetBraintree(
'braintree-payment-method-container',
this.braintreeCallback
);
});
},
/**
* The Braintree payment method received callback.
*/
braintreeCallback(response) {
this.form.braintree_type = response.type;
this.form.braintree_token = response.nonce;
this.update();
}
},
computed: {
/**
* Get the URL for the payment method update.
*/
urlForUpdate() {
return this.billingUser
? '/settings/payment-method'
: `/settings/${Spark.pluralTeamString}/${this.team.id}/payment-method`;
},
/**
* Get the proper brand icon for the customer's credit card.
*/
cardIcon() {
if (! this.billable.card_brand) {
return 'fa-credit-card';
}
switch (this.billable.card_brand) {
case 'American Express':
return 'fa-cc-amex';
case 'Diners Club':
return 'fa-cc-diners-club';
case 'Discover':
return 'fa-cc-discover';
case 'JCB':
return 'fa-cc-jcb';
case 'MasterCard':
return 'fa-cc-mastercard';
case 'Visa':
return 'fa-cc-visa';
default:
return 'fa-credit-card';
}
}
}
};

View File

@@ -0,0 +1,183 @@
module.exports = {
props: ['user', 'team', 'billableType'],
/**
* The component's data.
*/
data() {
return {
form: new SparkForm({
stripe_token: '',
address: '',
address_line_2: '',
city: '',
state: '',
zip: '',
country: 'US'
}),
cardForm: new SparkForm({
name: '',
number: '',
cvc: '',
month: '',
year: ''
})
};
},
/**
* Prepare the component.
*/
mounted() {
Stripe.setPublishableKey(Spark.stripeKey);
this.initializeBillingAddress();
},
methods: {
/**
* Initialize the billing address form for the billable entity.
*/
initializeBillingAddress() {
if (! Spark.collectsBillingAddress) {
return;
}
this.form.address = this.billable.billing_address;
this.form.address_line_2 = this.billable.billing_address_line_2;
this.form.city = this.billable.billing_city;
this.form.state = this.billable.billing_state;
this.form.zip = this.billable.billing_zip;
this.form.country = this.billable.billing_country || 'US';
},
/**
* Update the billable's card information.
*/
update() {
this.form.busy = true;
this.form.errors.forget();
this.form.successful = false;
this.cardForm.errors.forget();
// Here we will build out the payload to send to Stripe to obtain a card token so
// we can create the actual subscription. We will build out this data that has
// this credit card number, CVC, etc. and exchange it for a secure token ID.
const payload = {
name: this.cardForm.name,
number: this.cardForm.number,
cvc: this.cardForm.cvc,
exp_month: this.cardForm.month,
exp_year: this.cardForm.year,
address_line1: this.form.address,
address_line2: this.form.address_line_2,
address_city: this.form.city,
address_state: this.form.state,
address_zip: this.form.zip,
address_country: this.form.country,
};
// Once we have the Stripe payload we'll send it off to Stripe and obtain a token
// which we will send to the server to update this payment method. If there is
// an error we will display that back out to the user for their information.
Stripe.card.createToken(payload, (status, response) => {
if (response.error) {
this.cardForm.errors.set({number: [
response.error.message
]});
this.form.busy = false;
} else {
this.sendUpdateToServer(response.id);
}
});
},
/**
* Send the credit card update information to the server.
*/
sendUpdateToServer(token) {
this.form.stripe_token = token;
Spark.put(this.urlForUpdate, this.form)
.then(() => {
Bus.$emit('updateUser');
Bus.$emit('updateTeam');
this.cardForm.name = '';
this.cardForm.number = '';
this.cardForm.cvc = '';
this.cardForm.month = '';
this.cardForm.year = '';
if ( ! Spark.collectsBillingAddress) {
this.form.zip = '';
}
});
}
},
computed: {
/**
* Get the billable entity's "billable" name.
*/
billableName() {
return this.billingUser ? this.user.name : this.team.owner.name;
},
/**
* Get the URL for the payment method update.
*/
urlForUpdate() {
return this.billingUser
? '/settings/payment-method'
: `/settings/${Spark.pluralTeamString}/${this.team.id}/payment-method`;
},
/**
* Get the proper brand icon for the customer's credit card.
*/
cardIcon() {
if (! this.billable.card_brand) {
return 'fa-cc-stripe';
}
switch (this.billable.card_brand) {
case 'American Express':
return 'fa-cc-amex';
case 'Diners Club':
return 'fa-cc-diners-club';
case 'Discover':
return 'fa-cc-discover';
case 'JCB':
return 'fa-cc-jcb';
case 'MasterCard':
return 'fa-cc-mastercard';
case 'Visa':
return 'fa-cc-visa';
default:
return 'fa-cc-stripe';
}
},
/**
* Get the placeholder for the billable entity's credit card.
*/
placeholder() {
if (this.billable.card_last_four) {
return `************${this.billable.card_last_four}`;
}
return '';
}
}
};

View File

@@ -0,0 +1,42 @@
module.exports = {
props: ['user', 'team', 'billableType'],
/**
* The component's data.
*/
data() {
return {
form: new SparkForm({ vat_id: '' })
};
},
/**
* Bootstrap the component.
*/
mounted() {
this.form.vat_id = this.billable.vat_id;
},
methods: {
/**
* Update the customer's VAT ID.
*/
update() {
Spark.put(this.urlForUpdate, this.form);
}
},
computed: {
/**
* Get the URL for the VAT ID update.
*/
urlForUpdate() {
return this.billingUser
? '/settings/payment-method/vat-id'
: `/settings/${Spark.pluralTeamString}/${this.team.id}/payment-method/vat-id`;
}
}
}

View File

@@ -0,0 +1,3 @@
module.exports = {
props: ['user']
};

View File

@@ -0,0 +1,37 @@
module.exports = {
props: ['user'],
/**
* The component's data.
*/
data() {
return {
form: $.extend(true, new SparkForm({
name: '',
email: ''
}), Spark.forms.updateContactInformation)
};
},
/**
* Bootstrap the component.
*/
mounted() {
this.form.name = this.user.name;
this.form.email = this.user.email;
},
methods: {
/**
* Update the user's contact information.
*/
update() {
Spark.put('/settings/contact', this.form)
.then(() => {
Bus.$emit('updateUser');
});
}
}
};

View File

@@ -0,0 +1,67 @@
module.exports = {
props: ['user'],
/**
* The component's data.
*/
data() {
return {
form: new SparkForm({})
};
},
methods: {
/**
* Update the user's profile photo.
*/
update(e) {
e.preventDefault();
if ( ! this.$refs.photo.files.length) {
return;
}
var self = this;
this.form.startProcessing();
// We need to gather a fresh FormData instance with the profile photo appended to
// the data so we can POST it up to the server. This will allow us to do async
// uploads of the profile photos. We will update the user after this action.
axios.post('/settings/photo', this.gatherFormData())
.then(
() => {
Bus.$emit('updateUser');
self.form.finishProcessing();
},
(error) => {
self.form.setErrors(error.response.data.errors);
}
);
},
/**
* Gather the form data for the photo upload.
*/
gatherFormData() {
const data = new FormData();
data.append('photo', this.$refs.photo.files[0]);
return data;
}
},
computed: {
/**
* Calculate the style attribute for the photo preview.
*/
previewStyle() {
return `background-image: url(${this.user.photo_url})`;
}
}
};

View File

@@ -0,0 +1,27 @@
module.exports = {
props: ['user'],
/**
* The component's data.
*/
data() {
return {
twoFactorResetCode: null
};
},
/**
* The component has been created by Vue.
*/
created() {
var self = this;
this.$on('receivedTwoFactorResetCode', function (code) {
self.twoFactorResetCode = code;
$('#modal-show-two-factor-reset-code').modal('show');
});
}
};

View File

@@ -0,0 +1,25 @@
module.exports = {
props: ['user'],
/**
* The component's data.
*/
data() {
return {
form: new SparkForm({})
}
},
methods: {
/**
* Disable two-factor authentication for the user.
*/
disable() {
Spark.delete('/settings/two-factor-auth', this.form)
.then(() => {
Bus.$emit('updateUser');
});
}
}
};

View File

@@ -0,0 +1,39 @@
module.exports = {
props: ['user'],
/**
* The component's data.
*/
data() {
return {
form: new SparkForm({
country_code: '',
phone: ''
})
}
},
/**
* Prepare the component.
*/
mounted() {
this.form.country_code = this.user.country_code;
this.form.phone = this.user.phone;
},
methods: {
/**
* Enable two-factor authentication for the user.
*/
enable() {
Spark.post('/settings/two-factor-auth', this.form)
.then(code => {
this.$parent.$emit('receivedTwoFactorResetCode', code);
Bus.$emit('updateUser');
});
}
}
};

View File

@@ -0,0 +1,24 @@
module.exports = {
/**
* The component's data.
*/
data() {
return {
form: new SparkForm({
current_password: '',
password: '',
password_confirmation: ''
})
};
},
methods: {
/**
* Update the user's password.
*/
update() {
Spark.put('/settings/password', this.form);
}
}
};

View File

@@ -0,0 +1,28 @@
module.exports = {
props: ['user', 'teams'],
/**
* Load mixins for the component.
*/
mixins: [require('./../mixins/tab-state')],
/**
* The component's data.
*/
data() {
return {
billableType: 'user',
team: null
};
},
/**
* Prepare the component.
*/
mounted() {
this.usePushStateForTabs('.spark-settings-tabs');
}
};

View File

@@ -0,0 +1,50 @@
module.exports = {
props: ['user', 'team', 'billableType'],
/**
* Load mixins for the component.
*/
mixins: [
require('./../mixins/plans'),
require('./../mixins/subscriptions')
],
/**
* The component's data.
*/
data() {
return {
plans: []
};
},
/**
* Prepare the component.
*/
mounted() {
var self = this;
this.getPlans();
this.$on('showPlanDetails', function (plan) {
self.showPlanDetails(plan);
});
},
methods: {
/**
* Get the active plans for the application.
*/
getPlans() {
axios.get('/spark/plans')
.then(response => {
this.plans = this.billingUser
? _.where(response.data, {type: "user"})
: _.where(response.data, {type: "team"});
});
}
}
};

View File

@@ -0,0 +1,48 @@
module.exports = {
props: ['user', 'team', 'billableType'],
/**
* The component's data.
*/
data() {
return {
form: new SparkForm({})
};
},
methods: {
/**
* Confirm the cancellation operation.
*/
confirmCancellation() {
$('#modal-confirm-cancellation').modal('show');
},
/**
* Cancel the current subscription.
*/
cancel() {
Spark.delete(this.urlForCancellation, this.form)
.then(() => {
Bus.$emit('updateUser');
Bus.$emit('updateTeam');
$('#modal-confirm-cancellation').modal('hide');
});
}
},
computed: {
/**
* Get the URL for the subscription cancellation.
*/
urlForCancellation() {
return this.billingUser
? '/settings/subscription'
: `/settings/${Spark.pluralTeamString}/${this.team.id}/subscription`;
}
}
};

View File

@@ -0,0 +1,41 @@
module.exports = {
props: ['user', 'team', 'plans', 'billableType'],
/**
* Load mixins for the component.
*/
mixins: [
require('./../../mixins/plans'),
require('./../../mixins/subscriptions')
],
/**
* Prepare the component.
*/
mounted() {
if (this.onlyHasYearlyPlans) {
this.showYearlyPlans();
}
},
methods: {
/**
* Show the plan details for the given plan.
*
* We'll ask the parent subscription component to display it.
*/
showPlanDetails(plan) {
this.$parent.$emit('showPlanDetails', plan);
},
/**
* Get the plan price with the applicable VAT.
*/
priceWithTax(plan) {
return plan.price + (plan.price * (this.billable.tax_rate / 100));
}
}
};

View File

@@ -0,0 +1,96 @@
module.exports = {
props: ['user', 'team', 'plans', 'billableType'],
/**
* Load mixins for the component.
*/
mixins: [
require('./../../mixins/braintree'),
require('./../../mixins/plans'),
require('./../../mixins/subscriptions')
],
/**
* The component's data.
*/
data() {
return {
form: new SparkForm({
braintree_type: '',
braintree_token: '',
plan: '',
coupon: null
})
};
},
/**
* Prepare the component.
*/
mounted() {
// If only yearly subscription plans are available, we will select that interval so that we
// can show the plans. Then we'll select the first available paid plan from the list and
// start the form in a good default spot. The user may then select another plan later.
if (this.onlyHasYearlyPaidPlans) {
this.showYearlyPlans();
}
// Next, we will configure the braintree container element on the page and handle the nonce
// received callback. We'll then set the nonce and fire off the subscribe method so this
// nonce can be used to create the subscription for the billable entity being managed.
this.braintree('braintree-subscribe-container', response => {
this.form.braintree_type = response.type;
this.form.braintree_token = response.nonce;
this.subscribe();
});
},
methods: {
/**
* Mark the given plan as selected.
*/
selectPlan(plan) {
this.selectedPlan = plan;
this.form.plan = this.selectedPlan.id;
},
/**
* Subscribe to the specified plan.
*/
subscribe() {
Spark.post(this.urlForNewSubscription, this.form)
.then(response => {
Bus.$emit('updateUser');
Bus.$emit('updateTeam');
});
},
/**
* Show the plan details for the given plan.
*
* We'll ask the parent subscription component to display it.
*/
showPlanDetails(plan) {
this.$parent.$emit('showPlanDetails', plan);
}
},
computed: {
/**
* Get the URL for subscribing to a plan.
*/
urlForNewSubscription() {
return this.billingUser
? '/settings/subscription'
: `/settings/${Spark.pluralTeamString}/${this.team.id}/subscription`;
}
}
};

View File

@@ -0,0 +1,216 @@
module.exports = {
props: ['user', 'team', 'plans', 'billableType'],
/**
* Load mixins for the component.
*/
mixins: [
require('./../../mixins/plans'),
require('./../../mixins/subscriptions'),
require('./../../mixins/vat')
],
/**
* The component's data.
*/
data() {
return {
taxRate: 0,
form: new SparkForm({
stripe_token: '',
plan: '',
coupon: null,
address: '',
address_line_2: '',
city: '',
state: '',
zip: '',
country: 'US',
vat_id: ''
}),
cardForm: new SparkForm({
name: '',
number: '',
cvc: '',
month: '',
year: '',
zip: ''
})
};
},
watch: {
/**
* Watch for changes on the entire billing address.
*/
'currentBillingAddress': function (value) {
if ( ! Spark.collectsEuropeanVat) {
return;
}
this.refreshTaxRate(this.form);
}
},
/**
* Prepare the component.
*/
mounted() {
Stripe.setPublishableKey(Spark.stripeKey);
this.initializeBillingAddress();
if (this.onlyHasYearlyPaidPlans) {
this.showYearlyPlans();
}
},
methods: {
/**
* Initialize the billing address form for the billable entity.
*/
initializeBillingAddress() {
this.form.address = this.billable.billing_address;
this.form.address_line_2 = this.billable.billing_address_line_2;
this.form.city = this.billable.billing_city;
this.form.state = this.billable.billing_state;
this.form.zip = this.billable.billing_zip;
this.form.country = this.billable.billing_country || 'US';
this.form.vat_id = this.billable.vat_id;
},
/**
* Mark the given plan as selected.
*/
selectPlan(plan) {
this.selectedPlan = plan;
this.form.plan = this.selectedPlan.id;
},
/**
* Subscribe to the specified plan.
*/
subscribe() {
this.cardForm.errors.forget();
this.form.startProcessing();
// Here we will build out the payload to send to Stripe to obtain a card token so
// we can create the actual subscription. We will build out this data that has
// this credit card number, CVC, etc. and exchange it for a secure token ID.
const payload = {
name: this.cardForm.name,
number: this.cardForm.number,
cvc: this.cardForm.cvc,
exp_month: this.cardForm.month,
exp_year: this.cardForm.year,
address_line1: this.form.address,
address_line2: this.form.address_line_2,
address_city: this.form.city,
address_state: this.form.state,
address_zip: this.form.zip,
address_country: this.form.country
};
// Next, we will send the payload to Stripe and handle the response. If we have a
// valid token we can send that to the server and use the token to create this
// subscription on the back-end. Otherwise, we will show the error messages.
Stripe.card.createToken(payload, (status, response) => {
if (response.error) {
this.cardForm.errors.set({number: [
response.error.message
]})
this.form.busy = false;
} else {
this.createSubscription(response.id);
}
});
},
/*
* After obtaining the Stripe token, create subscription on the Spark server.
*/
createSubscription(token) {
this.form.stripe_token = token;
Spark.post(this.urlForNewSubscription, this.form)
.then(response => {
Bus.$emit('updateUser');
Bus.$emit('updateTeam');
});
},
/**
* Determine if the user has subscribed to the given plan before.
*/
hasSubscribed(plan) {
return !!_.where(this.billable.subscriptions, {provider_plan: plan.id}).length
},
/**
* Show the plan details for the given plan.
*
* We'll ask the parent subscription component to display it.
*/
showPlanDetails(plan) {
this.$parent.$emit('showPlanDetails', plan);
}
},
computed: {
/**
* Get the billable entity's "billable" name.
*/
billableName() {
return this.billingUser ? this.user.name : this.team.owner.name;
},
/**
* Determine if the selected country collects European VAT.
*/
countryCollectsVat() {
return this.collectsVat(this.form.country);
},
/**
* Get the URL for subscribing to a plan.
*/
urlForNewSubscription() {
return this.billingUser
? '/settings/subscription'
: `/settings/${Spark.pluralTeamString}/${this.team.id}/subscription`;
},
/**
* Get the current billing address from the subscribe form.
*
* This used primarily for wathcing.
*/
currentBillingAddress() {
return this.form.address +
this.form.address_line_2 +
this.form.city +
this.form.state +
this.form.zip +
this.form.country +
this.form.vat_id;
}
}
};

View File

@@ -0,0 +1,92 @@
module.exports = {
props: ['user', 'team', 'plans', 'billableType'],
/**
* Load mixins for the component.
*/
mixins: [
require('./../../mixins/plans'),
require('./../../mixins/subscriptions')
],
/**
* The component's data.
*/
data() {
return {
confirmingPlan: null
};
},
/**
* Prepare the component.
*/
mounted() {
this.selectActivePlanInterval();
// We need to watch the activePlan computed property for changes so we can select
// the proper active plan on the plan interval button group. So, we will watch
// this property and fire off a method anytime it changes so it can sync up.
this.$watch('activePlan', value => {
this.selectActivePlanInterval();
});
if (this.onlyHasYearlyPlans) {
this.showYearlyPlans();
}
},
methods: {
/**
* Confirm the plan update with the user.
*/
confirmPlanUpdate(plan) {
this.confirmingPlan = plan;
$('#modal-confirm-plan-update').modal('show');
},
/**
* Approve the plan update.
*/
approvePlanUpdate() {
$('#modal-confirm-plan-update').modal('hide');
this.updateSubscription(this.confirmingPlan);
},
/**
* Select the active plan interval.
*/
selectActivePlanInterval() {
if (this.activePlanIsMonthly || this.yearlyPlans.length == 0) {
this.showMonthlyPlans();
} else {
this.showYearlyPlans();
}
},
/**
* Show the plan details for the given plan.
*
* We'll ask the parent subscription component to display it.
*/
showPlanDetails(plan) {
this.$parent.$emit('showPlanDetails', plan);
},
/**
* Get the plan price with the applicable VAT.
*/
priceWithTax(plan) {
return plan.price + (plan.price * (this.billable.tax_rate / 100));
}
}
};

View File

@@ -0,0 +1,3 @@
module.exports = {
props: ['user', 'teams'],
};

View File

@@ -0,0 +1,151 @@
module.exports = {
/**
* The component's data.
*/
data() {
return {
plans: [],
form: new SparkForm({
name: '',
slug: ''
})
};
},
computed: {
/**
* Get the active subscription instance.
*/
activeSubscription() {
if ( ! this.$parent.billable) {
return;
}
const subscription = _.find(
this.$parent.billable.subscriptions,
subscription => subscription.name == 'default'
);
if (typeof subscription !== 'undefined') {
return subscription;
}
},
/**
* Get the active plan instance.
*/
activePlan() {
if (this.activeSubscription) {
return _.find(this.plans, (plan) => {
return plan.id == this.activeSubscription.provider_plan;
});
}
},
/**
* Check if there's a limit for the number of teams.
*/
hasTeamLimit() {
if (! this.activePlan) {
return false;
}
return !! this.activePlan.attributes.teams;
},
/**
*
* Get the remaining teams in the active plan.
*/
remainingTeams() {
var ownedTeams = _.filter(this.$parent.teams, {owner_id: this.$parent.billable.id});
return this.activePlan
? this.activePlan.attributes.teams - ownedTeams.length
: 0;
},
/**
* Check if the user can create more teams.
*/
canCreateMoreTeams() {
if (! this.hasTeamLimit) {
return true;
}
return this.remainingTeams > 0;
}
},
/**
* The component has been created by Vue.
*/
created() {
this.getPlans();
},
events: {
/**
* Handle the "activatedTab" event.
*/
activatedTab(tab) {
if (tab === Spark.pluralTeamString) {
Vue.nextTick(() => {
$('#create-team-name').focus();
});
}
return true;
}
},
watch: {
/**
* Watch the team name for changes.
*/
'form.name': function (val, oldVal) {
if (this.form.slug == '' ||
this.form.slug == oldVal.toLowerCase().replace(/[\s\W-]+/g, '-')
) {
this.form.slug = val.toLowerCase().replace(/[\s\W-]+/g, '-');
}
}
},
methods: {
/**
* Create a new team.
*/
create() {
Spark.post('/settings/'+Spark.pluralTeamString, this.form)
.then(() => {
this.form.name = '';
this.form.slug = '';
Bus.$emit('updateUser');
Bus.$emit('updateTeams');
});
},
/**
* Get all the plans defined in the application.
*/
getPlans() {
axios.get('/spark/plans')
.then(response => {
this.plans = response.data;
});
}
}
};

View File

@@ -0,0 +1,85 @@
module.exports = {
props: ['user', 'teams'],
/**
* The component's data.
*/
data() {
return {
leavingTeam: null,
deletingTeam: null,
leaveTeamForm: new SparkForm({}),
deleteTeamForm: new SparkForm({})
};
},
/**
* Prepare the component.
*/
mounted() {
$('[data-toggle="tooltip"]').tooltip();
},
methods: {
/**
* Approve leaving the given team.
*/
approveLeavingTeam(team) {
this.leavingTeam = team;
$('#modal-leave-team').modal('show');
},
/**
* Leave the given team.
*/
leaveTeam() {
Spark.delete(this.urlForLeaving, this.leaveTeamForm)
.then(() => {
Bus.$emit('updateUser');
Bus.$emit('updateTeams');
$('#modal-leave-team').modal('hide');
});
},
/**
* Approve the deletion of the given team.
*/
approveTeamDelete(team) {
this.deletingTeam = team;
$('#modal-delete-team').modal('show');
},
/**
* Delete the given team.
*/
deleteTeam() {
Spark.delete(`/settings/${Spark.pluralTeamString}/${this.deletingTeam.id}`, this.deleteTeamForm)
.then(() => {
Bus.$emit('updateUser');
Bus.$emit('updateTeams');
$('#modal-delete-team').modal('hide');
});
}
},
computed: {
/**
* Get the URL for leaving a team.
*/
urlForLeaving() {
return `/settings/${Spark.pluralTeamString}/${this.leavingTeam.id}/members/${this.user.id}`;
}
}
};

View File

@@ -0,0 +1,16 @@
module.exports = {
props: ['team', 'invitations'],
methods: {
/**
* Cancel the sent invitation.
*/
cancel(invitation) {
axios.delete(`/settings/invitations/${invitation.id}`)
.then(() => {
this.$parent.$emit('updateInvitations');
});
}
}
};

View File

@@ -0,0 +1,67 @@
module.exports = {
/**
* The component's data.
*/
data() {
return {
invitations: []
};
},
/**
* The component has been created by Vue.
*/
created() {
this.getPendingInvitations();
},
methods: {
/**
* Get the pending invitations for the user.
*/
getPendingInvitations() {
axios.get('/settings/invitations/pending')
.then(response => {
this.invitations = response.data;
});
},
/**
* Accept the given invitation.
*/
accept(invitation) {
axios.post(`/settings/invitations/${invitation.id}/accept`)
.then(() => {
Bus.$emit('updateTeams');
this.getPendingInvitations();
});
this.removeInvitation(invitation);
},
/**
* Reject the given invitation.
*/
reject(invitation) {
axios.post(`/settings/invitations/${invitation.id}/reject`)
.then(() => {
this.getPendingInvitations();
});
this.removeInvitation(invitation);
},
/**
* Remove the given invitation from the list.
*/
removeInvitation(invitation) {
this.invitations = _.reject(this.invitations, i => i.id === invitation.id);
}
}
};

View File

@@ -0,0 +1,116 @@
module.exports = {
props: ['user', 'team', 'billableType'],
/**
* The component's data.
*/
data() {
return {
plans: [],
form: new SparkForm({
email: ''
})
};
},
computed: {
/**
* Get the active subscription instance.
*/
activeSubscription() {
if ( ! this.billable) {
return;
}
const subscription = _.find(
this.billable.subscriptions,
subscription => subscription.name == 'default'
);
if (typeof subscription !== 'undefined') {
return subscription;
}
},
/**
* Get the active plan instance.
*/
activePlan() {
if (this.activeSubscription) {
return _.find(this.plans, (plan) => {
return plan.id == this.activeSubscription.provider_plan;
});
}
},
/**
* Check if there's a limit for the number of team members.
*/
hasTeamMembersLimit() {
if (! this.activePlan) {
return false;
}
return !! this.activePlan.attributes.teamMembers;
},
/**
*
* Get the remaining team members in the active plan.
*/
remainingTeamMembers() {
return this.activePlan
? this.activePlan.attributes.teamMembers - this.$parent.team.users.length
: 0;
},
/**
* Check if the user can invite more team members.
*/
canInviteMoreTeamMembers() {
if (! this.hasTeamMembersLimit) {
return true;
}
return this.remainingTeamMembers > 0;
}
},
/**
* The component has been created by Vue.
*/
created() {
this.getPlans();
},
methods: {
/**
* Send a team invitation.
*/
send() {
Spark.post(`/settings/${Spark.pluralTeamString}/${this.team.id}/invitations`, this.form)
.then(() => {
this.form.email = '';
this.$parent.$emit('updateInvitations');
});
},
/**
* Get all the plans defined in the application.
*/
getPlans() {
axios.get('/spark/plans')
.then(response => {
this.plans = response.data;
});
}
}
};

View File

@@ -0,0 +1,144 @@
module.exports = {
props: ['user', 'team'],
/**
* The component's data.
*/
data() {
return {
roles: [],
updatingTeamMember: null,
deletingTeamMember: null,
updateTeamMemberForm: $.extend(true, new SparkForm({
role: ''
}), Spark.forms.updateTeamMember),
deleteTeamMemberForm: new SparkForm({})
}
},
/**
* The component has been created by Vue.
*/
created() {
this.getRoles();
},
methods: {
/**
* Get the available team member roles.
*/
getRoles() {
axios.get(`/settings/${Spark.pluralTeamString}/roles`)
.then(response => {
this.roles = response.data;
});
},
/**
* Edit the given team member.
*/
editTeamMember(member) {
this.updatingTeamMember = member;
this.updateTeamMemberForm.role = member.pivot.role;
$('#modal-update-team-member').modal('show');
},
/**
* Update the team member.
*/
update() {
Spark.put(this.urlForUpdating, this.updateTeamMemberForm)
.then(() => {
Bus.$emit('updateTeam');
$('#modal-update-team-member').modal('hide');
});
},
/**
* Display the approval modal for the deletion of a team member.
*/
approveTeamMemberDelete(member) {
this.deletingTeamMember = member;
$('#modal-delete-member').modal('show');
},
/**
* Delete the given team member.
*/
deleteMember() {
Spark.delete(this.urlForDeleting, this.deleteTeamMemberForm)
.then(() => {
Bus.$emit('updateTeam');
$('#modal-delete-member').modal('hide');
});
},
/**
* Determine if the current user can edit a team member.
*/
canEditTeamMember(member) {
return this.user.id === this.team.owner_id && this.user.id !== member.id
},
/**
* Determine if the current user can delete a team member.
*/
canDeleteTeamMember(member) {
return this.user.id === this.team.owner_id && this.user.id !== member.id
},
/**
* Get the displayable role for the given team member.
*/
teamMemberRole(member) {
if (this.roles.length == 0) {
return '';
}
if (member.pivot.role == 'owner') {
return 'Owner';
}
const role = _.find(this.roles, role => role.value == member.pivot.role);
if (typeof role !== 'undefined') {
return role.text;
}
}
},
computed: {
/**
* Get the URL for updating a team member.
*/
urlForUpdating: function () {
return `/settings/${Spark.pluralTeamString}/${this.team.id}/members/${this.updatingTeamMember.id}`;
},
/**
* Get the URL for deleting a team member.
*/
urlForDeleting() {
return `/settings/${Spark.pluralTeamString}/${this.team.id}/members/${this.deletingTeamMember.id}`;
}
}
};

View File

@@ -0,0 +1,39 @@
module.exports = {
props: ['user', 'team', 'billableType'],
/**
* The component's data.
*/
data() {
return {
invitations: []
};
},
/**
* The component has been created by Vue.
*/
created() {
var self = this;
this.getInvitations();
this.$on('updateInvitations', function () {
self.getInvitations();
});
},
methods: {
/**
* Get all of the invitations for the team.
*/
getInvitations() {
axios.get(`/settings/${Spark.pluralTeamString}/${this.team.id}/invitations`)
.then(response => {
this.invitations = response.data;
});
}
}
};

View File

@@ -0,0 +1,3 @@
module.exports = {
props: ['user', 'team']
};

View File

@@ -0,0 +1,55 @@
module.exports = {
props: ['user', 'teamId'],
/**
* Load mixins for the component.
*/
mixins: [require('./../../mixins/tab-state')],
/**
* The component's data.
*/
data() {
return {
billableType: 'team',
team: null
};
},
/**
* The component has been created by Vue.
*/
created() {
var self = this;
this.getTeam();
Bus.$on('updateTeam', function () {
self.getTeam();
});
},
/**
* Prepare the component.
*/
mounted() {
this.usePushStateForTabs('.spark-settings-tabs');
},
methods: {
/**
* Get the team being managed.
*/
getTeam() {
axios.get(`/${Spark.pluralTeamString}/${this.teamId}`)
.then(response => {
this.team = response.data;
});
}
}
};

View File

@@ -0,0 +1,36 @@
module.exports = {
props: ['user', 'team'],
/**
* The component's data.
*/
data() {
return {
form: new SparkForm({
name: ''
})
};
},
/**
* Prepare the component.
*/
mounted() {
this.form.name = this.team.name;
},
methods: {
/**
* Update the team name.
*/
update() {
Spark.put(`/settings/${Spark.pluralTeamString}/${this.team.id}/name`, this.form)
.then(() => {
Bus.$emit('updateTeam');
Bus.$emit('updateTeams');
});
}
}
};

View File

@@ -0,0 +1,76 @@
module.exports = {
props: ['user', 'team'],
/**
* The component's data.
*/
data() {
return {
form: new SparkForm({})
};
},
methods: {
/**
* Update the team's photo.
*/
update(e) {
e.preventDefault();
if ( ! this.$refs.photo.files.length) {
return;
}
var self = this;
this.form.startProcessing();
// We need to gather a fresh FormData instance with the profile photo appended to
// the data so we can POST it up to the server. This will allow us to do async
// uploads of the profile photos. We will update the user after this action.
axios.post(this.urlForUpdate, this.gatherFormData())
.then(
() => {
Bus.$emit('updateTeam');
Bus.$emit('updateTeams');
self.form.finishProcessing();
},
(error) => {
self.form.setErrors(error.response.data.errors);
}
);
},
/**
* Gather the form data for the photo upload.
*/
gatherFormData() {
const data = new FormData();
data.append('photo', this.$refs.photo.files[0]);
return data;
}
},
computed: {
/**
* Get the URL for updating the team photo.
*/
urlForUpdate() {
return `/settings/${Spark.pluralTeamString}/${this.team.id}/photo`;
},
/**
* Calculate the style attribute for the photo preview.
*/
previewStyle() {
return `background-image: url(${this.team.photo_url})`;
}
}
};

View File

@@ -0,0 +1,80 @@
/*
* Load various JavaScript modules that assist Spark.
*/
window.URI = require('urijs');
window.axios = require('axios');
window._ = require('underscore');
window.moment = require('moment');
window.Promise = require('promise');
window.Cookies = require('js-cookie');
/*
* Define Moment locales
*/
window.moment.defineLocale('en-short', {
parentLocale: 'en',
relativeTime : {
future: "in %s",
past: "%s",
s: "1s",
m: "1m",
mm: "%dm",
h: "1h",
hh: "%dh",
d: "1d",
dd: "%dd",
M: "1 month ago",
MM: "%d months ago",
y: "1y",
yy: "%dy"
}
});
window.moment.locale('en');
/*
* Load jQuery and Bootstrap jQuery, used for front-end interaction.
*/
if (window.$ === undefined || window.jQuery === undefined) {
window.$ = window.jQuery = require('jquery');
}
require('bootstrap/dist/js/npm');
/**
* Load Vue if this application is using Vue as its framework.
*/
if ($('#spark-app').length > 0) {
require('vue-bootstrap');
}
/**
* We'll load the axios HTTP library which allows us to easily issue requests
* to our Laravel back-end. This library automatically handles sending the
* CSRF token as a header based on the value of the "XSRF" token cookie.
*/
window.axios.defaults.headers.common = {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': Spark.csrfToken
};
/**
* Intercept the incoming responses.
*
* Handle any unexpected HTTP errors and pop up modals, etc.
*/
window.axios.interceptors.response.use(function (response) {
return response;
}, function (error) {
switch (error.response.status) {
case 401:
window.axios.get('/logout');
$('#modal-session-expired').modal('show');
break;
case 402:
window.location = '/settings#/subscription';
break;
}
return Promise.reject(error);
});

266
spark/resources/assets/js/spark.js vendored Normal file
View File

@@ -0,0 +1,266 @@
/**
* Export the root Spark application.
*/
module.exports = {
el: '#spark-app',
/**
* Holds the timestamp for the last time we updated the API token.
*/
lastRefreshedApiTokenAt: null,
/**
* The application's data.
*/
data: {
user: Spark.state.user,
teams: Spark.state.teams,
currentTeam: Spark.state.currentTeam,
loadingNotifications: false,
notifications: null,
supportForm: new SparkForm({
from: '',
subject: '',
message: ''
})
},
/**
* The component has been created by Vue.
*/
created() {
var self = this;
if (Spark.userId) {
this.loadDataForAuthenticatedUser();
}
if (Spark.userId && Spark.usesApi) {
this.refreshApiTokenEveryFewMinutes();
}
Bus.$on('updateUser', function () {
self.getUser();
});
Bus.$on('updateUserData', function() {
self.loadDataForAuthenticatedUser();
});
Bus.$on('updateTeams', function () {
self.getTeams();
});
Bus.$on('showNotifications', function () {
$('#modal-notifications').modal('show');
self.markNotificationsAsRead();
});
Bus.$on('showSupportForm', function () {
if (self.user) {
self.supportForm.from = self.user.email;
}
$('#modal-support').modal('show');
setTimeout(() => {
$('#support-subject').focus();
}, 500);
});
},
/**
* Prepare the application.
*/
mounted() {
this.whenReady();
},
methods: {
/**
* Finish bootstrapping the application.
*/
whenReady() {
//
},
/**
* Load the data for an authenticated user.
*/
loadDataForAuthenticatedUser() {
this.getNotifications();
},
/**
* Refresh the current API token every few minutes.
*/
refreshApiTokenEveryFewMinutes() {
this.lastRefreshedApiTokenAt = moment();
setInterval(() => {
this.refreshApiToken();
}, 240000);
setInterval(() => {
if (moment().diff(this.lastRefreshedApiTokenAt, 'minutes') >= 5) {
this.refreshApiToken();
}
}, 5000);
},
/**
* Refresh the current API token.
*/
refreshApiToken() {
this.lastRefreshedApiTokenAt = moment();
axios.put('/spark/token');
},
/*
* Get the current user of the application.
*/
getUser() {
axios.get('/user/current')
.then(response => {
this.user = response.data;
});
},
/**
* Get the current team list.
*/
getTeams() {
axios.get('/'+Spark.pluralTeamString)
.then(response => {
this.teams = response.data;
});
},
/**
* Get the current team.
*/
getCurrentTeam() {
axios.get(`/${Spark.pluralTeamString}/current`)
.then(response => {
this.currentTeam = response.data;
})
.catch(response => {
//
});
},
/**
* Get the application notifications.
*/
getNotifications() {
this.loadingNotifications = true;
axios.get('/notifications/recent')
.then(response => {
this.notifications = response.data;
this.loadingNotifications = false;
});
},
/**
* Mark the current notifications as read.
*/
markNotificationsAsRead() {
if ( ! this.hasUnreadNotifications) {
return;
}
axios.put('/notifications/read', {
notifications: _.pluck(this.notifications.notifications, 'id')
});
_.each(this.notifications.notifications, notification => {
notification.read = 1;
});
},
/**
* Send a customer support request.
*/
sendSupportRequest() {
Spark.post('/support/email', this.supportForm)
.then(() => {
$('#modal-support').modal('hide');
this.showSupportRequestSuccessMessage();
this.supportForm.subject = '';
this.supportForm.message = '';
});
},
/**
* Show an alert informing the user their support request was sent.
*/
showSupportRequestSuccessMessage() {
swal({
title: 'Got It!',
text: 'We have received your message and will respond soon!',
type: 'success',
showConfirmButton: false,
timer: 2000
});
}
},
computed: {
/**
* Determine if the user has any unread notifications.
*/
hasUnreadAnnouncements() {
if (this.notifications && this.user) {
if (this.notifications.announcements.length && ! this.user.last_read_announcements_at) {
return true;
}
return _.filter(this.notifications.announcements, announcement => {
return moment.utc(this.user.last_read_announcements_at).isBefore(
moment.utc(announcement.created_at)
);
}).length > 0;
}
return false;
},
/**
* Determine if the user has any unread notifications.
*/
hasUnreadNotifications() {
if (this.notifications) {
return _.filter(this.notifications.notifications, notification => {
return ! notification.read;
}).length > 0;
}
return false;
}
}
};

View File

@@ -0,0 +1,25 @@
/*
* Load Vue & Vue-Resource.
*
* Vue is the JavaScript framework used by Spark.
*/
if (window.Vue === undefined) {
window.Vue = require('vue');
window.Bus = new Vue();
}
/**
* Load Vue Global Mixin.
*/
Vue.mixin(require('./mixin'));
/**
* Define the Vue filters.
*/
require('./filters');
/**
* Load the Spark form utilities.
*/
require('./forms/bootstrap');

View File

@@ -0,0 +1,125 @@
#modal-notifications {
.modal-header {
background: #fff;
border-bottom: 0;
margin-bottom: 0;
height: 70px;
width: 350px;
position: fixed;
z-index: 100;
.btn-group {
padding-top: 4px;
width: 100%;
}
}
.modal-dialog {
height: 100vh;
min-height: 100vh;
}
.modal-content {
background: #f5f8fa;
border: none;
overflow-y: scroll;
height: 100vh;
min-height: 100vh;
}
.modal-body {
padding: 0;
height: 100vh;
min-height: 100vh;
}
.modal-footer {
background: #fff;
border-top: 1px dashed rgba(0,0,0,.1);
width: 350px;
}
.notification-container:first-child {
padding-top: 70px;
}
.notification-container:last-child {
padding-bottom: 65px + 23px;
}
.notification:not(:first-child) {
padding-top: 25px;
}
.notification:not(:last-child) {
margin-bottom: 23px;
}
.notification {
position: relative;
padding: 20px 15px 0px 15px;
border-top: 1px dashed rgba(0,0,0,.1);
}
figure { position: absolute; }
.notification-content {
padding-left: 70px;
.meta {
display: flex;
align-content: flex-end;
}
.title {
flex: 1;
font-weight: bold;
line-height: 1.2;
margin: 2px 0 10px;
}
.date { color: #aaa; }
.notification-body {
margin-bottom: 15px;
}
}
.spark-profile-photo {
height: 52px;
width: 52px;
}
.fa-stack {
color: @spark-border-color;
font-size: 26px;
}
h4 {
margin-bottom: 15px;
margin-top: 3px;
}
}
.modal.docked .modal-dialog {
position: fixed;
top: 0;
margin: 0;
height: 100vh;
width: 350px;
}
.modal.docked.docked-left .modal-dialog { left: 0; }
.modal.docked.docked-right .modal-dialog { right: 0; }
.modal.docked .modal-content {
border-radius: 0;
height: 100vh;
}
.modal.docked .modal-footer {
position: fixed;
bottom: 0;
right: 0;
}

View File

@@ -0,0 +1,7 @@
.btn-plan {
width: 150px;
}
.plan-feature-list li {
line-height: 35px;
}

View File

@@ -0,0 +1,27 @@
.spark-settings-stacked-tabs {
border-radius: @border-radius-base;
font-weight: 300;
a {
border-bottom: 1px solid lighten(@spark-border-color, 5%);
border-left: 3px solid transparent;
color: @text-color;
i {
color: lighten(@text-color, 25%);
position: relative;
}
}
li:last-child a {
border-bottom: 0;
}
li.active a {
border-left: 3px solid @brand-primary;
}
li a:active, li a:hover, li a:link, li a:visited {
background-color: white;
}
}

View File

@@ -0,0 +1,37 @@
.terms-of-service {
h1 {
font-size: 26px;
margin-top: 35px;
margin-bottom: 20px;
&:first-child {
margin-top: 10px;
}
}
h2 {
font-size: 20px;
}
h3 {
font-size: 18px;
}
h4 {
font-size: 16px;
margin-top: 20px;
}
p {
line-height: 25px;
margin: 0;
}
ul {
margin-top: 15px;
}
li {
line-height: 25px;
}
}

View File

@@ -0,0 +1,3 @@
.alert {
font-weight: 300;
}

View File

@@ -0,0 +1,97 @@
// Basic Buttons
.btn {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
transition: background-color 0.2s;
}
.btn-primary, .btn-info, .btn-success, .btn-warning, .btn-danger {
border: 0;
}
.btn-default:hover {
background-color: white;
}
// Outline Buttons
.button-outline-variant(@color; @activeTextColor: #fff) {
color: @color;
background-color: transparent;
border-color: @color;
&:focus,
&.focus,
&:hover,
&:active,
&.active,
.open > .dropdown-toggle& {
color: @activeTextColor;
background-color: @color;
box-shadow: none;
}
&.disabled,
&[disabled],
fieldset[disabled] & {
&,
&:hover,
&:focus,
&.focus,
&:active,
&.active {
border-color: @color;
}
}
}
.btn-default-outline {
.button-outline-variant(@btn-default-color);
}
.btn-primary-outline {
.button-outline-variant(@btn-primary-border);
}
.btn-success-outline {
.button-outline-variant(@btn-success-border);
}
.btn-info-outline {
.button-outline-variant(@btn-info-border);
}
.btn-warning-outline {
.button-outline-variant(@btn-warning-border);
}
.btn-danger-outline {
.button-outline-variant(@btn-danger-border);
}
// File Upload Button
.btn-upload {
overflow: hidden;
position: relative;
input[type="file"] {
cursor: pointer;
margin: 0;
opacity: 0;
padding: 0;
position: absolute;
right: 0;
top: 0;
}
}
// Other Button Utilities
.btn-table-align {
padding-top: @padding-base-vertical + 1px;
padding-bottom: @padding-base-vertical + 1px;
}
.fa-btn {
.m-r-xs;
}

View File

@@ -0,0 +1,19 @@
.spark-screen {
form h2 {
background-color: @panel-default-heading-bg;
border-radius: @border-radius-base;
font-size: 14px;
font-weight: 300;
margin-top: 0;
margin-bottom: 15px;
padding: 12px;
}
}
.control-label {
font-weight: 300;
}
.radio label, .checkbox label {
font-weight: 300;
}

View File

@@ -0,0 +1,51 @@
.spark-profile-photo {
border: 2px solid @spark-border-color;
border-radius: 50%;
height: 40px;
padding: 2px;
width: 40px;
}
.spark-profile-photo-lg {
.spark-profile-photo;
height: 75px;
width: 75px;
}
.spark-profile-photo-xl {
.spark-profile-photo;
height: 125px;
width: 125px;
}
.spark-nav-profile-photo {
.spark-profile-photo;
height: 50px;
width: 50px;
}
.spark-team-photo {
.spark-profile-photo;
}
.spark-team-photo-xs {
border-radius: 50%;
height: 1.28571429em;
width: 1.28571429em;
}
.spark-screen {
.profile-photo-preview {
.img-rounded;
display: inline-block;
background-position: center;
background-size: cover;
height: 150px;
vertical-align: middle;
width: 150px;
}
.team-photo-preview {
.profile-photo-preview;
}
}

View File

@@ -0,0 +1,71 @@
.navbar-inverse .navbar-brand {
color: @navbar-inverse-link-color;
font-weight: 400;
}
.navbar-inverse .navbar-brand:hover,
.navbar-inverse .navbar-brand:focus {
color: @navbar-inverse-link-color;
}
.navbar-nav > li > a {
cursor: pointer;
font-weight: 400;
}
.navbar-nav > li > a,
.navbar-brand,
.hamburger {
height: 70px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.navbar-icon {
padding-top: 3px;
position: relative;
.activity-indicator {
width: 13px;
height: 13px;
border-radius: 10px;
background: @brand-danger;
border: 2px solid #fff;
border-radius: 10px;
position: absolute;
right: -1px;
transition: all .15s;
transform: scale(.85);
}
.icon { font-size: 21px; }
}
.has-activity-indicator:hover .activity-indicator {
transform: scale(1.1);
}
.navbar .dropdown-header {
font-weight: bold;
margin-bottom: 5px;
}
.navbar .dropdown-menu > li > a {
font-weight: 300;
i {
color: lighten(@text-color, 25%);
}
i.text-success {
color: @brand-success;
}
}
.hamburger { float: right; }
.with-navbar {
padding-top: 100px;
}

View File

@@ -0,0 +1,18 @@
.panel {
overflow: hidden;
}
.panel-heading {
font-size: 15px;
font-weight: 400;
}
.panel-body {
font-weight: 300;
}
.panel-flush {
.panel-body, .panel-header {
padding: 0;
}
}

View File

@@ -0,0 +1,9 @@
.table-borderless {
> thead > tr > th {
border-bottom: 0;
}
> tbody > tr > td {
border-top: 0;
}
}

View File

@@ -0,0 +1,47 @@
.p-t-lg { padding-top: 20px; }
.p-r-lg { padding-right: 20px; }
.p-b-lg { padding-bottom: 20px; }
.p-l-lg { padding-left: 20px; }
.p-md { padding: 15px; }
.p-t-md { padding-top: 15px; }
.p-r-md { padding-right: 15px; }
.p-b-md { padding-bottom: 15px; }
.p-l-md { padding-left: 15px; }
.p-t-xs { padding-top: 5px; }
.p-r-xs { padding-right: 5px; }
.p-b-xs { padding-bottom: 5px; }
.p-l-xs { padding-left: 5px; }
.p-none { padding: 0; }
.p-t-none { padding-top: 0; }
.p-r-none { padding-right: 0; }
.p-b-none { padding-bottom: 0; }
.p-l-none { padding-left: 0; }
.m-t-lg { margin-top: 20px; }
.m-r-lg { margin-right: 20px; }
.m-b-lg { margin-bottom: 20px; }
.m-l-lg { margin-left: 20px; }
.m-t-md { margin-top: 15px; }
.m-r-md { margin-right: 15px; }
.m-b-md { margin-bottom: 15px; }
.m-l-md { margin-left: 15px; }
.m-t-sm { margin-top: 10px; }
.m-r-sm { margin-right: 10px; }
.m-b-sm { margin-bottom: 10px; }
.m-l-sm { margin-left: 10px; }
.m-t-xs { margin-top: 5px; }
.m-r-xs { margin-right: 5px; }
.m-b-xs { margin-bottom: 5px; }
.m-l-xs { margin-left: 5px; }
.m-none { margin: 0; }
.m-t-none { margin-top: 0; }
.m-r-none { margin-right: 0; }
.m-b-none { margin-bottom: 0; }
.m-l-none { margin-left: 0; }

View File

@@ -0,0 +1,33 @@
// Import Variables
@import "variables";
// Spacing Helpers
@import "spacing";
// Utility Helpers
@import "utilities";
// HTML Elements
@import "elements/alerts";
@import "elements/buttons";
@import "elements/forms";
@import "elements/images";
@import "elements/navbar";
@import "elements/panels";
@import "elements/tables";
// Component Styling
@import "components/notifications";
@import "components/plans";
@import "components/settings";
@import "components/terms";
// Body Styling
body {
font-weight: 300;
}
// Vue Cloak
[v-cloak] {
display: none;
}

View File

@@ -0,0 +1,3 @@
.border-none {
border: 0;
}

View File

@@ -0,0 +1,59 @@
// Body
@body-bg: #f5f8fa;
// Base Border Color
@spark-border-color: darken(@body-bg, 10%);
// Set Common Borders
@list-group-border: @spark-border-color;
@navbar-default-border: @spark-border-color;
@panel-default-border: @spark-border-color;
@panel-inner-border: @spark-border-color;
// Brands
@brand-primary: #3097D1;
@brand-info: #8eb4cb;
@brand-success: #4eb76e;
@brand-warning: #cbb956;
@brand-danger: #bf5329;
// Typography
@font-family-sans-serif: "Open Sans", Helvetica, Arial, sans-serif;
@line-height-base: 1.6;
@text-color: #636b6f;
// Buttons
@btn-default-color: @text-color;
@btn-font-size: @font-size-base;
@btn-font-weight: 300;
// Inputs
@input-border: lighten(@text-color, 40%);
@input-border-focus: lighten(@brand-primary, 25%);
@input-color-placeholder: lighten(@text-color, 30%);
// Navbar
@navbar-height: 50px;
@navbar-margin-bottom: 0;
@navbar-inverse-bg: #fff;
@navbar-inverse-color: lighten(@text-color, 30%);
@navbar-inverse-border: @spark-border-color;
@navbar-inverse-link-color: lighten(@text-color, 25%);
@navbar-inverse-link-active-bg: transparent;
@navbar-inverse-link-active-color: @navbar-inverse-link-hover-color;
@navbar-inverse-link-hover-color: darken(@navbar-inverse-link-color, 5%);
@navbar-inverse-toggle-border-color: @spark-border-color;
@navbar-inverse-toggle-hover-bg: @navbar-inverse-bg;
@navbar-inverse-toggle-icon-bar-bg: @spark-border-color;
// Dropdowns
@dropdown-anchor-padding: 5px 20px;
@dropdown-border: @spark-border-color;
@dropdown-divider-bg: lighten(@spark-border-color, 5%);
@dropdown-header-color: darken(@text-color, 10%);
@dropdown-link-color: @text-color;
@dropdown-link-hover-bg: #fff;
@dropdown-padding: 10px 0;

View File

@@ -0,0 +1,5 @@
<?php
return [
'eligibility' => 'You are not eligible for this plan based on your current number of '.str_plural(Spark::teamString()).' / '.Spark::teamString().' members.'
];

View File

@@ -0,0 +1 @@
Click here to reset your password: <a href="{{ $link = url('password/reset', $token).'?email='.urlencode($user->getEmailForPasswordReset()) }}"> {{ $link }} </a>

View File

@@ -0,0 +1,46 @@
@extends('spark::layouts.app')
@section('content')
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">Login Via Emergency Token</div>
<div class="panel-body">
@include('spark::shared.errors')
<!-- Warning Message -->
<div class="alert alert-warning">
After logging in via your emergency token, two-factor authentication will be
disabled for your account. If you would like to maintain two-factor
authentication security, you should re-enable it after logging in.
</div>
<form class="form-horizontal" role="form" method="POST" action="/login-via-emergency-token">
{{ csrf_field() }}
<!-- Emergency Token -->
<div class="form-group">
<label class="col-md-4 control-label">Emergency Token</label>
<div class="col-md-6">
<input type="password" class="form-control" name="token" autofocus>
</div>
</div>
<!-- Emergency Token Login Button -->
<div class="form-group">
<div class="col-md-8 col-md-offset-4">
<button type="submit" class="btn btn-primary">
<i class="fa fa-btn fa-sign-in"></i>Login
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,61 @@
@extends('spark::layouts.app')
@section('content')
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">Login</div>
<div class="panel-body">
@include('spark::shared.errors')
<form class="form-horizontal" role="form" method="POST" action="/login">
{{ csrf_field() }}
<!-- E-Mail Address -->
<div class="form-group">
<label class="col-md-4 control-label">E-Mail Address</label>
<div class="col-md-6">
<input type="email" class="form-control" name="email" value="{{ old('email') }}" autofocus>
</div>
</div>
<!-- Password -->
<div class="form-group">
<label class="col-md-4 control-label">Password</label>
<div class="col-md-6">
<input type="password" class="form-control" name="password">
</div>
</div>
<!-- Remember Me -->
<div class="form-group">
<div class="col-md-6 col-md-offset-4">
<div class="checkbox">
<label>
<input type="checkbox" name="remember"> Remember Me
</label>
</div>
</div>
</div>
<!-- Login Button -->
<div class="form-group">
<div class="col-md-8 col-md-offset-4">
<button type="submit" class="btn btn-primary">
<i class="fa m-r-xs fa-sign-in"></i>Login
</button>
<a class="btn btn-link" href="{{ url('/password/reset') }}">Forgot Your Password?</a>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,50 @@
@extends('spark::layouts.app')
<!-- Main Content -->
@section('content')
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">Reset Password</div>
<div class="panel-body">
@if (session('status'))
<div class="alert alert-success">
{{ session('status') }}
</div>
@endif
<form class="form-horizontal" role="form" method="POST" action="{{ url('/password/email') }}">
{!! csrf_field() !!}
<!-- E-Mail Address -->
<div class="form-group{{ $errors->has('email') ? ' has-error' : '' }}">
<label class="col-md-4 control-label">E-Mail Address</label>
<div class="col-md-6">
<input type="email" class="form-control" name="email" value="{{ old('email') }}" autofocus>
@if ($errors->has('email'))
<span class="help-block">
{{ $errors->first('email') }}
</span>
@endif
</div>
</div>
<!-- Send Password Reset Link Button -->
<div class="form-group">
<div class="col-md-6 col-md-offset-4">
<button type="submit" class="btn btn-primary">
<i class="fa fa-btn fa-envelope"></i>Send Password Reset Link
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,74 @@
@extends('spark::layouts.app')
@section('content')
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">Reset Password</div>
<div class="panel-body">
<form class="form-horizontal" role="form" method="POST" action="{{ url('/password/reset') }}">
{!! csrf_field() !!}
<input type="hidden" name="token" value="{{ $token }}">
<!-- E-Mail Address -->
<div class="form-group{{ $errors->has('email') ? ' has-error' : '' }}">
<label class="col-md-4 control-label">E-Mail Address</label>
<div class="col-md-6">
<input type="email" class="form-control" name="email" value="{{ $email or old('email') }}" autofocus>
@if ($errors->has('email'))
<span class="help-block">
{{ $errors->first('email') }}
</span>
@endif
</div>
</div>
<!-- Password -->
<div class="form-group{{ $errors->has('password') ? ' has-error' : '' }}">
<label class="col-md-4 control-label">Password</label>
<div class="col-md-6">
<input type="password" class="form-control" name="password">
@if ($errors->has('password'))
<span class="help-block">
{{ $errors->first('password') }}
</span>
@endif
</div>
</div>
<!-- Password Confirmation -->
<div class="form-group{{ $errors->has('password_confirmation') ? ' has-error' : '' }}">
<label class="col-md-4 control-label">Confirm Password</label>
<div class="col-md-6">
<input type="password" class="form-control" name="password_confirmation">
@if ($errors->has('password_confirmation'))
<span class="help-block">
{{ $errors->first('password_confirmation') }}
</span>
@endif
</div>
</div>
<!-- Reset Button -->
<div class="form-group">
<div class="col-md-6 col-md-offset-4">
<button type="submit" class="btn btn-primary">
<i class="fa fa-btn fa-refresh"></i>Reset Password
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,91 @@
<!-- Address -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('address')}">
<label class="col-md-4 control-label">Address</label>
<div class="col-sm-6">
<input type="text" class="form-control" v-model="registerForm.address" lazy>
<span class="help-block" v-show="registerForm.errors.has('address')">
@{{ registerForm.errors.get('address') }}
</span>
</div>
</div>
<!-- Address Line 2 -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('address_line_2')}">
<label class="col-md-4 control-label">Address Line 2</label>
<div class="col-sm-6">
<input type="text" class="form-control" v-model="registerForm.address_line_2" lazy>
<span class="help-block" v-show="registerForm.errors.has('address_line_2')">
@{{ registerForm.errors.get('address_line_2') }}
</span>
</div>
</div>
<!-- City -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('city')}">
<label class="col-md-4 control-label">City</label>
<div class="col-sm-6">
<input type="text" class="form-control" v-model.lazy="registerForm.city">
<span class="help-block" v-show="registerForm.errors.has('city')">
@{{ registerForm.errors.get('city') }}
</span>
</div>
</div>
<!-- State & ZIP Code -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('state')}">
<label class="col-md-4 control-label">State & ZIP / Postal Code</label>
<!-- State -->
<div class="col-sm-3">
<input type="text" class="form-control" placeholder="State" v-model.lazy="registerForm.state">
<span class="help-block" v-show="registerForm.errors.has('state')">
@{{ registerForm.errors.get('state') }}
</span>
</div>
<!-- Zip Code -->
<div class="col-sm-3">
<input type="text" class="form-control" placeholder="Postal Code" v-model.lazy="registerForm.zip">
<span class="help-block" v-show="registerForm.errors.has('zip')">
@{{ registerForm.errors.get('zip') }}
</span>
</div>
</div>
<!-- Country -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('country')}">
<label class="col-md-4 control-label">Country</label>
<div class="col-sm-6">
<select class="form-control" v-model.lazy="registerForm.country">
@foreach (app(Laravel\Spark\Repositories\Geography\CountryRepository::class)->all() as $key => $country)
<option value="{{ $key }}">{{ $country }}</option>
@endforeach
</select>
<span class="help-block" v-show="registerForm.errors.has('country')">
@{{ registerForm.errors.get('country') }}
</span>
</div>
</div>
<!-- European VAT ID -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('vat_id')}" v-if="countryCollectsVat">
<label class="col-md-4 control-label">VAT ID</label>
<div class="col-sm-6">
<input type="text" class="form-control" v-model.lazy="registerForm.vat_id">
<span class="help-block" v-show="registerForm.errors.has('vat_id')">
@{{ registerForm.errors.get('vat_id') }}
</span>
</div>
</div>

View File

@@ -0,0 +1,85 @@
@extends('spark::layouts.app')
@section('scripts')
<script src="https://js.braintreegateway.com/v2/braintree.js"></script>
@endsection
@section('content')
<spark-register-braintree inline-template>
<div>
<div class="spark-screen container">
<!-- Common Register Form Contents -->
@include('spark::auth.register-common')
<!-- Billing Information -->
<div class="row" v-show="selectedPlan && selectedPlan.price > 0">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading"><i class="fa fa-btn fa-credit-card"></i>Billing</div>
<div class="panel-body">
<!-- Generic 500 Level Error Message / Stripe Threw Exception -->
<div class="alert alert-danger" v-if="registerForm.errors.has('form')">
We had trouble validating your card. It's possible your card provider is preventing
us from charging the card. Please contact your card provider or customer support.
</div>
<form class="form-horizontal" role="form">
<!-- Braintree Container -->
<div id="braintree-container" class="m-b-sm"></div>
<!-- Coupon Code -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('coupon')}" v-if="query.coupon">
<label for="number" class="col-md-4 control-label">Coupon Code</label>
<div class="col-sm-6">
<input type="text" class="form-control" name="coupon" v-model="registerForm.coupon">
<span class="help-block" v-show="registerForm.errors.has('coupon')">
@{{ registerForm.errors.get('coupon') }}
</span>
</div>
</div>
<!-- Terms And Conditions -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('terms')}">
<div class="col-sm-6 col-sm-offset-4">
<div class="checkbox">
<label>
<input type="checkbox" v-model="registerForm.terms">
I Accept The <a href="/terms" target="_blank">Terms Of Service</a>
<span class="help-block" v-show="registerForm.errors.has('terms')">
<strong>@{{ registerForm.errors.get('terms') }}</strong>
</span>
</label>
</div>
</div>
</div>
<!-- Register Button -->
<div class="form-group">
<div class="col-sm-6 col-sm-offset-4">
<button type="submit" class="btn btn-primary" :disabled="registerForm.busy">
<span v-if="registerForm.busy">
<i class="fa fa-btn fa-spinner fa-spin"></i>Registering
</span>
<span v-else>
<i class="fa fa-btn fa-check-circle"></i>Register
</span>
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Plan Features Modal -->
@include('spark::modals.plan-details')
</div>
</spark-register-braintree>
@endsection

View File

@@ -0,0 +1,119 @@
<form class="form-horizontal" role="form">
@if (Spark::usesTeams() && Spark::onlyTeamPlans())
<!-- Team Name -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('team')}" v-if=" ! invitation">
<label class="col-md-4 control-label">{{ ucfirst(Spark::teamString()) }} Name</label>
<div class="col-md-6">
<input type="text" class="form-control" name="team" v-model="registerForm.team" autofocus>
<span class="help-block" v-show="registerForm.errors.has('team')">
@{{ registerForm.errors.get('team') }}
</span>
</div>
</div>
@if (Spark::teamsIdentifiedByPath())
<!-- Team Slug (Only Shown When Using Paths For Teams) -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('team_slug')}" v-if=" ! invitation">
<label class="col-md-4 control-label">{{ ucfirst(Spark::teamString()) }} Slug</label>
<div class="col-md-6">
<input type="text" class="form-control" name="team_slug" v-model="registerForm.team_slug" autofocus>
<p class="help-block" v-show=" ! registerForm.errors.has('team_slug')">
This slug is used to identify your {{ Spark::teamString() }} in URLs.
</p>
<span class="help-block" v-show="registerForm.errors.has('team_slug')">
@{{ registerForm.errors.get('team_slug') }}
</span>
</div>
</div>
@endif
@endif
<!-- Name -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('name')}">
<label class="col-md-4 control-label">Name</label>
<div class="col-md-6">
<input type="text" class="form-control" name="name" v-model="registerForm.name" autofocus>
<span class="help-block" v-show="registerForm.errors.has('name')">
@{{ registerForm.errors.get('name') }}
</span>
</div>
</div>
<!-- E-Mail Address -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('email')}">
<label class="col-md-4 control-label">E-Mail Address</label>
<div class="col-md-6">
<input type="email" class="form-control" name="email" v-model="registerForm.email">
<span class="help-block" v-show="registerForm.errors.has('email')">
@{{ registerForm.errors.get('email') }}
</span>
</div>
</div>
<!-- Password -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('password')}">
<label class="col-md-4 control-label">Password</label>
<div class="col-md-6">
<input type="password" class="form-control" name="password" v-model="registerForm.password">
<span class="help-block" v-show="registerForm.errors.has('password')">
@{{ registerForm.errors.get('password') }}
</span>
</div>
</div>
<!-- Password Confirmation -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('password_confirmation')}">
<label class="col-md-4 control-label">Confirm Password</label>
<div class="col-md-6">
<input type="password" class="form-control" name="password_confirmation" v-model="registerForm.password_confirmation">
<span class="help-block" v-show="registerForm.errors.has('password_confirmation')">
@{{ registerForm.errors.get('password_confirmation') }}
</span>
</div>
</div>
<!-- Terms And Conditions -->
<div v-if=" ! selectedPlan || selectedPlan.price == 0">
<div class="form-group" :class="{'has-error': registerForm.errors.has('terms')}">
<div class="col-md-6 col-md-offset-4">
<div class="checkbox">
<label>
<input type="checkbox" name="terms" v-model="registerForm.terms">
I Accept The <a href="/terms" target="_blank">Terms Of Service</a>
</label>
<span class="help-block" v-show="registerForm.errors.has('terms')">
@{{ registerForm.errors.get('terms') }}
</span>
</div>
</div>
</div>
<div class="form-group">
<div class="col-md-6 col-md-offset-4">
<button class="btn btn-primary" @click.prevent="register" :disabled="registerForm.busy">
<span v-if="registerForm.busy">
<i class="fa fa-btn fa-spinner fa-spin"></i>Registering
</span>
<span v-else>
<i class="fa fa-btn fa-check-circle"></i>Register
</span>
</button>
</div>
</div>
</div>
</form>

View File

@@ -0,0 +1,175 @@
<!-- Coupon -->
<div class="row" v-if="coupon">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-success">
<div class="panel-heading">Discount</div>
<div class="panel-body">
The coupon's @{{ discount }} discount will be applied to your subscription!
</div>
</div>
</div>
</div>
<!-- Invalid Coupon -->
<div class="row" v-if="invalidCoupon">
<div class="col-md-8 col-md-offset-2">
<div class="alert alert-danger">
Whoops! This coupon code is invalid.
</div>
</div>
</div>
<!-- Invitation -->
<div class="row" v-if="invitation">
<div class="col-md-8 col-md-offset-2">
<div class="alert alert-success">
We found your invitation to the <strong>@{{ invitation.team.name }}</strong> {{ Spark::teamString() }}!
</div>
</div>
</div>
<!-- Invalid Invitation -->
<div class="row" v-if="invalidInvitation">
<div class="col-md-8 col-md-offset-2">
<div class="alert alert-danger">
Whoops! This invitation code is invalid.
</div>
</div>
</div>
<!-- Plan Selection -->
<div class="row" v-if="paidPlans.length > 0">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">
<div class="pull-left" :class="{'btn-table-align': hasMonthlyAndYearlyPlans}">
Subscription
</div>
<!-- Interval Selector Button Group -->
<div class="pull-right">
<div class="btn-group" v-if="hasMonthlyAndYearlyPlans" style="padding-top: 2px;">
<!-- Monthly Plans -->
<button type="button" class="btn btn-default"
@click="showMonthlyPlans"
:class="{'active': showingMonthlyPlans}">
Monthly
</button>
<!-- Yearly Plans -->
<button type="button" class="btn btn-default"
@click="showYearlyPlans"
:class="{'active': showingYearlyPlans}">
Yearly
</button>
</div>
</div>
<div class="clearfix"></div>
</div>
<div class="panel-body spark-row-list">
<!-- Plan Error Message - In General Will Never Be Shown -->
<div class="alert alert-danger" v-if="registerForm.errors.has('plan')">
@{{ registerForm.errors.get('plan') }}
</div>
<!-- European VAT Notice -->
@if (Spark::collectsEuropeanVat())
<p class="p-b-md">
All subscription plan prices are excluding applicable VAT.
</p>
@endif
<table class="table table-borderless m-b-none">
<thead></thead>
<tbody>
<tr v-for="plan in plansForActiveInterval">
<!-- Plan Name -->
<td>
<div class="btn-table-align" @click="showPlanDetails(plan)">
<span style="cursor: pointer;">
<strong>@{{ plan.name }}</strong>
</span>
</div>
</td>
<!-- Plan Features Button -->
<td>
<button class="btn btn-default m-l-sm" @click="showPlanDetails(plan)">
<i class="fa fa-btn fa-star-o"></i>Plan Features
</button>
</td>
<!-- Plan Price -->
<td>
<div class="btn-table-align">
<span v-if="plan.price == 0">
Free
</span>
<span v-else>
@{{ plan.price | currency }} / @{{ plan.interval | capitalize }}
</span>
</div>
</td>
<!-- Trial Days -->
<td>
<div class="btn-table-align" v-if="plan.trialDays">
@{{ plan.trialDays}} Day Trial
</div>
</td>
<!-- Plan Select Button -->
<td class="text-right">
<button class="btn btn-primary btn-plan" v-if="isSelected(plan)" disabled>
<i class="fa fa-btn fa-check"></i>Selected
</button>
<button class="btn btn-primary-outline btn-plan" @click="selectPlan(plan)" v-else>
Select
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Basic Profile -->
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">
<span v-if="paidPlans.length > 0">
Profile
</span>
<span v-else>
Register
</span>
</div>
<div class="panel-body">
<!-- Generic Error Message -->
<div class="alert alert-danger" v-if="registerForm.errors.has('form')">
@{{ registerForm.errors.get('form') }}
</div>
<!-- Invitation Code Error -->
<div class="alert alert-danger" v-if="registerForm.errors.has('invitation')">
@{{ registerForm.errors.get('invitation') }}
</div>
<!-- Registration Form -->
@include('spark::auth.register-common-form')
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,166 @@
@extends('spark::layouts.app')
@section('scripts')
<script src="https://js.stripe.com/v2/"></script>
@endsection
@section('content')
<spark-register-stripe inline-template>
<div>
<div class="spark-screen container">
<!-- Common Register Form Contents -->
@include('spark::auth.register-common')
<!-- Billing Information -->
<div class="row" v-if="selectedPlan && selectedPlan.price > 0">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">Billing Information</div>
<div class="panel-body">
<!-- Generic 500 Level Error Message / Stripe Threw Exception -->
<div class="alert alert-danger" v-if="registerForm.errors.has('form')">
We had trouble validating your card. It's possible your card provider is preventing
us from charging the card. Please contact your card provider or customer support.
</div>
<form class="form-horizontal" role="form">
<!-- Billing Address Fields -->
@if (Spark::collectsBillingAddress())
<h2><i class="fa fa-btn fa-map-marker"></i>Billing Address</h2>
@include('spark::auth.register-address')
<h2><i class="fa fa-btn fa-credit-card"></i>Credit Card</h2>
@endif
<!-- Cardholder's Name -->
<div class="form-group">
<label for="name" class="col-md-4 control-label">Cardholder's Name</label>
<div class="col-md-6">
<input type="text" class="form-control" name="name" v-model="cardForm.name">
</div>
</div>
<!-- Card Number -->
<div class="form-group" :class="{'has-error': cardForm.errors.has('number')}">
<label class="col-md-4 control-label">Card Number</label>
<div class="col-md-6">
<input type="text" class="form-control" name="number" data-stripe="number" v-model="cardForm.number">
<span class="help-block" v-show="cardForm.errors.has('number')">
@{{ cardForm.errors.get('number') }}
</span>
</div>
</div>
<!-- Security Code -->
<div class="form-group">
<label class="col-md-4 control-label">Security Code</label>
<div class="col-md-6">
<input type="text" class="form-control" name="cvc" data-stripe="cvc" v-model="cardForm.cvc">
</div>
</div>
<!-- Expiration -->
<div class="form-group">
<label class="col-md-4 control-label">Expiration</label>
<!-- Month -->
<div class="col-md-3">
<input type="text" class="form-control" name="month"
placeholder="MM" maxlength="2" data-stripe="exp-month" v-model="cardForm.month">
</div>
<!-- Year -->
<div class="col-md-3">
<input type="text" class="form-control" name="year"
placeholder="YYYY" maxlength="4" data-stripe="exp-year" v-model="cardForm.year">
</div>
</div>
<!-- ZIP Code -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('zip')}" v-if=" ! spark.collectsBillingAddress">
<label class="col-md-4 control-label">ZIP / Postal Code</label>
<div class="col-md-6">
<input type="text" class="form-control" name="zip" v-model="registerForm.zip">
<span class="help-block" v-show="registerForm.errors.has('zip')">
@{{ registerForm.errors.get('zip') }}
</span>
</div>
</div>
<!-- Coupon Code -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('coupon')}" v-if="query.coupon">
<label class="col-md-4 control-label">Coupon Code</label>
<div class="col-md-6">
<input type="text" class="form-control" name="coupon" v-model="registerForm.coupon">
<span class="help-block" v-show="registerForm.errors.has('coupon')">
@{{ registerForm.errors.get('coupon') }}
</span>
</div>
</div>
<!-- Terms And Conditions -->
<div class="form-group" :class="{'has-error': registerForm.errors.has('terms')}">
<div class="col-md-6 col-md-offset-4">
<div class="checkbox">
<label>
<input type="checkbox" v-model="registerForm.terms">
I Accept The <a href="/terms" target="_blank">Terms Of Service</a>
<span class="help-block" v-show="registerForm.errors.has('terms')">
<strong>@{{ registerForm.errors.get('terms') }}</strong>
</span>
</label>
</div>
</div>
</div>
<!-- Tax / Price Information -->
<div class="form-group" v-if="spark.collectsEuropeanVat && countryCollectsVat && selectedPlan">
<label class="col-md-4 control-label">&nbsp;</label>
<div class="col-md-6">
<div class="alert alert-info" style="margin: 0;">
<strong>Tax:</strong> @{{ taxAmount(selectedPlan) | currency }}
<br><br>
<strong>Total Price Including Tax:</strong>
@{{ priceWithTax(selectedPlan) | currency }} / @{{ selectedPlan.interval | capitalize }}
</div>
</div>
</div>
<!-- Register Button -->
<div class="form-group">
<div class="col-md-6 col-md-offset-4">
<button type="submit" class="btn btn-primary" @click.prevent="register" :disabled="registerForm.busy">
<span v-if="registerForm.busy">
<i class="fa fa-btn fa-spinner fa-spin"></i>Registering
</span>
<span v-else>
<i class="fa fa-btn fa-check-circle"></i>Register
</span>
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Plan Features Modal -->
@include('spark::modals.plan-details')
</div>
</spark-register-stripe>
@endsection

View File

@@ -0,0 +1,5 @@
@if (Spark::billsUsingStripe())
@include('spark::auth.register-stripe')
@else
@include('spark::auth.register-braintree')
@endif

View File

@@ -0,0 +1,43 @@
@extends('spark::layouts.app')
@section('content')
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">Two-Factor Authentication</div>
<div class="panel-body">
@include('spark::shared.errors')
<form class="form-horizontal" role="form" method="POST" action="/login/token">
{{ csrf_field() }}
<!-- Token -->
<div class="form-group">
<label class="col-md-4 control-label">Authentication Token</label>
<div class="col-md-6">
<input type="text" class="form-control" name="token" autofocus>
</div>
</div>
<!-- Verify Button -->
<div class="form-group">
<div class="col-md-6 col-md-offset-4">
<button type="submit" class="btn btn-primary">
Verify
</button>
<a class="btn btn-link" href="{{ url('login-via-emergency-token') }}">
Lost Your Device?
</a>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,70 @@
@extends('spark::layouts.app')
@section('scripts')
<script src="https://cdnjs.cloudflare.com/ajax/libs/mousetrap/1.4.6/mousetrap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.4.0/Chart.min.js"></script>
@endsection
@section('content')
<spark-kiosk :user="user" inline-template>
<div class="container-fluid">
<div class="row">
<!-- Tabs -->
<div class="col-md-4">
<div class="panel panel-default panel-flush">
<div class="panel-heading">
Kiosk
</div>
<div class="panel-body">
<div class="spark-settings-tabs">
<ul class="nav spark-settings-stacked-tabs" role="tablist">
<!-- Announcements Link -->
<li role="presentation" class="active">
<a href="#announcements" aria-controls="announcements" role="tab" data-toggle="tab">
<i class="fa fa-fw fa-btn fa-bullhorn"></i>Announcements
</a>
</li>
<!-- Metrics Link -->
<li role="presentation">
<a href="#metrics" aria-controls="metrics" role="tab" data-toggle="tab">
<i class="fa fa-fw fa-btn fa-bar-chart"></i>Metrics
</a>
</li>
<!-- Users Link -->
<li role="presentation">
<a href="#users" aria-controls="users" role="tab" data-toggle="tab">
<i class="fa fa-fw fa-btn fa-user"></i>Users
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Tab Panels -->
<div class="col-md-8">
<div class="tab-content">
<!-- Announcements -->
<div role="tabpanel" class="tab-pane active" id="announcements">
@include('spark::kiosk.announcements')
</div>
<!-- Metrics -->
<div role="tabpanel" class="tab-pane" id="metrics">
@include('spark::kiosk.metrics')
</div>
<!-- User Management -->
<div role="tabpanel" class="tab-pane" id="users">
@include('spark::kiosk.users')
</div>
</div>
</div>
</div>
</div>
</spark-kiosk>
@endsection

View File

@@ -0,0 +1,212 @@
<spark-kiosk-announcements inline-template>
<div>
<div class="panel panel-default">
<div class="panel-heading">Create Announcement</div>
<div class="panel-body">
<div class="alert alert-info">
Announcements you create here will be sent to the "Product Announcements" section of
the notifications modal window, informing your users about new features and improvements
to your application.
</div>
<form class="form-horizontal" role="form">
<!-- Announcement -->
<div class="form-group" :class="{'has-error': createForm.errors.has('body')}">
<label class="col-md-4 control-label">Announcement</label>
<div class="col-md-6">
<textarea class="form-control" name="announcement" rows="7" v-model="createForm.body" style="font-family: monospace;">
</textarea>
<span class="help-block" v-show="createForm.errors.has('body')">
@{{ createForm.errors.get('body') }}
</span>
</div>
</div>
<!-- Action Text -->
<div class="form-group" :class="{'has-error': createForm.errors.has('action_text')}">
<label class="col-md-4 control-label">Action Button Text</label>
<div class="col-md-6">
<input type="text" class="form-control" name="action_text" v-model="createForm.action_text">
<span class="help-block" v-show="createForm.errors.has('action_text')">
@{{ createForm.errors.get('action_text') }}
</span>
</div>
</div>
<!-- Action URL -->
<div class="form-group" :class="{'has-error': createForm.errors.has('action_url')}">
<label class="col-md-4 control-label">Action Button URL</label>
<div class="col-md-6">
<input type="text" class="form-control" name="action_url" v-model="createForm.action_url">
<span class="help-block" v-show="createForm.errors.has('action_url')">
@{{ createForm.errors.get('action_url') }}
</span>
</div>
</div>
<!-- Create Button -->
<div class="form-group">
<div class="col-md-offset-4 col-md-6">
<button type="submit" class="btn btn-primary"
@click.prevent="create"
:disabled="createForm.busy">
Create
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Recent Announcements List -->
<div class="panel panel-default" v-if="announcements.length > 0">
<div class="panel-heading">Recent Announcements</div>
<div class="panel-body">
<table class="table table-borderless m-b-none">
<thead>
<th></th>
<th>Date</th>
<th></th>
<th></th>
</thead>
<tbody>
<tr v-for="announcement in announcements">
<!-- Photo -->
<td>
<img :src="announcement.creator.photo_url" class="spark-profile-photo">
</td>
<!-- Date -->
<td>
<div class="btn-table-align">
@{{ announcement.created_at | datetime }}
</div>
</td>
<!-- Edit Button -->
<td>
<button class="btn btn-primary" @click="editAnnouncement(announcement)">
<i class="fa fa-pencil"></i>
</button>
</td>
<!-- Delete Button -->
<td>
<button class="btn btn-danger-outline" @click="approveAnnouncementDelete(announcement)">
<i class="fa fa-times"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Edit Announcement Modal -->
<div class="modal fade" id="modal-update-announcement" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" v-if="updatingAnnouncement">
<div class="modal-content">
<div class="modal-header">
<button type="button " class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">
Update Announcement
</h4>
</div>
<div class="modal-body">
<!-- Update Announcement -->
<form class="form-horizontal" role="form">
<!-- Announcement -->
<div class="form-group" :class="{'has-error': updateForm.errors.has('body')}">
<label class="col-md-4 control-label">Announcement</label>
<div class="col-md-6">
<textarea class="form-control" rows="7" v-model="updateForm.body" style="font-family: monospace;">
</textarea>
<span class="help-block" v-show="updateForm.errors.has('body')">
@{{ updateForm.errors.get('body') }}
</span>
</div>
</div>
<!-- Action Text -->
<div class="form-group" :class="{'has-error': updateForm.errors.has('action_text')}">
<label class="col-md-4 control-label">Action Button Text</label>
<div class="col-md-6">
<input type="text" class="form-control" name="action_text" v-model="updateForm.action_text">
<span class="help-block" v-show="updateForm.errors.has('action_text')">
@{{ updateForm.errors.get('action_text') }}
</span>
</div>
</div>
<!-- Action URL -->
<div class="form-group" :class="{'has-error': updateForm.errors.has('action_url')}">
<label class="col-md-4 control-label">Action Button URL</label>
<div class="col-md-6">
<input type="text" class="form-control" name="action_url" v-model="updateForm.action_url">
<span class="help-block" v-show="updateForm.errors.has('action_url')">
@{{ updateForm.errors.get('action_url') }}
</span>
</div>
</div>
</form>
</div>
<!-- Modal Actions -->
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" @click="update" :disabled="updateForm.busy">
Update
</button>
</div>
</div>
</div>
</div>
<!-- Delete Announcement Modal -->
<div class="modal fade" id="modal-delete-announcement" tabindex="-1" role="dialog">
<div class="modal-dialog" v-if="deletingAnnouncement">
<div class="modal-content">
<div class="modal-header">
<button type="button " class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">
Delete Announcement
</h4>
</div>
<div class="modal-body">
Are you sure you want to delete this announcement?
</div>
<!-- Modal Actions -->
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">No, Go Back</button>
<button type="button" class="btn btn-danger" @click="deleteAnnouncement" :disabled="deleteForm.busy">
Yes, Delete
</button>
</div>
</div>
</div>
</div>
</div>
</spark-kiosk-announcements>

View File

@@ -0,0 +1,197 @@
<spark-kiosk-metrics :user="user" inline-template>
<!-- The Landsman™ -->
<div>
<div class="row">
<!-- Monthly Recurring Revenue -->
<div class="col-md-6">
<div class="panel panel-success">
<div class="panel-heading text-center">Monthly Recurring Revenue</div>
<div class="panel-body text-center">
<div style="font-size: 24px;">
@{{ monthlyRecurringRevenue | currency }}
</div>
<!-- Compared To Last Month -->
<div v-if="monthlyChangeInMonthlyRecurringRevenue" style="font-size: 12px;">
@{{ monthlyChangeInMonthlyRecurringRevenue }}% From Last Month
</div>
<!-- Compared To Last Year -->
<div v-if="yearlyChangeInMonthlyRecurringRevenue" style="font-size: 12px;">
@{{ yearlyChangeInMonthlyRecurringRevenue }}% From Last Year
</div>
</div>
</div>
</div>
<!-- Yearly Recurring Revenue -->
<div class="col-md-6">
<div class="panel panel-success">
<div class="panel-heading text-center">Yearly Recurring Revenue</div>
<div class="panel-body text-center">
<div style="font-size: 24px;">
@{{ yearlyRecurringRevenue | currency }}
</div>
<!-- Compared To Last Month -->
<div v-if="monthlyChangeInYearlyRecurringRevenue" style="font-size: 12px;">
@{{ monthlyChangeInYearlyRecurringRevenue }}% From Last Month
</div>
<!-- Compared To Last Year -->
<div v-if="yearlyChangeInYearlyRecurringRevenue" style="font-size: 12px;">
@{{ yearlyChangeInYearlyRecurringRevenue }}% From Last Year
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Total Volume -->
<div class="col-md-6">
<div class="panel panel-success">
<div class="panel-heading text-center">Total Volume</div>
<div class="panel-body text-center">
<span style="font-size: 24px;">
@{{ totalVolume | currency }}
</span>
</div>
</div>
</div>
<!-- Total Trials -->
<div class="col-md-6">
<div class="panel panel-info">
@if(Spark::teamTrialDays())
<div class="panel-heading text-center">Teams Currently Trialing</div>
@else
<div class="panel-heading text-center">Users Currently Trialing</div>
@endif
<div class="panel-body text-center">
<span style="font-size: 24px;">
@{{ totalTrialUsers }}
</span>
</div>
</div>
</div>
</div>
<!-- Monthly Recurring Revenue Chart -->
<div class="row" v-show="indicators.length > 0">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">Monthly Recurring Revenue</div>
<div class="panel-body">
<canvas id="monthlyRecurringRevenueChart" height="100"></canvas>
</div>
</div>
</div>
</div>
<!-- Yearly Recurring Revenue Chart -->
<div class="row" v-show="indicators.length > 0">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">Yearly Recurring Revenue</div>
<div class="panel-body">
<canvas id="yearlyRecurringRevenueChart" height="100"></canvas>
</div>
</div>
</div>
</div>
<div class="row" v-show="indicators.length > 0">
<!-- Daily Volume Chart -->
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Daily Volume</div>
<div class="panel-body">
<canvas id="dailyVolumeChart" height="100"></canvas>
</div>
</div>
</div>
<!-- Daily New Users Chart -->
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">New Users</div>
<div class="panel-body">
<canvas id="newUsersChart" height="100"></canvas>
</div>
</div>
</div>
</div>
<!-- Subscribers Per Plan -->
<div class="row" v-if="plans.length > 0">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">Subscribers</div>
<div class="panel-body">
<table class="table table-borderless m-b-none">
<thead>
<th>Name</th>
<th>Subscribers</th>
<th>Trialing</th>
</thead>
<tbody>
<tr v-if="genericTrialUsers">
<td>
<div class="btn-table-align">
On Generic Trial
</div>
</td>
<td>
<div class="btn-table-align">
N/A
</div>
</td>
<td>
<div class="btn-table-align">
@{{ genericTrialUsers }}
</div>
</td>
</tr>
<tr v-for="plan in plans">
<!-- Plan Name -->
<td>
<div class="btn-table-align">
@{{ plan.name }} (@{{ plan.interval | capitalize }})
</div>
</td>
<!-- Subscriber Count -->
<td>
<div class="btn-table-align">
@{{ plan.count }}
</div>
</td>
<!-- Trialing Count -->
<td>
<div class="btn-table-align">
@{{ plan.trialing }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</spark-kiosk-metrics>

View File

@@ -0,0 +1,127 @@
<spark-kiosk-add-discount inline-template>
<div>
<div class="modal fade" id="modal-add-discount" tabindex="-1" role="dialog">
<div class="modal-dialog" v-if="discountingUser">
<div class="modal-content">
<div class="modal-header">
<button type="button " class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">
Add Discount (@{{ discountingUser.name }})
</h4>
</div>
<div class="modal-body">
<!-- Current Discount -->
<div class="alert alert-success" v-if="currentDiscount">
This user has a discount of @{{ formattedDiscount(currentDiscount) }}
for @{{ formattedDiscountDuration(currentDiscount) }}.
</div>
<!-- Add Discount Form -->
<form class="form-horizontal" role="form">
<!-- Discount Type -->
<div class="form-group" :class="{'has-error': form.errors.has('type')}">
<label class="col-sm-4 control-label">Discount Type</label>
<div class="col-sm-6">
<div class="radio">
<label>
<input type="radio" value="amount" v-model="form.type">&nbsp;&nbsp;Amount
</label>
</div>
<div class="radio">
<label>
<input type="radio" value="percent" v-model="form.type">&nbsp;&nbsp;Percentage
</label>
</div>
<span class="help-block" v-show="form.errors.has('type')">
@{{ form.errors.get('type') }}
</span>
</div>
</div>
<!-- Discount Value -->
<div class="form-group" :class="{'has-error': form.errors.has('value')}">
<label class="col-md-4 control-label">
<span v-if="form.type == 'percent'">Percentage</span>
<span v-if="form.type == 'amount'">Amount</span>
</label>
<div class="col-md-6">
<input type="text" class="form-control" v-model="form.value">
<span class="help-block" v-show="form.errors.has('value')">
@{{ form.errors.get('value') }}
</span>
</div>
</div>
<!-- Discount Duration -->
<div class="form-group" :class="{'has-error': form.errors.has('duration')}">
<label class="col-sm-4 control-label">Discount Duration</label>
<div class="col-sm-6">
<div class="radio">
<label>
<input type="radio" value="once" v-model="form.duration">&nbsp;&nbsp;Once
</label>
</div>
<div class="radio">
<label>
<input type="radio" value="forever" v-model="form.duration">&nbsp;&nbsp;Forever
</label>
</div>
<div class="radio">
<label>
<input type="radio" value="repeating" v-model="form.duration">&nbsp;&nbsp;Multiple Months
</label>
</div>
<span class="help-block" v-show="form.errors.has('duration')">
@{{ form.errors.get('duration') }}
</span>
</div>
</div>
<!-- Duration Months -->
<div class="form-group" :class="{'has-error': form.errors.has('months')}" v-if="form.duration == 'repeating'">
<label class="col-md-4 control-label">
Months
</label>
<div class="col-md-6">
<input type="text" class="form-control" v-model="form.months">
<span class="help-block" v-show="form.errors.has('months')">
@{{ form.errors.get('months') }}
</span>
</div>
</div>
</form>
</div>
<!-- Modal Actions -->
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" @click="applyDiscount" :disabled="form.busy">
<span v-if="form.busy">
<i class="fa fa-btn fa-spinner fa-spin"></i>Applying
</span>
<span v-else>
Apply Discount
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</spark-kiosk-add-discount>

View File

@@ -0,0 +1,148 @@
<spark-kiosk-profile :user="user" :plans="plans" inline-template>
<div>
<!-- Loading Indicator -->
<div class="row" v-if="loading">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-body">
<i class="fa fa-btn fa-spinner fa-spin"></i>Loading
</div>
</div>
</div>
</div>
<!-- User Profile -->
<div v-if=" ! loading && profile">
<div class="row">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">
<!-- User Name -->
<div class="pull-left">
<div class="btn-table-align">
<i class="fa fa-btn fa-times" style="cursor: pointer;" @click="showSearch"></i>
@{{ profile.name }}
</div>
</div>
<!-- Profile Actions -->
<div class="pull-right" style="padding-top: 2px;">
<div class="btn-group" role="group">
<!-- Apply Discount -->
<button class="btn btn-default" v-if="spark.usesStripe && profile.stripe_id" @click="addDiscount(profile)">
<i class="fa fa-gift"></i>
</button>
<!-- Impersonate Button -->
<button class="btn btn-default" @click="impersonate(profile)" :disabled="user.id === profile.id">
<i class="fa fa-user-secret"></i>
</button>
</div>
</div>
<div class="clearfix"></div>
</div>
<div class="panel-body">
<div class="row">
<!-- Profile Photo -->
<div class="col-md-3 text-center">
<img :src="profile.photo_url" class="spark-profile-photo-xl">
</div>
<div class="col-md-9">
<!-- Email Address -->
<p>
<strong>Email Address:</strong> <a :href="'mailto:'+profile.email">@{{ profile.email }}</a>
</p>
<!-- Joined Date -->
<p>
<strong>Joined:</strong> @{{ profile.created_at | datetime }}
</p>
<!-- Subscription -->
<p>
<strong>Subscription:</strong>
<span v-if="activePlan(profile)">
<a :href="customerUrlOnBillingProvider(profile)" target="_blank">
@{{ activePlan(profile).name }} (@{{ activePlan(profile).interval | capitalize }})
</a>
</span>
<span v-else>
None
</span>
</p>
<!-- Total Revenue -->
<p>
<strong>Total Revenue:</strong> @{{ revenue | currency(spark.currencySymbol) }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Teams -->
<div class="row" v-if="spark.usesTeams && profile.owned_teams.length > 0">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">
{{ ucfirst(str_plural(Spark::teamString())) }}
</div>
<div class="panel-body">
<table class="table table-borderless m-b-none">
<thead>
<th></th>
<th>Name</th>
<th>Subscription</th>
</thead>
<tbody>
<tr v-for="team in profile.owned_teams">
<!-- Photo -->
<td>
<img :src="team.photo_url" class="spark-team-photo">
</td>
<!-- Team Name -->
<td>
<div class="btn-table-align">
@{{ team.name }}
</div>
</td>
<!-- Subscription -->
<td>
<div class="btn-table-align">
<span v-if="activePlan(team)">
<a :href="customerUrlOnBillingProvider(team)" target="_blank">
@{{ activePlan(team).name }} (@{{ activePlan(team).interval | capitalize }})
</a>
</span>
<span v-else>
None
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Apply Discount Modal -->
<div>
@include('spark::kiosk.modals.add-discount')
</div>
</div>
</spark-kiosk-profile>

View File

@@ -0,0 +1,92 @@
<spark-kiosk-users :user="user" inline-template>
<div>
<div v-show=" ! showingUserProfile">
<!-- Search Field Panel -->
<div class="panel panel-default panel-flush" style="border: 0;">
<div class="panel-body">
<form class="form-horizontal p-b-none" role="form" @submit.prevent>
<!-- Search Field -->
<div class="form-group m-b-none">
<div class="col-md-12">
<input type="text" id="kiosk-users-search" class="form-control"
name="search"
placeholder="Search By Name Or E-Mail Address..."
v-model="searchForm.query"
@keyup.enter="search">
</div>
</div>
</form>
</div>
</div>
<!-- Searching -->
<div class="panel panel-default" v-if="searching">
<div class="panel-heading">Search Results</div>
<div class="panel-body">
<i class="fa fa-btn fa-spinner fa-spin"></i>Searching
</div>
</div>
<!-- No Search Results -->
<div class="panel panel-warning" v-if=" ! searching && noSearchResults">
<div class="panel-heading">Search Results</div>
<div class="panel-body">
No users matched the given criteria.
</div>
</div>
<!-- User Search Results -->
<div class="panel panel-default" v-if=" ! searching && searchResults.length > 0">
<div class="panel-heading">Search Results</div>
<div class="panel-body">
<table class="table table-borderless m-b-none">
<thead>
<th></th>
<th>Name</th>
<th>E-Mail Address</th>
<th></th>
</thead>
<tbody>
<tr v-for="searchUser in searchResults">
<!-- Profile Photo -->
<td>
<img :src="searchUser.photo_url" class="spark-profile-photo">
</td>
<!-- Name -->
<td>
<div class="btn-table-align">
@{{ searchUser.name }}
</div>
</td>
<!-- E-Mail Address -->
<td>
<div class="btn-table-align">
@{{ searchUser.email }}
</div>
</td>
<td>
<!-- View User Profile -->
<button class="btn btn-default" @click="showUserProfile(searchUser)">
<i class="fa fa-search"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- User Profile Detail -->
<div v-show="showingUserProfile">
@include('spark::kiosk.profile')
</div>
</div>
</spark-kiosk-users>

View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Meta Information -->
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', config('app.name'))</title>
<!-- Fonts -->
<link href='https://fonts.googleapis.com/css?family=Open+Sans:300,400,600' rel='stylesheet' type='text/css'>
<link href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.5.0/css/font-awesome.min.css' rel='stylesheet' type='text/css'>
<!-- CSS -->
<link href="/css/sweetalert.css" rel="stylesheet">
<link href="{{ mix('css/app.css') }}" rel="stylesheet">
<!-- Scripts -->
@yield('scripts', '')
<!-- Global Spark Object -->
<script>
window.Spark = <?php echo json_encode(array_merge(
Spark::scriptVariables(), []
)); ?>;
</script>
</head>
<body class="with-navbar">
<div id="spark-app" v-cloak>
<!-- Navigation -->
@if (Auth::check())
@include('spark::nav.user')
@else
@include('spark::nav.guest')
@endif
<!-- Main Content -->
@yield('content')
<!-- Application Level Modals -->
@if (Auth::check())
@include('spark::modals.notifications')
@include('spark::modals.support')
@include('spark::modals.session-expired')
@endif
</div>
<!-- JavaScript -->
<script src="{{ mix('js/app.js') }}"></script>
<script src="/js/sweetalert.min.js"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More