spree_promo 0.60.6 → 0.70.RC1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. data/LICENSE +26 -0
  2. data/app/assets/javascripts/admin/promotions.js +131 -0
  3. data/app/assets/javascripts/admin/spree_promo.js +3 -0
  4. data/app/assets/javascripts/store/spree_promo.js +2 -0
  5. data/{public → app/assets}/stylesheets/admin/promotions.css +19 -29
  6. data/app/assets/stylesheets/admin/spree_promo.css +5 -0
  7. data/app/assets/stylesheets/store/spree_promo.css +4 -0
  8. data/app/controllers/admin/promotion_actions_controller.rb +28 -0
  9. data/app/controllers/admin/promotions_controller.rb +2 -2
  10. data/app/controllers/content_controller_decorator.rb +8 -0
  11. data/app/controllers/orders_controller_decorator.rb +14 -5
  12. data/app/models/adjustment_decorator.rb +3 -0
  13. data/app/models/order_decorator.rb +24 -0
  14. data/app/models/product_decorator.rb +10 -0
  15. data/app/models/product_group_decorator.rb +3 -0
  16. data/app/models/promotion.rb +61 -41
  17. data/app/models/promotion/actions/create_adjustment.rb +28 -0
  18. data/app/models/promotion/actions/create_line_items.rb +29 -0
  19. data/app/models/promotion/rules/first_order.rb +1 -1
  20. data/app/models/promotion/rules/item_total.rb +3 -2
  21. data/app/models/promotion/rules/landing_page.rb +17 -0
  22. data/app/models/promotion/rules/product.rb +1 -1
  23. data/app/models/promotion/rules/user.rb +1 -1
  24. data/app/models/promotion/rules/user_logged_in.rb +13 -0
  25. data/app/models/promotion_action.rb +19 -0
  26. data/app/models/promotion_action_line_item.rb +6 -0
  27. data/app/models/promotion_rule.rb +3 -16
  28. data/app/overrides/promo_admin_tabs.rb +5 -0
  29. data/app/overrides/promo_coupon_code_field.rb +5 -0
  30. data/app/overrides/promo_product_properties.rb +5 -0
  31. data/app/views/admin/promotion_actions/create.js.erb +7 -0
  32. data/app/views/admin/promotion_actions/destroy.js.erb +2 -0
  33. data/app/views/admin/promotions/_actions.html.erb +32 -0
  34. data/app/views/admin/promotions/_form.html.erb +17 -17
  35. data/app/views/admin/promotions/_promotion_action.html.erb +12 -0
  36. data/app/views/admin/promotions/_promotion_rule.html.erb +2 -2
  37. data/app/views/admin/promotions/_rules.html.erb +32 -0
  38. data/app/views/admin/promotions/actions/_create_adjustment.html.erb +19 -0
  39. data/app/views/admin/promotions/actions/_create_line_items.html.erb +52 -0
  40. data/app/views/admin/promotions/actions/_give_store_credit.html.erb +6 -0
  41. data/app/views/admin/promotions/edit.html.erb +4 -38
  42. data/app/views/admin/promotions/index.html.erb +5 -7
  43. data/app/views/admin/promotions/rules/_landing_page.html.erb +8 -0
  44. data/app/views/admin/promotions/rules/_user_logged_in.html.erb +3 -0
  45. data/app/views/checkout/_coupon_code_field.html.erb +1 -1
  46. data/config/locales/en.yml +27 -5
  47. data/config/routes.rb +1 -0
  48. data/db/migrate/20101026184833_migrate_adjustments.rb +9 -0
  49. data/db/migrate/20110331094351_promotion_changes_to_subclass_of_activator.rb +22 -0
  50. data/db/migrate/20110404123358_create_promotion_actions.rb +13 -0
  51. data/db/migrate/20110601202923_create_promotion_action_line_items.rb +12 -0
  52. data/lib/spree_promo.rb +24 -100
  53. metadata +77 -26
  54. data/app/models/promotion_credit.rb +0 -9
  55. data/db/migrate/20100419194457_fix_existing_coupon_credits.rb +0 -9
  56. data/lib/spree_promo_hooks.rb +0 -11
  57. data/lib/tasks/install.rake +0 -26
  58. data/public/javascripts/admin/promotions.js +0 -26
