├── .gitignore ├── .travis.yml ├── README.md ├── docs ├── actions.md ├── calculators.md └── stores.md ├── package.json ├── src ├── Actions.js ├── JekyllStoreEngine.js ├── Utils.js ├── calculators │ ├── Calculators.js │ ├── FixedCalculator.js │ ├── PercentCalculator.js │ └── TieredCalculator.js ├── mixins │ ├── Mixins.js │ ├── keptInStorage.js │ ├── listenAndMix.js │ └── resource.js ├── services │ ├── Services.js │ ├── Totals.js │ └── adjustOrder.js └── stores │ ├── AddressStore.js │ ├── BasketStore.js │ ├── CheckoutStore.js │ ├── CountriesStore.js │ ├── DeliveryMethodsStore.js │ ├── DeliveryStore.js │ ├── OrderStore.js │ ├── PaymentOptionsStore.js │ ├── ProductsStore.js │ └── Stores.js └── test ├── Actions.spec.js ├── calculators ├── FixedCalculator.spec.js ├── PercentCalculator.spec.js └── TieredCalculator.spec.js ├── mixins ├── keptInStorage.spec.js ├── listenAndMix.spec.js └── resource.spec.js ├── mocha.opts ├── services ├── Totals.spec.js └── adjustOrder.spec.js └── stores ├── AddressStore.spec.js ├── BasketStore.spec.js ├── CheckoutStore.spec.js ├── CountriesStore.spec.js ├── DeliveryMethodsStore.spec.js ├── DeliveryStore.spec.js ├── OrderStore.spec.js ├── PaymentOptionsStore.spec.js └── ProductsStore.spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '0.12' -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jekyll-Store/Engine 2 | 3 | [![Build Status](https://travis-ci.org/jekyll-store/engine.svg?branch=master)](https://travis-ci.org/jekyll-store/engine) 4 | 5 | Even though Jekyll-Store Engine was written with Jekyll and the Jekyll-Store solution in mind, it is decoupled from most implementation specifics and could just as easily serve the basis of any static-site ecommerce solution. It also has been written such that most of the code is reachable from the main `JekyllStoreEngine` object to enable extensibility. 6 | 7 | ## Actions 8 | 9 | As with all [Flux](https://github.com/facebook/flux) architectures, interaction in Jekyll-Store Engine flows uni-directionally from Actions to Stores and from Stores to any components listening to them. The following is a list of the actions: 10 | 11 | * `setItem` - Sets the quantity of an item in the basket. 12 | * `removeItem` - Removes an item from the basket. 13 | * `setAddress` - Sets the address, specifically the country. 14 | * `setDelivery` - Sets the delivery method to be used to deliver the order. 15 | * `loadProducts` - Loads products. 16 | * `loadCountries` - Loads countries. 17 | * `loadDeliveryMethods` - Loads delivery methods. 18 | * `setPaymentOptions` - Sets the payment options and purchase hook. 19 | * `purchase` - Processes order. 20 | * `completed` - Called when payment has been successully processed. 21 | * `refreshCheckout` - (Used internally). 22 | * `setErrors` - Can be used to set errors manually. 23 | 24 | For more information, consult the [Actions reference page](/docs/actions.md). 25 | 26 | ## Stores 27 | 28 | Stores are to be considered the absolute source of truth for the data they trigger. For the most part, data is immutable, using [seamless-immutable](https://github.com/rtfeldman/seamless-immutable) arrays and objects. As such, the triggered data cannot be changed by other listeners. The following is a list of the stores and what they publish: 29 | 30 | * `AddressStore` - The current address, specifically the country. 31 | * `BasketStore` - The items currently in the basket. 32 | * `CountriesStore` - The list of all countries. 33 | * `DeliveryMethodsStore` - The available delivery methods for the current address. 34 | * `DeliveryStore` - The currently selected delivery method and it's associated cost. 35 | * `OrderStore` - The current state of the order with totals, adjustments and errors. 36 | * `PaymentOptionsStore` - The payment options. 37 | * `ProductsStore` - The list of all products. 38 | * `CheckoutStore` - (Used internally). 39 | 40 | For more information, consult the [Stores reference page](/docs/stores.md). 41 | 42 | ## Calculators 43 | 44 | Calculators are simple functions that take an order and return a number, usually representing the cost for an adjustment, such as the delivery rate. The following is a list of the calculators: 45 | 46 | * `FixedCalculator` - Returns a fixed amount, regardless of the order. 47 | * `PercentCalculator` - Returns a percentage of one of the order's totals. 48 | * `TieredCalculator` - Returns an amount dependent on which tier one of the order's totals falls into. 49 | 50 | For more information, consult the [Calculators reference page](/docs/calculators.md). 51 | 52 | ## Plugins 53 | 54 | The following plugins are made to be used with Jekyll-Store Engine: 55 | 56 | * [Display](https://github.com/jekyll-store/display) 57 | * [Favourites](https://github.com/jekyll-store/favourites) 58 | * [Google-Analytics](https://github.com/jekyll-store/google-analytics) 59 | * [Visited](https://github.com/jekyll-store/visited) 60 | 61 | ## Contributing 62 | 63 | 1. [Fork it](https://github.com/jekyll-store/engine/fork) 64 | 2. Create your feature branch (`git checkout -b my-new-feature`) 65 | 3. Commit your changes (`git commit -am 'Add some feature'`) 66 | 4. Push to the branch (`git push origin my-new-feature`) 67 | 5. Create a new Pull Request 68 | -------------------------------------------------------------------------------- /docs/actions.md: -------------------------------------------------------------------------------- 1 | # Actions 2 | 3 | ## setItem 4 | 5 | Sets the quantity of an item in the basket. If the item is not in the basket, it is added. 6 | 7 | If quantity is negative, it is ignored. 8 | 9 | Args: 10 | 11 | * `name` - Unique name of product. 12 | * `quantity` - The quantity that it should be set to. 13 | 14 | Example: 15 | 16 | ```javascript 17 | JekyllStoreEngine.Actions.setItem({ name: 'Ironic T-Shirt', quantity: 2 }); 18 | ``` 19 | 20 | ## removeItem 21 | 22 | Removes an item from the basket. 23 | 24 | Args: 25 | 26 | * `name` - Unique name of product. 27 | 28 | Example: 29 | 30 | ```javascript 31 | JekyllStoreEngine.Actions.removeItem({ name: 'Ironic T-Shirt' }); 32 | ``` 33 | 34 | ## setAddress 35 | 36 | Sets the address. If the country cannot be found, it is set to the first country in the list. 37 | 38 | Args: 39 | 40 | * `country` - ISO code for country. 41 | 42 | Example: 43 | 44 | ```javascript 45 | JekyllStoreEngine.Actions.setAddress({ country: 'GF' }); 46 | ``` 47 | 48 | ## setDelivery 49 | 50 | Sets the delivery method to be used to deliver the order. If the method is unavailable for the current address, it is set to the first method available. If the method is not applicable for the current order, it cannot be purchased. 51 | 52 | Args: 53 | 54 | * `name` - Unique name for the delivery method. 55 | 56 | Example: 57 | 58 | ```javascript 59 | JekyllStoreEngine.Actions.setDelivery({ name: 'Express 24' }); 60 | ``` 61 | 62 | ## loadProducts 63 | 64 | Loads products. If any products are missing a name, or if any name is not unique, the action is ignored with a warning. 65 | 66 | Args: 67 | 68 | * `products` 69 | 70 | Example: 71 | 72 | ```javascript 73 | JekyllStoreEngine.Actions.loadProducts({ 74 | products: [ 75 | { name: 'Basil', price: 1.25 }, 76 | { name: 'Cinnamon', price: 1.78 }, 77 | { name: 'Ginger', price: 0.95 }, 78 | { name: 'Nutmeg', price: 1.30 } 79 | ] 80 | }); 81 | ``` 82 | 83 | ## loadCountries 84 | 85 | Loads countries. If any countries are missing an iso, or if any iso is not unique, the action is ignored with a warning. 86 | 87 | Args: 88 | 89 | * `countries` 90 | 91 | Example: 92 | 93 | ```javascript 94 | JekyllStoreEngine.Actions.loadCountries({ 95 | countries: [ 96 | { iso: 'KH', name: 'Cambodia', zones: ['Shipping'] }, 97 | { iso: 'AT', name: 'Austria', zones: ['Shipping'] }, 98 | { iso: 'GU', name: 'Guam', zones: ['Domestic'] } 99 | ] 100 | }); 101 | ``` 102 | 103 | ## loadDeliveryMethods 104 | 105 | Loads delivery methods. If any methods are missing a name, or if any name is not unique, the action is ignored with a warning. 106 | 107 | Args: 108 | 109 | * `methods` 110 | 111 | Example: 112 | 113 | ```javascript 114 | JekyllStoreEngine.Actions.loadDeliveryMethods({ 115 | methods: [ 116 | { 117 | name: 'Express', 118 | zones: ['Domestic'], 119 | calculator: 'Percent', 120 | args: { field: 'total', percent: 25 } 121 | }, 122 | { 123 | name: 'Tracked', 124 | zones: ['Domestic', 'Shipping'], 125 | calculator: 'Fixed', 126 | args: { amount: 13.50 } 127 | } 128 | ] 129 | }); 130 | ``` 131 | 132 | ## setPaymentOptions 133 | 134 | Sets the payment options. 135 | 136 | Args: 137 | 138 | * `currency` - [ISO 4217](http://en.wikipedia.org/wiki/ISO_4217) currency code. 139 | * `hook` - url for service to process order with card token 140 | 141 | Example: 142 | 143 | ```javascript 144 | JekyllStoreEngine.Actions.setPaymentOptions({ 145 | currency: 'USD', 146 | hook: 'http://my-payments-microserver.com/purchase' 147 | }); 148 | ``` 149 | 150 | ## purchase 151 | 152 | Processes order. All arguments are sent as part of the payload to the payment 153 | URL hook, along with a summary of the baskets contents, the delivery method, 154 | the cuurency and the order total. 155 | 156 | Example: 157 | 158 | ```javascript 159 | JekyllStoreEngine.Actions.purchase({ 160 | token: 'BE4653EB4EJJH97', 161 | cutomer: { 162 | name: 'Frank Abagnale', 163 | email: 'frankieSaysChilax@example.com' 164 | }, 165 | address: { 166 | address1: '45 Bloomsfield Crescent', 167 | city: 'Agloe', 168 | state: 'New York', 169 | country: 'US', 170 | zipcode: 'MN 55416' 171 | } 172 | }); 173 | ``` 174 | 175 | ## completed 176 | 177 | Called when payment has been successully processed. Triggers BasketStore to clear the session. 178 | 179 | ## refreshCheckout 180 | 181 | Used internally to allow adjustors to call for checkout adjustments to be recalculated. 182 | 183 | ## setErrors 184 | 185 | Can be used to set errors manually. 186 | 187 | Args: 188 | 189 | * `errors` 190 | * `mutate` - Set to true to keep the errors until manually removed. 191 | 192 | Example: 193 | 194 | ```javascript 195 | JekyllStoreEngine.Actions.setErrors({ 196 | errors: ['Card code is not recognized'], 197 | mutate: false 198 | }); 199 | ``` 200 | -------------------------------------------------------------------------------- /docs/calculators.md: -------------------------------------------------------------------------------- 1 | # Calculators 2 | 3 | ## FixedCalculator 4 | 5 | Returns a fixed amount, regardless of the order. 6 | 7 | Example: 8 | 9 | ```javascript 10 | JekyllStoreEngine.Calculators.Fixed({ amount: 3.50 }); 11 | ``` 12 | 13 | ## PercentCalculator 14 | 15 | Returns a percentage of one of the order's totals. 16 | 17 | Example: 18 | 19 | ```javascript 20 | JekyllStoreEngine.Calculators.Percent({ field: 'price', percent: 25 }); 21 | ``` 22 | 23 | ## TieredCalculator 24 | 25 | Returns an amount dependent on which tier one of the order's totals falls into. 26 | 27 | Example: 28 | 29 | ```javascript 30 | JekyllStoreEngine.Calculators.Tiered({ 31 | field: 'volume', 32 | tiers: [ 33 | [0, 3.29], // 0 < volume <= 0.5, it returns 3.29 34 | [0.5, 4.59], // 0.5 < volume <= 1.2, it returns 4.59 35 | [1.2] // volume > 1.2, it returns undefined 36 | ] 37 | }); 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/stores.md: -------------------------------------------------------------------------------- 1 | # Stores 2 | 3 | ## AddressStore 4 | 5 | The current address, specifically the country. 6 | 7 | Example output: 8 | 9 | ```javascript 10 | { 11 | country: Immutable({ 12 | iso: 'AD', 13 | name: 'Andorra', 14 | zones: ['International', 'Europe'] 15 | }) 16 | } 17 | ``` 18 | 19 | ## BasketStore 20 | 21 | The items currently in the basket. 22 | 23 | Example output: 24 | 25 | ```javascript 26 | { 27 | basket: Immutable({ 28 | 'bag': { name: 'bag', price: 2.45, quantity: 3, subtotal: 7.35 }, 29 | 'shoe': { name: 'shoe', price: 14.89, quantity: 1, subtotal: 14.89 } 30 | }) 31 | } 32 | ``` 33 | 34 | ## CountriesStore 35 | 36 | The list of all countries. 37 | 38 | Example output: 39 | 40 | ```javascript 41 | { 42 | countries: Immutable({ 43 | 'KH': { iso: 'KH', name: 'Cambodia', zones: ['Shipping'] }, 44 | 'AT': { iso: 'AT', name: 'Austria', zones: ['Shipping'] }, 45 | 'GU': { iso: 'GU', name: 'Guam', zones: ['Domestic'] } 46 | }) 47 | } 48 | ``` 49 | 50 | ## DeliveryMethodsStore 51 | 52 | The available delivery methods for the current address. 53 | 54 | Example output: 55 | 56 | ```javascript 57 | { 58 | methods: Immutable({ 59 | 'Express': { name: 'Express', zones: ['Domestic'], calculator: [function] }, 60 | 'Tracked': { name: 'Tracked', zones: ['Domestic', 'Shipping'], calculator: [function] } 61 | }) 62 | } 63 | ``` 64 | 65 | ## DeliveryStore 66 | 67 | The currently selected delivery method and it's associated cost. 68 | 69 | Example output: 70 | 71 | ```javascript 72 | { 73 | delivery: Immutable({ name: 'Express', amount: 5.48 }) 74 | } 75 | ``` 76 | 77 | ## OrderStore 78 | 79 | The current state of the order with totals, adjustments and errors. 80 | 81 | Example output: 82 | 83 | ```javascript 84 | { 85 | order: Immutable({ 86 | totals: { price: 5.30, weight: 1500, order: 7.80 }, 87 | delivery: 'Second Class', 88 | errors: ['Card is no longer valid or has expired'], 89 | adjustments: [{ label: 'Second Class', amount: 2.50 }] 90 | }) 91 | } 92 | ``` 93 | 94 | ## PaymentOptionsStore 95 | 96 | The payment options. 97 | 98 | Example output: 99 | 100 | ```javascript 101 | { 102 | paymentOptions: Immutable({ 103 | currency: 'USD', 104 | hook: 'http://my-payments-server.com/purchase' 105 | }) 106 | } 107 | 108 | ``` 109 | 110 | ## ProductsStore 111 | 112 | The list of all products. 113 | 114 | Example output: 115 | 116 | ```javascript 117 | { 118 | products: Immutable({ 119 | 'Crocs': { name: 'Crocs', price: 88.00 }, 120 | 'Sandals': { name: 'Sandals', price: 5.25 }, 121 | 'Slippers': { name: 'Slippers', price: 45.50 } 122 | }) 123 | } 124 | ``` 125 | 126 | ## CheckoutStore 127 | 128 | (Used internally). 129 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jekyll-store-engine", 3 | "main": "./src/JekyllStoreEngine.js", 4 | "version": "0.3.2", 5 | "description": "Static ecommerce business logic written with Reflux.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/jekyll-store/engine.git" 9 | }, 10 | "dependencies": { 11 | "big.js": "^3.0.1", 12 | "reflux": "^0.2.7", 13 | "seamless-immutable": "^2.3.2", 14 | "superagent": "^1.1.0" 15 | }, 16 | "devDependencies": { 17 | "chai": "^2.1.2", 18 | "mocha": "^2.2.1", 19 | "sinon": "^1.14.1" 20 | }, 21 | "scripts": { 22 | "test": "mocha" 23 | }, 24 | "author": "Max White", 25 | "license": "ISC" 26 | } 27 | -------------------------------------------------------------------------------- /src/Actions.js: -------------------------------------------------------------------------------- 1 | var Reflux = require('reflux'); 2 | 3 | var Actions = Reflux.createActions([ 4 | 'setItem', 5 | 'removeItem', 6 | 'setAddress', 7 | 'setDelivery', 8 | 'loadProducts', 9 | 'loadCountries', 10 | 'loadDeliveryMethods', 11 | 'setPaymentOptions', 12 | 'purchase', 13 | 'completed', 14 | 'refreshCheckout', 15 | 'setErrors' 16 | ]); 17 | 18 | Actions.setItem.shouldEmit = function(args) { return args.quantity >= 0; }; 19 | 20 | Actions.loadProducts.shouldEmit = function(args) { 21 | return keyCheck(args.products, 'name'); 22 | }; 23 | 24 | Actions.loadCountries.shouldEmit = function(args) { 25 | return keyCheck(args.countries, 'iso'); 26 | }; 27 | 28 | Actions.loadDeliveryMethods.shouldEmit = function(args) { 29 | return keyCheck(args.methods, 'name'); 30 | }; 31 | 32 | function keyCheck(arr, keyField) { 33 | var keys = []; 34 | 35 | for(var i = 0; i < arr.length; i ++) { 36 | var key = arr[i][keyField]; 37 | 38 | if(!key) { 39 | console.warn('Key Check failed: Expected ' + keyField); 40 | return false; 41 | } 42 | 43 | if(keys.indexOf(key) >= 0) { 44 | console.warn('Key Check failed: "' + key + '" is not a unique ' + keyField); 45 | return false; 46 | } 47 | 48 | keys.push(key); 49 | } 50 | 51 | return true; 52 | } 53 | 54 | module.exports = Actions; 55 | -------------------------------------------------------------------------------- /src/JekyllStoreEngine.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Actions: require('./Actions'), 3 | Calculators: require('./calculators/Calculators'), 4 | Mixins: require('./mixins/Mixins'), 5 | Services: require('./services/Services'), 6 | Stores: require('./stores/Stores'), 7 | Utils: require('./Utils') 8 | }; 9 | -------------------------------------------------------------------------------- /src/Utils.js: -------------------------------------------------------------------------------- 1 | exports.first = function(obj) { for(var k in obj) { return obj[k]; } }; 2 | 3 | exports.mapping = function(key, value) { 4 | var obj = {}; 5 | obj[key] = value; 6 | return obj; 7 | }; 8 | 9 | exports.Session = { 10 | set: function(k, v) { 11 | if(this.safe) { localStorage.setItem(k, JSON.stringify(v)); } 12 | }, 13 | get: function(k) { 14 | if(this.safe) { return JSON.parse(localStorage.getItem(k)); } 15 | }, 16 | safe: typeof(localStorage) !== "undefined" 17 | }; 18 | 19 | exports.intersects = function(arr1, arr2) { 20 | for(var i = 0; i < arr1.length; i++) { 21 | if(arr2.indexOf(arr1[i]) >= 0) { return true; } 22 | } 23 | return false; 24 | }; 25 | -------------------------------------------------------------------------------- /src/calculators/Calculators.js: -------------------------------------------------------------------------------- 1 | var Calculators = { 2 | Fixed: require('./FixedCalculator'), 3 | Percent: require('./PercentCalculator'), 4 | Tiered: require('./TieredCalculator') 5 | }; 6 | 7 | module.exports = Calculators; -------------------------------------------------------------------------------- /src/calculators/FixedCalculator.js: -------------------------------------------------------------------------------- 1 | function FixedCalculator(args) { 2 | return function(order) { return args.amount; }; 3 | } 4 | 5 | module.exports = FixedCalculator; -------------------------------------------------------------------------------- /src/calculators/PercentCalculator.js: -------------------------------------------------------------------------------- 1 | var B = require('big.js'); 2 | 3 | function PercentCalculator(args) { 4 | var ratio = B(args.percent).div(100); 5 | return function(order) { return +ratio.times(order.totals[args.field]); }; 6 | } 7 | 8 | module.exports = PercentCalculator; -------------------------------------------------------------------------------- /src/calculators/TieredCalculator.js: -------------------------------------------------------------------------------- 1 | function TieredCalculator(args) { 2 | return function(order) { 3 | var value = order.totals[args.field]; 4 | 5 | for(var i = 0; i < args.tiers.length; i++) { 6 | var lowerbound = args.tiers[i][0]; 7 | 8 | if(lowerbound <= value) { 9 | var upperbound = (args.tiers[i + 1] || [])[0]; 10 | 11 | if(!upperbound || value < upperbound) { 12 | return args.tiers[i][1]; 13 | } 14 | } 15 | } 16 | }; 17 | } 18 | 19 | module.exports = TieredCalculator; -------------------------------------------------------------------------------- /src/mixins/Mixins.js: -------------------------------------------------------------------------------- 1 | var Mixins = { 2 | keptInStorage: require('./keptInStorage'), 3 | listenAndMix: require('./listenAndMix'), 4 | resource: require('./resource') 5 | }; 6 | 7 | module.exports = Mixins; 8 | -------------------------------------------------------------------------------- /src/mixins/keptInStorage.js: -------------------------------------------------------------------------------- 1 | // Includes 2 | var I = require('seamless-immutable'); 3 | var m = require('../Utils').mapping; 4 | 5 | function keptInStorage(key, defaultValue) { 6 | return { 7 | // Public 8 | init: function() { this[key] = I(this.session.get(key) || defaultValue); }, 9 | getInitialState: function() { return m(key, this[key]); }, 10 | 11 | // Private 12 | session: require('../Utils').Session, 13 | update: function() { 14 | this.session.set(key, this[key]); 15 | this.trigger(m(key, this[key])); 16 | } 17 | }; 18 | } 19 | 20 | module.exports = keptInStorage; 21 | -------------------------------------------------------------------------------- /src/mixins/listenAndMix.js: -------------------------------------------------------------------------------- 1 | function listenAndMix(store, callback) { 2 | return { 3 | init: function() { this.listenTo(store, this._mixAndUpdate, this._mix); }, 4 | _mix: function(obj) { for(var key in obj) { this[key] = obj[key]; } }, 5 | _mixAndUpdate: function(obj) { 6 | this._mix(obj); 7 | if(callback) { this[callback](); } 8 | } 9 | }; 10 | } 11 | 12 | module.exports = listenAndMix; -------------------------------------------------------------------------------- /src/mixins/resource.js: -------------------------------------------------------------------------------- 1 | //Includes 2 | var I = require('seamless-immutable'); 3 | var m = require('../Utils').mapping; 4 | 5 | function resource(resName) { 6 | var mixin = { 7 | // Public 8 | listenables: [require('../Actions')], 9 | getInitialState: function() { return m(resName, this[resName]); }, 10 | 11 | // Protected 12 | toLookUp: function(primaryKey, args) { 13 | var lookUp = {}; 14 | 15 | for(var i = 0; i < args[resName].length; i++) { 16 | var el = args[resName][i]; 17 | lookUp[el[primaryKey]] = el; 18 | } 19 | 20 | this[resName] = I(lookUp); 21 | this.trigger(m(resName, this[resName])); 22 | } 23 | }; 24 | 25 | mixin[resName] = I({}); 26 | return mixin; 27 | } 28 | 29 | module.exports = resource; 30 | -------------------------------------------------------------------------------- /src/services/Services.js: -------------------------------------------------------------------------------- 1 | var Services = { 2 | adjustOrder: require('./adjustOrder'), 3 | Totals: require('./Totals') 4 | }; 5 | 6 | module.exports = Services; 7 | -------------------------------------------------------------------------------- /src/services/Totals.js: -------------------------------------------------------------------------------- 1 | // Includes 2 | var I = require('seamless-immutable'); 3 | var B = require('big.js'); 4 | 5 | var Totals = { 6 | // Public 7 | fields: ['price', 'weight'], 8 | accumulate: function(basket) { 9 | var totals = {}; 10 | 11 | t.fields.forEach(function(field) { 12 | totals[field] = 0; 13 | 14 | for(var name in basket) { 15 | var item = basket[name]; 16 | 17 | if(item[field]) { 18 | totals[field] = +B(item[field]).times(item.quantity).plus(totals[field]); 19 | } 20 | } 21 | }); 22 | 23 | return I(totals); 24 | } 25 | }; 26 | 27 | var t = module.exports = Totals; -------------------------------------------------------------------------------- /src/services/adjustOrder.js: -------------------------------------------------------------------------------- 1 | var I = require('seamless-immutable'); 2 | var B = require('big.js'); 3 | 4 | function adjustOrder(order, label, amount) { 5 | var adjustments = order.adjustments.concat({ label: label, amount: amount }) 6 | var total = +B(order.totals.order).plus(amount); 7 | var totals = order.totals.merge({ order: total }); 8 | return order.merge({ adjustments: adjustments, totals: totals }); 9 | } 10 | 11 | module.exports = adjustOrder; -------------------------------------------------------------------------------- /src/stores/AddressStore.js: -------------------------------------------------------------------------------- 1 | // Includes 2 | var Reflux = require('reflux'); 3 | var I = require('seamless-immutable'); 4 | var listenAndMix = require('../mixins/listenAndMix'); 5 | var first = require('../Utils').first; 6 | 7 | var AddressStore = Reflux.createStore({ 8 | // Public 9 | mixins: [ 10 | listenAndMix(require('./CountriesStore'), 'update'), 11 | listenAndMix(require('../Actions').setAddress, 'update') 12 | ], 13 | getInitialState: function() { return t.address(); }, 14 | 15 | // Private 16 | update: function() { t.trigger(t.address()); }, 17 | address: function() { 18 | return { country: t.countries[t.country] || first(t.countries) || I({}) }; 19 | } 20 | }); 21 | 22 | var t = module.exports = AddressStore; -------------------------------------------------------------------------------- /src/stores/BasketStore.js: -------------------------------------------------------------------------------- 1 | // Includes 2 | var Reflux = require('reflux'); 3 | var listenAndMix = require('../mixins/listenAndMix'); 4 | var keptInStorage = require('../mixins/keptInStorage'); 5 | var m = require('../Utils').mapping; 6 | var B = require('big.js'); 7 | 8 | var BasketStore = Reflux.createStore({ 9 | // Public 10 | listenables: [require('../Actions')], 11 | mixins: [ 12 | listenAndMix(require('./ProductsStore')), 13 | keptInStorage('basket', {}) 14 | ], 15 | 16 | onRemoveItem: function(args) { 17 | t.basket = t.basket.without(args.name); 18 | t.update(); 19 | }, 20 | 21 | onSetItem: function(args) { 22 | var item = t.basket[args.name] || t.products[args.name]; 23 | var subtotal = +B(item.price).times(args.quantity); 24 | item = item.merge({ quantity: args.quantity, subtotal: subtotal }); 25 | t.basket = t.basket.merge(m(args.name, item)); 26 | t.update(); 27 | }, 28 | 29 | onCompleted: function() { t.session.set('basket', {}); } 30 | }); 31 | 32 | var t = module.exports = BasketStore; 33 | -------------------------------------------------------------------------------- /src/stores/CheckoutStore.js: -------------------------------------------------------------------------------- 1 | // Includes 2 | var Reflux = require('reflux'); 3 | var I = require('seamless-immutable'); 4 | var listenAndMix = require('../mixins/listenAndMix'); 5 | var Totals = require('../services/Totals'); 6 | 7 | var CheckoutStore = Reflux.createStore({ 8 | // Public 9 | listenables: [require('../Actions')], 10 | adjustors: [require('./DeliveryStore')], 11 | mixins: [listenAndMix(require('./BasketStore'), 'update')], 12 | getInitialState: function() { return t.checkout(); }, 13 | onRefreshCheckout: function() { t.update(); }, 14 | 15 | // Private 16 | update: function() { t.trigger(t.checkout()); }, 17 | 18 | checkout: function() { 19 | var order = t.newOrder(); 20 | t.adjustors.forEach(function(adjustor) { order = adjustor.adjust(order); }); 21 | return { order: order }; 22 | }, 23 | 24 | newOrder: function() { 25 | var totals = Totals.accumulate(t.basket); 26 | totals = totals.merge({ order: totals.price }); 27 | return I({ adjustments: [], errors: [], totals: totals }); 28 | } 29 | }); 30 | 31 | var t = module.exports = CheckoutStore; 32 | -------------------------------------------------------------------------------- /src/stores/CountriesStore.js: -------------------------------------------------------------------------------- 1 | // Includes 2 | var Reflux = require('reflux'); 3 | var resource = require('../mixins/resource'); 4 | 5 | var CountriesStore = Reflux.createStore({ 6 | // Public 7 | mixins: [resource('countries')], 8 | onLoadCountries: function(args) { this.toLookUp('iso', args); } 9 | }); 10 | 11 | module.exports = CountriesStore; 12 | -------------------------------------------------------------------------------- /src/stores/DeliveryMethodsStore.js: -------------------------------------------------------------------------------- 1 | // Includes 2 | var Reflux = require('reflux'); 3 | var I = require('seamless-immutable'); 4 | var listenAndMix = require('../mixins/listenAndMix'); 5 | var m = require('../Utils').mapping; 6 | var intersects = require('../Utils').intersects; 7 | 8 | var DeliveryMethodsStore = Reflux.createStore({ 9 | // Public 10 | listenables: [require('../Actions')], 11 | mixins: [listenAndMix(require('./AddressStore'), 'update')], 12 | getInitialState: function() { return { methods: t.available }; }, 13 | onLoadDeliveryMethods: function(args) { 14 | args.methods.forEach(function(method) { 15 | t.methods = t.methods.merge(m(method.name, { 16 | name: method.name, 17 | zones: method.zones, 18 | calculator: t.calculators[method.calculator](method.args) 19 | })); 20 | }); 21 | 22 | t.update(); 23 | }, 24 | 25 | //Private 26 | methods: I({}), 27 | available: I({}), 28 | calculators: require('../calculators/Calculators'), 29 | update: function() { 30 | var zones = t.country.zones || []; 31 | t.available = {}; 32 | 33 | for(var name in t.methods) { 34 | if(intersects(t.methods[name].zones, zones)) { 35 | t.available[name] = t.methods[name]; 36 | } 37 | } 38 | 39 | t.available = I(t.available); 40 | t.trigger({ methods: t.available }); 41 | } 42 | }); 43 | 44 | var t = module.exports = DeliveryMethodsStore; -------------------------------------------------------------------------------- /src/stores/DeliveryStore.js: -------------------------------------------------------------------------------- 1 | // Includes 2 | var Reflux = require('reflux'); 3 | var I = require('seamless-immutable'); 4 | var listenAndMix = require('../mixins/listenAndMix'); 5 | var adjustOrder = require('../services/adjustOrder'); 6 | var first = require('../Utils').first; 7 | var Actions = require('../Actions'); 8 | 9 | var DeliveryStore = Reflux.createStore({ 10 | // Public 11 | mixins: [ 12 | listenAndMix(require('./DeliveryMethodsStore'), 'update'), 13 | listenAndMix(Actions.setDelivery, 'update') 14 | ], 15 | Errors: { 16 | UNDELIVERABLE: 'Unfortunately, we can not deliver to this address.', 17 | NOT_APPLICABLE: 'Delivery service is not available for your order.' 18 | }, 19 | getInitialState: function() { return { delivery: t.delivery }; }, 20 | adjust: function(order) { 21 | var method = t.methods[t.delivery] || first(t.methods); 22 | if(!method) { return t.orderWithError(order, t.Errors.UNDELIVERABLE); } 23 | 24 | var amount = method.calculator(order); 25 | t.delivery = I({ name: method.name, amount: amount }); 26 | t.trigger({ delivery: t.delivery }); 27 | 28 | return amount ? 29 | adjustOrder(order, method.name, amount).merge({ delivery: method.name }) : 30 | t.orderWithError(order, t.Errors.NOT_APPLICABLE); 31 | }, 32 | 33 | // Private 34 | update: function() { Actions.refreshCheckout(); }, 35 | orderWithError: function(order, error) { 36 | return order.merge({ errors: order.errors.concat(error) }); 37 | } 38 | }); 39 | 40 | var t = module.exports = DeliveryStore; 41 | -------------------------------------------------------------------------------- /src/stores/OrderStore.js: -------------------------------------------------------------------------------- 1 | // Includes 2 | var Reflux = require('reflux'); 3 | var I = require('seamless-immutable'); 4 | var B = require('big.js'); 5 | var listenAndMix = require('../mixins/listenAndMix'); 6 | 7 | var OrderStore = Reflux.createStore({ 8 | // Public 9 | listenables: [require('../Actions')], 10 | mixins: [ 11 | listenAndMix(require('./PaymentOptionsStore')), 12 | listenAndMix(require('./BasketStore')), 13 | listenAndMix(require('./CheckoutStore'), 'update') 14 | ], 15 | getInitialState: function() { return { order: t.order }; }, 16 | onPurchase: function(payload) { 17 | if(t.order.errors.length > 0) { return; } 18 | 19 | payload = I(payload).merge({ 20 | basket: t.minimalBasket(), 21 | delivery: t.order.delivery, 22 | currency: t.paymentOptions.currency, 23 | total: t.order.totals.order 24 | }); 25 | 26 | t.request 27 | .post(t.paymentOptions.hook) 28 | .send(payload) 29 | .end(function(err, response) { 30 | if(err) { 31 | t.onSetErrors({ errors: [t.parseError(err)] }); 32 | } else { 33 | t.completed(response.body); 34 | } 35 | }); 36 | }, 37 | onSetErrors: function(args) { 38 | var order = t.order.merge({ errors: args.errors }); 39 | if(args.mutate) { t.order = order; } 40 | t.trigger({ order: order }); 41 | }, 42 | 43 | // Private 44 | update: function() { t.trigger({ order: t.order }); }, 45 | 46 | minimalBasket: function() { 47 | var minBask = {}; 48 | for(var name in t.basket) { minBask[name] = t.basket[name].quantity; } 49 | return minBask; 50 | }, 51 | 52 | parseError: function(error) { 53 | return error.response && error.response.body && error.response.body.message; 54 | } 55 | }); 56 | 57 | OrderStore.request = require('superagent'); 58 | OrderStore.completed = require('../Actions').completed; 59 | 60 | var t = module.exports = OrderStore; 61 | -------------------------------------------------------------------------------- /src/stores/PaymentOptionsStore.js: -------------------------------------------------------------------------------- 1 | // Includes 2 | var Reflux = require('reflux'); 3 | var I = require('seamless-immutable'); 4 | var resource = require('../mixins/resource'); 5 | 6 | var PaymentOptionsStore = Reflux.createStore({ 7 | // Public 8 | mixins: [resource('paymentOptions')], 9 | onSetPaymentOptions: function(args) { 10 | t.paymentOptions = I(args); 11 | t.trigger({ paymentOptions: t.paymentOptions }); 12 | } 13 | }); 14 | 15 | var t = module.exports = PaymentOptionsStore; 16 | -------------------------------------------------------------------------------- /src/stores/ProductsStore.js: -------------------------------------------------------------------------------- 1 | // Includes 2 | var Reflux = require('reflux'); 3 | var resource = require('../mixins/resource'); 4 | 5 | var ProductsStore = Reflux.createStore({ 6 | // Public 7 | mixins: [resource('products')], 8 | onLoadProducts: function(args) { this.toLookUp('name', args); } 9 | }); 10 | 11 | module.exports = ProductsStore; 12 | -------------------------------------------------------------------------------- /src/stores/Stores.js: -------------------------------------------------------------------------------- 1 | var Stores = { 2 | Address: require('./AddressStore'), 3 | Basket: require('./BasketStore'), 4 | Checkout: require('./CheckoutStore'), 5 | Countries: require('./CountriesStore'), 6 | DeliveryMethods: require('./DeliveryMethodsStore'), 7 | Delivery: require('./DeliveryStore'), 8 | Order: require('./OrderStore'), 9 | PaymentOptions: require('./PaymentOptionsStore'), 10 | Products: require('./ProductsStore') 11 | }; 12 | 13 | module.exports = Stores; 14 | -------------------------------------------------------------------------------- /test/Actions.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var Actions = require('../src/Actions'); 4 | 5 | describe('Actions', function() { 6 | describe('checkKey', function() { 7 | before(function() { sinon.stub(console, 'warn'); }); 8 | afterEach(function() { console.warn.reset(); }); 9 | after(function() { console.warn.restore(); }); 10 | 11 | it('accepts arrays with all keys are present and unique', function() { 12 | var input = { 13 | products: [ 14 | { name: 'bag', price: 3.45 }, 15 | { name: 'coat', price: 4.60 }, 16 | { name: 'shoes', price: 2.25 } 17 | ] 18 | }; 19 | assert.ok(Actions.loadProducts.shouldEmit(input)); 20 | assert(console.warn.notCalled); 21 | }); 22 | 23 | it('does not accept missing keys', function() { 24 | var input = { 25 | products: [ 26 | { name: 'bag', price: 3.45 }, 27 | { price: 4.60 }, 28 | { name: 'shoes', price: 2.25 } 29 | ] 30 | }; 31 | 32 | assert.notOk(Actions.loadProducts.shouldEmit(input)); 33 | assert(console.warn.called); 34 | }); 35 | 36 | it('does not accept duplicate keys', function() { 37 | var input = { 38 | products: [ 39 | { name: 'bag', price: 3.45 }, 40 | { name: 'bag', price: 4.60 }, 41 | { name: 'shoes', price: 2.25 } 42 | ] 43 | }; 44 | 45 | assert.notOk(Actions.loadProducts.shouldEmit(input)); 46 | assert(console.warn.called); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/calculators/FixedCalculator.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var I = require('seamless-immutable'); 4 | var FixedCalculator = require('../../src/calculators/FixedCalculator'); 5 | 6 | describe('FixedCalculator', function() { 7 | it('calculates', function() { 8 | var order = I({ totals: { order: 23.36 } }); 9 | var calc = FixedCalculator({ amount: 4.30 }); 10 | assert.equal(calc(order), 4.30); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/calculators/PercentCalculator.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var I = require('seamless-immutable'); 4 | var PercentCalculator = require('../../src/calculators/PercentCalculator'); 5 | 6 | describe('PercentCalculator', function() { 7 | it('calculates', function() { 8 | var order = I({ totals: { price: 23.35 } }); 9 | var calc = PercentCalculator({ field: 'price', percent: 25.24 }); 10 | assert.equal(calc(order), 5.89354); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/calculators/TieredCalculator.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var I = require('seamless-immutable'); 4 | var TieredCalculator = require('../../src/calculators/TieredCalculator'); 5 | 6 | describe('TieredCalculator', function() { 7 | var calc = TieredCalculator({ 8 | field: 'volume', 9 | tiers: [[0, 3.29], [0.5, 4.59], [1.2, 5.39]] 10 | }); 11 | 12 | it('calculates', function() { 13 | order = I({ totals: { volume: 0.25 } }); 14 | assert.equal(calc(order), 3.29); 15 | 16 | order = I({ totals: { volume: 0.89 } }); 17 | assert.equal(calc(order), 4.59); 18 | 19 | order = I({ totals: { volume: 3.25 } }); 20 | assert.equal(calc(order), 5.39); 21 | }); 22 | 23 | it('has inclusive lowerbounds and exclusive upperbounds', function() { 24 | order = I({ totals: { volume: 1.2 } }); 25 | assert.equal(calc(order), 5.39); 26 | }); 27 | 28 | it('supports close ended tiers', function() { 29 | calc = TieredCalculator({ 30 | field: 'volume', 31 | tiers: [[0, 3.29], [0.5, 4.59], [1.2]] 32 | }); 33 | 34 | order = I({ totals: { volume: 3.25 } }); 35 | assert.equal(calc(order), undefined); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/mixins/keptInStorage.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var I = require('seamless-immutable'); 4 | var Reflux = require('reflux'); 5 | var keptInStorage = require('../../src/mixins/keptInStorage'); 6 | 7 | describe('keptInStorage', function() { 8 | var AssasinsStore = Reflux.createStore({ 9 | mixins: [keptInStorage('assasins', [])] 10 | }); 11 | 12 | it('is empty initially if not in session', function() { 13 | assert.deepEqual(AssasinsStore.getInitialState(), { assasins: I([]) }); 14 | }); 15 | 16 | it('retreives initial from session if in session', function() { 17 | var assasins = [{ name: 'Dave' }, { name: 'Terry' }]; 18 | AssasinsStore.session.get = function() { return assasins; }; 19 | 20 | AssasinsStore.init(); 21 | 22 | var expected = { assasins: I(assasins) }; 23 | assert.deepEqual(AssasinsStore.getInitialState(), expected); 24 | }); 25 | 26 | it('updates', function() { 27 | var trigger = AssasinsStore.trigger = sinon.spy(); 28 | var set = AssasinsStore.session.set = sinon.spy(); 29 | var assasins = AssasinsStore.assasins = I([{ name: 'George' }]); 30 | 31 | AssasinsStore.update(); 32 | 33 | assert(trigger.calledWith({ assasins: assasins })); 34 | assert(set.calledWith('assasins', assasins)); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/mixins/listenAndMix.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var Reflux = require('reflux'); 4 | var listenAndMix = require('../../src/mixins/listenAndMix'); 5 | 6 | describe('listenAndMix', function() { 7 | describe('listening to action', function() { 8 | var myAction = Reflux.createAction(); 9 | var callback = sinon.stub(); 10 | var myStore; 11 | 12 | it('does nothing initially', function() { 13 | myStore = Reflux.createStore({ 14 | mixins: [listenAndMix(myAction, 'callback')], 15 | callback: callback 16 | }); 17 | assert(callback.notCalled); 18 | }); 19 | 20 | it('mixes triggered state and calls callback', function() { 21 | myAction.trigger({ name: 'Harry' }); 22 | assert.equal(myStore.name, 'Harry'); 23 | assert(callback.called); 24 | }); 25 | }); 26 | 27 | describe('listening to store', function() { 28 | var myStore = Reflux.createStore({ 29 | getInitialState: function(){ return { name: 'Peter' }; } 30 | }); 31 | var callback = sinon.stub(); 32 | var myAggregateStore; 33 | 34 | it('mixes initial state and does not call callback', function() { 35 | myAggregateStore = Reflux.createStore({ 36 | mixins: [listenAndMix(myStore, 'callback')], 37 | callback: callback 38 | }); 39 | assert.equal(myAggregateStore.name, 'Peter'); 40 | assert(callback.notCalled); 41 | }); 42 | 43 | it('mixes triggered state and calls callback', function() { 44 | myStore.trigger({ name: 'George' }); 45 | assert.equal(myAggregateStore.name, 'George'); 46 | assert(callback.called); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/mixins/resource.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var Reflux = require('reflux'); 4 | var resource = require('../../src/mixins/resource'); 5 | 6 | describe('resource', function() { 7 | var PixiesStore = Reflux.createStore({ 8 | mixins: [resource('pixies')], 9 | onLoadPixies: function(args) { this.toLookUp('name', args); } 10 | }); 11 | 12 | var input = { 13 | pixies: [{ name: 'Aisling', age: 5 }, { name: 'Vogelein', age: 7 }] 14 | }; 15 | 16 | var expected = { 17 | pixies: { 18 | 'Aisling': { name: 'Aisling', age: 5 }, 19 | 'Vogelein': { name: 'Vogelein', age: 7 } 20 | } 21 | }; 22 | 23 | it('initially returns empty', function() { 24 | assert.deepEqual(PixiesStore.getInitialState().pixies, {}); 25 | }); 26 | 27 | it('creates lookup', function() { 28 | sinon.spy(PixiesStore, 'trigger'); 29 | PixiesStore.onLoadPixies(input); 30 | assert(PixiesStore.trigger.calledWith(expected)); 31 | }); 32 | 33 | it('returns pixies as inital state after load', function() { 34 | assert.deepEqual(PixiesStore.getInitialState(), expected); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --recursive 2 | -------------------------------------------------------------------------------- /test/services/Totals.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var I = require('seamless-immutable'); 4 | var Totals = require('../../src/services/Totals'); 5 | 6 | describe('Totals', function() { 7 | it('calculates totals for empty baskets', function() { 8 | assert.deepEqual(Totals.accumulate(I({})), I({ price: 0, weight: 0 })); 9 | }); 10 | 11 | it('calculates totals for filled basket', function() { 12 | var basket = I({ 13 | 'ball': { name: 'ball', price: 2.14, weight: 245, quantity: 1 }, 14 | 'bat': { name: 'bat', price: 1.23, weight: 367, quantity: 2 } 15 | }); 16 | assert.deepEqual(Totals.accumulate(basket), I({ price: 4.6, weight: 979 })); 17 | }); 18 | 19 | it('it ignores missing values', function() { 20 | var basket = I({ 21 | 'ball': { name: 'ball', price: 2.14, quantity: 1 }, 22 | 'bat': { name: 'bat', price: 1.23, quantity: 2 } 23 | }); 24 | assert.deepEqual(Totals.accumulate(basket), I({ price: 4.6, weight: 0 })); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/services/adjustOrder.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var I = require('seamless-immutable'); 4 | var adjustOrder = require('../../src/services/adjustOrder'); 5 | 6 | describe('adjustOrder', function() { 7 | it('creates an adjustment and adds to total', function() { 8 | var order = I({ totals: { order: 34.50 }, adjustments: [] }); 9 | 10 | var expected = I({ 11 | totals: { order: 34.60 }, 12 | adjustments: [{ label: '10p Tax', amount: 0.10 }] 13 | }); 14 | 15 | assert.deepEqual(adjustOrder(order, '10p Tax', 0.10), expected); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/stores/AddressStore.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var I = require('seamless-immutable'); 4 | var s = require('../../src/stores/AddressStore'); 5 | 6 | describe('AddressStore', function() { 7 | var countries = I({ 8 | 'KH': { name: 'Cambodia', zones: ['International', 'Asia'], iso: 'KH' }, 9 | 'AT': { name: 'Austria', zones: ['International', 'Europe'], iso: 'AT' }, 10 | 'GU': { name: 'Guam', zones: ['International', 'Oceanian'], iso: 'GU' } 11 | }); 12 | 13 | before(function() { s.trigger = sinon.spy(); }); 14 | 15 | it('has empty address initially', function() { 16 | assert.deepEqual(s.getInitialState(), { country: {} }); 17 | }); 18 | 19 | it('defaults to first country when countries loaded', function() { 20 | s.countries = countries; 21 | s.update(); 22 | assert.deepEqual(s.trigger.lastCall.args[0], { country: countries['KH'] }); 23 | }); 24 | 25 | it('finds country when set', function() { 26 | s.country = 'GU'; 27 | s.update(); 28 | assert.deepEqual(s.trigger.lastCall.args[0], { country: countries['GU'] }); 29 | }); 30 | 31 | it('uses first country if iso not found', function() { 32 | s.country = 'US'; 33 | s.update(); 34 | assert.deepEqual(s.trigger.lastCall.args[0], { country: countries['KH'] }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/stores/BasketStore.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var I = require('seamless-immutable'); 4 | var s = require('../../src/stores/BasketStore'); 5 | 6 | describe('BasketStore', function() { 7 | var expected; 8 | 9 | before(function() { 10 | s.trigger = sinon.spy(); 11 | s.session.set = sinon.spy(); 12 | 13 | s.products = I({ 14 | 'shoes': { name: 'shoes', price: 1.20 }, 15 | 'socks': { name: 'socks', price: 2.50 } 16 | }); 17 | 18 | s.basket = I({ 19 | 'bag': { name: 'bag', price: 6.10, quantity: 2, subtotal: 12.20 } 20 | }); 21 | }); 22 | 23 | it('sets items already in basket', function() { 24 | expected = I({ 'bag': { name: 'bag', price: 6.1, quantity: 5, subtotal: 30.50 } }); 25 | 26 | s.onSetItem({ name: 'bag', quantity: 5 }); 27 | assert.deepEqual(s.trigger.lastCall.args[0], { basket: expected }); 28 | assert.deepEqual(s.session.set.lastCall.args, ['basket', expected]); 29 | }); 30 | 31 | it('sets items from products', function() { 32 | expected = expected.merge({ 33 | 'shoes': { name: 'shoes', price: 1.20, quantity: 1, subtotal: 1.20 }, 34 | }); 35 | 36 | s.onSetItem({ name: 'shoes', quantity: 1 }); 37 | assert.deepEqual(s.trigger.lastCall.args[0], { basket: expected }); 38 | assert.deepEqual(s.session.set.lastCall.args, ['basket', expected]); 39 | }); 40 | 41 | it('removes items', function() { 42 | expected = expected.without('bag'); 43 | s.onRemoveItem({ name: 'bag' }); 44 | assert.deepEqual(s.trigger.lastCall.args[0], { basket: expected }); 45 | assert.deepEqual(s.session.set.lastCall.args, ['basket', expected]); 46 | }); 47 | 48 | it('clears items', function() { 49 | s.onCompleted(); 50 | assert.deepEqual(s.session.set.lastCall.args, ['basket', {}]); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/stores/CheckoutStore.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var I = require('seamless-immutable'); 4 | var adjustOrder = require('../../src/services/adjustOrder'); 5 | var s = require('../../src/stores/CheckoutStore'); 6 | 7 | describe('CheckoutStore', function() { 8 | var licenseAdjustor = { 9 | adjust: function(order) { return adjustOrder(order, 'License Fee', 10); } 10 | }; 11 | 12 | var vatAdjustor = { 13 | adjust: function(order) { return adjustOrder(order, 'VAT', 5.60); } 14 | }; 15 | 16 | var expected = I({ 17 | adjustments: [ 18 | { label: 'License Fee', amount: 10 }, 19 | { label: 'VAT', amount: 5.60 } 20 | ], 21 | errors: [], 22 | totals: { price: 4.6, weight: 979, order: 20.20 } 23 | }); 24 | 25 | before(function() { 26 | s.trigger = sinon.spy(); 27 | s.adjustors = [licenseAdjustor, vatAdjustor]; 28 | s.basket = I({ 29 | 'ball': { name: 'ball', price: 2.14, weight: 245, quantity: 1 }, 30 | 'bat': { name: 'bat', price: 1.23, weight: 367, quantity: 2 } 31 | }); 32 | }); 33 | 34 | it('runs basket through checkout', function() { 35 | s.update(); 36 | assert.deepEqual(s.trigger.args[0][0], { order: expected }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/stores/CountriesStore.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var I = require('seamless-immutable'); 4 | var s = require('../../src/stores/CountriesStore'); 5 | 6 | describe('CountriesStore', function() { 7 | var input = [{ iso: 'KH' }, { iso: 'AT' }, { iso: 'GU' }]; 8 | var expected = I({ 'KH': input[0], 'AT': input[1], 'GU': input[2] }); 9 | 10 | before(function() { s.trigger = sinon.spy(); }); 11 | 12 | it('creates lookup', function() { 13 | s.onLoadCountries({ countries: input }); 14 | assert.deepEqual(s.trigger.args[0][0], { countries: expected }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/stores/DeliveryMethodsStore.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var I = require('seamless-immutable'); 4 | var s = require('../../src/stores/DeliveryMethodsStore'); 5 | 6 | describe('DeliveryMethodsStore', function() { 7 | var input = [ 8 | { name: 'Express', zones: ['Central'], calculator: 'Percent' }, 9 | { name: 'Tracked', zones: ['South', 'West'], calculator: 'Tiered' } 10 | ]; 11 | input[0].args = { field: 'total', percent: 25 }; 12 | input[1].args = { field: 'weight', tiers: [[0, 3.56], [750, 5.35], [2000]] }; 13 | 14 | var expected = I({ 15 | 'Express': { name: 'Express', zones: ['Central'], calculator: 'Percent: 25' }, 16 | 'Tracked': { name: 'Tracked', zones: ['South', 'West'], calculator: 'Tiered: 3.56' } 17 | }); 18 | 19 | before(function() { 20 | s.trigger = sinon.spy(); 21 | s.country = I({ name: 'The Crownlands', zones: ['Central', 'West'] }); 22 | 23 | s.calculators = { 24 | Percent: function(args) { return 'Percent: ' + args.percent }, 25 | Tiered: function(args) { return 'Tiered: ' + args.tiers[0][1] } 26 | } 27 | }); 28 | 29 | it('sets methods and creates calculators', function() { 30 | s.onLoadDeliveryMethods({ methods: input }); 31 | assert.deepEqual(s.trigger.lastCall.args[0], { methods: expected }); 32 | }); 33 | 34 | it('sets country and only passes on available methods', function() { 35 | s.country = I({ name: 'Dorne', zones: ['South'] }); 36 | expected = expected.without('Express'); 37 | s.update(); 38 | assert.deepEqual(s.trigger.lastCall.args[0], { methods: expected }); 39 | }); 40 | 41 | it('responds correctly to getInitialState after country set', function() { 42 | assert.deepEqual(s.getInitialState(), { methods: expected }); 43 | }); 44 | 45 | it('passes on nothing if country not set', function() { 46 | s.country = I({}); 47 | s.update(); 48 | assert.deepEqual(s.trigger.lastCall.args[0], { methods: I({}) }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/stores/DeliveryStore.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var I = require('seamless-immutable'); 4 | var s = require('../../src/stores/DeliveryStore'); 5 | var adjustOrder = require('../../src/services/adjustOrder'); 6 | 7 | describe('DeliveryStore', function() { 8 | var delivery, expected; 9 | var order = I({ totals: { order: 13.28 }, errors: [], adjustments: [] }); 10 | 11 | before(function() { 12 | s.trigger = sinon.spy(); 13 | s.methods = I({ 14 | 'Standard': { name: 'Standard', calculator: sinon.stub().returns(1.60) }, 15 | 'Express': { name: 'Express', calculator: sinon.stub().returns(3.40) }, 16 | 'Limited': { name: 'Limited', calculator: sinon.stub().returns(undefined) } 17 | }); 18 | }); 19 | 20 | it('creates adjustment with first method by default', function() { 21 | delivery = I({ name: 'Standard', amount: 1.60 }); 22 | expected = adjustOrder(order, 'Standard', 1.60).merge({ delivery: 'Standard' }); 23 | assert.deepEqual(s.adjust(order), expected); 24 | assert.deepEqual(s.trigger.args[0][0], { delivery: delivery }); 25 | }); 26 | 27 | it('creates adjustment with specified method', function() { 28 | s.delivery = 'Express'; 29 | delivery = I({ name: 'Express', amount: 3.40 }); 30 | expected = adjustOrder(order, 'Express', 3.40).merge({ delivery: 'Express' }); 31 | assert.deepEqual(s.adjust(order), expected); 32 | assert.deepEqual(s.trigger.args[1][0], { delivery: delivery }); 33 | }); 34 | 35 | it('returns previously set delivery with getInitialState', function() { 36 | assert.deepEqual(s.getInitialState(), { delivery: delivery }); 37 | }); 38 | 39 | it('raises error if selected method returns nothing', function() { 40 | s.delivery = 'Limited'; 41 | delivery = I({ name: 'Limited', amount: undefined }); 42 | expected = order.merge({ errors: [s.Errors.NOT_APPLICABLE] }); 43 | assert.deepEqual(s.adjust(order), expected); 44 | assert.deepEqual(s.trigger.args[2][0], { delivery: delivery }); 45 | }); 46 | 47 | it('raises error if no methods available', function() { 48 | s.methods = I({}); 49 | expected = order.merge({ errors: [s.Errors.UNDELIVERABLE] }); 50 | assert.deepEqual(s.adjust(order), expected); 51 | }); 52 | 53 | it('adds errors to previous', function() { 54 | order = order.merge({ errors: ['This item is a fashion disaster'] }); 55 | 56 | expected = order.merge({ 57 | errors: ['This item is a fashion disaster', s.Errors.UNDELIVERABLE] 58 | }); 59 | 60 | assert.deepEqual(s.adjust(order), expected); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/stores/OrderStore.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var I = require('seamless-immutable'); 4 | var s = require('../../src/stores/OrderStore'); 5 | 6 | describe('OrderStore', function() { 7 | var success = { body: { number: '28475EUF839' } }; 8 | var post = sinon.stub().returnsThis(); 9 | var send = sinon.stub().returnsThis(); 10 | var end = sinon.stub().callsArgWith(0, null, success); 11 | 12 | var payload = { 13 | token: 'FHRFDG4523DF3', 14 | customer: { name: 'Jimmy Chu' }, 15 | address: { address1: '45 Avenbury Rd' } 16 | }; 17 | 18 | before(function() { 19 | s.trigger = sinon.spy(); 20 | s.completed = sinon.spy(); 21 | s.basket = I({ 'bag': { name: 'bag', quantity: 1, price: 5.30, weight: 1500 } }); 22 | s.paymentOptions = I({ hook: 'www.server.io/', currency: 'GBP' }); 23 | s.request = { post: post, send: send, end: end }; 24 | 25 | s.order = I({ 26 | totals: { price: 5.30, weight: 1500, order: 7.80 }, 27 | delivery: 'Second Class', 28 | errors: [], 29 | adjustments: [{ label: 'Second Class', amount: 2.50 }] 30 | }); 31 | }); 32 | 33 | it('processes a successful purchase correctly', function() { 34 | var expectedJSON = { 35 | token: payload.token, 36 | customer: payload.customer, 37 | address: payload.address, 38 | basket: { 'bag': 1 }, 39 | delivery: s.order.delivery, 40 | currency: s.paymentOptions.currency, 41 | total: s.order.totals.order 42 | }; 43 | 44 | s.onPurchase(payload); 45 | assert(post.calledWith('www.server.io/')); 46 | assert.deepEqual(send.args[0][0], expectedJSON); 47 | assert(s.completed.calledWith(success.body)); 48 | }); 49 | 50 | it('forwards purchase errors', function() { 51 | end.callsArgWith(0, { response: { body: { message: 'Internal server error.' } } }); 52 | s.onPurchase(payload); 53 | var expected = s.order.merge({ errors: ['Internal server error.'] }); 54 | assert.deepEqual(s.trigger.args[0][0], { order: expected }); 55 | }); 56 | 57 | it('does not process order with errors', function() { 58 | post.reset(); 59 | s.order = s.order.merge({ errors: ['Order is too big!'] }); 60 | s.onPurchase(payload); 61 | assert(post.notCalled); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/stores/PaymentOptionsStore.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var I = require('seamless-immutable'); 4 | var s = require('../../src/stores/PaymentOptionsStore'); 5 | 6 | describe('PaymentOptionsStore', function() { 7 | it('triggers options', function() { 8 | var input = { currency: 'GBP', hook: 'www.payments.io/' }; 9 | s.trigger = sinon.spy(); 10 | s.onSetPaymentOptions(input); 11 | assert.deepEqual(s.trigger.args[0][0], { paymentOptions: I(input) }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/stores/ProductsStore.spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var I = require('seamless-immutable'); 4 | var s = require('../../src/stores/ProductsStore'); 5 | 6 | describe('ProductsStore', function() { 7 | var input = [{ name: 'brush' }, { name: 'comb' }]; 8 | var expected = I({ 'brush': input[0], 'comb': input[1] }); 9 | 10 | before(function() { s.trigger = sinon.spy(); }); 11 | 12 | it('creates lookup', function() { 13 | s.onLoadProducts({ products: input }); 14 | assert.deepEqual(s.trigger.args[0][0], { products: expected }); 15 | }); 16 | }); 17 | --------------------------------------------------------------------------------