]> git.immae.eu Git - perso/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front.git/commitdiff
Poloniex connection.
authorjloup <jeanloup.jamet@gmail.com>
Thu, 22 Feb 2018 10:31:59 +0000 (11:31 +0100)
committerjloup <jeanloup.jamet@gmail.com>
Thu, 22 Feb 2018 10:31:59 +0000 (11:31 +0100)
22 files changed:
Gopkg.lock
Gopkg.toml
api/const.go
api/const_string.go
api/external_services.go [new file with mode: 0644]
api/market_config.go
api/markets.go [new file with mode: 0644]
api/routes.go
cmd/web/Makefile
cmd/web/js/api.js
cmd/web/js/main.jsx
cmd/web/js/otp.jsx
cmd/web/js/poloniex.jsx
cmd/web/js/signin.jsx
cmd/web/js/signup.jsx
cmd/web/static/cryptocoins.css [new file with mode: 0644]
cmd/web/static/cryptocoins.ttf [new file with mode: 0644]
cmd/web/static/cryptocoins.woff [new file with mode: 0644]
cmd/web/static/cryptocoins.woff2 [new file with mode: 0644]
cmd/web/static/index.html
cmd/web/static/style.css
markets/poloniex.go [new file with mode: 0644]

index 7f0f166efdfe3a28437c119a1cc5ba28a54969e0..88c2eda93a1a0c4b94bd9ad5288836d8023c368e 100644 (file)
   packages = ["."]
   revision = "1c35d901db3da928c72a72d8458480cc9ade058f"
 
+[[projects]]
+  branch = "master"
+  name = "github.com/jloup/poloniex"
+  packages = ["."]
+  revision = "e75e6fd7991c1d71576ad97de73fc922f24a5fd2"
+
 [[projects]]
   branch = "master"
   name = "github.com/jloup/utils"
   revision = "b7b89250c468c06871d3837bee02e2d5c155ae19"
   version = "v1.0.0"
 
+[[projects]]
+  branch = "master"
+  name = "github.com/shopspring/decimal"
+  packages = ["."]
+  revision = "e3482495ff4cba75613e4177ed79825c890058a9"
+
 [[projects]]
   name = "github.com/ugorji/go"
   packages = ["codec"]
     "blowfish",
     "ssh/terminal"
   ]
-  revision = "650f4a345ab4e5b245a3034b110ebc7299e68186"
+  revision = "432090b8f568c018896cd8a0fb0345872bbac6ce"
+
+[[projects]]
+  branch = "master"
+  name = "golang.org/x/net"
+  packages = ["websocket"]
+  revision = "cbe0f9307d0156177f9dd5dc85da1a31abc5f2fb"
 
 [[projects]]
   branch = "master"
 [solve-meta]
   analyzer-name = "dep"
   analyzer-version = 1
-  inputs-digest = "c9af022a586632799c6259f6c48eef8dad7080e36b96e8cb5cb905b316c4cb9b"
+  inputs-digest = "d3c9b3094ed174bcf1631e3a998a75d557c65c195d7a8fd5ca9912f71f334ce1"
   solver-name = "gps-cdcl"
   solver-version = 1
index f4686b5eae63b5146c819ea70ce84f74be8a403a..4feee4d8d35575ed61cbe41fa961058705f0db5a 100644 (file)
 [prune]
   go-tests = true
   unused-packages = true
+
+[[constraint]]
+  branch = "master"
+  name = "github.com/jloup/poloniex"
+
+[[constraint]]
+  branch = "master"
+  name = "github.com/shopspring/decimal"
index 2edd6f4b941f2defe30b4b6a7f87efa1b69aff7f..1b22355d23d213e01e11d2db9ccb663ee73fd5d4 100644 (file)
@@ -6,15 +6,19 @@ import "net/http"
 type Status uint32
 type ErrorCode uint32
 