data/LICENSE ADDED
@@ -0,0 +1,26 @@
1
+ Copyright (c) 2007-2010, Rails Dog LLC and other contributors
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without modification,
5
+ are permitted provided that the following conditions are met:
6
+
7
+ * Redistributions of source code must retain the above copyright notice,
8
+ this list of conditions and the following disclaimer.
9
+ * Redistributions in binary form must reproduce the above copyright notice,
10
+ this list of conditions and the following disclaimer in the documentation
11
+ and/or other materials provided with the distribution.
12
+ * Neither the name Spree nor the names of its contributors may be used to
13
+ endorse or promote products derived from this software without specific
14
+ prior written permission.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
20
+ CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
21
+ EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
22
+ PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
23
+ PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
24
+ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
25
+ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
26
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,131 @@
1
+
2
+ var initProductRuleSourceField = function(){
3
+
4
+ $products_source_field = jQuery('.products_rule_products_source_field input');
5
+ $products_source_field.click(function() {
6
+ $rule_container = jQuery(this).parents('.promotion-rule');
7
+ if(this.checked){
8
+ if(this.value == 'manual'){
9
+ $rule_container.find('.products_rule_products').show();
10
+ $rule_container.find('.products_rule_product_group').hide();
11
+ } else {
12
+ $rule_container.find('.products_rule_products').hide();
13
+ $rule_container.find('.products_rule_product_group').show();
14
+ }
15
+ }
16
+ });
17
+ $products_source_field.each(function() {
18
+ $(this).triggerHandler('click');
19
+ });
20
+
21
+ };
22
+
23
+ var initProductActions = function(){
24
+
25
+ $('.calculator-fields').each(function(){
26
+ var $fields_container = $(this);
27
+ var $type_select = $fields_container.find('.type-select');
28
+ var $settings = $fields_container.find('.settings');
29
+ var $warning = $fields_container.find('.warning');
30
+ var originalType = $type_select.val();
31
+
32
+ $warning.hide();
33
+ $type_select.change(function(){
34
+ if( $(this).val() == originalType ){
35
+ $warning.hide();
36
+ $settings.show();
37
+ $settings.find('input').removeAttr('disabled');
38
+ } else {
39
+ $warning.show();
40
+ $settings.hide();
41
+ $settings.find('input').attr('disabled', 'disabled');
42
+ }
43
+ });
44
+ });
45
+
46
+
47
+ //
48
+ // CreateLineItems Promotion Action
49
+ //
50
+ ( function(){
51
+ // Autocomplete product and populate variant select
52
+ if($('.promotion_action.create_line_items ').is('*')){
53
+ $(".promotion_action.create_line_items input[name='add_product_name']").autocomplete("/admin/products.json?authenticity_token=" + $('meta[name=csrf-token]').attr("content"), {
54
+ parse: prep_autocomplete_data,
55
+ formatItem: function(item) {
56
+ return format_autocomplete(item);
57
+ }
58
+ }).result(function(event, data, formatted) {
59
+ if(data){
60
+ // $('#add_product_id').val(data.product.id);
61
+ var url = "/admin/products/" + data.product.permalink + "/variants.json?authenticity_token=" + $('meta[name=csrf-token]').attr("content");
62
+ var $variant_select = $("select[name='add_line_item_variant_id']");
63
+ $variant_select.html('');
64
+ $.getJSON(url, {}, function(variants_data){
65
+ $.each(variants_data, function(){
66
+ $variant_select.append($("<option />").val(this.id).text(this.label));
67
+ });
68
+ });
69
+ }
70
+ }
71
+ );
72
+ }
73
+
74
+ var hideOrShowItemTables = function(){
75
+ $('.promotion_action table').each(function(){
76
+ if($(this).find('td').length == 0){
77
+ $(this).hide();
78
+ } else {
79
+ $(this).show();
80
+ }
81
+ });
82
+ };
83
+ hideOrShowItemTables();
84
+
85
+ // Remove line item
86
+ var setupRemoveLineItems = function(){
87
+ $(".promotion_action.create_line_items table img").unbind('click').click(function(){
88
+ var $container = $(this).parents('.promotion_action');
89
+ var $hiddenField = $container.find("input[type='hidden']");
90
+ var $row = $(this).parents('tr');
91
+ var index = $row.parents('table').find('tr').index($row.get(0));
92
+ // Remove variant_id quantity pair from the string
93
+ var items = _($hiddenField.val().split(',')).compact();
94
+ items.splice(index - 1, 1);
95
+ $hiddenField.val(items.join(','));
96
+ $(this).parents('tr').remove();
97
+ hideOrShowItemTables();
98
+ });
99
+ };
100
+ setupRemoveLineItems();
101
+ // Add line item to list
102
+ $(".promotion_action.create_line_items button.add").click(function(){
103
+ var $container = $(this).parents('.promotion_action');
104
+ var product_name = $container.find("input[name='add_product_name']").val();
105
+ var variant_id = $container.find("select[name='add_line_item_variant_id']").val();
106
+ var variant_name = $container.find("select[name='add_line_item_variant_id'] option:selected").text();
107
+ var quantity = $container.find("input[name='add_quantity']").val();
108
+ if(variant_id){
109
+ // Add to the table
110
+ var newRow = "<tr><td>" + product_name + "</td><td>" + variant_name + "</td><td>" + quantity + "</td><td><img src='/admin/images/icons/cross.png' /></td></tr>";
111
+ $container.find('table').append(newRow);
112
+ // Add to serialized string in hidden text field
113
+ var $hiddenField = $container.find("input[type='hidden']");
114
+ $hiddenField.val($hiddenField.val() + "," + variant_id + "x" + quantity);
115
+ setupRemoveLineItems();
116
+ hideOrShowItemTables();
117
+ }
118
+ return false;
119
+ });
120
+
121
+ } )();
122
+
123
+ }
124
+
125
+ $(document).ready(function() {
126
+ initProductRuleSourceField();
127
+ initProductActions();
128
+ });
129
+
130
+
131
+
@@ -0,0 +1,3 @@
1
+ //= require admin/spree_core
2
+ //= require admin/spree_auth
3
+ //= require_tree .
@@ -0,0 +1,2 @@
1
+ //= require store/spree_core
2
+ //= require store/spree_auth
@@ -1,4 +1,4 @@
1
- .promotion-rule {
1
+ .promotion_rule, .promotion_action {
2
2
  -moz-border-radius: 5px;
3
3
  -webkit-border-radius: 5px;
4
4
  padding: 8px 40px 8px 10px;
@@ -7,48 +7,38 @@
7
7
  margin-bottom: 4px;
8
8
  position: relative;
9
9
  }
10
- .promotion-rule .delete {
10
+ .promotion_rule .delete, .promotion_action .delete {
11
11
  display: block;
12
12
  position: absolute;
13
13
  right: 10px;
14
14
  top: 10px;
15
15
  }
16
-
17
-
18
16
  .products_rule_products_source_field label {
19
17
  display: inline;
20
18
  }
21
-
22
-
23
-
24
19
  .edit_promotion #general_fields, .edit_promotion #expiry_fields {
25
- float: left;
26
- width: 46%;
20
+ float: left;
21
+ width: 46%;
27
22
  }
28
-
29
23
  .edit_promotion #general_fields {
30
- margin-right: 10px;
24
+ margin-right: 10px;
31
25
  }
