diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..f4c7df6d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# Tab indentation (no size specified) +[*.js] +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d2e78f2e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,26 @@ +#Github Global settings https://help.github.com/articles/dealing-with-line-endings + + +text eol=lf + +# Explicitly declare text files we want to always be normalized and converted +# to native line endings on checkout. +*.js text +*.json text +*.jake text +*.ejs text +*.html text +*.styl text +*.css text +*.sql text +*.yml text +*.sh text +*.markdown text +*.sql text +Vagrantfile text +Jakefile text + + +# Denote all files that are truly binary and should not be modified. +*.png binary +*.jpg binary \ No newline at end of file diff --git a/.gitignore b/.gitignore index 52b4a3b4..7b64d574 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -config.dev.json -config.production.json -node_modules/ -*.log +/.gitignore +/btc +/ltc +/Jakefile +*.ps1 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..63eff762 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,52 @@ +language: node_js +node_js: + - 0.10 +env: + - NODE_ENV=travis +before_install: + - npm config set loglevel warn +before_script: + # postgresql 9.2 + - sudo /etc/init.d/postgresql stop + - sudo cp /etc/postgresql/9.1/main/pg_hba.conf ./ + - sudo apt-get remove postgresql postgresql-9.1 -qq --purge + - source /etc/lsb-release + - echo "deb http://apt.postgresql.org/pub/repos/apt/ $DISTRIB_CODENAME-pgdg main" > pgdg.list + - sudo mv pgdg.list /etc/apt/sources.list.d/ + - wget --quiet -O - http://apt.postgresql.org/pub/repos/apt/ACCC4CF8.asc | sudo apt-key add - + - sudo apt-get update + - sudo apt-get -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confnew" install postgresql-9.2 postgresql-contrib-9.2 -qq + - sudo /etc/init.d/postgresql stop + - sudo cp ./pg_hba.conf /etc/postgresql/9.2/main + - sudo /etc/init.d/postgresql start + # xwindows + - "export DISPLAY=:99.0" + - "sh -e /etc/init.d/xvfb start" + - sudo apt-get install -y lynx +script: + # db + - "psql -c 'create database travis;' -U postgres" + - "pushd db" + - "npm install" + - "node ./node_modules/pg-migrate/bin/pg-migrate -d migrations -u postgres://postgres@localhost/travis" + - "node ./node_modules/pg-test/bin/pg-test test" + - "popd" + # api + - "pushd api" + - "npm install" + - "which mocha" + - "npm test" + - npm run-script coverage-text + - "popd" + # workers + - "pushd workers" + - "npm install" + - "npm test" + - "popd" + # client + - "pushd client" + - "npm install" + - "npm test" + - "popd" + - "npm install jshint@2.1.3" + - "jshint `find | grep -P '\\.js$' | grep -vP '(/node_modules/|/vendor/|/admin/components/|/coverage/)' | sed ':a;N;$!ba;s/\\n/ /g'`" diff --git a/Jakefile b/Jakefile new file mode 100644 index 00000000..36e1df5d --- /dev/null +++ b/Jakefile @@ -0,0 +1,39 @@ +require('shelljs/global') +config.silent = false + +function exited(name, code, output) { + console.error('%s exited with code %d\n%s', name, code, output) + + // bitcoind/litecoind gets stuck at times + exec('powershell -Command "kill -name bitcoind"') + exec('powershell -Command "kill -name litecoind"') + + process.exit(1) +} + +task('dev', function() { + // Static web server + pushd('web') + var staticWeb = exec('jake host', { async: true }, exited.bind(this, 'staticWeb')) + popd() + + // API server + pushd('api') + var apiServer = exec('nodemon index.js', { async: true }, exited.bind(this, 'apiServer')) + popd() + + return + + // bitcoind +// var bitcoind = exec('bitcoind -datadir=btc -txindex=1', exited.bind(this, 'bitcoind')) + + // litecoind +// var litecoind = exec('litecoind -datadir=ltc', exited.bind(this, 'litecoind')) + + // workers + /* + pushd('workers') + var workers = exec('nodemon bin/all', exited.bind(this, 'workers')) + popd() + */ +}, { async: true }) diff --git a/README.markdown b/README.markdown new file mode 100644 index 00000000..918d5865 --- /dev/null +++ b/README.markdown @@ -0,0 +1,43 @@ +# Snow + +Snow is a digital currency exchange engine written in node.js. + +Snow uses postgresql as its database engine and is currently being developed for Ubuntu 12.04. + +It currently supports deposits and withdrawals operations on **Bitcoin**, **Litecoin** and **Ripple** as well as a trading engine. + +## Project Overview + +### Roles and components: + +A fully functional Snow system includes 7 roles, the install scripts are located in the /ops/ folder + +#### Roles + +Role | Install script | Description +--- | --- | --- | +pgm | /ops/pg/master.sh | The database master, all the write queries are sent to this node +pgs| /ops/pg/slave.sh | The read only node acts a live backup of the master +api| /ops/api/setup.sh | The API node exposes the API to the web server. It is responsible for most of the database operations and contain the business logic +workers | /ops/workers/setup.sh | The workers communicates to the database and is reponsible to relay transactions from and to the Bitcoin, Litecoin and Ripple networks +web | not included | The client facing web server that communicates with the API +bitcoin | not included | The Bitcoin node that communicates to the workers via RPC calls +litecoin | not included| The Litecoin node that communicates to the workers via RPC calls + + +##### Folder Sructure + +Path | Content | Description | +--- | --- | --- | +/admin/ | admin | node.js code for the snow admin interface role | +/workers/ | worker | node.js code for the workers role| +/api/ | api | node.js code for the snow api server role | +/client/ | client api | node.js client library for accessing the market | +/db/ | database scripts|Contains initialization, migration and test scripts for the postgresql database | +/docs/ | documentation | API and Activity types documentation | +/ops/ | snow-ops | Installation scripts and network topology + + +##### [API Documentation](https://github.com/justcoin/snow/blob/master/docs/calls.md) + +##### [Snow-Ops and Network topology](https://github.com/justcoin/snow/blob/master/ops/README.markdown) diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 00000000..9d3be45b --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,7 @@ +npm-debug.log +node_modules/ +phantomjs.exe +build/ +config.dev.json +bower_components/ +/*.ps1 diff --git a/admin/.jshintrc b/admin/.jshintrc new file mode 100644 index 00000000..1ff31b4a --- /dev/null +++ b/admin/.jshintrc @@ -0,0 +1,32 @@ +{ + "browser": true, // This option defines globals exposed by modern browsers: all the way from good ol' document and navigator to the HTML5 FileReader and other new developments in the browser world. + "devel": true, // This option defines globals that are usually used for logging poor-man's debugging: console, alert, etc. + "expr": true, // This option suppresses warnings about the use of expressions where normally you would expect to see assignments or function calls. Most of the time, such code is a typo. However, it is not forbidden by the spec and that's why this warning is optional. + "globals": { + "$app": false, + "api": false, + "router": false, + "errors": false, + "numbers": false, + "Modernizr": false, + "alertify": false + }, + "es3": true, + "indent": 4, // This option enforces specific tab width for your code. + "jquery": true, // This option defines globals exposed by the jQuery JavaScript library. + "laxbreak": true, // This option suppresses most of the warnings about possibly unsafe line breakings in your code. It doesn't suppress warnings about comma-first coding style. + "laxcomma": true, // This option suppresses warnings about comma-first coding style: + "loopfunc": true, // This option suppresses warnings about functions inside of loops. + "node": true, // This option defines globals available when your code is running inside of the Node runtime environment. Node.js is a server-side JavaScript environment that uses an asynchronous event-driven model. + "quotmark": "single", // This option enforces the consistency of quotation marks used throughout your code. It accepts three values: true if you don't want to enforce one particular style but want some consistency, "single" if you want to allow only single quotes and "double" if you want to allow only double quotes. + "sub": true, // This option suppresses warnings about using [] notation when it can be expressed in dot notation: person['name'] vs. person.name. + "undef": true, // This option prohibits the use of explicitly undeclared variables. This option is very useful for spotting leaking and mistyped variables. + "unused": true, // This option warns when you define and never use your variables. It is very useful for general code cleanup, especially when used in addition to undef., + "asi": true, + "noarg": true, + "nonew": true, + "trailing": true, + "maxdepth": 3, + "maxlen": 90, + "maxcomplexity": 10 +} diff --git a/admin/Jakefile b/admin/Jakefile new file mode 100644 index 00000000..d02d4e52 --- /dev/null +++ b/admin/Jakefile @@ -0,0 +1,11 @@ +/* global task, config, rm, directory */ +require('shelljs/global') +config.silent = true + +task('clean', function() { + rm('-Rf', 'build/*') +}) + +directory('build') + +task('default', ['dist']) diff --git a/admin/README.markdown b/admin/README.markdown new file mode 100644 index 00000000..8ad66372 --- /dev/null +++ b/admin/README.markdown @@ -0,0 +1,3 @@ +Snow Administration Web Interface +=== + diff --git a/admin/api.js b/admin/api.js new file mode 100644 index 00000000..4fbbda77 --- /dev/null +++ b/admin/api.js @@ -0,0 +1,123 @@ +/* global -api */ +var _ = require('lodash') +, sjcl = require('./vendor/sjcl') +, emitter = require('./util/emitter') +, api = module.exports = emitter() + +function keyFromCredentials(email, password) { + var concat = email.toLowerCase() + password + , bits = sjcl.hash.sha256.hash(concat) + , hex = sjcl.codec.hex.fromBits(bits) + return hex +} + +function formatQuerystring(qs) { + var params = _.map(qs, function(v, k) { + if (v === null) return null + if (_.isString(v) && !v.length) return k + return k + '=' + encodeURIComponent(v) + }) + + params = _.filter(params, function(x) { + return x !== null + }) + + return params.length ? '?' + params.join('&') : '' +} + +api.call = function(method, data, options) { + var settings = { + url: '/api/' + method + } + + options = options || {} + options.qs = options.qs || {} + options.qs.ts = +new Date() + + if (options.key || api.key) { + options.qs.key = options.key || api.key + } + + if (options.type) settings.type = options.type + else if (data) settings.type = 'POST' + + if (data) { + settings.contentType = 'application/json; charset=utf-8' + settings.data = JSON.stringify(data) + } + + if (_.size(options.qs)) { + settings.url += formatQuerystring(options.qs) + } + + var xhr = $.ajax(settings) + xhr.settings = settings + + return xhr + .then(null, function(xhr, statusText, status) { + var body = errors.bodyFromXhr(xhr) + + var error = { + xhr: xhr, + xhrOptions: options, + body: body, + statusText: statusText, + status: status, + name: body && body.name ? body.name : null, + message: body && body.message || null + } + + return error + }) +} + +api.loginWithKey = function(key) { + return api.call('v1/whoami', null, { key: key }) + .then(function(user) { + $.cookie('apiKey', key) + $.cookie('existingUser', true, { path: '/', expires: 365 * 10 }) + + api.key = key + api.user = user + api.trigger('user', user) + + $app.addClass('is-logged-in') + }) +} + +api.login = function(email, password, otp) { + var key = keyFromCredentials(email, password) + return api.call('v1/twoFactor/auth', { + otp: otp + }, { + qs: { + key: key + } + }).then(function() { + return api.loginWithKey(key) + }) +} + +api.currencies = function() { + return api.call('v1/currencies') + .done(function(currencies) { + api.currencies.value = currencies + api.trigger('currencies', currencies) + }) +} + +api.markets = function() { + return api.call('v1/markets') + .done(function(markets) { + api.markets.value = markets + api.trigger('markets', markets) + }) +} + +api.bootstrap = function() { + return $.when( + api.currencies() + ).done(function() { + $app.removeClass('is-loading') + }) +} diff --git a/admin/authorize.js b/admin/authorize.js new file mode 100644 index 00000000..480c6fad --- /dev/null +++ b/admin/authorize.js @@ -0,0 +1,10 @@ +exports.admin = function(register) { + if (api.user && api.user.admin) return true + + var after = window.location.hash.substr(1) + + // Avoid looping after-inception + after = after.replace(/(register|login)(\?after=)?/, '') + + router.go((register ? 'register' : 'login') + (after ? '?after=' + after : '')) +} diff --git a/admin/bower.json b/admin/bower.json new file mode 100644 index 00000000..34095af0 --- /dev/null +++ b/admin/bower.json @@ -0,0 +1,22 @@ +{ + "name": "admin", + "main": "index.js", + "version": "1.0.9", + "homepage": "https://github.com/justcoin/justcoin", + "authors": [ + "Andreas Brekken " + ], + "license": "MIT", + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "test", + "tests" + ], + "dependencies": { + "bootstrap-notify": "~0.1.0", + "alertify": "~0.3.10", + "jquery": "~2.0.3" + } +} diff --git a/admin/controllers/balances/index.js b/admin/controllers/balances/index.js new file mode 100644 index 00000000..bd530ea3 --- /dev/null +++ b/admin/controllers/balances/index.js @@ -0,0 +1,26 @@ +module.exports = function() { + var itemTemplate = require('./item.html') + , $el = $(require('./template.html')()) + , controller = { + $el: $el + } + , $balances = $el.find('.balances') + + function itemsChanged(items) { + $balances.html($.map(items, function(item) { + return itemTemplate(item) + })) + } + + function refresh() { + api.call('admin/balances') + .fail(errors.alertFromXhr) + .done(itemsChanged) + } + + refresh() + + $el.find('.nav a[href="#balances"]').parent().addClass('active') + + return controller +} diff --git a/admin/controllers/balances/item.html b/admin/controllers/balances/item.html new file mode 100644 index 00000000..ac2e9d84 --- /dev/null +++ b/admin/controllers/balances/item.html @@ -0,0 +1,5 @@ + + <%= currency %> + <%= type %> + <%= numbers.formatVolume(balance) %> + diff --git a/admin/controllers/balances/template.html b/admin/controllers/balances/template.html new file mode 100644 index 00000000..2d9efb75 --- /dev/null +++ b/admin/controllers/balances/template.html @@ -0,0 +1,16 @@ +
+ +
+

