diff --git a/gratipay/utils/ghost.py b/gratipay/utils/ghost.py
new file mode 100644
index 0000000000..ea0848aa3d
--- /dev/null
+++ b/gratipay/utils/ghost.py
@@ -0,0 +1,162 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+PACKAGE_JSON = '''\
+{
+ "name": "ghost",
+ "version": "1.0.0-alpha.21",
+ "description": "Just a blogging platform.",
+ "author": "Ghost Foundation",
+ "homepage": "http://ghost.org",
+ "keywords": [
+ "ghost",
+ "blog",
+ "cms"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "git://github.com/TryGhost/Ghost.git"
+ },
+ "bugs": "https://github.com/TryGhost/Ghost/issues",
+ "contributors": "https://github.com/TryGhost/Ghost/graphs/contributors",
+ "license": "MIT",
+ "main": "./core/index",
+ "scripts": {
+ "start": "node index",
+ "test": "grunt validate --verbose",
+ "init": "yarn global add knex-migrator ember-cli grunt-cli && yarn install && grunt symlink && grunt init || true"
+ },
+ "engines": {
+ "node": "^4.5.0 || ^6.9.0"
+ },
+ "dependencies": {
+ "amperize": "0.3.4",
+ "archiver": "1.3.0",
+ "bcryptjs": "2.4.3",
+ "bluebird": "3.5.0",
+ "body-parser": "1.17.1",
+ "bookshelf": "0.10.3",
+ "brute-knex": "https://github.com/cobbspur/brute-knex/tarball/37439f56965b17d29bb4ff9b3f3222b2f4bd6ce3",
+ "bson-objectid": "1.1.5",
+ "chalk": "1.1.3",
+ "cheerio": "0.22.0",
+ "compression": "1.6.2",
+ "connect-slashes": "1.3.1",
+ "cookie-session": "1.2.0",
+ "cors": "2.8.3",
+ "csv-parser": "1.11.0",
+ "debug": "2.6.6",
+ "downsize": "0.0.8",
+ "express": "4.15.2",
+ "express-brute": "1.0.1",
+ "express-hbs": "1.0.4",
+ "extract-zip-fork": "1.5.1",
+ "fs-extra": "3.0.1",
+ "ghost-gql": "0.0.6",
+ "ghost-ignition": "2.8.11",
+ "ghost-storage-base": "0.0.1",
+ "glob": "5.0.15",
+ "gscan": "1.1.0",
+ "html-to-text": "3.2.0",
+ "icojs": "0.7.2",
+ "image-size": "0.5.2",
+ "intl": "1.2.5",
+ "intl-messageformat": "1.3.0",
+ "jsdom": "9.12.0",
+ "jsonpath": "0.2.11",
+ "knex": "0.12.9",
+ "knex-migrator": "2.0.16",
+ "lodash": "4.17.4",
+ "markdown-it": "8.3.1",
+ "markdown-it-footnote": "3.0.1",
+ "markdown-it-lazy-headers": "0.1.3",
+ "markdown-it-mark": "2.0.0",
+ "markdown-it-named-headers": "0.0.4",
+ "mobiledoc-dom-renderer": "0.6.5",
+ "moment": "2.18.1",
+ "moment-timezone": "0.5.13",
+ "multer": "1.3.0",
+ "mysql": "2.13.0",
+ "nconf": "0.8.4",
+ "netjet": "1.1.3",
+ "nodemailer": "0.7.1",
+ "oauth2orize": "1.8.0",
+ "passport": "0.3.2",
+ "passport-ghost": "2.3.1",
+ "passport-http-bearer": "1.0.1",
+ "passport-oauth2-client-password": "0.1.2",
+ "path-match": "1.2.4",
+ "rss": "1.2.2",
+ "sanitize-html": "1.14.1",
+ "semver": "5.3.0",
+ "simple-dom": "0.3.2",
+ "simple-html-tokenizer": "0.4.1",
+ "superagent": "3.5.2",
+ "unidecode": "0.1.8",
+ "uuid": "3.0.1",
+ "validator": "6.3.0",
+ "xml": "1.0.1"
+ },
+ "optionalDependencies": {
+ "sqlite3": "3.1.8"
+ },
+ "devDependencies": {
+ "grunt": "1.0.1",
+ "grunt-bg-shell": "2.3.3",
+ "grunt-cli": "1.2.0",
+ "grunt-contrib-clean": "1.0.0",
+ "grunt-contrib-compress": "1.3.0",
+ "grunt-contrib-copy": "1.0.0",
+ "grunt-contrib-jshint": "1.0.0",
+ "grunt-contrib-symlink": "^1.0.0",
+ "grunt-contrib-uglify": "2.0.0",
+ "grunt-contrib-watch": "1.0.0",
+ "grunt-cssnano": "2.1.0",
+ "grunt-docker": "0.0.11",
+ "grunt-express-server": "0.5.3",
+ "grunt-jscs": "3.0.1",
+ "grunt-mocha-cli": "2.1.0",
+ "grunt-mocha-istanbul": "5.0.2",
+ "grunt-shell": "1.3.1",
+ "grunt-subgrunt": "1.2.0",
+ "grunt-update-submodules": "0.4.1",
+ "istanbul": "0.4.5",
+ "jshint": "2.9.4",
+ "jshint-stylish": "2.2.1",
+ "matchdep": "1.0.1",
+ "minimist": "1.2.0",
+ "mocha": "3.3.0",
+ "nock": "9.0.13",
+ "rewire": "2.5.2",
+ "run-sequence": "1.2.2",
+ "should": "11.2.1",
+ "should-http": "0.1.1",
+ "sinon": "1.17.7",
+ "supertest": "3.0.0",
+ "tmp": "0.0.31"
+ },
+ "greenkeeper": {
+ "ignore": [
+ "glob",
+ "nodemailer",
+ "grunt",
+ "grunt-bg-shell",
+ "grunt-cli",
+ "grunt-contrib-clean",
+ "grunt-contrib-compress",
+ "grunt-contrib-copy",
+ "grunt-contrib-jshint",
+ "grunt-contrib-uglify",
+ "grunt-contrib-watch",
+ "grunt-docker",
+ "grunt-express-server",
+ "grunt-jscs",
+ "grunt-mocha-cli",
+ "grunt-mocha-istanbul",
+ "grunt-shell",
+ "grunt-subgrunt",
+ "grunt-update-submodules",
+ "sinon"
+ ]
+ }
+}'''
diff --git a/scss/pages/npm.scss b/scss/pages/npm.scss
new file mode 100644
index 0000000000..0591cd4fc9
--- /dev/null
+++ b/scss/pages/npm.scss
@@ -0,0 +1,66 @@
+#npm {
+
+ #content {
+ .important-thing-at-the-top {
+ margin-bottom: 40px;
+ }
+ .nav {
+ margin: 0 20% 60px;
+
+ li {
+ width: 50%;
+ text-align: center;
+ display: block;
+ float: left;
+ margin: 0;
+ padding: 0;
+ a {
+ font: normal 24px/28px $Ideal;
+ display: block;
+ height: 64px;
+ padding: 18px 0 0;
+ margin: 0;
+ border-bottom: 0;
+
+ .textwrap {
+ color: $black;
+ display: inline-block;
+ border-bottom: 4px solid transparent;
+ }
+
+ &:hover {
+ background: none;
+ }
+
+ &.selected {
+ .textwrap {
+ border-bottom-color: $green;
+ font-weight: bold;
+ }
+ }
+ }
+ &:nth-child(1) a {
+ padding-left: 33%;
+ text-align: left;
+ }
+ &:nth-child(2) a {
+ padding-right: 33%;
+ text-align: right;
+ }
+ }
+ }
+ .discovery .sorry {
+ margin: 40px 0;
+ }
+
+ form.package-json {
+ margin-top: 30px;
+ }
+
+ textarea {
+ height: 192px;
+ width: 100%;
+ font: 10px/12px $Mono;
+ }
+ }
+}
diff --git a/templates/base.html b/templates/base.html
index 16dd90d7f7..e2161da0e4 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -71,18 +71,20 @@
{% endif %}{% endblock %}
+ {% if page_id != 'npm' %}
★ |
{{ _( "{nowrap}We have {a}integrated npm{_a} into Gratipay.{_nowrap}{_nowrap}"
, nowrap=''|safe
, _nowrap=''|safe
- , a=''|safe
+ , a=''|safe
, _a=''|safe
) }} |
★ |
+ {% endif %}
{% block main %}
diff --git a/tests/py/test_notifications.py b/tests/py/test_notifications.py
index 3a8576c6e3..f516d6912d 100644
--- a/tests/py/test_notifications.py
+++ b/tests/py/test_notifications.py
@@ -26,5 +26,5 @@ def test_remove_notification(self):
alice.remove_notification('1234')
assert alice.notifications == ["abcd", "bcde"]
- def test_blog_announcement(self):
- assert 'integrating-npm-39333109419d">integrated' in self.client.GET('/').body
+ def test_star_announcement(self):
+ assert '/on/npm/">integrate' in self.client.GET('/').body
diff --git a/tests/py/test_www_npm_package.py b/tests/py/test_www_npm_package.py
index e1ea7801f9..5f465d058b 100644
--- a/tests/py/test_www_npm_package.py
+++ b/tests/py/test_www_npm_package.py
@@ -12,7 +12,7 @@ def setUp(self):
def test_anon_gets_signin_page_from_unclaimed(self):
body = self.client.GET('/on/npm/foo/').body
- assert 'foo npm package:' in body
+ assert 'foo npm package on Gratipay:' in body
def test_auth_gets_send_confirmation_page_from_unclaimed(self):
self.make_participant('bob', claimed_time='now')
@@ -60,6 +60,7 @@ class Bulk(Harness):
def setUp(self):
self.make_package()
- def test_anon_gets_signin_page(self):
+ def test_anon_gets_payment_flow(self):
body = self.client.GET('/on/npm/').body
+ assert 'Paste a package.json' in body
assert '0 out of all 1 npm package' in body
diff --git a/tests/ttw/test_package_claiming.py b/tests/ttw/test_package_claiming.py
index 14501bd7a1..0eebc9c855 100644
--- a/tests/ttw/test_package_claiming.py
+++ b/tests/ttw/test_package_claiming.py
@@ -162,10 +162,10 @@ def setUp(self):
def visit_as(self, username):
self.visit('/')
self.sign_in(username)
- self.visit('/on/npm/')
+ self.visit('/on/npm/?flow=receive')
def test_anon_gets_sign_in_prompt(self):
- self.visit('/on/npm/')
+ self.visit('/on/npm/?flow=receive')
assert self.css('.important-button button').text == 'Sign in / Sign up'
def test_auth_without_email_gets_highlighted_link_to_email(self):
diff --git a/tests/ttw/test_package_discovery.py b/tests/ttw/test_package_discovery.py
new file mode 100644
index 0000000000..bb981ec9d3
--- /dev/null
+++ b/tests/ttw/test_package_discovery.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+from gratipay.testing import BrowserHarness
+
+
+class Tests(BrowserHarness):
+
+ def assertDiscovery(self):
+ instructions = self.css('.instructions').text
+ assert instructions == 'Paste a package.json to find packages to pay for:'
+
+ def test_anon_gets_discovery_page_by_default(self):
+ self.visit('/on/npm/')
+ self.assertDiscovery()
+
+ def test_auth_also_gets_discovery_page_by_default(self):
+ self.make_participant('alice')
+ self.sign_in('alice')
+ self.visit('/on/npm/')
+ self.assertDiscovery()
+
+ def test_pasting_a_package_json_works(self):
+ self.make_package(name='amperize', description='Amperize!')
+ mysql = self.make_package(name='mysql', description='MySQL!', emails=['bob@example.com'])
+ self.make_package(name='netjet', description='Netjet!', emails=['cat@example.com'])
+ scape = self.make_package(name='scape', description='Reject!', emails=['goat@example.com'])
+ self.claim_package(self.make_participant('alice'), 'amperize')
+ self.claim_package(self.make_participant('bob'), 'mysql')
+ self.claim_package(self.make_participant('goat'), 'scape')
+
+ admin = self.make_admin()
+ mysql.team.update(name='MySQL')
+ mysql.team.update_review_status('approved', admin)
+ scape.team.update_review_status('rejected', admin)
+
+ self.visit('/on/npm/')
+ self.css('textarea').fill('''\
+
+ { "dependencies": {"scape": "...", "mysql": "...", "amperize": "..."}
+ , "optionalDependencies": {"netjet": "...", "falafel": "..."}
+ }
+
+ ''')
+ self.css('form.package-json button').click()
+
+ names = [x.text for x in self.css('.listing-name')]
+ assert names == ['MySQL (mysql on npm)', 'scape', 'amperize', 'netjet', 'falafel']
+
+ statuses = [x.text[3:] for x in self.css('.listing-details .status')]
+ assert statuses == ['Approved', 'Rejected', 'Unreviewed', 'Unclaimed']
+
+ enabled = [not x.has_class('disabled') for x in self.css('td.item')]
+ assert enabled == [True, True, True, True, False]
diff --git a/www/assets/gratipay.css.spt b/www/assets/gratipay.css.spt
index cb36ae767c..b2da2dc116 100644
--- a/www/assets/gratipay.css.spt
+++ b/www/assets/gratipay.css.spt
@@ -68,6 +68,7 @@
@import "scss/pages/history";
@import "scss/pages/identities";
@import "scss/pages/team";
+@import "scss/pages/npm";
@import "scss/pages/package";
@import "scss/pages/profile-edit";
@import "scss/pages/giving";
diff --git a/www/on/npm/%package/index.html.spt b/www/on/npm/%package/index.html.spt
index 9efb9543bb..0703212df9 100644
--- a/www/on/npm/%package/index.html.spt
+++ b/www/on/npm/%package/index.html.spt
@@ -51,17 +51,22 @@ if user.participant:
{{ _("No description available.") }}
{% endif %}
-
- {{ _( 'Apply to accept payments for the {package_link} npm package:'
- , package_link=('' + package_name + '')|safe
- ) }}
-
-
{% if user.ANON %}
+
+ {{ _( 'Claim the {package_link} npm package on Gratipay:'
+ , package_link=('' + package_name + '')|safe
+ ) }}
+
{{ sign_in_using(button_class='large') }}
{% else %}
+
+ {{ _( 'Apply to accept payments for the {package_link} npm package:'
+ , package_link=('' + package_name + '')|safe
+ ) }}
+
+
{% if len(emails) == 0 %}
{{ _("No email addresses on file.") }}
{% else %}
diff --git a/www/on/npm/index.html.spt b/www/on/npm/index.html.spt
index a2bcf17065..c93b1adf41 100644
--- a/www/on/npm/index.html.spt
+++ b/www/on/npm/index.html.spt
@@ -1,6 +1,12 @@
-from gratipay.utils import icons
+import json
+from collections import OrderedDict
+
+from aspen import Response
+from gratipay.utils import icons, tabs, listings, ghost
+
+GROUPS = ('dependencies', 'devDependencies', 'optionalDependencies')
[---]
-banner = manager = "npm"
+banner = manager = page_id = "npm"
suppress_sidebar = True
npm_stats = website.db.one('''
select (select count(*) from teams_to_packages) as claimed_packages
@@ -9,7 +15,67 @@ npm_stats = website.db.one('''
if user.participant:
packages_for_claiming = user.participant.get_packages_for_claiming(manager)
any_claimable = any([rec.claimed_by is None for rec in packages_for_claiming])
+
+# Can't factor this out because of translations?
+i18ned_statuses = { "approved": _("Approved")
+ , "unreviewed" : _("Unreviewed")
+ , "rejected": _("Rejected")
+ , "featured": _("Featured")
+ }
+
+flow = request.qs.get('flow')
+i18ned_flows = {'give': _('Give'), 'receive' : _('Receive')}
+tab_html = lambda key, tab: '{}'.format(i18ned_flows[key])
+tabs = tabs.make(tab_html, 'flow', flow, 'give', 'receive')
+
+discovered = []
+if request.method == 'POST':
+ try:
+ package_json = json.loads(request.body['package.json'], object_pairs_hook=OrderedDict)
+ except:
+ raise Response(400)
+
+ # We want a single list of (source, name, package, project) tuples. Source
+ # is 'dependencies', 'devDependencies', or 'optionalDependencies'. We want
+ # name in the tuple in case there is no package. Package and team should be
+ # None if we don't have one.
+
+ package_json_groups = [package_json.get(g, OrderedDict()) for g in GROUPS]
+ assert all(d.__class__ is OrderedDict for d in package_json_groups)
+
+ # Load package and project (team) objects (in a single database call).
+ alldeps = set()
+ [map(alldeps.add, d) for d in package_json_groups]
+ known = website.db.all('''
+ SELECT p.name
+ , p.*::packages package
+ , t.*::teams project
+ FROM packages p
+ LEFT JOIN teams_to_packages t2p
+ ON p.id = t2p.package_id
+ LEFT JOIN teams t
+ ON t2p.team_id = t.id
+ WHERE package_manager='npm'
+ AND p.name = ANY(%s)
+ ''', (list(alldeps),))
+ known = {n:(p,t) for n,p,t in known}
+
+ # Replace ranges with tuples.
+ for source, deps in zip(GROUPS, package_json_groups):
+ for name in deps:
+ package, project = known.get(name, (None, None))
+ deps[name] = (source, name, package, project)
+
+ # Regroup.
+ yes, no = [], []
+ for group in package_json_groups:
+ for rec in group.values():
+ project = rec[3]
+ (yes if project and project.is_approved else no).append(rec)
+ discovered = [(_("Ready to accept payments"), yes), (_("Not available to pay"), no)]
+
[---]
+{% from "templates/nav-tabs.html" import nav_tabs with context %}
{% extends "templates/base.html" %}
{% block banner %}
@@ -33,20 +99,100 @@ if user.participant:
{% block content %}
-
- {{ _('Free as in money.') }}
-
+
{{ _('Free as in money.') }}
+{{ nav_tabs(tabs) }}
-
- {{ _('Apply to accept payments for your npm packages:') }}
-
+{% if flow == None %}
+{% if discovered %}
+
+{% for heading, group in discovered %}
+
{{ heading }}
+{% if group %}
+
+{% else %}
+
{{ _("No packages discovered.") }}
+{% endif %}
+{% endfor %}
+
+{% endif %}
+
+
+
{% else %}
+
{{ _('Apply to accept payments for your npm packages:') }}
+{% if user.ANON %}
+
{{ sign_in_using(button_class='large') }}
+{% else %}
{% if not packages_for_claiming %}
{{ _("No packages found.") }}
{% else %}
@@ -120,6 +266,7 @@ if user.participant:
, _a=''|safe
) }}
+ {% endif %}
{% endif %}
{{ _( "{n} out of all {N} npm packages are on Gratipay."