32
-
33
- .edit_promotion #expiry_fields {
34
-
35
- }
36
-
26
+ .edit_promotion #expiry_fields {}
37
27
  .edit_promotion #starts_at_field, .edit_promotion #expires_at_field {
38
- width: 200px;
39
- float: left;
40
- clear: none;
28
+ width: 200px;
29
+ float: left;
30
+ clear: none;
41
31
  }
42
32
  .edit_promotion #starts_at_field label, .edit_promotion #expires_at_field label{
43
- display: block;
44
- }
45
- #rule_fields {
46
- position: relative;
33
+ display: block;
47
34
  }
48
- #new_product_rule_form {
49
- position: absolute;
50
- text-align:right;
51
- width: 300px;
52
- right: 30px;
53
- bottom: 20px;
35
+ #rule_fields, #action_fields {
36
+ position: relative;
54
37
  }
38
+ #new_product_rule_form, #new_promotion_action_form {
39
+ position: absolute;
40
+ text-align:right;
41
+ width: 300px;
42
+ right: 30px;
43
+ bottom: 20px;
44
+ }
@@ -0,0 +1,5 @@
1
+ /*
2
+ *= require admin/spree_core
3
+ *= require admin/spree_auth
4
+ *= require admin/promotions
5
+ */
@@ -0,0 +1,4 @@
1
+ /*
2
+ *= require store/spree_core
3
+ *= require store/spree_auth
4
+ */
@@ -0,0 +1,28 @@
1
+ class Admin::PromotionActionsController < Admin::BaseController
2
+ def create
3
+ @calculators = Promotion::Actions::CreateAdjustment.calculators
4
+ @promotion = Promotion.find(params[:promotion_id])
5
+ @promotion_action = params[:promotion_action][:type].constantize.new(params[:promotion_action])
6
+ @promotion_action.promotion = @promotion
7
+ if @promotion_action.save
8
+ flash[:notice] = I18n.t(:successfully_created, :resource => I18n.t(:promotion_action))
9
+ end
10
+ respond_to do |format|
11
+ format.html { redirect_to edit_admin_promotion_path(@promotion)}
12
+ format.js { render :layout => false }
13
+ end
14
+ end
15
+
16
+ def destroy
17
+ @promotion = Promotion.find(params[:promotion_id])
18
+ @promotion_action = @promotion.promotion_actions.find(params[:id])
19
+ if @promotion_action.destroy
20
+ flash[:notice] = I18n.t(:successfully_removed, :resource => I18n.t(:promotion_action))
21
+ end
22
+ respond_to do |format|
23
+ format.html { redirect_to edit_admin_promotion_path(@promotion)}
24
+ format.js { render :layout => false }
25
+ end
26
+ end
27
+ end
28
+
@@ -10,12 +10,12 @@ class Admin::PromotionsController < Admin::ResourceController
10
10
  end
