Add a visible free plan. Tweak the plans and pricing page. Move all plans to a central plans service to have a single point for editing. Support the free plan on the user admin page. Tweak the landing page.
This commit is contained in:
parent
8d40f12165
commit
3eca5f65e1
7 changed files with 194 additions and 139 deletions
|
@ -383,6 +383,17 @@ def subscribe():
|
||||||
else:
|
else:
|
||||||
# Change the plan
|
# Change the plan
|
||||||
cus = stripe.Customer.retrieve(user.stripe_id)
|
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||||
|
|
||||||
|
if plan == 'free':
|
||||||
|
cus.cancel_subscription()
|
||||||
|
cus.save()
|
||||||
|
|
||||||
|
response_json = {
|
||||||
|
'plan': 'free',
|
||||||
|
'usedPrivateRepos': private_repos,
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
cus.plan = plan
|
cus.plan = plan
|
||||||
|
|
||||||
# User may have been a previous customer who is resubscribing
|
# User may have been a previous customer who is resubscribing
|
||||||
|
@ -390,34 +401,25 @@ def subscribe():
|
||||||
cus.card = request_data['token']
|
cus.card = request_data['token']
|
||||||
|
|
||||||
cus.save()
|
cus.save()
|
||||||
return jsonify(subscription_view(cus.subscription, private_repos))
|
|
||||||
|
response_json = subscription_view(cus.subscription, private_repos)
|
||||||
|
|
||||||
|
return jsonify(response_json)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/user/plan', methods=['GET'])
|
@app.route('/api/user/plan', methods=['GET'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
def get_subscription():
|
def get_subscription():
|
||||||
user = current_user.db_user
|
user = current_user.db_user
|
||||||
|
private_repos = model.get_private_repo_count(user.username)
|
||||||
|
|
||||||
if user.stripe_id:
|
if user.stripe_id:
|
||||||
private_repos = model.get_private_repo_count(user.username)
|
|
||||||
cus = stripe.Customer.retrieve(user.stripe_id)
|
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||||
|
|
||||||
if cus.subscription:
|
if cus.subscription:
|
||||||
return jsonify(subscription_view(cus.subscription, private_repos))
|
return jsonify(subscription_view(cus.subscription, private_repos))
|
||||||
|
|
||||||
abort(404)
|
return jsonify({
|
||||||
|
'plan': 'free',
|
||||||
|
'usedPrivateRepos': private_repos,
|
||||||
|
});
|
||||||
@app.route('/api/user/plan', methods=['DELETE'])
|
|
||||||
@api_login_required
|
|
||||||
def cancel_subscription():
|
|
||||||
user = current_user.db_user
|
|
||||||
|
|
||||||
if user.stripe_id:
|
|
||||||
private_repos = model.get_private_repo_count(user.username)
|
|
||||||
cus = stripe.Customer.retrieve(user.stripe_id)
|
|
||||||
cus.cancel_subscription()
|
|
||||||
return make_response('Deleted', 204)
|
|
||||||
|
|
||||||
abort(404)
|
|
||||||
|
|
|
@ -5,13 +5,23 @@
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plans .all-plans {
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans .all-plans .feature {
|
||||||
|
color: #428bca;
|
||||||
|
}
|
||||||
|
|
||||||
.plans-list {
|
.plans-list {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
margin-bottom: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plans-list .plan {
|
.plans-list .plan {
|
||||||
width: 245px;
|
width: 245px;
|
||||||
height: 260px;
|
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
@ -67,16 +77,26 @@
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plans-list .plan.focus {
|
.plans-list .plan .features {
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan .features i {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.plans-list .plan.small {
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-top: 4px solid #428bca;
|
border-top: 4px solid #428bca;
|
||||||
margin-top: 0px;
|
margin-top: 0px;
|
||||||
font-size: 1.6em;
|
font-size: 1.6em;
|
||||||
height: 270px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plans-list .plan button {
|
.plans .plan-faq dd{
|
||||||
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
|
@ -608,6 +628,10 @@ p.editable:hover i {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-admin .panel-plan .button-hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.user-admin .plan-description {
|
.user-admin .plan-description {
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
|
@ -34,7 +34,58 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
||||||
userService.load();
|
userService.load();
|
||||||
|
|
||||||
return userService;
|
return userService;
|
||||||
}])
|
}]);
|
||||||
|
|
||||||
|
$provide.factory('PlanService', [function() {
|
||||||
|
var plans = [
|
||||||
|
{
|
||||||
|
title: 'Open Source',
|
||||||
|
price: 0,
|
||||||
|
privateRepos: 0,
|
||||||
|
stripeId: 'free',
|
||||||
|
audience: 'Share with the world',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Micro',
|
||||||
|
price: 700,
|
||||||
|
privateRepos: 5,
|
||||||
|
stripeId: 'micro',
|
||||||
|
audience: 'For smaller teams',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Basic',
|
||||||
|
price: 1200,
|
||||||
|
privateRepos: 10,
|
||||||
|
stripeId: 'small',
|
||||||
|
audience: 'For your basic team',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Medium',
|
||||||
|
price: 2200,
|
||||||
|
privateRepos: 20,
|
||||||
|
stripeId: 'medium',
|
||||||
|
audience: 'For medium-sized teams',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
var planDict = {};
|
||||||
|
var i;
|
||||||
|
for(i = 0; i < plans.length; i++) {
|
||||||
|
planDict[plans[i].stripeId] = plans[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
var planService = {}
|
||||||
|
|
||||||
|
planService.planList = function() {
|
||||||
|
return plans;
|
||||||
|
}
|
||||||
|
|
||||||
|
planService.getPlan = function(planId) {
|
||||||
|
return planDict[planId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return planService;
|
||||||
|
}]);
|
||||||
}).
|
}).
|
||||||
directive('match', function($parse) {
|
directive('match', function($parse) {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -77,7 +77,9 @@ function HeaderCtrl($scope, UserService) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlansCtrl($scope, UserService) {
|
function PlansCtrl($scope, UserService, PlanService) {
|
||||||
|
$scope.plans = PlanService.planList();
|
||||||
|
|
||||||
$scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) {
|
$scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) {
|
||||||
$scope.user = currentUser;
|
$scope.user = currentUser;
|
||||||
}, true);
|
}, true);
|
||||||
|
@ -440,38 +442,18 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserAdminCtrl($scope, Restangular) {
|
function UserAdminCtrl($scope, Restangular, PlanService, $routeParams) {
|
||||||
$scope.plans = [
|
$scope.plans = PlanService.planList();
|
||||||
{
|
|
||||||
title: 'Micro',
|
|
||||||
price: 700,
|
|
||||||
privateRepos: 5,
|
|
||||||
stripeId: 'micro',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Basic',
|
|
||||||
price: 1200,
|
|
||||||
privateRepos: 10,
|
|
||||||
stripeId: 'small',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Medium',
|
|
||||||
price: 2200,
|
|
||||||
privateRepos: 20,
|
|
||||||
stripeId: 'medium',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
var planDict = {};
|
|
||||||
var i;
|
|
||||||
for(i = 0; i < $scope.plans.length; i++) {
|
|
||||||
planDict[$scope.plans[i].stripeId] = $scope.plans[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
var subscribedToPlan = function(sub) {
|
var subscribedToPlan = function(sub) {
|
||||||
$scope.subscription = sub;
|
$scope.subscription = sub;
|
||||||
$scope.subscribedPlan = planDict[sub.plan];
|
$scope.subscribedPlan = PlanService.getPlan(sub.plan);
|
||||||
$scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos;
|
$scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos;
|
||||||
|
|
||||||
|
if (sub.usedPrivateRepos > $scope.subscribedPlan.privateRepos) {
|
||||||
|
$scope.errorMessage = 'You are using more private repositories than your plan allows, please upgrate your subscription to avoid disruptions in your service.';
|
||||||
|
}
|
||||||
|
|
||||||
$scope.planLoading = false;
|
$scope.planLoading = false;
|
||||||
$scope.planChanging = false;
|
$scope.planChanging = false;
|
||||||
}
|
}
|
||||||
|
@ -505,8 +487,7 @@ function UserAdminCtrl($scope, Restangular) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Got request for plan: ' + planId);
|
var planDetails = PlanService.getPlan(planId)
|
||||||
var planDetails = planDict[planId]
|
|
||||||
StripeCheckout.open({
|
StripeCheckout.open({
|
||||||
key: 'pk_test_uEDHANKm9CHCvVa2DLcipGRh',
|
key: 'pk_test_uEDHANKm9CHCvVa2DLcipGRh',
|
||||||
address: false, // TODO change to true
|
address: false, // TODO change to true
|
||||||
|
@ -536,34 +517,14 @@ function UserAdminCtrl($scope, Restangular) {
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.cancelSubscription = function() {
|
$scope.cancelSubscription = function() {
|
||||||
$scope.planChanging = true;
|
$scope.changeSubscription('free');
|
||||||
$scope.errorMessage = undefined;
|
|
||||||
var unsubscribeRequest = Restangular.one('user/plan');
|
|
||||||
unsubscribeRequest.customDELETE().then(function() {
|
|
||||||
$scope.subscription = undefined;
|
|
||||||
$scope.subscribedPlan = undefined;
|
|
||||||
$scope.planUsagePercent = 0;
|
|
||||||
$scope.planChanging = false;
|
|
||||||
}, function() {
|
|
||||||
// Failure
|
|
||||||
$scope.errorMessage = 'Unable to unsubscribe.';
|
|
||||||
$scope.planChanging = false;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show the subscribe dialog if a plan was requested.
|
// Show the subscribe dialog if a plan was requested.
|
||||||
if (location.hash.indexOf('?') >= 0) {
|
var requested = $routeParams['plan']
|
||||||
var query = location.hash.substr(1).split('?')[1];
|
if (requested !== undefined && requested !== 'free') {
|
||||||
var data = query.split("&");
|
if (PlanService.getPlan(requested) !== undefined) {
|
||||||
for (var i = 0; i < data.length; i++) {
|
$scope.subscribe(requested);
|
||||||
var item = data[i].split("=");
|
|
||||||
if (item[0] == 'plan') {
|
|
||||||
var planId = item[1];
|
|
||||||
if (planDict[planId]) {
|
|
||||||
$scope.subscribe(planId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -5,7 +5,7 @@
|
||||||
<div ng-show="user.anonymous">
|
<div ng-show="user.anonymous">
|
||||||
<h1>Secure hosting for <b>private</b> docker containers</h1>
|
<h1>Secure hosting for <b>private</b> docker containers</h1>
|
||||||
<h3>Use the docker images <b>your team</b> needs with the safety of <b>private</b> storage</h3>
|
<h3>Use the docker images <b>your team</b> needs with the safety of <b>private</b> storage</h3>
|
||||||
<div class="sellcall"><a href="#/plans">Starting at $7/mo</a></div>
|
<div class="sellcall"><a href="#/plans">Private repository plans starting at $7/mo</a></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ng-show="!user.anonymous">
|
<div ng-show="!user.anonymous">
|
||||||
|
@ -41,7 +41,10 @@
|
||||||
<input type="email" class="form-control" placeholder="Email address" ng-model="newUser.email" required>
|
<input type="email" class="form-control" placeholder="Email address" ng-model="newUser.email" required>
|
||||||
<input type="password" class="form-control" placeholder="Create a password" ng-model="newUser.password" required>
|
<input type="password" class="form-control" placeholder="Create a password" ng-model="newUser.password" required>
|
||||||
<input type="password" class="form-control" placeholder="Verify your password" ng-model="newUser.repeatePassword" match="newUser.password" required>
|
<input type="password" class="form-control" placeholder="Verify your password" ng-model="newUser.repeatePassword" match="newUser.password" required>
|
||||||
<button class="btn btn-lg btn-primary btn-block" ng-disabled="signupForm.$invalid" type="submit">Get Started!</button>
|
<div class="form-group">
|
||||||
|
<button class="btn btn-lg btn-primary btn-block" ng-disabled="signupForm.$invalid" type="submit">Sign Up for Free!</button>
|
||||||
|
<p class="help-block">No credit card required.</p>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div ng-show="registering" style="text-align: center">
|
<div ng-show="registering" style="text-align: center">
|
||||||
<span class="spin" color="#fff" style="display: inline-block"></span>
|
<span class="spin" color="#fff" style="display: inline-block"></span>
|
||||||
|
@ -67,15 +70,15 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4 shoutout">
|
<div class="col-md-4 shoutout">
|
||||||
<i class="icon-cloud"></i>
|
<i class="icon-user"></i>
|
||||||
<b>Cloud Hosted</b>
|
<b>Shareable</b>
|
||||||
Accessible from anywhere, anytime
|
Have to share a container? No problem! Share with anyone you choose
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4 shoutout">
|
<div class="col-md-4 shoutout">
|
||||||
<i class="icon-share-sign"></i>
|
<i class="icon-cloud"></i>
|
||||||
<b>Shareable</b>
|
<b>Cloud Hosted</b>
|
||||||
Have to share a container? No problem! Share with anyone you choose
|
Accessible from anywhere, anytime
|
||||||
</div>
|
</div>
|
||||||
</div> <!-- row -->
|
</div> <!-- row -->
|
||||||
</div> <!-- container -->
|
</div> <!-- container -->
|
||||||
|
|
|
@ -1,40 +1,47 @@
|
||||||
<div class="container plans">
|
<div class="container plans">
|
||||||
<div class="callout">
|
<div class="callout">
|
||||||
Plans and Pricing
|
Plans & Pricing
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="all-plans">
|
||||||
|
All plans include <span class="feature">unlimited public repositories</span> and <span class="feature">unlimited sharing</span>. All paid plans have a <span class="feature">14-day free trial</span>.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="plans-list">
|
<div class="plans-list">
|
||||||
<div class="plan">
|
<div class="plan" ng-repeat="plan in plans" ng-class="plan.stripeId">
|
||||||
<div class="plan-title">Micro</div>
|
<div class="plan-title">{{ plan.title }}</div>
|
||||||
<div class="plan-price">$7</div>
|
<div class="plan-price">${{ plan.price/100 }}</div>
|
||||||
<div class="count"><b>5</b> private repositories</div>
|
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
|
||||||
<div class="description">For smaller teams</div>
|
<div class="description">{{ plan.audience }}</div>
|
||||||
|
|
||||||
<button class="btn btn-primary btn-block" ng-click="buyNow('micro')">Buy Now</button>
|
<button class="btn btn-primary btn-block" ng-click="buyNow(plan.stripeId)">Sign Up Now</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="plan focus">
|
<div class="row plan-faq">
|
||||||
<div class="plan-title">Basic</div>
|
<div class="col-md-6">
|
||||||
<div class="plan-price">$12</div>
|
<dl>
|
||||||
<div class="count"><b>10</b> private repositories</div>
|
<dt>Can I use Quay for free?</dt>
|
||||||
<div class="description">For your basic team</div>
|
<dd>Yes! We offer unlimited storage and serving of public repositories. We strongly believe in the open source community and will do what we can to help!</dd>
|
||||||
|
<dt>What types of payment do you accept?</dt>
|
||||||
|
<dd>Quay uses Stripe as our payment processor, so we can accept any of the payment options they offer, which are currently: Visa, MasterCard, American Express, JCB, Discover and Diners Club.</dd>
|
||||||
|
|
||||||
<button class="btn btn-primary btn-block" ng-click="buyNow('small')">Buy Now</button>
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<dl>
|
||||||
|
<dt>Can I change my plan?</dt>
|
||||||
|
<dd>Yes, you can change your plan at any time and your account will be pro-rated for the difference.</dd>
|
||||||
|
<dt>Do you offer special plans for business or academic institutions?</dt>
|
||||||
|
<dd>Please contact us at our <a href="mailto:support@quay.io">support email address</a> to discuss the details of your organization and inteded usage.</dd>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="plan">
|
|
||||||
<div class="plan-title">Medium</div>
|
|
||||||
<div class="plan-price">$22</div>
|
|
||||||
<div class="count"><b>20</b> private repositories</div>
|
|
||||||
<div class="description">For medium-sized teams</div>
|
|
||||||
|
|
||||||
<button class="btn btn-primary btn-block" ng-click="buyNow('medium')">Buy Now</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal message dialog -->
|
<!-- Modal message dialog -->
|
||||||
<div class="modal fade" id="signinModal">
|
<div class="modal fade" id="signinModal">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
@ -49,4 +56,4 @@
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /.modal-content -->
|
</div><!-- /.modal-content -->
|
||||||
</div><!-- /.modal-dialog -->
|
</div><!-- /.modal-dialog -->
|
||||||
</div><!-- /.modal -->
|
</div><!-- /.modal -->
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" ng-hide="planLoading">
|
<div class="row" ng-hide="planLoading">
|
||||||
<div class="col-md-4" ng-repeat='plan in plans'>
|
<div class="col-md-3" ng-repeat='plan in plans'>
|
||||||
<div class="panel" ng-class="{'panel-success': subscription.plan == plan.stripeId, 'panel-default': subscription.plan != plan.stripeId}">
|
<div class="panel" ng-class="{'panel-success': subscription.plan == plan.stripeId, 'panel-default': subscription.plan != plan.stripeId}">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
{{ plan.title }}
|
{{ plan.title }}
|
||||||
|
@ -20,9 +20,16 @@
|
||||||
<div class="panel-body panel-plan">
|
<div class="panel-body panel-plan">
|
||||||
<div class="plan-price">${{ plan.price / 100 }}</div>
|
<div class="plan-price">${{ plan.price / 100 }}</div>
|
||||||
<div class="plan-description"><b>{{ plan.privateRepos }}</b> Private Repositories</div>
|
<div class="plan-description"><b>{{ plan.privateRepos }}</b> Private Repositories</div>
|
||||||
<button class="btn btn-primary" ng-hide="subscription" ng-click="subscribe(plan.stripeId)">Subscribe</button>
|
<div ng-switch='plan.stripeId'>
|
||||||
<button class="btn" ng-show="subscription && (subscription.plan != plan.stripeId)" ng-click="changeSubscription(plan.stripeId)">Change</button>
|
<div ng-switch-when='free'>
|
||||||
<button class="btn btn-danger" ng-show="subscription.plan == plan.stripeId" ng-click="cancelSubscription()">Cancel</button>
|
<button class="btn button-hidden">Hidden!</button>
|
||||||
|
</div>
|
||||||
|
<div ng-switch-default>
|
||||||
|
<button class="btn btn-primary" ng-show="subscription.plan === 'free'" ng-click="subscribe(plan.stripeId)">Subscribe</button>
|
||||||
|
<button class="btn btn-default" ng-hide="subscription.plan === 'free' || subscription.plan === plan.stripeId" ng-click="changeSubscription(plan.stripeId)">Change</button>
|
||||||
|
<button class="btn btn-danger" ng-show="subscription.plan === plan.stripeId" ng-click="cancelSubscription()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Reference in a new issue