Admin: Balances

+ +
+ + + + + + + +
CurrencyTypeSum
+
+
diff --git a/admin/controllers/error/template.html b/admin/controllers/error/template.html new file mode 100644 index 00000000..c9544721 --- /dev/null +++ b/admin/controllers/error/template.html @@ -0,0 +1,15 @@ +
+
+
+

Something went wrong...

+ +

I'm not entirely sure how to handle this error, so you're just seeing this default page

+ +

+

<%= JSON.stringify(error, null, 4) %>
+

+ +

Maybe you can retry what you were doing or ask support for help

+
+
+
\ No newline at end of file diff --git a/admin/controllers/index.ejs b/admin/controllers/index.ejs new file mode 100644 index 00000000..7a210f0e --- /dev/null +++ b/admin/controllers/index.ejs @@ -0,0 +1,35 @@ + + + + Snow Admin + + + + + <% if (minify) { %> + + <% } else { %> + + <% } %> + + <% if (minify) { %> + + <% } else { %> + + <% } %> + + + + <% if (minify) { %> + + <% } else { %> + + <% } %> + + <% if (minify) { %> + + <% } else { %> + + <% } %> + + diff --git a/admin/controllers/index.styl b/admin/controllers/index.styl new file mode 100644 index 00000000..d4f16c66 --- /dev/null +++ b/admin/controllers/index.styl @@ -0,0 +1,33 @@ +@import "../node_modules/nib/lib/nib/vendor.styl" +@import "loading.styl" +@import "states.styl" +@import "square.styl" + +-animation() + animation arguments + -webkit-animation arguments + -moz-animation arguments + +body + position relative + padding-top 50px + padding-top: 60px + +@media (max-width: 979px) + body + padding-top 0px + +footer { + text-align: center; +} + +.sidebar-nav + padding: 9px 0; + +@import "top" +@import "shared" +@import "users" +@import "user" +@import "user" +@import "login" +@import "overview" diff --git a/admin/controllers/loading.styl b/admin/controllers/loading.styl new file mode 100644 index 00000000..756100ee --- /dev/null +++ b/admin/controllers/loading.styl @@ -0,0 +1,38 @@ +is-loading() { + background-image: -webkit-gradient(linear, 0 0, 100% 100%, + color-stop(.25, rgba(0, 0, 0, .10)), + color-stop(.25, transparent), + color-stop(.5, transparent), + color-stop(.5, rgba(0, 0, 0, .10)), + color-stop(.75, rgba(0, 0, 0, .10)), + color-stop(.75, transparent), + to(transparent)); + background-image: + -moz-linear-gradient(-45deg, + rgba(0, 0, 0, .10) 25%, + transparent 25%, + transparent 50%, rgba(0, 0, 0, .10) 50%, + rgba(0, 0, 0, .10) 75%, + transparent 75%, transparent + ); + background-size: 50px 50px; + -moz-background-size: 50px 50px; + -webkit-background-size: 50px 50px; + -webkit-animation: animate-stripes 2s linear infinite; +} + +@-webkit-keyframes animate-stripes { + from { + background-position: 0 0; + } + to { + background-position: -50px 0; + } +} + +.btn.is-loading + is-loading() + +.is-loading + input, select, button + pointer-events none diff --git a/admin/controllers/login/index.js b/admin/controllers/login/index.js new file mode 100644 index 00000000..b5ab0ec6 --- /dev/null +++ b/admin/controllers/login/index.js @@ -0,0 +1,138 @@ +var _ = require('lodash') +, debug = require('../../util/debug')('login') + +module.exports = function(after) { + var controller = { + $el: $(require('./template.html')()) + } + , $form = controller.$el.find('.login') + , $email = $form.find('.control-group.email') + , $password = $form.find('.control-group.password') + , $submit = $form.find('button') + , validatePasswordTimer + , validateEmailTimer + + $email.add($password) + .on('keyup', function(e) { + if (e.which == 13 || e.which == 9) return + + // Revert to the original hint + var group = $(this).closest('.control-group') + group.removeClass('error warning success is-valid') + .find('.help-inline') + .empty() + }) + + if (after) { + controller.$el.find('.new-user').attr('href', '#register?after=' + after) + } + + function validateEmail() { + var email = $email.find('input').val() + , expression = /^\S+@\S+$/ + , $hint = $email.find('.help-inline') + + var valid = !!email.match(expression) + + if (email.length === 0 || valid) { + $email.removeClass('error') + if (valid) $email.addClass('success') + $hint.empty() + } else { + $email.removeClass('success').addClass('error') + $hint.empty() + } + + $email.toggleClass('is-valid', valid) + + return valid + } + + function validatePassword() { + var password = $password.find('input').val() + , $hint = $password.find('.help-inline') + + var valid = password.length >= 6 + + if (password.length === 0 || valid) { + $password.removeClass('error') + if (valid) $password.addClass('success') + $hint.empty() + } else { + $password.removeClass('success').addClass('error') + } + + $password.toggleClass('is-valid', valid) + + return valid + } + + $email.on('change keyup blur', 'input', function(e) { + if (e.which == 9) return + validateEmailTimer && clearTimeout(validateEmailTimer) + validateEmailTimer = setTimeout(function() { + validateEmail() + }, 750) + }) + + $password.on('change keyup blur', 'input', function(e) { + if (e.which == 9) return + validatePasswordTimer && clearTimeout(validatePasswordTimer) + validatePasswordTimer = setTimeout(function() { + validatePassword() + }, 750) + }) + + $form.on('submit', function(e) { + e.preventDefault() + + validateEmail() + validatePassword() + + var fields = [$email, $password] + , invalid + + _.some(fields, function($e) { + if ($e.hasClass('is-valid')) return + $e.find('input').focus() + invalid = true + return true + }) + + if (invalid) return + + $submit.prop('disabled', true) + .addClass('is-loading') + .html('Logging in') + + debug('logging in') + + api.login( + $email.find('input').val(), + $password.find('input').val(), + $form.field('otp').val() + ) + .always(function() { + $submit.prop('disabled', false) + .removeClass('is-loading') + .html('Login') + }).done(function() { + debug('login success') + window.location.hash = '#' + (after || '') + }).fail(function(err) { + if (err !== null && err.name == 'UnknownApiKey') { + $email + .addClass('error') + .find('.help-inline') + .html('Wrong username or password') + return + } + + errors.alertFromXhr(err) + }) + }) + + $email.find('input').focusSoon() + + return controller +} diff --git a/admin/controllers/login/index.styl b/admin/controllers/login/index.styl new file mode 100644 index 00000000..1d9c9e7f --- /dev/null +++ b/admin/controllers/login/index.styl @@ -0,0 +1,42 @@ +.is-section-login + -webkit-background-size: cover; + -moz-background-size: cover; + -o-background-size: cover; + background-size: cover; + + > .top + display none + + footer, hr + display none + +.container.login + max-width: 550px + padding: 19px 0px 29px 100px + margin: 40px auto 20px + border: 1px solid #e5e5e5 + border-radius: 5px + box-shadow: 0 1px 2px rgba(0,0,0,.05) + background-color #fff + + .register-form + button + width 300px + + input + width 285px + + label + font-weight bold + + h3 + margin-bottom 20px + + .help-inline + max-width 220px + font-size 90% + margin-bottom 11px + visibility hidden + + .email .help-inline + visibility visible diff --git a/admin/controllers/login/template.html b/admin/controllers/login/template.html new file mode 100644 index 00000000..870e3ed8 --- /dev/null +++ b/admin/controllers/login/template.html @@ -0,0 +1,32 @@ +
+

Login

+ + +
diff --git a/admin/controllers/master/index.js b/admin/controllers/master/index.js new file mode 100644 index 00000000..7a7f8826 --- /dev/null +++ b/admin/controllers/master/index.js @@ -0,0 +1,52 @@ +var debug = require('../../util/debug')('snow:master') +, page +, template = require('./template.html') +, section +, $section +, header +, $top +, $nav + +var master = module.exports = function(val, name) { + if (!$section) { + throw new Error('master called before render') + } + + if (val !== undefined) { + if (page && page.destroy) { + page.destroy() + } + page = val + $section.html(page.$el) + master.section(name || null) + } + return page +} + +master.section = function(name) { + if (name !== undefined) { + $nav.find('li').removeClass('active') + name && $nav.find('.' + name).addClass('active') + + master.$el.removeClasses(/^is-section-/) + name && master.$el.addClass('is-section-' + name) + + section = name + } + return section +} + +master.render = function() { + debug('rendering') + + master.$el = $('body') + master.$el.prepend(template()) + + $section = $('#section') + header = require('../top')() + $top = $('.top') + $nav = $top.find('.nav') + master.$el.find('.top').replaceWith(header.$el) + + return master.$l +} diff --git a/admin/controllers/master/template.html b/admin/controllers/master/template.html new file mode 100644 index 00000000..0b44b63a --- /dev/null +++ b/admin/controllers/master/template.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/admin/controllers/notfound/index.js b/admin/controllers/notfound/index.js new file mode 100644 index 00000000..720fcbe2 --- /dev/null +++ b/admin/controllers/notfound/index.js @@ -0,0 +1,7 @@ +module.exports = function(hash) { + var controller = { + $el: $(require('./template.html')({ hash: hash })) + } + + return controller +} diff --git a/admin/controllers/notfound/template.html b/admin/controllers/notfound/template.html new file mode 100644 index 00000000..16c0e3c7 --- /dev/null +++ b/admin/controllers/notfound/template.html @@ -0,0 +1,13 @@ +
+
+
+

Not found

+ +

+

<%= hash %>
+

+ +

Page not found

+
+
+
diff --git a/admin/controllers/overview/index.js b/admin/controllers/overview/index.js new file mode 100644 index 00000000..452c61c6 --- /dev/null +++ b/admin/controllers/overview/index.js @@ -0,0 +1,44 @@ +var template = require('./template.html') +, recentUserTemplate = require('./recent-user.html') + +module.exports = function() { + var $el = $('
').html(template()) + , controller = { + $el: $el + } + + function refreshBtcHeight() { + api.call('admin/btc/height') + .fail(errors.alertFromXhr) + .done(function(res) { + $el.find('.btc-height').html(res.height) + }) + } + + function refreshLtcHeight() { + api.call('admin/ltc/height') + .fail(errors.alertFromXhr) + .done(function(res) { + $el.find('.ltc-height').html(res.height) + }) + } + + function refreshRecentUsers() { + var recentCookie = $.cookie('recent-users') + , recent = recentCookie ? JSON.parse(recentCookie) : [] + , $recent = $el.find('.recent-users') + + $recent + .toggleClass('is-empty', !recent.length) + .find('tbody') + .html($.map(recent, function(user) { + return recentUserTemplate(user) + })) + } + + refreshBtcHeight() + refreshLtcHeight() + refreshRecentUsers() + + return controller +} diff --git a/admin/controllers/overview/index.styl b/admin/controllers/overview/index.styl new file mode 100644 index 00000000..f077ab3d --- /dev/null +++ b/admin/controllers/overview/index.styl @@ -0,0 +1,3 @@ +#section > .overview + .recent-users + states empty diff --git a/admin/controllers/overview/recent-user.html b/admin/controllers/overview/recent-user.html new file mode 100644 index 00000000..d4fb5a0f --- /dev/null +++ b/admin/controllers/overview/recent-user.html @@ -0,0 +1,11 @@ + + + <%- user_id %> + + + <%- first_name ? first_name + ' ' + last_name : '' %> + + + <%- email %> + + diff --git a/admin/controllers/overview/template.html b/admin/controllers/overview/template.html new file mode 100644 index 00000000..8a332697 --- /dev/null +++ b/admin/controllers/overview/template.html @@ -0,0 +1,38 @@ +
+ +
+
+
+

Network status

+ + + + + + + + + + +
Bitcoin height
Litecoin height
+
+ +
+
+

Recently viewed users

+ + + + + + + + + + + +
IDNameEmail
+
+
+
+
diff --git a/admin/controllers/shared/amount-input/index.html b/admin/controllers/shared/amount-input/index.html new file mode 100644 index 00000000..bfe6fa4b --- /dev/null +++ b/admin/controllers/shared/amount-input/index.html @@ -0,0 +1,30 @@ +
+ + +
+
+ + + <% if (fixedCurrency) { %> + <%= currency %> + <% } else { %> + + <% } %> +
+ + + + Insufficient funds + + + Precision + + +
+
diff --git a/admin/controllers/shared/amount-input/index.js b/admin/controllers/shared/amount-input/index.js new file mode 100644 index 00000000..04e1407d --- /dev/null +++ b/admin/controllers/shared/amount-input/index.js @@ -0,0 +1,126 @@ +var template = require('./index.html') +, _ = require('lodash') +, num = require('num') +, debug = require('debug')('snow:amount-input') + +module.exports = function(opts) { + opts = _.extend({ + fixedCurrency: false, + currency: null, + currencies: _.pluck(api.currencies.value, 'id'), + minExclusive: 0, + maxExclusive: null, + minInclusive: null, + maxInclusive: null, + maxPrecision: null, + value: '' + }, opts) + + var $el = $('
').html(template(opts)) + , controller = { + $el: $el, + opts: opts + } + , $amount = $el.find('.amount') + , $amountField = $el.field('amount') + + $el.toggleClass('is-fixed-currency') + + controller.validate = function(emptyIsError) { + var val = $amountField.val() + , empty = !val.length + $amount.toggleClass('is-empty', empty) + + if (empty) { + debug('amount is empty') + $amount.toggleClass('error is-invalid', !!emptyIsError) + return + } + + var valn = $amountField.parseNumber() + + if (valn === null) { + $amount.addClass('error is-invalid') + return + } + + valn = num(valn) + + debug('value parsed as %s', valn.toString()) + + var currency = opts.fixedCurrency ? opts.currency : $el.field('currency').val() + + var maxPrecision = opts.maxPrecision !== null ? + opts.maxPrecision : + _.find(api.currencies.value, { id: currency }).scale + + if (valn.get_precision() > maxPrecision) { + debug('precision %s is higher than max, %s', valn.get_precision(), + maxPrecision) + + $amount.addClass('error is-invalid') + return + } + + if (opts.minInclusive !== null && valn.lt(opts.minInclusive)) { + $amount.addClass('error is-invalid') + debug('lt min inclusive %s', opts.minInclusive) + return + } + + if (opts.maxInclusive !== null && valn.gt(opts.maxInclusive)) { + $amount.addClass('error is-invalid') + debug('gt max inclusive %s', opts.maxInclusive) + return + } + + if (opts.minExclusive !== null && valn.lte(opts.minExclusive)) { + $amount.addClass('error is-invalid') + debug('lte min exclusive %s', opts.minExclusive) + return + } + + if (opts.maxExclusive !== null && valn.gte(opts.maxExclusive)) { + $amount.addClass('error is-invalid') + debug('gte max exclusive %s', opts.maxExclusive) + return + } + + var balanceItem = _.find(api.balances.current, { currency: currency}) + , avail = balanceItem.available + + if (valn.gt(avail)) { + $amount.addClass('error is-invalid') + debug('gt available %s', avail) + return + } + + $amount.removeClass('error is-invalid') + + return true + } + + $amountField.on('keyup change', function(e) { + if (e.which == 13) return + controller.validate() + }) + + $el.on('click', '[data-action="all"]', function(e) { + e.preventDefault() + var currency = opts.fixedCurrency ? opts.currency : $el.field('currency').val() + , balanceItem = _.find(api.balances.current, { currency: currency}) + , avail = balanceItem.available + $amountField.val(numbers.format(avail)) + controller.validate() + }) + + if (!opts.fixedCurrency) { + $el.field('currency').html($.map(opts.currencies, function(id) { + return $('') + })) + } + + controller.validate() + + return controller +} diff --git a/admin/controllers/shared/amount-input/index.styl b/admin/controllers/shared/amount-input/index.styl new file mode 100644 index 00000000..095b8f31 --- /dev/null +++ b/admin/controllers/shared/amount-input/index.styl @@ -0,0 +1,12 @@ +.amount-input + states invalid empty + + .field[name="currency"] + max-width 5em + + field[name="amount"] + width 12.5 + + &.is-fixed-currency + field[name="amount"] + width 10em diff --git a/admin/controllers/shared/index.styl b/admin/controllers/shared/index.styl new file mode 100644 index 00000000..bcc207fc --- /dev/null +++ b/admin/controllers/shared/index.styl @@ -0,0 +1,2 @@ +@import "amount-input" +@import "withdraws" diff --git a/admin/controllers/shared/withdraws/confirm/index.html b/admin/controllers/shared/withdraws/confirm/index.html new file mode 100644 index 00000000..bb854a53 --- /dev/null +++ b/admin/controllers/shared/withdraws/confirm/index.html @@ -0,0 +1,35 @@ + diff --git a/admin/controllers/shared/withdraws/confirm/index.js b/admin/controllers/shared/withdraws/confirm/index.js new file mode 100644 index 00000000..0d20f392 --- /dev/null +++ b/admin/controllers/shared/withdraws/confirm/index.js @@ -0,0 +1,56 @@ +var template = require('./index.html') +, num = require('num') + +module.exports = function(wr) { + var $el = $('
') + .html(template({ + net: num(wr.amount).sub(10).toString(), + fee: '10' + })) + , controller = { + $el: $el + } + , $modal = $el.find('.modal').modal() + , $net = $el.field('net') + , $fee = $el.field('fee') + , $total = $el.field('total') + + function recalculate() { + var net = num($net.val()) + , fee = num($fee.val()) + , total = net.add(fee) + $total.val(total.toString()) + + var valid = total.eq(wr.amount) + + $el.find('.total').toggleClass('error', !valid) + $el.find('button[type="submit"]').enabled(valid) + } + + $fee.add($total).add($total).on('change keyup', function() { + recalculate() + }) + + recalculate() + + $modal.modal() + + $el.on('submit', 'form', function(e) { + e.preventDefault() + recalculate() + + if ($el.find('.error').length) return + + var url = 'admin/withdraws/' + wr.id + '/complete' + + api.call(url, { fee: $fee.val() }, { type: 'POST' }) + .done(function() { + $modal.modal('hide') + }) + .fail(function(xhr) { + errors.alertFromXhr(xhr) + }) + }) + + return controller +} diff --git a/admin/controllers/shared/withdraws/index.html b/admin/controllers/shared/withdraws/index.html new file mode 100644 index 00000000..45291e86 --- /dev/null +++ b/admin/controllers/shared/withdraws/index.html @@ -0,0 +1,16 @@ +

There are no withdraw requests.

+ + + + + + + + + + + + + + +
#DateMethodStateUserAmountDestination
diff --git a/admin/controllers/shared/withdraws/index.js b/admin/controllers/shared/withdraws/index.js new file mode 100644 index 00000000..e349a355 --- /dev/null +++ b/admin/controllers/shared/withdraws/index.js @@ -0,0 +1,101 @@ +var template = require('./index.html') +, itemTemplate = require('./item.html') +, _ = require('lodash') + +module.exports = function(opts) { + var $el = $('
').html(template()) + , controller = { + $el: $el + } + , $items = controller.$el.find('tbody') + , lastItems + + function itemsChanged(items) { + $items.html($.map(items, function(item) { + var $item = $(itemTemplate(item)) + $item.addClass('is-' + item.state) + .attr('data-id', item.id) + + return $item + })) + + lastItems = items + } + + function refresh() { + api.call('admin/withdraws', null, { qs: opts }) + .fail(errors.alertFromXhr) + .done(itemsChanged) + } + + $el.on('click', '.cancel', function() { + var id = $(this).closest('.withdraw').attr('data-id') + , $el = $(this) + , message = 'Why is the request being cancelled? The user will see this.' + + alertify.prompt(message, function(ok, error) { + if (!ok) return + + $el.addClass('is-loading') + .enabled(false) + .siblings().enabled(false) + + var url = 'admin/withdraws/' + id + , data = { state: 'cancelled', error: error || null } + + api.call(url, data, { type: 'PATCH' }) + .done(function() { + $el.closest('.withdraw').fadeAway() + }) + .fail(function(xhr) { + errors.alertFromXhr(xhr) + refresh() + }) + }) + }) + + $el.on('click', '.process', function() { + var id = $(this).closest('.withdraw').attr('data-id') + , $el = $(this) + + $el.addClass('is-loading') + .enabled(false) + .siblings().enabled(false) + + + api.call('admin/withdraws/' + id, { state: 'processing' }, { type: 'PATCH' }) + .done(function() { + refresh() + }) + .fail(function(xhr) { + errors.alertFromXhr(xhr) + refresh() + }) + }) + + $el.on('click', '.complete', function(e) { + e.preventDefault() + var id = +$(this).closest('.withdraw').attr('data-id') + + console.log('???', id) + + var item = _.find(lastItems, { id: id }) + + console.log('???', lastItems) + console.log('???', item) + + var modal = require('./confirm')(item) + + $('body').append(modal.$el) + + modal.$el.on('hidden', '.modal', function() { + router.reload() + }) + }) + + controller.init = function() { + refresh() + } + + return controller +} diff --git a/admin/controllers/shared/withdraws/index.styl b/admin/controllers/shared/withdraws/index.styl new file mode 100644 index 00000000..b2931cd0 --- /dev/null +++ b/admin/controllers/shared/withdraws/index.styl @@ -0,0 +1,5 @@ +.withdraws + states empty, single-user + + .withdraw + states requested, processing, completed, cancelled diff --git a/admin/controllers/shared/withdraws/item.html b/admin/controllers/shared/withdraws/item.html new file mode 100644 index 00000000..e6ba296a --- /dev/null +++ b/admin/controllers/shared/withdraws/item.html @@ -0,0 +1,16 @@ + + <%= id %> + <%= moment(created).format('YYYY-MM-DD HH:mm') %> + <%= method %> + <%= state %> + + <%= user %> + <%= amount %> <%= currency %> + + <%= destination %> + + + + + + diff --git a/admin/controllers/square.styl b/admin/controllers/square.styl new file mode 100644 index 00000000..c6494d50 --- /dev/null +++ b/admin/controllers/square.styl @@ -0,0 +1,9 @@ +.well + border-radius 0px + +.btn + border-radius 2px + background-image none + +input[type="text"], select + border-radius 2px diff --git a/admin/controllers/states.styl b/admin/controllers/states.styl new file mode 100644 index 00000000..79592eba --- /dev/null +++ b/admin/controllers/states.styl @@ -0,0 +1,20 @@ +states(states...) + for state in states + .visible-{state} + display none + + .is-{state}, + .has-{state}, + &.is-{state}, + &.has-{state} + .visible-{state} + display block + + tr.visible-{state} + display table-row + + .btn.visible-{state} + display inline-block + + .hidden-{state} + display none diff --git a/admin/controllers/top/index.js b/admin/controllers/top/index.js new file mode 100644 index 00000000..92a3a9c3 --- /dev/null +++ b/admin/controllers/top/index.js @@ -0,0 +1,21 @@ +module.exports = function() { + var $el = $(require('./template.html')()) + , controller = { + $el: $el + } + , $summary = controller.$el.find('.account-summary') + + api.on('user', function(user) { + $summary.find('.email').html(user.email) + + api.call('admin/withdraws?activeOnly=1') + .done(function(withdraws) { + $el.find('.active-withdraw-count').html(withdraws.length) + }) + }) + + controller.destroy = function() { + } + + return controller +} diff --git a/admin/controllers/top/index.styl b/admin/controllers/top/index.styl new file mode 100644 index 00000000..be66beb9 --- /dev/null +++ b/admin/controllers/top/index.styl @@ -0,0 +1,32 @@ +body > .top + .logged-in + display none + + .balances-wrap + position relative + + .balances + position absolute + top 50px + left -23px + font-size 11px + + td + border solid 1px #eee + + .currency + width 40px + + .available + text-align right + width 95px + +body.is-logged-in > .top + .not-logged-in + display none + + .logged-in + display block + + .balances, .orders, .activities, .dashboard + display block diff --git a/admin/controllers/top/template.html b/admin/controllers/top/template.html new file mode 100644 index 00000000..04cde782 --- /dev/null +++ b/admin/controllers/top/template.html @@ -0,0 +1,45 @@ + diff --git a/admin/controllers/transactions/index.html b/admin/controllers/transactions/index.html new file mode 100644 index 00000000..e795fdc7 --- /dev/null +++ b/admin/controllers/transactions/index.html @@ -0,0 +1,34 @@ +
+
+
+ + +

Search for transactions

+ +
+
+ + +
+ + +
+ + + + + + + + + + + + + + + +
#DateTypeAmountDebitCredit
+
+
+
diff --git a/admin/controllers/transactions/index.js b/admin/controllers/transactions/index.js new file mode 100644 index 00000000..5e997df9 --- /dev/null +++ b/admin/controllers/transactions/index.js @@ -0,0 +1,65 @@ +var template = require('./index.html') + +module.exports = function(userId) { + var itemTemplate = require('./item.html') + , $el = $('
').html(template()) + , controller = { + $el: $el + } + , $items = $el.find('.transactions') + , $form = $el.find('.search-form') + + function itemsChanged(query, items) { + $items.find('tbody').html($.map(items.transactions, function(item) { + item.raw = JSON.stringify(item) + item.query = query + var $item = $(itemTemplate(item)) + $item.attr('data-id', item.id) + return $item + })) + } + + function refresh(query) { + $form.addClass('is-loading') + + api.call('admin/transactions', query) + .fail(errors.alertFromXhr) + .always(function() { + $form.removeClass('is-loading') + }) + .done(itemsChanged.bind(this, query)) + } + + function search() { + var q = { + sort: { + timestamp: 'desc' + } + } + + var userId = $el.field('userId').val() + + if (userId) { + q.userId = +userId + } + + refresh(q) + } + + $form.on('submit', function(e) { + e.preventDefault() + search() + }) + + if (userId) { + $el.field('userId').val(userId) + } + + search() + + $el.find('.nav a[href="#transactions"]').parent().addClass('active') + + $el.find('.query').focusSoon() + + return controller +} diff --git a/admin/controllers/transactions/index.styl b/admin/controllers/transactions/index.styl new file mode 100644 index 00000000..018329e8 --- /dev/null +++ b/admin/controllers/transactions/index.styl @@ -0,0 +1,21 @@ +.admin-users + .users-table + width auto + + .user-id-header, .user-id + width 80px + .name-header, .name + width 175px + .email-header, .email + width 250px + .country-header, .country + width 105px + .actions-header, .actions + width 200px + + .search-form + .search + + &.is-loading + .search + is-loading() diff --git a/admin/controllers/transactions/item.html b/admin/controllers/transactions/item.html new file mode 100644 index 00000000..dd3effb6 --- /dev/null +++ b/admin/controllers/transactions/item.html @@ -0,0 +1,27 @@ + + <%- id %> + <%- moment(timestamp * 1e3).format('YYYY-MM-DD HH:mm') %> + <%- type %> + + <%- numbers.format(amount, { currency: currency }) %> + <% if (currency != 'BTC') { %> + (<%- numbers.format(amountBtc, { currency: 'BTC' }) %>) + <% } %> + + + <% if (debitUserId) { %> + (#<%- debitUserId %>, <%- debitUserName || debitUserEmail %>) + <% } else { %> + <%- debitAccountType %> + <% } %> + + + <% if (creditUserId) { %> + (#<%- creditUserId %>, <%- creditUserName || creditUserEmail %>) + <% } else { %> + <%- creditAccountType %> + <% } %> + + + + diff --git a/admin/controllers/user/accounts/index.js b/admin/controllers/user/accounts/index.js new file mode 100644 index 00000000..b0619ffb --- /dev/null +++ b/admin/controllers/user/accounts/index.js @@ -0,0 +1,26 @@ +var header = require('../header') + +module.exports = function(userId) { + var itemTemplate = require('./item.html') + , $el = $(require('./template.html')()) + , controller = { + $el: $el + } + , $items = controller.$el.find('.accounts') + + // Insert header + $el.find('.header-placeholder').replaceWith(header(userId, 'accounts').$el) + + function itemsChanged(items) { + $items.html($.map(items, function(item) { + return itemTemplate(item) + })) + } + + function refresh() { + api.call('admin/users/' + userId + '/accounts').done(itemsChanged) + } + refresh() + + return controller +} diff --git a/admin/controllers/user/accounts/item.html b/admin/controllers/user/accounts/item.html new file mode 100644 index 00000000..827d058d --- /dev/null +++ b/admin/controllers/user/accounts/item.html @@ -0,0 +1,20 @@ + + + <%= account_id %> + + + <%= currency %> + + + <%= type %> + + + <%= balance %> + + + <%= hold %> + + + <%= available %> + + diff --git a/admin/controllers/user/accounts/template.html b/admin/controllers/user/accounts/template.html new file mode 100644 index 00000000..7e11c026 --- /dev/null +++ b/admin/controllers/user/accounts/template.html @@ -0,0 +1,21 @@ +
+
+ +
+
+ + + + + + + + + + + + +
CurrencyTypeBalanceHeldAvailable
+
+
+
diff --git a/admin/controllers/user/activity/index.js b/admin/controllers/user/activity/index.js new file mode 100644 index 00000000..657f8360 --- /dev/null +++ b/admin/controllers/user/activity/index.js @@ -0,0 +1,31 @@ +var header = require('../header') +, formatActivity = require('../../../util/activity') + +module.exports = function(userId) { + var itemTemplate = require('./item.html') + , $el = $(require('./template.html')()) + , controller = { + $el: $el + } + , $items = controller.$el.find('.activities') + + // Insert header + $el.find('.header-placeholder').replaceWith(header(userId, 'activity').$el) + + function itemsChanged(items) { + $items.html($.map(items, function(item) { + item.text = formatActivity(item) + return itemTemplate(item) + })) + } + + function refresh() { + api.call('admin/users/' + userId + '/activity') + .fail(errors.alertFromXhr) + .done(itemsChanged) + } + + refresh() + + return controller +} diff --git a/admin/controllers/user/activity/item.html b/admin/controllers/user/activity/item.html new file mode 100644 index 00000000..68b32fa6 --- /dev/null +++ b/admin/controllers/user/activity/item.html @@ -0,0 +1,8 @@ + + + <%= moment(created_at).format('YYYY-MM-DD HH:mm') %> + + + <%= text %> + + diff --git a/admin/controllers/user/activity/template.html b/admin/controllers/user/activity/template.html new file mode 100644 index 00000000..e54821f1 --- /dev/null +++ b/admin/controllers/user/activity/template.html @@ -0,0 +1,11 @@ +
+
+ +
+
+ + +
+
+
+
diff --git a/admin/controllers/user/bankaccounts/addaccount/index.js b/admin/controllers/user/bankaccounts/addaccount/index.js new file mode 100644 index 00000000..b7e4665d --- /dev/null +++ b/admin/controllers/user/bankaccounts/addaccount/index.js @@ -0,0 +1,38 @@ +module.exports = function(userId) { + var $el = $(require('./template.html')()) + , controller = { + $el: $el + } + , $form = $el.find('.bank-account-form') + + $form.find('.account-number input').focusSoon() + + $form.on('submit', function(e) { + e.preventDefault() + + var data = { + account_number: $form.find('.account-number input').val() || null, + iban: $form.find('.iban input').val() || null, + swiftbic: $form.find('.swiftbic input').val() || null, + routing_number: $form.find('.routing-number input').val() || null + } + + var $button = $form.find('.add-button') + $button.addClass('is-loading').enabled(false) + + var url = 'admin/users/' + userId + '/bankAccounts' + api.call(url, data, { type: 'POST' }) + .always(function() { + $button.addClass('is-loading').enabled(false) + }) + .fail(errors.alertFromXhr) + .done(function() { + $el.trigger('added') + $el.modal('hide').remove() + }) + }) + + $el.modal() + + return controller +} diff --git a/admin/controllers/user/bankaccounts/addaccount/template.html b/admin/controllers/user/bankaccounts/addaccount/template.html new file mode 100644 index 00000000..e1bdfea1 --- /dev/null +++ b/admin/controllers/user/bankaccounts/addaccount/template.html @@ -0,0 +1,42 @@ + diff --git a/admin/controllers/user/bankaccounts/index.js b/admin/controllers/user/bankaccounts/index.js new file mode 100644 index 00000000..8ec2e94c --- /dev/null +++ b/admin/controllers/user/bankaccounts/index.js @@ -0,0 +1,54 @@ +var util = require('util') +, format = util.format +, header = require('../header') +, addAccount = require('./addaccount') + +module.exports = function(userId) { + var itemTemplate = require('./item.html') + , $el = $(require('./template.html')()) + , controller = { + $el: $el + } + , $items = controller.$el.find('.accounts') + + // Insert header + $el.find('.header-placeholder').replaceWith(header(userId, 'bank-accounts').$el) + + function itemsChanged(items) { + $items.html($.map(items, function(item) { + return itemTemplate(item) + })) + } + + function refresh() { + api.call('admin/users/' + userId + '/bankAccounts') + .done(itemsChanged) + } + + $items.on('click', '[data-action="delete"]', function(e) { + e.preventDefault() + var id = $(this).closest('tr').attr('data-id') + $(this).enabled(false).loading(true, 'Deleting...') + var url = format('admin/users/%s/bankAccounts/%s', userId, id) + + api.call(url, null, { type: 'DELETE' }) + .fail(errors.alertFromXhr) + .done(function() { + refresh() + }) + }) + + $el.on('click', '*[data-action="add"]', function(e) { + e.preventDefault() + + var modal = addAccount(userId) + + modal.$el.one('added', function() { + refresh() + }) + }) + + refresh() + + return controller +} diff --git a/admin/controllers/user/bankaccounts/item.html b/admin/controllers/user/bankaccounts/item.html new file mode 100644 index 00000000..c25bfa9d --- /dev/null +++ b/admin/controllers/user/bankaccounts/item.html @@ -0,0 +1,17 @@ + + + <%- account_number %> + + + <%- iban %> + + + <%- swiftbic %> + + + <%- routing_number %> + + + + + diff --git a/admin/controllers/user/bankaccounts/template.html b/admin/controllers/user/bankaccounts/template.html new file mode 100644 index 00000000..55f2fd90 --- /dev/null +++ b/admin/controllers/user/bankaccounts/template.html @@ -0,0 +1,24 @@ +
+
+ +
+ +
+ +
+
+ + + + + + + + + + + +
IBANSWIFT/BICRouting #
+
+
+
diff --git a/admin/controllers/user/bankcredit/index.js b/admin/controllers/user/bankcredit/index.js new file mode 100644 index 00000000..4f333a9c --- /dev/null +++ b/admin/controllers/user/bankcredit/index.js @@ -0,0 +1,45 @@ +var header = require('../header') +, template = require('./template.html') + +module.exports = function(userId) { + var $el = $('
').html(template()) + , controller = { + $el: $el + } + + // Navigation partial + $el.find('.nav-container').replaceWith(header(userId, 'bank-credit').$el) + + $el.on('submit', 'form', function(e) { + e.preventDefault() + + var $el = $(this) + + var body = { + user_id: userId, + amount: $el.find('.amount input').val(), + reference: $el.find('.reference input').val(), + currency_id: $el.find('.currency input').val() + } + + if (!body.amount) return alert('amount id not set') + if (!body.currency_id) return alert('currency_id not set') + if (!body.reference) return alert('reference not set') + + $el.addClass('is-loading').enabled(false) + + api.call('admin/bankCredit', body, { type: 'POST' }) + .always(function() { + $el.removeClass('is-loading').enabled(true) + }) + .fail(errors.alertFromXhr) + .done(function() { + $el.find('input').val('') + $el.find('.user input').focusSoon() + }) + }) + + $el.find('.nav a[href="#admin/credit"]').parent().addClass('active') + + return controller +} diff --git a/admin/controllers/user/bankcredit/template.html b/admin/controllers/user/bankcredit/template.html new file mode 100644 index 00000000..82519959 --- /dev/null +++ b/admin/controllers/user/bankcredit/template.html @@ -0,0 +1,39 @@ + + +
+

Bank credit

+ +
+
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+
+ +
+
+
+
+
diff --git a/admin/controllers/user/header/index.js b/admin/controllers/user/header/index.js new file mode 100644 index 00000000..3f0e8381 --- /dev/null +++ b/admin/controllers/user/header/index.js @@ -0,0 +1,16 @@ +var template = require('./template.html') + +module.exports = function(userId, tab) { + var $el = $('
').html(template({ + userId: userId + })) + , controller = { + $el: $el + } + + if (tab) { + $el.find('.nav .' + tab).addClass('active') + } + + return controller +} diff --git a/admin/controllers/user/header/template.html b/admin/controllers/user/header/template.html new file mode 100644 index 00000000..0cbcc247 --- /dev/null +++ b/admin/controllers/user/header/template.html @@ -0,0 +1,32 @@ +
+
+
+ +
+
+
diff --git a/admin/controllers/user/index.js b/admin/controllers/user/index.js new file mode 100644 index 00000000..6d127330 --- /dev/null +++ b/admin/controllers/user/index.js @@ -0,0 +1,181 @@ +var header = require('./header') +, template = require('./template.html') +, model = require('../../util/model') +, _ = require('lodash') +, moment = require('moment') + +module.exports = function(userId) { + var $el = $('