11
11
  @promotion
12
12
  end
13
-
13
+
14
14
  def location_after_save
15
15
  edit_admin_promotion_url(@promotion)
16
16
  end
17
17
 
18
18
  def load_data
19
- @calculators = Promotion.calculators
19
+ @calculators = Promotion::Actions::CreateAdjustment.calculators
20
20
  end
21
21
  end
@@ -0,0 +1,8 @@
1
+ # Keep a record ot all static page paths visited for promotions that require them
2
+ ContentController.class_eval do
3
+ after_filter :store_visited_path
4
+ def store_visited_path
5
+ session[:visited_paths] ||= []
6
+ session[:visited_paths] = (session[:visited_paths] + [params[:path]]).compact.uniq
7
+ end
8
+ end
@@ -1,10 +1,19 @@
1
1
  OrdersController.class_eval do
2
- after_filter :clear_promotions
3
2
 
4
- private
5
- def clear_promotions
6
- current_order.promotion_credits.destroy_all if current_order
3
+ def update
4
+ @order = current_order
5
+ if @order.update_attributes(params[:order])
6
+
7
+ if @order.coupon_code.present?
8
+ fire_event('spree.checkout.coupon_code_added', :coupon_code => @order.coupon_code)
9
+ end
10
+
11
+ @order.line_items = @order.line_items.select {|li| li.quantity > 0 }
12
+ fire_event('spree.order.contents_changed')
13
+ respond_with(@order) { |format| format.html { redirect_to cart_path } }
14
+ else
15
+ respond_with(@order)
16
+ end
7
17
  end