+const EXTERNAL_SERVICE_TIMEOUT_SECONDS = 10
+
 const (
        OK Status = iota
        NOK
 
        BadRequest ErrorCode = iota + 1
        EmailExists
+       ExternalServiceTimeout
        InternalError
        InvalidCredentials
        InvalidEmail
+       InvalidMarketCredentials
        InvalidOtp
        InvalidPassword
        NeedOtpValidation
@@ -31,7 +35,7 @@ func StatusToHttpCode(status Status, code ErrorCode) int {
        }
 
        switch code {
-       case BadRequest, InvalidPassword, InvalidEmail:
+       case BadRequest, InvalidPassword, InvalidEmail, InvalidMarketCredentials:
                return http.StatusBadRequest
 
        case InvalidCredentials, InvalidOtp:
@@ -45,6 +49,9 @@ func StatusToHttpCode(status Status, code ErrorCode) int {
 
        case NotFound:
                return http.StatusNotFound
+
+       case ExternalServiceTimeout:
+               return http.StatusGatewayTimeout
        }
 
        return http.StatusInternalServerError
index 611db406f07731df3d83b64b86eae920a2f242d0..e4b9e507421d386728072c2ff9e84e3b9390d009 100644 (file)
@@ -15,9 +15,9 @@ func (i Status) String() string {
        return _Status_name[_Status_index[i]:_Status_index[i+1]]
 }
 
-const _ErrorCode_name = "BadRequestEmailExistsInternalErrorInvalidCredentialsInvalidEmailInvalidOtpInvalidPasswordNeedOtpValidationNotAuthorizedNotFoundOtpAlreadySetupOtpNotSetupUserNotConfirmed"
+const _ErrorCode_name = "BadRequestEmailExistsExternalServiceTimeoutInternalErrorInvalidCredentialsInvalidEmailInvalidMarketCredentialsInvalidOtpInvalidPasswordNeedOtpValidationNotAuthorizedNotFoundOtpAlreadySetupOtpNotSetupUserNotConfirmed"
 
-var _ErrorCode_index = [...]uint8{0, 10, 21, 34, 52, 64, 74, 89, 106, 119, 127, 142, 153, 169}
+var _ErrorCode_index = [...]uint8{0, 10, 21, 43, 56, 74, 86, 110, 120, 135, 152, 165, 173, 188, 199, 215}
 
 func (i ErrorCode) String() string {
        i -= 3
diff --git a/api/external_services.go b/api/external_services.go
new file mode 100644 (file)
index 0000000..c467171
--- /dev/null
@@ -0,0 +1,40 @@
+package api
+
+import (
+       "context"
+       "fmt"
+       "time"
+)
+
+// Use this to call external services. It will handle timeout and request cancellation gracefully.
+func CallExternalService(tag string, timeout time.Duration, routine func() *Error) *Error {
+       routineDone := make(chan *Error)
+
+       go func() {
+               routineDone <- routine()
+       }()
+
+       ctx, cancel := context.WithTimeout(context.Background(), timeout)
+       defer cancel()
+
+       select {
+       case err := <-routineDone:
+               return err
+       case <-ctx.Done():
+               return &Error{ExternalServiceTimeout, "external service timeout", fmt.Errorf("'%v' routine timeouted", tag)}
+       }
+}
+
+var ErrorChan chan error
+
+func ErrorMonitoring() {
+       for {
+               err := <-ErrorChan
+               log.Errorf("error: %v", err)
+       }
+}
+
+func init() {
+       ErrorChan = make(chan error)
+       go ErrorMonitoring()
+}
index 3fd10ae000d19fba89f6e86b4911a1dde21bf294..d85af4de9e3c5f3a7e6f18c65d162ce130aa1d1e 100644 (file)
@@ -2,7 +2,13 @@ package api
 
 import (
        "fmt"
+       "strings"
+       "time"
 
+       "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/markets"
+
+       "github.com/jloup/utils"
+       "github.com/shopspring/decimal"
        "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/db"
 )
 
@@ -42,6 +48,73 @@ func (q MarketConfigQuery) Run() (interface{}, *Error) {
        return config.Config, nil
 }
 
+type MarketBalanceQuery struct {
+       In struct {
+               User     db.User
+               Market   string
+               Currency string
+       }
+}
+
+func (q MarketBalanceQuery) ValidateParams() *Error {
+       if q.In.Market != "poloniex" {
+               return &Error{BadRequest, "invalid market name", fmt.Errorf("'%v' is not a valid market name", q.In.Market)}
+       }
+
+       // TODO: we should request market for available currencies.
+       if q.In.Currency != "BTC" && q.In.Currency != "USDT" && q.In.Currency != "ETH" {
+               return &Error{BadRequest, "invalid currency, accept [BTC, USDT, ETH]", fmt.Errorf("'%v' is not a valid currency", q.In.Currency)}
+       }
+
+       return nil
+}
+
+func (q MarketBalanceQuery) Run() (interface{}, *Error) {
+       config, err := db.GetUserMarketConfig(q.In.User.Id, q.In.Market)
+       if err != nil {
+               return nil, NewInternalError(err)
+       }
+
+       if config.Config["key"] == "" || config.Config["secret"] == "" {
+               return nil, &Error{BadRequest, "your credentials for this market are not setup", fmt.Errorf("'%v' credentials are not setup", q.In.Market)}
+       }
+
+       result := struct {
+               Value         decimal.Decimal            `json:"value"`
+               ValueCurrency string                     `json:"valueCurrency"`
+               Balance       map[string]decimal.Decimal `json:"balance"`
+       }{}
+
+       resultErr := CallExternalService(fmt.Sprintf("'%s' GetBalanceValue", q.In.Market), EXTERNAL_SERVICE_TIMEOUT_SECONDS*time.Second, func() *Error {
+               balance, err := Poloniex.GetBalance(config.Config["key"], config.Config["secret"])
+
+               if utils.ErrIs(err, markets.InvalidCredentials) {
+                       return &Error{InvalidMarketCredentials, "wrong market credentials", fmt.Errorf("wrong '%v' market credentials", q.In.Market)}
+               }
+
+               if err != nil {
+                       return NewInternalError(err)
+               }
+
+               value, err := Poloniex.ComputeAccountBalanceValue(balance, q.In.Currency)
+               if err != nil {
+                       return NewInternalError(err)
+               }
+
+               result.Balance = balance
+               result.ValueCurrency = q.In.Currency
+               result.Value = value.Round(8)
+
+               return nil
+       })
+
+       if resultErr != nil {
+               return nil, resultErr
+       }
+
+       return &result, nil
+}
+
 type UpdateMarketConfigQuery struct {
        In struct {
                User   db.User
@@ -56,6 +129,9 @@ func (q UpdateMarketConfigQuery) ValidateParams() *Error {
                return &Error{BadRequest, "invalid market name", fmt.Errorf("'%v' is not a valid market name", q.In.Market)}
        }
 
+       q.In.Secret = strings.TrimSpace(q.In.Secret)
+       q.In.Key = strings.TrimSpace(q.In.Key)
+
        return nil
 }
 
diff --git a/api/markets.go b/api/markets.go
new file mode 100644 (file)
index 0000000..60fb912
--- /dev/null
@@ -0,0 +1,29 @@
+package api
+
+import (
+       "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/markets"
+)
+
+var Poloniex *markets.Poloniex
+
+func OpenMarketsConnection() error {
+       for {
+               err := Poloniex.StartTicker()
+               if err != nil {
+                       return err
+               }
+               log.Warn("connection to poloniex stream ended, restarting it...")
+       }
+}
+
+func init() {
+       Poloniex = markets.NewPoloniex()
+
+       // We open markets connections in the background as it can take time.
+       go func() {
+               err := OpenMarketsConnection()
+               if err != nil {
+                       ErrorChan <- err
+               }
+       }()
+}
index d7e712c7c5f0495717ef850e88695a401781037c..cdf3dd97cd5d67cb8e73ba6c7c6bc89ae2fe4816 100644 (file)
@@ -41,6 +41,7 @@ var Groups = []Group{
                []Route{
                        {"GET", []gin.HandlerFunc{GetMarketConfig}, "/:name"},
                        {"POST", []gin.HandlerFunc{UpdateMarketConfig}, "/:name/update"},
+                       {"GET", []gin.HandlerFunc{GetMarketBalance}, "/:name/balance"},
                },
        },
 }
@@ -111,6 +112,16 @@ func GetMarketConfig(c *gin.Context) {
        RunQuery(query, c)
 }
 
+func GetMarketBalance(c *gin.Context) {
+       query := &MarketBalanceQuery{}
+
+       query.In.User = GetUser(c)
+       query.In.Market = c.Param("name")
+       query.In.Currency = c.Query("currency")
+
+       RunQuery(query, c)
+}
+
 func UpdateMarketConfig(c *gin.Context) {
        query := &UpdateMarketConfigQuery{}
 
index 1d980854639fc27a19a258315940ce693671d6fb..2ebb734d0397af420959fb6f064c72f5a42dd6dc 100644 (file)
@@ -6,6 +6,7 @@ SRC_DIR=js
 BUILD_DIR=build/js
 JSX_SRC= main.jsx signup.jsx signin.jsx otp.jsx poloniex.jsx
 JS_SRC= cookies.js app.js api.js
+STATIC_FILES= index.html style.css cryptocoins.css cryptocoins.ttf cryptocoins.woff cryptocoins.woff2
 JSX_OBJS=$(addprefix $(BUILD_DIR)/,$(JSX_SRC:.jsx=.js))
 JS_OBJS=$(addprefix $(BUILD_DIR)/,$(JS_SRC))
 STATIC_BUILD_DIR=build/static
@@ -16,18 +17,18 @@ install:
        yarn --version
        yarn install
 
-static: js $(STATIC_BUILD_DIR)/index.html $(STATIC_BUILD_DIR)/style.css
+static: $(STATIC_BUILD_DIR) js $(addprefix $(STATIC_BUILD_DIR)/, $(STATIC_FILES))
 
 js: build/static/main.js
 
-$(STATIC_BUILD_DIR)/index.html: static/index.html
-       cp static/index.html $@
+$(STATIC_BUILD_DIR)/%: static/%
+       cp $< $@
 
-$(STATIC_BUILD_DIR)/style.css: static/style.css
-       cp static/style.css $@
+$(STATIC_BUILD_DIR):
+       mkdir -p $(BUILD_DIR)
+       mkdir -p $@
 
 $(BUILD_DIR)/%.js: $(SRC_DIR)/%.jsx
-       mkdir -p $(@D)
        jscs --fix $<
        babel $< -o $@
        jshint $@
@@ -42,11 +43,11 @@ build/static/main.js: $(JSX_OBJS) $(JS_OBJS) env/$(ENV).env
                                                 -t [ debowerify ]  \
                                                 $(BUILD_DIR)/main.js -o $@
 
-build/webapp.tar.gz: $(STATIC_BUILD_DIR)/main.js $(STATIC_BUILD_DIR)/index.html $(STATIC_BUILD_DIR)/style.css
+build/webapp.tar.gz: $(addprefix $(STATIC_BUILD_DIR)/, $(STATIC_FILES)) build/static/main.js
        tar czf $@ --directory=$(dir $<) $(notdir $^)
 
-release: build/webapp.tar.gz
+release: $(STATIC_BUILD_DIR) build/webapp.tar.gz
 
 clean:
        rm -rf build
-       rm -rf node_modules
\ No newline at end of file
+       rm -rf node_modules
index e2acd1d31dd4b9ea3b0d5555770a32f9d87ec47a..5c19fdf613fd5d165ec17d75d0cb40222d92fe5c 100644 (file)
@@ -53,6 +53,17 @@ var ApiEndpoints = {
       return '/market/' + params.name;
     }
   },
+  'MARKET_BALANCE': {
+    'type': 'GET',
+    'auth': true,
+    'parameters': [
+      {'name': 'name',     'mandatory': true, 'inquery': false},
+      {'name': 'currency', 'mandatory': true, 'inquery': true},
+    ],
+    'buildUrl': function(params) {
+      return '/market/' + params.name + '/balance';
+    }
+  },
   'UPDATE_MARKET': {
     'type': 'POST',
     'auth': true,
index eb530577fa2ae53e4a075cde960f109fea702d19..e5e505d8daa5ef4ba301c5112b026bf4aebe69ae 100644 (file)
@@ -1,15 +1,17 @@
-var SignupForm    = require('./signup.js').SignupForm;
-var SigninForm    = require('./signin.js').SigninForm;
-var OtpEnrollForm = require('./otp.js').OtpEnrollForm;
-var PoloniexForm  = require('./poloniex.js').PoloniexForm;
-var App           = require('./app.js');
-var Api           = require('./api.js').Api;
-var cookies       = require('./cookies.js');
+var SignupForm         = require('./signup.js').SignupForm;
+var SigninForm         = require('./signin.js').SigninForm;
+var OtpEnrollForm      = require('./otp.js').OtpEnrollForm;
+var PoloniexController = require('./poloniex.js').PoloniexController;
+var App                = require('./app.js');
+var Api                = require('./api.js').Api;
+var cookies            = require('./cookies.js');
 
 var Logo = React.createClass({
   render: function() {
-    return (<div id='logo'>
-              <a href='/'>Cryptoportfolio</a>
+    return (<div className='row'>
+              <div id='logo' className='offset-4 col-4'>
+                <a href='/'>Cryptoportfolio</a>
+              </div>
             </div>);
   }
 });
@@ -49,21 +51,12 @@ App.page('/signout', true, function(context) {
 });
 
 App.page('/me', true, function(context) {
-  Api.Call('MARKET', {'name': 'poloniex'}, function(err, status, data) {
-    if (err) {
-      console.error(err, data);
-      return;
-    }
-
-    App.mount(
-      <div>
-        <Logo />
-        <p>Poloniex</p>
-        <PoloniexForm apiKey={data.key} apiSecret={data.secret}/>
-      </div>
-    );
-
-  }.bind(this));
+  App.mount(
+    <div>
+      <Logo />
+      <PoloniexController/>
+    </div>
+  );
 });
 
 App.page('/otp/setup', true, function(context) {
index 2717d9f30ccef24e7166764d175334c09c6e5a8b..a0ee5cc3e2d28178e36c2a1f028548bd89fddef0 100644 (file)
@@ -53,12 +53,12 @@ module.exports.OtpEnrollForm = React.createClass({
       );
     }
     return (
-        <div className='row otp-enroll justify-content-center'>
-          <div className='col-lg-offset-4 col-lg-4 col-md-offset-4 col-md-4 col-sm-offset-4 col-sm-4 col-xs-offset-1 col-xs-10'>
+        <div className='row otp-enroll'>
+          <div className='offset-4 col-4 col-xs-offset-1 col-xs-10 text-center'>
             {qrCode}
             <div className='row justify-content-center'>
               <form role='form' onSubmit={this.handleSubmit}>
-                <input className='form-control' type='pass' placeholder='pass' onChange={this.handlePassChange} />
+                <input className='form-control' type='pass' placeholder='code' onChange={this.handlePassChange} />
                 <input className='form-control submit' type='submit' value='Validate' />
                 <div className={cName} ref='message'>{this.state.msg}</div>
               </form>
index 877198d81fed44dfa5312032ab128010daabd801..8b577b4a256e2e4c365fe1ebfcebc8c423dcdbcb 100644 (file)
 var Api        = require('./api.js').Api;
-var App        = require('./app.js');
 var classNames = require('classnames');
 
-module.exports.PoloniexForm = React.createClass({
+module.exports.PoloniexController = React.createClass({
   getInitialState: function() {
-    return {'hideMsg': true, 'msg': '', 'msgOk': false, 'apiSecret': this.props.apiSecret, 'apiKey': this.props.apiKey};
+    return {'apiKey': '', 'apiSecret': '', 'flag': 'loading', 'valueCurrency': null, 'balanceValue': null, 'balance': null};
   },
-  handleSubmit: function(e) {
+  handleCredentialsChange: function(key, secret) {
+    this.setState({'apiKey': key, 'apiSecret': secret});
+  },
+  handleCredentialsSubmit: function() {
+    if (!this.state.apiKey || !this.state.apiSecret) {
+      return;
+    }
     Api.Call('UPDATE_MARKET', {'key': this.state.apiKey, 'secret': this.state.apiSecret, 'name': 'poloniex'}, function(err, status, data) {
       if (err) {
         console.error(err, data);
-        this.displayMessage(App.errorCodeToMessage(err.code), false);
         return;
       }
 
-      this.displayMessage('OK', true);
+      this.setState({'flag': 'loading', 'valueCurrency': null, 'balanceValue': null, 'balance': null});
+      this.loadBalance();
+    }.bind(this));
+  },
+  loadBalance: function() {
+    Api.Call('MARKET_BALANCE', {'name': 'poloniex', 'currency': 'BTC'}, function(err, status, data) {
+      if (err) {
+        console.error(err, data);
+        if (err.code === 'invalid_market_credentials') {
+          this.setState({'flag': 'invalidCredentials', 'valueCurrency': null, 'balanceValue': null, 'balance': null});
+        }
+        return;
+      }
 
+      this.setState({'flag': 'ok', 'valueCurrency': data.valueCurrency, 'balanceValue': data.value, 'balance': data.balance});
     }.bind(this));
+  },
+  componentDidMount: function() {
+    Api.Call('MARKET', {'name': 'poloniex'}, function(err, status, data) {
+      if (err) {
+        console.error(err, data);
+        return;
+      }
+
+      var flag = this.state.flag;
+      if (!data.key || !data.secret) {
+        flag = 'emptyCredentials';
+      } else {
+        this.loadBalance();
+      }
+
+      this.setState({'apiKey': data.key, 'apiSecret': data.secret, 'flag': flag});
+    }.bind(this));
+  },
+  render: function() {
+    var displayText = null;
+    switch (this.state.flag) {
+      case 'loading':
+        displayText = 'Loading data from poloniex...';
+        break;
+      case 'invalidCredentials':
+        displayText = 'Invalid poloniex credentials';
+        break;
+      case 'emptyCredentials':
+        displayText = 'Please provide poloniex credentials';
+        break;
+      default:
+        displayText = null;
+    }
+    return (
+      <div>
+        <PoloniexBalance  balanceCurrency={this.state.valueCurrency}
+                          balanceValue={this.state.balanceValue}
+                          balance={this.state.balance}
+                          displayText={displayText}/>
+        <PoloniexCredentialsForm onLoadCredentials={this.onLoadCredentials}
+                                 onCredentialsSubmit={this.handleCredentialsSubmit}
+                                 onCredentialsChange={this.handleCredentialsChange}
+                                 apiSecret={this.state.apiSecret}
+                                 apiKey={this.state.apiKey}/>
+      </div>
+    );
+  }
+});
+
+var PoloniexBalance = React.createClass({
+  getInitialState: function() {
+    return {'hideMsg': true, 'msg': '', 'msgOk': false};
+  },
+  render: function() {
+    var dashboard = null;
+
+    if (this.props.balanceValue !== null) {
+
+      var balance = Object.keys(this.props.balance).map(function(currency) {
+        return <div key={currency}><i className={classNames('cc', currency)}></i> {this.props.balance[currency]}</div>;
+      }.bind(this));
+
+      dashboard = (
+        <div className='row'>
+          <div className='col-4 align-self-center h-100'>
+            <div>
+              {balance}
+            </div>
+          </div>
+          <div className='offset-1 col-7 h-100 align-self-center'>
+            <div className='text-center'>
+              Balance ({this.props.balanceCurrency}): <span>{this.props.balanceValue} <i className={classNames('cc', this.props.balanceCurrency)}></i></span>
+            </div>
+          </div>
+        </div>
+      );
+    } else {
+      dashboard = (
+        <div className='row'>
+          <div className='col-12 text-center'>
+           <span>{this.props.displayText}</span>
+          </div>
+        </div>
+
+      );
+    }
+
+    return (
+      <div className='row'>
+        <div className='box offset-2 col-8'>
+          <div className='row'>
+            <div className='col-4'>Portfolio</div>
+          </div>
+          <hr/>
+          {dashboard}
+        </div>
+      </div>
+    );
+  }
+});
+
+module.exports.PoloniexBalance = PoloniexBalance;
+
+var PoloniexCredentialsForm = React.createClass({
+  getInitialState: function() {
+    return {'hideMsg': true, 'msg': '', 'editMode': false, 'msgOk': false};
+  },
+  handleSubmit: function(e) {
+    this.props.onCredentialsSubmit();
+    this.setState({'editMode': false});
     e.preventDefault();
   },
   handleApiKeyChange: function(event) {
-    this.setState({'apiKey': event.target.value});
+    this.props.onCredentialsChange(event.target.value, this.props.apiSecret);
   },
   handleApiSecretChange: function(event) {
-    this.setState({'apiSecret': event.target.value});
+    this.props.onCredentialsChange(this.props.apiKey, event.target.value);
   },
-  hideMessage: function() {
-    this.setState({'hideMsg': true});
-  },
-  displayMessage: function(msg, ok) {
-    this.setState({'msg': msg, 'msgOk': ok, 'hideMsg': false});
+  onEditClick: function() {
+    this.setState({'editMode': true});
   },
   render: function() {
-    var cName = classNames('form-message', {'hidden': this.state.hideMsg, 'message-ok': this.state.msgOk});
+    var submitType      = (this.state.editMode === true) ? 'submit' : 'hidden';
+    var buttonDisplay   = (this.state.editMode === true) ? 'none' : 'inline';
+    var secretDisplayed = (this.state.editMode === true) ? this.props.apiSecret : 'XXXXXXX';
+    var keyDisplayed    = (this.state.editMode === true) ? this.props.apiKey : 'XXXXXXX';
+
     return (
-        <div className='row justify-content-center api-credentials-form'>
-          <div className='col-lg-offset-4 col-lg-4 col-md-offset-4 col-md-4 col-sm-offset-4 col-sm-4 col-xs-offset-1 col-xs-10'>
+        <div className='row api-credentials-form'>
+          <div className='offset-3 col-6 box'>
+            <span className='text-center'>Poloniex credentials</span>
+            <hr/>
             <form role='form' onSubmit={this.handleSubmit}>
-              <input className='form-control' type='text' placeholder='apiKey' value={this.state.apiKey} onChange={this.handleApiKeyChange} />
-              <input className='form-control' type='text' placeholder='apiSecret' value={this.state.apiSecret} onChange={this.handleApiSecretChange} />
-              <input className='form-control submit' type='submit' value='Save' />
-              <div className={cName} ref='message'>{this.state.msg}</div>
+              <label className='w-100'>Key:
+                <input className='form-control' type='text' placeholder='key' value={keyDisplayed} onChange={this.handleApiKeyChange} disabled={!this.state.editMode}/>
+              </label>
+              <label className='w-100'>Secret:
+                <input className='form-control' type='text' placeholder='secret' value={secretDisplayed} onChange={this.handleApiSecretChange} disabled={!this.state.editMode}/>
+              </label>
+              <input className='form-control submit' type={submitType} value='Save' />
+              <button className='form-control submit' style={{display: buttonDisplay}} onSubmit={null} onClick={this.onEditClick} type='button'>Show/Edit</button>
             </form>
           </div>
         </div>
        );
   }
 });
+
+module.exports.PoloniexCredentialsForm = PoloniexCredentialsForm;
index 443a461ed64cca604d8156fe923bb0187ab9df2a..a2cfd1b91843c909003624da8ccd554c4c7bd7e5 100644 (file)
@@ -35,8 +35,8 @@ module.exports.SigninForm = React.createClass({
   render: function() {
     var cName = classNames('form-message', {'hidden': this.state.hideMsg, 'message-ok': this.state.msgOk});
     return (
-        <div className='row justify-content-center sign-in'>
-          <div className='col-lg-offset-4 col-lg-4 col-md-offset-4 col-md-4 col-sm-offset-4 col-sm-4 col-xs-offset-1 col-xs-10'>
+        <div className='row sign-in'>
+          <div className='offset-4 col-4 col-xs-offset-1 col-xs-10 text-center'>
             <form role='form' onSubmit={this.handleSubmit}>
               <input className='form-control' type='email' placeholder='email' onChange={this.handleEmailChange} />
               <input className='form-control' type='password' placeholder='password' onChange={this.handlePasswordChange} />
index 149125ab0230b7071a19fd1390e13673b02eb2b7..404a8281dccd75fefabbb6d1d80a79fd4e84bd26 100644 (file)
@@ -40,8 +40,8 @@ module.exports.SignupForm = React.createClass({
   render: function() {
     var cName = classNames('form-message', {'hidden': this.state.hideMsg, 'message-ok': this.state.msgOk});
     return (
-        <div className='row justify-content-center sign-in'>
-          <div className='col-lg-offset-4 col-lg-4 col-md-offset-4 col-md-4 col-sm-offset-4 col-sm-4 col-xs-offset-1 col-xs-10'>
+        <div className='row sign-in'>
+          <div className='offset-4 col-4 col-xs-offset-1 col-xs-10 text-center'>
             <form role='form' onSubmit={this.handleSubmit}>
               <input className='form-control' type='email' placeholder='email' onChange={this.handleEmailChange} />
               <input className='form-control' type='password' placeholder='password' onChange={this.handlePasswordChange} />
diff --git a/cmd/web/static/cryptocoins.css b/cmd/web/static/cryptocoins.css
new file mode 100644 (file)
index 0000000..6829169
--- /dev/null
@@ -0,0 +1,1150 @@
+/*! Cryptocoins - cryptocurrency icon font | https://github.com/allienworks/cryptocoins */
+
+@font-face {
+  font-family: "cryptocoins";
+  src: url('cryptocoins.woff2') format('woff2'),
+    url('cryptocoins.woff') format('woff'),
+    url('cryptocoins.ttf') format('truetype');
+}
+
+/* .cc:before { */
+.cc::before {
+  font-family: "cryptocoins";
+    -webkit-font-smoothing: antialiased;
+    -moz-osx-font-smoothing: grayscale;
+  font-style: normal;
+  font-variant: normal;
+  font-weight: normal;
+  /* speak: none; only necessary if not using the private unicode range (firstGlyph option) */
+  text-decoration: none;
+  text-transform: none;
+}
+
+
+.cc.ADA-alt::before {
+  content: "\E001";
+}
+
+.cc.ADA::before {
+  content: "\E002";
+}
+
+.cc.ADC-alt::before {
+  content: "\E003";
+}
+
+.cc.ADC::before {
+  content: "\E004";
+}
+
+.cc.AEON-alt::before {
+  content: "\E005";
+}
+
+.cc.AEON::before {
+  content: "\E006";
+}
+
+.cc.AMP-alt::before {
+  content: "\E007";
+}
+
+.cc.AMP::before {
+  content: "\E008";
+}
+
+.cc.ANC-alt::before {
+  content: "\E009";
+}
+
+.cc.ANC::before {
+  content: "\E00A";
+}
+
+.cc.ARCH-alt::before {
+  content: "\E00B";
+}
+
+.cc.ARCH::before {
+  content: "\E00C";
+}
+
+.cc.ARDR-alt::before {
+  content: "\E00D";
+}
+
+.cc.ARDR::before {
+  content: "\E00E";
+}
+
+.cc.ARK-alt::before {
+  content: "\E00F";
+}
+
+.cc.ARK::before {
+  content: "\E010";
+}
+
+.cc.AUR-alt::before {
+  content: "\E011";
+}
+
+.cc.AUR::before {
+  content: "\E012";
+}
+
+.cc.BANX-alt::before {
+  content: "\E013";
+}
+
+.cc.BANX::before {
+  content: "\E014";
+}
+
+.cc.BAT-alt::before {
+  content: "\E015";
+}
+
+.cc.BAT::before {
+  content: "\E016";
+}
+
+.cc.BAY-alt::before {
+  content: "\E017";
+}
+
+.cc.BAY::before {
+  content: "\E018";
+}
+
+.cc.BC-alt::before {
+  content: "\E019";
+}
+
+.cc.BC::before {
+  content: "\E01A";
+}
+
+.cc.BCH-alt::before {
+  content: "\E01B";
+}
+
+.cc.BCH::before {
+  content: "\E01C";
+}
+
+.cc.BCN-alt::before {
+  content: "\E01D";
+}
+
+.cc.BCN::before {
+  content: "\E01E";
+}
+
+.cc.BFT-alt::before {
+  content: "\E01F";
+}
+
+.cc.BFT::before {
+  content: "\E020";
+}
+
+.cc.BRK-alt::before {
+  content: "\E021";
+}
+
+.cc.BRK::before {
+  content: "\E022";
+}
+
+.cc.BRX-alt::before {
+  content: "\E023";
+}
+
+.cc.BRX::before {
+  content: "\E024";
+}
+
+.cc.BSD-alt::before {
+  content: "\E025";
+}
+
+.cc.BSD::before {
+  content: "\E026";
+}
+
+.cc.BTA::before {
+  content: "\E027";
+}
+
+.cc.BTC-alt::before {
+  content: "\E028";
+}
+
+.cc.BTC::before {
+  content: "\E029";
+}
+
+.cc.BTCD-alt::before {
+  content: "\E02A";
+}
+
+.cc.BTCD::before {
+  content: "\E02B";
+}
+
+.cc.BTM-alt::before {
+  content: "\E02C";
+}
+
+.cc.BTM::before {
+  content: "\E02D";
+}
+
+.cc.BTS-alt::before {
+  content: "\E02E";
+}
+
+.cc.BTS::before {
+  content: "\E02F";
+}
+
+.cc.CLAM-alt::before {
+  content: "\E030";
+}
+
+.cc.CLAM::before {
+  content: "\E031";
+}
+
+.cc.CLOAK-alt::before {
+  content: "\E032";
+}
+
+.cc.CLOAK::before {
+  content: "\E033";
+}
+
+.cc.DAO-alt::before {
+  content: "\E034";
+}
+
+.cc.DAO::before {
+  content: "\E035";
+}
+
+.cc.DASH-alt::before {
+  content: "\E036";
+}
+
+.cc.DASH::before {
+  content: "\E037";
+}
+
+.cc.DCR-alt::before {
+  content: "\E038";
+}
+
+.cc.DCR::before {
+  content: "\E039";
+}
+
+.cc.DCT-alt::before {
+  content: "\E03A";
+}
+
+.cc.DCT::before {
+  content: "\E03B";
+}
+
+.cc.DGB-alt::before {
+  content: "\E03C";
+}
+
+.cc.DGB::before {
+  content: "\E03D";
+}
+
+.cc.DGD::before {
+  content: "\E03E";
+}
+
+.cc.DGX::before {
+  content: "\E03F";
+}
+
+.cc.DMD-alt::before {
+  content: "\E040";
+}
+
+.cc.DMD::before {
+  content: "\E041";
+}
+
+.cc.DOGE-alt::before {
+  content: "\E042";
+}
+
+.cc.DOGE::before {
+  content: "\E043";
+}
+
+.cc.EMC-alt::before {
+  content: "\E044";
+}
+
+.cc.EMC::before {
+  content: "\E045";
+}
+
+.cc.EOS-alt::before {
+  content: "\E046";
+}
+
+.cc.EOS::before {
+  content: "\E047";
+}
+
+.cc.ERC-alt::before {
+  content: "\E048";
+}
+
+.cc.ERC::before {
+  content: "\E049";
+}
+
+.cc.ETC-alt::before {
+  content: "\E04A";
+}
+
+.cc.ETC::before {
+  content: "\E04B";
+}
+
+.cc.ETH-alt::before {
+  content: "\E04C";
+}
+
+.cc.ETH::before {
+  content: "\E04D";
+}
+
+.cc.FC2-alt::before {
+  content: "\E04E";
+}
+
+.cc.FC2::before {
+  content: "\E04F";
+}
+
+.cc.FCT-alt::before {
+  content: "\E050";
+}
+
+.cc.FCT::before {
+  content: "\E051";
+}
+
+.cc.FLO-alt::before {
+  content: "\E052";
+}
+
+.cc.FLO::before {
+  content: "\E053";
+}
+
+.cc.FRK-alt::before {
+  content: "\E054";
+}
+
+.cc.FRK::before {
+  content: "\E055";
+}
+
+.cc.FTC-alt::before {
+  content: "\E056";
+}
+
+.cc.FTC::before {
+  content: "\E057";
+}
+
+.cc.GAME-alt::before {
+  content: "\E058";
+}
+
+.cc.GAME::before {
+  content: "\E059";
+}
+
+.cc.GBYTE-alt::before {
+  content: "\E05A";
+}
+
+.cc.GBYTE::before {
+  content: "\E05B";
+}
+
+.cc.GDC-alt::before {
+  content: "\E05C";
+}
+
+.cc.GDC::before {
+  content: "\E05D";
+}
+
+.cc.GEMZ-alt::before {
+  content: "\E05E";
+}
+
+.cc.GEMZ::before {
+  content: "\E05F";
+}
+
+.cc.GLD-alt::before {
+  content: "\E060";
+}
+
+.cc.GLD::before {
+  content: "\E061";
+}
+
+.cc.GNO-alt::before {
+  content: "\E062";
+}
+
+.cc.GNO::before {
+  content: "\E063";
+}
+
+.cc.GNT-alt::before {
+  content: "\E064";
+}
+
+.cc.GNT::before {
+  content: "\E065";
+}
+
+.cc.GOLOS-alt::before {
+  content: "\E066";
+}
+
+.cc.GOLOS::before {
+  content: "\E067";
+}
+
+.cc.GRC-alt::before {
+  content: "\E068";
+}
+
+.cc.GRC::before {
+  content: "\E069";
+}
+
+.cc.GRS::before {
+  content: "\E06A";
+}
+
+.cc.HEAT-alt::before {
+  content: "\E06B";
+}
+
+.cc.HEAT::before {
+  content: "\E06C";
+}
+
+.cc.ICN-alt::before {
+  content: "\E06D";
+}
+
+.cc.ICN::before {
+  content: "\E06E";
+}
+
+.cc.IFC-alt::before {
+  content: "\E06F";
+}
+
+.cc.IFC::before {
+  content: "\E070";
+}
+
+.cc.INCNT-alt::before {
+  content: "\E071";
+}
+
+.cc.INCNT::before {
+  content: "\E072";
+}
+
+.cc.IOC-alt::before {
+  content: "\E073";
+}
+
+.cc.IOC::before {
+  content: "\E074";
+}
+
+.cc.IOTA-alt::before {
+  content: "\E075";
+}
+
+.cc.IOTA::before {
+  content: "\E076";
+}
+
+.cc.JBS-alt::before {
+  content: "\E077";
+}
+
+.cc.JBS::before {
+  content: "\E078";
+}
+
+.cc.KMD-alt::before {
+  content: "\E079";
+}
+
+.cc.KMD::before {
+  content: "\E07A";
+}
+
+.cc.KOBO::before {
+  content: "\E07B";
+}
+
+.cc.KORE-alt::before {
+  content: "\E07C";
+}
+
+.cc.KORE::before {
+  content: "\E07D";
+}
+
+.cc.LBC-alt::before {
+  content: "\E07E";
+}
+
+.cc.LBC::before {
+  content: "\E07F";
+}
+
+.cc.LDOGE-alt::before {
+  content: "\E080";
+}
+
+.cc.LDOGE::before {
+  content: "\E081";
+}
+
+.cc.LSK-alt::before {
+  content: "\E082";
+}
+
+.cc.LSK::before {
+  content: "\E083";
+}
+
+.cc.LTC-alt::before {
+  content: "\E084";
+}
+
+.cc.LTC::before {
+  content: "\E085";
+}
+
+.cc.MAID-alt::before {
+  content: "\E086";
+}
+
+.cc.MAID::before {
+  content: "\E087";
+}
+
+.cc.MCO-alt::before {
+  content: "\E088";
+}
+
+.cc.MCO::before {
+  content: "\E089";
+}
+
+.cc.MINT-alt::before {
+  content: "\E08A";
+}
+
+.cc.MINT::before {
+  content: "\E08B";
+}
+
+.cc.MONA-alt::before {
+  content: "\E08C";
+}
+
+.cc.MONA::before {
+  content: "\E08D";
+}
+
+.cc.MRC::before {
+  content: "\E08E";
+}
+
+.cc.MSC-alt::before {
+  content: "\E08F";
+}
+
+.cc.MSC::before {
+  content: "\E090";
+}
+
+.cc.MTR-alt::before {
+  content: "\E091";
+}
+
+.cc.MTR::before {
+  content: "\E092";
+}
+
+.cc.MUE-alt::before {
+  content: "\E093";
+}
+
+.cc.MUE::before {
+  content: "\E094";
+}
+
+.cc.NBT::before {
+  content: "\E095";
+}
+
+.cc.NEO-alt::before {
+  content: "\E096";
+}
+
+.cc.NEO::before {
+  content: "\E097";
+}
+
+.cc.NEOS-alt::before {
+  content: "\E098";
+}
+
+.cc.NEOS::before {
+  content: "\E099";
+}
+
+.cc.NEU-alt::before {
+  content: "\E09A";
+}
+
+.cc.NEU::before {
+  content: "\E09B";
+}
+
+.cc.NLG-alt::before {
+  content: "\E09C";
+}
+
+.cc.NLG::before {
+  content: "\E09D";
+}
+
+.cc.NMC-alt::before {
+  content: "\E09E";
+}
+
+.cc.NMC::before {
+  content: "\E09F";
+}
+
+.cc.NOTE-alt::before {
+  content: "\E0A0";
+}
+
+.cc.NOTE::before {
+  content: "\E0A1";
+}
+
+.cc.NVC-alt::before {
+  content: "\E0A2";
+}
+
+.cc.NVC::before {
+  content: "\E0A3";
+}
+
+.cc.NXT-alt::before {
+  content: "\E0A4";
+}
+
+.cc.NXT::before {
+  content: "\E0A5";
+}
+
+.cc.OK-alt::before {
+  content: "\E0A6";
+}
+
+.cc.OK::before {
+  content: "\E0A7";
+}
+
+.cc.OMG-alt::before {
+  content: "\E0A8";
+}
+
+.cc.OMG::before {
+  content: "\E0A9";
+}
+
+.cc.OMNI-alt::before {
+  content: "\E0AA";
+}
+
+.cc.OMNI::before {
+  content: "\E0AB";
+}
+
+.cc.OPAL-alt::before {
+  content: "\E0AC";
+}
+
+.cc.OPAL::before {
+  content: "\E0AD";
+}
+
+.cc.PART-alt::before {
+  content: "\E0AE";
+}
+
+.cc.PART::before {
+  content: "\E0AF";
+}
+
+.cc.PIGGY-alt::before {
+  content: "\E0B0";
+}
+
+.cc.PIGGY::before {
+  content: "\E0B1";
+}
+
+.cc.PINK-alt::before {
+  content: "\E0B2";
+}
+
+.cc.PINK::before {
+  content: "\E0B3";
+}
+
+.cc.PIVX-alt::before {
+  content: "\E0B4";
+}
+
+.cc.PIVX::before {
+  content: "\E0B5";
+}
+
+.cc.POT-alt::before {
+  content: "\E0B6";
+}
+
+.cc.POT::before {
+  content: "\E0B7";
+}
+
+.cc.PPC-alt::before {
+  content: "\E0B8";
+}
+
+.cc.PPC::before {
+  content: "\E0B9";
+}
+
+.cc.QRK-alt::before {
+  content: "\E0BA";
+}
+
+.cc.QRK::before {
+  content: "\E0BB";
+}
+
+.cc.QTUM-alt::before {
+  content: "\E0BC";
+}
+
+.cc.QTUM::before {
+  content: "\E0BD";
+}
+
+.cc.RADS-alt::before {
+  content: "\E0BE";
+}
+
+.cc.RADS::before {
+  content: "\E0BF";
+}
+
+.cc.RBIES-alt::before {
+  content: "\E0C0";
+}
+
+.cc.RBIES::before {
+  content: "\E0C1";
+}
+
+.cc.RBT-alt::before {
+  content: "\E0C2";
+}
+
+.cc.RBT::before {
+  content: "\E0C3";
+}
+
+.cc.RBY-alt::before {
+  content: "\E0C4";
+}
+
+.cc.RBY::before {
+  content: "\E0C5";
+}
+
+.cc.RDD-alt::before {
+  content: "\E0C6";
+}
+
+.cc.RDD::before {
+  content: "\E0C7";
+}
+
+.cc.REP-alt::before {
+  content: "\E0C8";
+}
+
+.cc.REP::before {
+  content: "\E0C9";
+}
+
+.cc.RISE-alt::before {
+  content: "\E0CA";
+}
+
+.cc.RISE::before {
+  content: "\E0CB";
+}
+
+.cc.SALT-alt::before {
+  content: "\E0CC";
+}
+
+.cc.SALT::before {
+  content: "\E0CD";
+}
+
+.cc.SAR-alt::before {
+  content: "\E0CE";
+}
+
+.cc.SAR::before {
+  content: "\E0CF";
+}
+
+.cc.SCOT-alt::before {
+  content: "\E0D0";
+}
+
+.cc.SCOT::before {
+  content: "\E0D1";
+}
+
+.cc.SDC-alt::before {
+  content: "\E0D2";
+}
+
+.cc.SDC::before {
+  content: "\E0D3";
+}
+
+.cc.SIA-alt::before {
+  content: "\E0D4";
+}
+
+.cc.SIA::before {
+  content: "\E0D5";
+}
+
+.cc.SJCX-alt::before {
+  content: "\E0D6";
+}
+
+.cc.SJCX::before {
+  content: "\E0D7";
+}
+
+.cc.SLG-alt::before {
+  content: "\E0D8";
+}
+
+.cc.SLG::before {
+  content: "\E0D9";
+}
+
+.cc.SLS-alt::before {
+  content: "\E0DA";
+}
+
+.cc.SLS::before {
+  content: "\E0DB";
+}
+
+.cc.SNRG-alt::before {
+  content: "\E0DC";
+}
+
+.cc.SNRG::before {
+  content: "\E0DD";
+}
+
+.cc.START-alt::before {
+  content: "\E0DE";
+}
+
+.cc.START::before {
+  content: "\E0DF";
+}
+
+.cc.STEEM-alt::before {
+  content: "\E0E0";
+}
+
+.cc.STEEM::before {
+  content: "\E0E1";
+}
+
+.cc.STR-alt::before {
+  content: "\E0E2";
+}
+
+.cc.STR::before {
+  content: "\E0E3";
+}
+
+.cc.STRAT-alt::before {
+  content: "\E0E4";
+}
+
+.cc.STRAT::before {
+  content: "\E0E5";
+}
+
+.cc.SWIFT-alt::before {
+  content: "\E0E6";
+}
+
+.cc.SWIFT::before {
+  content: "\E0E7";
+}
+
+.cc.SYNC-alt::before {
+  content: "\E0E8";
+}
+
+.cc.SYNC::before {
+  content: "\E0E9";
+}
+
+.cc.SYS-alt::before {
+  content: "\E0EA";
+}
+
+.cc.SYS::before {
+  content: "\E0EB";
+}
+
+.cc.TRIG-alt::before {
+  content: "\E0EC";
+}
+
+.cc.TRIG::before {
+  content: "\E0ED";
+}
+
+.cc.TX-alt::before {
+  content: "\E0EE";
+}
+
+.cc.TX::before {
+  content: "\E0EF";
+}
+
+.cc.UBQ-alt::before {
+  content: "\E0F0";
+}
+
+.cc.UBQ::before {
+  content: "\E0F1";
+}
+
+.cc.UNITY-alt::before {
+  content: "\E0F2";
+}
+
+.cc.UNITY::before {
+  content: "\E0F3";
+}
+
+.cc.USDT-alt::before {
+  content: "\E0F4";
+}
+
+.cc.USDT::before {
+  content: "\E0F5";
+}
+
+.cc.VIOR-alt::before {
+  content: "\E0F6";
+}
+
+.cc.VIOR::before {
+  content: "\E0F7";
+}
+
+.cc.VNL-alt::before {
+  content: "\E0F8";
+}
+
+.cc.VNL::before {
+  content: "\E0F9";
+}
+
+.cc.VPN-alt::before {
+  content: "\E0FA";
+}
+
+.cc.VPN::before {
+  content: "\E0FB";
+}
+
+.cc.VRC-alt::before {
+  content: "\E0FC";
+}
+
+.cc.VRC::before {
+  content: "\E0FD";
+}
+
+.cc.VTC-alt::before {
+  content: "\E0FE";
+}
+
+.cc.VTC::before {
+  content: "\E0FF";
+}
+
+.cc.WAVES-alt::before {
+  content: "\E100";
+}
+
+.cc.WAVES::before {
+  content: "\E101";
+}
+
+.cc.XAI-alt::before {
+  content: "\E102";
+}
+
+.cc.XAI::before {
+  content: "\E103";
+}
+
+.cc.XBS-alt::before {
+  content: "\E104";
+}
+
+.cc.XBS::before {
+  content: "\E105";
+}
+
+.cc.XCP-alt::before {
+  content: "\E106";
+}
+
+.cc.XCP::before {
+  content: "\E107";
+}
+
+.cc.XEM-alt::before {
+  content: "\E108";
+}
+
+.cc.XEM::before {
+  content: "\E109";
+}
+
+.cc.XMR::before {
+  content: "\E10A";
+}
+
+.cc.XPM-alt::before {
+  content: "\E10B";
+}
+
+.cc.XPM::before {
+  content: "\E10C";
+}
+
+.cc.XRP-alt::before {
+  content: "\E10D";
+}
+
+.cc.XRP::before {
+  content: "\E10E";
+}
+
+.cc.XTZ-alt::before {
+  content: "\E10F";
+}
+
+.cc.XTZ::before {
+  content: "\E110";
+}
+
+.cc.XVG-alt::before {
+  content: "\E111";
+}
+
+.cc.XVG::before {
+  content: "\E112";
+}
+
+.cc.XZC-alt::before {
+  content: "\E113";
+}
+
+.cc.XZC::before {
+  content: "\E114";
+}
+
+.cc.YBC-alt::before {
+  content: "\E115";
+}
+
+.cc.YBC::before {
+  content: "\E116";
+}
+
+.cc.ZEC-alt::before {
+  content: "\E117";
+}
+
+.cc.ZEC::before {
+  content: "\E118";
+}
+
+.cc.ZEIT-alt::before {
+  content: "\E119";
+}
+
+.cc.ZEIT::before {
+  content: "\E11A";
+}
\ No newline at end of file
diff --git a/cmd/web/static/cryptocoins.ttf b/cmd/web/static/cryptocoins.ttf
new file mode 100644 (file)
index 0000000..b92c27d
Binary files /dev/null and b/cmd/web/static/cryptocoins.ttf differ
diff --git a/cmd/web/static/cryptocoins.woff b/cmd/web/static/cryptocoins.woff
new file mode 100644 (file)
index 0000000..bf8eb08
Binary files /dev/null and b/cmd/web/static/cryptocoins.woff differ
diff --git a/cmd/web/static/cryptocoins.woff2 b/cmd/web/static/cryptocoins.woff2
new file mode 100644 (file)
index 0000000..dcc195c
Binary files /dev/null and b/cmd/web/static/cryptocoins.woff2 differ
index 9a7007466339d7fc3f1ff7bb4488b308d9192a4f..a37251790624da100dc7f255f8ad73bc8dfc51c1 100644 (file)
@@ -11,6 +11,7 @@
     <title>Cryptoportfolio</title>
     <link href='https://fonts.googleapis.com/css?family=Fira+Mono' rel='stylesheet' type='text/css'>
     <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
+    <link rel="stylesheet" type="text/css" href="/public/cryptocoins.css"/>
     <link rel="stylesheet" type="text/css" href="/public/style.css"/>
     <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
     <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
index 12af3793c2a8ec0b6d13b4c9deac912ae8855951..bfa43f06d70cdf5398e83da63f4492dd3da60419 100644 (file)
@@ -3,7 +3,6 @@
 body {
   font-family: 'Fira Mono', 'Helvetica Neue', Arial, Helvetica, sans-serif;
   background-color: rgb(246, 248, 251);
-  text-align: center;
 }
 
 ul {
@@ -25,6 +24,10 @@ a:focus {
   color: inherit;
 }
 
+i.cc {
+  font-size: 1.5em;
+}
+
 #logo {
   text-align: center;
   display: inline-block;
@@ -93,8 +96,8 @@ a:focus {
   background-color: rgb(250, 250, 250);
   box-shadow: 0 2px 6px 2px rgba(0,0,0,.05);
   border-radius: 4px;
-}
-
-.box:hover {
-  box-shadow: 0 4px 15px 2px rgba(0,0,0,.20);
-}
+  margin-bottom: 10px;
+  margin-top: 10px;
+  padding-top: 10px;
+  padding-bottom: 10px;
+}
\ No newline at end of file
diff --git a/markets/poloniex.go b/markets/poloniex.go
new file mode 100644 (file)
index 0000000..5e1ec64
--- /dev/null
@@ -0,0 +1,177 @@
+package markets
+
+import (
+       "fmt"
+       "strings"
+
+       "github.com/jloup/poloniex"
+       "github.com/jloup/utils"
+       "github.com/shopspring/decimal"
+)
+
+var (
+       ErrorFlagCounter        utils.Counter = 0
+       CurrencyPairNotInTicker               = utils.InitFlag(&ErrorFlagCounter, "CurrencyPairNotInTicker")
+       InvalidCredentials                    = utils.InitFlag(&ErrorFlagCounter, "InvalidCredentials")
+)
+
+func poloniexInvalidCredentialsError(err error) bool {
+       if err == nil {
+               return false
+       }
+       return strings.Contains(err.Error(), "Invalid API key/secret pair")
+}
+
+type CurrencyPair struct {
+       Name string
+       Rate decimal.Decimal
+}
+
+type Poloniex struct {
+       TickerCache map[string]CurrencyPair
+
+       publicClient     *poloniex.Poloniex
+       updateTickerChan chan CurrencyPair
+}
+
+func NewPoloniex() *Poloniex {
+       client, _ := poloniex.NewClient("", "")
+
+       return &Poloniex{
+               TickerCache:      make(map[string]CurrencyPair),
+               updateTickerChan: nil,
+               publicClient:     client,
+       }
+}
+
+func (p *Poloniex) GetBalance(apiKey, apiSecret string) (map[string]decimal.Decimal, error) {
+       client, _ := poloniex.NewClient(apiKey, apiSecret)
+
+       accounts, err := client.TradeReturnAvailableAccountBalances()
+       if poloniexInvalidCredentialsError(err) {
+               return nil, utils.Error{InvalidCredentials, "invalid poloniex credentials"}
+       }
+
+       if err != nil {
+               return nil, err
+       }
+
+       balances := make(map[string]decimal.Decimal)
+       for currency, balance := range accounts.Margin {
+               balances[currency] = balances[currency].Add(balance)
+       }
+
+       for currency, balance := range accounts.Exchange {
+               balances[currency] = balances[currency].Add(balance)
+       }
+
+       return balances, nil
+}
+
+func (p *Poloniex) ComputeAccountBalanceValue(account map[string]decimal.Decimal, baseCurrency string) (decimal.Decimal, error) {
+       var total decimal.Decimal
+
+       for currency, amount := range account {
+               pair, err := p.GetCurrencyPair(baseCurrency, currency)
+               if err != nil {
+                       return decimal.Zero, err
+               }
+
+               total = total.Add(amount.Mul(pair.Rate))
+       }
+
+       return total, nil
+}
+
+func (p *Poloniex) GetCurrencyPair(curr1, curr2 string) (CurrencyPair, error) {
+       pairName := fmt.Sprintf("%s_%s", curr1, curr2)
+       var err error
+
+       if curr1 == curr2 {
+               return CurrencyPair{pairName, decimal.NewFromFloat(1.0)}, nil
+       }
+
+       pair, ok := p.TickerCache[pairName]
+       if !ok {
+               pair, err = p.fetchTicker(curr1, curr2)
+
+               if utils.ErrIs(err, CurrencyPairNotInTicker) {
+                       // try to invert an existing ticker.
+                       pair, err = p.fetchTicker(curr2, curr1)
+                       if err != nil {
+                               return CurrencyPair{}, err
+                       }
+
+                       return CurrencyPair{pairName, decimal.NewFromFloat(1.0).Div(pair.Rate)}, nil
+               }
+
+               if err != nil {
+                       return CurrencyPair{}, err
+               }
+       }
+
+       return pair, nil
+}
+
+func (p *Poloniex) fetchTicker(curr1, curr2 string) (CurrencyPair, error) {
+       tickers, err := p.publicClient.PubReturnTickers()
+       if err != nil {
+               return CurrencyPair{}, err
+       }
+
+       pairName := fmt.Sprintf("%s_%s", curr1, curr2)
+
+       if ticker, ok := tickers[pairName]; ok {
+               pair := CurrencyPair{Name: pairName, Rate: ticker.Last}
+
+               if p.updateTickerChan != nil {
+                       p.updateTickerChan <- pair
+               }
+
+               return pair, nil
+       }
+
+       return CurrencyPair{}, utils.Error{CurrencyPairNotInTicker, fmt.Sprintf("%s_%s not in ticker", curr1, curr2)}
+}
+
+func (p *Poloniex) StartTicker() error {
+       stream, err := poloniex.NewWSClient()
+       if err != nil {
+               return err
+       }
+
+       err = stream.SubscribeTicker()
+       if err != nil {
+               return err
+       }
+
+       p.updateTickerChan = make(chan CurrencyPair)
+
+       for {
+               quit := false
+               select {
+               case data, ok := <-stream.Subs["ticker"]:
+                       if !ok {
+                               quit = true
+                       } else {
+                               ticker := data.(poloniex.WSTicker)
+                               if ticker.CurrencyPair == "USDT_BTC" || true {
+                               }
+                               p.TickerCache[ticker.CurrencyPair] = CurrencyPair{Name: ticker.CurrencyPair, Rate: decimal.NewFromFloat(ticker.Last)}
+                       }
+
+               case pair, ok := <-p.updateTickerChan:
+                       if !ok {
+                               quit = true
+                       } else {
+                               p.TickerCache[pair.Name] = pair
+                       }
+               }
+               if quit {
+                       p.updateTickerChan = nil
+                       break
+               }
+       }
+
+       return nil
+}