hola_shopping_cart 0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 519019c411f966ece64787aca2326ccc50fedea00d6fb581035d1ea50e3b95ad
4
+ data.tar.gz: daedaf24f05f04c2d3c2ab2eb5f315a7d7d1c33971fe7d4e9fe9e0b580ec0c4b
5
+ SHA512:
6
+ metadata.gz: e0d042982394c72a4b702a261d3616c8af16bbd266f09ec3cc4b7896bea2bda1d164c4a23eab41a5e65583a2c4f7fe741cc49ea1670343bb7c37d9e35c151a4e
7
+ data.tar.gz: 2447418bbba5340df270c318ba10146ef6345393d08928f529ee2902482d12972ef46738f7ef3577067e1a48970134666b2564d1ed11166bf81c9a38ee8e1b28
data/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # Hola Shopping Cart 🛍️
2
+
3
+ ## Challenge
4
+ > *Build an app that supports adding products to a shopping cart and displaying a total price.*
5
+ > | *Product code* | *Name* | *Price* |
6
+ > | -------------- | -------------- | --------- |
7
+ > | *GR1* | *Green Tea* | *3.11 €* |
8
+ > | *SR1* | *Strawberries* | *5.00 €* |
9
+ > | *CF1* | *Coffee* | *11.23 €* |
10
+ > ### *Special conditions*
11
+ > * *Green Tea has buy-one-get-one-free offer*
12
+ >
13
+ > * *Strawberries price changes to 4.50€ if you buy 3 or more*
14
+ >
15
+ > * *Coffee price drops to 2/3 of the original price if you buy 3 or more*
16
+ >
17
+ > *Shopping cart can accept items in any order and be flexible regarding pricing rules.*
18
+ > ### *Test data*
19
+ >| *Basket* | *Total price expected* |
20
+ >| ------------------------- | ---------------------- |
21
+ >| *GR1, GR1* | *3.11€* |
22
+ >| *SR1, SR1, GR1, SR1* | *16.61€* |
23
+ >| *GR1, CF1, SR1, CF1, CF1* | *30.57€* |
24
+
25
+
26
+ ## Solution
27
+
28
+ ### Hola Shopping Cart 🛍️
29
+
30
+ *Are you tired of shopping on over-designed webshops? Hola Shopping Cart is a simple, eyes-friendly CLI application to browse products and fill the shopping cart. Don't leave your favorite terminal to go shopping.*
31
+
32
+ ![Demo](docs/demo.gif)
33
+
34
+
35
+ ## Installation
36
+
37
+ *Requirements: Ruby >= 3.0.0 installed*
38
+
39
+ Available as a gem through [RubyGems](https://rubygems.org/gems/hola_shopping_cart), install it:
40
+
41
+ ```
42
+ $ gem install hola_shopping_cart
43
+ ```
44
+
45
+ ## Usage
46
+ In order to start shopping, run the command:
47
+
48
+ ```
49
+ $ hola
50
+ ```
51
+ Use your keyboard's Up/Down arrow keys to navigate the product list. Press Enter to select a product. Select a quantity by inserting a number. When you want to finish shopping, press `n` to see the shopping cart.
52
+
53
+ ## Arhitecture
54
+ Three primary actions happen in the life cycle of the app:
55
+ * User input capture
56
+ * Processing items in a cart
57
+ * Rendering a cart to display it
58
+
59
+ ![AppLifeCycle](docs/app-life-cycle.png)
60
+
61
+ ### User input
62
+ The underlying library [TTY::Prompt](https://github.com/piotrmurach/tty-prompt) is used to ease up gathering user input from the command line. The library has a number of handy prompt types; one of them is `select`, which is used to show a list of available products and make a selection. The command line immediately displays a user selection for a great user experience. After that, it's up to the processing action to take over.
63
+
64
+ ### Processing
65
+ Processing action is responsible for fetching necessary data, processing the product offers, and answering with `Cart::Item`. The item is then stored in the `Cart`, and processing is complete. The processing is done intentionally before adding an item to the cart to avoid big performance spikes at the end when the cart is rendered. That brings additional benefits. For example, if the product price or offer changes in the middle of the user's shopping, the total price will respect the price shown when the product is added to the cart.
66
+
67
+ Sequence diagram of processing an item:
68
+ ![AddToCart](docs/add-to-cart-seq-diagram.png)
69
+
70
+ ### Cart render
71
+ The [TTY::Table](https://github.com/piotrmurach/tty-table) table formatting component is used to perform rendering. The table is rendered with an ASCII-type border.
72
+ ```
73
+ +------------+------+--------+--------+------------------------+
74
+ |Item |Price |Quantity|Subtotal|Offer |
75
+ +------------+------+--------+--------+------------------------+
76
+ |Green Tea |3.11€ |1 |3.11€ | |
77
+ |Strawberries|5.00€ |1 |5.00€ | |
78
+ |Coffee |11.23€|3 |22.46€ |Two-Thirds Bulk Discount|
79
+ +------------+------+--------+--------+------------------------+
80
+ |Total | | |30.57€ | |
81
+ +------------+------+--------+--------+------------------------+
82
+ ```
83
+
84
+ ### Flexibility
85
+ The architecture is designed to be flexible as much as possible when comes to adding new special offers or modifying existing ones.
86
+
87
+ To add a new special offer, you must create a new file inside `hola/offers`. The example below shows adding a price reduction of 20%.
88
+ ```ruby
89
+ module Hola
90
+ class Offer
91
+ class NewSpecialDiscount < Offer
92
+ def price
93
+ super * 0.8
94
+ end
95
+ end
96
+ end
97
+ end
98
+ ```
99
+ The last step is to add a newly created special offer to a product.
100
+ ```ruby
101
+ product.apply_offer("NewSpecialDiscount")
102
+ ```
103
+
104
+ ## Tests
105
+ ```
106
+ rspec ./spec
107
+ .............................................................................................
108
+
109
+ Finished in 0.0316 seconds (files took 0.15474 seconds to load)
110
+ 93 examples, 0 failures
111
+ ```
data/bin/hola ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "hola"
4
+
5
+ Hola::CLI.start
data/lib/hola/app.rb ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+ require "hola/product/selector"
5
+ require "hola/cart/renderer"
6
+ require "hola/cart"
7
+
8
+ # This class facilitates user flow experience.
9
+ # It allows user to select products and prints the cart.
10
+ module Hola
11
+ class App
12
+ def initialize(prompt = TTY::Prompt.new)
13
+ @prompt = prompt
14
+ end
15
+
16
+ def run
17
+ prompt.say("Hola. It's shopping time 🛍️")
18
+
19
+ loop do
20
+ selection = Product::Selector.new(prompt).perform
21
+ cart.add(
22
+ product_id: selection[:product_id],
23
+ quantity: selection[:quantity]
24
+ )
25
+
26
+ break unless prompt.yes?("Would you like to add more items?")
27
+ end
28
+
29
+ prompt.say(Cart::Renderer.new(cart).perform)
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :prompt
35
+
36
+ def cart
37
+ @cart ||= Cart.new
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hola/cart/item"
4
+ require "hola/offer"
5
+ Dir.glob(File.expand_path("../../offer/*.rb", __dir__), &method(:require))
6
+
7
+ # The item processor is responsible for processing the item before
8
+ # it's added to a cart. It applies the product's offer
9
+ # and responds with a cart item value object.
10
+ module Hola
11
+ class Cart
12
+ class Item
13
+ class Processor
14
+ attr_reader :product_id, :quantity
15
+
16
+ def initialize(product_id:, quantity:)
17
+ @product_id = product_id
18
+ @quantity = quantity
19
+ end
20
+
21
+ def perform
22
+ Cart::Item.new(
23
+ product: product,
24
+ quantity: quantity,
25
+ subtotal: offer.subtotal,
26
+ offer: (offer.name if offer.applied?)
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ def product
33
+ @product ||= Product.find(product_id)
34
+ end
35
+
36
+ def offer
37
+ @offer ||= begin
38
+ klass = product.offer? ? product.offer : "NoOffer"
39
+ Object.const_get("Hola::Offer::#{klass}").new(
40
+ product: product,
41
+ quantity: quantity
42
+ )
43
+ end
44
+ rescue NameError
45
+ raise ArgumentError.new("Offer not found")
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Value object for Item in a Cart.
4
+ # It only knows how to display the values.
5
+ module Hola
6
+ class Cart
7
+ class Item
8
+ attr_reader :product, :quantity, :subtotal, :offer
9
+
10
+ def initialize(product: nil, quantity: 0, subtotal: 0, offer: "")
11
+ @product = product
12
+ @quantity = quantity
13
+ @subtotal = subtotal
14
+ @offer = offer
15
+ end
16
+
17
+ def output
18
+ [
19
+ product.name,
20
+ Utils::Money.to_currency(product.price),
21
+ quantity,
22
+ Utils::Money.to_currency(subtotal),
23
+ offer
24
+ ]
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-table"
4
+ require "hola/product"
5
+
6
+ # Renders cart in table format
7
+ module Hola
8
+ class Cart
9
+ class Renderer
10
+ def initialize(cart)
11
+ @cart = cart
12
+ end
13
+
14
+ def perform
15
+ table.render(:ascii)
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :cart
21
+
22
+ def table
23
+ @table ||= TTY::Table.new(
24
+ header: %w[Item Price Quantity Subtotal Offer],
25
+ rows: [
26
+ :separator,
27
+ *cart.output,
28
+ :separator,
29
+ ["Total", "", "", Utils::Money.to_currency(cart.total), ""]
30
+ ]
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
data/lib/hola/cart.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hola/cart/item/processor"
4
+ require "hola/cart/item"
5
+ require "hola/product"
6
+
7
+ # The cart is responsible for holding list of items
8
+ module Hola
9
+ class Cart
10
+ attr_reader :items
11
+
12
+ def initialize
13
+ @items = Hash.new { Cart::Item.new }
14
+ end
15
+
16
+ def add(product_id:, quantity:)
17
+ items[product_id] = Cart::Item::Processor.new(
18
+ product_id: product_id,
19
+ quantity: items[product_id].quantity + quantity
20
+ ).perform
21
+ end
22
+
23
+ def total
24
+ items.sum { |_k, item| item.subtotal }
25
+ end
26
+
27
+ def output
28
+ items.map { |_k, item| item.output }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hola
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hola
4
+ class Offer
5
+ class GetOneFree < Offer
6
+ def name
7
+ "Get One Free"
8
+ end
9
+
10
+ def quantity
11
+ @_quantity ||= super - quantity_reduction
12
+ end
13
+
14
+ def applied?
15
+ eligible?
16
+ end
17
+
18
+ private
19
+
20
+ def quantity_reduction
21
+ eligible? ? 1 : 0
22
+ end
23
+
24
+ def eligible?
25
+ @quantity > 1
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # When no offer assigned to a product this is should be the class
4
+ # for computing subtotal
5
+ module Hola
6
+ class Offer
7
+ class NoOffer < Offer
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Hola
6
+ class Offer
7
+ class StrawberryBulkDiscount < Offer
8
+ DISCOUNTED_PRICE = BigDecimal("4.5")
9
+
10
+ def name
11
+ "Strawberry Bulk Discount"
12
+ end
13
+
14
+ def price
15
+ eligible? ? DISCOUNTED_PRICE : super
16
+ end
17
+
18
+ def applied?
19
+ eligible?
20
+ end
21
+
22
+ private
23
+
24
+ def eligible?
25
+ quantity >= 3
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Hola
6
+ class Offer
7
+ class TwoThirdsBulkDiscount < Offer
8
+ def name
9
+ "Two-Thirds Bulk Discount"
10
+ end
11
+
12
+ def price
13
+ super * discount
14
+ end
15
+
16
+ def applied?
17
+ eligible?
18
+ end
19
+
20
+ private
21
+
22
+ def discount
23
+ return BigDecimal(1) unless eligible?
24
+
25
+ BigDecimal(2) / BigDecimal(3)
26
+ end
27
+
28
+ def eligible?
29
+ quantity >= 3
30
+ end
31
+ end
32
+ end
33
+ end
data/lib/hola/offer.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A parent class that all offers should inherit from
4
+ module Hola
5
+ class Offer
6
+ attr_reader :quantity
7
+
8
+ def initialize(product:, quantity:)
9
+ @product = product
10
+ @quantity = quantity
11
+ end
12
+
13
+ def name
14
+ ""
15
+ end
16
+
17
+ def price
18
+ @price ||= product.price
19
+ end
20
+
21
+ def applied?
22
+ false
23
+ end
24
+
25
+ def subtotal
26
+ price * quantity
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :product
32
+ end
33
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "hola/product"
5
+
6
+ # The inventory stores products.
7
+ # The Hola shop has only 3 products in stock.
8
+ # This class can be extended to support bulk import of products from files
9
+ # provided via the command line or
10
+ # get completely replaced by the database in the future.
11
+ module Hola
12
+ class Product
13
+ class Inventory
14
+ include Singleton
15
+ attr_reader :products
16
+
17
+ def initialize
18
+ @products = [
19
+ Product.new(name: "Green Tea", price: 3.11, offer: "GetOneFree"),
20
+ Product.new(name: "Strawberries", price: 5.0, offer: "StrawberryBulkDiscount"),
21
+ Product.new(name: "Coffee", price: 11.23, offer: "TwoThirdsBulkDiscount")
22
+ ]
23
+ end
24
+
25
+ def find_product(id)
26
+ products.find { |p| p.id == id }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+ require "hola/product"
5
+
6
+ # This class provides an interface for a user to select a product
7
+ module Hola
8
+ class Product
9
+ class Selector
10
+ def initialize(prompt)
11
+ @prompt = prompt
12
+ end
13
+
14
+ def perform
15
+ {
16
+ product_id: select_product,
17
+ quantity: select_quantity
18
+ }
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :prompt
24
+
25
+ def product_options
26
+ @product_options ||= Product.list.map(&:to_option)
27
+ end
28
+
29
+ def select_product
30
+ prompt.select("Choose a product", product_options)
31
+ end
32
+
33
+ def select_quantity
34
+ prompt.ask(
35
+ "Select quantity (stock: 100)? ",
36
+ convert: :int
37
+ ) do |q|
38
+ q.in("1-100")
39
+ q.messages[:range?] = "out of expected range"
40
+ q.messages[:convert?] = "not a number"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hola/product/inventory"
4
+ require "hola/utils/money"
5
+ require "securerandom"
6
+
7
+ module Hola
8
+ class Product
9
+ class << self
10
+ def list
11
+ Inventory.instance.products
12
+ end
13
+
14
+ def find(id)
15
+ Inventory.instance.find_product(id)
16
+ end
17
+
18
+ def currency
19
+ "€"
20
+ end
21
+ end
22
+
23
+ attr_reader :id, :name, :price, :offer
24
+
25
+ def initialize(name:, price:, offer: "")
26
+ @id = SecureRandom.uuid
27
+ @name = name
28
+ @price = Utils::Money.parse(price)
29
+ @offer = offer
30
+ end
31
+
32
+ def offer?
33
+ !offer.empty?
34
+ end
35
+
36
+ def to_option
37
+ {name: title, value: id}
38
+ end
39
+
40
+ def apply_offer(name)
41
+ self.offer = name
42
+ end
43
+
44
+ private
45
+
46
+ attr_writer :offer
47
+
48
+ def title
49
+ "#{name} (#{Utils::Money.to_currency(price)})"
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "hola/errors"
5
+
6
+ module Hola
7
+ module Utils
8
+ class Money
9
+ class << self
10
+ def parse(...)
11
+ new(...).parse
12
+ end
13
+
14
+ def to_currency(...)
15
+ new(...).to_currency
16
+ end
17
+ end
18
+
19
+ def initialize(value)
20
+ @value = value
21
+ end
22
+
23
+ def parse
24
+ BigDecimal((value || 0).to_s.strip)
25
+ rescue ArgumentError, TypeError => err
26
+ raise Error.new(err.message)
27
+ end
28
+
29
+ def to_currency
30
+ "#{format("%.2f", value)}#{Product.currency}"
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :value
36
+ end
37
+ end
38
+ end
data/lib/hola.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hola/app"
4
+
5
+ module Hola
6
+ class CLI
7
+ class << self
8
+ def start
9
+ App.new.run
10
+ end
11
+ end
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,137 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hola_shopping_cart
3
+ version: !ruby/object:Gem::Version
4
+ version: '0.4'
5
+ platform: ruby
6
+ authors:
7
+ - Luka Domitrovic
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-02-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: tty-prompt
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.23.1
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.23.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: tty-table
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.12.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.12.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.12'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.12'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec-uuid
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.5.0
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.5.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 1.60.2
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 1.60.2
83
+ description: " Are you tired of shopping on over-designed webshops? \n Hola Shopping
84
+ Cart is a simple, eyes-friendly CLI application to browse \n products and fill
85
+ the shopping cart. \n Don't leave your favorite terminal to go shopping. \n It's
86
+ an example app for a technical assignment ;)\n"
87
+ email:
88
+ - luka@domitrovic.si
89
+ executables:
90
+ - hola
91
+ extensions: []
92
+ extra_rdoc_files: []
93
+ files:
94
+ - README.md
95
+ - bin/hola
96
+ - lib/hola.rb
97
+ - lib/hola/app.rb
98
+ - lib/hola/cart.rb
99
+ - lib/hola/cart/item.rb
100
+ - lib/hola/cart/item/processor.rb
101
+ - lib/hola/cart/renderer.rb
102
+ - lib/hola/errors.rb
103
+ - lib/hola/offer.rb
104
+ - lib/hola/offer/get_one_free.rb
105
+ - lib/hola/offer/no_offer.rb
106
+ - lib/hola/offer/strawberry_bulk_discount.rb
107
+ - lib/hola/offer/two_thirds_bulk_discount.rb
108
+ - lib/hola/product.rb
109
+ - lib/hola/product/inventory.rb
110
+ - lib/hola/product/selector.rb
111
+ - lib/hola/utils/money.rb
112
+ homepage: https://rubygems.org/gems/hola_shopping_cart
113
+ licenses:
114
+ - MIT
115
+ metadata:
116
+ homepage_uri: https://rubygems.org/gems/hola_shopping_cart
117
+ source_code_uri: https://github.com/luxxi/hola_shopping_cart
118
+ post_install_message:
119
+ rdoc_options: []
120
+ require_paths:
121
+ - lib
122
+ required_ruby_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - "~>"
125
+ - !ruby/object:Gem::Version
126
+ version: '3.0'
127
+ required_rubygems_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ requirements: []
133
+ rubygems_version: 3.5.3
134
+ signing_key:
135
+ specification_version: 4
136
+ summary: Hola Shopping Cart CLI app
137
+ test_files: []