8
18
 
9
19
  end
10
-
@@ -0,0 +1,3 @@
1
+ Adjustment.class_eval do
2
+ scope :promotion, lambda { where('label LIKE ?', "#{I18n.t(:promotion)}%") }
3
+ end
@@ -0,0 +1,24 @@
1
+ Order.class_eval do
2
+
3
+ attr_accessible :coupon_code
4
+ attr_accessor :coupon_code
5
+
6
+ def promotion_credit_exists?(promotion)
7
+ !! adjustments.promotion.reload.detect { |credit| credit.originator.promotion.id == promotion.id }
8
+ end
9
+
10
+ def products
11
+ line_items.map {|li| li.variant.product}
12
+ end
13
+
14
+ unless self.instance_methods.include?("update_adjustments_with_promotion_limiting")
15
+ def update_adjustments_with_promotion_limiting
16
+ update_adjustments_without_promotion_limiting
17
+ return if adjustments.promotion.eligible.none?
18
+ most_valuable_adjustment = adjustments.promotion.eligible.max{|a,b| a.amount.abs <=> b.amount.abs}
19
+ ( adjustments.promotion.eligible - [most_valuable_adjustment] ).each{|adjustment| adjustment.update_attribute_without_callbacks(:eligible, false)}
20
+ end
21
+ alias_method_chain :update_adjustments, :promotion_limiting
22
+ end
23
+
24
+ end
@@ -0,0 +1,10 @@
1
+ Product.class_eval do
2
+ has_and_belongs_to_many :promotion_rules
3
+
4
+ def possible_promotions
5
+ rules_with_matching_product_groups = product_groups.map(&:promotion_rules).flatten
6
+ all_rules = promotion_rules + rules_with_matching_product_groups
7
+ promotion_ids = all_rules.map(&:activator_id).uniq
8
+ Promotion.advertised.where(:id => promotion_ids)
9
+ end
10
+ end
@@ -0,0 +1,3 @@
1
+ ProductGroup.class_eval do
2
+ has_many :promotion_rules
3
+ end
@@ -1,13 +1,37 @@
1
- class Promotion < ActiveRecord::Base
2
- has_many :promotion_credits, :as => :source
3
- calculated_adjustments
4
- alias credits promotion_credits
1
+ class Promotion < Activator
5
2
 
6
- has_many :promotion_rules, :autosave => true
7
- accepts_nested_attributes_for :promotion_rules
3
+
4
+ MATCH_POLICIES = %w(all any)
5
+
6
+ preference :usage_limit, :integer
7
+ preference :match_policy, :string, :default => MATCH_POLICIES.first
8
+ preference :code, :string
9
+ preference :advertise, :boolean, :default => false
10
+
11
+ [:usage_limit, :match_policy, :code, :advertise].each do |field|
12
+ alias_method field, "preferred_#{field}"
13
+ alias_method "#{field}=", "preferred_#{field}="
14
+ end
15
+
16
+ has_many :promotion_rules, :foreign_key => 'activator_id', :autosave => true, :dependent => :destroy
8
17
  alias_method :rules, :promotion_rules
18
+ accepts_nested_attributes_for :promotion_rules
19
+
20
+ has_many :promotion_actions, :foreign_key => 'activator_id', :autosave => true, :dependent => :destroy
21
+ alias_method :actions, :promotion_actions
22
+ accepts_nested_attributes_for :promotion_actions
23
+
24
+ # TODO: This shouldn't be necessary with :autosave option but nested attribute updating of actions is broken without it
25
+ after_save :save_rules_and_actions
26
+ def save_rules_and_actions
27
+ (rules + actions).each &:save
28
+ end
29
+
9
30
 
