Merge branch 'develop' into fix/nonce-increment

This commit is contained in:
Vitaliy Vlasov 2018-04-26 20:21:02 +03:00
commit 8851873a49
24 changed files with 802 additions and 254 deletions

View File

@ -0,0 +1,40 @@
# 0004. Use Tachyons
| Date | Tags |
|---|---|
| 2018-04-18 | design, css, tooling |
## Status
Accepted
## Context
A primary component of OpenBounty is a web application. As part of our work on this web application we regularly need to implement new UI elements or flows to support overall product development. This frontend work requires usage of CSS to specify positioning, text styles and many more variables.
A common problem with CSS is that developers try to generalize CSS classes so that they can be reused (see e.g. [BEM](http://getbem.com/)). Arguably the intention is great but inevitably the time will come when constraints change and so the component's CSS is modified. By that time other people may have used that component in other places relying on the current implementation.
In programming languages breaking a collaborator's expectation like this can be mitigated using assertions or automatic tests but this is less easily done when working with CSS.
## Decision
In order to avoid the problems outlined above we will adopt the approach of using atomic, immutable utility classes as promoted by the [Tachyons](http://tachyons.io/) library.
Tachyons provides safe-to-reuse, single-purpose classes that help with achieving consistent scales of whitespace and font-sizes.
By not modifying the definition of CSS classes anymore we can safely build out UI components using those classes without needing to worry if we're breaking someone else's expectations.
## Consequences
- Tachyons can be a bit weird when not being familiar with the general approach. While I believe it will enable contributors to move more confidently & quickly in the long run we might need to go the extra mile to make them buy into this approach.
- Previously reuse what at the level of CSS classes. With an approach like Tachyon's reuse will get elevated to the component level.
## Appendix
- [CSS and Scalability](http://mrmrs.github.io/writing/2016/03/24/scalable-css/) — an insightful article on why utility classes are a good idea by the author of Tachyons. A quote from that article:
> In [the monolith] model, you will never stop writing css. Refactoring css is hard and time consuming. Deleting unused css is hard and time consuming. And more often than not - its not work people are excited to do. So what happens? People keep writing more and more css
- [tachyons-tldr](https://tachyons-tldr.now.sh) — super helpful tool to look up classes provided by Tachyons via the CSS attributes they affect.
- [dwyl/learn-tachyons](https://github.com/dwyl/learn-tachyons) — a nice repository with another 30s pitch and various examples outlining basic usage.

View File

@ -0,0 +1 @@
ALTER TABLE issues DROP CONSTRAINT transaction_hash_uniq;

View File

@ -0,0 +1 @@
ALTER TABLE issues ADD CONSTRAINT transaction_hash_uniq UNIQUE (transaction_hash);

View File

@ -486,6 +486,8 @@ SELECT
i.updated AS updated,
i.winner_login AS winner_login,
r.repo AS repo_name,
r.owner AS repo_owner,
r.owner_avatar_url AS repo_owner_avatar_url,
o.address AS owner_address,
u.address AS payout_address
FROM users o, repositories r, issues i LEFT OUTER JOIN users u ON u.login = i.winner_login

View File

@ -7,6 +7,7 @@
[commiteth.eth.tracker :as tracker]
[commiteth.github.core :as github]
[commiteth.eth.multisig-wallet :as multisig]
[commiteth.model.bounty :as bnt]
[commiteth.util.png-rendering :as png-rendering]
[clojure.tools.logging :as log]))
@ -124,17 +125,27 @@
[bounty]
(assert-keys bounty [:winner_login :payout_address :confirm_hash :payout_hash
:claims :tokens :contract_address])
(if-let [merged? (:winner_login bounty)]
;; Some bounties have been paid out manually, the payout hash
;; was set properly but winner_login was not
(let [open-claims (fn open-claims [bounty]
(filter bnt/open? (:claims bounty)))]
(if-let [merged-or-paid? (or (:winner_login bounty)
(:payout_hash bounty))]
(cond
(nil? (:payout_address bounty)) :pending-contributor-address
(nil? (:confirm_hash bounty)) :pending-maintainer-confirmation
(:payout_hash bounty) :paid
(nil? (:payout_address bounty)) :pending-contributor-address
;; `confirm_hash` is set by us as soon as a PR is merged and the
;; contributor address is known. Usually end users should not need
;; to be aware of this step.
(nil? (:confirm_hash bounty)) :pending-sob-confirmation
;; `payout_hash` is set when the bounty issuer signs the payout
(nil? (:payout_hash bounty)) :pending-maintainer-confirmation
:else :merged)
(cond ; not yet merged
(< 1 (count (:claims bounty))) :multiple-claims
(= 1 (count (:claims bounty))) :claimed
(< 1 (count (open-claims bounty))) :multiple-claims
(= 1 (count (open-claims bounty))) :claimed
(seq (:tokens bounty)) :funded
(:contract_address bounty) :opened)))
(:contract_address bounty) :opened))))
(comment
(def user 97496)

View File

@ -32,7 +32,7 @@
(defn update-issue-title
[issue-id title]
(log/info "issue %s: Updating changed title \"%s\"" issue-id title)
(log/infof "issue %s: Updating changed title \"%s\"" issue-id title)
(jdbc/with-db-connection [con-db *db*]
(db/update-issue-title con-db {:issue_id issue-id
:title title})))

View File

@ -60,6 +60,24 @@
(map update-enabled repos)})
github-repos))))
(def bounty-renames
;; TODO this needs to go away ASAP we need to be super consistent
;; about keys unless we will just step on each others toes constantly
{:user_name :display-name
:user_avatar_url :avatar-url
:issue_title :issue-title
:pr_title :pr-title
:pr_number :pr-number
:pr_id :pr-id
:type :item-type
:repo_name :repo-name
:repo_owner :repo-owner
:issue_number :issue-number
:issue_id :issue-id
:value_usd :value-usd
:claim_count :claim-count
:balance_eth :balance-eth
:user_has_address :user-has-address})
(defn ^:private enrich-owner-bounties [owner-bounty]
(let [claims (map
@ -67,14 +85,16 @@
(bounties-db/bounty-claims (:issue_id owner-bounty)))
with-claims (assoc owner-bounty :claims claims)]
(-> with-claims
(update :value_usd usd-decimal->str)
(rename-keys bounty-renames)
(update :value-usd usd-decimal->str)
(update :balance-eth eth-decimal->str)
(assoc :state (bounties/bounty-state with-claims)))))
(defn user-bounties [user]
(let [owner-bounties (bounties-db/owner-bounties (:id user))]
(->> owner-bounties
(map enrich-owner-bounties)
(map (juxt :issue_id identity))
(map (juxt :issue-id identity))
(into {}))))
(defn top-hunters []
@ -105,24 +125,9 @@
(map (fn [[tla balance]]
[tla (format-float bounty balance)]))
(into {})
(assoc bounty :tokens)))
renames {:user_name :display-name
:user_avatar_url :avatar-url
:issue_title :issue-title
:pr_title :pr-title
:pr_number :pr-number
:pr_id :pr-id
:type :item-type
:repo_name :repo-name
:repo_owner :repo-owner
:issue_number :issue-number
:issue_id :issue-id
:value_usd :value-usd
:claim_count :claim-count
:balance_eth :balance-eth
:user_has_address :user-has-address}]
(assoc bounty :tokens)))]
(map #(-> %
(rename-keys renames)
(rename-keys bounty-renames)
(update :value-usd usd-decimal->str)
(update :balance-eth eth-decimal->str)
update-token-values)

