diff --git a/hc/front/urls.py b/hc/front/urls.py index 201f9a35..ea385500 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -11,7 +11,6 @@ urlpatterns = [ url(r'^checks/([\w-]+)/email/$', views.email_preview), url(r'^checks/([\w-]+)/remove/$', views.remove_check, name="hc-remove-check"), url(r'^checks/([\w-]+)/log/$', views.log, name="hc-log"), - url(r'^pricing/$', views.pricing, name="hc-pricing"), url(r'^docs/$', views.docs, name="hc-docs"), url(r'^about/$', views.about, name="hc-about"), url(r'^integrations/$', views.channels, name="hc-channels"), diff --git a/hc/front/views.py b/hc/front/views.py index a56bfdfe..8a6d589a 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -50,10 +50,6 @@ def index(request): return render(request, "front/welcome.html", ctx) -def pricing(request): - return render(request, "front/pricing.html", {"page": "pricing"}) - - def docs(request): if "welcome_code" in request.session: code = request.session["welcome_code"] diff --git a/hc/payments/__init__.py b/hc/payments/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hc/payments/admin.py b/hc/payments/admin.py new file mode 100644 index 00000000..67599c92 --- /dev/null +++ b/hc/payments/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from .models import Subscription + + +@admin.register(Subscription) +class SubsAdmin(admin.ModelAdmin): + + list_display = ("id", "user") diff --git a/hc/payments/context_processors.py b/hc/payments/context_processors.py new file mode 100644 index 00000000..a6a428eb --- /dev/null +++ b/hc/payments/context_processors.py @@ -0,0 +1,5 @@ +from django.conf import settings + + +def payments(request): + return {'USE_PAYMENTS': settings.USE_PAYMENTS} diff --git a/hc/payments/migrations/0001_initial.py b/hc/payments/migrations/0001_initial.py new file mode 100644 index 00000000..0f87d3de --- /dev/null +++ b/hc/payments/migrations/0001_initial.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Subscription', + fields=[ + ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), + ('customer_id', models.CharField(blank=True, max_length=36)), + ('payment_method_token', models.CharField(blank=True, max_length=35)), + ('subscription_id', models.CharField(blank=True, max_length=10)), + ('user', models.OneToOneField(blank=True, null=True, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/hc/payments/migrations/__init__.py b/hc/payments/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hc/payments/models.py b/hc/payments/models.py new file mode 100644 index 00000000..4a8df4bd --- /dev/null +++ b/hc/payments/models.py @@ -0,0 +1,32 @@ +import braintree +from django.contrib.auth.models import User +from django.db import models + + +class Subscription(models.Model): + user = models.OneToOneField(User, blank=True, null=True) + customer_id = models.CharField(max_length=36, blank=True) + payment_method_token = models.CharField(max_length=35, blank=True) + subscription_id = models.CharField(max_length=10, blank=True) + + def _get_braintree_sub(self): + if not hasattr(self, "_sub"): + print("getting subscription over network") + self._sub = braintree.Subscription.find(self.subscription_id) + + return self._sub + + def is_active(self): + if not self.subscription_id: + return False + + o = self._get_braintree_sub() + return o.status == "Active" + + def price(self): + o = self._get_braintree_sub() + return int(o.price) + + def next_billing_date(self): + o = self._get_braintree_sub() + return o.next_billing_date diff --git a/hc/payments/tests.py b/hc/payments/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/hc/payments/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/hc/payments/urls.py b/hc/payments/urls.py new file mode 100644 index 00000000..053acb64 --- /dev/null +++ b/hc/payments/urls.py @@ -0,0 +1,22 @@ +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^pricing/$', + views.pricing, + name="hc-pricing"), + + url(r'^pricing/create_plan/$', + views.create_plan, + name="hc-create-plan"), + + url(r'^pricing/update_plan/$', + views.update_plan, + name="hc-update-plan"), + + url(r'^pricing/cancel_plan/$', + views.cancel_plan, + name="hc-cancel-plan"), + +] diff --git a/hc/payments/views.py b/hc/payments/views.py new file mode 100644 index 00000000..9bf9bde4 --- /dev/null +++ b/hc/payments/views.py @@ -0,0 +1,101 @@ +import braintree +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.shortcuts import redirect, render +from django.views.decorators.http import require_POST + +from .models import Subscription + + +def setup_braintree(): + kw = { + "merchant_id": settings.BRAINTREE_MERCHANT_ID, + "public_key": settings.BRAINTREE_PUBLIC_KEY, + "private_key": settings.BRAINTREE_PRIVATE_KEY + } + + braintree.Configuration.configure(settings.BRAINTREE_ENV, **kw) + + +def pricing(request): + setup_braintree() + + try: + sub = Subscription.objects.get(user=request.user) + except Subscription.DoesNotExist: + sub = Subscription(user=request.user) + sub.save() + + ctx = { + "page": "pricing", + "sub": sub, + "client_token": braintree.ClientToken.generate() + } + + return render(request, "payments/pricing.html", ctx) + + +@login_required +@require_POST +def create_plan(request): + setup_braintree() + sub = Subscription.objects.get(user=request.user) + if not sub.customer_id: + result = braintree.Customer.create({}) + assert result.is_success + sub.customer_id = result.customer.id + sub.save() + + if "payment_method_nonce" in request.POST: + result = braintree.PaymentMethod.create({ + "customer_id": sub.customer_id, + "payment_method_nonce": request.POST["payment_method_nonce"] + }) + assert result.is_success + sub.payment_method_token = result.payment_method.token + sub.save() + + price = int(request.POST["price"]) + assert price in (2, 5, 10, 15, 20, 25, 50, 100) + + result = braintree.Subscription.create({ + "payment_method_token": sub.payment_method_token, + "plan_id": "P%d" % price, + "price": price + }) + + sub.subscription_id = result.subscription.id + sub.save() + + return redirect("hc-pricing") + + +@login_required +@require_POST +def update_plan(request): + setup_braintree() + sub = Subscription.objects.get(user=request.user) + + price = int(request.POST["price"]) + assert price in (2, 5, 10, 15, 20, 25, 50, 100) + + fields = { + "plan_id": "P%s" % price, + "price": price + } + + braintree.Subscription.update(sub.subscription_id, fields) + return redirect("hc-pricing") + + +@login_required +@require_POST +def cancel_plan(request): + setup_braintree() + sub = Subscription.objects.get(user=request.user) + + braintree.Subscription.cancel(sub.subscription_id) + sub.subscription_id = "" + sub.save() + + return redirect("hc-pricing") diff --git a/hc/settings.py b/hc/settings.py index 2e0b805a..1b743fec 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -20,6 +20,7 @@ SECRET_KEY = "---" DEBUG = True ALLOWED_HOSTS = [] DEFAULT_FROM_EMAIL = 'healthchecks@example.org' +USE_PAYMENTS = False INSTALLED_APPS = ( @@ -62,6 +63,7 @@ TEMPLATES = [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'hc.payments.context_processors.payments' ], }, }, @@ -131,7 +133,7 @@ PUSHOVER_SUBSCRIPTION_URL = None PUSHOVER_EMERGENCY_RETRY_DELAY = 300 PUSHOVER_EMERGENCY_EXPIRATION = 86400 -try: +if os.path.exists(os.path.join(BASE_DIR, "hc/local_settings.py")): from .local_settings import * -except ImportError as e: +else: warnings.warn("local_settings.py not found, using defaults") diff --git a/hc/urls.py b/hc/urls.py index 5351cbea..8038c1d9 100644 --- a/hc/urls.py +++ b/hc/urls.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.conf.urls import include, url from django.contrib import admin @@ -6,4 +7,8 @@ urlpatterns = [ url(r'^accounts/', include('hc.accounts.urls')), url(r'^', include('hc.api.urls')), url(r'^', include('hc.front.urls')), + ] + +if settings.USE_PAYMENTS: + urlpatterns.append(url(r'^', include('hc.payments.urls'))) diff --git a/static/css/bootstrap.css b/static/css/bootstrap.css index 668e7384..add848f8 100644 --- a/static/css/bootstrap.css +++ b/static/css/bootstrap.css @@ -3466,19 +3466,22 @@ fieldset[disabled] .btn-link:focus { color: #777777; text-decoration: none; } -.btn-lg { +.btn-lg, +.btn-group-lg > .btn { padding: 10px 16px; font-size: 18px; line-height: 1.3333333; border-radius: 6px; } -.btn-sm { +.btn-sm, +.btn-group-sm > .btn { padding: 5px 10px; font-size: 12px; line-height: 1.5; border-radius: 3px; } -.btn-xs { +.btn-xs, +.btn-group-xs > .btn { padding: 1px 5px; font-size: 12px; line-height: 1.5; @@ -3670,6 +3673,175 @@ tbody.collapse.in { right: auto; } } +.btn-group, +.btn-group-vertical { + position: relative; + display: inline-block; + vertical-align: middle; +} +.btn-group > .btn, +.btn-group-vertical > .btn { + position: relative; + float: left; +} +.btn-group > .btn:hover, +.btn-group-vertical > .btn:hover, +.btn-group > .btn:focus, +.btn-group-vertical > .btn:focus, +.btn-group > .btn:active, +.btn-group-vertical > .btn:active, +.btn-group > .btn.active, +.btn-group-vertical > .btn.active { + z-index: 2; +} +.btn-group .btn + .btn, +.btn-group .btn + .btn-group, +.btn-group .btn-group + .btn, +.btn-group .btn-group + .btn-group { + margin-left: -1px; +} +.btn-toolbar { + margin-left: -5px; +} +.btn-toolbar .btn, +.btn-toolbar .btn-group, +.btn-toolbar .input-group { + float: left; +} +.btn-toolbar > .btn, +.btn-toolbar > .btn-group, +.btn-toolbar > .input-group { + margin-left: 5px; +} +.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) { + border-radius: 0; +} +.btn-group > .btn:first-child { + margin-left: 0; +} +.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) { + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} +.btn-group > .btn:last-child:not(:first-child), +.btn-group > .dropdown-toggle:not(:first-child) { + border-bottom-left-radius: 0; + border-top-left-radius: 0; +} +.btn-group > .btn-group { + float: left; +} +.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn { + border-radius: 0; +} +.btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child, +.btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle { + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} +.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child { + border-bottom-left-radius: 0; + border-top-left-radius: 0; +} +.btn-group .dropdown-toggle:active, +.btn-group.open .dropdown-toggle { + outline: 0; +} +.btn-group > .btn + .dropdown-toggle { + padding-left: 8px; + padding-right: 8px; +} +.btn-group > .btn-lg + .dropdown-toggle { + padding-left: 12px; + padding-right: 12px; +} +.btn-group.open .dropdown-toggle { + -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); +} +.btn-group.open .dropdown-toggle.btn-link { + -webkit-box-shadow: none; + box-shadow: none; +} +.btn .caret { + margin-left: 0; +} +.btn-lg .caret { + border-width: 5px 5px 0; + border-bottom-width: 0; +} +.dropup .btn-lg .caret { + border-width: 0 5px 5px; +} +.btn-group-vertical > .btn, +.btn-group-vertical > .btn-group, +.btn-group-vertical > .btn-group > .btn { + display: block; + float: none; + width: 100%; + max-width: 100%; +} +.btn-group-vertical > .btn-group > .btn { + float: none; +} +.btn-group-vertical > .btn + .btn, +.btn-group-vertical > .btn + .btn-group, +.btn-group-vertical > .btn-group + .btn, +.btn-group-vertical > .btn-group + .btn-group { + margin-top: -1px; + margin-left: 0; +} +.btn-group-vertical > .btn:not(:first-child):not(:last-child) { + border-radius: 0; +} +.btn-group-vertical > .btn:first-child:not(:last-child) { + border-top-right-radius: 4px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group-vertical > .btn:last-child:not(:first-child) { + border-bottom-left-radius: 4px; + border-top-right-radius: 0; + border-top-left-radius: 0; +} +.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn { + border-radius: 0; +} +.btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child, +.btn-group-vertical > .btn-group:first-child:not(:last-child) > .dropdown-toggle { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child { + border-top-right-radius: 0; + border-top-left-radius: 0; +} +.btn-group-justified { + display: table; + width: 100%; + table-layout: fixed; + border-collapse: separate; +} +.btn-group-justified > .btn, +.btn-group-justified > .btn-group { + float: none; + display: table-cell; + width: 1%; +} +.btn-group-justified > .btn-group .btn { + width: 100%; +} +.btn-group-justified > .btn-group .dropdown-menu { + left: auto; +} +[data-toggle="buttons"] > .btn input[type="radio"], +[data-toggle="buttons"] > .btn-group > .btn input[type="radio"], +[data-toggle="buttons"] > .btn input[type="checkbox"], +[data-toggle="buttons"] > .btn-group > .btn input[type="checkbox"] { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; +} .input-group { position: relative; display: table; @@ -5743,6 +5915,10 @@ button.close { .row:after, .form-horizontal .form-group:before, .form-horizontal .form-group:after, +.btn-toolbar:before, +.btn-toolbar:after, +.btn-group-vertical > .btn-group:before, +.btn-group-vertical > .btn-group:after, .nav:before, .nav:after, .navbar:before, @@ -5764,6 +5940,8 @@ button.close { .container-fluid:after, .row:after, .form-horizontal .form-group:after, +.btn-toolbar:after, +.btn-group-vertical > .btn-group:after, .nav:after, .navbar:after, .navbar-header:after, diff --git a/static/css/pricing.css b/static/css/pricing.css index 6f126402..648a4737 100644 --- a/static/css/pricing.css +++ b/static/css/pricing.css @@ -1,11 +1,3 @@ -.panel-pricing { - -moz-transition: all .3s ease; - -o-transition: all .3s ease; - -webkit-transition: all .3s ease; -} -.panel-pricing:hover { - box-shadow: 0px 0px 30px rgba(0, 0, 0, 0.2); -} .panel-pricing .panel-heading { padding: 20px 10px; } @@ -26,9 +18,93 @@ border-top-left-radius: 0px; } .panel-pricing .panel-body { - background-color: #f0f0f0; - font-size: 40px; - color: #777777; - padding: 20px; - margin: 0px; + font-size: 40px; + color: #777777; + padding: 20px; + margin: 0px; + background-color: #f0f0f0; +} + +.panel-slider { + padding: 20px 20px 40px 20px; + height: 78px; + background-color: #f0f0f0; +} + +.animated { + animation-duration: 1s; + animation-fill-mode: both; +} + + +@keyframes tada { + from { + -webkit-transform: scale3d(1, 1, 1); + transform: scale3d(1, 1, 1); + } + + 10%, 20% { + -webkit-transform: scale3d(.9, .9, .9) rotate3d(0, 0, 1, -3deg); + transform: scale3d(.9, .9, .9) rotate3d(0, 0, 1, -3deg); + } + + 30%, 50%, 70%, 90% { + -webkit-transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); + transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); + } + + 40%, 60%, 80% { + -webkit-transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); + transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); + } + + to { + -webkit-transform: scale3d(1, 1, 1); + transform: scale3d(1, 1, 1); + } +} + +.tada { + -webkit-animation-name: tada; + animation-name: tada; +} + +@keyframes tadaIn { + from { + -webkit-transform: scale3d(1, 1, 1); + transform: scale3d(1, 1, 1); + } + + 10%, 20% { + -webkit-transform: scale3d(1.05, 1.05, 1.05) rotate3d(0, 0, 1, -3deg); + transform: scale3d(1.05, 1.05, 1.05) rotate3d(0, 0, 1, -3deg); + } + + 30%, 50%, 70%, 90% { + -webkit-transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, 3deg); + transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, 3deg); + } + + 40%, 60%, 80% { + -webkit-transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); + transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); + } + + to { + -webkit-transform: scale3d(1, 1, 1); + transform: scale3d(1, 1, 1); + } +} + +.tadaIn { + -webkit-animation-name: tadaIn; + animation-name: tadaIn; +} + +#pww-switch-btn { + display: none; } + +#subscription-status form { + display: inline-block; +} \ No newline at end of file diff --git a/static/js/pricing.js b/static/js/pricing.js new file mode 100644 index 00000000..b20d7511 --- /dev/null +++ b/static/js/pricing.js @@ -0,0 +1,57 @@ +$(function () { + var prices = [2, 5, 10, 15, 20, 25, 50, 100]; + var initialPrice = parseInt($("#pricing-value").text()); + var priceIdx = prices.indexOf(initialPrice); + + function updateDisplayPrice(price) { + $("#pricing-value").text(price); + $(".selected-price").val(price); + $("#pww-switch-btn").text("Switch to $" + price + " / mo"); + + if (price == initialPrice) { + $("#pww-selected-btn").show(); + $("#pww-switch-btn").hide(); + } else { + $("#pww-selected-btn").hide(); + $("#pww-switch-btn").show(); + } + } + + $("#pay-plus").click(function() { + if (priceIdx > 6) + return; + + priceIdx += 1; + updateDisplayPrice(prices[priceIdx]); + + $("#piggy").removeClass().addClass("tada animated").one('webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend', function(){ + $(this).removeClass(); + });; + + }); + + $("#pay-minus").click(function() { + if (priceIdx <= 0) + return; + + priceIdx -= 1; + updateDisplayPrice(prices[priceIdx]); + + $("#piggy").removeClass().addClass("tadaIn animated").one('webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend', function(){ + $(this).removeClass(); + });; + + }); + + $("#pww-create-payment-method").click(function(){ + var $modal = $("#payment-method-modal"); + var clientToken = $modal.attr("data-client-token"); + + braintree.setup(clientToken, "dropin", { + container: "payment-form" + }); + + $modal.modal("show"); + }); + +}); \ No newline at end of file diff --git a/stuff/bootstrap/bootstrap.less b/stuff/bootstrap/bootstrap.less index a4333692..ac59f666 100755 --- a/stuff/bootstrap/bootstrap.less +++ b/stuff/bootstrap/bootstrap.less @@ -25,7 +25,7 @@ // Components @import "component-animations.less"; @import "dropdowns.less"; -// @import "button-groups.less"; +@import "button-groups.less"; @import "input-groups.less"; @import "navs.less"; @import "navbar.less"; diff --git a/templates/base.html b/templates/base.html index dec36838..3ce911f4 100644 --- a/templates/base.html +++ b/templates/base.html @@ -20,7 +20,9 @@ + {% if USE_PAYMENTS %} + {% endif %} @@ -80,9 +82,11 @@ {% endif %} + {% if USE_PAYMENTS %}
  • Pricing
  • + {% endif %}
  • Docs diff --git a/templates/front/pricing.html b/templates/front/pricing.html deleted file mode 100644 index 03062b26..00000000 --- a/templates/front/pricing.html +++ /dev/null @@ -1,41 +0,0 @@ -{% extends "base.html" %} -{% load staticfiles %} - -{% block title %}Pricing - It's Free! - healthchecks.io{% endblock %} - -{% block content %} - - -
    -
    -
    - - -
    -
    -
    - -

    Free Plan

    -
    -
    -

    €0 / Month

    -
    -
      -
    • Personal or Commercial use
    • -
    • Unlimited Checks
    • -
    • Unlimited Alerts
    • -
    - -
    -
    - - -
    -
    -
    - - - -{% endblock %} \ No newline at end of file diff --git a/templates/payments/pricing.html b/templates/payments/pricing.html new file mode 100644 index 00000000..b355f6a4 --- /dev/null +++ b/templates/payments/pricing.html @@ -0,0 +1,190 @@ +{% extends "base.html" %} +{% load staticfiles compress %} + +{% block title %}Pricing - It's Free! - healthchecks.io{% endblock %} + +{% block content %} + + +
    +
    + {% if sub.is_active %} +
    +
    +
    +

    + You are currently paying ${{ sub.price }}/month. + Next billing date will be {{ sub.next_billing_date }}.

    +

    + Thank you for supporting healthchecks.io! +

    + Update Payment Method +
    + {% csrf_token %} + +
    +
    +
    +
    + {% endif %} + + +
    + + +
    +
    +
    + +

    Free Plan

    +
    +
    +

    $0 / Month

    +
    +
      +
    • Personal or Commercial use
    • +
    • Unlimited Checks
    • +
    • Unlimited Alerts
    • +
    •  
    • +
    + +
    +
    + + + +
    +
    +
    +
    + +
    +

    Pay What You Want Plan

    +
    +
    +

    + + {% if sub.is_active %} + ${{ sub.price }} / Month + {% else %} + $10 / Month + {% endif %} + + + + + + + + +

    +
    + +
      +
    • Personal or Commercial use
    • +
    • Unlimited Checks
    • +
    • Unlimited Alerts
    • +
    • Priority Support
    • +
    + +
    +
    + +
    + +
    +
    + + + + +{% endblock %} + +{% block scripts %} + +{% compress js %} + + + +{% endcompress %} +{% endblock %}