10
31
  validates :name, :presence => true
32
+ validates :preferred_code, :presence => true, :if => lambda{|r| r.event_name == 'spree.ceckout.coupon_code_added' }
33
+
34
+ scope :advertised, includes(:stored_preferences).where(:preferences => {:name => 'advertise', :value => '1'})
11
35
 
12
36
  # TODO: Remove that after fix for https://rails.lighthouseapp.com/projects/8994/tickets/4329-has_many-through-association-does-not-link-models-on-association-save
13
37
  # is provided
@@ -17,52 +41,32 @@ class Promotion < ActiveRecord::Base
17
41
  end
18
42
  end
19
43
 
20
- MATCH_POLICIES = %w(all any)
21
-
22
- scope :automatic, where("code IS NULL OR code = ''")
23
- scope :manual, where("code IS NOT NULL AND code <> ''")
24
-
25
- def eligible?(order)
26
- !expired? && rules_are_eligible?(order)
27
- end
28
44
 
29
- def expired?
30
- starts_at && Time.now < starts_at ||
31
- expires_at && Time.now > expires_at ||
32
- usage_limit && credits_count >= usage_limit
45
+ def activate(payload)
46
+ if eligible?(payload[:order], payload)
47
+ actions.each do |action|
48
+ action.perform(payload)
49
+ end
50
+ end
33
51
  end
34
52
 
35
- def credits_count
36
- credits.with_order.count
53
+ def eligible?(order, options = {})
54
+ !expired? && rules_are_eligible?(order, options) && coupon_is_eligible?(order, options[:coupon_code])
37
55
  end
38
56
 
39
- def rules_are_eligible?(order)
57
+ def rules_are_eligible?(order, options = {})
40
58
  return true if rules.none?
41
59
  if match_policy == 'all'
42
- rules.all?{|r| r.eligible?(order)}
60
+ rules.all?{|r| r.eligible?(order, options)}
43
61
  else
44
- rules.any?{|r| r.eligible?(order)}
45
- end
46
- end
47
-
48
- def create_discount(order)
49
- return if order.promotion_credit_exists?(self)
50
- if eligible?(order) and amount = compute(order)
51
- order.promotion_credits.reload.clear unless combine? and order.promotion_credits.all? { |credit| credit.source.combine? }
52
- order.update!
53
- PromotionCredit.create!({
54
- :label => "#{I18n.t(:coupon)} (#{code})",
55
- :source => self,
56
- :amount => -amount.abs,
57
- :order => order
58
- })
62
+ rules.any?{|r| r.eligible?(order, options)}
59
63
  end
60
64
  end
61
65
 
62
- def compute(order)
63
- amount = calculator.compute(order)
64
- amount = order.item_total if amount > order.item_total
65
- -amount
66
+ def coupon_is_eligible?(order, code = nil)
67
+ return true if order && order.promotion_credit_exists?(self)
68
+ return true if event_name != 'spree.checkout.coupon_code_added'
69
+ code.to_s.strip.downcase == preferred_code.to_s.strip.downcase
66
70
  end
67
71
 
68
72
  # Products assigned to all product rules
@@ -70,4 +74,20 @@ class Promotion < ActiveRecord::Base
70
74
  @products ||= rules.of_type("Promotion::Rules::Product").map(&:products).flatten.uniq
71
75
  end
72
76
 
77
+ def expired?
78
+ super || usage_limit_exceeded?
79
+ end
80
+
81
+ def usage_limit_exceeded?
82
+ preferred_usage_limit.present? && preferred_usage_limit > 0 && credits_count >= preferred_usage_limit
83
+ end
84
+
85
+ def credits
86
+ Adjustment.where(:originator_id => actions.map(&:id))
87
+ end
88
+
89
+ def credits_count
90
+ credits.count
91
+ end
92
+
73
93
  end