View File

@ -7,6 +7,7 @@
[commiteth.bounties :as bounties]
[commiteth.db
[issues :as issues]
[bounties :as db-bounties]
[pull-requests :as pull-requests]
[repositories :as repositories]
[users :as users]]
@ -133,19 +134,17 @@
;; merged
(cond
open-or-edit? (do
(log/info "PR with reference to bounty issue"
(:issue_number issue) "opened")
(log/infof "issue %s: PR with reference to bounty issue opened" (:issue_number issue))
(pull-requests/save (merge pr-data {:state :opened
:commit_sha head-sha})))
close? (if merged?
(do (log/info "PR with reference to bounty issue"
(:issue_number issue) "merged")
(do (log/infof "issue %s: PR with reference to bounty issue merged" (:issue_number issue))
(pull-requests/save
(merge pr-data {:state :merged
:commit_sha head-sha}))
(issues/update-commit-sha (:issue_id issue) head-sha))
(do (log/info "PR with reference to bounty issue"
(:issue_number issue) "closed with no merge")
(issues/update-commit-sha (:issue_id issue) head-sha)
(db-bounties/update-winner-login (:issue_id issue) login))
(do (log/infof "issue %s: PR with reference to bounty issue closed with no merge" (:issue_number issue))
(pull-requests/save
(merge pr-data {:state :closed
:commit_sha head-sha})))))))

View File

@ -88,7 +88,9 @@
(defn deploy-pending-contracts
"Under high-concurrency circumstances or in case geth is in defunct state, a bounty contract may not deploy successfully when the bounty label is addded to an issue. This function deploys such contracts."
"Under high-concurrency circumstances or in case geth is in defunct
state, a bounty contract may not deploy successfully when the bounty
label is addded to an issue. This function deploys such contracts."
[]
(p :deploy-pending-contracts
(doseq [{issue-id :issue_id
@ -118,10 +120,14 @@
tokens :tokens
winner-login :winner_login} (db-bounties/pending-bounties)]
(try
;; TODO(martin) delete this shortly after org-dashboard deploy
;; as we're now setting `winner_login` when handling a new claims
;; coming in via webhooks (see `commiteth.routes.webhooks/handle-claim`)
(db-bounties/update-winner-login issue-id winner-login)
(let [value (eth/get-balance-hex contract-address)]
(if (empty? payout-address)
(do
(log/warn "issue %s: Cannot sign pending bounty - winner has no payout address" issue-id)
(log/warn "issue %s: Cannot sign pending bounty - winner (%s) has no payout address" issue-id winner-login)
(github/update-merged-issue-comment owner
repo
comment-id
@ -135,7 +141,6 @@
:internal-tx-id [:execute issue-id]})]
(log/infof "issue %s: Payout self-signed, called sign-all(%s) tx: %s" issue-id contract-address payout-address (:tx-hash tx-info))
(tracker/track-tx! tx-info)
(db-bounties/update-winner-login issue-id winner-login)
(github/update-merged-issue-comment owner
repo
comment-id

View File

@ -1,66 +1,100 @@
(ns commiteth.util.crypto-fiat-value
(:require [mount.core :as mount]
[clj-time.core :as t]
[clojure.edn :as edn]
[clojure.tools.logging :as log]
[commiteth.config :refer [env]]
[commiteth.util.util :refer [json-api-request]]))
(defn fiat-api-provider []
(defn- fiat-api-provider []
(env :fiat-api-provider :coinmarketcap))
(defn get-token-usd-price-cryptonator
(defn- get-token-usd-price-cryptonator
"Get current USD value for a token using cryptonator API"
[tla]
(log/infof "%s: Getting price-data from Cryptonator" tla)
(let [token (subs (str tla) 1)
url (str "https://api.cryptonator.com/api/ticker/"
token
"-usd")
url (format "https://api.cryptonator.com/api/ticker/%s-usd" token)
m (json-api-request url)]
(-> (get-in m ["ticker" "price"])
(read-string))))
(edn/read-string))))
(def tla-to-id-mapping (atom {}))
(defn make-tla-to-id-mapping
(defn- make-tla-to-id-mapping
"Coinmarketcap API uses it's own IDs for tokens instead of TLAs"
[]
(let [data (json-api-request "https://api.coinmarketcap.com/v1/ticker/?limit=0")]
(into {} (map
(fn [x] [(keyword (get x "symbol")) (get x "id")])
data))))
(->> (json-api-request "https://api.coinmarketcap.com/v1/ticker/?limit=0")
(map (fn [x] [(keyword (get x "symbol")) (get x "id")]))
(into {})))
(defn get-token-usd-price-coinmarketcap
(defn- get-token-usd-price-coinmarketcap
"Get current USD value for a token using coinmarketcap API"
[tla]
(let [token-id (get @tla-to-id-mapping tla)
url (format "https://api.coinmarketcap.com/v1/ticker/%s" token-id)
data (json-api-request url)]
(-> (first data)
[tla token-id]
{:pre [(some? token-id)]}
(log/infof "%s: Getting price-data from CoinMarketCap (token-id %s)" tla token-id)
(-> (json-api-request (format "https://api.coinmarketcap.com/v1/ticker/%s" token-id))
(first)
(get "price_usd")
(read-string))))
(edn/read-string)))
(defn- get-price-fn []
(let [fns {:cryptonator get-token-usd-price-cryptonator
:coinmarketcap get-token-usd-price-coinmarketcap}]
(get fns (fiat-api-provider))))
(defrecord PriceInfo [tla usd date])
(defn bounty-usd-value
"Get current USD value of a bounty. bounty is a map of token-tla (keyword) to value"
[bounty]
(let [get-token-usd-price (get-price-fn)]
(reduce + (map (fn [[tla value]]
(let [usd-price (get-token-usd-price tla)]
(* usd-price value)))
bounty))))
(defn recent? [price-info]
(t/after? (:date price-info) (t/minus (t/now) (t/minutes 5))))
(defprotocol IFiatCryptoConverter
(start [_])
(convert-usd [_ tla amount]))
(defrecord FiatCryptoConverter [provider state]
IFiatCryptoConverter
(start [_]
(when (= :coinmarketcap provider)
(swap! state assoc :tla->id (make-tla-to-id-mapping))))
(convert-usd [_ tla amount]
(if-let [recent-price-info (and (some-> (get-in @state [:price-info tla]) recent?)
;; return actual price info
(get-in @state [:price-info tla]))]
(* (:usd recent-price-info) amount)
;; if we don't have price-info we need to fetch & store it
(let [price (case provider
:coinmarketcap (get-token-usd-price-coinmarketcap
tla
(get-in @state [:tla->id tla]))
:cryptonator (get-token-usd-price-cryptonator tla))]
(swap! state assoc-in [:price-info tla] (->PriceInfo tla price (t/now)))
(* price amount)))))
(mount/defstate
crypto-fiat-util
:start
(do
(reset! tla-to-id-mapping (make-tla-to-id-mapping))
(log/info "crypto-fiat-util started"))
:stop
(log/info "crypto-fiat-util stopped"))
fiat-converter
:start (let [provider (fiat-api-provider)]
(log/infof "Starting FiatCryptoConverter %s" provider)
(doto (->FiatCryptoConverter provider (atom {}))
(start)))
:stop (log/info "Stopping FiatCryptoConverter"))
;; public api ------------------------------------------------------------------
(defn bounty-usd-value
"Get current USD value for the crypto-amounts passed as argument.
Example: {:ETH 123, :SNT 456}"
[crypto-amounts]
(->> crypto-amounts
(map (fn [[tla value]]
(convert-usd fiat-converter tla value)))
(reduce + 0)))
(comment
(fiat-api-provider)
(def fc (->FiatCryptoConverter (fiat-api-provider) (atom {})))
(start fc)
(convert-usd fc :SNT 400)
(bounty-usd-value {:ETH 2 :ANT 2 :SNT 5})
(mount/start)
)

View File

@ -0,0 +1,38 @@
(ns commiteth.model.bounty
(:require [commiteth.util :as util]))
;; Most of the functions in here are currently intended for use inside the CLJS codebase
;; record maps look vastly different on frontend and backend due to simple things like kebab/camel
;; casing as well as more complex stuff like Postgres views shuffling data around
;; In the future we may want to establish Clojure records to assign names to the various
;; incarnations of maps we currently have adding the following functions to those records
;; via a protocol. Clojure records could also be serialized via transit making it easier
;; to communicate what datatypes are returned where.
(defn open? [claim]
(assert (find claim :pr_state))
(= 0 (:pr_state claim)))
(defn merged? [claim]
(assert (find claim :pr_state))
(= 1 (:pr_state claim)))
(defn paid? [claim]
(assert (find claim :payout_hash))
(not-empty (:payout_hash claim)))
(defn bot-confirm-unmined? [bounty]
(assert (find bounty :confirm_hash))
(empty? (:confirm_hash bounty)))
(defn confirming? [bounty]
(:confirming? bounty))
(defn issue-url
[bounty]
{:pre [(:repo-owner bounty) (:repo-name bounty) (:issue-number bounty)]}
(str "https://github.com/" (:repo-owner bounty) "/" (:repo-name bounty) "/issues/" (:issue-number bounty)))
(defn crypto-balances [bounty]
(assoc (:tokens bounty) :ETH (util/parse-float (:balance-eth bounty))))

View File

@ -0,0 +1,46 @@
(ns commiteth.ui.balances)
(defn tla-color
[tla]
{:pre [(string? tla)]}
(get {"ETH" "#57a7ed"} tla "#4360df"))
(defn balance-badge
[tla balance]
{:pre [(keyword? tla)]}
(let [tla (name tla)]
[:div.dib.ph2.pv1.relative
{:style {:color (tla-color tla)}}
[:div.absolute.top-0.left-0.right-0.bottom-0.o-10.br2
{:style {:background-color (tla-color tla)}}]
[:span.pg-med (str balance " " tla)]]))
(defn balance-label
[tla balance]
{:pre [(keyword? tla)]}
(let [tla (name tla)]
[:span.pg-med.fw5
{:style {:color (tla-color tla)}}
(str balance " " tla)]))
(defn usd-value-label [value-usd]
[:span
[:span.gray "Value "]
[:span.dark-gray (str "$" value-usd)]])
(defn token-balances
"Render ETH and token balances using the specified `style` (:label or :badge).
Non-positive balances will not be rendered. ETH will always be rendered first."
[crypto-balances style]
[:span ; TODO consider non DOM el react wrapping
(for [[tla balance] (-> (dissoc crypto-balances :ETH)
(seq)
(conj [:ETH (:ETH crypto-balances)]))
:when (pos? balance)]
^{:key tla}
[:div.dib.mr2
(case style
:label [balance-label tla balance]
:badge [balance-badge tla balance]
[balance-badge tla balance])])])

View File

@ -0,0 +1,19 @@
(ns commiteth.util)
(defn parse-float [x]
#?(:cljs (js/parseFloat x)
:clj (Float/parseFloat x)))
(defn assert-first [xs]
(assert (first xs) "assert-first failure")
(first xs))
(defn sum-maps
"Take a collection of maps and sum the numeric values for all keys in those maps."
[maps]
(let [sum-keys (fn sum-keys [r k v]
(update r k (fnil + 0) v))]
(reduce (fn [r m]
(reduce-kv sum-keys r m))
{}
maps)))

View File

@ -1,3 +0,0 @@
(ns commiteth.validation
(:require [bouncer.core :as b]
[bouncer.validators :as v]))

View File

@ -1,6 +1,8 @@
(ns commiteth.activity
(:require [re-frame.core :as rf]
[reagent.core :as r]
[commiteth.ui.balances :as ui-balances]
[commiteth.model.bounty :as bnt]
[commiteth.common :refer [human-time
items-per-page
display-data-page
@ -46,15 +48,10 @@
[:div.header.display-name display-name]
[:div.description
[item-description item]]
[:div.footer-row
[:div.footer-row.f6.lh-title.mt2
(when-not (= item-type "new-bounty")
[:div
[:div.balance-badge "ETH " balance-eth]
(for [[tla balance] tokens]
^{:key (random-uuid)}
[:div.balance-badge.token
(str (subs (str tla) 1) " " balance)])])
[:div.time (human-time updated)]]]])
[ui-balances/token-balances (bnt/crypto-balances item) :badge])
[:span.gray (human-time updated)]]]])
(defn activity-list [{:keys [items item-count page-number total-count]
:as activity-page-data}

View File

@ -9,6 +9,8 @@
[commiteth.handlers :as handlers]
[commiteth.db :as db]
[commiteth.ui-model :as ui-model]
[commiteth.ui.balances :as ui-balances]
[commiteth.model.bounty :as bnt]
[commiteth.subscriptions :as subs]
[commiteth.util :as util]))
@ -71,15 +73,12 @@
[:div.bounty-item-row
[:div.time (human-time updated)]
[:span.bounty-repo-label repo-link]]
[:div.footer-row
[:div.balance-badge "ETH " balance-eth]
(for [[tla balance] tokens]
^{:key (random-uuid)}
[:div.balance-badge.token
(str (subs (str tla) 1) " " balance)])
[:span.usd-value-label "Value "] [:span.usd-balance-label (str "$" value-usd)]
[:div.footer-row.f6.lh-title.mt2
[ui-balances/token-balances (bnt/crypto-balances bounty) :badge]
[:span.mr3
[ui-balances/usd-value-label (:value-usd bounty)]]
(when (> claim-count 0)
[:span.open-claims-label
[:span.dib.sob-blue.pointer
{:on-click (if matches-current-issue?
#(close-claims-click)
#(open-claims-click))}
@ -269,7 +268,7 @@
[:div.view-loading-container
[:div.ui.active.inverted.dimmer
[:div.ui.text.loader.view-loading-label "Loading"]]]
[:div.ui.container.open-bounties-container
[:div.ui.container.open-bounties-container.shadow-6
{:ref #(reset! container-element %1)}
[:div.open-bounties-header.ph4.pt4 "Bounties"]
[:div.open-bounties-filter-and-sort.ph4

View File

@ -75,18 +75,22 @@
(defn tabs [mobile?]
(let [user (rf/subscribe [:user])
owner-bounties (rf/subscribe [:owner-bounties])
current-page (rf/subscribe [:page])]
route (rf/subscribe [:route])]
(fn tabs-render []
(let [tabs [[:bounties (str (when-not @user "Open ") "Bounties")]
(let [route-id (:route-id @route)
tabs [[:bounties (str (when-not @user "Open ") "Bounties")]
[:activity "Activity"]
(when (seq @owner-bounties)
[:manage-payouts (str (when-not mobile? "Manage ") "Payouts")])
[:dashboard "Manage bounties"])
(when (:status-team-member? @user)
[:usage-metrics "Usage metrics"])]]
(into [:div.ui.attached.tabular.menu.tiny]
(for [[page caption] (remove nil? tabs)]
(let [props {:class (str "ui item"
(when (= @current-page page) " active"))
(if (= :dashboard page)
(when (contains? #{:dashboard :dashboard/to-confirm :dashboard/to-merge} route-id)
" active")
(when (= route-id page) " active")))
:on-click #(commiteth.routes/nav! page)}]
^{:key page} [:div props caption])))))))
@ -102,10 +106,11 @@
(let [user (rf/subscribe [:user])
flash-message (rf/subscribe [:flash-message])]
(fn []
[:div.vertical.segment.commiteth-header
[:div.vertical.segment.commiteth-header.bg-sob-tile
[:div.ui.grid.container.computer.tablet.only
[:div.four.wide.column
[header-logo]]
[:a {:href "/"}
[header-logo]]]
[:div.eight.wide.column.middle.aligned.computer.tablet.only.computer-tabs-container
[tabs false]]
[:div.four.wide.column.right.aligned.computer.tablet.only
@ -189,9 +194,9 @@
[:div.version-footer "version " [:a {:href (str "https://github.com/status-im/commiteth/commit/" version)} version]])]))
(defn page []
(let [current-page (rf/subscribe [:page])
show-top-hunters? #(contains? #{:bounties :activity} @current-page)]
(fn []
(let [route (rf/subscribe [:route])
show-top-hunters? #(contains? #{:bounties :activity} (:route-id @route))]
(fn page-render []
[:div.ui.pusher
[page-header]
[:div.ui.vertical.segment
@ -200,16 +205,18 @@
[:div {:class (str (if (show-top-hunters?) "eleven" "sixteen")
" wide computer sixteen wide tablet column")}
[:div.ui.container
(case @current-page
(case (:route-id @route)
:activity [activity-page]
:bounties [bounties-page]
:repos [repos-page]
:manage-payouts [manage-payouts-page]
(:dashboard
:dashboard/to-confirm
:dashboard/to-merge) [manage-payouts-page]
:settings [update-address-page]
:usage-metrics [usage-metrics-page])]]
(when (show-top-hunters?)
[:div.five.wide.column.computer.only
[:div.ui.container.top-hunters
[:div.ui.container.top-hunters.shadow-6
[:h3.top-hunters-header "Top 5 hunters"]
[:div.top-hunters-subheader "All time"]
[top-hunters]]])]]]
@ -239,10 +246,11 @@
(reset! active-user nil)))
(defn load-data [initial-load?]
(doall (map rf/dispatch
[[:load-open-bounties initial-load?]
(doseq [event [[:load-open-bounties initial-load?]
[:load-activity-feed initial-load?]
[:load-top-hunters initial-load?]]))
[:load-top-hunters initial-load?]
[:load-owner-bounties initial-load?]]]
(rf/dispatch event))
(load-user))
(defonce timer-id (r/atom nil))
@ -254,12 +262,12 @@
(mount-components))
(defn init! []
(commiteth.routes/setup-nav!)
(rf/dispatch-sync [:initialize-db])
(rf/dispatch [:initialize-web3])
(when config/debug?
(enable-re-frisk!))
(load-interceptors!)
(commiteth.routes/setup-nav!)
(load-data true)
(.addEventListener js/window "click" #(rf/dispatch [:clear-flash-message]))
(on-js-load))

View File

@ -70,7 +70,6 @@
(do
(println "Using injected Web3 constructor with current provider")
(new (aget js/window "web3" "constructor") (web3/current-provider injected-web3))))]
(println "web3" w3)
{:db (merge db {:web3 w3})})))
(reg-event-db
@ -81,8 +80,8 @@
(reg-event-db
:set-active-page
(fn [db [_ page params query]]
(assoc db :page page
:page-number 1
(assoc db :page-number 1
:route {:route-id page :params params :query query}
::db/open-bounties-filters
(reduce-kv
#(let [type (ui-model/query-param->bounty-filter-type %2)]
@ -223,6 +222,13 @@
:owner-bounties issues
:owner-bounties-loading? false)))
(reg-event-fx
:dashboard/mark-banner-as-seen
[(inject-cofx :store)]
(fn [{:keys [db store]} [_ banner-id]]
{:db (update-in db [:dashboard/seen-banners] (fnil conj #{}) banner-id)
:store (update-in store [:dashboard/seen-banners] (fnil conj #{}) banner-id)}))
(defn get-ls-token [db token]
(let [login (get-in db [:user :login])]
(get-in db [:tokens login token])))

View File

@ -1,91 +1,401 @@
(ns commiteth.manage-payouts
(:require [re-frame.core :as rf]
(:require [reagent.core :as r]
[re-frame.core :as rf]
[goog.string :as gstring]
[commiteth.util :as util]
[commiteth.routes :as routes]
[commiteth.model.bounty :as bnt]
[commiteth.ui.balances :as ui-balances]
[commiteth.common :as common :refer [human-time]]))
(defn pr-url [{owner :repo_owner
pr-number :pr_number
repo :repo_name}]
(str "https://github.com/" owner "/" repo "/pull/" pr-number))
(defn claim-card [bounty claim]
(let [{pr-state :pr_state
user-name :user_name
avatar-url :user_avatar_url
issue-id :issue_id
issue-title :issue_title} claim
merged? (= 1 (:pr_state claim))
paid? (not-empty (:payout_hash claim))
winner-login (:winner_login bounty)
bot-confirm-unmined? (empty? (:confirm_hash bounty))
confirming? (:confirming? bounty)
updated (:updated bounty)]
[:div.activity-item
[:div.ui.grid.container
[:div.left-column
[:div.ui.circular.image
[:img {:src avatar-url}]]]
[:div.content
[:div.header user-name]
[:div.description "Submitted a claim for " [:a {:href (pr-url claim)}
issue-title]]
[:div.description (if paid?
(str "(paid to " winner-login ")")
(str "(" (if merged? "merged" "open") ")"))]
[:div.time (human-time updated)]
[:button.ui.button
(merge (if (and merged? (not paid?))
(def primary-button-button :button.f7.ttu.tracked.outline-0.bg-sob-blue.white.pv3.ph4.pg-med.br3.bn.pointer.shadow-7)
(def primary-button-link :a.dib.tc.f7.ttu.tracked.bg-sob-blue.white.pv2.ph3.pg-med.br2.pointer.hover-white.shadow-7)
(defn bounty-card [{owner :repo-owner
repo-name :repo-name
issue-title :issue-title
issue-number :issue-number
updated :updated
tokens :tokens
balance-eth :balance-eth
value-usd :value-usd
:as bounty}
{:keys [style] :as opts}]
[:div
[:a {:href (bnt/issue-url bounty)}
[:div.cf
[:div.fl.w-80
[:span.pg-med.fw5.db.f4.dark-gray.hover-black
(gstring/truncate issue-title 110)]
#_[:div.mt2
[:span.f5.gray.pg-book (str owner "/" repo-name " #" issue-number)]]]
[:div.fl.w-20.tr
[:span.f6.gray.pg-book
{:on-click #(do (.preventDefault %) (prn (dissoc bounty :claims)))}
(common/human-time updated)]]]]])
(defn confirm-button [bounty claim]
(let [paid? (bnt/paid? claim)
merged? (bnt/merged? claim)]
(when (and merged? (not paid?))
[primary-button-button
(merge {:on-click #(rf/dispatch [:confirm-payout claim])}
(if (and merged? (not paid?) (:payout_address bounty))
{}
{:disabled true})
{:on-click #(rf/dispatch [:confirm-payout claim])}
(when (and (or confirming? bot-confirm-unmined?)
(when (and (or (bnt/confirming? bounty)
(bnt/bot-confirm-unmined? bounty))
merged?)
{:class "busy loading" :disabled true}))
(if paid?
"Signed off"
"Confirm")]]]]))
"Confirm Payment")])))
(defn confirm-row [bounty claim]
(let [payout-address-available? (:payout_address bounty)]
[:div
(when-not payout-address-available?
[:div.bg-sob-blue-o-20.pv2.ph3.br3.mb3.f6
[:p [:span.pg-med (or (:user_name claim) (:user_login claim))
"s payment address is pending."] " You will be able to confirm the payment once the address is provided."]])
[:div.cf
[:div.dt.fr
(when-not payout-address-available?
{:style {:-webkit-filter "grayscale(1)"
:pointer-events "none"}})
[:div.dtc.v-mid.pr3.f6
[:div
[ui-balances/token-balances (bnt/crypto-balances bounty) :badge]
[:div.dib.mr2.pv1
[ui-balances/usd-value-label (:value-usd bounty)]]]]
[:div.dtc.v-mid
[confirm-button bounty claim]]]]]))
(defn claim-list [bounties]
;; TODO: exclude bounties with no claims
(defn view-pr-button [claim]
[primary-button-link
{:href (pr-url claim)
:target "_blank"}
"View Pull Request"])
(defn claim-card [bounty claim {:keys [render-view-claim-button?] :as opts}]
(let [{user-name :user_name
user-login :user_login
avatar-url :user_avatar_url} claim
winner-login (:winner_login bounty)]
[:div.pv2
[:div.flex
{:class (when (and (bnt/paid? claim) (not (= user-login winner-login)))
"o-50")}
[:div.w3.flex-none.pr3.pl1.nl1
[:img.br-100.w-100.bg-white {:src avatar-url}]]
[:div.flex-auto
[:div
[:span.f5.dark-gray.pg-med.fw5
(or user-name user-login) " "
[:span.f6.o-60 (when user-name (str "@" user-login "") )]
(if (bnt/paid? claim)
(if (= user-login winner-login)
[:span "Received payout"]
[:span "No payout"]))]
[:div.f6.gray "Submitted a claim via "
[:a.gray {:href (pr-url claim)}
(str (:repo_owner claim) "/" (:repo_name claim) " PR #" (:pr_number claim))]]
;; We render the button twice for difference screen sizes, first button is for small screens:
;; 1) db + dn-ns: `display: block` + `display: none` for not-small screens
;; 2) dn + db-ns: `display: none` + `display: block` for not-small screens
(when render-view-claim-button?
[:div.mt2.db.dn-ns
(view-pr-button claim)])]]
(when render-view-claim-button?
[:div.dn.db-ns
[:div.w-100
(view-pr-button claim)]])]]))
(defn to-confirm-list [bounties]
(if (empty? bounties)
[:div.ui.text "No items"]
(into [:div.activity-item-container]
[:div.mb3.br3.shadow-6.bg-white.tc.pa5
[:h3.pg-book "Nothing to confirm"]
[:p "Here you will see the merged claims awaiting payment confirmation"]]
(into [:div]
;; FIXME we remove all bounties that Andy 'won' as this basically
;; has been our method for revocations. This needs to be cleaned up ASAP.
;; https://github.com/status-im/open-bounty/issues/284
(for [bounty (filter #(not= "andytudhope" (:winner_login %)) bounties)
;; Identifying the winning claim like this is a bit
;; imprecise if there have been two PRs for the same
;; bounty by the same contributor
;; Since the resulting payout is the same we can probably
;; ignore this edge case for a first version
:let [winning-claim (->> (:claims bounty)
(filter #(and (bnt/merged? %)
(= (:user_login %)
(:winner_login bounty))))
util/assert-first)]]
^{:key (:issue-id bounty)}
[:div.mb3.br3.shadow-6.bg-white
[:div.ph4.pt4
[bounty-card bounty]]
[:div.ph4.pv3
[claim-card bounty winning-claim]]
[:div.ph4.pv3.bg-sob-tint.br3.br--bottom
[confirm-row bounty winning-claim]]]))))
(defn to-merge-list [bounties]
(if (empty? bounties)
[:div.mb3.br3.shadow-6.bg-white.tc.pa5
[:h3.pg-book "Nothing to merge"]
[:p "Here you will see the claims waiting to be merged"]]
(into [:div]
(for [bounty bounties
claim (filter #(not (= 2 (:pr_state %))) ;; exclude closed
(:claims bounty))]
[claim-card bounty claim]))))
:let [claims (filter bnt/open? (:claims bounty))]]
^{:key (:issue-id bounty)}
[:div.mb3.shadow-6
[:div.pa4.nb2.bg-white.br3.br--top
[bounty-card bounty]]
[:div.ph4.pv3.bg-sob-tint.br3.br--bottom
[:span.f6.gray (if (second claims)
(str "Current Claims (" (count claims) ")")
"Current Claim")]
(for [[idx claim] (zipmap (range) claims)]
^{:key (:pr_id claim)}
[:div
{:class (when (> idx 0) "bt b--light-gray pt2")}
[claim-card bounty claim {:render-view-claim-button? true}]])]]))))
(defn bounty-stats [{:keys [paid unpaid]}]
[:div.cf
[:div.fl-ns.w-50-ns.tc.pv4
[:div.ttu.tracked "Open"]
[:div.f2.pa2 (common/usd-string (:combined-usd-value unpaid))]
[:div (:count unpaid) " bounties"]]
[:div.fl-ns.w-33-ns.tc.pv4
#_[:div.ttu.tracked "Paid"]
[:div.f3.pa2 (common/usd-string (:combined-usd-value paid))]
[:div.ph4 "Invested so far"]]
[:div.fl-ns.w-50-ns.tc.pv4
[:div.ttu.tracked "Paid"]
[:div.f2.pa2 (common/usd-string (:combined-usd-value paid))]
[:div (:count paid) " bounties"]]])
[:div.fl-ns.w-33-ns.tc.pv4
[:div.f3.pa2 (:count paid)]
[:div.ph4 "Bounties solved by contributors"]]
[:div.fl-ns.w-33-ns.tc.pv4
[:div.f3.pa2 (:count unpaid)]
[:div.ph4 "Open bounties in total"]]])
(defn bounty-stats-new [{:keys [paid unpaid]}]
(let [usd-stat (fn usd-stat [usd-amount]
[:div.dt
[:span.dtc.v-mid.pr1 "$"]
[:span.dtc.pg-med.fw5.mb2.dark-gray
{:class (if (< 100000000 usd-amount) "f3" "f2")}
(.toLocaleString usd-amount)]])]
[:div.br3.bg-white.shadow-6.pa4.dark-gray
[:span.db.mb3.f6 "Open for " [:span.dark-gray (:count unpaid) " bounties"]]
(usd-stat (:combined-usd-value unpaid))
[:div.f6.mt3
[ui-balances/token-balances (:crypto unpaid) :label]]
[:div.bb.b--near-white.mv3]
[:span.db.mb3.f6 "Paid for " (:count paid) " solved bounties"]
(usd-stat (:combined-usd-value paid))
[:div.f6.mt3
[ui-balances/token-balances (:crypto paid) :label]]]))
(def state-mapping
{:opened :open
:funded :funded
:claimed :claimed
:multiple-claims :claimed
:merged :merged
:pending-contributor-address :pending-contributor-address
:pending-maintainer-confirmation :pending-maintainer-confirmation
:paid :paid})
(defn bounty-title-link [bounty {:keys [show-date? max-length]}]
[:a.lh-title {:href (common/issue-url (:repo-owner bounty) (:repo-name bounty) (:issue-number bounty))}
[:div.w-100.overflow-hidden
[:span.db.f5.pg-med.dark-gray.hover-black
(cond-> (:issue-title bounty)
max-length (gstring/truncate max-length))]
(when show-date?
[:span.db.mt1.f6.gray.pg-book
(common/human-time (:updated bounty))])]])
(defn square-card
"A mostly generic component that renders a square with a section
pinned to the top and a section pinned to the bottom."
[top bottom]
[:div.aspect-ratio-l.aspect-ratio--1x1-l
[:div.bg-sob-tint.br3.shadow-6.pa3.aspect-ratio--object-l.flex-l.flex-column-l
[:div.flex-auto top]
[:div bottom]]])
(defn small-card-balances [bounty]
[:div.f6
[ui-balances/token-balances (bnt/crypto-balances bounty) :label]
[:div
[ui-balances/usd-value-label (:value-usd bounty)]]])
(defn unclaimed-bounty [bounty]
[:div.w-third-l.fl-l.pa2
[square-card
[bounty-title-link bounty {:show-date? true :max-length 60}]
[small-card-balances bounty]]])
(defn paid-bounty [bounty]
[:div.w-third-l.fl-l.pa2
[square-card
[:div
[bounty-title-link bounty {:show-date? false :max-length 60}]
[:div.f6.mt1.gray
"Paid out to " [:span.pg-med.fw5 "@" (:winner_login bounty)]]]
[small-card-balances bounty]]])
(defn expandable-bounty-list [bounty-component bounties]
(let [expanded? (r/atom false)]
(fn expandable-bounty-list-render [bounty-component bounties]
[:div
[:div.cf.nl2.nr2
(for [bounty (cond->> bounties
(not @expanded?) (take 3))]
^{:key (:issue-id bounty)}
[bounty-component bounty])]
(when (> (count bounties) 3)
[:div.tr
[:span.f5.sob-blue.pointer
{:role "button"
:on-click #(reset! expanded? (not @expanded?))}
(if @expanded?
"Collapse ↑"
"See all ↓")]])])))
(defn count-pill [n]
[:span.v-top.ml3.ph3.pv1.bg-black-05.gray.br3.f7 n])
(defn salute [name]
(let [msg-info (rf/subscribe [:dashboard/banner-msg])]
(fn salute-render [name]
(when @msg-info
[:div.relative.pa3.pr4.bg-sob-blue-o-20.br3.nt1
[:div.f3.dark-gray.absolute.top-0.right-0.pa3.b.pointer
{:role "button"
:on-click #(rf/dispatch [:dashboard/mark-banner-as-seen (:banner-id @msg-info)])}
"× "]
[:div
(case (:banner-id @msg-info)
"bounty-issuer-salute" [:p [:span.f4.mr2.v-mid "🖖"] [:span.pg-med "We salute you " (:name @msg-info) "!"]
" Here is where you can manage your bounties. Questions or comments? "
[:a.sob-blue.pg-med {:href "https://chat.status.im" :target "_blank"} "Chat with us"]]
"new-dashboard-info" [:p [:span.pg-med "NEW!"]
" Here is where you can manage your bounties. Questions or comments? "
[:a.sob-blue.pg-med {:href "https://chat.status.im"} "Chat with us"]])]]))))
(defn manage-bounties-title []
[:h1.f3.pg-med.fw5.dark-gray.mb3 "Manage bounties"])
(defn manage-bounties-nav [active-route-id]
(let [active-classes "dark-gray bb bw2 b--sob-blue"
non-active-classes "silver pointer"
tab :span.dib.f6.tracked.ttu.pg-med.mr3.ml2.pb2]
[:div.mv4.nl2
[tab
{:role "button"
:class (if (= active-route-id :dashboard/to-confirm) active-classes non-active-classes)
:on-click #(routes/nav! :dashboard/to-confirm)}
"To confirm payment"]
[tab
{:role "button"
:class (if (= active-route-id :dashboard/to-merge) active-classes non-active-classes)
:on-click #(routes/nav! :dashboard/to-merge)}
"To merge"]]))
(defn manage-payouts-loading []
[:div.center.mw9.pa2.pa0-l
[manage-bounties-title]
[manage-bounties-nav :dashboard/to-confirm]
[:div.w-two-thirds-l.mb6
;; This semantic UI loading spinner thing makes so many assumptions
;; severly limiting where and how it can be used.
;; TODO replace with React spinner library, CSS spinner or something else
[:div.ui.segment
[:div.ui.active.inverted.dimmer
[:div.ui.text.loader "Loading"]]]]])
(defn manage-payouts-page []
(let [owner-bounties (rf/subscribe [:owner-bounties])
(let [route (rf/subscribe [:route])
user (rf/subscribe [:user])
owner-bounties (rf/subscribe [:owner-bounties])
bounty-stats-data (rf/subscribe [:owner-bounties-stats])
owner-bounties-loading? (rf/subscribe [:get-in [:owner-bounties-loading?]])]
(fn []
(if @owner-bounties-loading?
[:container
[:div.ui.active.inverted.dimmer
[:div.ui.text.loader "Loading"]]]
(let [bounties (vals @owner-bounties)]
[:div.ui.container
(fn manage-payouts-page-render []
(cond
(nil? @user)
[:div.bg-white.br3.shadow-6.pa4.tc
[:h3 "Please log in to view this page."]]
(and
(empty? @owner-bounties)
(or (nil? @owner-bounties-loading?) @owner-bounties-loading?))
[manage-payouts-loading]
:else
(let [route-id (:route-id @route)
bounties (vals @owner-bounties)
grouped (group-by (comp state-mapping :state) bounties)
unclaimed (into (get grouped :funded)
(get grouped :open))
to-confirm (into (get grouped :pending-maintainer-confirmation)
(get grouped :pending-contributor-address))]
[:div.center.mw9.pa2.pa0-l
[manage-bounties-title]
[salute "Andy"]
[:div.dn-l.db-ns.mt4
[bounty-stats-new @bounty-stats-data]]
(when (nil? (common/web3))
[:div.ui.warning.message
[:i.warning.icon]
"To sign off claims, please view Status Open Bounty in Status, Mist or Metamask"])
[bounty-stats @bounty-stats-data]
[:h3 "New claims"]
[claim-list (filter (complement :paid?) bounties)]
[:h3 "Old claims"]
[claim-list (filter :paid? bounties)]])))))
[manage-bounties-nav route-id]
[:div.cf
[:div.fr.w-third.pl4.mb3.dn.db-l
[bounty-stats-new @bounty-stats-data]]
[:div.fr-l.w-two-thirds-l
(case route-id
:dashboard/to-confirm (->> to-confirm
(sort-by :updated >)
(to-confirm-list))
:dashboard/to-merge (->> (get grouped :claimed)
(sort-by :updated >)
(to-merge-list))
(cond
(seq to-confirm)
(routes/nav! :dashboard/to-confirm)
(seq (get grouped :claimed))
(routes/nav! :dashboard/to-merge)
:else (routes/nav! :dashboard/to-confirm)))
(let [heading :h4.f4.normal.pg-book.dark-gray]
[:div.mt5
[:div.mt4
[heading "Unclaimed bounties" (count-pill (count unclaimed))]
[expandable-bounty-list
unclaimed-bounty
(sort-by :updated > unclaimed)]]
[:div.mt4
[heading "Paid out bounties" (count-pill (count (get grouped :paid)))]
[expandable-bounty-list
paid-bounty
(sort-by :updated > (get grouped :paid))]]
#_[:div.mt4
[heading "Merged bounties" (count-pill (count (get grouped :merged)))]
[:p "I'm not sure why these exist. They have a :payout-address and a :confirm-hash so why are they missing the :payout-hash?"]
[expandable-bounty-list paid-bounty (get grouped :merged)]]
#_[:div.mt4
[:h4.f3 "Revoked bounties (" (count (get grouped :paid)) ")"]
[expandable-bounty-list unclaimed-bounty (get grouped :paid)]]])]
]
[:div.mb5]])))))

View File

@ -6,7 +6,9 @@
(bide/router [["/" :bounties]
["/activity" :activity]
["/repos" :repos]
["/manage-payouts" :manage-payouts]
["/dashboard" :dashboard]
["/dashboard/to-confirm" :dashboard/to-confirm]
["/dashboard/to-merge" :dashboard/to-merge]
["/settings" :settings]
["/usage-metrics" :usage-metrics]]))

View File

@ -1,6 +1,7 @@
(ns commiteth.subscriptions
(:require [re-frame.core :refer [reg-sub]]
[commiteth.db :as db]
[commiteth.util :as util]
[commiteth.ui-model :as ui-model]
[commiteth.common :refer [items-per-page]]
[clojure.string :as string]))
@ -10,9 +11,10 @@
(fn [db _] db))
(reg-sub
:page
:route
(fn [db _]
(:page db)))
(or (:route db)
{:route-id :bounties})))
(reg-sub
:user
@ -87,14 +89,38 @@
:owner-bounties-stats
:<- [:owner-bounties]
(fn [owner-bounties _]
(let [sum-dollars (fn sum-dollars [bounties]
(reduce + (map #(js/parseFloat (:value_usd %)) bounties)))
(let [sum-field (fn sum-field [field bounties]
(reduce + (map #(js/parseFloat (get % field)) bounties)))
sum-crypto (fn sum-crypto [bounties]
(-> (map :tokens bounties)
(util/sum-maps)
(assoc :ETH (sum-field :balance-eth bounties))))
{:keys [paid unpaid]} (group-by #(if (:paid? %) :paid :unpaid)
(vals owner-bounties))]
{:paid {:count (count paid)
:combined-usd-value (sum-dollars paid)}
:combined-usd-value (sum-field :value-usd paid)
:crypto (sum-crypto paid)}
:unpaid {:count (count unpaid)
:combined-usd-value (sum-dollars unpaid)}})))
:combined-usd-value (sum-field :value-usd unpaid)
:crypto (sum-crypto unpaid)}})))
(reg-sub
:dashboard/seen-banners
(fn [db _] (:dashboard/seen-banners db)))
(reg-sub
:dashboard/banner-msg
:<- [:user]
:<- [:dashboard/seen-banners]
(fn [[user seen-banners] _]
(cond
(not (contains? seen-banners "bounty-issuer-salute"))
{:name (or (some-> (:name user) (string/split #"\s") first)
(:login user))
:banner-id "bounty-issuer-salute"}
#_(not (contains? seen-banners "new-dashboard-info"))
#_{:banner-id "new-dashboard-info"})))
(reg-sub
:pagination

View File

@ -1,5 +0,0 @@
(ns commiteth.util
(:require [clojure.string :as string]))
(defn os-windows? []
(string/includes? (-> js/navigator .-platform) "Win"))

View File

@ -1,3 +1,4 @@
html,body { font-size: 16px }
@font-face {
font-family: PostGrotesk-Book;
@ -257,7 +258,6 @@ label[for="input-hidden"] {
}
.ui.attached.tabular {
background-color: #57a7ed;
border-bottom: none;
height: 80%;
.ui.item {
@ -817,24 +817,6 @@ label[for="input-hidden"] {
color: #8d99a4;
}
.usd-value-label {
padding-left: 5px;
font-size: 15px;
color: #8d99a4;
}
.usd-balance-label {
font-size: 15px;
color: #42505c;
}
.open-claims-label {
padding-left: 15px;
font-size: 15px;
color: #57a7ed;
cursor: pointer;
}
.activity-item-container {
padding: 1em;
}
@ -936,36 +918,6 @@ label[for="input-hidden"] {
}
}
.footer-row {
padding: 1em 0 0;
.balance-badge {
float: left;
margin-right: 1em;
}
}
.balance-badge {
color: #57a7ed;
background-color: rgba(87,167,237,.2);
font-family: "PostGrotesk-Medium";
font-weight: 500;
font-size: 13px;
line-height: 1.15;
width: auto;
min-width: 0;
height: auto;
min-height: 0;
line-height: 2em;
display: table;
padding: 0 10px 0;
letter-spacing: 1px;
border-radius: 8px;
&.token {
color: #4360df;
background-color: rgba(67,96,223,.2);
}
}
.fork-span {
padding-left: .5em;
}
@ -1208,7 +1160,7 @@ label[for="input-hidden"] {
}
body {
background-color: #eaecee;
background-color: #f2f5f8;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
@ -1312,3 +1264,6 @@ body {
color: #8d99a4;
padding-top: 20px;
}
@import (inline) "tachyons-utils.css";

View File

@ -0,0 +1,52 @@
.pg-book {font-family: "PostGrotesk-Book", sans-serif}
.pg-med {font-family: "PostGrotesk-Medium", sans-serif}
.shadow-7 {box-shadow: 0 2px 4px 0 rgba(22, 51, 81, 0.14)}
.shadow-6 {box-shadow: 0 5px 16px 0 rgba(230, 235, 238, 0.68)}
.bg-sob-tile {
background-image: url(/dest/img/new-site/SOB_tile4@2x.png);
background-size: 20px;
background-position: left top;
background-repeat: repeat;
}
/* Color classes are generated using https://tachyons-tldr.now.sh/#/tools */
/* all colors defined here: https://app.zeplin.io/project/59dd4d45719799b4220a2e49/styleguide */
/* TODO names need some updating */
.sob-blue { color: #57a7ed; }
.bg-sob-blue { background-color: #57a7ed; }
.bg-sob-blue-o-20 { background-color: rgba(87, 167, 237, 0.2); }
.b--sob-blue { border-color: #57a7ed; }
.hover-sob-blue:hover, .hover-sob-blue:focus { color: #57a7ed; }
.hover-bg-sob-blue:hover, .hover-bg-sob-blue:focus { background-color: #57a7ed; }
.sob-sky { color: #f2f5f8; }
.bg-sob-sky { background-color: #f2f5f8; }
.b--sob-sky { border-color: #f2f5f8; }
.hover-sob-sky:hover, .hover-sob-sky:focus { color: #f2f5f8; }
.hover-bg-sob-sky:hover, .hover-bg-sob-sky:focus { background-color: #f2f5f8; }
.sob-tint { color: #f7f9fa; }
.bg-sob-tint { background-color: #f7f9fa; }
.b--sob-tint { border-color: #f7f9fa; }
.hover-sob-tint:hover, .hover-sob-tint:focus { color: #f7f9fa; }
.hover-bg-sob-tint:hover, .hover-bg-sob-tint:focus { background-color: #f7f9fa; }
.gray { color: #8d99a4; }
.bg-gray { background-color: #8d99a4; }
.b--gray { border-color: #8d99a4; }
.hover-gray:hover, .hover-gray:focus { color: #8d99a4; }
.hover-bg-gray:hover, .hover-bg-gray:focus { background-color: #8d99a4; }
.dark-gray { color: #42505c; }
.bg-dark-gray { background-color: #42505c; }
.b--dark-gray { border-color: #42505c; }
.hover-dark-gray:hover, .hover-dark-gray:focus { color: #42505c; }
.hover-bg-dark-gray:hover, .hover-bg-dark-gray:focus { background-color: #42505c; }
/* Tachyons overrides */
.tracked { letter-spacing: .0625em } /* tachyons default: .1em */